"""Chord symbols in a MusicXML score: `` elements over the top staff. MusicXML keeps harmony out of the notes: a `` element sits in the measure next to the note it starts on, and the engraver draws it above the staff. Everything here is about placing it at the right musical instant — the recognizer works in seconds, the score works in divisions of a quarter note, and the two meet at a position in quarter notes from the start of bar 1 (which is what a chord's tick in the MIDI file already is). Only the first part gets the symbols. That is where a reader expects them, and repeating them over every staff would be noise. """ import xml.etree.ElementTree as ET from collections.abc import Sequence from pathlib import Path from muscriptor.utils.chords import NO_CHORD_LABEL, QUALITY_SUFFIXES, parse_label # MusicXML's own names for the qualities BTC distinguishes. The vocabulary was # chosen to be writable: every one of them has a standard ``. MUSICXML_KINDS = { "min": "minor", "maj": "major", "dim": "diminished", "aug": "augmented", "min6": "minor-sixth", "maj6": "major-sixth", "min7": "minor-seventh", "minmaj7": "major-minor", "maj7": "major-seventh", "7": "dominant", "dim7": "diminished-seventh", "hdim7": "half-diminished", "sus2": "suspended-second", "sus4": "suspended-fourth", } # Notes advance the musical clock; these two move it explicitly (a `` # is how a second voice starts over at the beginning of the measure). _DURATION_TAGS = ("note", "forward", "backup") def _duration(element: ET.Element) -> int: """The `` an element occupies, in divisions. 0 when it has none.""" text = element.findtext("duration") return int(text) if text else 0 def _anchors(measure: ET.Element) -> tuple[list[tuple[int, int]], int]: """Where a harmony can be attached in `measure`, and how long the measure is. Returns `[(child index, position in divisions)]` for every note that starts a new musical instant, plus the measure's total length. Scanning stops contributing anchors at the first ``: that is the second voice starting the measure over, and a chord symbol belongs with the first. """ anchors: list[tuple[int, int]] = [] position = 0 length = 0 first_voice = True for index, child in enumerate(measure): if child.tag == "note": # A `` note stacks onto the note before it, and a grace note # is squeezed in before one: neither begins an instant of its own. if ( first_voice and child.find("chord") is None and child.find("grace") is None ): anchors.append((index, position)) if child.find("chord") is None: position += _duration(child) elif child.tag == "forward": position += _duration(child) elif child.tag == "backup": position -= _duration(child) first_voice = False length = max(length, position) return anchors, length def _divisions(measure: ET.Element, current: int) -> int: """The divisions per quarter note in force after `measure`'s attributes.""" for attributes in measure.findall("attributes"): text = attributes.findtext("divisions") if text: current = int(text) return current def _harmony_element(label: str) -> ET.Element: """A `` for the chord `label`, ready to be placed in a measure.""" harmony = ET.Element("harmony", {"print-frame": "no"}) root = ET.SubElement(harmony, "root") step = ET.SubElement(root, "root-step") if label == NO_CHORD_LABEL: # "No chord" has no root to print, but MusicXML still wants the element: # an empty `text` attribute keeps the letter off the page. step.text = "C" step.set("text", "") ET.SubElement(harmony, "kind", {"text": NO_CHORD_LABEL}).text = "none" return harmony _, quality = parse_label(label) # also rejects anything malformed # The label already carries the spelling chosen for the whole song, so the # letter and the accidental are read straight back out of it. step.text = label[0] accidental = {"#": 1, "b": -1}.get(label[1:2]) if accidental is not None: ET.SubElement(root, "root-alter").text = str(accidental) ET.SubElement( harmony, "kind", {"text": QUALITY_SUFFIXES[quality]} ).text = MUSICXML_KINDS[quality] return harmony def add_chord_symbols(path: Path, symbols: Sequence[tuple[float, str]]) -> int: """Write chord symbols into the MusicXML score at `path`, in place. `symbols` is `(position in quarter notes from the start of the score, chord label)`, in order. Returns how many were placed; a symbol past the end of the score is dropped rather than extending it. A symbol that falls between two notes is attached to the note before it and given an ``, which is how MusicXML expresses a chord change that doesn't coincide with an attack — mid-bar changes over a held note, above all. """ original = path.read_text() tree = ET.parse(path) part = tree.getroot().find("part") if part is None: return 0 # Where each measure begins, in quarter notes, and what a division is worth # inside it — both accumulated by walking the part, so a pickup bar or a # change of time signature needs no special case. measures = [] start = 0.0 divisions = 1 for measure in part.findall("measure"): divisions = _divisions(measure, divisions) anchors, length = _anchors(measure) measures.append((measure, start, divisions, anchors)) start += length / divisions placed = 0 # Last symbol first, so that inserting one into a measure cannot shift the # child indices of an anchor this loop has yet to use. (Sorted rather than # merely reversed, so that holds however the caller ordered them.) for quarters, label in sorted(symbols, key=lambda symbol: symbol[0], reverse=True): if quarters >= start - 1e-6: continue # past the last bar line: there is nothing to write it over # The measure the chord falls in: the last one that begins before it. found = None for entry in measures: if entry[1] > quarters + 1e-6: break found = entry if found is None: continue measure, measure_start, measure_divisions, anchors = found if not anchors: continue # an empty measure has no note to write the chord over offset = round((quarters - measure_start) * measure_divisions) # The note this chord is written over: the last one that has already # started by the time the chord changes. index, position = anchors[0] for candidate_index, candidate_position in anchors: if candidate_position > offset: break index, position = candidate_index, candidate_position harmony = _harmony_element(label) if offset != position: ET.SubElement(harmony, "offset").text = str(offset - position) measure.insert(index, harmony) placed += 1 # ElementTree drops the XML declaration and the DOCTYPE that MusicXML # readers expect, so the original prologue is put back verbatim. body = ET.tostring(tree.getroot(), encoding="unicode") marker = original.find("