fffiloni's picture
Upload 8 files
6fe7e6d verified
Raw
History Blame Contribute Delete
71 kB
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();