Spaces:
Running on Zero
Running on Zero
Add per-instrument piano-roll canvas; fix note-count summary
Browse files- app.py +279 -25
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 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
|