Spaces:
Paused
Paused
File size: 7,720 Bytes
e319a7b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | """Chord symbols in a MusicXML score: `<harmony>` elements over the top staff.
MusicXML keeps harmony out of the notes: a `<harmony>` 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 `<kind>`.
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 `<backup>`
# 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 `<duration>` 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 `<backup>`: 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 `<chord/>` 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 `<harmony>` 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 `<offset>`, 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("<score-partwise")
prologue = original[:marker] if marker != -1 else ""
path.write_text(prologue + body + "\n")
return placed
|