Today, I spent a few hours hacking away at the problem of automatically transposing song sheets into other keys. I’m pretty sure software already exists that does this, but I wanted to do this as an academic exercise, to see if I could come up with a markup language specification, and write a parser for it. I ended up accomplishing both, but also finally figured out how to write non-ASCII characters to a file on disk. Here’s my description of the implementation, and a link to the code at the end.
Sheet music can take many forms. Here, I focus on writing a specification for the chord progression plus lyrics form.
A modern song can be minimally specified by its title, key, and arrangement, where the arrangement comprises of modular song parts that can be joined together and rearranged (mostly). For example, with an intro chord progression (called “I”), a single stanza (called “S”), and a chorus (called “C”), one may choose to to have the order ISC, or ISCSCC, or some other combination that works well. Each part can be decomposed into lines where each line comprises a chord progression and lyrics. In this particular format, the time at which each chord is played is determined by the lyrics at that chord, therefore, the chords and lyrics are represented as plain text, with the spacing between chords adjusted to the lyrics to reflect their timing.
Here, the markup for the structure of the song is specified as such:
>> is used for the metadata, for example:
>>Title: Song Name
>>Arrangement: Intro, Stanza, Chorus, Stanza, Chorus
>>>Part Name is used to demarcate the name of each part, which is arranged in the Arrangement metadata, and is bookended by a closing
<<<. For example, we may have:
C: A E D A
Each chord can be of varying complexity. Some are simple, such as the major chords on the white keys of a piano. The black keys’ major chords are not much more complicated, only requiring a “#” or “b” symbol after the white key letter. As such, we can specify the chord key for any chord by the following list:
keys = ['A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab']
More complicated forms can be written. For example, we can have minor keys, major 7ths, minor 7ths, and diminished keys. Using the C chord key as an example, we can demarcate this as Cm, C7, Cm7, an Cdim respectively. For ease of parsing, one can specify this in abstract terms as a base key plus modifiers, separated by a semi-colon character (this can be changed if desired), resulting in
C;m, C;7, C;m7, and C;dim respectively. When transposing, usually the chord key changes while the modifiers remain the same, hence the rationale for splitting the two parts by a semicolon.
Finally, sometimes, a chord is played with the base key different from the chord key. An example is the C major chord played with an E base key, which is written as
C/E. We can read this as “C on E”. In this case, when transposing, both the chord key and the base key have to be changed. However, while the chord key can have modifiers, the base key doesn’t. One example of how we might write this is for a C minor 7th chord is
C;m7/E. Between two chords should be at least 2 blank spaces, meaning the lyrics may sometimes have to be adjusted appropriately.
The goal of this markup structure is to make it human- and machine-readable. The machine-readable portion requires some compromises to make the coding easier, for example, writing the modifiers after a semi-colon.
With the markup structure specified, I set out to write a parser for the raw text file that contains the marked up song. The parser iterates over the text file line by line, reading into memory the title, key, and arrangement. The parts are stored as a nested dictionary, with each part denoted by a unique key. The values are another dictionary, with the lines as the keys and the values as yet another dictionary of “chords” and “lyrics”. Each chord’s position and the chord is recorded as key-value pairs in yet another dictionary, and the lyrics are stored as one string.
Transposition becomes a simple matter of adding or subtracting. If I define the keys as above, there are 12 possible keys (7 white notes and 5 black notes). If my original song is in A major, but I want to transpose it to C major, I simply transpose every chord and base key upwards by +3 keys, maintaining the modifiers in place. If I am playing in G major and want to transpose to A major, on my list, that is going upwards by -11 keys, and so everything gets transposed by -11 units. Python supports negative indexing, but not positive indexing beyond the length of a list, so I had to use a modulo (remainder) to correctly compute any positive transpositions.
The biggest challenge I faced here was parsing Chinese lyrics, or Unicode characters. Python has a very hard time dealing with mixed ASCII and Unicode characters. As I learned from another blog post, basically I should be working with Unicode and not ASCII. That is easily accomplished by adding a
u before every string, for example,
print(u'This is a string') to denote that it is a unicode string.