168 lines
6.9 KiB
Python
Executable File
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())
|