commit 8f4df1451ed637130800ee69134a54eb42198231 Author: Fabricio Giglio <58532944+gigliof@users.noreply.github.com> Date: Wed May 6 17:56:38 2026 +0200 Initial commit 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