diff options
Diffstat (limited to 'app/backends')
| -rw-r--r-- | app/backends/managed.py | 162 | ||||
| -rw-r--r-- | app/backends/qwen.py | 26 |
2 files changed, 184 insertions, 4 deletions
diff --git a/app/backends/managed.py b/app/backends/managed.py new file mode 100644 index 0000000..3b98aea --- /dev/null +++ b/app/backends/managed.py @@ -0,0 +1,162 @@ +"""Start and stop a managed TTS server around a CLI conversion run. + +The TUI owns the server lifecycle through its Generate flow (the hub's +autostart plan boots the server in the run view, and the stop-and-exit +toggle shuts it down afterwards). This module gives the CLI the same +capability: when ``audiobook.py`` runs without ``--api-url`` it calls +``ensure_running`` to boot the selected backend's managed server — the +instance installed through the TUI — converts against it, and stops it +again afterwards. Only a server this run started is ever stopped: one +found already answering at the backend's configured URL is used as-is and +left running when the run ends, whoever started it. + +The qwen backend hosts one model per process, so its "already running" +check is model-aware: a managed demo hosting another model than the run +needs is stopped and rebooted with the right one (the TUI's Generate form +does the same), while a foreign server with the wrong model refuses the +run with an actionable message instead of letting the conversion fail +against the wrong endpoints. The other backends host all their models in +one server process, so any server answering at the configured URL is +usable as-is. + +All server boot/stop console output comes from ``backends.servers`` (the +same lines the TUI's console tail prints); this module only adds the +decisions around them. Like ``servers`` it is presentation-agnostic +enough to run in the plain console — the CLI's only caller. +""" + +from dataclasses import dataclass +from typing import Optional + +from backends import ServerSpec, common, probe, servers +from converter.clients import BACKEND_QWEN + + +@dataclass +class ManagedServer: + """The server-lifecycle outcome of one conversion run's boot phase. + + SPEC is the server spec the conversion targets (its URL is the + endpoint the converter uses when no ``--api-url`` overrides it). + STARTED is True when this run spawned (or rebooted) the process — + only then does ``shutdown`` stop it; a server found already running + belongs to whoever launched it and is left alone. OK is False when + the boot failed or was refused: the caller must not convert, and + ``shutdown`` is a no-op (``servers.start`` owns any pid file left + behind, mirroring the TUI's behavior for a failed boot). + """ + + spec: ServerSpec + started: bool = False + ok: bool = True + + def shutdown(self) -> None: + """Stop the server when this run started it (no-op otherwise).""" + if self.started: + servers.stop(self.spec.name) + + +def ensure_running(backend: str, voice_mode: str) -> Optional[ManagedServer]: + """Make the backend's managed server ready for a conversion run. + + Resolves the server spec for BACKEND (qwen: the demo hosting the + model VOICE_MODE needs; the others: their single configured spec), + then starts it when its port is free — waiting out the boot and + streaming ``servers``' console progress — or reuses the server + already answering there (qwen: restarting a managed server that hosts + another model, refusing a foreign one). Returns the run's + ``ManagedServer`` (call ``shutdown`` when the conversion is over), or + None when the backend is not installed here and nothing can be + started: the caller proceeds unmanaged, since a foreign server at the + configured endpoint may still answer and otherwise the conversion + fails with the converter's own unreachable-server message. + + Raises KeyboardInterrupt when the boot poll is interrupted (after + stopping a server this call spawned, so nothing is left loading). + """ + from backends import detect as _registry_detect + + status = _registry_detect(backend) + if status is None or not status.servers or not status.installed: + label = status.label if status is not None else backend + print(f"[WARNING] {label} is not installed — cannot start a server " + "automatically; the conversion will use the configured " + "endpoint (see the TUI's Configure Backends to install it).") + return None + spec = _spec_for(status, backend, voice_mode) + return _boot(spec, backend, voice_mode) + + +def _spec_for(status, backend: str, voice_mode: str) -> ServerSpec: + """The server spec this run needs, from STATUS's detected servers.""" + if backend != BACKEND_QWEN: + return status.servers[0] + # qwen hosts one model per process: aim the spec at the model this + # run selected rather than the default one detect() reports. + from backends import qwen + return qwen.build_spec(qwen.model_for_voice_mode(voice_mode)) + + +def _boot(spec: ServerSpec, backend: str, voice_mode: str) -> ManagedServer: + """Start or reuse the server SPEC describes, per the run's needs.""" + wanted_model = None + if backend == BACKEND_QWEN: + from backends import qwen + wanted_model = qwen.model_for_voice_mode(voice_mode) + + if common.server_running(spec.url): + if wanted_model is None: + print(f"[INFO] using the {spec.name} server already running " + f"at {spec.url}") + return ManagedServer(spec) + running_model = qwen.model_for_identity( + probe.identify_server(spec.url)) + if running_model == wanted_model: + print(f"[INFO] using the {spec.name} server already running " + f"at {spec.url} (hosting {wanted_model})") + return ManagedServer(spec) + if not servers.alive(spec.name): + print(f"[ERROR] a server this tool did not start is running at " + f"{spec.url} hosting {running_model or 'an unknown'} — " + f"this run needs {wanted_model}. Stop that server first, " + "or adjust the voice flags to use the hosted model.") + return ManagedServer(spec, ok=False) + # Ours: stop it and boot the newly-selected model on the same + # port (the TUI's Generate form restarts a managed server the + # same way when the run's model selection changes). + print(f"[INFO] restarting the {spec.name} server to host " + f"{wanted_model}...") + servers.stop(spec.name) + return _start(spec) + + +def _start(spec: ServerSpec) -> ManagedServer: + """Spawn SPEC's server and wait for readiness (``servers.start``). + + STARTED is recorded only when ``servers.start`` actually spawned the + process — its "running" event (a server appeared under us between the + caller's port check and the spawn) marks the run as a reuser, so + ``shutdown`` never stops a server this run did not start. A failed + boot reports False and leaves any pid file in place, exactly like the + TUI's boot path. + """ + spawned = {"yes": True} + + def _progress(event: dict) -> None: + if event.get("kind") == "running": + spawned["yes"] = False + # Same-package reuse of the console printer: without it a custom + # progress callback would silence the CLI's boot output. + servers._console_progress(event) + + try: + ok = servers.start(spec, progress=_progress) + except KeyboardInterrupt: + # Ctrl-C while waiting out the boot: kill what we spawned so no + # half-booted server is left loading in the background. Without a + # pid file nothing was spawned (or it exited already) — skip the + # noisy "not started by this tool" notice. + if servers.pid_for(spec.name) is not None: + servers.stop(spec.name) + raise + return ManagedServer(spec, started=spawned["yes"] and ok, ok=ok) diff --git a/app/backends/qwen.py b/app/backends/qwen.py index a4cddb5..a94b3cc 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -44,7 +44,8 @@ from backends import ( setup, ) from converter import config -from converter.clients import QWEN3_TTS_SPEAKERS +from converter.clients import (QWEN3_TTS_SPEAKERS, VOICE_MODE_CLONE, + VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN) from ui import taskview, tui QWEN_PIP_PKG = "qwen-tts" @@ -191,6 +192,18 @@ def model_for_identity(identity: Optional[str]) -> Optional[str]: return IDENTITY_TO_MODEL.get(identity) +def model_for_voice_mode(voice_mode: str) -> str: + """The MODEL_REPOS key a run with VOICE_MODE needs hosted. + + The mirror of ``model_for_identity`` for a planned (not yet running) + run: instructions design the voice (VoiceDesign), a reference .wav + clones (Base), otherwise built-in speakers (CustomVoice). + """ + return {VOICE_MODE_DESIGN: "VoiceDesign", + VOICE_MODE_CLONE: "Base", + VOICE_MODE_CUSTOM: "CustomVoice"}[voice_mode] + + def desired_identity(model: str) -> str: """The probe identity the model's demo answers as (used while booting).""" return { @@ -283,8 +296,13 @@ def build_parser() -> argparse.ArgumentParser: return parser -def _build_spec(model: str) -> ServerSpec: - """The single managed ServerSpec hosting MODEL on the configured port.""" +def build_spec(model: str) -> ServerSpec: + """The single managed ServerSpec hosting MODEL on the configured port. + + Public because callers outside this module (the hub's run preparation + and the CLI's managed-server bootstrap) need to boot exactly the model + their run selected, which can differ from the default one. + """ url = config.QWEN_API_URL return ServerSpec( "qwen", url, @@ -325,7 +343,7 @@ def detect() -> BackendStatus: "not installed — run setup to pip install qwen-tts") details.append(f"port: {_config_port(url, DEFAULT_PORT)}") details.append(f"default model: {model}") - specs = [_build_spec(model)] + specs = [build_spec(model)] managed = servers.manages(specs) # A locally-managed server names its running model via the probe of the # managed URL; a remotely-run demo names it via the remote-URL probe. |
