mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-21 14:49:10 -04:00
add sheet music OCR CLI tool using Audiveris
Adds a Typer CLI (sheet-music-ocr) that converts scanned sheet music (PDF, PNG, JPG, TIFF) to MusicXML via Audiveris, preserving lyrics and text annotations. Includes Audiveris in the nix dev shell. https://claude.ai/code/session_017GqUbuRDT58toRaxMtfRmf
This commit is contained in:
1
python/sheet_music_ocr/__init__.py
Normal file
1
python/sheet_music_ocr/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Sheet music OCR tool using Audiveris."""
|
||||
62
python/sheet_music_ocr/audiveris.py
Normal file
62
python/sheet_music_ocr/audiveris.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Audiveris subprocess wrapper for optical music recognition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class AudiverisError(Exception):
|
||||
"""Raised when Audiveris processing fails."""
|
||||
|
||||
|
||||
def find_audiveris() -> str:
|
||||
"""Find the Audiveris executable on PATH.
|
||||
|
||||
Returns:
|
||||
Path to the audiveris executable.
|
||||
|
||||
Raises:
|
||||
AudiverisError: If Audiveris is not found.
|
||||
"""
|
||||
path = shutil.which("audiveris")
|
||||
if not path:
|
||||
msg = "Audiveris not found on PATH. Install it via 'nix develop' or add it to your environment."
|
||||
raise AudiverisError(msg)
|
||||
return path
|
||||
|
||||
|
||||
def run_audiveris(input_path: Path, output_dir: Path) -> Path:
|
||||
"""Run Audiveris on an input file and return the path to the generated .mxl.
|
||||
|
||||
Args:
|
||||
input_path: Path to the input sheet music file (PDF, PNG, JPG, TIFF).
|
||||
output_dir: Directory where Audiveris will write its output.
|
||||
|
||||
Returns:
|
||||
Path to the generated .mxl file.
|
||||
|
||||
Raises:
|
||||
AudiverisError: If Audiveris fails or produces no output.
|
||||
"""
|
||||
audiveris = find_audiveris()
|
||||
result = subprocess.run(
|
||||
[audiveris, "-batch", "-export", "-output", str(output_dir), str(input_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
msg = f"Audiveris failed (exit {result.returncode}):\n{result.stderr}"
|
||||
raise AudiverisError(msg)
|
||||
|
||||
mxl_files = list(output_dir.rglob("*.mxl"))
|
||||
if not mxl_files:
|
||||
msg = f"Audiveris produced no .mxl output in {output_dir}"
|
||||
raise AudiverisError(msg)
|
||||
|
||||
return mxl_files[0]
|
||||
88
python/sheet_music_ocr/main.py
Normal file
88
python/sheet_music_ocr/main.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""CLI tool for converting scanned sheet music to MusicXML.
|
||||
|
||||
Usage:
|
||||
sheet-music-ocr scan.pdf
|
||||
sheet-music-ocr scan.png -o output.mxml
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from python.sheet_music_ocr.audiveris import AudiverisError, run_audiveris
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"}
|
||||
|
||||
app = typer.Typer(help="Convert scanned sheet music to MusicXML using Audiveris.")
|
||||
|
||||
|
||||
def extract_mxml_from_mxl(mxl_path: Path, output_path: Path) -> Path:
|
||||
"""Extract the MusicXML file from an .mxl archive.
|
||||
|
||||
An .mxl file is a ZIP archive containing one or more .xml MusicXML files.
|
||||
|
||||
Args:
|
||||
mxl_path: Path to the .mxl file.
|
||||
output_path: Path where the extracted .mxml file should be written.
|
||||
|
||||
Returns:
|
||||
The output path.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If no MusicXML file is found inside the archive.
|
||||
"""
|
||||
with zipfile.ZipFile(mxl_path, "r") as zf:
|
||||
xml_names = [n for n in zf.namelist() if n.endswith(".xml") and not n.startswith("META-INF")]
|
||||
if not xml_names:
|
||||
msg = f"No MusicXML (.xml) file found inside {mxl_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
with zf.open(xml_names[0]) as src, output_path.open("wb") as dst:
|
||||
dst.write(src.read())
|
||||
return output_path
|
||||
|
||||
|
||||
@app.command()
|
||||
def convert(
|
||||
input_file: Annotated[Path, typer.Argument(help="Path to sheet music scan (PDF, PNG, JPG, TIFF).")],
|
||||
output: Annotated[
|
||||
Path | None,
|
||||
typer.Option("--output", "-o", help="Output .mxml file path. Defaults to <input_stem>.mxml."),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Convert a scanned sheet music file to MusicXML."""
|
||||
if not input_file.exists():
|
||||
typer.echo(f"Error: {input_file} does not exist.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
if input_file.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||||
typer.echo(
|
||||
f"Error: Unsupported format '{input_file.suffix}'. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
output_path = output or input_file.with_suffix(".mxml")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
try:
|
||||
mxl_path = run_audiveris(input_file, Path(tmpdir))
|
||||
except AudiverisError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
try:
|
||||
extract_mxml_from_mxl(mxl_path, output_path)
|
||||
except FileNotFoundError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
typer.echo(f"Written: {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
Reference in New Issue
Block a user