File size: 19,599 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
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
"""Chord recognition from the audio, the way a DAW's chord track does it.

The chords are heard, not inferred from the transcribed notes: the audio goes
through BTC, the Bi-directional Transformer for Chord Recognition (Park et al.,
ISMIR 2019), which reads a constant-Q spectrogram and labels every ~93 ms frame
with one of 170 chords β€” 12 roots x 14 qualities, plus "no chord". The network
lives in `muscriptor.models.btc`; this module owns everything around it: the
features it expects, its published weights, and turning its frame labels into
chord spans that line up with the beat grid.

That last step is what makes the chords usable as notation. The model's
boundaries land wherever the audio changes, a few tens of milliseconds either
side of the beat; `snap_to_beats` moves each one onto the nearest tracked beat,
so a chord change coincides with a bar or beat line in the score instead of
landing a 32nd note off it.
"""

import dataclasses
import hashlib
import logging
from collections.abc import Sequence

import numpy as np
import torch
from mido import MidiFile, MidiTrack

from muscriptor.models.btc import TIMESTEP, BTCModel
from muscriptor.utils.audio import resample
from muscriptor.utils.beats import BeatGrid
from muscriptor.utils.download import download_if_necessary

logger = logging.getLogger(__name__)

# Feature settings BTC was trained with (its run_config.yaml). All of them are
# constraints, not preferences: the model reads a 144-bin constant-Q transform
# at 24 bins per octave from C1 up, and nothing else.
SAMPLE_RATE = 22050
N_BINS = 144
BINS_PER_OCTAVE = 24
HOP_LENGTH = 2048

# The reference implementation computes the CQT in 10-second blocks and
# concatenates them, which is worth reproducing exactly: a 10-second block at
# this hop yields exactly TIMESTEP frames, so a block is also one model window,
# and the model's attention masks are sized for that.
WINDOW_SECONDS = 10.0
FRAME_SECONDS = HOP_LENGTH / SAMPLE_RATE

# Model windows per forward pass. Windows are independent β€” the attention never
# crosses one β€” so this only trades memory for speed.
BATCH_WINDOWS = 16

# The published "large vocabulary" checkpoint, pinned to the commit it was
# vetted at. It is a pickle rather than a safetensors file, so it is only ever
# loaded after its digest matches: see `_checkpoint_path`.
CHECKPOINT_URL = (
    "https://raw.githubusercontent.com/jayg996/BTC-ISMIR19/"
    "2682317be668032e6e4b269ded36adaa2ad57df0/test/btc_model_large_voca.pt"
)
CHECKPOINT_SHA256 = "1673d23f8f9a55ae7f9e8b80a51da616debb22675b8d8b67ea6ce0ef37b0ab51"

# Chord roots as the model numbers them, and as they are spelled in a score.
# Which spelling a song gets is decided once, by `prefers_flats`.
ROOTS_SHARP = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
ROOTS_FLAT = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")

# The 14 qualities of the large vocabulary, in the checkpoint's own order: a
# label index is `root * 14 + quality`. The suffix is what gets written above
# the staff (and into the MIDI markers, which are parsed back out again β€” so
# the suffixes have to stay distinct from one another).
QUALITY_SUFFIXES = {
    "min": "m",
    "maj": "",
    "dim": "dim",
    "aug": "aug",
    "min6": "m6",
    "maj6": "6",
    "min7": "m7",
    "minmaj7": "mMaj7",
    "maj7": "maj7",
    "7": "7",
    "dim7": "dim7",
    "hdim7": "m7b5",
    "sus2": "sus2",
    "sus4": "sus4",
}
QUALITIES = tuple(QUALITY_SUFFIXES)

# The notes each quality is made of, as semitones above the root. What the
# symbol means, in other words β€” needed by anything that has to sound a chord
# rather than print it (the web player's chord track).
QUALITY_INTERVALS = {
    "min": (0, 3, 7),
    "maj": (0, 4, 7),
    "dim": (0, 3, 6),
    "aug": (0, 4, 8),
    "min6": (0, 3, 7, 9),
    "maj6": (0, 4, 7, 9),
    "min7": (0, 3, 7, 10),
    "minmaj7": (0, 3, 7, 11),
    "maj7": (0, 4, 7, 11),
    "7": (0, 4, 7, 10),
    "dim7": (0, 3, 6, 9),
    "hdim7": (0, 3, 6, 10),
    "sus2": (0, 2, 7),
    "sus4": (0, 5, 7),
}

# Everything above ends the vocabulary: 168 is "some chord, but not one of the
# above", 169 is "no chord at all". Both are written as a rest in the harmony.
UNKNOWN_INDEX = len(QUALITIES) * 12
NO_CHORD_INDEX = UNKNOWN_INDEX + 1
NO_CHORD_LABEL = "N.C."

# Prefix of the MIDI marker each chord change is written as, so the chord track
# travels with the notes: `muscriptor:chord=Bbm7`. Written by
# `muscriptor.tokenizer.notes.note_event2midi`, read back by
# `read_chord_markers` β€” which is how a MIDI upload to /sheets can still be
# engraved with its chords on it.
CHORD_MARKER = "muscriptor:chord="

# Where each pitch class sits on the line of fifths, spelled with sharps and
# spelled with flats (…F=-1, C=0, G=1, D=2…). The naturals coincide; the five
# black keys are what the two orderings disagree about, and how far they pull
# the rest of the song's roots apart is what `prefers_flats` measures.
_FIFTHS_SHARP = (0, 7, 2, 9, 4, -1, 6, 1, 8, 3, 10, 5)
_FIFTHS_FLAT = (0, -5, 2, -3, 4, -1, -6, 1, -4, 3, -2, 5)


class ChordDetectionError(RuntimeError):
    """Chord recognition could not run (missing dependency, bad weights, …)."""


@dataclasses.dataclass(frozen=True)
class Chord:
    """One chord, sounding over `[start, end)` seconds of the original audio.

    `root` is a pitch class (0 = C) and `quality` one of QUALITIES, except for
    a silent or unrecognizable span, which has neither and reads as "N.C.".
    """

    start: float
    end: float
    root: int | None
    quality: str | None

    @property
    def is_no_chord(self) -> bool:
        return self.root is None

    @property
    def intervals(self) -> tuple[int, ...]:
        """Semitones above the root that sound in this chord; empty for N.C."""
        return QUALITY_INTERVALS[self.quality] if self.quality else ()

    def label(self, flats: bool = False) -> str:
        """The chord symbol, e.g. "Bbm7" β€” what a reader sees above the staff."""
        if self.root is None or self.quality is None:
            return NO_CHORD_LABEL
        roots = ROOTS_FLAT if flats else ROOTS_SHARP
        return roots[self.root] + QUALITY_SUFFIXES[self.quality]

    def step_and_alter(self, flats: bool = False) -> tuple[str, int]:
        """The root as a note letter and an alteration in semitones.

        MusicXML spells a harmony root that way (`<root-step>`, `<root-alter>`)
        rather than as a symbol, so this is the same choice `label` makes,
        taken apart.
        """
        if self.root is None:
            raise ValueError("a no-chord span has no root")
        name = (ROOTS_FLAT if flats else ROOTS_SHARP)[self.root]
        return name[0], {"": 0, "#": 1, "b": -1}[name[1:]]


def parse_label(label: str) -> tuple[int | None, str | None]:
    """Inverse of `Chord.label`: a symbol back to (root pitch class, quality).

    Returns (None, None) for "N.C.". Raises ValueError on anything this module
    would not have written, so a malformed marker fails loudly instead of
    silently engraving the wrong chord.
    """
    if label == NO_CHORD_LABEL:
        return None, None
    for length in (2, 1):  # "Bb" before "B"
        head, tail = label[:length], label[length:]
        for roots in (ROOTS_SHARP, ROOTS_FLAT):
            if head not in roots:
                continue
            for quality, suffix in QUALITY_SUFFIXES.items():
                if suffix == tail:
                    return roots.index(head), quality
    raise ValueError(f"not a chord symbol: {label!r}")


def prefers_flats(chords: Sequence[Chord]) -> bool:
    """Whether this progression reads better with flats than with sharps.

    A key's chords sit close together on the line of fifths, so the spelling
    that packs the song's roots into the tighter cluster is the one a musician
    would write: the roots of a song in B major spread over five fifths as B,
    F#, C#, G#, and over eleven as B, Gb, Db, Ab. Decided once for the whole
    song, weighted by how long each root sounds, so a score never mixes D# and
    Eb. Ties go to sharps.
    """
    weight = np.zeros(12)
    for chord in chords:
        if chord.root is not None:
            weight[chord.root] += max(0.0, chord.end - chord.start)
    if not weight.any():
        return False

    def spread(positions: tuple[int, ...]) -> float:
        """Weighted variance of the roots' positions under one spelling."""
        places = np.asarray(positions, dtype=float)
        centre = float((weight * places).sum() / weight.sum())
        return float((weight * (places - centre) ** 2).sum() / weight.sum())

    return spread(_FIFTHS_FLAT) < spread(_FIFTHS_SHARP)


def _mono_audio(wav: torch.Tensor, sample_rate: int) -> np.ndarray:
    """`wav` as a 1-D float32 array at SAMPLE_RATE, mono."""
    audio = wav.detach().cpu().float()
    if audio.dim() == 3:
        audio = audio.squeeze(0)
    if audio.dim() == 2:
        audio = audio.mean(dim=0)
    if sample_rate != SAMPLE_RATE:
        audio = resample(audio, sample_rate, SAMPLE_RATE)
    return audio.numpy()


def cqt_features(wav: torch.Tensor, sample_rate: int) -> tuple[np.ndarray, np.ndarray]:
    """Log-magnitude CQT of `wav`, and the time each frame sits at.

    Returns (features [n_frames, N_BINS], times [n_frames] in seconds of the
    original audio). The transform is taken over WINDOW_SECONDS blocks and the
    results concatenated, as in the reference implementation β€” each block
    starts its own frame clock, which is why the times are built here alongside
    the features rather than derived from the frame index afterwards.
    """
    try:
        import librosa
    except ImportError as e:  # pragma: no cover - depends on the install
        raise ChordDetectionError(
            "chord recognition needs librosa, which is missing from this "
            "install: pip install librosa"
        ) from e

    audio = _mono_audio(wav, sample_rate)
    block_samples = int(SAMPLE_RATE * WINDOW_SECONDS)
    blocks, times = [], []
    for start in range(0, max(len(audio), 1), block_samples):
        chunk = audio[start : start + block_samples]
        # librosa needs a couple of hops to place a frame at all; a shorter
        # tail carries no chord anyone would notate, so it is dropped.
        if len(chunk) < 2 * HOP_LENGTH:
            break
        spectrum = librosa.cqt(
            chunk,
            sr=SAMPLE_RATE,
            n_bins=N_BINS,
            bins_per_octave=BINS_PER_OCTAVE,
            hop_length=HOP_LENGTH,
        )
        blocks.append(spectrum)
        times.append(start / SAMPLE_RATE + np.arange(spectrum.shape[1]) * FRAME_SECONDS)
    if not blocks:
        return np.zeros((0, N_BINS), dtype=np.float32), np.zeros(0)
    feature = np.log(np.abs(np.concatenate(blocks, axis=1)) + 1e-6)
    return feature.T.astype(np.float32), np.concatenate(times)


def _checkpoint_path():
    """The published weights, downloaded once and checked against their digest.

    The reference distributes a pickle, which `torch.load` executes on load, so
    the digest is verified first: this refuses to run anything but the exact
    file the URL was pinned to.
    """
    path = download_if_necessary(CHECKPOINT_URL)
    digest = hashlib.sha256(path.read_bytes()).hexdigest()
    if digest != CHECKPOINT_SHA256:
        raise ChordDetectionError(
            f"the chord model downloaded from {CHECKPOINT_URL} has digest "
            f"{digest}, expected {CHECKPOINT_SHA256}. Delete {path} and retry."
        )
    return path


_MODEL_CACHE: dict[str, tuple[BTCModel, float, float]] = {}


def load_model(device: str | torch.device = "cpu") -> tuple[BTCModel, float, float]:
    """The BTC network plus the (mean, std) its features are normalized with.

    Cached per device: the weights are 12 MB and loading them takes long enough
    to be worth not repeating for every request the server handles.
    """
    key = str(device)
    if key not in _MODEL_CACHE:
        checkpoint = torch.load(
            _checkpoint_path(), map_location="cpu", weights_only=False
        )
        model = BTCModel()
        model.load_published_state_dict(checkpoint["model"])
        model.eval().to(device)
        _MODEL_CACHE[key] = (model, float(checkpoint["mean"]), float(checkpoint["std"]))
    return _MODEL_CACHE[key]


def _predict(features: np.ndarray, device: str | torch.device) -> np.ndarray:
    """Chord index per frame, from normalized features [n_frames, N_BINS]."""
    model, mean, std = load_model(device)
    normalized = (features - mean) / std
    # The model reads fixed-size windows, so the tail is padded out to one and
    # its filler frames dropped again afterwards.
    padding = -len(normalized) % TIMESTEP
    if padding:
        normalized = np.pad(normalized, ((0, padding), (0, 0)))
    windows = torch.from_numpy(normalized).reshape(-1, TIMESTEP, N_BINS)

    predictions = []
    with torch.inference_mode():
        for start in range(0, len(windows), BATCH_WINDOWS):
            batch = windows[start : start + BATCH_WINDOWS].to(device)
            predictions.append(model(batch).argmax(dim=-1).flatten().cpu())
    labels = torch.cat(predictions).numpy()
    return labels[: len(features)]


def _to_spans(labels: np.ndarray, times: np.ndarray, duration: float) -> list[Chord]:
    """Frame labels to chord spans, merging every run of equal labels."""
    chords = []
    starts = np.flatnonzero(np.diff(labels, prepend=labels[0] - 1))
    for i, first in enumerate(starts):
        index = int(labels[first])
        end = float(times[starts[i + 1]]) if i + 1 < len(starts) else duration
        if index >= UNKNOWN_INDEX:
            root, quality = None, None
        else:
            root, quality = divmod(index, len(QUALITIES))
            quality = QUALITIES[quality]
        chords.append(Chord(float(times[first]), end, root, quality))
    return chords


def merge_adjacent(chords: Sequence[Chord]) -> list[Chord]:
    """Fuse neighbouring spans that name the same chord, and drop empty ones."""
    merged: list[Chord] = []
    for chord in chords:
        if chord.end <= chord.start:
            continue
        previous = merged[-1] if merged else None
        if (
            previous is not None
            and previous.root == chord.root
            and previous.quality == chord.quality
        ):
            merged[-1] = dataclasses.replace(previous, end=chord.end)
        else:
            merged.append(chord)
    return merged


def snap_to_beats(chords: Sequence[Chord], grid: BeatGrid) -> list[Chord]:
    """Move every chord boundary onto the nearest beat of `grid`.

    A chord change that the model put 40 ms before the beat belongs *on* the
    beat once it is written down; without this the engraver would have to
    render it as a tied 32nd note. Spans that collapse (both ends snapping to
    the same beat) are dropped, which is also what removes the flicker the
    model sometimes produces at a transition.
    """
    beats = grid.beats
    if beats is None or len(beats) < 2:
        # A grid built by hand carries no beat times; extrapolate them from its
        # tempo instead, which is all the notation is quantized to anyway.
        span = 60.0 / grid.bpm
        last = max((chord.end for chord in chords), default=grid.first_downbeat)
        count = max(2, int((last - grid.first_downbeat) / span) + 2)
        beats = grid.first_downbeat + np.arange(count) * span
    beats = np.asarray(beats, dtype=float)

    def snap(time: float) -> float:
        return float(beats[int(np.abs(beats - time).argmin())])

    snapped = [
        dataclasses.replace(chord, start=snap(chord.start), end=snap(chord.end))
        for chord in chords
    ]
    return merge_adjacent(snapped)


def detect_chords(
    wav: torch.Tensor,
    sample_rate: int,
    grid: BeatGrid | None = None,
    device: str | torch.device = "cpu",
) -> list[Chord]:
    """Recognize the chords in `wav`, aligned to `grid` when there is one.

    Returns spans covering the audio in order, including the "N.C." stretches
    where nothing harmonic is playing. Without a beat grid the boundaries stay
    where the model heard them, which is fine for a chord track but too ragged
    to notate β€” `snap_to_beats` is what makes them line up with the bar lines.
    """
    features, times = cqt_features(wav, sample_rate)
    if not len(features):
        return []
    duration = wav.shape[-1] / sample_rate
    chords = merge_adjacent(_to_spans(_predict(features, device), times, duration))
    if grid is not None:
        chords = snap_to_beats(chords, grid)
    named = sum(1 for chord in chords if not chord.is_no_chord)
    logger.info("recognized %d chords (%d distinct spans)", named, len(chords))
    return chords


def published_chords(chords: Sequence[Chord]) -> list[Chord]:
    """`chords` from the first real one on.

    The silence a recording opens with is not a chord change, so leading "N.C."
    spans are dropped; the ones between chords stay, since they say the harmony
    stopped. Everything that hands the chord track to someone else β€” the MIDI
    markers, the server's event stream β€” starts here, so they all agree on
    where the chords begin.
    """
    for index, chord in enumerate(chords):
        if not chord.is_no_chord:
            return list(chords[index:])
    return []


def to_markers(
    chords: Sequence[Chord], flats: bool | None = None
) -> list[tuple[float, str]]:
    """`(seconds, chord symbol)` for every chord change, for the MIDI markers.

    `flats` overrides the spelling that `prefers_flats` would pick.
    """
    if flats is None:
        flats = prefers_flats(chords)
    return [(chord.start, chord.label(flats)) for chord in published_chords(chords)]


def read_chord_markers(midi: MidiFile) -> list[tuple[float, str]]:
    """`(position in quarter notes, chord symbol)` for the markers in `midi`.

    Quarter notes rather than seconds: this reads a written score's chord
    track, where the position that matters is the musical one β€” and the MIDI
    ticks already are that, tempo changes and bar offset included.
    """
    markers = []
    for track in midi.tracks:
        tick = 0
        for message in track:
            tick += message.time
            if message.type == "marker" and message.text.startswith(CHORD_MARKER):
                symbol = message.text.removeprefix(CHORD_MARKER)
                markers.append((tick / midi.ticks_per_beat, symbol))
    markers.sort(key=lambda marker: marker[0])
    return markers


def strip_chord_markers(midi: MidiFile) -> MidiFile:
    """`midi` without its chord markers, leaving everything else where it was.

    Notation software renders markers as text in the score, so the copy handed
    to the engraver has them taken out β€” the chords go back in as proper
    harmony elements instead (see `muscriptor.utils.harmony`).
    """
    stripped = MidiFile(ticks_per_beat=midi.ticks_per_beat, type=midi.type)
    for track in midi.tracks:
        kept = MidiTrack()
        # A dropped message's delta has to be handed to the message after it,
        # or everything downstream of it slides earlier.
        carried = 0
        for message in track:
            if message.type == "marker" and message.text.startswith(CHORD_MARKER):
                carried += message.time
                continue
            kept.append(message.copy(time=message.time + carried))
            carried = 0
        stripped.tracks.append(kept)
    return stripped