From a076cb47f3078e0aea1febbc9a966e39a664810b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 00:11:52 +0000 Subject: [PATCH] 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 --- pyproject.toml | 1 + python/sheet_music_ocr/__init__.py | 1 + python/sheet_music_ocr/audiveris.py | 62 +++++++++++ python/sheet_music_ocr/main.py | 88 ++++++++++++++++ shell.nix | 2 + tests/test_sheet_music_ocr.py | 154 ++++++++++++++++++++++++++++ 6 files changed, 308 insertions(+) create mode 100644 python/sheet_music_ocr/__init__.py create mode 100644 python/sheet_music_ocr/audiveris.py create mode 100644 python/sheet_music_ocr/main.py create mode 100644 tests/test_sheet_music_ocr.py diff --git a/pyproject.toml b/pyproject.toml index 3f58e7b..cb87464 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ [project.scripts] database = "python.database_cli:app" van-inventory = "python.van_inventory.main:serve" +sheet-music-ocr = "python.sheet_music_ocr.main:app" [dependency-groups] dev = [ diff --git a/python/sheet_music_ocr/__init__.py b/python/sheet_music_ocr/__init__.py new file mode 100644 index 0000000..6a84820 --- /dev/null +++ b/python/sheet_music_ocr/__init__.py @@ -0,0 +1 @@ +"""Sheet music OCR tool using Audiveris.""" diff --git a/python/sheet_music_ocr/audiveris.py b/python/sheet_music_ocr/audiveris.py new file mode 100644 index 0000000..d2247c3 --- /dev/null +++ b/python/sheet_music_ocr/audiveris.py @@ -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] diff --git a/python/sheet_music_ocr/main.py b/python/sheet_music_ocr/main.py new file mode 100644 index 0000000..2d957a5 --- /dev/null +++ b/python/sheet_music_ocr/main.py @@ -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 .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() diff --git a/shell.nix b/shell.nix index b8a7742..d235cf8 100644 --- a/shell.nix +++ b/shell.nix @@ -14,6 +14,8 @@ ssh-to-age gnupg age + + audiveris ]; }; } diff --git a/tests/test_sheet_music_ocr.py b/tests/test_sheet_music_ocr.py new file mode 100644 index 0000000..cde8537 --- /dev/null +++ b/tests/test_sheet_music_ocr.py @@ -0,0 +1,154 @@ +import zipfile +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from python.sheet_music_ocr.audiveris import AudiverisError, find_audiveris, run_audiveris +from python.sheet_music_ocr.main import SUPPORTED_EXTENSIONS, app, extract_mxml_from_mxl + +runner = CliRunner() + + +def make_mxl(path, xml_content=b""): + """Create a minimal .mxl (ZIP) file with a MusicXML inside.""" + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("score.xml", xml_content) + + +class TestExtractMxmlFromMxl: + def test_extracts_xml(self, tmp_path): + mxl = tmp_path / "test.mxl" + output = tmp_path / "output.mxml" + content = b"hello" + make_mxl(mxl, content) + + result = extract_mxml_from_mxl(mxl, output) + + assert result == output + assert output.read_bytes() == content + + def test_skips_meta_inf(self, tmp_path): + mxl = tmp_path / "test.mxl" + output = tmp_path / "output.mxml" + with zipfile.ZipFile(mxl, "w") as zf: + zf.writestr("META-INF/container.xml", "") + zf.writestr("score.xml", b"") + + extract_mxml_from_mxl(mxl, output) + + assert output.read_bytes() == b"" + + def test_raises_when_no_xml(self, tmp_path): + mxl = tmp_path / "test.mxl" + output = tmp_path / "output.mxml" + with zipfile.ZipFile(mxl, "w") as zf: + zf.writestr("readme.txt", "no xml here") + + with pytest.raises(FileNotFoundError, match="No MusicXML"): + extract_mxml_from_mxl(mxl, output) + + +class TestFindAudiveris: + def test_raises_when_not_found(self): + with ( + patch("python.sheet_music_ocr.audiveris.shutil.which", return_value=None), + pytest.raises(AudiverisError, match="not found"), + ): + find_audiveris() + + def test_returns_path_when_found(self): + with patch("python.sheet_music_ocr.audiveris.shutil.which", return_value="/usr/bin/audiveris"): + assert find_audiveris() == "/usr/bin/audiveris" + + +class TestRunAudiveris: + def test_raises_on_nonzero_exit(self, tmp_path): + with ( + patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"), + patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run, + ): + mock_run.return_value.returncode = 1 + mock_run.return_value.stderr = "something went wrong" + + with pytest.raises(AudiverisError, match="failed"): + run_audiveris(tmp_path / "input.pdf", tmp_path / "output") + + def test_raises_when_no_mxl_produced(self, tmp_path): + output_dir = tmp_path / "output" + output_dir.mkdir() + + with ( + patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"), + patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run, + ): + mock_run.return_value.returncode = 0 + + with pytest.raises(AudiverisError, match=r"no \.mxl output"): + run_audiveris(tmp_path / "input.pdf", output_dir) + + def test_returns_mxl_path(self, tmp_path): + output_dir = tmp_path / "output" + output_dir.mkdir() + mxl = output_dir / "score.mxl" + make_mxl(mxl) + + with ( + patch("python.sheet_music_ocr.audiveris.find_audiveris", return_value="audiveris"), + patch("python.sheet_music_ocr.audiveris.subprocess.run") as mock_run, + ): + mock_run.return_value.returncode = 0 + + result = run_audiveris(tmp_path / "input.pdf", output_dir) + assert result == mxl + + +class TestCli: + def test_missing_input_file(self, tmp_path): + result = runner.invoke(app, [str(tmp_path / "nonexistent.pdf")]) + assert result.exit_code == 1 + assert "does not exist" in result.output + + def test_unsupported_format(self, tmp_path): + bad_file = tmp_path / "music.bmp" + bad_file.touch() + result = runner.invoke(app, [str(bad_file)]) + assert result.exit_code == 1 + assert "Unsupported format" in result.output + + def test_supported_extensions_complete(self): + assert ".pdf" in SUPPORTED_EXTENSIONS + assert ".png" in SUPPORTED_EXTENSIONS + assert ".jpg" in SUPPORTED_EXTENSIONS + assert ".jpeg" in SUPPORTED_EXTENSIONS + assert ".tiff" in SUPPORTED_EXTENSIONS + + def test_successful_conversion(self, tmp_path): + input_file = tmp_path / "score.pdf" + input_file.touch() + output_file = tmp_path / "score.mxml" + + mxl_path = tmp_path / "tmp_mxl" / "score.mxl" + mxl_path.parent.mkdir() + make_mxl(mxl_path, b"") + + with patch("python.sheet_music_ocr.main.run_audiveris", return_value=mxl_path): + result = runner.invoke(app, [str(input_file), "-o", str(output_file)]) + + assert result.exit_code == 0 + assert output_file.exists() + assert "Written" in result.output + + def test_default_output_path(self, tmp_path): + input_file = tmp_path / "score.png" + input_file.touch() + + mxl_path = tmp_path / "tmp_mxl" / "score.mxl" + mxl_path.parent.mkdir() + make_mxl(mxl_path) + + with patch("python.sheet_music_ocr.main.run_audiveris", return_value=mxl_path): + result = runner.invoke(app, [str(input_file)]) + + assert result.exit_code == 0 + assert (tmp_path / "score.mxml").exists()