// ── 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 `
${score.primary ? 'Guided demo' : 'Sandbox'}
${score.title.split(' ')[0]}
`;
}).join('');
requestAnimationFrame(() => resizeScorePreviews());
renderPlayPieceList();
}
function renderPlayPieceList() {
const root = document.getElementById('play-piece-list');
if (!root) return;
const currentName = state.current?.name || null;
const names = Array.isArray(state.scores) ? state.scores : [];
if (!names.length) {
root.innerHTML = 'No pieces in your library yet.
';
return;
}
root.innerHTML = names.map((name) => {
const active = name === currentName;
return `
`;
}).join('');
}
function initPlaySidebar() {
applyPlaySidebarCollapsed(localStorage.getItem(PLAY_SIDEBAR_COLLAPSED_KEY) === '1');
}
function formatName(name) {
return name.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase());
}
let _musicXmlDisplay = null;
async function renderMusicXmlFallback(xmlText) {
const host = document.getElementById('sheet-musicxml-fallback');
if (!host || !window.opensheetmusicdisplay?.OpenSheetMusicDisplay) return false;
host.innerHTML = '';
host.style.display = 'block';
try {
_musicXmlDisplay = new window.opensheetmusicdisplay.OpenSheetMusicDisplay(host, {
autoResize: true,
drawTitle: false,
drawPartNames: true,
backend: 'svg',
});
await _musicXmlDisplay.load(xmlText);
_musicXmlDisplay.render();
applyMusicXmlFallbackTheme();
return true;
} catch (error) {
console.warn('MusicXML fallback render failed:', error);
host.innerHTML = '';
host.style.display = 'none';
return false;
}
}
// ── Sheet viewer toolbar ─────────────────────────────────────────────────────
function applySheetView() {
const wrap = document.getElementById('sheet-content-wrap');
const frame = document.getElementById('sheet-frame');
const fallback = document.getElementById('sheet-musicxml-fallback');
const label = document.getElementById('sheet-zoom-label');
if (!wrap) return;
const { zoom, rotation } = state.sheetView;
const frameVisible = frame && frame.style.display !== 'none';
const fallbackVisible = fallback && fallback.style.display !== 'none';
// OSMD uses native zoom so the SVG re-renders at the requested size
// instead of being bitmap-scaled. This also avoids resize-feedback loops.
if (fallbackVisible && _musicXmlDisplay) {
try {
_musicXmlDisplay.Zoom = zoom;
_musicXmlDisplay.render();
} catch (e) { console.warn('OSMD zoom failed', e); }
}
if (frameVisible) {
applySheetFrameZoom(frame, zoom);
}
wrap.style.transformOrigin = 'top left';
const parts = [];
if (rotation) parts.push(`rotate(${rotation}deg)`);
wrap.style.transform = parts.join(' ');
if (label) label.textContent = `${Math.round(zoom * 100)}%`;
}
function sheetZoom(delta) {
const next = Math.max(0.4, Math.min(2.5, (state.sheetView.zoom || 1) + delta));
state.sheetView.zoom = Math.round(next * 100) / 100;
applySheetView();
}
function sheetRotate() {
state.sheetView.rotation = (state.sheetView.rotation + 90) % 360;
applySheetView();
}
function sheetResetView() {
state.sheetView = { zoom: 1.0, rotation: 0 };
applySheetView();
}
function resetSheetView() {
state.sheetView = { zoom: 1.0, rotation: 0 };
applySheetView();
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
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 ``;
}).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 });
}
}
});