#!/usr/bin/env python # Transpose chord files in Adam's reasonably-regular format. import cgi, sys, os, re, getopt, StringIO # Testcases are transposed up two semitones; None means it shouldn't parse. testcases = [ # Things that shouldn't parse ("blah", None), ("Colorado", None), ("Jackdaws love my big sphinx of quartz", None), # Non-chords ("--", "--"), ("NC", "NC"), ("A -- C", "B -- D"), ("A NC C", "B NC D"), # Note names ("A", "B"), ("A#", "C "), ("G#", "Bb"), ("Bb", "C "), # Chord names ("Am", "Bm"), ("A7", "B7"), ("Adim", "Bdim"), ("Asus4", "Bsus4"), ("A-5", "B-5"), # Chord suffixes ("A/C", "B/D"), ("D D/C", "E E/D"), ("D /C", "E /D"), # Spacing ("B B B", "Db Db Db"), ("B B B", "Db Db Db"), ("Bb Bb Bb", "C C C "), #("B B Bb", "Db Db C "), -- it's not smart enough to do this yet ("\tA C", " B D"), ] safe_fn_re = re.compile(r'[a-z0-9][a-z0-9_.-]*', re.I) notes = ["Ab", "A", "Bb", "B", "C", "Db", "D", "Eb", "E", "F", "F#", "G"] def parse_note(s): if len(s) > 2: raise ValueError("bad note") try: n = notes.index(s) except ValueError: n = -1 if n != -1: return n elif len(s) > 1 and s[-1] == "b": return parse_note(s[0]) - 1 elif len(s) > 1 and s[-1] == "#": return parse_note(s[0]) + 1 else: raise ValueError("bad note") magic_chord_names = ["NC", "--", "-"] def parse_chord(s): if s == "Colorado": raise ValueError("Colorado is not a chord") if s in magic_chord_names: return (0, s, None) i = s.find("/") if i == -1: bass = None else: if s[i + 1:] == "NB": bass = "NB" else: bass = parse_note(s[i + 1:]) s = s[:i] if i == 0: return (None, s, bass) if len(s) > 2 and s[2] in "b#": root = parse_note(s[:3]) s = s[3:] elif len(s) > 1 and s[1] in "b#": root = parse_note(s[:2]) s = s[2:] elif len(s) > 0: root = parse_note(s[0]) s = s[1:] else: raise ValueError("nothing left to parse") return (root, s, bass) def format_note(n): if n is None: return "" return notes[n % 12] def format_chord(root, name, bass): if name in magic_chord_names: return name s = format_note(root) + name if bass is not None: if bass == "NB": s += "/NB" else: s += "/" + format_note(bass) return s def format_nash_note(n, key, roman): if n is None: return "" root = key[0] if key[1] == "m": root += 3 if roman: nash_notes = ["I", "bII", "II", "bIII", "III", "IV", "bV", "V", "bVI", "VI", "bVII", "VII"] else: nash_notes = ["1", "b2", "2", "b3", "3", "4", "b5", "5", "b6", "6", "b7", "7"] return nash_notes[(n - root) % 12] def format_nash(root, name, bass, key, roman): if name in magic_chord_names: return name s = format_nash_note(root, key, roman) + name if bass is not None: if bass == "NB": s += "/NB" else: s += "/" + format_nash_note(bass, key, roman) return s def split_pos(s): out = [] i = 0 last = -1 while i < len(s) + 1: if i == len(s) or s[i] == " ": if last != -1: out.append((last, s[last:i])) last = -1 else: if last == -1: last = i i += 1 return out def insert_space(s, pos, n): i = pos while i < len(s): if s[i] == " ": break i += 1 if i == len(s): return s else: return s[:i] + (" " * n) + s[i:] def transpose_file(f, key = 0, roman = False, nash = False): lines = [s[:-1].expandtabs() for s in f.readlines()] song_key = None chord_lines = [] for i in range(len(lines)): cs = lines[i].strip().split() if len(cs) == 2 and cs[0] == "Key:": try: song_key = parse_chord(cs[1]) (r, n, b) = song_key r += key if b is not None and b != "NB": b += key lines[i] = "Key: " + format_chord(r, n, b) except ValueError: pass is_chord = (cs != []) for c in cs: try: (r, n, b) = parse_chord(c) except ValueError: is_chord = 0 break chord_lines.append(is_chord) if song_key is None or key != 0: nash = 0 for i in range(len(lines)): if not chord_lines[i]: continue cs = split_pos(lines[i]) adjust = 0 for pos, chord in cs: pos += adjust (root, name, bass) = parse_chord(chord) if root is not None: root += key if bass is not None and bass != "NB": bass += key if nash: new_chord = format_nash(root, name, bass, song_key, roman) else: new_chord = format_chord(root, name, bass) while len(new_chord) < len(chord): new_chord += " " n = len(new_chord) - len(chord) if n > 0: needed = 0 for c in lines[i][pos + len(chord):pos + len(new_chord) + 1]: if c != " ": needed = 1 if not needed: n = 0 if n > 0: lines[i] = insert_space(lines[i], pos, n) if i + 1 < len(lines) and not chord_lines[i + 1]: lines[i + 1] = insert_space(lines[i + 1], pos, n) adjust += n lines[i] = lines[i][:pos] + new_chord + lines[i][pos + len(new_chord):] return song_key, zip(lines, chord_lines) def die(s): sys.stdout.write("

Error: " + cgi.escape(s) + "

") sys.exit(0) def cgi_main(): form = cgi.FieldStorage() key = 0 roman = False nash = False key_value = "0" if form.has_key("key"): key_value = form["key"].value if key_value == "nash": nash = True elif key_value == "roman": nash, roman = True, True else: key = int(key_value) path_info = os.getenv("PATH_INFO") o = sys.stdout.write o("Content-type: text/html\n\n") o(""" Transpose-O-Matic \n""") ps = path_info.split("/") if len(ps) != 3: die("Bad path") (dummy, artist, song) = ps if safe_fn_re.match(artist) is None or safe_fn_re.match(song) is None: die("Bad path") if not song.endswith(".txt"): die("Bad path") try: f = open("/hosts/offog.org/pages/chords/" + artist + "/" + song) except IOError: die("Bad path") song_key, transposed = transpose_file(f, key, roman, nash) f.close() o("""

Adam's Transpose-O-Matic: Show me this song

\n""") o("
\n")
	for line, is_chord in transposed:
		if is_chord:
			o("")
		o(cgi.escape(line))
		if is_chord:
			o("")
		o("\n")
	o("
\n") o(""" \n""") def test(): for orig, want in testcases: f = StringIO.StringIO(orig + "\n") song_key, transposed = transpose_file(f, 2) f.close() print repr(orig), "->", if len(transposed) != 1: print "not one line -- FAIL" continue got = transposed[0][0] is_chord = transposed[0][1] if want is not None: if not is_chord: print "non-parse -- FAIL" continue if got != want: print "want", repr(want), "got", repr(got), "-- FAIL" continue elif is_chord: print "want non-parse, got", repr(got), "-- FAIL" continue print repr(got), "-- ok" def usage(rc): print "transmat by Adam Sampson " print "Usage: transmat [OPTIONS ...] [FILES ...]" print print "-t SEMITONES Transpose by SEMITONES semitones" print "-n Display chords as Nashville numbers" print "-r Display chords as Roman Nashville numbers" print "-h, --help Display this help" print print "Invoke with no arguments to run as CGI script." sys.exit(rc) def cmd_main(args): key = 0 nash = False roman = False try: optlist, args = getopt.getopt(args, "t:nrh", ["help", "test"]) except getopt.GetoptError: usage(1) for o, a in optlist: if o == "-t": key = int(a) elif o == "-n": nash = True elif o == "-r": nash, roman = True, True elif o in ("-h", "--help"): usage(0) elif o == "--test": test() return if args == []: args = ["-"] def process(f): song_key, transposed = transpose_file(f, key, roman, nash) for line, is_chord in transposed: print line for arg in args: if arg == "-": process(sys.stdin) else: f = open(arg) process(f) f.close() if __name__ == "__main__": if len(sys.argv) == 1: cgi_main() else: cmd_main(sys.argv[1:])