Add SECURITY.md, update CONTRIBUTING, issue templates, Lighthouse fixes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & drop or click ↑ Import</span>
|
<span>No file loaded - drag & 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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user