Initial commit

This commit is contained in:
Fabricio Giglio
2026-05-06 17:56:38 +02:00
commit 8f4df1451e
78 changed files with 12130 additions and 0 deletions
+34
View File
@@ -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
+49
View File
@@ -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
+109
View File
@@ -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/
+49
View File
@@ -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.
+21
View File
@@ -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.
+58
View File
@@ -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.
+225
View File
@@ -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.
![Bach to Basics demo](docs/demo.gif)
| Sheet music | All views | Settings |
|---|---|---|
| ![Sheet music](docs/screenshot-sheet.png) | ![All views](docs/screenshot-all.png) | ![Settings](docs/screenshot-settings.png) |
## 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.
+8
View File
@@ -0,0 +1,8 @@
.venv/
.venv-ai/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.DS_Store
bin/*.jar
+32
View File
@@ -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"]
+257
View File
@@ -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"}
+11
View File
@@ -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
+18
View File
@@ -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
View File
+132
View File
@@ -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.")
+39
View File
@@ -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.",
)
+140
View File
@@ -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.")
+163
View File
@@ -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;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.")
+90
View File
@@ -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)
View File
+183
View File
@@ -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")
+50
View File
@@ -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
+444
View File
@@ -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)
+76
View File
@@ -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)
+59
View File
@@ -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
+43
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.vite/
tsconfig.tsbuildinfo
.DS_Store
test-results/
playwright-report/
+27
View File
@@ -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;"]
+45
View File
@@ -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>
+54
View File
@@ -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;
}
}
+47
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+12
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.
+5
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
@@ -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>
);
+119
View File
@@ -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;
+109
View File
@@ -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 */ }
}
}
+11
View File
@@ -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;
+79
View File
@@ -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";
}
}
+545
View File
@@ -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;
+37
View File
@@ -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;
}
+277
View File
@@ -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);
}
+7
View File
@@ -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 />);
+555
View File
@@ -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 } });
}
}
+22
View File
@@ -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);
}
+156
View File
@@ -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);
}
+619
View File
@@ -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 &amp; 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 &amp; 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>
);
+133
View File
@@ -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) });
}
};
+23
View File
@@ -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"]
}
+29
View File
@@ -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"],
},
});
+33
View File
@@ -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"
}
}
}
+2500
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- "frontend"
- "shared"
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@bach-to-basics/shared",
"version": "0.1.0",
"private": true,
"main": "./types/index.ts",
"types": "./types/index.ts"
}
+201
View File
@@ -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);
}
+89
View File
@@ -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];
+2
View File
@@ -0,0 +1,2 @@
export * from "./MusicData";
export * from "./SyncEvent";