cgcardona / muse public
muse_render_preview.py python
286 lines 10.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Render-Preview Service — MIDI → audio preview via Storpheus.
2
3 Converts a Muse commit snapshot (a manifest of MIDI files) into a rendered
4 audio file by delegating to the Storpheus render endpoint. This is the
5 commit-aware counterpart to ``muse play``: instead of playing whatever file
6 the user points to, it first resolves a commit, extracts its MIDI files, and
7 dispatches them for audio rendering.
8
9 Boundary contract:
10 - Input: ``dict[str, str]`` snapshot manifest (rel_path → object_id)
11 - Output: ``RenderPreviewResult`` — path written, format, commit short ID, and
12 a ``stubbed`` flag that signals whether a full Storpheus render
13 actually occurred or whether the MIDI was copied as a placeholder.
14 - Side effects: Writes exactly one file to the caller-supplied ``output_path``.
15 Never touches the Muse repository or the database.
16
17 Storpheus render status:
18 The Storpheus service exposes MIDI *generation* at ``POST /generate``.
19 A dedicated ``POST /render`` endpoint (MIDI-in → audio-out) is planned but
20 not yet deployed. Until that endpoint ships this module performs a
21 health-check to confirm Storpheus is reachable, then writes the first MIDI
22 file from the manifest to the output path (format: wav stub).
23
24 When ``/render`` is available, replace ``_render_via_storpheus`` with a
25 real POST call and set ``stubbed=False`` on the result.
26 """
27 from __future__ import annotations
28
29 import logging
30 import pathlib
31 import shutil
32 from dataclasses import dataclass
33 from enum import Enum
34 from typing import Optional
35
36 import httpx
37
38 logger = logging.getLogger(__name__)
39
40
41 # ---------------------------------------------------------------------------
42 # Public types
43 # ---------------------------------------------------------------------------
44
45
46 class PreviewFormat(str, Enum):
47 """Audio format for the rendered preview file."""
48
49 WAV = "wav"
50 MP3 = "mp3"
51 FLAC = "flac"
52
53
54 @dataclass(frozen=True)
55 class RenderPreviewResult:
56 """Result of a single render-preview operation.
57
58 Attributes:
59 output_path: Absolute path of the file written to disk.
60 format: Audio format that was rendered.
61 commit_id: Full commit ID whose snapshot was rendered.
62 midi_files_used: Number of MIDI files from the snapshot used for rendering.
63 skipped_count: Manifest entries skipped (wrong type / missing on disk).
64 stubbed: True when the Storpheus ``/render`` endpoint is not yet
65 available and a MIDI file was copied in its place. Consumers
66 should surface this to the user so they understand the file is
67 not a full audio render.
68 """
69
70 output_path: pathlib.Path
71 format: PreviewFormat
72 commit_id: str
73 midi_files_used: int
74 skipped_count: int
75 stubbed: bool = True
76
77
78 class StorpheusRenderUnavailableError(Exception):
79 """Raised when Storpheus is not reachable and a render is requested.
80
81 The CLI catches this and surfaces a clear human-readable message rather
82 than an unhandled traceback.
83 """
84
85
86 # ---------------------------------------------------------------------------
87 # Internal helpers
88 # ---------------------------------------------------------------------------
89
90 _MIDI_SUFFIXES: frozenset[str] = frozenset({".mid", ".midi"})
91
92
93 def _collect_midi_files(
94 manifest: dict[str, str],
95 root: pathlib.Path,
96 track: Optional[str],
97 section: Optional[str],
98 ) -> tuple[list[pathlib.Path], int]:
99 """Walk the snapshot manifest and return (midi_paths, skipped_count).
100
101 Applies optional ``track`` and ``section`` substring filters before
102 collecting. Files that pass the filter but are absent on disk are
103 counted in ``skipped_count`` and logged at WARNING level.
104
105 Args:
106 manifest: ``{rel_path: object_id}`` snapshot manifest.
107 root: Muse repository root — MIDI files live under ``<root>/muse-work/``.
108 track: Optional case-insensitive track name substring filter.
109 section: Optional case-insensitive section name substring filter.
110
111 Returns:
112 Tuple of (list[pathlib.Path], skipped_count).
113 """
114 workdir = root / "muse-work"
115 midi_paths: list[pathlib.Path] = []
116 skipped = 0
117
118 for rel_path in sorted(manifest.keys()):
119 path_lower = rel_path.lower()
120 if track is not None and track.lower() not in path_lower:
121 skipped += 1
122 continue
123 if section is not None and section.lower() not in path_lower:
124 skipped += 1
125 continue
126 if pathlib.PurePosixPath(rel_path).suffix.lower() not in _MIDI_SUFFIXES:
127 skipped += 1
128 continue
129 src = workdir / rel_path
130 if not src.exists():
131 skipped += 1
132 logger.warning("⚠️ render-preview: MIDI source missing: %s", src)
133 continue
134 midi_paths.append(src)
135
136 return midi_paths, skipped
137
138
139 def _check_storpheus_reachable(storpheus_url: str) -> None:
140 """Probe Storpheus health endpoint; raise StorpheusRenderUnavailableError if down.
141
142 Uses a short (3 s) probe timeout so the CLI fails quickly when Storpheus
143 is not running rather than hanging for the full generation timeout.
144
145 Args:
146 storpheus_url: Base URL for the Storpheus service (e.g. ``http://storpheus:10002``).
147
148 Raises:
149 StorpheusRenderUnavailableError: If the service is unreachable or returns non-200.
150 """
151 probe_timeout = httpx.Timeout(connect=3.0, read=3.0, write=3.0, pool=3.0)
152 try:
153 with httpx.Client(timeout=probe_timeout) as client:
154 resp = client.get(f"{storpheus_url.rstrip('/')}/health")
155 reachable = resp.status_code == 200
156 except Exception as exc:
157 raise StorpheusRenderUnavailableError(
158 f"Storpheus is not reachable at {storpheus_url}: {exc}\n"
159 "Start Storpheus (docker compose up storpheus) and retry."
160 ) from exc
161
162 if not reachable:
163 raise StorpheusRenderUnavailableError(
164 f"Storpheus health check returned non-200 at {storpheus_url}/health.\n"
165 "Check Storpheus logs: docker compose logs storpheus"
166 )
167
168
169 def _render_via_storpheus(
170 midi_path: pathlib.Path,
171 output_path: pathlib.Path,
172 fmt: PreviewFormat,
173 storpheus_url: str,
174 ) -> bool:
175 """Attempt to render *midi_path* to audio via Storpheus ``POST /render``.
176
177 This is a *stub implementation*: the Storpheus ``/render`` endpoint
178 (MIDI-in → audio-out) is not yet deployed. Until it ships the function
179 copies the MIDI file to ``output_path`` as a placeholder and returns
180 ``True`` (stubbed=True).
181
182 When the endpoint is available, implement:
183 POST {storpheus_url}/render
184 Content-Type: multipart/form-data
185 Body: {midi: <bytes>, format: <fmt.value>}
186 → writes response body to output_path, returns False (stubbed=False).
187
188 Args:
189 midi_path: Source MIDI file path.
190 output_path: Destination audio file path.
191 fmt: Target audio format.
192 storpheus_url: Storpheus base URL.
193
194 Returns:
195 True when the output is a MIDI stub (no real audio render occurred).
196 """
197 logger.warning(
198 "⚠️ Storpheus /render endpoint not yet available"
199 "copying MIDI as placeholder for %s",
200 output_path.name,
201 )
202 output_path.parent.mkdir(parents=True, exist_ok=True)
203 shutil.copy2(midi_path, output_path)
204 logger.info(
205 "✅ render-preview stub: %s copied to %s [format=%s]",
206 midi_path.name,
207 output_path,
208 fmt.value,
209 )
210 return True # stubbed
211
212
213 # ---------------------------------------------------------------------------
214 # Public API
215 # ---------------------------------------------------------------------------
216
217
218 def render_preview(
219 manifest: dict[str, str],
220 root: pathlib.Path,
221 commit_id: str,
222 output_path: pathlib.Path,
223 fmt: PreviewFormat = PreviewFormat.WAV,
224 track: Optional[str] = None,
225 section: Optional[str] = None,
226 storpheus_url: str = "http://localhost:10002",
227 ) -> RenderPreviewResult:
228 """Render a commit snapshot to an audio preview file.
229
230 Entry point for the ``muse render-preview`` command. Collects MIDI
231 files from the snapshot, checks that Storpheus is reachable, then
232 delegates to ``_render_via_storpheus`` for the actual audio conversion.
233
234 When the Storpheus ``/render`` endpoint is not yet available, the first
235 matching MIDI file is copied to ``output_path`` as a placeholder and
236 ``RenderPreviewResult.stubbed`` is set to ``True``.
237
238 Args:
239 manifest: ``{rel_path: object_id}`` snapshot manifest from the DB.
240 root: Muse repository root.
241 commit_id: Full commit ID being previewed (used for result metadata).
242 output_path: Destination file path for the rendered audio.
243 fmt: Audio format for the rendered preview (wav / mp3 / flac).
244 track: Optional track name filter (case-insensitive substring).
245 section: Optional section name filter (case-insensitive substring).
246 storpheus_url: Storpheus base URL (overridable in tests).
247
248 Returns:
249 RenderPreviewResult describing what was written.
250
251 Raises:
252 StorpheusRenderUnavailableError: When Storpheus health check fails.
253 ValueError: When no MIDI files are found in the (filtered) snapshot.
254 """
255 midi_paths, skipped = _collect_midi_files(manifest, root, track, section)
256
257 if not midi_paths:
258 raise ValueError(
259 f"No MIDI files found in snapshot for commit {commit_id[:8]}. "
260 "Use --track / --section to widen the filter, or check muse-work/."
261 )
262
263 _check_storpheus_reachable(storpheus_url)
264
265 # Render the first MIDI file. When /render supports multi-track mixing,
266 # pass all midi_paths and merge the output here.
267 primary_midi = midi_paths[0]
268 stubbed = _render_via_storpheus(primary_midi, output_path, fmt, storpheus_url)
269
270 logger.info(
271 "✅ muse render-preview: commit=%s format=%s output=%s midi_files=%d stubbed=%s",
272 commit_id[:8],
273 fmt.value,
274 output_path,
275 len(midi_paths),
276 stubbed,
277 )
278
279 return RenderPreviewResult(
280 output_path=output_path,
281 format=fmt,
282 commit_id=commit_id,
283 midi_files_used=len(midi_paths),
284 skipped_count=skipped,
285 stubbed=stubbed,
286 )