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:
Executable
+4
@@ -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 "$@"
|
||||
Executable
+4
@@ -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
|
||||
Executable
+7
@@ -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" "$@"
|
||||
@@ -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()
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user