Commit 0759def5 by PLN (Algolia)

feat(foundry): length-preserving zero-crossing snap — exported loops are exact bar multiples

The B1 zc-snap moved each loop edge independently (±10ms tolerance), so
exported loop DURATIONS drifted off the bar grid: measured −4.2ms on a
4-bar (superfreak bass) and −11.4ms on 2-bar loops (hammer kit, both
edges snapped inward). loopAt absorbs the drift, but the kit contract we
want is stronger: every exported loop's duration == bars × bar_len.

Fix — _snap_length_preserving(): hold the exact bar-multiple sample
count RIGID (bar_len_samples from the take's grid BPM) and slide the
WHOLE window by a single common offset δ ∈ ±ZC_TOL_MS, picking the δ
that minimizes combined edge amplitude |y[start]|+|y[end]| (tie-break:
smallest |δ|). Both edges move together ⇒ duration is preserved by
construction; guarantee |exported_dur − bars·bar_len| < 1ms (measured
<0.02ms on real stems). Chops (bars=0) keep the per-edge snap.

_snapped_window/_true_seam (verify-rerank, 791a0d78) now mirror the same
scheme so the finder still measures the exact window export writes —
export-faithfulness is preserved through the change.

kit_multiples_check() makes the guarantee explicit per forge: after
export, each file's duration-in-bars at the shared grid BPM is compared
to the nearest integer multiple; >0.5% deviation warns with numbers.
export_take_report() bundles export + check; the server /api/export now
returns the multiples block.

Spot-check on real stems (old per-edge vs new length-preserving snap,
top-5 finder candidates re-graded on the exact export window):
  superfreak drums: dev +0.4…+3.6ms → ≤0.01ms; grades C→S, A→A, S→A, S→S
  superfreak bass:  dev −1.1…+3.7ms → ≤0.01ms; grades A→S, B→C, S→B, A→A
  hammer drums:     dev −1.8…+1.9ms → ≤0.01ms; all S stay S
  hammer bass:      dev −2.9…+6.9ms → ≤0.01ms; C→B, A→S, D→C, B→C
Net: duration guarantee achieved; grade moves are the rigid-length
window exposing true seams (some old windows only looked seamless
because per-edge snap trimmed the tail). No systematic regression;
existing superfreak/hammer kit exports untouched (validated, in use).

Tests: +4 (snap length invariance, bar_len_samples, end-to-end exported-
duration-is-exact-bar-multiple on a synthetic stem, multiples-check
flags off-grid). 81 passed.
parent c5963f63
......@@ -157,9 +157,15 @@ def _structural(ssm: np.ndarray, s: int, e: int) -> float:
def _snapped_window(y: np.ndarray, s_smp: int, e_smp: int, sr: int) -> np.ndarray:
"""The exact slice export_take would write: both boundaries zc-snapped (B1)."""
a = _snap_zc(y, s_smp, sr)
b = _snap_zc(y, e_smp, sr)
"""The exact slice export_take would write: length-preserving zc-snap (B1).
Mirrors export_take's snap so the verify-rerank measures the window that actually
ships. The length is held rigid at (e_smp − s_smp) and the whole window slides by one
common offset onto near-zero edges — identical scheme to `_snap_length_preserving`, so
duration is preserved and the seam read is export-faithful.
"""
length = e_smp - s_smp
a, b = _snap_length_preserving(y, s_smp, sr, length)
if b <= a: # degenerate snap → fall back to raw bounds
a, b = s_smp, e_smp
return y[a:b]
......@@ -405,11 +411,93 @@ def _snap_zc(y: np.ndarray, idx: int, sr: int) -> int:
return int(lo + zc[np.argmin(np.abs(zc - (idx - lo)))]) if zc.size else idx
def bar_len_samples(bars: int, bpm: float, sr: int) -> int:
"""Exact sample length of `bars` bars at `bpm` (BEATS_PER_BAR beats/bar)."""
if bars <= 0 or bpm <= 0:
return 0
return int(round(bars * BEATS_PER_BAR * (60.0 / bpm) * sr))
def _snap_length_preserving(y: np.ndarray, a: int, sr: int, length: int) -> tuple[int, int]:
"""Zero-crossing snap that PRESERVES the loop LENGTH exactly (task: bar-multiple guarantee).
The naïve export snapped each edge to its own nearest zero crossing independently, so
the two edges drifted apart by up to ±ZC_TOL_MS each and the exported DURATION deviated
from an exact bar multiple (measured −4.2 ms on a 4-bar, −11.4 ms on 2-bar loops — both
edges snapped inward). loopAt absorbs it, but the guarantee we want is
|exported_dur − bars·bar_len| < 1 ms.
Fix: keep the length RIGID at `length` (the exact bar-multiple sample count) and slide the
WHOLE window by a single common offset so both edges land near zero simultaneously. The
window [a+δ, a+δ+length) is evaluated for δ ∈ ±ZC_TOL_MS and we pick the δ minimizing the
combined edge amplitude |y[start]| + |y[end]|. Because both edges move by the same δ, the
length — hence the duration — is exactly preserved (start and end are that many samples
apart by construction). Returns (start, end) with end - start == length (clamped to y).
"""
n = len(y)
if length <= 0 or n < 2:
b = min(n, a + max(2, length))
return max(0, min(a, n - 2)), b
tol = int(sr * ZC_TOL_MS / 1000)
# candidate common offsets, clamped so both edges stay inside the signal
lo = max(-tol, -a)
hi = min(tol, n - length - a)
if hi < lo: # window barely fits — no room to slide
a = max(0, min(a, n - length))
return a, a + length
best_delta, best_cost = 0, float("inf")
for delta in range(lo, hi + 1):
s = a + delta
e = s + length
cost = abs(float(y[s])) + abs(float(y[min(e, n - 1)]))
# prefer the smaller |δ| on ties so we don't wander from the intended bounds
cost += 1e-9 * abs(delta)
if cost < best_cost:
best_cost, best_delta = cost, delta
s = a + best_delta
return s, s + length
def kit_multiples_check(files: list[Path], bpm: float, *, tol_pct: float = 0.5) -> dict:
"""Per-file duration-in-bars at the shared grid `bpm`; flag any off an integer multiple.
Makes the bar-multiple guarantee EXPLICIT per forge (task 1b): after export, each written
loop's duration is expressed in bars at the take's grid BPM and compared to the nearest
integer bar count. A file deviating by more than `tol_pct` (%) is flagged — with a
length-preserving snap this should never trip, so a flag means a real off-grid slice.
Returns {"bpm", "bar_len_s", "files": [{name, dur_s, bars, nearest, dev_pct, ok}], "ok"}.
"""
bar_len_s = (BEATS_PER_BAR * 60.0 / bpm) if bpm > 0 else 0.0
rows: list[dict] = []
all_ok = True
for p in files:
try:
info = sf.info(str(p))
dur = info.frames / info.samplerate
except Exception:
continue
bars = dur / bar_len_s if bar_len_s > 0 else 0.0
nearest = max(1, round(bars)) if bar_len_s > 0 else 0
dev_pct = abs(bars - nearest) / nearest * 100.0 if nearest else 0.0
ok = bar_len_s <= 0 or dev_pct <= tol_pct
all_ok = all_ok and ok
rows.append({"name": p.name, "dur_s": round(dur, 4), "bars": round(bars, 4),
"nearest": nearest, "dev_pct": round(dev_pct, 3), "ok": ok})
return {"bpm": round(bpm, 3), "bar_len_s": round(bar_len_s, 6), "files": rows, "ok": all_ok}
def export_take(take: Take, kit: str, workspace: Path, catch: Catch, *,
keep=("drums", "bass", "other", "vocals"),
names: Optional[dict[str, str]] = None, peak_norm=False,
samples_root: Optional[Path] = None) -> list[Path]:
"""Write a Take's stem slices to Samples/<kit>/ honoring B1/B6/B8/B10, then link."""
"""Write a Take's stem slices to Samples/<kit>/ honoring B1/B6/B8/B10, then link.
B1 snap is LENGTH-PRESERVING (see `_snap_length_preserving`): the exact bar-multiple
sample count is held rigid and the whole window slides by one common offset onto
near-zero edges, so every exported loop's duration is a bar multiple to <1 ms rather
than drifting inward per-edge. `export_take_report` wraps this with the kit-multiples
check that surfaces the per-file deviation numbers.
"""
from . import publish
out_dir = (samples_root or publish.samples_root()) / kit
out_dir.mkdir(parents=True, exist_ok=True)
......@@ -421,9 +509,16 @@ def export_take(take: Take, kit: str, workspace: Path, catch: Catch, *,
if not src or not src.exists():
continue
y, sr = sf.read(str(src), always_2d=True, dtype="float32") # (n, ch)
a, b = int(sl.start_s * sr), int(sl.end_s * sr)
a = int(sl.start_s * sr)
mono_ref = y[:, 0]
a, b = _snap_zc(mono_ref, a, sr), _snap_zc(mono_ref, b, sr) # B1
# exact bar-multiple length from the take's grid; fall back to the raw span if the
# slice has no bar count (chops: bars=0) so those keep the per-edge snap behaviour.
length = bar_len_samples(take.bars, take.bpm, sr) if take.bars > 0 else 0
if length > 0:
a, b = _snap_length_preserving(mono_ref, a, sr, length) # B1, length-rigid
else:
b = int(sl.end_s * sr)
a, b = _snap_zc(mono_ref, a, sr), _snap_zc(mono_ref, b, sr)
clip = y[a:b]
if sl.name == "bass" and clip.shape[1] > 1: # B6 mono-sum
clip = clip.mean(axis=1, keepdims=True)
......@@ -444,6 +539,19 @@ def export_take(take: Take, kit: str, workspace: Path, catch: Catch, *,
return written
def export_take_report(take: Take, kit: str, workspace: Path, catch: Catch,
**kwargs) -> dict:
"""`export_take` + the kit-multiples check, returned together for the result/report path.
Returns {"written": [Path…], "kit", "multiples": <kit_multiples_check dict>}. The
server surfaces `multiples` so the bar-multiple guarantee is visible per forge; a
non-ok row is a genuine off-grid warning.
"""
written = export_take(take, kit, workspace, catch, **kwargs)
return {"written": written, "kit": kit,
"multiples": kit_multiples_check(written, take.bpm)}
# ── CLI ───────────────────────────────────────────────────────────────────────
def _main(argv=None) -> int:
import argparse
......
......@@ -147,10 +147,11 @@ class Handler(SimpleHTTPRequestHandler):
catch = Catch.load(self.workspace / slug)
take = L.Take(**body["take"])
keep = tuple(body.get("keep") or ("drums", "bass", "other", "vocals"))
written = L.export_take(take, kit, self.workspace, catch, keep=keep,
res = L.export_take_report(take, kit, self.workspace, catch, keep=keep,
names=body.get("names"),
peak_norm=bool(body.get("peak_norm")))
return self._json({"written": [str(p) for p in written], "kit": kit})
return self._json({"written": [str(p) for p in res["written"]],
"kit": kit, "multiples": res["multiples"]})
except Exception as e: # noqa: BLE001
return self._json({"error": str(e)}, 500)
return self.send_error(404)
......
......@@ -8,6 +8,7 @@ import sys
from pathlib import Path
import numpy as np
import soundfile as sf
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
......@@ -72,6 +73,77 @@ def test_snap_zc_moves_to_crossing():
assert abs(snapped - idx) <= int(sr * L.ZC_TOL_MS / 1000)
def test_snap_length_preserving_keeps_exact_length_on_near_zero_edges():
# the whole window slides by one common offset → length is rigid, and both edges
# land near zero. Independent per-edge snap would let the two edges drift apart.
sr = 44100
t = np.arange(sr * 3) / sr
y = np.sin(2 * np.pi * 100 * t).astype(np.float32) # zero crossings every ~220.5 samp
a0, length = 5000, 88200 # 2 s window, arbitrary offset
a, b = L._snap_length_preserving(y, a0, sr, length)
assert b - a == length # LENGTH exactly preserved
assert abs(a - a0) <= int(sr * L.ZC_TOL_MS / 1000) # start within B1 tolerance
assert abs(y[a]) < 0.15 and abs(y[b]) < 0.15 # both edges near zero
def test_bar_len_samples_matches_bpm():
sr = 44100
# 132.51 bpm, beat = 60/132.51 s, bar = 4 beats; a 2-bar loop:
n = L.bar_len_samples(2, 132.51, sr)
expected = 2 * 4 * (60.0 / 132.51) * sr
assert abs(n - expected) < 1.0 # rounds to the nearest sample
def test_exported_duration_is_exact_bar_multiple(tmp_path):
# END-TO-END regression: synthesize a tempo'd stem, export a 2-bar take, and assert the
# written file's duration is within <1 ms of exactly 2 bars — the guarantee the
# length-preserving snap must give (independent per-edge snap drifted −4 to −11 ms).
from engine.model import Catch, Stem
sr = 44100
bpm = 132.51
bar_len_s = 4 * 60.0 / bpm
rng = np.random.default_rng(7)
T = np.arange(int(20 * sr)) / sr
# tonal bed + light noise so zero crossings exist everywhere for the snap to grab
y = (0.3 * np.sin(2 * np.pi * 110 * T) + 0.05 * rng.standard_normal(len(T))).astype(np.float32)
catch_dir = tmp_path / "ws" / "catchslug"
(catch_dir / "stems").mkdir(parents=True)
stem_path = catch_dir / "stems" / "drums.wav"
sf.write(str(stem_path), y, sr, subtype="PCM_24")
catch = Catch(slug="catchslug", title="t", source_id="x", source_url="u",
source_path="source.wav", duration=20.0,
stems=[Stem(name="drums", path="stems/drums.wav")])
take = L.Take(start_s=3.3, end_s=3.3 + 2 * bar_len_s, bars=2, bpm=bpm,
tempo_unstable=False, score=0.9, n_stems=1,
stems=[L.StemSlice(name="drums", start_s=3.3, end_s=3.3 + 2 * bar_len_s,
score=0.9, rms_dbfs=-12.0, is_one_shot=False)])
res = L.export_take_report(take, "test_kit", tmp_path / "ws", catch,
keep=("drums",), samples_root=tmp_path / "Samples")
assert res["written"], "should have written the drums slice"
info = sf.info(str(res["written"][0]))
dur = info.frames / info.samplerate
assert abs(dur - 2 * bar_len_s) < 1e-3, f"exported {dur:.6f}s vs {2*bar_len_s:.6f}s"
# and the explicit multiples check must agree
assert res["multiples"]["ok"]
assert res["multiples"]["files"][0]["dev_pct"] < 0.5
def test_kit_multiples_check_flags_off_grid(tmp_path):
sr = 44100
bpm = 120.0
bar_len_s = 4 * 60.0 / bpm # 2.0 s
good = tmp_path / "good.wav"; bad = tmp_path / "bad.wav"
sf.write(str(good), np.zeros(int(2 * bar_len_s * sr), dtype=np.float32), sr) # exactly 2 bars
sf.write(str(bad), np.zeros(int(2.15 * bar_len_s * sr), dtype=np.float32), sr) # 7.5% off
rep = L.kit_multiples_check([good, bad], bpm)
assert not rep["ok"]
rows = {r["name"]: r for r in rep["files"]}
assert rows["good.wav"]["ok"] and rows["good.wav"]["dev_pct"] < 0.5
assert not rows["bad.wav"]["ok"] and rows["bad.wav"]["dev_pct"] > 0.5
def test_analyze_stem_honors_shared_grid():
# a sparse stem (few onsets) gets no usable per-stem grid, but a shared grid
# from a rhythmic source lets it produce candidates (#18 grid-from-drums).
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment