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>
233 lines
9.0 KiB
Python
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}
|