Add piano fingering generation and display via pianoplayer, fix silent audio on first load
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user