// ── 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','strings','flute','clarinet','oboe','voice']; const INSTRUMENT_EMOJI = { piano:'🎹', violin:'🎻', viola:'🎻', cello:'🎻', strings:'🎻', flute:'🪈', clarinet:'🎷', oboe:'🎷', voice:'🎤', }; const TEMPO_PAUSE_IGNORE_SEC = 3; let _accompanimentLatencyCompSec = 0.28; const CHORD_MATCH_WINDOW_SEC = 0.5; const MIDI_DUPLICATE_NOTE_GUARD_MS = 18; // ── Playback engines ──────────────────────────────────────────────────────── let _audioCtx = null; let _midiConnected = false; let _lastMidiNoteTime = 0; let _lastMidiPitch = null; let _pedalResonanceBus = null; function audioCtx() { if (!_audioCtx) _audioCtx = new AudioContext({ latencyHint: 'interactive' }); return _audioCtx; } const SAMPLE_ALIAS = { viola: 'violin', strings: 'violin', oboe: 'clarinet', }; const SAMPLE_LIBRARY = { piano: { baseUrl: 'https://tonejs.github.io/audio/salamander/', release: 0.45, noteDuration: 0.5, urls: { A0: 'A0.mp3', C1: 'C1.mp3', 'D#1': 'Ds1.mp3', 'F#1': 'Fs1.mp3', A1: 'A1.mp3', C2: 'C2.mp3', 'D#2': 'Ds2.mp3', 'F#2': 'Fs2.mp3', A2: 'A2.mp3', C3: 'C3.mp3', 'D#3': 'Ds3.mp3', 'F#3': 'Fs3.mp3', A3: 'A3.mp3', C4: 'C4.mp3', 'D#4': 'Ds4.mp3', 'F#4': 'Fs4.mp3', A4: 'A4.mp3', C5: 'C5.mp3', 'D#5': 'Ds5.mp3', 'F#5': 'Fs5.mp3', A5: 'A5.mp3', C6: 'C6.mp3', 'D#6': 'Ds6.mp3', 'F#6': 'Fs6.mp3', A6: 'A6.mp3', C7: 'C7.mp3', 'D#7': 'Ds7.mp3', 'F#7': 'Fs7.mp3', A7: 'A7.mp3', C8: 'C8.mp3', }, }, violin: { baseUrl: 'https://cdn.jsdelivr.net/npm/tonejs-instrument-violin-mp3@1.1.1/', release: 0.35, noteDuration: 0.45, urls: { A3: 'A3.mp3', A4: 'A4.mp3', A5: 'A5.mp3', A6: 'A6.mp3', C4: 'C4.mp3', C5: 'C5.mp3', C6: 'C6.mp3', C7: 'C7.mp3', E4: 'E4.mp3', E5: 'E5.mp3', E6: 'E6.mp3', G3: 'G3.mp3', G4: 'G4.mp3', G5: 'G5.mp3', G6: 'G6.mp3', }, }, cello: { baseUrl: 'https://cdn.jsdelivr.net/npm/tonejs-instrument-cello-mp3@1.1.1/', release: 0.4, noteDuration: 0.5, urls: { A2: 'A2.mp3', A3: 'A3.mp3', A4: 'A4.mp3', 'A#2': 'As2.mp3', 'A#3': 'As3.mp3', 'A#4': 'As4.mp3', B2: 'B2.mp3', B3: 'B3.mp3', B4: 'B4.mp3', C2: 'C2.mp3', C3: 'C3.mp3', C4: 'C4.mp3', C5: 'C5.mp3', 'C#3': 'Cs3.mp3', 'C#4': 'Cs4.mp3', D2: 'D2.mp3', D3: 'D3.mp3', D4: 'D4.mp3', 'D#2': 'Ds2.mp3', 'D#3': 'Ds3.mp3', 'D#4': 'Ds4.mp3', E2: 'E2.mp3', E3: 'E3.mp3', E4: 'E4.mp3', F3: 'F3.mp3', F4: 'F4.mp3', 'F#3': 'Fs3.mp3', 'F#4': 'Fs4.mp3', G2: 'G2.mp3', G3: 'G3.mp3', G4: 'G4.mp3', 'G#2': 'Gs2.mp3', 'G#3': 'Gs3.mp3', 'G#4': 'Gs4.mp3', }, }, flute: { baseUrl: 'https://cdn.jsdelivr.net/npm/tonejs-instrument-flute-mp3@1.1.2/', release: 0.25, noteDuration: 0.4, urls: { A4: 'A4.mp3', A5: 'A5.mp3', A6: 'A6.mp3', C4: 'C4.mp3', C5: 'C5.mp3', C6: 'C6.mp3', C7: 'C7.mp3', E4: 'E4.mp3', E5: 'E5.mp3', E6: 'E6.mp3', }, }, clarinet: { baseUrl: 'https://cdn.jsdelivr.net/npm/tonejs-instrument-clarinet-ogg@1.1.0/', release: 0.3, noteDuration: 0.42, urls: { 'A#3': 'As3.ogg', 'A#4': 'As4.ogg', 'A#5': 'As5.ogg', D3: 'D3.ogg', D4: 'D4.ogg', D5: 'D5.ogg', D6: 'D6.ogg', F3: 'F3.ogg', F4: 'F4.ogg', F5: 'F5.ogg', 'F#6': 'Fs6.ogg', }, }, }; let _toneReady = false; const _sampleSamplers = {}; const _sampleSamplerReady = {}; // 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 }, 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' }, 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 currentBps() { if (state.playing && state.tracker) return state.tracker.bps(); if (state.playing && state.accompanist) return state.accompanist._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', 'strings'].includes(instrument) ? 1.15 : 1.0; const aliasBoost = ['violin', 'cello'].includes(sampledInstrument) ? 1.1 : 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 eventVelocity(event, fallback = 0.5) { const velocity = Number(eventDynamic(event)?.velocity); return Number.isFinite(velocity) ? Math.max(0.08, Math.min(1, velocity)) : fallback; } 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 familyBoost = ['violin', 'viola', 'cello', 'strings'].includes(instrument) ? 1.08 : 1.0; const pianoBoost = instrument === 'piano' ? 1.22 : 1.0; return Math.max(0.14, Math.min(10, seconds * familyBoost * pianoBoost)); } function ensurePedalResonanceBus() { if (_pedalResonanceBus) return _pedalResonanceBus; const ctx = audioCtx(); const convolver = ctx.createConvolver(); const impulseSeconds = 2.4; 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, 2.2); data[i] = (Math.random() * 2 - 1) * decay * 0.22; } } convolver.buffer = impulse; const pre = ctx.createGain(); pre.gain.value = 0.55; 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.05; pre.connect(convolver); convolver.connect(lowpass); lowpass.connect(highpass); highpass.connect(wet); wet.connect(ctx.destination); _pedalResonanceBus = { pre, wet }; return _pedalResonanceBus; } function playPedalResonance(pitches, velocity = 0.5, opts = {}) { if (!pitches?.length) return; const ctx = audioCtx(); const now = ctx.currentTime; const { pre, wet } = ensurePedalResonanceBus(); const duration = Math.max(0.7, Math.min(12, (opts.duration ?? 1.5) * 1.9 + 0.45)); const pedalHold = Math.max(duration, opts.pedalHold ?? duration); const resonanceGain = Math.min(0.16, 0.08 + velocity * 0.06) / Math.sqrt(pitches.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.05, wet.gain.value), now); wet.gain.linearRampToValueAtTime(Math.max(0.18, 0.12 + velocity * 0.12), now + 0.08); wet.gain.setValueAtTime(Math.max(0.18, 0.12 + velocity * 0.12), now + pedalHold); wet.gain.exponentialRampToValueAtTime(0.05, now + pedalHold + 0.45); pitches.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 || typeof Tone === 'undefined') return false; 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) => { _sampleSamplers[instrument] = new Tone.Sampler({ urls: config.urls, baseUrl: config.baseUrl, release: config.release, onload: resolve, onerror: reject, }).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); } 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]; } 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 = audioCtx(); const freq = 440 * Math.pow(2, (midi - 69) / 12); const preset = INSTRUMENT_PRESETS[instrument] || 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 masterGain = ctx.createGain(); masterGain.connect(ctx.destination); masterGain.gain.setValueAtTime(0, now); masterGain.gain.linearRampToValueAtTime(velocity * 0.35, now + preset.attack); 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 playNote(midi, velocity = 0.6, instrument = 'piano', opts = {}) { const sampledInstrument = SAMPLE_ALIAS[instrument] || instrument; const sampler = _sampleSamplers[sampledInstrument]; if (sampler && !opts.preferSynth) { 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, velocity)); if (instrument === 'piano' && opts.pedaled) { playPedalResonance([midi], velocity, opts); } return; } if (isSampleBackedInstrument(instrument)) return; playSynthNote(midi, velocity, 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)); pitches.forEach(p => playNote(p, noteVelocity, instrument, opts)); if (instrument === 'piano' && opts.pedaled) { playPedalResonance(pitches, velocity, opts); } } function eventPitches(event) { if (!event) return []; return Array.isArray(event[0]) ? event[0] : [event[0]]; } 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); 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); if (dynamic) { if (expanded.length === 3) expanded.push(null); expanded.push(dynamic); } 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; if (typeof _pitchDetector.setExpectedMidi !== 'function') return; const rightHand = getRightHand(); const position = state.tracker?.position ?? 0; const expected = leadPitchFromEvent(rightHand[position] ?? rightHand[0]); _pitchDetector.setExpectedMidi(expected); } // ── 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._lastAdvanceTime = 0; this._pendingChord = new Set(); this._pendingPosition = -1; this._pendingStartedAt = 0; this._recentNotes = []; this._lastAdvancedPosition = -1; } 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) : 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) : null; } // Mic mode: accept only the current note, with a small pitch tolerance. onNoteFuzzy(midi) { const expected = this.score[this.position]; if (!expected) return null; return Math.abs(leadPitchFromEvent(expected) - midi) <= 1 ? this._advance(this.position) : null; } _advance(i) { const now = performance.now() / 1000; if (now - this._lastAdvanceTime < 0.06) return null; this._lastAdvanceTime = now; this._pendingChord.clear(); this._pendingPosition = -1; this._pendingStartedAt = 0; this.position = i + 1; this._lastAdvancedPosition = i; const beat = this.score[i][1]; this.timestamps.push({ time: now, beat }); if (this.timestamps.length > 5) this.timestamps.shift(); return beat; } bps() { const ts = this.timestamps; if (ts.length < 2) return this._smoothedBps; 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 >= 0.08 && 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; } isFinished() { return this.position >= this.score.length; } progress() { return this.position / this.score.length; } } // ── Accompanist ────────────────────────────────────────────────────────────── class Accompanist { constructor(leftHand, rightHand, initialBps, leftInstruments = []) { // 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); this._bps = initialBps; this._syncBeat = 0; this._syncTime = null; // null = waiting for first RH note this._nextSync = this.rhBeats[0] ?? Infinity; this._lhIdx = 0; this._running = false; this._raf = null; } start() { 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; } resume(beat, bps) { this._bps = bps; this._syncBeat = beat; this._syncTime = performance.now() / 1000 - _accompanimentLatencyCompSec; this._nextSync = this.rhBeats.find((b) => b > beat + 0.01) ?? Infinity; this._running = true; this._tick(); } onRhNote(beat, bps) { this._bps = bps; 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; this._nextSync = this.rhBeats.find(b => b > beat + 0.01) ?? Infinity; // 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; return this._syncBeat + (performance.now()/1000 - this._syncTime) * this._bps; } _tick() { if (!this._running) return; if (this._syncTime !== null && this._lhIdx < this.events.length) { const [pitches, beat, durationBeats] = this.events[this._lhIdx]; // Pause before next RH sync point if (beat < this._nextSync - 0.01) { const current = this._currentBeat(); if (current >= beat - 0.005) { const instr = this._instruments[0] || 'piano'; const duration = Math.max(0.12, (durationBeats ?? 0.75) / Math.max(0.5, this._bps)); 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, }); this._lhIdx++; } } } this._raf = requestAnimationFrame(() => this._tick()); } } // ── App State ──────────────────────────────────────────────────────────────── let state = { scores: [], serverScores: [], current: null, fingeringJob: null, tracker: null, accompanist: null, practiceRightHand: null, practiceLeftHand: null, playing: false, selectedPart: 0, partInstruments: {}, // partIndex → instrument name override scoreGridColumns: 3, keyboardLayoutMode: 'full', paused: false, pausedBeat: 0, pausedBps: 1, finishedPlayback: false, guestMode: false, sheetView: { zoom: 1.0, rotation: 0 }, sheetSource: null, // { name, variant, hasSheet, musicXml } sheetVariant: 'base', }; let _noteHighwayRaf = null; let _noteHighwayStartTime = null; let _noteHighwayStartBeat = 0; let _noteHighwayBps = 1; let _finishPlaybackTimer = null; let _sheetMeasureEls = []; let _sheetHighlightRect = null; let _sheetHighlightIndex = -1; 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 PRACTICE_SPLITTER_SIZE = 12; const LAYOUT_SPLITTER_SIZE = 12; let _appConfig = { supabase_enabled: false, auth_enabled: false }; let _authUser = null; const OPENING_GUIDE_LEAD_BEATS = 0.75; 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(await r.text()); return r.json(); } 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 || formatName(name); } function guestScorePayload(score) { const parts = score.parts.map((part) => ({ name: part.name, instrument: part.instrument || 'piano', notes: part.notes.map((note) => Array.isArray(note) ? [...note] : note), })); return { name: score.name, title: score.title, source_type: 'guest', guest: true, has_sheet: false, has_fingered_sheet: false, sheet_html: '', musicxml_source: guestMusicXml(score), fingered_musicxml_source: '', fingering: { eligible: false, available: false, applied: false }, measure_beats: score.measure_beats || [0, 4, 8], parts, right_hand: parts[0]?.notes || [], left_hand: parts.slice(1).flatMap((part) => part.notes), tips: score.tips || [], }; } function guestMusicXml(score) { const starts = score.measure_beats || [0]; const parts = score.parts?.length ? score.parts : []; const partList = parts.map((part, index) => `${escapeXml(part.name || `Part ${index + 1}`)}` ).join(''); const partXml = parts.map((part, partIndex) => { const measures = starts.map((start, measureIndex) => { const end = starts[measureIndex + 1] ?? start + 4; return guestMeasureXml(part.notes || [], start, end, measureIndex, partIndex); }).join(''); return `${measures}`; }).join(''); return ` ${escapeXml(score.title)} ${partList} ${partXml} `; } function guestMeasureXml(events, start, end, measureIndex, partIndex) { const attrs = measureIndex === 0 ? `40${partIndex === 0 ? 'G' : 'F'}${partIndex === 0 ? '2' : '4'}` : ''; const measureEvents = [...events] .filter((event) => event[1] >= start && event[1] < end) .sort((a, b) => a[1] - b[1]); let cursor = start; const notes = []; measureEvents.forEach((event) => { const beat = Math.max(start, Number(event[1]) || start); if (beat > cursor) notes.push(guestRestXml(beat - cursor)); const eventBeats = Math.min(eventDuration(event, 1), end - beat); notes.push(guestEventXml(event, eventBeats)); cursor = Math.max(cursor, beat + eventBeats); }); if (cursor < end) notes.push(guestRestXml(end - cursor)); return `${attrs}${notes.join('') || guestRestXml(end - start)}`; } function guestEventXml(event, beats) { const pitches = eventPitches(event); if (!pitches.length) return guestRestXml(beats); return pitches.map((pitch, index) => guestNoteXml(pitch, beats, index > 0)).join(''); } function guestNoteXml(pitch, beats, chord = false) { const duration = Math.max(1, Math.round(Math.max(0.25, beats || 1) * 4)); const { step, alter, octave } = midiPitchParts(pitch); const type = duration >= 8 ? 'half' : duration >= 4 ? 'quarter' : duration >= 2 ? 'eighth' : '16th'; return `${chord ? '' : ''}${step}${alter ? `${alter}` : ''}${octave}${duration}${type}`; } function guestRestXml(beats) { const duration = Math.max(1, Math.round(Math.max(0.25, beats || 1) * 4)); const type = duration >= 16 ? 'whole' : duration >= 8 ? 'half' : duration >= 4 ? 'quarter' : duration >= 2 ? 'eighth' : '16th'; return `${duration}${type}`; } function midiPitchParts(midi) { const names = [ ['C', 0], ['C', 1], ['D', 0], ['D', 1], ['E', 0], ['F', 0], ['F', 1], ['G', 0], ['G', 1], ['A', 0], ['A', 1], ['B', 0], ]; const [step, alter] = names[midi % 12]; return { step, alter, octave: Math.floor(midi / 12) - 1 }; } function escapeXml(value) { return String(value ?? '').replace(/[<>&'"]/g, (char) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"', }[char])); } // ── Screens ────────────────────────────────────────────────────────────────── function showScreen(id) { document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); document.getElementById(id).classList.add('active'); document.body.classList.toggle('practice-mode', id === 'play-screen'); const header = document.getElementById('app-header'); if (header) header.hidden = id === 'play-screen'; } function routeScoreName() { const raw = window.location.pathname.replace(/^\/+|\/+$/g, ''); if (!raw || raw.startsWith('api/') || raw === 'auth/callback') return null; try { return decodeURIComponent(raw); } catch { return raw; } } function updateScoreUrl(name, replace = false) { const nextPath = name ? `/${encodeURIComponent(name)}` : '/'; if (window.location.pathname === nextPath) return; const method = replace ? 'replaceState' : 'pushState'; window.history[method]({ score: name || null }, '', nextPath); } function goHome() { if (state.playing) stopPlaying(); updateScoreUrl(null); showScreen('list-screen'); } window.goHome = goHome; 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 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(); document.body.classList.toggle('guest-mode', state.guestMode); 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) { document.body.classList.remove('auth-mode'); panel.style.display = 'none'; loggedOut.style.display = 'none'; addPieceBtn.disabled = false; addPieceBtn.textContent = '+ Add piece'; if (navAuthUser) navAuthUser.style.display = 'none'; if (navUsername) navUsername.textContent = ''; renderPlayPieceList(); return; } if (!_appConfig.auth_enabled) { document.body.classList.remove('auth-mode'); panel.style.display = 'none'; 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; } const isLoggedIn = !!_authUser; document.body.classList.toggle('auth-mode', !isLoggedIn); panel.style.display = isLoggedIn ? 'none' : 'block'; loggedOut.style.display = isLoggedIn ? 'none' : 'block'; if (googleBtn) googleBtn.style.display = _appConfig.google_auth_enabled ? 'inline-flex' : 'none'; if (googleDivider) googleDivider.style.display = _appConfig.google_auth_enabled ? 'flex' : 'none'; addPieceBtn.disabled = !isLoggedIn; addPieceBtn.textContent = '+ Add piece'; if (navAuthUser) navAuthUser.style.display = isLoggedIn ? 'flex' : 'none'; if (isLoggedIn) { if (navUsername) navUsername.textContent = _authUser.email || _authUser.username || 'Signed in'; setAuthStatus(''); } else { if (navUsername) navUsername.textContent = ''; document.getElementById('score-grid').innerHTML = '
Sign in to load your score library.
'; } renderPlayPieceList(); } function accountLabel() { if (state.guestMode) return 'Guest Mode'; return _authUser?.email || _authUser?.username || 'Not signed in'; } function accountInitials() { if (state.guestMode) return 'G'; const label = accountLabel(); if (!label || label === 'Not signed in') return '?'; const parts = label.split(/[@\s._-]+/).filter(Boolean); return (parts.length >= 2 ? parts[0][0] + parts[1][0] : label.slice(0, 1)).toUpperCase(); } function updateSidebarAccountUI() { const button = document.getElementById('sidebar-account-btn'); const name = document.getElementById('sidebar-account-name'); const detail = document.getElementById('sidebar-account-detail'); const action = document.getElementById('sidebar-account-action'); if (!button || !name || !detail || !action) return; button.textContent = accountInitials(); name.textContent = accountLabel(); if (state.guestMode) { detail.textContent = 'Demo changes stay on this device.'; action.textContent = 'Create Account'; } else if (_authUser) { detail.textContent = 'Signed in to NotePilot.'; action.textContent = 'Log out'; } else { detail.textContent = 'Sign in to save your library.'; action.textContent = 'Sign in'; } } function toggleSidebarAccountMenu(force) { const popover = document.getElementById('sidebar-account-popover'); const button = document.getElementById('sidebar-account-btn'); if (!popover || !button) return; const shouldOpen = typeof force === 'boolean' ? force : popover.style.display === 'none'; popover.style.display = shouldOpen ? 'block' : 'none'; button.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false'); if (shouldOpen) updateSidebarAccountUI(); } function sidebarAccountAction() { toggleSidebarAccountMenu(false); if (state.guestMode) { showCreateAccountPrompt('Create a free account to save your own pieces and progress.'); return; } if (_authUser) { signOut(); return; } showScreen('list-screen'); updateScoreUrl(null, true); updateAuthUI(); } 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({ score: null }, '', '/'); 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; window.history.replaceState({ score: null }, '', '/'); } catch (error) { window.history.replaceState({ score: null }, '', '/'); 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'); if (_appConfig.auth_enabled) { await completeGoogleRedirectIfNeeded(); const session = await api('/api/session'); _authUser = session.user || null; if (_authUser) { state.guestMode = false; localStorage.removeItem(GUEST_MODE_KEY); } } } catch (error) { _appConfig = { supabase_enabled: false, auth_enabled: false }; setAuthStatus(`Auth config failed to load: ${error.message || 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); await loadScoreList(); updateAuthUI(); await openRouteFromLocation({ replace: true }); setAuthStatus('Signed in.', 'success'); } catch (error) { setAuthStatus(error.message || '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); await loadScoreList(); updateAuthUI(); await openRouteFromLocation({ replace: true }); setAuthStatus('Account created.', 'success'); } catch (error) { setAuthStatus(error.message || '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.sheetSource = null; state.sheetVariant = 'base'; updateAuthUI(); renderPlayPieceList(); updateScoreUrl(null, true); showScreen('list-screen'); } 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(); updateScoreUrl(null, true); showScreen('list-screen'); await loadScoreList(); } 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(); updateScoreUrl(null, true); updateAuthUI(); showScreen('list-screen'); 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 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 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); 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) { const ms = Math.max(0, Math.min(500, Number.parseInt(value, 10) || 0)); _accompanimentLatencyCompSec = ms / 1000; 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 = `${ms} ms`; localStorage.setItem('accompy_latency_comp_ms', String(ms)); } function initLatencyControls() { const slider = document.getElementById('latency-slider'); if (!slider || slider.dataset.bound === '1') return; const sync = () => setLatencyCompensation(slider.value); slider.addEventListener('input', sync); slider.addEventListener('change', sync); slider.dataset.bound = '1'; } function applyTheme(theme) { const normalized = theme === 'light' ? 'light' : 'dark'; document.body.classList.toggle('light', normalized === 'light'); document.querySelectorAll('[data-theme-toggle]').forEach((toggle) => { toggle.textContent = normalized === 'light' ? 'Dark' : 'Light'; }); 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; const width = preview.clientWidth; if (!width) return; preview.style.setProperty('--preview-scale', String(width / 960)); }); } 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'); } applySheetFrameZoom(frame, state.sheetView?.zoom || 1); } 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 ───────────────────────────────────────────────────────── async function loadScoreList() { if (state.guestMode) { renderGuestDashboard(); return; } if (_appConfig.auth_enabled && !_authUser) { updateAuthUI(); return; } applyScoreGridColumns(state.scoreGridColumns); const { scores = [], items = [] } = await api('/api/scores'); state.serverScores = scores; const grid = document.getElementById('score-grid'); const itemByName = new Map((items.length ? items : scores.map(name => ({ name, has_sheet: false }))).map((item) => [item.name, item])); let scoreItems; if (_appConfig.auth_enabled) { state.scores = [...scores]; scoreItems = (items.length ? items : scores.map(name => ({ name, has_sheet: false }))); } 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 = library .map((name) => itemByName.get(name)) .filter(Boolean); } if (scoreItems.length === 0) { grid.innerHTML = `

Your library is empty

Click + Add piece at the top right to get started!

`; renderPlayPieceList(); return; } grid.innerHTML = scoreItems.map(({ name, has_sheet }) => `
${has_sheet ? `` : `
No sheet preview
` }

${displayNameForScore(name)}

${name}
`).join(''); document.querySelectorAll('.score-preview-frame').forEach((frame) => { frame.addEventListener('load', () => sanitizeSheetFrame(frame), { once: true }); sanitizeSheetFrame(frame); }); requestAnimationFrame(() => resizeScorePreviews()); renderPlayPieceList(); } function renderGuestDashboard() { updateAuthUI(); applyScoreGridColumns(state.scoreGridColumns); const grid = document.getElementById('score-grid'); if (!grid) return; const progress = loadGuestProgress(); state.scores = GUEST_DEMO_SCORES.map((score) => score.name); grid.innerHTML = GUEST_DEMO_SCORES.map((score) => { const completed = !!progress[score.name]?.completed; const primary = score.primary ? ' primary' : ''; return `
${completed ? 'Completed demo' : (score.primary ? 'Start here' : 'Included demo')}

${score.title}

${score.subtitle}
`; }).join(''); requestAnimationFrame(() => resizeScorePreviews()); renderPlayPieceList(); } function renderPlayPieceList() { const root = document.getElementById('play-piece-list'); if (!root) return; const currentName = state.current?.name || null; const names = Array.isArray(state.scores) ? state.scores : []; if (!names.length) { root.innerHTML = '
No pieces in your library yet.
'; return; } root.innerHTML = names.map((name) => { const active = name === currentName; return ` `; }).join(''); } function initPlaySidebar() { applyPlaySidebarCollapsed(localStorage.getItem(PLAY_SIDEBAR_COLLAPSED_KEY) === '1'); } function formatName(name) { return name.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase()); } let _musicXmlDisplay = null; async function renderMusicXmlFallback(xmlText) { const host = document.getElementById('sheet-musicxml-fallback'); if (!host || !window.opensheetmusicdisplay?.OpenSheetMusicDisplay) return false; host.innerHTML = ''; host.style.display = 'block'; try { _musicXmlDisplay = new window.opensheetmusicdisplay.OpenSheetMusicDisplay(host, { autoResize: true, drawTitle: false, drawPartNames: true, backend: 'svg', }); await _musicXmlDisplay.load(xmlText); _musicXmlDisplay.render(); applyMusicXmlFallbackTheme(); return true; } catch (error) { console.warn('MusicXML fallback render failed:', error); host.innerHTML = ''; host.style.display = 'none'; return false; } } // ── Sheet viewer toolbar ───────────────────────────────────────────────────── function applySheetView() { const wrap = document.getElementById('sheet-content-wrap'); const frame = document.getElementById('sheet-frame'); const fallback = document.getElementById('sheet-musicxml-fallback'); const label = document.getElementById('sheet-zoom-label'); if (!wrap) return; const { zoom, rotation } = state.sheetView; const frameVisible = frame && frame.style.display !== 'none'; const fallbackVisible = fallback && fallback.style.display !== 'none'; // OSMD uses native zoom so the SVG re-renders at the requested size // instead of being bitmap-scaled. This also avoids resize-feedback loops. if (fallbackVisible && _musicXmlDisplay) { try { _musicXmlDisplay.Zoom = zoom; _musicXmlDisplay.render(); } catch (e) { console.warn('OSMD zoom failed', e); } } if (frameVisible) { applySheetFrameZoom(frame, zoom); } wrap.style.transformOrigin = 'top left'; const parts = []; if (rotation) parts.push(`rotate(${rotation}deg)`); wrap.style.transform = parts.join(' '); if (label) label.textContent = `${Math.round(zoom * 100)}%`; } function sheetZoom(delta) { const next = Math.max(0.4, Math.min(2.5, (state.sheetView.zoom || 1) + delta)); state.sheetView.zoom = Math.round(next * 100) / 100; applySheetView(); } function sheetRotate() { state.sheetView.rotation = (state.sheetView.rotation + 90) % 360; applySheetView(); } function sheetResetView() { state.sheetView = { zoom: 1.0, rotation: 0 }; applySheetView(); } function resetSheetView() { state.sheetView = { zoom: 1.0, rotation: 0 }; applySheetView(); } function triggerDownload(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } async function loadSheetIntoFrame(frame, url) { const response = await fetch(url, { cache: 'no-store' }); if (!response.ok) { throw new Error(await response.text()); } const html = await response.text(); await new Promise((resolve, reject) => { let settled = false; const finish = () => { if (settled) return; settled = true; clearTimeout(timeoutId); resolve(); }; const fail = (error) => { if (settled) return; settled = true; clearTimeout(timeoutId); reject(error); }; const onLoad = () => { requestAnimationFrame(() => finish()); }; const timeoutId = setTimeout(() => { const doc = frame.contentDocument; if (doc?.documentElement?.innerHTML?.trim()) { finish(); return; } fail(new Error('Sheet frame failed to load.')); }, 1500); frame.addEventListener('load', onLoad, { once: true }); frame.removeAttribute('src'); frame.srcdoc = html; }); } 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 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 this score.'; 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 currently limited to scores with one or two 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); 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 frame = document.getElementById('sheet-frame'); const placeholder = document.getElementById('sheet-placeholder'); const musicXmlFallback = document.getElementById('sheet-musicxml-fallback'); const assets = currentSheetAssets(); musicXmlFallback.style.display = 'none'; musicXmlFallback.innerHTML = ''; frame.style.display = 'none'; frame.onload = null; placeholder.style.display = 'none'; state.sheetSource = { name: data.name, variant: assets.variant, hasSheet: assets.hasSheet, musicXml: assets.musicXml, }; updateSheetFingeringStatus(); updateFingeringProgressUI(); if (assets.hasSheet && !(data.guest && assets.musicXml)) { try { await loadSheetIntoFrame( frame, `/api/scores/${encodeURIComponent(data.name)}/sheet?variant=${encodeURIComponent(assets.variant)}&v=${encodeURIComponent(Date.now())}` ); sanitizeSheetFrame(frame); initializeSheetHighlighting(); frame.style.display = 'block'; return; } catch (error) { console.warn(`Sheet render failed for ${data.name} (${assets.variant})`, error); } } frame.srcdoc = ''; const renderedFallback = assets.musicXml ? await renderMusicXmlFallback(assets.musicXml) : false; placeholder.style.display = renderedFallback ? 'none' : 'block'; placeholder.textContent = renderedFallback ? '' : 'Sheet preview unavailable 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 resp = await fetch( `/api/scores/${encodeURIComponent(src.name)}/sheet?variant=${encodeURIComponent(src.variant || 'base')}`, { cache: 'no-store' } ); if (!resp.ok) throw new Error(await resp.text()); const html = await resp.text(); triggerDownload(new Blob([html], { type: 'text/html' }), `${src.name}.sheet.html`); } catch (err) { alert(`Download failed: ${readApiErrorMessage(err)}`); } return; } alert('No sheet source available to download.'); } // ── Play screen ─────────────────────────────────────────────────────────────── async function fetchScore(name) { const guestScore = guestScoreByName(name); if (guestScore) return guestScorePayload(guestScore); return api(`/api/scores/${encodeURIComponent(name)}`); } function renderGuestTips(data) { const card = document.getElementById('guest-tips-card'); const list = document.getElementById('guest-tips-list'); if (!card || !list) return; const tips = data?.guest ? (data.tips || []) : []; card.style.display = tips.length ? 'block' : 'none'; list.innerHTML = tips.map((tip) => `
  • ${escapeHtml(tip)}
  • `).join(''); renderGuestAnnotations(); } function escapeHtml(value) { const div = document.createElement('div'); div.textContent = String(value ?? ''); return div.innerHTML; } function guestAnnotationsForCurrent() { if (!state.current?.guest) return []; const progress = loadGuestProgress(); return Array.isArray(progress[state.current.name]?.annotations) ? progress[state.current.name].annotations : []; } function renderGuestAnnotations() { const root = document.getElementById('guest-annotation-list'); if (!root) return; const annotations = guestAnnotationsForCurrent(); root.innerHTML = annotations.length ? annotations.map((annotation) => `${escapeHtml(annotation)}`).join('') : 'No demo annotations yet.'; } function addGuestAnnotation(text) { if (!state.current?.guest) return; const progress = loadGuestProgress(); const item = progress[state.current.name] || {}; const annotations = Array.isArray(item.annotations) ? item.annotations : []; annotations.push(text); progress[state.current.name] = { ...item, annotations: annotations.slice(-5) }; saveGuestProgress(progress); renderGuestAnnotations(); } window.addGuestAnnotation = addGuestAnnotation; 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); const previousPart = preserveSelectedPart ? (state.selectedPart ?? 0) : 0; const previousVariant = preserveSheetVariant ? state.sheetVariant : 'base'; const data = await fetchScore(name); state.current = data; renderGuestTips(data); state.finishedPlayback = false; state.practiceRightHand = null; state.practiceLeftHand = null; const parts = data.parts || []; state.selectedPart = parts.length ? Math.max(0, Math.min(previousPart, parts.length - 1)) : 0; state.partInstruments = {}; state.sheetVariant = (previousVariant === 'fingered' && data.fingering?.applied) ? 'fingered' : 'base'; _stopMic(); setInputMode('keyboard'); 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 = p.instrument || 'piano'; return `
    `; }).join(''); picker.style.display = 'block'; } else { picker.style.display = 'none'; } buildKeyboard(getRightHand()); updateNextKey(getRightHand(), 0); renderNoteHighway(); syncExpectedMicNote(); preloadCurrentScoreInstruments(); renderPlayPieceList(); if (updateUrl) updateScoreUrl(name); if (reveal) showScreen('play-screen'); 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; } async function openRouteFromLocation(options = {}) { const name = routeScoreName(); if (!name) { if (options.replace) updateScoreUrl(null, true); showScreen('list-screen'); 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); showScreen('list-screen'); } } window.addEventListener('popstate', () => { openRouteFromLocation({ replace: false }); }); async function selectPart(idx) { state.selectedPart = idx; state.practiceRightHand = null; state.practiceLeftHand = null; document.querySelectorAll('.part-btn').forEach((b, i) => b.classList.toggle('selected', i === idx)); buildKeyboard(getRightHand()); updateNextKey(getRightHand(), 0); renderNoteHighway(); syncExpectedMicNote(); preloadCurrentScoreInstruments(); } function getInstrumentForPart(idx) { return state.partInstruments[idx] ?? state.current?.parts?.[idx]?.instrument ?? '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(); 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(); } function getRightHandRaw() { const parts = state.current?.parts; if (parts && parts.length > 0) return parts[state.selectedPart ?? 0].notes; 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 idx = state.selectedPart ?? 0; // Merge all parts except the selected one const left = []; parts.forEach((p, i) => { if (i !== idx) left.push(...p.notes); }); left.sort((a, b) => a[1] - b[1]); return left.map((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) : []; if (metadata.length) { if (merged.length === 3) merged.push(null); merged.push(...metadata); } return merged; }); } function getLeftHand() { if (state.practiceLeftHand) return state.practiceLeftHand; return expandDynamicTremolos(getLeftHandRaw(), currentBps()); } function initializeSheetHighlighting() { const frame = document.getElementById('sheet-frame'); const doc = frame?.contentDocument; if (!doc) return; _sheetMeasureEls = [...doc.querySelectorAll('g.measure')]; _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; } `; doc.head?.appendChild(style); } updateSheetHighlight(0); } function clearSheetHighlight() { _sheetMeasureEls = []; _sheetHighlightRect?.remove(); _sheetHighlightRect = null; _sheetHighlightIndex = -1; } function measureIndexForBeat(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 Math.min(idx, _sheetMeasureEls.length - 1); } function updateSheetHighlight(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 = doc.querySelector('svg.definition-scale g.page-margin') || measureEl.parentNode; if (!overlayRoot) return; const bbox = measureEl.getBBox(); const starts = state.current?.measure_beats || []; const measureStart = starts[idx] ?? 0; const nextStart = starts[idx + 1]; const previousSpan = idx > 0 ? ((starts[idx] ?? 0) - (starts[idx - 1] ?? 0)) : 4; const measureSpan = Math.max(0.25, (nextStart ?? (measureStart + previousSpan || 4)) - 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)); } // ── 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; 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); const last = state.tracker?.timestamps?.[state.tracker.timestamps.length - 1]; const anchorBeat = last?.beat ?? _noteHighwayStartBeat ?? 0; const anchorTime = last?.time ?? _noteHighwayStartTime; const bps = state.tracker?.bps?.() ?? _noteHighwayBps ?? 1; if (!last && trackerPos === 0) return Math.max(0, expectedBeat - OPENING_GUIDE_LEAD_BEATS); if (!anchorTime) return Math.min(anchorBeat, expectedBeat); const estimated = anchorBeat + Math.max(0, performance.now() / 1000 - anchorTime) * bps; if (state.tracker?.isFinished?.()) return estimated; return Math.min(estimated, expectedBeat); } function ensureNoteHighway() { const root = document.getElementById('note-highway'); if (!root || root.dataset.ready === '1') return; root.innerHTML = VISUAL_SLOTS.map((slot) => { return `
    ${slot.noteName}
    `; }).join(''); root.dataset.ready = '1'; } const HIGHWAY_LOOK_BEHIND_BEATS = 0.5; const HIGHWAY_LOOK_AHEAD_BEATS = 4; const HIGHWAY_TOP_PADDING = 12; const HIGHWAY_MIN_BAR_HEIGHT = 5; const HIGHWAY_BAR_GAP_BEATS = 0.06; function resolveSustainBeats(event, nextBeat) { const beat = event?.[1]; const gap = (Number.isFinite(nextBeat) && Number.isFinite(beat)) ? (nextBeat - beat) : Infinity; const fallback = Number.isFinite(gap) ? gap : 0.75; const raw = eventDuration(event, fallback); const cap = Number.isFinite(gap) ? Math.max(0.02, gap - HIGHWAY_BAR_GAP_BEATS) : raw; return Math.max(0.02, Math.min(raw, cap)); } // bar's bottom = hit line at onset (delta=0); bar's height = sustain * pxPerBeat. function computeBarGeometry(delta, sustainBeats, laneHeight, hitLineTop, pixelsPerBeat) { const bottomRaw = hitLineTop - delta * pixelsPerBeat; const topRaw = bottomRaw - sustainBeats * pixelsPerBeat; const top = Math.max(HIGHWAY_TOP_PADDING, topRaw); const bottom = Math.min(laneHeight, bottomRaw); if (bottom <= HIGHWAY_TOP_PADDING || bottom <= top) return null; const height = Math.min(bottom - HIGHWAY_TOP_PADDING, Math.max(HIGHWAY_MIN_BAR_HEIGHT, bottom - top)); return { top: Math.max(HIGHWAY_TOP_PADDING, bottom - height), height }; } function renderNoteHighway() { ensureNoteHighway(); const root = document.getElementById('note-highway'); if (state.finishedPlayback && !state.playing) { document.querySelectorAll('.note-bar, .full-note-bar').forEach((el) => el.remove()); return; } const rightHand = getRightHand(); const trackerPos = state.tracker?.position ?? 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(); if (state.playing && !state.paused) _noteHighwayRaf = requestAnimationFrame(tick); }; tick(); } function buildKeyboard(rightHand) { const nextPitches = eventPitches(rightHand[0]); const row = document.getElementById('kb-row-main'); row.innerHTML = SIMPLE_KEY_ORDER.map((code) => { const naturalSlot = VISUAL_SLOTS.find((slot) => slot.code === code && !slot.sharp); const sharpSlot = VISUAL_SLOTS.find((slot) => slot.anchorCode === code && slot.sharp); const naturalIsNext = naturalSlot && nextPitches.some((pitch) => pitch % 12 === naturalSlot.pitchClass); const sharpIsNext = sharpSlot && nextPitches.some((pitch) => pitch % 12 === sharpSlot.pitchClass); return `
    ${naturalSlot.keyLabel} ${naturalSlot.noteName}
    ${sharpSlot ? `
    ${sharpSlot.keyLabel} ${sharpSlot.noteName}
    ` : ''}
    `; }).join(''); buildReferenceKeyboard(); applyKeyboardLayoutMode(state.keyboardLayoutMode); } function buildReferenceKeyboard() { const whiteRoot = document.getElementById('full-kb-whites'); const blackRoot = document.getElementById('full-kb-blacks'); if (!whiteRoot || !blackRoot) return; let whiteIndex = 0; const whiteKeys = []; const blackKeys = []; for (let midi = FULL_KEYBOARD_START; midi <= FULL_KEYBOARD_END; midi++) { const label = pitchName(midi); if (isBlackKeyMidi(midi)) { blackKeys.push( `
    ${label}
    ` ); } else { whiteKeys.push( `
    ${label}
    ` ); whiteIndex += 1; } } whiteRoot.innerHTML = whiteKeys.join(''); blackRoot.innerHTML = blackKeys.join(''); } function scrollReferenceKeyboardToMidi(midi) { if (state.keyboardLayoutMode !== 'full') return; const shell = document.getElementById('reference-keyboard-shell'); const key = document.getElementById(`refkey-${midi}`); if (!shell || !key) return; key.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); } function highlightKey(key, on) { const el = document.getElementById(key); if (el) el.classList.toggle('active', on); } function updateNextKey(rightHand, position) { document.querySelectorAll('.kb-key.next, .kb-sharp.next, .ref-key.next') .forEach(el => el.classList.remove('next')); if (position >= rightHand.length) { document.getElementById('next-note-display').textContent = '—'; renderNoteHighway(); return; } const event = rightHand[position]; eventPitches(event).forEach((midi) => { const keyId = cueKeyIdForMidi(midi); if (keyId) document.getElementById(keyId)?.classList.add('next'); document.getElementById(`refkey-${midi}`)?.classList.add('next'); }); document.getElementById('next-note-display').textContent = eventLabel(event); scrollReferenceKeyboardToMidi(leadPitchFromEvent(event)); renderNoteHighway(); syncExpectedMicNote(); } // ── Start / Stop ────────────────────────────────────────────────────────────── function handleStartPause() { if (!state.playing) { return startPlaying(); } return togglePausePlaying(); } async function startPlaying() { audioCtx().resume(); // 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 sel = state.selectedPart ?? 0; const leftInstruments = parts .map((_, i) => i) .filter(i => i !== sel) .map(i => getInstrumentForPart(i)); const instrumentsInUse = [getInstrumentForPart(sel), ...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); state.accompanist.start(); state.playing = true; state.paused = false; state.pausedBeat = 0; state.pausedBps = initialBps; state.finishedPlayback = false; _noteHighwayStartTime = null; _noteHighwayStartBeat = 0; _noteHighwayBps = initialBps; startBtn.disabled = false; stopBtn.disabled = false; startBtn.textContent = '❚❚ Pause'; startBtn.classList.add('btn-primary'); stopBtn.textContent = '■ End'; updateNextKey(right, 0); updatePracticePanelStatus(); updateSheetHighlight(0); startNoteHighwayLoop(); enableMidi(); 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 = state.tracker?.bps?.() ?? _noteHighwayBps ?? 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?.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, state.tracker?.bps?.() ?? _noteHighwayBps ?? 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); updatePracticePanelStatus(); renderNoteHighway(); } // ── Note handler (called by keyboard and MIDI) ──────────────────────────────── function handleNoteMic(midi) { if (!state.playing || state.paused || !state.tracker) return; const beat = state.tracker.onNoteFuzzy(midi); if (beat !== null) { const bps = state.tracker.bps(); _noteHighwayStartBeat = beat; _noteHighwayStartTime = performance.now() / 1000; _noteHighwayBps = bps; state.accompanist.onRhNote(beat, bps); updateSheetHighlight(beat); document.getElementById('beat-val').textContent = beat.toFixed(1); document.getElementById('tempo-val').textContent = Math.round(bps * 60) + ' BPM'; document.getElementById('progress-fill').style.width = (state.tracker.progress() * 100).toFixed(1) + '%'; updateNextKey(getRightHand(), state.tracker.position); syncExpectedMicNote(); } if (state.tracker.isFinished()) scheduleFinishedPlayback(); } function handleNote(midi) { if (!state.playing || state.paused || !state.tracker) return; 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, }); 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) { const bps = state.tracker.bps(); _noteHighwayStartBeat = beat; _noteHighwayStartTime = performance.now() / 1000; _noteHighwayBps = bps; state.accompanist.onRhNote(beat, bps); updateSheetHighlight(beat); document.getElementById('beat-val').textContent = beat.toFixed(1); document.getElementById('tempo-val').textContent = Math.round(bps * 60) + ' BPM'; document.getElementById('progress-fill').style.width = (state.tracker.progress() * 100).toFixed(1) + '%'; 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; 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) { const bps = state.tracker.bps(); _noteHighwayStartBeat = beat; _noteHighwayStartTime = performance.now() / 1000; _noteHighwayBps = bps; state.accompanist.onRhNote(beat, bps); updateSheetHighlight(beat); document.getElementById('beat-val').textContent = beat.toFixed(1); document.getElementById('tempo-val').textContent = Math.round(bps * 60) + ' BPM'; document.getElementById('progress-fill').style.width = (state.tracker.progress() * 100).toFixed(1) + '%'; updateNextKey(getRightHand(), state.tracker.position); } if (state.tracker.isFinished()) scheduleFinishedPlayback(); } // ── Input mode ─────────────────────────────────────────────────────────────── let _inputMode = 'keyboard'; let _pitchDetector = null; let _selectedMicId = null; let _selectedSpeakerId = 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' : 'Following') : 'Ready'; } } function setInputMode(mode) { _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'; if (mode === 'mic') { _startMic(); } else { _stopMic(); } updatePracticePanelStatus(); } function onNoiseGateChange(val) { document.getElementById('noise-gate-val').textContent = val; if (_pitchDetector) _pitchDetector.setThreshold(val / 1000); _drawMeter(_lastRms); // redraw so threshold line updates immediately } // ── 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) 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'); 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 speakers = devices.filter(d => d.kind === 'audiooutput'); const micSel = document.getElementById('mic-select'); micSel.innerHTML = mics.map(d => `` ).join(''); if (!_selectedMicId && mics.length) _selectedMicId = mics[0].deviceId; const spkSel = document.getElementById('speaker-select'); spkSel.innerHTML = '' + speakers.map(d => `` ).join(''); } async function onMicChange() { const sel = document.getElementById('mic-select'); _selectedMicId = sel.value; if (_pitchDetector) { _stopMic(); await _startMic(); } } async function onSpeakerChange() { const sel = document.getElementById('speaker-select'); _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(); const gate = parseFloat(document.getElementById('noise-gate').value) / 1000; _pitchDetector = new PitchDetector(audioCtx(), { threshold: gate, deviceId: _selectedMicId || undefined, onNote: (midi, info) => { _micDot('hearing'); const freqText = info?.freq ? ` ${info.freq.toFixed(1)} Hz` : ''; document.getElementById('mic-note-display').textContent = `${pitchName(midi)}${freqText}`; handleNoteMic(midi); setTimeout(() => { if (_pitchDetector) _micDot('active'); }, 300); }, onSilence: () => { document.getElementById('mic-note-display').textContent = ''; _micDot('active'); }, onLevel: (rms) => _drawMeter(rms), onDebug: (msg) => { document.getElementById('mic-debug').textContent = msg; }, }); // 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) { alert(e.message); setInputMode('keyboard'); } } function _stopMic() { if (_pitchDetector) { _pitchDetector.stop(); _pitchDetector = null; } _micDot(null); document.getElementById('mic-note-display').textContent = ''; document.getElementById('mic-debug').textContent = ''; } // ── 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/${name}`, { method: 'DELETE' }); 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; removeScoreFromLibrary(name); await loadScoreList(); } // ── 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.'); document.getElementById('search-results').innerHTML = '

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

    '; setTimeout(() => document.getElementById('corpus-search').focus(), 50); } function closeAddModal(e) { if (e && e.target !== document.getElementById('add-modal')) return; document.getElementById('add-modal').style.display = 'none'; } document.addEventListener('keydown', e => { if (e.key === 'Escape') document.getElementById('add-modal').style.display = 'none'; }); const savedScoreGridColumns = localStorage.getItem('accompy_score_grid_columns'); if (savedScoreGridColumns) { state.scoreGridColumns = normalizedScoreGridColumns(savedScoreGridColumns); } state.keyboardLayoutMode = localStorage.getItem('accompy_keyboard_layout_mode') === 'mini' ? 'mini' : 'full'; applyScoreGridColumns(state.scoreGridColumns); applyKeyboardLayoutMode(state.keyboardLayoutMode); initPracticeSplitter(); applyTheme(localStorage.getItem('accompy_theme') || 'dark'); window.addEventListener('resize', () => { resizeScorePreviews(); applyShellSplit(localStorage.getItem(SHELL_SPLIT_KEY)); applyPanelSplit(localStorage.getItem(PANEL_SPLIT_KEY)); applyPracticeSplit(localStorage.getItem(PRACTICE_SPLIT_KEY)); }); // ── Click-to-play on keyboard keys ─────────────────────────────────────────── function midiFromKeyboardEl(el) { const refKey = el.closest?.('.ref-key'); if (refKey) { const midi = parseInt(refKey.dataset.midi, 10); return Number.isFinite(midi) ? { midi, highlightId: refKey.id } : null; } const kbKey = el.closest?.('.kb-key'); if (kbKey?.id?.startsWith('kbslot-')) { const slotId = kbKey.id.slice('kbslot-'.length); const slot = VISUAL_SLOTS.find((s) => s.id === slotId); if (slot) return { midi: resolveKeyboardPitchesForPitchClass(slot.pitchClass), highlightId: kbKey.id }; } return null; } const _keyboardSectionEl = document.getElementById('keyboard-section'); if (_keyboardSectionEl) { _keyboardSectionEl.addEventListener('click', (e) => { const hit = midiFromKeyboardEl(e.target); if (!hit) return; if (typeof Tone !== 'undefined' && Tone.start) Tone.start().catch(() => {}); if (state.playing && !state.paused && state.tracker) { // Same flow as a physical keypress — plays the note and advances the tracker. handleKeyboardInput(hit.midi); return; } // Preview mode — piece isn't running; just play the note + flash the key. const instrument = getInstrumentForPart(state.selectedPart ?? 0); const sampledInstrument = SAMPLE_ALIAS[instrument] || instrument; const pitches = Array.isArray(hit.midi) ? hit.midi : [hit.midi]; pitches.forEach((midi) => { if (_sampleSamplers[sampledInstrument]) { playNote(midi, 0.6, instrument, { duration: 0.45 }); } else { playSynthNote(midi, 0.6, 'piano', { duration: 0.45 }); } 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.style.color = tone === 'error' ? '#e05c5c' : tone === 'success' ? 'var(--success)' : 'var(--muted)'; } function updateImportFileLabel(files) { const label = document.getElementById('import-file-label'); if (!label) return; if (!files.length) { label.textContent = 'No files selected'; return; } label.textContent = files.length === 1 ? files[0].name : `${files.length} files selected`; } async function runSearch() { const q = document.getElementById('corpus-search').value.trim(); const spinner = document.getElementById('search-spinner'); spinner.style.display = 'inline'; try { const { results } = await api(`/api/corpus/search?q=${encodeURIComponent(q)}`); renderSearchResults(results); } catch (e) { document.getElementById('search-results').innerHTML = `

    Error: ${e.message}

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

    No results found.

    '; return; } document.getElementById('search-results').innerHTML = results.map(r => { const safeName = r.path.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); const added = already.has(safeName); return `
    ${r.composer} — ${r.title}
    ${r.path}
    `; }).join(''); } async function addPiece(corpusPath, safeName) { const item = document.getElementById(`result-${safeName}`); item.classList.add('adding'); item.querySelector('button').textContent = 'Converting…'; try { await api('/api/convert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ corpus_path: corpusPath, name: safeName }), }); item.classList.remove('adding'); item.classList.add('done'); item.querySelector('button').textContent = '✓ Added'; item.querySelector('button').disabled = true; 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, one PDF, or one/more 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 isDirectScore = files.length === 1 && /\.(xml|mxl|musicxml|mscx|mscz)$/i.test(files[0].name || ''); setImportStatus(isDirectScore ? 'Importing score file and building the score…' : 'Running Audiveris and converting the score…'); try { const response = await fetch('/api/import', { method: 'POST', body: form }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.detail || 'Import failed'); } addScoreToLibrary(payload.name); setImportStatus(`Imported ${formatName(payload.name)}.`, 'success'); await loadScoreList(); setTimeout(() => closeAddModal(), 3000); } catch (error) { 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.'); return; } const label = files.length === 1 ? files[0].name : `${files.length} files selected`; setImportStatus(label); }); 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; 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(); }); } // ── Init ────────────────────────────────────────────────────────────────────── initLatencyControls(); initImportControls(); initTempoControls(); initKeyboardLayoutToggle(); initPlaySidebar(); initHorizontalSplitters(); onNoiseGateChange(document.getElementById('noise-gate')?.value || '1'); setLatencyCompensation(localStorage.getItem('accompy_latency_comp_ms') || '0'); initAppConfig().then(async () => { if (state.guestMode || !_appConfig.auth_enabled || _authUser) { const hasRoute = !!routeScoreName(); if (hasRoute) { await Promise.all([ loadScoreList(), openRouteFromLocation({ replace: true }), ]); } else { await loadScoreList(); await openRouteFromLocation({ replace: true }); } } });