diff --git a/db/connectors/audio-generate b/db/connectors/audio-generate new file mode 100755 index 000000000..9e85b8b7c --- /dev/null +++ b/db/connectors/audio-generate @@ -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 "$@" diff --git a/db/connectors/audio-health b/db/connectors/audio-health new file mode 100755 index 000000000..112affff7 --- /dev/null +++ b/db/connectors/audio-health @@ -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 diff --git a/db/connectors/audio-post b/db/connectors/audio-post new file mode 100755 index 000000000..3131fcfd5 --- /dev/null +++ b/db/connectors/audio-post @@ -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" "$@" diff --git a/db/connectors/audio_connector.py b/db/connectors/audio_connector.py new file mode 100644 index 000000000..1323a8e70 --- /dev/null +++ b/db/connectors/audio_connector.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() diff --git a/db/connectors/audio_post.py b/db/connectors/audio_post.py new file mode 100644 index 000000000..dcee95c62 --- /dev/null +++ b/db/connectors/audio_post.py @@ -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() diff --git a/db/connectors/config.json b/db/connectors/config.json index d8386e4c2..1b69b0a7b 100644 --- a/db/connectors/config.json +++ b/db/connectors/config.json @@ -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