aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md6
-rw-r--r--app/backends/managed.py162
-rw-r--r--app/backends/qwen.py26
-rw-r--r--app/tests/test_audiobook_cli.py135
-rw-r--r--app/tests/test_backends.py2
-rw-r--r--app/tests/test_backends_managed.py283
-rw-r--r--app/ui/hub.py2
-rwxr-xr-xaudiobook.py42
8 files changed, 643 insertions, 15 deletions
diff --git a/README.md b/README.md
index 3ca8f60..b974d14 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ cd tts-audiobook-generator
## CLI Options
-Note: unlike the TUI, the CLI does **not** start or stop backend servers. You point it at a server that is already running with `--api-url`. You can manually launch local servers in the TUI too.
+Without `--api-url` the CLI manages the server itself, just like the TUI: it starts the selected backend's managed instance (installed via the TUI), converts, and stops it again. A server already running at the configured endpoint is used as-is and left running when the run ends. `--api-url` points at an external server instead, and never touches server state.
```bash
python audiobook.py --backend audiocpp --model qwen3_tts_1_7b_base_q8_0 --voice narrator
@@ -87,7 +87,7 @@ python audiobook.py --input-file the_odyssey.epub --output-file the_odyssey.mp3
| `--single-file` | | Merge all chapters into a single file. `m4b` is always one file. |
| `--language <lang>` | `English` | Output language for the synthesized speech. May add an accent even if the text is English. |
| `--debug` | | Dump each chunk's raw audio and sent text to `debug/` and log every request. Forces the `DEBUG` setting in `app/converter/config.py` on for this run. |
-| `--api-url <url>` | `http://10.20.30.40:8080` | URL of a remote TTS server. Accepts `host:port` or a full `http(s)://` URL. |
+| `--api-url <url>` | `http://10.20.30.40:8080` | URL of an external TTS server. Accepts `host:port` or a full `http(s)://` URL. Without it the CLI starts and stops the backend's managed server itself. |
| `--model <id>` | `qwen3_tts_1_7b_base_q8_0` | `audiocpp`: Choose the TTS model from `server.json` (required on multi-model servers; auto-selected on single-model servers) |
| `--option KEY=VALUE` | `emotion=neutral` | `audiocpp`: Some models support custom options with this (e.g. `emotion=neutral`) |
@@ -109,7 +109,7 @@ If the TUI auto-install doesn't work for some reason, you may need to set up the
- [qwen-tts instructions](app/docs/backend-qwen.md)
- [faster-qwen-tts instructions](app/docs/backend-faster.md)
-`./audiobook.py` can also connect to external servers running these backends if you want to completely manage your own install. The TUI will automatically look for and connect to the API URLs configured in settings. For CLI use, manually point at `--api-url` for an external server.
+`./audiobook.py` can also connect to external servers running these backends if you want to completely manage your own install. The TUI will automatically look for and connect to the API URLs configured in settings. For CLI use, point at `--api-url` for an external server; without it the CLI starts and stops a locally-installed backend server around the run.
## Development
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.
diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py
index d947c5d..f13cf9e 100644
--- a/app/tests/test_audiobook_cli.py
+++ b/app/tests/test_audiobook_cli.py
@@ -1,12 +1,13 @@
-"""Tests for the audiobook.py CLI — single-book flags and arg validation.
+"""Tests for the audiobook.py CLI — single-book flags, arg validation, and
+the managed-server wiring.
audiobook.py lives at the repo root (one level above app/), so the tests
bootstrap the root onto sys.path to import it. main() runs with the
managed-environment bootstrap stubbed (it would otherwise re-exec the
process into envs/tts) and convert() mocked, asserting only argparse
-behavior and what reaches convert(); convert()'s single-book wiring and
-the pre-flight overrides are tested against the real functions with
-temporary directories.
+behavior and what reaches convert(); convert()'s single-book wiring, the
+pre-flight overrides, and the manage_server lifecycle are tested against
+the real functions with temporary directories.
"""
import contextlib
@@ -250,6 +251,23 @@ class MainHappyPathTests(MainTestCase):
self.assertIsNone(kwargs["input_file"])
self.assertIsNone(kwargs["output_file"])
+ def test_manage_server_requested_without_api_url(self):
+ # Without --api-url the CLI asks convert() to manage the server
+ # lifecycle (convert() performs the actual boot/stop).
+ code, _, convert = self.run_main([])
+ self.assertEqual(code, 0)
+ self.assertIs(convert.call_args.kwargs["manage_server"], True)
+
+ def test_api_url_run_still_carries_the_manage_flag(self):
+ # The flag travels too; convert() itself skips management when an
+ # explicit api_url targets an external server.
+ code, _, convert = self.run_main(
+ ["--api-url", "10.20.30.40:8080"])
+ self.assertEqual(code, 0)
+ self.assertEqual(convert.call_args.kwargs["api_url"],
+ "http://10.20.30.40:8080")
+ self.assertIs(convert.call_args.kwargs["manage_server"], True)
+
class ConvertWiringTests(unittest.TestCase):
"""convert() turns the single-book flags into the pre-flight overrides."""
@@ -314,6 +332,115 @@ class ConvertWiringTests(unittest.TestCase):
self._convert(output_file=self.tmp / "dune.mp3")
+class ManagedServerWiringTests(unittest.TestCase):
+ """convert(manage_server=True) boots and stops the server around the run.
+
+ The lifecycle decisions live in backends.managed (tested there); these
+ pin convert()'s wiring: when the boot happens relative to pre-flight
+ and the conversion, that shutdown runs even on failure or Ctrl-C, and
+ that an external api_url or the hub's progress-callback path never
+ touch the server.
+ """
+
+ def setUp(self):
+ self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_managed_"))
+ self.addCleanup(shutil.rmtree, self.tmp, True)
+ self.book = _make_book(self.tmp)
+ self._old_folders = (converter_mod.BOOKS_FOLDER,
+ converter_mod.AUDIOBOOKS_FOLDER)
+ self.addCleanup(self._restore_folders)
+
+ def _restore_folders(self):
+ converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = \
+ self._old_folders
+
+ def _convert(self, *, server_ok=True, run_result=True, run_raises=None,
+ preflight_result=None, **kwargs):
+ """Run convert() with the managed-server and converter mocked.
+
+ Returns (code, ensure mock, server mock, events, preflight mock);
+ EVENTS records the order of ensure_running / run / shutdown.
+ """
+ kwargs.setdefault("backend", "audiocpp")
+ kwargs.setdefault("manage_server", True)
+ if preflight_result is None:
+ preflight_result = ([self.book], [(self.book, "dune")])
+ preflight = MagicMock(return_value=preflight_result)
+ fake_instance = MagicMock()
+ events = []
+
+ def _run():
+ events.append("run")
+ if run_raises is not None:
+ raise run_raises
+ return run_result
+ fake_instance.run.side_effect = _run
+ fake_class = MagicMock(return_value=fake_instance)
+ fake_class.preflight_overwrites = preflight
+ server = MagicMock()
+ server.ok = server_ok
+ server.shutdown.side_effect = lambda: events.append("shutdown")
+ ensure = MagicMock(return_value=server)
+
+ def _ensure(backend, voice_mode):
+ events.append(("ensure", backend, voice_mode))
+ return server
+ ensure.side_effect = _ensure
+ with patch.object(audiobook, "setup_logging"), \
+ patch.object(audiobook, "setup_directories"), \
+ patch.object(audiobook, "AudiobookConverter", fake_class), \
+ patch("backends.managed.ensure_running", ensure):
+ code = audiobook.convert(**kwargs)
+ return code, ensure, server, events, preflight
+
+ def test_server_boots_before_the_run_and_stops_after(self):
+ code, ensure, _, events, _ = self._convert()
+ self.assertEqual(code, 0)
+ self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
+ "run", "shutdown"])
+
+ def test_qwen_run_needs_its_voice_mode_model(self):
+ _, ensure, _, events, _ = self._convert(
+ backend="qwen", clone="ref.wav")
+ self.assertEqual(events[0], ("ensure", "qwen", "voice_clone"))
+
+ def test_not_ok_boot_stops_before_converting(self):
+ code, ensure, server, events, _ = self._convert(server_ok=False)
+ self.assertEqual(code, 1)
+ self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
+ "shutdown"])
+
+ def test_shutdown_runs_when_the_conversion_fails(self):
+ code, _, _, events, _ = self._convert(
+ run_raises=RuntimeError("server unreachable"))
+ self.assertEqual(code, 1)
+ self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
+ "run", "shutdown"])
+
+ def test_shutdown_runs_on_ctrl_c(self):
+ code, _, _, events, _ = self._convert(run_raises=KeyboardInterrupt)
+ self.assertEqual(code, 130)
+ self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
+ "run", "shutdown"])
+
+ def test_no_management_for_an_explicit_api_url(self):
+ code, ensure, _, _, _ = self._convert(
+ api_url="http://10.20.30.40:8080")
+ self.assertEqual(code, 0)
+ ensure.assert_not_called()
+
+ def test_no_management_for_the_hub_path(self):
+ # The run view boots/stops the server itself: manage_server False.
+ _, ensure, _, _, _ = self._convert(manage_server=False)
+ ensure.assert_not_called()
+
+ def test_no_server_boot_when_nothing_to_convert(self):
+ code, ensure, _, _, preflight = self._convert(
+ preflight_result=([], []))
+ self.assertEqual(code, 0)
+ ensure.assert_not_called()
+
+
class PreflightOverrideTests(unittest.TestCase):
"""preflight_overwrites honors the explicit book list and output name."""
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index 64fd483..7a39880 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -169,7 +169,7 @@ class DetectAllTests(unittest.TestCase):
for model, wanted in (("Base", IDENTITY_QWEN_CLONE),
("VoiceDesign", IDENTITY_QWEN_DESIGN)):
with self.subTest(model=model):
- spec = qwen._build_spec(model)
+ spec = qwen.build_spec(model)
self.assertEqual(spec.identity, wanted)
self.assertIn(qwen.MODEL_REPOS[model], spec.argv)
diff --git a/app/tests/test_backends_managed.py b/app/tests/test_backends_managed.py
new file mode 100644
index 0000000..cd1f03a
--- /dev/null
+++ b/app/tests/test_backends_managed.py
@@ -0,0 +1,283 @@
+"""Tests for the CLI's managed-server bootstrap (backends/managed.py).
+
+The CLI (without --api-url) calls ``ensure_running`` before converting and
+``ManagedServer.shutdown`` after; both are tested against mocked
+``servers``/``probe``/registry-detect so no process is ever spawned. The
+qwen paths exercise the real ``build_spec``/``model_for_voice_mode``
+mapping (pure config reads) to pin the model-per-voice-mode contract.
+"""
+
+import io
+import unittest
+from contextlib import redirect_stdout
+from types import SimpleNamespace
+from unittest.mock import ANY, patch
+
+from backends import ServerSpec, managed, servers
+from backends.managed import ManagedServer, ensure_running
+from backends.probe import (IDENTITY_AUDIOCPP, IDENTITY_QWEN_CLONE,
+ IDENTITY_QWEN_CUSTOM, IDENTITY_QWEN_DESIGN)
+from converter.clients import (BACKEND_AUDIOCPP, BACKEND_QWEN,
+ VOICE_MODE_CLONE, VOICE_MODE_CUSTOM,
+ VOICE_MODE_DESIGN)
+
+
+def _spec(name="audiocpp", url="http://127.0.0.1:8080", identity=None):
+ return ServerSpec(name, url, ["/bin/fake_server"], identity=identity)
+
+
+def _status(specs, installed=True, label="audio.cpp"):
+ """A minimal stand-in for the registry's BackendStatus."""
+ return SimpleNamespace(servers=specs, installed=installed, label=label)
+
+
+class ShutdownTests(unittest.TestCase):
+ """ManagedServer.shutdown stops only what the run started."""
+
+ def test_noop_when_not_started(self):
+ with patch.object(servers, "stop") as mk_stop:
+ ManagedServer(_spec()).shutdown()
+ mk_stop.assert_not_called()
+
+ def test_stops_the_spec_when_started(self):
+ spec = _spec()
+ with patch.object(servers, "stop") as mk_stop:
+ ManagedServer(spec, started=True).shutdown()
+ mk_stop.assert_called_once_with(spec.name)
+
+
+class EnsureRunningTests(unittest.TestCase):
+ """ensure_running resolves the spec and boots or reuses the server."""
+
+ def setUp(self):
+ self.spec = _spec(identity=IDENTITY_AUDIOCPP)
+
+ def _run(self, backend=BACKEND_AUDIOCPP, voice_mode="custom_voice"):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ result = ensure_running(backend, voice_mode)
+ return result, out.getvalue()
+
+ def test_not_installed_returns_none_with_warning(self):
+ with patch("backends.detect",
+ return_value=_status([self.spec], installed=False)), \
+ patch.object(servers, "start") as mk_start:
+ result, output = self._run()
+ self.assertIsNone(result)
+ self.assertIn("not installed", output)
+ mk_start.assert_not_called()
+
+ def test_unknown_backend_returns_none_with_warning(self):
+ with patch("backends.detect", return_value=None), \
+ patch.object(servers, "start") as mk_start:
+ result, output = self._run(backend="gone")
+ self.assertIsNone(result)
+ self.assertIn("not installed", output)
+ mk_start.assert_not_called()
+
+ def test_no_specs_returns_none_with_warning(self):
+ with patch("backends.detect",
+ return_value=_status([], installed=True)), \
+ patch.object(servers, "start") as mk_start:
+ result, _ = self._run()
+ self.assertIsNone(result)
+ mk_start.assert_not_called()
+
+ def test_starts_when_port_free(self):
+ with patch("backends.detect",
+ return_value=_status([self.spec])), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers, "start", return_value=True) as mk_start:
+ result, _ = self._run()
+ mk_start.assert_called_once_with(self.spec, progress=ANY)
+ self.assertTrue(result.ok)
+ self.assertTrue(result.started)
+ self.assertEqual(result.spec, self.spec)
+
+ def test_start_failure_reports_not_ok_and_never_stops(self):
+ with patch("backends.detect",
+ return_value=_status([self.spec])), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers, "start", return_value=False), \
+ patch.object(servers, "stop") as mk_stop:
+ result, _ = self._run()
+ result.shutdown()
+ self.assertFalse(result.ok)
+ self.assertFalse(result.started)
+ mk_stop.assert_not_called()
+
+ def test_running_server_is_reused_and_left_running(self):
+ with patch("backends.detect",
+ return_value=_status([self.spec])), \
+ patch("backends.common.server_running",
+ return_value=True), \
+ patch.object(servers, "start") as mk_start:
+ result, _ = self._run()
+ result.shutdown()
+ mk_start.assert_not_called()
+ self.assertTrue(result.ok)
+ self.assertFalse(result.started)
+
+ def test_running_event_marks_the_run_as_a_reuser(self):
+ """A server appearing under us between the port check and the spawn.
+
+ ``servers.start`` reports "running" instead of spawning in that
+ race; the run must then not stop the server at shutdown.
+ """
+ seen = []
+
+ def fake_start(spec, progress=None, cancel=None):
+ event = {"kind": "running", "name": spec.name, "url": spec.url}
+ seen.append(event)
+ if progress is not None:
+ progress(event)
+ return True
+
+ with patch("backends.detect",
+ return_value=_status([self.spec])), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers, "start", side_effect=fake_start), \
+ patch.object(servers, "_console_progress") as mk_print, \
+ patch.object(servers, "stop") as mk_stop:
+ result, _ = self._run()
+ result.shutdown()
+ self.assertTrue(result.ok)
+ self.assertFalse(result.started)
+ # The console still hears the event (delegated printer).
+ mk_print.assert_called_once_with(seen[0])
+ mk_stop.assert_not_called()
+
+ def test_keyboard_interrupt_stops_the_spawned_server(self):
+ with patch("backends.detect",
+ return_value=_status([self.spec])), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers, "start",
+ side_effect=KeyboardInterrupt), \
+ patch.object(servers, "pid_for", return_value=4242), \
+ patch.object(servers, "stop") as mk_stop, \
+ self.assertRaises(KeyboardInterrupt):
+ self._run()
+ mk_stop.assert_called_once_with(self.spec.name)
+
+ def test_keyboard_interrupt_without_spawn_skips_the_stop(self):
+ with patch("backends.detect",
+ return_value=_status([self.spec])), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers, "start",
+ side_effect=KeyboardInterrupt), \
+ patch.object(servers, "pid_for", return_value=None), \
+ patch.object(servers, "stop") as mk_stop, \
+ self.assertRaises(KeyboardInterrupt):
+ self._run()
+ mk_stop.assert_not_called()
+
+
+class QwenEnsureRunningTests(unittest.TestCase):
+ """qwen hosts one model per server: the running-model check is aware."""
+
+ def _detect_qwen(self):
+ from backends import qwen
+ return _status([qwen.build_spec("CustomVoice")],
+ installed=True, label="qwen-tts")
+
+ def _run(self, voice_mode, backend=BACKEND_QWEN):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ result = ensure_running(backend, voice_mode)
+ return result, out.getvalue()
+
+ def test_spec_aims_at_the_model_the_voice_mode_needs(self):
+ from backends import qwen
+ cases = [(VOICE_MODE_CLONE, "Base", IDENTITY_QWEN_CLONE),
+ (VOICE_MODE_CUSTOM, "CustomVoice", IDENTITY_QWEN_CUSTOM),
+ (VOICE_MODE_DESIGN, "VoiceDesign", IDENTITY_QWEN_DESIGN)]
+ for voice_mode, model, identity in cases:
+ with self.subTest(voice_mode=voice_mode):
+ with patch("backends.detect", return_value=self._detect_qwen()), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers, "start",
+ return_value=True) as mk_start:
+ result, _ = self._run(voice_mode)
+ spec = mk_start.call_args.args[0]
+ self.assertEqual(spec.identity, identity)
+ self.assertIn(qwen.MODEL_REPOS[model], spec.argv)
+ self.assertTrue(result.started)
+
+ def test_running_server_hosting_the_wanted_model_is_reused(self):
+ with patch("backends.detect", return_value=self._detect_qwen()), \
+ patch("backends.common.server_running", return_value=True), \
+ patch("backends.probe.identify_server",
+ return_value=IDENTITY_QWEN_CLONE), \
+ patch.object(servers, "start") as mk_start, \
+ patch.object(servers, "stop") as mk_stop:
+ result, _ = self._run(VOICE_MODE_CLONE)
+ result.shutdown()
+ mk_start.assert_not_called()
+ mk_stop.assert_not_called()
+ self.assertTrue(result.ok)
+ self.assertFalse(result.started)
+
+ def test_managed_server_hosting_another_model_is_rebooted(self):
+ with patch("backends.detect", return_value=self._detect_qwen()), \
+ patch("backends.common.server_running", return_value=True), \
+ patch("backends.probe.identify_server",
+ return_value=IDENTITY_QWEN_CUSTOM), \
+ patch.object(servers, "alive", return_value=True), \
+ patch.object(servers, "start", return_value=True) as mk_start, \
+ patch.object(servers, "stop") as mk_stop:
+ result, output = self._run(VOICE_MODE_CLONE)
+ self.assertIn("restarting", output)
+ mk_stop.assert_called_once_with("qwen")
+ spec = mk_start.call_args.args[0]
+ self.assertEqual(spec.identity, IDENTITY_QWEN_CLONE)
+ self.assertTrue(result.started)
+ # The rebooted server is this run's: shutdown stops it again.
+ with patch.object(servers, "stop") as mk_stop:
+ result.shutdown()
+ mk_stop.assert_called_once_with("qwen")
+
+ def test_foreign_server_hosting_another_model_refuses_the_run(self):
+ with patch("backends.detect", return_value=self._detect_qwen()), \
+ patch("backends.common.server_running", return_value=True), \
+ patch("backends.probe.identify_server",
+ return_value=IDENTITY_QWEN_CUSTOM), \
+ patch.object(servers, "alive", return_value=False), \
+ patch.object(servers, "start") as mk_start, \
+ patch.object(servers, "stop") as mk_stop:
+ result, output = self._run(VOICE_MODE_CLONE)
+ self.assertFalse(result.ok)
+ self.assertIn("this run needs Base", output)
+ mk_start.assert_not_called()
+ mk_stop.assert_not_called()
+
+ def test_unidentified_running_server_counts_as_unknown(self):
+ """A port squatting service that probes as nothing known refuses."""
+ with patch("backends.detect", return_value=self._detect_qwen()), \
+ patch("backends.common.server_running", return_value=True), \
+ patch("backends.probe.identify_server", return_value=None), \
+ patch.object(servers, "alive", return_value=False), \
+ patch.object(servers, "start") as mk_start:
+ result, output = self._run(VOICE_MODE_CLONE)
+ self.assertFalse(result.ok)
+ self.assertIn("an unknown", output)
+ mk_start.assert_not_called()
+
+
+class ManagedModuleSmokeTests(unittest.TestCase):
+ """Import-surface sanity for the module the CLI wires in."""
+
+ def test_managed_server_defaults(self):
+ server = ManagedServer(_spec())
+ self.assertFalse(server.started)
+ self.assertTrue(server.ok)
+
+ def test_module_uses_the_servers_module_singletons(self):
+ # managed delegates to the same servers module the TUI uses, so
+ # pid/log files and console output stay identical.
+ self.assertIs(managed.servers, servers)
diff --git a/app/ui/hub.py b/app/ui/hub.py
index f63ff99..5c312cf 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -1852,7 +1852,7 @@ def _prepare_run_config(backend: str, kwargs: dict
# run selected (same URL/port, matching probe identity), so an
# autostart or model-switch restart boots exactly what the run
# needs instead of the Start/Stop menu's default model.
- spec = qwen_backend._build_spec(_qwen_wanted_model(kwargs))
+ spec = qwen_backend.build_spec(_qwen_wanted_model(kwargs))
return runview.RunConfig(
backend=backend, backend_label=label, kwargs=kwargs,
book_files=book_files, planned=planned,
diff --git a/audiobook.py b/audiobook.py
index 5da78ae..36fc2df 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -9,6 +9,12 @@ process the input directory); pass flags to script a conversion directly.
directory (the two flag pairs are mutually exclusive). Edit
app/converter/config.py for server URLs and processing settings; the TTS
backend, model and voice are chosen per run with CLI flags.
+
+Without --api-url the CLI manages the server itself: it starts the
+backend's managed instance (the one installed via the TUI), converts, and
+stops it again — unless a server is already running at the configured
+endpoint, which is used as-is and left running. --api-url targets an
+externally-run server and never touches server state.
"""
import argparse
@@ -83,7 +89,7 @@ def convert(backend: str, voice: str = None, clone: str = None,
output_dir: Path = None, api_url: str = None,
input_file: Path = None, output_file: Path = None,
progress=None, cancel=None, confirm=None,
- book_files=None, planned=None) -> int:
+ book_files=None, planned=None, manage_server: bool = False) -> int:
"""Run one conversion pass with explicit options (used by the CLI and hub).
Returns the process exit code (0 on success, 1 on failure, 130 on
@@ -96,6 +102,15 @@ def convert(backend: str, voice: str = None, clone: str = None,
configured server URL for the selected backend (used by the hub's
"[remote]" entries and --api-url).
+ MANAGE_SERVER (the CLI, without --api-url) boots the backend's managed
+ server before converting — backends.managed.ensure_running — and stops
+ it again afterwards, but only when this run started it; a server
+ already answering at the configured endpoint is used as-is and left
+ running. It is skipped when API_URL is set (an external server is
+ never touched) and when nothing needs converting (the early returns
+ below run first, so an empty input folder never boots a server). The
+ hub leaves it False: its run view boots and stops the server itself.
+
INPUT_FILE converts a single book file instead of scanning the
input folder (the CLI validates it and resolves relative paths
against the project root). OUTPUT_FILE, which requires INPUT_FILE,
@@ -172,7 +187,18 @@ def convert(backend: str, voice: str = None, clone: str = None,
print("[INFO] Nothing to convert (all books skipped)")
return 0
+ # The CLI's server lifecycle (MANAGE_SERVER without API_URL): boot the
+ # managed instance before converting and shut it down when the run is
+ # over — success, failure, or Ctrl-C — via the finally below. A server
+ # already running is reused and left running (shutdown is a no-op for
+ # it), and a refused/failed boot (server.ok False) stops here.
+ server = None
try:
+ if manage_server and api_url is None:
+ from backends import managed
+ server = managed.ensure_running(backend, voice_mode)
+ if server is not None and not server.ok:
+ return 1
converter = AudiobookConverter(
voice_mode=voice_mode, voice_clone_ref_audio=clone,
voice_clone_ref_text=transcription,
@@ -201,6 +227,9 @@ def convert(backend: str, voice: str = None, clone: str = None,
if progress is not None:
progress({"kind": "error", "message": str(exc)})
return 1
+ finally:
+ if server is not None:
+ server.shutdown()
return 0 if ok else 1
@@ -240,6 +269,10 @@ Examples:
# No arguments, in a terminal: the full TUI (set up backends, convert).
python audiobook.py
+ # Without --api-url the CLI starts the backend's managed server (installed
+ # via the TUI), converts, and stops it again; a server already running at
+ # the configured endpoint is used as-is and left running.
+
# Use the audio.cpp audiocpp_server (a built-in speaker, or a server-side voice)
python audiobook.py --backend audiocpp --voice Vivian
@@ -401,7 +434,11 @@ Examples:
"endpoint for the selected backend. Accept a host:port "
"(e.g. 10.20.30.40:8080) or a full http(s):// URL. With "
"--backend qwen it overrides the endpoint for the active "
- "voice mode (custom or clone).")
+ "voice mode (custom or clone). Without it the CLI starts the "
+ "backend's managed server itself (when installed), converts, "
+ "and stops it again — unless a server is already running at "
+ "the configured endpoint, which is used as-is and left "
+ "running; an explicit --api-url never touches server state.")
)
args = parser.parse_args()
@@ -577,6 +614,7 @@ Examples:
input_dir=args.input, output_dir=args.output,
api_url=api_url,
input_file=input_file, output_file=output_file,
+ manage_server=True,
))