Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user