Nokia123543 commited on
Commit
86363db
·
verified ·
1 Parent(s): 8adcdca

ensemble: merge_transcriptions.py

Browse files
Files changed (1) hide show
  1. merge_transcriptions.py +251 -0
merge_transcriptions.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Merge several MuScriptor passes over the same audio into one transcription.
3
+
4
+ A single pass of a transcription model makes two kinds of mistake, and they are
5
+ not the same mistake twice: one pass drops the blurry note in a fast run, another
6
+ pass invents a ghost in a reverb tail. Running the model a few times — different
7
+ sizes, different instrument conditioning — and keeping only what the passes agree
8
+ on removes both, because a real note is found again and a hallucination is not.
9
+
10
+ This is the one "global" improvement available without a GPU on the training
11
+ side: no weights change, no extra data, only a second opinion.
12
+
13
+ Voting rules:
14
+ * notes cluster by pitch and onset within `tolerance`;
15
+ * a cluster survives when at least `votes` distinct passes found it;
16
+ * its onset is the **median** of the passes, which is steadier than any one;
17
+ * velocity is measured from the audio at the agreed onset, since no pass
18
+ predicts loudness.
19
+
20
+ Usage:
21
+ python3 merge_transcriptions.py song.wav out.mid pass1.mid pass2.mid pass3.mid
22
+ python3 merge_transcriptions.py song.wav out.mid --votes 2 --keep-strong a.mid b.mid
23
+ python3 merge_transcriptions.py --selftest
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import os
30
+ import sys
31
+
32
+ import numpy as np
33
+
34
+ from muscriptor_dynamics import load_audio, measure, read_midi, write_midi
35
+
36
+
37
+ def _median(values):
38
+ ordered = sorted(values)
39
+ middle = len(ordered) // 2
40
+ if len(ordered) % 2:
41
+ return ordered[middle]
42
+ return 0.5 * (ordered[middle - 1] + ordered[middle])
43
+
44
+
45
+ def cluster(passes, tolerance=0.060):
46
+ """Group notes that the passes agree on.
47
+
48
+ `passes` is a list of note lists, each note (start, dur, pitch, channel).
49
+ Returns clusters as dicts with the members' pass indices.
50
+ """
51
+ flat = []
52
+ for index, notes in enumerate(passes):
53
+ for note in notes:
54
+ flat.append((note[0], note[1], note[2], note[3], index))
55
+ flat.sort()
56
+
57
+ clusters = []
58
+ for start, dur, pitch, channel, which in flat:
59
+ placed = False
60
+ for item in reversed(clusters[-8:]):
61
+ if item["pitch"] != pitch or which in item["passes"]:
62
+ continue
63
+ if abs(item["times"][0] - start) > tolerance:
64
+ continue
65
+ item["times"].append(start)
66
+ item["durs"].append(dur)
67
+ item["passes"].add(which)
68
+ item["channel"] = channel
69
+ placed = True
70
+ break
71
+ if not placed:
72
+ clusters.append({"pitch": pitch, "channel": channel, "times": [start],
73
+ "durs": [dur], "passes": {which}})
74
+ return clusters
75
+
76
+
77
+ def merge(passes, samples=None, rate=None, votes=2, tolerance=0.060,
78
+ keep_strong_singletons=False, on_stage=None):
79
+ """One transcription out of several passes. Returns (notes, report)."""
80
+ def stage(percent, message):
81
+ if on_stage:
82
+ on_stage(percent, message)
83
+
84
+ stage(5, "группирую ноты из %d проходов" % len(passes))
85
+ clusters = cluster(passes, tolerance)
86
+ stage(35, "согласовано кластеров: %d" % len(clusters))
87
+
88
+ kept = [item for item in clusters if len(item["passes"]) >= votes]
89
+ report = {
90
+ "passes": len(passes),
91
+ "clusters": len(clusters),
92
+ "agreed": len(kept),
93
+ "dropped_doubtful": len(clusters) - len(kept),
94
+ "rescued": 0,
95
+ }
96
+ stage(55, "прошли голосование: %d" % len(kept))
97
+
98
+ if keep_strong_singletons and samples is not None and rate:
99
+ # A note that only one pass heard is usually a hallucination, but a loud,
100
+ # clean attack is not: a fortissimo note in an otherwise quiet passage is
101
+ # heard once and is real. Those are rescued by energy, not by vote.
102
+ levels = []
103
+ for item in kept:
104
+ first = int(max(0, round(_median(item["times"]) * rate)))
105
+ chunk = samples[first:first + max(1, int(0.06 * rate))]
106
+ levels.append(20 * math.log10(float(np.sqrt(np.mean(chunk * chunk)) + 1e-9))
107
+ if chunk.size else -120.0)
108
+ floor = float(np.percentile(levels, 10)) if levels else -60.0
109
+ for item in clusters:
110
+ if len(item["passes"]) >= votes:
111
+ continue
112
+ first = int(max(0, round(_median(item["times"]) * rate)))
113
+ chunk = samples[first:first + max(1, int(0.06 * rate))]
114
+ if not chunk.size:
115
+ continue
116
+ level = 20 * math.log10(float(np.sqrt(np.mean(chunk * chunk)) + 1e-9))
117
+ if level > floor + 6.0:
118
+ kept.append(item)
119
+ report["rescued"] += 1
120
+ kept.sort(key=lambda item: _median(item["times"]))
121
+ stage(65, "спасено по атаке: %d" % report["rescued"])
122
+
123
+ notes = [(_median(item["times"]), max(0.05, _median(item["durs"])),
124
+ item["pitch"], item["channel"]) for item in kept]
125
+ notes.sort(key=lambda note: note[0])
126
+ stage(75, "слито нот: %d" % len(notes))
127
+
128
+ if samples is not None and rate and notes:
129
+ stage(85, "меряю velocity по аудио")
130
+ velocities = measure(notes, samples, rate)
131
+ notes = [(start, dur, pitch, channel, velocities[i])
132
+ for i, (start, dur, pitch, channel) in enumerate(notes)]
133
+ else:
134
+ notes = [(start, dur, pitch, channel, 80) for start, dur, pitch, channel in notes]
135
+
136
+ report["notes"] = len(notes)
137
+ stage(95, "готово")
138
+ return notes, report
139
+
140
+
141
+ # --------------------------------------------------------------------------- #
142
+ # scoring, shared with transcript_fix's benchmark idea
143
+ # --------------------------------------------------------------------------- #
144
+ def score(reference, candidate, tolerance=0.05):
145
+ used = [False] * len(candidate)
146
+ matched = 0
147
+ errors = []
148
+ for start, _dur, pitch, *_rest in reference:
149
+ best = None
150
+ for index, other in enumerate(candidate):
151
+ if used[index] or other[2] != pitch:
152
+ continue
153
+ delta = abs(other[0] - start)
154
+ if delta <= tolerance and (best is None or delta < best[0]):
155
+ best = (delta, index)
156
+ if best is not None:
157
+ used[best[1]] = True
158
+ matched += 1
159
+ errors.append(best[0])
160
+ precision = matched / max(1, len(candidate))
161
+ recall = matched / max(1, len(reference))
162
+ return {
163
+ "precision": precision,
164
+ "recall": recall,
165
+ "f1": 2 * precision * recall / max(1e-9, precision + recall),
166
+ "mean_error_ms": 1000.0 * float(np.mean(errors)) if errors else float("nan"),
167
+ }
168
+
169
+
170
+ def selftest():
171
+ """Three simulated passes, each losing different notes.
172
+
173
+ This is the case ensembles exist for: the union of what they miss must not
174
+ survive, and the union of what they find must.
175
+ """
176
+ truth = [
177
+ (0.00, 0.24, 60), (0.30, 0.22, 62), (0.58, 0.20, 64), (0.86, 0.26, 65),
178
+ (1.14, 0.22, 67), (1.42, 0.24, 69), (1.70, 0.20, 71), (1.98, 0.26, 72),
179
+ (2.26, 0.22, 74), (2.54, 0.20, 75), (2.82, 0.24, 76), (3.10, 0.26, 77),
180
+ (3.38, 0.22, 79), (3.66, 0.24, 81),
181
+ ]
182
+ reference = [(start, dur, pitch, 0, 90) for start, dur, pitch in truth]
183
+
184
+ missing = ({2, 7, 11}, {4, 9, 12}, {1, 6, 10})
185
+ jitter = (0.010, 0.012, 0.008)
186
+ ghosts = (3.98, 2.40, 0.44)
187
+ rng = np.random.default_rng(11)
188
+
189
+ passes = []
190
+ for index in range(3):
191
+ notes = []
192
+ for position, (start, dur, pitch) in enumerate(truth):
193
+ if position in missing[index]:
194
+ continue
195
+ notes.append((max(0.0, start + rng.normal(0, jitter[index])), dur, pitch, 0))
196
+ notes.append((ghosts[index], 0.2, 55 + index, 0))
197
+ notes.sort()
198
+ passes.append(notes)
199
+ result = score(reference, [(n[0], n[1], n[2], n[3], 80) for n in notes])
200
+ print("проход %d: P=%.2f R=%.2f F1=%.2f, нот %d"
201
+ % (index + 1, result["precision"], result["recall"], result["f1"], len(notes)),
202
+ flush=True)
203
+
204
+ merged, report = merge(passes, votes=2,
205
+ on_stage=lambda p, m: print(" [%3d%%] %s" % (p, m), flush=True))
206
+ result = score(reference, merged)
207
+ print("слияние: P=%.2f R=%.2f F1=%.2f, ошибка %.1f мс, нот %d"
208
+ % (result["precision"], result["recall"], result["f1"],
209
+ result["mean_error_ms"], len(merged)), flush=True)
210
+ print("отчёт: %s" % report, flush=True)
211
+
212
+ best_single = max(score(reference, [(n[0], n[1], n[2], n[3], 80) for n in notes])["f1"]
213
+ for notes in passes)
214
+ assert result["f1"] > best_single + 0.15, "ансамбль не лучше лучшего прохода"
215
+ assert result["recall"] > 0.95, "потерянные ноты не восстановлены"
216
+ assert result["precision"] > 0.95, "фантомы не отфильтрованы"
217
+ print("selftest: OK", flush=True)
218
+
219
+
220
+ def run(audio_path, out_path, midi_paths, votes=2, keep_strong=False):
221
+ print("[ 0%] читаю аудио", flush=True)
222
+ samples, rate = load_audio(audio_path)
223
+ passes = []
224
+ for path in midi_paths:
225
+ notes, _tempo, _division = read_midi(path)
226
+ passes.append([note[:4] for note in notes])
227
+ print(" %s: %d нот" % (os.path.basename(path), len(notes)), flush=True)
228
+ if len(passes) < votes:
229
+ raise SystemExit("проходов меньше, чем нужно голосов")
230
+ merged, report = merge(passes, samples, rate, votes=votes,
231
+ keep_strong_singletons=keep_strong,
232
+ on_stage=lambda p, m: print("[%3d%%] %s" % (p, m), flush=True))
233
+ write_midi(out_path, merged, 120.0)
234
+ print("[100%] %s: %s" % (out_path, report), flush=True)
235
+ return merged
236
+
237
+
238
+ if __name__ == "__main__":
239
+ if "--selftest" in sys.argv:
240
+ selftest()
241
+ elif len(sys.argv) >= 4:
242
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
243
+ keep = "--keep-strong" in sys.argv
244
+ votes = 2
245
+ if "--votes" in sys.argv:
246
+ votes = int(sys.argv[sys.argv.index("--votes") + 1])
247
+ args = [a for a in args if a != str(votes)]
248
+ run(args[0], args[1], args[2:], votes=votes, keep_strong=keep)
249
+ else:
250
+ print(__doc__)
251
+ raise SystemExit(2)