Portfolio: Lead Sheets

By

View an example: Example Lead Sheet

About the App

Lead sheets help guitarists and other instrumentalists play music. They consist of song lyrics with chord symbols written above the words. For accurate formatting, I prefer to store my lead sheets as plain text files, viewed using a monospaced font. This project was borne out of a desire to automate the process of transposing songs from one key to another, as when using a capo.

This tool first parses a plain text lead sheet file and separates the chords from the lyrics. It then enables transposition, as well as uses a stylesheet that supports easy printing.

Source Files

lead_sheets.js

// TODO: Improve chord symbol end boundry check
const chord_symbol = /\b[A-G](?:#|b|bb|x|♯|♭|𝄫|𝄪)?(?:m|Maj|maj|°|aug|M|\+|-)?\d{0,2}(?:dim|aug|\+|°|sus|sus2|sus4|\(?(?:#|b|bb|x|♯|♭|𝄫|𝄪)?\d?\)?)?\/?[A-G]?(?:#|b|bb|x|♯|♭|𝄫|𝄪)?(?=\W)/gu;
const word = /\b\w+?\b/g;
const measure_mark = /\b(?:\|:|:\||\|)\b/g;
var current_key = undefined;

class KeyInfo {
    constructor(maj, min, sig, scale) {
        this.name_major = maj;
        this.name_minor = min;
        this.signature = sig;
        this.scale = scale;
    }
}

// F C G D A E B
const circle_of_fifths = [
    new KeyInfo("C♭", 'A♭m', '7 flats', ['C𝄫', 'C♭', 'C', 'D𝄫', 'D♭', 'D', 'E𝄫', 'E♭', 'E', 'F𝄫', 'F♭', 'F', 'G𝄫', 'G♭', 'G', 'A𝄫', 'A♭', 'A', 'B𝄫', 'B♭', 'B']),
    new KeyInfo('G♭', 'E♭m', '6 flats', ['G𝄫', 'G♭', 'G', 'A𝄫', 'A♭', 'A', 'B𝄫', 'B♭', 'B', 'C𝄫', 'C♭', 'C', 'D𝄫', 'D♭', 'D', 'E𝄫', 'E♭', 'E', 'F♭', 'F', 'F♯']),
    new KeyInfo('D♭', 'B♭m', '5 flats', ['D𝄫', 'D♭', 'D', 'E𝄫', 'E♭', 'E', 'F♭', 'F', 'F♯', 'G𝄫', 'G♭', 'G', 'A𝄫', 'A♭', 'A', 'B𝄫', 'B♭', 'B', 'C♭', 'C', 'C♯']),
    new KeyInfo('A♭', 'Fm', '4 flats', ['A𝄫', 'A♭', 'A', 'B𝄫', 'B♭', 'B', 'C♭', 'C', 'C♯', 'D𝄫', 'D♭', 'D', 'E𝄫', 'E♭', 'E', 'F♭', 'F', 'F♯', 'G♭', 'G', 'G♯']),
    new KeyInfo('E♭', 'Cm', '3 flats', ['E𝄫', 'E♭', 'E', 'F♭', 'F', 'F♯', 'G♭', 'G', 'G♯', 'A𝄫', 'A♭', 'A', 'B𝄫', 'B♭', 'B', 'C♭', 'C', 'C♯', 'D♭', 'D', 'D♯']),
    new KeyInfo('B♭', 'Gm', '2 flats', ['B𝄫', 'B♭', 'B', 'C♭', 'C', 'C♯', 'D♭', 'D', 'D♯', 'E𝄫', 'E♭', 'E', 'F♭', 'F', 'F♯', 'G♭', 'G', 'G♯', 'A♭', 'A', 'A♯']),
    new KeyInfo('F', 'Dm', '1 flat', ['F♭', 'F', 'F♯', 'G♭', 'G', 'G♯', 'A♭', 'A', 'A♯', 'B𝄫', 'B♭', 'B', 'C♭', 'C', 'C♯', 'D♭', 'D', 'D♯', 'E♭', 'E', 'E♯']),
    new KeyInfo('C', 'Am', 'No sharps or flats', ['C♭', 'C', 'C♯', 'D♭', 'D', 'D♯', 'E♭', 'E', 'E♯', 'F♭','F', 'F♯', 'G♭', 'G', 'G♯', 'A♭', 'A', 'A♯', 'B♭', 'B', 'B♯']),
    new KeyInfo('G', 'Em', '1 sharp', ['G♭', 'G', 'G♯', 'A♭', 'A', 'A♯', 'B♭', 'B', 'B♯', 'C♭','C', 'C♯', 'D♭', 'D', 'D♯', 'E♭', 'E', 'E♯', 'F', 'F♯', 'F𝄪']),
    new KeyInfo('D', 'Bm', '2 sharps', ['D♭', 'D', 'D♯', 'E♭', 'E', 'E♯', 'F', 'F♯', 'F𝄪', 'G♭','G', 'G♯', 'A♭', 'A', 'A♯', 'B♭', 'B', 'B♯', 'C', 'C♯', 'C𝄪']),
    new KeyInfo('A', 'F♯m', '3 sharps', ['A♭', 'A', 'A♯', 'B♭', 'B', 'B♯', 'C', 'C♯', 'C𝄪', 'D♭','D', 'D♯', 'E♭', 'E', 'E♯', 'F', 'F♯', 'F𝄪', 'G', 'G♯', 'G𝄪']),
    new KeyInfo('E', 'C♯m', '4 sharps', ['E♭', 'E', 'E♯', 'F', 'F♯', 'F𝄪', 'G', 'G♯', 'G𝄪', 'A♭','A', 'A♯', 'B♭', 'B', 'B♯', 'C', 'C♯', 'C𝄪', 'D', 'D♯', 'D𝄪']),
    new KeyInfo('B', 'G♯m', '5 sharps', ['B♭', 'B', 'B♯', 'C', 'C♯', 'C𝄪', 'D', 'D♯', 'D𝄪', 'E♭','E', 'E♯', 'F', 'F♯', 'F𝄪', 'G', 'G♯', 'G𝄪', 'A', 'A♯', 'A𝄪']),
    new KeyInfo('F♯', 'D♯m', '6 sharps', ['F', 'F♯', 'F𝄪', 'G', 'G♯', 'G𝄪', 'A', 'A♯', 'A𝄪', 'B♭','B', 'B♯', 'C', 'C♯', 'C𝄪', 'D', 'D♯', 'D𝄪', 'E', 'E♯', 'E𝄪']),
    new KeyInfo('C♯', 'A♯', '7 sharps', ['C', 'C♯', 'C𝄪', 'D', 'D♯', 'D𝄪', 'E', 'E♯', 'E𝄪', 'F','F♯', 'F𝄪', 'G', 'G♯', 'G𝄪', 'A', 'A♯', 'A𝄪', 'B', 'B♯', 'B𝄪'])
];

if(!String.prototype.insert) {
    // Taken from https://stackoverflow.com/a/9160869
    String.prototype.insert = function(index, string) {
        if (index > 0) {
          return this.substring(0, index) + string + this.substr(index);
        }

        return string + this;
      };
}

add_boot_to_jQuery();

function add_boot_to_jQuery() {
    if(window.jQuery) {
        console.debug('add_boot_to_jQuery(): jQuery is available.');
        $().ready(function() {
            boot_lead_sheet();
        });
    } else {
        console.debug('add_boot_to_jQuery(): jQuery not available yet.');
        setTimeout(add_boot_to_jQuery, 25)
    }
}

function boot_lead_sheet() {
    var key = $('#key')[0];
    key.innerHTML = key.innerHTML.replace(/#/g, '♯').replace(/b/g, '♭');
    var last_chord_line = -1;
    var blank = /^\W*$/
    var outer_container = $('#song')[0];
    var container = document.createElement('div');
    container.id = 'work-area';
    var lines = outer_container.innerHTML.split('\n');
    outer_container.innerHTML = '';
    outer_container.appendChild(container);

    for(let i=0; i<lines.length; i++) {
        let blank_line = blank.test(lines[i]);
        if(blank_line) {
            let no_next_line = i + 1 > lines.length;
            let non_blank_next_line = !blank.test(lines[i + 1]);
            if(no_next_line || non_blank_next_line) {
                continue;
            }
        }
        let line_cont = document.createElement('p');
        $(line_cont).attr('seq', i);
        if(!blank_line && is_chord_line(lines[i])) {
            $(line_cont).addClass('chords');
            last_chord_line = i;
        } else {
            $(line_cont).addClass('text');
            if(last_chord_line >= 0) {
                $(line_cont).attr('chords_at', last_chord_line);
                last_chord_line = -1;
            }
        }
        if(lines[i].length > 0) {
            line_cont.innerHTML = lines[i].replace(/ /g, '&nbsp;').replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;');
        } else {
            line_cont.innerHTML = '&nbsp;';
        }
        container.appendChild(line_cont);
    }
    group_lines();
    musicify_ascii();
    segment_lines();
    mark_up_chords();
    init_events();
}

function is_chord_line(line) {
    var num_chords = line.replace(/&nbsp;/g, ' ').replace(/(\S)$/, '$1 ').match(chord_symbol);
    var num_words = line.match(word);
    num_chords = (num_chords === null) ? 0 : num_chords.length;
    num_words = (num_words === null) ? 0 : num_words.length;
    if(num_chords >= num_words - num_chords) {
        return true;
    } else {
        if(num_chords > 0) {
            console.info(num_chords + ' chord(s) found on this non-chord line: ' + line);
            /*
            let symbols = line.match(chord_symbol);
            for(let i=0; i<symbols.length; i++) {
                if(symbols[i] != 'Am') {
                    return true;
                }
            }
            */
        }
    }
    return false;
}

function group_lines() {
    var lyrics_lines = $('#work-area .text');
    const lyrics_length = lyrics_lines.length;
    for(let i=0; i<lyrics_length; i++) {
        let text = lyrics_lines[i];
        let wrapper = document.createElement('div');
        $(wrapper).addClass('line-wrapper').attr('seq', i);
        if(text.hasAttribute('chords_at')) {
            let index = $(text).attr('chords_at');
            let chords = $('#work-area p[seq="' + index + '"]')[0];
            wrapper.appendChild(chords);
        }
        wrapper.appendChild(text);
        $('#work-area')[0].appendChild(wrapper);
    }
}

function musicify_ascii() {
    const inner_replace = (variable, search, replacement) => {
        variable.innerHTML = variable.innerHTML.replace(search, replacement);
    }
    var lyrics = $('#work-area .text');
    var chords = $('#work-area .chords');
    var repeat_start = '𝄆';
    var repeat_end = '𝄇';
    var sharp = '♯';
    var flat = '♭';
    for(let i=0; i<chords.length; i++) {
        let line = chords[i];
        inner_replace(line, /#/g, sharp);
        inner_replace(line, /(?<=([A-G]|\())b/g, flat);
        inner_replace(line, /\|:/g, repeat_start);
        inner_replace(line, /:\|/g, repeat_end);
    }
    for(let i=0; i<lyrics.length; i++) {
        let line = lyrics[i];
        inner_replace(line, /\|:/g, repeat_start);
        inner_replace(line, /:\|/g, repeat_end);
    }
}

function segment_lines() {
    var wrappers = $('#work-area .line-wrapper');
    var tables = [];
    const wrappers_length = wrappers.length;
    for(let i=0; i<wrappers_length; i++) {
        var lyrics = null;
        var chords = null;
        var wrapper = wrappers[i].children;
        if(wrapper.length > 2) {
            console.warn('Wrapper ' + i + ' has too many lines. Only the first two will be used.\n\nWrapper:\n' + wrapper.outerHTML);
        }
        for(let j=0; j<Math.min(2,wrapper.length); j++) {
            let this_wrapper = $(wrapper[j]);
            if(this_wrapper.hasClass('chords')) {
                chords = this_wrapper;
            } else if(this_wrapper.hasClass('text')) {
                lyrics = this_wrapper;
            }
        }
        if(lyrics && chords) {
            let table = document.createElement('table');
            $(table).addClass('line-group');
            let tbody = document.createElement('tbody');
            table.appendChild(tbody)
            let tr_chords = document.createElement('tr');
            $(tr_chords).addClass('chords');
            let tr_lyrics = document.createElement('tr');
            $(tr_lyrics).addClass('lyrics');
            tbody.appendChild(tr_chords);
            tbody.appendChild(tr_lyrics);

            let lyrics_text = lyrics[0].innerHTML;
            let chords_text = chords[0].innerHTML
            if(lyrics_text.match(/^&nbsp;/) && chords_text.match(/^&nbsp;/)) {
                $(table).addClass('indent');
            }
            lyrics_text = lyrics_text.replace(/&nbsp;/g, ' ');
            chords_text = chords_text.replace(/&nbsp;/g, ' ');
            let target_length = Math.max(lyrics_text.length, chords_text.length);
            lyrics_text = lyrics_text.padEnd(target_length, ' ');
            chords_text = chords_text.padEnd(target_length, ' ');
            let chords_list = [...chords_text.matchAll(chord_symbol)];
            let prev_chord_start = 0;
            for(let j=0; j<chords_list.length; j++) {
                let chord_start = chords_list[j].index;
                let chord_cell = document.createElement('td');
                let lyrics_cell = document.createElement('td');
                tr_chords.appendChild(chord_cell);
                tr_lyrics.appendChild(lyrics_cell);
                chord_cell.innerHTML = chords_text.substring(prev_chord_start, chord_start).replace(/ +$/g, '').replace(/ /g, '&nbsp;');
                lyrics_cell.innerHTML = lyrics_text.substring(prev_chord_start, chord_start).replace(/ /g, '&nbsp;');
                prev_chord_start = chord_start;
            }
            if(prev_chord_start < lyrics_text.length) {
                let chord_cell = document.createElement('td');
                let lyrics_cell = document.createElement('td');
                tr_chords.appendChild(chord_cell);
                tr_lyrics.appendChild(lyrics_cell);
                let lyrics_sub = lyrics_text.substring(prev_chord_start, lyrics_text.length).replace(/\s+$/, '&nbsp;');
                if(prev_chord_start == 0) {
                    lyrics_sub = lyrics_sub.replace(/\s/g, '&nbsp;');
                }
                let chords_sub = chords_text.substring(prev_chord_start, chords_text.length).replace(/\s+$/, '&nbsp;');
                if(prev_chord_start == 0) {
                    chords_sub = chords_sub.replace(/\s/g, '&nbsp;');
                }
                chord_cell.innerHTML = chords_text.substring(prev_chord_start, lyrics_text.length);
                lyrics_cell.innerHTML = lyrics_text.substring(prev_chord_start, chords_text.length);
            }
            tables.push(table);
        } else {
            let lyrics_text = (lyrics) ? lyrics[0].innerHTML : false;
            let chords_text = (chords) ? chords[0].innerHTML : false;
            let table = document.createElement('table');
            $(table).addClass('line-group');
            let str = '<tr class = "';
            str += (lyrics_text) ? 'lyrics' : 'chords';
            str += '"><td>';
            let str_temp = (lyrics_text) ? lyrics_text : chords_text;
            if(str_temp.match(/^&nbsp;/)) {
                $(table).addClass('indent');
            }
            str += str_temp;
            str += '</td></tr>';
            table.innerHTML = str;
            tables.push(table);
        }
    }
    var work_area = $('#work-area');
    work_area[0].innerHTML = '';
    for(let i=0; i<tables.length; i++) {
        work_area[0].appendChild(tables[i]);
    }
    var cells = $('.line-group td');
    for(let i=0; i<cells.length; i++) {
        let cell = cells[i].innerHTML;
        cell = cell.replace(/(𝄆|𝄇)/g, '<span class="no-italics">$1</span>');

        if(cells[i].parentNode.className == 'chords') {
            let chords_list = [...cell.replace(/(\S)$/, '$1 ').matchAll(chord_symbol)];
            for(let j=chords_list.length-1; j>=0; j--) {
                cell = cell.insert(chords_list[j].index + chords_list[j][0].length, '</span>').insert(chords_list[j].index, '<span class="chord">');
            }
        }
        cells[i].innerHTML = cell;
    }
}

function mark_up_chords() {
    var chords = $('#song .line-group .chord');
    for(let i=0; i<chords.length; i++) {
        let chord = chords[i].innerHTML;
        chord = chord.replace(/(?<=(?:[A-G])(?:#|b|bb|x|♯|♭|𝄫|𝄪)?(?:m|Maj|maj|°|aug|\+|-)?)(\d){1,2}/g, '<span class="seven">$1</span>');
        chord = chord.replace(/^\s*([A-G])(#|b|♯|♭)?/g, '<span class="note-name">$1$2</span>');
        chord = chord.replace(/(?<=\/)([A-G])(#|b|bb|x|♯|♭|𝄫|𝄪)?/g, '<span class="bass-note">$1$2</span>');
        chords[i].innerHTML = chord;
        chord = chords[i].innerHTML;
        let regex = /(?<!\<)\//;
        if(chord.match(regex) !== null) {
          let pieces = chord.split(regex);
          chords[i].classList.add('bass-chord');
          chords[i].innerHTML = `<span class="top">${pieces[0]}</span><span class="bottom">${pieces[1]}</span>`;
        }
    }
    chords = $('#song .line-group tr');
    for(let i=0; i<chords.length; i++) {
        let text = chords[i].innerHTML;
        text = text.replace(/(?<=\<\/span\>)\s+/g, '');
        chords[i].innerHTML = text;
    }
}

function init_events() {
    init_key_change();
}

function init_key_change() {
    var key_elem = $('#key')[0];
    var key = key_elem.innerHTML;

    if(key.match(/^[A-G](?:#|b|bb|x|♯|♭|𝄫|𝄪)?m?$/)) {
        current_key = key;
        key_elem.innerHTML = '<span class="key">' + key + '</span> <span class="click no-print">(click to change)</span>';
        $('#key').on('click', key_change_controls);
    } else {
        key_elem.innerHTML = '<span class="key">' + key + '</span> <span class="no-print>(unable to change key: invalid key given)';
    }
}

function key_change_controls() {
    var options = [];

    for(let i=0; i<circle_of_fifths.length; i++) {
        let key = circle_of_fifths[i];
        let option = '<option value="' + key.name_major + '"';
        switch(current_key) {
            case key.name_major:
            case key.name_minor:
                option += ' selected';
        }
        option += '>' + key.name_major + ' / ' + key.name_minor + ' (' + key.signature + ')</option>';
        options.push(option);
    }
    var form = document.createElement('form');
    var label = document.createElement('label');
    $(label).attr('for', 'key-select');
    label.innerHTML = 'Choose a new key: ';
    var select = document.createElement('select');
    select.id = 'key-select';
    select.innerHTML = options.join('');
    var button = document.createElement('button');
    button.innerHTML = 'Apply';
    $('#key').off('click');
    form.appendChild(label);
    form.appendChild(select);
    form.appendChild(button);
    $('#key')[0].innerHTML = '';
    $('#key')[0].appendChild(form);
    $(button).on('click', () => {
        change_to_key($('#key-select')[0].selectedIndex);
        $(button).off('click');
        $('#key')[0].innerHTML = '<span class="key">' + current_key + '</span> <span class="click no-print">(click to change)</span>';
        setTimeout( () => {
            $('#key').on('click', key_change_controls);
        }, 200);
    });
}

function change_to_key(index) {
    var minor = current_key.match(/m$/) ? true : false;
    var new_key = (minor) ? circle_of_fifths[index].name_minor : circle_of_fifths[index].name_major;
    var old_key = current_key;
    current_key = new_key;
    for(let i=0; i<circle_of_fifths.length; i++) {
        if(circle_of_fifths[i].name_major == old_key || circle_of_fifths[i].name_minor == old_key) {
            var old_index = i;
            break;
        }
    }
    var notes = [...$('#song .chord .note-name'), ...$('#song .chord .bass-note')];
    for(let i=0; i<notes.length; i++) {
        let note = notes[i].innerHTML;
        let scale_index = circle_of_fifths[old_index].scale.indexOf(note);
        let new_note = circle_of_fifths[index].scale[scale_index];
        notes[i].innerHTML = new_note;
    }
}

Example Lead Sheet (still.txt)

---
layout: lead_sheet
title: Still
key: G
capo: 3
credit: >
    Words & Music by Reuben Morgan.
    © 2002 Hillsong Music Publishing Australia
permalink: /music/lead_sheets/songs/still.html
---
Intro:

|: G     Em    | C    D     :|
|: /  /  /  /  | /  /  /  / :|



G    D  Em  G/D   C   A    Dsus D
Hide me now     under Your wings,

G D   Em  G/D     C       A      Dsus
Cover me      within Your mighty hand.


 D               C        D        G
 When the oceans rise and thunders roar,

                  C    B        Em
 I will soar with You above the storm.

 G/D             C     D       G
 Father, You are King over the flood,

           C              D       G
 I will be still and know You are God.


Bridge: Same as Intro

G    D       Em  G/D    C      A Dsus D
Find rest my soul    in Christ a-lone.

G    D   Em  G/D    C    A        Dsus
Know His power   in quietness and trust.


 D               C        D        G
 When the oceans rise and thunders roar,

                  C    B        Em
 I will soar with You above the storm.

 G/D             C     D       G
 Father, You are King over the flood,

 |:           C              D       Em   :|
 |: I will be still and know You are God. :|

    (rit.) C              D       G
 I will be still and know You are God.


(a tempo) Outro: Same as Intro