Initial commit

This commit is contained in:
Fabricio Giglio
2026-05-06 17:56:38 +02:00
commit 8f4df1451e
78 changed files with 12130 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.venv/
.venv-ai/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.DS_Store
bin/*.jar
+32
View File
@@ -0,0 +1,32 @@
# Bach to Basics: backend image
# Python 3.12 base supports both the core requirements AND the optional
# Basic Pitch AI dependencies (which don't yet support 3.13).
FROM python:3.12-slim
# System packages: Java for Audiveris (PDF-to-MusicXML), ffmpeg for audio export,
# poppler-utils for pdf2image. Keep the layer slim; remove apt cache.
RUN apt-get update && apt-get install -y --no-install-recommends \
default-jre-headless \
ffmpeg \
poppler-utils \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python deps first so they cache independently of source changes
COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Source last, small, changes often
COPY . .
EXPOSE 8000
# Run as a non-root user for safety
RUN useradd --create-home --uid 1000 app && chown -R app:app /app
USER app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+257
View File
@@ -0,0 +1,257 @@
import asyncio
import hmac
import logging
import os
import shutil
import time
from collections import defaultdict
from contextlib import asynccontextmanager
# M1 - Patch stdlib XML parsers before any other import uses them.
# defuse_stdlib() replaces xml.etree.ElementTree, xml.dom.minidom, etc.
# with safe equivalents that reject XXE / XML-bomb payloads.
import defusedxml
defusedxml.defuse_stdlib()
# H1 - Patch lxml's default XMLParser to disable entity resolution and
# network access. defuse_stdlib() only covers stdlib xml.etree; music21
# may use lxml internally if it is installed.
try:
from lxml import etree as _lxml_etree
_orig_xmlparser_init = _lxml_etree.XMLParser.__init__
def _safe_xmlparser_init(self, *args, **kwargs):
kwargs.setdefault("resolve_entities", False)
kwargs.setdefault("no_network", True)
_orig_xmlparser_init(self, *args, **kwargs)
_lxml_etree.XMLParser.__init__ = _safe_xmlparser_init
except ImportError:
pass # lxml not installed, nothing to patch
from fastapi import Depends, FastAPI, HTTPException, Request, Response, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import APIKeyHeader
from routers import export_routes, fingering, omr, transcribe, youtube
logging.basicConfig(level=logging.WARNING)
# ── H1: Optional API-key authentication ──────────────────────────────────────
# Set BACKEND_API_KEY env-var to enable. When the var is absent the guard is
# a no-op so local dev works without any extra config.
_API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
_auth_warned = False
def _require_api_key(key: str | None = Security(_API_KEY_HEADER)) -> None:
global _auth_warned
expected = os.environ.get("BACKEND_API_KEY")
# T1-1: only treat *absent* key (None) as "auth disabled".
# An empty string means a deployment misconfiguration, not "no auth".
if expected is None:
if not _auth_warned:
logging.warning(
"BACKEND_API_KEY is not set - all API endpoints are unauthenticated. "
"Set this env var in production."
)
_auth_warned = True
return # auth genuinely not configured (dev mode)
if not expected:
# Empty string = broken deployment config; reject loudly
raise HTTPException(status_code=500, detail="Server authentication misconfigured")
# T2-2: constant-time comparison prevents timing-based key extraction
if not hmac.compare_digest(key or "", expected):
raise HTTPException(status_code=403, detail="Forbidden")
# ── Startup checks ────────────────────────────────────────────────────────────
def _startup_checks() -> None:
"""Log warnings for optional external binaries that are absent at launch."""
# M1: fail fast if auth is explicitly required but no key is configured
if os.environ.get("REQUIRE_AUTH", "").strip().lower() in ("1", "true", "yes"):
if not os.environ.get("BACKEND_API_KEY"):
raise SystemExit(
"FATAL: REQUIRE_AUTH is set but BACKEND_API_KEY is empty. "
"Set BACKEND_API_KEY or unset REQUIRE_AUTH."
)
checks = [
("ffmpeg", "needed for /export/mp3"),
("yt-dlp", "needed for /youtube/extract"),
]
for binary, purpose in checks:
if not shutil.which(binary):
logging.warning("Optional binary not found: %s (%s)", binary, purpose)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
_startup_checks()
yield
# ── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(
title="Bach to Basics",
version="0.1.0",
dependencies=[Depends(_require_api_key)],
lifespan=_lifespan,
)
# H2 / L1 - CORS: read allowed origins from ALLOWED_ORIGINS env var
# (comma-separated list of URLs). Defaults to the Vite dev server so local
# development works without any extra config.
#
# Production example:
# ALLOWED_ORIGINS=https://myapp.example.com
# ALLOWED_ORIGINS=https://myapp.example.com,https://www.myapp.example.com
_raw_origins = os.environ.get("ALLOWED_ORIGINS", "http://localhost:5173")
_allowed_origins = [o.strip() for o in _raw_origins.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_allowed_origins,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type", "X-API-Key"],
)
# ── Security-headers middleware ───────────────────────────────────────────────
@app.middleware("http")
async def _add_security_headers(request: Request, call_next) -> Response:
response = await call_next(request)
# Prevent MIME-type sniffing
response.headers.setdefault("X-Content-Type-Options", "nosniff")
# Prevent the API from being embedded in iframes (clickjacking)
response.headers.setdefault("X-Frame-Options", "DENY")
# Don't send the Referer header to third parties
response.headers.setdefault("Referrer-Policy", "no-referrer")
# Disable browser features this API doesn't need
response.headers.setdefault(
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
)
# T4-2: HSTS: enforce HTTPS for 2 years (Web MIDI requires HTTPS anyway)
response.headers.setdefault(
"Strict-Transport-Security", "max-age=63072000; includeSubDomains"
)
return response
# ── Per-IP rate limiter (no extra dependencies) ───────────────────────────────
# Caps requests per IP in a rolling 60-second window.
# Set RATE_LIMIT_PER_MIN=0 to disable (e.g. when sitting behind your own
# reverse proxy that already handles rate limiting).
_rate_window: dict[str, list[float]] = defaultdict(list)
_RATE_LIMIT = int(os.environ.get("RATE_LIMIT_PER_MIN", "60"))
# L4: stricter limit for expensive endpoints (OMR, YouTube)
_HEAVY_RATE_LIMIT = int(os.environ.get("HEAVY_RATE_LIMIT_PER_MIN", "10"))
_HEAVY_PREFIXES = ("/omr/", "/youtube/")
# M3: periodic sweep of stale entries to prevent unbounded memory growth
_last_sweep = 0.0
_SWEEP_INTERVAL = 300.0 # 5 minutes
# T4-3: hard cap on tracked IPs to prevent memory growth from botnet drip attacks
_MAX_RATE_ENTRIES = 50_000
# T2-1: asyncio.Lock prevents TOCTOU race in concurrent async handlers
_rate_lock = asyncio.Lock()
# M4: proxy-aware IP resolution: when the request arrives from a trusted
# proxy, use X-Forwarded-For instead of request.client.host.
# Set TRUSTED_PROXY_IPS=10.0.0.1,10.0.0.2 to enable.
_TRUSTED_PROXIES = {
ip.strip()
for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",")
if ip.strip()
}
def _get_client_ip(request: Request) -> str:
"""Resolve client IP, respecting X-Forwarded-For from trusted proxies."""
direct_ip = request.client.host if request.client else "unknown"
if _TRUSTED_PROXIES and direct_ip in _TRUSTED_PROXIES:
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
# Rightmost IP not in trusted proxies = last non-proxy hop
for ip in reversed(forwarded.split(",")):
ip = ip.strip()
if ip and ip not in _TRUSTED_PROXIES:
return ip
return direct_ip
def _sweep_stale_entries(now: float) -> None:
"""Remove IPs with no requests in the last 2 minutes (M3).
Also enforces a hard cap on total tracked IPs (T4-3) to prevent
slow-drip memory growth from botnets.
Must be called while holding _rate_lock.
"""
global _last_sweep
if now - _last_sweep < _SWEEP_INTERVAL:
return
_last_sweep = now
stale = [ip for ip, ts in _rate_window.items() if not ts or now - ts[-1] > 120.0]
for ip in stale:
del _rate_window[ip]
# T4-3: evict oldest entries if still over the hard cap
while len(_rate_window) > _MAX_RATE_ENTRIES:
oldest = min(_rate_window, key=lambda k: _rate_window[k][-1] if _rate_window[k] else 0)
del _rate_window[oldest]
@app.middleware("http")
async def _rate_limit(request: Request, call_next) -> Response:
# Skip the health probe and CORS preflight requests.
if request.url.path == "/health" or request.method == "OPTIONS":
return await call_next(request)
if _RATE_LIMIT > 0:
ip = _get_client_ip(request)
now = time.monotonic()
# T2-1: lock the read-check-write cycle so concurrent async
# handlers can't all pass the limit check simultaneously
async with _rate_lock:
_sweep_stale_entries(now)
# L4: apply stricter limit for heavy endpoints
limit = _RATE_LIMIT
if any(request.url.path.startswith(p) for p in _HEAVY_PREFIXES):
limit = _HEAVY_RATE_LIMIT
# Prune timestamps outside the rolling window
window = [t for t in _rate_window[ip] if now - t < 60.0]
if len(window) >= limit:
return Response(
content='{"detail":"Too many requests - please wait a moment."}',
status_code=429,
media_type="application/json",
headers={"Retry-After": "60"},
)
window.append(now)
_rate_window[ip] = window
return await call_next(request)
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(transcribe.router, prefix="/transcribe", tags=["transcribe"])
app.include_router(omr.router, prefix="/omr", tags=["omr"])
app.include_router(youtube.router, prefix="/youtube", tags=["youtube"])
app.include_router(fingering.router, prefix="/fingering", tags=["fingering"])
app.include_router(export_routes.router, prefix="/export", tags=["export"])
@app.get("/health")
async def health():
return {"status": "ok"}
+11
View File
@@ -0,0 +1,11 @@
# Optional audio-transcription dependencies (Basic Pitch).
# Requires Python 3.11 or 3.12, tensorflow-macos doesn't yet support 3.13.
#
# To enable MP3 / audio-to-MIDI transcription:
# brew install python@3.12
# /opt/homebrew/bin/python3.12 -m venv .venv
# source .venv/bin/activate
# pip install -r requirements-transcribe.txt
#
-r requirements.txt
basic-pitch>=0.3.6
+18
View File
@@ -0,0 +1,18 @@
# Core requirements: work with Python 3.13
fastapi>=0.111
uvicorn[standard]>=0.30
python-multipart>=0.0.9
pydantic>=2.0
defusedxml>=0.7
# Music processing
music21>=9.1
# YouTube download
yt-dlp>=2024.4.9
# PDF processing (for Audiveris input prep)
pdf2image>=1.17
# Fingering generation
pianoplayer>=0.9
View File
+132
View File
@@ -0,0 +1,132 @@
"""
/export/pdf - MusicXML to PDF via music21 + LilyPond (or Audiveris)
/export/mp3 - WAV buffer to MP3 via FFmpeg
"""
import base64
import binascii
import logging
import os
import subprocess
import tempfile
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter()
# C2 - size caps
MAX_B64_BYTES = 300 * 1024 * 1024 # 300 MB base64 string (≈ 200 MB decoded)
MAX_WAV_BYTES = 200 * 1024 * 1024 # 200 MB decoded WAV
MAX_MUSICXML_BYTES = 10 * 1024 * 1024 # 10 MB
# M5 - WAV magic bytes: "RIFF....WAVE"
_WAV_RIFF = b"RIFF"
_WAV_WAVE = b"WAVE"
class ExportPdfRequest(BaseModel):
musicxml: str
class ExportMp3Request(BaseModel):
wav_base64: str # base64-encoded WAV
def _rm(path: str) -> None:
try:
os.unlink(path)
except OSError:
pass
@router.post("/pdf")
async def export_pdf(req: ExportPdfRequest, background_tasks: BackgroundTasks):
"""Convert MusicXML to PDF sheet music."""
if len(req.musicxml.encode()) > MAX_MUSICXML_BYTES:
raise HTTPException(status_code=413, detail="MusicXML payload too large (max 10 MB)")
try:
from services.music21_svc import musicxml_to_pdf
pdf_path = await musicxml_to_pdf(req.musicxml)
background_tasks.add_task(_rm, pdf_path) # H5
return FileResponse(
pdf_path,
media_type="application/pdf",
filename="sheet.pdf",
headers={
"Cache-Control": "no-store",
"Content-Disposition": 'attachment; filename="sheet.pdf"',
},
)
except HTTPException:
raise
except Exception:
logger.exception("export_pdf failed")
raise HTTPException(status_code=500, detail="PDF export failed. Please try again.")
@router.post("/mp3")
async def export_mp3(req: ExportMp3Request, background_tasks: BackgroundTasks):
"""Convert base64 WAV to MP3 using FFmpeg."""
# L6 - size-check before decoding; catch malformed base64 explicitly
if len(req.wav_base64) > MAX_B64_BYTES:
raise HTTPException(status_code=413, detail="WAV payload too large (max 200 MB)")
try:
wav_bytes = base64.b64decode(req.wav_base64, validate=True)
except binascii.Error:
raise HTTPException(status_code=422, detail="Invalid base64 encoding")
# C2 - check decoded size too
if len(wav_bytes) > MAX_WAV_BYTES:
raise HTTPException(status_code=413, detail="WAV data too large (max 200 MB)")
# M5 - validate WAV magic bytes: "RIFF....WAVE"
if len(wav_bytes) < 12 or wav_bytes[:4] != _WAV_RIFF or wav_bytes[8:12] != _WAV_WAVE:
raise HTTPException(status_code=422, detail="Not a valid WAV file")
try:
wav_tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
wav_tmp.write(wav_bytes)
wav_tmp.close()
mp3_tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
mp3_tmp.close()
# H5 - register cleanup regardless of success/failure
background_tasks.add_task(_rm, wav_tmp.name)
background_tasks.add_task(_rm, mp3_tmp.name)
result = subprocess.run(
[
"ffmpeg", "-y",
"-i", wav_tmp.name,
"-codec:a", "libmp3lame",
"-q:a", "2",
mp3_tmp.name,
],
capture_output=True,
timeout=60,
)
if result.returncode != 0:
logger.error("ffmpeg failed: %s", result.stderr.decode(errors="replace")[:500])
raise RuntimeError("ffmpeg conversion failed")
return FileResponse(
mp3_tmp.name,
media_type="audio/mpeg",
filename="export.mp3",
headers={
"Cache-Control": "no-store",
"Content-Disposition": 'attachment; filename="export.mp3"',
},
)
except HTTPException:
raise
except Exception:
logger.exception("export_mp3 failed")
raise HTTPException(status_code=500, detail="MP3 export failed. Please try again.")
+39
View File
@@ -0,0 +1,39 @@
"""
/fingering/generate - MusicXML to annotated MusicXML with finger numbers
"""
import logging
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter()
MAX_MUSICXML_BYTES = 10 * 1024 * 1024 # 10 MB - large scores are ~500 KB
class FingeringRequest(BaseModel):
musicxml: str
@router.post("/generate")
async def generate_fingering(req: FingeringRequest):
"""Run pianoplayer Parncutt algorithm on MusicXML, returns annotated MusicXML."""
if len(req.musicxml.encode()) > MAX_MUSICXML_BYTES:
raise HTTPException(status_code=413, detail="MusicXML payload too large (max 10 MB)")
try:
from services.pianoplayer_svc import annotate_fingering
annotated = await annotate_fingering(req.musicxml)
return JSONResponse(
{"musicxml": annotated},
headers={"Cache-Control": "no-store"},
)
except HTTPException:
raise
except Exception:
logger.exception("generate_fingering failed")
raise HTTPException(
status_code=500,
detail="Fingering generation failed. Please try again.",
)
+140
View File
@@ -0,0 +1,140 @@
"""
/omr/pdf - PDF sheet music to MusicXML via Audiveris
/omr/image - Image (PNG/JPG) sheet music to MusicXML via Audiveris
"""
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# C2 - file size cap
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
# H4 - extension allowlists
_PDF_EXTS = {".pdf"}
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".tiff", ".tif"}
# M5 - magic bytes
_PDF_MAGIC = b"%PDF"
_PNG_MAGIC = b"\x89PNG"
_JPEG_MAGIC = b"\xff\xd8\xff"
_TIFF_MAGIC = (b"II*\x00", b"MM\x00*")
async def _read_with_limit(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
"""Stream-read upload in 64 KB chunks; reject as soon as total exceeds limit.
Unlike ``await file.read()`` followed by a size check, this never buffers
an arbitrarily large payload into memory (H2: memory-DoS prevention).
"""
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(65_536)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=413, detail="Upload too large (max 50 MB)")
chunks.append(chunk)
return b"".join(chunks)
def _check_ext(filename: str, allowed: set[str]) -> None:
ext = Path(filename).suffix.lower()
if ext not in allowed:
raise HTTPException(
status_code=422,
detail=f"Unsupported file type. Allowed: {', '.join(sorted(allowed))}",
)
def _check_pdf_magic(data: bytes) -> None:
if not data.startswith(_PDF_MAGIC):
raise HTTPException(status_code=422, detail="Not a valid PDF file")
def _check_image_magic(data: bytes, ext: str) -> None:
if ext in {".png"} and not data.startswith(_PNG_MAGIC):
raise HTTPException(status_code=422, detail="Not a valid PNG file")
if ext in {".jpg", ".jpeg"} and not data.startswith(_JPEG_MAGIC):
raise HTTPException(status_code=422, detail="Not a valid JPEG file")
if ext in {".tiff", ".tif"} and not any(data.startswith(m) for m in _TIFF_MAGIC):
raise HTTPException(status_code=422, detail="Not a valid TIFF file")
@router.post("/pdf")
async def omr_pdf(file: UploadFile):
"""Run OMR on a PDF sheet music file to MusicXML."""
try:
_check_ext(file.filename or "score.pdf", _PDF_EXTS)
data = await _read_with_limit(file)
_check_pdf_magic(data)
from services.audiveris import pdf_to_musicxml
musicxml = await pdf_to_musicxml(data, suffix=".pdf")
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
except HTTPException:
raise
except Exception:
logger.exception("omr_pdf failed")
raise HTTPException(status_code=500, detail="OMR processing failed. Please try again.")
@router.post("/image")
async def omr_image(file: UploadFile):
"""Run OMR on an image (PNG/JPG) of sheet music to MusicXML."""
try:
filename = file.filename or "image.png"
_check_ext(filename, _IMAGE_EXTS)
data = await _read_with_limit(file)
ext = Path(filename).suffix.lower()
_check_image_magic(data, ext)
from services.audiveris import pdf_to_musicxml
musicxml = await pdf_to_musicxml(data, suffix=ext)
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
except HTTPException:
raise
except Exception:
logger.exception("omr_image failed")
raise HTTPException(status_code=500, detail="OMR processing failed. Please try again.")
@router.post("/pdf2midi")
async def omr_pdf_to_midi(file: UploadFile):
"""PDF sheet music, OMR to MusicXML + MIDI in one round-trip.
Returns JSON: { musicxml: str, midi_b64: str, filename: str }
Both artifacts arrive together so the frontend can render sheet music
AND enable playback without a second network request.
"""
import base64
try:
_check_ext(file.filename or "score.pdf", _PDF_EXTS)
data = await _read_with_limit(file)
_check_pdf_magic(data)
from services.audiveris import pdf_to_musicxml_and_midi
musicxml, midi_bytes = await pdf_to_musicxml_and_midi(data, suffix=".pdf")
midi_b64 = base64.b64encode(midi_bytes).decode("ascii")
return JSONResponse(
{"musicxml": musicxml, "midi_b64": midi_b64, "filename": "score.mid"},
headers={"Cache-Control": "no-store"},
)
except HTTPException:
raise
except RuntimeError as exc:
# User-safe messages from audiveris.py (no JAR, no output, etc.)
logger.warning("omr_pdf_to_midi: %s", exc)
raise HTTPException(status_code=422, detail=str(exc))
except Exception:
logger.exception("omr_pdf_to_midi failed unexpectedly")
raise HTTPException(status_code=500, detail="PDF processing failed. Please try again.")
+163
View File
@@ -0,0 +1,163 @@
"""
/transcribe/midi2musicxml - MIDI to MusicXML via music21
/transcribe/musicxml2midi - MusicXML to MIDI via music21
/transcribe/mp3 - MP3 to MIDI via Basic Pitch (Spotify)
"""
import logging
import os
import re
import tempfile
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, JSONResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# C2 - file size cap
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50 MB
# H4 - extension allowlists
_MIDI_EXTS = {".mid", ".midi"}
_XML_EXTS = {".xml", ".mxl"}
_AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"}
# M5 - MIDI magic bytes: "MThd"
_MIDI_MAGIC = b"MThd"
# ── Shared guards ─────────────────────────────────────────────────────────────
async def _read_with_limit(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
"""Stream-read upload in 64 KB chunks; reject as soon as total exceeds limit.
Unlike ``await file.read()`` followed by a size check, this never buffers
an arbitrarily large payload into memory (H2: memory-DoS prevention).
"""
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(65_536)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=413, detail="Upload too large (max 50 MB)")
chunks.append(chunk)
return b"".join(chunks)
def _check_ext(filename: str, allowed: set[str]) -> None:
ext = Path(filename).suffix.lower()
if ext not in allowed:
raise HTTPException(
status_code=422,
detail=f"Unsupported file type. Allowed: {', '.join(sorted(allowed))}",
)
def _check_midi_magic(data: bytes) -> None:
if not data.startswith(_MIDI_MAGIC):
raise HTTPException(status_code=422, detail="Not a valid MIDI file")
def _safe_title(raw: str) -> str:
"""M2 - strip control chars and cap length.
HTML-escaping for XML embedding is done by music21_svc, not here,
to avoid double-escaping (&amp;amp; etc.)."""
cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", raw)
return cleaned[:200]
def _rm(path: str) -> None:
try:
os.unlink(path)
except OSError:
pass
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("/midi2musicxml")
async def midi_to_musicxml(file: UploadFile, title: str = Query(default="")):
"""Convert a MIDI file to MusicXML for sheet music rendering."""
try:
_check_ext(file.filename or "track.mid", _MIDI_EXTS)
data = await _read_with_limit(file)
_check_midi_magic(data)
from services.music21_svc import midi_bytes_to_musicxml
safe_title = _safe_title(title)
musicxml = await midi_bytes_to_musicxml(data, title=safe_title)
# L2 - prevent caching of user data
return JSONResponse({"musicxml": musicxml}, headers={"Cache-Control": "no-store"})
except HTTPException:
raise
except Exception:
logger.exception("midi_to_musicxml failed")
raise HTTPException(status_code=500, detail="Conversion failed. Please try again.")
@router.post("/musicxml2midi")
async def musicxml_to_midi(file: UploadFile, background_tasks: BackgroundTasks):
"""Convert a MusicXML (.xml or .mxl) file to MIDI for playback."""
try:
filename = file.filename or "score.xml"
_check_ext(filename, _XML_EXTS)
data = await _read_with_limit(file)
from services.music21_svc import musicxml_bytes_to_midi
midi_bytes = await musicxml_bytes_to_midi(data, filename)
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
tmp.write(midi_bytes)
tmp.close()
background_tasks.add_task(_rm, tmp.name) # H5
return FileResponse(
tmp.name,
media_type="audio/midi",
filename="score.mid",
headers={
"Cache-Control": "no-store",
"Content-Disposition": 'attachment; filename="score.mid"',
},
)
except HTTPException:
raise
except Exception:
logger.exception("musicxml_to_midi failed")
raise HTTPException(status_code=500, detail="Conversion failed. Please try again.")
@router.post("/mp3")
async def transcribe_mp3(file: UploadFile, background_tasks: BackgroundTasks):
"""Transcribe an MP3/audio file to MIDI using Basic Pitch."""
try:
_check_ext(file.filename or "audio.mp3", _AUDIO_EXTS)
data = await _read_with_limit(file)
from services.basic_pitch import transcribe_audio_to_midi
midi_bytes = await transcribe_audio_to_midi(data, file.filename or "audio.mp3")
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
tmp.write(midi_bytes)
tmp.close()
background_tasks.add_task(_rm, tmp.name) # H5
return FileResponse(
tmp.name,
media_type="audio/midi",
filename="transcribed.mid",
headers={
"Cache-Control": "no-store",
"Content-Disposition": 'attachment; filename="transcribed.mid"',
},
)
except HTTPException:
raise
except Exception:
logger.exception("transcribe_mp3 failed")
raise HTTPException(status_code=500, detail="Transcription failed. Please try again.")
+90
View File
@@ -0,0 +1,90 @@
"""
/youtube/extract - Download audio from YouTube URL, transcribe to MIDI
"""
import logging
import os
import re
import shutil
import tempfile
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter()
# C1 - validate video IDs: exactly 11 URL-safe chars
_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
class YoutubeExtractRequest(BaseModel):
url: str
@router.post("/extract")
async def youtube_extract(req: YoutubeExtractRequest, background_tasks: BackgroundTasks):
"""Download best audio from YouTube, run Basic Pitch, return MIDI."""
# C1 - extract video ID from user URL, reconstruct a safe canonical URL.
# The raw req.url is NEVER forwarded to yt-dlp (SSRF prevention).
match = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{11})", req.url)
if not match or not _VIDEO_ID_RE.match(match.group(1)):
raise HTTPException(status_code=422, detail="Invalid or unsupported YouTube URL")
video_id = match.group(1)
safe_url = f"https://www.youtube.com/watch?v={video_id}"
try:
from services.ytdlp import download_audio
from services.basic_pitch import transcribe_audio_to_midi
audio_path, audio_tmpdir = await download_audio(safe_url)
# H5 - register audio dir cleanup immediately so it runs even if
# transcription fails (don't wait until after the whole try block).
background_tasks.add_task(_rm_dir, audio_tmpdir)
midi_bytes = await transcribe_audio_to_midi(
audio_path.read_bytes(), audio_path.name
)
# H5 - write MIDI to a named temp file; register cleanup in background
tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
tmp.write(midi_bytes)
tmp.close()
background_tasks.add_task(_rm_file, tmp.name)
# L2 - no-store so the browser never caches the MIDI file
return FileResponse(
tmp.name,
media_type="audio/midi",
filename=f"{video_id}.mid",
headers={
"X-Video-Id": video_id,
"Cache-Control": "no-store",
"Content-Disposition": f'attachment; filename="{video_id}.mid"',
},
)
except HTTPException:
raise
except Exception:
logger.exception("youtube_extract failed for video_id=%s", video_id)
raise HTTPException(
status_code=500,
detail="Audio extraction failed. Please try again.",
)
# ── Cleanup helpers ───────────────────────────────────────────────────────────
def _rm_file(path: str) -> None:
try:
os.unlink(path)
except OSError:
pass
def _rm_dir(path: str) -> None:
try:
shutil.rmtree(path)
except OSError as e:
logger.warning("Failed to remove temp directory %s: %s", path, e)
View File
+183
View File
@@ -0,0 +1,183 @@
"""
Audiveris service: PDF/image to MusicXML via Audiveris.
Audiveris is detected automatically in three locations (in order):
1. backend/bin/audiveris.jar - legacy standalone JAR (any platform)
2. /Applications/Audiveris.app - macOS DMG install
3. `audiveris` in PATH - Linux .deb / Windows .msi install
Get Audiveris at: https://github.com/Audiveris/audiveris/releases
Requires Java 17+ (already present on this machine).
"""
import asyncio
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
# ── Audiveris binary detection ────────────────────────────────────────────────
_LEGACY_JAR = Path(__file__).parent.parent / "bin" / "audiveris.jar"
_MACOS_APP_BIN = Path("/Applications/Audiveris.app/Contents/MacOS/Audiveris")
def _find_audiveris() -> list[str] | None:
"""Return the command prefix to invoke Audiveris, or None if not found.
Checked in order:
1. backend/bin/audiveris.jar (legacy standalone JAR)
2. /Applications/Audiveris.app (macOS DMG install)
3. `audiveris` in PATH (Linux deb / Windows msi)
"""
if _LEGACY_JAR.exists():
return ["java", "-jar", str(_LEGACY_JAR)]
if _MACOS_APP_BIN.exists():
return [str(_MACOS_APP_BIN)]
if shutil.which("audiveris"):
return ["audiveris"]
return None
# ── Timeouts (configurable via env vars) ──────────────────────────────────────
# Audiveris: typically 30-120 s per page; set higher for very large scores.
AUDIVERIS_TIMEOUT = int(os.environ.get("AUDIVERIS_TIMEOUT_S", "120"))
# H4 - extension allowlist (defence-in-depth; routers also enforce this)
_ALLOWED_SUFFIXES = {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"}
# ── Public async entry points ─────────────────────────────────────────────────
async def pdf_to_musicxml(file_bytes: bytes, suffix: str = ".pdf") -> str:
"""Run Audiveris OMR on a PDF or image, returns MusicXML string."""
return await asyncio.get_running_loop().run_in_executor(
None, _run_audiveris, file_bytes, suffix
)
async def pdf_to_musicxml_and_midi(
file_bytes: bytes, suffix: str = ".pdf"
) -> tuple[str, bytes]:
"""OMR to MusicXML, then to MIDI.
Runs both steps in a single thread-pool task (both are blocking) to
avoid two executor round-trips and to keep the intermediate MusicXML
in memory rather than writing it back over the wire.
Returns:
(musicxml_str, midi_bytes)
"""
return await asyncio.get_running_loop().run_in_executor(
None, _run_omr_and_convert, file_bytes, suffix
)
# ── Combined OMR + MIDI conversion (blocking, runs in thread pool) ───────────
def _run_omr_and_convert(file_bytes: bytes, suffix: str) -> tuple[str, bytes]:
musicxml = _run_audiveris(file_bytes, suffix)
# Import the private sync helper directly to avoid a second executor call.
from services.music21_svc import _xml_to_midi # noqa: PLC0415
midi_bytes = _xml_to_midi(musicxml.encode("utf-8"), "score.xml")
return musicxml, midi_bytes
# ── Audiveris (primary engine) ────────────────────────────────────────────────
def _run_audiveris(file_bytes: bytes, suffix: str) -> str:
# H4 - validate suffix even if caller skips router-level check
if suffix.lower() not in _ALLOWED_SUFFIXES:
raise ValueError(f"Unsupported file type: {suffix!r}")
cmd_prefix = _find_audiveris()
if cmd_prefix is None:
# T3-2: generic message for the API consumer; full setup details are
# logged server-side only to avoid leaking OS/path information.
import logging as _log
_log.getLogger(__name__).error(
"Audiveris not found. Checked: %s, %s, PATH",
_LEGACY_JAR, _MACOS_APP_BIN,
)
raise RuntimeError("OMR engine is not available on this server")
with tempfile.TemporaryDirectory() as tmpdir:
input_path = Path(tmpdir) / f"input{suffix}"
input_path.write_bytes(file_bytes)
output_dir = Path(tmpdir) / "output"
output_dir.mkdir()
result = subprocess.run(
cmd_prefix + [
"-batch",
"-export",
"-output", str(output_dir),
"--", str(input_path),
],
capture_output=True,
timeout=AUDIVERIS_TIMEOUT,
)
if result.returncode != 0:
# H3 - don't leak internal tool output in API response
raise RuntimeError("Audiveris OMR processing failed")
xml_files = list(output_dir.rglob("*.mxl")) + list(output_dir.rglob("*.xml"))
if not xml_files:
raise RuntimeError(
"Audiveris produced no MusicXML output. "
"The PDF may be a text document or a poor-quality scan."
)
result_path = xml_files[0]
if result_path.suffix.lower() == ".mxl":
# .mxl is a ZIP-compressed MusicXML - extract the main XML entry
import posixpath
import zipfile
def _safe_member(name: str) -> bool:
"""Reject ZIP entry names with path traversal sequences."""
return not posixpath.normpath(name).startswith("..")
# T1-2: decompressed-size cap to defend against ZIP bombs.
# Don't trust ZipInfo.file_size (headers can lie); enforce
# the limit during actual streaming decompression.
MAX_DECOMPRESSED = 50 * 1024 * 1024 # 50 MB
with zipfile.ZipFile(result_path) as zf:
# META-INF/container.xml lists the root file; fall back to first .xml
names = zf.namelist()
root_file = next(
(
n for n in names
if n.endswith(".xml")
and not n.startswith("META-INF")
and _safe_member(n)
),
next(
(n for n in names if n.endswith(".xml") and _safe_member(n)),
None,
),
)
if root_file is None:
raise RuntimeError("Audiveris .mxl archive contains no XML entry")
# Stream-read with size enforcement (T1-2)
with zf.open(root_file) as entry:
chunks: list[bytes] = []
total = 0
while True:
chunk = entry.read(65_536)
if not chunk:
break
total += len(chunk)
if total > MAX_DECOMPRESSED:
raise ValueError(
"Decompressed MusicXML exceeds 50 MB, possible ZIP bomb"
)
chunks.append(chunk)
return b"".join(chunks).decode("utf-8")
else:
return result_path.read_text(encoding="utf-8")
+50
View File
@@ -0,0 +1,50 @@
"""
Basic Pitch service: audio (MP3/WAV/etc.) to MIDI via Spotify's Basic Pitch model.
Requires Python 3.11 or 3.12 due to tensorflow-macos constraints.
See requirements-transcribe.txt for setup instructions.
"""
import asyncio
import tempfile
from pathlib import Path
_IMPORT_ERROR: str | None = None
try:
from basic_pitch.inference import predict # type: ignore
from basic_pitch import ICASSP_2022_MODEL_PATH # type: ignore
except ImportError as e:
_IMPORT_ERROR = (
"basic-pitch is not installed. MP3 transcription requires Python 3.11 or 3.12. "
"See backend/requirements-transcribe.txt for setup instructions. "
f"Original error: {e}"
)
async def transcribe_audio_to_midi(audio_bytes: bytes, filename: str) -> bytes:
"""Transcribe audio bytes to MIDI bytes using Basic Pitch."""
if _IMPORT_ERROR:
raise RuntimeError(_IMPORT_ERROR)
return await asyncio.get_running_loop().run_in_executor(
None, _transcribe, audio_bytes, filename
)
def _transcribe(audio_bytes: bytes, filename: str) -> bytes:
suffix = Path(filename).suffix or ".mp3"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
f.write(audio_bytes)
audio_path = f.name
_model_output, midi_data, _note_events = predict( # type: ignore[name-defined]
audio_path, ICASSP_2022_MODEL_PATH # type: ignore[name-defined]
)
midi_tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
midi_tmp.close()
midi_data.write(midi_tmp.name)
midi_bytes = Path(midi_tmp.name).read_bytes()
Path(audio_path).unlink(missing_ok=True)
Path(midi_tmp.name).unlink(missing_ok=True)
return midi_bytes
+444
View File
@@ -0,0 +1,444 @@
"""
music21 service: MIDI to MusicXML, MusicXML to PDF
Note: defusedxml.defuse_stdlib() is called in main.py before this module
is imported, so xml.etree (used by music21 internally) is already patched.
"""
import asyncio
import concurrent.futures
import html
import logging
import os
import re
import tempfile
from collections.abc import Callable
from pathlib import Path
logger = logging.getLogger(__name__)
# T1-3: Dedicated process pool for music21 operations.
#
# Why ProcessPoolExecutor instead of ThreadPoolExecutor:
# Threads can't be forcibly killed when asyncio.wait_for fires; the
# timed-out thread keeps consuming CPU/memory until it naturally ends,
# occupying one of the two worker slots indefinitely. A separate OS
# process can be terminated, so a timed-out worker actually frees its slot.
#
# max_workers=2 caps concurrency so pathologically complex scores can't
# starve all other endpoints. Functions must be module-level (picklable),
# which they already are (_convert, _xml_to_midi, _to_pdf take only
# bytes/str arguments and return bytes/str).
#
# Initializer: On macOS / Linux with "spawn" start method each worker is a
# fresh process, defusedxml patches from main.py are NOT inherited.
# _music21_worker_init() re-applies them so XXE protection is active in
# every worker.
def _music21_worker_init() -> None:
"""Apply XML security patches in each worker process (spawn-safe)."""
import defusedxml as _defusedxml
_defusedxml.defuse_stdlib()
try:
from lxml import etree as _lxml_etree # type: ignore
_orig = _lxml_etree.XMLParser.__init__
def _safe_init(self, *args, **kwargs):
kwargs.setdefault("resolve_entities", False)
kwargs.setdefault("no_network", True)
_orig(self, *args, **kwargs)
_lxml_etree.XMLParser.__init__ = _safe_init
except ImportError:
pass # lxml not installed, nothing to patch
_music21_executor = concurrent.futures.ProcessPoolExecutor(
max_workers=2,
initializer=_music21_worker_init,
)
_MUSIC21_TIMEOUT = int(os.environ.get("MUSIC21_TIMEOUT_S", "60"))
async def _run_in_music21_pool(fn: Callable, *args) -> object:
"""Run a blocking music21 function in the dedicated pool with a hard timeout."""
try:
return await asyncio.wait_for(
asyncio.get_running_loop().run_in_executor(_music21_executor, fn, *args),
timeout=_MUSIC21_TIMEOUT,
)
except asyncio.TimeoutError:
raise RuntimeError(
"Processing timed out, the score may be too complex. "
"Try a simpler file or increase MUSIC21_TIMEOUT_S."
)
async def midi_bytes_to_musicxml(midi_bytes: bytes, title: str = "") -> str:
"""Convert MIDI binary to MusicXML string using music21."""
return await _run_in_music21_pool(_convert, midi_bytes, title)
def _convert(midi_bytes: bytes, title: str = "") -> str: # noqa: C901
import music21
from music21 import tempo as m21tempo
midi_path = None
xml_tmp_name = None
try:
# H5 - track paths so finally block can always clean up
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
f.write(midi_bytes)
midi_path = f.name
score = music21.converter.parse(midi_path)
# Quantize to 16th-note grid + triplets - eliminates exotic tuplets
try:
score = score.quantize(
quarterLengthDivisors=(4, 3),
processOffsets=True,
processDurations=True,
inPlace=False,
)
except Exception as e:
logger.warning("Score quantization skipped: %s", e)
# Strip excessive MetronomeMark objects from rubato performances
try:
all_marks = list(score.recurse().getElementsByClass("MetronomeMark"))
primary_bpm = round(all_marks[0].number) if all_marks else 120
for mark in all_marks:
if mark.activeSite is not None:
try:
mark.activeSite.remove(mark)
except Exception:
pass # mark already detached or activeSite stale
try:
first_part = score.parts[0]
measures = first_part.getElementsByClass("Measure")
if measures:
first_measure = list(measures)[0]
mm = m21tempo.MetronomeMark(number=primary_bpm)
first_measure.insert(0, mm)
except Exception as e:
logger.warning("Could not insert primary tempo mark: %s", e)
except Exception as e:
logger.warning("Tempo cleanup skipped: %s", e)
# Set score title, sanitize to prevent XML injection
try:
from music21 import metadata as m21meta
if score.metadata is None:
score.metadata = m21meta.Metadata()
safe_title = html.escape(title[:200]) if title else "Untitled"
score.metadata.title = safe_title
except Exception as e:
logger.warning("Could not set score title: %s", e)
# Split into treble/bass staves for proper piano rendering
score = _apply_piano_split(score)
xml_tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
xml_tmp.close()
xml_tmp_name = xml_tmp.name
score.write("musicxml", fp=xml_tmp_name)
xml = Path(xml_tmp_name).read_text(encoding="utf-8")
# Strip music21-generated boilerplate that clutters the score display
xml = re.sub(r'\s*<creator\b[^>]*>[^<]*</creator>\s*\n?', '\n ', xml)
xml = re.sub(r'\s*<movement-title\b[^>]*>[^<]*</movement-title>\s*\n?', '\n ', xml)
return xml
finally:
if midi_path:
Path(midi_path).unlink(missing_ok=True)
if xml_tmp_name:
Path(xml_tmp_name).unlink(missing_ok=True)
def _apply_piano_split(score): # noqa: C901
"""Rewrite a MIDI-derived score as a proper piano grand staff.
Grand-staff piano in MusicXML must be ONE part with TWO staves
(staves>2</staves>, per-note <staff> tags, <backup> elements).
music21 achieves this via PartStaff + StaffGroup - which is what
AlphaTab needs to render treble + bass clef linked by a brace.
Strategy:
• Single-part score: split notes at C4 (MIDI 60) into treble/bass PartStaff.
• Multi-part score: treat the two highest-/lowest-pitched parts as
treble/bass and link them the same way.
"""
import copy
from music21 import clef as m21clef, instrument as m21inst, layout as m21layout
from music21 import note as m21note, chord as m21chord
from music21 import stream as m21s
parts = list(score.parts)
if not parts:
return score
SPLIT = 60 # Middle C - notes >= C4 go treble, < C4 go bass
def _avg_pitch(part):
pitches = []
for n in part.flatten().notes:
if hasattr(n, "pitches"):
pitches.extend(p.midi for p in n.pitches)
elif hasattr(n, "pitch"):
pitches.append(n.pitch.midi)
return sum(pitches) / len(pitches) if pitches else 60
# ── Build two PartStaff streams ──────────────────────────────────────────
treble_staff = m21s.PartStaff()
bass_staff = m21s.PartStaff()
treble_staff.insert(0, m21inst.Piano())
bass_staff.insert(0, m21inst.Piano())
def _has_pitched_content(m):
"""Return True if the Measure contains at least one Note, Rest, or Chord
at the top level OR nested inside Voice containers."""
if any(isinstance(e, (m21note.Note, m21note.Rest, m21chord.Chord)) for e in m):
return True
# Also check inside Voice containers
from music21 import stream as _m21s
for e in m:
if isinstance(e, _m21s.Voice):
if any(isinstance(v, (m21note.Note, m21note.Rest, m21chord.Chord)) for v in e):
return True
return False
if len(parts) == 1:
# Single-track MIDI: distribute notes by pitch while preserving Voice
# structure.
#
# Key design points:
# 1. Preserve Voice containers. Game/sequencer MIDIs nest notes inside
# Voice sub-streams. If we flatten and re-insert notes at the same
# offset into a plain Measure, music21 writes them SEQUENTIALLY
# (note A dur=2 + note B dur=0.5 = 2.5 beats consumed), inflating
# <backup> values and breaking AlphaTab's cursor tracker.
# Keeping Voice containers tells music21 that those notes are
# concurrent, so it emits correct <backup> values between voices.
# 2. Structural elements (TimeSignature, KeySignature, TempoIndication)
# go to the TREBLE staff only - putting them in both staves causes
# music21 to emit doubled <time>/<key> attributes.
# 3. Pad each staff to barDuration with a single trailing rest so the
# MusicXML <backup> value always equals the measure length exactly.
from music21 import meter as m21meter, key as m21key, tempo as m21tempo
def _dist_note(el, t_dest, b_dest):
"""Distribute a single Note or Chord between treble/bass targets."""
if isinstance(el, m21note.Note):
dest = t_dest if el.pitch.midi >= SPLIT else b_dest
dest.insert(el.offset, copy.deepcopy(el))
elif isinstance(el, m21chord.Chord):
t_notes = [n for n in el.notes if n.pitch.midi >= SPLIT]
b_notes = [n for n in el.notes if n.pitch.midi < SPLIT]
if t_notes:
c = m21chord.Chord([copy.deepcopy(n) for n in t_notes])
c.duration = copy.deepcopy(el.duration)
t_dest.insert(el.offset, c)
if b_notes:
c = m21chord.Chord([copy.deepcopy(n) for n in b_notes])
c.duration = copy.deepcopy(el.duration)
b_dest.insert(el.offset, c)
source = parts[0]
for meas in source.getElementsByClass("Measure"):
t_meas = m21s.Measure(number=meas.number)
b_meas = m21s.Measure(number=meas.number)
# ── Structural / non-pitched elements: treble only ───────────────
for el in meas:
if isinstance(el, (m21meter.TimeSignature,
m21key.KeySignature,
m21tempo.TempoIndication)):
t_meas.insert(el.offset, copy.deepcopy(el))
# Clefs are assigned separately below; instruments already set.
# ── Pitched content: preserve Voice containers ────────────────────
# Walk top-level elements. Voice containers are kept intact so that
# music21 writes concurrent notes correctly in MusicXML.
for el in meas:
if isinstance(el, m21s.Voice):
t_voice = m21s.Voice()
b_voice = m21s.Voice()
for sub in el:
_dist_note(sub, t_voice, b_voice)
if any(True for _ in t_voice):
t_meas.insert(el.offset, t_voice)
if any(True for _ in b_voice):
b_meas.insert(el.offset, b_voice)
elif isinstance(el, (m21note.Note, m21chord.Chord)):
_dist_note(el, t_meas, b_meas)
# Rests at top level are skipped; padding is added below.
# ── Pad each staff to barDuration with a single trailing rest ─────
# This ensures highestTime == barDuration so music21 emits the
# correct <backup> value when writing the combined grand-staff part.
bar_ql = meas.barDuration.quarterLength
for staff_meas in (t_meas, b_meas):
ht = staff_meas.highestTime
if ht < bar_ql - 0.001:
pad = m21note.Rest()
pad.duration.quarterLength = bar_ql - ht
staff_meas.append(pad)
treble_staff.append(t_meas)
bass_staff.append(b_meas)
else:
# Multi-track MIDI: pick the two parts with highest/lowest avg pitch
sorted_parts = sorted(parts, key=_avg_pitch, reverse=True)
treble_src, bass_src = sorted_parts[0], sorted_parts[-1]
for src, dest in [(treble_src, treble_staff), (bass_src, bass_staff)]:
for meas in src.getElementsByClass("Measure"):
dest.append(copy.deepcopy(meas))
# ── Assign clefs to first measure of each PartStaff ─────────────────────
for staff, clef_obj in [
(treble_staff, m21clef.TrebleClef()),
(bass_staff, m21clef.BassClef()),
]:
measures = list(staff.getElementsByClass("Measure"))
if measures:
first_m = measures[0]
for c in list(first_m.getElementsByClass("Clef")):
first_m.remove(c)
first_m.insert(0, clef_obj)
# ── Assemble score with brace StaffGroup (= grand staff) ─────────────────
new_score = m21s.Score()
if score.metadata:
new_score.metadata = score.metadata
new_score.insert(0, treble_staff)
new_score.insert(0, bass_staff)
# StaffGroup with symbol='brace' tells MusicXML consumers (AlphaTab)
# that these two PartStaffs are a single piano grand staff.
staff_group = m21layout.StaffGroup(
[treble_staff, bass_staff],
name="Piano",
abbreviation="Pno.",
symbol="brace",
)
new_score.insert(0, staff_group)
return new_score
async def musicxml_bytes_to_midi(xml_bytes: bytes, filename: str = "score.xml") -> bytes:
"""Convert MusicXML (.xml or .mxl) bytes to MIDI bytes using music21."""
return await _run_in_music21_pool(_xml_to_midi, xml_bytes, filename)
def _xml_to_midi(xml_bytes: bytes, filename: str) -> bytes:
import music21
suffix = ".mxl" if filename.lower().endswith(".mxl") else ".xml"
xml_path = None
midi_tmp_name = None
try:
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
f.write(xml_bytes)
xml_path = f.name
score = music21.converter.parse(xml_path)
midi_tmp = tempfile.NamedTemporaryFile(suffix=".mid", delete=False)
midi_tmp.close()
midi_tmp_name = midi_tmp.name
score.write("midi", fp=midi_tmp_name)
return Path(midi_tmp_name).read_bytes()
finally:
if xml_path:
Path(xml_path).unlink(missing_ok=True)
if midi_tmp_name:
Path(midi_tmp_name).unlink(missing_ok=True)
async def musicxml_to_pdf(musicxml: str) -> str:
"""Render MusicXML to PDF using music21 + LilyPond. Returns path to PDF file."""
return await _run_in_music21_pool(_to_pdf, musicxml)
def _to_pdf(musicxml: str) -> str:
import music21
import shutil
import subprocess
xml_path = None
ly_path = None
pdf_tmp_name = None
try:
with tempfile.NamedTemporaryFile(
suffix=".xml", delete=False, mode="w", encoding="utf-8"
) as f:
f.write(musicxml)
xml_path = f.name
score = music21.converter.parse(xml_path)
# M2: Generate LilyPond source via music21, then invoke lilypond
# manually with a restricted environment. This prevents a crafted
# score from exfiltrating env vars or touching the filesystem via
# LilyPond's embedded Scheme interpreter.
ly_tmp = tempfile.NamedTemporaryFile(suffix=".ly", delete=False)
ly_tmp.close()
ly_path = ly_tmp.name
score.write("lily", fp=ly_path)
lilypond_bin = shutil.which("lilypond")
if not lilypond_bin:
raise RuntimeError(
"LilyPond not found in PATH. PDF export requires LilyPond.\n"
" macOS: brew install lilypond\n"
" Linux: apt install lilypond"
)
pdf_tmp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
pdf_tmp.close()
pdf_tmp_name = pdf_tmp.name
# LilyPond appends .pdf to the -o basename, so strip the extension
pdf_base = pdf_tmp_name[:-4]
# Minimal environment: only PATH, HOME, and LilyPond/Guile paths
safe_env = {
"PATH": os.environ.get("PATH", "/usr/bin:/usr/local/bin"),
"HOME": os.environ.get("HOME", "/tmp"),
}
for key in ("GUILE_LOAD_PATH", "GUILE_LOAD_COMPILED_PATH", "LILYPOND_DATADIR"):
if key in os.environ:
safe_env[key] = os.environ[key]
result = subprocess.run(
[lilypond_bin, "--pdf", "-dno-point-and-click", "-o", pdf_base, ly_path],
capture_output=True,
timeout=60,
env=safe_env,
)
if result.returncode != 0 or not Path(pdf_tmp_name).exists():
logger.error("LilyPond failed: %s", result.stderr.decode(errors="replace")[:500])
raise RuntimeError("LilyPond PDF rendering failed")
# Caller (export_routes) owns cleanup via BackgroundTask
pdf_tmp_name = None # transfer ownership, don't clean up in finally
return pdf_tmp.name
finally:
if xml_path:
Path(xml_path).unlink(missing_ok=True)
if ly_path:
Path(ly_path).unlink(missing_ok=True)
# Clean up PDF temp only if we still own it (i.e. render raised)
if pdf_tmp_name:
Path(pdf_tmp_name).unlink(missing_ok=True)
+76
View File
@@ -0,0 +1,76 @@
"""
pianoplayer service: annotate MusicXML with fingering (Parncutt algorithm).
Note: defusedxml.defuse_stdlib() is called in main.py before this module
is imported, so xml.etree (used by music21 internally) is already patched.
"""
import asyncio
import os
import tempfile
from pathlib import Path
# Configurable via env var; default 120 s is generous for complex scores.
_FINGERING_TIMEOUT = int(os.environ.get("FINGERING_TIMEOUT_S", "120"))
async def annotate_fingering(musicxml: str) -> str:
"""Add finger annotations to MusicXML using the pianoplayer library."""
try:
return await asyncio.wait_for(
asyncio.get_running_loop().run_in_executor(None, _annotate, musicxml),
timeout=_FINGERING_TIMEOUT,
)
except asyncio.TimeoutError:
raise RuntimeError(
"Fingering generation timed out. "
"Try a shorter score or increase FINGERING_TIMEOUT_S."
)
def _annotate(musicxml: str) -> str:
from pianoplayer.hand import RightHand, LeftHand
import music21
xml_path = None
out_tmp_name = None
try:
with tempfile.NamedTemporaryFile(
suffix=".xml", delete=False, mode="w", encoding="utf-8"
) as f:
f.write(musicxml)
xml_path = f.name
score = music21.converter.parse(xml_path)
# pianoplayer works on individual parts
for part in score.parts:
notes = list(part.flatten().notes)
if not notes:
continue
# Determine left / right hand by average register
avg_pitch = sum(
n.pitch.midi for n in notes if hasattr(n, "pitch")
) / max(len(notes), 1)
hand_cls = RightHand if avg_pitch >= 60 else LeftHand
try:
hand = hand_cls(score, 0)
hand.autodepth = False
hand.verbose = False
hand.generate(part=part.id if hasattr(part, "id") else 0)
except Exception:
pass # pianoplayer can fail on complex scores; skip gracefully
out_tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
out_tmp.close()
out_tmp_name = out_tmp.name
score.write("musicxml", fp=out_tmp_name)
return Path(out_tmp_name).read_text(encoding="utf-8")
finally:
if xml_path:
Path(xml_path).unlink(missing_ok=True)
if out_tmp_name:
Path(out_tmp_name).unlink(missing_ok=True)
+59
View File
@@ -0,0 +1,59 @@
"""
yt-dlp service: download best audio from a YouTube URL.
Returns (audio_path, tmpdir) - the caller is responsible for cleaning up
tmpdir (e.g. via a BackgroundTask) after the audio data has been consumed.
"""
import asyncio
import subprocess
import tempfile
from pathlib import Path
async def download_audio(url: str) -> tuple[Path, str]:
"""Download best audio track from YouTube URL.
Returns:
(mp3_path, tmpdir) - tmpdir must be deleted by the caller.
"""
return await asyncio.get_running_loop().run_in_executor(None, _download, url)
def _download(url: str) -> tuple[Path, str]:
tmpdir = tempfile.mkdtemp()
output_template = str(Path(tmpdir) / "audio.%(ext)s")
result = subprocess.run(
[
"yt-dlp",
"--format", "bestaudio/best",
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", "0",
"--output", output_template,
"--no-playlist",
# Cap download size: a 30-min piano recording at high quality is ~50 MB;
# 200 MB gives ample headroom while guarding against 24-hour streams
# that would exhaust disk space before the 300 s timeout fires.
"--max-filesize", "200m",
url, # already a safe canonical URL from the router
],
capture_output=True,
timeout=300,
)
if result.returncode != 0:
# Don't leak yt-dlp stderr in the API response; log it server-side
import logging
logging.getLogger(__name__).error(
"yt-dlp failed (exit %d): %s",
result.returncode,
result.stderr.decode(errors="replace")[:500],
)
raise RuntimeError("Audio download failed")
mp3_files = list(Path(tmpdir).glob("*.mp3"))
if not mp3_files:
raise RuntimeError("Audio download produced no output")
return mp3_files[0], tmpdir