const root = element.querySelector(".msv-root"); if (!root) { throw new Error("MuScriptor visualizer template is missing .msv-root"); } const $ = (selector) => root.querySelector(selector); const notesCanvas = $('[data-canvas="notes"]'); const cursorCanvas = $('[data-canvas="cursor"]'); const rollScroll = $('[data-region="roll-scroll"]'); const rollStage = $('[data-region="roll-stage"]'); const emptyRegion = $('[data-region="empty"]'); const tracksRegion = $('[data-region="tracks"]'); const announcer = $('[data-region="announcer"]'); const progressRegion = $('[data-region="progress"]'); const progressTrack = progressRegion.querySelector('[role="progressbar"]'); const playButton = $('[data-action="play"]'); const stopButton = $('[data-action="stop"]'); const fullDownload = $('[data-action="download-full"]'); const fallbackColors = [ "#8b7cff", "#5f9cff", "#54c7a4", "#f2b96f", "#e878a6", "#58c4dc", "#b48cf2", "#91c96f", ]; const stateLabels = { idle: "Ready for audio", queued: "ZeroGPU queued", loading: "Loading model", cold_start: "Starting ZeroGPU", transcribing: "Transcribing", processing: "Transcribing", complete: "Transcription complete", completed: "Transcription complete", ready: "Transcription complete", error: "Transcription failed", failed: "Transcription failed", }; const activeStates = new Set(["queued", "loading", "cold_start", "transcribing", "processing"]); const terminalStates = new Set(["complete", "completed", "ready", "error", "failed", "idle"]); const trackState = new Map(); let snapshot = normalizePayload({}); let geometry = null; let playback = null; let resizeFrame = 0; let announceTimer = 0; function asFinite(value, fallback = 0) { const number = Number(value); return Number.isFinite(number) ? number : fallback; } function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); } function safeColor(value, index) { const candidate = typeof value === "string" ? value.trim() : ""; if (candidate && typeof CSS !== "undefined" && CSS.supports("color", candidate)) { return candidate; } return fallbackColors[index % fallbackColors.length]; } function titleFromId(value) { return String(value || "Instrument") .replace(/[_-]+/g, " ") .replace(/\b\w/g, (letter) => letter.toUpperCase()); } function normalizeNote(note) { const pitch = clamp(Math.round(asFinite(note && note.pitch, 60)), 0, 127); const start = Math.max(0, asFinite(note && note.start, 0)); const end = Math.max(start + 0.015, asFinite(note && note.end, start + 0.12)); const velocity = clamp(Math.round(asFinite(note && note.velocity, 90)), 1, 127); return { pitch, start, end, velocity }; } function normalizeTrack(track, index) { const id = String(track && track.id != null ? track.id : `track-${index + 1}`); const notes = Array.isArray(track && track.notes) ? track.notes.filter((note) => note && typeof note === "object").map(normalizeNote) : []; notes.sort((left, right) => left.start - right.start || left.pitch - right.pitch); return { id, name: String((track && track.name) || titleFromId(id)), color: safeColor(track && track.color, index), note_count: Math.max(0, Math.round(asFinite(track && track.note_count, notes.length))), program: track && track.program != null ? Math.round(asFinite(track.program, 0)) : null, is_drum: Boolean(track && track.is_drum), midi: typeof (track && track.midi) === "string" ? track.midi : "", notes, }; } function parseIncoming(value) { if (value && typeof value === "object") { return value; } if (typeof value !== "string" || !value.trim()) { return {}; } try { const parsed = JSON.parse(value); return parsed && typeof parsed === "object" ? parsed : {}; } catch (error) { return { state: "error", status: "The transcription response could not be read.", _parse_error: true, }; } } function normalizePayload(raw) { const tracks = Array.isArray(raw && raw.tracks) ? raw.tracks.map(normalizeTrack) : []; const notesFromTracks = tracks.reduce((total, track) => total + track.notes.length, 0); const noteEnd = tracks.reduce( (latest, track) => track.notes.reduce((trackEnd, note) => Math.max(trackEnd, note.end), latest), 0, ); const suppliedDuration = Math.max(0, asFinite(raw && raw.duration, 0)); const inferredState = tracks.length || (raw && raw.full_midi) ? "complete" : "idle"; const state = String((raw && raw.state) || inferredState) .trim() .toLowerCase() .replace(/[\s-]+/g, "_"); const rawProgress = Number(raw && raw.progress); let progress = Number.isFinite(rawProgress) ? rawProgress : null; if (progress != null && progress > 1) { progress /= 100; } if (progress != null) { progress = clamp(progress, 0, 1); } return { state, status: typeof (raw && raw.status) === "string" ? raw.status.trim() : "", progress, audio_name: typeof (raw && raw.audio_name) === "string" ? raw.audio_name.trim() : "", elapsed: Math.max(0, asFinite(raw && raw.elapsed, 0)), duration: Math.max(suppliedDuration, noteEnd), note_count: Math.max(0, Math.round(asFinite(raw && raw.note_count, notesFromTracks))), full_midi: typeof (raw && raw.full_midi) === "string" ? raw.full_midi : "", tracks, _parse_error: Boolean(raw && raw._parse_error), }; } function stateCopy(payload) { if (payload.status) { return payload.status; } switch (payload.state) { case "queued": return "Waiting briefly for a ZeroGPU worker…"; case "loading": case "cold_start": return "Waking the model. A cold start can take a little longer."; case "transcribing": case "processing": return "Listening in five-second windows and separating instruments…"; case "complete": case "completed": case "ready": return "Every color is an independently playable instrument track."; case "error": case "failed": return "Something interrupted the transcription. Please try again."; default: return "Upload a recording to reveal its notes and instruments."; } } function formatClock(seconds) { if (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0) { return "—"; } const total = Math.round(Number(seconds)); const minutes = Math.floor(total / 60); const remainder = total % 60; return `${minutes}:${String(remainder).padStart(2, "0")}`; } function formatElapsed(seconds) { if (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0) { return "—"; } if (Number(seconds) < 60) { return `${Number(seconds).toFixed(Number(seconds) < 10 ? 1 : 0)}s`; } return formatClock(seconds); } function fileStem(name) { const raw = String(name || "muscriptor").replace(/\.[a-z0-9]{1,5}$/i, ""); const safe = raw .normalize("NFKD") .replace(/[^a-z0-9]+/gi, "-") .replace(/^-+|-+$/g, "") .toLowerCase(); return safe || "muscriptor"; } function normalizeMidiHref(value) { const midi = String(value || "").trim(); if (!midi) { return ""; } if (/^(data:|blob:|https?:\/\/|\/)/i.test(midi)) { return midi; } return `data:audio/midi;base64,${midi.replace(/\s+/g, "")}`; } function setText(selector, value) { const target = $(selector); if (target) { target.textContent = value; } } function announce(message) { window.clearTimeout(announceTimer); announcer.textContent = ""; announceTimer = window.setTimeout(() => { announcer.textContent = message; }, 60); } function hasSolo() { return snapshot.tracks.some((track) => trackState.get(track.id)?.solo); } function isAudible(trackId) { const state = trackState.get(trackId) || { solo: false, muted: false }; return !state.muted && (!hasSolo() || state.solo); } function syncTrackState(tracks) { const currentIds = new Set(tracks.map((track) => track.id)); for (const id of trackState.keys()) { if (!currentIds.has(id)) { trackState.delete(id); } } tracks.forEach((track) => { if (!trackState.has(track.id)) { trackState.set(track.id, { solo: false, muted: false }); } }); } function updateDownload(anchor, midi, filename) { const href = normalizeMidiHref(midi); if (href) { anchor.href = href; anchor.download = filename; anchor.removeAttribute("aria-disabled"); anchor.removeAttribute("tabindex"); } else { anchor.removeAttribute("href"); anchor.removeAttribute("download"); anchor.setAttribute("aria-disabled", "true"); anchor.setAttribute("tabindex", "-1"); } } function trackMeta(track) { const notes = `${track.note_count.toLocaleString()} ${track.note_count === 1 ? "note" : "notes"}`; if (track.is_drum) { return `${notes} · percussion`; } if (track.program != null) { return `${notes} · program ${track.program}`; } return notes; } function downloadIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 20 20"); svg.setAttribute("aria-hidden", "true"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "M10 3.5v9m0 0 3.2-3.2M10 12.5 6.8 9.3M4.5 15.7h11"); svg.append(path); return svg; } function renderTracks() { tracksRegion.replaceChildren(); const stem = fileStem(snapshot.audio_name); if (!snapshot.tracks.length) { const empty = document.createElement("div"); empty.className = "msv-track-empty"; empty.textContent = activeStates.has(snapshot.state) ? "Instrument tracks will arrive as transcription completes." : "Tracks will appear after transcription."; tracksRegion.append(empty); return; } snapshot.tracks.forEach((track) => { const state = trackState.get(track.id); const card = document.createElement("article"); card.className = "msv-track-card"; card.dataset.trackId = track.id; card.dataset.muted = String(Boolean(state.muted)); card.dataset.inactive = String(!isAudible(track.id)); card.style.setProperty("--track-color", track.color); card.setAttribute("aria-label", `${track.name}, ${trackMeta(track)}`); const swatch = document.createElement("span"); swatch.className = "msv-track-swatch"; swatch.setAttribute("aria-hidden", "true"); const copy = document.createElement("div"); copy.className = "msv-track-copy"; const name = document.createElement("h4"); name.className = "msv-track-name"; name.textContent = track.name; const meta = document.createElement("div"); meta.className = "msv-track-meta"; meta.textContent = trackMeta(track); copy.append(name, meta); const controls = document.createElement("div"); controls.className = "msv-track-controls"; controls.setAttribute("aria-label", `${track.name} controls`); const solo = document.createElement("button"); solo.className = "msv-track-button"; solo.type = "button"; solo.textContent = "S"; solo.title = `Solo ${track.name}`; solo.setAttribute("aria-label", `Solo ${track.name}`); solo.setAttribute("aria-pressed", String(Boolean(state.solo))); solo.addEventListener("click", () => { state.solo = !state.solo; renderTracks(); refreshTrackMix(); drawPianoRoll(); announce(`${track.name} solo ${state.solo ? "on" : "off"}.`); }); const mute = document.createElement("button"); mute.className = "msv-track-button"; mute.type = "button"; mute.textContent = "M"; mute.title = `Mute ${track.name}`; mute.setAttribute("aria-label", `Mute ${track.name}`); mute.setAttribute("aria-pressed", String(Boolean(state.muted))); mute.addEventListener("click", () => { state.muted = !state.muted; renderTracks(); refreshTrackMix(); drawPianoRoll(); announce(`${track.name} mute ${state.muted ? "on" : "off"}.`); }); const download = document.createElement("a"); download.className = "msv-track-download"; download.title = `Download ${track.name} MIDI`; download.setAttribute("aria-label", `Download ${track.name} MIDI`); download.append(downloadIcon()); updateDownload(download, track.midi, `${stem}-${fileStem(track.name)}.mid`); controls.append(solo, mute, download); card.append(swatch, copy, controls); tracksRegion.append(card); }); } function refreshTrackMix() { if (!playback) { return; } const now = playback.context.currentTime; playback.trackGains.forEach((gain, id) => { gain.gain.cancelScheduledValues(now); gain.gain.setTargetAtTime(isAudible(id) ? 1 : 0.0001, now, 0.018); }); } function renderProgress() { const active = activeStates.has(snapshot.state); progressRegion.hidden = !active; if (!active) { return; } const determinate = snapshot.progress != null; const percent = Math.round((snapshot.progress || 0) * 100); progressRegion.dataset.indeterminate = String(!determinate); setText('[data-field="progress-copy"]', stateCopy(snapshot)); setText('[data-field="progress-value"]', determinate ? `${percent}%` : "Working"); $('[data-field="progress-bar"]').style.width = `${percent}%`; progressTrack.setAttribute("aria-valuenow", String(percent)); progressTrack.setAttribute("aria-valuetext", determinate ? `${percent}%` : "In progress"); } function renderEmptyState(visibleTracks) { const noteTotal = snapshot.tracks.reduce((total, track) => total + track.notes.length, 0); const hasAudibleNotes = visibleTracks.some((track) => track.notes.length); let title = "A score will appear here"; let copy = "MuScriptor separates notes into individual, color-coded instrument tracks."; if (snapshot.state === "queued" || snapshot.state === "loading" || snapshot.state === "cold_start") { title = "Waking ZeroGPU"; copy = "The first run can take a little longer while the model is allocated and loaded."; } else if (snapshot.state === "transcribing" || snapshot.state === "processing") { title = "Listening in five-second windows"; copy = "Notes and instrument tracks will appear as soon as transcription finishes."; } else if (snapshot.state === "error" || snapshot.state === "failed") { title = "Transcription could not finish"; copy = stateCopy(snapshot); } else if (noteTotal && !hasAudibleNotes) { title = "All tracks are muted"; copy = "Unmute a track or change the solo selection to bring its notes back."; } else if (snapshot.tracks.length && !noteTotal) { title = "No pitched notes detected"; copy = "The MIDI tracks are available below even though the piano roll is empty."; } setText('[data-field="empty-title"]', title); setText('[data-field="empty-copy"]', copy); emptyRegion.hidden = hasAudibleNotes; } function niceTimeStep(duration) { if (duration <= 12) return 1; if (duration <= 45) return 2; if (duration <= 120) return 5; if (duration <= 360) return 10; return 30; } function setupCanvas(canvas, width, height, ratio) { const pixelWidth = Math.max(1, Math.round(width * ratio)); const pixelHeight = Math.max(1, Math.round(height * ratio)); if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; } canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; const context = canvas.getContext("2d"); context.setTransform(ratio, 0, 0, ratio, 0, 0); return context; } function roundedRect(context, x, y, width, height, radius) { const r = Math.min(radius, width / 2, height / 2); context.beginPath(); context.moveTo(x + r, y); context.arcTo(x + width, y, x + width, y + height, r); context.arcTo(x + width, y + height, x, y + height, r); context.arcTo(x, y + height, x, y, r); context.arcTo(x, y, x + width, y, r); context.closePath(); } function computePitchRange(tracks) { let minimumPitch = 128; let maximumPitch = -1; tracks.forEach((track) => { track.notes.forEach((note) => { minimumPitch = Math.min(minimumPitch, note.pitch); maximumPitch = Math.max(maximumPitch, note.pitch); }); }); if (maximumPitch < 0) { return { minimum: 36, maximum: 84 }; } let minimum = clamp(minimumPitch - 2, 0, 127); let maximum = clamp(maximumPitch + 2, 0, 127); if (maximum - minimum < 24) { const missing = 24 - (maximum - minimum); minimum = clamp(minimum - Math.ceil(missing / 2), 0, 127); maximum = clamp(minimum + 24, 0, 127); minimum = Math.max(0, maximum - 24); } return { minimum, maximum }; } function drawPianoRoll() { const visibleTracks = snapshot.tracks.filter((track) => isAudible(track.id)); renderEmptyState(visibleTracks); const viewportWidth = Math.max(320, rollScroll.clientWidth || root.clientWidth || 720); const height = Math.max(220, rollScroll.clientHeight || 334); const duration = Math.max(snapshot.duration, 1); const timePixels = duration <= 15 ? 72 : duration <= 60 ? 54 : duration <= 180 ? 38 : 25; const width = Math.round(Math.max(viewportWidth, Math.min(9000, 68 + duration * timePixels))); const ratio = width > 4200 ? 1 : Math.min(2, window.devicePixelRatio || 1); const keyWidth = 52; const rulerHeight = 24; const rightPadding = 14; const plotWidth = Math.max(1, width - keyWidth - rightPadding); const rangeSource = visibleTracks.some((track) => track.notes.length) ? visibleTracks : snapshot.tracks; const pitchRange = computePitchRange(rangeSource); const pitchCount = pitchRange.maximum - pitchRange.minimum + 1; const rowHeight = (height - rulerHeight) / pitchCount; rollStage.style.width = `${width}px`; const context = setupCanvas(notesCanvas, width, height, ratio); setupCanvas(cursorCanvas, width, height, ratio).clearRect(0, 0, width, height); context.clearRect(0, 0, width, height); context.fillStyle = "#0a0d12"; context.fillRect(0, 0, width, height); context.fillStyle = "#0e1118"; context.fillRect(0, 0, width, rulerHeight); const blackNotes = new Set([1, 3, 6, 8, 10]); for (let pitch = pitchRange.minimum; pitch <= pitchRange.maximum; pitch += 1) { const row = pitchRange.maximum - pitch; const y = rulerHeight + row * rowHeight; if (blackNotes.has(pitch % 12)) { context.fillStyle = "rgba(255,255,255,0.013)"; context.fillRect(keyWidth, y, width - keyWidth, rowHeight); } context.beginPath(); context.moveTo(keyWidth, Math.round(y) + 0.5); context.lineTo(width, Math.round(y) + 0.5); context.strokeStyle = pitch % 12 === 0 ? "rgba(255,255,255,0.068)" : "rgba(255,255,255,0.026)"; context.lineWidth = 1; context.stroke(); } const step = niceTimeStep(duration); context.font = '9px "SFMono-Regular", Consolas, monospace'; context.textBaseline = "middle"; for (let second = 0; second <= duration + 0.001; second += step) { const x = keyWidth + (second / duration) * plotWidth; context.beginPath(); context.moveTo(Math.round(x) + 0.5, 0); context.lineTo(Math.round(x) + 0.5, height); context.strokeStyle = second === 0 ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,0.045)"; context.lineWidth = 1; context.stroke(); context.fillStyle = "rgba(180,185,198,0.55)"; const label = second < 60 ? `${Math.round(second)}s` : formatClock(second); context.fillText(label, x + 5, rulerHeight / 2 + 0.5); } context.fillStyle = "#10141b"; context.fillRect(0, rulerHeight, keyWidth, height - rulerHeight); context.beginPath(); context.moveTo(keyWidth - 0.5, 0); context.lineTo(keyWidth - 0.5, height); context.strokeStyle = "rgba(255,255,255,0.1)"; context.stroke(); context.font = '8px "SFMono-Regular", Consolas, monospace'; context.textAlign = "right"; for (let pitch = pitchRange.minimum; pitch <= pitchRange.maximum; pitch += 1) { const row = pitchRange.maximum - pitch; const y = rulerHeight + row * rowHeight; const isBlack = blackNotes.has(pitch % 12); if (isBlack) { context.fillStyle = "#090b10"; context.fillRect(0, y, keyWidth * 0.66, rowHeight); } if (pitch % 12 === 0 && rowHeight >= 4.5) { context.fillStyle = "rgba(191,196,209,0.52)"; context.textBaseline = "middle"; context.fillText(`C${Math.floor(pitch / 12) - 1}`, keyWidth - 7, y + rowHeight / 2); } } context.textAlign = "left"; visibleTracks.forEach((track) => { track.notes.forEach((note) => { const x = keyWidth + (clamp(note.start, 0, duration) / duration) * plotWidth; const endX = keyWidth + (clamp(note.end, 0, duration) / duration) * plotWidth; const widthPx = Math.max(2.2, endX - x); const row = pitchRange.maximum - clamp(note.pitch, pitchRange.minimum, pitchRange.maximum); const y = rulerHeight + row * rowHeight + Math.max(0.65, rowHeight * 0.12); const heightPx = Math.max(2, rowHeight * 0.76); context.globalAlpha = 0.56 + (note.velocity / 127) * 0.38; context.fillStyle = track.color; roundedRect(context, x, y, widthPx, heightPx, Math.min(2.5, heightPx * 0.34)); context.fill(); if (heightPx >= 4) { context.globalAlpha = 0.24; context.fillStyle = "#ffffff"; roundedRect(context, x + 0.7, y + 0.7, Math.max(1, widthPx - 1.4), 1, 0.5); context.fill(); } }); }); context.globalAlpha = 1; geometry = { width, height, ratio, duration, keyWidth, plotWidth }; const audibleNames = visibleTracks.map((track) => track.name).join(", "); notesCanvas.setAttribute( "aria-label", visibleTracks.length ? `Piano roll with ${visibleTracks.length} audible tracks: ${audibleNames}` : "Piano roll with no audible tracks", ); } function clearCursor() { if (!geometry) return; const context = setupCanvas(cursorCanvas, geometry.width, geometry.height, geometry.ratio); context.clearRect(0, 0, geometry.width, geometry.height); } function drawCursor(seconds, follow = false) { if (!geometry) return; const context = setupCanvas(cursorCanvas, geometry.width, geometry.height, geometry.ratio); context.clearRect(0, 0, geometry.width, geometry.height); const x = geometry.keyWidth + (clamp(seconds, 0, geometry.duration) / geometry.duration) * geometry.plotWidth; const gradient = context.createLinearGradient(x, 0, x + 10, 0); gradient.addColorStop(0, "rgba(238,235,255,0.92)"); gradient.addColorStop(0.18, "rgba(150,133,255,0.22)"); gradient.addColorStop(1, "rgba(150,133,255,0)"); context.fillStyle = gradient; context.fillRect(x, 0, 12, geometry.height); context.fillStyle = "#f3f1ff"; context.fillRect(Math.round(x), 0, 1.25, geometry.height); if (follow && x > rollScroll.scrollLeft + rollScroll.clientWidth * 0.82) { rollScroll.scrollTo({ left: Math.max(0, x - rollScroll.clientWidth * 0.28), behavior: "smooth" }); } } function noteFrequency(pitch) { return clamp(440 * 2 ** ((pitch - 69) / 12), 28, 4200); } function scheduleNote(track, note, currentElapsed) { if (!playback) return; const context = playback.context; const trackGain = playback.trackGains.get(track.id); if (!trackGain) return; const scheduledStart = playback.startedAt + note.start; const start = Math.max(context.currentTime + 0.006, scheduledStart); const naturalLength = Math.max(0.025, note.end - Math.max(note.start, currentElapsed)); const length = track.is_drum ? Math.min(0.13, naturalLength) : Math.min(8, naturalLength); const end = start + length; const oscillator = context.createOscillator(); const envelope = context.createGain(); const amplitude = (note.velocity / 127) * (track.is_drum ? 0.05 : 0.075); oscillator.type = track.is_drum ? "square" : ["sine", "triangle", "sine", "triangle"][ Math.abs(track.id.split("").reduce((sum, letter) => sum + letter.charCodeAt(0), 0)) % 4 ]; oscillator.frequency.setValueAtTime(track.is_drum ? 52 + (note.pitch % 24) * 7 : noteFrequency(note.pitch), start); if (track.is_drum) { oscillator.frequency.exponentialRampToValueAtTime(34, end); } envelope.gain.setValueAtTime(0.0001, start); envelope.gain.exponentialRampToValueAtTime(Math.max(0.0002, amplitude), start + Math.min(0.012, length * 0.25)); envelope.gain.exponentialRampToValueAtTime(0.0001, end); oscillator.connect(envelope); envelope.connect(trackGain); oscillator.start(start); oscillator.stop(end + 0.01); playback.nodes.add(oscillator); oscillator.addEventListener("ended", () => playback && playback.nodes.delete(oscillator), { once: true }); } function runScheduler() { if (!playback) return; const elapsed = playback.context.currentTime - playback.startedAt; const horizon = elapsed + 0.42; snapshot.tracks.forEach((track) => { let index = playback.nextNote.get(track.id) || 0; while (index < track.notes.length && track.notes[index].start <= horizon) { const note = track.notes[index]; if (note.end > elapsed) { scheduleNote(track, note, elapsed); } index += 1; } playback.nextNote.set(track.id, index); }); } function playbackFrame() { if (!playback) return; const elapsed = Math.max(0, playback.context.currentTime - playback.startedAt); drawCursor(elapsed, true); if (elapsed >= playback.duration + 0.08) { stopPlayback(true); announce("Playback finished."); return; } playback.frame = window.requestAnimationFrame(playbackFrame); } async function startPlayback() { const AudioContextClass = window.AudioContext || window.webkitAudioContext; const hasNotes = snapshot.tracks.some((track) => track.notes.length); if (!AudioContextClass || !hasNotes) { announce(AudioContextClass ? "There are no notes to play." : "WebAudio is not available in this browser."); return; } stopPlayback(false); const context = new AudioContextClass({ latencyHint: "interactive" }); if (context.state === "suspended") { await context.resume(); } const master = context.createGain(); const compressor = context.createDynamicsCompressor(); master.gain.value = 0.62; compressor.threshold.value = -14; compressor.knee.value = 14; compressor.ratio.value = 8; compressor.attack.value = 0.004; compressor.release.value = 0.12; master.connect(compressor); compressor.connect(context.destination); const trackGains = new Map(); snapshot.tracks.forEach((track) => { const gain = context.createGain(); gain.gain.value = isAudible(track.id) ? 1 : 0.0001; gain.connect(master); trackGains.set(track.id, gain); }); playback = { context, master, trackGains, nodes: new Set(), nextNote: new Map(snapshot.tracks.map((track) => [track.id, 0])), startedAt: context.currentTime + 0.07, duration: Math.max(snapshot.duration, 0.1), timer: 0, frame: 0, }; runScheduler(); playback.timer = window.setInterval(runScheduler, 90); playback.frame = window.requestAnimationFrame(playbackFrame); playButton.disabled = true; playButton.setAttribute("aria-pressed", "true"); stopButton.disabled = false; announce("Playback started."); } function stopPlayback(resetCursor = true) { if (playback) { window.clearInterval(playback.timer); window.cancelAnimationFrame(playback.frame); playback.nodes.forEach((node) => { try { node.stop(); } catch (error) { // A node that has already ended cannot be stopped again. } }); playback.context.close().catch(() => {}); playback = null; } const hasNotes = snapshot.tracks.some((track) => track.notes.length); playButton.disabled = !hasNotes; playButton.setAttribute("aria-pressed", "false"); stopButton.disabled = true; if (resetCursor) { clearCursor(); } } function render(nextSnapshot, options = {}) { const previousState = snapshot.state; const previousSignature = snapshot.tracks .map((track) => `${track.id}:${track.notes.length}:${track.notes.at(-1)?.end || 0}`) .join("|"); const nextSignature = nextSnapshot.tracks .map((track) => `${track.id}:${track.notes.length}:${track.notes.at(-1)?.end || 0}`) .join("|"); if (playback && previousSignature !== nextSignature) { stopPlayback(true); } snapshot = nextSnapshot; syncTrackState(snapshot.tracks); root.dataset.state = snapshot.state || "idle"; setText('[data-field="state-label"]', stateLabels[snapshot.state] || titleFromId(snapshot.state)); setText('[data-field="audio-name"]', snapshot.audio_name || "Your transcription"); setText('[data-field="status"]', stateCopy(snapshot)); setText('[data-field="duration"]', formatClock(snapshot.duration)); setText('[data-field="note-count"]', snapshot.note_count ? snapshot.note_count.toLocaleString() : "—"); setText('[data-field="track-count"]', snapshot.tracks.length ? snapshot.tracks.length.toLocaleString() : "—"); setText('[data-field="elapsed"]', formatElapsed(snapshot.elapsed)); setText( '[data-field="track-summary"]', snapshot.tracks.length ? `${snapshot.tracks.length} ${snapshot.tracks.length === 1 ? "track" : "tracks"}` : "No tracks yet", ); updateDownload(fullDownload, snapshot.full_midi, `${fileStem(snapshot.audio_name)}-full.mid`); renderProgress(); renderTracks(); drawPianoRoll(); const hasNotes = snapshot.tracks.some((track) => track.notes.length); if (!playback) { playButton.disabled = !hasNotes; stopButton.disabled = true; } const submitButton = findSubmitButton(); if (submitButton && terminalStates.has(snapshot.state)) { submitButton.removeAttribute("aria-busy"); } if (!options.silent && (previousState !== snapshot.state || options.local)) { announce(`${stateLabels[snapshot.state] || titleFromId(snapshot.state)}. ${stateCopy(snapshot)}`); } } function updateFromProps() { render(normalizePayload(parseIncoming(props.value))); } function findSubmitButton() { const localRoot = element.getRootNode && element.getRootNode(); return (localRoot && localRoot.querySelector && localRoot.querySelector("#transcribe-button")) || document.querySelector("#transcribe-button"); } function bindSubmitButton() { const button = findSubmitButton(); if (!button || button.__muscriptorVisualizerBound) { return Boolean(button); } button.__muscriptorVisualizerBound = true; button.addEventListener("click", () => { if (button.matches(":disabled") || button.getAttribute("aria-disabled") === "true") { return; } button.setAttribute("aria-busy", "true"); render( normalizePayload({ ...snapshot, state: "queued", status: "Request sent. Allocating a ZeroGPU worker…", progress: 0.01, elapsed: 0, duration: 0, note_count: 0, full_midi: "", tracks: [], }), { local: true }, ); }, { capture: true }); return true; } playButton.addEventListener("click", () => { startPlayback().catch(() => { stopPlayback(true); announce("Playback could not start. Check this browser's audio permissions."); }); }); stopButton.addEventListener("click", () => { stopPlayback(true); announce("Playback stopped."); }); const resizeObserver = new ResizeObserver(() => { window.cancelAnimationFrame(resizeFrame); resizeFrame = window.requestAnimationFrame(drawPianoRoll); }); resizeObserver.observe(rollScroll); if (!bindSubmitButton()) { const buttonObserver = new MutationObserver(() => { if (bindSubmitButton()) { buttonObserver.disconnect(); } }); buttonObserver.observe(document.body, { childList: true, subtree: true }); window.setTimeout(() => buttonObserver.disconnect(), 15000); } updateFromProps(); watch("value", updateFromProps);