cgcardona / muse public
play.py python
101 lines 3.4 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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)