postprocess

Turn per-frame beat/one/last probability curves into a labeled beat list.

Pipeline: pick_peaks() (continuous curve -> discrete timestamps) -> gate_periodicity() (clean up the timestamp list using beat regularity) -> a bar-position stage (assign each timestamp a position 1-4 in the bar) -> all three composed in readout().

Two bar-position stages are available, selected by readout()’s decoder argument: label_bar_position() (greedy count-forward with threshold-gated resyncs) and label_bar_position_global() (whole-track maximum-likelihood over the soft probabilities). See the latter’s docstring for why the greedy one loses information.

Functions

gate_periodicity(times[, tolerance, ...])

Clean up a raw peak-picked beat-time sequence using local periodicity.

label_bar_position(beat_times, one_probs, ...)

Assign each gated beat time a group position (1-group_size), or None if unresolved.

label_bar_position_global(beat_times, ...[, ...])

Assign each gated beat time a group position (1-group_size) by a global maximum-likelihood decode over the whole track.

phase_advances(beat_times[, ema_alpha, ...])

How many bar positions to advance across each consecutive pair of beats.

pick_peaks(probs[, threshold, min_distance])

Find local-maximum frame indices in a per-frame probability curve.

readout(beat_probs, one_probs, last_probs, fps)

End-to-end: per-frame probability curves -> a labeled beat list.

readout_beat_only(beat_probs, fps[, ...])

End-to-end: a per-frame beat probability curve -> beat timestamps.

gate_periodicity(times, tolerance=0.2, ema_alpha=0.2, bootstrap_window=8)[source]

Clean up a raw peak-picked beat-time sequence using local periodicity.

Beats are not independent events — they’re (locally) evenly spaced. This walks the raw timestamps in order, maintaining a running estimate of the current beat period, and uses it to both drop peaks that can’t be real beats and fill in beats the peak-picker missed. It is a light-weight, single-pass stand-in for full tempo tracking (a particle filter would be the principled version — see the beat-phase plan’s postprocessing section); it assumes the tempo is roughly constant within tolerance from one beat to the next, not that it’s constant across the whole track.

Algorithm, per candidate timestamp t (with last = the most recently accepted timestamp, and period = the current running period estimate, seeded from the median of the first bootstrap_window raw intervals):

  1. Compute ratio = (t - last) / period — how many beat-periods have elapsed since the last accepted beat.

  2. Too soon (``ratio < 1 - tolerance``): less than one period has elapsed. A real next beat can’t be this close — t is almost certainly a spurious duplicate (e.g. a loud snare hit inside the same beat re-triggering the detector). Drop it: don’t accept it, don’t move last, don’t touch period.

  3. Matches ``k`` periods (``round(ratio) = k`` and ``|ratio - k| <= tolerance``): the gap is consistent with exactly k beats’ worth of time.

    • k == 1: an ordinary, expected next beat. Accept t.

    • k >= 2: the peak-picker likely missed k - 1 beats in a row (e.g. a quiet passage where p_beat never crossed threshold). Rather than leave a hole, synthesize k - 1 evenly-spaced timestamps filling the gap (assuming constant tempo within this one gap, which is a much narrower assumption than constant tempo for the whole track), then accept t.

    • Either way, refresh period as an exponential moving average (weight ema_alpha) using the implied single-beat interval (t - last) / k — so tempo can drift slowly over a track without this gate’s expectation going stale, but no single noisy interval can swing it too far in one step.

  4. Ambiguous (fits no integer ``k`` within tolerance): doesn’t look like a clean duplicate or a clean multi-beat gap — e.g. the model’s timing was just imprecise on this one beat. Rather than guess, accept t as-is (never silently drop a real-looking detection on a best-effort heuristic) but leave period unchanged, since neither explanation was confident enough to learn from.

Parameters:
  • times (ndarray) – Raw peak timestamps (seconds), any order — sorted internally.

  • tolerance (float) – Relative slack around an integer multiple of the current period for a candidate to count as “matching” (e.g. 0.2 = ±20%).

  • ema_alpha (float) – Weight given to each new observed interval when updating the running period estimate. Higher = adapts faster to tempo drift, but is noisier.

  • bootstrap_window (int) – Number of leading raw intervals averaged (median) to seed the initial period estimate.

Returns:

Cleaned, sorted timestamps (seconds), including any gap-filled beats. Shape (n_out,) — not generally equal to the input length.

Return type:

ndarray

label_bar_position(beat_times, one_probs, last_probs, fps, anchor_threshold=0.5, group_size=4)[source]

Assign each gated beat time a group position (1-group_size), or None if unresolved.

Each beat casts an “anchor vote” by sampling one_probs/last_probs at its nearest frame: a confident vote for position 1 or group_size if the corresponding probability clears anchor_threshold and beats the other; no vote otherwise (expected for the majority of beats — the in-between positions have no dedicated head). Positions are then assigned by counting forward from confident votes (1, 2, …, group_size, 1, 2, …), with every new confident vote resyncing the count — so a stretch of ambiguous beats between two anchors is still labeled by counting, but the moment a strong anchor disagrees with where counting drifted to, the anchor wins. Beats before the first confident anchor are left unresolved (None).

Because a resync only happens on a confident vote, anchor_threshold is a two-sided tuning knob, not a “higher is always safer” one: too low, and a noisy false-positive vote (a probability bump on a beat that isn’t actually the anchor position) triggers a spurious resync that corrupts every beat until the next vote — one bad anchor wrecks a long stretch, not just itself. Too high, and even genuine anchors stop clearing the bar, so the count never resyncs (or never starts) and beats go unresolved instead. Empirically this gives a real interior optimum rather than a monotonic curve — see tools/eval_beat.py --sweep, which grid-searches this against real F-measure.

Parameters:
  • beat_times (ndarray) – Gated beat timestamps (seconds), sorted.

  • one_probs (ndarray) – Per-frame “one” probability curve, shape (T,).

  • last_probs (ndarray) – Per-frame “last” (position group_size) probability curve, shape (T,).

  • fps (float) – Frames per second (sample_rate / hop_length), used to map a beat time to its nearest frame.

  • anchor_threshold (float) – Minimum probability for a beat to be trusted as a confident 1/group_size anchor. Non-monotonic optimum — see the tradeoff above.

  • group_size (int) – Number of positions per group — 4 for bar position (default), 8 for phrase position.

Returns:

One entry per beat time: 1..``group_size``, or None.

Return type:

list[int | None]

label_bar_position_global(beat_times, one_probs, last_probs, fps, group_size=4, switch_penalty=None, advance='index', position_probs=None, eps=1e-06)[source]

Assign each gated beat time a group position (1-group_size) by a global maximum-likelihood decode over the whole track.

Drop-in alternative to label_bar_position(), which walks the beats once and resyncs its counter on any single above-threshold vote. That greedy rule has two structural weaknesses this function removes:

  • It only listens to beats whose probability clears anchor_threshold, throwing away every softer piece of evidence (p_one=0.45 vs p_last=0.15 says something, and is ignored).

  • One false anchor resyncs the count and corrupts every beat after it until the next vote, and nothing can revise it in hindsight.

Here, every beat contributes its full soft evidence and the phase is chosen to maximize the total log-likelihood over the entire track.

Emission model. Sampling one_probs/last_probs at each beat’s nearest frame, the log-likelihood of that beat occupying position p is

\[\begin{split}\ell(p) = \begin{cases} \log \hat{o} + \log(1 - \hat{l}) & p = 1 \\ \log \hat{l} + \log(1 - \hat{o}) & p = G \\ \log(1 - \hat{o}) + \log(1 - \hat{l}) & \text{otherwise} \end{cases}\end{split}\]

Positions strictly between 1 and group_size are indistinguishable under this model — correct, since there is no head that predicts them.

Decoding. With switch_penalty=None the position is assumed to advance +1 mod group_size at every beat with no exceptions, so the entire track’s labeling is determined by a single phase offset. Scoring all group_size offsets and taking the argmax is then an exact global maximum-likelihood decode, in O(group_size x n_beats). Up to a constant that is the same for every offset, that score is just

\[\text{score}(o) = \sum_{i \in \text{pos } 1} \log \frac{\hat{o}_i}{1 - \hat{o}_i} + \sum_{i \in \text{pos } G} \log \frac{\hat{l}_i}{1 - \hat{l}_i}\]

— i.e. “line the bar grid up wherever the accumulated log-odds are highest”, which is the whole idea.

Setting switch_penalty to a finite value instead runs a Viterbi decode that may break the +1 rule mid-track at a fixed cost in log-units, for tracks with a genuine meter change, a pickup bar, or a stretch of beats the gate synthesized in the wrong place. Small values resync eagerly (approaching label_bar_position()’s failure mode); large values approach the single-offset decode above.

Note this returns an int for every beat — unlike label_bar_position(), there is no None, because a global decode always has a best hypothesis and no notion of “haven’t seen an anchor yet”.

Parameters:
  • beat_times (ndarray) – Gated beat timestamps (seconds), sorted.

  • one_probs (ndarray) – Per-frame “one” probability curve, shape (T,).

  • last_probs (ndarray) – Per-frame “last” (position group_size) probability curve, shape (T,).

  • fps (float) – Frames per second (sample_rate / hop_length), used to map a beat time to its nearest frame.

  • group_size (int) – Number of positions per group — 4 for bar position (default), 8 for phrase position. Must be >= 2.

  • switch_penalty (float | None) – Log-likelihood cost of deviating from the expected advance at one beat. None (default) forbids it entirely, reducing the decode to the exact single-offset argmax.

  • position_probs (ndarray | None) – Optional (group_size, T) per-frame distribution over bar positions, from a softmax-head checkpoint (see musicality.losses.beat_position.beat_position_loss()). When given it replaces the emission model built from one_probs/last_probs below — the model supplies the full group_size-way distribution directly, so there is nothing to infer and positions 2..G-1 stop being indistinguishable. one_probs/last_probs are ignored.

  • advance (str) – How many positions each beat moves the count on. "index" (default) moves exactly one position per detected beat. "time" derives it from the elapsed time via phase_advances(), so a missed or spurious detection no longer shifts the bar grid for the rest of the track.

  • eps (float) – Probabilities are clipped to [eps, 1 - eps] before the log, so a saturated 0.0 or 1.0 can’t contribute an infinite score.

Returns:

One entry per beat time, each 1..``group_size``.

Return type:

list[int]

phase_advances(beat_times, ema_alpha=0.2, bootstrap_window=8)[source]

How many bar positions to advance across each consecutive pair of beats.

label_bar_position_global() currently assumes the bar position moves exactly +1 at every detected beat. That is only true when the beat list is perfect. A beat the detector missed leaves a two-period gap, and the count silently slips by one for the rest of the track; a spurious extra detection slips it the other way. Measured on real tracks, this — not the labeller — is what makes the predicted phase unstable within a track (see docs/switch_penalty_explained.md).

This function replaces “one beat, one position” with “however much time elapsed”: a gap of roughly two beat periods advances two positions, a near-zero gap advances none

Contract

  • Returns a non-negative integer array of length max(len(beat_times) - 1, 0). Entry i is how many positions to move from beat_times[i] to beat_times[i + 1].

  • Evenly spaced beats give all ones — identical to today’s behaviour.

  • A gap of k beat periods gives k.

  • A near-duplicate detection (gap far below one period) gives 0, so a spurious beat consumes no position.

  • Tempo is allowed to drift over a track, so the period estimate must be local, not one constant fitted to the whole sequence. There is a test that fails if you use a single global median.

Parameters:
  • beat_times (ndarray) – Gated beat timestamps (seconds), sorted.

  • ema_alpha (float) – Weight given to each newly observed interval when updating the running period estimate. Higher adapts faster to drift but is noisier.

  • bootstrap_window (int) – Number of leading intervals used to seed the initial period estimate.

Returns:

Per-gap position advances, shape (len(beat_times) - 1,).

Return type:

ndarray

Note

gate_periodicity() in this module already solves a closely related problem — maintaining a running period estimate over a noisy beat list and deciding how many beats a gap represents. Read it before writing this; the same shape of reasoning applies, and reusing its conventions (EMA weight, bootstrap median) keeps the two consistent.

pick_peaks(probs, threshold=0.3, min_distance=1)[source]

Find local-maximum frame indices in a per-frame probability curve.

Because training targets are Gaussian-smeared (see musicality.loaders.beat_dataset.gaussian_smear()), a real event shows up as a bump spanning several frames, not a single spike. Thresholding alone would return every frame in that bump; this finds the single frame at each bump’s summit instead.

Parameters:
  • probs (ndarray) – Per-frame probability curve, shape (T,).

  • threshold (float) – Minimum probability to be considered a peak at all.

  • min_distance (int) – Minimum frame gap enforced between returned peaks. When two candidate peaks are closer than this, only the higher one is kept (greedy non-max suppression) — a cheap safety net against multiple noisy local maxima inside one bump.

Returns:

Sorted frame indices of accepted peaks, shape (n_peaks,).

Return type:

ndarray

readout(beat_probs, one_probs, last_probs, fps, beat_threshold=0.3, min_distance_frames=1, gate_tolerance=0.2, anchor_threshold=0.5, group_size=4, decoder='greedy', switch_penalty=None, advance='index', position_probs=None)[source]

End-to-end: per-frame probability curves -> a labeled beat list.

Composes pick_peaks() on beat_probs, gate_periodicity() on the resulting timestamps, then label_bar_position() using one_probs/last_probs.

Parameters:
  • beat_probs (ndarray) – Per-frame beat probability curve, shape (T,).

  • one_probs (ndarray) – Per-frame “one” probability curve, shape (T,).

  • last_probs (ndarray) – Per-frame “last” (position group_size) probability curve, shape (T,).

  • fps (float) – Frames per second (sample_rate / hop_length).

  • beat_threshold (float) – Passed to pick_peaks().

  • min_distance_frames (int) – Passed to pick_peaks().

  • gate_tolerance (float) – Passed to gate_periodicity() as tolerance.

  • anchor_threshold (float) – Passed to label_bar_position().

  • group_size (int) – Passed to the bar-position stage — 4 for bar position (default), 8 for phrase position.

  • decoder (str) – Which bar-position stage to run — "greedy" (label_bar_position(), the default, count-forward with threshold-gated resyncs) or "global" (label_bar_position_global(), whole-track maximum-likelihood). anchor_threshold is unused by "global", and switch_penalty is unused by "greedy".

  • switch_penalty (float | None) – Passed to label_bar_position_global(). Only used when decoder="global".

  • advance (str) – Passed to label_bar_position_global(). Only used when decoder="global".

  • position_probs (ndarray | None) – Passed to label_bar_position_global(). Only used when decoder="global".

Returns:

One dict per detected beat, sorted by time: {"time": float, "beat_in_bar": int | None}.

Return type:

list[dict]

readout_beat_only(beat_probs, fps, beat_threshold=0.3, min_distance_frames=1, gate_tolerance=0.2)[source]

End-to-end: a per-frame beat probability curve -> beat timestamps.

Same first two stages as readout() (pick_peaks() then gate_periodicity()), minus the bar-position labeling step — that needs “one”/”last” probability curves, which a beat-only model doesn’t produce.

Parameters:
  • beat_probs (ndarray) – Per-frame beat probability curve, shape (T,).

  • fps (float) – Frames per second (sample_rate / hop_length).

  • beat_threshold (float) – Passed to pick_peaks().

  • min_distance_frames (int) – Passed to pick_peaks().

  • gate_tolerance (float) – Passed to gate_periodicity() as tolerance.

Returns:

Sorted beat timestamps (seconds).

Return type:

ndarray