const root = element.querySelector('.ms-root'); const $ = (selector) => root.querySelector(selector); const $$ = (selector) => Array.from(root.querySelectorAll(selector)); const LIVE_REVEAL_STEP_SECONDS = .1; const LIVE_REVEAL_INTERVAL_MS = 100; const LIVE_WINDOW_SECONDS = 5; const LIVE_REVEAL_MIN_START_DELAY_MS = 1800; const LIVE_REVEAL_MAX_START_DELAY_MS = 6500; const LIVE_REVEAL_SAFETY_SECONDS = 1.25; const MIN_LOOP_SECONDS = .25; const ui = { empty: $('.ms-empty'), workspace: $('.ms-workspace'), state: $('[data-field="state"]'), status: $('[data-field="status"]'), audioName: $('[data-field="audio-name"]'), summary: $('[data-field="summary"]'), progress: $('.ms-progress'), progressLabel: $('[data-field="progress-label"]'), progressValue: $('[data-field="progress-value"]'), progressBar: $('[data-field="progress-bar"]'), progressTrack: $('.ms-progress [role="progressbar"]'), play: $('[data-action="play"]'), stop: $('[data-action="stop"]'), seek: $('[data-action="seek"]'), clock: $('[data-field="clock"]'), duration: $('[data-field="duration"]'), volume: $('[data-action="volume"]'), loop: $('[data-action="loop"]'), selectRegion: $('[data-action="select-region"]'), loopIn: $('[data-action="loop-in"]'), loopOut: $('[data-action="loop-out"]'), clearRegion: $('[data-action="clear-region"]'), loopMode: $('[data-field="loop-mode"]'), loopLabel: $('[data-field="loop-button-label"]'), loopRange: $('[data-field="loop-range"]'), rollHelp: $('[data-field="roll-help"]'), sourceMix: $('[data-action="source-mix"]'), stereo: $('[data-action="stereo"]'), synthState: $('[data-field="synth-state"]'), zoom: $('[data-action="zoom"]'), zoomIn: $('[data-action="zoom-in"]'), zoomOut: $('[data-action="zoom-out"]'), zoomValue: $('[data-field="zoom-value"]'), keys: $('[data-canvas="keys"]'), canvas: $('[data-canvas="roll"]'), cursor: $('[data-canvas="cursor"]'), rollScroll: $('[data-region="roll-scroll"]'), rollStage: $('[data-region="roll-stage"]'), tracks: $('[data-region="tracks"]'), score: $('[data-region="score"]'), scoreEmpty: $('[data-region="score-empty"]'), scoreToolbar: $('[data-region="score-toolbar"]'), scoreDocument: $('[data-action="score-document"]'), scoreDocumentMeta: $('[data-field="score-document-meta"]'), scoreOpen: $('[data-action="open-score"]'), scoreDownload: $('[data-action="download-score"]'), scoreEmptyTitle: $('[data-field="score-empty-title"]'), scoreEmptyCopy: $('[data-field="score-empty-copy"]'), notationInfo: $('.ms-notation-info'), announcer: $('.ms-announcer') }; const state = { data: null, audio: null, master: null, compressor: null, gains: new Map(), midiGain: null, midiPanner: null, originalGain: null, originalPanner: null, originalElement: null, originalUrl: '', originalBuffer: null, originalBufferPromise: null, originalBufferSource: null, originalFailed: false, sourceMix: Number(ui.sourceMix.value), stereo: Boolean(ui.stereo.checked), synth: null, synthPromise: null, synthFailed: false, soundFontBytes: null, soundFontPromise: null, channels: new Map(), nextChannel: 0, programOverrides: new Map(), playing: false, starting: false, transportToken: 0, songTime: 0, originSong: 0, originAudio: 0, nextNote: 0, notes: [], playbackNotes: [], playbackSignature: '', scheduledNotes: new Set(), nodes: new Set(), muted: new Set(), solo: new Set(), animation: 0, loopEnabled: false, loopIn: null, loopOut: null, regionMode: false, regionAnchor: null, suppressRollClick: false, rollGeometry: null, cursorX: null, activeTab: 'roll', activeScoreId: 'full', liveTarget: null, liveHorizon: 0, liveTimer: 0, liveStartTimer: 0, liveRevealStarted: false, liveStartDelay: 0, liveAudioName: '' }; const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const formatTime = (seconds) => { const safe = Math.max(0, Number(seconds) || 0); const minutes = Math.floor(safe / 60); return `${minutes}:${(safe % 60).toFixed(1).padStart(4, '0')}`; }; const fileStem = (value) => { const stem = String(value || 'muscriptor').replace(/\.[a-z0-9]{1,6}$/i, ''); return stem.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'muscriptor'; }; const noteName = (pitch) => `${['C','C♯','D','E♭','E','F','F♯','G','A♭','A','B♭','B'][pitch % 12]}${Math.floor(pitch / 12) - 1}`; const titleFromId = (value) => String(value || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); const setText = (selector, value) => { const target = $(selector); if (target) target.textContent = value; }; function normalize(value) { if (typeof value === 'string') { try { value = JSON.parse(value); } catch (_) { value = {}; } } const raw = value && typeof value === 'object' ? value : {}; const tracks = Array.isArray(raw.tracks) ? raw.tracks.map((track, index) => ({ id: String(track.id ?? track.key ?? `track-${index}`), key: String(track.key || `track-${index}`), name: String(track.name || `Track ${index + 1}`), color: String(track.color || '#8b7cff'), note_count: Number(track.note_count || 0), program: Number(track.program || 0), is_drum: Boolean(track.is_drum), midi: String(track.midi || ''), midi_name: String(track.midi_name || ''), notes: Array.isArray(track.notes) ? track.notes.map((note) => ({ pitch: clamp(Number(note.pitch || 60), 0, 127), start: Math.max(0, Number(note.start || 0)), end: Math.max(Number(note.end || 0.1), Number(note.start || 0) + .01), velocity: clamp(Number(note.velocity || 100), 1, 127) })).sort((a, b) => a.start - b.start || a.pitch - b.pitch) : [] })) : []; return { state: String(raw.state || 'idle'), status: String(raw.status || ''), progress: clamp(Number(raw.progress || 0), 0, 1), completed_windows: Math.max(0, Math.round(Number(raw.completed_windows || 0))), total_windows: Math.max(0, Math.round(Number(raw.total_windows || 0))), audio_name: String(raw.audio_name || ''), export_stem: String(raw.export_stem || fileStem(raw.audio_name)), elapsed: Number(raw.elapsed || 0), duration: Number(raw.duration || 0), available_until: Math.max(0, Number(raw.available_until || 0)), live_horizon: Math.max(0, Number(raw.live_horizon || 0)), original_audio: String(raw.original_audio || ''), note_count: Number(raw.note_count || tracks.reduce((sum, track) => sum + track.notes.length, 0)), tracks, full_midi: String(raw.full_midi || ''), score_svg: String(raw.score_svg || ''), score_pdf_url: String(raw.score_pdf_url || ''), score_parts: Array.isArray(raw.score_parts) ? raw.score_parts.map((part, index) => ({ id: String(part.track_id ?? part.track_key ?? `part-${index}`), key: String(part.track_key ?? part.track_id ?? `part-${index}`), name: String(part.name || `Instrument ${index + 1}`), pdf: String(part.pdf || ''), pdf_name: String(part.pdf_name || ''), musicxml: String(part.musicxml || ''), musicxml_name: String(part.musicxml_name || ''), pages: Array.isArray(part.pages) ? part.pages.filter((url) => typeof url === 'string' && url) : [], page_count: Number(part.page_count || 0), render_error: String(part.render_error || ''), })) : [], score_page_urls: Array.isArray(raw.score_page_urls) ? raw.score_page_urls.filter((url) => typeof url === 'string' && url) : [], score_pages: Number(raw.score_pages || 0), notation: raw.notation && typeof raw.notation === 'object' ? raw.notation : {} }; } function announce(message) { ui.announcer.textContent = ''; setTimeout(() => { ui.announcer.textContent = message; }, 30); } function updateDownload(selector, href, name) { const anchor = $(selector); if (!anchor) return; if (href) { anchor.href = href; anchor.download = name; anchor.removeAttribute('aria-disabled'); } else { anchor.removeAttribute('href'); anchor.removeAttribute('download'); anchor.setAttribute('aria-disabled', 'true'); } } function trackAudible(id) { return !state.muted.has(id) && (!state.solo.size || state.solo.has(id)); } const SOUNDFONT_URL = 'https://huggingface.co/MuScriptor/assets/resolve/main/MuseScore_General.sf3'; const GM_PROGRAMS = [ [0, 'Acoustic piano'], [4, 'Electric piano'], [9, 'Chromatic percussion'], [19, 'Organ'], [24, 'Acoustic guitar'], [27, 'Electric guitar'], [30, 'Distorted guitar'], [32, 'Acoustic bass'], [33, 'Electric bass'], [40, 'Violin'], [41, 'Viola'], [42, 'Cello'], [46, 'Harp'], [48, 'String ensemble'], [52, 'Choir / voice'], [56, 'Trumpet'], [57, 'Trombone'], [60, 'French horn'], [61, 'Brass section'], [65, 'Alto saxophone'], [66, 'Tenor saxophone'], [67, 'Baritone saxophone'], [68, 'Oboe'], [70, 'Bassoon'], [71, 'Clarinet'], [73, 'Flute'], [80, 'Synth lead'], [89, 'Synth pad'] ]; function setSynthStatus(label, kind = '') { ui.synthState.textContent = label; ui.synthState.dataset.kind = kind; } function preloadSoundFont() { if (state.soundFontBytes) return Promise.resolve(state.soundFontBytes); if (state.soundFontPromise) return state.soundFontPromise; setSynthStatus('Downloading MuseScore instruments…', 'loading'); state.soundFontPromise = fetch(SOUNDFONT_URL, { cache: 'force-cache' }) .then((response) => { if (!response.ok) throw new Error(`SoundFont HTTP ${response.status}`); return response.arrayBuffer(); }) .then((bytes) => { state.soundFontBytes = bytes; return bytes; }) .catch((error) => { state.soundFontPromise = null; throw error; }); return state.soundFontPromise; } async function initSoundFont() { const library = globalThis.MuScriptorSynthLib; if (!library?.WorkletSynthesizer || !library?.processorUrl) { throw new Error('SpessaSynth is unavailable'); } setSynthStatus('Loading audio engine…', 'loading'); await state.audio.audioWorklet.addModule(library.processorUrl); const synth = new library.WorkletSynthesizer(state.audio); synth.connect(state.midiGain); await synth.isReady; setSynthStatus('Preparing MuseScore instruments…', 'loading'); const soundFontBytes = await preloadSoundFont(); await synth.soundBankManager.addSoundBank(soundFontBytes.slice(0), 'MuseScore General'); state.synth = synth; state.channels.clear(); state.nextChannel = 0; setSynthStatus('MuseScore General ready', 'ready'); refreshMix(); return synth; } function startSynthLoading() { if (state.synth) return Promise.resolve(state.synth); if (state.synthPromise) return state.synthPromise; state.synthFailed = false; state.synthPromise = initSoundFont().catch((error) => { state.synthPromise = null; state.synthFailed = true; console.warn('SoundFont unavailable, using WebAudio fallback:', error); setSynthStatus('Lightweight synth fallback', 'error'); return null; }); return state.synthPromise; } function ensureAudio() { if (state.audio) return; const AudioContext = window.AudioContext || window.webkitAudioContext; if (!AudioContext) return; state.audio = new AudioContext({ latencyHint: 'interactive' }); state.master = state.audio.createGain(); state.compressor = state.audio.createDynamicsCompressor(); state.midiGain = state.audio.createGain(); state.midiPanner = state.audio.createStereoPanner(); state.originalGain = state.audio.createGain(); state.originalPanner = state.audio.createStereoPanner(); state.master.gain.value = Number(ui.volume.value); state.compressor.threshold.value = -14; state.compressor.knee.value = 18; state.compressor.ratio.value = 7; state.midiGain.connect(state.midiPanner).connect(state.master); state.originalGain.connect(state.originalPanner).connect(state.master); state.master.connect(state.compressor).connect(state.audio.destination); rebuildGains(); applySourceMix(); startSynthLoading(); } function rebuildGains() { if (!state.audio || !state.data) return; const currentTrackIds = new Set(state.data.tracks.map((track) => track.id)); state.gains.forEach((gain, id) => { if (currentTrackIds.has(id)) return; try { gain.disconnect(); } catch (_) {} state.gains.delete(id); }); state.data.tracks.forEach((track) => { if (state.gains.has(track.id)) return; const gain = state.audio.createGain(); gain.connect(state.midiGain); state.gains.set(track.id, gain); }); refreshMix(); } function findOriginalAudio() { const selectors = ['#original-audio-output audio', '#audio-input audio']; const roots = [document, element.getRootNode(), element]; for (const selector of selectors) { for (const searchRoot of roots) { const audio = searchRoot?.querySelector?.(selector); if (audio && (audio.currentSrc || audio.src)) return audio; } } return null; } function stopOriginalBufferSource() { if (!state.originalBufferSource) return; try { state.originalBufferSource.stop(); } catch (_) {} try { state.originalBufferSource.disconnect(); } catch (_) {} state.originalBufferSource = null; } function resetOriginalAudio() { stopOriginalBufferSource(); state.originalElement = null; state.originalUrl = ''; state.originalBuffer = null; state.originalBufferPromise = null; state.originalFailed = false; } function discoverOriginalAudio() { let audio = null; let url = String(state.data?.original_audio || '').trim(); // Keep a DOM fallback for payloads created by older app versions, but the // current server always sends a stable /gradio_api/file= URL explicitly. if (!url) { audio = findOriginalAudio(); url = String(audio?.currentSrc || audio?.src || ''); } if (!url) return null; if (url !== state.originalUrl) { stopOriginalBufferSource(); state.originalUrl = url; state.originalBuffer = null; state.originalBufferPromise = null; state.originalFailed = false; } state.originalElement = audio; if (audio && !audio.paused) audio.pause(); return url; } async function loadOriginalBuffer() { if (!state.audio) return null; const url = discoverOriginalAudio(); if (!url) return null; if (state.originalBuffer) return state.originalBuffer; if (!state.originalBufferPromise) { const requestedUrl = url; state.originalBufferPromise = fetch(url, { credentials: 'same-origin' }) .then((response) => { if (!response.ok) throw new Error(`Original audio HTTP ${response.status}`); return response.arrayBuffer(); }) .then((data) => state.audio.decodeAudioData(data.slice(0))) .then((buffer) => { if (state.originalUrl !== requestedUrl) return null; state.originalBuffer = buffer; state.originalFailed = false; applySourceMix(); return buffer; }) .catch((error) => { console.warn('Original audio could not be decoded:', error); if (state.originalUrl === requestedUrl) { state.originalBufferPromise = null; state.originalFailed = true; applySourceMix(); } return null; }); } return state.originalBufferPromise; } function startOriginalBufferSource(startAt, offset) { stopOriginalBufferSource(); if (!state.audio || !state.originalGain || !state.originalBuffer) return; const safeOffset = clamp(offset, 0, Math.max(0, state.originalBuffer.duration - .001)); if (safeOffset >= state.originalBuffer.duration) return; const source = state.audio.createBufferSource(); source.buffer = state.originalBuffer; source.connect(state.originalGain); source.addEventListener('ended', () => { if (state.originalBufferSource === source) state.originalBufferSource = null; }, { once: true }); source.start(startAt, safeOffset); state.originalBufferSource = source; } function applySourceMix() { const mix = clamp(Number(state.sourceMix), 0, 1); const hasOriginal = Boolean(state.originalBuffer || (!state.originalFailed && discoverOriginalAudio())); // Match the official client: a predictable linear blend in normal mode, // with split mode overriding the blend so both sources remain audible. const originalLevel = state.stereo && hasOriginal ? .62 : (hasOriginal ? 1 - mix : 0); const midiLevel = state.stereo && hasOriginal ? .62 : (hasOriginal ? mix : 1); if (state.audio) { const now = state.audio.currentTime; state.originalGain?.gain.setTargetAtTime(Math.max(.0001, originalLevel), now, .018); state.midiGain?.gain.setTargetAtTime(Math.max(.0001, midiLevel), now, .018); state.originalPanner?.pan.setTargetAtTime(state.stereo && hasOriginal ? -1 : 0, now, .018); state.midiPanner?.pan.setTargetAtTime(state.stereo && hasOriginal ? 1 : 0, now, .018); } $$('[data-source]').forEach((button) => { const selected = button.dataset.source === 'original' ? mix <= .01 : mix >= .99; button.setAttribute('aria-pressed', String(selected)); }); ui.stereo.closest('label')?.setAttribute('data-active', String(state.stereo)); } function refreshMix() { if (state.audio) state.gains.forEach((gain, id) => gain.gain.setTargetAtTime(trackAudible(id) ? 1 : .0001, state.audio.currentTime, .015)); if (state.synth) state.channels.forEach((channel, id) => { state.synth.midiChannels[channel]?.setSystemParameter('isMuted', !trackAudible(id)); }); $$('.ms-track-row').forEach((row) => { const id = row.dataset.track; row.dataset.inactive = String(!trackAudible(id)); row.querySelector('[data-mix="mute"]').setAttribute('aria-pressed', String(state.muted.has(id))); row.querySelector('[data-mix="solo"]').setAttribute('aria-pressed', String(state.solo.has(id))); }); drawRoll(); } function channelFor(track) { if (state.channels.has(track.id)) return state.channels.get(track.id); let channel; if (track.is_drum) { channel = 9; state.synth.midiChannels[channel]?.setDrums(true); } else { channel = state.nextChannel++; if (state.nextChannel === 9) state.nextChannel += 1; if (channel === 9) channel = state.nextChannel++; while (channel >= state.synth.channelCount) state.synth.addNewChannel(); const program = state.programOverrides.get(track.id) ?? track.program; state.synth.programChange(channel, clamp(Math.round(program), 0, 127)); } state.synth.midiChannels[channel]?.setSystemParameter('isMuted', !trackAudible(track.id)); state.channels.set(track.id, channel); return channel; } function waveform(track) { if (track.is_drum) return 'square'; if (track.program >= 24 && track.program <= 39) return 'triangle'; if (track.program >= 40 && track.program <= 55) return 'sawtooth'; return track.program >= 80 ? 'square' : 'sine'; } function scheduleNote(track, note, nowSong) { const scheduledStart = state.originAudio + (note.start - state.originSong); const scheduledEnd = state.originAudio + (note.end - state.originSong); const startAt = Math.max(state.audio.currentTime + .006, scheduledStart); const endAt = Math.max(startAt + .03, scheduledEnd); if (state.synth) { const channel = channelFor(track); state.synth.noteOn(channel, note.pitch, Math.round(note.velocity), { time: startAt }); state.synth.noteOff(channel, note.pitch, { time: Math.min(endAt, startAt + 12) }); return; } const gain = state.gains.get(track.id); if (!state.audio || !gain) return; const duration = clamp(endAt - startAt, .03, 8); const oscillator = state.audio.createOscillator(); const envelope = state.audio.createGain(); oscillator.type = waveform(track); oscillator.frequency.value = track.is_drum ? 55 + (note.pitch % 20) * 8 : 440 * 2 ** ((note.pitch - 69) / 12); const level = (note.velocity / 127) * (track.is_drum ? .055 : .07); envelope.gain.setValueAtTime(.0001, startAt); envelope.gain.exponentialRampToValueAtTime(Math.max(.0002, level), startAt + Math.min(.012, duration * .25)); envelope.gain.exponentialRampToValueAtTime(.0001, startAt + duration); oscillator.connect(envelope).connect(gain); oscillator.start(startAt); oscillator.stop(startAt + duration + .02); state.nodes.add(oscillator); oscillator.addEventListener('ended', () => state.nodes.delete(oscillator), { once: true }); } function exactTime() { if (!state.playing || !state.audio) return state.songTime; return clamp( state.originSong + Math.max(0, state.audio.currentTime - state.originAudio), 0, state.data?.duration || Number.POSITIVE_INFINITY, ); } function flattenNotes(data) { if (!data) return []; return data.tracks .flatMap((track) => track.notes.map((note) => ({ track, note }))) .sort((a, b) => a.note.start - b.note.start || a.note.pitch - b.note.pitch || a.track.id.localeCompare(b.track.id)); } function noteKey(item) { const { track, note } = item; return `${track.id}:${note.pitch}:${note.start.toFixed(4)}:${note.end.toFixed(4)}:${note.velocity}`; } function playbackSource(data = state.data) { if ( data?.state === 'transcribing' && state.liveTarget && state.liveTarget.audio_name === data.audio_name ) return state.liveTarget; return data; } function noteListSignature(data) { return data?.tracks.map((track) => { const last = track.notes.at(-1); return `${track.id}:${track.notes.length}:${last?.start || 0}:${last?.end || 0}`; }).join('|') || ''; } function playableEnd() { const data = state.data; if (!data) return 0; const duration = Math.max(0, Number(data.duration) || 0); if (data.state !== 'transcribing') return duration; const source = playbackSource(data); const declaredAvailable = Math.max( Number(source?.available_until) || 0, Number(data.available_until) || 0, Number(data.live_horizon) || 0, ); const available = declaredAvailable || state.playbackNotes.reduce( (latest, item) => Math.max(latest, item.note.end), 0, ); return clamp(available, 0, Math.max(duration, available)); } function hasLoopRegion() { return Number.isFinite(state.loopIn) && Number.isFinite(state.loopOut) && state.loopOut - state.loopIn >= MIN_LOOP_SECONDS; } function loopBounds() { const availableEnd = playableEnd(); if (!hasLoopRegion()) { return { start: 0, end: availableEnd, valid: availableEnd >= MIN_LOOP_SECONDS, selected: false }; } const start = clamp(state.loopIn, 0, availableEnd); const end = clamp(state.loopOut, 0, availableEnd); return { start, end, valid: end - start >= MIN_LOOP_SECONDS, selected: true }; } function updateTransportButtons() { const hasPlayback = state.playbackNotes.length > 0 && playableEnd() > 0; ui.play.disabled = !hasPlayback || state.starting; ui.stop.disabled = !hasPlayback; [ui.loop, ui.selectRegion, ui.loopIn, ui.loopOut].forEach((button) => { button.disabled = !hasPlayback; }); ui.clearRegion.disabled = !hasLoopRegion(); } function updateLoopUI() { const bounds = loopBounds(); const selected = hasLoopRegion(); root.dataset.loopActive = String(state.loopEnabled); root.dataset.regionMode = String(state.regionMode); ui.loop.setAttribute('aria-pressed', String(state.loopEnabled)); ui.loop.setAttribute('aria-label', state.loopEnabled ? 'Disable loop' : 'Enable loop'); ui.loopLabel.textContent = state.loopEnabled ? 'Loop on' : 'Loop off'; ui.selectRegion.setAttribute('aria-pressed', String(state.regionMode)); if (state.regionMode) ui.loopMode.textContent = 'Drag across the piano roll'; else if (state.loopEnabled) ui.loopMode.textContent = selected ? 'Region loop active' : 'Full loop active'; else ui.loopMode.textContent = selected ? 'Region ready' : 'Loop off'; if (selected) { const waiting = state.data?.state === 'transcribing' && state.loopOut > playableEnd() + .001; ui.loopRange.textContent = `${formatTime(state.loopIn)} → ${formatTime(state.loopOut)}${waiting ? ' · partly pending' : ''}`; } else { const prefix = state.data?.state === 'transcribing' ? 'Available' : 'Full range'; ui.loopRange.textContent = `${prefix} · ${formatTime(bounds.start)} → ${formatTime(bounds.end)}`; } ui.rollHelp.textContent = state.regionMode ? 'Drag horizontally to select the loop region · Esc cancels.' : 'Click to seek · Shift-click sets In · Alt/Option-click sets Out.'; updateTransportButtons(); } function lowerBound(time) { let low = 0, high = state.playbackNotes.length; while (low < high) { const middle = (low + high) >> 1; if (state.playbackNotes[middle].note.start < time) low = middle + 1; else high = middle; } return low; } function stopScheduledAudio() { stopOriginalBufferSource(); state.nodes.forEach((node) => { try { node.stop(); } catch (_) {} }); state.nodes.clear(); state.synth?.stopAll(); state.scheduledNotes.clear(); } function stopPlayback(reset = false) { if (state.playing) state.songTime = clamp(exactTime(), 0, state.data?.duration || 0); state.transportToken += 1; state.playing = false; state.starting = false; cancelAnimationFrame(state.animation); stopScheduledAudio(); if (reset) { const bounds = loopBounds(); state.songTime = state.loopEnabled && bounds.valid ? bounds.start : 0; } ui.play.textContent = '▶'; ui.play.setAttribute('aria-label', 'Play'); updateTimeline(); updateTransportButtons(); } async function startPlayback() { if (!state.data || !state.playbackNotes.length || state.playing || state.starting) return; const bounds = loopBounds(); const limit = state.loopEnabled ? bounds.end : playableEnd(); if (state.loopEnabled && !bounds.valid) { announce('The selected loop region is not available yet.'); return; } if (limit <= 0) return; state.starting = true; const token = ++state.transportToken; ui.play.textContent = '…'; ui.play.setAttribute('aria-label', 'Preparing playback'); updateTransportButtons(); try { ensureAudio(); if (!state.audio) return; await state.audio.resume(); const originalPromise = loadOriginalBuffer(); await Promise.all([ state.synth ? Promise.resolve(state.synth) : (state.synthPromise || startSynthLoading()), originalPromise, ]); if (token !== state.transportToken) return; if ( state.songTime >= limit - .02 || (state.loopEnabled && state.songTime < bounds.start) ) state.songTime = state.loopEnabled ? bounds.start : 0; state.songTime = clamp(state.songTime, 0, limit); state.originSong = state.songTime; state.originAudio = state.audio.currentTime + .065; startOriginalBufferSource(state.originAudio, state.songTime); // Include sustained notes that began before a seek position. Twelve seconds // matches the scheduler's maximum rendered note length. state.scheduledNotes.clear(); state.nextNote = lowerBound(Math.max(0, state.songTime - 12)); state.playing = true; state.starting = false; ui.play.textContent = '❚❚'; ui.play.setAttribute('aria-label', 'Pause'); applySourceMix(); updateTransportButtons(); frame(); } finally { if (token === state.transportToken && !state.playing) { state.starting = false; ui.play.textContent = '▶'; ui.play.setAttribute('aria-label', 'Play'); updateTransportButtons(); } } } function restartTransportAt(time) { if (!state.playing || !state.audio) return; stopScheduledAudio(); state.songTime = Math.max(0, time); state.originSong = state.songTime; state.originAudio = state.audio.currentTime + .04; startOriginalBufferSource(state.originAudio, state.songTime); state.nextNote = lowerBound(Math.max(0, state.songTime - 12)); updateTimeline(); } function frame() { if (!state.playing) return; const now = exactTime(); const bounds = loopBounds(); const limit = state.loopEnabled ? bounds.end : playableEnd(); if (now >= limit - .002) { if (state.loopEnabled && bounds.valid) { restartTransportAt(bounds.start); state.animation = requestAnimationFrame(frame); return; } state.songTime = limit; stopPlayback(false); if (state.data?.state === 'transcribing') announce('Playback reached the transcription currently available.'); return; } const horizon = Math.min(now + .45, limit - .001); while (state.nextNote < state.playbackNotes.length && state.playbackNotes[state.nextNote].note.start <= horizon) { const item = state.playbackNotes[state.nextNote++]; const key = noteKey(item); if (item.note.end > now && item.note.start < limit && !state.scheduledNotes.has(key)) { state.scheduledNotes.add(key); scheduleNote(item.track, item.note, now); } } state.songTime = now; updateTimeline(); state.animation = requestAnimationFrame(frame); } function seekTo(time) { const resume = state.playing; stopPlayback(false); state.songTime = clamp(time, 0, playableEnd()); updateTimeline(); if (resume) startPlayback(); } function updateTimeline() { if (!state.data) return; ui.seek.value = String(clamp(state.songTime, 0, state.data.duration)); ui.clock.textContent = formatTime(state.songTime); if (state.rollGeometry) drawCursor(state.songTime, state.playing); else drawRoll(); } function setRegionMode(enabled) { state.regionMode = Boolean(enabled && playableEnd() >= MIN_LOOP_SECONDS); state.regionAnchor = null; updateLoopUI(); drawRoll(); if (state.regionMode) announce('Region selection on. Drag horizontally across the piano roll.'); } function applyLoopRegion(start, end, { seek = false } = {}) { const available = playableEnd(); if (available < MIN_LOOP_SECONDS) return; let safeStart = clamp(Math.min(start, end), 0, available); let safeEnd = clamp(Math.max(start, end), 0, available); if (safeEnd - safeStart < MIN_LOOP_SECONDS) { safeEnd = Math.min(available, safeStart + MIN_LOOP_SECONDS); safeStart = Math.max(0, safeEnd - MIN_LOOP_SECONDS); } state.loopIn = safeStart; state.loopOut = safeEnd; state.loopEnabled = true; if (seek) seekTo(safeStart); updateLoopUI(); drawRoll(); } function setLoopPoint(kind, time = state.songTime) { const available = playableEnd(); if (available < MIN_LOOP_SECONDS) return; const currentStart = hasLoopRegion() ? state.loopIn : 0; const currentEnd = hasLoopRegion() ? state.loopOut : available; if (kind === 'in') { let start = clamp(time, 0, available); let end = currentEnd; if (end - start < MIN_LOOP_SECONDS) end = Math.min(available, start + MIN_LOOP_SECONDS); if (end - start < MIN_LOOP_SECONDS) start = Math.max(0, end - MIN_LOOP_SECONDS); applyLoopRegion(start, end); announce(`Loop In set to ${formatTime(state.loopIn)}.`); } else { let start = currentStart; let end = clamp(time, 0, available); if (end - start < MIN_LOOP_SECONDS) start = Math.max(0, end - MIN_LOOP_SECONDS); if (end - start < MIN_LOOP_SECONDS) end = Math.min(available, start + MIN_LOOP_SECONDS); applyLoopRegion(start, end); announce(`Loop Out set to ${formatTime(state.loopOut)}.`); } } function rollTimeFromPointer(event) { if (!state.rollGeometry) return 0; const rect = ui.canvas.getBoundingClientRect(); const x = clamp( state.rollGeometry.scrollLeft + event.clientX - rect.left, 0, state.rollGeometry.width, ); return (x / state.rollGeometry.width) * state.rollGeometry.duration; } function scorePartForTrack(track) { return state.data?.score_parts.find((part) => part.id === track.id || part.key === track.key) || null; } function renderTracks() { ui.tracks.replaceChildren(); if (!state.data.tracks.length) { const empty = document.createElement('div'); empty.className = 'ms-track-placeholder'; empty.textContent = ['queued', 'loading', 'transcribing'].includes(state.data.state) ? 'Instrument tracks will appear as transcription progresses.' : 'Tracks will appear after transcription.'; ui.tracks.append(empty); return; } state.data.tracks.forEach((track) => { const row = document.createElement('article'); row.className = 'ms-track-row'; row.dataset.track = track.id; row.style.setProperty('--track', track.color); row.setAttribute('aria-label', `${track.name}, ${track.note_count.toLocaleString('en-US')} notes`); const dot = document.createElement('i'); dot.className = 'ms-track-color'; const body = document.createElement('div'); body.className = 'ms-track-body'; const header = document.createElement('div'); header.className = 'ms-track-header'; const copy = document.createElement('div'); copy.className = 'ms-track-copy'; const name = document.createElement('strong'); name.textContent = track.name; const meta = document.createElement('span'); meta.textContent = `${track.note_count.toLocaleString('en-US')} notes${track.is_drum ? ' · drums' : ''}`; copy.append(name, meta); const mixControls = document.createElement('div'); mixControls.className = 'ms-track-controls ms-track-mix-controls'; mixControls.setAttribute('aria-label', `${track.name} mix controls`); ['solo', 'mute'].forEach((kind) => { const button = document.createElement('button'); button.type = 'button'; button.dataset.mix = kind; button.textContent = kind === 'solo' ? 'S' : 'M'; button.title = kind === 'solo' ? `Solo ${track.name}` : `Mute ${track.name}`; button.setAttribute('aria-label', button.title); button.setAttribute('aria-pressed', 'false'); mixControls.append(button); }); header.append(copy, mixControls); const footer = document.createElement('div'); footer.className = 'ms-track-footer'; const sound = document.createElement(track.is_drum ? 'div' : 'label'); sound.className = 'ms-track-sound'; sound.title = track.is_drum ? 'General MIDI drum kit' : `Choose the playback instrument for ${track.name}`; if (!track.is_drum) { const select = document.createElement('select'); select.className = 'ms-program-select'; select.dataset.program = track.id; select.setAttribute('aria-label', `Sound for ${track.name}`); const selectedProgram = state.programOverrides.get(track.id) ?? track.program; GM_PROGRAMS.forEach(([program, label]) => { const option = document.createElement('option'); option.value = String(program); option.textContent = label; option.selected = program === selectedProgram; select.append(option); }); if (!GM_PROGRAMS.some(([program]) => program === selectedProgram)) { const option = document.createElement('option'); option.value = String(selectedProgram); option.textContent = `GM program ${selectedProgram + 1}`; option.selected = true; select.prepend(option); } sound.append(select); } else { const fixedSound = document.createElement('b'); fixedSound.className = 'ms-program-fixed'; fixedSound.textContent = 'Drum kit'; sound.append(fixedSound); } const exports = document.createElement('div'); exports.className = 'ms-track-controls ms-track-export-controls'; exports.setAttribute('aria-label', `${track.name} exports`); const scorePart = scorePartForTrack(track); const scoreButton = document.createElement('button'); const hasVisualScore = Boolean(scorePart && (scorePart.pdf || scorePart.pages.length)); scoreButton.type = 'button'; scoreButton.className = 'ms-track-score'; scoreButton.textContent = scorePart?.pdf ? 'PDF' : scorePart?.pages.length ? 'Score' : scorePart?.musicxml ? 'XML' : 'Score'; scoreButton.dataset.scoreTrack = track.id; scoreButton.title = `Show ${track.name} score`; scoreButton.setAttribute('aria-label', `Show ${track.name} score`); scoreButton.disabled = !scorePart || (!hasVisualScore && !scorePart.musicxml); const download = document.createElement('a'); download.textContent = 'MIDI ↓'; download.title = `Download ${track.name} MIDI`; download.setAttribute('aria-label', download.title); if (track.midi) { download.href = track.midi; download.download = track.midi_name || `${state.data.export_stem}-${fileStem(track.key)}.mid`; } else download.setAttribute('aria-disabled', 'true'); exports.append(scoreButton, download); footer.append(sound, exports); body.append(header, footer); row.append(dot, body); ui.tracks.append(row); }); } function niceTimeStep(duration, pixelsPerSecond = 30) { const candidates = [.25, .5, 1, 2, 5, 10, 15, 30, 60]; return candidates.find((step) => step * pixelsPerSecond >= 64) || Math.max(60, duration / 10); } 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 safeRadius = Math.min(radius, width / 2, height / 2); context.beginPath(); context.moveTo(x + safeRadius, y); context.arcTo(x + width, y, x + width, y + height, safeRadius); context.arcTo(x + width, y + height, x, y + height, safeRadius); context.arcTo(x, y + height, x, y, safeRadius); context.arcTo(x, y, x + width, y, safeRadius); 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 drawPianoKeys(minPitch, maxPitch, pitchSpan, height, ratio, rulerHeight) { const width = Math.max(1, ui.keys.getBoundingClientRect().width || 56); const context = setupCanvas(ui.keys, width, height, ratio); const plotHeight = Math.max(1, height - rulerHeight); const rowHeight = plotHeight / pitchSpan; const blackNotes = new Set([1, 3, 6, 8, 10]); context.clearRect(0, 0, width, height); context.fillStyle = '#10141b'; context.fillRect(0, 0, width, height); context.fillStyle = '#0e1118'; context.fillRect(0, 0, width, rulerHeight); context.fillStyle = 'rgba(180,185,198,.42)'; context.font = '8px ui-monospace, monospace'; context.textAlign = 'center'; context.textBaseline = 'middle'; context.fillText('KEYS', width / 2, rulerHeight / 2); for (let pitch = minPitch; pitch <= maxPitch; pitch += 1) { const y = rulerHeight + (maxPitch - pitch) * rowHeight; if (blackNotes.has(pitch % 12)) { context.fillStyle = '#080b0f'; context.fillRect(0, y + .35, width * .68, Math.max(1, rowHeight - .7)); context.fillStyle = 'rgba(255,255,255,.055)'; context.fillRect(1, y + .7, width * .68 - 2, 1); } context.beginPath(); context.moveTo(0, Math.round(y + rowHeight) + .5); context.lineTo(width, Math.round(y + rowHeight) + .5); context.strokeStyle = pitch % 12 === 0 ? 'rgba(255,255,255,.13)' : 'rgba(255,255,255,.04)'; context.stroke(); if (pitch % 12 === 0 && rowHeight >= 4.5) { context.fillStyle = 'rgba(191,196,209,.58)'; context.font = '8px ui-monospace, monospace'; context.textAlign = 'right'; context.fillText(`C${Math.floor(pitch / 12) - 1}`, width - 6, y + rowHeight / 2); } } context.fillStyle = 'rgba(255,255,255,.1)'; context.fillRect(width - 1, 0, 1, height); } function clearCursor() { if (!state.rollGeometry) return; const geometry = state.rollGeometry; const context = setupCanvas(ui.cursor, geometry.viewportWidth, geometry.height, geometry.ratio); context.clearRect(0, 0, geometry.viewportWidth, geometry.height); state.cursorX = null; } function drawCursor(seconds, follow = false) { if (!state.rollGeometry || state.activeTab !== 'roll') return; const geometry = state.rollGeometry; const context = setupCanvas(ui.cursor, geometry.viewportWidth, geometry.height, geometry.ratio); const globalX = clamp((clamp(seconds, 0, geometry.duration) / geometry.duration) * geometry.width, 0, geometry.width - 1); const x = globalX - geometry.scrollLeft; context.clearRect(0, 0, geometry.viewportWidth, geometry.height); if (x >= -12 && x <= geometry.viewportWidth + 12) { const gradient = context.createLinearGradient(x, 0, x + 12, 0); gradient.addColorStop(0, 'rgba(238,255,250,.94)'); gradient.addColorStop(.2, 'rgba(69,209,181,.22)'); gradient.addColorStop(1, 'rgba(69,209,181,0)'); context.fillStyle = gradient; context.fillRect(x, 0, 12, geometry.height); context.fillStyle = '#effffb'; context.fillRect(Math.round(x), 0, 1.25, geometry.height); } state.cursorX = x; if (follow) { const viewport = geometry.viewportWidth; if (x > viewport * .82) ui.rollScroll.scrollLeft = Math.max(0, globalX - viewport * .28); else if (x < viewport * .08) ui.rollScroll.scrollLeft = Math.max(0, globalX - viewport * .18); } } function drawRoll() { if (!state.data || state.activeTab !== 'roll') return; const viewportWidth = Math.max(1, ui.rollScroll.clientWidth || 720); const height = Math.max(260, ui.rollScroll.clientHeight || 420); const duration = Math.max(state.data.duration, 1); const timePixels = duration <= 15 ? 72 : duration <= 60 ? 54 : duration <= 180 ? 38 : 25; const zoom = clamp(Number(ui.zoom.value) || 1, .5, 12); const width = Math.round(Math.max(viewportWidth, Math.min(240000, duration * timePixels * zoom))); const ratio = Math.min(3, Math.max(2, window.devicePixelRatio || 1)); const scrollLeft = Math.min(ui.rollScroll.scrollLeft, Math.max(0, width - viewportWidth)); const rulerHeight = 24; const visibleTracks = state.data.tracks.filter((track) => trackAudible(track.id)); const rangeSource = visibleTracks.some((track) => track.notes.length) ? visibleTracks : state.data.tracks; const pitchRange = computePitchRange(rangeSource); const pitchSpan = pitchRange.maximum - pitchRange.minimum + 1; const rowHeight = (height - rulerHeight) / pitchSpan; ui.rollStage.style.width = `${width}px`; ui.rollStage.style.height = `${height}px`; ui.canvas.style.left = `${scrollLeft}px`; ui.cursor.style.left = `${scrollLeft}px`; const context = setupCanvas(ui.canvas, viewportWidth, height, ratio); setupCanvas(ui.cursor, viewportWidth, height, ratio).clearRect(0, 0, viewportWidth, height); state.cursorX = null; context.clearRect(0, 0, viewportWidth, height); context.fillStyle = '#0a0d12'; context.fillRect(0, 0, viewportWidth, height); context.fillStyle = '#0e1118'; context.fillRect(0, 0, viewportWidth, rulerHeight); const blackNotes = new Set([1, 3, 6, 8, 10]); for (let pitch = pitchRange.minimum; pitch <= pitchRange.maximum; pitch += 1) { const y = rulerHeight + (pitchRange.maximum - pitch) * rowHeight; if (blackNotes.has(pitch % 12)) { context.fillStyle = 'rgba(255,255,255,.013)'; context.fillRect(0, y, viewportWidth, rowHeight); } context.beginPath(); context.moveTo(0, Math.round(y) + .5); context.lineTo(viewportWidth, Math.round(y) + .5); context.strokeStyle = pitch % 12 === 0 ? 'rgba(255,255,255,.07)' : 'rgba(255,255,255,.026)'; context.stroke(); } const pixelsPerSecond = width / duration; const step = niceTimeStep(duration, pixelsPerSecond); context.font = '9px ui-monospace, monospace'; context.textBaseline = 'middle'; for (let second = 0; second <= duration + .001; second += step) { const x = (second / duration) * width - scrollLeft; if (x < -50 || x > viewportWidth + 1) continue; context.beginPath(); context.moveTo(Math.round(x) + .5, 0); context.lineTo(Math.round(x) + .5, height); context.strokeStyle = second === 0 ? 'rgba(255,255,255,.1)' : 'rgba(255,255,255,.045)'; context.stroke(); context.fillStyle = 'rgba(180,185,198,.55)'; const label = second < 60 ? `${Number(second.toFixed(2))}s` : formatTime(second); context.fillText(label, x + 5, rulerHeight / 2 + .5); } if (hasLoopRegion()) { const regionStart = (clamp(state.loopIn, 0, duration) / duration) * width - scrollLeft; const regionEnd = (clamp(state.loopOut, 0, duration) / duration) * width - scrollLeft; const visibleStart = clamp(regionStart, 0, viewportWidth); const visibleEnd = clamp(regionEnd, 0, viewportWidth); context.fillStyle = state.loopEnabled ? 'rgba(69,209,181,.065)' : 'rgba(177,188,184,.035)'; if (visibleEnd > visibleStart) context.fillRect(visibleStart, rulerHeight, visibleEnd - visibleStart, height - rulerHeight); context.fillStyle = 'rgba(0,0,0,.16)'; if (regionStart > 0) context.fillRect(0, rulerHeight, Math.min(regionStart, viewportWidth), height - rulerHeight); if (regionEnd < viewportWidth) context.fillRect(Math.max(0, regionEnd), rulerHeight, viewportWidth - Math.max(0, regionEnd), height - rulerHeight); context.strokeStyle = state.loopEnabled ? 'rgba(69,209,181,.82)' : 'rgba(177,188,184,.48)'; context.lineWidth = 1; [regionStart, regionEnd].forEach((x) => { if (x < 0 || x > viewportWidth) return; context.beginPath(); context.moveTo(Math.round(x) + .5, 0); context.lineTo(Math.round(x) + .5, height); context.stroke(); }); context.fillStyle = state.loopEnabled ? '#9ff3e2' : 'rgba(200,210,206,.7)'; context.font = '700 8px ui-monospace, monospace'; context.textBaseline = 'middle'; if (regionStart >= -18 && regionStart <= viewportWidth + 1) context.fillText('IN', clamp(regionStart + 4, 2, viewportWidth - 18), rulerHeight / 2); if (regionEnd >= -1 && regionEnd <= viewportWidth + 20) context.fillText('OUT', clamp(regionEnd - 24, 2, viewportWidth - 24), rulerHeight / 2); } visibleTracks.forEach((track) => track.notes.forEach((note) => { const x = (clamp(note.start, 0, duration) / duration) * width - scrollLeft; const endX = (clamp(note.end, 0, duration) / duration) * width - scrollLeft; if (endX < -2 || x > viewportWidth + 2) return; const noteWidth = Math.max(2.2, endX - x); const y = rulerHeight + (pitchRange.maximum - clamp(note.pitch, pitchRange.minimum, pitchRange.maximum)) * rowHeight + Math.max(.65, rowHeight * .12); const noteHeight = Math.max(2, rowHeight * .76); context.globalAlpha = .56 + (note.velocity / 127) * .38; context.fillStyle = track.color; roundedRect(context, x, y, noteWidth, noteHeight, Math.min(2.5, noteHeight * .34)); context.fill(); if (noteHeight >= 4) { context.globalAlpha = .24; context.fillStyle = '#fff'; roundedRect(context, x + .7, y + .7, Math.max(1, noteWidth - 1.4), 1, .5); context.fill(); } })); context.globalAlpha = 1; state.rollGeometry = { width, viewportWidth, scrollLeft, height, ratio, duration, rulerHeight }; ui.zoomValue.textContent = `${Number(zoom.toFixed(2))}×`; drawPianoKeys(pitchRange.minimum, pitchRange.maximum, pitchSpan, height, ratio, rulerHeight); drawCursor(state.songTime, false); } function scoreDocuments(data) { const documents = []; if (data.score_pdf_url || data.score_page_urls.length || data.score_svg || data.notation?.musicxml) { documents.push({ id: 'full', name: 'Full score', subtitle: 'Conductor score', pdf: data.score_pdf_url, pages: data.score_page_urls, rawSvg: data.score_svg, pageCount: data.score_pages, pdfName: data.notation?.pdf_name || '', musicxml: data.notation?.musicxml || '', musicxmlName: data.notation?.musicxml_name || '', renderError: Array.isArray(data.notation?.warnings) ? data.notation.warnings.join(' · ') : String(data.notation?.warnings || ''), }); } data.score_parts.forEach((part) => documents.push({ id: `part:${part.id}`, name: part.name, subtitle: 'Instrument part', pdf: part.pdf, pdfName: part.pdf_name, pages: part.pages, rawSvg: '', pageCount: part.page_count, musicxml: part.musicxml, musicxmlName: part.musicxml_name, renderError: part.render_error, })); return documents; } function updateScoreAnchor(anchor, href, filename, openInNewTab = false) { if (href) { anchor.href = href; if (openInNewTab) anchor.target = '_blank'; else anchor.download = filename; anchor.removeAttribute('aria-disabled'); } else { anchor.removeAttribute('href'); anchor.removeAttribute('download'); anchor.setAttribute('aria-disabled', 'true'); } } function renderScoreWorkspace(data) { const documents = scoreDocuments(data); const generating = data.state === 'generating_score'; const failed = data.state === 'notation_error'; if (!documents.some((descriptor) => descriptor.id === state.activeScoreId)) state.activeScoreId = 'full'; ui.scoreDocument.replaceChildren(); documents.forEach((descriptor) => { const option = document.createElement('option'); option.value = descriptor.id; option.textContent = descriptor.id === 'full' ? 'Full score' : descriptor.name; option.selected = descriptor.id === state.activeScoreId; ui.scoreDocument.append(option); }); ui.score.replaceChildren(); if (!documents.length) { ui.scoreToolbar.hidden = true; ui.score.hidden = true; ui.scoreEmpty.hidden = false; ui.scoreEmptyTitle.textContent = generating ? 'Engraving the score…' : failed ? 'Score generation failed' : 'No score generated yet'; ui.scoreEmptyCopy.textContent = generating ? 'Building the conductor score first, then rendering one PDF for every detected instrument.' : failed ? data.status : 'Use the Score controls above the studio to generate the full PDF and instrument parts.'; return; } const selected = documents.find((descriptor) => descriptor.id === state.activeScoreId) || documents[0]; state.activeScoreId = selected.id; ui.scoreDocument.value = selected.id; ui.scoreToolbar.hidden = false; ui.scoreEmpty.hidden = true; ui.score.hidden = false; const pageCopy = selected.pageCount ? `${selected.pageCount} page${selected.pageCount > 1 ? 's' : ''}` : selected.pdf ? 'PDF document' : 'MusicXML only'; ui.scoreDocumentMeta.textContent = `${selected.subtitle} · ${pageCopy}${generating ? ' · updating…' : ''}`; const pdfFilename = selected.pdfName || `${data.export_stem}-${selected.id === 'full' ? 'full-score' : fileStem(selected.name)}.pdf`; const xmlFilename = selected.musicxmlName || `${data.export_stem}-${selected.id === 'full' ? 'full-score' : fileStem(selected.name)}.musicxml`; updateScoreAnchor(ui.scoreOpen, selected.pdf, pdfFilename, true); ui.scoreOpen.textContent = 'Open PDF'; const downloadHref = selected.pdf || selected.musicxml; const downloadFilename = selected.pdf ? pdfFilename : xmlFilename; ui.scoreDownload.textContent = selected.pdf ? 'Download PDF' : 'Download MusicXML'; updateScoreAnchor(ui.scoreDownload, downloadHref, downloadFilename, false); // Chrome's native PDF plugin is unreliable inside Gradio's nested custom // component iframe. The SVG pages below are the exact vector pages used by // the backend to assemble the PDF, so they provide the same score without a // browser-plugin dependency. The toolbar still opens/downloads the PDF. if (selected.pages.length || selected.rawSvg) { ui.score.dataset.mode = 'pages'; selected.pages.forEach((url, index) => { const page = document.createElement('figure'); page.className = 'ms-score-page'; const image = document.createElement('img'); image.src = url; image.alt = `${selected.name}, page ${index + 1} of ${selected.pages.length}`; image.loading = index === 0 ? 'eager' : 'lazy'; const number = document.createElement('figcaption'); number.className = 'ms-score-page-number'; number.textContent = `Page ${index + 1} / ${selected.pages.length}`; page.append(image, number); ui.score.append(page); }); if (!selected.pages.length && selected.rawSvg) { const page = document.createElement('figure'); page.className = 'ms-score-page'; page.innerHTML = selected.rawSvg; ui.score.append(page); } } else { ui.score.dataset.mode = 'message'; const message = document.createElement('div'); message.className = 'ms-score-message'; const title = document.createElement('strong'); title.textContent = 'Preview unavailable'; const copy = document.createElement('p'); copy.textContent = selected.renderError ? `The vector renderer rejected this document. ${selected.renderError}` : 'This score has no vector preview, but its editable MusicXML export is available.'; const open = document.createElement('a'); open.textContent = selected.pdf ? 'Open PDF' : 'Download MusicXML'; updateScoreAnchor(open, selected.pdf || selected.musicxml, selected.pdf ? pdfFilename : xmlFilename, Boolean(selected.pdf)); message.append(title, copy, open); ui.score.append(message); } } function switchTab(tab) { state.activeTab = tab; $$('[data-tab]').forEach((button) => button.setAttribute('aria-selected', String(button.dataset.tab === tab))); $$('[data-view]').forEach((view) => { view.hidden = view.dataset.view !== tab; }); if (tab === 'roll') requestAnimationFrame(drawRoll); } function renderProgressSegments(data, indeterminate) { if (indeterminate) return { completed: 0, total: 0 }; const inferredTotal = Math.max(0, Math.ceil((Number(data.duration) || 0) / LIVE_WINDOW_SECONDS)); const totalWindows = Math.max(0, Number(data.total_windows) || inferredTotal); const segmentCount = Math.max(1, totalWindows || 10); if (ui.progressBar.children.length !== segmentCount) { const fragment = document.createDocumentFragment(); for (let index = 0; index < segmentCount; index += 1) { const segment = document.createElement('b'); segment.setAttribute('aria-hidden', 'true'); segment.append(document.createElement('span')); fragment.append(segment); } ui.progressBar.replaceChildren(fragment); } ui.progressBar.style.setProperty('--segment-count', String(segmentCount)); const exactCompleted = totalWindows ? clamp(Number(data.completed_windows) || 0, 0, totalWindows) : clamp((Number(data.progress) || 0) * segmentCount, 0, segmentCount); Array.from(ui.progressBar.children).forEach((segment, index) => { const fill = clamp(exactCompleted - index, 0, 1) * 100; const fillElement = segment.firstElementChild; if (fillElement) fillElement.style.width = `${fill}%`; segment.dataset.complete = String(fill >= 100); segment.dataset.partial = String(fill > 0 && fill < 100); }); return { completed: exactCompleted, total: totalWindows || segmentCount }; } function render(data, { silent = false } = {}) { const playbackTime = state.playing ? exactTime() : state.songTime; const audioChanged = Boolean(state.data && ( state.data.audio_name !== data.audio_name || state.data.original_audio !== data.original_audio )); const previousPlaybackSignature = state.playbackSignature; if (audioChanged) { stopPlayback(true); resetOriginalAudio(); ui.rollScroll.scrollLeft = 0; state.rollGeometry = null; state.synth?.stopAll(); state.channels.clear(); state.nextChannel = 0; state.programOverrides.clear(); state.activeScoreId = 'full'; state.loopIn = null; state.loopOut = null; state.regionMode = false; state.regionAnchor = null; state.songTime = 0; } state.data = data; state.notes = flattenNotes(data); const source = playbackSource(data); state.playbackNotes = flattenNotes(source); const nextPlaybackSignature = noteListSignature(source); state.playbackSignature = nextPlaybackSignature; if (state.playing) { state.songTime = clamp(playbackTime, 0, data.duration || playableEnd()); if (previousPlaybackSignature !== nextPlaybackSignature) { state.nextNote = lowerBound(Math.max(0, state.songTime - 12)); } } else { state.songTime = clamp(state.songTime, 0, Math.max(data.duration, playableEnd())); } root.dataset.state = data.state; const active = ['queued', 'loading', 'transcribing', 'generating_score'].includes(data.state); const failed = ['error', 'notation_error'].includes(data.state); const hasResults = data.tracks.length > 0 || active; ui.empty.hidden = hasResults; ui.workspace.hidden = !hasResults; ui.state.textContent = ({ idle: 'Ready for audio', queued: 'ZeroGPU queued', loading: 'Loading model', transcribing: 'Transcribing', generating_score: 'Generating score', complete: 'MIDI ready', ready: 'Score ready', error: 'Error', notation_error: 'MIDI ready' })[data.state] || data.state; ui.status.textContent = data.status; ui.audioName.textContent = data.audio_name || 'Your transcription'; ui.summary.textContent = data.audio_name ? `${data.note_count.toLocaleString('en-US')} notes · ${data.tracks.length} tracks` : '—'; const indeterminate = data.state === 'generating_score'; const progressPercent = failed ? 100 : Math.round(data.progress * 100); ui.progress.hidden = !(active || failed); ui.progress.dataset.indeterminate = String(indeterminate); ui.progress.dataset.mode = indeterminate ? 'score' : 'windows'; ui.progressTrack.hidden = indeterminate; ui.progressLabel.textContent = data.status; ui.progressValue.textContent = failed ? (data.state === 'notation_error' ? 'Score issue' : 'Failed') : indeterminate ? 'Rendering…' : `${progressPercent}%`; const progressWindows = renderProgressSegments(data, indeterminate); if (indeterminate) { ui.progressTrack.removeAttribute('aria-valuenow'); ui.progressTrack.setAttribute('aria-valuetext', 'Rendering in progress'); } else { ui.progressTrack.setAttribute('aria-valuenow', String(progressPercent)); const windowText = data.state === 'transcribing' && progressWindows.total ? `${Math.floor(progressWindows.completed)} of ${progressWindows.total} windows transcribed` : `${progressPercent}% complete`; ui.progressTrack.setAttribute('aria-valuetext', failed ? 'Operation failed' : windowText); } ui.duration.textContent = formatTime(data.duration); ui.seek.max = String(Math.max(.01, data.duration)); setText('[data-field="track-count"]', data.tracks.length ? `${data.tracks.length} track${data.tracks.length > 1 ? 's' : ''}` : 'No tracks yet'); setText('[data-field="info-duration"]', formatTime(data.duration)); setText('[data-field="note-count"]', data.note_count ? data.note_count.toLocaleString('en-US') : '—'); setText('[data-field="info-tracks"]', data.tracks.length || '—'); setText('[data-field="elapsed"]', data.elapsed ? `${data.elapsed.toFixed(1)} s` : '—'); const pitches = state.notes.map((item) => item.note.pitch); setText('[data-field="pitch-range"]', pitches.length ? `${noteName(Math.min(...pitches))} → ${noteName(Math.max(...pitches))}` : 'Waiting for notes'); state.muted.forEach((id) => { if (!data.tracks.some((track) => track.id === id)) state.muted.delete(id); }); state.solo.forEach((id) => { if (!data.tracks.some((track) => track.id === id)) state.solo.delete(id); }); renderTracks(); rebuildGains(); renderScoreWorkspace(data); const hasScore = scoreDocuments(data).length > 0; const notation = data.notation || {}; ui.notationInfo.hidden = !hasScore; setText('[data-field="tempo"]', notation.tempo ? `${notation.tempo} BPM` : '—'); setText('[data-field="meter"]', notation.time_signature || '—'); setText('[data-field="key"]', notation.key_signature || '—'); const pickupBeats = Number(notation.pickup_beats || 0); setText('[data-field="pickup"]', pickupBeats > 0 ? `${Number(pickupBeats.toFixed(2))} beat${pickupBeats === 1 ? '' : 's'}` : 'None'); setText('[data-field="quantization"]', notation.quantization ? `${notation.quantization} · mixed` : '—'); setText('[data-field="parts"]', notation.part_count || '—'); const reviewCount = Array.isArray(notation.review_flags) ? notation.review_flags.length : 0; setText('[data-field="review"]', reviewCount ? `${reviewCount} flag${reviewCount === 1 ? '' : 's'}` : 'None'); const stem = data.export_stem || fileStem(data.audio_name); updateDownload('[data-download="midi"]', data.full_midi, `${stem}-full-transcription.mid`); updateDownload('[data-download="musicxml"]', notation.musicxml || '', notation.musicxml_name || `${stem}-full-score.musicxml`); updateDownload('[data-download="pdf"]', notation.pdf || data.score_pdf_url, notation.pdf_name || `${stem}-full-score.pdf`); updateDownload('[data-download="bundle"]', notation.bundle || '', notation.bundle_name || `${stem}-exports.zip`); drawRoll(); updateTimeline(); updateLoopUI(); if (!state.playing && data.state === 'transcribing' && data.live_horizon > 0) { drawCursor(data.live_horizon, true); ui.clock.textContent = formatTime(data.live_horizon); } if (state.audio) discoverOriginalAudio(); applySourceMix(); if (data.state === 'ready' || data.state === 'generating_score') switchTab('score'); if (!silent) announce(`${ui.state.textContent}. ${data.status}`); } function liveSnapshot(target, horizon) { const visibleUntil = clamp(horizon, 0, Math.max(target.duration, target.available_until)); const tracks = target.tracks.map((track) => { const notes = track.notes.filter((note) => note.start <= visibleUntil + .0001); return { ...track, notes, note_count: notes.length }; }); return { ...target, tracks, note_count: tracks.reduce((total, track) => total + track.notes.length, 0), live_horizon: visibleUntil, }; } function stopLiveReveal(clearTarget = true) { if (state.liveTimer) window.clearInterval(state.liveTimer); if (state.liveStartTimer) window.clearTimeout(state.liveStartTimer); state.liveTimer = 0; state.liveStartTimer = 0; if (clearTarget) { state.liveTarget = null; state.liveHorizon = 0; state.liveRevealStarted = false; state.liveStartDelay = 0; state.liveAudioName = ''; } } function liveRevealStartDelay(data) { const completedWindows = Math.max( 1, Math.ceil((Number(data.available_until) || 0) / LIVE_WINDOW_SECONDS), ); const observedSecondsPerWindow = Math.max(0, Number(data.elapsed) || 0) / completedWindows; const adaptiveDelay = ( observedSecondsPerWindow - LIVE_WINDOW_SECONDS + LIVE_REVEAL_SAFETY_SECONDS ) * 1000; return Math.round(clamp( adaptiveDelay, LIVE_REVEAL_MIN_START_DELAY_MS, LIVE_REVEAL_MAX_START_DELAY_MS, )); } function advanceLiveReveal() { if (!state.liveTarget) { stopLiveReveal(); return false; } state.liveHorizon = Math.min( state.liveTarget.available_until, state.liveHorizon + LIVE_REVEAL_STEP_SECONDS, ); render(liveSnapshot(state.liveTarget, state.liveHorizon), { silent: true }); if (state.liveHorizon >= state.liveTarget.available_until) { stopLiveReveal(false); return false; } return true; } function runLiveReveal() { if (state.liveTimer || !state.liveTarget || state.liveHorizon >= state.liveTarget.available_until) return; state.liveRevealStarted = true; if (!advanceLiveReveal()) return; state.liveTimer = window.setInterval(advanceLiveReveal, LIVE_REVEAL_INTERVAL_MS); } function startLiveReveal() { if ( state.liveTimer || state.liveStartTimer || !state.liveTarget || state.liveHorizon >= state.liveTarget.available_until ) return; if (state.liveRevealStarted) { runLiveReveal(); return; } state.liveStartDelay = liveRevealStartDelay(state.liveTarget); state.liveStartTimer = window.setTimeout(() => { state.liveStartTimer = 0; runLiveReveal(); }, state.liveStartDelay); } function queueLiveReveal(data) { if (state.liveAudioName !== data.audio_name) { stopLiveReveal(); state.liveAudioName = data.audio_name; } state.liveTarget = data; state.liveHorizon = Math.min(state.liveHorizon, data.available_until); render(liveSnapshot(data, state.liveHorizon), { silent: true }); startLiveReveal(); } ui.play.addEventListener('click', () => { if (state.playing) stopPlayback(false); else startPlayback().catch((error) => { console.error(error); setSynthStatus('Playback unavailable', 'error'); }); }); ui.stop.addEventListener('click', () => stopPlayback(true)); ui.seek.addEventListener('input', () => state.data && seekTo(Number(ui.seek.value))); ui.loop.addEventListener('click', () => { state.loopEnabled = !state.loopEnabled; const bounds = loopBounds(); if (state.loopEnabled && !bounds.valid) { state.loopEnabled = false; announce('At least 250 milliseconds of transcription are needed for a loop.'); } else if (state.loopEnabled) { const now = state.playing ? exactTime() : state.songTime; if (now < bounds.start || now >= bounds.end) { if (state.playing) restartTransportAt(bounds.start); else state.songTime = bounds.start; } announce(hasLoopRegion() ? 'Region loop on.' : 'Full-range loop on.'); } else announce('Loop off.'); updateTimeline(); updateLoopUI(); drawRoll(); }); ui.selectRegion.addEventListener('click', () => setRegionMode(!state.regionMode)); ui.loopIn.addEventListener('click', () => setLoopPoint('in')); ui.loopOut.addEventListener('click', () => setLoopPoint('out')); ui.clearRegion.addEventListener('click', () => { state.loopIn = null; state.loopOut = null; setRegionMode(false); announce(state.loopEnabled ? 'Loop now uses the full available range.' : 'Loop region cleared.'); }); ui.volume.addEventListener('input', () => { if (state.master && state.audio) state.master.gain.setTargetAtTime(Number(ui.volume.value), state.audio.currentTime, .02); }); ui.sourceMix.addEventListener('input', () => { state.sourceMix = Number(ui.sourceMix.value); applySourceMix(); }); ui.stereo.addEventListener('change', () => { state.stereo = ui.stereo.checked; applySourceMix(); announce(state.stereo ? 'Split mode: original left, MIDI right.' : 'Split mode off.'); }); $$('[data-source]').forEach((button) => button.addEventListener('click', () => { state.sourceMix = button.dataset.source === 'original' ? 0 : 1; ui.sourceMix.value = String(state.sourceMix); applySourceMix(); })); ui.canvas.addEventListener('pointerdown', (event) => { if (!state.regionMode || !state.data || !state.rollGeometry || event.button !== 0) return; state.regionAnchor = rollTimeFromPointer(event); ui.canvas.setPointerCapture?.(event.pointerId); event.preventDefault(); }); ui.canvas.addEventListener('pointermove', (event) => { if (!state.regionMode || state.regionAnchor == null) return; applyLoopRegion(state.regionAnchor, rollTimeFromPointer(event)); }); ui.canvas.addEventListener('pointerup', (event) => { if (!state.regionMode || state.regionAnchor == null) return; const start = state.regionAnchor; const end = rollTimeFromPointer(event); state.regionAnchor = null; state.suppressRollClick = true; applyLoopRegion(start, end, { seek: true }); setRegionMode(false); ui.canvas.releasePointerCapture?.(event.pointerId); announce(`Loop region selected: ${formatTime(state.loopIn)} to ${formatTime(state.loopOut)}.`); window.setTimeout(() => { state.suppressRollClick = false; }, 0); }); ui.canvas.addEventListener('pointercancel', () => { state.regionAnchor = null; setRegionMode(false); }); ui.canvas.addEventListener('click', (event) => { if (!state.data || !state.rollGeometry || state.suppressRollClick || state.regionMode) return; const time = rollTimeFromPointer(event); if (event.shiftKey) setLoopPoint('in', time); else if (event.altKey) setLoopPoint('out', time); else seekTo(time); }); root.addEventListener('keydown', (event) => { if (event.key === 'Escape' && state.regionMode) { setRegionMode(false); announce('Region selection cancelled.'); } }); function applyRollZoom(nextValue) { const previous = state.rollGeometry; const focusTime = previous ? ((ui.rollScroll.scrollLeft + ui.rollScroll.clientWidth / 2) / previous.width) * previous.duration : state.songTime; ui.zoom.value = String(clamp(Number(nextValue) || 1, Number(ui.zoom.min), Number(ui.zoom.max))); drawRoll(); if (state.rollGeometry) { ui.rollScroll.scrollLeft = Math.max( 0, (clamp(focusTime, 0, state.rollGeometry.duration) / state.rollGeometry.duration) * state.rollGeometry.width - ui.rollScroll.clientWidth / 2, ); requestAnimationFrame(drawRoll); } } ui.zoom.addEventListener('input', () => applyRollZoom(ui.zoom.value)); ui.zoomIn.addEventListener('click', () => applyRollZoom(Number(ui.zoom.value) + .5)); ui.zoomOut.addEventListener('click', () => applyRollZoom(Number(ui.zoom.value) - .5)); $('[data-action="reset-mix"]').addEventListener('click', () => { state.muted.clear(); state.solo.clear(); refreshMix(); }); ui.tracks.addEventListener('click', (event) => { const scoreButton = event.target.closest('[data-score-track]'); if (scoreButton) { const track = state.data?.tracks.find((candidate) => candidate.id === scoreButton.dataset.scoreTrack); const part = track ? scorePartForTrack(track) : null; if (part) { state.activeScoreId = `part:${part.id}`; switchTab('score'); renderScoreWorkspace(state.data); announce(`Showing the ${part.name} score PDF.`); } return; } const button = event.target.closest('[data-mix]'); if (!button) return; const id = button.closest('.ms-track-row').dataset.track; const set = button.dataset.mix === 'mute' ? state.muted : state.solo; set.has(id) ? set.delete(id) : set.add(id); refreshMix(); }); ui.tracks.addEventListener('change', (event) => { const select = event.target.closest('[data-program]'); if (!select) return; const id = select.dataset.program; const program = Number(select.value); state.programOverrides.set(id, program); const channel = state.channels.get(id); if (state.synth && channel !== undefined) { state.synth.stopAll(); state.synth.programChange(channel, program); } announce(`Sound changed to ${select.options[select.selectedIndex].textContent}.`); }); $$('[data-tab]').forEach((button) => button.addEventListener('click', () => switchTab(button.dataset.tab))); ui.scoreDocument.addEventListener('change', () => { state.activeScoreId = ui.scoreDocument.value; if (state.data) renderScoreWorkspace(state.data); }); new ResizeObserver(() => drawRoll()).observe(ui.rollScroll); let scrollFrame = 0; ui.rollScroll.addEventListener('scroll', () => { cancelAnimationFrame(scrollFrame); scrollFrame = requestAnimationFrame(drawRoll); }, { passive: true }); function receive() { const data = normalize(props.value); if (data.state === 'transcribing') queueLiveReveal(data); else { stopLiveReveal(); render(data); } } preloadSoundFont().catch(() => {}); ensureAudio(); watch('value', receive); receive();