"""Tests for lib/gp8_audio_sync.py and lib/gp_autosync.py — the GP8 embedded audio / score-to-audio auto-sync helpers. Fixture-free: every test drives a pure helper with hand-built inputs (in-memory ZIP containers, ElementTree fragments, synthetic warping paths, crafted tempo-event lists). The librosa-backed chroma/DTW stages need real audio and are covered by manual validation in the PR; here we pin the timing math, asset resolution, and sync-point extraction that are easy to drive without a fixture. """ import io import zipfile import xml.etree.ElementTree as ET import numpy as np import pytest import gp8_audio_sync as g8 from gp8_audio_sync import ( GpSyncData, SyncPoint, build_tempo_map_from_sync, _parse_gpif, _resolve_audio_asset, ) import gp_autosync as ga from gp_autosync import ( _gp345_tick_to_secs, _gp345_tempo_events, _get_tempo_map, _get_initial_tempo, _tempo_at_bar, _safe_normalise, _extract_sync_points, ) # ── helpers ─────────────────────────────────────────────────────────────────── def _gpif_bytes(asset_id: str = "abc-123") -> bytes: root = ET.Element("GPIF") bt = ET.SubElement(root, "BackingTrack") ET.SubElement(bt, "AssetId").text = asset_id return ET.tostring(root) def _make_gp_zip(asset_id="abc-123", ogg_stems=("abc-123",), asset_ext=".ogg") -> zipfile.ZipFile: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr("Content/score.gpif", _gpif_bytes(asset_id)) for stem in ogg_stems: zf.writestr(f"Content/Assets/{stem}{asset_ext}", b"fake-audio") buf.seek(0) return zipfile.ZipFile(buf) def _fake_gpif_root(n_bars: int, tempo: float = 120.0, time_sig="4/4") -> ET.Element: root = ET.Element("GPIF") mt = ET.SubElement(root, "MasterTrack") autos = ET.SubElement(mt, "Automations") auto = ET.SubElement(autos, "Automation") ET.SubElement(auto, "Type").text = "Tempo" ET.SubElement(auto, "Bar").text = "0" ET.SubElement(auto, "Value").text = str(tempo) mbs = ET.SubElement(root, "MasterBars") for _ in range(n_bars): mb = ET.SubElement(mbs, "MasterBar") ET.SubElement(mb, "Time").text = time_sig return root # ── GpSyncData.time_at_bar / tempo_at_bar ─────────────────────────────────────── def test_time_at_bar_interpolates_linearly_between_points(): sp = [ SyncPoint(bar=0, time_secs=0.0, modified_tempo=120, original_tempo=120), SyncPoint(bar=10, time_secs=20.0, modified_tempo=120, original_tempo=120), ] d = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=sp) assert d.time_at_bar(0) == pytest.approx(0.0) assert d.time_at_bar(10) == pytest.approx(20.0) assert d.time_at_bar(5) == pytest.approx(10.0) # exact halfway, any meter assert d.time_at_bar(2) == pytest.approx(4.0) def test_time_at_bar_interpolation_is_time_signature_agnostic(): # 3/4 material: linear interp between sync points must not assume 4 beats. sp = [ SyncPoint(bar=0, time_secs=0.0, modified_tempo=90, original_tempo=90), SyncPoint(bar=4, time_secs=8.0, modified_tempo=90, original_tempo=90), ] d = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=sp) assert d.time_at_bar(1) == pytest.approx(2.0) assert d.time_at_bar(3) == pytest.approx(6.0) def test_time_at_bar_extrapolates_past_last_point_by_tempo(): sp = [SyncPoint(bar=0, time_secs=0.0, modified_tempo=120, original_tempo=120)] d = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=sp) # 4 beats/bar at 120 BPM = 2.0 s/bar. assert d.time_at_bar(3) == pytest.approx(6.0) # 3/4 (beats_per_bar=3) at 120 BPM = 1.5 s/bar. assert d.time_at_bar(2, beats_per_bar=3.0) == pytest.approx(3.0) def test_time_at_bar_empty_returns_zero(): d = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=[]) assert d.time_at_bar(5) == 0.0 def test_tempo_at_bar_holds_last_segment(): sp = [ SyncPoint(bar=0, time_secs=0.0, modified_tempo=100, original_tempo=100), SyncPoint(bar=8, time_secs=10.0, modified_tempo=140, original_tempo=120), ] d = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=sp) assert d.tempo_at_bar(0) == 100 assert d.tempo_at_bar(7) == 100 assert d.tempo_at_bar(8) == 140 assert d.tempo_at_bar(20) == 140 def test_build_tempo_map_from_sync(): sp = [ SyncPoint(bar=0, time_secs=0.0, modified_tempo=120, original_tempo=120), SyncPoint(bar=4, time_secs=8.0, modified_tempo=130, original_tempo=120), ] d = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=sp) assert build_tempo_map_from_sync(d) == [(0, 120.0), (4, 130.0)] empty = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=[]) assert build_tempo_map_from_sync(empty) == [(0, 120.0)] # ── _parse_gpif / _resolve_audio_asset ──────────────────────────────────────────── def test_parse_gpif_reads_backing_track(): root = _parse_gpif(_gpif_bytes("xyz")) assert root.find("BackingTrack/AssetId").text == "xyz" def test_resolve_audio_asset_matches_declared_id(): with _make_gp_zip(asset_id="match", ogg_stems=("other", "match")) as zf: stem, path = _resolve_audio_asset(zf) assert stem == "match" assert path == "Content/Assets/match.ogg" def test_resolve_audio_asset_falls_back_to_first_when_unmatched(): with _make_gp_zip(asset_id="missing", ogg_stems=("only",)) as zf: stem, path = _resolve_audio_asset(zf) assert stem == "only" assert path == "Content/Assets/only.ogg" def test_resolve_audio_asset_matches_mp3(): # GP8 commonly embeds the backing track as MP3, not OGG — the resolver # must match it (regression guard for the OGG-only bug). with _make_gp_zip(asset_id="trk", ogg_stems=("trk",), asset_ext=".mp3") as zf: stem, path = _resolve_audio_asset(zf) assert stem == "trk" assert path == "Content/Assets/trk.mp3" def test_resolve_audio_asset_prefers_ogg_when_multiple_formats(): # If the same backing track is present as both OGG and MP3, prefer the # OGG (lossless copy, no transcode) regardless of ZIP order. buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr("Content/score.gpif", _gpif_bytes("trk")) zf.writestr("Content/Assets/trk.mp3", b"fake-audio") # first in order zf.writestr("Content/Assets/trk.ogg", b"fake-audio") buf.seek(0) with zipfile.ZipFile(buf) as zf: stem, path = _resolve_audio_asset(zf) assert path == "Content/Assets/trk.ogg" def test_resolve_audio_asset_none_when_no_audio(): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: zf.writestr("Content/score.gpif", _gpif_bytes()) buf.seek(0) with zipfile.ZipFile(buf) as zf: stem, path = _resolve_audio_asset(zf) assert stem == "" and path is None # ── gp_autosync timing helpers ────────────────────────────────────────────────── def test_gp345_tick_to_secs_constant_tempo(): te = [(0, 120.0)] # 120 BPM, 960 ticks/quarter -> 1 quarter = 0.5 s assert _gp345_tick_to_secs(te, 960) == pytest.approx(0.5) assert _gp345_tick_to_secs(te, 1920) == pytest.approx(1.0) assert _gp345_tick_to_secs(te, 0) == pytest.approx(0.0) def test_gp345_tick_to_secs_handles_mid_stream_tempo_change(): # 120 BPM for the first quarter (0.5 s), then 60 BPM (1.0 s/quarter). te = [(0, 120.0), (960, 60.0)] assert _gp345_tick_to_secs(te, 1920) == pytest.approx(1.5) def test_get_tempo_map_and_initial_tempo(): root = _fake_gpif_root(n_bars=2, tempo=144.0) assert _get_initial_tempo(root) == pytest.approx(144.0) assert _get_tempo_map(root) == [(0, 144.0)] def test_get_tempo_map_defaults_when_absent(): assert _get_tempo_map(ET.Element("GPIF")) == [(0, 120.0)] def test_tempo_at_bar_uses_last_event_at_or_before(): tmap = [(0, 100.0), (4, 150.0)] assert _tempo_at_bar(tmap, 0) == 100.0 assert _tempo_at_bar(tmap, 3) == 100.0 assert _tempo_at_bar(tmap, 4) == 150.0 assert _tempo_at_bar(tmap, 9) == 150.0 def test_safe_normalise_unit_columns_and_no_nan_on_zero(): m = np.array([[3.0, 0.0], [4.0, 0.0]], dtype=np.float32) out = _safe_normalise(m) assert out[:, 0] == pytest.approx([0.6, 0.8]) # 3-4-5 triangle -> unit norm assert np.all(np.isfinite(out)) # zero column -> no NaN # zero-energy column is filled with 1/12 (equidistant from all bins) assert out[:, 1] == pytest.approx([1.0 / 12.0, 1.0 / 12.0]) # ── _extract_sync_points (synthetic DTW path) ──────────────────────────────────── def _identity_setup(n_bars=4, tempo=120.0, sr=22050, hop=4096): root = _fake_gpif_root(n_bars=n_bars, tempo=tempo) n = 64 wp = np.array([[i, i] for i in range(n)]) # identity warp path times = np.arange(n) * hop / sr return root, wp, times, sr, hop def test_extract_sync_points_identity_path_maps_bars_to_their_score_time(): root, wp, times, sr, hop = _identity_setup(n_bars=4, tempo=120.0) pts = _extract_sync_points(wp, root, times, times, sr, hop, n_sync_points=4) bars = [p.bar for p in pts] assert bars == [0, 1, 2, 3] # 4/4 @ 120 BPM -> 2.0 s/bar; identity path -> audio time == score bar start. for p in pts: assert p.time_secs == pytest.approx(p.bar * 2.0, abs=hop / sr) # modified tempo is derived from the score/audio segment ratio and clamped; # frame quantisation keeps it near (not exactly) the authored 120 BPM. for p in pts: assert 20.0 <= p.modified_tempo <= 300.0 assert p.modified_tempo == pytest.approx(120.0, abs=15.0) def test_extract_sync_points_fills_trailing_modified_tempo(): # Regression: the final sync point must not keep the original-tempo # placeholder — it carries the last computed segment tempo forward. root, wp, times, sr, hop = _identity_setup(n_bars=4, tempo=120.0) pts = _extract_sync_points(wp, root, times, times, sr, hop, n_sync_points=4) assert pts[-1].modified_tempo == pts[-2].modified_tempo def test_extract_sync_points_uses_bar_starts_override(): root, wp, times, sr, hop = _identity_setup(n_bars=4, tempo=120.0) # Override every bar to sit at score-time 0 -> all map to audio frame 0. override = [0.0, 0.0, 0.0, 0.0] pts = _extract_sync_points( wp, root, times, times, sr, hop, n_sync_points=4, bar_starts_override=override, ) assert all(p.time_secs == pytest.approx(0.0, abs=hop / sr) for p in pts) def test_extract_sync_points_handles_zero_sync_points_without_crashing(): # n_sync_points is public; 0 must not raise ZeroDivisionError on the stride. root, wp, times, sr, hop = _identity_setup(n_bars=4, tempo=120.0) pts = _extract_sync_points(wp, root, times, times, sr, hop, n_sync_points=0) assert len(pts) >= 1 def test_extract_sync_points_empty_when_no_bars(): root = ET.Element("GPIF") # no MasterBars _, wp, times, sr, hop = _identity_setup() assert _extract_sync_points(wp, root, times, times, sr, hop, 4) == []