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). pianoplayer service: annotate MusicXML with fingering (Parncutt algorithm).
Note: defusedxml.defuse_stdlib() is called in main.py before this module Uses the pianoplayer v3 API (run_annotate) which handles part routing and
is imported, so xml.etree (used by music21 internally) is already patched. hand assignment internally.
""" """
import asyncio import asyncio
import os import os
@@ -28,11 +28,12 @@ async def annotate_fingering(musicxml: str) -> str:
def _annotate(musicxml: str) -> str: def _annotate(musicxml: str) -> str:
from pianoplayer.hand import RightHand, LeftHand import io
import music21 import contextlib
from pianoplayer.core import run_annotate
xml_path = None xml_path = None
out_tmp_name = None out_path = None
try: try:
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
suffix=".xml", delete=False, mode="w", encoding="utf-8" suffix=".xml", delete=False, mode="w", encoding="utf-8"
@@ -40,37 +41,24 @@ def _annotate(musicxml: str) -> str:
f.write(musicxml) f.write(musicxml)
xml_path = f.name 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 = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
out_tmp.close() out_tmp.close()
out_tmp_name = out_tmp.name out_path = out_tmp.name
score.write("musicxml", fp=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: finally:
if xml_path: if xml_path:
Path(xml_path).unlink(missing_ok=True) Path(xml_path).unlink(missing_ok=True)
if out_tmp_name: if out_path:
Path(out_tmp_name).unlink(missing_ok=True) Path(out_path).unlink(missing_ok=True)
@@ -244,6 +244,9 @@ class NoteBar {
private label: PIXI.Text; private label: PIXI.Text;
/** Whether this bar's label should be shown at all (text non-empty AND bar big enough). */ /** Whether this bar's label should be shown at all (text non-empty AND bar big enough). */
private labelWanted = false; 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; note: NoteEvent | null = null;
active = false; active = false;
impactFired = false; impactFired = false;
@@ -283,6 +286,8 @@ class NoteBar {
this.active = true; this.active = true;
this.impactFired = false; this.impactFired = false;
this.baseColor = baseColor; this.baseColor = baseColor;
this.barWidth = barWidth;
this.barHeight = barHeight;
this.container.visible = true; this.container.visible = true;
this.container.x = x; this.container.x = x;
this.container.y = startY; this.container.y = startY;
@@ -325,6 +330,13 @@ class NoteBar {
this.label.visible = this.labelWanted; 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). */ /** Keep label clamped within the visible portion of the bar (for very tall notes). */
updateLabelY(containerY: number, barHeight: number, viewportHeight: number) { updateLabelY(containerY: number, barHeight: number, viewportHeight: number) {
if (!this.labelWanted) { if (!this.labelWanted) {
@@ -362,6 +374,7 @@ export interface FallingNotesOptions {
height: number; height: number;
viewportSeconds: number; viewportSeconds: number;
fallingNotesLabelMode?: NoteLabelMode; fallingNotesLabelMode?: NoteLabelMode;
/** When true, append the assigned finger digit to each note bar's label */
showFingering: boolean; showFingering: boolean;
showHandColors: boolean; showHandColors: boolean;
useFlats: boolean; useFlats: boolean;
@@ -983,9 +996,13 @@ export class FallingNotesRenderer {
("useFlats" in opts && opts.useFlats !== this.opts.useFlats) || ("useFlats" in opts && opts.useFlats !== this.opts.useFlats) ||
("fallingNotesLabelMode" in opts && ("fallingNotesLabelMode" in opts &&
opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) || opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) ||
("showFingering" in opts && opts.showFingering !== this.opts.showFingering) ||
("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline); ("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); Object.assign(this.opts, opts);
if (!this._initialized) return; if (!this._initialized) return;
@@ -993,6 +1010,10 @@ export class FallingNotesRenderer {
for (const g of this.gradientCache.values()) g.destroy(); for (const g of this.gradientCache.values()) g.destroy();
this.gradientCache.clear(); this.gradientCache.clear();
this.reset(); 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 ("showGrid" in opts) this.gridLayer.visible = !!opts.showGrid;
if ("impactStyle" in opts) { if ("impactStyle" in opts) {
@@ -17,12 +17,16 @@ export function FallingNotesView() {
const accuracy = useAccuracy(midiEnabled && (status === "playing" || status === "paused")); const accuracy = useAccuracy(midiEnabled && (status === "playing" || status === "paused"));
const measureStarts = useMemo( const measureStarts = useMemo(
() => doc ? computeMeasureSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration) : [], () =>
doc
? computeMeasureSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration)
: [],
[doc] [doc]
); );
const beatStarts = useMemo( const beatStarts = useMemo(
() => doc ? computeBeatSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration) : [], () =>
doc ? computeBeatSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration) : [],
[doc] [doc]
); );
@@ -38,7 +42,7 @@ export function FallingNotesView() {
height, height,
viewportSeconds: settings.viewportSeconds, viewportSeconds: settings.viewportSeconds,
fallingNotesLabelMode: settings.fallingNotesLabelMode, fallingNotesLabelMode: settings.fallingNotesLabelMode,
showFingering: settings.showFingering, showFingering: settings.showFingering && settings.showFingeringOnNotes,
showHandColors: settings.showHandColors, showHandColors: settings.showHandColors,
useFlats: settings.useFlats, useFlats: settings.useFlats,
noteFilter: settings.noteFilter, noteFilter: settings.noteFilter,
@@ -103,7 +107,7 @@ export function FallingNotesView() {
rendererRef.current?.setOptions({ rendererRef.current?.setOptions({
viewportSeconds: settings.viewportSeconds, viewportSeconds: settings.viewportSeconds,
fallingNotesLabelMode: settings.fallingNotesLabelMode, fallingNotesLabelMode: settings.fallingNotesLabelMode,
showFingering: settings.showFingering, showFingering: settings.showFingering && settings.showFingeringOnNotes,
showHandColors: settings.showHandColors, showHandColors: settings.showHandColors,
useFlats: settings.useFlats, useFlats: settings.useFlats,
noteFilter: settings.noteFilter, noteFilter: settings.noteFilter,
@@ -127,6 +131,7 @@ export function FallingNotesView() {
settings.viewportSeconds, settings.viewportSeconds,
settings.fallingNotesLabelMode, settings.fallingNotesLabelMode,
settings.showFingering, settings.showFingering,
settings.showFingeringOnNotes,
settings.showHandColors, settings.showHandColors,
settings.useFlats, settings.useFlats,
settings.noteFilter, settings.noteFilter,
@@ -151,7 +156,10 @@ export function FallingNotesView() {
if (!doc || !settings.scrollToSeek) return; if (!doc || !settings.scrollToSeek) return;
// Seek forward/backward by 30% of the viewport window per scroll tick // Seek forward/backward by 30% of the viewport window per scroll tick
const delta = (e.deltaY > 0 ? 1 : -1) * settings.viewportSeconds * 0.3; 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); seek(next);
}; };
@@ -162,19 +170,25 @@ export function FallingNotesView() {
onWheel={handleWheel} onWheel={handleWheel}
style={{ style={{
minHeight: 200, 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)", borderTop: "1px solid var(--color-notes-border)",
}} }}
> >
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} /> <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 <div
className="absolute top-2 left-2 z-10 pointer-events-none text-xs px-2 py-0.5 rounded-full font-medium" className="absolute top-2 left-2 z-10 pointer-events-none text-xs px-2 py-0.5 rounded-full font-medium"
style={{ 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:
color: accuracy.score >= 80 ? "#4ade80" : accuracy.score >= 50 ? "#eab308" : "#ef4444", accuracy.score >= 80
border: "1px solid currentColor", ? "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",
}} }}
> >
<TargetIcon /> {accuracy.score}% <TargetIcon /> {accuracy.score}%
@@ -187,9 +201,18 @@ export function FallingNotesView() {
// ── Icons ───────────────────────────────────────────────────────────────────── // ── Icons ─────────────────────────────────────────────────────────────────────
const TargetIcon = () => ( 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
<circle cx="12" cy="12" r="9"/> width="11"
<circle cx="12" cy="12" r="5"/> height="11"
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none"/> 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" />
</svg> </svg>
); );
@@ -430,7 +430,13 @@ function ResetButton() {
// ── Main component ──────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () => void }) { 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. // Computes the inline style for range inputs.
// Sets --fill-pct so the CSS gradient draws the correct filled/unfilled split. // 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 ───────────────────────────────────────────────────── */} {/* ── Overlays ───────────────────────────────────────────────────── */}
<Section title="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 <Row
label="Measure numbers" label="Measure numbers"
sublabel="on the left edge of falling notes" sublabel="on the left edge of falling notes"
+52 -15
View File
@@ -4,18 +4,18 @@ import type { NoteEvent } from "@bach-to-basics/shared";
// ── Instrument catalogue ────────────────────────────────────────────────────── // ── Instrument catalogue ──────────────────────────────────────────────────────
export type InstrumentId = export type InstrumentId =
| "grand" // Splendid Grand Piano (SplendidGrandPiano) | "grand" // Splendid Grand Piano (SplendidGrandPiano)
| "bright" // Bright Acoustic Piano (Soundfont) | "bright" // Bright Acoustic Piano (Soundfont)
| "electric" // CP80 Electric Grand (ElectricPiano) | "electric" // CP80 Electric Grand (ElectricPiano)
| "harpsichord" // Harpsichord (Soundfont) | "harpsichord" // Harpsichord (Soundfont)
| "honkytonk"; // Honky-Tonk Piano (Soundfont) | "honkytonk"; // Honky-Tonk Piano (Soundfont)
export const INSTRUMENT_LABELS: Record<InstrumentId, string> = { export const INSTRUMENT_LABELS: Record<InstrumentId, string> = {
grand: "Grand", grand: "Grand",
bright: "Bright", bright: "Bright",
electric: "E-Piano", electric: "E-Piano",
harpsichord: "Harpsi", harpsichord: "Harpsi",
honkytonk: "Honky", honkytonk: "Honky",
}; };
// Minimal common interface shared by all smplr players // Minimal common interface shared by all smplr players
@@ -54,10 +54,20 @@ export class AudioEngine {
if (this._loaded) return; if (this._loaded) return;
if (this._loadPromise) return this._loadPromise; if (this._loadPromise) return this._loadPromise;
this._loadPromise = (async () => { this._loadPromise = (async () => {
if (!this.ctx) this.ctx = new AudioContext(); try {
this.player = createPlayer(this._currentId, this.ctx); if (!this.ctx) this.ctx = new AudioContext();
await this.player.load; this.player = createPlayer(this._currentId, this.ctx);
this._loaded = true; 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; return this._loadPromise;
} }
@@ -72,7 +82,11 @@ export class AudioEngine {
this._loaded = false; this._loaded = false;
this._loadPromise = null; this._loadPromise = null;
// Silence any ongoing audio before swapping the player // Silence any ongoing audio before swapping the player
try { this.player?.stop?.(); } catch { /* ignore */ } try {
this.player?.stop?.();
} catch {
/* ignore */
}
this.player = null; this.player = null;
// Pre-load the new instrument if an AudioContext already exists // Pre-load the new instrument if an AudioContext already exists
if (this.ctx) await this.load(); if (this.ctx) await this.load();
@@ -82,6 +96,25 @@ export class AudioEngine {
return this._loaded; 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) { playNote(note: NoteEvent) {
if (!this.player || !this._loaded) return; if (!this.player || !this._loaded) return;
// Resume in case the AudioContext was created before a user gesture (browser // Resume in case the AudioContext was created before a user gesture (browser
@@ -104,6 +137,10 @@ export class AudioEngine {
} }
stopAll() { stopAll() {
try { this.player?.stop?.(); } catch { /* ignore */ } try {
this.player?.stop?.();
} catch {
/* ignore */
}
} }
} }
+90 -26
View File
@@ -11,10 +11,10 @@ interface YTPlayer {
} }
interface SpeedTrainerOpts { interface SpeedTrainerOpts {
enabled: boolean; enabled: boolean;
startPct: number; startPct: number;
endPct: number; endPct: number;
stepPct: number; stepPct: number;
currentPct: number; currentPct: number;
} }
@@ -77,7 +77,11 @@ class SyncEngine {
private countInBars: 0 | 1 | 2 = 0; private countInBars: 0 | 1 | 2 = 0;
private countInTimeouts: ReturnType<typeof setTimeout>[] = []; private countInTimeouts: ReturnType<typeof setTimeout>[] = [];
private speedTrainer: SpeedTrainerOpts = { private speedTrainer: SpeedTrainerOpts = {
enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60, enabled: false,
startPct: 60,
endPct: 100,
stepPct: 5,
currentPct: 60,
}; };
constructor() { constructor() {
@@ -96,6 +100,39 @@ class SyncEngine {
this.emitState("stopped"); 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) { async play(fromSeconds?: number) {
if (!this.doc) return; if (!this.doc) return;
await this.audio.load(); await this.audio.load();
@@ -125,7 +162,7 @@ class SyncEngine {
const timeSig = this.doc!.timeSignatures[0] ?? { numerator: 4, denominator: 4 }; const timeSig = this.doc!.timeSignatures[0] ?? { numerator: 4, denominator: 4 };
const beatsPerBar = timeSig.numerator; const beatsPerBar = timeSig.numerator;
// Beat duration respects current tempo multiplier so count-in matches playback speed // 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; const totalBeats = this.countInBars * beatsPerBar;
// Ensure metronome synth exists // Ensure metronome synth exists
@@ -143,8 +180,11 @@ class SyncEngine {
for (let b = 0; b < totalBeats; b++) { for (let b = 0; b < totalBeats; b++) {
const isAccent = b % beatsPerBar === 0; const isAccent = b % beatsPerBar === 0;
const id = setTimeout(() => { const id = setTimeout(() => {
try { this.metronome!.triggerAttackRelease(isAccent ? "C6" : "G5", "32n"); } try {
catch { /* AudioContext not ready */ } this.metronome!.triggerAttackRelease(isAccent ? "C6" : "G5", "32n");
} catch {
/* AudioContext not ready */
}
}, b * beatDurMs); }, b * beatDurMs);
this.countInTimeouts.push(id); this.countInTimeouts.push(id);
} }
@@ -274,7 +314,10 @@ class SyncEngine {
} }
setHandVolume(vol: { left: number; right: number }) { 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") { setWaitForHand(hand: "left" | "right" | "both") {
@@ -285,7 +328,9 @@ class SyncEngine {
await this.audio.setInstrument(id); await this.audio.setInstrument(id);
} }
setYouTubePlayer(player: YTPlayer | null) { this.ytPlayer = player; } setYouTubePlayer(player: YTPlayer | null) {
this.ytPlayer = player;
}
// ── Practice feature setters ──────────────────────────────────────────────── // ── Practice feature setters ────────────────────────────────────────────────
@@ -351,7 +396,9 @@ class SyncEngine {
return () => this.stateListeners.delete(cb); return () => this.stateListeners.delete(cb);
} }
get state(): Readonly<SyncEngineState> { return this._state; } get state(): Readonly<SyncEngineState> {
return this._state;
}
// ── Private ───────────────────────────────────────────────────────────────── // ── Private ─────────────────────────────────────────────────────────────────
@@ -407,7 +454,11 @@ class SyncEngine {
const beat = Math.floor(docSeconds / (60 / bpm)); const beat = Math.floor(docSeconds / (60 / bpm));
if (beat !== this.lastBeatCrossed) { if (beat !== this.lastBeatCrossed) {
this.lastBeatCrossed = beat; 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 // note.startSeconds is in doc-time; compare directly to docSeconds
if (note.startSeconds > docSeconds + LOOKAHEAD_DOC_SECONDS) break; 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) // Apply transposition (creates a new NoteEvent if needed, preserves original id)
const tNote = this.transposeNote(note); const tNote = this.transposeNote(note);
@@ -454,21 +508,27 @@ class SyncEngine {
// Convert doc-time offset to real-time for Tone.js scheduling. // Convert doc-time offset to real-time for Tone.js scheduling.
// renderOffsetMs > 0 means audio plays earlier (compensates for delayed output). // renderOffsetMs > 0 means audio plays earlier (compensates for delayed output).
const offsetSec = this.renderOffsetMs / 1000; const offsetSec = this.renderOffsetMs / 1000;
const onDelay = Math.max(0, (note.startSeconds - docSeconds) / tempoMultiplier - offsetSec); const onDelay = Math.max(0, (note.startSeconds - docSeconds) / tempoMultiplier - offsetSec);
const offDelay = Math.max(0, (note.endSeconds - docSeconds) / tempoMultiplier - offsetSec); const offDelay = Math.max(0, (note.endSeconds - docSeconds) / tempoMultiplier - offsetSec);
Tone.getTransport().scheduleOnce(() => { Tone.getTransport().scheduleOnce(() => {
// Read activeHands at fire-time so hand toggles take effect immediately // Read activeHands at fire-time so hand toggles take effect immediately
// (not at schedule-time, which would lag up to LOOKAHEAD_DOC_SECONDS). // (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) { if (isActive) {
bus.emit("note:on", tNote); bus.emit("note:on", tNote);
// Apply per-hand volume multiplier (also read at fire-time for instant effect) // Apply per-hand volume multiplier (also read at fire-time for instant effect)
const handVol = note.hand === "left" ? this._state.handVolume.left const handVol =
: note.hand === "right" ? this._state.handVolume.right note.hand === "left"
: 1; ? this._state.handVolume.left
const scaledNote = handVol === 1 ? tNote : note.hand === "right"
: { ...tNote, velocity: Math.max(1, Math.round(tNote.velocity * handVol)) }; ? this._state.handVolume.right
: 1;
const scaledNote =
handVol === 1
? tNote
: { ...tNote, velocity: Math.max(1, Math.round(tNote.velocity * handVol)) };
this.audio.playNote(scaledNote); this.audio.playNote(scaledNote);
} }
}, `+${onDelay}`); }, `+${onDelay}`);
@@ -480,11 +540,13 @@ class SyncEngine {
// Wait mode // Wait mode
if (this._state.waitMode && this._state.status === "playing") { 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 // Only pause for the targeted hand (waitForHand); "both" = current behavior
const isWaited = this._state.waitForHand === "both" const isWaited =
|| note.hand === "unknown" this._state.waitForHand === "both" ||
|| note.hand === this._state.waitForHand; note.hand === "unknown" ||
note.hand === this._state.waitForHand;
if (isActive && isWaited && onDelay <= 0.05) { if (isActive && isWaited && onDelay <= 0.05) {
this.clock.pause(); this.clock.pause();
this._state.status = "waiting"; this._state.status = "waiting";
@@ -503,7 +565,9 @@ class SyncEngine {
if (Math.abs(actual - expectedYtTime) > YT_DRIFT_THRESHOLD) { if (Math.abs(actual - expectedYtTime) > YT_DRIFT_THRESHOLD) {
this.ytPlayer.seekTo(expectedYtTime, true); this.ytPlayer.seekTo(expectedYtTime, true);
} }
} catch { /* not ready yet */ } } catch {
/* not ready yet */
}
} }
} }
+236 -72
View File
@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { syncEngine } from "../engine/SyncEngine"; import { syncEngine } from "../engine/SyncEngine";
import bus from "../engine/EventBus"; 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"; import type { InstrumentId } from "../engine/AudioEngine";
export { computeMeasureSeconds, keySignatureToLabel } from "@bach-to-basics/shared"; export { computeMeasureSeconds, keySignatureToLabel } from "@bach-to-basics/shared";
export type { InstrumentId } from "../engine/AudioEngine"; export type { InstrumentId } from "../engine/AudioEngine";
@@ -28,17 +28,17 @@ export type ColorTheme = "violet" | "classic" | "ocean" | "forest" | "cascade" |
/** Per-channel colors for the custom theme (hex strings, e.g. "#3b82f6"). */ /** Per-channel colors for the custom theme (hex strings, e.g. "#3b82f6"). */
export interface CustomColors { export interface CustomColors {
leftHand: string; leftHand: string;
rightHand: string; rightHand: string;
unknown: string; unknown: string;
} }
/** Pre-built color values matching each named preset (used to seed the custom picker). */ /** Pre-built color values matching each named preset (used to seed the custom picker). */
export const COLOR_PRESET_VALUES: Record<Exclude<ColorTheme, "custom">, CustomColors> = { export const COLOR_PRESET_VALUES: Record<Exclude<ColorTheme, "custom">, CustomColors> = {
violet: { leftHand: "#7c3aed", rightHand: "#f59e0b", unknown: "#a78bfa" }, // purple + amber violet: { leftHand: "#7c3aed", rightHand: "#f59e0b", unknown: "#a78bfa" }, // purple + amber
classic: { leftHand: "#2563eb", rightHand: "#dc2626", unknown: "#7c3aed" }, // blue + red classic: { leftHand: "#2563eb", rightHand: "#dc2626", unknown: "#7c3aed" }, // blue + red
ocean: { leftHand: "#0284c7", rightHand: "#f97316", unknown: "#0ea5e9" }, // blue + coral ocean: { leftHand: "#0284c7", rightHand: "#f97316", unknown: "#0ea5e9" }, // blue + coral
forest: { leftHand: "#16a34a", rightHand: "#a855f7", unknown: "#4ade80" }, // green + purple forest: { leftHand: "#16a34a", rightHand: "#a855f7", unknown: "#4ade80" }, // green + purple
cascade: { leftHand: "#9333ea", rightHand: "#22d3ee", unknown: "#a855f7" }, // purple + cyan cascade: { leftHand: "#9333ea", rightHand: "#22d3ee", unknown: "#a855f7" }, // purple + cyan
}; };
@@ -46,15 +46,17 @@ export const COLOR_PRESET_VALUES: Record<Exclude<ColorTheme, "custom">, CustomCo
export type NoteLabelMode = "none" | "c_only" | "white" | "black" | "all"; export type NoteLabelMode = "none" | "c_only" | "white" | "black" | "all";
export interface SpeedTrainerSettings { export interface SpeedTrainerSettings {
enabled: boolean; enabled: boolean;
startPct: number; // 0-100 startPct: number; // 0-100
endPct: number; // 0-100 endPct: number; // 0-100
stepPct: number; // increment per loop stepPct: number; // increment per loop
currentPct: number; // current ramp position currentPct: number; // current ramp position
} }
export interface AppSettings { export interface AppSettings {
showFingering: boolean; showFingering: boolean;
/** Also overlay finger digits on falling-note bars (default off - adds visual clutter) */
showFingeringOnNotes: boolean;
showHandColors: boolean; showHandColors: boolean;
useFlats: boolean; useFlats: boolean;
viewportSeconds: number; // FallingNotes lookahead window viewportSeconds: number; // FallingNotes lookahead window
@@ -68,49 +70,51 @@ export interface AppSettings {
metronomeEnabled: boolean; metronomeEnabled: boolean;
// ── New settings ────────────────────────────────────────────────────────── // ── New settings ──────────────────────────────────────────────────────────
/** Note labels on the piano keyboard */ /** Note labels on the piano keyboard */
noteLabelMode: NoteLabelMode; noteLabelMode: NoteLabelMode;
/** Note labels on the falling notes bars */ /** Note labels on the falling notes bars */
fallingNotesLabelMode: NoteLabelMode; fallingNotesLabelMode: NoteLabelMode;
pianoTheme: "white" | "ivory"; pianoTheme: "white" | "ivory";
showGrid: boolean; showGrid: boolean;
showMeasureNums: boolean; showMeasureNums: boolean;
showKeySignature: boolean; showKeySignature: boolean;
transposeSemitones: number; // -6..+6 transposeSemitones: number; // -6..+6
speedTrainer: SpeedTrainerSettings; speedTrainer: SpeedTrainerSettings;
countInBars: 0 | 1 | 2; countInBars: 0 | 1 | 2;
/** Which visual effect plays when a note reaches the hit line */ /** Which visual effect plays when a note reaches the hit line */
impactStyle: ImpactStyle; impactStyle: ImpactStyle;
sheetMusicWhiteBackground: boolean; sheetMusicWhiteBackground: boolean;
// ── MIDIano-inspired additions ──────────────────────────────────────────── // ── MIDIano-inspired additions ────────────────────────────────────────────
/** Scroll wheel on the falling notes canvas seeks playback position */ /** Scroll wheel on the falling notes canvas seeks playback position */
scrollToSeek: boolean; scrollToSeek: boolean;
/** Minimum pixel height for note bars (prevents staccato notes becoming invisible) */ /** Minimum pixel height for note bars (prevents staccato notes becoming invisible) */
minNoteHeight: number; // 4-24 minNoteHeight: number; // 4-24
/** Border-radius for note bar corners (0 = sharp, 12 = pill) */ /** Border-radius for note bar corners (0 = sharp, 12 = pill) */
noteCornerRadius: number; // 0-12 noteCornerRadius: number; // 0-12
/** Draw faint horizontal lines at each beat and measure boundary */ /** Draw faint horizontal lines at each beat and measure boundary */
showBeatLines: boolean; showBeatLines: boolean;
/** Show sustain pedal (CC64) regions as semi-transparent bands */ /** Show sustain pedal (CC64) regions as semi-transparent bands */
showSustainPedal: boolean; showSustainPedal: boolean;
/** Per-hand volume multiplier (0 = silent, 1 = full) */ /** Per-hand volume multiplier (0 = silent, 1 = full) */
handVolume: { left: number; right: number }; handVolume: { left: number; right: number };
/** Active instrument for audio playback */ /** Active instrument for audio playback */
instrument: InstrumentId; instrument: InstrumentId;
/** Which hand(s) trigger wait-mode pauses ("both" = default, all hands) */ /** Which hand(s) trigger wait-mode pauses ("both" = default, all hands) */
waitForHand: "left" | "right" | "both"; waitForHand: "left" | "right" | "both";
/** Draw a colored outline stroke around falling note bars */ /** Draw a colored outline stroke around falling note bars */
showNoteOutline: boolean; showNoteOutline: boolean;
// ── MIDIano live-app additions ──────────────────────────────────────────── // ── MIDIano live-app additions ────────────────────────────────────────────
/** Shift audio scheduling by ±ms to compensate for audio interface latency (positive = play earlier) */ /** Shift audio scheduling by ±ms to compensate for audio interface latency (positive = play earlier) */
renderOffset: number; // -200..+200 ms renderOffset: number; // -200..+200 ms
/** Keep a ghost indicator for notes that have ended while sustain pedal is held */ /** Keep a ghost indicator for notes that have ended while sustain pedal is held */
showSustainedNotes: boolean; showSustainedNotes: boolean;
} }
export interface AppState { export interface AppState {
// ── Document ───────────────────────────────────────────────────────────── // ── Document ─────────────────────────────────────────────────────────────
document: MusicDocument | null; document: MusicDocument | null;
isLoadingDocument: boolean; isLoadingDocument: boolean;
/** True while /fingering/generate is in flight */
isGeneratingFingering: boolean;
loadError: string | null; loadError: string | null;
/** Non-null during slow imports (e.g. OMR) to show a descriptive message */ /** Non-null during slow imports (e.g. OMR) to show a descriptive message */
loadingMessage: string | null; loadingMessage: string | null;
@@ -134,6 +138,7 @@ export interface AppState {
loadMidiFile: (file: File) => Promise<void>; loadMidiFile: (file: File) => Promise<void>;
loadMusicXmlFile: (file: File) => Promise<void>; loadMusicXmlFile: (file: File) => Promise<void>;
loadPdfFile: (file: File) => Promise<void>; loadPdfFile: (file: File) => Promise<void>;
generateFingering: () => Promise<void>;
play: () => void; play: () => void;
pause: () => void; pause: () => void;
stop: () => void; stop: () => void;
@@ -152,6 +157,7 @@ export interface AppState {
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
showFingering: false, showFingering: false,
showFingeringOnNotes: false,
showHandColors: true, showHandColors: true,
useFlats: false, useFlats: false,
viewportSeconds: 4.0, viewportSeconds: 4.0,
@@ -164,31 +170,31 @@ export const DEFAULT_SETTINGS: AppSettings = {
layoutMode: "falling", layoutMode: "falling",
metronomeEnabled: false, metronomeEnabled: false,
// ── New defaults ────────────────────────────────────────────────────────── // ── New defaults ──────────────────────────────────────────────────────────
noteLabelMode: "c_only", noteLabelMode: "c_only",
fallingNotesLabelMode: "all", fallingNotesLabelMode: "all",
pianoTheme: "white", pianoTheme: "white",
showGrid: false, showGrid: false,
showMeasureNums: true, showMeasureNums: true,
showKeySignature: true, showKeySignature: true,
transposeSemitones: 0, transposeSemitones: 0,
speedTrainer: { enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60 }, speedTrainer: { enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60 },
countInBars: 0, countInBars: 0,
impactStyle: "bloom" as ImpactStyle, impactStyle: "bloom" as ImpactStyle,
sheetMusicWhiteBackground: false, sheetMusicWhiteBackground: false,
// ── MIDIano-inspired additions ──────────────────────────────────────────── // ── MIDIano-inspired additions ────────────────────────────────────────────
scrollToSeek: false, scrollToSeek: false,
minNoteHeight: 8, minNoteHeight: 8,
noteCornerRadius: 4, noteCornerRadius: 4,
showBeatLines: false, showBeatLines: false,
showSustainPedal: false, showSustainPedal: false,
handVolume: { left: 1, right: 1 }, handVolume: { left: 1, right: 1 },
// ── "Consider later" additions ──────────────────────────────────────────── // ── "Consider later" additions ────────────────────────────────────────────
instrument: "grand" as InstrumentId, instrument: "grand" as InstrumentId,
waitForHand: "both" as "left" | "right" | "both", waitForHand: "both" as "left" | "right" | "both",
showNoteOutline: false, showNoteOutline: false,
// ── MIDIano live-app additions ──────────────────────────────────────────── // ── MIDIano live-app additions ────────────────────────────────────────────
renderOffset: 0, renderOffset: 0,
showSustainedNotes: false, showSustainedNotes: false,
}; };
export const useAppStore = create<AppState>((set, get) => { export const useAppStore = create<AppState>((set, get) => {
@@ -218,6 +224,7 @@ export const useAppStore = create<AppState>((set, get) => {
return { return {
document: null, document: null,
isLoadingDocument: false, isLoadingDocument: false,
isGeneratingFingering: false,
loadError: null, loadError: null,
loadingMessage: null, loadingMessage: null,
status: "stopped", status: "stopped",
@@ -280,7 +287,8 @@ export const useAppStore = create<AppState>((set, get) => {
// Stub document - sheet music renders immediately via musicXml string // Stub document - sheet music renders immediately via musicXml string
const doc: import("@bach-to-basics/shared").MusicDocument = { const doc: import("@bach-to-basics/shared").MusicDocument = {
id, title, id,
title,
sourceType: "musicxml", sourceType: "musicxml",
musicXml, musicXml,
midiBuffer: null, midiBuffer: null,
@@ -303,9 +311,10 @@ export const useAppStore = create<AppState>((set, get) => {
// to UTF-8) with a .xml extension, so the backend gets clean MusicXML rather // 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 // than the ZIP container. This avoids cross-staff voice-duplication artefacts
// that music21 can produce when it parses a compressed .mxl directly. // that music21 can produce when it parses a compressed .mxl directly.
const xmlBytesForBackend = (musicXml && file.name.toLowerCase().endsWith(".mxl")) const xmlBytesForBackend =
? new TextEncoder().encode(musicXml).buffer musicXml && file.name.toLowerCase().endsWith(".mxl")
: xmlBytes; ? new TextEncoder().encode(musicXml).buffer
: xmlBytes;
const filenameForBackend = file.name.toLowerCase().endsWith(".mxl") const filenameForBackend = file.name.toLowerCase().endsWith(".mxl")
? file.name.replace(/\.mxl$/i, ".xml") ? file.name.replace(/\.mxl$/i, ".xml")
: file.name; : file.name;
@@ -323,7 +332,11 @@ export const useAppStore = create<AppState>((set, get) => {
const rawTitle = file.name.replace(/\.pdf$/i, ""); const rawTitle = file.name.replace(/\.pdf$/i, "");
const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled"; 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 { try {
const form = new FormData(); 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 }); const res = await fetch("/api/omr/pdf2midi", { method: "POST", body: form });
if (!res.ok) { 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}`); 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; musicxml: string;
midi_b64: string; midi_b64: string;
filename: string; filename: string;
@@ -361,7 +374,6 @@ export const useAppStore = create<AppState>((set, get) => {
set({ document: { ...doc, midiBuffer: bufferForDoc } }); set({ document: { ...doc, midiBuffer: bufferForDoc } });
await syncEngine.loadDocument({ ...doc, midiBuffer: bufferForDoc }); await syncEngine.loadDocument({ ...doc, midiBuffer: bufferForDoc });
} catch (err) { } catch (err) {
set({ loadError: String(err) }); set({ loadError: String(err) });
} finally { } 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: () => { play: () => {
const { settings, status } = get(); const { settings, status } = get();
// Speed trainer: seed the engine's current state before play // Speed trainer: seed the engine's current state before play
@@ -472,11 +530,7 @@ export const useAppStore = create<AppState>((set, get) => {
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
function parseMidiInWorker( function parseMidiInWorker(buffer: ArrayBuffer, id: string, title: string): Promise<MusicDocument> {
buffer: ArrayBuffer,
id: string,
title: string
): Promise<MusicDocument> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const worker = new Worker(new URL("../workers/midi.worker.ts", import.meta.url), { const worker = new Worker(new URL("../workers/midi.worker.ts", import.meta.url), {
type: "module", 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( async function fetchMidiFromXml(
xmlBytes: ArrayBuffer, xmlBytes: ArrayBuffer,
filename: string, filename: string,
@@ -531,20 +696,19 @@ async function fetchMidiFromXml(
} }
} }
async function fetchMusicXml( async function fetchMusicXml(buffer: ArrayBuffer, doc: MusicDocument, _id: string): Promise<void> {
buffer: ArrayBuffer,
doc: MusicDocument,
_id: string
): Promise<void> {
const blob = new Blob([buffer], { type: "audio/midi" }); const blob = new Blob([buffer], { type: "audio/midi" });
const form = new FormData(); const form = new FormData();
form.append("file", blob, "track.mid"); form.append("file", blob, "track.mid");
const titleParam = encodeURIComponent(doc.title ?? ""); 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; if (!res.ok) return;
const { musicxml } = await res.json() as { musicxml: string }; const { musicxml } = (await res.json()) as { musicxml: string };
doc.musicXml = musicxml; doc.musicXml = musicxml;
// Notify sheet view that MusicXML is now available // Notify sheet view that MusicXML is now available
+21 -1
View File
@@ -44,7 +44,9 @@ export function PracticeView() {
isLoadingDocument, isLoadingDocument,
} = useAppStore(); } = 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(() => { useEffect(() => {
syncEngine.audio.load().catch(() => {}); syncEngine.audio.load().catch(() => {});
const preWarm = () => syncEngine.audio.load().catch(() => {}); const preWarm = () => syncEngine.audio.load().catch(() => {});
@@ -52,6 +54,24 @@ export function PracticeView() {
return () => document.removeEventListener("pointermove", preWarm); 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 // Apply theme to <html> element
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = settings.theme; document.documentElement.dataset.theme = settings.theme;