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