import { create } from "zustand"; import { syncEngine } from "../engine/SyncEngine"; import bus from "../engine/EventBus"; import type { MusicDocument, PlaybackStatus, NoteEvent, Finger } from "@bach-to-basics/shared"; import type { InstrumentId } from "../engine/AudioEngine"; export { computeMeasureSeconds, keySignatureToLabel } from "@bach-to-basics/shared"; export type { InstrumentId } from "../engine/AudioEngine"; export { INSTRUMENT_LABELS } from "../engine/AudioEngine"; /** Which visual effect plays when a note reaches the hit line. */ export type ImpactStyle = "off" | "bloom" | "side" | "trail"; /** Which pitch classes to show in the falling-notes view */ export type NoteFilter = "all" | "white" | "black" | "c_only"; /** Layout mode - which panels are visible */ export type LayoutMode = "falling" | "sheet" | "piano" | "all"; /** * Color theme for falling notes (and keyboard highlights). * - violet : all notes violet (default, no hand separation) * - classic : left=blue, right=red (hand-aware) * - ocean : cyan palette * - forest : green palette * - custom : user-defined colors via CustomColors */ export type ColorTheme = "violet" | "classic" | "ocean" | "forest" | "cascade" | "custom"; /** Per-channel colors for the custom theme (hex strings, e.g. "#3b82f6"). */ export interface CustomColors { leftHand: string; rightHand: string; unknown: string; } /** Pre-built color values matching each named preset (used to seed the custom picker). */ export const COLOR_PRESET_VALUES: Record, CustomColors> = { violet: { leftHand: "#7c3aed", rightHand: "#f59e0b", unknown: "#a78bfa" }, // purple + amber classic: { leftHand: "#2563eb", rightHand: "#dc2626", unknown: "#7c3aed" }, // blue + red ocean: { leftHand: "#0284c7", rightHand: "#f97316", unknown: "#0ea5e9" }, // blue + coral forest: { leftHand: "#16a34a", rightHand: "#a855f7", unknown: "#4ade80" }, // green + purple cascade: { leftHand: "#9333ea", rightHand: "#22d3ee", unknown: "#a855f7" }, // purple + cyan }; /** Which keys receive note-name labels on the piano keyboard */ export type NoteLabelMode = "none" | "c_only" | "white" | "black" | "all"; export interface SpeedTrainerSettings { enabled: boolean; startPct: number; // 0-100 endPct: number; // 0-100 stepPct: number; // increment per loop currentPct: number; // current ramp position } export interface AppSettings { showFingering: boolean; /** Also overlay finger digits on falling-note bars (default off - adds visual clutter) */ showFingeringOnNotes: boolean; showHandColors: boolean; useFlats: boolean; viewportSeconds: number; // FallingNotes lookahead window theme: "dark" | "light"; activeHands: Set<"left" | "right">; volume: number; // 0-1 noteFilter: NoteFilter; colorTheme: ColorTheme; customColors: CustomColors; layoutMode: LayoutMode; metronomeEnabled: boolean; // ── New settings ────────────────────────────────────────────────────────── /** Note labels on the piano keyboard */ noteLabelMode: NoteLabelMode; /** Note labels on the falling notes bars */ fallingNotesLabelMode: NoteLabelMode; pianoTheme: "white" | "ivory"; showGrid: boolean; showMeasureNums: boolean; showKeySignature: boolean; transposeSemitones: number; // -6..+6 speedTrainer: SpeedTrainerSettings; countInBars: 0 | 1 | 2; /** Which visual effect plays when a note reaches the hit line */ impactStyle: ImpactStyle; sheetMusicWhiteBackground: boolean; // ── MIDIano-inspired additions ──────────────────────────────────────────── /** Scroll wheel on the falling notes canvas seeks playback position */ scrollToSeek: boolean; /** Minimum pixel height for note bars (prevents staccato notes becoming invisible) */ minNoteHeight: number; // 4-24 /** Border-radius for note bar corners (0 = sharp, 12 = pill) */ noteCornerRadius: number; // 0-12 /** Draw faint horizontal lines at each beat and measure boundary */ showBeatLines: boolean; /** Show sustain pedal (CC64) regions as semi-transparent bands */ showSustainPedal: boolean; /** Per-hand volume multiplier (0 = silent, 1 = full) */ handVolume: { left: number; right: number }; /** Active instrument for audio playback */ instrument: InstrumentId; /** Which hand(s) trigger wait-mode pauses ("both" = default, all hands) */ waitForHand: "left" | "right" | "both"; /** Draw a colored outline stroke around falling note bars */ showNoteOutline: boolean; // ── MIDIano live-app additions ──────────────────────────────────────────── /** Shift audio scheduling by ±ms to compensate for audio interface latency (positive = play earlier) */ renderOffset: number; // -200..+200 ms /** Keep a ghost indicator for notes that have ended while sustain pedal is held */ showSustainedNotes: boolean; } export interface AppState { // ── Document ───────────────────────────────────────────────────────────── document: MusicDocument | null; isLoadingDocument: boolean; /** True while /fingering/generate is in flight */ isGeneratingFingering: boolean; /** True while /export/pdf is in flight (server-side LilyPond render) */ isExportingPdf: boolean; /** True while MP3 export is in flight (frontend offline-render → backend FFmpeg) */ isExportingMp3: boolean; loadError: string | null; /** Non-null during slow imports (e.g. OMR) to show a descriptive message */ loadingMessage: string | null; // ── Playback ───────────────────────────────────────────────────────────── status: PlaybackStatus; currentSeconds: number; tempoMultiplier: number; loopStart: number | null; loopEnd: number | null; waitMode: boolean; // ── Settings ───────────────────────────────────────────────────────────── settings: AppSettings; // ── MIDI device ────────────────────────────────────────────────────────── midiDeviceName: string | null; midiEnabled: boolean; // ── Actions ────────────────────────────────────────────────────────────── loadMidiFile: (file: File) => Promise; loadMusicXmlFile: (file: File) => Promise; loadPdfFile: (file: File) => Promise; loadAudioFile: (file: File) => Promise; generateFingering: () => Promise; exportMidi: () => void; exportMusicXml: () => void; exportPdf: () => Promise; exportMp3: () => Promise; play: () => void; pause: () => void; stop: () => void; seek: (seconds: number) => void; setTempoMultiplier: (v: number) => void; setLoopPoints: (start: number | null, end: number | null) => void; setWaitMode: (on: boolean) => void; setActiveHands: (hands: Set<"left" | "right">) => void; setMetronome: (on: boolean) => void; updateSettings: (patch: Partial) => void; resetSettings: () => void; setMidiDevice: (name: string | null) => void; clearLoadError: () => void; syncEngineState: () => void; } /** localStorage key for persisting the user's theme choice across reloads. */ const THEME_STORAGE_KEY = "b2b-theme"; /** * Initial theme - the standard "system default + persisted override" pattern * used by GitHub, Linear, Notion, Vercel, etc.: * * 1. If the user has previously toggled, honor that choice (localStorage) * 2. Otherwise match the OS preference (`prefers-color-scheme: dark`) * 3. Fall back to light * * Runs at module load (before React mounts) so the first paint is correct - * no flash of wrong theme on reload. */ function getInitialTheme(): "dark" | "light" { if (typeof window === "undefined") return "light"; try { const stored = window.localStorage.getItem(THEME_STORAGE_KEY); if (stored === "dark" || stored === "light") return stored; } catch { /* localStorage may throw in private/incognito mode */ } try { if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) return "dark"; } catch { /* matchMedia not supported (very old browsers) */ } return "light"; } export const DEFAULT_SETTINGS: AppSettings = { showFingering: false, showFingeringOnNotes: false, showHandColors: true, useFlats: false, viewportSeconds: 4.0, theme: getInitialTheme(), activeHands: new Set(["left", "right"]), volume: 0.8, noteFilter: "all", colorTheme: "cascade", customColors: { leftHand: "#9333ea", rightHand: "#22d3ee", unknown: "#a855f7" }, layoutMode: "falling", metronomeEnabled: false, // ── New defaults ────────────────────────────────────────────────────────── noteLabelMode: "c_only", fallingNotesLabelMode: "all", pianoTheme: "white", showGrid: false, showMeasureNums: true, showKeySignature: true, transposeSemitones: 0, speedTrainer: { enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60 }, countInBars: 0, impactStyle: "bloom" as ImpactStyle, sheetMusicWhiteBackground: false, // ── MIDIano-inspired additions ──────────────────────────────────────────── scrollToSeek: false, minNoteHeight: 8, noteCornerRadius: 4, showBeatLines: false, showSustainPedal: false, handVolume: { left: 1, right: 1 }, // ── "Consider later" additions ──────────────────────────────────────────── instrument: "grand" as InstrumentId, waitForHand: "both" as "left" | "right" | "both", showNoteOutline: false, // ── MIDIano live-app additions ──────────────────────────────────────────── renderOffset: 0, showSustainedNotes: false, }; export const useAppStore = create((set, get) => { // Subscribe to SyncEngine state changes --> push into Zustand syncEngine.onStateChange((s) => { set({ status: s.status, currentSeconds: s.currentSeconds, tempoMultiplier: s.tempoMultiplier, loopStart: s.loopStart, loopEnd: s.loopEnd, waitMode: s.waitMode, }); }); // Speed trainer: keep currentPct in sync when the engine steps the tempo bus.on("speedTrainer:stepped", ({ currentPct }) => { set((s) => ({ tempoMultiplier: currentPct / 100, settings: { ...s.settings, speedTrainer: { ...s.settings.speedTrainer, currentPct }, }, })); }); return { document: null, isLoadingDocument: false, isGeneratingFingering: false, isExportingPdf: false, isExportingMp3: false, loadError: null, loadingMessage: null, status: "stopped", currentSeconds: 0, tempoMultiplier: 1.0, loopStart: null, loopEnd: null, waitMode: false, settings: DEFAULT_SETTINGS, midiDeviceName: null, midiEnabled: false, loadMidiFile: async (file: File) => { set({ isLoadingDocument: true, loadError: null }); try { const rawBuffer = await file.arrayBuffer(); const id = crypto.randomUUID(); // M4 - strip path separators, control chars; cap length const rawTitle = file.name.replace(/\.midi?$/i, ""); const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled"; // Slice a copy BEFORE transferring - postMessage() detaches (empties) the original const bufferForXml = rawBuffer.slice(0); // Parse in Web Worker (transfers rawBuffer ownership) const doc = await parseMidiInWorker(rawBuffer, id, title); set({ document: doc }); await syncEngine.loadDocument(doc); // Request MusicXML from backend (non-blocking), using the copy fetchMusicXml(bufferForXml, doc, id).catch(console.warn); } catch (err) { set({ loadError: String(err) }); } finally { set({ isLoadingDocument: false }); } }, loadMusicXmlFile: async (file: File) => { set({ isLoadingDocument: true, loadError: null }); try { const id = crypto.randomUUID(); // M4 - strip path separators, control chars; cap length const rawTitle = file.name.replace(/\.(xml|musicxml|mxl)$/i, ""); const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled"; const xmlBytes = await file.arrayBuffer(); // For .mxl (ZIP-compressed MusicXML) decompress in the browser so we // always end up with a plain XML string. This is more reliable than // relying on AlphaTab's internal ZIP reader (errors are swallowed there // and produce a silent blank sheet view). let musicXml: string | null; if (file.name.toLowerCase().endsWith(".mxl")) { const { extractXmlFromMxl } = await import("../utils/mxlExtract"); musicXml = await extractXmlFromMxl(xmlBytes); } else { musicXml = new TextDecoder().decode(xmlBytes); } // Stub document - sheet music renders immediately via musicXml string const doc: import("@bach-to-basics/shared").MusicDocument = { id, title, sourceType: "musicxml", musicXml, midiBuffer: null, notes: [], tempoMap: [{ tick: 0, bpm: 120 }], timeSignatures: [{ tick: 0, numerator: 4, denominator: 4 }], totalDuration: 0, ppq: 480, keySignature: null, youtubeId: null, youtubeSyncOffset: 0, fingeringVersion: "none", }; set({ document: doc }); await syncEngine.loadDocument(doc); // Fetch MIDI from backend for playback + falling notes (non-blocking). // For .mxl files we send the already-extracted plain XML string (re-encoded // to UTF-8) with a .xml extension, so the backend gets clean MusicXML rather // than the ZIP container. This avoids cross-staff voice-duplication artefacts // that music21 can produce when it parses a compressed .mxl directly. const xmlBytesForBackend = musicXml && file.name.toLowerCase().endsWith(".mxl") ? new TextEncoder().encode(musicXml).buffer : xmlBytes; const filenameForBackend = file.name.toLowerCase().endsWith(".mxl") ? file.name.replace(/\.mxl$/i, ".xml") : file.name; fetchMidiFromXml(xmlBytesForBackend, filenameForBackend, doc, id).catch(console.warn); } catch (err) { set({ loadError: String(err) }); } finally { set({ isLoadingDocument: false }); } }, loadPdfFile: async (file: File) => { const id = crypto.randomUUID(); // M4 - strip path separators and control chars; cap length const rawTitle = file.name.replace(/\.pdf$/i, ""); const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled"; set({ isLoadingDocument: true, loadingMessage: "Reading sheet music\u2026", loadError: null, }); try { const form = new FormData(); form.append("file", file, file.name); // OMR can take 10-120 s; no client-side fetch timeout - // the server's subprocess timeout is the effective limit. const res = await fetch("/api/omr/pdf2midi", { method: "POST", body: form }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(body.detail ?? `Server error ${res.status}`); } const { musicxml, midi_b64 } = (await res.json()) as { musicxml: string; midi_b64: string; filename: string; }; // Decode base64 MIDI const binaryStr = atob(midi_b64); const midiBytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) midiBytes[i] = binaryStr.charCodeAt(i); const midiBuffer = midiBytes.buffer; const bufferForDoc = midiBuffer.slice(0); // Parse notes from MIDI (same Web Worker path as loadMidiFile) const doc = await parseMidiInWorker(midiBuffer, id, title); // Attach MusicXML so sheet music renders immediately alongside playback. // sourceType "musicxml" tells AlphaTab to render the score. doc.musicXml = musicxml; doc.sourceType = "musicxml"; // Preserve any printed fingerings Audiveris detected in the PDF // (uncommon but possible for well-scanned editorial scores). let manualFingeringsFound = false; if (musicxml) { const notesWithFingerings = applyFingeringFromXml(doc.notes, musicxml); if (notesWithFingerings.some((n) => n.finger !== null)) { doc.notes = notesWithFingerings; doc.fingeringVersion = "manual"; manualFingeringsFound = true; } } const finalDoc = { ...doc, midiBuffer: bufferForDoc }; set((s) => ({ document: finalDoc, settings: manualFingeringsFound ? { ...s.settings, showFingering: true } : s.settings, })); await syncEngine.loadDocument(finalDoc); } catch (err) { set({ loadError: String(err) }); } finally { set({ isLoadingDocument: false, loadingMessage: null }); } }, loadAudioFile: async (file: File) => { const id = crypto.randomUUID(); const rawTitle = file.name.replace(/\.(mp3|wav|m4a|ogg|flac|aac)$/i, ""); const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled"; set({ isLoadingDocument: true, loadingMessage: "Transcribing audio…", loadError: null, }); try { const form = new FormData(); form.append("file", file, file.name); // Basic Pitch can take 10-60 s on the backend; no client-side fetch // timeout - the server's subprocess timeout is the effective limit. const res = await fetch("/api/transcribe/mp3", { method: "POST", body: form }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(body.detail ?? `Server error ${res.status}`); } // Endpoint returns a raw MIDI file (audio/midi) - parse it through the // same Web Worker pipeline as a regular .mid import. const midiBuffer = await res.arrayBuffer(); const bufferForDoc = midiBuffer.slice(0); const doc = await parseMidiInWorker(midiBuffer, id, title); set({ document: { ...doc, midiBuffer: bufferForDoc } }); await syncEngine.loadDocument({ ...doc, midiBuffer: bufferForDoc }); // Kick off MusicXML transcription so the sheet-music view eventually // populates, mirroring loadMidiFile's pattern. Fire-and-forget. fetchMusicXml(bufferForDoc.slice(0), doc, id).catch(console.warn); } catch (err) { const msg = err instanceof Error ? err.message : String(err); set({ loadError: msg }); } finally { set({ isLoadingDocument: false, loadingMessage: null }); } }, generateFingering: async () => { const { document: doc } = get(); if (!doc?.musicXml) return; set({ isGeneratingFingering: true, loadError: null }); try { const res = await fetch("/api/fingering/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ musicxml: doc.musicXml }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(body.detail ?? `Server error ${res.status}`); } const { musicxml: annotatedXml } = (await res.json()) as { musicxml: string }; const updatedNotes = applyFingeringFromXml(doc.notes, annotatedXml); // Guard: don't overwrite if the user loaded a different document while we waited const { document: currentDoc } = get(); if (currentDoc?.id === doc.id) { // Note: we deliberately do NOT replace `musicXml` with the annotated // version - fingerings are a piano-keyboard-only overlay. const updatedDoc = { ...currentDoc, notes: updatedNotes, fingeringVersion: "auto" as const, }; set((s) => ({ document: updatedDoc, settings: { ...s.settings, showFingering: true }, })); // Hand SyncEngine the updated notes WITHOUT resetting playback - // notes already scheduled within the lookahead window keep their // old (no-finger) values, but everything beyond it picks up the // new fingerings. This lets the user keep playing through Generate. syncEngine.updateDocumentNotes(updatedNotes); } } catch (err) { set({ loadError: String(err) }); } finally { set({ isGeneratingFingering: false }); } }, // ── Export ────────────────────────────────────────────────────────────── // MIDI + MusicXML are blob-and-download from data we already have in memory. // PDF requires a backend round-trip (music21 + LilyPond / Audiveris). // MP3 is intentionally NOT here yet - it needs an OfflineAudioContext WAV // pipeline which is a separate feature. exportMidi: () => { const { document: doc } = get(); if (!doc?.midiBuffer) return; triggerDownload( new Blob([doc.midiBuffer], { type: "audio/midi" }), `${safeFilename(doc.title)}.midi` ); }, exportMusicXml: () => { const { document: doc } = get(); if (!doc?.musicXml) return; triggerDownload( new Blob([doc.musicXml], { type: "application/vnd.recordare.musicxml+xml" }), `${safeFilename(doc.title)}.musicxml` ); }, exportPdf: async () => { const { document: doc } = get(); if (!doc?.musicXml) return; set({ isExportingPdf: true, loadError: null }); try { const res = await fetch("/api/export/pdf", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ musicxml: doc.musicXml }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(body.detail ?? `Server error ${res.status}`); } const blob = await res.blob(); triggerDownload(blob, `${safeFilename(doc.title)}.pdf`); } catch (err) { // Use the backend's detail verbatim - it's already worded for the user. // Don't prefix with "PDF export failed:" because the toast's warning icon // already conveys the failure state and the backend message is descriptive. const msg = err instanceof Error ? err.message : String(err); set({ loadError: msg }); } finally { set({ isExportingPdf: false }); } }, exportMp3: async () => { const { document: doc } = get(); if (!doc?.notes || doc.notes.length === 0) return; set({ isExportingMp3: true, loadError: null }); try { // Lazy-load smplr so this code path doesn't bloat the initial bundle // for users who never export. const { renderOffline, Scheduler, SplendidGrandPiano } = await import("smplr"); // Tail buffer so the final note's release isn't cut off + small lead-in // so the first note isn't clipped at sample 0. const RENDER_TAIL_SEC = 1.5; const RENDER_LEAD_SEC = 0.05; const renderResult = await renderOffline( async (ctx) => { // === Working around a real bug in smplr's offline rendering === // // smplr's default Scheduler uses setInterval to drain queued notes // whose `time` is outside its 200ms lookahead window. setInterval // callbacks NEVER fire during synchronous OfflineAudioContext // rendering, so any note past 200ms would be silently discarded // (you'd get a long silent WAV with only the very first beats // audible). The README's renderOffline example has the same bug. // // Workaround: construct our own Scheduler with a huge lookahead // (effectively infinite) so EVERY note falls within the synchronous // dispatch branch in Scheduler.schedule(). smplr 0.24's // SplendidGrandPiano options now accept SmplrOptions (including // `scheduler`), so we pass it directly to the wrapper rather than // bypassing to the low-level Smplr class. // // v1: always render with SplendidGrandPiano regardless of the // currently-selected live instrument. 99% of users are on the // default "grand" anyway; we can extend per-instrument later. const scheduler = new Scheduler(ctx, { lookaheadMs: 86_400_000 }); const player = new SplendidGrandPiano(ctx, { scheduler }); await player.load; // Schedule every note. velocity/duration straight from the doc; // we ignore current tempoMultiplier/transpose/hand-volume on // purpose - MP3 export is "render the score as-written", not // "render my current practice settings". for (const note of doc.notes) { player.start({ note: note.midi, velocity: note.velocity, time: RENDER_LEAD_SEC + note.startSeconds, duration: Math.max(0.05, note.endSeconds - note.startSeconds), }); } }, { sampleRate: 44100, channels: 2, duration: RENDER_LEAD_SEC + doc.totalDuration + RENDER_TAIL_SEC, } ); // Encode the AudioBuffer as 16-bit WAV - half the size of float32, the // codec FFmpeg expects, and well within the backend's 200 MB cap for // any practical song length. smplr returns a Blob; we need raw bytes // for base64 encoding. const wavBlob = renderResult.toWav16(); const wavBuffer = await wavBlob.arrayBuffer(); const wavBase64 = arrayBufferToBase64(wavBuffer); const res = await fetch("/api/export/mp3", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ wav_base64: wavBase64 }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(body.detail ?? `Server error ${res.status}`); } const blob = await res.blob(); triggerDownload(blob, `${safeFilename(doc.title)}.mp3`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); set({ loadError: msg }); } finally { set({ isExportingMp3: false }); } }, play: () => { const { settings, status } = get(); // Speed trainer: seed the engine's current state before play if (settings.speedTrainer.enabled) { const st = settings.speedTrainer; // If stopped (fresh start), reset to startPct; otherwise keep current const currentPct = status === "stopped" ? st.startPct : st.currentPct; syncEngine.setSpeedTrainer({ ...st, currentPct }); syncEngine.setTempoMultiplier(currentPct / 100); set((s) => ({ tempoMultiplier: currentPct / 100, settings: { ...s.settings, speedTrainer: { ...s.settings.speedTrainer, currentPct }, }, })); } syncEngine.play(); }, pause: () => syncEngine.pause(), stop: () => syncEngine.stop(), seek: (seconds) => syncEngine.seek(seconds), setTempoMultiplier: (v) => { syncEngine.setTempoMultiplier(v); set({ tempoMultiplier: v }); }, setLoopPoints: (start, end) => { syncEngine.setLoopPoints(start, end); set({ loopStart: start, loopEnd: end }); }, setWaitMode: (on) => { syncEngine.setWaitMode(on); set({ waitMode: on }); }, setMetronome: (on) => { syncEngine.setMetronome(on); set((s) => ({ settings: { ...s.settings, metronomeEnabled: on } })); }, setActiveHands: (hands) => { syncEngine.setActiveHands(hands); set((s) => ({ settings: { ...s.settings, activeHands: hands }, })); }, updateSettings: (patch) => { set((s) => ({ settings: { ...s.settings, ...patch } })); // Sync practice settings to the engine immediately if ("transposeSemitones" in patch && patch.transposeSemitones !== undefined) syncEngine.setTranspose(patch.transposeSemitones); if ("countInBars" in patch && patch.countInBars !== undefined) syncEngine.setCountInBars(patch.countInBars); if ("speedTrainer" in patch && patch.speedTrainer !== undefined) syncEngine.setSpeedTrainer(patch.speedTrainer); if ("handVolume" in patch && patch.handVolume !== undefined) syncEngine.setHandVolume(patch.handVolume); if ("instrument" in patch && patch.instrument !== undefined) syncEngine.setInstrument(patch.instrument).catch(console.warn); if ("waitForHand" in patch && patch.waitForHand !== undefined) syncEngine.setWaitForHand(patch.waitForHand); if ("renderOffset" in patch && patch.renderOffset !== undefined) syncEngine.setRenderOffset(patch.renderOffset); // Persist theme choice across reloads. localStorage write is fire-and-forget; // private/incognito mode can throw, in which case the choice just won't persist. if ("theme" in patch && (patch.theme === "dark" || patch.theme === "light")) { try { window.localStorage.setItem(THEME_STORAGE_KEY, patch.theme); } catch { /* localStorage unavailable - ignore */ } } }, resetSettings: () => { set({ settings: { ...DEFAULT_SETTINGS } }); // Re-sync engine with defaults syncEngine.setTranspose(DEFAULT_SETTINGS.transposeSemitones); syncEngine.setCountInBars(DEFAULT_SETTINGS.countInBars); syncEngine.setSpeedTrainer(DEFAULT_SETTINGS.speedTrainer); syncEngine.setMetronome(DEFAULT_SETTINGS.metronomeEnabled); syncEngine.setActiveHands(DEFAULT_SETTINGS.activeHands); syncEngine.setHandVolume(DEFAULT_SETTINGS.handVolume); syncEngine.setWaitForHand(DEFAULT_SETTINGS.waitForHand); syncEngine.setInstrument(DEFAULT_SETTINGS.instrument).catch(console.warn); syncEngine.setRenderOffset(DEFAULT_SETTINGS.renderOffset); }, setMidiDevice: (name) => set({ midiDeviceName: name, midiEnabled: name !== null }), clearLoadError: () => set({ loadError: null }), syncEngineState: () => { const s = syncEngine.state; set({ status: s.status, currentSeconds: s.currentSeconds, tempoMultiplier: s.tempoMultiplier, loopStart: s.loopStart, loopEnd: s.loopEnd, waitMode: s.waitMode, }); }, }; }); // ── Helpers ────────────────────────────────────────────────────────────────── function parseMidiInWorker(buffer: ArrayBuffer, id: string, title: string): Promise { return new Promise((resolve, reject) => { const worker = new Worker(new URL("../workers/midi.worker.ts", import.meta.url), { type: "module", }); // M3 - kill the worker if it hangs (e.g. malformed MIDI, infinite loop) const timeout = setTimeout(() => { worker.terminate(); reject(new Error("MIDI parsing timed out")); }, 30_000); worker.onmessage = (e) => { clearTimeout(timeout); worker.terminate(); if (e.data.ok) resolve(e.data.doc as MusicDocument); else reject(new Error(e.data.error)); }; worker.onerror = (e) => { clearTimeout(timeout); worker.terminate(); reject(e); }; worker.postMessage({ buffer, id, title }, [buffer]); }); } // ── Export helpers ──────────────────────────────────────────────────────────── /** Make a string safe for use as a filename: strip path separators, control chars, * and characters disallowed on Windows. Browsers further sanitize on download. */ function safeFilename(name: string): string { return name.replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, "_").slice(0, 200) || "untitled"; } /** Encode a Uint8Array / ArrayBuffer as a standard base64 string. * Chunked to avoid `String.fromCharCode(...veryLongArray)` blowing the call * stack on large WAV exports (a 5-min stereo render is ~26 MB). */ function arrayBufferToBase64(buffer: Uint8Array | ArrayBuffer): string { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); const CHUNK = 0x8000; // 32 KB let binary = ""; for (let i = 0; i < bytes.length; i += CHUNK) { binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK))); } return btoa(binary); } /** Create an anchor, click it to trigger a download, then revoke the object URL. */ function triggerDownload(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Defer revoke - some browsers race the download otherwise. setTimeout(() => URL.revokeObjectURL(url), 1000); } // ── Fingering helpers ───────────────────────────────────────────────────────── const STEP_TO_SEMITONE: Record = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11, }; function xmlStepToMidi(step: string, octave: number, alter: number): number { return (octave + 1) * 12 + (STEP_TO_SEMITONE[step] ?? 0) + Math.round(alter); } /** * Parse fingering annotations from an annotated MusicXML string (returned by * /fingering/generate) and splice them into the existing NoteEvent array. * * Strategy: piano scores from music21 typically have ONE with two * elements (staff 1 = treble = right, staff 2 = bass = left). We * bucket fingerings by per-note ; for parts with no staff info we * fall back to the part's average MIDI pitch (≥60 → right). Then we match * by position index within each hand group: the i-th XML right-hand note * → the i-th NoteEvent whose `hand === "right"`. * * Pianoplayer encodes "anchored" fingers as circled glyphs (①-⑤); we * normalize those to plain digits. */ const CIRCLED_FINGER_TO_DIGIT: Record = { "①": "1", "②": "2", "③": "3", "④": "4", "⑤": "5", }; function applyFingeringFromXml(notes: NoteEvent[], annotatedXml: string): NoteEvent[] { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(annotatedXml, "application/xml"); // Collect finger numbers per hand, in document order const fingersByHand: Record<"left" | "right", (Finger | null)[]> = { left: [], right: [] }; const parts = Array.from(xmlDoc.querySelectorAll("part")); for (const part of parts) { const noteEls = Array.from(part.querySelectorAll("note")); // First pass: do these notes carry info? (typical for piano grand-staff) let hasStaff = false; let pitchSum = 0, pitchCount = 0; for (const el of noteEls) { if (el.querySelector("rest")) continue; if (el.querySelector("staff")) hasStaff = true; const step = el.querySelector("pitch > step")?.textContent ?? ""; const octave = Number(el.querySelector("pitch > octave")?.textContent ?? 0); const alter = Number(el.querySelector("pitch > alter")?.textContent ?? 0); if (step) { pitchSum += xmlStepToMidi(step, octave, alter); pitchCount++; } } const fallbackHand: "left" | "right" = (pitchCount > 0 ? pitchSum / pitchCount : 60) >= 60 ? "right" : "left"; // Second pass: bucket fingerings into the correct hand for (const el of noteEls) { if (el.querySelector("rest")) continue; // Determine hand: prefer (1 = right/treble, 2 = left/bass), else fallback let handKey: "left" | "right" = fallbackHand; if (hasStaff) { const staff = Number(el.querySelector("staff")?.textContent ?? 1); handKey = staff === 2 ? "left" : "right"; } const fingeringEl = el.querySelector("notations technical fingering") ?? el.querySelector("notations fingering"); const raw = fingeringEl?.textContent?.trim() ?? ""; const normalized = CIRCLED_FINGER_TO_DIGIT[raw] ?? raw; const finger: Finger | null = /^[1-5]$/.test(normalized) ? (Number(normalized) as Finger) : null; fingersByHand[handKey].push(finger); } } // Collect per-hand NoteEvent indices (already sorted by startSeconds in the flat array) const idxByHand: Record<"left" | "right" | "unknown", number[]> = { left: [], right: [], unknown: [], }; notes.forEach((note, idx) => idxByHand[note.hand].push(idx)); // Splice in fingerings by index const updated = notes.map((n) => ({ ...n })); for (const hand of ["left", "right"] as const) { const eventIdxs = idxByHand[hand]; const fingers = fingersByHand[hand]; const count = Math.min(eventIdxs.length, fingers.length); for (let i = 0; i < count; i++) { updated[eventIdxs[i]] = { ...updated[eventIdxs[i]], finger: fingers[i] }; } } return updated; } async function fetchMidiFromXml( xmlBytes: ArrayBuffer, filename: string, doc: import("@bach-to-basics/shared").MusicDocument, id: string ): Promise { const blob = new Blob([xmlBytes], { type: "application/xml" }); const form = new FormData(); form.append("file", blob, filename); const res = await fetch("/api/transcribe/musicxml2midi", { method: "POST", body: form }); if (!res.ok) return; const midiBuffer = await res.arrayBuffer(); const bufferForDoc = midiBuffer.slice(0); // Parse notes from the returned MIDI const updatedDoc = await parseMidiInWorker(midiBuffer, id, doc.title); updatedDoc.musicXml = doc.musicXml; updatedDoc.sourceType = "musicxml"; // Preserve editorial fingerings from the source MusicXML when present // (e.g. publisher scores from Henle, Barenreiter). Without this the // round-trip through MIDI would strip them, since MIDI has no fingering // field. Same matching helper as Generate - any standard // elements with hints are honored. let manualFingeringsFound = false; if (doc.musicXml) { const notesWithFingerings = applyFingeringFromXml(updatedDoc.notes, doc.musicXml); if (notesWithFingerings.some((n) => n.finger !== null)) { updatedDoc.notes = notesWithFingerings; updatedDoc.fingeringVersion = "manual"; manualFingeringsFound = true; } } const { document: currentDoc } = useAppStore.getState(); if (currentDoc?.id === id) { const finalDoc = { ...updatedDoc, midiBuffer: bufferForDoc }; useAppStore.setState((s) => ({ document: finalDoc, settings: manualFingeringsFound ? { ...s.settings, showFingering: true } : s.settings, })); await syncEngine.loadDocument(finalDoc); } } async function fetchMusicXml(buffer: ArrayBuffer, doc: MusicDocument, _id: string): Promise { const blob = new Blob([buffer], { type: "audio/midi" }); const form = new FormData(); form.append("file", blob, "track.mid"); const titleParam = encodeURIComponent(doc.title ?? ""); const res = await fetch(`/api/transcribe/midi2musicxml?title=${titleParam}`, { method: "POST", body: form, }); if (!res.ok) return; const { musicxml } = (await res.json()) as { musicxml: string }; doc.musicXml = musicxml; // Notify sheet view that MusicXML is now available const { document: currentDoc } = useAppStore.getState(); if (currentDoc?.id === doc.id) { useAppStore.setState({ document: { ...doc } }); } }