Simplified image-gen SKILL.md, removed obsolete DDS/style references (replaced by Gemini API connector). Updated audio_connector, gitignore, and CLAUDE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
355 lines
13 KiB
Python
355 lines
13 KiB
Python
#!/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] [--post]
|
|
python3 audio_connector.py health
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
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 post_process(wav_path, ogg_path=None, lufs=-16, quality=6, threshold=-50):
|
|
"""Run trim + normalize + convert on a WAV file via audio-post pipeline."""
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
post_script = os.path.join(script_dir, "audio_post.py")
|
|
if ogg_path is None:
|
|
ogg_path = os.path.splitext(wav_path)[0] + ".ogg"
|
|
cmd = [
|
|
sys.executable, post_script, "pipeline", wav_path,
|
|
"--output", ogg_path,
|
|
"--lufs", str(lufs),
|
|
"--quality", str(quality),
|
|
"--threshold", str(threshold),
|
|
]
|
|
print(f" Post-processing → {os.path.basename(ogg_path)}...", file=sys.stderr)
|
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
return {"ok": False, "error": f"Post-processing failed: {result.stderr.strip()}"}
|
|
try:
|
|
return json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
return {"ok": True, "output": ogg_path}
|
|
|
|
|
|
def _check_available(base):
|
|
"""Quick check if Stable Audio is reachable. Fail fast with a clear message."""
|
|
try:
|
|
req = urllib.request.Request(f"{base}/config", method="GET")
|
|
urllib.request.urlopen(req, timeout=5)
|
|
except Exception:
|
|
print(json.dumps({
|
|
"ok": False,
|
|
"error": f"Stable Audio is not available at {base}. The service may be switched off to save system resources. Start it before generating audio."
|
|
}, indent=2))
|
|
sys.exit(1)
|
|
|
|
|
|
def generate(prompt, duration=10.0, steps=100, cfg=7.0, output=None, timeout=600,
|
|
post=False, output_ogg=None):
|
|
"""
|
|
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)
|
|
post: If True, run trim+normalize+convert after generation
|
|
output_ogg: OGG output path when post=True (default: same basename .ogg)
|
|
"""
|
|
base = get_base_url()
|
|
_check_available(base)
|
|
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)
|
|
result_json = {
|
|
"ok": True,
|
|
"file": output,
|
|
"size_bytes": file_size,
|
|
"duration_requested": duration,
|
|
"steps": steps,
|
|
"cfg": cfg,
|
|
"prompt": prompt,
|
|
"generation_time_s": elapsed
|
|
}
|
|
|
|
if post:
|
|
post_result = post_process(output, ogg_path=output_ogg)
|
|
if not post_result.get("ok"):
|
|
result_json["post_processed"] = False
|
|
result_json["post_error"] = post_result.get("error", "unknown")
|
|
else:
|
|
result_json["post_processed"] = True
|
|
result_json["ogg_file"] = post_result.get("output", output_ogg)
|
|
ogg_size = os.path.getsize(result_json["ogg_file"])
|
|
result_json["ogg_size_bytes"] = ogg_size
|
|
|
|
print(json.dumps(result_json, 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
|
|
post = False
|
|
output_ogg = None
|
|
|
|
# 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
|
|
elif sys.argv[i] == "--post":
|
|
post = True
|
|
i += 1
|
|
elif sys.argv[i] == "--output-ogg" and i + 1 < len(sys.argv):
|
|
output_ogg = sys.argv[i + 1]
|
|
post = True # --output-ogg implies --post
|
|
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, post=post, output_ogg=output_ogg)
|
|
else:
|
|
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|