Initial commit

This commit is contained in:
Fabricio Giglio
2026-05-06 17:56:38 +02:00
commit 8f4df1451e
78 changed files with 12130 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import { PracticeView } from "./views/PracticeView";
export default function App() {
return <PracticeView />;
}
@@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from "react";
import { WebMidi } from "webmidi";
import { syncEngine } from "../../engine/SyncEngine";
import { useAppStore } from "../../store/useAppStore";
export function DevicePanel() {
const [inputs, setInputs] = useState<string[]>([]);
const [midiStatus, setMidiStatus] = useState<"idle" | "enabled" | "error">("idle");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const chipRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const { midiDeviceName, setMidiDevice } = useAppStore();
useEffect(() => {
WebMidi.enable({ sysex: false })
.then(() => {
setMidiStatus("enabled");
setInputs(WebMidi.inputs.map((i) => i.name));
WebMidi.addListener("connected", () => setInputs(WebMidi.inputs.map((i) => i.name)));
WebMidi.addListener("disconnected", (e) => {
setInputs(WebMidi.inputs.map((i) => i.name));
if (e.port.name === midiDeviceName) setMidiDevice(null);
});
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setErrorMsg(
!navigator.requestMIDIAccess
? "Web MIDI not supported. Use Chrome or Edge."
: `MIDI access denied. Check browser permissions. (${msg})`
);
setMidiStatus("error");
});
return () => {
try { WebMidi.removeListener("connected"); } catch {}
try { WebMidi.removeListener("disconnected"); } catch {}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Close popover on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
!chipRef.current?.contains(e.target as Node) &&
!popoverRef.current?.contains(e.target as Node)
) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const connectDevice = (name: string) => {
if (midiDeviceName) {
try { WebMidi.getInputByName(midiDeviceName)?.removeListener(); } catch {}
}
const input = WebMidi.getInputByName(name);
if (!input) return;
input.addListener("noteon", (e) => syncEngine.onMidiInput(e.note.number, e.rawValue ?? 64));
input.addListener("noteoff", (e) => syncEngine.onMidiInputOff(e.note.number));
setMidiDevice(name);
setOpen(false);
};
const disconnect = () => {
if (midiDeviceName) {
try { WebMidi.getInputByName(midiDeviceName)?.removeListener(); } catch {}
}
setMidiDevice(null);
setOpen(false);
};
// Error state
if (midiStatus === "error") {
return (
<div
className="text-xs px-2 py-1 rounded cursor-help shrink-0"
title={errorMsg ?? "MIDI error"}
style={{ color: "var(--color-warning)", background: "var(--color-warning-subtle)", border: "1px solid var(--color-warning-border)" }}
>
⚠ MIDI
</div>
);
}
// Not yet initialized
if (midiStatus === "idle") return null;
const connected = !!midiDeviceName;
return (
<div className="relative shrink-0">
{/* Chip button */}
<button
ref={chipRef}
onClick={() => setOpen((v) => !v)}
title={connected ? `Connected: ${midiDeviceName}` : "Select MIDI input device"}
className="flex items-center gap-2 text-xs font-semibold transition-colors shrink-0"
style={{
background: connected ? "rgba(34,197,94,0.08)" : "var(--color-warning-subtle)",
border: connected ? "1px solid rgba(34,197,94,0.5)" : "1px solid var(--color-warning-border)",
color: connected ? "#4ade80" : "var(--color-warning)",
cursor: "pointer",
maxWidth: 200,
borderRadius: 999,
fontSize: 12,
padding: connected ? "4px 10px" : "5px 12px",
}}
>
{connected ? (
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: "#4ade80", boxShadow: "0 0 6px #4ade80" }}
/>
) : (
<span style={{ fontSize: 11, lineHeight: 1, flexShrink: 0 }}>⚠</span>
)}
<span
className="truncate"
style={{ maxWidth: 130 }}
>
{connected ? midiDeviceName : "No MIDI device"}
</span>
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.5, flexShrink: 0 }}>
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
{/* Popover */}
{open && (
<div
ref={popoverRef}
style={{
position: "absolute",
top: "calc(100% + 6px)",
right: 0,
minWidth: 200,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
zIndex: 100,
overflow: "hidden",
}}
>
<div
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide"
style={{ color: "var(--color-text-muted)", borderBottom: "1px solid var(--color-border)" }}
>
MIDI Input
</div>
{inputs.length === 0 ? (
<div className="px-3 py-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
No MIDI devices found.
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: "4px 0" }}>
{inputs.map((name) => {
const isActive = name === midiDeviceName;
return (
<li key={name}>
<button
onClick={() => connectDevice(name)}
className="w-full text-left px-3 py-2 text-xs transition-colors flex items-center gap-2"
style={{
background: isActive ? "rgba(99,102,241,0.12)" : "transparent",
color: isActive ? "var(--color-accent)" : "var(--color-text)",
border: "none",
cursor: "pointer",
}}
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: isActive ? "#4ade80" : "var(--color-border)" }}
/>
<span className="truncate">{name}</span>
{isActive && (
<span className="ml-auto text-xs" style={{ color: "#4ade80" }}>✓</span>
)}
</button>
</li>
);
})}
</ul>
)}
{connected && (
<div style={{ borderTop: "1px solid var(--color-border)", padding: "4px 0" }}>
<button
onClick={disconnect}
className="w-full text-left px-3 py-2 text-xs transition-colors"
style={{ background: "transparent", color: "#f87171", border: "none", cursor: "pointer" }}
>
Disconnect
</button>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,931 @@
import * as PIXI from "pixi.js";
import { isBlackKey, midiToNoteName, midiToPitch } from "@bach-to-basics/shared";
import type { NoteEvent } from "@bach-to-basics/shared";
import type { NoteFilter, ColorTheme, CustomColors, NoteLabelMode, ImpactStyle } from "../../store/useAppStore";
import type { SustainRange } from "@bach-to-basics/shared";
import { lighten, hexToNum } from "../../utils/colorUtils";
// ── Layout constants ──────────────────────────────────────────────────────────
const MIDI_MIN = 21;
const MIDI_MAX = 108;
const POOL_SIZE = 1200;
function isWhite(midi: number) { return !isBlackKey(midi); }
const whiteKeyIndex: number[] = new Array(MIDI_MAX + 1).fill(0);
let wi = 0;
for (let m = MIDI_MIN; m <= MIDI_MAX; m++) {
whiteKeyIndex[m] = wi;
if (isWhite(m)) wi++;
}
const TOTAL_WHITE_KEYS = wi;
// ── Theme palette ─────────────────────────────────────────────────────────────
// Each theme has: left hand, right hand, unknown, and black-key variant (slightly lighter)
interface ThemePalette {
left: number;
right: number;
unknown: number;
leftBlack: number;
rightBlack: number;
unknownBlack: number;
}
const THEMES: Record<Exclude<ColorTheme, "custom">, ThemePalette> = {
violet: {
left: 0x7c3aed, right: 0x8b5cf6, unknown: 0x8b5cf6,
leftBlack: 0xa78bfa, rightBlack: 0xc4b5fd, unknownBlack: 0xc4b5fd,
},
classic: {
left: 0x2563eb, right: 0xdc2626, unknown: 0x7c3aed,
leftBlack: 0x60a5fa, rightBlack: 0xf87171, unknownBlack: 0xa78bfa,
},
ocean: {
left: 0x0284c7, right: 0x06b6d4, unknown: 0x0ea5e9,
leftBlack: 0x38bdf8, rightBlack: 0x67e8f9, unknownBlack: 0x7dd3fc,
},
forest: {
left: 0x15803d, right: 0x16a34a, unknown: 0x22c55e,
leftBlack: 0x4ade80, rightBlack: 0x86efac, unknownBlack: 0xbbf7d0,
},
cascade: {
left: 0x9333ea, right: 0x22d3ee, unknown: 0xa855f7,
leftBlack: 0xb06af5, rightBlack: 0x4dd5ec, unknownBlack: 0xb87af8,
},
};
/** Build a ThemePalette from three base hex colors (black-key variants auto-lightened). */
function paletteFromCustom(c: CustomColors): ThemePalette {
const l = hexToNum(c.leftHand);
const r = hexToNum(c.rightHand);
const u = hexToNum(c.unknown);
return {
left: l,
right: r,
unknown: u,
leftBlack: lighten(l, 1.55),
rightBlack: lighten(r, 1.55),
unknownBlack: lighten(u, 1.55),
};
}
function noteColor(
note: NoteEvent,
showHandColors: boolean,
theme: ColorTheme,
customColors?: CustomColors,
): number {
const pal = theme === "custom" && customColors
? paletteFromCustom(customColors)
: THEMES[theme as Exclude<ColorTheme, "custom">];
const black = isBlackKey(note.midi);
if (!showHandColors || note.hand === "unknown") {
return black ? pal.unknownBlack : pal.unknown;
}
if (note.hand === "left") return black ? pal.leftBlack : pal.left;
if (note.hand === "right") return black ? pal.rightBlack : pal.right;
return black ? pal.unknownBlack : pal.unknown;
}
// ── Gradient helpers ──────────────────────────────────────────────────────────
/**
* Linear-interpolate each RGB channel from c1 toward c2 by factor t (0=c1, 1=c2).
* Unlike multiplicative lighten(), this NEVER blows light colours to pure white.
*/
function mixColor(c1: number, c2: number, t: number): number {
const r1 = (c1 >> 16) & 0xff, r2 = (c2 >> 16) & 0xff;
const g1 = (c1 >> 8) & 0xff, g2 = (c2 >> 8) & 0xff;
const b1 = c1 & 0xff, b2 = c2 & 0xff;
return (
(Math.round(r1 + (r2 - r1) * t) << 16) |
(Math.round(g1 + (g2 - g1) * t) << 8) |
Math.round(b1 + (b2 - b1) * t)
);
}
function makeGradient(base: number): PIXI.FillGradient {
return new PIXI.FillGradient({
type: "linear",
start: { x: 0, y: 0 },
end: { x: 0, y: 1 },
textureSpace: "local",
colorStops: [
{ offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white
{ offset: 0.22, color: mixColor(base, 0xffffff, 0.22) }, // slightly bright
{ offset: 0.62, color: base }, // solid base
{ offset: 1, color: mixColor(base, 0x000000, 0.30) }, // deeper bottom
],
});
}
// ── ImpactFlash ───────────────────────────────────────────────────────────────
// Soft bloom effect: two expanding filled halos + a brief central sparkle.
// No hard strokes, no particles - purely diffuse light that dissolves gently.
class ImpactFlash {
container: PIXI.Container;
private outerBloom: PIXI.Graphics; // large, very soft, low-opacity halo
private innerBloom: PIXI.Graphics; // smaller, slightly more visible glow
private sparkle: PIXI.Graphics; // tiny bright flash at moment of contact
active = false;
private t = 0;
private noteW = 0;
private baseColor = 0xffffff;
constructor() {
this.container = new PIXI.Container();
this.outerBloom = new PIXI.Graphics();
this.innerBloom = new PIXI.Graphics();
this.sparkle = new PIXI.Graphics();
// Render back-to-front: outer halo, inner glow, sparkle on top
this.container.addChild(this.outerBloom, this.innerBloom, this.sparkle);
this.container.visible = false;
}
trigger(cx: number, cy: number, w: number, color: number) {
this.noteW = w;
this.baseColor = color;
this.t = 0;
this.active = true;
this.container.visible = true;
this.container.x = cx;
this.container.y = cy;
this._draw();
}
/** dtFrames: PIXI ticker.deltaTime (1.0 = one 60fps frame). Frame-rate independent. */
advance(dtFrames: number): boolean {
this.t += dtFrames * 0.033; // ~500ms total at 60fps
if (this.t >= 1) { this.reset(); return true; }
this._draw();
return false;
}
private _draw() {
const { t, noteW, baseColor } = this;
// Cubic ease-out: rapid initial expansion that smoothly decelerates
const et = 1 - (1 - t) * (1 - t) * (1 - t);
// ── Inner bloom - note-colored soft glow ─────────────────────────────────
const r1 = noteW * 0.4 + et * noteW * 1.5;
this.innerBloom.clear();
this.innerBloom.circle(0, 0, r1);
this.innerBloom.fill({ color: baseColor, alpha: (1 - t) * 0.28 });
// ── Outer bloom - large diffuse halo, 80ms delayed start ─────────────────
const t2 = Math.max(0, t - 0.10);
const et2 = 1 - (1 - t2) * (1 - t2) * (1 - t2);
const r2 = noteW * 0.4 + et2 * noteW * 3.0;
this.outerBloom.clear();
this.outerBloom.circle(0, 0, r2);
this.outerBloom.fill({ color: baseColor, alpha: (1 - t2) * 0.10 });
// ── Sparkle - bright note-colored flash that exists only for the first ~100ms ──
// Using baseColor (the note's own color) instead of white so it's visible in
// both dark and light mode (white would be invisible on the light #EEF1FA bg).
const sa = Math.max(0, 1 - t * 5.0);
this.sparkle.clear();
if (sa > 0) {
const sr = noteW * 0.35 * (1 - et);
if (sr > 0.5) {
this.sparkle.circle(0, 0, sr);
this.sparkle.fill({ color: baseColor, alpha: sa * 0.80 });
}
}
}
reset() {
this.active = false;
this.container.visible = false;
this.outerBloom.clear();
this.innerBloom.clear();
this.sparkle.clear();
}
}
// ── NoteBar ───────────────────────────────────────────────────────────────────
class NoteBar {
container: PIXI.Container;
private bg: PIXI.Graphics;
private label: PIXI.Text;
/** Whether this bar's label should be shown at all (text non-empty AND bar big enough). */
private labelWanted = false;
note: NoteEvent | null = null;
active = false;
impactFired = false;
baseColor = 0;
constructor() {
this.container = new PIXI.Container();
this.bg = new PIXI.Graphics();
this.label = new PIXI.Text({
text: "",
style: {
fontSize: 10,
fill: 0xffffff,
fontFamily: "Space Grotesk, system-ui, sans-serif",
fontWeight: "700",
dropShadow: { color: 0x000000, blur: 4, distance: 0, alpha: 0.85 },
},
});
this.label.anchor.set(0.5, 0.5);
this.container.addChild(this.bg, this.label);
this.container.visible = false;
}
configure(
note: NoteEvent,
x: number, barWidth: number,
startY: number, barHeight: number,
gradient: PIXI.FillGradient,
baseColor: number,
labelText: string,
cornerRadius: number,
showOutline: boolean,
) {
this.note = note;
this.active = true;
this.impactFired = false;
this.baseColor = baseColor;
this.container.visible = true;
this.container.x = x;
this.container.y = startY;
const r = Math.min(cornerRadius, barWidth * 0.25);
this.bg.clear();
// Gradient fill (mixColor-based stops, always colored, never pure white)
this.bg.roundRect(0, 0, barWidth, barHeight, r);
this.bg.fill(gradient);
// Subtle white highlight strip at the very top
if (barHeight > 10) {
const hlH = Math.min(barHeight * 0.18, 7);
this.bg.roundRect(1, 1, barWidth - 2, hlH, r * 0.6);
this.bg.fill({ color: 0xffffff, alpha: 0.2 });
}
// Border - subtle white tint by default; colored outline when enabled
this.bg.roundRect(0, 0, barWidth, barHeight, r);
if (showOutline) {
this.bg.stroke({ width: 1.5, color: baseColor, alpha: 0.55 });
} else {
this.bg.stroke({ width: 1, color: 0xffffff, alpha: 0.15 });
}
// Label - position updated each tick for tall notes via updateLabelY()
// Scale font to fit the bar: tiny bars (≤ 8 px tall or black-key narrow)
// use size 6-7 so the text doesn't dwarf the note; normal bars use 10.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.label.style as any).fontSize = barHeight <= 8 ? 6 : barWidth <= 11 ? 7 : 10;
this.label.text = labelText;
this.label.x = barWidth / 2;
this.label.y = barHeight / 2;
// Show labels whenever the bar is tall enough to hold the text (≥ 6 px)
// and wide enough to be legible (≥ 7 px). The 6 px threshold means even
// the minimum-height (minNoteHeight=8) bars get a label; at tiny sizes the
// font is already scaled down to 6-7 px so overflow is imperceptible.
this.labelWanted = labelText.length > 0 && barHeight > 6 && 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) { this.label.visible = false; return; }
// Visible slice of the bar in local coords
const localTop = Math.max(0, -containerY);
const localBottom = Math.min(barHeight, viewportHeight - containerY);
if (localBottom <= localTop) { this.label.visible = false; return; }
this.label.visible = true;
const midLocal = (localTop + localBottom) / 2;
// Only reposition if the natural center is outside the visible slice
const natural = barHeight / 2;
this.label.y = (natural >= localTop && natural <= localBottom) ? natural : midLocal;
}
reset() {
this.note = null;
this.active = false;
this.impactFired = false;
this.baseColor = 0;
this.labelWanted = false;
this.label.visible = false;
this.container.visible = false;
}
}
// ── Options & renderer ────────────────────────────────────────────────────────
export interface FallingNotesOptions {
width: number;
height: number;
viewportSeconds: number;
fallingNotesLabelMode?: NoteLabelMode;
showFingering: boolean;
showHandColors: boolean;
useFlats: boolean;
noteFilter: NoteFilter;
colorTheme: ColorTheme;
customColors?: CustomColors;
showGrid?: boolean;
showMeasureNums?: boolean;
measureStarts?: number[];
/** Which visual effect plays when a note reaches the hit line */
impactStyle?: ImpactStyle;
/** Minimum pixel height for note bars - prevents staccato slivers */
minNoteHeight?: number; // default 8
/** Border radius for note bars (0 = sharp, 12 = pill) */
noteCornerRadius?: number; // default 4
/** Draw horizontal beat/measure lines across the canvas */
showBeatLines?: boolean;
/** Pre-computed beat start times in seconds */
beatStarts?: number[];
/** Draw sustain pedal (CC64) regions as semi-transparent bands */
showSustainPedal?: boolean;
/** Sustain pedal on/off ranges in seconds */
sustainRanges?: SustainRange[];
/** Draw a colored outline stroke around note bars */
showNoteOutline?: boolean;
/** Show ghost indicators for notes that have ended while sustain pedal is held */
showSustainedNotes?: boolean;
/** True when the app is in dark mode - used to choose hit-line and beat-line colors */
darkMode?: boolean;
}
export class FallingNotesRenderer {
app: PIXI.Application;
private _initialized = false;
private opts: FallingNotesOptions;
private pool: NoteBar[] = [];
private active = new Map<string, NoteBar>();
private hitLine: PIXI.Graphics;
private ghostBar: PIXI.Graphics;
private whiteW = 0;
private blackW = 0;
// Gradient cache: base color to FillGradient (safe to share; textureSpace:"local" adapts per shape)
private gradientCache = new Map<number, PIXI.FillGradient>();
private flashes: ImpactFlash[] = [];
private flashLayer!: PIXI.Container;
private measureLayer!: PIXI.Container;
private particleLayer!: PIXI.Graphics;
private particles: Array<{
x: number; y: number; vx: number; vy: number;
size: number; life: number; decay: number;
gravity: number; color: number;
}> = [];
private gridLayer!: PIXI.Graphics;
private beatLayer!: PIXI.Graphics;
private sustainLineLayer!: PIXI.Graphics; // thin connecting line
private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool
/** Ghost indicators for notes that ended while sustain was held */
private sustainedNoteLayer!: PIXI.Graphics;
private sustainedNotes = new Map<string, { midi: number; baseColor: number; endSeconds: number }>();
constructor(opts: FallingNotesOptions) {
this.opts = opts;
this.app = new PIXI.Application();
this.hitLine = new PIXI.Graphics();
this.ghostBar = new PIXI.Graphics();
this.ghostBar.visible = false;
}
async init(canvas: HTMLCanvasElement) {
await this.app.init({
canvas,
width: this.opts.width,
height: this.opts.height,
backgroundColor: 0x050509,
backgroundAlpha: 0,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
// Keep framebuffer alive for screenshot tools (Playwright) in dev mode
...(import.meta.env.DEV ? { preserveDrawingBuffer: true } : {}),
});
this.computeLayout();
const noteLayer = new PIXI.Container();
for (let i = 0; i < POOL_SIZE; i++) {
const bar = new NoteBar();
this.pool.push(bar);
noteLayer.addChild(bar.container);
}
this.drawHitLine();
this.gridLayer = this.buildGrid();
this.gridLayer.visible = this.opts.showGrid ?? false;
this.sustainLineLayer = new PIXI.Graphics();
this.sustainTextLayer = new PIXI.Container();
// Pool of 80 text objects (up to 40 Ped + 40 ✱ markers visible at once)
for (let i = 0; i < 80; i++) {
const t = new PIXI.Text({
text: "",
style: {
fontSize: 11,
fill: 0xa855f7,
fontFamily: "Space Grotesk, system-ui, sans-serif",
fontStyle: "italic",
fontWeight: "700",
},
});
t.x = 10;
t.visible = false;
this.sustainTextLayer.addChild(t);
}
this.beatLayer = new PIXI.Graphics();
this.sustainedNoteLayer = new PIXI.Graphics();
this.particleLayer = new PIXI.Graphics();
this.flashLayer = new PIXI.Container();
for (let i = 0; i < 30; i++) {
const f = new ImpactFlash();
this.flashes.push(f);
this.flashLayer.addChild(f.container);
}
this.measureLayer = new PIXI.Container();
for (let i = 0; i < 60; i++) {
const t = new PIXI.Text({
text: "",
style: { fontSize: 11, fill: 0x8896b8, fontFamily: "Space Grotesk, system-ui, sans-serif", fontWeight: "600" },
});
t.x = 3;
t.visible = false;
this.measureLayer.addChild(t);
}
this.app.stage.addChild(
this.gridLayer, this.sustainLineLayer, this.sustainTextLayer,
this.beatLayer, noteLayer, this.particleLayer,
this.sustainedNoteLayer,
this.measureLayer, this.flashLayer, this.hitLine, this.ghostBar,
);
this._initialized = true;
}
private computeLayout() {
this.whiteW = this.opts.width / TOTAL_WHITE_KEYS;
this.blackW = this.whiteW * 0.6;
}
private drawHitLine() {
// Hit line intentionally left invisible - the CSS border between the notes
// canvas and the piano keyboard already provides a clean visual separator.
this.hitLine.clear();
}
private buildGrid(): PIXI.Graphics {
const g = new PIXI.Graphics();
// Faint vertical separator at every C (octave markers)
// Neutral gray at low opacity - visible on both dark and light backgrounds
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
if (midi % 12 === 0 && isWhite(midi)) {
const x = whiteKeyIndex[midi] * this.whiteW;
g.rect(x, 0, 1, this.opts.height);
g.fill({ color: 0x808090, alpha: 0.2 });
}
}
return g;
}
private noteX(midi: number): number {
if (isWhite(midi)) return whiteKeyIndex[midi] * this.whiteW;
return whiteKeyIndex[midi - 1] * this.whiteW + this.whiteW - this.blackW / 2;
}
private getGradient(base: number): PIXI.FillGradient {
let g = this.gradientCache.get(base);
if (!g) { g = makeGradient(base); this.gradientCache.set(base, g); }
return g;
}
private noteLabel(note: NoteEvent): string {
const parts: string[] = [];
const labelText = this.pitchLabelFor(note.midi);
if (labelText) parts.push(labelText);
if (this.opts.showFingering && note.finger) parts.push(String(note.finger));
return parts.join(" ");
}
private pitchLabelFor(midi: number): string {
const mode = this.opts.fallingNotesLabelMode ?? "none";
if (mode === "none") return "";
const white = isWhite(midi);
if (mode === "c_only") {
return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : "";
}
if (mode === "white" && !white) return "";
if (mode === "black" && white) return "";
// "white", "black", "all": note name class only (no octave)
return midiToNoteName(midi, this.opts.useFlats);
}
// Returns false if the note should be hidden by the current filter
private passesFilter(note: NoteEvent): boolean {
switch (this.opts.noteFilter) {
case "white": return isWhite(note.midi);
case "black": return isBlackKey(note.midi);
case "c_only": return note.midi % 12 === 0;
default: return true;
}
}
/** Burst of dots shooting sideways from the note's left & right edges at impact. */
private spawnBurst(cx: number, cy: number, noteW: number, color: number) {
const count = 9 + Math.floor(Math.random() * 6);
for (let i = 0; i < count; i++) {
const side = Math.random() < 0.5 ? -1 : 1;
this.particles.push({
x: cx + side * (noteW * 0.5 + 1 + Math.random() * 3),
y: cy,
vx: side * (1.8 + Math.random() * 3.8),
vy: -(0.5 + Math.random() * 2.8),
size: 1.4 + Math.random() * 2.4,
life: 1.0,
decay: 0.024 + Math.random() * 0.018,
gravity: 0.07,
color,
});
}
}
/** Single sparkle drifting off the side of a falling bar (called each tick). */
private spawnTrail(cx: number, cy: number, noteW: number, color: number) {
if (Math.random() > 0.38) return;
const side = Math.random() < 0.5 ? -1 : 1;
this.particles.push({
x: cx + side * (noteW * 0.5),
y: cy - Math.random() * 22,
vx: side * (0.5 + Math.random() * 1.4),
vy: -(0.2 + Math.random() * 1.0),
size: 1.0 + Math.random() * 1.6,
life: 1.0,
decay: 0.04 + Math.random() * 0.03,
gravity: 0.025,
color,
});
}
private triggerFlash(cx: number, cy: number, w: number, color: number) {
const f = this.flashes.find(f => !f.active);
if (f) f.trigger(cx, cy, w, color);
}
/** Returns true if the sustain pedal is active at `currentSeconds`. */
private isSustainActive(currentSeconds: number): boolean {
const ranges = this.opts.sustainRanges;
if (!ranges) return false;
for (const r of ranges) {
if (r.startSeconds > currentSeconds) break; // ranges are sorted
if (currentSeconds <= r.endSeconds) return true;
}
return false;
}
// ── Public API ────────────────────────────────────────────────────────────
addNote(note: NoteEvent, currentSeconds: number) {
if (!this._initialized) return;
if (this.active.has(note.id)) return;
if (!this.passesFilter(note)) return;
// Skip notes whose bottom edge has already scrolled 60 px below the hit line —
// they would be recycled on the very next tick anyway, so don't waste a pool slot.
// Threshold mirrors the recycle condition in tick(): bar.container.y > hitY + 60.
// barBottomPastHit = (currentSeconds - note.startSeconds) * pxPerSec - barHeight
// recycle if barBottomPastHit > 60
{
const pxPerSec = this.opts.height / this.opts.viewportSeconds;
const minH = this.opts.minNoteHeight ?? 8;
const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec);
const timePastHit = currentSeconds - note.startSeconds; // positive = note already started
if (timePastHit * pxPerSec - barHeight > 60) return;
}
const bar = this.pool.pop();
if (!bar) return; // pool exhausted (> POOL_SIZE notes visible at once)
const { viewportSeconds, height } = this.opts;
const white = isWhite(note.midi);
const barWidth = Math.max(5, (white ? this.whiteW : this.blackW) - 2);
const x = this.noteX(note.midi) + 1;
const pxPerSec = height / viewportSeconds;
const minH = this.opts.minNoteHeight ?? 8;
const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec);
const timeUntilHit = note.startSeconds - currentSeconds;
const startY = (height - 4) - barHeight - timeUntilHit * pxPerSec;
const base = noteColor(note, this.opts.showHandColors, this.opts.colorTheme, this.opts.customColors);
bar.configure(note, x, barWidth, startY, barHeight, this.getGradient(base), base, this.noteLabel(note), this.opts.noteCornerRadius ?? 4, this.opts.showNoteOutline ?? false);
this.active.set(note.id, bar);
}
tick(currentSeconds: number) {
if (!this._initialized) return;
const { height, viewportSeconds } = this.opts;
const pxPerSec = height / viewportSeconds;
const hitY = height - 4;
const minH = this.opts.minNoteHeight ?? 8;
const cornerRadius = this.opts.noteCornerRadius ?? 4;
const impactStyle = this.opts.impactStyle ?? "bloom";
const doSustained = this.opts.showSustainedNotes ?? false;
const sustainActive = doSustained && this.isSustainActive(currentSeconds);
for (const [id, bar] of this.active) {
if (!bar.note) continue;
const barHeight = Math.max(minH, (bar.note.endSeconds - bar.note.startSeconds) * pxPerSec);
const timeUntilHit = bar.note.startSeconds - currentSeconds;
bar.container.y = hitY - barHeight - timeUntilHit * pxPerSec;
bar.updateLabelY(bar.container.y, barHeight, height);
// ── Impact effect ───────────────────────────────────────────────────────
if (!bar.impactFired && impactStyle !== "off") {
if (bar.container.y + barHeight >= hitY - 2) {
bar.impactFired = true;
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
const cx = bar.container.x + bw * 0.5;
if (impactStyle === "bloom")
this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor);
else if (impactStyle === "side")
this.spawnBurst(cx, hitY - 3, bw, bar.baseColor);
// "trail" is handled continuously below
}
}
// ── Trail particles ─────────────────────────────────────────────────────
if (impactStyle === "trail") {
const barTopY = bar.container.y;
if (barTopY + barHeight < hitY && barTopY < hitY) {
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
this.spawnTrail(bar.container.x + bw * 0.5, barTopY + barHeight * 0.35, bw, bar.baseColor);
}
}
// ── Recycle ─────────────────────────────────────────────────────────────
if (bar.container.y > hitY + 60) {
// Transition into sustained-ghost tracking before recycling bar
if (doSustained && sustainActive) {
const timeAfterEnd = currentSeconds - bar.note.endSeconds;
if (timeAfterEnd < 3.0) {
this.sustainedNotes.set(id, {
midi: bar.note.midi,
baseColor: bar.baseColor,
endSeconds: bar.note.endSeconds,
});
}
}
bar.reset();
this.pool.push(bar);
this.active.delete(id);
}
}
// ── Sustained-note ghost indicators ──────────────────────────────────────
this.sustainedNoteLayer.clear();
if (doSustained && this.sustainedNotes.size > 0) {
const sustainStillActive = this.isSustainActive(currentSeconds);
const ghostH = 12;
for (const [sid, sn] of this.sustainedNotes) {
const timeAfterEnd = currentSeconds - sn.endSeconds;
if (!sustainStillActive || timeAfterEnd >= 3.0) {
this.sustainedNotes.delete(sid);
continue;
}
const bw = Math.max(5, (isWhite(sn.midi) ? this.whiteW : this.blackW) - 2);
const x = this.noteX(sn.midi) + 1;
const r = Math.min(cornerRadius, bw * 0.25);
// Fade out over the last second of the 3s window
const alpha = Math.min(0.32, 0.32 * (1 - Math.max(0, timeAfterEnd - 2.0)));
this.sustainedNoteLayer.roundRect(x, hitY - ghostH, bw, ghostH, r);
this.sustainedNoteLayer.fill({ color: sn.baseColor, alpha });
}
}
// Advance all active flashes - pass PIXI's normalized delta so animation
// duration is frame-rate independent (1.0 = one 60fps frame).
const dt = this.app.ticker.deltaTime;
for (const f of this.flashes) {
if (f.active) f.advance(dt);
}
// ── Particles ──────────────────────────────────────────────────────────
this.particleLayer.clear();
if (this.particles.length > 0) {
this.particles = this.particles.filter(p => {
p.x += p.vx;
p.y += p.vy;
p.vy += p.gravity;
p.life -= p.decay;
return p.life > 0;
});
for (const p of this.particles) {
const r = Math.max(0.5, p.size * p.life);
this.particleLayer.circle(p.x, p.y, r);
this.particleLayer.fill({ color: p.color, alpha: p.life * 0.88 });
}
}
// Update measure number labels
if (this.opts.showMeasureNums && this.opts.measureStarts?.length) {
let li = 0;
const labels = this.measureLayer.children as PIXI.Text[];
const viewportSeconds = this.opts.viewportSeconds;
for (let i = 0; i < this.opts.measureStarts.length && li < labels.length; i++) {
const sec = this.opts.measureStarts[i];
const timeUntil = sec - currentSeconds;
if (timeUntil < -0.5 || timeUntil > viewportSeconds + 0.5) continue;
const y = hitY - timeUntil * pxPerSec;
if (y < -15 || y > height + 15) continue;
const lbl = labels[li++];
lbl.visible = true;
lbl.text = String(i + 1);
lbl.y = y - 10;
}
for (let i = li; i < labels.length; i++) (labels[i] as PIXI.Text).visible = false;
} else {
for (const c of this.measureLayer.children) (c as PIXI.Text).visible = false;
}
// ── Beat lines ──────────────────────────────────────────────────────────
if ((this.opts.showBeatLines ?? false) && this.opts.beatStarts?.length) {
this.beatLayer.clear();
// Build a Set of measure-start times (×100 for int comparison) for styling
const mSet = new Set((this.opts.measureStarts ?? []).map(s => Math.round(s * 100)));
for (const sec of this.opts.beatStarts) {
const timeUntil = sec - currentSeconds;
if (timeUntil < -0.3 || timeUntil > viewportSeconds + 0.3) continue;
const y = Math.round(hitY - timeUntil * pxPerSec);
if (y < 0 || y > height + 1) continue;
const isMeasure = mSet.has(Math.round(sec * 100));
const dark = this.opts.darkMode !== false;
this.beatLayer.rect(0, y, this.opts.width, isMeasure ? 1.5 : 1);
// Dark mode: light blue-gray at low opacity. Light mode: darker blue-gray at higher opacity.
this.beatLayer.fill({ color: dark ? 0x8896b8 : 0x4a5568, alpha: isMeasure ? (dark ? 0.2 : 0.35) : (dark ? 0.08 : 0.15) });
}
} else if (!(this.opts.showBeatLines ?? false)) {
this.beatLayer.clear();
}
// ── Sustain pedal - "Ped" / "✱" markers + left-edge connector line ────────
// "Ped" appears at the press position (bottom of each range, reaches the hit
// line first). "✱" appears at the release position (top of range, arrives
// later). A 1px line connects them on the left edge - same layout as printed
// sheet music pedal notation, without any full-width bands obscuring notes.
if ((this.opts.showSustainPedal ?? false) && this.opts.sustainRanges?.length) {
this.sustainLineLayer.clear();
const pool = this.sustainTextLayer.children as PIXI.Text[];
for (const t of pool) t.visible = false;
let ti = 0;
const LABEL_H = 16; // approximate text height + gap
let prevRelY = -999; // Y of the last "✱" placed - guards Ped overlap from above
for (const range of this.opts.sustainRanges) {
const startTimeUntil = range.startSeconds - currentSeconds;
const endTimeUntil = range.endSeconds - currentSeconds;
if (endTimeUntil < -0.5 || startTimeUntil > viewportSeconds + 0.5) continue;
// topY = release (end) - higher on canvas (later in time)
// botY = press (start) - lower on canvas (sooner, approaches hit line first)
const topY = Math.max(0, hitY - endTimeUntil * pxPerSec);
const botY = Math.min(height + 14, hitY - startTimeUntil * pxPerSec);
if (botY <= topY) continue;
// 1 px connecting line on the left edge
this.sustainLineLayer.rect(9, topY, 1, botY - topY);
this.sustainLineLayer.fill({ color: 0xa855f7, alpha: 0.55 });
const pedY = Math.min(botY - 14, height - 14);
const relY = Math.max(2, topY - 13);
// "Ped" - skip if it would sit on top of the previous "✱"
const pedClear = pedY - prevRelY > LABEL_H;
if (pedClear && ti < pool.length && botY >= -4 && botY <= height + 14) {
const t = pool[ti++];
t.text = "Ped";
t.y = pedY;
t.visible = true;
}
// "✱" - only if the band is tall enough to show both labels without crowding
const bandPx = botY - topY;
if (bandPx >= LABEL_H * 2 && ti < pool.length && topY >= -4 && topY <= height) {
const t = pool[ti++];
t.text = "✱";
t.y = relY;
t.visible = true;
prevRelY = relY;
}
}
} else if (!(this.opts.showSustainPedal ?? false)) {
this.sustainLineLayer.clear();
for (const t of this.sustainTextLayer.children as PIXI.Text[]) t.visible = false;
}
}
showGhost(note: NoteEvent | null) {
if (!note) { this.ghostBar.visible = false; return; }
const white = isWhite(note.midi);
const w = (white ? this.whiteW : this.blackW) - 2;
const x = this.noteX(note.midi) + 1;
this.ghostBar.clear();
this.ghostBar.roundRect(x, this.opts.height - 22, w, 16, 3);
this.ghostBar.fill({ color: 0x6366f1, alpha: 0.5 });
this.ghostBar.visible = true;
}
reset() {
for (const [, bar] of this.active) { bar.reset(); this.pool.push(bar); }
this.active.clear();
this.particles = [];
this.particleLayer?.clear();
this.sustainedNotes.clear();
this.sustainedNoteLayer?.clear();
this.ghostBar.visible = false;
}
/**
* Fade the stage from transparent to fully opaque over `durationMs`.
* Call this when playback starts from a stopped state so the first notes
* materialize gently rather than snapping into view.
*/
fadeIn(durationMs = 420) {
if (!this._initialized) return;
this.app.stage.alpha = 0;
// Frames at 60 fps; clamp to at least 1 to avoid division by zero.
const frames = Math.max(1, Math.round((durationMs / 1000) * 60));
const step = 1 / frames;
const onTick = () => {
const next = Math.min(1, this.app.stage.alpha + step);
this.app.stage.alpha = next;
if (next >= 1) this.app.ticker.remove(onTick);
};
this.app.ticker.remove(onTick); // guard against double-call
this.app.ticker.add(onTick);
}
setOptions(opts: Partial<FallingNotesOptions>) {
// Check whether any colour-affecting option actually CHANGED before merging,
// because "x in opts" is always true when callers always pass the full options
// object - triggering spurious resets that wipe all visible note bars.
const needsReset =
("colorTheme" in opts && opts.colorTheme !== this.opts.colorTheme) ||
("customColors" in opts && opts.customColors !== this.opts.customColors) ||
("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) ||
("showHandColors" in opts && opts.showHandColors !== this.opts.showHandColors) ||
("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);
Object.assign(this.opts, opts);
if (!this._initialized) return;
if (needsReset) {
for (const g of this.gradientCache.values()) g.destroy();
this.gradientCache.clear();
this.reset();
}
if ("showGrid" in opts) this.gridLayer.visible = !!opts.showGrid;
if ("impactStyle" in opts) {
this.particles = [];
this.particleLayer?.clear();
}
if ("darkMode" in opts) this.drawHitLine();
if ("showBeatLines" in opts && !opts.showBeatLines) this.beatLayer.clear();
if ("showSustainPedal" in opts && !opts.showSustainPedal) {
this.sustainLineLayer.clear();
for (const t of this.sustainTextLayer.children as PIXI.Text[]) t.visible = false;
}
// Turning off sustained notes: discard ghost state and clear the layer
if ("showSustainedNotes" in opts && !opts.showSustainedNotes) {
this.sustainedNotes.clear();
this.sustainedNoteLayer?.clear();
}
}
resize(width: number, height: number) {
if (!this._initialized) return;
this.opts.width = width;
this.opts.height = height;
this.app.renderer.resize(width, height);
this.computeLayout();
this.reset();
this.drawHitLine();
}
destroy() {
if (!this._initialized) return;
for (const g of this.gradientCache.values()) g.destroy();
this.gradientCache.clear();
this.app.destroy(false, { children: true });
}
}
@@ -0,0 +1,195 @@
import { useEffect, useRef, useMemo } from "react";
import bus from "../../engine/EventBus";
import { FallingNotesRenderer } from "./FallingNotesRenderer";
import { useAppStore } from "../../store/useAppStore";
import { computeMeasureSeconds, computeBeatSeconds } from "@bach-to-basics/shared";
import type { NoteEvent } from "@bach-to-basics/shared";
import { useAccuracy } from "../../hooks/useAccuracy";
export function FallingNotesView() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<FallingNotesRenderer | null>(null);
const lastSecondsRef = useRef(0);
const { settings, document: doc, midiEnabled, status, seek } = useAppStore();
// Accuracy scoring - active when MIDI device connected
const accuracy = useAccuracy(midiEnabled && (status === "playing" || status === "paused"));
const measureStarts = useMemo(
() => 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]
);
useEffect(() => {
if (!canvasRef.current || !containerRef.current) return;
const container = containerRef.current;
const width = container.clientWidth || window.innerWidth;
const height = container.clientHeight || 400;
const renderer = new FallingNotesRenderer({
width,
height,
viewportSeconds: settings.viewportSeconds,
fallingNotesLabelMode: settings.fallingNotesLabelMode,
showFingering: settings.showFingering,
showHandColors: settings.showHandColors,
useFlats: settings.useFlats,
noteFilter: settings.noteFilter,
colorTheme: settings.colorTheme,
customColors: settings.customColors,
showGrid: settings.showGrid,
showMeasureNums: settings.showMeasureNums,
measureStarts,
impactStyle: settings.impactStyle,
minNoteHeight: settings.minNoteHeight,
noteCornerRadius: settings.noteCornerRadius,
showBeatLines: settings.showBeatLines,
beatStarts,
showSustainPedal: settings.showSustainPedal,
sustainRanges: doc?.sustainRanges,
showNoteOutline: settings.showNoteOutline,
showSustainedNotes: settings.showSustainedNotes,
darkMode: settings.theme === "dark",
});
rendererRef.current = renderer;
renderer.init(canvasRef.current);
const onUpcoming = (note: NoteEvent) => renderer.addNote(note, lastSecondsRef.current);
const onTick = ({ seconds }: { seconds: number; tick: number }) => {
lastSecondsRef.current = seconds;
renderer.tick(seconds);
};
let prevStatus = "stopped";
const onStateChange = ({ status }: { status: string }) => {
if (status === "stopped") renderer.reset();
if (status === "playing" && prevStatus === "stopped") renderer.fadeIn();
prevStatus = status;
};
const onSeek = () => renderer.reset();
bus.on("note:upcoming", onUpcoming as never);
bus.on("transport:tick", onTick as never);
bus.on("transport:stateChange", onStateChange as never);
bus.on("transport:seek", onSeek as never);
const ro = new ResizeObserver(() => {
const w = container.clientWidth;
const h = container.clientHeight;
if (w > 0 && h > 0 && rendererRef.current) rendererRef.current.resize(w, h);
});
ro.observe(container);
return () => {
bus.off("note:upcoming", onUpcoming as never);
bus.off("transport:tick", onTick as never);
bus.off("transport:stateChange", onStateChange as never);
bus.off("transport:seek", onSeek as never);
ro.disconnect();
renderer.destroy();
rendererRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Push setting changes into the renderer without remounting
useEffect(() => {
rendererRef.current?.setOptions({
viewportSeconds: settings.viewportSeconds,
fallingNotesLabelMode: settings.fallingNotesLabelMode,
showFingering: settings.showFingering,
showHandColors: settings.showHandColors,
useFlats: settings.useFlats,
noteFilter: settings.noteFilter,
colorTheme: settings.colorTheme,
customColors: settings.customColors,
showGrid: settings.showGrid,
showMeasureNums: settings.showMeasureNums,
measureStarts,
impactStyle: settings.impactStyle,
minNoteHeight: settings.minNoteHeight,
noteCornerRadius: settings.noteCornerRadius,
showBeatLines: settings.showBeatLines,
beatStarts,
showSustainPedal: settings.showSustainPedal,
sustainRanges: doc?.sustainRanges,
showNoteOutline: settings.showNoteOutline,
showSustainedNotes: settings.showSustainedNotes,
darkMode: settings.theme === "dark",
});
}, [
settings.viewportSeconds,
settings.fallingNotesLabelMode,
settings.showFingering,
settings.showHandColors,
settings.useFlats,
settings.noteFilter,
settings.colorTheme,
settings.customColors,
settings.showGrid,
settings.showMeasureNums,
settings.impactStyle,
measureStarts,
settings.minNoteHeight,
settings.noteCornerRadius,
settings.showBeatLines,
beatStarts,
settings.showSustainPedal,
doc?.sustainRanges,
settings.showNoteOutline,
settings.showSustainedNotes,
settings.theme,
]);
const handleWheel = (e: React.WheelEvent) => {
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));
seek(next);
};
return (
<div
ref={containerRef}
className="w-full flex-1 relative"
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)",
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 && (
<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)",
color: accuracy.score >= 80 ? "#4ade80" : accuracy.score >= 50 ? "#eab308" : "#ef4444",
border: "1px solid currentColor",
}}
>
<TargetIcon /> {accuracy.score}%
</div>
)}
</div>
);
}
// ── 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">
<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>
);
@@ -0,0 +1,523 @@
import * as PIXI from "pixi.js";
import { isBlackKey, midiToPitch, midiToNoteName } from "@bach-to-basics/shared";
import type { NoteEvent } from "@bach-to-basics/shared";
import type { CustomColors, NoteLabelMode } from "../../store/useAppStore";
import { lighten, hexToNum } from "../../utils/colorUtils";
// ── Layout constants ──────────────────────────────────────────────────────────
const MIDI_MIN = 21; // A0
const MIDI_MAX = 108; // C8
function isWhite(midi: number) { return !isBlackKey(midi); }
const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i)
.filter(isWhite).length;
// ── Color tokens ──────────────────────────────────────────────────────────────
const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge
const C_WHITE_MID = 0xf5f5f0; // main key face
const C_WHITE_BTM = 0xe4e4dc; // front edge shadow
const C_BLACK_TOP = 0x2c2c38; // top of black key
const C_BLACK_MID = 0x181820; // main body
const C_BLACK_BTM = 0x363644; // slight gloss at bottom
const C_BORDER = 0x9a9a90;
const C_LABEL = 0x6b7280;
const C_FINGER = 0xffffff;
// Active state colors (hand-aware)
const C_PLAYBACK = 0x6366f1;
const C_INPUT = 0x22c55e;
const C_LEFT = 0x3b82f6;
const C_RIGHT = 0xef4444;
// ── Gradient factory ──────────────────────────────────────────────────────────
function lg(stops: Array<[number, number | string]>): PIXI.FillGradient {
return new PIXI.FillGradient({
type: "linear",
start: { x: 0, y: 0 },
end: { x: 0, y: 1 },
textureSpace: "local",
colorStops: stops.map(([offset, color]) => ({ offset, color })),
});
}
// ── Key color factories (themed) ──────────────────────────────────────────────
function whiteKeyColors(ivory: boolean): { top: number; mid: number; btm: number } {
return ivory
? { top: 0xcdc8a8, mid: 0xfff8e7, btm: 0xe8e0c8 }
: { top: C_WHITE_TOP, mid: C_WHITE_MID, btm: C_WHITE_BTM };
}
// Pre-built gradient factories for the ~10 possible states
const GRAD = {
whiteNormal: (ivory: boolean) => {
const c = whiteKeyColors(ivory);
return lg([[0, c.top], [0.07, c.mid], [0.88, c.mid], [1, c.btm]]);
},
blackNormal: () => lg([[0, C_BLACK_TOP], [0.35, C_BLACK_MID], [0.88, C_BLACK_MID], [1, C_BLACK_BTM]]),
activeWhite: (base: number) => lg([
[0, 0x050508], // deep hinge shadow
[0.12, lighten(base, 0.58)],
[0.22, base],
[0.83, base],
[1, lighten(base, 1.45)], // bright front lip
]),
activeBlack: (base: number) => lg([
[0, lighten(base, 0.6)],
[0.5, base],
[1, lighten(base, 1.5)],
]),
};
export interface PianoKeyboardOptions {
width: number;
height: number;
noteLabelMode: NoteLabelMode;
pianoTheme: "white" | "ivory";
showFingering: boolean;
showHandColors: boolean;
useFlats: boolean;
customColors?: CustomColors;
onKeyPress?: (midi: number) => void;
onKeyRelease?: (midi: number) => void;
}
interface KeyState {
playbackActive: boolean;
inputActive: boolean;
hand: "left" | "right" | null;
finger: number | null;
hovered: boolean;
}
// ── Main class ────────────────────────────────────────────────────────────────
export class PianoKeyboard {
app: PIXI.Application;
private _initialized = false;
private keyContainers = new Map<number, PIXI.Container>();
private keys = new Map<number, PIXI.Graphics>();
private keyLabels = new Map<number, PIXI.Text>();
private fingerLabels = new Map<number, PIXI.Text>();
private keyStates = new Map<number, KeyState>();
private opts: PianoKeyboardOptions;
private whiteW = 0;
private whiteH = 0;
private blackW = 0;
private blackH = 0;
private keyX = new Map<number, number>();
// Gradient cache - keyed by "white|black-normal|#hexcolor"
private gradCache = new Map<string, PIXI.FillGradient>();
// Drag-to-play tracking
private isPointerDown = false;
private dragKeys = new Set<number>();
private readonly onWindowPointerUp: (e: PointerEvent) => void;
constructor(_canvas: HTMLCanvasElement, opts: PianoKeyboardOptions) {
this.opts = opts;
this.app = new PIXI.Application();
this.onWindowPointerUp = (e: PointerEvent) => {
if (e.button !== 0) return;
this.isPointerDown = false;
for (const m of this.dragKeys) this.opts.onKeyRelease?.(m);
this.dragKeys.clear();
};
}
async init(canvas: HTMLCanvasElement) {
await this.app.init({
canvas,
width: this.opts.width,
height: this.opts.height,
backgroundAlpha: 0,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
...(import.meta.env.DEV ? { preserveDrawingBuffer: true } : {}),
});
window.addEventListener("pointerup", this.onWindowPointerUp);
this.computeLayout();
this.drawKeys();
this._initialized = true;
}
// ── Layout ────────────────────────────────────────────────────────────────
private computeLayout() {
const { width, height } = this.opts;
this.whiteW = width / WHITE_COUNT;
this.whiteH = height;
this.blackW = this.whiteW * 0.58;
this.blackH = height * 0.60;
let wi = 0;
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
if (isWhite(midi)) {
this.keyX.set(midi, wi * this.whiteW);
wi++;
}
}
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
if (!isWhite(midi)) {
const lx = this.keyX.get(midi - 1) ?? 0;
this.keyX.set(midi, lx + this.whiteW - this.blackW / 2);
}
}
}
private drawKeys() {
const root = new PIXI.Container();
const whiteLayer = new PIXI.Container();
const blackLayer = new PIXI.Container();
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
const white = isWhite(midi);
const x = this.keyX.get(midi) ?? 0;
const w = white ? this.whiteW : this.blackW;
const h = white ? this.whiteH : this.blackH;
const container = new PIXI.Container();
container.x = x;
container.interactive = true;
container.cursor = "pointer";
// Draw shape
const g = new PIXI.Graphics();
this.paintKey(g, w, h, white, false, null, null, false);
container.addChild(g);
// Note label (created for every key; text/visibility set by noteLabelMode)
{
const labelStr = this.noteLabel(midi);
const lFontSz = white
? Math.max(7, this.whiteW * 0.42)
: Math.max(6, this.blackW * 0.55);
const label = new PIXI.Text({
text: labelStr,
style: {
fontSize: lFontSz,
fill: white ? C_LABEL : 0xaaaaaa,
fontFamily: "Space Grotesk, system-ui, sans-serif",
fontWeight: "600",
},
});
label.anchor.set(0.5, 1);
label.x = w / 2;
label.y = white ? h - 5 : h - 3;
label.visible = labelStr.length > 0;
container.addChild(label);
this.keyLabels.set(midi, label);
}
// Finger label
const fingerText = new PIXI.Text({
text: "",
style: {
fontSize: Math.max(9, this.whiteW * 0.48),
fill: C_FINGER,
fontFamily: "Space Grotesk, system-ui, sans-serif",
fontWeight: "700",
},
});
fingerText.anchor.set(0.5, 0.5);
fingerText.x = w / 2;
fingerText.y = h * 0.22;
fingerText.visible = false;
container.addChild(fingerText);
this.fingerLabels.set(midi, fingerText);
// Click + drag-to-play handlers
container.on("pointerdown", () => {
this.isPointerDown = true;
if (!this.dragKeys.has(midi)) {
this.opts.onKeyPress?.(midi);
this.dragKeys.add(midi);
}
});
container.on("pointerover", () => {
if (this.isPointerDown && !this.dragKeys.has(midi)) {
this.opts.onKeyPress?.(midi);
this.dragKeys.add(midi);
}
// Hover highlight
const st = this.keyStates.get(midi);
if (st && !st.hovered) { st.hovered = true; this.redrawKey(midi); }
});
container.on("pointerout", () => {
if (this.dragKeys.has(midi)) {
this.opts.onKeyRelease?.(midi);
this.dragKeys.delete(midi);
}
// Remove hover highlight
const st = this.keyStates.get(midi);
if (st && st.hovered) { st.hovered = false; this.redrawKey(midi); }
});
this.keys.set(midi, g);
this.keyContainers.set(midi, container);
this.keyStates.set(midi, { playbackActive: false, inputActive: false, hand: null, finger: null, hovered: false });
if (white) whiteLayer.addChild(container);
else blackLayer.addChild(container);
}
root.addChild(whiteLayer, blackLayer);
this.app.stage.addChild(root);
}
// ── Painting ──────────────────────────────────────────────────────────────
private getGrad(key: string, factory: () => PIXI.FillGradient): PIXI.FillGradient {
let g = this.gradCache.get(key);
if (!g) { g = factory(); this.gradCache.set(key, g); }
return g;
}
private paintKey(
g: PIXI.Graphics,
w: number, h: number,
white: boolean,
active: boolean,
activeColor: number | null,
_hand: "left" | "right" | null,
hovered = false,
) {
g.clear();
const r = white ? 4 : 3;
const ivory = this.opts.pianoTheme === "ivory";
let fill: PIXI.FillGradient | number;
if (!active) {
fill = white
? this.getGrad(ivory ? "wn-iv" : "wn", () => GRAD.whiteNormal(ivory))
: this.getGrad("bn", GRAD.blackNormal);
} else {
const base = activeColor ?? C_PLAYBACK;
const hexKey = (white ? "aw" : "ab") + base.toString(16);
fill = white
? this.getGrad(hexKey, () => GRAD.activeWhite(base))
: this.getGrad(hexKey, () => GRAD.activeBlack(base));
}
// Main shape
g.roundRect(0.5, 0.5, w - 1, h - 1, r);
g.fill(fill);
// ── Active white key: depth / pressed look ─────────────────────────────
if (active && white) {
// Keybed shadow strip at bottom (felt bumper contact)
g.rect(1, h - 4, w - 2, 4);
g.fill({ color: 0x000000, alpha: 0.40 });
// Side edge depth shadows
g.rect(0.5, h * 0.08, 2, h * 0.88);
g.fill({ color: 0x000000, alpha: 0.10 });
g.rect(w - 2.5, h * 0.08, 2, h * 0.88);
g.fill({ color: 0x000000, alpha: 0.10 });
}
// White highlight strip at top of active black keys
if (active && !white && h > 10) {
g.roundRect(1, 1, w - 2, Math.min(h * 0.12, 6), r * 0.4);
g.fill({ color: 0xffffff, alpha: 0.28 });
// Subtle colored glow at bottom of active black key
g.rect(1, h - 5, w - 2, 4);
g.fill({ color: activeColor ?? C_PLAYBACK, alpha: 0.25 });
}
// Border
if (white) {
g.roundRect(0.5, 0.5, w - 1, h - 1, r);
g.stroke({ width: 1, color: active ? 0x00000033 : C_BORDER });
}
// Black key inner shadow on left/right edges (inactive)
if (!white && !active) {
g.rect(0, 4, 1, h - 8);
g.fill({ color: 0x000000, alpha: 0.35 });
g.rect(w - 1, 4, 1, h - 8);
g.fill({ color: 0x000000, alpha: 0.35 });
}
// Hover overlay - subtle white sheen on non-active keys
if (hovered && !active) {
if (white) {
g.roundRect(0.5, 0.5, w - 1, h - 1, r);
g.fill({ color: 0xffffff, alpha: 0.22 });
} else {
g.roundRect(1, 1, w - 2, h - 2, r);
g.fill({ color: 0xffffff, alpha: 0.14 });
}
}
}
private redrawKey(midi: number) {
const g = this.keys.get(midi);
const state = this.keyStates.get(midi);
const cont = this.keyContainers.get(midi);
if (!g || !state || !cont) return;
const white = isWhite(midi);
const w = white ? this.whiteW : this.blackW;
const h = white ? this.whiteH : this.blackH;
const active = state.playbackActive || state.inputActive;
// 3D press: shift container down when active
const pressOffset = active ? (white ? 5 : 3) : 0;
cont.y = pressOffset;
let color: number | null = null;
if (active) {
const cc = this.opts.customColors;
if (this.opts.showHandColors && state.hand) {
color = state.hand === "left"
? (cc ? hexToNum(cc.leftHand) : C_LEFT)
: (cc ? hexToNum(cc.rightHand) : C_RIGHT);
} else if (state.inputActive) {
color = C_INPUT;
} else {
color = cc ? hexToNum(cc.unknown) : C_PLAYBACK;
}
}
this.paintKey(g, w, h, white, active, color, state.hand, !active && state.hovered);
// Finger label
const fl = this.fingerLabels.get(midi);
if (fl) {
fl.visible = this.opts.showFingering && active && state.finger !== null;
if (state.finger !== null) fl.text = String(state.finger);
}
}
private noteLabel(midi: number): string {
const mode = this.opts.noteLabelMode ?? "none";
if (mode === "none") return "";
const white = isWhite(midi);
if (mode === "c_only") {
// Show full pitch (e.g. "C4") only for C notes
return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : "";
}
if (mode === "white" && !white) return "";
if (mode === "black" && white) return "";
// "white", "black", "all": show note name class only (no octave)
return midiToNoteName(midi, this.opts.useFlats);
}
// ── Public API ────────────────────────────────────────────────────────────
noteOn(note: NoteEvent, source: "playback" | "input") {
const state = this.keyStates.get(note.midi);
if (!state) return;
if (source === "playback") {
state.playbackActive = true;
state.hand = note.hand === "unknown" ? null : note.hand as "left" | "right";
state.finger = note.finger;
} else {
state.inputActive = true;
}
this.redrawKey(note.midi);
}
noteOff(note: NoteEvent, source: "playback" | "input") {
const state = this.keyStates.get(note.midi);
if (!state) return;
if (source === "playback") {
state.playbackActive = false;
if (!state.inputActive) { state.hand = null; state.finger = null; }
} else {
state.inputActive = false;
}
this.redrawKey(note.midi);
}
inputNoteOn(midi: number) {
const state = this.keyStates.get(midi);
if (!state) return;
state.inputActive = true;
this.redrawKey(midi);
}
inputNoteOff(midi: number) {
const state = this.keyStates.get(midi);
if (!state) return;
state.inputActive = false;
this.redrawKey(midi);
}
reset() {
for (const [midi] of this.keys) {
const state = this.keyStates.get(midi);
if (state) {
state.playbackActive = false;
state.inputActive = false;
state.hand = null;
state.finger = null;
state.hovered = false;
}
this.redrawKey(midi);
}
}
setOptions(opts: Partial<PianoKeyboardOptions>) {
const needsFullRebuild = (
("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) ||
("noteLabelMode" in opts && opts.noteLabelMode !== this.opts.noteLabelMode) ||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats)
);
Object.assign(this.opts, opts);
if (needsFullRebuild) {
// Full key rebuild - clears gradient cache, recomputes layout, redraws all keys
this.resize(this.opts.width, this.opts.height);
return;
}
// Light update: refresh note labels in place
for (const [midi, label] of this.keyLabels) {
const text = this.noteLabel(midi);
label.text = text;
label.visible = text.length > 0;
}
// Redraw any currently-active keys so color changes take effect immediately
if ("customColors" in opts || "showHandColors" in opts) {
for (const [midi, state] of this.keyStates) {
if (state.playbackActive || state.inputActive) this.redrawKey(midi);
}
}
}
resize(width: number, height: number) {
if (!this._initialized) return;
this.opts.width = width;
this.opts.height = height;
this.app.renderer.resize(width, height);
// Destroy cached gradients so they get rebuilt at new dimensions
for (const g of this.gradCache.values()) g.destroy();
this.gradCache.clear();
this.keyX.clear();
this.keys.clear();
this.keyContainers.clear();
this.keyLabels.clear();
this.fingerLabels.clear();
this.keyStates.clear();
this.app.stage.removeChildren();
this.computeLayout();
this.drawKeys();
}
destroy() {
window.removeEventListener("pointerup", this.onWindowPointerUp);
if (this._initialized) {
for (const g of this.gradCache.values()) g.destroy();
this.gradCache.clear();
this.app.destroy(false, { children: true });
}
}
}
@@ -0,0 +1,118 @@
import { useEffect, useRef } from "react";
import bus from "../../engine/EventBus";
import { PianoKeyboard } from "./PianoKeyboard";
import { useAppStore, COLOR_PRESET_VALUES } from "../../store/useAppStore";
import { syncEngine } from "../../engine/SyncEngine";
import type { NoteEvent } from "@bach-to-basics/shared";
const KEYBOARD_HEIGHT = 160;
export function PianoKeyboardView() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const kbRef = useRef<PianoKeyboard | null>(null);
const { settings } = useAppStore();
useEffect(() => {
if (!canvasRef.current || !containerRef.current) return;
const container = containerRef.current;
const width = container.clientWidth || window.innerWidth;
const themeColors = settings.colorTheme === "custom"
? settings.customColors
: COLOR_PRESET_VALUES[settings.colorTheme];
const kb = new PianoKeyboard(canvasRef.current, {
width,
height: KEYBOARD_HEIGHT,
noteLabelMode: settings.noteLabelMode,
pianoTheme: settings.pianoTheme,
showFingering: settings.showFingering,
showHandColors: settings.showHandColors,
useFlats: settings.useFlats,
customColors: themeColors,
onKeyPress: (midi) => {
// playMidi loads audio if needed, plays sound, and emits input:note:on
syncEngine.playMidi(midi, 90);
},
onKeyRelease: (midi) => {
syncEngine.stopMidi(midi);
},
});
kbRef.current = kb;
kb.init(canvasRef.current);
const onNoteOn = (note: NoteEvent) => kb.noteOn(note, "playback");
const onNoteOff = (note: NoteEvent) => kb.noteOff(note, "playback");
const onInputOn = ({ midi }: { midi: number; velocity: number }) => kb.inputNoteOn(midi);
const onInputOff = ({ midi }: { midi: number }) => kb.inputNoteOff(midi);
const onStateChange = ({ status }: { status: string; seconds: number }) => {
if (status === "stopped") kb.reset();
};
const onSeek = () => kb.reset();
bus.on("note:on", onNoteOn as never);
bus.on("note:off", onNoteOff as never);
bus.on("input:note:on", onInputOn as never);
bus.on("input:note:off", onInputOff as never);
bus.on("transport:stateChange", onStateChange as never);
bus.on("transport:seek", onSeek as never);
const ro = new ResizeObserver(() => {
const w = container.clientWidth;
if (w > 0 && kbRef.current) kbRef.current.resize(w, KEYBOARD_HEIGHT);
});
ro.observe(container);
return () => {
bus.off("note:on", onNoteOn as never);
bus.off("note:off", onNoteOff as never);
bus.off("input:note:on", onInputOn as never);
bus.off("input:note:off", onInputOff as never);
bus.off("transport:stateChange", onStateChange as never);
bus.off("transport:seek", onSeek as never);
ro.disconnect();
kb.destroy();
kbRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
kbRef.current?.setOptions({
noteLabelMode: settings.noteLabelMode,
pianoTheme: settings.pianoTheme,
showFingering: settings.showFingering,
showHandColors: settings.showHandColors,
useFlats: settings.useFlats,
customColors: settings.colorTheme === "custom"
? settings.customColors
: COLOR_PRESET_VALUES[settings.colorTheme],
});
}, [
settings.noteLabelMode,
settings.pianoTheme,
settings.showFingering,
settings.showHandColors,
settings.useFlats,
settings.colorTheme,
settings.customColors,
]);
return (
<div
ref={containerRef}
className="w-full"
style={{
height: KEYBOARD_HEIGHT,
// Dark keybed: shows through key gaps and as the pressed-key hinge -
// matches the look of a real piano's wooden frame regardless of app theme.
background: "#0d0d10",
flexShrink: 0,
}}
>
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
}
@@ -0,0 +1,284 @@
import { useEffect, useRef } from "react";
import bus from "../../engine/EventBus";
import { useAppStore } from "../../store/useAppStore";
import { syncEngine } from "../../engine/SyncEngine";
import { keySignatureToLabel } from "@bach-to-basics/shared";
import type { TempoEvent } from "@bach-to-basics/shared";
// T3-1: Defense-in-depth: strip potential XSS vectors from MusicXML before
// passing to AlphaTab. AlphaTab likely renders SVG programmatically (safe),
// but this prevents exploitation if a future version changes that behavior.
// NOTE: CDATA sections are valid XML used by MusicXML for text content (tempo
// markings, lyrics, etc.) - they must NOT be stripped or AlphaTab receives
// malformed XML. AlphaTab processes MusicXML as data, not HTML, so CDATA
// poses no XSS risk here.
function sanitizeMusicXml(xml: string): string {
return xml
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/\bon\w+\s*=/gi, "data-removed=");
}
export function SheetMusicView() {
const scrollRef = useRef<HTMLDivElement>(null); // outer overflow-auto container
const containerRef = useRef<HTMLDivElement>(null); // inner AlphaTab host
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiRef = useRef<any>(null);
const playerReady = useRef(false); // true once soundFont is loaded
const scoreLoaded = useRef(false); // true once score SVG is rendered
const lastScrollTop = useRef(-1);
const lastScrollMs = useRef(0);
// ── AlphaTab init (runs once on mount) ──────────────────────────────────
useEffect(() => {
if (!containerRef.current) return;
let cleanupBus: (() => void) | null = null;
import("@coderline/alphatab").then(({ AlphaTabApi }) => {
if (!containerRef.current) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = new AlphaTabApi(containerRef.current, {
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
display: {
layoutMode: 0, // page mode - vertical scroll
scale: 0.9,
// stretchForce 0 = notes keep their natural rhythmic proportions (like
// a printed score). A very small value (0.1) avoids a ragged right
// edge on the last system without visibly compressing the spacing.
stretchForce: 0.1,
staveProfile: 0,
},
player: {
// enablePlayer: true is required for cursor/highlight rendering.
// We load the real soundfont so the player initialises fully, then
// silence it via masterVolume=0 inside playerReady - our AudioEngine
// handles all actual audio output.
enablePlayer: true,
enableCursor: true,
enableUserInteraction: true,
soundFont: "/soundfont/sonivox.sf3",
scrollMode: 0, // we drive scrolling ourselves
},
} as never);
apiRef.current = api;
// ── Mute AlphaTab AFTER soundFont finishes loading ───────────────────
try {
api.playerReady.on(() => {
playerReady.current = true;
api.masterVolume = 0;
});
} catch { /* ignore */ }
// ── Mark score as rendered so cursor/scroll can activate ─────────────
try {
api.scoreLoaded.on(() => { scoreLoaded.current = true; });
} catch { /* ignore */ }
// ── Load score if MusicXML is already available ──────────────────────
const initialDoc = useAppStore.getState().document;
if (initialDoc?.musicXml) {
api.load(new TextEncoder().encode(sanitizeMusicXml(initialDoc.musicXml)));
} else if (initialDoc?.mxlBuffer) {
// .mxl is ZIP-compressed MusicXML; AlphaTab handles it natively
api.load(new Uint8Array(initialDoc.mxlBuffer));
}
// ── Click note to play sound ─────────────────────────────────────────
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
api.beatMouseDown.on((beat: any) => {
if (!beat?.notes?.length) return;
for (const note of beat.notes) syncEngine.playMidi(note.realValue, 80);
setTimeout(() => {
for (const note of beat.notes) syncEngine.stopMidi(note.realValue);
}, 500);
});
} catch { /* ignore */ }
// ── Transport tick: cursor position + scroll ─────────────────────────
// IMPORTANT: do NOT close over `doc` here - it would always be null
// (stale closure from first render). Always read fresh state instead.
const onTick = ({ seconds }: { seconds: number; tick: number }) => {
if (!apiRef.current || !playerReady.current || !scoreLoaded.current) return;
// Always-fresh doc - avoids stale closure bug
const doc = useAppStore.getState().document;
if (!doc) return;
const tick = secondsToTick(seconds, doc.tempoMap ?? [], doc.ppq ?? 480);
try {
apiRef.current.tickPosition = tick;
} catch {
return;
}
// Scroll cursor into view - throttled to ~4 fps to avoid jitter
const now = performance.now();
if (now - lastScrollMs.current < 250) return;
lastScrollMs.current = now;
requestAnimationFrame(() => {
const scrollEl = scrollRef.current; // outer scroll container
const innerEl = containerRef.current; // AlphaTab host (cursor lives here)
if (!scrollEl || !innerEl) return;
// AlphaTab renders absolutely-positioned cursor overlays inside innerEl
const cursor =
innerEl.querySelector<HTMLElement>(".at-cursor-beat") ??
innerEl.querySelector<HTMLElement>(".at-cursor-bar");
if (!cursor) return;
// cursor position relative to the scrollable content area
const cursorTop =
cursor.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop;
const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3);
if (Math.abs(target - lastScrollTop.current) > 80) {
lastScrollTop.current = target;
scrollEl.scrollTo({ top: target, behavior: "smooth" });
}
});
};
bus.on("transport:tick", onTick as never);
cleanupBus = () => bus.off("transport:tick", onTick as never);
});
return () => {
cleanupBus?.();
playerReady.current = false;
scoreLoaded.current = false;
if (apiRef.current) {
try { apiRef.current.destroy(); } catch {}
apiRef.current = null;
}
};
}, []); // intentionally empty - AlphaTab owns its own lifecycle
// ── Load when MusicXML/mxlBuffer arrives after initial mount ────────────
const { document: doc, settings } = useAppStore();
const isDark = settings.theme === "dark";
const keepWhite = isDark && settings.sheetMusicWhiteBackground;
const invertSheet = isDark && !settings.sheetMusicWhiteBackground;
// ── Adapt stretchForce to layout mode ────────────────────────────────────
// "all" mode gives the sheet a narrow shared column, so we keep more
// stretching (0.5) to fill each line. In dedicated sheet mode we use
// near-natural note spacing (0.1) for a printed-score look.
// The value is stored in a ref so the load effect below can read it fresh.
const stretchForceRef = useRef(0.1);
stretchForceRef.current = settings.layoutMode === "all" ? 0.5 : 0.1;
useEffect(() => {
// Re-render the current score when the user switches between layout modes.
// api.load() (below) already reads stretchForceRef before each load, so we
// only need an explicit render() here for live layout-mode switches.
if (!apiRef.current || !scoreLoaded.current) return;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(apiRef.current.settings as any).display.stretchForce = stretchForceRef.current;
apiRef.current.render();
} catch { /* ignore */ }
}, [settings.layoutMode]);
// ── Load score when MusicXML/mxlBuffer arrives ───────────────────────────
useEffect(() => {
if (!apiRef.current) return;
// Apply the correct stretchForce before every load so AlphaTab uses it
// in the render it triggers internally.
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(apiRef.current.settings as any).display.stretchForce = stretchForceRef.current;
} catch { /* ignore */ }
if (doc?.musicXml) {
scoreLoaded.current = false;
apiRef.current.load(new TextEncoder().encode(sanitizeMusicXml(doc.musicXml)));
} else if (doc?.mxlBuffer) {
// .mxl is ZIP-compressed MusicXML; AlphaTab handles it natively
scoreLoaded.current = false;
apiRef.current.load(new Uint8Array(doc.mxlBuffer));
}
}, [doc?.musicXml, doc?.mxlBuffer]);
// Background logic:
// light / dark normal: radial gradient + var(--color-notes-bg), matching FallingNotesView
// dark + keep white: explicit #fff so black notation stays readable on white
// The gradient (7% purple at bottom-center to transparent) is the same subtle glow used
// by FallingNotesView and PianoModeBackground, keeping all canvas areas visually consistent.
const scrollBg = keepWhite
? "#ffffff"
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)";
return (
<div ref={scrollRef} className="flex-1 overflow-auto" style={{ background: scrollBg, minHeight: 0, borderTop: "1px solid var(--color-notes-border)" }}>
{/*
Page-width wrapper — centres the score and caps it at ~A4 screen width
(900 px). On wide monitors this creates margins on both sides so the
score never looks like a stretched banner; on narrow screens it fills
the full width just like before. position:relative is needed so the
key-signature badge stays anchored inside the content column.
*/}
<div style={{ maxWidth: 900, margin: "0 auto", padding: "8px 16px", position: "relative" }}>
<div
ref={containerRef}
className={invertSheet ? "sheet-dark-invert" : ""}
/>
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
<div
className="absolute top-2 right-4 z-10 pointer-events-none text-xs px-2 py-0.5 rounded"
style={{
background: keepWhite ? "#f5f7fb" : "var(--color-surface-2)",
border: `1px solid ${keepWhite ? "#dde1ea" : "var(--color-border)"}`,
color: keepWhite ? "#64748b" : "var(--color-text-muted)",
letterSpacing: "0.03em",
}}
>
{keySignatureToLabel(doc.keySignature)}
</div>
)}
</div>
</div>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// AlphaTab uses 960 ticks per quarter note internally, regardless of the MIDI file's PPQ.
// This function converts playback seconds to AlphaTab internal ticks (960 PPQ output).
// The tempo map uses MIDI-file ticks for segment boundaries, so we still need `ppq`
// to convert those boundaries to seconds, but the output is always in 960-PPQ space.
const AT_PPQ = 960;
function secondsToTick(targetSec: number, tempoMap: TempoEvent[], ppq: number): number {
if (!tempoMap.length) return Math.round(targetSec * 2 * AT_PPQ);
let atTick = 0; // accumulated output in AlphaTab 960-PPQ ticks
let remainingSec = targetSec;
for (let i = 0; i < tempoMap.length; i++) {
const ev = tempoMap[i];
const next = tempoMap[i + 1];
// seconds per MIDI tick at this tempo
const secPerMidiTick = 60 / (ev.bpm * ppq);
// AlphaTab ticks per MIDI tick (scale factor between file PPQ and AT PPQ)
const atPerMidi = AT_PPQ / ppq;
if (next) {
const midiSpan = next.tick - ev.tick; // segment length in MIDI ticks
const secSpan = midiSpan * secPerMidiTick;
if (remainingSec <= secSpan) {
atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi);
return atTick;
}
remainingSec -= secSpan;
atTick += Math.round(midiSpan * atPerMidi);
} else {
// Last segment - extends to end of piece
atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi);
return atTick;
}
}
return atTick;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,532 @@
import { useEffect, useRef, useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { syncEngine } from "../../engine/SyncEngine";
function formatTime(s: number): string {
const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, "0")}`;
}
export function TransportControls() {
const {
status, currentSeconds, tempoMultiplier,
loopStart, loopEnd, waitMode,
document: doc,
play, pause, stop, seek,
setTempoMultiplier, setLoopPoints, setWaitMode, setMetronome,
settings, loadMidiFile, loadMusicXmlFile, loadPdfFile,
isLoadingDocument, loadingMessage,
} = useAppStore();
const [speedOpen, setSpeedOpen] = useState(false);
const [speedRect, setSpeedRect] = useState<{ bottom: number; right: number } | null>(null);
const speedContainerRef = useRef<HTMLDivElement>(null);
// When opening, capture fixed position so popover escapes the overflow:auto container
const toggleSpeed = () => {
if (!speedOpen && speedContainerRef.current) {
const r = speedContainerRef.current.getBoundingClientRect();
setSpeedRect({ bottom: window.innerHeight - r.top + 8, right: window.innerWidth - r.right });
}
setSpeedOpen((v) => !v);
};
// Close speed popover on outside click
useEffect(() => {
if (!speedOpen) return;
const handler = (e: MouseEvent) => {
if (!speedContainerRef.current?.contains(e.target as Node)) setSpeedOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [speedOpen]);
const openFile = (file: File) => {
if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file);
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
};
const total = doc?.totalDuration ?? 0;
const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0;
const loopActive = loopStart !== null && loopEnd !== null;
const isPlaying = status === "playing";
const isPaused = status === "paused";
const isStopped = status === "stopped";
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!total) return;
const rect = e.currentTarget.getBoundingClientRect();
seek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * total);
};
const toggleHand = (hand: "left" | "right") => {
const next = new Set(settings.activeHands);
if (next.has(hand)) { if (next.size > 1) next.delete(hand); }
else next.add(hand);
useAppStore.getState().setActiveHands(next);
};
const startLoop = () => {
const start = currentSeconds;
const end = Math.min(total, start + 30);
setLoopPoints(start, end > start ? end : total);
};
const clearLoop = () => setLoopPoints(null, null);
const setLoopA = () => { if (loopEnd !== null) setLoopPoints(Math.min(currentSeconds, loopEnd - 1), loopEnd); };
const setLoopB = () => { if (loopStart !== null) setLoopPoints(loopStart, Math.max(currentSeconds, loopStart + 1)); };
return (
<div
className="flex flex-col gap-1.5 pt-2 pb-2 flex-shrink-0 select-none"
style={{ borderTop: "1px solid var(--color-border)", background: "var(--color-surface)", paddingLeft: 12, paddingRight: 12 }}
>
{/* ── Progress bar ──────────────────────────────────────────────────── */}
<div className="flex items-center gap-2" style={{ marginLeft: -12, marginRight: -12 }}>
<span
className="text-xs tabular-nums w-9 shrink-0 text-right"
style={{ color: "var(--color-text-muted)", paddingLeft: 12 }}
aria-label="current time"
>
{formatTime(currentSeconds)}
</span>
{/* Enlarged hit area (vertical padding) around the visual bar */}
<div
className="flex-1 relative cursor-pointer"
style={{ padding: "8px 0", margin: "-8px 0" }}
onClick={handleProgressClick}
role="slider"
aria-label="Playback position"
aria-valuenow={Math.round(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="relative rounded-full" style={{ height: 6, background: "var(--color-surface-3)" }}>
{/* Loop region */}
{loopActive && total > 0 && (
<div
className="absolute top-0 h-full"
style={{
left: `${(loopStart! / total) * 100}%`,
width: `${((loopEnd! - loopStart!) / total) * 100}%`,
background: "var(--color-accent)",
opacity: 0.25,
}}
/>
)}
{/* Playhead fill */}
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{ width: `${progress * 100}%`, background: "var(--color-accent)" }}
/>
{/* Thumb - clamped so it never bleeds outside the track */}
<div
style={{
position: "absolute",
top: "50%",
left: `clamp(7px, ${progress * 100}%, calc(100% - 7px))`,
transform: "translate(-50%, -50%)",
width: 14,
height: 14,
borderRadius: "50%",
background: "white",
boxShadow: "0 0 0 2px var(--color-accent), 0 1px 4px rgba(0,0,0,0.3)",
pointerEvents: "none",
}}
/>
</div>
</div>
<span
className="text-xs tabular-nums w-9 shrink-0"
style={{ color: "var(--color-text-muted)", paddingRight: 12 }}
aria-label="total duration"
>
{formatTime(total)}
</span>
</div>
{/* ── Controls row ──────────────────────────────────────────────────── */}
<div className="flex items-center min-w-0" style={{ gap: 6, overflowX: "auto", scrollbarWidth: "none" }}>
{/* ── Zone 1: File import - accent-styled to stand out ── */}
<label
title="Open MIDI, MusicXML, or PDF sheet music"
aria-label="Import MIDI, MusicXML, or PDF file"
style={{
display: "inline-flex", alignItems: "center", gap: 5,
height: 32, padding: "0 12px",
borderRadius: 7, cursor: "pointer",
fontSize: 12, fontWeight: 600, fontFamily: "inherit",
flexShrink: 0,
background: "rgba(147,51,234,0.14)",
border: "1px solid rgba(147,51,234,0.35)",
color: "var(--color-accent-text)",
transition: "background 0.12s",
}}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.24)")}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)")}
>
{isLoadingDocument ? <SpinnerIcon /> : <UploadIcon />}
{isLoadingDocument && loadingMessage
? <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{loadingMessage}</span>
: <span>Import</span>
}
<input
type="file"
accept=".mid,.midi,.xml,.mxl,.pdf"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) openFile(f); e.target.value = ""; }}
/>
</label>
<Divider />
{/* ── Zone 2: Playback controls ───────────────────────── */}
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
<Btn onClick={stop} title="Stop and return to start" aria-label="Stop" disabled={isStopped}>
<StopIcon />
</Btn>
<Btn onClick={() => syncEngine.skipBy(-10)} title="Skip back 10 seconds" aria-label="Skip back 10 seconds" disabled={!doc}>
<SkipBackIcon />
</Btn>
<Btn
primary
onClick={isPlaying ? pause : () => play()}
title={isPlaying ? "Pause" : isPaused ? "Resume" : "Play"}
aria-label={isPlaying ? "Pause" : isPaused ? "Resume playback" : "Play"}
disabled={!doc}
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</Btn>
<Btn onClick={() => syncEngine.skipBy(10)} title="Skip forward 10 seconds" aria-label="Skip forward 10 seconds" disabled={!doc}>
<SkipFwdIcon />
</Btn>
</div>
<Divider />
{/* ── Zone 4: Hands ───────────────────────────────────── */}
<div className="flex items-center shrink-0" style={{ gap: 5 }}>
{(["left", "right"] as const).map((h) => {
const active = settings.activeHands.has(h);
const isLeft = h === "left";
return (
<button
key={h}
onClick={() => toggleHand(h)}
title={`${active ? "Mute" : "Unmute"} ${h} hand`}
aria-label={`${active ? "Mute" : "Unmute"} ${h} hand`}
aria-pressed={active}
className="rounded text-xs font-bold transition-all"
style={{
width: 28, height: 28,
cursor: "pointer",
fontFamily: "inherit",
...(active ? {
background: isLeft ? "var(--color-left-hand-subtle)" : "var(--color-right-hand-subtle)",
color: isLeft ? "var(--color-left-hand-text)" : "var(--color-right-hand-text)",
border: isLeft
? "1px solid var(--color-left-hand-subtle-border)"
: "1px solid var(--color-right-hand-subtle-border)",
} : {
background: "var(--color-surface-2)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
opacity: 0.5,
}),
}}
>
{isLeft ? "L" : "R"}
</button>
);
})}
</div>
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* ── Zone 5: Practice tools ──────────────────────────── */}
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
<ChipBtn active={waitMode} onClick={() => setWaitMode(!waitMode)} title="Pause until you play the correct note" aria-label="Wait mode" aria-pressed={waitMode}>
<WaitIcon /> Wait
</ChipBtn>
{/* ── A / Loop / B - single compound grouped control ── */}
<div
className="flex items-center shrink-0"
style={{
border: "1px solid var(--color-border)",
borderRadius: 7,
overflow: "hidden",
background: "var(--color-surface-2)",
}}
>
{/* A marker */}
<button
onClick={setLoopA}
disabled={!loopActive}
title="Set loop start (A) to current position"
aria-label="Set loop start to current position"
style={{
height: 28, padding: "0 8px",
background: "transparent",
border: "none",
borderRight: "1px solid var(--color-border)",
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
fontSize: 11, fontWeight: 700, fontFamily: "inherit",
cursor: loopActive ? "pointer" : "default",
opacity: loopActive ? 1 : 0.5,
transition: "all 0.12s",
}}
>
A
</button>
{/* Loop toggle (middle) */}
<button
onClick={loopActive ? clearLoop : startLoop}
disabled={!doc}
title={loopActive ? "Clear loop" : "Start A/B loop from current position"}
aria-label={loopActive ? "Clear loop" : "Start loop"}
aria-pressed={loopActive}
style={{
height: 28, padding: "0 9px",
background: loopActive ? "var(--color-accent-subtle)" : "transparent",
border: "none",
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
fontSize: 11, fontWeight: 500, fontFamily: "inherit",
cursor: doc ? "pointer" : "default",
display: "flex", alignItems: "center", gap: 4,
transition: "all 0.12s",
}}
>
<LoopIcon />
{loopActive ? `${formatTime(loopStart!)}-${formatTime(loopEnd!)}` : "Loop"}
</button>
{/* B marker */}
<button
onClick={setLoopB}
disabled={!loopActive}
title="Set loop end (B) to current position"
aria-label="Set loop end to current position"
style={{
height: 28, padding: "0 8px",
background: "transparent",
border: "none",
borderLeft: "1px solid var(--color-border)",
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
fontSize: 11, fontWeight: 700, fontFamily: "inherit",
cursor: loopActive ? "pointer" : "default",
opacity: loopActive ? 1 : 0.5,
transition: "all 0.12s",
}}
>
B
</button>
</div>
<ChipBtn
active={settings.metronomeEnabled}
onClick={() => setMetronome(!settings.metronomeEnabled)}
title="Metronome (auto-syncs to MIDI BPM)"
aria-label="Toggle metronome"
aria-pressed={settings.metronomeEnabled}
>
<MetronomeIcon /> Metronome
</ChipBtn>
{/* ── Speed chip with popover ─────────────────────────── */}
<div className="relative shrink-0" ref={speedContainerRef}>
<ChipBtn
active={tempoMultiplier !== 1.0 || speedOpen}
onClick={toggleSpeed}
title="Playback speed"
aria-label="Playback speed"
aria-pressed={speedOpen}
>
<SpeedIcon />
{tempoMultiplier !== 1.0 ? `${Math.round(tempoMultiplier * 100)}%` : "Speed"}
</ChipBtn>
{speedOpen && speedRect && (
<div
style={{
position: "fixed",
bottom: speedRect.bottom,
right: speedRect.right,
width: 220,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 10,
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
zIndex: 100,
padding: "12px 14px",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text)" }}>Playback Speed</span>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--color-accent-text)", fontVariantNumeric: "tabular-nums" }}>
{Math.round(tempoMultiplier * 100)}%
</span>
</div>
<input
type="range" min={25} max={200} step={5}
value={Math.round(tempoMultiplier * 100)}
onChange={(e) => setTempoMultiplier(Number(e.target.value) / 100)}
aria-label="Playback speed"
style={{ width: "100%", accentColor: "var(--color-accent)" }}
/>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: "var(--color-text-muted)", marginTop: 4 }}>
<span>25%</span>
<span>100%</span>
<span>200%</span>
</div>
{tempoMultiplier !== 1.0 && (
<button
onClick={() => setTempoMultiplier(1.0)}
style={{
marginTop: 10, width: "100%",
background: "var(--color-surface-2)",
border: "1px solid var(--color-border)",
borderRadius: 6,
color: "var(--color-accent-text)",
fontSize: 11, fontWeight: 600, fontFamily: "inherit",
padding: "5px 0",
cursor: "pointer",
}}
>
Reset to 100%
</button>
)}
</div>
)}
</div>
</div>
{/* Waiting status pill */}
{status === "waiting" && (
<span
className="text-xs animate-pulse ml-1 shrink-0"
style={{ color: "var(--color-accent)" }}
aria-live="polite"
aria-label="Waiting for you to play the correct note"
>
● Waiting…
</span>
)}
</div>
</div>
);
}
// ── Sub-components ────────────────────────────────────────────────────────────
function Btn({ onClick, title, "aria-label": ariaLabel, children, primary, disabled }: {
onClick: () => void;
title?: string;
"aria-label"?: string;
children: React.ReactNode;
primary?: boolean;
disabled?: boolean;
}) {
return (
<button
className={`t-btn${primary ? " primary" : ""}`}
onClick={onClick}
title={title}
aria-label={ariaLabel ?? title}
disabled={disabled}
>
{children}
</button>
);
}
function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-pressed": ariaPressed, children, disabled }: {
active: boolean;
onClick: () => void;
title?: string;
"aria-label"?: string;
"aria-pressed"?: boolean;
children: React.ReactNode;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
title={title}
aria-label={ariaLabel ?? title}
aria-pressed={ariaPressed}
disabled={disabled}
className="flex items-center gap-1 rounded text-xs font-medium transition-all shrink-0"
style={{
height: 28, padding: "0 8px",
cursor: disabled ? "default" : "pointer",
fontFamily: "inherit",
...(active
? { background: "var(--color-accent-subtle)", color: "var(--color-accent-text)", border: "1px solid var(--color-accent-subtle-border)" }
: { background: "var(--color-surface-2)", color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }),
}}
>
{children}
</button>
);
}
function Divider() {
return <div className="shrink-0" style={{ width: 1, height: 20, background: "var(--color-border)", margin: "0 2px" }} />;
}
// ── Icons ─────────────────────────────────────────────────────────────────────
const PlayIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>;
const PauseIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>;
const StopIcon = () => <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="1"/></svg>;
const SkipBackIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text>
</svg>
);
const SkipFwdIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text>
</svg>
);
const UploadIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/></svg>;
const SpinnerIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ animation: "spin 1s linear infinite" }}><path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/></svg>;
const WaitIcon = () => (
<svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor">
<rect x="3.5" y="10" width="13" height="7" rx="1.5"/>
<rect x="4" y="2.5" width="2.5" height="9" rx="1.25"/>
<rect x="7.5" y="1" width="2.5" height="10.5" rx="1.25"/>
<rect x="11" y="1.5" width="2.5" height="10" rx="1.25"/>
<rect x="14" y="4" width="2.5" height="7.5" rx="1.25"/>
</svg>
);
const LoopIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.68 14.72 20 13.41 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.32 9.28 4 10.59 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
</svg>
);
const SpeedIcon = () => (
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 2v11h3v9l7-12h-4l4-8z"/>
</svg>
);
const MetronomeIcon = () => (
<svg width="10" height="10" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 2L2 18h16L10 2z"/>
<line x1="10" y1="15" x2="13.5" y2="6"/>
</svg>
);
+119
View File
@@ -0,0 +1,119 @@
/**
* AccuracyTracker - real-time scoring of user input vs. expected notes.
*
* Strategy:
* - When a note:on fires, record it in a "window" of expected notes.
* - When the user plays (input:note:on), check if any expected note in the
* ±HIT_WINDOW_MS tolerance matches the MIDI number. If yes: correct hit.
* - When a note's off-time passes without a matching input: missed.
* - If the user plays a MIDI that doesn't match any expected note: wrong.
*
* Emits bus event "accuracy:update" with running totals after every event.
*/
import bus from "./EventBus";
import type { NoteEvent } from "@bach-to-basics/shared";
/** How many milliseconds before/after note start counts as a "hit" */
const HIT_WINDOW_MS = 300;
interface PendingNote {
note: NoteEvent;
startMs: number; // performance.now() when note:on fired
hit: boolean;
}
class AccuracyTracker {
private enabled = false;
private pending: PendingNote[] = [];
private correct = 0;
private wrong = 0;
private missed = 0;
constructor() {
bus.on("note:on", (note) => this.onNoteOn(note as NoteEvent));
bus.on("note:off", (note) => this.onNoteOff(note as NoteEvent));
bus.on("input:note:on", ({ midi }) => this.onInput(midi));
bus.on("transport:stateChange", ({ status }) => {
if (status === "stopped") this.reset();
});
bus.on("transport:seek", () => {
// Clear pending notes on seek - they're no longer relevant
this.pending = [];
});
}
enable(on: boolean) {
this.enabled = on;
if (!on) this.reset();
}
reset() {
this.pending = [];
this.correct = 0;
this.wrong = 0;
this.missed = 0;
this.emit();
}
get score(): number {
const total = this.correct + this.wrong + this.missed;
return total === 0 ? 0 : Math.round((this.correct / total) * 100);
}
private onNoteOn(note: NoteEvent) {
if (!this.enabled) return;
this.pending.push({ note, startMs: performance.now(), hit: false });
}
private onNoteOff(note: NoteEvent) {
if (!this.enabled) return;
const idx = this.pending.findIndex((p) => p.note.id === note.id);
if (idx === -1) return;
const [pending] = this.pending.splice(idx, 1);
if (!pending.hit) {
this.missed++;
this.emit();
}
}
private onInput(midi: number) {
if (!this.enabled) return;
const now = performance.now();
// Find the closest pending note matching this MIDI within the hit window
let bestIdx = -1;
let bestDelta = Infinity;
for (let i = 0; i < this.pending.length; i++) {
const p = this.pending[i];
if (p.hit) continue;
if (p.note.midi !== midi) continue;
const delta = Math.abs(now - p.startMs);
if (delta <= HIT_WINDOW_MS && delta < bestDelta) {
bestDelta = delta;
bestIdx = i;
}
}
if (bestIdx >= 0) {
this.pending[bestIdx].hit = true;
this.correct++;
} else {
this.wrong++;
}
this.emit();
}
private emit() {
bus.emit("accuracy:update", {
correct: this.correct,
wrong: this.wrong,
missed: this.missed,
score: this.score,
});
}
}
export const accuracyTracker = new AccuracyTracker();
export default accuracyTracker;
+109
View File
@@ -0,0 +1,109 @@
import { SplendidGrandPiano, Soundfont, ElectricPiano } from "smplr";
import type { NoteEvent } from "@bach-to-basics/shared";
// ── Instrument catalogue ──────────────────────────────────────────────────────
export type InstrumentId =
| "grand" // Splendid Grand Piano (SplendidGrandPiano)
| "bright" // Bright Acoustic Piano (Soundfont)
| "electric" // CP80 Electric Grand (ElectricPiano)
| "harpsichord" // Harpsichord (Soundfont)
| "honkytonk"; // Honky-Tonk Piano (Soundfont)
export const INSTRUMENT_LABELS: Record<InstrumentId, string> = {
grand: "Grand",
bright: "Bright",
electric: "E-Piano",
harpsichord: "Harpsi",
honkytonk: "Honky",
};
// Minimal common interface shared by all smplr players
type Player = {
load: Promise<unknown>;
start(opts: { note: number; velocity?: number; stopId?: number | string }): unknown;
stop(opts?: { stopId?: number | string }): void;
};
function createPlayer(id: InstrumentId, ctx: AudioContext): Player {
switch (id) {
case "grand":
return new SplendidGrandPiano(ctx) as unknown as Player;
case "bright":
return new Soundfont(ctx, { instrument: "bright_acoustic_piano" }) as unknown as Player;
case "electric":
return new ElectricPiano(ctx, { instrument: "CP80" }) as unknown as Player;
case "harpsichord":
return new Soundfont(ctx, { instrument: "harpsichord" }) as unknown as Player;
case "honkytonk":
return new Soundfont(ctx, { instrument: "honkytonk_piano" }) as unknown as Player;
}
}
// ── AudioEngine ───────────────────────────────────────────────────────────────
export class AudioEngine {
private ctx: AudioContext | null = null;
private player: Player | null = null;
private _currentId: InstrumentId = "grand";
private _loaded = false;
// Dedup: all callers await the same promise so we never create two AudioContexts
private _loadPromise: Promise<void> | null = null;
async load() {
if (this._loaded) return;
if (this._loadPromise) return this._loadPromise;
this._loadPromise = (async () => {
if (!this.ctx) this.ctx = new AudioContext();
this.player = createPlayer(this._currentId, this.ctx);
await this.player.load;
this._loaded = true;
})();
return this._loadPromise;
}
/**
* Switch to a different instrument. If the AudioContext already exists the
* new instrument is pre-loaded immediately so the next note plays without delay.
*/
async setInstrument(id: InstrumentId): Promise<void> {
if (id === this._currentId && this._loaded) return;
this._currentId = id;
this._loaded = false;
this._loadPromise = null;
// Silence any ongoing audio before swapping the player
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();
}
get loaded() {
return this._loaded;
}
playNote(note: NoteEvent) {
if (!this.player || !this._loaded) return;
// Resume in case the AudioContext was created before a user gesture (browser
// autoplay policy starts it suspended). Fire-and-forget, resolves quickly.
this.ctx?.resume();
this.player.start({ note: note.midi, velocity: note.velocity, stopId: note.midi });
}
/** Play a single MIDI note by number - for virtual keyboard clicks. */
playMidi(midi: number, velocity = 90) {
if (!this.player || !this._loaded) return;
// Resume in case the AudioContext was created before a user gesture.
this.ctx?.resume();
this.player.start({ note: midi, velocity, stopId: midi });
}
stopNote(midi: number) {
if (!this.player || !this._loaded) return;
this.player.stop({ stopId: midi });
}
stopAll() {
try { this.player?.stop?.(); } catch { /* ignore */ }
}
}
+11
View File
@@ -0,0 +1,11 @@
import mitt, { type Emitter } from "mitt";
import type { SyncEventMap } from "@bach-to-basics/shared";
// mitt's generic constraint requires Record<string | symbol, unknown>.
// We cast our typed map through a compatible intermediate type.
type BusEvents = SyncEventMap & Record<string | symbol, unknown>;
const bus = mitt<BusEvents>();
export type EventBus = Emitter<BusEvents>;
export default bus;
+79
View File
@@ -0,0 +1,79 @@
import * as Tone from "tone";
export type TickCallback = (docSeconds: number) => void;
/**
* MidiClock tracks position in "doc-time" (the song's native seconds, same
* unit as NoteEvent.startSeconds). Playing at tempoMultiplier 0.5 means the
* song advances at half speed relative to real time:
*
* docSeconds = _startOffset + Transport.seconds * tempoMultiplier
*
* This keeps progress-bar / seek values always in doc-time (0..totalDuration).
* Audio scheduling converts doc-time differences to real-time via / multiplier.
*/
export class MidiClock {
private onTickCb: TickCallback | null = null;
private _tempoMultiplier = 1.0;
private _startOffset = 0; // doc-seconds we started / seeked from
get tempoMultiplier() { return this._tempoMultiplier; }
set tempoMultiplier(v: number) {
const current = this.currentSeconds; // preserve position
this._tempoMultiplier = Math.max(0.25, Math.min(2.0, v));
// Recompute offset so currentSeconds stays the same after the change
this._startOffset = current - Tone.getTransport().seconds * this._tempoMultiplier;
}
onTick(cb: TickCallback) { this.onTickCb = cb; }
async start(offsetDocSeconds = 0) {
await Tone.start();
this._startOffset = offsetDocSeconds;
Tone.getTransport().cancel();
Tone.getTransport().seconds = 0;
// BPM at 120, 16n fires every 0.125 real seconds. Fine for tight ticks.
Tone.getTransport().bpm.value = 120;
Tone.getTransport().scheduleRepeat(() => {
this.onTickCb?.(this.currentSeconds);
}, "16n");
Tone.getTransport().start();
}
pause() { Tone.getTransport().pause(); }
resume() { Tone.getTransport().start(); }
stop() {
Tone.getTransport().stop();
Tone.getTransport().cancel();
}
/**
* Called after an external Tone.getTransport().cancel() to rebuild the
* scheduleRepeat that drives onTick(). Must be called before resume().
*/
reschedule(docSeconds: number) {
this._startOffset = docSeconds;
Tone.getTransport().seconds = 0;
Tone.getTransport().scheduleRepeat(() => {
this.onTickCb?.(this.currentSeconds);
}, "16n");
}
seek(docSeconds: number) {
this._startOffset = docSeconds;
Tone.getTransport().seconds = 0;
}
get currentSeconds(): number {
// Real Transport.seconds * tempo = how far we've advanced through the song
return this._startOffset + Tone.getTransport().seconds * this._tempoMultiplier;
}
get state(): "started" | "stopped" | "paused" {
return Tone.getTransport().state as "started" | "stopped" | "paused";
}
}
+545
View File
@@ -0,0 +1,545 @@
import * as Tone from "tone";
import bus from "./EventBus";
import { MidiClock } from "./MidiClock";
import { AudioEngine } from "./AudioEngine";
import type { InstrumentId } from "./AudioEngine";
import type { MusicDocument, NoteEvent, PlaybackStatus } from "@bach-to-basics/shared";
interface YTPlayer {
getCurrentTime(): number;
seekTo(seconds: number, allowSeekAhead: boolean): void;
}
interface SpeedTrainerOpts {
enabled: boolean;
startPct: number;
endPct: number;
stepPct: number;
currentPct: number;
}
// How many doc-seconds ahead to emit "note:upcoming" for FallingNotes pre-rendering.
const LOOKAHEAD_DOC_SECONDS = 5.0;
const YT_DRIFT_THRESHOLD = 0.25;
export interface SyncEngineState {
status: PlaybackStatus;
currentSeconds: number;
tempoMultiplier: number;
loopStart: number | null;
loopEnd: number | null;
waitMode: boolean;
activeHands: Set<"left" | "right">;
metronomeEnabled: boolean;
handVolume: { left: number; right: number };
/** Which hand(s) trigger wait-mode pauses. Only relevant when waitMode is on. */
waitForHand: "left" | "right" | "both";
}
class SyncEngine {
private clock = new MidiClock();
readonly audio = new AudioEngine();
private doc: MusicDocument | null = null;
private noteIndex = 0;
private activeNotes = new Set<string>();
private _state: SyncEngineState = {
status: "stopped",
currentSeconds: 0,
tempoMultiplier: 1.0,
loopStart: null,
loopEnd: null,
waitMode: false,
activeHands: new Set(["left", "right"]),
metronomeEnabled: false,
handVolume: { left: 1, right: 1 },
waitForHand: "both",
};
private ytPlayer: YTPlayer | null = null;
private animFrameId: number | null = null;
private stateListeners = new Set<(s: SyncEngineState) => void>();
// Metronome
private metronome: Tone.Synth | null = null;
private lastBeatCrossed = -1;
// ── Virtual keyboard pending-play tracking ──────────────────────────────────
// Tracks MIDI notes that are awaiting audio-load before playing.
// If the user releases a key before the load completes, the entry is removed
// so the note never fires - preventing stuck/echoing notes.
private pendingMidiPlays = new Set<number>();
// ── Practice features ───────────────────────────────────────────────────────
private renderOffsetMs = 0;
private transposeSemitones = 0;
private countInBars: 0 | 1 | 2 = 0;
private countInTimeouts: ReturnType<typeof setTimeout>[] = [];
private speedTrainer: SpeedTrainerOpts = {
enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60,
};
constructor() {
this.clock.onTick(this.onTick.bind(this));
}
// ── Public API ──────────────────────────────────────────────────────────────
async loadDocument(doc: MusicDocument) {
this.stop();
this.doc = doc;
this.noteIndex = 0;
this.activeNotes.clear();
this._state.currentSeconds = 0;
bus.emit("document:loaded", { id: doc.id, title: doc.title, totalDuration: doc.totalDuration });
this.emitState("stopped");
}
async play(fromSeconds?: number) {
if (!this.doc) return;
await this.audio.load();
const startFrom = fromSeconds ?? this._state.currentSeconds;
// Speed trainer: reset currentPct when starting from the very beginning
if (this.speedTrainer.enabled && startFrom === 0) {
this.speedTrainer.currentPct = this.speedTrainer.startPct;
this._state.tempoMultiplier = this.speedTrainer.startPct / 100;
this.clock.tempoMultiplier = this._state.tempoMultiplier;
bus.emit("speedTrainer:stepped", { currentPct: this.speedTrainer.startPct });
}
if (this.countInBars > 0) {
await this._playWithCountIn(startFrom);
} else {
this.seekTo(startFrom);
await this.clock.start(startFrom);
this.startAnimLoop();
this.emitState("playing");
}
}
private async _playWithCountIn(startFrom: number) {
const bpm = this.doc!.tempoMap[0]?.bpm ?? 120;
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 totalBeats = this.countInBars * beatsPerBar;
// Ensure metronome synth exists
if (!this.metronome) {
this.metronome = new Tone.Synth({
oscillator: { type: "triangle" },
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.02 },
volume: -8,
}).toDestination();
}
this.emitState("playing"); // Show as playing during count-in
// Schedule one click per beat
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 */ }
}, b * beatDurMs);
this.countInTimeouts.push(id);
}
// After count-in, start actual playback
const startId = setTimeout(async () => {
this.countInTimeouts = [];
this.seekTo(startFrom);
await this.clock.start(startFrom);
this.startAnimLoop();
// status stays "playing"
}, totalBeats * beatDurMs);
this.countInTimeouts.push(startId);
}
private _cancelCountIn() {
for (const id of this.countInTimeouts) clearTimeout(id);
this.countInTimeouts = [];
}
pause() {
this._cancelCountIn();
this.clock.pause();
this.stopAnimLoop();
this.emitState("paused");
}
stop() {
this._cancelCountIn();
this.clock.stop();
this.audio.stopAll();
this.stopAnimLoop();
this.noteIndex = 0;
this.activeNotes.clear();
this._state.currentSeconds = 0;
this.lastBeatCrossed = -1;
this.emitState("stopped");
bus.emit("transport:tick", { seconds: 0, tick: 0 });
}
seek(seconds: number) {
const wasPlaying = this._state.status === "playing" || this._state.status === "waiting";
if (wasPlaying) this.clock.pause();
// Tell the UI to release all visually-pressed keys before we cancel Tone events.
// Without this, any note whose note:on fired but note:off was still scheduled
// (and then cancelled below) leaves the key stuck in a pressed state.
bus.emit("transport:seek", { seconds });
// Cancel pending Tone events so notes don't fire from the old position
Tone.getTransport().cancel();
this.seekTo(seconds);
this.audio.stopAll();
bus.emit("transport:tick", { seconds, tick: 0 });
if (wasPlaying) {
// cancel() above killed the scheduleRepeat - rebuild it before resuming
this.clock.reschedule(seconds);
this.clock.resume();
this._state.status = "playing";
}
this.emitState(this._state.status);
}
skipBy(delta: number) {
const total = this.doc?.totalDuration ?? 0;
const next = Math.max(0, Math.min(total, this._state.currentSeconds + delta));
this.seek(next);
}
setTempoMultiplier(v: number) {
const clamped = Math.max(0.25, Math.min(2.0, v));
this._state.tempoMultiplier = clamped;
// Update clock rate - its setter recalculates _startOffset so currentSeconds
// stays the same despite the new multiplier.
this.clock.tempoMultiplier = clamped;
// If playing, all Tone.js note:on/note:off callbacks were scheduled at the
// OLD tempo rate and will now fire at the wrong wall-clock time.
// Solution: same as seek() - cancel pending Tone events, reset the note
// cursor, and reschedule everything at the new rate from the current position.
const isActive = this._state.status === "playing" || this._state.status === "waiting";
if (isActive) {
const currentSec = this._state.currentSeconds;
this.clock.pause();
bus.emit("transport:seek", { seconds: currentSec }); // release pressed keys
Tone.getTransport().cancel();
this.audio.stopAll();
this.seekTo(currentSec); // resets noteIndex + activeNotes
this.clock.reschedule(currentSec);
this.clock.resume();
this._state.status = "playing";
}
this.emitState(this._state.status);
}
setLoopPoints(start: number | null, end: number | null) {
this._state.loopStart = start;
this._state.loopEnd = end;
this.emitState(this._state.status);
}
setWaitMode(on: boolean) {
this._state.waitMode = on;
this.emitState(this._state.status);
}
setMetronome(on: boolean) {
this._state.metronomeEnabled = on;
if (on && !this.metronome) {
// Lazy-create the synth so AudioContext is already started
this.metronome = new Tone.Synth({
oscillator: { type: "triangle" },
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.02 },
volume: -8,
}).toDestination();
}
if (!on) this.lastBeatCrossed = -1;
this.emitState(this._state.status);
}
setActiveHands(hands: Set<"left" | "right">) {
this._state.activeHands = hands;
this.emitState(this._state.status);
}
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)) };
}
setWaitForHand(hand: "left" | "right" | "both") {
this._state.waitForHand = hand;
}
async setInstrument(id: InstrumentId): Promise<void> {
await this.audio.setInstrument(id);
}
setYouTubePlayer(player: YTPlayer | null) { this.ytPlayer = player; }
// ── Practice feature setters ────────────────────────────────────────────────
setTranspose(semitones: number) {
this.transposeSemitones = Math.round(Math.max(-12, Math.min(12, semitones)));
}
setCountInBars(n: 0 | 1 | 2) {
this.countInBars = n;
}
setSpeedTrainer(opts: SpeedTrainerOpts) {
this.speedTrainer = { ...opts };
}
setRenderOffset(ms: number) {
this.renderOffsetMs = Math.max(-200, Math.min(200, ms));
}
// ── MIDI input ──────────────────────────────────────────────────────────────
/** Called by WebMidi.js, virtual keyboard click, etc. */
onMidiInput(midi: number, velocity: number) {
bus.emit("input:note:on", { midi, velocity });
if (this._state.status === "waiting") {
const expected = this.waitingNote();
if (expected && midi === expected.midi) {
this._state.status = "playing";
this.clock.resume();
this.emitState("playing");
}
}
}
/** Play a note through the audio engine immediately (for virtual keyboard clicks). */
async playMidi(midi: number, velocity = 90) {
// Emit input:note:on synchronously so the key lights up with no delay.
this.onMidiInput(midi, velocity);
// Register this MIDI as pending. If stopMidi() fires before audio loads
// (user tapped quickly), the entry is removed and we skip the play call -
// preventing the "echo" where a note sustains after the key was released.
this.pendingMidiPlays.add(midi);
await this.audio.load();
if (this.pendingMidiPlays.has(midi)) {
this.pendingMidiPlays.delete(midi);
this.audio.playMidi(midi, velocity);
}
}
stopMidi(midi: number) {
this.pendingMidiPlays.delete(midi); // cancel play if still loading
this.audio.stopNote(midi);
this.onMidiInputOff(midi);
}
onMidiInputOff(midi: number) {
bus.emit("input:note:off", { midi });
}
onStateChange(cb: (s: SyncEngineState) => void) {
this.stateListeners.add(cb);
return () => this.stateListeners.delete(cb);
}
get state(): Readonly<SyncEngineState> { return this._state; }
// ── Private ─────────────────────────────────────────────────────────────────
/**
* seekTo positions the note-cursor for doc-time `seconds`.
* All note.startSeconds are in doc-time (same unit as currentSeconds).
*/
private seekTo(seconds: number) {
if (!this.doc) return;
this._state.currentSeconds = seconds;
this.noteIndex = this.doc.notes.findIndex((n) => n.startSeconds >= seconds);
if (this.noteIndex === -1) this.noteIndex = this.doc.notes.length;
this.activeNotes.clear();
// Reset metronome so the first beat at new position doesn't double-fire
const bpm = this.doc.tempoMap[0]?.bpm ?? 120;
this.lastBeatCrossed = Math.floor(seconds / (60 / bpm));
}
private waitingNote(): NoteEvent | null {
if (!this.doc || this.noteIndex >= this.doc.notes.length) return null;
const note = this.doc.notes[this.noteIndex];
const { activeHands, waitForHand } = this._state;
if (note.hand !== "unknown" && !activeHands.has(note.hand as "left" | "right")) return null;
// When waitForHand restricts to one hand, ignore notes from the other hand
if (waitForHand !== "both" && note.hand !== "unknown" && note.hand !== waitForHand) return null;
return note;
}
/**
* Applies transposition to a note, clamping to the 88-key range (A0-C8).
* Returns the original note object unchanged if transposeSemitones is 0.
*/
private transposeNote(note: NoteEvent): NoteEvent {
if (this.transposeSemitones === 0) return note;
const tMidi = Math.max(21, Math.min(108, note.midi + this.transposeSemitones));
return { ...note, midi: tMidi };
}
/**
* Called by MidiClock every ~125ms.
* `docSeconds` is in doc-time (same unit as note.startSeconds).
*
* To schedule Tone.js events in real-time:
* realDelay = docTimeDiff / tempoMultiplier
*/
private onTick(docSeconds: number) {
if (!this.doc) return;
this._state.currentSeconds = docSeconds;
// ── Metronome ─────────────────────────────────────────────────────────────
if (this._state.metronomeEnabled && this.doc && this.metronome) {
const bpm = this.doc.tempoMap[0]?.bpm ?? 120;
const beat = Math.floor(docSeconds / (60 / bpm));
if (beat !== this.lastBeatCrossed) {
this.lastBeatCrossed = beat;
try { this.metronome.triggerAttackRelease("C6", "64n"); } catch { /* ignore */ }
}
}
// ── Loop ──────────────────────────────────────────────────────────────────
const { loopStart, loopEnd, tempoMultiplier } = this._state;
if (loopStart !== null && loopEnd !== null && docSeconds >= loopEnd) {
// Speed trainer: bump tempo on each loop wrap (only while below target)
if (this.speedTrainer.enabled) {
const st = this.speedTrainer;
if (st.currentPct < st.endPct) {
const nextPct = Math.min(st.endPct, st.currentPct + st.stepPct);
st.currentPct = nextPct;
// setTempoMultiplier calls emitState internally
this.setTempoMultiplier(nextPct / 100);
bus.emit("speedTrainer:stepped", { currentPct: nextPct });
}
}
this.seek(loopStart);
return;
}
// ── End of song auto-stop ─────────────────────────────────────────────────
// When no loop is active and playback reaches the end, stop and reset to 0.
if ((loopStart === null || loopEnd === null) && docSeconds >= this.doc.totalDuration - 0.05) {
this.stop();
return;
}
// ── Schedule upcoming notes ───────────────────────────────────────────────
const notes = this.doc.notes;
while (this.noteIndex < notes.length) {
const note = notes[this.noteIndex];
// 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; }
// Apply transposition (creates a new NoteEvent if needed, preserves original id)
const tNote = this.transposeNote(note);
bus.emit("note:upcoming", tNote);
this.activeNotes.add(note.id);
// Convert doc-time offset to real-time for Tone.js scheduling.
// renderOffsetMs > 0 means audio plays earlier (compensates for delayed output).
const offsetSec = this.renderOffsetMs / 1000;
const onDelay = Math.max(0, (note.startSeconds - docSeconds) / tempoMultiplier - offsetSec);
const offDelay = Math.max(0, (note.endSeconds - docSeconds) / tempoMultiplier - offsetSec);
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");
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
: 1;
const scaledNote = handVol === 1 ? tNote
: { ...tNote, velocity: Math.max(1, Math.round(tNote.velocity * handVol)) };
this.audio.playNote(scaledNote);
}
}, `+${onDelay}`);
Tone.getTransport().scheduleOnce(() => {
bus.emit("note:off", tNote);
this.audio.stopNote(tNote.midi);
}, `+${offDelay}`);
// Wait mode
if (this._state.waitMode && this._state.status === "playing") {
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;
if (isActive && isWaited && onDelay <= 0.05) {
this.clock.pause();
this._state.status = "waiting";
this.emitState("waiting");
}
}
this.noteIndex++;
}
// ── YouTube sync ──────────────────────────────────────────────────────────
if (this.ytPlayer && this.doc.youtubeId) {
const expectedYtTime = docSeconds + this.doc.youtubeSyncOffset;
try {
const actual = this.ytPlayer.getCurrentTime();
if (Math.abs(actual - expectedYtTime) > YT_DRIFT_THRESHOLD) {
this.ytPlayer.seekTo(expectedYtTime, true);
}
} catch { /* not ready yet */ }
}
}
// rAF loop: emits transport:tick at 60fps for smooth visuals.
// State listeners throttled to ~10fps to avoid React re-renders.
private startAnimLoop() {
let lastStateMs = 0;
const loop = () => {
const seconds = this.clock.currentSeconds;
this._state.currentSeconds = seconds;
bus.emit("transport:tick", { seconds, tick: 0 });
const now = performance.now();
if (now - lastStateMs >= 100) {
lastStateMs = now;
for (const cb of this.stateListeners) cb({ ...this._state });
}
this.animFrameId = requestAnimationFrame(loop);
};
this.animFrameId = requestAnimationFrame(loop);
}
private stopAnimLoop() {
if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId);
this.animFrameId = null;
}
}
private emitState(status: PlaybackStatus) {
this._state.status = status;
bus.emit("transport:stateChange", { status, seconds: this._state.currentSeconds });
for (const cb of this.stateListeners) cb({ ...this._state });
}
}
export const syncEngine = new SyncEngine();
export default syncEngine;
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
import bus from "../engine/EventBus";
import { accuracyTracker } from "../engine/AccuracyTracker";
export interface AccuracyState {
correct: number;
wrong: number;
missed: number;
score: number; // 0-100
}
const INITIAL: AccuracyState = { correct: 0, wrong: 0, missed: 0, score: 0 };
/**
* Returns live accuracy data from AccuracyTracker.
* Automatically enables the tracker while the component is mounted.
*/
export function useAccuracy(enabled: boolean): AccuracyState {
const [state, setState] = useState<AccuracyState>(INITIAL);
useEffect(() => {
accuracyTracker.enable(enabled);
if (!enabled) {
setState(INITIAL);
return;
}
const handler = (data: AccuracyState) => setState({ ...data });
bus.on("accuracy:update", handler as never);
return () => {
bus.off("accuracy:update", handler as never);
accuracyTracker.enable(false);
};
}, [enabled]);
return state;
}
+277
View File
@@ -0,0 +1,277 @@
@import "tailwindcss";
/* ── Design tokens ─────────────────────────────────────────────────────────── */
:root {
/* Surfaces - full indigo palette */
--color-bg: #1a1a2e;
--color-surface: #1e1e38;
--color-surface-2: #232348;
--color-surface-3: #282858;
--color-border: #2d2d55;
--color-border-subtle: #232348;
/* Accent */
--color-accent: #9333ea;
--color-accent-hover: #a855f7;
--color-accent-subtle: rgba(147, 51, 234, 0.18);
--color-accent-subtle-border: rgba(147, 51, 234, 0.35);
--color-accent-text: #c084fc;
/* Toggle switch inactive track */
--color-toggle-off: #4a4f88;
/* Text - muted bumped to #9ca3af for WCAG AA compliance on dark surfaces */
--color-text: #e2e8f0;
--color-text-muted: #9ca3af;
--color-text-dim: #3d4070;
/* Notes canvas - slightly deeper than bg so the practice area reads as distinct */
--color-notes-bg: #141428;
--color-notes-border: #1f1f44;
/* Hands */
--color-warning: #f59e0b;
--color-warning-subtle: rgba(245, 158, 11, 0.08);
--color-warning-border: rgba(245, 158, 11, 0.22);
--color-left-hand: #9333ea;
--color-left-hand-subtle: rgba(147, 51, 234, 0.18);
--color-left-hand-subtle-border: rgba(147, 51, 234, 0.38);
--color-left-hand-text: #c084fc;
--color-right-hand: #22d3ee;
--color-right-hand-subtle: rgba(34, 211, 238, 0.14);
--color-right-hand-subtle-border: rgba(34, 211, 238, 0.32);
--color-right-hand-text: #22d3ee;
}
[data-theme="light"] {
/* Surfaces */
--color-bg: #ffffff;
--color-surface: #ffffff;
--color-surface-2: #f5f7fb;
--color-surface-3: #eaecf4;
--color-border: #dde1ea;
--color-border-subtle: #eaecf4;
/* Accent */
--color-accent: #6366f1;
--color-accent-hover: #4f46e5;
--color-accent-subtle: rgba(99, 102, 241, 0.12);
--color-accent-subtle-border: rgba(99, 102, 241, 0.30);
--color-accent-text: #4f46e5;
/* Toggle switch inactive track */
--color-toggle-off: #c1c9d6;
/* Text */
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-text-dim: #94a3b8;
/* Hands */
--color-warning: #d97706;
--color-warning-subtle: rgba(217, 119, 6, 0.08);
--color-warning-border: rgba(217, 119, 6, 0.22);
--color-left-hand: #3b82f6;
--color-left-hand-subtle: rgba(59, 130, 246, 0.12);
--color-left-hand-subtle-border: rgba(59, 130, 246, 0.30);
--color-left-hand-text: #1d4ed8;
--color-right-hand: #ef4444;
--color-right-hand-subtle: rgba(239, 68, 68, 0.10);
--color-right-hand-subtle-border: rgba(239, 68, 68, 0.28);
--color-right-hand-text: #dc2626;
/* Notes canvas - cooler, slightly darker than chrome to create a
"stage vs UI" separation that mirrors what dark mode does with
#141428 content vs #1E1E38 chrome. #EEF1FA has the same blue-indigo
undertone as the dark palette and reads as white at a glance while
still being visibly distinct from the #FFFFFF header/transport. */
--color-notes-bg: #EEF1FA;
--color-notes-border: #D4D9EE;
}
/* ── Reset ─────────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root {
height: 100%;
background: var(--color-bg);
color: var(--color-text);
font-family: "Space Grotesk", system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
/* ── Typography scale ──────────────────────────────────────────────────────── */
.text-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
/* ── Inputs & selects ──────────────────────────────────────────────────────── */
select, input[type="range"] { font-family: inherit; }
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7'%3E%3Cpath d='M0 0l6 7 6-7z' fill='%2364748b'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px !important;
}
[data-theme="dark"] select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7'%3E%3Cpath d='M0 0l6 7 6-7z' fill='%239ca3af'/%3E%3C/svg%3E");
}
/* ── Settings panel rows ───────────────────────────────────────────────────── */
.settings-row:hover { background: var(--color-surface-2); }
/* ── Scrollbar ─────────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-surface-3); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-border); }
/* ── Range slider - fully custom ───────────────────────────────────────────── */
/* Remove native appearance so our pseudo-element CSS actually takes effect.
Each slider sets --fill-pct via inline style (computed from value/min/max)
so the gradient draws the correct filled/unfilled split. */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
outline: none;
}
/* Thumb - WebKit/Blink */
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-accent);
cursor: pointer;
margin-top: -5.5px; /* center on 3px track: -(14-3)/2 */
box-shadow: 0 1px 4px rgba(0,0,0,0.35);
transition: background 0.12s, box-shadow 0.12s;
}
input[type="range"]:hover::-webkit-slider-thumb {
background: var(--color-accent-hover);
box-shadow: 0 0 0 4px var(--color-accent-subtle);
}
/* Track - WebKit/Blink (uses --fill-pct for filled/unfilled split) */
input[type="range"]::-webkit-slider-runnable-track {
height: 3px;
border-radius: 99px;
background: linear-gradient(
to right,
var(--color-accent) var(--fill-pct, 0%),
var(--color-border) var(--fill-pct, 0%)
);
}
/* Thumb - Firefox */
input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-accent);
border: none;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0,0,0,0.35);
}
input[type="range"]:hover::-moz-range-thumb {
background: var(--color-accent-hover);
box-shadow: 0 0 0 4px var(--color-accent-subtle);
}
/* Track - Firefox */
input[type="range"]::-moz-range-track {
height: 3px;
border-radius: 99px;
background: var(--color-border);
}
input[type="range"]::-moz-range-progress {
height: 3px;
border-radius: 99px;
background: var(--color-accent);
}
/* ── Transport buttons ─────────────────────────────────────────────────────── */
/* Shared base for all icon/action buttons in the transport bar */
.t-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 32px;
min-width: 32px;
padding: 0 8px;
border-radius: 7px;
background: var(--color-surface-2);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.12s, opacity 0.12s;
flex-shrink: 0;
font-size: 12px;
font-family: inherit;
}
.t-btn:hover:not(:disabled) { background: var(--color-surface-3); color: var(--color-text); }
.t-btn:disabled { opacity: 0.55; cursor: default; }
/* Primary play/pause button - circular, accent-colored */
.t-btn.primary {
background: var(--color-accent);
border-color: transparent;
width: 40px;
height: 40px;
min-width: 40px;
padding: 0;
border-radius: 50%;
color: white;
box-shadow: 0 0 18px rgba(147, 51, 234, 0.5);
}
.t-btn.primary:hover:not(:disabled) {
background: var(--color-accent-hover);
box-shadow: 0 0 24px rgba(147, 51, 234, 0.65);
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── AlphaTab host ─────────────────────────────────────────────────────────── */
.at-surface svg { display: block; }
/* ── AlphaTab playback cursor ─────────────────────────────────────────────── */
/* Beat cursor - thin accent-colored vertical bar following playhead */
.at-cursor-beat {
background: rgba(147, 51, 234, 0.65);
border-radius: 1px;
}
/* Bar cursor - wider semi-transparent highlight for the current measure */
.at-cursor-bar {
background: rgba(147, 51, 234, 0.12);
border-radius: 2px;
}
/* Selection overlay */
.at-selection div {
background: rgba(147, 51, 234, 0.2);
}
/* ── Dark-mode sheet music inversion ──────────────────────────────────────── */
/* Filter is applied only to SVG elements (the actual music notation), NOT to
the container. This way:
- Black staff lines/noteheads to white (readable on dark background)
- The container's background (--color-bg dark gray) shows through transparent SVG areas
- Cursor/selection divs (.at-cursor-beat etc.) are NOT SVGs, so they keep their
natural purple accent colors - no pre-inversion math needed. */
[data-theme="dark"] .sheet-dark-invert svg {
filter: invert(1);
}
+7
View File
@@ -0,0 +1,7 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
// StrictMode deliberately double-mounts in dev, which conflicts with Pixi.js's
// async init - Application.destroy() fires before init() resolves.
createRoot(document.getElementById("root")!).render(<App />);
+555
View File
@@ -0,0 +1,555 @@
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 { InstrumentId } from "../engine/AudioEngine";
export { computeMeasureSeconds, keySignatureToLabel } from "@bach-to-basics/shared";
export type { InstrumentId } from "../engine/AudioEngine";
export { INSTRUMENT_LABELS } from "../engine/AudioEngine";
/** Which visual effect plays when a note reaches the hit line. */
export type ImpactStyle = "off" | "bloom" | "side" | "trail";
/** Which pitch classes to show in the falling-notes view */
export type NoteFilter = "all" | "white" | "black" | "c_only";
/** Layout mode - which panels are visible */
export type LayoutMode = "falling" | "sheet" | "piano" | "all";
/**
* Color theme for falling notes (and keyboard highlights).
* - violet : all notes violet (default, no hand separation)
* - classic : left=blue, right=red (hand-aware)
* - ocean : cyan palette
* - forest : green palette
* - custom : user-defined colors via CustomColors
*/
export type ColorTheme = "violet" | "classic" | "ocean" | "forest" | "cascade" | "custom";
/** Per-channel colors for the custom theme (hex strings, e.g. "#3b82f6"). */
export interface CustomColors {
leftHand: string;
rightHand: string;
unknown: string;
}
/** Pre-built color values matching each named preset (used to seed the custom picker). */
export const COLOR_PRESET_VALUES: Record<Exclude<ColorTheme, "custom">, CustomColors> = {
violet: { leftHand: "#7c3aed", rightHand: "#f59e0b", unknown: "#a78bfa" }, // purple + amber
classic: { leftHand: "#2563eb", rightHand: "#dc2626", unknown: "#7c3aed" }, // blue + red
ocean: { leftHand: "#0284c7", rightHand: "#f97316", unknown: "#0ea5e9" }, // blue + coral
forest: { leftHand: "#16a34a", rightHand: "#a855f7", unknown: "#4ade80" }, // green + purple
cascade: { leftHand: "#9333ea", rightHand: "#22d3ee", unknown: "#a855f7" }, // purple + cyan
};
/** Which keys receive note-name labels on the piano keyboard */
export type NoteLabelMode = "none" | "c_only" | "white" | "black" | "all";
export interface SpeedTrainerSettings {
enabled: boolean;
startPct: number; // 0-100
endPct: number; // 0-100
stepPct: number; // increment per loop
currentPct: number; // current ramp position
}
export interface AppSettings {
showFingering: boolean;
showHandColors: boolean;
useFlats: boolean;
viewportSeconds: number; // FallingNotes lookahead window
theme: "dark" | "light";
activeHands: Set<"left" | "right">;
volume: number; // 0-1
noteFilter: NoteFilter;
colorTheme: ColorTheme;
customColors: CustomColors;
layoutMode: LayoutMode;
metronomeEnabled: boolean;
// ── New settings ──────────────────────────────────────────────────────────
/** Note labels on the piano keyboard */
noteLabelMode: NoteLabelMode;
/** Note labels on the falling notes bars */
fallingNotesLabelMode: NoteLabelMode;
pianoTheme: "white" | "ivory";
showGrid: boolean;
showMeasureNums: boolean;
showKeySignature: boolean;
transposeSemitones: number; // -6..+6
speedTrainer: SpeedTrainerSettings;
countInBars: 0 | 1 | 2;
/** Which visual effect plays when a note reaches the hit line */
impactStyle: ImpactStyle;
sheetMusicWhiteBackground: boolean;
// ── MIDIano-inspired additions ────────────────────────────────────────────
/** Scroll wheel on the falling notes canvas seeks playback position */
scrollToSeek: boolean;
/** Minimum pixel height for note bars (prevents staccato notes becoming invisible) */
minNoteHeight: number; // 4-24
/** Border-radius for note bar corners (0 = sharp, 12 = pill) */
noteCornerRadius: number; // 0-12
/** Draw faint horizontal lines at each beat and measure boundary */
showBeatLines: boolean;
/** Show sustain pedal (CC64) regions as semi-transparent bands */
showSustainPedal: boolean;
/** Per-hand volume multiplier (0 = silent, 1 = full) */
handVolume: { left: number; right: number };
/** Active instrument for audio playback */
instrument: InstrumentId;
/** Which hand(s) trigger wait-mode pauses ("both" = default, all hands) */
waitForHand: "left" | "right" | "both";
/** Draw a colored outline stroke around falling note bars */
showNoteOutline: boolean;
// ── MIDIano live-app additions ────────────────────────────────────────────
/** Shift audio scheduling by ±ms to compensate for audio interface latency (positive = play earlier) */
renderOffset: number; // -200..+200 ms
/** Keep a ghost indicator for notes that have ended while sustain pedal is held */
showSustainedNotes: boolean;
}
export interface AppState {
// ── Document ─────────────────────────────────────────────────────────────
document: MusicDocument | null;
isLoadingDocument: boolean;
loadError: string | null;
/** Non-null during slow imports (e.g. OMR) to show a descriptive message */
loadingMessage: string | null;
// ── Playback ─────────────────────────────────────────────────────────────
status: PlaybackStatus;
currentSeconds: number;
tempoMultiplier: number;
loopStart: number | null;
loopEnd: number | null;
waitMode: boolean;
// ── Settings ─────────────────────────────────────────────────────────────
settings: AppSettings;
// ── MIDI device ──────────────────────────────────────────────────────────
midiDeviceName: string | null;
midiEnabled: boolean;
// ── Actions ──────────────────────────────────────────────────────────────
loadMidiFile: (file: File) => Promise<void>;
loadMusicXmlFile: (file: File) => Promise<void>;
loadPdfFile: (file: File) => Promise<void>;
play: () => void;
pause: () => void;
stop: () => void;
seek: (seconds: number) => void;
setTempoMultiplier: (v: number) => void;
setLoopPoints: (start: number | null, end: number | null) => void;
setWaitMode: (on: boolean) => void;
setActiveHands: (hands: Set<"left" | "right">) => void;
setMetronome: (on: boolean) => void;
updateSettings: (patch: Partial<AppSettings>) => void;
resetSettings: () => void;
setMidiDevice: (name: string | null) => void;
clearLoadError: () => void;
syncEngineState: () => void;
}
export const DEFAULT_SETTINGS: AppSettings = {
showFingering: false,
showHandColors: true,
useFlats: false,
viewportSeconds: 4.0,
theme: "light",
activeHands: new Set(["left", "right"]),
volume: 0.8,
noteFilter: "all",
colorTheme: "cascade",
customColors: { leftHand: "#9333ea", rightHand: "#22d3ee", unknown: "#a855f7" },
layoutMode: "falling",
metronomeEnabled: false,
// ── New defaults ──────────────────────────────────────────────────────────
noteLabelMode: "c_only",
fallingNotesLabelMode: "all",
pianoTheme: "white",
showGrid: false,
showMeasureNums: true,
showKeySignature: true,
transposeSemitones: 0,
speedTrainer: { enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60 },
countInBars: 0,
impactStyle: "bloom" as ImpactStyle,
sheetMusicWhiteBackground: false,
// ── MIDIano-inspired additions ────────────────────────────────────────────
scrollToSeek: false,
minNoteHeight: 8,
noteCornerRadius: 4,
showBeatLines: false,
showSustainPedal: false,
handVolume: { left: 1, right: 1 },
// ── "Consider later" additions ────────────────────────────────────────────
instrument: "grand" as InstrumentId,
waitForHand: "both" as "left" | "right" | "both",
showNoteOutline: false,
// ── MIDIano live-app additions ────────────────────────────────────────────
renderOffset: 0,
showSustainedNotes: false,
};
export const useAppStore = create<AppState>((set, get) => {
// Subscribe to SyncEngine state changes --> push into Zustand
syncEngine.onStateChange((s) => {
set({
status: s.status,
currentSeconds: s.currentSeconds,
tempoMultiplier: s.tempoMultiplier,
loopStart: s.loopStart,
loopEnd: s.loopEnd,
waitMode: s.waitMode,
});
});
// Speed trainer: keep currentPct in sync when the engine steps the tempo
bus.on("speedTrainer:stepped", ({ currentPct }) => {
set((s) => ({
tempoMultiplier: currentPct / 100,
settings: {
...s.settings,
speedTrainer: { ...s.settings.speedTrainer, currentPct },
},
}));
});
return {
document: null,
isLoadingDocument: false,
loadError: null,
loadingMessage: null,
status: "stopped",
currentSeconds: 0,
tempoMultiplier: 1.0,
loopStart: null,
loopEnd: null,
waitMode: false,
settings: DEFAULT_SETTINGS,
midiDeviceName: null,
midiEnabled: false,
loadMidiFile: async (file: File) => {
set({ isLoadingDocument: true, loadError: null });
try {
const rawBuffer = await file.arrayBuffer();
const id = crypto.randomUUID();
// M4 - strip path separators, control chars; cap length
const rawTitle = file.name.replace(/\.midi?$/i, "");
const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled";
// Slice a copy BEFORE transferring - postMessage() detaches (empties) the original
const bufferForXml = rawBuffer.slice(0);
// Parse in Web Worker (transfers rawBuffer ownership)
const doc = await parseMidiInWorker(rawBuffer, id, title);
set({ document: doc });
await syncEngine.loadDocument(doc);
// Request MusicXML from backend (non-blocking), using the copy
fetchMusicXml(bufferForXml, doc, id).catch(console.warn);
} catch (err) {
set({ loadError: String(err) });
} finally {
set({ isLoadingDocument: false });
}
},
loadMusicXmlFile: async (file: File) => {
set({ isLoadingDocument: true, loadError: null });
try {
const id = crypto.randomUUID();
// M4 - strip path separators, control chars; cap length
const rawTitle = file.name.replace(/\.(xml|mxl)$/i, "");
const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled";
const xmlBytes = await file.arrayBuffer();
// For .mxl (ZIP-compressed MusicXML) decompress in the browser so we
// always end up with a plain XML string. This is more reliable than
// relying on AlphaTab's internal ZIP reader (errors are swallowed there
// and produce a silent blank sheet view).
let musicXml: string | null;
if (file.name.toLowerCase().endsWith(".mxl")) {
const { extractXmlFromMxl } = await import("../utils/mxlExtract");
musicXml = await extractXmlFromMxl(xmlBytes);
} else {
musicXml = new TextDecoder().decode(xmlBytes);
}
// Stub document - sheet music renders immediately via musicXml string
const doc: import("@bach-to-basics/shared").MusicDocument = {
id, title,
sourceType: "musicxml",
musicXml,
midiBuffer: null,
notes: [],
tempoMap: [{ tick: 0, bpm: 120 }],
timeSignatures: [{ tick: 0, numerator: 4, denominator: 4 }],
totalDuration: 0,
ppq: 480,
keySignature: null,
youtubeId: null,
youtubeSyncOffset: 0,
fingeringVersion: "none",
};
set({ document: doc });
await syncEngine.loadDocument(doc);
// Fetch MIDI from backend for playback + falling notes (non-blocking).
// For .mxl files we send the already-extracted plain XML string (re-encoded
// to UTF-8) with a .xml extension, so the backend gets clean MusicXML rather
// than the ZIP container. This avoids cross-staff voice-duplication artefacts
// that music21 can produce when it parses a compressed .mxl directly.
const xmlBytesForBackend = (musicXml && file.name.toLowerCase().endsWith(".mxl"))
? new TextEncoder().encode(musicXml).buffer
: xmlBytes;
const filenameForBackend = file.name.toLowerCase().endsWith(".mxl")
? file.name.replace(/\.mxl$/i, ".xml")
: file.name;
fetchMidiFromXml(xmlBytesForBackend, filenameForBackend, doc, id).catch(console.warn);
} catch (err) {
set({ loadError: String(err) });
} finally {
set({ isLoadingDocument: false });
}
},
loadPdfFile: async (file: File) => {
const id = crypto.randomUUID();
// M4 - strip path separators and control chars; cap length
const rawTitle = file.name.replace(/\.pdf$/i, "");
const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled";
set({ isLoadingDocument: true, loadingMessage: "Reading sheet music\u2026", loadError: null });
try {
const form = new FormData();
form.append("file", file, file.name);
// OMR can take 10-120 s; no client-side fetch timeout -
// the server's subprocess timeout is the effective limit.
const res = await fetch("/api/omr/pdf2midi", { method: "POST", body: form });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { detail?: string };
throw new Error(body.detail ?? `Server error ${res.status}`);
}
const { musicxml, midi_b64 } = await res.json() as {
musicxml: string;
midi_b64: string;
filename: string;
};
// Decode base64 MIDI
const binaryStr = atob(midi_b64);
const midiBytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) midiBytes[i] = binaryStr.charCodeAt(i);
const midiBuffer = midiBytes.buffer;
const bufferForDoc = midiBuffer.slice(0);
// Parse notes from MIDI (same Web Worker path as loadMidiFile)
const doc = await parseMidiInWorker(midiBuffer, id, title);
// Attach MusicXML so sheet music renders immediately alongside playback.
// sourceType "musicxml" tells AlphaTab to render the score.
doc.musicXml = musicxml;
doc.sourceType = "musicxml";
set({ document: { ...doc, midiBuffer: bufferForDoc } });
await syncEngine.loadDocument({ ...doc, midiBuffer: bufferForDoc });
} catch (err) {
set({ loadError: String(err) });
} finally {
set({ isLoadingDocument: false, loadingMessage: null });
}
},
play: () => {
const { settings, status } = get();
// Speed trainer: seed the engine's current state before play
if (settings.speedTrainer.enabled) {
const st = settings.speedTrainer;
// If stopped (fresh start), reset to startPct; otherwise keep current
const currentPct = status === "stopped" ? st.startPct : st.currentPct;
syncEngine.setSpeedTrainer({ ...st, currentPct });
syncEngine.setTempoMultiplier(currentPct / 100);
set((s) => ({
tempoMultiplier: currentPct / 100,
settings: {
...s.settings,
speedTrainer: { ...s.settings.speedTrainer, currentPct },
},
}));
}
syncEngine.play();
},
pause: () => syncEngine.pause(),
stop: () => syncEngine.stop(),
seek: (seconds) => syncEngine.seek(seconds),
setTempoMultiplier: (v) => {
syncEngine.setTempoMultiplier(v);
set({ tempoMultiplier: v });
},
setLoopPoints: (start, end) => {
syncEngine.setLoopPoints(start, end);
set({ loopStart: start, loopEnd: end });
},
setWaitMode: (on) => {
syncEngine.setWaitMode(on);
set({ waitMode: on });
},
setMetronome: (on) => {
syncEngine.setMetronome(on);
set((s) => ({ settings: { ...s.settings, metronomeEnabled: on } }));
},
setActiveHands: (hands) => {
syncEngine.setActiveHands(hands);
set((s) => ({
settings: { ...s.settings, activeHands: hands },
}));
},
updateSettings: (patch) => {
set((s) => ({ settings: { ...s.settings, ...patch } }));
// Sync practice settings to the engine immediately
if ("transposeSemitones" in patch && patch.transposeSemitones !== undefined)
syncEngine.setTranspose(patch.transposeSemitones);
if ("countInBars" in patch && patch.countInBars !== undefined)
syncEngine.setCountInBars(patch.countInBars);
if ("speedTrainer" in patch && patch.speedTrainer !== undefined)
syncEngine.setSpeedTrainer(patch.speedTrainer);
if ("handVolume" in patch && patch.handVolume !== undefined)
syncEngine.setHandVolume(patch.handVolume);
if ("instrument" in patch && patch.instrument !== undefined)
syncEngine.setInstrument(patch.instrument).catch(console.warn);
if ("waitForHand" in patch && patch.waitForHand !== undefined)
syncEngine.setWaitForHand(patch.waitForHand);
if ("renderOffset" in patch && patch.renderOffset !== undefined)
syncEngine.setRenderOffset(patch.renderOffset);
},
resetSettings: () => {
set({ settings: { ...DEFAULT_SETTINGS } });
// Re-sync engine with defaults
syncEngine.setTranspose(DEFAULT_SETTINGS.transposeSemitones);
syncEngine.setCountInBars(DEFAULT_SETTINGS.countInBars);
syncEngine.setSpeedTrainer(DEFAULT_SETTINGS.speedTrainer);
syncEngine.setMetronome(DEFAULT_SETTINGS.metronomeEnabled);
syncEngine.setActiveHands(DEFAULT_SETTINGS.activeHands);
syncEngine.setHandVolume(DEFAULT_SETTINGS.handVolume);
syncEngine.setWaitForHand(DEFAULT_SETTINGS.waitForHand);
syncEngine.setInstrument(DEFAULT_SETTINGS.instrument).catch(console.warn);
syncEngine.setRenderOffset(DEFAULT_SETTINGS.renderOffset);
},
setMidiDevice: (name) => set({ midiDeviceName: name, midiEnabled: name !== null }),
clearLoadError: () => set({ loadError: null }),
syncEngineState: () => {
const s = syncEngine.state;
set({
status: s.status,
currentSeconds: s.currentSeconds,
tempoMultiplier: s.tempoMultiplier,
loopStart: s.loopStart,
loopEnd: s.loopEnd,
waitMode: s.waitMode,
});
},
};
});
// ── Helpers ──────────────────────────────────────────────────────────────────
function parseMidiInWorker(
buffer: ArrayBuffer,
id: string,
title: string
): Promise<MusicDocument> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("../workers/midi.worker.ts", import.meta.url), {
type: "module",
});
// M3 - kill the worker if it hangs (e.g. malformed MIDI, infinite loop)
const timeout = setTimeout(() => {
worker.terminate();
reject(new Error("MIDI parsing timed out"));
}, 30_000);
worker.onmessage = (e) => {
clearTimeout(timeout);
worker.terminate();
if (e.data.ok) resolve(e.data.doc as MusicDocument);
else reject(new Error(e.data.error));
};
worker.onerror = (e) => {
clearTimeout(timeout);
worker.terminate();
reject(e);
};
worker.postMessage({ buffer, id, title }, [buffer]);
});
}
async function fetchMidiFromXml(
xmlBytes: ArrayBuffer,
filename: string,
doc: import("@bach-to-basics/shared").MusicDocument,
id: string
): Promise<void> {
const blob = new Blob([xmlBytes], { type: "application/xml" });
const form = new FormData();
form.append("file", blob, filename);
const res = await fetch("/api/transcribe/musicxml2midi", { method: "POST", body: form });
if (!res.ok) return;
const midiBuffer = await res.arrayBuffer();
const bufferForDoc = midiBuffer.slice(0);
// Parse notes from the returned MIDI
const updatedDoc = await parseMidiInWorker(midiBuffer, id, doc.title);
updatedDoc.musicXml = doc.musicXml;
updatedDoc.sourceType = "musicxml";
const { document: currentDoc } = useAppStore.getState();
if (currentDoc?.id === id) {
useAppStore.setState({ document: { ...updatedDoc, midiBuffer: bufferForDoc } });
await syncEngine.loadDocument({ ...updatedDoc, midiBuffer: bufferForDoc });
}
}
async function fetchMusicXml(
buffer: ArrayBuffer,
doc: MusicDocument,
_id: string
): Promise<void> {
const blob = new Blob([buffer], { type: "audio/midi" });
const form = new FormData();
form.append("file", blob, "track.mid");
const titleParam = encodeURIComponent(doc.title ?? "");
const res = await fetch(`/api/transcribe/midi2musicxml?title=${titleParam}`, { method: "POST", body: form });
if (!res.ok) return;
const { musicxml } = await res.json() as { musicxml: string };
doc.musicXml = musicxml;
// Notify sheet view that MusicXML is now available
const { document: currentDoc } = useAppStore.getState();
if (currentDoc?.id === doc.id) {
useAppStore.setState({ document: { ...doc } });
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Shared color utility functions used by the PIXI renderers.
*/
/**
* Multiply each RGB channel of a packed hex color by factor f, clamped to 255.
* Used to generate lighter/darker variants of palette colors.
*/
export function lighten(hex: number, f: number): number {
return (
(Math.min(255, Math.round(((hex >> 16) & 0xff) * f)) << 16) |
(Math.min(255, Math.round(((hex >> 8) & 0xff) * f)) << 8) |
Math.min(255, Math.round((hex & 0xff) * f))
);
}
/**
* Convert a CSS hex color string ("#rrggbb") to a packed numeric color.
*/
export function hexToNum(hex: string): number {
return parseInt(hex.replace("#", ""), 16);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Browser-side .mxl extractor.
*
* .mxl (compressed MusicXML) is a ZIP archive containing:
* META-INF/container.xml — lists the root MusicXML file
* <root>.xml — the actual MusicXML document
*
* We parse the ZIP central directory to locate files, then decompress each
* DEFLATE-compressed entry with the browser-native DecompressionStream API
* (available in Chrome 80+, Firefox 113+, Safari 16.4+).
*
* Why not rely on AlphaTab's built-in .mxl support?
* AlphaTab's MusicXmlImporter can parse .mxl, but it runs synchronously inside
* a try/catch; if its ZIP reader or XML parser chokes on any detail of this
* specific file (e.g. MuseScore 2 export quirks) the error is swallowed and
* the sheet view silently stays blank. Extracting ourselves gives us a plain
* XML string that takes the battle-tested string path in SheetMusicView.
*/
const SIG_LOCAL = 0x04034b50; // PK\x03\x04
const SIG_CENTRAL = 0x02014b50; // PK\x01\x02
const SIG_EOCD = 0x06054b50; // PK\x05\x06
interface ZipEntry {
name: string;
localOffset: number;
}
/** Scan for the End-of-Central-Directory record (last PK\x05\x06 in the file). */
function findEocd(bytes: Uint8Array): number {
for (let i = bytes.length - 22; i >= 0; i--) {
if (
bytes[i] === 0x50 && bytes[i + 1] === 0x4b &&
bytes[i + 2] === 0x05 && bytes[i + 3] === 0x06
) return i;
}
return -1;
}
/** Read central-directory entries to build a name→localOffset map. */
function readCentralDirectory(view: DataView, bytes: Uint8Array): ZipEntry[] {
const eocd = findEocd(bytes);
if (eocd === -1) throw new Error("Not a ZIP archive");
if (view.getUint32(eocd, true) !== SIG_EOCD) throw new Error("Bad EOCD signature");
const numEntries = view.getUint16(eocd + 10, true);
let cdPos = view.getUint32(eocd + 16, true);
const entries: ZipEntry[] = [];
for (let i = 0; i < numEntries; i++) {
if (view.getUint32(cdPos, true) !== SIG_CENTRAL) break;
const fileNameLen = view.getUint16(cdPos + 28, true);
const extraLen = view.getUint16(cdPos + 30, true);
const commentLen = view.getUint16(cdPos + 32, true);
const localOffset = view.getUint32(cdPos + 42, true);
const name = new TextDecoder().decode(bytes.slice(cdPos + 46, cdPos + 46 + fileNameLen));
entries.push({ name, localOffset });
cdPos += 46 + fileNameLen + extraLen + commentLen;
}
return entries;
}
/** Decompress one local-file entry, returning its raw bytes. */
async function readLocalEntry(
view: DataView,
bytes: Uint8Array,
entry: ZipEntry,
): Promise<Uint8Array> {
const off = entry.localOffset;
if (view.getUint32(off, true) !== SIG_LOCAL) {
throw new Error(`Bad local header at offset ${off}`);
}
const compressionMethod = view.getUint16(off + 8, true);
const compressedSize = view.getUint32(off + 18, true);
const fileNameLen = view.getUint16(off + 26, true);
const extraLen = view.getUint16(off + 28, true);
const dataStart = off + 30 + fileNameLen + extraLen;
const compressed = bytes.slice(dataStart, dataStart + compressedSize);
if (compressionMethod === 0) {
// Stored — no compression
return compressed;
}
if (compressionMethod !== 8) {
throw new Error(`Unsupported ZIP compression method ${compressionMethod}`);
}
// DEFLATE (raw, no zlib header) — decompress with browser-native stream.
// Cap decompressed size to 50 MB to prevent ZIP-bomb attacks: a crafted
// .mxl with a tiny compressed payload that expands to gigabytes would
// otherwise freeze or crash the browser tab.
const MAX_DECOMPRESSED = 50 * 1024 * 1024; // 50 MB - largest real scores are <5 MB
const ds = new DecompressionStream("deflate-raw");
const writer = ds.writable.getWriter();
const reader = ds.readable.getReader();
writer.write(compressed);
writer.close();
const chunks: Uint8Array[] = [];
let totalDecompressed = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalDecompressed += value.length;
if (totalDecompressed > MAX_DECOMPRESSED) {
throw new Error("Decompressed MusicXML exceeds 50 MB — possible ZIP bomb");
}
chunks.push(value);
}
const total = chunks.reduce((n, c) => n + c.length, 0);
const result = new Uint8Array(total);
let cursor = 0;
for (const chunk of chunks) {
result.set(chunk, cursor);
cursor += chunk.length;
}
return result;
}
/**
* Extract the MusicXML string from a .mxl (ZIP-compressed MusicXML) ArrayBuffer.
* Throws if the archive is malformed or missing required files.
*/
export async function extractXmlFromMxl(buffer: ArrayBuffer): Promise<string> {
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
const entries = readCentralDirectory(view, bytes);
const find = (name: string) => entries.find(e => e.name === name);
// 1. Read META-INF/container.xml
const containerEntry = find("META-INF/container.xml");
if (!containerEntry) throw new Error("Not a valid .mxl file: missing META-INF/container.xml");
const containerBytes = await readLocalEntry(view, bytes, containerEntry);
const containerXml = new TextDecoder("utf-8").decode(containerBytes);
// 2. Parse the root-file path (e.g. full-path="score.xml")
const match = containerXml.match(/full-path="([^"]+)"/);
if (!match) throw new Error('Malformed container.xml: no full-path attribute');
const rootPath = match[1];
// 3. Read the root MusicXML entry
const rootEntry = find(rootPath);
if (!rootEntry) throw new Error(`Root file "${rootPath}" not found in .mxl archive`);
const xmlBytes = await readLocalEntry(view, bytes, rootEntry);
return new TextDecoder("utf-8").decode(xmlBytes);
}
+619
View File
@@ -0,0 +1,619 @@
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { FallingNotesView } from "../components/FallingNotes/FallingNotesView";
import { PianoKeyboardView } from "../components/PianoKeyboard/PianoKeyboardView";
import { SheetMusicView } from "../components/SheetMusic/SheetMusicView";
import { TransportControls } from "../components/Transport/TransportControls";
import { SettingsPanel } from "../components/Transport/SettingsPanel";
import { DevicePanel } from "../components/DevicePanel/DevicePanel";
import { useAppStore } from "../store/useAppStore";
import type { LayoutMode } from "../store/useAppStore";
import bus from "../engine/EventBus";
import { midiToPitch, keySignatureToLabel } from "@bach-to-basics/shared";
import { syncEngine } from "../engine/SyncEngine";
const TAB_LABELS: Record<LayoutMode, string> = {
piano: "Piano",
falling: "Notes",
sheet: "Sheet",
all: "All",
};
const TAB_TITLES: Record<LayoutMode, string> = {
piano: "Show only the piano keyboard view",
falling: "Show only the falling notes view",
sheet: "Show only the sheet music view",
all: "Show all views at once",
};
export function PracticeView() {
const [settingsOpen, setSettingsOpen] = useState(false);
const {
loadMidiFile, loadMusicXmlFile, loadPdfFile,
settings, updateSettings,
status, play, pause, seek,
loadError, clearLoadError,
document: doc,
isLoadingDocument,
} = useAppStore();
// Pre-warm audio so piano keys respond with minimal latency.
useEffect(() => {
syncEngine.audio.load().catch(() => {});
const preWarm = () => syncEngine.audio.load().catch(() => {});
document.addEventListener("pointermove", preWarm, { once: true });
return () => document.removeEventListener("pointermove", preWarm);
}, []);
// Apply theme to <html> element
useEffect(() => {
document.documentElement.dataset.theme = settings.theme;
}, [settings.theme]);
// Spacebar: play / pause
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (e.code !== "Space" || tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
e.preventDefault();
if (status === "playing") pause();
else play();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [status, play, pause]);
// ArrowLeft / ArrowRight: seek to prev / next note onset
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return;
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
e.preventDefault();
const { document: d, currentSeconds } = useAppStore.getState();
if (!d?.notes.length) return;
const onsets = [...new Set(d.notes.map((n) => n.startSeconds))].sort((a, b) => a - b);
if (e.code === "ArrowRight") {
const next = onsets.find((t) => t > currentSeconds + 0.05);
if (next !== undefined) seek(next);
} else {
const prev = [...onsets].reverse().find((t) => t < currentSeconds - 0.05);
if (prev !== undefined) seek(prev);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [seek]);
const loadFile = useCallback((file: File) => {
if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file);
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
}, [loadMidiFile, loadMusicXmlFile, loadPdfFile]);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) loadFile(file);
}, [loadFile]);
const { layoutMode } = settings;
const setLayout = (mode: LayoutMode) => updateSettings({ layoutMode: mode });
return (
<div
className="flex flex-col h-screen"
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
{/* ── Header ─────────────────────────────────────────────────────────── */}
{/* Flex layout with absolutely-centered tabs:
- Left group: flex, takes natural width (never truncates)
- Center tabs: position:absolute left:50% transform:translateX(-50%) - true viewport center
- Right group: marginLeft:auto, pushes to far right
This pattern is immune to left/right asymmetry that breaks grid-based centering. */}
<header
className="flex-shrink-0 flex items-center"
style={{
position: "relative",
height: 44,
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
paddingLeft: 10,
paddingRight: 10,
}}
>
{/* Left: gradient logo + app name → links to GitHub repo */}
<a
href="https://github.com/gigliof/bach-to-basics"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 shrink-0"
style={{ textDecoration: "none" }}
aria-label="Bach to Basics on GitHub"
>
<div
role="img"
aria-hidden="true"
className="flex items-center justify-center shrink-0 select-none"
style={{
width: 32, height: 32,
background: "linear-gradient(135deg, #7c3aed 0%, #9333ea 100%)",
borderRadius: 8,
boxShadow: "0 3px 10px rgba(147,51,234,0.45)",
}}
>
<PianoKeysIcon />
</div>
{/* Typographic pun: "Bach" and "Basics" bold, "to" muted */}
<span style={{ fontSize: 15, whiteSpace: "nowrap", letterSpacing: "-0.3px", lineHeight: 1 }}>
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>Bach</span>
<span style={{ fontWeight: 400, color: "var(--color-text-muted)", margin: "0 3px" }}>to</span>
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>Basics</span>
</span>
</a>
{/* Center: layout mode tabs - absolutely centered in the header */}
<div
className="flex items-center"
style={{ position: "absolute", left: "50%", transform: "translateX(-50%)" }}
>
<div
className="flex items-center"
style={{ padding: 3, background: "var(--color-surface-2)", borderRadius: 999, border: "1px solid var(--color-border)" }}
>
{(["piano", "falling", "sheet", "all"] as LayoutMode[]).map((mode) => (
<button
key={mode}
onClick={() => setLayout(mode)}
title={TAB_TITLES[mode]}
aria-label={TAB_TITLES[mode]}
className="transition-all font-semibold"
style={{
padding: "4px 12px",
fontSize: 12,
borderRadius: 999,
border: "none",
background: layoutMode === mode ? "var(--color-accent)" : "transparent",
color: layoutMode === mode ? "#ffffff" : "var(--color-text-muted)",
boxShadow: layoutMode === mode ? "0 0 12px rgba(147,51,234,0.35)" : "none",
cursor: "pointer",
}}
>
{TAB_LABELS[mode]}
</button>
))}
</div>
</div>
{/* Right: MIDI device chip + theme toggle + settings gear */}
<div className="flex items-center gap-1.5" style={{ marginLeft: "auto" }}>
<DevicePanel />
<button
onClick={() => updateSettings({ theme: settings.theme === "dark" ? "light" : "dark" })}
title={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
aria-label={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className="flex items-center justify-center rounded transition-colors shrink-0"
style={{ width: 30, height: 30, background: "var(--color-surface-2)", border: "1px solid var(--color-border)", color: "var(--color-text-muted)" }}
>
{settings.theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
<button
onClick={() => setSettingsOpen(true)}
title="Settings"
aria-label="Open settings"
className="flex items-center justify-center rounded transition-colors shrink-0"
style={{ width: 30, height: 30, background: "var(--color-surface-2)", border: "1px solid var(--color-border)", color: "var(--color-text-muted)" }}
>
<GearIcon />
</button>
</div>
</header>
{/* ── Main content - layout-aware ────────────────────────────────────── */}
<div className="flex flex-1 overflow-hidden min-h-0 relative">
{layoutMode === "piano" && (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<PianoModeBackground />
<PianoKeyboardView />
</div>
)}
{layoutMode === "falling" && (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<FallingNotesView />
<PianoKeyboardView />
</div>
)}
{layoutMode === "sheet" && (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<SheetMusicView />
<PianoKeyboardView />
</div>
)}
{layoutMode === "all" && (
<>
<div
className="flex flex-col flex-shrink-0 min-h-0 overflow-hidden relative"
style={{ flex: "0 0 42%", minWidth: 260, maxWidth: 440 }}
>
<SheetMusicView />
</div>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<FallingNotesView />
<PianoKeyboardView />
</div>
</>
)}
</div>
{/* ── Empty state overlay ────────────────────────────────────────────── */}
{!doc && !isLoadingDocument && (
<EmptyState onImport={loadFile} />
)}
{/* ── Error toast ────────────────────────────────────────────────────── */}
{loadError && <ErrorToast message={loadError} onDismiss={clearLoadError} />}
{/* ── Transport bar ──────────────────────────────────────────────────── */}
<TransportControls />
{/* ── Status bar ─────────────────────────────────────────────────────── */}
<StatusBar />
{/* ── Settings panel (managed here, gear is in header) ───────────────── */}
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div>
);
}
// ── Empty state ───────────────────────────────────────────────────────────────
function EmptyState({ onImport }: { onImport: (file: File) => void }) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 10,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 20,
textAlign: "center",
padding: 40,
pointerEvents: "none",
}}
>
{/* Illustration */}
<div
style={{
width: 72, height: 72,
borderRadius: 18,
background: "linear-gradient(135deg, rgba(147,51,234,0.25) 0%, rgba(147,51,234,0.10) 100%), var(--color-notes-bg)",
border: "1px solid rgba(147,51,234,0.25)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "var(--color-accent-text)",
}}
>
<MusicNoteIcon />
</div>
<div style={{ maxWidth: 300 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text)", marginBottom: 8 }}>
No file loaded
</div>
<div style={{ fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
Import a MIDI, MusicXML, or PDF sheet music file to start practicing
</div>
</div>
{/* Primary CTA */}
<button
onClick={() => fileInputRef.current?.click()}
style={{
pointerEvents: "all",
display: "flex", alignItems: "center", gap: 8,
padding: "11px 28px",
background: "var(--color-accent)",
color: "#fff",
border: "none",
borderRadius: 10,
fontSize: 14, fontWeight: 600,
cursor: "pointer",
boxShadow: "0 0 24px rgba(147,51,234,0.45)",
fontFamily: "inherit",
transition: "all 0.15s",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)"; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 34px rgba(147,51,234,0.6)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = ""; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 24px rgba(147,51,234,0.45)"; }}
>
<UploadIconLg />
Import a file
</button>
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: -10 }}>
or drag &amp; drop anywhere
</span>
<input
ref={fileInputRef}
type="file"
accept=".mid,.midi,.xml,.mxl,.pdf"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }}
/>
</div>
);
}
const UploadIconLg = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/>
</svg>
);
// ── Piano-mode background ─────────────────────────────────────────────────────
// Mirrors the grid drawn by FallingNotesRenderer so the piano-only view has the
// same dark background + black-key lanes + octave separator lines.
const MIDI_MIN_BG = 21; // A0
const MIDI_MAX_BG = 108; // C8
/** Precompute whiteKeyIndex (cumulative white-key counter per MIDI note). */
function buildWhiteKeyIndex() {
const isBlk = (m: number) => [1,3,6,8,10].includes(m % 12);
const idx = new Array<number>(MIDI_MAX_BG + 1).fill(0);
let wi = 0;
for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) {
idx[m] = wi;
if (!isBlk(m)) wi++;
}
return { idx, totalWhite: wi };
}
const { idx: wkIdx, totalWhite: TOTAL_WHITE_BG } = buildWhiteKeyIndex();
function drawPianoGrid(canvas: HTMLCanvasElement) {
const { width, height } = canvas;
if (width === 0 || height === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const isBlk = (m: number) => [1,3,6,8,10].includes(m % 12);
const wW = width / TOTAL_WHITE_BG;
// Clear to transparent - the CSS background on the parent div provides the colour.
ctx.clearRect(0, 0, width, height);
// Faint octave separator lines at every C - neutral, works on dark and light
ctx.fillStyle = "rgba(128,128,144,0.2)";
for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) {
if (m % 12 === 0 && !isBlk(m)) {
const x = wkIdx[m] * wW;
ctx.fillRect(x, 0, 1, height);
}
}
}
function PianoModeBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { settings } = useAppStore();
// Reusable draw callback
const redraw = useMemo(() => () => {
const canvas = canvasRef.current;
if (!canvas) return;
drawPianoGrid(canvas);
}, []);
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
canvas.width = Math.round(width);
canvas.height = Math.round(height);
if (settings.showGrid) redraw();
});
ro.observe(container);
// Initial size
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
if (settings.showGrid) redraw();
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [redraw]);
// Redraw when showGrid toggles
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
if (settings.showGrid) {
redraw();
} else {
const ctx = canvas.getContext("2d");
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}, [settings.showGrid, redraw]);
return (
<div
ref={containerRef}
className="flex-1 relative"
style={{
minHeight: 0,
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%" }}
/>
</div>
);
}
// ── Status bar ────────────────────────────────────────────────────────────────
function StatusBar() {
const { document: doc, currentSeconds, tempoMultiplier, status } = useAppStore();
const [lastNote, setLastNote] = useState<string>("");
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const onNoteOn = ({ midi }: { midi: number; velocity: number }) => {
setLastNote(midiToPitch(midi));
if (clearTimerRef.current) clearTimeout(clearTimerRef.current);
clearTimerRef.current = setTimeout(() => setLastNote(""), 2000);
};
bus.on("input:note:on", onNoteOn as never);
return () => {
bus.off("input:note:on", onNoteOn as never);
if (clearTimerRef.current) clearTimeout(clearTimerRef.current);
};
}, []);
const bpm = doc?.tempoMap[0]?.bpm ?? null;
const effectiveBpm = bpm ? Math.round(bpm * tempoMultiplier) : null;
const fmt = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, "0")}`;
const keySig = doc?.keySignature ? keySignatureToLabel(doc.keySignature) : null;
const isActive = status === "playing" || status === "paused";
return (
<div
className="flex items-center gap-3 border-t flex-shrink-0 min-w-0"
style={{ height: 26, background: "var(--color-surface)", borderColor: "var(--color-border-subtle)", fontSize: 11, color: "var(--color-text-muted)", fontVariantNumeric: "tabular-nums", paddingLeft: 10, paddingRight: 12 }}
>
{doc ? (
<>
{isActive && (
<span style={{ color: "var(--color-accent)", fontWeight: 600, flexShrink: 0 }}>Playing</span>
)}
<span className="truncate" style={{ minWidth: 0 }}>{doc.title}</span>
{keySig && <><Dot /><span className="shrink-0">{keySig}</span></>}
{effectiveBpm !== null && <><Dot /><span className="shrink-0">{effectiveBpm} BPM</span></>}
{lastNote && <><Dot /><span className="shrink-0" style={{ color: "var(--color-accent)" }}>{lastNote}</span></>}
</>
) : (
<span>No file loaded - drag &amp; drop or click ↑ Import</span>
)}
<div className="flex-1 min-w-0" />
{doc && <span className="shrink-0">{fmt(currentSeconds)} / {fmt(doc.totalDuration)}</span>}
</div>
);
}
const Dot = () => <span style={{ color: "var(--color-text-dim)" }}>·</span>;
// ── Error toast ───────────────────────────────────────────────────────────────
// Shown whenever loadError is non-null. Dismissible with ✕ or auto-dismissed
// after 12 s so it doesn't block the UI forever.
function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () => void }) {
useEffect(() => {
// Longer timeout for multi-line messages (setup instructions, etc.)
const delay = message.includes("\n") ? 30_000 : 12_000;
const t = setTimeout(onDismiss, delay);
return () => clearTimeout(t);
}, [message, onDismiss]);
return (
<div
role="alert"
style={{
position: "fixed",
bottom: 68, // above the transport bar
left: "50%",
transform: "translateX(-50%)",
zIndex: 2000,
maxWidth: 520,
width: "calc(100% - 32px)",
background: "var(--color-surface)",
border: "1px solid rgba(239,68,68,0.5)",
borderRadius: 10,
boxShadow: "0 8px 32px rgba(0,0,0,0.45)",
padding: "10px 14px",
display: "flex",
alignItems: "flex-start",
gap: 10,
fontSize: 12,
color: "var(--color-text)",
lineHeight: 1.5,
}}
>
{/* Warning icon */}
<span style={{ color: "#f87171", fontSize: 16, lineHeight: 1.2, flexShrink: 0 }}>⚠</span>
<span style={{ flex: 1, wordBreak: "break-word" }}>
{message.split("\n").map((line, i) => (
<span key={i}>{line}{i < message.split("\n").length - 1 && <br />}</span>
))}
</span>
<button
onClick={onDismiss}
aria-label="Dismiss error"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--color-text-muted)",
fontSize: 16,
lineHeight: 1,
flexShrink: 0,
padding: "0 2px",
}}
>
✕
</button>
</div>
);
}
// ── Icons ─────────────────────────────────────────────────────────────────────
const MusicNoteIcon = () => (
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>
);
/** Piano keyboard icon: 5 white keys with 3 black keys (C-D-E-F-G pattern) */
const PianoKeysIcon = () => (
<svg width="18" height="14" viewBox="0 0 17 14" fill="none" aria-hidden="true">
{/* White keys */}
<rect x="0" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/>
<rect x="3.5" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/>
<rect x="7" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/>
<rect x="10.5" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/>
<rect x="14" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/>
{/* Black keys */}
<rect x="2" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)"/>
<rect x="5.5" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)"/>
<rect x="12.5" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)"/>
</svg>
);
const SunIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2v-2H2v2zm18 0h2v-2h-2v2zM11 2v2h2V2h-2zm0 18v2h2v-2h-2zM5.64 6.35 4.22 4.93a1 1 0 0 0-1.41 1.41l1.41 1.42 1.42-1.41zM18.36 17.65l1.42 1.41a1 1 0 0 0 1.41-1.41l-1.41-1.42-1.42 1.42zM5.64 17.66l-1.42 1.41a1 1 0 0 1-1.41-1.41l1.41-1.42 1.42 1.42zM19.78 4.93l-1.42 1.42-1.41-1.42 1.42-1.41a1 1 0 0 1 1.41 1.41z"/>
</svg>
);
const MoonIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/>
</svg>
);
const GearIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
);
+133
View File
@@ -0,0 +1,133 @@
// Web Worker: parses MIDI ArrayBuffer --> NoteEvent[] + metadata
// Uses @tonejs/midi for parsing (synchronous, no nested worker issues).
import { Midi } from "@tonejs/midi";
import type { NoteEvent, TempoEvent, TimeSignatureEvent, MusicDocument, KeySignature, SustainRange } from "@bach-to-basics/shared";
import { midiToPitch } from "@bach-to-basics/shared";
self.onmessage = (e: MessageEvent<{ buffer: ArrayBuffer; id: string; title: string }>) => {
const { buffer, id } = e.data;
// L5 - strip control characters that could corrupt downstream metadata
const title = e.data.title.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
try {
const midi = new Midi(buffer);
const ppq = midi.header.ppq;
// ── Tempo map ────────────────────────────────────────────────────────────
const tempoMap: TempoEvent[] = midi.header.tempos.map((t) => ({
tick: t.ticks,
bpm: t.bpm,
}));
if (tempoMap.length === 0) tempoMap.push({ tick: 0, bpm: 120 });
// ── Time signatures ──────────────────────────────────────────────────────
const timeSignatures: TimeSignatureEvent[] = midi.header.timeSignatures.map((ts) => ({
tick: ts.ticks,
numerator: ts.timeSignature[0],
denominator: ts.timeSignature[1],
}));
if (timeSignatures.length === 0) timeSignatures.push({ tick: 0, numerator: 4, denominator: 4 });
// ── Key signature ────────────────────────────────────────────────────────
// @tonejs/midi exposes header.keySignatures as { ticks, key, scale }[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rawKeySigs: any[] = (midi.header as any).keySignatures ?? [];
let keySignature: KeySignature | null = null;
if (rawKeySigs.length > 0) {
const first = rawKeySigs[0];
// L5 - sanitize key field: allow only valid key-signature characters
const rawKey = String(first.key ?? "C");
const safeKey = rawKey.replace(/[^A-Ga-g#b]/g, "").slice(0, 3) || "C";
keySignature = {
key: safeKey,
scale: first.scale === "minor" ? "minor" : "major",
};
}
// ── Note events ──────────────────────────────────────────────────────────
const notes: NoteEvent[] = [];
let idCounter = 0;
for (let trackIdx = 0; trackIdx < midi.tracks.length; trackIdx++) {
const track = midi.tracks[trackIdx];
// Hand assignment by track: track 0 = right, track 1 = left (standard)
let hand: NoteEvent["hand"] = "unknown";
if (midi.tracks.length >= 2) {
hand = trackIdx === 0 ? "right" : "left";
}
for (const note of track.notes) {
notes.push({
id: `n${idCounter++}`,
midi: note.midi,
pitch: midiToPitch(note.midi),
startTick: note.ticks,
durationTick: note.durationTicks,
startSeconds: note.time, // @tonejs/midi computes this
endSeconds: note.time + note.duration,
hand,
finger: null,
velocity: Math.round(note.velocity * 127),
channel: 0,
});
}
}
notes.sort((a, b) => a.startSeconds - b.startSeconds);
const totalDuration = notes.length > 0
? Math.max(...notes.map((n) => n.endSeconds))
: 0;
// ── Sustain pedal (CC64) to sustain ranges ───────────────────────────────
// @tonejs/midi normalises CC values to 0-1; ≥0.5 means pedal is depressed.
const sustainRanges: SustainRange[] = [];
const allCC64: { time: number; value: number }[] = [];
for (const track of midi.tracks) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cc64: any[] = (track.controlChanges as any)[64] ?? [];
for (const cc of cc64) {
allCC64.push({ time: cc.time as number, value: cc.value as number });
}
}
allCC64.sort((a, b) => a.time - b.time);
let sustainStart: number | null = null;
for (const cc of allCC64) {
const isOn = cc.value >= 0.5;
if (isOn && sustainStart === null) {
sustainStart = cc.time;
} else if (!isOn && sustainStart !== null) {
sustainRanges.push({ startSeconds: sustainStart, endSeconds: cc.time });
sustainStart = null;
}
}
if (sustainStart !== null) {
sustainRanges.push({ startSeconds: sustainStart, endSeconds: totalDuration });
}
const doc: MusicDocument = {
id,
title,
sourceType: "midi",
musicXml: null,
midiBuffer: buffer,
notes,
tempoMap,
timeSignatures,
totalDuration,
ppq,
keySignature,
youtubeId: null,
youtubeSyncOffset: 0,
fingeringVersion: "none",
sustainRanges,
};
self.postMessage({ ok: true, doc });
} catch (err) {
self.postMessage({ ok: false, error: String(err) });
}
};