// ── 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) =>
`${escapeXml(part.name || `Part ${index + 1}`)}`
).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 `${measures}`;
}).join('');
return `
${escapeXml(score.title)}
${partList}
${partXml}
`;
}
function guestMeasureXml(events, start, end, measureIndex, partIndex) {
const attrs = measureIndex === 0
? `40${partIndex === 0 ? 'G' : 'F'}${partIndex === 0 ? '2' : '4'}`
: '';
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 `${attrs}${notes.join('') || guestRestXml(end - start)}`;
}
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 `${chord ? '' : ''}${step}${alter ? `${alter}` : ''}${octave}${duration}${type}`;
}
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 `${duration}${type}`;
}
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) => ({
'<': '<',
'>': '>',
'&': '&',
"'": ''',
'"': '"',
}[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 = '
Sign in to load your score library.
';
}
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 = ['']
.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 ``;
}));
select.innerHTML = options.join('');
if (selected && !outputs.some((device) => device.deviceId === selected)) {
select.insertAdjacentHTML('beforeend', ``);
}
});
}
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 = `
Your library is empty
Click + Add piece at the top right to get started!
`;
renderPlayPieceList();
return;
}
grid.innerHTML = items.map(({ name, has_sheet }) => `
${has_sheet === false
? `
`
: `
${scorePreviewPlaceholder(name)}
`
}
${escapeHtml(displayNameForScore(name))}
${name}
`).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) => `
`).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 `
${score.primary ? 'Guided demo' : 'Sandbox'}
${score.title.split(' ')[0]}
`;
}).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 = 'No pieces in your library yet.
';
return;
}
root.innerHTML = names.map((name) => {
const active = name === currentName;
return `
`;
}).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('