Initial commit

This commit is contained in:
Fabricio Giglio
2026-05-06 17:56:38 +02:00
commit 8f4df1451e
78 changed files with 12130 additions and 0 deletions
@@ -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>
);
}