"""Batch audio generation from a manifest file. Processes assets in order: Stable Audio generation (`method: sao`) or harmonic synthesis (`method: synth`), each followed by post-processing to OGG. Formerly tooling/db/audio_batch.py behind the audio-batch wrapper (T-1290). It used to re-launch audio_connector.py and audio_post.py as subprocesses and parse their stdout; it now calls them. The synthesis itself is unchanged — same parameters give byte-identical WAVs. One behaviour change: a batch with failures used to print `"ok": false` and exit 0; the router now fails it. Manifest schema (docs/assets/audio/, see the audio-gen skill): {"output_dir": ..., "gen_dir": ..., "defaults": {steps, cfg, timeout, lufs, quality}, "assets": [{"id", "filename", "method": "sao"|"synth", "prompt"?, "duration"?, "synth"?: {"type": "harmonic", "duration", "fundamental", "harmonics": [{"freq", "db"}], "attack_ms", "sustain_ratio", "decay": "exponential"|"linear"}}]} """ from __future__ import annotations import json import os import wave import numpy as np from tooling.core import config, console from tooling.core.errors import ReachError from tooling.domains.assets import audio, audio_post SAMPLE_RATE = 44100 def load_manifest(path: str) -> dict: try: with open(path) as f: return json.load(f) except FileNotFoundError as exc: raise ReachError(f"manifest not found: {path}", fix="pass a manifest .json path") from exc except json.JSONDecodeError as exc: raise ReachError(f"manifest is not valid JSON: {path}: {exc}", fix="fix the JSON, then re-run") from exc def resolve_paths(manifest: dict) -> tuple[str, str]: """output_dir and gen_dir, relative to the repo root (created if missing). The old script found the root by walking up from the manifest looking for .git; config.repo_root() asks git, which gives the same answer in a worktree and does not silently fall back to the manifest's own directory. """ root = str(config.repo_root()) output_dir = os.path.join(root, manifest.get("output_dir", "client/assets/audio")) gen_dir = os.path.join(root, manifest.get("gen_dir", "client/assets/audio/gen")) os.makedirs(output_dir, exist_ok=True) os.makedirs(gen_dir, exist_ok=True) return output_dir, gen_dir def get_default(manifest: dict, asset: dict, key: str): """An asset value, falling back to the manifest's defaults.""" return asset.get(key, manifest.get("defaults", {}).get(key)) def run_sao_generate(asset: dict, manifest: dict, gen_dir: str, output_dir: str) -> dict: """Generate via Stable Audio, post-processing straight to the final OGG.""" filename = asset["filename"] base_name = os.path.splitext(filename)[0] return audio.generate( asset["prompt"], duration=asset.get("duration", 10), steps=get_default(manifest, asset, "steps") or 100, cfg=get_default(manifest, asset, "cfg") or 7, output=os.path.join(gen_dir, base_name + ".wav"), timeout=get_default(manifest, asset, "timeout") or 600, output_ogg=os.path.join(output_dir, filename), ) def synthesize_harmonic(params: dict, wav_path: str) -> str: """Synthesize a tone from harmonic parameters and write a 16-bit mono WAV.""" sr = SAMPLE_RATE duration = params["duration"] fundamental = params["fundamental"] harmonics = params.get("harmonics", []) attack_ms = params.get("attack_ms", 10) sustain_ratio = params.get("sustain_ratio", 0.2) decay = params.get("decay", "exponential") n = int(sr * duration) t = np.linspace(0, duration, n, endpoint=False) signal = np.sin(2 * np.pi * fundamental * t) for h in harmonics: amplitude = 10 ** (h["db"] / 20) signal = signal + amplitude * np.sin(2 * np.pi * h["freq"] * t) # Envelope: attack + sustain + decay. attack_s = attack_ms / 1000 attack_env = np.minimum(t / attack_s, 1.0) if attack_s > 0 else np.ones(n) sustain_end = duration * sustain_ratio if decay == "exponential": # Reach -60 dB by the end of the duration. decay_rate = 6.9 / (duration - sustain_end) if duration > sustain_end else 10 decay_env = np.where(t < sustain_end, 1.0, np.exp(-decay_rate * (t - sustain_end))) else: decay_env = np.where(t < sustain_end, 1.0, 1.0 - (t - sustain_end) / (duration - sustain_end)) signal = signal * attack_env * decay_env peak = np.max(np.abs(signal)) if peak > 0: signal = signal / peak * 0.9 int_samples = np.clip(signal * 32767, -32767, 32767).astype(np.int16) with wave.open(wav_path, "w") as f: f.setnchannels(1) f.setsampwidth(2) f.setframerate(sr) f.writeframes(int_samples.tobytes()) return wav_path def run_synth(asset: dict, manifest: dict, gen_dir: str, output_dir: str) -> dict: """Synthesize, then normalize + convert (no trim: synth has no silence to trim).""" filename = asset["filename"] base_name = os.path.splitext(filename)[0] wav_path = os.path.join(gen_dir, base_name + "_synth.wav") norm_path = os.path.join(gen_dir, base_name + "_norm.wav") ogg_path = os.path.join(output_dir, filename) synth_params = asset.get("synth") if not synth_params: return {"ok": False, "error": "No synth parameters provided"} if synth_params.get("type", "harmonic") != "harmonic": return {"ok": False, "error": f"Unknown synth type: {synth_params.get('type')}"} try: synthesize_harmonic(synth_params, wav_path) audio_post.normalize(wav_path, norm_path, lufs=get_default(manifest, asset, "lufs") or -16) audio_post.convert(norm_path, ogg_path, quality=get_default(manifest, asset, "quality") or 6) except ReachError as exc: return {"ok": False, "error": exc.message} finally: if os.path.exists(norm_path): os.remove(norm_path) return { "ok": True, "file": wav_path, "ogg_file": ogg_path, "ogg_size_bytes": os.path.getsize(ogg_path), "synth_params": synth_params, "post_processed": True, } def run( manifest_path: str, *, dry_run: bool = False, only: set[str] | None = None, skip_existing: bool = False, ) -> dict: """Process a manifest; returns the summary. The router fails it on any failure.""" manifest = load_manifest(manifest_path) output_dir, gen_dir = resolve_paths(manifest) assets = manifest.get("assets", []) if only: assets = [a for a in assets if a["id"] in only] # One health check up front if anything needs Stable Audio — so an OFF # service fails once, with the right remedy, not once per asset. if not dry_run and any(a.get("method") == "sao" for a in assets): console.event("Checking Stable Audio health...") audio.health() total = len(assets) results = [] counts = {"success": 0, "failed": 0, "skipped": 0} console.event( f"Processing {total} assets from {os.path.basename(manifest_path)}" + (" — dry run, nothing generated" if dry_run else "") ) for i, asset in enumerate(assets, 1): asset_id, filename = asset["id"], asset["filename"] method = asset.get("method", "sao") phase = f"{i}/{total}" console.event(f"{asset_id}: {filename} ({method})", phase=phase, progress=i / total if total else None) if skip_existing and os.path.exists(os.path.join(output_dir, filename)): console.event("skipping — already exists", phase=phase) results.append({"id": asset_id, "status": "skipped", "reason": "exists"}) counts["skipped"] += 1 continue if dry_run: if method == "sao": console.event(f"would generate — prompt: {asset.get('prompt', '(none)')[:80]}...", phase=phase) elif method == "synth": synth = asset.get("synth", {}) console.event( f"would synthesize — {synth.get('fundamental')}Hz, {synth.get('duration')}s", phase=phase ) results.append({"id": asset_id, "status": "dry_run"}) continue if method == "sao": try: result = run_sao_generate(asset, manifest, gen_dir, output_dir) except ReachError as exc: result = {"ok": False, "error": exc.message} elif method == "synth": result = run_synth(asset, manifest, gen_dir, output_dir) else: result = {"ok": False, "error": f"Unknown method: {method}"} result["id"] = asset_id if result.get("ok"): counts["success"] += 1 result["status"] = "success" console.event(f"OK → {result.get('ogg_file', filename)}", phase=phase) else: counts["failed"] += 1 result["status"] = "failed" console.event(f"FAILED: {result.get('error', 'unknown')}", phase=phase, level="warn") results.append(result) return {"ok": counts["failed"] == 0, "total": total, **counts, "results": results}