Files
settled-reach/tooling/domains/assets/audio_batch.py
T
jpmschweitzerandClaude Opus 5.5 ddce4441a9 refactor(tooling): T-1290 — the assets domain, where OFF is the normal case
tooling/db/ (a misnamed directory: connectors, not database work),
trellis-batch.sh and synth_ui_sounds.py become `reach assets`:
audio {health,generate,batch,post {convert,normalize,trim,pipeline}},
image {health,generate}, trellis {health,generate,batch}, and synth-ui.
The four audio bash wrappers are retired, and tooling/db/ is gone.

Parity, from baselines taken before anything moved:

- the four UI-sound WAVs and the harmonic-synth WAVs (exponential and linear
  decay) are byte-identical
- the ffmpeg pipeline's decoded PCM is identical. Its .ogg bytes are not,
  even between two runs of the OLD code: Ogg picks a random stream serial,
  so the encoded file was never the right thing to compare
- the network success paths can't be run in a gate (Stable Audio and Trellis
  are kept off, Gemini costs money), so tooling/test_assets.py stands up a
  fake Gradio and pins every payload: the audio submit, Trellis's six-call
  session sequence with its 9-input image_to_3d, and the Gemini body. It
  failed when one Trellis value was mutated (7.5 → 7.0)

Failure classification, in endpoints.py, is the point of the port. The
services are OFF by design (VRAM on tower-of-joy, D-17), and the topology doc
warns against "fixing" one by restarting it. So a refused connection says OFF
and asks for the service to be turned on rather than restarted; a 4xx/5xx says
the request was rejected; 401/403 says credentials; 429 says quota; and an
unreachable Gemini blames the network, not VRAM.

Behaviour changes, each a failure that used to read as success or crash:

- audio batch and trellis batch exited 0 with failures in their summaries;
  they now print the summary and exit 1
- trellis generate on a missing image crashed with a TypeError
  (print(..., indent=2)); it now names the file, and checks it before the
  service so a typo is not reported as an outage
- the ffmpeg pipeline left its intermediates behind when a step failed

Structure: the connectors called each other as subprocesses (batch spawned
the connector, which spawned audio_post) and parsed each other's stdout. They
are now function calls, and ffmpeg is the only exec, through core/process.
ensure_venv() is removed: it os.execv'd into .venv, which D-263's exec rule
forbids, and reach declares the dependencies itself. config.json moved into
the domain deliberately, and the local-services rule follows it.

Output contract: results are still JSON on stdout with the same keys, so skill
readers keep working. Failures are an exit status with a Fix line, never
{"ok": false}. The audio-gen, glb-gen and image-gen skills, Araminta's agent
file and the allow-list are updated to match. glb-gen's "trellis-batch.sh is
hardcoded to one category" caveat is gone: batch takes --input-dir or --names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:49:20 +02:00

233 lines
9.0 KiB
Python

"""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}