feat(db): add Stable Audio Open connector and post-processing wrappers

audio-generate: Gradio API wrapper with timeout handling (600s default),
sequential-only generation, SSE stream polling. audio-health: API
health check. audio-post: ffmpeg wrapper for convert/normalize/trim/
pipeline operations. All follow existing connector pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:03:30 +01:00
co-authored by Claude Opus 4.6
parent 34d0e7211c
commit 094939a33d
6 changed files with 477 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Generate audio via Stable Audio Open. Whitelistable command.
# Usage: audio-generate "prompt text" [--duration N] [--steps N] [--cfg N] [--output file.wav] [--timeout N]
exec python3 "$(dirname "$0")/audio_connector.py" generate "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Check if the Stable Audio Open API is reachable. Whitelistable command.
# Usage: audio-health
exec python3 "$(dirname "$0")/audio_connector.py" health
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Audio post-processing (ffmpeg wrapper). Whitelistable command.
# Usage: audio-post convert input.wav [--output output.ogg]
# audio-post normalize input.wav [--lufs -16]
# audio-post trim input.wav [--threshold -50]
# audio-post pipeline input.wav [--output output.ogg]
exec python3 "$(dirname "$0")/audio_post.py" "$@"
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
Stable Audio Open connector — Gradio API wrapper.
Talks to the Stable Audio Open Gradio app at tower-of-joy:11500.
Uses the async Gradio API pattern: POST to submit, SSE stream for results.
Usage:
python3 audio_connector.py generate "prompt text" [--duration 10] [--steps 100] [--cfg 7] [--output file.wav]
python3 audio_connector.py health
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
import shutil
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
def get_base_url():
config = load_config()
return config.get("stable_audio_url", "http://tower-of-joy:11500")
def health():
"""Check if the Stable Audio API is reachable."""
base = get_base_url()
try:
req = urllib.request.Request(f"{base}/config", method="GET")
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
version = data.get("version", "unknown")
# Extract component info for the generate endpoint
api_names = []
for dep in data.get("dependencies", []):
name = dep.get("api_name", "")
if name and not name.startswith("js_"):
api_names.append(name)
print(json.dumps({
"ok": True,
"url": base,
"gradio_version": version,
"api_endpoints": api_names
}, indent=2))
except Exception as e:
print(json.dumps({
"ok": False,
"url": base,
"error": str(e)
}, indent=2))
sys.exit(1)
def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600):
"""
Generate audio from a text prompt.
Args:
prompt: Text description of the audio to generate
duration: Duration in seconds (0-47, default 10)
steps: Number of diffusion steps (default 100, lower = faster but lower quality)
cfg: Classifier-free guidance scale (default 7)
output: Output file path (default: auto-named in current directory)
timeout: Maximum wait time in seconds (default 600 = 10 minutes)
"""
base = get_base_url()
api_url = f"{base}/gradio_api/call/generate_audio"
if output is None:
# Auto-name: sanitize prompt to a filename
safe = "".join(c if c.isalnum() or c in "-_ " else "" for c in prompt[:40])
safe = safe.strip().replace(" ", "_").lower()
output = f"{safe}_{int(duration)}s.wav"
# Step 1: Submit the generation request
payload = json.dumps({"data": [prompt, duration, steps, cfg]})
req = urllib.request.Request(
api_url,
data=payload.encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
print(f"Submitting generation request...", file=sys.stderr)
print(f" Prompt: {prompt}", file=sys.stderr)
print(f" Duration: {duration}s, Steps: {steps}, CFG: {cfg}", file=sys.stderr)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
event_id = result.get("event_id")
if not event_id:
print(json.dumps({"ok": False, "error": "No event_id returned", "response": result}, indent=2))
sys.exit(1)
except Exception as e:
print(json.dumps({"ok": False, "error": f"Submit failed: {e}"}, indent=2))
sys.exit(1)
print(f" Event ID: {event_id}", file=sys.stderr)
print(f" Waiting for generation (timeout: {timeout}s)...", file=sys.stderr)
# Step 2: Poll the SSE stream for results
stream_url = f"{api_url}/{event_id}"
start_time = time.time()
result_data = None
last_status = None
while time.time() - start_time < timeout:
try:
req = urllib.request.Request(stream_url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
# Read SSE events
current_event = None
for line_bytes in resp:
line = line_bytes.decode("utf-8").strip()
if line.startswith("event: "):
current_event = line[7:]
elif line.startswith("data: ") and current_event:
data_str = line[6:]
if current_event == "heartbeat":
elapsed = int(time.time() - start_time)
if elapsed % 30 == 0 and elapsed > 0:
print(f" Still generating... ({elapsed}s elapsed)", file=sys.stderr)
continue
if current_event == "error":
error_msg = data_str
try:
error_msg = json.loads(data_str)
except (json.JSONDecodeError, TypeError):
pass
print(json.dumps({"ok": False, "error": "Generation failed", "details": error_msg}, indent=2))
sys.exit(1)
if current_event == "complete":
try:
result_data = json.loads(data_str)
except json.JSONDecodeError:
result_data = data_str
break
if current_event == "progress":
try:
progress = json.loads(data_str)
# Gradio progress events vary; log what we get
status = str(progress)[:80]
if status != last_status:
print(f" Progress: {status}", file=sys.stderr)
last_status = status
except (json.JSONDecodeError, TypeError):
pass
continue
if result_data is not None:
break
except urllib.error.URLError as e:
# Connection dropped — retry after brief pause
time.sleep(2)
continue
except Exception as e:
print(json.dumps({"ok": False, "error": f"Stream error: {e}"}, indent=2))
sys.exit(1)
if result_data is None:
print(json.dumps({"ok": False, "error": f"Generation timed out after {timeout}s"}, indent=2))
sys.exit(1)
# Step 3: Download the audio file
# Gradio returns file info in the data array
elapsed = round(time.time() - start_time, 1)
print(f" Generation complete ({elapsed}s)", file=sys.stderr)
try:
# result_data is typically [{"path": "...", "url": "...", ...}] or similar
if isinstance(result_data, list) and len(result_data) > 0:
audio_info = result_data[0]
elif isinstance(result_data, dict) and "data" in result_data:
audio_info = result_data["data"][0] if result_data["data"] else None
else:
audio_info = result_data
# Extract the file URL
file_url = None
if isinstance(audio_info, dict):
file_url = audio_info.get("url") or audio_info.get("path")
elif isinstance(audio_info, str):
file_url = audio_info
if not file_url:
print(json.dumps({
"ok": False,
"error": "Could not extract audio URL from response",
"response": result_data
}, indent=2))
sys.exit(1)
# Handle relative URLs
if file_url.startswith("/"):
file_url = f"{base}{file_url}"
elif not file_url.startswith("http"):
file_url = f"{base}/file={file_url}"
# Download the file
print(f" Downloading to {output}...", file=sys.stderr)
req = urllib.request.Request(file_url, method="GET")
with urllib.request.urlopen(req, timeout=60) as resp:
with open(output, "wb") as f:
shutil.copyfileobj(resp, f)
file_size = os.path.getsize(output)
print(json.dumps({
"ok": True,
"file": output,
"size_bytes": file_size,
"duration_requested": duration,
"steps": steps,
"cfg": cfg,
"prompt": prompt,
"generation_time_s": elapsed
}, indent=2))
except Exception as e:
print(json.dumps({
"ok": False,
"error": f"Download failed: {e}",
"response": result_data
}, indent=2))
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("Usage:")
print(" audio_connector.py health")
print(" audio_connector.py generate 'prompt' [--duration N] [--steps N] [--cfg N] [--output file.wav] [--timeout N]")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "health":
health()
elif cmd == "generate":
if len(sys.argv) < 3:
print("Error: prompt required", file=sys.stderr)
print("Usage: audio_connector.py generate 'prompt' [--duration N] [--steps N] [--cfg N] [--output file.wav]", file=sys.stderr)
sys.exit(1)
prompt = sys.argv[2]
duration = 10.0
steps = 100
cfg = 7.0
output = None
timeout = 600
# Parse optional args
i = 3
while i < len(sys.argv):
if sys.argv[i] == "--duration" and i + 1 < len(sys.argv):
duration = float(sys.argv[i + 1])
i += 2
elif sys.argv[i] == "--steps" and i + 1 < len(sys.argv):
steps = int(sys.argv[i + 1])
i += 2
elif sys.argv[i] == "--cfg" and i + 1 < len(sys.argv):
cfg = float(sys.argv[i + 1])
i += 2
elif sys.argv[i] == "--output" and i + 1 < len(sys.argv):
output = sys.argv[i + 1]
i += 2
elif sys.argv[i] == "--timeout" and i + 1 < len(sys.argv):
timeout = int(sys.argv[i + 1])
i += 2
else:
print(f"Unknown argument: {sys.argv[i]}", file=sys.stderr)
sys.exit(1)
generate(prompt, duration=duration, steps=steps, cfg=cfg, output=output, timeout=timeout)
else:
print(f"Unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Audio post-processing wrapper around ffmpeg.
Subcommands:
convert — WAV to OGG (libvorbis, quality 6)
normalize — LUFS normalize to -16 LUFS (broadcast standard)
trim — Remove leading/trailing silence
pipeline — trim + normalize + convert (full post-processing chain)
All operations write to a new file (never overwrites input).
"""
import argparse
import os
import subprocess
import sys
import shutil
import json
def check_ffmpeg():
if not shutil.which("ffmpeg"):
print(json.dumps({"ok": False, "error": "ffmpeg not found in PATH"}))
sys.exit(1)
def run_ffmpeg(args, description):
"""Run ffmpeg, capture output, return success."""
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args
print(f" {description}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(json.dumps({
"ok": False,
"error": f"ffmpeg failed: {result.stderr.strip()}",
"command": " ".join(cmd)
}))
sys.exit(1)
return True
def cmd_convert(args):
"""Convert WAV to OGG (libvorbis)."""
output = args.output or args.input.rsplit(".", 1)[0] + ".ogg"
run_ffmpeg(
["-i", args.input, "-c:a", "libvorbis", "-q:a", str(args.quality), output],
f"converting {os.path.basename(args.input)}{os.path.basename(output)}"
)
size = os.path.getsize(output)
print(json.dumps({"ok": True, "output": output, "size_bytes": size}))
def cmd_normalize(args):
"""LUFS normalize audio file."""
output = args.output or _suffixed(args.input, "_norm")
run_ffmpeg(
["-i", args.input, "-af",
f"loudnorm=I={args.lufs}:LRA=11:TP=-1",
output],
f"normalizing to {args.lufs} LUFS"
)
print(json.dumps({"ok": True, "output": output}))
def cmd_trim(args):
"""Trim leading/trailing silence."""
output = args.output or _suffixed(args.input, "_trimmed")
af = (
"silenceremove=start_periods=1:start_silence=0.05"
f":start_threshold={args.threshold}dB,"
"areverse,"
"silenceremove=start_periods=1:start_silence=0.05"
f":start_threshold={args.threshold}dB,"
"areverse"
)
run_ffmpeg(
["-i", args.input, "-af", af, output],
f"trimming silence (threshold: {args.threshold}dB)"
)
print(json.dumps({"ok": True, "output": output}))
def cmd_pipeline(args):
"""Full post-processing: trim → normalize → convert to OGG."""
base = args.input.rsplit(".", 1)[0]
trimmed = base + "_trimmed.wav"
normalized = base + "_norm.wav"
output = args.output or base + ".ogg"
# Step 1: trim
af_trim = (
"silenceremove=start_periods=1:start_silence=0.05"
f":start_threshold={args.threshold}dB,"
"areverse,"
"silenceremove=start_periods=1:start_silence=0.05"
f":start_threshold={args.threshold}dB,"
"areverse"
)
run_ffmpeg(
["-i", args.input, "-af", af_trim, trimmed],
"step 1/3: trimming silence"
)
# Step 2: normalize
run_ffmpeg(
["-i", trimmed, "-af",
f"loudnorm=I={args.lufs}:LRA=11:TP=-1",
normalized],
f"step 2/3: normalizing to {args.lufs} LUFS"
)
# Step 3: convert
run_ffmpeg(
["-i", normalized, "-c:a", "libvorbis", "-q:a", str(args.quality), output],
f"step 3/3: converting to OGG"
)
# Clean up intermediates
os.remove(trimmed)
os.remove(normalized)
size = os.path.getsize(output)
print(json.dumps({"ok": True, "output": output, "size_bytes": size}))
def _suffixed(path, suffix):
base, ext = os.path.splitext(path)
return base + suffix + ext
def main():
parser = argparse.ArgumentParser(description="Audio post-processing (ffmpeg wrapper)")
sub = parser.add_subparsers(dest="command", required=True)
# convert
p = sub.add_parser("convert", help="WAV → OGG")
p.add_argument("input", help="Input WAV file")
p.add_argument("--output", "-o", help="Output file (default: same name .ogg)")
p.add_argument("--quality", "-q", type=int, default=6, help="Vorbis quality 0-10 (default: 6)")
p.set_defaults(func=cmd_convert)
# normalize
p = sub.add_parser("normalize", help="LUFS normalize")
p.add_argument("input", help="Input audio file")
p.add_argument("--output", "-o", help="Output file")
p.add_argument("--lufs", type=float, default=-16, help="Target LUFS (default: -16)")
p.set_defaults(func=cmd_normalize)
# trim
p = sub.add_parser("trim", help="Trim silence")
p.add_argument("input", help="Input audio file")
p.add_argument("--output", "-o", help="Output file")
p.add_argument("--threshold", type=int, default=-50, help="Silence threshold in dB (default: -50)")
p.set_defaults(func=cmd_trim)
# pipeline
p = sub.add_parser("pipeline", help="Full post-processing: trim + normalize + convert")
p.add_argument("input", help="Input WAV file")
p.add_argument("--output", "-o", help="Output OGG file")
p.add_argument("--quality", "-q", type=int, default=6, help="Vorbis quality 0-10 (default: 6)")
p.add_argument("--lufs", type=float, default=-16, help="Target LUFS (default: -16)")
p.add_argument("--threshold", type=int, default=-50, help="Silence threshold in dB (default: -50)")
p.set_defaults(func=cmd_pipeline)
args = parser.parse_args()
check_ffmpeg()
args.func(args)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"qdrant_url": "http://tower-of-joy:6333",
"ollama_url": "http://tower-of-joy:11434",
"stable_audio_url": "http://tower-of-joy:11500",
"collection": "commonwealth",
"embed_model": "nomic-embed-text",
"embed_dimensions": 768