mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
501 lines
18 KiB
Python
501 lines
18 KiB
Python
"""Tests for scripts/lift_keys_notation.py — the legacy keys → notation lifter.
|
|
|
|
Builds directory-form sloppak fixtures with guitar-wire keys notes
|
|
(``midi = s*24 + f``, the style of tests/test_sloppak_notation_load.py's
|
|
fixture builder) and asserts the lifted ``notation_<id>.json`` contents:
|
|
hand split, durations, tempo derivation, idempotency, and dry-run no-op.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from scripts.lift_keys_notation import (
|
|
_load_song_beats,
|
|
build_notation,
|
|
decode_wire_notes,
|
|
downbeat_times,
|
|
lift_sloppak,
|
|
main,
|
|
measure_tempos,
|
|
notation_mod,
|
|
quantize_duration,
|
|
split_hands,
|
|
)
|
|
|
|
|
|
# ── Fixture builder ──────────────────────────────────────────────────────────
|
|
|
|
def _wire_note(t: float, midi: int, sus: float = 0.0) -> dict:
|
|
"""Guitar-wire keys note: midi packed as s*24 + f."""
|
|
return {"t": t, "s": midi // 24, "f": midi % 24, "sus": sus}
|
|
|
|
|
|
def _write_keys_sloppak(
|
|
root: Path,
|
|
*,
|
|
notes: list[dict],
|
|
beats: list[dict],
|
|
arr_id: str = "keys",
|
|
arr_name: str = "Keys",
|
|
chords: list[dict] | None = None,
|
|
extra_arrangements: list[dict] | None = None,
|
|
extra_manifest: dict | None = None,
|
|
notation_key: str | None = None,
|
|
) -> Path:
|
|
pak = root / "song.sloppak"
|
|
pak.mkdir()
|
|
arr_dir = pak / "arrangements"
|
|
arr_dir.mkdir()
|
|
|
|
entry: dict = {"id": arr_id, "name": arr_name,
|
|
"file": f"arrangements/{arr_id}.json"}
|
|
if notation_key is not None:
|
|
entry["notation"] = notation_key
|
|
arrangements = [entry] + (extra_arrangements or [])
|
|
|
|
arr_content = {
|
|
"name": arr_name,
|
|
"notes": notes,
|
|
"chords": chords or [],
|
|
"anchors": [], "handshapes": [], "templates": [],
|
|
"beats": beats,
|
|
"sections": [],
|
|
}
|
|
(arr_dir / f"{arr_id}.json").write_text(json.dumps(arr_content))
|
|
|
|
manifest: dict = {
|
|
"title": "Test", "artist": "Tester", "duration": 10.0,
|
|
"arrangements": arrangements,
|
|
"stems": [{"id": "full", "file": "stems/full.ogg"}],
|
|
}
|
|
if extra_manifest:
|
|
manifest.update(extra_manifest)
|
|
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
|
return pak
|
|
|
|
|
|
def _beats_4_4(n_measures: int, spb: float = 0.5) -> list[dict]:
|
|
"""4/4 beats at `spb` seconds per beat (0.5 = 120 BPM)."""
|
|
out = []
|
|
for m in range(n_measures):
|
|
for b in range(4):
|
|
out.append({"time": round((m * 4 + b) * spb, 3),
|
|
"measure": m + 1 if b == 0 else -1})
|
|
return out
|
|
|
|
|
|
# ── Pure helpers ─────────────────────────────────────────────────────────────
|
|
|
|
def test_decode_wire_notes_includes_chord_notes_and_l_alias():
|
|
arr = {
|
|
"notes": [{"t": 0.0, "s": 2, "f": 12, "sus": 0.4}], # midi 60
|
|
"chords": [{"t": 1.0, "id": 0, "notes": [
|
|
{"s": 1, "f": 12, "l": 0.25}, # midi 36, l alias
|
|
{"s": 2, "f": 16}, # midi 64
|
|
]}],
|
|
}
|
|
notes = decode_wire_notes(arr)
|
|
assert [(n["t"], n["midi"], n["sus"]) for n in notes] == [
|
|
(0.0, 60, 0.4), (1.0, 36, 0.25), (1.0, 64, 0.0),
|
|
]
|
|
|
|
|
|
def test_decode_wire_notes_skips_malformed():
|
|
arr = {"notes": [{"t": "x", "s": 1, "f": 2}, {"t": 0.0, "s": 9, "f": 23}]}
|
|
# 9*24+23 = 239 > 127 → dropped; malformed t → dropped.
|
|
assert decode_wire_notes(arr) == []
|
|
|
|
|
|
def test_downbeat_times_measure_ge_zero():
|
|
beats = [
|
|
{"time": 0.0, "measure": 1}, {"time": 0.5, "measure": -1},
|
|
{"time": 1.0, "measure": 0}, # measure >= 0 counts as a downbeat
|
|
{"time": 2.0, "measure": 2},
|
|
]
|
|
assert downbeat_times(beats) == [0.0, 1.0, 2.0]
|
|
|
|
|
|
def test_measure_tempos_from_downbeat_spacing():
|
|
# 4/4, downbeats 2 s apart → 120 BPM; last measure inherits.
|
|
assert measure_tempos([0.0, 2.0, 4.0], (4, 4)) == [120.0, 120.0, 120.0]
|
|
# 6/8 measure of 1.5 s → 3 quarter notes / 1.5 s → 120 BPM.
|
|
assert measure_tempos([0.0, 1.5], (6, 8)) == [120.0, 120.0]
|
|
|
|
|
|
@pytest.mark.parametrize("secs, bpm, expected", [
|
|
(0.5, 120.0, (4, 0)), # exact quarter
|
|
(0.75, 120.0, (4, 1)), # dotted quarter
|
|
(0.25, 120.0, (8, 0)), # eighth
|
|
(1.0, 120.0, (2, 0)), # half
|
|
(0.005, 120.0, (32, 0)), # below the 32nd floor
|
|
(10.0, 120.0, (1, 1)), # clamps to the largest candidate (dotted whole)
|
|
])
|
|
def test_quantize_duration(secs, bpm, expected):
|
|
assert quantize_duration(secs, bpm) == expected
|
|
|
|
|
|
def test_split_hands_wide_group_splits_at_largest_gap():
|
|
# C2+E2 cluster vs C5 melody — span 36 > 12, largest gap E2→C5.
|
|
notes = [
|
|
{"t": 0.0, "midi": 36, "sus": 0.0},
|
|
{"t": 0.0, "midi": 40, "sus": 0.0},
|
|
{"t": 0.0, "midi": 72, "sus": 0.0},
|
|
]
|
|
hands = split_hands(notes)
|
|
assert sorted(n["midi"] for n in hands["lh"]) == [36, 40]
|
|
assert [n["midi"] for n in hands["rh"]] == [72]
|
|
|
|
|
|
def test_split_hands_narrow_group_by_mean_pitch():
|
|
high = [{"t": 0.0, "midi": 64, "sus": 0.0}, {"t": 0.0, "midi": 67, "sus": 0.0}]
|
|
low = [{"t": 1.0, "midi": 45, "sus": 0.0}, {"t": 1.0, "midi": 50, "sus": 0.0}]
|
|
hands = split_hands(high + low)
|
|
assert sorted(n["midi"] for n in hands["rh"]) == [64, 67]
|
|
assert sorted(n["midi"] for n in hands["lh"]) == [45, 50]
|
|
|
|
|
|
def test_split_hands_simultaneity_window():
|
|
# 8 ms apart → same group; 20 ms apart → separate groups.
|
|
same = split_hands([
|
|
{"t": 0.0, "midi": 40, "sus": 0.0},
|
|
{"t": 0.008, "midi": 72, "sus": 0.0},
|
|
])
|
|
assert "lh" in same and "rh" in same # split as one wide group
|
|
apart = split_hands([
|
|
{"t": 0.0, "midi": 40, "sus": 0.0},
|
|
{"t": 0.020, "midi": 72, "sus": 0.0},
|
|
])
|
|
# Two separate groups, each assigned by mean pitch.
|
|
assert [n["midi"] for n in apart["lh"]] == [40]
|
|
assert [n["midi"] for n in apart["rh"]] == [72]
|
|
|
|
|
|
# ── build_notation ───────────────────────────────────────────────────────────
|
|
|
|
def test_build_notation_measures_tempo_and_durations():
|
|
# 120 BPM 4/4: sustained half note (1.0 s) then sus=0 quarter gap pair.
|
|
notes = decode_wire_notes({"notes": [
|
|
_wire_note(0.0, 72, sus=1.0),
|
|
_wire_note(2.0, 74),
|
|
_wire_note(2.5, 76),
|
|
]})
|
|
payload = build_notation(notes, _beats_4_4(2))
|
|
assert payload["version"] == 1
|
|
assert payload["instrument"] == "piano"
|
|
assert [s["id"] for s in payload["staves"]] == ["rh"] # single-staff output
|
|
|
|
m1, m2 = payload["measures"]
|
|
assert m1["idx"] == 1 and m1["t"] == 0.0 and m1["ts"] == [4, 4]
|
|
assert m1["tempo"] == 120.0
|
|
assert "tempo" not in m2 # unchanged within 1 BPM
|
|
|
|
rh1 = m1["staves"]["rh"]["voices"][0]["beats"]
|
|
assert rh1 == [{"t": 0.0, "dur": 2, "notes": [{"midi": 72}]}] # 1.0s → half
|
|
# Measure 2 (downbeat 2.0): gap 0.5s to next onset → quarter, then the
|
|
# last note (no sustain, no next onset) → quarter fallback at local tempo.
|
|
rh2 = m2["staves"]["rh"]["voices"][0]["beats"]
|
|
assert rh2 == [
|
|
{"t": 2.0, "dur": 4, "notes": [{"midi": 74}]},
|
|
{"t": 2.5, "dur": 4, "notes": [{"midi": 76}]},
|
|
]
|
|
|
|
|
|
def test_build_notation_tempo_emitted_on_real_change():
|
|
# Measure 1 at 120 BPM (2.0 s), measure 2 at 60 BPM (4.0 s), measure 3 inherits.
|
|
beats = [
|
|
{"time": 0.0, "measure": 1},
|
|
{"time": 2.0, "measure": 2},
|
|
{"time": 6.0, "measure": 3},
|
|
]
|
|
notes = [{"t": 0.0, "midi": 72, "sus": 0.5},
|
|
{"t": 2.0, "midi": 74, "sus": 0.5},
|
|
{"t": 6.0, "midi": 76, "sus": 0.5}]
|
|
payload = build_notation(notes, beats)
|
|
m1, m2, m3 = payload["measures"]
|
|
assert m1["tempo"] == 120.0
|
|
assert m2["tempo"] == 60.0
|
|
assert "tempo" not in m3 # inherited, no >1 BPM change
|
|
|
|
|
|
def test_build_notation_two_hands_and_chord_grouping():
|
|
# Wide simultaneous group → lh cluster + rh melody as separate staves,
|
|
# each one beat with (deduplicated, sorted) chord notes.
|
|
notes = [
|
|
{"t": 0.0, "midi": 36, "sus": 0.5},
|
|
{"t": 0.0, "midi": 40, "sus": 0.5},
|
|
{"t": 0.0, "midi": 72, "sus": 0.5},
|
|
{"t": 0.0, "midi": 72, "sus": 0.5}, # duplicate pitch collapses
|
|
]
|
|
payload = build_notation(notes, _beats_4_4(1))
|
|
assert [s["id"] for s in payload["staves"]] == ["rh", "lh"]
|
|
m = payload["measures"][0]
|
|
assert m["staves"]["lh"]["voices"][0]["beats"][0]["notes"] == [
|
|
{"midi": 36}, {"midi": 40}]
|
|
assert m["staves"]["rh"]["voices"][0]["beats"][0]["notes"] == [{"midi": 72}]
|
|
|
|
|
|
def test_build_notation_returns_none_without_notes_or_downbeats():
|
|
assert build_notation([], _beats_4_4(1)) is None
|
|
assert build_notation([{"t": 0.0, "midi": 60, "sus": 0.0}], []) is None
|
|
|
|
|
|
# ── lift_sloppak end-to-end ──────────────────────────────────────────────────
|
|
|
|
def test_lift_writes_notation_and_manifest_key(tmp_path: Path):
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 36, sus=0.5), _wire_note(0.0, 40, sus=0.5),
|
|
_wire_note(0.0, 72, sus=0.5), _wire_note(1.0, 74, sus=0.5)],
|
|
beats=_beats_4_4(2),
|
|
)
|
|
changes = lift_sloppak(pak)
|
|
assert len(changes) == 1
|
|
|
|
nt = pak / "notation_keys.json"
|
|
assert nt.exists()
|
|
payload = json.loads(nt.read_text())
|
|
ok, reason = notation_mod.validate_notation(payload)
|
|
assert ok, reason
|
|
assert [s["id"] for s in payload["staves"]] == ["rh", "lh"]
|
|
assert len(payload["measures"]) == 2
|
|
|
|
manifest = yaml.safe_load((pak / "manifest.yaml").read_text())
|
|
entry = manifest["arrangements"][0]
|
|
assert entry["notation"] == "notation_keys.json"
|
|
# Key order preserved by the sort_keys=False round-trip.
|
|
assert list(manifest.keys()) == ["title", "artist", "duration",
|
|
"arrangements", "stems"]
|
|
|
|
|
|
def test_lift_is_idempotent(tmp_path: Path):
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 72, sus=0.5)],
|
|
beats=_beats_4_4(1),
|
|
)
|
|
assert len(lift_sloppak(pak)) == 1
|
|
first_payload = (pak / "notation_keys.json").read_text()
|
|
first_manifest = (pak / "manifest.yaml").read_text()
|
|
|
|
# Second run: the manifest now carries the notation key → no-op.
|
|
assert lift_sloppak(pak) == []
|
|
assert (pak / "notation_keys.json").read_text() == first_payload
|
|
assert (pak / "manifest.yaml").read_text() == first_manifest
|
|
|
|
|
|
def test_lift_dry_run_writes_nothing(tmp_path: Path):
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 72, sus=0.5)],
|
|
beats=_beats_4_4(1),
|
|
)
|
|
before = (pak / "manifest.yaml").read_text()
|
|
changes = lift_sloppak(pak, dry_run=True)
|
|
assert len(changes) == 1
|
|
assert not (pak / "notation_keys.json").exists()
|
|
assert (pak / "manifest.yaml").read_text() == before
|
|
|
|
|
|
def test_lift_skips_non_keys_arrangements(tmp_path: Path):
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 72, sus=0.5)],
|
|
beats=_beats_4_4(1),
|
|
arr_id="lead", arr_name="Lead Guitar",
|
|
)
|
|
assert lift_sloppak(pak) == []
|
|
assert not any(pak.glob("notation_*.json"))
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["Keys", "piano", "My Keyboard Part", "Synth 2"])
|
|
def test_keys_name_variants_match(tmp_path: Path, name: str):
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 72, sus=0.5)],
|
|
beats=_beats_4_4(1),
|
|
arr_name=name,
|
|
)
|
|
assert len(lift_sloppak(pak)) == 1
|
|
|
|
|
|
def test_lift_refuses_to_overwrite_orphan_notation_file(tmp_path: Path):
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 72, sus=0.5)],
|
|
beats=_beats_4_4(1),
|
|
)
|
|
orphan = pak / "notation_keys.json"
|
|
orphan.write_text("{}")
|
|
assert lift_sloppak(pak) == []
|
|
assert orphan.read_text() == "{}"
|
|
|
|
|
|
def test_lift_uses_song_timeline_when_present(tmp_path: Path):
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 72, sus=0.5), _wire_note(2.0, 74, sus=0.5)],
|
|
beats=[], # arrangement carries no beats
|
|
extra_manifest={"song_timeline": "song_timeline.json"},
|
|
)
|
|
(pak / "song_timeline.json").write_text(json.dumps({
|
|
"version": 1, "beats": _beats_4_4(2), "sections": [],
|
|
}))
|
|
changes = lift_sloppak(pak)
|
|
assert len(changes) == 1
|
|
payload = json.loads((pak / "notation_keys.json").read_text())
|
|
assert len(payload["measures"]) == 2
|
|
|
|
|
|
def test_main_end_to_end_and_dry_run(tmp_path: Path, capsys):
|
|
dlc = tmp_path / "dlc"
|
|
dlc.mkdir()
|
|
_write_keys_sloppak(
|
|
dlc,
|
|
notes=[_wire_note(0.0, 72, sus=0.5)],
|
|
beats=_beats_4_4(1),
|
|
)
|
|
assert main([str(dlc), "--dry-run"]) == 0
|
|
assert not (dlc / "song.sloppak" / "notation_keys.json").exists()
|
|
assert main([str(dlc)]) == 0
|
|
assert (dlc / "song.sloppak" / "notation_keys.json").exists()
|
|
|
|
|
|
def test_main_rejects_missing_dir(tmp_path: Path):
|
|
assert main([str(tmp_path / "nope")]) == 2
|
|
|
|
|
|
def test_build_notation_pickup_measure_for_anacrusis():
|
|
"""Onsets before the first downbeat land in a pickup: true measure."""
|
|
# First downbeat at t=2.0; one pickup note at t=1.0.
|
|
beats = [{"time": 2.0, "measure": 1}, {"time": 4.0, "measure": 2}]
|
|
notes = [
|
|
{"t": 1.0, "midi": 60, "sus": 0.5},
|
|
{"t": 2.0, "midi": 64, "sus": 0.5},
|
|
]
|
|
payload = build_notation(notes, beats)
|
|
m0, m1 = payload["measures"][0], payload["measures"][1]
|
|
assert m0["pickup"] is True
|
|
assert m0["t"] == 1.0
|
|
assert m0["idx"] == 1 and m1["idx"] == 2
|
|
# The pickup note sits inside the pickup measure, not measure 2.
|
|
pickup_beats = m0["staves"]["rh"]["voices"][0]["beats"]
|
|
assert [b["t"] for b in pickup_beats] == [1.0]
|
|
m1_beats = m1["staves"]["rh"]["voices"][0]["beats"]
|
|
assert [b["t"] for b in m1_beats] == [2.0]
|
|
# No beat ever precedes its measure's own start.
|
|
for m in payload["measures"]:
|
|
for staff in m["staves"].values():
|
|
for beat in staff["voices"][0]["beats"]:
|
|
assert beat["t"] >= m["t"]
|
|
|
|
|
|
def test_build_notation_no_pickup_flag_when_first_onset_on_downbeat():
|
|
beats = [{"time": 0.0, "measure": 1}, {"time": 2.0, "measure": 2}]
|
|
payload = build_notation([{"t": 0.0, "midi": 60, "sus": 0.5}], beats)
|
|
assert "pickup" not in payload["measures"][0]
|
|
|
|
|
|
def test_build_notation_splits_note_across_barline_with_tie():
|
|
"""A sustain crossing the next downbeat becomes note + tied continuation."""
|
|
# 4/4 at 120 BPM (2s measures). Note starts on beat 4 (t=1.5) and rings
|
|
# 1.0s — half a beat into measure 2.
|
|
beats = [
|
|
{"time": 0.0, "measure": 1},
|
|
{"time": 2.0, "measure": 2},
|
|
{"time": 4.0, "measure": 3},
|
|
]
|
|
payload = build_notation([{"t": 1.5, "midi": 60, "sus": 1.0}], beats)
|
|
m1, m2 = payload["measures"][0], payload["measures"][1]
|
|
b1 = m1["staves"]["rh"]["voices"][0]["beats"]
|
|
b2 = m2["staves"]["rh"]["voices"][0]["beats"]
|
|
# Measure 1 holds the truncated head (0.5s = quarter at 120), untied.
|
|
assert [b["t"] for b in b1] == [1.5]
|
|
assert b1[0]["dur"] == 4
|
|
assert "tied" not in b1[0]["notes"][0]
|
|
# Measure 2 holds the tied continuation (0.5s quarter at its start).
|
|
assert [b["t"] for b in b2] == [2.0]
|
|
assert b2[0]["dur"] == 4
|
|
assert b2[0]["notes"][0]["tied"] is True
|
|
|
|
|
|
def test_build_notation_clean_durations_not_split_by_jitter():
|
|
"""A note ending exactly on the barline is NOT split."""
|
|
beats = [{"time": 0.0, "measure": 1}, {"time": 2.0, "measure": 2}]
|
|
payload = build_notation([{"t": 1.0, "midi": 60, "sus": 1.0}], beats)
|
|
m1 = payload["measures"][0]
|
|
b1 = m1["staves"]["rh"]["voices"][0]["beats"]
|
|
assert len(b1) == 1 and b1[0]["dur"] == 2 # half note at 120 BPM
|
|
m2_staves = payload["measures"][1]["staves"]
|
|
assert not m2_staves or "rh" not in m2_staves
|
|
|
|
|
|
def test_empty_song_timeline_beats_falls_back_to_arrangement(tmp_path: Path):
|
|
"""song_timeline with beats: [] is not authoritative — the arrangement
|
|
JSON's beats still drive the lift."""
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 72, sus=0.5)],
|
|
beats=_beats_4_4(1), # beats live in the arrangement JSON
|
|
extra_manifest={"song_timeline": "song_timeline.json"},
|
|
)
|
|
(pak / "song_timeline.json").write_text(json.dumps({
|
|
"version": 1, "beats": [], "sections": [],
|
|
}))
|
|
assert len(lift_sloppak(pak)) == 1
|
|
assert (pak / "notation_keys.json").is_file()
|
|
|
|
|
|
def test_manifest_write_failure_rolls_back_notation_files(tmp_path: Path, monkeypatch):
|
|
"""A failed manifest rewrite removes this run's notation sidecars so no
|
|
orphan files remain."""
|
|
pak = _write_keys_sloppak(
|
|
tmp_path,
|
|
notes=[_wire_note(0.0, 72, sus=0.5)],
|
|
beats=_beats_4_4(1),
|
|
)
|
|
real_replace = Path.replace
|
|
|
|
def boom(self, target):
|
|
if str(target).endswith("manifest.yaml"):
|
|
raise OSError("disk full")
|
|
return real_replace(self, target)
|
|
|
|
monkeypatch.setattr(Path, "replace", boom)
|
|
with pytest.raises(OSError):
|
|
lift_sloppak(pak)
|
|
assert not any(pak.glob("notation_*.json"))
|
|
# The manifest itself is untouched (no notation: key was persisted).
|
|
assert "notation" not in (pak / "manifest.yaml").read_text()
|
|
|
|
|
|
def test_load_song_beats_skips_malformed_first_arrangement(tmp_path: Path):
|
|
"""A non-list `beats` (dict/string) in the first arrangement must not
|
|
short-circuit the fallback — _load_song_beats consults later arrangements
|
|
and returns the first VALID non-empty list."""
|
|
pak = tmp_path / "song.sloppak"
|
|
(pak / "arrangements").mkdir(parents=True)
|
|
# First arrangement: malformed beats (a dict).
|
|
(pak / "arrangements" / "a.json").write_text(json.dumps({
|
|
"notes": [], "beats": {"bogus": 1}, "sections": [],
|
|
}))
|
|
# Second arrangement: valid beats.
|
|
good = _beats_4_4(1)
|
|
(pak / "arrangements" / "b.json").write_text(json.dumps({
|
|
"notes": [], "beats": good, "sections": [],
|
|
}))
|
|
manifest = {
|
|
"title": "T", "artist": "A", "duration": 4.0,
|
|
"arrangements": [
|
|
{"id": "a", "name": "Keys A", "file": "arrangements/a.json"},
|
|
{"id": "b", "name": "Keys B", "file": "arrangements/b.json"},
|
|
],
|
|
"stems": [],
|
|
}
|
|
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
|
assert _load_song_beats(pak, manifest) == good
|