Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,17 +786,39 @@ seconds of every track converging — through the intro, which is the least repr
— and pays that cost again on every device, on every play. Same reasoning as the loudness: the audio
does not change, so neither does the answer. Measure once, keep it in `track_analysis`, serve it.

- The TEMPO is **Deezer's own** where Deezer has one: its public API publishes a `bpm` per track,
exact and free. Anything else (a local upload, a track with no figure) is measured from a
low-passed envelope — ffmpeg decimates to 1 kHz u8 and the per-frame peak is a `max`/`min` over a
ten-byte slice, so no audio sample is ever touched from Python — **and only when something in
- The TEMPO is **beat-tracked from the file** (`deezer/beats.py`, `ANALYSIS_VERSION` 5): a numpy port
of the analyser Mixxx ships as its default — qm-dsp's complex-spectral-difference onsets (44.1 kHz,
1024/512) and TempoTrackV2 (comb filter bank + Viterbi for the period, Ellis-style dynamic
programming for the beats), then Mixxx's BeatUtils (constant regions within 25 ms, the longest
joined to those agreeing in tempo and phase, rounded to the simplest value its error bars allow).
GPL-2.0-or-later code, credited in the file. The envelope estimator it replaced read 154 for a 150
BPM record and 133 for 132 (2.7%: a beat lost every nine seconds); on the 28 arranged records
(`tools/tempo_eval.py`) it was 17/26 within 3.5%, the tracker is **23/26 exact** and 26/26 with
Deezer's figure as witness, ~1 s for a five-minute file. And Deezer has NO figure for nearly all
of a hard-dance library (2 of 3 462 files here carried a non-zero one), so the estimator was what
everybody got. Three rules sit on top, each measured:
- **Hardcore is doubled back**: the Rayleigh weighting centres on 120 BPM and tracks a 200 BPM
kick on every other beat (Mixxx's own failure). When the half beats strike as hard as the
beats — both in the kick band (43-129 Hz) and across the spectrum, between 0.85 and 1.3 of the
beats — the tempo is doubled (frenchcore, Krach, speedcore read 0.93-1.06; nothing tracked right
falls in the window; hard house's offbeat donk took the phase and read 13.7, hence the cap).
- **No beat, no tempo**: the old attack gate (`_attack_rate`, 40 rises a minute in the bass) and
the beats' salience against the quarter-beat positions ≥ 1.12 (real masters 1.16-3.3, pads
1.01-1.07 — a thin margin, stated; the player's locked grid re-checks presence anyway).
- **Deezer's figure only witnesses the OCTAVE** (`_reconcile_tracked`): Deezer at twice the
tracked tempo moves it up (dnb at 87/174, double-kick metal); at half it is Deezer's hardcore
listing and ignored, unless alternate beats are unequal in the bass (< 0.3: a one-drop tracked
at double time) — then it halves; any other disagreement follows the tracker's confidence.
Without numpy the envelope estimator below still answers. A version bump re-measures the library
on boot (`webui/analysis.reanalyse_if_stale`) instead of waiting for the button.
- The fallback envelope estimator measures a low-passed envelope — ffmpeg decimates to 1 kHz u8 and
the per-frame peak is a `max`/`min` over a ten-byte slice — **and only when something in
that band STRIKES** (`_attack_rate`, `ANALYSIS_VERSION` 3). The block peak of a steady note
ripples with its phase, and the normalised autocorrelation turned that ripple into a confident
beat: a held 82 Hz note read 240 BPM at 0.79, a held chord 231 at 0.50, and the player anchored
on it. The same bytes, squared and smoothed, now have to rise 1 dB within 30 ms at least 40 times
a minute (held tones and chords: 0; the sparsest record with a beat: 99.7). The rise is small on
purpose — a limitered hard kick's tail sits 2 dB under its attack, and 3 dB lost those. Every
record with a beat measures exactly what it did before; `tools/tempo_eval.py` is the check.
purpose — a limitered hard kick's tail sits 2 dB under its attack, and 3 dB lost those.
- The DESCRIPTORS come from ffmpeg's `aspectralstats` and `ebur128`: centroid, spread, flatness,
entropy, rolloff and flux per frame, plus integrated loudness and **loudness range**. LRA is the
single best "how squashed is this" axis there is — a limitered hardcore master sits near 3 LU, a
Expand Down
111 changes: 109 additions & 2 deletions supysonic/deezer/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@
# a grid share of 0.06 and trance was doubled to 280), the hard families
# are written on the scales these measures have, and a verdict's
# confidence is backed by how much its rules actually found.
ANALYSIS_VERSION = 4
# 5 the tempo is BEAT-TRACKED (beats.py, Mixxx's analyser) and Deezer's
# figure only witnesses its octave; the envelope estimator read 154 for
# a 150 BPM record and the wrong octave on half the non-club records.
ANALYSIS_VERSION = 5

# Never analyse more than this much of a file. Ten minutes is far more than any
# verdict needs, and it is what stops a two-hour DJ set costing two hours of
Expand Down Expand Up @@ -623,15 +626,48 @@ def _grid_share(odf, period):
return num / den if den > 1e-9 else 0.0


#: The beat tracker's salience (beats.py `_salience`: on-beat onsets against
#: those a quarter of a beat away) under which there is no beat to serve.
#: Measured: twenty real masters from this library read 1.16 (a dense hard
#: techno) to 3.3, the eval's records 1.33 (dubstep) to 29; the ambient pads
#: 1.07 and a detuned-saw pad 1.01. The margin is thin on both sides, and
#: stated: a phantom tempo here is caught again by the player, whose locked
#: grid only counts as present where the music folds onto it.
MIN_BEAT_SALIENCE = 1.12


def _measure_tempo(path):
"""(bpm, confidence, grid_share) measured from the file itself.

``(None, 0.0, 0.0)`` when nothing in the low band strikes often enough to
carry a beat at all (``_attack_rate``): no tempo is better than a phantom.

The tempo is the BEAT TRACKER's (beats.py: Mixxx's analyser, ported) when
numpy is there — exact to the hundredth on a record made to a click, where
the envelope estimator below read 154 for 150. Without numpy the envelope
estimator still answers, as it always did.
"""
odf, attacks = _low_envelope(path)
if attacks < MIN_ATTACKS_PER_MINUTE:
return None, 0.0, 0.0
from . import beats

if beats.available():
r = beats.analyse(path)
if r is None or r["salience"] < MIN_BEAT_SALIENCE:
return None, 0.0, 0.0
bpm = r["bpm"]
if not (MIN_BPM <= bpm <= MAX_BPM):
return None, 0.0, 0.0
smooth = _smooth(odf)
grid = _grid_share(smooth, 60 * ENV_HZ / bpm) if len(smooth) >= 8 * ENV_HZ else 0.0
_remember_witness(path, r["alternation"])
return round(bpm, 3), round(r["confidence"], 3), round(grid, 3)
return _measure_tempo_envelope(odf)


def _measure_tempo_envelope(odf):
"""The envelope estimator: the fallback when numpy is not installed."""
odf = _smooth(odf)
n = len(odf)
win = 8 * ENV_HZ
Expand Down Expand Up @@ -1248,6 +1284,73 @@ def _reconcile_octave(published, measured, measured_conf, published_conf, source
return published, published_conf, source


# The tracker's alternation reading per file, for the reconciliation that
# follows the measurement in the same analysis (kept apart so _measure_tempo's
# (bpm, confidence, grid) contract is unchanged). Bounded; taken once.
_witness_lock = threading.Lock()
_witness = OrderedDict()


def _remember_witness(path, alternation):
with _witness_lock:
_witness[path] = alternation
while len(_witness) > 64:
_witness.popitem(last=False)


def _take_witness(path):
with _witness_lock:
return _witness.pop(path, None)


def _tracker() -> bool:
from . import beats

return beats.available()


#: Alternate beats this unequal in the bass mean the track's beat is every
#: OTHER tracked one (see beats.py `alternation`): measured, a one-drop
#: tracked at double time 0.21; every hard-dance record 0.93-1.00; the
#: kick-on-one-and-three records it must never halve (boom bap, pop, piano
#: rag) 0.33-0.41 — and those agree with Deezer anyway.
HALF_ALTERNATION = 0.3
#: How sure the tracker must be (the share of the track its constant tempo
#: was taken from) to be served over a published figure it disagrees with.
TRACKED_CONF = 0.5


def _reconcile_tracked(published, measured, conf, alternation=None):
"""The beat tracker's tempo, with Deezer's figure as a witness of OCTAVE
only. (bpm, confidence, source).

The tracker is the authority on the tempo — it is exact where Deezer's
figure is rounded or simply wrong — and it can be wrong only one way the
signal cannot settle: the octave of a genre whose half-time and full-time
readings fold alike (dnb at 87 or 174, double-kick metal at 90 or 180).
So: Deezer at TWICE the tracked tempo moves it up an octave; Deezer at
HALF is Deezer's own well-known listing of hardcore and is ignored —
unless alternate tracked beats are clearly unequal in the bass, which is a
half-time groove tracked at double time (a one-drop), and both witnesses
then agree; any other disagreement goes to the tracker when it is sure,
to Deezer when it is not.
"""
if not published:
return measured, conf, "measured"
r = published / measured
if abs(r - 1) < _SAME:
return measured, max(conf, 0.9), "measured"
if abs(r / 2 - 1) < _SAME and measured * 2 <= MAX_BPM:
return round(measured * 2, 3), max(conf, 0.85), "measured+octave"
if abs(r * 2 - 1) < _SAME:
if alternation is not None and alternation < HALF_ALTERNATION and measured / 2 >= MIN_BPM:
return round(measured / 2, 3), max(conf, 0.85), "measured+octave"
return measured, conf, "measured"
if conf >= TRACKED_CONF:
return measured, conf, "measured"
return published, 0.8, "deezer"


# --- is Deezer's figure right? ---------------------------------------------------
#: Below this the file's own measurement is not a witness either way.
AUDIT_MIN_CONF = 0.3
Expand Down Expand Up @@ -1395,7 +1498,11 @@ def analyze_track_verbose(track: Track, provider=None, force: bool = False):
pulse = 0.0
try:
measured, conf, pulse = _measure_tempo(track.path)
if not bpm and measured:
if measured and _tracker():
bpm, bpm_conf, bpm_source = _reconcile_tracked(
bpm, measured, conf, _take_witness(track.path)
)
elif not bpm and measured:
bpm, bpm_conf, bpm_source = measured, conf, "measured"
elif bpm and measured:
bpm, bpm_conf, bpm_source = _reconcile_octave(
Expand Down
Loading
Loading