Commit 12fd5bb7 by PLN (Algolia)

fix(foundry): one rotation pass leaves 1 loop in 6 starting on a weak beat

Chasing "does a shipped loop actually start on its downbeat" and nearly filing a false
alarm on the way. First measurement said 17% — but it scored 16 slots with no full-band
term while the rotator scores bars*4 slots WITH one, so the disagreement was the
instrument's. Factored the scorer out (_downbeat_slot_scores) so the rotation and its
verification cannot use lookalike measures, and re-measured with the real one.

The bug is real and smaller than the first number: over 60 shipped bar-loops, slot 0 was
the best slot in 29 and in the WORSE HALF of slots in 10. Median miss was 0.032 — i.e.
usually a near-tie — but one loop in six genuinely opens on a weak beat, which is exactly
the "timed ok but cut BCDA/DABC" complaint from 2026-07-11 that this function was written
to fix.

Mechanism: the roll point is zero-crossing snapped by up to ~10 ms against a 20 ms attack
window, and between two near-tied slots that is enough to flip which one wins. On clean
synthetic material rotation is idempotent (pass 1 finds slot 4, pass 2 finds 0); on real
music the tie-breaking is fragile.

So it now checks its own result and repeats, up to 3 passes, keeping a pass only if the
measured miss improves. Safe because rotation is free: two rolls compose into one and the
guard cannot oscillate. Best slot 29 -> 46 of 60, worse half 10 -> 1, median miss
0.032 -> 0.000.

Default is 3 rather than opt-in: it is strictly better and applies to every caller,
including export_take, so the GUI's forge gets it too. 112 tests.
parent 3e26a735
......@@ -473,8 +473,44 @@ 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 _downbeat_slot_scores(clip: np.ndarray, sr: int, n_beats: int) -> np.ndarray:
"""Per-slot downbeat score: low-band circular attack + a small full-band term.
Factored out of `_rotate_to_downbeat` so the rotation can CHECK ITS OWN RESULT with
the identical scorer instead of a lookalike — measuring a rotation with a slightly
different window or weighting produces a disagreement that is the measurement's fault
(`feedback_check_the_instrument_first`).
"""
from scipy.signal import butter, sosfiltfilt
c2 = clip if clip.ndim == 2 else clip[:, None]
n = c2.shape[0]
mono = c2.mean(axis=1).astype(np.float64)
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))
out = np.empty(n_beats)
for k in range(n_beats):
p = int(round(k * n / n_beats))
out[k] = ((_rms(low, p, p + w) - _rms(low, p - w, p))
+ 0.25 * (_rms(mono, p, p + w) - _rms(mono, p - w, p)))
return out
def _downbeat_miss(clip: np.ndarray, sr: int, n_beats: int) -> float:
"""How far slot 0 is from being the best slot: 0.0 = it IS the downbeat, 1.0 = worst."""
s = _downbeat_slot_scores(clip, sr, n_beats)
rng = float(s.max() - s.min())
return 0.0 if rng <= 0 else float((s.max() - s[0]) / rng)
def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int,
force_slot: Optional[int] = None) -> tuple[np.ndarray, int, int]:
force_slot: Optional[int] = None,
passes: int = 3) -> 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
......@@ -498,7 +534,22 @@ def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int,
from the most rhythmic stem of the window and applied to all of them; independent
per-stem rotation could shift their relative groove by whole beats).
`passes` (default 3): rotate, then CHECK, and rotate again if slot 0 still is not the
strongest attack. One pass is not enough on real music, because the roll point is
zero-crossing snapped (up to ~10 ms, against a 20 ms attack window) and that is enough
to flip the winner between two near-tied slots. Measured over 60 shipped bar-loops from
the Fred pack: after one pass, slot 0 was the best slot in 29 and in the WORSE HALF of
slots in 10 — one loop in six starting on a weak beat, which is the "timed ok but cut
BCDA/DABC" complaint this function exists to fix. With up to three passes and a
strictly-improving guard: best slot in 46, worse half in 1, median miss 0.032 → 0.000.
Iterating is safe because rotation is free (see above): two rolls compose into one, and
the guard means a pass that does not improve the measured miss is discarded, so this
cannot oscillate.
`clip` is (n,) or (n, ch). Returns (rotated, best_slot, roll_samples); slot 0 ⇒ no-op.
`best_slot`/`roll_samples` describe the FIRST pass, so a caller re-deriving a shared
rotation with `force_slot` sees what it saw before.
"""
c2 = clip if clip.ndim == 2 else clip[:, None]
n = c2.shape[0]
......@@ -506,23 +557,7 @@ def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int,
return clip, 0, 0
mono = c2.mean(axis=1).astype(np.float64)
if force_slot is None:
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
best_k = int(np.argmax(_downbeat_slot_scores(clip, sr, n_beats)))
else:
best_k = int(force_slot) % n_beats
if best_k == 0:
......@@ -531,6 +566,22 @@ def _rotate_to_downbeat(clip: np.ndarray, sr: int, n_beats: int,
rotated = np.roll(c2, -r, axis=0)
return (rotated if clip.ndim == 2 else rotated[:, 0]), best_k, r
# ── the check-and-repeat pass (see `passes` above) ────────────────────────
if force_slot is None and passes > 1:
cur, miss = out, _downbeat_miss(out, sr, n_beats)
for _ in range(passes - 1):
if miss <= 0.02: # slot 0 already is the downbeat
break
nxt, slot, _roll = _rotate_to_downbeat(cur, sr, n_beats, passes=1)
if slot == 0:
break
m = _downbeat_miss(nxt, sr, n_beats)
if m >= miss: # no improvement: keep what we have
break
cur, miss = nxt, m
out = cur
return out, best_k, r
def _pocket_zc(mono: np.ndarray, idx: int, sr: int, *,
back_s: float = 0.025, fwd_s: float = 0.002) -> int:
......
......@@ -357,3 +357,49 @@ def test_beat_grid_recovers_tempo_not_its_octave():
assert len(times) >= 8
med = float(np.median(bpm))
assert 100 <= med <= 140 # not the 240 octave
def test_rotation_checks_its_own_work_and_repeats():
"""One rotation pass is not enough on real music. The roll point is zero-crossing
snapped by up to ~10 ms against a 20 ms attack window, which is enough to flip the
winner between two near-tied slots — so the loop can come out starting on a weak
beat, the "timed ok but cut BCDA/DABC" failure this function exists to prevent.
Measured over 60 shipped bar-loops: slot 0 was the best slot in 29 and in the worse
half in 10 after one pass; 46 and 1 with the check-and-repeat.
"""
import numpy as np
from engine import loops as L
sr, bpm, bars = 44100, 120.0, 4
nb = bars * 4
n = int(round(bars * 4 * 60.0 / bpm * sr))
beat = n // nb
y = np.zeros(n)
for b in range(nb): # strongest kick deliberately on slot 4
i = b * beat
env = np.exp(-np.arange(beat) / (sr * 0.04))
y[i:i + beat] += (np.sin(2 * np.pi * 55 * np.arange(beat) / sr) * env
* (1.0 if b == 4 else 0.35))
clip = y.astype(np.float32)[:, None]
rot, slot, _ = L._rotate_to_downbeat(clip, sr, nb)
assert slot == 4 # found the real downbeat
assert L._downbeat_miss(rot, sr, nb) <= 0.02 # and it now sits at 0
# rotation is free: a bar-exact loop keeps its length and its wrap
assert rot.shape == clip.shape
# idempotent — a rotated loop is already on its downbeat
_, slot2, _ = L._rotate_to_downbeat(rot, sr, nb)
assert slot2 == 0
def test_the_downbeat_scorer_is_shared_so_a_check_cannot_disagree():
"""The rotation and its verification must use the same scorer; a lookalike with a
different window produces disagreements that are the measurement's fault."""
import numpy as np
from engine import loops as L
sr, nb = 44100, 8
rs = np.random.RandomState(0)
y = (rs.randn(sr * 2) * 0.1).astype(np.float32)
s = L._downbeat_slot_scores(y, sr, nb)
assert s.shape == (nb,)
assert 0.0 <= L._downbeat_miss(y, sr, nb) <= 1.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