Commit 0e69fa12 by PLN (Algolia)

feat(foundry): rotate-to-downbeat export post-pass — loops start on the 1, not 'BCDA'

PLN's kit audition (logged 6cfc7af0): loops felt 'timed ok but cut
BCDA/DABC — not at a good start'. Measured and confirmed — the PLP beat
grid has no downbeat anchor, so candidate windows start on an arbitrary
beat: superfreak other_skank's strongest onset sat on slot 2/8 (its
first 30 ms −23 dB below its own peak 30 ms), bass_deep's on slot 4/16,
drums_dub's on slot 1/16. Bar-exact and tempo-locked, but musically
rotated.

The key insight that makes the fix free: for a verified clean-seam,
bar-exact loop, ROTATION IS SAFE BY CONSTRUCTION. Looped playback of a
rotated loop is the same audio cycle — the old wrap junction plays
interiorly, and the new wrap (the rotation point) joins samples that
were contiguous in the source, i.e. perfectly continuous. Rotation only
changes where the loop STARTS, never how it wraps.

_rotate_to_downbeat(): score each beat slot by its circular low-band
(<200 Hz, kick-weighted) attack — RMS just after minus just before —
plus a small full-band term; roll the winning slot to position 0,
zc-snapping the roll offset (B1) so a re-trigger starts at a crossing.
Length untouched (np.roll) ⇒ the <1 ms bar-multiple guarantee (0759def5)
holds through rotation.

Wired into export_take (on by default, rotate=False opts out; chops
bars=0 never rotate). Verify-rerank stays export-faithful: _true_seam
now returns min(old-wrap seam, rotated-wrap seam) — both junctions of
the shipped cycle are measured, so rotation can never hide a bad wrap
by moving it inside the file.

Tests: +3 (downbeat at slot 5 → rotated to 0; slot-0 no-op; rotating a
continuous loop never breaks the seam). 85 passed.
parent 6cfc7af0
......@@ -171,17 +171,28 @@ def _snapped_window(y: np.ndarray, s_smp: int, e_smp: int, sr: int) -> np.ndarra
return y[a:b]
def _true_seam(y: np.ndarray, s_smp: int, e_smp: int, sr: int) -> float:
def _true_seam(y: np.ndarray, s_smp: int, e_smp: int, sr: int, n_beats: int = 0) -> float:
"""TRUE post-snap seam quality (0..1) of a window, via the grader's seam scorer.
Reuses grade.seam_score (DRY — no duplicated DSP) on the zc-snapped slice that will
actually be exported, so the finder's seam term is the same quantity the grader
reports rather than a raw-window proxy that the snap invalidates.
Export-faithful under rotation: with the downbeat rotation (`_rotate_to_downbeat`,
n_beats > 0) the shipped file's looped playback has TWO junctions — the rotation
point (its new wrap) and the old window wrap (now interior, but it still plays every
cycle). We return the MIN of both seams so the binding, audible junction drives the
rank; a rotation can never hide a bad wrap by moving it inside the file.
"""
snapped = _snapped_window(y, s_smp, e_smp, sr)
if snapped.size < 3:
return 1.0
seam, _ = G.seam_score(snapped)
seam, _ = G.seam_score(snapped) # old wrap (interior post-rotation)
if n_beats > 1:
rotated, slot, _ = _rotate_to_downbeat(snapped, sr, n_beats)
if slot != 0:
seam_rot, _ = G.seam_score(rotated) # rotation-point wrap of shipped bytes
seam = min(seam, seam_rot)
return seam
......@@ -265,7 +276,7 @@ def _verify_rerank(cands: list[LoopCandidate], y: np.ndarray, sr: int,
survivors = sorted(cands, key=lambda x: -x.score)[:VERIFY_SURVIVOR_MULT * top_n]
for c in survivors:
s_smp, e_smp = int(c.start_s * sr), int(c.end_s * sr)
true_seam = _true_seam(y, s_smp, e_smp, sr)
true_seam = _true_seam(y, s_smp, e_smp, sr, n_beats=c.bars * BEATS_PER_BAR)
# rebuild the composite swapping proxy seam → true seam (same weights)
base = c.score - w["seam"] * c.seam + w["seam"] * true_seam
if true_seam < VERIFY_SEAM_FLOOR: # audible wrap click after snap
......@@ -458,6 +469,60 @@ def _snap_length_preserving(y: np.ndarray, a: int, sr: int, length: int) -> tupl
return s, s + length
ROTATE_ATTACK_WIN_S = 0.030 # attack window each side of a beat slot
ROTATE_LOWBAND_HZ = 200.0 # downbeat = strongest LOW-band attack (kick-weighted)
def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int) -> tuple[np.ndarray, int, int]:
"""Rotate a bar-exact, clean-seam loop so the musical downbeat sits at position 0.
PLN's ear-finding (2026-07-11): loops came out 'timed ok but cut BCDA/DABC' — the PLP
beat grid has no downbeat anchor, so a candidate window can start on any beat and the
strongest hit lands mid-loop (measured: other_skank opened −23 dB below its own peak
30 ms; bass_deep's strongest onset sat on slot 4/16).
Rotation is FREE for a verified loop: looped playback of a rotated loop is the same
audio cycle — the old wrap junction plays interiorly and the new wrap (the rotation
point) joins samples that were contiguous in the source, i.e. continuous by
construction. So this only ever changes WHERE the loop starts, never how it wraps.
Scoring: at each of the n_beats slot positions, the low-passed (ROTATE_LOWBAND_HZ)
circular attack — RMS just after the slot minus RMS just before — plus a small
full-band term; the kick-weighted low band anchors "downbeat" to the drum's 1. The
winning slot becomes position 0; the exact roll offset is zc-snapped (B1) on the mono
reference so a re-trigger starts at a zero crossing. Length untouched (np.roll).
`clip` is (n,) or (n, ch). Returns (rotated, best_slot, roll_samples); slot 0 ⇒ no-op.
"""
c2 = clip if clip.ndim == 2 else clip[:, None]
n = c2.shape[0]
if n_beats <= 1 or n < n_beats * 8:
return clip, 0, 0
mono = c2.mean(axis=1).astype(np.float64)
from scipy.signal import butter, sosfiltfilt
sos = butter(4, ROTATE_LOWBAND_HZ / (sr / 2), btype="low", output="sos")
low = sosfiltfilt(sos, mono)
w = max(8, int(ROTATE_ATTACK_WIN_S * sr))
def _rms(x: np.ndarray, a: int, b: int) -> float: # circular slice RMS
idx = np.arange(a, b) % n
return float(np.sqrt(np.mean(x[idx] ** 2) + 1e-12))
best_k, best_score = 0, -np.inf
for k in range(n_beats):
p = int(round(k * n / n_beats))
att_low = _rms(low, p, p + w) - _rms(low, p - w, p)
att_full = _rms(mono, p, p + w) - _rms(mono, p - w, p)
score = att_low + 0.25 * att_full
if score > best_score:
best_score, best_k = score, k
if best_k == 0:
return clip, 0, 0
r = _snap_zc(c2.mean(axis=1), int(round(best_k * n / n_beats)), sr)
rotated = np.roll(c2, -r, axis=0)
return (rotated if clip.ndim == 2 else rotated[:, 0]), best_k, r
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.
......@@ -489,6 +554,7 @@ def kit_multiples_check(files: list[Path], bpm: float, *, tol_pct: float = 0.5)
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,
rotate: bool = True,
samples_root: Optional[Path] = None) -> list[Path]:
"""Write a Take's stem slices to Samples/<kit>/ honoring B1/B6/B8/B10, then link.
......@@ -497,6 +563,12 @@ def export_take(take: Take, kit: str, workspace: Path, catch: Catch, *,
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.
`rotate` (default True): bar-loops are rotated to the musical downbeat before writing
(`_rotate_to_downbeat`) so the loop STARTS on its strongest low-band attack instead of
an arbitrary grid beat (the BCDA/DABC ear-finding). Duration and wrap quality are
unaffected by construction; pass rotate=False to keep the raw grid phase. Chops
(bars=0) are never rotated.
"""
from . import publish
out_dir = (samples_root or publish.samples_root()) / kit
......@@ -520,6 +592,8 @@ def export_take(take: Take, kit: str, workspace: Path, catch: Catch, *,
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 rotate and take.bars > 0: # start on the downbeat
clip, _slot, _roll = _rotate_to_downbeat(clip, sr, take.bars * BEATS_PER_BAR)
if sl.name == "bass" and clip.shape[1] > 1: # B6 mono-sum
clip = clip.mean(axis=1, keepdims=True)
if peak_norm: # B10: off by default
......
......@@ -130,6 +130,57 @@ def test_exported_duration_is_exact_bar_multiple(tmp_path):
assert res["multiples"]["files"][0]["dev_pct"] < 0.5
def _loop_with_downbeat_at(slot, n_beats=8, sr=22050, beat_s=0.5):
"""Synthetic bar-exact loop: soft high ticks on every beat, one BIG low thump at `slot`."""
n = int(n_beats * beat_s * sr)
y = 0.02 * np.random.default_rng(9).standard_normal(n).astype(np.float32)
for k in range(n_beats):
i = int(k * n / n_beats)
L_hit = int(0.05 * sr)
env = np.exp(-np.linspace(0, 10, L_hit)).astype(np.float32)
tick = env * np.sin(2 * np.pi * 3000 * np.arange(L_hit) / sr).astype(np.float32)
y[i:i + L_hit] += 0.1 * tick # every beat: soft tick
if k == slot: # the downbeat: low thump
kick = env * np.sin(2 * np.pi * 60 * np.arange(L_hit) / sr).astype(np.float32)
y[i:i + L_hit] += 0.9 * kick
return y, sr
def test_rotate_to_downbeat_moves_kick_to_zero():
# BCDA regression: a loop whose downbeat (low thump) sits at slot 5 must come out
# rotated so the thump is at position 0 (within the zc-snap tolerance of the slot).
y, sr = _loop_with_downbeat_at(5)
rotated, slot, roll = L._rotate_to_downbeat(y, sr, 8)
assert slot == 5
assert rotated.shape == y.shape # length untouched
# the low thump now opens the loop: first 60 ms carries (near-)max energy
w = int(0.06 * sr)
first = float(np.sqrt(np.mean(rotated[:w] ** 2)))
windows = [float(np.sqrt(np.mean(rotated[i:i + w] ** 2)))
for i in range(0, len(rotated) - w, w)]
assert first >= 0.9 * max(windows), "downbeat thump should now sit at position 0"
def test_rotate_noop_when_downbeat_already_at_zero():
y, sr = _loop_with_downbeat_at(0)
rotated, slot, roll = L._rotate_to_downbeat(y, sr, 8)
assert slot == 0 and roll == 0
assert np.array_equal(rotated, y)
def test_rotation_never_breaks_a_clean_seam():
# a seamless loop (integer periods, continuous everywhere) stays seamless after
# rotation — the new wrap joins samples that were contiguous in the source.
from engine import grade as G
sr = 22050
n = sr * 2
y = (0.4 * np.sin(2 * np.pi * 110 * np.arange(n) / sr)).astype(np.float32) # 220 periods
rotated, slot, roll = L._rotate_to_downbeat(y, sr, 8)
assert rotated.shape == y.shape
seam, _ = G.seam_score(rotated.astype(np.float64))
assert seam > 0.6, "rotating a continuous loop must not introduce a wrap discontinuity"
def test_kit_multiples_check_flags_off_grid(tmp_path):
sr = 44100
bpm = 120.0
......
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