Commit b8854723 by PLN (Algolia)

fix(foundry): gate onset density on envelope crest — a steady sine read as drums

librosa's peak-picker finds peaks in whatever onset envelope it is handed, flat ones
included: a steady 50 Hz sine yields 84 onsets over six seconds (14/s) and was being
filed as drums, which made the bass branch unreachable for exactly the signal it was
written for. Onset count is now gated on the frame-RMS crest — ~1.0 for a sustained
tone against 3.1-9.5 for anything struck.

Measured across MAREA/ANGIE/ME to pick the threshold rather than guess it. Worth
recording that crest is a SPARSITY measure, not a percussion one — MAREA's single
vocal phrase reads 17.98 — so it only ever gates onset density and is never used
alone. HPSS was tried first and rejected: a kick-heavy drum bus reads 0.094
percussive, because HPSS hears a modern kick as harmonic.

18 tests.
parent 2c5b68fd
...@@ -128,6 +128,7 @@ class StemProbe: ...@@ -128,6 +128,7 @@ class StemProbe:
lf_frac: float # energy below 150 Hz / total lf_frac: float # energy below 150 Hz / total
hf_frac: float # energy above 2 kHz / total hf_frac: float # energy above 2 kHz / total
onset_rate: float # onsets per second over the loud excerpts onset_rate: float # onsets per second over the loud excerpts
env_crest: float # frame-RMS crest: ~1 sustained, >3 struck (gates onset_rate)
measured_family: str measured_family: str
agrees: bool = field(default=False) agrees: bool = field(default=False)
note: str = "" note: str = ""
...@@ -170,7 +171,12 @@ def _loud_windows(db: np.ndarray, y: np.ndarray, sr: int, hop: int, *, ...@@ -170,7 +171,12 @@ def _loud_windows(db: np.ndarray, y: np.ndarray, sr: int, hop: int, *,
def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe: def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe:
"""Measure a mono stem and decide what it actually is. Name is never trusted.""" """Measure a mono stem and decide what it actually is. Name is never trusted.
Note that `env_crest` is a sparsity measure, not a percussion one — MAREA's single
vocal phrase reads 17.98 — which is why it is only ever used to *gate* onset density,
never on its own.
"""
import librosa import librosa
eps = 1e-12 eps = 1e-12
...@@ -201,6 +207,14 @@ def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe: ...@@ -201,6 +207,14 @@ def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe:
onsets = librosa.onset.onset_detect(y=w, sr=sr, units="time", backtrack=True) onsets = librosa.onset.onset_detect(y=w, sr=sr, units="time", backtrack=True)
onset_rate = float(len(onsets) / max(len(w) / sr, 1.0)) onset_rate = float(len(onsets) / max(len(w) / sr, 1.0))
# Onset COUNT is not a percussiveness measure. librosa's peak-picker finds peaks in
# whatever envelope it is handed, including a flat one: a steady 50 Hz sine yields 84
# onsets over six seconds (14/s), which would file a pure bass tone as drums. So the
# count is gated on the envelope actually having transients in it — the crest of the
# frame-RMS, ~1.0 for a sustained tone against 3.1-9.5 for anything struck.
fr = librosa.feature.rms(y=w, hop_length=hop)[0]
env_crest = float(np.percentile(fr, 99) / (fr.mean() + eps))
# ── the verdict ────────────────────────────────────────────────────────── # ── the verdict ──────────────────────────────────────────────────────────
# PERCUSSIVE FIRST. The obvious test — "mostly low energy, low centroid ⇒ bass" — # PERCUSSIVE FIRST. The obvious test — "mostly low energy, low centroid ⇒ bass" —
# fires on a drum bus, because a modern kick carries most of a drum mix's ENERGY: # fires on a drum bus, because a modern kick carries most of a drum mix's ENERGY:
...@@ -208,7 +222,7 @@ def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe: ...@@ -208,7 +222,7 @@ def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe:
# 182/240 Hz, indistinguishable from a bass stem on those two features alone. What # 182/240 Hz, indistinguishable from a bass stem on those two features alone. What
# separates them is onset density (ALL DRUMS 6.6/s, BASS 1.2/s), so density is # separates them is onset density (ALL DRUMS 6.6/s, BASS 1.2/s), so density is
# asked first and the spectral test only sees what is left. # asked first and the spectral test only sees what is left.
if onset_rate > 3.0: if onset_rate > 3.0 and env_crest > 2.0:
measured = "drums" measured = "drums"
elif hf_frac > 0.25 and onset_rate > 0.8: elif hf_frac > 0.25 and onset_rate > 0.8:
measured = "drums" # bright and struck: a clap/snare stem is sparse measured = "drums" # bright and struck: a clap/snare stem is sparse
...@@ -225,7 +239,7 @@ def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe: ...@@ -225,7 +239,7 @@ def probe(y: np.ndarray, sr: int, claimed: str) -> StemProbe:
active_frac=round(active_frac, 3), active_rms_dbfs=round(active_rms, 2), active_frac=round(active_frac, 3), active_rms_dbfs=round(active_rms, 2),
centroid_hz=round(centroid, 1), lf_frac=round(lf_frac, 3), centroid_hz=round(centroid, 1), lf_frac=round(lf_frac, 3),
hf_frac=round(hf_frac, 4), onset_rate=round(onset_rate, 2), hf_frac=round(hf_frac, 4), onset_rate=round(onset_rate, 2),
measured_family=measured) env_crest=round(env_crest, 2), measured_family=measured)
# `vox` and `tonal` are not separable by these cheap features (a sung line and a # `vox` and `tonal` are not separable by these cheap features (a sung line and a
# rhodes chord have the same coarse shape) — CLAP does that job downstream. So a # rhodes chord have the same coarse shape) — CLAP does that job downstream. So a
......
...@@ -163,3 +163,33 @@ def test_a_wrong_sample_rate_is_caught(tmp_path): ...@@ -163,3 +163,33 @@ def test_a_wrong_sample_rate_is_caught(tmp_path):
(c,) = K.check_cuts([_cut(p, bpm=130.909)]) (c,) = K.check_cuts([_cut(p, bpm=130.909)])
assert not c.ok assert not c.ok
assert any("sample rate" in x for x in c.problems) assert any("sample rate" in x for x in c.problems)
def test_a_steady_tone_is_not_percussive_however_many_onsets_are_reported():
"""librosa's peak-picker finds peaks in a flat envelope — a steady 50 Hz sine
yields 84 onsets over six seconds (14/s). Without the crest gate that files a pure
bass tone as drums, which is how the bass override came to be unreachable."""
p = R.probe(_sine(50), SR, claimed="tonal")
assert p.onset_rate > 3.0 # the count really is that high
assert p.env_crest < 2.0 # and the envelope really is flat
assert p.measured_family == "bass"
def test_the_producers_label_survives_a_weak_contradiction():
"""MAREA's KEYS2 measures 5.1 onsets/s — genuinely rhythmic playing. Filing it as
drums would be silently wrong, so a known label wins and the disagreement is
reported instead."""
name = R.parse_stem_name("MAREA MIX10 123BPM KEYS2 STEM.wav")
p = R.probe(_sine(660), SR, claimed="tonal")
p.measured_family, p.agrees = "drums", False
assert R.effective_family(name, p) == "tonal"
def test_a_fallback_guess_loses_to_the_measurement():
"""`MAREA STEM` is not a role token; `tonal` there is our own guess, not the
producer's word, so it carries no authority against a measurement."""
name = R.parse_stem_name("MAREA MIX10 123BPM MAREA STEM.wav")
assert name.known is False
p = R.probe(_sine(660), SR, claimed="tonal")
p.measured_family, p.agrees = "drums", False
assert R.effective_family(name, p) == "drums"
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