447 lines
17 KiB
Python
447 lines
17 KiB
Python
"""Owned audio.cpp server process launcher."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Mapping, Optional, Sequence
|
|
|
|
from .client import AudioCppClient, AudioCppClientError
|
|
from .windows_job import WindowsKillOnCloseJob
|
|
|
|
|
|
class AudioCppProcessError(RuntimeError):
|
|
"""Base error for managed audio.cpp process failures."""
|
|
|
|
|
|
class AudioCppProcessStartupError(AudioCppProcessError):
|
|
"""The owned audio.cpp server failed before becoming ready."""
|
|
|
|
|
|
def find_free_loopback_port() -> int:
|
|
"""Ask the OS for a currently unused IPv4 loopback port."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
def normalize_audio_cpp_task(task: Any) -> str:
|
|
normalized = str(task or "tts").strip().lower().replace("-", "_").replace(" ", "_")
|
|
aliases = {
|
|
"clone": "clon",
|
|
"cloning": "clon",
|
|
"voice_clone": "clon",
|
|
"voice_cloning": "clon",
|
|
"design": "vdes",
|
|
"voice_design": "vdes",
|
|
"voice_designer": "vdes",
|
|
}
|
|
return aliases.get(normalized, normalized)
|
|
|
|
|
|
def _first(config: Mapping[str, Any], *keys: str, default: Any = None) -> Any:
|
|
for key in keys:
|
|
if key in config and config[key] is not None:
|
|
return config[key]
|
|
return default
|
|
|
|
|
|
def _resolve_existing_path(value: Any, label: str, *, executable: bool = False) -> Path:
|
|
raw = os.path.expandvars(os.path.expanduser(str(value or "").strip()))
|
|
if not raw:
|
|
raise AudioCppProcessStartupError(f"Missing audio.cpp {label}")
|
|
candidate = Path(raw)
|
|
if executable and not candidate.exists():
|
|
located = shutil.which(raw)
|
|
if located:
|
|
candidate = Path(located)
|
|
candidate = candidate.resolve()
|
|
if not candidate.exists():
|
|
raise AudioCppProcessStartupError(f"audio.cpp {label} does not exist: {candidate}")
|
|
if executable and not candidate.is_file():
|
|
raise AudioCppProcessStartupError(f"audio.cpp {label} is not a file: {candidate}")
|
|
return candidate
|
|
|
|
|
|
def _safe_model_id(value: Any, family: str) -> str:
|
|
raw = str(value or family or "audio-cpp-model").strip()
|
|
cleaned = "".join(char if char.isalnum() or char in "._-" else "-" for char in raw)
|
|
cleaned = cleaned.strip("-.")
|
|
return cleaned or "audio-cpp-model"
|
|
|
|
|
|
def _option_dict(config: Mapping[str, Any], key: str) -> Dict[str, Any]:
|
|
value = config.get(key, {})
|
|
if value is None:
|
|
return {}
|
|
if not isinstance(value, Mapping):
|
|
raise AudioCppProcessStartupError(f"audio.cpp {key} must be a JSON object")
|
|
return dict(value)
|
|
|
|
|
|
class AudioCppServerProcess:
|
|
"""One suite-owned, single-model ``audiocpp_server`` process."""
|
|
|
|
def __init__(self, config: Mapping[str, Any]) -> None:
|
|
self.config = dict(config)
|
|
self.binary_path = _resolve_existing_path(
|
|
_first(
|
|
self.config,
|
|
"binary_path",
|
|
"server_binary",
|
|
"executable_path",
|
|
"audio_cpp_binary",
|
|
),
|
|
"server executable",
|
|
executable=True,
|
|
)
|
|
self.model_path = _resolve_existing_path(
|
|
_first(self.config, "model_path", "package_path", "gguf_path"),
|
|
"model path",
|
|
)
|
|
self.family = str(_first(self.config, "family", "model_family", default="")).strip()
|
|
if not self.family:
|
|
raise AudioCppProcessStartupError("Missing audio.cpp model family")
|
|
self.task = normalize_audio_cpp_task(_first(self.config, "task", default="tts"))
|
|
self.model_id = _safe_model_id(
|
|
_first(self.config, "model_id", "server_model_id", "package_id"),
|
|
self.family,
|
|
)
|
|
self.backend = str(_first(self.config, "backend", default="cuda")).strip().lower()
|
|
if self.backend == "auto":
|
|
self.backend = "cuda"
|
|
if self.backend not in {"cuda", "hip", "cpu", "vulkan", "metal"}:
|
|
raise AudioCppProcessStartupError(f"Unsupported audio.cpp backend: {self.backend}")
|
|
self.device = self._resolve_device_index(
|
|
_first(self.config, "device_index", "device", default=0)
|
|
)
|
|
# Match audio.cpp's CLI default instead of the server's conservative
|
|
# one-thread example configuration.
|
|
self.threads = max(1, int(_first(self.config, "threads", default=4)))
|
|
self.port = int(_first(self.config, "port", "server_port", default=0) or 0)
|
|
self.startup_timeout = max(
|
|
0.1, float(_first(self.config, "startup_timeout", "startup_timeout_seconds", default=30.0))
|
|
)
|
|
self.request_timeout = max(
|
|
0.1, float(_first(self.config, "request_timeout", "request_timeout_seconds", default=600.0))
|
|
)
|
|
self.connect_timeout = max(
|
|
0.05, float(_first(self.config, "connect_timeout", "connect_timeout_seconds", default=2.0))
|
|
)
|
|
self.stop_timeout = max(
|
|
0.1, float(_first(self.config, "stop_timeout", "stop_timeout_seconds", default=5.0))
|
|
)
|
|
self._lock = threading.RLock()
|
|
self._process: Optional[subprocess.Popen] = None
|
|
self._client: Optional[AudioCppClient] = None
|
|
self._temp_dir: Optional[tempfile.TemporaryDirectory] = None
|
|
self._config_path: Optional[Path] = None
|
|
self._log_path: Optional[Path] = None
|
|
self._log_handle = None
|
|
self._closed = False
|
|
self._parent_job: Optional[WindowsKillOnCloseJob] = None
|
|
|
|
@staticmethod
|
|
def _resolve_device_index(value: Any) -> int:
|
|
text = str(value).strip().lower()
|
|
if text in {"auto", "cuda", "hip", "cpu", "vulkan", "metal", ""}:
|
|
return 0
|
|
if ":" in text:
|
|
text = text.rsplit(":", 1)[-1]
|
|
try:
|
|
return max(0, int(text))
|
|
except ValueError as exc:
|
|
raise AudioCppProcessStartupError(
|
|
f"audio.cpp device must be an integer index, got {value!r}"
|
|
) from exc
|
|
|
|
@property
|
|
def process(self) -> Optional[subprocess.Popen]:
|
|
return self._process
|
|
|
|
@property
|
|
def client(self) -> AudioCppClient:
|
|
if self._client is None:
|
|
raise AudioCppProcessError("audio.cpp server process has not started")
|
|
return self._client
|
|
|
|
@property
|
|
def config_path(self) -> Optional[Path]:
|
|
return self._config_path
|
|
|
|
@property
|
|
def log_path(self) -> Optional[Path]:
|
|
return self._log_path
|
|
|
|
@property
|
|
def base_url(self) -> str:
|
|
if self.port <= 0:
|
|
raise AudioCppProcessError("audio.cpp server port is not allocated")
|
|
return f"http://127.0.0.1:{self.port}"
|
|
|
|
@property
|
|
def running(self) -> bool:
|
|
return self._process is not None and self._process.poll() is None
|
|
|
|
def _model_spec_override(self) -> Optional[Path]:
|
|
if "model_spec_override" in self.config:
|
|
explicit = self.config.get("model_spec_override")
|
|
if explicit in (None, "", False):
|
|
return None
|
|
path = _resolve_existing_path(explicit, "model spec override")
|
|
else:
|
|
path = (Path(__file__).resolve().parent / "model_specs").resolve()
|
|
if not path.is_dir():
|
|
raise AudioCppProcessStartupError(
|
|
"Bundled audio.cpp release-0.5.1 model specs are missing: " + str(path)
|
|
)
|
|
return path
|
|
|
|
def _build_server_config(self) -> Dict[str, Any]:
|
|
model: Dict[str, Any] = {
|
|
"id": self.model_id,
|
|
"family": self.family,
|
|
"path": str(self.model_path),
|
|
"task": self.task,
|
|
"mode": str(_first(self.config, "mode", "run_mode", default="offline")),
|
|
"lazy": bool(_first(self.config, "lazy", "lazy_load", default=True)),
|
|
"load_options": _option_dict(self.config, "load_options"),
|
|
"session_options": _option_dict(self.config, "session_options"),
|
|
"default_request_options": _option_dict(self.config, "default_request_options"),
|
|
}
|
|
for source_key, target_key in (
|
|
("config_id", "config"),
|
|
("weight_id", "weight"),
|
|
("voice_presets", "voice_presets"),
|
|
("default_voice_preset", "default_voice_preset"),
|
|
("model_busy_timeout_ms", "busy_timeout_ms"),
|
|
):
|
|
if source_key in self.config and self.config[source_key] is not None:
|
|
model[target_key] = self.config[source_key]
|
|
|
|
server: Dict[str, Any] = {
|
|
"host": "127.0.0.1",
|
|
"port": self.port,
|
|
"cors_origins": "",
|
|
"backend": self.backend,
|
|
"device": self.device,
|
|
"threads": self.threads,
|
|
"lazy_load": bool(_first(self.config, "lazy_load", default=True)),
|
|
"log_request_body": False,
|
|
"max_request_body_bytes": int(
|
|
_first(self.config, "max_request_body_bytes", default=2 * 1024 * 1024 * 1024)
|
|
),
|
|
"busy_timeout_ms": int(_first(self.config, "busy_timeout_ms", default=300000)),
|
|
"models": [model],
|
|
}
|
|
model_spec_override = self._model_spec_override()
|
|
if model_spec_override is not None:
|
|
server["model_spec_override"] = str(model_spec_override)
|
|
return server
|
|
|
|
def _prepare_files(self) -> None:
|
|
temp_root = _first(self.config, "temp_root", "runtime_temp_root")
|
|
if temp_root:
|
|
Path(str(temp_root)).expanduser().resolve().mkdir(parents=True, exist_ok=True)
|
|
self._temp_dir = tempfile.TemporaryDirectory(
|
|
prefix="tts_audio_cpp_",
|
|
dir=str(Path(str(temp_root)).expanduser().resolve()) if temp_root else None,
|
|
)
|
|
temp_path = Path(self._temp_dir.name)
|
|
self._config_path = temp_path / "server.json"
|
|
log_dir = _first(self.config, "log_dir")
|
|
if log_dir:
|
|
resolved_log_dir = Path(str(log_dir)).expanduser().resolve()
|
|
resolved_log_dir.mkdir(parents=True, exist_ok=True)
|
|
self._log_path = resolved_log_dir / f"audio_cpp_{self.model_id}_{self.port}.log"
|
|
else:
|
|
self._log_path = temp_path / "server.log"
|
|
with self._config_path.open("w", encoding="utf-8", newline="\n") as handle:
|
|
json.dump(self._build_server_config(), handle, ensure_ascii=False, indent=2)
|
|
handle.write("\n")
|
|
self._log_handle = self._log_path.open("ab", buffering=0)
|
|
|
|
def _command(self) -> list[str]:
|
|
binary_args = _first(self.config, "binary_args", "launcher_args", default=[])
|
|
if binary_args is None:
|
|
binary_args = []
|
|
if isinstance(binary_args, (str, bytes)) or not isinstance(binary_args, Sequence):
|
|
raise AudioCppProcessStartupError("audio.cpp binary_args must be a list")
|
|
return [
|
|
str(self.binary_path),
|
|
*[str(item) for item in binary_args],
|
|
"--config",
|
|
str(self._config_path),
|
|
]
|
|
|
|
def start(self) -> "AudioCppServerProcess":
|
|
with self._lock:
|
|
if self._closed:
|
|
raise AudioCppProcessError("audio.cpp server process launcher is closed")
|
|
if self.running:
|
|
return self
|
|
if self.port <= 0:
|
|
self.port = find_free_loopback_port()
|
|
try:
|
|
self._prepare_files()
|
|
env = os.environ.copy()
|
|
extra_env = _first(self.config, "environment", "env", default={})
|
|
if extra_env:
|
|
if not isinstance(extra_env, Mapping):
|
|
raise AudioCppProcessStartupError(
|
|
"audio.cpp environment must be an object"
|
|
)
|
|
env.update({str(key): str(value) for key, value in extra_env.items()})
|
|
env.setdefault("PYTHONUTF8", "1")
|
|
visible_console = bool(self.config.get("show_server_console", False))
|
|
creationflags = 0
|
|
if os.name == "nt":
|
|
creationflags = getattr(
|
|
subprocess,
|
|
"CREATE_NEW_CONSOLE" if visible_console else "CREATE_NO_WINDOW",
|
|
0,
|
|
)
|
|
self._process = subprocess.Popen(
|
|
self._command(),
|
|
cwd=str(self.binary_path.parent),
|
|
env=env,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=None if visible_console else self._log_handle,
|
|
stderr=None if visible_console else subprocess.STDOUT,
|
|
shell=False,
|
|
creationflags=creationflags,
|
|
)
|
|
if os.name == "nt":
|
|
self._parent_job = WindowsKillOnCloseJob()
|
|
self._parent_job.assign(int(self._process._handle))
|
|
self._client = AudioCppClient(
|
|
self.base_url,
|
|
connect_timeout=self.connect_timeout,
|
|
request_timeout=self.request_timeout,
|
|
)
|
|
self._wait_until_ready()
|
|
return self
|
|
except Exception as exc:
|
|
log_tail = self.read_log_tail()
|
|
self._terminate_exact_process()
|
|
self._client = None
|
|
self._cleanup_files()
|
|
if isinstance(exc, AudioCppProcessStartupError):
|
|
raise
|
|
suffix = f"\nServer log tail:\n{log_tail}" if log_tail else ""
|
|
raise AudioCppProcessStartupError(
|
|
f"Failed to start audio.cpp server: {exc}{suffix}"
|
|
) from exc
|
|
|
|
def _wait_until_ready(self) -> None:
|
|
deadline = time.monotonic() + self.startup_timeout
|
|
last_error: Optional[BaseException] = None
|
|
while time.monotonic() < deadline:
|
|
if self._process is None or self._process.poll() is not None:
|
|
code = self._process.poll() if self._process is not None else "unknown"
|
|
log_tail = self.read_log_tail()
|
|
suffix = f"\nServer log tail:\n{log_tail}" if log_tail else ""
|
|
raise AudioCppProcessStartupError(
|
|
f"audio.cpp server exited during startup with code {code}{suffix}"
|
|
)
|
|
try:
|
|
health = self.client.health(timeout=min(self.connect_timeout, 0.5))
|
|
status = str(health.get("status", "")).lower()
|
|
if status in {"ok", "ready", "healthy"} or health:
|
|
models = self.client.models(timeout=min(self.connect_timeout, 1.0))
|
|
if any(str(item.get("id")) == self.model_id for item in models):
|
|
return
|
|
last_error = AudioCppProcessStartupError(
|
|
f"audio.cpp server did not register expected model id '{self.model_id}'"
|
|
)
|
|
except AudioCppClientError as exc:
|
|
last_error = exc
|
|
time.sleep(0.05)
|
|
log_tail = self.read_log_tail()
|
|
suffix = f"\nServer log tail:\n{log_tail}" if log_tail else ""
|
|
raise AudioCppProcessStartupError(
|
|
f"audio.cpp server was not ready after {self.startup_timeout:.1f}s"
|
|
+ (f": {last_error}" if last_error else "")
|
|
+ suffix
|
|
)
|
|
|
|
def read_log_tail(self, max_bytes: int = 16384) -> str:
|
|
path = self._log_path
|
|
if path is None or not path.exists():
|
|
return ""
|
|
try:
|
|
if self._log_handle is not None:
|
|
self._log_handle.flush()
|
|
with path.open("rb") as handle:
|
|
size = path.stat().st_size
|
|
handle.seek(max(0, size - max(1, int(max_bytes))))
|
|
return handle.read().decode("utf-8", errors="replace").strip()
|
|
except OSError:
|
|
return ""
|
|
|
|
def _terminate_exact_process(self) -> None:
|
|
process = self._process
|
|
if process is None:
|
|
return
|
|
try:
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=self.stop_timeout)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=self.stop_timeout)
|
|
finally:
|
|
self._process = None
|
|
if self._parent_job is not None:
|
|
self._parent_job.close()
|
|
self._parent_job = None
|
|
|
|
def _cleanup_files(self) -> None:
|
|
if self._log_handle is not None:
|
|
try:
|
|
self._log_handle.close()
|
|
except OSError:
|
|
pass
|
|
self._log_handle = None
|
|
if self._temp_dir is not None:
|
|
try:
|
|
self._temp_dir.cleanup()
|
|
except OSError:
|
|
pass
|
|
self._temp_dir = None
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
self._terminate_exact_process()
|
|
self._client = None
|
|
self._cleanup_files()
|
|
|
|
stop = close
|
|
|
|
def __enter__(self) -> "AudioCppServerProcess":
|
|
return self.start()
|
|
|
|
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
self.close()
|
|
|
|
|
|
__all__ = [
|
|
"AudioCppProcessError",
|
|
"AudioCppProcessStartupError",
|
|
"AudioCppServerProcess",
|
|
"find_free_loopback_port",
|
|
"normalize_audio_cpp_task",
|
|
]
|