mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass (#787)
* feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass auto_sync computes per-bar sync points but consumers only ever applied the scalar bar-1 audio_offset, so any tempo drift between the recording and the tab's authored tempo accumulated over the song. Add the librosa-free helpers needed to apply the full piecewise mapping: - bar_start_times(gp_path): per-bar score times sharing auto_sync's axis (GPIF bar-resolution map, GP3/4/5 per-tick integration) - build_warp_anchors(points, bar_starts): monotonic (score, audio) anchors - warp_time(t, anchors): piecewise-linear map with edge-slope extrapolation - warp_song_times(song, warp): retime a lib.song.Song in place (notes, sustains, chords, beats, sections, anchors, handshapes, phrase levels, tone changes, tempo overrides) - gp_has_expandable_repeats(gp_path): detects GP3/4/5 repeat/volta/direction markup whose playback expansion auto_sync's as-written points cannot map Also implement refine_sync() — the editor's refine-sync endpoint has imported it since the snapshot but it never existed in lib, so the Refine button 500'd. It densifies the DTW points to every Nth bar and re-times each with a local onset phase sweep (radius clamped under half a beat to avoid one-beat locks, short scoring grid + median residual snap against the first beats). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117-123 BPM recordings of a 120 BPM tab. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review: Copilot round 1 — normalize bar_start_times GP3/4/5 parse failures to ValueError, document ImportError Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
74cff4e0d6
commit
92dc321fdf
@@ -0,0 +1,284 @@
|
||||
"""Tests for the librosa-free piecewise time-warp helpers in lib/gp_autosync.py
|
||||
(bar_start_times / build_warp_anchors / warp_time / warp_song_times /
|
||||
gp_has_expandable_repeats) plus refine_sync's pure fallbacks.
|
||||
|
||||
Fixture-free, matching tests/test_gp_audio_sync.py: every test drives a pure
|
||||
helper with hand-built inputs (in-memory GPIF zips, synthetic sync points,
|
||||
hand-rolled Song objects). The librosa-backed sweep inside refine_sync needs
|
||||
real audio and is covered by manual validation in the PR.
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import gp_autosync as ga
|
||||
from gp8_audio_sync import GpSyncData, SyncPoint
|
||||
from song import (
|
||||
Anchor,
|
||||
Arrangement,
|
||||
Beat,
|
||||
Chord,
|
||||
HandShape,
|
||||
Note,
|
||||
Phrase,
|
||||
PhraseLevel,
|
||||
Section,
|
||||
Song,
|
||||
)
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _gpif_zip(gpif_xml: str) -> bytes:
|
||||
"""Build an in-memory .gp container holding the given score.gpif."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("Content/score.gpif", gpif_xml)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _gpif(tempo_autos: list[tuple[int, float]], bar_sigs: list[str]) -> str:
|
||||
autos = "".join(
|
||||
f"<Automation><Type>Tempo</Type><Bar>{bar}</Bar>"
|
||||
f"<Value>{bpm} 2</Value></Automation>"
|
||||
for bar, bpm in tempo_autos
|
||||
)
|
||||
bars = "".join(f"<MasterBar><Time>{sig}</Time></MasterBar>" for sig in bar_sigs)
|
||||
return (
|
||||
"<GPIF><MasterTrack><Automations>"
|
||||
f"{autos}</Automations></MasterTrack>"
|
||||
f"<MasterBars>{bars}</MasterBars></GPIF>"
|
||||
)
|
||||
|
||||
|
||||
def _sp(bar, t, mod=120.0, orig=120.0):
|
||||
return SyncPoint(bar=bar, time_secs=t, modified_tempo=mod, original_tempo=orig)
|
||||
|
||||
|
||||
# ── bar_start_times ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_bar_start_times_constant_tempo(tmp_path):
|
||||
# 120 BPM, 4/4 → every bar is exactly 2s
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"] * 4)))
|
||||
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 6.0])
|
||||
|
||||
|
||||
def test_bar_start_times_tempo_change_and_meter(tmp_path):
|
||||
# Bar 0-1 at 120 (4/4 → 2s each), bar 2 switches to 60 in 3/4 (3s)
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(
|
||||
_gpif_zip(_gpif([(0, 120.0), (2, 60.0)], ["4/4", "4/4", "3/4", "3/4"]))
|
||||
)
|
||||
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 7.0])
|
||||
|
||||
|
||||
# ── build_warp_anchors ────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_warp_anchors_maps_bars_to_score_time():
|
||||
bar_starts = [0.0, 2.0, 4.0, 6.0]
|
||||
points = [_sp(0, 1.0), _sp(2, 5.4)]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
|
||||
|
||||
|
||||
def test_build_warp_anchors_drops_nonmonotonic_and_out_of_range():
|
||||
bar_starts = [0.0, 2.0, 4.0, 6.0]
|
||||
points = [
|
||||
_sp(0, 1.0),
|
||||
_sp(1, 0.5), # audio time goes backwards — dropped
|
||||
_sp(2, 5.4),
|
||||
_sp(99, 9.9), # bar out of range — dropped
|
||||
]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
|
||||
|
||||
|
||||
def test_build_warp_anchors_drops_implausible_slopes():
|
||||
# 2s score bars. A DTW fold (or a run of monotonicity-clamped refine
|
||||
# points) can produce a near-flat audio segment — slope far below the
|
||||
# 0.2x plausibility floor — which would crush every bar in the span.
|
||||
bar_starts = [float(2 * b) for b in range(11)]
|
||||
points = [
|
||||
_sp(0, 1.0),
|
||||
_sp(4, 9.0), # slope 1.0 — kept
|
||||
_sp(8, 9.1), # slope 0.0125 over 8s of score — dropped
|
||||
_sp(10, 21.0), # slope 1.0 vs the last KEPT anchor — kept
|
||||
]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [
|
||||
(0.0, 1.0), (8.0, 9.0), (20.0, 21.0)
|
||||
]
|
||||
|
||||
|
||||
def test_build_warp_anchors_requires_two_points():
|
||||
assert ga.build_warp_anchors([_sp(0, 1.0)], [0.0, 2.0]) == []
|
||||
assert ga.build_warp_anchors([], [0.0, 2.0]) == []
|
||||
|
||||
|
||||
# ── warp_time ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_warp_time_interpolates_between_anchors():
|
||||
anchors = [(0.0, 1.0), (10.0, 21.0)] # slope 2, offset 1
|
||||
assert ga.warp_time(0.0, anchors) == pytest.approx(1.0)
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(11.0)
|
||||
assert ga.warp_time(10.0, anchors) == pytest.approx(21.0)
|
||||
|
||||
|
||||
def test_warp_time_piecewise_segments():
|
||||
# First half plays at authored speed, second half at half speed
|
||||
anchors = [(0.0, 0.0), (10.0, 10.0), (20.0, 30.0)]
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(5.0)
|
||||
assert ga.warp_time(15.0, anchors) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_warp_time_extrapolates_with_edge_slopes():
|
||||
anchors = [(10.0, 20.0), (20.0, 40.0)] # slope 2
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(10.0) # before first
|
||||
assert ga.warp_time(25.0, anchors) == pytest.approx(50.0) # after last
|
||||
|
||||
|
||||
def test_warp_time_preserves_order():
|
||||
anchors = [(0.0, 0.5), (4.0, 4.1), (8.0, 9.3), (12.0, 12.9)]
|
||||
times = [i * 0.37 for i in range(40)]
|
||||
warped = [ga.warp_time(t, anchors) for t in times]
|
||||
assert warped == sorted(warped)
|
||||
|
||||
|
||||
# ── warp_song_times ───────────────────────────────────────────────────────────
|
||||
|
||||
def _shifted_double(t):
|
||||
return 2.0 * t + 1.0
|
||||
|
||||
|
||||
def test_warp_song_times_covers_all_time_fields():
|
||||
song = Song(
|
||||
song_length=100.0,
|
||||
beats=[Beat(time=0.0, measure=1), Beat(time=1.0, measure=-1)],
|
||||
sections=[Section(name="verse", number=1, start_time=10.0)],
|
||||
arrangements=[
|
||||
Arrangement(
|
||||
name="Lead",
|
||||
notes=[Note(time=2.0, string=0, fret=3, sustain=1.0)],
|
||||
chords=[
|
||||
Chord(
|
||||
time=4.0,
|
||||
chord_id=0,
|
||||
notes=[Note(time=4.0, string=1, fret=2, sustain=0.5)],
|
||||
)
|
||||
],
|
||||
anchors=[Anchor(time=6.0, fret=3)],
|
||||
hand_shapes=[HandShape(chord_id=0, start_time=4.0, end_time=5.0)],
|
||||
phrases=[
|
||||
Phrase(
|
||||
start_time=0.0,
|
||||
end_time=8.0,
|
||||
max_difficulty=0,
|
||||
levels=[
|
||||
PhraseLevel(
|
||||
difficulty=0,
|
||||
notes=[Note(time=3.0, string=0, fret=0, sustain=2.0)],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
tones={"base": "clean", "changes": [{"t": 7.0, "name": "lead"}]},
|
||||
tempos=[{"time": 0.0, "bpm": 120.0}],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ga.warp_song_times(song, _shifted_double)
|
||||
|
||||
assert song.song_length == pytest.approx(201.0)
|
||||
assert [b.time for b in song.beats] == pytest.approx([1.0, 3.0])
|
||||
assert song.sections[0].start_time == pytest.approx(21.0)
|
||||
|
||||
arr = song.arrangements[0]
|
||||
n = arr.notes[0]
|
||||
assert n.time == pytest.approx(5.0)
|
||||
assert n.sustain == pytest.approx(2.0) # (2+1)*2+1 - 5
|
||||
ch = arr.chords[0]
|
||||
assert ch.time == pytest.approx(9.0)
|
||||
assert ch.notes[0].time == pytest.approx(9.0)
|
||||
assert ch.notes[0].sustain == pytest.approx(1.0)
|
||||
assert arr.anchors[0].time == pytest.approx(13.0)
|
||||
hs = arr.hand_shapes[0]
|
||||
assert (hs.start_time, hs.end_time) == (pytest.approx(9.0), pytest.approx(11.0))
|
||||
ph = arr.phrases[0]
|
||||
assert (ph.start_time, ph.end_time) == (pytest.approx(1.0), pytest.approx(17.0))
|
||||
assert ph.levels[0].notes[0].time == pytest.approx(7.0)
|
||||
assert ph.levels[0].notes[0].sustain == pytest.approx(4.0)
|
||||
assert arr.tones["changes"][0]["t"] == pytest.approx(15.0)
|
||||
assert arr.tempos[0]["time"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_warp_song_times_clamps_negative_sustain():
|
||||
# A non-monotonic warp callable must not produce negative sustains
|
||||
song = Song(arrangements=[
|
||||
Arrangement(name="Lead",
|
||||
notes=[Note(time=1.0, string=0, fret=0, sustain=1.0)])
|
||||
])
|
||||
ga.warp_song_times(song, lambda t: 5.0 - t) # decreasing map
|
||||
assert song.arrangements[0].notes[0].sustain == 0.0
|
||||
|
||||
|
||||
# ── gp_has_expandable_repeats ─────────────────────────────────────────────────
|
||||
|
||||
def test_gpif_files_never_expand_repeats(tmp_path):
|
||||
# GPIF conversion is single-pass as-written, so .gp/.gpx are always False
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"])))
|
||||
assert ga.gp_has_expandable_repeats(str(gp)) is False
|
||||
|
||||
|
||||
def test_gp345_unparseable_returns_false(tmp_path):
|
||||
bad = tmp_path / "song.gp5"
|
||||
bad.write_bytes(b"not a real gp5 file")
|
||||
assert ga.gp_has_expandable_repeats(str(bad)) is False
|
||||
|
||||
|
||||
def test_gp345_repeats_detected(tmp_path):
|
||||
guitarpro = pytest.importorskip("guitarpro")
|
||||
song = guitarpro.models.Song()
|
||||
track = guitarpro.models.Track(song)
|
||||
song.tracks = [track]
|
||||
# Bar 2 of 3 opens a repeat
|
||||
for _ in range(2):
|
||||
header = guitarpro.models.MeasureHeader()
|
||||
song.addMeasureHeader(header)
|
||||
song.measureHeaders[1].isRepeatOpen = True
|
||||
for header in song.measureHeaders:
|
||||
track.measures.append(guitarpro.models.Measure(track, header))
|
||||
path = tmp_path / "repeat.gp5"
|
||||
guitarpro.write(song, str(path))
|
||||
assert ga.gp_has_expandable_repeats(str(path)) is True
|
||||
|
||||
|
||||
def test_gp345_plain_song_no_repeats(tmp_path):
|
||||
guitarpro = pytest.importorskip("guitarpro")
|
||||
song = guitarpro.models.Song()
|
||||
track = guitarpro.models.Track(song)
|
||||
song.tracks = [track]
|
||||
for _ in range(2):
|
||||
header = guitarpro.models.MeasureHeader()
|
||||
song.addMeasureHeader(header)
|
||||
for header in song.measureHeaders:
|
||||
track.measures.append(guitarpro.models.Measure(track, header))
|
||||
path = tmp_path / "plain.gp5"
|
||||
guitarpro.write(song, str(path))
|
||||
assert ga.gp_has_expandable_repeats(str(path)) is False
|
||||
|
||||
|
||||
# ── refine_sync pure fallbacks ────────────────────────────────────────────────
|
||||
|
||||
def test_refine_sync_empty_points_returns_input():
|
||||
sync = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=[])
|
||||
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
|
||||
|
||||
|
||||
def test_refine_sync_single_point_returns_input():
|
||||
# One point → fewer than 2 warp anchors → unchanged, no audio load
|
||||
sync = GpSyncData(audio_offset=-1.0, audio_asset_id="",
|
||||
sync_points=[_sp(0, 1.0)])
|
||||
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
|
||||
Reference in New Issue
Block a user