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.

Selected Source Files

index.ts

import { LeadSheet } from "./lead_sheets/classes/LeadSheet";
import { globals } from "./lead_sheets/globals";
import type { ChordPattern } from "./lead_sheets/types";
import { listen } from "./lib/events";
import type { Maybe } from "./lib/types/meta";

declare const interlace: boolean;
declare const intro_score: string;
declare const chord_pattern: Maybe<ChordPattern>;
declare const manual_lyrics: Maybe<string>;

listen({
  event: "DOMContentLoaded",
  callback: () => {
    // Boot the application (in a setTimeout to make it asynchronous).
    setTimeout(() => {
      globals.lead_sheet = new LeadSheet(interlace, intro_score, chord_pattern, manual_lyrics);
    }, 1);
  },
});

LeadSheet.ts

import { MultipleEventCalmer } from "../../events/event_calming/MultipleEventCalmer";
import { current_font_size, get_css_rule, get_style, parse_css_value } from "../../lib/css";
import { crEl, qs, qsNullable, qsa } from "../../lib/dom";
import type { Maybe, Nullable } from "../../lib/types/meta";
import { update_query_string } from "../../lib/util";
import { find_transposition_factor, insert_manual_lyrics, replace_sharps_flats } from "../functions";
import { circle_of_fifths, globals } from "../globals";
import { InsertionMethod, type ChordPattern, type IntervalListItem, type KeyInfoScaleItem, type LineInfo } from "../types";
import { ChordsLine } from "./ChordsLine";
import { LineGroup } from "./LineGroup";
import { LyricsLine } from "./LyricsLine";
import { LyricsOnlyLine } from "./LyricsOnlyLine";
import { LyricsOnlyLineGroup } from "./LyricsOnlyLineGroup";
import { Score } from "./Score";

/**
 * Represents a lead sheet.
 */
export class LeadSheet {
    key_elem: Element;
    chords_used_elem: NodeListOf<HTMLTableCellElement>;
    interlace: boolean;
    intro_score: string;
    chord_pattern: Maybe<ChordPattern>;
    hide_chords_text = "Hide chords (lyrics only)";
    show_chords_text = "Show chords";
    manual_lyrics: Maybe<string>;

    /**
     * Constructs a new instance of the LeadSheet class.
     * @param interlace - A boolean indicating whether to interlace the lyrics.
     * @param intro_score - The intro score for the lead sheet.
     */
    constructor(interlace = false, intro_score = "", chord_pattern: Maybe<ChordPattern> = undefined, manual_lyrics: Maybe<string> = undefined) {
      // Fix the meta typography
      this.key_elem = qs("#key");
      this.chords_used_elem = qsa<HTMLTableCellElement>("#chords-used .name");
      this.interlace = interlace;
      this.intro_score = intro_score;
      this.chord_pattern = chord_pattern;
      this.manual_lyrics = manual_lyrics;
      this.#fix_meta();

      // Initialization
      globals.interlaced_lines = new LyricsOnlyLineGroup();
      const outer_container = qs("#song");
      const lines = outer_container.innerHTML.split("\n");
      const line_groups: LineGroup[] = [];
      const prev_line: LineInfo = {
        blank: true,
        group: new LineGroup(0),
      };
      let sequence = 0;

      // Loop through the lines and group them
      lines.forEach((line, line_index) => {
        const blank_line = globals.re.blank.test(line);
        const error = false;
        if (blank_line) {
          const no_next_line = line_index >= lines.length - 1;
          const next_line = no_next_line ? false : lines[line_index + 1];
          const non_blank_next_line =
            next_line !== false && !globals.re.blank.test(next_line);
          if (no_next_line || non_blank_next_line) {
            prev_line.blank = true;
            if (this.interlace) {
              globals.interlaced_lines.add(new LyricsOnlyLine(line));
            }
            return;
          } else {
            line = "";
          }
        }
        sequence++;
        if (this.is_chord_line(line)) {
          const group = new LineGroup(sequence);
          prev_line.group = group;
          line_groups.push(group);
          const chords = new ChordsLine(line);
          chords.error = error;
          group.add_line(chords);
          prev_line.blank = false;
        } else {
          // lyrics line
          if (prev_line.blank) {
            prev_line.group = new LineGroup(sequence);
            line_groups.push(prev_line.group);
          }
          prev_line.group.add_line(new LyricsLine(line));
          prev_line.blank = false;
          if (interlace) {
            globals.interlaced_lines.add(new LyricsOnlyLine(line));
          }
        }
      });
      outer_container.innerHTML = "";
      line_groups.forEach((group) =>
        outer_container?.appendChild(group.toHTML())
      );

      this.#remove_blank_cells(outer_container);
      this.init_events();
      this.resize_tables();

      if (this.intro_score) {
        this.init_score();
      }

      // Insert manual lyrics
      if (this.manual_lyrics) {
        insert_manual_lyrics(qs('#song'), this.manual_lyrics);
      }
      // Insert interlaced lyrics
      else if (this.interlace) {
        this.insert_interlaced_lyrics(outer_container);
      }

      // Show lyrics only if requested
      if (globals.query_string.has("lyrics")) {
        this.handle_toggle_chords();
      }

      // Add a song number if requested
      if (globals.query_string.has("number")) {
        this.insert_song_number(globals.query_string.get("number"));
      }

      // auto-transpose if requested
      if (globals.query_string.has("transpose")) {
        const key = globals.query_string.get("transpose");
        const index = circle_of_fifths.findIndex(value => value.name_major == key || value.name_minor == key);
        if (index >= 0) {
          this.change_to_key(index);
          // Adjust the key in the meta section
          qs("#key .key").innerHTML = globals.current_key!;
        }
      }
    }

    /**
     * Fixes the meta information of the lead sheet.
     * Replaces '#' with '♯' and 'b' with '♭' in the key element's inner HTML.
     * Updates the initial key value in the globals object.
     */
    #fix_meta(): void {
      if (this.key_elem !== null) {
        this.key_elem.innerHTML = replace_sharps_flats(this.key_elem.innerHTML);
        globals.initial_key = this.key_elem.innerHTML.split(" ")[0];
      }
      this.chords_used_elem.forEach((elem) => {
        elem.innerHTML = replace_sharps_flats(elem.innerHTML);
      });
    }

    /**
     * Update the query string to reflect the current state of the lead sheet.
     *
     * @param key - The query string key.
     * @param value - The value to set.
     */
    #update_url(key: string, value: any): void {
      globals.query_string = update_query_string(key, value);
    }

    /**
     * Removes blank cells at the start of some lines.
     *
     * @param outer_container - The outer container element.
     */
    #remove_blank_cells(outer_container: HTMLElement): void {
      qsa<HTMLTableSectionElement>("tbody", outer_container).forEach((tbody) => {
        const tr_chords = qsNullable<HTMLTableRowElement>("tr.chords", tbody);
        const tr_lyrics = qsa<HTMLTableRowElement>("tr.lyrics", tbody);
        const blank_lyrics: HTMLElement[] = [];
        let blank_chord: Maybe<Nullable<HTMLElement>>;
        try {
          if (tr_chords === null) {
            throw new TypeError("No chord line found");
          }
          blank_chord = qsNullable("td", tr_chords);
        } catch (e) {
          if (e instanceof TypeError) {
            // Sometimes there's no chord line.
            console.debug(
              "Removing blank cells: There's no chord line here. This isn't a problem."
            );
            console.debug(tbody);
            console.debug(e);
            return;
          } else {
            throw e;
          }
        }
        if (blank_chord && blank_chord.innerHTML.trim().length == 0) {
          let blank_start: boolean = true;
          tr_lyrics.forEach((tr) => {
            const td = qs<HTMLTableCellElement>("td", tr);
            const span = qsNullable<HTMLSpanElement>(".with-chords", td);
            if (span && span.innerHTML.trim().length > 0) {
              blank_start = false;
            } else if (!span && td && td.innerHTML.trim().length > 0) {
              blank_start = false;
            } else if (td) {
              blank_lyrics.push(td);
            }
          });
          if (blank_start) {
            blank_chord?.parentElement?.removeChild(blank_chord);
            blank_lyrics.forEach((td) => {
              td.parentElement?.removeChild(td);
            });
          }
        }
      });
    }

    /**
     * Changes the key of the lead sheet to the specified index.
     *
     * @param index - The index of the new key in the circle of fifths.
     * @throws Error if no current key is set.
     */
    change_to_key(index: number): void {
      if (globals.current_key === null) {
        throw new Error("No current key set");
      }
      const minor = globals.current_key.match(/m$/) ? true : false;
      const new_key = minor
        ? circle_of_fifths[index].name_minor
        : circle_of_fifths[index].name_major;
      const old_key = globals.current_key;
      globals.current_key = new_key;
      const old_index = circle_of_fifths.findIndex(value => value.name_major == old_key || value.name_minor == old_key);
      const notes = [
        ...Array.from(qsa("#song .chord .note-name")),
        ...Array.from(qsa("#song .chord .bass-note")),
      ];
      notes.forEach((note) => {
        const text = <KeyInfoScaleItem>note.innerHTML;
        const scale_index = circle_of_fifths[old_index].scale.indexOf(text);
        const new_note = circle_of_fifths[index].scale[scale_index];
        note.innerHTML = new_note;
      });

      if (globals.score) {
        // We have to use the initial key instead of the old key because ABCJS
        // throws an error if it tries to transpose multiple times.
        const old_key_score = (/(F♯|G♭)m?/.test(globals.initial_key || 'C')) ? "F♯/G♭" : <IntervalListItem>globals.initial_key?.replace(/m$/, "");
        let new_key_score = (/(F♯|G♭)m?/.test(new_key)) ? "F♯/G♭" : <IntervalListItem>new_key.replace(/m$/, "");
        if(new_key_score == "C♯") new_key_score = "D♭";
        if(new_key_score == "C♭") new_key_score = "B";
        globals.score.generate(find_transposition_factor(old_key_score, new_key_score));
      }
      this.#update_url("transpose", new_key !== globals.initial_key ? new_key : null);
    }

    /**
     * Handles the toggle of chords visibility.
     */
    handle_toggle_chords(): void {
      if (this.manual_lyrics) {
        qsa("#song table, #manual-lyrics").forEach((elem) => elem.classList.toggle("hide"));
      } else {
        this.toggle_chords();
        const toggle_elem = qs("#toggle-chords .click");
        if (globals.chords_hidden) {
          toggle_elem.innerHTML = this.show_chords_text;
        } else {
          toggle_elem.innerHTML = this.hide_chords_text;
        }
        try {
          qs("#toggle-tab").classList.toggle("hide");
        } catch (e) {
          console.debug("No tab element found");
        }
      }
    }

    /**
     * Initializes the chords toggle functionality.
     * Adds a toggle link to the specified box element and attaches a click
     * event listener to it.
     */
    init_chords_toggle(): void {
      crEl("P", {
        id: "toggle-chords",
        html: `<span class="click no-print">${this.hide_chords_text}</span>`,
        parent: qs("#meta .column-2"),
        listeners: { click: () => this.handle_toggle_chords() },
      });
    }

    /**
     * Initializes the events for the LeadSheet class.
     * This method initializes the key change and chords toggle events,
     * and sets up a resize event listener to dynamically resize the lyrics.
     */
    init_events(): void {
      this.init_key_change();
      this.init_chords_toggle();
      const article = qs("article");
      let columns = get_style(article, "column-count");
      const cols_handler = () => {
        const cols = get_style(article, "column-count");
        if(cols != columns) {
          columns = cols;
          evt_handler.trigger();
        }
      }
      const evt_handler = new MultipleEventCalmer(["resize", "orientationchange", "beforeprint", "fullscreenchange"], {
        target: window,
        minimum_interval: 25,
        handler: async () => {
          evt_handler.stop();
          if(columns_interval) clearInterval(columns_interval);
          this.reset_table_sizes();
          this.resize_tables();
          columns_interval = setInterval(cols_handler, 200);
          evt_handler.start();
        }
      });
      evt_handler.start();
      var columns_interval = setInterval(cols_handler, 200);
    }

    /**
     * Initializes the key change functionality. The method extracts the key
     * from the key element's innerHTML and updates the global `current_key`
     * variable. It also updates the key element's innerHTML with the extracted
     * key and adds a click event listener to trigger the key change controls.
     *
     * If the extracted key is invalid, it displays an error message indicating
     * that the key cannot be changed. If the key element is unset, it returns
     * early.
     */
    init_key_change(): void {
      if (this.key_elem === null) {
        return;
      }
      const key = this.key_elem.innerHTML.split(globals.re.white_space, 1)[0];
      let key_additional = this.key_elem.innerHTML;
      key_additional = key_additional.slice(key.length, key_additional.length);

      if (key.match(/^[A-G](?:#|b|bb|x|♯|♭|𝄫|𝄪)?m?$/)) {
        // (?:something) non-capturing group; doesn't get included in \1, \2, etc.
        globals.current_key = key;
        this.key_elem.innerHTML = `<span class="key">${key}</span>${key_additional} <span class="click no-print">(click to change)</span>`;
        qs("#key .click").addEventListener("click", () =>
          this.key_change_controls()
        );
      } else {
        this.key_elem.innerHTML = `<span class="key">${key}</span>${key_additional} <span class="no-print>(unable to change key: invalid key given)`;
      }
    }

    /**
     * Initializes the intro score for the lead sheet.
     * The method creates a new Score object and sets the global `score` variable.
     */
    init_score(): void {
      const line = qs(".line-group");
        let method = InsertionMethod.insert;
        const with_chords = qs(".with-chords", line);
        if (with_chords.innerHTML.startsWith("Intro")) {
          method = InsertionMethod.replace;
        }
        globals.score = new Score(this.intro_score, line, method, this.chord_pattern);
    }

    /**
     * Inserts the interlaced lyrics into the lead sheet.
     *
     * @param outer_container - The outer container element.
     */
    insert_interlaced_lyrics(outer_container: HTMLElement): void {
      const lyrics = document.createElement("div");
      lyrics.id = "lyrics";
      lyrics.classList.add("hide");
      lyrics.innerHTML = globals.interlaced_lines.to_html();
      outer_container.appendChild(lyrics);
    }

    /**
     * Inserts a song number into the lead sheet.
     *
     * @param num - The song number to insert.
     * @param match_regex - The regular expression to match the song number
     *        against. The number will only be inserted if the match is
     *        successful. The default value is /^[0-9.-]+$/.
     */
    insert_song_number(num: Nullable<string>, match_regex: RegExp = /^[0-9.-]+$/): void {
      if (num?.match(match_regex)) {
        // prevent query string code injection/XSS
        const title = qs("#title");
        title.innerHTML = `<span class="sequence-number">${num}</span> ${title.innerHTML}`;
      }
    }

    /**
     * Checks if a given line is a chord line.
     * A chord line is a line that contains chords or chord symbols.
     *
     * @param line - The line to check.
     * @returns `true` if the line is a chord line, `false` otherwise.
     */
    is_chord_line(line: string): boolean {
      if (line.length == 0) return false;
      if (line.match(/[.,;:?!'"]\s*$/)) return false;
      const num_chords_list = line
        .replace(/&nbsp;/g, " ")
        .replace(/(\S)$/, "$1 ")
        .match(globals.re.chord_symbol);
      const num_words_list = line.match(globals.re.word);
      const num_chords = num_chords_list === null ? 0 : num_chords_list.length;
      if (num_chords == 0) {
        return false;
      }
      const num_words = num_words_list === null ? 0 : num_words_list.length;
      if (num_chords >= num_words - num_chords) {
        return true;
      } else {
        if (num_chords > 0) {
          console.debug(
            `${num_chords} chord(s) found on this non-chord line: ${line}`
          );
        }
      }
      return false;
    }

    /**
     * Displays the key change controls for selecting a new key.
     * Updates the UI to reflect the selected key and applies the key change
     * when the "Apply" button is clicked.
     */
    key_change_controls(): void {
      const options: string[] = [];

      circle_of_fifths.forEach(key => {
        let option = `<option value="${key.name_major}"`;
        switch (globals.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);
      });
      this.key_elem.innerHTML = "";
      const form = crEl<HTMLFormElement>("form", { parent: this.key_elem });
      crEl("label", {
        attr: { for: "key-select" },
        text: "Choose a new key: ",
        parent: form,
      });
      crEl("select", {
        id: "key-select",
        html: options.join(""),
        parent: form,
      });
      const button = crEl<HTMLButtonElement>("button", {
        text: "Apply",
        parent: form,
      });
      const button_click_handler_reference = (evt: Event) => {
        evt.preventDefault();
        const key_select = qs("#key-select") as HTMLSelectElement;
        this.change_to_key(key_select.selectedIndex);
        button.removeEventListener("click", button_click_handler_reference);
        this.key_elem.innerHTML =
          '<span class="key">' +
          globals.current_key +
          '</span> <span class="click no-print">(click to change)</span>';
        setTimeout(() => {
          const click = qs("#key .click");
          click.addEventListener("click", () => this.key_change_controls());
        }, 200);
      };
      button.addEventListener("click", button_click_handler_reference);
    }

    /**
     * Resets the font sizes of the tables in the lead sheet to their default
     * values as specified in globals.base_font_size, globals.chords_font_size,
     * and globals.verse_font_size. If any of these values are unset, the method
     * throws an error.
     *
     * @throws {Error} If the base font size, chords font size, or verse font size
     *                is not set.
     */
    reset_table_sizes(): void {
      if (!globals.base_font_size) {
        throw new Error("Base font size not set");
      }
      if (!globals.chords_font_size) {
        throw new Error("Chords font size not set");
      }
      if (!globals.verse_font_size) {
        throw new Error("Verse font size not set");
      }
      const table_css = get_css_rule("#song .line-group");
      const chord_css = get_css_rule("#song .chord");
      const verse_css = get_css_rule("#song .verse-number, #lyrics .verse-number");
      if (table_css === null) {
        throw new Error("No CSS for #song .line-group found");
      }
      if (chord_css === null) {
        throw new Error("No CSS for #song .chord found");
      }
      if (verse_css === null) {
        throw new Error(
          "No CSS for #song .verse-number, #lyrics .verse-number found"
        );
      }
      table_css.style.fontSize = `${globals.base_font_size}px`;
      chord_css.style.fontSize = `${globals.chords_font_size}px`;
      verse_css.style.fontSize = `${globals.verse_font_size}px`;
    }

    /**
     * Resizes the tables in the lead sheet to fit within a maximum width.
     * The method adjusts the font size of the tables, chords, and verse numbers
     * to ensure that the widest table fits within the parent container.
     *
     * @throws {Error} If the CSS rules for the tables, chords, or verse numbers
     *                 are not found.
     * @throws {Error} If a parent element is not found.
     */
    resize_tables(): void {
      let max_width = window.innerWidth;
      if(get_style(qs("article"), "column-count") == "2") {
        max_width /= 2;
        max_width -= 20;
      }
      globals.max_width = max_width;

      const table_css = get_css_rule("#song .line-group");
      const chord_css = get_css_rule("#song .chord");
      const verse_css = get_css_rule("#song .verse-number, #lyrics .verse-number");
      if (table_css === null) {
        throw new Error("No CSS for #song .line-group found");
      }
      if (chord_css === null) {
        throw new Error("No CSS for #song .chord found");
      }
      if (verse_css === null) {
        throw new Error(
          "No CSS for #song .verse-number, #lyrics .verse-number found"
        );
      }
      if (globals.base_font_size) {
        table_css.style.fontSize = `${globals.base_font_size}px`;
        chord_css.style.fontSize = `${globals.chords_font_size}px`;
        verse_css.style.fontSize = `${globals.verse_font_size}px`;
      }
      const tables = qsa<HTMLTableElement>(".line-group");
      const parent = tables[0].parentElement;
      if (parent === null) {
        throw new Error("No parent element found");
      }
      const parent_width = Math.min(
        max_width,
        Math.floor(parse_css_value(get_style(parent, "width")))
      );
      let widest_table_width = 0;
      let widest_table_elem = tables[0]; // assigned only to silence warning about the element being used before being assigned
      tables.forEach((table) => {
        let table_width = Math.ceil(parse_css_value(get_style(table, "width")));
        if(table.classList.contains('indent')) {
          table_width += parse_css_value(get_style(table, "margin-left")) + parse_css_value(get_style(table, "margin-right"));
        }
        widest_table_width = Math.max(widest_table_width, table_width);
        if (table_width == widest_table_width) {
          widest_table_elem = table;
        }
      });
      if (!globals.base_font_size) {
        globals.base_font_size = parse_css_value(table_css.style.fontSize);
        globals.chords_font_size = parse_css_value(chord_css.style.fontSize);
        globals.verse_font_size = parse_css_value(verse_css.style.fontSize);
      }
      let counter = 0;

      while (
        counter < 100 &&
        current_font_size(table_css) > 9 &&
        Math.ceil(parse_css_value(get_style(widest_table_elem, "width"))) >
          parent_width
      ) {
        table_css.style.fontSize = `${current_font_size(table_css) - 1}px`;
        chord_css.style.fontSize = `${current_font_size(chord_css) - 1}px`;
        verse_css.style.fontSize = `${current_font_size(verse_css) - 1}px`;
        counter++;
      }
    }

    /**
     * Toggles the visibility of chords in the lead sheet. If a score is present
     * and interlace is disabled, it toggles the visibility of the score.
     */
    toggle_chords(): void {
      globals.chords_hidden = !globals.chords_hidden;
      if (this.interlace) {
        qsa("#song .line-group").forEach((c) => c.classList.toggle("hide"));
        const lyrics = qs("#lyrics");
        lyrics.classList.toggle("hide");
      } else {
        qsa("#song .chords").forEach((c) => c.classList.toggle("hide"));
        qsa("#song .lyrics td").forEach((c) => c.classList.toggle("lyrics-only"));
      }
      if (globals.score && !this.interlace) {
        globals.score.toggle_score_wrapper();
      }
      this.#update_url("lyrics", globals.chords_hidden ? true : null);
    }
  }

LyricsOnlyLine.ts

import { crEl } from "../../lib/dom";
import type { Maybe } from "../../lib/types/meta";
import { globals } from "../globals";
import { LyricsLine } from "./LyricsLine";

/**
 * Represents a line of lyrics without any chords or annotations, such as when
 * interlacing is enabled.
 */
export class LyricsOnlyLine extends LyricsLine {
  verse: Maybe<number>;
  indented = false;
  first_line = false;
  group_number: number = -1;

  /**
   * Constructs a new instance of the LyricsOnlyLine class.
   *
   * @param line - The text of the lyrics line.
   * @param verse - The verse number of the lyrics line.
   * @param replace_dashes - Whether to replace dashes in the lyrics line with hyphens.
   */
  constructor(line: string, verse?: number, replace_dashes = false) {
    super(line, replace_dashes);
    if (/^\s\s\s\s/.test(this.text)) this.indented = true;
    this.text = this.text
      .replace(/^\s+/, "")
      .replace(/\s+$/, "")
      .replace(/\s\s+/g, " ")
      .replace(/–(?!\s)/g, "--") // unfortunately, we can't preserve en dashes because Jekyll is overly aggressive
      .replace(/(?<=\s)–(?=\s)/g, "--") // replace an en dash with two hyphens if it's preceeded and followed by a whitespace character
      .replace(/(?<=\s)–(?=\s)/g, "—") // replace an en dash with an em dash if it's followed by a whitespace character but not preceeded by a word character
      .replace(/\s+(?=-)/g, "") // one or more spaces followed by a hyphen
      .replace(/(?<=-)\s+/g, "") // one or more spaces preceeded by a hyphen
      .replace(/--+/g, "-");

    // hunt for compound words
    const compound_regex = /(?:\w+-)+\w+/g; // matches words with hyphens
    Array.from(this.text.matchAll(compound_regex)).forEach((compound_obj) => {
      const compound = compound_obj[0];
      console.debug(`Found possible compound word: ${compound}`);
      if (!globals.hyphenated_compounds.includes(compound)) {
        this.text = this.text.replace(compound, compound.replace(/-/g, ""));
      }
    });

    this.verse = verse;
  }

  /**
   * Sets the verse number of the lyrics line and returns the current instance.
   * @param num - The verse number to set.
   * @returns The current instance of the LyricsOnlyLine class.
   */
  set_verse_return_this(num: number): this {
    this.verse = num;
    return this;
  }

  /**
   * Converts the lyrics line to an HTML string.
   * @returns The HTML representation of the lyrics line.
   */
  to_html(): string {
    const out = crEl("p", { html: this.text });
    if (this.verse) {
      out.setAttribute("verse", this.verse.toString());
    }
    if (this.indented) out.classList.add("indented");
    if (this.first_line) out.classList.add("first-of-verse");
    return out.outerHTML;
  }
}

Score.ts

import ABCJS from "abcjs";
import { crEl, qs, qsa } from "../../lib/dom";
import type { Maybe } from "../../lib/types/meta";
import { set_midi, suppress_print_tempo } from "../functions";
import { globals } from "../globals";
import { InsertionMethod, type ChordPattern } from "../types";
import { ScorePlayback } from "./ScorePlayback";

/**
 * Represents a musical score.
 */
export class Score {
  /**
   * The ABC notation for the score.
   */
  abc: string;
  /**
   * The container element for the score.
   */
  container: HTMLDivElement;
  /**
   * The object that handles the playback of the score.
   */
  score_playback: Maybe<ScorePlayback>;
  /**
   * CSS selector for the playback controller element, into which the playback
   * controls will be inserted.
   */
  score_playback_selector = ".score-outer-wrapper .playback-controller";
  /**
   * Indicates whether the score uses tablature.
   */
  tablature = false;
  /**
   * An array of ABCJS.Tablature objects representing the tab options for the score.
   */
  tab_options: ABCJS.Tablature[] = [{ instrument: "guitar" }];
  /**
   * The wrapper element for the score. Doesn't include the playback controller.
   */
  wrapper: HTMLDivElement;
  /**
   * The options for rendering the score. See the ABCJS documentation for details.
   */
  abc_options: ABCJS.AbcVisualParams = {
    responsive: "resize",
    add_classes: true,
    oneSvgPerLine: true,
    expandToWidest: true,
    staffwidth: this.#max_width(),
    wrap: {
      minSpacing: 1.5,
      maxSpacing: 2.7,
      preferredMeasuresPerLine: 8,
    },
    viewportHorizontal: true,
    // format: {
    //   gchordfont: '"Gentium Plus"',
    //   vocalfont: '"Gentium Plus"',
    // },
    clickListener: () => qsa(".abcjs-note_selected").forEach((note) => {
      note.classList.remove("abcjs-note_selected");
      note.setAttribute("fill", "currentColor");
    }),
  };

  /**
   * Constructs a new Score object.
   *
   * @param abc - The ABC notation for the score.
   * @param insert_into - The HTML element where the score will be inserted.
   * @param method - The method of insertion ("replace" or "insert").
   */
  constructor(abc: string, insert_into: HTMLElement, method: InsertionMethod, chord_pattern?: ChordPattern) {
    this.abc = suppress_print_tempo(abc);
    this.abc = set_midi(this.abc, chord_pattern);
    /**
     * The outer wrapper element for the score. Wraps the score and the playback
     * controller.
     */
    const outer_wrapper = crEl<HTMLDivElement>("div", { class: "score-outer-wrapper" });
    this.wrapper = crEl<HTMLDivElement>("div", { class: "score-wrapper", parent: outer_wrapper});
    crEl<HTMLDivElement>("div", {
      class: "score-label",
      html: "Intro",
      parent: this.wrapper,
    });
    this.container = crEl<HTMLDivElement>("div", {
      class: "score",
      parent: this.wrapper,
    });
    if (method == InsertionMethod.replace) {
      insert_into.parentElement?.replaceChild(outer_wrapper, insert_into);
    } else {
      insert_into.parentElement?.insertBefore(outer_wrapper, insert_into.nextSibling);
    }
    if (ABCJS.synth.supportsAudio()) {
      crEl('div', {class: 'playback-controller', parent: outer_wrapper});
      let tempo = parseInt((this.abc.match(/(?<=Q:.*= *)(\d+)$/m) ?? ['',''])[1]);
      this.score_playback = new ScorePlayback(this.score_playback_selector, tempo);
    }
    this.generate();

    // initialize tablature toggling
    /* disabling tab for now as it isn't functional
    let tab_toggle_button = crEl<HTMLSpanElement>("span", {
      class: "click no-print",
      text: "Show tablature",
      parent: crEl<HTMLParagraphElement>("p", {
        id: "toggle-tab",
        parent: qs("#meta .column-2"),
      }),
      listeners: {
        click: () => {
          this.tablature = !this.tablature;
          tab_toggle_button.innerHTML =
            tab_toggle_button.innerHTML == "Show tablature"
              ? "Hide tablature"
              : "Show tablature";
          this.generate();
        },
      },
    });
    */
  }

  /**
   * Generates the score based on the provided ABC notation.
   *
   * @param transposition - The amount of transposition in semitones to apply to
   *                        the score (optional).
   */
  generate(transposition?: number): void {
    if (!this.abc) {
      return;
    }
    if (this.tablature) {
      this.abc_options.tablature = this.tab_options;
    } else {
      delete this.abc_options.tablature;
    }
    if (globals.max_width && globals.max_width != this.abc_options.staffwidth) {
      this.abc_options.staffwidth = this.#max_width();
    }
    const rendered_score = ABCJS.renderAbc(this.container, this.abc, this.abc_options);
    if (this.score_playback) this.score_playback.rendered_score = rendered_score;
    if (transposition !== undefined) {
      if (this.score_playback) this.reset_playback();
      try {
        const result = ABCJS.renderAbc(
          this.container,
          ABCJS.strTranspose(this.abc, rendered_score, transposition),
          this.abc_options
        );
        if (this.score_playback) this.score_playback.rendered_score = result;
      } catch (e) {
        console.error(e);
        setTimeout(
          () => alert(
            "An error impacted the rendering of the intro score. Debugging details are on the console."
          ),
          10
        );
      }
    }
    this.format_chord_symbols();
    if (this.score_playback) {
      qsa(".abcjs-inline-audio").forEach((elem) => {
        elem.classList.add("no-print");
      });
    }
  }

  /**
   * Toggles the visibility of the score wrapper element.
   */
  toggle_score_wrapper(): void {
    this.wrapper.classList.toggle("hide");
    this.score_playback?.container.classList.toggle("hide");
  }

  /**
   * Resets the playback controller.
   */
  reset_playback(): void {
    if (!this.score_playback) return;
    this.score_playback.controller.pause();
    let warp = qs<HTMLInputElement>('input', this.score_playback.container).value;
    this.score_playback.delete_container();
    let div = crEl("div", { class: "playback-controller" });
    this.wrapper.parentElement?.insertBefore(div, this.wrapper.nextSibling);
    this.score_playback = new ScorePlayback(this.score_playback_selector);
    if (warp != "100") {
      qs<HTMLInputElement>("input", this.score_playback.container).value = warp;
      this.score_playback.warp = parseInt(warp);
    }
  }

  /**
   * Formats the chord symbols in the score, adding class "chord-sharp-flat" to
   * all sharps and flats in the chord symbols.
   */
  format_chord_symbols(): void {
    const sharp_flat = /(♯|♭)/;
    Array.from(qsa(".abcjs-chord tspan", this.container))
      .filter((tspan) => sharp_flat.test(tspan.textContent as string))
      .forEach((tspan) => {
        tspan.outerHTML = (tspan.textContent as string)
          .split(sharp_flat)
          .map((part) => {
            if (sharp_flat.test(part)) {
              return `<tspan class="chord-sharp-flat">${part}</tspan>`;
            } else if (part.length == 0) {
              return part;
            } else {
              return `<tspan>${part}</tspan>`;
            }
          })
          .join("");
      });
  }

  /**
   * Returns the maximum width for the score.
   * The maximum width is determined by the value of `globals.max_width` or 800,
   * whichever is smaller.
   * @returns The maximum width for the score.
   */
  #max_width(): number {
    return Math.min(globals.max_width || 800, 800);
  }
}

Example Lead Sheet (Joy_to_the_World.txt)

---
genre: christmas
layout: lead_sheet
title: "Joy to the World"
key: "A (C in <i>SDA Hymnal</i>)"
sda_hymnal: 125
permalink: /music/lead_sheets/songs/joy_to_the_world.html
no_ads: true
interlace: true
other_meta: "<span class=\"type\">Sheet music:</span><ul><li><a href=\"christmas/joy_to_the_world-Violin_1.pdf\">Violin 1</a></li><li><a href=\"christmas/joy_to_the_world-Violin_2.pdf\">Violin 2</a></li><li><a href=\"christmas/joy_to_the_world-Violins.pdf\">Both violins</a></li><li><a href=\"christmas/joy_to_the_world.pdf\">Full score</a></li><li><a href=\"christmas/joy_to_the_world.mscz\">MuseScore source</a></li></ul>"
score: |
    X:1
    M:2/4
    L:1/8
    Q:1/4=110
    K:A
    V:1 clef=treble octave=-1
    c     | "F#m"cc c(c/d/)       | "C#m"e3(d/c/) | "G"BB B(B/c/)       |
    w:And | heaven and na-ture_   | sing, And_    | heaven and na-ture_ |
      "E7"d3"^┍ Opt. intro"(c/B/) | "D"(Aa2)f    | "A"(e>d c)d    | "E"c2B2 | "A"A4"^┑"y ||
    w:sing, And_                  | heaven,_ and | heaven__ and   | na-ture | sing.
chord_pattern: 2
---
Intro:

   A                       E         F#m
1. Joy to the world, the   Lord is   come!
2. Joy to the earth, the   Savior    reigns!
3. No more let sin and     sorrow    grow,
4. He rules the world with truth and grace,

    D         E         F#m
Let earth   receive her King;
Let men their songs   employ;
Nor thorns  infest the  ground;
And makes the nations   prove

      D          A              D          A
Let   every      heart       prepare Him   room,
While fields and floods, rocks, hills, and plains,
He    comes to   make His       blessings  flow
The   glories    of His         righteous--ness,

    F#m               C#m
And heaven and nature sing,
Re--peat the sounding joy,
Far as the curse is   found,
And wonders of His    love,

    G                 E7
And heaven and nature sing,
Re--peat the sounding joy,
Far as the curse is   found,
And wonders of His    love,

    D            A          E        A
And heaven, and  heaven and nature   sing.
Re--peat,      repeat the   sounding joy.
Far as, far      as the     curse is found.
And wonders, and wonders    of His   love.