muse_release.py
python
| 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 | ) |