cgcardona / muse public
render_preview.py python
302 lines 10.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse render-preview — generate an audio preview of a commit's snapshot.
2
3 Fetches the MIDI snapshot for a target commit, passes it to the Storpheus
4 render pipeline, and writes the resulting audio file to disk. Optionally
5 opens the file in the system default audio player.
6
7 This is the musical equivalent of ``git show <commit>`` + audio playback:
8 it lets producers hear what the project sounded like at any point in history
9 without opening a DAW session.
10
11 Usage::
12
13 muse render-preview # HEAD → /tmp/muse-preview-<id>.wav
14 muse render-preview abc1234 # specific commit
15 muse render-preview --format mp3 --output ./my.mp3 # custom path and format
16 muse render-preview --track drums --section chorus # filtered render
17 muse render-preview abc1234 --open # render + open in system player
18
19 Flags:
20 [<commit>] Short commit ID prefix (default: HEAD).
21 --track TEXT Render only MIDI files matching this track name substring.
22 --section TEXT Render only MIDI files matching this section name substring.
23 --format Output audio format: wav | mp3 | flac (default: wav).
24 --open Open the rendered file in the system default player after rendering.
25 --output PATH Write the preview to a specific path
26 (default: /tmp/muse-preview-<short_id>.<format>).
27 --json Emit structured JSON instead of human-readable output.
28
29 This command is read-only — it never creates a new Muse commit or modifies
30 the working tree.
31 """
32 from __future__ import annotations
33
34 import asyncio
35 import json as json_mod
36 import logging
37 import pathlib
38 import platform
39 import subprocess
40 from typing import Optional
41
42 import typer
43 from sqlalchemy.ext.asyncio import AsyncSession
44
45 from maestro.config import settings
46 from maestro.muse_cli._repo import require_repo
47 from maestro.muse_cli.db import find_commits_by_prefix, open_session
48 from maestro.muse_cli.errors import ExitCode
49 from maestro.muse_cli.export_engine import resolve_commit_id
50 from maestro.services.muse_render_preview import (
51 PreviewFormat,
52 RenderPreviewResult,
53 StorpheusRenderUnavailableError,
54 render_preview,
55 )
56
57 logger = logging.getLogger(__name__)
58
59 app = typer.Typer()
60
61
62 def _default_output_path(commit_id: str, fmt: PreviewFormat) -> pathlib.Path:
63 """Return the default /tmp output path for a render-preview.
64
65 Pattern: ``/tmp/muse-preview-<commit8>.<format>``.
66
67 Args:
68 commit_id: Full commit ID (uses first 8 chars).
69 fmt: Target audio format.
70
71 Returns:
72 A Path under /tmp suitable for ephemeral preview files.
73 """
74 return pathlib.Path(f"/tmp/muse-preview-{commit_id[:8]}.{fmt.value}")
75
76
77 def _open_file(path: pathlib.Path) -> None:
78 """Open *path* in the system default application (macOS only).
79
80 Falls back gracefully on non-macOS with a warning rather than crashing,
81 since ``muse render-preview`` is otherwise platform-agnostic.
82
83 Args:
84 path: Path to the rendered audio file.
85 """
86 if platform.system() != "Darwin":
87 typer.echo(
88 "⚠️ --open is only supported on macOS. "
89 f"Open manually: {path}"
90 )
91 return
92 try:
93 subprocess.run(["open", str(path)], check=True)
94 logger.info("✅ muse render-preview: opened %s in system player", path)
95 except subprocess.CalledProcessError as exc:
96 typer.echo(f"⚠️ Failed to open {path}: {exc}")
97 logger.warning("⚠️ muse render-preview: open failed: %s", exc)
98
99
100 async def _render_preview_async(
101 *,
102 commit_ref: Optional[str],
103 fmt: PreviewFormat,
104 output: Optional[pathlib.Path],
105 track: Optional[str],
106 section: Optional[str],
107 root: pathlib.Path,
108 session: AsyncSession,
109 ) -> RenderPreviewResult:
110 """Core render-preview logic — injectable for tests.
111
112 Resolves the commit reference to a full commit ID, loads its snapshot
113 manifest from the database, and delegates to the render-preview service.
114
115 Args:
116 commit_ref: Short commit ID prefix or None for HEAD.
117 fmt: Target audio format.
118 output: Explicit output path or None to use the default /tmp path.
119 track: Track name filter.
120 section: Section name filter.
121 root: Muse repository root.
122 session: Open async DB session.
123
124 Returns:
125 RenderPreviewResult describing the rendered file.
126
127 Raises:
128 typer.Exit: On user errors (no commits, bad prefix, empty snapshot).
129 StorpheusRenderUnavailableError: When Storpheus is not reachable.
130 """
131 from maestro.muse_cli.db import get_commit_snapshot_manifest
132
133 try:
134 raw_ref = resolve_commit_id(root, commit_ref)
135 except ValueError as exc:
136 typer.echo(f"❌ {exc}")
137 raise typer.Exit(code=ExitCode.USER_ERROR)
138
139 if len(raw_ref) < 64:
140 matches = await find_commits_by_prefix(session, raw_ref)
141 if not matches:
142 typer.echo(f"❌ No commit found matching prefix '{raw_ref[:8]}'")
143 raise typer.Exit(code=ExitCode.USER_ERROR)
144 if len(matches) > 1:
145 typer.echo(
146 f"❌ Ambiguous commit prefix '{raw_ref[:8]}' "
147 f"— matches {len(matches)} commits:"
148 )
149 for c in matches:
150 typer.echo(f" {c.commit_id[:8]} {c.message[:60]}")
151 typer.echo("Use a longer prefix to disambiguate.")
152 raise typer.Exit(code=ExitCode.USER_ERROR)
153 full_commit_id = matches[0].commit_id
154 else:
155 full_commit_id = raw_ref
156
157 manifest = await get_commit_snapshot_manifest(session, full_commit_id)
158 if manifest is None:
159 typer.echo(f"❌ Commit {full_commit_id[:8]} not found in database.")
160 raise typer.Exit(code=ExitCode.USER_ERROR)
161
162 if not manifest:
163 typer.echo(
164 f"⚠️ Snapshot for commit {full_commit_id[:8]} is empty — nothing to render."
165 )
166 raise typer.Exit(code=ExitCode.USER_ERROR)
167
168 out_path = output if output is not None else _default_output_path(full_commit_id, fmt)
169 storpheus_url = settings.storpheus_base_url
170
171 return render_preview(
172 manifest=manifest,
173 root=root,
174 commit_id=full_commit_id,
175 output_path=out_path,
176 fmt=fmt,
177 track=track,
178 section=section,
179 storpheus_url=storpheus_url,
180 )
181
182
183 @app.callback(invoke_without_command=True)
184 def render_preview_cmd(
185 ctx: typer.Context,
186 commit: Optional[str] = typer.Argument(
187 None,
188 help="Short commit ID prefix to preview (default: HEAD).",
189 show_default=False,
190 ),
191 fmt: PreviewFormat = typer.Option(
192 PreviewFormat.WAV,
193 "--format",
194 "-f",
195 help="Output audio format: wav | mp3 | flac.",
196 case_sensitive=False,
197 ),
198 output: Optional[pathlib.Path] = typer.Option(
199 None,
200 "--output",
201 "-o",
202 help="Write the preview to this path (default: /tmp/muse-preview-<id>.<format>).",
203 ),
204 track: Optional[str] = typer.Option(
205 None,
206 "--track",
207 help="Render only MIDI files matching this track name substring.",
208 ),
209 section: Optional[str] = typer.Option(
210 None,
211 "--section",
212 help="Render only MIDI files matching this section name substring.",
213 ),
214 open_after: bool = typer.Option(
215 False,
216 "--open",
217 help="Open the rendered preview in the system default audio player.",
218 ),
219 as_json: bool = typer.Option(
220 False,
221 "--json",
222 help="Emit structured JSON output for agent consumption.",
223 ),
224 ) -> None:
225 """Generate an audio preview of a commit's MIDI snapshot.
226
227 Retrieves the snapshot for COMMIT (default: HEAD), renders its MIDI
228 content to audio via Storpheus, and writes the result to disk.
229
230 Use --open to hear the preview immediately. Use --json for structured
231 output suitable for AI agent consumption.
232
233 Supported formats: wav (default), mp3, flac.
234 """
235 root = require_repo()
236
237 async def _run() -> RenderPreviewResult:
238 async with open_session() as session:
239 return await _render_preview_async(
240 commit_ref=commit,
241 fmt=fmt,
242 output=output,
243 track=track,
244 section=section,
245 root=root,
246 session=session,
247 )
248
249 try:
250 result = asyncio.run(_run())
251 except typer.Exit:
252 raise
253 except StorpheusRenderUnavailableError as exc:
254 typer.echo(f"❌ Storpheus not reachable — render aborted.\n{exc}")
255 logger.error("muse render-preview: Storpheus unavailable: %s", exc)
256 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
257 except ValueError as exc:
258 typer.echo(f"❌ {exc}")
259 logger.error("muse render-preview: %s", exc)
260 raise typer.Exit(code=ExitCode.USER_ERROR)
261 except Exception as exc:
262 typer.echo(f"❌ muse render-preview failed: {exc}")
263 logger.error("muse render-preview error: %s", exc, exc_info=True)
264 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
265
266 if as_json:
267 payload = {
268 "commit_id": result.commit_id,
269 "commit_short": result.commit_id[:8],
270 "output_path": str(result.output_path),
271 "format": result.format.value,
272 "midi_files_used": result.midi_files_used,
273 "skipped_count": result.skipped_count,
274 "stubbed": result.stubbed,
275 }
276 typer.echo(json_mod.dumps(payload, indent=2))
277 else:
278 if result.stubbed:
279 typer.echo(
280 f"⚠️ Preview generated (stub — Storpheus /render not yet deployed):\n"
281 f" {result.output_path}"
282 )
283 else:
284 typer.echo(
285 f"✅ Preview rendered [{result.format.value}]:\n"
286 f" {result.output_path}"
287 )
288 if result.midi_files_used > 1:
289 typer.echo(f" ({result.midi_files_used} MIDI files used)")
290 if result.skipped_count:
291 typer.echo(f" ({result.skipped_count} entries skipped)")
292
293 logger.info(
294 "muse render-preview: commit=%s format=%s output=%s stubbed=%s",
295 result.commit_id[:8],
296 result.format.value,
297 result.output_path,
298 result.stubbed,
299 )
300
301 if open_after:
302 _open_file(result.output_path)