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).
|
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"
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user