Add SECURITY.md, update CONTRIBUTING, issue templates, Lighthouse fixes

This commit is contained in:
Fabricio Giglio
2026-05-06 22:38:17 +02:00
parent 451054c559
commit 4bb718ae08
14 changed files with 1115 additions and 517 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Got an idea? Check the [roadmap](https://github.com/gigliof/bach-to-basics#roadmap) first — some features are already planned. Got an idea? Check the [roadmap](https://github.com/gigliof/bach-to-basics#roadmap) first - some features are already planned.
- type: textarea - type: textarea
id: problem id: problem
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs: jobs:
frontend: frontend:
name: Frontend — type-check & test name: Frontend - type-check & test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1 -1
View File
@@ -134,7 +134,7 @@ All backend settings come from environment variables. Copy `.env.example` to `.e
| -------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `ALLOWED_ORIGINS` | `http://localhost:5173` | Comma-separated list of allowed CORS origins | | `ALLOWED_ORIGINS` | `http://localhost:5173` | Comma-separated list of allowed CORS origins |
| `BACKEND_API_KEY` | _(unset = open)_ | When set, all requests must include `X-API-Key: <value>` | | `BACKEND_API_KEY` | _(unset = open)_ | When set, all requests must include `X-API-Key: <value>` |
| `REQUIRE_AUTH` | _(unset)_ | Set to `1` to make the server refuse to start if `BACKEND_API_KEY` is not configured — useful for preventing accidental open deployments | | `REQUIRE_AUTH` | _(unset)_ | Set to `1` to make the server refuse to start if `BACKEND_API_KEY` is not configured - useful for preventing accidental open deployments |
| `RATE_LIMIT_PER_MIN` | `60` | Max requests per IP per minute. `0` disables it | | `RATE_LIMIT_PER_MIN` | `60` | Max requests per IP per minute. `0` disables it |
| `HEAVY_RATE_LIMIT_PER_MIN` | `10` | Stricter limit applied to expensive endpoints (`/omr/`, `/youtube/`) | | `HEAVY_RATE_LIMIT_PER_MIN` | `10` | Stricter limit applied to expensive endpoints (`/omr/`, `/youtube/`) |
| `TRUSTED_PROXY_IPS` | _(unset)_ | Comma-separated IPs of trusted reverse proxies. When set, the real client IP is read from `X-Forwarded-For` instead of the connection address | | `TRUSTED_PROXY_IPS` | _(unset)_ | Comma-separated IPs of trusted reverse proxies. When set, the real client IP is read from `X-Forwarded-For` instead of the connection address |
+2 -2
View File
@@ -2,7 +2,7 @@
## Scope ## Scope
Bach to Basics handles no user accounts, no personal data, and no financial information. It is a local music practice tool — the main attack surface is **file processing** (MIDI, MusicXML, PDF uploads) and the **FastAPI backend** when self-hosted. Bach to Basics handles no user accounts, no personal data, and no financial information. It is a local music practice tool - the main attack surface is **file processing** (MIDI, MusicXML, PDF uploads) and the **FastAPI backend** when self-hosted.
## What to report ## What to report
@@ -23,6 +23,6 @@ Use **GitHub's private vulnerability reporting**:
1. Go to the [Security tab](https://github.com/gigliof/bach-to-basics/security) of this repo 1. Go to the [Security tab](https://github.com/gigliof/bach-to-basics/security) of this repo
2. Click **"Report a vulnerability"** 2. Click **"Report a vulnerability"**
3. Fill in the details — steps to reproduce, impact, any suggested fix 3. Fill in the details - steps to reproduce, impact, any suggested fix
You'll get a response within a few days. There's no formal embargo policy given the nature of this project, but please give a reasonable heads-up before going public. You'll get a response within a few days. There's no formal embargo policy given the nature of this project, but please give a reasonable heads-up before going public.
+1
View File
@@ -8,6 +8,7 @@
tighten by moving it to a separate .js file if desired. --> tighten by moving it to a separate .js file if desired. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' https://gleitz.github.io https://danigb.github.io https://goldst.dev; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' https://gleitz.github.io https://danigb.github.io https://goldst.dev; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" />
<title>Bach to Basics</title> <title>Bach to Basics</title>
<meta name="description" content="Browser-based piano practice tool. Import MIDI, MusicXML, or PDF sheet music and learn pieces with falling notes, interactive piano, A/B loop, speed trainer, and wait mode." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon-light.png" /> <link rel="icon" type="image/png" href="/favicon-light.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"description": "Bach to Basics — browser-based piano learning app with falling notes, sheet music, and MIDI support", "description": "Bach to Basics - browser-based piano learning app with falling notes, sheet music, and MIDI support",
"author": "gigliof", "author": "gigliof",
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/gigliof/bach-to-basics#readme", "homepage": "https://github.com/gigliof/bach-to-basics#readme",
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
@@ -1,7 +1,13 @@
import * as PIXI from "pixi.js"; import * as PIXI from "pixi.js";
import { isBlackKey, midiToNoteName, midiToPitch } from "@bach-to-basics/shared"; import { isBlackKey, midiToNoteName, midiToPitch } from "@bach-to-basics/shared";
import type { NoteEvent } 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 {
NoteFilter,
ColorTheme,
CustomColors,
NoteLabelMode,
ImpactStyle,
} from "../../store/useAppStore";
import type { SustainRange } from "@bach-to-basics/shared"; import type { SustainRange } from "@bach-to-basics/shared";
import { lighten, hexToNum } from "../../utils/colorUtils"; import { lighten, hexToNum } from "../../utils/colorUtils";
@@ -10,7 +16,9 @@ const MIDI_MIN = 21;
const MIDI_MAX = 108; const MIDI_MAX = 108;
const POOL_SIZE = 1200; const POOL_SIZE = 1200;
function isWhite(midi: number) { return !isBlackKey(midi); } function isWhite(midi: number) {
return !isBlackKey(midi);
}
const whiteKeyIndex: number[] = new Array(MIDI_MAX + 1).fill(0); const whiteKeyIndex: number[] = new Array(MIDI_MAX + 1).fill(0);
let wi = 0; let wi = 0;
@@ -33,24 +41,44 @@ interface ThemePalette {
const THEMES: Record<Exclude<ColorTheme, "custom">, ThemePalette> = { const THEMES: Record<Exclude<ColorTheme, "custom">, ThemePalette> = {
violet: { violet: {
left: 0x7c3aed, right: 0x8b5cf6, unknown: 0x8b5cf6, left: 0x7c3aed,
leftBlack: 0xa78bfa, rightBlack: 0xc4b5fd, unknownBlack: 0xc4b5fd, right: 0x8b5cf6,
unknown: 0x8b5cf6,
leftBlack: 0xa78bfa,
rightBlack: 0xc4b5fd,
unknownBlack: 0xc4b5fd,
}, },
classic: { classic: {
left: 0x2563eb, right: 0xdc2626, unknown: 0x7c3aed, left: 0x2563eb,
leftBlack: 0x60a5fa, rightBlack: 0xf87171, unknownBlack: 0xa78bfa, right: 0xdc2626,
unknown: 0x7c3aed,
leftBlack: 0x60a5fa,
rightBlack: 0xf87171,
unknownBlack: 0xa78bfa,
}, },
ocean: { ocean: {
left: 0x0284c7, right: 0x06b6d4, unknown: 0x0ea5e9, left: 0x0284c7,
leftBlack: 0x38bdf8, rightBlack: 0x67e8f9, unknownBlack: 0x7dd3fc, right: 0x06b6d4,
unknown: 0x0ea5e9,
leftBlack: 0x38bdf8,
rightBlack: 0x67e8f9,
unknownBlack: 0x7dd3fc,
}, },
forest: { forest: {
left: 0x15803d, right: 0x16a34a, unknown: 0x22c55e, left: 0x15803d,
leftBlack: 0x4ade80, rightBlack: 0x86efac, unknownBlack: 0xbbf7d0, right: 0x16a34a,
unknown: 0x22c55e,
leftBlack: 0x4ade80,
rightBlack: 0x86efac,
unknownBlack: 0xbbf7d0,
}, },
cascade: { cascade: {
left: 0x9333ea, right: 0x22d3ee, unknown: 0xa855f7, left: 0x9333ea,
leftBlack: 0xb06af5, rightBlack: 0x4dd5ec, unknownBlack: 0xb87af8, right: 0x22d3ee,
unknown: 0xa855f7,
leftBlack: 0xb06af5,
rightBlack: 0x4dd5ec,
unknownBlack: 0xb87af8,
}, },
}; };
@@ -60,11 +88,11 @@ function paletteFromCustom(c: CustomColors): ThemePalette {
const r = hexToNum(c.rightHand); const r = hexToNum(c.rightHand);
const u = hexToNum(c.unknown); const u = hexToNum(c.unknown);
return { return {
left: l, left: l,
right: r, right: r,
unknown: u, unknown: u,
leftBlack: lighten(l, 1.55), leftBlack: lighten(l, 1.55),
rightBlack: lighten(r, 1.55), rightBlack: lighten(r, 1.55),
unknownBlack: lighten(u, 1.55), unknownBlack: lighten(u, 1.55),
}; };
} }
@@ -73,16 +101,17 @@ function noteColor(
note: NoteEvent, note: NoteEvent,
showHandColors: boolean, showHandColors: boolean,
theme: ColorTheme, theme: ColorTheme,
customColors?: CustomColors, customColors?: CustomColors
): number { ): number {
const pal = theme === "custom" && customColors const pal =
? paletteFromCustom(customColors) theme === "custom" && customColors
: THEMES[theme as Exclude<ColorTheme, "custom">]; ? paletteFromCustom(customColors)
: THEMES[theme as Exclude<ColorTheme, "custom">];
const black = isBlackKey(note.midi); const black = isBlackKey(note.midi);
if (!showHandColors || note.hand === "unknown") { if (!showHandColors || note.hand === "unknown") {
return black ? pal.unknownBlack : pal.unknown; return black ? pal.unknownBlack : pal.unknown;
} }
if (note.hand === "left") return black ? pal.leftBlack : pal.left; if (note.hand === "left") return black ? pal.leftBlack : pal.left;
if (note.hand === "right") return black ? pal.rightBlack : pal.right; if (note.hand === "right") return black ? pal.rightBlack : pal.right;
return black ? pal.unknownBlack : pal.unknown; return black ? pal.unknownBlack : pal.unknown;
} }
@@ -93,13 +122,16 @@ function noteColor(
* Unlike multiplicative lighten(), this NEVER blows light colours to pure white. * Unlike multiplicative lighten(), this NEVER blows light colours to pure white.
*/ */
function mixColor(c1: number, c2: number, t: number): number { function mixColor(c1: number, c2: number, t: number): number {
const r1 = (c1 >> 16) & 0xff, r2 = (c2 >> 16) & 0xff; const r1 = (c1 >> 16) & 0xff,
const g1 = (c1 >> 8) & 0xff, g2 = (c2 >> 8) & 0xff; r2 = (c2 >> 16) & 0xff;
const b1 = c1 & 0xff, b2 = c2 & 0xff; const g1 = (c1 >> 8) & 0xff,
g2 = (c2 >> 8) & 0xff;
const b1 = c1 & 0xff,
b2 = c2 & 0xff;
return ( return (
(Math.round(r1 + (r2 - r1) * t) << 16) | (Math.round(r1 + (r2 - r1) * t) << 16) |
(Math.round(g1 + (g2 - g1) * t) << 8) | (Math.round(g1 + (g2 - g1) * t) << 8) |
Math.round(b1 + (b2 - b1) * t) Math.round(b1 + (b2 - b1) * t)
); );
} }
@@ -107,13 +139,13 @@ function makeGradient(base: number): PIXI.FillGradient {
return new PIXI.FillGradient({ return new PIXI.FillGradient({
type: "linear", type: "linear",
start: { x: 0, y: 0 }, start: { x: 0, y: 0 },
end: { x: 0, y: 1 }, end: { x: 0, y: 1 },
textureSpace: "local", textureSpace: "local",
colorStops: [ colorStops: [
{ offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white { 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.22, color: mixColor(base, 0xffffff, 0.22) }, // slightly bright
{ offset: 0.62, color: base }, // solid base { offset: 0.62, color: base }, // solid base
{ offset: 1, color: mixColor(base, 0x000000, 0.30) }, // deeper bottom { offset: 1, color: mixColor(base, 0x000000, 0.3) }, // deeper bottom
], ],
}); });
} }
@@ -122,30 +154,30 @@ function makeGradient(base: number): PIXI.FillGradient {
// Soft bloom effect: two expanding filled halos + a brief central sparkle. // Soft bloom effect: two expanding filled halos + a brief central sparkle.
// No hard strokes, no particles - purely diffuse light that dissolves gently. // No hard strokes, no particles - purely diffuse light that dissolves gently.
class ImpactFlash { class ImpactFlash {
container: PIXI.Container; container: PIXI.Container;
private outerBloom: PIXI.Graphics; // large, very soft, low-opacity halo private outerBloom: PIXI.Graphics; // large, very soft, low-opacity halo
private innerBloom: PIXI.Graphics; // smaller, slightly more visible glow private innerBloom: PIXI.Graphics; // smaller, slightly more visible glow
private sparkle: PIXI.Graphics; // tiny bright flash at moment of contact private sparkle: PIXI.Graphics; // tiny bright flash at moment of contact
active = false; active = false;
private t = 0; private t = 0;
private noteW = 0; private noteW = 0;
private baseColor = 0xffffff; private baseColor = 0xffffff;
constructor() { constructor() {
this.container = new PIXI.Container(); this.container = new PIXI.Container();
this.outerBloom = new PIXI.Graphics(); this.outerBloom = new PIXI.Graphics();
this.innerBloom = new PIXI.Graphics(); this.innerBloom = new PIXI.Graphics();
this.sparkle = new PIXI.Graphics(); this.sparkle = new PIXI.Graphics();
// Render back-to-front: outer halo, inner glow, sparkle on top // Render back-to-front: outer halo, inner glow, sparkle on top
this.container.addChild(this.outerBloom, this.innerBloom, this.sparkle); this.container.addChild(this.outerBloom, this.innerBloom, this.sparkle);
this.container.visible = false; this.container.visible = false;
} }
trigger(cx: number, cy: number, w: number, color: number) { trigger(cx: number, cy: number, w: number, color: number) {
this.noteW = w; this.noteW = w;
this.baseColor = color; this.baseColor = color;
this.t = 0; this.t = 0;
this.active = true; this.active = true;
this.container.visible = true; this.container.visible = true;
this.container.x = cx; this.container.x = cx;
this.container.y = cy; this.container.y = cy;
@@ -155,7 +187,10 @@ class ImpactFlash {
/** dtFrames: PIXI ticker.deltaTime (1.0 = one 60fps frame). Frame-rate independent. */ /** dtFrames: PIXI ticker.deltaTime (1.0 = one 60fps frame). Frame-rate independent. */
advance(dtFrames: number): boolean { advance(dtFrames: number): boolean {
this.t += dtFrames * 0.033; // ~500ms total at 60fps this.t += dtFrames * 0.033; // ~500ms total at 60fps
if (this.t >= 1) { this.reset(); return true; } if (this.t >= 1) {
this.reset();
return true;
}
this._draw(); this._draw();
return false; return false;
} }
@@ -172,12 +207,12 @@ class ImpactFlash {
this.innerBloom.fill({ color: baseColor, alpha: (1 - t) * 0.28 }); this.innerBloom.fill({ color: baseColor, alpha: (1 - t) * 0.28 });
// ── Outer bloom - large diffuse halo, 80ms delayed start ───────────────── // ── Outer bloom - large diffuse halo, 80ms delayed start ─────────────────
const t2 = Math.max(0, t - 0.10); const t2 = Math.max(0, t - 0.1);
const et2 = 1 - (1 - t2) * (1 - t2) * (1 - t2); const et2 = 1 - (1 - t2) * (1 - t2) * (1 - t2);
const r2 = noteW * 0.4 + et2 * noteW * 3.0; const r2 = noteW * 0.4 + et2 * noteW * 3.0;
this.outerBloom.clear(); this.outerBloom.clear();
this.outerBloom.circle(0, 0, r2); this.outerBloom.circle(0, 0, r2);
this.outerBloom.fill({ color: baseColor, alpha: (1 - t2) * 0.10 }); this.outerBloom.fill({ color: baseColor, alpha: (1 - t2) * 0.1 });
// ── Sparkle - bright note-colored flash that exists only for the first ~100ms ── // ── 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 // Using baseColor (the note's own color) instead of white so it's visible in
@@ -188,7 +223,7 @@ class ImpactFlash {
const sr = noteW * 0.35 * (1 - et); const sr = noteW * 0.35 * (1 - et);
if (sr > 0.5) { if (sr > 0.5) {
this.sparkle.circle(0, 0, sr); this.sparkle.circle(0, 0, sr);
this.sparkle.fill({ color: baseColor, alpha: sa * 0.80 }); this.sparkle.fill({ color: baseColor, alpha: sa * 0.8 });
} }
} }
} }
@@ -234,13 +269,15 @@ class NoteBar {
configure( configure(
note: NoteEvent, note: NoteEvent,
x: number, barWidth: number, x: number,
startY: number, barHeight: number, barWidth: number,
startY: number,
barHeight: number,
gradient: PIXI.FillGradient, gradient: PIXI.FillGradient,
baseColor: number, baseColor: number,
labelText: string, labelText: string,
cornerRadius: number, cornerRadius: number,
showOutline: boolean, showOutline: boolean
) { ) {
this.note = note; this.note = note;
this.active = true; this.active = true;
@@ -290,16 +327,22 @@ class NoteBar {
/** Keep label clamped within the visible portion of the bar (for very tall notes). */ /** Keep label clamped within the visible portion of the bar (for very tall notes). */
updateLabelY(containerY: number, barHeight: number, viewportHeight: number) { updateLabelY(containerY: number, barHeight: number, viewportHeight: number) {
if (!this.labelWanted) { this.label.visible = false; return; } if (!this.labelWanted) {
this.label.visible = false;
return;
}
// Visible slice of the bar in local coords // Visible slice of the bar in local coords
const localTop = Math.max(0, -containerY); const localTop = Math.max(0, -containerY);
const localBottom = Math.min(barHeight, viewportHeight - containerY); const localBottom = Math.min(barHeight, viewportHeight - containerY);
if (localBottom <= localTop) { this.label.visible = false; return; } if (localBottom <= localTop) {
this.label.visible = false;
return;
}
this.label.visible = true; this.label.visible = true;
const midLocal = (localTop + localBottom) / 2; const midLocal = (localTop + localBottom) / 2;
// Only reposition if the natural center is outside the visible slice // Only reposition if the natural center is outside the visible slice
const natural = barHeight / 2; const natural = barHeight / 2;
this.label.y = (natural >= localTop && natural <= localBottom) ? natural : midLocal; this.label.y = natural >= localTop && natural <= localBottom ? natural : midLocal;
} }
reset() { reset() {
@@ -331,7 +374,7 @@ export interface FallingNotesOptions {
/** Which visual effect plays when a note reaches the hit line */ /** Which visual effect plays when a note reaches the hit line */
impactStyle?: ImpactStyle; impactStyle?: ImpactStyle;
/** Minimum pixel height for note bars - prevents staccato slivers */ /** Minimum pixel height for note bars - prevents staccato slivers */
minNoteHeight?: number; // default 8 minNoteHeight?: number; // default 8
/** Border radius for note bars (0 = sharp, 12 = pill) */ /** Border radius for note bars (0 = sharp, 12 = pill) */
noteCornerRadius?: number; // default 4 noteCornerRadius?: number; // default 4
/** Draw horizontal beat/measure lines across the canvas */ /** Draw horizontal beat/measure lines across the canvas */
@@ -370,17 +413,26 @@ export class FallingNotesRenderer {
private measureLayer!: PIXI.Container; private measureLayer!: PIXI.Container;
private particleLayer!: PIXI.Graphics; private particleLayer!: PIXI.Graphics;
private particles: Array<{ private particles: Array<{
x: number; y: number; vx: number; vy: number; x: number;
size: number; life: number; decay: number; y: number;
gravity: number; color: number; vx: number;
vy: number;
size: number;
life: number;
decay: number;
gravity: number;
color: number;
}> = []; }> = [];
private gridLayer!: PIXI.Graphics; private gridLayer!: PIXI.Graphics;
private beatLayer!: PIXI.Graphics; private beatLayer!: PIXI.Graphics;
private sustainLineLayer!: PIXI.Graphics; // thin connecting line private sustainLineLayer!: PIXI.Graphics; // thin connecting line
private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool
/** Ghost indicators for notes that ended while sustain was held */ /** Ghost indicators for notes that ended while sustain was held */
private sustainedNoteLayer!: PIXI.Graphics; private sustainedNoteLayer!: PIXI.Graphics;
private sustainedNotes = new Map<string, { midi: number; baseColor: number; endSeconds: number }>(); private sustainedNotes = new Map<
string,
{ midi: number; baseColor: number; endSeconds: number }
>();
constructor(opts: FallingNotesOptions) { constructor(opts: FallingNotesOptions) {
this.opts = opts; this.opts = opts;
@@ -452,7 +504,12 @@ export class FallingNotesRenderer {
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
const t = new PIXI.Text({ const t = new PIXI.Text({
text: "", text: "",
style: { fontSize: 11, fill: 0x8896b8, fontFamily: "Space Grotesk, system-ui, sans-serif", fontWeight: "600" }, style: {
fontSize: 11,
fill: 0x8896b8,
fontFamily: "Space Grotesk, system-ui, sans-serif",
fontWeight: "600",
},
}); });
t.x = 3; t.x = 3;
t.visible = false; t.visible = false;
@@ -460,10 +517,17 @@ export class FallingNotesRenderer {
} }
this.app.stage.addChild( this.app.stage.addChild(
this.gridLayer, this.sustainLineLayer, this.sustainTextLayer, this.gridLayer,
this.beatLayer, noteLayer, this.particleLayer, this.sustainLineLayer,
this.sustainTextLayer,
this.beatLayer,
noteLayer,
this.particleLayer,
this.sustainedNoteLayer, this.sustainedNoteLayer,
this.measureLayer, this.flashLayer, this.hitLine, this.ghostBar, this.measureLayer,
this.flashLayer,
this.hitLine,
this.ghostBar
); );
this._initialized = true; this._initialized = true;
} }
@@ -501,7 +565,10 @@ export class FallingNotesRenderer {
private getGradient(base: number): PIXI.FillGradient { private getGradient(base: number): PIXI.FillGradient {
let g = this.gradientCache.get(base); let g = this.gradientCache.get(base);
if (!g) { g = makeGradient(base); this.gradientCache.set(base, g); } if (!g) {
g = makeGradient(base);
this.gradientCache.set(base, g);
}
return g; return g;
} }
@@ -521,7 +588,7 @@ export class FallingNotesRenderer {
return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : ""; return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : "";
} }
if (mode === "white" && !white) return ""; if (mode === "white" && !white) return "";
if (mode === "black" && white) return ""; if (mode === "black" && white) return "";
// "white", "black", "all": note name class only (no octave) // "white", "black", "all": note name class only (no octave)
return midiToNoteName(midi, this.opts.useFlats); return midiToNoteName(midi, this.opts.useFlats);
} }
@@ -529,10 +596,14 @@ export class FallingNotesRenderer {
// Returns false if the note should be hidden by the current filter // Returns false if the note should be hidden by the current filter
private passesFilter(note: NoteEvent): boolean { private passesFilter(note: NoteEvent): boolean {
switch (this.opts.noteFilter) { switch (this.opts.noteFilter) {
case "white": return isWhite(note.midi); case "white":
case "black": return isBlackKey(note.midi); return isWhite(note.midi);
case "c_only": return note.midi % 12 === 0; case "black":
default: return true; return isBlackKey(note.midi);
case "c_only":
return note.midi % 12 === 0;
default:
return true;
} }
} }
@@ -542,10 +613,10 @@ export class FallingNotesRenderer {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const side = Math.random() < 0.5 ? -1 : 1; const side = Math.random() < 0.5 ? -1 : 1;
this.particles.push({ this.particles.push({
x: cx + side * (noteW * 0.5 + 1 + Math.random() * 3), x: cx + side * (noteW * 0.5 + 1 + Math.random() * 3),
y: cy, y: cy,
vx: side * (1.8 + Math.random() * 3.8), vx: side * (1.8 + Math.random() * 3.8),
vy: -(0.5 + Math.random() * 2.8), vy: -(0.5 + Math.random() * 2.8),
size: 1.4 + Math.random() * 2.4, size: 1.4 + Math.random() * 2.4,
life: 1.0, life: 1.0,
decay: 0.024 + Math.random() * 0.018, decay: 0.024 + Math.random() * 0.018,
@@ -560,10 +631,10 @@ export class FallingNotesRenderer {
if (Math.random() > 0.38) return; if (Math.random() > 0.38) return;
const side = Math.random() < 0.5 ? -1 : 1; const side = Math.random() < 0.5 ? -1 : 1;
this.particles.push({ this.particles.push({
x: cx + side * (noteW * 0.5), x: cx + side * (noteW * 0.5),
y: cy - Math.random() * 22, y: cy - Math.random() * 22,
vx: side * (0.5 + Math.random() * 1.4), vx: side * (0.5 + Math.random() * 1.4),
vy: -(0.2 + Math.random() * 1.0), vy: -(0.2 + Math.random() * 1.0),
size: 1.0 + Math.random() * 1.6, size: 1.0 + Math.random() * 1.6,
life: 1.0, life: 1.0,
decay: 0.04 + Math.random() * 0.03, decay: 0.04 + Math.random() * 0.03,
@@ -573,7 +644,7 @@ export class FallingNotesRenderer {
} }
private triggerFlash(cx: number, cy: number, w: number, color: number) { private triggerFlash(cx: number, cy: number, w: number, color: number) {
const f = this.flashes.find(f => !f.active); const f = this.flashes.find((f) => !f.active);
if (f) f.trigger(cx, cy, w, color); if (f) f.trigger(cx, cy, w, color);
} }
@@ -595,7 +666,7 @@ export class FallingNotesRenderer {
if (this.active.has(note.id)) return; if (this.active.has(note.id)) return;
if (!this.passesFilter(note)) return; if (!this.passesFilter(note)) return;
// Skip notes whose bottom edge has already scrolled 60 px below the hit line — // 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. // 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. // Threshold mirrors the recycle condition in tick(): bar.container.y > hitY + 60.
// barBottomPastHit = (currentSeconds - note.startSeconds) * pxPerSec - barHeight // barBottomPastHit = (currentSeconds - note.startSeconds) * pxPerSec - barHeight
@@ -620,10 +691,26 @@ export class FallingNotesRenderer {
const minH = this.opts.minNoteHeight ?? 8; const minH = this.opts.minNoteHeight ?? 8;
const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec); const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec);
const timeUntilHit = note.startSeconds - currentSeconds; const timeUntilHit = note.startSeconds - currentSeconds;
const startY = (height - 4) - barHeight - timeUntilHit * pxPerSec; const startY = height - 4 - barHeight - timeUntilHit * pxPerSec;
const base = noteColor(note, this.opts.showHandColors, this.opts.colorTheme, this.opts.customColors); const base = noteColor(
bar.configure(note, x, barWidth, startY, barHeight, this.getGradient(base), base, this.noteLabel(note), this.opts.noteCornerRadius ?? 4, this.opts.showNoteOutline ?? false); 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); this.active.set(note.id, bar);
} }
@@ -633,17 +720,17 @@ export class FallingNotesRenderer {
const pxPerSec = height / viewportSeconds; const pxPerSec = height / viewportSeconds;
const hitY = height - 4; const hitY = height - 4;
const minH = this.opts.minNoteHeight ?? 8; const minH = this.opts.minNoteHeight ?? 8;
const cornerRadius = this.opts.noteCornerRadius ?? 4; const cornerRadius = this.opts.noteCornerRadius ?? 4;
const impactStyle = this.opts.impactStyle ?? "bloom"; const impactStyle = this.opts.impactStyle ?? "bloom";
const doSustained = this.opts.showSustainedNotes ?? false; const doSustained = this.opts.showSustainedNotes ?? false;
const sustainActive = doSustained && this.isSustainActive(currentSeconds); const sustainActive = doSustained && this.isSustainActive(currentSeconds);
for (const [id, bar] of this.active) { for (const [id, bar] of this.active) {
if (!bar.note) continue; if (!bar.note) continue;
const barHeight = Math.max(minH, (bar.note.endSeconds - bar.note.startSeconds) * pxPerSec); const barHeight = Math.max(minH, (bar.note.endSeconds - bar.note.startSeconds) * pxPerSec);
const timeUntilHit = bar.note.startSeconds - currentSeconds; const timeUntilHit = bar.note.startSeconds - currentSeconds;
bar.container.y = hitY - barHeight - timeUntilHit * pxPerSec; bar.container.y = hitY - barHeight - timeUntilHit * pxPerSec;
bar.updateLabelY(bar.container.y, barHeight, height); bar.updateLabelY(bar.container.y, barHeight, height);
// ── Impact effect ─────────────────────────────────────────────────────── // ── Impact effect ───────────────────────────────────────────────────────
@@ -652,10 +739,8 @@ export class FallingNotesRenderer {
bar.impactFired = true; bar.impactFired = true;
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW; const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
const cx = bar.container.x + bw * 0.5; const cx = bar.container.x + bw * 0.5;
if (impactStyle === "bloom") if (impactStyle === "bloom") this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor);
this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor); else if (impactStyle === "side") this.spawnBurst(cx, hitY - 3, bw, bar.baseColor);
else if (impactStyle === "side")
this.spawnBurst(cx, hitY - 3, bw, bar.baseColor);
// "trail" is handled continuously below // "trail" is handled continuously below
} }
} }
@@ -665,7 +750,12 @@ export class FallingNotesRenderer {
const barTopY = bar.container.y; const barTopY = bar.container.y;
if (barTopY + barHeight < hitY && barTopY < hitY) { if (barTopY + barHeight < hitY && barTopY < hitY) {
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW; 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); this.spawnTrail(
bar.container.x + bw * 0.5,
barTopY + barHeight * 0.35,
bw,
bar.baseColor
);
} }
} }
@@ -676,8 +766,8 @@ export class FallingNotesRenderer {
const timeAfterEnd = currentSeconds - bar.note.endSeconds; const timeAfterEnd = currentSeconds - bar.note.endSeconds;
if (timeAfterEnd < 3.0) { if (timeAfterEnd < 3.0) {
this.sustainedNotes.set(id, { this.sustainedNotes.set(id, {
midi: bar.note.midi, midi: bar.note.midi,
baseColor: bar.baseColor, baseColor: bar.baseColor,
endSeconds: bar.note.endSeconds, endSeconds: bar.note.endSeconds,
}); });
} }
@@ -699,9 +789,9 @@ export class FallingNotesRenderer {
this.sustainedNotes.delete(sid); this.sustainedNotes.delete(sid);
continue; continue;
} }
const bw = Math.max(5, (isWhite(sn.midi) ? this.whiteW : this.blackW) - 2); const bw = Math.max(5, (isWhite(sn.midi) ? this.whiteW : this.blackW) - 2);
const x = this.noteX(sn.midi) + 1; const x = this.noteX(sn.midi) + 1;
const r = Math.min(cornerRadius, bw * 0.25); const r = Math.min(cornerRadius, bw * 0.25);
// Fade out over the last second of the 3s window // 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))); 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.roundRect(x, hitY - ghostH, bw, ghostH, r);
@@ -719,9 +809,9 @@ export class FallingNotesRenderer {
// ── Particles ────────────────────────────────────────────────────────── // ── Particles ──────────────────────────────────────────────────────────
this.particleLayer.clear(); this.particleLayer.clear();
if (this.particles.length > 0) { if (this.particles.length > 0) {
this.particles = this.particles.filter(p => { this.particles = this.particles.filter((p) => {
p.x += p.vx; p.x += p.vx;
p.y += p.vy; p.y += p.vy;
p.vy += p.gravity; p.vy += p.gravity;
p.life -= p.decay; p.life -= p.decay;
return p.life > 0; return p.life > 0;
@@ -758,7 +848,7 @@ export class FallingNotesRenderer {
if ((this.opts.showBeatLines ?? false) && this.opts.beatStarts?.length) { if ((this.opts.showBeatLines ?? false) && this.opts.beatStarts?.length) {
this.beatLayer.clear(); this.beatLayer.clear();
// Build a Set of measure-start times (×100 for int comparison) for styling // 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))); const mSet = new Set((this.opts.measureStarts ?? []).map((s) => Math.round(s * 100)));
for (const sec of this.opts.beatStarts) { for (const sec of this.opts.beatStarts) {
const timeUntil = sec - currentSeconds; const timeUntil = sec - currentSeconds;
if (timeUntil < -0.3 || timeUntil > viewportSeconds + 0.3) continue; if (timeUntil < -0.3 || timeUntil > viewportSeconds + 0.3) continue;
@@ -768,7 +858,10 @@ export class FallingNotesRenderer {
const dark = this.opts.darkMode !== false; const dark = this.opts.darkMode !== false;
this.beatLayer.rect(0, y, this.opts.width, isMeasure ? 1.5 : 1); 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. // 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) }); 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)) { } else if (!(this.opts.showBeatLines ?? false)) {
this.beatLayer.clear(); this.beatLayer.clear();
@@ -785,17 +878,17 @@ export class FallingNotesRenderer {
for (const t of pool) t.visible = false; for (const t of pool) t.visible = false;
let ti = 0; let ti = 0;
const LABEL_H = 16; // approximate text height + gap const LABEL_H = 16; // approximate text height + gap
let prevRelY = -999; // Y of the last "✱" placed - guards Ped overlap from above let prevRelY = -999; // Y of the last "✱" placed - guards Ped overlap from above
for (const range of this.opts.sustainRanges) { for (const range of this.opts.sustainRanges) {
const startTimeUntil = range.startSeconds - currentSeconds; const startTimeUntil = range.startSeconds - currentSeconds;
const endTimeUntil = range.endSeconds - currentSeconds; const endTimeUntil = range.endSeconds - currentSeconds;
if (endTimeUntil < -0.5 || startTimeUntil > viewportSeconds + 0.5) continue; if (endTimeUntil < -0.5 || startTimeUntil > viewportSeconds + 0.5) continue;
// topY = release (end) - higher on canvas (later in time) // topY = release (end) - higher on canvas (later in time)
// botY = press (start) - lower on canvas (sooner, approaches hit line first) // botY = press (start) - lower on canvas (sooner, approaches hit line first)
const topY = Math.max(0, hitY - endTimeUntil * pxPerSec); const topY = Math.max(0, hitY - endTimeUntil * pxPerSec);
const botY = Math.min(height + 14, hitY - startTimeUntil * pxPerSec); const botY = Math.min(height + 14, hitY - startTimeUntil * pxPerSec);
if (botY <= topY) continue; if (botY <= topY) continue;
@@ -832,7 +925,10 @@ export class FallingNotesRenderer {
} }
showGhost(note: NoteEvent | null) { showGhost(note: NoteEvent | null) {
if (!note) { this.ghostBar.visible = false; return; } if (!note) {
this.ghostBar.visible = false;
return;
}
const white = isWhite(note.midi); const white = isWhite(note.midi);
const w = (white ? this.whiteW : this.blackW) - 2; const w = (white ? this.whiteW : this.blackW) - 2;
const x = this.noteX(note.midi) + 1; const x = this.noteX(note.midi) + 1;
@@ -843,7 +939,10 @@ export class FallingNotesRenderer {
} }
reset() { reset() {
for (const [, bar] of this.active) { bar.reset(); this.pool.push(bar); } for (const [, bar] of this.active) {
bar.reset();
this.pool.push(bar);
}
this.active.clear(); this.active.clear();
this.particles = []; this.particles = [];
this.particleLayer?.clear(); this.particleLayer?.clear();
@@ -877,12 +976,13 @@ export class FallingNotesRenderer {
// because "x in opts" is always true when callers always pass the full options // because "x in opts" is always true when callers always pass the full options
// object - triggering spurious resets that wipe all visible note bars. // object - triggering spurious resets that wipe all visible note bars.
const needsReset = const needsReset =
("colorTheme" in opts && opts.colorTheme !== this.opts.colorTheme) || ("colorTheme" in opts && opts.colorTheme !== this.opts.colorTheme) ||
("customColors" in opts && opts.customColors !== this.opts.customColors) || ("customColors" in opts && opts.customColors !== this.opts.customColors) ||
("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) || ("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) ||
("showHandColors" in opts && opts.showHandColors !== this.opts.showHandColors) || ("showHandColors" in opts && opts.showHandColors !== this.opts.showHandColors) ||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats) || ("useFlats" in opts && opts.useFlats !== this.opts.useFlats) ||
("fallingNotesLabelMode" in opts && opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) || ("fallingNotesLabelMode" in opts &&
opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) ||
("showFingering" in opts && opts.showFingering !== this.opts.showFingering) || ("showFingering" in opts && opts.showFingering !== this.opts.showFingering) ||
("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline); ("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline);
@@ -233,7 +233,7 @@ export function SheetMusicView() {
}} }}
> >
{/* {/*
Page-width wrapper — centres the score and caps it at ~A4 screen width 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 (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 score never looks like a stretched banner; on narrow screens it fills
the full width just like before. position:relative is needed so the the full width just like before. position:relative is needed so the
File diff suppressed because it is too large Load Diff
+110 -67
View File
@@ -3,102 +3,114 @@
/* ── Design tokens ─────────────────────────────────────────────────────────── */ /* ── Design tokens ─────────────────────────────────────────────────────────── */
:root { :root {
/* Surfaces - full indigo palette */ /* Surfaces - full indigo palette */
--color-bg: #1a1a2e; --color-bg: #1a1a2e;
--color-surface: #1e1e38; --color-surface: #1e1e38;
--color-surface-2: #232348; --color-surface-2: #232348;
--color-surface-3: #282858; --color-surface-3: #282858;
--color-border: #2d2d55; --color-border: #2d2d55;
--color-border-subtle: #232348; --color-border-subtle: #232348;
/* Accent */ /* Accent */
--color-accent: #9333ea; --color-accent: #9333ea;
--color-accent-hover: #a855f7; --color-accent-hover: #a855f7;
--color-accent-subtle: rgba(147, 51, 234, 0.18); --color-accent-subtle: rgba(147, 51, 234, 0.18);
--color-accent-subtle-border: rgba(147, 51, 234, 0.35); --color-accent-subtle-border: rgba(147, 51, 234, 0.35);
--color-accent-text: #c084fc; --color-accent-text: #c084fc;
/* Toggle switch inactive track */ /* Toggle switch inactive track */
--color-toggle-off: #4a4f88; --color-toggle-off: #4a4f88;
/* Text - muted bumped to #9ca3af for WCAG AA compliance on dark surfaces */ /* Text - muted bumped to #9ca3af for WCAG AA compliance on dark surfaces */
--color-text: #e2e8f0; --color-text: #e2e8f0;
--color-text-muted: #9ca3af; --color-text-muted: #9ca3af;
--color-text-dim: #3d4070; --color-text-dim: #3d4070;
/* Notes canvas - slightly deeper than bg so the practice area reads as distinct */ /* Notes canvas - slightly deeper than bg so the practice area reads as distinct */
--color-notes-bg: #141428; --color-notes-bg: #141428;
--color-notes-border: #1f1f44; --color-notes-border: #1f1f44;
/* Hands */ /* Hands */
--color-warning: #f59e0b; --color-warning: #f59e0b;
--color-warning-subtle: rgba(245, 158, 11, 0.08); --color-warning-subtle: rgba(245, 158, 11, 0.08);
--color-warning-border: rgba(245, 158, 11, 0.22); --color-warning-border: rgba(245, 158, 11, 0.22);
--color-left-hand: #9333ea; --color-left-hand: #9333ea;
--color-left-hand-subtle: rgba(147, 51, 234, 0.18); --color-left-hand-subtle: rgba(147, 51, 234, 0.18);
--color-left-hand-subtle-border: rgba(147, 51, 234, 0.38); --color-left-hand-subtle-border: rgba(147, 51, 234, 0.38);
--color-left-hand-text: #c084fc; --color-left-hand-text: #c084fc;
--color-right-hand: #22d3ee; --color-right-hand: #22d3ee;
--color-right-hand-subtle: rgba(34, 211, 238, 0.14); --color-right-hand-subtle: rgba(34, 211, 238, 0.14);
--color-right-hand-subtle-border: rgba(34, 211, 238, 0.32); --color-right-hand-subtle-border: rgba(34, 211, 238, 0.32);
--color-right-hand-text: #22d3ee; --color-right-hand-text: #22d3ee;
} }
[data-theme="light"] { [data-theme="light"] {
/* Surfaces */ /* Surfaces */
--color-bg: #ffffff; --color-bg: #ffffff;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-2: #f5f7fb; --color-surface-2: #f5f7fb;
--color-surface-3: #eaecf4; --color-surface-3: #eaecf4;
--color-border: #dde1ea; --color-border: #dde1ea;
--color-border-subtle: #eaecf4; --color-border-subtle: #eaecf4;
/* Accent */ /* Accent */
--color-accent: #6366f1; --color-accent: #5e61f0; /* was #6366f1, darkened to pass WCAG AA contrast */
--color-accent-hover: #4f46e5; --color-accent-hover: #4f46e5;
--color-accent-subtle: rgba(99, 102, 241, 0.12); --color-accent-subtle: rgba(94, 97, 240, 0.12);
--color-accent-subtle-border: rgba(99, 102, 241, 0.30); --color-accent-subtle-border: rgba(94, 97, 240, 0.3);
--color-accent-text: #4f46e5; --color-accent-text: #4f46e5;
/* Toggle switch inactive track */ /* Toggle switch inactive track */
--color-toggle-off: #c1c9d6; --color-toggle-off: #c1c9d6;
/* Text */ /* Text */
--color-text: #0f172a; --color-text: #0f172a;
--color-text-muted: #64748b; --color-text-muted: #64748b;
--color-text-dim: #94a3b8; --color-text-dim: #94a3b8;
/* Hands */ /* Hands */
--color-warning: #d97706; --color-warning: #d97706;
--color-warning-subtle: rgba(217, 119, 6, 0.08); --color-warning-subtle: rgba(217, 119, 6, 0.08);
--color-warning-border: rgba(217, 119, 6, 0.22); --color-warning-border: rgba(217, 119, 6, 0.22);
--color-left-hand: #3b82f6; --color-left-hand: #3b82f6;
--color-left-hand-subtle: rgba(59, 130, 246, 0.12); --color-left-hand-subtle: rgba(59, 130, 246, 0.12);
--color-left-hand-subtle-border: rgba(59, 130, 246, 0.30); --color-left-hand-subtle-border: rgba(59, 130, 246, 0.3);
--color-left-hand-text: #1d4ed8; --color-left-hand-text: #1d4ed8;
--color-right-hand: #ef4444; --color-right-hand: #ef4444;
--color-right-hand-subtle: rgba(239, 68, 68, 0.10); --color-right-hand-subtle: rgba(239, 68, 68, 0.1);
--color-right-hand-subtle-border: rgba(239, 68, 68, 0.28); --color-right-hand-subtle-border: rgba(239, 68, 68, 0.28);
--color-right-hand-text: #dc2626; --color-right-hand-text: #dc2626;
/* Notes canvas - cooler, slightly darker than chrome to create a /* Notes canvas - cooler, slightly darker than chrome to create a
"stage vs UI" separation that mirrors what dark mode does with "stage vs UI" separation that mirrors what dark mode does with
#141428 content vs #1E1E38 chrome. #EEF1FA has the same blue-indigo #141428 content vs #1E1E38 chrome. #EEF1FA has the same blue-indigo
undertone as the dark palette and reads as white at a glance while undertone as the dark palette and reads as white at a glance while
still being visibly distinct from the #FFFFFF header/transport. */ still being visibly distinct from the #FFFFFF header/transport. */
--color-notes-bg: #EEF1FA; --color-notes-bg: #eef1fa;
--color-notes-border: #D4D9EE; --color-notes-border: #d4d9ee;
} }
/* ── Reset ─────────────────────────────────────────────────────────────────── */ /* ── Reset ─────────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root { html,
body,
#root {
height: 100%; height: 100%;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-family: "Space Grotesk", system-ui, -apple-system, sans-serif; font-family:
"Space Grotesk",
system-ui,
-apple-system,
sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow: hidden; overflow: hidden;
} }
@@ -112,7 +124,10 @@ html, body, #root {
} }
/* ── Inputs & selects ──────────────────────────────────────────────────────── */ /* ── Inputs & selects ──────────────────────────────────────────────────────── */
select, input[type="range"] { font-family: inherit; } select,
input[type="range"] {
font-family: inherit;
}
select { select {
appearance: none; appearance: none;
@@ -127,13 +142,25 @@ select {
} }
/* ── Settings panel rows ───────────────────────────────────────────────────── */ /* ── Settings panel rows ───────────────────────────────────────────────────── */
.settings-row:hover { background: var(--color-surface-2); } .settings-row:hover {
background: var(--color-surface-2);
}
/* ── Scrollbar ─────────────────────────────────────────────────────────────── */ /* ── Scrollbar ─────────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar {
::-webkit-scrollbar-track { background: transparent; } width: 5px;
::-webkit-scrollbar-thumb { background: var(--color-surface-3); border-radius: 99px; } height: 5px;
::-webkit-scrollbar-thumb:hover { background: var(--color-border); } }
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-3);
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border);
}
/* ── Range slider - fully custom ───────────────────────────────────────────── */ /* ── Range slider - fully custom ───────────────────────────────────────────── */
/* Remove native appearance so our pseudo-element CSS actually takes effect. /* Remove native appearance so our pseudo-element CSS actually takes effect.
@@ -156,8 +183,10 @@ input[type="range"]::-webkit-slider-thumb {
background: var(--color-accent); background: var(--color-accent);
cursor: pointer; cursor: pointer;
margin-top: -5.5px; /* center on 3px track: -(14-3)/2 */ margin-top: -5.5px; /* center on 3px track: -(14-3)/2 */
box-shadow: 0 1px 4px rgba(0,0,0,0.35); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
transition: background 0.12s, box-shadow 0.12s; transition:
background 0.12s,
box-shadow 0.12s;
} }
input[type="range"]:hover::-webkit-slider-thumb { input[type="range"]:hover::-webkit-slider-thumb {
background: var(--color-accent-hover); background: var(--color-accent-hover);
@@ -183,7 +212,7 @@ input[type="range"]::-moz-range-thumb {
background: var(--color-accent); background: var(--color-accent);
border: none; border: none;
cursor: pointer; cursor: pointer;
box-shadow: 0 1px 4px rgba(0,0,0,0.35); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
} }
input[type="range"]:hover::-moz-range-thumb { input[type="range"]:hover::-moz-range-thumb {
background: var(--color-accent-hover); background: var(--color-accent-hover);
@@ -217,13 +246,21 @@ input[type="range"]::-moz-range-progress {
color: var(--color-text-muted); color: var(--color-text-muted);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
cursor: pointer; cursor: pointer;
transition: background 0.12s, opacity 0.12s; transition:
background 0.12s,
opacity 0.12s;
flex-shrink: 0; flex-shrink: 0;
font-size: 12px; font-size: 12px;
font-family: inherit; font-family: inherit;
} }
.t-btn:hover:not(:disabled) { background: var(--color-surface-3); color: var(--color-text); } .t-btn:hover:not(:disabled) {
.t-btn:disabled { opacity: 0.55; cursor: default; } background: var(--color-surface-3);
color: var(--color-text);
}
.t-btn:disabled {
opacity: 0.55;
cursor: default;
}
/* Primary play/pause button - circular, accent-colored */ /* Primary play/pause button - circular, accent-colored */
.t-btn.primary { .t-btn.primary {
@@ -242,10 +279,16 @@ input[type="range"]::-moz-range-progress {
box-shadow: 0 0 24px rgba(147, 51, 234, 0.65); box-shadow: 0 0 24px rgba(147, 51, 234, 0.65);
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── AlphaTab host ─────────────────────────────────────────────────────────── */ /* ── AlphaTab host ─────────────────────────────────────────────────────────── */
.at-surface svg { display: block; } .at-surface svg {
display: block;
}
/* ── AlphaTab playback cursor ─────────────────────────────────────────────── */ /* ── AlphaTab playback cursor ─────────────────────────────────────────────── */
/* Beat cursor - thin accent-colored vertical bar following playhead */ /* Beat cursor - thin accent-colored vertical bar following playhead */
+33 -30
View File
@@ -2,8 +2,8 @@
* Browser-side .mxl extractor. * Browser-side .mxl extractor.
* *
* .mxl (compressed MusicXML) is a ZIP archive containing: * .mxl (compressed MusicXML) is a ZIP archive containing:
* META-INF/container.xml — lists the root MusicXML file * META-INF/container.xml - lists the root MusicXML file
* <root>.xml — the actual MusicXML document * <root>.xml - the actual MusicXML document
* *
* We parse the ZIP central directory to locate files, then decompress each * We parse the ZIP central directory to locate files, then decompress each
* DEFLATE-compressed entry with the browser-native DecompressionStream API * DEFLATE-compressed entry with the browser-native DecompressionStream API
@@ -17,9 +17,9 @@
* XML string that takes the battle-tested string path in SheetMusicView. * XML string that takes the battle-tested string path in SheetMusicView.
*/ */
const SIG_LOCAL = 0x04034b50; // PK\x03\x04 const SIG_LOCAL = 0x04034b50; // PK\x03\x04
const SIG_CENTRAL = 0x02014b50; // PK\x01\x02 const SIG_CENTRAL = 0x02014b50; // PK\x01\x02
const SIG_EOCD = 0x06054b50; // PK\x05\x06 const SIG_EOCD = 0x06054b50; // PK\x05\x06
interface ZipEntry { interface ZipEntry {
name: string; name: string;
@@ -30,9 +30,12 @@ interface ZipEntry {
function findEocd(bytes: Uint8Array): number { function findEocd(bytes: Uint8Array): number {
for (let i = bytes.length - 22; i >= 0; i--) { for (let i = bytes.length - 22; i >= 0; i--) {
if ( if (
bytes[i] === 0x50 && bytes[i + 1] === 0x4b && bytes[i] === 0x50 &&
bytes[i + 2] === 0x05 && bytes[i + 3] === 0x06 bytes[i + 1] === 0x4b &&
) return i; bytes[i + 2] === 0x05 &&
bytes[i + 3] === 0x06
)
return i;
} }
return -1; return -1;
} }
@@ -45,17 +48,17 @@ function readCentralDirectory(view: DataView, bytes: Uint8Array): ZipEntry[] {
if (view.getUint32(eocd, true) !== SIG_EOCD) throw new Error("Bad EOCD signature"); if (view.getUint32(eocd, true) !== SIG_EOCD) throw new Error("Bad EOCD signature");
const numEntries = view.getUint16(eocd + 10, true); const numEntries = view.getUint16(eocd + 10, true);
let cdPos = view.getUint32(eocd + 16, true); let cdPos = view.getUint32(eocd + 16, true);
const entries: ZipEntry[] = []; const entries: ZipEntry[] = [];
for (let i = 0; i < numEntries; i++) { for (let i = 0; i < numEntries; i++) {
if (view.getUint32(cdPos, true) !== SIG_CENTRAL) break; if (view.getUint32(cdPos, true) !== SIG_CENTRAL) break;
const fileNameLen = view.getUint16(cdPos + 28, true); const fileNameLen = view.getUint16(cdPos + 28, true);
const extraLen = view.getUint16(cdPos + 30, true); const extraLen = view.getUint16(cdPos + 30, true);
const commentLen = view.getUint16(cdPos + 32, true); const commentLen = view.getUint16(cdPos + 32, true);
const localOffset = view.getUint32(cdPos + 42, true); const localOffset = view.getUint32(cdPos + 42, true);
const name = new TextDecoder().decode(bytes.slice(cdPos + 46, cdPos + 46 + fileNameLen)); const name = new TextDecoder().decode(bytes.slice(cdPos + 46, cdPos + 46 + fileNameLen));
entries.push({ name, localOffset }); entries.push({ name, localOffset });
cdPos += 46 + fileNameLen + extraLen + commentLen; cdPos += 46 + fileNameLen + extraLen + commentLen;
} }
@@ -66,36 +69,36 @@ function readCentralDirectory(view: DataView, bytes: Uint8Array): ZipEntry[] {
async function readLocalEntry( async function readLocalEntry(
view: DataView, view: DataView,
bytes: Uint8Array, bytes: Uint8Array,
entry: ZipEntry, entry: ZipEntry
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const off = entry.localOffset; const off = entry.localOffset;
if (view.getUint32(off, true) !== SIG_LOCAL) { if (view.getUint32(off, true) !== SIG_LOCAL) {
throw new Error(`Bad local header at offset ${off}`); throw new Error(`Bad local header at offset ${off}`);
} }
const compressionMethod = view.getUint16(off + 8, true); const compressionMethod = view.getUint16(off + 8, true);
const compressedSize = view.getUint32(off + 18, true); const compressedSize = view.getUint32(off + 18, true);
const fileNameLen = view.getUint16(off + 26, true); const fileNameLen = view.getUint16(off + 26, true);
const extraLen = view.getUint16(off + 28, true); const extraLen = view.getUint16(off + 28, true);
const dataStart = off + 30 + fileNameLen + extraLen; const dataStart = off + 30 + fileNameLen + extraLen;
const compressed = bytes.slice(dataStart, dataStart + compressedSize); const compressed = bytes.slice(dataStart, dataStart + compressedSize);
if (compressionMethod === 0) { if (compressionMethod === 0) {
// Stored — no compression // Stored - no compression
return compressed; return compressed;
} }
if (compressionMethod !== 8) { if (compressionMethod !== 8) {
throw new Error(`Unsupported ZIP compression method ${compressionMethod}`); throw new Error(`Unsupported ZIP compression method ${compressionMethod}`);
} }
// DEFLATE (raw, no zlib header) — decompress with browser-native stream. // DEFLATE (raw, no zlib header) - decompress with browser-native stream.
// Cap decompressed size to 50 MB to prevent ZIP-bomb attacks: a crafted // Cap decompressed size to 50 MB to prevent ZIP-bomb attacks: a crafted
// .mxl with a tiny compressed payload that expands to gigabytes would // .mxl with a tiny compressed payload that expands to gigabytes would
// otherwise freeze or crash the browser tab. // otherwise freeze or crash the browser tab.
const MAX_DECOMPRESSED = 50 * 1024 * 1024; // 50 MB - largest real scores are <5 MB const MAX_DECOMPRESSED = 50 * 1024 * 1024; // 50 MB - largest real scores are <5 MB
const ds = new DecompressionStream("deflate-raw"); const ds = new DecompressionStream("deflate-raw");
const writer = ds.writable.getWriter(); const writer = ds.writable.getWriter();
const reader = ds.readable.getReader(); const reader = ds.readable.getReader();
@@ -109,14 +112,14 @@ async function readLocalEntry(
if (done) break; if (done) break;
totalDecompressed += value.length; totalDecompressed += value.length;
if (totalDecompressed > MAX_DECOMPRESSED) { if (totalDecompressed > MAX_DECOMPRESSED) {
throw new Error("Decompressed MusicXML exceeds 50 MB — possible ZIP bomb"); throw new Error("Decompressed MusicXML exceeds 50 MB - possible ZIP bomb");
} }
chunks.push(value); chunks.push(value);
} }
const total = chunks.reduce((n, c) => n + c.length, 0); const total = chunks.reduce((n, c) => n + c.length, 0);
const result = new Uint8Array(total); const result = new Uint8Array(total);
let cursor = 0; let cursor = 0;
for (const chunk of chunks) { for (const chunk of chunks) {
result.set(chunk, cursor); result.set(chunk, cursor);
cursor += chunk.length; cursor += chunk.length;
@@ -129,22 +132,22 @@ async function readLocalEntry(
* Throws if the archive is malformed or missing required files. * Throws if the archive is malformed or missing required files.
*/ */
export async function extractXmlFromMxl(buffer: ArrayBuffer): Promise<string> { export async function extractXmlFromMxl(buffer: ArrayBuffer): Promise<string> {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
const view = new DataView(buffer); const view = new DataView(buffer);
const entries = readCentralDirectory(view, bytes); const entries = readCentralDirectory(view, bytes);
const find = (name: string) => entries.find(e => e.name === name); const find = (name: string) => entries.find((e) => e.name === name);
// 1. Read META-INF/container.xml // 1. Read META-INF/container.xml
const containerEntry = find("META-INF/container.xml"); const containerEntry = find("META-INF/container.xml");
if (!containerEntry) throw new Error("Not a valid .mxl file: missing META-INF/container.xml"); if (!containerEntry) throw new Error("Not a valid .mxl file: missing META-INF/container.xml");
const containerBytes = await readLocalEntry(view, bytes, containerEntry); const containerBytes = await readLocalEntry(view, bytes, containerEntry);
const containerXml = new TextDecoder("utf-8").decode(containerBytes); const containerXml = new TextDecoder("utf-8").decode(containerBytes);
// 2. Parse the root-file path (e.g. full-path="score.xml") // 2. Parse the root-file path (e.g. full-path="score.xml")
const match = containerXml.match(/full-path="([^"]+)"/); const match = containerXml.match(/full-path="([^"]+)"/);
if (!match) throw new Error('Malformed container.xml: no full-path attribute'); if (!match) throw new Error("Malformed container.xml: no full-path attribute");
const rootPath = match[1]; const rootPath = match[1];
// 3. Read the root MusicXML entry // 3. Read the root MusicXML entry
+176 -87
View File
@@ -12,27 +12,34 @@ import { midiToPitch, keySignatureToLabel } from "@bach-to-basics/shared";
import { syncEngine } from "../engine/SyncEngine"; import { syncEngine } from "../engine/SyncEngine";
const TAB_LABELS: Record<LayoutMode, string> = { const TAB_LABELS: Record<LayoutMode, string> = {
piano: "Piano", piano: "Piano",
falling: "Notes", falling: "Notes",
sheet: "Sheet", sheet: "Sheet",
all: "All", all: "All",
}; };
const TAB_TITLES: Record<LayoutMode, string> = { const TAB_TITLES: Record<LayoutMode, string> = {
piano: "Show only the piano keyboard view", piano: "Show only the piano keyboard view",
falling: "Show only the falling notes view", falling: "Show only the falling notes view",
sheet: "Show only the sheet music view", sheet: "Show only the sheet music view",
all: "Show all views at once", all: "Show all views at once",
}; };
export function PracticeView() { export function PracticeView() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const { const {
loadMidiFile, loadMusicXmlFile, loadPdfFile, loadMidiFile,
settings, updateSettings, loadMusicXmlFile,
status, play, pause, seek, loadPdfFile,
loadError, clearLoadError, settings,
updateSettings,
status,
play,
pause,
seek,
loadError,
clearLoadError,
document: doc, document: doc,
isLoadingDocument, isLoadingDocument,
} = useAppStore(); } = useAppStore();
@@ -85,17 +92,23 @@ export function PracticeView() {
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [seek]); }, [seek]);
const loadFile = useCallback((file: File) => { const loadFile = useCallback(
if (file.name.match(/\.midi?$/i)) loadMidiFile(file); (file: File) => {
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file); if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file); else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file);
}, [loadMidiFile, loadMusicXmlFile, loadPdfFile]); else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
},
[loadMidiFile, loadMusicXmlFile, loadPdfFile]
);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDrop = useCallback(
e.preventDefault(); (e: React.DragEvent<HTMLDivElement>) => {
const file = e.dataTransfer.files[0]; e.preventDefault();
if (file) loadFile(file); const file = e.dataTransfer.files[0];
}, [loadFile]); if (file) loadFile(file);
},
[loadFile]
);
const { layoutMode } = settings; const { layoutMode } = settings;
const setLayout = (mode: LayoutMode) => updateSettings({ layoutMode: mode }); const setLayout = (mode: LayoutMode) => updateSettings({ layoutMode: mode });
@@ -130,14 +143,14 @@ export function PracticeView() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 shrink-0" className="flex items-center gap-2 shrink-0"
style={{ textDecoration: "none" }} style={{ textDecoration: "none" }}
aria-label="Bach to Basics on GitHub"
> >
<div <div
role="img" role="img"
aria-hidden="true" aria-hidden="true"
className="flex items-center justify-center shrink-0 select-none" className="flex items-center justify-center shrink-0 select-none"
style={{ style={{
width: 32, height: 32, width: 32,
height: 32,
background: "linear-gradient(135deg, #7c3aed 0%, #9333ea 100%)", background: "linear-gradient(135deg, #7c3aed 0%, #9333ea 100%)",
borderRadius: 8, borderRadius: 8,
boxShadow: "0 3px 10px rgba(147,51,234,0.45)", boxShadow: "0 3px 10px rgba(147,51,234,0.45)",
@@ -146,10 +159,15 @@ export function PracticeView() {
<PianoKeysIcon /> <PianoKeysIcon />
</div> </div>
{/* Typographic pun: "Bach" and "Basics" bold, "to" muted */} {/* Typographic pun: "Bach" and "Basics" bold, "to" muted */}
<span style={{ fontSize: 15, whiteSpace: "nowrap", letterSpacing: "-0.3px", lineHeight: 1 }}> <span
style={{ fontSize: 15, whiteSpace: "nowrap", letterSpacing: "-0.3px", lineHeight: 1 }}
>
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>Bach</span> <span style={{ fontWeight: 700, color: "var(--color-text)" }}>Bach</span>
<span style={{ fontWeight: 400, color: "var(--color-text-muted)", margin: "0 3px" }}>to</span> <span style={{ fontWeight: 400, color: "var(--color-text-muted)", margin: "0 3px" }}>
to
</span>
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>Basics</span> <span style={{ fontWeight: 700, color: "var(--color-text)" }}>Basics</span>
<span className="sr-only"> on GitHub</span>
</span> </span>
</a> </a>
@@ -160,7 +178,12 @@ export function PracticeView() {
> >
<div <div
className="flex items-center" className="flex items-center"
style={{ padding: 3, background: "var(--color-surface-2)", borderRadius: 999, border: "1px solid var(--color-border)" }} style={{
padding: 3,
background: "var(--color-surface-2)",
borderRadius: 999,
border: "1px solid var(--color-border)",
}}
> >
{(["piano", "falling", "sheet", "all"] as LayoutMode[]).map((mode) => ( {(["piano", "falling", "sheet", "all"] as LayoutMode[]).map((mode) => (
<button <button
@@ -175,8 +198,8 @@ export function PracticeView() {
borderRadius: 999, borderRadius: 999,
border: "none", border: "none",
background: layoutMode === mode ? "var(--color-accent)" : "transparent", background: layoutMode === mode ? "var(--color-accent)" : "transparent",
color: layoutMode === mode ? "#ffffff" : "var(--color-text-muted)", color: layoutMode === mode ? "#ffffff" : "var(--color-text-muted)",
boxShadow: layoutMode === mode ? "0 0 12px rgba(147,51,234,0.35)" : "none", boxShadow: layoutMode === mode ? "0 0 12px rgba(147,51,234,0.35)" : "none",
cursor: "pointer", cursor: "pointer",
}} }}
> >
@@ -194,7 +217,13 @@ export function PracticeView() {
title={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"} title={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
aria-label={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"} aria-label={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className="flex items-center justify-center rounded transition-colors shrink-0" className="flex items-center justify-center rounded transition-colors shrink-0"
style={{ width: 30, height: 30, background: "var(--color-surface-2)", border: "1px solid var(--color-border)", color: "var(--color-text-muted)" }} style={{
width: 30,
height: 30,
background: "var(--color-surface-2)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
> >
{settings.theme === "dark" ? <SunIcon /> : <MoonIcon />} {settings.theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button> </button>
@@ -203,7 +232,13 @@ export function PracticeView() {
title="Settings" title="Settings"
aria-label="Open settings" aria-label="Open settings"
className="flex items-center justify-center rounded transition-colors shrink-0" className="flex items-center justify-center rounded transition-colors shrink-0"
style={{ width: 30, height: 30, background: "var(--color-surface-2)", border: "1px solid var(--color-border)", color: "var(--color-text-muted)" }} style={{
width: 30,
height: 30,
background: "var(--color-surface-2)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
> >
<GearIcon /> <GearIcon />
</button> </button>
@@ -211,7 +246,7 @@ export function PracticeView() {
</header> </header>
{/* ── Main content - layout-aware ────────────────────────────────────── */} {/* ── Main content - layout-aware ────────────────────────────────────── */}
<div className="flex flex-1 overflow-hidden min-h-0 relative"> <main className="flex flex-1 overflow-hidden min-h-0 relative">
{layoutMode === "piano" && ( {layoutMode === "piano" && (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<PianoModeBackground /> <PianoModeBackground />
@@ -244,12 +279,10 @@ export function PracticeView() {
</div> </div>
</> </>
)} )}
</div> </main>
{/* ── Empty state overlay ────────────────────────────────────────────── */} {/* ── Empty state overlay ────────────────────────────────────────────── */}
{!doc && !isLoadingDocument && ( {!doc && !isLoadingDocument && <EmptyState onImport={loadFile} />}
<EmptyState onImport={loadFile} />
)}
{/* ── Error toast ────────────────────────────────────────────────────── */} {/* ── Error toast ────────────────────────────────────────────────────── */}
{loadError && <ErrorToast message={loadError} onDismiss={clearLoadError} />} {loadError && <ErrorToast message={loadError} onDismiss={clearLoadError} />}
@@ -262,7 +295,6 @@ export function PracticeView() {
{/* ── Settings panel (managed here, gear is in header) ───────────────── */} {/* ── Settings panel (managed here, gear is in header) ───────────────── */}
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} /> <SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div> </div>
); );
} }
@@ -290,11 +322,15 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) {
{/* Illustration */} {/* Illustration */}
<div <div
style={{ style={{
width: 72, height: 72, width: 72,
height: 72,
borderRadius: 18, borderRadius: 18,
background: "linear-gradient(135deg, rgba(147,51,234,0.25) 0%, rgba(147,51,234,0.10) 100%), var(--color-notes-bg)", background:
"linear-gradient(135deg, rgba(147,51,234,0.25) 0%, rgba(147,51,234,0.10) 100%), var(--color-notes-bg)",
border: "1px solid rgba(147,51,234,0.25)", border: "1px solid rgba(147,51,234,0.25)",
display: "flex", alignItems: "center", justifyContent: "center", display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--color-accent-text)", color: "var(--color-accent-text)",
}} }}
> >
@@ -315,20 +351,29 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
style={{ style={{
pointerEvents: "all", pointerEvents: "all",
display: "flex", alignItems: "center", gap: 8, display: "flex",
alignItems: "center",
gap: 8,
padding: "11px 28px", padding: "11px 28px",
background: "var(--color-accent)", background: "var(--color-accent)",
color: "#fff", color: "#fff",
border: "none", border: "none",
borderRadius: 10, borderRadius: 10,
fontSize: 14, fontWeight: 600, fontSize: 14,
fontWeight: 600,
cursor: "pointer", cursor: "pointer",
boxShadow: "0 0 24px rgba(147,51,234,0.45)", boxShadow: "0 0 24px rgba(147,51,234,0.45)",
fontFamily: "inherit", fontFamily: "inherit",
transition: "all 0.15s", transition: "all 0.15s",
}} }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)"; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 34px rgba(147,51,234,0.6)"; }} onMouseEnter={(e) => {
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = ""; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 24px rgba(147,51,234,0.45)"; }} (e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)";
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 34px rgba(147,51,234,0.6)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.transform = "";
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 24px rgba(147,51,234,0.45)";
}}
> >
<UploadIconLg /> <UploadIconLg />
Import a file Import a file
@@ -343,7 +388,11 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) {
type="file" type="file"
accept=".mid,.midi,.xml,.mxl,.pdf" accept=".mid,.midi,.xml,.mxl,.pdf"
className="hidden" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImport(f);
e.target.value = "";
}}
/> />
</div> </div>
); );
@@ -351,7 +400,7 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) {
const UploadIconLg = () => ( const UploadIconLg = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/> <path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z" />
</svg> </svg>
); );
@@ -359,12 +408,12 @@ const UploadIconLg = () => (
// Mirrors the grid drawn by FallingNotesRenderer so the piano-only view has the // Mirrors the grid drawn by FallingNotesRenderer so the piano-only view has the
// same dark background + black-key lanes + octave separator lines. // same dark background + black-key lanes + octave separator lines.
const MIDI_MIN_BG = 21; // A0 const MIDI_MIN_BG = 21; // A0
const MIDI_MAX_BG = 108; // C8 const MIDI_MAX_BG = 108; // C8
/** Precompute whiteKeyIndex (cumulative white-key counter per MIDI note). */ /** Precompute whiteKeyIndex (cumulative white-key counter per MIDI note). */
function buildWhiteKeyIndex() { function buildWhiteKeyIndex() {
const isBlk = (m: number) => [1,3,6,8,10].includes(m % 12); const isBlk = (m: number) => [1, 3, 6, 8, 10].includes(m % 12);
const idx = new Array<number>(MIDI_MAX_BG + 1).fill(0); const idx = new Array<number>(MIDI_MAX_BG + 1).fill(0);
let wi = 0; let wi = 0;
for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) { for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) {
@@ -381,7 +430,7 @@ function drawPianoGrid(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
const isBlk = (m: number) => [1,3,6,8,10].includes(m % 12); const isBlk = (m: number) => [1, 3, 6, 8, 10].includes(m % 12);
const wW = width / TOTAL_WHITE_BG; const wW = width / TOTAL_WHITE_BG;
// Clear to transparent - the CSS background on the parent div provides the colour. // Clear to transparent - the CSS background on the parent div provides the colour.
@@ -398,37 +447,40 @@ function drawPianoGrid(canvas: HTMLCanvasElement) {
} }
function PianoModeBackground() { function PianoModeBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { settings } = useAppStore(); const { settings } = useAppStore();
// Reusable draw callback // Reusable draw callback
const redraw = useMemo(() => () => { const redraw = useMemo(
const canvas = canvasRef.current; () => () => {
if (!canvas) return; const canvas = canvasRef.current;
drawPianoGrid(canvas); if (!canvas) return;
}, []); drawPianoGrid(canvas);
},
[]
);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!container || !canvas) return; if (!container || !canvas) return;
const ro = new ResizeObserver(([entry]) => { const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect; const { width, height } = entry.contentRect;
canvas.width = Math.round(width); canvas.width = Math.round(width);
canvas.height = Math.round(height); canvas.height = Math.round(height);
if (settings.showGrid) redraw(); if (settings.showGrid) redraw();
}); });
ro.observe(container); ro.observe(container);
// Initial size // Initial size
canvas.width = container.clientWidth; canvas.width = container.clientWidth;
canvas.height = container.clientHeight; canvas.height = container.clientHeight;
if (settings.showGrid) redraw(); if (settings.showGrid) redraw();
return () => ro.disconnect(); return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [redraw]); }, [redraw]);
// Redraw when showGrid toggles // Redraw when showGrid toggles
@@ -449,15 +501,12 @@ function PianoModeBackground() {
className="flex-1 relative" className="flex-1 relative"
style={{ style={{
minHeight: 0, minHeight: 0,
background: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)", background:
"radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
borderTop: "1px solid var(--color-notes-border)", borderTop: "1px solid var(--color-notes-border)",
}} }}
> >
<canvas <canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
ref={canvasRef}
style={{ display: "block", width: "100%", height: "100%" }}
/>
</div> </div>
); );
} }
@@ -484,30 +533,67 @@ function StatusBar() {
const bpm = doc?.tempoMap[0]?.bpm ?? null; const bpm = doc?.tempoMap[0]?.bpm ?? null;
const effectiveBpm = bpm ? Math.round(bpm * tempoMultiplier) : null; const effectiveBpm = bpm ? Math.round(bpm * tempoMultiplier) : null;
const fmt = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, "0")}`; const fmt = (s: number) =>
`${Math.floor(s / 60)}:${Math.floor(s % 60)
.toString()
.padStart(2, "0")}`;
const keySig = doc?.keySignature ? keySignatureToLabel(doc.keySignature) : null; const keySig = doc?.keySignature ? keySignatureToLabel(doc.keySignature) : null;
const isActive = status === "playing" || status === "paused"; const isActive = status === "playing" || status === "paused";
return ( return (
<div <div
className="flex items-center gap-3 border-t flex-shrink-0 min-w-0" className="flex items-center gap-3 border-t flex-shrink-0 min-w-0"
style={{ height: 26, background: "var(--color-surface)", borderColor: "var(--color-border-subtle)", fontSize: 11, color: "var(--color-text-muted)", fontVariantNumeric: "tabular-nums", paddingLeft: 10, paddingRight: 12 }} style={{
height: 26,
background: "var(--color-surface)",
borderColor: "var(--color-border-subtle)",
fontSize: 11,
color: "var(--color-text-muted)",
fontVariantNumeric: "tabular-nums",
paddingLeft: 10,
paddingRight: 12,
}}
> >
{doc ? ( {doc ? (
<> <>
{isActive && ( {isActive && (
<span style={{ color: "var(--color-accent)", fontWeight: 600, flexShrink: 0 }}>Playing</span> <span style={{ color: "var(--color-accent)", fontWeight: 600, flexShrink: 0 }}>
Playing
</span>
)}
<span className="truncate" style={{ minWidth: 0 }}>
{doc.title}
</span>
{keySig && (
<>
<Dot />
<span className="shrink-0">{keySig}</span>
</>
)}
{effectiveBpm !== null && (
<>
<Dot />
<span className="shrink-0">{effectiveBpm} BPM</span>
</>
)}
{lastNote && (
<>
<Dot />
<span className="shrink-0" style={{ color: "var(--color-accent)" }}>
{lastNote}
</span>
</>
)} )}
<span className="truncate" style={{ minWidth: 0 }}>{doc.title}</span>
{keySig && <><Dot /><span className="shrink-0">{keySig}</span></>}
{effectiveBpm !== null && <><Dot /><span className="shrink-0">{effectiveBpm} BPM</span></>}
{lastNote && <><Dot /><span className="shrink-0" style={{ color: "var(--color-accent)" }}>{lastNote}</span></>}
</> </>
) : ( ) : (
<span>No file loaded - drag &amp; drop or click ↑ Import</span> <span>No file loaded - drag &amp; drop or click ↑ Import</span>
)} )}
<div className="flex-1 min-w-0" /> <div className="flex-1 min-w-0" />
{doc && <span className="shrink-0">{fmt(currentSeconds)} / {fmt(doc.totalDuration)}</span>} {doc && (
<span className="shrink-0">
{fmt(currentSeconds)} / {fmt(doc.totalDuration)}
</span>
)}
</div> </div>
); );
} }
@@ -531,7 +617,7 @@ function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () =>
role="alert" role="alert"
style={{ style={{
position: "fixed", position: "fixed",
bottom: 68, // above the transport bar bottom: 68, // above the transport bar
left: "50%", left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
zIndex: 2000, zIndex: 2000,
@@ -554,7 +640,10 @@ function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () =>
<span style={{ color: "#f87171", fontSize: 16, lineHeight: 1.2, flexShrink: 0 }}>⚠</span> <span style={{ color: "#f87171", fontSize: 16, lineHeight: 1.2, flexShrink: 0 }}>⚠</span>
<span style={{ flex: 1, wordBreak: "break-word" }}> <span style={{ flex: 1, wordBreak: "break-word" }}>
{message.split("\n").map((line, i) => ( {message.split("\n").map((line, i) => (
<span key={i}>{line}{i < message.split("\n").length - 1 && <br />}</span> <span key={i}>
{line}
{i < message.split("\n").length - 1 && <br />}
</span>
))} ))}
</span> </span>
<button <button
@@ -581,7 +670,7 @@ function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () =>
const MusicNoteIcon = () => ( const MusicNoteIcon = () => (
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/> <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg> </svg>
); );
@@ -589,31 +678,31 @@ const MusicNoteIcon = () => (
const PianoKeysIcon = () => ( const PianoKeysIcon = () => (
<svg width="18" height="14" viewBox="0 0 17 14" fill="none" aria-hidden="true"> <svg width="18" height="14" viewBox="0 0 17 14" fill="none" aria-hidden="true">
{/* White keys */} {/* White keys */}
<rect x="0" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/> <rect x="0" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
<rect x="3.5" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/> <rect x="3.5" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
<rect x="7" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/> <rect x="7" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
<rect x="10.5" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/> <rect x="10.5" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
<rect x="14" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/> <rect x="14" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
{/* Black keys */} {/* Black keys */}
<rect x="2" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)"/> <rect x="2" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)" />
<rect x="5.5" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)"/> <rect x="5.5" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)" />
<rect x="12.5" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)"/> <rect x="12.5" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)" />
</svg> </svg>
); );
const SunIcon = () => ( const SunIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2v-2H2v2zm18 0h2v-2h-2v2zM11 2v2h2V2h-2zm0 18v2h2v-2h-2zM5.64 6.35 4.22 4.93a1 1 0 0 0-1.41 1.41l1.41 1.42 1.42-1.41zM18.36 17.65l1.42 1.41a1 1 0 0 0 1.41-1.41l-1.41-1.42-1.42 1.42zM5.64 17.66l-1.42 1.41a1 1 0 0 1-1.41-1.41l1.41-1.42 1.42 1.42zM19.78 4.93l-1.42 1.42-1.41-1.42 1.42-1.41a1 1 0 0 1 1.41 1.41z"/> <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2v-2H2v2zm18 0h2v-2h-2v2zM11 2v2h2V2h-2zm0 18v2h2v-2h-2zM5.64 6.35 4.22 4.93a1 1 0 0 0-1.41 1.41l1.41 1.42 1.42-1.41zM18.36 17.65l1.42 1.41a1 1 0 0 0 1.41-1.41l-1.41-1.42-1.42 1.42zM5.64 17.66l-1.42 1.41a1 1 0 0 1-1.41-1.41l1.41-1.42 1.42 1.42zM19.78 4.93l-1.42 1.42-1.41-1.42 1.42-1.41a1 1 0 0 1 1.41 1.41z" />
</svg> </svg>
); );
const MoonIcon = () => ( const MoonIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/> <path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z" />
</svg> </svg>
); );
const GearIcon = () => ( const GearIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg> </svg>
); );
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "bach-to-basics", "name": "bach-to-basics",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"description": "Bach to Basics — browser-based piano learning app with falling notes, sheet music, and MIDI support", "description": "Bach to Basics - browser-based piano learning app with falling notes, sheet music, and MIDI support",
"author": "gigliof", "author": "gigliof",
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/gigliof/bach-to-basics#readme", "homepage": "https://github.com/gigliof/bach-to-basics#readme",