Initial commit
This commit is contained in:
@@ -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.")
|
||||
Reference in New Issue
Block a user