Spaces:
Running on Zero
Running on Zero
| 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); | |