// ── 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 ? `` : `` }

${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 `
${completed ? 'Completed demo' : (score.primary ? 'Start here' : 'Included demo')}

${escapeHtml(score.title)}

${escapeHtml(score.subtitle)}
`; }).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(' ${escapeHtml(displayNameForScore(name).slice(0, 1) || 'N')} `; } function stopScorePreviewLoader() { if (_scorePreviewTimer) { window.clearTimeout(_scorePreviewTimer); _scorePreviewTimer = null; } if (_scorePreviewRun?.observer) _scorePreviewRun.observer.disconnect(); _scorePreviewRun = null; } function scheduleScorePreviewLoader() { if (_scorePreviewTimer) window.clearTimeout(_scorePreviewTimer); _scorePreviewTimer = window.setTimeout(() => { _scorePreviewTimer = null; initScorePreviewLoader(); }, SCORE_PREVIEW_START_DELAY_MS); } function queueScorePreview(run, preview) { if (run !== _scorePreviewRun || !preview || preview.dataset.previewState) return; preview.dataset.previewState = 'queued'; run.queue.push(preview); pumpScorePreviewQueue(run); } function pumpScorePreviewQueue(run) { if (run !== _scorePreviewRun) return; while (run.active < SCORE_PREVIEW_CONCURRENCY && run.queue.length) { const preview = run.queue.shift(); if (!preview?.isConnected) continue; run.active += 1; loadScorePreview(preview, run).finally(() => { if (run !== _scorePreviewRun) return; run.active = Math.max(0, run.active - 1); pumpScorePreviewQueue(run); }); } } function loadScorePreviewOnDemand(card) { const preview = card?.querySelector?.('[data-score-preview]'); if (!preview || preview.dataset.previewState) return; if (!_scorePreviewRun) _scorePreviewRun = { active: 0, queue: [], observer: null }; queueScorePreview(_scorePreviewRun, preview); } async function loadScorePreview(preview, run) { const name = preview.dataset.scoreName || ''; if (!name) return; preview.dataset.previewState = 'loading'; preview.classList.add('score-preview-loading'); try { const entry = await fetchSheetHtmlEntry(name, 'base', ''); if (run !== _scorePreviewRun || !preview.isConnected) return; const frame = document.createElement('iframe'); frame.className = 'score-preview-frame'; frame.loading = 'lazy'; frame.tabIndex = -1; frame.setAttribute('aria-hidden', 'true'); frame.addEventListener('load', () => sanitizeSheetFrame(frame), { once: true }); frame.src = entry.url; preview.replaceChildren(frame); preview.classList.remove('score-preview-lite', 'score-preview-loading', 'empty'); preview.dataset.previewState = 'loaded'; } catch (error) { console.warn(`Could not load sheet preview for ${name}:`, error); if (run !== _scorePreviewRun || !preview.isConnected) return; preview.classList.remove('score-preview-lite', 'score-preview-loading'); preview.classList.add('empty'); preview.innerHTML = '
No sheet preview
'; preview.dataset.previewState = 'empty'; } } function initScorePreviewLoader() { stopScorePreviewLoader(); const previews = [...document.querySelectorAll('[data-score-preview]')]; if (!previews.length) return; const run = { active: 0, queue: [], observer: null }; _scorePreviewRun = run; if (!('IntersectionObserver' in window)) { previews.forEach((preview) => queueScorePreview(run, preview)); return; } run.observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; run.observer.unobserve(entry.target); queueScorePreview(run, entry.target); }); }, { rootMargin: '100px 0px', threshold: 0.01 }); previews.forEach((preview) => run.observer.observe(preview)); } function updateSheetDisplayModeUI() { const selectedMode = state.sheetDisplayMode === 'selected'; const fullBtn = document.getElementById('sheet-display-full'); const selectedBtn = document.getElementById('sheet-display-selected'); if (fullBtn) { fullBtn.classList.toggle('active', !selectedMode); fullBtn.setAttribute('aria-pressed', selectedMode ? 'false' : 'true'); fullBtn.title = 'Show the complete score.'; } if (selectedBtn) { selectedBtn.classList.toggle('active', selectedMode); selectedBtn.setAttribute('aria-pressed', selectedMode ? 'true' : 'false'); selectedBtn.title = 'Show only the selected practice parts.'; } } async function setSheetDisplayMode(mode) { const normalized = mode === 'selected' ? 'selected' : 'full'; if (state.sheetDisplayMode === normalized) { updateSheetDisplayModeUI(); return; } state.sheetDisplayMode = normalized; localStorage.setItem(SHEET_DISPLAY_MODE_KEY, state.sheetDisplayMode); updateSheetDisplayModeUI(); await renderScoreSheet(); } async function toggleSheetDisplayMode() { await setSheetDisplayMode(state.sheetDisplayMode === 'selected' ? 'full' : 'selected'); } function initSheetDisplayMode() { const saved = localStorage.getItem(SHEET_DISPLAY_MODE_KEY); state.sheetDisplayMode = saved === 'selected' ? 'selected' : 'full'; updateSheetDisplayModeUI(); } function filterMusicXmlParts(xmlText, partIndices) { if (!xmlText || !partIndices?.length) return xmlText; try { const doc = new DOMParser().parseFromString(xmlText, 'application/xml'); if (doc.querySelector('parsererror')) return xmlText; const partList = [...doc.documentElement.children].find((child) => child.localName === 'part-list'); if (!partList) return xmlText; const scoreParts = [...partList.children].filter((child) => child.localName === 'score-part'); const selectedIds = new Set( partIndices .map((idx) => scoreParts[idx]?.getAttribute('id')) .filter(Boolean) ); if (!selectedIds.size) return xmlText; [...partList.children].forEach((child) => { if (child.localName === 'part-group') { partList.removeChild(child); } else if (child.localName === 'score-part' && !selectedIds.has(child.getAttribute('id'))) { partList.removeChild(child); } }); [...doc.documentElement.children].forEach((child) => { if (child.localName === 'part' && !selectedIds.has(child.getAttribute('id'))) { doc.documentElement.removeChild(child); } }); return new XMLSerializer().serializeToString(doc); } catch (error) { console.warn('Could not filter MusicXML parts:', error); return xmlText; } } function updateFingeringProgressUI() { const root = document.getElementById('sheet-fingering-progress'); const fill = document.getElementById('sheet-fingering-progress-fill'); const message = document.getElementById('sheet-fingering-progress-message'); const value = document.getElementById('sheet-fingering-progress-value'); if (!root || !fill || !message || !value) return; const job = state.fingeringJob; if (!job || !['queued', 'running'].includes(job.status)) { root.style.display = 'none'; fill.style.width = '0%'; message.textContent = 'Generating fingering'; value.textContent = '0%'; return; } const progress = Math.max(0, Math.min(100, Math.round(Number(job.progress || 0)))); root.style.display = 'block'; fill.style.width = `${progress}%`; message.textContent = job.message || 'Generating fingering'; value.textContent = `${progress}%`; } function updateSheetFingeringStatus() { const badge = document.getElementById('sheet-fingering-status'); const generateBtn = document.getElementById('sheet-generate-fingering-btn'); const toggleBtn = document.getElementById('sheet-toggle-fingering-btn'); if (!badge || !generateBtn || !toggleBtn) return; const fingering = state.current?.fingering; const job = state.fingeringJob; const hideBadge = () => { badge.style.display = 'none'; badge.textContent = ''; badge.title = ''; delete badge.dataset.state; }; if (!fingering) { generateBtn.style.display = 'none'; toggleBtn.style.display = 'none'; hideBadge(); return; } const generating = !!job && ['queued', 'running'].includes(job.status); if (generating) { const progress = Math.max(0, Math.min(100, Math.round(Number(job.progress || 0)))); badge.style.display = 'inline-flex'; badge.dataset.state = 'ok'; badge.textContent = 'Generating fingering'; badge.title = job.message || ''; generateBtn.style.display = 'inline-flex'; generateBtn.disabled = true; generateBtn.textContent = progress > 0 ? `Generating ${progress}%` : 'Generating…'; toggleBtn.style.display = 'none'; return; } const applied = !!fingering.applied; const eligible = !!fingering.eligible; const available = fingering.available !== false; const showingFingered = currentSheetVariant() === 'fingered'; if (applied) { badge.style.display = 'inline-flex'; badge.dataset.state = 'ok'; badge.textContent = showingFingered ? 'Showing fingering' : 'Fingering ready'; badge.title = fingering.annotations ? `${fingering.annotations} annotated notes` : ''; generateBtn.style.display = 'none'; toggleBtn.style.display = 'inline-flex'; toggleBtn.disabled = false; toggleBtn.textContent = showingFingered ? 'Hide fingering' : 'Show fingering'; return; } toggleBtn.style.display = 'none'; if (eligible && available) { badge.style.display = 'inline-flex'; badge.dataset.state = 'ok'; badge.textContent = 'Fingering available'; badge.title = 'Generate a beginner fingering version for the piano part.'; generateBtn.style.display = 'inline-flex'; generateBtn.disabled = false; generateBtn.textContent = 'Generate fingering'; return; } generateBtn.style.display = 'none'; if (!eligible || !available) { badge.style.display = 'inline-flex'; badge.dataset.state = 'warning'; badge.textContent = 'Fingering unavailable'; badge.title = !eligible ? 'Automatic fingering is available for piano or keyboard parts.' : 'PianoPlayer is not installed in the backend environment.'; return; } hideBadge(); } async function pollFingeringJob(scoreName, jobId) { clearFingeringJobPolling(); const tick = async () => { if (!state.current || state.current.name !== scoreName) { clearFingeringJobPolling(); return; } try { const job = await api(`/api/scores/${encodeURIComponent(scoreName)}/fingering/jobs/${encodeURIComponent(jobId)}`); if (!state.current || state.current.name !== scoreName) return; if (job.status === 'completed') { clearFingeringJobPolling(); setFingeringJob(null); clearSheetHtmlCache(scoreName); state.sheetVariant = 'fingered'; await openScore(scoreName, { preserveSelectedPart: true, preserveSheetVariant: true, reveal: false, }); return; } if (job.status === 'failed') { clearFingeringJobPolling(); setFingeringJob(null); alert(`Failed to generate fingering: ${job.error || job.message || 'Unknown error.'}`); return; } setFingeringJob(job); _fingeringJobPollTimer = setTimeout(tick, 500); } catch (error) { clearFingeringJobPolling(); setFingeringJob(null); alert(`Failed to check fingering progress: ${readApiErrorMessage(error)}`); } }; await tick(); } async function renderScoreSheet() { const data = state.current; if (!data) return; const renderSeq = ++_sheetRenderSeq; const scoreName = data.name; const frame = document.getElementById('sheet-frame'); const placeholder = document.getElementById('sheet-placeholder'); const musicXmlFallback = document.getElementById('sheet-musicxml-fallback'); const assets = currentSheetAssets(); const sheetPartIndices = selectedSheetPartIndices(); const sheetPartsParam = sheetPartIndices.join(','); musicXmlFallback.style.display = 'none'; musicXmlFallback.innerHTML = ''; frame.style.display = 'none'; frame.onload = null; frame.removeAttribute('src'); frame.srcdoc = ''; _sheetMeasureEls = []; _sheetHighlightRect?.remove(); _sheetHighlightRect = null; _sheetHighlightIndex = -1; placeholder.style.display = 'none'; placeholder.textContent = ''; state.sheetSource = { name: data.name, variant: assets.variant, hasSheet: assets.hasSheet, musicXml: assets.musicXml, parts: sheetPartIndices, }; updateSheetFingeringStatus(); updateFingeringProgressUI(); updateSheetDisplayModeUI(); const finalizeFrame = () => { if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return; sanitizeSheetFrame(frame); initializeSheetHighlighting(); }; const showSheetHtmlEntry = (entry) => { if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return false; if (!entry?.url) return false; frame.addEventListener('load', finalizeFrame, { once: true }); frame.removeAttribute('srcdoc'); frame.src = entry.url; frame.style.display = 'block'; placeholder.style.display = 'none'; requestAnimationFrame(finalizeFrame); return true; }; if (assets.hasSheet && !data.guest) { try { const entry = await fetchSheetHtmlEntry(data.name, assets.variant, sheetPartsParam); if (showSheetHtmlEntry(entry)) return; } catch (error) { console.warn(`Sheet render failed for ${data.name} (${assets.variant}, parts=${sheetPartsParam || 'full'})`, error); if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return; } } frame.removeAttribute('src'); frame.srcdoc = ''; let sourceXml = assets.musicXml || null; if (!sourceXml && assets.hasSheet && !data.guest) { try { sourceXml = await fetchSheetMusicXml(data.name, assets.variant); if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return; if (assets.variant === 'fingered') { state.current.fingered_musicxml_source = sourceXml; } else { state.current.musicxml_source = sourceXml; } state.sheetSource.musicXml = sourceXml; } catch (error) { console.warn(`MusicXML fallback source failed for ${data.name} (${assets.variant})`, error); if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return; } } const fallbackXml = sourceXml ? filterMusicXmlParts(sourceXml, sheetPartIndices) : null; const renderedFallback = fallbackXml ? await renderMusicXmlFallback(fallbackXml) : false; if (renderSeq !== _sheetRenderSeq || state.current?.name !== scoreName) return; if (renderedFallback) { placeholder.style.display = 'none'; placeholder.textContent = ''; } else { placeholder.style.display = 'block'; placeholder.textContent = sheetPartsParam ? 'Selected-parts sheet view could not be rendered. Switch to Full or change the selected parts.' : 'Sheet music could not be rendered for this score.'; } clearSheetHighlight(); } async function toggleSheetFingering() { if (!state.current?.fingering?.applied) return; state.sheetVariant = currentSheetVariant() === 'fingered' ? 'base' : 'fingered'; await renderScoreSheet(); } async function generateSheetFingering() { const current = state.current; const button = document.getElementById('sheet-generate-fingering-btn'); if (!current || !current.fingering?.eligible || current.fingering?.applied || !button || state.fingeringJob) return; if (state.guestMode || current.guest) { showGuestUpgradeToast('Create a free account to generate and save fingering for your own pieces.'); return; } if (state.playing) stopPlaying(); try { const job = await api(`/api/scores/${encodeURIComponent(current.name)}/fingering/generate`, { method: 'POST', }); setFingeringJob(job); await pollFingeringJob(current.name, job.id); } catch (error) { alert(`Failed to generate fingering: ${readApiErrorMessage(error)}`); } } async function sheetDownload() { const src = state.sheetSource; if (!src || !src.name) { alert('Open a score first.'); return; } if (state.guestMode || state.current?.guest) { showGuestUpgradeToast('Create a free account to export and save your own work.'); return; } if (src.musicXml) { triggerDownload( new Blob([src.musicXml], { type: 'application/vnd.recordare.musicxml+xml' }), `${src.name}.musicxml` ); return; } if (src.hasSheet) { try { const query = new URLSearchParams({ variant: src.variant || 'base' }); if (Array.isArray(src.parts) && src.parts.length) query.set('parts', src.parts.join(',')); const resp = await fetch( `/api/scores/${encodeURIComponent(src.name)}/sheet?${query.toString()}`, { cache: 'no-store' } ); if (!resp.ok) throw new Error(await resp.text()); const html = await resp.text(); triggerDownload(new Blob([html], { type: 'text/html' }), `${src.name}.sheet.html`); } catch (err) { alert(`Download failed: ${readApiErrorMessage(err)}`); } return; } alert('No sheet source available to download.'); } // ── Play screen ─────────────────────────────────────────────────────────────── async function fetchScore(name) { const guestScore = guestScoreByName(name); if (guestScore) return guestScorePayload(guestScore); return api(`/api/scores/${encodeURIComponent(name)}`); } function renderGuestTips(data) { const card = document.getElementById('guest-tips-card'); const list = document.getElementById('guest-tips-list'); if (!card || !list) return; const tips = data?.guest ? (data.tips || []) : []; card.style.display = tips.length ? 'block' : 'none'; list.innerHTML = tips.map((tip) => `
  • ${escapeHtml(tip)}
  • `).join(''); renderGuestAnnotations(); } function escapeHtml(value) { const div = document.createElement('div'); div.textContent = String(value ?? ''); return div.innerHTML; } function guestAnnotationsForCurrent() { if (!state.current?.guest) return []; const progress = loadGuestProgress(); return Array.isArray(progress[state.current.name]?.annotations) ? progress[state.current.name].annotations : []; } function renderGuestAnnotations() { const root = document.getElementById('guest-annotation-list'); if (!root) return; const annotations = guestAnnotationsForCurrent(); root.innerHTML = annotations.length ? annotations.map((annotation) => `${escapeHtml(annotation)}`).join('') : 'No demo annotations yet.'; } function addGuestAnnotation(text) { if (!state.current?.guest) return; const progress = loadGuestProgress(); const item = progress[state.current.name] || {}; const annotations = Array.isArray(item.annotations) ? item.annotations : []; annotations.push(text); progress[state.current.name] = { ...item, annotations: annotations.slice(-5) }; saveGuestProgress(progress); renderGuestAnnotations(); } window.addGuestAnnotation = addGuestAnnotation; function setPieceLoadingUI(loading, name = '') { const placeholder = document.getElementById('sheet-placeholder'); const frame = document.getElementById('sheet-frame'); const fallback = document.getElementById('sheet-musicxml-fallback'); const keyboardLoading = document.getElementById('keyboard-loading'); const label = name ? `Loading ${displayNameForScore(name)}...` : 'Loading piece...'; if (placeholder) { placeholder.style.display = loading ? 'block' : placeholder.style.display; if (loading) placeholder.textContent = label; } if (frame && loading) { frame.style.display = 'none'; frame.removeAttribute('src'); frame.srcdoc = ''; } if (fallback && loading) { fallback.style.display = 'none'; fallback.innerHTML = ''; } if (keyboardLoading) { keyboardLoading.classList.remove('error'); keyboardLoading.textContent = 'Loading piece...'; keyboardLoading.style.display = loading ? 'grid' : 'none'; } document.getElementById('progress-fill').style.width = loading ? '0%' : document.getElementById('progress-fill').style.width; if (loading) { document.getElementById('next-note-display').textContent = '—'; document.getElementById('beat-val').textContent = '—'; document.getElementById('tempo-val').textContent = '—'; updatePracticePanelStatus(); } } function setPieceErrorUI(name, error) { const placeholder = document.getElementById('sheet-placeholder'); const keyboardLoading = document.getElementById('keyboard-loading'); const message = readApiErrorMessage(error); if (placeholder) { placeholder.style.display = 'block'; placeholder.textContent = `Could not load ${displayNameForScore(name)}. ${message}`; } if (keyboardLoading) { keyboardLoading.classList.add('error'); keyboardLoading.textContent = 'Could not load piece'; keyboardLoading.style.display = 'grid'; } document.getElementById('start-btn').disabled = true; document.getElementById('stop-btn').disabled = true; } async function openScore(name, options = {}) { const preserveSelectedPart = !!options.preserveSelectedPart; const preserveSheetVariant = !!options.preserveSheetVariant; const updateUrl = options.updateUrl !== false; const reveal = options.reveal !== false; if (state.playing && state.current?.name !== name) stopPlaying(); clearFingeringJobPolling(); if (state.current?.name !== name) setFingeringJob(null); if (reveal) showScreen('play-screen'); setPieceLoadingUI(true, name); let loaded = false; try { const previousParts = preserveSelectedPart ? selectedPartIndices() : null; const previousVariant = preserveSheetVariant ? state.sheetVariant : 'base'; const data = await fetchScore(name); const prefs = loadScorePreferences(data.name); state.current = data; state.scoreMeta[data.name] = { ...(state.scoreMeta[data.name] || {}), title: data.title || displayNameForScore(data.name), }; renderGuestTips(data); state.finishedPlayback = false; state.practiceRightHand = null; state.practiceLeftHand = null; state.practiceStartBeat = 0; const parts = data.parts || []; setSelectedPartIndices(parts.length ? (previousParts || defaultSelectedPartsForScore(data, prefs)) : [0]); if (!applySavedLatencyForCurrentSelection(prefs)) { setLatencyCompensation(localStorage.getItem('accompy_latency_comp_ms') || '0', { persistScore: false }); } state.partInstruments = prefs.partInstruments && typeof prefs.partInstruments === 'object' ? { ...prefs.partInstruments } : {}; state.sheetVariant = (previousVariant === 'fingered' && data.fingering?.applied) ? 'fingered' : 'base'; _stopMic(); if (Number.isFinite(Number(prefs.bpm))) { const bpmInput = document.getElementById('bpm-input'); if (bpmInput) bpmInput.value = String(Math.max(20, Math.min(300, Number(prefs.bpm)))); } document.getElementById('progress-fill').style.width = '0%'; document.getElementById('next-note-display').textContent = '—'; document.getElementById('beat-val').textContent = '—'; document.getElementById('tempo-val').textContent = '—'; updatePracticePanelStatus(); resetSheetView(); await renderScoreSheet(); // Part picker const picker = document.getElementById('part-picker'); const btns = document.getElementById('part-buttons'); if (parts.length > 0) { btns.innerHTML = parts.map((p, i) => { const instr = getInstrumentForPart(i); return `
    `; }).join(''); picker.style.display = 'block'; updatePartSelectionUI(); } else { picker.style.display = 'none'; } setInputMode(prefs.inputMode === 'mic' ? 'mic' : 'keyboard', { persist: false }); if (prefs.keyboardLayoutMode) applyKeyboardLayoutMode(prefs.keyboardLayoutMode); buildKeyboard(getRightHand()); updateNextKey(getRightHand(), 0); renderNoteHighway(); syncExpectedMicNote(); setTimeout(() => { if (state.current?.name === data.name) preloadCurrentScoreInstruments(); }, 500); renderPlayPieceList(); if (updateUrl) updateScoreUrl(name); applyPracticeSplit(localStorage.getItem(PRACTICE_SPLIT_KEY)); state.paused = false; state.pausedBeat = 0; state.pausedBps = 1; document.getElementById('start-btn').disabled = false; document.getElementById('start-btn').textContent = '▶ Start'; document.getElementById('start-btn').classList.add('btn-primary'); document.getElementById('stop-btn').disabled = true; markScoreOpened(name); loaded = true; } catch (error) { console.warn(`Could not open score "${name}":`, error); setPieceLoadingUI(false); setPieceErrorUI(name, error); return; } finally { if (loaded) setPieceLoadingUI(false); } } async function openRouteFromLocation(options = {}) { const route = parseAppRoute(); if (route.mode === 'landing') { if (state.guestMode || _authUser || !_appConfig.auth_enabled) { await goToLibrary(!!options.replace); } else { if (options.replace) writeUrl('/', true, { route: 'landing' }); setListRoute('landing'); } return; } if (route.mode === 'signin') { if (state.guestMode || _authUser) { await goToLibrary(true); } else { if (options.replace) writeUrl('/signin', true, { route: 'signin' }); setListRoute('signin'); } return; } if (route.mode === 'home') { if (_appConfig.auth_enabled && !_authUser && !state.guestMode) { goToSignIn(true); return; } await goToLibrary(!!options.replace); return; } const name = route.score; if (_appConfig.auth_enabled && !_authUser && !state.guestMode) { goToSignIn(true); return; } showScreen('play-screen'); try { await openScore(name, { updateUrl: false }); if (options.replace) updateScoreUrl(name, true); } catch (error) { console.warn(`Could not open score from route "${name}":`, error); await goToLibrary(true); } } window.addEventListener('popstate', () => { openRouteFromLocation({ replace: false }); }); async function selectPart(idx) { const selected = selectedPartIndices(); const next = selected.includes(idx) ? selected.filter((partIdx) => partIdx !== idx) : [...selected, idx]; setSelectedPartIndices(next.length ? next : selected); state.practiceRightHand = null; state.practiceLeftHand = null; updatePartSelectionUI(); buildKeyboard(getRightHand()); updateNextKey(getRightHand(), 0); renderNoteHighway(); syncExpectedMicNote(); applySavedLatencyForCurrentSelection(); if (state.sheetDisplayMode === 'selected') await renderScoreSheet(); preloadCurrentScoreInstruments(); saveCurrentScorePreferences({ selectedPart: state.selectedPart ?? 0, selectedParts: selectedPartIndices(), }); } function getInstrumentForPart(idx) { if (state.partInstruments[idx]) return state.partInstruments[idx]; const part = state.current?.parts?.[idx]; const storedInstrument = part?.instrument || ''; const inferredInstrument = inferInstrumentFromPartName(part?.name); if ( inferredInstrument && (!storedInstrument || ['piano', 'horn', 'cello', 'strings', 'voice'].includes(storedInstrument)) ) { return inferredInstrument; } return storedInstrument || 'piano'; } async function changeInstrument(partIdx, instrument) { state.partInstruments[partIdx] = instrument; if (state.current?.parts?.[partIdx]) { state.current.parts[partIdx].instrument = instrument; } if (state.current?.guest) { preloadCurrentScoreInstruments(); saveCurrentScorePreferences(); return; } try { await api(`/api/scores/${state.current.name}/instrument`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ part_index: partIdx, instrument }), }); } catch { /* non-critical — change is applied in-memory either way */ } preloadCurrentScoreInstruments(); saveCurrentScorePreferences(); } function getRightHandRaw() { const parts = state.current?.parts; if (parts && parts.length > 0) return mergePracticeEventsFromParts(selectedPartIndices()); return state.current?.right_hand || []; } function getRightHand() { if (state.practiceRightHand) return state.practiceRightHand; return expandDynamicTremolos(getRightHandRaw(), currentBps()); } function getLeftHandRaw() { const parts = state.current?.parts; if (!parts || parts.length === 0) return state.current?.left_hand || []; const selected = new Set(selectedPartIndices()); const left = []; parts.forEach((p, i) => { if (selected.has(i)) return; const instrument = getInstrumentForPart(i); (p.notes || []).forEach((event) => { const merged = [eventPitches(event), event[1], eventDuration(event)]; const pedalRelease = eventPedalRelease(event); if (pedalRelease !== null) merged.push(pedalRelease); const metadata = Array.isArray(event) ? event.slice(4).filter((item) => item !== undefined && item !== null) : []; metadata.forEach((item) => appendEventMetadata(merged, item)); appendEventMetadata(merged, { type: 'instrument', instrument }); left.push(merged); }); }); left.sort((a, b) => a[1] - b[1]); return left; } function getLeftHand() { if (state.practiceLeftHand) return state.practiceLeftHand; return expandDynamicTremolos(getLeftHandRaw(), currentBps()); } function readSheetMeasureMap(doc, measureCount) { const fallback = Array.from({ length: measureCount }, (_unused, idx) => ({ startMeasure: idx, endMeasure: idx + 1, })); const script = doc?.getElementById('notepilot-measure-map'); if (!script?.textContent) return fallback; try { const parsed = JSON.parse(script.textContent); if (!Array.isArray(parsed)) return fallback; return fallback.map((entry, idx) => { const raw = parsed[idx] || {}; const startMeasure = Number(raw.startMeasure); const endMeasure = Number(raw.endMeasure); if (!Number.isFinite(startMeasure) || !Number.isFinite(endMeasure) || endMeasure <= startMeasure) { return entry; } return { startMeasure: Math.max(0, Math.floor(startMeasure)), endMeasure: Math.max(Math.floor(startMeasure) + 1, Math.floor(endMeasure)), }; }); } catch (error) { console.warn('Could not parse sheet measure map:', error); return fallback; } } function initializeSheetHighlighting() { const frame = document.getElementById('sheet-frame'); const doc = frame?.contentDocument; if (!doc) return; _sheetMeasureEls = [...doc.querySelectorAll('g.measure, g[class~="measure"], g[class*=" measure "]')]; _sheetMeasureMap = readSheetMeasureMap(doc, _sheetMeasureEls.length); _sheetHighlightRect = null; _sheetHighlightIndex = -1; const svg = doc.querySelector('svg.definition-scale'); if (!svg || !_sheetMeasureEls.length) return; let style = doc.getElementById('accompy-measure-highlight-style'); if (!style) { style = doc.createElement('style'); style.id = 'accompy-measure-highlight-style'; style.textContent = ` .accompy-measure-highlight { fill: rgba(98, 255, 140, 0.96); stroke: rgba(190, 255, 205, 0.95); stroke-width: 4px; rx: 10px; ry: 10px; filter: drop-shadow(0 0 10px rgba(98, 255, 140, 0.65)); pointer-events: none; } .accompy-clickable-measure { cursor: pointer; } .accompy-clickable-measure:hover { filter: drop-shadow(0 0 7px rgba(37, 99, 255, 0.55)); } `; doc.head?.appendChild(style); } _sheetMeasureEls.forEach((measureEl, idx) => { const mapEntry = _sheetMeasureMap[idx] || { startMeasure: idx, endMeasure: idx + 1 }; const label = mapEntry.endMeasure > mapEntry.startMeasure + 1 ? `Jump to measures ${mapEntry.startMeasure + 1}-${mapEntry.endMeasure}` : `Jump to measure ${mapEntry.startMeasure + 1}`; measureEl.classList.add('accompy-clickable-measure'); measureEl.setAttribute('role', 'button'); measureEl.setAttribute('tabindex', '0'); measureEl.setAttribute('aria-label', label); if (measureEl.dataset.notepilotJumpBound === '1') return; const jump = (event) => { event.preventDefault(); event.stopPropagation(); jumpToSheetMeasure(idx); }; measureEl.addEventListener('click', jump); measureEl.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; jump(event); }); measureEl.dataset.notepilotJumpBound = '1'; }); const rightHand = state.playing ? getRightHand() : []; const beat = _pendingSheetHighlightBeat ?? (rightHand.length ? currentGuideBeat(rightHand) : 0); updateSheetHighlight(beat, { remember: false }); } function clearSheetHighlight() { _sheetMeasureEls = []; _sheetMeasureMap = []; _sheetHighlightRect?.remove(); _sheetHighlightRect = null; _sheetHighlightIndex = -1; _pendingSheetHighlightBeat = null; } function originalMeasureIndexForBeat(beat) { const starts = state.current?.measure_beats || []; if (!starts.length) return -1; let idx = 0; for (let i = 0; i < starts.length; i++) { if (starts[i] <= beat + 0.001) idx = i; else break; } return idx; } function visibleMeasureIndexForOriginalIndex(originalIndex) { if (originalIndex < 0) return -1; const idx = _sheetMeasureMap.findIndex((entry) => ( originalIndex >= entry.startMeasure && originalIndex < entry.endMeasure )); return idx >= 0 ? idx : Math.min(originalIndex, _sheetMeasureEls.length - 1); } function measureIndexForBeat(beat) { return visibleMeasureIndexForOriginalIndex(originalMeasureIndexForBeat(beat)); } function sheetMeasureEntry(index) { return _sheetMeasureMap[index] || { startMeasure: index, endMeasure: index + 1 }; } function beatForMeasureIndex(measureIndex) { const starts = state.current?.measure_beats || []; if (!starts.length) return 0; const entry = sheetMeasureEntry(Number(measureIndex) || 0); const idx = Math.max(0, Math.min(starts.length - 1, entry.startMeasure)); return Number(starts[idx]) || 0; } function jumpToSheetMeasure(measureIndex) { const beat = beatForMeasureIndex(measureIndex); seekPracticeToBeat(beat); } function seekPracticeToBeat(beat) { const targetBeat = Math.max(0, Number(beat) || 0); state.practiceStartBeat = targetBeat; state.finishedPlayback = false; clearFinishPlaybackTimer(); const rightHand = state.practiceRightHand || getRightHand(); const position = eventIndexAtOrAfterBeat(rightHand, targetBeat); let bps = Math.max(0.35, state.pausedBps || _noteHighwayBps || state.tracker?.bps?.() || currentBps() || 1); bps = state.accompanist?.tempoForBeat?.(targetBeat, bps) ?? bps; if (state.tracker) state.tracker.seekToBeat(targetBeat); if (state.accompanist) { state.accompanist.seek(targetBeat, bps); if (state.paused) state.accompanist.pause(); } state.pausedBeat = targetBeat; state.pausedBps = bps; _noteHighwayStartBeat = targetBeat; _noteHighwayStartTime = state.playing && !state.paused ? performance.now() / 1000 : null; _noteHighwayBps = bps; updateNextKey(rightHand, state.tracker?.position ?? position); updateSheetHighlight(targetBeat); updatePracticePanelStatus(); const progress = rightHand.length ? Math.min(1, (state.tracker?.position ?? position) / rightHand.length) : 0; const progressFill = document.getElementById('progress-fill'); if (progressFill) progressFill.style.width = `${(progress * 100).toFixed(1)}%`; const beatVal = document.getElementById('beat-val'); if (beatVal) beatVal.textContent = targetBeat.toFixed(1); const tempoVal = document.getElementById('tempo-val'); if (tempoVal && state.playing) tempoVal.textContent = `${Math.round(bps * 60)} BPM`; renderNoteHighway(); syncExpectedMicNote(); } function updateSheetHighlight(beat, options = {}) { const remember = options.remember !== false; if (remember && Number.isFinite(Number(beat))) _pendingSheetHighlightBeat = Number(beat); if (!_sheetMeasureEls.length) return; const idx = measureIndexForBeat(beat); if (idx < 0) return; const measureEl = _sheetMeasureEls[idx]; if (!measureEl) return; const doc = measureEl.ownerDocument; const overlayRoot = measureEl.parentNode; if (!overlayRoot) return; let bbox; try { bbox = measureEl.getBBox(); } catch { return; } if (!bbox || bbox.width <= 0 || bbox.height <= 0) return; const starts = state.current?.measure_beats || []; const mapEntry = sheetMeasureEntry(idx); const startMeasure = Math.max(0, Math.min(starts.length - 1, mapEntry.startMeasure)); const endMeasure = Math.max(startMeasure + 1, Math.min(starts.length, mapEntry.endMeasure)); const measureStart = starts[startMeasure] ?? 0; const nextStart = starts[endMeasure]; const previousSpan = startMeasure > 0 ? ((starts[startMeasure] ?? 0) - (starts[startMeasure - 1] ?? 0)) : 4; const fallbackSpan = (previousSpan || 4) * Math.max(1, endMeasure - startMeasure); const measureSpan = Math.max(0.25, (nextStart ?? (measureStart + fallbackSpan)) - measureStart); const progress = Math.max(0, Math.min(1, (beat - measureStart) / measureSpan)); const barWidth = 14; const minX = bbox.x - 7; const maxX = bbox.x + bbox.width - 7; const x = minX + (maxX - minX) * progress; const y = bbox.y - 18; const height = bbox.height + 36; if (!_sheetHighlightRect || idx !== _sheetHighlightIndex) { _sheetHighlightRect?.remove(); const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('class', 'accompy-measure-highlight'); rect.setAttribute('width', String(barWidth)); rect.setAttribute('height', String(height)); overlayRoot.appendChild(rect); _sheetHighlightRect = rect; _sheetHighlightIndex = idx; measureEl.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } _sheetHighlightRect.setAttribute('x', String(x)); _sheetHighlightRect.setAttribute('y', String(y)); _sheetHighlightRect.setAttribute('width', String(barWidth)); _sheetHighlightRect.setAttribute('height', String(height)); _pendingSheetHighlightBeat = null; } // ── Keyboard visualiser ─────────────────────────────────────────────────────── function expectedKeyboardPitch() { const rightHand = getRightHand(); const position = state.tracker?.position ?? 0; return leadPitchFromEvent(rightHand[position] ?? rightHand[0]) ?? 60; } function resolveKeyboardPitchesForPitchClass(pitchClass) { const rightHand = getRightHand(); const position = state.tracker?.position ?? 0; const expectedEvent = rightHand[position] ?? rightHand[0]; const expectedPitches = eventPitches(expectedEvent); const exactMatches = expectedPitches.filter((pitch) => pitch % 12 === pitchClass); if (exactMatches.length === 1) return exactMatches[0]; if (exactMatches.length > 1) return exactMatches; const target = expectedKeyboardPitch(); let midi = pitchClass; while (midi < target - 6) midi += 12; while (midi > target + 6) midi -= 12; while (midi < 24) midi += 12; while (midi > 108) midi -= 12; return midi; } function resolveTypedMidi(code) { const layout = SIMPLE_KEY_LAYOUT[code]; if (!layout) return undefined; return resolveKeyboardPitchesForPitchClass(layout.pitchClass); } function cueKeyIdForMidi(midi) { const slot = VISUAL_SLOTS.find((entry) => entry.pitchClass === (midi % 12)); return slot ? `kbslot-${slot.id}` : null; } function laneCodeForMidi(midi) { return VISUAL_SLOTS.find((entry) => entry.pitchClass === (midi % 12)) || null; } function currentGuideBeat(rightHand) { if (!rightHand.length) return 0; if (!state.playing && Number.isFinite(Number(state.practiceStartBeat))) { return Math.max(0, Number(state.practiceStartBeat) || 0); } const trackerPos = state.tracker?.position ?? 0; const expectedBeat = rightHand[trackerPos]?.[1] ?? rightHand[rightHand.length - 1]?.[1] ?? 0; if (state.paused) return Math.min(state.pausedBeat ?? 0, expectedBeat); if (state.followMode === 'leader') { const accompanimentBeat = state.accompanist?.currentBeat?.(); const lastBeat = rightHand[rightHand.length - 1]?.[1] ?? expectedBeat; return Number.isFinite(accompanimentBeat) ? Math.max(0, Math.min(accompanimentBeat, lastBeat + 0.5)) : expectedBeat; } const last = state.tracker?.timestamps?.[state.tracker.timestamps.length - 1]; const anchorBeat = last?.beat ?? _noteHighwayStartBeat ?? 0; const anchorTime = last?.time ?? _noteHighwayStartTime; const bps = _noteHighwayBps || state.tracker?.bps?.() || 1; if (!last && trackerPos === 0) { const accompanimentBeat = state.accompanist?.currentBeat?.(); return Number.isFinite(accompanimentBeat) ? Math.max(0, Math.min(accompanimentBeat, expectedBeat)) : 0; } if (!anchorTime) return Math.min(anchorBeat, expectedBeat); const estimated = anchorBeat + Math.max(0, performance.now() / 1000 - anchorTime) * bps; if (state.tracker?.isFinished?.()) return estimated; return Math.min(estimated, expectedBeat); } function ensureNoteHighway() { const root = document.getElementById('note-highway'); if (!root || root.dataset.ready === '1') return; root.innerHTML = VISUAL_SLOTS.map((slot) => { return `
    ${slot.noteName}
    `; }).join(''); root.dataset.ready = '1'; } const HIGHWAY_LOOK_BEHIND_BEATS = 0.5; const HIGHWAY_LOOK_AHEAD_BEATS = 4; const HIGHWAY_TOP_PADDING = 12; const HIGHWAY_MIN_BAR_HEIGHT = 5; const HIGHWAY_BAR_GAP_BEATS = 0.06; function resolveSustainBeats(event, nextBeat) { const beat = event?.[1]; const gap = (Number.isFinite(nextBeat) && Number.isFinite(beat)) ? (nextBeat - beat) : Infinity; const fallback = Number.isFinite(gap) ? gap : 0.75; const raw = eventDuration(event, fallback); const cap = Number.isFinite(gap) ? Math.max(0.02, gap - HIGHWAY_BAR_GAP_BEATS) : raw; return Math.max(0.02, Math.min(raw, cap)); } // bar's bottom = hit line at onset (delta=0); bar's height = sustain * pxPerBeat. function computeBarGeometry(delta, sustainBeats, laneHeight, hitLineTop, pixelsPerBeat) { const bottomRaw = hitLineTop - delta * pixelsPerBeat; const topRaw = bottomRaw - sustainBeats * pixelsPerBeat; const top = Math.max(HIGHWAY_TOP_PADDING, topRaw); const bottom = Math.min(laneHeight, bottomRaw); if (bottom <= HIGHWAY_TOP_PADDING || bottom <= top) return null; const height = Math.min(bottom - HIGHWAY_TOP_PADDING, Math.max(HIGHWAY_MIN_BAR_HEIGHT, bottom - top)); return { top: Math.max(HIGHWAY_TOP_PADDING, bottom - height), height }; } function renderNoteHighway() { ensureNoteHighway(); const root = document.getElementById('note-highway'); if (state.finishedPlayback && !state.playing) { document.querySelectorAll('.note-bar, .full-note-bar').forEach((el) => el.remove()); return; } const rightHand = getRightHand(); const trackerPos = state.tracker?.position ?? eventIndexAtOrAfterBeat(rightHand, state.practiceStartBeat ?? 0); const activeIndex = state.tracker?.isFinished?.() ? (state.tracker?._lastAdvancedPosition ?? trackerPos) : trackerPos; const hasMatchedAnyNote = (state.tracker?.timestamps?.length ?? 0) > 0; const openingOnlyCurrent = trackerPos === 0 && !hasMatchedAnyNote; const currentBeat = rightHand.length ? currentGuideBeat(rightHand) : 0; updateSheetHighlight(currentBeat); renderFullKeyboardHighway(rightHand, activeIndex, currentBeat, openingOnlyCurrent); if (!root) return; root.querySelectorAll('.note-bar').forEach((el) => el.remove()); if (!rightHand.length) return; const firstLane = root.querySelector('.note-lane'); const hitLineEl = firstLane?.querySelector('.note-hit-line'); if (!firstLane || !hitLineEl) return; const laneRect = firstLane.getBoundingClientRect(); const hitRect = hitLineEl.getBoundingClientRect(); if (!laneRect.height) return; const laneHeight = laneRect.height; const hitLineTop = hitRect.top - laneRect.top; const pixelsPerBeat = (hitLineTop - HIGHWAY_TOP_PADDING) / HIGHWAY_LOOK_AHEAD_BEATS; const startIndex = Math.max(0, trackerPos - 1); for (let i = startIndex; i < rightHand.length; i++) { const event = rightHand[i]; if (openingOnlyCurrent && i !== trackerPos) continue; const beat = event?.[1]; const delta = beat - currentBeat; if (delta < -HIGHWAY_LOOK_BEHIND_BEATS) continue; if (delta > HIGHWAY_LOOK_AHEAD_BEATS) break; const sustainBeats = resolveSustainBeats(event, rightHand[i + 1]?.[1]); const geom = computeBarGeometry(delta, sustainBeats, laneHeight, hitLineTop, pixelsPerBeat); if (!geom) continue; eventPitches(event).forEach((midi) => { const lane = laneCodeForMidi(midi); if (!lane) return; const laneEl = root.querySelector(`[data-lane="${lane.id}"]`); if (!laneEl) return; const bar = document.createElement('div'); bar.className = `note-bar${lane.sharp ? ' sharp' : ''}${i === activeIndex ? ' current' : ''}`; bar.style.top = `${geom.top}px`; bar.style.height = `${geom.height}px`; laneEl.appendChild(bar); }); } } function renderFullKeyboardHighway(rightHand, trackerPos, currentBeat, openingOnlyCurrent = false) { const root = document.getElementById('full-note-highway'); const hitLineEl = root?.querySelector('.full-note-hit-line'); if (!root || !hitLineEl) return; root.querySelectorAll('.full-note-bar').forEach((el) => el.remove()); if (!rightHand.length) return; const rootRect = root.getBoundingClientRect(); const hitRect = hitLineEl.getBoundingClientRect(); if (!rootRect.width || !rootRect.height) return; const laneHeight = rootRect.height; const hitLineTop = hitRect.top - rootRect.top; const pixelsPerBeat = (hitLineTop - HIGHWAY_TOP_PADDING) / HIGHWAY_LOOK_AHEAD_BEATS; const startIndex = Math.max(0, trackerPos - 1); for (let i = startIndex; i < rightHand.length; i++) { const event = rightHand[i]; if (openingOnlyCurrent && i !== trackerPos) continue; const beat = event?.[1]; const delta = beat - currentBeat; if (delta < -HIGHWAY_LOOK_BEHIND_BEATS) continue; if (delta > HIGHWAY_LOOK_AHEAD_BEATS) break; const sustainBeats = resolveSustainBeats(event, rightHand[i + 1]?.[1]); const geom = computeBarGeometry(delta, sustainBeats, laneHeight, hitLineTop, pixelsPerBeat); if (!geom) continue; eventPitches(event).forEach((midi) => { const keyEl = document.getElementById(`refkey-${midi}`); if (!keyEl) return; const keyRect = keyEl.getBoundingClientRect(); const bar = document.createElement('div'); bar.className = `full-note-bar${isBlackKeyMidi(midi) ? ' sharp' : ''}${i === trackerPos ? ' current' : ''}`; bar.style.left = `${Math.max(0, keyRect.left - rootRect.left + 2)}px`; bar.style.width = `${Math.max(8, keyRect.width - 4)}px`; bar.style.top = `${geom.top}px`; bar.style.height = `${geom.height}px`; root.appendChild(bar); }); } } function stopNoteHighwayLoop() { if (_noteHighwayRaf) cancelAnimationFrame(_noteHighwayRaf); _noteHighwayRaf = null; } function startNoteHighwayLoop() { stopNoteHighwayLoop(); const tick = () => { renderNoteHighway(); updatePracticePanelStatus(); if (state.playing && !state.paused) _noteHighwayRaf = requestAnimationFrame(tick); }; tick(); } function buildKeyboard(rightHand) { const nextPitches = eventPitches(rightHand[0]); const row = document.getElementById('kb-row-main'); row.innerHTML = SIMPLE_KEY_ORDER.map((code) => { const naturalSlot = VISUAL_SLOTS.find((slot) => slot.code === code && !slot.sharp); const sharpSlot = VISUAL_SLOTS.find((slot) => slot.anchorCode === code && slot.sharp); const naturalIsNext = naturalSlot && nextPitches.some((pitch) => pitch % 12 === naturalSlot.pitchClass); const sharpIsNext = sharpSlot && nextPitches.some((pitch) => pitch % 12 === sharpSlot.pitchClass); return `
    ${naturalSlot.keyLabel} ${naturalSlot.noteName}
    ${sharpSlot ? `
    ${sharpSlot.keyLabel} ${sharpSlot.noteName}
    ` : ''}
    `; }).join(''); buildReferenceKeyboard(); applyKeyboardLayoutMode(state.keyboardLayoutMode); } function buildReferenceKeyboard() { const whiteRoot = document.getElementById('full-kb-whites'); const blackRoot = document.getElementById('full-kb-blacks'); if (!whiteRoot || !blackRoot) return; let whiteIndex = 0; const whiteKeys = []; const blackKeys = []; for (let midi = FULL_KEYBOARD_START; midi <= FULL_KEYBOARD_END; midi++) { const label = pitchName(midi); if (isBlackKeyMidi(midi)) { blackKeys.push( `
    ${label}
    ` ); } else { whiteKeys.push( `
    ${label}
    ` ); whiteIndex += 1; } } whiteRoot.innerHTML = whiteKeys.join(''); blackRoot.innerHTML = blackKeys.join(''); } function scrollReferenceKeyboardToMidi(midi) { if (state.keyboardLayoutMode !== 'full') return; const shell = document.getElementById('reference-keyboard-shell'); const key = document.getElementById(`refkey-${midi}`); if (!shell || !key) return; key.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); } function highlightKey(key, on) { const el = document.getElementById(key); if (el) el.classList.toggle('active', on); } function updateNextKey(rightHand, position) { document.querySelectorAll('.kb-key.next, .kb-sharp.next, .ref-key.next') .forEach(el => el.classList.remove('next')); if (position >= rightHand.length) { document.getElementById('next-note-display').textContent = '—'; renderNoteHighway(); return; } const event = rightHand[position]; eventPitches(event).forEach((midi) => { const keyId = cueKeyIdForMidi(midi); if (keyId) document.getElementById(keyId)?.classList.add('next'); document.getElementById(`refkey-${midi}`)?.classList.add('next'); }); document.getElementById('next-note-display').textContent = eventLabel(event); scrollReferenceKeyboardToMidi(leadPitchFromEvent(event)); renderNoteHighway(); syncExpectedMicNote(); } // ── Start / Stop ────────────────────────────────────────────────────────────── function handleStartPause() { if (!state.playing) { return startPlaying(); } return togglePausePlaying(); } async function startPlaying() { await resumeOutputContexts(); // unblock audio on user gesture clearFinishPlaybackTimer(); const bpm = parseFloat(document.getElementById('bpm-input').value) || 100; const initialBps = bpm / 60; const right = expandDynamicTremolos(getRightHandRaw(), initialBps); const left = expandDynamicTremolos(getLeftHandRaw(), initialBps); state.practiceRightHand = right; state.practiceLeftHand = left; // Build per-part instrument map for the accompanist (all non-selected parts) const parts = state.current?.parts || []; const selected = new Set(selectedPartIndices()); const leftInstruments = parts .map((_, i) => i) .filter(i => !selected.has(i)) .map(i => getInstrumentForPart(i)); const selectedInstruments = selectedPartIndices().map((idx) => getInstrumentForPart(idx)); const instrumentsInUse = [...selectedInstruments, ...leftInstruments]; const startBtn = document.getElementById('start-btn'); const stopBtn = document.getElementById('stop-btn'); startBtn.disabled = true; const originalLabel = '▶ Start'; startBtn.textContent = 'Loading...'; try { await ensureSamplePlayback(instrumentsInUse); } finally { startBtn.textContent = originalLabel; } const missingSampleInstrument = instrumentsInUse.find((instrument) => isSampleBackedInstrument(instrument) && !hasLoadedSampler(instrument) ); if (missingSampleInstrument) { startBtn.disabled = false; stopBtn.disabled = true; startBtn.textContent = '▶ Start'; startBtn.classList.add('btn-primary'); stopBtn.textContent = '■ End'; alert(`Could not load the sampled ${missingSampleInstrument} instrument yet. Reload and try again.`); return; } state.tracker = new Tracker(right, initialBps); state.accompanist = new Accompanist(left, right, initialBps, leftInstruments, { followMode: state.followMode, }); const startBeat = Math.max(0, Number(state.practiceStartBeat) || 0); state.tracker.seekToBeat(startBeat); state.accompanist.start(startBeat); state.playing = true; state.paused = false; state.pausedBeat = startBeat; state.pausedBps = initialBps; state.finishedPlayback = false; _noteHighwayStartTime = state.followMode === 'leader' || startBeat > 0 ? performance.now() / 1000 : null; _noteHighwayStartBeat = startBeat; _noteHighwayBps = initialBps; startBtn.disabled = false; stopBtn.disabled = false; startBtn.textContent = '❚❚ Pause'; startBtn.classList.add('btn-primary'); stopBtn.textContent = '■ End'; updateNextKey(right, state.tracker.position); updatePracticePanelStatus(); updateSheetHighlight(startBeat); startNoteHighwayLoop(); enableMidi(); resetAcousticMicFollower(); if (_inputMode === 'mic') _startMic(); syncExpectedMicNote(); } function togglePausePlaying() { if (!state.playing || !state.tracker) return; const startBtn = document.getElementById('start-btn'); if (!startBtn) return; if (!state.paused) { state.pausedBeat = currentGuideBeat(getRightHand()); state.pausedBps = _noteHighwayBps || state.tracker?.accompanimentBps?.({ adaptiveTempo: state.adaptiveTempo }) || state.tracker?.bps?.() || 1; state.paused = true; state.accompanist?.pause(); _stopMic(); stopNoteHighwayLoop(); updateSheetHighlight(state.pausedBeat); renderNoteHighway(); startBtn.textContent = '▶ Resume'; updatePracticePanelStatus(); return; } state.paused = false; _noteHighwayStartBeat = state.pausedBeat ?? 0; _noteHighwayStartTime = performance.now() / 1000; _noteHighwayBps = state.pausedBps ?? (state.tracker?.accompanimentBps?.({ adaptiveTempo: state.adaptiveTempo }) ?? state.tracker?.bps?.() ?? 1); state.accompanist?.resume(_noteHighwayStartBeat, _noteHighwayBps); startBtn.textContent = '❚❚ Pause'; updatePracticePanelStatus(); startNoteHighwayLoop(); if (_inputMode === 'mic') _startMic(); syncExpectedMicNote(); } function clearFinishPlaybackTimer() { if (!_finishPlaybackTimer) return; clearTimeout(_finishPlaybackTimer); _finishPlaybackTimer = null; } function scheduleFinishedPlayback() { if (_finishPlaybackTimer || !state.tracker?.isFinished?.()) return; const bps = Math.max(0.35, _noteHighwayBps || state.tracker?.bps?.() || 1); const delayMs = Math.max(650, Math.min(2200, ((HIGHWAY_LOOK_BEHIND_BEATS + 0.18) / bps) * 1000)); _finishPlaybackTimer = setTimeout(() => { _finishPlaybackTimer = null; if (state.tracker?.isFinished?.()) stopPlaying('finished'); }, delayMs); } function stopPlaying(reason = 'stopped') { clearFinishPlaybackTimer(); if (state.accompanist) state.accompanist.stop(); _stopMic(); stopNoteHighwayLoop(); const finished = reason === 'finished' && !!state.tracker?.isFinished?.(); state.playing = false; state.paused = false; state.pausedBeat = 0; state.finishedPlayback = finished; state.practiceRightHand = null; state.practiceLeftHand = null; _noteHighwayStartTime = null; const startBtn = document.getElementById('start-btn'); const stopBtn = document.getElementById('stop-btn'); startBtn.disabled = false; stopBtn.disabled = true; startBtn.textContent = '▶ Start'; startBtn.classList.add('btn-primary'); stopBtn.textContent = '■ End'; if (finished) { document.getElementById('next-note-display').textContent = 'Finished'; document.getElementById('progress-fill').style.width = '100%'; if (state.guestMode && state.current?.guest) { const progress = loadGuestProgress(); progress[state.current.name] = { ...(progress[state.current.name] || {}), completed: true, completedAt: new Date().toISOString(), }; saveGuestProgress(progress); showGuestUpgradeToast('Nice run. Create a free account to save your own pieces and progress.'); } } else { document.getElementById('next-note-display').textContent = '—'; } if (typeof _pitchDetector?.setExpectedMidi === 'function') _pitchDetector.setExpectedMidi(null); if (typeof _pitchDetector?.setExpectedPitches === 'function') _pitchDetector.setExpectedPitches([]); updatePracticePanelStatus(); renderNoteHighway(); } // ── Note handler (called by keyboard and MIDI) ──────────────────────────────── function advancePracticeClock(beat) { const detectedBps = state.tracker.bps(); const leaderKeepsTempo = state.followMode === 'leader' && (!state.adaptiveTempo || (state.tracker?.timestamps?.length || 0) < 4); let accompanimentBps = leaderKeepsTempo ? (state.accompanist?._bps || state.pausedBps || detectedBps) : (state.tracker.accompanimentBps?.({ adaptiveTempo: state.adaptiveTempo }) ?? detectedBps); accompanimentBps = state.accompanist?.tempoForBeat?.(beat, accompanimentBps) ?? accompanimentBps; state.accompanist.onRhNote(beat, accompanimentBps); const guideBeat = state.followMode === 'leader' ? currentGuideBeat(getRightHand()) : beat; _noteHighwayStartBeat = guideBeat; _noteHighwayStartTime = performance.now() / 1000; _noteHighwayBps = accompanimentBps; state.pausedBps = accompanimentBps; updateSheetHighlight(guideBeat); updatePracticePanelStatus(); document.getElementById('beat-val').textContent = guideBeat.toFixed(1); const displayedBps = state.followMode === 'leader' ? accompanimentBps : detectedBps; document.getElementById('tempo-val').textContent = Math.round(displayedBps * 60) + ' BPM'; document.getElementById('progress-fill').style.width = (state.tracker.progress() * 100).toFixed(1) + '%'; } function handleNoteMic(midi) { if (!state.playing || state.paused || !state.tracker) return; const beat = state.tracker.onNoteFuzzy(midi); if (beat !== null) { advancePracticeClock(beat); updateNextKey(getRightHand(), state.tracker.position); syncExpectedMicNote(); } if (state.tracker.isFinished()) scheduleFinishedPlayback(); } let _pendingMicNoteTimer = null; let _pendingMicNote = null; let _pendingMicNoteSeq = 0; function clearPendingMicNote() { if (_pendingMicNoteTimer) { clearTimeout(_pendingMicNoteTimer); _pendingMicNoteTimer = null; } _pendingMicNote = null; _pendingMicNoteSeq++; } function queueMicNoteForFollowing(midi, info = {}) { if (!state.playing || state.paused || !state.tracker) return; _pendingMicNote = { midi, info, seq: ++_pendingMicNoteSeq, }; if (_pendingMicNoteTimer) clearTimeout(_pendingMicNoteTimer); _pendingMicNoteTimer = setTimeout(() => { const pending = _pendingMicNote; _pendingMicNote = null; _pendingMicNoteTimer = null; if (!pending || pending.seq !== _pendingMicNoteSeq) return; handleNoteMic(pending.midi); }, MIC_NOTE_ACCEPT_DELAY_MS); } function handleMicChord(pitches, info = {}) { if (!state.playing || state.paused || !state.tracker) return; clearPendingMicNote(); const beat = state.tracker.onChordFuzzy(pitches); if (beat !== null) { const noteDisplay = document.getElementById('mic-note-display'); if (noteDisplay) noteDisplay.textContent = pitches.map((pitch) => pitchName(pitch)).join(' / '); _micDot('hearing'); advancePracticeClock(beat); updateNextKey(getRightHand(), state.tracker.position); syncExpectedMicNote(); setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 220); } if (state.tracker.isFinished()) scheduleFinishedPlayback(); } function resetAcousticMicFollower() { _acousticMicFollower = new AcousticMicFollower(); updateMicFollowerPanel(null); } function acousticChromaSummary(chroma) { if (!Array.isArray(chroma) || chroma.length !== 12) return ''; return chroma .map((value, index) => ({ label: ACOUSTIC_PC_LABELS[index], value })) .filter((entry) => entry.value > 0.18) .sort((a, b) => b.value - a.value) .slice(0, 4) .map((entry) => entry.label) .join(' '); } function updateMicFollowerPanel(output) { const modeEl = document.getElementById('mic-follower-mode'); const confidenceEl = document.getElementById('mic-follower-confidence'); const delayEl = document.getElementById('mic-follower-delay'); const candidateEl = document.getElementById('mic-follower-candidates'); const chromaEl = document.getElementById('mic-follower-chroma'); if (!modeEl || !confidenceEl || !delayEl || !candidateEl || !chromaEl) return; if (!output) { modeEl.textContent = 'Listening'; confidenceEl.textContent = '--'; delayEl.textContent = '--'; candidateEl.textContent = 'Play the next highlighted note or chord.'; chromaEl.innerHTML = ACOUSTIC_PC_LABELS .map((label) => `${label}`) .join(''); return; } modeEl.textContent = output.mode; confidenceEl.textContent = `${Math.round(output.confidence * 100)}%`; delayEl.textContent = `${Math.round(output.suggestedDelayMs)} ms`; candidateEl.innerHTML = output.candidates.length ? output.candidates.map((candidate) => `${candidate.label} @ ${candidate.beat.toFixed(1)} ${Math.round(candidate.confidence * 100)}%` ).join('') : 'No plausible score event yet.'; chromaEl.innerHTML = output.audioChroma .map((value, index) => `${ACOUSTIC_PC_LABELS[index]}` ) .join(''); } function handleAcousticMicFrame(input, meta) { if (!_acousticMicFollower) resetAcousticMicFollower(); if (!state.playing || state.paused || !state.tracker) return; const output = _acousticMicFollower.processChunk(input, meta); if (!output) return; updateMicFollowerPanel(output); const noteDisplay = document.getElementById('mic-note-display'); if (noteDisplay) { const chromaText = acousticChromaSummary(output.audioChroma); noteDisplay.textContent = output.beat !== null ? `Beat ${output.beat.toFixed(1)} ${Math.round(output.confidence * 100)}%${chromaText ? ` (${chromaText})` : ''}` : `Listening ${Math.round(output.confidence * 100)}%`; } const debug = document.getElementById('mic-debug'); if (debug) { const mode = ACOUSTIC_FRAME_ADVANCE_ENABLED ? 'follow' : 'diagnostic'; debug.textContent = `${mode} delay ${Math.round(output.suggestedDelayMs)}ms`; } if (!ACOUSTIC_FRAME_ADVANCE_ENABLED) return; if (!state.playing || state.paused || !state.tracker || !output.shouldPlayAccompaniment) return; const beat = state.tracker.advanceTo(output.bestEventIndex, { inputPitch: leadPitchFromEvent(getRightHand()[output.bestEventIndex] ?? getRightHand()[state.tracker.position]), maxJump: MAX_ACOUSTIC_EVENT_JUMP, }); if (beat !== null) { _micDot('hearing'); advancePracticeClock(beat); updateNextKey(getRightHand(), state.tracker.position); syncExpectedMicNote(); setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 220); } if (state.tracker.isFinished()) scheduleFinishedPlayback(); } function playInputNoteSound(midi) { const expectedEvent = getRightHand()[state.tracker.position] ?? getRightHand()[0]; playNote(midi, expectedEvent ? eventVelocity(expectedEvent, 0.6) : 0.6, getInstrumentForPart(state.selectedPart ?? 0), { duration: expectedEvent ? eventDurationSeconds(expectedEvent, getInstrumentForPart(state.selectedPart ?? 0)) : undefined, pedaled: expectedEvent ? isPedaledEvent(expectedEvent) : false, pedalHold: expectedEvent ? eventPedalHoldSeconds(expectedEvent) : null, volume: state.playbackVolumes?.solo ?? 1, outputRole: 'solo', }); const keyId = cueKeyIdForMidi(midi); highlightKey(keyId, true); highlightKey(`refkey-${midi}`, true); setTimeout(() => highlightKey(keyId, false), 120); setTimeout(() => highlightKey(`refkey-${midi}`, false), 120); } function handleNote(midi) { if (!state.playing || state.paused || !state.tracker) return; playInputNoteSound(midi); if (_inputMode === 'mic') return; const beat = state.tracker.onNote(midi); if (beat !== null) { advancePracticeClock(beat); updateNextKey(getRightHand(), state.tracker.position); } if (state.tracker.isFinished()) scheduleFinishedPlayback(); } function handleKeyboardInput(midiOrPitches) { const pitches = Array.isArray(midiOrPitches) ? midiOrPitches : [midiOrPitches]; pitches.forEach((midi) => handleNote(midi)); } function handleMidiNote(midi) { if (!state.playing || state.paused || !state.tracker) return; const now = performance.now(); if (_lastMidiPitch === midi && now - _lastMidiNoteTime < MIDI_DUPLICATE_NOTE_GUARD_MS) return; _lastMidiPitch = midi; _lastMidiNoteTime = now; if (_inputMode === 'mic') { playInputNoteSound(midi); return; } const keyId = cueKeyIdForMidi(midi); highlightKey(keyId, true); highlightKey(`refkey-${midi}`, true); setTimeout(() => highlightKey(keyId, false), 120); setTimeout(() => highlightKey(`refkey-${midi}`, false), 120); const beat = state.tracker.onNote(midi); if (beat !== null) { advancePracticeClock(beat); updateNextKey(getRightHand(), state.tracker.position); } if (state.tracker.isFinished()) scheduleFinishedPlayback(); } // ── Input mode ─────────────────────────────────────────────────────────────── let _inputMode = 'keyboard'; let _pitchDetector = null; let _acousticMicFollower = null; let _selectedMicId = null; let _selectedSpeakerId = null; const MIC_LATENCY_TRIALS = 5; const MIC_LATENCY_PROMPTS = [ { label: 'C5', midi: 72 }, { label: 'G5', midi: 79 }, ]; let _micLatencyCalibration = { active: false, waiting: false, round: 0, total: MIC_LATENCY_TRIALS, promptAt: 0, probeStartMs: 0, promptNote: null, results: [], timer: null, }; function updatePracticePanelStatus() { const inputStatus = document.getElementById('practice-input-status'); if (inputStatus) { inputStatus.textContent = _inputMode === 'mic' ? 'Microphone' : 'Keyboard'; } const followingStatus = document.getElementById('practice-following-status'); if (followingStatus) { followingStatus.textContent = state.playing ? ( state.paused ? 'Paused' : state.followMode === 'leader' ? (state.accompanist?.isHoldingForSoloist?.() ? 'Waiting for note' : 'Accompanist leading') : 'Following' ) : 'Ready'; } } function setInputMode(mode, options = {}) { const persist = options.persist !== false; const previousEffectiveSec = accompanimentLatencyCompSec(); _inputMode = mode; document.getElementById('tab-keyboard').classList.toggle('active', mode === 'keyboard'); document.getElementById('tab-mic').classList.toggle('active', mode === 'mic'); document.getElementById('mic-controls').style.display = mode === 'mic' ? 'block' : 'none'; const keyboardSection = document.getElementById('keyboard-section'); if (keyboardSection) keyboardSection.dataset.inputMode = mode === 'mic' ? 'mic' : 'keyboard'; setLatencyCompensation(Math.round(_accompanimentLatencyCompSec * 1000), { persistGlobal: false, persistScore: false, }); if (state.accompanist && state.playing && !state.paused) { state.accompanist.updateLatencyCompensation(previousEffectiveSec, accompanimentLatencyCompSec()); } let startPromise = Promise.resolve(); if (mode === 'mic') { startPromise = _startMic(); } else { cancelMicLatencyCalibration(); _stopMic(); } updatePracticePanelStatus(); if (persist) saveCurrentScorePreferences({ inputMode: mode }); return startPromise; } function onNoiseGateChange(val) { const label = document.getElementById('noise-gate-val'); if (label) label.textContent = val; if (_pitchDetector) _pitchDetector.setThreshold(val / 1000); if (_acousticMicFollower) _acousticMicFollower.calibration.noiseFloor = val / 1000; _drawMeter(_lastRms); // redraw so threshold line updates immediately } function setMicLatencyStatus(message, tone = 'muted') { const result = document.getElementById('mic-latency-result'); if (!result) return; result.textContent = message || ''; result.style.color = tone === 'error' ? '#e05c5c' : tone === 'success' ? 'var(--success)' : 'var(--muted)'; } function renderMicLatencySamples() { const el = document.getElementById('mic-latency-samples'); if (!el) return; el.innerHTML = _micLatencyCalibration.results.map((sample, index) => { if (!sample) return `Try ${index + 1}: missed`; const label = sample.midi ? pitchName(sample.midi) : 'note'; const target = sample.targetLabel ? ` for ${sample.targetLabel}` : ''; return `Try ${index + 1}: ${Math.round(sample.ms)} ms${target} (${label})`; }).join(''); } function updateMicLatencyControls() { const active = _micLatencyCalibration.active; const startBtn = document.getElementById('mic-latency-btn'); const cancelBtn = document.getElementById('mic-latency-cancel-btn'); if (startBtn) startBtn.disabled = active; if (cancelBtn) cancelBtn.style.display = active ? 'inline-flex' : 'none'; } function clearMicLatencyTimer() { if (_micLatencyCalibration.timer) { clearTimeout(_micLatencyCalibration.timer); _micLatencyCalibration.timer = null; } } function setMicLatencyTimer(fn, delayMs) { clearMicLatencyTimer(); _micLatencyCalibration.timer = setTimeout(fn, delayMs); } function performanceTimeForAudioContextTime(ctx, audioTime) { if (typeof ctx.getOutputTimestamp === 'function') { const stamp = ctx.getOutputTimestamp(); if (Number.isFinite(stamp.contextTime) && Number.isFinite(stamp.performanceTime)) { return stamp.performanceTime + (audioTime - stamp.contextTime) * 1000; } } return performance.now() + Math.max(0, audioTime - ctx.currentTime + (ctx.baseLatency || 0)) * 1000; } function scheduleMicLatencyProbe(midi) { const ctx = outputCtx('solo'); const startAt = ctx.currentTime + 0.14; const duration = 0.18; const freq = 440 * Math.pow(2, (midi - 69) / 12); const osc = ctx.createOscillator(); const gain = ctx.createGain(); const soloVolume = normalizePlaybackVolume(state.playbackVolumes?.solo, 1, maxPlaybackVolumeForChannel('solo')); const probeGain = Math.max(0.015, Math.min(0.28, 0.18 * soloVolume)); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, startAt); gain.gain.setValueAtTime(0.0001, startAt); gain.gain.exponentialRampToValueAtTime(probeGain, startAt + 0.012); gain.gain.setValueAtTime(probeGain, startAt + duration - 0.035); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); osc.connect(gain).connect(ctx.destination); osc.start(startAt); osc.stop(startAt + duration + 0.04); return { audibleAtMs: performanceTimeForAudioContextTime(ctx, startAt), durationMs: duration * 1000, }; } function resetMicLatencyCalibrationUi() { const round = document.getElementById('mic-latency-round'); const prompt = document.getElementById('mic-latency-prompt'); if (round) round.textContent = 'Ready'; if (prompt) prompt.textContent = '--'; setMicLatencyStatus('Put the microphone near the speaker. NotePilot will emit test tones automatically.'); renderMicLatencySamples(); updateMicLatencyControls(); } async function calculateMicLatency() { if (_micLatencyCalibration.active) return; if (!navigator.mediaDevices?.getUserMedia) { setMicLatencyStatus('Microphone access is not available in this browser.', 'error'); return; } if (state.playing) stopPlaying(); try { const ctx = audioCtx(); if (ctx.state === 'suspended') await ctx.resume(); } catch (error) { setMicLatencyStatus(error.message || 'Audio output could not be started.', 'error'); return; } _micLatencyCalibration = { active: true, waiting: false, round: 0, total: MIC_LATENCY_TRIALS, promptAt: 0, probeStartMs: 0, promptNote: null, results: [], timer: null, }; updateMicLatencyControls(); renderMicLatencySamples(); setMicLatencyStatus('Starting microphone...'); try { await setInputMode('mic'); } catch (error) { cancelMicLatencyCalibration({ resetStatus: false }); setMicLatencyStatus(error.message || 'Microphone could not be started.', 'error'); return; } if (!_micLatencyCalibration.active || !_pitchDetector) return; startMicLatencyRound(); } function cancelMicLatencyCalibration(options = {}) { const wasActive = _micLatencyCalibration.active; clearMicLatencyTimer(); _micLatencyCalibration.active = false; _micLatencyCalibration.waiting = false; _micLatencyCalibration.promptAt = 0; _micLatencyCalibration.probeStartMs = 0; _pitchDetector?.setExpectedMidi?.(null); _pitchDetector?.setExpectedPitches?.([]); updateMicLatencyControls(); if (options.resetStatus !== false && wasActive) { const prompt = document.getElementById('mic-latency-prompt'); const round = document.getElementById('mic-latency-round'); if (prompt) prompt.textContent = '--'; if (round) round.textContent = 'Ready'; setMicLatencyStatus('Latency calculation canceled.'); } } function startMicLatencyRound() { if (!_micLatencyCalibration.active) return; _micLatencyCalibration.round += 1; _micLatencyCalibration.waiting = false; _micLatencyCalibration.promptAt = 0; _micLatencyCalibration.probeStartMs = 0; _micLatencyCalibration.promptNote = MIC_LATENCY_PROMPTS[(_micLatencyCalibration.round - 1) % MIC_LATENCY_PROMPTS.length]; _pitchDetector?.setExpectedMidi?.(_micLatencyCalibration.promptNote.midi); _pitchDetector?.setExpectedPitches?.([_micLatencyCalibration.promptNote.midi]); const round = document.getElementById('mic-latency-round'); const prompt = document.getElementById('mic-latency-prompt'); if (round) round.textContent = `Test ${_micLatencyCalibration.round} of ${_micLatencyCalibration.total}`; if (prompt) prompt.textContent = _micLatencyCalibration.promptNote.label; setMicLatencyStatus(`Put the microphone near the speaker. Emitting ${_micLatencyCalibration.promptNote.label} soon.`); runMicLatencyCountdown(3); } function runMicLatencyCountdown(value) { if (!_micLatencyCalibration.active) return; const prompt = document.getElementById('mic-latency-prompt'); const target = _micLatencyCalibration.promptNote?.label || 'the note'; if (value > 0) { if (prompt) prompt.textContent = `${target} in ${value}`; setMicLatencyTimer(() => runMicLatencyCountdown(value - 1), 450); return; } if (prompt) prompt.textContent = `Emitting ${target}`; const probe = scheduleMicLatencyProbe(_micLatencyCalibration.promptNote.midi); _micLatencyCalibration.promptAt = probe.audibleAtMs; _micLatencyCalibration.probeStartMs = probe.audibleAtMs; _micLatencyCalibration.waiting = true; setMicLatencyStatus(`Listening for the ${target} speaker probe...`); setMicLatencyTimer(() => finishMicLatencyRound(null), 1800); } function finishMicLatencyRound(sample) { if (!_micLatencyCalibration.active) return; clearMicLatencyTimer(); _micLatencyCalibration.waiting = false; _micLatencyCalibration.probeStartMs = 0; _micLatencyCalibration.results.push(sample); renderMicLatencySamples(); if (sample) { const target = sample.targetLabel ? `${sample.targetLabel}: ` : ''; setMicLatencyStatus(`${target}detected speaker probe after ${Math.round(sample.ms)} ms.`); } else { setMicLatencyStatus('No note detected for this test.', 'error'); } if (_micLatencyCalibration.round >= _micLatencyCalibration.total) { finishMicLatencyCalibration(); return; } setMicLatencyTimer(startMicLatencyRound, 900); } function finishMicLatencyCalibration() { const valid = _micLatencyCalibration.results .filter(Boolean) .map((sample) => sample.ms) .filter((ms) => ms >= 5 && ms <= LATENCY_COMPENSATION_MAX_MS) .sort((a, b) => a - b); _micLatencyCalibration.active = false; _micLatencyCalibration.waiting = false; _pitchDetector?.setExpectedMidi?.(null); _pitchDetector?.setExpectedPitches?.([]); clearMicLatencyTimer(); updateMicLatencyControls(); const prompt = document.getElementById('mic-latency-prompt'); const round = document.getElementById('mic-latency-round'); if (round) round.textContent = 'Complete'; if (!valid.length) { if (prompt) prompt.textContent = '--'; setMicLatencyStatus('No usable latency samples. Try again closer to the microphone.', 'error'); return; } const reliable = valid.length >= 4 ? valid.slice(1, -1) : valid; const estimatedMs = Math.round(acousticMedian(reliable) / 10) * 10; const appliedMs = Math.max(0, Math.min(LATENCY_COMPENSATION_MAX_MS, estimatedMs)); setLatencyCompensation(appliedMs); if (prompt) prompt.textContent = `${appliedMs} ms`; setMicLatencyStatus( appliedMs === estimatedMs ? `Applied ${appliedMs} ms to accompaniment latency.` : `Measured ${estimatedMs} ms and applied the ${appliedMs} ms maximum.`, 'success', ); } function handleMicLatencyNote(midi, info = {}) { if (!_micLatencyCalibration.active || !_micLatencyCalibration.waiting) return false; const now = performance.now(); const ms = now - _micLatencyCalibration.promptAt; const expectedMidi = _micLatencyCalibration.promptNote?.midi; if (Number.isFinite(expectedMidi) && Math.abs(midi - expectedMidi) > 1) return true; if (ms < 5) return true; finishMicLatencyRound({ ms, midi, clarity: Number(info.clarity) || 0, targetMidi: _micLatencyCalibration.promptNote?.midi ?? null, targetLabel: _micLatencyCalibration.promptNote?.label || '', }); return true; } // ── Level meter ─────────────────────────────────────────────────────────────── let _lastRms = 0; let _peakRms = 0; let _peakHold = 0; function _drawMeter(rms) { _lastRms = rms; const fill = document.getElementById('level-fill'); const peak = document.getElementById('level-peak'); const gate = document.getElementById('level-gate'); const label = document.getElementById('level-gate-label'); if (!fill || !peak || !gate || !label) return; const gateVal = parseFloat(document.getElementById('noise-gate')?.value || 8) / 1000; const scale = v => Math.min(v / 0.15, 1.0) * 100; // % of bar width const fillPct = scale(rms); const gatePct = scale(gateVal); const aboveGate = rms >= gateVal; // Peak hold if (rms > _peakRms) { _peakRms = rms; _peakHold = 50; } else if (_peakHold > 0) _peakHold--; else _peakRms = Math.max(_peakRms * 0.95, 0); const peakPct = scale(_peakRms); fill.style.width = fillPct + '%'; fill.classList.toggle('active', aboveGate); peak.style.left = Math.min(peakPct, 99) + '%'; gate.style.left = gatePct + '%'; const labelLeft = gatePct > 85 ? gatePct - 6 : gatePct + 0.5; label.style.left = labelLeft + '%'; } function _micDot(state) { const dot = document.getElementById('mic-dot'); const text = document.getElementById('mic-status-text'); if (!dot || !text) return; dot.className = 'mic-status-dot' + (state ? ' ' + state : ''); text.textContent = state === 'active' ? 'Listening…' : state === 'hearing' ? 'Note detected' : 'Mic off'; } async function _populateMicList() { try { // Need a temporary permission grant to get device labels const tmp = await navigator.mediaDevices.getUserMedia({ audio: true }); tmp.getTracks().forEach(t => t.stop()); } catch { return; } const devices = await navigator.mediaDevices.enumerateDevices(); const mics = devices.filter(d => d.kind === 'audioinput'); const micSel = document.getElementById('mic-select'); if (micSel) { micSel.innerHTML = mics.map(d => `` ).join(''); } if (!_selectedMicId && mics.length) _selectedMicId = mics[0].deviceId; await populateOutputDeviceSelectors(); } async function onMicChange() { const sel = document.getElementById('mic-select'); if (!sel) return; _selectedMicId = sel.value; if (_pitchDetector) { _stopMic(); await _startMic(); } } async function onSpeakerChange() { const sel = document.getElementById('speaker-select'); if (!sel) return; _selectedSpeakerId = sel.value; const ctx = audioCtx(); if (ctx.setSinkId) { try { await ctx.setSinkId(_selectedSpeakerId || ''); } catch (e) { console.warn('setSinkId failed:', e); } } } async function _startMic() { if (_pitchDetector) return; await _populateMicList(); if (!_acousticMicFollower) resetAcousticMicFollower(); const gate = parseFloat(document.getElementById('noise-gate')?.value || 1) / 1000; _acousticMicFollower.calibration.noiseFloor = gate; _pitchDetector = new PitchDetector(audioCtx(), { threshold: gate, deviceId: _selectedMicId || undefined, onNote: (midi, info) => { _micDot('hearing'); if (handleMicLatencyNote(midi, info)) { const noteDisplay = document.getElementById('mic-note-display'); if (noteDisplay) noteDisplay.textContent = pitchName(midi); setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 300); return; } const freqText = info?.freq ? ` ${info.freq.toFixed(1)} Hz` : ''; const noteDisplay = document.getElementById('mic-note-display'); if (noteDisplay) noteDisplay.textContent = `${pitchName(midi)}${freqText}`; if (state.playing) { queueMicNoteForFollowing(midi, info); } setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 300); }, onChord: (pitches, info) => { if (_micLatencyCalibration.active) return; _micDot('hearing'); const noteDisplay = document.getElementById('mic-note-display'); if (noteDisplay) noteDisplay.textContent = pitches.map((pitch) => pitchName(pitch)).join(' / '); handleMicChord(pitches, info); setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 300); }, onSilence: () => { const noteDisplay = document.getElementById('mic-note-display'); if (noteDisplay && !_micLatencyCalibration.active) noteDisplay.textContent = ''; _micDot('active'); }, onLevel: (rms) => _drawMeter(rms), onDebug: (msg) => { const debug = document.getElementById('mic-debug'); if (debug) debug.textContent = msg; }, onFrame: (input, meta) => handleAcousticMicFrame(input, meta), }); // Keep meter decaying to zero when silent (function decayLoop() { if (!_pitchDetector) { _drawMeter(0); return; } requestAnimationFrame(decayLoop); })(); try { await _pitchDetector.start(); syncExpectedMicNote(); _micDot('active'); } catch (e) { _pitchDetector = null; if (_micLatencyCalibration.active) { cancelMicLatencyCalibration({ resetStatus: false }); setMicLatencyStatus(e.message || 'Microphone access failed.', 'error'); } else { alert(e.message); } setInputMode('keyboard'); } } function _stopMic() { if (_pitchDetector) { _pitchDetector.stop(); _pitchDetector = null; } clearPendingMicNote(); _micDot(null); const noteDisplay = document.getElementById('mic-note-display'); const debug = document.getElementById('mic-debug'); if (noteDisplay) noteDisplay.textContent = ''; if (debug) debug.textContent = ''; updateMicFollowerPanel(null); } // ── Computer keyboard input ─────────────────────────────────────────────────── document.addEventListener('keydown', e => { if (e.repeat) return; if (['INPUT','TEXTAREA'].includes(e.target.tagName)) return; const midi = resolveTypedMidi(e.code); if (midi !== undefined) { e.preventDefault(); handleKeyboardInput(midi); } }); // ── Web MIDI input ──────────────────────────────────────────────────────────── function setMidiBadge(stateClass, text, title = '') { const badge = document.getElementById('midi-status'); const label = document.getElementById('midi-status-text'); if (!badge || !label) return; badge.classList.remove('midi-badge-connected', 'midi-badge-disconnected', 'midi-badge-unsupported'); badge.classList.add(stateClass); label.textContent = text; badge.title = title || 'MIDI piano status'; } function isVirtualMidiInput(input) { const label = `${input?.name || ''} ${input?.manufacturer || ''}`.toLowerCase(); if (!label.trim()) return false; return [ 'iac driver', 'network session', 'session 1', 'python midi', 'midi output', 'loopmidi', 'loopbe', 'virtual', 'through port', 'midi through', ].some((needle) => label.includes(needle)); } function refreshMidiInputs(access) { const inputs = Array.from(access.inputs.values()); const pianoInputs = inputs.filter((input) => input.state !== 'disconnected' && !isVirtualMidiInput(input)); const ignoredInputs = inputs.filter((input) => input.state !== 'disconnected' && isVirtualMidiInput(input)); _midiConnected = pianoInputs.length > 0; for (const input of ignoredInputs) { input.onmidimessage = null; } for (const input of pianoInputs) { input.onmidimessage = ({ data }) => { const [status, pitch, velocity] = data; if ((status & 0xF0) === 0x90 && velocity > 0) handleMidiNote(pitch); }; } if (pianoInputs.length === 0) { const ignoredNames = ignoredInputs.map((input) => input.name || 'Virtual MIDI port').join(', '); setMidiBadge( 'midi-badge-disconnected', 'No piano detected', ignoredNames ? `Ignored virtual MIDI ports: ${ignoredNames}` : 'MIDI piano status' ); } else { const name = pianoInputs[0].name || 'MIDI device'; const extra = pianoInputs.length > 1 ? ` (+${pianoInputs.length - 1})` : ''; setMidiBadge('midi-badge-connected', `Piano connected: ${name}${extra}`); } } function enableMidi() { if (!navigator.requestMIDIAccess) { setMidiBadge('midi-badge-unsupported', 'MIDI not supported'); return; } navigator.requestMIDIAccess().then(access => { refreshMidiInputs(access); access.onstatechange = () => refreshMidiInputs(access); }).catch(() => { setMidiBadge('midi-badge-disconnected', 'MIDI access denied'); }); } // ── Delete piece ───────────────────────────────────────────────────────────── function confirmDialog({ title = 'Are you sure?', message = '', confirmText = 'Delete', cancelText = 'Cancel', danger = true } = {}) { return new Promise((resolve) => { const modal = document.getElementById('confirm-modal'); const titleEl = document.getElementById('confirm-modal-title'); const msgEl = document.getElementById('confirm-modal-message'); const okBtn = document.getElementById('confirm-modal-ok'); const cancelBtn = document.getElementById('confirm-modal-cancel'); if (!modal || !okBtn || !cancelBtn) { resolve(window.confirm(message)); return; } titleEl.textContent = title; msgEl.textContent = message; okBtn.textContent = confirmText; cancelBtn.textContent = cancelText; okBtn.classList.toggle('btn-danger', !!danger); okBtn.classList.toggle('btn-primary', !danger); modal.style.display = 'flex'; const cleanup = (result) => { modal.style.display = 'none'; okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); modal.removeEventListener('click', onBackdrop); document.removeEventListener('keydown', onKey); resolve(result); }; const onOk = () => cleanup(true); const onCancel = () => cleanup(false); const onBackdrop = (ev) => { if (ev.target === modal) cleanup(false); }; const onKey = (ev) => { if (ev.key === 'Escape') cleanup(false); else if (ev.key === 'Enter') cleanup(true); }; okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); modal.addEventListener('click', onBackdrop); document.addEventListener('keydown', onKey); setTimeout(() => okBtn.focus(), 0); }); } async function deleteScore(e, name) { e.stopPropagation(); if (state.guestMode || isGuestScoreName(name)) { showGuestUpgradeToast('Demo pieces are built in. Create a free account to manage your own library.'); return; } if (_appConfig.auth_enabled) { if (!await confirmDialog({ title: 'Delete piece?', message: `Delete "${formatName(name)}" from this account? This cannot be undone.` })) return; await api(`/api/scores/${encodeURIComponent(name)}`, { method: 'DELETE' }); clearSheetHtmlCache(name); localStorage.removeItem(`accompy_score_v2_${_authUser?.id || _authUser?.username || 'auth'}_${name}`); await loadScoreList(); return; } if (!await confirmDialog({ title: 'Remove piece?', message: `Remove "${formatName(name)}" from this browser's list?`, confirmText: 'Remove' })) return; clearSheetHtmlCache(name); removeScoreFromLibrary(name); await loadScoreList(); } async function renameScore(e, name) { e.stopPropagation(); if (state.guestMode || isGuestScoreName(name)) { showGuestUpgradeToast('Create a free account to rename and manage your own library.'); return; } const currentTitle = state.scoreMeta?.[name]?.title || formatName(name); const title = prompt('Rename piece', currentTitle)?.trim(); if (!title || title === currentTitle) return; if (_appConfig.auth_enabled && !_authUser) { setAuthStatus('Sign in first to rename pieces.', 'error'); return; } try { if (_appConfig.auth_enabled) { const result = await api(`/api/scores/${encodeURIComponent(name)}/title`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }), }); state.scoreMeta[name] = { ...(state.scoreMeta[name] || {}), title: result.title || title }; } else { state.scoreMeta[name] = { ...(state.scoreMeta[name] || {}), title }; } await loadScoreList(); renderPlayPieceList(); } catch (error) { alert(`Rename failed: ${readApiErrorMessage(error)}`); } } window.renameScore = renameScore; // ── Add piece modal ─────────────────────────────────────────────────────────── let _searchTimer = null; function openAddModal() { if (state.guestMode) { showGuestUpgradeToast('Create a free account to upload personal sheet music and keep it in your library.'); return; } if (_appConfig.auth_enabled && !_authUser) { setAuthStatus('Sign in first to add pieces.', 'error'); return; } document.getElementById('add-modal').style.display = 'flex'; document.getElementById('corpus-search').value = ''; document.getElementById('import-name').value = ''; document.getElementById('import-files').value = ''; updateImportFileLabel([]); setImportStatus('No files selected.'); setImportProgress({ visible: false }); document.getElementById('search-results').innerHTML = '

    Type to search the built-in music library (535 pieces).

    '; setTimeout(() => document.getElementById('corpus-search').focus(), 50); } function closeAddModal(e) { if (e && e.target !== document.getElementById('add-modal')) return; document.getElementById('add-modal').style.display = 'none'; } document.addEventListener('keydown', e => { if (e.key === 'Escape') document.getElementById('add-modal').style.display = 'none'; }); const savedScoreGridColumns = localStorage.getItem('accompy_score_grid_columns'); if (savedScoreGridColumns) { state.scoreGridColumns = normalizedScoreGridColumns(savedScoreGridColumns); } state.keyboardLayoutMode = localStorage.getItem('accompy_keyboard_layout_mode') === 'mini' ? 'mini' : 'full'; applyScoreGridColumns(state.scoreGridColumns); applyKeyboardLayoutMode(state.keyboardLayoutMode); initPracticeSplitter(); applyTheme(localStorage.getItem('accompy_theme') || 'dark'); window.addEventListener('resize', () => { resizeScorePreviews(); applyShellSplit(localStorage.getItem(SHELL_SPLIT_KEY)); applyPanelSplit(localStorage.getItem(PANEL_SPLIT_KEY)); applyPracticeSplit(localStorage.getItem(PRACTICE_SPLIT_KEY)); }); // ── Click-to-play on keyboard keys ─────────────────────────────────────────── function midiFromKeyboardEl(el) { const refKey = el.closest?.('.ref-key'); if (refKey) { const midi = parseInt(refKey.dataset.midi, 10); return Number.isFinite(midi) ? { midi, highlightId: refKey.id } : null; } const kbKey = el.closest?.('.kb-key'); if (kbKey?.id?.startsWith('kbslot-')) { const slotId = kbKey.id.slice('kbslot-'.length); const slot = VISUAL_SLOTS.find((s) => s.id === slotId); if (slot) return { midi: resolveKeyboardPitchesForPitchClass(slot.pitchClass), highlightId: kbKey.id }; } return null; } const _keyboardSectionEl = document.getElementById('keyboard-section'); if (_keyboardSectionEl) { _keyboardSectionEl.addEventListener('click', (e) => { const hit = midiFromKeyboardEl(e.target); if (!hit) return; if (typeof Tone !== 'undefined' && Tone.start) Tone.start().catch(() => {}); if (state.playing && !state.paused && state.tracker) { // Same flow as a physical keypress — plays the note and advances the tracker. handleKeyboardInput(hit.midi); return; } // Preview mode — piece isn't running; just play the note + flash the key. const instrument = getInstrumentForPart(state.selectedPart ?? 0); const pitches = Array.isArray(hit.midi) ? hit.midi : [hit.midi]; pitches.forEach((midi) => { if (hasLoadedSampler(instrument)) { playNote(midi, 0.6, instrument, { duration: 0.45, outputRole: 'solo', volume: state.playbackVolumes?.solo ?? 1 }); } else { const soloVolume = normalizePlaybackVolume(state.playbackVolumes?.solo, 1, maxPlaybackVolumeForChannel('solo')); playSynthNote(midi, 0.6 * soloVolume, instrument, { duration: 0.45, outputRole: 'solo' }); } highlightKey(`refkey-${midi}`, true); setTimeout(() => highlightKey(`refkey-${midi}`, false), 180); }); highlightKey(hit.highlightId, true); setTimeout(() => highlightKey(hit.highlightId, false), 180); }); } function onSearchInput() { clearTimeout(_searchTimer); _searchTimer = setTimeout(runSearch, 300); } function setImportStatus(message, tone = 'muted') { const status = document.getElementById('import-status'); if (!status) return; status.textContent = message; status.hidden = tone === 'muted'; status.style.color = tone === 'error' ? '#e05c5c' : tone === 'success' ? 'var(--success)' : 'var(--muted)'; } function formatImportEta(seconds) { if (!Number.isFinite(seconds) || seconds <= 0) return ''; const rounded = Math.max(1, Math.round(seconds)); if (rounded < 60) return `about ${rounded}s remaining`; const minutes = Math.floor(rounded / 60); const extraSeconds = rounded % 60; if (minutes < 60) { return extraSeconds ? `about ${minutes}m ${extraSeconds}s remaining` : `about ${minutes}m remaining`; } const hours = Math.floor(minutes / 60); const extraMinutes = minutes % 60; return extraMinutes ? `about ${hours}h ${extraMinutes}m remaining` : `about ${hours}h remaining`; } function estimateImportEta(progress, startedAt, active) { if (!active || !startedAt) return ''; const normalized = Math.max(0, Math.min(99, Number(progress) || 0)); const elapsedSeconds = (Date.now() - startedAt) / 1000; if (elapsedSeconds < 3 || normalized < 3) return 'Estimating…'; const remainingSeconds = elapsedSeconds * ((100 - normalized) / normalized); return formatImportEta(remainingSeconds) || 'Estimating…'; } function setImportProgress({ visible = true, progress = 0, message = '', active = false, eta = '' } = {}) { const container = document.getElementById('import-progress'); const fill = document.getElementById('import-progress-fill'); const label = document.getElementById('import-progress-label'); const percent = document.getElementById('import-progress-percent'); if (!container || !fill || !label || !percent) return; const normalized = Math.max(0, Math.min(100, Number(progress) || 0)); container.hidden = !visible; container.classList.toggle('active', !!active); fill.style.width = `${normalized}%`; label.textContent = message || 'Working…'; percent.textContent = eta ? `${Math.round(normalized)}% · ${eta}` : `${Math.round(normalized)}%`; } async function waitForImportJob(jobId, startedAt = Date.now()) { let lastProgress = 0; while (true) { const job = await api(`/api/import/jobs/${encodeURIComponent(jobId)}`); lastProgress = Math.max(lastProgress, Number(job.progress) || 0); const baseMessage = job.message || 'Importing…'; const active = job.status === 'queued' || job.status === 'running'; setImportProgress({ visible: true, progress: lastProgress, message: baseMessage, active, eta: estimateImportEta(lastProgress, startedAt, active), }); setImportStatus(baseMessage); if (job.status === 'completed') return job.result; if (job.status === 'failed') throw new Error(job.error || job.message || 'Import failed'); await new Promise((resolve) => setTimeout(resolve, 900)); } } function updateImportFileLabel(files) { const label = document.getElementById('import-file-label'); if (!label) return; if (!files.length) { label.textContent = 'No files selected'; return; } label.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`; } async function runSearch() { const q = document.getElementById('corpus-search').value.trim(); const spinner = document.getElementById('search-spinner'); spinner.style.display = 'inline'; try { const { results } = await api(`/api/corpus/search?q=${encodeURIComponent(q)}`); renderSearchResults(results); } catch (e) { document.getElementById('search-results').innerHTML = `

    Error: ${e.message}

    `; } finally { spinner.style.display = 'none'; } } function renderSearchResults(results) { const already = new Set(state.scores); if (!results.length) { document.getElementById('search-results').innerHTML = '

    No results found.

    '; return; } document.getElementById('search-results').innerHTML = results.map(r => { const safeName = r.path.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); const added = already.has(safeName); return `
    ${r.composer} — ${r.title}
    ${r.path}
    `; }).join(''); } async function addPiece(corpusPath, safeName) { const item = document.getElementById(`result-${safeName}`); item.classList.add('adding'); item.querySelector('button').textContent = 'Converting…'; try { await api('/api/convert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ corpus_path: corpusPath, name: safeName }), }); item.classList.remove('adding'); item.classList.add('done'); item.querySelector('button').textContent = 'Added'; item.querySelector('button').disabled = true; clearSheetHtmlCache(safeName); addScoreToLibrary(safeName); // Refresh score list in background await loadScoreList(); } catch (e) { item.classList.remove('adding'); item.querySelector('button').textContent = '+ Add'; alert(`Failed to convert: ${readApiErrorMessage(e)}`); } } async function importScoreFiles() { const fileInput = document.getElementById('import-files'); const nameInput = document.getElementById('import-name'); const button = document.getElementById('import-btn'); const files = [...(fileInput?.files || [])]; if (!files.length) { setImportStatus('Choose a MusicXML/MuseScore file, LilyPond .ily files, a .zip bundle, one PDF, or page images first.', 'error'); return; } const form = new FormData(); files.forEach((file) => form.append('files', file)); form.append('name', (nameInput?.value || '').trim()); button.disabled = true; const importStartedAt = Date.now(); const isDirectScore = files.length === 1 && /\.(xml|mxl|musicxml|mscx|mscz)$/i.test(files[0].name || ''); const isLilyPond = files.every((file) => /\.(ily|ly)$/i.test(file.name || '')) || (files.length === 1 && /\.zip$/i.test(files[0].name || '')); setImportStatus( isLilyPond ? 'Combining LilyPond parts and building the score…' : isDirectScore ? 'Importing score file and building the score…' : 'Running Audiveris and converting the score…' ); setImportProgress({ visible: true, progress: isDirectScore || isLilyPond ? 18 : 8, message: isLilyPond ? 'Uploading LilyPond parts…' : isDirectScore ? 'Uploading score file…' : 'Uploading for Audiveris…', active: true, eta: 'Estimating…', }); try { const response = await fetch('/api/import/start', { method: 'POST', body: form }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.detail || 'Import failed'); } const result = await waitForImportJob(payload.id, importStartedAt); if (!result?.name) throw new Error('Import completed without a score name.'); clearSheetHtmlCache(result.name); addScoreToLibrary(result.name); setImportProgress({ visible: true, progress: 100, message: 'Import complete', active: false }); setImportStatus(`Imported ${formatName(result.name)}.`, 'success'); await loadScoreList(); setTimeout(() => closeAddModal(), 3000); } catch (error) { setImportProgress({ visible: true, progress: 100, message: error.message || 'Import failed.', active: false }); setImportStatus(error.message || 'Import failed.', 'error'); } finally { button.disabled = false; } } function initImportControls() { const importFiles = document.getElementById('import-files'); const dropzone = document.getElementById('import-dropzone'); const importBtn = document.getElementById('import-btn'); importFiles?.addEventListener('change', (event) => { const files = [...(event.target.files || [])]; updateImportFileLabel(files); if (!files.length) { setImportStatus('No files selected.'); setImportProgress({ visible: false }); return; } const label = files.length === 1 ? files[0].name : `${files.length} files selected`; setImportStatus(label); setImportProgress({ visible: false }); }); dropzone?.addEventListener('click', () => importFiles?.click()); dropzone?.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); importFiles?.click(); }); ['dragenter', 'dragover'].forEach((eventName) => { dropzone?.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach((eventName) => { dropzone?.addEventListener(eventName, () => { dropzone.classList.remove('drag-over'); }); }); dropzone?.addEventListener('drop', (event) => { event.preventDefault(); if (!importFiles || !event.dataTransfer?.files?.length) return; importFiles.files = event.dataTransfer.files; importFiles.dispatchEvent(new Event('change', { bubbles: true })); }); importBtn?.addEventListener('click', (event) => { event.preventDefault(); importScoreFiles(); }); } window.importScoreFiles = importScoreFiles; window.setPlaybackVolume = setPlaybackVolume; window.onOutputDeviceChange = onOutputDeviceChange; window.setSheetDisplayMode = setSheetDisplayMode; window.toggleSheetDisplayMode = toggleSheetDisplayMode; window.toggleAdaptiveTempo = toggleAdaptiveTempo; window.toggleFollowMode = toggleFollowMode; window.loadScorePreviewOnDemand = loadScorePreviewOnDemand; function initTempoControls() { const input = document.getElementById('bpm-input'); if (!input || input.dataset.bound === '1') return; input.dataset.bound = '1'; input.addEventListener('input', () => { if (state.playing) return; state.practiceRightHand = null; state.practiceLeftHand = null; buildKeyboard(getRightHand()); updateNextKey(getRightHand(), 0); renderNoteHighway(); saveCurrentScorePreferences(); }); } // ── Init ────────────────────────────────────────────────────────────────────── initLatencyControls(); initAdaptiveTempoControl(); initFollowModeControl(); initPlaybackVolumeControls(); initOutputRoutingControls(); initSheetDisplayMode(); initImportControls(); initTempoControls(); initKeyboardLayoutToggle(); initPlaySidebar(); initHorizontalSplitters(); onNoiseGateChange(document.getElementById('noise-gate')?.value || '1'); setLatencyCompensation(localStorage.getItem('accompy_latency_comp_ms') || '0', { persistScore: false }); initAppConfig().then(async () => { const route = parseAppRoute(); const canUseLibrary = state.guestMode || !_appConfig.auth_enabled || _authUser; if (canUseLibrary && route.mode === 'score') { await Promise.all([ loadScoreList(), openRouteFromLocation({ replace: true }), ]); } else { await openRouteFromLocation({ replace: true }); } });