Commit c17dd0d5 by PLN (Algolia)

feat(foundry): the loop finder (engine/loops.py) — §1 pipeline, per-stem (#5)

Generates ranked loop candidates from a separated stem, the phase-2 design made
real (PHASE2_FINDINGS §1/§3/§4): drift-tolerant PLP beat grid → beat-sync SSM
(MFCC⊕chroma → recurrence affinity) → Foote checkerboard novelty → candidate
windows at {1,2,4,8} bars → §3 composite score → NMS dedup. Works PER STEM (§0),
then loosely groups candidates across stems by shared time window into Takes the
human auditions together. export_take honours the B-rulebook (B1 ZC-snap, B6 bass
mono-sum, B8 24-bit/44.1k, B10 no-normalize) and calls publish.link_kit.

DRY with the katana: the loopability score reuses engine.grade's seam_score /
zc_score on each candidate slice, and adds the two terms only the finder has the
SSM context for — structural (does the window recur off its own diagonal?) and
boundary novelty (clean segment edges). Weights w_struct .4 / w_novel .2 / w_seam
.3 / w_zc .1, minus a tempo-instability penalty (§3).

Caught while validating on the real Loituma stems: PLP latched onto the eighth-
note pulse and reported ~250 bpm (Loituma is ~125), so 2-bar windows came out
half-length — the §7 octave hazard, live. Fixed by pinning PLP's tempo range to
one musical octave (70–160) and enforcing a min inter-beat spacing in peak-pick.
Now reads ~120 bpm and 2-bar windows land at ~3.9s, squarely in the A-profile
band (3.7–8.0s). find_takes on the 4-stem catch correctly clusters time-aligned
candidates (e.g. vocals+drums sharing a 2-bar window).

CLI: python3 -m engine.loops <stem.wav> [--bars 2 4 8] [--top N] [--json].
7 new tests (checkerboard signs, Foote peak at a block seam, structural recurrence,
NMS dedup, ZC-snap, and the tempo-octave guard). Full foundry suite: 41 pass.
parent 4c9a8312
"""Tests for the loop finder (engine/loops.py).
Pure DSP/logic on synthetic inputs, plus one light integration (beat_grid tempo
recovery, which guards the §7 octave fix). Composite scoring weights are v1 and
calibrated in #7, so assertions are structural/relative, not pinned to thresholds.
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from engine import loops as L
def test_checkerboard_shape_and_signs():
k = 4
ker = L._checkerboard(k)
assert ker.shape == (2 * k, 2 * k)
# top-left & bottom-right quadrants positive; off-diagonal quadrants negative
assert ker[:k, :k].sum() > 0 and ker[k:, k:].sum() > 0
assert ker[:k, k:].sum() < 0 and ker[k:, :k].sum() < 0
def test_foote_novelty_peaks_at_block_boundary():
# two self-similar blocks → a checkerboard novelty spike at their seam (idx 10)
n = 20
ssm = np.zeros((n, n))
ssm[:10, :10] = 1.0
ssm[10:, 10:] = 1.0
nov = L.foote_novelty(ssm, 4)
assert 8 <= int(np.argmax(nov)) <= 12
def test_structural_high_when_window_repeats():
n = 8
ssm = np.eye(n)
# window [0:2] strongly resembles [4:6] elsewhere
ssm[0, 4] = ssm[1, 5] = ssm[4, 0] = ssm[5, 1] = 1.0
rep = L._structural(ssm, 0, 2)
uniq = L._structural(ssm, 2, 4)
assert rep > uniq
def _cand(start, end, score, bars=2):
return L.LoopCandidate(start_s=start, end_s=end, bars=bars, bpm=120,
tempo_unstable=False, score=score, structural=0.5,
novelty=0.5, seam=0.5, zc=0.5)
def test_overlap_fraction():
a, b = _cand(0, 4, .9), _cand(2, 6, .8)
assert 0.4 < L._overlap(a, b) < 0.6 # 2s overlap of 4s windows
assert L._overlap(_cand(0, 4, .9), _cand(10, 14, .8)) == 0.0
def test_dedup_keeps_best_drops_overlap():
cands = [_cand(0, 4, .9), _cand(2, 6, .8), _cand(20, 24, .7)]
out = L._dedup(cands, top_n=8)
starts = sorted(c.start_s for c in out)
assert starts == [0.0, 20.0] # the .8 overlapping the .9 is dropped
def test_snap_zc_moves_to_crossing():
sr = 44100
t = np.arange(sr) / sr
y = np.sin(2 * np.pi * 100 * t).astype(np.float32) # zero crossings every 220.5 samp
idx = 1000
snapped = L._snap_zc(y, idx, sr)
assert abs(y[snapped]) < abs(y[idx]) + 1e-3 # lands on/near a crossing
assert abs(snapped - idx) <= int(sr * L.ZC_TOL_MS / 1000)
def test_beat_grid_recovers_tempo_not_its_octave():
# 120 bpm click train; the fix must report ~120, NOT ~240
sr = 22050
spb = 0.5
n = int(sr * spb * 16)
y = np.zeros(n, dtype=np.float32)
for b in range(16):
i = int(b * spb * sr)
y[i:i + 200] += np.hanning(200).astype(np.float32)
_, times, bpm = L.beat_grid(y, sr)
assert len(times) >= 8
med = float(np.median(bpm))
assert 100 <= med <= 140 # not the 240 octave
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