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:
Claude
2026-03-17 00:11:52 +00:00
parent 76da6cbc54
commit a076cb47f3
6 changed files with 308 additions and 0 deletions

View File

@@ -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 = [

View File

@@ -0,0 +1 @@
"""Sheet music OCR tool using Audiveris."""

View 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]

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

View File

@@ -14,6 +14,8 @@
ssh-to-age
gnupg
age
audiveris
];
};
}

View File

@@ -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"<score-partwise/>"):
"""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"<score-partwise>hello</score-partwise>"
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", "<container/>")
zf.writestr("score.xml", b"<score/>")
extract_mxml_from_mxl(mxl, output)
assert output.read_bytes() == b"<score/>"
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"<score-partwise/>")
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()