feedBack/tests/test_notation_to_notes.py
2026-06-16 18:47:13 +02:00

176 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)}