diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0fea341..886bf48 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -5,7 +5,7 @@ body: - type: markdown attributes: 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 id: problem diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 034a1f3..f70873a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: frontend: - name: Frontend — type-check & test + name: Frontend - type-check & test runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 132621a..deeed3b 100644 --- a/README.md +++ b/README.md @@ -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 | | `BACKEND_API_KEY` | _(unset = open)_ | When set, all requests must include `X-API-Key: ` | -| `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 | | `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 | diff --git a/SECURITY.md b/SECURITY.md index ee758a2..f2c3cf7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## 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 @@ -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 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. diff --git a/frontend/index.html b/frontend/index.html index f51548f..42305c0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,7 @@ tighten by moving it to a separate .js file if desired. --> Bach to Basics + diff --git a/frontend/package.json b/frontend/package.json index 607ba96..a33bc06 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,7 +2,7 @@ "name": "frontend", "private": true, "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", "license": "MIT", "homepage": "https://github.com/gigliof/bach-to-basics#readme", diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/frontend/src/components/FallingNotes/FallingNotesRenderer.ts b/frontend/src/components/FallingNotes/FallingNotesRenderer.ts index e0c46ea..8998b00 100644 --- a/frontend/src/components/FallingNotes/FallingNotesRenderer.ts +++ b/frontend/src/components/FallingNotes/FallingNotesRenderer.ts @@ -1,7 +1,13 @@ import * as PIXI from "pixi.js"; import { isBlackKey, midiToNoteName, midiToPitch } from "@bach-to-basics/shared"; import type { NoteEvent } from "@bach-to-basics/shared"; -import type { NoteFilter, ColorTheme, CustomColors, NoteLabelMode, ImpactStyle } from "../../store/useAppStore"; +import type { + NoteFilter, + ColorTheme, + CustomColors, + NoteLabelMode, + ImpactStyle, +} from "../../store/useAppStore"; import type { SustainRange } from "@bach-to-basics/shared"; import { lighten, hexToNum } from "../../utils/colorUtils"; @@ -10,7 +16,9 @@ const MIDI_MIN = 21; const MIDI_MAX = 108; 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); let wi = 0; @@ -33,24 +41,44 @@ interface ThemePalette { const THEMES: Record, ThemePalette> = { violet: { - left: 0x7c3aed, right: 0x8b5cf6, unknown: 0x8b5cf6, - leftBlack: 0xa78bfa, rightBlack: 0xc4b5fd, unknownBlack: 0xc4b5fd, + left: 0x7c3aed, + right: 0x8b5cf6, + unknown: 0x8b5cf6, + leftBlack: 0xa78bfa, + rightBlack: 0xc4b5fd, + unknownBlack: 0xc4b5fd, }, classic: { - left: 0x2563eb, right: 0xdc2626, unknown: 0x7c3aed, - leftBlack: 0x60a5fa, rightBlack: 0xf87171, unknownBlack: 0xa78bfa, + left: 0x2563eb, + right: 0xdc2626, + unknown: 0x7c3aed, + leftBlack: 0x60a5fa, + rightBlack: 0xf87171, + unknownBlack: 0xa78bfa, }, ocean: { - left: 0x0284c7, right: 0x06b6d4, unknown: 0x0ea5e9, - leftBlack: 0x38bdf8, rightBlack: 0x67e8f9, unknownBlack: 0x7dd3fc, + left: 0x0284c7, + right: 0x06b6d4, + unknown: 0x0ea5e9, + leftBlack: 0x38bdf8, + rightBlack: 0x67e8f9, + unknownBlack: 0x7dd3fc, }, forest: { - left: 0x15803d, right: 0x16a34a, unknown: 0x22c55e, - leftBlack: 0x4ade80, rightBlack: 0x86efac, unknownBlack: 0xbbf7d0, + left: 0x15803d, + right: 0x16a34a, + unknown: 0x22c55e, + leftBlack: 0x4ade80, + rightBlack: 0x86efac, + unknownBlack: 0xbbf7d0, }, cascade: { - left: 0x9333ea, right: 0x22d3ee, unknown: 0xa855f7, - leftBlack: 0xb06af5, rightBlack: 0x4dd5ec, unknownBlack: 0xb87af8, + left: 0x9333ea, + 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 u = hexToNum(c.unknown); return { - left: l, - right: r, - unknown: u, - leftBlack: lighten(l, 1.55), - rightBlack: lighten(r, 1.55), + left: l, + right: r, + unknown: u, + leftBlack: lighten(l, 1.55), + rightBlack: lighten(r, 1.55), unknownBlack: lighten(u, 1.55), }; } @@ -73,16 +101,17 @@ function noteColor( note: NoteEvent, showHandColors: boolean, theme: ColorTheme, - customColors?: CustomColors, + customColors?: CustomColors ): number { - const pal = theme === "custom" && customColors - ? paletteFromCustom(customColors) - : THEMES[theme as Exclude]; + const pal = + theme === "custom" && customColors + ? paletteFromCustom(customColors) + : THEMES[theme as Exclude]; const black = isBlackKey(note.midi); if (!showHandColors || note.hand === "unknown") { return black ? pal.unknownBlack : pal.unknown; } - if (note.hand === "left") return black ? pal.leftBlack : pal.left; + if (note.hand === "left") return black ? pal.leftBlack : pal.left; if (note.hand === "right") return black ? pal.rightBlack : pal.right; return black ? pal.unknownBlack : pal.unknown; } @@ -93,13 +122,16 @@ function noteColor( * Unlike multiplicative lighten(), this NEVER blows light colours to pure white. */ function mixColor(c1: number, c2: number, t: number): number { - const r1 = (c1 >> 16) & 0xff, r2 = (c2 >> 16) & 0xff; - const g1 = (c1 >> 8) & 0xff, g2 = (c2 >> 8) & 0xff; - const b1 = c1 & 0xff, b2 = c2 & 0xff; + const r1 = (c1 >> 16) & 0xff, + r2 = (c2 >> 16) & 0xff; + const g1 = (c1 >> 8) & 0xff, + g2 = (c2 >> 8) & 0xff; + const b1 = c1 & 0xff, + b2 = c2 & 0xff; return ( (Math.round(r1 + (r2 - r1) * t) << 16) | - (Math.round(g1 + (g2 - g1) * t) << 8) | - Math.round(b1 + (b2 - b1) * t) + (Math.round(g1 + (g2 - g1) * t) << 8) | + Math.round(b1 + (b2 - b1) * t) ); } @@ -107,13 +139,13 @@ function makeGradient(base: number): PIXI.FillGradient { return new PIXI.FillGradient({ type: "linear", start: { x: 0, y: 0 }, - end: { x: 0, y: 1 }, + end: { x: 0, y: 1 }, textureSpace: "local", colorStops: [ - { offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white + { offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white { offset: 0.22, color: mixColor(base, 0xffffff, 0.22) }, // slightly bright - { offset: 0.62, color: base }, // solid base - { offset: 1, color: mixColor(base, 0x000000, 0.30) }, // deeper bottom + { offset: 0.62, color: base }, // solid base + { 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. // No hard strokes, no particles - purely diffuse light that dissolves gently. class ImpactFlash { - container: PIXI.Container; + container: PIXI.Container; private outerBloom: PIXI.Graphics; // large, very soft, low-opacity halo private innerBloom: PIXI.Graphics; // smaller, slightly more visible glow - private sparkle: PIXI.Graphics; // tiny bright flash at moment of contact + private sparkle: PIXI.Graphics; // tiny bright flash at moment of contact active = false; private t = 0; private noteW = 0; private baseColor = 0xffffff; constructor() { - this.container = new PIXI.Container(); + this.container = new PIXI.Container(); this.outerBloom = 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 this.container.addChild(this.outerBloom, this.innerBloom, this.sparkle); this.container.visible = false; } trigger(cx: number, cy: number, w: number, color: number) { - this.noteW = w; + this.noteW = w; this.baseColor = color; - this.t = 0; - this.active = true; + this.t = 0; + this.active = true; this.container.visible = true; this.container.x = cx; this.container.y = cy; @@ -155,7 +187,10 @@ class ImpactFlash { /** dtFrames: PIXI ticker.deltaTime (1.0 = one 60fps frame). Frame-rate independent. */ advance(dtFrames: number): boolean { this.t += dtFrames * 0.033; // ~500ms total at 60fps - if (this.t >= 1) { this.reset(); return true; } + if (this.t >= 1) { + this.reset(); + return true; + } this._draw(); return false; } @@ -172,12 +207,12 @@ class ImpactFlash { this.innerBloom.fill({ color: baseColor, alpha: (1 - t) * 0.28 }); // ── Outer bloom - large diffuse halo, 80ms delayed start ───────────────── - const t2 = Math.max(0, t - 0.10); + const t2 = Math.max(0, t - 0.1); 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.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 ── // 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); if (sr > 0.5) { 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( note: NoteEvent, - x: number, barWidth: number, - startY: number, barHeight: number, + x: number, + barWidth: number, + startY: number, + barHeight: number, gradient: PIXI.FillGradient, baseColor: number, labelText: string, cornerRadius: number, - showOutline: boolean, + showOutline: boolean ) { this.note = note; this.active = true; @@ -290,16 +327,22 @@ class NoteBar { /** Keep label clamped within the visible portion of the bar (for very tall notes). */ updateLabelY(containerY: number, barHeight: number, viewportHeight: number) { - if (!this.labelWanted) { this.label.visible = false; return; } + if (!this.labelWanted) { + this.label.visible = false; + return; + } // 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); - if (localBottom <= localTop) { this.label.visible = false; return; } + if (localBottom <= localTop) { + this.label.visible = false; + return; + } this.label.visible = true; const midLocal = (localTop + localBottom) / 2; // Only reposition if the natural center is outside the visible slice const natural = barHeight / 2; - this.label.y = (natural >= localTop && natural <= localBottom) ? natural : midLocal; + this.label.y = natural >= localTop && natural <= localBottom ? natural : midLocal; } reset() { @@ -331,7 +374,7 @@ export interface FallingNotesOptions { /** Which visual effect plays when a note reaches the hit line */ impactStyle?: ImpactStyle; /** Minimum pixel height for note bars - prevents staccato slivers */ - minNoteHeight?: number; // default 8 + minNoteHeight?: number; // default 8 /** Border radius for note bars (0 = sharp, 12 = pill) */ noteCornerRadius?: number; // default 4 /** Draw horizontal beat/measure lines across the canvas */ @@ -370,17 +413,26 @@ export class FallingNotesRenderer { private measureLayer!: PIXI.Container; private particleLayer!: PIXI.Graphics; private particles: Array<{ - x: number; y: number; vx: number; vy: number; - size: number; life: number; decay: number; - gravity: number; color: number; + x: number; + y: number; + vx: number; + vy: number; + size: number; + life: number; + decay: number; + gravity: number; + color: number; }> = []; private gridLayer!: PIXI.Graphics; private beatLayer!: PIXI.Graphics; - private sustainLineLayer!: PIXI.Graphics; // thin connecting line - private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool + private sustainLineLayer!: PIXI.Graphics; // thin connecting line + private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool /** Ghost indicators for notes that ended while sustain was held */ private sustainedNoteLayer!: PIXI.Graphics; - private sustainedNotes = new Map(); + private sustainedNotes = new Map< + string, + { midi: number; baseColor: number; endSeconds: number } + >(); constructor(opts: FallingNotesOptions) { this.opts = opts; @@ -452,7 +504,12 @@ export class FallingNotesRenderer { for (let i = 0; i < 60; i++) { const t = new PIXI.Text({ text: "", - style: { fontSize: 11, fill: 0x8896b8, fontFamily: "Space Grotesk, system-ui, sans-serif", fontWeight: "600" }, + style: { + fontSize: 11, + fill: 0x8896b8, + fontFamily: "Space Grotesk, system-ui, sans-serif", + fontWeight: "600", + }, }); t.x = 3; t.visible = false; @@ -460,10 +517,17 @@ export class FallingNotesRenderer { } this.app.stage.addChild( - this.gridLayer, this.sustainLineLayer, this.sustainTextLayer, - this.beatLayer, noteLayer, this.particleLayer, + this.gridLayer, + this.sustainLineLayer, + this.sustainTextLayer, + this.beatLayer, + noteLayer, + this.particleLayer, this.sustainedNoteLayer, - this.measureLayer, this.flashLayer, this.hitLine, this.ghostBar, + this.measureLayer, + this.flashLayer, + this.hitLine, + this.ghostBar ); this._initialized = true; } @@ -501,7 +565,10 @@ export class FallingNotesRenderer { private getGradient(base: number): PIXI.FillGradient { 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; } @@ -521,7 +588,7 @@ export class FallingNotesRenderer { return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : ""; } if (mode === "white" && !white) return ""; - if (mode === "black" && white) return ""; + if (mode === "black" && white) return ""; // "white", "black", "all": note name class only (no octave) 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 private passesFilter(note: NoteEvent): boolean { switch (this.opts.noteFilter) { - case "white": return isWhite(note.midi); - case "black": return isBlackKey(note.midi); - case "c_only": return note.midi % 12 === 0; - default: return true; + case "white": + return isWhite(note.midi); + case "black": + 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++) { const side = Math.random() < 0.5 ? -1 : 1; this.particles.push({ - x: cx + side * (noteW * 0.5 + 1 + Math.random() * 3), - y: cy, - vx: side * (1.8 + Math.random() * 3.8), - vy: -(0.5 + Math.random() * 2.8), + x: cx + side * (noteW * 0.5 + 1 + Math.random() * 3), + y: cy, + vx: side * (1.8 + Math.random() * 3.8), + vy: -(0.5 + Math.random() * 2.8), size: 1.4 + Math.random() * 2.4, life: 1.0, decay: 0.024 + Math.random() * 0.018, @@ -560,10 +631,10 @@ export class FallingNotesRenderer { if (Math.random() > 0.38) return; const side = Math.random() < 0.5 ? -1 : 1; this.particles.push({ - x: cx + side * (noteW * 0.5), - y: cy - Math.random() * 22, - vx: side * (0.5 + Math.random() * 1.4), - vy: -(0.2 + Math.random() * 1.0), + x: cx + side * (noteW * 0.5), + y: cy - Math.random() * 22, + vx: side * (0.5 + Math.random() * 1.4), + vy: -(0.2 + Math.random() * 1.0), size: 1.0 + Math.random() * 1.6, life: 1.0, decay: 0.04 + Math.random() * 0.03, @@ -573,7 +644,7 @@ export class FallingNotesRenderer { } 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); } @@ -595,7 +666,7 @@ export class FallingNotesRenderer { if (this.active.has(note.id)) return; if (!this.passesFilter(note)) return; - // Skip notes whose bottom edge has already scrolled 60 px below the hit line — + // Skip notes whose bottom edge has already scrolled 60 px below the hit line - // they would be recycled on the very next tick anyway, so don't waste a pool slot. // Threshold mirrors the recycle condition in tick(): bar.container.y > hitY + 60. // barBottomPastHit = (currentSeconds - note.startSeconds) * pxPerSec - barHeight @@ -620,10 +691,26 @@ export class FallingNotesRenderer { const minH = this.opts.minNoteHeight ?? 8; const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec); const timeUntilHit = note.startSeconds - currentSeconds; - const startY = (height - 4) - barHeight - timeUntilHit * pxPerSec; + const startY = height - 4 - barHeight - timeUntilHit * pxPerSec; - const base = noteColor(note, this.opts.showHandColors, this.opts.colorTheme, this.opts.customColors); - bar.configure(note, x, barWidth, startY, barHeight, this.getGradient(base), base, this.noteLabel(note), this.opts.noteCornerRadius ?? 4, this.opts.showNoteOutline ?? false); + const base = noteColor( + note, + this.opts.showHandColors, + this.opts.colorTheme, + this.opts.customColors + ); + bar.configure( + note, + x, + barWidth, + startY, + barHeight, + this.getGradient(base), + base, + this.noteLabel(note), + this.opts.noteCornerRadius ?? 4, + this.opts.showNoteOutline ?? false + ); this.active.set(note.id, bar); } @@ -633,17 +720,17 @@ export class FallingNotesRenderer { const pxPerSec = height / viewportSeconds; const hitY = height - 4; - const minH = this.opts.minNoteHeight ?? 8; + const minH = this.opts.minNoteHeight ?? 8; const cornerRadius = this.opts.noteCornerRadius ?? 4; - const impactStyle = this.opts.impactStyle ?? "bloom"; - const doSustained = this.opts.showSustainedNotes ?? false; + const impactStyle = this.opts.impactStyle ?? "bloom"; + const doSustained = this.opts.showSustainedNotes ?? false; const sustainActive = doSustained && this.isSustainActive(currentSeconds); for (const [id, bar] of this.active) { if (!bar.note) continue; - const barHeight = Math.max(minH, (bar.note.endSeconds - bar.note.startSeconds) * pxPerSec); + const barHeight = Math.max(minH, (bar.note.endSeconds - bar.note.startSeconds) * pxPerSec); const timeUntilHit = bar.note.startSeconds - currentSeconds; - bar.container.y = hitY - barHeight - timeUntilHit * pxPerSec; + bar.container.y = hitY - barHeight - timeUntilHit * pxPerSec; bar.updateLabelY(bar.container.y, barHeight, height); // ── Impact effect ─────────────────────────────────────────────────────── @@ -652,10 +739,8 @@ export class FallingNotesRenderer { bar.impactFired = true; const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW; const cx = bar.container.x + bw * 0.5; - if (impactStyle === "bloom") - this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor); - else if (impactStyle === "side") - this.spawnBurst(cx, hitY - 3, bw, bar.baseColor); + if (impactStyle === "bloom") this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor); + else if (impactStyle === "side") this.spawnBurst(cx, hitY - 3, bw, bar.baseColor); // "trail" is handled continuously below } } @@ -665,7 +750,12 @@ export class FallingNotesRenderer { const barTopY = bar.container.y; if (barTopY + barHeight < hitY && barTopY < hitY) { const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW; - this.spawnTrail(bar.container.x + bw * 0.5, barTopY + barHeight * 0.35, bw, bar.baseColor); + 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; if (timeAfterEnd < 3.0) { this.sustainedNotes.set(id, { - midi: bar.note.midi, - baseColor: bar.baseColor, + midi: bar.note.midi, + baseColor: bar.baseColor, endSeconds: bar.note.endSeconds, }); } @@ -699,9 +789,9 @@ export class FallingNotesRenderer { this.sustainedNotes.delete(sid); continue; } - const bw = Math.max(5, (isWhite(sn.midi) ? this.whiteW : this.blackW) - 2); - const x = this.noteX(sn.midi) + 1; - const r = Math.min(cornerRadius, bw * 0.25); + const bw = Math.max(5, (isWhite(sn.midi) ? this.whiteW : this.blackW) - 2); + const x = this.noteX(sn.midi) + 1; + const r = Math.min(cornerRadius, bw * 0.25); // Fade out over the last second of the 3s window const alpha = Math.min(0.32, 0.32 * (1 - Math.max(0, timeAfterEnd - 2.0))); this.sustainedNoteLayer.roundRect(x, hitY - ghostH, bw, ghostH, r); @@ -719,9 +809,9 @@ export class FallingNotesRenderer { // ── Particles ────────────────────────────────────────────────────────── this.particleLayer.clear(); if (this.particles.length > 0) { - this.particles = this.particles.filter(p => { - p.x += p.vx; - p.y += p.vy; + this.particles = this.particles.filter((p) => { + p.x += p.vx; + p.y += p.vy; p.vy += p.gravity; p.life -= p.decay; return p.life > 0; @@ -758,7 +848,7 @@ export class FallingNotesRenderer { if ((this.opts.showBeatLines ?? false) && this.opts.beatStarts?.length) { this.beatLayer.clear(); // Build a Set of measure-start times (×100 for int comparison) for styling - const mSet = new Set((this.opts.measureStarts ?? []).map(s => Math.round(s * 100))); + const mSet = new Set((this.opts.measureStarts ?? []).map((s) => Math.round(s * 100))); for (const sec of this.opts.beatStarts) { const timeUntil = sec - currentSeconds; if (timeUntil < -0.3 || timeUntil > viewportSeconds + 0.3) continue; @@ -768,7 +858,10 @@ export class FallingNotesRenderer { const dark = this.opts.darkMode !== false; this.beatLayer.rect(0, y, this.opts.width, isMeasure ? 1.5 : 1); // Dark mode: light blue-gray at low opacity. Light mode: darker blue-gray at higher opacity. - this.beatLayer.fill({ color: dark ? 0x8896b8 : 0x4a5568, alpha: isMeasure ? (dark ? 0.2 : 0.35) : (dark ? 0.08 : 0.15) }); + this.beatLayer.fill({ + color: dark ? 0x8896b8 : 0x4a5568, + alpha: isMeasure ? (dark ? 0.2 : 0.35) : dark ? 0.08 : 0.15, + }); } } else if (!(this.opts.showBeatLines ?? false)) { this.beatLayer.clear(); @@ -785,17 +878,17 @@ export class FallingNotesRenderer { for (const t of pool) t.visible = false; let ti = 0; - const LABEL_H = 16; // approximate text height + gap - let prevRelY = -999; // Y of the last "✱" placed - guards Ped overlap from above + const LABEL_H = 16; // approximate text height + gap + let prevRelY = -999; // Y of the last "✱" placed - guards Ped overlap from above for (const range of this.opts.sustainRanges) { const startTimeUntil = range.startSeconds - currentSeconds; - const endTimeUntil = range.endSeconds - currentSeconds; + const endTimeUntil = range.endSeconds - currentSeconds; if (endTimeUntil < -0.5 || startTimeUntil > viewportSeconds + 0.5) continue; // topY = release (end) - higher on canvas (later in time) // botY = press (start) - lower on canvas (sooner, approaches hit line first) - const topY = Math.max(0, hitY - endTimeUntil * pxPerSec); + const topY = Math.max(0, hitY - endTimeUntil * pxPerSec); const botY = Math.min(height + 14, hitY - startTimeUntil * pxPerSec); if (botY <= topY) continue; @@ -832,7 +925,10 @@ export class FallingNotesRenderer { } showGhost(note: NoteEvent | null) { - if (!note) { this.ghostBar.visible = false; return; } + if (!note) { + this.ghostBar.visible = false; + return; + } const white = isWhite(note.midi); const w = (white ? this.whiteW : this.blackW) - 2; const x = this.noteX(note.midi) + 1; @@ -843,7 +939,10 @@ export class FallingNotesRenderer { } 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.particles = []; this.particleLayer?.clear(); @@ -877,12 +976,13 @@ export class FallingNotesRenderer { // because "x in opts" is always true when callers always pass the full options // object - triggering spurious resets that wipe all visible note bars. const needsReset = - ("colorTheme" in opts && opts.colorTheme !== this.opts.colorTheme) || - ("customColors" in opts && opts.customColors !== this.opts.customColors) || - ("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) || + ("colorTheme" in opts && opts.colorTheme !== this.opts.colorTheme) || + ("customColors" in opts && opts.customColors !== this.opts.customColors) || + ("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) || ("showHandColors" in opts && opts.showHandColors !== this.opts.showHandColors) || - ("useFlats" in opts && opts.useFlats !== this.opts.useFlats) || - ("fallingNotesLabelMode" in opts && opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) || + ("useFlats" in opts && opts.useFlats !== this.opts.useFlats) || + ("fallingNotesLabelMode" in opts && + opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) || ("showFingering" in opts && opts.showFingering !== this.opts.showFingering) || ("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline); diff --git a/frontend/src/components/SheetMusic/SheetMusicView.tsx b/frontend/src/components/SheetMusic/SheetMusicView.tsx index cfc9f68..0024b2d 100644 --- a/frontend/src/components/SheetMusic/SheetMusicView.tsx +++ b/frontend/src/components/SheetMusic/SheetMusicView.tsx @@ -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 score never looks like a stretched banner; on narrow screens it fills the full width just like before. position:relative is needed so the diff --git a/frontend/src/components/Transport/SettingsPanel.tsx b/frontend/src/components/Transport/SettingsPanel.tsx index 41e5109..0f49920 100644 --- a/frontend/src/components/Transport/SettingsPanel.tsx +++ b/frontend/src/components/Transport/SettingsPanel.tsx @@ -1,38 +1,68 @@ import { useAppStore } from "../../store/useAppStore"; -import type { NoteFilter, ColorTheme, CustomColors, NoteLabelMode, InstrumentId, ImpactStyle } from "../../store/useAppStore"; +import type { + NoteFilter, + ColorTheme, + CustomColors, + NoteLabelMode, + InstrumentId, + ImpactStyle, +} from "../../store/useAppStore"; import { COLOR_PRESET_VALUES, INSTRUMENT_LABELS } from "../../store/useAppStore"; -import { useState } from "react"; +import { useState, createContext, useContext } from "react"; + +/** Provides the Row's label to child Toggle buttons for aria-label. */ +const RowLabelContext = createContext(""); const NOTE_FILTER_OPTIONS: { value: NoteFilter; label: string; title: string }[] = [ - { value: "all", label: "All", title: "Show all notes" }, - { value: "white", label: "Natural", title: "Show only natural (white) key notes" }, - { value: "black", label: "Sharps", title: "Show only sharp/flat (black) key notes" }, - { value: "c_only", label: "C only", title: "Show only C notes" }, + { value: "all", label: "All", title: "Show all notes" }, + { value: "white", label: "Natural", title: "Show only natural (white) key notes" }, + { value: "black", label: "Sharps", title: "Show only sharp/flat (black) key notes" }, + { value: "c_only", label: "C only", title: "Show only C notes" }, ]; const COLOR_THEME_LABELS: Record = { - violet: "Violet", + violet: "Violet", classic: "Classic", - ocean: "Ocean", - forest: "Forest", + ocean: "Ocean", + forest: "Forest", cascade: "Cascade", - custom: "Custom", + custom: "Custom", }; const NOTE_LABEL_OPTIONS: { value: NoteLabelMode; label: string; title: string }[] = [ - { value: "none", label: "None", title: "No note name labels" }, - { value: "c_only", label: "C only", title: "Label C notes with octave number (C4, C5…)" }, - { value: "white", label: "Natural", title: "Label all natural (white) keys" }, - { value: "black", label: "Sharps", title: "Label all sharp/flat (black) keys" }, - { value: "all", label: "All", title: "Label every key" }, + { value: "none", label: "None", title: "No note name labels" }, + { value: "c_only", label: "C only", title: "Label C notes with octave number (C4, C5…)" }, + { value: "white", label: "Natural", title: "Label all natural (white) keys" }, + { value: "black", label: "Sharps", title: "Label all sharp/flat (black) keys" }, + { value: "all", label: "All", title: "Label every key" }, ]; const INSTRUMENT_OPTIONS: { value: InstrumentId; label: string; title: string }[] = [ - { value: "grand", label: INSTRUMENT_LABELS.grand, title: "Splendid Grand Piano - high-quality sampled concert grand" }, - { value: "bright", label: INSTRUMENT_LABELS.bright, title: "Bright Acoustic Piano - brighter attack and tone" }, - { value: "electric", label: INSTRUMENT_LABELS.electric, title: "CP80 Electric Grand Piano - vintage Yamaha electric grand" }, - { value: "harpsichord", label: INSTRUMENT_LABELS.harpsichord, title: "Harpsichord - plucked strings, no velocity dynamic" }, - { value: "honkytonk", label: INSTRUMENT_LABELS.honkytonk, title: "Honky-Tonk Piano - slightly out-of-tune saloon upright" }, + { + value: "grand", + label: INSTRUMENT_LABELS.grand, + title: "Splendid Grand Piano - high-quality sampled concert grand", + }, + { + value: "bright", + label: INSTRUMENT_LABELS.bright, + title: "Bright Acoustic Piano - brighter attack and tone", + }, + { + value: "electric", + label: INSTRUMENT_LABELS.electric, + title: "CP80 Electric Grand Piano - vintage Yamaha electric grand", + }, + { + value: "harpsichord", + label: INSTRUMENT_LABELS.harpsichord, + title: "Harpsichord - plucked strings, no velocity dynamic", + }, + { + value: "honkytonk", + label: INSTRUMENT_LABELS.honkytonk, + title: "Honky-Tonk Piano - slightly out-of-tune saloon upright", + }, ]; function formatTranspose(n: number): string { @@ -47,15 +77,19 @@ function Toggle({ active, onClick, title, + "aria-label": ariaLabel, }: { active: boolean; onClick: () => void; title?: string; + "aria-label"?: string; }) { + const rowLabel = useContext(RowLabelContext); return ( @@ -539,12 +621,14 @@ export function SettingsPanel({ gap: 8, padding: "7px 10px", borderRadius: 8, - border: settings.colorTheme === "custom" - ? "2px solid var(--color-accent)" - : "2px solid var(--color-border)", - background: settings.colorTheme === "custom" - ? "var(--color-accent-subtle)" - : "var(--color-surface-2)", + border: + settings.colorTheme === "custom" + ? "2px solid var(--color-accent)" + : "2px solid var(--color-border)", + background: + settings.colorTheme === "custom" + ? "var(--color-accent-subtle)" + : "var(--color-surface-2)", cursor: "pointer", width: "100%", transition: "border-color 0.15s, background 0.15s", @@ -552,58 +636,91 @@ export function SettingsPanel({ > {/* Live dots with L / R / ? labels */}
- {( - [ - { key: "leftHand" as const, label: "L" }, - { key: "rightHand" as const, label: "R" }, - { key: "unknown" as const, label: "?" }, - ] - ).map(({ key, label }) => ( -
- - + {[ + { key: "leftHand" as const, label: "L" }, + { key: "rightHand" as const, label: "R" }, + { key: "unknown" as const, label: "?" }, + ].map(({ key, label }) => ( +
+ + {label}
))}
- + Custom - + pick your own colors {/* Custom color pickers - shown inline when custom is active */} {settings.colorTheme === "custom" && ( -
+
{( [ - { key: "leftHand" as keyof CustomColors, label: "Left hand" }, + { key: "leftHand" as keyof CustomColors, label: "Left hand" }, { key: "rightHand" as keyof CustomColors, label: "Right hand" }, - { key: "unknown" as keyof CustomColors, label: "Other" }, + { key: "unknown" as keyof CustomColors, label: "Other" }, ] as { key: keyof CustomColors; label: string }[] ).map(({ key, label }) => ( @@ -632,20 +756,29 @@ export function SettingsPanel({ {/* Differentiate hands - toggle + live L/R legend */} {(() => { - const activeColors = settings.colorTheme === "custom" - ? settings.customColors - : COLOR_PRESET_VALUES[settings.colorTheme as Exclude]; + const activeColors = + settings.colorTheme === "custom" + ? settings.customColors + : COLOR_PRESET_VALUES[settings.colorTheme as Exclude]; return ( -
-
+
+
Differentiate hands @@ -657,23 +790,34 @@ export function SettingsPanel({ updateSettings({ showHandColors: !settings.showHandColors })} + aria-label="Differentiate hands" />
{settings.showHandColors && (
{( [ - { color: activeColors.leftHand, label: "Left hand" }, + { color: activeColors.leftHand, label: "Left hand" }, { color: activeColors.rightHand, label: "Right hand" }, ] as { color: string; label: string }[] ).map(({ color, label }) => ( -
- - {label} +
+ + + {label} +
))}
@@ -711,7 +855,14 @@ export function SettingsPanel({ midColor: "#fff8e7", btmColor: "#e8e0c8", }, - ] as { value: "white" | "ivory"; label: string; title: string; topColor: string; midColor: string; btmColor: string }[] + ] as { + value: "white" | "ivory"; + label: string; + title: string; + topColor: string; + midColor: string; + btmColor: string; + }[] ).map(({ value, label, title, topColor, midColor, btmColor }) => { const active = settings.pianoTheme === value; return ( @@ -740,20 +891,24 @@ export function SettingsPanel({ }} > {/* Mini piano key */} -
- +
+ {label} @@ -853,7 +1008,15 @@ export function SettingsPanel({ style={rangeStyle(settings.minNoteHeight, 4, 24)} /> 24 - + {settings.minNoteHeight}px
@@ -880,7 +1043,15 @@ export function SettingsPanel({ style={rangeStyle(settings.noteCornerRadius, 0, 12)} /> 12 - + {settings.noteCornerRadius}px
@@ -904,15 +1075,15 @@ export function SettingsPanel({ > updateSettings({ sheetMusicWhiteBackground: !settings.sheetMusicWhiteBackground })} + onClick={() => + updateSettings({ sheetMusicWhiteBackground: !settings.sheetMusicWhiteBackground }) + } /> - {/* ── Overlays ───────────────────────────────────────────────────── */}
- updateSettings({ impactStyle: v as ImpactStyle })} /> -
{/* ── Playback ───────────────────────────────────────────────────── */}
-
- 2s + + 2s + - 10s - + + 10s + + {settings.viewportSeconds}s
@@ -1015,7 +1216,11 @@ export function SettingsPanel({ stacked >
- -6♭ + + -6♭ + - +6♯ - + + +6♯ + + {formatTranspose(settings.transposeSemitones)}
@@ -1042,7 +1261,11 @@ export function SettingsPanel({ stacked >
- -200 + + -200 + - +200 - - {settings.renderOffset > 0 ? `+${settings.renderOffset}` : settings.renderOffset}ms + + +200 + + + {settings.renderOffset > 0 ? `+${settings.renderOffset}` : settings.renderOffset} + ms
@@ -1099,9 +1335,23 @@ export function SettingsPanel({ updateSettings({ waitForHand: v as "left" | "right" | "both" })} @@ -1118,7 +1368,14 @@ export function SettingsPanel({
{(["left", "right"] as const).map((hand) => (
- + {hand === "left" ? "Left" : "Right"} updateSettings({ handVolume: { ...settings.handVolume, [hand]: Number(e.target.value) } })} + onChange={(e) => + updateSettings({ + handVolume: { ...settings.handVolume, [hand]: Number(e.target.value) }, + }) + } aria-label={`${hand === "left" ? "Left" : "Right"} hand volume`} aria-valuetext={`${Math.round(settings.handVolume[hand] * 100)}%`} className="flex-1" style={rangeStyle(settings.handVolume[hand], 0, 1)} /> - + {Math.round(settings.handVolume[hand] * 100)}%
@@ -1151,7 +1420,7 @@ export function SettingsPanel({ aria-label="Count-in bars" options={[ { value: 0 as const, label: "0 bars", title: "No count-in - start immediately" }, - { value: 1 as const, label: "1 bar", title: "One bar count-in" }, + { value: 1 as const, label: "1 bar", title: "One bar count-in" }, { value: 2 as const, label: "2 bars", title: "Two bars count-in" }, ]} value={settings.countInBars} @@ -1169,7 +1438,10 @@ export function SettingsPanel({ active={settings.speedTrainer.enabled} onClick={() => updateSettings({ - speedTrainer: { ...settings.speedTrainer, enabled: !settings.speedTrainer.enabled }, + speedTrainer: { + ...settings.speedTrainer, + enabled: !settings.speedTrainer.enabled, + }, }) } /> @@ -1185,7 +1457,15 @@ export function SettingsPanel({ stacked >
- 25% + + 25% + updateSettings({ - speedTrainer: { ...settings.speedTrainer, startPct: Number(e.target.value) }, + speedTrainer: { + ...settings.speedTrainer, + startPct: Number(e.target.value), + }, }) } aria-label="Speed trainer start percentage" @@ -1202,8 +1485,24 @@ export function SettingsPanel({ className="flex-1" style={rangeStyle(settings.speedTrainer.startPct, 25, 100)} /> - 100% - + + 100% + + {settings.speedTrainer.startPct}%
@@ -1216,7 +1515,15 @@ export function SettingsPanel({ stacked >
- 50% + + 50% + updateSettings({ - speedTrainer: { ...settings.speedTrainer, endPct: Number(e.target.value) }, + speedTrainer: { + ...settings.speedTrainer, + endPct: Number(e.target.value), + }, }) } aria-label="Speed trainer end percentage" @@ -1233,8 +1543,24 @@ export function SettingsPanel({ className="flex-1" style={rangeStyle(settings.speedTrainer.endPct, 50, 200)} /> - 200% - + + 200% + + {settings.speedTrainer.endPct}%
@@ -1247,7 +1573,15 @@ export function SettingsPanel({ stacked >
- 1% + + 1% + updateSettings({ - speedTrainer: { ...settings.speedTrainer, stepPct: Number(e.target.value) }, + speedTrainer: { + ...settings.speedTrainer, + stepPct: Number(e.target.value), + }, }) } aria-label="Speed trainer step percentage" @@ -1264,20 +1601,34 @@ export function SettingsPanel({ className="flex-1" style={rangeStyle(settings.speedTrainer.stepPct, 1, 20)} /> - 20% - + + 20% + + {settings.speedTrainer.stepPct}%
)} -
{/* ── Reset to defaults ─────────────────────────────────────────── */} -
@@ -1287,12 +1638,21 @@ export function SettingsPanel({ // ── Icons ───────────────────────────────────────────────────────────────────── const MetronomeIcon = () => ( - + {/* Trapezoid body - wider at base, tapers toward top */} - + {/* Pendulum rod - angled to suggest motion */} - + {/* Weight - small circle riding the rod at mid-height */} - + ); diff --git a/frontend/src/index.css b/frontend/src/index.css index 6d75c35..339256b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,102 +3,114 @@ /* ── Design tokens ─────────────────────────────────────────────────────────── */ :root { /* Surfaces - full indigo palette */ - --color-bg: #1a1a2e; - --color-surface: #1e1e38; - --color-surface-2: #232348; - --color-surface-3: #282858; - --color-border: #2d2d55; - --color-border-subtle: #232348; + --color-bg: #1a1a2e; + --color-surface: #1e1e38; + --color-surface-2: #232348; + --color-surface-3: #282858; + --color-border: #2d2d55; + --color-border-subtle: #232348; /* Accent */ - --color-accent: #9333ea; - --color-accent-hover: #a855f7; - --color-accent-subtle: rgba(147, 51, 234, 0.18); + --color-accent: #9333ea; + --color-accent-hover: #a855f7; + --color-accent-subtle: rgba(147, 51, 234, 0.18); --color-accent-subtle-border: rgba(147, 51, 234, 0.35); - --color-accent-text: #c084fc; + --color-accent-text: #c084fc; /* Toggle switch inactive track */ --color-toggle-off: #4a4f88; /* Text - muted bumped to #9ca3af for WCAG AA compliance on dark surfaces */ - --color-text: #e2e8f0; - --color-text-muted: #9ca3af; - --color-text-dim: #3d4070; + --color-text: #e2e8f0; + --color-text-muted: #9ca3af; + --color-text-dim: #3d4070; /* Notes canvas - slightly deeper than bg so the practice area reads as distinct */ - --color-notes-bg: #141428; - --color-notes-border: #1f1f44; + --color-notes-bg: #141428; + --color-notes-border: #1f1f44; /* Hands */ - --color-warning: #f59e0b; - --color-warning-subtle: rgba(245, 158, 11, 0.08); - --color-warning-border: rgba(245, 158, 11, 0.22); + --color-warning: #f59e0b; + --color-warning-subtle: rgba(245, 158, 11, 0.08); + --color-warning-border: rgba(245, 158, 11, 0.22); - --color-left-hand: #9333ea; - --color-left-hand-subtle: rgba(147, 51, 234, 0.18); + --color-left-hand: #9333ea; + --color-left-hand-subtle: rgba(147, 51, 234, 0.18); --color-left-hand-subtle-border: rgba(147, 51, 234, 0.38); - --color-left-hand-text: #c084fc; - --color-right-hand: #22d3ee; - --color-right-hand-subtle: rgba(34, 211, 238, 0.14); + --color-left-hand-text: #c084fc; + --color-right-hand: #22d3ee; + --color-right-hand-subtle: rgba(34, 211, 238, 0.14); --color-right-hand-subtle-border: rgba(34, 211, 238, 0.32); - --color-right-hand-text: #22d3ee; + --color-right-hand-text: #22d3ee; } [data-theme="light"] { /* Surfaces */ - --color-bg: #ffffff; - --color-surface: #ffffff; - --color-surface-2: #f5f7fb; - --color-surface-3: #eaecf4; - --color-border: #dde1ea; - --color-border-subtle: #eaecf4; + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-surface-2: #f5f7fb; + --color-surface-3: #eaecf4; + --color-border: #dde1ea; + --color-border-subtle: #eaecf4; /* Accent */ - --color-accent: #6366f1; - --color-accent-hover: #4f46e5; - --color-accent-subtle: rgba(99, 102, 241, 0.12); - --color-accent-subtle-border: rgba(99, 102, 241, 0.30); - --color-accent-text: #4f46e5; + --color-accent: #5e61f0; /* was #6366f1, darkened to pass WCAG AA contrast */ + --color-accent-hover: #4f46e5; + --color-accent-subtle: rgba(94, 97, 240, 0.12); + --color-accent-subtle-border: rgba(94, 97, 240, 0.3); + --color-accent-text: #4f46e5; /* Toggle switch inactive track */ --color-toggle-off: #c1c9d6; /* Text */ - --color-text: #0f172a; - --color-text-muted: #64748b; - --color-text-dim: #94a3b8; + --color-text: #0f172a; + --color-text-muted: #64748b; + --color-text-dim: #94a3b8; /* Hands */ - --color-warning: #d97706; - --color-warning-subtle: rgba(217, 119, 6, 0.08); - --color-warning-border: rgba(217, 119, 6, 0.22); + --color-warning: #d97706; + --color-warning-subtle: rgba(217, 119, 6, 0.08); + --color-warning-border: rgba(217, 119, 6, 0.22); - --color-left-hand: #3b82f6; - --color-left-hand-subtle: rgba(59, 130, 246, 0.12); - --color-left-hand-subtle-border: rgba(59, 130, 246, 0.30); - --color-left-hand-text: #1d4ed8; - --color-right-hand: #ef4444; - --color-right-hand-subtle: rgba(239, 68, 68, 0.10); + --color-left-hand: #3b82f6; + --color-left-hand-subtle: rgba(59, 130, 246, 0.12); + --color-left-hand-subtle-border: rgba(59, 130, 246, 0.3); + --color-left-hand-text: #1d4ed8; + --color-right-hand: #ef4444; + --color-right-hand-subtle: rgba(239, 68, 68, 0.1); --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 "stage vs UI" separation that mirrors what dark mode does with #141428 content vs #1E1E38 chrome. #EEF1FA has the same blue-indigo undertone as the dark palette and reads as white at a glance while still being visibly distinct from the #FFFFFF header/transport. */ - --color-notes-bg: #EEF1FA; - --color-notes-border: #D4D9EE; + --color-notes-bg: #eef1fa; + --color-notes-border: #d4d9ee; } /* ── 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%; background: var(--color-bg); 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; overflow: hidden; } @@ -112,7 +124,10 @@ html, body, #root { } /* ── Inputs & selects ──────────────────────────────────────────────────────── */ -select, input[type="range"] { font-family: inherit; } +select, +input[type="range"] { + font-family: inherit; +} select { appearance: none; @@ -127,13 +142,25 @@ select { } /* ── Settings panel rows ───────────────────────────────────────────────────── */ -.settings-row:hover { background: var(--color-surface-2); } +.settings-row:hover { + background: var(--color-surface-2); +} /* ── Scrollbar ─────────────────────────────────────────────────────────────── */ -::-webkit-scrollbar { width: 5px; height: 5px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--color-surface-3); border-radius: 99px; } -::-webkit-scrollbar-thumb:hover { background: var(--color-border); } +::-webkit-scrollbar { + width: 5px; + height: 5px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-surface-3); + border-radius: 99px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-border); +} /* ── Range slider - fully custom ───────────────────────────────────────────── */ /* Remove native appearance so our pseudo-element CSS actually takes effect. @@ -156,8 +183,10 @@ input[type="range"]::-webkit-slider-thumb { background: var(--color-accent); cursor: pointer; margin-top: -5.5px; /* center on 3px track: -(14-3)/2 */ - box-shadow: 0 1px 4px rgba(0,0,0,0.35); - transition: background 0.12s, box-shadow 0.12s; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); + transition: + background 0.12s, + box-shadow 0.12s; } input[type="range"]:hover::-webkit-slider-thumb { background: var(--color-accent-hover); @@ -183,7 +212,7 @@ input[type="range"]::-moz-range-thumb { background: var(--color-accent); border: none; 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 { background: var(--color-accent-hover); @@ -217,13 +246,21 @@ input[type="range"]::-moz-range-progress { color: var(--color-text-muted); border: 1px solid var(--color-border); cursor: pointer; - transition: background 0.12s, opacity 0.12s; + transition: + background 0.12s, + opacity 0.12s; flex-shrink: 0; font-size: 12px; font-family: inherit; } -.t-btn:hover:not(:disabled) { background: var(--color-surface-3); color: var(--color-text); } -.t-btn:disabled { opacity: 0.55; cursor: default; } +.t-btn:hover:not(:disabled) { + background: var(--color-surface-3); + color: var(--color-text); +} +.t-btn:disabled { + opacity: 0.55; + cursor: default; +} /* Primary play/pause button - circular, accent-colored */ .t-btn.primary { @@ -242,10 +279,16 @@ input[type="range"]::-moz-range-progress { 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 ─────────────────────────────────────────────────────────── */ -.at-surface svg { display: block; } +.at-surface svg { + display: block; +} /* ── AlphaTab playback cursor ─────────────────────────────────────────────── */ /* Beat cursor - thin accent-colored vertical bar following playhead */ diff --git a/frontend/src/utils/mxlExtract.ts b/frontend/src/utils/mxlExtract.ts index 2431c5d..71e31ad 100644 --- a/frontend/src/utils/mxlExtract.ts +++ b/frontend/src/utils/mxlExtract.ts @@ -2,8 +2,8 @@ * Browser-side .mxl extractor. * * .mxl (compressed MusicXML) is a ZIP archive containing: - * META-INF/container.xml — lists the root MusicXML file - * .xml — the actual MusicXML document + * META-INF/container.xml - lists the root MusicXML file + * .xml - the actual MusicXML document * * We parse the ZIP central directory to locate files, then decompress each * DEFLATE-compressed entry with the browser-native DecompressionStream API @@ -17,9 +17,9 @@ * 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_EOCD = 0x06054b50; // PK\x05\x06 +const SIG_EOCD = 0x06054b50; // PK\x05\x06 interface ZipEntry { name: string; @@ -30,9 +30,12 @@ interface ZipEntry { function findEocd(bytes: Uint8Array): number { for (let i = bytes.length - 22; i >= 0; i--) { if ( - bytes[i] === 0x50 && bytes[i + 1] === 0x4b && - bytes[i + 2] === 0x05 && bytes[i + 3] === 0x06 - ) return i; + bytes[i] === 0x50 && + bytes[i + 1] === 0x4b && + bytes[i + 2] === 0x05 && + bytes[i + 3] === 0x06 + ) + return i; } 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"); const numEntries = view.getUint16(eocd + 10, true); - let cdPos = view.getUint32(eocd + 16, true); + let cdPos = view.getUint32(eocd + 16, true); const entries: ZipEntry[] = []; for (let i = 0; i < numEntries; i++) { if (view.getUint32(cdPos, true) !== SIG_CENTRAL) break; - const fileNameLen = view.getUint16(cdPos + 28, true); - const extraLen = view.getUint16(cdPos + 30, true); - const commentLen = view.getUint16(cdPos + 32, true); - const localOffset = view.getUint32(cdPos + 42, true); - const name = new TextDecoder().decode(bytes.slice(cdPos + 46, cdPos + 46 + fileNameLen)); + const fileNameLen = view.getUint16(cdPos + 28, true); + const extraLen = view.getUint16(cdPos + 30, true); + const commentLen = view.getUint16(cdPos + 32, true); + const localOffset = view.getUint32(cdPos + 42, true); + const name = new TextDecoder().decode(bytes.slice(cdPos + 46, cdPos + 46 + fileNameLen)); entries.push({ name, localOffset }); cdPos += 46 + fileNameLen + extraLen + commentLen; } @@ -66,36 +69,36 @@ function readCentralDirectory(view: DataView, bytes: Uint8Array): ZipEntry[] { async function readLocalEntry( view: DataView, bytes: Uint8Array, - entry: ZipEntry, + entry: ZipEntry ): Promise { const off = entry.localOffset; if (view.getUint32(off, true) !== SIG_LOCAL) { throw new Error(`Bad local header at offset ${off}`); } - const compressionMethod = view.getUint16(off + 8, true); - const compressedSize = view.getUint32(off + 18, true); - const fileNameLen = view.getUint16(off + 26, true); - const extraLen = view.getUint16(off + 28, true); - const dataStart = off + 30 + fileNameLen + extraLen; + const compressionMethod = view.getUint16(off + 8, true); + const compressedSize = view.getUint32(off + 18, true); + const fileNameLen = view.getUint16(off + 26, true); + const extraLen = view.getUint16(off + 28, true); + const dataStart = off + 30 + fileNameLen + extraLen; const compressed = bytes.slice(dataStart, dataStart + compressedSize); if (compressionMethod === 0) { - // Stored — no compression + // Stored - no compression return compressed; } if (compressionMethod !== 8) { throw new Error(`Unsupported ZIP compression method ${compressionMethod}`); } - // DEFLATE (raw, no zlib header) — decompress with browser-native stream. + // DEFLATE (raw, no zlib header) - decompress with browser-native stream. // Cap decompressed size to 50 MB to prevent ZIP-bomb attacks: a crafted // .mxl with a tiny compressed payload that expands to gigabytes would // otherwise freeze or crash the browser tab. const MAX_DECOMPRESSED = 50 * 1024 * 1024; // 50 MB - largest real scores are <5 MB - const ds = new DecompressionStream("deflate-raw"); + const ds = new DecompressionStream("deflate-raw"); const writer = ds.writable.getWriter(); const reader = ds.readable.getReader(); @@ -109,14 +112,14 @@ async function readLocalEntry( if (done) break; totalDecompressed += value.length; 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); } - 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); - let cursor = 0; + let cursor = 0; for (const chunk of chunks) { result.set(chunk, cursor); cursor += chunk.length; @@ -129,22 +132,22 @@ async function readLocalEntry( * Throws if the archive is malformed or missing required files. */ export async function extractXmlFromMxl(buffer: ArrayBuffer): Promise { - const bytes = new Uint8Array(buffer); - const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); const entries = readCentralDirectory(view, bytes); - const find = (name: string) => entries.find(e => e.name === name); + const find = (name: string) => entries.find((e) => e.name === name); // 1. Read META-INF/container.xml const containerEntry = find("META-INF/container.xml"); if (!containerEntry) throw new Error("Not a valid .mxl file: missing META-INF/container.xml"); const containerBytes = await readLocalEntry(view, bytes, containerEntry); - const containerXml = new TextDecoder("utf-8").decode(containerBytes); + const containerXml = new TextDecoder("utf-8").decode(containerBytes); // 2. Parse the root-file path (e.g. full-path="score.xml") const match = containerXml.match(/full-path="([^"]+)"/); - if (!match) throw new Error('Malformed container.xml: no full-path attribute'); + if (!match) throw new Error("Malformed container.xml: no full-path attribute"); const rootPath = match[1]; // 3. Read the root MusicXML entry diff --git a/frontend/src/views/PracticeView.tsx b/frontend/src/views/PracticeView.tsx index 013b058..317bf25 100644 --- a/frontend/src/views/PracticeView.tsx +++ b/frontend/src/views/PracticeView.tsx @@ -12,27 +12,34 @@ import { midiToPitch, keySignatureToLabel } from "@bach-to-basics/shared"; import { syncEngine } from "../engine/SyncEngine"; const TAB_LABELS: Record = { - piano: "Piano", + piano: "Piano", falling: "Notes", - sheet: "Sheet", - all: "All", + sheet: "Sheet", + all: "All", }; const TAB_TITLES: Record = { - piano: "Show only the piano keyboard view", + piano: "Show only the piano keyboard view", falling: "Show only the falling notes view", - sheet: "Show only the sheet music view", - all: "Show all views at once", + sheet: "Show only the sheet music view", + all: "Show all views at once", }; export function PracticeView() { const [settingsOpen, setSettingsOpen] = useState(false); const { - loadMidiFile, loadMusicXmlFile, loadPdfFile, - settings, updateSettings, - status, play, pause, seek, - loadError, clearLoadError, + loadMidiFile, + loadMusicXmlFile, + loadPdfFile, + settings, + updateSettings, + status, + play, + pause, + seek, + loadError, + clearLoadError, document: doc, isLoadingDocument, } = useAppStore(); @@ -85,17 +92,23 @@ export function PracticeView() { return () => window.removeEventListener("keydown", handler); }, [seek]); - const loadFile = useCallback((file: File) => { - if (file.name.match(/\.midi?$/i)) loadMidiFile(file); - else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file); - else if (file.name.match(/\.pdf$/i)) loadPdfFile(file); - }, [loadMidiFile, loadMusicXmlFile, loadPdfFile]); + const loadFile = useCallback( + (file: File) => { + if (file.name.match(/\.midi?$/i)) loadMidiFile(file); + else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file); + else if (file.name.match(/\.pdf$/i)) loadPdfFile(file); + }, + [loadMidiFile, loadMusicXmlFile, loadPdfFile] + ); - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - if (file) loadFile(file); - }, [loadFile]); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file) loadFile(file); + }, + [loadFile] + ); const { layoutMode } = settings; const setLayout = (mode: LayoutMode) => updateSettings({ layoutMode: mode }); @@ -130,14 +143,14 @@ export function PracticeView() { rel="noopener noreferrer" className="flex items-center gap-2 shrink-0" style={{ textDecoration: "none" }} - aria-label="Bach to Basics on GitHub" > {/* Typographic pun: "Bach" and "Basics" bold, "to" muted */} - + Bach - to + + to + Basics + on GitHub @@ -160,7 +178,12 @@ export function PracticeView() { >
{(["piano", "falling", "sheet", "all"] as LayoutMode[]).map((mode) => ( @@ -203,7 +232,13 @@ export function PracticeView() { title="Settings" aria-label="Open settings" className="flex items-center justify-center rounded transition-colors shrink-0" - style={{ width: 30, height: 30, background: "var(--color-surface-2)", border: "1px solid var(--color-border)", color: "var(--color-text-muted)" }} + style={{ + width: 30, + height: 30, + background: "var(--color-surface-2)", + border: "1px solid var(--color-border)", + color: "var(--color-text-muted)", + }} > @@ -211,7 +246,7 @@ export function PracticeView() { {/* ── Main content - layout-aware ────────────────────────────────────── */} -
+
{layoutMode === "piano" && (
@@ -244,12 +279,10 @@ export function PracticeView() {
)} -
+ {/* ── Empty state overlay ────────────────────────────────────────────── */} - {!doc && !isLoadingDocument && ( - - )} + {!doc && !isLoadingDocument && } {/* ── Error toast ────────────────────────────────────────────────────── */} {loadError && } @@ -262,7 +295,6 @@ export function PracticeView() { {/* ── Settings panel (managed here, gear is in header) ───────────────── */} setSettingsOpen(false)} /> -
); } @@ -290,11 +322,15 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) { {/* Illustration */}
@@ -315,20 +351,29 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) { onClick={() => fileInputRef.current?.click()} style={{ pointerEvents: "all", - display: "flex", alignItems: "center", gap: 8, + display: "flex", + alignItems: "center", + gap: 8, padding: "11px 28px", background: "var(--color-accent)", color: "#fff", border: "none", borderRadius: 10, - fontSize: 14, fontWeight: 600, + fontSize: 14, + fontWeight: 600, cursor: "pointer", boxShadow: "0 0 24px rgba(147,51,234,0.45)", fontFamily: "inherit", transition: "all 0.15s", }} - onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)"; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 34px rgba(147,51,234,0.6)"; }} - onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = ""; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 24px rgba(147,51,234,0.45)"; }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)"; + (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 34px rgba(147,51,234,0.6)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLButtonElement).style.transform = ""; + (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 24px rgba(147,51,234,0.45)"; + }} > Import a file @@ -343,7 +388,11 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) { type="file" accept=".mid,.midi,.xml,.mxl,.pdf" className="hidden" - onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} + onChange={(e) => { + const f = e.target.files?.[0]; + if (f) onImport(f); + e.target.value = ""; + }} />
); @@ -351,7 +400,7 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) { const UploadIconLg = () => ( - + ); @@ -359,12 +408,12 @@ const UploadIconLg = () => ( // Mirrors the grid drawn by FallingNotesRenderer so the piano-only view has the // same dark background + black-key lanes + octave separator lines. -const MIDI_MIN_BG = 21; // A0 -const MIDI_MAX_BG = 108; // C8 +const MIDI_MIN_BG = 21; // A0 +const MIDI_MAX_BG = 108; // C8 /** Precompute whiteKeyIndex (cumulative white-key counter per MIDI note). */ function buildWhiteKeyIndex() { - const isBlk = (m: number) => [1,3,6,8,10].includes(m % 12); + const isBlk = (m: number) => [1, 3, 6, 8, 10].includes(m % 12); const idx = new Array(MIDI_MAX_BG + 1).fill(0); let wi = 0; for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) { @@ -381,7 +430,7 @@ function drawPianoGrid(canvas: HTMLCanvasElement) { const ctx = canvas.getContext("2d"); 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; // Clear to transparent - the CSS background on the parent div provides the colour. @@ -398,37 +447,40 @@ function drawPianoGrid(canvas: HTMLCanvasElement) { } function PianoModeBackground() { - const canvasRef = useRef(null); + const canvasRef = useRef(null); const containerRef = useRef(null); const { settings } = useAppStore(); // Reusable draw callback - const redraw = useMemo(() => () => { - const canvas = canvasRef.current; - if (!canvas) return; - drawPianoGrid(canvas); - }, []); + const redraw = useMemo( + () => () => { + const canvas = canvasRef.current; + if (!canvas) return; + drawPianoGrid(canvas); + }, + [] + ); useEffect(() => { const container = containerRef.current; - const canvas = canvasRef.current; + const canvas = canvasRef.current; if (!container || !canvas) return; const ro = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; - canvas.width = Math.round(width); + canvas.width = Math.round(width); canvas.height = Math.round(height); if (settings.showGrid) redraw(); }); ro.observe(container); // Initial size - canvas.width = container.clientWidth; + canvas.width = container.clientWidth; canvas.height = container.clientHeight; if (settings.showGrid) redraw(); return () => ro.disconnect(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [redraw]); // Redraw when showGrid toggles @@ -449,15 +501,12 @@ function PianoModeBackground() { className="flex-1 relative" style={{ minHeight: 0, - background: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)", + 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)", }} > - - +
); } @@ -484,30 +533,67 @@ function StatusBar() { const bpm = doc?.tempoMap[0]?.bpm ?? null; const effectiveBpm = bpm ? Math.round(bpm * tempoMultiplier) : null; - const fmt = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, "0")}`; + const fmt = (s: number) => + `${Math.floor(s / 60)}:${Math.floor(s % 60) + .toString() + .padStart(2, "0")}`; const keySig = doc?.keySignature ? keySignatureToLabel(doc.keySignature) : null; const isActive = status === "playing" || status === "paused"; return (
{doc ? ( <> {isActive && ( - Playing + + Playing + + )} + + {doc.title} + + {keySig && ( + <> + + {keySig} + + )} + {effectiveBpm !== null && ( + <> + + {effectiveBpm} BPM + + )} + {lastNote && ( + <> + + + {lastNote} + + )} - {doc.title} - {keySig && <>{keySig}} - {effectiveBpm !== null && <>{effectiveBpm} BPM} - {lastNote && <>{lastNote}} ) : ( No file loaded - drag & drop or click ↑ Import )}
- {doc && {fmt(currentSeconds)} / {fmt(doc.totalDuration)}} + {doc && ( + + {fmt(currentSeconds)} / {fmt(doc.totalDuration)} + + )}
); } @@ -531,7 +617,7 @@ function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () => role="alert" style={{ position: "fixed", - bottom: 68, // above the transport bar + bottom: 68, // above the transport bar left: "50%", transform: "translateX(-50%)", zIndex: 2000, @@ -554,7 +640,10 @@ function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () => ⚠ {message.split("\n").map((line, i) => ( - {line}{i < message.split("\n").length - 1 &&
}
+ + {line} + {i < message.split("\n").length - 1 &&
} +
))}