muscriptor-studio / assets /visualizer.js
Mike0021's picture
Initial MuScriptor ZeroGPU studio
4bada97 verified
Raw
History Blame Contribute Delete
31.8 kB
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);