Commit 4a74213c by PLN (Algolia)

fix(vox): DC-free clickless cuts + length-rigid bar-quantized loop variants

Two defects surfaced while shipping the hammer (U Can't Touch This) vox
kit extension — both fixed in the shared cut tail, now factored as
_finish_clip():

1. DC offset. Demucs vocal stems carry a small DC bias (measured
   −9.6e-5 stem-wide on the hammer catch); every cut inherits it and
   longer phrases tripped the grader's B2 dc-offset flag (|mean|>1e-4,
   'break it down' cut measured −1.1e-4). Subtlety: a naive
   mean-subtract BEFORE fading is undone by the fades — the edge ramps
   remove signal asymmetrically and re-introduce up to ~1e-3 DC
   (caught by the new regression test, not by the real-data smoke
   run). Fix: subtract the FADE-WEIGHTED mean c = Σ(w·x)/Σw (w = fade
   envelope), then fade — post-fade mean is exactly 0 per channel AND
   edges are exactly 0. On-disk verify of the shipped hammer vox files
   reads dc = −0.0e+00, edge = 0.0e+00 across all six.

2. Loop-length drift. cut_phrase_loop placed the tail at exactly
   loop_beats × beat_s … then zc-snapped it (±12 ms), re-introducing
   the very grid deviation the length-preserving export rule (0759def5)
   forbids. The end snap is unnecessary — the fades already guarantee
   clickless edges — so the tail is now length-rigid: shipped
   08_vox_stophammertime_loop is 4.00 beats to 0.02 ms at BPM 132.51.

Tests: tightened test_cut_phrase_loop_is_exact_beat_multiple from
0.06-beat slack to <1 ms, added test_cut_removes_source_dc_offset
(biased source ⇒ DC-free, unflagged cut). 82 passed.
parent 0759def5
...@@ -358,12 +358,30 @@ def cut_phrase(y: np.ndarray, sr: int, phrase: Phrase, *, next_start: Optional[f ...@@ -358,12 +358,30 @@ def cut_phrase(y: np.ndarray, sr: int, phrase: Phrase, *, next_start: Optional[f
if b <= a: if b <= a:
b = min(n, a + 2) b = min(n, a + 2)
clip = y2[a:b].astype(np.float32).copy() clip = y2[a:b].astype(np.float32).copy()
return _finish_clip(clip, sr, fade_ms)
f = min(int(fade_ms / 1000 * sr), clip.shape[0] // 2)
def _finish_clip(clip: np.ndarray, sr: int, fade_ms: float) -> np.ndarray:
"""DC-remove + fade a cut clip (shared tail of cut_phrase / cut_phrase_loop).
Demucs vocal stems carry a small DC bias (measured −9.6e-5 stem-wide on the hammer
catch) that every cut inherits — enough to trip the grader's B2 dc-offset flag
(|mean| > 1e-4) on longer phrases. A one-shot should ship DC-free.
Order matters: a plain mean-subtract BEFORE fading is undone by the fades (the edge
ramps remove signal asymmetrically and re-introduce up to ~1e-3 DC). Instead subtract
the FADE-WEIGHTED mean c = Σ(w·x)/Σw (w = the fade envelope), then apply the fades:
the post-fade mean is exactly 0 per channel AND the edges are exactly 0 (ramp gain 0).
"""
n = clip.shape[0]
f = min(int(fade_ms / 1000 * sr), n // 2)
w = np.ones(n, dtype=np.float32)
if f > 1: if f > 1:
ramp = np.linspace(0.0, 1.0, f, dtype=np.float32)[:, None] ramp = np.linspace(0.0, 1.0, f, dtype=np.float32)
clip[:f] *= ramp w[:f] = ramp
clip[-f:] *= ramp[::-1] w[-f:] = ramp[::-1]
c = (clip * w[:, None]).sum(axis=0, keepdims=True) / max(float(w.sum()), 1e-9)
clip = (clip - c) * w[:, None]
return clip return clip
...@@ -391,12 +409,15 @@ def annotate_beats(phrases: list[Phrase], *, bpm: float = DEFAULT_BPM, ...@@ -391,12 +409,15 @@ def annotate_beats(phrases: list[Phrase], *, bpm: float = DEFAULT_BPM,
def cut_phrase_loop(y: np.ndarray, sr: int, phrase: Phrase, *, bpm: float = DEFAULT_BPM, def cut_phrase_loop(y: np.ndarray, sr: int, phrase: Phrase, *, bpm: float = DEFAULT_BPM,
fade_ms: float = FADE_MS) -> Optional[np.ndarray]: fade_ms: float = FADE_MS) -> Optional[np.ndarray]:
"""Bar-quantized loop variant of a loop-capable phrase: edges pulled to the EXACT """Bar-quantized loop variant of a loop-capable phrase: LENGTH-RIGID on the beat grid.
beat grid (phrase.loop_beats * beat_s), then zc-snapped within ±ZC_TOL_MS and faded.
Anchored at the phrase's first-word onset (minus the small pre-pad so the syllable Anchored at the phrase's first-word onset (minus the small pre-pad so the syllable
isn't clipped); the tail is placed at exactly loop_beats away so dur == an integer isn't clipped), zc-snapped at the START only; the tail is placed at EXACTLY
beat multiple, giving a clean loopAt. Returns None if the phrase isn't loop-capable. loop_beats × beat_s away so dur == an integer beat multiple with zero drift (the
length-preserving rule, cf. loops._snap_length_preserving — the previous end-side
zc-snap could pull the tail up to ±ZC_TOL_MS off the grid, the very deviation the
guarantee forbids). The fades make both edges exactly 0, so no end-snap is needed
for clicklessness. Returns None if the phrase isn't loop-capable.
""" """
if not phrase.loop_capable or phrase.loop_beats <= 0: if not phrase.loop_capable or phrase.loop_beats <= 0:
return None return None
...@@ -409,15 +430,9 @@ def cut_phrase_loop(y: np.ndarray, sr: int, phrase: Phrase, *, bpm: float = DEFA ...@@ -409,15 +430,9 @@ def cut_phrase_loop(y: np.ndarray, sr: int, phrase: Phrase, *, bpm: float = DEFA
a = max(0, min(a, n - 2)) a = max(0, min(a, n - 2))
a = _snap_zc(mono, a, sr) a = _snap_zc(mono, a, sr)
b = a + int(round(target * sr)) # EXACT grid length from the anchor b = a + int(round(target * sr)) # EXACT grid length from the anchor
b = _snap_zc(mono, b, sr) # ±ZC_TOL_MS only — stays on grid
b = max(a + 2, min(b, n)) b = max(a + 2, min(b, n))
clip = y2[a:b].astype(np.float32).copy() clip = y2[a:b].astype(np.float32).copy()
f = min(int(fade_ms / 1000 * sr), clip.shape[0] // 2) return _finish_clip(clip, sr, fade_ms)
if f > 1:
ramp = np.linspace(0.0, 1.0, f, dtype=np.float32)[:, None]
clip[:f] *= ramp
clip[-f:] *= ramp[::-1]
return clip
# ── 5+6. grade + name ──────────────────────────────────────────────────────────── # ── 5+6. grade + name ────────────────────────────────────────────────────────────
......
...@@ -140,6 +140,8 @@ def test_cut_respects_next_start(): ...@@ -140,6 +140,8 @@ def test_cut_respects_next_start():
def test_cut_phrase_loop_is_exact_beat_multiple(): def test_cut_phrase_loop_is_exact_beat_multiple():
# length-rigid grid cut: the tail sits at EXACTLY loop_beats × beat_s from the
# snapped anchor (no end-side zc-snap drift) — duration is grid-exact to <1 ms.
sr, bpm = 44100, 132.5 sr, bpm = 44100, 132.5
beat_s = 60.0 / bpm beat_s = 60.0 / bpm
y = _tone(sr, 6.0) y = _tone(sr, 6.0)
...@@ -147,8 +149,19 @@ def test_cut_phrase_loop_is_exact_beat_multiple(): ...@@ -147,8 +149,19 @@ def test_cut_phrase_loop_is_exact_beat_multiple():
V.annotate_beats([p], bpm=bpm) V.annotate_beats([p], bpm=bpm)
assert p.loop_capable assert p.loop_capable
clip = V.cut_phrase_loop(y, sr, p, bpm=bpm) clip = V.cut_phrase_loop(y, sr, p, bpm=bpm)
got_beats = (clip.shape[0] / sr) / beat_s dur = clip.shape[0] / sr
assert abs(got_beats - p.loop_beats) < 0.06 # within zc-snap slack of exact grid assert abs(dur - p.loop_beats * beat_s) < 1e-3
def test_cut_removes_source_dc_offset():
# demucs stems carry a small DC bias; a cut one-shot must ship DC-free (B2)
sr = 44100
y = _tone(sr, 3.0) + 5e-4 # biased source, above the 1e-4 flag
p = _phrase("dc test", start=1.0, dur=1.0)
clip = V.cut_phrase(y, sr, p)
assert abs(float(clip.mean())) < 1e-4
g = V.grade_cut(clip, sr)
assert "dc-offset" not in g.flags
def test_cut_phrase_loop_none_when_not_capable(): def test_cut_phrase_loop_none_when_not_capable():
......
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