Commit bb404419 by PLN (Algolia)

fix(foundry): the finder spent its candidate slots on the rests

Rejecting silent loops at export was the right fix in the wrong place. BIGHEN's
strings stem plays in two sections and rests between them, and all six of the
windows the finder ranked highest sat inside a rest — so filtering at the gate took
`fred_bighen_tonal` from four silent loops down to one real one, when it should
have gone to four real ones. `fred_bighen_fx` disappeared entirely for the same
reason.

The composite is degenerate on silence in the finder too, and for the same reason
it was in the grader: a rest is maximally self-similar in the SSM, its seam is
immaculate, its zero crossings are trivial. So presence is checked before ranking,
on the window itself, sharing `grade.THRESH["empty_dbfs"]` by import so the two
stages cannot disagree about what silence is.

The test for this took two attempts, and the first one was the interesting one. A
stem gated on its own beat grid CANNOT reproduce the bug: `beat_grid` finds no
beats in the silence, so no window is ever generated there, and the test passed
with the gate disabled — proving nothing. The bug needs a grid derived from ANOTHER
stem, which is exactly how `stempack` calls `analyze_stem` and exactly why it does
so: sharing a rhythmic track's grid gives every stem windows across the full
duration, including the parts where that stem is not playing. Verified the test now
fails with the gate off and passes with it on.

Suite 119 → 122.
parent 18ec7b4b
...@@ -196,6 +196,11 @@ def _true_seam(y: np.ndarray, s_smp: int, e_smp: int, sr: int, n_beats: int = 0) ...@@ -196,6 +196,11 @@ def _true_seam(y: np.ndarray, s_smp: int, e_smp: int, sr: int, n_beats: int = 0)
return seam return seam
# Amplitude below which a window has nothing in it. One number with `grade`, by import,
# so the finder and the grader cannot disagree about what silence is.
_EMPTY_AMP = 10.0 ** (G.THRESH["empty_dbfs"] / 20.0)
def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8, def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
grid=None, weights=None, verify=True) -> list[LoopCandidate]: grid=None, weights=None, verify=True) -> list[LoopCandidate]:
"""Rank loop candidates within one stem. """Rank loop candidates within one stem.
...@@ -237,6 +242,15 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8, ...@@ -237,6 +242,15 @@ def analyze_stem(y: np.ndarray, sr: int, *, bars=(2, 4, 8), top_n=8,
sl = y[s_smp:e_smp] sl = y[s_smp:e_smp]
if sl.size < 8: if sl.size < 8:
continue continue
# Presence, BEFORE ranking. Every term below is vacuously perfect on silence
# — a seam between two silences is immaculate, its zero crossings are trivial,
# a flat SSM block is maximally self-similar — so the composite's optimum is
# "no audio" and a stem with long gaps spends all its candidate slots there.
# Rejecting at export (via `grade`) is not enough: BIGHEN's strings stem had
# all six of its top windows inside a rest, so the kit went from four silent
# loops to one real one instead of to four real ones.
if float(np.max(np.abs(sl))) < _EMPTY_AMP:
continue
seam, _ = G.seam_score(sl) seam, _ = G.seam_score(sl)
zc, _, _ = G.zc_score(sl, sr) zc, _, _ = G.zc_score(sl, sr)
struct = _structural(ssm, i, e) struct = _structural(ssm, i, e)
......
...@@ -403,3 +403,44 @@ def test_the_downbeat_scorer_is_shared_so_a_check_cannot_disagree(): ...@@ -403,3 +403,44 @@ def test_the_downbeat_scorer_is_shared_so_a_check_cannot_disagree():
s = L._downbeat_slot_scores(y, sr, nb) s = L._downbeat_slot_scores(y, sr, nb)
assert s.shape == (nb,) assert s.shape == (nb,)
assert 0.0 <= L._downbeat_miss(y, sr, nb) <= 1.0 assert 0.0 <= L._downbeat_miss(y, sr, nb) <= 1.0
def test_the_finder_will_not_spend_its_candidate_slots_on_silence():
"""A stem that plays for a few bars and then rests has a degenerate optimum: every
term in the composite — structural self-similarity, seam, zero crossings — is
vacuously perfect across the rest. Measured on BIGHEN's strings stem, all six top
windows sat inside a gap, so filtering at export left the kit with one loop where it
should have had four.
The grid has to come from ANOTHER stem for this to reproduce, which is also how
`stempack` calls it: a per-stem grid finds no beats in the silence, so no window is
ever generated there and the bug cannot appear. Share a rhythmic track's grid — the
whole point of doing so — and every stem gets windows across the full duration,
including the parts where it is not playing.
"""
import numpy as np
from engine import loops as L
sr = 22050
bpm, bars = 120.0, 4
bar_s = 4 * 60.0 / bpm
def ticks(n_bars, seed):
r = np.random.RandomState(seed)
out = np.zeros(int(round(n_bars * bar_s * sr)), dtype=np.float32)
step = int(round(bar_s / 4 * sr))
env = np.exp(-np.arange(step) / (sr * 0.03))
for i in range(0, len(out) - step, step):
out[i:i + step] += (r.randn(step) * env * 0.4).astype(np.float32)
return out
drums = ticks(32, 1) # plays throughout: the grid source
strings = np.concatenate([ticks(8, 2), # plays for 8 bars …
np.zeros(len(drums) - int(round(8 * bar_s * sr)),
dtype=np.float32)]) # … then rests
assert len(strings) == len(drums)
grid = L.beat_grid(drums, sr)
cands = L.analyze_stem(strings, sr, bars=(bars,), top_n=6, grid=grid)
assert cands, "the played section should still yield candidates"
played_s = 8 * bar_s
assert all(c.start_s < played_s for c in cands), \
[(c.start_s, c.score) for c in cands]
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