multimodalart HF Staff commited on
Commit
a00afb2
·
verified ·
1 Parent(s): ce37ff9

Add per-instrument piano-roll canvas; fix note-count summary

Browse files
Files changed (2) hide show
  1. app.py +279 -25
  2. requirements.txt +1 -0
app.py CHANGED
@@ -11,6 +11,7 @@ import re
11
  import base64
12
  from pathlib import Path
13
 
 
14
  from safetensors.torch import load_file
15
  from huggingface_hub import hf_hub_download
16
 
@@ -20,7 +21,11 @@ from muscriptor.modules.conditioners import (
20
  ClassConditioner,
21
  ConditioningProvider,
22
  )
23
- from muscriptor.tokenizer.mt3 import MT3Tokenizer, MT3_FULL_PLUS_GROUP_NAMES
 
 
 
 
24
  from muscriptor.transcription_model import (
25
  TranscriptionModel,
26
  _resolve_source,
@@ -87,11 +92,33 @@ print(f"[muscriptor-space] Model loaded in {time.perf_counter() - t0:.1f}s", fil
87
  # Build the instrument choices list (sorted by group ID for stable ordering)
88
  INSTRUMENT_CHOICES = sorted(MT3_FULL_PLUS_GROUP_NAMES.keys(), key=lambda k: MT3_FULL_PLUS_GROUP_NAMES[k])
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  CSS = """
91
  #col-container { max-width: 1100px; margin: 0 auto; }
92
  .dark .gradio-container { color: var(--body-text-color); }
93
  midi-player { width: 100%; display: block; margin-bottom: 8px; }
94
  midi-visualizer { width: 100%; display: block; overflow: auto; }
 
 
95
  """
96
 
97
  # The html-midi-player web components (<midi-player> / <midi-visualizer>) rely
@@ -139,6 +166,226 @@ watch('value', syncPlayer);
139
  """
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def _midi_player_src(midi_bytes: bytes | None) -> str:
143
  """Return the transcribed MIDI as a base64 data URI for the player.
144
 
@@ -175,7 +422,7 @@ def transcribe_audio(
175
  temperature: Sampling temperature (only used when use_sampling is True).
176
  """
177
  if audio_path is None:
178
- return None, "Please upload an audio file first.", ""
179
 
180
  t0 = time.perf_counter()
181
 
@@ -188,7 +435,7 @@ def transcribe_audio(
188
  temperature=temperature,
189
  )
190
  except Exception as e:
191
- return None, f"Transcription failed: {e}", ""
192
 
193
  elapsed = time.perf_counter() - t0
194
 
@@ -197,29 +444,30 @@ def transcribe_audio(
197
  tmp.write(midi_bytes)
198
  tmp.close()
199
 
200
- # Also collect note statistics for the summary
201
- note_count = 0
202
- instrument_set = set()
203
- try:
204
- from mido import MidiFile
205
- midi = MidiFile(tmp.name)
206
- for track in midi.tracks:
207
- for msg in track:
208
- if msg.type == "note_on" and msg.velocity > 0:
209
- note_count += 1
210
- instrument_set.add(msg.program if not msg.is_meta else 0)
211
- except Exception:
212
- pass
213
-
214
- summary = (
215
- f"Transcription complete in {elapsed:.1f}s. "
216
- f"Found {note_count} notes across {len(instrument_set)} instrument program(s). "
217
- f"Play it back below or download the MIDI file."
218
- )
219
 
220
  player_src = _midi_player_src(midi_bytes)
 
221
 
222
- return tmp.name, summary, player_src
223
 
224
 
225
  with gr.Blocks() as demo:
@@ -275,6 +523,12 @@ with gr.Blocks() as demo:
275
  html_template=_MIDI_PLAYER_TEMPLATE,
276
  js_on_load=_MIDI_PLAYER_JS,
277
  )
 
 
 
 
 
 
278
  midi_output = gr.File(label="Download MIDI file")
279
 
280
  summary_output = gr.Textbox(label="Summary", interactive=False, visible=False)
@@ -282,7 +536,7 @@ with gr.Blocks() as demo:
282
  transcribe_btn.click(
283
  fn=transcribe_audio,
284
  inputs=[audio_input, instrument_checkbox, use_sampling, temperature],
285
- outputs=[midi_output, summary_output, midi_player],
286
  api_name="transcribe",
287
  )
288
 
@@ -293,7 +547,7 @@ with gr.Blocks() as demo:
293
  ["example_water_afro_pop.mp3", [], False, 1.0],
294
  ],
295
  inputs=[audio_input, instrument_checkbox, use_sampling, temperature],
296
- outputs=[midi_output, summary_output, midi_player],
297
  fn=transcribe_audio,
298
  cache_examples=True,
299
  cache_mode="lazy",
 
11
  import base64
12
  from pathlib import Path
13
 
14
+ import pretty_midi
15
  from safetensors.torch import load_file
16
  from huggingface_hub import hf_hub_download
17
 
 
21
  ClassConditioner,
22
  ConditioningProvider,
23
  )
24
+ from muscriptor.tokenizer.mt3 import (
25
+ MT3Tokenizer,
26
+ MT3_FULL_PLUS_GROUP_NAMES,
27
+ get_group_program_map,
28
+ )
29
  from muscriptor.transcription_model import (
30
  TranscriptionModel,
31
  _resolve_source,
 
92
  # Build the instrument choices list (sorted by group ID for stable ordering)
93
  INSTRUMENT_CHOICES = sorted(MT3_FULL_PLUS_GROUP_NAMES.keys(), key=lambda k: MT3_FULL_PLUS_GROUP_NAMES[k])
94
 
95
+ # MIDI program -> human-readable label using the model's own MT3_FULL_PLUS
96
+ # instrument groups (the model emits the representative program of a group,
97
+ # so this names lanes with the taxonomy the model actually decodes into).
98
+ # misc_programs="OMIT" keeps only the named groups 0-35; drums are handled
99
+ # via is_drum. Falls back to General MIDI names for unmapped programs.
100
+ _PROGRAM_TO_GROUP_LABEL: dict[int, str] = {}
101
+ try:
102
+ _gid_to_name = {v: k for k, v in MT3_FULL_PLUS_GROUP_NAMES.items()}
103
+ for _gid, _programs in get_group_program_map(
104
+ "MT3_FULL_PLUS", misc_programs="OMIT", is_mt3=True
105
+ ).items():
106
+ _name = _gid_to_name.get(_gid)
107
+ if _name is None:
108
+ continue
109
+ _label = _name.replace("_", " ").title()
110
+ for _p in _programs:
111
+ _PROGRAM_TO_GROUP_LABEL[_p] = _label
112
+ except Exception:
113
+ pass
114
+
115
  CSS = """
116
  #col-container { max-width: 1100px; margin: 0 auto; }
117
  .dark .gradio-container { color: var(--body-text-color); }
118
  midi-player { width: 100%; display: block; margin-bottom: 8px; }
119
  midi-visualizer { width: 100%; display: block; overflow: auto; }
120
+ .midi-roll-container { margin-top: 10px; }
121
+ .midi-roll-canvas { width: 100%; display: block; }
122
  """
123
 
124
  # The html-midi-player web components (<midi-player> / <midi-visualizer>) rely
 
166
  """
167
 
168
 
169
+ # --- Piano-roll canvas (visual design ported from the space-demo-kit MIDI roll) ---
170
+ # One lane per instrument track: rounded lane background, label chip (colored
171
+ # dot + instrument name + note count), gridlines, note bars placed by
172
+ # onset/offset on x and pitch on y (per-lane pitch range), and a playhead
173
+ # synced to the <midi-player> above. Notes glow while they are being played.
174
+ _MIDI_ROLL_TEMPLATE = (
175
+ '<div class="midi-roll-container" style="display:none">'
176
+ '<canvas class="midi-roll-canvas"></canvas>'
177
+ "</div>"
178
+ )
179
+
180
+ _MIDI_ROLL_JS = r"""
181
+ const LANE_COLORS = ['#f97316', '#3b82f6', '#22c55e', '#a855f7', '#ec4899', '#eab308'];
182
+ const container = element.querySelector('.midi-roll-container');
183
+ const canvas = element.querySelector('.midi-roll-canvas');
184
+ let mt = null; // {span, tracks: [{label, drum, notes: [[t0, t1, pitch], ...]}]}
185
+ let lastT = 0; // last known playback time (seconds)
186
+ let live = false; // player currently playing
187
+ let rafId = null;
188
+ let wired = null; // the <midi-player> element we attached listeners to
189
+
190
+ function isDark() {
191
+ return document.body.classList.contains('dark') ||
192
+ document.documentElement.classList.contains('dark');
193
+ }
194
+
195
+ const PAD_L = 20, PAD_R = 20, PAD_T = 14, PAD_B = 18, LANE_H = 84, LANE_GAP = 12;
196
+
197
+ function layout() {
198
+ if (!mt) return;
199
+ const dpr = window.devicePixelRatio || 1;
200
+ const w = container.clientWidth || 600;
201
+ const nT = mt.tracks.length;
202
+ const h = PAD_T + PAD_B + nT * LANE_H + (nT - 1) * LANE_GAP;
203
+ canvas.width = Math.round(w * dpr);
204
+ canvas.height = Math.round(h * dpr);
205
+ canvas.style.width = w + 'px';
206
+ canvas.style.height = h + 'px';
207
+ }
208
+
209
+ function draw() {
210
+ if (!mt) return;
211
+ const dpr = window.devicePixelRatio || 1;
212
+ const cx = canvas.getContext('2d');
213
+ const W = canvas.width / dpr, H = canvas.height / dpr;
214
+ cx.setTransform(dpr, 0, 0, dpr, 0, 0);
215
+ cx.clearRect(0, 0, W, H);
216
+ const dark = isDark();
217
+ const span = mt.span;
218
+ const tNow = Math.min(Math.max(lastT, 0), span);
219
+ const innerW = W - PAD_L - PAD_R;
220
+ const tx = s => PAD_L + (s / span) * innerW;
221
+
222
+ // gridlines across the full roll (step adapts to the clip length)
223
+ const step = span > 240 ? 30 : span > 120 ? 15 : span > 60 ? 10 : span > 30 ? 5 : 1;
224
+ cx.strokeStyle = dark ? 'rgba(255,255,255,.07)' : 'rgba(17,24,39,.07)';
225
+ cx.lineWidth = 1;
226
+ for (let s = step; s < span; s += step) {
227
+ cx.beginPath(); cx.moveTo(tx(s), PAD_T); cx.lineTo(tx(s), H - PAD_B); cx.stroke();
228
+ }
229
+
230
+ mt.tracks.forEach((tr, i) => {
231
+ const y0 = PAD_T + i * (LANE_H + LANE_GAP);
232
+ const color = LANE_COLORS[i % LANE_COLORS.length];
233
+ // lane background
234
+ cx.fillStyle = dark ? 'rgba(255,255,255,.035)' : 'rgba(17,24,39,.04)';
235
+ cx.beginPath(); cx.roundRect(PAD_L - 10, y0, innerW + 20, LANE_H, 12); cx.fill();
236
+
237
+ // note area (below the label band)
238
+ const labelBand = 34;
239
+ const nTop = y0 + labelBand, nBot = y0 + LANE_H - 8;
240
+ let pMin = 127, pMax = 0;
241
+ for (const nn of tr.notes) { if (nn[2] < pMin) pMin = nn[2]; if (nn[2] > pMax) pMax = nn[2]; }
242
+ const pSpan = Math.max(1, pMax - pMin);
243
+ const nH = Math.max(4, Math.min(12, (nBot - nTop) / (pSpan + 1)));
244
+
245
+ for (const [t0, t1, p] of tr.notes) {
246
+ const x0 = tx(t0);
247
+ const w = Math.max(4, tx(t1) - x0);
248
+ const y = pMax === pMin
249
+ ? (nTop + nBot - nH) / 2
250
+ : nTop + ((pMax - p) / pSpan) * (nBot - nTop - nH);
251
+ const active = live && tNow >= t0 && tNow <= t1;
252
+ const past = tNow > t1;
253
+ cx.save();
254
+ if (active) { cx.shadowColor = color; cx.shadowBlur = 12; cx.globalAlpha = 1; }
255
+ else cx.globalAlpha = past ? .85 : .3;
256
+ cx.fillStyle = color;
257
+ cx.beginPath();
258
+ cx.roundRect(x0, active ? y - 1 : y, w, active ? nH + 2 : nH, 2);
259
+ cx.fill();
260
+ cx.restore();
261
+ }
262
+
263
+ // label chip: colored dot + instrument name + note count
264
+ const label = tr.label.toUpperCase();
265
+ const count = tr.notes.length + (tr.notes.length === 1 ? ' NOTE' : ' NOTES');
266
+ cx.font = '700 13px system-ui, -apple-system, sans-serif';
267
+ const lw = cx.measureText(label).width;
268
+ cx.font = '500 12px ui-monospace, SFMono-Regular, Menlo, monospace';
269
+ const cw = cx.measureText(count).width;
270
+ const chipW = 12 + 9 + 7 + lw + 10 + cw + 12, chipH = 24;
271
+ cx.fillStyle = dark ? 'rgba(22,26,34,.92)' : 'rgba(255,255,255,.94)';
272
+ cx.shadowColor = 'rgba(0,0,0,.10)'; cx.shadowBlur = 8;
273
+ cx.beginPath(); cx.roundRect(PAD_L, y0 + 5, chipW, chipH, 999); cx.fill();
274
+ cx.shadowBlur = 0;
275
+ cx.fillStyle = color;
276
+ cx.beginPath(); cx.arc(PAD_L + 12 + 4, y0 + 5 + chipH / 2, 4, 0, 7); cx.fill();
277
+ cx.textBaseline = 'middle'; cx.textAlign = 'left';
278
+ cx.fillStyle = dark ? '#e5e7eb' : '#1f2430';
279
+ cx.font = '700 13px system-ui, -apple-system, sans-serif';
280
+ cx.fillText(label, PAD_L + 12 + 9 + 7, y0 + 6 + chipH / 2);
281
+ cx.fillStyle = dark ? '#9aa2ae' : '#6b7280';
282
+ cx.font = '500 12px ui-monospace, SFMono-Regular, Menlo, monospace';
283
+ cx.fillText(count, PAD_L + 12 + 9 + 7 + lw + 10, y0 + 6 + chipH / 2);
284
+ });
285
+
286
+ // playhead
287
+ const x = tx(tNow);
288
+ cx.fillStyle = dark ? '#e5e7eb' : '#111827';
289
+ cx.globalAlpha = live ? .85 : (lastT > 0 ? .3 : 0);
290
+ cx.beginPath(); cx.roundRect(x - 1.5, PAD_T - 4, 3, H - PAD_T - PAD_B + 8, 2); cx.fill();
291
+ cx.globalAlpha = 1;
292
+ }
293
+
294
+ // --- playhead sync with the <midi-player> web component (lives in the
295
+ // sibling gr.HTML component, so we look it up on document) ---
296
+ function tick() {
297
+ if (!live) { rafId = null; return; }
298
+ if (wired && typeof wired.currentTime === 'number') lastT = wired.currentTime;
299
+ draw();
300
+ rafId = requestAnimationFrame(tick);
301
+ }
302
+
303
+ function wirePlayer() {
304
+ const player = document.querySelector('midi-player');
305
+ if (!player || wired === player) return;
306
+ wired = player;
307
+ player.addEventListener('start', () => {
308
+ live = true;
309
+ if (rafId === null) rafId = requestAnimationFrame(tick);
310
+ });
311
+ player.addEventListener('stop', () => { // fires on both pause and finish
312
+ live = false;
313
+ if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
314
+ if (wired && typeof wired.currentTime === 'number') lastT = wired.currentTime;
315
+ draw();
316
+ });
317
+ }
318
+
319
+ // Low-rate poll: wires the player once it mounts, and catches scrubbing /
320
+ // theme flips while paused. Deliberately NOT a ResizeObserver.
321
+ setInterval(() => {
322
+ wirePlayer();
323
+ if (!live && wired && mt) {
324
+ const t = wired.currentTime;
325
+ if (typeof t === 'number' && Math.abs(t - lastT) > 0.05) { lastT = t; draw(); }
326
+ }
327
+ }, 400);
328
+
329
+ let resizeTimer = null;
330
+ window.addEventListener('resize', () => {
331
+ clearTimeout(resizeTimer);
332
+ resizeTimer = setTimeout(() => { layout(); draw(); }, 150);
333
+ });
334
+
335
+ function syncRoll() {
336
+ let data = null;
337
+ try { data = props.value ? JSON.parse(props.value) : null; } catch (e) { data = null; }
338
+ if (data && data.tracks && data.tracks.length) {
339
+ mt = data; lastT = 0; live = false;
340
+ container.style.display = 'block';
341
+ layout(); draw();
342
+ } else {
343
+ mt = null;
344
+ container.style.display = 'none';
345
+ }
346
+ }
347
+ syncRoll();
348
+ watch('value', syncRoll);
349
+ wirePlayer();
350
+ """
351
+
352
+
353
+ def _midi_roll_data(midi_bytes: bytes | None) -> dict:
354
+ """Per-track note data for the piano-roll canvas (and the summary stats).
355
+
356
+ Returns {"span": seconds, "tracks": [{"label", "drum", "notes":
357
+ [[onset, offset, pitch], ...]}, ...]} computed from the real returned
358
+ MIDI bytes with pretty_midi. All tracks are included (vocals too).
359
+ """
360
+ if not midi_bytes:
361
+ return {}
362
+ try:
363
+ pm = pretty_midi.PrettyMIDI(io.BytesIO(midi_bytes))
364
+ except Exception:
365
+ return {}
366
+ tracks = []
367
+ for inst in pm.instruments:
368
+ if not inst.notes:
369
+ continue
370
+ if inst.is_drum:
371
+ label = "Drums"
372
+ elif inst.name and inst.name.strip():
373
+ label = inst.name.strip()
374
+ else:
375
+ label = _PROGRAM_TO_GROUP_LABEL.get(
376
+ inst.program
377
+ ) or pretty_midi.program_to_instrument_name(inst.program)
378
+ notes = sorted(
379
+ [round(float(n.start), 4), round(float(n.end), 4), int(n.pitch)]
380
+ for n in inst.notes
381
+ )
382
+ tracks.append({"label": label, "drum": bool(inst.is_drum), "notes": notes})
383
+ if not tracks:
384
+ return {}
385
+ span = max(float(pm.get_end_time()), 0.001)
386
+ return {"span": round(span, 4), "tracks": tracks}
387
+
388
+
389
  def _midi_player_src(midi_bytes: bytes | None) -> str:
390
  """Return the transcribed MIDI as a base64 data URI for the player.
391
 
 
422
  temperature: Sampling temperature (only used when use_sampling is True).
423
  """
424
  if audio_path is None:
425
+ return None, "Please upload an audio file first.", "", ""
426
 
427
  t0 = time.perf_counter()
428
 
 
435
  temperature=temperature,
436
  )
437
  except Exception as e:
438
+ return None, f"Transcription failed: {e}", "", ""
439
 
440
  elapsed = time.perf_counter() - t0
441
 
 
444
  tmp.write(midi_bytes)
445
  tmp.close()
446
 
447
+ # Per-track note data drives both the piano roll and the summary stats.
448
+ # (The previous mido-based stats loop hit an AttributeError on the first
449
+ # note — note_on messages have no .program — so it always reported 1 note.)
450
+ roll = _midi_roll_data(midi_bytes)
451
+ tracks = roll.get("tracks", [])
452
+ total_notes = sum(len(t["notes"]) for t in tracks)
453
+ if tracks:
454
+ per_track = ", ".join(f"{t['label']} {len(t['notes'])}" for t in tracks)
455
+ summary = (
456
+ f"Transcription complete in {elapsed:.1f}s. "
457
+ f"Found {total_notes} note{'s' if total_notes != 1 else ''} "
458
+ f"across {len(tracks)} track(s): {per_track}. "
459
+ f"Play it back below or download the MIDI file."
460
+ )
461
+ else:
462
+ summary = (
463
+ f"Transcription complete in {elapsed:.1f}s, but no notes were "
464
+ f"detected in the result."
465
+ )
466
 
467
  player_src = _midi_player_src(midi_bytes)
468
+ roll_json = json.dumps(roll) if tracks else ""
469
 
470
+ return tmp.name, summary, player_src, roll_json
471
 
472
 
473
  with gr.Blocks() as demo:
 
523
  html_template=_MIDI_PLAYER_TEMPLATE,
524
  js_on_load=_MIDI_PLAYER_JS,
525
  )
526
+ midi_roll = gr.HTML(
527
+ value="",
528
+ label="Piano roll (per instrument)",
529
+ html_template=_MIDI_ROLL_TEMPLATE,
530
+ js_on_load=_MIDI_ROLL_JS,
531
+ )
532
  midi_output = gr.File(label="Download MIDI file")
533
 
534
  summary_output = gr.Textbox(label="Summary", interactive=False, visible=False)
 
536
  transcribe_btn.click(
537
  fn=transcribe_audio,
538
  inputs=[audio_input, instrument_checkbox, use_sampling, temperature],
539
+ outputs=[midi_output, summary_output, midi_player, midi_roll],
540
  api_name="transcribe",
541
  )
542
 
 
547
  ["example_water_afro_pop.mp3", [], False, 1.0],
548
  ],
549
  inputs=[audio_input, instrument_checkbox, use_sampling, temperature],
550
+ outputs=[midi_output, summary_output, midi_player, midi_roll],
551
  fn=transcribe_audio,
552
  cache_examples=True,
553
  cache_mode="lazy",
requirements.txt CHANGED
@@ -2,5 +2,6 @@ git+https://github.com/muscriptor/muscriptor.git
2
  numpy>=1.24
3
  einops>=0.4
4
  mido>=1.3
 
5
  safetensors>=0.4
6
  soundfile>=0.14.0
 
2
  numpy>=1.24
3
  einops>=0.4
4
  mido>=1.3
5
+ pretty_midi>=0.2.10
6
  safetensors>=0.4
7
  soundfile>=0.14.0