play.py
python
| 1 | """muse play — play a Muse audio artifact via macOS ``afplay``. |
| 2 | |
| 3 | Behaviour by file type: |
| 4 | |
| 5 | - ``.mp3`` / ``.aiff`` / ``.wav`` / ``.m4a`` → played via ``afplay`` (no UI, |
| 6 | process exits when playback finishes). |
| 7 | - ``.mid`` → falls back to ``open`` (hands off to the system default MIDI |
| 8 | app). ``afplay`` does not support MIDI; this limitation is surfaced |
| 9 | clearly in the terminal output. |
| 10 | |
| 11 | macOS-only. Exits 1 with a clear error on other platforms. |
| 12 | """ |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import asyncio |
| 16 | import logging |
| 17 | import pathlib |
| 18 | import platform |
| 19 | import subprocess |
| 20 | |
| 21 | import typer |
| 22 | |
| 23 | from maestro.muse_cli._repo import require_repo |
| 24 | from maestro.muse_cli.artifact_resolver import resolve_artifact_async |
| 25 | from maestro.muse_cli.db import open_session |
| 26 | from maestro.muse_cli.errors import ExitCode |
| 27 | |
| 28 | logger = logging.getLogger(__name__) |
| 29 | |
| 30 | app = typer.Typer() |
| 31 | |
| 32 | #: File extensions that ``afplay`` handles natively. |
| 33 | _AFPLAY_EXTENSIONS = frozenset({".mp3", ".aiff", ".aif", ".wav", ".m4a", ".caf"}) |
| 34 | |
| 35 | #: File extensions where we fall back to ``open``. |
| 36 | _OPEN_FALLBACK_EXTENSIONS = frozenset({".mid", ".midi"}) |
| 37 | |
| 38 | |
| 39 | def _guard_macos() -> None: |
| 40 | """Exit 1 with a clear message if not running on macOS.""" |
| 41 | if platform.system() != "Darwin": |
| 42 | typer.echo("❌ muse play requires macOS.") |
| 43 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 44 | |
| 45 | |
| 46 | def _play_path(path: pathlib.Path) -> None: |
| 47 | """Dispatch playback for *path* based on its suffix. |
| 48 | |
| 49 | Extracted for unit-testability — callers mock ``subprocess.run``. |
| 50 | """ |
| 51 | suffix = path.suffix.lower() |
| 52 | |
| 53 | if suffix in _AFPLAY_EXTENSIONS: |
| 54 | typer.echo(f"▶ Playing {path.name} …") |
| 55 | subprocess.run(["afplay", str(path)], check=True) |
| 56 | typer.echo("⏹ Playback finished.") |
| 57 | logger.info("✅ muse play (afplay): %s", path) |
| 58 | |
| 59 | elif suffix in _OPEN_FALLBACK_EXTENSIONS: |
| 60 | typer.echo( |
| 61 | f"⚠️ MIDI playback via afplay is not supported.\n" |
| 62 | f" Opening {path.name} in the system default MIDI app instead." |
| 63 | ) |
| 64 | subprocess.run(["open", str(path)], check=True) |
| 65 | logger.info("✅ muse play (open fallback): %s", path) |
| 66 | |
| 67 | else: |
| 68 | typer.echo( |
| 69 | f"⚠️ Unsupported file type '{suffix}'.\n" |
| 70 | f" Attempting to open with system default app." |
| 71 | ) |
| 72 | subprocess.run(["open", str(path)], check=True) |
| 73 | logger.warning("⚠️ muse play: unknown extension '%s' for %s", suffix, path) |
| 74 | |
| 75 | |
| 76 | @app.callback(invoke_without_command=True) |
| 77 | def play( |
| 78 | ctx: typer.Context, |
| 79 | path_or_id: str = typer.Argument(..., help="File path or short commit ID."), |
| 80 | ) -> None: |
| 81 | """Play an audio artifact via macOS afplay, or open MIDI in system default app.""" |
| 82 | _guard_macos() |
| 83 | root = require_repo() |
| 84 | |
| 85 | async def _run() -> pathlib.Path: |
| 86 | async with open_session() as session: |
| 87 | return await resolve_artifact_async(path_or_id, root=root, session=session) |
| 88 | |
| 89 | try: |
| 90 | resolved = asyncio.run(_run()) |
| 91 | _play_path(resolved) |
| 92 | except typer.Exit: |
| 93 | raise |
| 94 | except subprocess.CalledProcessError as exc: |
| 95 | typer.echo(f"❌ muse play failed: {exc}") |
| 96 | logger.error("❌ muse play subprocess error: %s", exc) |
| 97 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 98 | except Exception as exc: |
| 99 | typer.echo(f"❌ muse play failed: {exc}") |
| 100 | logger.error("❌ muse play error: %s", exc, exc_info=True) |
| 101 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |