Kai Djuric
Deploy: muscriptor-vercel
9629081
Raw
History Blame Contribute Delete
10.3 kB
// MuScriptor front-end. Plain ES modules, no build step.
// Loads @gradio/client (for the HF Space) and @tonejs/midi + Tone (for playback) from esm.sh.
import { Client, handle_file } from "https://esm.sh/@gradio/client@1.6.0";
import { Midi } from "https://esm.sh/@tonejs/midi@2.0.28?bundle";
import * as Tone from "https://esm.sh/tone@15.1.22?bundle";
// ---- DOM helpers ----
const $ = (id) => document.getElementById(id);
const log = (msg, level = "info") => {
const el = $("log");
const line = `[${new Date().toISOString().slice(11, 19)}] ${level.toUpperCase()} ${msg}\n`;
el.textContent = (el.textContent === "(no events yet)" ? "" : el.textContent) + line;
el.scrollTop = el.scrollHeight;
if (level === "error") console.error(msg);
else console.log(msg);
};
const setStatus = (id, text, cls) => {
const el = $(id);
el.textContent = text;
el.className = "status " + (cls || "muted");
};
// ---- State ----
let audioFile = null; // { file: File, url: string, source: 'upload' | 'sample' }
let audioEl = null; // for sample playback
let midiBuffer = null; // ArrayBuffer from Space
let midiBlobUrl = null;
let musicxmlBlobUrl = null;
let midiData = null; // parsed @tonejs/midi structure
let toneReady = false;
let client = null;
let clientConnecting = null;
// ---- Wire up the form once the DOM is parsed ----
document.addEventListener("DOMContentLoaded", () => {
// 1. audio file
$("audio-file").addEventListener("change", (e) => {
const f = e.target.files[0];
if (f) setAudio(f, "upload");
});
// 2. sample buttons
document.querySelectorAll(".sample").forEach((btn) => {
btn.addEventListener("click", async () => {
const url = btn.dataset.sample;
const fname = url.split("/").pop();
setStatus("audio-name", `loading sample ${fname}…`, "muted");
try {
const r = await fetch(url);
if (!r.ok) throw new Error(`fetch ${url} -> ${r.status}`);
const blob = await r.blob();
const file = new File([blob], fname, { type: blob.type || "audio/wav" });
setAudio(file, "sample", url);
} catch (err) {
log(`failed to fetch sample ${url}: ${err.message}`, "error");
setStatus("audio-name", `failed to load ${fname}`, "err");
}
});
});
// 3. play audio
$("play-audio").addEventListener("click", async () => {
if (!audioFile) return;
if (!audioEl) {
// Attach to the DOM so the element appears in querySelectorAll and is
// inspectable by tests / dev tools.
audioEl = new Audio(audioFile.url);
audioEl.id = "sample-audio-el";
audioEl.preload = "auto";
audioEl.crossOrigin = "anonymous";
document.body.appendChild(audioEl);
}
try { await audioEl.play(); } catch (e) { log(`play audio failed: ${e.message}`, "error"); }
});
// 4. transcribe
$("transcribe").addEventListener("click", transcribe);
// 5. backend check
$("check-backend").addEventListener("click", checkBackend);
// 6. model + instrument change -> reset result card status
["model-size", "instruments"].forEach((id) => $(id).addEventListener("change", () => {
if (midiBuffer || musicxmlBlobUrl) {
setStatus("status-line", "options changed — re-transcribe to refresh results", "warn");
}
}));
log("UI ready", "info");
});
function setAudio(file, source, sampleUrl) {
if (audioFile?.url) URL.revokeObjectURL(audioFile.url);
audioFile = {
file,
url: sampleUrl || URL.createObjectURL(file),
source,
};
$("audio-name").textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
$("audio-name").className = "status";
$("play-audio").disabled = false;
$("transcribe").disabled = false;
audioEl = null;
log(`audio set: ${file.name} (${source})`, "info");
}
async function checkBackend() {
const url = $("space-url").value.trim().replace(/\/+$/, "");
setStatus("backend-status", "checking…", "muted");
try {
// Gradio Spaces serve /config with the full app config.
const r = await fetch(url + "/config", { method: "GET" });
if (!r.ok) throw new Error(`GET /config -> ${r.status}`);
const cfg = await r.json();
setStatus("backend-status", `online · ${cfg.title || "Gradio app"} · v${cfg.version || "?"}`, "ok");
log(`backend ${url} online`, "info");
} catch (e) {
setStatus("backend-status", `offline (${e.message})`, "err");
log(`backend check failed for ${url}: ${e.message}`, "error");
}
}
async function ensureClient() {
if (client) return client;
if (clientConnecting) return clientConnecting;
const url = $("space-url").value.trim().replace(/\/+$/, "");
setStatus("status-line", `connecting to ${url}…`, "muted");
log(`connecting to ${url}`, "info");
clientConnecting = (async () => {
try {
client = await Client.connect(url);
log(`client connected`, "info");
return client;
} catch (e) {
log(`client connect failed: ${e.message}`, "error");
clientConnecting = null;
throw e;
} finally {
clientConnecting = null;
}
})();
return clientConnecting;
}
async function transcribe() {
if (!audioFile) {
setStatus("status-line", "load an audio file first", "warn");
return;
}
const modelSize = $("model-size").value;
const instrumentSelection = Array.from($("instruments").selectedOptions).map((o) => o.value);
if (instrumentSelection.length === 0) instrumentSelection.push("all");
const btn = $("transcribe");
btn.disabled = true;
setStatus("status-line", "transcribing… this can take 10-60s for medium/large", "warn");
try {
const c = await ensureClient();
// /transcribe is the endpoint name (the Python function name).
const result = await c.predict("/transcribe", [
handle_file(audioFile.file), // audio
modelSize, // model_size
instrumentSelection, // instruments
]);
log(`predict done · data keys: ${Object.keys(result.data).join(", ")}`, "info");
// Output order matches the Python signature: roll, midi_dl, musicxml_dl, summary
const [roll, midiDl, musicxmlDl, summary] = result.data;
// 1. piano roll image
if (roll && roll.url) {
$("roll").src = roll.url;
$("roll").alt = `Piano roll for ${audioFile.file.name}`;
} else if (roll && roll.path) {
$("roll").src = `${$("space-url").value.trim().replace(/\/+$/, "")}/file=${roll.path}`;
}
// 2. MIDI file
if (midiDl) {
const midiUrl = midiDl.url || `${$("space-url").value.trim().replace(/\/+$/, "")}/file=${midiDl.path}`;
const mr = await fetch(midiUrl);
midiBuffer = await mr.arrayBuffer();
midiData = new Midi(midiBuffer);
if (midiBlobUrl) URL.revokeObjectURL(midiBlobUrl);
midiBlobUrl = URL.createObjectURL(new Blob([midiBuffer], { type: "audio/midi" }));
$("midi-audio").src = midiBlobUrl;
const dl = $("midi-download");
dl.href = midiBlobUrl;
dl.hidden = false;
$("midi-info").textContent = `${midiData.tracks.length} tracks · ${midiData.duration.toFixed(1)}s · ${midiData.header?.tempos?.[0]?.bpm?.toFixed(0) || "?"} BPM`;
$("midi-info").className = "status ok";
log(`MIDI parsed: ${midiData.tracks.length} tracks, ${midiData.notes.length} notes`, "info");
}
// 3. MusicXML
if (musicxmlDl && (musicxmlDl.url || musicxmlDl.path)) {
const xurl = musicxmlDl.url || `${$("space-url").value.trim().replace(/\/+$/, "")}/file=${musicxmlDl.path}`;
const xr = await fetch(xurl);
const xb = await xr.arrayBuffer();
if (musicxmlBlobUrl) URL.revokeObjectURL(musicxmlBlobUrl);
musicxmlBlobUrl = URL.createObjectURL(new Blob([xb], { type: "application/vnd.recordare.musicxml+xml" }));
const dl = $("musicxml-download");
dl.href = musicxmlBlobUrl;
dl.hidden = false;
$("musicxml-info").textContent = `ready (${(xb.byteLength / 1024).toFixed(1)} KB)`;
$("musicxml-info").className = "status ok";
} else {
$("musicxml-info").textContent = "(not generated for this file)";
$("musicxml-info").className = "muted";
}
// 4. summary
if (summary) {
const txt = typeof summary === "string" ? summary : JSON.stringify(summary, null, 2);
$("summary").textContent = txt;
}
setStatus("status-line", "done — press Play on the MIDI result to hear it", "ok");
} catch (e) {
log(`transcribe failed: ${e.message}`, "error");
setStatus("status-line", `failed: ${e.message}`, "err");
} finally {
btn.disabled = false;
}
}
// Auto-play the parsed MIDI when the user clicks the audio player's play button.
$("midi-audio")?.addEventListener("play", async () => {
// The <audio> element just streams the .mid file. But <audio> doesn't actually play MIDI
// reliably in all browsers. We intercept and render the parsed @tonejs/midi instead.
if (midiData && toneReady) {
$("midi-audio").pause();
await playMidi();
}
});
async function playMidi() {
if (!midiData) return;
if (!toneReady) {
await Tone.start();
toneReady = true;
}
Tone.Transport.stop();
Tone.Transport.cancel();
const synth = new Tone.PolySynth(Tone.Synth, {
envelope: { attack: 0.005, decay: 0.1, sustain: 0.3, release: 1.2 },
}).toDestination();
Tone.Destination.volume.value = -8;
const now = Tone.now() + 0.05;
midiData.tracks.forEach((track) => {
track.notes.forEach((note) => {
synth.triggerAttackRelease(
note.name,
Math.max(0.05, note.duration),
now + note.time,
Math.min(0.9, note.velocity / 127)
);
});
});
const total = midiData.duration + 0.5;
setStatus("status-line", `playing ${midiData.tracks.length}-track MIDI · ${total.toFixed(1)}s`, "ok");
setTimeout(() => {
setStatus("status-line", "playback finished", "muted");
synth.dispose();
}, total * 1000);
}
// First-user gesture hook for Tone (browsers require a user interaction).
document.addEventListener("click", async () => {
if (toneReady) return;
try {
await Tone.start();
toneReady = true;
log("audio context unlocked", "info");
} catch (e) { /* ignore — will retry on first play */ }
}, { once: false });