Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
"""Tests for lib/notation.notation_to_notes — the notation → flat-notes inverse.
Covers onset-gap sustain (tuplet/tempo-robust), tempo carry, tie folding, grace
and rest handling, plus a round-trip through notation_lift.build_notation.
"""
from __future__ import annotations
import pytest
import notation
import notation_lift
def _beat(t, midi, dur=4, dot=0, tied=False, rest=False, grace=None):
b = {"t": t, "dur": dur}
if dot:
b["dot"] = dot
if grace:
b["grace"] = grace
if rest:
b["rest"] = True
else:
b["notes"] = [{"midi": midi, **({"tied": True} if tied else {})}]
return b
def _nt(measures):
return {"version": 1, "instrument": "piano",
"staves": [{"id": "rh", "clef": "G2"}, {"id": "lh", "clef": "F4"}],
"measures": measures}
def test_returns_empty_for_non_dict_or_empty():
assert notation.notation_to_notes(None) == []
assert notation.notation_to_notes({}) == []
assert notation.notation_to_notes({"measures": []}) == []
def test_flat_notes_sorted_with_absolute_midi():
nt = _nt([{
"idx": 1, "t": 0.0, "tempo": 120.0,
"staves": {
"rh": {"voices": [{"v": 1, "beats": [_beat(0.5, 64), _beat(0.0, 67)]}]},
"lh": {"voices": [{"v": 1, "beats": [_beat(0.0, 48)]}]},
},
}])
notes = notation.notation_to_notes(nt)
assert [(n["t"], n["midi"]) for n in notes] == [(0.0, 48), (0.0, 67), (0.5, 64)]
# Sustain is the written quarter (0.5 s at 120 BPM).
assert notes[1]["sus"] == pytest.approx(0.5)
def test_short_note_before_gap_keeps_written_length():
# note@0.0 (32nd, sus 0.1) then next onset far away at 2.0 with no rest beat
# (lifter-style) must keep its written 32nd length, not stretch to the gap.
nt = _nt([{
"idx": 1, "t": 0.0, "tempo": 120.0,
"staves": {"rh": {"voices": [{"v": 1, "beats": [
_beat(0.0, 60, dur=32), _beat(2.0, 62),
]}]}},
}])
by = {n["midi"]: n for n in notation.notation_to_notes(nt)}
assert by[60]["sus"] == pytest.approx(0.0625) # 32nd at 120 BPM
def test_tie_folds_into_sustain():
nt = _nt([{
"idx": 1, "t": 0.0, "tempo": 120.0,
"staves": {"rh": {"voices": [{"v": 1, "beats": [
_beat(0.0, 60), _beat(0.5, 60, tied=True), _beat(1.0, 60, tied=True),
]}]}},
}])
notes = notation.notation_to_notes(nt)
assert len(notes) == 1
assert notes[0]["sus"] == pytest.approx(1.5)
def test_grace_and_rest_skipped():
nt = _nt([{
"idx": 1, "t": 0.0, "tempo": 120.0,
"staves": {"rh": {"voices": [{"v": 1, "beats": [
_beat(0.0, 62, grace="a"), _beat(0.0, 60), _beat(0.5, 0, rest=True),
]}]}},
}])
notes = notation.notation_to_notes(nt)
assert [n["midi"] for n in notes] == [60]
def test_tuplet_sustain_uses_tu_ratio():
# An eighth triplet carrying `tu: [3, 2]` sounds ⅔ of a straight eighth.
nt = _nt([{
"idx": 1, "t": 0.0, "tempo": 120.0,
"staves": {"rh": {"voices": [{"v": 1, "beats": [
{"t": 0.0, "dur": 8, "tu": [3, 2], "notes": [{"midi": 60}]},
]}]}},
}])
by = {n["midi"]: n["sus"] for n in notation.notation_to_notes(nt)}
assert by[60] == pytest.approx(0.25 * 2 / 3, abs=1e-3) # eighth 0.25 × 2/3
def test_terminal_tuplet_fallback_honors_ratio():
# A lone triplet eighth (dur=8, tu=[3,2]) at 120 BPM with no later onset
# must fall back to ~0.1667 s (⅔ of a straight eighth), not 0.25 s.
nt = _nt([{
"idx": 1, "t": 0.0, "tempo": 120.0,
"staves": {"rh": {"voices": [{"v": 1, "beats": [
{"t": 0.0, "dur": 8, "tu": [3, 2], "notes": [{"midi": 60}]},
]}]}},
}])
notes = notation.notation_to_notes(nt)
assert notes[0]["sus"] == pytest.approx(0.5 / 3, abs=1e-3)
def test_malformed_measure_staves_skipped():
# A truthy non-dict `staves` (the validator only checks the top-level shape)
# must be skipped, not crash the flattener.
nt = _nt([
{"idx": 1, "t": 0.0, "tempo": 120.0, "staves": "oops"},
{"idx": 2, "t": 2.0, "staves": {
"rh": {"voices": [{"v": 1, "beats": [_beat(2.0, 60)]}]}}},
])
notes = notation.notation_to_notes(nt)
assert [n["midi"] for n in notes] == [60]
def test_grace_beat_does_not_truncate_previous_sustain():
# note@0.0, grace@0.45, next note@0.5 — the 0.0 note must sustain to 0.5
# (the next sounded onset), not to the grace at 0.45.
nt = _nt([{
"idx": 1, "t": 0.0, "tempo": 120.0,
"staves": {"rh": {"voices": [{"v": 1, "beats": [
_beat(0.0, 60), _beat(0.45, 62, grace="a"), _beat(0.5, 64),
]}]}},
}])
by = {n["midi"]: n for n in notation.notation_to_notes(nt)}
assert 62 not in by # grace excluded from output
assert by[60]["sus"] == pytest.approx(0.5)
def test_malformed_voices_and_notes_skipped():
# Truthy-but-non-list `voices` / beat `notes` (only the top-level shape is
# validated) must be skipped, not crash the flattener.
nt = _nt([
{"idx": 1, "t": 0.0, "tempo": 120.0, "staves": {"rh": {"voices": 1}}},
{"idx": 2, "t": 2.0, "staves": {"rh": {"voices": [
{"v": 1, "beats": [{"t": 2.0, "dur": 4, "notes": 1}, _beat(2.5, 60)]}]}}},
])
notes = notation.notation_to_notes(nt)
assert [n["midi"] for n in notes] == [60]
def test_out_of_order_beats_still_correct():
# Beats listed out of chronological order must still yield correct onset-gap
# sustains (the voice timeline is sorted before deriving durations).
nt = _nt([{
"idx": 1, "t": 0.0, "tempo": 120.0,
"staves": {"rh": {"voices": [{"v": 1, "beats": [
_beat(0.5, 64), _beat(0.0, 60), # reversed
]}]}},
}])
notes = notation.notation_to_notes(nt)
by = {n["midi"]: n for n in notes}
assert by[60]["t"] == 0.0 and by[60]["sus"] == pytest.approx(0.5) # gap to 64
def test_round_trips_build_notation():
# wire notes → notation (lifter) → flat notes; pitches + onsets survive.
wire = [{"t": 0.0, "midi": 60, "sus": 0.5}, {"t": 0.0, "midi": 48, "sus": 0.5},
{"t": 0.5, "midi": 64, "sus": 0.5}, {"t": 1.0, "midi": 55, "sus": 0.5}]
beats = [{"time": round(i * 2.0, 3), "measure": i} for i in range(3)]
payload = notation_lift.build_notation(wire, beats, ts=(4, 4))
notes = notation.notation_to_notes(payload)
assert {(round(n["t"], 3), n["midi"]) for n in notes} == {
(0.0, 60), (0.0, 48), (0.5, 64), (1.0, 55)}