Skip to content

Instantly share code, notes, and snippets.

@andrewstucki
Created October 4, 2016 14:56
Show Gist options
  • Save andrewstucki/106c9704be9233e197350ceabec6a32c to your computer and use it in GitHub Desktop.
Save andrewstucki/106c9704be9233e197350ceabec6a32c to your computer and use it in GitHub Desktop.
#!/usr/bin/env ruby
class ChordParser
CHROMATICS = ['A', ['A#','Bb'], 'B', 'C', ['C#','Db'], 'D', ['D#','Eb'], 'E', 'F', ['F#','Gb'], 'G', ['G#','Ab']].freeze
MAJOR_STEPS = [0, 2, 2, 1, 2, 2, 2].freeze
MAJOR_SCALES = (0..11).map do |offset|
accumulator = 0
scale = []
MAJOR_STEPS.each_with_index do |increment, index|
accumulator += increment
chord = { base: CHROMATICS[(offset + accumulator) % 12] }
chord[:modifier] = :minor if [1, 2, 5].include? index
chord[:modifier] = :diminished if index == 6
scale << chord
end
{ key: CHROMATICS[offset], scale: scale }
end.freeze
CHORD_REGEX = /^(\s*(([A-G1-7][#b]?(m|M|dim)?(no|add|s|sus)?\d*)|:\]|\[:|:?\|:?|-|\/|\}|\(|\))\s*)+$/
CHORD_TOKENIZER = /\s*\(?([A-G1-7][#b]?\/[A-G1-7][#b]?)|(([A-G1-7][#b]?(m|M|dim)?)\d*)\)?\s*/
attr_reader :sheet, :key
def initialize(sheet, key = nil)
@sheet = sheet
@chords = Hash.new(0)
@key = key if key
@key_stats = []
@transposed_sheets = {}
parse_sheet!
guess_key! unless key || (key == false)
end
def statistics
total = @chords.values.reduce(0, :+)
@key_stats.map do |stat|
short_key = stat[:key].kind_of?(Array) ? stat[:key][1] : stat[:key]
"#{short_key}: #{stat[:matches]/total.to_f * 100}%"
end.join("\n")
end
def transpose_to(new_key)
return dump_sheet(@parsed_sheet) if new_key.nil?
integer = Integer(new_key) rescue false
half_steps = integer if integer
@transposed_sheets[new_key] ||= begin
sheet = []
current_index = CHROMATICS.index(CHROMATICS.detect {|note| note.kind_of?(Array) ? note.include?(@key) : (note == @key)}) unless half_steps
new_index = CHROMATICS.index(CHROMATICS.detect {|note| note.kind_of?(Array) ? note.include?(new_key) : (note == new_key)}) unless half_steps
half_steps ||= new_index-current_index
@parsed_sheet.each do |line|
if line[:type] == :lyrics
sheet << line
elsif line[:type] == :chords
new_chords = transpose_line(half_steps, line[:content])
sheet << {type: :chords, content: new_chords, parsed: self.class.chords(new_chords)}
end
end
sheet
end
dump_sheet(@transposed_sheets[new_key])
end
def highlight
dump_sheet(@parsed_sheet)
end
def guess_key!
@key ||= begin
chords = @chords.keys
counts = MAJOR_SCALES.map do |scale|
key_matches = 0
chords.each do |chord|
in_scale = self.class.scale_has_chord?(scale, chord)
key_matches += @chords[chord] if in_scale # accumulate the total number of chords in the song that match this key
end
{ key: scale[:key], matches: key_matches }
end
@key_stats = counts.sort_by {|count| count[:matches]}.reverse
key = @key_stats.first[:key]
key.kind_of?(Array) ? key[1] : key
end
end
class << self
def chords?(line)
line =~ CHORD_REGEX
end
def chords(line)
return nil unless chords?(line)
tokens = line.scan CHORD_TOKENIZER
tokens.map{|m| m[0] || m[2]}.flatten.compact
end
def format_chord(chord)
modifier = case
when chord.include?('dim')
chord.slice! 'dim'
:diminished
when chord.include?('m')
chord.slice! 'm'
:minor
when chord.include?('M') # drop the major
chord.slice! 'M'
nil
when chord.include?('/') # slash chord
chord = chord.split("/")
:inversion
else
nil
end
{ base: chord, modifier: modifier }
end
def scale_has_chord?(scale, chord)
scale = MAJOR_SCALES.detect {|s| s[:key] == scale } if scale.kind_of?(String)
return false unless scale
chord = format_chord(chord) if chord.kind_of?(String)
return false unless chord
if chord[:base].kind_of?(Array) # slash chord
chord[:base].all? do |note| # all notes are in the major
scale[:scale].any? do |n|
n[:base] == note || (n[:base].kind_of?(Array) && n[:base].include?(note))
end
end
else
scale[:scale].any? do |n| # chord is found in the scale with a proper major, minor, or diminished
(n[:base] == chord[:base] || (n[:base].kind_of?(Array) && n[:base].include?(chord[:base]))) && chord[:modifier] == n[:modifier]
end
end
end
end
private
def parse_sheet!
@parsed_sheet ||= begin
parsed_sheet = []
parsed_chords = []
key_change = false
@sheet.each_line do |line|
chords = self.class.chords(line)
key_change = true if line =~ /KEY (UP|DOWN)/
parsed_sheet << (chords ? { type: :chords, content: line, parsed: chords } : { type: :lyrics, content: line })
parsed_chords += chords if chords && !key_change
end
numbers = parsed_chords.any? {|chord| chord =~ /\d/ }
letters = parsed_chords.any? {|chord| chord =~ /[A-Z]/ }
parsed_chords = (numbers && letters) ? parsed_chords.select {|chord| chord =~ /[A-Z]/} : parsed_chords
formatted_chords = parsed_chords.map {|chord| self.class.format_chord(chord) }
formatted_chords.each { |chord| @chords.store(chord, @chords[chord]+1) } # Ruby lets us use objects as keys...
parsed_sheet
end
end
def transpose_line(half_steps, line)
tokens = line.split("") # do this so that we can keep track of what we replace
new_tokens = []
tokens.each_with_index do |token, index|
second_token = tokens[index + 1] unless index == tokens.length
if ['b', '#'].include?(second_token)
token += second_token
tokens[index + 1] = ''
end
new_tokens << transpose_token(half_steps, token)
end
new_tokens.join("")
end
def transpose_token(half_steps, token)
return token unless token =~ /[A-G]/
index = (CHROMATICS.index(CHROMATICS.detect {|note| note.kind_of?(Array) ? note.include?(token) : (note == token)}) + half_steps) % 12
new_token = CHROMATICS[index]
new_token.kind_of?(Array) ? new_token[1] : new_token
end
def dump_sheet(sheet)
colorize = "".respond_to? :colorize #colorize gem
title = true
sheet.map do |line|
if colorize
if title
title = false
line[:content].bold.light_cyan
else
line[:content].colorize(line[:type] == :chords ? :light_yellow : :white)
end
else
line[:content]
end
end.join("")
end
end
if __FILE__ == $0
require 'colorize'
require 'trollop'
opts = Trollop::options do
opt :verbose, "Verbose flag"
opt :no_color, "No colorization"
opt :location, "Song file or directory", type: :string # string --song <s>, default nil
opt :re_key, "Number of half-steps to transpose or key to transpose to", type: :string # integer --half-steps <i>, default to 0
end
Trollop::die :location, "must be specified" unless opts[:location]
Trollop::die :location, "must exist" unless File.exist?(opts[:location])
String.disable_colorization = true if opts[:no_color]
files = File.directory?(opts[:location]) ? Dir[File.join(opts[:location], '*.txt')] : [opts[:location]]
files.each do |file|
parser = ChordParser.new(File.read(file))
new_key = opts[:re_key] || parser.key
integer = Integer(new_key) rescue false
if integer
index = (ChordParser::CHROMATICS.index(ChordParser::CHROMATICS.detect {|note| note.kind_of?(Array) ? note.include?(parser.key) : (note == parser.key)}) + integer) % 12
new_key = ChordParser::CHROMATICS[index]
new_key = new_key.kind_of?(Array) ? new_key[1] : new_key
end
puts ("-"*40).yellow, file.yellow, "\n", "Chord sheet originally in the key of: #{parser.key}".yellow
puts "Keying to: #{new_key}".yellow unless opts[:re_key].nil?
puts "Key statistics:\n#{parser.statistics}".yellow if opts[:verbose]
puts ("-"*40).yellow
puts parser.transpose_to(new_key)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment