Initial commit
@@ -0,0 +1,34 @@
|
||||
# Root-level .dockerignore — applied to the build context for the frontend
|
||||
# (which uses the repo root as context). Keep this list aggressive so the
|
||||
# build context stays small.
|
||||
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
**/.venv/
|
||||
**/.venv-ai/
|
||||
**/__pycache__/
|
||||
**/dist/
|
||||
**/.vite/
|
||||
**/tsconfig.tsbuildinfo
|
||||
|
||||
.git/
|
||||
.github/
|
||||
.vscode/
|
||||
.claude/
|
||||
.idea/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
playwright-report/
|
||||
test-results/
|
||||
coverage/
|
||||
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
*.log
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
backend/bin/*.jar
|
||||
@@ -0,0 +1,49 @@
|
||||
# ─── Bach to Basics — environment configuration ─────────────────────────────
|
||||
# Copy this file to .env and customize for your deployment.
|
||||
# .env is gitignored — never commit secrets.
|
||||
|
||||
# ── Authentication ───────────────────────────────────────────────────────────
|
||||
|
||||
# When set to a non-empty string, all API requests must include the header:
|
||||
# X-API-Key: <value>
|
||||
# Leave blank for an open backend (local-only setup).
|
||||
# Generate a strong key with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
BACKEND_API_KEY=
|
||||
|
||||
# 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=1
|
||||
|
||||
# ── CORS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Comma-separated list of frontend origins allowed to call the backend.
|
||||
# For local development the default is fine. For a public deployment, set
|
||||
# this to your actual domain(s), e.g. "https://bach.example.com".
|
||||
ALLOWED_ORIGINS=http://localhost:5173
|
||||
|
||||
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Max requests per IP per minute. Set to 0 to disable
|
||||
# (e.g. when running behind a reverse proxy that handles rate limiting).
|
||||
RATE_LIMIT_PER_MIN=60
|
||||
|
||||
# Stricter limit for expensive endpoints (/omr/, /youtube/).
|
||||
# HEAVY_RATE_LIMIT_PER_MIN=10
|
||||
|
||||
# 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=10.0.0.1
|
||||
|
||||
# ── Processing timeouts ───────────────────────────────────────────────────────
|
||||
|
||||
# Hard timeout (seconds) for music21 conversions (MIDI-to-MusicXML, etc.).
|
||||
# Raise for very dense scores; reduce for faster failure.
|
||||
MUSIC21_TIMEOUT_S=60
|
||||
|
||||
# Hard timeout (seconds) for Audiveris OMR.
|
||||
# Raise for large or multi-page PDFs.
|
||||
AUDIVERIS_TIMEOUT_S=120
|
||||
|
||||
# Hard timeout (seconds) for auto-fingering generation (pianoplayer).
|
||||
# Raise for very long or complex scores.
|
||||
# FINGERING_TIMEOUT_S=120
|
||||
@@ -0,0 +1,109 @@
|
||||
# ── Dependencies ──────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# ── Build output ──────────────────────────────────────────────────────────────
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# ── Python ────────────────────────────────────────────────────────────────────
|
||||
backend/.venv/
|
||||
backend/.venv-*/
|
||||
backend/venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# ── Environment / secrets ─────────────────────────────────────────────────────
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
*.pem
|
||||
*.key
|
||||
secrets.*
|
||||
|
||||
# ── Audiveris (large binary — download separately per README) ────────────────
|
||||
backend/bin/*.jar
|
||||
|
||||
# ── OS metadata ───────────────────────────────────────────────────────────────
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# ── IDE / editor ──────────────────────────────────────────────────────────────
|
||||
.idea/
|
||||
.vscode/
|
||||
.fleet/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.code-workspace
|
||||
|
||||
# ── AI / coding-assistant artifacts (local only) ─────────────────────────────
|
||||
.claude/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.cursorignore
|
||||
.cursor.json
|
||||
.aider*
|
||||
.continue/
|
||||
.windsurf/
|
||||
.copilot/
|
||||
.copilotignore
|
||||
.codeium/
|
||||
.tabnine/
|
||||
.codegpt/
|
||||
.zed-cache/
|
||||
ai-notes/
|
||||
prompts/
|
||||
.chats/
|
||||
*.chat.md
|
||||
|
||||
# ── Local notes / scratch / drafts (NEVER commit personal docs) ──────────────
|
||||
NOTES.md
|
||||
TODO.md
|
||||
ROADMAP.md
|
||||
SCRATCH.md
|
||||
DRAFT.md
|
||||
WIP.md
|
||||
notes/
|
||||
scratch/
|
||||
drafts/
|
||||
local-docs/
|
||||
private/
|
||||
.private/
|
||||
*.draft
|
||||
*.bak
|
||||
*.old
|
||||
*.tmp
|
||||
*.orig
|
||||
|
||||
# ── Test / coverage output ────────────────────────────────────────────────────
|
||||
playwright-report/
|
||||
test-results/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
lcov.info
|
||||
junit.xml
|
||||
*.lcov
|
||||
|
||||
# ── Logs ──────────────────────────────────────────────────────────────────────
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# ── Docker / runtime data ─────────────────────────────────────────────────────
|
||||
.docker-data/
|
||||
*.pid
|
||||
|
||||
# ── Misc ──────────────────────────────────────────────────────────────────────
|
||||
*.iml
|
||||
.history/
|
||||
@@ -0,0 +1,49 @@
|
||||
# Contributing to Bach to Basics
|
||||
|
||||
Thanks for your interest! This guide covers the practical bits of working on the codebase.
|
||||
|
||||
## Quick start (development)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm backend:setup # creates backend/.venv with the core deps
|
||||
pnpm dev # starts frontend (5173) + backend (8000) concurrently
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
- `frontend/`: React 19 + Vite + TypeScript app
|
||||
- `src/components/`: UI components, organised by feature
|
||||
- `src/engine/`: `SyncEngine`, `AudioEngine`, MIDI clock (no React inside)
|
||||
- `src/store/`: Zustand state
|
||||
- `src/views/`: top-level layouts (`PracticeView`)
|
||||
- `backend/`: FastAPI app
|
||||
- `routers/`: endpoint groups (`transcribe`, `omr`, `youtube`, `export_routes`, `fingering`)
|
||||
- `services/`: long-running blocking work, run in dedicated executors with timeouts
|
||||
- `shared/`: TypeScript types used by the frontend (the backend has its own duplicates intentionally, keeps backend deployable independently)
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript strict mode** is on. `pnpm --filter frontend exec tsc --noEmit` should pass cleanly.
|
||||
- **No `any` without a comment** explaining why.
|
||||
- **All audio-engine work happens off the React tree.** The `SyncEngine` is a singleton that emits events on a `mitt` bus; views subscribe via `useEffect`.
|
||||
- **Backend services are blocking-by-default** and wrapped in `_run_in_*_pool()` helpers with hard timeouts. Don't add long-running CPU work directly in route handlers.
|
||||
|
||||
## Before opening a PR
|
||||
|
||||
1. `pnpm --filter frontend exec tsc --noEmit`: type check
|
||||
2. `pnpm test`: frontend unit tests
|
||||
3. If you touched the backend: `cd backend && python -m py_compile $(find . -name "*.py" -not -path "./.venv/*")`
|
||||
4. Verify the dev server still boots cleanly (`pnpm dev`)
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Include:
|
||||
- Browser + OS version
|
||||
- Whether you're running via `pnpm dev` or Docker
|
||||
- Steps to reproduce
|
||||
- Anything in the browser console (DevTools > Console) and the backend terminal
|
||||
|
||||
## Security
|
||||
|
||||
Found a vulnerability? Please don't open a public issue. Reach out via the contact info on the repo owner's GitHub profile.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 gigliof
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,58 @@
|
||||
Bach to Basics
|
||||
Copyright (c) 2025 gigliof
|
||||
|
||||
This product includes the following third-party components. Each is subject
|
||||
to its own license terms; check the upstream project for the full text.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
Bundled assets
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
frontend/public/soundfont/sonivox.sf3
|
||||
SONiVOX Embedded Audio Synthesis (EAS) soundfont, originally released
|
||||
as part of the Android Open Source Project under the Apache License 2.0.
|
||||
Used here as the AlphaTab playback soundfont.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
Frontend dependencies (selected — full list in frontend/package.json)
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
AlphaTab — MPL-2.0 (sheet music renderer)
|
||||
PixiJS — MIT (WebGL renderer)
|
||||
Tone.js — MIT (audio scheduling)
|
||||
smplr — MIT (sample-based instruments)
|
||||
WebMidi.js — Apache-2.0 (MIDI input)
|
||||
React — MIT
|
||||
Zustand — MIT
|
||||
Vite — MIT
|
||||
Tailwind CSS — MIT
|
||||
|
||||
MPL-2.0 note: AlphaTab is consumed unmodified as an npm dependency. MPL-2.0
|
||||
is per-file copyleft — if you modify AlphaTab's own source files you must
|
||||
share those changes under MPL-2.0, but the rest of this codebase remains
|
||||
under MIT.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
Backend dependencies (selected — full list in backend/requirements.txt)
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
music21 — LGPL-3.0 (music theory + MusicXML)
|
||||
FastAPI — MIT
|
||||
defusedxml — PSF-2.0
|
||||
pianoplayer — MIT (Parncutt fingering algorithm)
|
||||
yt-dlp — Unlicense (YouTube audio extraction)
|
||||
pdf2image — MIT
|
||||
Basic Pitch (optional) — Apache-2.0 (audio → MIDI transcription)
|
||||
|
||||
LGPL note (music21): used as a pure Python library import, unmodified.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
Optional external tools (NOT bundled — user installs separately)
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Audiveris 5.x — AGPL-3.0 (PDF → MusicXML OMR)
|
||||
|
||||
Audiveris is invoked as a subprocess if the user places audiveris.jar at
|
||||
backend/bin/. Because it's distributed separately by the user (and is
|
||||
optional), the AGPL obligations apply only to anyone redistributing the
|
||||
JAR or hosting Audiveris itself — not to this codebase.
|
||||
@@ -0,0 +1,225 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/gigliof/bach-to-basics">
|
||||
<img alt="Bach to Basics" src="docs/logo.png" width="340">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/gigliof/bach-to-basics/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-7c3aed"></a>
|
||||
<a href="https://github.com/gigliof/bach-to-basics/issues"><img alt="Issues" src="https://img.shields.io/github/issues/gigliof/bach-to-basics"></a>
|
||||
</p>
|
||||
|
||||
A browser-based piano practice tool. Drop in a **MIDI file**, a **MusicXML score**, or even a **PDF of sheet music**. Bach to Basics turns it into synced views of falling notes, an interactive 88-key piano, and rendered sheet music, then layers on practice tools (A/B loop, speed trainer, wait mode, metronome, transpose) to help you learn the piece.
|
||||
|
||||

|
||||
|
||||
| Sheet music | All views | Settings |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|
||||
## Features
|
||||
|
||||
### Import
|
||||
|
||||
- **MIDI** (`.mid`, `.midi`): parsed in a Web Worker, converted to MusicXML on the backend for sheet rendering
|
||||
- **MusicXML** (`.xml`, `.mxl`): both uncompressed and compressed (zip-style) variants
|
||||
- **PDF sheet music** (`.pdf`): optical music recognition via [Audiveris](https://github.com/Audiveris/audiveris) to MusicXML, then to MIDI (optional setup, see below)
|
||||
|
||||
Drag-and-drop anywhere on the window, or use the import button.
|
||||
|
||||
> ⚠️ **A note on import accuracy.** Both PDF-to-MIDI (optical music recognition) and MIDI-to-sheet-music (notation reconstruction) are inherently lossy conversions. Expect some inconsistencies:
|
||||
> - **PDF imports** can mis-read notes, dynamics, ornaments, articulation, and voicing, especially with low-resolution scans, handwritten scores, or complex layouts. Audiveris is the best open-source OMR engine available, but no engine matches a careful human transcription.
|
||||
> - **MIDI imports** produce sheet music by re-deriving notation from raw note timings. MIDI doesn't encode key signatures, beaming, voicing, articulation, or enharmonic spelling, so the rendered sheet is an interpretation, not a faithful reproduction of the composer's original score.
|
||||
>
|
||||
> For the cleanest sheet-music experience, import a **MusicXML** file (or `.mxl`) when you have one, which preserves full notation semantics end-to-end.
|
||||
|
||||
### Visualization
|
||||
|
||||
- **Falling notes**: Synthesia-style, GPU-accelerated via PixiJS. Hand-color separation, octave grid, measure numbers, optional beat lines, sustain-pedal indicators, ghost-notes-while-pedal-held, configurable impact effects (bloom / side burst / particle trail / off), and a "note outline" mode for high-contrast viewing.
|
||||
- **Sheet music**: full notation rendering via AlphaTab, with auto-scrolling cursor that tracks playback. White background toggle so notation stays readable in dark mode.
|
||||
- **88-key piano**: interactive keyboard with hover, press depth, ivory or white key themes. Tap keys to play; drag across them to glissando.
|
||||
|
||||
### Practice tools
|
||||
|
||||
- **A/B loop** with click-to-set markers; the loop region highlights across all three views
|
||||
- **Speed trainer**: auto-ramps tempo on each loop pass (configurable start %, end %, step %)
|
||||
- **Wait mode**: playback pauses until you play the correct note. Per-hand: wait for left only, right only, or both.
|
||||
- **Count-in**: 0, 1, or 2 bars of metronome before the music starts
|
||||
- **Metronome**: accent on downbeat
|
||||
- **Transposition**: shift the whole score ±12 semitones
|
||||
- **Per-hand volume**: solo or mute either hand independently
|
||||
- **Render-offset**: shift audio scheduling ±200 ms to compensate for audio interface latency
|
||||
|
||||
### Audio & MIDI
|
||||
|
||||
- **5 instrument options**: Splendid Grand Piano, Bright Acoustic, CP80 Electric, Harpsichord, Honky-Tonk (sampled, via [smplr](https://github.com/danigb/smplr))
|
||||
- **MIDI input** via Web MIDI: connect a hardware keyboard (Roland, Yamaha, etc.); your input lights up the on-screen keys and drives wait mode
|
||||
- **Tempo control**: 25%-200% with snap-back to 100%
|
||||
|
||||
### Look & feel
|
||||
|
||||
- **Color themes**: Cascade, Violet, Classic, Ocean, Forest, or fully custom (left/right/unknown hand colors)
|
||||
- **Dark / light mode**: system-aware initial theme, manual override
|
||||
- **Note labels**: none, C-only, white keys, black keys, or all
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | React 19, TypeScript, Vite, Tailwind CSS |
|
||||
| Rendering | PixiJS 8 (falling notes + piano), AlphaTab (sheet music) |
|
||||
| Audio | Tone.js (scheduling), smplr (sampled instruments), Web Audio API |
|
||||
| MIDI input | WebMidi.js |
|
||||
| State | Zustand |
|
||||
| Backend | FastAPI (Python 3.11/3.12) |
|
||||
| Music processing | music21, defusedxml |
|
||||
| OMR (optional) | Audiveris (Java) |
|
||||
|
||||
## Running the app
|
||||
|
||||
### Option A: Docker (recommended for end users)
|
||||
|
||||
The simplest way. Requires only Docker Desktop (or Docker Engine + Compose).
|
||||
|
||||
```bash
|
||||
docker compose up # build the images on first run, then start
|
||||
open http://localhost:5173
|
||||
```
|
||||
|
||||
That's it. Stop with `Ctrl+C` (or `docker compose down`).
|
||||
|
||||
> Optional: copy `.env.example` to `.env` and set `BACKEND_API_KEY` if you want to require an API key on the backend.
|
||||
|
||||
### Option B: Native (recommended for development)
|
||||
|
||||
You'll need:
|
||||
|
||||
- **Node.js** 20+ and **pnpm** (`npm i -g pnpm`)
|
||||
- **Python** 3.11 or 3.12
|
||||
- **Java 17+**, only needed for PDF import via Audiveris
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm backend:setup # creates backend/.venv with the core deps
|
||||
pnpm dev # frontend on :5173, backend on :8000, with hot reload
|
||||
```
|
||||
|
||||
### Optional: PDF import via Audiveris
|
||||
|
||||
PDF to MusicXML uses [Audiveris](https://github.com/Audiveris/audiveris), an open-source OMR engine.
|
||||
|
||||
1. Download `audiveris.jar` from [Audiveris releases](https://github.com/Audiveris/audiveris/releases)
|
||||
2. Place it at `backend/bin/audiveris.jar`
|
||||
3. Make sure Java 17+ is on `PATH` (Docker users: already included in the image)
|
||||
|
||||
Without the JAR, MIDI / MusicXML import still works, only PDF import is unavailable.
|
||||
|
||||
## Using the app on iPad / iPhone (same network)
|
||||
|
||||
1. In `frontend/vite.config.ts`, add `host: true` to the `server` block (or run `pnpm --filter frontend dev --host`)
|
||||
2. Find your computer's LAN IP (System Settings > Network on macOS)
|
||||
3. On the iPad, open `http://<your-ip>:5173`
|
||||
|
||||
⚠️ **Web MIDI is not available on iOS**, the on-screen keyboard works, but you cannot connect a hardware piano via USB or Bluetooth from iOS Safari. This is a WebKit limitation Apple has not addressed.
|
||||
|
||||
## Configuration
|
||||
|
||||
All backend settings come from environment variables. Copy `.env.example` to `.env` and edit.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `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>` |
|
||||
| `REQUIRE_AUTH` | *(unset)* | Set to `1` to make the server refuse to start if `BACKEND_API_KEY` is not configured — useful for preventing accidental open deployments |
|
||||
| `RATE_LIMIT_PER_MIN` | `60` | Max requests per IP per minute. `0` disables it |
|
||||
| `HEAVY_RATE_LIMIT_PER_MIN` | `10` | Stricter limit applied to expensive endpoints (`/omr/`, `/youtube/`) |
|
||||
| `TRUSTED_PROXY_IPS` | *(unset)* | Comma-separated IPs of trusted reverse proxies. When set, the real client IP is read from `X-Forwarded-For` instead of the connection address |
|
||||
| `MUSIC21_TIMEOUT_S` | `60` | Hard timeout (seconds) for music21 conversions (MIDI-to-MusicXML, MusicXML-to-MIDI). Raise for very dense scores |
|
||||
| `AUDIVERIS_TIMEOUT_S` | `120` | Hard timeout (seconds) for Audiveris OMR. Raise for large or multi-page PDFs |
|
||||
|
||||
> **Web MIDI requires HTTPS** in production. Plain `http://` only works on `localhost`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
bach-to-basics/
|
||||
├── frontend/ # React app (Vite)
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── FallingNotes/ # PixiJS falling-note renderer
|
||||
│ │ │ ├── PianoKeyboard/ # PixiJS 88-key keyboard
|
||||
│ │ │ ├── SheetMusic/ # AlphaTab sheet music view
|
||||
│ │ │ └── Transport/ # Transport bar + settings panel
|
||||
│ │ ├── engine/ # SyncEngine, AudioEngine, MidiClock
|
||||
│ │ ├── store/ # Zustand app state
|
||||
│ │ ├── utils/ # Shared utilities
|
||||
│ │ ├── views/ # PracticeView (main layout)
|
||||
│ │ └── workers/ # MIDI parsing in a Web Worker
|
||||
│ └── public/ # Static assets (soundfont)
|
||||
├── backend/ # FastAPI backend
|
||||
│ ├── routers/ # transcribe, omr, youtube, export, fingering
|
||||
│ └── services/ # blocking work (music21, Audiveris, etc.)
|
||||
├── shared/ # TypeScript types shared with the frontend
|
||||
├── docker-compose.yml # 1-command run for end users
|
||||
└── package.json # pnpm workspace root
|
||||
```
|
||||
|
||||
## Browser support
|
||||
|
||||
Requires a Chromium-based browser (Chrome, Edge, Brave, Arc) for **Web MIDI API** support. Firefox and Safari don't implement Web MIDI; on those, the on-screen keyboard still works but hardware MIDI input does not.
|
||||
|
||||
## Roadmap
|
||||
|
||||
The backend already exposes endpoints for several features that don't yet have UI hooks:
|
||||
|
||||
- **Audio-to-MIDI transcription** via [Basic Pitch](https://github.com/spotify/basic-pitch) (`/transcribe/mp3`)
|
||||
- **YouTube-to-MIDI** via yt-dlp + Basic Pitch (`/youtube/extract`), sync data model already in place
|
||||
- **Auto-fingering** via [pianoplayer](https://github.com/marcomusy/pianoplayer) (`/fingering/generate`)
|
||||
- **Export** to PDF / MP3 / MIDI / MusicXML (`/export/pdf`, `/export/mp3`)
|
||||
|
||||
These will become user-facing in upcoming releases. PRs welcome.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary><strong>Sheet music doesn't render after loading a MIDI file</strong></summary>
|
||||
|
||||
The MIDI-to-MusicXML conversion runs on the backend. Check that the backend is up (`curl http://localhost:8000/health` should return OK) and look at the backend terminal for `music21` errors. Very dense scores can hit the `MUSIC21_TIMEOUT_S` (default 60s); raise it if needed.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>No sound from the on-screen piano</strong></summary>
|
||||
|
||||
The first interaction (any click) wakes the audio context. If you still hear nothing, open DevTools > Console and look for errors related to `AudioContext` or sample fetches from `danigb.github.io` / `gleitz.github.io`. Some corporate networks block these CDNs.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>"WebMIDI not supported" on Firefox or Safari</strong></summary>
|
||||
|
||||
Use Chrome, Edge, Brave, or Arc. Firefox and Safari haven't implemented Web MIDI.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>PDF import says "OMR engine not available"</strong></summary>
|
||||
|
||||
Either Audiveris isn't installed, or `audiveris.jar` isn't at `backend/bin/audiveris.jar`, or Java 17+ isn't on the PATH. See the PDF import section above. MIDI and MusicXML import are unaffected.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Docker build fails on the frontend</strong></summary>
|
||||
|
||||
The frontend Dockerfile uses the repo root as build context to read `pnpm-workspace.yaml` and `shared/`. If you've moved or renamed those, update `docker-compose.yml`'s `build.context` accordingly.
|
||||
</details>
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Bug reports and PRs welcome.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) for this codebase.
|
||||
|
||||
Third-party components (AlphaTab, music21, etc.) keep their own licenses, see [NOTICE](NOTICE) for the rundown. Notably:
|
||||
|
||||
- **AlphaTab** is MPL-2.0 (per-file copyleft), fine for both open source and commercial use as long as you don't modify AlphaTab's own files.
|
||||
- **Audiveris** is AGPL-3.0 and is *not* bundled, users download it separately, so this repo doesn't inherit AGPL obligations.
|
||||
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
.venv-ai/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.DS_Store
|
||||
bin/*.jar
|
||||
@@ -0,0 +1,32 @@
|
||||
# Bach to Basics: backend image
|
||||
# Python 3.12 base supports both the core requirements AND the optional
|
||||
# Basic Pitch AI dependencies (which don't yet support 3.13).
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# System packages: Java for Audiveris (PDF-to-MusicXML), ffmpeg for audio export,
|
||||
# poppler-utils for pdf2image. Keep the layer slim; remove apt cache.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
default-jre-headless \
|
||||
ffmpeg \
|
||||
poppler-utils \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps first so they cache independently of source changes
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Source last, small, changes often
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Run as a non-root user for safety
|
||||
RUN useradd --create-home --uid 1000 app && chown -R app:app /app
|
||||
USER app
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,257 @@
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# M1 - Patch stdlib XML parsers before any other import uses them.
|
||||
# defuse_stdlib() replaces xml.etree.ElementTree, xml.dom.minidom, etc.
|
||||
# with safe equivalents that reject XXE / XML-bomb payloads.
|
||||
import defusedxml
|
||||
defusedxml.defuse_stdlib()
|
||||
|
||||
# H1 - Patch lxml's default XMLParser to disable entity resolution and
|
||||
# network access. defuse_stdlib() only covers stdlib xml.etree; music21
|
||||
# may use lxml internally if it is installed.
|
||||
try:
|
||||
from lxml import etree as _lxml_etree
|
||||
|
||||
_orig_xmlparser_init = _lxml_etree.XMLParser.__init__
|
||||
|
||||
def _safe_xmlparser_init(self, *args, **kwargs):
|
||||
kwargs.setdefault("resolve_entities", False)
|
||||
kwargs.setdefault("no_network", True)
|
||||
_orig_xmlparser_init(self, *args, **kwargs)
|
||||
|
||||
_lxml_etree.XMLParser.__init__ = _safe_xmlparser_init
|
||||
except ImportError:
|
||||
pass # lxml not installed, nothing to patch
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response, Security
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import APIKeyHeader
|
||||
from routers import export_routes, fingering, omr, transcribe, youtube
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
# ── H1: Optional API-key authentication ──────────────────────────────────────
|
||||
# Set BACKEND_API_KEY env-var to enable. When the var is absent the guard is
|
||||
# a no-op so local dev works without any extra config.
|
||||
_API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
_auth_warned = False
|
||||
|
||||
|
||||
def _require_api_key(key: str | None = Security(_API_KEY_HEADER)) -> None:
|
||||
global _auth_warned
|
||||
expected = os.environ.get("BACKEND_API_KEY")
|
||||
# T1-1: only treat *absent* key (None) as "auth disabled".
|
||||
# An empty string means a deployment misconfiguration, not "no auth".
|
||||
if expected is None:
|
||||
if not _auth_warned:
|
||||
logging.warning(
|
||||
"BACKEND_API_KEY is not set - all API endpoints are unauthenticated. "
|
||||
"Set this env var in production."
|
||||
)
|
||||
_auth_warned = True
|
||||
return # auth genuinely not configured (dev mode)
|
||||
if not expected:
|
||||
# Empty string = broken deployment config; reject loudly
|
||||
raise HTTPException(status_code=500, detail="Server authentication misconfigured")
|
||||
# T2-2: constant-time comparison prevents timing-based key extraction
|
||||
if not hmac.compare_digest(key or "", expected):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
# ── Startup checks ────────────────────────────────────────────────────────────
|
||||
|
||||
def _startup_checks() -> None:
|
||||
"""Log warnings for optional external binaries that are absent at launch."""
|
||||
# M1: fail fast if auth is explicitly required but no key is configured
|
||||
if os.environ.get("REQUIRE_AUTH", "").strip().lower() in ("1", "true", "yes"):
|
||||
if not os.environ.get("BACKEND_API_KEY"):
|
||||
raise SystemExit(
|
||||
"FATAL: REQUIRE_AUTH is set but BACKEND_API_KEY is empty. "
|
||||
"Set BACKEND_API_KEY or unset REQUIRE_AUTH."
|
||||
)
|
||||
|
||||
checks = [
|
||||
("ffmpeg", "needed for /export/mp3"),
|
||||
("yt-dlp", "needed for /youtube/extract"),
|
||||
]
|
||||
for binary, purpose in checks:
|
||||
if not shutil.which(binary):
|
||||
logging.warning("Optional binary not found: %s (%s)", binary, purpose)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI):
|
||||
_startup_checks()
|
||||
yield
|
||||
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(
|
||||
title="Bach to Basics",
|
||||
version="0.1.0",
|
||||
dependencies=[Depends(_require_api_key)],
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
# H2 / L1 - CORS: read allowed origins from ALLOWED_ORIGINS env var
|
||||
# (comma-separated list of URLs). Defaults to the Vite dev server so local
|
||||
# development works without any extra config.
|
||||
#
|
||||
# Production example:
|
||||
# ALLOWED_ORIGINS=https://myapp.example.com
|
||||
# ALLOWED_ORIGINS=https://myapp.example.com,https://www.myapp.example.com
|
||||
_raw_origins = os.environ.get("ALLOWED_ORIGINS", "http://localhost:5173")
|
||||
_allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_allowed_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["Content-Type", "X-API-Key"],
|
||||
)
|
||||
|
||||
# ── Security-headers middleware ───────────────────────────────────────────────
|
||||
|
||||
@app.middleware("http")
|
||||
async def _add_security_headers(request: Request, call_next) -> Response:
|
||||
response = await call_next(request)
|
||||
# Prevent MIME-type sniffing
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
# Prevent the API from being embedded in iframes (clickjacking)
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
# Don't send the Referer header to third parties
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
# Disable browser features this API doesn't need
|
||||
response.headers.setdefault(
|
||||
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
|
||||
)
|
||||
# T4-2: HSTS: enforce HTTPS for 2 years (Web MIDI requires HTTPS anyway)
|
||||
response.headers.setdefault(
|
||||
"Strict-Transport-Security", "max-age=63072000; includeSubDomains"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# ── Per-IP rate limiter (no extra dependencies) ───────────────────────────────
|
||||
# Caps requests per IP in a rolling 60-second window.
|
||||
# Set RATE_LIMIT_PER_MIN=0 to disable (e.g. when sitting behind your own
|
||||
# reverse proxy that already handles rate limiting).
|
||||
|
||||
_rate_window: dict[str, list[float]] = defaultdict(list)
|
||||
_RATE_LIMIT = int(os.environ.get("RATE_LIMIT_PER_MIN", "60"))
|
||||
|
||||
# L4: stricter limit for expensive endpoints (OMR, YouTube)
|
||||
_HEAVY_RATE_LIMIT = int(os.environ.get("HEAVY_RATE_LIMIT_PER_MIN", "10"))
|
||||
_HEAVY_PREFIXES = ("/omr/", "/youtube/")
|
||||
|
||||
# M3: periodic sweep of stale entries to prevent unbounded memory growth
|
||||
_last_sweep = 0.0
|
||||
_SWEEP_INTERVAL = 300.0 # 5 minutes
|
||||
|
||||
# T4-3: hard cap on tracked IPs to prevent memory growth from botnet drip attacks
|
||||
_MAX_RATE_ENTRIES = 50_000
|
||||
|
||||
# T2-1: asyncio.Lock prevents TOCTOU race in concurrent async handlers
|
||||
_rate_lock = asyncio.Lock()
|
||||
|
||||
# M4: proxy-aware IP resolution: when the request arrives from a trusted
|
||||
# proxy, use X-Forwarded-For instead of request.client.host.
|
||||
# Set TRUSTED_PROXY_IPS=10.0.0.1,10.0.0.2 to enable.
|
||||
_TRUSTED_PROXIES = {
|
||||
ip.strip()
|
||||
for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",")
|
||||
if ip.strip()
|
||||
}
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
"""Resolve client IP, respecting X-Forwarded-For from trusted proxies."""
|
||||
direct_ip = request.client.host if request.client else "unknown"
|
||||
if _TRUSTED_PROXIES and direct_ip in _TRUSTED_PROXIES:
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
# Rightmost IP not in trusted proxies = last non-proxy hop
|
||||
for ip in reversed(forwarded.split(",")):
|
||||
ip = ip.strip()
|
||||
if ip and ip not in _TRUSTED_PROXIES:
|
||||
return ip
|
||||
return direct_ip
|
||||
|
||||
|
||||
def _sweep_stale_entries(now: float) -> None:
|
||||
"""Remove IPs with no requests in the last 2 minutes (M3).
|
||||
|
||||
Also enforces a hard cap on total tracked IPs (T4-3) to prevent
|
||||
slow-drip memory growth from botnets.
|
||||
Must be called while holding _rate_lock.
|
||||
"""
|
||||
global _last_sweep
|
||||
if now - _last_sweep < _SWEEP_INTERVAL:
|
||||
return
|
||||
_last_sweep = now
|
||||
stale = [ip for ip, ts in _rate_window.items() if not ts or now - ts[-1] > 120.0]
|
||||
for ip in stale:
|
||||
del _rate_window[ip]
|
||||
# T4-3: evict oldest entries if still over the hard cap
|
||||
while len(_rate_window) > _MAX_RATE_ENTRIES:
|
||||
oldest = min(_rate_window, key=lambda k: _rate_window[k][-1] if _rate_window[k] else 0)
|
||||
del _rate_window[oldest]
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _rate_limit(request: Request, call_next) -> Response:
|
||||
# Skip the health probe and CORS preflight requests.
|
||||
if request.url.path == "/health" or request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
if _RATE_LIMIT > 0:
|
||||
ip = _get_client_ip(request)
|
||||
now = time.monotonic()
|
||||
|
||||
# T2-1: lock the read-check-write cycle so concurrent async
|
||||
# handlers can't all pass the limit check simultaneously
|
||||
async with _rate_lock:
|
||||
_sweep_stale_entries(now)
|
||||
|
||||
# L4: apply stricter limit for heavy endpoints
|
||||
limit = _RATE_LIMIT
|
||||
if any(request.url.path.startswith(p) for p in _HEAVY_PREFIXES):
|
||||
limit = _HEAVY_RATE_LIMIT
|
||||
|
||||
# Prune timestamps outside the rolling window
|
||||
window = [t for t in _rate_window[ip] if now - t < 60.0]
|
||||
if len(window) >= limit:
|
||||
return Response(
|
||||
content='{"detail":"Too many requests - please wait a moment."}',
|
||||
status_code=429,
|
||||
media_type="application/json",
|
||||
headers={"Retry-After": "60"},
|
||||
)
|
||||
window.append(now)
|
||||
_rate_window[ip] = window
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(transcribe.router, prefix="/transcribe", tags=["transcribe"])
|
||||
app.include_router(omr.router, prefix="/omr", tags=["omr"])
|
||||
app.include_router(youtube.router, prefix="/youtube", tags=["youtube"])
|
||||
app.include_router(fingering.router, prefix="/fingering", tags=["fingering"])
|
||||
app.include_router(export_routes.router, prefix="/export", tags=["export"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Optional audio-transcription dependencies (Basic Pitch).
|
||||
# Requires Python 3.11 or 3.12, tensorflow-macos doesn't yet support 3.13.
|
||||
#
|
||||
# To enable MP3 / audio-to-MIDI transcription:
|
||||
# brew install python@3.12
|
||||
# /opt/homebrew/bin/python3.12 -m venv .venv
|
||||
# source .venv/bin/activate
|
||||
# pip install -r requirements-transcribe.txt
|
||||
#
|
||||
-r requirements.txt
|
||||
basic-pitch>=0.3.6
|
||||
@@ -0,0 +1,18 @@
|
||||
# Core requirements: work with Python 3.13
|
||||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.30
|
||||
python-multipart>=0.0.9
|
||||
pydantic>=2.0
|
||||
defusedxml>=0.7
|
||||
|
||||
# Music processing
|
||||
music21>=9.1
|
||||
|
||||
# YouTube download
|
||||
yt-dlp>=2024.4.9
|
||||
|
||||
# PDF processing (for Audiveris input prep)
|
||||
pdf2image>=1.17
|
||||
|
||||
# Fingering generation
|
||||
pianoplayer>=0.9
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
/export/pdf - MusicXML to PDF via music21 + LilyPond (or Audiveris)
|
||||
/export/mp3 - WAV buffer to MP3 via FFmpeg
|
||||
"""
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# C2 - size caps
|
||||
MAX_B64_BYTES = 300 * 1024 * 1024 # 300 MB base64 string (≈ 200 MB decoded)
|
||||
MAX_WAV_BYTES = 200 * 1024 * 1024 # 200 MB decoded WAV
|
||||
|
||||
|
||||
MAX_MUSICXML_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
# M5 - WAV magic bytes: "RIFF....WAVE"
|
||||
_WAV_RIFF = b"RIFF"
|
||||
_WAV_WAVE = b"WAVE"
|
||||
|
||||
|
||||
class ExportPdfRequest(BaseModel):
|
||||
musicxml: str
|
||||
|
||||
|
||||
class ExportMp3Request(BaseModel):
|
||||
wav_base64: str # base64-encoded WAV
|
||||
|
||||
|
||||
def _rm(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/pdf")
|
||||
async def export_pdf(req: ExportPdfRequest, background_tasks: BackgroundTasks):
|
||||
"""Convert MusicXML to PDF sheet music."""
|
||||
if len(req.musicxml.encode()) > MAX_MUSICXML_BYTES:
|
||||
raise HTTPException(status_code=413, detail="MusicXML payload too large (max 10 MB)")
|
||||
try:
|
||||
from services.music21_svc import musicxml_to_pdf
|
||||
pdf_path = await musicxml_to_pdf(req.musicxml)
|
||||
background_tasks.add_task(_rm, pdf_path) # H5
|
||||
|
||||
return FileResponse(
|
||||
pdf_path,
|
||||
media_type="application/pdf",
|
||||
filename="sheet.pdf",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": 'attachment; filename="sheet.pdf"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("export_pdf failed")
|
||||
raise HTTPException(status_code=500, detail="PDF export failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/mp3")
|
||||
async def export_mp3(req: ExportMp3Request, background_tasks: BackgroundTasks):
|
||||
"""Convert base64 WAV to MP3 using FFmpeg."""
|
||||
# L6 - size-check before decoding; catch malformed base64 explicitly
|
||||
if len(req.wav_base64) > MAX_B64_BYTES:
|
||||
raise HTTPException(status_code=413, detail="WAV payload too large (max 200 MB)")
|
||||
|
||||
try:
|
||||
wav_bytes = base64.b64decode(req.wav_base64, validate=True)
|
||||
except binascii.Error:
|
||||
raise HTTPException(status_code=422, detail="Invalid base64 encoding")
|
||||
|
||||
# C2 - check decoded size too
|
||||
if len(wav_bytes) > MAX_WAV_BYTES:
|
||||
raise HTTPException(status_code=413, detail="WAV data too large (max 200 MB)")
|
||||
|
||||
# M5 - validate WAV magic bytes: "RIFF....WAVE"
|
||||
if len(wav_bytes) < 12 or wav_bytes[:4] != _WAV_RIFF or wav_bytes[8:12] != _WAV_WAVE:
|
||||
raise HTTPException(status_code=422, detail="Not a valid WAV file")
|
||||
|
||||
try:
|
||||
wav_tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
wav_tmp.write(wav_bytes)
|
||||
wav_tmp.close()
|
||||
|
||||
mp3_tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
||||
mp3_tmp.close()
|
||||
|
||||
# H5 - register cleanup regardless of success/failure
|
||||
background_tasks.add_task(_rm, wav_tmp.name)
|
||||
background_tasks.add_task(_rm, mp3_tmp.name)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-i", wav_tmp.name,
|
||||
"-codec:a", "libmp3lame",
|
||||
"-q:a", "2",
|
||||
mp3_tmp.name,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("ffmpeg failed: %s", result.stderr.decode(errors="replace")[:500])
|
||||
raise RuntimeError("ffmpeg conversion failed")
|
||||
|
||||
return FileResponse(
|
||||
mp3_tmp.name,
|
||||
media_type="audio/mpeg",
|
||||
filename="export.mp3",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": 'attachment; filename="export.mp3"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("export_mp3 failed")
|
||||
raise HTTPException(status_code=500, detail="MP3 export failed. Please try again.")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
/fingering/generate - MusicXML to annotated MusicXML with finger numbers
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
MAX_MUSICXML_BYTES = 10 * 1024 * 1024 # 10 MB - large scores are ~500 KB
|
||||
|
||||
|
||||
class FingeringRequest(BaseModel):
|
||||
musicxml: str
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_fingering(req: FingeringRequest):
|
||||
"""Run pianoplayer Parncutt algorithm on MusicXML, returns annotated MusicXML."""
|
||||
if len(req.musicxml.encode()) > MAX_MUSICXML_BYTES:
|
||||
raise HTTPException(status_code=413, detail="MusicXML payload too large (max 10 MB)")
|
||||
try:
|
||||
from services.pianoplayer_svc import annotate_fingering
|
||||
annotated = await annotate_fingering(req.musicxml)
|
||||
return JSONResponse(
|
||||
{"musicxml": annotated},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("generate_fingering failed")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Fingering generation failed. Please try again.",
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
/omr/pdf - PDF sheet music to MusicXML via Audiveris
|
||||
/omr/image - Image (PNG/JPG) sheet music to MusicXML via Audiveris
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# C2 - file size cap
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
# H4 - extension allowlists
|
||||
_PDF_EXTS = {".pdf"}
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".tiff", ".tif"}
|
||||
|
||||
# M5 - magic bytes
|
||||
_PDF_MAGIC = b"%PDF"
|
||||
_PNG_MAGIC = b"\x89PNG"
|
||||
_JPEG_MAGIC = b"\xff\xd8\xff"
|
||||
_TIFF_MAGIC = (b"II*\x00", b"MM\x00*")
|
||||
|
||||
|
||||
async def _read_with_limit(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
|
||||
"""Stream-read upload in 64 KB chunks; reject as soon as total exceeds limit.
|
||||
|
||||
Unlike ``await file.read()`` followed by a size check, this never buffers
|
||||
an arbitrarily large payload into memory (H2: memory-DoS prevention).
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(65_536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=413, detail="Upload too large (max 50 MB)")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _check_ext(filename: str, allowed: set[str]) -> None:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported file type. Allowed: {', '.join(sorted(allowed))}",
|
||||
)
|
||||
|
||||
|
||||
def _check_pdf_magic(data: bytes) -> None:
|
||||
if not data.startswith(_PDF_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid PDF file")
|
||||
|
||||
|
||||
def _check_image_magic(data: bytes, ext: str) -> None:
|
||||
if ext in {".png"} and not data.startswith(_PNG_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid PNG file")
|
||||
if ext in {".jpg", ".jpeg"} and not data.startswith(_JPEG_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid JPEG file")
|
||||
if ext in {".tiff", ".tif"} and not any(data.startswith(m) for m in _TIFF_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid TIFF file")
|
||||
|
||||
|
||||
@router.post("/pdf")
|
||||
async def omr_pdf(file: UploadFile):
|
||||
"""Run OMR on a PDF sheet music file to MusicXML."""
|
||||
try:
|
||||
_check_ext(file.filename or "score.pdf", _PDF_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
_check_pdf_magic(data)
|
||||
|
||||
from services.audiveris import pdf_to_musicxml
|
||||
musicxml = await pdf_to_musicxml(data, suffix=".pdf")
|
||||
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("omr_pdf failed")
|
||||
raise HTTPException(status_code=500, detail="OMR processing failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/image")
|
||||
async def omr_image(file: UploadFile):
|
||||
"""Run OMR on an image (PNG/JPG) of sheet music to MusicXML."""
|
||||
try:
|
||||
filename = file.filename or "image.png"
|
||||
_check_ext(filename, _IMAGE_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
ext = Path(filename).suffix.lower()
|
||||
_check_image_magic(data, ext)
|
||||
|
||||
from services.audiveris import pdf_to_musicxml
|
||||
musicxml = await pdf_to_musicxml(data, suffix=ext)
|
||||
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("omr_image failed")
|
||||
raise HTTPException(status_code=500, detail="OMR processing failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/pdf2midi")
|
||||
async def omr_pdf_to_midi(file: UploadFile):
|
||||
"""PDF sheet music, OMR to MusicXML + MIDI in one round-trip.
|
||||
|
||||
Returns JSON: { musicxml: str, midi_b64: str, filename: str }
|
||||
Both artifacts arrive together so the frontend can render sheet music
|
||||
AND enable playback without a second network request.
|
||||
"""
|
||||
import base64
|
||||
|
||||
try:
|
||||
_check_ext(file.filename or "score.pdf", _PDF_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
_check_pdf_magic(data)
|
||||
|
||||
from services.audiveris import pdf_to_musicxml_and_midi
|
||||
musicxml, midi_bytes = await pdf_to_musicxml_and_midi(data, suffix=".pdf")
|
||||
midi_b64 = base64.b64encode(midi_bytes).decode("ascii")
|
||||
|
||||
return JSONResponse(
|
||||
{"musicxml": musicxml, "midi_b64": midi_b64, "filename": "score.mid"},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except RuntimeError as exc:
|
||||
# User-safe messages from audiveris.py (no JAR, no output, etc.)
|
||||
logger.warning("omr_pdf_to_midi: %s", exc)
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except Exception:
|
||||
logger.exception("omr_pdf_to_midi failed unexpectedly")
|
||||
raise HTTPException(status_code=500, detail="PDF processing failed. Please try again.")
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
/transcribe/midi2musicxml - MIDI to MusicXML via music21
|
||||
/transcribe/musicxml2midi - MusicXML to MIDI via music21
|
||||
/transcribe/mp3 - MP3 to MIDI via Basic Pitch (Spotify)
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# C2 - file size cap
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
# H4 - extension allowlists
|
||||
_MIDI_EXTS = {".mid", ".midi"}
|
||||
_XML_EXTS = {".xml", ".mxl"}
|
||||
_AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"}
|
||||
|
||||
# M5 - MIDI magic bytes: "MThd"
|
||||
_MIDI_MAGIC = b"MThd"
|
||||
|
||||
|
||||
# ── Shared guards ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _read_with_limit(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
|
||||
"""Stream-read upload in 64 KB chunks; reject as soon as total exceeds limit.
|
||||
|
||||
Unlike ``await file.read()`` followed by a size check, this never buffers
|
||||
an arbitrarily large payload into memory (H2: memory-DoS prevention).
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(65_536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=413, detail="Upload too large (max 50 MB)")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _check_ext(filename: str, allowed: set[str]) -> None:
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported file type. Allowed: {', '.join(sorted(allowed))}",
|
||||
)
|
||||
|
||||
|
||||
def _check_midi_magic(data: bytes) -> None:
|
||||
if not data.startswith(_MIDI_MAGIC):
|
||||
raise HTTPException(status_code=422, detail="Not a valid MIDI file")
|
||||
|
||||
|
||||
def _safe_title(raw: str) -> str:
|
||||
"""M2 - strip control chars and cap length.
|
||||
HTML-escaping for XML embedding is done by music21_svc, not here,
|
||||
to avoid double-escaping (&amp; etc.)."""
|
||||
cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", raw)
|
||||
return cleaned[:200]
|
||||
|
||||
|
||||
def _rm(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/midi2musicxml")
|
||||
async def midi_to_musicxml(file: UploadFile, title: str = Query(default="")):
|
||||
"""Convert a MIDI file to MusicXML for sheet music rendering."""
|
||||
try:
|
||||
_check_ext(file.filename or "track.mid", _MIDI_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
_check_midi_magic(data)
|
||||
|
||||
from services.music21_svc import midi_bytes_to_musicxml
|
||||
safe_title = _safe_title(title)
|
||||
musicxml = await midi_bytes_to_musicxml(data, title=safe_title)
|
||||
|
||||
# L2 - prevent caching of user data
|
||||
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("midi_to_musicxml failed")
|
||||
raise HTTPException(status_code=500, detail="Conversion failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/musicxml2midi")
|
||||
async def musicxml_to_midi(file: UploadFile, background_tasks: BackgroundTasks):
|
||||
"""Convert a MusicXML (.xml or .mxl) file to MIDI for playback."""
|
||||
try:
|
||||
filename = file.filename or "score.xml"
|
||||
_check_ext(filename, _XML_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
|
||||
from services.music21_svc import musicxml_bytes_to_midi
|
||||
midi_bytes = await musicxml_bytes_to_midi(data, filename)
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
|
||||
tmp.write(midi_bytes)
|
||||
tmp.close()
|
||||
background_tasks.add_task(_rm, tmp.name) # H5
|
||||
|
||||
return FileResponse(
|
||||
tmp.name,
|
||||
media_type="audio/midi",
|
||||
filename="score.mid",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": 'attachment; filename="score.mid"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("musicxml_to_midi failed")
|
||||
raise HTTPException(status_code=500, detail="Conversion failed. Please try again.")
|
||||
|
||||
|
||||
@router.post("/mp3")
|
||||
async def transcribe_mp3(file: UploadFile, background_tasks: BackgroundTasks):
|
||||
"""Transcribe an MP3/audio file to MIDI using Basic Pitch."""
|
||||
try:
|
||||
_check_ext(file.filename or "audio.mp3", _AUDIO_EXTS)
|
||||
data = await _read_with_limit(file)
|
||||
|
||||
from services.basic_pitch import transcribe_audio_to_midi
|
||||
midi_bytes = await transcribe_audio_to_midi(data, file.filename or "audio.mp3")
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
|
||||
tmp.write(midi_bytes)
|
||||
tmp.close()
|
||||
background_tasks.add_task(_rm, tmp.name) # H5
|
||||
|
||||
return FileResponse(
|
||||
tmp.name,
|
||||
media_type="audio/midi",
|
||||
filename="transcribed.mid",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": 'attachment; filename="transcribed.mid"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("transcribe_mp3 failed")
|
||||
raise HTTPException(status_code=500, detail="Transcription failed. Please try again.")
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
/youtube/extract - Download audio from YouTube URL, transcribe to MIDI
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# C1 - validate video IDs: exactly 11 URL-safe chars
|
||||
_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
||||
|
||||
|
||||
class YoutubeExtractRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.post("/extract")
|
||||
async def youtube_extract(req: YoutubeExtractRequest, background_tasks: BackgroundTasks):
|
||||
"""Download best audio from YouTube, run Basic Pitch, return MIDI."""
|
||||
# C1 - extract video ID from user URL, reconstruct a safe canonical URL.
|
||||
# The raw req.url is NEVER forwarded to yt-dlp (SSRF prevention).
|
||||
match = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{11})", req.url)
|
||||
if not match or not _VIDEO_ID_RE.match(match.group(1)):
|
||||
raise HTTPException(status_code=422, detail="Invalid or unsupported YouTube URL")
|
||||
|
||||
video_id = match.group(1)
|
||||
safe_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
try:
|
||||
from services.ytdlp import download_audio
|
||||
from services.basic_pitch import transcribe_audio_to_midi
|
||||
|
||||
audio_path, audio_tmpdir = await download_audio(safe_url)
|
||||
# H5 - register audio dir cleanup immediately so it runs even if
|
||||
# transcription fails (don't wait until after the whole try block).
|
||||
background_tasks.add_task(_rm_dir, audio_tmpdir)
|
||||
|
||||
midi_bytes = await transcribe_audio_to_midi(
|
||||
audio_path.read_bytes(), audio_path.name
|
||||
)
|
||||
|
||||
# H5 - write MIDI to a named temp file; register cleanup in background
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
|
||||
tmp.write(midi_bytes)
|
||||
tmp.close()
|
||||
background_tasks.add_task(_rm_file, tmp.name)
|
||||
|
||||
# L2 - no-store so the browser never caches the MIDI file
|
||||
return FileResponse(
|
||||
tmp.name,
|
||||
media_type="audio/midi",
|
||||
filename=f"{video_id}.mid",
|
||||
headers={
|
||||
"X-Video-Id": video_id,
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": f'attachment; filename="{video_id}.mid"',
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("youtube_extract failed for video_id=%s", video_id)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Audio extraction failed. Please try again.",
|
||||
)
|
||||
|
||||
|
||||
# ── Cleanup helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _rm_file(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _rm_dir(path: str) -> None:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove temp directory %s: %s", path, e)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Audiveris service: PDF/image to MusicXML via Audiveris.
|
||||
|
||||
Audiveris is detected automatically in three locations (in order):
|
||||
1. backend/bin/audiveris.jar - legacy standalone JAR (any platform)
|
||||
2. /Applications/Audiveris.app - macOS DMG install
|
||||
3. `audiveris` in PATH - Linux .deb / Windows .msi install
|
||||
|
||||
Get Audiveris at: https://github.com/Audiveris/audiveris/releases
|
||||
Requires Java 17+ (already present on this machine).
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# ── Audiveris binary detection ────────────────────────────────────────────────
|
||||
|
||||
_LEGACY_JAR = Path(__file__).parent.parent / "bin" / "audiveris.jar"
|
||||
|
||||
_MACOS_APP_BIN = Path("/Applications/Audiveris.app/Contents/MacOS/Audiveris")
|
||||
|
||||
|
||||
def _find_audiveris() -> list[str] | None:
|
||||
"""Return the command prefix to invoke Audiveris, or None if not found.
|
||||
|
||||
Checked in order:
|
||||
1. backend/bin/audiveris.jar (legacy standalone JAR)
|
||||
2. /Applications/Audiveris.app (macOS DMG install)
|
||||
3. `audiveris` in PATH (Linux deb / Windows msi)
|
||||
"""
|
||||
if _LEGACY_JAR.exists():
|
||||
return ["java", "-jar", str(_LEGACY_JAR)]
|
||||
if _MACOS_APP_BIN.exists():
|
||||
return [str(_MACOS_APP_BIN)]
|
||||
if shutil.which("audiveris"):
|
||||
return ["audiveris"]
|
||||
return None
|
||||
|
||||
|
||||
# ── Timeouts (configurable via env vars) ──────────────────────────────────────
|
||||
|
||||
# Audiveris: typically 30-120 s per page; set higher for very large scores.
|
||||
AUDIVERIS_TIMEOUT = int(os.environ.get("AUDIVERIS_TIMEOUT_S", "120"))
|
||||
|
||||
# H4 - extension allowlist (defence-in-depth; routers also enforce this)
|
||||
_ALLOWED_SUFFIXES = {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"}
|
||||
|
||||
|
||||
# ── Public async entry points ─────────────────────────────────────────────────
|
||||
|
||||
async def pdf_to_musicxml(file_bytes: bytes, suffix: str = ".pdf") -> str:
|
||||
"""Run Audiveris OMR on a PDF or image, returns MusicXML string."""
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _run_audiveris, file_bytes, suffix
|
||||
)
|
||||
|
||||
|
||||
async def pdf_to_musicxml_and_midi(
|
||||
file_bytes: bytes, suffix: str = ".pdf"
|
||||
) -> tuple[str, bytes]:
|
||||
"""OMR to MusicXML, then to MIDI.
|
||||
|
||||
Runs both steps in a single thread-pool task (both are blocking) to
|
||||
avoid two executor round-trips and to keep the intermediate MusicXML
|
||||
in memory rather than writing it back over the wire.
|
||||
|
||||
Returns:
|
||||
(musicxml_str, midi_bytes)
|
||||
"""
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _run_omr_and_convert, file_bytes, suffix
|
||||
)
|
||||
|
||||
|
||||
# ── Combined OMR + MIDI conversion (blocking, runs in thread pool) ───────────
|
||||
|
||||
def _run_omr_and_convert(file_bytes: bytes, suffix: str) -> tuple[str, bytes]:
|
||||
musicxml = _run_audiveris(file_bytes, suffix)
|
||||
# Import the private sync helper directly to avoid a second executor call.
|
||||
from services.music21_svc import _xml_to_midi # noqa: PLC0415
|
||||
midi_bytes = _xml_to_midi(musicxml.encode("utf-8"), "score.xml")
|
||||
return musicxml, midi_bytes
|
||||
|
||||
|
||||
# ── Audiveris (primary engine) ────────────────────────────────────────────────
|
||||
|
||||
def _run_audiveris(file_bytes: bytes, suffix: str) -> str:
|
||||
# H4 - validate suffix even if caller skips router-level check
|
||||
if suffix.lower() not in _ALLOWED_SUFFIXES:
|
||||
raise ValueError(f"Unsupported file type: {suffix!r}")
|
||||
|
||||
cmd_prefix = _find_audiveris()
|
||||
if cmd_prefix is None:
|
||||
# T3-2: generic message for the API consumer; full setup details are
|
||||
# logged server-side only to avoid leaking OS/path information.
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).error(
|
||||
"Audiveris not found. Checked: %s, %s, PATH",
|
||||
_LEGACY_JAR, _MACOS_APP_BIN,
|
||||
)
|
||||
raise RuntimeError("OMR engine is not available on this server")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
input_path = Path(tmpdir) / f"input{suffix}"
|
||||
input_path.write_bytes(file_bytes)
|
||||
output_dir = Path(tmpdir) / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
result = subprocess.run(
|
||||
cmd_prefix + [
|
||||
"-batch",
|
||||
"-export",
|
||||
"-output", str(output_dir),
|
||||
"--", str(input_path),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=AUDIVERIS_TIMEOUT,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# H3 - don't leak internal tool output in API response
|
||||
raise RuntimeError("Audiveris OMR processing failed")
|
||||
|
||||
xml_files = list(output_dir.rglob("*.mxl")) + list(output_dir.rglob("*.xml"))
|
||||
if not xml_files:
|
||||
raise RuntimeError(
|
||||
"Audiveris produced no MusicXML output. "
|
||||
"The PDF may be a text document or a poor-quality scan."
|
||||
)
|
||||
|
||||
result_path = xml_files[0]
|
||||
if result_path.suffix.lower() == ".mxl":
|
||||
# .mxl is a ZIP-compressed MusicXML - extract the main XML entry
|
||||
import posixpath
|
||||
import zipfile
|
||||
|
||||
def _safe_member(name: str) -> bool:
|
||||
"""Reject ZIP entry names with path traversal sequences."""
|
||||
return not posixpath.normpath(name).startswith("..")
|
||||
|
||||
# T1-2: decompressed-size cap to defend against ZIP bombs.
|
||||
# Don't trust ZipInfo.file_size (headers can lie); enforce
|
||||
# the limit during actual streaming decompression.
|
||||
MAX_DECOMPRESSED = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
with zipfile.ZipFile(result_path) as zf:
|
||||
# META-INF/container.xml lists the root file; fall back to first .xml
|
||||
names = zf.namelist()
|
||||
root_file = next(
|
||||
(
|
||||
n for n in names
|
||||
if n.endswith(".xml")
|
||||
and not n.startswith("META-INF")
|
||||
and _safe_member(n)
|
||||
),
|
||||
next(
|
||||
(n for n in names if n.endswith(".xml") and _safe_member(n)),
|
||||
None,
|
||||
),
|
||||
)
|
||||
if root_file is None:
|
||||
raise RuntimeError("Audiveris .mxl archive contains no XML entry")
|
||||
|
||||
# Stream-read with size enforcement (T1-2)
|
||||
with zf.open(root_file) as entry:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = entry.read(65_536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_DECOMPRESSED:
|
||||
raise ValueError(
|
||||
"Decompressed MusicXML exceeds 50 MB, possible ZIP bomb"
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks).decode("utf-8")
|
||||
else:
|
||||
return result_path.read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Basic Pitch service: audio (MP3/WAV/etc.) to MIDI via Spotify's Basic Pitch model.
|
||||
|
||||
Requires Python 3.11 or 3.12 due to tensorflow-macos constraints.
|
||||
See requirements-transcribe.txt for setup instructions.
|
||||
"""
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_IMPORT_ERROR: str | None = None
|
||||
|
||||
try:
|
||||
from basic_pitch.inference import predict # type: ignore
|
||||
from basic_pitch import ICASSP_2022_MODEL_PATH # type: ignore
|
||||
except ImportError as e:
|
||||
_IMPORT_ERROR = (
|
||||
"basic-pitch is not installed. MP3 transcription requires Python 3.11 or 3.12. "
|
||||
"See backend/requirements-transcribe.txt for setup instructions. "
|
||||
f"Original error: {e}"
|
||||
)
|
||||
|
||||
|
||||
async def transcribe_audio_to_midi(audio_bytes: bytes, filename: str) -> bytes:
|
||||
"""Transcribe audio bytes to MIDI bytes using Basic Pitch."""
|
||||
if _IMPORT_ERROR:
|
||||
raise RuntimeError(_IMPORT_ERROR)
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _transcribe, audio_bytes, filename
|
||||
)
|
||||
|
||||
|
||||
def _transcribe(audio_bytes: bytes, filename: str) -> bytes:
|
||||
suffix = Path(filename).suffix or ".mp3"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
||||
f.write(audio_bytes)
|
||||
audio_path = f.name
|
||||
|
||||
_model_output, midi_data, _note_events = predict( # type: ignore[name-defined]
|
||||
audio_path, ICASSP_2022_MODEL_PATH # type: ignore[name-defined]
|
||||
)
|
||||
|
||||
midi_tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
|
||||
midi_tmp.close()
|
||||
midi_data.write(midi_tmp.name)
|
||||
|
||||
midi_bytes = Path(midi_tmp.name).read_bytes()
|
||||
Path(audio_path).unlink(missing_ok=True)
|
||||
Path(midi_tmp.name).unlink(missing_ok=True)
|
||||
return midi_bytes
|
||||
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
music21 service: MIDI to MusicXML, MusicXML to PDF
|
||||
|
||||
Note: defusedxml.defuse_stdlib() is called in main.py before this module
|
||||
is imported, so xml.etree (used by music21 internally) is already patched.
|
||||
"""
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# T1-3: Dedicated process pool for music21 operations.
|
||||
#
|
||||
# Why ProcessPoolExecutor instead of ThreadPoolExecutor:
|
||||
# Threads can't be forcibly killed when asyncio.wait_for fires; the
|
||||
# timed-out thread keeps consuming CPU/memory until it naturally ends,
|
||||
# occupying one of the two worker slots indefinitely. A separate OS
|
||||
# process can be terminated, so a timed-out worker actually frees its slot.
|
||||
#
|
||||
# max_workers=2 caps concurrency so pathologically complex scores can't
|
||||
# starve all other endpoints. Functions must be module-level (picklable),
|
||||
# which they already are (_convert, _xml_to_midi, _to_pdf take only
|
||||
# bytes/str arguments and return bytes/str).
|
||||
#
|
||||
# Initializer: On macOS / Linux with "spawn" start method each worker is a
|
||||
# fresh process, defusedxml patches from main.py are NOT inherited.
|
||||
# _music21_worker_init() re-applies them so XXE protection is active in
|
||||
# every worker.
|
||||
|
||||
|
||||
def _music21_worker_init() -> None:
|
||||
"""Apply XML security patches in each worker process (spawn-safe)."""
|
||||
import defusedxml as _defusedxml
|
||||
_defusedxml.defuse_stdlib()
|
||||
try:
|
||||
from lxml import etree as _lxml_etree # type: ignore
|
||||
|
||||
_orig = _lxml_etree.XMLParser.__init__
|
||||
|
||||
def _safe_init(self, *args, **kwargs):
|
||||
kwargs.setdefault("resolve_entities", False)
|
||||
kwargs.setdefault("no_network", True)
|
||||
_orig(self, *args, **kwargs)
|
||||
|
||||
_lxml_etree.XMLParser.__init__ = _safe_init
|
||||
except ImportError:
|
||||
pass # lxml not installed, nothing to patch
|
||||
|
||||
|
||||
_music21_executor = concurrent.futures.ProcessPoolExecutor(
|
||||
max_workers=2,
|
||||
initializer=_music21_worker_init,
|
||||
)
|
||||
_MUSIC21_TIMEOUT = int(os.environ.get("MUSIC21_TIMEOUT_S", "60"))
|
||||
|
||||
|
||||
async def _run_in_music21_pool(fn: Callable, *args) -> object:
|
||||
"""Run a blocking music21 function in the dedicated pool with a hard timeout."""
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.get_running_loop().run_in_executor(_music21_executor, fn, *args),
|
||||
timeout=_MUSIC21_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(
|
||||
"Processing timed out, the score may be too complex. "
|
||||
"Try a simpler file or increase MUSIC21_TIMEOUT_S."
|
||||
)
|
||||
|
||||
|
||||
async def midi_bytes_to_musicxml(midi_bytes: bytes, title: str = "") -> str:
|
||||
"""Convert MIDI binary to MusicXML string using music21."""
|
||||
return await _run_in_music21_pool(_convert, midi_bytes, title)
|
||||
|
||||
|
||||
def _convert(midi_bytes: bytes, title: str = "") -> str: # noqa: C901
|
||||
import music21
|
||||
from music21 import tempo as m21tempo
|
||||
|
||||
midi_path = None
|
||||
xml_tmp_name = None
|
||||
try:
|
||||
# H5 - track paths so finally block can always clean up
|
||||
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
|
||||
f.write(midi_bytes)
|
||||
midi_path = f.name
|
||||
|
||||
score = music21.converter.parse(midi_path)
|
||||
|
||||
# Quantize to 16th-note grid + triplets - eliminates exotic tuplets
|
||||
try:
|
||||
score = score.quantize(
|
||||
quarterLengthDivisors=(4, 3),
|
||||
processOffsets=True,
|
||||
processDurations=True,
|
||||
inPlace=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Score quantization skipped: %s", e)
|
||||
|
||||
# Strip excessive MetronomeMark objects from rubato performances
|
||||
try:
|
||||
all_marks = list(score.recurse().getElementsByClass("MetronomeMark"))
|
||||
primary_bpm = round(all_marks[0].number) if all_marks else 120
|
||||
|
||||
for mark in all_marks:
|
||||
if mark.activeSite is not None:
|
||||
try:
|
||||
mark.activeSite.remove(mark)
|
||||
except Exception:
|
||||
pass # mark already detached or activeSite stale
|
||||
|
||||
try:
|
||||
first_part = score.parts[0]
|
||||
measures = first_part.getElementsByClass("Measure")
|
||||
if measures:
|
||||
first_measure = list(measures)[0]
|
||||
mm = m21tempo.MetronomeMark(number=primary_bpm)
|
||||
first_measure.insert(0, mm)
|
||||
except Exception as e:
|
||||
logger.warning("Could not insert primary tempo mark: %s", e)
|
||||
except Exception as e:
|
||||
logger.warning("Tempo cleanup skipped: %s", e)
|
||||
|
||||
# Set score title, sanitize to prevent XML injection
|
||||
try:
|
||||
from music21 import metadata as m21meta
|
||||
if score.metadata is None:
|
||||
score.metadata = m21meta.Metadata()
|
||||
safe_title = html.escape(title[:200]) if title else "Untitled"
|
||||
score.metadata.title = safe_title
|
||||
except Exception as e:
|
||||
logger.warning("Could not set score title: %s", e)
|
||||
|
||||
# Split into treble/bass staves for proper piano rendering
|
||||
score = _apply_piano_split(score)
|
||||
|
||||
xml_tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
|
||||
xml_tmp.close()
|
||||
xml_tmp_name = xml_tmp.name
|
||||
score.write("musicxml", fp=xml_tmp_name)
|
||||
|
||||
xml = Path(xml_tmp_name).read_text(encoding="utf-8")
|
||||
|
||||
# Strip music21-generated boilerplate that clutters the score display
|
||||
xml = re.sub(r'\s*<creator\b[^>]*>[^<]*</creator>\s*\n?', '\n ', xml)
|
||||
xml = re.sub(r'\s*<movement-title\b[^>]*>[^<]*</movement-title>\s*\n?', '\n ', xml)
|
||||
return xml
|
||||
|
||||
finally:
|
||||
if midi_path:
|
||||
Path(midi_path).unlink(missing_ok=True)
|
||||
if xml_tmp_name:
|
||||
Path(xml_tmp_name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _apply_piano_split(score): # noqa: C901
|
||||
"""Rewrite a MIDI-derived score as a proper piano grand staff.
|
||||
|
||||
Grand-staff piano in MusicXML must be ONE part with TWO staves
|
||||
(staves>2</staves>, per-note <staff> tags, <backup> elements).
|
||||
music21 achieves this via PartStaff + StaffGroup - which is what
|
||||
AlphaTab needs to render treble + bass clef linked by a brace.
|
||||
|
||||
Strategy:
|
||||
• Single-part score: split notes at C4 (MIDI 60) into treble/bass PartStaff.
|
||||
• Multi-part score: treat the two highest-/lowest-pitched parts as
|
||||
treble/bass and link them the same way.
|
||||
"""
|
||||
import copy
|
||||
from music21 import clef as m21clef, instrument as m21inst, layout as m21layout
|
||||
from music21 import note as m21note, chord as m21chord
|
||||
from music21 import stream as m21s
|
||||
|
||||
parts = list(score.parts)
|
||||
if not parts:
|
||||
return score
|
||||
|
||||
SPLIT = 60 # Middle C - notes >= C4 go treble, < C4 go bass
|
||||
|
||||
def _avg_pitch(part):
|
||||
pitches = []
|
||||
for n in part.flatten().notes:
|
||||
if hasattr(n, "pitches"):
|
||||
pitches.extend(p.midi for p in n.pitches)
|
||||
elif hasattr(n, "pitch"):
|
||||
pitches.append(n.pitch.midi)
|
||||
return sum(pitches) / len(pitches) if pitches else 60
|
||||
|
||||
# ── Build two PartStaff streams ──────────────────────────────────────────
|
||||
treble_staff = m21s.PartStaff()
|
||||
bass_staff = m21s.PartStaff()
|
||||
treble_staff.insert(0, m21inst.Piano())
|
||||
bass_staff.insert(0, m21inst.Piano())
|
||||
|
||||
def _has_pitched_content(m):
|
||||
"""Return True if the Measure contains at least one Note, Rest, or Chord
|
||||
at the top level OR nested inside Voice containers."""
|
||||
if any(isinstance(e, (m21note.Note, m21note.Rest, m21chord.Chord)) for e in m):
|
||||
return True
|
||||
# Also check inside Voice containers
|
||||
from music21 import stream as _m21s
|
||||
for e in m:
|
||||
if isinstance(e, _m21s.Voice):
|
||||
if any(isinstance(v, (m21note.Note, m21note.Rest, m21chord.Chord)) for v in e):
|
||||
return True
|
||||
return False
|
||||
|
||||
if len(parts) == 1:
|
||||
# Single-track MIDI: distribute notes by pitch while preserving Voice
|
||||
# structure.
|
||||
#
|
||||
# Key design points:
|
||||
# 1. Preserve Voice containers. Game/sequencer MIDIs nest notes inside
|
||||
# Voice sub-streams. If we flatten and re-insert notes at the same
|
||||
# offset into a plain Measure, music21 writes them SEQUENTIALLY
|
||||
# (note A dur=2 + note B dur=0.5 = 2.5 beats consumed), inflating
|
||||
# <backup> values and breaking AlphaTab's cursor tracker.
|
||||
# Keeping Voice containers tells music21 that those notes are
|
||||
# concurrent, so it emits correct <backup> values between voices.
|
||||
# 2. Structural elements (TimeSignature, KeySignature, TempoIndication)
|
||||
# go to the TREBLE staff only - putting them in both staves causes
|
||||
# music21 to emit doubled <time>/<key> attributes.
|
||||
# 3. Pad each staff to barDuration with a single trailing rest so the
|
||||
# MusicXML <backup> value always equals the measure length exactly.
|
||||
from music21 import meter as m21meter, key as m21key, tempo as m21tempo
|
||||
|
||||
def _dist_note(el, t_dest, b_dest):
|
||||
"""Distribute a single Note or Chord between treble/bass targets."""
|
||||
if isinstance(el, m21note.Note):
|
||||
dest = t_dest if el.pitch.midi >= SPLIT else b_dest
|
||||
dest.insert(el.offset, copy.deepcopy(el))
|
||||
elif isinstance(el, m21chord.Chord):
|
||||
t_notes = [n for n in el.notes if n.pitch.midi >= SPLIT]
|
||||
b_notes = [n for n in el.notes if n.pitch.midi < SPLIT]
|
||||
if t_notes:
|
||||
c = m21chord.Chord([copy.deepcopy(n) for n in t_notes])
|
||||
c.duration = copy.deepcopy(el.duration)
|
||||
t_dest.insert(el.offset, c)
|
||||
if b_notes:
|
||||
c = m21chord.Chord([copy.deepcopy(n) for n in b_notes])
|
||||
c.duration = copy.deepcopy(el.duration)
|
||||
b_dest.insert(el.offset, c)
|
||||
|
||||
source = parts[0]
|
||||
for meas in source.getElementsByClass("Measure"):
|
||||
t_meas = m21s.Measure(number=meas.number)
|
||||
b_meas = m21s.Measure(number=meas.number)
|
||||
|
||||
# ── Structural / non-pitched elements: treble only ───────────────
|
||||
for el in meas:
|
||||
if isinstance(el, (m21meter.TimeSignature,
|
||||
m21key.KeySignature,
|
||||
m21tempo.TempoIndication)):
|
||||
t_meas.insert(el.offset, copy.deepcopy(el))
|
||||
# Clefs are assigned separately below; instruments already set.
|
||||
|
||||
# ── Pitched content: preserve Voice containers ────────────────────
|
||||
# Walk top-level elements. Voice containers are kept intact so that
|
||||
# music21 writes concurrent notes correctly in MusicXML.
|
||||
for el in meas:
|
||||
if isinstance(el, m21s.Voice):
|
||||
t_voice = m21s.Voice()
|
||||
b_voice = m21s.Voice()
|
||||
for sub in el:
|
||||
_dist_note(sub, t_voice, b_voice)
|
||||
if any(True for _ in t_voice):
|
||||
t_meas.insert(el.offset, t_voice)
|
||||
if any(True for _ in b_voice):
|
||||
b_meas.insert(el.offset, b_voice)
|
||||
elif isinstance(el, (m21note.Note, m21chord.Chord)):
|
||||
_dist_note(el, t_meas, b_meas)
|
||||
# Rests at top level are skipped; padding is added below.
|
||||
|
||||
# ── Pad each staff to barDuration with a single trailing rest ─────
|
||||
# This ensures highestTime == barDuration so music21 emits the
|
||||
# correct <backup> value when writing the combined grand-staff part.
|
||||
bar_ql = meas.barDuration.quarterLength
|
||||
for staff_meas in (t_meas, b_meas):
|
||||
ht = staff_meas.highestTime
|
||||
if ht < bar_ql - 0.001:
|
||||
pad = m21note.Rest()
|
||||
pad.duration.quarterLength = bar_ql - ht
|
||||
staff_meas.append(pad)
|
||||
|
||||
treble_staff.append(t_meas)
|
||||
bass_staff.append(b_meas)
|
||||
|
||||
else:
|
||||
# Multi-track MIDI: pick the two parts with highest/lowest avg pitch
|
||||
sorted_parts = sorted(parts, key=_avg_pitch, reverse=True)
|
||||
treble_src, bass_src = sorted_parts[0], sorted_parts[-1]
|
||||
for src, dest in [(treble_src, treble_staff), (bass_src, bass_staff)]:
|
||||
for meas in src.getElementsByClass("Measure"):
|
||||
dest.append(copy.deepcopy(meas))
|
||||
|
||||
# ── Assign clefs to first measure of each PartStaff ─────────────────────
|
||||
for staff, clef_obj in [
|
||||
(treble_staff, m21clef.TrebleClef()),
|
||||
(bass_staff, m21clef.BassClef()),
|
||||
]:
|
||||
measures = list(staff.getElementsByClass("Measure"))
|
||||
if measures:
|
||||
first_m = measures[0]
|
||||
for c in list(first_m.getElementsByClass("Clef")):
|
||||
first_m.remove(c)
|
||||
first_m.insert(0, clef_obj)
|
||||
|
||||
# ── Assemble score with brace StaffGroup (= grand staff) ─────────────────
|
||||
new_score = m21s.Score()
|
||||
if score.metadata:
|
||||
new_score.metadata = score.metadata
|
||||
new_score.insert(0, treble_staff)
|
||||
new_score.insert(0, bass_staff)
|
||||
|
||||
# StaffGroup with symbol='brace' tells MusicXML consumers (AlphaTab)
|
||||
# that these two PartStaffs are a single piano grand staff.
|
||||
staff_group = m21layout.StaffGroup(
|
||||
[treble_staff, bass_staff],
|
||||
name="Piano",
|
||||
abbreviation="Pno.",
|
||||
symbol="brace",
|
||||
)
|
||||
new_score.insert(0, staff_group)
|
||||
|
||||
return new_score
|
||||
|
||||
|
||||
async def musicxml_bytes_to_midi(xml_bytes: bytes, filename: str = "score.xml") -> bytes:
|
||||
"""Convert MusicXML (.xml or .mxl) bytes to MIDI bytes using music21."""
|
||||
return await _run_in_music21_pool(_xml_to_midi, xml_bytes, filename)
|
||||
|
||||
|
||||
def _xml_to_midi(xml_bytes: bytes, filename: str) -> bytes:
|
||||
import music21
|
||||
|
||||
suffix = ".mxl" if filename.lower().endswith(".mxl") else ".xml"
|
||||
xml_path = None
|
||||
midi_tmp_name = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
||||
f.write(xml_bytes)
|
||||
xml_path = f.name
|
||||
|
||||
score = music21.converter.parse(xml_path)
|
||||
|
||||
midi_tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
|
||||
midi_tmp.close()
|
||||
midi_tmp_name = midi_tmp.name
|
||||
score.write("midi", fp=midi_tmp_name)
|
||||
|
||||
return Path(midi_tmp_name).read_bytes()
|
||||
|
||||
finally:
|
||||
if xml_path:
|
||||
Path(xml_path).unlink(missing_ok=True)
|
||||
if midi_tmp_name:
|
||||
Path(midi_tmp_name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def musicxml_to_pdf(musicxml: str) -> str:
|
||||
"""Render MusicXML to PDF using music21 + LilyPond. Returns path to PDF file."""
|
||||
return await _run_in_music21_pool(_to_pdf, musicxml)
|
||||
|
||||
|
||||
def _to_pdf(musicxml: str) -> str:
|
||||
import music21
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
xml_path = None
|
||||
ly_path = None
|
||||
pdf_tmp_name = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".xml", delete=False, mode="w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(musicxml)
|
||||
xml_path = f.name
|
||||
|
||||
score = music21.converter.parse(xml_path)
|
||||
|
||||
# M2: Generate LilyPond source via music21, then invoke lilypond
|
||||
# manually with a restricted environment. This prevents a crafted
|
||||
# score from exfiltrating env vars or touching the filesystem via
|
||||
# LilyPond's embedded Scheme interpreter.
|
||||
ly_tmp = tempfile.NamedTemporaryFile(suffix=".ly", delete=False)
|
||||
ly_tmp.close()
|
||||
ly_path = ly_tmp.name
|
||||
score.write("lily", fp=ly_path)
|
||||
|
||||
lilypond_bin = shutil.which("lilypond")
|
||||
if not lilypond_bin:
|
||||
raise RuntimeError(
|
||||
"LilyPond not found in PATH. PDF export requires LilyPond.\n"
|
||||
" macOS: brew install lilypond\n"
|
||||
" Linux: apt install lilypond"
|
||||
)
|
||||
|
||||
pdf_tmp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
|
||||
pdf_tmp.close()
|
||||
pdf_tmp_name = pdf_tmp.name
|
||||
# LilyPond appends .pdf to the -o basename, so strip the extension
|
||||
pdf_base = pdf_tmp_name[:-4]
|
||||
|
||||
# Minimal environment: only PATH, HOME, and LilyPond/Guile paths
|
||||
safe_env = {
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/usr/local/bin"),
|
||||
"HOME": os.environ.get("HOME", "/tmp"),
|
||||
}
|
||||
for key in ("GUILE_LOAD_PATH", "GUILE_LOAD_COMPILED_PATH", "LILYPOND_DATADIR"):
|
||||
if key in os.environ:
|
||||
safe_env[key] = os.environ[key]
|
||||
|
||||
result = subprocess.run(
|
||||
[lilypond_bin, "--pdf", "-dno-point-and-click", "-o", pdf_base, ly_path],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
env=safe_env,
|
||||
)
|
||||
|
||||
if result.returncode != 0 or not Path(pdf_tmp_name).exists():
|
||||
logger.error("LilyPond failed: %s", result.stderr.decode(errors="replace")[:500])
|
||||
raise RuntimeError("LilyPond PDF rendering failed")
|
||||
|
||||
# Caller (export_routes) owns cleanup via BackgroundTask
|
||||
pdf_tmp_name = None # transfer ownership, don't clean up in finally
|
||||
return pdf_tmp.name
|
||||
|
||||
finally:
|
||||
if xml_path:
|
||||
Path(xml_path).unlink(missing_ok=True)
|
||||
if ly_path:
|
||||
Path(ly_path).unlink(missing_ok=True)
|
||||
# Clean up PDF temp only if we still own it (i.e. render raised)
|
||||
if pdf_tmp_name:
|
||||
Path(pdf_tmp_name).unlink(missing_ok=True)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
pianoplayer service: annotate MusicXML with fingering (Parncutt algorithm).
|
||||
|
||||
Note: defusedxml.defuse_stdlib() is called in main.py before this module
|
||||
is imported, so xml.etree (used by music21 internally) is already patched.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Configurable via env var; default 120 s is generous for complex scores.
|
||||
_FINGERING_TIMEOUT = int(os.environ.get("FINGERING_TIMEOUT_S", "120"))
|
||||
|
||||
|
||||
async def annotate_fingering(musicxml: str) -> str:
|
||||
"""Add finger annotations to MusicXML using the pianoplayer library."""
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.get_running_loop().run_in_executor(None, _annotate, musicxml),
|
||||
timeout=_FINGERING_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(
|
||||
"Fingering generation timed out. "
|
||||
"Try a shorter score or increase FINGERING_TIMEOUT_S."
|
||||
)
|
||||
|
||||
|
||||
def _annotate(musicxml: str) -> str:
|
||||
from pianoplayer.hand import RightHand, LeftHand
|
||||
import music21
|
||||
|
||||
xml_path = None
|
||||
out_tmp_name = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".xml", delete=False, mode="w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(musicxml)
|
||||
xml_path = f.name
|
||||
|
||||
score = music21.converter.parse(xml_path)
|
||||
|
||||
# pianoplayer works on individual parts
|
||||
for part in score.parts:
|
||||
notes = list(part.flatten().notes)
|
||||
if not notes:
|
||||
continue
|
||||
|
||||
# Determine left / right hand by average register
|
||||
avg_pitch = sum(
|
||||
n.pitch.midi for n in notes if hasattr(n, "pitch")
|
||||
) / max(len(notes), 1)
|
||||
hand_cls = RightHand if avg_pitch >= 60 else LeftHand
|
||||
|
||||
try:
|
||||
hand = hand_cls(score, 0)
|
||||
hand.autodepth = False
|
||||
hand.verbose = False
|
||||
hand.generate(part=part.id if hasattr(part, "id") else 0)
|
||||
except Exception:
|
||||
pass # pianoplayer can fail on complex scores; skip gracefully
|
||||
|
||||
out_tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
|
||||
out_tmp.close()
|
||||
out_tmp_name = out_tmp.name
|
||||
score.write("musicxml", fp=out_tmp_name)
|
||||
|
||||
return Path(out_tmp_name).read_text(encoding="utf-8")
|
||||
|
||||
finally:
|
||||
if xml_path:
|
||||
Path(xml_path).unlink(missing_ok=True)
|
||||
if out_tmp_name:
|
||||
Path(out_tmp_name).unlink(missing_ok=True)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
yt-dlp service: download best audio from a YouTube URL.
|
||||
|
||||
Returns (audio_path, tmpdir) - the caller is responsible for cleaning up
|
||||
tmpdir (e.g. via a BackgroundTask) after the audio data has been consumed.
|
||||
"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
async def download_audio(url: str) -> tuple[Path, str]:
|
||||
"""Download best audio track from YouTube URL.
|
||||
|
||||
Returns:
|
||||
(mp3_path, tmpdir) - tmpdir must be deleted by the caller.
|
||||
"""
|
||||
return await asyncio.get_running_loop().run_in_executor(None, _download, url)
|
||||
|
||||
|
||||
def _download(url: str) -> tuple[Path, str]:
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
output_template = str(Path(tmpdir) / "audio.%(ext)s")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"yt-dlp",
|
||||
"--format", "bestaudio/best",
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", "0",
|
||||
"--output", output_template,
|
||||
"--no-playlist",
|
||||
# Cap download size: a 30-min piano recording at high quality is ~50 MB;
|
||||
# 200 MB gives ample headroom while guarding against 24-hour streams
|
||||
# that would exhaust disk space before the 300 s timeout fires.
|
||||
"--max-filesize", "200m",
|
||||
url, # already a safe canonical URL from the router
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Don't leak yt-dlp stderr in the API response; log it server-side
|
||||
import logging
|
||||
logging.getLogger(__name__).error(
|
||||
"yt-dlp failed (exit %d): %s",
|
||||
result.returncode,
|
||||
result.stderr.decode(errors="replace")[:500],
|
||||
)
|
||||
raise RuntimeError("Audio download failed")
|
||||
|
||||
mp3_files = list(Path(tmpdir).glob("*.mp3"))
|
||||
if not mp3_files:
|
||||
raise RuntimeError("Audio download produced no output")
|
||||
|
||||
return mp3_files[0], tmpdir
|
||||
@@ -0,0 +1,43 @@
|
||||
# Bach to Basics: production-style docker compose
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # optional, only needed if you want to set BACKEND_API_KEY
|
||||
# docker compose up # build + start
|
||||
# open http://localhost:5173
|
||||
#
|
||||
# To enable PDF import via Audiveris, drop audiveris.jar into ./backend/bin/
|
||||
# before bringing the stack up, it gets mounted into the backend container.
|
||||
#
|
||||
# This compose file is for end users who want to RUN the app. For active
|
||||
# development with hot reload, use `pnpm dev` directly on your host.
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: bach-backend
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:5173}
|
||||
- BACKEND_API_KEY=${BACKEND_API_KEY:-}
|
||||
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-60}
|
||||
- MUSIC21_TIMEOUT_S=${MUSIC21_TIMEOUT_S:-60}
|
||||
- FINGERING_TIMEOUT_S=${FINGERING_TIMEOUT_S:-120}
|
||||
volumes:
|
||||
# Mount audiveris.jar from host (optional - PDF import is disabled if absent)
|
||||
- ./backend/bin:/app/bin:ro
|
||||
|
||||
frontend:
|
||||
build:
|
||||
# Root context so the Dockerfile can read pnpm-workspace.yaml + shared/
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
container_name: bach-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 1020 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 542 KiB |
|
After Width: | Height: | Size: 580 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 355 KiB |
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
tsconfig.tsbuildinfo
|
||||
.DS_Store
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,27 @@
|
||||
# Bach to Basics: frontend image
|
||||
# Multi-stage build: pnpm build, static files served by nginx with /api proxy
|
||||
|
||||
# ── Stage 1: build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace lockfile + manifests at the repo root drive deterministic installs.
|
||||
# Build context is the repo root (see docker-compose.yml).
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY shared ./shared
|
||||
COPY frontend ./frontend
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter ./frontend...
|
||||
RUN pnpm --filter frontend build
|
||||
|
||||
# ── Stage 2: serve ──────────────────────────────────────────────────────────
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- T4-1: Content Security Policy: restrict script/style/connect sources.
|
||||
'unsafe-inline' for script-src is needed for the error overlay below;
|
||||
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'" />
|
||||
<title>Bach to Basics</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-light.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- Error catcher: shows any unhandled JS error directly on screen -->
|
||||
<div id="error-overlay" style="display:none;position:fixed;inset:0;background:#1a0000;color:#ff6b6b;padding:24px;font:14px monospace;z-index:9999;white-space:pre-wrap;overflow:auto;"></div>
|
||||
<script>
|
||||
// T3-3: Only show detailed stack traces in development (localhost).
|
||||
// Production displays a user-friendly message without file paths.
|
||||
var isDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
||||
window.onerror = function(msg, src, line, col, err) {
|
||||
var el = document.getElementById('error-overlay');
|
||||
el.style.display = 'block';
|
||||
el.textContent = isDev
|
||||
? 'JS ERROR:\n' + msg + '\n\nFile: ' + src + ':' + line + '\n\n' + (err && err.stack ? err.stack : '')
|
||||
: 'Something went wrong. Please reload the page.';
|
||||
};
|
||||
window.addEventListener('unhandledrejection', function(e) {
|
||||
var el = document.getElementById('error-overlay');
|
||||
el.style.display = 'block';
|
||||
if (isDev) {
|
||||
el.textContent = (el.textContent || '') + '\n\nUNHANDLED PROMISE:\n' + (e.reason && e.reason.stack ? e.reason.stack : String(e.reason));
|
||||
} else if (!el.textContent) {
|
||||
el.textContent = 'Something went wrong. Please reload the page.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
# Bach to Basics: nginx config for the static frontend container.
|
||||
#
|
||||
# Serves the built React app and proxies /api/* to the backend service.
|
||||
# Backend hostname "backend" is resolved via Docker Compose's network.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ── Security headers ────────────────────────────────────────────────────
|
||||
# These are set here (server block) so they apply to all responses that
|
||||
# don't have their own add_header directives.
|
||||
# NOTE: nginx does NOT inherit server-level add_header into location blocks
|
||||
# that define their own add_header (e.g. /assets/). Those blocks repeat
|
||||
# the security headers explicitly to avoid the inheritance pitfall.
|
||||
# /api/ is intentionally excluded - the FastAPI backend sets its own.
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# SPA fallback: any route that isn't a real file goes to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy, strips the /api prefix on the way through
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Sample / soundfont uploads can be large
|
||||
client_max_body_size 60M;
|
||||
# OMR + transcription can take a while
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Cache static assets aggressively (Vite hashes filenames).
|
||||
# Repeats security headers because this block defines its own add_header
|
||||
# (nginx would otherwise drop the server-level ones here).
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Bach to Basics — browser-based piano learning app with falling notes, sheet music, and MIDI support",
|
||||
"author": "gigliof",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/gigliof/bach-to-basics#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/gigliof/bach-to-basics.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/gigliof/bach-to-basics/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bach-to-basics/shared": "workspace:*",
|
||||
"@coderline/alphatab": "^1.3.0",
|
||||
"pixi.js": "^8.0.0",
|
||||
"tone": "^15.0.4",
|
||||
"smplr": "^0.14.0",
|
||||
"webmidi": "^3.1.11",
|
||||
"@tonejs/midi": "^2.0.6",
|
||||
"mitt": "^3.0.1",
|
||||
"zustand": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"vitest": "^2.0.0",
|
||||
"@vitest/ui": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<style>
|
||||
.lm { display: block; }
|
||||
.dm { display: none; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.lm { display: none; }
|
||||
.dm { display: block; }
|
||||
}
|
||||
</style>
|
||||
<image class="lm" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAG9klEQVR4nO1Vf2xVdxU/P773vvcoZVvbVUDWDSz08QaSUOgaDLb8eKV07WDL3oZsyZyLcWYui4mSqDHPJibMLcZppjHL5vSPuc03iBtdC4PJr2gVrQyExyvRaBVL6YCCpa/v3Xu/55j7UpJSumVxf/gPnz/uN+9+zz2fcz7nnHcAbuAG/s/Aj3Cvq5Md812GJBJ+ShQGDUJMVQ9fHJ5zpL19ju3s7NTQbtI3BAC2trY2wq67FQEfBVHI5bJNEzZXbSE0nB7pdHinybZ77oo69BQqCQKcR9FGIg4MOw9WVg9/qbOzU6YGHJIvXrz4MWOcoyD4M0JarQrOxN01SdMHZt7ZqSvXrasUKylieA8ZVIQuRyOxLkSa6Vs7hKwr1224uzl03NTUZMIzkUi4ixbFX1LFz6nCgpBTRAJEHZ+OiKZPPl3K5CaObUDCiyhUsIG9HVF7337rV6/07NrxnaIUXra+LGCiJWHABw8eDCWfZS0cIOYvEMHDiLoCAYaZOQxudDouM93LbDYbBoACMt8Bc0UkqGKmO8TDwZaOjlry/WEbi1kZt1VWpC4MtrGxMTYycuk1RFiGiGBFjxjmNZ7nLUfCPwBA+aQyfagCmMlk7Nq1HdUAXK0IlYR8SURnciy41ffsU5YiD9sx72ugGgPAzVu3PrrgwoWLLyLCUFlZbK6q9Btjbgus9hJRBah8hgh3TW3AD1KgJL81ekuEyYjKoGOI/QCqADGIoHzLw6CcDc+TwM5ix93Yfzr7fWZnMwD+K5/Px13Hqfe84IBjnBW+r72um5937NjADyb4gg9VIJVKlSRioDkCwExc4VuZJ6hBIFzR09MzShaWqZVHyDh1xULh5tH/XI4BaMYYvk0U3/U8b8XSpXc2BoH/JhK8XyzGNk64D9Lpazlpuh4oXRASKlwAIJcIi0xUSTYoR0RF0iuAeIeo3Hnx/LlZqlrI5bIPBIH/a2ZTBkDdJ0+eTOZy2c2GuVEVP19Xl7g/Ho//aMeOeCmYVAp42gAyiUSpRv54cE5ValRkFqoUALQCkJ5pa9u0ikXeY8ftHb00cmv+ymjedSOb4vHFL+dy2Xtt4O90XHeGtfqV0M+JEyfORaPug0SQCUtL5B4IeTMZsFfrfV3yACDr29trIMBnwjFUgfusDU44xn1O1LYTU1lxvLj27JmBISL9Yii7MeZmsf6zuVxuWyKx5BvWBsuM4d6qqqofDw2d268KO06fzj03HdlUSCqV4n1dXf9kxl8K0ENW8RNo3OYg8KMj7w8+adg8P3z2zGUAuN1xnL8T6gNirSKZr8fjiSey2RPbIxH3MRFZdG74/DEA3BOS19fXOx9lDCkcw3UbOjb5Qt8TKzNt6F2VkTmzatWq2LG+36OIXUjMlZ4X7MnlcntV9atEJXfJ8HH8+PExERkUtX/t7z/13ZC8r6/PnzqGON0IJpPJakvRM2RcJwi80gdMhKoyXFszZ/6BA4cOEXG9tdYzxrgidnt//6lvJhKJVhG5H4COAkiviL5aLBaWDTQ0+JDJyFTy6xRoamoqdaZSZAM7Dvp+Mfz/DpulSGwCJt67f+/eemZebsUWw4CtDQoAsK2urm5NNpvdXVZW9mVVXalK7zDTloGBgQJkwv4rkV+nOE3+0dzcHEaJAvikcSLGcdyY67jhGY24EQOq/cK8ndkgs4kYYxxijjIzA2D30qULF4QyI6qjKj89derU0YklZSfUnbw5SzATBiXycLW2trZGLNIu6xf2gQpIWHtF8TxRf3zskOtEQueHEVQn1VCNcaIADjQ0NMwaGxs7lsudfDZMsLq6umS3pqVluSFnyd7db/8inU7T1TWO18aTpnQaYMqO/58QkoRn19mz3PfCC8GaZNvqiEMNNfPm/nBkZKTkP5FIKG5ov/cJZg4KxWLvb3bvOj7VUbL17pRDkSPd3TsHwvHM5+2CYpGH9+3LhGNYwvr1qZtM1HsICcX39bf7et78y2QfLS2pCuHCz6OsW7q6uvKT7zC58Z6/GeN2+YFfiQqvk+FkYOV5tcEWJopYtXNcNzrX94tvvbu76yfrWzt+B4CHEewfHSey0RsvPj1jRvlY3s93M9Mh6we3IOpOIGpS0TcA8T4C/ZNhp6qoNusipwK1+0F0CRL8mxDpMhv8h6p1FGURAswn1NeQsIIBXkEloypvEGJbqAAR/ZkNnVfkTaq2Ww18u7ycryDgqEEzAIiOKNYaduOA8LiqfhIBFyliM4o+bVVfQtErCPBpAoqTgsbEQg0ihjVrJCLXEB9E1SqL8jgzQoSoj5iHZs+ebRQga61djqoXAOkRANjjeR4q6AwBqQkbm5juEhCDgGcZaVQVV4PqRUB8BwG2EepCABgU0c9+3F67gRuAj4v/Ao0tXEO3E/gTAAAAAElFTkSuQmCC" width="32" height="32"/>
|
||||
<image class="dm" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAG/klEQVR4nO1Xb2xWZxU/5zz33vdPaUf/UFZkdNStfXlpaEKZIxoGTJpR1gnCXoZuydTFOTOXxURJ1Jj6JibMLcZppvHDJvphuOXdiKxdU1oYLUSKncgm86WdGB3O0lVYJKX0fe99zjnmeVNNV+tY/MIHdz7cm5vn3PP7Pb9zznPuRVWFa2l0TdHhQwJw7VPgXWUdAUCP9XctDxnaVPSjhDBmFRKIeKyq9vxwd/d57uzsdJWss95xG+OzZ8/GOAw/q6CfB0JIpdLrZ3z06gpks25N+3teurUQyaOKIgpwQQnXirC1HN1zcaL2S52dnTKXsAM/c+bMA9ZGp4D0Z6KyDhH8mTXnA1cjgNDZqa8ePlxNhjLC8JoyKJFcKhSnu1VlgW/MuDLecvjgyxtc4MHBQaem5vP54M03R55B1M8gQoMLRURWFafnA6L5N58t7eQST9+hou8qScF4pl4Vh+781M5n2+/a8Z0YxfcanxpYpNkRXr9+vZO8whgYEOYviMB9qrhGASaY2ZGb/MA1kE6nHQEkoOUR2MtEXg2z3EiBjvV1dd0kvj9hpqeZEqbGEDU5sidOnEhUVi58ThVa3OFmCIct88YgCFar6G8AoHxWmt5XAcxkMvzKK121AFyLCtWi/A8iXMDT3iI/MI8aKd5nyoKvAWICQLft27e3obq66mlVGJ+aml6CSKPW2hs8g0MiUgVInxDRrpn4ejUF0DkZi5VFFo+QxiIrxvegBlRtUelbgXrlbHkpeaaCo7C9qTH9feZoG4D+NZlMpsIoag0CbyCy0Rrfx6EwTC5taan/wQyefV8FcrlcSSIGqSMAw8JVvqGlpGg94qr29vZJMdCChu4XGzXF4vGF5RXXJQAwZy3fQKiHgyBYc/r0H9Z6nn9ABf4ei023z4S32ex7MQn+i4koKcJFAAlEtMgi1WK8cnUmeBlUbySklVU1iysQsZBKpXd6nv8rZlsGID0rV65sS6XS2yzzWkT93Oho/u6RkZEf7dgxUiKTy4GZl0Amny/lyE947yDSMiSqUKQCAFaByuM9PQc+zkSvcRQOlS+sXJRcUH4lDItbR0bO7E2l0p82nr8/CsOkMfgVF6e5ufmdQiG8RwRyLrUi4YDDzWSAS/meZxw7UnKou3sZePq4a0Mk2G6M90ZkwycJTYewlMUSsdvrltaPi+AXnezW2oVk/CdSqdTufP6NbxjjtVjLQxcuXPjx9dcvPoIILzY2pp6cD2yuSS6XM5s6Os4x6z4CudegLlYbbvA8P165aMkjlu1TtXVLLwFAfRRFfxbFnWSMqtivj4zkH06nm/cUi+EDRNS4uLbmdQA96MBPnjzpf5A2JNeGhw92bfVJvkeGFhgyjIhGmXPHjx9PtLSuRSJzszBXB4F3MJVK9SPiV0VKp3Kbu6xatWqKiMYIzdmmphXfdeCtra3R3DbEOSkotWB/f3+tkcLbYkPf84KSA4sgIk2cPXd++YYNtx0V4VZjTGitDYjMnqamFd/M5/ObiehuADkFQENE+MtYLN5SPzwcQSbj2M2b73/b4OBgqTJRindwFKHvx9z57YqlWJo/wv0b29pamXm1IVN0rsZ4BQDYPTo6ujGdTvdOTU19GRFvQZQ+ZtlVX19fgEwGZsD/Q3Ga/TAwMOBYIoE+YqOiF0VhIoxCd48Xw6IHiKPEvIfZIrONWWt9YY4zswHQntOn/9jgZFZFH5F+umLFilMzQ4pn1J09OUvmzTiUwN1o7e3tjRmVLuPHD5Wmgcs9qgQBqZ8oOxpGRRf8mAKW5JzRVK2N4gARDA8PV5SVlb2eSq18wm1wYmKi5HKkr2+1lai5bfOdv8hms/SvMY7vrYEsZbMAc2b8/2QOxN076upM64MP2iP9PeuKkXzs3NtjP6ysrCzFz+fzir1d+x9mZhuPxYZu33zX7+cG6u99ORNJcXjLlu1vufZMJk1DLMYTmzZlXBuW7NCh3HW2ENyrouL7+OtN7VtPz47R15erIo7/vMC4q6Oj48rsNezrOfAna8Nu3/OrFeF5sdzmGXoKjbeLRWIGTV0YFpb4fuylT27u+Mmh3q7j7itNwbwaRcX2IBF77MqVyamkn+xhlqPG9ypVcT+IrEfCF0B1uwD+1nJUE0OTD5UzHpojQNisAn8jVbnEVv+CaHxUalSA5aL4nIpWMcCziuIh0guiusUpICK/YysXUHkroulBC9+enOTLCjpp1b4Fqj6h3mQ5TIHCQ4j4EQVtRNUNSviYQXxGCS8rwCoBSRECJsjAMlV1OVsrIoEVHlTEGqP0ELNCUeSkMI+Pj497CJA3xqxWxIugcj8AHAyCABEwSUDLXGELy60E5CnoeVaZRNR1gPguqPYpwG5RvBkAxojwtvlmwf/XfwF9SACusf0TYbmv17ywe0EAAAAASUVORK5CYII=" width="32" height="32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 1020 KiB |
@@ -0,0 +1,5 @@
|
||||
import { PracticeView } from "./views/PracticeView";
|
||||
|
||||
export default function App() {
|
||||
return <PracticeView />;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { WebMidi } from "webmidi";
|
||||
import { syncEngine } from "../../engine/SyncEngine";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
|
||||
export function DevicePanel() {
|
||||
const [inputs, setInputs] = useState<string[]>([]);
|
||||
const [midiStatus, setMidiStatus] = useState<"idle" | "enabled" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const chipRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const { midiDeviceName, setMidiDevice } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
WebMidi.enable({ sysex: false })
|
||||
.then(() => {
|
||||
setMidiStatus("enabled");
|
||||
setInputs(WebMidi.inputs.map((i) => i.name));
|
||||
WebMidi.addListener("connected", () => setInputs(WebMidi.inputs.map((i) => i.name)));
|
||||
WebMidi.addListener("disconnected", (e) => {
|
||||
setInputs(WebMidi.inputs.map((i) => i.name));
|
||||
if (e.port.name === midiDeviceName) setMidiDevice(null);
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setErrorMsg(
|
||||
!navigator.requestMIDIAccess
|
||||
? "Web MIDI not supported. Use Chrome or Edge."
|
||||
: `MIDI access denied. Check browser permissions. (${msg})`
|
||||
);
|
||||
setMidiStatus("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
try { WebMidi.removeListener("connected"); } catch {}
|
||||
try { WebMidi.removeListener("disconnected"); } catch {}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Close popover on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
!chipRef.current?.contains(e.target as Node) &&
|
||||
!popoverRef.current?.contains(e.target as Node)
|
||||
) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const connectDevice = (name: string) => {
|
||||
if (midiDeviceName) {
|
||||
try { WebMidi.getInputByName(midiDeviceName)?.removeListener(); } catch {}
|
||||
}
|
||||
const input = WebMidi.getInputByName(name);
|
||||
if (!input) return;
|
||||
input.addListener("noteon", (e) => syncEngine.onMidiInput(e.note.number, e.rawValue ?? 64));
|
||||
input.addListener("noteoff", (e) => syncEngine.onMidiInputOff(e.note.number));
|
||||
setMidiDevice(name);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (midiDeviceName) {
|
||||
try { WebMidi.getInputByName(midiDeviceName)?.removeListener(); } catch {}
|
||||
}
|
||||
setMidiDevice(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// Error state
|
||||
if (midiStatus === "error") {
|
||||
return (
|
||||
<div
|
||||
className="text-xs px-2 py-1 rounded cursor-help shrink-0"
|
||||
title={errorMsg ?? "MIDI error"}
|
||||
style={{ color: "var(--color-warning)", background: "var(--color-warning-subtle)", border: "1px solid var(--color-warning-border)" }}
|
||||
>
|
||||
⚠ MIDI
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not yet initialized
|
||||
if (midiStatus === "idle") return null;
|
||||
|
||||
const connected = !!midiDeviceName;
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
{/* Chip button */}
|
||||
<button
|
||||
ref={chipRef}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title={connected ? `Connected: ${midiDeviceName}` : "Select MIDI input device"}
|
||||
className="flex items-center gap-2 text-xs font-semibold transition-colors shrink-0"
|
||||
style={{
|
||||
background: connected ? "rgba(34,197,94,0.08)" : "var(--color-warning-subtle)",
|
||||
border: connected ? "1px solid rgba(34,197,94,0.5)" : "1px solid var(--color-warning-border)",
|
||||
color: connected ? "#4ade80" : "var(--color-warning)",
|
||||
cursor: "pointer",
|
||||
maxWidth: 200,
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
padding: connected ? "4px 10px" : "5px 12px",
|
||||
}}
|
||||
>
|
||||
{connected ? (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ background: "#4ade80", boxShadow: "0 0 6px #4ade80" }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, lineHeight: 1, flexShrink: 0 }}>⚠</span>
|
||||
)}
|
||||
<span
|
||||
className="truncate"
|
||||
style={{ maxWidth: 130 }}
|
||||
>
|
||||
{connected ? midiDeviceName : "No MIDI device"}
|
||||
</span>
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor" style={{ opacity: 0.5, flexShrink: 0 }}>
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Popover */}
|
||||
{open && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 6px)",
|
||||
right: 0,
|
||||
minWidth: 200,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||
zIndex: 100,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wide"
|
||||
style={{ color: "var(--color-text-muted)", borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
MIDI Input
|
||||
</div>
|
||||
|
||||
{inputs.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
No MIDI devices found.
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: "4px 0" }}>
|
||||
{inputs.map((name) => {
|
||||
const isActive = name === midiDeviceName;
|
||||
return (
|
||||
<li key={name}>
|
||||
<button
|
||||
onClick={() => connectDevice(name)}
|
||||
className="w-full text-left px-3 py-2 text-xs transition-colors flex items-center gap-2"
|
||||
style={{
|
||||
background: isActive ? "rgba(99,102,241,0.12)" : "transparent",
|
||||
color: isActive ? "var(--color-accent)" : "var(--color-text)",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ background: isActive ? "#4ade80" : "var(--color-border)" }}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
{isActive && (
|
||||
<span className="ml-auto text-xs" style={{ color: "#4ade80" }}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{connected && (
|
||||
<div style={{ borderTop: "1px solid var(--color-border)", padding: "4px 0" }}>
|
||||
<button
|
||||
onClick={disconnect}
|
||||
className="w-full text-left px-3 py-2 text-xs transition-colors"
|
||||
style={{ background: "transparent", color: "#f87171", border: "none", cursor: "pointer" }}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
import * as PIXI from "pixi.js";
|
||||
import { isBlackKey, midiToNoteName, midiToPitch } from "@bach-to-basics/shared";
|
||||
import type { NoteEvent } from "@bach-to-basics/shared";
|
||||
import type { NoteFilter, ColorTheme, CustomColors, NoteLabelMode, ImpactStyle } from "../../store/useAppStore";
|
||||
import type { SustainRange } from "@bach-to-basics/shared";
|
||||
import { lighten, hexToNum } from "../../utils/colorUtils";
|
||||
|
||||
// ── Layout constants ──────────────────────────────────────────────────────────
|
||||
const MIDI_MIN = 21;
|
||||
const MIDI_MAX = 108;
|
||||
const POOL_SIZE = 1200;
|
||||
|
||||
function isWhite(midi: number) { return !isBlackKey(midi); }
|
||||
|
||||
const whiteKeyIndex: number[] = new Array(MIDI_MAX + 1).fill(0);
|
||||
let wi = 0;
|
||||
for (let m = MIDI_MIN; m <= MIDI_MAX; m++) {
|
||||
whiteKeyIndex[m] = wi;
|
||||
if (isWhite(m)) wi++;
|
||||
}
|
||||
const TOTAL_WHITE_KEYS = wi;
|
||||
|
||||
// ── Theme palette ─────────────────────────────────────────────────────────────
|
||||
// Each theme has: left hand, right hand, unknown, and black-key variant (slightly lighter)
|
||||
interface ThemePalette {
|
||||
left: number;
|
||||
right: number;
|
||||
unknown: number;
|
||||
leftBlack: number;
|
||||
rightBlack: number;
|
||||
unknownBlack: number;
|
||||
}
|
||||
|
||||
const THEMES: Record<Exclude<ColorTheme, "custom">, ThemePalette> = {
|
||||
violet: {
|
||||
left: 0x7c3aed, right: 0x8b5cf6, unknown: 0x8b5cf6,
|
||||
leftBlack: 0xa78bfa, rightBlack: 0xc4b5fd, unknownBlack: 0xc4b5fd,
|
||||
},
|
||||
classic: {
|
||||
left: 0x2563eb, right: 0xdc2626, unknown: 0x7c3aed,
|
||||
leftBlack: 0x60a5fa, rightBlack: 0xf87171, unknownBlack: 0xa78bfa,
|
||||
},
|
||||
ocean: {
|
||||
left: 0x0284c7, right: 0x06b6d4, unknown: 0x0ea5e9,
|
||||
leftBlack: 0x38bdf8, rightBlack: 0x67e8f9, unknownBlack: 0x7dd3fc,
|
||||
},
|
||||
forest: {
|
||||
left: 0x15803d, right: 0x16a34a, unknown: 0x22c55e,
|
||||
leftBlack: 0x4ade80, rightBlack: 0x86efac, unknownBlack: 0xbbf7d0,
|
||||
},
|
||||
cascade: {
|
||||
left: 0x9333ea, right: 0x22d3ee, unknown: 0xa855f7,
|
||||
leftBlack: 0xb06af5, rightBlack: 0x4dd5ec, unknownBlack: 0xb87af8,
|
||||
},
|
||||
};
|
||||
|
||||
/** Build a ThemePalette from three base hex colors (black-key variants auto-lightened). */
|
||||
function paletteFromCustom(c: CustomColors): ThemePalette {
|
||||
const l = hexToNum(c.leftHand);
|
||||
const r = hexToNum(c.rightHand);
|
||||
const u = hexToNum(c.unknown);
|
||||
return {
|
||||
left: l,
|
||||
right: r,
|
||||
unknown: u,
|
||||
leftBlack: lighten(l, 1.55),
|
||||
rightBlack: lighten(r, 1.55),
|
||||
unknownBlack: lighten(u, 1.55),
|
||||
};
|
||||
}
|
||||
|
||||
function noteColor(
|
||||
note: NoteEvent,
|
||||
showHandColors: boolean,
|
||||
theme: ColorTheme,
|
||||
customColors?: CustomColors,
|
||||
): number {
|
||||
const pal = theme === "custom" && customColors
|
||||
? paletteFromCustom(customColors)
|
||||
: THEMES[theme as Exclude<ColorTheme, "custom">];
|
||||
const black = isBlackKey(note.midi);
|
||||
if (!showHandColors || note.hand === "unknown") {
|
||||
return black ? pal.unknownBlack : pal.unknown;
|
||||
}
|
||||
if (note.hand === "left") return black ? pal.leftBlack : pal.left;
|
||||
if (note.hand === "right") return black ? pal.rightBlack : pal.right;
|
||||
return black ? pal.unknownBlack : pal.unknown;
|
||||
}
|
||||
|
||||
// ── Gradient helpers ──────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Linear-interpolate each RGB channel from c1 toward c2 by factor t (0=c1, 1=c2).
|
||||
* Unlike multiplicative lighten(), this NEVER blows light colours to pure white.
|
||||
*/
|
||||
function mixColor(c1: number, c2: number, t: number): number {
|
||||
const r1 = (c1 >> 16) & 0xff, r2 = (c2 >> 16) & 0xff;
|
||||
const g1 = (c1 >> 8) & 0xff, g2 = (c2 >> 8) & 0xff;
|
||||
const b1 = c1 & 0xff, b2 = c2 & 0xff;
|
||||
return (
|
||||
(Math.round(r1 + (r2 - r1) * t) << 16) |
|
||||
(Math.round(g1 + (g2 - g1) * t) << 8) |
|
||||
Math.round(b1 + (b2 - b1) * t)
|
||||
);
|
||||
}
|
||||
|
||||
function makeGradient(base: number): PIXI.FillGradient {
|
||||
return new PIXI.FillGradient({
|
||||
type: "linear",
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 0, y: 1 },
|
||||
textureSpace: "local",
|
||||
colorStops: [
|
||||
{ offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white
|
||||
{ offset: 0.22, color: mixColor(base, 0xffffff, 0.22) }, // slightly bright
|
||||
{ offset: 0.62, color: base }, // solid base
|
||||
{ offset: 1, color: mixColor(base, 0x000000, 0.30) }, // deeper bottom
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ── ImpactFlash ───────────────────────────────────────────────────────────────
|
||||
// Soft bloom effect: two expanding filled halos + a brief central sparkle.
|
||||
// No hard strokes, no particles - purely diffuse light that dissolves gently.
|
||||
class ImpactFlash {
|
||||
container: PIXI.Container;
|
||||
private outerBloom: PIXI.Graphics; // large, very soft, low-opacity halo
|
||||
private innerBloom: PIXI.Graphics; // smaller, slightly more visible glow
|
||||
private sparkle: PIXI.Graphics; // tiny bright flash at moment of contact
|
||||
active = false;
|
||||
private t = 0;
|
||||
private noteW = 0;
|
||||
private baseColor = 0xffffff;
|
||||
|
||||
constructor() {
|
||||
this.container = new PIXI.Container();
|
||||
this.outerBloom = new PIXI.Graphics();
|
||||
this.innerBloom = new PIXI.Graphics();
|
||||
this.sparkle = new PIXI.Graphics();
|
||||
// Render back-to-front: outer halo, inner glow, sparkle on top
|
||||
this.container.addChild(this.outerBloom, this.innerBloom, this.sparkle);
|
||||
this.container.visible = false;
|
||||
}
|
||||
|
||||
trigger(cx: number, cy: number, w: number, color: number) {
|
||||
this.noteW = w;
|
||||
this.baseColor = color;
|
||||
this.t = 0;
|
||||
this.active = true;
|
||||
this.container.visible = true;
|
||||
this.container.x = cx;
|
||||
this.container.y = cy;
|
||||
this._draw();
|
||||
}
|
||||
|
||||
/** dtFrames: PIXI ticker.deltaTime (1.0 = one 60fps frame). Frame-rate independent. */
|
||||
advance(dtFrames: number): boolean {
|
||||
this.t += dtFrames * 0.033; // ~500ms total at 60fps
|
||||
if (this.t >= 1) { this.reset(); return true; }
|
||||
this._draw();
|
||||
return false;
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const { t, noteW, baseColor } = this;
|
||||
// Cubic ease-out: rapid initial expansion that smoothly decelerates
|
||||
const et = 1 - (1 - t) * (1 - t) * (1 - t);
|
||||
|
||||
// ── Inner bloom - note-colored soft glow ─────────────────────────────────
|
||||
const r1 = noteW * 0.4 + et * noteW * 1.5;
|
||||
this.innerBloom.clear();
|
||||
this.innerBloom.circle(0, 0, r1);
|
||||
this.innerBloom.fill({ color: baseColor, alpha: (1 - t) * 0.28 });
|
||||
|
||||
// ── Outer bloom - large diffuse halo, 80ms delayed start ─────────────────
|
||||
const t2 = Math.max(0, t - 0.10);
|
||||
const et2 = 1 - (1 - t2) * (1 - t2) * (1 - t2);
|
||||
const r2 = noteW * 0.4 + et2 * noteW * 3.0;
|
||||
this.outerBloom.clear();
|
||||
this.outerBloom.circle(0, 0, r2);
|
||||
this.outerBloom.fill({ color: baseColor, alpha: (1 - t2) * 0.10 });
|
||||
|
||||
// ── Sparkle - bright note-colored flash that exists only for the first ~100ms ──
|
||||
// Using baseColor (the note's own color) instead of white so it's visible in
|
||||
// both dark and light mode (white would be invisible on the light #EEF1FA bg).
|
||||
const sa = Math.max(0, 1 - t * 5.0);
|
||||
this.sparkle.clear();
|
||||
if (sa > 0) {
|
||||
const sr = noteW * 0.35 * (1 - et);
|
||||
if (sr > 0.5) {
|
||||
this.sparkle.circle(0, 0, sr);
|
||||
this.sparkle.fill({ color: baseColor, alpha: sa * 0.80 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.active = false;
|
||||
this.container.visible = false;
|
||||
this.outerBloom.clear();
|
||||
this.innerBloom.clear();
|
||||
this.sparkle.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ── NoteBar ───────────────────────────────────────────────────────────────────
|
||||
class NoteBar {
|
||||
container: PIXI.Container;
|
||||
private bg: PIXI.Graphics;
|
||||
private label: PIXI.Text;
|
||||
/** Whether this bar's label should be shown at all (text non-empty AND bar big enough). */
|
||||
private labelWanted = false;
|
||||
note: NoteEvent | null = null;
|
||||
active = false;
|
||||
impactFired = false;
|
||||
baseColor = 0;
|
||||
|
||||
constructor() {
|
||||
this.container = new PIXI.Container();
|
||||
this.bg = new PIXI.Graphics();
|
||||
this.label = new PIXI.Text({
|
||||
text: "",
|
||||
style: {
|
||||
fontSize: 10,
|
||||
fill: 0xffffff,
|
||||
fontFamily: "Space Grotesk, system-ui, sans-serif",
|
||||
fontWeight: "700",
|
||||
dropShadow: { color: 0x000000, blur: 4, distance: 0, alpha: 0.85 },
|
||||
},
|
||||
});
|
||||
this.label.anchor.set(0.5, 0.5);
|
||||
this.container.addChild(this.bg, this.label);
|
||||
this.container.visible = false;
|
||||
}
|
||||
|
||||
configure(
|
||||
note: NoteEvent,
|
||||
x: number, barWidth: number,
|
||||
startY: number, barHeight: number,
|
||||
gradient: PIXI.FillGradient,
|
||||
baseColor: number,
|
||||
labelText: string,
|
||||
cornerRadius: number,
|
||||
showOutline: boolean,
|
||||
) {
|
||||
this.note = note;
|
||||
this.active = true;
|
||||
this.impactFired = false;
|
||||
this.baseColor = baseColor;
|
||||
this.container.visible = true;
|
||||
this.container.x = x;
|
||||
this.container.y = startY;
|
||||
|
||||
const r = Math.min(cornerRadius, barWidth * 0.25);
|
||||
this.bg.clear();
|
||||
|
||||
// Gradient fill (mixColor-based stops, always colored, never pure white)
|
||||
this.bg.roundRect(0, 0, barWidth, barHeight, r);
|
||||
this.bg.fill(gradient);
|
||||
|
||||
// Subtle white highlight strip at the very top
|
||||
if (barHeight > 10) {
|
||||
const hlH = Math.min(barHeight * 0.18, 7);
|
||||
this.bg.roundRect(1, 1, barWidth - 2, hlH, r * 0.6);
|
||||
this.bg.fill({ color: 0xffffff, alpha: 0.2 });
|
||||
}
|
||||
|
||||
// Border - subtle white tint by default; colored outline when enabled
|
||||
this.bg.roundRect(0, 0, barWidth, barHeight, r);
|
||||
if (showOutline) {
|
||||
this.bg.stroke({ width: 1.5, color: baseColor, alpha: 0.55 });
|
||||
} else {
|
||||
this.bg.stroke({ width: 1, color: 0xffffff, alpha: 0.15 });
|
||||
}
|
||||
|
||||
// Label - position updated each tick for tall notes via updateLabelY()
|
||||
// Scale font to fit the bar: tiny bars (≤ 8 px tall or black-key narrow)
|
||||
// use size 6-7 so the text doesn't dwarf the note; normal bars use 10.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.label.style as any).fontSize = barHeight <= 8 ? 6 : barWidth <= 11 ? 7 : 10;
|
||||
this.label.text = labelText;
|
||||
this.label.x = barWidth / 2;
|
||||
this.label.y = barHeight / 2;
|
||||
// Show labels whenever the bar is tall enough to hold the text (≥ 6 px)
|
||||
// and wide enough to be legible (≥ 7 px). The 6 px threshold means even
|
||||
// the minimum-height (minNoteHeight=8) bars get a label; at tiny sizes the
|
||||
// font is already scaled down to 6-7 px so overflow is imperceptible.
|
||||
this.labelWanted = labelText.length > 0 && barHeight > 6 && barWidth > 7;
|
||||
this.label.visible = this.labelWanted;
|
||||
}
|
||||
|
||||
/** Keep label clamped within the visible portion of the bar (for very tall notes). */
|
||||
updateLabelY(containerY: number, barHeight: number, viewportHeight: number) {
|
||||
if (!this.labelWanted) { this.label.visible = false; return; }
|
||||
// Visible slice of the bar in local coords
|
||||
const localTop = Math.max(0, -containerY);
|
||||
const localBottom = Math.min(barHeight, viewportHeight - containerY);
|
||||
if (localBottom <= localTop) { this.label.visible = false; return; }
|
||||
this.label.visible = true;
|
||||
const midLocal = (localTop + localBottom) / 2;
|
||||
// Only reposition if the natural center is outside the visible slice
|
||||
const natural = barHeight / 2;
|
||||
this.label.y = (natural >= localTop && natural <= localBottom) ? natural : midLocal;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.note = null;
|
||||
this.active = false;
|
||||
this.impactFired = false;
|
||||
this.baseColor = 0;
|
||||
this.labelWanted = false;
|
||||
this.label.visible = false;
|
||||
this.container.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Options & renderer ────────────────────────────────────────────────────────
|
||||
export interface FallingNotesOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
viewportSeconds: number;
|
||||
fallingNotesLabelMode?: NoteLabelMode;
|
||||
showFingering: boolean;
|
||||
showHandColors: boolean;
|
||||
useFlats: boolean;
|
||||
noteFilter: NoteFilter;
|
||||
colorTheme: ColorTheme;
|
||||
customColors?: CustomColors;
|
||||
showGrid?: boolean;
|
||||
showMeasureNums?: boolean;
|
||||
measureStarts?: number[];
|
||||
/** Which visual effect plays when a note reaches the hit line */
|
||||
impactStyle?: ImpactStyle;
|
||||
/** Minimum pixel height for note bars - prevents staccato slivers */
|
||||
minNoteHeight?: number; // default 8
|
||||
/** Border radius for note bars (0 = sharp, 12 = pill) */
|
||||
noteCornerRadius?: number; // default 4
|
||||
/** Draw horizontal beat/measure lines across the canvas */
|
||||
showBeatLines?: boolean;
|
||||
/** Pre-computed beat start times in seconds */
|
||||
beatStarts?: number[];
|
||||
/** Draw sustain pedal (CC64) regions as semi-transparent bands */
|
||||
showSustainPedal?: boolean;
|
||||
/** Sustain pedal on/off ranges in seconds */
|
||||
sustainRanges?: SustainRange[];
|
||||
/** Draw a colored outline stroke around note bars */
|
||||
showNoteOutline?: boolean;
|
||||
/** Show ghost indicators for notes that have ended while sustain pedal is held */
|
||||
showSustainedNotes?: boolean;
|
||||
/** True when the app is in dark mode - used to choose hit-line and beat-line colors */
|
||||
darkMode?: boolean;
|
||||
}
|
||||
|
||||
export class FallingNotesRenderer {
|
||||
app: PIXI.Application;
|
||||
private _initialized = false;
|
||||
private opts: FallingNotesOptions;
|
||||
private pool: NoteBar[] = [];
|
||||
private active = new Map<string, NoteBar>();
|
||||
private hitLine: PIXI.Graphics;
|
||||
private ghostBar: PIXI.Graphics;
|
||||
|
||||
private whiteW = 0;
|
||||
private blackW = 0;
|
||||
|
||||
// Gradient cache: base color to FillGradient (safe to share; textureSpace:"local" adapts per shape)
|
||||
private gradientCache = new Map<number, PIXI.FillGradient>();
|
||||
|
||||
private flashes: ImpactFlash[] = [];
|
||||
private flashLayer!: PIXI.Container;
|
||||
private measureLayer!: PIXI.Container;
|
||||
private particleLayer!: PIXI.Graphics;
|
||||
private particles: Array<{
|
||||
x: number; y: number; vx: number; vy: number;
|
||||
size: number; life: number; decay: number;
|
||||
gravity: number; color: number;
|
||||
}> = [];
|
||||
private gridLayer!: PIXI.Graphics;
|
||||
private beatLayer!: PIXI.Graphics;
|
||||
private sustainLineLayer!: PIXI.Graphics; // thin connecting line
|
||||
private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool
|
||||
/** Ghost indicators for notes that ended while sustain was held */
|
||||
private sustainedNoteLayer!: PIXI.Graphics;
|
||||
private sustainedNotes = new Map<string, { midi: number; baseColor: number; endSeconds: number }>();
|
||||
|
||||
constructor(opts: FallingNotesOptions) {
|
||||
this.opts = opts;
|
||||
this.app = new PIXI.Application();
|
||||
this.hitLine = new PIXI.Graphics();
|
||||
this.ghostBar = new PIXI.Graphics();
|
||||
this.ghostBar.visible = false;
|
||||
}
|
||||
|
||||
async init(canvas: HTMLCanvasElement) {
|
||||
await this.app.init({
|
||||
canvas,
|
||||
width: this.opts.width,
|
||||
height: this.opts.height,
|
||||
backgroundColor: 0x050509,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
autoDensity: true,
|
||||
// Keep framebuffer alive for screenshot tools (Playwright) in dev mode
|
||||
...(import.meta.env.DEV ? { preserveDrawingBuffer: true } : {}),
|
||||
});
|
||||
|
||||
this.computeLayout();
|
||||
|
||||
const noteLayer = new PIXI.Container();
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const bar = new NoteBar();
|
||||
this.pool.push(bar);
|
||||
noteLayer.addChild(bar.container);
|
||||
}
|
||||
|
||||
this.drawHitLine();
|
||||
this.gridLayer = this.buildGrid();
|
||||
this.gridLayer.visible = this.opts.showGrid ?? false;
|
||||
|
||||
this.sustainLineLayer = new PIXI.Graphics();
|
||||
this.sustainTextLayer = new PIXI.Container();
|
||||
// Pool of 80 text objects (up to 40 Ped + 40 ✱ markers visible at once)
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const t = new PIXI.Text({
|
||||
text: "",
|
||||
style: {
|
||||
fontSize: 11,
|
||||
fill: 0xa855f7,
|
||||
fontFamily: "Space Grotesk, system-ui, sans-serif",
|
||||
fontStyle: "italic",
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
t.x = 10;
|
||||
t.visible = false;
|
||||
this.sustainTextLayer.addChild(t);
|
||||
}
|
||||
|
||||
this.beatLayer = new PIXI.Graphics();
|
||||
this.sustainedNoteLayer = new PIXI.Graphics();
|
||||
|
||||
this.particleLayer = new PIXI.Graphics();
|
||||
|
||||
this.flashLayer = new PIXI.Container();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const f = new ImpactFlash();
|
||||
this.flashes.push(f);
|
||||
this.flashLayer.addChild(f.container);
|
||||
}
|
||||
|
||||
this.measureLayer = new PIXI.Container();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const t = new PIXI.Text({
|
||||
text: "",
|
||||
style: { fontSize: 11, fill: 0x8896b8, fontFamily: "Space Grotesk, system-ui, sans-serif", fontWeight: "600" },
|
||||
});
|
||||
t.x = 3;
|
||||
t.visible = false;
|
||||
this.measureLayer.addChild(t);
|
||||
}
|
||||
|
||||
this.app.stage.addChild(
|
||||
this.gridLayer, this.sustainLineLayer, this.sustainTextLayer,
|
||||
this.beatLayer, noteLayer, this.particleLayer,
|
||||
this.sustainedNoteLayer,
|
||||
this.measureLayer, this.flashLayer, this.hitLine, this.ghostBar,
|
||||
);
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
private computeLayout() {
|
||||
this.whiteW = this.opts.width / TOTAL_WHITE_KEYS;
|
||||
this.blackW = this.whiteW * 0.6;
|
||||
}
|
||||
|
||||
private drawHitLine() {
|
||||
// Hit line intentionally left invisible - the CSS border between the notes
|
||||
// canvas and the piano keyboard already provides a clean visual separator.
|
||||
this.hitLine.clear();
|
||||
}
|
||||
|
||||
private buildGrid(): PIXI.Graphics {
|
||||
const g = new PIXI.Graphics();
|
||||
|
||||
// Faint vertical separator at every C (octave markers)
|
||||
// Neutral gray at low opacity - visible on both dark and light backgrounds
|
||||
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
|
||||
if (midi % 12 === 0 && isWhite(midi)) {
|
||||
const x = whiteKeyIndex[midi] * this.whiteW;
|
||||
g.rect(x, 0, 1, this.opts.height);
|
||||
g.fill({ color: 0x808090, alpha: 0.2 });
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
private noteX(midi: number): number {
|
||||
if (isWhite(midi)) return whiteKeyIndex[midi] * this.whiteW;
|
||||
return whiteKeyIndex[midi - 1] * this.whiteW + this.whiteW - this.blackW / 2;
|
||||
}
|
||||
|
||||
private getGradient(base: number): PIXI.FillGradient {
|
||||
let g = this.gradientCache.get(base);
|
||||
if (!g) { g = makeGradient(base); this.gradientCache.set(base, g); }
|
||||
return g;
|
||||
}
|
||||
|
||||
private noteLabel(note: NoteEvent): string {
|
||||
const parts: string[] = [];
|
||||
const labelText = this.pitchLabelFor(note.midi);
|
||||
if (labelText) parts.push(labelText);
|
||||
if (this.opts.showFingering && note.finger) parts.push(String(note.finger));
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
private pitchLabelFor(midi: number): string {
|
||||
const mode = this.opts.fallingNotesLabelMode ?? "none";
|
||||
if (mode === "none") return "";
|
||||
const white = isWhite(midi);
|
||||
if (mode === "c_only") {
|
||||
return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : "";
|
||||
}
|
||||
if (mode === "white" && !white) return "";
|
||||
if (mode === "black" && white) return "";
|
||||
// "white", "black", "all": note name class only (no octave)
|
||||
return midiToNoteName(midi, this.opts.useFlats);
|
||||
}
|
||||
|
||||
// Returns false if the note should be hidden by the current filter
|
||||
private passesFilter(note: NoteEvent): boolean {
|
||||
switch (this.opts.noteFilter) {
|
||||
case "white": return isWhite(note.midi);
|
||||
case "black": return isBlackKey(note.midi);
|
||||
case "c_only": return note.midi % 12 === 0;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Burst of dots shooting sideways from the note's left & right edges at impact. */
|
||||
private spawnBurst(cx: number, cy: number, noteW: number, color: number) {
|
||||
const count = 9 + Math.floor(Math.random() * 6);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const side = Math.random() < 0.5 ? -1 : 1;
|
||||
this.particles.push({
|
||||
x: cx + side * (noteW * 0.5 + 1 + Math.random() * 3),
|
||||
y: cy,
|
||||
vx: side * (1.8 + Math.random() * 3.8),
|
||||
vy: -(0.5 + Math.random() * 2.8),
|
||||
size: 1.4 + Math.random() * 2.4,
|
||||
life: 1.0,
|
||||
decay: 0.024 + Math.random() * 0.018,
|
||||
gravity: 0.07,
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Single sparkle drifting off the side of a falling bar (called each tick). */
|
||||
private spawnTrail(cx: number, cy: number, noteW: number, color: number) {
|
||||
if (Math.random() > 0.38) return;
|
||||
const side = Math.random() < 0.5 ? -1 : 1;
|
||||
this.particles.push({
|
||||
x: cx + side * (noteW * 0.5),
|
||||
y: cy - Math.random() * 22,
|
||||
vx: side * (0.5 + Math.random() * 1.4),
|
||||
vy: -(0.2 + Math.random() * 1.0),
|
||||
size: 1.0 + Math.random() * 1.6,
|
||||
life: 1.0,
|
||||
decay: 0.04 + Math.random() * 0.03,
|
||||
gravity: 0.025,
|
||||
color,
|
||||
});
|
||||
}
|
||||
|
||||
private triggerFlash(cx: number, cy: number, w: number, color: number) {
|
||||
const f = this.flashes.find(f => !f.active);
|
||||
if (f) f.trigger(cx, cy, w, color);
|
||||
}
|
||||
|
||||
/** Returns true if the sustain pedal is active at `currentSeconds`. */
|
||||
private isSustainActive(currentSeconds: number): boolean {
|
||||
const ranges = this.opts.sustainRanges;
|
||||
if (!ranges) return false;
|
||||
for (const r of ranges) {
|
||||
if (r.startSeconds > currentSeconds) break; // ranges are sorted
|
||||
if (currentSeconds <= r.endSeconds) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
addNote(note: NoteEvent, currentSeconds: number) {
|
||||
if (!this._initialized) return;
|
||||
if (this.active.has(note.id)) return;
|
||||
if (!this.passesFilter(note)) return;
|
||||
|
||||
// Skip notes whose bottom edge has already scrolled 60 px below the hit line —
|
||||
// they would be recycled on the very next tick anyway, so don't waste a pool slot.
|
||||
// Threshold mirrors the recycle condition in tick(): bar.container.y > hitY + 60.
|
||||
// barBottomPastHit = (currentSeconds - note.startSeconds) * pxPerSec - barHeight
|
||||
// recycle if barBottomPastHit > 60
|
||||
{
|
||||
const pxPerSec = this.opts.height / this.opts.viewportSeconds;
|
||||
const minH = this.opts.minNoteHeight ?? 8;
|
||||
const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec);
|
||||
const timePastHit = currentSeconds - note.startSeconds; // positive = note already started
|
||||
if (timePastHit * pxPerSec - barHeight > 60) return;
|
||||
}
|
||||
|
||||
const bar = this.pool.pop();
|
||||
if (!bar) return; // pool exhausted (> POOL_SIZE notes visible at once)
|
||||
|
||||
const { viewportSeconds, height } = this.opts;
|
||||
const white = isWhite(note.midi);
|
||||
const barWidth = Math.max(5, (white ? this.whiteW : this.blackW) - 2);
|
||||
const x = this.noteX(note.midi) + 1;
|
||||
|
||||
const pxPerSec = height / viewportSeconds;
|
||||
const minH = this.opts.minNoteHeight ?? 8;
|
||||
const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec);
|
||||
const timeUntilHit = note.startSeconds - currentSeconds;
|
||||
const startY = (height - 4) - barHeight - timeUntilHit * pxPerSec;
|
||||
|
||||
const base = noteColor(note, this.opts.showHandColors, this.opts.colorTheme, this.opts.customColors);
|
||||
bar.configure(note, x, barWidth, startY, barHeight, this.getGradient(base), base, this.noteLabel(note), this.opts.noteCornerRadius ?? 4, this.opts.showNoteOutline ?? false);
|
||||
this.active.set(note.id, bar);
|
||||
}
|
||||
|
||||
tick(currentSeconds: number) {
|
||||
if (!this._initialized) return;
|
||||
const { height, viewportSeconds } = this.opts;
|
||||
const pxPerSec = height / viewportSeconds;
|
||||
const hitY = height - 4;
|
||||
|
||||
const minH = this.opts.minNoteHeight ?? 8;
|
||||
const cornerRadius = this.opts.noteCornerRadius ?? 4;
|
||||
const impactStyle = this.opts.impactStyle ?? "bloom";
|
||||
const doSustained = this.opts.showSustainedNotes ?? false;
|
||||
const sustainActive = doSustained && this.isSustainActive(currentSeconds);
|
||||
|
||||
for (const [id, bar] of this.active) {
|
||||
if (!bar.note) continue;
|
||||
const barHeight = Math.max(minH, (bar.note.endSeconds - bar.note.startSeconds) * pxPerSec);
|
||||
const timeUntilHit = bar.note.startSeconds - currentSeconds;
|
||||
bar.container.y = hitY - barHeight - timeUntilHit * pxPerSec;
|
||||
bar.updateLabelY(bar.container.y, barHeight, height);
|
||||
|
||||
// ── Impact effect ───────────────────────────────────────────────────────
|
||||
if (!bar.impactFired && impactStyle !== "off") {
|
||||
if (bar.container.y + barHeight >= hitY - 2) {
|
||||
bar.impactFired = true;
|
||||
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
|
||||
const cx = bar.container.x + bw * 0.5;
|
||||
if (impactStyle === "bloom")
|
||||
this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor);
|
||||
else if (impactStyle === "side")
|
||||
this.spawnBurst(cx, hitY - 3, bw, bar.baseColor);
|
||||
// "trail" is handled continuously below
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trail particles ─────────────────────────────────────────────────────
|
||||
if (impactStyle === "trail") {
|
||||
const barTopY = bar.container.y;
|
||||
if (barTopY + barHeight < hitY && barTopY < hitY) {
|
||||
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
|
||||
this.spawnTrail(bar.container.x + bw * 0.5, barTopY + barHeight * 0.35, bw, bar.baseColor);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recycle ─────────────────────────────────────────────────────────────
|
||||
if (bar.container.y > hitY + 60) {
|
||||
// Transition into sustained-ghost tracking before recycling bar
|
||||
if (doSustained && sustainActive) {
|
||||
const timeAfterEnd = currentSeconds - bar.note.endSeconds;
|
||||
if (timeAfterEnd < 3.0) {
|
||||
this.sustainedNotes.set(id, {
|
||||
midi: bar.note.midi,
|
||||
baseColor: bar.baseColor,
|
||||
endSeconds: bar.note.endSeconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
bar.reset();
|
||||
this.pool.push(bar);
|
||||
this.active.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sustained-note ghost indicators ──────────────────────────────────────
|
||||
this.sustainedNoteLayer.clear();
|
||||
if (doSustained && this.sustainedNotes.size > 0) {
|
||||
const sustainStillActive = this.isSustainActive(currentSeconds);
|
||||
const ghostH = 12;
|
||||
for (const [sid, sn] of this.sustainedNotes) {
|
||||
const timeAfterEnd = currentSeconds - sn.endSeconds;
|
||||
if (!sustainStillActive || timeAfterEnd >= 3.0) {
|
||||
this.sustainedNotes.delete(sid);
|
||||
continue;
|
||||
}
|
||||
const bw = Math.max(5, (isWhite(sn.midi) ? this.whiteW : this.blackW) - 2);
|
||||
const x = this.noteX(sn.midi) + 1;
|
||||
const r = Math.min(cornerRadius, bw * 0.25);
|
||||
// Fade out over the last second of the 3s window
|
||||
const alpha = Math.min(0.32, 0.32 * (1 - Math.max(0, timeAfterEnd - 2.0)));
|
||||
this.sustainedNoteLayer.roundRect(x, hitY - ghostH, bw, ghostH, r);
|
||||
this.sustainedNoteLayer.fill({ color: sn.baseColor, alpha });
|
||||
}
|
||||
}
|
||||
|
||||
// Advance all active flashes - pass PIXI's normalized delta so animation
|
||||
// duration is frame-rate independent (1.0 = one 60fps frame).
|
||||
const dt = this.app.ticker.deltaTime;
|
||||
for (const f of this.flashes) {
|
||||
if (f.active) f.advance(dt);
|
||||
}
|
||||
|
||||
// ── Particles ──────────────────────────────────────────────────────────
|
||||
this.particleLayer.clear();
|
||||
if (this.particles.length > 0) {
|
||||
this.particles = this.particles.filter(p => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += p.gravity;
|
||||
p.life -= p.decay;
|
||||
return p.life > 0;
|
||||
});
|
||||
for (const p of this.particles) {
|
||||
const r = Math.max(0.5, p.size * p.life);
|
||||
this.particleLayer.circle(p.x, p.y, r);
|
||||
this.particleLayer.fill({ color: p.color, alpha: p.life * 0.88 });
|
||||
}
|
||||
}
|
||||
|
||||
// Update measure number labels
|
||||
if (this.opts.showMeasureNums && this.opts.measureStarts?.length) {
|
||||
let li = 0;
|
||||
const labels = this.measureLayer.children as PIXI.Text[];
|
||||
const viewportSeconds = this.opts.viewportSeconds;
|
||||
for (let i = 0; i < this.opts.measureStarts.length && li < labels.length; i++) {
|
||||
const sec = this.opts.measureStarts[i];
|
||||
const timeUntil = sec - currentSeconds;
|
||||
if (timeUntil < -0.5 || timeUntil > viewportSeconds + 0.5) continue;
|
||||
const y = hitY - timeUntil * pxPerSec;
|
||||
if (y < -15 || y > height + 15) continue;
|
||||
const lbl = labels[li++];
|
||||
lbl.visible = true;
|
||||
lbl.text = String(i + 1);
|
||||
lbl.y = y - 10;
|
||||
}
|
||||
for (let i = li; i < labels.length; i++) (labels[i] as PIXI.Text).visible = false;
|
||||
} else {
|
||||
for (const c of this.measureLayer.children) (c as PIXI.Text).visible = false;
|
||||
}
|
||||
|
||||
// ── Beat lines ──────────────────────────────────────────────────────────
|
||||
if ((this.opts.showBeatLines ?? false) && this.opts.beatStarts?.length) {
|
||||
this.beatLayer.clear();
|
||||
// Build a Set of measure-start times (×100 for int comparison) for styling
|
||||
const mSet = new Set((this.opts.measureStarts ?? []).map(s => Math.round(s * 100)));
|
||||
for (const sec of this.opts.beatStarts) {
|
||||
const timeUntil = sec - currentSeconds;
|
||||
if (timeUntil < -0.3 || timeUntil > viewportSeconds + 0.3) continue;
|
||||
const y = Math.round(hitY - timeUntil * pxPerSec);
|
||||
if (y < 0 || y > height + 1) continue;
|
||||
const isMeasure = mSet.has(Math.round(sec * 100));
|
||||
const dark = this.opts.darkMode !== false;
|
||||
this.beatLayer.rect(0, y, this.opts.width, isMeasure ? 1.5 : 1);
|
||||
// Dark mode: light blue-gray at low opacity. Light mode: darker blue-gray at higher opacity.
|
||||
this.beatLayer.fill({ color: dark ? 0x8896b8 : 0x4a5568, alpha: isMeasure ? (dark ? 0.2 : 0.35) : (dark ? 0.08 : 0.15) });
|
||||
}
|
||||
} else if (!(this.opts.showBeatLines ?? false)) {
|
||||
this.beatLayer.clear();
|
||||
}
|
||||
|
||||
// ── Sustain pedal - "Ped" / "✱" markers + left-edge connector line ────────
|
||||
// "Ped" appears at the press position (bottom of each range, reaches the hit
|
||||
// line first). "✱" appears at the release position (top of range, arrives
|
||||
// later). A 1px line connects them on the left edge - same layout as printed
|
||||
// sheet music pedal notation, without any full-width bands obscuring notes.
|
||||
if ((this.opts.showSustainPedal ?? false) && this.opts.sustainRanges?.length) {
|
||||
this.sustainLineLayer.clear();
|
||||
const pool = this.sustainTextLayer.children as PIXI.Text[];
|
||||
for (const t of pool) t.visible = false;
|
||||
let ti = 0;
|
||||
|
||||
const LABEL_H = 16; // approximate text height + gap
|
||||
let prevRelY = -999; // Y of the last "✱" placed - guards Ped overlap from above
|
||||
|
||||
for (const range of this.opts.sustainRanges) {
|
||||
const startTimeUntil = range.startSeconds - currentSeconds;
|
||||
const endTimeUntil = range.endSeconds - currentSeconds;
|
||||
if (endTimeUntil < -0.5 || startTimeUntil > viewportSeconds + 0.5) continue;
|
||||
|
||||
// topY = release (end) - higher on canvas (later in time)
|
||||
// botY = press (start) - lower on canvas (sooner, approaches hit line first)
|
||||
const topY = Math.max(0, hitY - endTimeUntil * pxPerSec);
|
||||
const botY = Math.min(height + 14, hitY - startTimeUntil * pxPerSec);
|
||||
if (botY <= topY) continue;
|
||||
|
||||
// 1 px connecting line on the left edge
|
||||
this.sustainLineLayer.rect(9, topY, 1, botY - topY);
|
||||
this.sustainLineLayer.fill({ color: 0xa855f7, alpha: 0.55 });
|
||||
|
||||
const pedY = Math.min(botY - 14, height - 14);
|
||||
const relY = Math.max(2, topY - 13);
|
||||
|
||||
// "Ped" - skip if it would sit on top of the previous "✱"
|
||||
const pedClear = pedY - prevRelY > LABEL_H;
|
||||
if (pedClear && ti < pool.length && botY >= -4 && botY <= height + 14) {
|
||||
const t = pool[ti++];
|
||||
t.text = "Ped";
|
||||
t.y = pedY;
|
||||
t.visible = true;
|
||||
}
|
||||
|
||||
// "✱" - only if the band is tall enough to show both labels without crowding
|
||||
const bandPx = botY - topY;
|
||||
if (bandPx >= LABEL_H * 2 && ti < pool.length && topY >= -4 && topY <= height) {
|
||||
const t = pool[ti++];
|
||||
t.text = "✱";
|
||||
t.y = relY;
|
||||
t.visible = true;
|
||||
prevRelY = relY;
|
||||
}
|
||||
}
|
||||
} else if (!(this.opts.showSustainPedal ?? false)) {
|
||||
this.sustainLineLayer.clear();
|
||||
for (const t of this.sustainTextLayer.children as PIXI.Text[]) t.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
showGhost(note: NoteEvent | null) {
|
||||
if (!note) { this.ghostBar.visible = false; return; }
|
||||
const white = isWhite(note.midi);
|
||||
const w = (white ? this.whiteW : this.blackW) - 2;
|
||||
const x = this.noteX(note.midi) + 1;
|
||||
this.ghostBar.clear();
|
||||
this.ghostBar.roundRect(x, this.opts.height - 22, w, 16, 3);
|
||||
this.ghostBar.fill({ color: 0x6366f1, alpha: 0.5 });
|
||||
this.ghostBar.visible = true;
|
||||
}
|
||||
|
||||
reset() {
|
||||
for (const [, bar] of this.active) { bar.reset(); this.pool.push(bar); }
|
||||
this.active.clear();
|
||||
this.particles = [];
|
||||
this.particleLayer?.clear();
|
||||
this.sustainedNotes.clear();
|
||||
this.sustainedNoteLayer?.clear();
|
||||
this.ghostBar.visible = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade the stage from transparent to fully opaque over `durationMs`.
|
||||
* Call this when playback starts from a stopped state so the first notes
|
||||
* materialize gently rather than snapping into view.
|
||||
*/
|
||||
fadeIn(durationMs = 420) {
|
||||
if (!this._initialized) return;
|
||||
this.app.stage.alpha = 0;
|
||||
// Frames at 60 fps; clamp to at least 1 to avoid division by zero.
|
||||
const frames = Math.max(1, Math.round((durationMs / 1000) * 60));
|
||||
const step = 1 / frames;
|
||||
const onTick = () => {
|
||||
const next = Math.min(1, this.app.stage.alpha + step);
|
||||
this.app.stage.alpha = next;
|
||||
if (next >= 1) this.app.ticker.remove(onTick);
|
||||
};
|
||||
this.app.ticker.remove(onTick); // guard against double-call
|
||||
this.app.ticker.add(onTick);
|
||||
}
|
||||
|
||||
setOptions(opts: Partial<FallingNotesOptions>) {
|
||||
// Check whether any colour-affecting option actually CHANGED before merging,
|
||||
// because "x in opts" is always true when callers always pass the full options
|
||||
// object - triggering spurious resets that wipe all visible note bars.
|
||||
const needsReset =
|
||||
("colorTheme" in opts && opts.colorTheme !== this.opts.colorTheme) ||
|
||||
("customColors" in opts && opts.customColors !== this.opts.customColors) ||
|
||||
("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) ||
|
||||
("showHandColors" in opts && opts.showHandColors !== this.opts.showHandColors) ||
|
||||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats) ||
|
||||
("fallingNotesLabelMode" in opts && opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) ||
|
||||
("showFingering" in opts && opts.showFingering !== this.opts.showFingering) ||
|
||||
("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline);
|
||||
|
||||
Object.assign(this.opts, opts);
|
||||
if (!this._initialized) return;
|
||||
|
||||
if (needsReset) {
|
||||
for (const g of this.gradientCache.values()) g.destroy();
|
||||
this.gradientCache.clear();
|
||||
this.reset();
|
||||
}
|
||||
if ("showGrid" in opts) this.gridLayer.visible = !!opts.showGrid;
|
||||
if ("impactStyle" in opts) {
|
||||
this.particles = [];
|
||||
this.particleLayer?.clear();
|
||||
}
|
||||
if ("darkMode" in opts) this.drawHitLine();
|
||||
if ("showBeatLines" in opts && !opts.showBeatLines) this.beatLayer.clear();
|
||||
if ("showSustainPedal" in opts && !opts.showSustainPedal) {
|
||||
this.sustainLineLayer.clear();
|
||||
for (const t of this.sustainTextLayer.children as PIXI.Text[]) t.visible = false;
|
||||
}
|
||||
// Turning off sustained notes: discard ghost state and clear the layer
|
||||
if ("showSustainedNotes" in opts && !opts.showSustainedNotes) {
|
||||
this.sustainedNotes.clear();
|
||||
this.sustainedNoteLayer?.clear();
|
||||
}
|
||||
}
|
||||
|
||||
resize(width: number, height: number) {
|
||||
if (!this._initialized) return;
|
||||
this.opts.width = width;
|
||||
this.opts.height = height;
|
||||
this.app.renderer.resize(width, height);
|
||||
this.computeLayout();
|
||||
this.reset();
|
||||
this.drawHitLine();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!this._initialized) return;
|
||||
for (const g of this.gradientCache.values()) g.destroy();
|
||||
this.gradientCache.clear();
|
||||
this.app.destroy(false, { children: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useRef, useMemo } from "react";
|
||||
import bus from "../../engine/EventBus";
|
||||
import { FallingNotesRenderer } from "./FallingNotesRenderer";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { computeMeasureSeconds, computeBeatSeconds } from "@bach-to-basics/shared";
|
||||
import type { NoteEvent } from "@bach-to-basics/shared";
|
||||
import { useAccuracy } from "../../hooks/useAccuracy";
|
||||
|
||||
export function FallingNotesView() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<FallingNotesRenderer | null>(null);
|
||||
const lastSecondsRef = useRef(0);
|
||||
const { settings, document: doc, midiEnabled, status, seek } = useAppStore();
|
||||
|
||||
// Accuracy scoring - active when MIDI device connected
|
||||
const accuracy = useAccuracy(midiEnabled && (status === "playing" || status === "paused"));
|
||||
|
||||
const measureStarts = useMemo(
|
||||
() => doc ? computeMeasureSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration) : [],
|
||||
[doc]
|
||||
);
|
||||
|
||||
const beatStarts = useMemo(
|
||||
() => doc ? computeBeatSeconds(doc.timeSignatures, doc.tempoMap, doc.ppq, doc.totalDuration) : [],
|
||||
[doc]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth || window.innerWidth;
|
||||
const height = container.clientHeight || 400;
|
||||
|
||||
const renderer = new FallingNotesRenderer({
|
||||
width,
|
||||
height,
|
||||
viewportSeconds: settings.viewportSeconds,
|
||||
fallingNotesLabelMode: settings.fallingNotesLabelMode,
|
||||
showFingering: settings.showFingering,
|
||||
showHandColors: settings.showHandColors,
|
||||
useFlats: settings.useFlats,
|
||||
noteFilter: settings.noteFilter,
|
||||
colorTheme: settings.colorTheme,
|
||||
customColors: settings.customColors,
|
||||
showGrid: settings.showGrid,
|
||||
showMeasureNums: settings.showMeasureNums,
|
||||
measureStarts,
|
||||
impactStyle: settings.impactStyle,
|
||||
minNoteHeight: settings.minNoteHeight,
|
||||
noteCornerRadius: settings.noteCornerRadius,
|
||||
showBeatLines: settings.showBeatLines,
|
||||
beatStarts,
|
||||
showSustainPedal: settings.showSustainPedal,
|
||||
sustainRanges: doc?.sustainRanges,
|
||||
showNoteOutline: settings.showNoteOutline,
|
||||
showSustainedNotes: settings.showSustainedNotes,
|
||||
darkMode: settings.theme === "dark",
|
||||
});
|
||||
rendererRef.current = renderer;
|
||||
renderer.init(canvasRef.current);
|
||||
|
||||
const onUpcoming = (note: NoteEvent) => renderer.addNote(note, lastSecondsRef.current);
|
||||
const onTick = ({ seconds }: { seconds: number; tick: number }) => {
|
||||
lastSecondsRef.current = seconds;
|
||||
renderer.tick(seconds);
|
||||
};
|
||||
let prevStatus = "stopped";
|
||||
const onStateChange = ({ status }: { status: string }) => {
|
||||
if (status === "stopped") renderer.reset();
|
||||
if (status === "playing" && prevStatus === "stopped") renderer.fadeIn();
|
||||
prevStatus = status;
|
||||
};
|
||||
const onSeek = () => renderer.reset();
|
||||
|
||||
bus.on("note:upcoming", onUpcoming as never);
|
||||
bus.on("transport:tick", onTick as never);
|
||||
bus.on("transport:stateChange", onStateChange as never);
|
||||
bus.on("transport:seek", onSeek as never);
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
if (w > 0 && h > 0 && rendererRef.current) rendererRef.current.resize(w, h);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
return () => {
|
||||
bus.off("note:upcoming", onUpcoming as never);
|
||||
bus.off("transport:tick", onTick as never);
|
||||
bus.off("transport:stateChange", onStateChange as never);
|
||||
bus.off("transport:seek", onSeek as never);
|
||||
ro.disconnect();
|
||||
renderer.destroy();
|
||||
rendererRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Push setting changes into the renderer without remounting
|
||||
useEffect(() => {
|
||||
rendererRef.current?.setOptions({
|
||||
viewportSeconds: settings.viewportSeconds,
|
||||
fallingNotesLabelMode: settings.fallingNotesLabelMode,
|
||||
showFingering: settings.showFingering,
|
||||
showHandColors: settings.showHandColors,
|
||||
useFlats: settings.useFlats,
|
||||
noteFilter: settings.noteFilter,
|
||||
colorTheme: settings.colorTheme,
|
||||
customColors: settings.customColors,
|
||||
showGrid: settings.showGrid,
|
||||
showMeasureNums: settings.showMeasureNums,
|
||||
measureStarts,
|
||||
impactStyle: settings.impactStyle,
|
||||
minNoteHeight: settings.minNoteHeight,
|
||||
noteCornerRadius: settings.noteCornerRadius,
|
||||
showBeatLines: settings.showBeatLines,
|
||||
beatStarts,
|
||||
showSustainPedal: settings.showSustainPedal,
|
||||
sustainRanges: doc?.sustainRanges,
|
||||
showNoteOutline: settings.showNoteOutline,
|
||||
showSustainedNotes: settings.showSustainedNotes,
|
||||
darkMode: settings.theme === "dark",
|
||||
});
|
||||
}, [
|
||||
settings.viewportSeconds,
|
||||
settings.fallingNotesLabelMode,
|
||||
settings.showFingering,
|
||||
settings.showHandColors,
|
||||
settings.useFlats,
|
||||
settings.noteFilter,
|
||||
settings.colorTheme,
|
||||
settings.customColors,
|
||||
settings.showGrid,
|
||||
settings.showMeasureNums,
|
||||
settings.impactStyle,
|
||||
measureStarts,
|
||||
settings.minNoteHeight,
|
||||
settings.noteCornerRadius,
|
||||
settings.showBeatLines,
|
||||
beatStarts,
|
||||
settings.showSustainPedal,
|
||||
doc?.sustainRanges,
|
||||
settings.showNoteOutline,
|
||||
settings.showSustainedNotes,
|
||||
settings.theme,
|
||||
]);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (!doc || !settings.scrollToSeek) return;
|
||||
// Seek forward/backward by 30% of the viewport window per scroll tick
|
||||
const delta = (e.deltaY > 0 ? 1 : -1) * settings.viewportSeconds * 0.3;
|
||||
const next = Math.max(0, Math.min(doc.totalDuration, useAppStore.getState().currentSeconds + delta));
|
||||
seek(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full flex-1 relative"
|
||||
onWheel={handleWheel}
|
||||
style={{
|
||||
minHeight: 200,
|
||||
background: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
|
||||
borderTop: "1px solid var(--color-notes-border)",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
|
||||
|
||||
{midiEnabled && (accuracy.correct + accuracy.wrong + accuracy.missed) > 0 && (
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10 pointer-events-none text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{
|
||||
background: accuracy.score >= 80 ? "rgba(34,197,94,0.12)" : accuracy.score >= 50 ? "rgba(234,179,8,0.12)" : "rgba(239,68,68,0.12)",
|
||||
color: accuracy.score >= 80 ? "#4ade80" : accuracy.score >= 50 ? "#eab308" : "#ef4444",
|
||||
border: "1px solid currentColor",
|
||||
}}
|
||||
>
|
||||
<TargetIcon /> {accuracy.score}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TargetIcon = () => (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ display: "inline", verticalAlign: "middle", marginBottom: 1 }} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,523 @@
|
||||
import * as PIXI from "pixi.js";
|
||||
import { isBlackKey, midiToPitch, midiToNoteName } from "@bach-to-basics/shared";
|
||||
import type { NoteEvent } from "@bach-to-basics/shared";
|
||||
import type { CustomColors, NoteLabelMode } from "../../store/useAppStore";
|
||||
import { lighten, hexToNum } from "../../utils/colorUtils";
|
||||
|
||||
// ── Layout constants ──────────────────────────────────────────────────────────
|
||||
const MIDI_MIN = 21; // A0
|
||||
const MIDI_MAX = 108; // C8
|
||||
|
||||
function isWhite(midi: number) { return !isBlackKey(midi); }
|
||||
const WHITE_COUNT = Array.from({ length: MIDI_MAX - MIDI_MIN + 1 }, (_, i) => MIDI_MIN + i)
|
||||
.filter(isWhite).length;
|
||||
|
||||
// ── Color tokens ──────────────────────────────────────────────────────────────
|
||||
const C_WHITE_TOP = 0xc4c4bc; // subtle shadow at key hinge
|
||||
const C_WHITE_MID = 0xf5f5f0; // main key face
|
||||
const C_WHITE_BTM = 0xe4e4dc; // front edge shadow
|
||||
const C_BLACK_TOP = 0x2c2c38; // top of black key
|
||||
const C_BLACK_MID = 0x181820; // main body
|
||||
const C_BLACK_BTM = 0x363644; // slight gloss at bottom
|
||||
|
||||
const C_BORDER = 0x9a9a90;
|
||||
const C_LABEL = 0x6b7280;
|
||||
const C_FINGER = 0xffffff;
|
||||
|
||||
// Active state colors (hand-aware)
|
||||
const C_PLAYBACK = 0x6366f1;
|
||||
const C_INPUT = 0x22c55e;
|
||||
const C_LEFT = 0x3b82f6;
|
||||
const C_RIGHT = 0xef4444;
|
||||
|
||||
// ── Gradient factory ──────────────────────────────────────────────────────────
|
||||
function lg(stops: Array<[number, number | string]>): PIXI.FillGradient {
|
||||
return new PIXI.FillGradient({
|
||||
type: "linear",
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 0, y: 1 },
|
||||
textureSpace: "local",
|
||||
colorStops: stops.map(([offset, color]) => ({ offset, color })),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Key color factories (themed) ──────────────────────────────────────────────
|
||||
function whiteKeyColors(ivory: boolean): { top: number; mid: number; btm: number } {
|
||||
return ivory
|
||||
? { top: 0xcdc8a8, mid: 0xfff8e7, btm: 0xe8e0c8 }
|
||||
: { top: C_WHITE_TOP, mid: C_WHITE_MID, btm: C_WHITE_BTM };
|
||||
}
|
||||
|
||||
// Pre-built gradient factories for the ~10 possible states
|
||||
const GRAD = {
|
||||
whiteNormal: (ivory: boolean) => {
|
||||
const c = whiteKeyColors(ivory);
|
||||
return lg([[0, c.top], [0.07, c.mid], [0.88, c.mid], [1, c.btm]]);
|
||||
},
|
||||
blackNormal: () => lg([[0, C_BLACK_TOP], [0.35, C_BLACK_MID], [0.88, C_BLACK_MID], [1, C_BLACK_BTM]]),
|
||||
activeWhite: (base: number) => lg([
|
||||
[0, 0x050508], // deep hinge shadow
|
||||
[0.12, lighten(base, 0.58)],
|
||||
[0.22, base],
|
||||
[0.83, base],
|
||||
[1, lighten(base, 1.45)], // bright front lip
|
||||
]),
|
||||
activeBlack: (base: number) => lg([
|
||||
[0, lighten(base, 0.6)],
|
||||
[0.5, base],
|
||||
[1, lighten(base, 1.5)],
|
||||
]),
|
||||
};
|
||||
|
||||
export interface PianoKeyboardOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
noteLabelMode: NoteLabelMode;
|
||||
pianoTheme: "white" | "ivory";
|
||||
showFingering: boolean;
|
||||
showHandColors: boolean;
|
||||
useFlats: boolean;
|
||||
customColors?: CustomColors;
|
||||
onKeyPress?: (midi: number) => void;
|
||||
onKeyRelease?: (midi: number) => void;
|
||||
}
|
||||
|
||||
interface KeyState {
|
||||
playbackActive: boolean;
|
||||
inputActive: boolean;
|
||||
hand: "left" | "right" | null;
|
||||
finger: number | null;
|
||||
hovered: boolean;
|
||||
}
|
||||
|
||||
// ── Main class ────────────────────────────────────────────────────────────────
|
||||
export class PianoKeyboard {
|
||||
app: PIXI.Application;
|
||||
private _initialized = false;
|
||||
|
||||
private keyContainers = new Map<number, PIXI.Container>();
|
||||
private keys = new Map<number, PIXI.Graphics>();
|
||||
private keyLabels = new Map<number, PIXI.Text>();
|
||||
private fingerLabels = new Map<number, PIXI.Text>();
|
||||
private keyStates = new Map<number, KeyState>();
|
||||
private opts: PianoKeyboardOptions;
|
||||
|
||||
private whiteW = 0;
|
||||
private whiteH = 0;
|
||||
private blackW = 0;
|
||||
private blackH = 0;
|
||||
private keyX = new Map<number, number>();
|
||||
|
||||
// Gradient cache - keyed by "white|black-normal|#hexcolor"
|
||||
private gradCache = new Map<string, PIXI.FillGradient>();
|
||||
|
||||
// Drag-to-play tracking
|
||||
private isPointerDown = false;
|
||||
private dragKeys = new Set<number>();
|
||||
private readonly onWindowPointerUp: (e: PointerEvent) => void;
|
||||
|
||||
constructor(_canvas: HTMLCanvasElement, opts: PianoKeyboardOptions) {
|
||||
this.opts = opts;
|
||||
this.app = new PIXI.Application();
|
||||
this.onWindowPointerUp = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
this.isPointerDown = false;
|
||||
for (const m of this.dragKeys) this.opts.onKeyRelease?.(m);
|
||||
this.dragKeys.clear();
|
||||
};
|
||||
}
|
||||
|
||||
async init(canvas: HTMLCanvasElement) {
|
||||
await this.app.init({
|
||||
canvas,
|
||||
width: this.opts.width,
|
||||
height: this.opts.height,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
autoDensity: true,
|
||||
...(import.meta.env.DEV ? { preserveDrawingBuffer: true } : {}),
|
||||
});
|
||||
window.addEventListener("pointerup", this.onWindowPointerUp);
|
||||
this.computeLayout();
|
||||
this.drawKeys();
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
private computeLayout() {
|
||||
const { width, height } = this.opts;
|
||||
this.whiteW = width / WHITE_COUNT;
|
||||
this.whiteH = height;
|
||||
this.blackW = this.whiteW * 0.58;
|
||||
this.blackH = height * 0.60;
|
||||
|
||||
let wi = 0;
|
||||
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
|
||||
if (isWhite(midi)) {
|
||||
this.keyX.set(midi, wi * this.whiteW);
|
||||
wi++;
|
||||
}
|
||||
}
|
||||
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
|
||||
if (!isWhite(midi)) {
|
||||
const lx = this.keyX.get(midi - 1) ?? 0;
|
||||
this.keyX.set(midi, lx + this.whiteW - this.blackW / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawKeys() {
|
||||
const root = new PIXI.Container();
|
||||
const whiteLayer = new PIXI.Container();
|
||||
const blackLayer = new PIXI.Container();
|
||||
|
||||
for (let midi = MIDI_MIN; midi <= MIDI_MAX; midi++) {
|
||||
const white = isWhite(midi);
|
||||
const x = this.keyX.get(midi) ?? 0;
|
||||
const w = white ? this.whiteW : this.blackW;
|
||||
const h = white ? this.whiteH : this.blackH;
|
||||
|
||||
const container = new PIXI.Container();
|
||||
container.x = x;
|
||||
container.interactive = true;
|
||||
container.cursor = "pointer";
|
||||
|
||||
// Draw shape
|
||||
const g = new PIXI.Graphics();
|
||||
this.paintKey(g, w, h, white, false, null, null, false);
|
||||
container.addChild(g);
|
||||
|
||||
// Note label (created for every key; text/visibility set by noteLabelMode)
|
||||
{
|
||||
const labelStr = this.noteLabel(midi);
|
||||
const lFontSz = white
|
||||
? Math.max(7, this.whiteW * 0.42)
|
||||
: Math.max(6, this.blackW * 0.55);
|
||||
const label = new PIXI.Text({
|
||||
text: labelStr,
|
||||
style: {
|
||||
fontSize: lFontSz,
|
||||
fill: white ? C_LABEL : 0xaaaaaa,
|
||||
fontFamily: "Space Grotesk, system-ui, sans-serif",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
label.anchor.set(0.5, 1);
|
||||
label.x = w / 2;
|
||||
label.y = white ? h - 5 : h - 3;
|
||||
label.visible = labelStr.length > 0;
|
||||
container.addChild(label);
|
||||
this.keyLabels.set(midi, label);
|
||||
}
|
||||
|
||||
// Finger label
|
||||
const fingerText = new PIXI.Text({
|
||||
text: "",
|
||||
style: {
|
||||
fontSize: Math.max(9, this.whiteW * 0.48),
|
||||
fill: C_FINGER,
|
||||
fontFamily: "Space Grotesk, system-ui, sans-serif",
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
fingerText.anchor.set(0.5, 0.5);
|
||||
fingerText.x = w / 2;
|
||||
fingerText.y = h * 0.22;
|
||||
fingerText.visible = false;
|
||||
container.addChild(fingerText);
|
||||
this.fingerLabels.set(midi, fingerText);
|
||||
|
||||
// Click + drag-to-play handlers
|
||||
container.on("pointerdown", () => {
|
||||
this.isPointerDown = true;
|
||||
if (!this.dragKeys.has(midi)) {
|
||||
this.opts.onKeyPress?.(midi);
|
||||
this.dragKeys.add(midi);
|
||||
}
|
||||
});
|
||||
container.on("pointerover", () => {
|
||||
if (this.isPointerDown && !this.dragKeys.has(midi)) {
|
||||
this.opts.onKeyPress?.(midi);
|
||||
this.dragKeys.add(midi);
|
||||
}
|
||||
// Hover highlight
|
||||
const st = this.keyStates.get(midi);
|
||||
if (st && !st.hovered) { st.hovered = true; this.redrawKey(midi); }
|
||||
});
|
||||
container.on("pointerout", () => {
|
||||
if (this.dragKeys.has(midi)) {
|
||||
this.opts.onKeyRelease?.(midi);
|
||||
this.dragKeys.delete(midi);
|
||||
}
|
||||
// Remove hover highlight
|
||||
const st = this.keyStates.get(midi);
|
||||
if (st && st.hovered) { st.hovered = false; this.redrawKey(midi); }
|
||||
});
|
||||
|
||||
this.keys.set(midi, g);
|
||||
this.keyContainers.set(midi, container);
|
||||
this.keyStates.set(midi, { playbackActive: false, inputActive: false, hand: null, finger: null, hovered: false });
|
||||
|
||||
if (white) whiteLayer.addChild(container);
|
||||
else blackLayer.addChild(container);
|
||||
}
|
||||
|
||||
root.addChild(whiteLayer, blackLayer);
|
||||
this.app.stage.addChild(root);
|
||||
}
|
||||
|
||||
// ── Painting ──────────────────────────────────────────────────────────────
|
||||
|
||||
private getGrad(key: string, factory: () => PIXI.FillGradient): PIXI.FillGradient {
|
||||
let g = this.gradCache.get(key);
|
||||
if (!g) { g = factory(); this.gradCache.set(key, g); }
|
||||
return g;
|
||||
}
|
||||
|
||||
private paintKey(
|
||||
g: PIXI.Graphics,
|
||||
w: number, h: number,
|
||||
white: boolean,
|
||||
active: boolean,
|
||||
activeColor: number | null,
|
||||
_hand: "left" | "right" | null,
|
||||
hovered = false,
|
||||
) {
|
||||
g.clear();
|
||||
const r = white ? 4 : 3;
|
||||
|
||||
const ivory = this.opts.pianoTheme === "ivory";
|
||||
|
||||
let fill: PIXI.FillGradient | number;
|
||||
if (!active) {
|
||||
fill = white
|
||||
? this.getGrad(ivory ? "wn-iv" : "wn", () => GRAD.whiteNormal(ivory))
|
||||
: this.getGrad("bn", GRAD.blackNormal);
|
||||
} else {
|
||||
const base = activeColor ?? C_PLAYBACK;
|
||||
const hexKey = (white ? "aw" : "ab") + base.toString(16);
|
||||
fill = white
|
||||
? this.getGrad(hexKey, () => GRAD.activeWhite(base))
|
||||
: this.getGrad(hexKey, () => GRAD.activeBlack(base));
|
||||
}
|
||||
|
||||
// Main shape
|
||||
g.roundRect(0.5, 0.5, w - 1, h - 1, r);
|
||||
g.fill(fill);
|
||||
|
||||
// ── Active white key: depth / pressed look ─────────────────────────────
|
||||
if (active && white) {
|
||||
// Keybed shadow strip at bottom (felt bumper contact)
|
||||
g.rect(1, h - 4, w - 2, 4);
|
||||
g.fill({ color: 0x000000, alpha: 0.40 });
|
||||
// Side edge depth shadows
|
||||
g.rect(0.5, h * 0.08, 2, h * 0.88);
|
||||
g.fill({ color: 0x000000, alpha: 0.10 });
|
||||
g.rect(w - 2.5, h * 0.08, 2, h * 0.88);
|
||||
g.fill({ color: 0x000000, alpha: 0.10 });
|
||||
}
|
||||
|
||||
// White highlight strip at top of active black keys
|
||||
if (active && !white && h > 10) {
|
||||
g.roundRect(1, 1, w - 2, Math.min(h * 0.12, 6), r * 0.4);
|
||||
g.fill({ color: 0xffffff, alpha: 0.28 });
|
||||
// Subtle colored glow at bottom of active black key
|
||||
g.rect(1, h - 5, w - 2, 4);
|
||||
g.fill({ color: activeColor ?? C_PLAYBACK, alpha: 0.25 });
|
||||
}
|
||||
|
||||
// Border
|
||||
if (white) {
|
||||
g.roundRect(0.5, 0.5, w - 1, h - 1, r);
|
||||
g.stroke({ width: 1, color: active ? 0x00000033 : C_BORDER });
|
||||
}
|
||||
|
||||
// Black key inner shadow on left/right edges (inactive)
|
||||
if (!white && !active) {
|
||||
g.rect(0, 4, 1, h - 8);
|
||||
g.fill({ color: 0x000000, alpha: 0.35 });
|
||||
g.rect(w - 1, 4, 1, h - 8);
|
||||
g.fill({ color: 0x000000, alpha: 0.35 });
|
||||
}
|
||||
|
||||
// Hover overlay - subtle white sheen on non-active keys
|
||||
if (hovered && !active) {
|
||||
if (white) {
|
||||
g.roundRect(0.5, 0.5, w - 1, h - 1, r);
|
||||
g.fill({ color: 0xffffff, alpha: 0.22 });
|
||||
} else {
|
||||
g.roundRect(1, 1, w - 2, h - 2, r);
|
||||
g.fill({ color: 0xffffff, alpha: 0.14 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private redrawKey(midi: number) {
|
||||
const g = this.keys.get(midi);
|
||||
const state = this.keyStates.get(midi);
|
||||
const cont = this.keyContainers.get(midi);
|
||||
if (!g || !state || !cont) return;
|
||||
|
||||
const white = isWhite(midi);
|
||||
const w = white ? this.whiteW : this.blackW;
|
||||
const h = white ? this.whiteH : this.blackH;
|
||||
const active = state.playbackActive || state.inputActive;
|
||||
|
||||
// 3D press: shift container down when active
|
||||
const pressOffset = active ? (white ? 5 : 3) : 0;
|
||||
cont.y = pressOffset;
|
||||
|
||||
let color: number | null = null;
|
||||
if (active) {
|
||||
const cc = this.opts.customColors;
|
||||
if (this.opts.showHandColors && state.hand) {
|
||||
color = state.hand === "left"
|
||||
? (cc ? hexToNum(cc.leftHand) : C_LEFT)
|
||||
: (cc ? hexToNum(cc.rightHand) : C_RIGHT);
|
||||
} else if (state.inputActive) {
|
||||
color = C_INPUT;
|
||||
} else {
|
||||
color = cc ? hexToNum(cc.unknown) : C_PLAYBACK;
|
||||
}
|
||||
}
|
||||
|
||||
this.paintKey(g, w, h, white, active, color, state.hand, !active && state.hovered);
|
||||
|
||||
// Finger label
|
||||
const fl = this.fingerLabels.get(midi);
|
||||
if (fl) {
|
||||
fl.visible = this.opts.showFingering && active && state.finger !== null;
|
||||
if (state.finger !== null) fl.text = String(state.finger);
|
||||
}
|
||||
}
|
||||
|
||||
private noteLabel(midi: number): string {
|
||||
const mode = this.opts.noteLabelMode ?? "none";
|
||||
if (mode === "none") return "";
|
||||
const white = isWhite(midi);
|
||||
if (mode === "c_only") {
|
||||
// Show full pitch (e.g. "C4") only for C notes
|
||||
return midi % 12 === 0 ? midiToPitch(midi, this.opts.useFlats) : "";
|
||||
}
|
||||
if (mode === "white" && !white) return "";
|
||||
if (mode === "black" && white) return "";
|
||||
// "white", "black", "all": show note name class only (no octave)
|
||||
return midiToNoteName(midi, this.opts.useFlats);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
noteOn(note: NoteEvent, source: "playback" | "input") {
|
||||
const state = this.keyStates.get(note.midi);
|
||||
if (!state) return;
|
||||
if (source === "playback") {
|
||||
state.playbackActive = true;
|
||||
state.hand = note.hand === "unknown" ? null : note.hand as "left" | "right";
|
||||
state.finger = note.finger;
|
||||
} else {
|
||||
state.inputActive = true;
|
||||
}
|
||||
this.redrawKey(note.midi);
|
||||
}
|
||||
|
||||
noteOff(note: NoteEvent, source: "playback" | "input") {
|
||||
const state = this.keyStates.get(note.midi);
|
||||
if (!state) return;
|
||||
if (source === "playback") {
|
||||
state.playbackActive = false;
|
||||
if (!state.inputActive) { state.hand = null; state.finger = null; }
|
||||
} else {
|
||||
state.inputActive = false;
|
||||
}
|
||||
this.redrawKey(note.midi);
|
||||
}
|
||||
|
||||
inputNoteOn(midi: number) {
|
||||
const state = this.keyStates.get(midi);
|
||||
if (!state) return;
|
||||
state.inputActive = true;
|
||||
this.redrawKey(midi);
|
||||
}
|
||||
|
||||
inputNoteOff(midi: number) {
|
||||
const state = this.keyStates.get(midi);
|
||||
if (!state) return;
|
||||
state.inputActive = false;
|
||||
this.redrawKey(midi);
|
||||
}
|
||||
|
||||
reset() {
|
||||
for (const [midi] of this.keys) {
|
||||
const state = this.keyStates.get(midi);
|
||||
if (state) {
|
||||
state.playbackActive = false;
|
||||
state.inputActive = false;
|
||||
state.hand = null;
|
||||
state.finger = null;
|
||||
state.hovered = false;
|
||||
}
|
||||
this.redrawKey(midi);
|
||||
}
|
||||
}
|
||||
|
||||
setOptions(opts: Partial<PianoKeyboardOptions>) {
|
||||
const needsFullRebuild = (
|
||||
("pianoTheme" in opts && opts.pianoTheme !== this.opts.pianoTheme) ||
|
||||
("noteLabelMode" in opts && opts.noteLabelMode !== this.opts.noteLabelMode) ||
|
||||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats)
|
||||
);
|
||||
|
||||
Object.assign(this.opts, opts);
|
||||
|
||||
if (needsFullRebuild) {
|
||||
// Full key rebuild - clears gradient cache, recomputes layout, redraws all keys
|
||||
this.resize(this.opts.width, this.opts.height);
|
||||
return;
|
||||
}
|
||||
|
||||
// Light update: refresh note labels in place
|
||||
for (const [midi, label] of this.keyLabels) {
|
||||
const text = this.noteLabel(midi);
|
||||
label.text = text;
|
||||
label.visible = text.length > 0;
|
||||
}
|
||||
// Redraw any currently-active keys so color changes take effect immediately
|
||||
if ("customColors" in opts || "showHandColors" in opts) {
|
||||
for (const [midi, state] of this.keyStates) {
|
||||
if (state.playbackActive || state.inputActive) this.redrawKey(midi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resize(width: number, height: number) {
|
||||
if (!this._initialized) return;
|
||||
this.opts.width = width;
|
||||
this.opts.height = height;
|
||||
this.app.renderer.resize(width, height);
|
||||
|
||||
// Destroy cached gradients so they get rebuilt at new dimensions
|
||||
for (const g of this.gradCache.values()) g.destroy();
|
||||
this.gradCache.clear();
|
||||
|
||||
this.keyX.clear();
|
||||
this.keys.clear();
|
||||
this.keyContainers.clear();
|
||||
this.keyLabels.clear();
|
||||
this.fingerLabels.clear();
|
||||
this.keyStates.clear();
|
||||
this.app.stage.removeChildren();
|
||||
this.computeLayout();
|
||||
this.drawKeys();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener("pointerup", this.onWindowPointerUp);
|
||||
if (this._initialized) {
|
||||
for (const g of this.gradCache.values()) g.destroy();
|
||||
this.gradCache.clear();
|
||||
this.app.destroy(false, { children: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import bus from "../../engine/EventBus";
|
||||
import { PianoKeyboard } from "./PianoKeyboard";
|
||||
import { useAppStore, COLOR_PRESET_VALUES } from "../../store/useAppStore";
|
||||
import { syncEngine } from "../../engine/SyncEngine";
|
||||
import type { NoteEvent } from "@bach-to-basics/shared";
|
||||
|
||||
const KEYBOARD_HEIGHT = 160;
|
||||
|
||||
export function PianoKeyboardView() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const kbRef = useRef<PianoKeyboard | null>(null);
|
||||
const { settings } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth || window.innerWidth;
|
||||
|
||||
const themeColors = settings.colorTheme === "custom"
|
||||
? settings.customColors
|
||||
: COLOR_PRESET_VALUES[settings.colorTheme];
|
||||
|
||||
const kb = new PianoKeyboard(canvasRef.current, {
|
||||
width,
|
||||
height: KEYBOARD_HEIGHT,
|
||||
noteLabelMode: settings.noteLabelMode,
|
||||
pianoTheme: settings.pianoTheme,
|
||||
showFingering: settings.showFingering,
|
||||
showHandColors: settings.showHandColors,
|
||||
useFlats: settings.useFlats,
|
||||
customColors: themeColors,
|
||||
onKeyPress: (midi) => {
|
||||
// playMidi loads audio if needed, plays sound, and emits input:note:on
|
||||
syncEngine.playMidi(midi, 90);
|
||||
},
|
||||
onKeyRelease: (midi) => {
|
||||
syncEngine.stopMidi(midi);
|
||||
},
|
||||
});
|
||||
kbRef.current = kb;
|
||||
kb.init(canvasRef.current);
|
||||
|
||||
const onNoteOn = (note: NoteEvent) => kb.noteOn(note, "playback");
|
||||
const onNoteOff = (note: NoteEvent) => kb.noteOff(note, "playback");
|
||||
const onInputOn = ({ midi }: { midi: number; velocity: number }) => kb.inputNoteOn(midi);
|
||||
const onInputOff = ({ midi }: { midi: number }) => kb.inputNoteOff(midi);
|
||||
const onStateChange = ({ status }: { status: string; seconds: number }) => {
|
||||
if (status === "stopped") kb.reset();
|
||||
};
|
||||
const onSeek = () => kb.reset();
|
||||
|
||||
bus.on("note:on", onNoteOn as never);
|
||||
bus.on("note:off", onNoteOff as never);
|
||||
bus.on("input:note:on", onInputOn as never);
|
||||
bus.on("input:note:off", onInputOff as never);
|
||||
bus.on("transport:stateChange", onStateChange as never);
|
||||
bus.on("transport:seek", onSeek as never);
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.clientWidth;
|
||||
if (w > 0 && kbRef.current) kbRef.current.resize(w, KEYBOARD_HEIGHT);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
return () => {
|
||||
bus.off("note:on", onNoteOn as never);
|
||||
bus.off("note:off", onNoteOff as never);
|
||||
bus.off("input:note:on", onInputOn as never);
|
||||
bus.off("input:note:off", onInputOff as never);
|
||||
bus.off("transport:stateChange", onStateChange as never);
|
||||
bus.off("transport:seek", onSeek as never);
|
||||
ro.disconnect();
|
||||
kb.destroy();
|
||||
kbRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
kbRef.current?.setOptions({
|
||||
noteLabelMode: settings.noteLabelMode,
|
||||
pianoTheme: settings.pianoTheme,
|
||||
showFingering: settings.showFingering,
|
||||
showHandColors: settings.showHandColors,
|
||||
useFlats: settings.useFlats,
|
||||
customColors: settings.colorTheme === "custom"
|
||||
? settings.customColors
|
||||
: COLOR_PRESET_VALUES[settings.colorTheme],
|
||||
});
|
||||
}, [
|
||||
settings.noteLabelMode,
|
||||
settings.pianoTheme,
|
||||
settings.showFingering,
|
||||
settings.showHandColors,
|
||||
settings.useFlats,
|
||||
settings.colorTheme,
|
||||
settings.customColors,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full"
|
||||
style={{
|
||||
height: KEYBOARD_HEIGHT,
|
||||
// Dark keybed: shows through key gaps and as the pressed-key hinge -
|
||||
// matches the look of a real piano's wooden frame regardless of app theme.
|
||||
background: "#0d0d10",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import bus from "../../engine/EventBus";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { syncEngine } from "../../engine/SyncEngine";
|
||||
import { keySignatureToLabel } from "@bach-to-basics/shared";
|
||||
import type { TempoEvent } from "@bach-to-basics/shared";
|
||||
|
||||
// T3-1: Defense-in-depth: strip potential XSS vectors from MusicXML before
|
||||
// passing to AlphaTab. AlphaTab likely renders SVG programmatically (safe),
|
||||
// but this prevents exploitation if a future version changes that behavior.
|
||||
// NOTE: CDATA sections are valid XML used by MusicXML for text content (tempo
|
||||
// markings, lyrics, etc.) - they must NOT be stripped or AlphaTab receives
|
||||
// malformed XML. AlphaTab processes MusicXML as data, not HTML, so CDATA
|
||||
// poses no XSS risk here.
|
||||
function sanitizeMusicXml(xml: string): string {
|
||||
return xml
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/\bon\w+\s*=/gi, "data-removed=");
|
||||
}
|
||||
|
||||
export function SheetMusicView() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null); // outer overflow-auto container
|
||||
const containerRef = useRef<HTMLDivElement>(null); // inner AlphaTab host
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const apiRef = useRef<any>(null);
|
||||
const playerReady = useRef(false); // true once soundFont is loaded
|
||||
const scoreLoaded = useRef(false); // true once score SVG is rendered
|
||||
const lastScrollTop = useRef(-1);
|
||||
const lastScrollMs = useRef(0);
|
||||
|
||||
// ── AlphaTab init (runs once on mount) ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
let cleanupBus: (() => void) | null = null;
|
||||
|
||||
import("@coderline/alphatab").then(({ AlphaTabApi }) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const api = new AlphaTabApi(containerRef.current, {
|
||||
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
|
||||
display: {
|
||||
layoutMode: 0, // page mode - vertical scroll
|
||||
scale: 0.9,
|
||||
// stretchForce 0 = notes keep their natural rhythmic proportions (like
|
||||
// a printed score). A very small value (0.1) avoids a ragged right
|
||||
// edge on the last system without visibly compressing the spacing.
|
||||
stretchForce: 0.1,
|
||||
staveProfile: 0,
|
||||
},
|
||||
player: {
|
||||
// enablePlayer: true is required for cursor/highlight rendering.
|
||||
// We load the real soundfont so the player initialises fully, then
|
||||
// silence it via masterVolume=0 inside playerReady - our AudioEngine
|
||||
// handles all actual audio output.
|
||||
enablePlayer: true,
|
||||
enableCursor: true,
|
||||
enableUserInteraction: true,
|
||||
soundFont: "/soundfont/sonivox.sf3",
|
||||
scrollMode: 0, // we drive scrolling ourselves
|
||||
},
|
||||
} as never);
|
||||
|
||||
apiRef.current = api;
|
||||
|
||||
// ── Mute AlphaTab AFTER soundFont finishes loading ───────────────────
|
||||
try {
|
||||
api.playerReady.on(() => {
|
||||
playerReady.current = true;
|
||||
api.masterVolume = 0;
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// ── Mark score as rendered so cursor/scroll can activate ─────────────
|
||||
try {
|
||||
api.scoreLoaded.on(() => { scoreLoaded.current = true; });
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// ── Load score if MusicXML is already available ──────────────────────
|
||||
const initialDoc = useAppStore.getState().document;
|
||||
if (initialDoc?.musicXml) {
|
||||
api.load(new TextEncoder().encode(sanitizeMusicXml(initialDoc.musicXml)));
|
||||
} else if (initialDoc?.mxlBuffer) {
|
||||
// .mxl is ZIP-compressed MusicXML; AlphaTab handles it natively
|
||||
api.load(new Uint8Array(initialDoc.mxlBuffer));
|
||||
}
|
||||
|
||||
// ── Click note to play sound ─────────────────────────────────────────
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
api.beatMouseDown.on((beat: any) => {
|
||||
if (!beat?.notes?.length) return;
|
||||
for (const note of beat.notes) syncEngine.playMidi(note.realValue, 80);
|
||||
setTimeout(() => {
|
||||
for (const note of beat.notes) syncEngine.stopMidi(note.realValue);
|
||||
}, 500);
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// ── Transport tick: cursor position + scroll ─────────────────────────
|
||||
// IMPORTANT: do NOT close over `doc` here - it would always be null
|
||||
// (stale closure from first render). Always read fresh state instead.
|
||||
const onTick = ({ seconds }: { seconds: number; tick: number }) => {
|
||||
if (!apiRef.current || !playerReady.current || !scoreLoaded.current) return;
|
||||
|
||||
// Always-fresh doc - avoids stale closure bug
|
||||
const doc = useAppStore.getState().document;
|
||||
if (!doc) return;
|
||||
|
||||
const tick = secondsToTick(seconds, doc.tempoMap ?? [], doc.ppq ?? 480);
|
||||
try {
|
||||
apiRef.current.tickPosition = tick;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll cursor into view - throttled to ~4 fps to avoid jitter
|
||||
const now = performance.now();
|
||||
if (now - lastScrollMs.current < 250) return;
|
||||
lastScrollMs.current = now;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const scrollEl = scrollRef.current; // outer scroll container
|
||||
const innerEl = containerRef.current; // AlphaTab host (cursor lives here)
|
||||
if (!scrollEl || !innerEl) return;
|
||||
// AlphaTab renders absolutely-positioned cursor overlays inside innerEl
|
||||
const cursor =
|
||||
innerEl.querySelector<HTMLElement>(".at-cursor-beat") ??
|
||||
innerEl.querySelector<HTMLElement>(".at-cursor-bar");
|
||||
if (!cursor) return;
|
||||
// cursor position relative to the scrollable content area
|
||||
const cursorTop =
|
||||
cursor.getBoundingClientRect().top -
|
||||
scrollEl.getBoundingClientRect().top +
|
||||
scrollEl.scrollTop;
|
||||
const target = Math.max(0, cursorTop - scrollEl.clientHeight * 0.3);
|
||||
if (Math.abs(target - lastScrollTop.current) > 80) {
|
||||
lastScrollTop.current = target;
|
||||
scrollEl.scrollTo({ top: target, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
bus.on("transport:tick", onTick as never);
|
||||
cleanupBus = () => bus.off("transport:tick", onTick as never);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanupBus?.();
|
||||
playerReady.current = false;
|
||||
scoreLoaded.current = false;
|
||||
if (apiRef.current) {
|
||||
try { apiRef.current.destroy(); } catch {}
|
||||
apiRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []); // intentionally empty - AlphaTab owns its own lifecycle
|
||||
|
||||
// ── Load when MusicXML/mxlBuffer arrives after initial mount ────────────
|
||||
const { document: doc, settings } = useAppStore();
|
||||
const isDark = settings.theme === "dark";
|
||||
const keepWhite = isDark && settings.sheetMusicWhiteBackground;
|
||||
const invertSheet = isDark && !settings.sheetMusicWhiteBackground;
|
||||
|
||||
// ── Adapt stretchForce to layout mode ────────────────────────────────────
|
||||
// "all" mode gives the sheet a narrow shared column, so we keep more
|
||||
// stretching (0.5) to fill each line. In dedicated sheet mode we use
|
||||
// near-natural note spacing (0.1) for a printed-score look.
|
||||
// The value is stored in a ref so the load effect below can read it fresh.
|
||||
const stretchForceRef = useRef(0.1);
|
||||
stretchForceRef.current = settings.layoutMode === "all" ? 0.5 : 0.1;
|
||||
|
||||
useEffect(() => {
|
||||
// Re-render the current score when the user switches between layout modes.
|
||||
// api.load() (below) already reads stretchForceRef before each load, so we
|
||||
// only need an explicit render() here for live layout-mode switches.
|
||||
if (!apiRef.current || !scoreLoaded.current) return;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(apiRef.current.settings as any).display.stretchForce = stretchForceRef.current;
|
||||
apiRef.current.render();
|
||||
} catch { /* ignore */ }
|
||||
}, [settings.layoutMode]);
|
||||
|
||||
// ── Load score when MusicXML/mxlBuffer arrives ───────────────────────────
|
||||
useEffect(() => {
|
||||
if (!apiRef.current) return;
|
||||
// Apply the correct stretchForce before every load so AlphaTab uses it
|
||||
// in the render it triggers internally.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(apiRef.current.settings as any).display.stretchForce = stretchForceRef.current;
|
||||
} catch { /* ignore */ }
|
||||
if (doc?.musicXml) {
|
||||
scoreLoaded.current = false;
|
||||
apiRef.current.load(new TextEncoder().encode(sanitizeMusicXml(doc.musicXml)));
|
||||
} else if (doc?.mxlBuffer) {
|
||||
// .mxl is ZIP-compressed MusicXML; AlphaTab handles it natively
|
||||
scoreLoaded.current = false;
|
||||
apiRef.current.load(new Uint8Array(doc.mxlBuffer));
|
||||
}
|
||||
}, [doc?.musicXml, doc?.mxlBuffer]);
|
||||
|
||||
// Background logic:
|
||||
// light / dark normal: radial gradient + var(--color-notes-bg), matching FallingNotesView
|
||||
// dark + keep white: explicit #fff so black notation stays readable on white
|
||||
// The gradient (7% purple at bottom-center to transparent) is the same subtle glow used
|
||||
// by FallingNotesView and PianoModeBackground, keeping all canvas areas visually consistent.
|
||||
const scrollBg = keepWhite
|
||||
? "#ffffff"
|
||||
: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)";
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto" style={{ background: scrollBg, minHeight: 0, borderTop: "1px solid var(--color-notes-border)" }}>
|
||||
{/*
|
||||
Page-width wrapper — centres the score and caps it at ~A4 screen width
|
||||
(900 px). On wide monitors this creates margins on both sides so the
|
||||
score never looks like a stretched banner; on narrow screens it fills
|
||||
the full width just like before. position:relative is needed so the
|
||||
key-signature badge stays anchored inside the content column.
|
||||
*/}
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: "8px 16px", position: "relative" }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={invertSheet ? "sheet-dark-invert" : ""}
|
||||
/>
|
||||
|
||||
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
|
||||
<div
|
||||
className="absolute top-2 right-4 z-10 pointer-events-none text-xs px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: keepWhite ? "#f5f7fb" : "var(--color-surface-2)",
|
||||
border: `1px solid ${keepWhite ? "#dde1ea" : "var(--color-border)"}`,
|
||||
color: keepWhite ? "#64748b" : "var(--color-text-muted)",
|
||||
letterSpacing: "0.03em",
|
||||
}}
|
||||
>
|
||||
{keySignatureToLabel(doc.keySignature)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// AlphaTab uses 960 ticks per quarter note internally, regardless of the MIDI file's PPQ.
|
||||
// This function converts playback seconds to AlphaTab internal ticks (960 PPQ output).
|
||||
// The tempo map uses MIDI-file ticks for segment boundaries, so we still need `ppq`
|
||||
// to convert those boundaries to seconds, but the output is always in 960-PPQ space.
|
||||
const AT_PPQ = 960;
|
||||
|
||||
function secondsToTick(targetSec: number, tempoMap: TempoEvent[], ppq: number): number {
|
||||
if (!tempoMap.length) return Math.round(targetSec * 2 * AT_PPQ);
|
||||
let atTick = 0; // accumulated output in AlphaTab 960-PPQ ticks
|
||||
let remainingSec = targetSec;
|
||||
|
||||
for (let i = 0; i < tempoMap.length; i++) {
|
||||
const ev = tempoMap[i];
|
||||
const next = tempoMap[i + 1];
|
||||
|
||||
// seconds per MIDI tick at this tempo
|
||||
const secPerMidiTick = 60 / (ev.bpm * ppq);
|
||||
// AlphaTab ticks per MIDI tick (scale factor between file PPQ and AT PPQ)
|
||||
const atPerMidi = AT_PPQ / ppq;
|
||||
|
||||
if (next) {
|
||||
const midiSpan = next.tick - ev.tick; // segment length in MIDI ticks
|
||||
const secSpan = midiSpan * secPerMidiTick;
|
||||
if (remainingSec <= secSpan) {
|
||||
atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi);
|
||||
return atTick;
|
||||
}
|
||||
remainingSec -= secSpan;
|
||||
atTick += Math.round(midiSpan * atPerMidi);
|
||||
} else {
|
||||
// Last segment - extends to end of piece
|
||||
atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi);
|
||||
return atTick;
|
||||
}
|
||||
}
|
||||
return atTick;
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { syncEngine } from "../../engine/SyncEngine";
|
||||
|
||||
function formatTime(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${Math.floor(s % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function TransportControls() {
|
||||
const {
|
||||
status, currentSeconds, tempoMultiplier,
|
||||
loopStart, loopEnd, waitMode,
|
||||
document: doc,
|
||||
play, pause, stop, seek,
|
||||
setTempoMultiplier, setLoopPoints, setWaitMode, setMetronome,
|
||||
settings, loadMidiFile, loadMusicXmlFile, loadPdfFile,
|
||||
isLoadingDocument, loadingMessage,
|
||||
} = useAppStore();
|
||||
|
||||
const [speedOpen, setSpeedOpen] = useState(false);
|
||||
const [speedRect, setSpeedRect] = useState<{ bottom: number; right: number } | null>(null);
|
||||
const speedContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// When opening, capture fixed position so popover escapes the overflow:auto container
|
||||
const toggleSpeed = () => {
|
||||
if (!speedOpen && speedContainerRef.current) {
|
||||
const r = speedContainerRef.current.getBoundingClientRect();
|
||||
setSpeedRect({ bottom: window.innerHeight - r.top + 8, right: window.innerWidth - r.right });
|
||||
}
|
||||
setSpeedOpen((v) => !v);
|
||||
};
|
||||
|
||||
// Close speed popover on outside click
|
||||
useEffect(() => {
|
||||
if (!speedOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!speedContainerRef.current?.contains(e.target as Node)) setSpeedOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [speedOpen]);
|
||||
|
||||
const openFile = (file: File) => {
|
||||
if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
|
||||
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file);
|
||||
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
|
||||
};
|
||||
|
||||
const total = doc?.totalDuration ?? 0;
|
||||
const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0;
|
||||
const loopActive = loopStart !== null && loopEnd !== null;
|
||||
const isPlaying = status === "playing";
|
||||
const isPaused = status === "paused";
|
||||
const isStopped = status === "stopped";
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!total) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
seek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * total);
|
||||
};
|
||||
|
||||
const toggleHand = (hand: "left" | "right") => {
|
||||
const next = new Set(settings.activeHands);
|
||||
if (next.has(hand)) { if (next.size > 1) next.delete(hand); }
|
||||
else next.add(hand);
|
||||
useAppStore.getState().setActiveHands(next);
|
||||
};
|
||||
|
||||
const startLoop = () => {
|
||||
const start = currentSeconds;
|
||||
const end = Math.min(total, start + 30);
|
||||
setLoopPoints(start, end > start ? end : total);
|
||||
};
|
||||
const clearLoop = () => setLoopPoints(null, null);
|
||||
const setLoopA = () => { if (loopEnd !== null) setLoopPoints(Math.min(currentSeconds, loopEnd - 1), loopEnd); };
|
||||
const setLoopB = () => { if (loopStart !== null) setLoopPoints(loopStart, Math.max(currentSeconds, loopStart + 1)); };
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1.5 pt-2 pb-2 flex-shrink-0 select-none"
|
||||
style={{ borderTop: "1px solid var(--color-border)", background: "var(--color-surface)", paddingLeft: 12, paddingRight: 12 }}
|
||||
>
|
||||
{/* ── Progress bar ──────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2" style={{ marginLeft: -12, marginRight: -12 }}>
|
||||
<span
|
||||
className="text-xs tabular-nums w-9 shrink-0 text-right"
|
||||
style={{ color: "var(--color-text-muted)", paddingLeft: 12 }}
|
||||
aria-label="current time"
|
||||
>
|
||||
{formatTime(currentSeconds)}
|
||||
</span>
|
||||
|
||||
{/* Enlarged hit area (vertical padding) around the visual bar */}
|
||||
<div
|
||||
className="flex-1 relative cursor-pointer"
|
||||
style={{ padding: "8px 0", margin: "-8px 0" }}
|
||||
onClick={handleProgressClick}
|
||||
role="slider"
|
||||
aria-label="Playback position"
|
||||
aria-valuenow={Math.round(progress * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="relative rounded-full" style={{ height: 6, background: "var(--color-surface-3)" }}>
|
||||
{/* Loop region */}
|
||||
{loopActive && total > 0 && (
|
||||
<div
|
||||
className="absolute top-0 h-full"
|
||||
style={{
|
||||
left: `${(loopStart! / total) * 100}%`,
|
||||
width: `${((loopEnd! - loopStart!) / total) * 100}%`,
|
||||
background: "var(--color-accent)",
|
||||
opacity: 0.25,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Playhead fill */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{ width: `${progress * 100}%`, background: "var(--color-accent)" }}
|
||||
/>
|
||||
{/* Thumb - clamped so it never bleeds outside the track */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: `clamp(7px, ${progress * 100}%, calc(100% - 7px))`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: "50%",
|
||||
background: "white",
|
||||
boxShadow: "0 0 0 2px var(--color-accent), 0 1px 4px rgba(0,0,0,0.3)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="text-xs tabular-nums w-9 shrink-0"
|
||||
style={{ color: "var(--color-text-muted)", paddingRight: 12 }}
|
||||
aria-label="total duration"
|
||||
>
|
||||
{formatTime(total)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Controls row ──────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center min-w-0" style={{ gap: 6, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
|
||||
{/* ── Zone 1: File import - accent-styled to stand out ── */}
|
||||
<label
|
||||
title="Open MIDI, MusicXML, or PDF sheet music"
|
||||
aria-label="Import MIDI, MusicXML, or PDF file"
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 5,
|
||||
height: 32, padding: "0 12px",
|
||||
borderRadius: 7, cursor: "pointer",
|
||||
fontSize: 12, fontWeight: 600, fontFamily: "inherit",
|
||||
flexShrink: 0,
|
||||
background: "rgba(147,51,234,0.14)",
|
||||
border: "1px solid rgba(147,51,234,0.35)",
|
||||
color: "var(--color-accent-text)",
|
||||
transition: "background 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.24)")}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)")}
|
||||
>
|
||||
{isLoadingDocument ? <SpinnerIcon /> : <UploadIcon />}
|
||||
{isLoadingDocument && loadingMessage
|
||||
? <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{loadingMessage}</span>
|
||||
: <span>Import</span>
|
||||
}
|
||||
<input
|
||||
type="file"
|
||||
accept=".mid,.midi,.xml,.mxl,.pdf"
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) openFile(f); e.target.value = ""; }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Zone 2: Playback controls ───────────────────────── */}
|
||||
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
|
||||
<Btn onClick={stop} title="Stop and return to start" aria-label="Stop" disabled={isStopped}>
|
||||
<StopIcon />
|
||||
</Btn>
|
||||
<Btn onClick={() => syncEngine.skipBy(-10)} title="Skip back 10 seconds" aria-label="Skip back 10 seconds" disabled={!doc}>
|
||||
<SkipBackIcon />
|
||||
</Btn>
|
||||
<Btn
|
||||
primary
|
||||
onClick={isPlaying ? pause : () => play()}
|
||||
title={isPlaying ? "Pause" : isPaused ? "Resume" : "Play"}
|
||||
aria-label={isPlaying ? "Pause" : isPaused ? "Resume playback" : "Play"}
|
||||
disabled={!doc}
|
||||
>
|
||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
</Btn>
|
||||
<Btn onClick={() => syncEngine.skipBy(10)} title="Skip forward 10 seconds" aria-label="Skip forward 10 seconds" disabled={!doc}>
|
||||
<SkipFwdIcon />
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Zone 4: Hands ───────────────────────────────────── */}
|
||||
<div className="flex items-center shrink-0" style={{ gap: 5 }}>
|
||||
{(["left", "right"] as const).map((h) => {
|
||||
const active = settings.activeHands.has(h);
|
||||
const isLeft = h === "left";
|
||||
return (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => toggleHand(h)}
|
||||
title={`${active ? "Mute" : "Unmute"} ${h} hand`}
|
||||
aria-label={`${active ? "Mute" : "Unmute"} ${h} hand`}
|
||||
aria-pressed={active}
|
||||
className="rounded text-xs font-bold transition-all"
|
||||
style={{
|
||||
width: 28, height: 28,
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
...(active ? {
|
||||
background: isLeft ? "var(--color-left-hand-subtle)" : "var(--color-right-hand-subtle)",
|
||||
color: isLeft ? "var(--color-left-hand-text)" : "var(--color-right-hand-text)",
|
||||
border: isLeft
|
||||
? "1px solid var(--color-left-hand-subtle-border)"
|
||||
: "1px solid var(--color-right-hand-subtle-border)",
|
||||
} : {
|
||||
background: "var(--color-surface-2)",
|
||||
color: "var(--color-text-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
opacity: 0.5,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{isLeft ? "L" : "R"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{/* ── Zone 5: Practice tools ──────────────────────────── */}
|
||||
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
|
||||
|
||||
<ChipBtn active={waitMode} onClick={() => setWaitMode(!waitMode)} title="Pause until you play the correct note" aria-label="Wait mode" aria-pressed={waitMode}>
|
||||
<WaitIcon /> Wait
|
||||
</ChipBtn>
|
||||
|
||||
{/* ── A / Loop / B - single compound grouped control ── */}
|
||||
<div
|
||||
className="flex items-center shrink-0"
|
||||
style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 7,
|
||||
overflow: "hidden",
|
||||
background: "var(--color-surface-2)",
|
||||
}}
|
||||
>
|
||||
{/* A marker */}
|
||||
<button
|
||||
onClick={setLoopA}
|
||||
disabled={!loopActive}
|
||||
title="Set loop start (A) to current position"
|
||||
aria-label="Set loop start to current position"
|
||||
style={{
|
||||
height: 28, padding: "0 8px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
||||
fontSize: 11, fontWeight: 700, fontFamily: "inherit",
|
||||
cursor: loopActive ? "pointer" : "default",
|
||||
opacity: loopActive ? 1 : 0.5,
|
||||
transition: "all 0.12s",
|
||||
}}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
|
||||
{/* Loop toggle (middle) */}
|
||||
<button
|
||||
onClick={loopActive ? clearLoop : startLoop}
|
||||
disabled={!doc}
|
||||
title={loopActive ? "Clear loop" : "Start A/B loop from current position"}
|
||||
aria-label={loopActive ? "Clear loop" : "Start loop"}
|
||||
aria-pressed={loopActive}
|
||||
style={{
|
||||
height: 28, padding: "0 9px",
|
||||
background: loopActive ? "var(--color-accent-subtle)" : "transparent",
|
||||
border: "none",
|
||||
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
||||
fontSize: 11, fontWeight: 500, fontFamily: "inherit",
|
||||
cursor: doc ? "pointer" : "default",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
transition: "all 0.12s",
|
||||
}}
|
||||
>
|
||||
<LoopIcon />
|
||||
{loopActive ? `${formatTime(loopStart!)}-${formatTime(loopEnd!)}` : "Loop"}
|
||||
</button>
|
||||
|
||||
{/* B marker */}
|
||||
<button
|
||||
onClick={setLoopB}
|
||||
disabled={!loopActive}
|
||||
title="Set loop end (B) to current position"
|
||||
aria-label="Set loop end to current position"
|
||||
style={{
|
||||
height: 28, padding: "0 8px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderLeft: "1px solid var(--color-border)",
|
||||
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
||||
fontSize: 11, fontWeight: 700, fontFamily: "inherit",
|
||||
cursor: loopActive ? "pointer" : "default",
|
||||
opacity: loopActive ? 1 : 0.5,
|
||||
transition: "all 0.12s",
|
||||
}}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ChipBtn
|
||||
active={settings.metronomeEnabled}
|
||||
onClick={() => setMetronome(!settings.metronomeEnabled)}
|
||||
title="Metronome (auto-syncs to MIDI BPM)"
|
||||
aria-label="Toggle metronome"
|
||||
aria-pressed={settings.metronomeEnabled}
|
||||
>
|
||||
<MetronomeIcon /> Metronome
|
||||
</ChipBtn>
|
||||
|
||||
{/* ── Speed chip with popover ─────────────────────────── */}
|
||||
<div className="relative shrink-0" ref={speedContainerRef}>
|
||||
<ChipBtn
|
||||
active={tempoMultiplier !== 1.0 || speedOpen}
|
||||
onClick={toggleSpeed}
|
||||
title="Playback speed"
|
||||
aria-label="Playback speed"
|
||||
aria-pressed={speedOpen}
|
||||
>
|
||||
<SpeedIcon />
|
||||
{tempoMultiplier !== 1.0 ? `${Math.round(tempoMultiplier * 100)}%` : "Speed"}
|
||||
</ChipBtn>
|
||||
|
||||
{speedOpen && speedRect && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: speedRect.bottom,
|
||||
right: speedRect.right,
|
||||
width: 220,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 10,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
||||
zIndex: 100,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text)" }}>Playback Speed</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--color-accent-text)", fontVariantNumeric: "tabular-nums" }}>
|
||||
{Math.round(tempoMultiplier * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={25} max={200} step={5}
|
||||
value={Math.round(tempoMultiplier * 100)}
|
||||
onChange={(e) => setTempoMultiplier(Number(e.target.value) / 100)}
|
||||
aria-label="Playback speed"
|
||||
style={{ width: "100%", accentColor: "var(--color-accent)" }}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: "var(--color-text-muted)", marginTop: 4 }}>
|
||||
<span>25%</span>
|
||||
<span>100%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
{tempoMultiplier !== 1.0 && (
|
||||
<button
|
||||
onClick={() => setTempoMultiplier(1.0)}
|
||||
style={{
|
||||
marginTop: 10, width: "100%",
|
||||
background: "var(--color-surface-2)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
color: "var(--color-accent-text)",
|
||||
fontSize: 11, fontWeight: 600, fontFamily: "inherit",
|
||||
padding: "5px 0",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Reset to 100%
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Waiting status pill */}
|
||||
{status === "waiting" && (
|
||||
<span
|
||||
className="text-xs animate-pulse ml-1 shrink-0"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
aria-live="polite"
|
||||
aria-label="Waiting for you to play the correct note"
|
||||
>
|
||||
● Waiting…
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function Btn({ onClick, title, "aria-label": ariaLabel, children, primary, disabled }: {
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
"aria-label"?: string;
|
||||
children: React.ReactNode;
|
||||
primary?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`t-btn${primary ? " primary" : ""}`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel ?? title}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-pressed": ariaPressed, children, disabled }: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
"aria-label"?: string;
|
||||
"aria-pressed"?: boolean;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel ?? title}
|
||||
aria-pressed={ariaPressed}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1 rounded text-xs font-medium transition-all shrink-0"
|
||||
style={{
|
||||
height: 28, padding: "0 8px",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
fontFamily: "inherit",
|
||||
...(active
|
||||
? { background: "var(--color-accent-subtle)", color: "var(--color-accent-text)", border: "1px solid var(--color-accent-subtle-border)" }
|
||||
: { background: "var(--color-surface-2)", color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="shrink-0" style={{ width: 1, height: 20, background: "var(--color-border)", margin: "0 2px" }} />;
|
||||
}
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
const PlayIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>;
|
||||
const PauseIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>;
|
||||
const StopIcon = () => <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="1"/></svg>;
|
||||
const SkipBackIcon = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text>
|
||||
</svg>
|
||||
);
|
||||
const SkipFwdIcon = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text>
|
||||
</svg>
|
||||
);
|
||||
const UploadIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/></svg>;
|
||||
const SpinnerIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ animation: "spin 1s linear infinite" }}><path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/></svg>;
|
||||
|
||||
const WaitIcon = () => (
|
||||
<svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor">
|
||||
<rect x="3.5" y="10" width="13" height="7" rx="1.5"/>
|
||||
<rect x="4" y="2.5" width="2.5" height="9" rx="1.25"/>
|
||||
<rect x="7.5" y="1" width="2.5" height="10.5" rx="1.25"/>
|
||||
<rect x="11" y="1.5" width="2.5" height="10" rx="1.25"/>
|
||||
<rect x="14" y="4" width="2.5" height="7.5" rx="1.25"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LoopIcon = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.68 14.72 20 13.41 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.32 9.28 4 10.59 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SpeedIcon = () => (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 2v11h3v9l7-12h-4l4-8z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MetronomeIcon = () => (
|
||||
<svg width="10" height="10" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 2L2 18h16L10 2z"/>
|
||||
<line x1="10" y1="15" x2="13.5" y2="6"/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* AccuracyTracker - real-time scoring of user input vs. expected notes.
|
||||
*
|
||||
* Strategy:
|
||||
* - When a note:on fires, record it in a "window" of expected notes.
|
||||
* - When the user plays (input:note:on), check if any expected note in the
|
||||
* ±HIT_WINDOW_MS tolerance matches the MIDI number. If yes: correct hit.
|
||||
* - When a note's off-time passes without a matching input: missed.
|
||||
* - If the user plays a MIDI that doesn't match any expected note: wrong.
|
||||
*
|
||||
* Emits bus event "accuracy:update" with running totals after every event.
|
||||
*/
|
||||
|
||||
import bus from "./EventBus";
|
||||
import type { NoteEvent } from "@bach-to-basics/shared";
|
||||
|
||||
/** How many milliseconds before/after note start counts as a "hit" */
|
||||
const HIT_WINDOW_MS = 300;
|
||||
|
||||
interface PendingNote {
|
||||
note: NoteEvent;
|
||||
startMs: number; // performance.now() when note:on fired
|
||||
hit: boolean;
|
||||
}
|
||||
|
||||
class AccuracyTracker {
|
||||
private enabled = false;
|
||||
private pending: PendingNote[] = [];
|
||||
|
||||
private correct = 0;
|
||||
private wrong = 0;
|
||||
private missed = 0;
|
||||
|
||||
constructor() {
|
||||
bus.on("note:on", (note) => this.onNoteOn(note as NoteEvent));
|
||||
bus.on("note:off", (note) => this.onNoteOff(note as NoteEvent));
|
||||
bus.on("input:note:on", ({ midi }) => this.onInput(midi));
|
||||
bus.on("transport:stateChange", ({ status }) => {
|
||||
if (status === "stopped") this.reset();
|
||||
});
|
||||
bus.on("transport:seek", () => {
|
||||
// Clear pending notes on seek - they're no longer relevant
|
||||
this.pending = [];
|
||||
});
|
||||
}
|
||||
|
||||
enable(on: boolean) {
|
||||
this.enabled = on;
|
||||
if (!on) this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.pending = [];
|
||||
this.correct = 0;
|
||||
this.wrong = 0;
|
||||
this.missed = 0;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
get score(): number {
|
||||
const total = this.correct + this.wrong + this.missed;
|
||||
return total === 0 ? 0 : Math.round((this.correct / total) * 100);
|
||||
}
|
||||
|
||||
private onNoteOn(note: NoteEvent) {
|
||||
if (!this.enabled) return;
|
||||
this.pending.push({ note, startMs: performance.now(), hit: false });
|
||||
}
|
||||
|
||||
private onNoteOff(note: NoteEvent) {
|
||||
if (!this.enabled) return;
|
||||
const idx = this.pending.findIndex((p) => p.note.id === note.id);
|
||||
if (idx === -1) return;
|
||||
const [pending] = this.pending.splice(idx, 1);
|
||||
if (!pending.hit) {
|
||||
this.missed++;
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
private onInput(midi: number) {
|
||||
if (!this.enabled) return;
|
||||
const now = performance.now();
|
||||
|
||||
// Find the closest pending note matching this MIDI within the hit window
|
||||
let bestIdx = -1;
|
||||
let bestDelta = Infinity;
|
||||
for (let i = 0; i < this.pending.length; i++) {
|
||||
const p = this.pending[i];
|
||||
if (p.hit) continue;
|
||||
if (p.note.midi !== midi) continue;
|
||||
const delta = Math.abs(now - p.startMs);
|
||||
if (delta <= HIT_WINDOW_MS && delta < bestDelta) {
|
||||
bestDelta = delta;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx >= 0) {
|
||||
this.pending[bestIdx].hit = true;
|
||||
this.correct++;
|
||||
} else {
|
||||
this.wrong++;
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit() {
|
||||
bus.emit("accuracy:update", {
|
||||
correct: this.correct,
|
||||
wrong: this.wrong,
|
||||
missed: this.missed,
|
||||
score: this.score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const accuracyTracker = new AccuracyTracker();
|
||||
export default accuracyTracker;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { SplendidGrandPiano, Soundfont, ElectricPiano } from "smplr";
|
||||
import type { NoteEvent } from "@bach-to-basics/shared";
|
||||
|
||||
// ── Instrument catalogue ──────────────────────────────────────────────────────
|
||||
|
||||
export type InstrumentId =
|
||||
| "grand" // Splendid Grand Piano (SplendidGrandPiano)
|
||||
| "bright" // Bright Acoustic Piano (Soundfont)
|
||||
| "electric" // CP80 Electric Grand (ElectricPiano)
|
||||
| "harpsichord" // Harpsichord (Soundfont)
|
||||
| "honkytonk"; // Honky-Tonk Piano (Soundfont)
|
||||
|
||||
export const INSTRUMENT_LABELS: Record<InstrumentId, string> = {
|
||||
grand: "Grand",
|
||||
bright: "Bright",
|
||||
electric: "E-Piano",
|
||||
harpsichord: "Harpsi",
|
||||
honkytonk: "Honky",
|
||||
};
|
||||
|
||||
// Minimal common interface shared by all smplr players
|
||||
type Player = {
|
||||
load: Promise<unknown>;
|
||||
start(opts: { note: number; velocity?: number; stopId?: number | string }): unknown;
|
||||
stop(opts?: { stopId?: number | string }): void;
|
||||
};
|
||||
|
||||
function createPlayer(id: InstrumentId, ctx: AudioContext): Player {
|
||||
switch (id) {
|
||||
case "grand":
|
||||
return new SplendidGrandPiano(ctx) as unknown as Player;
|
||||
case "bright":
|
||||
return new Soundfont(ctx, { instrument: "bright_acoustic_piano" }) as unknown as Player;
|
||||
case "electric":
|
||||
return new ElectricPiano(ctx, { instrument: "CP80" }) as unknown as Player;
|
||||
case "harpsichord":
|
||||
return new Soundfont(ctx, { instrument: "harpsichord" }) as unknown as Player;
|
||||
case "honkytonk":
|
||||
return new Soundfont(ctx, { instrument: "honkytonk_piano" }) as unknown as Player;
|
||||
}
|
||||
}
|
||||
|
||||
// ── AudioEngine ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class AudioEngine {
|
||||
private ctx: AudioContext | null = null;
|
||||
private player: Player | null = null;
|
||||
private _currentId: InstrumentId = "grand";
|
||||
private _loaded = false;
|
||||
// Dedup: all callers await the same promise so we never create two AudioContexts
|
||||
private _loadPromise: Promise<void> | null = null;
|
||||
|
||||
async load() {
|
||||
if (this._loaded) return;
|
||||
if (this._loadPromise) return this._loadPromise;
|
||||
this._loadPromise = (async () => {
|
||||
if (!this.ctx) this.ctx = new AudioContext();
|
||||
this.player = createPlayer(this._currentId, this.ctx);
|
||||
await this.player.load;
|
||||
this._loaded = true;
|
||||
})();
|
||||
return this._loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different instrument. If the AudioContext already exists the
|
||||
* new instrument is pre-loaded immediately so the next note plays without delay.
|
||||
*/
|
||||
async setInstrument(id: InstrumentId): Promise<void> {
|
||||
if (id === this._currentId && this._loaded) return;
|
||||
this._currentId = id;
|
||||
this._loaded = false;
|
||||
this._loadPromise = null;
|
||||
// Silence any ongoing audio before swapping the player
|
||||
try { this.player?.stop?.(); } catch { /* ignore */ }
|
||||
this.player = null;
|
||||
// Pre-load the new instrument if an AudioContext already exists
|
||||
if (this.ctx) await this.load();
|
||||
}
|
||||
|
||||
get loaded() {
|
||||
return this._loaded;
|
||||
}
|
||||
|
||||
playNote(note: NoteEvent) {
|
||||
if (!this.player || !this._loaded) return;
|
||||
// Resume in case the AudioContext was created before a user gesture (browser
|
||||
// autoplay policy starts it suspended). Fire-and-forget, resolves quickly.
|
||||
this.ctx?.resume();
|
||||
this.player.start({ note: note.midi, velocity: note.velocity, stopId: note.midi });
|
||||
}
|
||||
|
||||
/** Play a single MIDI note by number - for virtual keyboard clicks. */
|
||||
playMidi(midi: number, velocity = 90) {
|
||||
if (!this.player || !this._loaded) return;
|
||||
// Resume in case the AudioContext was created before a user gesture.
|
||||
this.ctx?.resume();
|
||||
this.player.start({ note: midi, velocity, stopId: midi });
|
||||
}
|
||||
|
||||
stopNote(midi: number) {
|
||||
if (!this.player || !this._loaded) return;
|
||||
this.player.stop({ stopId: midi });
|
||||
}
|
||||
|
||||
stopAll() {
|
||||
try { this.player?.stop?.(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import mitt, { type Emitter } from "mitt";
|
||||
import type { SyncEventMap } from "@bach-to-basics/shared";
|
||||
|
||||
// mitt's generic constraint requires Record<string | symbol, unknown>.
|
||||
// We cast our typed map through a compatible intermediate type.
|
||||
type BusEvents = SyncEventMap & Record<string | symbol, unknown>;
|
||||
|
||||
const bus = mitt<BusEvents>();
|
||||
|
||||
export type EventBus = Emitter<BusEvents>;
|
||||
export default bus;
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as Tone from "tone";
|
||||
|
||||
export type TickCallback = (docSeconds: number) => void;
|
||||
|
||||
/**
|
||||
* MidiClock tracks position in "doc-time" (the song's native seconds, same
|
||||
* unit as NoteEvent.startSeconds). Playing at tempoMultiplier 0.5 means the
|
||||
* song advances at half speed relative to real time:
|
||||
*
|
||||
* docSeconds = _startOffset + Transport.seconds * tempoMultiplier
|
||||
*
|
||||
* This keeps progress-bar / seek values always in doc-time (0..totalDuration).
|
||||
* Audio scheduling converts doc-time differences to real-time via / multiplier.
|
||||
*/
|
||||
export class MidiClock {
|
||||
private onTickCb: TickCallback | null = null;
|
||||
private _tempoMultiplier = 1.0;
|
||||
private _startOffset = 0; // doc-seconds we started / seeked from
|
||||
|
||||
get tempoMultiplier() { return this._tempoMultiplier; }
|
||||
|
||||
set tempoMultiplier(v: number) {
|
||||
const current = this.currentSeconds; // preserve position
|
||||
this._tempoMultiplier = Math.max(0.25, Math.min(2.0, v));
|
||||
// Recompute offset so currentSeconds stays the same after the change
|
||||
this._startOffset = current - Tone.getTransport().seconds * this._tempoMultiplier;
|
||||
}
|
||||
|
||||
onTick(cb: TickCallback) { this.onTickCb = cb; }
|
||||
|
||||
async start(offsetDocSeconds = 0) {
|
||||
await Tone.start();
|
||||
this._startOffset = offsetDocSeconds;
|
||||
Tone.getTransport().cancel();
|
||||
Tone.getTransport().seconds = 0;
|
||||
// BPM at 120, 16n fires every 0.125 real seconds. Fine for tight ticks.
|
||||
Tone.getTransport().bpm.value = 120;
|
||||
|
||||
Tone.getTransport().scheduleRepeat(() => {
|
||||
this.onTickCb?.(this.currentSeconds);
|
||||
}, "16n");
|
||||
|
||||
Tone.getTransport().start();
|
||||
}
|
||||
|
||||
pause() { Tone.getTransport().pause(); }
|
||||
resume() { Tone.getTransport().start(); }
|
||||
|
||||
stop() {
|
||||
Tone.getTransport().stop();
|
||||
Tone.getTransport().cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after an external Tone.getTransport().cancel() to rebuild the
|
||||
* scheduleRepeat that drives onTick(). Must be called before resume().
|
||||
*/
|
||||
reschedule(docSeconds: number) {
|
||||
this._startOffset = docSeconds;
|
||||
Tone.getTransport().seconds = 0;
|
||||
Tone.getTransport().scheduleRepeat(() => {
|
||||
this.onTickCb?.(this.currentSeconds);
|
||||
}, "16n");
|
||||
}
|
||||
|
||||
seek(docSeconds: number) {
|
||||
this._startOffset = docSeconds;
|
||||
Tone.getTransport().seconds = 0;
|
||||
}
|
||||
|
||||
get currentSeconds(): number {
|
||||
// Real Transport.seconds * tempo = how far we've advanced through the song
|
||||
return this._startOffset + Tone.getTransport().seconds * this._tempoMultiplier;
|
||||
}
|
||||
|
||||
get state(): "started" | "stopped" | "paused" {
|
||||
return Tone.getTransport().state as "started" | "stopped" | "paused";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
import * as Tone from "tone";
|
||||
import bus from "./EventBus";
|
||||
import { MidiClock } from "./MidiClock";
|
||||
import { AudioEngine } from "./AudioEngine";
|
||||
import type { InstrumentId } from "./AudioEngine";
|
||||
import type { MusicDocument, NoteEvent, PlaybackStatus } from "@bach-to-basics/shared";
|
||||
|
||||
interface YTPlayer {
|
||||
getCurrentTime(): number;
|
||||
seekTo(seconds: number, allowSeekAhead: boolean): void;
|
||||
}
|
||||
|
||||
interface SpeedTrainerOpts {
|
||||
enabled: boolean;
|
||||
startPct: number;
|
||||
endPct: number;
|
||||
stepPct: number;
|
||||
currentPct: number;
|
||||
}
|
||||
|
||||
// How many doc-seconds ahead to emit "note:upcoming" for FallingNotes pre-rendering.
|
||||
const LOOKAHEAD_DOC_SECONDS = 5.0;
|
||||
const YT_DRIFT_THRESHOLD = 0.25;
|
||||
|
||||
export interface SyncEngineState {
|
||||
status: PlaybackStatus;
|
||||
currentSeconds: number;
|
||||
tempoMultiplier: number;
|
||||
loopStart: number | null;
|
||||
loopEnd: number | null;
|
||||
waitMode: boolean;
|
||||
activeHands: Set<"left" | "right">;
|
||||
metronomeEnabled: boolean;
|
||||
handVolume: { left: number; right: number };
|
||||
/** Which hand(s) trigger wait-mode pauses. Only relevant when waitMode is on. */
|
||||
waitForHand: "left" | "right" | "both";
|
||||
}
|
||||
|
||||
class SyncEngine {
|
||||
private clock = new MidiClock();
|
||||
readonly audio = new AudioEngine();
|
||||
|
||||
private doc: MusicDocument | null = null;
|
||||
private noteIndex = 0;
|
||||
private activeNotes = new Set<string>();
|
||||
|
||||
private _state: SyncEngineState = {
|
||||
status: "stopped",
|
||||
currentSeconds: 0,
|
||||
tempoMultiplier: 1.0,
|
||||
loopStart: null,
|
||||
loopEnd: null,
|
||||
waitMode: false,
|
||||
activeHands: new Set(["left", "right"]),
|
||||
metronomeEnabled: false,
|
||||
handVolume: { left: 1, right: 1 },
|
||||
waitForHand: "both",
|
||||
};
|
||||
|
||||
private ytPlayer: YTPlayer | null = null;
|
||||
private animFrameId: number | null = null;
|
||||
private stateListeners = new Set<(s: SyncEngineState) => void>();
|
||||
|
||||
// Metronome
|
||||
private metronome: Tone.Synth | null = null;
|
||||
private lastBeatCrossed = -1;
|
||||
|
||||
// ── Virtual keyboard pending-play tracking ──────────────────────────────────
|
||||
// Tracks MIDI notes that are awaiting audio-load before playing.
|
||||
// If the user releases a key before the load completes, the entry is removed
|
||||
// so the note never fires - preventing stuck/echoing notes.
|
||||
private pendingMidiPlays = new Set<number>();
|
||||
|
||||
// ── Practice features ───────────────────────────────────────────────────────
|
||||
private renderOffsetMs = 0;
|
||||
private transposeSemitones = 0;
|
||||
private countInBars: 0 | 1 | 2 = 0;
|
||||
private countInTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
private speedTrainer: SpeedTrainerOpts = {
|
||||
enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.clock.onTick(this.onTick.bind(this));
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
async loadDocument(doc: MusicDocument) {
|
||||
this.stop();
|
||||
this.doc = doc;
|
||||
this.noteIndex = 0;
|
||||
this.activeNotes.clear();
|
||||
this._state.currentSeconds = 0;
|
||||
bus.emit("document:loaded", { id: doc.id, title: doc.title, totalDuration: doc.totalDuration });
|
||||
this.emitState("stopped");
|
||||
}
|
||||
|
||||
async play(fromSeconds?: number) {
|
||||
if (!this.doc) return;
|
||||
await this.audio.load();
|
||||
|
||||
const startFrom = fromSeconds ?? this._state.currentSeconds;
|
||||
|
||||
// Speed trainer: reset currentPct when starting from the very beginning
|
||||
if (this.speedTrainer.enabled && startFrom === 0) {
|
||||
this.speedTrainer.currentPct = this.speedTrainer.startPct;
|
||||
this._state.tempoMultiplier = this.speedTrainer.startPct / 100;
|
||||
this.clock.tempoMultiplier = this._state.tempoMultiplier;
|
||||
bus.emit("speedTrainer:stepped", { currentPct: this.speedTrainer.startPct });
|
||||
}
|
||||
|
||||
if (this.countInBars > 0) {
|
||||
await this._playWithCountIn(startFrom);
|
||||
} else {
|
||||
this.seekTo(startFrom);
|
||||
await this.clock.start(startFrom);
|
||||
this.startAnimLoop();
|
||||
this.emitState("playing");
|
||||
}
|
||||
}
|
||||
|
||||
private async _playWithCountIn(startFrom: number) {
|
||||
const bpm = this.doc!.tempoMap[0]?.bpm ?? 120;
|
||||
const timeSig = this.doc!.timeSignatures[0] ?? { numerator: 4, denominator: 4 };
|
||||
const beatsPerBar = timeSig.numerator;
|
||||
// Beat duration respects current tempo multiplier so count-in matches playback speed
|
||||
const beatDurMs = (60 / bpm) / this._state.tempoMultiplier * 1000;
|
||||
const totalBeats = this.countInBars * beatsPerBar;
|
||||
|
||||
// Ensure metronome synth exists
|
||||
if (!this.metronome) {
|
||||
this.metronome = new Tone.Synth({
|
||||
oscillator: { type: "triangle" },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.02 },
|
||||
volume: -8,
|
||||
}).toDestination();
|
||||
}
|
||||
|
||||
this.emitState("playing"); // Show as playing during count-in
|
||||
|
||||
// Schedule one click per beat
|
||||
for (let b = 0; b < totalBeats; b++) {
|
||||
const isAccent = b % beatsPerBar === 0;
|
||||
const id = setTimeout(() => {
|
||||
try { this.metronome!.triggerAttackRelease(isAccent ? "C6" : "G5", "32n"); }
|
||||
catch { /* AudioContext not ready */ }
|
||||
}, b * beatDurMs);
|
||||
this.countInTimeouts.push(id);
|
||||
}
|
||||
|
||||
// After count-in, start actual playback
|
||||
const startId = setTimeout(async () => {
|
||||
this.countInTimeouts = [];
|
||||
this.seekTo(startFrom);
|
||||
await this.clock.start(startFrom);
|
||||
this.startAnimLoop();
|
||||
// status stays "playing"
|
||||
}, totalBeats * beatDurMs);
|
||||
this.countInTimeouts.push(startId);
|
||||
}
|
||||
|
||||
private _cancelCountIn() {
|
||||
for (const id of this.countInTimeouts) clearTimeout(id);
|
||||
this.countInTimeouts = [];
|
||||
}
|
||||
|
||||
pause() {
|
||||
this._cancelCountIn();
|
||||
this.clock.pause();
|
||||
this.stopAnimLoop();
|
||||
this.emitState("paused");
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._cancelCountIn();
|
||||
this.clock.stop();
|
||||
this.audio.stopAll();
|
||||
this.stopAnimLoop();
|
||||
this.noteIndex = 0;
|
||||
this.activeNotes.clear();
|
||||
this._state.currentSeconds = 0;
|
||||
this.lastBeatCrossed = -1;
|
||||
this.emitState("stopped");
|
||||
bus.emit("transport:tick", { seconds: 0, tick: 0 });
|
||||
}
|
||||
|
||||
seek(seconds: number) {
|
||||
const wasPlaying = this._state.status === "playing" || this._state.status === "waiting";
|
||||
if (wasPlaying) this.clock.pause();
|
||||
|
||||
// Tell the UI to release all visually-pressed keys before we cancel Tone events.
|
||||
// Without this, any note whose note:on fired but note:off was still scheduled
|
||||
// (and then cancelled below) leaves the key stuck in a pressed state.
|
||||
bus.emit("transport:seek", { seconds });
|
||||
|
||||
// Cancel pending Tone events so notes don't fire from the old position
|
||||
Tone.getTransport().cancel();
|
||||
|
||||
this.seekTo(seconds);
|
||||
this.audio.stopAll();
|
||||
bus.emit("transport:tick", { seconds, tick: 0 });
|
||||
|
||||
if (wasPlaying) {
|
||||
// cancel() above killed the scheduleRepeat - rebuild it before resuming
|
||||
this.clock.reschedule(seconds);
|
||||
this.clock.resume();
|
||||
this._state.status = "playing";
|
||||
}
|
||||
this.emitState(this._state.status);
|
||||
}
|
||||
|
||||
skipBy(delta: number) {
|
||||
const total = this.doc?.totalDuration ?? 0;
|
||||
const next = Math.max(0, Math.min(total, this._state.currentSeconds + delta));
|
||||
this.seek(next);
|
||||
}
|
||||
|
||||
setTempoMultiplier(v: number) {
|
||||
const clamped = Math.max(0.25, Math.min(2.0, v));
|
||||
this._state.tempoMultiplier = clamped;
|
||||
// Update clock rate - its setter recalculates _startOffset so currentSeconds
|
||||
// stays the same despite the new multiplier.
|
||||
this.clock.tempoMultiplier = clamped;
|
||||
|
||||
// If playing, all Tone.js note:on/note:off callbacks were scheduled at the
|
||||
// OLD tempo rate and will now fire at the wrong wall-clock time.
|
||||
// Solution: same as seek() - cancel pending Tone events, reset the note
|
||||
// cursor, and reschedule everything at the new rate from the current position.
|
||||
const isActive = this._state.status === "playing" || this._state.status === "waiting";
|
||||
if (isActive) {
|
||||
const currentSec = this._state.currentSeconds;
|
||||
this.clock.pause();
|
||||
bus.emit("transport:seek", { seconds: currentSec }); // release pressed keys
|
||||
Tone.getTransport().cancel();
|
||||
this.audio.stopAll();
|
||||
this.seekTo(currentSec); // resets noteIndex + activeNotes
|
||||
this.clock.reschedule(currentSec);
|
||||
this.clock.resume();
|
||||
this._state.status = "playing";
|
||||
}
|
||||
|
||||
this.emitState(this._state.status);
|
||||
}
|
||||
|
||||
setLoopPoints(start: number | null, end: number | null) {
|
||||
this._state.loopStart = start;
|
||||
this._state.loopEnd = end;
|
||||
this.emitState(this._state.status);
|
||||
}
|
||||
|
||||
setWaitMode(on: boolean) {
|
||||
this._state.waitMode = on;
|
||||
this.emitState(this._state.status);
|
||||
}
|
||||
|
||||
setMetronome(on: boolean) {
|
||||
this._state.metronomeEnabled = on;
|
||||
if (on && !this.metronome) {
|
||||
// Lazy-create the synth so AudioContext is already started
|
||||
this.metronome = new Tone.Synth({
|
||||
oscillator: { type: "triangle" },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.02 },
|
||||
volume: -8,
|
||||
}).toDestination();
|
||||
}
|
||||
if (!on) this.lastBeatCrossed = -1;
|
||||
this.emitState(this._state.status);
|
||||
}
|
||||
|
||||
setActiveHands(hands: Set<"left" | "right">) {
|
||||
this._state.activeHands = hands;
|
||||
this.emitState(this._state.status);
|
||||
}
|
||||
|
||||
setHandVolume(vol: { left: number; right: number }) {
|
||||
this._state.handVolume = { left: Math.max(0, Math.min(1, vol.left)), right: Math.max(0, Math.min(1, vol.right)) };
|
||||
}
|
||||
|
||||
setWaitForHand(hand: "left" | "right" | "both") {
|
||||
this._state.waitForHand = hand;
|
||||
}
|
||||
|
||||
async setInstrument(id: InstrumentId): Promise<void> {
|
||||
await this.audio.setInstrument(id);
|
||||
}
|
||||
|
||||
setYouTubePlayer(player: YTPlayer | null) { this.ytPlayer = player; }
|
||||
|
||||
// ── Practice feature setters ────────────────────────────────────────────────
|
||||
|
||||
setTranspose(semitones: number) {
|
||||
this.transposeSemitones = Math.round(Math.max(-12, Math.min(12, semitones)));
|
||||
}
|
||||
|
||||
setCountInBars(n: 0 | 1 | 2) {
|
||||
this.countInBars = n;
|
||||
}
|
||||
|
||||
setSpeedTrainer(opts: SpeedTrainerOpts) {
|
||||
this.speedTrainer = { ...opts };
|
||||
}
|
||||
|
||||
setRenderOffset(ms: number) {
|
||||
this.renderOffsetMs = Math.max(-200, Math.min(200, ms));
|
||||
}
|
||||
|
||||
// ── MIDI input ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Called by WebMidi.js, virtual keyboard click, etc. */
|
||||
onMidiInput(midi: number, velocity: number) {
|
||||
bus.emit("input:note:on", { midi, velocity });
|
||||
|
||||
if (this._state.status === "waiting") {
|
||||
const expected = this.waitingNote();
|
||||
if (expected && midi === expected.midi) {
|
||||
this._state.status = "playing";
|
||||
this.clock.resume();
|
||||
this.emitState("playing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Play a note through the audio engine immediately (for virtual keyboard clicks). */
|
||||
async playMidi(midi: number, velocity = 90) {
|
||||
// Emit input:note:on synchronously so the key lights up with no delay.
|
||||
this.onMidiInput(midi, velocity);
|
||||
// Register this MIDI as pending. If stopMidi() fires before audio loads
|
||||
// (user tapped quickly), the entry is removed and we skip the play call -
|
||||
// preventing the "echo" where a note sustains after the key was released.
|
||||
this.pendingMidiPlays.add(midi);
|
||||
await this.audio.load();
|
||||
if (this.pendingMidiPlays.has(midi)) {
|
||||
this.pendingMidiPlays.delete(midi);
|
||||
this.audio.playMidi(midi, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
stopMidi(midi: number) {
|
||||
this.pendingMidiPlays.delete(midi); // cancel play if still loading
|
||||
this.audio.stopNote(midi);
|
||||
this.onMidiInputOff(midi);
|
||||
}
|
||||
|
||||
onMidiInputOff(midi: number) {
|
||||
bus.emit("input:note:off", { midi });
|
||||
}
|
||||
|
||||
onStateChange(cb: (s: SyncEngineState) => void) {
|
||||
this.stateListeners.add(cb);
|
||||
return () => this.stateListeners.delete(cb);
|
||||
}
|
||||
|
||||
get state(): Readonly<SyncEngineState> { return this._state; }
|
||||
|
||||
// ── Private ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* seekTo positions the note-cursor for doc-time `seconds`.
|
||||
* All note.startSeconds are in doc-time (same unit as currentSeconds).
|
||||
*/
|
||||
private seekTo(seconds: number) {
|
||||
if (!this.doc) return;
|
||||
this._state.currentSeconds = seconds;
|
||||
this.noteIndex = this.doc.notes.findIndex((n) => n.startSeconds >= seconds);
|
||||
if (this.noteIndex === -1) this.noteIndex = this.doc.notes.length;
|
||||
this.activeNotes.clear();
|
||||
// Reset metronome so the first beat at new position doesn't double-fire
|
||||
const bpm = this.doc.tempoMap[0]?.bpm ?? 120;
|
||||
this.lastBeatCrossed = Math.floor(seconds / (60 / bpm));
|
||||
}
|
||||
|
||||
private waitingNote(): NoteEvent | null {
|
||||
if (!this.doc || this.noteIndex >= this.doc.notes.length) return null;
|
||||
const note = this.doc.notes[this.noteIndex];
|
||||
const { activeHands, waitForHand } = this._state;
|
||||
if (note.hand !== "unknown" && !activeHands.has(note.hand as "left" | "right")) return null;
|
||||
// When waitForHand restricts to one hand, ignore notes from the other hand
|
||||
if (waitForHand !== "both" && note.hand !== "unknown" && note.hand !== waitForHand) return null;
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies transposition to a note, clamping to the 88-key range (A0-C8).
|
||||
* Returns the original note object unchanged if transposeSemitones is 0.
|
||||
*/
|
||||
private transposeNote(note: NoteEvent): NoteEvent {
|
||||
if (this.transposeSemitones === 0) return note;
|
||||
const tMidi = Math.max(21, Math.min(108, note.midi + this.transposeSemitones));
|
||||
return { ...note, midi: tMidi };
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by MidiClock every ~125ms.
|
||||
* `docSeconds` is in doc-time (same unit as note.startSeconds).
|
||||
*
|
||||
* To schedule Tone.js events in real-time:
|
||||
* realDelay = docTimeDiff / tempoMultiplier
|
||||
*/
|
||||
private onTick(docSeconds: number) {
|
||||
if (!this.doc) return;
|
||||
this._state.currentSeconds = docSeconds;
|
||||
|
||||
// ── Metronome ─────────────────────────────────────────────────────────────
|
||||
if (this._state.metronomeEnabled && this.doc && this.metronome) {
|
||||
const bpm = this.doc.tempoMap[0]?.bpm ?? 120;
|
||||
const beat = Math.floor(docSeconds / (60 / bpm));
|
||||
if (beat !== this.lastBeatCrossed) {
|
||||
this.lastBeatCrossed = beat;
|
||||
try { this.metronome.triggerAttackRelease("C6", "64n"); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loop ──────────────────────────────────────────────────────────────────
|
||||
const { loopStart, loopEnd, tempoMultiplier } = this._state;
|
||||
if (loopStart !== null && loopEnd !== null && docSeconds >= loopEnd) {
|
||||
// Speed trainer: bump tempo on each loop wrap (only while below target)
|
||||
if (this.speedTrainer.enabled) {
|
||||
const st = this.speedTrainer;
|
||||
if (st.currentPct < st.endPct) {
|
||||
const nextPct = Math.min(st.endPct, st.currentPct + st.stepPct);
|
||||
st.currentPct = nextPct;
|
||||
// setTempoMultiplier calls emitState internally
|
||||
this.setTempoMultiplier(nextPct / 100);
|
||||
bus.emit("speedTrainer:stepped", { currentPct: nextPct });
|
||||
}
|
||||
}
|
||||
this.seek(loopStart);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── End of song auto-stop ─────────────────────────────────────────────────
|
||||
// When no loop is active and playback reaches the end, stop and reset to 0.
|
||||
if ((loopStart === null || loopEnd === null) && docSeconds >= this.doc.totalDuration - 0.05) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Schedule upcoming notes ───────────────────────────────────────────────
|
||||
const notes = this.doc.notes;
|
||||
while (this.noteIndex < notes.length) {
|
||||
const note = notes[this.noteIndex];
|
||||
|
||||
// note.startSeconds is in doc-time; compare directly to docSeconds
|
||||
if (note.startSeconds > docSeconds + LOOKAHEAD_DOC_SECONDS) break;
|
||||
if (this.activeNotes.has(note.id)) { this.noteIndex++; continue; }
|
||||
|
||||
// Apply transposition (creates a new NoteEvent if needed, preserves original id)
|
||||
const tNote = this.transposeNote(note);
|
||||
|
||||
bus.emit("note:upcoming", tNote);
|
||||
this.activeNotes.add(note.id);
|
||||
|
||||
// Convert doc-time offset to real-time for Tone.js scheduling.
|
||||
// renderOffsetMs > 0 means audio plays earlier (compensates for delayed output).
|
||||
const offsetSec = this.renderOffsetMs / 1000;
|
||||
const onDelay = Math.max(0, (note.startSeconds - docSeconds) / tempoMultiplier - offsetSec);
|
||||
const offDelay = Math.max(0, (note.endSeconds - docSeconds) / tempoMultiplier - offsetSec);
|
||||
|
||||
Tone.getTransport().scheduleOnce(() => {
|
||||
// Read activeHands at fire-time so hand toggles take effect immediately
|
||||
// (not at schedule-time, which would lag up to LOOKAHEAD_DOC_SECONDS).
|
||||
const isActive = note.hand === "unknown" || this._state.activeHands.has(note.hand as "left" | "right");
|
||||
if (isActive) {
|
||||
bus.emit("note:on", tNote);
|
||||
// Apply per-hand volume multiplier (also read at fire-time for instant effect)
|
||||
const handVol = note.hand === "left" ? this._state.handVolume.left
|
||||
: note.hand === "right" ? this._state.handVolume.right
|
||||
: 1;
|
||||
const scaledNote = handVol === 1 ? tNote
|
||||
: { ...tNote, velocity: Math.max(1, Math.round(tNote.velocity * handVol)) };
|
||||
this.audio.playNote(scaledNote);
|
||||
}
|
||||
}, `+${onDelay}`);
|
||||
|
||||
Tone.getTransport().scheduleOnce(() => {
|
||||
bus.emit("note:off", tNote);
|
||||
this.audio.stopNote(tNote.midi);
|
||||
}, `+${offDelay}`);
|
||||
|
||||
// Wait mode
|
||||
if (this._state.waitMode && this._state.status === "playing") {
|
||||
const isActive = note.hand === "unknown" || this._state.activeHands.has(note.hand as "left" | "right");
|
||||
// Only pause for the targeted hand (waitForHand); "both" = current behavior
|
||||
const isWaited = this._state.waitForHand === "both"
|
||||
|| note.hand === "unknown"
|
||||
|| note.hand === this._state.waitForHand;
|
||||
if (isActive && isWaited && onDelay <= 0.05) {
|
||||
this.clock.pause();
|
||||
this._state.status = "waiting";
|
||||
this.emitState("waiting");
|
||||
}
|
||||
}
|
||||
|
||||
this.noteIndex++;
|
||||
}
|
||||
|
||||
// ── YouTube sync ──────────────────────────────────────────────────────────
|
||||
if (this.ytPlayer && this.doc.youtubeId) {
|
||||
const expectedYtTime = docSeconds + this.doc.youtubeSyncOffset;
|
||||
try {
|
||||
const actual = this.ytPlayer.getCurrentTime();
|
||||
if (Math.abs(actual - expectedYtTime) > YT_DRIFT_THRESHOLD) {
|
||||
this.ytPlayer.seekTo(expectedYtTime, true);
|
||||
}
|
||||
} catch { /* not ready yet */ }
|
||||
}
|
||||
}
|
||||
|
||||
// rAF loop: emits transport:tick at 60fps for smooth visuals.
|
||||
// State listeners throttled to ~10fps to avoid React re-renders.
|
||||
private startAnimLoop() {
|
||||
let lastStateMs = 0;
|
||||
const loop = () => {
|
||||
const seconds = this.clock.currentSeconds;
|
||||
this._state.currentSeconds = seconds;
|
||||
bus.emit("transport:tick", { seconds, tick: 0 });
|
||||
|
||||
const now = performance.now();
|
||||
if (now - lastStateMs >= 100) {
|
||||
lastStateMs = now;
|
||||
for (const cb of this.stateListeners) cb({ ...this._state });
|
||||
}
|
||||
|
||||
this.animFrameId = requestAnimationFrame(loop);
|
||||
};
|
||||
this.animFrameId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
private stopAnimLoop() {
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.animFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private emitState(status: PlaybackStatus) {
|
||||
this._state.status = status;
|
||||
bus.emit("transport:stateChange", { status, seconds: this._state.currentSeconds });
|
||||
for (const cb of this.stateListeners) cb({ ...this._state });
|
||||
}
|
||||
}
|
||||
|
||||
export const syncEngine = new SyncEngine();
|
||||
export default syncEngine;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import bus from "../engine/EventBus";
|
||||
import { accuracyTracker } from "../engine/AccuracyTracker";
|
||||
|
||||
export interface AccuracyState {
|
||||
correct: number;
|
||||
wrong: number;
|
||||
missed: number;
|
||||
score: number; // 0-100
|
||||
}
|
||||
|
||||
const INITIAL: AccuracyState = { correct: 0, wrong: 0, missed: 0, score: 0 };
|
||||
|
||||
/**
|
||||
* Returns live accuracy data from AccuracyTracker.
|
||||
* Automatically enables the tracker while the component is mounted.
|
||||
*/
|
||||
export function useAccuracy(enabled: boolean): AccuracyState {
|
||||
const [state, setState] = useState<AccuracyState>(INITIAL);
|
||||
|
||||
useEffect(() => {
|
||||
accuracyTracker.enable(enabled);
|
||||
if (!enabled) {
|
||||
setState(INITIAL);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (data: AccuracyState) => setState({ ...data });
|
||||
bus.on("accuracy:update", handler as never);
|
||||
return () => {
|
||||
bus.off("accuracy:update", handler as never);
|
||||
accuracyTracker.enable(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ── Design tokens ─────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Surfaces - full indigo palette */
|
||||
--color-bg: #1a1a2e;
|
||||
--color-surface: #1e1e38;
|
||||
--color-surface-2: #232348;
|
||||
--color-surface-3: #282858;
|
||||
--color-border: #2d2d55;
|
||||
--color-border-subtle: #232348;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #9333ea;
|
||||
--color-accent-hover: #a855f7;
|
||||
--color-accent-subtle: rgba(147, 51, 234, 0.18);
|
||||
--color-accent-subtle-border: rgba(147, 51, 234, 0.35);
|
||||
--color-accent-text: #c084fc;
|
||||
|
||||
/* Toggle switch inactive track */
|
||||
--color-toggle-off: #4a4f88;
|
||||
|
||||
/* Text - muted bumped to #9ca3af for WCAG AA compliance on dark surfaces */
|
||||
--color-text: #e2e8f0;
|
||||
--color-text-muted: #9ca3af;
|
||||
--color-text-dim: #3d4070;
|
||||
|
||||
/* Notes canvas - slightly deeper than bg so the practice area reads as distinct */
|
||||
--color-notes-bg: #141428;
|
||||
--color-notes-border: #1f1f44;
|
||||
|
||||
/* Hands */
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-subtle: rgba(245, 158, 11, 0.08);
|
||||
--color-warning-border: rgba(245, 158, 11, 0.22);
|
||||
|
||||
--color-left-hand: #9333ea;
|
||||
--color-left-hand-subtle: rgba(147, 51, 234, 0.18);
|
||||
--color-left-hand-subtle-border: rgba(147, 51, 234, 0.38);
|
||||
--color-left-hand-text: #c084fc;
|
||||
--color-right-hand: #22d3ee;
|
||||
--color-right-hand-subtle: rgba(34, 211, 238, 0.14);
|
||||
--color-right-hand-subtle-border: rgba(34, 211, 238, 0.32);
|
||||
--color-right-hand-text: #22d3ee;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
/* Surfaces */
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f5f7fb;
|
||||
--color-surface-3: #eaecf4;
|
||||
--color-border: #dde1ea;
|
||||
--color-border-subtle: #eaecf4;
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #4f46e5;
|
||||
--color-accent-subtle: rgba(99, 102, 241, 0.12);
|
||||
--color-accent-subtle-border: rgba(99, 102, 241, 0.30);
|
||||
--color-accent-text: #4f46e5;
|
||||
|
||||
/* Toggle switch inactive track */
|
||||
--color-toggle-off: #c1c9d6;
|
||||
|
||||
/* Text */
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-dim: #94a3b8;
|
||||
|
||||
/* Hands */
|
||||
--color-warning: #d97706;
|
||||
--color-warning-subtle: rgba(217, 119, 6, 0.08);
|
||||
--color-warning-border: rgba(217, 119, 6, 0.22);
|
||||
|
||||
--color-left-hand: #3b82f6;
|
||||
--color-left-hand-subtle: rgba(59, 130, 246, 0.12);
|
||||
--color-left-hand-subtle-border: rgba(59, 130, 246, 0.30);
|
||||
--color-left-hand-text: #1d4ed8;
|
||||
--color-right-hand: #ef4444;
|
||||
--color-right-hand-subtle: rgba(239, 68, 68, 0.10);
|
||||
--color-right-hand-subtle-border: rgba(239, 68, 68, 0.28);
|
||||
--color-right-hand-text: #dc2626;
|
||||
|
||||
/* Notes canvas - cooler, slightly darker than chrome to create a
|
||||
"stage vs UI" separation that mirrors what dark mode does with
|
||||
#141428 content vs #1E1E38 chrome. #EEF1FA has the same blue-indigo
|
||||
undertone as the dark palette and reads as white at a glance while
|
||||
still being visibly distinct from the #FFFFFF header/transport. */
|
||||
--color-notes-bg: #EEF1FA;
|
||||
--color-notes-border: #D4D9EE;
|
||||
}
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: "Space Grotesk", system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Typography scale ──────────────────────────────────────────────────────── */
|
||||
.text-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Inputs & selects ──────────────────────────────────────────────────────── */
|
||||
select, input[type="range"] { font-family: inherit; }
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7'%3E%3Cpath d='M0 0l6 7 6-7z' fill='%2364748b'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 28px !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7'%3E%3Cpath d='M0 0l6 7 6-7z' fill='%239ca3af'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ── Settings panel rows ───────────────────────────────────────────────────── */
|
||||
.settings-row:hover { background: var(--color-surface-2); }
|
||||
|
||||
/* ── Scrollbar ─────────────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--color-surface-3); border-radius: 99px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-border); }
|
||||
|
||||
/* ── Range slider - fully custom ───────────────────────────────────────────── */
|
||||
/* Remove native appearance so our pseudo-element CSS actually takes effect.
|
||||
Each slider sets --fill-pct via inline style (computed from value/min/max)
|
||||
so the gradient draws the correct filled/unfilled split. */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Thumb - WebKit/Blink */
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
cursor: pointer;
|
||||
margin-top: -5.5px; /* center on 3px track: -(14-3)/2 */
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.35);
|
||||
transition: background 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
input[type="range"]:hover::-webkit-slider-thumb {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: 0 0 0 4px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
/* Track - WebKit/Blink (uses --fill-pct for filled/unfilled split) */
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
height: 3px;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-accent) var(--fill-pct, 0%),
|
||||
var(--color-border) var(--fill-pct, 0%)
|
||||
);
|
||||
}
|
||||
|
||||
/* Thumb - Firefox */
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.35);
|
||||
}
|
||||
input[type="range"]:hover::-moz-range-thumb {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: 0 0 0 4px var(--color-accent-subtle);
|
||||
}
|
||||
|
||||
/* Track - Firefox */
|
||||
input[type="range"]::-moz-range-track {
|
||||
height: 3px;
|
||||
border-radius: 99px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
input[type="range"]::-moz-range-progress {
|
||||
height: 3px;
|
||||
border-radius: 99px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Transport buttons ─────────────────────────────────────────────────────── */
|
||||
/* Shared base for all icon/action buttons in the transport bar */
|
||||
.t-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 7px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, opacity 0.12s;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.t-btn:hover:not(:disabled) { background: var(--color-surface-3); color: var(--color-text); }
|
||||
.t-btn:disabled { opacity: 0.55; cursor: default; }
|
||||
|
||||
/* Primary play/pause button - circular, accent-colored */
|
||||
.t-btn.primary {
|
||||
background: var(--color-accent);
|
||||
border-color: transparent;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
box-shadow: 0 0 18px rgba(147, 51, 234, 0.5);
|
||||
}
|
||||
.t-btn.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: 0 0 24px rgba(147, 51, 234, 0.65);
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── AlphaTab host ─────────────────────────────────────────────────────────── */
|
||||
.at-surface svg { display: block; }
|
||||
|
||||
/* ── AlphaTab playback cursor ─────────────────────────────────────────────── */
|
||||
/* Beat cursor - thin accent-colored vertical bar following playhead */
|
||||
.at-cursor-beat {
|
||||
background: rgba(147, 51, 234, 0.65);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Bar cursor - wider semi-transparent highlight for the current measure */
|
||||
.at-cursor-bar {
|
||||
background: rgba(147, 51, 234, 0.12);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Selection overlay */
|
||||
.at-selection div {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
}
|
||||
|
||||
/* ── Dark-mode sheet music inversion ──────────────────────────────────────── */
|
||||
/* Filter is applied only to SVG elements (the actual music notation), NOT to
|
||||
the container. This way:
|
||||
- Black staff lines/noteheads to white (readable on dark background)
|
||||
- The container's background (--color-bg dark gray) shows through transparent SVG areas
|
||||
- Cursor/selection divs (.at-cursor-beat etc.) are NOT SVGs, so they keep their
|
||||
natural purple accent colors - no pre-inversion math needed. */
|
||||
[data-theme="dark"] .sheet-dark-invert svg {
|
||||
filter: invert(1);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
// StrictMode deliberately double-mounts in dev, which conflicts with Pixi.js's
|
||||
// async init - Application.destroy() fires before init() resolves.
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,555 @@
|
||||
import { create } from "zustand";
|
||||
import { syncEngine } from "../engine/SyncEngine";
|
||||
import bus from "../engine/EventBus";
|
||||
import type { MusicDocument, PlaybackStatus } from "@bach-to-basics/shared";
|
||||
import type { InstrumentId } from "../engine/AudioEngine";
|
||||
export { computeMeasureSeconds, keySignatureToLabel } from "@bach-to-basics/shared";
|
||||
export type { InstrumentId } from "../engine/AudioEngine";
|
||||
export { INSTRUMENT_LABELS } from "../engine/AudioEngine";
|
||||
|
||||
/** Which visual effect plays when a note reaches the hit line. */
|
||||
export type ImpactStyle = "off" | "bloom" | "side" | "trail";
|
||||
|
||||
/** Which pitch classes to show in the falling-notes view */
|
||||
export type NoteFilter = "all" | "white" | "black" | "c_only";
|
||||
|
||||
/** Layout mode - which panels are visible */
|
||||
export type LayoutMode = "falling" | "sheet" | "piano" | "all";
|
||||
|
||||
/**
|
||||
* Color theme for falling notes (and keyboard highlights).
|
||||
* - violet : all notes violet (default, no hand separation)
|
||||
* - classic : left=blue, right=red (hand-aware)
|
||||
* - ocean : cyan palette
|
||||
* - forest : green palette
|
||||
* - custom : user-defined colors via CustomColors
|
||||
*/
|
||||
export type ColorTheme = "violet" | "classic" | "ocean" | "forest" | "cascade" | "custom";
|
||||
|
||||
/** Per-channel colors for the custom theme (hex strings, e.g. "#3b82f6"). */
|
||||
export interface CustomColors {
|
||||
leftHand: string;
|
||||
rightHand: string;
|
||||
unknown: string;
|
||||
}
|
||||
|
||||
/** Pre-built color values matching each named preset (used to seed the custom picker). */
|
||||
export const COLOR_PRESET_VALUES: Record<Exclude<ColorTheme, "custom">, CustomColors> = {
|
||||
violet: { leftHand: "#7c3aed", rightHand: "#f59e0b", unknown: "#a78bfa" }, // purple + amber
|
||||
classic: { leftHand: "#2563eb", rightHand: "#dc2626", unknown: "#7c3aed" }, // blue + red
|
||||
ocean: { leftHand: "#0284c7", rightHand: "#f97316", unknown: "#0ea5e9" }, // blue + coral
|
||||
forest: { leftHand: "#16a34a", rightHand: "#a855f7", unknown: "#4ade80" }, // green + purple
|
||||
cascade: { leftHand: "#9333ea", rightHand: "#22d3ee", unknown: "#a855f7" }, // purple + cyan
|
||||
};
|
||||
|
||||
/** Which keys receive note-name labels on the piano keyboard */
|
||||
export type NoteLabelMode = "none" | "c_only" | "white" | "black" | "all";
|
||||
|
||||
export interface SpeedTrainerSettings {
|
||||
enabled: boolean;
|
||||
startPct: number; // 0-100
|
||||
endPct: number; // 0-100
|
||||
stepPct: number; // increment per loop
|
||||
currentPct: number; // current ramp position
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
showFingering: boolean;
|
||||
showHandColors: boolean;
|
||||
useFlats: boolean;
|
||||
viewportSeconds: number; // FallingNotes lookahead window
|
||||
theme: "dark" | "light";
|
||||
activeHands: Set<"left" | "right">;
|
||||
volume: number; // 0-1
|
||||
noteFilter: NoteFilter;
|
||||
colorTheme: ColorTheme;
|
||||
customColors: CustomColors;
|
||||
layoutMode: LayoutMode;
|
||||
metronomeEnabled: boolean;
|
||||
// ── New settings ──────────────────────────────────────────────────────────
|
||||
/** Note labels on the piano keyboard */
|
||||
noteLabelMode: NoteLabelMode;
|
||||
/** Note labels on the falling notes bars */
|
||||
fallingNotesLabelMode: NoteLabelMode;
|
||||
pianoTheme: "white" | "ivory";
|
||||
showGrid: boolean;
|
||||
showMeasureNums: boolean;
|
||||
showKeySignature: boolean;
|
||||
transposeSemitones: number; // -6..+6
|
||||
speedTrainer: SpeedTrainerSettings;
|
||||
countInBars: 0 | 1 | 2;
|
||||
/** Which visual effect plays when a note reaches the hit line */
|
||||
impactStyle: ImpactStyle;
|
||||
sheetMusicWhiteBackground: boolean;
|
||||
// ── MIDIano-inspired additions ────────────────────────────────────────────
|
||||
/** Scroll wheel on the falling notes canvas seeks playback position */
|
||||
scrollToSeek: boolean;
|
||||
/** Minimum pixel height for note bars (prevents staccato notes becoming invisible) */
|
||||
minNoteHeight: number; // 4-24
|
||||
/** Border-radius for note bar corners (0 = sharp, 12 = pill) */
|
||||
noteCornerRadius: number; // 0-12
|
||||
/** Draw faint horizontal lines at each beat and measure boundary */
|
||||
showBeatLines: boolean;
|
||||
/** Show sustain pedal (CC64) regions as semi-transparent bands */
|
||||
showSustainPedal: boolean;
|
||||
/** Per-hand volume multiplier (0 = silent, 1 = full) */
|
||||
handVolume: { left: number; right: number };
|
||||
/** Active instrument for audio playback */
|
||||
instrument: InstrumentId;
|
||||
/** Which hand(s) trigger wait-mode pauses ("both" = default, all hands) */
|
||||
waitForHand: "left" | "right" | "both";
|
||||
/** Draw a colored outline stroke around falling note bars */
|
||||
showNoteOutline: boolean;
|
||||
// ── MIDIano live-app additions ────────────────────────────────────────────
|
||||
/** Shift audio scheduling by ±ms to compensate for audio interface latency (positive = play earlier) */
|
||||
renderOffset: number; // -200..+200 ms
|
||||
/** Keep a ghost indicator for notes that have ended while sustain pedal is held */
|
||||
showSustainedNotes: boolean;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
// ── Document ─────────────────────────────────────────────────────────────
|
||||
document: MusicDocument | null;
|
||||
isLoadingDocument: boolean;
|
||||
loadError: string | null;
|
||||
/** Non-null during slow imports (e.g. OMR) to show a descriptive message */
|
||||
loadingMessage: string | null;
|
||||
|
||||
// ── Playback ─────────────────────────────────────────────────────────────
|
||||
status: PlaybackStatus;
|
||||
currentSeconds: number;
|
||||
tempoMultiplier: number;
|
||||
loopStart: number | null;
|
||||
loopEnd: number | null;
|
||||
waitMode: boolean;
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
settings: AppSettings;
|
||||
|
||||
// ── MIDI device ──────────────────────────────────────────────────────────
|
||||
midiDeviceName: string | null;
|
||||
midiEnabled: boolean;
|
||||
|
||||
// ── Actions ──────────────────────────────────────────────────────────────
|
||||
loadMidiFile: (file: File) => Promise<void>;
|
||||
loadMusicXmlFile: (file: File) => Promise<void>;
|
||||
loadPdfFile: (file: File) => Promise<void>;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
seek: (seconds: number) => void;
|
||||
setTempoMultiplier: (v: number) => void;
|
||||
setLoopPoints: (start: number | null, end: number | null) => void;
|
||||
setWaitMode: (on: boolean) => void;
|
||||
setActiveHands: (hands: Set<"left" | "right">) => void;
|
||||
setMetronome: (on: boolean) => void;
|
||||
updateSettings: (patch: Partial<AppSettings>) => void;
|
||||
resetSettings: () => void;
|
||||
setMidiDevice: (name: string | null) => void;
|
||||
clearLoadError: () => void;
|
||||
syncEngineState: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
showFingering: false,
|
||||
showHandColors: true,
|
||||
useFlats: false,
|
||||
viewportSeconds: 4.0,
|
||||
theme: "light",
|
||||
activeHands: new Set(["left", "right"]),
|
||||
volume: 0.8,
|
||||
noteFilter: "all",
|
||||
colorTheme: "cascade",
|
||||
customColors: { leftHand: "#9333ea", rightHand: "#22d3ee", unknown: "#a855f7" },
|
||||
layoutMode: "falling",
|
||||
metronomeEnabled: false,
|
||||
// ── New defaults ──────────────────────────────────────────────────────────
|
||||
noteLabelMode: "c_only",
|
||||
fallingNotesLabelMode: "all",
|
||||
pianoTheme: "white",
|
||||
showGrid: false,
|
||||
showMeasureNums: true,
|
||||
showKeySignature: true,
|
||||
transposeSemitones: 0,
|
||||
speedTrainer: { enabled: false, startPct: 60, endPct: 100, stepPct: 5, currentPct: 60 },
|
||||
countInBars: 0,
|
||||
impactStyle: "bloom" as ImpactStyle,
|
||||
sheetMusicWhiteBackground: false,
|
||||
// ── MIDIano-inspired additions ────────────────────────────────────────────
|
||||
scrollToSeek: false,
|
||||
minNoteHeight: 8,
|
||||
noteCornerRadius: 4,
|
||||
showBeatLines: false,
|
||||
showSustainPedal: false,
|
||||
handVolume: { left: 1, right: 1 },
|
||||
// ── "Consider later" additions ────────────────────────────────────────────
|
||||
instrument: "grand" as InstrumentId,
|
||||
waitForHand: "both" as "left" | "right" | "both",
|
||||
showNoteOutline: false,
|
||||
// ── MIDIano live-app additions ────────────────────────────────────────────
|
||||
renderOffset: 0,
|
||||
showSustainedNotes: false,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => {
|
||||
// Subscribe to SyncEngine state changes --> push into Zustand
|
||||
syncEngine.onStateChange((s) => {
|
||||
set({
|
||||
status: s.status,
|
||||
currentSeconds: s.currentSeconds,
|
||||
tempoMultiplier: s.tempoMultiplier,
|
||||
loopStart: s.loopStart,
|
||||
loopEnd: s.loopEnd,
|
||||
waitMode: s.waitMode,
|
||||
});
|
||||
});
|
||||
|
||||
// Speed trainer: keep currentPct in sync when the engine steps the tempo
|
||||
bus.on("speedTrainer:stepped", ({ currentPct }) => {
|
||||
set((s) => ({
|
||||
tempoMultiplier: currentPct / 100,
|
||||
settings: {
|
||||
...s.settings,
|
||||
speedTrainer: { ...s.settings.speedTrainer, currentPct },
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
document: null,
|
||||
isLoadingDocument: false,
|
||||
loadError: null,
|
||||
loadingMessage: null,
|
||||
status: "stopped",
|
||||
currentSeconds: 0,
|
||||
tempoMultiplier: 1.0,
|
||||
loopStart: null,
|
||||
loopEnd: null,
|
||||
waitMode: false,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
midiDeviceName: null,
|
||||
midiEnabled: false,
|
||||
|
||||
loadMidiFile: async (file: File) => {
|
||||
set({ isLoadingDocument: true, loadError: null });
|
||||
|
||||
try {
|
||||
const rawBuffer = await file.arrayBuffer();
|
||||
const id = crypto.randomUUID();
|
||||
// M4 - strip path separators, control chars; cap length
|
||||
const rawTitle = file.name.replace(/\.midi?$/i, "");
|
||||
const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled";
|
||||
|
||||
// Slice a copy BEFORE transferring - postMessage() detaches (empties) the original
|
||||
const bufferForXml = rawBuffer.slice(0);
|
||||
|
||||
// Parse in Web Worker (transfers rawBuffer ownership)
|
||||
const doc = await parseMidiInWorker(rawBuffer, id, title);
|
||||
set({ document: doc });
|
||||
await syncEngine.loadDocument(doc);
|
||||
|
||||
// Request MusicXML from backend (non-blocking), using the copy
|
||||
fetchMusicXml(bufferForXml, doc, id).catch(console.warn);
|
||||
} catch (err) {
|
||||
set({ loadError: String(err) });
|
||||
} finally {
|
||||
set({ isLoadingDocument: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadMusicXmlFile: async (file: File) => {
|
||||
set({ isLoadingDocument: true, loadError: null });
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
// M4 - strip path separators, control chars; cap length
|
||||
const rawTitle = file.name.replace(/\.(xml|mxl)$/i, "");
|
||||
const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled";
|
||||
const xmlBytes = await file.arrayBuffer();
|
||||
|
||||
// For .mxl (ZIP-compressed MusicXML) decompress in the browser so we
|
||||
// always end up with a plain XML string. This is more reliable than
|
||||
// relying on AlphaTab's internal ZIP reader (errors are swallowed there
|
||||
// and produce a silent blank sheet view).
|
||||
let musicXml: string | null;
|
||||
if (file.name.toLowerCase().endsWith(".mxl")) {
|
||||
const { extractXmlFromMxl } = await import("../utils/mxlExtract");
|
||||
musicXml = await extractXmlFromMxl(xmlBytes);
|
||||
} else {
|
||||
musicXml = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
|
||||
// Stub document - sheet music renders immediately via musicXml string
|
||||
const doc: import("@bach-to-basics/shared").MusicDocument = {
|
||||
id, title,
|
||||
sourceType: "musicxml",
|
||||
musicXml,
|
||||
midiBuffer: null,
|
||||
notes: [],
|
||||
tempoMap: [{ tick: 0, bpm: 120 }],
|
||||
timeSignatures: [{ tick: 0, numerator: 4, denominator: 4 }],
|
||||
totalDuration: 0,
|
||||
ppq: 480,
|
||||
keySignature: null,
|
||||
youtubeId: null,
|
||||
youtubeSyncOffset: 0,
|
||||
fingeringVersion: "none",
|
||||
};
|
||||
|
||||
set({ document: doc });
|
||||
await syncEngine.loadDocument(doc);
|
||||
|
||||
// Fetch MIDI from backend for playback + falling notes (non-blocking).
|
||||
// For .mxl files we send the already-extracted plain XML string (re-encoded
|
||||
// to UTF-8) with a .xml extension, so the backend gets clean MusicXML rather
|
||||
// than the ZIP container. This avoids cross-staff voice-duplication artefacts
|
||||
// that music21 can produce when it parses a compressed .mxl directly.
|
||||
const xmlBytesForBackend = (musicXml && file.name.toLowerCase().endsWith(".mxl"))
|
||||
? new TextEncoder().encode(musicXml).buffer
|
||||
: xmlBytes;
|
||||
const filenameForBackend = file.name.toLowerCase().endsWith(".mxl")
|
||||
? file.name.replace(/\.mxl$/i, ".xml")
|
||||
: file.name;
|
||||
fetchMidiFromXml(xmlBytesForBackend, filenameForBackend, doc, id).catch(console.warn);
|
||||
} catch (err) {
|
||||
set({ loadError: String(err) });
|
||||
} finally {
|
||||
set({ isLoadingDocument: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadPdfFile: async (file: File) => {
|
||||
const id = crypto.randomUUID();
|
||||
// M4 - strip path separators and control chars; cap length
|
||||
const rawTitle = file.name.replace(/\.pdf$/i, "");
|
||||
const title = rawTitle.replace(/[\x00-\x1f\x7f/\\]/g, "").slice(0, 200) || "Untitled";
|
||||
|
||||
set({ isLoadingDocument: true, loadingMessage: "Reading sheet music\u2026", loadError: null });
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
|
||||
// OMR can take 10-120 s; no client-side fetch timeout -
|
||||
// the server's subprocess timeout is the effective limit.
|
||||
const res = await fetch("/api/omr/pdf2midi", { method: "POST", body: form });
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { detail?: string };
|
||||
throw new Error(body.detail ?? `Server error ${res.status}`);
|
||||
}
|
||||
|
||||
const { musicxml, midi_b64 } = await res.json() as {
|
||||
musicxml: string;
|
||||
midi_b64: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
// Decode base64 MIDI
|
||||
const binaryStr = atob(midi_b64);
|
||||
const midiBytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) midiBytes[i] = binaryStr.charCodeAt(i);
|
||||
const midiBuffer = midiBytes.buffer;
|
||||
const bufferForDoc = midiBuffer.slice(0);
|
||||
|
||||
// Parse notes from MIDI (same Web Worker path as loadMidiFile)
|
||||
const doc = await parseMidiInWorker(midiBuffer, id, title);
|
||||
|
||||
// Attach MusicXML so sheet music renders immediately alongside playback.
|
||||
// sourceType "musicxml" tells AlphaTab to render the score.
|
||||
doc.musicXml = musicxml;
|
||||
doc.sourceType = "musicxml";
|
||||
|
||||
set({ document: { ...doc, midiBuffer: bufferForDoc } });
|
||||
await syncEngine.loadDocument({ ...doc, midiBuffer: bufferForDoc });
|
||||
|
||||
} catch (err) {
|
||||
set({ loadError: String(err) });
|
||||
} finally {
|
||||
set({ isLoadingDocument: false, loadingMessage: null });
|
||||
}
|
||||
},
|
||||
|
||||
play: () => {
|
||||
const { settings, status } = get();
|
||||
// Speed trainer: seed the engine's current state before play
|
||||
if (settings.speedTrainer.enabled) {
|
||||
const st = settings.speedTrainer;
|
||||
// If stopped (fresh start), reset to startPct; otherwise keep current
|
||||
const currentPct = status === "stopped" ? st.startPct : st.currentPct;
|
||||
syncEngine.setSpeedTrainer({ ...st, currentPct });
|
||||
syncEngine.setTempoMultiplier(currentPct / 100);
|
||||
set((s) => ({
|
||||
tempoMultiplier: currentPct / 100,
|
||||
settings: {
|
||||
...s.settings,
|
||||
speedTrainer: { ...s.settings.speedTrainer, currentPct },
|
||||
},
|
||||
}));
|
||||
}
|
||||
syncEngine.play();
|
||||
},
|
||||
pause: () => syncEngine.pause(),
|
||||
stop: () => syncEngine.stop(),
|
||||
seek: (seconds) => syncEngine.seek(seconds),
|
||||
|
||||
setTempoMultiplier: (v) => {
|
||||
syncEngine.setTempoMultiplier(v);
|
||||
set({ tempoMultiplier: v });
|
||||
},
|
||||
|
||||
setLoopPoints: (start, end) => {
|
||||
syncEngine.setLoopPoints(start, end);
|
||||
set({ loopStart: start, loopEnd: end });
|
||||
},
|
||||
|
||||
setWaitMode: (on) => {
|
||||
syncEngine.setWaitMode(on);
|
||||
set({ waitMode: on });
|
||||
},
|
||||
|
||||
setMetronome: (on) => {
|
||||
syncEngine.setMetronome(on);
|
||||
set((s) => ({ settings: { ...s.settings, metronomeEnabled: on } }));
|
||||
},
|
||||
|
||||
setActiveHands: (hands) => {
|
||||
syncEngine.setActiveHands(hands);
|
||||
set((s) => ({
|
||||
settings: { ...s.settings, activeHands: hands },
|
||||
}));
|
||||
},
|
||||
|
||||
updateSettings: (patch) => {
|
||||
set((s) => ({ settings: { ...s.settings, ...patch } }));
|
||||
// Sync practice settings to the engine immediately
|
||||
if ("transposeSemitones" in patch && patch.transposeSemitones !== undefined)
|
||||
syncEngine.setTranspose(patch.transposeSemitones);
|
||||
if ("countInBars" in patch && patch.countInBars !== undefined)
|
||||
syncEngine.setCountInBars(patch.countInBars);
|
||||
if ("speedTrainer" in patch && patch.speedTrainer !== undefined)
|
||||
syncEngine.setSpeedTrainer(patch.speedTrainer);
|
||||
if ("handVolume" in patch && patch.handVolume !== undefined)
|
||||
syncEngine.setHandVolume(patch.handVolume);
|
||||
if ("instrument" in patch && patch.instrument !== undefined)
|
||||
syncEngine.setInstrument(patch.instrument).catch(console.warn);
|
||||
if ("waitForHand" in patch && patch.waitForHand !== undefined)
|
||||
syncEngine.setWaitForHand(patch.waitForHand);
|
||||
if ("renderOffset" in patch && patch.renderOffset !== undefined)
|
||||
syncEngine.setRenderOffset(patch.renderOffset);
|
||||
},
|
||||
|
||||
resetSettings: () => {
|
||||
set({ settings: { ...DEFAULT_SETTINGS } });
|
||||
// Re-sync engine with defaults
|
||||
syncEngine.setTranspose(DEFAULT_SETTINGS.transposeSemitones);
|
||||
syncEngine.setCountInBars(DEFAULT_SETTINGS.countInBars);
|
||||
syncEngine.setSpeedTrainer(DEFAULT_SETTINGS.speedTrainer);
|
||||
syncEngine.setMetronome(DEFAULT_SETTINGS.metronomeEnabled);
|
||||
syncEngine.setActiveHands(DEFAULT_SETTINGS.activeHands);
|
||||
syncEngine.setHandVolume(DEFAULT_SETTINGS.handVolume);
|
||||
syncEngine.setWaitForHand(DEFAULT_SETTINGS.waitForHand);
|
||||
syncEngine.setInstrument(DEFAULT_SETTINGS.instrument).catch(console.warn);
|
||||
syncEngine.setRenderOffset(DEFAULT_SETTINGS.renderOffset);
|
||||
},
|
||||
|
||||
setMidiDevice: (name) => set({ midiDeviceName: name, midiEnabled: name !== null }),
|
||||
|
||||
clearLoadError: () => set({ loadError: null }),
|
||||
|
||||
syncEngineState: () => {
|
||||
const s = syncEngine.state;
|
||||
set({
|
||||
status: s.status,
|
||||
currentSeconds: s.currentSeconds,
|
||||
tempoMultiplier: s.tempoMultiplier,
|
||||
loopStart: s.loopStart,
|
||||
loopEnd: s.loopEnd,
|
||||
waitMode: s.waitMode,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseMidiInWorker(
|
||||
buffer: ArrayBuffer,
|
||||
id: string,
|
||||
title: string
|
||||
): Promise<MusicDocument> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL("../workers/midi.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
// M3 - kill the worker if it hangs (e.g. malformed MIDI, infinite loop)
|
||||
const timeout = setTimeout(() => {
|
||||
worker.terminate();
|
||||
reject(new Error("MIDI parsing timed out"));
|
||||
}, 30_000);
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
if (e.data.ok) resolve(e.data.doc as MusicDocument);
|
||||
else reject(new Error(e.data.error));
|
||||
};
|
||||
worker.onerror = (e) => {
|
||||
clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
reject(e);
|
||||
};
|
||||
worker.postMessage({ buffer, id, title }, [buffer]);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchMidiFromXml(
|
||||
xmlBytes: ArrayBuffer,
|
||||
filename: string,
|
||||
doc: import("@bach-to-basics/shared").MusicDocument,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const blob = new Blob([xmlBytes], { type: "application/xml" });
|
||||
const form = new FormData();
|
||||
form.append("file", blob, filename);
|
||||
|
||||
const res = await fetch("/api/transcribe/musicxml2midi", { method: "POST", body: form });
|
||||
if (!res.ok) return;
|
||||
|
||||
const midiBuffer = await res.arrayBuffer();
|
||||
const bufferForDoc = midiBuffer.slice(0);
|
||||
|
||||
// Parse notes from the returned MIDI
|
||||
const updatedDoc = await parseMidiInWorker(midiBuffer, id, doc.title);
|
||||
updatedDoc.musicXml = doc.musicXml;
|
||||
updatedDoc.sourceType = "musicxml";
|
||||
|
||||
const { document: currentDoc } = useAppStore.getState();
|
||||
if (currentDoc?.id === id) {
|
||||
useAppStore.setState({ document: { ...updatedDoc, midiBuffer: bufferForDoc } });
|
||||
await syncEngine.loadDocument({ ...updatedDoc, midiBuffer: bufferForDoc });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMusicXml(
|
||||
buffer: ArrayBuffer,
|
||||
doc: MusicDocument,
|
||||
_id: string
|
||||
): Promise<void> {
|
||||
const blob = new Blob([buffer], { type: "audio/midi" });
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "track.mid");
|
||||
|
||||
const titleParam = encodeURIComponent(doc.title ?? "");
|
||||
const res = await fetch(`/api/transcribe/midi2musicxml?title=${titleParam}`, { method: "POST", body: form });
|
||||
if (!res.ok) return;
|
||||
|
||||
const { musicxml } = await res.json() as { musicxml: string };
|
||||
doc.musicXml = musicxml;
|
||||
|
||||
// Notify sheet view that MusicXML is now available
|
||||
const { document: currentDoc } = useAppStore.getState();
|
||||
if (currentDoc?.id === doc.id) {
|
||||
useAppStore.setState({ document: { ...doc } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared color utility functions used by the PIXI renderers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Multiply each RGB channel of a packed hex color by factor f, clamped to 255.
|
||||
* Used to generate lighter/darker variants of palette colors.
|
||||
*/
|
||||
export function lighten(hex: number, f: number): number {
|
||||
return (
|
||||
(Math.min(255, Math.round(((hex >> 16) & 0xff) * f)) << 16) |
|
||||
(Math.min(255, Math.round(((hex >> 8) & 0xff) * f)) << 8) |
|
||||
Math.min(255, Math.round((hex & 0xff) * f))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a CSS hex color string ("#rrggbb") to a packed numeric color.
|
||||
*/
|
||||
export function hexToNum(hex: string): number {
|
||||
return parseInt(hex.replace("#", ""), 16);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Browser-side .mxl extractor.
|
||||
*
|
||||
* .mxl (compressed MusicXML) is a ZIP archive containing:
|
||||
* META-INF/container.xml — lists the root MusicXML file
|
||||
* <root>.xml — the actual MusicXML document
|
||||
*
|
||||
* We parse the ZIP central directory to locate files, then decompress each
|
||||
* DEFLATE-compressed entry with the browser-native DecompressionStream API
|
||||
* (available in Chrome 80+, Firefox 113+, Safari 16.4+).
|
||||
*
|
||||
* Why not rely on AlphaTab's built-in .mxl support?
|
||||
* AlphaTab's MusicXmlImporter can parse .mxl, but it runs synchronously inside
|
||||
* a try/catch; if its ZIP reader or XML parser chokes on any detail of this
|
||||
* specific file (e.g. MuseScore 2 export quirks) the error is swallowed and
|
||||
* the sheet view silently stays blank. Extracting ourselves gives us a plain
|
||||
* XML string that takes the battle-tested string path in SheetMusicView.
|
||||
*/
|
||||
|
||||
const SIG_LOCAL = 0x04034b50; // PK\x03\x04
|
||||
const SIG_CENTRAL = 0x02014b50; // PK\x01\x02
|
||||
const SIG_EOCD = 0x06054b50; // PK\x05\x06
|
||||
|
||||
interface ZipEntry {
|
||||
name: string;
|
||||
localOffset: number;
|
||||
}
|
||||
|
||||
/** Scan for the End-of-Central-Directory record (last PK\x05\x06 in the file). */
|
||||
function findEocd(bytes: Uint8Array): number {
|
||||
for (let i = bytes.length - 22; i >= 0; i--) {
|
||||
if (
|
||||
bytes[i] === 0x50 && bytes[i + 1] === 0x4b &&
|
||||
bytes[i + 2] === 0x05 && bytes[i + 3] === 0x06
|
||||
) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Read central-directory entries to build a name→localOffset map. */
|
||||
function readCentralDirectory(view: DataView, bytes: Uint8Array): ZipEntry[] {
|
||||
const eocd = findEocd(bytes);
|
||||
if (eocd === -1) throw new Error("Not a ZIP archive");
|
||||
|
||||
if (view.getUint32(eocd, true) !== SIG_EOCD) throw new Error("Bad EOCD signature");
|
||||
|
||||
const numEntries = view.getUint16(eocd + 10, true);
|
||||
let cdPos = view.getUint32(eocd + 16, true);
|
||||
|
||||
const entries: ZipEntry[] = [];
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
if (view.getUint32(cdPos, true) !== SIG_CENTRAL) break;
|
||||
|
||||
const fileNameLen = view.getUint16(cdPos + 28, true);
|
||||
const extraLen = view.getUint16(cdPos + 30, true);
|
||||
const commentLen = view.getUint16(cdPos + 32, true);
|
||||
const localOffset = view.getUint32(cdPos + 42, true);
|
||||
const name = new TextDecoder().decode(bytes.slice(cdPos + 46, cdPos + 46 + fileNameLen));
|
||||
entries.push({ name, localOffset });
|
||||
cdPos += 46 + fileNameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Decompress one local-file entry, returning its raw bytes. */
|
||||
async function readLocalEntry(
|
||||
view: DataView,
|
||||
bytes: Uint8Array,
|
||||
entry: ZipEntry,
|
||||
): Promise<Uint8Array> {
|
||||
const off = entry.localOffset;
|
||||
if (view.getUint32(off, true) !== SIG_LOCAL) {
|
||||
throw new Error(`Bad local header at offset ${off}`);
|
||||
}
|
||||
|
||||
const compressionMethod = view.getUint16(off + 8, true);
|
||||
const compressedSize = view.getUint32(off + 18, true);
|
||||
const fileNameLen = view.getUint16(off + 26, true);
|
||||
const extraLen = view.getUint16(off + 28, true);
|
||||
const dataStart = off + 30 + fileNameLen + extraLen;
|
||||
|
||||
const compressed = bytes.slice(dataStart, dataStart + compressedSize);
|
||||
|
||||
if (compressionMethod === 0) {
|
||||
// Stored — no compression
|
||||
return compressed;
|
||||
}
|
||||
if (compressionMethod !== 8) {
|
||||
throw new Error(`Unsupported ZIP compression method ${compressionMethod}`);
|
||||
}
|
||||
|
||||
// DEFLATE (raw, no zlib header) — decompress with browser-native stream.
|
||||
// Cap decompressed size to 50 MB to prevent ZIP-bomb attacks: a crafted
|
||||
// .mxl with a tiny compressed payload that expands to gigabytes would
|
||||
// otherwise freeze or crash the browser tab.
|
||||
const MAX_DECOMPRESSED = 50 * 1024 * 1024; // 50 MB - largest real scores are <5 MB
|
||||
|
||||
const ds = new DecompressionStream("deflate-raw");
|
||||
const writer = ds.writable.getWriter();
|
||||
const reader = ds.readable.getReader();
|
||||
|
||||
writer.write(compressed);
|
||||
writer.close();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalDecompressed = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
totalDecompressed += value.length;
|
||||
if (totalDecompressed > MAX_DECOMPRESSED) {
|
||||
throw new Error("Decompressed MusicXML exceeds 50 MB — possible ZIP bomb");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let cursor = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, cursor);
|
||||
cursor += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the MusicXML string from a .mxl (ZIP-compressed MusicXML) ArrayBuffer.
|
||||
* Throws if the archive is malformed or missing required files.
|
||||
*/
|
||||
export async function extractXmlFromMxl(buffer: ArrayBuffer): Promise<string> {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
const entries = readCentralDirectory(view, bytes);
|
||||
|
||||
const find = (name: string) => entries.find(e => e.name === name);
|
||||
|
||||
// 1. Read META-INF/container.xml
|
||||
const containerEntry = find("META-INF/container.xml");
|
||||
if (!containerEntry) throw new Error("Not a valid .mxl file: missing META-INF/container.xml");
|
||||
|
||||
const containerBytes = await readLocalEntry(view, bytes, containerEntry);
|
||||
const containerXml = new TextDecoder("utf-8").decode(containerBytes);
|
||||
|
||||
// 2. Parse the root-file path (e.g. full-path="score.xml")
|
||||
const match = containerXml.match(/full-path="([^"]+)"/);
|
||||
if (!match) throw new Error('Malformed container.xml: no full-path attribute');
|
||||
const rootPath = match[1];
|
||||
|
||||
// 3. Read the root MusicXML entry
|
||||
const rootEntry = find(rootPath);
|
||||
if (!rootEntry) throw new Error(`Root file "${rootPath}" not found in .mxl archive`);
|
||||
|
||||
const xmlBytes = await readLocalEntry(view, bytes, rootEntry);
|
||||
return new TextDecoder("utf-8").decode(xmlBytes);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { FallingNotesView } from "../components/FallingNotes/FallingNotesView";
|
||||
import { PianoKeyboardView } from "../components/PianoKeyboard/PianoKeyboardView";
|
||||
import { SheetMusicView } from "../components/SheetMusic/SheetMusicView";
|
||||
import { TransportControls } from "../components/Transport/TransportControls";
|
||||
import { SettingsPanel } from "../components/Transport/SettingsPanel";
|
||||
import { DevicePanel } from "../components/DevicePanel/DevicePanel";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import type { LayoutMode } from "../store/useAppStore";
|
||||
import bus from "../engine/EventBus";
|
||||
import { midiToPitch, keySignatureToLabel } from "@bach-to-basics/shared";
|
||||
import { syncEngine } from "../engine/SyncEngine";
|
||||
|
||||
const TAB_LABELS: Record<LayoutMode, string> = {
|
||||
piano: "Piano",
|
||||
falling: "Notes",
|
||||
sheet: "Sheet",
|
||||
all: "All",
|
||||
};
|
||||
|
||||
const TAB_TITLES: Record<LayoutMode, string> = {
|
||||
piano: "Show only the piano keyboard view",
|
||||
falling: "Show only the falling notes view",
|
||||
sheet: "Show only the sheet music view",
|
||||
all: "Show all views at once",
|
||||
};
|
||||
|
||||
export function PracticeView() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const {
|
||||
loadMidiFile, loadMusicXmlFile, loadPdfFile,
|
||||
settings, updateSettings,
|
||||
status, play, pause, seek,
|
||||
loadError, clearLoadError,
|
||||
document: doc,
|
||||
isLoadingDocument,
|
||||
} = useAppStore();
|
||||
|
||||
// Pre-warm audio so piano keys respond with minimal latency.
|
||||
useEffect(() => {
|
||||
syncEngine.audio.load().catch(() => {});
|
||||
const preWarm = () => syncEngine.audio.load().catch(() => {});
|
||||
document.addEventListener("pointermove", preWarm, { once: true });
|
||||
return () => document.removeEventListener("pointermove", preWarm);
|
||||
}, []);
|
||||
|
||||
// Apply theme to <html> element
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = settings.theme;
|
||||
}, [settings.theme]);
|
||||
|
||||
// Spacebar: play / pause
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (e.code !== "Space" || tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
||||
e.preventDefault();
|
||||
if (status === "playing") pause();
|
||||
else play();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [status, play, pause]);
|
||||
|
||||
// ArrowLeft / ArrowRight: seek to prev / next note onset
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
||||
e.preventDefault();
|
||||
const { document: d, currentSeconds } = useAppStore.getState();
|
||||
if (!d?.notes.length) return;
|
||||
const onsets = [...new Set(d.notes.map((n) => n.startSeconds))].sort((a, b) => a - b);
|
||||
if (e.code === "ArrowRight") {
|
||||
const next = onsets.find((t) => t > currentSeconds + 0.05);
|
||||
if (next !== undefined) seek(next);
|
||||
} else {
|
||||
const prev = [...onsets].reverse().find((t) => t < currentSeconds - 0.05);
|
||||
if (prev !== undefined) seek(prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [seek]);
|
||||
|
||||
const loadFile = useCallback((file: File) => {
|
||||
if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
|
||||
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file);
|
||||
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
|
||||
}, [loadMidiFile, loadMusicXmlFile, loadPdfFile]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) loadFile(file);
|
||||
}, [loadFile]);
|
||||
|
||||
const { layoutMode } = settings;
|
||||
const setLayout = (mode: LayoutMode) => updateSettings({ layoutMode: mode });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-screen"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
{/* Flex layout with absolutely-centered tabs:
|
||||
- Left group: flex, takes natural width (never truncates)
|
||||
- Center tabs: position:absolute left:50% transform:translateX(-50%) - true viewport center
|
||||
- Right group: marginLeft:auto, pushes to far right
|
||||
This pattern is immune to left/right asymmetry that breaks grid-based centering. */}
|
||||
<header
|
||||
className="flex-shrink-0 flex items-center"
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 44,
|
||||
background: "var(--color-surface)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
}}
|
||||
>
|
||||
{/* Left: gradient logo + app name → links to GitHub repo */}
|
||||
<a
|
||||
href="https://github.com/gigliof/bach-to-basics"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 shrink-0"
|
||||
style={{ textDecoration: "none" }}
|
||||
aria-label="Bach to Basics on GitHub"
|
||||
>
|
||||
<div
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
className="flex items-center justify-center shrink-0 select-none"
|
||||
style={{
|
||||
width: 32, height: 32,
|
||||
background: "linear-gradient(135deg, #7c3aed 0%, #9333ea 100%)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 3px 10px rgba(147,51,234,0.45)",
|
||||
}}
|
||||
>
|
||||
<PianoKeysIcon />
|
||||
</div>
|
||||
{/* Typographic pun: "Bach" and "Basics" bold, "to" muted */}
|
||||
<span style={{ fontSize: 15, whiteSpace: "nowrap", letterSpacing: "-0.3px", lineHeight: 1 }}>
|
||||
<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: 700, color: "var(--color-text)" }}>Basics</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Center: layout mode tabs - absolutely centered in the header */}
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{ position: "absolute", left: "50%", transform: "translateX(-50%)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{ padding: 3, background: "var(--color-surface-2)", borderRadius: 999, border: "1px solid var(--color-border)" }}
|
||||
>
|
||||
{(["piano", "falling", "sheet", "all"] as LayoutMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setLayout(mode)}
|
||||
title={TAB_TITLES[mode]}
|
||||
aria-label={TAB_TITLES[mode]}
|
||||
className="transition-all font-semibold"
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
fontSize: 12,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
background: layoutMode === mode ? "var(--color-accent)" : "transparent",
|
||||
color: layoutMode === mode ? "#ffffff" : "var(--color-text-muted)",
|
||||
boxShadow: layoutMode === mode ? "0 0 12px rgba(147,51,234,0.35)" : "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{TAB_LABELS[mode]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: MIDI device chip + theme toggle + settings gear */}
|
||||
<div className="flex items-center gap-1.5" style={{ marginLeft: "auto" }}>
|
||||
<DevicePanel />
|
||||
<button
|
||||
onClick={() => updateSettings({ theme: settings.theme === "dark" ? "light" : "dark" })}
|
||||
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"}
|
||||
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)" }}
|
||||
>
|
||||
{settings.theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
title="Settings"
|
||||
aria-label="Open settings"
|
||||
className="flex items-center justify-center rounded transition-colors shrink-0"
|
||||
style={{ width: 30, height: 30, background: "var(--color-surface-2)", border: "1px solid var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<GearIcon />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Main content - layout-aware ────────────────────────────────────── */}
|
||||
<div className="flex flex-1 overflow-hidden min-h-0 relative">
|
||||
{layoutMode === "piano" && (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<PianoModeBackground />
|
||||
<PianoKeyboardView />
|
||||
</div>
|
||||
)}
|
||||
{layoutMode === "falling" && (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<FallingNotesView />
|
||||
<PianoKeyboardView />
|
||||
</div>
|
||||
)}
|
||||
{layoutMode === "sheet" && (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<SheetMusicView />
|
||||
<PianoKeyboardView />
|
||||
</div>
|
||||
)}
|
||||
{layoutMode === "all" && (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-col flex-shrink-0 min-h-0 overflow-hidden relative"
|
||||
style={{ flex: "0 0 42%", minWidth: 260, maxWidth: 440 }}
|
||||
>
|
||||
<SheetMusicView />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<FallingNotesView />
|
||||
<PianoKeyboardView />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Empty state overlay ────────────────────────────────────────────── */}
|
||||
{!doc && !isLoadingDocument && (
|
||||
<EmptyState onImport={loadFile} />
|
||||
)}
|
||||
|
||||
{/* ── Error toast ────────────────────────────────────────────────────── */}
|
||||
{loadError && <ErrorToast message={loadError} onDismiss={clearLoadError} />}
|
||||
|
||||
{/* ── Transport bar ──────────────────────────────────────────────────── */}
|
||||
<TransportControls />
|
||||
|
||||
{/* ── Status bar ─────────────────────────────────────────────────────── */}
|
||||
<StatusBar />
|
||||
|
||||
{/* ── Settings panel (managed here, gear is in header) ───────────────── */}
|
||||
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ onImport }: { onImport: (file: File) => void }) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 20,
|
||||
textAlign: "center",
|
||||
padding: 40,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{/* Illustration */}
|
||||
<div
|
||||
style={{
|
||||
width: 72, height: 72,
|
||||
borderRadius: 18,
|
||||
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)",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "var(--color-accent-text)",
|
||||
}}
|
||||
>
|
||||
<MusicNoteIcon />
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text)", marginBottom: 8 }}>
|
||||
No file loaded
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||
Import a MIDI, MusicXML, or PDF sheet music file to start practicing
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary CTA */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
pointerEvents: "all",
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
padding: "11px 28px",
|
||||
background: "var(--color-accent)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 10,
|
||||
fontSize: 14, fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
boxShadow: "0 0 24px rgba(147,51,234,0.45)",
|
||||
fontFamily: "inherit",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)"; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 34px rgba(147,51,234,0.6)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = ""; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 24px rgba(147,51,234,0.45)"; }}
|
||||
>
|
||||
<UploadIconLg />
|
||||
Import a file
|
||||
</button>
|
||||
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginTop: -10 }}>
|
||||
or drag & drop anywhere
|
||||
</span>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".mid,.midi,.xml,.mxl,.pdf"
|
||||
className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const UploadIconLg = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Piano-mode background ─────────────────────────────────────────────────────
|
||||
// Mirrors the grid drawn by FallingNotesRenderer so the piano-only view has the
|
||||
// same dark background + black-key lanes + octave separator lines.
|
||||
|
||||
const MIDI_MIN_BG = 21; // A0
|
||||
const MIDI_MAX_BG = 108; // C8
|
||||
|
||||
/** Precompute whiteKeyIndex (cumulative white-key counter per MIDI note). */
|
||||
function buildWhiteKeyIndex() {
|
||||
const isBlk = (m: number) => [1,3,6,8,10].includes(m % 12);
|
||||
const idx = new Array<number>(MIDI_MAX_BG + 1).fill(0);
|
||||
let wi = 0;
|
||||
for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) {
|
||||
idx[m] = wi;
|
||||
if (!isBlk(m)) wi++;
|
||||
}
|
||||
return { idx, totalWhite: wi };
|
||||
}
|
||||
const { idx: wkIdx, totalWhite: TOTAL_WHITE_BG } = buildWhiteKeyIndex();
|
||||
|
||||
function drawPianoGrid(canvas: HTMLCanvasElement) {
|
||||
const { width, height } = canvas;
|
||||
if (width === 0 || height === 0) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const isBlk = (m: number) => [1,3,6,8,10].includes(m % 12);
|
||||
const wW = width / TOTAL_WHITE_BG;
|
||||
|
||||
// Clear to transparent - the CSS background on the parent div provides the colour.
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Faint octave separator lines at every C - neutral, works on dark and light
|
||||
ctx.fillStyle = "rgba(128,128,144,0.2)";
|
||||
for (let m = MIDI_MIN_BG; m <= MIDI_MAX_BG; m++) {
|
||||
if (m % 12 === 0 && !isBlk(m)) {
|
||||
const x = wkIdx[m] * wW;
|
||||
ctx.fillRect(x, 0, 1, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function PianoModeBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { settings } = useAppStore();
|
||||
|
||||
// Reusable draw callback
|
||||
const redraw = useMemo(() => () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
drawPianoGrid(canvas);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
canvas.width = Math.round(width);
|
||||
canvas.height = Math.round(height);
|
||||
if (settings.showGrid) redraw();
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
// Initial size
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
if (settings.showGrid) redraw();
|
||||
|
||||
return () => ro.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [redraw]);
|
||||
|
||||
// Redraw when showGrid toggles
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (settings.showGrid) {
|
||||
redraw();
|
||||
} else {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}, [settings.showGrid, redraw]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
minHeight: 0,
|
||||
background: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
|
||||
borderTop: "1px solid var(--color-notes-border)",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: "block", width: "100%", height: "100%" }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status bar ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBar() {
|
||||
const { document: doc, currentSeconds, tempoMultiplier, status } = useAppStore();
|
||||
const [lastNote, setLastNote] = useState<string>("");
|
||||
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onNoteOn = ({ midi }: { midi: number; velocity: number }) => {
|
||||
setLastNote(midiToPitch(midi));
|
||||
if (clearTimerRef.current) clearTimeout(clearTimerRef.current);
|
||||
clearTimerRef.current = setTimeout(() => setLastNote(""), 2000);
|
||||
};
|
||||
bus.on("input:note:on", onNoteOn as never);
|
||||
return () => {
|
||||
bus.off("input:note:on", onNoteOn as never);
|
||||
if (clearTimerRef.current) clearTimeout(clearTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const bpm = doc?.tempoMap[0]?.bpm ?? null;
|
||||
const effectiveBpm = bpm ? Math.round(bpm * tempoMultiplier) : null;
|
||||
const fmt = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, "0")}`;
|
||||
const keySig = doc?.keySignature ? keySignatureToLabel(doc.keySignature) : null;
|
||||
const isActive = status === "playing" || status === "paused";
|
||||
|
||||
return (
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
{doc ? (
|
||||
<>
|
||||
{isActive && (
|
||||
<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>No file loaded - drag & drop or click ↑ Import</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0" />
|
||||
{doc && <span className="shrink-0">{fmt(currentSeconds)} / {fmt(doc.totalDuration)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Dot = () => <span style={{ color: "var(--color-text-dim)" }}>·</span>;
|
||||
|
||||
// ── Error toast ───────────────────────────────────────────────────────────────
|
||||
// Shown whenever loadError is non-null. Dismissible with ✕ or auto-dismissed
|
||||
// after 12 s so it doesn't block the UI forever.
|
||||
|
||||
function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () => void }) {
|
||||
useEffect(() => {
|
||||
// Longer timeout for multi-line messages (setup instructions, etc.)
|
||||
const delay = message.includes("\n") ? 30_000 : 12_000;
|
||||
const t = setTimeout(onDismiss, delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [message, onDismiss]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 68, // above the transport bar
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 2000,
|
||||
maxWidth: 520,
|
||||
width: "calc(100% - 32px)",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid rgba(239,68,68,0.5)",
|
||||
borderRadius: 10,
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.45)",
|
||||
padding: "10px 14px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
fontSize: 12,
|
||||
color: "var(--color-text)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{/* Warning icon */}
|
||||
<span style={{ color: "#f87171", fontSize: 16, lineHeight: 1.2, flexShrink: 0 }}>⚠</span>
|
||||
<span style={{ flex: 1, wordBreak: "break-word" }}>
|
||||
{message.split("\n").map((line, i) => (
|
||||
<span key={i}>{line}{i < message.split("\n").length - 1 && <br />}</span>
|
||||
))}
|
||||
</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss error"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--color-text-muted)",
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MusicNoteIcon = () => (
|
||||
<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"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Piano keyboard icon: 5 white keys with 3 black keys (C-D-E-F-G pattern) */
|
||||
const PianoKeysIcon = () => (
|
||||
<svg width="18" height="14" viewBox="0 0 17 14" fill="none" aria-hidden="true">
|
||||
{/* White keys */}
|
||||
<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="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="14" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95"/>
|
||||
{/* 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="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)"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SunIcon = () => (
|
||||
<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"/>
|
||||
</svg>
|
||||
);
|
||||
const MoonIcon = () => (
|
||||
<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"/>
|
||||
</svg>
|
||||
);
|
||||
const GearIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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"/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
// Web Worker: parses MIDI ArrayBuffer --> NoteEvent[] + metadata
|
||||
// Uses @tonejs/midi for parsing (synchronous, no nested worker issues).
|
||||
|
||||
import { Midi } from "@tonejs/midi";
|
||||
import type { NoteEvent, TempoEvent, TimeSignatureEvent, MusicDocument, KeySignature, SustainRange } from "@bach-to-basics/shared";
|
||||
import { midiToPitch } from "@bach-to-basics/shared";
|
||||
|
||||
self.onmessage = (e: MessageEvent<{ buffer: ArrayBuffer; id: string; title: string }>) => {
|
||||
const { buffer, id } = e.data;
|
||||
// L5 - strip control characters that could corrupt downstream metadata
|
||||
const title = e.data.title.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
|
||||
|
||||
try {
|
||||
const midi = new Midi(buffer);
|
||||
const ppq = midi.header.ppq;
|
||||
|
||||
// ── Tempo map ────────────────────────────────────────────────────────────
|
||||
const tempoMap: TempoEvent[] = midi.header.tempos.map((t) => ({
|
||||
tick: t.ticks,
|
||||
bpm: t.bpm,
|
||||
}));
|
||||
if (tempoMap.length === 0) tempoMap.push({ tick: 0, bpm: 120 });
|
||||
|
||||
// ── Time signatures ──────────────────────────────────────────────────────
|
||||
const timeSignatures: TimeSignatureEvent[] = midi.header.timeSignatures.map((ts) => ({
|
||||
tick: ts.ticks,
|
||||
numerator: ts.timeSignature[0],
|
||||
denominator: ts.timeSignature[1],
|
||||
}));
|
||||
if (timeSignatures.length === 0) timeSignatures.push({ tick: 0, numerator: 4, denominator: 4 });
|
||||
|
||||
// ── Key signature ────────────────────────────────────────────────────────
|
||||
// @tonejs/midi exposes header.keySignatures as { ticks, key, scale }[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawKeySigs: any[] = (midi.header as any).keySignatures ?? [];
|
||||
let keySignature: KeySignature | null = null;
|
||||
if (rawKeySigs.length > 0) {
|
||||
const first = rawKeySigs[0];
|
||||
// L5 - sanitize key field: allow only valid key-signature characters
|
||||
const rawKey = String(first.key ?? "C");
|
||||
const safeKey = rawKey.replace(/[^A-Ga-g#b]/g, "").slice(0, 3) || "C";
|
||||
keySignature = {
|
||||
key: safeKey,
|
||||
scale: first.scale === "minor" ? "minor" : "major",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Note events ──────────────────────────────────────────────────────────
|
||||
const notes: NoteEvent[] = [];
|
||||
let idCounter = 0;
|
||||
|
||||
for (let trackIdx = 0; trackIdx < midi.tracks.length; trackIdx++) {
|
||||
const track = midi.tracks[trackIdx];
|
||||
|
||||
// Hand assignment by track: track 0 = right, track 1 = left (standard)
|
||||
let hand: NoteEvent["hand"] = "unknown";
|
||||
if (midi.tracks.length >= 2) {
|
||||
hand = trackIdx === 0 ? "right" : "left";
|
||||
}
|
||||
|
||||
for (const note of track.notes) {
|
||||
notes.push({
|
||||
id: `n${idCounter++}`,
|
||||
midi: note.midi,
|
||||
pitch: midiToPitch(note.midi),
|
||||
startTick: note.ticks,
|
||||
durationTick: note.durationTicks,
|
||||
startSeconds: note.time, // @tonejs/midi computes this
|
||||
endSeconds: note.time + note.duration,
|
||||
hand,
|
||||
finger: null,
|
||||
velocity: Math.round(note.velocity * 127),
|
||||
channel: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
notes.sort((a, b) => a.startSeconds - b.startSeconds);
|
||||
|
||||
const totalDuration = notes.length > 0
|
||||
? Math.max(...notes.map((n) => n.endSeconds))
|
||||
: 0;
|
||||
|
||||
// ── Sustain pedal (CC64) to sustain ranges ───────────────────────────────
|
||||
// @tonejs/midi normalises CC values to 0-1; ≥0.5 means pedal is depressed.
|
||||
const sustainRanges: SustainRange[] = [];
|
||||
const allCC64: { time: number; value: number }[] = [];
|
||||
for (const track of midi.tracks) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cc64: any[] = (track.controlChanges as any)[64] ?? [];
|
||||
for (const cc of cc64) {
|
||||
allCC64.push({ time: cc.time as number, value: cc.value as number });
|
||||
}
|
||||
}
|
||||
allCC64.sort((a, b) => a.time - b.time);
|
||||
|
||||
let sustainStart: number | null = null;
|
||||
for (const cc of allCC64) {
|
||||
const isOn = cc.value >= 0.5;
|
||||
if (isOn && sustainStart === null) {
|
||||
sustainStart = cc.time;
|
||||
} else if (!isOn && sustainStart !== null) {
|
||||
sustainRanges.push({ startSeconds: sustainStart, endSeconds: cc.time });
|
||||
sustainStart = null;
|
||||
}
|
||||
}
|
||||
if (sustainStart !== null) {
|
||||
sustainRanges.push({ startSeconds: sustainStart, endSeconds: totalDuration });
|
||||
}
|
||||
|
||||
const doc: MusicDocument = {
|
||||
id,
|
||||
title,
|
||||
sourceType: "midi",
|
||||
musicXml: null,
|
||||
midiBuffer: buffer,
|
||||
notes,
|
||||
tempoMap,
|
||||
timeSignatures,
|
||||
totalDuration,
|
||||
ppq,
|
||||
keySignature,
|
||||
youtubeId: null,
|
||||
youtubeSyncOffset: 0,
|
||||
fingeringVersion: "none",
|
||||
sustainRanges,
|
||||
};
|
||||
|
||||
self.postMessage({ ok: true, doc });
|
||||
} catch (err) {
|
||||
self.postMessage({ ok: false, error: String(err) });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@bach-to-basics/shared": ["../shared/types/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@bach-to-basics/shared": resolve(__dirname, "../shared/types/index.ts"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["@coderline/alphatab"],
|
||||
include: ["pixi.js"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "bach-to-basics",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Bach to Basics — browser-based piano learning app with falling notes, sheet music, and MIDI support",
|
||||
"author": "gigliof",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/gigliof/bach-to-basics#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/gigliof/bach-to-basics.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/gigliof/bach-to-basics/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently --names \"frontend,backend\" --prefix-colors \"cyan,yellow\" \"pnpm --filter frontend dev\" \"cd backend && .venv/bin/uvicorn main:app --reload --port 8000\"",
|
||||
"build": "pnpm --filter frontend build",
|
||||
"test": "pnpm --filter frontend test",
|
||||
"backend:setup": "cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
],
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "frontend"
|
||||
- "shared"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@bach-to-basics/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./types/index.ts",
|
||||
"types": "./types/index.ts"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// ─── Core music data model ────────────────────────────────────────────────────
|
||||
// All views (SheetMusic, PianoKeyboard, FallingNotes) are driven by these types.
|
||||
// NoteEvent arrays are computed ONCE at import time and never mutated during playback.
|
||||
|
||||
export type Hand = "left" | "right" | "unknown";
|
||||
export type Finger = 1 | 2 | 3 | 4 | 5;
|
||||
export type SourceType = "midi" | "musicxml" | "guitarpro" | "mp3" | "youtube";
|
||||
export type FingeringVersion = "auto" | "manual" | "none";
|
||||
export type PlaybackStatus = "stopped" | "playing" | "paused" | "waiting";
|
||||
|
||||
export interface NoteEvent {
|
||||
id: string;
|
||||
/** MIDI note number 0-127 */
|
||||
midi: number;
|
||||
/** Human-readable pitch e.g. "C4", "F#5" */
|
||||
pitch: string;
|
||||
startTick: number;
|
||||
durationTick: number;
|
||||
/** Wall-clock start time at tempo multiplier 1.0 (seconds) */
|
||||
startSeconds: number;
|
||||
/** Wall-clock end time at tempo multiplier 1.0 (seconds) */
|
||||
endSeconds: number;
|
||||
hand: Hand;
|
||||
finger: Finger | null;
|
||||
/** MIDI velocity 0-127 */
|
||||
velocity: number;
|
||||
channel: number;
|
||||
}
|
||||
|
||||
export interface TempoEvent {
|
||||
tick: number;
|
||||
bpm: number;
|
||||
}
|
||||
|
||||
export interface TimeSignatureEvent {
|
||||
tick: number;
|
||||
numerator: number;
|
||||
denominator: number;
|
||||
}
|
||||
|
||||
export interface KeySignature {
|
||||
/** Root note name: "C" | "G" | "D" | "A" | "E" | "B" | "F#" | "C#" | "F" | "Bb" | "Eb" | "Ab" | "Db" | "Gb" | "Cb" */
|
||||
key: string;
|
||||
scale: "major" | "minor";
|
||||
}
|
||||
|
||||
export interface SustainRange {
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
}
|
||||
|
||||
export interface MusicDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
sourceType: SourceType;
|
||||
/** MusicXML string - drives AlphaTab sheet music view */
|
||||
musicXml: string | null;
|
||||
/** Raw .mxl bytes (ZIP-compressed MusicXML) - present only for direct .mxl imports */
|
||||
mxlBuffer?: ArrayBuffer;
|
||||
/** Raw MIDI binary - drives Tone.js playback */
|
||||
midiBuffer: ArrayBuffer | null;
|
||||
/** Flat sorted array of all note events, pre-computed at import time */
|
||||
notes: NoteEvent[];
|
||||
tempoMap: TempoEvent[];
|
||||
timeSignatures: TimeSignatureEvent[];
|
||||
/** Total duration in seconds at tempo multiplier 1.0 */
|
||||
totalDuration: number;
|
||||
/** Ticks per quarter note (PPQ) */
|
||||
ppq: number;
|
||||
/** Key signature - null if not present in the source file */
|
||||
keySignature: KeySignature | null;
|
||||
/** YouTube video ID if source is YouTube */
|
||||
youtubeId: string | null;
|
||||
/** User-adjustable sync offset for YouTube (seconds) */
|
||||
youtubeSyncOffset: number;
|
||||
fingeringVersion: FingeringVersion;
|
||||
/** Sustain pedal (CC64) on/off ranges, pre-converted to seconds */
|
||||
sustainRanges?: SustainRange[];
|
||||
}
|
||||
|
||||
export interface PlaybackState {
|
||||
status: PlaybackStatus;
|
||||
/** Current position in seconds (scaled by tempo multiplier) */
|
||||
currentSeconds: number;
|
||||
/** Tempo multiplier: 0.25 = 25%, 1.0 = 100%, 2.0 = 200% */
|
||||
tempoMultiplier: number;
|
||||
/** Loop start in seconds (tempo-scaled), null = no loop */
|
||||
loopStart: number | null;
|
||||
/** Loop end in seconds (tempo-scaled), null = no loop */
|
||||
loopEnd: number | null;
|
||||
/** Which hands are active for playback and wait-mode evaluation */
|
||||
activeHands: Set<Hand>;
|
||||
/** When true, transport pauses until user plays the expected note */
|
||||
waitMode: boolean;
|
||||
}
|
||||
|
||||
// ─── Utility: key signature to display label ─────────────────────────────────
|
||||
export function keySignatureToLabel(ks: KeySignature): string {
|
||||
return `${ks.key} ${ks.scale}`;
|
||||
}
|
||||
|
||||
// ─── Utility: MIDI ticks to seconds using a tempo map ────────────────────────
|
||||
function tickToSeconds(tick: number, tempoMap: TempoEvent[], ppq: number): number {
|
||||
let sec = 0;
|
||||
let prevTick = 0;
|
||||
for (let i = 0; i < tempoMap.length; i++) {
|
||||
const ev = tempoMap[i];
|
||||
if (ev.tick > tick) break;
|
||||
const next = tempoMap[i + 1];
|
||||
const endTick = (next && next.tick <= tick) ? next.tick : tick;
|
||||
sec += (endTick - prevTick) * (60 / (ev.bpm * ppq));
|
||||
prevTick = endTick;
|
||||
if (!next || next.tick > tick) break;
|
||||
}
|
||||
return sec;
|
||||
}
|
||||
|
||||
// ─── Utility: compute measure start times in seconds ─────────────────────────
|
||||
export function computeMeasureSeconds(
|
||||
timeSignatures: TimeSignatureEvent[],
|
||||
tempoMap: TempoEvent[],
|
||||
ppq: number,
|
||||
totalDuration: number,
|
||||
): number[] {
|
||||
if (!timeSignatures.length || !tempoMap.length) return [];
|
||||
|
||||
const measures: number[] = [];
|
||||
let measureTick = 0;
|
||||
let tsIdx = 0;
|
||||
|
||||
while (true) {
|
||||
// Advance to the right time signature segment
|
||||
while (tsIdx + 1 < timeSignatures.length && timeSignatures[tsIdx + 1].tick <= measureTick) {
|
||||
tsIdx++;
|
||||
}
|
||||
const ts = timeSignatures[tsIdx];
|
||||
const ticksPerMeasure = Math.round(ppq * 4 * ts.numerator / ts.denominator);
|
||||
const sec = tickToSeconds(measureTick, tempoMap, ppq);
|
||||
if (sec > totalDuration + 1) break;
|
||||
measures.push(sec);
|
||||
measureTick += ticksPerMeasure;
|
||||
if (measures.length > 2000) break; // safety cap
|
||||
}
|
||||
|
||||
return measures;
|
||||
}
|
||||
|
||||
// ─── Utility: compute every beat start time in seconds ───────────────────────
|
||||
export function computeBeatSeconds(
|
||||
timeSignatures: TimeSignatureEvent[],
|
||||
tempoMap: TempoEvent[],
|
||||
ppq: number,
|
||||
totalDuration: number,
|
||||
): number[] {
|
||||
if (!timeSignatures.length || !tempoMap.length) return [];
|
||||
|
||||
const beats: number[] = [];
|
||||
let beatTick = 0;
|
||||
let tsIdx = 0;
|
||||
|
||||
while (true) {
|
||||
while (tsIdx + 1 < timeSignatures.length && timeSignatures[tsIdx + 1].tick <= beatTick) {
|
||||
tsIdx++;
|
||||
}
|
||||
const ts = timeSignatures[tsIdx];
|
||||
// One beat = one denominator unit relative to the quarter note
|
||||
const ticksPerBeat = Math.round(ppq * 4 / ts.denominator);
|
||||
const sec = tickToSeconds(beatTick, tempoMap, ppq);
|
||||
if (sec > totalDuration + 1) break;
|
||||
beats.push(sec);
|
||||
beatTick += ticksPerBeat;
|
||||
if (beats.length > 20000) break; // safety cap
|
||||
}
|
||||
|
||||
return beats;
|
||||
}
|
||||
|
||||
// ─── Utility: scale stored seconds by tempo multiplier ───────────────────────
|
||||
export function scaleTime(storedSeconds: number, tempoMultiplier: number): number {
|
||||
return storedSeconds / tempoMultiplier;
|
||||
}
|
||||
|
||||
// ─── Utility: MIDI note number to pitch string ────────────────────────────────
|
||||
const SHARP_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
||||
const FLAT_NAMES = ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B"];
|
||||
|
||||
export function midiToPitch(midi: number, useFlats = false): string {
|
||||
const names = useFlats ? FLAT_NAMES : SHARP_NAMES;
|
||||
const octave = Math.floor(midi / 12) - 1;
|
||||
return `${names[midi % 12]}${octave}`;
|
||||
}
|
||||
|
||||
export function midiToNoteName(midi: number, useFlats = false): string {
|
||||
const names = useFlats ? FLAT_NAMES : SHARP_NAMES;
|
||||
return names[midi % 12];
|
||||
}
|
||||
|
||||
export function isBlackKey(midi: number): boolean {
|
||||
const mod = midi % 12;
|
||||
return [1, 3, 6, 8, 10].includes(mod);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// ─── EventBus typed event map ─────────────────────────────────────────────────
|
||||
// All inter-component communication flows through these events.
|
||||
// Views subscribe; only SyncEngine emits (except input:note from MIDI/WebMidi).
|
||||
|
||||
import type { NoteEvent, PlaybackStatus } from "./MusicData";
|
||||
|
||||
export interface SyncEventMap {
|
||||
// ── Playback events ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emitted LOOKAHEAD_SECONDS before a note plays.
|
||||
* FallingNotes pre-renders the bar so it's ready when it enters the viewport.
|
||||
*/
|
||||
"note:upcoming": NoteEvent;
|
||||
|
||||
/**
|
||||
* Emitted at the exact moment a note begins playing.
|
||||
* PianoKeyboard highlights the key; Tone.js triggers the sample.
|
||||
*/
|
||||
"note:on": NoteEvent;
|
||||
|
||||
/**
|
||||
* Emitted at the exact moment a note stops playing.
|
||||
* PianoKeyboard un-highlights the key.
|
||||
*/
|
||||
"note:off": NoteEvent;
|
||||
|
||||
/**
|
||||
* Emitted every animation frame (~16ms) with the current playback position.
|
||||
* SheetMusicView drives the AlphaTab cursor; FallingNotesView moves bars.
|
||||
*/
|
||||
"transport:tick": { seconds: number; tick: number };
|
||||
|
||||
/**
|
||||
* Emitted when transport status changes (play/pause/stop/loop/waiting).
|
||||
*/
|
||||
"transport:stateChange": { status: PlaybackStatus; seconds: number };
|
||||
|
||||
// ── User input events ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emitted when the user plays a note on the physical piano or virtual keyboard.
|
||||
* SyncEngine uses this for wait-mode comparison.
|
||||
* PianoKeyboard echoes the key highlight with a different color.
|
||||
*/
|
||||
"input:note:on": { midi: number; velocity: number };
|
||||
|
||||
/**
|
||||
* Emitted when the user releases a note.
|
||||
*/
|
||||
"input:note:off": { midi: number };
|
||||
|
||||
// ── Document events ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emitted when a new MusicDocument is loaded and all views should reset.
|
||||
*/
|
||||
"document:loaded": { id: string; title: string; totalDuration: number };
|
||||
|
||||
/**
|
||||
* Emitted when settings change that affect all views (e.g., toggle labels,
|
||||
* change hand colors, toggle fingering).
|
||||
*/
|
||||
"settings:changed": { key: string; value: unknown };
|
||||
|
||||
// ── Transport ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emitted at the start of every seek/skip so UI can release pressed keys
|
||||
* before Tone cancels pending note:off callbacks.
|
||||
*/
|
||||
"transport:seek": { seconds: number };
|
||||
|
||||
// ── Practice features ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emitted by SyncEngine when the speed trainer advances one step.
|
||||
* The store updates settings.speedTrainer.currentPct to keep UI in sync.
|
||||
*/
|
||||
"speedTrainer:stepped": { currentPct: number };
|
||||
|
||||
/**
|
||||
* Emitted by AccuracyTracker after each note event with running totals.
|
||||
*/
|
||||
"accuracy:update": { correct: number; wrong: number; missed: number; score: number };
|
||||
}
|
||||
|
||||
export type SyncEventName = keyof SyncEventMap;
|
||||
export type SyncEventPayload<K extends SyncEventName> = SyncEventMap[K];
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./MusicData";
|
||||
export * from "./SyncEvent";
|
||||