File size: 16,405 Bytes
e855057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dba3cba
 
e855057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cc3020d
 
 
 
 
 
 
 
 
 
 
e855057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Improve a MuScriptor transcription without touching the weights.

The model's own output is the starting point: it gets the notes broadly right,
then loses on three things that are visible in any difficult passage.

  1. **Timing drift.** Onsets land on the analysis hop grid, so fast passages
     smear by tens of milliseconds.
  2. **Ghost notes.** In dense or noisy passages the decoder emits notes at
     moments where nothing was struck.
  3. **Dropped notes.** Complex, blurred or quiet passages come back with gaps —
     the onset is in the audio, the note is missing from the MIDI.

All three are measurable in the audio itself, at the onsets the model already
committed to, so they can be repaired without a GPU and without retraining:
onsets are snapped to the spectral-flux peak, ghosts are dropped where the
onset has no energy, near-duplicates are merged, and gaps are filled only where
a confident pitch estimate exists.

Usage:
    python3 transcript_fix.py song.wav notes.mid out.mid
    python3 transcript_fix.py --selftest
"""

from __future__ import annotations

__version__ = "1.1"

import math
import os
import sys

import numpy as np

from muscriptor_dynamics import load_audio, measure, read_midi, write_midi

FRAME_S = 0.010
SNAP_WINDOW_S = 0.070        # how far an onset may be moved to meet a real attack
GHOST_MARGIN_DB = 9.0        # a note this far under the piece's floor is a ghost
DUPLICATE_WINDOW_S = 0.045   # same pitch this close is one note, not two
FILL_WINDOW_S = 0.085        # an onset with no note this close is a gap
FILL_MIN_CONFIDENCE = 0.45   # autocorrelation peak ratio needed to trust a pitch


# --------------------------------------------------------------------------- #
# onset detection
# --------------------------------------------------------------------------- #
def onset_envelope(samples, rate):
    """Spectral flux: positive frame-to-frame change in magnitude.

    A struck note raises every harmonic at once, so the frames where the
    magnitude jumps are the frames where something began — which is exactly what
    the model's note onsets should coincide with.
    """
    hop = max(1, int(FRAME_S * rate))
    win = 4 * hop
    if len(samples) < win + hop:
        return np.zeros(1), hop / rate
    frames = np.lib.stride_tricks.sliding_window_view(samples, win)[::hop]
    window = np.hanning(win).astype(np.float32)
    magnitude = np.abs(np.fft.rfft(frames * window, axis=1))
    flux = np.diff(magnitude, axis=0).clip(min=0).sum(axis=1)
    flux = np.concatenate([[0.0], flux])
    if flux.max() > 0:
        flux = flux / flux.max()
    return flux, hop / rate


def pick_onsets(flux, frame_s, min_gap=0.040):
    """Local maxima of the flux, with an adaptive floor."""
    if len(flux) < 3:
        return []
    floor = float(np.percentile(flux, 82))
    peaks = []
    for i in range(1, len(flux) - 1):
        if flux[i] < floor or flux[i] < flux[i - 1] or flux[i] < flux[i + 1]:
            continue
        time = i * frame_s
        if peaks and time - peaks[-1][0] < min_gap:
            if flux[i] > peaks[-1][1]:
                peaks[-1] = (time, float(flux[i]))
            continue
        peaks.append((time, float(flux[i])))
    return peaks


def refine_onset(samples, rate, time, radius=0.06):
    """Where the attack really starts, to about a millisecond.

    The flux peak is honest but late-frame: the magnitude jumps when the attack
    enters the analysis window, so its position lags the attack by roughly the
    window length. The energy derivative inside a small neighbourhood has no such
    lag, so the coarse peak is snapped to the first point where the local energy
    lifts off.
    """
    first = int(max(0, (time - radius) * rate))
    last = int(min(len(samples), (time + radius) * rate))
    if last - first < 32:
        return time
    chunk = samples[first:last]
    hop = max(1, int(0.001 * rate))
    win = max(8, int(0.006 * rate))
    if chunk.size < win + hop:
        return time
    frames = np.lib.stride_tricks.sliding_window_view(chunk, win)[::hop]
    energy = (frames * frames).mean(axis=1)
    peak = float(energy.max())
    if peak <= 0:
        return time
    # The attack is the sharpest *rise*, not the first loud frame: a window that
    # opens inside the previous note's decay starts loud and would drag the note
    # backwards.
    rise = np.diff(energy)
    if not rise.size:
        return time
    return first / rate + (int(np.argmax(rise)) + 1) * hop / rate

def estimate_pitch(chunk, rate):
    """Fundamental frequency by autocorrelation, with a confidence in 0..1.

    Used only to fill gaps the model left. A wrong pitch is worse than a missing
    note, so the caller keeps additions only when the periodic peak is strong.
    """
    if chunk.size < int(0.02 * rate):
        return None, 0.0
    signal = chunk - float(chunk.mean())
    energy = float(np.dot(signal, signal))
    if energy <= 1e-7:
        return None, 0.0
    corr = np.correlate(signal, signal, "full")[signal.size - 1:]
    low = max(2, int(rate / 2000.0))
    high = min(corr.size - 1, int(rate / 55.0))
    if high <= low + 1:
        return None, 0.0
    segment = corr[low:high]
    strongest = float(segment.max())
    if strongest <= 0 or corr[0] <= 0:
        return None, 0.0
    # The tallest peak is often a multiple of the period, which reports the note
    # an octave or two down. The first lag that reaches most of that height is
    # the period itself.
    lag = low + int(np.argmax(segment >= 0.85 * strongest))
    return rate / lag, max(0.0, min(1.0, strongest / corr[0]))


# --------------------------------------------------------------------------- #
# the repair
# --------------------------------------------------------------------------- #
def improve(notes, samples, rate, on_progress=None, on_stage=None):
    """Repair a transcription. Returns (notes, report).

    A note carries (start, dur, pitch, channel, velocity). Every stage below is
    one pass over the notes or over the onset list, so nothing here loops
    unboundedly.
    """
    def stage(percent, message):
        if on_stage:
            on_stage(percent, message)

    report = {"before": len(notes)}
    if not notes:
        return [], report

    stage(10, "детектирую онсеты в аудио")
    flux, frame_s = onset_envelope(samples, rate)
    onsets = [(refine_onset(samples, rate, time), strength)
              for time, strength in pick_onsets(flux, frame_s)]
    report["onsets"] = len(onsets)

    stage(25, "измеряю уровни нот")
    velocities = measure([note[:4] for note in notes], samples, rate, on_progress)
    levels = np.zeros(len(notes))
    for index, note in enumerate(notes):
        start = note[0]
        first = int(max(0, round(start * rate)))
        span = max(1, int(0.06 * rate))
        chunk = samples[first:first + span]
        levels[index] = 20 * math.log10(float(np.sqrt(np.mean(chunk * chunk)) + 1e-9)) if chunk.size else -120.0

    stage(45, "уточняю онсеты по атакам")
    # Each note is refined in place rather than moved to a nearby detected peak:
    # in a fast passage the neighbour's attack is also within the window, and
    # jumping to it swaps one error for a worse one.
    snapped = 0
    for index, note in enumerate(notes):
        start, dur, pitch, channel = note[:4]
        refined = refine_onset(samples, rate, start, radius=0.05)
        if abs(refined - start) > 1e-4:
            snapped += 1
        notes[index] = (refined, dur, pitch, channel, velocities[index])
    report["snapped"] = snapped

    stage(60, "убираю фантомные ноты")
    floor = float(np.percentile(levels, 12))
    kept = []
    dropped = 0
    for index, note in enumerate(notes):
        if levels[index] < floor - GHOST_MARGIN_DB:
            dropped += 1
            continue
        kept.append(note)
    notes = kept
    report["ghosts_removed"] = dropped

    stage(72, "склеиваю дубликаты")
    notes.sort(key=lambda n: (n[0], n[2]))
    merged = []
    removed = 0
    for note in notes:
        if merged and note[2] == merged[-1][2] and abs(note[0] - merged[-1][0]) < DUPLICATE_WINDOW_S:
            previous = merged[-1]
            if note[1] > previous[1]:
                merged[-1] = (previous[0], note[1], previous[2], previous[3], max(previous[4], note[4]))
            removed += 1
            continue
        merged.append(note)
    notes = merged
    report["duplicates_merged"] = removed

    stage(85, "добираю пропущенные ноты")
    # Filling is the riskiest stage: a wrong pitch is worse than a missing note.
    # Three guards keep it honest — a real onset, a confident autocorrelation, and
    # a pitch that this piece actually uses, since a performer repeats their own
    # material and noise does not. The count is capped so a noisy file cannot
    # produce more notes than it had.
    piece_pitches = sorted({note[2] for note in notes})
    budget = max(1, len(notes) // 3)
    added = 0
    window = int(0.09 * rate)
    for time, strength in onsets:
        if added >= budget:
            break
        if strength < 0.30:
            continue
        if any(abs(note[0] - time) < FILL_WINDOW_S for note in notes):
            continue
        first = int(max(0, round(time * rate)))
        chunk = samples[first:first + window]
        if chunk.size < 32:
            continue
        freq, confidence = estimate_pitch(chunk, rate)
        if freq is None or confidence < 0.60:
            continue
        pitch = int(round(69 + 12 * math.log2(freq / 440.0)))
        if piece_pitches:
            nearest = min(piece_pitches, key=lambda candidate: abs(candidate - pitch))
            if abs(nearest - pitch) > 2:
                continue
            pitch = nearest
        if not 21 <= pitch <= 108:
            continue
        level = 20 * math.log10(float(np.sqrt(np.mean(chunk * chunk)) + 1e-9))
        share = min(1.0, max(0.0, (level - floor) / max(6.0, 24.0)))
        notes.append((time, 0.18, pitch, 0, int(round(20 + share * 100))))
        added += 1
    notes.sort(key=lambda n: n[0])
    # A filled note can land on top of one that was just snapped onto the same
    # attack, which is one note heard twice. Sweep again before reporting.
    deduped = []
    for note in notes:
        if deduped and note[2] == deduped[-1][2] and abs(note[0] - deduped[-1][0]) < DUPLICATE_WINDOW_S:
            if note[1] > deduped[-1][1]:
                deduped[-1] = (deduped[-1][0], note[1], note[2], note[3], max(deduped[-1][4], note[4]))
            continue
        deduped.append(note)
    report["filled"] = added - (len(notes) - len(deduped))
    notes = deduped
    report["after"] = len(notes)

    stage(95, "готово")
    return notes, report


# --------------------------------------------------------------------------- #
# scoring
# --------------------------------------------------------------------------- #
def score(reference, candidate, tolerance=0.05):
    """Precision, recall, F1 and mean timing error, matching one-to-one."""
    used = [False] * len(candidate)
    matched = 0
    errors = []
    for start, _dur, pitch, *_rest in reference:
        best = None
        for index, other in enumerate(candidate):
            if used[index] or other[2] != pitch:
                continue
            delta = abs(other[0] - start)
            if delta <= tolerance and (best is None or delta < best[0]):
                best = (delta, index)
        if best is not None:
            used[best[1]] = True
            matched += 1
            errors.append(best[0])
    precision = matched / max(1, len(candidate))
    recall = matched / max(1, len(reference))
    f1 = 2 * precision * recall / max(1e-9, precision + recall)
    return {
        "precision": precision,
        "recall": recall,
        "f1": f1,
        "mean_error_ms": 1000.0 * float(np.mean(errors)) if errors else float("nan"),
        "matched": matched,
    }


def _synth(rate, notes):
    total = int(rate * (max(n[0] + n[1] for n in notes) + 0.3))
    out = np.zeros(total, dtype=np.float32)
    for start, dur, pitch, amplitude in notes:
        freq = 440.0 * 2 ** ((pitch - 69) / 12)
        length = int(rate * dur)
        t = np.arange(length) / rate
        tone = sum((1.0 / k ** 1.2) * np.sin(2 * math.pi * freq * k * t) for k in (1, 2, 3, 4, 5))
        tone *= np.exp(-4.0 * t) * amplitude
        first = int(start * rate)
        out[first:first + length] += tone.astype(np.float32)
    peak = float(np.max(np.abs(out))) or 1.0
    return np.clip(out / peak * 0.95, -1, 1)


def selftest():
    rate = 22050
    # ground truth: a fast passage with a wide dynamic range, including a quiet
    # note and two notes only 90 ms apart, which is where transcription struggles
    truth = [
        (0.00, 0.22, 60, 0.55), (0.32, 0.22, 64, 0.50), (0.64, 0.22, 67, 0.45),
        (0.96, 0.22, 62, 0.24), (1.28, 0.22, 65, 0.52), (1.60, 0.22, 69, 0.48),
        (1.92, 0.22, 71, 0.14), (2.24, 0.22, 72, 0.50), (2.56, 0.22, 74, 0.46),
        (2.88, 0.22, 67, 0.22), (3.20, 0.22, 69, 0.50), (3.52, 0.22, 72, 0.44),
    ]
    samples = _synth(rate, truth)
    reference = [(start, dur, pitch, 0, 90) for start, dur, pitch, _amp in truth]

    # what a model does to this: onsets on a 20 ms grid, two notes lost in the
    # fast run, one ghost in a gap, and one note doubled
    rng = np.random.default_rng(7)
    degraded = []
    for index, (start, dur, pitch, _channel, _vel) in enumerate(reference):
        if index in (3, 6, 9):                   # lost notes, three quiet ones among them
            continue
        jitter = 0.02 * (index % 3 - 1) + rng.normal(0, 0.004)
        degraded.append((max(0.0, start + jitter), dur, pitch, 0, 64))
    degraded.append((3.95, 0.2, 66, 0, 64))     # ghost, in real silence
    degraded.append((0.11, 0.22, 64, 0, 64))    # duplicate of the second note
    degraded.sort()

    for probe in (0.96, 1.92):
        first = int(probe * rate)
        freq, confidence = estimate_pitch(samples[first:first + int(0.09 * rate)], rate)
        print("  зонд %.2f с: f0=%.0f Гц (pitch %d), conf %.2f"
              % (probe, freq or 0, round(69 + 12 * math.log2((freq or 440) / 440)), confidence), flush=True)
    before = score(reference, degraded)
    print("до правки:  P=%.2f R=%.2f F1=%.2f, ошибка онсета %.0f мс, нот %d"
          % (before["precision"], before["recall"], before["f1"],
             before["mean_error_ms"], len(degraded)), flush=True)

    improved, report = improve(degraded, samples, rate,
                               on_stage=lambda p, m: print("  [%3d%%] %s" % (p, m), flush=True))
    after = score(reference, improved)
    print("после:      P=%.2f R=%.2f F1=%.2f, ошибка онсета %.0f мс, нот %d"
          % (after["precision"], after["recall"], after["f1"],
             after["mean_error_ms"], len(improved)), flush=True)
    print("отчёт: %s" % report, flush=True)

    assert after["mean_error_ms"] < before["mean_error_ms"] * 0.6, "тайминг не улучшился"
    assert report["ghosts_removed"] >= 1, "фантом не убран"
    assert report["snapped"] >= len(degraded) * 0.6, "онсеты почти не уточнились"
    assert after["f1"] >= before["f1"] - 0.05, "F1 просел больше чем на 0.05"
    print("selftest: OK", flush=True)


def run(audio_path, midi_path, out_path):
    print("[  0%] читаю аудио и MIDI", flush=True)
    samples, rate = load_audio(audio_path)
    notes, tempo, _division = read_midi(midi_path)
    bpm = 60000000.0 / tempo[0][1] if tempo else 120.0
    print("[ 10%] нот во входе: %d" % len(notes), flush=True)
    improved, report = improve(notes, samples, rate,
                               on_stage=lambda p, m: print("[%3d%%] %s" % (p, m), flush=True))
    write_midi(out_path, improved, bpm)
    print("[100%] записан %s: %s" % (out_path, report), flush=True)
    return improved


if __name__ == "__main__":
    if "--selftest" in sys.argv:
        selftest()
    elif len(sys.argv) == 4:
        run(sys.argv[1], sys.argv[2], sys.argv[3])
    else:
        print(__doc__)
        raise SystemExit(2)