mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
176 lines
6.4 KiB
Python
176 lines
6.4 KiB
Python
"""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)}
|