File size: 14,732 Bytes
e319a7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
"""Beat-grid detection, for writing real tempo and time signatures into MIDI."""

import dataclasses
import logging
import math
from collections.abc import Sequence
from typing import Literal

import numpy as np
import torch

logger = logging.getLogger(__name__)

# Used to detect songs that don't have a constant tempo (don't use a metronome)
MAX_TEMPO_RESIDUAL = 0.05

# Fraction of bars that must agree on a beats-per-bar count to write a time
# signature. Trackers that lose the meter spread their downbeats across several
# spacings, and a wrong time signature is worse than none.
MIN_METER_AGREEMENT = 0.9

MIN_BEATS = 8

# Marker text prefix recording how far notes were delayed to align bar lines,
# so `/auralize` can line the synthesis back up with the original audio.
BAR_OFFSET_MARKER = "muscriptor:bar_offset="

### Constants for estimate_onset_delay()

# Candidate grids, binary and triplet divisions of the beat, simplest first.
ONSET_SUBDIVISIONS = (1, 2, 3, 4, 6, 8, 12, 16, 24)

# Discard detections with a concentration lower than this. On our test set, 92% songs
# are above 0.5
MIN_ONSET_CONCENTRATION = 0.5

# |R| for n random angles is about 1/sqrt(n). With MIN_ONSETS = 40 the chance of
# passing the bar with a random distribution is about 1 in 5,000.
MIN_ONSETS = 40

# The largest delay this is meant to correct.
# Trade-off: we want to correct larger offsets if they happen, but a smaller value
# allows us to use smaller grids like 1/16th notes (depends also on the tempo).
# This is because if a grid has 20ms, we can't distinguish between a +15ms and -5ms
# offset since things are cyclical, unless we have a max offset of <15ms.
# With 0.04 we can do 16th notes up to 187 BPM.
MAX_ONSET_DELAY_S = 0.04

# Note onset times in seconds, however the caller happens to hold them.
Onsets = Sequence[float] | np.ndarray


class BeatDetectionError(RuntimeError):
    """No usable beat grid in the audio (too short, or no constant tempo)."""


# What to do when the tempo can't be detected: True raises, False doesn't even
# try (the escape hatch for songs the detector gets wrong), and "best-effort"
# warns and falls back to the placeholder tempo.
TempoDetection = bool | Literal["best-effort"]


def read_bar_offset(midi) -> float:
    """Seconds of bar-alignment delay recorded in a MidiFile, 0.0 if absent."""
    for track in midi.tracks:
        for msg in track:
            if msg.type == "marker" and msg.text.startswith(BAR_OFFSET_MARKER):
                try:
                    return float(msg.text.removeprefix(BAR_OFFSET_MARKER))
                except ValueError:
                    return 0.0
    return 0.0


@dataclasses.dataclass
class BeatGrid:
    """A constant-tempo beat grid detected from audio."""

    bpm: float
    # None when the meter could not be determined; write no time signature.
    beats_per_bar: int | None
    # Time of the first detected bar line, in seconds.
    first_downbeat: float
    # The individual beat times the grid was fitted to, kept for
    # `with_onset_delay`, which needs them rather than the fitted tempo: the
    # tracked beats follow the recording's small tempo wobbles, and those are the
    # same size as the offset being measured. None for a hand-built grid; kept
    # out of equality and repr, being an array and an implementation detail.
    beats: np.ndarray | None = dataclasses.field(
        default=None, repr=False, compare=False
    )
    # Seconds by which the transcribed onsets are late against these beats, or None
    # until some notes have been measured against the grid (see `with_onset_delay`).
    # Whoever writes the notes out subtracts it from them.
    onset_delay: float | None = None
    # Subdivisions of the beat to use for quantizing the onsets.
    beat_subdivision: int | None = None

    @property
    def bar_seconds(self) -> float | None:
        if self.beats_per_bar is None:
            return None
        return self.beats_per_bar * 60.0 / self.bpm

    def bar_offset(self, min_shift: float = 0.0) -> float:
        """Seconds to delay every note so bar lines land on downbeats.

        MIDI has no pickup measure: bar 1 starts at tick 0, so the only way to
        put a bar line on the first downbeat is to shift the music later. Always
        a forward shift, keeping ticks non-negative and dropping no notes; the
        leading partial bar holds whatever preceded the first downbeat.

        Whole bars are added until the shift reaches `min_shift`, so a caller
        that moved the notes earlier (by `onset_delay`) still gets non-negative
        ticks without having to squash the notes near the start.
        """
        step = self.bar_seconds
        if step is None:
            # No meter to align to, so the only reason to shift is the headroom;
            # do it in whole beats, which are all this grid has.
            step, offset = 60.0 / self.bpm, 0.0
        else:
            offset = (step - self.first_downbeat % step) % step
        if offset < min_shift:
            offset += step * math.ceil((min_shift - offset) / step)
        return offset

    def with_onset_delay(self, onsets: Onsets) -> "BeatGrid":
        """This grid with `onsets`' lag against it measured and filled in.

        Subtracting that lag from the note times moves them onto the beats. We
        trust the beats tracked by beat_this over the transcription - they're a bit
        more accurate based on our measurements. 0.0 when there is nothing to
        measure from; see estimate_onset_delay() for details.

        Measuring once and carrying the answer around is what keeps the MIDI file
        and the UI (which is told the same number) shifting by the same amount.
        """
        measured = None
        if self.beats is not None:
            measured = estimate_onset_delay(onsets, self)
        if measured is None:
            return dataclasses.replace(self, onset_delay=0.0, beat_subdivision=None)
        logger.info(
            "onsets sit %+.1f ms off a 1/%d-beat grid (|R| = %.2f over %d "
            "onsets); moving the notes back by that much",
            1000 * measured.seconds,
            measured.subdivision,
            measured.concentration,
            measured.n_onsets,
        )
        return dataclasses.replace(
            self, onset_delay=measured.seconds, beat_subdivision=measured.subdivision
        )


@dataclasses.dataclass(frozen=True)
class OnsetDelay:
    """How late a set of note onsets sits against the beat subdivision it is on."""

    # Signed seconds, positive when the onsets are late.
    seconds: float
    # Resultant length in [0, 1]: how tightly the onsets sit on the grid.
    concentration: float
    # Subdivisions per beat of the grid the delay is measured against.
    subdivision: int
    # Distinct onset times that went into it.
    n_onsets: int


def get_onsets_phase(onsets: Onsets, beats: np.ndarray) -> np.ndarray:
    """Onset times as positions in continuous beats.

    Onsets outside the tracked span drop out, since there is no beat to place
    them in. One entry per distinct onset time (to the millisecond) rather than
    per note, so a six-note chord does not outvote six single notes elsewhere.
    """
    times = np.unique(np.round(np.asarray(onsets, dtype=float), 3))
    inside = (times >= beats[0]) & (times <= beats[-1])
    return np.interp(times[inside], beats, np.arange(len(beats)))


def get_phase_with_subdivision(
    phase_beats: np.ndarray, subdivision: int
) -> tuple[float, float]:
    """Compute the average of unit vectors (phase_beats) on a circle.

    The angle tells us the mean offset, and the magnitude tells us how well they align
    (the concentration).
    """
    angles = 2 * np.pi * np.mod(phase_beats * subdivision, 1.0)
    mean = np.exp(1j * angles).mean()
    turns = 1 / (2 * np.pi * subdivision)  # radians on the fine grid → beats
    return float(np.abs(mean)), float(np.angle(mean)) * turns


def estimate_onset_delay(onsets: Onsets, grid: "BeatGrid") -> OnsetDelay | None:
    """How late `onsets` sit against the beat subdivision they are on.

    This is to correct for the fact that we observed that the detected onsets don't
    always align well with the beats.

    Algorithm: For each onset, plot it as a unit vector on a circle where the angle is
    its relative position in the beat. Then take the average of these vectors: the
    angle tells you the average offset and the magnitude says how well they align.
    In practice, the onsets are on a subdivision of the beat (e.g. 8th notes) so
    also repeat this for different subdivisions and pick the one where the notes align
    the best to read the alignment from.
    """
    if grid.beats is None or len(grid.beats) < 2:
        return None
    period_s = 60.0 / grid.bpm

    # The phases relative to full beats
    phase_beats = get_onsets_phase(onsets, grid.beats)
    if len(phase_beats) < MIN_ONSETS:
        logger.info(
            "not correcting the downbeat: %d onset time(s) on the tracked span, "
            "need %d",
            len(phase_beats),
            MIN_ONSETS,
        )
        return None

    candidates = [
        s for s in ONSET_SUBDIVISIONS if period_s / (2 * s) >= MAX_ONSET_DELAY_S
    ]
    if not candidates:
        # Should not happen with reasonable tempos but guard it anyway
        logger.info("not correcting the downbeat: no grid coarse enough to fit")
        return None

    scored = {s: get_phase_with_subdivision(phase_beats, s) for s in candidates}

    # Keep the subdivision with the highest concentration
    subdivision = max(scored, key=lambda s: scored[s][0])
    concentration, delay_beats = scored[subdivision]

    if concentration < MIN_ONSET_CONCENTRATION:
        logger.info(
            "not correcting the downbeat: onsets sit on no subdivision of the beat "
            "(best |R| = %.2f, need %.2f)",
            concentration,
            MIN_ONSET_CONCENTRATION,
        )
        return None

    delay_s = delay_beats * period_s
    if abs(delay_s) > MAX_ONSET_DELAY_S:
        logger.warning(
            "not correcting the downbeat: onsets measured %+.0f ms off a "
            "1/%d-beat grid, further than the %+.0f ms this can plausibly be",
            1000 * delay_s,
            subdivision,
            1000 * MAX_ONSET_DELAY_S,
        )
        return None

    return OnsetDelay(
        seconds=delay_s,
        concentration=concentration,
        subdivision=subdivision,
        n_onsets=len(phase_beats),
    )


def fit_tempo(beats: np.ndarray) -> tuple[float, float]:
    """Least-squares tempo over the beat sequence.

    Returns (bpm, residual RMS in seconds). Fitting a line through beat index
    against time beats taking the median inter-beat interval: trackers quantise
    beats to a frame grid (50 Hz for beat_this), which alone limits median-IBI
    tempo resolution to a few BPM.
    """
    index = np.arange(len(beats))
    slope, intercept = np.polyfit(index, beats, 1)
    residual = beats - (intercept + slope * index)
    return 60.0 / float(slope), float(residual.std())


def infer_beats_per_bar(
    beats: np.ndarray,
    downbeats: np.ndarray,
    min_agreement: float = MIN_METER_AGREEMENT,
) -> int | None:
    """Beats per bar from downbeat spacing, or None if the bars disagree.

    Only measures how far apart the downbeats are; it cannot tell whether the
    downbeats themselves are on the right beat. Note that a tracker that
    subdivides the bar wrongly (reporting two beats per bar for music in 3/4)
    can still be self-consistent here, which is why this stays conservative.
    """
    if len(downbeats) < 3 or len(beats) < 2:
        return None
    beat = float(np.median(np.diff(beats)))
    counts = np.round(np.diff(downbeats) / beat).astype(int)
    counts = counts[counts >= 2]
    if not len(counts):
        return None
    values, tally = np.unique(counts, return_counts=True)
    best = int(tally.argmax())
    if tally[best] / len(counts) < min_agreement:
        return None
    return int(values[best])


def detect_grid(
    wav: torch.Tensor, sr: int, checkpoint: str = "final0", device: str = "cpu"
) -> BeatGrid:
    """Detect a constant-tempo beat grid.

    Args:
        wav: Audio as [C, T] (this repo's convention), float32.
        sr: Sample rate of `wav`; beat_this resamples internally.
        checkpoint: beat_this checkpoint name. "final0" over "small0": the small
            model emits spurious beats before the first downbeat, which shifts
            the bar offset by a beat or two.
        device: Torch device for the beat model.

    Raises BeatDetectionError when the audio is too short or the beats do not
    fit a constant tempo. An unclear meter is not fatal: the BeatGrid comes back
    with beats_per_bar=None, since tempo alone is worth writing.
    """
    # Imported here, not at module scope: beat_this pulls in torchaudio and soxr,
    # which would slow every CLI invocation that never transcribes anything.
    from beat_this.inference import Audio2Beats

    # This triggers an error in beat_this so report as BeatDetectionError directly
    min_duration_s = 1.0
    if wav.shape[-1] < min_duration_s * sr:
        raise BeatDetectionError(
            f"Audio is {wav.shape[-1] / sr:.2f}s long, too short to detect a tempo"
        )

    signal = wav.mean(dim=0).detach().cpu().numpy()  # beat_this wants mono, 1-D
    # Returns (beats, downbeats) despite beat_this's own File2File unpacking
    # them the other way round.
    beats, downbeats = Audio2Beats(
        checkpoint_path=checkpoint, device=device, dbn=False
    )(signal, sr)

    beats = np.asarray(beats, dtype=float)
    downbeats = np.asarray(downbeats, dtype=float)
    if len(beats) < MIN_BEATS:
        raise BeatDetectionError(
            f"Only {len(beats)} beats detected, need at least {MIN_BEATS}"
        )

    bpm, residual = fit_tempo(beats)
    beat_seconds = 60.0 / bpm
    if residual > MAX_TEMPO_RESIDUAL * beat_seconds:
        raise BeatDetectionError(
            f"The recording has no fixed tempo (beats deviate {residual * 1000:.0f} ms "
            f"RMS from a constant {bpm:.1f} BPM)"
        )

    beats_per_bar = infer_beats_per_bar(beats, downbeats)
    first_downbeat = float(downbeats[0]) if len(downbeats) else float(beats[0])
    logger.info(
        "detected %.3f BPM, %s, first downbeat %.3fs (beat residual %.1f ms)",
        bpm,
        f"{beats_per_bar}/4" if beats_per_bar else "meter unknown",
        first_downbeat,
        residual * 1000,
    )
    return BeatGrid(
        bpm=bpm,
        beats_per_bar=beats_per_bar,
        first_downbeat=first_downbeat,
        beats=beats,
    )