FresherDifference commited on
Commit
5ddeb88
·
verified ·
1 Parent(s): 5bd914e

Add audio.cpp runtime helpers

Browse files
Files changed (1) hide show
  1. audiocpp_runtime.py +557 -0
audiocpp_runtime.py ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """audio.cpp runtime helpers for the Hugging Face Space.
2
+
3
+ Everything audio.cpp-specific lives here so it can be imported both by the
4
+ Gradio app and by user code in the Python tab:
5
+
6
+ import audiocpp_runtime as ac
7
+ ac.list_packages(task="tts")
8
+ result = ac.run(family="pocket_tts", task="tts", request={"text": "Hello"})
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import ctypes
14
+ import glob
15
+ import json
16
+ import os
17
+ import shutil
18
+ import socket
19
+ import subprocess
20
+ import sys
21
+ import tarfile
22
+ import threading
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+ import uuid
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ APP_DIR = Path(__file__).resolve().parent
31
+ AUDIOCPP_VERSION = os.environ.get("AUDIOCPP_VERSION", "v0.9.0")
32
+ ASSET_NAME = "audio-{tag}-bin-ubuntu-x64-cuda12.8-colab.tar.gz"
33
+ RELEASE_URL = "https://github.com/0xShug0/audio.cpp/releases/download/{tag}/{asset}"
34
+
35
+ HOME = Path(os.environ.get("AUDIOCPP_HOME", Path.home() / ".audiocpp"))
36
+ BIN_ROOT = HOME / "bin"
37
+ WORK_ROOT = HOME / "work"
38
+
39
+
40
+ def _pick_models_root() -> Path:
41
+ override = os.environ.get("AUDIOCPP_MODELS_DIR")
42
+ if override:
43
+ return Path(override)
44
+ data = Path("/data")
45
+ if data.is_dir() and os.access(data, os.W_OK):
46
+ return data / "audiocpp-models" # persistent storage, if the Space has it
47
+ return HOME / "models"
48
+
49
+
50
+ MODELS_ROOT = _pick_models_root()
51
+ STUB_SRC = APP_DIR / "stub" / "cuda_stub.c"
52
+ STUB_DIR = HOME / "stub"
53
+
54
+ # Spec task names -> audio.cpp runtime task tokens.
55
+ TASK_TOKEN = {
56
+ "tts": "tts", "clone": "clon", "design": "vdes", "asr": "asr", "align": "align",
57
+ "vad": "vad", "diar": "diar", "sep": "sep", "vc": "vc", "svc": "svc", "s2s": "s2s",
58
+ "music": "gen", "sfx": "gen", "edit": "gen", "midi": "midi", "turn": "turn",
59
+ "speaker": "spk",
60
+ }
61
+ TASK_LABELS = {
62
+ "tts": "Text to speech",
63
+ "clone": "Voice cloning",
64
+ "design": "Voice design",
65
+ "asr": "Speech recognition (ASR)",
66
+ "align": "Forced alignment",
67
+ "vad": "Voice activity detection",
68
+ "diar": "Speaker diarization",
69
+ "sep": "Source separation",
70
+ "vc": "Voice conversion",
71
+ "svc": "Singing voice conversion",
72
+ "s2s": "Speech to speech / restoration",
73
+ "music": "Music generation",
74
+ "sfx": "Sound effects",
75
+ "edit": "Audio editing",
76
+ "midi": "Audio to MIDI / score",
77
+ "turn": "Turn detection",
78
+ }
79
+ # Families whose loader wants the package directory rather than one GGUF file.
80
+ DIR_FAMILIES = {"auk", "lfm2_audio", "liveavatar", "minimax_music3", "yue2"}
81
+
82
+ _setup_lock = threading.Lock()
83
+ _install_lock = threading.Lock()
84
+ _state: dict[str, Any] = {"ready": False, "error": None, "server": None}
85
+
86
+
87
+ # --------------------------------------------------------------------------- setup
88
+ def log(msg: str) -> None:
89
+ print(f"[audiocpp] {msg}", flush=True)
90
+
91
+
92
+ def ensure_runtime() -> Path:
93
+ """Download and unpack the audio.cpp CUDA prebuilt once. Returns the bundle dir."""
94
+ with _setup_lock:
95
+ if _state["ready"]:
96
+ return _state["bundle"]
97
+ bundle = BIN_ROOT / AUDIOCPP_VERSION
98
+ server = bundle / "audiocpp_server"
99
+ if not server.exists():
100
+ bundle.mkdir(parents=True, exist_ok=True)
101
+ asset = ASSET_NAME.format(tag=AUDIOCPP_VERSION)
102
+ url = RELEASE_URL.format(tag=AUDIOCPP_VERSION, asset=asset)
103
+ archive = BIN_ROOT / asset
104
+ log(f"downloading {url}")
105
+ req = urllib.request.Request(url, headers={"User-Agent": "audiocpp-hf-space"})
106
+ with urllib.request.urlopen(req, timeout=600) as resp, open(archive, "wb") as out:
107
+ shutil.copyfileobj(resp, out, length=1 << 20)
108
+ with tarfile.open(archive, "r:gz") as tar:
109
+ tar.extractall(bundle, filter="data")
110
+ archive.unlink(missing_ok=True)
111
+ found = list(bundle.rglob("audiocpp_server"))
112
+ if not found:
113
+ raise RuntimeError("release archive has no audiocpp_server")
114
+ server = found[0]
115
+ server.chmod(0o755)
116
+ MODELS_ROOT.mkdir(parents=True, exist_ok=True)
117
+ WORK_ROOT.mkdir(parents=True, exist_ok=True)
118
+ build_cuda_stub()
119
+ _state.update(ready=True, bundle=server.parent, server=server)
120
+ log(f"runtime ready: {server}")
121
+ return server.parent
122
+
123
+
124
+ def build_cuda_stub() -> None:
125
+ """Compile the tiny libcuda.so.1 stand-in used for CPU-only runs."""
126
+ target = STUB_DIR / "libcuda.so.1"
127
+ if target.exists():
128
+ return
129
+ STUB_DIR.mkdir(parents=True, exist_ok=True)
130
+ prebuilt = APP_DIR / "stub" / "libcuda.so.1"
131
+ if prebuilt.exists():
132
+ shutil.copy2(prebuilt, target)
133
+ return
134
+ cc = shutil.which("gcc") or shutil.which("cc")
135
+ if not cc:
136
+ log("no C compiler found; CPU fallback unavailable")
137
+ return
138
+ res = subprocess.run([cc, "-shared", "-fPIC", "-O2", "-Wl,-soname,libcuda.so.1",
139
+ "-o", str(target), str(STUB_SRC)], capture_output=True, text=True)
140
+ if res.returncode != 0:
141
+ log("stub build failed: " + res.stderr[-500:])
142
+
143
+
144
+ def cuda_lib_dirs() -> list[str]:
145
+ dirs: set[str] = set()
146
+ for base in sys.path:
147
+ for lib in glob.glob(os.path.join(base, "nvidia", "*", "lib")):
148
+ dirs.add(lib)
149
+ return sorted(dirs)
150
+
151
+
152
+ def gpu_available() -> bool:
153
+ """True when a real NVIDIA driver and device are visible to this process."""
154
+ try:
155
+ ctypes.CDLL("libcuda.so.1")
156
+ except OSError:
157
+ return False
158
+ smi = shutil.which("nvidia-smi")
159
+ if smi:
160
+ try:
161
+ out = subprocess.run([smi, "-L"], capture_output=True, text=True, timeout=20)
162
+ return out.returncode == 0 and "GPU" in out.stdout
163
+ except Exception:
164
+ return False
165
+ return True
166
+
167
+
168
+ def server_env(backend: str) -> dict[str, str]:
169
+ env = {k: v for k, v in os.environ.items()}
170
+ dirs = cuda_lib_dirs()
171
+ if backend == "cpu":
172
+ dirs.append(str(STUB_DIR)) # lets the CUDA build start without a driver
173
+ if env.get("LD_LIBRARY_PATH"):
174
+ dirs.append(env["LD_LIBRARY_PATH"])
175
+ env["LD_LIBRARY_PATH"] = ":".join(dirs)
176
+ env.setdefault("OMP_NUM_THREADS", str(os.cpu_count() or 4))
177
+ return env
178
+
179
+
180
+ # --------------------------------------------------------------------------- catalog
181
+ _catalog_cache: dict[str, dict] | None = None
182
+
183
+
184
+ def specs_dir() -> Path:
185
+ return ensure_runtime() / "model_specs"
186
+
187
+
188
+ def catalog() -> dict[str, dict]:
189
+ """family -> spec summary (tasks, packages, voices, options)."""
190
+ global _catalog_cache
191
+ if _catalog_cache is not None:
192
+ return _catalog_cache
193
+ out: dict[str, dict] = {}
194
+ for path in sorted(specs_dir().glob("*.json")):
195
+ try:
196
+ spec = json.loads(path.read_text(encoding="utf-8"))
197
+ except Exception:
198
+ continue
199
+ family = spec.get("family") or path.stem
200
+ defaults = spec.get("package_defaults") or {}
201
+ pkgs = []
202
+ for p in spec.get("packages", []):
203
+ p = dict(p)
204
+ p.setdefault("download", defaults.get("download"))
205
+ pkgs.append(p)
206
+ ui = spec.get("ui") or {}
207
+ out[family] = {
208
+ "family": family,
209
+ "display_name": spec.get("display_name", family),
210
+ "description": spec.get("description", ""),
211
+ "category": spec.get("category", ""),
212
+ "status": spec.get("status", ""),
213
+ "tasks": spec.get("tasks", []),
214
+ "languages": spec.get("languages", []),
215
+ "packages": pkgs,
216
+ "recommended": ui.get("recommended_package"),
217
+ "voices": ui.get("builtin_voices") or [],
218
+ "default_voice": ui.get("default_voice"),
219
+ "request_options": (spec.get("options") or {}).get("request", []),
220
+ "docs": ui.get("docs", []),
221
+ }
222
+ _catalog_cache = out
223
+ return out
224
+
225
+
226
+ def families_for_task(task: str) -> list[str]:
227
+ return sorted(f for f, s in catalog().items() if task in s["tasks"] and s["packages"])
228
+
229
+
230
+ def package(pkg_id: str) -> tuple[dict, dict]:
231
+ for fam in catalog().values():
232
+ for p in fam["packages"]:
233
+ if p["id"] == pkg_id:
234
+ return fam, p
235
+ raise KeyError(f"unknown package {pkg_id!r}")
236
+
237
+
238
+ def default_package(family: str) -> str | None:
239
+ fam = catalog()[family]
240
+ ids = [p["id"] for p in fam["packages"]]
241
+ if fam["recommended"] in ids:
242
+ return fam["recommended"]
243
+ for p in fam["packages"]:
244
+ if p.get("default"):
245
+ return p["id"]
246
+ gguf = [p["id"] for p in fam["packages"] if p.get("format") == "gguf"]
247
+ return (gguf or ids or [None])[0]
248
+
249
+
250
+ def list_packages(task: str | None = None) -> list[dict]:
251
+ rows = []
252
+ for fam in catalog().values():
253
+ if task and task not in fam["tasks"]:
254
+ continue
255
+ for p in fam["packages"]:
256
+ rows.append({
257
+ "family": fam["family"], "package": p["id"], "format": p.get("format"),
258
+ "precision": p.get("precision"), "tasks": fam["tasks"],
259
+ "installed": is_installed(p["id"]),
260
+ })
261
+ return rows
262
+
263
+
264
+ def package_dir(pkg: dict) -> Path:
265
+ return MODELS_ROOT / pkg["target_directory"]
266
+
267
+
268
+ def model_path(pkg_id: str) -> Path:
269
+ """Path to hand to the loader, mirroring the audio.cpp WebUI's choice."""
270
+ fam, pkg = package(pkg_id)
271
+ root = package_dir(pkg)
272
+ if pkg.get("format") != "gguf" or fam["family"] in DIR_FAMILIES:
273
+ return root
274
+ files = pkg.get("files") or []
275
+ if fam["family"] == "minimax_h3":
276
+ want = "dit_int8.gguf" if "int8_dit" in pkg_id else "dit.gguf"
277
+ files = [f for f in files if f.lower().endswith("/" + want)]
278
+ ggufs = [f for f in files if f.lower().endswith(".gguf")]
279
+ if not ggufs:
280
+ return root
281
+ rel = ggufs[0].replace("\\", "/")
282
+ prefix = (pkg.get("strip_prefix") or "").rstrip("/")
283
+ if prefix and rel.startswith(prefix + "/"):
284
+ rel = rel[len(prefix) + 1:]
285
+ return root / rel
286
+
287
+
288
+ def session_options_for(pkg_id: str) -> dict[str, str]:
289
+ fam, pkg = package(pkg_id)
290
+ if fam["family"] == "lfm2_audio":
291
+ backbone = next((f for f in pkg.get("files", []) if not Path(f).name.startswith("mmproj-")), None)
292
+ return {"lfm2_audio.model_gguf": Path(backbone).name} if backbone else {}
293
+ if fam["family"] == "minimax_music3":
294
+ table = {
295
+ "minimax_music3_q8_0": ("language_model_q8_0.gguf", "rvq_depth_decoder_q8_0.gguf", "transformer_q8_0.gguf"),
296
+ "minimax_music3_bf16": ("language_model_bf16.gguf", "rvq_depth_decoder_bf16.gguf", "transformer_bf16.gguf"),
297
+ "minimax_music3_q4_0": ("language_model_q4_0.gguf", "rvq_depth_decoder_q8_0.gguf", "transformer_q4_0.gguf"),
298
+ }
299
+ if pkg_id in table:
300
+ lm, rvq, flow = table[pkg_id]
301
+ return {"minimax_music3.language_model_gguf": lm,
302
+ "minimax_music3.rvq_depth_decoder_gguf": rvq,
303
+ "minimax_music3.flow_transformer_gguf": flow}
304
+ return {}
305
+
306
+
307
+ def is_installed(pkg_id: str) -> bool:
308
+ try:
309
+ _, pkg = package(pkg_id)
310
+ except KeyError:
311
+ return False
312
+ root = package_dir(pkg)
313
+ if not root.exists():
314
+ return False
315
+ files = pkg.get("files") or []
316
+ prefix = (pkg.get("strip_prefix") or "").rstrip("/")
317
+ for f in files:
318
+ rel = f.replace("\\", "/")
319
+ if prefix and rel.startswith(prefix + "/"):
320
+ rel = rel[len(prefix) + 1:]
321
+ if not (root / rel).exists():
322
+ return False
323
+ return bool(files) or any(root.iterdir())
324
+
325
+
326
+ def package_size(pkg_id: str) -> int | None:
327
+ """Download size in bytes from the Hugging Face API, if available."""
328
+ try:
329
+ from huggingface_hub import HfApi
330
+ _, pkg = package(pkg_id)
331
+ dl = pkg.get("download") or {}
332
+ if dl.get("kind") != "huggingface_snapshot" or not pkg.get("files"):
333
+ return None
334
+ infos = HfApi().get_paths_info(dl["repo"], pkg["files"], revision=dl.get("revision") or "main")
335
+ return sum(getattr(i, "size", 0) or 0 for i in infos)
336
+ except Exception:
337
+ return None
338
+
339
+
340
+ def install(pkg_id: str, progress=None) -> Path:
341
+ """Download one package with the repo's model manager. Returns the model path."""
342
+ ensure_runtime()
343
+ if is_installed(pkg_id):
344
+ return model_path(pkg_id)
345
+ with _install_lock:
346
+ if is_installed(pkg_id):
347
+ return model_path(pkg_id)
348
+ bundle = ensure_runtime()
349
+ cmd = [sys.executable, str(bundle / "tools" / "model_manager_v2.py"),
350
+ "--specs-dir", str(bundle / "model_specs"), "install", pkg_id,
351
+ "--models-root", str(MODELS_ROOT), "--progress"]
352
+ log("installing " + pkg_id)
353
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
354
+ tail: list[str] = []
355
+ for line in proc.stdout: # type: ignore[union-attr]
356
+ line = line.rstrip()
357
+ tail = (tail + [line])[-30:]
358
+ if progress and line.startswith("AUDIOCPP_PROGRESS"):
359
+ try:
360
+ kv = dict(part.split("=", 1) for part in line.split()[1:] if "=" in part)
361
+ done, total = int(kv.get("downloaded", 0)), int(kv.get("total", 0))
362
+ if total > 0:
363
+ progress(min(done / total, 1.0),
364
+ desc=f"Downloading {pkg_id} ({done / 1e9:.2f} / {total / 1e9:.2f} GB)")
365
+ except Exception:
366
+ pass
367
+ if proc.wait() != 0:
368
+ raise RuntimeError(f"install of {pkg_id} failed:\n" + "\n".join(tail))
369
+ return model_path(pkg_id)
370
+
371
+
372
+ def uninstall(pkg_id: str) -> None:
373
+ bundle = ensure_runtime()
374
+ subprocess.run([sys.executable, str(bundle / "tools" / "model_manager_v2.py"),
375
+ "--specs-dir", str(bundle / "model_specs"), "uninstall", pkg_id,
376
+ "--models-root", str(MODELS_ROOT)], check=False)
377
+
378
+
379
+ def installed_packages() -> list[str]:
380
+ return [r["package"] for r in list_packages() if r["installed"]]
381
+
382
+
383
+ # --------------------------------------------------------------------------- audio
384
+ def to_wav(src: str | os.PathLike, dst_dir: str | os.PathLike, rate: int | None = None,
385
+ mono: bool = False, max_seconds: float | None = None) -> str:
386
+ """Convert any audio or video file to 16-bit PCM WAV with ffmpeg."""
387
+ dst = Path(dst_dir) / f"{Path(src).stem[:40]}-{uuid.uuid4().hex[:6]}.wav"
388
+ cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src), "-vn"]
389
+ if max_seconds:
390
+ cmd += ["-t", str(max_seconds)]
391
+ if rate:
392
+ cmd += ["-ar", str(rate)]
393
+ if mono:
394
+ cmd += ["-ac", "1"]
395
+ cmd += ["-c:a", "pcm_s16le", str(dst)]
396
+ res = subprocess.run(cmd, capture_output=True, text=True)
397
+ if res.returncode != 0:
398
+ raise RuntimeError(f"ffmpeg could not read {Path(src).name}: {res.stderr.strip()[-400:]}")
399
+ return str(dst)
400
+
401
+
402
+ def input_format(task: str, family: str) -> tuple[int | None, bool]:
403
+ """Sample rate / mono policy matching the audio.cpp WebUI."""
404
+ if task == "sep" or family == "apollo":
405
+ return 44100, False
406
+ if task in {"asr", "vad", "diar", "align", "midi", "turn"}:
407
+ return 16000, True
408
+ return None, False
409
+
410
+
411
+ # --------------------------------------------------------------------------- server
412
+ def _free_port() -> int:
413
+ with socket.socket() as s:
414
+ s.bind(("127.0.0.1", 0))
415
+ return s.getsockname()[1]
416
+
417
+
418
+ def _post(url: str, body: dict, timeout: float) -> dict:
419
+ data = json.dumps(body).encode()
420
+ req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
421
+ try:
422
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
423
+ return json.loads(resp.read().decode("utf-8", "replace"))
424
+ except urllib.error.HTTPError as err:
425
+ payload = err.read().decode("utf-8", "replace")
426
+ try:
427
+ msg = json.loads(payload).get("error", {}).get("message", payload)
428
+ except Exception:
429
+ msg = payload
430
+ raise RuntimeError(f"HTTP {err.code}: {msg}") from None
431
+
432
+
433
+ def _tail(path: Path, n: int = 60) -> str:
434
+ try:
435
+ return "\n".join(path.read_text(errors="replace").splitlines()[-n:])
436
+ except Exception:
437
+ return ""
438
+
439
+
440
+ def _save_result(res: dict, out_dir: Path, stem: str) -> dict:
441
+ """Replace base64 payloads with files on disk."""
442
+ out: dict[str, Any] = {k: v for k, v in res.items()
443
+ if k not in {"audio", "named_audio_outputs", "artifacts"}}
444
+ files: list[str] = []
445
+ if res.get("audio"):
446
+ p = out_dir / f"{stem}.wav"
447
+ p.write_bytes(base64.b64decode(res["audio"]))
448
+ files.append(str(p))
449
+ out["audio_file"] = str(p)
450
+ for item in res.get("named_audio_outputs") or []:
451
+ name = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(item.get("id", "out")))
452
+ p = out_dir / f"{stem}-{name}.wav"
453
+ p.write_bytes(base64.b64decode(item["audio"]))
454
+ files.append(str(p))
455
+ out.setdefault("named_audio_files", {})[item.get("id", name)] = str(p)
456
+ for art in res.get("artifacts") or []:
457
+ meta = art.get("meta") or {}
458
+ ext = meta.get("extension") or meta.get("ext") or {"midi": "mid", "video": "mp4", "json": "json",
459
+ "text": "txt"}.get(art.get("kind", ""), "bin")
460
+ name = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(art.get("id", "artifact")))
461
+ p = out_dir / f"{stem}-{name}.{ext.lstrip('.')}"
462
+ p.write_bytes(base64.b64decode(art.get("payload", "")))
463
+ files.append(str(p))
464
+ out.setdefault("artifact_files", []).append({"id": art.get("id"), "kind": art.get("kind"),
465
+ "meta": meta, "file": str(p)})
466
+ out["files"] = files
467
+ return out
468
+
469
+
470
+ def execute(job: dict, prefer_gpu: bool = True) -> dict:
471
+ """Start audiocpp_server for one model, run every request in job, stop it.
472
+
473
+ job keys: family, task (spec name), model_path, requests (list of request
474
+ dicts), load_options, session_options, timeout (seconds), out_dir.
475
+ """
476
+ ensure_runtime()
477
+ started = time.time()
478
+ timeout = float(job.get("timeout") or 600)
479
+ backends = ["cuda", "cpu"] if (prefer_gpu and gpu_available()) else ["cpu"]
480
+ out_dir = Path(job.get("out_dir") or WORK_ROOT / uuid.uuid4().hex)
481
+ out_dir.mkdir(parents=True, exist_ok=True)
482
+ errors: list[str] = []
483
+ for backend in backends:
484
+ port = _free_port()
485
+ model = {
486
+ "id": "m",
487
+ "family": job["family"],
488
+ "path": str(job["model_path"]),
489
+ "task": TASK_TOKEN.get(job["task"], job["task"]),
490
+ "mode": "offline",
491
+ }
492
+ if job.get("load_options"):
493
+ model["load_options"] = {k: str(v) for k, v in job["load_options"].items()}
494
+ if job.get("session_options"):
495
+ model["session_options"] = {k: str(v) for k, v in job["session_options"].items()}
496
+ cfg = {"host": "127.0.0.1", "port": port, "backend": backend, "device": 0,
497
+ "threads": os.cpu_count() or 4, "lazy_load": True,
498
+ "busy_timeout_ms": int(timeout * 1000), "models": [model]}
499
+ cfg_path = out_dir / f"server-{backend}.json"
500
+ cfg_path.write_text(json.dumps(cfg, indent=1))
501
+ log_path = out_dir / f"server-{backend}.log"
502
+ server = _state["server"]
503
+ with open(log_path, "w") as logf:
504
+ proc = subprocess.Popen([str(server), "--config", str(cfg_path), "--no-ui"],
505
+ cwd=str(server.parent), env=server_env(backend),
506
+ stdout=logf, stderr=subprocess.STDOUT)
507
+ try:
508
+ base = f"http://127.0.0.1:{port}"
509
+ deadline = time.time() + 60
510
+ while True:
511
+ if proc.poll() is not None:
512
+ raise RuntimeError("server exited during startup")
513
+ try:
514
+ with urllib.request.urlopen(base + "/health", timeout=2) as r:
515
+ if r.status == 200:
516
+ break
517
+ except Exception:
518
+ pass
519
+ if time.time() > deadline:
520
+ raise RuntimeError("server did not become ready")
521
+ time.sleep(0.3)
522
+ results = []
523
+ for i, request in enumerate(job["requests"]):
524
+ remaining = max(10.0, timeout - (time.time() - started))
525
+ t0 = time.time()
526
+ res = _post(base + "/v1/tasks/run", {"model": "m", "request": request}, remaining)
527
+ saved = _save_result(res, out_dir, request.get("id") or f"out{i + 1:02d}")
528
+ saved["wall_s"] = round(time.time() - t0, 2)
529
+ results.append(saved)
530
+ return {"backend": backend, "results": results, "log": _tail(log_path),
531
+ "elapsed_s": round(time.time() - started, 2), "out_dir": str(out_dir)}
532
+ except Exception as err:
533
+ errors.append(f"[{backend}] {err}\n{_tail(log_path, 25)}")
534
+ # Request-level errors (HTTP 4xx from the model) would repeat on CPU,
535
+ # so only start-up / transport failures fall through to the CPU backend.
536
+ if str(err).startswith("HTTP 4"):
537
+ break
538
+ finally:
539
+ proc.terminate()
540
+ try:
541
+ proc.wait(timeout=10)
542
+ except subprocess.TimeoutExpired:
543
+ proc.kill()
544
+ raise RuntimeError("\n\n".join(errors))
545
+
546
+
547
+ def run(family: str, task: str, request: dict | list[dict] | None = None, package_id: str | None = None,
548
+ load_options: dict | None = None, session_options: dict | None = None,
549
+ timeout: float = 600, prefer_gpu: bool = True, out_dir: str | None = None) -> dict:
550
+ """Convenience wrapper: install the package if needed and run request(s)."""
551
+ package_id = package_id or default_package(family)
552
+ path = install(package_id)
553
+ sess = {**session_options_for(package_id), **(session_options or {})}
554
+ reqs = request if isinstance(request, list) else [request or {}]
555
+ return execute({"family": family, "task": task, "model_path": str(path), "requests": reqs,
556
+ "load_options": load_options or {}, "session_options": sess,
557
+ "timeout": timeout, "out_dir": out_dir}, prefer_gpu=prefer_gpu)