Repository navigation
The beat layer: tempo, its confidence, and downbeats #45
robert-dean
started this conversation in
Ideas
Replies: 1 comment
|
Status check, 2026-09-22 (main at 23b983e) Done: the sidecar, Still open: the beat layer. No |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Status (2026-10-10)
This file began as "where per-track measurement comes from", and almost all of that has an answer.
analysis/is a Python sidecar answeringPOST /analyze,plugins/analyzer/is the adapter,analysisis a capability of its own(
packages/plugin-sdk/src/capabilities/analysis.ts), anddeadair.track_analysis(migration0005) holds a schema version and a completeness flag. The four cue points come off an RMS envelope
and the loudness layer is a BS.1770 implementation in
analysis/loudness.py, read throughplayout/gain.ts. The sidecar runs undernice(Keep the stream on time: analyzer priority, repeated breaks, show overruns #223), measures held copies first and fetchesnothing while the audience gate is shut (Analysis: no provider downloads while nobody is listening #399).
analysis/README.md§ "The rule, stated once": nothingcopyleft or non-commercial enters the analysis path, weights included, and the sidecar exists to
keep decoding out of Node and for no licence reason.
2026-08-30 (the negative result is kept below). They now come from synced lyrics (Lyrics: a store for the words of each record, and LRCLIB to fill it #411, Breaks: talk up to the post, from where the singing starts #412):
lyrics/vocal.ranges.tsturns timed lines into stretches of singing,vocal.markers.service.tspublishes where the singing starts and stops, the talk-up limit reads the first, and an operator
can override both (
0064_track_vocal_overrides.sql). That istrack-lyrics, and it is what this file's own
conclusion asked for.
/healthreports the worker's resident memory (_resident_mbinanalysis/app.py), taken so there is a number from before the measurements get more expensive.Nothing acts on it yet.
What is left is the beat layer alone:
bpm,beat_confidenceanddownbeats. None of them is inanalysis/*.py, andmeasure.pyandANALYSIS_SCHEMA_VERSIONare both still1. Its one consumeris crossfades, "What the blend does inside
the buffer", which needs a beat grid and a tempo confidence on top of the four cue points. The
talk-up limit no longer waits on this file.
What has to be measured
The four cue points stand unchanged. What the transition work adds is a second layer:
bpmbeat_confidencedownbeatsbeat_confidenceis the load-bearing one, and it is the field a naive implementation omits. Thewhole point of the policy ladder is that it refuses to beat-match on weak evidence, and a tempo
figure with no confidence attached cannot express weak. A measured BPM and a metadata BPM are also
different claims and should not be stored in one column: the rule the ladder enforces is that
metadata alone never authorises beat-matching, which is unstateable if the provenance is lost on
write.
Two guards from a comparable station's worst bugs in this exact layer, added 2026-08-28. Both are
the naive implementation rather than bad luck, and both are cheap to hold and expensive to retrofit:
octave correction reports double-time on slow material and scores the wrong tempo as fully
plausible. A number that says "this is definitely 140" when the record is 70 is worse than no
number at all, because the ladder above is built to trust it. The row for
bpmsaysoctave-ambiguous; this is the same fact asked of the field beside it.
that depended on it. That was their vocal detector, and the lesson is general: whatever threshold
the beat pass ships with is a measured number on this library, not a default.
whole measurement.
bpm,beat_confidenceanddownbeatssit behind onetry/exceptoftheir own; a beat-tracking failure on one record answers with the four cue points and the loudness
figures still present and the beat fields simply absent, rather than the row failing outright and
taking a working measurement of the rest of the track down with it.
_analyzetoday has no reasonto isolate a failure this way (cue points and loudness both come off the same decode, and either
one raising is genuinely fatal to the row), but the beat layer is a second, riskier detector added
later on top of a row that already works, and the negative result above is the reminder of how
wrong a detector in this exact spot can be while still returning a number.
One operational note for the sidecar, same layer. A long-running Python process that decodes
whole records accumulates resident memory the allocator does not return, independent of any leak in
the code: their analysis worker grew unbounded over long uptime for exactly this reason.
analysis/has the same shape. It now REPORTS its resident memory, so there is a baseline; the cheap answers
(trim thresholds, or recycling the worker every N records) are worth having in hand before the beat
layer makes each measurement more expensive, and are now a decision against a number.
The vocal fields cannot be measured this way, and now do not need to be
Built and reverted 2026-08-30. The licence rule in
analysis/README.mdputsvocal_onsetandvocal_curveoutside the licence question ("two of the five are not part of the problem at all",computed from "band-limited energy, 200 Hz to 4 kHz"). That is correct about licensing and wrong
about feasibility, and the difference cost a day.
Band energy alone measures nothing new, which was caught at design time: 200 Hz to 4 kHz is the
band
intro_endalready uses, because it is where voices and lead instruments live. Thereplacement was the standard speech/music discriminator: a voice is SYLLABIC, so measure the
envelope's own modulation at 2 to 8 Hz against the level it is modulating. Pure
scipy.signal, nomodel, no weights.
It works on synthetic signals and carries no vocal information on real music. Measured through
the sidecar against this library:
The steps are ±5 with a sign unrelated to the truth, and the instrumental's own drift (−4.6) is as
large as any of them. Across twelve records the presence distribution was p50 17, p90 44 with no
bimodality for a threshold to cut, three of seven onsets landed on
cue_in, and YYZ, aninstrumental, was given an onset at 0:00. A refinement contrasting the vocal band against a
60 to 160 Hz band (drums move both, a voice moves only the mid) collapsed to zero almost everywhere.
The reason, which is the part to keep: syllabic-rate modulation in the vocal band is not
specific to voice in rhythmic popular music, because drums, rhythm guitar and arpeggios all modulate
at 2 to 8 Hz. The synthetic negative control was a held tone, and no real instrumental is a held
tone, so the tests passed while the feature did not work. A detector whose negative case is
easier than reality is a detector that ships.
Three things worth having from it:
before the threshold was pinned rather than after, and a walk that had swept the library first
would have written 762 confident wrong rows.
to_monorectifies, which is now documented inloudness.pyand matters to anything spectralthe beat layer adds. Folding as
sqrt(mean(x²))lifted a 60 Hz bassline into the mid band by64 dB. The cue points are calibrated against that signal; a filter must not read it.
attempt. track-lyrics argued the better route, which needs no audio at
all: a synced lyric's first timestamp is "a number somebody typed while listening". That route
shipped (Breaks: talk up to the post, from where the singing starts #412), so this result is now the record of why it was taken.
For a record with no synced lyric there is still no vocal curve, and this result is the reason
not to try again from the signal. The coarse curve's two uses were filter depth during a blend and
outro talk; the lyric ranges answer the second wherever a lyric exists. If the blend ever needs the
first for records without one, the honest route is a permissively licensed model under the rule
above, not a third DSP attempt.
Bumping the schema version re-measures the library
The beat fields change the row's shape, so they come with
SCHEMA_VERSION = 2inmeasure.pyandANALYSIS_SCHEMA_VERSION = 2in the SDK, and the staleness rule this table was built on then readsevery existing row as stale. That is the design working: reanalysis is automatic rather than a
migration. It is also every record in the library fetched and decoded again, through a walk paced to
fifteen an hour and gated on the audience. Two things follow:
AnalysisRepository.trustedAnalysisForfiltersschemaVersion >= ANALYSIS_SCHEMA_VERSION, so abump as the code stands drops every record's cue points at once, and the station gets them back over
weeks as the walk grinds through. The fields a version-1 row carries are still right under
version 2, so the readers of THOSE fields should accept any version that has them, and only a
reader of the beat fields should require 2. Settle this in the same commit as the bump, not after.
try, itsfields simply absent) is what keeps a version-2 row from being worse than the version-1 row it
replaces.
What this does not solve
Nobody sells these numbers. §3 checked this on 2026-08-10 for the four points and the answer is
the same for the beat layer: catalog upstreams return tempo, key and energy, none of them returns a
downbeat grid, a beat confidence or a vocal onset. The one large free corpus of computed descriptors
is a fixed 2022 dump, useful as a cold-start layer for back catalogue keyed by recording id and
nothing at all for recent releases. So this is measured locally or not at all, which is why the
sidecar was the whole question, and why the beat layer goes into it rather than beside it.
Analysis cannot be a precondition for airing. Whatever this becomes, an unanalysed track has to
play. Every consumer listed at the top degrades to a defined behaviour when the row is missing (plain
fade, no talk-up, original ordering), and that is a property to preserve deliberately rather than a
gap to close.
All reactions