Add piano fingering generation and display via pianoplayer, fix silent audio on first load

This commit is contained in:
Fabricio Giglio
2026-05-10 23:50:20 +02:00
parent 5c0ad9afd1
commit c6a0c7f676
8 changed files with 542 additions and 162 deletions
+20 -32
View File
@@ -1,8 +1,8 @@
"""
pianoplayer service: annotate MusicXML with fingering (Parncutt algorithm).
Note: defusedxml.defuse_stdlib() is called in main.py before this module
is imported, so xml.etree (used by music21 internally) is already patched.
Uses the pianoplayer v3 API (run_annotate) which handles part routing and
hand assignment internally.
"""
import asyncio
import os
@@ -28,11 +28,12 @@ async def annotate_fingering(musicxml: str) -> str:
def _annotate(musicxml: str) -> str:
from pianoplayer.hand import RightHand, LeftHand
import music21
import io
import contextlib
from pianoplayer.core import run_annotate
xml_path = None
out_tmp_name = None
out_path = None
try:
with tempfile.NamedTemporaryFile(
suffix=".xml", delete=False, mode="w", encoding="utf-8"
@@ -40,37 +41,24 @@ def _annotate(musicxml: str) -> str:
f.write(musicxml)
xml_path = f.name
score = music21.converter.parse(xml_path)
# pianoplayer works on individual parts
for part in score.parts:
notes = list(part.flatten().notes)
if not notes:
continue
# Determine left / right hand by average register
avg_pitch = sum(
n.pitch.midi for n in notes if hasattr(n, "pitch")
) / max(len(notes), 1)
hand_cls = RightHand if avg_pitch >= 60 else LeftHand
try:
hand = hand_cls(score, 0)
hand.autodepth = False
hand.verbose = False
hand.generate(part=part.id if hasattr(part, "id") else 0)
except Exception:
pass # pianoplayer can fail on complex scores; skip gracefully
out_tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
out_tmp.close()
out_tmp_name = out_tmp.name
score.write("musicxml", fp=out_tmp_name)
out_path = out_tmp.name
return Path(out_tmp_name).read_text(encoding="utf-8")
# Suppress Rich progress/summary output so server logs stay clean.
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
run_annotate(
xml_path,
outputfile=out_path,
n_measures=1000,
auto_routing=True, # let pianoplayer detect which part is L/R
quiet=True,
)
return Path(out_path).read_text(encoding="utf-8")
finally:
if xml_path:
Path(xml_path).unlink(missing_ok=True)
if out_tmp_name:
Path(out_tmp_name).unlink(missing_ok=True)
if out_path:
Path(out_path).unlink(missing_ok=True)
@@ -244,6 +244,9 @@ class NoteBar {
private label: PIXI.Text;
/** Whether this bar's label should be shown at all (text non-empty AND bar big enough). */
private labelWanted = false;
/** Cached at configure() time so relabel() can recompute labelWanted without re-rendering the bar. */
private barWidth = 0;
private barHeight = 0;
note: NoteEvent | null = null;
active = false;
impactFired = false;
@@ -283,6 +286,8 @@ class NoteBar {
this.active = true;
this.impactFired = false;
this.baseColor = baseColor;
this.barWidth = barWidth;
this.barHeight = barHeight;
this.container.visible = true;
this.container.x = x;
this.container.y = startY;
@@ -325,6 +330,13 @@ class NoteBar {
this.label.visible = this.labelWanted;
}
/** Update just the label text on an already-configured bar. Cheap: no re-rendering of bg. */
relabel(labelText: string) {
this.label.text = labelText;
this.labelWanted = labelText.length > 0 && this.barHeight > 6 && this.barWidth > 7;
this.label.visible = this.labelWanted;
}
/** Keep label clamped within the visible portion of the bar (for very tall notes). */
updateLabelY(containerY: number, barHeight: number, viewportHeight: number) {
if (!this.labelWanted) {
@@ -362,6 +374,7 @@ export interface FallingNotesOptions {
height: number;
viewportSeconds: number;
fallingNotesLabelMode?: NoteLabelMode;
/** When true, append the assigned finger digit to each note bar's label */
showFingering: boolean;
showHandColors: boolean;
useFlats: boolean;
@@ -983,9 +996,13 @@ export class FallingNotesRenderer {
("useFlats" in opts && opts.useFlats !== this.opts.useFlats) ||
("fallingNotesLabelMode" in opts &&
opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) ||
("showFingering" in opts && opts.showFingering !== this.opts.showFingering) ||
("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline);
// Fingering toggle is cheaper - re-label active bars in place rather than
// resetting (which would briefly clear all bars from the screen).
const fingeringChanged =
"showFingering" in opts && opts.showFingering !== this.opts.showFingering;
Object.assign(this.opts, opts);
if (!this._initialized) return;
@@ -993,6 +1010,10 @@ export class FallingNotesRenderer {
for (const g of this.gradientCache.values()) g.destroy();
this.gradientCache.clear();
this.reset();
} else if (fingeringChanged) {
for (const [, bar] of this.active) {
if (bar.note) bar.relabel(this.noteLabel(bar.note));
}
}
if ("showGrid" in opts) this.gridLayer.visible = !!opts.showGrid;
if ("impactStyle" in opts) {
@@ -17,12 +17,16 @@ export function FallingNotesView() {
const accuracy = useAccuracy(midiEnabled && (status === "playing" || status === "paused"));
const measureStarts = useMemo(
() => doc ? computeMeasureSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration) : [],
() =>
doc
? computeMeasureSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration)
: [],
[doc]
);
const beatStarts = useMemo(
() => doc ? computeBeatSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration) : [],
() =>
doc ? computeBeatSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration) : [],
[doc]
);
@@ -38,7 +42,7 @@ export function FallingNotesView() {
height,
viewportSeconds: settings.viewportSeconds,
fallingNotesLabelMode: settings.fallingNotesLabelMode,
showFingering: settings.showFingering,
showFingering: settings.showFingering && settings.showFingeringOnNotes,
showHandColors: settings.showHandColors,
useFlats: settings.useFlats,
noteFilter: settings.noteFilter,
@@ -103,7 +107,7 @@ export function FallingNotesView() {
rendererRef.current?.setOptions({
viewportSeconds: settings.viewportSeconds,
fallingNotesLabelMode: settings.fallingNotesLabelMode,
showFingering: settings.showFingering,
showFingering: settings.showFingering && settings.showFingeringOnNotes,
showHandColors: settings.showHandColors,
useFlats: settings.useFlats,
noteFilter: settings.noteFilter,
@@ -127,6 +131,7 @@ export function FallingNotesView() {
settings.viewportSeconds,
settings.fallingNotesLabelMode,
settings.showFingering,
settings.showFingeringOnNotes,
settings.showHandColors,
settings.useFlats,
settings.noteFilter,
@@ -151,7 +156,10 @@ export function FallingNotesView() {
if (!doc || !settings.scrollToSeek) return;
// Seek forward/backward by 30% of the viewport window per scroll tick
const delta = (e.deltaY > 0 ? 1 : -1) * settings.viewportSeconds * 0.3;
const next = Math.max(0, Math.min(doc.totalDuration, useAppStore.getState().currentSeconds + delta));
const next = Math.max(
0,
Math.min(doc.totalDuration, useAppStore.getState().currentSeconds + delta)
);
seek(next);
};
@@ -162,17 +170,23 @@ export function FallingNotesView() {
onWheel={handleWheel}
style={{
minHeight: 200,
background: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
background:
"radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
borderTop: "1px solid var(--color-notes-border)",
}}
>
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
{midiEnabled && (accuracy.correct + accuracy.wrong + accuracy.missed) > 0 && (
{midiEnabled && accuracy.correct + accuracy.wrong + accuracy.missed > 0 && (
<div
className="absolute top-2 left-2 z-10 pointer-events-none text-xs px-2 py-0.5 rounded-full font-medium"
style={{
background: accuracy.score >= 80 ? "rgba(34,197,94,0.12)" : accuracy.score >= 50 ? "rgba(234,179,8,0.12)" : "rgba(239,68,68,0.12)",
background:
accuracy.score >= 80
? "rgba(34,197,94,0.12)"
: accuracy.score >= 50
? "rgba(234,179,8,0.12)"
: "rgba(239,68,68,0.12)",
color: accuracy.score >= 80 ? "#4ade80" : accuracy.score >= 50 ? "#eab308" : "#ef4444",
border: "1px solid currentColor",
}}
@@ -187,7 +201,16 @@ export function FallingNotesView() {
// ── Icons ─────────────────────────────────────────────────────────────────────
const TargetIcon = () => (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ display: "inline", verticalAlign: "middle", marginBottom: 1 }} aria-hidden="true">
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ display: "inline", verticalAlign: "middle", marginBottom: 1 }}
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none" />
@@ -430,7 +430,13 @@ function ResetButton() {
// ── Main component ────────────────────────────────────────────────────────────
export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
const { settings, updateSettings } = useAppStore();
const {
settings,
updateSettings,
document: doc,
isGeneratingFingering,
generateFingering,
} = useAppStore();
// Computes the inline style for range inputs.
// Sets --fill-pct so the CSS gradient draws the correct filled/unfilled split.
@@ -1084,6 +1090,63 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>
{/* ── Overlays ───────────────────────────────────────────────────── */}
<Section title="Overlays">
<Row
label="Finger numbers"
sublabel="1-5 hints on the piano keyboard"
title="Show finger number hints on the piano keyboard"
>
{doc?.musicXml && (
<button
onClick={() => void generateFingering()}
disabled={isGeneratingFingering}
title={
doc.fingeringVersion === "auto"
? "Regenerate finger numbers using the Parncutt algorithm"
: "Generate finger number hints using the Parncutt algorithm"
}
style={{
fontSize: 11,
padding: "3px 9px",
borderRadius: 5,
border: "1px solid var(--color-border)",
background: "var(--color-surface-2)",
color: isGeneratingFingering
? "var(--color-text-muted)"
: "var(--color-accent)",
cursor: isGeneratingFingering ? "wait" : "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
opacity: isGeneratingFingering ? 0.6 : 1,
}}
>
{isGeneratingFingering
? "Generating…"
: doc.fingeringVersion === "auto"
? "Regenerate"
: "Generate"}
</button>
)}
<Toggle
active={settings.showFingering}
onClick={() => updateSettings({ showFingering: !settings.showFingering })}
/>
</Row>
{settings.showFingering && (
<Row
label="Also on falling notes"
sublabel="appends digit to each note bar"
title="Also overlay finger digits on the falling-note bars (off by default - visually busier)"
>
<Toggle
active={settings.showFingeringOnNotes}
onClick={() =>
updateSettings({ showFingeringOnNotes: !settings.showFingeringOnNotes })
}
/>
</Row>
)}
<Row
label="Measure numbers"
sublabel="on the left edge of falling notes"
+39 -2
View File
@@ -54,10 +54,20 @@ export class AudioEngine {
if (this._loaded) return;
if (this._loadPromise) return this._loadPromise;
this._loadPromise = (async () => {
try {
if (!this.ctx) this.ctx = new AudioContext();
this.player = createPlayer(this._currentId, this.ctx);
await this.player.load;
this._loaded = true;
} catch (err) {
// Reset the cached promise so the next call can retry. Without this,
// a one-time failure (CDN hiccup, blocked sample fetch, etc.) would
// leave _loaded=false and ALL audio permanently silent.
this._loadPromise = null;
this.player = null;
console.error("AudioEngine.load failed:", err);
throw err;
}
})();
return this._loadPromise;
}
@@ -72,7 +82,11 @@ export class AudioEngine {
this._loaded = false;
this._loadPromise = null;
// Silence any ongoing audio before swapping the player
try { this.player?.stop?.(); } catch { /* ignore */ }
try {
this.player?.stop?.();
} catch {
/* ignore */
}
this.player = null;
// Pre-load the new instrument if an AudioContext already exists
if (this.ctx) await this.load();
@@ -82,6 +96,25 @@ export class AudioEngine {
return this._loaded;
}
/**
* Resume the AudioContext after a user gesture. Safe to call repeatedly.
*
* Browsers' autoplay policy keeps a freshly-created AudioContext in
* "suspended" state until a real user gesture. Our `load()` is invoked on
* mount (before any gesture) so the context exists but produces no sound
* until this is called from a click/keydown handler.
*/
async wake() {
if (!this.ctx) this.ctx = new AudioContext();
if (this.ctx.state === "suspended") {
try {
await this.ctx.resume();
} catch {
/* ignore */
}
}
}
playNote(note: NoteEvent) {
if (!this.player || !this._loaded) return;
// Resume in case the AudioContext was created before a user gesture (browser
@@ -104,6 +137,10 @@ export class AudioEngine {
}
stopAll() {
try { this.player?.stop?.(); } catch { /* ignore */ }
try {
this.player?.stop?.();
} catch {
/* ignore */
}
}
}
+82 -18
View File
@@ -77,7 +77,11 @@ class SyncEngine {
private countInBars: 0 | 1 | 2 = 0;
private countInTimeouts: ReturnType<typeof setTimeout>[] = [];
private speedTrainer: SpeedTrainerOpts = {
enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60,
enabled: false,
startPct: 60,
endPct: 100,
stepPct: 5,
currentPct: 60,
};
constructor() {
@@ -96,6 +100,39 @@ class SyncEngine {
this.emitState("stopped");
}
/**
* Resume both audio contexts (Tone.js + smplr) after a user gesture.
*
* Required because browser autoplay policy keeps freshly-created
* AudioContexts suspended until a real gesture. Our pre-warm in
* PracticeView creates them on mount (before any gesture), so without
* this they never produce sound. Call from a click/keydown handler.
*/
async wakeAudio() {
try {
await Tone.start(); // unblock Tone.js context
await this.audio.wake(); // unblock smplr context
} catch (err) {
console.error("SyncEngine.wakeAudio failed:", err);
}
}
/**
* Swap the document's `notes` array WITHOUT resetting playback state.
*
* Used to splice in fingering annotations mid-playback. The new array MUST
* have the same length, order, and `id`s as the existing one - otherwise
* `noteIndex` and `activeNotes` would point at stale data.
*
* Caveat: notes already scheduled into Tone.js (within ~LOOKAHEAD_DOC_SECONDS
* of the current position) keep their old `finger` value via closure capture.
* Notes beyond that window are scheduled fresh on the next tick.
*/
updateDocumentNotes(notes: NoteEvent[]) {
if (!this.doc) return;
this.doc = { ...this.doc, notes };
}
async play(fromSeconds?: number) {
if (!this.doc) return;
await this.audio.load();
@@ -125,7 +162,7 @@ class SyncEngine {
const timeSig = this.doc!.timeSignatures[0] ?? { numerator: 4, denominator: 4 };
const beatsPerBar = timeSig.numerator;
// Beat duration respects current tempo multiplier so count-in matches playback speed
const beatDurMs = (60 / bpm) / this._state.tempoMultiplier * 1000;
const beatDurMs = (60 / bpm / this._state.tempoMultiplier) * 1000;
const totalBeats = this.countInBars * beatsPerBar;
// Ensure metronome synth exists
@@ -143,8 +180,11 @@ class SyncEngine {
for (let b = 0; b < totalBeats; b++) {
const isAccent = b % beatsPerBar === 0;
const id = setTimeout(() => {
try { this.metronome!.triggerAttackRelease(isAccent ? "C6" : "G5", "32n"); }
catch { /* AudioContext not ready */ }
try {
this.metronome!.triggerAttackRelease(isAccent ? "C6" : "G5", "32n");
} catch {
/* AudioContext not ready */
}
}, b * beatDurMs);
this.countInTimeouts.push(id);
}
@@ -274,7 +314,10 @@ class SyncEngine {
}
setHandVolume(vol: { left: number; right: number }) {
this._state.handVolume = { left: Math.max(0, Math.min(1, vol.left)), right: Math.max(0, Math.min(1, vol.right)) };
this._state.handVolume = {
left: Math.max(0, Math.min(1, vol.left)),
right: Math.max(0, Math.min(1, vol.right)),
};
}
setWaitForHand(hand: "left" | "right" | "both") {
@@ -285,7 +328,9 @@ class SyncEngine {
await this.audio.setInstrument(id);
}
setYouTubePlayer(player: YTPlayer | null) { this.ytPlayer = player; }
setYouTubePlayer(player: YTPlayer | null) {
this.ytPlayer = player;
}
// ── Practice feature setters ────────────────────────────────────────────────
@@ -351,7 +396,9 @@ class SyncEngine {
return () => this.stateListeners.delete(cb);
}
get state(): Readonly<SyncEngineState> { return this._state; }
get state(): Readonly<SyncEngineState> {
return this._state;
}
// ── Private ─────────────────────────────────────────────────────────────────
@@ -407,7 +454,11 @@ class SyncEngine {
const beat = Math.floor(docSeconds / (60 / bpm));
if (beat !== this.lastBeatCrossed) {
this.lastBeatCrossed = beat;
try { this.metronome.triggerAttackRelease("C6", "64n"); } catch { /* ignore */ }
try {
this.metronome.triggerAttackRelease("C6", "64n");
} catch {
/* ignore */
}
}
}
@@ -443,7 +494,10 @@ class SyncEngine {
// note.startSeconds is in doc-time; compare directly to docSeconds
if (note.startSeconds > docSeconds + LOOKAHEAD_DOC_SECONDS) break;
if (this.activeNotes.has(note.id)) { this.noteIndex++; continue; }
if (this.activeNotes.has(note.id)) {
this.noteIndex++;
continue;
}
// Apply transposition (creates a new NoteEvent if needed, preserves original id)
const tNote = this.transposeNote(note);
@@ -460,14 +514,20 @@ class SyncEngine {
Tone.getTransport().scheduleOnce(() => {
// Read activeHands at fire-time so hand toggles take effect immediately
// (not at schedule-time, which would lag up to LOOKAHEAD_DOC_SECONDS).
const isActive = note.hand === "unknown" || this._state.activeHands.has(note.hand as "left" | "right");
const isActive =
note.hand === "unknown" || this._state.activeHands.has(note.hand as "left" | "right");
if (isActive) {
bus.emit("note:on", tNote);
// Apply per-hand volume multiplier (also read at fire-time for instant effect)
const handVol = note.hand === "left" ? this._state.handVolume.left
: note.hand === "right" ? this._state.handVolume.right
const handVol =
note.hand === "left"
? this._state.handVolume.left
: note.hand === "right"
? this._state.handVolume.right
: 1;
const scaledNote = handVol === 1 ? tNote
const scaledNote =
handVol === 1
? tNote
: { ...tNote, velocity: Math.max(1, Math.round(tNote.velocity * handVol)) };
this.audio.playNote(scaledNote);
}
@@ -480,11 +540,13 @@ class SyncEngine {
// Wait mode
if (this._state.waitMode && this._state.status === "playing") {
const isActive = note.hand === "unknown" || this._state.activeHands.has(note.hand as "left" | "right");
const isActive =
note.hand === "unknown" || this._state.activeHands.has(note.hand as "left" | "right");
// Only pause for the targeted hand (waitForHand); "both" = current behavior
const isWaited = this._state.waitForHand === "both"
|| note.hand === "unknown"
|| note.hand === this._state.waitForHand;
const isWaited =
this._state.waitForHand === "both" ||
note.hand === "unknown" ||
note.hand === this._state.waitForHand;
if (isActive && isWaited && onDelay <= 0.05) {
this.clock.pause();
this._state.status = "waiting";
@@ -503,7 +565,9 @@ class SyncEngine {
if (Math.abs(actual - expectedYtTime) > YT_DRIFT_THRESHOLD) {
this.ytPlayer.seekTo(expectedYtTime, true);
}
} catch { /* not ready yet */ }
} catch {
/* not ready yet */
}
}
}
+183 -19
View File
@@ -1,7 +1,7 @@
import { create } from "zustand";
import { syncEngine } from "../engine/SyncEngine";
import bus from "../engine/EventBus";
import type { MusicDocument, PlaybackStatus } from "@bach-to-basics/shared";
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";
@@ -55,6 +55,8 @@ export interface SpeedTrainerSettings {
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
@@ -111,6 +113,8 @@ export interface AppState {
// ── Document ─────────────────────────────────────────────────────────────
document: MusicDocument | null;
isLoadingDocument: boolean;
/** True while /fingering/generate is in flight */
isGeneratingFingering: boolean;
loadError: string | null;
/** Non-null during slow imports (e.g. OMR) to show a descriptive message */
loadingMessage: string | null;
@@ -134,6 +138,7 @@ export interface AppState {
loadMidiFile: (file: File) => Promise<void>;
loadMusicXmlFile: (file: File) => Promise<void>;
loadPdfFile: (file: File) => Promise<void>;
generateFingering: () => Promise<void>;
play: () => void;
pause: () => void;
stop: () => void;
@@ -152,6 +157,7 @@ export interface AppState {
export const DEFAULT_SETTINGS: AppSettings = {
showFingering: false,
showFingeringOnNotes: false,
showHandColors: true,
useFlats: false,
viewportSeconds: 4.0,
@@ -218,6 +224,7 @@ export const useAppStore = create<AppState>((set, get) => {
return {
document: null,
isLoadingDocument: false,
isGeneratingFingering: false,
loadError: null,
loadingMessage: null,
status: "stopped",
@@ -280,7 +287,8 @@ export const useAppStore = create<AppState>((set, get) => {
// Stub document - sheet music renders immediately via musicXml string
const doc: import("@bach-to-basics/shared").MusicDocument = {
id, title,
id,
title,
sourceType: "musicxml",
musicXml,
midiBuffer: null,
@@ -303,7 +311,8 @@ export const useAppStore = create<AppState>((set, get) => {
// 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"))
const xmlBytesForBackend =
musicXml && file.name.toLowerCase().endsWith(".mxl")
? new TextEncoder().encode(musicXml).buffer
: xmlBytes;
const filenameForBackend = file.name.toLowerCase().endsWith(".mxl")
@@ -323,7 +332,11 @@ export const useAppStore = create<AppState>((set, get) => {
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 });
set({
isLoadingDocument: true,
loadingMessage: "Reading sheet music\u2026",
loadError: null,
});
try {
const form = new FormData();
@@ -334,11 +347,11 @@ export const useAppStore = create<AppState>((set, get) => {
const res = await fetch("/api/omr/pdf2midi", { method: "POST", body: form });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { detail?: string };
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 {
const { musicxml, midi_b64 } = (await res.json()) as {
musicxml: string;
midi_b64: string;
filename: string;
@@ -361,7 +374,6 @@ export const useAppStore = create<AppState>((set, get) => {
set({ document: { ...doc, midiBuffer: bufferForDoc } });
await syncEngine.loadDocument({ ...doc, midiBuffer: bufferForDoc });
} catch (err) {
set({ loadError: String(err) });
} finally {
@@ -369,6 +381,52 @@ export const useAppStore = create<AppState>((set, get) => {
}
},
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 });
}
},
play: () => {
const { settings, status } = get();
// Speed trainer: seed the engine's current state before play
@@ -472,11 +530,7 @@ export const useAppStore = create<AppState>((set, get) => {
// ── Helpers ──────────────────────────────────────────────────────────────────
function parseMidiInWorker(
buffer: ArrayBuffer,
id: string,
title: string
): Promise<MusicDocument> {
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",
@@ -503,6 +557,117 @@ function parseMidiInWorker(
});
}
// ── 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,
@@ -531,20 +696,19 @@ async function fetchMidiFromXml(
}
}
async function fetchMusicXml(
buffer: ArrayBuffer,
doc: MusicDocument,
_id: string
): Promise<void> {
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 });
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 };
const { musicxml } = (await res.json()) as { musicxml: string };
doc.musicXml = musicxml;
// Notify sheet view that MusicXML is now available
+21 -1
View File
@@ -44,7 +44,9 @@ export function PracticeView() {
isLoadingDocument,
} = useAppStore();
// Pre-warm audio so piano keys respond with minimal latency.
// Pre-warm sample loading so piano keys respond with minimal latency.
// Sample fetch (CDN) does NOT need a user gesture - just creates an
// AudioContext (suspended) and downloads samples in the background.
useEffect(() => {
syncEngine.audio.load().catch(() => {});
const preWarm = () => syncEngine.audio.load().catch(() => {});
@@ -52,6 +54,24 @@ export function PracticeView() {
return () => document.removeEventListener("pointermove", preWarm);
}, []);
// Wake both audio contexts (Tone.js + smplr) on the FIRST real user gesture.
// The pre-warm above creates contexts before any gesture, so browser autoplay
// policy keeps them suspended. Without this, no sound ever plays.
useEffect(() => {
let woken = false;
const wake = () => {
if (woken) return;
woken = true;
syncEngine.wakeAudio();
};
window.addEventListener("pointerdown", wake);
window.addEventListener("keydown", wake);
return () => {
window.removeEventListener("pointerdown", wake);
window.removeEventListener("keydown", wake);
};
}, []);
// Apply theme to <html> element
useEffect(() => {
document.documentElement.dataset.theme = settings.theme;