From 8f4df1451ed637130800ee69134a54eb42198231 Mon Sep 17 00:00:00 2001 From: Fabricio Giglio <58532944+gigliof@users.noreply.github.com> Date: Wed, 6 May 2026 17:56:38 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 34 + .env.example | 49 + .gitignore | 109 + CONTRIBUTING.md | 49 + LICENSE | 21 + NOTICE | 58 + README.md | 225 ++ backend/.dockerignore | 8 + backend/Dockerfile | 32 + backend/main.py | 257 ++ backend/requirements-transcribe.txt | 11 + backend/requirements.txt | 18 + backend/routers/__init__.py | 0 backend/routers/export_routes.py | 132 + backend/routers/fingering.py | 39 + backend/routers/omr.py | 140 + backend/routers/transcribe.py | 163 ++ backend/routers/youtube.py | 90 + backend/services/__init__.py | 0 backend/services/audiveris.py | 183 ++ backend/services/basic_pitch.py | 50 + backend/services/music21_svc.py | 444 +++ backend/services/pianoplayer_svc.py | 76 + backend/services/ytdlp.py | 59 + docker-compose.yml | 43 + docs/demo.gif | Bin 0 -> 3507742 bytes docs/logo.png | Bin 0 -> 1044778 bytes docs/screenshot-all.png | Bin 0 -> 133810 bytes docs/screenshot-notes-cascade.png | Bin 0 -> 555048 bytes docs/screenshot-notes-dark.png | Bin 0 -> 594018 bytes docs/screenshot-notes-light.png | Bin 0 -> 462549 bytes docs/screenshot-settings.png | Bin 0 -> 94794 bytes docs/screenshot-sheet.png | Bin 0 -> 133391 bytes docs/screenshot-welcome.png | Bin 0 -> 363558 bytes frontend/.dockerignore | 7 + frontend/Dockerfile | 27 + frontend/index.html | 45 + frontend/nginx.conf | 54 + frontend/package.json | 47 + frontend/public/apple-touch-icon.png | Bin 0 -> 18797 bytes frontend/public/demo.mid | Bin 0 -> 41243 bytes frontend/public/favicon-dark.png | Bin 0 -> 1847 bytes frontend/public/favicon-light.png | Bin 0 -> 1839 bytes frontend/public/favicon.png | Bin 0 -> 1839 bytes frontend/public/favicon.svg | 12 + frontend/public/logo.png | Bin 0 -> 1044778 bytes frontend/public/soundfont/sonivox.sf3 | Bin 0 -> 977208 bytes frontend/src/App.tsx | 5 + .../components/DevicePanel/DevicePanel.tsx | 206 ++ .../FallingNotes/FallingNotesRenderer.ts | 931 ++++++ .../FallingNotes/FallingNotesView.tsx | 195 ++ .../components/PianoKeyboard/PianoKeyboard.ts | 523 ++++ .../PianoKeyboard/PianoKeyboardView.tsx | 118 + .../components/SheetMusic/SheetMusicView.tsx | 284 ++ .../components/Transport/SettingsPanel.tsx | 1298 +++++++++ .../Transport/TransportControls.tsx | 532 ++++ frontend/src/engine/AccuracyTracker.ts | 119 + frontend/src/engine/AudioEngine.ts | 109 + frontend/src/engine/EventBus.ts | 11 + frontend/src/engine/MidiClock.ts | 79 + frontend/src/engine/SyncEngine.ts | 545 ++++ frontend/src/hooks/useAccuracy.ts | 37 + frontend/src/index.css | 277 ++ frontend/src/main.tsx | 7 + frontend/src/store/useAppStore.ts | 555 ++++ frontend/src/utils/colorUtils.ts | 22 + frontend/src/utils/mxlExtract.ts | 156 + frontend/src/views/PracticeView.tsx | 619 ++++ frontend/src/workers/midi.worker.ts | 133 + frontend/tsconfig.json | 23 + frontend/vite.config.ts | 29 + package.json | 33 + pnpm-lock.yaml | 2500 +++++++++++++++++ pnpm-workspace.yaml | 3 + shared/package.json | 7 + shared/types/MusicData.ts | 201 ++ shared/types/SyncEvent.ts | 89 + shared/types/index.ts | 2 + 78 files changed, 12130 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/main.py create mode 100644 backend/requirements-transcribe.txt create mode 100644 backend/requirements.txt create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/export_routes.py create mode 100644 backend/routers/fingering.py create mode 100644 backend/routers/omr.py create mode 100644 backend/routers/transcribe.py create mode 100644 backend/routers/youtube.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/audiveris.py create mode 100644 backend/services/basic_pitch.py create mode 100644 backend/services/music21_svc.py create mode 100644 backend/services/pianoplayer_svc.py create mode 100644 backend/services/ytdlp.py create mode 100644 docker-compose.yml create mode 100644 docs/demo.gif create mode 100644 docs/logo.png create mode 100644 docs/screenshot-all.png create mode 100644 docs/screenshot-notes-cascade.png create mode 100644 docs/screenshot-notes-dark.png create mode 100644 docs/screenshot-notes-light.png create mode 100644 docs/screenshot-settings.png create mode 100644 docs/screenshot-sheet.png create mode 100644 docs/screenshot-welcome.png create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/demo.mid create mode 100644 frontend/public/favicon-dark.png create mode 100644 frontend/public/favicon-light.png create mode 100644 frontend/public/favicon.png create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/logo.png create mode 100644 frontend/public/soundfont/sonivox.sf3 create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/DevicePanel/DevicePanel.tsx create mode 100644 frontend/src/components/FallingNotes/FallingNotesRenderer.ts create mode 100644 frontend/src/components/FallingNotes/FallingNotesView.tsx create mode 100644 frontend/src/components/PianoKeyboard/PianoKeyboard.ts create mode 100644 frontend/src/components/PianoKeyboard/PianoKeyboardView.tsx create mode 100644 frontend/src/components/SheetMusic/SheetMusicView.tsx create mode 100644 frontend/src/components/Transport/SettingsPanel.tsx create mode 100644 frontend/src/components/Transport/TransportControls.tsx create mode 100644 frontend/src/engine/AccuracyTracker.ts create mode 100644 frontend/src/engine/AudioEngine.ts create mode 100644 frontend/src/engine/EventBus.ts create mode 100644 frontend/src/engine/MidiClock.ts create mode 100644 frontend/src/engine/SyncEngine.ts create mode 100644 frontend/src/hooks/useAccuracy.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/store/useAppStore.ts create mode 100644 frontend/src/utils/colorUtils.ts create mode 100644 frontend/src/utils/mxlExtract.ts create mode 100644 frontend/src/views/PracticeView.tsx create mode 100644 frontend/src/workers/midi.worker.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 shared/package.json create mode 100644 shared/types/MusicData.ts create mode 100644 shared/types/SyncEvent.ts create mode 100644 shared/types/index.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b2c379f --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b49214f --- /dev/null +++ b/.env.example @@ -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: +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0dc272 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d0c2add --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..af097f6 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..5a4240a --- /dev/null +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c5975c --- /dev/null +++ b/README.md @@ -0,0 +1,225 @@ +

+ + Bach to Basics + +

+ +

+ License: MIT + Issues +

+ +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://: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: ` | +| `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 + +
+Sheet music doesn't render after loading a MIDI file + +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. +
+ +
+No sound from the on-screen piano + +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. +
+ +
+"WebMIDI not supported" on Firefox or Safari + +Use Chrome, Edge, Brave, or Arc. Firefox and Safari haven't implemented Web MIDI. +
+ +
+PDF import says "OMR engine not available" + +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. +
+ +
+Docker build fails on the frontend + +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. +
+ +## 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. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..9a7fd80 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +.venv/ +.venv-ai/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.DS_Store +bin/*.jar diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4cfac6c --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..c82cbde --- /dev/null +++ b/backend/main.py @@ -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"} diff --git a/backend/requirements-transcribe.txt b/backend/requirements-transcribe.txt new file mode 100644 index 0000000..e89773e --- /dev/null +++ b/backend/requirements-transcribe.txt @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..07983fb --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/export_routes.py b/backend/routers/export_routes.py new file mode 100644 index 0000000..10555f7 --- /dev/null +++ b/backend/routers/export_routes.py @@ -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.") diff --git a/backend/routers/fingering.py b/backend/routers/fingering.py new file mode 100644 index 0000000..884bc34 --- /dev/null +++ b/backend/routers/fingering.py @@ -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.", + ) diff --git a/backend/routers/omr.py b/backend/routers/omr.py new file mode 100644 index 0000000..f24aa75 --- /dev/null +++ b/backend/routers/omr.py @@ -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.") diff --git a/backend/routers/transcribe.py b/backend/routers/transcribe.py new file mode 100644 index 0000000..bad3e7b --- /dev/null +++ b/backend/routers/transcribe.py @@ -0,0 +1,163 @@ +""" +/transcribe/midi2musicxml - MIDI to MusicXML via music21 +/transcribe/musicxml2midi - MusicXML to MIDI via music21 +/transcribe/mp3 - MP3 to MIDI via Basic Pitch (Spotify) +""" +import logging +import os +import re +import tempfile +from pathlib import Path + +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse, JSONResponse + +logger = logging.getLogger(__name__) +router = APIRouter() + +# C2 - file size cap +MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB + +# H4 - extension allowlists +_MIDI_EXTS = {".mid", ".midi"} +_XML_EXTS = {".xml", ".mxl"} +_AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"} + +# M5 - MIDI magic bytes: "MThd" +_MIDI_MAGIC = b"MThd" + + +# ── Shared guards ───────────────────────────────────────────────────────────── + +async def _read_with_limit(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes: + """Stream-read upload in 64 KB chunks; reject as soon as total exceeds limit. + + Unlike ``await file.read()`` followed by a size check, this never buffers + an arbitrarily large payload into memory (H2: memory-DoS prevention). + """ + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(65_536) + if not chunk: + break + total += len(chunk) + if total > max_bytes: + raise HTTPException(status_code=413, detail="Upload too large (max 50 MB)") + chunks.append(chunk) + return b"".join(chunks) + + +def _check_ext(filename: str, allowed: set[str]) -> None: + ext = Path(filename).suffix.lower() + if ext not in allowed: + raise HTTPException( + status_code=422, + detail=f"Unsupported file type. Allowed: {', '.join(sorted(allowed))}", + ) + + +def _check_midi_magic(data: bytes) -> None: + if not data.startswith(_MIDI_MAGIC): + raise HTTPException(status_code=422, detail="Not a valid MIDI file") + + +def _safe_title(raw: str) -> str: + """M2 - strip control chars and cap length. + HTML-escaping for XML embedding is done by music21_svc, not here, + to avoid double-escaping (&amp; etc.).""" + cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", raw) + return cleaned[:200] + + +def _rm(path: str) -> None: + try: + os.unlink(path) + except OSError: + pass + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.post("/midi2musicxml") +async def midi_to_musicxml(file: UploadFile, title: str = Query(default="")): + """Convert a MIDI file to MusicXML for sheet music rendering.""" + try: + _check_ext(file.filename or "track.mid", _MIDI_EXTS) + data = await _read_with_limit(file) + _check_midi_magic(data) + + from services.music21_svc import midi_bytes_to_musicxml + safe_title = _safe_title(title) + musicxml = await midi_bytes_to_musicxml(data, title=safe_title) + + # L2 - prevent caching of user data + return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"}) + except HTTPException: + raise + except Exception: + logger.exception("midi_to_musicxml failed") + raise HTTPException(status_code=500, detail="Conversion failed. Please try again.") + + +@router.post("/musicxml2midi") +async def musicxml_to_midi(file: UploadFile, background_tasks: BackgroundTasks): + """Convert a MusicXML (.xml or .mxl) file to MIDI for playback.""" + try: + filename = file.filename or "score.xml" + _check_ext(filename, _XML_EXTS) + data = await _read_with_limit(file) + + from services.music21_svc import musicxml_bytes_to_midi + midi_bytes = await musicxml_bytes_to_midi(data, filename) + + tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False) + tmp.write(midi_bytes) + tmp.close() + background_tasks.add_task(_rm, tmp.name) # H5 + + return FileResponse( + tmp.name, + media_type="audio/midi", + filename="score.mid", + headers={ + "Cache-Control": "no-store", + "Content-Disposition": 'attachment; filename="score.mid"', + }, + ) + except HTTPException: + raise + except Exception: + logger.exception("musicxml_to_midi failed") + raise HTTPException(status_code=500, detail="Conversion failed. Please try again.") + + +@router.post("/mp3") +async def transcribe_mp3(file: UploadFile, background_tasks: BackgroundTasks): + """Transcribe an MP3/audio file to MIDI using Basic Pitch.""" + try: + _check_ext(file.filename or "audio.mp3", _AUDIO_EXTS) + data = await _read_with_limit(file) + + from services.basic_pitch import transcribe_audio_to_midi + midi_bytes = await transcribe_audio_to_midi(data, file.filename or "audio.mp3") + + tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False) + tmp.write(midi_bytes) + tmp.close() + background_tasks.add_task(_rm, tmp.name) # H5 + + return FileResponse( + tmp.name, + media_type="audio/midi", + filename="transcribed.mid", + headers={ + "Cache-Control": "no-store", + "Content-Disposition": 'attachment; filename="transcribed.mid"', + }, + ) + except HTTPException: + raise + except Exception: + logger.exception("transcribe_mp3 failed") + raise HTTPException(status_code=500, detail="Transcription failed. Please try again.") diff --git a/backend/routers/youtube.py b/backend/routers/youtube.py new file mode 100644 index 0000000..e8aebc1 --- /dev/null +++ b/backend/routers/youtube.py @@ -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) diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/audiveris.py b/backend/services/audiveris.py new file mode 100644 index 0000000..c7996fa --- /dev/null +++ b/backend/services/audiveris.py @@ -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") diff --git a/backend/services/basic_pitch.py b/backend/services/basic_pitch.py new file mode 100644 index 0000000..d3663a1 --- /dev/null +++ b/backend/services/basic_pitch.py @@ -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 diff --git a/backend/services/music21_svc.py b/backend/services/music21_svc.py new file mode 100644 index 0000000..9720e81 --- /dev/null +++ b/backend/services/music21_svc.py @@ -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*]*>[^<]*\s*\n?', '\n ', xml) + xml = re.sub(r'\s*]*>[^<]*\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, per-note tags, 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 + # values and breaking AlphaTab's cursor tracker. + # Keeping Voice containers tells music21 that those notes are + # concurrent, so it emits correct 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