Files
ITOSMPiano-BachToBasics/frontend/src/store/useAppStore.ts
T
이창현 b51da176a1 MusicXML 표준 확장자를 지원한다
파일 선택 목록과 가져오기 분기에서 .musicxml 확장자를 .xml 및 .mxl과 동일하게 처리한다. 한국어 파일명의 MusicXML 악보를 실제로 불러올 수 있도록 제목 정리 규칙도 맞췄다.
2026-10-03 07:59:20 +09:00

1027 lines
41 KiB
TypeScript

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<Exclude<ColorTheme, "custom">, 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<void>;
loadMusicXmlFile: (file: File) => Promise<void>;
loadPdfFile: (file: File) => Promise<void>;
loadAudioFile: (file: File) => Promise<void>;
generateFingering: () => Promise<void>;
exportMidi: () => void;
exportMusicXml: () => void;
exportPdf: () => Promise<void>;
exportMp3: () => Promise<void>;
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<AppSettings>) => 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<AppState>((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<MusicDocument> {
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<string, number> = {
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 <part> with two
* <staff> elements (staff 1 = treble = right, staff 2 = bass = left). We
* bucket fingerings by per-note <staff>; 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<string, string> = {
"①": "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 <staff> 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 <staff> (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<void> {
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 <fingering>
// elements with <staff> 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<void> {
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 } });
}
}