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
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)