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