Files
settled-reach/tooling/db/audio_batch.py
T
jpmschweitzerandClaude Opus 4.6 bc226d8baf chore(db): move db/connectors/ to tooling/db/ (#274)
Consolidates all connector scripts under tooling/ per project
structure conventions. Symlink at db/connectors → tooling/db/
preserves backwards compatibility (remove after Sprint 22).

Updated references in CLAUDE.md, Makefile, DEVOPS.md, all skill
files, agent files, rules, schema comments, and Sprint 21
briefings. Python scripts updated with correct SCHEMA_PATH
(now relative to WORKTREE_ROOT/db/schema.sql).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:08:00 +01:00

310 lines
10 KiB
Python

#!/usr/bin/env python3
"""Batch audio generation from a manifest file.
Processes multiple assets sequentially: SAO generation or harmonic synthesis,
followed by post-processing (trim, normalize, convert to OGG).
Usage:
python3 audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]
"""
import json
import math
import os
import struct
import subprocess
import sys
import wave
import numpy as np
def load_manifest(path):
with open(path) as f:
return json.load(f)
def resolve_paths(manifest, manifest_dir):
"""Resolve output_dir and gen_dir relative to the git root."""
# Find git root by walking up from manifest_dir
# Check for .git as file (worktree) or directory (regular repo)
git_root = manifest_dir
while git_root != "/":
if os.path.exists(os.path.join(git_root, ".git")):
break
git_root = os.path.dirname(git_root)
else:
git_root = manifest_dir
output_dir = os.path.join(git_root, manifest.get("output_dir", "client/assets/audio"))
gen_dir = os.path.join(git_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, git_root
def get_default(manifest, asset, key):
"""Get a value from the asset, falling back to manifest defaults."""
defaults = manifest.get("defaults", {})
return asset.get(key, defaults.get(key))
def run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir):
"""Generate audio via Stable Audio Open + post-processing."""
filename = asset["filename"]
base_name = os.path.splitext(filename)[0]
wav_path = os.path.join(gen_dir, base_name + ".wav")
ogg_path = os.path.join(output_dir, filename)
prompt = asset["prompt"]
duration = asset.get("duration", 10)
steps = get_default(manifest, asset, "steps") or 100
cfg = get_default(manifest, asset, "cfg") or 7
timeout = get_default(manifest, asset, "timeout") or 600
# Run audio-generate with --post
cmd = [
sys.executable, os.path.join(script_dir, "audio_connector.py"),
"generate", prompt,
"--duration", str(duration),
"--steps", str(steps),
"--cfg", str(cfg),
"--output", wav_path,
"--output-ogg", ogg_path,
"--timeout", str(timeout),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60)
if result.returncode != 0:
stderr = result.stderr.strip()
try:
err = json.loads(result.stdout)
return {"ok": False, "error": err.get("error", stderr)}
except (json.JSONDecodeError, ValueError):
return {"ok": False, "error": stderr or "generation failed"}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"ok": False, "error": f"Unexpected output: {result.stdout[:200]}"}
def synthesize_harmonic(params, wav_path):
"""Synthesize audio from harmonic parameters."""
sr = 44100
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)
# Fundamental
signal = np.sin(2 * np.pi * fundamental * t)
# Add harmonics
for h in harmonics:
freq = h["freq"]
db = h["db"]
amplitude = 10 ** (db / 20)
signal = signal + amplitude * np.sin(2 * np.pi * 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":
# Decay rate: reach -60dB by end of 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:
# Linear decay
decay_env = np.where(t < sustain_end, 1.0,
1.0 - (t - sustain_end) / (duration - sustain_end))
envelope = attack_env * decay_env
signal = signal * envelope
# Normalize to peak
peak = np.max(np.abs(signal))
if peak > 0:
signal = signal / peak * 0.9
# Write WAV
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, manifest, gen_dir, output_dir, script_dir):
"""Synthesize audio from harmonic parameters + post-process."""
filename = asset["filename"]
base_name = os.path.splitext(filename)[0]
wav_path = os.path.join(gen_dir, base_name + "_synth.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"}
synth_type = synth_params.get("type", "harmonic")
if synth_type != "harmonic":
return {"ok": False, "error": f"Unknown synth type: {synth_type}"}
try:
synthesize_harmonic(synth_params, wav_path)
except Exception as e:
return {"ok": False, "error": f"Synthesis failed: {e}"}
# Post-process: normalize + convert (skip trim for synth — no silence to trim)
post_script = os.path.join(script_dir, "audio_post.py")
lufs = get_default(manifest, asset, "lufs") or -16
quality = get_default(manifest, asset, "quality") or 6
# Normalize
norm_path = os.path.join(gen_dir, base_name + "_norm.wav")
cmd = [sys.executable, post_script, "normalize", wav_path, "--output", norm_path,
"--lufs", str(lufs)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"ok": False, "error": f"Normalize failed: {result.stderr.strip()}"}
# Convert to OGG
cmd = [sys.executable, post_script, "convert", norm_path, "--output", ogg_path,
"--quality", str(quality)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return {"ok": False, "error": f"Convert failed: {result.stderr.strip()}"}
# Clean up intermediate
try:
os.remove(norm_path)
except OSError:
pass
ogg_size = os.path.getsize(ogg_path)
return {
"ok": True,
"file": wav_path,
"ogg_file": ogg_path,
"ogg_size_bytes": ogg_size,
"synth_params": synth_params,
"post_processed": True,
}
def main():
if len(sys.argv) < 2:
print("Usage: audio_batch.py manifest.json [--dry-run] [--only ID,ID,...] [--skip-existing]",
file=sys.stderr)
sys.exit(1)
manifest_path = sys.argv[1]
dry_run = "--dry-run" in sys.argv
skip_existing = "--skip-existing" in sys.argv
only_ids = None
for i, arg in enumerate(sys.argv):
if arg == "--only" and i + 1 < len(sys.argv):
only_ids = set(sys.argv[i + 1].split(","))
manifest = load_manifest(manifest_path)
manifest_dir = os.path.dirname(os.path.abspath(manifest_path))
output_dir, gen_dir, git_root = resolve_paths(manifest, manifest_dir)
script_dir = os.path.dirname(os.path.abspath(__file__))
assets = manifest.get("assets", [])
if only_ids:
assets = [a for a in assets if a["id"] in only_ids]
# Health check if any SAO assets
sao_assets = [a for a in assets if a.get("method") == "sao"]
if sao_assets and not dry_run:
print(f"Checking SAO API health...", file=sys.stderr)
health_cmd = [sys.executable, os.path.join(script_dir, "audio_connector.py"), "health"]
result = subprocess.run(health_cmd, capture_output=True, text=True)
if result.returncode != 0:
print(json.dumps({"ok": False, "error": "SAO API health check failed",
"details": result.stdout.strip()}))
sys.exit(1)
print(f" SAO API is up.", file=sys.stderr)
total = len(assets)
results = []
success = 0
failed = 0
skipped = 0
print(f"Processing {total} assets from {os.path.basename(manifest_path)}...", file=sys.stderr)
if dry_run:
print(" (dry run — no generation will occur)", file=sys.stderr)
for i, asset in enumerate(assets, 1):
asset_id = asset["id"]
filename = asset["filename"]
method = asset.get("method", "sao")
print(f"\n[{i}/{total}] {asset_id}: {filename} ({method})", file=sys.stderr)
if skip_existing:
ogg_path = os.path.join(output_dir, filename)
if os.path.exists(ogg_path):
print(f" Skipping — already exists", file=sys.stderr)
results.append({"id": asset_id, "status": "skipped", "reason": "exists"})
skipped += 1
continue
if dry_run:
print(f" Would generate: {filename}", file=sys.stderr)
if method == "sao":
print(f" Prompt: {asset.get('prompt', '(none)')[:80]}...", file=sys.stderr)
elif method == "synth":
synth = asset.get("synth", {})
print(f" Synth: {synth.get('fundamental')}Hz, {synth.get('duration')}s",
file=sys.stderr)
results.append({"id": asset_id, "status": "dry_run"})
continue
if method == "sao":
result = run_sao_generate(asset, manifest, gen_dir, output_dir, script_dir)
elif method == "synth":
result = run_synth(asset, manifest, gen_dir, output_dir, script_dir)
else:
result = {"ok": False, "error": f"Unknown method: {method}"}
result["id"] = asset_id
if result.get("ok"):
success += 1
result["status"] = "success"
print(f" OK → {result.get('ogg_file', filename)}", file=sys.stderr)
else:
failed += 1
result["status"] = "failed"
print(f" FAILED: {result.get('error', 'unknown')}", file=sys.stderr)
results.append(result)
# Summary
summary = {
"ok": failed == 0,
"total": total,
"success": success,
"failed": failed,
"skipped": skipped,
"results": results,
}
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()