Files
diodiogod-TTS-Audio-Suite/utils/audio_cpp/process.py

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",
]