Files
aetherbound-guild/tools/audio/generate_music.py
T

168 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Dry-run-first ElevenLabs music generator for Aetherbound Guild.
Mirrors the safety conventions of the workspace `elevenlabs-game-audio`
skill: dry-run is the default, network generation needs both --execute and
--confirm-paid-generation, the API key is read only from the environment and
never written to any output, and every generated file keeps a sidecar record.
Endpoint: POST https://api.elevenlabs.io/v1/music (prompt or composition_plan,
model_id, music_length_ms 3000-600000, seed).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
API_URL = "https://api.elevenlabs.io/v1/music"
MIN_LENGTH_MS = 3000
MAX_LENGTH_MS = 600000
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="ElevenLabs music batch (dry-run default)")
parser.add_argument("manifest")
parser.add_argument("--output-dir", default=None)
parser.add_argument("--execute", action="store_true")
parser.add_argument("--confirm-paid-generation", action="store_true")
parser.add_argument("--overwrite", action="store_true")
parser.add_argument("--timeout", type=float, default=600.0)
parser.add_argument("--report", default=None)
return parser.parse_args()
def load_items(manifest_path: Path, output_dir: str | None) -> tuple[str, list[dict], Path]:
data = json.loads(manifest_path.read_text())
base = Path(output_dir) if output_dir else Path(str(data.get("output_dir", "audio/out")))
if not base.is_absolute():
base = manifest_path.parent / base
items = list(data.get("items", []))
model_id = str(data.get("model_id", "music_v1"))
return model_id, items, base
def validate(item: dict) -> list[str]:
problems: list[str] = []
if not str(item.get("id", "")).strip():
problems.append("missing id")
prompt = str(item.get("prompt", "")).strip()
if not prompt:
problems.append("missing prompt")
length = item.get("music_length_ms")
if length is not None:
try:
length_value = int(length)
except (TypeError, ValueError):
problems.append("music_length_ms is not an integer")
else:
if not (MIN_LENGTH_MS <= length_value <= MAX_LENGTH_MS):
problems.append(f"music_length_ms {length_value} outside {MIN_LENGTH_MS}..{MAX_LENGTH_MS}")
if not str(item.get("output", "")).strip():
problems.append("missing output")
return problems
def request_music(api_key: str, model_id: str, item: dict, timeout: float) -> tuple[bytes, dict]:
payload: dict = {"prompt": str(item["prompt"]).strip(), "model_id": model_id}
if item.get("music_length_ms") is not None:
payload["music_length_ms"] = int(item["music_length_ms"])
# `seed` is only accepted alongside `composition_plan`; the prompt path rejects it.
if item.get("seed") is not None and item.get("composition_plan") is not None:
payload["seed"] = int(item["seed"])
request = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"xi-api-key": api_key, "Content-Type": "application/json", "Accept": "audio/mpeg"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read()
headers = {key.lower(): value for key, value in response.headers.items()}
return body, headers
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", "replace")[:400]
raise RuntimeError(f"HTTP {error.code}: {detail}") from error
def main() -> int:
args = parse_args()
manifest_path = Path(args.manifest).resolve()
if not manifest_path.is_file():
print(f"manifest not found: {manifest_path}", file=sys.stderr)
return 2
model_id, items, base_dir = load_items(manifest_path, args.output_dir)
problems: list[str] = []
for index, item in enumerate(items):
for problem in validate(item):
problems.append(f"item {index} ({item.get('id', '?')}): {problem}")
if not items:
problems.append("manifest has no items")
if problems:
for problem in problems:
print(f"INVALID {problem}", file=sys.stderr)
return 2
planned = [str(item["output"]) for item in items]
for relative in planned:
print(f"PLAN model={model_id} output={relative}")
for item in items:
if item.get("seed") is not None and item.get("composition_plan") is None:
print(f"WARN {item.get('id')}: seed ignored (the music API only accepts seed with composition_plan)")
execute = args.execute and args.confirm_paid_generation
if not execute:
print(f"DRY-RUN ok items={len(items)} (add --execute --confirm-paid-generation for network generation)")
return 0
api_key = os.environ.get("ELEVENLABS_API_KEY", "")
if not api_key:
print("ELEVENLABS_API_KEY is not set", file=sys.stderr)
return 2
report: list[dict] = []
failures = 0
for item in items:
target = base_dir / str(item["output"])
if target.exists() and not args.overwrite:
print(f"SKIP existing {target}")
report.append({"id": item["id"], "status": "skipped_existing", "path": str(target)})
continue
try:
body, headers = request_music(api_key, model_id, item, args.timeout)
except Exception as error: # noqa: BLE001 - report the safe message only
failures += 1
print(f"FAIL {item['id']}: {error}", file=sys.stderr)
report.append({"id": item["id"], "status": "failed", "error": str(error)[:400]})
continue
if len(body) < 1024:
failures += 1
print(f"FAIL {item['id']}: empty or invalid audio payload ({len(body)} bytes)", file=sys.stderr)
report.append({"id": item["id"], "status": "failed", "error": "invalid payload"})
continue
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(body)
record = {
"id": item["id"],
"status": "generated",
"path": str(target),
"bytes": len(body),
"model_id": model_id,
"seed": item.get("seed"),
"music_length_ms": item.get("music_length_ms"),
"request_id": headers.get("request-id") or headers.get("x-request-id"),
"trace_id": headers.get("x-trace-id"),
}
sidecar = target.with_suffix(target.suffix + ".meta.json")
sidecar.write_text(json.dumps(record, ensure_ascii=False, indent=1))
report.append(record)
print(f"OK {item['id']} -> {target} ({len(body)} bytes)")
if args.report:
Path(args.report).write_text(json.dumps({"items": report}, ensure_ascii=False, indent=1))
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())