Created
October 4, 2016 14:56
-
-
Save andrewstucki/106c9704be9233e197350ceabec6a32c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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