notepilot / static /app.js
jasonmdong's picture
Sync from GitHub via hub-sync
8028d40 verified
Raw
History Blame Contribute Delete
255 kB
// ── Simple keyboard mapping ─────────────────────────────────────────────────
const SIMPLE_KEY_ORDER = ['KeyA', 'KeyS', 'KeyD', 'KeyJ', 'KeyK', 'KeyL', 'Semicolon'];
const SIMPLE_KEY_LAYOUT = {
KeyA: { label: 'a', pitchClass: 0, noteName: 'C' },
KeyW: { label: 'W', pitchClass: 1, noteName: 'C#' },
KeyS: { label: 's', pitchClass: 2, noteName: 'D' },
KeyE: { label: 'E', pitchClass: 3, noteName: 'D#' },
KeyD: { label: 'd', pitchClass: 4, noteName: 'E' },
KeyJ: { label: 'j', pitchClass: 5, noteName: 'F' },
KeyI: { label: 'I', pitchClass: 6, noteName: 'F#' },
KeyK: { label: 'k', pitchClass: 7, noteName: 'G' },
KeyO: { label: 'O', pitchClass: 8, noteName: 'G#' },
KeyL: { label: 'l', pitchClass: 9, noteName: 'A' },
KeyP: { label: 'P', pitchClass: 10, noteName: 'A#' },
Semicolon: { label: ';', pitchClass: 11, noteName: 'B' },
};
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const FULL_KEYBOARD_START = 21;
const FULL_KEYBOARD_END = 108;
const VISUAL_SLOTS = [
{ id: 'KeyA-natural', code: 'KeyA', sharp: false, noteName: 'C', keyLabel: 'a', kind: 'white', pitchClass: 0 },
{ id: 'KeyW-sharp', code: 'KeyW', anchorCode: 'KeyA', sharp: true, noteName: 'C#', keyLabel: 'W', kind: 'black', pitchClass: 1 },
{ id: 'KeyS-natural', code: 'KeyS', sharp: false, noteName: 'D', keyLabel: 's', kind: 'white', pitchClass: 2 },
{ id: 'KeyE-sharp', code: 'KeyE', anchorCode: 'KeyS', sharp: true, noteName: 'D#', keyLabel: 'E', kind: 'black', pitchClass: 3 },
{ id: 'KeyD-natural', code: 'KeyD', sharp: false, noteName: 'E', keyLabel: 'd', kind: 'white', pitchClass: 4 },
{ id: 'KeyJ-natural', code: 'KeyJ', sharp: false, noteName: 'F', keyLabel: 'j', kind: 'white', pitchClass: 5 },
{ id: 'KeyI-sharp', code: 'KeyI', anchorCode: 'KeyJ', sharp: true, noteName: 'F#', keyLabel: 'I', kind: 'black', pitchClass: 6 },
{ id: 'KeyK-natural', code: 'KeyK', sharp: false, noteName: 'G', keyLabel: 'k', kind: 'white', pitchClass: 7 },
{ id: 'KeyO-sharp', code: 'KeyO', anchorCode: 'KeyK', sharp: true, noteName: 'G#', keyLabel: 'O', kind: 'black', pitchClass: 8 },
{ id: 'KeyL-natural', code: 'KeyL', sharp: false, noteName: 'A', keyLabel: 'l', kind: 'white', pitchClass: 9 },
{ id: 'KeyP-sharp', code: 'KeyP', anchorCode: 'KeyL', sharp: true, noteName: 'A#', keyLabel: 'P', kind: 'black', pitchClass: 10 },
{ id: 'Semicolon-natural', code: 'Semicolon', sharp: false, noteName: 'B', keyLabel: ';', kind: 'white', pitchClass: 11 },
];
function pitchName(midi) { return NOTE_NAMES[midi % 12] + (Math.floor(midi/12)-1); }
function isBlackKeyMidi(midi) { return [1, 3, 6, 8, 10].includes(midi % 12); }
const INSTRUMENTS = [
'piano',
'violin',
'viola',
'cello',
'contrabass',
'strings',
'flute',
'clarinet',
'oboe',
'bassoon',
'horn',
'trumpet',
'trombone',
'tuba',
'timpani',
'voice',
];
const TEMPO_PAUSE_IGNORE_SEC = 3;
const TEMPO_TARGET_TOLERANCE = 0.08;
const TEMPO_TARGET_HOLD_SEC = 1.35;
let _accompanimentLatencyCompSec = 0.28;
const LATENCY_COMPENSATION_MAX_MS = 2000;
const CHORD_MATCH_WINDOW_SEC = 0.5;
const MIDI_DUPLICATE_NOTE_GUARD_MS = 18;
const MIC_NOTE_ACCEPT_DELAY_MS = 40;
const MIN_TRACKER_ADVANCE_SEC = 0.18;
const MIN_REPEATED_PITCH_ADVANCE_SEC = 0.34;
const MAX_ACOUSTIC_EVENT_JUMP = 1;
const ACOUSTIC_FRAME_ADVANCE_ENABLED = false;
const AIRPODS_OUTPUT_LATENCY_MS = 200;
let _outputDelayMs = 0; // user-adjustable accompaniment output delay (AirPods/Bluetooth)
function micDetectionDelaySec() {
return _inputMode === 'mic' ? MIC_NOTE_ACCEPT_DELAY_MS / 1000 : 0;
}
// Extra delay for the accompaniment audio output. Driven by the output-delay
// slider, which auto-defaults to 200 ms when AirPods is the accompaniment
// output and 0 otherwise; the user can override it.
function accompanimentOutputDelaySec() {
return _outputDelayMs / 1000;
}
function accompanimentLatencyCompSec() {
return _accompanimentLatencyCompSec + micDetectionDelaySec() + accompanimentOutputDelaySec();
}
// ── Playback engines ────────────────────────────────────────────────────────
let _audioCtx = null;
const _outputAudioCtxs = { solo: null, accompaniment: null };
const _outputSinkIds = { solo: '', accompaniment: '' };
const _outputSinkLabels = { solo: 'System default', accompaniment: 'System default' };
const OUTPUT_ROUTING_KEY = 'notepilot_output_routing_v1';
let _midiConnected = false;
let _lastMidiNoteTime = 0;
let _lastMidiPitch = null;
const _pedalResonanceBuses = new WeakMap();
function audioCtx() {
if (!_audioCtx) _audioCtx = new AudioContext({ latencyHint: 'interactive' });
return _audioCtx;
}
function outputContextSupportsSink(ctx) {
return !!ctx && typeof ctx.setSinkId === 'function';
}
function outputCtx(role = 'default') {
const normalized = role === 'solo' || role === 'accompaniment' ? role : 'default';
if (normalized === 'default' || !_outputSinkIds[normalized]) return audioCtx();
if (!_outputAudioCtxs[normalized]) {
_outputAudioCtxs[normalized] = new AudioContext({ latencyHint: 'interactive' });
configureOutputContext(normalized).catch((error) => {
console.warn(`Could not configure ${normalized} output:`, error);
setOutputRoutingStatus(error.message || `Could not configure ${normalized} output.`, 'error');
});
}
return _outputAudioCtxs[normalized];
}
function hasDedicatedOutputRoute(role = 'default') {
return (role === 'solo' || role === 'accompaniment') && !!_outputSinkIds[role];
}
const SOUNDFONT_BASE = 'https://gleitz.github.io/midi-js-soundfonts/FluidR3_GM/';
const SAMPLE_ALIAS = {};
function soundfontFile(noteName) {
return `${noteName.replace('#', 's')}.mp3`;
}
function soundfontUrls(notes) {
return Object.fromEntries(notes.map((noteName) => [noteName, soundfontFile(noteName)]));
}
function soundfontNotes(octaveStart, octaveEnd) {
const anchors = ['C', 'D', 'F', 'G', 'A'];
const notes = [];
for (let octave = octaveStart; octave <= octaveEnd; octave++) {
anchors.forEach((anchor) => notes.push(`${anchor}${octave}`));
}
notes.push(`C${octaveEnd + 1}`);
return notes;
}
function soundfontPreset(folder, octaveStart, octaveEnd, options = {}) {
return {
baseUrl: `${SOUNDFONT_BASE}${folder}-mp3/`,
release: options.release ?? 0.28,
noteDuration: options.noteDuration ?? 0.48,
gainDb: options.gainDb ?? 0,
urls: soundfontUrls(options.notes || soundfontNotes(octaveStart, octaveEnd)),
};
}
const SAMPLE_LIBRARY = {
piano: soundfontPreset('acoustic_grand_piano', 1, 7, { release: 0.38, noteDuration: 0.46, gainDb: -1 }),
violin: soundfontPreset('violin', 3, 6, { release: 0.42, noteDuration: 0.55, gainDb: -3 }),
viola: soundfontPreset('viola', 2, 5, { release: 0.42, noteDuration: 0.56, gainDb: -3 }),
cello: soundfontPreset('cello', 2, 5, { release: 0.44, noteDuration: 0.58, gainDb: -2 }),
contrabass: soundfontPreset('contrabass', 1, 4, { release: 0.48, noteDuration: 0.62, gainDb: -2 }),
strings: soundfontPreset('string_ensemble_1', 2, 6, { release: 0.55, noteDuration: 0.7, gainDb: -4 }),
flute: soundfontPreset('flute', 4, 7, { release: 0.24, noteDuration: 0.44, gainDb: -4 }),
clarinet: soundfontPreset('clarinet', 3, 6, { release: 0.28, noteDuration: 0.46, gainDb: -3 }),
oboe: soundfontPreset('oboe', 3, 6, { release: 0.24, noteDuration: 0.44, gainDb: -4 }),
bassoon: soundfontPreset('bassoon', 1, 4, { release: 0.3, noteDuration: 0.5, gainDb: -3 }),
horn: soundfontPreset('french_horn', 2, 5, { release: 0.36, noteDuration: 0.58, gainDb: -5 }),
trumpet: soundfontPreset('trumpet', 3, 6, { release: 0.24, noteDuration: 0.42, gainDb: -5 }),
trombone: soundfontPreset('trombone', 2, 5, { release: 0.34, noteDuration: 0.54, gainDb: -5 }),
tuba: soundfontPreset('tuba', 1, 4, { release: 0.38, noteDuration: 0.62, gainDb: -5 }),
timpani: soundfontPreset('timpani', 1, 4, { release: 0.5, noteDuration: 0.7, gainDb: -3 }),
voice: soundfontPreset('choir_aahs', 3, 6, { release: 0.55, noteDuration: 0.72, gainDb: -5 }),
};
let _toneReady = false;
const _sampleSamplers = {};
const _sampleSamplerReady = {};
const _sampleBuffers = {};
const _sampleBufferReady = {};
const PART_NAME_INSTRUMENT_HINTS = [
['piano', 'piano'],
['contrabass', 'contrabass'],
['contrabasses', 'contrabass'],
['contrabassi', 'contrabass'],
['double bass', 'contrabass'],
['bassi', 'contrabass'],
['basso', 'contrabass'],
['violin', 'violin'],
['violino', 'violin'],
['viola', 'viola'],
['cello', 'cello'],
['violoncello', 'cello'],
['bassoon', 'bassoon'],
['fagotti', 'bassoon'],
['fagotto', 'bassoon'],
['trumpet', 'trumpet'],
['trombe', 'trumpet'],
['tromba', 'trumpet'],
['trombone', 'trombone'],
['tromboni', 'trombone'],
['trombono', 'trombone'],
['tuba', 'tuba'],
['timpani', 'timpani'],
['timpano', 'timpani'],
['horn', 'horn'],
['corni', 'horn'],
['corno', 'horn'],
['clarinet', 'clarinet'],
['clarinetti', 'clarinet'],
['clarinetto', 'clarinet'],
['flute', 'flute'],
['flauti', 'flute'],
['flauto', 'flute'],
['oboe', 'oboe'],
['oboi', 'oboe'],
['soprano', 'flute'],
['alto', 'clarinet'],
['tenor', 'violin'],
['bass', 'contrabass'],
];
function inferInstrumentFromPartName(partName = '') {
const normalized = String(partName || '').trim().toLowerCase();
if (!normalized) return null;
const match = PART_NAME_INSTRUMENT_HINTS.find(([keyword]) => normalized.includes(keyword));
return match?.[1] || null;
}
// instrument presets: { harmonics: [[mult, amp]], attack, decay, sustain, release, type }
const INSTRUMENT_PRESETS = {
piano: { harmonics:[[1,.7],[2,.2],[3,.1]], attack:.008, decay:3.5, type:'sine' },
violin: { harmonics:[[1,.5],[2,.25],[3,.15],[4,.07],[5,.03]], attack:.06, decay:.4, type:'sawtooth', vibrato:true },
viola: { harmonics:[[1,.55],[2,.25],[3,.13],[4,.05],[5,.02]], attack:.07, decay:.35, type:'sawtooth', vibrato:true },
cello: { harmonics:[[1,.6],[2,.22],[3,.12],[4,.04],[5,.02]], attack:.08, decay:.3, type:'sawtooth', vibrato:true },
contrabass:{ harmonics:[[1,.72],[2,.18],[3,.08],[4,.03]], attack:.1, decay:.34, type:'sawtooth', vibrato:true },
strings: { harmonics:[[1,.5],[2,.2],[3,.15],[4,.1],[5,.05]], attack:.09, decay:.25, type:'sawtooth', vibrato:true },
flute: { harmonics:[[1,.85],[2,.12],[3,.03]], attack:.04, decay:.4, type:'sine', noise:.04 },
clarinet: { harmonics:[[1,.7],[3,.25],[5,.08],[7,.03]], attack:.025, decay:.5, type:'sine' },
oboe: { harmonics:[[1,.5],[2,.3],[3,.15],[4,.05]], attack:.02, decay:.6, type:'sine' },
bassoon: { harmonics:[[1,.68],[2,.17],[3,.12],[4,.05]], attack:.035, decay:.48, type:'triangle' },
horn: { harmonics:[[1,.55],[2,.28],[3,.12],[4,.05]], attack:.045, decay:.5, type:'triangle' },
trumpet: { harmonics:[[1,.52],[2,.32],[3,.18],[4,.08]], attack:.018, decay:.36, type:'sawtooth' },
trombone: { harmonics:[[1,.58],[2,.26],[3,.12],[4,.04]], attack:.035, decay:.44, type:'sawtooth' },
tuba: { harmonics:[[1,.72],[2,.2],[3,.07],[4,.03]], attack:.045, decay:.52, type:'triangle' },
timpani: { harmonics:[[1,.78],[1.5,.18],[2,.08],[2.7,.04]], attack:.012, decay:1.1, type:'sine' },
voice: { harmonics:[[1,.62],[2,.2],[3,.1],[4,.05],[5,.03]], attack:.05, decay:.55, type:'triangle', vibrato:true },
};
function midiToToneNote(midi) {
return `${NOTE_NAMES[midi % 12]}${Math.floor(midi / 12) - 1}`;
}
function sampleNoteToMidi(noteName) {
const match = /^([A-G])(#?)(-?\d+)$/.exec(String(noteName || '').trim());
if (!match) return null;
const base = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }[match[1]];
const sharp = match[2] ? 1 : 0;
const octave = Number.parseInt(match[3], 10);
return Number.isFinite(base) && Number.isFinite(octave) ? (octave + 1) * 12 + base + sharp : null;
}
function currentBps() {
if (state.playing && state.accompanist) return state.accompanist._bps;
if (state.playing && state.tracker) return _noteHighwayBps || state.tracker.bps();
const bpm = parseFloat(document.getElementById('bpm-input')?.value) || 120;
return bpm / 60;
}
function noteDurationSeconds(instrument, baseSeconds) {
const sampledInstrument = SAMPLE_ALIAS[instrument] || instrument;
const bps = Math.max(0.5, currentBps());
const tempoScale = Math.max(0.75, Math.min(1.9, 2 / bps));
const familyBoost = ['violin', 'viola', 'cello', 'contrabass', 'strings', 'voice'].includes(instrument) ? 1.12 : 1.0;
const aliasBoost = ['violin', 'viola', 'cello', 'contrabass', 'strings'].includes(sampledInstrument) ? 1.08 : 1.0;
return Math.max(0.18, Math.min(1.25, baseSeconds * tempoScale * familyBoost * aliasBoost));
}
function eventDuration(event, fallbackBeats = 0.75) {
const raw = Number(event?.[2]);
return Number.isFinite(raw) && raw > 0 ? raw : fallbackBeats;
}
function eventPedalRelease(event) {
const raw = Number(event?.[3]);
return Number.isFinite(raw) && raw > 0 ? raw : null;
}
function eventOrnament(event) {
return eventMetadata(event, 'tremolo');
}
function eventMetadata(event, type) {
if (!Array.isArray(event)) return null;
for (const item of event.slice(4)) {
if (item?.type === type) return item;
if (Array.isArray(item)) {
const match = item.find((entry) => entry?.type === type);
if (match) return match;
}
}
return null;
}
function eventDynamic(event) {
return eventMetadata(event, 'dynamic');
}
function eventArticulation(event) {
return eventMetadata(event, 'articulation');
}
function eventSourceInstrument(event) {
const source = eventMetadata(event, 'instrument');
return typeof source?.instrument === 'string' ? source.instrument : null;
}
function appendEventMetadata(event, metadata) {
if (!metadata) return event;
if (event.length === 3) event.push(null);
event.push(metadata);
return event;
}
function eventVelocity(event, fallback = 0.5) {
const velocity = Number(eventDynamic(event)?.velocity);
const baseVelocity = Number.isFinite(velocity)
? Math.max(0.08, Math.min(1, velocity))
: fallback;
const articulationScale = Number(eventArticulation(event)?.velocityScale);
return Math.max(0.04, Math.min(1.15, baseVelocity * (Number.isFinite(articulationScale) ? articulationScale : 1)));
}
function isPedaledEvent(event) {
return eventPedalRelease(event) !== null;
}
function eventPedalHoldSeconds(event, bps = currentBps()) {
const releaseBeat = eventPedalRelease(event);
const beat = Number(event?.[1]);
if (releaseBeat === null || !Number.isFinite(beat)) return null;
return Math.max(0.12, (releaseBeat - beat) / Math.max(0.5, bps));
}
function eventDurationSeconds(event, instrument = 'piano', fallbackBeats = 0.75) {
const beats = eventDuration(event, fallbackBeats);
const seconds = beats / Math.max(0.5, currentBps());
const pianoBoost = instrument === 'piano' ? 1.22 : 1.0;
const articulationScale = Number(eventArticulation(event)?.durationScale);
return Math.max(0.08, seconds * pianoBoost * (Number.isFinite(articulationScale) ? articulationScale : 1));
}
function isSustainedStringInstrument(instrument) {
const normalized = String(instrument || '').trim().toLowerCase();
return ['violin', 'viola', 'cello', 'contrabass', 'strings', 'string ensemble'].includes(normalized);
}
function ensurePedalResonanceBus(role = 'default') {
const ctx = outputCtx(role);
const existing = _pedalResonanceBuses.get(ctx);
if (existing) return existing;
const convolver = ctx.createConvolver();
const impulseSeconds = 1.15;
const length = Math.floor(ctx.sampleRate * impulseSeconds);
const impulse = ctx.createBuffer(2, length, ctx.sampleRate);
for (let channel = 0; channel < impulse.numberOfChannels; channel++) {
const data = impulse.getChannelData(channel);
for (let i = 0; i < length; i++) {
const t = i / length;
const decay = Math.pow(1 - t, 3.2);
data[i] = (Math.random() * 2 - 1) * decay * 0.08;
}
}
convolver.buffer = impulse;
const pre = ctx.createGain();
pre.gain.value = 0.32;
const lowpass = ctx.createBiquadFilter();
lowpass.type = 'lowpass';
lowpass.frequency.value = 2800;
const highpass = ctx.createBiquadFilter();
highpass.type = 'highpass';
highpass.frequency.value = 110;
const wet = ctx.createGain();
wet.gain.value = 0.02;
pre.connect(convolver);
convolver.connect(lowpass);
lowpass.connect(highpass);
highpass.connect(wet);
wet.connect(ctx.destination);
const bus = { pre, wet };
_pedalResonanceBuses.set(ctx, bus);
return bus;
}
function playPedalResonance(pitches, velocity = 0.5, opts = {}) {
if (!pitches?.length) return;
const resonancePitches = [...new Set(pitches)].slice(0, 8);
const ctx = outputCtx(opts.outputRole);
const now = ctx.currentTime;
const { pre, wet } = ensurePedalResonanceBus(opts.outputRole);
const duration = Math.max(0.35, Math.min(4.2, (opts.duration ?? 1.2) * 0.95 + 0.2));
const pedalHold = Math.max(duration, opts.pedalHold ?? duration);
const resonanceGain = Math.min(0.07, 0.025 + velocity * 0.035) / Math.sqrt(resonancePitches.length);
const harmonicShape = [
[1, 1.0],
[2, 0.65],
[3, 0.38],
[4, 0.22],
[5, 0.12],
];
wet.gain.cancelScheduledValues(now);
wet.gain.setValueAtTime(Math.max(0.018, wet.gain.value), now);
wet.gain.linearRampToValueAtTime(Math.max(0.045, 0.035 + velocity * 0.035), now + 0.05);
wet.gain.setValueAtTime(Math.max(0.045, 0.035 + velocity * 0.035), now + pedalHold);
wet.gain.exponentialRampToValueAtTime(0.018, now + pedalHold + 0.25);
resonancePitches.forEach((midi) => {
const fundamental = 440 * Math.pow(2, (midi - 69) / 12);
harmonicShape.forEach(([mult, amp], idx) => {
const osc = ctx.createOscillator();
const body = ctx.createBiquadFilter();
body.type = 'bandpass';
body.frequency.value = Math.min(5200, fundamental * mult);
body.Q.value = idx === 0 ? 8 : 10 + idx * 2;
const gain = ctx.createGain();
const start = now + idx * 0.004;
const peak = resonanceGain * amp;
osc.type = idx === 0 ? 'triangle' : 'sine';
osc.frequency.value = fundamental * mult;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), start + 0.05);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
osc.connect(body);
body.connect(gain);
gain.connect(pre);
osc.start(start);
osc.stop(start + duration + 0.03);
});
});
}
async function ensureSamplePlayback(instruments = []) {
const requested = [...new Set(instruments
.map(ins => SAMPLE_ALIAS[ins] || ins)
.filter(ins => SAMPLE_LIBRARY[ins]))];
if (!requested.length) return false;
const webAudioReady = ensureWebAudioSamplePlayback(requested);
let toneReady = Promise.resolve(false);
if (typeof Tone !== 'undefined') {
toneReady = ensureToneSamplePlayback(requested);
}
const results = await Promise.allSettled([webAudioReady, toneReady]);
return results.some((result) => result.status === 'fulfilled' && result.value);
}
async function ensureToneSamplePlayback(requested = []) {
const toneContext = Tone.getContext?.();
if (toneContext) {
if ('lookAhead' in toneContext) toneContext.lookAhead = 0.01;
if ('updateInterval' in toneContext) toneContext.updateInterval = 0.01;
}
if (!_toneReady) {
await Tone.start();
_toneReady = true;
}
const loaded = await Promise.all(requested.map(async (instrument) => {
if (!_sampleSamplerReady[instrument]) {
const config = SAMPLE_LIBRARY[instrument];
_sampleSamplerReady[instrument] = new Promise((resolve, reject) => {
const sampler = new Tone.Sampler({
urls: config.urls,
baseUrl: config.baseUrl,
release: config.release,
onload: resolve,
onerror: reject,
});
if (Number.isFinite(config.gainDb)) sampler.volume.value = config.gainDb;
_sampleSamplers[instrument] = sampler.toDestination();
}).catch((error) => {
console.warn(`Tone sampler load failed for ${instrument}:`, error);
_sampleSamplerReady[instrument] = null;
_sampleSamplers[instrument] = null;
return false;
});
}
const ready = await _sampleSamplerReady[instrument];
return ready !== false && !!_sampleSamplers[instrument];
}));
return loaded.some(Boolean);
}
async function ensureWebAudioSamplePlayback(requested = []) {
const instruments = [...new Set(requested
.map(ins => SAMPLE_ALIAS[ins] || ins)
.filter(ins => SAMPLE_LIBRARY[ins]))];
if (!instruments.length) return false;
const ctx = audioCtx();
const loaded = await Promise.all(instruments.map(async (instrument) => {
if (!_sampleBufferReady[instrument]) {
const config = SAMPLE_LIBRARY[instrument];
_sampleBufferReady[instrument] = Promise.all(Object.entries(config.urls).map(async ([noteName, file]) => {
const url = `${config.baseUrl}${file}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Could not load ${instrument} ${noteName}`);
const data = await res.arrayBuffer();
const buffer = await ctx.decodeAudioData(data.slice(0));
return [noteName, {
buffer,
midi: sampleNoteToMidi(noteName),
}];
})).then((entries) => {
_sampleBuffers[instrument] = Object.fromEntries(entries.filter(([, sample]) => sample.buffer && Number.isFinite(sample.midi)));
return true;
}).catch((error) => {
console.warn(`Web Audio sample load failed for ${instrument}:`, error);
_sampleBufferReady[instrument] = null;
_sampleBuffers[instrument] = null;
return false;
});
}
const ready = await _sampleBufferReady[instrument];
return ready !== false && !!_sampleBuffers[instrument];
}));
return loaded.some(Boolean);
}
function isSampleBackedInstrument(instrument) {
const sampledInstrument = SAMPLE_ALIAS[instrument] || instrument;
return !!SAMPLE_LIBRARY[sampledInstrument];
}
function hasLoadedSampler(instrument) {
const sampledInstrument = SAMPLE_ALIAS[instrument] || instrument;
return !!_sampleSamplers[sampledInstrument] || !!_sampleBuffers[sampledInstrument];
}
function currentScoreInstruments() {
const parts = state.current?.parts || [];
if (!parts.length) return [getInstrumentForPart(state.selectedPart ?? 0)];
return parts.map((_, idx) => getInstrumentForPart(idx));
}
async function preloadCurrentScoreInstruments() {
if (!state.current) return false;
try {
return await ensureSamplePlayback(currentScoreInstruments());
} catch (error) {
console.warn('Sample preload failed:', error);
return false;
}
}
function playSynthNote(midi, velocity = 0.6, instrument = 'piano', opts = {}) {
const ctx = outputCtx(opts.outputRole);
const freq = 440 * Math.pow(2, (midi - 69) / 12);
const presetName = String(instrument || '').trim().toLowerCase() === 'string ensemble' ? 'strings' : instrument;
const preset = INSTRUMENT_PRESETS[presetName] || INSTRUMENT_PRESETS.piano;
const now = ctx.currentTime;
const baseDur = instrument === 'piano' ? 0.55 : 0.5;
const dur = Math.max(0.12, opts.duration ?? noteDurationSeconds(instrument, baseDur));
const sustained = isSustainedStringInstrument(instrument);
const release = sustained ? Math.min(0.28, Math.max(0.06, dur * 0.08)) : dur;
const releaseStart = sustained ? Math.max(now + preset.attack + 0.02, now + dur - release) : now + preset.attack;
const peak = velocity * 0.35;
const masterGain = ctx.createGain();
masterGain.connect(ctx.destination);
masterGain.gain.setValueAtTime(0, now);
masterGain.gain.linearRampToValueAtTime(peak, now + preset.attack);
if (sustained) {
masterGain.gain.setValueAtTime(peak, releaseStart);
masterGain.gain.exponentialRampToValueAtTime(0.0001, now + dur);
} else {
masterGain.gain.exponentialRampToValueAtTime(0.0001, now + dur);
}
preset.harmonics.forEach(([mult, amp]) => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
g.gain.value = amp;
osc.type = preset.type || 'sine';
osc.frequency.value = freq * mult;
// Vibrato for strings
if (preset.vibrato) {
const lfo = ctx.createOscillator();
const lfoG = ctx.createGain();
lfo.frequency.value = 5.5;
lfoG.gain.setValueAtTime(0, now);
lfoG.gain.linearRampToValueAtTime(freq * 0.003, now + 0.15);
lfo.connect(lfoG);
lfoG.connect(osc.frequency);
lfo.start(now); lfo.stop(now + dur);
}
osc.connect(g); g.connect(masterGain);
osc.start(now); osc.stop(now + dur);
});
// Breath noise for flute
if (preset.noise) {
const buf = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * preset.noise;
const src = ctx.createBufferSource();
const noiseG = ctx.createGain();
noiseG.gain.value = 0.3;
src.buffer = buf;
src.connect(noiseG); noiseG.connect(masterGain);
src.start(now); src.stop(now + dur);
}
}
function nearestSampleBuffer(instrument, midi) {
const samples = _sampleBuffers[instrument];
if (!samples) return null;
let best = null;
Object.values(samples).forEach((sample) => {
if (!sample?.buffer || !Number.isFinite(sample.midi)) return;
if (!best || Math.abs(sample.midi - midi) < Math.abs(best.midi - midi)) best = sample;
});
return best;
}
function playWebAudioSampleNote(midi, velocity = 0.6, instrument = 'piano', opts = {}) {
const sampledInstrument = SAMPLE_ALIAS[instrument] || instrument;
const config = SAMPLE_LIBRARY[sampledInstrument];
const sample = nearestSampleBuffer(sampledInstrument, midi);
if (!config || !sample) return false;
const ctx = outputCtx(opts.outputRole);
const source = ctx.createBufferSource();
const gain = ctx.createGain();
const baseDuration = config.noteDuration ?? 0.45;
const sustained = isSustainedStringInstrument(instrument) || isSustainedStringInstrument(sampledInstrument) || sampledInstrument === 'voice';
const duration = Math.max(sustained ? 0.32 : 0.12, opts.duration ?? noteDurationSeconds(instrument, baseDuration));
const release = sustained
? Math.max(0.24, config.release ?? 0.42)
: Math.max(0.04, Math.min(config.release ?? 0.24, duration * 0.8));
const now = ctx.currentTime;
const endAt = now + duration;
const stopAt = endAt + release + 0.08;
const attack = sustained ? 0.035 : 0.006;
const releaseStart = sustained ? endAt : Math.max(now + 0.012, endAt - release);
const gainScale = Number.isFinite(config.gainDb) ? Math.pow(10, config.gainDb / 20) : 1;
const peak = Math.max(0.0001, Math.min(1.2, velocity) * gainScale);
source.buffer = sample.buffer;
source.playbackRate.value = Math.pow(2, (midi - sample.midi) / 12);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.linearRampToValueAtTime(peak, now + attack);
gain.gain.setValueAtTime(peak, releaseStart);
gain.gain.exponentialRampToValueAtTime(0.0001, stopAt);
source.connect(gain).connect(ctx.destination);
source.start(now);
source.stop(stopAt);
if (instrument === 'piano' && opts.pedaled && !opts.suppressPedalResonance) {
playPedalResonance([midi], velocity, opts);
}
return true;
}
function playNote(midi, velocity = 0.6, instrument = 'piano', opts = {}) {
const volume = normalizePlaybackVolume(opts.volume ?? 1, 1);
const effectiveVelocity = Math.max(0, Math.min(3, velocity * volume));
if (effectiveVelocity <= 0.001) return;
const sampledInstrument = SAMPLE_ALIAS[instrument] || instrument;
const sampler = _sampleSamplers[sampledInstrument];
const routedOutput = hasDedicatedOutputRoute(opts.outputRole);
if (routedOutput && !opts.preferSynth && playWebAudioSampleNote(midi, effectiveVelocity, instrument, opts)) {
return;
}
if (sampler && !opts.preferSynth && !routedOutput) {
const baseDuration = SAMPLE_LIBRARY[sampledInstrument]?.noteDuration ?? 0.45;
const duration = Math.max(0.12, opts.duration ?? noteDurationSeconds(instrument, baseDuration));
sampler.triggerAttackRelease(midiToToneNote(midi), duration, undefined, Math.min(1, effectiveVelocity));
if (instrument === 'piano' && opts.pedaled && !opts.suppressPedalResonance) {
playPedalResonance([midi], effectiveVelocity, opts);
}
return;
}
if (!opts.preferSynth && playWebAudioSampleNote(midi, effectiveVelocity, instrument, opts)) return;
if (isSampleBackedInstrument(instrument) && !opts.preferSynth) return;
playSynthNote(midi, effectiveVelocity, instrument, opts);
}
function playChord(pitches, velocity = 0.5, instrument = 'piano', opts = {}) {
const chordSize = Math.max(1, pitches.length);
const noteVelocity = Math.min(1, Math.max(0.08, velocity / Math.sqrt(chordSize) + 0.08));
const noteOpts = instrument === 'piano' && opts.pedaled
? { ...opts, suppressPedalResonance: true }
: opts;
pitches.forEach(p => playNote(p, noteVelocity, instrument, noteOpts));
if (instrument === 'piano' && opts.pedaled) {
playPedalResonance(pitches, velocity * normalizePlaybackVolume(opts.volume ?? 1, 1), opts);
}
}
function eventPitches(event) {
if (!event) return [];
return Array.isArray(event[0]) ? event[0] : [event[0]];
}
function eventIndexAtOrAfterBeat(events, beat) {
const target = Math.max(0, Number(beat) || 0);
const list = Array.isArray(events) ? events : [];
const idx = list.findIndex((event) => Number(event?.[1]) >= target - 0.001);
return idx >= 0 ? idx : list.length;
}
function extendedSoloRestThresholdBeats(referenceBeat = 0) {
const starts = (state.current?.measure_beats || [])
.map((beat) => Number(beat))
.filter((beat) => Number.isFinite(beat))
.sort((a, b) => a - b);
if (starts.length >= 5) {
let measureIndex = 0;
const beat = Math.max(0, Number(referenceBeat) || 0);
for (let i = 0; i < starts.length; i++) {
if (starts[i] <= beat + 0.001) measureIndex = i;
else break;
}
if (Number.isFinite(starts[measureIndex + 4])) {
return Math.max(1, starts[measureIndex + 4] - starts[measureIndex]);
}
const spans = starts
.slice(1)
.map((start, i) => start - starts[i])
.filter((span) => span > 0);
if (spans.length) {
spans.sort((a, b) => a - b);
return Math.max(1, spans[Math.floor(spans.length / 2)] * 4);
}
}
return 16;
}
function tremoloTargetSeconds(marks) {
const normalized = Math.max(1, Math.min(4, Number.parseInt(marks, 10) || 3));
return ({ 1: 0.18, 2: 0.125, 3: 0.085, 4: 0.065 })[normalized] || 0.085;
}
function expandedTremoloEvents(event, bps = currentBps()) {
const ornament = eventOrnament(event);
if (ornament?.type !== 'tremolo') return null;
const groups = Array.isArray(ornament.groups)
? ornament.groups
.map((group) => Array.isArray(group) ? group.filter(Number.isFinite) : [])
.filter((group) => group.length)
: [];
if (!groups.length) return null;
const beat = Number(event?.[1]);
const durationBeats = eventDuration(event);
if (!Number.isFinite(beat) || durationBeats <= 0) return null;
const seconds = durationBeats / Math.max(0.35, bps);
const targetSeconds = tremoloTargetSeconds(ornament.marks);
const minimum = groups.length > 1 ? groups.length : 2;
const attackCount = Math.max(
minimum,
Math.min(128, Math.round(seconds / targetSeconds))
);
const intervalBeats = durationBeats / attackCount;
const pedalRelease = eventPedalRelease(event);
const dynamic = eventDynamic(event);
const articulation = eventArticulation(event);
const sourceInstrument = eventSourceInstrument(event);
return Array.from({ length: attackCount }, (_, idx) => {
const group = groups[idx % groups.length];
const payload = group.length === 1 ? group[0] : group;
const expanded = [payload, beat + idx * intervalBeats, intervalBeats];
if (pedalRelease !== null) expanded.push(pedalRelease);
appendEventMetadata(expanded, dynamic);
appendEventMetadata(expanded, articulation);
appendEventMetadata(expanded, sourceInstrument ? { type: 'instrument', instrument: sourceInstrument } : null);
return expanded;
});
}
function expandDynamicTremolos(events, bps = currentBps()) {
if (!Array.isArray(events)) return [];
const expanded = [];
events.forEach((event) => {
const tremoloEvents = expandedTremoloEvents(event, bps);
if (tremoloEvents) expanded.push(...tremoloEvents);
else expanded.push(event);
});
expanded.sort((a, b) => a[1] - b[1]);
return expanded;
}
function leadPitchFromEvent(event) {
const pitches = eventPitches(event);
return pitches.length ? pitches[pitches.length - 1] : 60;
}
function eventLabel(event) {
const pitches = eventPitches(event);
if (!pitches.length) return 'β€”';
return pitches.map((pitch) => pitchName(pitch)).join(' / ');
}
function syncExpectedMicNote() {
if (!_pitchDetector || !state.playing) return;
const rightHand = getRightHand();
const position = state.tracker?.position ?? 0;
const event = rightHand[position] ?? rightHand[0];
const pitches = eventPitches(event);
const expected = leadPitchFromEvent(event);
if (typeof _pitchDetector.setExpectedMidi === 'function') _pitchDetector.setExpectedMidi(expected);
if (typeof _pitchDetector.setExpectedPitches === 'function') _pitchDetector.setExpectedPitches(pitches);
}
// ── Tracker ──────────────────────────────────────────────────────────────────
class Tracker {
constructor(rightHand, initialBps) {
this.score = rightHand; // [[pitch, beat], ...]
this.position = 0;
this.timestamps = []; // [{time, beat}, ...]
this._defaultBps = initialBps;
this._smoothedBps = initialBps;
this._targetBps = initialBps;
this._governedBps = initialBps;
this._targetHoldStartedAt = null;
this._lastTempoSampleKey = '';
this._lastAdvanceTime = 0;
this._pendingChord = new Set();
this._pendingPosition = -1;
this._pendingStartedAt = 0;
this._recentNotes = [];
this._lastAdvancedPosition = -1;
this._lastAdvanceBeat = null;
this._lastAdvancePitch = null;
}
onNote(pitch) {
const expected = this.score[this.position];
if (!expected) return null;
const now = performance.now() / 1000;
this._recentNotes.push({ pitch, time: now });
this._recentNotes = this._recentNotes.filter((entry) => now - entry.time <= CHORD_MATCH_WINDOW_SEC);
const pitches = eventPitches(expected);
if (pitches.length === 1) {
return pitches[0] === pitch ? this._advance(this.position, { inputPitch: pitch }) : null;
}
if (!pitches.includes(pitch)) return null;
if (this._pendingPosition !== this.position || now - this._pendingStartedAt > CHORD_MATCH_WINDOW_SEC) {
this._pendingChord = new Set(
this._recentNotes
.filter((entry) => pitches.includes(entry.pitch))
.map((entry) => entry.pitch)
);
this._pendingPosition = this.position;
this._pendingStartedAt = now;
}
this._pendingChord.add(pitch);
return pitches.every((expectedPitch) => this._pendingChord.has(expectedPitch))
? this._advance(this.position, { inputPitch: pitch })
: null;
}
// Mic mode: accept the current note, with a small pitch tolerance. For chords,
// wait for either the chord-aware detector or multiple expected notes in time.
onNoteFuzzy(midi) {
const expected = this.score[this.position];
if (!expected) return null;
const pitches = eventPitches(expected);
if (pitches.length <= 1) {
if (Math.abs(leadPitchFromEvent(expected) - midi) <= 1) {
return this._advance(this.position, { inputPitch: midi });
}
// No match here. If the current note is a REPEAT of the one the soloist
// just played, they may have skipped re-striking it (repeats are
// ambiguous), so let the played note match the next DISTINCT note instead
// of stalling. Only repeats are ever skipped β€” never a fresh note.
const jump = this._skipRepeatsToMatch(midi);
return jump !== -1 ? this._advance(jump, { inputPitch: midi }) : null;
}
const now = performance.now() / 1000;
this._recentNotes.push({ pitch: midi, time: now });
this._recentNotes = this._recentNotes.filter((entry) => now - entry.time <= CHORD_MATCH_WINDOW_SEC);
const matched = new Set();
this._recentNotes.forEach((entry) => {
const pitch = pitches.find((expectedPitch) => Math.abs(expectedPitch - entry.pitch) <= 1);
if (Number.isFinite(pitch)) matched.add(pitch);
});
return pitches.every((expectedPitch) => matched.has(expectedPitch))
? this._advance(this.position, { inputPitch: leadPitchFromEvent(expected) })
: null;
}
// If the soloist played `midi` but it doesn't match the current note, scan a
// short run of notes that are REPEATS of the one just played and return the
// index of the next note that matches `midi`. This lets a missed re-strike of
// a repeated note be skipped so the following distinct note still registers
// (repeated notes are treated as phrasing). Returns -1 when nothing matches.
_skipRepeatsToMatch(midi) {
if (this._lastAdvancePitch === null) return -1;
let skipped = 0;
for (let i = this.position; i < this.score.length && skipped < 3; i++, skipped++) {
// Only step over notes that repeat the pitch the soloist just played.
if (Math.abs(leadPitchFromEvent(this.score[i]) - this._lastAdvancePitch) > 0) break;
const next = this.score[i + 1];
if (next && Math.abs(leadPitchFromEvent(next) - midi) <= 1) return i + 1;
}
return -1;
}
onChordFuzzy(midiList) {
const expected = this.score[this.position];
if (!expected) return null;
const expectedPitches = eventPitches(expected);
const detected = Array.isArray(midiList) ? midiList : [];
if (!expectedPitches.length || !detected.length) return null;
const matched = expectedPitches.every((expectedPitch) =>
detected.some((midi) => Math.abs(expectedPitch - midi) <= 1)
);
return matched ? this._advance(this.position, { inputPitch: leadPitchFromEvent(expected) }) : null;
}
advanceTo(index, options = {}) {
if (!Number.isFinite(index)) return null;
const maxJump = Number.isFinite(options.maxJump) ? Math.max(0, Math.floor(options.maxJump)) : MAX_ACOUSTIC_EVENT_JUMP;
const requested = Math.floor(index);
if (requested > this.position + maxJump) return null;
const target = Math.max(0, Math.min(this.score.length - 1, requested));
return this._advance(target, options);
}
_advance(i, options = {}) {
const now = performance.now() / 1000;
const event = this.score[i];
if (!event) return null;
const beat = event[1];
const inputPitch = Number.isFinite(options.inputPitch) ? options.inputPitch : leadPitchFromEvent(event);
const elapsed = now - this._lastAdvanceTime;
const beatDelta = this._lastAdvanceBeat !== null ? Math.max(0, beat - this._lastAdvanceBeat) : 0;
const referenceBps = this._smoothedBps || this._defaultBps || currentBps();
const expectedInterval = beatDelta > 0 && referenceBps > 0 ? beatDelta / referenceBps : MIN_TRACKER_ADVANCE_SEC;
const minAdvanceSec = Math.min(MIN_TRACKER_ADVANCE_SEC, Math.max(0.07, expectedInterval * 0.45));
const minRepeatedSec = Math.min(MIN_REPEATED_PITCH_ADVANCE_SEC, Math.max(0.12, expectedInterval * 0.65));
if (this._lastAdvanceTime && elapsed < minAdvanceSec) return null;
if (
this._lastAdvanceTime &&
this._lastAdvancePitch !== null &&
Math.abs(this._lastAdvancePitch - inputPitch) <= 1 &&
elapsed < minRepeatedSec
) return null;
this._lastAdvanceTime = now;
this._lastAdvanceBeat = beat;
this._lastAdvancePitch = inputPitch;
this._pendingChord.clear();
this._pendingPosition = -1;
this._pendingStartedAt = 0;
this.position = i + 1;
this._lastAdvancedPosition = i;
this.timestamps.push({ time: now, beat });
if (this.timestamps.length > 5) this.timestamps.shift();
return beat;
}
seekToBeat(beat) {
const target = Math.max(0, Number(beat) || 0);
this.position = eventIndexAtOrAfterBeat(this.score, target);
this.timestamps = [];
this._smoothedBps = this._defaultBps;
this._governedBps = this._targetBps || this._defaultBps;
this._targetHoldStartedAt = null;
this._lastTempoSampleKey = '';
this._lastAdvanceTime = 0;
this._pendingChord.clear();
this._pendingPosition = -1;
this._pendingStartedAt = 0;
this._recentNotes = [];
this._lastAdvancedPosition = Math.max(-1, this.position - 1);
this._lastAdvanceBeat = null;
this._lastAdvancePitch = null;
}
bps() {
const ts = this.timestamps;
if (ts.length < 2) return this._smoothedBps;
const last = ts[ts.length - 1];
const sampleKey = `${ts.length}:${last.time.toFixed(4)}:${last.beat}`;
if (sampleKey === this._lastTempoSampleKey) return this._smoothedBps;
this._lastTempoSampleKey = sampleKey;
const rates = [];
for (let i = 1; i < ts.length; i++) {
const dt = ts[i].time - ts[i-1].time;
const db = ts[i].beat - ts[i-1].beat;
if (dt >= MIN_TRACKER_ADVANCE_SEC && dt <= TEMPO_PAUSE_IGNORE_SEC && db > 0) rates.push(db / dt);
}
if (!rates.length) return this._smoothedBps;
rates.sort((a, b) => a - b);
let candidate = rates[Math.floor(rates.length / 2)];
const minBps = 35 / 60;
const maxBps = 300 / 60;
if (candidate > maxBps) return this._smoothedBps;
candidate = Math.max(minBps, Math.min(maxBps, candidate));
const maxRise = Math.min(maxBps, this._smoothedBps * 1.22 + 0.05);
const maxDrop = Math.max(minBps, this._smoothedBps * 0.78 - 0.03);
candidate = Math.max(maxDrop, Math.min(maxRise, candidate));
this._smoothedBps = this._smoothedBps * 0.72 + candidate * 0.28;
return this._smoothedBps;
}
accompanimentBps(options = {}) {
const detected = this.bps();
if (options.adaptiveTempo) return detected;
const target = this._targetBps || this._defaultBps || detected;
if (!Number.isFinite(target) || target <= 0) return detected;
const lower = target * (1 - TEMPO_TARGET_TOLERANCE);
const upper = target * (1 + TEMPO_TARGET_TOLERANCE);
const now = performance.now() / 1000;
if (detected > upper) {
this._targetHoldStartedAt = now;
this._governedBps = target;
return this._governedBps;
}
if (detected >= lower) {
if (this._targetHoldStartedAt === null) this._targetHoldStartedAt = now;
const stableNearTarget = now - this._targetHoldStartedAt >= TEMPO_TARGET_HOLD_SEC;
this._governedBps = stableNearTarget ? target : detected;
return this._governedBps;
}
this._targetHoldStartedAt = null;
this._governedBps = detected;
return this._governedBps;
}
isFinished() { return this.position >= this.score.length; }
progress() { return this.position / this.score.length; }
}
// ── Acoustic microphone score follower ───────────────────────────────────────
// This is intentionally score-constrained: microphone audio is noisy evidence
// for one of the next plausible score events, not a full piano transcription.
const ACOUSTIC_PC_LABELS = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const ACOUSTIC_MIN_MIDI = 33; // A1
const ACOUSTIC_MAX_MIDI = 96; // C7
function acousticMedian(values) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function acousticMad(values, median) {
if (!values.length) return 0;
return acousticMedian(values.map((value) => Math.abs(value - median)));
}
function acousticNormalize(vector) {
const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0));
if (!norm) return vector.map(() => 0);
return vector.map((value) => value / norm);
}
function acousticCosine(a, b) {
let dot = 0;
let an = 0;
let bn = 0;
for (let i = 0; i < Math.min(a.length, b.length); i++) {
dot += a[i] * b[i];
an += a[i] * a[i];
bn += b[i] * b[i];
}
return an > 0 && bn > 0 ? dot / Math.sqrt(an * bn) : 0;
}
function acousticMidiToPitchClass(midi) {
return ((Math.round(midi) % 12) + 12) % 12;
}
function acousticEventChroma(event) {
const chroma = new Array(12).fill(0);
eventPitches(event).forEach((midi) => {
chroma[acousticMidiToPitchClass(midi)] += 1;
});
return acousticNormalize(chroma);
}
function acousticRms(frame) {
let sum = 0;
for (let i = 0; i < frame.length; i++) sum += frame[i] * frame[i];
return Math.sqrt(sum / frame.length);
}
function acousticGoertzelMagnitude(frame, sampleRate, freq) {
const n = frame.length;
const k = Math.max(1, Math.round((n * freq) / sampleRate));
const omega = (2 * Math.PI * k) / n;
const coeff = 2 * Math.cos(omega);
let s0 = 0;
let s1 = 0;
let s2 = 0;
for (let i = 0; i < n; i++) {
const window = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / Math.max(1, n - 1));
s0 = frame[i] * window + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}
return Math.sqrt(Math.max(0, s1 * s1 + s2 * s2 - coeff * s1 * s2)) / n;
}
function acousticSemitoneSpectrum(frame, sampleRate) {
const bins = [];
for (let midi = ACOUSTIC_MIN_MIDI; midi <= ACOUSTIC_MAX_MIDI; midi++) {
const freq = 440 * Math.pow(2, (midi - 69) / 12);
bins.push(acousticGoertzelMagnitude(frame, sampleRate, freq));
}
return bins;
}
function acousticSpectrumToChroma(spectrum) {
const chroma = new Array(12).fill(0);
const max = Math.max(...spectrum, 1e-9);
const cutoff = max * 0.12;
spectrum.forEach((value, index) => {
if (value < cutoff) return;
const midi = ACOUSTIC_MIN_MIDI + index;
chroma[acousticMidiToPitchClass(midi)] += value * value;
});
return acousticNormalize(chroma);
}
function acousticDelayForConfidence(confidence, ambiguity, previousDelayMs) {
const penalty = ambiguity > 0.65 ? 60 : ambiguity > 0.45 ? 30 : 0;
let target = 400;
if (confidence > 0.90) target = 50;
else if (confidence > 0.75) target = 90;
else if (confidence > 0.60) target = 150;
else if (confidence > 0.45) target = 250;
target += penalty;
return previousDelayMs * 0.8 + target * 0.2;
}
class AcousticMicFollower {
constructor({
frameSize = 2048,
hopSize = 512,
refractoryMs = 105,
thresholdMultiplier = 3.2,
} = {}) {
this.frameSize = frameSize;
this.hopSize = hopSize;
this.refractoryMs = refractoryMs;
this.thresholdMultiplier = thresholdMultiplier;
this.reset();
}
reset() {
this.pending = new Float32Array(0);
this.previousSpectrum = null;
this.fluxHistory = [];
this.lastOnsetTimeMs = -Infinity;
this.smoothedFlux = 0;
this.mode = 'LISTENING';
this.reliableOnsets = 0;
this.confidence = 0;
this.states = [];
this.displayedDelayMs = 150;
this.lastOutput = null;
this.calibration = {
noiseFloor: 0,
averageOnsetStrength: 0,
thresholdMultiplier: this.thresholdMultiplier,
reverbLevel: 0,
};
}
processChunk(input, meta = {}) {
const sampleRate = meta.sampleRate || 44100;
const copied = new Float32Array(input);
const joined = new Float32Array(this.pending.length + copied.length);
joined.set(this.pending, 0);
joined.set(copied, this.pending.length);
let offset = 0;
let latestOutput = null;
while (offset + this.frameSize <= joined.length) {
const frame = joined.slice(offset, offset + this.frameSize);
const frameTimeMs = performance.now();
const output = this._processFrame(frame, sampleRate, frameTimeMs);
if (output) latestOutput = output;
offset += this.hopSize;
}
this.pending = joined.slice(offset);
return latestOutput;
}
_processFrame(frame, sampleRate, frameTimeMs) {
const rms = acousticRms(frame);
const spectrum = acousticSemitoneSpectrum(frame, sampleRate);
let flux = 0;
if (this.previousSpectrum) {
for (let i = 0; i < spectrum.length; i++) {
flux += Math.max(0, spectrum[i] - this.previousSpectrum[i]);
}
}
this.previousSpectrum = spectrum;
this.smoothedFlux = this.smoothedFlux * 0.62 + flux * 0.38;
this.fluxHistory.push(this.smoothedFlux);
if (this.fluxHistory.length > 64) this.fluxHistory.shift();
const median = acousticMedian(this.fluxHistory);
const mad = acousticMad(this.fluxHistory, median) || 1e-7;
const threshold = median + this.thresholdMultiplier * mad;
const strength = threshold > 0 ? this.smoothedFlux / threshold : 0;
if (rms < this.calibration.noiseFloor * 1.2) return this._decayConfidence(frameTimeMs);
if (this.fluxHistory.length < 16) return null;
if (frameTimeMs - this.lastOnsetTimeMs < this.refractoryMs) return null;
if (this.smoothedFlux <= threshold) return this._decayConfidence(frameTimeMs);
this.lastOnsetTimeMs = frameTimeMs;
const audioChroma = acousticSpectrumToChroma(spectrum);
return this._matchOnset(audioChroma, strength);
}
_decayConfidence(frameTimeMs) {
if (frameTimeMs - this.lastOnsetTimeMs < 850) return null;
this.confidence *= 0.985;
if (this.confidence < 0.36) {
this.mode = 'LISTENING';
this.reliableOnsets = 0;
}
if (this.confidence < 0.18 && frameTimeMs - this.lastOnsetTimeMs > 2500) this.mode = 'STOPPED';
return null;
}
_matchOnset(audioChroma, onsetStrength) {
if (!state.playing || state.paused || !state.tracker) return null;
const score = state.tracker.score || [];
const currentIndex = state.tracker.position || 0;
if (!score.length || currentIndex >= score.length) return null;
const start = Math.max(0, currentIndex - 1);
const end = Math.min(score.length - 1, currentIndex + 8);
const candidates = [];
for (let index = start; index <= end; index++) {
const event = score[index];
if (!eventPitches(event).length) continue;
const scoreChroma = acousticEventChroma(event);
const chromaSimilarity = acousticCosine(audioChroma, scoreChroma);
const distance = index - currentIndex;
const timingScore = distance >= 0
? Math.max(0, 1 - distance / 8)
: 0.42;
const continuityScore = distance >= 0
? Math.max(0.35, 1 - distance * 0.08)
: 0.15;
const scoreValue =
0.65 * chromaSimilarity +
0.25 * timingScore +
0.10 * continuityScore;
candidates.push({
eventIndex: index,
measure: null,
beat: event[1],
confidence: Math.max(0, Math.min(1, scoreValue)),
chromaSimilarity,
label: eventLabel(event),
});
}
const previousStates = this.states.length
? this.states
: [{ eventIndex: currentIndex - 1, confidence: 1, scoreTime: score[currentIndex]?.[1] ?? 0 }];
candidates.forEach((candidate) => {
let statePrior = 0;
previousStates.forEach((previous) => {
const expectedNext = previous.eventIndex + 1;
const distance = Math.abs(candidate.eventIndex - expectedNext);
const transition = Math.max(0, 1 - distance / 8);
statePrior = Math.max(statePrior, previous.confidence * transition);
});
candidate.confidence = Math.max(0, Math.min(1, candidate.confidence * 0.78 + statePrior * 0.22));
});
candidates.sort((a, b) => b.confidence - a.confidence);
const top = candidates.slice(0, 5);
const totalConfidence = top.reduce((sum, candidate) => sum + candidate.confidence, 0) || 1;
this.states = top.map((candidate) => ({
eventIndex: candidate.eventIndex,
scoreTime: candidate.beat,
tempoEstimate: state.tracker?.bps?.() ?? currentBps(),
confidence: candidate.confidence / totalConfidence,
lastOnsetTime: performance.now(),
latencyEstimateMs: this.displayedDelayMs,
}));
const best = top[0] || null;
const second = top[1]?.confidence ?? 0;
const ambiguity = best ? Math.max(0, 1 - (best.confidence - second)) : 1;
const onsetConfidence = Math.min(1, Math.max(0.35, onsetStrength / 2.4));
const confidence = best ? Math.min(1, best.confidence * (0.82 + onsetConfidence * 0.18)) : 0;
this.displayedDelayMs = acousticDelayForConfidence(confidence, ambiguity, this.displayedDelayMs);
const shouldCommit = !!best && best.eventIndex >= currentIndex && confidence >= 0.46;
if (shouldCommit) this.reliableOnsets += 1;
else this.reliableOnsets = Math.max(0, this.reliableOnsets - 1);
this.confidence = confidence;
this.mode = confidence >= 0.46 ? 'PLAYING' : (this.mode === 'STOPPED' ? 'LISTENING' : this.mode);
const output = {
timestamp: performance.now(),
mode: this.mode,
bestEventIndex: best?.eventIndex ?? null,
measure: best?.measure ?? null,
beat: best?.beat ?? null,
confidence,
suggestedDelayMs: this.displayedDelayMs,
shouldPlayAccompaniment: shouldCommit,
shouldPauseAccompaniment: confidence < 0.28,
shouldResumeAccompaniment: shouldCommit && this.reliableOnsets >= 1,
candidates: top,
audioChroma,
expectedChroma: best ? acousticEventChroma(score[best.eventIndex]) : new Array(12).fill(0),
};
this.lastOutput = output;
return output;
}
}
// ── Accompanist ──────────────────────────────────────────────────────────────
class Accompanist {
constructor(leftHand, rightHand, initialBps, leftInstruments = [], options = {}) {
// leftInstruments: array of instrument names parallel to the non-selected parts
// Each event gets the instrument of the source part it came from.
// Since getLeftHand() merges parts in order, we track that here.
this.events = [...leftHand].sort((a,b) => a[1]-b[1]); // [[pitches,beat,duration],...]
this._instruments = leftInstruments;
this.rhBeats = [...new Set(rightHand.map(n=>n[1]))].sort((a,b)=>a-b);
// Distinct-pitch checkpoints: the beat of the FIRST note in each run of
// consecutive same-pitch soloist notes. A repeated note is ambiguous to a
// mic (re-strike vs. the previous one still ringing), so in leader mode the
// accompaniment only WAITS at pitch changes β€” it plays a run of repeats
// through at tempo as one phrase and re-syncs at the next distinct pitch.
this._checkpointBeats = (() => {
const sorted = [...rightHand].sort((a, b) => a[1] - b[1]);
const beats = [];
let prevPitch = null;
for (const note of sorted) {
const pitch = leadPitchFromEvent(note);
if (prevPitch === null || pitch !== prevPitch) beats.push(note[1]);
prevPitch = pitch;
}
return beats;
})();
this._leadMode = options.followMode === 'leader';
this._leadStopMarginBeats = options.leadStopMarginBeats ?? 0.02;
this._bps = initialBps;
this._targetBps = initialBps;
this._syncBeat = 0;
this._syncTime = null; // null = waiting for first RH note
this._nextSync = this.rhBeats[0] ?? Infinity;
this._lastConfirmedRhBeat = 0;
this._lhIdx = 0;
this._running = false;
this._raf = null;
this._holdingForSoloist = false;
}
start(startBeat = 0) {
const targetBeat = Math.max(0, Number(startBeat) || 0);
if (targetBeat > 0) {
this._running = true;
this.seek(targetBeat, this._bps);
this._tick();
return;
}
if (this._leadMode) {
this._syncBeat = targetBeat;
this._syncTime = performance.now() / 1000;
// Nothing confirmed yet: the first solo beat is itself a checkpoint, so
// the accompaniment plays its opening note(s) and then waits for the
// soloist to catch up to that first note before continuing.
this._lastConfirmedRhBeat = -Infinity;
this._nextSync = this.rhBeats.find((b) => b > targetBeat + 0.01) ?? Infinity;
this._running = true;
this._tick();
return;
}
const hasPrelude = this.events.some((event) => event[1] < this._nextSync - 0.01);
if (hasPrelude) {
this._syncBeat = 0;
this._syncTime = performance.now() / 1000 - accompanimentLatencyCompSec();
}
this._running = true;
this._tick();
}
stop() {
this._running = false;
if (this._raf) cancelAnimationFrame(this._raf);
}
pause() {
this._running = false;
if (this._raf) cancelAnimationFrame(this._raf);
this._raf = null;
this._holdingForSoloist = false;
}
resume(beat, bps) {
this._bps = this.tempoForBeat(beat, bps);
this._syncBeat = beat;
this._syncTime = performance.now() / 1000 - (this._leadMode ? 0 : accompanimentLatencyCompSec());
this._nextSync = this.rhBeats.find((b) => b > beat + 0.01) ?? Infinity;
if (this._leadMode) {
this._lastConfirmedRhBeat = beat;
this._holdingForSoloist = false;
}
this._running = true;
this._tick();
}
onRhNote(beat, bps) {
this._nextSync = this.rhBeats.find(b => b > beat + 0.01) ?? Infinity;
this._bps = this.tempoForBeat(beat, bps);
if (this._leadMode) {
this._lastConfirmedRhBeat = Math.max(this._lastConfirmedRhBeat, beat);
this._holdingForSoloist = false;
this._syncBeat = beat;
// Latency compensation: the soloist physically played this note ~latency
// ago; the mic only just told us. Rebase the clock that far into the past
// so the next accompaniment note fires after (noteDuration - latency)
// instead of a full noteDuration. This keeps the accompaniment on its
// true tempo while leading, undoing input latency rather than adding to it.
this._syncTime = performance.now() / 1000 - accompanimentLatencyCompSec();
while (this._lhIdx < this.events.length && this.events[this._lhIdx][1] < beat - 0.08)
this._lhIdx++;
// …but the head-start must NOT dump notes or race past the next solo
// checkpoint. Cap the rebased clock at the next unplayed accompaniment
// note (and never beyond the next checkpoint): at most that one note is
// pulled early (delay clamped to 0 when latency β‰₯ its gap); everything
// after still plays at tempo, and the accompaniment still stops at the
// checkpoint to wait for the soloist instead of auto-playing through it.
const nextEventBeat = this._lhIdx < this.events.length ? this.events[this._lhIdx][1] : Infinity;
const cap = Math.min(nextEventBeat, this._allowedLeadBeat());
if (Number.isFinite(cap) && this._rawBeat() > cap) {
this._syncBeat = cap;
this._syncTime = performance.now() / 1000;
}
return;
}
this._syncBeat = beat;
// Bias the accompaniment clock slightly ahead to compensate for browser
// output latency that is still audible even with wired MIDI input.
this._syncTime = performance.now() / 1000 - accompanimentLatencyCompSec();
// Skip LH events now in the past
while (this._lhIdx < this.events.length && this.events[this._lhIdx][1] < beat - 0.08)
this._lhIdx++;
}
_currentBeat() {
if (this._syncTime === null) return 0;
const raw = this._rawBeat();
return this._leadMode ? Math.min(raw, this._allowedLeadBeat()) : raw;
}
currentBeat() {
return this._currentBeat();
}
seek(beat, bps = this._bps) {
const targetBeat = Math.max(0, Number(beat) || 0);
this._bps = this.tempoForBeat(targetBeat, bps);
this._syncBeat = targetBeat;
this._syncTime = performance.now() / 1000 - (this._leadMode ? 0 : accompanimentLatencyCompSec());
this._nextSync = this.rhBeats.find((b) => b > targetBeat + 0.01) ?? Infinity;
if (this._leadMode) this._lastConfirmedRhBeat = targetBeat;
if (this._leadMode) {
this._holdingForSoloist = false;
}
this._lhIdx = eventIndexAtOrAfterBeat(this.events, targetBeat - 0.01);
}
updateLatencyCompensation(previousSec, nextSec) {
// Leader mode applies latency fresh at each confirmed solo note (onRhNote),
// so a slider change takes effect on the next note without rebasing here.
if (this._leadMode) return;
if (this._syncTime === null) return;
const previous = Number.isFinite(previousSec) ? previousSec : 0;
const next = Number.isFinite(nextSec) ? nextSec : previous;
this._syncTime += previous - next;
}
tempoForBeat(beat, bps = this._bps) {
const fallback = Number.isFinite(bps) && bps > 0 ? bps : this._bps;
if (state.adaptiveTempo) return fallback;
return this.hasExtendedSoloGapAt(beat) ? this._targetBps : fallback;
}
hasExtendedSoloGapAt(beat) {
const targetBeat = Math.max(0, Number(beat) || 0);
const gap = this._soloGapAroundBeat(targetBeat);
if (!gap) return false;
const threshold = extendedSoloRestThresholdBeats(gap.start);
return gap.end - gap.start >= threshold - 0.001;
}
_soloGapAroundBeat(beat) {
const beats = this.rhBeats;
if (!beats.length) return null;
let previous = 0;
for (const rhBeat of beats) {
if (rhBeat <= beat + 0.01) previous = rhBeat;
else break;
}
const next = beats.find((rhBeat) => rhBeat > beat + 0.01);
const end = Number.isFinite(next) ? next : this._scoreEndBeat();
return Number.isFinite(end) && end > previous + 0.01 ? { start: previous, end } : null;
}
_scoreEndBeat() {
const lastRhBeat = this.rhBeats[this.rhBeats.length - 1] ?? 0;
const lastAccompanimentBeat = this.events.reduce((latest, event) => {
const start = Number(event?.[1]);
if (!Number.isFinite(start)) return latest;
return Math.max(latest, start + eventDuration(event, 0));
}, 0);
return Math.max(lastRhBeat, lastAccompanimentBeat);
}
_allowedLeadBeat() {
if (!this._leadMode) return Infinity;
// Choose where the accompaniment waits. Only the MIC needs to collapse
// repeated notes into phrasing β€” it can't tell a re-strike from a still-
// ringing note. MIDI/keyboard input is exact, so it must wait at EVERY note,
// including repeats; using the sparse checkpoints there made the
// accompaniment skip ahead through notes the player hadn't pressed yet.
const waitBeats = _inputMode === 'mic' ? this._checkpointBeats : this.rhBeats;
const nextRhBeat = waitBeats.find((beat) => beat > this._lastConfirmedRhBeat + 0.01);
if (!Number.isFinite(nextRhBeat)) return this._scoreEndBeat() + 0.25;
// Cap AT the next solo checkpoint, inclusive: the accompaniment plays its
// note at that beat (leading the soloist), then holds there until the
// soloist plays the same note. Capping just *before* the checkpoint was
// the old bug β€” it made the accompaniment wait behind the soloist.
return nextRhBeat;
}
_rawBeat() {
if (this._syncTime === null) return 0;
return this._syncBeat + (performance.now()/1000 - this._syncTime) * this._bps;
}
_holdLeadClockIfNeeded() {
if (!this._leadMode || this._syncTime === null) return;
const allowed = this._allowedLeadBeat();
if (this._rawBeat() <= allowed) {
if (this._holdingForSoloist && this._rawBeat() < allowed - 0.02) {
this._holdingForSoloist = false;
updatePracticePanelStatus();
}
return;
}
this._syncBeat = allowed;
this._syncTime = performance.now() / 1000;
if (!this._holdingForSoloist) {
this._holdingForSoloist = true;
updatePracticePanelStatus();
}
}
isHoldingForSoloist() {
return this._leadMode && this._syncTime !== null && (this._holdingForSoloist || this._rawBeat() >= this._allowedLeadBeat() - 0.01);
}
_eventReady(beat, current, allowedBeat, now) {
if (beat > allowedBeat + 0.001) return false;
// Both modes fire strictly on the beat clock. In leader mode that clock is
// capped at the next solo checkpoint (inclusive β€” see _allowedLeadBeat), so
// events play at tempo up to and including the checkpoint note and then
// wait for the soloist. Latency compensation is applied once per confirmed
// solo note by rebasing the clock in onRhNote, NOT as a per-note look-ahead
// here: a look-ahead made every note up to the checkpoint ready at once and
// the accompaniment rushed.
return current >= beat - 0.005;
}
_tick() {
if (!this._running) return;
this._holdLeadClockIfNeeded();
if (this._syncTime !== null && this._lhIdx < this.events.length) {
const [pitches, beat, durationBeats] = this.events[this._lhIdx];
const current = this._currentBeat();
const now = performance.now() / 1000;
const allowedBeat = this._leadMode ? this._allowedLeadBeat() : this._nextSync - 0.01;
if (this._eventReady(beat, current, allowedBeat, now)) {
let burst = 0;
const leaderGroupBeat = this._leadMode ? beat : null;
while (this._lhIdx < this.events.length && burst < 6) {
const [pitches, beat, durationBeats] = this.events[this._lhIdx];
const current = this._currentBeat();
const now = performance.now() / 1000;
const allowedBeat = this._leadMode ? this._allowedLeadBeat() : this._nextSync - 0.01;
if (this._leadMode && Math.abs(beat - leaderGroupBeat) > 0.001) break;
if (!this._eventReady(beat, current, allowedBeat, now)) break;
const instr = eventSourceInstrument(this.events[this._lhIdx]) || this._instruments[0] || 'piano';
const duration = eventDurationSeconds(this.events[this._lhIdx], instr, durationBeats ?? 0.75);
const pedalRelease = eventPedalRelease(this.events[this._lhIdx]);
playChord(pitches, eventVelocity(this.events[this._lhIdx]), instr, {
duration,
pedaled: pedalRelease !== null,
pedalHold: pedalRelease !== null ? Math.max(duration, (pedalRelease - beat) / Math.max(0.5, this._bps)) : null,
volume: state.playbackVolumes?.accompaniment ?? 1,
outputRole: 'accompaniment',
});
this._lhIdx++;
burst++;
}
}
}
this._raf = requestAnimationFrame(() => this._tick());
}
}
// ── App State ────────────────────────────────────────────────────────────────
let state = {
scores: [],
serverScores: [],
scoreMeta: {},
current: null,
fingeringJob: null,
tracker: null,
accompanist: null,
practiceRightHand: null,
practiceLeftHand: null,
playing: false,
selectedPart: 0,
selectedParts: [0],
partInstruments: {}, // partIndex β†’ instrument name override
scoreGridColumns: 3,
keyboardLayoutMode: 'full',
paused: false,
pausedBeat: 0,
pausedBps: 1,
practiceStartBeat: 0,
adaptiveTempo: false,
followMode: 'leader',
finishedPlayback: false,
guestMode: false,
playbackVolumes: { accompaniment: 0.5, solo: 1 },
listRoute: 'landing',
sheetView: { zoom: 1.0, rotation: 0 },
sheetSource: null, // { name, variant, hasSheet, musicXml }
sheetVariant: 'base',
sheetDisplayMode: 'full',
};
let _noteHighwayRaf = null;
let _noteHighwayStartTime = null;
let _noteHighwayStartBeat = 0;
let _noteHighwayBps = 1;
let _finishPlaybackTimer = null;
let _sheetMeasureEls = [];
let _sheetMeasureMap = [];
let _sheetHighlightRect = null;
let _sheetHighlightIndex = -1;
let _pendingSheetHighlightBeat = null;
let _sheetRenderSeq = 0;
const _sheetHtmlCache = new Map();
const _sheetMusicXmlCache = new Map();
let _scorePreviewRun = null;
let _scorePreviewTimer = null;
let _fingeringJobPollTimer = null;
const SCORE_LIBRARY_KEY = 'accompy_score_library_v1';
const SCORE_LIBRARY_INIT_KEY = 'accompy_score_library_initialized_v1';
const PLAY_SIDEBAR_COLLAPSED_KEY = 'accompy_play_sidebar_collapsed_v1';
const PRACTICE_SPLIT_KEY = 'accompy_practice_split_v1';
const SHELL_SPLIT_KEY = 'accompy_shell_split_px_v1';
const PANEL_SPLIT_KEY = 'accompy_panel_split_px_v1';
const GUEST_MODE_KEY = 'notepilot_guest_mode_v1';
const GUEST_PROGRESS_KEY = 'notepilot_guest_progress_v1';
const SCORE_RECENTS_KEY = 'notepilot_recent_scores_v1';
const SCORE_PREFS_KEY = 'notepilot_score_preferences_v1';
const SCORE_LIST_CACHE_KEY = 'notepilot_score_list_snapshot_v1';
const PLAYBACK_VOLUME_KEY = 'notepilot_playback_volume_v3';
const SHEET_DISPLAY_MODE_KEY = 'notepilot_sheet_display_mode_v1';
const ADAPTIVE_TEMPO_KEY = 'notepilot_adaptive_tempo_v1';
const FOLLOW_MODE_KEY = 'notepilot_follow_mode_v1';
const SCORE_PREVIEW_CONCURRENCY = 2;
const SCORE_PREVIEW_START_DELAY_MS = 900;
const PRACTICE_SPLITTER_SIZE = 12;
const LAYOUT_SPLITTER_SIZE = 12;
let _appConfig = { supabase_enabled: false, auth_enabled: false };
let _authUser = null;
const GUEST_DEMO_SCORES = [
{
name: 'guest_beginner_demo',
title: 'Beginner Demo Piece',
subtitle: 'A short C major melody that shows following, tempo, and the keyboard visualizer.',
cta: 'Try your first piece',
primary: true,
measure_beats: [0, 4, 8, 12],
tips: [
'Start at 60 BPM and play the highlighted notes with A S D J K.',
'Watch the green guide move as NotePilot follows your tempo.',
'Try switching to Mini keyboard after the first run.',
],
parts: [
{
name: 'Melody',
instrument: 'piano',
notes: [[60, 0, 1], [62, 1, 1], [64, 2, 1], [67, 3, 1], [69, 4, 1], [67, 5, 1], [64, 6, 1], [62, 7, 1], [60, 8, 2], [62, 10, 1], [60, 11, 1], [60, 12, 2]],
},
{
name: 'Accompaniment',
instrument: 'piano',
notes: [[[48, 55], 0, 4], [[53, 57], 4, 4], [[48, 55], 8, 1], [[47, 55], 9, 3], [[48, 52, 55], 12, 2]],
},
],
},
];
// ── API helpers ──────────────────────────────────────────────────────────────
async function api(path, opts) {
const headers = { ...((opts && opts.headers) || {}) };
const request = { cache: 'no-store', ...(opts || {}), headers };
const r = await fetch(path, request);
if (!r.ok) throw new Error(readApiErrorText(await r.text()));
return r.json();
}
function readApiErrorText(text) {
try {
const parsed = JSON.parse(text);
return parsed.detail || text;
} catch {
return text;
}
}
function readApiErrorMessage(error) {
const fallback = error?.message || String(error);
try {
const parsed = JSON.parse(fallback);
return parsed.detail || fallback;
} catch {
return fallback;
}
}
function loadGuestProgress() {
try {
const parsed = JSON.parse(localStorage.getItem(GUEST_PROGRESS_KEY) || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function saveGuestProgress(progress) {
localStorage.setItem(GUEST_PROGRESS_KEY, JSON.stringify(progress || {}));
}
function guestScoreByName(name) {
return GUEST_DEMO_SCORES.find((score) => score.name === name) || null;
}
function isGuestScoreName(name) {
return !!guestScoreByName(name);
}
function displayNameForScore(name) {
return guestScoreByName(name)?.title || state.scoreMeta?.[name]?.title || formatName(name);
}
function guestScorePayload(score) {
const parts = score.parts.map((part) => ({
name: part.name,
instrument: part.instrument || 'piano',
notes: part.notes.map((note) => Array.isArray(note) ? [...note] : note),
}));
return {
name: score.name,
title: score.title,
source_type: 'guest',
guest: true,
has_sheet: false,
has_fingered_sheet: false,
sheet_html: '',
musicxml_source: guestMusicXml(score),
fingered_musicxml_source: '',
fingering: { eligible: false, available: false, applied: false },
measure_beats: score.measure_beats || [0, 4, 8],
parts,
right_hand: parts[0]?.notes || [],
left_hand: parts.slice(1).flatMap((part) => part.notes),
tips: score.tips || [],
};
}
function guestMusicXml(score) {
const starts = score.measure_beats || [0];
const parts = score.parts?.length ? score.parts : [];
const partList = parts.map((part, index) =>
`<score-part id="P${index + 1}"><part-name>${escapeXml(part.name || `Part ${index + 1}`)}</part-name></score-part>`
).join('');
const partXml = parts.map((part, partIndex) => {
const measures = starts.map((start, measureIndex) => {
const end = starts[measureIndex + 1] ?? start + 4;
return guestMeasureXml(part.notes || [], start, end, measureIndex, partIndex);
}).join('');
return `<part id="P${partIndex + 1}">${measures}</part>`;
}).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<score-partwise version="3.1">
<work><work-title>${escapeXml(score.title)}</work-title></work>
<part-list>${partList}</part-list>
${partXml}
</score-partwise>`;
}
function guestMeasureXml(events, start, end, measureIndex, partIndex) {
const attrs = measureIndex === 0
? `<attributes><divisions>4</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>${partIndex === 0 ? 'G' : 'F'}</sign><line>${partIndex === 0 ? '2' : '4'}</line></clef></attributes>`
: '';
const measureEvents = [...events]
.filter((event) => event[1] >= start && event[1] < end)
.sort((a, b) => a[1] - b[1]);
let cursor = start;
const notes = [];
measureEvents.forEach((event) => {
const beat = Math.max(start, Number(event[1]) || start);
if (beat > cursor) notes.push(guestRestXml(beat - cursor));
const eventBeats = Math.min(eventDuration(event, 1), end - beat);
notes.push(guestEventXml(event, eventBeats));
cursor = Math.max(cursor, beat + eventBeats);
});
if (cursor < end) notes.push(guestRestXml(end - cursor));
return `<measure number="${measureIndex + 1}">${attrs}${notes.join('') || guestRestXml(end - start)}</measure>`;
}
function guestEventXml(event, beats) {
const pitches = eventPitches(event);
if (!pitches.length) return guestRestXml(beats);
return pitches.map((pitch, index) => guestNoteXml(pitch, beats, index > 0)).join('');
}
function guestNoteXml(pitch, beats, chord = false) {
const duration = Math.max(1, Math.round(Math.max(0.25, beats || 1) * 4));
const { step, alter, octave } = midiPitchParts(pitch);
const type = duration >= 8 ? 'half' : duration >= 4 ? 'quarter' : duration >= 2 ? 'eighth' : '16th';
return `<note>${chord ? '<chord/>' : ''}<pitch><step>${step}</step>${alter ? `<alter>${alter}</alter>` : ''}<octave>${octave}</octave></pitch><duration>${duration}</duration><type>${type}</type></note>`;
}
function guestRestXml(beats) {
const duration = Math.max(1, Math.round(Math.max(0.25, beats || 1) * 4));
const type = duration >= 16 ? 'whole' : duration >= 8 ? 'half' : duration >= 4 ? 'quarter' : duration >= 2 ? 'eighth' : '16th';
return `<note><rest/><duration>${duration}</duration><type>${type}</type></note>`;
}
function midiPitchParts(midi) {
const names = [
['C', 0], ['C', 1], ['D', 0], ['D', 1], ['E', 0], ['F', 0],
['F', 1], ['G', 0], ['G', 1], ['A', 0], ['A', 1], ['B', 0],
];
const [step, alter] = names[midi % 12];
return { step, alter, octave: Math.floor(midi / 12) - 1 };
}
function escapeXml(value) {
return String(value ?? '').replace(/[<>&'"]/g, (char) => ({
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
"'": '&apos;',
'"': '&quot;',
}[char]));
}
// ── Screens ──────────────────────────────────────────────────────────────────
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
document.body.classList.toggle('practice-mode', id === 'play-screen');
const header = document.getElementById('app-header');
if (header) header.hidden = id === 'play-screen';
}
function decodePathSegment(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function accountUrlSlug() {
if (state.guestMode) return 'guest';
const raw = _authUser?.username || _authUser?.email || _authUser?.id || 'me';
const base = String(raw).split('@')[0] || 'me';
const slug = base.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'me';
}
function homeUrlPath() {
return `/${encodeURIComponent(accountUrlSlug())}/home`;
}
function scoreUrlPath(name) {
return `/${encodeURIComponent(accountUrlSlug())}/${encodeURIComponent(name)}`;
}
function parseAppRoute() {
const raw = window.location.pathname.replace(/^\/+|\/+$/g, '');
if (!raw || raw.startsWith('api/')) return { mode: 'landing' };
const segments = raw.split('/').filter(Boolean).map(decodePathSegment);
const first = (segments[0] || '').toLowerCase();
const second = (segments[1] || '').toLowerCase();
if (first === 'auth' && second === 'callback') return { mode: 'signin' };
if (first === 'signin' || first === 'login') return { mode: 'signin' };
if (first === 'home') return { mode: 'home' };
if (second === 'home') return { mode: 'home', account: segments[0] };
if (segments.length >= 2) return { mode: 'score', account: segments[0], score: segments.slice(1).join('/') };
return { mode: 'score', score: segments[0] };
}
function routeScoreName() {
const route = parseAppRoute();
return route.mode === 'score' ? route.score : null;
}
function writeUrl(nextPath, replace = false, statePayload = {}) {
if (window.location.pathname === nextPath && !window.location.search && !window.location.hash) return;
const method = replace ? 'replaceState' : 'pushState';
window.history[method](statePayload, '', nextPath);
}
function updateScoreUrl(name, replace = false) {
writeUrl(name ? scoreUrlPath(name) : '/', replace, { score: name || null });
}
function updateHomeUrl(replace = false) {
writeUrl(homeUrlPath(), replace, { route: 'home' });
}
function setListRoute(mode) {
state.listRoute = mode;
showScreen('list-screen');
document.body.classList.toggle('landing-route', mode === 'landing');
document.body.classList.toggle('signin-route', mode === 'signin');
document.body.classList.toggle('library-route', mode === 'library');
updateAuthUI();
}
function goHome() {
if (state.playing) stopPlaying();
writeUrl('/', false, { route: 'landing' });
setListRoute('landing');
}
function goToSignIn(replace = false) {
if (state.playing) stopPlaying();
writeUrl('/signin', replace, { route: 'signin' });
setListRoute('signin');
setTimeout(() => document.getElementById('auth-email')?.focus(), 50);
}
async function goToLibrary(replace = false) {
if (state.playing) stopPlaying();
if (_appConfig.auth_enabled && !_authUser && !state.guestMode) {
goToSignIn(replace);
return;
}
updateHomeUrl(replace);
setListRoute('library');
await loadScoreList().catch((error) => console.warn('Could not refresh score list:', error));
}
window.goHome = goHome;
window.goToSignIn = goToSignIn;
window.goToLibrary = goToLibrary;
function applyPlaySidebarCollapsed(collapsed) {
const shell = document.querySelector('#play-screen .play-shell');
const toggle = document.getElementById('play-sidebar-toggle');
if (!shell || !toggle) return;
const normalized = !!collapsed;
shell.dataset.sidebar = normalized ? 'collapsed' : 'open';
toggle.textContent = '☰';
toggle.setAttribute('aria-label', normalized ? 'Expand piece sidebar' : 'Collapse piece sidebar');
localStorage.setItem(PLAY_SIDEBAR_COLLAPSED_KEY, normalized ? '1' : '0');
applyShellSplit(localStorage.getItem(SHELL_SPLIT_KEY));
}
function togglePlaySidebar() {
const shell = document.querySelector('#play-screen .play-shell');
if (!shell) return;
applyPlaySidebarCollapsed(shell.dataset.sidebar !== 'collapsed');
}
window.togglePlaySidebar = togglePlaySidebar;
function setAuthStatus(message, tone = 'muted') {
const el = document.getElementById('auth-status');
if (!el) return;
el.textContent = message || '';
el.style.color = tone === 'error'
? '#e05c5c'
: tone === 'success'
? 'var(--success)'
: 'var(--muted)';
}
function updateAuthUI() {
const panel = document.getElementById('auth-panel');
const loggedOut = document.getElementById('auth-logged-out');
const addPieceBtn = document.getElementById('add-piece-btn');
const guestBanner = document.getElementById('guest-banner');
const playGuestBanner = document.getElementById('play-guest-banner');
const navAuthUser = document.getElementById('nav-auth-user');
const navUsername = document.getElementById('nav-auth-username');
const navSignIn = document.getElementById('nav-signin-btn');
const navLibrary = document.getElementById('nav-library-btn');
const googleBtn = document.getElementById('google-signin-btn');
const googleDivider = document.getElementById('google-auth-divider');
const libraryTitle = document.getElementById('library-title');
const librarySubtitle = document.getElementById('library-subtitle');
if (!panel || !loggedOut || !addPieceBtn) return;
updateSidebarAccountUI();
const isLoggedIn = !!_authUser;
const canUseLibrary = state.guestMode || isLoggedIn || !_appConfig.auth_enabled;
const showSignInRoute = state.listRoute === 'signin' && !canUseLibrary;
document.body.classList.toggle('guest-mode', state.guestMode);
document.body.classList.toggle('auth-mode', showSignInRoute);
panel.style.display = showSignInRoute ? 'grid' : 'none';
loggedOut.style.display = showSignInRoute ? 'block' : 'none';
if (navSignIn) {
navSignIn.style.display = (!isLoggedIn && !state.guestMode && _appConfig.auth_enabled && state.listRoute !== 'signin') ? 'inline-flex' : 'none';
}
if (navLibrary) {
navLibrary.style.display = (canUseLibrary && state.listRoute !== 'library') ? 'inline-flex' : 'none';
}
if (guestBanner) guestBanner.style.display = state.guestMode ? 'flex' : 'none';
if (playGuestBanner) playGuestBanner.style.display = state.guestMode ? 'block' : 'none';
if (libraryTitle) libraryTitle.textContent = state.guestMode ? 'Demo Library' : 'Your Library';
if (librarySubtitle) {
librarySubtitle.textContent = state.guestMode
? 'Try a guided NotePilot sandbox. Demo changes stay on this device.'
: 'Choose a piece, set your part, and let NotePilot follow your tempo.';
}
if (state.guestMode) {
addPieceBtn.disabled = false;
addPieceBtn.textContent = '+ Add piece';
if (navAuthUser) navAuthUser.style.display = 'none';
if (navUsername) navUsername.textContent = '';
renderPlayPieceList();
return;
}
if (!_appConfig.auth_enabled) {
if (guestBanner) guestBanner.style.display = 'none';
if (playGuestBanner) playGuestBanner.style.display = 'none';
if (navAuthUser) navAuthUser.style.display = 'none';
addPieceBtn.disabled = false;
addPieceBtn.textContent = '+ Add piece';
return;
}
if (googleBtn) googleBtn.style.display = _appConfig.google_auth_enabled ? 'inline-flex' : 'none';
if (googleDivider) googleDivider.style.display = _appConfig.google_auth_enabled ? 'flex' : 'none';
addPieceBtn.disabled = !isLoggedIn;
addPieceBtn.textContent = '+ Add piece';
if (navAuthUser) navAuthUser.style.display = isLoggedIn ? 'flex' : 'none';
if (isLoggedIn) {
if (navUsername) navUsername.textContent = _authUser.email || _authUser.username || 'Signed in';
setAuthStatus('');
} else {
if (navUsername) navUsername.textContent = '';
document.getElementById('score-grid').innerHTML = '<div class="score-preview-empty">Sign in to load your score library.</div>';
}
renderPlayPieceList();
}
function accountLabel() {
if (state.guestMode) return 'Guest Mode';
return _authUser?.email || _authUser?.username || 'Not signed in';
}
function accountInitials() {
if (state.guestMode) return 'G';
const label = accountLabel();
if (!label || label === 'Not signed in') return '?';
const parts = label.split(/[@\s._-]+/).filter(Boolean);
return (parts.length >= 2 ? parts[0][0] + parts[1][0] : label.slice(0, 1)).toUpperCase();
}
function updateSidebarAccountUI() {
const button = document.getElementById('sidebar-account-btn');
const name = document.getElementById('sidebar-account-name');
const detail = document.getElementById('sidebar-account-detail');
const action = document.getElementById('sidebar-account-action');
if (!button || !name || !detail || !action) return;
button.textContent = accountInitials();
name.textContent = accountLabel();
if (state.guestMode) {
detail.textContent = 'Demo changes stay on this device.';
action.textContent = 'Create Account';
} else if (_authUser) {
detail.textContent = 'Signed in to NotePilot.';
action.textContent = 'Log out';
} else {
detail.textContent = 'Sign in to save your library.';
action.textContent = 'Sign in';
}
}
function toggleSidebarAccountMenu(force) {
const popover = document.getElementById('sidebar-account-popover');
const button = document.getElementById('sidebar-account-btn');
if (!popover || !button) return;
const shouldOpen = typeof force === 'boolean' ? force : popover.style.display === 'none';
popover.style.display = shouldOpen ? 'block' : 'none';
button.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false');
if (shouldOpen) updateSidebarAccountUI();
}
function sidebarAccountAction() {
toggleSidebarAccountMenu(false);
if (state.guestMode) {
showCreateAccountPrompt('Create a free account to save your own pieces and progress.');
return;
}
if (_authUser) {
signOut();
return;
}
goToSignIn(true);
}
document.addEventListener('click', (event) => {
const menu = document.querySelector('.sidebar-account-menu');
if (!menu || menu.contains(event.target)) return;
toggleSidebarAccountMenu(false);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') toggleSidebarAccountMenu(false);
});
window.toggleSidebarAccountMenu = toggleSidebarAccountMenu;
window.sidebarAccountAction = sidebarAccountAction;
async function completeGoogleRedirectIfNeeded() {
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const query = new URLSearchParams(window.location.search);
const oauthError = params.get('error_description') || query.get('error_description') || params.get('error') || query.get('error');
if (oauthError) {
window.history.replaceState({ route: 'signin' }, '', '/signin');
setAuthStatus(`Google sign-in failed: ${oauthError}`, 'error');
return;
}
const accessToken = params.get('access_token');
if (!accessToken) return;
setAuthStatus('Finishing Google sign-in...');
try {
const result = await api('/api/auth/supabase-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: accessToken }),
});
_authUser = result.user || null;
if (_authUser) {
state.guestMode = false;
localStorage.removeItem(GUEST_MODE_KEY);
window.history.replaceState({ route: 'home' }, '', homeUrlPath());
} else {
window.history.replaceState({ route: 'signin' }, '', '/signin');
}
} catch (error) {
window.history.replaceState({ route: 'signin' }, '', '/signin');
setAuthStatus(error.message || 'Google sign-in failed.', 'error');
}
}
async function initAppConfig() {
state.guestMode = localStorage.getItem(GUEST_MODE_KEY) === '1';
try {
_appConfig = await api('/api/config');
} catch (error) {
_appConfig = { supabase_enabled: false, auth_enabled: false };
setAuthStatus(`Auth config failed to load: ${readApiErrorMessage(error)}`);
updateAuthUI();
return;
}
if (_appConfig.auth_enabled) {
await completeGoogleRedirectIfNeeded();
try {
const session = await api('/api/session');
_authUser = session.user || null;
if (_authUser) {
state.guestMode = false;
localStorage.removeItem(GUEST_MODE_KEY);
}
if (session.session_error && !_authUser) {
setAuthStatus('Previous session could not be restored. Please sign in again.', 'error');
}
} catch (error) {
_authUser = null;
setAuthStatus(`Previous session could not be restored. Please sign in again.`, 'error');
}
}
updateAuthUI();
}
async function signIn() {
const username = document.getElementById('auth-email').value.trim();
const password = document.getElementById('auth-password').value;
if (!_appConfig.auth_enabled) return;
setAuthStatus('Signing in...');
try {
const result = await api('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
_authUser = result.user || null;
state.guestMode = false;
localStorage.removeItem(GUEST_MODE_KEY);
updateAuthUI();
await goToLibrary(true);
setAuthStatus('Signed in.', 'success');
} catch (error) {
setAuthStatus(readApiErrorMessage(error) || 'Sign in failed.', 'error');
return;
}
}
async function signUp() {
if (!_appConfig.auth_enabled) return;
const username = document.getElementById('auth-email').value.trim();
const password = document.getElementById('auth-password').value;
setAuthStatus('Creating account...');
try {
const result = await api('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
_authUser = result.user || null;
state.guestMode = false;
localStorage.removeItem(GUEST_MODE_KEY);
updateAuthUI();
await goToLibrary(true);
setAuthStatus('Account created.', 'success');
} catch (error) {
setAuthStatus(readApiErrorMessage(error) || 'Sign up failed.', 'error');
return;
}
}
function signInWithGoogle() {
if (!_appConfig.google_auth_enabled) {
setAuthStatus('Google sign-in is not configured.', 'error');
return;
}
setAuthStatus('Redirecting to Google...');
window.location.href = '/api/auth/google/start';
}
async function signOut() {
if (state.playing) stopPlaying();
clearFingeringJobPolling();
setFingeringJob(null);
await api('/api/logout', { method: 'POST' });
_authUser = null;
state.current = null;
state.scores = [];
state.serverScores = [];
state.scoreMeta = {};
state.sheetSource = null;
state.sheetVariant = 'base';
updateAuthUI();
renderPlayPieceList();
writeUrl('/', true, { route: 'landing' });
setListRoute('landing');
}
async function continueAsGuest() {
if (state.playing) stopPlaying();
_authUser = null;
state.guestMode = true;
state.current = null;
state.scores = GUEST_DEMO_SCORES.map((score) => score.name);
state.serverScores = [];
localStorage.setItem(GUEST_MODE_KEY, '1');
hideUpgradeToast();
updateAuthUI();
await goToLibrary(true);
}
async function resetGuestDemo() {
localStorage.removeItem(GUEST_PROGRESS_KEY);
hideUpgradeToast();
if (state.guestMode) await loadScoreList();
}
function showCreateAccountPrompt(message = 'Create a free account to save your own pieces and progress.') {
if (state.playing) stopPlaying();
state.guestMode = false;
localStorage.removeItem(GUEST_MODE_KEY);
hideUpgradeToast();
goToSignIn(true);
setAuthStatus(message, 'success');
setTimeout(() => document.getElementById('auth-email')?.focus(), 50);
}
function showGuestUpgradeToast(message) {
if (!state.guestMode) return;
const toast = document.getElementById('upgrade-toast');
const messageEl = document.getElementById('upgrade-toast-message');
if (!toast || !messageEl) return;
messageEl.textContent = message || 'Create a free account to save your own pieces and progress.';
toast.style.display = 'block';
}
function hideUpgradeToast() {
const toast = document.getElementById('upgrade-toast');
if (toast) toast.style.display = 'none';
}
window.signIn = signIn;
window.signUp = signUp;
window.signInWithGoogle = signInWithGoogle;
window.signOut = signOut;
window.continueAsGuest = continueAsGuest;
window.resetGuestDemo = resetGuestDemo;
window.showCreateAccountPrompt = showCreateAccountPrompt;
window.hideUpgradeToast = hideUpgradeToast;
function loadPersonalScoreLibrary() {
if (_appConfig.auth_enabled) return [...(state.serverScores || [])];
try {
const parsed = JSON.parse(localStorage.getItem(SCORE_LIBRARY_KEY) || '[]');
return Array.isArray(parsed) ? parsed.filter((name) => typeof name === 'string' && name) : [];
} catch {
return [];
}
}
function savePersonalScoreLibrary(names) {
if (_appConfig.auth_enabled) {
const deduped = [...new Set((names || []).filter(Boolean))].sort();
state.scores = deduped;
return deduped;
}
const deduped = [...new Set((names || []).filter(Boolean))].sort();
localStorage.setItem(SCORE_LIBRARY_KEY, JSON.stringify(deduped));
localStorage.setItem(SCORE_LIBRARY_INIT_KEY, '1');
state.scores = deduped;
return deduped;
}
function scoreListCacheKey() {
return `${SCORE_LIST_CACHE_KEY}:${accountUrlSlug()}`;
}
function normalizeScoreListItems(items = [], scores = []) {
const rawItems = Array.isArray(items) && items.length
? items
: (Array.isArray(scores) ? scores : []).map((name) => ({ name }));
return rawItems
.filter((item) => item && typeof item.name === 'string' && item.name)
.map((item) => ({
name: item.name,
title: item.title || formatName(item.name),
created_at: item.created_at || null,
has_sheet: item.has_sheet,
}));
}
function readCachedScoreList() {
try {
const parsed = JSON.parse(localStorage.getItem(scoreListCacheKey()) || 'null');
if (!parsed || !Array.isArray(parsed.items)) return [];
return normalizeScoreListItems(parsed.items);
} catch {
return [];
}
}
function writeCachedScoreList(items = []) {
const normalized = normalizeScoreListItems(items);
if (!normalized.length) {
localStorage.removeItem(scoreListCacheKey());
return;
}
localStorage.setItem(scoreListCacheKey(), JSON.stringify({
savedAt: Date.now(),
items: normalized,
}));
}
function addScoreToLibrary(name) {
return savePersonalScoreLibrary([...loadPersonalScoreLibrary(), name]);
}
function removeScoreFromLibrary(name) {
localStorage.removeItem(`accompy_score_${name}`);
localStorage.removeItem(`accompy_score_v2_${name}`);
return savePersonalScoreLibrary(loadPersonalScoreLibrary().filter((item) => item !== name));
}
function userStorageSuffix() {
if (state.guestMode) return 'guest';
return _authUser?.id || _authUser?.username || 'local';
}
function scopedStorageKey(base) {
return `${base}_${userStorageSuffix()}`;
}
function readJsonStorage(key, fallback) {
try {
const parsed = JSON.parse(localStorage.getItem(key) || '');
return parsed ?? fallback;
} catch {
return fallback;
}
}
function writeJsonStorage(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function loadRecentScores() {
const data = readJsonStorage(scopedStorageKey(SCORE_RECENTS_KEY), {});
return data && typeof data === 'object' ? data : {};
}
function markScoreOpened(name) {
if (!name) return;
const recents = loadRecentScores();
recents[name] = Date.now();
writeJsonStorage(scopedStorageKey(SCORE_RECENTS_KEY), recents);
}
function sortScoreItemsByRecent(items) {
const recents = loadRecentScores();
return [...items].sort((a, b) => {
const recentDelta = (recents[b.name] || 0) - (recents[a.name] || 0);
if (recentDelta) return recentDelta;
return displayNameForScore(a.name).localeCompare(displayNameForScore(b.name));
});
}
function loadAllScorePreferences() {
const data = readJsonStorage(scopedStorageKey(SCORE_PREFS_KEY), {});
return data && typeof data === 'object' ? data : {};
}
function loadScorePreferences(name) {
return loadAllScorePreferences()[name] || {};
}
function saveScorePreferences(name, prefs) {
if (!name) return;
const all = loadAllScorePreferences();
all[name] = { ...(all[name] || {}), ...prefs };
writeJsonStorage(scopedStorageKey(SCORE_PREFS_KEY), all);
}
function saveCurrentScorePreferences(extra = {}) {
const name = state.current?.name;
if (!name) return;
const bpm = Number.parseFloat(document.getElementById('bpm-input')?.value);
saveScorePreferences(name, {
bpm: Number.isFinite(bpm) ? bpm : undefined,
inputMode: _inputMode,
keyboardLayoutMode: state.keyboardLayoutMode,
selectedPart: state.selectedPart ?? 0,
selectedParts: selectedPartIndices(),
partInstruments: { ...(state.partInstruments || {}) },
...extra,
});
}
function selectedPartsPreferenceKey(indices = selectedPartIndices()) {
return [...indices].map((idx) => Number.parseInt(idx, 10)).filter(Number.isInteger).sort((a, b) => a - b).join(',');
}
function normalizedLatencyMs(value) {
const ms = Number.parseInt(value, 10);
return Number.isFinite(ms) ? Math.max(0, Math.min(LATENCY_COMPENSATION_MAX_MS, ms)) : null;
}
function savedLatencyForSelectedParts(prefs = {}, indices = selectedPartIndices()) {
const key = selectedPartsPreferenceKey(indices);
const bySelection = prefs.latencyBySelectedParts && typeof prefs.latencyBySelectedParts === 'object'
? prefs.latencyBySelectedParts
: {};
const selectedLatency = normalizedLatencyMs(bySelection[key]);
if (selectedLatency !== null) return selectedLatency;
const scoreLatency = normalizedLatencyMs(prefs.latencyMs);
if (scoreLatency !== null) return scoreLatency;
return normalizedLatencyMs(localStorage.getItem('accompy_latency_comp_ms')) ?? 0;
}
function saveCurrentLatencyPreference(ms = Math.round(_accompanimentLatencyCompSec * 1000)) {
const name = state.current?.name;
if (!name) return;
const prefs = loadScorePreferences(name);
const latencyMs = normalizedLatencyMs(ms);
if (latencyMs === null) return;
const key = selectedPartsPreferenceKey();
saveScorePreferences(name, {
latencyMs,
latencyBySelectedParts: {
...((prefs.latencyBySelectedParts && typeof prefs.latencyBySelectedParts === 'object') ? prefs.latencyBySelectedParts : {}),
[key]: latencyMs,
},
});
}
function applySavedLatencyForCurrentSelection(prefs = loadScorePreferences(state.current?.name)) {
const key = selectedPartsPreferenceKey();
const bySelection = prefs?.latencyBySelectedParts && typeof prefs.latencyBySelectedParts === 'object'
? prefs.latencyBySelectedParts
: {};
const hasSavedSelection = normalizedLatencyMs(bySelection[key]) !== null
|| normalizedLatencyMs(prefs?.latencyMs) !== null;
if (!hasSavedSelection) return false;
setLatencyCompensation(savedLatencyForSelectedParts(prefs), { persistScore: false });
return true;
}
function selectedPartIndices() {
const parts = state.current?.parts || [];
const max = Math.max(0, parts.length - 1);
const raw = Array.isArray(state.selectedParts) && state.selectedParts.length
? state.selectedParts
: [state.selectedPart ?? 0];
const indices = [...new Set(raw
.map((idx) => Number.parseInt(idx, 10))
.filter((idx) => Number.isInteger(idx) && idx >= 0 && (!parts.length || idx <= max))
)].sort((a, b) => a - b);
return indices.length ? indices : [0];
}
function setSelectedPartIndices(indices) {
const parts = state.current?.parts || [];
const max = Math.max(0, parts.length - 1);
const normalized = [...new Set((indices || [])
.map((idx) => Number.parseInt(idx, 10))
.filter((idx) => Number.isInteger(idx) && idx >= 0 && (!parts.length || idx <= max))
)].sort((a, b) => a - b);
state.selectedParts = normalized.length ? normalized : [0];
state.selectedPart = state.selectedParts[0] ?? 0;
}
function defaultSelectedPartsForScore(score, prefs = {}) {
const parts = score?.parts || [];
if (!parts.length) return [0];
if (Array.isArray(prefs.selectedParts) && prefs.selectedParts.length) {
return prefs.selectedParts;
}
const title = `${score.title || ''} ${score.name || ''}`.toLowerCase();
if (title.includes('piano concerto')) {
const pianoParts = parts
.map((part, idx) => ({ part, idx }))
.filter(({ part }) => String(part.instrument || part.name || '').toLowerCase().includes('piano'))
.map(({ idx }) => idx);
if (pianoParts.length) return pianoParts;
}
if (Number.isInteger(prefs.selectedPart)) {
return [prefs.selectedPart];
}
return [0];
}
function mergePracticeEventsFromParts(partIndices) {
const parts = state.current?.parts;
if (!parts || !parts.length) return state.current?.right_hand || [];
const grouped = new Map();
partIndices.forEach((partIdx) => {
const part = parts[partIdx];
(part?.notes || []).forEach((event) => {
const beat = Number(event?.[1]);
if (!Number.isFinite(beat)) return;
const key = beat.toFixed(6);
const pitches = eventPitches(event);
if (!pitches.length) return;
const duration = eventDuration(event);
const existing = grouped.get(key) || {
pitches: [],
beat,
duration: 0,
pedalRelease: null,
metadata: [],
};
existing.pitches.push(...pitches);
existing.duration = Math.max(existing.duration, duration);
const pedalRelease = eventPedalRelease(event);
if (pedalRelease !== null) {
existing.pedalRelease = Math.max(existing.pedalRelease ?? pedalRelease, pedalRelease);
}
if (Array.isArray(event)) {
existing.metadata.push(...event.slice(4).filter((item) => item !== undefined && item !== null));
}
grouped.set(key, existing);
});
});
return [...grouped.values()]
.sort((a, b) => a.beat - b.beat)
.map((entry) => {
const pitches = [...new Set(entry.pitches)].sort((a, b) => a - b);
const merged = [pitches.length === 1 ? pitches[0] : pitches, entry.beat, entry.duration || 0.75];
if (entry.pedalRelease !== null) merged.push(entry.pedalRelease);
entry.metadata.forEach((item) => appendEventMetadata(merged, item));
return merged;
});
}
function updatePartSelectionUI() {
const selected = new Set(selectedPartIndices());
document.querySelectorAll('.part-btn').forEach((button) => {
const idx = Number.parseInt(button.dataset.partIndex || '-1', 10);
const isSelected = selected.has(idx);
button.classList.toggle('selected', isSelected);
button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
});
const summary = document.getElementById('accompaniment-summary');
if (summary) {
const parts = state.current?.parts || [];
const accompanimentCount = Math.max(0, parts.length - selected.size);
summary.textContent = accompanimentCount === 1 ? '1 other part' : `${accompanimentCount} other parts`;
}
}
function normalizedScoreGridColumns(value) {
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n)) return 3;
return Math.max(2, Math.min(6, n));
}
function applyScoreGridColumns(value) {
const columns = normalizedScoreGridColumns(value);
state.scoreGridColumns = columns;
document.getElementById('score-grid')?.style.setProperty('--score-grid-columns', String(columns));
const select = document.getElementById('score-grid-columns');
if (select && select.value !== String(columns)) select.value = String(columns);
requestAnimationFrame(() => resizeScorePreviews());
}
function setScoreGridColumns(value) {
const columns = normalizedScoreGridColumns(value);
applyScoreGridColumns(columns);
localStorage.setItem('accompy_score_grid_columns', String(columns));
}
function applyKeyboardLayoutMode(mode) {
const normalized = mode === 'mini' ? 'mini' : 'full';
state.keyboardLayoutMode = normalized;
const section = document.getElementById('keyboard-section');
const miniPanel = document.getElementById('mini-keyboard-panel');
const mini = document.getElementById('mini-keyboard-layout');
const full = document.getElementById('full-keyboard-panel');
if (section) section.dataset.layoutMode = normalized;
if (miniPanel) {
miniPanel.classList.toggle('hidden', normalized !== 'mini');
miniPanel.style.display = normalized === 'mini' ? '' : 'none';
miniPanel.hidden = normalized !== 'mini';
}
if (mini) {
mini.classList.toggle('hidden', normalized !== 'mini');
mini.style.display = normalized === 'mini' ? '' : 'none';
mini.hidden = normalized !== 'mini';
}
if (full) {
full.classList.toggle('hidden', normalized !== 'full');
full.style.display = normalized === 'full' ? '' : 'none';
full.hidden = normalized !== 'full';
}
document.getElementById('keyboard-view-mini')?.classList.toggle('active', normalized === 'mini');
document.getElementById('keyboard-view-full')?.classList.toggle('active', normalized === 'full');
}
function setKeyboardLayoutMode(mode) {
applyKeyboardLayoutMode(mode);
localStorage.setItem('accompy_keyboard_layout_mode', state.keyboardLayoutMode);
saveCurrentScorePreferences({ keyboardLayoutMode: state.keyboardLayoutMode });
queuePracticeLayoutRender();
}
window.setKeyboardLayoutMode = setKeyboardLayoutMode;
function queuePracticeLayoutRender() {
requestAnimationFrame(() => {
applySheetView();
renderNoteHighway();
});
}
function normalizedPracticeSplit(value) {
const numeric = Number.parseFloat(value);
if (!Number.isFinite(numeric)) return 50;
return Math.max(0, Math.min(100, numeric));
}
function applyPracticeSplit(value, persist = false) {
const workspace = document.getElementById('practice-workspace');
const splitter = document.getElementById('practice-splitter');
if (!workspace) return;
const normalized = normalizedPracticeSplit(value);
const splitterSize = splitter?.getBoundingClientRect().height || PRACTICE_SPLITTER_SIZE;
const available = Math.max(0, workspace.getBoundingClientRect().height - splitterSize);
const sheetPx = Math.round((available * normalized) / 100);
workspace.style.gridTemplateRows = `${sheetPx}px ${splitterSize}px minmax(0, 1fr)`;
if (splitter) splitter.setAttribute('aria-valuenow', String(Math.round(normalized)));
if (persist) localStorage.setItem(PRACTICE_SPLIT_KEY, String(normalized));
queuePracticeLayoutRender();
}
function setPracticeSplit(value) {
applyPracticeSplit(value, true);
}
function normalizedPixels(value, fallback, min, max) {
const numeric = Number.parseFloat(value);
const safe = Number.isFinite(numeric) ? numeric : fallback;
return Math.max(min, Math.min(max, safe));
}
function queueLayoutRender() {
requestAnimationFrame(() => {
applyPracticeSplit(localStorage.getItem(PRACTICE_SPLIT_KEY));
applySheetView();
renderNoteHighway();
});
}
function applyShellSplit(value, persist = false) {
const shell = document.querySelector('#play-screen .play-shell');
const splitter = document.getElementById('shell-splitter');
if (!shell) return;
const expandedWidth = normalizedPixels(value, 260, 180, 420);
const isCollapsed = shell.dataset.sidebar === 'collapsed';
const width = isCollapsed ? 58 : expandedWidth;
shell.style.gridTemplateColumns = `${width}px ${LAYOUT_SPLITTER_SIZE}px minmax(0, 1fr)`;
if (splitter) splitter.setAttribute('aria-valuenow', String(Math.round(expandedWidth)));
if (persist && !isCollapsed) localStorage.setItem(SHELL_SPLIT_KEY, String(expandedWidth));
queueLayoutRender();
}
function applyPanelSplit(value, persist = false) {
const area = document.querySelector('.practice-area');
const splitter = document.getElementById('panel-splitter');
if (!area) return;
const rect = area.getBoundingClientRect();
const maxByContainer = rect.width
? Math.max(260, rect.width - LAYOUT_SPLITTER_SIZE - 420)
: 520;
const max = Math.min(520, maxByContainer);
const width = normalizedPixels(value, 320, 260, max);
area.style.gridTemplateColumns = `minmax(0, 1fr) ${LAYOUT_SPLITTER_SIZE}px ${width}px`;
if (splitter) splitter.setAttribute('aria-valuenow', String(Math.round(width)));
if (persist) localStorage.setItem(PANEL_SPLIT_KEY, String(width));
queueLayoutRender();
}
function initLayoutSplitter({
id,
currentValue,
applyValue,
valueFromPointer,
step = 12,
min,
max,
onStart,
}) {
const splitter = document.getElementById(id);
if (!splitter || splitter.dataset.bound === '1') return;
splitter.dataset.bound = '1';
let dragging = false;
const moveTo = (clientX) => applyValue(valueFromPointer(clientX), true);
splitter.addEventListener('pointerdown', (event) => {
onStart?.();
dragging = true;
document.body.classList.add('layout-resizing');
splitter.setPointerCapture?.(event.pointerId);
moveTo(event.clientX);
event.preventDefault();
});
window.addEventListener('pointermove', (event) => {
if (dragging) moveTo(event.clientX);
});
window.addEventListener('pointerup', () => {
if (!dragging) return;
dragging = false;
document.body.classList.remove('layout-resizing');
});
splitter.addEventListener('keydown', (event) => {
let next = null;
if (event.key === 'ArrowLeft') next = currentValue() - step;
if (event.key === 'ArrowRight') next = currentValue() + step;
if (event.key === 'PageUp') next = currentValue() - step * 4;
if (event.key === 'PageDown') next = currentValue() + step * 4;
if (event.key === 'Home') next = min;
if (event.key === 'End') next = max;
if (next === null) return;
onStart?.();
applyValue(next, true);
event.preventDefault();
});
}
function initHorizontalSplitters() {
initLayoutSplitter({
id: 'shell-splitter',
currentValue: () => normalizedPixels(localStorage.getItem(SHELL_SPLIT_KEY), 260, 180, 420),
applyValue: applyShellSplit,
valueFromPointer: (clientX) => {
const shell = document.querySelector('#play-screen .play-shell');
const rect = shell?.getBoundingClientRect();
return rect ? clientX - rect.left - LAYOUT_SPLITTER_SIZE / 2 : 260;
},
min: 180,
max: 420,
onStart: () => {
const shell = document.querySelector('#play-screen .play-shell');
if (shell?.dataset.sidebar === 'collapsed') applyPlaySidebarCollapsed(false);
},
});
initLayoutSplitter({
id: 'panel-splitter',
currentValue: () => normalizedPixels(localStorage.getItem(PANEL_SPLIT_KEY), 320, 260, 520),
applyValue: applyPanelSplit,
valueFromPointer: (clientX) => {
const area = document.querySelector('.practice-area');
const rect = area?.getBoundingClientRect();
return rect ? rect.right - clientX - LAYOUT_SPLITTER_SIZE / 2 : 320;
},
min: 260,
max: 520,
});
applyShellSplit(localStorage.getItem(SHELL_SPLIT_KEY));
applyPanelSplit(localStorage.getItem(PANEL_SPLIT_KEY));
}
function practiceSplitFromClientY(clientY) {
const workspace = document.getElementById('practice-workspace');
const splitter = document.getElementById('practice-splitter');
if (!workspace) return 50;
const rect = workspace.getBoundingClientRect();
const splitterSize = splitter?.getBoundingClientRect().height || PRACTICE_SPLITTER_SIZE;
const available = Math.max(1, rect.height - splitterSize);
return ((clientY - rect.top - splitterSize / 2) / available) * 100;
}
function initPracticeSplitter() {
const splitter = document.getElementById('practice-splitter');
if (!splitter || splitter.dataset.bound === '1') return;
splitter.dataset.bound = '1';
let dragging = false;
const currentValue = () => normalizedPracticeSplit(localStorage.getItem(PRACTICE_SPLIT_KEY));
const moveTo = (clientY) => setPracticeSplit(practiceSplitFromClientY(clientY));
splitter.addEventListener('pointerdown', (event) => {
dragging = true;
document.body.classList.add('practice-resizing');
splitter.setPointerCapture?.(event.pointerId);
moveTo(event.clientY);
event.preventDefault();
});
window.addEventListener('pointermove', (event) => {
if (dragging) moveTo(event.clientY);
});
window.addEventListener('pointerup', () => {
if (!dragging) return;
dragging = false;
document.body.classList.remove('practice-resizing');
});
splitter.addEventListener('keydown', (event) => {
let next = null;
if (event.key === 'ArrowUp') next = currentValue() - 3;
if (event.key === 'ArrowDown') next = currentValue() + 3;
if (event.key === 'PageUp') next = currentValue() - 10;
if (event.key === 'PageDown') next = currentValue() + 10;
if (event.key === 'Home') next = 0;
if (event.key === 'End') next = 100;
if (next === null) return;
setPracticeSplit(next);
event.preventDefault();
});
applyPracticeSplit(currentValue());
}
function initKeyboardLayoutToggle() {
document.getElementById('keyboard-view-full')?.addEventListener('click', () => setKeyboardLayoutMode('full'));
document.getElementById('keyboard-view-mini')?.addEventListener('click', () => setKeyboardLayoutMode('mini'));
}
function setLatencyCompensation(value, options = {}) {
const persistGlobal = options.persistGlobal !== false;
const persistScore = options.persistScore !== false;
const previousEffectiveSec = accompanimentLatencyCompSec();
const ms = Math.max(0, Math.min(LATENCY_COMPENSATION_MAX_MS, Number.parseInt(value, 10) || 0));
_accompanimentLatencyCompSec = ms / 1000;
const nextEffectiveSec = accompanimentLatencyCompSec();
const slider = document.getElementById('latency-slider');
const label = document.getElementById('latency-value');
if (slider && slider.value !== String(ms)) slider.value = String(ms);
if (label) {
label.textContent = _inputMode === 'mic'
? `${ms} ms (+${MIC_NOTE_ACCEPT_DELAY_MS} ms detector)`
: `${ms} ms`;
}
if (state.accompanist && state.playing && !state.paused) {
state.accompanist.updateLatencyCompensation(previousEffectiveSec, nextEffectiveSec);
}
if (persistGlobal) localStorage.setItem('accompy_latency_comp_ms', String(ms));
if (persistScore) saveCurrentLatencyPreference(ms);
}
function initLatencyControls() {
const slider = document.getElementById('latency-slider');
if (slider && slider.dataset.bound !== '1') {
const sync = () => setLatencyCompensation(slider.value);
slider.addEventListener('input', sync);
slider.addEventListener('change', sync);
slider.dataset.bound = '1';
}
const outSlider = document.getElementById('output-delay-slider');
if (outSlider && outSlider.dataset.bound !== '1') {
const sync = () => setOutputDelay(outSlider.value);
outSlider.addEventListener('input', sync);
outSlider.addEventListener('change', sync);
outSlider.dataset.bound = '1';
}
setOutputDelay(_outputDelayMs);
}
function setOutputDelay(value) {
const previousEffectiveSec = accompanimentLatencyCompSec();
const ms = Math.max(0, Math.min(LATENCY_COMPENSATION_MAX_MS, Number.parseInt(value, 10) || 0));
_outputDelayMs = ms;
const slider = document.getElementById('output-delay-slider');
const label = document.getElementById('output-delay-value');
if (slider && slider.value !== String(ms)) slider.value = String(ms);
if (label) label.textContent = `${ms} ms`;
if (state.accompanist && state.playing && !state.paused) {
state.accompanist.updateLatencyCompensation(previousEffectiveSec, accompanimentLatencyCompSec());
}
}
// Default the output-delay slider when the accompaniment output device changes:
// 200 ms for AirPods (Bluetooth lag), 0 for anything else. The user can still
// drag it afterwards.
function applyDefaultOutputDelayForDevice() {
const isAirpods = /airpods/i.test(_outputSinkLabels.accompaniment || '');
setOutputDelay(isAirpods ? AIRPODS_OUTPUT_LATENCY_MS : 0);
}
function setAdaptiveTempo(enabled, persist = true) {
state.adaptiveTempo = !!enabled;
const toggle = document.getElementById('adaptive-tempo-toggle');
if (toggle) {
toggle.classList.toggle('active', state.adaptiveTempo);
toggle.setAttribute('aria-pressed', state.adaptiveTempo ? 'true' : 'false');
toggle.title = state.adaptiveTempo
? 'Adaptive tempo is on.'
: 'Adaptive tempo is off. Long solo rests return to starting tempo.';
}
if (persist) localStorage.setItem(ADAPTIVE_TEMPO_KEY, state.adaptiveTempo ? '1' : '0');
}
function toggleAdaptiveTempo() {
setAdaptiveTempo(!state.adaptiveTempo);
}
function initAdaptiveTempoControl() {
setAdaptiveTempo(localStorage.getItem(ADAPTIVE_TEMPO_KEY) === '1', false);
}
function setFollowMode(mode, persist = true) {
if (persist && state.playing) {
alert('Stop playback before changing the follow mode.');
setFollowMode(state.followMode, false);
return;
}
state.followMode = mode === 'reactive' ? 'reactive' : 'leader';
const toggle = document.getElementById('follow-mode-toggle');
if (toggle) {
const leading = state.followMode === 'leader';
toggle.classList.toggle('active', leading);
toggle.setAttribute('aria-pressed', leading ? 'true' : 'false');
toggle.title = leading
? 'Accompanist leads and waits at solo checkpoints.'
: 'Accompanist waits for your notes before moving.';
}
if (persist) localStorage.setItem(FOLLOW_MODE_KEY, state.followMode);
updatePracticePanelStatus();
}
function toggleFollowMode() {
setFollowMode(state.followMode === 'leader' ? 'reactive' : 'leader');
}
function initFollowModeControl() {
setFollowMode(localStorage.getItem(FOLLOW_MODE_KEY) || 'leader', false);
}
function normalizePlaybackVolume(value, fallback = 1, max = 3) {
const numeric = Number.parseFloat(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.max(0, Math.min(max, numeric));
}
function maxPlaybackVolumeForChannel(channel) {
return channel === 'solo' ? 1.5 : 1.5;
}
function readPlaybackVolumes() {
try {
const parsed = JSON.parse(localStorage.getItem(PLAYBACK_VOLUME_KEY) || '{}');
return {
accompaniment: normalizePlaybackVolume(parsed.accompaniment, 0.5, maxPlaybackVolumeForChannel('accompaniment')),
solo: normalizePlaybackVolume(parsed.solo, 1, maxPlaybackVolumeForChannel('solo')),
};
} catch {
return { accompaniment: 0.5, solo: 1 };
}
}
function writePlaybackVolumes() {
localStorage.setItem(PLAYBACK_VOLUME_KEY, JSON.stringify(state.playbackVolumes));
}
function updatePlaybackVolumeUI() {
['accompaniment', 'solo'].forEach((channel) => {
const volume = normalizePlaybackVolume(state.playbackVolumes?.[channel], 1, maxPlaybackVolumeForChannel(channel));
const percent = Math.round(volume * 100);
const slider = document.getElementById(`${channel}-volume-slider`);
const label = document.getElementById(`${channel}-volume-value`);
if (slider && slider.value !== String(percent)) slider.value = String(percent);
if (label) label.textContent = `${percent}%`;
});
}
function setPlaybackVolume(channel, value) {
if (!['accompaniment', 'solo'].includes(channel)) return;
const volume = normalizePlaybackVolume((Number.parseFloat(value) || 0) / 100, 1, maxPlaybackVolumeForChannel(channel));
state.playbackVolumes = {
...(state.playbackVolumes || {}),
[channel]: volume,
};
updatePlaybackVolumeUI();
writePlaybackVolumes();
}
function initPlaybackVolumeControls() {
state.playbackVolumes = readPlaybackVolumes();
updatePlaybackVolumeUI();
['accompaniment', 'solo'].forEach((channel) => {
const slider = document.getElementById(`${channel}-volume-slider`);
if (!slider || slider.dataset.bound === '1') return;
slider.addEventListener('input', () => setPlaybackVolume(channel, slider.value));
slider.addEventListener('change', () => setPlaybackVolume(channel, slider.value));
slider.dataset.bound = '1';
});
}
function readOutputRouting() {
try {
const parsed = JSON.parse(localStorage.getItem(OUTPUT_ROUTING_KEY) || '{}');
return {
solo: typeof parsed.solo === 'string' ? parsed.solo : '',
accompaniment: typeof parsed.accompaniment === 'string' ? parsed.accompaniment : '',
};
} catch {
return { solo: '', accompaniment: '' };
}
}
function writeOutputRouting() {
localStorage.setItem(OUTPUT_ROUTING_KEY, JSON.stringify(_outputSinkIds));
}
function setOutputRoutingStatus(message = '', tone = 'muted') {
const el = document.getElementById('output-routing-status');
if (!el) return;
el.textContent = message;
el.style.color = tone === 'error'
? '#e05c5c'
: tone === 'success'
? 'var(--success)'
: 'var(--muted)';
}
async function configureOutputContext(role) {
if (role !== 'solo' && role !== 'accompaniment') return;
const sinkId = _outputSinkIds[role] || '';
if (!sinkId) return;
const ctx = _outputAudioCtxs[role] || new AudioContext({ latencyHint: 'interactive' });
_outputAudioCtxs[role] = ctx;
if (ctx.state === 'suspended') {
try { await ctx.resume(); } catch {}
}
if (!outputContextSupportsSink(ctx)) {
throw new Error('This browser does not support separate audio outputs for Web Audio.');
}
await ctx.setSinkId(sinkId);
}
function outputOptionLabel(device, fallback) {
return device.label || fallback || `Output ${device.deviceId.slice(0, 6)}`;
}
async function populateOutputDeviceSelectors() {
if (!navigator.mediaDevices?.enumerateDevices) return;
let devices = [];
try {
devices = await navigator.mediaDevices.enumerateDevices();
} catch (error) {
setOutputRoutingStatus('Could not list audio outputs.', 'error');
return;
}
const outputs = devices.filter((device) => device.kind === 'audiooutput');
['solo', 'accompaniment'].forEach((role) => {
const select = document.getElementById(`${role}-output-select`);
if (!select) return;
const selected = _outputSinkIds[role] || '';
const options = ['<option value="">System default</option>']
.concat(outputs.map((device, index) => {
const label = outputOptionLabel(device, `Output ${index + 1}`);
const isSelected = device.deviceId === selected ? ' selected' : '';
if (device.deviceId === selected) _outputSinkLabels[role] = label;
return `<option value="${escapeHtml(device.deviceId)}"${isSelected}>${escapeHtml(label)}</option>`;
}));
select.innerHTML = options.join('');
if (selected && !outputs.some((device) => device.deviceId === selected)) {
select.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(selected)}" selected>Saved output</option>`);
}
});
}
async function resumeOutputContexts() {
const contexts = [audioCtx(), ...Object.values(_outputAudioCtxs).filter(Boolean)];
await Promise.all(contexts.map((ctx) =>
ctx.state === 'suspended' ? ctx.resume().catch(() => {}) : Promise.resolve()
));
}
async function onOutputDeviceChange(role, sinkId) {
if (role !== 'solo' && role !== 'accompaniment') return;
_outputSinkIds[role] = sinkId || '';
const select = document.getElementById(`${role}-output-select`);
_outputSinkLabels[role] = select?.selectedOptions?.[0]?.textContent || 'System default';
if (role === 'accompaniment') applyDefaultOutputDelayForDevice();
writeOutputRouting();
try {
if (_outputSinkIds[role]) await configureOutputContext(role);
setOutputRoutingStatus(
_outputSinkIds.solo || _outputSinkIds.accompaniment
? 'Separate output routing is active. Routed sample instruments use the built-in synth.'
: '',
'success',
);
} catch (error) {
setOutputRoutingStatus(error.message || 'Could not set audio output.', 'error');
}
}
async function initOutputRoutingControls() {
const saved = readOutputRouting();
_outputSinkIds.solo = saved.solo;
_outputSinkIds.accompaniment = saved.accompaniment;
await populateOutputDeviceSelectors();
applyDefaultOutputDelayForDevice();
await Promise.all(['solo', 'accompaniment']
.filter((role) => _outputSinkIds[role])
.map((role) => configureOutputContext(role).catch((error) => {
console.warn(`Output routing failed for ${role}:`, error);
setOutputRoutingStatus(error.message || 'Could not restore audio output.', 'error');
})));
if (_outputSinkIds.solo || _outputSinkIds.accompaniment) {
setOutputRoutingStatus('Separate output routing is active. Routed sample instruments use the built-in synth.', 'success');
}
}
function applyTheme(theme) {
const normalized = theme === 'light' ? 'light' : 'dark';
document.documentElement.classList.toggle('light', normalized === 'light');
document.documentElement.style.colorScheme = normalized;
document.body.classList.toggle('light', normalized === 'light');
document.querySelectorAll('[data-theme-toggle]').forEach((toggle) => {
const nextTheme = normalized === 'light' ? 'dark' : 'light';
toggle.dataset.icon = normalized === 'light' ? 'moon' : 'sun';
toggle.setAttribute('aria-label', `Switch to ${nextTheme} mode`);
toggle.title = `Switch to ${nextTheme} mode`;
});
localStorage.setItem('accompy_theme', normalized);
document.querySelectorAll('#sheet-frame, .score-preview-frame').forEach((frame) => sanitizeSheetFrame(frame));
applyMusicXmlFallbackTheme();
}
function toggleTheme() {
applyTheme(document.body.classList.contains('light') ? 'dark' : 'light');
}
function resizeScorePreviews() {
document.querySelectorAll('.score-preview-frame').forEach((frame) => {
const preview = frame.parentElement;
if (!preview) return;
fitScorePreviewFrame(frame);
});
}
function isScorePreviewFrame(frame) {
return !!frame?.classList?.contains('score-preview-frame');
}
function fitScorePreviewFrame(frame) {
const preview = frame?.parentElement;
const doc = frame?.contentDocument;
if (!preview || !doc) return;
const page = doc.querySelector('.page') || doc.querySelector('svg');
if (!page) return;
const rect = page.getBoundingClientRect();
const pageWidth = Math.max(1, Math.ceil(rect.width || page.scrollWidth || 960));
const pageHeight = Math.max(1, Math.ceil(rect.height || page.scrollHeight || 1240));
const previewWidth = preview.clientWidth;
if (!previewWidth) return;
frame.style.width = `${pageWidth}px`;
frame.style.height = `${pageHeight}px`;
preview.style.aspectRatio = `${pageWidth} / ${pageHeight}`;
preview.style.setProperty('--preview-scale', String(previewWidth / pageWidth));
}
function sanitizeSheetFrame(frame) {
const doc = frame?.contentDocument;
if (!doc) return;
const darkMode = !document.body.classList.contains('light');
const themeName = darkMode ? 'dark' : 'light';
doc.querySelectorAll('h1').forEach((el) => el.remove());
let style = doc.getElementById('accompy-sheet-cleanup-style');
if (!style) {
style = doc.createElement('style');
style.id = 'accompy-sheet-cleanup-style';
doc.head?.appendChild(style);
}
style.textContent = `
:root {
color-scheme: ${themeName};
--notepilot-sheet-outer: ${darkMode ? '#0f0f13' : '#f4f1ea'};
--notepilot-sheet-page: ${darkMode ? '#181824' : '#ffffff'};
--notepilot-sheet-ink: ${darkMode ? '#f2efe8' : '#111318'};
}
h1 { display: none !important; }
body {
padding-top: 0 !important;
margin-top: 0 !important;
background: var(--notepilot-sheet-outer) !important;
color: var(--notepilot-sheet-ink) !important;
}
.page {
background: var(--notepilot-sheet-page) !important;
box-shadow: ${darkMode ? '0 6px 18px rgba(0,0,0,.55)' : '0 2px 6px rgba(0,0,0,.18)'} !important;
max-width: none !important;
margin: 0 auto 1rem !important;
box-sizing: border-box !important;
}
svg {
color: var(--notepilot-sheet-ink) !important;
}
svg :is(path, ellipse, polygon, polyline, line, text, tspan, use):not(.accompy-measure-highlight) {
${darkMode ? 'fill: #f2efe8 !important; stroke: #f2efe8 !important;' : ''}
}
svg use {
${darkMode ? 'color: #f2efe8 !important; fill: #f2efe8 !important; stroke: #f2efe8 !important;' : ''}
}
svg rect:not(.accompy-measure-highlight) {
${darkMode ? 'fill: #f2efe8 !important; stroke: #f2efe8 !important;' : ''}
}
svg [fill="none"] {
fill: none !important;
}
svg [stroke="none"] {
stroke: none !important;
}
svg rect[fill="white"],
svg rect[fill="#ffffff"],
svg rect[fill="#FFF"],
svg rect[fill="#fff"] {
fill: var(--notepilot-sheet-page) !important;
stroke: var(--notepilot-sheet-page) !important;
}
`;
doc.documentElement.dataset.notepilotTheme = themeName;
doc.documentElement.style.colorScheme = themeName;
if (darkMode) {
doc.body?.classList.add('accompy-dark-sheet');
doc.body?.classList.remove('accompy-light-sheet');
} else {
doc.body?.classList.remove('accompy-dark-sheet');
doc.body?.classList.add('accompy-light-sheet');
}
if (isScorePreviewFrame(frame)) {
applyScorePreviewFrameCleanup(frame);
fitScorePreviewFrame(frame);
requestAnimationFrame(() => fitScorePreviewFrame(frame));
return;
}
applySheetFrameZoom(frame, state.sheetView?.zoom || 1);
}
function applyScorePreviewFrameCleanup(frame) {
const doc = frame?.contentDocument;
if (!doc) return;
let style = doc.getElementById('accompy-score-preview-style');
if (!style) {
style = doc.createElement('style');
style.id = 'accompy-score-preview-style';
doc.head?.appendChild(style);
}
style.textContent = `
html,
body {
width: max-content !important;
min-width: 0 !important;
overflow: hidden !important;
}
body {
display: inline-block !important;
padding: 0 !important;
margin: 0 !important;
}
.page {
width: auto !important;
max-width: none !important;
margin: 0 !important;
box-shadow: none !important;
}
.page:not(:first-of-type) {
display: none !important;
}
`;
}
function applyMusicXmlFallbackTheme() {
const host = document.getElementById('sheet-musicxml-fallback');
if (!host) return;
host.dataset.theme = document.body.classList.contains('light') ? 'light' : 'dark';
}
function applySheetFrameZoom(frame, zoom = 1) {
const doc = frame?.contentDocument;
if (!doc) return;
const normalized = Math.max(0.4, Math.min(2.5, zoom || 1));
// Drive zoom via the .page width β€” at 100% it fills the iframe (fit-to-width),
// and +/- scales it past the iframe to show a horizontal scroll, or smaller.
let style = doc.getElementById('accompy-sheet-zoom-style');
if (!style) {
style = doc.createElement('style');
style.id = 'accompy-sheet-zoom-style';
doc.head?.appendChild(style);
}
style.textContent = `.page { width: ${normalized * 100}% !important; }`;
// Clear any previous CSS zoom we may have set in earlier versions.
doc.documentElement.style.zoom = '';
}
// ── Score list screen ─────────────────────────────────────────────────────────
function applyScoreItemsToState(scoreItems = []) {
const normalized = normalizeScoreListItems(scoreItems);
state.scoreMeta = {
...state.scoreMeta,
...Object.fromEntries(normalized.map((item) => [
item.name,
{ title: item.title || formatName(item.name), created_at: item.created_at || null },
])),
};
state.scores = normalized.map((item) => item.name);
return normalized;
}
function renderScoreGridItems(scoreItems = [], options = {}) {
const grid = document.getElementById('score-grid');
if (!grid) return;
const items = applyScoreItemsToState(scoreItems);
if (!items.length) {
stopScorePreviewLoader();
grid.innerHTML = `
<div class="score-grid-empty">
<h3>Your library is empty</h3>
<p>Click <strong>+ Add piece</strong> at the top right to get started!</p>
</div>`;
renderPlayPieceList();
return;
}
grid.innerHTML = items.map(({ name, has_sheet }) => `
<div class="score-card" id="card-${name}" onclick="openScore('${name}')">
<div class="score-card-actions">
<button class="rename-btn" onclick="renameScore(event, '${name}')" title="Rename piece">✎</button>
<button class="delete-btn" onclick="deleteScore(event, '${name}')" title="Remove from my list">βœ•</button>
</div>
${has_sheet === false
? `<div class="score-preview empty" aria-hidden="true"><div class="score-preview-empty">No sheet preview</div></div>`
: `<div class="score-preview score-preview-lite" data-score-preview data-score-name="${escapeHtml(name)}" aria-hidden="true">${scorePreviewPlaceholder(name)}</div>`
}
<div class="score-card-meta">
<h3>${escapeHtml(displayNameForScore(name))}</h3>
<small>${name}</small>
</div>
</div>
`).join('');
stopScorePreviewLoader();
requestAnimationFrame(() => {
resizeScorePreviews();
if (options.loadPreviews !== false) scheduleScorePreviewLoader();
});
renderPlayPieceList();
}
function renderScoreGridSkeleton() {
const grid = document.getElementById('score-grid');
if (!grid || grid.children.length) return;
stopScorePreviewLoader();
grid.innerHTML = Array.from({ length: 6 }, (_, index) => `
<div class="score-card score-card-skeleton" aria-hidden="true">
<div class="score-preview score-preview-lite">
<div class="score-preview-lines"></div>
<span>${index + 1}</span>
</div>
<div class="score-card-meta">
<h3></h3>
<small></small>
</div>
</div>
`).join('');
}
async function loadScoreList() {
if (state.guestMode) {
renderGuestDashboard();
return;
}
if (_appConfig.auth_enabled && !_authUser) {
stopScorePreviewLoader();
updateAuthUI();
return;
}
applyScoreGridColumns(state.scoreGridColumns);
let renderedCachedList = false;
const cachedItems = _appConfig.auth_enabled ? readCachedScoreList() : normalizeScoreListItems(loadPersonalScoreLibrary().map((name) => ({ name })));
if (cachedItems.length) {
renderScoreGridItems(sortScoreItemsByRecent(cachedItems), { loadPreviews: false });
renderedCachedList = true;
} else {
renderScoreGridSkeleton();
}
let response;
try {
response = await api('/api/scores');
} catch (error) {
if (renderedCachedList) {
console.warn('Could not refresh score list; showing cached library.', error);
return;
}
throw error;
}
const { scores = [], items = [] } = response;
state.serverScores = scores;
const normalizedItems = normalizeScoreListItems(items, scores);
const itemByName = new Map(normalizedItems.map((item) => [item.name, item]));
let scoreItems;
if (_appConfig.auth_enabled) {
scoreItems = sortScoreItemsByRecent(normalizedItems);
state.scores = scoreItems.map((item) => item.name);
} else {
const existing = new Set(scores);
let library = loadPersonalScoreLibrary();
if (!localStorage.getItem(SCORE_LIBRARY_INIT_KEY)) library = scores;
library = savePersonalScoreLibrary(library.filter((name) => existing.has(name)));
scoreItems = sortScoreItemsByRecent(library
.map((name) => itemByName.get(name))
.filter(Boolean));
state.scores = scoreItems.map((item) => item.name);
}
writeCachedScoreList(scoreItems);
renderScoreGridItems(scoreItems);
}
function renderGuestDashboard() {
stopScorePreviewLoader();
updateAuthUI();
applyScoreGridColumns(state.scoreGridColumns);
const grid = document.getElementById('score-grid');
if (!grid) return;
const progress = loadGuestProgress();
state.scores = GUEST_DEMO_SCORES.map((score) => score.name);
grid.innerHTML = GUEST_DEMO_SCORES.map((score) => {
const completed = !!progress[score.name]?.completed;
const primary = score.primary ? ' primary' : '';
return `
<div class="score-card guest-demo-card${primary}" id="card-${score.name}" onclick="openScore('${score.name}')">
<div class="guest-demo-preview" aria-hidden="true">
<span>${score.primary ? 'Guided demo' : 'Sandbox'}</span>
<strong>${score.title.split(' ')[0]}</strong>
</div>
<div class="score-card-meta">
<div class="guest-demo-kicker">${completed ? 'Completed demo' : (score.primary ? 'Start here' : 'Included demo')}</div>
<h3>${escapeHtml(score.title)}</h3>
<small>${escapeHtml(score.subtitle)}</small>
</div>
<button class="btn ${score.primary ? 'btn-primary' : 'btn-ghost'} guest-card-action" type="button" onclick="event.stopPropagation(); openScore('${score.name}')">
${completed ? 'Replay demo' : score.cta}
</button>
</div>
`;
}).join('');
requestAnimationFrame(() => resizeScorePreviews());
renderPlayPieceList();
}
function renderPlayPieceList() {
const root = document.getElementById('play-piece-list');
if (!root) return;
const currentName = state.current?.name || null;
const names = Array.isArray(state.scores) ? state.scores : [];
if (!names.length) {
root.innerHTML = '<div class="score-preview-empty">No pieces in your library yet.</div>';
return;
}
root.innerHTML = names.map((name) => {
const active = name === currentName;
return `
<button
class="play-piece-item${active ? ' active' : ''}"
${active ? 'disabled' : ''}
onclick="openScore('${name}')">
<span class="play-piece-title">${escapeHtml(displayNameForScore(name))}</span>
<span class="play-piece-slug">${name}</span>
</button>
`;
}).join('');
}
function initPlaySidebar() {
applyPlaySidebarCollapsed(localStorage.getItem(PLAY_SIDEBAR_COLLAPSED_KEY) === '1');
}
function formatName(name) {
return name.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase());
}
let _musicXmlDisplay = null;
async function renderMusicXmlFallback(xmlText) {
const host = document.getElementById('sheet-musicxml-fallback');
if (!host || !window.opensheetmusicdisplay?.OpenSheetMusicDisplay) return false;
host.innerHTML = '';
host.style.display = 'block';
try {
_musicXmlDisplay = new window.opensheetmusicdisplay.OpenSheetMusicDisplay(host, {
autoResize: true,
drawTitle: false,
drawPartNames: true,
backend: 'svg',
});
await _musicXmlDisplay.load(xmlText);
_musicXmlDisplay.render();
applyMusicXmlFallbackTheme();
return true;
} catch (error) {
console.warn('MusicXML fallback render failed:', error);
host.innerHTML = '';
host.style.display = 'none';
return false;
}
}
// ── Sheet viewer toolbar ─────────────────────────────────────────────────────
function applySheetView() {
const wrap = document.getElementById('sheet-content-wrap');
const frame = document.getElementById('sheet-frame');
const fallback = document.getElementById('sheet-musicxml-fallback');
const label = document.getElementById('sheet-zoom-label');
if (!wrap) return;
const { zoom, rotation } = state.sheetView;
const frameVisible = frame && frame.style.display !== 'none';
const fallbackVisible = fallback && fallback.style.display !== 'none';
// OSMD uses native zoom so the SVG re-renders at the requested size
// instead of being bitmap-scaled. This also avoids resize-feedback loops.
if (fallbackVisible && _musicXmlDisplay) {
try {
_musicXmlDisplay.Zoom = zoom;
_musicXmlDisplay.render();
} catch (e) { console.warn('OSMD zoom failed', e); }
}
if (frameVisible) {
applySheetFrameZoom(frame, zoom);
}
wrap.style.transformOrigin = 'top left';
const parts = [];
if (rotation) parts.push(`rotate(${rotation}deg)`);
wrap.style.transform = parts.join(' ');
if (label) label.textContent = `${Math.round(zoom * 100)}%`;
}
function sheetZoom(delta) {
const next = Math.max(0.4, Math.min(2.5, (state.sheetView.zoom || 1) + delta));
state.sheetView.zoom = Math.round(next * 100) / 100;
applySheetView();
}
function sheetRotate() {
state.sheetView.rotation = (state.sheetView.rotation + 90) % 360;
applySheetView();
}
function sheetResetView() {
state.sheetView = { zoom: 1.0, rotation: 0 };
applySheetView();
}
function resetSheetView() {
state.sheetView = { zoom: 1.0, rotation: 0 };
applySheetView();
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function clearFingeringJobPolling() {
if (_fingeringJobPollTimer) {
clearTimeout(_fingeringJobPollTimer);
_fingeringJobPollTimer = null;
}
}
function setFingeringJob(job) {
state.fingeringJob = job || null;
updateSheetFingeringStatus();
updateFingeringProgressUI();
}
function currentSheetVariant() {
return state.current?.fingering?.applied && state.sheetVariant === 'fingered'
? 'fingered'
: 'base';
}
function currentSheetAssets() {
const data = state.current || {};
if (currentSheetVariant() === 'fingered') {
return {
variant: 'fingered',
hasSheet: !!(data.has_fingered_sheet || data.fingered_musicxml_source),
musicXml: data.fingered_musicxml_source || null,
};
}
return {
variant: 'base',
hasSheet: !!(data.has_sheet || data.musicxml_source),
musicXml: data.musicxml_source || null,
};
}
function selectedSheetPartIndices() {
if (state.sheetDisplayMode !== 'selected') return [];
const parts = state.current?.parts || [];
if (!parts.length) return [];
return selectedPartIndices();
}
function selectedSheetPartsParam() {
return selectedSheetPartIndices().join(',');
}
function sheetHtmlCacheKey(name, variant, partsParam = '') {
return [name || '', variant || 'base', partsParam || 'full'].join('::');
}
function sheetMusicXmlCacheKey(name, variant) {
return [name || '', variant || 'base'].join('::');
}
function clearSheetHtmlCache(scoreName = null) {
const clearEntry = (key) => {
const entry = _sheetHtmlCache.get(key);
if (entry?.url) URL.revokeObjectURL(entry.url);
_sheetHtmlCache.delete(key);
};
if (!scoreName) {
[..._sheetHtmlCache.keys()].forEach(clearEntry);
_sheetMusicXmlCache.clear();
return;
}
const prefix = `${scoreName}::`;
[..._sheetHtmlCache.keys()].forEach((key) => {
if (key.startsWith(prefix)) clearEntry(key);
});
[..._sheetMusicXmlCache.keys()].forEach((key) => {
if (key.startsWith(prefix)) _sheetMusicXmlCache.delete(key);
});
}
async function fetchSheetHtmlEntry(name, variant, partsParam = '', options = {}) {
const cacheKey = sheetHtmlCacheKey(name, variant, partsParam);
if (!options.force && _sheetHtmlCache.has(cacheKey)) return _sheetHtmlCache.get(cacheKey);
const query = new URLSearchParams({ variant: variant || 'base' });
if (partsParam) query.set('parts', partsParam);
const resp = await fetch(`/api/scores/${encodeURIComponent(name)}/sheet?${query.toString()}`, { cache: 'no-store' });
if (!resp.ok) throw new Error(await resp.text());
const html = await resp.text();
if (!html.includes('<svg')) throw new Error('Sheet rendering returned no SVG.');
const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
const entry = { url };
_sheetHtmlCache.set(cacheKey, entry);
return entry;
}
async function fetchSheetMusicXml(name, variant, options = {}) {
const normalizedVariant = variant || 'base';
const cacheKey = sheetMusicXmlCacheKey(name, normalizedVariant);
if (!options.force && _sheetMusicXmlCache.has(cacheKey)) return _sheetMusicXmlCache.get(cacheKey);
const query = new URLSearchParams({ variant: normalizedVariant });
const resp = await fetch(`/api/scores/${encodeURIComponent(name)}/musicxml?${query.toString()}`, { cache: 'no-store' });
if (!resp.ok) throw new Error(await resp.text());
const xml = await resp.text();
if (!xml.trim()) throw new Error('MusicXML source was empty.');
_sheetMusicXmlCache.set(cacheKey, xml);
return xml;
}
async function loadScoreRender(scoreId, variant = 'base') {
const query = new URLSearchParams({ variant: variant || 'base' });
const res = await fetch(
`/api/scores/${encodeURIComponent(scoreId)}/artifact-url?${query.toString()}`,
{ cache: 'no-store' }
);
if (!res.ok) {
throw new Error(readApiErrorText(await res.text()));
}
const payload = await res.json();
if (!payload?.url) throw new Error('Score render URL was empty.');
return payload.url;
}
function scorePreviewPlaceholder(name) {
return `
<div class="score-preview-lines"></div>
<span>${escapeHtml(displayNameForScore(name).slice(0, 1) || 'N')}</span>
`;
}
function stopScorePreviewLoader() {
if (_scorePreviewTimer) {
window.clearTimeout(_scorePreviewTimer);
_scorePreviewTimer = null;
}
if (_scorePreviewRun?.observer) _scorePreviewRun.observer.disconnect();
_scorePreviewRun = null;
}
function scheduleScorePreviewLoader() {
if (_scorePreviewTimer) window.clearTimeout(_scorePreviewTimer);
_scorePreviewTimer = window.setTimeout(() => {
_scorePreviewTimer = null;
initScorePreviewLoader();
}, SCORE_PREVIEW_START_DELAY_MS);
}
function queueScorePreview(run, preview) {
if (run !== _scorePreviewRun || !preview || preview.dataset.previewState) return;
preview.dataset.previewState = 'queued';
run.queue.push(preview);
pumpScorePreviewQueue(run);
}
function pumpScorePreviewQueue(run) {
if (run !== _scorePreviewRun) return;
while (run.active < SCORE_PREVIEW_CONCURRENCY && run.queue.length) {
const preview = run.queue.shift();
if (!preview?.isConnected) continue;
run.active += 1;
loadScorePreview(preview, run).finally(() => {
if (run !== _scorePreviewRun) return;
run.active = Math.max(0, run.active - 1);
pumpScorePreviewQueue(run);
});
}
}
function loadScorePreviewOnDemand(card) {
const preview = card?.querySelector?.('[data-score-preview]');
if (!preview || preview.dataset.previewState) return;
if (!_scorePreviewRun) _scorePreviewRun = { active: 0, queue: [], observer: null };
queueScorePreview(_scorePreviewRun, preview);
}
async function loadScorePreview(preview, run) {
const name = preview.dataset.scoreName || '';
if (!name) return;
preview.dataset.previewState = 'loading';
preview.classList.add('score-preview-loading');
try {
const entry = await fetchSheetHtmlEntry(name, 'base', '');
if (run !== _scorePreviewRun || !preview.isConnected) return;
const frame = document.createElement('iframe');
frame.className = 'score-preview-frame';
frame.loading = 'lazy';
frame.tabIndex = -1;
frame.setAttribute('aria-hidden', 'true');
frame.addEventListener('load', () => sanitizeSheetFrame(frame), { once: true });
frame.src = entry.url;
preview.replaceChildren(frame);
preview.classList.remove('score-preview-lite', 'score-preview-loading', 'empty');
preview.dataset.previewState = 'loaded';
} catch (error) {
console.warn(`Could not load sheet preview for ${name}:`, error);
if (run !== _scorePreviewRun || !preview.isConnected) return;
preview.classList.remove('score-preview-lite', 'score-preview-loading');
preview.classList.add('empty');
preview.innerHTML = '<div class="score-preview-empty">No sheet preview</div>';
preview.dataset.previewState = 'empty';
}
}
function initScorePreviewLoader() {
stopScorePreviewLoader();
const previews = [...document.querySelectorAll('[data-score-preview]')];
if (!previews.length) return;
const run = { active: 0, queue: [], observer: null };
_scorePreviewRun = run;
if (!('IntersectionObserver' in window)) {
previews.forEach((preview) => queueScorePreview(run, preview));
return;
}
run.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
run.observer.unobserve(entry.target);
queueScorePreview(run, entry.target);
});
}, { rootMargin: '100px 0px', threshold: 0.01 });
previews.forEach((preview) => run.observer.observe(preview));
}
function updateSheetDisplayModeUI() {
const selectedMode = state.sheetDisplayMode === 'selected';
const fullBtn = document.getElementById('sheet-display-full');
const selectedBtn = document.getElementById('sheet-display-selected');
if (fullBtn) {
fullBtn.classList.toggle('active', !selectedMode);
fullBtn.setAttribute('aria-pressed', selectedMode ? 'false' : 'true');
fullBtn.title = 'Show the complete score.';
}
if (selectedBtn) {
selectedBtn.classList.toggle('active', selectedMode);
selectedBtn.setAttribute('aria-pressed', selectedMode ? 'true' : 'false');
selectedBtn.title = 'Show only the selected practice parts.';
}
}
async function setSheetDisplayMode(mode) {
const normalized = mode === 'selected' ? 'selected' : 'full';
if (state.sheetDisplayMode === normalized) {
updateSheetDisplayModeUI();
return;
}
state.sheetDisplayMode = normalized;
localStorage.setItem(SHEET_DISPLAY_MODE_KEY, state.sheetDisplayMode);
updateSheetDisplayModeUI();
await renderScoreSheet();
}
async function toggleSheetDisplayMode() {
await setSheetDisplayMode(state.sheetDisplayMode === 'selected' ? 'full' : 'selected');
}
function initSheetDisplayMode() {
const saved = localStorage.getItem(SHEET_DISPLAY_MODE_KEY);
state.sheetDisplayMode = saved === 'selected' ? 'selected' : 'full';
updateSheetDisplayModeUI();
}
function filterMusicXmlParts(xmlText, partIndices) {
if (!xmlText || !partIndices?.length) return xmlText;
try {
const doc = new DOMParser().parseFromString(xmlText, 'application/xml');
if (doc.querySelector('parsererror')) return xmlText;
const partList = [...doc.documentElement.children].find((child) => child.localName === 'part-list');
if (!partList) return xmlText;
const scoreParts = [...partList.children].filter((child) => child.localName === 'score-part');
const selectedIds = new Set(
partIndices
.map((idx) => scoreParts[idx]?.getAttribute('id'))
.filter(Boolean)
);
if (!selectedIds.size) return xmlText;
[...partList.children].forEach((child) => {
if (child.localName === 'part-group') {
partList.removeChild(child);
} else if (child.localName === 'score-part' && !selectedIds.has(child.getAttribute('id'))) {
partList.removeChild(child);
}
});
[...doc.documentElement.children].forEach((child) => {
if (child.localName === 'part' && !selectedIds.has(child.getAttribute('id'))) {
doc.documentElement.removeChild(child);
}
});
return new XMLSerializer().serializeToString(doc);
} catch (error) {
console.warn('Could not filter MusicXML parts:', error);
return xmlText;
}
}
function updateFingeringProgressUI() {
const root = document.getElementById('sheet-fingering-progress');
const fill = document.getElementById('sheet-fingering-progress-fill');
const message = document.getElementById('sheet-fingering-progress-message');
const value = document.getElementById('sheet-fingering-progress-value');
if (!root || !fill || !message || !value) return;
const job = state.fingeringJob;
if (!job || !['queued', 'running'].includes(job.status)) {
root.style.display = 'none';
fill.style.width = '0%';
message.textContent = 'Generating fingering';
value.textContent = '0%';
return;
}
const progress = Math.max(0, Math.min(100, Math.round(Number(job.progress || 0))));
root.style.display = 'block';
fill.style.width = `${progress}%`;
message.textContent = job.message || 'Generating fingering';
value.textContent = `${progress}%`;
}
function updateSheetFingeringStatus() {
const badge = document.getElementById('sheet-fingering-status');
const generateBtn = document.getElementById('sheet-generate-fingering-btn');
const toggleBtn = document.getElementById('sheet-toggle-fingering-btn');
if (!badge || !generateBtn || !toggleBtn) return;
const fingering = state.current?.fingering;
const job = state.fingeringJob;
const hideBadge = () => {
badge.style.display = 'none';
badge.textContent = '';
badge.title = '';
delete badge.dataset.state;
};
if (!fingering) {
generateBtn.style.display = 'none';
toggleBtn.style.display = 'none';
hideBadge();
return;
}
const generating = !!job && ['queued', 'running'].includes(job.status);
if (generating) {
const progress = Math.max(0, Math.min(100, Math.round(Number(job.progress || 0))));
badge.style.display = 'inline-flex';
badge.dataset.state = 'ok';
badge.textContent = 'Generating fingering';
badge.title = job.message || '';
generateBtn.style.display = 'inline-flex';
generateBtn.disabled = true;
generateBtn.textContent = progress > 0 ? `Generating ${progress}%` : 'Generating…';
toggleBtn.style.display = 'none';
return;
}
const applied = !!fingering.applied;
const eligible = !!fingering.eligible;
const available = fingering.available !== false;
const showingFingered = currentSheetVariant() === 'fingered';
if (applied) {
badge.style.display = 'inline-flex';
badge.dataset.state = 'ok';
badge.textContent = showingFingered ? 'Showing fingering' : 'Fingering ready';
badge.title = fingering.annotations ? `${fingering.annotations} annotated notes` : '';
generateBtn.style.display = 'none';
toggleBtn.style.display = 'inline-flex';
toggleBtn.disabled = false;
toggleBtn.textContent = showingFingered ? 'Hide fingering' : 'Show fingering';
return;
}
toggleBtn.style.display = 'none';
if (eligible && available) {
badge.style.display = 'inline-flex';
badge.dataset.state = 'ok';
badge.textContent = 'Fingering available';
badge.title = 'Generate a beginner fingering version for the piano part.';
generateBtn.style.display = 'inline-flex';
generateBtn.disabled = false;
generateBtn.textContent = 'Generate fingering';
return;
}
generateBtn.style.display = 'none';
if (!eligible || !available) {
badge.style.display = 'inline-flex';
badge.dataset.state = 'warning';
badge.textContent = 'Fingering unavailable';
badge.title = !eligible
? 'Automatic fingering is available for piano or keyboard parts.'
: 'PianoPlayer is not installed in the backend environment.';
return;
}
hideBadge();
}
async function pollFingeringJob(scoreName, jobId) {
clearFingeringJobPolling();
const tick = async () => {
if (!state.current || state.current.name !== scoreName) {
clearFingeringJobPolling();
return;
}
try {
const job = await api(`/api/scores/${encodeURIComponent(scoreName)}/fingering/jobs/${encodeURIComponent(jobId)}`);
if (!state.current || state.current.name !== scoreName) return;
if (job.status === 'completed') {
clearFingeringJobPolling();
setFingeringJob(null);
clearSheetHtmlCache(scoreName);
state.sheetVariant = 'fingered';
await openScore(scoreName, {
preserveSelectedPart: true,
preserveSheetVariant: true,
reveal: false,
});
return;
}
if (job.status === 'failed') {
clearFingeringJobPolling();
setFingeringJob(null);
alert(`Failed to generate fingering: ${job.error || job.message || 'Unknown error.'}`);
return;
}
setFingeringJob(job);
_fingeringJobPollTimer = setTimeout(tick, 500);
} catch (error) {
clearFingeringJobPolling();
setFingeringJob(null);
alert(`Failed to check fingering progress: ${readApiErrorMessage(error)}`);
}
};
await tick();
}
async function renderScoreSheet() {
const data = state.current;
if (!data) return;
const renderSeq = ++_sheetRenderSeq;
const scoreName = data.name;
const frame = document.getElementById('sheet-frame');
const placeholder = document.getElementById('sheet-placeholder');
const musicXmlFallback = document.getElementById('sheet-musicxml-fallback');
const assets = currentSheetAssets();
const sheetPartIndices = selectedSheetPartIndices();
const sheetPartsParam = sheetPartIndices.join(',');
musicXmlFallback.style.display = 'none';
musicXmlFallback.innerHTML = '';
frame.style.display = 'none';
frame.onload = null;
frame.removeAttribute('src');
frame.srcdoc = '';
_sheetMeasureEls = [];
_sheetHighlightRect?.remove();
_sheetHighlightRect = null;
_sheetHighlightIndex = -1;
placeholder.style.display = 'none';
placeholder.textContent = '';
state.sheetSource = {
name: data.name,
variant: assets.variant,
hasSheet: assets.hasSheet,
musicXml: assets.musicXml,
parts: sheetPartIndices,
};
updateSheetFingeringStatus();
updateFingeringProgressUI();
updateSheetDisplayModeUI();
const finalizeFrame = () => {
if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return;
sanitizeSheetFrame(frame);
initializeSheetHighlighting();
};
const showSheetHtmlEntry = (entry) => {
if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return false;
if (!entry?.url) return false;
frame.addEventListener('load', finalizeFrame, { once: true });
frame.removeAttribute('srcdoc');
frame.src = entry.url;
frame.style.display = 'block';
placeholder.style.display = 'none';
requestAnimationFrame(finalizeFrame);
return true;
};
if (assets.hasSheet && !data.guest) {
try {
const entry = await fetchSheetHtmlEntry(data.name, assets.variant, sheetPartsParam);
if (showSheetHtmlEntry(entry)) return;
} catch (error) {
console.warn(`Sheet render failed for ${data.name} (${assets.variant}, parts=${sheetPartsParam || 'full'})`, error);
if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return;
}
}
frame.removeAttribute('src');
frame.srcdoc = '';
let sourceXml = assets.musicXml || null;
if (!sourceXml && assets.hasSheet && !data.guest) {
try {
sourceXml = await fetchSheetMusicXml(data.name, assets.variant);
if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return;
if (assets.variant === 'fingered') {
state.current.fingered_musicxml_source = sourceXml;
} else {
state.current.musicxml_source = sourceXml;
}
state.sheetSource.musicXml = sourceXml;
} catch (error) {
console.warn(`MusicXML fallback source failed for ${data.name} (${assets.variant})`, error);
if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return;
}
}
const fallbackXml = sourceXml
? filterMusicXmlParts(sourceXml, sheetPartIndices)
: null;
const renderedFallback = fallbackXml
? await renderMusicXmlFallback(fallbackXml)
: false;
if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return;
if (renderedFallback) {
placeholder.style.display = 'none';
placeholder.textContent = '';
} else {
placeholder.style.display = 'block';
placeholder.textContent = sheetPartsParam
? 'Selected-parts sheet view could not be rendered. Switch to Full or change the selected parts.'
: 'Sheet music could not be rendered for this score.';
}
clearSheetHighlight();
}
async function toggleSheetFingering() {
if (!state.current?.fingering?.applied) return;
state.sheetVariant = currentSheetVariant() === 'fingered' ? 'base' : 'fingered';
await renderScoreSheet();
}
async function generateSheetFingering() {
const current = state.current;
const button = document.getElementById('sheet-generate-fingering-btn');
if (!current || !current.fingering?.eligible || current.fingering?.applied || !button || state.fingeringJob) return;
if (state.guestMode || current.guest) {
showGuestUpgradeToast('Create a free account to generate and save fingering for your own pieces.');
return;
}
if (state.playing) stopPlaying();
try {
const job = await api(`/api/scores/${encodeURIComponent(current.name)}/fingering/generate`, {
method: 'POST',
});
setFingeringJob(job);
await pollFingeringJob(current.name, job.id);
} catch (error) {
alert(`Failed to generate fingering: ${readApiErrorMessage(error)}`);
}
}
async function sheetDownload() {
const src = state.sheetSource;
if (!src || !src.name) {
alert('Open a score first.');
return;
}
if (state.guestMode || state.current?.guest) {
showGuestUpgradeToast('Create a free account to export and save your own work.');
return;
}
if (src.musicXml) {
triggerDownload(
new Blob([src.musicXml], { type: 'application/vnd.recordare.musicxml+xml' }),
`${src.name}.musicxml`
);
return;
}
if (src.hasSheet) {
try {
const query = new URLSearchParams({ variant: src.variant || 'base' });
if (Array.isArray(src.parts) && src.parts.length) query.set('parts', src.parts.join(','));
const resp = await fetch(
`/api/scores/${encodeURIComponent(src.name)}/sheet?${query.toString()}`,
{ cache: 'no-store' }
);
if (!resp.ok) throw new Error(await resp.text());
const html = await resp.text();
triggerDownload(new Blob([html], { type: 'text/html' }), `${src.name}.sheet.html`);
} catch (err) {
alert(`Download failed: ${readApiErrorMessage(err)}`);
}
return;
}
alert('No sheet source available to download.');
}
// ── Play screen ───────────────────────────────────────────────────────────────
async function fetchScore(name) {
const guestScore = guestScoreByName(name);
if (guestScore) return guestScorePayload(guestScore);
return api(`/api/scores/${encodeURIComponent(name)}`);
}
function renderGuestTips(data) {
const card = document.getElementById('guest-tips-card');
const list = document.getElementById('guest-tips-list');
if (!card || !list) return;
const tips = data?.guest ? (data.tips || []) : [];
card.style.display = tips.length ? 'block' : 'none';
list.innerHTML = tips.map((tip) => `<li>${escapeHtml(tip)}</li>`).join('');
renderGuestAnnotations();
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = String(value ?? '');
return div.innerHTML;
}
function guestAnnotationsForCurrent() {
if (!state.current?.guest) return [];
const progress = loadGuestProgress();
return Array.isArray(progress[state.current.name]?.annotations)
? progress[state.current.name].annotations
: [];
}
function renderGuestAnnotations() {
const root = document.getElementById('guest-annotation-list');
if (!root) return;
const annotations = guestAnnotationsForCurrent();
root.innerHTML = annotations.length
? annotations.map((annotation) => `<span>${escapeHtml(annotation)}</span>`).join('')
: '<small>No demo annotations yet.</small>';
}
function addGuestAnnotation(text) {
if (!state.current?.guest) return;
const progress = loadGuestProgress();
const item = progress[state.current.name] || {};
const annotations = Array.isArray(item.annotations) ? item.annotations : [];
annotations.push(text);
progress[state.current.name] = { ...item, annotations: annotations.slice(-5) };
saveGuestProgress(progress);
renderGuestAnnotations();
}
window.addGuestAnnotation = addGuestAnnotation;
function setPieceLoadingUI(loading, name = '') {
const placeholder = document.getElementById('sheet-placeholder');
const frame = document.getElementById('sheet-frame');
const fallback = document.getElementById('sheet-musicxml-fallback');
const keyboardLoading = document.getElementById('keyboard-loading');
const label = name ? `Loading ${displayNameForScore(name)}...` : 'Loading piece...';
if (placeholder) {
placeholder.style.display = loading ? 'block' : placeholder.style.display;
if (loading) placeholder.textContent = label;
}
if (frame && loading) {
frame.style.display = 'none';
frame.removeAttribute('src');
frame.srcdoc = '';
}
if (fallback && loading) {
fallback.style.display = 'none';
fallback.innerHTML = '';
}
if (keyboardLoading) {
keyboardLoading.classList.remove('error');
keyboardLoading.textContent = 'Loading piece...';
keyboardLoading.style.display = loading ? 'grid' : 'none';
}
document.getElementById('progress-fill').style.width = loading ? '0%' : document.getElementById('progress-fill').style.width;
if (loading) {
document.getElementById('next-note-display').textContent = 'β€”';
document.getElementById('beat-val').textContent = 'β€”';
document.getElementById('tempo-val').textContent = 'β€”';
updatePracticePanelStatus();
}
}
function setPieceErrorUI(name, error) {
const placeholder = document.getElementById('sheet-placeholder');
const keyboardLoading = document.getElementById('keyboard-loading');
const message = readApiErrorMessage(error);
if (placeholder) {
placeholder.style.display = 'block';
placeholder.textContent = `Could not load ${displayNameForScore(name)}. ${message}`;
}
if (keyboardLoading) {
keyboardLoading.classList.add('error');
keyboardLoading.textContent = 'Could not load piece';
keyboardLoading.style.display = 'grid';
}
document.getElementById('start-btn').disabled = true;
document.getElementById('stop-btn').disabled = true;
}
async function openScore(name, options = {}) {
const preserveSelectedPart = !!options.preserveSelectedPart;
const preserveSheetVariant = !!options.preserveSheetVariant;
const updateUrl = options.updateUrl !== false;
const reveal = options.reveal !== false;
if (state.playing && state.current?.name !== name) stopPlaying();
clearFingeringJobPolling();
if (state.current?.name !== name) setFingeringJob(null);
if (reveal) showScreen('play-screen');
setPieceLoadingUI(true, name);
let loaded = false;
try {
const previousParts = preserveSelectedPart ? selectedPartIndices() : null;
const previousVariant = preserveSheetVariant ? state.sheetVariant : 'base';
const data = await fetchScore(name);
const prefs = loadScorePreferences(data.name);
state.current = data;
state.scoreMeta[data.name] = {
...(state.scoreMeta[data.name] || {}),
title: data.title || displayNameForScore(data.name),
};
renderGuestTips(data);
state.finishedPlayback = false;
state.practiceRightHand = null;
state.practiceLeftHand = null;
state.practiceStartBeat = 0;
const parts = data.parts || [];
setSelectedPartIndices(parts.length
? (previousParts || defaultSelectedPartsForScore(data, prefs))
: [0]);
if (!applySavedLatencyForCurrentSelection(prefs)) {
setLatencyCompensation(localStorage.getItem('accompy_latency_comp_ms') || '0', { persistScore: false });
}
state.partInstruments = prefs.partInstruments && typeof prefs.partInstruments === 'object'
? { ...prefs.partInstruments }
: {};
state.sheetVariant = (previousVariant === 'fingered' && data.fingering?.applied) ? 'fingered' : 'base';
_stopMic();
if (Number.isFinite(Number(prefs.bpm))) {
const bpmInput = document.getElementById('bpm-input');
if (bpmInput) bpmInput.value = String(Math.max(20, Math.min(300, Number(prefs.bpm))));
}
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('next-note-display').textContent = 'β€”';
document.getElementById('beat-val').textContent = 'β€”';
document.getElementById('tempo-val').textContent = 'β€”';
updatePracticePanelStatus();
resetSheetView();
await renderScoreSheet();
// Part picker
const picker = document.getElementById('part-picker');
const btns = document.getElementById('part-buttons');
if (parts.length > 0) {
btns.innerHTML = parts.map((p, i) => {
const instr = getInstrumentForPart(i);
return `<div class="part-row" id="part-row-${i}">
<button class="part-btn${selectedPartIndices().includes(i) ? ' selected' : ''}"
onclick="selectPart(${i})" id="part-btn-${i}" data-part-index="${i}"
aria-pressed="${selectedPartIndices().includes(i) ? 'true' : 'false'}">
<span class="part-name">${escapeHtml(p.name)}</span>
</button>
<select class="instr-select" onchange="changeInstrument(${i}, this.value)" id="instr-${i}">
${INSTRUMENTS.map(ins =>
`<option value="${ins}"${ins === instr ? ' selected' : ''}>${ins}</option>`
).join('')}
</select>
</div>`;
}).join('');
picker.style.display = 'block';
updatePartSelectionUI();
} else {
picker.style.display = 'none';
}
setInputMode(prefs.inputMode === 'mic' ? 'mic' : 'keyboard', { persist: false });
if (prefs.keyboardLayoutMode) applyKeyboardLayoutMode(prefs.keyboardLayoutMode);
buildKeyboard(getRightHand());
updateNextKey(getRightHand(), 0);
renderNoteHighway();
syncExpectedMicNote();
setTimeout(() => {
if (state.current?.name === data.name) preloadCurrentScoreInstruments();
}, 500);
renderPlayPieceList();
if (updateUrl) updateScoreUrl(name);
applyPracticeSplit(localStorage.getItem(PRACTICE_SPLIT_KEY));
state.paused = false;
state.pausedBeat = 0;
state.pausedBps = 1;
document.getElementById('start-btn').disabled = false;
document.getElementById('start-btn').textContent = 'β–Ά Start';
document.getElementById('start-btn').classList.add('btn-primary');
document.getElementById('stop-btn').disabled = true;
markScoreOpened(name);
loaded = true;
} catch (error) {
console.warn(`Could not open score "${name}":`, error);
setPieceLoadingUI(false);
setPieceErrorUI(name, error);
return;
} finally {
if (loaded) setPieceLoadingUI(false);
}
}
async function openRouteFromLocation(options = {}) {
const route = parseAppRoute();
if (route.mode === 'landing') {
if (state.guestMode || _authUser || !_appConfig.auth_enabled) {
await goToLibrary(!!options.replace);
} else {
if (options.replace) writeUrl('/', true, { route: 'landing' });
setListRoute('landing');
}
return;
}
if (route.mode === 'signin') {
if (state.guestMode || _authUser) {
await goToLibrary(true);
} else {
if (options.replace) writeUrl('/signin', true, { route: 'signin' });
setListRoute('signin');
}
return;
}
if (route.mode === 'home') {
if (_appConfig.auth_enabled && !_authUser && !state.guestMode) {
goToSignIn(true);
return;
}
await goToLibrary(!!options.replace);
return;
}
const name = route.score;
if (_appConfig.auth_enabled && !_authUser && !state.guestMode) {
goToSignIn(true);
return;
}
showScreen('play-screen');
try {
await openScore(name, { updateUrl: false });
if (options.replace) updateScoreUrl(name, true);
} catch (error) {
console.warn(`Could not open score from route "${name}":`, error);
await goToLibrary(true);
}
}
window.addEventListener('popstate', () => {
openRouteFromLocation({ replace: false });
});
async function selectPart(idx) {
const selected = selectedPartIndices();
const next = selected.includes(idx)
? selected.filter((partIdx) => partIdx !== idx)
: [...selected, idx];
setSelectedPartIndices(next.length ? next : selected);
state.practiceRightHand = null;
state.practiceLeftHand = null;
updatePartSelectionUI();
buildKeyboard(getRightHand());
updateNextKey(getRightHand(), 0);
renderNoteHighway();
syncExpectedMicNote();
applySavedLatencyForCurrentSelection();
if (state.sheetDisplayMode === 'selected') await renderScoreSheet();
preloadCurrentScoreInstruments();
saveCurrentScorePreferences({
selectedPart: state.selectedPart ?? 0,
selectedParts: selectedPartIndices(),
});
}
function getInstrumentForPart(idx) {
if (state.partInstruments[idx]) return state.partInstruments[idx];
const part = state.current?.parts?.[idx];
const storedInstrument = part?.instrument || '';
const inferredInstrument = inferInstrumentFromPartName(part?.name);
if (
inferredInstrument &&
(!storedInstrument || ['piano', 'horn', 'cello', 'strings', 'voice'].includes(storedInstrument))
) {
return inferredInstrument;
}
return storedInstrument || 'piano';
}
async function changeInstrument(partIdx, instrument) {
state.partInstruments[partIdx] = instrument;
if (state.current?.parts?.[partIdx]) {
state.current.parts[partIdx].instrument = instrument;
}
if (state.current?.guest) {
preloadCurrentScoreInstruments();
saveCurrentScorePreferences();
return;
}
try {
await api(`/api/scores/${state.current.name}/instrument`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ part_index: partIdx, instrument }),
});
} catch { /* non-critical β€” change is applied in-memory either way */ }
preloadCurrentScoreInstruments();
saveCurrentScorePreferences();
}
function getRightHandRaw() {
const parts = state.current?.parts;
if (parts && parts.length > 0) return mergePracticeEventsFromParts(selectedPartIndices());
return state.current?.right_hand || [];
}
function getRightHand() {
if (state.practiceRightHand) return state.practiceRightHand;
return expandDynamicTremolos(getRightHandRaw(), currentBps());
}
function getLeftHandRaw() {
const parts = state.current?.parts;
if (!parts || parts.length === 0) return state.current?.left_hand || [];
const selected = new Set(selectedPartIndices());
const left = [];
parts.forEach((p, i) => {
if (selected.has(i)) return;
const instrument = getInstrumentForPart(i);
(p.notes || []).forEach((event) => {
const merged = [eventPitches(event), event[1], eventDuration(event)];
const pedalRelease = eventPedalRelease(event);
if (pedalRelease !== null) merged.push(pedalRelease);
const metadata = Array.isArray(event)
? event.slice(4).filter((item) => item !== undefined && item !== null)
: [];
metadata.forEach((item) => appendEventMetadata(merged, item));
appendEventMetadata(merged, { type: 'instrument', instrument });
left.push(merged);
});
});
left.sort((a, b) => a[1] - b[1]);
return left;
}
function getLeftHand() {
if (state.practiceLeftHand) return state.practiceLeftHand;
return expandDynamicTremolos(getLeftHandRaw(), currentBps());
}
function readSheetMeasureMap(doc, measureCount) {
const fallback = Array.from({ length: measureCount }, (_unused, idx) => ({
startMeasure: idx,
endMeasure: idx + 1,
}));
const script = doc?.getElementById('notepilot-measure-map');
if (!script?.textContent) return fallback;
try {
const parsed = JSON.parse(script.textContent);
if (!Array.isArray(parsed)) return fallback;
return fallback.map((entry, idx) => {
const raw = parsed[idx] || {};
const startMeasure = Number(raw.startMeasure);
const endMeasure = Number(raw.endMeasure);
if (!Number.isFinite(startMeasure) || !Number.isFinite(endMeasure) || endMeasure <= startMeasure) {
return entry;
}
return {
startMeasure: Math.max(0, Math.floor(startMeasure)),
endMeasure: Math.max(Math.floor(startMeasure) + 1, Math.floor(endMeasure)),
};
});
} catch (error) {
console.warn('Could not parse sheet measure map:', error);
return fallback;
}
}
function initializeSheetHighlighting() {
const frame = document.getElementById('sheet-frame');
const doc = frame?.contentDocument;
if (!doc) return;
_sheetMeasureEls = [...doc.querySelectorAll('g.measure, g[class~="measure"], g[class*=" measure "]')];
_sheetMeasureMap = readSheetMeasureMap(doc, _sheetMeasureEls.length);
_sheetHighlightRect = null;
_sheetHighlightIndex = -1;
const svg = doc.querySelector('svg.definition-scale');
if (!svg || !_sheetMeasureEls.length) return;
let style = doc.getElementById('accompy-measure-highlight-style');
if (!style) {
style = doc.createElement('style');
style.id = 'accompy-measure-highlight-style';
style.textContent = `
.accompy-measure-highlight {
fill: rgba(98, 255, 140, 0.96);
stroke: rgba(190, 255, 205, 0.95);
stroke-width: 4px;
rx: 10px;
ry: 10px;
filter: drop-shadow(0 0 10px rgba(98, 255, 140, 0.65));
pointer-events: none;
}
.accompy-clickable-measure {
cursor: pointer;
}
.accompy-clickable-measure:hover {
filter: drop-shadow(0 0 7px rgba(37, 99, 255, 0.55));
}
`;
doc.head?.appendChild(style);
}
_sheetMeasureEls.forEach((measureEl, idx) => {
const mapEntry = _sheetMeasureMap[idx] || { startMeasure: idx, endMeasure: idx + 1 };
const label = mapEntry.endMeasure > mapEntry.startMeasure + 1
? `Jump to measures ${mapEntry.startMeasure + 1}-${mapEntry.endMeasure}`
: `Jump to measure ${mapEntry.startMeasure + 1}`;
measureEl.classList.add('accompy-clickable-measure');
measureEl.setAttribute('role', 'button');
measureEl.setAttribute('tabindex', '0');
measureEl.setAttribute('aria-label', label);
if (measureEl.dataset.notepilotJumpBound === '1') return;
const jump = (event) => {
event.preventDefault();
event.stopPropagation();
jumpToSheetMeasure(idx);
};
measureEl.addEventListener('click', jump);
measureEl.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
jump(event);
});
measureEl.dataset.notepilotJumpBound = '1';
});
const rightHand = state.playing ? getRightHand() : [];
const beat = _pendingSheetHighlightBeat
?? (rightHand.length ? currentGuideBeat(rightHand) : 0);
updateSheetHighlight(beat, { remember: false });
}
function clearSheetHighlight() {
_sheetMeasureEls = [];
_sheetMeasureMap = [];
_sheetHighlightRect?.remove();
_sheetHighlightRect = null;
_sheetHighlightIndex = -1;
_pendingSheetHighlightBeat = null;
}
function originalMeasureIndexForBeat(beat) {
const starts = state.current?.measure_beats || [];
if (!starts.length) return -1;
let idx = 0;
for (let i = 0; i < starts.length; i++) {
if (starts[i] <= beat + 0.001) idx = i;
else break;
}
return idx;
}
function visibleMeasureIndexForOriginalIndex(originalIndex) {
if (originalIndex < 0) return -1;
const idx = _sheetMeasureMap.findIndex((entry) => (
originalIndex >= entry.startMeasure && originalIndex < entry.endMeasure
));
return idx >= 0 ? idx : Math.min(originalIndex, _sheetMeasureEls.length - 1);
}
function measureIndexForBeat(beat) {
return visibleMeasureIndexForOriginalIndex(originalMeasureIndexForBeat(beat));
}
function sheetMeasureEntry(index) {
return _sheetMeasureMap[index] || { startMeasure: index, endMeasure: index + 1 };
}
function beatForMeasureIndex(measureIndex) {
const starts = state.current?.measure_beats || [];
if (!starts.length) return 0;
const entry = sheetMeasureEntry(Number(measureIndex) || 0);
const idx = Math.max(0, Math.min(starts.length - 1, entry.startMeasure));
return Number(starts[idx]) || 0;
}
function jumpToSheetMeasure(measureIndex) {
const beat = beatForMeasureIndex(measureIndex);
seekPracticeToBeat(beat);
}
function seekPracticeToBeat(beat) {
const targetBeat = Math.max(0, Number(beat) || 0);
state.practiceStartBeat = targetBeat;
state.finishedPlayback = false;
clearFinishPlaybackTimer();
const rightHand = state.practiceRightHand || getRightHand();
const position = eventIndexAtOrAfterBeat(rightHand, targetBeat);
let bps = Math.max(0.35, state.pausedBps || _noteHighwayBps || state.tracker?.bps?.() || currentBps() || 1);
bps = state.accompanist?.tempoForBeat?.(targetBeat, bps) ?? bps;
if (state.tracker) state.tracker.seekToBeat(targetBeat);
if (state.accompanist) {
state.accompanist.seek(targetBeat, bps);
if (state.paused) state.accompanist.pause();
}
state.pausedBeat = targetBeat;
state.pausedBps = bps;
_noteHighwayStartBeat = targetBeat;
_noteHighwayStartTime = state.playing && !state.paused ? performance.now() / 1000 : null;
_noteHighwayBps = bps;
updateNextKey(rightHand, state.tracker?.position ?? position);
updateSheetHighlight(targetBeat);
updatePracticePanelStatus();
const progress = rightHand.length ? Math.min(1, (state.tracker?.position ?? position) / rightHand.length) : 0;
const progressFill = document.getElementById('progress-fill');
if (progressFill) progressFill.style.width = `${(progress * 100).toFixed(1)}%`;
const beatVal = document.getElementById('beat-val');
if (beatVal) beatVal.textContent = targetBeat.toFixed(1);
const tempoVal = document.getElementById('tempo-val');
if (tempoVal && state.playing) tempoVal.textContent = `${Math.round(bps * 60)} BPM`;
renderNoteHighway();
syncExpectedMicNote();
}
function updateSheetHighlight(beat, options = {}) {
const remember = options.remember !== false;
if (remember && Number.isFinite(Number(beat))) _pendingSheetHighlightBeat = Number(beat);
if (!_sheetMeasureEls.length) return;
const idx = measureIndexForBeat(beat);
if (idx < 0) return;
const measureEl = _sheetMeasureEls[idx];
if (!measureEl) return;
const doc = measureEl.ownerDocument;
const overlayRoot = measureEl.parentNode;
if (!overlayRoot) return;
let bbox;
try {
bbox = measureEl.getBBox();
} catch {
return;
}
if (!bbox || bbox.width <= 0 || bbox.height <= 0) return;
const starts = state.current?.measure_beats || [];
const mapEntry = sheetMeasureEntry(idx);
const startMeasure = Math.max(0, Math.min(starts.length - 1, mapEntry.startMeasure));
const endMeasure = Math.max(startMeasure + 1, Math.min(starts.length, mapEntry.endMeasure));
const measureStart = starts[startMeasure] ?? 0;
const nextStart = starts[endMeasure];
const previousSpan = startMeasure > 0 ? ((starts[startMeasure] ?? 0) - (starts[startMeasure - 1] ?? 0)) : 4;
const fallbackSpan = (previousSpan || 4) * Math.max(1, endMeasure - startMeasure);
const measureSpan = Math.max(0.25, (nextStart ?? (measureStart + fallbackSpan)) - measureStart);
const progress = Math.max(0, Math.min(1, (beat - measureStart) / measureSpan));
const barWidth = 14;
const minX = bbox.x - 7;
const maxX = bbox.x + bbox.width - 7;
const x = minX + (maxX - minX) * progress;
const y = bbox.y - 18;
const height = bbox.height + 36;
if (!_sheetHighlightRect || idx !== _sheetHighlightIndex) {
_sheetHighlightRect?.remove();
const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('class', 'accompy-measure-highlight');
rect.setAttribute('width', String(barWidth));
rect.setAttribute('height', String(height));
overlayRoot.appendChild(rect);
_sheetHighlightRect = rect;
_sheetHighlightIndex = idx;
measureEl.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
_sheetHighlightRect.setAttribute('x', String(x));
_sheetHighlightRect.setAttribute('y', String(y));
_sheetHighlightRect.setAttribute('width', String(barWidth));
_sheetHighlightRect.setAttribute('height', String(height));
_pendingSheetHighlightBeat = null;
}
// ── Keyboard visualiser ───────────────────────────────────────────────────────
function expectedKeyboardPitch() {
const rightHand = getRightHand();
const position = state.tracker?.position ?? 0;
return leadPitchFromEvent(rightHand[position] ?? rightHand[0]) ?? 60;
}
function resolveKeyboardPitchesForPitchClass(pitchClass) {
const rightHand = getRightHand();
const position = state.tracker?.position ?? 0;
const expectedEvent = rightHand[position] ?? rightHand[0];
const expectedPitches = eventPitches(expectedEvent);
const exactMatches = expectedPitches.filter((pitch) => pitch % 12 === pitchClass);
if (exactMatches.length === 1) return exactMatches[0];
if (exactMatches.length > 1) return exactMatches;
const target = expectedKeyboardPitch();
let midi = pitchClass;
while (midi < target - 6) midi += 12;
while (midi > target + 6) midi -= 12;
while (midi < 24) midi += 12;
while (midi > 108) midi -= 12;
return midi;
}
function resolveTypedMidi(code) {
const layout = SIMPLE_KEY_LAYOUT[code];
if (!layout) return undefined;
return resolveKeyboardPitchesForPitchClass(layout.pitchClass);
}
function cueKeyIdForMidi(midi) {
const slot = VISUAL_SLOTS.find((entry) => entry.pitchClass === (midi % 12));
return slot ? `kbslot-${slot.id}` : null;
}
function laneCodeForMidi(midi) {
return VISUAL_SLOTS.find((entry) => entry.pitchClass === (midi % 12)) || null;
}
function currentGuideBeat(rightHand) {
if (!rightHand.length) return 0;
if (!state.playing && Number.isFinite(Number(state.practiceStartBeat))) {
return Math.max(0, Number(state.practiceStartBeat) || 0);
}
const trackerPos = state.tracker?.position ?? 0;
const expectedBeat = rightHand[trackerPos]?.[1]
?? rightHand[rightHand.length - 1]?.[1]
?? 0;
if (state.paused) return Math.min(state.pausedBeat ?? 0, expectedBeat);
if (state.followMode === 'leader') {
const accompanimentBeat = state.accompanist?.currentBeat?.();
const lastBeat = rightHand[rightHand.length - 1]?.[1] ?? expectedBeat;
return Number.isFinite(accompanimentBeat)
? Math.max(0, Math.min(accompanimentBeat, lastBeat + 0.5))
: expectedBeat;
}
const last = state.tracker?.timestamps?.[state.tracker.timestamps.length - 1];
const anchorBeat = last?.beat ?? _noteHighwayStartBeat ?? 0;
const anchorTime = last?.time ?? _noteHighwayStartTime;
const bps = _noteHighwayBps || state.tracker?.bps?.() || 1;
if (!last && trackerPos === 0) {
const accompanimentBeat = state.accompanist?.currentBeat?.();
return Number.isFinite(accompanimentBeat)
? Math.max(0, Math.min(accompanimentBeat, expectedBeat))
: 0;
}
if (!anchorTime) return Math.min(anchorBeat, expectedBeat);
const estimated = anchorBeat + Math.max(0, performance.now() / 1000 - anchorTime) * bps;
if (state.tracker?.isFinished?.()) return estimated;
return Math.min(estimated, expectedBeat);
}
function ensureNoteHighway() {
const root = document.getElementById('note-highway');
if (!root || root.dataset.ready === '1') return;
root.innerHTML = VISUAL_SLOTS.map((slot) => {
return `<div class="note-lane ${slot.kind}" data-lane="${slot.id}">
<div class="note-lane-label">${slot.noteName}</div>
<div class="note-hit-line"></div>
</div>`;
}).join('');
root.dataset.ready = '1';
}
const HIGHWAY_LOOK_BEHIND_BEATS = 0.5;
const HIGHWAY_LOOK_AHEAD_BEATS = 4;
const HIGHWAY_TOP_PADDING = 12;
const HIGHWAY_MIN_BAR_HEIGHT = 5;
const HIGHWAY_BAR_GAP_BEATS = 0.06;
function resolveSustainBeats(event, nextBeat) {
const beat = event?.[1];
const gap = (Number.isFinite(nextBeat) && Number.isFinite(beat)) ? (nextBeat - beat) : Infinity;
const fallback = Number.isFinite(gap) ? gap : 0.75;
const raw = eventDuration(event, fallback);
const cap = Number.isFinite(gap) ? Math.max(0.02, gap - HIGHWAY_BAR_GAP_BEATS) : raw;
return Math.max(0.02, Math.min(raw, cap));
}
// bar's bottom = hit line at onset (delta=0); bar's height = sustain * pxPerBeat.
function computeBarGeometry(delta, sustainBeats, laneHeight, hitLineTop, pixelsPerBeat) {
const bottomRaw = hitLineTop - delta * pixelsPerBeat;
const topRaw = bottomRaw - sustainBeats * pixelsPerBeat;
const top = Math.max(HIGHWAY_TOP_PADDING, topRaw);
const bottom = Math.min(laneHeight, bottomRaw);
if (bottom <= HIGHWAY_TOP_PADDING || bottom <= top) return null;
const height = Math.min(bottom - HIGHWAY_TOP_PADDING, Math.max(HIGHWAY_MIN_BAR_HEIGHT, bottom - top));
return { top: Math.max(HIGHWAY_TOP_PADDING, bottom - height), height };
}
function renderNoteHighway() {
ensureNoteHighway();
const root = document.getElementById('note-highway');
if (state.finishedPlayback && !state.playing) {
document.querySelectorAll('.note-bar, .full-note-bar').forEach((el) => el.remove());
return;
}
const rightHand = getRightHand();
const trackerPos = state.tracker?.position ?? eventIndexAtOrAfterBeat(rightHand, state.practiceStartBeat ?? 0);
const activeIndex = state.tracker?.isFinished?.()
? (state.tracker?._lastAdvancedPosition ?? trackerPos)
: trackerPos;
const hasMatchedAnyNote = (state.tracker?.timestamps?.length ?? 0) > 0;
const openingOnlyCurrent = trackerPos === 0 && !hasMatchedAnyNote;
const currentBeat = rightHand.length ? currentGuideBeat(rightHand) : 0;
updateSheetHighlight(currentBeat);
renderFullKeyboardHighway(rightHand, activeIndex, currentBeat, openingOnlyCurrent);
if (!root) return;
root.querySelectorAll('.note-bar').forEach((el) => el.remove());
if (!rightHand.length) return;
const firstLane = root.querySelector('.note-lane');
const hitLineEl = firstLane?.querySelector('.note-hit-line');
if (!firstLane || !hitLineEl) return;
const laneRect = firstLane.getBoundingClientRect();
const hitRect = hitLineEl.getBoundingClientRect();
if (!laneRect.height) return;
const laneHeight = laneRect.height;
const hitLineTop = hitRect.top - laneRect.top;
const pixelsPerBeat = (hitLineTop - HIGHWAY_TOP_PADDING) / HIGHWAY_LOOK_AHEAD_BEATS;
const startIndex = Math.max(0, trackerPos - 1);
for (let i = startIndex; i < rightHand.length; i++) {
const event = rightHand[i];
if (openingOnlyCurrent && i !== trackerPos) continue;
const beat = event?.[1];
const delta = beat - currentBeat;
if (delta < -HIGHWAY_LOOK_BEHIND_BEATS) continue;
if (delta > HIGHWAY_LOOK_AHEAD_BEATS) break;
const sustainBeats = resolveSustainBeats(event, rightHand[i + 1]?.[1]);
const geom = computeBarGeometry(delta, sustainBeats, laneHeight, hitLineTop, pixelsPerBeat);
if (!geom) continue;
eventPitches(event).forEach((midi) => {
const lane = laneCodeForMidi(midi);
if (!lane) return;
const laneEl = root.querySelector(`[data-lane="${lane.id}"]`);
if (!laneEl) return;
const bar = document.createElement('div');
bar.className = `note-bar${lane.sharp ? ' sharp' : ''}${i === activeIndex ? ' current' : ''}`;
bar.style.top = `${geom.top}px`;
bar.style.height = `${geom.height}px`;
laneEl.appendChild(bar);
});
}
}
function renderFullKeyboardHighway(rightHand, trackerPos, currentBeat, openingOnlyCurrent = false) {
const root = document.getElementById('full-note-highway');
const hitLineEl = root?.querySelector('.full-note-hit-line');
if (!root || !hitLineEl) return;
root.querySelectorAll('.full-note-bar').forEach((el) => el.remove());
if (!rightHand.length) return;
const rootRect = root.getBoundingClientRect();
const hitRect = hitLineEl.getBoundingClientRect();
if (!rootRect.width || !rootRect.height) return;
const laneHeight = rootRect.height;
const hitLineTop = hitRect.top - rootRect.top;
const pixelsPerBeat = (hitLineTop - HIGHWAY_TOP_PADDING) / HIGHWAY_LOOK_AHEAD_BEATS;
const startIndex = Math.max(0, trackerPos - 1);
for (let i = startIndex; i < rightHand.length; i++) {
const event = rightHand[i];
if (openingOnlyCurrent && i !== trackerPos) continue;
const beat = event?.[1];
const delta = beat - currentBeat;
if (delta < -HIGHWAY_LOOK_BEHIND_BEATS) continue;
if (delta > HIGHWAY_LOOK_AHEAD_BEATS) break;
const sustainBeats = resolveSustainBeats(event, rightHand[i + 1]?.[1]);
const geom = computeBarGeometry(delta, sustainBeats, laneHeight, hitLineTop, pixelsPerBeat);
if (!geom) continue;
eventPitches(event).forEach((midi) => {
const keyEl = document.getElementById(`refkey-${midi}`);
if (!keyEl) return;
const keyRect = keyEl.getBoundingClientRect();
const bar = document.createElement('div');
bar.className = `full-note-bar${isBlackKeyMidi(midi) ? ' sharp' : ''}${i === trackerPos ? ' current' : ''}`;
bar.style.left = `${Math.max(0, keyRect.left - rootRect.left + 2)}px`;
bar.style.width = `${Math.max(8, keyRect.width - 4)}px`;
bar.style.top = `${geom.top}px`;
bar.style.height = `${geom.height}px`;
root.appendChild(bar);
});
}
}
function stopNoteHighwayLoop() {
if (_noteHighwayRaf) cancelAnimationFrame(_noteHighwayRaf);
_noteHighwayRaf = null;
}
function startNoteHighwayLoop() {
stopNoteHighwayLoop();
const tick = () => {
renderNoteHighway();
updatePracticePanelStatus();
if (state.playing && !state.paused) _noteHighwayRaf = requestAnimationFrame(tick);
};
tick();
}
function buildKeyboard(rightHand) {
const nextPitches = eventPitches(rightHand[0]);
const row = document.getElementById('kb-row-main');
row.innerHTML = SIMPLE_KEY_ORDER.map((code) => {
const naturalSlot = VISUAL_SLOTS.find((slot) => slot.code === code && !slot.sharp);
const sharpSlot = VISUAL_SLOTS.find((slot) => slot.anchorCode === code && slot.sharp);
const naturalIsNext = naturalSlot && nextPitches.some((pitch) => pitch % 12 === naturalSlot.pitchClass);
const sharpIsNext = sharpSlot && nextPitches.some((pitch) => pitch % 12 === sharpSlot.pitchClass);
return `<div class="kb-key-wrap">
<div class="kb-key kb-key-white${naturalIsNext ? ' next' : ''}" id="kbslot-${naturalSlot.id}">
<span class="key-char">${naturalSlot.keyLabel}</span>
<span class="note-name">${naturalSlot.noteName}</span>
</div>
${sharpSlot ? `
<div class="kb-key kb-key-black${sharpIsNext ? ' next' : ''}" id="kbslot-${sharpSlot.id}">
<span class="key-char">${sharpSlot.keyLabel}</span>
<span class="note-name">${sharpSlot.noteName}</span>
</div>
` : ''}
</div>`;
}).join('');
buildReferenceKeyboard();
applyKeyboardLayoutMode(state.keyboardLayoutMode);
}
function buildReferenceKeyboard() {
const whiteRoot = document.getElementById('full-kb-whites');
const blackRoot = document.getElementById('full-kb-blacks');
if (!whiteRoot || !blackRoot) return;
let whiteIndex = 0;
const whiteKeys = [];
const blackKeys = [];
for (let midi = FULL_KEYBOARD_START; midi <= FULL_KEYBOARD_END; midi++) {
const label = pitchName(midi);
if (isBlackKeyMidi(midi)) {
blackKeys.push(
`<div class="ref-key ref-key-black" id="refkey-${midi}" data-midi="${midi}" style="left: calc(${whiteIndex} * var(--ref-white-width) - var(--ref-black-offset));">
<span class="ref-key-note">${label}</span>
</div>`
);
} else {
whiteKeys.push(
`<div class="ref-key ref-key-white" id="refkey-${midi}" data-midi="${midi}">
<span class="ref-key-note">${label}</span>
</div>`
);
whiteIndex += 1;
}
}
whiteRoot.innerHTML = whiteKeys.join('');
blackRoot.innerHTML = blackKeys.join('');
}
function scrollReferenceKeyboardToMidi(midi) {
if (state.keyboardLayoutMode !== 'full') return;
const shell = document.getElementById('reference-keyboard-shell');
const key = document.getElementById(`refkey-${midi}`);
if (!shell || !key) return;
key.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
function highlightKey(key, on) {
const el = document.getElementById(key);
if (el) el.classList.toggle('active', on);
}
function updateNextKey(rightHand, position) {
document.querySelectorAll('.kb-key.next, .kb-sharp.next, .ref-key.next')
.forEach(el => el.classList.remove('next'));
if (position >= rightHand.length) {
document.getElementById('next-note-display').textContent = 'β€”';
renderNoteHighway();
return;
}
const event = rightHand[position];
eventPitches(event).forEach((midi) => {
const keyId = cueKeyIdForMidi(midi);
if (keyId) document.getElementById(keyId)?.classList.add('next');
document.getElementById(`refkey-${midi}`)?.classList.add('next');
});
document.getElementById('next-note-display').textContent = eventLabel(event);
scrollReferenceKeyboardToMidi(leadPitchFromEvent(event));
renderNoteHighway();
syncExpectedMicNote();
}
// ── Start / Stop ──────────────────────────────────────────────────────────────
function handleStartPause() {
if (!state.playing) {
return startPlaying();
}
return togglePausePlaying();
}
async function startPlaying() {
await resumeOutputContexts(); // unblock audio on user gesture
clearFinishPlaybackTimer();
const bpm = parseFloat(document.getElementById('bpm-input').value) || 100;
const initialBps = bpm / 60;
const right = expandDynamicTremolos(getRightHandRaw(), initialBps);
const left = expandDynamicTremolos(getLeftHandRaw(), initialBps);
state.practiceRightHand = right;
state.practiceLeftHand = left;
// Build per-part instrument map for the accompanist (all non-selected parts)
const parts = state.current?.parts || [];
const selected = new Set(selectedPartIndices());
const leftInstruments = parts
.map((_, i) => i)
.filter(i => !selected.has(i))
.map(i => getInstrumentForPart(i));
const selectedInstruments = selectedPartIndices().map((idx) => getInstrumentForPart(idx));
const instrumentsInUse = [...selectedInstruments, ...leftInstruments];
const startBtn = document.getElementById('start-btn');
const stopBtn = document.getElementById('stop-btn');
startBtn.disabled = true;
const originalLabel = 'β–Ά Start';
startBtn.textContent = 'Loading...';
try {
await ensureSamplePlayback(instrumentsInUse);
} finally {
startBtn.textContent = originalLabel;
}
const missingSampleInstrument = instrumentsInUse.find((instrument) =>
isSampleBackedInstrument(instrument) && !hasLoadedSampler(instrument)
);
if (missingSampleInstrument) {
startBtn.disabled = false;
stopBtn.disabled = true;
startBtn.textContent = 'β–Ά Start';
startBtn.classList.add('btn-primary');
stopBtn.textContent = 'β–  End';
alert(`Could not load the sampled ${missingSampleInstrument} instrument yet. Reload and try again.`);
return;
}
state.tracker = new Tracker(right, initialBps);
state.accompanist = new Accompanist(left, right, initialBps, leftInstruments, {
followMode: state.followMode,
});
const startBeat = Math.max(0, Number(state.practiceStartBeat) || 0);
state.tracker.seekToBeat(startBeat);
state.accompanist.start(startBeat);
state.playing = true;
state.paused = false;
state.pausedBeat = startBeat;
state.pausedBps = initialBps;
state.finishedPlayback = false;
_noteHighwayStartTime = state.followMode === 'leader' || startBeat > 0 ? performance.now() / 1000 : null;
_noteHighwayStartBeat = startBeat;
_noteHighwayBps = initialBps;
startBtn.disabled = false;
stopBtn.disabled = false;
startBtn.textContent = '❚❚ Pause';
startBtn.classList.add('btn-primary');
stopBtn.textContent = 'β–  End';
updateNextKey(right, state.tracker.position);
updatePracticePanelStatus();
updateSheetHighlight(startBeat);
startNoteHighwayLoop();
enableMidi();
resetAcousticMicFollower();
if (_inputMode === 'mic') _startMic();
syncExpectedMicNote();
}
function togglePausePlaying() {
if (!state.playing || !state.tracker) return;
const startBtn = document.getElementById('start-btn');
if (!startBtn) return;
if (!state.paused) {
state.pausedBeat = currentGuideBeat(getRightHand());
state.pausedBps = _noteHighwayBps || state.tracker?.accompanimentBps?.({ adaptiveTempo: state.adaptiveTempo }) || state.tracker?.bps?.() || 1;
state.paused = true;
state.accompanist?.pause();
_stopMic();
stopNoteHighwayLoop();
updateSheetHighlight(state.pausedBeat);
renderNoteHighway();
startBtn.textContent = 'β–Ά Resume';
updatePracticePanelStatus();
return;
}
state.paused = false;
_noteHighwayStartBeat = state.pausedBeat ?? 0;
_noteHighwayStartTime = performance.now() / 1000;
_noteHighwayBps = state.pausedBps ?? (state.tracker?.accompanimentBps?.({ adaptiveTempo: state.adaptiveTempo }) ?? state.tracker?.bps?.() ?? 1);
state.accompanist?.resume(_noteHighwayStartBeat, _noteHighwayBps);
startBtn.textContent = '❚❚ Pause';
updatePracticePanelStatus();
startNoteHighwayLoop();
if (_inputMode === 'mic') _startMic();
syncExpectedMicNote();
}
function clearFinishPlaybackTimer() {
if (!_finishPlaybackTimer) return;
clearTimeout(_finishPlaybackTimer);
_finishPlaybackTimer = null;
}
function scheduleFinishedPlayback() {
if (_finishPlaybackTimer || !state.tracker?.isFinished?.()) return;
const bps = Math.max(0.35, _noteHighwayBps || state.tracker?.bps?.() || 1);
const delayMs = Math.max(650, Math.min(2200, ((HIGHWAY_LOOK_BEHIND_BEATS + 0.18) / bps) * 1000));
_finishPlaybackTimer = setTimeout(() => {
_finishPlaybackTimer = null;
if (state.tracker?.isFinished?.()) stopPlaying('finished');
}, delayMs);
}
function stopPlaying(reason = 'stopped') {
clearFinishPlaybackTimer();
if (state.accompanist) state.accompanist.stop();
_stopMic();
stopNoteHighwayLoop();
const finished = reason === 'finished' && !!state.tracker?.isFinished?.();
state.playing = false;
state.paused = false;
state.pausedBeat = 0;
state.finishedPlayback = finished;
state.practiceRightHand = null;
state.practiceLeftHand = null;
_noteHighwayStartTime = null;
const startBtn = document.getElementById('start-btn');
const stopBtn = document.getElementById('stop-btn');
startBtn.disabled = false;
stopBtn.disabled = true;
startBtn.textContent = 'β–Ά Start';
startBtn.classList.add('btn-primary');
stopBtn.textContent = 'β–  End';
if (finished) {
document.getElementById('next-note-display').textContent = 'Finished';
document.getElementById('progress-fill').style.width = '100%';
if (state.guestMode && state.current?.guest) {
const progress = loadGuestProgress();
progress[state.current.name] = {
...(progress[state.current.name] || {}),
completed: true,
completedAt: new Date().toISOString(),
};
saveGuestProgress(progress);
showGuestUpgradeToast('Nice run. Create a free account to save your own pieces and progress.');
}
} else {
document.getElementById('next-note-display').textContent = 'β€”';
}
if (typeof _pitchDetector?.setExpectedMidi === 'function') _pitchDetector.setExpectedMidi(null);
if (typeof _pitchDetector?.setExpectedPitches === 'function') _pitchDetector.setExpectedPitches([]);
updatePracticePanelStatus();
renderNoteHighway();
}
// ── Note handler (called by keyboard and MIDI) ────────────────────────────────
function advancePracticeClock(beat) {
const detectedBps = state.tracker.bps();
const leaderKeepsTempo = state.followMode === 'leader'
&& (!state.adaptiveTempo || (state.tracker?.timestamps?.length || 0) < 4);
let accompanimentBps = leaderKeepsTempo
? (state.accompanist?._bps || state.pausedBps || detectedBps)
: (state.tracker.accompanimentBps?.({ adaptiveTempo: state.adaptiveTempo }) ?? detectedBps);
accompanimentBps = state.accompanist?.tempoForBeat?.(beat, accompanimentBps) ?? accompanimentBps;
state.accompanist.onRhNote(beat, accompanimentBps);
const guideBeat = state.followMode === 'leader'
? currentGuideBeat(getRightHand())
: beat;
_noteHighwayStartBeat = guideBeat;
_noteHighwayStartTime = performance.now() / 1000;
_noteHighwayBps = accompanimentBps;
state.pausedBps = accompanimentBps;
updateSheetHighlight(guideBeat);
updatePracticePanelStatus();
document.getElementById('beat-val').textContent = guideBeat.toFixed(1);
const displayedBps = state.followMode === 'leader' ? accompanimentBps : detectedBps;
document.getElementById('tempo-val').textContent = Math.round(displayedBps * 60) + ' BPM';
document.getElementById('progress-fill').style.width =
(state.tracker.progress() * 100).toFixed(1) + '%';
}
function handleNoteMic(midi) {
if (!state.playing || state.paused || !state.tracker) return;
const beat = state.tracker.onNoteFuzzy(midi);
if (beat !== null) {
advancePracticeClock(beat);
updateNextKey(getRightHand(), state.tracker.position);
syncExpectedMicNote();
}
if (state.tracker.isFinished()) scheduleFinishedPlayback();
}
let _pendingMicNoteTimer = null;
let _pendingMicNote = null;
let _pendingMicNoteSeq = 0;
function clearPendingMicNote() {
if (_pendingMicNoteTimer) {
clearTimeout(_pendingMicNoteTimer);
_pendingMicNoteTimer = null;
}
_pendingMicNote = null;
_pendingMicNoteSeq++;
}
function queueMicNoteForFollowing(midi, info = {}) {
if (!state.playing || state.paused || !state.tracker) return;
_pendingMicNote = {
midi,
info,
seq: ++_pendingMicNoteSeq,
};
if (_pendingMicNoteTimer) clearTimeout(_pendingMicNoteTimer);
_pendingMicNoteTimer = setTimeout(() => {
const pending = _pendingMicNote;
_pendingMicNote = null;
_pendingMicNoteTimer = null;
if (!pending || pending.seq !== _pendingMicNoteSeq) return;
handleNoteMic(pending.midi);
}, MIC_NOTE_ACCEPT_DELAY_MS);
}
function handleMicChord(pitches, info = {}) {
if (!state.playing || state.paused || !state.tracker) return;
clearPendingMicNote();
const beat = state.tracker.onChordFuzzy(pitches);
if (beat !== null) {
const noteDisplay = document.getElementById('mic-note-display');
if (noteDisplay) noteDisplay.textContent = pitches.map((pitch) => pitchName(pitch)).join(' / ');
_micDot('hearing');
advancePracticeClock(beat);
updateNextKey(getRightHand(), state.tracker.position);
syncExpectedMicNote();
setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 220);
}
if (state.tracker.isFinished()) scheduleFinishedPlayback();
}
function resetAcousticMicFollower() {
_acousticMicFollower = new AcousticMicFollower();
updateMicFollowerPanel(null);
}
function acousticChromaSummary(chroma) {
if (!Array.isArray(chroma) || chroma.length !== 12) return '';
return chroma
.map((value, index) => ({ label: ACOUSTIC_PC_LABELS[index], value }))
.filter((entry) => entry.value > 0.18)
.sort((a, b) => b.value - a.value)
.slice(0, 4)
.map((entry) => entry.label)
.join(' ');
}
function updateMicFollowerPanel(output) {
const modeEl = document.getElementById('mic-follower-mode');
const confidenceEl = document.getElementById('mic-follower-confidence');
const delayEl = document.getElementById('mic-follower-delay');
const candidateEl = document.getElementById('mic-follower-candidates');
const chromaEl = document.getElementById('mic-follower-chroma');
if (!modeEl || !confidenceEl || !delayEl || !candidateEl || !chromaEl) return;
if (!output) {
modeEl.textContent = 'Listening';
confidenceEl.textContent = '--';
delayEl.textContent = '--';
candidateEl.textContent = 'Play the next highlighted note or chord.';
chromaEl.innerHTML = ACOUSTIC_PC_LABELS
.map((label) => `<span class="mic-chroma-cell"><span style="height:0%"></span><em>${label}</em></span>`)
.join('');
return;
}
modeEl.textContent = output.mode;
confidenceEl.textContent = `${Math.round(output.confidence * 100)}%`;
delayEl.textContent = `${Math.round(output.suggestedDelayMs)} ms`;
candidateEl.innerHTML = output.candidates.length
? output.candidates.map((candidate) =>
`<span>${candidate.label} @ ${candidate.beat.toFixed(1)} <strong>${Math.round(candidate.confidence * 100)}%</strong></span>`
).join('')
: 'No plausible score event yet.';
chromaEl.innerHTML = output.audioChroma
.map((value, index) =>
`<span class="mic-chroma-cell"><span style="height:${Math.round(value * 100)}%"></span><em>${ACOUSTIC_PC_LABELS[index]}</em></span>`
)
.join('');
}
function handleAcousticMicFrame(input, meta) {
if (!_acousticMicFollower) resetAcousticMicFollower();
if (!state.playing || state.paused || !state.tracker) return;
const output = _acousticMicFollower.processChunk(input, meta);
if (!output) return;
updateMicFollowerPanel(output);
const noteDisplay = document.getElementById('mic-note-display');
if (noteDisplay) {
const chromaText = acousticChromaSummary(output.audioChroma);
noteDisplay.textContent = output.beat !== null
? `Beat ${output.beat.toFixed(1)} ${Math.round(output.confidence * 100)}%${chromaText ? ` (${chromaText})` : ''}`
: `Listening ${Math.round(output.confidence * 100)}%`;
}
const debug = document.getElementById('mic-debug');
if (debug) {
const mode = ACOUSTIC_FRAME_ADVANCE_ENABLED ? 'follow' : 'diagnostic';
debug.textContent = `${mode} delay ${Math.round(output.suggestedDelayMs)}ms`;
}
if (!ACOUSTIC_FRAME_ADVANCE_ENABLED) return;
if (!state.playing || state.paused || !state.tracker || !output.shouldPlayAccompaniment) return;
const beat = state.tracker.advanceTo(output.bestEventIndex, {
inputPitch: leadPitchFromEvent(getRightHand()[output.bestEventIndex] ?? getRightHand()[state.tracker.position]),
maxJump: MAX_ACOUSTIC_EVENT_JUMP,
});
if (beat !== null) {
_micDot('hearing');
advancePracticeClock(beat);
updateNextKey(getRightHand(), state.tracker.position);
syncExpectedMicNote();
setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 220);
}
if (state.tracker.isFinished()) scheduleFinishedPlayback();
}
function playInputNoteSound(midi) {
const expectedEvent = getRightHand()[state.tracker.position] ?? getRightHand()[0];
playNote(midi, expectedEvent ? eventVelocity(expectedEvent, 0.6) : 0.6, getInstrumentForPart(state.selectedPart ?? 0), {
duration: expectedEvent ? eventDurationSeconds(expectedEvent, getInstrumentForPart(state.selectedPart ?? 0)) : undefined,
pedaled: expectedEvent ? isPedaledEvent(expectedEvent) : false,
pedalHold: expectedEvent ? eventPedalHoldSeconds(expectedEvent) : null,
volume: state.playbackVolumes?.solo ?? 1,
outputRole: 'solo',
});
const keyId = cueKeyIdForMidi(midi);
highlightKey(keyId, true);
highlightKey(`refkey-${midi}`, true);
setTimeout(() => highlightKey(keyId, false), 120);
setTimeout(() => highlightKey(`refkey-${midi}`, false), 120);
}
function handleNote(midi) {
if (!state.playing || state.paused || !state.tracker) return;
playInputNoteSound(midi);
if (_inputMode === 'mic') return;
const beat = state.tracker.onNote(midi);
if (beat !== null) {
advancePracticeClock(beat);
updateNextKey(getRightHand(), state.tracker.position);
}
if (state.tracker.isFinished()) scheduleFinishedPlayback();
}
function handleKeyboardInput(midiOrPitches) {
const pitches = Array.isArray(midiOrPitches) ? midiOrPitches : [midiOrPitches];
pitches.forEach((midi) => handleNote(midi));
}
function handleMidiNote(midi) {
if (!state.playing || state.paused || !state.tracker) return;
const now = performance.now();
if (_lastMidiPitch === midi && now - _lastMidiNoteTime < MIDI_DUPLICATE_NOTE_GUARD_MS) return;
_lastMidiPitch = midi;
_lastMidiNoteTime = now;
if (_inputMode === 'mic') {
playInputNoteSound(midi);
return;
}
const keyId = cueKeyIdForMidi(midi);
highlightKey(keyId, true);
highlightKey(`refkey-${midi}`, true);
setTimeout(() => highlightKey(keyId, false), 120);
setTimeout(() => highlightKey(`refkey-${midi}`, false), 120);
const beat = state.tracker.onNote(midi);
if (beat !== null) {
advancePracticeClock(beat);
updateNextKey(getRightHand(), state.tracker.position);
}
if (state.tracker.isFinished()) scheduleFinishedPlayback();
}
// ── Input mode ───────────────────────────────────────────────────────────────
let _inputMode = 'keyboard';
let _pitchDetector = null;
let _acousticMicFollower = null;
let _selectedMicId = null;
let _selectedSpeakerId = null;
const MIC_LATENCY_TRIALS = 5;
const MIC_LATENCY_PROMPTS = [
{ label: 'C5', midi: 72 },
{ label: 'G5', midi: 79 },
];
let _micLatencyCalibration = {
active: false,
waiting: false,
round: 0,
total: MIC_LATENCY_TRIALS,
promptAt: 0,
probeStartMs: 0,
promptNote: null,
results: [],
timer: null,
};
function updatePracticePanelStatus() {
const inputStatus = document.getElementById('practice-input-status');
if (inputStatus) {
inputStatus.textContent = _inputMode === 'mic' ? 'Microphone' : 'Keyboard';
}
const followingStatus = document.getElementById('practice-following-status');
if (followingStatus) {
followingStatus.textContent = state.playing
? (
state.paused
? 'Paused'
: state.followMode === 'leader'
? (state.accompanist?.isHoldingForSoloist?.() ? 'Waiting for note' : 'Accompanist leading')
: 'Following'
)
: 'Ready';
}
}
function setInputMode(mode, options = {}) {
const persist = options.persist !== false;
const previousEffectiveSec = accompanimentLatencyCompSec();
_inputMode = mode;
document.getElementById('tab-keyboard').classList.toggle('active', mode === 'keyboard');
document.getElementById('tab-mic').classList.toggle('active', mode === 'mic');
document.getElementById('mic-controls').style.display = mode === 'mic' ? 'block' : 'none';
const keyboardSection = document.getElementById('keyboard-section');
if (keyboardSection) keyboardSection.dataset.inputMode = mode === 'mic' ? 'mic' : 'keyboard';
setLatencyCompensation(Math.round(_accompanimentLatencyCompSec * 1000), {
persistGlobal: false,
persistScore: false,
});
if (state.accompanist && state.playing && !state.paused) {
state.accompanist.updateLatencyCompensation(previousEffectiveSec, accompanimentLatencyCompSec());
}
let startPromise = Promise.resolve();
if (mode === 'mic') {
startPromise = _startMic();
} else {
cancelMicLatencyCalibration();
_stopMic();
}
updatePracticePanelStatus();
if (persist) saveCurrentScorePreferences({ inputMode: mode });
return startPromise;
}
function onNoiseGateChange(val) {
const label = document.getElementById('noise-gate-val');
if (label) label.textContent = val;
if (_pitchDetector) _pitchDetector.setThreshold(val / 1000);
if (_acousticMicFollower) _acousticMicFollower.calibration.noiseFloor = val / 1000;
_drawMeter(_lastRms); // redraw so threshold line updates immediately
}
function setMicLatencyStatus(message, tone = 'muted') {
const result = document.getElementById('mic-latency-result');
if (!result) return;
result.textContent = message || '';
result.style.color = tone === 'error'
? '#e05c5c'
: tone === 'success'
? 'var(--success)'
: 'var(--muted)';
}
function renderMicLatencySamples() {
const el = document.getElementById('mic-latency-samples');
if (!el) return;
el.innerHTML = _micLatencyCalibration.results.map((sample, index) => {
if (!sample) return `<span class="mic-latency-sample">Try ${index + 1}: missed</span>`;
const label = sample.midi ? pitchName(sample.midi) : 'note';
const target = sample.targetLabel ? ` for ${sample.targetLabel}` : '';
return `<span class="mic-latency-sample good">Try ${index + 1}: ${Math.round(sample.ms)} ms${target} (${label})</span>`;
}).join('');
}
function updateMicLatencyControls() {
const active = _micLatencyCalibration.active;
const startBtn = document.getElementById('mic-latency-btn');
const cancelBtn = document.getElementById('mic-latency-cancel-btn');
if (startBtn) startBtn.disabled = active;
if (cancelBtn) cancelBtn.style.display = active ? 'inline-flex' : 'none';
}
function clearMicLatencyTimer() {
if (_micLatencyCalibration.timer) {
clearTimeout(_micLatencyCalibration.timer);
_micLatencyCalibration.timer = null;
}
}
function setMicLatencyTimer(fn, delayMs) {
clearMicLatencyTimer();
_micLatencyCalibration.timer = setTimeout(fn, delayMs);
}
function performanceTimeForAudioContextTime(ctx, audioTime) {
if (typeof ctx.getOutputTimestamp === 'function') {
const stamp = ctx.getOutputTimestamp();
if (Number.isFinite(stamp.contextTime) && Number.isFinite(stamp.performanceTime)) {
return stamp.performanceTime + (audioTime - stamp.contextTime) * 1000;
}
}
return performance.now() + Math.max(0, audioTime - ctx.currentTime + (ctx.baseLatency || 0)) * 1000;
}
function scheduleMicLatencyProbe(midi) {
const ctx = outputCtx('solo');
const startAt = ctx.currentTime + 0.14;
const duration = 0.18;
const freq = 440 * Math.pow(2, (midi - 69) / 12);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const soloVolume = normalizePlaybackVolume(state.playbackVolumes?.solo, 1, maxPlaybackVolumeForChannel('solo'));
const probeGain = Math.max(0.015, Math.min(0.28, 0.18 * soloVolume));
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, startAt);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(probeGain, startAt + 0.012);
gain.gain.setValueAtTime(probeGain, startAt + duration - 0.035);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
osc.connect(gain).connect(ctx.destination);
osc.start(startAt);
osc.stop(startAt + duration + 0.04);
return {
audibleAtMs: performanceTimeForAudioContextTime(ctx, startAt),
durationMs: duration * 1000,
};
}
function resetMicLatencyCalibrationUi() {
const round = document.getElementById('mic-latency-round');
const prompt = document.getElementById('mic-latency-prompt');
if (round) round.textContent = 'Ready';
if (prompt) prompt.textContent = '--';
setMicLatencyStatus('Put the microphone near the speaker. NotePilot will emit test tones automatically.');
renderMicLatencySamples();
updateMicLatencyControls();
}
async function calculateMicLatency() {
if (_micLatencyCalibration.active) return;
if (!navigator.mediaDevices?.getUserMedia) {
setMicLatencyStatus('Microphone access is not available in this browser.', 'error');
return;
}
if (state.playing) stopPlaying();
try {
const ctx = audioCtx();
if (ctx.state === 'suspended') await ctx.resume();
} catch (error) {
setMicLatencyStatus(error.message || 'Audio output could not be started.', 'error');
return;
}
_micLatencyCalibration = {
active: true,
waiting: false,
round: 0,
total: MIC_LATENCY_TRIALS,
promptAt: 0,
probeStartMs: 0,
promptNote: null,
results: [],
timer: null,
};
updateMicLatencyControls();
renderMicLatencySamples();
setMicLatencyStatus('Starting microphone...');
try {
await setInputMode('mic');
} catch (error) {
cancelMicLatencyCalibration({ resetStatus: false });
setMicLatencyStatus(error.message || 'Microphone could not be started.', 'error');
return;
}
if (!_micLatencyCalibration.active || !_pitchDetector) return;
startMicLatencyRound();
}
function cancelMicLatencyCalibration(options = {}) {
const wasActive = _micLatencyCalibration.active;
clearMicLatencyTimer();
_micLatencyCalibration.active = false;
_micLatencyCalibration.waiting = false;
_micLatencyCalibration.promptAt = 0;
_micLatencyCalibration.probeStartMs = 0;
_pitchDetector?.setExpectedMidi?.(null);
_pitchDetector?.setExpectedPitches?.([]);
updateMicLatencyControls();
if (options.resetStatus !== false && wasActive) {
const prompt = document.getElementById('mic-latency-prompt');
const round = document.getElementById('mic-latency-round');
if (prompt) prompt.textContent = '--';
if (round) round.textContent = 'Ready';
setMicLatencyStatus('Latency calculation canceled.');
}
}
function startMicLatencyRound() {
if (!_micLatencyCalibration.active) return;
_micLatencyCalibration.round += 1;
_micLatencyCalibration.waiting = false;
_micLatencyCalibration.promptAt = 0;
_micLatencyCalibration.probeStartMs = 0;
_micLatencyCalibration.promptNote =
MIC_LATENCY_PROMPTS[(_micLatencyCalibration.round - 1) % MIC_LATENCY_PROMPTS.length];
_pitchDetector?.setExpectedMidi?.(_micLatencyCalibration.promptNote.midi);
_pitchDetector?.setExpectedPitches?.([_micLatencyCalibration.promptNote.midi]);
const round = document.getElementById('mic-latency-round');
const prompt = document.getElementById('mic-latency-prompt');
if (round) round.textContent = `Test ${_micLatencyCalibration.round} of ${_micLatencyCalibration.total}`;
if (prompt) prompt.textContent = _micLatencyCalibration.promptNote.label;
setMicLatencyStatus(`Put the microphone near the speaker. Emitting ${_micLatencyCalibration.promptNote.label} soon.`);
runMicLatencyCountdown(3);
}
function runMicLatencyCountdown(value) {
if (!_micLatencyCalibration.active) return;
const prompt = document.getElementById('mic-latency-prompt');
const target = _micLatencyCalibration.promptNote?.label || 'the note';
if (value > 0) {
if (prompt) prompt.textContent = `${target} in ${value}`;
setMicLatencyTimer(() => runMicLatencyCountdown(value - 1), 450);
return;
}
if (prompt) prompt.textContent = `Emitting ${target}`;
const probe = scheduleMicLatencyProbe(_micLatencyCalibration.promptNote.midi);
_micLatencyCalibration.promptAt = probe.audibleAtMs;
_micLatencyCalibration.probeStartMs = probe.audibleAtMs;
_micLatencyCalibration.waiting = true;
setMicLatencyStatus(`Listening for the ${target} speaker probe...`);
setMicLatencyTimer(() => finishMicLatencyRound(null), 1800);
}
function finishMicLatencyRound(sample) {
if (!_micLatencyCalibration.active) return;
clearMicLatencyTimer();
_micLatencyCalibration.waiting = false;
_micLatencyCalibration.probeStartMs = 0;
_micLatencyCalibration.results.push(sample);
renderMicLatencySamples();
if (sample) {
const target = sample.targetLabel ? `${sample.targetLabel}: ` : '';
setMicLatencyStatus(`${target}detected speaker probe after ${Math.round(sample.ms)} ms.`);
} else {
setMicLatencyStatus('No note detected for this test.', 'error');
}
if (_micLatencyCalibration.round >= _micLatencyCalibration.total) {
finishMicLatencyCalibration();
return;
}
setMicLatencyTimer(startMicLatencyRound, 900);
}
function finishMicLatencyCalibration() {
const valid = _micLatencyCalibration.results
.filter(Boolean)
.map((sample) => sample.ms)
.filter((ms) => ms >= 5 && ms <= LATENCY_COMPENSATION_MAX_MS)
.sort((a, b) => a - b);
_micLatencyCalibration.active = false;
_micLatencyCalibration.waiting = false;
_pitchDetector?.setExpectedMidi?.(null);
_pitchDetector?.setExpectedPitches?.([]);
clearMicLatencyTimer();
updateMicLatencyControls();
const prompt = document.getElementById('mic-latency-prompt');
const round = document.getElementById('mic-latency-round');
if (round) round.textContent = 'Complete';
if (!valid.length) {
if (prompt) prompt.textContent = '--';
setMicLatencyStatus('No usable latency samples. Try again closer to the microphone.', 'error');
return;
}
const reliable = valid.length >= 4 ? valid.slice(1, -1) : valid;
const estimatedMs = Math.round(acousticMedian(reliable) / 10) * 10;
const appliedMs = Math.max(0, Math.min(LATENCY_COMPENSATION_MAX_MS, estimatedMs));
setLatencyCompensation(appliedMs);
if (prompt) prompt.textContent = `${appliedMs} ms`;
setMicLatencyStatus(
appliedMs === estimatedMs
? `Applied ${appliedMs} ms to accompaniment latency.`
: `Measured ${estimatedMs} ms and applied the ${appliedMs} ms maximum.`,
'success',
);
}
function handleMicLatencyNote(midi, info = {}) {
if (!_micLatencyCalibration.active || !_micLatencyCalibration.waiting) return false;
const now = performance.now();
const ms = now - _micLatencyCalibration.promptAt;
const expectedMidi = _micLatencyCalibration.promptNote?.midi;
if (Number.isFinite(expectedMidi) && Math.abs(midi - expectedMidi) > 1) return true;
if (ms < 5) return true;
finishMicLatencyRound({
ms,
midi,
clarity: Number(info.clarity) || 0,
targetMidi: _micLatencyCalibration.promptNote?.midi ?? null,
targetLabel: _micLatencyCalibration.promptNote?.label || '',
});
return true;
}
// ── Level meter ───────────────────────────────────────────────────────────────
let _lastRms = 0;
let _peakRms = 0;
let _peakHold = 0;
function _drawMeter(rms) {
_lastRms = rms;
const fill = document.getElementById('level-fill');
const peak = document.getElementById('level-peak');
const gate = document.getElementById('level-gate');
const label = document.getElementById('level-gate-label');
if (!fill || !peak || !gate || !label) return;
const gateVal = parseFloat(document.getElementById('noise-gate')?.value || 8) / 1000;
const scale = v => Math.min(v / 0.15, 1.0) * 100; // % of bar width
const fillPct = scale(rms);
const gatePct = scale(gateVal);
const aboveGate = rms >= gateVal;
// Peak hold
if (rms > _peakRms) { _peakRms = rms; _peakHold = 50; }
else if (_peakHold > 0) _peakHold--;
else _peakRms = Math.max(_peakRms * 0.95, 0);
const peakPct = scale(_peakRms);
fill.style.width = fillPct + '%';
fill.classList.toggle('active', aboveGate);
peak.style.left = Math.min(peakPct, 99) + '%';
gate.style.left = gatePct + '%';
const labelLeft = gatePct > 85 ? gatePct - 6 : gatePct + 0.5;
label.style.left = labelLeft + '%';
}
function _micDot(state) {
const dot = document.getElementById('mic-dot');
const text = document.getElementById('mic-status-text');
if (!dot || !text) return;
dot.className = 'mic-status-dot' + (state ? ' ' + state : '');
text.textContent = state === 'active' ? 'Listening…'
: state === 'hearing' ? 'Note detected'
: 'Mic off';
}
async function _populateMicList() {
try {
// Need a temporary permission grant to get device labels
const tmp = await navigator.mediaDevices.getUserMedia({ audio: true });
tmp.getTracks().forEach(t => t.stop());
} catch { return; }
const devices = await navigator.mediaDevices.enumerateDevices();
const mics = devices.filter(d => d.kind === 'audioinput');
const micSel = document.getElementById('mic-select');
if (micSel) {
micSel.innerHTML = mics.map(d =>
`<option value="${d.deviceId}"${d.deviceId === _selectedMicId ? ' selected' : ''}>${d.label || 'Microphone ' + d.deviceId.slice(0,6)}</option>`
).join('');
}
if (!_selectedMicId && mics.length) _selectedMicId = mics[0].deviceId;
await populateOutputDeviceSelectors();
}
async function onMicChange() {
const sel = document.getElementById('mic-select');
if (!sel) return;
_selectedMicId = sel.value;
if (_pitchDetector) {
_stopMic();
await _startMic();
}
}
async function onSpeakerChange() {
const sel = document.getElementById('speaker-select');
if (!sel) return;
_selectedSpeakerId = sel.value;
const ctx = audioCtx();
if (ctx.setSinkId) {
try { await ctx.setSinkId(_selectedSpeakerId || ''); } catch (e) { console.warn('setSinkId failed:', e); }
}
}
async function _startMic() {
if (_pitchDetector) return;
await _populateMicList();
if (!_acousticMicFollower) resetAcousticMicFollower();
const gate = parseFloat(document.getElementById('noise-gate')?.value || 1) / 1000;
_acousticMicFollower.calibration.noiseFloor = gate;
_pitchDetector = new PitchDetector(audioCtx(), {
threshold: gate,
deviceId: _selectedMicId || undefined,
onNote: (midi, info) => {
_micDot('hearing');
if (handleMicLatencyNote(midi, info)) {
const noteDisplay = document.getElementById('mic-note-display');
if (noteDisplay) noteDisplay.textContent = pitchName(midi);
setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 300);
return;
}
const freqText = info?.freq ? ` ${info.freq.toFixed(1)} Hz` : '';
const noteDisplay = document.getElementById('mic-note-display');
if (noteDisplay) noteDisplay.textContent = `${pitchName(midi)}${freqText}`;
if (state.playing) {
queueMicNoteForFollowing(midi, info);
}
setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 300);
},
onChord: (pitches, info) => {
if (_micLatencyCalibration.active) return;
_micDot('hearing');
const noteDisplay = document.getElementById('mic-note-display');
if (noteDisplay) noteDisplay.textContent = pitches.map((pitch) => pitchName(pitch)).join(' / ');
handleMicChord(pitches, info);
setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 300);
},
onSilence: () => {
const noteDisplay = document.getElementById('mic-note-display');
if (noteDisplay && !_micLatencyCalibration.active) noteDisplay.textContent = '';
_micDot('active');
},
onLevel: (rms) => _drawMeter(rms),
onDebug: (msg) => {
const debug = document.getElementById('mic-debug');
if (debug) debug.textContent = msg;
},
onFrame: (input, meta) => handleAcousticMicFrame(input, meta),
});
// Keep meter decaying to zero when silent
(function decayLoop() {
if (!_pitchDetector) { _drawMeter(0); return; }
requestAnimationFrame(decayLoop);
})();
try {
await _pitchDetector.start();
syncExpectedMicNote();
_micDot('active');
} catch (e) {
_pitchDetector = null;
if (_micLatencyCalibration.active) {
cancelMicLatencyCalibration({ resetStatus: false });
setMicLatencyStatus(e.message || 'Microphone access failed.', 'error');
} else {
alert(e.message);
}
setInputMode('keyboard');
}
}
function _stopMic() {
if (_pitchDetector) { _pitchDetector.stop(); _pitchDetector = null; }
clearPendingMicNote();
_micDot(null);
const noteDisplay = document.getElementById('mic-note-display');
const debug = document.getElementById('mic-debug');
if (noteDisplay) noteDisplay.textContent = '';
if (debug) debug.textContent = '';
updateMicFollowerPanel(null);
}
// ── Computer keyboard input ───────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.repeat) return;
if (['INPUT','TEXTAREA'].includes(e.target.tagName)) return;
const midi = resolveTypedMidi(e.code);
if (midi !== undefined) {
e.preventDefault();
handleKeyboardInput(midi);
}
});
// ── Web MIDI input ────────────────────────────────────────────────────────────
function setMidiBadge(stateClass, text, title = '') {
const badge = document.getElementById('midi-status');
const label = document.getElementById('midi-status-text');
if (!badge || !label) return;
badge.classList.remove('midi-badge-connected', 'midi-badge-disconnected', 'midi-badge-unsupported');
badge.classList.add(stateClass);
label.textContent = text;
badge.title = title || 'MIDI piano status';
}
function isVirtualMidiInput(input) {
const label = `${input?.name || ''} ${input?.manufacturer || ''}`.toLowerCase();
if (!label.trim()) return false;
return [
'iac driver',
'network session',
'session 1',
'python midi',
'midi output',
'loopmidi',
'loopbe',
'virtual',
'through port',
'midi through',
].some((needle) => label.includes(needle));
}
function refreshMidiInputs(access) {
const inputs = Array.from(access.inputs.values());
const pianoInputs = inputs.filter((input) => input.state !== 'disconnected' && !isVirtualMidiInput(input));
const ignoredInputs = inputs.filter((input) => input.state !== 'disconnected' && isVirtualMidiInput(input));
_midiConnected = pianoInputs.length > 0;
for (const input of ignoredInputs) {
input.onmidimessage = null;
}
for (const input of pianoInputs) {
input.onmidimessage = ({ data }) => {
const [status, pitch, velocity] = data;
if ((status & 0xF0) === 0x90 && velocity > 0) handleMidiNote(pitch);
};
}
if (pianoInputs.length === 0) {
const ignoredNames = ignoredInputs.map((input) => input.name || 'Virtual MIDI port').join(', ');
setMidiBadge(
'midi-badge-disconnected',
'No piano detected',
ignoredNames ? `Ignored virtual MIDI ports: ${ignoredNames}` : 'MIDI piano status'
);
} else {
const name = pianoInputs[0].name || 'MIDI device';
const extra = pianoInputs.length > 1 ? ` (+${pianoInputs.length - 1})` : '';
setMidiBadge('midi-badge-connected', `Piano connected: ${name}${extra}`);
}
}
function enableMidi() {
if (!navigator.requestMIDIAccess) {
setMidiBadge('midi-badge-unsupported', 'MIDI not supported');
return;
}
navigator.requestMIDIAccess().then(access => {
refreshMidiInputs(access);
access.onstatechange = () => refreshMidiInputs(access);
}).catch(() => {
setMidiBadge('midi-badge-disconnected', 'MIDI access denied');
});
}
// ── Delete piece ─────────────────────────────────────────────────────────────
function confirmDialog({ title = 'Are you sure?', message = '', confirmText = 'Delete', cancelText = 'Cancel', danger = true } = {}) {
return new Promise((resolve) => {
const modal = document.getElementById('confirm-modal');
const titleEl = document.getElementById('confirm-modal-title');
const msgEl = document.getElementById('confirm-modal-message');
const okBtn = document.getElementById('confirm-modal-ok');
const cancelBtn = document.getElementById('confirm-modal-cancel');
if (!modal || !okBtn || !cancelBtn) { resolve(window.confirm(message)); return; }
titleEl.textContent = title;
msgEl.textContent = message;
okBtn.textContent = confirmText;
cancelBtn.textContent = cancelText;
okBtn.classList.toggle('btn-danger', !!danger);
okBtn.classList.toggle('btn-primary', !danger);
modal.style.display = 'flex';
const cleanup = (result) => {
modal.style.display = 'none';
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
modal.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
resolve(result);
};
const onOk = () => cleanup(true);
const onCancel = () => cleanup(false);
const onBackdrop = (ev) => { if (ev.target === modal) cleanup(false); };
const onKey = (ev) => {
if (ev.key === 'Escape') cleanup(false);
else if (ev.key === 'Enter') cleanup(true);
};
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
modal.addEventListener('click', onBackdrop);
document.addEventListener('keydown', onKey);
setTimeout(() => okBtn.focus(), 0);
});
}
async function deleteScore(e, name) {
e.stopPropagation();
if (state.guestMode || isGuestScoreName(name)) {
showGuestUpgradeToast('Demo pieces are built in. Create a free account to manage your own library.');
return;
}
if (_appConfig.auth_enabled) {
if (!await confirmDialog({ title: 'Delete piece?', message: `Delete "${formatName(name)}" from this account? This cannot be undone.` })) return;
await api(`/api/scores/${encodeURIComponent(name)}`, { method: 'DELETE' });
clearSheetHtmlCache(name);
localStorage.removeItem(`accompy_score_v2_${_authUser?.id || _authUser?.username || 'auth'}_${name}`);
await loadScoreList();
return;
}
if (!await confirmDialog({ title: 'Remove piece?', message: `Remove "${formatName(name)}" from this browser's list?`, confirmText: 'Remove' })) return;
clearSheetHtmlCache(name);
removeScoreFromLibrary(name);
await loadScoreList();
}
async function renameScore(e, name) {
e.stopPropagation();
if (state.guestMode || isGuestScoreName(name)) {
showGuestUpgradeToast('Create a free account to rename and manage your own library.');
return;
}
const currentTitle = state.scoreMeta?.[name]?.title || formatName(name);
const title = prompt('Rename piece', currentTitle)?.trim();
if (!title || title === currentTitle) return;
if (_appConfig.auth_enabled && !_authUser) {
setAuthStatus('Sign in first to rename pieces.', 'error');
return;
}
try {
if (_appConfig.auth_enabled) {
const result = await api(`/api/scores/${encodeURIComponent(name)}/title`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
state.scoreMeta[name] = { ...(state.scoreMeta[name] || {}), title: result.title || title };
} else {
state.scoreMeta[name] = { ...(state.scoreMeta[name] || {}), title };
}
await loadScoreList();
renderPlayPieceList();
} catch (error) {
alert(`Rename failed: ${readApiErrorMessage(error)}`);
}
}
window.renameScore = renameScore;
// ── Add piece modal ───────────────────────────────────────────────────────────
let _searchTimer = null;
function openAddModal() {
if (state.guestMode) {
showGuestUpgradeToast('Create a free account to upload personal sheet music and keep it in your library.');
return;
}
if (_appConfig.auth_enabled && !_authUser) {
setAuthStatus('Sign in first to add pieces.', 'error');
return;
}
document.getElementById('add-modal').style.display = 'flex';
document.getElementById('corpus-search').value = '';
document.getElementById('import-name').value = '';
document.getElementById('import-files').value = '';
updateImportFileLabel([]);
setImportStatus('No files selected.');
setImportProgress({ visible: false });
document.getElementById('search-results').innerHTML =
'<p class="search-hint">Type to search the built-in music library (535 pieces).</p>';
setTimeout(() => document.getElementById('corpus-search').focus(), 50);
}
function closeAddModal(e) {
if (e && e.target !== document.getElementById('add-modal')) return;
document.getElementById('add-modal').style.display = 'none';
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.getElementById('add-modal').style.display = 'none';
});
const savedScoreGridColumns = localStorage.getItem('accompy_score_grid_columns');
if (savedScoreGridColumns) {
state.scoreGridColumns = normalizedScoreGridColumns(savedScoreGridColumns);
}
state.keyboardLayoutMode = localStorage.getItem('accompy_keyboard_layout_mode') === 'mini' ? 'mini' : 'full';
applyScoreGridColumns(state.scoreGridColumns);
applyKeyboardLayoutMode(state.keyboardLayoutMode);
initPracticeSplitter();
applyTheme(localStorage.getItem('accompy_theme') || 'dark');
window.addEventListener('resize', () => {
resizeScorePreviews();
applyShellSplit(localStorage.getItem(SHELL_SPLIT_KEY));
applyPanelSplit(localStorage.getItem(PANEL_SPLIT_KEY));
applyPracticeSplit(localStorage.getItem(PRACTICE_SPLIT_KEY));
});
// ── Click-to-play on keyboard keys ───────────────────────────────────────────
function midiFromKeyboardEl(el) {
const refKey = el.closest?.('.ref-key');
if (refKey) {
const midi = parseInt(refKey.dataset.midi, 10);
return Number.isFinite(midi) ? { midi, highlightId: refKey.id } : null;
}
const kbKey = el.closest?.('.kb-key');
if (kbKey?.id?.startsWith('kbslot-')) {
const slotId = kbKey.id.slice('kbslot-'.length);
const slot = VISUAL_SLOTS.find((s) => s.id === slotId);
if (slot) return { midi: resolveKeyboardPitchesForPitchClass(slot.pitchClass), highlightId: kbKey.id };
}
return null;
}
const _keyboardSectionEl = document.getElementById('keyboard-section');
if (_keyboardSectionEl) {
_keyboardSectionEl.addEventListener('click', (e) => {
const hit = midiFromKeyboardEl(e.target);
if (!hit) return;
if (typeof Tone !== 'undefined' && Tone.start) Tone.start().catch(() => {});
if (state.playing && !state.paused && state.tracker) {
// Same flow as a physical keypress β€” plays the note and advances the tracker.
handleKeyboardInput(hit.midi);
return;
}
// Preview mode β€” piece isn't running; just play the note + flash the key.
const instrument = getInstrumentForPart(state.selectedPart ?? 0);
const pitches = Array.isArray(hit.midi) ? hit.midi : [hit.midi];
pitches.forEach((midi) => {
if (hasLoadedSampler(instrument)) {
playNote(midi, 0.6, instrument, { duration: 0.45, outputRole: 'solo', volume: state.playbackVolumes?.solo ?? 1 });
} else {
const soloVolume = normalizePlaybackVolume(state.playbackVolumes?.solo, 1, maxPlaybackVolumeForChannel('solo'));
playSynthNote(midi, 0.6 * soloVolume, instrument, { duration: 0.45, outputRole: 'solo' });
}
highlightKey(`refkey-${midi}`, true);
setTimeout(() => highlightKey(`refkey-${midi}`, false), 180);
});
highlightKey(hit.highlightId, true);
setTimeout(() => highlightKey(hit.highlightId, false), 180);
});
}
function onSearchInput() {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(runSearch, 300);
}
function setImportStatus(message, tone = 'muted') {
const status = document.getElementById('import-status');
if (!status) return;
status.textContent = message;
status.hidden = tone === 'muted';
status.style.color = tone === 'error'
? '#e05c5c'
: tone === 'success'
? 'var(--success)'
: 'var(--muted)';
}
function formatImportEta(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return '';
const rounded = Math.max(1, Math.round(seconds));
if (rounded < 60) return `about ${rounded}s remaining`;
const minutes = Math.floor(rounded / 60);
const extraSeconds = rounded % 60;
if (minutes < 60) {
return extraSeconds
? `about ${minutes}m ${extraSeconds}s remaining`
: `about ${minutes}m remaining`;
}
const hours = Math.floor(minutes / 60);
const extraMinutes = minutes % 60;
return extraMinutes
? `about ${hours}h ${extraMinutes}m remaining`
: `about ${hours}h remaining`;
}
function estimateImportEta(progress, startedAt, active) {
if (!active || !startedAt) return '';
const normalized = Math.max(0, Math.min(99, Number(progress) || 0));
const elapsedSeconds = (Date.now() - startedAt) / 1000;
if (elapsedSeconds < 3 || normalized < 3) return 'Estimating…';
const remainingSeconds = elapsedSeconds * ((100 - normalized) / normalized);
return formatImportEta(remainingSeconds) || 'Estimating…';
}
function setImportProgress({ visible = true, progress = 0, message = '', active = false, eta = '' } = {}) {
const container = document.getElementById('import-progress');
const fill = document.getElementById('import-progress-fill');
const label = document.getElementById('import-progress-label');
const percent = document.getElementById('import-progress-percent');
if (!container || !fill || !label || !percent) return;
const normalized = Math.max(0, Math.min(100, Number(progress) || 0));
container.hidden = !visible;
container.classList.toggle('active', !!active);
fill.style.width = `${normalized}%`;
label.textContent = message || 'Working…';
percent.textContent = eta ? `${Math.round(normalized)}% Β· ${eta}` : `${Math.round(normalized)}%`;
}
async function waitForImportJob(jobId, startedAt = Date.now()) {
let lastProgress = 0;
while (true) {
const job = await api(`/api/import/jobs/${encodeURIComponent(jobId)}`);
lastProgress = Math.max(lastProgress, Number(job.progress) || 0);
const baseMessage = job.message || 'Importing…';
const active = job.status === 'queued' || job.status === 'running';
setImportProgress({
visible: true,
progress: lastProgress,
message: baseMessage,
active,
eta: estimateImportEta(lastProgress, startedAt, active),
});
setImportStatus(baseMessage);
if (job.status === 'completed') return job.result;
if (job.status === 'failed') throw new Error(job.error || job.message || 'Import failed');
await new Promise((resolve) => setTimeout(resolve, 900));
}
}
function updateImportFileLabel(files) {
const label = document.getElementById('import-file-label');
if (!label) return;
if (!files.length) {
label.textContent = 'No files selected';
return;
}
label.textContent = files.length === 1
? files[0].name
: `${files.length} files selected`;
}
async function runSearch() {
const q = document.getElementById('corpus-search').value.trim();
const spinner = document.getElementById('search-spinner');
spinner.style.display = 'inline';
try {
const { results } = await api(`/api/corpus/search?q=${encodeURIComponent(q)}`);
renderSearchResults(results);
} catch (e) {
document.getElementById('search-results').innerHTML =
`<p class="search-hint" style="color:#e05c5c">Error: ${e.message}</p>`;
} finally {
spinner.style.display = 'none';
}
}
function renderSearchResults(results) {
const already = new Set(state.scores);
if (!results.length) {
document.getElementById('search-results').innerHTML =
'<p class="search-hint">No results found.</p>';
return;
}
document.getElementById('search-results').innerHTML = results.map(r => {
const safeName = r.path.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
const added = already.has(safeName);
return `
<div class="result-item${added ? ' done' : ''}" id="result-${safeName}">
<div class="result-info">
<div class="result-name">${r.composer} β€” ${r.title}</div>
<div class="result-path">${r.path}</div>
</div>
<button class="btn btn-primary" onclick="addPiece('${r.path}', '${safeName}')"
${added ? 'disabled' : ''}>
${added ? 'Added' : '+ Add'}
</button>
</div>`;
}).join('');
}
async function addPiece(corpusPath, safeName) {
const item = document.getElementById(`result-${safeName}`);
item.classList.add('adding');
item.querySelector('button').textContent = 'Converting…';
try {
await api('/api/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ corpus_path: corpusPath, name: safeName }),
});
item.classList.remove('adding');
item.classList.add('done');
item.querySelector('button').textContent = 'Added';
item.querySelector('button').disabled = true;
clearSheetHtmlCache(safeName);
addScoreToLibrary(safeName);
// Refresh score list in background
await loadScoreList();
} catch (e) {
item.classList.remove('adding');
item.querySelector('button').textContent = '+ Add';
alert(`Failed to convert: ${readApiErrorMessage(e)}`);
}
}
async function importScoreFiles() {
const fileInput = document.getElementById('import-files');
const nameInput = document.getElementById('import-name');
const button = document.getElementById('import-btn');
const files = [...(fileInput?.files || [])];
if (!files.length) {
setImportStatus('Choose a MusicXML/MuseScore file, LilyPond .ily files, a .zip bundle, one PDF, or page images first.', 'error');
return;
}
const form = new FormData();
files.forEach((file) => form.append('files', file));
form.append('name', (nameInput?.value || '').trim());
button.disabled = true;
const importStartedAt = Date.now();
const isDirectScore = files.length === 1 && /\.(xml|mxl|musicxml|mscx|mscz)$/i.test(files[0].name || '');
const isLilyPond = files.every((file) => /\.(ily|ly)$/i.test(file.name || ''))
|| (files.length === 1 && /\.zip$/i.test(files[0].name || ''));
setImportStatus(
isLilyPond
? 'Combining LilyPond parts and building the score…'
: isDirectScore
? 'Importing score file and building the score…'
: 'Running Audiveris and converting the score…'
);
setImportProgress({
visible: true,
progress: isDirectScore || isLilyPond ? 18 : 8,
message: isLilyPond
? 'Uploading LilyPond parts…'
: isDirectScore
? 'Uploading score file…'
: 'Uploading for Audiveris…',
active: true,
eta: 'Estimating…',
});
try {
const response = await fetch('/api/import/start', { method: 'POST', body: form });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.detail || 'Import failed');
}
const result = await waitForImportJob(payload.id, importStartedAt);
if (!result?.name) throw new Error('Import completed without a score name.');
clearSheetHtmlCache(result.name);
addScoreToLibrary(result.name);
setImportProgress({ visible: true, progress: 100, message: 'Import complete', active: false });
setImportStatus(`Imported ${formatName(result.name)}.`, 'success');
await loadScoreList();
setTimeout(() => closeAddModal(), 3000);
} catch (error) {
setImportProgress({ visible: true, progress: 100, message: error.message || 'Import failed.', active: false });
setImportStatus(error.message || 'Import failed.', 'error');
} finally {
button.disabled = false;
}
}
function initImportControls() {
const importFiles = document.getElementById('import-files');
const dropzone = document.getElementById('import-dropzone');
const importBtn = document.getElementById('import-btn');
importFiles?.addEventListener('change', (event) => {
const files = [...(event.target.files || [])];
updateImportFileLabel(files);
if (!files.length) {
setImportStatus('No files selected.');
setImportProgress({ visible: false });
return;
}
const label = files.length === 1
? files[0].name
: `${files.length} files selected`;
setImportStatus(label);
setImportProgress({ visible: false });
});
dropzone?.addEventListener('click', () => importFiles?.click());
dropzone?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
importFiles?.click();
});
['dragenter', 'dragover'].forEach((eventName) => {
dropzone?.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach((eventName) => {
dropzone?.addEventListener(eventName, () => {
dropzone.classList.remove('drag-over');
});
});
dropzone?.addEventListener('drop', (event) => {
event.preventDefault();
if (!importFiles || !event.dataTransfer?.files?.length) return;
importFiles.files = event.dataTransfer.files;
importFiles.dispatchEvent(new Event('change', { bubbles: true }));
});
importBtn?.addEventListener('click', (event) => {
event.preventDefault();
importScoreFiles();
});
}
window.importScoreFiles = importScoreFiles;
window.setPlaybackVolume = setPlaybackVolume;
window.onOutputDeviceChange = onOutputDeviceChange;
window.setSheetDisplayMode = setSheetDisplayMode;
window.toggleSheetDisplayMode = toggleSheetDisplayMode;
window.toggleAdaptiveTempo = toggleAdaptiveTempo;
window.toggleFollowMode = toggleFollowMode;
window.loadScorePreviewOnDemand = loadScorePreviewOnDemand;
function initTempoControls() {
const input = document.getElementById('bpm-input');
if (!input || input.dataset.bound === '1') return;
input.dataset.bound = '1';
input.addEventListener('input', () => {
if (state.playing) return;
state.practiceRightHand = null;
state.practiceLeftHand = null;
buildKeyboard(getRightHand());
updateNextKey(getRightHand(), 0);
renderNoteHighway();
saveCurrentScorePreferences();
});
}
// ── Init ──────────────────────────────────────────────────────────────────────
initLatencyControls();
initAdaptiveTempoControl();
initFollowModeControl();
initPlaybackVolumeControls();
initOutputRoutingControls();
initSheetDisplayMode();
initImportControls();
initTempoControls();
initKeyboardLayoutToggle();
initPlaySidebar();
initHorizontalSplitters();
onNoiseGateChange(document.getElementById('noise-gate')?.value || '1');
setLatencyCompensation(localStorage.getItem('accompy_latency_comp_ms') || '0', { persistScore: false });
initAppConfig().then(async () => {
const route = parseAppRoute();
const canUseLibrary = state.guestMode || !_appConfig.auth_enabled || _authUser;
if (canUseLibrary && route.mode === 'score') {
await Promise.all([
loadScoreList(),
openRouteFromLocation({ replace: true }),
]);
} else {
await openRouteFromLocation({ replace: true });
}
});