cgcardona / muse public
muse_release.py python
486 lines 16.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Release Service — export a tagged commit as distribution-ready artifacts.
2
3 This service is the music-native publish step: given a tag (applied via
4 ``muse tag add``) it resolves the tagged commit, fetches its MIDI snapshot,
5 and renders it to audio/MIDI artifacts for distribution.
6
7 Boundary contract:
8 - Input: tag string, snapshot manifest, output directory, release options.
9 - Output: ``ReleaseResult`` — paths written, manifest JSON path, format,
10 commit short ID, and a ``stubbed`` flag when Storpheus audio render
11 is not yet available.
12 - Side effects: Writes files under ``output_dir``. Never modifies the Muse
13 repository or the database.
14
15 Output layout::
16
17 <output_dir>/
18 release-manifest.json # always written; includes SHA-256 checksums
19 audio/<commit8>.<format> # --render-audio
20 midi/<stem>.mid (zipped) # --render-midi → midi-bundle.zip
21 stems/<stem>.<format> # --export-stems (one file per track)
22
23 Storpheus render status:
24 The Storpheus service exposes MIDI generation at ``POST /generate``.
25 A dedicated ``POST /render`` endpoint (MIDI-in → audio-out) is planned but
26 not yet deployed. Until that endpoint ships this module performs a health
27 check, then copies the first MIDI file to the audio output path as a stub.
28 When ``/render`` is available, replace ``_render_midi_to_audio`` with a
29 real POST call and set ``stubbed=False`` on the result.
30 """
31 from __future__ import annotations
32
33 import hashlib
34 import json
35 import logging
36 import pathlib
37 import shutil
38 import zipfile
39 from dataclasses import dataclass, field
40 from datetime import datetime, timezone
41 from enum import Enum
42 from typing import Optional
43
44 import httpx
45
46 logger = logging.getLogger(__name__)
47
48
49 # ---------------------------------------------------------------------------
50 # Public types
51 # ---------------------------------------------------------------------------
52
53
54 class ReleaseAudioFormat(str, Enum):
55 """Audio output format for release artifacts."""
56
57 WAV = "wav"
58 MP3 = "mp3"
59 FLAC = "flac"
60
61
62 @dataclass(frozen=True)
63 class ReleaseArtifact:
64 """A single file produced during a release operation.
65
66 Attributes:
67 path: Absolute path of the written file.
68 sha256: SHA-256 hex digest of the file contents.
69 size_bytes: File size in bytes.
70 role: Human-readable role label (e.g. ``"audio"``, ``"midi-bundle"``,
71 ``"stem"``, ``"manifest"``).
72 """
73
74 path: pathlib.Path
75 sha256: str
76 size_bytes: int
77 role: str
78
79
80 @dataclass
81 class ReleaseResult:
82 """Result of a ``muse release`` operation.
83
84 Attributes:
85 tag: The release tag string (e.g. ``"v1.0"``).
86 commit_id: Full commit ID of the released snapshot.
87 output_dir: Root directory where all artifacts were written.
88 manifest_path: Path to the ``release-manifest.json`` file.
89 artifacts: All files produced (audio, MIDI bundle, stems, manifest).
90 audio_format: Audio format used for rendered files.
91 stubbed: True when the Storpheus ``/render`` endpoint is not yet
92 available and MIDI was copied as an audio placeholder.
93 """
94
95 tag: str
96 commit_id: str
97 output_dir: pathlib.Path
98 manifest_path: pathlib.Path
99 artifacts: list[ReleaseArtifact] = field(default_factory=list)
100 audio_format: ReleaseAudioFormat = ReleaseAudioFormat.WAV
101 stubbed: bool = True
102
103
104 class StorpheusReleaseUnavailableError(Exception):
105 """Raised when Storpheus is not reachable and audio rendering is requested.
106
107 The CLI catches this and surfaces a clear human-readable message rather
108 than an unhandled traceback.
109 """
110
111
112 # ---------------------------------------------------------------------------
113 # Internal helpers
114 # ---------------------------------------------------------------------------
115
116
117 _MIDI_SUFFIXES: frozenset[str] = frozenset({".mid", ".midi"})
118
119
120 def _collect_midi_paths(
121 manifest: dict[str, str],
122 root: pathlib.Path,
123 track: Optional[str] = None,
124 section: Optional[str] = None,
125 ) -> tuple[list[pathlib.Path], int]:
126 """Collect MIDI file paths from the snapshot manifest.
127
128 Applies optional track/section substring filters. Missing files are
129 counted in the skipped total and logged at WARNING level.
130
131 Args:
132 manifest: ``{rel_path: object_id}`` snapshot manifest.
133 root: Muse repository root; MIDI files live under ``<root>/muse-work/``.
134 track: Optional case-insensitive track name substring filter.
135 section: Optional case-insensitive section name substring filter.
136
137 Returns:
138 Tuple of (list[pathlib.Path], skipped_count).
139 """
140 workdir = root / "muse-work"
141 midi_paths: list[pathlib.Path] = []
142 skipped = 0
143
144 for rel_path in sorted(manifest.keys()):
145 path_lower = rel_path.lower()
146 if track is not None and track.lower() not in path_lower:
147 skipped += 1
148 continue
149 if section is not None and section.lower() not in path_lower:
150 skipped += 1
151 continue
152 if pathlib.PurePosixPath(rel_path).suffix.lower() not in _MIDI_SUFFIXES:
153 skipped += 1
154 continue
155 src = workdir / rel_path
156 if not src.exists():
157 skipped += 1
158 logger.warning("⚠️ release: MIDI source missing: %s", src)
159 continue
160 midi_paths.append(src)
161
162 return midi_paths, skipped
163
164
165 def _sha256_file(path: pathlib.Path) -> str:
166 """Compute the SHA-256 hex digest of *path*.
167
168 Reads the file in 64 KiB chunks to avoid loading large audio files into
169 memory at once.
170
171 Args:
172 path: File to hash.
173
174 Returns:
175 Lowercase hex digest string (64 chars).
176 """
177 h = hashlib.sha256()
178 with path.open("rb") as fh:
179 for chunk in iter(lambda: fh.read(65536), b""):
180 h.update(chunk)
181 return h.hexdigest()
182
183
184 def _make_artifact(path: pathlib.Path, role: str) -> ReleaseArtifact:
185 """Build a ``ReleaseArtifact`` for a file that was just written.
186
187 Args:
188 path: Absolute path of the written file (must exist).
189 role: Role label for the artifact.
190
191 Returns:
192 ReleaseArtifact with SHA-256 checksum and size.
193 """
194 return ReleaseArtifact(
195 path=path,
196 sha256=_sha256_file(path),
197 size_bytes=path.stat().st_size,
198 role=role,
199 )
200
201
202 def _check_storpheus_reachable(storpheus_url: str) -> None:
203 """Probe Storpheus health endpoint; raise ``StorpheusReleaseUnavailableError`` if down.
204
205 Uses a 3-second probe timeout so the CLI fails fast when Storpheus is not
206 running rather than hanging for the full generation timeout.
207
208 Args:
209 storpheus_url: Base URL for the Storpheus service.
210
211 Raises:
212 StorpheusReleaseUnavailableError: When the service is unreachable.
213 """
214 probe_timeout = httpx.Timeout(connect=3.0, read=3.0, write=3.0, pool=3.0)
215 try:
216 with httpx.Client(timeout=probe_timeout) as client:
217 resp = client.get(f"{storpheus_url.rstrip('/')}/health")
218 reachable = resp.status_code == 200
219 except Exception as exc:
220 raise StorpheusReleaseUnavailableError(
221 f"Storpheus is not reachable at {storpheus_url}: {exc}\n"
222 "Start Storpheus (docker compose up storpheus) and retry."
223 ) from exc
224
225 if not reachable:
226 raise StorpheusReleaseUnavailableError(
227 f"Storpheus health check returned non-200 at {storpheus_url}/health.\n"
228 "Check Storpheus logs: docker compose logs storpheus"
229 )
230
231
232 def _render_midi_to_audio(
233 midi_path: pathlib.Path,
234 output_path: pathlib.Path,
235 fmt: ReleaseAudioFormat,
236 storpheus_url: str,
237 ) -> bool:
238 """Render a MIDI file to audio via Storpheus ``POST /render``.
239
240 This is a *stub implementation*: the Storpheus ``/render`` endpoint
241 (MIDI-in → audio-out) is not yet deployed. Until it ships the function
242 copies the MIDI file to ``output_path`` as a placeholder and returns
243 ``True`` (stubbed=True).
244
245 When the endpoint is available, implement::
246
247 POST {storpheus_url}/render
248 Content-Type: multipart/form-data
249 Body: {midi: <bytes>, format: <fmt.value>}
250 → writes response body to output_path, returns False (stubbed=False).
251
252 Args:
253 midi_path: Source MIDI file path.
254 output_path: Destination audio file path.
255 fmt: Target audio format.
256 storpheus_url: Storpheus base URL.
257
258 Returns:
259 True when the output is a MIDI stub (no real audio render occurred).
260 """
261 logger.warning(
262 "⚠️ Storpheus /render endpoint not yet available"
263 "copying MIDI as placeholder for %s",
264 output_path.name,
265 )
266 output_path.parent.mkdir(parents=True, exist_ok=True)
267 shutil.copy2(midi_path, output_path)
268 logger.info(
269 "✅ release stub: %s copied to %s [format=%s]",
270 midi_path.name,
271 output_path,
272 fmt.value,
273 )
274 return True
275
276
277 def _write_release_manifest(
278 output_dir: pathlib.Path,
279 tag: str,
280 commit_id: str,
281 audio_format: ReleaseAudioFormat,
282 artifacts: list[ReleaseArtifact],
283 stubbed: bool,
284 ) -> pathlib.Path:
285 """Write the ``release-manifest.json`` to *output_dir*.
286
287 The manifest is the authoritative index of everything produced by
288 ``muse release``. It is always the last artifact written so that its
289 presence signals a complete, consistent release directory.
290
291 Manifest shape::
292
293 {
294 "tag": "v1.0",
295 "commit_id": "<full sha>",
296 "commit_short": "<8-char>",
297 "released_at": "<ISO-8601 UTC>",
298 "audio_format": "wav",
299 "stubbed": false,
300 "files": [
301 {"path": "audio/abc123.wav", "sha256": "...", "size_bytes": ..., "role": "audio"},
302 ...
303 ]
304 }
305
306 Args:
307 output_dir: Root release directory.
308 tag: Release tag string.
309 commit_id: Full commit ID.
310 audio_format: Audio format used for rendered files.
311 artifacts: All non-manifest artifacts already written.
312 stubbed: Whether audio renders are stub copies.
313
314 Returns:
315 Path of the written manifest file.
316 """
317 manifest_path = output_dir / "release-manifest.json"
318 files_list = [
319 {
320 "path": str(a.path.relative_to(output_dir)),
321 "sha256": a.sha256,
322 "size_bytes": a.size_bytes,
323 "role": a.role,
324 }
325 for a in artifacts
326 ]
327 payload: dict[str, object] = {
328 "tag": tag,
329 "commit_id": commit_id,
330 "commit_short": commit_id[:8],
331 "released_at": datetime.now(timezone.utc).isoformat(),
332 "audio_format": audio_format.value,
333 "stubbed": stubbed,
334 "files": files_list,
335 }
336 output_dir.mkdir(parents=True, exist_ok=True)
337 manifest_path.write_text(json.dumps(payload, indent=2))
338 logger.info("✅ release: manifest written to %s", manifest_path)
339 return manifest_path
340
341
342 # ---------------------------------------------------------------------------
343 # Public API
344 # ---------------------------------------------------------------------------
345
346
347 def build_release(
348 *,
349 tag: str,
350 commit_id: str,
351 manifest: dict[str, str],
352 root: pathlib.Path,
353 output_dir: pathlib.Path,
354 audio_format: ReleaseAudioFormat = ReleaseAudioFormat.WAV,
355 render_audio: bool = False,
356 render_midi: bool = False,
357 export_stems: bool = False,
358 storpheus_url: str = "http://localhost:10002",
359 ) -> ReleaseResult:
360 """Build a release artifact bundle from a tagged Muse commit snapshot.
361
362 Entry point for the ``muse release`` command. Depending on the flags
363 passed it:
364
365 - Renders the primary MIDI file to audio via Storpheus (``render_audio``).
366 - Bundles all MIDI files into a zip archive (``render_midi``).
367 - Exports each MIDI file as a separate audio stem (``export_stems``).
368 - Always writes a ``release-manifest.json`` with SHA-256 checksums.
369
370 At least one of ``render_audio``, ``render_midi``, or ``export_stems``
371 must be True; otherwise only the manifest is written (which is useful
372 for dry-run validation but not an interesting release).
373
374 Args:
375 tag: Release tag string (e.g. ``"v1.0"``).
376 commit_id: Full commit ID of the snapshot to release.
377 manifest: ``{rel_path: object_id}`` snapshot manifest.
378 root: Muse repository root.
379 output_dir: Destination directory for all artifacts.
380 audio_format: Audio format for rendered files (wav / mp3 / flac).
381 render_audio: Render primary MIDI to a single audio file.
382 render_midi: Bundle all MIDI files into a zip archive.
383 export_stems: Export each MIDI track as a separate audio file.
384 storpheus_url: Storpheus base URL (overridable in tests).
385
386 Returns:
387 ReleaseResult describing everything that was written.
388
389 Raises:
390 StorpheusReleaseUnavailableError: When audio render is requested and
391 Storpheus health check fails.
392 ValueError: When no MIDI files are found in the snapshot.
393 """
394 midi_paths, _skipped = _collect_midi_paths(manifest, root)
395
396 if not midi_paths:
397 raise ValueError(
398 f"No MIDI files found in snapshot for commit {commit_id[:8]}. "
399 "Check muse-work/ for MIDI content before releasing."
400 )
401
402 needs_storpheus = render_audio or export_stems
403 if needs_storpheus:
404 _check_storpheus_reachable(storpheus_url)
405
406 output_dir.mkdir(parents=True, exist_ok=True)
407 artifacts: list[ReleaseArtifact] = []
408 any_stubbed = False
409
410 # --- Render audio: primary MIDI → single audio file ---
411 if render_audio:
412 audio_dir = output_dir / "audio"
413 audio_dir.mkdir(parents=True, exist_ok=True)
414 primary_midi = midi_paths[0]
415 audio_out = audio_dir / f"{commit_id[:8]}.{audio_format.value}"
416 stubbed = _render_midi_to_audio(primary_midi, audio_out, audio_format, storpheus_url)
417 if stubbed:
418 any_stubbed = True
419 artifacts.append(_make_artifact(audio_out, "audio"))
420 logger.info(
421 "✅ release: audio rendered %s → %s [stubbed=%s]",
422 primary_midi.name,
423 audio_out,
424 stubbed,
425 )
426
427 # --- Render MIDI bundle: all MIDI files → zip archive ---
428 if render_midi:
429 midi_dir = output_dir / "midi"
430 midi_dir.mkdir(parents=True, exist_ok=True)
431 bundle_path = midi_dir / "midi-bundle.zip"
432 with zipfile.ZipFile(bundle_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
433 for midi_path in midi_paths:
434 zf.write(midi_path, arcname=midi_path.name)
435 artifacts.append(_make_artifact(bundle_path, "midi-bundle"))
436 logger.info(
437 "✅ release: MIDI bundle written to %s (%d files)", bundle_path, len(midi_paths)
438 )
439
440 # --- Export stems: each MIDI file → separate audio file ---
441 if export_stems:
442 stems_dir = output_dir / "stems"
443 stems_dir.mkdir(parents=True, exist_ok=True)
444 for midi_path in midi_paths:
445 stem_out = stems_dir / f"{midi_path.stem}.{audio_format.value}"
446 stubbed = _render_midi_to_audio(midi_path, stem_out, audio_format, storpheus_url)
447 if stubbed:
448 any_stubbed = True
449 artifacts.append(_make_artifact(stem_out, "stem"))
450 logger.info(
451 "✅ release: stem exported %s → %s [stubbed=%s]",
452 midi_path.name,
453 stem_out,
454 stubbed,
455 )
456
457 # Always write manifest last — its presence signals a complete release.
458 manifest_path = _write_release_manifest(
459 output_dir=output_dir,
460 tag=tag,
461 commit_id=commit_id,
462 audio_format=audio_format,
463 artifacts=artifacts,
464 stubbed=any_stubbed,
465 )
466 manifest_artifact = _make_artifact(manifest_path, "manifest")
467 artifacts.append(manifest_artifact)
468
469 logger.info(
470 "✅ muse release: tag=%r commit=%s output_dir=%s artifacts=%d stubbed=%s",
471 tag,
472 commit_id[:8],
473 output_dir,
474 len(artifacts),
475 any_stubbed,
476 )
477
478 return ReleaseResult(
479 tag=tag,
480 commit_id=commit_id,
481 output_dir=output_dir,
482 manifest_path=manifest_path,
483 artifacts=artifacts,
484 audio_format=audio_format,
485 stubbed=any_stubbed,
486 )