aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 02:59:26 -0400
committerhistoria <historiavg@proton.me>2026-08-24 02:59:26 -0400
commitf00249db9d1ea051d29aa1bcca869fc4b88e83eb (patch)
treea75f076fac1b63e0b4bf2eb8f54affbcc681a891 /app
parent9dd4f9595be3b1d76a3a07dc3eca90cfaf8a3f97 (diff)
downloadtts-audiobook-generator-f00249db9d1ea051d29aa1bcca869fc4b88e83eb.tar.gz
refactor: add app directory, dir structure change
Diffstat (limited to 'app')
-rw-r--r--app/backends/__init__.py146
-rwxr-xr-xapp/backends/audiocpp.py1731
-rw-r--r--app/backends/common.py269
-rw-r--r--app/backends/envs.py185
-rwxr-xr-xapp/backends/faster.py416
-rw-r--r--app/backends/qwen.py272
-rw-r--r--app/backends/servers.py244
-rw-r--r--app/converter/__init__.py1
-rw-r--r--app/converter/audio.py616
-rw-r--r--app/converter/chunking.py91
-rw-r--r--app/converter/config.py77
-rw-r--r--app/converter/converter.py782
-rw-r--r--app/converter/cover.py279
-rw-r--r--app/converter/extractors.py328
-rw-r--r--app/converter/tts.py1305
-rw-r--r--app/docs/backend-audiocpp.md91
-rw-r--r--app/docs/backend-faster.md45
-rw-r--r--app/docs/backend-qwen.md67
-rw-r--r--app/tests/__init__.py0
-rw-r--r--app/tests/cover_test.pngbin0 -> 6801 bytes
-rw-r--r--app/tests/gen_test_cover.py8
-rw-r--r--app/tests/test_audio.py524
-rw-r--r--app/tests/test_backends.py178
-rw-r--r--app/tests/test_backends_audiocpp.py1062
-rw-r--r--app/tests/test_backends_envs.py204
-rw-r--r--app/tests/test_backends_faster.py172
-rw-r--r--app/tests/test_backends_servers.py146
-rw-r--r--app/tests/test_chunking.py118
-rw-r--r--app/tests/test_cleaning.py54
-rw-r--r--app/tests/test_converter.py619
-rw-r--r--app/tests/test_cover.py189
-rw-r--r--app/tests/test_extractors.py213
-rw-r--r--app/tests/test_hub.py502
-rw-r--r--app/tests/test_tts.py1513
-rw-r--r--app/tests/test_tui.py661
-rw-r--r--app/ui/__init__.py9
-rw-r--r--app/ui/hub.py637
-rw-r--r--app/ui/tui.py1145
38 files changed, 14899 insertions, 0 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
new file mode 100644
index 0000000..ed772d4
--- /dev/null
+++ b/app/backends/__init__.py
@@ -0,0 +1,146 @@
+"""Registry of the TTS backends the audiobook generator can talk to.
+
+Each backend (audio.cpp, qwen, faster) lives in its own module and owns
+its setup wizard, its status detection, and the launch command it prints
+once configured. This package aggregates them into a single registry so
+``audiobook.py``'s TUI hub and future tools can iterate backends without
+hardcoding their names: ``backends.detect_all()`` reports which are set
+up (and whether their server is currently running), and the registry
+drives the hub's setup/configure menus.
+
+The registry is built lazily on the first call to ``get``/``detect_all``/
+``detect`` (not at package import time), because the backend modules pull
+in ``converter.tts`` and its third-party dependencies, which are only
+available inside the managed venv that ``audiobook.py`` bootstraps before
+importing them. ``backends.envs`` is imported during that bootstrap, so
+importing this package must stay cheap and dependency-free.
+
+Adding a backend: create ``backends/<name>.py`` exposing
+``detect() -> BackendStatus``, ``run_tui() -> int`` and
+``configure_actions: list[ConfigureAction]``, then append a ``BackendInfo`` in
+``_build_registry`` below. ``audiobook.py`` and the hub pick it up
+automatically.
+"""
+
+import shlex
+from dataclasses import dataclass, field
+from typing import Callable, List, Optional
+
+
+@dataclass
+class ServerSpec:
+ """One launchable server process for a backend.
+
+ A backend may expose more than one server (qwen runs CustomVoice and Base
+ on separate ports). ARGV is the exact command line the hub spawns (using
+ the managed venv's absolute binaries, so no shell activation is needed);
+ URL is the endpoint ``common.server_running`` probes to decide readiness.
+ """
+ name: str
+ url: str
+ argv: List[str]
+
+
+@dataclass
+class BackendStatus:
+ """How far a backend is set up, plus the command to start it.
+
+ INSTALLED means the backend itself is present (a cloned + built
+ checkout, or a pip package). CONFIGURED means the supporting files are
+ in place (a server.json / voices.json and an app/converter/config.py that
+ points at the right port). RUNNING means an external server is
+ currently accepting connections on the configured port (probed by
+ ``backends.common.server_running``). DETAILS are short status lines for
+ the hub. LAUNCH_HINT is the human-readable command(s) the user runs to
+ start the server, derived from SERVERS by ``format_launch_hint``.
+ SERVERS is the machine-usable list of server processes the hub can
+ start/stop (empty when the backend is not yet configured).
+ """
+ key: str
+ label: str
+ installed: bool
+ configured: bool
+ running: bool = False
+ details: List[str] = field(default_factory=list)
+ launch_hint: str = ""
+ servers: List[ServerSpec] = field(default_factory=list)
+
+ @property
+ def ready(self) -> bool:
+ """True when the backend is installed and configured for use."""
+ return self.installed and self.configured
+
+
+def format_launch_hint(servers: List[ServerSpec]) -> str:
+ """Join a backend's server argvs into a copy-pasteable launch hint."""
+ return " ; ".join(shlex.join(s.argv) for s in servers)
+
+
+@dataclass
+class ConfigureAction:
+ """A per-backend "configure" menu entry (e.g. "New server.json")."""
+ label: str
+ run: Callable[[], int]
+
+
+@dataclass
+class BackendInfo:
+ """One registry entry: identity, detector, setup wizard, configure menu."""
+ key: str
+ label: str
+ detect: Callable[[], BackendStatus]
+ setup_tui: Callable[[], int]
+ configure_actions: List[ConfigureAction] = field(default_factory=list)
+
+
+REGISTRY: List[BackendInfo] = []
+_BY_KEY: dict = {}
+
+
+def _build_registry() -> None:
+ """Import the backend modules and wire up REGISTRY (once)."""
+ if REGISTRY:
+ return
+ from . import audiocpp, faster, qwen
+
+ REGISTRY.append(BackendInfo(
+ key="audiocpp",
+ label="audio.cpp",
+ detect=audiocpp.detect,
+ setup_tui=audiocpp.run_tui,
+ configure_actions=audiocpp.configure_actions,
+ ))
+ REGISTRY.append(BackendInfo(
+ key="qwen",
+ label="qwen-tts",
+ detect=qwen.detect,
+ setup_tui=qwen.run_tui,
+ configure_actions=qwen.configure_actions,
+ ))
+ REGISTRY.append(BackendInfo(
+ key="faster",
+ label="faster-qwen3-tts",
+ detect=faster.detect,
+ setup_tui=faster.run_tui,
+ configure_actions=faster.configure_actions,
+ ))
+ for info in REGISTRY:
+ _BY_KEY[info.key] = info
+
+
+def get(key: str) -> Optional[BackendInfo]:
+ """Return the registry entry for KEY, or None."""
+ _build_registry()
+ return _BY_KEY.get(key)
+
+
+def detect_all() -> List[BackendStatus]:
+ """Detect every registered backend's status, in registry order."""
+ _build_registry()
+ return [info.detect() for info in REGISTRY]
+
+
+def detect(key: str) -> Optional[BackendStatus]:
+ """Detect a single backend by key."""
+ info = get(key)
+ return info.detect() if info is not None else None
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py
new file mode 100755
index 0000000..cc67efc
--- /dev/null
+++ b/app/backends/audiocpp.py
@@ -0,0 +1,1731 @@
+#!/usr/bin/env python3
+"""Set up the audio.cpp TTS backend for the audiobook generator.
+
+This does the whole audio.cpp setup end-to-end as a full-screen DOS-style
+TUI: locate or clone an audio.cpp checkout into ``app/audio.cpp``, optionally
+build ``audiocpp_server``, pick model families/packages from the checkout's
+``model_specs`` catalog, transcribe reference .wav voices, write
+``server.json``, sync ``app/converter/config.py``, download the models, and
+print the exact command to start the server. It is driven by
+``audiobook.py``'s TUI hub (``backends.REGISTRY``) but can also be run
+directly for scripting — every value has a flag, and a non-interactive run
+with all flags supplied never opens the TUI.
+
+The converter is family-agnostic (it detects the family of the selected
+entry from ``GET /v1/models`` at startup), so any TTS family listed in the
+catalog works without further changes.
+
+Usage:
+ python app/backends/audiocpp.py [--wavs WAV_DIR] [--output PATH]
+ [--audiocpp-dir PATH] [--clone] [--families FAM1,FAM2]
+ [--all-packages] [--host HOST] [--port PORT]
+ [--build-backend {cuda,vulkan,hip,cpu}] [--backend {cuda,vulkan,hip,cpu}]
+ [--lazy-load] [--whisper-model NAME] [--force]
+ [--download] [--no-sync-port] [--no-sync-model-ids]
+
+With no flags and a terminal, the TUI wizard runs. Without a terminal
+(or with all flags supplied), it runs non-interactively from the flags;
+any missing required value is a hard error with a remediation hint.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.parse
+from pathlib import Path
+from typing import Callable, Dict, List, Optional, Set, Tuple
+
+# Allow running directly (python app/backends/audiocpp.py) from any cwd.
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from backends import (
+ BackendStatus,
+ ConfigureAction,
+ ServerSpec,
+ common,
+ format_launch_hint,
+)
+from backends.common import (
+ APP_DIR,
+ CONFIG_PATH,
+ PROMPT_TEXT_FILENAME,
+ TTS_ROOT,
+ VOICES_DIR,
+ detect_wav_dir,
+ find_wav_files,
+ normalize_dir_arg,
+ read_prompt_text,
+ resolve_wav_dir_arg,
+ write_prompt_text,
+)
+from backends.common import (
+ wav_dir_info as _wav_dir_info,
+)
+from backends.common import (
+ wav_dir_preview as _wav_dir_preview,
+)
+from converter import config
+from converter.tts import transcribe_reference_audio, whisper_backend_available
+from ui import tui
+
+DEFAULT_HOST = "127.0.0.1"
+FALLBACK_PORT = 8080
+
+BACKENDS = ("cuda", "vulkan", "hip", "cpu")
+
+TASK_TTS = "tts"
+TASK_VDES = "vdes"
+
+# audio.cpp is cloned into a sibling directory of the audiobook generator.
+AUDIOCPP_DIR_NAME = "audio.cpp"
+AUDIOCPP_GIT_URL = "https://github.com/0xShug0/audio.cpp"
+
+# Sentinel returned by tui.confirm (via its cancel_value) when the user
+# presses Esc on an overwrite prompt to go back to the checkout browser
+# instead of aborting the wizard.
+_GO_BACK = object()
+
+
+class _GoBack(Exception):
+ """Raised inside the TUI wizard to fall back to the previous screen group.
+
+ Every wizard widget is passed ``back_value=_GO_BACK`` so Esc returns the
+ sentinel instead of aborting; pickers and confirmations that call into
+ callbacks (task/id pickers, the transcription plan, the download prompt)
+ convert that sentinel into this exception so the enclosing step can catch
+ it and step back. Only the first screen (the checkout browser) lets Esc
+ abort the whole wizard.
+ """
+
+# Package names that mark a voice-design model (hosted with task "vdes").
+DESIGN_PACKAGE_RE = re.compile(r"voice[\s_\-]?design", re.IGNORECASE)
+
+# Short, friendly default entry ids for selected families. Other families
+# derive an id from their family name (see default_model_id). All families
+# are listed equally, in alphabetical order.
+PREFERRED_IDS = {
+ "qwen3_tts": "qwen",
+ "higgs_audio_tts": "higgs",
+ "voxcpm2": "voxcpm2",
+ "index_tts2": "indextts2",
+}
+
+
+class _TuiError(Exception):
+ """A fatal error raised from inside the TUI wizard.
+
+ The message is reported to stderr after the terminal is restored; the
+ process exits with code 2 (matching a parser error).
+ """
+
+
+def _interactive() -> bool:
+ """True when the TUI wizard can run (curses importable + tty)."""
+ try:
+ import curses # noqa: F401
+ except ImportError:
+ return False
+ try:
+ return sys.stdin.isatty() and sys.stdout.isatty()
+ except (AttributeError, ValueError):
+ return False
+
+
+def _resolve_audiocpp_root(directory: Path) -> Optional[Path]:
+ """Return the audio.cpp checkout root for DIRECTORY, or None.
+
+ Accepts either the checkout root itself (it must contain a
+ ``model_specs`` directory) or the ``model_specs`` directory inside
+ it (the parent is used), so the file browser cannot pick the wrong
+ one of the two.
+ """
+ if (directory / "model_specs").is_dir():
+ return directory
+ if directory.name == "model_specs" and directory.is_dir():
+ return directory.parent
+ return None
+
+
+def _audiocpp_root_status(directory: Path) -> Tuple[str, str]:
+ """TUI status describing the directory listed in the checkout browser."""
+ if _resolve_audiocpp_root(directory) is not None:
+ return ("model_specs/ found here", "ok")
+ return ("No model_specs/ directory here", "warn")
+
+
+def _audiocpp_root_preview(directory: Path) -> Optional[Tuple[str, str]]:
+ """TUI status for a highlighted subdirectory in the checkout browser."""
+ if (directory / "model_specs").is_dir():
+ return ("contains model_specs/", "ok")
+ return None
+
+
+def _checkout_auto_select(entry: Path) -> Optional[Path]:
+ """Auto-accept a highlighted checkout in the TUI browser.
+
+ A subdirectory named ``audio.cpp`` that already contains a
+ ``model_specs`` directory is the audio.cpp checkout root, so it is
+ accepted immediately on Enter/Right (as if ``[ Use this directory ]``
+ had been pressed) instead of being descended into. Anything else
+ returns None so the user keeps browsing. This is only consulted
+ while auto-accepting is still enabled; after the user presses Esc to
+ go back, the browser is restarted inside the previously accepted
+ checkout and this callback is no longer passed, so a wrong guess can
+ be corrected.
+ """
+ if entry.name == "audio.cpp" and (entry / "model_specs").is_dir():
+ return entry
+ return None
+
+
+# Backend display order, with short descriptions. The backend name is padded
+# so the descriptions' dashes line up in the menu.
+_BACKEND_DESCRIPTIONS = (
+ ("cuda", "NVIDIA GPUs (fastest)"),
+ ("vulkan", "cross-vendor GPU"),
+ ("hip", "AMD GPUs"),
+ ("cpu", "no GPU required"),
+)
+
+
+def _backend_options(detected: Optional[str] = None
+ ) -> Tuple[List[Tuple[str, str]], int]:
+ """Build the aligned backend menu options and the default index.
+
+ The backend names are padded to a common width so the ``-`` dashes
+ before the descriptions line up. When DETECTED matches one of the
+ options, that option gets ``[auto-detected]`` appended and is the
+ default (cursor/start) selection; otherwise the first option is the
+ default as before. Returns (options, default_index).
+ """
+ width = max(len(name) for name, _ in _BACKEND_DESCRIPTIONS)
+ options: List[Tuple[str, str]] = []
+ default_index = 0
+ for index, (name, desc) in enumerate(_BACKEND_DESCRIPTIONS):
+ label = f"{name.ljust(width)} - {desc}"
+ if detected == name:
+ label += " [auto-detected]"
+ default_index = index
+ options.append((label, name))
+ return options, default_index
+
+
+def config_port() -> int:
+ """Return the port of AUDIOCPP_API_URL in app/converter/config.py."""
+ try:
+ return urllib.parse.urlsplit(config.AUDIOCPP_API_URL).port or FALLBACK_PORT
+ except ValueError:
+ return FALLBACK_PORT
+
+
+def _url_with_port(url: str, port: int) -> str:
+ parts = urllib.parse.urlsplit(url)
+ host = parts.hostname or "127.0.0.1"
+ return urllib.parse.urlunsplit(
+ (parts.scheme or "http", f"{host}:{port}", parts.path, "", ""))
+
+
+def update_config_api_url_port(port: int, config_path: Optional[Path] = None) -> bool:
+ """Rewrite the port inside AUDIOCPP_API_URL in app/converter/config.py.
+
+ Only the quoted URL literal is replaced; surrounding lines and the
+ trailing comment are preserved. Returns True when the file was changed.
+ """
+ path = Path(config_path) if config_path is not None else CONFIG_PATH
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError:
+ return False
+ match = re.search(r'(?m)^(\s*AUDIOCPP_API_URL\s*=\s*")([^"]*)(")', text)
+ if not match:
+ return False
+ new_url = _url_with_port(match.group(2), port)
+ if new_url == match.group(2):
+ return False
+ text = text[:match.start(2)] + new_url + text[match.end(2):]
+ try:
+ path.write_text(text, encoding="utf-8")
+ except OSError:
+ return False
+ return True
+
+
+def update_config_model_ids(model_id: str,
+ clone_model_id: Optional[str] = None,
+ config_path: Optional[Path] = None) -> bool:
+ """Rewrite AUDIOCPP_MODEL_ID (and AUDIOCPP_CLONE_MODEL_ID when given).
+
+ Only the quoted id literals are replaced; surrounding lines and
+ comments are preserved. Returns True when the file was changed.
+ """
+ path = Path(config_path) if config_path is not None else CONFIG_PATH
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError:
+ return False
+ updates: List[Tuple[str, str]] = [("AUDIOCPP_MODEL_ID", model_id)]
+ if clone_model_id is not None:
+ updates.append(("AUDIOCPP_CLONE_MODEL_ID", clone_model_id))
+ changed = False
+ for name, value in updates:
+ match = re.search(r'(?m)^(\s*' + name + r'\s*=\s*")([^"]*)(")', text)
+ if match and match.group(2) != value:
+ text = text[:match.start(2)] + value + text[match.end(2):]
+ changed = True
+ if not changed:
+ return False
+ try:
+ path.write_text(text, encoding="utf-8")
+ except OSError:
+ return False
+ return True
+
+
+def default_model_id(family: str) -> str:
+ """Derive a default server entry id from a family name."""
+ if family in PREFERRED_IDS:
+ return PREFERRED_IDS[family]
+ name = family
+ if name.endswith("_tts"):
+ name = name[:-4]
+ return name.replace("_", "") or family
+
+
+def detect_audiocpp_dir() -> Optional[Path]:
+ """Best-effort location of a local audio.cpp checkout with model_specs.
+
+ Checks the AUDIOCPP_DIR environment variable, then ``app/audio.cpp`` in
+ the tts-audiobook-generator root, then an ``audio.cpp`` directory in or
+ above the current working directory. Returns the path only when it
+ contains a ``model_specs`` directory.
+ """
+ candidates: List[Path] = []
+ env_dir = os.environ.get("AUDIOCPP_DIR")
+ if env_dir:
+ candidates.append(Path(os.path.expanduser(env_dir)))
+ candidates.append(APP_DIR / AUDIOCPP_DIR_NAME)
+ cwd = Path.cwd()
+ candidates.append(cwd / "audio.cpp")
+ candidates.append(cwd.parent / "audio.cpp")
+ candidates.append(cwd.parent.parent / "audio.cpp")
+ for candidate in candidates:
+ try:
+ resolved = candidate.resolve()
+ except OSError:
+ continue
+ if (resolved / "model_specs").is_dir():
+ return resolved
+ return None
+
+
+# audio.cpp build directories are named ``<platform>-<backend>-<type>`` (e.g.
+# ``linux-cuda-release``, ``windows-vulkan-debug``, ``macos-metal-release``)
+# and the built server lands in ``<that>/bin/audiocpp_server``. The Metal
+# macOS backend is reported as "cpu" here since it is not a separate
+# --backend choice for audiocpp_server.
+_BACKEND_TOKEN_RE = re.compile(r"-(cuda|vulkan|hip|cpu|metal)(?:-|$)")
+
+
+def detect_backend(audiocpp_dir: Path) -> Optional[str]:
+ """Best-effort detection of the backend audiocpp_server was built for.
+
+ Scans ``audiocpp_dir/build/*`` for build directories that contain a
+ built ``bin/audiocpp_server`` (``.exe`` allowed on Windows) and reads
+ the backend token out of the directory name (``-cuda-``, ``-vulkan-``,
+ ``-hip-`` or ``-cpu-``; ``-metal-`` is mapped to ``cpu``). Returns the
+ backend only when exactly one distinct backend was built, so a checkout
+ with builds for several backends does not silently pick one. Returns
+ None when there is no ``build/`` directory, no built server, or more
+ than one distinct backend.
+ """
+ build_root = audiocpp_dir / "build"
+ if not build_root.is_dir():
+ return None
+ backends: Set[str] = set()
+ try:
+ build_dirs = sorted(build_root.iterdir(),
+ key=lambda p: p.name.lower())
+ except OSError:
+ return None
+ for build_dir in build_dirs:
+ if not build_dir.is_dir():
+ continue
+ server = build_dir / "bin" / "audiocpp_server"
+ if not server.exists():
+ server_exe = build_dir / "bin" / "audiocpp_server.exe"
+ if not server_exe.exists():
+ continue
+ match = _BACKEND_TOKEN_RE.search(build_dir.name.lower())
+ if not match:
+ continue
+ token = match.group(1)
+ backends.add("cpu" if token == "metal" else token)
+ if len(backends) == 1:
+ return next(iter(backends))
+ return None
+
+
+def _default_package(packages: List[dict]) -> Optional[dict]:
+ """Pick the default package from a list of packages.
+
+ Prefers the package flagged ``default: true``, then the first GGUF
+ package, then the first package overall. Returns None for an empty list.
+ """
+ if not packages:
+ return None
+ for package in packages:
+ if package.get("default"):
+ return package
+ for package in packages:
+ if package.get("format") == "gguf":
+ return package
+ return packages[0]
+
+
+def load_model_catalog(audiocpp_dir: Path) -> List[dict]:
+ """Read model_specs/*.json and return the TTS-capable families.
+
+ Each returned entry has: family, display_name, description, languages,
+ clone_capable, packages (the full list from the spec), install_id
+ (recommended package id), default_path (``models/<target_directory>``),
+ and preferred_id. All families are treated equally and listed in
+ alphabetical order by display name.
+ """
+ specs_dir = audiocpp_dir / "model_specs"
+ if not specs_dir.is_dir():
+ raise NotADirectoryError(
+ f"{audiocpp_dir} has no model_specs/ directory; point "
+ "--audiocpp-dir at an audio.cpp checkout")
+ entries: List[dict] = []
+ for spec_path in sorted(specs_dir.glob("*.json")):
+ try:
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ continue
+ tasks = spec.get("tasks") or []
+ if "tts" not in tasks and spec.get("category") != "tts":
+ continue
+ family = spec.get("family") or spec_path.stem
+ packages = spec.get("packages") or []
+ package = _default_package(packages)
+ if package is None:
+ # No installable package: skip (cannot be hosted from a path).
+ continue
+ target_directory = package.get("target_directory") or family
+ languages = spec.get("languages") or []
+ display_name = spec.get("display_name") or family
+ description = spec.get("description") or ""
+ entries.append({
+ "family": family,
+ "display_name": display_name,
+ "description": description,
+ "languages": languages,
+ "tasks": list(tasks),
+ "clone_capable": "clone" in tasks,
+ "packages": packages,
+ "install_id": package.get("id") or family,
+ "default_path": f"models/{target_directory}",
+ "preferred_id": default_model_id(family),
+ })
+
+ # All families are treated equally: alphabetical by display name.
+ entries.sort(key=lambda entry: entry["display_name"].lower())
+ return entries
+
+
+def is_design_package(package: dict) -> bool:
+ """Return True when a package's name marks it a voice-design model.
+
+ audio.cpp voice-design packages (whose id, display name, or target
+ directory mentions "voice design") are the only packages that must be
+ hosted with task "vdes"; their role is not in the schema, only in those
+ strings, so it is detected from them.
+ """
+ text = " ".join(str(package.get(key, ""))
+ for key in ("id", "display_name", "target_directory"))
+ return bool(DESIGN_PACKAGE_RE.search(text))
+
+
+def package_dir_options(entry: dict) -> List[dict]:
+ """Return one option per distinct target_directory of a family's packages.
+
+ Each option is a dict with: target_directory, install_id (the recommended
+ package id inside that directory), design (voice-design package flag), and
+ recommended (whether it holds the family's default package). Precisions
+ that share a directory (q8_0/bf16/...) collapse to a single option.
+ """
+ packages = entry.get("packages") or []
+ default_pkg = _default_package(packages)
+ default_dir = (default_pkg or {}).get("target_directory") or entry["family"]
+ by_dir: Dict[str, List[dict]] = {}
+ order: List[str] = []
+ for package in packages:
+ directory = package.get("target_directory") or entry["family"]
+ if directory not in by_dir:
+ by_dir[directory] = []
+ order.append(directory)
+ by_dir[directory].append(package)
+ options: List[dict] = []
+ for directory in order:
+ package = _default_package(by_dir[directory])
+ options.append({
+ "target_directory": directory,
+ "install_id": (package or {}).get("id") or directory,
+ "design": is_design_package(package or {}),
+ "recommended": directory == default_dir,
+ })
+ # Put the recommended package first for a friendlier checklist.
+ options.sort(key=lambda opt: not opt["recommended"])
+ return options
+
+
+def build_model_entry(family: str, model_id: str, model_path: str,
+ task: str = TASK_TTS) -> dict:
+ """Assemble one server.json model entry.
+
+ ``task`` defaults to "tts"; voice design packages are hosted with
+ "vdes" so the server runs its design session for speech requests
+ (audiobook.py then requires --instructions with that entry).
+ """
+ return {
+ "id": model_id,
+ "family": family,
+ "path": model_path,
+ "task": task,
+ "mode": "offline",
+ }
+
+
+def build_server_config(host: str, port: int, backend: str, lazy_load: bool,
+ model_entries: List[dict],
+ voice_dir: Optional[str] = None) -> dict:
+ """Assemble the server.json document.
+
+ ``voice_dir`` is a server-level cloning voice library; when set, every
+ hosted clone-capable family can use its voices with ``--voice``.
+ """
+ config_doc = {
+ "host": host,
+ "port": port,
+ "backend": backend,
+ "lazy_load": lazy_load,
+ "models": model_entries,
+ }
+ if voice_dir:
+ config_doc["voice_dir"] = voice_dir
+ return config_doc
+
+
+def transcribe_wav_dir(wav_files: list, whisper_model: str) -> Dict[str, str]:
+ """Transcribe each wav file and return a mapping of stem -> transcript."""
+ transcripts: Dict[str, str] = {}
+ for wav_file in wav_files:
+ name = wav_file.stem
+ print(f"[INFO] Transcribing {wav_file.name} (voice '{name}')...")
+ text = transcribe_reference_audio(str(wav_file), model_name=whisper_model)
+ if text:
+ print(f"[OK] {name}: {text}")
+ else:
+ print(f"[WARNING] No transcript for '{name}'; cloning works best "
+ "with an accurate transcript — consider editing prompt_text "
+ "by hand before starting the server")
+ transcripts[name] = text or ""
+ return transcripts
+
+
+def print_empty_transcript_warning(transcripts: Dict[str, str]) -> None:
+ """Print a loud, final warning for voices whose transcript is empty."""
+ empty = sorted(name for name, text in transcripts.items() if not text)
+ if not empty:
+ return
+ bar = "=" * 70
+ print()
+ print(bar)
+ print("[WARNING] MANUAL TRANSCRIPTION REQUIRED")
+ print(bar)
+ listing = " - " + "\n - ".join(empty) if len(empty) > 1 else f" - {empty[0]}"
+ print(f"The following voice(s) have an EMPTY transcript in prompt_text:\n"
+ f"{listing}")
+ print("Those voices will NOT work until you add an accurate transcript.")
+ print(f"Edit {PROMPT_TEXT_FILENAME} in your voice directory and fill in the "
+ "text after '|' for each voice above.")
+ print(bar)
+
+
+def _apply_port_sync(port: int, accepted: bool) -> None:
+ """Write the port into app/converter/config.py, or report when declined."""
+ if accepted:
+ if not update_config_api_url_port(port):
+ print(f"[WARNING] Could not update {CONFIG_PATH}; edit "
+ "AUDIOCPP_API_URL by hand so audiobook.py uses the "
+ "new port")
+ else:
+ print("[WARNING] Left AUDIOCPP_API_URL unchanged; audiobook.py "
+ f"will still use port {config_port()}")
+
+
+def _decide_transcription(wav_files: list, existing: Dict[str, str],
+ prompt_exists: bool, force: bool,
+ confirm: Callable[[str, bool], bool]) -> dict:
+ """Decide which voices to transcribe; CONFIRM asks the plan questions.
+
+ Returns a plan dict: {"mode": "all"|"missing"|"keep", "missing":
+ [...], "existing": {...}} — "existing" carries the prompt_text
+ mapping read while deciding, so the caller can reuse it instead of
+ reading the file again.
+ """
+ mode = "all"
+ missing: List[Path] = []
+ if prompt_exists and not force:
+ missing = [wav for wav in wav_files
+ if not existing.get(wav.stem, "").strip()]
+ if not missing:
+ if confirm("All voices already transcribed in prompt_text. "
+ "Re-transcribe anyway?", False):
+ mode = "all"
+ else:
+ mode = "keep"
+ elif confirm("Existing transcription and new .wavs detected, "
+ "only transcribe new voices?", True):
+ mode = "missing"
+ else:
+ mode = "all"
+ return {"mode": mode, "missing": missing, "existing": existing}
+
+
+def _transcribe(args: argparse.Namespace, include_clone: bool,
+ plan: dict) -> Tuple[Dict[str, str], bool]:
+ """Transcribe the wav directory into a stem -> transcript mapping.
+
+ Returns the mapping and a flag indicating whether it should be written to
+ prompt_text (False when an existing, complete prompt_text is kept as-is).
+ PLAN is always pre-collected — by the TUI (via _decide_transcription and
+ its confirm callbacks) or by _flag_plan for a non-interactive run — so no
+ questions are asked here.
+ """
+ if not include_clone:
+ print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family "
+ "selected, so voice presets are not used")
+ return {}, False
+
+ wav_files = find_wav_files(args.input_dir)
+ if not wav_files:
+ print(f"[WARNING] No .wav files found in {args.input_dir}; writing the "
+ "config without a voice_dir")
+ return {}, False
+
+ prompt_path = args.input_dir / PROMPT_TEXT_FILENAME
+ existing = plan.get("existing") or {} if plan else {}
+
+ if plan["mode"] == "keep":
+ print(f"[INFO] Kept existing {prompt_path}; all voices were "
+ "already transcribed, nothing new to transcribe")
+ return existing, False
+
+ if whisper_backend_available() is None:
+ print("[WARNING] Neither faster_whisper nor whisper was found, so "
+ "reference .wav files cannot be transcribed automatically and "
+ "every transcript will be empty.")
+ print(" Install whisper (or faster_whisper) in your "
+ "audiobook environment to transcribe automatically; otherwise "
+ "transcripts must be added by hand (see the warning at the end).")
+
+ if plan["mode"] == "missing":
+ new_transcripts = transcribe_wav_dir(plan["missing"], args.whisper_model)
+ transcripts = dict(existing)
+ transcripts.update(new_transcripts)
+ else:
+ transcripts = transcribe_wav_dir(wav_files, args.whisper_model)
+ return transcripts, True
+
+
+def _flag_plan(wav_files: list, prompt_path: Path, force: bool) -> dict:
+ """Build a transcription plan for a non-interactive (flag-only) run.
+
+ With --force everything is re-transcribed; otherwise an existing
+ prompt_text is reused and only voices with an empty transcript are
+ re-transcribed, mirroring what the TUI confirms interactively.
+ """
+ if prompt_path.exists() and not force:
+ existing = read_prompt_text(prompt_path)
+ missing = [wav for wav in wav_files
+ if not existing.get(wav.stem, "").strip()]
+ if not missing:
+ return {"mode": "keep", "missing": [], "existing": existing}
+ return {"mode": "missing", "missing": missing, "existing": existing}
+ return {"mode": "all", "missing": [], "existing": {}}
+
+
+def _offer_config_model_id_sync(model_id: str, accepted: Optional[bool]) -> None:
+ """Point app/converter/config.py at a single hosted model entry.
+
+ The converter requests the model id configured in AUDIOCPP_MODEL_ID,
+ and single-model servers use the same id for the clone entry, so both
+ ids are rewritten together. ACCEPTED is True/False (apply/skip the
+ rewrite) or None when no single-entry sync applies (nothing to do).
+ """
+ if config.AUDIOCPP_MODEL_ID == model_id \
+ and config.AUDIOCPP_CLONE_MODEL_ID == model_id:
+ return
+ if accepted is None:
+ return
+ if accepted:
+ if not update_config_model_ids(model_id, model_id):
+ print(f"[WARNING] Could not update {CONFIG_PATH}; edit "
+ "AUDIOCPP_MODEL_ID and AUDIOCPP_CLONE_MODEL_ID by hand so "
+ "audiobook.py uses this model")
+ else:
+ print("[WARNING] Left the model ids unchanged; audiobook.py will "
+ f"still request model '{config.AUDIOCPP_MODEL_ID}'")
+
+
+def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]],
+ catalog_by_family: Dict[str, dict],
+ task_picker: Callable[[str], str],
+ id_picker: Callable[[str, str, str], str]
+ ) -> Tuple[List[dict], List[str], List[Tuple[str, str]],
+ List[str], bool]:
+ """Build server.json model entries from the selected families/packages.
+
+ TASK_PICKER is called for each design package to choose vdes/tts;
+ ID_PICKER resolves a duplicate server entry id. Returns (model_entries,
+ entry_ids, install_guidance, design_entry_ids, include_clone).
+ """
+ model_entries: List[dict] = []
+ entry_ids: List[str] = []
+ install_guidance: List[Tuple[str, str]] = []
+ design_entry_ids: List[str] = []
+ include_clone = False
+ for family in family_keys:
+ entry = catalog_by_family[family]
+ include_clone = include_clone or entry["clone_capable"]
+ for opt in chosen[family]:
+ task = task_picker(opt["install_id"]) if opt["design"] else TASK_TTS
+ base_id = (f"{entry['preferred_id']}-design"
+ if task == TASK_VDES else entry["preferred_id"])
+ model_id = base_id
+ if model_id in entry_ids:
+ model_id = id_picker(entry["display_name"], opt["install_id"],
+ f"{base_id}-2")
+ entry_ids.append(model_id)
+ model_entries.append(build_model_entry(
+ family, model_id, f"models/{opt['target_directory']}",
+ task=task))
+ install_guidance.append((entry["display_name"], opt["install_id"]))
+ if task == TASK_VDES:
+ design_entry_ids.append(model_id)
+ return (model_entries, entry_ids, install_guidance,
+ design_entry_ids, include_clone)
+
+
+def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path],
+ output_path: Path, model_entries: List[dict],
+ install_guidance: List[Tuple[str, str]], host: str,
+ port: int, backend: str, lazy_load: bool,
+ transcripts: Dict[str, str], write_prompt: bool) -> None:
+ """Console phase shared by both UI modes: write files, print summary.
+
+ After a successful run the console output is the path of the written
+ server.json. The model install commands (and optional automatic
+ download) are handled separately by _install_models, called by both
+ UI modes once the user has decided whether to download.
+ """
+ voice_dir: Optional[str] = None
+ if transcripts:
+ if write_prompt:
+ prompt_path = wav_dir / PROMPT_TEXT_FILENAME
+ write_prompt_text(wav_dir, transcripts)
+ print(f"[OK] Wrote {prompt_path}")
+ voice_dir = str(wav_dir.resolve())
+
+ server_config = build_server_config(
+ host=host, port=port, backend=backend, lazy_load=lazy_load,
+ model_entries=model_entries, voice_dir=voice_dir)
+
+ with output_path.open("w", encoding="utf-8") as handle:
+ json.dump(server_config, handle, indent=2, ensure_ascii=False)
+ handle.write("\n")
+
+ count = len(model_entries)
+ print(f"Wrote {output_path.resolve()} with {count} "
+ f"{'entry' if count == 1 else 'entries'}.")
+
+
+def _install_models(audiocpp_dir: Path,
+ install_guidance: List[Tuple[str, str]],
+ download: bool) -> None:
+ """Print and optionally run the model install commands.
+
+ One ``python <manager> install <id>`` command per hosted model (de-duped
+ by install id). When DOWNLOAD is True each command is run in the audio.cpp
+ checkout via ``subprocess.run`` so the models are downloaded automatically;
+ a failing install is reported as a warning and does not abort the remaining
+ downloads. When DOWNLOAD is False (or the model manager is missing) the
+ commands are only printed, copy-pasteable as before.
+ """
+ manager = audiocpp_dir / "tools" / "model_manager_v2.py"
+ seen: Set[str] = set()
+ install_ids: List[str] = []
+ for _, install_id in install_guidance:
+ if install_id not in seen:
+ seen.add(install_id)
+ install_ids.append(install_id)
+
+ if download and not manager.is_file():
+ print(f"[WARNING] {manager} not found; printing the install commands "
+ "instead of running them")
+ download = False
+
+ for install_id in install_ids:
+ command = f"python {manager} install {install_id}"
+ if not download:
+ print(command)
+ continue
+ print(f"[INFO] Downloading {install_id}...")
+ try:
+ result = subprocess.run(
+ [sys.executable, str(manager), "install", install_id],
+ cwd=str(audiocpp_dir))
+ except OSError as exc:
+ print(f"[WARNING] Could not run {command}: {exc}")
+ continue
+ if result.returncode != 0:
+ print(f"[WARNING] install {install_id} exited with code "
+ f"{result.returncode}; the model may need to be downloaded "
+ "by hand")
+
+
+def _decide_download(audiocpp_dir: Path,
+ confirm: Callable[[str, bool], bool]) -> bool:
+ """Ask whether to download the selected models now.
+
+ CONFIRM asks the yes/no question (ask_bool for the line prompts, a TUI
+ confirm for the wizard). When the audio.cpp model manager is missing the
+ prompt is skipped and False is returned, so the install commands are only
+ printed rather than offered to run.
+ """
+ manager = audiocpp_dir / "tools" / "model_manager_v2.py"
+ if not manager.is_file():
+ return False
+ return confirm(
+ "Automatically download the selected models with model_manager_v2.py "
+ "now?", False)
+
+
+def _build_tree_families(catalog: List[dict]) -> List[dict]:
+ """Shape the catalog into the checkbox_tree widget's family list."""
+ families: List[dict] = []
+ for entry in catalog:
+ capabilities = ["tts"]
+ if "clone" in entry["tasks"]:
+ capabilities.append("cloning")
+ if "design" in entry["tasks"]:
+ capabilities.append("design")
+ name = entry["display_name"]
+ if name != entry["family"]:
+ name = f"{name} ({entry['family']})"
+ options = []
+ for opt in package_dir_options(entry):
+ options.append({
+ "key": opt["target_directory"],
+ "label": opt["install_id"],
+ "recommended": opt["recommended"],
+ })
+ families.append({
+ "label": name,
+ "detail": ", ".join(capabilities),
+ "options": options,
+ })
+ return families
+
+
+def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
+ ) -> Optional[dict]:
+ """Run every TUI screen; return the collected settings, or None to abort.
+
+ The wizard is a step state machine; each screen group is one step, and
+ Esc anywhere but the first step falls back to the previous group (the
+ widget returns the _GO_BACK sentinel, or a callback raises _GoBack). On
+ the first screen (the audio.cpp checkout browser) Esc aborts the whole
+ wizard as before.
+ """
+
+ def ask_confirm(question: str, default: bool) -> bool:
+ result = tui.confirm(stdscr, question, default=default,
+ cancel_value=_GO_BACK)
+ if result is _GO_BACK:
+ raise _GoBack()
+ return result
+
+ step = 0
+ while True:
+ if step == 0:
+ # Checkout browser + the output path/overwrite confirmation. The
+ # browser asks for the checkout root and finds model_specs/ inside
+ # it (picking the model_specs directory itself works too — its
+ # parent is used). A highlighted subdirectory named "audio.cpp"
+ # that already contains model_specs/ is auto-accepted on
+ # Enter/Right, skipping the "[ Use this directory ]" step.
+ # Pressing Esc on an overwrite confirmation returns here instead
+ # of aborting: the browser then restarts inside the previously
+ # accepted checkout with auto-accept disabled, so a wrong guess
+ # can be corrected. An explicit --audiocpp-dir flag has no
+ # browser to return to, so Esc still aborts there. Esc on the
+ # browser itself is the first step, so it aborts the wizard.
+ auto_accept = True
+ browser_start: Path = Path.cwd()
+ force_browse = False
+
+ def do_browse():
+ return tui.browse_directory(
+ stdscr, "Select your audio.cpp directory",
+ validate=lambda p: None if _resolve_audiocpp_root(p)
+ else "No model_specs/ directory here",
+ info=_audiocpp_root_status,
+ preview=_audiocpp_root_preview,
+ help_lines=["The root folder of your audio.cpp "
+ "checkout;",
+ "it is the one that contains "
+ "model_specs/"],
+ start=browser_start,
+ auto_select=_checkout_auto_select if auto_accept
+ else None)
+
+ while True:
+ audiocpp_dir = args.audiocpp_dir
+ if audiocpp_dir is None and not force_browse:
+ audiocpp_dir = find_local_checkout()
+ if force_browse:
+ audiocpp_dir = None
+ if audiocpp_dir is None:
+ if force_browse:
+ # Esc on an overwrite confirmation came back here: go
+ # straight back into the browser inside the previously
+ # accepted checkout (auto-accept disabled).
+ audiocpp_dir = do_browse()
+ else:
+ # No checkout found anywhere: offer to clone one into
+ # ./app/audio.cpp or browse for an existing checkout.
+ # Esc on this first menu aborts the wizard.
+ choice = tui.menu(
+ stdscr, "No audio.cpp checkout found",
+ [(f"Clone into ./app/{AUDIOCPP_DIR_NAME} "
+ f"(from {AUDIOCPP_GIT_URL})", "clone"),
+ ("Browse for an existing checkout", "browse")],
+ help_lines=[
+ "audio.cpp hosts the TTS model families "
+ "this generator uses.",
+ "Clone it into the project's app "
+ "directory, or point at an existing "
+ "checkout."])
+ if choice == "clone":
+ target = APP_DIR / AUDIOCPP_DIR_NAME
+ with tui.suspend(stdscr):
+ rc = common.git_clone(AUDIOCPP_GIT_URL,
+ target)
+ if rc != 0:
+ raise _TuiError(
+ f"git clone failed (exit {rc}). Clone "
+ f"audio.cpp manually: git clone "
+ f"{AUDIOCPP_GIT_URL} {target}")
+ audiocpp_dir = target
+ else:
+ audiocpp_dir = do_browse()
+ audiocpp_dir = Path(audiocpp_dir).resolve()
+ if not audiocpp_dir.is_dir():
+ raise _TuiError(f"audio.cpp checkout not found: "
+ f"{audiocpp_dir}")
+ root = _resolve_audiocpp_root(audiocpp_dir)
+ if root is None:
+ raise _TuiError(
+ f"{audiocpp_dir} has no model_specs/ directory; "
+ "select the root of your audio.cpp checkout")
+ audiocpp_dir = root
+ try:
+ catalog = load_model_catalog(audiocpp_dir)
+ except NotADirectoryError as exc:
+ raise _TuiError(str(exc))
+ if not catalog:
+ raise _TuiError(f"No TTS model families found in "
+ f"{audiocpp_dir}/model_specs; check the "
+ "checkout is up to date")
+ catalog_by_family = {entry["family"]: entry
+ for entry in catalog}
+
+ output_path = args.output if args.output is not None \
+ else audiocpp_dir / "server.json"
+ esc_back = args.audiocpp_dir is None
+ went_back = False
+ if not args.force and output_path.exists():
+ decision = tui.confirm(
+ stdscr, f"{output_path} already exists. Overwrite?",
+ default=True,
+ cancel_value=_GO_BACK if esc_back else None)
+ if decision is _GO_BACK:
+ went_back = True
+ elif decision is False:
+ if args.output is None:
+ output_path = Path.cwd() / "server.json"
+ if output_path.exists():
+ decision = tui.confirm(
+ stdscr,
+ f"{output_path} already exists. "
+ "Overwrite?",
+ default=True,
+ cancel_value=_GO_BACK if esc_back else None)
+ if decision is _GO_BACK:
+ went_back = True
+ elif decision is False:
+ return None
+ else:
+ return None
+ if went_back:
+ auto_accept = False
+ browser_start = audiocpp_dir
+ force_browse = True
+ continue
+ break
+ detected_backend = detect_backend(audiocpp_dir)
+ step = 1
+ continue
+
+ if step == 1:
+ # Families and packages (flag or tree). Esc returns to the
+ # checkout browser (step 0).
+ chosen: Dict[str, List[dict]] = {}
+ if args.families is not None:
+ requested = [f.strip() for f in args.families.split(",")
+ if f.strip()]
+ unknown = [f for f in requested if f not in catalog_by_family]
+ if unknown:
+ raise _TuiError(
+ f"Unknown family in --families: {', '.join(unknown)}. "
+ f"Available: {', '.join(catalog_by_family)}")
+ family_keys: List[str] = []
+ for family in requested:
+ if family not in family_keys:
+ family_keys.append(family)
+ chosen[family] = [opt for opt in package_dir_options(
+ catalog_by_family[family]) if opt["recommended"]]
+ else:
+ tree_families = _build_tree_families(catalog)
+ picked = tui.checkbox_tree(
+ stdscr, "Select TTS model families to host",
+ tree_families, expand_all=args.all_packages,
+ back_value=_GO_BACK)
+ if picked is _GO_BACK:
+ step = 0
+ continue
+ family_keys = []
+ for family_index, option_key in picked:
+ family = catalog[family_index]["family"]
+ if family not in chosen:
+ chosen[family] = []
+ family_keys.append(family)
+ chosen[family].append(option_key)
+ for family in list(chosen):
+ keyed = {opt["target_directory"]: opt
+ for opt in package_dir_options(
+ catalog_by_family[family])}
+ chosen[family] = [keyed[key] for key in chosen[family]]
+ step = 2
+ continue
+
+ if step == 2:
+ # Design task menus and duplicate-id renames. Esc anywhere here
+ # falls back to the families tree (step 1).
+ def task_picker(install_id: str) -> str:
+ result = tui.menu(
+ stdscr,
+ f"How should the '{install_id}' package be hosted?",
+ [
+ ("design (vdes) - describe the voice with "
+ "--instructions", TASK_VDES),
+ ("tts - normal synthesis", TASK_TTS),
+ ], default_index=0, back_value=_GO_BACK)
+ if result is _GO_BACK:
+ raise _GoBack()
+ return result
+
+ def id_picker(display_name: str, install_id: str,
+ default: str) -> str:
+ result = tui.line_edit(
+ stdscr,
+ f"Server model id for {display_name} package "
+ f"'{install_id}'", default, back_value=_GO_BACK)
+ if result is _GO_BACK:
+ raise _GoBack()
+ return result
+
+ try:
+ model_entries, entry_ids, install_guidance, \
+ design_entry_ids, include_clone = _build_entries(
+ family_keys, chosen, catalog_by_family,
+ task_picker, id_picker)
+ except _GoBack:
+ step = 1
+ continue
+ step = 3
+ continue
+
+ if step == 3:
+ # Server settings (host, port, port-sync, backend, lazy). Esc on
+ # any of them falls back to the previous group (step 2).
+ if args.host:
+ host = args.host
+ else:
+ host = tui.line_edit(
+ stdscr, "Bind host", DEFAULT_HOST,
+ help_lines=["The IP address audiocpp will be hosted on",
+ "127.0.0.1 (this machine) is probably "
+ "correct"], back_value=_GO_BACK)
+ if host is _GO_BACK:
+ step = 2
+ continue
+ if args.port is not None:
+ port = args.port
+ else:
+ port_text = tui.line_edit(
+ stdscr, "Port", str(config_port()),
+ validate=lambda s: None if (s.isdigit()
+ and 1 <= int(s) <= 65535)
+ else "Enter a port number between 1 and 65535",
+ help_lines=["The port audiocpp will be hosted on"],
+ back_value=_GO_BACK)
+ if port_text is _GO_BACK:
+ step = 2
+ continue
+ port = int(port_text)
+ sync_port: Optional[bool] = None
+ if port != config_port():
+ sync_port = tui.confirm(
+ stdscr, f"Update AUDIOCPP_API_URL in app/converter/config.py "
+ f"to port {port} so audiobook.py talks to this server",
+ default=True, cancel_value=_GO_BACK)
+ if sync_port is _GO_BACK:
+ step = 2
+ continue
+ if args.build_backend:
+ backend = args.build_backend
+ build = detected_backend is None
+ elif args.backend:
+ backend = args.backend
+ build = False
+ elif detected_backend is not None:
+ # Already built: use the detected backend, no menu, no build.
+ backend = detected_backend
+ build = False
+ else:
+ backend_options, backend_default = _backend_options(None)
+ backend = tui.menu(
+ stdscr, "Which inference backend was audiocpp_server "
+ "built for?", backend_options,
+ default_index=backend_default, back_value=_GO_BACK)
+ if backend is _GO_BACK:
+ step = 2
+ continue
+ # Not built for any backend yet: offer to build it now. The
+ # build itself runs in the console tail after the wizard.
+ build = tui.confirm(
+ stdscr, f"audiocpp_server is not built for {backend}. "
+ f"Build it now (runs scripts/build_*)?",
+ default=True, cancel_value=_GO_BACK)
+ if build is _GO_BACK:
+ step = 2
+ continue
+ default_lazy = len(model_entries) > 1
+ if args.lazy_load:
+ lazy_load = True
+ else:
+ lazy_load = tui.confirm(
+ stdscr, "Load models lazily (on first use instead of at "
+ "startup)", default=default_lazy, cancel_value=_GO_BACK)
+ if lazy_load is _GO_BACK:
+ step = 2
+ continue
+ step = 4
+ continue
+
+ if step == 4:
+ # Wav directory (flag, browsed when cloning, else skipped). Esc
+ # falls back to the server settings (step 3).
+ if args.input_dir is not None:
+ wav_dir = args.input_dir
+ elif include_clone:
+ wav_start = detect_wav_dir(audiocpp_dir, TTS_ROOT)
+ wav_dir = tui.browse_directory(
+ stdscr, "Select the directory with your .wav voices",
+ info=_wav_dir_info, preview=_wav_dir_preview,
+ start=wav_start if wav_start is not None else VOICES_DIR,
+ back_value=_GO_BACK)
+ if wav_dir is _GO_BACK:
+ step = 3
+ continue
+ else:
+ wav_dir = None
+ step = 5
+ continue
+
+ if step == 5:
+ # Transcription plan (questions only; transcription runs after).
+ # Esc falls back to the wav browser (step 4).
+ plan: Optional[dict] = None
+ if include_clone and wav_dir is not None:
+ wav_files = find_wav_files(wav_dir)
+ if wav_files:
+ prompt_path = wav_dir / PROMPT_TEXT_FILENAME
+ existing = read_prompt_text(prompt_path) if (
+ prompt_path.exists() and not args.force) else {}
+ try:
+ plan = _decide_transcription(
+ wav_files, existing, prompt_path.exists(),
+ args.force, ask_confirm)
+ except _GoBack:
+ step = 4
+ continue
+ step = 6
+ continue
+
+ if step == 6:
+ # Single-model id sync decision. Esc falls back to the
+ # transcription plan (step 5).
+ sync_model_ids: Optional[bool] = None
+ if len(entry_ids) == 1 and not (
+ config.AUDIOCPP_MODEL_ID == entry_ids[0]
+ and config.AUDIOCPP_CLONE_MODEL_ID == entry_ids[0]):
+ sync_model_ids = tui.confirm(
+ stdscr, "Update AUDIOCPP_MODEL_ID and "
+ "AUDIOCPP_CLONE_MODEL_ID in app/converter/config.py to "
+ f"'{entry_ids[0]}' so audiobook.py uses this model",
+ default=True, cancel_value=_GO_BACK)
+ if sync_model_ids is _GO_BACK:
+ step = 5
+ continue
+ step = 8
+ continue
+
+ if step == 8:
+ # Automatic model download (or print the install commands). Esc
+ # falls back to the model-id sync (step 6).
+ try:
+ download = _decide_download(audiocpp_dir, ask_confirm)
+ except _GoBack:
+ step = 6
+ continue
+ return {
+ "audiocpp_dir": audiocpp_dir,
+ "catalog": catalog,
+ "catalog_by_family": catalog_by_family,
+ "output_path": output_path,
+ "family_keys": family_keys,
+ "chosen": chosen,
+ "model_entries": model_entries,
+ "entry_ids": entry_ids,
+ "install_guidance": install_guidance,
+ "design_entry_ids": design_entry_ids,
+ "include_clone": include_clone,
+ "host": host,
+ "port": port,
+ "backend": backend,
+ "build": build,
+ "lazy_load": lazy_load,
+ "sync_port": sync_port,
+ "sync_model_ids": sync_model_ids,
+ "wav_dir": wav_dir,
+ "plan": plan,
+ "download": download,
+ }
+
+
+def find_local_checkout() -> Optional[Path]:
+ """Best-effort location of an audio.cpp checkout with model_specs.
+
+ Checks the AUDIOCPP_DIR environment variable, then ``app/audio.cpp``
+ inside the tts-audiobook-generator root, then an ``audio.cpp`` directory
+ in or above the current working directory. Returns the path only when it
+ contains a ``model_specs`` directory.
+ """
+ candidates: List[Path] = []
+ env_dir = os.environ.get("AUDIOCPP_DIR")
+ if env_dir:
+ candidates.append(Path(os.path.expanduser(env_dir)))
+ candidates.append(APP_DIR / AUDIOCPP_DIR_NAME)
+ cwd = Path.cwd()
+ candidates.append(cwd / AUDIOCPP_DIR_NAME)
+ candidates.append(cwd.parent / AUDIOCPP_DIR_NAME)
+ candidates.append(cwd.parent.parent / AUDIOCPP_DIR_NAME)
+ for candidate in candidates:
+ try:
+ resolved = candidate.resolve()
+ except OSError:
+ continue
+ if (resolved / "model_specs").is_dir():
+ return resolved
+ return None
+
+
+def find_audiocpp_server_bin(audiocpp_dir: Path) -> Optional[Path]:
+ """Return the built audiocpp_server binary, or None when not built.
+
+ Scans ``audiocpp_dir/build/*`` for a build directory containing
+ ``bin/audiocpp_server`` (``.exe`` allowed on Windows). When several
+ builds exist the first (alphabetical) is returned.
+ """
+ build_root = audiocpp_dir / "build"
+ if not build_root.is_dir():
+ return None
+ try:
+ build_dirs = sorted(build_root.iterdir(),
+ key=lambda p: p.name.lower())
+ except OSError:
+ return None
+ for build_dir in build_dirs:
+ if not build_dir.is_dir():
+ continue
+ for name in ("audiocpp_server", "audiocpp_server.exe"):
+ server = build_dir / "bin" / name
+ if server.exists():
+ return server
+ return None
+
+
+def find_build_script(audiocpp_dir: Path) -> Optional[Path]:
+ """Return the audio.cpp build helper script to run, or None.
+
+ Prefers ``scripts/build_linux.sh``; otherwise the first
+ ``scripts/build_*.sh`` it finds. (Windows ``.bat`` scripts are not run
+ automatically — build manually there.)
+ """
+ scripts = audiocpp_dir / "scripts"
+ if not scripts.is_dir():
+ return None
+ preferred = scripts / "build_linux.sh"
+ if preferred.exists():
+ return preferred
+ try:
+ candidates = sorted(scripts.glob("build_*.sh"),
+ key=lambda p: p.name.lower())
+ except OSError:
+ return None
+ return candidates[0] if candidates else None
+
+
+def build_audiocpp(audiocpp_dir: Path, backend: str) -> int:
+ """Build audiocpp_server for BACKEND, streaming output to the console.
+
+ Returns the build script's exit code (non-zero when the script is
+ missing). Run from a console context (after the TUI wizard returns, or
+ inside ``tui.suspend``).
+ """
+ script = find_build_script(audiocpp_dir)
+ if script is None:
+ print(f"[ERROR] No build script found in {audiocpp_dir}/scripts; "
+ "build audiocpp_server manually (see the audio.cpp README)")
+ return 1
+ print(f"[INFO] Building audiocpp_server for {backend} "
+ f"({script} --backend {backend} --target audiocpp_server)...")
+ return common.run_console_subprocess(
+ ["sh", str(script), "--backend", backend, "--target",
+ "audiocpp_server"],
+ cwd=audiocpp_dir)
+
+
+def _print_launch_hint(audiocpp_dir: Path, output_path: Path) -> None:
+ """Print the exact command to start the server (or build guidance)."""
+ binary = find_audiocpp_server_bin(audiocpp_dir)
+ print()
+ if binary is not None:
+ print("Start the server with:")
+ print(f" {binary} --config {output_path}")
+ else:
+ print("[INFO] audiocpp_server binary not found. Build it first, e.g.:")
+ script = find_build_script(audiocpp_dir)
+ if script is not None:
+ print(f" sh {script} --backend <cuda|vulkan|hip|cpu> "
+ "--target audiocpp_server")
+ print(f" then run: ./build/<platform>-<backend>-release/bin/"
+ f"audiocpp_server --config {output_path}")
+
+
+def _execute(settings: dict, args: argparse.Namespace) -> int:
+ """Shared console tail: build, sync, transcribe, write, install, advise.
+
+ Runs after the TUI wizard returns (or after _collect_from_flags for a
+ non-interactive run): the terminal is plain, so subprocess output and
+ transcription progress appear normally.
+ """
+ audiocpp_dir = settings["audiocpp_dir"]
+
+ # Build audiocpp_server first (the longest step), when requested.
+ if settings.get("build"):
+ rc = build_audiocpp(audiocpp_dir, settings["backend"])
+ if rc != 0:
+ print(f"[WARNING] build exited with code {rc}; the server.json "
+ "was still written — build audiocpp_server manually before "
+ "starting it")
+ else:
+ print("[OK] build complete")
+
+ # Port sync (applied now that the terminal is back).
+ if settings["sync_port"] is True:
+ _apply_port_sync(settings["port"], True)
+ elif settings["sync_port"] is False:
+ _apply_port_sync(settings["port"], False)
+
+ # Transcription (console; the questions were already answered).
+ args.input_dir = settings["wav_dir"]
+ if settings["include_clone"] and args.input_dir is not None:
+ transcripts, write_prompt = _transcribe(args, True, plan=settings["plan"])
+ elif args.input_dir is not None:
+ print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family "
+ "selected, so voice presets are not used")
+ transcripts, write_prompt = {}, False
+ else:
+ transcripts, write_prompt = {}, False
+
+ _write_and_advise(
+ audiocpp_dir, settings["wav_dir"], settings["output_path"],
+ settings["model_entries"], settings["install_guidance"],
+ settings["host"], settings["port"], settings["backend"],
+ settings["lazy_load"], transcripts, write_prompt)
+
+ if len(settings["entry_ids"]) == 1:
+ _offer_config_model_id_sync(settings["entry_ids"][0],
+ settings["sync_model_ids"])
+ print_empty_transcript_warning(transcripts)
+ _install_models(audiocpp_dir, settings["install_guidance"],
+ settings["download"])
+ _print_launch_hint(audiocpp_dir, settings["output_path"])
+ return 0
+
+
+def run_tui(args: Optional[argparse.Namespace] = None,
+ parser: Optional[argparse.ArgumentParser] = None) -> int:
+ """Run the audio.cpp setup wizard end-to-end.
+
+ With no ARGS (the hub's call) a default namespace is built so the full
+ wizard runs. Called from ``main`` after argparse when the terminal is
+ interactive. Returns the process exit code.
+ """
+ import curses
+ if args is None:
+ parser = build_parser()
+ args = parser.parse_args([])
+ if args.input_dir is not None and not args.input_dir.is_dir():
+ print(f"[ERROR] --wavs not found: {args.input_dir}",
+ file=sys.stderr)
+ return 2
+ try:
+ settings = curses.wrapper(_wizard, args, parser)
+ except _TuiError as exc:
+ print(f"[ERROR] {exc}", file=sys.stderr)
+ return 2
+ except tui.WizardCancelled:
+ print("\n[INFO] Cancelled; nothing was written")
+ return 1
+ try:
+ curses.curs_set(1) # restore the text cursor hidden by the TUI
+ except curses.error:
+ pass
+ if settings is None:
+ print("[INFO] Aborted; existing server.json kept")
+ return 1
+ return _execute(settings, args)
+
+
+def _collect_from_flags(args: argparse.Namespace,
+ parser: argparse.ArgumentParser) -> Optional[dict]:
+ """Build the settings dict from flags for a non-interactive run.
+
+ Every required value must come from a flag (there are no prompts in a
+ non-interactive run); a missing one is a hard ``parser.error``. Returns
+ the settings dict, or None when the user declined an overwrite (the
+ default-location fallback then also exists).
+ """
+ # Checkout: --audiocpp-dir, else a local checkout, else --clone clones one.
+ audiocpp_dir = args.audiocpp_dir
+ if audiocpp_dir is None:
+ audiocpp_dir = find_local_checkout()
+ if audiocpp_dir is None and args.clone:
+ target = APP_DIR / AUDIOCPP_DIR_NAME
+ rc = common.git_clone(AUDIOCPP_GIT_URL, target)
+ if rc != 0:
+ parser.error(f"git clone failed (exit {rc}); clone audio.cpp "
+ f"manually: git clone {AUDIOCPP_GIT_URL} {target}")
+ audiocpp_dir = target
+ if audiocpp_dir is None:
+ parser.error(
+ "An audio.cpp checkout is required. Pass --audiocpp-dir PATH, "
+ "or --clone to clone app/audio.cpp, or run without flags for the "
+ "TUI wizard.")
+ audiocpp_dir = Path(audiocpp_dir).resolve()
+ if not audiocpp_dir.is_dir():
+ parser.error(f"audio.cpp checkout not found: {audiocpp_dir}")
+ root = _resolve_audiocpp_root(audiocpp_dir)
+ if root is None:
+ parser.error(f"{audiocpp_dir} has no model_specs/ directory; point "
+ "--audiocpp-dir at the root of an audio.cpp checkout")
+ audiocpp_dir = root
+ try:
+ catalog = load_model_catalog(audiocpp_dir)
+ except NotADirectoryError as exc:
+ parser.error(str(exc))
+ if not catalog:
+ parser.error(
+ f"No TTS model families found in {audiocpp_dir}/model_specs; "
+ "check the checkout is up to date")
+ catalog_by_family = {entry["family"]: entry for entry in catalog}
+
+ # Families: required from --families in a non-interactive run.
+ if args.families is None:
+ parser.error("--families is required in a non-interactive run (or run "
+ "without flags for the TUI wizard)")
+ requested = [f.strip() for f in args.families.split(",") if f.strip()]
+ unknown = [f for f in requested if f not in catalog_by_family]
+ if unknown:
+ parser.error(
+ f"Unknown family in --families: {', '.join(unknown)}. "
+ f"Available: {', '.join(catalog_by_family)}")
+ family_keys: List[str] = []
+ for fam in requested:
+ if fam not in family_keys:
+ family_keys.append(fam)
+
+ chosen: Dict[str, List[dict]] = {}
+ for family in family_keys:
+ opts = package_dir_options(catalog_by_family[family])
+ if args.all_packages:
+ chosen[family] = opts
+ else:
+ chosen[family] = [opt for opt in opts if opt["recommended"]]
+
+ # Non-interactive pickers: design packages default to vdes, dup ids get -2.
+ def task_picker(install_id: str) -> str:
+ return TASK_VDES
+
+ def id_picker(display_name: str, install_id: str, default: str) -> str:
+ return default
+
+ model_entries, entry_ids, install_guidance, design_entry_ids, include_clone = \
+ _build_entries(family_keys, chosen, catalog_by_family,
+ task_picker, id_picker)
+
+ # Server settings.
+ host = args.host or DEFAULT_HOST
+ detected_backend = detect_backend(audiocpp_dir)
+ if args.build_backend:
+ backend = args.build_backend
+ build = detected_backend is None
+ elif args.backend:
+ backend = args.backend
+ build = False
+ elif detected_backend is not None:
+ backend = detected_backend
+ build = False
+ else:
+ backend = "cuda"
+ build = False
+ port = args.port if args.port is not None else config_port()
+ lazy_load = args.lazy_load if args.lazy_load else (len(model_entries) > 1)
+
+ # Output path / overwrite (decline falls back to cwd, then aborts).
+ output_path = args.output if args.output is not None \
+ else audiocpp_dir / "server.json"
+ if output_path.exists() and not args.force:
+ if args.output is None:
+ output_path = Path.cwd() / "server.json"
+ if output_path.exists() and not args.force:
+ print("[INFO] Aborted; existing server.json kept")
+ return None
+ else:
+ print("[INFO] Aborted; existing server.json kept")
+ return None
+
+ # Config sync decisions (auto-apply unless explicitly declined).
+ sync_port: Optional[bool] = None
+ if port != config_port():
+ sync_port = not args.no_sync_port
+ sync_model_ids: Optional[bool] = None
+ if len(entry_ids) == 1 and not (
+ config.AUDIOCPP_MODEL_ID == entry_ids[0]
+ and config.AUDIOCPP_CLONE_MODEL_ID == entry_ids[0]):
+ sync_model_ids = not args.no_sync_model_ids
+
+ # Wav dir + transcription plan (defaults to the project's voices/ dir).
+ wav_dir = args.input_dir if args.input_dir is not None else VOICES_DIR
+ plan: Optional[dict] = None
+ if include_clone and wav_dir is not None:
+ wav_files = find_wav_files(wav_dir)
+ if wav_files:
+ prompt_path = wav_dir / PROMPT_TEXT_FILENAME
+ plan = _flag_plan(wav_files, prompt_path, args.force)
+
+ return {
+ "audiocpp_dir": audiocpp_dir,
+ "catalog": catalog,
+ "catalog_by_family": catalog_by_family,
+ "output_path": output_path,
+ "family_keys": family_keys,
+ "chosen": chosen,
+ "model_entries": model_entries,
+ "entry_ids": entry_ids,
+ "install_guidance": install_guidance,
+ "design_entry_ids": design_entry_ids,
+ "include_clone": include_clone,
+ "host": host,
+ "port": port,
+ "backend": backend,
+ "build": build,
+ "lazy_load": lazy_load,
+ "sync_port": sync_port,
+ "sync_model_ids": sync_model_ids,
+ "wav_dir": wav_dir,
+ "plan": plan,
+ "download": args.download,
+ }
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """The audio.cpp setup CLI (also used to build a default namespace)."""
+ parser = argparse.ArgumentParser(
+ description="Set up the audio.cpp TTS backend: clone/build, pick "
+ "models, write server.json, and sync app/converter/config.py.")
+ parser.add_argument("--wavs", type=resolve_wav_dir_arg, default=None,
+ dest="input_dir", metavar="WAV_DIR",
+ help="Directory with .wav reference files to publish as "
+ "a server-level voice_dir cloning library "
+ f"(default: {VOICES_DIR}; asked for when omitted "
+ "in the TUI)")
+ parser.add_argument("--output", type=Path, default=None,
+ help="Output path for server.json (default: "
+ "server.json inside the audio.cpp checkout; an "
+ "existing file is overwritten only with --force "
+ "or a TUI confirm)")
+ parser.add_argument("--audiocpp-dir", type=normalize_dir_arg, default=None,
+ help="Path to a local audio.cpp checkout containing a "
+ "model_specs/ directory (default: detected from "
+ "AUDIOCPP_DIR or ./app/audio.cpp; in the TUI you can "
+ "clone one instead)")
+ parser.add_argument("--clone", action="store_true",
+ help="Non-interactive: clone audio.cpp into "
+ "./app/audio.cpp when no checkout is found")
+ parser.add_argument("--families", type=str, default=None,
+ help="Comma-separated model families to host, as named "
+ "in the audio.cpp catalog (e.g. "
+ "qwen3_tts,higgs_audio_tts). Required in a "
+ "non-interactive run; skips the family tree in "
+ "the TUI")
+ parser.add_argument("--all-packages", action="store_true",
+ help="Host every installable package of each selected "
+ "family (distinct target_directory) instead of "
+ "only the recommended one. Voice-design packages "
+ "are hosted with task 'vdes'")
+ parser.add_argument("--host", type=str, default=None,
+ help="Bind host for the server (default: 127.0.0.1)")
+ parser.add_argument("--port", type=int, default=None,
+ help="Port for the server (default: the port in "
+ "AUDIOCPP_API_URL from app/converter/config.py)")
+ parser.add_argument("--backend", choices=BACKENDS, default=None,
+ help="Inference backend recorded in server.json "
+ "(default: auto-detected from the checkout's "
+ "build/ directory, else cuda)")
+ parser.add_argument("--build-backend", choices=BACKENDS, default=None,
+ help="Build audiocpp_server for this backend when it "
+ "is not built yet, and use it in server.json")
+ parser.add_argument("--lazy-load", action="store_true",
+ help="Load models on first use instead of at startup "
+ "(default: on when more than one model is hosted)")
+ parser.add_argument("--whisper-model", type=str, default="base",
+ help="Whisper model size for transcription "
+ "(default: base)")
+ parser.add_argument("--force", action="store_true",
+ help="Overwrite the output file (and prompt_text) "
+ "without prompting")
+ parser.add_argument("--download", action="store_true",
+ help="Run model_manager_v2.py install for each hosted "
+ "model automatically (default: print the commands "
+ "only)")
+ parser.add_argument("--no-sync-port", action="store_true",
+ help="Do not rewrite AUDIOCPP_API_URL in "
+ "app/converter/config.py when --port differs")
+ parser.add_argument("--no-sync-model-ids", action="store_true",
+ help="Do not rewrite AUDIOCPP_MODEL_ID/"
+ "AUDIOCPP_CLONE_MODEL_ID for a single-entry server")
+ return parser
+
+
+def detect() -> BackendStatus:
+ """Detect how far audio.cpp is set up, plus the command to start it."""
+ checkout = find_local_checkout()
+ # Probe the server first: it may be running externally even with no
+ # local checkout, and the status table should show that.
+ running = common.server_running(config.AUDIOCPP_API_URL)
+ details: List[str] = []
+ launch = ""
+ if checkout is None:
+ return BackendStatus("audiocpp", "audio.cpp", installed=False,
+ configured=False, running=running,
+ details=["not cloned — run setup to clone "
+ "./app/audio.cpp"])
+ details.append(f"checkout: {checkout}")
+ binary = find_audiocpp_server_bin(checkout)
+ built = binary is not None
+ if built:
+ details.append(f"built: {binary}")
+ else:
+ details.append("not built — run setup to build audiocpp_server")
+ server_json = checkout / "server.json"
+ configured = server_json.exists()
+ servers: List[ServerSpec] = []
+ if configured:
+ details.append(f"config: {server_json}")
+ if built:
+ servers = [ServerSpec(
+ "audiocpp", config.AUDIOCPP_API_URL,
+ [str(binary), "--config", str(server_json)])]
+ else:
+ launch = (f"./build/<platform>-<backend>-release/bin/"
+ f"audiocpp_server --config {server_json}")
+ else:
+ details.append("no server.json — run setup to configure models")
+ if servers:
+ launch = format_launch_hint(servers)
+ return BackendStatus("audiocpp", "audio.cpp", installed=built,
+ configured=configured, running=running,
+ details=details, launch_hint=launch,
+ servers=servers)
+
+
+configure_actions: List[ConfigureAction] = [
+ ConfigureAction("Reconfigure audio.cpp (models, voices, server.json)",
+ run_tui),
+]
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+
+ if args.input_dir is not None and not args.input_dir.is_dir():
+ parser.error(
+ f"WAV directory not found: {args.input_dir}\n"
+ f" (resolved from the current working directory: "
+ f"{Path.cwd()})\n"
+ " --wavs must be a directory containing the .wav "
+ "reference files to use as voice cloning presets")
+
+ if _interactive():
+ return run_tui(args, parser)
+
+ # Non-interactive (no terminal, or all flags supplied): flag-only path.
+ settings = _collect_from_flags(args, parser)
+ if settings is None:
+ return 1
+ return _execute(settings, args)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/app/backends/common.py b/app/backends/common.py
new file mode 100644
index 0000000..42faa7e
--- /dev/null
+++ b/app/backends/common.py
@@ -0,0 +1,269 @@
+"""Shared helpers for the backend setup wizards.
+
+Every TTS backend setup wizard (audio.cpp, qwen, faster) lives in its own
+module under ``backends``; this module holds the pieces more than one of
+them needs: .wav discovery, path normalization, and the regex edit that
+keeps ``app/converter/config.py`` in sync with the choices made in a wizard.
+It deliberately imports nothing from the other backend modules (or the
+TUI) so it can be reused without pulling curses into a non-interactive
+run.
+"""
+
+import os
+import re
+import urllib.parse
+from pathlib import Path
+from typing import Dict, List, Optional, Set, Tuple
+
+# The tts-audiobook-generator checkout root (where audiobook.py lives).
+# Everything non-user-facing lives under ./app: the source packages
+# (backends, converter, ui), the generated dirs (envs, chunks, logs, debug),
+# and the backend checkouts (app/audio.cpp, app/faster-qwen3-tts).
+TTS_ROOT = Path(__file__).resolve().parent.parent.parent
+
+# The single "everything else" directory under TTS_ROOT.
+APP_DIR = TTS_ROOT / "app"
+
+# The project's sample-voice directory: .wav files dropped here are offered
+# as the default source when a setup/configure wizard asks for a wav
+# directory (both the TUI browser start and the --wavs flag default).
+VOICES_DIR = TTS_ROOT / "voices"
+
+# app/converter/config.py — rewritten in place by update_config_value so the
+# converter picks up the host/port/voice a wizard configured.
+CONFIG_PATH = APP_DIR / "converter" / "config.py"
+
+# Output directory of tts-audiobook-generator; never offered as a .wav
+# source by detect_wav_dir.
+TTS_OUTPUT_DIR = "output"
+
+# The voice-transcript mapping file audio.cpp reads from its voice_dir.
+# (The faster backend uses voices.json instead; see backends.faster.)
+PROMPT_TEXT_FILENAME = "prompt_text"
+
+
+def normalize_dir_arg(value: str) -> Path:
+ """Normalize a user-supplied path argument.
+
+ Strips surrounding quotes (a common copy-paste artifact), expands a
+ leading ``~``, and resolves the result to an absolute path so relative
+ paths are always validated against the current working directory.
+ """
+ cleaned = value.strip()
+ if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in "\"'":
+ cleaned = cleaned[1:-1]
+ return Path(os.path.expanduser(cleaned)).resolve()
+
+
+def resolve_wav_dir_arg(value: str) -> Path:
+ """Normalize a user-supplied wav directory argument."""
+ return normalize_dir_arg(value)
+
+
+def find_wav_files(input_dir: Path) -> List[Path]:
+ """Return the .wav files in INPUT_DIR, sorted alphabetically by name."""
+ return sorted(
+ (path for path in input_dir.iterdir()
+ if path.is_file() and path.suffix.lower() == ".wav"),
+ key=lambda path: path.name.lower(),
+ )
+
+
+def count_wavs(directory: Path) -> int:
+ """Count the .wav files in DIRECTORY (0 when it cannot be read)."""
+ try:
+ return sum(1 for path in directory.iterdir()
+ if path.is_file() and path.suffix.lower() == ".wav")
+ except OSError:
+ return 0
+
+
+def detect_wav_dir(audiocpp_dir: Path, tts_root: Path) -> Optional[Path]:
+ """Find a unique directory that directly contains .wav files.
+
+ Looks shallowly (the root itself and its immediate subdirectories) in
+ both the audio.cpp checkout and the tts-audiobook-generator root (where
+ audiobook.py lives), since clone reference .wavs commonly live in
+ either. The tts-audiobook-generator ``output/`` directory is excluded.
+ When exactly one candidate is found it is returned (as a starting
+ directory for the .wav browser); when none or several are found None is
+ returned so the caller falls back to its default start location.
+ """
+ candidates: List[Path] = []
+ seen: Set[Path] = set()
+
+ def consider(directory: Path) -> None:
+ try:
+ resolved = directory.resolve()
+ except OSError:
+ return
+ if resolved in seen:
+ return
+ seen.add(resolved)
+ if count_wavs(directory) > 0:
+ candidates.append(directory)
+
+ for root in (audiocpp_dir, tts_root):
+ if not root.is_dir():
+ continue
+ consider(root)
+ try:
+ children = sorted(root.iterdir(), key=lambda p: p.name.lower())
+ except OSError:
+ continue
+ for child in children:
+ if not child.is_dir() or child.name.startswith("."):
+ continue
+ if root == tts_root and child.name == TTS_OUTPUT_DIR:
+ continue
+ consider(child)
+
+ if len(candidates) == 1:
+ return candidates[0]
+ return None
+
+
+def wav_dir_info(directory: Path) -> Tuple[str, str]:
+ """TUI status describing the directory listed in the wav browser."""
+ count = count_wavs(directory)
+ if count:
+ wavs = ".wav" if count == 1 else ".wavs"
+ return (f"{count} {wavs} found in this directory. Press Enter.",
+ "ok")
+ return ("No .wav files found in this directory", "warn")
+
+
+def wav_dir_preview(directory: Path) -> Tuple[str, str]:
+ """TUI status describing a highlighted subdirectory in the wav browser."""
+ count = count_wavs(directory)
+ if count:
+ wavs = ".wav" if count == 1 else ".wavs"
+ return (f"{count} {wavs}", "ok")
+ return ("no .wav files", "info")
+
+
+def url_with_port(url: str, port: int) -> str:
+ """Return URL with its port replaced/inserted as PORT."""
+ parts = urllib.parse.urlsplit(url)
+ host = parts.hostname or "127.0.0.1"
+ return urllib.parse.urlunsplit(
+ (parts.scheme or "http", f"{host}:{port}", parts.path, "", ""))
+
+
+def server_running(url: str, timeout: float = 0.3) -> bool:
+ """True when something accepts TCP connections at URL's host:port.
+
+ A protocol-agnostic socket connect: an HTTP TTS server that is up will
+ accept the connection (we do not need to speak HTTP to know it is
+ listening). Returns False on any parse or connection error, so a
+ misconfigured URL never blocks the hub — it just reports the backend
+ as not running. Used by each backend's ``detect()`` to set
+ ``BackendStatus.running``.
+ """
+ import socket
+ try:
+ parts = urllib.parse.urlsplit(url)
+ host = parts.hostname or "127.0.0.1"
+ port = parts.port or (443 if (parts.scheme or "http") == "https"
+ else 80)
+ except ValueError:
+ return False
+ try:
+ with socket.create_connection((host, port), timeout=timeout):
+ return True
+ except OSError:
+ return False
+
+
+def update_config_value(key: str, value: str,
+ config_path: Optional[Path] = None) -> bool:
+ """Rewrite a ``KEY = "value"`` line in app/converter/config.py.
+
+ Only the quoted literal is replaced; surrounding lines and the trailing
+ comment are preserved. Returns True when the file was changed. Used by
+ the qwen and faster wizards to keep their API URL / voice / speaker
+ settings in sync with the converter.
+ """
+ path = Path(config_path) if config_path is not None else CONFIG_PATH
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError:
+ return False
+ match = re.search(r'(?m)^(\s*' + re.escape(key) + r'\s*=\s*")([^"]*)(")',
+ text)
+ if not match or match.group(2) == value:
+ return False
+ text = text[:match.start(2)] + value + text[match.end(2):]
+ try:
+ path.write_text(text, encoding="utf-8")
+ except OSError:
+ return False
+ return True
+
+
+def read_prompt_text(prompt_path: Path) -> Dict[str, str]:
+ """Parse a prompt_text file into a stem -> transcript mapping.
+
+ Lines are ``<name>|<transcript>``; blank lines are skipped and a line
+ without a ``|`` separator is treated as a name with an empty transcript.
+ Returns an empty mapping when the file does not exist.
+ """
+ if not prompt_path.exists():
+ return {}
+ mapping: Dict[str, str] = {}
+ for line in prompt_path.read_text(encoding="utf-8").splitlines():
+ if not line.strip():
+ continue
+ if "|" in line:
+ name, _, text = line.partition("|")
+ else:
+ name, text = line, ""
+ mapping[name.strip()] = text
+ return mapping
+
+
+def write_prompt_text(wav_dir: Path,
+ transcripts: Dict[str, str]) -> Path:
+ """Write the voice_dir prompt_text mapping into WAV_DIR.
+
+ One ``<basename-without-extension>|<transcript>`` line per voice.
+ Returns the path of the written file.
+ """
+ prompt_path = wav_dir / PROMPT_TEXT_FILENAME
+ lines = [f"{name}|{text}" for name, text in transcripts.items()]
+ prompt_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ return prompt_path
+
+
+def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None) -> int:
+ """Run a subprocess whose output streams to the plain console.
+
+ Used inside ``tui.suspend`` for clone/build/pip steps: the caller has
+ already left curses mode, so the child inherits the real terminal and
+ its output appears normally. Returns the process exit code.
+ """
+ import subprocess
+ try:
+ result = subprocess.run(argv, cwd=str(cwd) if cwd is not None else None)
+ except OSError as exc:
+ print(f"[ERROR] Could not run {' '.join(argv)}: {exc}")
+ return 1
+ return result.returncode
+
+
+def git_clone(url: str, target: Path) -> int:
+ """Clone URL into TARGET, streaming to the console. Returns exit code."""
+ print(f"[INFO] Cloning {url} into {target}...")
+ return run_console_subprocess(["git", "clone", url, str(target)])
+
+
+def pip_install(packages: List[str]) -> int:
+ """pip install PACKAGES into the managed venv (``envs/tts``). Returns exit code.
+
+ Delegates to ``backends.envs.pip_install`` so backend TTS packages are
+ installed alongside the app requirements in the tool-managed environment
+ rather than into whatever interpreter happens to be running the wizard.
+ The import is local to avoid a circular import (envs imports this module).
+ """
+ from backends import envs
+ return envs.pip_install(packages)
diff --git a/app/backends/envs.py b/app/backends/envs.py
new file mode 100644
index 0000000..6e5b6cc
--- /dev/null
+++ b/app/backends/envs.py
@@ -0,0 +1,185 @@
+"""The managed Python environment for the audiobook generator and its backends.
+
+audiobook.py is meant to be launched from any Python (a bare system interpreter
+is fine): on startup it bootstraps a single tool-managed venv at
+``app/envs/tts`` and re-execs itself inside it. That venv holds both the
+audiobook app's own ``requirements.txt`` dependencies and the backend TTS
+packages (``qwen-tts``, ``faster-qwen3-tts[demo]``) the setup wizards pip
+install, so nothing is ever installed into the launching interpreter's
+environment.
+
+A parent process never needs to "activate" an environment — activation is
+just a shell convenience that puts an env's ``bin`` on PATH. Instead every
+helper here resolves the env's binaries by absolute path
+(``app/envs/tts/bin/python``, ``app/envs/tts/bin/qwen-tts-demo``), so the hub can
+spawn servers in this env from any parent environment.
+
+This module is imported before audiobook.py's third-party dependencies, so
+it must stay stdlib-only (it may import ``backends.common``, which is also
+stdlib-only, but never ``converter`` or the backend modules).
+"""
+
+import hashlib
+import os
+import sys
+from pathlib import Path
+from typing import List
+
+from backends import common
+
+# The tts-audiobook-generator checkout root (where audiobook.py lives).
+TTS_ROOT = Path(__file__).resolve().parent.parent.parent
+
+# One shared venv for the app requirements and every pip-installed backend.
+ENV_DIR = TTS_ROOT / "app" / "envs" / "tts"
+REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt"
+
+# Marker file recording the requirements.txt hash last installed into the env,
+# so ensure_app_env() re-installs when requirements.txt changes.
+MARKER_PATH = ENV_DIR / ".audiobook_env_ready"
+
+
+def _is_windows() -> bool:
+ return sys.platform == "win32"
+
+
+def env_python() -> Path:
+ """Absolute path to the venv's python interpreter."""
+ return ENV_DIR / ("Scripts/python.exe" if _is_windows() else "bin/python")
+
+
+def env_script(name: str) -> Path:
+ """Absolute path to a console script installed in the venv (e.g. qwen-tts-demo)."""
+ subdir = "Scripts" if _is_windows() else "bin"
+ suffix = ".exe" if _is_windows() else ""
+ return ENV_DIR / subdir / f"{name}{suffix}"
+
+
+def env_exists() -> bool:
+ """True when the venv's python interpreter is present on disk."""
+ return env_python().is_file()
+
+
+def is_managed_env() -> bool:
+ """True when the current process is already running inside the managed venv."""
+ try:
+ return Path(sys.executable).resolve() == env_python().resolve()
+ except OSError:
+ return False
+
+
+def create_env() -> int:
+ """Create the venv with the launching interpreter (inherits its version).
+
+ pip is bootstrapped inside the venv by ensurepip. Returns the ``python -m
+ venv`` exit code; a non-zero result is reported with platform remediation.
+ """
+ print(f"[INFO] creating managed environment at {ENV_DIR}...")
+ rc = common.run_console_subprocess(
+ [sys.executable, "-m", "venv", str(ENV_DIR)])
+ if rc != 0:
+ print(f"[ERROR] python -m venv failed (exit {rc}).")
+ if _is_windows():
+ print(" On Windows make sure the launcher has the venv module.")
+ else:
+ print(" On Debian/Ubuntu install the venv package, e.g.:")
+ print(" sudo apt install python3-venv")
+ return rc
+
+
+def install_requirements() -> int:
+ """pip install -r requirements.txt into the venv. Returns pip's exit code."""
+ print(f"[INFO] pip install -r {REQUIREMENTS_PATH} into {ENV_DIR}...")
+ return common.run_console_subprocess(
+ [str(env_python()), "-m", "pip", "install", "-r", str(REQUIREMENTS_PATH)])
+
+
+def pip_install(packages: List[str]) -> int:
+ """pip install PACKAGES into the venv, creating it first if needed.
+
+ Used by the qwen/faster setup wizards to install backend TTS packages
+ alongside the app requirements. Returns pip's exit code.
+ """
+ if not env_exists() and create_env() != 0:
+ return 1
+ print(f"[INFO] pip install {' '.join(packages)} into {ENV_DIR}...")
+ return common.run_console_subprocess(
+ [str(env_python()), "-m", "pip", "install", *packages])
+
+
+def module_available(module: str) -> bool:
+ """True when MODULE imports inside the venv (e.g. qwen_tts, faster_qwen3_tts).
+
+ A short subprocess probe against the venv's interpreter — the equivalent of
+ importlib.util.find_spec, but for the managed env rather than the current
+ one. Used by each backend's ``_is_installed``.
+ """
+ if not env_exists():
+ return False
+ import subprocess
+ try:
+ result = subprocess.run(
+ [str(env_python()), "-c", f"import {module}"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ timeout=30, check=False)
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+ return result.returncode == 0
+
+
+def _requirements_sha() -> str:
+ try:
+ data = REQUIREMENTS_PATH.read_bytes()
+ except OSError:
+ return ""
+ return hashlib.sha256(data).hexdigest()
+
+
+def _marker_valid() -> bool:
+ try:
+ return MARKER_PATH.read_text(encoding="utf-8").strip() == _requirements_sha()
+ except OSError:
+ return False
+
+
+def _write_marker() -> None:
+ try:
+ MARKER_PATH.write_text(_requirements_sha() + "\n", encoding="utf-8")
+ except OSError:
+ pass
+
+
+def ensure_app_env() -> None:
+ """Make sure the venv exists and has the current requirements.txt installed.
+
+ Creates the venv when missing, and (re)installs requirements.txt when it is
+ missing or has changed since the last install (tracked by a hash marker).
+ Raises RuntimeError on any failure so the caller can abort before re-exec.
+ """
+ if not env_exists() and create_env() != 0:
+ raise RuntimeError("could not create the managed environment")
+ if not _marker_valid():
+ if install_requirements() != 0:
+ raise RuntimeError("pip install -r requirements.txt failed")
+ _write_marker()
+
+
+def bootstrap(script_path: str) -> None:
+ """Run audiobook.py inside the managed venv, creating it first if needed.
+
+ A no-op when the current process is already the venv's interpreter. Otherwise
+ ensures the env (and requirements) are ready, then replaces the process with
+ the venv's python running the same script and CLI args. Called at the top of
+ audiobook.py before any third-party import.
+ """
+ if is_managed_env():
+ return
+ try:
+ ensure_app_env()
+ except RuntimeError as exc:
+ print(f"[FATAL] {exc}", file=sys.stderr)
+ sys.exit(1)
+ py = str(env_python())
+ target = str(Path(script_path).resolve())
+ print(f"[INFO] re-launching inside managed environment: {py}")
+ os.execv(py, [py, target, *sys.argv[1:]])
diff --git a/app/backends/faster.py b/app/backends/faster.py
new file mode 100755
index 0000000..0d34a0f
--- /dev/null
+++ b/app/backends/faster.py
@@ -0,0 +1,416 @@
+#!/usr/bin/env python3
+"""Set up the faster-qwen3-tts backend for the audiobook generator.
+
+faster-qwen3-tts is an OpenAI-compatible Qwen3-TTS server with CUDA-graph
+inference (NVIDIA GPU required). It always uses voice cloning, with the
+reference voice configured on the server through a ``voices.json``. This
+module sets the whole backend up end-to-end as a TUI: pip-install the
+package, clone the repo (for ``examples/openai_server.py``), build a
+``voices.json`` from a directory of .wav references (transcribed with
+Whisper), sync ``app/converter/config.py``, and print the launch command. It is
+driven by ``audiobook.py``'s hub but can also be run directly with flags.
+
+Usage:
+ python app/backends/faster.py [--wavs WAV_DIR] [--output PATH]
+ [--language LANG] [--whisper-model NAME] [--force]
+ [--port PORT] [--voice NAME] [--skip-install] [--skip-clone]
+"""
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import List, Optional
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from backends import (
+ BackendStatus,
+ ConfigureAction,
+ ServerSpec,
+ common,
+ envs,
+ format_launch_hint,
+)
+from backends.common import (
+ APP_DIR,
+ VOICES_DIR,
+ find_wav_files,
+ normalize_dir_arg,
+)
+from converter import config
+from converter.tts import (
+ normalize_language,
+ transcribe_reference_audio,
+ whisper_backend_available,
+)
+from ui import tui
+
+FASTER_DIR_NAME = "faster-qwen3-tts"
+FASTER_GIT_URL = "https://github.com/andimarafioti/faster-qwen3-tts"
+FASTER_PIP_PKG = "faster-qwen3-tts[demo]"
+WHISPER_MODELS = ("tiny", "base", "small", "medium", "large-v3")
+
+
+def _checkout() -> Path:
+ return APP_DIR / FASTER_DIR_NAME
+
+
+def _is_installed() -> bool:
+ return envs.module_available("faster_qwen3_tts")
+
+
+def _is_cloned() -> bool:
+ return (_checkout() / "examples" / "openai_server.py").is_file()
+
+
+def _config_port() -> int:
+ import urllib.parse
+ try:
+ return urllib.parse.urlsplit(config.FASTER_API_URL).port or 8000
+ except ValueError:
+ return 8000
+
+
+def build_voices(wav_files: list, language: str, whisper_model: str) -> dict:
+ """Transcribe each wav file and build the voices mapping."""
+ voices = {}
+ for wav_file in wav_files:
+ name = wav_file.stem
+ print(f"[INFO] Transcribing {wav_file.name} (voice '{name}')...")
+ text = transcribe_reference_audio(str(wav_file), model_name=whisper_model)
+ if text:
+ print(f"[OK] {name}: {text}")
+ else:
+ print(f"[WARNING] No transcript for '{name}'; the faster backend "
+ "strongly recommends an accurate transcript — consider "
+ "editing voices.json by hand before starting the server")
+ voices[name] = {
+ "ref_audio": str(wav_file.resolve()),
+ "ref_text": text or "",
+ "language": language,
+ }
+ return voices
+
+
+def _write_voices_json(output_path: Path, wav_dir: Path, language: str,
+ whisper_model: str, force: bool) -> Optional[dict]:
+ """Transcribe the wav dir and write voices.json; return the voices dict."""
+ wav_files = find_wav_files(wav_dir)
+ if not wav_files:
+ print(f"[ERROR] No .wav files found in {wav_dir}")
+ return None
+ if whisper_backend_available() is None:
+ print("[WARNING] Neither faster_whisper nor whisper was found, so "
+ "transcripts will be empty — install one or edit voices.json "
+ "by hand.")
+ voices = build_voices(wav_files, language, whisper_model)
+ with output_path.open("w", encoding="utf-8") as handle:
+ json.dump(voices, handle, indent=4, ensure_ascii=False)
+ handle.write("\n")
+ print(f"[OK] Wrote {output_path} with {len(voices)} voice(s): "
+ f"{', '.join(voices)}")
+ return voices
+
+
+def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
+ """Linear TUI wizard collecting every faster-setup decision."""
+ _GO_BACK = object()
+
+ def confirm(question: str, default: bool = True) -> Optional[bool]:
+ res = tui.confirm(stdscr, question, default=default,
+ cancel_value=_GO_BACK)
+ return None if res is _GO_BACK else res
+
+ # Step 0: pip install (if not installed and not skipped).
+ do_install = False
+ if not _is_installed() and not args.skip_install:
+ choice = confirm("faster-qwen3-tts is not installed. "
+ "pip install it now?", default=True)
+ if choice is None:
+ return None
+ do_install = choice
+
+ # Step 1: clone (if not cloned and not skipped).
+ do_clone = False
+ if not _is_cloned() and not args.skip_clone:
+ choice = confirm(f"faster-qwen3-tts repo not cloned. Clone it into "
+ f"./app/{FASTER_DIR_NAME}?", default=True)
+ if choice is None:
+ return None
+ do_clone = choice
+
+ # Step 2: voices.json — wav dir, language, whisper model, output path.
+ wav_dir = args.input_dir
+ if wav_dir is None:
+ wav_dir = tui.browse_directory(
+ stdscr, "Select the directory with your .wav voices",
+ info=common.wav_dir_info, preview=common.wav_dir_preview,
+ start=VOICES_DIR)
+ language = args.language
+ if language is None:
+ lang_text = tui.line_edit(
+ stdscr, "Language", config.LANGUAGE,
+ validate=lambda s: None if _try_language(s)
+ else "Unknown language (e.g. English, en)",
+ help_lines=["Language for every voice, as passed to the TTS "
+ "model (names or short codes accepted)"])
+ language = lang_text
+ whisper_model = args.whisper_model
+ if whisper_model is None:
+ whisper_model = tui.menu(
+ stdscr, "Whisper model for transcription",
+ [(m, m) for m in WHISPER_MODELS],
+ default_index=WHISPER_MODELS.index("base"))
+ output_path = args.output
+ if output_path is None:
+ # Default into the cloned checkout; fall back to the wav directory
+ # when the checkout is not present (so a flag-only run still works).
+ output_path = (_checkout() / "voices.json") if _is_cloned() \
+ else (wav_dir / "voices.json")
+ if output_path.exists() and not args.force:
+ choice = confirm(f"{output_path} already exists. Overwrite?",
+ default=True)
+ if choice is None or choice is False:
+ # Fall back to a path in the current directory.
+ output_path = Path.cwd() / "voices.json"
+
+ # Step 3: port + default voice.
+ port = args.port
+ if port is None:
+ port_text = tui.line_edit(
+ stdscr, "Server port", str(_config_port()),
+ validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535)
+ else "Enter a port number between 1 and 65535")
+ port = int(port_text)
+
+ return {
+ "do_install": do_install,
+ "do_clone": do_clone,
+ "wav_dir": wav_dir,
+ "language": language,
+ "whisper_model": whisper_model,
+ "output_path": output_path,
+ "port": port,
+ "force": args.force,
+ }
+
+
+def _try_language(value: str) -> bool:
+ try:
+ normalize_language(value)
+ return True
+ except ValueError:
+ return False
+
+
+def _execute(settings: dict) -> int:
+ """Console tail: install, clone, write voices.json, sync, advise."""
+ if settings["do_install"]:
+ rc = common.pip_install([FASTER_PIP_PKG])
+ if rc != 0:
+ print(f"[WARNING] pip install failed (exit {rc}); install "
+ f"{FASTER_PIP_PKG} manually")
+ else:
+ print("[OK] faster-qwen3-tts installed")
+
+ if settings["do_clone"]:
+ rc = common.git_clone(FASTER_GIT_URL, _checkout())
+ if rc != 0:
+ print(f"[WARNING] git clone failed (exit {rc}); clone manually: "
+ f"git clone {FASTER_GIT_URL} {_checkout()}")
+ else:
+ print(f"[OK] cloned into {_checkout()}")
+
+ voices = _write_voices_json(settings["output_path"], settings["wav_dir"],
+ settings["language"], settings["whisper_model"],
+ settings["force"])
+ if voices is None:
+ return 1
+
+ # Sync app/converter/config.py port + default voice.
+ port = settings["port"]
+ new_url = common.url_with_port(config.FASTER_API_URL, port)
+ if new_url != config.FASTER_API_URL:
+ if common.update_config_value("FASTER_API_URL", new_url):
+ print(f"[OK] Updated FASTER_API_URL to {new_url}")
+ else:
+ print("[WARNING] Could not update FASTER_API_URL; edit "
+ "app/converter/config.py by hand")
+ default_voice = next(iter(voices))
+ if default_voice != config.FASTER_VOICE:
+ if common.update_config_value("FASTER_VOICE", default_voice):
+ print(f"[OK] Updated FASTER_VOICE to {default_voice}")
+ else:
+ print("[WARNING] Could not update FASTER_VOICE; edit "
+ "app/converter/config.py by hand")
+
+ _print_launch_hint(settings["output_path"], port)
+ return 0
+
+
+def _print_launch_hint(voices_path: Path, port: int) -> None:
+ print()
+ if _is_cloned():
+ py = envs.env_python()
+ print("Start the server with (or use the hub's 'Server' menu):")
+ print(f" {py} {_checkout()}/examples/openai_server.py "
+ f"--voices {voices_path} --port {port}")
+ else:
+ print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,")
+ print(f" then run it with --voices {voices_path} --port {port}")
+
+
+def run_tui(args: Optional[argparse.Namespace] = None) -> int:
+ """Run the faster setup wizard end-to-end."""
+ import curses
+ if args is None:
+ args = build_parser().parse_args([])
+ try:
+ settings = curses.wrapper(_wizard, args)
+ except tui.WizardCancelled:
+ print("\n[INFO] Cancelled; nothing was written")
+ return 1
+ try:
+ curses.curs_set(1)
+ except curses.error:
+ pass
+ if settings is None:
+ print("[INFO] Aborted")
+ return 1
+ return _execute(settings)
+
+
+def _collect_from_flags(args: argparse.Namespace,
+ parser: argparse.ArgumentParser) -> Optional[dict]:
+ """Build the settings dict from flags for a non-interactive run."""
+ wav_dir = args.input_dir if args.input_dir is not None else VOICES_DIR
+ if not wav_dir.is_dir():
+ parser.error(f"WAV directory not found: {wav_dir}")
+ try:
+ language = normalize_language(args.language or config.LANGUAGE)
+ except ValueError as exc:
+ parser.error(str(exc))
+ output_path = args.output if args.output is not None \
+ else ((_checkout() / "voices.json") if _is_cloned()
+ else (wav_dir / "voices.json"))
+ if output_path.exists() and not args.force:
+ print("[INFO] Aborted; existing voices.json kept")
+ return None
+ return {
+ "do_install": (not _is_installed()) and not args.skip_install,
+ "do_clone": (not _is_cloned()) and not args.skip_clone,
+ "wav_dir": wav_dir,
+ "language": language,
+ "whisper_model": args.whisper_model or "base",
+ "output_path": output_path,
+ "port": args.port if args.port is not None else _config_port(),
+ "force": args.force,
+ }
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Set up the faster-qwen3-tts backend: pip install, clone, "
+ "build voices.json, and sync app/converter/config.py.")
+ parser.add_argument("input_dir", type=normalize_dir_arg, nargs="?",
+ default=None, metavar="WAV_DIR",
+ help="Directory with .wav reference files "
+ f"(default: {VOICES_DIR}; browsed for in the TUI)")
+ parser.add_argument("--output", type=Path, default=None,
+ help="Output path for voices.json (default: "
+ "./app/faster-qwen3-tts/voices.json, or "
+ "WAV_DIR/voices.json when not cloned)")
+ parser.add_argument("--language", type=str, default=None,
+ help="Language for all voices (default: English; "
+ "names and short codes accepted)")
+ parser.add_argument("--whisper-model", type=str, default=None,
+ choices=WHISPER_MODELS,
+ help="Whisper model size for transcription "
+ "(default: base)")
+ parser.add_argument("--force", action="store_true",
+ help="Overwrite an existing voices.json without "
+ "prompting")
+ parser.add_argument("--port", type=int, default=None,
+ help="Server port to record in app/converter/config.py "
+ "(default: the port in FASTER_API_URL)")
+ parser.add_argument("--skip-install", action="store_true",
+ help="Do not pip install faster-qwen3-tts[demo]")
+ parser.add_argument("--skip-clone", action="store_true",
+ help="Do not clone the faster-qwen3-tts repo")
+ return parser
+
+
+def detect() -> BackendStatus:
+ """Detect how far faster-qwen3-tts is set up, plus the launch command."""
+ installed = _is_installed()
+ cloned = _is_cloned()
+ voices_json = _checkout() / "voices.json"
+ configured = installed and cloned and voices_json.exists()
+ running = common.server_running(config.FASTER_API_URL)
+ details: List[str] = []
+ details.append("pip: installed" if installed else
+ "not installed — run setup to pip install")
+ details.append(f"checkout: {_checkout()}" if cloned else
+ f"not cloned — run setup to clone ./app/{FASTER_DIR_NAME}")
+ details.append(f"voices: {voices_json}" if voices_json.exists() else
+ "no voices.json — run setup to create one")
+ launch = ""
+ servers: List[ServerSpec] = []
+ if cloned and voices_json.exists():
+ argv = [str(envs.env_python()),
+ str(_checkout() / "examples" / "openai_server.py"),
+ "--voices", str(voices_json), "--port", str(_config_port())]
+ servers = [ServerSpec("faster", config.FASTER_API_URL, argv)]
+ launch = format_launch_hint(servers)
+ return BackendStatus("faster", "faster-qwen3-tts",
+ installed=installed and cloned,
+ configured=configured, running=running,
+ details=details, launch_hint=launch,
+ servers=servers)
+
+
+def _run_voices_only_tui() -> int:
+ """Rebuild voices.json via the TUI (the "configure" action).
+
+ Runs the same wizard but skips the pip/clone prerequisites so it goes
+ straight to picking the .wav directory and writing voices.json.
+ """
+ args = build_parser().parse_args([])
+ args.skip_install = True
+ args.skip_clone = True
+ return run_tui(args)
+
+
+configure_actions: List[ConfigureAction] = [
+ ConfigureAction("Rebuild voices.json", _run_voices_only_tui),
+ ConfigureAction("Reconfigure faster-qwen3-tts", run_tui),
+]
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+
+ if _interactive():
+ return run_tui(args)
+
+ settings = _collect_from_flags(args, parser)
+ if settings is None:
+ return 1
+ return _execute(settings)
+
+
+def _interactive() -> bool:
+ try:
+ import curses # noqa: F401
+ except ImportError:
+ return False
+ try:
+ return sys.stdin.isatty() and sys.stdout.isatty()
+ except (AttributeError, ValueError):
+ return False
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
new file mode 100644
index 0000000..21280a0
--- /dev/null
+++ b/app/backends/qwen.py
@@ -0,0 +1,272 @@
+#!/usr/bin/env python3
+"""Set up the Qwen3-TTS demo backend for the audiobook generator.
+
+qwen-tts is a pip package providing the ``qwen-tts-demo`` server, which
+hosts the Qwen3-TTS CustomVoice (built-in speakers) and Base (voice
+cloning) models on separate ports. This module sets it up end-to-end as a
+TUI: pip-install the package, configure the two ports and the built-in
+speaker in ``app/converter/config.py``, and print the launch commands. It is
+driven by ``audiobook.py``'s hub but can also be run directly with flags.
+
+Usage:
+ python app/backends/qwen.py [--port-custom PORT] [--port-clone PORT]
+ [--speaker NAME] [--skip-install]
+"""
+
+import argparse
+import sys
+from pathlib import Path
+from typing import List, Optional
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from backends import (
+ BackendStatus,
+ ConfigureAction,
+ ServerSpec,
+ common,
+ envs,
+ format_launch_hint,
+)
+from converter import config
+from ui import tui
+
+QWEN_PIP_PKG = "qwen-tts"
+QWEN_CUSTOMVOICE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
+QWEN_BASE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
+DEFAULT_CUSTOM_PORT = 7860
+DEFAULT_CLONE_PORT = 7861
+
+# Built-in CustomVoice speakers (see app/converter/config.py SPEAKER).
+QWEN_SPEAKERS = ("Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric", "Ryan",
+ "Aiden", "Ono_Anna", "Sohee")
+
+
+def _is_installed() -> bool:
+ if envs.env_script("qwen-tts-demo").is_file():
+ return True
+ return envs.module_available("qwen_tts")
+
+
+def _config_port(url: str, fallback: int) -> int:
+ import urllib.parse
+ try:
+ return urllib.parse.urlsplit(url).port or fallback
+ except ValueError:
+ return fallback
+
+
+def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
+ """Linear TUI wizard collecting every qwen-setup decision."""
+ _GO_BACK = object()
+
+ def confirm(question: str, default: bool = True) -> Optional[bool]:
+ res = tui.confirm(stdscr, question, default=default,
+ cancel_value=_GO_BACK)
+ return None if res is _GO_BACK else res
+
+ # Step 0: pip install (if not installed and not skipped).
+ do_install = False
+ if not _is_installed() and not args.skip_install:
+ choice = confirm("qwen-tts is not installed. pip install it now?",
+ default=True)
+ if choice is None:
+ return None
+ do_install = choice
+
+ # Step 1: ports.
+ custom_port = args.port_custom
+ if custom_port is None:
+ port_text = tui.line_edit(
+ stdscr, "CustomVoice (built-in speaker) port",
+ str(_config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT)),
+ validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535)
+ else "Enter a port number between 1 and 65535",
+ help_lines=["The port for qwen-tts-demo CustomVoice (speaker mode)"])
+ custom_port = int(port_text)
+ clone_port = args.port_clone
+ if clone_port is None:
+ port_text = tui.line_edit(
+ stdscr, "Base (voice clone) port",
+ str(_config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT)),
+ validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535)
+ else "Enter a port number between 1 and 65535",
+ help_lines=["The port for qwen-tts-demo Base (voice cloning)"])
+ clone_port = int(port_text)
+
+ # Step 2: built-in speaker.
+ speaker = args.speaker
+ if speaker is None:
+ speaker = tui.menu(
+ stdscr, "Built-in CustomVoice speaker",
+ [(s, s) for s in QWEN_SPEAKERS],
+ default_index=max(0, QWEN_SPEAKERS.index(config.SPEAKER)
+ if config.SPEAKER in QWEN_SPEAKERS else 0),
+ help_lines=["Used by audiobook.py --backend qwen without --clone"])
+
+ return {
+ "do_install": do_install,
+ "custom_port": custom_port,
+ "clone_port": clone_port,
+ "speaker": speaker,
+ }
+
+
+def _execute(settings: dict) -> int:
+ """Console tail: install, sync config, advise."""
+ if settings["do_install"]:
+ rc = common.pip_install([QWEN_PIP_PKG])
+ if rc != 0:
+ print(f"[WARNING] pip install failed (exit {rc}); install "
+ f"{QWEN_PIP_PKG} manually")
+ else:
+ print(f"[OK] {QWEN_PIP_PKG} installed")
+
+ custom_url = common.url_with_port(config.QWEN_API_URL, settings["custom_port"])
+ if custom_url != config.QWEN_API_URL:
+ if common.update_config_value("QWEN_API_URL", custom_url):
+ print(f"[OK] Updated QWEN_API_URL to {custom_url}")
+ else:
+ print("[WARNING] Could not update QWEN_API_URL; edit "
+ "app/converter/config.py by hand")
+ clone_url = common.url_with_port(config.CLONE_API_URL, settings["clone_port"])
+ if clone_url != config.CLONE_API_URL:
+ if common.update_config_value("CLONE_API_URL", clone_url):
+ print(f"[OK] Updated CLONE_API_URL to {clone_url}")
+ else:
+ print("[WARNING] Could not update CLONE_API_URL; edit "
+ "app/converter/config.py by hand")
+ if settings["speaker"] != config.SPEAKER:
+ if common.update_config_value("SPEAKER", settings["speaker"]):
+ print(f"[OK] Updated SPEAKER to {settings['speaker']}")
+ else:
+ print("[WARNING] Could not update SPEAKER; edit "
+ "app/converter/config.py by hand")
+
+ _print_launch_hint(settings["custom_port"], settings["clone_port"])
+ return 0
+
+
+def _print_launch_hint(custom_port: int, clone_port: int) -> None:
+ demo = envs.env_script("qwen-tts-demo")
+ print()
+ print("Start the servers (in separate terminals), or use the hub's")
+ print("'Server' menu / let a conversion start one automatically:")
+ print(f" {demo} {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 "
+ f"--port {custom_port}")
+ print(f" {demo} {QWEN_BASE_MODEL} --ip 127.0.0.1 "
+ f"--port {clone_port}")
+ print("Then run: python audiobook.py --backend qwen")
+
+
+def run_tui(args: Optional[argparse.Namespace] = None) -> int:
+ """Run the qwen setup wizard end-to-end."""
+ import curses
+ if args is None:
+ args = build_parser().parse_args([])
+ try:
+ settings = curses.wrapper(_wizard, args)
+ except tui.WizardCancelled:
+ print("\n[INFO] Cancelled; nothing was written")
+ return 1
+ try:
+ curses.curs_set(1)
+ except curses.error:
+ pass
+ if settings is None:
+ print("[INFO] Aborted")
+ return 1
+ return _execute(settings)
+
+
+def _collect_from_flags(args: argparse.Namespace,
+ parser: argparse.ArgumentParser) -> dict:
+ return {
+ "do_install": (not _is_installed()) and not args.skip_install,
+ "custom_port": args.port_custom if args.port_custom is not None
+ else _config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT),
+ "clone_port": args.port_clone if args.port_clone is not None
+ else _config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT),
+ "speaker": args.speaker or config.SPEAKER,
+ }
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Set up the Qwen3-TTS demo backend: pip install, "
+ "configure ports/speaker, and print launch commands.")
+ parser.add_argument("--port-custom", type=int, default=None,
+ help="CustomVoice (speaker) port (default: "
+ f"{DEFAULT_CUSTOM_PORT})")
+ parser.add_argument("--port-clone", type=int, default=None,
+ help="Base (voice clone) port (default: "
+ f"{DEFAULT_CLONE_PORT})")
+ parser.add_argument("--speaker", type=str, default=None,
+ choices=QWEN_SPEAKERS,
+ help="Built-in CustomVoice speaker (default: "
+ f"{config.SPEAKER})")
+ parser.add_argument("--skip-install", action="store_true",
+ help="Do not pip install qwen-tts")
+ return parser
+
+
+def detect() -> BackendStatus:
+ """Detect whether qwen-tts is installed, plus the launch commands."""
+ installed = _is_installed()
+ custom_port = _config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT)
+ clone_port = _config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT)
+ # Running when either server is up — CustomVoice (speaker mode) or Base
+ # (voice clone) each suffice for a conversion on their own.
+ running = (common.server_running(config.QWEN_API_URL)
+ or common.server_running(config.CLONE_API_URL))
+ details: List[str] = []
+ details.append("pip: installed" if installed else
+ "not installed — run setup to pip install qwen-tts")
+ details.append(f"CustomVoice port: {custom_port}")
+ details.append(f"Base (clone) port: {clone_port}")
+ details.append(f"speaker: {config.SPEAKER}")
+ demo = str(envs.env_script("qwen-tts-demo"))
+ servers = [
+ ServerSpec("qwen-custom", config.QWEN_API_URL,
+ [demo, QWEN_CUSTOMVOICE_MODEL, "--ip", "127.0.0.1",
+ "--port", str(custom_port)]),
+ ServerSpec("qwen-clone", config.CLONE_API_URL,
+ [demo, QWEN_BASE_MODEL, "--ip", "127.0.0.1",
+ "--port", str(clone_port)]),
+ ]
+ return BackendStatus("qwen", "qwen-tts",
+ installed=installed, configured=installed,
+ running=running, details=details,
+ launch_hint=format_launch_hint(servers),
+ servers=servers)
+
+
+configure_actions: List[ConfigureAction] = [
+ ConfigureAction("Reconfigure qwen-tts (ports/speaker)", run_tui),
+]
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+
+ if _interactive():
+ return run_tui(args)
+
+ settings = _collect_from_flags(args, parser)
+ return _execute(settings)
+
+
+def _interactive() -> bool:
+ try:
+ import curses # noqa: F401
+ except ImportError:
+ return False
+ try:
+ return sys.stdin.isatty() and sys.stdout.isatty()
+ except (AttributeError, ValueError):
+ return False
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/app/backends/servers.py b/app/backends/servers.py
new file mode 100644
index 0000000..a5a6829
--- /dev/null
+++ b/app/backends/servers.py
@@ -0,0 +1,244 @@
+"""Start and stop TTS backend servers from the TUI hub.
+
+Each backend's ``detect()`` returns a list of ``ServerSpec`` — the exact argv
+(absolute binaries in the managed venv, no shell activation needed) and the
+URL to probe for readiness. This module turns those specs into running
+processes: ``start`` spawns the server, streams its output to
+``app/logs/<name>-server.log``, records its pid, and polls the URL until it
+accepts connections (model loads are slow, so the timeout is generous);
+``stop`` terminates the process group the hub started.
+
+Everything here runs in the plain console tail after the curses TUI returns
+(matching the wizards' build/pip streaming), so progress and log tails appear
+normally. Pid/log files live under ``app/logs/`` which is already gitignored.
+"""
+
+import os
+import signal
+import subprocess
+import sys
+import time
+from pathlib import Path
+from typing import List
+
+from backends import common
+from backends.common import APP_DIR
+
+LOG_DIR = APP_DIR / "logs"
+
+# How long to wait for a server to accept connections on its URL. First-time
+# model loads (especially qwen-tts / faster-qwen3-tts pulling weights into
+# VRAM) can take minutes, so this is deliberately generous.
+SERVER_START_TIMEOUT = 600
+
+# Grace period after SIGTERM before escalating to SIGKILL (POSIX).
+STOP_GRACE_SECONDS = 10
+
+
+def _log_path(name: str) -> Path:
+ return LOG_DIR / f"{name}-server.log"
+
+
+def _pid_path(name: str) -> Path:
+ return LOG_DIR / f"{name}-server.pid"
+
+
+def _tail_log(name: str, lines: int = 20) -> None:
+ """Print the last LINES of the server's log (best-effort)."""
+ path = _log_path(name)
+ try:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return
+ tail = "\n".join(text.splitlines()[-lines:])
+ if tail:
+ print(f"--- last {lines} lines of {path} ---")
+ print(tail)
+ print("---")
+
+
+def _pid_alive(pid: int) -> bool:
+ """True when a process with PID is still running (POSIX signal-0 probe)."""
+ if sys.platform == "win32":
+ try:
+ import ctypes
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+ handle = kernel32.OpenProcess(
+ PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
+ if not handle:
+ return False
+ kernel32.CloseHandle(handle)
+ return True
+ except OSError:
+ return False
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ except PermissionError:
+ return True
+ return True
+
+
+def _kill_pid(pid: int) -> bool:
+ """Terminate PID (and its process group on POSIX). Returns True when dead."""
+ if sys.platform == "win32":
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except (ProcessLookupError, PermissionError, OSError):
+ return not _pid_alive(pid)
+ for _ in range(int(STOP_GRACE_SECONDS * 10)):
+ if not _pid_alive(pid):
+ return True
+ time.sleep(0.1)
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except OSError:
+ pass
+ return not _pid_alive(pid)
+ # POSIX: kill the whole process group (started with start_new_session=True).
+ try:
+ pgid = os.getpgid(pid)
+ except ProcessLookupError:
+ return True
+ try:
+ os.killpg(pgid, signal.SIGTERM)
+ except ProcessLookupError:
+ return True
+ except PermissionError:
+ return False
+ for _ in range(int(STOP_GRACE_SECONDS * 10)):
+ try:
+ os.killpg(pgid, 0)
+ except ProcessLookupError:
+ return True
+ except PermissionError:
+ return False
+ time.sleep(0.1)
+ try:
+ os.killpg(pgid, signal.SIGKILL)
+ except (ProcessLookupError, PermissionError):
+ pass
+ return True
+
+
+def start(spec) -> bool:
+ """Start the server described by SPEC (a ``backends.ServerSpec``).
+
+ Spawns its argv with stdout/stderr to ``logs/<name>-server.log``, records
+ the pid, and polls ``common.server_running(spec.url)`` until it accepts
+ connections or ``SERVER_START_TIMEOUT`` elapses. Returns True when the
+ server is up; on timeout or early exit, prints the log tail and returns
+ False. A no-op (True) when the server is already running.
+ """
+ argv: List[str] = list(spec.argv)
+ exe = Path(argv[0])
+ if not exe.exists():
+ print(f"[ERROR] server executable not found: {exe}")
+ print(" run 'Set up a backend' for "
+ f"{spec.name!r} first.")
+ return False
+ if common.server_running(spec.url):
+ print(f"[INFO] {spec.name} server already running on {spec.url}")
+ return True
+
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
+ pid_file = _pid_path(spec.name)
+ if pid_file.exists():
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+
+ print(f"[INFO] starting {spec.name} server: "
+ + " ".join(str(a) for a in argv))
+ log_handle = _log_path(spec.name).open("w", encoding="utf-8")
+ popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT}
+ if sys.platform == "win32":
+ popen_kwargs["creationflags"] = \
+ subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
+ else:
+ popen_kwargs["start_new_session"] = True
+ try:
+ proc = subprocess.Popen(argv, **popen_kwargs)
+ except OSError as exc:
+ print(f"[ERROR] could not start server: {exc}")
+ log_handle.close()
+ return False
+
+ pid_file.write_text(str(proc.pid), encoding="utf-8")
+ print(f"[INFO] pid {proc.pid}; logs: {_log_path(spec.name)}")
+
+ deadline = time.time() + SERVER_START_TIMEOUT
+ while time.time() < deadline:
+ if proc.poll() is not None:
+ print(f"[ERROR] {spec.name} server exited with code "
+ f"{proc.returncode}")
+ _tail_log(spec.name)
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+ return False
+ if common.server_running(spec.url):
+ print(f"[OK] {spec.name} server is up on {spec.url}")
+ return True
+ time.sleep(1)
+ print(f"[ERROR] {spec.name} server did not start within "
+ f"{SERVER_START_TIMEOUT}s")
+ _tail_log(spec.name)
+ # Leave the pid file in place so stop() can kill it (it may still load).
+ return False
+
+
+def stop(name: str) -> bool:
+ """Stop a server previously started by ``start`` (identified by pid file).
+
+ Returns True when the process was terminated (or already gone). Returns
+ False when there is no pid file — the server was not started by this tool,
+ so the user must stop it manually (e.g. close its terminal).
+ """
+ pid_file = _pid_path(name)
+ if not pid_file.exists():
+ print(f"[INFO] no pid file for '{name}' "
+ "(not started by this tool — stop it manually)")
+ return False
+ try:
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
+ except (OSError, ValueError):
+ print(f"[WARNING] could not read pid file {pid_file}; removing it")
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+ return False
+ if not _pid_alive(pid):
+ print(f"[INFO] {name} server (pid {pid}) already stopped")
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+ return True
+ print(f"[INFO] stopping {name} server (pid {pid})...")
+ killed = _kill_pid(pid)
+ if killed:
+ print(f"[OK] {name} server stopped")
+ else:
+ print(f"[WARNING] could not stop pid {pid}; stop it manually")
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+ return killed
+
+
+def pid_for(name: str):
+ """Return the recorded pid for NAME, or None when no pid file exists."""
+ pid_file = _pid_path(name)
+ if not pid_file.exists():
+ return None
+ try:
+ return int(pid_file.read_text(encoding="utf-8").strip())
+ except (OSError, ValueError):
+ return None
diff --git a/app/converter/__init__.py b/app/converter/__init__.py
new file mode 100644
index 0000000..86a827f
--- /dev/null
+++ b/app/converter/__init__.py
@@ -0,0 +1 @@
+"""TTS audiobook generator package."""
diff --git a/app/converter/audio.py b/app/converter/audio.py
new file mode 100644
index 0000000..81431cb
--- /dev/null
+++ b/app/converter/audio.py
@@ -0,0 +1,616 @@
+"""Audio assembly: combining chunks, speed adjustment, cleanup."""
+
+import logging
+import re
+import shutil
+import subprocess
+import traceback
+import wave
+from pathlib import Path
+from typing import Dict, List, NamedTuple, Optional, Tuple
+
+from . import config
+
+logger = logging.getLogger(__name__)
+
+CHUNKS_FOLDER = Path(__file__).resolve().parent.parent.parent / "app" / "chunks"
+
+
+def atempo_filters(speed: float) -> str:
+ """Return a comma-joined ffmpeg ``atempo`` filter chain for ``speed``.
+
+ ``atempo`` accepts 0.5..2.0 per filter; values outside that range are
+ handled by chaining multiple filters. Returns "" when ``speed`` is 1.0.
+ """
+ if speed <= 0:
+ raise ValueError(f"Speed must be a positive number, got {speed}")
+ if abs(speed - 1.0) < 1e-6:
+ return ""
+ remaining = float(speed)
+ chain = []
+ while remaining > 2.0:
+ chain.append("atempo=2.0")
+ remaining /= 2.0
+ while remaining < 0.5:
+ chain.append("atempo=0.5")
+ remaining /= 0.5
+ chain.append(f"atempo={remaining:g}")
+ return ",".join(chain)
+
+
+def speed_export_params(speed: float) -> List[str]:
+ """Return ffmpeg filter args for pitch-preserving speed adjustment."""
+ filters = atempo_filters(speed)
+ if not filters:
+ return []
+ return ["-filter:a", filters]
+
+
+def _concat_escape(path: str) -> str:
+ """Escape a path for use inside single quotes in an ffmpeg concat list."""
+ return path.replace("'", "'\\''")
+
+
+def _concat_wav_files(sources: List[Path], destination: Path) -> bool:
+ """Concatenate WAV files with matching parameters using the wave module.
+
+ Returns False (touching nothing) when any input is not a readable WAV
+ or the parameters differ, so the caller can fall back to ffmpeg.
+ """
+ opened = []
+ try:
+ parameters = None
+ for source in sources:
+ wav_file = wave.open(str(source), "rb")
+ opened.append(wav_file)
+ current = (wav_file.getnchannels(), wav_file.getsampwidth(),
+ wav_file.getframerate())
+ if parameters is None:
+ parameters = current
+ elif current != parameters:
+ return False
+ if parameters is None or min(parameters) < 1:
+ return False
+ with wave.open(str(destination), "wb") as output:
+ output.setnchannels(parameters[0])
+ output.setsampwidth(parameters[1])
+ output.setframerate(parameters[2])
+ for wav_file in opened:
+ output.writeframes(wav_file.readframes(wav_file.getnframes()))
+ return True
+ except (wave.Error, EOFError, OSError):
+ return False
+ finally:
+ for wav_file in opened:
+ try:
+ wav_file.close()
+ except Exception:
+ pass
+
+
+def _concat_with_ffmpeg(sources: List[Path], destination: Path) -> None:
+ """Concatenate audio files with ffmpeg's concat demuxer, re-encoding to
+ 16-bit PCM WAV (handles inputs the wave module cannot)."""
+ if shutil.which("ffmpeg") is None:
+ raise RuntimeError(
+ "ffmpeg is required to concatenate audio parts in non-WAV formats "
+ "(install ffmpeg and try again)"
+ )
+ list_path = destination.with_name(destination.stem + "_parts.txt")
+ try:
+ with open(list_path, "w", encoding="utf-8") as list_file:
+ for source in sources:
+ list_file.write(f"file '{_concat_escape(str(source))}'\n")
+ command = [
+ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
+ "-f", "concat", "-safe", "0", "-i", str(list_path),
+ "-c:a", "pcm_s16le", str(destination),
+ ]
+ proc = subprocess.run(command, capture_output=True, text=True)
+ if proc.returncode != 0:
+ raise RuntimeError(
+ f"ffmpeg failed to concatenate audio parts: {proc.stderr[-500:]}")
+ finally:
+ try:
+ list_path.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+
+def concat_audio_files(sources: List[Path], destination: Path) -> None:
+ """Concatenate audio files into one file at ``destination``.
+
+ Joins the audio returned by several TTS sub-requests for a single
+ chunk. Uses the stdlib wave module when every input is a WAV with
+ matching parameters (lossless, no external tools); otherwise falls
+ back to ffmpeg's concat demuxer with re-encoding.
+ """
+ if not sources:
+ raise ValueError("No audio files to concatenate")
+ if _concat_wav_files(sources, destination):
+ return
+ _concat_with_ffmpeg(sources, destination)
+
+
+def _encode_args(output_format: str) -> List[str]:
+ """Return ffmpeg output codec/bitrate args for the requested container."""
+ if output_format == "m4b":
+ return ["-c:a", "aac", "-b:a", config.AUDIO_BITRATE]
+ if output_format == "ogg":
+ return ["-c:a", "libvorbis", "-b:a", config.AUDIO_BITRATE]
+ if output_format == "flac":
+ return ["-c:a", "flac"]
+ if output_format == "wav":
+ # Lossless intermediate for per-chapter scratch audio; avoids
+ # generational loss when the final m4b re-encodes to AAC.
+ return ["-c:a", "pcm_s16le"]
+ return ["-b:a", config.AUDIO_BITRATE]
+
+
+class TrackMeta(NamedTuple):
+ """Tags embedded into a finished audiobook file."""
+
+ title: str
+ artist: str = ""
+ album: str = ""
+ track: Optional[int] = None
+ total_tracks: Optional[int] = None
+
+
+def _tag_args(meta: TrackMeta, output_format: str) -> List[str]:
+ """Return -metadata args plus format-specific tagging flags."""
+ args = ["-metadata", f"title={meta.title}"]
+ if meta.artist:
+ args += ["-metadata", f"artist={meta.artist}"]
+ if meta.album:
+ args += ["-metadata", f"album={meta.album}"]
+ if meta.track and meta.total_tracks:
+ args += ["-metadata", f"track={meta.track}/{meta.total_tracks}"]
+ if output_format == "mp3":
+ # ID3v2.3 is what essentially every player reads; ffmpeg's default
+ # (v2.4) still confuses some of them.
+ args += ["-id3v2_version", "3"]
+ return args
+
+
+# Formats whose container has no reliable embedded-picture support.
+_NO_COVER_FORMATS = ("ogg", "wav")
+
+
+def _cover_args(output_format: str, cover_input_index: int) -> List[str]:
+ """Return per-output args attaching a cover image as an embedded picture.
+
+ The cover must already be added as an ffmpeg input; ``cover_input_index``
+ is that input's position on the command line. mp3/flac keep the PNG
+ stream as-is; m4b re-encodes to JPEG, which audiobook players expect.
+ """
+ if output_format in _NO_COVER_FORMATS:
+ return []
+ codec = "mjpeg" if output_format == "m4b" else "copy"
+ args = ["-map", f"{cover_input_index}:v", "-c:v", codec,
+ "-disposition:v", "attached_pic",
+ "-metadata:s:v", "title=Album cover"]
+ if output_format == "m4b":
+ args += ["-q:v", "3"]
+ return args
+
+
+_brand_supported: Optional[bool] = None
+
+
+def _detect_brand_support() -> bool:
+ """Check whether the local ffmpeg muxer accepts the ``-brand`` option."""
+ if shutil.which("ffmpeg") is None:
+ return False
+ try:
+ proc = subprocess.run(
+ ["ffmpeg", "-hide_banner", "-h", "muxer=ipod"],
+ capture_output=True, text=True, timeout=15,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+ return "-brand" in proc.stdout
+
+
+def _m4b_container_args() -> List[str]:
+ """Return per-output container flags for m4b files.
+
+ ``+faststart`` moves the ``moov`` index to the front of the file so
+ streaming players (and naive linear readers like web players) can index
+ it; without it they may misreport the duration or refuse the file.
+ The ``M4B `` major brand identifies the file as an audiobook to
+ players that sniff brands instead of trusting the extension (the ffmpeg
+ default brand for .m4b is ``M4A ``).
+ """
+ global _brand_supported
+ if _brand_supported is None:
+ _brand_supported = _detect_brand_support()
+ args = ["-movflags", "+faststart"]
+ if _brand_supported:
+ args += ["-brand", "M4B "]
+ return args
+
+
+def build_concat_command(concat_list: Path, output_path: Path, output_format: str,
+ speed: float = 1.0, speed_path: Optional[Path] = None,
+ meta: Optional[TrackMeta] = None,
+ cover: Optional[Path] = None) -> List[str]:
+ """Build the ffmpeg command that concatenates chunk audio into a book file."""
+ encode = _encode_args(output_format)
+ container = _m4b_container_args() if output_format == "m4b" else []
+ inputs = ["-f", "concat", "-safe", "0", "-i", str(concat_list)]
+ cover_index = None
+ if cover is not None and output_format not in _NO_COVER_FORMATS:
+ inputs += ["-i", str(cover)]
+ cover_index = 1
+ cover_block = (_cover_args(output_format, cover_index)
+ if cover_index is not None else [])
+ tags = _tag_args(meta, output_format) if meta else []
+ filters = atempo_filters(speed)
+ if filters:
+ if speed_path is None:
+ raise ValueError("speed_path is required when speed is not 1.0")
+ return [
+ "ffmpeg", "-y", *inputs, "-filter_complex",
+ f"[0:a]split=2[base][spd];[spd]{filters}[spdout]",
+ "-map", "[base]", *encode, *tags, *cover_block, *container, str(output_path),
+ "-map", "[spdout]", *encode, *tags, *cover_block, *container, str(speed_path),
+ ]
+ output_maps = ["-map", "0:a"] if cover_block else []
+ return [
+ "ffmpeg", "-y", *inputs, *output_maps,
+ *encode, *tags, *cover_block, *container, str(output_path),
+ ]
+
+
+def build_m4b_chapters_command(concat_list: Path, metadata_file: Path, output_path: Path,
+ speed: float = 1.0, speed_path: Optional[Path] = None,
+ speed_metadata_file: Optional[Path] = None,
+ meta: Optional[TrackMeta] = None,
+ cover: Optional[Path] = None) -> List[str]:
+ """Build the ffmpeg command that assembles chapter audio into one m4b.
+
+ Chapter metadata inputs are bound to their outputs with explicit
+ ``-map_chapters`` so the normal-speed and speed-adjusted copies each get
+ their own (correctly scaled) chapter markers.
+ """
+ encode = _encode_args("m4b")
+ container = _m4b_container_args()
+ tags = _tag_args(meta, "m4b") if meta else []
+ inputs = ["-f", "concat", "-safe", "0", "-i", str(concat_list),
+ "-i", str(metadata_file)]
+ input_count = 2 # concat audio + ffmetadata
+ if speed_path is not None:
+ inputs += ["-i", str(speed_metadata_file)]
+ input_count += 1
+ cover_block: List[str] = []
+ if cover is not None:
+ inputs += ["-i", str(cover)]
+ cover_block = _cover_args("m4b", input_count)
+ if speed_path is not None:
+ return [
+ "ffmpeg", "-y", *inputs, "-filter_complex",
+ f"[0:a]split=2[base][spd];[spd]{atempo_filters(speed)}[spdout]",
+ "-map", "[base]", *cover_block, "-map_metadata", "1", "-map_chapters", "1",
+ *encode, *tags, *container, str(output_path),
+ "-map", "[spdout]", *cover_block, "-map_metadata", "2", "-map_chapters", "2",
+ *encode, *tags, *container, str(speed_path),
+ ]
+ return [
+ "ffmpeg", "-y", *inputs,
+ "-map", "0:a", *cover_block, "-map_metadata", "1", "-map_chapters", "1",
+ *encode, *tags, *container, str(output_path),
+ ]
+
+
+_DURATION_WARN_TOLERANCE = 0.05 # warn when output duration drifts >5%
+_DURATION_FAIL_TOLERANCE = 0.25 # fail when output duration drifts >25%
+
+
+def verify_output_duration(path: Path, expected_ms: int) -> bool:
+ """Sanity-check an assembled file's duration against the expected total.
+
+ Catches corrupt assembly (truncated concat, bogus container metadata)
+ before the file reaches audiobook players. Duration drift beyond the
+ warn tolerance is logged; drift beyond the fail tolerance is an error
+ and the output is treated as broken. Returns True when unverifiable.
+ """
+ if expected_ms <= 0:
+ return True
+ actual_ms = probe_duration_ms(path)
+ if actual_ms <= 0:
+ logger.warning("Could not verify duration of %s (ffprobe failed)", path)
+ return True
+ drift = abs(actual_ms - expected_ms) / expected_ms
+ if drift > _DURATION_FAIL_TOLERANCE:
+ logger.error(
+ "Duration mismatch for %s: expected ~%.1fs, got %.1fs (%.0f%% off); output is likely corrupt",
+ path.name, expected_ms / 1000.0, actual_ms / 1000.0, drift * 100.0,
+ )
+ return False
+ if drift > _DURATION_WARN_TOLERANCE:
+ logger.warning(
+ "Duration drift for %s: expected ~%.1fs, got %.1fs (%.0f%% off)",
+ path.name, expected_ms / 1000.0, actual_ms / 1000.0, drift * 100.0,
+ )
+ return True
+
+
+def _collect_chunk_files(total_chunks: int,
+ chunk_results: Dict[int, Optional[Path]]
+ ) -> Tuple[List[Path], List[int]]:
+ """Resolve chunk audio files in book order.
+
+ ``chunk_results`` maps chunk number -> path written (or None for a failed
+ chunk); recorded paths are used exactly as-is so stale files from a
+ previous chapter can never leak in.
+ """
+ chunk_files: List[Path] = []
+ missing: List[int] = []
+ for i in range(1, total_chunks + 1):
+ recorded = chunk_results.get(i)
+ if recorded is not None and Path(recorded).exists():
+ chunk_files.append(Path(recorded))
+ else:
+ missing.append(i)
+ return chunk_files, missing
+
+
+def combine_chunks(total_chunks: int, output_path: Path,
+ chunk_results: Dict[int, Optional[Path]],
+ speed: float = 1.0, output_format: str = config.AUDIO_FORMAT,
+ intermediate: bool = False,
+ meta: Optional[TrackMeta] = None,
+ cover: Optional[Path] = None) -> bool:
+ """Combine audio chunks into the final audiobook using ffmpeg's concat demuxer.
+
+ ``chunk_results`` maps chunk numbers to the audio file each chunk produced
+ (None for failed chunks); failed and missing chunks are skipped. When
+ ``speed`` differs from 1.0, an additional speed-adjusted copy is written
+ next to the normal-speed file. ``meta``/``cover`` embed tags and cover
+ art into the output (skipped for intermediate chapter scratch audio).
+ Chunks are streamed by ffmpeg, so the whole book is never held in
+ memory. Set ``intermediate`` for scratch chapter audio on the way to a
+ larger output (e.g. a chaptered m4b) so save messages don't present it
+ as the final audiobook.
+ """
+ if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
+ logger.error("ffmpeg and ffprobe are required to combine audio chunks (install ffmpeg)")
+ return False
+
+ chunk_files, missing_chunks = _collect_chunk_files(total_chunks, chunk_results)
+
+ if not chunk_files:
+ logger.error("No valid chunks found")
+ return False
+
+ if missing_chunks:
+ logger.warning("Missing chunks: %s", missing_chunks)
+
+ concat_list = CHUNKS_FOLDER / "_concat_list.txt"
+ try:
+ with open(concat_list, "w", encoding="utf-8") as list_file:
+ for chunk_file in chunk_files:
+ list_file.write(f"file '{_concat_escape(str(chunk_file))}'\n")
+
+ speed_path = None
+ if atempo_filters(speed):
+ speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}")
+ cmd = build_concat_command(concat_list, output_path, output_format,
+ speed=speed, speed_path=speed_path,
+ meta=None if intermediate else meta,
+ cover=None if intermediate else cover)
+
+ proc = subprocess.run(cmd, capture_output=True, text=True)
+ if proc.returncode != 0:
+ logger.error("ffmpeg failed: %s", proc.stderr[-2000:])
+ return False
+
+ # Verify the assembled duration against the sum of chunk durations
+ # so corrupt output is caught before it reaches audiobook players.
+ # A failed probe returns 0, which would deflate the expected total
+ # and falsely fail the check, so unverifiable sums skip it.
+ durations = [probe_duration_ms(chunk_file) for chunk_file in chunk_files]
+ if any(duration <= 0 for duration in durations):
+ logger.warning("Could not probe every chunk duration; skipping duration verification")
+ duration_ok = True
+ else:
+ expected_ms = sum(durations)
+ duration_ok = verify_output_duration(output_path, expected_ms)
+ if speed_path is not None:
+ duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok
+ if not duration_ok:
+ return False
+
+ if intermediate:
+ logger.info("Chapter audio saved (intermediate): %s (%d/%d chunks)",
+ output_path, len(chunk_files), total_chunks)
+ if len(chunk_files) == 1 and total_chunks == 1:
+ print(f"[INFO] Saved chapter audio (intermediate): "
+ f"{output_path.name}")
+ else:
+ print(f"[INFO] Saved chapter audio (intermediate): {output_path.name} "
+ f"({len(chunk_files)}/{total_chunks} chunks)")
+ else:
+ logger.info("Audiobook saved: %s (%d/%d chunks)", output_path, len(chunk_files), total_chunks)
+ if len(chunk_files) == 1 and total_chunks == 1:
+ print(f"[INFO] Saved audiobook: {output_path.name}")
+ else:
+ print(f"[INFO] Saved audiobook: {output_path.name} "
+ f"({len(chunk_files)}/{total_chunks} chunks)")
+
+ if speed_path is not None:
+ logger.info("Saved speed-adjusted audiobook (%gx): %s", speed, speed_path)
+ print(f"[INFO] Saved speed-adjusted audiobook: {speed_path.name} ({speed:g}x)")
+
+ return True
+
+ except FileNotFoundError:
+ logger.error("ffmpeg not found on PATH (install ffmpeg and try again)")
+ return False
+ except Exception as exc:
+ logger.error("Failed to combine chunks: %s", exc)
+ logger.error(traceback.format_exc())
+ return False
+ finally:
+ try:
+ concat_list.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+
+def cleanup_chunks() -> None:
+ """Remove temporary chunk and chapter files from the scratch folder."""
+ try:
+ chunk_count = 0
+ for pattern in ("chunk_*", "chapter_*"):
+ for chunk_file in CHUNKS_FOLDER.glob(pattern):
+ try:
+ if chunk_file.is_file():
+ chunk_file.unlink()
+ chunk_count += 1
+ except Exception as exc:
+ logger.warning("Failed to delete %s: %s", chunk_file, exc)
+
+ if chunk_count > 0:
+ logger.info("Cleaned up %d chunk files", chunk_count)
+ print(f"[INFO] Cleaned up {chunk_count} chunk files")
+ except Exception as exc:
+ logger.warning("Cleanup failed: %s", exc)
+
+
+def probe_duration_ms(path: Path) -> int:
+ """Return audio duration in milliseconds using ffprobe."""
+ try:
+ result = subprocess.run(
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
+ capture_output=True, text=True, timeout=30,
+ )
+ except subprocess.TimeoutExpired:
+ logger.warning("ffprobe timed out for %s", path)
+ return 0
+ if result.returncode != 0:
+ logger.warning("ffprobe failed for %s: %s", path, result.stderr[-200:])
+ return 0
+ try:
+ return max(0, int(round(float(result.stdout.strip()) * 1000.0)))
+ except ValueError:
+ logger.warning("Could not parse ffprobe duration for %s", path)
+ return 0
+
+
+def _escape_ffmetadata_value(value: str) -> str:
+ """Escape a metadata value for ffmpeg's FFMETADATA format.
+
+ Backslash and the structural characters ``=``, ``;`` and ``#`` must be
+ backslash-escaped; line breaks would corrupt the file and are collapsed
+ to spaces.
+ """
+ value = value.replace("\\", "\\\\")
+ value = re.sub(r"[\r\n]+", " ", value)
+ return re.sub(r"[=;#]", r"\\\g<0>", value)
+
+
+def build_ffmetadata(chapters: List[tuple], path: Path) -> None:
+ """Write an ffmpeg FFMETADATA file with ``[CHAPTER]`` entries.
+
+ ``chapters`` is a list of ``(start_ms, end_ms, title)`` tuples.
+ """
+ with open(path, "w", encoding="utf-8") as meta_file:
+ meta_file.write(";FFMETADATA1\n")
+ for start_ms, end_ms, title in chapters:
+ meta_file.write("[CHAPTER]\n")
+ meta_file.write("TIMEBASE=1/1000\n")
+ meta_file.write(f"START={int(start_ms)}\n")
+ meta_file.write(f"END={int(end_ms)}\n")
+ meta_file.write(f"title={_escape_ffmetadata_value(title)}\n")
+
+
+def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str],
+ output_path: Path, speed: float = 1.0,
+ meta: Optional[TrackMeta] = None,
+ cover: Optional[Path] = None) -> bool:
+ """Concatenate per-chapter audio into a single m4b with embedded chapter markers.
+
+ Chapter start/end times are derived from each chapter file's duration and
+ written as ffmpeg chapter metadata. ``meta``/``cover`` embed tags and
+ cover art. When ``speed`` differs from 1.0, a speed-adjusted copy (with
+ rescaled chapter markers) is written alongside the normal-speed file.
+ """
+ if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None:
+ logger.error("ffmpeg and ffprobe are required to build an m4b with chapters")
+ return False
+
+ if not chapter_files:
+ logger.error("No chapter files provided")
+ return False
+
+ concat_list = CHUNKS_FOLDER / "_concat_list.txt"
+ metadata_file = CHUNKS_FOLDER / "_chapters.txt"
+ speed_metadata_file = CHUNKS_FOLDER / "_chapters_speed.txt"
+ try:
+ chapters = []
+ start_ms = 0
+ with open(concat_list, "w", encoding="utf-8") as list_file:
+ for chapter_file, title in zip(chapter_files, titles):
+ list_file.write(f"file '{_concat_escape(str(chapter_file))}'\n")
+ duration_ms = probe_duration_ms(chapter_file)
+ end_ms = start_ms + duration_ms
+ chapters.append((start_ms, end_ms, title or "Chapter"))
+ start_ms = end_ms
+
+ build_ffmetadata(chapters, metadata_file)
+
+ speed_path = None
+ if atempo_filters(speed):
+ speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}")
+ scaled = [(int(s / speed), int(e / speed), t) for s, e, t in chapters]
+ build_ffmetadata(scaled, speed_metadata_file)
+ cmd = build_m4b_chapters_command(concat_list, metadata_file, output_path,
+ speed=speed, speed_path=speed_path,
+ speed_metadata_file=speed_metadata_file
+ if speed_path is not None else None,
+ meta=meta, cover=cover)
+
+ proc = subprocess.run(cmd, capture_output=True, text=True)
+ if proc.returncode != 0:
+ logger.error("ffmpeg failed: %s", proc.stderr[-2000:])
+ return False
+
+ # The last chapter's end time is the expected total duration; skip
+ # verification when any chapter duration probe failed (returned 0)
+ # so an unprobed chapter can't falsely fail the whole output.
+ expected_ms = chapters[-1][1]
+ if any(end_ms - start_ms <= 0 for start_ms, end_ms, _ in chapters):
+ logger.warning("Could not probe every chapter duration; skipping duration verification")
+ duration_ok = True
+ else:
+ duration_ok = verify_output_duration(output_path, expected_ms)
+ if speed_path is not None:
+ duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok
+ if not duration_ok:
+ return False
+
+ logger.info("Audiobook saved: %s (%d chapters)", output_path, len(chapter_files))
+ print(f"[INFO] Saved audiobook: {output_path.name} ({len(chapter_files)} chapters)")
+
+ if speed_path is not None:
+ logger.info("Saved speed-adjusted audiobook (%gx): %s", speed, speed_path)
+ print(f"[INFO] Saved speed-adjusted audiobook: {speed_path.name} ({speed:g}x)")
+
+ return True
+
+ except FileNotFoundError:
+ logger.error("ffmpeg/ffprobe not found on PATH (install ffmpeg and try again)")
+ return False
+ except Exception as exc:
+ logger.error("Failed to combine chapters: %s", exc)
+ logger.error(traceback.format_exc())
+ return False
+ finally:
+ for scratch in (concat_list, metadata_file, speed_metadata_file):
+ try:
+ scratch.unlink(missing_ok=True)
+ except OSError:
+ pass
diff --git a/app/converter/chunking.py b/app/converter/chunking.py
new file mode 100644
index 0000000..9ef6a5d
--- /dev/null
+++ b/app/converter/chunking.py
@@ -0,0 +1,91 @@
+"""Split extracted book text into TTS-sized chunks."""
+
+import logging
+import re
+from typing import List, Optional
+
+from . import config
+
+logger = logging.getLogger(__name__)
+
+
+def split_into_chunks(text: str, max_words: Optional[int] = None) -> List[str]:
+ """Split text into chunks of at most ``max_words`` words.
+
+ ``max_words`` defaults to ``config.CHUNK_SIZE`` (read at call time).
+ There is no ceiling beyond that setting, but note that the TTS
+ servers silently truncate audio when a single generation runs too
+ long without reporting an error, so very large values are at your
+ own risk (see CHUNK_SIZE in app/converter/config.py).
+
+ Splits on sentence boundaries. Sentences longer than the limit are
+ split further at clause punctuation (which is kept attached for TTS
+ prosody). Clause splits only happen at whitespace after punctuation,
+ so tokens like "1,000,000" or "12:30" are never broken apart. A piece
+ with no usable punctuation split point longer than the limit is split
+ at word boundaries as a last resort: individual tokens stay intact,
+ but whitespace between them is normalized.
+ """
+ if max_words is None:
+ max_words = config.CHUNK_SIZE
+ if max_words < 1:
+ max_words = 1
+
+ if not text.strip():
+ return []
+
+ sentences = re.split(r"(?<=[.!?])\s+", text)
+ chunks = []
+ current_chunk = ""
+ current_words = 0
+
+ for sentence in sentences:
+ sentence_words = len(sentence.split())
+
+ if sentence_words > max_words:
+ if current_chunk:
+ chunks.append(current_chunk.strip())
+ current_chunk = ""
+ current_words = 0
+
+ # Split long sentences at clause boundaries, keeping punctuation.
+ # Only split where whitespace already follows the punctuation so
+ # tokens are never broken apart or re-joined with added spaces
+ # (no spaces are injected into "1,000,000" or "12:30").
+ parts = re.split(r"(?<=[,;:])\s+", sentence)
+ for part in parts:
+ part_words = len(part.split())
+ if part_words > max_words:
+ # Last resort: no punctuation split point is available,
+ # so split at word boundaries. Tokens themselves (and
+ # therefore numbers like "1,000,000") stay intact.
+ if current_chunk:
+ chunks.append(current_chunk.strip())
+ current_chunk = ""
+ current_words = 0
+ words = part.split()
+ for start in range(0, len(words), max_words):
+ chunks.append(" ".join(words[start:start + max_words]))
+ continue
+ if current_words + part_words <= max_words:
+ current_chunk += part + " "
+ current_words += part_words
+ else:
+ if current_chunk:
+ chunks.append(current_chunk.strip())
+ current_chunk = part + " "
+ current_words = part_words
+ else:
+ if current_words + sentence_words <= max_words:
+ current_chunk += sentence + " "
+ current_words += sentence_words
+ else:
+ if current_chunk:
+ chunks.append(current_chunk.strip())
+ current_chunk = sentence + " "
+ current_words = sentence_words
+
+ if current_chunk.strip():
+ chunks.append(current_chunk.strip())
+
+ return [chunk for chunk in chunks if chunk.strip()]
diff --git a/app/converter/config.py b/app/converter/config.py
new file mode 100644
index 0000000..6a98136
--- /dev/null
+++ b/app/converter/config.py
@@ -0,0 +1,77 @@
+# Default output options
+AUDIO_FORMAT = "m4b"
+AUDIO_BITRATE = "128k"
+LANGUAGE = "English"
+
+API_TIMEOUT = 600 # Timeout per chunk request in seconds
+MAX_RETRIES = 3 # Attempts per chunk request
+HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" in console logs every N seconds
+
+# Words per TTS generation request (client-side chunking).
+# The qwen and faster backends always chunk with this size
+# The audio.cpp backend chunks long text itself, so this is ignored
+# by default with that backend. Force chunking with --chunk
+CHUNK_SIZE = 250
+
+# Default TTS backend.
+# audiocpp: audiocpp_server
+# qwen: qwen-tts-demo
+# faster: faster-qwen-tts
+# The --backend CLI flag overrides this
+BACKEND = "audiocpp"
+
+###############################################################################
+# BACKEND 1: qwen-tts-demo (qwen) options #
+###############################################################################
+
+# There are different API URLs for CustomVoice and Base models so you can run both at once
+QWEN_API_URL = "http://127.0.0.1:7860" # CustomVoice model
+CLONE_API_URL = "http://127.0.0.1:7861" # Base model
+
+# Custom voice options
+SPEAKER = "Vivian" #Vivian, Serena, Uncle_Fu, Dylan, Eric, Ryan, Aiden, Ono_Anna, Sohee
+INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience."
+
+# Don't clone with transcription, only use x-vector-only cloning. Generally "worse"
+XVECTOR_ONLY = False
+
+# Randomization seed. -1 means randomize with every generation
+# With SEED = -1 and CONSTANT_SEED = True, one random seed will be used for the entire audiobook.
+# This may keep the voice slightly more consistent across chunk boundaries
+SEED = -1
+CONSTANT_SEED = False
+
+###############################################################################
+# BACKEND 2: faster-qwen-tts options #
+###############################################################################
+FASTER_API_URL = "http://127.0.0.1:8000" # faster-qwen3-tts server (Base model only)
+
+# Default voice if no --voice is passed
+FASTER_VOICE = "default"
+
+###############################################################################
+# BACKEND 3: audio.cpp options #
+###############################################################################
+AUDIOCPP_API_URL = "http://127.0.0.1:8080" # audio.cpp audiocpp_server
+
+# Model ids in the audio.cpp server.json config. AUDIOCPP_MODEL_ID may point
+# at any TTS model entry the server hosts (qwen3_tts, higgs_audio_tts,
+# voxcpm2, index_tts2, ...); the family is detected from the server at
+# startup and adapts the request automatically. Only qwen3_tts has built-in
+# speakers (speaker mode); every other family needs --voice with a
+# server-side voice preset. For single-model servers, set
+# AUDIOCPP_CLONE_MODEL_ID to the same id as AUDIOCPP_MODEL_ID (or leave it
+# empty); for Qwen3-TTS it typically names a second entry with the Base
+# (cloning) model. A multi-model server (one server.json hosting several
+# lazily-loaded entries) does not need editing here: leave AUDIOCPP_MODEL_ID
+# unset to auto-select when only one entry is hosted, or pick the entry per
+# run with the --model CLI flag.
+AUDIOCPP_MODEL_ID = "qwen"
+AUDIOCPP_CLONE_MODEL_ID = "qwen"
+
+# Voice design / style instruction sent with every audio.cpp request when
+# the --instructions CLI flag is not given. Required for server entries
+# hosted with task "vdes" (voice design models such as Qwen3-TTS
+# VoiceDesign); on other families it acts as a style/delivery instruction
+# when the model supports one and is ignored otherwise. Empty by default.
+AUDIOCPP_INSTRUCTIONS = ""
diff --git a/app/converter/converter.py b/app/converter/converter.py
new file mode 100644
index 0000000..cef4808
--- /dev/null
+++ b/app/converter/converter.py
@@ -0,0 +1,782 @@
+"""Orchestrates book-to-audiobook conversion."""
+
+import glob
+import logging
+import re
+import shutil
+import sys
+import time
+import traceback
+from collections import Counter
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+from . import audio, chunking, config, cover, extractors
+from .audio import TrackMeta
+from .tts import (
+ BACKENDS,
+ BACKEND_AUDIOCPP,
+ BACKEND_FASTER,
+ BACKEND_QWEN,
+ MODEL_SIZE,
+ VOICE_MODE_CLONE,
+ VOICE_MODE_CUSTOM,
+ VOICE_MODES,
+ AudioCppTTSClient,
+ FasterTTSClient,
+ QwenTTSClient,
+ normalize_language,
+ speaker_display_name,
+)
+
+logger = logging.getLogger(__name__)
+
+# Folders, resolved from the project root so the converter runs from any
+# working directory. User-facing dirs (input/, output/) stay at the root;
+# scratch/log dirs live under the app/ container.
+BASE_DIR = Path(__file__).resolve().parent.parent.parent
+APP_DIR = BASE_DIR / "app"
+
+BOOKS_FOLDER = BASE_DIR / "input"
+AUDIOBOOKS_FOLDER = BASE_DIR / "output"
+CHUNKS_FOLDER = APP_DIR / "chunks" # Per-chunk scratch audio, cleaned per book
+LOGS_FOLDER = APP_DIR / "logs"
+DEBUG_FOLDER = APP_DIR / "debug" # --debug dumps, kept across runs
+
+# Output containers and supported input formats.
+AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
+SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"]
+
+
+def _console_log_filter(record: logging.LogRecord) -> bool:
+ """Keep httpx/httpcore request logs out of the console (file only)."""
+ return not record.name.startswith(("httpx", "httpcore"))
+
+
+def setup_logging(debug: bool = False) -> None:
+ """Configure logging to a dated file and the console.
+
+ The file keeps the full record (DEBUG with --debug), including httpx
+ request logs. The console handler only surfaces warnings and errors
+ (DEBUG with --debug) so progress prints are never mirrored as
+ timestamped log lines; httpx/httpcore request logs stay file-only.
+ """
+ LOGS_FOLDER.mkdir(parents=True, exist_ok=True)
+ file_handler = logging.FileHandler(
+ LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log",
+ encoding="utf-8",
+ )
+ file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setLevel(logging.DEBUG if debug else logging.WARNING)
+ console_handler.addFilter(_console_log_filter)
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s - %(levelname)s - %(message)s",
+ handlers=[file_handler, console_handler],
+ )
+ if debug:
+ logging.getLogger("converter").setLevel(logging.DEBUG)
+
+
+def setup_directories() -> None:
+ """Create necessary directories."""
+ for directory in (BOOKS_FOLDER, AUDIOBOOKS_FOLDER,
+ CHUNKS_FOLDER, LOGS_FOLDER):
+ Path(directory).mkdir(parents=True, exist_ok=True)
+
+
+def find_existing_outputs(output_name: str, output_format: str) -> List[Path]:
+ """Return existing output files that a conversion would overwrite.
+
+ Multi-section books (e.g. EPUB chapters) and speed-adjusted copies are
+ named ``{name}_suffix.{ext}``; exact chapter file names are only known
+ after text extraction, so any file matching that pattern counts.
+ """
+ folder = AUDIOBOOKS_FOLDER
+ existing: List[Path] = []
+ primary = folder / f"{output_name}.{output_format}"
+ if primary.exists():
+ existing.append(primary)
+ existing.extend(sorted(
+ folder.glob(f"{glob.escape(output_name)}_*.{output_format}")))
+ return existing
+
+
+def prompt_overwrite(existing: List[Path], output_name: str) -> bool:
+ """Ask whether to reconvert a book whose output files already exist.
+
+ All overwrite questions are asked before any conversion starts so the
+ rest of the run is unattended. Pressing Enter defaults to yes (so a
+ user can just hit Enter through the prompts), but a closed stdin
+ (non-interactive run) declines and keeps existing files safe.
+ """
+ if len(existing) == 1:
+ message = f"{existing[0].name} already exists. Convert anyway and overwrite it?"
+ else:
+ message = (f"{len(existing)} output files for '{output_name}' already exist "
+ f"(e.g. {existing[0].name}). Convert anyway and overwrite them?")
+ while True:
+ try:
+ answer = input(f"{message} [Y/n]: ").strip().lower()
+ except EOFError:
+ print("\n[WARNING] No interactive input available; keeping existing output")
+ return False
+ if not answer:
+ return True
+ if answer in ("y", "yes"):
+ return True
+ if answer in ("n", "no"):
+ return False
+ print("Please answer 'y' or 'n' (or press Enter for yes).")
+
+
+class AudiobookConverter:
+ """Audiobook converter using a local TTS API."""
+
+ def __init__(self, voice_mode: str = VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None,
+ voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
+ speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT,
+ language: Optional[str] = None, backend: str = config.BACKEND,
+ voice: Optional[str] = None, debug: bool = False,
+ chunk: bool = False, model_id: Optional[str] = None,
+ instructions: Optional[str] = None,
+ request_options: Optional[Dict[str, str]] = None):
+ if speed <= 0:
+ raise ValueError(f"Speed must be a positive number, got {speed}")
+ if output_format not in AUDIO_FORMATS:
+ raise ValueError(f"Unsupported output format: {output_format}")
+ if backend not in BACKENDS:
+ raise ValueError(
+ f"Unknown backend: {backend!r} (expected one of {BACKENDS})"
+ )
+ if language is None:
+ language = config.LANGUAGE
+ self.language = normalize_language(language)
+ self.voice_mode = voice_mode
+ self.voice_clone_ref_audio = voice_clone_ref_audio
+ self.speed = speed
+ self.single_file = single_file
+ self.output_format = output_format
+ self.backend = backend
+ self.voice = voice
+ self.debug = bool(debug)
+ # Client-side chunking: the qwen and faster backends always chunk
+ # (their servers do one generation per request and silently truncate
+ # long text). The audio.cpp server chunks long text itself, so it
+ # defaults to one request per chapter; --chunk forces client-side
+ # chunking on top (possible needless double-chunking).
+ self.client_chunks = bool(chunk) or backend != BACKEND_AUDIOCPP
+ # Voice design / style instruction and free-form request options
+ # (audio.cpp only): forwarded to AudioCppTTSClient, which validates
+ # them against the server-hosted model at connect time.
+ self.instructions = instructions
+ self.request_options = dict(request_options or {})
+ self._validate_configuration()
+ if backend == BACKEND_FASTER:
+ # The faster backend always voice-clones using a reference voice
+ # configured on the server, so no local reference audio is needed.
+ self.tts = FasterTTSClient(voice=voice)
+ elif backend == BACKEND_AUDIOCPP:
+ # Speaker mode (no voice) uses a built-in CustomVoice speaker;
+ # an explicit voice selects a server-side preset (cloning).
+ # model_id overrides AUDIOCPP_MODEL_ID for multi-model servers;
+ # instructions describe or style the voice, request_options pass
+ # per-model controls through to the server.
+ self.tts = AudioCppTTSClient(voice=voice, language=self.language,
+ chunk_text=self.client_chunks,
+ model_id=model_id,
+ instructions=instructions,
+ request_options=self.request_options)
+ else:
+ self.tts = QwenTTSClient(
+ voice_mode=voice_mode,
+ voice_clone_ref_audio=voice_clone_ref_audio,
+ voice_clone_ref_text=voice_clone_ref_text,
+ skip_transcription=skip_transcription,
+ language=self.language,
+ )
+
+ def _validate_configuration(self) -> None:
+ """Validate configuration settings."""
+ if self.voice_mode not in VOICE_MODES:
+ raise ValueError(
+ f"Unknown voice mode: {self.voice_mode!r} "
+ f"(expected one of {VOICE_MODES})"
+ )
+ if self.voice_mode == VOICE_MODE_CLONE and self.backend == BACKEND_QWEN:
+ if not self.voice_clone_ref_audio:
+ raise ValueError(
+ "Voice Clone mode requires a reference audio file. "
+ "Use --clone <path> to specify it."
+ )
+
+ if not Path(self.voice_clone_ref_audio).exists():
+ raise ValueError(
+ f"Reference audio file not found: {self.voice_clone_ref_audio}"
+ )
+
+ @staticmethod
+ def _sanitize_filename(name: str, fallback: str = "chapter") -> str:
+ """Make a chapter title safe to use as part of a file name."""
+ cleaned = re.sub(r'[\\/:*?"<>|]', " ", name)
+ cleaned = re.sub(r"\s+", " ", cleaned).strip().strip(".")
+ return cleaned[:80] or fallback
+
+ def _narrator_tag(self) -> str:
+ """Narrator name used in output file names (see compute_narrator_tag)."""
+ return self.compute_narrator_tag(
+ self.backend, self.voice, self.voice_mode,
+ self.voice_clone_ref_audio, self.instructions)
+
+ @staticmethod
+ def compute_narrator_tag(backend: str, voice: Optional[str],
+ voice_mode: str,
+ voice_clone_ref_audio: Optional[str],
+ instructions: Optional[str] = None) -> str:
+ """Narrator name used in output file names, without a server connection.
+
+ Custom voice mode uses the built-in speaker's display name; voice
+ clone mode uses the reference audio file's stem; the faster and
+ audiocpp backends use the server-side voice name (falling back to
+ the built-in speaker for the audiocpp backend's speaker mode). An
+ instruction without a voice (voice design, or instruction-defined
+ voices on families without built-in speakers) uses "designed".
+ Spaces become underscores (e.g. "Uncle Fu" -> "Uncle_Fu").
+
+ Pure (no I/O, no server) so the pre-flight overwrite check can
+ compute the exact output names a run would produce before spending
+ time connecting to a TTS server.
+ """
+ if backend == BACKEND_FASTER:
+ narrator = voice or config.FASTER_VOICE
+ elif backend == BACKEND_AUDIOCPP:
+ if voice:
+ narrator = voice
+ elif instructions:
+ # The voice comes from the instruction, not a speaker name.
+ narrator = "designed"
+ else:
+ narrator = speaker_display_name()
+ elif voice_mode == VOICE_MODE_CLONE:
+ narrator = Path(voice_clone_ref_audio).stem
+ else:
+ narrator = speaker_display_name()
+ return AudiobookConverter._sanitize_filename(
+ narrator, fallback="narrator").replace(" ", "_")
+
+ # ------------------------------------------------------------------
+ # Debug dumps (--debug)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _write_debug_text(debug_dir: Path, chunk_num: int, text: str) -> None:
+ """Write the exact text sent for a chunk to the debug folder.
+
+ Called before the request so the text survives a crash mid-generation.
+ A failed debug write must never abort a conversion.
+ """
+ try:
+ debug_dir.mkdir(parents=True, exist_ok=True)
+ (debug_dir / f"chunk_{chunk_num:04d}.txt").write_text(text, encoding="utf-8")
+ except OSError as exc:
+ logger.warning("Could not write debug text for chunk %d: %s", chunk_num, exc)
+
+ @staticmethod
+ def _copy_debug_audio(debug_dir: Path, chunk_num: int, source: Path) -> Optional[Path]:
+ """Copy a generated chunk's audio file into the debug folder.
+
+ Returns the copy's path, or None when the copy failed (which never
+ affects the conversion itself).
+ """
+ try:
+ debug_dir.mkdir(parents=True, exist_ok=True)
+ target = debug_dir / f"chunk_{chunk_num:04d}{source.suffix or '.wav'}"
+ shutil.copy2(source, target)
+ return target
+ except OSError as exc:
+ logger.warning("Could not write debug audio for chunk %d: %s", chunk_num, exc)
+ return None
+
+ @staticmethod
+ def _chapter_debug_dir(book_debug_dir: Optional[Path], index: int, title: str) -> Optional[Path]:
+ """Per-chapter subfolder of a book's debug folder (None when not debugging).
+
+ Chunk numbering restarts for each chapter, so chapters get their own
+ subfolder (e.g. debug/dune_Vivian/03_The_Trial/).
+ """
+ if book_debug_dir is None:
+ return None
+ return book_debug_dir / f"{index:02d}_{AudiobookConverter._sanitize_filename(title)}"
+
+ def convert_book(self, file_path: Path, output_name: Optional[str] = None) -> bool:
+ """Convert a single book to one or more audiobook files."""
+ logger.info("Converting: %s", file_path.name)
+ start_time = time.time()
+
+ try:
+ # Start from a clean scratch folder so a previous crash can never
+ # affect this run
+ audio.cleanup_chunks()
+
+ logger.info("Extracting text...")
+ book = extractors.extract_book(file_path)
+ sections = book.sections
+ if not sections or all(not s.text.strip() for s in sections):
+ logger.error("No text extracted")
+ return False
+
+ stem = output_name or f"{file_path.stem}_{self._narrator_tag()}"
+
+ # --debug: chunk text/audio dumps land in a per-book folder
+ debug_dir = DEBUG_FOLDER / stem if self.debug else None
+
+ # Cover art: generated once per book. Named with the chunk_
+ # prefix so cleanup_chunks() removes it with the other scratch
+ # files at the end of the book.
+ cover_path = cover.generate_cover(
+ book.title, CHUNKS_FOLDER / "chunk_cover.png")
+ if cover_path:
+ print(f"[INFO] Generated cover art for '{book.title}'")
+ meta = TrackMeta(title=book.title, artist=book.author, album=book.title)
+
+ # m4b is always a single file; multi-chapter books get embedded
+ # chapter markers so listeners can skip between chapters.
+ if self.output_format == "m4b":
+ if len(sections) > 1:
+ return self._convert_m4b_with_chapters(sections, stem, start_time,
+ meta=meta, cover=cover_path,
+ debug_dir=debug_dir)
+ output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ return self._convert_text(sections[0].text, output_path, start_time,
+ meta=meta, cover=cover_path, debug_dir=debug_dir)
+
+ if self.single_file or len(sections) == 1:
+ text = "\n\n".join(section.text for section in sections)
+ output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ return self._convert_text(text, output_path, start_time,
+ meta=meta, cover=cover_path, debug_dir=debug_dir)
+
+ success = True
+ for index, section in enumerate(sections, 1):
+ chapter_name = f"{stem}_{index:02d}_{self._sanitize_filename(section.title)}"
+ output_path = AUDIOBOOKS_FOLDER / f"{chapter_name}.{self.output_format}"
+ track_meta = meta._replace(
+ title=(section.title or "").strip() or f"Chapter {index}",
+ track=index, total_tracks=len(sections))
+ success = self._convert_text(
+ section.text, output_path, time.time(),
+ meta=track_meta, cover=cover_path,
+ debug_dir=self._chapter_debug_dir(debug_dir, index, section.title)
+ ) and success
+ return success
+
+ except Exception as exc:
+ logger.error("Conversion failed: %s", exc)
+ logger.error(traceback.format_exc())
+ return False
+ finally:
+ # Always cleanup, even on failure or interrupt
+ audio.cleanup_chunks()
+
+ def _convert_m4b_with_chapters(self, sections, stem: str, start_time: float,
+ meta: Optional[TrackMeta] = None,
+ cover: Optional[Path] = None,
+ debug_dir: Optional[Path] = None) -> bool:
+ """Convert each chapter to audio, then assemble a single m4b with
+ embedded chapter markers.
+
+ Chapters are synthesized to lossless WAV scratch files (~170 MB per
+ hour of audio) so the final AAC pass is the only lossy encode. When
+ ``debug_dir`` is given, each chapter's debug dumps land in its own
+ subfolder (chunk numbering restarts per chapter).
+ """
+ chapter_files = []
+ titles = []
+ total_chapters = len(sections)
+ for index, section in enumerate(sections, 1):
+ chapter_path = CHUNKS_FOLDER / f"chapter_{index:04d}.wav"
+ title = (section.title or "").strip() or f"Chapter {index}"
+ print(f"\n{'=' * 50}")
+ print(f"CHAPTER {index}/{total_chapters}: {title}")
+ print(f"{'=' * 50}")
+ logger.info("Converting chapter %d/%d: %s", index, total_chapters, title)
+ if not self._convert_text(section.text, chapter_path, time.time(),
+ speed=1.0, output_format="wav",
+ chapter=(index, total_chapters),
+ debug_dir=self._chapter_debug_dir(debug_dir, index, title)):
+ logger.error("Chapter %d (%s) failed; aborting the conversion",
+ index, title)
+ return False
+ chapter_files.append(chapter_path)
+ titles.append(title)
+
+ if not chapter_files:
+ logger.error("No chapters were successfully converted")
+ return False
+
+ output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path, speed=self.speed,
+ meta=meta, cover=cover):
+ return False
+ duration = time.time() - start_time
+ logger.info("Conversion completed in %dm %ds: %s",
+ int(duration // 60), int(duration % 60), output_path)
+ return True
+
+ def _synthesize_chunks(self, chunks: List[str],
+ debug_dir: Optional[Path] = None) -> Dict[int, Optional[Path]]:
+ """Synthesize chunks sequentially, preserving order and naming.
+
+ Returns a mapping of chunk number to the generated audio path, with
+ None for chunks that failed. Generation stops at the first failed
+ chunk: a partial audiobook is never assembled, so the remaining
+ chunks are not requested. When ``debug_dir`` is given (--debug),
+ each chunk's request text and returned audio are also dumped there,
+ and every request/response is logged.
+ """
+ total_chunks = len(chunks)
+ if self.client_chunks:
+ print(f"\n{'=' * 50}")
+ print(f"PROCESSING {total_chunks} CHUNKS")
+ print(f"{'=' * 50}")
+
+ results: Dict[int, Optional[Path]] = {}
+ for chunk_num, chunk_text in enumerate(chunks, 1):
+ if debug_dir is not None:
+ # Written before the request so the exact text survives a
+ # crash mid-generation; failed chunks keep their dumps.
+ self._write_debug_text(debug_dir, chunk_num, chunk_text)
+ logger.debug("Chunk %d/%d request text: %s", chunk_num, total_chunks, chunk_text)
+ request_start = time.time()
+ try:
+ result = self.tts.process_chunk_with_retry(chunk_num, chunk_text)
+ results[chunk_num] = result
+
+ if result:
+ if debug_dir is not None:
+ copied = self._copy_debug_audio(debug_dir, chunk_num, Path(result))
+ elapsed = time.time() - request_start
+ destination = f" -> {copied.name}" if copied else ""
+ logger.debug("Chunk %d/%d response in %.1fs%s",
+ chunk_num, total_chunks, elapsed, destination)
+ if self.client_chunks:
+ print(f"[OK] Chunk {chunk_num:3d}/{total_chunks} completed")
+ logger.info("+ Chunk %d/%d completed", chunk_num, total_chunks)
+ else:
+ logger.error("Chunk %d/%d failed; aborting the remaining chunks",
+ chunk_num, total_chunks)
+ break
+
+ except Exception as exc:
+ results[chunk_num] = None
+ logger.error("Chunk %d/%d error: %s; aborting the remaining chunks",
+ chunk_num, total_chunks, exc)
+ break
+
+ successful_chunks = sum(1 for path in results.values() if path)
+ if self.client_chunks:
+ print(f"\n{'=' * 50}")
+ print("CHUNK PROCESSING COMPLETE")
+ print(f"Successful: {successful_chunks}/{total_chunks}")
+ print(f"{'=' * 50}")
+ logger.info("Chunk processing completed: %d/%d chunks", successful_chunks, total_chunks)
+ return results
+
+ def _chapter_chunks(self, text: str) -> List[str]:
+ """Split chapter text into TTS requests.
+
+ Client-side chunking splits into CHUNK_SIZE-word chunks (qwen and
+ faster always; audio.cpp only with --chunk). Otherwise (audio.cpp
+ default) the whole text is one request and the server does its own
+ long-form chunking.
+ """
+ if self.client_chunks:
+ return chunking.split_into_chunks(text)
+ return [text] if text.strip() else []
+
+ def _convert_text(self, text: str, output_path: Path, start_time: float,
+ speed: Optional[float] = None,
+ output_format: Optional[str] = None,
+ chapter: Optional[Tuple[int, int]] = None,
+ meta: Optional[TrackMeta] = None,
+ cover: Optional[Path] = None,
+ debug_dir: Optional[Path] = None) -> bool:
+ """Chunk, synthesize, and assemble ``text`` into ``output_path``.
+
+ When ``chapter`` (a ``(number, total)`` pair) is given, the output is
+ an intermediate per-chapter file and progress messages are phrased
+ accordingly instead of implying the whole book is done. ``debug_dir``
+ (from --debug) receives the chunks' text and audio dumps.
+ """
+ if speed is None:
+ speed = self.speed
+ if output_format is None:
+ output_format = self.output_format
+
+ try:
+ if not text.strip():
+ logger.error("No text to convert for %s", output_path.name)
+ return False
+
+ logger.info("Extracted %d characters (%d words)", len(text), len(text.split()))
+
+ chunks = self._chapter_chunks(text)
+ total_chunks = len(chunks)
+ if total_chunks == 0:
+ logger.error("No chunks created")
+ return False
+
+ chunk_sizes = [len(chunk.split()) for chunk in chunks]
+ avg_chunk_size = sum(chunk_sizes) / len(chunk_sizes)
+ if len(chunks) == 1:
+ logger.info("Sending the whole text as one request (%d words; "
+ "the server chunks long text itself)",
+ chunk_sizes[0])
+ else:
+ logger.info("Split into %d chunks (avg %.0f words per chunk)",
+ total_chunks, avg_chunk_size)
+ backend_labels = {
+ BACKEND_FASTER: "faster TTS API",
+ BACKEND_AUDIOCPP: "audio.cpp server",
+ }
+ backend = backend_labels.get(self.backend, "Qwen API")
+ if self.client_chunks:
+ print(f"[INFO] Processing {total_chunks} chunks via {backend}...")
+ else:
+ # The whole request is sent at once and the server does its
+ # own long-form chunking, so the chunk vocabulary does not
+ # apply; warn that this one request can take a very long time.
+ subject = (f"chapter {chapter[0]}/{chapter[1]}"
+ if chapter is not None else "text")
+ print(f"[INFO] Sending the {subject} to the {backend} as a "
+ "single request...")
+ print("[NOTE] It is expected for this to take a very long "
+ "time: the server synthesizes the entire request before "
+ "returning any audio.")
+
+ results = self._synthesize_chunks(chunks, debug_dir=debug_dir)
+ successful_chunks = sum(1 for path in results.values() if path)
+
+ if successful_chunks < total_chunks:
+ logger.error("Chunk processing incomplete (%d/%d chunks); "
+ "aborting without producing an audiobook",
+ successful_chunks, total_chunks)
+ return False
+
+ success = audio.combine_chunks(total_chunks, output_path, chunk_results=results,
+ speed=speed, output_format=output_format,
+ intermediate=chapter is not None,
+ meta=meta, cover=cover)
+
+ if success:
+ duration = time.time() - start_time
+ minutes = int(duration // 60)
+ seconds = int(duration % 60)
+ if chapter is not None:
+ logger.info("Chapter %d/%d converted in %dm %ds (%d/%d chunks)",
+ chapter[0], chapter[1], minutes, seconds,
+ successful_chunks, total_chunks)
+ if self.client_chunks:
+ print(f"[INFO] Chapter {chapter[0]}/{chapter[1]} converted "
+ f"({successful_chunks}/{total_chunks} chunks)")
+ else:
+ print(f"[INFO] Chapter {chapter[0]}/{chapter[1]} converted")
+ else:
+ logger.info("Conversion completed in %dm %ds: %s", minutes, seconds, output_path)
+ else:
+ logger.error("Failed to combine chunks into final audiobook")
+
+ return success
+
+ except Exception as exc:
+ logger.error("Conversion failed: %s", exc)
+ logger.error(traceback.format_exc())
+ return False
+
+ def _print_banner(self) -> None:
+ """Print the startup summary for the selected backend."""
+ print("=" * 70)
+ print("TTS AUDIOBOOK GENERATOR")
+ print("=" * 70)
+ print(f"Books folder: {BOOKS_FOLDER}")
+ print(f"Output folder: {AUDIOBOOKS_FOLDER}")
+ if self.backend == BACKEND_FASTER:
+ print(f"Faster TTS endpoint: {config.FASTER_API_URL}")
+ print("Backend: faster (voice cloning, reference configured on server)")
+ print(f"Voice: {self.voice or config.FASTER_VOICE}")
+ elif self.backend == BACKEND_AUDIOCPP:
+ print(f"audio.cpp endpoint: {config.AUDIOCPP_API_URL}")
+ print(f"Model id: {self.tts.model_id}")
+ print(f"Model family: {getattr(self.tts, 'family', 'unknown')}")
+ if self.voice:
+ print("Backend: audio.cpp (voice cloning, reference configured on server)")
+ print(f"Voice: {self.voice}")
+ elif self.instructions:
+ print("Backend: audio.cpp (voice from --instructions description)")
+ print(f"Instruction: {self.instructions}")
+ else:
+ print("Backend: audio.cpp (custom voice, built-in speaker)")
+ print(f"Speaker: {config.SPEAKER}")
+ if self.request_options:
+ print(f"Request options: {self.request_options}")
+ if self.client_chunks:
+ print("Chunking: client-side (--chunk; the server also chunks "
+ "long text itself, so this may double-chunk)")
+ else:
+ print("Chunking: server-side (one request per chapter; "
+ "--chunk forces client-side chunking)")
+ print(f"Language: {self.language}")
+ else:
+ api_url = (config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE
+ else config.QWEN_API_URL)
+ print(f"Qwen API endpoint: {api_url}")
+ print(f"Voice mode: {self.voice_mode}")
+ print(f"Model size: {MODEL_SIZE} (always)")
+ if self.voice_mode == VOICE_MODE_CUSTOM:
+ print(f"Speaker: {config.SPEAKER}")
+ print(f"Language: {self.language}")
+ elif self.voice_mode == VOICE_MODE_CLONE:
+ print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}")
+ print(f"Language: {self.language}")
+ print(f"Output format: {self.output_format}")
+ if self.single_file and self.output_format != "m4b":
+ print("Chapter mode: single file (--single-file)")
+ if abs(self.speed - 1.0) >= 1e-6:
+ print(f"Playback speed: {self.speed:g}x")
+ if self.debug:
+ print(f"Debug dumps (per-chunk text + raw audio): {DEBUG_FOLDER}")
+ print("=" * 70)
+
+ # ------------------------------------------------------------------
+ # Pre-flight: overwrite checks before connecting to a TTS server
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def preflight_overwrites(backend: str, voice: Optional[str],
+ voice_mode: str,
+ voice_clone_ref_audio: Optional[str],
+ output_format: str,
+ instructions: Optional[str] = None
+ ) -> Tuple[List[Path], List[Tuple[Path, str]]]:
+ """Discover books and ask every overwrite question up front.
+
+ Pure of the TTS server: it scans the books folder, computes the
+ output name each book would produce (including the narrator tag
+ and stem-collision suffix), and asks whether to overwrite any
+ existing output files. Returns ``(book_files, planned)`` where
+ ``planned`` is the subset the user agreed to (re)convert.
+
+ Asking before connecting means a user who declines a prompt (or has
+ nothing to convert) never waits on a slow server handshake.
+ """
+ book_files = sorted(
+ f for f in BOOKS_FOLDER.iterdir()
+ if f.is_file() and f.suffix.lower() in SUPPORTED_FORMATS
+ )
+ if not book_files:
+ return [], []
+
+ print(f"[INFO] Found {len(book_files)} books to convert")
+
+ # Avoid output collisions when two books share a stem (e.g. dune.txt + dune.epub).
+ stem_counts: Dict[str, int] = Counter(book_file.stem for book_file in book_files)
+
+ # Ask every overwrite question up front, before any conversion
+ # starts, so the rest of the run is unattended.
+ planned: List[Tuple[Path, str]] = []
+ narrator_tag = AudiobookConverter.compute_narrator_tag(
+ backend, voice, voice_mode, voice_clone_ref_audio, instructions)
+ for book_file in book_files:
+ output_name = book_file.stem
+ if stem_counts[book_file.stem] > 1:
+ output_name = f"{book_file.stem}_{book_file.suffix.lstrip('.')}"
+ output_name = f"{output_name}_{narrator_tag}"
+ existing = find_existing_outputs(output_name, output_format)
+ if existing and not prompt_overwrite(existing, output_name):
+ print(f"[INFO] Skipping {book_file.name} (existing output kept)")
+ continue
+ planned.append((book_file, output_name))
+ return book_files, planned
+
+ # ------------------------------------------------------------------
+ # Main conversion loop
+ # ------------------------------------------------------------------
+
+ def run(self) -> bool:
+ """Main conversion process. Returns True if all books converted."""
+ run_start = time.time()
+ self._print_banner()
+
+ # When main() has already done the pre-flight overwrite check, use
+ # its results so the prompts are not asked a second time; otherwise
+ # (e.g. a converter constructed directly) discover and ask here.
+ if getattr(self, "_planned", None) is not None:
+ book_files = self._book_files
+ planned = self._planned
+ else:
+ book_files, planned = AudiobookConverter.preflight_overwrites(
+ self.backend, self.voice, self.voice_mode,
+ self.voice_clone_ref_audio, self.output_format,
+ self.instructions)
+
+ if not book_files:
+ print(f"[INFO] No supported files found in {BOOKS_FOLDER}")
+ print(f"Supported formats: {', '.join(SUPPORTED_FORMATS)}")
+ print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file "
+ f"to {BOOKS_FOLDER} and run again.")
+ return True
+
+ if not planned:
+ print("[INFO] Nothing to convert (all books skipped)")
+ return True
+
+ print(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)")
+
+ results = {}
+ for book_file, output_name in planned:
+ try:
+ success = self.convert_book(book_file, output_name=output_name)
+ results[book_file.name] = success
+ except KeyboardInterrupt:
+ print("\n[WARNING] Conversion interrupted by user")
+ results[book_file.name] = False
+ break
+ except Exception as exc:
+ logger.error("Unexpected error: %s", exc)
+ results[book_file.name] = False
+ if not results[book_file.name]:
+ logger.error("Conversion of %s failed; aborting the remaining books",
+ book_file.name)
+ break
+
+ successful = sum(results.values())
+ total = len(results)
+
+ print("\n" + "=" * 70)
+ print("CONVERSION SUMMARY")
+ print("=" * 70)
+ print(f"Total: {total} | Success: {successful} | Failed: {total - successful}")
+ print("=" * 70)
+
+ for filename, success in results.items():
+ status = "[OK]" if success else "[FAIL]"
+ print(f"{status} {filename}")
+
+ if successful > 0:
+ print(f"\n[INFO] Audiobooks saved to: {AUDIOBOOKS_FOLDER}/")
+
+ elapsed = int(time.time() - run_start)
+ hours, remainder = divmod(elapsed, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ if hours:
+ duration = f"{hours}h {minutes}m {seconds}s"
+ elif minutes:
+ duration = f"{minutes}m {seconds}s"
+ else:
+ duration = f"{seconds}s"
+ print(f"\n[INFO] Generation completed in {duration}")
+ logger.info("Generation completed in %s", duration)
+
+ return total > 0 and successful == total
diff --git a/app/converter/cover.py b/app/converter/cover.py
new file mode 100644
index 0000000..b2d3cb5
--- /dev/null
+++ b/app/converter/cover.py
@@ -0,0 +1,279 @@
+"""Book cover generation: stdlib-only PNG with gradient + title text.
+
+Covers are a vertical gradient between two random light colors with the
+book title rendered on top in white with a black outline and a drop
+shadow, using an embedded 5x7 bitmap font. No third-party image libraries
+are required: PNG scanlines are packed and compressed with zlib/struct
+directly.
+"""
+
+import logging
+import random
+import struct
+import zlib
+from colorsys import hsv_to_rgb
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+COVER_WIDTH = 600
+COVER_HEIGHT = 900
+
+# 5x7 bitmap font for ASCII 32..126. Each glyph is 7 rows of 5 bits
+# (MSB left), encoded as ints for compactness.
+_FONT = {
+ " ": [0, 0, 0, 0, 0, 0, 0],
+ "!": [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],
+ '"': [0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00],
+ "#": [0x0A, 0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x0A],
+ "$": [0x04, 0x0F, 0x14, 0x0E, 0x05, 0x1E, 0x04],
+ "%": [0x18, 0x19, 0x02, 0x04, 0x08, 0x13, 0x03],
+ "&": [0x08, 0x14, 0x14, 0x08, 0x15, 0x12, 0x0D],
+ "'": [0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00],
+ "(": [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
+ ")": [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
+ "*": [0x00, 0x04, 0x15, 0x0E, 0x15, 0x04, 0x00],
+ "+": [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
+ ",": [0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x08],
+ "-": [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
+ ".": [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C],
+ "/": [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x00],
+ "0": [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
+ "1": [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
+ "2": [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
+ "3": [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
+ "4": [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
+ "5": [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
+ "6": [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
+ "7": [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
+ "8": [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
+ "9": [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
+ ":": [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00],
+ ";": [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x04, 0x08],
+ "<": [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02],
+ "=": [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00],
+ ">": [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08],
+ "?": [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],
+ "@": [0x0E, 0x11, 0x17, 0x15, 0x17, 0x10, 0x0E],
+ "A": [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
+ "B": [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
+ "C": [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
+ "D": [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
+ "E": [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
+ "F": [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
+ "G": [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F],
+ "H": [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
+ "I": [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
+ "J": [0x01, 0x01, 0x01, 0x01, 0x01, 0x11, 0x0E],
+ "K": [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
+ "L": [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
+ "M": [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
+ "N": [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
+ "O": [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
+ "P": [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
+ "Q": [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
+ "R": [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
+ "S": [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
+ "T": [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
+ "U": [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
+ "V": [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
+ "W": [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A],
+ "X": [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
+ "Y": [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
+ "Z": [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
+ "[": [0x0E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0E],
+ "\\": [0x00, 0x10, 0x08, 0x04, 0x02, 0x01, 0x00],
+ "]": [0x0E, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0E],
+ "^": [0x04, 0x0A, 0x11, 0x00, 0x00, 0x00, 0x00],
+ "_": [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
+ "`": [0x08, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00],
+ "a": [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F],
+ "b": [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E],
+ "c": [0x00, 0x00, 0x0F, 0x10, 0x10, 0x10, 0x0F],
+ "d": [0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F],
+ "e": [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E],
+ "f": [0x06, 0x08, 0x1E, 0x08, 0x08, 0x08, 0x08],
+ "g": [0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01, 0x1E],
+ "h": [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x11],
+ "i": [0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E],
+ "j": [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C],
+ "k": [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12],
+ "l": [0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
+ "m": [0x00, 0x00, 0x1A, 0x15, 0x15, 0x15, 0x15],
+ "n": [0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x11],
+ "o": [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E],
+ "p": [0x00, 0x00, 0x1E, 0x11, 0x11, 0x1E, 0x10],
+ "q": [0x00, 0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01],
+ "r": [0x00, 0x00, 0x16, 0x09, 0x08, 0x08, 0x08],
+ "s": [0x00, 0x00, 0x0F, 0x10, 0x0E, 0x01, 0x1E],
+ "t": [0x08, 0x08, 0x1E, 0x08, 0x08, 0x08, 0x06],
+ "u": [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D],
+ "v": [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04],
+ "w": [0x00, 0x00, 0x11, 0x15, 0x15, 0x15, 0x0A],
+ "x": [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11],
+ "y": [0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x1E],
+ "z": [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F],
+ "{": [0x06, 0x08, 0x08, 0x04, 0x08, 0x08, 0x06],
+ "|": [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
+ "}": [0x0C, 0x02, 0x02, 0x04, 0x02, 0x02, 0x0C],
+ "~": [0x00, 0x00, 0x08, 0x15, 0x02, 0x00, 0x00],
+}
+
+_GLYPH_WIDTH = 5
+_GLYPH_HEIGHT = 7
+_TEXT_SCALE = 6 # render each font pixel as a 6x6 block
+_TEXT_MARGIN = 60 # horizontal padding when wrapping
+_TEXT_COLOR = (255, 255, 255)
+_STROKE_COLOR = (0, 0, 0)
+_STROKE_WIDTH = 3 # outline thickness in pixels
+_SHADOW_OFFSET = 4 # drop shadow shift in pixels (down-right)
+_SHADOW_DARKEN = 0.55 # shadow keeps 55% of the background color
+
+
+def _random_light_color(rng: random.Random) -> Tuple[int, int, int]:
+ """A random pastel: any hue, low saturation, high value."""
+ hue = rng.random()
+ saturation = rng.uniform(0.25, 0.55)
+ value = rng.uniform(0.82, 0.95)
+ r, g, b = hsv_to_rgb(hue, saturation, value)
+ return int(r * 255), int(g * 255), int(b * 255)
+
+
+def _lerp(a: int, b: int, t: float) -> int:
+ return int(round(a + (b - a) * t))
+
+
+def _text_width(text: str) -> int:
+ """Pixel width of ``text`` at the rendered scale (spaces count too)."""
+ if not text:
+ return 0
+ return (len(text) * (_GLYPH_WIDTH + 1) - 1) * _TEXT_SCALE
+
+
+def _wrap_title(title: str, max_width: int) -> List[str]:
+ """Word-wrap ``title`` into lines that fit ``max_width`` pixels."""
+ words = title.split()
+ if not words:
+ return []
+ lines: List[str] = []
+ current = ""
+ for word in words:
+ candidate = f"{current} {word}".strip()
+ if _text_width(candidate) <= max_width or not current:
+ current = candidate
+ else:
+ lines.append(current)
+ current = word
+ if current:
+ lines.append(current)
+ return lines
+
+
+def _set_pixel(pixels: List[List[Tuple[int, int, int]]], x: int, y: int,
+ color: Tuple[int, int, int], darken: Optional[float] = None) -> None:
+ """Set one pixel: solid ``color``, or darken the existing pixel by ``darken``."""
+ if not 0 <= y < len(pixels):
+ return
+ if not 0 <= x < len(pixels[y]):
+ return
+ if darken is None:
+ pixels[y][x] = color
+ else:
+ r, g, b = pixels[y][x]
+ pixels[y][x] = (int(r * darken), int(g * darken), int(b * darken))
+
+
+def _render_line(pixels: List[List[Tuple[int, int, int]]], text: str, x0: int, y0: int,
+ color: Tuple[int, int, int] = (0, 0, 0),
+ darken: Optional[float] = None) -> None:
+ """Blit one line of bitmap text onto the pixel grid in place.
+
+ With ``darken``, each glyph pixel darkens whatever is underneath it
+ instead of painting a solid color (used for the drop shadow, which
+ stays tinted by the gradient behind it).
+ """
+ for char_index, char in enumerate(text):
+ glyph = _FONT.get(char)
+ if glyph is None:
+ continue
+ x_off = x0 + char_index * (_GLYPH_WIDTH + 1) * _TEXT_SCALE
+ for gy, bits in enumerate(glyph):
+ for gx in range(_GLYPH_WIDTH):
+ if not bits & (1 << (_GLYPH_WIDTH - 1 - gx)):
+ continue
+ for dy in range(_TEXT_SCALE):
+ for dx in range(_TEXT_SCALE):
+ _set_pixel(pixels,
+ x_off + gx * _TEXT_SCALE + dx,
+ y0 + gy * _TEXT_SCALE + dy,
+ color, darken)
+
+
+def _encode_png(width: int, height: int, pixels: List[List[Tuple[int, int, int]]]) -> bytes:
+ """Encode an RGB pixel grid as a PNG using only the stdlib."""
+ def chunk(chunk_type: bytes, data: bytes) -> bytes:
+ payload = chunk_type + data
+ return (struct.pack(">I", len(data)) + payload
+ + struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF))
+
+ raw = b"".join(
+ b"\x00" + bytes(channel for pixel in scanline for channel in pixel)
+ for scanline in pixels
+ )
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8-bit RGB
+ return (b"\x89PNG\r\n\x1a\n"
+ + chunk(b"IHDR", ihdr)
+ + chunk(b"IDAT", zlib.compress(raw, 9))
+ + chunk(b"IEND", b""))
+
+
+def generate_cover(title: str, path: Path, width: int = COVER_WIDTH,
+ height: int = COVER_HEIGHT, seed: Optional[int] = None) -> Optional[Path]:
+ """Write a gradient cover PNG with ``title`` centered on it.
+
+ ``seed`` makes the gradient reproducible (used by tests). Returns the
+ path on success, or None when the PNG cannot be written (the audiobook
+ still gets tags, just without cover art).
+ """
+ rng = random.Random(seed)
+ top_color = _random_light_color(rng)
+ bottom_color = _random_light_color(rng)
+
+ pixels = []
+ for y in range(height):
+ t = y / max(1, height - 1)
+ color = (_lerp(top_color[0], bottom_color[0], t),
+ _lerp(top_color[1], bottom_color[1], t),
+ _lerp(top_color[2], bottom_color[2], t))
+ pixels.append([color] * width)
+
+ lines = _wrap_title((title or "").strip(), width - 2 * _TEXT_MARGIN)
+ if lines:
+ line_height = _GLYPH_HEIGHT * _TEXT_SCALE + _TEXT_SCALE * 3
+ total_height = len(lines) * line_height
+ y_start = max(0, (height - total_height) // 2)
+ for line_index, line in enumerate(lines):
+ x0 = (width - _text_width(line)) // 2
+ y0 = y_start + line_index * line_height
+ # Paint order: drop shadow (the outlined glyph's full silhouette
+ # — glyph dilated by the stroke width — shifted down-right and
+ # darkened, tinted by the gradient underneath), then the black
+ # outline traced around the glyph, then the solid white text.
+ for dx in range(-_STROKE_WIDTH, _STROKE_WIDTH + 1):
+ for dy in range(-_STROKE_WIDTH, _STROKE_WIDTH + 1):
+ _render_line(pixels, line, x0 + dx + _SHADOW_OFFSET,
+ y0 + dy + _SHADOW_OFFSET, darken=_SHADOW_DARKEN)
+ for dx in range(-_STROKE_WIDTH, _STROKE_WIDTH + 1):
+ for dy in range(-_STROKE_WIDTH, _STROKE_WIDTH + 1):
+ _render_line(pixels, line, x0 + dx, y0 + dy,
+ color=_STROKE_COLOR)
+ _render_line(pixels, line, x0, y0, color=_TEXT_COLOR)
+
+ try:
+ path.write_bytes(_encode_png(width, height, pixels))
+ except OSError as exc:
+ logger.warning("Could not write cover image %s: %s", path, exc)
+ return None
+ logger.info("Generated cover: %s (gradient %s -> %s)", path, top_color, bottom_color)
+ return path
diff --git a/app/converter/extractors.py b/app/converter/extractors.py
new file mode 100644
index 0000000..a564333
--- /dev/null
+++ b/app/converter/extractors.py
@@ -0,0 +1,328 @@
+"""Text extraction from book files (TXT, PDF, EPUB) and text/HTML cleaning."""
+
+import codecs
+import logging
+import re
+import zipfile
+from html import unescape
+from pathlib import Path
+from typing import List, NamedTuple
+
+try:
+ from bs4 import BeautifulSoup
+ BS4_AVAILABLE = True
+except ImportError:
+ BS4_AVAILABLE = False
+
+logger = logging.getLogger(__name__)
+
+
+class Section(NamedTuple):
+ """A titled chunk of a book (e.g. an EPUB chapter)."""
+
+ title: str
+ text: str
+
+
+class Book(NamedTuple):
+ """A book's metadata plus its titled sections."""
+
+ title: str
+ author: str
+ sections: List[Section]
+
+
+def extract_text(file_path: Path) -> str:
+ """Extract text from a book file based on its extension."""
+ extension = file_path.suffix.lower()
+ if extension == ".txt":
+ return _extract_txt(file_path)
+ if extension == ".pdf":
+ return _extract_pdf(file_path)
+ if extension == ".epub":
+ return extract_epub(file_path)
+ raise ValueError(f"Unsupported file format: {extension}")
+
+
+def extract_sections(file_path: Path) -> List[Section]:
+ """Extract the book's text as titled sections (chapters).
+
+ EPUB files are split on their spine documents so they can be converted
+ one chapter at a time. TXT and PDF files have no chapter structure and
+ always yield a single section.
+ """
+ if file_path.suffix.lower() == ".epub":
+ chapters = _extract_epub_chapters(file_path)
+ if not chapters:
+ raise RuntimeError("All EPUB extraction methods failed")
+ return chapters
+
+ return [Section(file_path.stem, extract_text(file_path))]
+
+
+def extract_book(file_path: Path) -> Book:
+ """Extract sections plus book-level metadata (title, author).
+
+ EPUB and PDF files carry embedded metadata; missing fields (and TXT
+ files, which have none) fall back to the file stem for the title and
+ an empty author.
+ """
+ title, author = "", ""
+ extension = file_path.suffix.lower()
+ if extension == ".epub":
+ title, author = _epub_metadata(file_path)
+ elif extension == ".pdf":
+ title, author = _pdf_metadata(file_path)
+ return Book(title or file_path.stem, author.strip(), extract_sections(file_path))
+
+
+def _epub_metadata(file_path: Path) -> tuple:
+ """Return (title, author) from an EPUB's Dublin Core metadata."""
+ try:
+ import ebooklib
+ from ebooklib import epub
+
+ book = epub.read_epub(str(file_path))
+ title = _first_dc_value(book.get_metadata("DC", "title"))
+ author = _first_dc_value(book.get_metadata("DC", "creator"))
+ return title, author
+ except Exception as exc:
+ logger.warning("Could not read EPUB metadata: %s", exc)
+ return "", ""
+
+
+def _pdf_metadata(file_path: Path) -> tuple:
+ """Return (title, author) from a PDF's document info dictionary."""
+ try:
+ from pypdf import PdfReader
+
+ reader = PdfReader(str(file_path))
+ info = reader.metadata or {}
+ title = str(info.get("/Title") or "")
+ author = str(info.get("/Author") or "")
+ return title, author
+ except Exception as exc:
+ logger.warning("Could not read PDF metadata: %s", exc)
+ return "", ""
+
+
+def _first_dc_value(entries) -> str:
+ """First value of an ebooklib DC metadata list: [(value, ...), ...]."""
+ if not entries:
+ return ""
+ value = entries[0][0]
+ return str(value).strip() if value else ""
+
+
+def _extract_epub_chapters(file_path: Path) -> List[Section]:
+ """Return one Section per EPUB spine document (chapter), in reading order."""
+ import ebooklib
+
+ book = None
+ for method in (_read_epub_ebooklib, _read_epub_zipfile, _read_epub_manual):
+ try:
+ book = method(file_path)
+ except Exception as exc:
+ logger.warning("EPUB chapter method %s failed: %s", method.__name__, exc)
+ continue
+ if book:
+ break
+
+ if book is None:
+ return []
+
+ chapters = []
+ for title, text in book:
+ cleaned = clean_html(text)
+ if cleaned.strip():
+ chapters.append(Section(title or file_path.stem, cleaned))
+ return chapters
+
+
+def _toc_titles(book) -> dict:
+ """Flatten an ebooklib TOC into a ``{href: title}`` mapping."""
+ titles = {}
+
+ def walk(nodes) -> None:
+ for node in nodes:
+ if isinstance(node, (tuple, list)):
+ walk(node[1] if len(node) > 1 else [])
+ continue
+ href = getattr(node, "href", None)
+ title = getattr(node, "title", None)
+ if href and title:
+ titles[href.split("#")[0]] = title
+
+ walk(book.toc)
+ return titles
+
+
+def _read_epub_ebooklib(file_path: Path):
+ """Read EPUB spine documents as (title, html) pairs via ebooklib."""
+ import ebooklib
+ from ebooklib import epub
+
+ book = epub.read_epub(str(file_path))
+ titles = _toc_titles(book)
+ items = []
+ for entry in book.spine:
+ item_id = entry[0] if isinstance(entry, (tuple, list)) else entry
+ try:
+ item = book.get_item_with_id(item_id)
+ except Exception as exc:
+ logger.debug("Skipping EPUB spine item %r: %s", item_id, exc)
+ continue
+ if not item or item.get_type() != ebooklib.ITEM_DOCUMENT:
+ continue
+ if isinstance(item, epub.EpubNav):
+ continue
+ content = item.get_body_content()
+ if content:
+ if isinstance(content, bytes):
+ content = content.decode("utf-8", errors="ignore")
+ title = (titles.get(item.file_name)
+ or titles.get(item.get_name())
+ or getattr(item, "title", None)
+ or item.get_name())
+ items.append((title, str(content)))
+ return items
+
+
+def _read_epub_zipfile(file_path: Path):
+ """Read EPUB HTML members as (title, html) pairs, ordered by filename."""
+ items = []
+ with zipfile.ZipFile(file_path, "r") as epub_zip:
+ for file_name in sorted(epub_zip.namelist(), key=_natural_key):
+ if file_name.lower().endswith((".html", ".xhtml", ".htm")):
+ try:
+ content = epub_zip.read(file_name).decode("utf-8", errors="ignore")
+ items.append((Path(file_name).stem, content))
+ except Exception as exc:
+ logger.debug("Skipping EPUB member %r: %s", file_name, exc)
+ return items
+
+
+def _read_epub_manual(file_path: Path):
+ """Last-resort read of any markup-looking EPUB member."""
+ skipped_extensions = (".jpg", ".jpeg", ".png", ".gif", ".css", ".js")
+ items = []
+ with zipfile.ZipFile(file_path, "r") as epub_zip:
+ for file_name in sorted(epub_zip.namelist(), key=_natural_key):
+ if file_name.lower().endswith(skipped_extensions):
+ continue
+ try:
+ content = epub_zip.read(file_name).decode("utf-8", errors="ignore")
+ if "<" in content and len(content.strip()) > 100:
+ items.append((Path(file_name).stem, content))
+ except Exception as exc:
+ logger.debug("Skipping EPUB member %r: %s", file_name, exc)
+ return items
+
+
+def clean_text(text: str) -> str:
+ """Normalize whitespace and strip standalone page numbers.
+
+ Page numbers are removed only when they appear as a short number alone on
+ its own line (before whitespace collapsing), so inline numbers like
+ "42 years", "1,000" or "3.5" are preserved.
+ """
+ if not text:
+ return ""
+ # Standalone page numbers (digits alone on a line) must go BEFORE the
+ # newline-collapsing step below.
+ text = re.sub(r"(?m)^\s*\d{1,4}\s*$", " ", text)
+ text = re.sub(r"\s+", " ", text)
+ return text.strip()
+
+
+def clean_html(html_content: str) -> str:
+ """Strip markup, scripts and styles from HTML content."""
+ if not html_content:
+ return ""
+
+ if BS4_AVAILABLE:
+ try:
+ soup = BeautifulSoup(html_content, "html.parser")
+ for tag in soup(["script", "style"]):
+ tag.decompose()
+ text = soup.get_text(separator=" ")
+ return re.sub(r"\s+", " ", text).strip()
+ except Exception as exc:
+ logger.debug("BeautifulSoup cleaning failed, falling back to regex: %s", exc)
+
+ # Fallback regex cleaning
+ html_content = re.sub(r"<style[^>]*>.*?</style>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
+ html_content = re.sub(r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
+ html_content = re.sub(r"<[^>]+>", " ", html_content)
+ html_content = unescape(html_content)
+ html_content = re.sub(r"\s+", " ", html_content)
+ return html_content.strip()
+
+
+def extract_epub(file_path: Path) -> str:
+ """Extract the book's text from EPUB, trying several methods in order."""
+ chapters = _extract_epub_chapters(file_path)
+ if not chapters:
+ raise RuntimeError("All EPUB extraction methods failed")
+ return "\n\n".join(section.text for section in chapters)
+
+
+def _natural_key(name: str):
+ """Sort key that orders numeric runs numerically (chapter2 before chapter10)."""
+ return [int(part) if part.isdigit() else part.lower()
+ for part in re.split(r"(\d+)", name)]
+
+
+def _extract_txt(file_path: Path) -> str:
+ """Extract from TXT, handling BOMs and common encodings (latin-1 is the catch-all).
+
+ UTF-16 files without a BOM are detected via NUL bytes; otherwise they would
+ silently decode as NUL-interleaved UTF-8 or cp1252/latin-1 garbage.
+ """
+ data = file_path.read_bytes()
+
+ if data.startswith((codecs.BOM_UTF32_LE, codecs.BOM_UTF32_BE)):
+ return clean_text(data.decode("utf-32"))
+ if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
+ return clean_text(data.decode("utf-16"))
+ if data.startswith(codecs.BOM_UTF8):
+ return clean_text(data.decode("utf-8-sig"))
+
+ # No BOM: UTF-16 without BOM is common on Windows; detect via NUL bytes.
+ sample = data[:4096]
+ even_nuls = sum(1 for i, b in enumerate(sample) if b == 0 and i % 2 == 0)
+ odd_nuls = sum(1 for i, b in enumerate(sample) if b == 0 and i % 2 == 1)
+ if even_nuls or odd_nuls:
+ encoding = "utf-16-be" if even_nuls > odd_nuls else "utf-16-le"
+ return clean_text(data.decode(encoding))
+
+ for encoding in ("utf-8", "cp1252", "latin-1"):
+ try:
+ return clean_text(data.decode(encoding))
+ except UnicodeError:
+ continue
+ raise ValueError(f"Could not decode text file: {file_path}")
+
+
+def _extract_pdf(file_path: Path) -> str:
+ """Extract from PDF."""
+ from pypdf import PdfReader
+
+ text = ""
+ with open(file_path, "rb") as file:
+ pdf_reader = PdfReader(file)
+ total_pages = len(pdf_reader.pages)
+ logger.info("PDF has %d pages", total_pages)
+
+ for page_num, page in enumerate(pdf_reader.pages, 1):
+ try:
+ page_text = page.extract_text() or ""
+ if page_text.strip():
+ text += f"\n\n{page_text}"
+ if page_num % 10 == 0:
+ logger.debug("Extracted %d/%d pages", page_num, total_pages)
+ except Exception as exc:
+ logger.warning("Failed to extract page %d: %s", page_num, exc)
+
+ logger.info("Extracted text from %d pages, %d characters total", total_pages, len(text))
+ return clean_text(text)
diff --git a/app/converter/tts.py b/app/converter/tts.py
new file mode 100644
index 0000000..6ccef56
--- /dev/null
+++ b/app/converter/tts.py
@@ -0,0 +1,1305 @@
+"""Client wrappers for the TTS backends.
+
+QwenTTSClient talks to the Qwen3-TTS demo server (custom voice / voice clone).
+FasterTTSClient talks to the OpenAI-compatible server from the
+faster-qwen3-tts repository (voice cloning only; the reference voice is
+configured server-side — see the "Faster backend" section of the README).
+AudioCppTTSClient talks to the audiocpp_server from the audio.cpp
+repository, which can host any TTS model family audio.cpp supports
+(Qwen3-TTS, Higgs Audio, VoxCPM2, IndexTTS2, ...) through one OpenAI-style
+API; the family is detected from the server at startup (see the
+"audio.cpp backend" sections of the README).
+"""
+
+import contextlib
+import io
+import json
+import logging
+import random
+import shutil
+import sys
+import tempfile
+import threading
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+import wave
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+from . import config
+from .audio import concat_audio_files
+from .chunking import split_into_chunks
+
+logger = logging.getLogger(__name__)
+
+# Voice modes (re-exported for the CLI and the converter orchestrator).
+VOICE_MODE_CUSTOM = "custom_voice"
+VOICE_MODE_CLONE = "voice_clone"
+VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE)
+
+# TTS backends (re-exported for the CLI and the converter orchestrator).
+BACKEND_QWEN = "qwen"
+BACKEND_FASTER = "faster"
+BACKEND_AUDIOCPP = "audiocpp"
+BACKENDS = (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER)
+
+# Languages understood by the Qwen3-TTS API. Display names must match the
+# demo dropdown exactly (the demo silently falls back to "Auto" for
+# unrecognized values, so languages are validated client-side first).
+TTS_LANGUAGES = (
+ "Auto",
+ "Chinese",
+ "English",
+ "German",
+ "Italian",
+ "Portuguese",
+ "Spanish",
+ "Japanese",
+ "Korean",
+ "French",
+ "Russian",
+)
+
+# Short aliases accepted on the command line (ISO 639-1 codes and common
+# shorthands), mapped to the display names above.
+TTS_LANGUAGE_ALIASES = {
+ "zh": "Chinese",
+ "en": "English",
+ "de": "German",
+ "it": "Italian",
+ "pt": "Portuguese",
+ "es": "Spanish",
+ "ja": "Japanese",
+ "ko": "Korean",
+ "fr": "French",
+ "ru": "Russian",
+ "zh-cn": "Chinese",
+ "zh-tw": "Chinese",
+ "pt-br": "Portuguese",
+ "en-us": "English",
+ "en-gb": "English",
+}
+
+# Qwen display names -> ISO 639-1 codes, for audio.cpp families whose
+# language request option takes a code instead of a display name. "Auto"
+# has no code and maps to None so the field is omitted and the server
+# applies its own default.
+LANGUAGE_ISO_CODES = {
+ "Chinese": "zh",
+ "English": "en",
+ "German": "de",
+ "Italian": "it",
+ "Portuguese": "pt",
+ "Spanish": "es",
+ "Japanese": "ja",
+ "Korean": "ko",
+ "French": "fr",
+ "Russian": "ru",
+}
+
+# --- audio.cpp model families ---------------------------------------------
+#
+# audiocpp_server exposes the same OpenAI-style API for every TTS family it
+# hosts; families only differ in a few request conventions, captured here as
+# profiles. Families that are not listed use the default profile below.
+
+# How the "language" request field is expressed by a family.
+AUDIOCPP_LANG_DISPLAY = "display" # Qwen display names, e.g. "English"
+AUDIOCPP_LANG_ISO = "iso" # ISO 639-1 codes, e.g. "en"
+AUDIOCPP_LANG_OMIT = "omit" # no language field; the model detects it
+
+# The only family with a built-in speaker mode (CustomVoice speaker names
+# plus the INSTRUCT style prompt). Every other family is clone-only: the
+# voice comes from a server-side preset requested with --voice.
+AUDIOCPP_FAMILY_QWEN3_TTS = "qwen3_tts"
+
+# Server model entry tasks this client can synthesize audiobooks with,
+# taken from GET /v1/models (the "task" field of each entry; servers that
+# predate the field reported TTS models only, so a missing task is treated
+# as "tts"). "vdes" entries are voice design models: the voice is described
+# with --instructions instead of coming from a speaker or a reference clip.
+# Entries with any other task (asr, vc, diar, ...) are rejected at connect
+# time with a hint to pick a synthesis entry.
+AUDIOCPP_TASK_TTS = "tts"
+AUDIOCPP_TASK_VDES = "vdes"
+AUDIOCPP_SYNTHESIS_TASKS = (AUDIOCPP_TASK_TTS, "clon", AUDIOCPP_TASK_VDES)
+
+
+class AudioCppFamilyProfile:
+ """Request conventions of one audio.cpp model family."""
+
+ def __init__(self, language_style: str = AUDIOCPP_LANG_OMIT,
+ sends_instructions: bool = False,
+ builtin_speakers: bool = False):
+ self.language_style = language_style
+ self.sends_instructions = sends_instructions
+ self.builtin_speakers = builtin_speakers
+
+
+# Generic profile for families not listed in AUDIOCPP_FAMILY_PROFILES:
+# clone-only, no style instructions, and no language field (the model
+# detects the language itself). Describes higgs_audio_tts, voxcpm2,
+# fish_audio, dots_tts, dramabox, omnivoice, outetts, glm_tts, miotts,
+# moss_tts_*, pocket_tts, vibevoice, ... as well as families added to
+# audio.cpp after this table was written.
+AUDIOCPP_DEFAULT_FAMILY_PROFILE = AudioCppFamilyProfile()
+
+AUDIOCPP_FAMILY_PROFILES = {
+ AUDIOCPP_FAMILY_QWEN3_TTS: AudioCppFamilyProfile(
+ language_style=AUDIOCPP_LANG_DISPLAY,
+ sends_instructions=True,
+ builtin_speakers=True,
+ ),
+ # Families whose language option takes a code (e.g. "en") instead of
+ # a Qwen display name; otherwise clone-only like the default profile.
+ "chatterbox": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO),
+ "confucius4_tts": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO),
+ "index_tts2": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO),
+ "magpie_tts": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO),
+ "supertonic": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO),
+}
+
+# Canonical speaker names -> display names used by the qwen-tts demo.
+SPEAKER_DISPLAY_NAMES = {
+ "ryan": "Ryan",
+ "serena": "Serena",
+ "vivian": "Vivian",
+ "uncle_fu": "Uncle Fu",
+ "aiden": "Aiden",
+ "ono_anna": "Ono Anna",
+ "sohee": "Sohee",
+ "eric": "Eric",
+ "dylan": "Dylan",
+}
+
+# Fixed model facts: both demos run the 1.7B model (the CustomVoice demo
+# takes its full HuggingFace id), and the 12Hz codec outputs 24 kHz audio.
+MODEL_SIZE = "1.7B"
+CUSTOM_VOICE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
+SAMPLE_RATE = 24000
+
+CHUNKS_FOLDER = Path(__file__).resolve().parent.parent.parent / "app" / "chunks"
+
+
+def _resolve_request_seed() -> int:
+ """Resolve the seed sent with every request.
+
+ Returns config.SEED as-is, or (with CONSTANT_SEED and SEED < 0) one
+ random value drawn per run, meant to be reused for every request so
+ the voice stays consistent across chunk boundaries. Without
+ CONSTANT_SEED, -1 is returned so the server re-samples the voice on
+ every generation.
+ """
+ seed = config.SEED
+ if config.CONSTANT_SEED and seed < 0:
+ seed = random.randrange(2 ** 31)
+ return seed
+
+
+def speaker_display_name() -> str:
+ """Return the display name for the configured custom speaker."""
+ return SPEAKER_DISPLAY_NAMES.get(
+ config.SPEAKER.lower(), config.SPEAKER)
+
+
+def normalize_language(value: Optional[str]) -> str:
+ """Normalize a user-provided language name to a Qwen3-TTS display name.
+
+ Accepts the display names in TTS_LANGUAGES case-insensitively as
+ well as the short aliases in TTS_LANGUAGE_ALIASES (ISO 639-1 codes
+ and common shorthands). Raises ValueError for anything else, since the
+ Qwen3-TTS demo silently falls back to "Auto" for unrecognized languages.
+ """
+ if value is None:
+ raise ValueError("Language must not be None")
+ candidate = value.strip()
+ if not candidate:
+ raise ValueError("Language must not be empty")
+ for name in TTS_LANGUAGES:
+ if candidate.lower() == name.lower():
+ return name
+ alias = TTS_LANGUAGE_ALIASES.get(candidate.lower())
+ if alias:
+ return alias
+ raise ValueError(
+ f"Unknown language: {value!r}. Expected one of "
+ f"{', '.join(TTS_LANGUAGES)} (or an alias: "
+ f"{', '.join(sorted(TTS_LANGUAGE_ALIASES))})."
+ )
+
+
+def transcribe_reference_audio(audio_path: str, model_name: str = "base") -> Optional[str]:
+ """Transcribe reference audio locally using an optional Whisper backend.
+
+ The current qwen-tts demo does not expose a transcription endpoint, so
+ transcription is done client-side when a Whisper package is available.
+ Returns None if no backend is installed.
+ """
+ for backend in ("faster_whisper", "whisper"):
+ try:
+ if backend == "faster_whisper":
+ from faster_whisper import WhisperModel
+ model = WhisperModel(model_name, device="cpu", compute_type="int8")
+ segments, _ = model.transcribe(audio_path)
+ text = " ".join(seg.text.strip() for seg in segments).strip()
+ else:
+ import whisper
+ model = whisper.load_model(model_name)
+ result = model.transcribe(audio_path)
+ text = (result.get("text") or "").strip()
+ if text:
+ logger.info("Transcription complete via %s: %s", backend, text)
+ return text
+ except ImportError:
+ continue
+ except Exception as exc:
+ logger.warning("%s transcription failed: %s", backend, exc)
+ logger.warning("No Whisper backend available; transcription skipped.")
+ return None
+
+
+def whisper_backend_available() -> Optional[str]:
+ """Return the name of an importable Whisper backend, or None.
+
+ Checks faster_whisper first (preferred), then the openai-whisper
+ package, without importing the heavy model code: a bare import probe
+ is enough to tell whether the package is installed in the current
+ environment. Used by the make_audiocpp_server_json tool to warn when
+ neither is present (e.g. the wrong conda environment is active).
+ """
+ for backend in ("faster_whisper", "whisper"):
+ try:
+ __import__(backend)
+ except ImportError:
+ continue
+ return backend
+ return None
+
+
+# 150 wpm is a typical spoken pace; used only to size the HTTP request
+# timeout for long audio.cpp generations (not as a correctness check).
+_ESTIMATED_WORDS_PER_MINUTE = 150
+
+
+class _BaseTTSClient:
+ """Shared chunk retry logic, heartbeat, and chunk file bookkeeping."""
+
+ def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
+ """Generate one audio chunk; returns its path in the chunks folder."""
+ raise NotImplementedError
+
+ def _chunk_path(self, chunk_num: int, suffix: str) -> Path:
+ """Resolve the target path for a chunk, removing stale files first.
+
+ Any stale chunk file for this index is removed so a retry or extension
+ change can never leave two files matching chunk_NNNN.*.
+ """
+ for stale in CHUNKS_FOLDER.glob(f"chunk_{chunk_num:04d}.*"):
+ try:
+ stale.unlink()
+ except OSError as exc:
+ logger.debug("Could not remove stale chunk file %s: %s", stale, exc)
+ return CHUNKS_FOLDER / f"chunk_{chunk_num:04d}{suffix}"
+
+ def process_chunk_with_retry(self, chunk_num: int, text: str) -> Optional[Path]:
+ """Process a chunk with retry logic.
+
+ Returns the generated chunk file's path, or None when all attempts
+ failed.
+ """
+ for attempt in range(config.MAX_RETRIES):
+ try:
+ result = self.generate_chunk(text, chunk_num)
+ if result and Path(result).exists():
+ return Path(result)
+ logger.warning("Chunk %d attempt %d failed", chunk_num, attempt + 1)
+ except Exception as exc:
+ logger.warning("Chunk %d attempt %d error: %s", chunk_num, attempt + 1, exc)
+
+ if attempt < config.MAX_RETRIES - 1:
+ sleep_time = 5 + (2 ** attempt)
+ logger.info("Waiting %ds before retry...", sleep_time)
+ time.sleep(sleep_time)
+
+ logger.error("Chunk %d failed after %d attempts", chunk_num, config.MAX_RETRIES)
+ return None
+
+ @contextlib.contextmanager
+ def _chunk_heartbeat(self, chunk_num: int, label: Optional[str] = None):
+ """Print a periodic "still working" message while a request generates.
+
+ ``label`` overrides the default "Chunk {chunk_num}" subject, for
+ backends that send one request per chapter without client-side
+ chunking (the audio.cpp default) where "chunk" would be misleading.
+ """
+ stop = threading.Event()
+ subject = label if label is not None else f"Chunk {chunk_num}"
+
+ def _beat():
+ start = time.time()
+ while not stop.wait(config.HEARTBEAT_INTERVAL_SECONDS):
+ elapsed = time.time() - start
+ print(f"[...] {subject} still generating — "
+ f"{int(elapsed // 60)}m {int(elapsed % 60)}s elapsed", flush=True)
+
+ thread = threading.Thread(target=_beat, daemon=True)
+ thread.start()
+ try:
+ yield
+ finally:
+ stop.set()
+ thread.join()
+
+
+class QwenTTSClient(_BaseTTSClient):
+ """Generates audio chunks through a Qwen3-TTS demo server."""
+
+ def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None,
+ voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
+ language: Optional[str] = None):
+ if voice_mode not in VOICE_MODES:
+ raise ValueError(
+ f"Unknown voice mode: {voice_mode!r} (expected one of {VOICE_MODES})"
+ )
+ self.voice_mode = voice_mode
+ self.voice_clone_ref_audio = voice_clone_ref_audio
+ self.voice_clone_ref_text = (voice_clone_ref_text or "").strip()
+ self.skip_transcription = skip_transcription
+ # Seed sent with every request: config.SEED as-is, or (with
+ # CONSTANT_SEED and SEED < 0) one random value drawn per run and
+ # reused for every request so the voice stays consistent across
+ # chunk boundaries. Without CONSTANT_SEED, -1 is forwarded so the
+ # server re-samples the voice on every generation.
+ self._seed = _resolve_request_seed()
+ if language is None:
+ language = config.LANGUAGE
+ # Validate before connecting so bad values fail fast without a server.
+ self.language = normalize_language(language)
+ self.client = None
+ self.api_info: Dict[str, Any] = {}
+ self.clone_client = None
+ self.clone_api_info: Dict[str, Any] = {}
+ self._ref_audio_filedata: Optional[Dict[str, Any]] = None
+ self._connect()
+
+ # ------------------------------------------------------------------
+ # Connection
+ # ------------------------------------------------------------------
+
+ def _connect(self) -> None:
+ api_url = config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE else config.QWEN_API_URL
+ try:
+ if self.voice_mode == VOICE_MODE_CLONE:
+ # Voice clone uses the Base-model demo, which is a separate server
+ # from the CustomVoice demo (that one only exposes /run_instruct).
+ self._init_client(config.CLONE_API_URL, clone=True)
+ print(f"[OK] Connected to Voice Clone API at {config.CLONE_API_URL}")
+ self._resolve_reference_text()
+ else:
+ self._init_client(config.QWEN_API_URL, clone=False)
+ print("[OK] Connected to Qwen API")
+ except Exception as exc:
+ raise RuntimeError(
+ f"Qwen API initialization failed at {api_url}: {exc}. "
+ "Make sure the Qwen demo server is running and reachable, and that your "
+ "installed Qwen3-TTS version matches this converter's API expectations "
+ "(voice clone requires the Base-model demo: Qwen/Qwen3-TTS-12Hz-1.7B-Base)."
+ ) from exc
+
+ def _resolve_reference_text(self) -> None:
+ """Resolve the reference transcript: explicit text, then local
+ transcription, then x-vector-only mode."""
+ if not self.voice_clone_ref_text and self.voice_clone_ref_audio:
+ if self.skip_transcription:
+ print("[INFO] Skipping reference audio transcription (--no-transcription).")
+ else:
+ print("[INFO] Transcribing reference audio for voice cloning...")
+ self.voice_clone_ref_text = self.transcribe_audio(self.voice_clone_ref_audio) or ""
+ if not self.voice_clone_ref_text:
+ print("[WARNING] No reference text available; using x-vector-only clone mode (lower quality).")
+ print(' Pass --transcription "..." for higher-quality in-context cloning.')
+ else:
+ print(f"[OK] Reference text:\n{self.voice_clone_ref_text}")
+
+ def _init_client(self, url: str, clone: bool = False) -> None:
+ """Initialize a Gradio client and store its API metadata.
+
+ gradio_client prints its usage info directly to stdout while the
+ client is created and its API metadata loaded, so stdout is swapped
+ for a buffer for the whole process; the captured text is re-emitted
+ at DEBUG level for troubleshooting.
+ """
+ from gradio_client import Client
+
+ logger.info("Connecting to Qwen API at %s...", url)
+ old_stdout = sys.stdout
+ captured = io.StringIO()
+ sys.stdout = captured
+ try:
+ try:
+ client = Client(url, httpx_kwargs={"timeout": config.API_TIMEOUT})
+ except TypeError:
+ # Older gradio_client versions don't support httpx_kwargs.
+ client = Client(url)
+ if clone:
+ self.clone_client = client
+ self.clone_api_info = self._load_api_info(client)
+ else:
+ self.client = client
+ self.api_info = self._load_api_info(client)
+ finally:
+ sys.stdout = old_stdout
+ usage_info = captured.getvalue().strip()
+ if usage_info:
+ logger.debug("Gradio client output for %s:\n%s", url, usage_info)
+ logger.info("Connected to Qwen API")
+
+ @staticmethod
+ def _load_api_info(client) -> Dict[str, Any]:
+ """Load available API metadata from the Gradio app."""
+ try:
+ return client.view_api(return_format="dict")
+ except Exception as exc:
+ logger.warning("Unable to read API metadata: %s", exc)
+ return {}
+
+ def _resolve_api_name(self, *candidates: str, api_info: Optional[Dict[str, Any]] = None) -> str:
+ """Return the first available api_name from candidate list."""
+ info = api_info if api_info is not None else self.api_info
+ named_endpoints = info.get("named_endpoints", {})
+ for candidate in candidates:
+ if candidate in named_endpoints:
+ return candidate
+ return candidates[0]
+
+ def _endpoint_accepts_param(self, api_name: str, param_name: str,
+ api_info: Optional[Dict[str, Any]] = None) -> bool:
+ """Check whether endpoint input schema includes the given parameter."""
+ info = api_info if api_info is not None else self.api_info
+ endpoint = info.get("named_endpoints", {}).get(api_name, {})
+ parameters = endpoint.get("parameters", [])
+ return any(parameter.get("parameter_name") == param_name for parameter in parameters)
+
+ # ------------------------------------------------------------------
+ # Reference audio transcription (voice clone)
+ # ------------------------------------------------------------------
+
+ def transcribe_audio(self, audio_path: str) -> Optional[str]:
+ """Transcribe reference audio locally using an optional Whisper backend."""
+ return transcribe_reference_audio(audio_path)
+
+ # ------------------------------------------------------------------
+ # Chunk generation
+ # ------------------------------------------------------------------
+
+ def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
+ """Generate one audio chunk; returns its path in the chunks folder.
+
+ The text is split into sub-requests of at most
+ ``config.CHUNK_SIZE`` words each (the book-level chunker
+ normally guarantees this already; the split is defense in depth
+ against pathological input such as a punctuation-free run of
+ text), and the audio files returned for the sub-requests are
+ concatenated into one chunk file.
+ """
+ try:
+ sub_texts = split_into_chunks(text, max_words=config.CHUNK_SIZE)
+ if not sub_texts:
+ raise RuntimeError("No text to synthesize")
+
+ output_path: Optional[Path] = None
+ with tempfile.TemporaryDirectory(prefix="tts_parts_") as parts_dir, \
+ self._chunk_heartbeat(chunk_num):
+ part_paths = [
+ self._generate_sub_request(sub_text, parts_dir, sub_num,
+ len(sub_texts), chunk_num)
+ for sub_num, sub_text in enumerate(sub_texts, 1)
+ ]
+ if len(part_paths) == 1:
+ suffix = part_paths[0].suffix or ".wav"
+ output_path = self._chunk_path(chunk_num, suffix)
+ shutil.copy2(part_paths[0], output_path)
+ else:
+ output_path = self._chunk_path(chunk_num, ".wav")
+ concat_audio_files(part_paths, output_path)
+
+ logger.debug("Chunk %d generated successfully (%d sub-request(s))",
+ chunk_num, len(sub_texts))
+ return str(output_path)
+
+ except Exception as exc:
+ logger.error("Qwen chunk processing failed for chunk %d: %s", chunk_num, exc)
+ return None
+
+ def _generate_sub_request(self, text: str, parts_dir: str, sub_num: int,
+ sub_total: int, chunk_num: int) -> Path:
+ """Run one API generation for ``text``; returns the downloaded audio."""
+ if sub_total > 1:
+ logger.info("Chunk %d: oversized input split into %d requests "
+ "(sub-request %d/%d)", chunk_num, sub_total, sub_num, sub_total)
+ if self.voice_mode == VOICE_MODE_CUSTOM:
+ result = self._generate_custom_voice(text)
+ elif self.voice_mode == VOICE_MODE_CLONE:
+ result = self._generate_voice_clone(text)
+ else:
+ raise ValueError(f"Unknown voice mode: {self.voice_mode}")
+
+ if not isinstance(result, (tuple, list)) or not result:
+ raise RuntimeError("Qwen API returned an invalid result")
+
+ audio_path = result[0] # First element is the audio file path
+ if not isinstance(audio_path, (str, Path)) or not audio_path:
+ raise RuntimeError("Qwen API did not return an audio file path")
+
+ source = Path(audio_path)
+ if not source.exists():
+ raise RuntimeError(f"Generated audio file not found: {audio_path}")
+
+ destination = Path(parts_dir) / f"part_{sub_num:02d}{source.suffix or '.wav'}"
+ shutil.copy2(source, destination)
+
+ return destination
+
+ # ------------------------------------------------------------------
+ # API payloads
+ # ------------------------------------------------------------------
+
+ def _generate_custom_voice(self, text: str) -> Tuple:
+ """Generate audio using CustomVoice mode."""
+ custom_api = self._resolve_api_name("/run_instruct", "/run_custom_voice", "/generate_custom_voice")
+ if custom_api == "/run_instruct":
+ payload = dict(
+ text=text,
+ lang_disp=self.language,
+ spk_disp=speaker_display_name(),
+ instruct=config.INSTRUCT,
+ )
+ else:
+ payload = dict(
+ text=text,
+ language=self.language,
+ speaker=config.SPEAKER,
+ instruct=config.INSTRUCT,
+ )
+ if self._endpoint_accepts_param(custom_api, "model_id_cv"):
+ payload["model_id_cv"] = CUSTOM_VOICE_MODEL_ID
+ elif self._endpoint_accepts_param(custom_api, "model_size"):
+ payload["model_size"] = MODEL_SIZE
+
+ if self._endpoint_accepts_param(custom_api, "seed"):
+ payload["seed"] = self._seed
+
+ return self.client.predict(**payload, api_name=custom_api)
+
+ def _ref_audio_payload(self) -> Dict[str, Any]:
+ """Gradio file payload for the reference audio (built once, reused)."""
+ if self._ref_audio_filedata is None:
+ from gradio_client import handle_file
+ self._ref_audio_filedata = handle_file(self.voice_clone_ref_audio)
+ return self._ref_audio_filedata
+
+ def _generate_voice_clone(self, text: str) -> Tuple:
+ """Generate audio using Voice Clone mode."""
+ if not Path(self.voice_clone_ref_audio).exists():
+ raise FileNotFoundError(f"Reference audio not found: {self.voice_clone_ref_audio}")
+
+ if self.clone_client is None:
+ raise RuntimeError("Voice Clone client is not initialized. Is the Base-model demo running?")
+
+ clone_api = self._resolve_api_name("/run_voice_clone", "/generate_voice_clone",
+ api_info=self.clone_api_info)
+ use_xvector = config.XVECTOR_ONLY or not self.voice_clone_ref_text
+
+ if clone_api == "/run_voice_clone":
+ payload = dict(
+ ref_aud=self._ref_audio_payload(),
+ ref_txt=self.voice_clone_ref_text,
+ use_xvec=use_xvector,
+ text=text,
+ lang_disp=self.language,
+ )
+ else:
+ payload = dict(
+ ref_audio=self._ref_audio_payload(),
+ ref_text=self.voice_clone_ref_text,
+ target_text=text,
+ language=self.language,
+ use_xvector_only=use_xvector,
+ )
+ optional_params = {
+ "model_size": MODEL_SIZE,
+ "seed": self._seed,
+ }
+ for name, value in optional_params.items():
+ if self._endpoint_accepts_param(clone_api, name, api_info=self.clone_api_info):
+ payload[name] = value
+
+ return self.clone_client.predict(**payload, api_name=clone_api)
+
+
+class FasterTTSClient(_BaseTTSClient):
+ """Generates audio chunks through a faster-qwen3-tts server.
+
+ Talks to the OpenAI-compatible server shipped in the faster-qwen3-tts
+ repository (examples/openai_server.py). The reference voice (ref audio,
+ ref text) and language are configured on the server itself via
+ --ref-audio/--ref-text or a --voices JSON file; this client only sends
+ text. Unlike the Qwen demo, the server performs one generation per
+ request, so long chunks are sub-chunked client-side.
+ """
+
+ def __init__(self, voice: Optional[str] = None, api_url: Optional[str] = None):
+ self.voice = voice or config.FASTER_VOICE
+ self.api_url = (api_url or config.FASTER_API_URL).rstrip("/")
+ self._check_health()
+
+ def _check_health(self) -> None:
+ """Verify the server is reachable and its model is loaded."""
+ url = f"{self.api_url}/health"
+ try:
+ with urllib.request.urlopen(url, timeout=10) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ except Exception as exc:
+ raise RuntimeError(
+ f"Faster TTS server not reachable at {url}: {exc}. "
+ "Start the faster-qwen3-tts OpenAI-compatible server first "
+ "(see the 'Faster backend' section of the README)."
+ ) from exc
+ if not payload.get("model_loaded"):
+ raise RuntimeError(
+ "The faster TTS server is running but its model is not loaded yet; "
+ "wait for model download and startup to finish, then retry."
+ )
+ print(f"[OK] Connected to faster TTS API at {self.api_url} (voice '{self.voice}')")
+ print(f"[INFO] The server silently falls back to its first configured voice if "
+ f"'{self.voice}' is not defined in its voice config (see README).")
+
+ # ------------------------------------------------------------------
+ # HTTP requests
+ # ------------------------------------------------------------------
+
+ def _request_pcm(self, text: str) -> bytes:
+ """POST one sub-chunk and return raw 16-bit mono PCM bytes."""
+ url = f"{self.api_url}/v1/audio/speech"
+ payload = json.dumps({
+ "model": "tts-1",
+ "input": text,
+ "voice": self.voice,
+ "response_format": "pcm",
+ }).encode("utf-8")
+ request = urllib.request.Request(
+ url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
+ try:
+ with urllib.request.urlopen(request, timeout=config.API_TIMEOUT) as response:
+ pcm = response.read()
+ except urllib.error.HTTPError as exc:
+ detail = ""
+ try:
+ detail = exc.read().decode("utf-8", errors="replace")[:200]
+ except Exception:
+ pass
+ raise RuntimeError(f"Faster TTS server returned HTTP {exc.code}: {detail}") from exc
+ except urllib.error.URLError as exc:
+ raise RuntimeError(f"Faster TTS request failed: {exc.reason}") from exc
+ if not pcm:
+ raise RuntimeError("Faster TTS server returned empty audio")
+ return pcm
+
+ def _request_pcm_with_retry(self, text: str, chunk_num: int, sub_num: int,
+ sub_total: int) -> bytes:
+ """Request one sub-chunk, retrying transient failures."""
+ for attempt in range(config.MAX_RETRIES):
+ try:
+ return self._request_pcm(text)
+ except Exception as exc:
+ logger.warning("Chunk %d sub-chunk %d/%d attempt %d failed: %s",
+ chunk_num, sub_num, sub_total, attempt + 1, exc)
+ if attempt < config.MAX_RETRIES - 1:
+ time.sleep(2 + 2 * attempt)
+ raise RuntimeError(f"Sub-chunk {sub_num}/{sub_total} failed after "
+ f"{config.MAX_RETRIES} attempts")
+
+ # ------------------------------------------------------------------
+ # Chunk generation
+ # ------------------------------------------------------------------
+
+ def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
+ """Generate one audio chunk; returns its path in the chunks folder."""
+ try:
+ sub_chunks = split_into_chunks(text, max_words=config.CHUNK_SIZE)
+ if not sub_chunks:
+ raise RuntimeError("No text to synthesize")
+
+ pcm_parts: List[bytes] = []
+ with self._chunk_heartbeat(chunk_num):
+ for sub_num, sub_text in enumerate(sub_chunks, 1):
+ pcm = self._request_pcm_with_retry(
+ sub_text, chunk_num, sub_num, len(sub_chunks))
+ pcm_parts.append(pcm)
+
+ output_path = self._chunk_path(chunk_num, ".wav")
+ with wave.open(str(output_path), "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(SAMPLE_RATE)
+ wav_file.writeframes(b"".join(pcm_parts))
+
+ logger.debug("Chunk %d generated (%d sub-chunks)", chunk_num, len(sub_chunks))
+ return str(output_path)
+
+ except Exception as exc:
+ logger.error("Faster chunk processing failed for chunk %d: %s", chunk_num, exc)
+ return None
+
+
+class AudioCppTTSClient(_BaseTTSClient):
+ """Generates audio chunks through an audio.cpp audiocpp_server.
+
+ Talks to the OpenAI-style HTTP API of audiocpp_server, which hosts TTS
+ model families through a native ggml runtime (GGUF weights, no Python
+ serving stack). The server API is family-agnostic; the family and task
+ of the configured model entry are read from GET /v1/models at startup
+ and adapt the request payload (language field style, style instructions)
+ through AUDIOCPP_FAMILY_PROFILES. Three voice modes, all resolved
+ server-side from the request's "voice"/"instructions" fields:
+
+ - Speaker mode (no ``voice``): Qwen3-TTS only. A built-in CustomVoice
+ speaker name (e.g. "Vivian") is passed through, plus the INSTRUCT
+ style prompt. The server must be configured with the CustomVoice
+ model for this. Families without built-in speakers reject this mode
+ with a hint to pass --voice (or --instructions, see below).
+ - Preset mode (``voice=NAME``): a voice configured on the server
+ (``voice_presets`` or ``voice_dir`` in its config, e.g. a cloning
+ reference). The name is validated against GET /v1/audio/voices at
+ startup because an unresolvable name would silently fall back to
+ plain TTS on a clone-based model instead of failing. When
+ AUDIOCPP_CLONE_MODEL_ID names a second server entry of the same
+ family (typically the Qwen Base model), preset requests are routed
+ to it. Only the entry actually used needs to exist on the server: a
+ clone-only (Base) server works for --voice runs, while speaker mode
+ on such a server fails with a hint to pass --voice.
+ - Voice design (task "vdes" entries, e.g. Qwen3-TTS VoiceDesign): the
+ voice is described in natural language through ``instructions``,
+ which is required and sent with every request (no ``voice`` field).
+ A constant per-run seed keeps the designed voice consistent across
+ chunk boundaries.
+
+ ``instructions`` also works on non-design entries, where it acts as a
+ generic style/delivery instruction (voice control): families that read
+ it (OmniVoice, Qwen3-TTS CustomVoice, ...) shape the voice or delivery
+ accordingly, and others ignore it. On instruction-conditioned families
+ without built-in speakers it may replace --voice entirely (the
+ instruction defines the voice). Extra request options (``--option
+ KEY=VALUE``, e.g. emotion, voice_id, speed) are forwarded verbatim in
+ the request's "options" object, which is the server's generic
+ pass-through for per-model controls.
+
+ Chunking: the server does its own long-form text chunking for every
+ family (its ``text_chunk_size`` option, with a per-family default), so
+ by default each chapter is sent as a single request and the audio
+ comes back already stitched. With ``chunk_text=True`` (the --chunk CLI
+ flag), text is instead split client-side into CHUNK_SIZE-word
+ sub-requests, which may needlessly double-chunk — the warning is
+ printed by the CLI.
+
+ Each response is a complete WAV file, so sub-request audio is
+ concatenated with the same lossless path used for the Qwen client.
+ """
+
+ def __init__(self, voice: Optional[str] = None, language: Optional[str] = None,
+ api_url: Optional[str] = None, chunk_text: bool = False,
+ model_id: Optional[str] = None,
+ instructions: Optional[str] = None,
+ request_options: Optional[Dict[str, str]] = None):
+ self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/")
+ # Per-run model selection: the --model CLI flag overrides config; an
+ # empty value is resolved at connect time when the server hosts exactly
+ # one entry, so multi-model servers don't require editing config.py.
+ self.model_id = (model_id if model_id is not None
+ else config.AUDIOCPP_MODEL_ID) or ""
+ self._model_id_explicit = bool(self.model_id)
+ # Validate before connecting so bad values fail fast without a server.
+ self.language = normalize_language(
+ language if language is not None else config.LANGUAGE)
+ # One seed value per run, reused for every request (see
+ # _resolve_request_seed). Unlike the Qwen demo, audio.cpp has no
+ # negative "randomize" seed, so a negative value means "send no seed
+ # at all" (see _request_wav) and the server randomizes.
+ self._seed = _resolve_request_seed()
+ self.preset_mode = bool(voice)
+ self.voice = voice or speaker_display_name()
+ # Style/voice-design instruction sent with every request (the CLI
+ # --instructions flag overrides AUDIOCPP_INSTRUCTIONS in config.py).
+ # For task "vdes" entries it describes the voice to design; for other
+ # families it is a generic style instruction when the model reads one.
+ self.instructions = (instructions if instructions is not None
+ else config.AUDIOCPP_INSTRUCTIONS or "").strip()
+ # Free-form per-request options (--option KEY=VALUE) forwarded in the
+ # request's "options" object; models ignore keys they don't know.
+ self.request_options: Dict[str, str] = dict(request_options or {})
+ # Both set during _connect once the entry's task is known: design_mode
+ # for "vdes" entries, instruction_voice when a family without built-in
+ # speakers gets its voice from the instruction alone (no voice field).
+ self.design_mode = False
+ self.instruction_voice = False
+ # When False (default), each chapter is sent as one request and the
+ # server does its own long-form chunking (text_chunk_size); when True,
+ # text is split client-side into CHUNK_SIZE-word sub-requests first.
+ self.chunk_text = bool(chunk_text)
+ # Family and task of the selected model entry and the family's request
+ # profile; all are resolved from GET /v1/models during _connect.
+ self.family = ""
+ self.task = AUDIOCPP_TASK_TTS
+ self.profile = AUDIOCPP_DEFAULT_FAMILY_PROFILE
+ self._connect()
+
+ # ------------------------------------------------------------------
+ # Connection
+ # ------------------------------------------------------------------
+
+ def _connect(self) -> None:
+ """Health-check the server and resolve the model, family, task, and voice.
+
+ Speaker mode is only offered to families with built-in speakers
+ (Qwen3-TTS); every other family must select a server-side voice
+ with --voice or describe one with --instructions, so it fails fast
+ with a hint instead of silently synthesizing with a random default
+ voice. Voice design entries (task "vdes") require --instructions
+ and reject --voice.
+ """
+ self._check_health()
+ models = self._list_models()
+ self._auto_pick_model_id(models)
+ if self.preset_mode:
+ self._select_model(models)
+ self._require_model_id(models)
+ self._resolve_family(models)
+ self._resolve_task(models)
+ if self.task not in AUDIOCPP_SYNTHESIS_TASKS:
+ available = ", ".join(model["id"] for model in models) or "none"
+ raise RuntimeError(
+ f"The audio.cpp model '{self.model_id}' has task "
+ f"'{self.task}'; audiobook.py can only synthesize with TTS "
+ f"model entries (tasks {', '.join(AUDIOCPP_SYNTHESIS_TASKS)}). "
+ f"Pick a synthesis entry with --model (available: {available})."
+ )
+ if self.design_mode:
+ if self.preset_mode:
+ raise RuntimeError(
+ f"--voice cannot be used with the voice design model "
+ f"'{self.model_id}': the voice is described by the "
+ "--instructions text instead (see README).")
+ if not self.instructions:
+ raise RuntimeError(
+ f"The audio.cpp model '{self.model_id}' (family "
+ f"'{self.family}') is a voice design model: pass a "
+ "description of the voice to synthesize with, e.g. "
+ '--instructions "A warm adult female narrator with a '
+ 'British accent" (see README).')
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ "voice design)")
+ print(f"[INFO] Designing the voice from: {self.instructions}")
+ elif self.preset_mode:
+ self._check_voice()
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ f"voice '{self.voice}')")
+ elif self.profile.builtin_speakers:
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ f"speaker '{self.voice}')")
+ print("[INFO] Speaker mode expects the server to be configured with the "
+ "CustomVoice model; with the Base model the speaker name is ignored "
+ "and a random default voice is used (see README).")
+ elif self.instructions:
+ # Families without built-in speakers can still get their voice
+ # from the instruction alone (e.g. OmniVoice voice design).
+ self.instruction_voice = True
+ print(f"[OK] Connected to audio.cpp server at {self.api_url} "
+ f"(model '{self.model_id}', family '{self.family}', "
+ "instruction voice)")
+ print(f"[INFO] Designing the voice from: {self.instructions}")
+ else:
+ raise RuntimeError(
+ f"The audio.cpp model '{self.model_id}' (family "
+ f"'{self.family}') has no built-in speakers, so its voice "
+ "must come from the server: rerun with --voice NAME "
+ "matching a voice_preset or voice_dir entry in the server "
+ "config, or describe a voice with --instructions for "
+ "families that support it (see README).")
+ if self.instructions and not self.design_mode and not self.instruction_voice:
+ print(f"[INFO] Sending instruction with every request: {self.instructions}")
+ print("[INFO] Its effect (style, emotion, delivery) depends on the "
+ "model family; models without instruction support ignore it.")
+
+ def _get_json(self, path: str, timeout: int = 10) -> Dict[str, Any]:
+ """GET a JSON document from the server."""
+ url = f"{self.api_url}{path}"
+ try:
+ with urllib.request.urlopen(url, timeout=timeout) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ detail = ""
+ try:
+ detail = exc.read().decode("utf-8", errors="replace")[:200]
+ except Exception:
+ pass
+ raise RuntimeError(
+ f"audio.cpp server returned HTTP {exc.code} for {path}: {detail}") from exc
+ except urllib.error.URLError as exc:
+ raise RuntimeError(f"audio.cpp request failed for {path}: {exc.reason}") from exc
+
+ def _check_health(self) -> None:
+ """Verify the server is reachable and reports healthy."""
+ try:
+ payload = self._get_json("/health")
+ except Exception as exc:
+ raise RuntimeError(
+ f"audio.cpp server not reachable at {self.api_url}: {exc}. "
+ "Start audiocpp_server first (see the 'audio.cpp backend' "
+ "section of the README)."
+ ) from exc
+ if payload.get("status") != "ok":
+ raise RuntimeError(
+ f"The audio.cpp server at {self.api_url} reports status "
+ f"{payload.get('status')!r} instead of 'ok'")
+
+ def _list_models(self) -> List[Dict[str, str]]:
+ """Fetch the (id, family, task) triples reported by the server."""
+ try:
+ payload = self._get_json("/v1/models")
+ except Exception as exc:
+ raise RuntimeError(
+ f"The audio.cpp server at {self.api_url} did not answer "
+ f"/v1/models: {exc}") from exc
+ entries = payload.get("data") or []
+ models: List[Dict[str, str]] = []
+ for entry in entries:
+ if isinstance(entry, dict) and entry.get("id"):
+ models.append({
+ "id": entry["id"],
+ "family": entry.get("family") or "",
+ "task": entry.get("task") or "",
+ })
+ return models
+
+ def _auto_pick_model_id(self, models: List[Dict[str, str]]) -> None:
+ """Resolve an empty model id when the server hosts exactly one entry.
+
+ Multi-model servers generated with several lazily-loaded entries can
+ be used without editing app/converter/config.py: leave AUDIOCPP_MODEL_ID
+ (and ``--model``) unset, and the single hosted entry is chosen
+ automatically. With more than one entry an explicit choice is required
+ (via ``--model`` or AUDIOCPP_MODEL_ID), since guessing would risk
+ synthesizing a whole book with the wrong family.
+ """
+ if self.model_id:
+ return
+ if len(models) == 1:
+ self.model_id = models[0]["id"]
+ logger.info(
+ "AUDIOCPP_MODEL_ID is unset; using the only server entry '%s'",
+ self.model_id)
+ else:
+ logger.debug(
+ "AUDIOCPP_MODEL_ID is unset and the server hosts %d entries; "
+ "an explicit --model or config id is required",
+ len(models))
+
+ def _require_model_id(self, models: List[Dict[str, str]]) -> None:
+ """Verify the model id chosen for this run exists on the server.
+
+ Speaker mode needs AUDIOCPP_MODEL_ID (the CustomVoice entry).
+ Preset mode validates whichever id _select_model resolved, so a
+ server hosting only a cloning model works for --voice.
+ """
+ model_ids = [model["id"] for model in models]
+ if self.model_id and self.model_id in model_ids:
+ return
+ configured = ", ".join(model_ids) or "none"
+ if not self.model_id:
+ raise RuntimeError(
+ f"The audio.cpp server at {self.api_url} hosts {len(model_ids)} "
+ f"model entries ({configured}); audiobook.py needs to know which "
+ "one to use. Pass --model <id> when converting, or set "
+ "AUDIOCPP_MODEL_ID in app/converter/config.py to one of them "
+ "(see README)."
+ )
+ if self.preset_mode:
+ raise RuntimeError(
+ f"The audio.cpp server at {self.api_url} has no model id "
+ f"'{self.model_id}' or clone model id "
+ f"'{config.AUDIOCPP_CLONE_MODEL_ID}' (configured: {configured}). "
+ "Add a TTS model entry for the family you want to the server "
+ "config and match AUDIOCPP_MODEL_ID / AUDIOCPP_CLONE_MODEL_ID "
+ "in app/converter/config.py to its id, or select it per run with "
+ "--model (see README)."
+ )
+ raise RuntimeError(
+ f"The audio.cpp server at {self.api_url} has no model id "
+ f"'{self.model_id}' (configured: {configured}). Speaker mode needs "
+ "the Qwen3-TTS CustomVoice model: add a qwen3_tts model entry to "
+ "the server config and match AUDIOCPP_MODEL_ID in app/converter/config.py to its "
+ "id (or pass --model), or rerun with --voice to use a voice preset "
+ "on any TTS model (see README)."
+ )
+
+ def _select_model(self, models: List[Dict[str, str]]) -> None:
+ """Pick the model for preset (cloning) requests.
+
+ Defaults to the primary model id. When AUDIOCPP_CLONE_MODEL_ID is
+ configured and present on the server, preset requests are routed
+ to it instead, so one server can host the CustomVoice model for
+ speaker mode and the Base model for cloning (Qwen3-TTS setups).
+ A clone id that names a model of a different family is ignored
+ with a warning, since preset requests must synthesize with the
+ family the run is configured for.
+ """
+ clone_model_id = config.AUDIOCPP_CLONE_MODEL_ID
+ if not clone_model_id or clone_model_id == self.model_id:
+ return
+ families = {model["id"]: model["family"] for model in models}
+ if clone_model_id not in families:
+ # A qwen3_tts primary without its clone entry silently degrades
+ # (presets are ignored on the CustomVoice model), so that case
+ # keeps the warning; single-model servers of other families are
+ # the normal configuration and only get a debug note.
+ primary_is_qwen = (families.get(self.model_id)
+ or AUDIOCPP_FAMILY_QWEN3_TTS) \
+ == AUDIOCPP_FAMILY_QWEN3_TTS
+ if primary_is_qwen:
+ logger.warning(
+ "AUDIOCPP_CLONE_MODEL_ID %r is not configured on the audio.cpp "
+ "server; preset requests use '%s' instead",
+ clone_model_id, self.model_id)
+ else:
+ logger.debug(
+ "AUDIOCPP_CLONE_MODEL_ID %r is not configured on the audio.cpp "
+ "server; preset requests use '%s' instead",
+ clone_model_id, self.model_id)
+ return
+ primary_family = families.get(self.model_id)
+ clone_family = families[clone_model_id]
+ if primary_family and clone_family and primary_family != clone_family:
+ logger.warning(
+ "AUDIOCPP_CLONE_MODEL_ID %r hosts family %r, but "
+ "AUDIOCPP_MODEL_ID %r hosts %r; preset requests stay on "
+ "'%s'. Point both ids at the same model entry in "
+ "app/converter/config.py (single-model servers use the same id "
+ "for both)",
+ clone_model_id, clone_family, self.model_id, primary_family,
+ self.model_id)
+ return
+ self.model_id = clone_model_id
+
+ def _resolve_family(self, models: List[Dict[str, str]]) -> None:
+ """Resolve the selected model's family and its request profile.
+
+ The family comes from GET /v1/models. Servers that predate the
+ family field served Qwen3-TTS only, so a missing family is treated
+ as qwen3_tts, which also preserves this client's legacy behavior
+ against those versions.
+ """
+ entry = next(
+ (model for model in models if model["id"] == self.model_id), None)
+ family = (entry["family"] if entry is not None else "") or ""
+ if not family:
+ family = AUDIOCPP_FAMILY_QWEN3_TTS
+ logger.debug("Model '%s' reported no family; assuming qwen3_tts",
+ self.model_id)
+ self.family = family
+ self.profile = AUDIOCPP_FAMILY_PROFILES.get(
+ family, AUDIOCPP_DEFAULT_FAMILY_PROFILE)
+ if family not in AUDIOCPP_FAMILY_PROFILES:
+ logger.info(
+ "audio.cpp family '%s' has no dedicated profile; using the "
+ "generic profile (voice cloning via --voice, model-detected "
+ "language)", family)
+
+ def _resolve_task(self, models: List[Dict[str, str]]) -> None:
+ """Resolve the selected model's task (tts, clon, vdes, ...) and set
+ design mode for voice design entries.
+
+ The task comes from GET /v1/models and is fixed per server entry by
+ its server.json config (a VoiceDesign model must be hosted with
+ "task": "vdes"). Servers that predate the task field hosted plain
+ TTS models, so a missing task is treated as tts.
+ """
+ entry = next(
+ (model for model in models if model["id"] == self.model_id), None)
+ task = (entry["task"] if entry is not None else "") or ""
+ if not task:
+ task = AUDIOCPP_TASK_TTS
+ logger.debug("Model '%s' reported no task; assuming tts",
+ self.model_id)
+ self.task = task
+ self.design_mode = task == AUDIOCPP_TASK_VDES
+
+ def _check_voice(self) -> None:
+ """Verify the requested voice is available on the server.
+
+ A voice name that matches no server preset or voice-library wav
+ would be passed through to the model as a cached voice id; on the
+ Base (cloning) model that is silently ignored and plain TTS audio
+ comes back, so preset names are validated up front. When the
+ voices endpoint cannot be queried, validation is skipped with a
+ warning rather than blocking the run.
+ """
+ query = urllib.parse.urlencode({"model": self.model_id})
+ try:
+ payload = self._get_json(f"/v1/audio/voices?{query}")
+ except Exception as exc:
+ logger.warning("Could not list server voices; skipping voice "
+ "validation: %s", exc)
+ return
+ voices = payload.get("voices") or []
+ if self.voice not in voices:
+ available = ", ".join(str(v) for v in voices) or "none"
+ raise RuntimeError(
+ f"Voice '{self.voice}' is not available on the audio.cpp server "
+ f"(available: {available}). Configure it as a voice_preset or "
+ "voice_dir entry in the server config, or pass a listed name "
+ "with --voice (see README)."
+ )
+
+ # ------------------------------------------------------------------
+ # HTTP requests
+ # ------------------------------------------------------------------
+
+ def _request_wav(self, text: str) -> bytes:
+ """POST one sub-chunk and return the raw WAV bytes.
+
+ The request timeout scales with the text length when a whole
+ chapter is sent in one request (no client-side chunking), since a
+ long chapter means many minutes of audio generated in one go.
+ """
+ url = f"{self.api_url}/v1/audio/speech"
+ payload: Dict[str, Any] = {
+ "model": self.model_id,
+ "input": text,
+ }
+ # Design models take no voice field (the voice comes from the
+ # instruction); instruction-voice runs on families without built-in
+ # speakers omit it too, since no speaker or preset was requested.
+ if not self.design_mode and not self.instruction_voice:
+ payload["voice"] = self.voice
+ if self.profile.language_style == AUDIOCPP_LANG_DISPLAY:
+ payload["language"] = self.language
+ elif self.profile.language_style == AUDIOCPP_LANG_ISO:
+ iso_code = LANGUAGE_ISO_CODES.get(self.language)
+ if iso_code:
+ payload["language"] = iso_code
+ else:
+ # "Auto": no code to send, so let the server pick its default.
+ logger.debug("%s: no language code for %r; omitted from request",
+ self.family, self.language)
+ if self._seed >= 0:
+ # audio.cpp has no negative "randomize" seed; a negative seed
+ # means "let the server randomize", so the field is omitted.
+ payload["seed"] = self._seed
+ if self.instructions:
+ # Explicit voice-design or style instruction (required for task
+ # "vdes" entries; a Ctrl/style control on families that read it).
+ payload["instructions"] = self.instructions
+ elif not self.preset_mode and config.INSTRUCT \
+ and self.profile.sends_instructions:
+ # Style instruction for the Qwen3-TTS CustomVoice speakers;
+ # ignored by the Base (cloning) model and other families.
+ payload["instructions"] = config.INSTRUCT
+ if self.request_options:
+ # Generic per-model controls (--option KEY=VALUE): forwarded
+ # verbatim; the model ignores keys it does not know.
+ payload["options"] = dict(self.request_options)
+ request = urllib.request.Request(
+ url, data=json.dumps(payload).encode("utf-8"),
+ headers={"Content-Type": "application/json"}, method="POST")
+ timeout = config.API_TIMEOUT
+ if not self.chunk_text:
+ # Estimated audio duration at 150 wpm, doubled plus a minute of
+ # slack, bounded below by the configured per-request timeout.
+ estimated_seconds = 60.0 * len(text.split()) / _ESTIMATED_WORDS_PER_MINUTE
+ timeout = max(timeout, int(estimated_seconds * 2) + 60)
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ wav = response.read()
+ except urllib.error.HTTPError as exc:
+ detail = ""
+ try:
+ detail = exc.read().decode("utf-8", errors="replace")[:200]
+ except Exception:
+ pass
+ raise RuntimeError(f"audio.cpp server returned HTTP {exc.code}: {detail}") from exc
+ except urllib.error.URLError as exc:
+ raise RuntimeError(f"audio.cpp request failed: {exc.reason}") from exc
+ if len(wav) < 12 or wav[:4] != b"RIFF" or wav[8:12] != b"WAVE":
+ raise RuntimeError("audio.cpp server returned audio that is not a WAV file")
+ return wav
+
+ def _request_wav_with_retry(self, text: str, chunk_num: int, sub_num: int,
+ sub_total: int) -> bytes:
+ """Request one sub-chunk, retrying transient failures."""
+ for attempt in range(config.MAX_RETRIES):
+ try:
+ return self._request_wav(text)
+ except Exception as exc:
+ logger.warning("Chunk %d sub-chunk %d/%d attempt %d failed: %s",
+ chunk_num, sub_num, sub_total, attempt + 1, exc)
+ if attempt < config.MAX_RETRIES - 1:
+ time.sleep(2 + 2 * attempt)
+ raise RuntimeError(f"Sub-chunk {sub_num}/{sub_total} failed after "
+ f"{config.MAX_RETRIES} attempts")
+
+ # ------------------------------------------------------------------
+ # Chunk generation
+ # ------------------------------------------------------------------
+
+ def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
+ """Generate one audio chunk; returns its path in the chunks folder.
+
+ By default the whole text goes out as a single request and the
+ server does its own long-form chunking (see the class docstring).
+ With ``chunk_text=True`` (--chunk), the text is split into
+ sub-requests of at most ``config.CHUNK_SIZE`` words each; each
+ sub-request returns a complete WAV file and the parts are
+ concatenated into one chunk file.
+ """
+ try:
+ if self.chunk_text:
+ sub_texts = split_into_chunks(text, max_words=config.CHUNK_SIZE)
+ elif text.strip():
+ sub_texts = [text]
+ else:
+ sub_texts = []
+ if not sub_texts:
+ raise RuntimeError("No text to synthesize")
+
+ output_path: Optional[Path] = None
+ with tempfile.TemporaryDirectory(prefix="tts_parts_") as parts_dir, \
+ self._chunk_heartbeat(
+ chunk_num,
+ label=None if self.chunk_text else "Request"):
+ part_paths = []
+ for sub_num, sub_text in enumerate(sub_texts, 1):
+ wav = self._request_wav_with_retry(
+ sub_text, chunk_num, sub_num, len(sub_texts))
+ destination = Path(parts_dir) / f"part_{sub_num:02d}.wav"
+ destination.write_bytes(wav)
+ part_paths.append(destination)
+ if len(part_paths) == 1:
+ output_path = self._chunk_path(chunk_num, ".wav")
+ shutil.copy2(part_paths[0], output_path)
+ else:
+ output_path = self._chunk_path(chunk_num, ".wav")
+ concat_audio_files(part_paths, output_path)
+
+ logger.debug("Chunk %d generated successfully (%d sub-request(s))",
+ chunk_num, len(sub_texts))
+ return str(output_path)
+
+ except Exception as exc:
+ logger.error("audio.cpp chunk processing failed for chunk %d: %s",
+ chunk_num, exc)
+ return None
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
new file mode 100644
index 0000000..271c9b6
--- /dev/null
+++ b/app/docs/backend-audiocpp.md
@@ -0,0 +1,91 @@
+# Backend Option 1: audio.cpp
+
+`--backend audiocpp` talks to `audiocpp_server` from [audio.cpp](https://github.com/0xShug0/audio.cpp), which hosts numerous TTS model families.
+
+The easiest way is the TUI: run `python audiobook.py`, choose **Set up a backend… → audio.cpp**, and it clones `audio.cpp` into `app/audio.cpp` (or reuses an existing checkout), builds `audiocpp_server`, lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `app/converter/config.py`, and prints the launch command (the hub can also start the server for you via the **Server** menu or automatically when converting). Run it directly with `python app/backends/audiocpp.py` (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). The TUI runs in the managed `app/envs/tts` venv, which includes `whisper` via `requirements.txt`; for a manual setup, make sure `whisper` (or `faster_whisper`) is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry.
+
+If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up.
+
+### Download and build audiocpp_server
+
+Download and build `audiocpp_server` for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. I'm using one of the helper scripts:
+
+```bash
+git clone https://github.com/0xShug0/audio.cpp
+cd audio.cpp
+scripts/build_linux.sh --backend cuda --target audiocpp_server
+```
+
+### Install models
+
+Download model packages with the python model manager script from the audio.cpp checkout. Each installs to `./models`. Here are two examples, Higgs Audio and Qwen3-TTS:
+
+```bash
+python tools/model_manager_v2.py install higgs_audio_tts_4b_q8_0
+python tools/model_manager_v2.py install qwen3_tts_1_7b_base_q8_0
+python tools/model_manager_v2.py install qwen3_tts_1_7b_customvoice_q8_0
+```
+
+You can run `python tools/model_manager_v2.py list` to see all available models.
+
+### Create server.json
+
+Create a `server.json` config file. One server can host multiple models and multiple cloned voices. The `id:` fields are the model names you will set for `tts-audiobook-generator` with `--model`.
+
+```json
+{
+ "host": "127.0.0.1",
+ "port": 8080,
+ "backend": "cuda",
+ "lazy_load": true,
+ "voice_dir": "/path/to/clone/wavs",
+ "models": [
+ {
+ "id": "higgs",
+ "family": "higgs_audio_tts",
+ "path": "models/Higgs-Audio-v3-TTS-4B-GGUF",
+ "task": "tts",
+ "mode": "offline"
+ },
+ {
+ "id": "qwen",
+ "family": "qwen3_tts",
+ "path": "models/Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
+ "task": "tts",
+ "mode": "offline"
+ },
+ {
+ "id": "qwen-clone",
+ "family": "qwen3_tts",
+ "path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ "task": "tts",
+ "mode": "offline"
+ }
+ ]
+}
+```
+
+### Run audio.cpp and the audiobook script
+
+Run the server with this config file. The `audiocpp_server` path will be slightly different depending on your platform and build options:
+
+```bash
+./build/linux-cuda-release/bin/audiocpp_server --config server.json
+```
+
+In a different terminal, run `audiobook.py`. Pick the TTS `--model` and `--voice` from server.json:
+
+```bash
+# Higgs Audio (clone-only)
+python audiobook.py --backend audiocpp --model higgs --voice narrator
+
+# Qwen3-TTS built-in speaker
+python audiobook.py --backend audiocpp --model qwen
+
+# Qwen3-TTS voice cloning
+python audiobook.py --backend audiocpp --model qwen-clone --voice narrator
+
+# Qwen-TTS voice design
+python audiobook.py --backend audiocpp --model qwen-design \
+ --instructions "A warm adult female narrator with a British accent"
+```
diff --git a/app/docs/backend-faster.md b/app/docs/backend-faster.md
new file mode 100644
index 0000000..b08e057
--- /dev/null
+++ b/app/docs/backend-faster.md
@@ -0,0 +1,45 @@
+# Backend Option 3: faster-qwen-tts
+
+`--backend faster` talks to the OpenAI-compatible server from [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts), which uses CUDA graph capture for roughly 5-10x faster inference with the same models. **It requires an NVIDIA GPU**.
+
+The easiest way is to run `python audiobook.py` → **Set up a backend… → faster-qwen3-tts** (or `python app/backends/faster.py path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its managed venv (`app/envs/tts`), clones the repo, transcribes the `.wav` files with `whisper`, and writes `voices.json` for you. You can also start the server from the hub's **Server** menu, or let a conversion start it automatically.
+
+If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up.
+
+Install into your environment (the same one used for qwen-tts is fine):
+
+```bash
+conda activate audiobook
+pip install -U qwen-tts
+pip install "faster-qwen3-tts[demo]"
+```
+
+**This backend always uses voice cloning**. The reference voice and language are configured on the **server**, not through the converter. The server does not transcribe reference audio itself, so do it manually or use the `backends.faster` setup wizard (see below).
+
+The pip package does not include the server script, so clone the repository (the `backends.faster` wizard does this for you into `./faster-qwen3-tts`):
+
+```bash
+git clone https://github.com/andimarafioti/faster-qwen3-tts
+cd faster-qwen3-tts
+```
+
+Create a `voices.json` mapping names to reference configurations (.wav to clone, transcript, language). The TUI setup writes this for you; manually it looks like:
+
+```json
+{
+ "default": {"ref_audio": "voice1.wav", "ref_text": "Transcript of voice 1.", "language": "English"},
+ "obama": {"ref_audio": "voice2.wav", "ref_text": "Transcript of voice 2.", "language": "English"}
+}
+```
+
+Run the server
+
+```bash
+python examples/openai_server.py --voices voices.json --port 8000
+```
+
+Then from another terminal, run audiobook.py with `--backend faster`
+
+```bash
+python audiobook.py --backend faster [--voice NAME]
+```
diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md
new file mode 100644
index 0000000..074004c
--- /dev/null
+++ b/app/docs/backend-qwen.md
@@ -0,0 +1,67 @@
+# Backend Option 2: Qwen3-TTS
+
+The easiest way is to run `python audiobook.py` → **Set up a backend… → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`), configures the two ports and the built-in speaker in `app/converter/config.py`, and prints the launch commands. You can also start the server from the hub's **Server** menu, or let a conversion start it automatically.
+
+If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up.
+
+Install qwen-tts with pip into your environment:
+
+```bash
+conda activate audiobook
+pip install -U qwen-tts
+```
+
+Run the backend with `qwen-tts-demo`. Add `--no-flash-attn` if FlashAttention isn't installed (see below). Note that the Base model and CustomVoice model run on different ports.
+
+## Voice clone
+
+```bash
+qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-Base --ip 127.0.0.1 --port 7861 [--no-flash-attn]
+```
+
+Then in another terminal:
+
+```bash
+python audiobook.py --backend qwen --clone reference.wav
+```
+
+The reference `.wav` should be ~10-15 seconds (3 second minimum, 60 second maximum; ~15 seconds is ideal). Longer is **not** better.
+
+Whisper (`faster_whisper` or `whisper`) is used automatically to transcribe the reference audio. Without a Whisper backend it falls back to x-vector-only cloning. Override with `--transcription "What the .wav says"` or skip transcription with `--no-transcription`.
+
+## Custom voice (i.e. built-in voice)
+
+```bash
+conda activate audiobook
+qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --ip 127.0.0.1 --port 7860 [--no-flash-attn]
+```
+
+```bash
+python audiobook.py --backend qwen
+```
+
+Change the voice settings in `app/converter/config.py`.
+
+## Optional: FlashAttention for qwen-tts-demo server
+
+FlashAttention provides a *small* speed boost on the `qwen` backend. It is **not** relevant with other backends, and switching to either of those will provide a bigger speed boost.
+
+`qwen-tts-demo` server tries to use FlashAttention 2 by default and requires `--no-flash-attn` without it. You have two options to install FlashAttention in your python environment:
+
+1. Build from source (takes absolutely forever). If you run out of memory, lower MAX_JOBS until you don't.
+
+```bash
+conda activate audiobook
+pip install ninja packaging psutil
+MAX_JOBS=4 pip install --no-build-isolation flash-attn
+```
+
+2. pip install a prebuilt wheel matching your torch / CUDA / Python / CXX11-ABI combination:
+
+```bash
+conda activate audiobook
+python -c "import torch; print(torch.__version__, torch.version.cuda, torch._C._GLIBCXX_USE_CXX11_ABI)"
+```
+
+- [Official wheels](https://github.com/Dao-AILab/flash-attention/releases) - Pick `cp312` + matching `cuX` + `torchX.Y` + `cxx11abiTRUE/FALSE`
+- [Third-party wheels](https://mjunya.com/flash-attention-prebuild-wheels/)
diff --git a/app/tests/__init__.py b/app/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/tests/__init__.py
diff --git a/app/tests/cover_test.png b/app/tests/cover_test.png
new file mode 100644
index 0000000..0c252db
--- /dev/null
+++ b/app/tests/cover_test.png
Binary files differ
diff --git a/app/tests/gen_test_cover.py b/app/tests/gen_test_cover.py
new file mode 100644
index 0000000..292469a
--- /dev/null
+++ b/app/tests/gen_test_cover.py
@@ -0,0 +1,8 @@
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+from converter.cover import generate_cover
+
+p = generate_cover('The Count of Monte Cristo',
+ Path(__file__).resolve().parent / 'cover_test.png')
+print('written:', p)
diff --git a/app/tests/test_audio.py b/app/tests/test_audio.py
new file mode 100644
index 0000000..ef5e92a
--- /dev/null
+++ b/app/tests/test_audio.py
@@ -0,0 +1,524 @@
+"""Tests for audio helpers: speed parameters, chunk cleanup, encoding,
+command construction, duration verification, and audio concatenation."""
+
+import io
+import tempfile
+import unittest
+import wave
+from contextlib import redirect_stdout
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from converter import audio
+from converter import config
+from converter.audio import (
+ TrackMeta,
+ _collect_chunk_files,
+ _cover_args,
+ _encode_args,
+ _tag_args,
+ build_concat_command,
+ build_ffmetadata,
+ build_m4b_chapters_command,
+ cleanup_chunks,
+ concat_audio_files,
+ speed_export_params,
+ verify_output_duration,
+)
+
+
+class SpeedExportParamsTests(unittest.TestCase):
+ def test_normal_speed_no_filter(self):
+ self.assertEqual(speed_export_params(1.0), [])
+
+ def test_simple_speedup(self):
+ self.assertEqual(speed_export_params(1.5), ["-filter:a", "atempo=1.5"])
+
+ def test_simple_slowdown(self):
+ self.assertEqual(speed_export_params(0.75), ["-filter:a", "atempo=0.75"])
+
+ def test_chained_speedup_beyond_2x(self):
+ self.assertEqual(speed_export_params(3.0), ["-filter:a", "atempo=2.0,atempo=1.5"])
+
+ def test_chained_slowdown_below_half(self):
+ self.assertEqual(speed_export_params(0.25), ["-filter:a", "atempo=0.5,atempo=0.5"])
+
+ def test_zero_speed_rejected(self):
+ with self.assertRaises(ValueError):
+ speed_export_params(0)
+
+ def test_negative_speed_rejected(self):
+ with self.assertRaises(ValueError):
+ speed_export_params(-1.5)
+
+
+class CleanupChunksTests(unittest.TestCase):
+ def test_removes_only_chunk_files(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ chunks_dir = Path(tmp)
+ (chunks_dir / "chunk_0001.wav").write_bytes(b"stale")
+ (chunks_dir / "chunk_0002.wav").write_bytes(b"stale")
+ (chunks_dir / "keep.txt").write_bytes(b"keep")
+
+ original = audio.CHUNKS_FOLDER
+ audio.CHUNKS_FOLDER = chunks_dir
+ try:
+ cleanup_chunks()
+ finally:
+ audio.CHUNKS_FOLDER = original
+
+ self.assertFalse((chunks_dir / "chunk_0001.wav").exists())
+ self.assertFalse((chunks_dir / "chunk_0002.wav").exists())
+ self.assertTrue((chunks_dir / "keep.txt").exists())
+
+ def test_removes_chapter_files(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ chunks_dir = Path(tmp)
+ (chunks_dir / "chapter_0001.m4b").write_bytes(b"stale")
+ (chunks_dir / "chunk_0001.wav").write_bytes(b"stale")
+
+ original = audio.CHUNKS_FOLDER
+ audio.CHUNKS_FOLDER = chunks_dir
+ try:
+ cleanup_chunks()
+ finally:
+ audio.CHUNKS_FOLDER = original
+
+ self.assertFalse((chunks_dir / "chapter_0001.m4b").exists())
+ self.assertFalse((chunks_dir / "chunk_0001.wav").exists())
+
+
+class EncodeArgsTests(unittest.TestCase):
+ def test_mp3_uses_bitrate_only(self):
+ self.assertEqual(_encode_args("mp3"), ["-b:a", config.AUDIO_BITRATE])
+
+ def test_m4b_uses_aac(self):
+ self.assertEqual(_encode_args("m4b"), ["-c:a", "aac", "-b:a", config.AUDIO_BITRATE])
+
+ def test_wav_is_lossless_pcm(self):
+ self.assertEqual(_encode_args("wav"), ["-c:a", "pcm_s16le"])
+
+ def test_ogg_uses_libvorbis(self):
+ self.assertEqual(_encode_args("ogg"), ["-c:a", "libvorbis", "-b:a", config.AUDIO_BITRATE])
+
+ def test_flac_is_lossless(self):
+ self.assertEqual(_encode_args("flac"), ["-c:a", "flac"])
+
+
+class M4bContainerArgsTests(unittest.TestCase):
+ def setUp(self):
+ self._original = audio._brand_supported
+ audio._brand_supported = True
+
+ def tearDown(self):
+ audio._brand_supported = self._original
+
+ def test_includes_faststart_and_brand(self):
+ args = audio._m4b_container_args()
+ self.assertIn("+faststart", args)
+ self.assertIn("M4B ", args)
+
+ def test_brand_omitted_when_unsupported(self):
+ audio._brand_supported = False
+ self.assertEqual(audio._m4b_container_args(), ["-movflags", "+faststart"])
+
+
+class BuildConcatCommandTests(unittest.TestCase):
+ def setUp(self):
+ self._original = audio._brand_supported
+ audio._brand_supported = True
+
+ def tearDown(self):
+ audio._brand_supported = self._original
+
+ def test_mp3_has_no_container_flags(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3")
+ self.assertEqual(cmd[:6], ["ffmpeg", "-y", "-f", "concat", "-safe", "0"])
+ self.assertNotIn("-movflags", cmd)
+ self.assertEqual(cmd[-1], "out.mp3")
+
+ def test_m4b_gets_faststart_and_brand(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.m4b"), "m4b")
+ self.assertIn("+faststart", cmd)
+ self.assertIn("M4B ", cmd)
+ self.assertEqual(cmd[-1], "out.m4b")
+
+ def test_speed_copy_writes_two_outputs(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.m4b"), "m4b",
+ speed=1.5, speed_path=Path("out_1.5.m4b"))
+ self.assertIn("out.m4b", cmd)
+ self.assertIn("out_1.5.m4b", cmd)
+ # faststart must apply to both outputs
+ self.assertEqual(cmd.count("+faststart"), 2)
+ self.assertTrue(any("atempo=1.5" in arg for arg in cmd))
+
+ def test_wav_intermediate(self):
+ cmd = build_concat_command(Path("list.txt"), Path("chapter.wav"), "wav")
+ self.assertIn("pcm_s16le", cmd)
+ self.assertNotIn("-movflags", cmd)
+
+ def test_speed_without_speed_path_rejected(self):
+ with self.assertRaises(ValueError):
+ build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3", speed=1.5)
+
+
+class CollectChunkFilesTests(unittest.TestCase):
+ def test_uses_recorded_paths_exactly(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ present = Path(tmp) / "chunk_0001.wav"
+ present.write_bytes(b"audio")
+ chunk_results = {
+ 1: present,
+ 2: None, # failed chunk
+ 3: Path(tmp) / "chunk_0003.wav", # recorded but deleted
+ }
+ files, missing = _collect_chunk_files(3, chunk_results)
+
+ self.assertEqual(files, [present])
+ self.assertEqual(missing, [2, 3])
+
+
+class BuildM4bChaptersCommandTests(unittest.TestCase):
+ def setUp(self):
+ self._original = audio._brand_supported
+ audio._brand_supported = True
+
+ def tearDown(self):
+ audio._brand_supported = self._original
+
+ def test_base_output_maps_metadata_and_chapters(self):
+ cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"), Path("out.m4b"))
+ self.assertIn("-map_metadata", cmd)
+ self.assertIn("-map_chapters", cmd)
+ self.assertIn("out.m4b", cmd)
+ self.assertIn("+faststart", cmd)
+ self.assertNotIn("filter_complex", cmd)
+
+ def test_speed_outputs_get_their_own_chapter_metadata(self):
+ cmd = build_m4b_chapters_command(
+ Path("list.txt"), Path("meta.txt"), Path("out.m4b"),
+ speed=2.0, speed_path=Path("out_2.m4b"), speed_metadata_file=Path("meta2.txt"),
+ )
+ self.assertEqual(cmd.count("+faststart"), 2)
+ self.assertEqual(cmd.count("-map_chapters"), 2)
+ self.assertTrue(any("atempo=2" in arg for arg in cmd))
+ # base output chapters come from metadata input 1, speed copy from 2
+ chapter_flags = [i for i, v in enumerate(cmd) if v == "-map_chapters"]
+ self.assertEqual(cmd[chapter_flags[0] + 1], "1")
+ self.assertEqual(cmd[chapter_flags[1] + 1], "2")
+ base_idx, speed_idx = cmd.index("out.m4b"), cmd.index("out_2.m4b")
+ self.assertLess(chapter_flags[0], base_idx)
+ self.assertGreater(chapter_flags[1], base_idx)
+ self.assertLess(chapter_flags[1], speed_idx)
+
+
+class VerifyOutputDurationTests(unittest.TestCase):
+ def _patch_probe(self, ms):
+ audio.probe_duration_ms = lambda path: ms
+
+ def setUp(self):
+ self._original_probe = audio.probe_duration_ms
+
+ def tearDown(self):
+ audio.probe_duration_ms = self._original_probe
+
+ def test_close_duration_passes(self):
+ self._patch_probe(100_000)
+ self.assertTrue(verify_output_duration(Path("x.m4b"), 101_000))
+
+ def test_unverifiable_duration_passes(self):
+ self._patch_probe(0)
+ self.assertTrue(verify_output_duration(Path("x.m4b"), 100_000))
+
+ def test_zero_expected_passes(self):
+ self._patch_probe(50_000)
+ self.assertTrue(verify_output_duration(Path("x.m4b"), 0))
+
+ def test_large_drift_fails(self):
+ self._patch_probe(3_600_000) # bogus "1 hour" for a 1 minute book
+ with self.assertLogs(level="ERROR"):
+ self.assertFalse(verify_output_duration(Path("x.m4b"), 60_000))
+
+
+class BuildFFMetadataTests(unittest.TestCase):
+ def test_writes_chapters(self):
+ chapters = [(0, 1200, "One"), (1200, 2500, "Two")]
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "meta.txt"
+ build_ffmetadata(chapters, path)
+ content = path.read_text(encoding="utf-8")
+
+ self.assertTrue(content.startswith(";FFMETADATA1\n"))
+ self.assertIn("[CHAPTER]", content)
+ self.assertIn("TIMEBASE=1/1000", content)
+ self.assertIn("START=0", content)
+ self.assertIn("END=1200", content)
+ self.assertIn("title=One", content)
+ self.assertIn("START=1200", content)
+ self.assertIn("title=Two", content)
+
+ def test_escapes_special_characters(self):
+ # ffmpeg's FFMETADATA format treats = ; # and \ as structural.
+ chapters = [(0, 1000, "A = B; C# D\\E")]
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "meta.txt"
+ build_ffmetadata(chapters, path)
+ content = path.read_text(encoding="utf-8")
+
+ self.assertIn(r"title=A \= B\; C\# D\\E", content)
+
+ def test_collapses_newlines_in_titles(self):
+ chapters = [(0, 1000, "Two\nLines")]
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "meta.txt"
+ build_ffmetadata(chapters, path)
+ content = path.read_text(encoding="utf-8")
+
+ self.assertIn("title=Two Lines\n", content)
+ self.assertNotIn("title=Two\n", content)
+
+
+class TagArgsTests(unittest.TestCase):
+ META = TrackMeta(title="Dune", artist="Frank Herbert", album="Dune",
+ track=2, total_tracks=5)
+
+ def test_full_meta_written(self):
+ args = _tag_args(self.META, "mp3")
+ for pair in ("title=Dune", "artist=Frank Herbert",
+ "album=Dune", "track=2/5"):
+ self.assertIn(pair, args)
+
+ def test_mp3_gets_id3v23(self):
+ mp3_args = _tag_args(self.META, "mp3")
+ self.assertIn("-id3v2_version", mp3_args)
+ self.assertEqual(mp3_args[mp3_args.index("-id3v2_version") + 1], "3")
+ self.assertNotIn("-id3v2_version", _tag_args(self.META, "flac"))
+
+ def test_empty_fields_omitted(self):
+ meta = TrackMeta(title="Only Title")
+ args = _tag_args(meta, "flac")
+ self.assertNotIn("artist", args)
+ self.assertNotIn("album", args)
+ self.assertNotIn("track", args)
+
+ def test_track_requires_total(self):
+ meta = TrackMeta(title="T", track=3)
+ self.assertNotIn("track", _tag_args(meta, "mp3"))
+
+
+class CoverArgsTests(unittest.TestCase):
+ def test_mp3_copies_png_stream(self):
+ args = _cover_args("mp3", 1)
+ self.assertIn("copy", args)
+ self.assertIn("attached_pic", args)
+ self.assertIn("1:v", args)
+
+ def test_m4b_reencodes_to_jpeg(self):
+ args = _cover_args("m4b", 2)
+ self.assertIn("mjpeg", args)
+ self.assertIn("attached_pic", args)
+ self.assertIn("3", args) # jpeg quality
+
+ def test_ogg_and_wav_have_no_cover(self):
+ self.assertEqual(_cover_args("ogg", 1), [])
+ self.assertEqual(_cover_args("wav", 1), [])
+
+
+class BuildConcatCommandMetaTests(unittest.TestCase):
+ META = TrackMeta(title="Chapter 1", artist="Author", album="Book",
+ track=1, total_tracks=3)
+
+ def test_cover_added_as_second_input(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3",
+ meta=self.META, cover=Path("cover.png"))
+ # The cover is the second input, after the concat list
+ self.assertIn("cover.png", cmd)
+ self.assertLess(cmd.index("list.txt"), cmd.index("cover.png"))
+ self.assertIn("-map", cmd)
+ self.assertIn("1:v", cmd)
+ self.assertIn("attached_pic", cmd)
+ self.assertEqual(cmd[-1], "out.mp3")
+
+ def test_audio_explicitly_mapped_when_cover_present(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.flac"), "flac",
+ cover=Path("cover.png"))
+ self.assertIn("0:a", cmd)
+
+ def test_no_cover_keeps_single_input(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3",
+ meta=self.META)
+ self.assertEqual(cmd.count("-i"), 1)
+ self.assertNotIn("attached_pic", cmd)
+
+ def test_ogg_never_gets_cover_input(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.ogg"), "ogg",
+ meta=self.META, cover=Path("cover.png"))
+ self.assertEqual(cmd.count("-i"), 1)
+ self.assertNotIn("attached_pic", cmd)
+
+ def test_speed_copy_gets_tags_and_cover(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3",
+ speed=1.5, speed_path=Path("out_1.5.mp3"),
+ meta=self.META, cover=Path("cover.png"))
+ self.assertEqual(cmd.count("attached_pic"), 2)
+ self.assertEqual(cmd.count("title=Chapter 1"), 2)
+ self.assertEqual(cmd.count("1:v"), 2)
+
+ def test_tags_without_cover_present(self):
+ cmd = build_concat_command(Path("list.txt"), Path("out.mp3"), "mp3",
+ meta=self.META)
+ self.assertIn("title=Chapter 1", cmd)
+ self.assertIn("artist=Author", cmd)
+ self.assertIn("album=Book", cmd)
+ self.assertIn("track=1/3", cmd)
+
+
+class BuildM4bChaptersCommandMetaTests(unittest.TestCase):
+ def setUp(self):
+ self._original = audio._brand_supported
+ audio._brand_supported = True
+
+ def tearDown(self):
+ audio._brand_supported = self._original
+
+ def test_cover_indexed_after_metadata_inputs(self):
+ cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"),
+ Path("out.m4b"), cover=Path("cover.png"))
+ # Inputs: 0=audio, 1=ffmetadata, 2=cover
+ self.assertIn("-i", cmd)
+ self.assertIn("2:v", cmd)
+ self.assertIn("attached_pic", cmd)
+
+ def test_speed_variant_cover_is_input_three(self):
+ cmd = build_m4b_chapters_command(
+ Path("list.txt"), Path("meta.txt"), Path("out.m4b"),
+ speed=2.0, speed_path=Path("out_2.m4b"), speed_metadata_file=Path("meta2.txt"),
+ meta=TrackMeta(title="Book"), cover=Path("cover.png"),
+ )
+ self.assertEqual(cmd.count("3:v"), 2) # both outputs attach the cover
+ self.assertNotIn("2:v", cmd)
+ self.assertEqual(cmd.count("title=Book"), 2)
+ # Chapter metadata inputs keep their 1/2 mapping
+ chapter_flags = [i for i, v in enumerate(cmd) if v == "-map_chapters"]
+ self.assertEqual(cmd[chapter_flags[0] + 1], "1")
+ self.assertEqual(cmd[chapter_flags[1] + 1], "2")
+
+ def test_without_cover_regression(self):
+ cmd = build_m4b_chapters_command(Path("list.txt"), Path("meta.txt"),
+ Path("out.m4b"))
+ self.assertNotIn("attached_pic", cmd)
+ self.assertNotIn("-metadata", cmd)
+
+
+class ConcatAudioFilesTests(unittest.TestCase):
+ """Concatenation of sub-request audio into one chunk file."""
+
+ @staticmethod
+ def _write_wav(path: Path, frames: bytes, framerate: int = 24000) -> Path:
+ with wave.open(str(path), "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(framerate)
+ wav_file.writeframes(frames)
+ return path
+
+ def test_wav_files_are_merged_in_order(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ first = self._write_wav(Path(tmp) / "a.wav", b"\x01\x00" * 10)
+ second = self._write_wav(Path(tmp) / "b.wav", b"\x02\x00" * 20)
+ destination = Path(tmp) / "out.wav"
+ concat_audio_files([first, second], destination)
+ with wave.open(str(destination), "rb") as wav_file:
+ self.assertEqual(wav_file.getframerate(), 24000)
+ self.assertEqual(wav_file.getnchannels(), 1)
+ self.assertEqual(wav_file.getsampwidth(), 2)
+ frames = wav_file.readframes(wav_file.getnframes())
+ self.assertEqual(frames, b"\x01\x00" * 10 + b"\x02\x00" * 20)
+
+ def test_single_wav_file_is_copied(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ source = self._write_wav(Path(tmp) / "a.wav", b"\x03\x00" * 15)
+ destination = Path(tmp) / "out.wav"
+ concat_audio_files([source], destination)
+ with wave.open(str(destination), "rb") as wav_file:
+ self.assertEqual(wav_file.readframes(wav_file.getnframes()),
+ b"\x03\x00" * 15)
+
+ def test_empty_source_list_raises(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ with self.assertRaises(ValueError):
+ concat_audio_files([], Path(tmp) / "out.wav")
+
+ def test_mismatched_wav_parameters_fall_back_to_ffmpeg(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ first = self._write_wav(Path(tmp) / "a.wav", b"\x01\x00" * 10, framerate=24000)
+ second = self._write_wav(Path(tmp) / "b.wav", b"\x02\x00" * 10, framerate=16000)
+ destination = Path(tmp) / "out.wav"
+ with patch("converter.audio.shutil.which", return_value=None), \
+ self.assertRaises(RuntimeError) as ctx:
+ concat_audio_files([first, second], destination)
+ self.assertIn("ffmpeg", str(ctx.exception))
+ # The wave-module path must not have written a partial output.
+ self.assertFalse(destination.exists())
+
+ def test_non_wav_input_falls_back_to_ffmpeg(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ source = Path(tmp) / "part.mp3"
+ source.write_bytes(b"not a wav file")
+ destination = Path(tmp) / "out.wav"
+ with patch("converter.audio.shutil.which", return_value=None), \
+ self.assertRaises(RuntimeError) as ctx:
+ concat_audio_files([source], destination)
+ self.assertIn("ffmpeg", str(ctx.exception))
+
+
+class CombineChunksPrintTests(unittest.TestCase):
+ """Single-request runs (audiocpp whole-chapter) omit the chunks suffix."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._chunks = patch.object(audio, "CHUNKS_FOLDER", Path(self._tmp.name))
+ self._chunks.start()
+ self.addCleanup(self._chunks.stop)
+
+ def _combine(self, total_chunks, chunk_results, intermediate=False):
+ buf = io.StringIO()
+ with patch.object(audio.shutil, "which", return_value="/usr/bin/ffmpeg"), \
+ patch.object(audio, "atempo_filters", return_value=False), \
+ patch.object(audio, "build_concat_command",
+ return_value=["ffmpeg"]), \
+ patch.object(audio.subprocess, "run",
+ return_value=MagicMock(returncode=0)), \
+ patch.object(audio, "probe_duration_ms", return_value=1000), \
+ patch.object(audio, "verify_output_duration",
+ return_value=True), \
+ redirect_stdout(buf):
+ ok = audio.combine_chunks(
+ total_chunks, Path("out.m4b"), chunk_results,
+ output_format="m4b", intermediate=intermediate)
+ self.assertTrue(ok)
+ return buf.getvalue()
+
+ def test_single_chunk_omits_chunks_suffix(self):
+ chunk = Path(self._tmp.name) / "chunk_0001.wav"
+ chunk.write_bytes(b"x")
+ out = self._combine(1, {1: chunk})
+ self.assertEqual(out.strip(), "[INFO] Saved audiobook: out.m4b")
+
+ def test_multi_chunk_keeps_chunks_suffix(self):
+ chunk = Path(self._tmp.name) / "chunk_0001.wav"
+ chunk.write_bytes(b"x")
+ out = self._combine(1, {1: chunk}, intermediate=True)
+ self.assertEqual(out.strip(),
+ "[INFO] Saved chapter audio (intermediate): out.m4b")
+
+ def test_partial_chunk_run_keeps_chunks_suffix(self):
+ chunk = Path(self._tmp.name) / "chunk_0001.wav"
+ chunk.write_bytes(b"x")
+ out = self._combine(2, {1: chunk, 2: chunk})
+ self.assertEqual(out.strip(),
+ "[INFO] Saved audiobook: out.m4b (2/2 chunks)")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
new file mode 100644
index 0000000..c0e8d4a
--- /dev/null
+++ b/app/tests/test_backends.py
@@ -0,0 +1,178 @@
+"""Tests for the backends package registry and detection aggregation."""
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from backends import REGISTRY, detect_all, get
+
+
+class RegistryTests(unittest.TestCase):
+ def setUp(self):
+ # The registry is built lazily on first access (the backend modules
+ # pull in converter.tts and its deps, which are only available inside
+ # the managed venv). Trigger the build so these tests don't depend on
+ # another test class having called detect_all() first.
+ get("audiocpp")
+
+ def test_registry_has_the_three_backends(self):
+ keys = [info.key for info in REGISTRY]
+ self.assertEqual(keys, ["audiocpp", "qwen", "faster"])
+
+ def test_every_entry_has_detect_and_setup_tui(self):
+ for info in REGISTRY:
+ self.assertTrue(callable(info.detect), info.key)
+ self.assertTrue(callable(info.setup_tui), info.key)
+ self.assertIsInstance(info.configure_actions, list)
+ for action in info.configure_actions:
+ self.assertTrue(callable(action.run))
+
+ def test_get_returns_entry_by_key(self):
+ self.assertIs(get("audiocpp").key, "audiocpp")
+ self.assertIsNone(get("nonexistent"))
+
+
+class DetectAllTests(unittest.TestCase):
+ def test_detect_all_returns_one_status_per_backend(self):
+ with patch("backends.common.server_running", return_value=False):
+ statuses = detect_all()
+ self.assertEqual([s.key for s in statuses],
+ ["audiocpp", "qwen", "faster"])
+ for s in statuses:
+ self.assertIn(s.key, ("audiocpp", "qwen", "faster"))
+ # ready requires both installed and configured; on a clean
+ # machine none are ready.
+ if s.ready:
+ self.assertTrue(s.installed and s.configured)
+ # running is always probed; patched False here so a dev machine
+ # running a real server can't flake the test.
+ self.assertFalse(s.running)
+
+ def test_audiocpp_status_when_cloned_built_configured(self):
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ checkout = root / "audio.cpp"
+ checkout.mkdir()
+ (checkout / "model_specs").mkdir()
+ (checkout / "build" / "linux-cuda-release" / "bin").mkdir(
+ parents=True)
+ (checkout / "build" / "linux-cuda-release" / "bin"
+ / "audiocpp_server").write_bytes(b"x")
+ (checkout / "server.json").write_text('{"models":[]}',
+ encoding="utf-8")
+ from backends import audiocpp
+ with patch.object(audiocpp, "find_local_checkout",
+ return_value=checkout), \
+ patch("backends.common.server_running",
+ return_value=False):
+ status = audiocpp.detect()
+ self.assertTrue(status.installed)
+ self.assertTrue(status.configured)
+ self.assertTrue(status.ready)
+ self.assertFalse(status.running)
+ self.assertIn("audiocpp_server", status.launch_hint)
+
+ def test_audiocpp_running_when_server_probe_succeeds(self):
+ from backends import audiocpp
+ with patch.object(audiocpp, "find_local_checkout",
+ return_value=None), \
+ patch("backends.common.server_running", return_value=True):
+ status = audiocpp.detect()
+ # Not installed (no checkout) but an external server is up.
+ self.assertFalse(status.installed)
+ self.assertTrue(status.running)
+
+ def test_qwen_status_reflects_install(self):
+ from backends import qwen
+ with patch.object(qwen, "_is_installed", return_value=True), \
+ patch("backends.common.server_running", return_value=False):
+ status = qwen.detect()
+ self.assertTrue(status.installed)
+ self.assertTrue(status.configured)
+ self.assertFalse(status.running)
+ self.assertIn("qwen-tts-demo", status.launch_hint)
+ with patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running", return_value=False):
+ status = qwen.detect()
+ self.assertFalse(status.installed)
+ self.assertFalse(status.configured)
+
+ def test_qwen_running_when_either_port_is_up(self):
+ # Either the CustomVoice port or the Base port counts as running.
+ from backends import qwen
+ with patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running",
+ side_effect=[True, False]):
+ status = qwen.detect()
+ self.assertTrue(status.running)
+ with patch.object(qwen, "_is_installed", return_value=False), \
+ patch("backends.common.server_running",
+ side_effect=[False, True]):
+ status = qwen.detect()
+ self.assertTrue(status.running)
+
+ def test_faster_status_reflects_install_clone_voices(self):
+ from backends import faster
+ with tempfile.TemporaryDirectory() as td:
+ checkout = Path(td) / "faster-qwen3-tts"
+ (checkout / "examples").mkdir(parents=True)
+ (checkout / "examples" / "openai_server.py").write_text("x")
+ (checkout / "voices.json").write_text('{"default":{}}',
+ encoding="utf-8")
+ with patch.object(faster, "_is_installed", return_value=True), \
+ patch.object(faster, "_checkout",
+ return_value=checkout), \
+ patch("backends.common.server_running",
+ return_value=False):
+ status = faster.detect()
+ self.assertTrue(status.installed)
+ self.assertTrue(status.configured)
+ self.assertFalse(status.running)
+ self.assertIn("openai_server.py", status.launch_hint)
+
+ def test_faster_running_when_server_probe_succeeds(self):
+ from backends import faster
+ with patch.object(faster, "_is_installed", return_value=False), \
+ patch.object(faster, "_is_cloned", return_value=False), \
+ patch("backends.common.server_running", return_value=True):
+ status = faster.detect()
+ self.assertTrue(status.running)
+
+
+class ServerRunningTests(unittest.TestCase):
+ """backends.common.server_running: TCP probe against a real socket."""
+
+ def test_true_for_open_port(self):
+ import socket
+
+ from backends import common
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ server.bind(("127.0.0.1", 0))
+ server.listen(1)
+ host, port = server.getsockname()
+ url = f"http://127.0.0.1:{port}"
+ try:
+ self.assertTrue(common.server_running(url))
+ finally:
+ server.close()
+
+ def test_false_for_closed_port(self):
+ # Pick an unused port by opening + closing a socket, then probe it.
+ import socket
+
+ from backends import common
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.bind(("127.0.0.1", 0))
+ _, port = s.getsockname()
+ s.close()
+ self.assertFalse(common.server_running(f"http://127.0.0.1:{port}"))
+
+ def test_false_for_invalid_url(self):
+ from backends import common
+ self.assertFalse(common.server_running("not a url"))
+ self.assertFalse(common.server_running(""))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
new file mode 100644
index 0000000..9882ce1
--- /dev/null
+++ b/app/tests/test_backends_audiocpp.py
@@ -0,0 +1,1062 @@
+"""Tests for the audio.cpp backend setup module (backends/audiocpp.py)."""
+
+import io
+import json
+import sys
+import tempfile
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from converter import config
+from backends import audiocpp as make_server
+
+FAKE_CONFIG = (
+ 'LANGUAGE = "English"\n'
+ "\n"
+ 'AUDIOCPP_API_URL = "http://127.0.0.1:9999" # audio.cpp audiocpp_server\n'
+ "\n"
+ "CHUNK_SIZE = 250\n"
+)
+
+FAKE_CONFIG_WITH_MODEL_IDS = (
+ 'AUDIOCPP_API_URL = "http://127.0.0.1:9999" # audio.cpp audiocpp_server\n'
+ "\n"
+ 'AUDIOCPP_MODEL_ID = "qwen" # server entry for speaker mode\n'
+ 'AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"\n'
+)
+
+
+def _write_spec(checkout: Path, family: str, *, display_name=None,
+ tasks=("tts", "clone"), languages=("en",), packages=None,
+ category="tts"):
+ """Write a minimal model_specs/<family>.json into a fake checkout."""
+ specs = checkout / "model_specs"
+ specs.mkdir(parents=True, exist_ok=True)
+ if packages is None:
+ packages = [{
+ "id": f"{family}_q8_0", "default": True, "format": "gguf",
+ "target_directory": f"{family}-GGUF",
+ }]
+ spec = {
+ "family": family,
+ "display_name": display_name or family,
+ "category": category,
+ "tasks": list(tasks),
+ "languages": list(languages),
+ "packages": packages,
+ }
+ (specs / f"{family}.json").write_text(json.dumps(spec), encoding="utf-8")
+ return spec
+
+
+def _make_checkout(tmp: Path) -> Path:
+ """Create a fake audio.cpp checkout with a realistic model_specs set."""
+ checkout = tmp / "audio.cpp"
+ checkout.mkdir()
+ _write_spec(checkout, "qwen3_tts", display_name="Qwen3-TTS",
+ tasks=("tts", "clone", "design"),
+ languages=("zh", "en", "ja"),
+ packages=[
+ {"id": "qwen3_tts_1_7b_base_q8_0", "default": True,
+ "format": "gguf",
+ "target_directory": "Qwen3-TTS-12Hz-1.7B-Base-GGUF"},
+ {"id": "qwen3_tts_1_7b_customvoice_q8_0",
+ "format": "gguf",
+ "target_directory": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"},
+ {"id": "qwen3_tts_1_7b_voicedesign_q8_0",
+ "format": "gguf",
+ "target_directory": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF"},
+ ])
+ _write_spec(checkout, "higgs_audio_tts", display_name="Higgs Audio v3 TTS 4B",
+ languages=("auto",),
+ packages=[{
+ "id": "higgs_audio_tts_4b_q8_0", "default": True,
+ "format": "gguf",
+ "target_directory": "Higgs-Audio-v3-TTS-4B-GGUF",
+ }])
+ _write_spec(checkout, "voxcpm2", display_name="VoxCPM2-2B",
+ languages=("en", "zh"),
+ packages=[{
+ "id": "voxcpm2_q8_0", "default": True, "format": "gguf",
+ "target_directory": "VoxCPM2-GGUF",
+ }])
+ _write_spec(checkout, "index_tts2", display_name="IndexTTS-2",
+ languages=("zh", "en"),
+ packages=[{
+ "id": "index_tts2_q8_0", "default": True, "format": "gguf",
+ "target_directory": "IndexTTS2-GGUF",
+ }])
+ _write_spec(checkout, "pocket_tts", display_name="PocketTTS-100M",
+ tasks=("tts", "clone"), languages=("en", "de"),
+ packages=[{
+ "id": "pocket_tts_q8_0", "default": True, "format": "gguf",
+ "target_directory": "PocketTTS-GGUF",
+ }])
+ _write_spec(checkout, "supertonic", display_name="Supertonic 3",
+ tasks=("tts",), languages=("en", "ko"),
+ packages=[{
+ "id": "supertonic_q8_0", "default": True, "format": "gguf",
+ "target_directory": "Supertonic-GGUF",
+ }])
+ # An ASR family that must be filtered out.
+ _write_spec(checkout, "qwen3_asr", display_name="Qwen3-ASR",
+ tasks=("asr",), category="asr")
+ # A TTS family with no installable packages (must be skipped).
+ _write_spec(checkout, "empty_tts", display_name="Empty TTS",
+ tasks=("tts",), packages=[])
+ return checkout
+
+
+class FindWavFilesTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.folder = Path(self._tmp.name)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _touch(self, name):
+ path = self.folder / name
+ path.write_bytes(b"x")
+ return path
+
+ def test_finds_only_wavs_case_insensitive(self):
+ self._touch("b.wav")
+ self._touch("a.WAV")
+ self._touch("notes.txt")
+ (self.folder / "sub").mkdir()
+ (self.folder / "sub" / "c.wav").write_bytes(b"x")
+ names = [path.name for path in make_server.find_wav_files(self.folder)]
+ self.assertEqual(names, ["a.WAV", "b.wav"])
+
+ def test_sorted_alphabetically_case_insensitive(self):
+ for name in ("Zed.wav", "alpha.wav", "Beta.wav"):
+ self._touch(name)
+ names = [path.name for path in make_server.find_wav_files(self.folder)]
+ self.assertEqual(names, ["alpha.wav", "Beta.wav", "Zed.wav"])
+
+ def test_empty_directory_returns_empty_list(self):
+ self.assertEqual(make_server.find_wav_files(self.folder), [])
+
+
+class DetectWavDirTests(unittest.TestCase):
+ """Shallow .wav-directory discovery across the two checkout roots."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.root = Path(self._td.name)
+ self.audiocpp = self.root / "audio.cpp"
+ self.tts_root = self.root / "tts-audiobook-generator"
+ self.audiocpp.mkdir()
+ self.tts_root.mkdir()
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def _wav_dir(self, where, name="voices"):
+ directory = where / name
+ directory.mkdir(parents=True, exist_ok=True)
+ (directory / "voice.wav").write_bytes(b"x")
+ return directory
+
+ def test_unique_wav_dir_in_tts_root_returned(self):
+ found = self._wav_dir(self.tts_root, "voices")
+ self.assertEqual(make_server.detect_wav_dir(self.audiocpp,
+ self.tts_root),
+ found)
+
+ def test_unique_wav_dir_in_audiocpp_root_returned(self):
+ found = self._wav_dir(self.audiocpp, "reference")
+ self.assertEqual(make_server.detect_wav_dir(self.audiocpp,
+ self.tts_root),
+ found)
+
+ def test_root_itself_containing_wavs_returned(self):
+ (self.tts_root / "direct.wav").write_bytes(b"x")
+ self.assertEqual(make_server.detect_wav_dir(self.audiocpp,
+ self.tts_root),
+ self.tts_root)
+
+ def test_multiple_wav_dirs_returns_none(self):
+ self._wav_dir(self.tts_root, "one")
+ self._wav_dir(self.audiocpp, "two")
+ self.assertIsNone(make_server.detect_wav_dir(self.audiocpp,
+ self.tts_root))
+
+ def test_output_dir_of_tts_root_excluded(self):
+ self._wav_dir(self.tts_root, "output")
+ self.assertIsNone(make_server.detect_wav_dir(self.audiocpp,
+ self.tts_root))
+
+ def test_no_wavs_returns_none(self):
+ self.assertIsNone(make_server.detect_wav_dir(self.audiocpp,
+ self.tts_root))
+
+ def test_nested_wav_dir_not_seen(self):
+ nested = self.tts_root / "outer" / "inner"
+ nested.mkdir(parents=True)
+ (nested / "voice.wav").write_bytes(b"x")
+ self.assertIsNone(make_server.detect_wav_dir(self.audiocpp,
+ self.tts_root))
+
+
+class ConfigPortTests(unittest.TestCase):
+ def test_port_parsed_from_config_url(self):
+ with patch.object(config, "AUDIOCPP_API_URL",
+ "http://127.0.0.1:8080"):
+ self.assertEqual(make_server.config_port(), 8080)
+
+ def test_missing_port_falls_back(self):
+ with patch.object(config, "AUDIOCPP_API_URL", "http://127.0.0.1"):
+ self.assertEqual(make_server.config_port(),
+ make_server.FALLBACK_PORT)
+
+ def test_invalid_url_falls_back(self):
+ with patch.object(config, "AUDIOCPP_API_URL", "not a url"):
+ self.assertEqual(make_server.config_port(),
+ make_server.FALLBACK_PORT)
+
+ def test_url_with_port_replaces_port(self):
+ self.assertEqual(
+ make_server._url_with_port("http://127.0.0.1:8080", 9000),
+ "http://127.0.0.1:9000")
+
+ def test_url_without_port_adds_port(self):
+ self.assertEqual(
+ make_server._url_with_port("http://localhost", 8080),
+ "http://localhost:8080")
+
+
+class UpdateConfigPortTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.config_path = Path(self._tmp.name) / "config.py"
+ self.config_path.write_text(FAKE_CONFIG, encoding="utf-8")
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_rewrites_port_preserving_comment(self):
+ changed = make_server.update_config_api_url_port(
+ 8080, config_path=self.config_path)
+ self.assertTrue(changed)
+ text = self.config_path.read_text(encoding="utf-8")
+ self.assertIn(
+ 'AUDIOCPP_API_URL = "http://127.0.0.1:8080" # audio.cpp audiocpp_server',
+ text)
+ self.assertIn('LANGUAGE = "English"', text)
+ self.assertIn("CHUNK_SIZE = 250", text)
+
+ def test_returns_false_when_no_url_line(self):
+ path = Path(self._tmp.name) / "other.py"
+ path.write_text('CHUNK_SIZE = 250\n', encoding="utf-8")
+ self.assertFalse(make_server.update_config_api_url_port(
+ 8080, config_path=path))
+
+ def test_returns_false_when_port_unchanged(self):
+ self.assertFalse(make_server.update_config_api_url_port(
+ 9999, config_path=self.config_path))
+ self.assertEqual(self.config_path.read_text(encoding="utf-8"),
+ FAKE_CONFIG)
+
+ def test_returns_false_when_file_missing(self):
+ self.assertFalse(make_server.update_config_api_url_port(
+ 8080, config_path=Path(self._tmp.name) / "nope.py"))
+
+
+class UpdateConfigModelIdsTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.config_path = Path(self._tmp.name) / "config.py"
+ self.config_path.write_text(FAKE_CONFIG_WITH_MODEL_IDS,
+ encoding="utf-8")
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_rewrites_both_ids_preserving_lines(self):
+ changed = make_server.update_config_model_ids(
+ "higgs", "higgs", config_path=self.config_path)
+ self.assertTrue(changed)
+ text = self.config_path.read_text(encoding="utf-8")
+ self.assertIn('AUDIOCPP_MODEL_ID = "higgs" # server entry for speaker mode',
+ text)
+ self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text)
+ self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:9999"', text)
+
+ def test_clone_id_optional(self):
+ changed = make_server.update_config_model_ids(
+ "voxcpm2", config_path=self.config_path)
+ self.assertTrue(changed)
+ text = self.config_path.read_text(encoding="utf-8")
+ self.assertIn('AUDIOCPP_MODEL_ID = "voxcpm2"', text)
+ self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"', text)
+
+ def test_returns_false_when_ids_unchanged(self):
+ changed = make_server.update_config_model_ids(
+ "qwen", "qwen-clone", config_path=self.config_path)
+ self.assertFalse(changed)
+ self.assertEqual(self.config_path.read_text(encoding="utf-8"),
+ FAKE_CONFIG_WITH_MODEL_IDS)
+
+ def test_returns_false_when_lines_missing(self):
+ path = Path(self._tmp.name) / "other.py"
+ path.write_text('CHUNK_SIZE = 250\n', encoding="utf-8")
+ self.assertFalse(make_server.update_config_model_ids(
+ "higgs", "higgs", config_path=path))
+
+ def test_returns_false_when_file_missing(self):
+ self.assertFalse(make_server.update_config_model_ids(
+ "higgs", "higgs",
+ config_path=Path(self._tmp.name) / "nope.py"))
+
+
+class ResolveWavDirArgTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.folder = Path(self._tmp.name)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_resolves_to_absolute(self):
+ self.assertEqual(make_server.resolve_wav_dir_arg(str(self.folder)),
+ self.folder.resolve())
+
+ def test_strips_surrounding_quotes(self):
+ quoted = f'"{self.folder}"'
+ self.assertEqual(make_server.resolve_wav_dir_arg(quoted),
+ self.folder.resolve())
+
+ def test_strips_single_quotes(self):
+ quoted = f"'{self.folder}'"
+ self.assertEqual(make_server.resolve_wav_dir_arg(quoted),
+ self.folder.resolve())
+
+ def test_strips_whitespace(self):
+ self.assertEqual(make_server.resolve_wav_dir_arg(f" {self.folder} "),
+ self.folder.resolve())
+
+ def test_expands_tilde(self):
+ with patch.object(make_server.os.path, "expanduser",
+ return_value=str(self.folder)) as mock_expand:
+ result = make_server.resolve_wav_dir_arg("~/voices")
+ mock_expand.assert_called_once_with("~/voices")
+ self.assertEqual(result, self.folder.resolve())
+
+ def test_trailing_slash_preserved_as_dir(self):
+ self.assertEqual(make_server.resolve_wav_dir_arg(f"{self.folder}/"),
+ self.folder.resolve())
+
+
+class NormalizeDirArgTests(unittest.TestCase):
+ """Path normalization for the audio.cpp checkout argument."""
+
+ def test_expands_tilde_and_resolves(self):
+ with patch.object(make_server.os.path, "expanduser",
+ return_value="/home/u/audio.cpp") as mock_expand:
+ result = make_server.normalize_dir_arg("~/audio.cpp")
+ mock_expand.assert_called_once_with("~/audio.cpp")
+ self.assertEqual(result, Path("/home/u/audio.cpp").resolve())
+
+ def test_strips_quotes_and_whitespace(self):
+ with patch.object(make_server.os.path, "expanduser",
+ side_effect=lambda s: s):
+ result = make_server.normalize_dir_arg(' "/tmp/foo" ')
+ self.assertEqual(result, Path("/tmp/foo").resolve())
+
+
+class CheckoutAutoSelectTests(unittest.TestCase):
+ """TUI browser auto-accept callback for an audio.cpp checkout."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.root = Path(self._td.name)
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def test_accepts_audio_cpp_containing_model_specs(self):
+ checkout = self.root / "audio.cpp"
+ checkout.mkdir()
+ (checkout / "model_specs").mkdir()
+ self.assertEqual(make_server._checkout_auto_select(checkout),
+ checkout)
+
+ def test_rejects_audio_cpp_without_model_specs(self):
+ checkout = self.root / "audio.cpp"
+ checkout.mkdir()
+ self.assertIsNone(make_server._checkout_auto_select(checkout))
+
+ def test_rejects_other_name_even_with_model_specs(self):
+ other = self.root / "not-audiocpp"
+ other.mkdir()
+ (other / "model_specs").mkdir()
+ self.assertIsNone(make_server._checkout_auto_select(other))
+
+ def test_rejects_plain_directory(self):
+ plain = self.root / "somewhere"
+ plain.mkdir()
+ self.assertIsNone(make_server._checkout_auto_select(plain))
+
+
+class DefaultModelIdTests(unittest.TestCase):
+ def test_preferred_ids_for_tested_families(self):
+ self.assertEqual(make_server.default_model_id("qwen3_tts"), "qwen")
+ self.assertEqual(make_server.default_model_id("higgs_audio_tts"), "higgs")
+ self.assertEqual(make_server.default_model_id("voxcpm2"), "voxcpm2")
+ self.assertEqual(make_server.default_model_id("index_tts2"), "indextts2")
+
+ def test_derived_id_strips_trailing_tts_and_underscores(self):
+ self.assertEqual(make_server.default_model_id("pocket_tts"), "pocket")
+ self.assertEqual(make_server.default_model_id("dots_tts"), "dots")
+ self.assertEqual(make_server.default_model_id("moss_tts_local"),
+ "mossttslocal")
+
+
+class LoadModelCatalogTests(unittest.TestCase):
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.checkout = _make_checkout(Path(self._td.name))
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def test_includes_tts_families_excludes_asr(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ families = [entry["family"] for entry in catalog]
+ self.assertIn("qwen3_tts", families)
+ self.assertIn("higgs_audio_tts", families)
+ self.assertIn("pocket_tts", families)
+ self.assertIn("supertonic", families)
+ self.assertNotIn("qwen3_asr", families)
+
+ def test_skips_families_with_no_packages(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ self.assertNotIn("empty_tts",
+ [entry["family"] for entry in catalog])
+
+ def test_families_sorted_alphabetically_by_display_name(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ names = [entry["display_name"].lower() for entry in catalog]
+ self.assertEqual(names, sorted(names))
+ self.assertNotIn("tested", catalog[0])
+ self.assertNotIn("TESTED_FAMILIES", dir(make_server))
+
+ def test_default_package_and_target_directory_resolved(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ by_family = {entry["family"]: entry for entry in catalog}
+ higgs = by_family["higgs_audio_tts"]
+ self.assertEqual(higgs["install_id"], "higgs_audio_tts_4b_q8_0")
+ self.assertEqual(higgs["default_path"],
+ "models/Higgs-Audio-v3-TTS-4B-GGUF")
+
+ def test_picks_first_gguf_when_no_default_flag(self):
+ _write_spec(self.checkout, "voxcpm2", display_name="VoxCPM2-2B",
+ packages=[
+ {"id": "voxcpm2_bf16", "format": "gguf",
+ "target_directory": "VoxCPM2-GGUF"},
+ {"id": "voxcpm2_q8_0", "format": "gguf",
+ "target_directory": "VoxCPM2-GGUF"},
+ ])
+ catalog = make_server.load_model_catalog(self.checkout)
+ by_family = {entry["family"]: entry for entry in catalog}
+ self.assertEqual(by_family["voxcpm2"]["install_id"], "voxcpm2_bf16")
+
+ def test_clone_capability_from_tasks(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ by_family = {entry["family"]: entry for entry in catalog}
+ self.assertTrue(by_family["higgs_audio_tts"]["clone_capable"])
+ self.assertFalse(by_family["supertonic"]["clone_capable"])
+
+ def test_missing_model_specs_dir_raises(self):
+ empty = Path(self._td.name) / "empty"
+ empty.mkdir()
+ with self.assertRaises(NotADirectoryError):
+ make_server.load_model_catalog(empty)
+
+
+class DetectBackendTests(unittest.TestCase):
+ """Backend detection from audio.cpp build directory names."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.checkout = Path(self._td.name) / "audio.cpp"
+ self.checkout.mkdir()
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def _build(self, name, binary="audiocpp_server"):
+ build_dir = self.checkout / "build" / name
+ bin_dir = build_dir / "bin"
+ bin_dir.mkdir(parents=True)
+ (bin_dir / binary).write_bytes(b"x")
+ return build_dir
+
+ def test_no_build_dir_returns_none(self):
+ self.assertIsNone(make_server.detect_backend(self.checkout))
+
+ def test_unique_linux_backend_detected(self):
+ self._build("linux-cuda-release")
+ self.assertEqual(make_server.detect_backend(self.checkout), "cuda")
+
+ def test_windows_exe_backend_detected(self):
+ self._build("windows-vulkan-debug", binary="audiocpp_server.exe")
+ self.assertEqual(make_server.detect_backend(self.checkout), "vulkan")
+
+ def test_hip_backend_detected(self):
+ self._build("linux-hip-release")
+ self.assertEqual(make_server.detect_backend(self.checkout), "hip")
+
+ def test_cpu_backend_detected(self):
+ self._build("linux-cpu-release")
+ self.assertEqual(make_server.detect_backend(self.checkout), "cpu")
+
+ def test_metal_maps_to_cpu(self):
+ self._build("macos-metal-release")
+ self.assertEqual(make_server.detect_backend(self.checkout), "cpu")
+
+ def test_multiple_backends_returns_none(self):
+ self._build("linux-cuda-release")
+ self._build("linux-cpu-release")
+ self.assertIsNone(make_server.detect_backend(self.checkout))
+
+ def test_multiple_builds_same_backend_detected(self):
+ self._build("linux-cuda-release")
+ self._build("windows-cuda-debug")
+ self.assertEqual(make_server.detect_backend(self.checkout), "cuda")
+
+ def test_build_dir_without_binary_ignored(self):
+ (self.checkout / "build" / "linux-cuda-release").mkdir(parents=True)
+ self.assertIsNone(make_server.detect_backend(self.checkout))
+
+ def test_non_matching_build_dir_name_ignored(self):
+ self._build("linux-mybuild-release")
+ self.assertIsNone(make_server.detect_backend(self.checkout))
+
+
+class BackendOptionsTests(unittest.TestCase):
+ """Aligned backend menu labels and the [auto-detected] default."""
+
+ def test_options_have_aligned_dashes(self):
+ options, default_index = make_server._backend_options()
+ dash_columns = {label.index(" - ") for label, _ in options}
+ self.assertEqual(len(dash_columns), 1)
+ self.assertEqual(default_index, 0)
+
+ def test_detected_backend_marked_and_defaulted(self):
+ options, default_index = make_server._backend_options("vulkan")
+ labels = [label for label, _ in options]
+ self.assertEqual(default_index, labels.index(next(
+ label for label, value in options
+ if value == "vulkan" and label.endswith("[auto-detected]"))))
+ self.assertTrue(labels[default_index].endswith("[auto-detected]"))
+ self.assertEqual(options[default_index][1], "vulkan")
+
+ def test_unknown_detected_backend_is_ignored(self):
+ options, default_index = make_server._backend_options("opencl")
+ self.assertEqual(default_index, 0)
+ self.assertFalse(any("[auto-detected]" in label
+ for label, _ in options))
+
+ def test_labels_keep_backend_values(self):
+ options, _ = make_server._backend_options()
+ self.assertEqual([value for _, value in options],
+ list(make_server.BACKENDS))
+
+
+class BuildServerConfigTests(unittest.TestCase):
+ def test_single_entry_without_voice_dir(self):
+ entry = make_server.build_model_entry(
+ "higgs_audio_tts", "higgs", "models/Higgs-GGUF")
+ cfg = make_server.build_server_config(
+ "127.0.0.1", 8080, "cuda", False, [entry])
+ self.assertEqual(cfg["host"], "127.0.0.1")
+ self.assertEqual(cfg["port"], 8080)
+ self.assertEqual(cfg["backend"], "cuda")
+ self.assertFalse(cfg["lazy_load"])
+ self.assertEqual(cfg["models"], [entry])
+ self.assertNotIn("voice_dir", cfg)
+
+ def test_voice_dir_added_when_given(self):
+ entry = make_server.build_model_entry("voxcpm2", "voxcpm2", "models/V")
+ cfg = make_server.build_server_config(
+ "0.0.0.0", 9000, "cpu", True, [entry],
+ voice_dir="/abs/voices")
+ self.assertTrue(cfg["lazy_load"])
+ self.assertEqual(cfg["voice_dir"], "/abs/voices")
+
+ def test_model_entry_shape(self):
+ entry = make_server.build_model_entry("index_tts2", "indextts2", "p")
+ self.assertEqual(entry["id"], "indextts2")
+ self.assertEqual(entry["family"], "index_tts2")
+ self.assertEqual(entry["path"], "p")
+ self.assertEqual(entry["task"], "tts")
+ self.assertEqual(entry["mode"], "offline")
+
+ def test_model_entry_design_task(self):
+ entry = make_server.build_model_entry(
+ "qwen3_tts", "qwen-design", "p", task="vdes")
+ self.assertEqual(entry["task"], "vdes")
+ self.assertEqual(entry["mode"], "offline")
+
+
+class InstallModelsTests(unittest.TestCase):
+ """Printing or auto-running the model install commands."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.checkout = Path(self._td.name) / "audio.cpp"
+ self.checkout.mkdir()
+ self.manager = self.checkout / "tools" / "model_manager_v2.py"
+ self.manager.parent.mkdir()
+ self.manager.write_text("#!/usr/bin/env python3\n", encoding="utf-8")
+ self.guidance = [("Higgs Audio v3 TTS 4B", "higgs_audio_tts_4b_q8_0"),
+ ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0"),
+ ("Qwen3-TTS", "qwen3_tts_1_7b_base_q8_0")]
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def test_declined_download_prints_commands_deduped(self):
+ buf = io.StringIO()
+ with redirect_stdout(buf), \
+ patch.object(make_server.subprocess, "run") as run:
+ make_server._install_models(self.checkout, self.guidance,
+ download=False)
+ out = buf.getvalue()
+ self.assertEqual(out.count("install higgs_audio_tts_4b_q8_0"), 1)
+ self.assertEqual(out.count("install qwen3_tts_1_7b_base_q8_0"), 1)
+ run.assert_not_called()
+
+ def test_accepted_download_runs_each_command(self):
+ with patch.object(make_server.subprocess, "run",
+ return_value=MagicMock(returncode=0)) as run:
+ make_server._install_models(self.checkout, self.guidance,
+ download=True)
+ self.assertEqual(run.call_count, 2)
+ commands = [call[0][0] for call in run.call_args_list]
+ self.assertEqual(commands[0],
+ [sys.executable, str(self.manager), "install",
+ "higgs_audio_tts_4b_q8_0"])
+ self.assertEqual(commands[1],
+ [sys.executable, str(self.manager), "install",
+ "qwen3_tts_1_7b_base_q8_0"])
+ for call in run.call_args_list:
+ self.assertEqual(call[1]["cwd"], str(self.checkout))
+
+ def test_missing_manager_falls_back_to_printing(self):
+ self.manager.unlink()
+ buf = io.StringIO()
+ with redirect_stdout(buf), \
+ patch.object(make_server.subprocess, "run") as run:
+ make_server._install_models(self.checkout, self.guidance,
+ download=True)
+ self.assertIn("install higgs_audio_tts_4b_q8_0", buf.getvalue())
+ run.assert_not_called()
+
+ def test_failed_install_reports_warning_and_continues(self):
+ results = iter([MagicMock(returncode=1), MagicMock(returncode=0)])
+ buf = io.StringIO()
+ with redirect_stdout(buf), \
+ patch.object(make_server.subprocess, "run",
+ side_effect=lambda *a, **k: next(results)) as run:
+ make_server._install_models(self.checkout, self.guidance,
+ download=True)
+ self.assertEqual(run.call_count, 2)
+ self.assertIn("exited with code 1", buf.getvalue())
+
+ def test_decide_download_skips_prompt_without_manager(self):
+ self.manager.unlink()
+ confirm = MagicMock()
+ self.assertFalse(make_server._decide_download(self.checkout, confirm))
+ confirm.assert_not_called()
+
+ def test_decide_download_asks_when_manager_present(self):
+ confirm = MagicMock(return_value=True)
+ self.assertTrue(make_server._decide_download(self.checkout, confirm))
+ confirm.assert_called_once()
+
+
+class TranscribeWavDirTests(unittest.TestCase):
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.folder = Path(self._td.name)
+ self.narrator = self.folder / "narrator.wav"
+ self.narrator.write_bytes(b"x")
+ self.other = self.folder / "other.wav"
+ self.other.write_bytes(b"x")
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def test_transcribes_to_stem_map_with_absolute_paths(self):
+ transcripts = {str(self.narrator): "First.",
+ str(self.other): "Second."}
+ with patch.object(make_server, "transcribe_reference_audio",
+ side_effect=lambda path, model_name="base":
+ transcripts[path]):
+ result = make_server.transcribe_wav_dir(
+ [self.narrator, self.other], "base")
+ self.assertEqual(list(result), ["narrator", "other"])
+ self.assertEqual(result["narrator"], "First.")
+
+ def test_failed_transcription_keeps_empty_string(self):
+ with patch.object(make_server, "transcribe_reference_audio",
+ return_value=None):
+ result = make_server.transcribe_wav_dir([self.narrator], "base")
+ self.assertEqual(result["narrator"], "")
+
+ def test_whisper_model_name_passed_through(self):
+ with patch.object(make_server, "transcribe_reference_audio",
+ return_value="text") as mock_transcribe:
+ make_server.transcribe_wav_dir([self.narrator], "large-v3")
+ self.assertEqual(mock_transcribe.call_args.kwargs["model_name"],
+ "large-v3")
+
+ def test_write_prompt_text_format(self):
+ path = make_server.write_prompt_text(
+ self.folder, {"narrator": "Hello.", "other": "World."})
+ self.assertEqual(path, self.folder / make_server.PROMPT_TEXT_FILENAME)
+ text = path.read_text(encoding="utf-8")
+ self.assertIn("narrator|Hello.", text)
+ self.assertIn("other|World.", text)
+
+
+class DesignPackageTests(unittest.TestCase):
+ """Voice-design package detection."""
+
+ def test_detects_voicedesign_in_id(self):
+ self.assertTrue(make_server.is_design_package(
+ {"id": "qwen3_tts_1_7b_voicedesign_q8_0"}))
+
+ def test_detects_voicedesign_in_directory(self):
+ self.assertTrue(make_server.is_design_package(
+ {"target_directory": "Foo-VoiceDesign-GGUF"}))
+
+ def test_detects_separated_voice_design(self):
+ self.assertTrue(make_server.is_design_package(
+ {"display_name": "Voice Design Q8_0"}))
+
+ def test_ignores_other_packages(self):
+ self.assertFalse(make_server.is_design_package(
+ {"id": "higgs_audio_tts_4b_q8_0"}))
+ self.assertFalse(make_server.is_design_package({}))
+
+
+class PackageDirOptionsTests(unittest.TestCase):
+ """Grouping a family's packages into distinct target directories."""
+
+ def test_groups_precisions_and_marks_recommended(self):
+ entry = {
+ "family": "qwen3_tts",
+ "packages": [
+ {"id": "base_q8", "default": True, "format": "gguf",
+ "target_directory": "Base-GGUF"},
+ {"id": "base_bf16", "format": "gguf",
+ "target_directory": "Base-GGUF"},
+ {"id": "voicedesign_q8", "format": "gguf",
+ "target_directory": "VoiceDesign-GGUF"},
+ ],
+ }
+ options = make_server.package_dir_options(entry)
+ self.assertEqual([o["target_directory"] for o in options],
+ ["Base-GGUF", "VoiceDesign-GGUF"])
+ self.assertTrue(options[0]["recommended"])
+ self.assertFalse(options[0]["design"])
+ self.assertFalse(options[1]["recommended"])
+ self.assertTrue(options[1]["design"])
+ self.assertEqual(options[0]["install_id"], "base_q8")
+
+ def test_recommended_comes_first_even_if_listed_later(self):
+ entry = {
+ "family": "demo_tts",
+ "packages": [
+ {"id": "demo_other", "format": "gguf",
+ "target_directory": "Other-GGUF"},
+ {"id": "demo_default", "default": True, "format": "gguf",
+ "target_directory": "Default-GGUF"},
+ ],
+ }
+ options = make_server.package_dir_options(entry)
+ self.assertEqual([o["target_directory"] for o in options],
+ ["Default-GGUF", "Other-GGUF"])
+
+
+class FindAudiocppServerBinTests(unittest.TestCase):
+ """Locating the built audiocpp_server binary."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.checkout = Path(self._td.name) / "audio.cpp"
+ self.checkout.mkdir()
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def _build(self, name, binary="audiocpp_server"):
+ bin_dir = self.checkout / "build" / name / "bin"
+ bin_dir.mkdir(parents=True)
+ (bin_dir / binary).write_bytes(b"x")
+
+ def test_no_build_dir_returns_none(self):
+ self.assertIsNone(make_server.find_audiocpp_server_bin(self.checkout))
+
+ def test_finds_built_binary(self):
+ self._build("linux-cuda-release")
+ self.assertEqual(
+ make_server.find_audiocpp_server_bin(self.checkout),
+ self.checkout / "build" / "linux-cuda-release" / "bin"
+ / "audiocpp_server")
+
+ def test_finds_windows_exe(self):
+ self._build("windows-vulkan-debug", binary="audiocpp_server.exe")
+ self.assertEqual(
+ make_server.find_audiocpp_server_bin(self.checkout).name,
+ "audiocpp_server.exe")
+
+ def test_build_dir_without_binary_returns_none(self):
+ (self.checkout / "build" / "linux-cuda-release" / "bin").mkdir(
+ parents=True)
+ self.assertIsNone(make_server.find_audiocpp_server_bin(self.checkout))
+
+
+class BuildAudiocppTests(unittest.TestCase):
+ """Running the audio.cpp build helper script."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.checkout = Path(self._td.name) / "audio.cpp"
+ self.checkout.mkdir()
+ self.scripts = self.checkout / "scripts"
+ self.scripts.mkdir()
+ (self.scripts / "build_linux.sh").write_text("#!/bin/sh\n",
+ encoding="utf-8")
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def test_runs_build_script_with_backend_and_target(self):
+ with patch.object(make_server.common, "run_console_subprocess",
+ return_value=0) as run:
+ rc = make_server.build_audiocpp(self.checkout, "cuda")
+ self.assertEqual(rc, 0)
+ argv = run.call_args[0][0]
+ self.assertEqual(argv[:3], ["sh", str(self.scripts / "build_linux.sh"),
+ "--backend"])
+ self.assertIn("cuda", argv)
+ self.assertIn("--target", argv)
+ self.assertIn("audiocpp_server", argv)
+ self.assertEqual(run.call_args[1]["cwd"], self.checkout)
+
+ def test_missing_script_returns_nonzero(self):
+ for f in self.scripts.iterdir():
+ f.unlink()
+ rc = make_server.build_audiocpp(self.checkout, "cuda")
+ self.assertNotEqual(rc, 0)
+
+
+class AudiocppDetectTests(unittest.TestCase):
+ """backends.audiocpp.detect() status reporting."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.root = Path(self._td.name)
+ self.checkout = _make_checkout(self.root)
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def test_not_cloned(self):
+ with patch.object(make_server, "find_local_checkout", return_value=None):
+ status = make_server.detect()
+ self.assertFalse(status.installed)
+ self.assertFalse(status.configured)
+ self.assertIn("not cloned", status.details[0])
+
+ def test_cloned_not_built_not_configured(self):
+ with patch.object(make_server, "find_local_checkout",
+ return_value=self.checkout), \
+ patch.object(make_server, "find_audiocpp_server_bin",
+ return_value=None):
+ status = make_server.detect()
+ self.assertFalse(status.installed)
+ self.assertFalse(status.configured)
+ self.assertEqual(status.launch_hint, "")
+
+ def test_built_and_configured_ready(self):
+ binary = self.checkout / "build" / "linux-cuda-release" / "bin" \
+ / "audiocpp_server"
+ binary.parent.mkdir(parents=True)
+ binary.write_bytes(b"x")
+ server_json = self.checkout / "server.json"
+ server_json.write_text('{"models":[]}', encoding="utf-8")
+ with patch.object(make_server, "find_local_checkout",
+ return_value=self.checkout):
+ status = make_server.detect()
+ self.assertTrue(status.installed)
+ self.assertTrue(status.configured)
+ self.assertIn(str(binary), status.launch_hint)
+ self.assertIn(str(server_json), status.launch_hint)
+
+
+class NonInteractiveMainTests(unittest.TestCase):
+ """The flag-only (non-TUI) path through main(), end to end."""
+
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.root = Path(self._td.name)
+ self.folder = self.root / "wavs"
+ self.folder.mkdir()
+ self.output = self.root / "server.json"
+ self.checkout = _make_checkout(self.root)
+ # Isolate config.py rewrites so no test touches the real one.
+ self.fake_config = self.root / "config.py"
+ self.fake_config.write_text(FAKE_CONFIG, encoding="utf-8")
+ patcher = patch.object(make_server, "CONFIG_PATH", self.fake_config)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ # Tests run without a tty -> main() takes the non-interactive path.
+ patcher = patch.object(make_server, "_interactive", return_value=False)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def _run(self, argv, transcribe=None, whisper="faster_whisper"):
+ argv = ["backends/audiocpp.py"] + argv
+ transcribe_effect = transcribe if transcribe is not None \
+ else MagicMock()
+ with patch.object(sys, "argv", argv), \
+ patch.object(make_server, "transcribe_reference_audio",
+ side_effect=transcribe_effect), \
+ patch.object(make_server, "whisper_backend_available",
+ return_value=whisper):
+ return make_server.main()
+
+ def _args(self, *extra):
+ return ["--wavs", str(self.folder), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout)] + list(extra)
+
+ def test_default_run_hosts_recommended_entry(self):
+ exit_code = self._run(
+ self._args("--families", "higgs_audio_tts", "--no-sync-model-ids"))
+ self.assertEqual(exit_code, 0)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ self.assertEqual(data["host"], "127.0.0.1")
+ self.assertEqual(data["port"], make_server.config_port())
+ self.assertEqual(data["backend"], "cuda")
+ self.assertFalse(data["lazy_load"])
+ self.assertEqual([m["id"] for m in data["models"]], ["higgs"])
+ self.assertNotIn("voice_dir", data)
+
+ def test_port_sync_accepted_updates_config(self):
+ with patch.object(config, "AUDIOCPP_API_URL",
+ "http://127.0.0.1:9999"):
+ exit_code = self._run(
+ self._args("--families", "higgs_audio_tts", "--port", "8080",
+ "--no-sync-model-ids"))
+ self.assertEqual(exit_code, 0)
+ self.assertIn('"http://127.0.0.1:8080"',
+ self.fake_config.read_text(encoding="utf-8"))
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ self.assertEqual(data["port"], 8080)
+
+ def test_port_sync_declined_keeps_config(self):
+ with patch.object(config, "AUDIOCPP_API_URL",
+ "http://127.0.0.1:9999"):
+ exit_code = self._run(
+ self._args("--families", "higgs_audio_tts", "--port", "8080",
+ "--no-sync-port", "--no-sync-model-ids"))
+ self.assertEqual(exit_code, 0)
+ self.assertIn('"http://127.0.0.1:9999"',
+ self.fake_config.read_text(encoding="utf-8"))
+
+ def test_model_id_sync_accepted_updates_config(self):
+ self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS,
+ encoding="utf-8")
+ exit_code = self._run(self._args("--families", "higgs_audio_tts"))
+ self.assertEqual(exit_code, 0)
+ text = self.fake_config.read_text(encoding="utf-8")
+ self.assertIn('AUDIOCPP_MODEL_ID = "higgs"', text)
+ self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text)
+
+ def test_multi_family_lazy_with_voice_dir(self):
+ (self.folder / "narrator.wav").write_bytes(b"x")
+ exit_code = self._run(
+ self._args("--families", "qwen3_tts,higgs_audio_tts",
+ "--no-sync-model-ids"),
+ transcribe=lambda path, model_name="base": "a transcript")
+ self.assertEqual(exit_code, 0)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ self.assertEqual([m["id"] for m in data["models"]], ["qwen", "higgs"])
+ self.assertTrue(data["lazy_load"])
+ self.assertEqual(data["voice_dir"], str(self.folder.resolve()))
+ prompt = (self.folder / make_server.PROMPT_TEXT_FILENAME).read_text(
+ encoding="utf-8")
+ self.assertIn("narrator|a transcript", prompt)
+
+ def test_force_overwrites_existing_output(self):
+ self.output.write_text('{"old": true}', encoding="utf-8")
+ exit_code = self._run(
+ self._args("--families", "higgs_audio_tts", "--force",
+ "--no-sync-model-ids"))
+ self.assertEqual(exit_code, 0)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ self.assertEqual(len(data["models"]), 1)
+
+ def test_existing_output_declined_keeps_file(self):
+ self.output.write_text('{"old": true}', encoding="utf-8")
+ exit_code = self._run(
+ self._args("--families", "higgs_audio_tts", "--no-sync-model-ids"))
+ self.assertEqual(exit_code, 1)
+ self.assertEqual(json.loads(self.output.read_text(encoding="utf-8")),
+ {"old": True})
+
+ def test_all_packages_hosts_design_as_vdes(self):
+ exit_code = self._run(
+ self._args("--families", "qwen3_tts", "--all-packages",
+ "--no-sync-model-ids"))
+ self.assertEqual(exit_code, 0)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ by_id = {m["id"]: m for m in data["models"]}
+ self.assertIn("qwen-design", by_id)
+ self.assertEqual(by_id["qwen-design"]["task"], "vdes")
+ # The non-design packages are hosted with task "tts".
+ self.assertTrue(any(m["id"] in ("qwen", "qwen-2") and m["task"] == "tts"
+ for m in data["models"]))
+
+ def test_unknown_family_rejected(self):
+ with self.assertRaises(SystemExit) as ctx:
+ self._run(self._args("--families", "not_a_family",
+ "--no-sync-model-ids"))
+ self.assertEqual(ctx.exception.code, 2)
+
+ def test_missing_checkout_rejected(self):
+ with patch.object(make_server, "find_local_checkout",
+ return_value=None), \
+ self.assertRaises(SystemExit) as ctx:
+ self._run(["--families", "higgs_audio_tts", "--output",
+ str(self.output), "--no-sync-model-ids"])
+ self.assertEqual(ctx.exception.code, 2)
+
+ def test_missing_wav_dir_rejected(self):
+ missing = self.root / "nope"
+ with self.assertRaises(SystemExit) as ctx:
+ self._run(["--wavs", str(missing), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout),
+ "--families", "higgs_audio_tts", "--no-sync-model-ids"])
+ self.assertEqual(ctx.exception.code, 2)
+
+ def test_families_required_in_noninteractive_run(self):
+ with self.assertRaises(SystemExit) as ctx:
+ self._run(self._args("--no-sync-model-ids"))
+ self.assertEqual(ctx.exception.code, 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py
new file mode 100644
index 0000000..cf4ecc6
--- /dev/null
+++ b/app/tests/test_backends_envs.py
@@ -0,0 +1,204 @@
+"""Tests for the managed Python environment (backends/envs.py)."""
+
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from backends import envs
+
+
+class EnvPathTests(unittest.TestCase):
+ """Platform-aware path helpers (no venv actually created)."""
+
+ def test_env_dir_under_envs_tts(self):
+ self.assertEqual(envs.ENV_DIR.name, "tts")
+ self.assertEqual(envs.ENV_DIR.parent.name, "envs")
+
+ def test_env_python_posix(self):
+ with patch.object(envs, "_is_windows", return_value=False):
+ self.assertEqual(envs.env_python(),
+ envs.ENV_DIR / "bin" / "python")
+
+ def test_env_python_windows(self):
+ with patch.object(envs, "_is_windows", return_value=True):
+ self.assertEqual(envs.env_python(),
+ envs.ENV_DIR / "Scripts" / "python.exe")
+
+ def test_env_script_posix(self):
+ with patch.object(envs, "_is_windows", return_value=False):
+ self.assertEqual(envs.env_script("qwen-tts-demo"),
+ envs.ENV_DIR / "bin" / "qwen-tts-demo")
+
+ def test_env_script_windows(self):
+ with patch.object(envs, "_is_windows", return_value=True):
+ self.assertEqual(envs.env_script("qwen-tts-demo"),
+ envs.ENV_DIR / "Scripts" / "qwen-tts-demo.exe")
+
+ def test_env_exists_false_when_python_missing(self):
+ with patch.object(envs, "env_python",
+ return_value=Path("/no/such/path/python")):
+ self.assertFalse(envs.env_exists())
+
+ def test_is_managed_env_compares_resolved_executable(self):
+ fake_env_python = Path("/tmp/opencode/managed-env/bin/python")
+ with patch.object(envs, "env_python", return_value=fake_env_python), \
+ patch.object(sys, "executable", str(fake_env_python)):
+ self.assertTrue(envs.is_managed_env())
+ with patch.object(envs, "env_python", return_value=fake_env_python), \
+ patch.object(sys, "executable", "/usr/bin/python3"):
+ self.assertFalse(envs.is_managed_env())
+
+
+class CreateEnvTests(unittest.TestCase):
+ def test_create_env_invokes_venv_module(self):
+ with patch.object(envs.common, "run_console_subprocess",
+ return_value=0) as run:
+ rc = envs.create_env()
+ self.assertEqual(rc, 0)
+ argv = run.call_args[0][0]
+ self.assertEqual(argv[0], sys.executable)
+ self.assertEqual(argv[1], "-m")
+ self.assertEqual(argv[2], "venv")
+ self.assertEqual(argv[3], str(envs.ENV_DIR))
+
+ def test_create_env_reports_remediation_on_failure(self):
+ with patch.object(envs.common, "run_console_subprocess",
+ return_value=1):
+ rc = envs.create_env()
+ self.assertEqual(rc, 1)
+
+
+class PipInstallTests(unittest.TestCase):
+ def test_creates_env_first_when_missing(self):
+ calls = []
+
+ def fake_run(argv):
+ calls.append(list(argv))
+ return 0
+
+ with patch.object(envs, "env_exists", return_value=False), \
+ patch.object(envs, "create_env", return_value=0) as mk, \
+ patch.object(envs.common, "run_console_subprocess",
+ side_effect=fake_run):
+ rc = envs.pip_install(["qwen-tts"])
+ self.assertEqual(rc, 0)
+ mk.assert_called_once_with()
+ # The actual pip call targets the venv's python.
+ self.assertEqual(calls[0][0], str(envs.env_python()))
+ self.assertIn("pip", calls[0])
+ self.assertIn("qwen-tts", calls[0])
+
+ def test_skips_create_when_env_exists(self):
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "create_env") as mk, \
+ patch.object(envs.common, "run_console_subprocess",
+ return_value=0):
+ envs.pip_install(["qwen-tts"])
+ mk.assert_not_called()
+
+ def test_returns_nonzero_when_create_fails(self):
+ with patch.object(envs, "env_exists", return_value=False), \
+ patch.object(envs, "create_env", return_value=1), \
+ patch.object(envs.common, "run_console_subprocess") as run:
+ rc = envs.pip_install(["qwen-tts"])
+ self.assertEqual(rc, 1)
+ run.assert_not_called()
+
+
+class ModuleAvailableTests(unittest.TestCase):
+ def test_false_when_env_missing(self):
+ with patch.object(envs, "env_exists", return_value=False):
+ self.assertFalse(envs.module_available("qwen_tts"))
+
+ def test_true_when_subprocess_exits_zero(self):
+ import subprocess
+ fake = subprocess.CompletedProcess(args=["x"], returncode=0)
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch("subprocess.run", return_value=fake) as run:
+ self.assertTrue(envs.module_available("qwen_tts"))
+ argv = run.call_args[0][0]
+ self.assertEqual(argv[0], str(envs.env_python()))
+ self.assertIn("import qwen_tts", argv[2])
+
+ def test_false_when_subprocess_exits_nonzero(self):
+ import subprocess
+ fake = subprocess.CompletedProcess(args=["x"], returncode=1)
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch("subprocess.run", return_value=fake):
+ self.assertFalse(envs.module_available("qwen_tts"))
+
+ def test_false_on_timeout(self):
+ import subprocess
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch("subprocess.run",
+ side_effect=subprocess.TimeoutExpired(cmd="x", timeout=1)):
+ self.assertFalse(envs.module_available("qwen_tts"))
+
+
+class EnsureAppEnvTests(unittest.TestCase):
+ def test_creates_env_then_installs_when_marker_invalid(self):
+ with patch.object(envs, "env_exists", return_value=False), \
+ patch.object(envs, "create_env", return_value=0), \
+ patch.object(envs, "_marker_valid", return_value=False), \
+ patch.object(envs, "install_requirements", return_value=0), \
+ patch.object(envs, "_write_marker") as mk:
+ envs.ensure_app_env()
+ mk.assert_called_once_with()
+
+ def test_raises_when_create_fails(self):
+ with patch.object(envs, "env_exists", return_value=False), \
+ patch.object(envs, "create_env", return_value=1):
+ with self.assertRaises(RuntimeError):
+ envs.ensure_app_env()
+
+ def test_raises_when_install_fails(self):
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "_marker_valid", return_value=False), \
+ patch.object(envs, "install_requirements", return_value=1):
+ with self.assertRaises(RuntimeError):
+ envs.ensure_app_env()
+
+ def test_skips_install_when_marker_valid(self):
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "_marker_valid", return_value=True), \
+ patch.object(envs, "install_requirements") as mk:
+ envs.ensure_app_env()
+ mk.assert_not_called()
+
+
+class BootstrapTests(unittest.TestCase):
+ def test_noop_when_already_managed(self):
+ with patch.object(envs, "is_managed_env", return_value=True), \
+ patch.object(envs, "ensure_app_env") as mk, \
+ patch("os.execv") as ex:
+ envs.bootstrap("/path/to/audiobook.py")
+ mk.assert_not_called()
+ ex.assert_not_called()
+
+ def test_ensures_env_then_execvs(self):
+ with patch.object(envs, "is_managed_env", return_value=False), \
+ patch.object(envs, "ensure_app_env") as mk_env, \
+ patch("os.execv") as ex, \
+ patch.object(sys, "argv", ["audiobook.py", "--backend", "qwen"]):
+ envs.bootstrap("/path/to/audiobook.py")
+ mk_env.assert_called_once_with()
+ py = str(envs.env_python())
+ args = ex.call_args[0]
+ self.assertEqual(args[0], py)
+ self.assertEqual(args[1][0], py)
+ self.assertTrue(args[1][1].endswith("audiobook.py"))
+ self.assertEqual(args[1][2:], ["--backend", "qwen"])
+
+ def test_exits_when_ensure_raises(self):
+ with patch.object(envs, "is_managed_env", return_value=False), \
+ patch.object(envs, "ensure_app_env",
+ side_effect=RuntimeError("boom")), \
+ patch("os.execv") as ex, \
+ self.assertRaises(SystemExit):
+ envs.bootstrap("/path/to/audiobook.py")
+ ex.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py
new file mode 100644
index 0000000..641f6ee
--- /dev/null
+++ b/app/tests/test_backends_faster.py
@@ -0,0 +1,172 @@
+"""Tests for the faster-qwen3-tts backend setup module (backends/faster.py)."""
+
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from backends import faster as make_voices
+
+
+class FindWavFilesTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.folder = Path(self._tmp.name)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _touch(self, name):
+ path = self.folder / name
+ path.write_bytes(b"x")
+ return path
+
+ def test_finds_only_wavs_case_insensitive(self):
+ self._touch("b.wav")
+ self._touch("a.WAV")
+ self._touch("notes.txt")
+ (self.folder / "sub").mkdir()
+ (self.folder / "sub" / "c.wav").write_bytes(b"x")
+ names = [path.name for path in make_voices.find_wav_files(self.folder)]
+ self.assertEqual(names, ["a.WAV", "b.wav"])
+
+ def test_sorted_alphabetically_case_insensitive(self):
+ for name in ("Zed.wav", "alpha.wav", "Beta.wav"):
+ self._touch(name)
+ names = [path.name for path in make_voices.find_wav_files(self.folder)]
+ self.assertEqual(names, ["alpha.wav", "Beta.wav", "Zed.wav"])
+
+ def test_empty_directory_returns_empty_list(self):
+ self.assertEqual(make_voices.find_wav_files(self.folder), [])
+
+
+class BuildVoicesTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.folder = Path(self._tmp.name)
+ self.narrator = self.folder / "narrator.wav"
+ self.narrator.write_bytes(b"x")
+ self.other = self.folder / "other.wav"
+ self.other.write_bytes(b"x")
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_voices_named_after_basenames_with_absolute_paths(self):
+ transcripts = {str(self.narrator): "First transcript.",
+ str(self.other): "Second transcript."}
+ with patch.object(make_voices, "transcribe_reference_audio",
+ side_effect=lambda path, model_name="base": transcripts[path]):
+ voices = make_voices.build_voices([self.narrator, self.other],
+ "English", "base")
+ self.assertEqual(list(voices), ["narrator", "other"])
+ self.assertEqual(voices["narrator"]["ref_text"], "First transcript.")
+ self.assertEqual(voices["narrator"]["language"], "English")
+ self.assertTrue(Path(voices["narrator"]["ref_audio"]).is_absolute())
+ self.assertEqual(Path(voices["narrator"]["ref_audio"]), self.narrator.resolve())
+
+ def test_failed_transcription_keeps_entry_with_empty_text(self):
+ with patch.object(make_voices, "transcribe_reference_audio",
+ return_value=None):
+ voices = make_voices.build_voices([self.narrator], "English", "base")
+ self.assertEqual(voices["narrator"]["ref_text"], "")
+
+ def test_whisper_model_name_is_passed_through(self):
+ with patch.object(make_voices, "transcribe_reference_audio",
+ return_value="text") as mock_transcribe:
+ make_voices.build_voices([self.narrator], "English", "large-v3")
+ self.assertEqual(mock_transcribe.call_args.kwargs["model_name"], "large-v3")
+
+
+class MainTests(unittest.TestCase):
+ """The flag-only (non-TUI) path through main(), end to end."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.folder = Path(self._tmp.name)
+ (self.folder / "narrator.wav").write_bytes(b"x")
+ (self.folder / "alpha.wav").write_bytes(b"x")
+ self.output = self.folder / "voices.json"
+ # Avoid touching the real converter/config.py and pip/git.
+ patcher = patch.object(make_voices.common, "update_config_value",
+ return_value=False)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ patcher = patch.object(make_voices, "_interactive", return_value=False)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _run(self, argv):
+ with patch.object(sys, "argv", ["backends/faster.py"] + argv), \
+ patch.object(make_voices, "transcribe_reference_audio",
+ return_value="hello"):
+ return make_voices.main()
+
+ def test_writes_json_with_alphabetical_voice_order(self):
+ exit_code = self._run([str(self.folder), "--output", str(self.output),
+ "--skip-install", "--skip-clone"])
+ self.assertEqual(exit_code, 0)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ self.assertEqual(list(data), ["alpha", "narrator"])
+ self.assertEqual(data["alpha"]["ref_text"], "hello")
+ self.assertEqual(data["alpha"]["language"], "English")
+
+ def test_custom_output_path(self):
+ custom = Path(self._tmp.name) / "custom.json"
+ exit_code = self._run([str(self.folder), "--output", str(custom),
+ "--skip-install", "--skip-clone"])
+ self.assertEqual(exit_code, 0)
+ self.assertTrue(custom.exists())
+ self.assertFalse(self.output.exists())
+
+ def test_invalid_language_errors_before_work(self):
+ with patch.object(make_voices, "transcribe_reference_audio") as mock_transcribe:
+ with self.assertRaises(SystemExit) as ctx:
+ self._run([str(self.folder), "--output", str(self.output),
+ "--language", "klingon", "--skip-install",
+ "--skip-clone"])
+ self.assertEqual(ctx.exception.code, 2)
+ mock_transcribe.assert_not_called()
+
+ def test_missing_input_dir_errors(self):
+ with self.assertRaises(SystemExit) as ctx:
+ self._run([str(self.folder / "nope"), "--output", str(self.output),
+ "--skip-install", "--skip-clone"])
+ self.assertEqual(ctx.exception.code, 2)
+
+ def test_no_wav_files_returns_error(self):
+ empty = Path(tempfile.mkdtemp())
+ try:
+ exit_code = self._run([str(empty), "--output",
+ str(empty / "voices.json"),
+ "--skip-install", "--skip-clone"])
+ self.assertEqual(exit_code, 1)
+ finally:
+ empty.rmdir()
+
+ def test_existing_output_declined_keeps_file(self):
+ self.output.write_text('{"old": true}', encoding="utf-8")
+ with patch.object(make_voices, "transcribe_reference_audio") as mock_transcribe:
+ exit_code = self._run([str(self.folder), "--output", str(self.output),
+ "--skip-install", "--skip-clone"])
+ self.assertEqual(exit_code, 1)
+ mock_transcribe.assert_not_called()
+ self.assertEqual(json.loads(self.output.read_text(encoding="utf-8")),
+ {"old": True})
+
+ def test_force_overwrites_without_prompt(self):
+ self.output.write_text('{"old": true}', encoding="utf-8")
+ exit_code = self._run([str(self.folder), "--output", str(self.output),
+ "--force", "--skip-install", "--skip-clone"])
+ self.assertEqual(exit_code, 0)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ self.assertEqual(list(data), ["alpha", "narrator"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py
new file mode 100644
index 0000000..02b65e6
--- /dev/null
+++ b/app/tests/test_backends_servers.py
@@ -0,0 +1,146 @@
+"""Tests for the server lifecycle module (backends/servers.py)."""
+
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from backends import ServerSpec, servers
+
+
+class StartTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+ # A fake executable so Path(argv[0]).exists() passes.
+ self.exe = self.dir / "fake_server"
+ self.exe.write_bytes(b"#!/bin/sh\n")
+ self.spec = ServerSpec("test", "http://127.0.0.1:9999",
+ [str(self.exe), "--port", "9999"])
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_returns_false_when_executable_missing(self):
+ spec = ServerSpec("nope", "http://127.0.0.1:1", ["/no/such/binary"])
+ with patch.object(servers, "LOG_DIR", self.dir):
+ self.assertFalse(servers.start(spec))
+
+ def test_noop_when_already_running(self):
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch("backends.common.server_running", return_value=True), \
+ patch("subprocess.Popen") as mk:
+ self.assertTrue(servers.start(self.spec))
+ mk.assert_not_called()
+
+ def test_happy_path_spawns_and_polls_until_ready(self):
+ proc = MagicMock()
+ proc.pid = 4242
+ proc.poll.return_value = None # process still running
+ # server_running: False on the pre-check, True once inside the loop.
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch("subprocess.Popen", return_value=proc) as mk, \
+ patch("backends.common.server_running",
+ side_effect=[False, True]), \
+ patch("time.sleep"):
+ ok = servers.start(self.spec)
+ self.assertTrue(ok)
+ mk.assert_called_once()
+ # Pid file written.
+ self.assertEqual(
+ (self.dir / "test-server.pid").read_text(encoding="utf-8"),
+ "4242")
+
+ def test_returns_false_when_process_exits_early(self):
+ proc = MagicMock()
+ proc.pid = 99
+ proc.poll.return_value = 1 # exited with code 1
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch("subprocess.Popen", return_value=proc), \
+ patch("backends.common.server_running", return_value=False), \
+ patch("time.sleep"):
+ ok = servers.start(self.spec)
+ self.assertFalse(ok)
+ # Pid file cleaned up after early exit.
+ self.assertFalse((self.dir / "test-server.pid").exists())
+
+ def test_returns_false_on_timeout(self):
+ proc = MagicMock()
+ proc.pid = 7
+ proc.poll.return_value = None
+ # time.time: first call < deadline loop entry, then past deadline.
+ times = iter([0.0, float(servers.SERVER_START_TIMEOUT + 1)])
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch("subprocess.Popen", return_value=proc), \
+ patch("backends.common.server_running", return_value=False), \
+ patch("time.sleep"), \
+ patch("time.time", side_effect=lambda: next(times)):
+ ok = servers.start(self.spec)
+ self.assertFalse(ok)
+
+
+class StopTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _write_pid(self, name, pid):
+ (self.dir / f"{name}-server.pid").write_text(str(pid),
+ encoding="utf-8")
+
+ def test_returns_false_when_no_pid_file(self):
+ with patch.object(servers, "LOG_DIR", self.dir):
+ self.assertFalse(servers.stop("test"))
+
+ def test_stops_alive_process_and_removes_pid_file(self):
+ self._write_pid("test", 1234)
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch.object(servers, "_pid_alive", return_value=True), \
+ patch.object(servers, "_kill_pid", return_value=True) as mk:
+ ok = servers.stop("test")
+ self.assertTrue(ok)
+ mk.assert_called_once_with(1234)
+ self.assertFalse((self.dir / "test-server.pid").exists())
+
+ def test_already_dead_returns_true_and_cleans_pid_file(self):
+ self._write_pid("test", 1234)
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch.object(servers, "_pid_alive", return_value=False), \
+ patch.object(servers, "_kill_pid") as mk:
+ ok = servers.stop("test")
+ self.assertTrue(ok)
+ mk.assert_not_called()
+ self.assertFalse((self.dir / "test-server.pid").exists())
+
+ def test_corrupt_pid_file_returns_false_and_cleans(self):
+ (self.dir / "test-server.pid").write_text("not-a-number",
+ encoding="utf-8")
+ with patch.object(servers, "LOG_DIR", self.dir):
+ self.assertFalse(servers.stop("test"))
+ self.assertFalse((self.dir / "test-server.pid").exists())
+
+
+class PidForTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.dir = Path(self._tmp.name)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_none_when_no_pid_file(self):
+ with patch.object(servers, "LOG_DIR", self.dir):
+ self.assertIsNone(servers.pid_for("test"))
+
+ def test_returns_pid_from_file(self):
+ (self.dir / "test-server.pid").write_text("555\n",
+ encoding="utf-8")
+ with patch.object(servers, "LOG_DIR", self.dir):
+ self.assertEqual(servers.pid_for("test"), 555)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_chunking.py b/app/tests/test_chunking.py
new file mode 100644
index 0000000..2904e40
--- /dev/null
+++ b/app/tests/test_chunking.py
@@ -0,0 +1,118 @@
+"""Tests for text chunking."""
+
+import unittest
+from unittest.mock import patch
+
+from converter import config
+from converter.chunking import split_into_chunks
+
+
+class ChunkSizeDefaultTests(unittest.TestCase):
+ """Guard the request-size setting: each API call is one model
+ generation, and the servers silently truncate audio when a single
+ generation runs too long (~2.5 min faster backend, ~11 min Qwen
+ demo), so the default chunk size must stay well inside that budget.
+ There is no hard ceiling beyond CHUNK_SIZE; users raising it accept
+ the truncation risk themselves."""
+
+ def test_default_chunk_size_within_single_generation_budget(self):
+ self.assertLessEqual(config.CHUNK_SIZE, 300)
+
+ def test_default_chunk_size_is_positive(self):
+ self.assertGreaterEqual(config.CHUNK_SIZE, 1)
+
+
+class RequestSizeTests(unittest.TestCase):
+ def test_oversized_chunk_size_is_honored(self):
+ # No clamping: whatever size is configured (or requested) is used.
+ text = " ".join(f"word{i}" for i in range(30)) + "."
+ chunks = split_into_chunks(text, max_words=5000)
+ self.assertEqual(len(chunks), 1)
+ self.assertEqual(len(chunks[0].split()), 30)
+
+ def test_default_uses_runtime_config_chunk_size(self):
+ # The default resolves config.CHUNK_SIZE at call time, so
+ # patching the config changes the default split size.
+ sentences = " ".join(
+ f"S{i} " + " ".join(["word"] * 8) + "." for i in range(60))
+ with patch.object(config, "CHUNK_SIZE", 120):
+ chunks = split_into_chunks(sentences)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(chunk.split()) <= 120 for chunk in chunks))
+
+
+class SplitIntoChunksTests(unittest.TestCase):
+ def test_empty_input(self):
+ self.assertEqual(split_into_chunks(""), [])
+ self.assertEqual(split_into_chunks(" \n "), [])
+
+ def test_short_text_single_chunk(self):
+ self.assertEqual(split_into_chunks("One short sentence."), ["One short sentence."])
+
+ def test_respects_word_limit_across_sentences(self):
+ # 10 sentences of 9 words each = 90 words total
+ sentences = [f"S{i} " + " ".join(["word"] * 8) + "." for i in range(10)]
+ chunks = split_into_chunks(" ".join(sentences), max_words=25)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(c.split()) <= 25 for c in chunks))
+ self.assertEqual(sum(len(c.split()) for c in chunks), 90)
+
+ def test_long_sentence_split_keeps_punctuation(self):
+ # 10 clauses of 5 words each, joined by comma+space
+ sentence = ", ".join([" ".join(["w"] * 5) for _ in range(10)]) + "."
+ chunks = split_into_chunks(sentence, max_words=12)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(c.split()) <= 12 for c in chunks))
+ self.assertIn(",", chunks[0]) # commas retained for TTS prosody
+
+ def test_clause_split_never_breaks_numbers(self):
+ # Regression: the clause split used to fire at every comma even
+ # without whitespace, mutating "1,000,000" into "1, 000, 000".
+ sentence = ("There were exactly 1,000,000 soldiers marching at 12:30, "
+ + "and they kept marching onward " * 30) + "endlessly."
+ chunks = split_into_chunks(sentence, max_words=25)
+ self.assertGreater(len(chunks), 1)
+ joined = " ".join(chunks)
+ self.assertIn("1,000,000", joined)
+ self.assertIn("12:30", joined)
+ self.assertNotIn("1, 000", joined)
+ self.assertNotIn("000, 000", joined)
+ self.assertNotIn("12: 30", joined)
+
+ def test_clause_split_requires_whitespace_after_punctuation(self):
+ # Run-on clauses without spaces after commas have no clause split
+ # point, so the last-resort word-boundary split fires instead.
+ # Tokens themselves (and numbers like "1,000,000") stay intact.
+ sentence = ",".join([" ".join(["w"] * 5) for _ in range(10)]) + "."
+ chunks = split_into_chunks(sentence, max_words=12)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(chunk.split()) <= 12 for chunk in chunks))
+ tokens = sentence.replace(",", " , ").split()
+ rejoined = " ".join(chunks).replace(",", " , ").split()
+ self.assertEqual(rejoined, tokens)
+
+ def test_single_oversized_sentence_is_word_split(self):
+ # A punctuation-free sentence longer than the limit is split at word
+ # boundaries so no single request exceeds the configured size.
+ sentence = " ".join(["word"] * 30) + "."
+ chunks = split_into_chunks(sentence, max_words=10)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(chunk.split()) <= 10 for chunk in chunks))
+ self.assertEqual(sum(len(chunk.split()) for chunk in chunks), 30)
+
+ def test_word_split_never_breaks_number_tokens(self):
+ # Numbers and other punctuation-bearing tokens are single words and
+ # must never be broken apart by the last-resort word split.
+ sentence = ("There were exactly 1,000,000 soldiers marching at 12:30 "
+ "and " + "they kept marching onward " * 20) + "endlessly."
+ chunks = split_into_chunks(sentence, max_words=10)
+ self.assertGreater(len(chunks), 1)
+ joined = " ".join(chunks)
+ self.assertIn("1,000,000", joined)
+ self.assertIn("12:30", joined)
+ self.assertNotIn("1, 000", joined)
+ self.assertNotIn("12: 30", joined)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_cleaning.py b/app/tests/test_cleaning.py
new file mode 100644
index 0000000..41f4ed7
--- /dev/null
+++ b/app/tests/test_cleaning.py
@@ -0,0 +1,54 @@
+"""Tests for text and HTML cleaning."""
+
+import unittest
+
+from converter.extractors import clean_html, clean_text
+
+
+class CleanTextTests(unittest.TestCase):
+ def test_empty_input(self):
+ self.assertEqual(clean_text(""), "")
+ self.assertEqual(clean_text(None), "")
+
+ def test_collapses_whitespace(self):
+ self.assertEqual(clean_text("a\n\n b \t c"), "a b c")
+
+ def test_preserves_inline_numbers(self):
+ self.assertEqual(clean_text("He was 42 years old."), "He was 42 years old.")
+
+ def test_preserves_grouped_and_decimal_numbers(self):
+ self.assertEqual(
+ clean_text("Over 1,000 pages and 3.5 stars."),
+ "Over 1,000 pages and 3.5 stars.",
+ )
+
+ def test_removes_standalone_page_numbers(self):
+ self.assertEqual(
+ clean_text("End of page.\n7\nNext page text."),
+ "End of page. Next page text.",
+ )
+
+ def test_page_number_removal_leaves_single_spacing(self):
+ result = clean_text("Chapter one\n\n12\n\nChapter two")
+ self.assertEqual(result, "Chapter one Chapter two")
+ self.assertNotIn(" ", result)
+
+
+class CleanHtmlTests(unittest.TestCase):
+ def test_strips_tags(self):
+ self.assertEqual(clean_html("<p>Hello <b>world</b></p>"), "Hello world")
+
+ def test_removes_script_and_style(self):
+ html = "<style>.x{color:red}</style><p>Text</p><script>var a=1;</script>"
+ self.assertEqual(clean_html(html), "Text")
+
+ def test_unescapes_entities(self):
+ self.assertEqual(clean_html("Tom &amp; Jerry"), "Tom & Jerry")
+
+ def test_empty(self):
+ self.assertEqual(clean_html(""), "")
+ self.assertEqual(clean_html(None), "")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py
new file mode 100644
index 0000000..2fe0f5d
--- /dev/null
+++ b/app/tests/test_converter.py
@@ -0,0 +1,619 @@
+"""Tests for the audiobook converter orchestration helpers."""
+
+import io
+import logging
+import tempfile
+import time
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from converter import config, tts
+from converter import converter as converter_mod
+from converter.converter import (
+ AudiobookConverter,
+ find_existing_outputs,
+ prompt_overwrite,
+ setup_logging,
+)
+
+
+class SanitizeFilenameTests(unittest.TestCase):
+ def test_removes_invalid_characters(self):
+ self.assertEqual(AudiobookConverter._sanitize_filename('A "bad" name: here'),
+ "A bad name here")
+
+ def test_collapses_whitespace(self):
+ self.assertEqual(AudiobookConverter._sanitize_filename(" spaced\tout "), "spaced out")
+
+ def test_empty_falls_back(self):
+ self.assertEqual(AudiobookConverter._sanitize_filename("///"), "chapter")
+
+
+class ConfigurationValidationTests(unittest.TestCase):
+ def test_invalid_voice_mode_rejected(self):
+ with self.assertRaises(ValueError):
+ AudiobookConverter(voice_mode="custon_voice")
+
+ def test_nonpositive_speed_rejected(self):
+ with self.assertRaises(ValueError):
+ AudiobookConverter(speed=0)
+
+ def test_unknown_format_rejected(self):
+ with self.assertRaises(ValueError):
+ AudiobookConverter(output_format="wma")
+
+ def test_unknown_language_rejected(self):
+ with self.assertRaises(ValueError):
+ AudiobookConverter(language="klingon")
+
+ def test_unknown_backend_rejected(self):
+ with self.assertRaises(ValueError) as ctx:
+ AudiobookConverter(backend="piper")
+ self.assertIn("piper", str(ctx.exception))
+ self.assertIn("audiocpp", str(ctx.exception))
+
+ def test_language_defaults_to_config(self):
+ with patch("converter.converter.QwenTTSClient") as mock_tts:
+ AudiobookConverter(backend=tts.BACKEND_QWEN)
+ self.assertEqual(mock_tts.call_args.kwargs["language"], config.LANGUAGE)
+
+ def test_output_format_defaults_to_config(self):
+ with patch("converter.converter.QwenTTSClient"):
+ converter = AudiobookConverter(backend=tts.BACKEND_QWEN)
+ self.assertEqual(converter.output_format, config.AUDIO_FORMAT)
+
+ def test_language_normalized_before_tts_client(self):
+ with patch("converter.converter.QwenTTSClient") as mock_tts:
+ converter = AudiobookConverter(language="ja", backend=tts.BACKEND_QWEN)
+ self.assertEqual(converter.language, "Japanese")
+ self.assertEqual(mock_tts.call_args.kwargs["language"], "Japanese")
+
+
+class FindExistingOutputsTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.folder = Path(self._tmp.name)
+ self._original = converter_mod.AUDIOBOOKS_FOLDER
+ converter_mod.AUDIOBOOKS_FOLDER = self.folder
+
+ def tearDown(self):
+ converter_mod.AUDIOBOOKS_FOLDER = self._original
+ self._tmp.cleanup()
+
+ def _touch(self, name):
+ path = self.folder / name
+ path.write_bytes(b"x")
+ return path
+
+ def test_no_existing_output(self):
+ self.assertEqual(find_existing_outputs("dune", "mp3"), [])
+
+ def test_primary_output_detected(self):
+ self._touch("dune.mp3")
+ self.assertEqual([p.name for p in find_existing_outputs("dune", "mp3")],
+ ["dune.mp3"])
+
+ def test_chapter_and_speed_copies_detected(self):
+ for name in ("dune_01_Dune.mp3", "dune_02_Barony.mp3", "dune_1.5x.mp3"):
+ self._touch(name)
+ self._touch("dune2_01.mp3") # different book stem; must not match
+ found = [p.name for p in find_existing_outputs("dune", "mp3")]
+ self.assertEqual(len(found), 3)
+
+ def test_other_extensions_ignored(self):
+ self._touch("dune.mp3")
+ self.assertEqual(find_existing_outputs("dune", "m4b"), [])
+
+ def test_glob_metacharacters_in_stem(self):
+ self._touch("book [1].mp3")
+ self._touch("book [1]_1.5x.mp3")
+ found = [p.name for p in find_existing_outputs("book [1]", "mp3")]
+ self.assertEqual(sorted(found), ["book [1].mp3", "book [1]_1.5x.mp3"])
+
+ def test_narrator_named_outputs_detected(self):
+ for name in ("dune_Vivian.mp3", "dune_Vivian_1.5.mp3", "dune_Vivian_01_Dune.mp3"):
+ self._touch(name)
+ found = [p.name for p in find_existing_outputs("dune_Vivian", "mp3")]
+ self.assertEqual(len(found), 3)
+
+ def test_legacy_outputs_without_narrator_ignored(self):
+ self._touch("dune.mp3")
+ self._touch("dune_1.5.mp3")
+ self.assertEqual(find_existing_outputs("dune_Vivian", "mp3"), [])
+
+
+class NarratorTagTests(unittest.TestCase):
+ def _converter(self, voice_mode, ref_audio=None, instructions=None):
+ converter = AudiobookConverter.__new__(AudiobookConverter)
+ converter.voice_mode = voice_mode
+ converter.voice_clone_ref_audio = ref_audio
+ converter.backend = tts.BACKEND_QWEN
+ converter.voice = None
+ converter.instructions = instructions
+ return converter
+
+ def test_custom_voice_uses_speaker_display_name(self):
+ self.assertEqual(self._converter(tts.VOICE_MODE_CUSTOM)._narrator_tag(),
+ "Vivian")
+
+ def test_multi_word_display_name_gets_underscores(self):
+ with patch.object(config, "SPEAKER", "uncle_fu"):
+ self.assertEqual(self._converter(tts.VOICE_MODE_CUSTOM)._narrator_tag(),
+ "Uncle_Fu")
+
+ def test_clone_uses_reference_audio_stem(self):
+ self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/ref.wav")._narrator_tag(),
+ "ref")
+
+ def test_clone_stem_spaces_become_underscores(self):
+ self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/my voice.wav")._narrator_tag(),
+ "my_voice")
+
+ def test_invalid_characters_sanitized(self):
+ self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/bad:name?.wav")._narrator_tag(),
+ "bad_name")
+
+ def test_empty_after_sanitize_falls_back(self):
+ self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/???.wav")._narrator_tag(),
+ "narrator")
+
+ def _audiocpp_converter(self, voice=None, instructions=None):
+ converter = self._converter(tts.VOICE_MODE_CUSTOM,
+ instructions=instructions)
+ converter.backend = tts.BACKEND_AUDIOCPP
+ converter.voice = voice
+ return converter
+
+ def test_audiocpp_design_run_uses_designed_tag(self):
+ # An instruction without a voice (voice design, or instruction-
+ # defined voices) must not be named after the built-in speaker.
+ converter = self._audiocpp_converter(instructions="A warm narrator")
+ self.assertEqual(converter._narrator_tag(), "designed")
+
+ def test_audiocpp_instruction_with_voice_keeps_voice_tag(self):
+ converter = self._audiocpp_converter(
+ voice="narrator", instructions="Calm delivery")
+ self.assertEqual(converter._narrator_tag(), "narrator")
+
+ def test_audiocpp_speaker_mode_keeps_speaker_tag(self):
+ converter = self._audiocpp_converter()
+ self.assertEqual(converter._narrator_tag(), "Vivian")
+
+ def test_preflight_design_run_uses_designed_tag(self):
+ with tempfile.TemporaryDirectory() as books_tmp, \
+ tempfile.TemporaryDirectory() as output_tmp:
+ original = (converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER)
+ converter_mod.BOOKS_FOLDER = Path(books_tmp)
+ converter_mod.AUDIOBOOKS_FOLDER = Path(output_tmp)
+ try:
+ (converter_mod.BOOKS_FOLDER / "book.txt").write_text(
+ "hello world", encoding="utf-8")
+ with patch("builtins.input",
+ side_effect=AssertionError("should not prompt")):
+ _, planned = AudiobookConverter.preflight_overwrites(
+ tts.BACKEND_AUDIOCPP, None, tts.VOICE_MODE_CUSTOM,
+ None, "mp3", instructions="A warm narrator")
+ self.assertEqual(planned, [(converter_mod.BOOKS_FOLDER / "book.txt",
+ "book_designed")])
+ finally:
+ converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = original
+
+
+class ChapterDebugDirTests(unittest.TestCase):
+ """Per-chapter debug subfolder naming (chunk numbering restarts per chapter)."""
+
+ def test_none_when_not_debugging(self):
+ self.assertIsNone(AudiobookConverter._chapter_debug_dir(None, 3, "The Trial"))
+
+ def test_chapter_subfolder_named_by_index_and_title(self):
+ book_dir = Path("debug") / "dune_Vivian"
+ chapter_dir = AudiobookConverter._chapter_debug_dir(book_dir, 3, "The Trial")
+ self.assertEqual(chapter_dir, book_dir / "03_The Trial")
+
+ def test_untitled_chapter_uses_fallback(self):
+ chapter_dir = AudiobookConverter._chapter_debug_dir(Path("d"), 1, "")
+ self.assertEqual(chapter_dir, Path("d") / "01_chapter")
+
+
+class DebugDumpTests(unittest.TestCase):
+ """--debug: per-chunk text/audio dumps and request/response logging."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._debug_folder = patch.object(converter_mod, "DEBUG_FOLDER", Path(self._tmp.name))
+ self._debug_folder.start()
+ self.debug_root = Path(self._tmp.name)
+ self.converter = AudiobookConverter.__new__(AudiobookConverter)
+ self.converter.client_chunks = True
+ self.converter.tts = MagicMock()
+
+ def tearDown(self):
+ self._debug_folder.stop()
+ self._tmp.cleanup()
+
+ def _chunk_source(self, name, body=b"audio"):
+ path = self.debug_root / "sources" / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(body)
+ return path
+
+ def test_successful_chunk_dumps_text_and_audio(self):
+ audio = self._chunk_source("chunk_0001.wav")
+ self.converter.tts.process_chunk_with_retry.return_value = audio
+ results = self.converter._synthesize_chunks(["Hello world."],
+ debug_dir=self.debug_root / "book")
+ self.assertEqual(results, {1: audio})
+ debug_dir = self.debug_root / "book"
+ self.assertEqual((debug_dir / "chunk_0001.txt").read_text(encoding="utf-8"),
+ "Hello world.")
+ self.assertEqual((debug_dir / "chunk_0001.wav").read_bytes(), b"audio")
+
+ def test_failed_chunk_dumps_text_but_no_audio(self):
+ self.converter.tts.process_chunk_with_retry.return_value = None
+ results = self.converter._synthesize_chunks(["Hello again."],
+ debug_dir=self.debug_root / "book")
+ self.assertEqual(results, {1: None})
+ debug_dir = self.debug_root / "book"
+ self.assertEqual([path.name for path in sorted(debug_dir.iterdir())],
+ ["chunk_0001.txt"])
+
+ def test_text_dumped_even_when_request_raises(self):
+ self.converter.tts.process_chunk_with_retry.side_effect = RuntimeError("boom")
+ results = self.converter._synthesize_chunks(["Crash text."],
+ debug_dir=self.debug_root / "book")
+ self.assertEqual(results, {1: None})
+ self.assertEqual((self.debug_root / "book" / "chunk_0001.txt").read_text(
+ encoding="utf-8"), "Crash text.")
+
+ def test_audio_suffix_preserved_and_nested_dirs_created(self):
+ audio = self._chunk_source("generated.mp3")
+ self.converter.tts.process_chunk_with_retry.return_value = audio
+ self.converter._synthesize_chunks(["Hello."],
+ debug_dir=self.debug_root / "nested" / "book")
+ self.assertTrue((self.debug_root / "nested" / "book" / "chunk_0001.mp3").exists())
+
+ def test_no_debug_dir_writes_nothing(self):
+ audio = self._chunk_source("chunk_0001.wav")
+ self.converter.tts.process_chunk_with_retry.return_value = audio
+ results = self.converter._synthesize_chunks(["Hello world."])
+ self.assertEqual(results, {1: audio})
+ self.assertEqual([path.name for path in self.debug_root.iterdir()], ["sources"])
+
+ def test_request_and_response_are_logged(self):
+ audio = self._chunk_source("chunk_0001.wav")
+ self.converter.tts.process_chunk_with_retry.return_value = audio
+ with self.assertLogs("converter.converter", level="DEBUG") as logs:
+ self.converter._synthesize_chunks(["Hello world."],
+ debug_dir=self.debug_root / "book")
+ joined = "\n".join(logs.output)
+ self.assertIn("Chunk 1/1 request text: Hello world.", joined)
+ self.assertIn("Chunk 1/1 response in", joined)
+ self.assertIn("chunk_0001.wav", joined)
+
+ def test_debug_write_failure_does_not_abort_conversion(self):
+ blocker = self.debug_root / "blocker"
+ blocker.write_bytes(b"")
+ audio = self._chunk_source("chunk_0001.wav")
+ self.converter.tts.process_chunk_with_retry.return_value = audio
+ results = self.converter._synthesize_chunks(["Hello."], debug_dir=blocker / "book")
+ self.assertEqual(results, {1: audio})
+
+ def test_failed_chunk_stops_remaining_chunks(self):
+ audio = self._chunk_source("chunk_0001.wav")
+ self.converter.tts.process_chunk_with_retry.side_effect = [audio, None, audio]
+ results = self.converter._synthesize_chunks(["One.", "Two.", "Three."])
+ self.assertEqual(results, {1: audio, 2: None})
+ self.assertEqual(self.converter.tts.process_chunk_with_retry.call_count, 2)
+
+ def test_raising_chunk_stops_remaining_chunks(self):
+ audio = self._chunk_source("chunk_0001.wav")
+ self.converter.tts.process_chunk_with_retry.side_effect = [audio, RuntimeError("boom")]
+ results = self.converter._synthesize_chunks(["One.", "Two.", "Three."])
+ self.assertEqual(results, {1: audio, 2: None})
+ self.assertEqual(self.converter.tts.process_chunk_with_retry.call_count, 2)
+
+ def test_debug_flag_wiring(self):
+ with patch("converter.converter.QwenTTSClient"):
+ self.assertFalse(AudiobookConverter(backend=tts.BACKEND_QWEN).debug)
+ self.assertTrue(AudiobookConverter(debug=True, backend=tts.BACKEND_QWEN).debug)
+
+
+class SetupLoggingTests(unittest.TestCase):
+ """Console handler stays quiet; the log file keeps the full record."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._logs_folder = patch.object(converter_mod, "LOGS_FOLDER", Path(self._tmp.name))
+ self._logs_folder.start()
+ self._root = logging.getLogger()
+ self._saved_handlers = self._root.handlers[:]
+ self._saved_level = self._root.level
+ self._saved_converter_level = logging.getLogger("converter").level
+ self._root.handlers.clear()
+
+ def tearDown(self):
+ for handler in self._root.handlers:
+ if handler not in self._saved_handlers:
+ handler.close()
+ self._root.handlers[:] = self._saved_handlers
+ self._root.setLevel(self._saved_level)
+ logging.getLogger("converter").setLevel(self._saved_converter_level)
+ self._logs_folder.stop()
+ self._tmp.cleanup()
+
+ def _console_handler(self):
+ matches = [h for h in logging.getLogger().handlers
+ if isinstance(h, logging.StreamHandler)
+ and not isinstance(h, logging.FileHandler)]
+ self.assertEqual(len(matches), 1)
+ return matches[0]
+
+ def _file_handler(self):
+ matches = [h for h in logging.getLogger().handlers
+ if isinstance(h, logging.FileHandler)]
+ self.assertEqual(len(matches), 1)
+ return matches[0]
+
+ def test_console_quiet_and_file_verbose_by_default(self):
+ setup_logging()
+ self.assertEqual(self._console_handler().level, logging.WARNING)
+ self.assertEqual(self._file_handler().level, logging.INFO)
+
+ def test_debug_flag_lowers_both_handlers(self):
+ setup_logging(debug=True)
+ self.assertEqual(self._console_handler().level, logging.DEBUG)
+ self.assertEqual(self._file_handler().level, logging.DEBUG)
+
+ def test_http_logs_filtered_from_console_only(self):
+ setup_logging(debug=True)
+ console = self._console_handler()
+ http_record = logging.LogRecord("httpx", logging.INFO, "httpx", 1,
+ "HTTP Request: GET ...", None, None)
+ self.assertFalse(console.filter(http_record))
+ chunk_record = logging.LogRecord("converter.converter", logging.DEBUG,
+ "converter", 1,
+ "Chunk 1/1 request text", None, None)
+ self.assertTrue(console.filter(chunk_record))
+
+
+class SynthesizeChunkLoggingTests(unittest.TestCase):
+ """Chunk failures surface as a single ERROR record (no print echo)."""
+
+ def setUp(self):
+ self.converter = AudiobookConverter.__new__(AudiobookConverter)
+ self.converter.client_chunks = True
+ self.converter.tts = MagicMock()
+
+ def test_failed_chunk_logs_single_error(self):
+ self.converter.tts.process_chunk_with_retry.return_value = None
+ with self.assertLogs("converter.converter", level="ERROR") as logs:
+ results = self.converter._synthesize_chunks(["Hello."])
+ self.assertEqual(results, {1: None})
+ self.assertEqual(len(logs.output), 1)
+ self.assertIn("Chunk 1/1 failed", logs.output[0])
+
+ def test_raising_chunk_logs_single_error(self):
+ self.converter.tts.process_chunk_with_retry.side_effect = RuntimeError("boom")
+ with self.assertLogs("converter.converter", level="ERROR") as logs:
+ results = self.converter._synthesize_chunks(["Hello."])
+ self.assertEqual(results, {1: None})
+ self.assertEqual(len(logs.output), 1)
+ self.assertIn("Chunk 1/1 error: boom", logs.output[0])
+
+
+class ServerSideChunkingOutputTests(unittest.TestCase):
+ """With client-side chunking off (audiocpp default), the console skips
+ the chunk vocabulary because the whole request is one server call."""
+
+ def _converter(self, client_chunks: bool):
+ converter = AudiobookConverter.__new__(AudiobookConverter)
+ converter.client_chunks = client_chunks
+ converter.backend = tts.BACKEND_AUDIOCPP
+ converter.speed = 1.0
+ converter.output_format = "mp3"
+ converter.tts = MagicMock()
+ converter.tts.process_chunk_with_retry.return_value = "chunk.wav"
+ return converter
+
+ def test_client_chunking_prints_chunk_progress(self):
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ self._converter(client_chunks=True)._synthesize_chunks(["Hello."])
+ out = buf.getvalue()
+ self.assertIn("PROCESSING 1 CHUNKS", out)
+ self.assertIn("Chunk 1/1 completed", out)
+ self.assertIn("Successful: 1/1", out)
+
+ def test_server_side_chunking_suppresses_chunk_output(self):
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ self._converter(client_chunks=False)._synthesize_chunks(["Hello."])
+ self.assertEqual(buf.getvalue(), "")
+
+ def test_server_side_chunking_suppresses_chapter_chunk_suffix(self):
+ buf = io.StringIO()
+ with patch.object(converter_mod.audio, "combine_chunks", return_value=True), \
+ redirect_stdout(buf):
+ ok = self._converter(client_chunks=False)._convert_text(
+ "Hello world.", Path("out.mp3"), time.time(), chapter=(2, 5))
+ self.assertTrue(ok)
+ out = buf.getvalue()
+ self.assertIn("Chapter 2/5 converted", out)
+ self.assertNotIn("chunk", out.lower())
+
+ def test_single_request_run_notes_long_wait(self):
+ buf = io.StringIO()
+ with patch.object(converter_mod.audio, "combine_chunks", return_value=True), \
+ redirect_stdout(buf):
+ ok = self._converter(client_chunks=False)._convert_text(
+ "Hello world.", Path("out.mp3"), time.time(), chapter=(2, 5))
+ self.assertTrue(ok)
+ out = buf.getvalue()
+ self.assertIn("Sending the chapter 2/5 to the audio.cpp server as a "
+ "single request", out)
+ self.assertIn("expected for this to take a very long time", out)
+
+ def test_client_chunking_run_keeps_chunk_phrasing(self):
+ buf = io.StringIO()
+ with patch.object(converter_mod.audio, "combine_chunks", return_value=True), \
+ redirect_stdout(buf):
+ ok = self._converter(client_chunks=True)._convert_text(
+ "Hello world.", Path("out.mp3"), time.time(), chapter=(2, 5))
+ self.assertTrue(ok)
+ out = buf.getvalue()
+ self.assertIn("Processing 1 chunks via audio.cpp server", out)
+ self.assertNotIn("single request", out)
+ self.assertIn("Chapter 2/5 converted (1/1 chunks)", out)
+
+ def test_partial_chunks_abort_without_assembling(self):
+ converter = self._converter(client_chunks=True)
+ converter.tts.process_chunk_with_retry.side_effect = ["chunk_0001.wav", None]
+ text = " ".join(f"word{i}" for i in range(8))
+ with patch.object(config, "CHUNK_SIZE", 5), \
+ patch.object(converter_mod.audio, "combine_chunks") as mock_combine:
+ ok = converter._convert_text(text, Path("out.mp3"), time.time())
+ self.assertFalse(ok)
+ mock_combine.assert_not_called()
+
+
+class PromptOverwriteTests(unittest.TestCase):
+ def test_single_file_yes(self):
+ with patch("builtins.input", return_value="y"):
+ self.assertTrue(prompt_overwrite([Path("dune.mp3")], "dune"))
+
+ def test_single_file_no(self):
+ with patch("builtins.input", return_value="n"):
+ self.assertFalse(prompt_overwrite([Path("dune.mp3")], "dune"))
+
+ def test_accepts_full_words(self):
+ with patch("builtins.input", return_value="yes"):
+ self.assertTrue(prompt_overwrite([Path("dune.mp3")], "dune"))
+ with patch("builtins.input", return_value="No"):
+ self.assertFalse(prompt_overwrite([Path("dune.mp3")], "dune"))
+
+ def test_invalid_answer_reasked(self):
+ with patch("builtins.input", side_effect=["maybe", "n"]) as mock_input:
+ self.assertFalse(prompt_overwrite([Path("dune.mp3")], "dune"))
+ self.assertEqual(mock_input.call_count, 2)
+
+ def test_empty_answer_defaults_yes(self):
+ # Pressing Enter (empty input) accepts the default of yes, matching
+ # the make_audiocpp_server_json tool's ask_bool(default=True) prompt.
+ with patch("builtins.input", return_value=""):
+ self.assertTrue(prompt_overwrite([Path("dune.mp3")], "dune"))
+
+ def test_eof_keeps_existing_output(self):
+ with patch("builtins.input", side_effect=EOFError):
+ self.assertFalse(prompt_overwrite([Path("dune.mp3")], "dune"))
+
+ def test_multiple_files_prompt_names_them(self):
+ files = [Path("dune_01_Dune.mp3"), Path("dune_02_Barony.mp3")]
+ with patch("builtins.input", return_value="y") as mock_input:
+ self.assertTrue(prompt_overwrite(files, "dune"))
+ prompt_text = mock_input.call_args[0][0]
+ self.assertIn("2 output files for 'dune'", prompt_text)
+ self.assertIn("dune_01_Dune.mp3", prompt_text)
+ self.assertIn("overwrite them", prompt_text)
+
+
+class PreflightOverwritesTests(unittest.TestCase):
+ """The pre-flight overwrite check runs without a TTS server connection."""
+
+ def setUp(self):
+ self._books_tmp = tempfile.TemporaryDirectory()
+ self._output_tmp = tempfile.TemporaryDirectory()
+ self._original_folders = (converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER)
+ converter_mod.BOOKS_FOLDER = Path(self._books_tmp.name)
+ converter_mod.AUDIOBOOKS_FOLDER = Path(self._output_tmp.name)
+ (converter_mod.BOOKS_FOLDER / "book.txt").write_text("hello world", encoding="utf-8")
+
+ def tearDown(self):
+ converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = self._original_folders
+ self._books_tmp.cleanup()
+ self._output_tmp.cleanup()
+
+ def test_no_books_returns_empty(self):
+ (converter_mod.BOOKS_FOLDER / "book.txt").unlink()
+ with patch("builtins.input", side_effect=AssertionError("should not prompt")):
+ book_files, planned = AudiobookConverter.preflight_overwrites(
+ tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
+ self.assertEqual(book_files, [])
+ self.assertEqual(planned, [])
+
+ def test_new_book_planned_without_prompt(self):
+ with patch("builtins.input", side_effect=AssertionError("should not prompt")):
+ book_files, planned = AudiobookConverter.preflight_overwrites(
+ tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
+ self.assertEqual(len(book_files), 1)
+ self.assertEqual(planned, [(book_files[0], "book_Vivian")])
+
+ def test_existing_output_enter_defaults_yes(self):
+ (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
+ with patch("builtins.input", return_value=""):
+ book_files, planned = AudiobookConverter.preflight_overwrites(
+ tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
+ self.assertEqual(planned, [(book_files[0], "book_Vivian")])
+
+ def test_existing_output_declined_is_skipped(self):
+ (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
+ with patch("builtins.input", return_value="n"):
+ book_files, planned = AudiobookConverter.preflight_overwrites(
+ tts.BACKEND_QWEN, None, tts.VOICE_MODE_CUSTOM, None, "mp3")
+ self.assertEqual(len(book_files), 1)
+ self.assertEqual(planned, [])
+
+
+class RunOverwritePromptTests(unittest.TestCase):
+ """The full run() flow: prompts collected before any conversion starts."""
+
+ def setUp(self):
+ self._books_tmp = tempfile.TemporaryDirectory()
+ self._output_tmp = tempfile.TemporaryDirectory()
+ self._original_folders = (converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER)
+ converter_mod.BOOKS_FOLDER = Path(self._books_tmp.name)
+ converter_mod.AUDIOBOOKS_FOLDER = Path(self._output_tmp.name)
+ (converter_mod.BOOKS_FOLDER / "book.txt").write_text("hello world", encoding="utf-8")
+ self.converter = AudiobookConverter.__new__(AudiobookConverter)
+ self.converter.voice_mode = tts.VOICE_MODE_CUSTOM
+ self.converter.voice_clone_ref_audio = None
+ self.converter.backend = tts.BACKEND_QWEN
+ self.converter.voice = None
+ self.converter.instructions = None
+ self.converter.speed = 1.0
+ self.converter.single_file = False
+ self.converter.output_format = "mp3"
+ self.converter.language = "English"
+ self.converter.debug = False
+ self.converted = []
+ self.converter.convert_book = (
+ lambda file_path, output_name=None:
+ not self.converted.append((file_path.name, output_name)) or True)
+
+ def tearDown(self):
+ converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = self._original_folders
+ self._books_tmp.cleanup()
+ self._output_tmp.cleanup()
+
+ def test_declined_book_is_skipped(self):
+ (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
+ with patch("builtins.input", return_value="n"):
+ self.assertTrue(self.converter.run())
+ self.assertEqual(self.converted, [])
+ self.assertTrue((converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").exists())
+
+ def test_accepted_book_is_converted(self):
+ (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing")
+ with patch("builtins.input", return_value="y"):
+ self.assertTrue(self.converter.run())
+ self.assertEqual(self.converted, [("book.txt", "book_Vivian")])
+
+ def test_new_book_converted_without_prompt(self):
+ with patch("builtins.input", side_effect=AssertionError("should not prompt")):
+ self.assertTrue(self.converter.run())
+ self.assertEqual(self.converted, [("book.txt", "book_Vivian")])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_cover.py b/app/tests/test_cover.py
new file mode 100644
index 0000000..f19db5a
--- /dev/null
+++ b/app/tests/test_cover.py
@@ -0,0 +1,189 @@
+"""Tests for stdlib-only cover generation: PNG structure, gradient, text."""
+
+import random
+import struct
+import tempfile
+import unittest
+import zlib
+from pathlib import Path
+
+from converter.cover import (
+ _random_light_color,
+ _text_width,
+ _wrap_title,
+ generate_cover,
+)
+
+
+def _decode_png(data: bytes):
+ """Parse a PNG into (width, height, rows of RGB tuples)."""
+ assert data[:8] == b"\x89PNG\r\n\x1a\n", "bad PNG signature"
+ pos = 8
+ idat = b""
+ width = height = None
+ while pos < len(data):
+ length, chunk_type = struct.unpack(">I4s", data[pos:pos + 8])
+ chunk_data = data[pos + 8:pos + 8 + length]
+ crc = struct.unpack(">I", data[pos + 8 + length:pos + 12 + length])[0]
+ assert crc == zlib.crc32(chunk_type + chunk_data) & 0xFFFFFFFF, "bad CRC"
+ if chunk_type == b"IHDR":
+ width, height, depth, color_type = struct.unpack(">IIBB", chunk_data[:10])
+ assert depth == 8 and color_type == 2 # 8-bit RGB
+ elif chunk_type == b"IDAT":
+ idat += chunk_data
+ pos += 12 + length
+ raw = zlib.decompress(idat)
+ stride = 1 + width * 3
+ assert len(raw) == height * stride, "unexpected decompressed size"
+ rows = []
+ for y in range(height):
+ row = raw[y * stride + 1:(y + 1) * stride]
+ rows.append([tuple(row[x * 3:x * 3 + 3]) for x in range(width)])
+ return width, height, rows
+
+
+def _black_pixels(rows):
+ return sum(1 for row in rows for pixel in row if pixel == (0, 0, 0))
+
+
+class GenerateCoverTests(unittest.TestCase):
+ def _write(self, title, width=120, height=180, seed=7):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "cover.png"
+ result = generate_cover(title, path, width=width, height=height, seed=seed)
+ data = path.read_bytes()
+ return result, data
+
+ def test_valid_png_with_requested_dimensions(self):
+ _, data = self._write("Hello")
+ width, height, rows = _decode_png(data)
+ self.assertEqual((width, height), (120, 180))
+ self.assertEqual(len(rows), 180)
+
+ def test_gradient_matches_seeded_colors(self):
+ _, data = self._write("Hello", seed=42)
+ width, height, rows = _decode_png(data)
+ rng = random.Random(42)
+ top = _random_light_color(rng)
+ bottom = _random_light_color(rng)
+ # Corners of the text-free top/bottom rows match the endpoints
+ self.assertEqual(rows[0][0], top)
+ self.assertEqual(rows[0][-1], top)
+ self.assertEqual(rows[height - 1][0], bottom)
+ self.assertEqual(rows[height - 1][-1], bottom)
+
+ def test_gradient_colors_are_light(self):
+ # Text-free bottom row: every channel must stay in pastel territory
+ _, data = self._write("Hello", seed=1)
+ _, height, rows = _decode_png(data)
+ for channel in rows[height - 1][0]:
+ self.assertGreaterEqual(channel, 90)
+
+ def test_title_renders_black_pixels(self):
+ _, data = self._write("Hello")
+ _, _, rows = _decode_png(data)
+ self.assertGreater(_black_pixels(rows), 50)
+
+ def test_title_renders_white_pixels(self):
+ _, data = self._write("Hello")
+ _, _, rows = _decode_png(data)
+ white = sum(1 for row in rows for pixel in row if pixel == (255, 255, 255))
+ self.assertGreater(white, 50)
+
+ def test_white_text_sits_on_black_stroke(self):
+ # Directly above a white pixel row there must be a black stroke row:
+ # sample white pixels and confirm black neighbors within stroke width.
+ _, data = self._write("Hi", width=200, height=100, seed=3)
+ _, _, rows = _decode_png(data)
+ whites = [(x, y) for y, row in enumerate(rows)
+ for x, pixel in enumerate(row) if pixel == (255, 255, 255)]
+ self.assertTrue(whites)
+ checked = near_stroke = 0
+ for x, y in whites[::5]:
+ neighborhood = []
+ for dy in range(-3, 4):
+ for dx in range(-3, 4):
+ if 0 <= y + dy < len(rows) and 0 <= x + dx < len(rows[0]):
+ neighborhood.append(rows[y + dy][x + dx])
+ checked += 1
+ if (0, 0, 0) in neighborhood:
+ near_stroke += 1
+ # Interior white pixels are surrounded by white; every sampled pixel
+ # should still see stroke black within 3px (font strokes are 5-6 px thick)
+ self.assertEqual(near_stroke, checked)
+
+ def test_empty_title_renders_gradient_only(self):
+ _, data = self._write("")
+ _, _, rows = _decode_png(data)
+ self.assertEqual(_black_pixels(rows), 0)
+
+ def test_unrenderable_title_degrades_to_gradient(self):
+ # CJK glyphs are not in the bitmap font; no crash, no text pixels
+ _, data = self._write("书名")
+ _, _, rows = _decode_png(data)
+ self.assertEqual(_black_pixels(rows), 0)
+
+ def test_write_failure_returns_none(self):
+ result = generate_cover("Hello", Path("/nonexistent_dir/cover.png"))
+ self.assertIsNone(result)
+
+
+class WrapTitleTests(unittest.TestCase):
+ def test_short_title_one_line(self):
+ self.assertEqual(len(_wrap_title("Dune", 500)), 1)
+
+ def test_long_title_wraps(self):
+ lines = _wrap_title("The Extremely Long Windy Title of a Very Long Book", 600)
+ self.assertGreater(len(lines), 1)
+ for line in lines:
+ self.assertLessEqual(_text_width(line), 600)
+
+ def test_single_long_word_kept_intact(self):
+ lines = _wrap_title("Antidisestablishmentarianism", 10)
+ self.assertEqual(lines, ["Antidisestablishmentarianism"])
+
+ def test_empty_title_no_lines(self):
+ self.assertEqual(_wrap_title("", 500), [])
+
+
+class TextWidthTests(unittest.TestCase):
+ def test_empty(self):
+ self.assertEqual(_text_width(""), 0)
+
+ def test_single_char_is_scaled_glyph(self):
+ self.assertEqual(_text_width("A"), 30) # 5 px * scale 6
+
+ def test_chars_include_spacing(self):
+ self.assertEqual(_text_width("AB"), 66) # (2 glyphs * 6 - 1) * 6
+
+
+class DropShadowTests(unittest.TestCase):
+ def _cover_rows(self, title, seed=7):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "cover.png"
+ generate_cover(title, path, width=200, height=200, seed=seed)
+ return _decode_png(path.read_bytes())[2]
+
+ def test_shadow_pixels_survive_next_to_text(self):
+ rows = self._cover_rows("Hi")
+ # The shadow lives down-right of the glyphs: there must be darkened
+ # (but not pure black, not full-brightness) pixels beyond the text
+ # block's bottom edge.
+ blacks = {(x, y) for y, row in enumerate(rows)
+ for x, pixel in enumerate(row) if pixel == (0, 0, 0)}
+ self.assertTrue(blacks, "no text rendered")
+ text_bottom = max(y for _, y in blacks)
+ darkened = [pixel for y, row in enumerate(rows)
+ if y > text_bottom for pixel in row
+ if pixel != (0, 0, 0) and max(pixel) < 130]
+ self.assertTrue(darkened, "no shadow pixels below the text")
+
+ def test_empty_title_has_no_shadow(self):
+ rows = self._cover_rows("")
+ for row in rows:
+ for pixel in row:
+ self.assertNotEqual(pixel, (0, 0, 0))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_extractors.py b/app/tests/test_extractors.py
new file mode 100644
index 0000000..ae1794c
--- /dev/null
+++ b/app/tests/test_extractors.py
@@ -0,0 +1,213 @@
+"""Tests for file text extraction."""
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from converter.extractors import extract_text
+
+
+class TxtExtractionTests(unittest.TestCase):
+ def _extract(self, data: bytes) -> str:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.txt"
+ path.write_bytes(data)
+ return extract_text(path)
+
+ def test_utf8(self):
+ self.assertEqual(self._extract("héllo wörld".encode("utf-8")), "héllo wörld")
+
+ def test_utf16_with_bom(self):
+ self.assertEqual(self._extract("héllo".encode("utf-16")), "héllo")
+
+ def test_cp1252(self):
+ self.assertEqual(self._extract("“quotes”".encode("cp1252")), "“quotes”")
+
+ def test_latin1_fallback(self):
+ # 0x81 is undefined in cp1252, forcing the latin-1 catch-all
+ self.assertEqual(self._extract(b"caf\x81"), "caf\x81")
+
+ def test_unsupported_format(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.xyz"
+ path.write_bytes(b"data")
+ with self.assertRaises(ValueError):
+ extract_text(path)
+
+
+def _build_test_epub(path: Path, chapters=(("One", "First chapter text."),
+ ("Two", "Second chapter text."))) -> None:
+ from ebooklib import epub
+
+ book = epub.EpubBook()
+ book.set_identifier("test-id")
+ book.set_title("Test Book")
+ book.set_language("en")
+ book.add_author("Test Author")
+
+ items = []
+ for index, (title, text) in enumerate(chapters, 1):
+ chapter = epub.EpubHtml(title=title, file_name=f"chap{index}.xhtml", lang="en")
+ chapter.content = f"<html><body><p>{text}</p></body></html>"
+ book.add_item(chapter)
+ items.append(chapter)
+
+ book.toc = tuple(items)
+ book.spine = ["nav", *items]
+ book.add_item(epub.EpubNcx())
+ book.add_item(epub.EpubNav())
+
+ epub.write_epub(str(path), book)
+
+
+class EpubExtractionTests(unittest.TestCase):
+ def setUp(self):
+ try:
+ import ebooklib # noqa: F401
+ except ImportError:
+ self.skipTest("ebooklib not installed")
+
+ def test_ebooklib_extraction(self):
+ # Regression test: the ebooklib path used to silently return "" due to
+ # isinstance(item, ebooklib.ITEM_DOCUMENT) (an int, not a class).
+ from converter.extractors import _read_epub_ebooklib
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.epub"
+ _build_test_epub(path)
+ items = _read_epub_ebooklib(path)
+
+ html = "\n".join(content for _, content in items)
+ self.assertIn("First chapter text.", html)
+ self.assertIn("Second chapter text.", html)
+
+ def test_epub_extraction_follows_spine_order(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.epub"
+ _build_test_epub(path)
+ text = extract_text(path)
+
+ self.assertIn("First chapter text.", text)
+ self.assertIn("Second chapter text.", text)
+ self.assertLess(text.index("First chapter text."),
+ text.index("Second chapter text."))
+
+
+class ExtractSectionsTests(unittest.TestCase):
+ def setUp(self):
+ try:
+ import ebooklib # noqa: F401
+ except ImportError:
+ self.skipTest("ebooklib not installed")
+
+ def test_epub_sections_split_on_chapters(self):
+ from converter.extractors import extract_sections
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.epub"
+ _build_test_epub(path)
+ sections = extract_sections(path)
+
+ self.assertEqual(len(sections), 2)
+ self.assertEqual(sections[0].title, "One")
+ self.assertEqual(sections[1].title, "Two")
+ self.assertIn("First chapter text.", sections[0].text)
+ self.assertIn("Second chapter text.", sections[1].text)
+
+ def test_txt_is_single_section(self):
+ from converter.extractors import extract_sections
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.txt"
+ path.write_text("Hello world.", encoding="utf-8")
+ sections = extract_sections(path)
+
+ self.assertEqual(len(sections), 1)
+ self.assertEqual(sections[0].title, "book")
+ self.assertEqual(sections[0].text, "Hello world.")
+
+ def test_single_chapter_epub_keeps_chapter_title(self):
+ from converter.extractors import extract_sections
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.epub"
+ _build_test_epub(path, chapters=(("Only", "Just one chapter."),))
+ sections = extract_sections(path)
+
+ self.assertEqual(len(sections), 1)
+ self.assertEqual(sections[0].title, "Only")
+ self.assertIn("Just one chapter.", sections[0].text)
+
+
+class ExtractBookTests(unittest.TestCase):
+ def test_txt_falls_back_to_stem_and_blank_author(self):
+ from converter.extractors import extract_book
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "mybook.txt"
+ path.write_text("Hello world.", encoding="utf-8")
+ book = extract_book(path)
+
+ self.assertEqual(book.title, "mybook")
+ self.assertEqual(book.author, "")
+ self.assertEqual(len(book.sections), 1)
+
+ def test_epub_metadata_harvested(self):
+ try:
+ import ebooklib # noqa: F401
+ except ImportError:
+ self.skipTest("ebooklib not installed")
+ from converter.extractors import extract_book
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.epub"
+ _build_test_epub(path)
+ book = extract_book(path)
+
+ self.assertEqual(book.title, "Test Book")
+ self.assertEqual(book.author, "Test Author")
+ self.assertEqual([s.title for s in book.sections], ["One", "Two"])
+
+ def test_pdf_metadata_harvested(self):
+ from converter.extractors import extract_book
+
+ try:
+ from pypdf import PdfWriter
+ except ImportError:
+ self.skipTest("pypdf not installed")
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.pdf"
+ writer = PdfWriter()
+ writer.add_metadata({"/Title": "PDF Title", "/Author": "PDF Author"})
+ writer.add_blank_page(width=612, height=792)
+ with open(path, "wb") as handle:
+ writer.write(handle)
+ book = extract_book(path)
+
+ self.assertEqual(book.title, "PDF Title")
+ self.assertEqual(book.author, "PDF Author")
+ self.assertEqual(len(book.sections), 1)
+
+ def test_pdf_without_metadata_falls_back(self):
+ from converter.extractors import extract_book
+
+ try:
+ from pypdf import PdfWriter
+ except ImportError:
+ self.skipTest("pypdf not installed")
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "plain.pdf"
+ writer = PdfWriter()
+ writer.add_blank_page(width=612, height=792)
+ with open(path, "wb") as handle:
+ writer.write(handle)
+ book = extract_book(path)
+
+ self.assertEqual(book.title, "plain")
+ self.assertEqual(book.author, "")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
new file mode 100644
index 0000000..b3a17e8
--- /dev/null
+++ b/app/tests/test_hub.py
@@ -0,0 +1,502 @@
+"""Tests for the TUI hub (ui/hub.py) menu and helpers.
+
+The hub drives the same curses widgets as ui/tui.py, so these tests reuse
+the fake curses/screen from test_tui to run the menu without a terminal.
+"""
+
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from backends import BackendStatus, ServerSpec
+from tests.test_tui import FakeCurses, FakeScreen
+from ui import hub, tui
+
+
+class HubHelperTests(unittest.TestCase):
+ """Pure helpers in hub.py (no curses)."""
+
+ def test_is_float(self):
+ self.assertTrue(hub._is_float("1.0"))
+ self.assertTrue(hub._is_float("2"))
+ self.assertFalse(hub._is_float("abc"))
+ self.assertFalse(hub._is_float(""))
+
+ def test_list_voices_from_dir(self):
+ with __import__("tempfile").TemporaryDirectory() as td:
+ d = Path(td)
+ (d / "Narrator.wav").write_bytes(b"x")
+ (d / "Alpha.WAV").write_bytes(b"x")
+ (d / "notes.txt").write_bytes(b"x")
+ voices = hub._list_voices(str(d))
+ # Stems preserve case; sorting is case-insensitive.
+ self.assertEqual(voices, ["Alpha", "Narrator"])
+
+ def test_list_voices_missing_dir(self):
+ self.assertEqual(hub._list_voices("/no/such/dir"), [])
+
+ def test_status_mark(self):
+ from backends import BackendStatus
+ running = BackendStatus("k", "l", installed=True, configured=True,
+ running=True)
+ installed = BackendStatus("k", "l", installed=True,
+ configured=False)
+ none = BackendStatus("k", "l", installed=False, configured=False)
+ # running beats installed (a server is up even if not configured);
+ # only a backend that is neither installed nor running is dimmed.
+ self.assertEqual(hub._status_mark(running),
+ ("running", "ok", "body"))
+ self.assertEqual(hub._status_mark(installed),
+ ("installed", "warn", "body"))
+ self.assertEqual(hub._status_mark(none),
+ ("unavailable", "err", "dim"))
+ self.assertEqual(hub._status_mark(None),
+ ("unavailable", "err", "dim"))
+
+
+class HubMenuTests(unittest.TestCase):
+ """Drive _hub_menu with a fake screen (no terminal)."""
+
+ def setUp(self):
+ tui._THEME.clear()
+ self.curses = FakeCurses()
+ from unittest.mock import patch as _patch
+ self._patcher = _patch.dict("sys.modules", {"curses": self.curses})
+ self._patcher.start()
+ self.addCleanup(self._patcher.stop)
+ self.addCleanup(tui._THEME.clear)
+
+ def _none_status(self, key="k", label="l"):
+ from backends import BackendStatus
+ return BackendStatus(key, label, installed=False, configured=False)
+
+ def test_quit_returns_none_when_no_backend(self):
+ # No backends installed/running: menu is [Set up, Settings, Quit].
+ # Quit is the 3rd option (Down twice) then Enter.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10])
+ with patch.object(hub, "detect_all", return_value=[]):
+ result = hub._hub_menu(screen)
+ self.assertIsNone(result)
+
+ def test_menu_has_only_setup_settings_and_quit_without_backends(self):
+ # Capture the options handed to tui.menu: with nothing installed or
+ # running, Convert/Configure must be absent.
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ return "quit"
+
+ screen = FakeScreen()
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all", return_value=[]):
+ hub._hub_menu(screen)
+ labels = [label for label, _ in captured["options"]]
+ self.assertEqual(labels, ["Set up a backend...", "Settings...",
+ "Quit"])
+
+ def test_menu_has_all_six_when_one_installed(self):
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ captured["rows"] = kwargs.get("table_rows")
+ return "quit"
+
+ screen = FakeScreen()
+ st = self._none_status("qwen", "qwen-tts")
+ st.installed = True
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all", return_value=[st]):
+ hub._hub_menu(screen)
+ labels = [label for label, _ in captured["options"]]
+ self.assertEqual(
+ labels,
+ ["Convert books...", "Set up a backend...",
+ "Configure a backend...", "Server...", "Settings...", "Quit"])
+ # The status table is passed through, one row per backend.
+ self.assertEqual(captured["rows"],
+ [("qwen-tts", "installed", "warn", "body")])
+
+ def test_table_dims_name_when_not_installed_and_not_running(self):
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["rows"] = kwargs.get("table_rows")
+ return "quit"
+
+ screen = FakeScreen()
+ dead = self._none_status("audiocpp", "audio.cpp")
+ external = self._none_status("qwen", "qwen-tts")
+ external.running = True
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all",
+ return_value=[dead, external]):
+ hub._hub_menu(screen)
+ # Unusable backend: dim name. Running-but-not-installed stays bright.
+ self.assertEqual(
+ captured["rows"],
+ [("audio.cpp", "unavailable", "err", "dim"),
+ ("qwen-tts", "running", "ok", "body")])
+
+ def test_menu_has_all_six_when_one_running_only(self):
+ # Running but not installed (an external server) still unlocks the
+ # Convert/Configure/Server entries.
+ captured = {}
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["options"] = options
+ return "quit"
+
+ screen = FakeScreen()
+ st = self._none_status("qwen", "qwen-tts")
+ st.running = True
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub, "detect_all", return_value=[st]):
+ hub._hub_menu(screen)
+ labels = [label for label, _ in captured["options"]]
+ self.assertEqual(
+ labels,
+ ["Convert books...", "Set up a backend...",
+ "Configure a backend...", "Server...", "Settings...", "Quit"])
+
+ def test_convert_with_no_available_backend_offers_setup(self):
+ # One installed-but-not-ready backend → Convert is offered. The
+ # convert menu lists no available backend, so only "Set up a
+ # backend..." is shown; Enter selects it → setup menu lists 3
+ # backends; Esc goes back → convert returns None → main menu loops.
+ # Then quit: main menu now has 5 options, Quit is the 5th (Down x4).
+ from backends import BackendInfo, BackendStatus
+ none = BackendStatus("k", "l", installed=True, configured=False)
+ infos = [BackendInfo("audiocpp", "audio.cpp", lambda: none,
+ lambda: 0),
+ BackendInfo("qwen", "qwen-tts", lambda: none, lambda: 0),
+ BackendInfo("faster", "faster", lambda: none, lambda: 0)]
+ # installed=True so the main menu shows Convert; but ready/running
+ # is False so the convert menu's available list is empty.
+ statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True,
+ configured=False),
+ BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=False),
+ BackendStatus("faster", "faster", installed=True,
+ configured=False)]
+ with patch.object(hub, "detect_all", return_value=statuses), \
+ patch.object(hub, "REGISTRY", infos):
+ # Convert(Enter), setup-entry(Enter), Esc on setup menu,
+ # back at main menu -> Down x5 -> Enter (Quit; Settings sits
+ # just before it).
+ screen = FakeScreen(keys=[10, 10, 27,
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, 10])
+ result = hub._hub_menu(screen)
+ self.assertIsNone(result)
+
+
+class SelectSpecTests(unittest.TestCase):
+ """_select_spec: mode-aware server selection (qwen has two servers)."""
+
+ def _qwen_status(self):
+ return BackendStatus(
+ "qwen", "qwen-tts", installed=True, configured=True,
+ servers=[ServerSpec("qwen-custom", "http://127.0.0.1:7860", []),
+ ServerSpec("qwen-clone", "http://127.0.0.1:7861", [])])
+
+ def test_qwen_custom_mode(self):
+ spec = hub._select_spec(self._qwen_status(), {"clone": None})
+ self.assertEqual(spec.name, "qwen-custom")
+
+ def test_qwen_clone_mode(self):
+ spec = hub._select_spec(self._qwen_status(), {"clone": "ref.wav"})
+ self.assertEqual(spec.name, "qwen-clone")
+
+ def test_audiocpp_returns_single_spec(self):
+ st = BackendStatus("audiocpp", "audio.cpp", installed=True,
+ configured=True,
+ servers=[ServerSpec("audiocpp", "http://x", [])])
+ spec = hub._select_spec(st, {})
+ self.assertEqual(spec.name, "audiocpp")
+
+ def test_none_when_no_servers(self):
+ st = BackendStatus("qwen", "qwen-tts", installed=False,
+ configured=False)
+ self.assertIsNone(hub._select_spec(st, {}))
+
+
+class RunConversionTests(unittest.TestCase):
+ """_run_conversion: autostart, hint-when-manual, and stop-after."""
+
+ def test_autostart_starts_server_then_converts(self):
+ spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
+ status = BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True, running=False,
+ servers=[spec])
+ kwargs = {"autostart": "qwen-custom"}
+ with patch.object(hub, "detect_all", return_value=[status]), \
+ patch.object(hub, "_find_spec", return_value=spec), \
+ patch.object(hub.servers, "start", return_value=True) as mk_start, \
+ patch.object(hub.audiobook, "convert", return_value=0) as mk_conv, \
+ patch("builtins.input", return_value="n") as mk_input, \
+ patch.object(hub.servers, "stop") as mk_stop:
+ hub._run_conversion("qwen", kwargs)
+ mk_start.assert_called_once_with(spec)
+ mk_conv.assert_called_once()
+ # User declined stopping → stop not called.
+ mk_stop.assert_not_called()
+
+ def test_autostart_stop_when_user_says_yes(self):
+ spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
+ status = BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True, running=False,
+ servers=[spec])
+ kwargs = {"autostart": "qwen-custom"}
+ with patch.object(hub, "detect_all", return_value=[status]), \
+ patch.object(hub, "_find_spec", return_value=spec), \
+ patch.object(hub.servers, "start", return_value=True), \
+ patch.object(hub.audiobook, "convert", return_value=0), \
+ patch("builtins.input", return_value="y"), \
+ patch.object(hub.servers, "stop") as mk_stop:
+ hub._run_conversion("qwen", kwargs)
+ mk_stop.assert_called_once_with("qwen-custom")
+
+ def test_autostart_aborts_when_server_fails(self):
+ spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
+ status = BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True, running=False,
+ launch_hint="hint cmd", servers=[spec])
+ kwargs = {"autostart": "qwen-custom"}
+ with patch.object(hub, "detect_all", return_value=[status]), \
+ patch.object(hub, "_find_spec", return_value=spec), \
+ patch.object(hub.servers, "start", return_value=False), \
+ patch.object(hub.audiobook, "convert") as mk_conv, \
+ patch.object(hub.servers, "stop") as mk_stop:
+ hub._run_conversion("qwen", kwargs)
+ mk_conv.assert_not_called()
+ mk_stop.assert_not_called()
+
+ def test_no_autostart_prints_hint_when_not_running(self):
+ status = BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True, running=False,
+ launch_hint="the-hint")
+ with patch.object(hub, "detect_all", return_value=[status]), \
+ patch.object(hub.audiobook, "convert", return_value=0) as mk_conv:
+ hub._run_conversion("qwen", {})
+ mk_conv.assert_called_once()
+
+
+class AddAutostartTests(unittest.TestCase):
+ """_add_autostart: offers to start the server when it isn't running."""
+
+ def setUp(self):
+ tui._THEME.clear()
+ self.curses = FakeCurses()
+ self._patcher = patch.dict("sys.modules", {"curses": self.curses})
+ self._patcher.start()
+ self.addCleanup(self._patcher.stop)
+ self.addCleanup(tui._THEME.clear)
+
+ def _status(self):
+ spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"])
+ return BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True, running=False,
+ servers=[spec])
+
+ def test_sets_autostart_when_user_confirms(self):
+ screen = FakeScreen(keys=[10]) # Enter = Yes
+ cmd = ("convert", "qwen", {"clone": None})
+ with patch.object(hub, "detect_all", return_value=[self._status()]), \
+ patch("backends.common.server_running", return_value=False):
+ hub._add_autostart(screen, cmd, [self._status()])
+ self.assertEqual(cmd[2]["autostart"], "qwen-custom")
+
+ def test_no_autostart_when_server_already_running(self):
+ screen = FakeScreen(keys=[10])
+ cmd = ("convert", "qwen", {"clone": None})
+ with patch.object(hub, "detect_all", return_value=[self._status()]), \
+ patch("backends.common.server_running", return_value=True):
+ hub._add_autostart(screen, cmd, [self._status()])
+ self.assertNotIn("autostart", cmd[2])
+
+
+class SettingsTests(unittest.TestCase):
+ """Settings menu: field collection, validation, config.py writing."""
+
+ def test_write_config_preserves_comments_and_other_lines(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.py"
+ path.write_text(
+ "# Default output options\n"
+ 'AUDIO_FORMAT = "m4b"\n'
+ 'AUDIO_BITRATE = "128k"\n'
+ 'LANGUAGE = "English"\n'
+ "\n"
+ "CHUNK_SIZE = 250 # words per request\n",
+ encoding="utf-8")
+ with patch.object(hub.config, "__file__", str(path)):
+ hub._write_config({"AUDIO_FORMAT": "mp3",
+ "AUDIO_BITRATE": "192k",
+ "LANGUAGE": "Japanese",
+ "CHUNK_SIZE": 300})
+ text = path.read_text(encoding="utf-8")
+ self.assertEqual(
+ text,
+ "# Default output options\n"
+ 'AUDIO_FORMAT = "mp3"\n'
+ 'AUDIO_BITRATE = "192k"\n'
+ 'LANGUAGE = "Japanese"\n'
+ "\n"
+ "CHUNK_SIZE = 300 # words per request\n")
+
+ def test_write_config_missing_key_raises(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.py"
+ path.write_text("X = 1\n", encoding="utf-8")
+ with patch.object(hub.config, "__file__", str(path)):
+ with self.assertRaises(ValueError):
+ hub._write_config({"AUDIO_FORMAT": "mp3"})
+
+ def test_apply_settings_writes_and_reloads_in_memory(self):
+ written = {}
+
+ def fake_write(updates):
+ written.update(updates)
+
+ original = {name: getattr(hub.config, name) for name in
+ ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
+ "CHUNK_SIZE")}
+ self.addCleanup(lambda: [setattr(hub.config, name, value)
+ for name, value in original.items()])
+ values = {"audio_format": "ogg", "audio_bitrate": " 192k ",
+ "language": "en", "chunk_size": "300"}
+ with patch.object(hub, "_write_config", fake_write):
+ hub._apply_settings(values)
+ # Values are trimmed and language normalized to a display name.
+ self.assertEqual(written, {"AUDIO_FORMAT": "ogg",
+ "AUDIO_BITRATE": "192k",
+ "LANGUAGE": "English",
+ "CHUNK_SIZE": 300})
+ # In-memory config is reloaded so this session sees the change.
+ self.assertEqual(hub.config.AUDIO_FORMAT, "ogg")
+ self.assertEqual(hub.config.AUDIO_BITRATE, "192k")
+ self.assertEqual(hub.config.LANGUAGE, "English")
+ self.assertEqual(hub.config.CHUNK_SIZE, 300)
+
+ def test_apply_settings_rejects_bad_values(self):
+ original = {name: getattr(hub.config, name) for name in
+ ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
+ "CHUNK_SIZE")}
+ self.addCleanup(lambda: [setattr(hub.config, name, value)
+ for name, value in original.items()])
+ with patch.object(hub, "_write_config") as mk_write:
+ with self.assertRaises(ValueError):
+ hub._apply_settings({"audio_format": "m4b",
+ "audio_bitrate": "128k",
+ "language": "Klingon",
+ "chunk_size": "250"})
+ with self.assertRaises(ValueError):
+ hub._apply_settings({"audio_format": "m4b",
+ "audio_bitrate": "128k",
+ "language": "English",
+ "chunk_size": "0"})
+ mk_write.assert_not_called()
+
+ def test_field_validators(self):
+ self.assertIsNone(hub._validate_bitrate("128k"))
+ self.assertIsNotNone(hub._validate_bitrate(" "))
+ self.assertIsNone(hub._validate_language("English"))
+ self.assertIsNone(hub._validate_language("en"))
+ self.assertIsNotNone(hub._validate_language("Klingon"))
+ self.assertIsNone(hub._validate_chunk_size("250"))
+ self.assertIsNotNone(hub._validate_chunk_size("abc"))
+ self.assertIsNotNone(hub._validate_chunk_size("0"))
+
+ def test_settings_menu_builds_form_and_saves(self):
+ captured = {}
+
+ def fake_form(stdscr, title, fields, back_value=None):
+ captured["fields"] = fields
+ return {"audio_format": "ogg", "audio_bitrate": "192k",
+ "language": "English", "chunk_size": "300"}
+
+ applied = []
+
+ def fake_apply(values):
+ applied.append(values)
+
+ def fake_flash(stdscr, text, kind="warn"):
+ captured["flash"] = (text, kind)
+
+ with patch.object(hub.tui, "form", fake_form), \
+ patch.object(hub, "_apply_settings", fake_apply), \
+ patch.object(hub.tui, "flash", fake_flash):
+ hub._settings_menu(None)
+ self.assertEqual([f["key"] for f in captured["fields"]],
+ ["audio_format", "audio_bitrate", "language",
+ "chunk_size"])
+ kinds = {f["key"]: f["kind"] for f in captured["fields"]}
+ self.assertEqual(kinds["audio_format"], "choice")
+ self.assertEqual(kinds["audio_bitrate"], "text")
+ self.assertEqual(applied, [{"audio_format": "ogg",
+ "audio_bitrate": "192k",
+ "language": "English",
+ "chunk_size": "300"}])
+ self.assertEqual(captured["flash"], ("Settings saved.", "ok"))
+
+ def test_settings_menu_cancel_does_not_apply(self):
+ def fake_form(stdscr, title, fields, back_value=None):
+ return back_value # user pressed Cancel
+
+ applied = []
+
+ def fake_apply(values):
+ applied.append(values)
+
+ with patch.object(hub.tui, "form", fake_form), \
+ patch.object(hub, "_apply_settings", fake_apply):
+ hub._settings_menu(None)
+ self.assertEqual(applied, [])
+
+ def test_settings_menu_writes_config_end_to_end(self):
+ import tempfile
+ tui._THEME.clear()
+ self.addCleanup(tui._THEME.clear)
+ curses = FakeCurses()
+ patcher = patch.dict("sys.modules", {"curses": curses})
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ original = {name: getattr(hub.config, name) for name in
+ ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
+ "CHUNK_SIZE")}
+ self.addCleanup(lambda: [setattr(hub.config, name, value)
+ for name, value in original.items()])
+
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.py"
+ path.write_text(
+ "# Default output options\n"
+ 'AUDIO_FORMAT = "m4b"\n'
+ 'AUDIO_BITRATE = "128k"\n'
+ 'LANGUAGE = "English"\n'
+ "\n"
+ "CHUNK_SIZE = 250\n",
+ encoding="utf-8")
+ with patch.object(hub.config, "__file__", str(path)):
+ # Down to Chunk size, Enter -> editor, Ctrl-U + '300',
+ # Enter; Tab -> Save, Enter; a key dismisses the flash.
+ screen = FakeScreen(keys=[
+ FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, 10, 21, ord("3"), ord("0"),
+ ord("0"), 10, 9, 10, 10])
+ hub._settings_menu(screen)
+ text = path.read_text(encoding="utf-8")
+ self.assertIn('AUDIO_FORMAT = "m4b"', text)
+ self.assertIn("CHUNK_SIZE = 300", text)
+ # The running session also picked up the change in-memory.
+ self.assertEqual(hub.config.CHUNK_SIZE, 300)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
new file mode 100644
index 0000000..a2df07f
--- /dev/null
+++ b/app/tests/test_tts.py
@@ -0,0 +1,1513 @@
+"""Tests for the TTS client wrappers (language handling and payloads)."""
+
+import io
+import json
+import tempfile
+import time
+import unittest
+import wave
+from contextlib import redirect_stdout
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from converter import config, tts
+from converter.converter import AudiobookConverter
+from converter.tts import (
+ AudioCppTTSClient,
+ FasterTTSClient,
+ QwenTTSClient,
+ normalize_language,
+)
+
+
+class NormalizeLanguageTests(unittest.TestCase):
+ def test_display_names_case_insensitive(self):
+ self.assertEqual(normalize_language("english"), "English")
+ self.assertEqual(normalize_language("ENGLISH"), "English")
+ self.assertEqual(normalize_language(" Japanese "), "Japanese")
+
+ def test_auto_accepted(self):
+ self.assertEqual(normalize_language("auto"), "Auto")
+ self.assertEqual(normalize_language("Auto"), "Auto")
+
+ def test_iso_aliases(self):
+ self.assertEqual(normalize_language("en"), "English")
+ self.assertEqual(normalize_language("ja"), "Japanese")
+ self.assertEqual(normalize_language("zh"), "Chinese")
+ self.assertEqual(normalize_language("ko"), "Korean")
+ self.assertEqual(normalize_language("de"), "German")
+ self.assertEqual(normalize_language("fr"), "French")
+ self.assertEqual(normalize_language("ru"), "Russian")
+ self.assertEqual(normalize_language("pt"), "Portuguese")
+ self.assertEqual(normalize_language("es"), "Spanish")
+ self.assertEqual(normalize_language("it"), "Italian")
+
+ def test_all_supported_languages_round_trip(self):
+ for name in tts.TTS_LANGUAGES:
+ self.assertEqual(normalize_language(name.lower()), name)
+
+ def test_unknown_language_rejected_with_guidance(self):
+ with self.assertRaises(ValueError) as ctx:
+ normalize_language("klingon")
+ message = str(ctx.exception)
+ self.assertIn("klingon", message)
+ self.assertIn("English", message)
+
+ def test_none_and_empty_rejected(self):
+ with self.assertRaises(ValueError):
+ normalize_language(None)
+ with self.assertRaises(ValueError):
+ normalize_language(" ")
+
+
+class QwenTTSClientLanguageTests(unittest.TestCase):
+ """Language validation and defaults, without touching the network."""
+
+ def _make_client(self, **kwargs):
+ with patch.object(QwenTTSClient, "_connect"):
+ return QwenTTSClient(**kwargs)
+
+ def test_default_follows_config_for_each_mode(self):
+ custom = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
+ self.assertEqual(custom.language, config.LANGUAGE)
+ clone = self._make_client(voice_mode=tts.VOICE_MODE_CLONE,
+ voice_clone_ref_audio="ref.wav")
+ self.assertEqual(clone.language, config.LANGUAGE)
+
+ def test_explicit_language_normalized(self):
+ client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM, language="ja")
+ self.assertEqual(client.language, "Japanese")
+
+ def test_invalid_language_fails_before_connect(self):
+ with patch.object(QwenTTSClient, "_connect") as mock_connect:
+ with self.assertRaises(ValueError):
+ QwenTTSClient(language="klingon")
+ mock_connect.assert_not_called()
+
+
+class SeedResolutionTests(unittest.TestCase):
+ """CONSTANT_SEED: one seed per run, reused for every request, so the
+ voice stays consistent across chunk boundaries (the servers
+ re-sample the voice when the seed changes between generations)."""
+
+ def _make_client(self, **kwargs):
+ with patch.object(QwenTTSClient, "_connect"):
+ return QwenTTSClient(**kwargs)
+
+ def test_constant_seed_draws_one_nonnegative_seed(self):
+ with patch.object(config, "CONSTANT_SEED", True), \
+ patch.object(config, "SEED", -1):
+ client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
+ self.assertGreaterEqual(client._seed, 0)
+
+ def test_explicit_seed_wins_over_constant_seed(self):
+ with patch.object(config, "CONSTANT_SEED", True), \
+ patch.object(config, "SEED", 42):
+ client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
+ self.assertEqual(client._seed, 42)
+
+ def test_without_constant_seed_minus_one_is_forwarded(self):
+ with patch.object(config, "CONSTANT_SEED", False), \
+ patch.object(config, "SEED", -1):
+ client = self._make_client(voice_mode=tts.VOICE_MODE_CUSTOM)
+ self.assertEqual(client._seed, -1)
+
+ def test_resolved_seed_is_reused_across_requests(self):
+ api_info = {
+ "named_endpoints": {
+ "/run_custom_voice": {
+ "parameters": [{"parameter_name": "seed"}]
+ }
+ }
+ }
+ client = QwenTTSClient.__new__(QwenTTSClient)
+ client.voice_mode = tts.VOICE_MODE_CUSTOM
+ client.language = "English"
+ client._seed = 1234
+ client.api_info = api_info
+ client.client = MagicMock()
+ client._generate_custom_voice("first text")
+ client._generate_custom_voice("second text")
+ seeds = [call.kwargs["seed"]
+ for call in client.client.predict.call_args_list]
+ self.assertEqual(seeds, [1234, 1234])
+
+
+class PayloadLanguageTests(unittest.TestCase):
+ """The language must reach the API payload in every endpoint variant."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.ref_audio = Path(self._tmp.name) / "reference.wav"
+ self.ref_audio.write_bytes(b"x")
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _custom_client(self, language, endpoint, api_info=None):
+ client = QwenTTSClient.__new__(QwenTTSClient)
+ client.voice_mode = tts.VOICE_MODE_CUSTOM
+ client.language = language
+ client._seed = config.SEED
+ client.api_info = api_info if api_info is not None else {
+ "named_endpoints": {endpoint: {}}
+ }
+ client.client = MagicMock()
+ return client
+
+ def _clone_client(self, language, endpoint, api_info=None, ref_text="hello"):
+ client = QwenTTSClient.__new__(QwenTTSClient)
+ client.voice_mode = tts.VOICE_MODE_CLONE
+ client.language = language
+ client._seed = config.SEED
+ client.voice_clone_ref_audio = str(self.ref_audio)
+ client.voice_clone_ref_text = ref_text
+ client.clone_api_info = api_info if api_info is not None else {
+ "named_endpoints": {endpoint: {}}
+ }
+ client.clone_client = MagicMock()
+ client._ref_audio_filedata = {"dummy": "payload"}
+ return client
+
+ def test_custom_voice_run_instruct_uses_language(self):
+ client = self._custom_client("Japanese", "/run_instruct")
+ client._generate_custom_voice("text")
+ kwargs = client.client.predict.call_args.kwargs
+ self.assertEqual(kwargs["lang_disp"], "Japanese")
+
+ def test_custom_voice_alt_endpoint_uses_language(self):
+ client = self._custom_client("French", "/run_custom_voice")
+ client._generate_custom_voice("text")
+ kwargs = client.client.predict.call_args.kwargs
+ self.assertEqual(kwargs["language"], "French")
+
+ def test_voice_clone_run_voice_clone_uses_language(self):
+ client = self._clone_client("Japanese", "/run_voice_clone")
+ client._generate_voice_clone("text")
+ kwargs = client.clone_client.predict.call_args.kwargs
+ self.assertEqual(kwargs["lang_disp"], "Japanese")
+
+ def test_voice_clone_alt_endpoint_uses_language(self):
+ client = self._clone_client("Korean", "/generate_voice_clone")
+ client._generate_voice_clone("text")
+ kwargs = client.clone_client.predict.call_args.kwargs
+ self.assertEqual(kwargs["language"], "Korean")
+
+ def test_voice_clone_alt_endpoint_includes_optional_params(self):
+ api_info = {
+ "named_endpoints": {
+ "/generate_voice_clone": {
+ "parameters": [
+ {"parameter_name": "model_size"},
+ {"parameter_name": "seed"},
+ ]
+ }
+ }
+ }
+ client = self._clone_client("English", "/generate_voice_clone", api_info=api_info)
+ client._generate_voice_clone("text")
+ kwargs = client.clone_client.predict.call_args.kwargs
+ self.assertEqual(kwargs["model_size"], tts.MODEL_SIZE)
+ self.assertEqual(kwargs["seed"], config.SEED)
+
+
+class FasterTTSClientHealthTests(unittest.TestCase):
+ """Connection behavior of the faster-qwen3-tts client."""
+
+ def _health_response(self, model_loaded=True):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = json.dumps(
+ {"status": "ok", "model_loaded": model_loaded}).encode("utf-8")
+ return response
+
+ def test_unreachable_server_raises_with_readme_pointer(self):
+ import urllib.error
+ with patch("converter.tts.urllib.request.urlopen",
+ side_effect=urllib.error.URLError("Connection refused")):
+ with self.assertRaises(RuntimeError) as ctx:
+ FasterTTSClient()
+ message = str(ctx.exception)
+ self.assertIn("not reachable", message)
+ self.assertIn("README", message)
+
+ def test_model_not_loaded_raises(self):
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._health_response(model_loaded=False)):
+ with self.assertRaises(RuntimeError) as ctx:
+ FasterTTSClient()
+ self.assertIn("not loaded", str(ctx.exception))
+
+ def test_healthy_server_defaults_from_config(self):
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._health_response()):
+ client = FasterTTSClient()
+ self.assertEqual(client.voice, config.FASTER_VOICE)
+ self.assertEqual(client.api_url, config.FASTER_API_URL.rstrip("/"))
+
+ def test_explicit_voice_and_url_override_config(self):
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._health_response()):
+ client = FasterTTSClient(voice="narrator", api_url="http://10.0.0.5:9000/")
+ self.assertEqual(client.voice, "narrator")
+ self.assertEqual(client.api_url, "http://10.0.0.5:9000")
+
+
+class FasterTTSClientGenerateTests(unittest.TestCase):
+ """Chunk generation: sub-chunking, WAV output, retries, bookkeeping."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
+ self._chunks.start()
+ self._sleep = patch("converter.tts.time.sleep")
+ self._sleep.start()
+
+ def tearDown(self):
+ self._sleep.stop()
+ self._chunks.stop()
+ self._tmp.cleanup()
+
+ def _make_client(self):
+ client = FasterTTSClient.__new__(FasterTTSClient)
+ client.voice = "default"
+ client.api_url = "http://127.0.0.1:8000"
+ return client
+
+ def _read_wav(self, path):
+ with wave.open(str(path), "rb") as wav_file:
+ return (wav_file.getnchannels(), wav_file.getsampwidth(),
+ wav_file.getframerate(), wav_file.readframes(wav_file.getnframes()))
+
+ def test_generate_chunk_writes_valid_wav(self):
+ client = self._make_client()
+ pcm = b"\x01\x00" * 100
+ with patch.object(client, "_request_pcm", return_value=pcm):
+ result = client.generate_chunk("Hello world.", 1)
+ self.assertIsNotNone(result)
+ path = Path(result)
+ self.assertEqual(path.name, "chunk_0001.wav")
+ channels, sampwidth, framerate, frames = self._read_wav(path)
+ self.assertEqual(channels, 1)
+ self.assertEqual(sampwidth, 2)
+ self.assertEqual(framerate, tts.SAMPLE_RATE)
+ self.assertEqual(frames, pcm)
+
+ def test_long_text_is_subchunked_and_concatenated_in_order(self):
+ client = self._make_client()
+ sentences = [" ".join(f"word{i}" for i in range(6)) + "." for _ in range(3)]
+ text = " ".join(sentences)
+ pcm_parts = [b"\x01\x00" * 10, b"\x02\x00" * 20, b"\x03\x00" * 30]
+ with patch.object(config, "CHUNK_SIZE", 10), \
+ patch.object(client, "_request_pcm", side_effect=pcm_parts) as mock_pcm:
+ result = client.generate_chunk(text, 1)
+ self.assertEqual(mock_pcm.call_count, 3)
+ _, _, _, frames = self._read_wav(Path(result))
+ self.assertEqual(frames, b"".join(pcm_parts))
+
+ def test_subchunk_size_follows_config_chunk_size(self):
+ client = self._make_client()
+ text = " ".join(f"word{i}" for i in range(8))
+ pcm = b"\x01\x00" * 10
+ with patch.object(config, "CHUNK_SIZE", 4), \
+ patch.object(client, "_request_pcm", return_value=pcm) as mock_pcm:
+ result = client.generate_chunk(text, 1)
+ # The sub-chunk split follows config.CHUNK_SIZE, so the whole
+ # (8-word) text needs two 4-word requests here.
+ self.assertEqual(mock_pcm.call_count, 2)
+ self.assertIsNotNone(result)
+
+ def test_stale_chunk_files_are_removed(self):
+ stale = Path(self._tmp.name) / "chunk_0001.mp3"
+ stale.write_bytes(b"old")
+ client = self._make_client()
+ with patch.object(client, "_request_pcm", return_value=b"\x01\x00"):
+ client.generate_chunk("Hello.", 1)
+ remaining = sorted(path.name for path in Path(self._tmp.name).glob("chunk_0001.*"))
+ self.assertEqual(remaining, ["chunk_0001.wav"])
+
+ def test_transient_failure_is_retried(self):
+ client = self._make_client()
+ pcm = b"\x01\x00" * 10
+ with patch.object(client, "_request_pcm",
+ side_effect=[RuntimeError("boom"), pcm]) as mock_pcm:
+ result = client.generate_chunk("Hello.", 1)
+ self.assertIsNotNone(result)
+ self.assertEqual(mock_pcm.call_count, 2)
+
+ def test_empty_pcm_response_is_treated_as_failure(self):
+ client = self._make_client()
+ pcm = b"\x01\x00" * 10
+
+ def _response(body):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = body
+ return response
+
+ with patch("converter.tts.urllib.request.urlopen",
+ side_effect=[_response(b""), _response(pcm)]) as mock_urlopen:
+ result = client.generate_chunk("Hello.", 1)
+ self.assertIsNotNone(result)
+ self.assertEqual(mock_urlopen.call_count, 2)
+ _, _, _, frames = self._read_wav(Path(result))
+ self.assertEqual(frames, pcm)
+
+ def test_exhausted_subchunk_retries_fail_the_chunk(self):
+ client = self._make_client()
+ with patch.object(client, "_request_pcm",
+ side_effect=RuntimeError("down")) as mock_pcm:
+ result = client.generate_chunk("Hello.", 1)
+ self.assertIsNone(result)
+ self.assertEqual(mock_pcm.call_count, config.MAX_RETRIES)
+
+ def test_empty_text_fails_the_chunk(self):
+ client = self._make_client()
+ with patch.object(client, "_request_pcm") as mock_pcm:
+ result = client.generate_chunk(" ", 1)
+ self.assertIsNone(result)
+ mock_pcm.assert_not_called()
+
+ def test_request_payload_includes_voice_text_and_format(self):
+ client = self._make_client()
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = b"\x01\x00" * 10
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=response) as mock_urlopen:
+ pcm = client._request_pcm("Hello world.")
+ self.assertEqual(pcm, b"\x01\x00" * 10)
+ request = mock_urlopen.call_args[0][0]
+ self.assertEqual(request.full_url, "http://127.0.0.1:8000/v1/audio/speech")
+ payload = json.loads(request.data.decode("utf-8"))
+ self.assertEqual(payload["input"], "Hello world.")
+ self.assertEqual(payload["voice"], "default")
+ self.assertEqual(payload["response_format"], "pcm")
+
+ def test_full_length_pcm_passes(self):
+ client = self._make_client()
+ text = " ".join(f"word{i}" for i in range(12))
+ # 12 words -> expected 4.8s, half is 2.4s -> 2.5s of audio passes.
+ pcm = b"\x01\x00" * int(2.5 * tts.SAMPLE_RATE)
+ with patch.object(client, "_request_pcm", return_value=pcm):
+ result = client.generate_chunk(text, 1)
+ self.assertIsNotNone(result)
+
+
+class QwenTTSClientGenerateTests(unittest.TestCase):
+ """Qwen chunk generation: sub-request splitting and concatenation."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
+ self._chunks.start()
+
+ def tearDown(self):
+ self._chunks.stop()
+ self._tmp.cleanup()
+
+ def _make_client(self):
+ client = QwenTTSClient.__new__(QwenTTSClient)
+ client.voice_mode = tts.VOICE_MODE_CUSTOM
+ return client
+
+ @staticmethod
+ def _write_wav(path: Path, frames: bytes) -> Path:
+ with wave.open(str(path), "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(tts.SAMPLE_RATE)
+ wav_file.writeframes(frames)
+ return path
+
+ def _read_wav_frames(self, path: Path) -> bytes:
+ with wave.open(str(path), "rb") as wav_file:
+ return wav_file.readframes(wav_file.getnframes())
+
+ def test_single_request_copies_audio(self):
+ client = self._make_client()
+ source = self._write_wav(Path(self._tmp.name) / "server.wav", b"\x01\x00" * 50)
+ with patch.object(client, "_generate_custom_voice",
+ return_value=(str(source),)) as mock_generate:
+ result = client.generate_chunk("Hello world.", 1)
+ mock_generate.assert_called_once_with("Hello world.")
+ path = Path(result)
+ self.assertEqual(path.name, "chunk_0001.wav")
+ self.assertEqual(self._read_wav_frames(path), b"\x01\x00" * 50)
+
+ def test_oversized_input_is_split_and_concatenated_in_order(self):
+ client = self._make_client()
+ first = self._write_wav(Path(self._tmp.name) / "one.wav", b"\x01\x00" * 10)
+ second = self._write_wav(Path(self._tmp.name) / "two.wav", b"\x02\x00" * 20)
+ text = " ".join(f"word{i}" for i in range(12))
+ with patch.object(config, "CHUNK_SIZE", 5), \
+ patch.object(client, "_generate_custom_voice",
+ side_effect=[(str(first),), (str(second),),
+ (str(first),)]) as mock_generate:
+ result = client.generate_chunk(text, 1)
+ self.assertEqual(mock_generate.call_count, 3)
+ path = Path(result)
+ self.assertEqual(path.name, "chunk_0001.wav")
+ self.assertEqual(self._read_wav_frames(path),
+ b"\x01\x00" * 10 + b"\x02\x00" * 20 + b"\x01\x00" * 10)
+ for call in mock_generate.call_args_list:
+ self.assertLessEqual(len(call[0][0].split()), 5)
+
+ def test_empty_text_fails_the_chunk(self):
+ client = self._make_client()
+ with patch.object(client, "_generate_custom_voice") as mock_generate:
+ result = client.generate_chunk(" ", 1)
+ self.assertIsNone(result)
+ mock_generate.assert_not_called()
+
+
+class AudioCppTTSClientHealthTests(unittest.TestCase):
+ """Connection behavior of the audio.cpp client."""
+
+ @staticmethod
+ def _json_response(payload):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = json.dumps(payload).encode("utf-8")
+ return response
+
+ def _get_responses(self, health=None, models=None, voices=None):
+ """Side effect dispatching GET responses by URL."""
+ def _dispatch(request, **_kwargs):
+ url = request if isinstance(request, str) else request.full_url
+ if url.endswith("/health"):
+ return self._json_response(health if health is not None
+ else {"status": "ok"})
+ if url.endswith("/v1/models"):
+ return self._json_response(models if models is not None else
+ {"data": [{"id": config.AUDIOCPP_MODEL_ID}]})
+ if "/v1/audio/voices" in url:
+ if voices is Exception:
+ raise Exception("voices endpoint down")
+ return self._json_response(voices if voices is not None
+ else {"voices": ["narrator"]})
+ raise AssertionError(f"unexpected URL: {url}")
+ return _dispatch
+
+ def _client(self, voice=None, language=None, model_id=None, **kwargs):
+ with patch("converter.tts.urllib.request.urlopen",
+ side_effect=self._get_responses(**kwargs)):
+ return AudioCppTTSClient(voice=voice, language=language,
+ model_id=model_id)
+
+ def test_unreachable_server_raises_with_readme_pointer(self):
+ import urllib.error
+ with patch("converter.tts.urllib.request.urlopen",
+ side_effect=urllib.error.URLError("Connection refused")):
+ with self.assertRaises(RuntimeError) as ctx:
+ AudioCppTTSClient()
+ message = str(ctx.exception)
+ self.assertIn("not reachable", message)
+ self.assertIn("README", message)
+
+ def test_unhealthy_status_raises(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(health={"status": "starting"})
+ self.assertIn("starting", str(ctx.exception))
+
+ def test_unknown_model_id_raises_with_configured_ids(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(models={"data": [{"id": "pocket-tts"}, {"id": "other"}]})
+ message = str(ctx.exception)
+ self.assertIn(config.AUDIOCPP_MODEL_ID, message)
+ self.assertIn("pocket-tts", message)
+ self.assertIn("other", message)
+
+ def test_healthy_server_speaker_mode_defaults(self):
+ client = self._client()
+ self.assertEqual(client.api_url, config.AUDIOCPP_API_URL.rstrip("/"))
+ self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID)
+ self.assertEqual(client.language, config.LANGUAGE)
+ self.assertEqual(client.voice, "Vivian")
+ self.assertFalse(client.preset_mode)
+
+ def test_speaker_mode_uses_configured_speaker(self):
+ with patch.object(config, "SPEAKER", "uncle_fu"):
+ client = self._client()
+ self.assertEqual(client.voice, "Uncle Fu")
+
+ def test_preset_mode_uses_requested_voice(self):
+ client = self._client(voice="narrator")
+ self.assertEqual(client.voice, "narrator")
+ self.assertTrue(client.preset_mode)
+
+ def test_preset_mode_validates_voice_against_server_list(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(voice="ghost", voices={"voices": ["narrator", "obama"]})
+ message = str(ctx.exception)
+ self.assertIn("ghost", message)
+ self.assertIn("narrator", message)
+ self.assertIn("obama", message)
+
+ def test_preset_mode_skips_validation_when_voices_endpoint_fails(self):
+ client = self._client(voice="narrator", voices=Exception)
+ self.assertEqual(client.voice, "narrator")
+
+ def test_invalid_language_fails_before_connect(self):
+ with patch("converter.tts.urllib.request.urlopen") as mock_urlopen:
+ with self.assertRaises(ValueError):
+ AudioCppTTSClient(language="klingon")
+ mock_urlopen.assert_not_called()
+
+ def test_explicit_language_normalized(self):
+ client = self._client(language="ja")
+ self.assertEqual(client.language, "Japanese")
+
+ def test_seed_resolved_once_per_run(self):
+ with patch.object(config, "CONSTANT_SEED", True), \
+ patch.object(config, "SEED", -1):
+ client = self._client()
+ self.assertGreaterEqual(client._seed, 0)
+
+ def test_preset_mode_routes_to_clone_model_when_configured(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
+ client = self._client(
+ voice="narrator",
+ models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]})
+ self.assertEqual(client.model_id, "qwen3-tts-clone")
+
+ def test_preset_mode_falls_back_when_clone_model_not_on_server(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \
+ self.assertLogs("converter.tts", level="WARNING") as logs:
+ client = self._client(
+ voice="narrator",
+ models={"data": [{"id": "qwen3-tts"}, {"id": "pocket-tts"}]})
+ self.assertEqual(client.model_id, "qwen3-tts")
+ self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output))
+
+ def test_empty_model_id_auto_picks_single_server_entry(self):
+ # A multi-model server used without editing config.py: an empty
+ # --model resolves to the only hosted entry automatically.
+ client = self._client(
+ voice="narrator", model_id="",
+ models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]},
+ voices={"voices": ["narrator"]})
+ self.assertEqual(client.model_id, "higgs")
+
+ def test_empty_model_id_with_multiple_entries_requires_explicit_choice(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(
+ voice="narrator", model_id="",
+ models={"data": [{"id": "higgs"}, {"id": "voxcpm2"}]},
+ voices={"voices": ["narrator"]})
+ message = str(ctx.exception)
+ self.assertIn("--model", message)
+ self.assertIn("higgs", message)
+ self.assertIn("voxcpm2", message)
+
+ def test_model_id_override_reaches_request(self):
+ # --model overrides AUDIOCPP_MODEL_ID for the run.
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"):
+ client = self._client(
+ voice="narrator", model_id="higgs",
+ models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]},
+ voices={"voices": ["narrator"]})
+ self.assertEqual(client.model_id, "higgs")
+
+ def test_clone_model_id_ignored_for_speaker_mode(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
+ client = self._client(
+ models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]})
+ self.assertEqual(client.model_id, "qwen3-tts")
+
+ def test_clone_model_id_equal_to_primary_is_noop(self):
+ with patch.object(config, "AUDIOCPP_CLONE_MODEL_ID",
+ config.AUDIOCPP_MODEL_ID):
+ client = self._client(voice="narrator")
+ self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID)
+
+ def test_preset_mode_with_clone_only_server_uses_clone_model(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
+ client = self._client(
+ voice="narrator",
+ models={"data": [{"id": "qwen3-tts-clone"}]})
+ self.assertEqual(client.model_id, "qwen3-tts-clone")
+
+ def test_speaker_mode_with_clone_only_server_suggests_voice(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(models={"data": [{"id": "qwen3-tts-clone"}]})
+ message = str(ctx.exception)
+ self.assertIn("qwen3-tts", message)
+ self.assertIn("--voice", message)
+
+ def test_preset_mode_with_no_matching_model_lists_both_ids(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \
+ self.assertLogs("converter.tts", level="WARNING"):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(voice="narrator",
+ models={"data": [{"id": "pocket-tts"}]})
+ message = str(ctx.exception)
+ self.assertIn("qwen3-tts", message)
+ self.assertIn("qwen3-tts-clone", message)
+ self.assertIn("pocket-tts", message)
+
+
+class AudioCppTaskDetectionTests(unittest.TestCase):
+ """Task auto-detection (tts/clon/vdes) and voice design validation."""
+
+ @staticmethod
+ def _json_response(payload):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = json.dumps(payload).encode("utf-8")
+ return response
+
+ def _client(self, voice=None, instructions=None, request_options=None,
+ models=None):
+ if models is None:
+ models = {"data": [{"id": config.AUDIOCPP_MODEL_ID,
+ "family": "qwen3_tts"}]}
+
+ def _dispatch(request, **_kwargs):
+ url = request if isinstance(request, str) else request.full_url
+ if url.endswith("/health"):
+ return self._json_response({"status": "ok"})
+ if url.endswith("/v1/models"):
+ return self._json_response(models)
+ if "/v1/audio/voices" in url:
+ return self._json_response({"voices": ["narrator"]})
+ raise AssertionError(f"unexpected URL: {url}")
+
+ with patch("converter.tts.urllib.request.urlopen",
+ side_effect=_dispatch):
+ return AudioCppTTSClient(voice=voice, instructions=instructions,
+ request_options=request_options)
+
+ def test_missing_task_falls_back_to_tts(self):
+ # Servers that predate the task field hosted plain TTS models.
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]})
+ self.assertEqual(client.task, tts.AUDIOCPP_TASK_TTS)
+ self.assertFalse(client.design_mode)
+
+ def test_task_detected_from_models_endpoint(self):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
+ "task": "vdes"}]},
+ instructions="A warm adult narrator")
+ self.assertEqual(client.task, tts.AUDIOCPP_TASK_VDES)
+ self.assertTrue(client.design_mode)
+
+ def test_clon_task_entry_connects_in_preset_mode(self):
+ client = self._client(voice="narrator", models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "chatterbox",
+ "task": "clon"}]})
+ self.assertEqual(client.task, "clon")
+ self.assertFalse(client.design_mode)
+ self.assertTrue(client.preset_mode)
+
+ def test_unsupported_task_rejected_with_available_entries(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_asr",
+ "task": "asr"},
+ {"id": "tts-1", "family": "qwen3_tts", "task": "tts"}]},
+ instructions="unused")
+ message = str(ctx.exception)
+ self.assertIn("'asr'", message)
+ self.assertIn("--model", message)
+ self.assertIn("tts-1", message)
+
+ def test_vdes_without_instructions_requires_description(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
+ "task": "vdes"}]})
+ message = str(ctx.exception)
+ self.assertIn("voice design", message)
+ self.assertIn("--instructions", message)
+
+ def test_vdes_with_voice_rejected(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(voice="narrator", models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
+ "task": "vdes"}]},
+ instructions="A warm adult narrator")
+ self.assertIn("--voice", str(ctx.exception))
+ self.assertIn("--instructions", str(ctx.exception))
+
+ def test_vdes_with_instructions_connects_in_design_mode(self):
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
+ "task": "vdes"}]},
+ instructions="A warm adult narrator")
+ self.assertTrue(client.design_mode)
+ self.assertEqual(client.instructions, "A warm adult narrator")
+ out = buf.getvalue()
+ self.assertIn("voice design", out)
+ self.assertIn("A warm adult narrator", out)
+
+ def test_instructions_without_voice_on_generic_family_connects(self):
+ # Families without built-in speakers can get their voice from the
+ # instruction alone (e.g. OmniVoice voice design).
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "omnivoice",
+ "task": "tts"}]},
+ instructions="female, young adult, moderate pitch")
+ self.assertFalse(client.design_mode)
+ self.assertTrue(client.instruction_voice)
+ self.assertIn("instruction voice", buf.getvalue())
+
+ def test_instructions_with_builtin_speaker_family_stays_speaker_mode(self):
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
+ "task": "tts"}]},
+ instructions="Very happy.")
+ self.assertFalse(client.design_mode)
+ self.assertFalse(client.instruction_voice)
+ self.assertIn("speaker 'Vivian'", buf.getvalue())
+
+ def test_config_instructions_used_when_flag_omitted(self):
+ with patch.object(config, "AUDIOCPP_INSTRUCTIONS",
+ "A calm elderly storyteller"):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
+ "task": "vdes"}]})
+ self.assertEqual(client.instructions, "A calm elderly storyteller")
+
+ def test_explicit_instructions_override_config_default(self):
+ with patch.object(config, "AUDIOCPP_INSTRUCTIONS", "from config"):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts",
+ "task": "vdes"}]},
+ instructions="from flag")
+ self.assertEqual(client.instructions, "from flag")
+
+
+class AudioCppFamilyDetectionTests(unittest.TestCase):
+ """Family auto-detection and per-family adaptations."""
+
+ @staticmethod
+ def _json_response(payload):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = json.dumps(payload).encode("utf-8")
+ return response
+
+ def _client(self, voice="narrator", models=None):
+ def _dispatch(request, **_kwargs):
+ url = request if isinstance(request, str) else request.full_url
+ if url.endswith("/health"):
+ return self._json_response({"status": "ok"})
+ if url.endswith("/v1/models"):
+ return self._json_response(models)
+ if "/v1/audio/voices" in url:
+ return self._json_response({"voices": [voice] if voice else []})
+ raise AssertionError(f"unexpected URL: {url}")
+
+ with patch("converter.tts.urllib.request.urlopen",
+ side_effect=_dispatch):
+ return AudioCppTTSClient(voice=voice)
+
+ def test_family_detected_from_models_endpoint(self):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]})
+ self.assertEqual(client.family, "higgs_audio_tts")
+ self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
+
+ def test_missing_family_falls_back_to_qwen3_tts(self):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID}]})
+ self.assertEqual(client.family, "qwen3_tts")
+ self.assertTrue(client.profile.builtin_speakers)
+
+ def test_unknown_family_uses_generic_profile(self):
+ client = self._client(models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "future_tts"}]})
+ self.assertEqual(client.family, "future_tts")
+ self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
+ self.assertFalse(client.profile.builtin_speakers)
+ self.assertEqual(client.profile.language_style, tts.AUDIOCPP_LANG_OMIT)
+
+ def test_speaker_mode_rejected_for_clone_only_family(self):
+ client = None
+ try:
+ client = self._client(voice=None, models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]})
+ except RuntimeError as exc:
+ message = str(exc)
+ self.assertIn("voxcpm2", message)
+ self.assertIn("--voice", message)
+ self.assertIn("no built-in speakers", message)
+ self.assertIsNone(client)
+
+ def test_speaker_mode_allowed_for_qwen_family(self):
+ client = self._client(voice=None, models={"data": [
+ {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]})
+ self.assertEqual(client.family, "qwen3_tts")
+
+ def test_clone_model_id_of_different_family_is_ignored(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \
+ self.assertLogs("converter.tts", level="WARNING") as logs:
+ client = self._client(models={"data": [
+ {"id": "higgs", "family": "higgs_audio_tts"},
+ {"id": "qwen-clone", "family": "qwen3_tts"}]})
+ self.assertEqual(client.model_id, "higgs")
+ self.assertTrue(any("different family" in line.lower() or
+ "hosts family" in line.lower()
+ for line in logs.output))
+
+ def test_clone_model_id_missing_on_non_qwen_server_is_debug_only(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \
+ self.assertNoLogs("converter.tts", level="WARNING"):
+ client = self._client(models={"data": [
+ {"id": "higgs", "family": "higgs_audio_tts"}]})
+ self.assertEqual(client.model_id, "higgs")
+
+ def test_clone_model_id_missing_on_qwen_server_still_warns(self):
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
+ patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \
+ self.assertLogs("converter.tts", level="WARNING") as logs:
+ client = self._client(models={"data": [
+ {"id": "qwen3-tts", "family": "qwen3_tts"},
+ {"id": "pocket-tts", "family": "pocket_tts"}]})
+ self.assertEqual(client.model_id, "qwen3-tts")
+ self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output))
+
+ def test_iso_language_code_helper(self):
+ self.assertEqual(tts.LANGUAGE_ISO_CODES["English"], "en")
+ self.assertIsNone(tts.LANGUAGE_ISO_CODES.get("Auto"))
+
+
+class AudioCppTTSClientRequestTests(unittest.TestCase):
+ """The /v1/audio/speech payload and response validation."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
+ self._chunks.start()
+ self._sleep = patch("converter.tts.time.sleep")
+ self._sleep.start()
+
+ def tearDown(self):
+ self._sleep.stop()
+ self._chunks.stop()
+ self._tmp.cleanup()
+
+ @staticmethod
+ def _make_client(preset_mode=False, voice="Vivian", language="English", seed=-1,
+ chunk_text=True, family="qwen3_tts", task="tts",
+ instructions=None, request_options=None):
+ client = AudioCppTTSClient.__new__(AudioCppTTSClient)
+ client.api_url = "http://127.0.0.1:8080"
+ client.model_id = config.AUDIOCPP_MODEL_ID
+ client.preset_mode = preset_mode
+ client.voice = voice
+ client.language = language
+ client._seed = seed
+ client.chunk_text = chunk_text
+ client.family = family
+ client.task = task
+ client.profile = tts.AUDIOCPP_FAMILY_PROFILES.get(
+ family, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE)
+ client.instructions = instructions or ""
+ client.request_options = dict(request_options or {})
+ client.design_mode = task == tts.AUDIOCPP_TASK_VDES
+ # Mirrors the connect-time rule: an instruction-defined voice on a
+ # family without built-in speakers (design mode takes precedence).
+ client.instruction_voice = (
+ not preset_mode and not client.design_mode
+ and not client.profile.builtin_speakers
+ and bool(client.instructions))
+ return client
+
+ @staticmethod
+ def _wav_bytes(frames=b"\x01\x00" * 10, rate=tts.SAMPLE_RATE):
+ buffer = io.BytesIO()
+ with wave.open(buffer, "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(rate)
+ wav_file.writeframes(frames)
+ return buffer.getvalue()
+
+ def _post_response(self, body):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = body
+ return response
+
+ def test_payload_includes_model_input_voice_language_and_seed(self):
+ client = self._make_client(preset_mode=True, voice="narrator",
+ language="Japanese", seed=1234)
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello world.")
+ request = mock_urlopen.call_args[0][0]
+ self.assertEqual(request.full_url,
+ "http://127.0.0.1:8080/v1/audio/speech")
+ payload = json.loads(request.data.decode("utf-8"))
+ self.assertEqual(payload["model"], config.AUDIOCPP_MODEL_ID)
+ self.assertEqual(payload["input"], "Hello world.")
+ self.assertEqual(payload["voice"], "narrator")
+ self.assertEqual(payload["language"], "Japanese")
+ self.assertEqual(payload["seed"], 1234)
+ self.assertNotIn("instructions", payload)
+
+ def test_negative_seed_omitted_from_payload(self):
+ client = self._make_client(preset_mode=True, voice="narrator", seed=-1)
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello world.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertNotIn("seed", payload)
+
+ def test_whole_text_sent_as_one_request_without_client_chunking(self):
+ client = self._make_client(chunk_text=False)
+ # 9 words with CHUNK_SIZE=5 would split in two if client chunking
+ # were on.
+ text = " ".join(f"word{i}" for i in range(9))
+ with patch.object(config, "CHUNK_SIZE", 5), \
+ patch.object(client, "_request_wav",
+ return_value=self._wav_bytes()) as mock_request:
+ result = client.generate_chunk(text, 1)
+ self.assertIsNotNone(result)
+ self.assertEqual(mock_request.call_count, 1)
+ self.assertEqual(mock_request.call_args[0][0], text)
+
+ def test_single_request_timeout_scales_with_text_length(self):
+ client = self._make_client(chunk_text=False)
+ long_text = " ".join(f"word{i}" for i in range(1500)) # ~10 min of audio
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav(long_text)
+ timeout = mock_urlopen.call_args[1]["timeout"]
+ self.assertGreater(timeout, config.API_TIMEOUT)
+
+ def test_client_chunking_keeps_configured_timeout(self):
+ client = self._make_client(chunk_text=True)
+ long_text = " ".join(f"word{i}" for i in range(1500))
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav(long_text)
+ timeout = mock_urlopen.call_args[1]["timeout"]
+ self.assertEqual(timeout, config.API_TIMEOUT)
+
+ def test_speaker_mode_sends_instruct(self):
+ client = self._make_client(preset_mode=False)
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["instructions"], config.INSTRUCT)
+
+ def test_explicit_instructions_replace_config_instruct(self):
+ # --instructions overrides the INSTRUCT default in speaker mode.
+ client = self._make_client(preset_mode=False,
+ instructions="Read whisper quiet.")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["instructions"], "Read whisper quiet.")
+
+ def test_preset_mode_sends_instructions_alongside_voice(self):
+ # Clone + style control: both the server-side voice and the
+ # instruction reach the model.
+ client = self._make_client(preset_mode=True, voice="narrator",
+ instructions="Calm and steady.")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["voice"], "narrator")
+ self.assertEqual(payload["instructions"], "Calm and steady.")
+
+ def test_design_mode_payload_omits_voice_and_sends_instructions(self):
+ client = self._make_client(task="vdes",
+ instructions="A warm adult narrator")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertNotIn("voice", payload)
+ self.assertEqual(payload["instructions"], "A warm adult narrator")
+
+ def test_design_mode_language_follows_family_profile(self):
+ # The VoiceDesign package is family qwen3_tts, whose language field
+ # takes Qwen display names like the other variants.
+ client = self._make_client(task="vdes", language="Japanese",
+ instructions="A warm adult narrator")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["language"], "Japanese")
+
+ def test_instruction_voice_payload_omits_voice(self):
+ # Instruction-defined voice on a family without built-in speakers:
+ # no speaker name is invented, the instruction carries the voice.
+ client = self._make_client(family="omnivoice",
+ instructions="female, young adult")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertNotIn("voice", payload)
+ self.assertNotIn("language", payload) # generic profile: omitted
+ self.assertEqual(payload["instructions"], "female, young adult")
+
+ def test_request_options_forwarded_in_payload(self):
+ client = self._make_client(preset_mode=True, voice="narrator",
+ request_options={"emotion": "neutral",
+ "speed": "1.1"})
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["options"], {"emotion": "neutral",
+ "speed": "1.1"})
+
+ def test_empty_request_options_omit_options_field(self):
+ client = self._make_client(preset_mode=True, voice="narrator")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertNotIn("options", payload)
+
+ def test_generic_family_omits_language_and_instructions(self):
+ # Clone-only families (higgs_audio_tts, voxcpm2, ...) detect the
+ # language themselves and take no style instruction.
+ client = self._make_client(preset_mode=False, family="higgs_audio_tts")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertNotIn("language", payload)
+ self.assertNotIn("instructions", payload)
+
+ def test_iso_family_sends_language_code(self):
+ client = self._make_client(preset_mode=True, voice="narrator",
+ language="Japanese", family="index_tts2")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["language"], "ja")
+
+ def test_iso_family_auto_omits_language(self):
+ client = self._make_client(preset_mode=True, voice="narrator",
+ language="Auto", family="index_tts2")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertNotIn("language", payload)
+
+ def test_qwen_language_display_name_still_sent(self):
+ client = self._make_client(preset_mode=True, voice="narrator",
+ language="Japanese", family="qwen3_tts")
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(self._wav_bytes())) as mock_urlopen:
+ client._request_wav("Hello.")
+ payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
+ self.assertEqual(payload["language"], "Japanese")
+
+ def test_non_wav_response_rejected(self):
+ client = self._make_client()
+ for body in (b"", b"RIFFxxxx", b"MP3DATA-MP3DATA", b"RIFF\x00\x00\x00\x00mpeg"):
+ with patch("converter.tts.urllib.request.urlopen",
+ return_value=self._post_response(body)):
+ with self.assertRaises(RuntimeError):
+ client._request_wav("Hello.")
+
+ def test_http_error_body_surfaced(self):
+ import urllib.error
+ client = self._make_client()
+ error = urllib.error.HTTPError(
+ "http://127.0.0.1:8080/v1/audio/speech", 500,
+ "Server Error", {}, io.BytesIO(b'{"error":"bad voice"}'))
+ with patch("converter.tts.urllib.request.urlopen", side_effect=error):
+ with self.assertRaises(RuntimeError) as ctx:
+ client._request_wav("Hello.")
+ self.assertIn("500", str(ctx.exception))
+ self.assertIn("bad voice", str(ctx.exception))
+
+ def test_transient_failure_is_retried(self):
+ client = self._make_client()
+ wav = self._wav_bytes()
+ with patch.object(client, "_request_wav",
+ side_effect=[RuntimeError("boom"), wav]) as mock_request:
+ result = client.generate_chunk("Hello.", 1)
+ self.assertIsNotNone(result)
+ self.assertEqual(mock_request.call_count, 2)
+
+ def test_exhausted_retries_fail_the_chunk(self):
+ client = self._make_client()
+ with patch.object(client, "_request_wav",
+ side_effect=RuntimeError("down")) as mock_request:
+ result = client.generate_chunk("Hello.", 1)
+ self.assertIsNone(result)
+ self.assertEqual(mock_request.call_count, config.MAX_RETRIES)
+
+ def test_empty_text_fails_the_chunk(self):
+ client = self._make_client()
+ with patch.object(client, "_request_wav") as mock_request:
+ result = client.generate_chunk(" ", 1)
+ self.assertIsNone(result)
+ mock_request.assert_not_called()
+
+ def test_generate_chunk_writes_valid_wav(self):
+ client = self._make_client()
+ frames = b"\x01\x00" * 100
+ with patch.object(client, "_request_wav", return_value=self._wav_bytes(frames)):
+ result = client.generate_chunk("Hello world.", 1)
+ self.assertIsNotNone(result)
+ path = Path(result)
+ self.assertEqual(path.name, "chunk_0001.wav")
+ with wave.open(str(path), "rb") as wav_file:
+ self.assertEqual(wav_file.getnchannels(), 1)
+ self.assertEqual(wav_file.getsampwidth(), 2)
+ self.assertEqual(wav_file.getframerate(), tts.SAMPLE_RATE)
+ self.assertEqual(wav_file.readframes(wav_file.getnframes()), frames)
+
+ def test_long_text_is_subchunked_and_concatenated_in_order(self):
+ client = self._make_client()
+ sentences = [" ".join(f"word{i}" for i in range(6)) + "." for _ in range(3)]
+ text = " ".join(sentences)
+ parts = [self._wav_bytes(b"\x01\x00" * 10),
+ self._wav_bytes(b"\x02\x00" * 20),
+ self._wav_bytes(b"\x03\x00" * 30)]
+ with patch.object(config, "CHUNK_SIZE", 10), \
+ patch.object(client, "_request_wav", side_effect=parts) as mock_request:
+ result = client.generate_chunk(text, 1)
+ self.assertEqual(mock_request.call_count, 3)
+ with wave.open(str(Path(result)), "rb") as wav_file:
+ self.assertEqual(wav_file.readframes(wav_file.getnframes()),
+ b"\x01\x00" * 10 + b"\x02\x00" * 20 + b"\x03\x00" * 30)
+
+ def test_stale_chunk_files_are_removed(self):
+ stale = Path(self._tmp.name) / "chunk_0001.mp3"
+ stale.write_bytes(b"old")
+ client = self._make_client()
+ with patch.object(client, "_request_wav", return_value=self._wav_bytes()):
+ client.generate_chunk("Hello.", 1)
+ remaining = sorted(path.name for path in Path(self._tmp.name).glob("chunk_0001.*"))
+ self.assertEqual(remaining, ["chunk_0001.wav"])
+
+
+class AudioCppHeartbeatTests(unittest.TestCase):
+ """The heartbeat label drops 'Chunk' when the server does its own
+ long-form chunking (chunk_text=False, the default)."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
+ self._chunks.start()
+
+ def tearDown(self):
+ self._chunks.stop()
+ self._tmp.cleanup()
+
+ @staticmethod
+ def _client(chunk_text):
+ client = AudioCppTTSClient.__new__(AudioCppTTSClient)
+ client.api_url = "http://127.0.0.1:8080"
+ client.model_id = config.AUDIOCPP_MODEL_ID
+ client.preset_mode = False
+ client.voice = "Vivian"
+ client.language = "English"
+ client._seed = -1
+ client.chunk_text = chunk_text
+ client.family = "qwen3_tts"
+ client.profile = tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE
+ return client
+
+ @staticmethod
+ def _wav_bytes():
+ buffer = io.BytesIO()
+ with wave.open(buffer, "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(tts.SAMPLE_RATE)
+ wav_file.writeframes(b"\x01\x00" * 10)
+ return buffer.getvalue()
+
+ def _run(self, chunk_text):
+ client = self._client(chunk_text)
+
+ def slow_request(*_args, **_kwargs):
+ time.sleep(0.12)
+ return self._wav_bytes()
+
+ buf = io.StringIO()
+ with patch.object(config, "HEARTBEAT_INTERVAL_SECONDS", 0.03), \
+ patch.object(client, "_request_wav_with_retry",
+ side_effect=slow_request), \
+ redirect_stdout(buf):
+ result = client.generate_chunk("Hello.", 1)
+ self.assertTrue(result)
+ return buf.getvalue()
+
+ def test_server_side_chunking_heartbeat_has_no_chunk_word(self):
+ out = self._run(chunk_text=False)
+ self.assertIn("Request still generating", out)
+ self.assertNotIn("Chunk", out)
+
+ def test_client_side_chunking_heartbeat_keeps_chunk_word(self):
+ out = self._run(chunk_text=True)
+ self.assertIn("Chunk 1 still generating", out)
+
+
+class AudioCppTTSClientTruncationTests(unittest.TestCase):
+ """Audio far shorter than its text implies fails the request."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._chunks = patch.object(tts, "CHUNKS_FOLDER", Path(self._tmp.name))
+ self._chunks.start()
+
+ def tearDown(self):
+ self._chunks.stop()
+ self._tmp.cleanup()
+
+ def _make_client(self):
+ client = AudioCppTTSClient.__new__(AudioCppTTSClient)
+ client.api_url = "http://127.0.0.1:8080"
+ client.model_id = config.AUDIOCPP_MODEL_ID
+ client.preset_mode = True
+ client.voice = "narrator"
+ client.language = "English"
+ client._seed = -1
+ client.chunk_text = True
+ client.family = "qwen3_tts"
+ client.profile = tts.AUDIOCPP_FAMILY_PROFILES["qwen3_tts"]
+ return client
+
+ @staticmethod
+ def _wav_bytes(frames):
+ buffer = io.BytesIO()
+ with wave.open(buffer, "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(tts.SAMPLE_RATE)
+ wav_file.writeframes(frames)
+ return buffer.getvalue()
+
+ def test_full_length_wav_passes(self):
+ client = self._make_client()
+ text = " ".join(f"word{i}" for i in range(12))
+ # 12 words -> expected 4.8s, half is 2.4s -> 2.5s of audio passes.
+ wav = self._wav_bytes(b"\x01\x00" * int(2.5 * tts.SAMPLE_RATE))
+ with patch.object(client, "_request_wav", return_value=wav):
+ result = client.generate_chunk(text, 1)
+ self.assertIsNotNone(result)
+
+
+class BackendWiringTests(unittest.TestCase):
+ """AudiobookConverter wiring for the --backend selector."""
+
+ def test_faster_backend_uses_faster_client_without_reference(self):
+ with patch("converter.converter.FasterTTSClient") as mock_faster, \
+ patch("converter.converter.QwenTTSClient") as mock_qwen, \
+ patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_FASTER, voice="narrator")
+ mock_faster.assert_called_once_with(voice="narrator")
+ mock_qwen.assert_not_called()
+ mock_audiocpp.assert_not_called()
+
+ def test_audiocpp_backend_with_voice_uses_audiocpp_client(self):
+ with patch("converter.converter.FasterTTSClient") as mock_faster, \
+ patch("converter.converter.QwenTTSClient") as mock_qwen, \
+ patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_AUDIOCPP, voice="narrator",
+ language="ja")
+ mock_audiocpp.assert_called_once_with(voice="narrator", language="Japanese",
+ chunk_text=False, model_id=None,
+ instructions=None,
+ request_options={})
+ mock_faster.assert_not_called()
+ mock_qwen.assert_not_called()
+
+ def test_audiocpp_backend_without_voice_uses_audiocpp_client(self):
+ with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
+ backend=tts.BACKEND_AUDIOCPP)
+ mock_audiocpp.assert_called_once_with(voice=None, language=config.LANGUAGE,
+ chunk_text=False, model_id=None,
+ instructions=None,
+ request_options={})
+
+ def test_audiocpp_backend_chunk_flag_forces_client_chunking(self):
+ with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
+ converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_AUDIOCPP,
+ voice="narrator", chunk=True)
+ mock_audiocpp.assert_called_once_with(voice="narrator",
+ language=config.LANGUAGE,
+ chunk_text=True, model_id=None,
+ instructions=None,
+ request_options={})
+ self.assertTrue(converter.client_chunks)
+
+ def test_audiocpp_backend_model_id_is_wired_through(self):
+ with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_AUDIOCPP, voice="narrator",
+ model_id="higgs")
+ mock_audiocpp.assert_called_once_with(
+ voice="narrator", language=config.LANGUAGE,
+ chunk_text=False, model_id="higgs", instructions=None,
+ request_options={})
+
+ def test_audiocpp_backend_instructions_and_options_are_wired_through(self):
+ with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
+ backend=tts.BACKEND_AUDIOCPP,
+ instructions="A warm adult narrator",
+ request_options={"emotion": "neutral",
+ "speed": "1.1"})
+ mock_audiocpp.assert_called_once_with(
+ voice=None, language=config.LANGUAGE,
+ chunk_text=False, model_id=None,
+ instructions="A warm adult narrator",
+ request_options={"emotion": "neutral", "speed": "1.1"})
+
+ def test_qwen_backend_uses_qwen_client(self):
+ with patch("converter.converter.FasterTTSClient") as mock_faster, \
+ patch("converter.converter.QwenTTSClient") as mock_qwen, \
+ patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
+ backend=tts.BACKEND_QWEN)
+ mock_qwen.assert_called_once()
+ mock_faster.assert_not_called()
+ mock_audiocpp.assert_not_called()
+
+ def test_qwen_clone_mode_still_requires_reference(self):
+ with patch("converter.converter.QwenTTSClient"):
+ with self.assertRaises(ValueError):
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_QWEN)
+
+ def test_audiocpp_clone_mode_does_not_require_reference(self):
+ # Cloning is server-side for the audiocpp backend, so the
+ # clone-mode voice can be selected without local reference audio.
+ with patch("converter.converter.AudioCppTTSClient"):
+ converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_AUDIOCPP,
+ voice="narrator")
+ self.assertIsNone(converter.voice_clone_ref_audio)
+
+ def test_chapter_chunks_audiocpp_default_is_one_request(self):
+ converter = self._audiocpp_converter(voice="narrator")
+ text = " ".join(f"word{i}" for i in range(50))
+ with patch.object(config, "CHUNK_SIZE", 10):
+ self.assertEqual(converter._chapter_chunks(text), [text])
+
+ def test_chapter_chunks_audiocpp_chunk_flag_splits(self):
+ with patch("converter.converter.AudioCppTTSClient"):
+ converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_AUDIOCPP,
+ voice="narrator", chunk=True)
+ text = " ".join(f"word{i}" for i in range(50))
+ with patch.object(config, "CHUNK_SIZE", 10):
+ chunks = converter._chapter_chunks(text)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(chunk.split()) <= 10 for chunk in chunks))
+
+ def test_chapter_chunks_qwen_always_splits(self):
+ with patch("converter.converter.QwenTTSClient"):
+ converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
+ backend=tts.BACKEND_QWEN)
+ text = " ".join(f"word{i}" for i in range(50))
+ with patch.object(config, "CHUNK_SIZE", 10):
+ chunks = converter._chapter_chunks(text)
+ self.assertGreater(len(chunks), 1)
+
+ def test_faster_backend_still_validates_other_settings(self):
+ with patch("converter.converter.FasterTTSClient"):
+ with self.assertRaises(ValueError):
+ AudiobookConverter(backend=tts.BACKEND_FASTER, speed=0)
+ with self.assertRaises(ValueError):
+ AudiobookConverter(backend=tts.BACKEND_FASTER, language="klingon")
+
+ def test_audiocpp_backend_still_validates_other_settings(self):
+ with patch("converter.converter.AudioCppTTSClient"):
+ with self.assertRaises(ValueError):
+ AudiobookConverter(backend=tts.BACKEND_AUDIOCPP, speed=0)
+ with self.assertRaises(ValueError):
+ AudiobookConverter(backend=tts.BACKEND_AUDIOCPP, language="klingon")
+
+ def _faster_converter(self, voice=None):
+ with patch("converter.converter.FasterTTSClient"):
+ return AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_FASTER, voice=voice)
+
+ def _audiocpp_converter(self, voice=None):
+ with patch("converter.converter.AudioCppTTSClient"):
+ return AudiobookConverter(
+ voice_mode=tts.VOICE_MODE_CLONE if voice else tts.VOICE_MODE_CUSTOM,
+ backend=tts.BACKEND_AUDIOCPP, voice=voice)
+
+ def test_narrator_tag_uses_faster_voice_name(self):
+ converter = self._faster_converter(voice="male_richard_poe")
+ self.assertEqual(converter._narrator_tag(), "male_richard_poe")
+
+ def test_narrator_tag_falls_back_to_config_voice(self):
+ converter = self._faster_converter()
+ self.assertEqual(converter._narrator_tag(), config.FASTER_VOICE)
+
+ def test_narrator_tag_audiocpp_uses_voice_name(self):
+ converter = self._audiocpp_converter(voice="female_narrator")
+ self.assertEqual(converter._narrator_tag(), "female_narrator")
+
+ def test_narrator_tag_audiocpp_falls_back_to_speaker(self):
+ converter = self._audiocpp_converter()
+ self.assertEqual(converter._narrator_tag(), "Vivian")
+
+ def test_banner_and_narrator_work_without_reference_audio(self):
+ converter = self._faster_converter(voice="male_richard_poe")
+ converter._print_banner() # must not raise (regression: Path(None))
+ self.assertIsNone(converter.voice_clone_ref_audio)
+
+ def test_audiocpp_banner_prints_without_reference_audio(self):
+ converter = self._audiocpp_converter(voice="narrator")
+ converter._print_banner() # must not raise
+ converter = self._audiocpp_converter()
+ converter._print_banner()
+
+ def test_audiocpp_banner_prints_model_family(self):
+ from contextlib import redirect_stdout
+ converter = self._audiocpp_converter(voice="narrator")
+ converter.tts.family = "higgs_audio_tts"
+ buffer = io.StringIO()
+ with redirect_stdout(buffer):
+ converter._print_banner()
+ self.assertIn("higgs_audio_tts", buffer.getvalue())
+
+ def test_non_faster_narrator_tag_unchanged(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ ref = Path(tmp) / "ref.wav"
+ ref.write_bytes(b"x")
+ with patch("converter.converter.QwenTTSClient"):
+ converter = AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ voice_clone_ref_audio=str(ref),
+ backend=tts.BACKEND_QWEN)
+ self.assertEqual(converter._narrator_tag(), "ref")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
new file mode 100644
index 0000000..c121d55
--- /dev/null
+++ b/app/tests/test_tui.py
@@ -0,0 +1,661 @@
+"""Tests for the DOS-style curses TUI widgets in tui.py.
+
+The widget module imports curses lazily, so these tests swap the
+curses module for a small fake (patched into sys.modules) and drive
+the widgets with scripted keys against a recording screen. That works
+without a terminal and lets the tests assert exact drawing
+coordinates: theme colors, the left margin of list rows, and that no
+row ever paints over the dialog border.
+"""
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from ui import tui
+
+
+class FakeCurses:
+ """Minimal curses stand-in: attributes, key codes, color pairs."""
+
+ A_BOLD = 1
+ A_DIM = 2
+ A_REVERSE = 4
+
+ COLOR_BLACK = 0
+ COLOR_RED = 1
+ COLOR_GREEN = 2
+ COLOR_YELLOW = 3
+ COLOR_BLUE = 4
+ COLOR_MAGENTA = 5
+ COLOR_CYAN = 6
+ COLOR_WHITE = 7
+
+ KEY_DOWN = 0x101
+ KEY_UP = 0x102
+ KEY_LEFT = 0x103
+ KEY_RIGHT = 0x104
+ KEY_HOME = 0x105
+ KEY_END = 0x106
+ KEY_PPAGE = 0x107
+ KEY_NPAGE = 0x108
+ KEY_BACKSPACE = 0x109
+ KEY_BTAB = 0x10A
+
+ ACS_ULCORNER = "ul"
+ ACS_URCORNER = "ur"
+ ACS_LLCORNER = "ll"
+ ACS_LRCORNER = "lr"
+ ACS_VLINE = "v"
+ ACS_HLINE = "h"
+
+ class error(Exception):
+ pass
+
+ def __init__(self):
+ self.pairs = {} # pair number -> (fg, bg)
+ self.colors = True
+
+ def has_colors(self):
+ return self.colors
+
+ def start_color(self):
+ pass
+
+ def init_pair(self, number, fg, bg):
+ self.pairs[number] = (fg, bg)
+
+ def color_pair(self, number):
+ return number << 8
+
+ def curs_set(self, visibility):
+ pass
+
+ def endwin(self):
+ pass
+
+
+class FakeScreen:
+ """Recording curses window; getch() replays scripted keys."""
+
+ def __init__(self, keys=(), width=80, height=24):
+ self.keys = list(keys)
+ self.width = width
+ self.height = height
+ self.strings = [] # (y, x, text, attr) from addstr
+ self.chars = [] # (y, x, ch, attr) from addch
+
+ def getmaxyx(self):
+ return self.height, self.width
+
+ def erase(self):
+ pass
+
+ def refresh(self):
+ pass
+
+ def bkgd(self, ch, attr):
+ pass
+
+ def addstr(self, y, x, text, attr=0):
+ self.strings.append((y, x, text, attr))
+
+ def addch(self, y, x, ch, attr=0):
+ self.chars.append((y, x, ch, attr))
+
+ def hline(self, y, x, ch, n, attr=0):
+ pass
+
+ def redrawwin(self):
+ pass
+
+ def getch(self):
+ if not self.keys:
+ raise AssertionError("the script ran out of keys")
+ return self.keys.pop(0)
+
+
+class TuiTestCase(unittest.TestCase):
+ """Base class: fresh theme + fake curses module for every test."""
+
+ def setUp(self):
+ tui._THEME.clear()
+ self.curses = FakeCurses()
+ self.screen = FakeScreen()
+ patcher = patch.dict(sys.modules, {"curses": self.curses})
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ self.addCleanup(tui._THEME.clear)
+
+ def dialog_box(self, screen=None):
+ """(x0, x_right) border columns of the drawn dialog."""
+ screen = screen or self.screen
+ corners = screen.chars[:2]
+ self.assertEqual(corners[0][2], FakeCurses.ACS_ULCORNER)
+ self.assertEqual(corners[1][2], FakeCurses.ACS_URCORNER)
+ return corners[0][1], corners[1][1]
+
+ def assert_inside_border(self, screen=None):
+ """No drawn string may reach the right border column."""
+ screen = screen or self.screen
+ _, x_right = self.dialog_box(screen)
+ for _, x, text, _ in screen.strings:
+ self.assertLessEqual(
+ x + len(text), x_right,
+ f"{text!r} painted over the border at x={x}")
+
+
+class ThemeTests(TuiTestCase):
+ def test_desktop_and_message_backgrounds_are_black(self):
+ frame = tui.Frame(self.screen, "Title", "footer")
+ theme, pairs = frame.theme, self.curses.pairs
+ for name in ("desktop", "border", "title", "ok", "warn", "err",
+ "info", "input", "check", "accent"):
+ fg, bg = pairs[theme[name] >> 8]
+ self.assertEqual(bg, FakeCurses.COLOR_BLACK, name)
+
+ def test_cursor_bar_and_active_button_stand_out(self):
+ frame = tui.Frame(self.screen, "Title", "footer")
+ theme, pairs = frame.theme, self.curses.pairs
+ fg, bg = pairs[theme["bar"] >> 8]
+ self.assertEqual((fg, bg),
+ (FakeCurses.COLOR_BLACK, FakeCurses.COLOR_CYAN))
+ fg, bg = pairs[theme["btn_on"] >> 8]
+ self.assertEqual((fg, bg),
+ (FakeCurses.COLOR_BLACK, FakeCurses.COLOR_GREEN))
+
+ def test_without_colors_theme_uses_plain_attributes(self):
+ self.curses.colors = False
+ frame = tui.Frame(self.screen, "Title", "footer")
+ self.assertEqual(self.curses.pairs, {})
+ self.assertEqual(frame.theme["desktop"], 0)
+ self.assertEqual(frame.theme["title"], FakeCurses.A_BOLD)
+
+
+class MenuTests(TuiTestCase):
+ OPTIONS = [("first option", "one"), ("second option", "two")]
+
+ def test_option_rows_left_justified_help_centered(self):
+ screen = FakeScreen(keys=[10])
+ value = tui.menu(screen, "Pick one", self.OPTIONS,
+ help_lines=["Help text"])
+ self.assertEqual(value, "one")
+ x0, x_right = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ for label in ("first option", "second option"):
+ x = next(x for _, x, text, _ in screen.strings if text == label)
+ self.assertEqual(x, margin, label)
+ inner_w = x_right - x0 - 1
+ help_x = next(x for _, x, text, _ in screen.strings
+ if text == "Help text")
+ self.assertEqual(help_x, x0 + 1 + (inner_w - len("Help text")) // 2)
+ self.assertGreater(help_x, margin)
+ self.assert_inside_border(screen)
+
+ def test_up_wraps_around_to_last_option(self):
+ screen = FakeScreen(keys=[FakeCurses.KEY_UP, 10])
+ value = tui.menu(screen, "Pick one", self.OPTIONS)
+ self.assertEqual(value, "two")
+
+ def test_no_option_painted_over_the_border(self):
+ screen = FakeScreen(keys=[FakeCurses.KEY_END, 10])
+ tui.menu(screen, "Pick", [("a" * 60, "a"), ("b", "b")])
+ self.assert_inside_border(screen)
+
+ def test_empty_options_rejected(self):
+ with self.assertRaises(ValueError):
+ tui.menu(self.screen, "Pick", [])
+
+ def test_esc_returns_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.menu(screen, "Pick", self.OPTIONS, back_value=marker),
+ marker)
+
+ def test_q_still_aborts_with_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q")])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.menu(screen, "Pick", self.OPTIONS, back_value=marker)
+
+
+class MenuTableTests(TuiTestCase):
+ """The optional status table: aligned columns and colored statuses."""
+
+ ROWS = [("audio.cpp", "not installed", "err"),
+ ("qwen-tts", "installed", "warn"),
+ ("faster-qwen3-tts", "running", "ok")]
+
+ def test_name_column_left_aligned_at_margin(self):
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Hub", [("Quit", "quit")],
+ table_title="Backend status", table_rows=self.ROWS)
+ x0, _ = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ for name, _, _ in self.ROWS:
+ x = next(x for _, x, text, _ in screen.strings
+ if text.rstrip() == name)
+ self.assertEqual(x, margin, name)
+
+ def test_status_column_aligned_at_one_fixed_offset(self):
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Hub", [("Quit", "quit")],
+ table_title="Backend status", table_rows=self.ROWS)
+ x0, _ = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ name_w = max(len(name) for name, _, _ in self.ROWS)
+ expected_x = margin + name_w # the " status" segment starts here
+ for _, status, _ in self.ROWS:
+ x = next(x for _, x, text, _ in screen.strings
+ if text.strip() == status)
+ self.assertEqual(x, expected_x, status)
+
+ def test_status_text_uses_the_theme_kind_color(self):
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Hub", [("Quit", "quit")],
+ table_title="Backend status", table_rows=self.ROWS)
+ want = {"err": tui._THEME["err"], "warn": tui._THEME["warn"],
+ "ok": tui._THEME["ok"]}
+ for _, status, kind in self.ROWS:
+ attr = next(a for _, _, text, a in screen.strings
+ if text.strip() == status)
+ self.assertEqual(attr, want[kind], status)
+
+ def test_optional_name_kind_colors_the_name_column(self):
+ # 4-element rows: the 4th value is a theme kind for the name.
+ rows = [("gone", "unavailable", "err", "dim"),
+ ("here", "running", "ok", "body")]
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Hub", [("Quit", "quit")], table_rows=rows)
+ drawn = {text.rstrip(): attr for _, _, text, attr in screen.strings}
+ self.assertEqual(drawn["gone"], tui._THEME["dim"])
+ self.assertEqual(drawn["here"], tui._THEME["body"])
+
+ def test_three_element_rows_default_to_body_names(self):
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Hub", [("Quit", "quit")],
+ table_title="Backend status", table_rows=self.ROWS)
+ for name, _, _ in self.ROWS:
+ attr = next(a for _, _, text, a in screen.strings
+ if text.rstrip() == name)
+ self.assertEqual(attr, tui._THEME["body"], name)
+
+ def test_table_title_is_dim_and_left_aligned(self):
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Hub", [("Quit", "quit")],
+ table_title="Backend status", table_rows=self.ROWS)
+ x0, _ = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ x, attr = next((x, a) for _, x, text, a in screen.strings
+ if text == "Backend status")
+ self.assertEqual(x, margin)
+ self.assertEqual(attr, tui._THEME["dim"])
+
+ def test_table_does_not_paint_over_the_border(self):
+ screen = FakeScreen(keys=[10])
+ tui.menu(screen, "Hub", [("Quit", "quit")],
+ table_title="Backend status", table_rows=self.ROWS)
+ self.assert_inside_border(screen)
+
+
+class ConfirmTests(TuiTestCase):
+ def test_tab_switches_and_enter_activates(self):
+ screen = FakeScreen(keys=[9, 10])
+ self.assertFalse(tui.confirm(screen, "Overwrite?", default=True))
+
+ def test_y_answers_directly(self):
+ screen = FakeScreen(keys=[ord("y")])
+ self.assertTrue(tui.confirm(screen, "Overwrite?", default=False))
+
+ def test_enter_takes_the_default(self):
+ screen = FakeScreen(keys=[10])
+ self.assertTrue(tui.confirm(screen, "Overwrite?", default=True))
+
+ def test_esc_aborts_without_cancel_value(self):
+ screen = FakeScreen(keys=[27])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.confirm(screen, "Overwrite?", default=True)
+
+ def test_esc_returns_cancel_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.confirm(screen, "Overwrite?", default=True,
+ cancel_value=marker),
+ marker)
+
+ def test_q_returns_cancel_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q")])
+ self.assertIs(
+ tui.confirm(screen, "Overwrite?", default=True,
+ cancel_value=marker),
+ marker)
+
+
+class LineEditTests(TuiTestCase):
+ def test_typing_backspace_and_enter(self):
+ keys = [ord("c"), ord("d"), FakeCurses.KEY_BACKSPACE, 10]
+ screen = FakeScreen(keys=keys)
+ value = tui.line_edit(screen, "Edit", "ab")
+ self.assertEqual(value, "abc")
+
+ def test_validation_error_keeps_editing(self):
+ keys = [ord("x"), 10, FakeCurses.KEY_BACKSPACE, 10]
+ screen = FakeScreen(keys=keys)
+ value = tui.line_edit(
+ screen, "Edit", "5",
+ validate=lambda s: None if s.isdigit() else "digits only")
+ self.assertEqual(value, "5")
+
+ def test_esc_returns_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.line_edit(screen, "Edit", "text", back_value=marker),
+ marker)
+
+ def test_q_stays_typeable_with_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q"), 10])
+ value = tui.line_edit(screen, "Edit", "te", back_value=marker)
+ self.assertEqual(value, "teq")
+
+
+class FormTests(TuiTestCase):
+ def _fields(self):
+ # Fresh dicts each call: form() edits field values in place, and a
+ # shared class attribute would leak edits between tests.
+ return [
+ {"key": "fmt", "label": "Format", "kind": "choice",
+ "value": "m4b", "choices": ["mp3", "m4b", "ogg"]},
+ {"key": "chunk", "label": "Chunk", "kind": "text",
+ "value": "250"},
+ ]
+
+ def test_save_returns_current_values(self):
+ screen = FakeScreen(keys=[9, 10]) # Tab -> Save, Enter
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250"})
+
+ def test_cancel_returns_back_value(self):
+ marker = object()
+ # Tab -> buttons, Left -> Cancel, Enter.
+ screen = FakeScreen(keys=[9, FakeCurses.KEY_LEFT, 10])
+ result = tui.form(screen, "Settings", self._fields(),
+ back_value=marker)
+ self.assertIs(result, marker)
+
+ def test_esc_returns_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.form(screen, "Settings", self._fields(), back_value=marker),
+ marker)
+
+ def test_q_still_aborts_with_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q")])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.form(screen, "Settings", self._fields(), back_value=marker)
+
+ def test_choice_field_picks_another_value(self):
+ # Enter opens the choice menu on 'm4b' (index 1); Down moves to
+ # 'ogg', Enter accepts; Tab -> Save, Enter.
+ screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, 9, 10])
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "ogg", "chunk": "250"})
+
+ def test_text_field_edits_then_saves(self):
+ # Down to the text row, Enter opens the editor, type 'x', Enter,
+ # then Tab -> Save, Enter.
+ screen = FakeScreen(
+ keys=[FakeCurses.KEY_DOWN, 10, ord("x"), 10, 9, 10])
+ result = tui.form(screen, "Settings", self._fields())
+ self.assertEqual(result, {"fmt": "m4b", "chunk": "250x"})
+
+ def test_validation_error_refuses_save_then_recovers(self):
+ fields = [
+ {"key": "chunk", "label": "Chunk", "kind": "text", "value": "bad",
+ "validate": lambda s: None if s.isdigit() else "digits only"},
+ {"key": "fmt", "label": "Format", "kind": "text", "value": "x"},
+ ]
+ # Tab->Save(Enter) fails, the flash consumes the next key; Enter
+ # reopens the editor, Ctrl-U clears 'bad', type '120', Enter; then
+ # Tab->Save(Enter).
+ keys = [9, 10, 10, 10, 21, ord("1"), ord("2"), ord("0"), 10, 9, 10]
+ screen = FakeScreen(keys=keys)
+ result = tui.form(screen, "Settings", fields)
+ self.assertEqual(result, {"chunk": "120", "fmt": "x"})
+
+ def test_rows_left_justified_inside_the_border(self):
+ screen = FakeScreen(keys=[9, 10])
+ tui.form(screen, "Settings", self._fields())
+ self.assert_inside_border(screen)
+
+ def test_empty_fields_rejected(self):
+ with self.assertRaises(ValueError):
+ tui.form(self.screen, "Settings", [])
+
+
+def _accept_audio_cpp(entry: Path):
+ """auto_select callback that accepts an 'audio.cpp' checkout root."""
+ if entry.name == "audio.cpp" and (entry / "model_specs").is_dir():
+ return entry
+ return None
+
+
+class BrowseDirectoryTests(TuiTestCase):
+ def setUp(self):
+ super().setUp()
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.root = Path(tmp.name)
+ for name in ("alpha", "beta", "zulu"):
+ (self.root / name).mkdir()
+ (self.root / "noise.txt").write_text("x", encoding="utf-8")
+
+ def _checkout_tree(self):
+ """A temp dir containing an 'audio.cpp' checkout + a sibling dir."""
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ root = Path(tmp.name)
+ (root / "audio.cpp").mkdir()
+ (root / "audio.cpp" / "model_specs").mkdir()
+ (root / "other").mkdir()
+ return root
+
+ def test_listing_rows_left_justified(self):
+ screen = FakeScreen(keys=[10])
+ chosen = tui.browse_directory(screen, "Pick", start=self.root)
+ self.assertEqual(chosen, self.root.resolve())
+ x0, x_right = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ for label in ("[ Use this directory ]", "..",
+ "alpha/", "beta/", "zulu/"):
+ x = next(x for _, x, text, _ in screen.strings if text == label)
+ self.assertEqual(x, margin, label)
+ drawn = " ".join(text for _, _, text, _ in screen.strings)
+ self.assertNotIn("noise.txt", drawn)
+ self.assert_inside_border(screen)
+
+ def test_enter_opens_highlighted_subdirectory(self):
+ keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10, 10]
+ screen = FakeScreen(keys=keys)
+ chosen = tui.browse_directory(screen, "Pick", start=self.root)
+ self.assertEqual(chosen, (self.root / "alpha").resolve())
+ self.assert_inside_border(screen)
+
+ def test_enter_auto_accepts_matching_subdir(self):
+ root = self._checkout_tree()
+ # sel 0 = [ Use this directory ], 1 = .., 2 = audio.cpp/
+ keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, 10]
+ screen = FakeScreen(keys=keys)
+ chosen = tui.browse_directory(screen, "Pick", start=root,
+ auto_select=_accept_audio_cpp)
+ self.assertEqual(chosen, (root / "audio.cpp").resolve())
+
+ def test_right_auto_accepts_matching_subdir(self):
+ root = self._checkout_tree()
+ keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_RIGHT]
+ screen = FakeScreen(keys=keys)
+ chosen = tui.browse_directory(screen, "Pick", start=root,
+ auto_select=_accept_audio_cpp)
+ self.assertEqual(chosen, (root / "audio.cpp").resolve())
+
+ def test_use_this_directory_ignores_auto_select(self):
+ # Enter on '[ Use this directory ]' must accept the listed dir
+ # without ever consulting auto_select.
+ root = self._checkout_tree()
+ calls = []
+
+ def callback(entry):
+ calls.append(entry)
+ return entry # would auto-accept any subdir if consulted
+
+ screen = FakeScreen(keys=[10])
+ chosen = tui.browse_directory(screen, "Pick", start=root,
+ auto_select=callback)
+ self.assertEqual(chosen, root.resolve())
+ self.assertEqual(calls, [])
+
+ def test_auto_select_returning_none_descends_normally(self):
+ # A non-matching subdir (or a None reply) keeps browsing: Enter
+ # descends into it, then '[ Use this directory ]' accepts it.
+ root = self._checkout_tree()
+ calls = []
+
+ def callback(entry):
+ calls.append(entry)
+ return None
+
+ # sel 0 = use, 1 = .., 2 = audio.cpp/, 3 = other/
+ keys = [FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ FakeCurses.KEY_DOWN, 10, 10]
+ screen = FakeScreen(keys=keys)
+ chosen = tui.browse_directory(screen, "Pick", start=root,
+ auto_select=callback)
+ self.assertEqual(chosen, (root / "other").resolve())
+ # auto_select was consulted only for the highlighted 'other/' row.
+ self.assertEqual([p.name for p in calls], ["other"])
+
+ def test_esc_returns_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.browse_directory(screen, "Pick", start=self.root,
+ back_value=marker),
+ marker)
+
+ def test_q_still_aborts_with_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q")])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.browse_directory(screen, "Pick", start=self.root,
+ back_value=marker)
+
+
+class CheckboxTreeTests(TuiTestCase):
+ FAMILIES = [
+ {"label": "Family one", "detail": "tts",
+ "options": [{"key": "pkg-a", "label": "pkg-a", "recommended": True},
+ {"key": "pkg-b", "label": "pkg-b", "recommended": False}]},
+ {"label": "Family two", "detail": "tts, cloning",
+ "options": [{"key": "pkg-c", "label": "pkg-c", "recommended": True}]},
+ ]
+
+ def test_nothing_selected_by_default(self):
+ # Nothing is pre-checked: Enter alone flashes and waits, and a
+ # selection only happens after Space checks an option. The first
+ # Enter and the flash each consume a key.
+ screen = FakeScreen(keys=[10, 10, ord(" "), 10])
+ picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES)
+ self.assertEqual(picked, [(0, "pkg-a")])
+
+ def test_rows_left_justified_inside_the_border(self):
+ screen = FakeScreen(keys=[ord(" "), 10])
+ tui.checkbox_tree(screen, "Pick models", self.FAMILIES)
+ x0, x_right = self.dialog_box(screen)
+ margin = x0 + 1 + tui.Frame.LIST_MARGIN
+ strings = sorted((y, x, text) for y, x, text, _ in screen.strings)
+ # Family row: its checkbox starts at the margin.
+ y_family = next(y for y, _, text in strings if text == "- Family one")
+ family_box_x = next(x for y, x, text in strings
+ if y == y_family and text == "[x] ")
+ self.assertEqual(family_box_x, margin)
+ # Option row: its checkbox sits one indent (2 columns) deeper.
+ option_box_x = next(x for y, x, text in strings
+ if text == "[x] " and y != y_family)
+ self.assertEqual(option_box_x, margin + 4)
+ self.assert_inside_border(screen)
+
+ def test_long_indented_options_do_not_paint_over_the_border(self):
+ # A wide screen keeps the dialog width driven by the option row
+ # itself (not the footer), the geometry where the old centered
+ # segments drawing could paint over the right border.
+ families = [{"label": "F", "detail": "tts",
+ "options": [{"key": "long", "label": "x" * 60,
+ "recommended": True}]}]
+ screen = FakeScreen(keys=[ord(" "), 10], width=120)
+ picked = tui.checkbox_tree(screen, "Pick", families)
+ self.assertEqual(picked, [(0, "long")])
+ self.assert_inside_border(screen)
+
+ def test_space_checks_then_enter_accepts(self):
+ screen = FakeScreen(keys=[ord(" "), 10])
+ picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES)
+ self.assertEqual(picked, [(0, "pkg-a")])
+
+ def test_empty_families_rejected(self):
+ with self.assertRaises(ValueError):
+ tui.checkbox_tree(self.screen, "Pick", [])
+
+ def test_esc_returns_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[27])
+ self.assertIs(
+ tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
+ back_value=marker),
+ marker)
+
+ def test_q_still_aborts_with_back_value(self):
+ marker = object()
+ screen = FakeScreen(keys=[ord("q")])
+ with self.assertRaises(tui.WizardCancelled):
+ tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
+ back_value=marker)
+
+
+class SuspendTests(TuiTestCase):
+ """tui.suspend leaves curses, runs code, then repaints."""
+
+ def test_suspend_runs_block_and_restores(self):
+ ran = []
+ with tui.suspend(self.screen):
+ ran.append("inside")
+ self.assertEqual(ran, ["inside"])
+
+ def test_suspend_always_restores_on_exception(self):
+ class Boom(Exception):
+ pass
+ with self.assertRaises(Boom):
+ with tui.suspend(self.screen):
+ raise Boom()
+
+
+class FlashTests(TuiTestCase):
+ """tui.flash shows a notice until any key is pressed."""
+
+ def test_notice_dismissed_by_any_key(self):
+ screen = FakeScreen(keys=[10])
+ # Should return (None) after consuming one key; not raise.
+ tui.flash(screen, "a notice", kind="warn")
+ self.assertEqual(screen.keys, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/ui/__init__.py b/app/ui/__init__.py
new file mode 100644
index 0000000..4986a10
--- /dev/null
+++ b/app/ui/__init__.py
@@ -0,0 +1,9 @@
+"""The TUI frontend for the audiobook generator.
+
+``ui.tui`` is the DOS-style curses widget library, and ``ui.hub`` is the
+main menu the user sees when running ``audiobook.py`` with no arguments
+(set up/configure backends, convert the input directory). It is the only
+entry point for the interactive workflow; everything else under
+``backends/`` and ``converter/`` is library code driven by it or by the
+``audiobook.py`` CLI flags.
+"""
diff --git a/app/ui/hub.py b/app/ui/hub.py
new file mode 100644
index 0000000..6a94096
--- /dev/null
+++ b/app/ui/hub.py
@@ -0,0 +1,637 @@
+#!/usr/bin/env python3
+"""The TUI main menu for the audiobook generator (run via ``audiobook.py``).
+
+The hub is the single entry point for the whole workflow: it detects which
+backends are already set up and offers to convert the input directory with
+one of them, set up a new backend, or configure an existing one.
+Each backend's setup wizard runs in its own curses session, so the hub
+collects a "command" inside its own wrapper, returns to the plain terminal,
+and then dispatches — no nested curses sessions.
+
+Esc on the main menu quits the hub. Esc inside a sub-menu falls back to the
+main menu.
+"""
+
+import json
+import re
+from pathlib import Path
+from typing import Optional, Tuple
+
+import audiobook
+from backends import (
+ REGISTRY,
+ BackendStatus,
+ ServerSpec,
+ common,
+ detect_all,
+ get,
+ servers,
+)
+from backends import audiocpp as audiocpp_backend
+from backends import faster as faster_backend
+from converter import config
+from converter.converter import AUDIO_FORMATS
+from converter.tts import (
+ AUDIOCPP_FAMILY_QWEN3_TTS,
+ BACKEND_AUDIOCPP,
+ BACKEND_FASTER,
+ BACKEND_QWEN,
+ normalize_language,
+)
+from ui import tui
+
+_GO_BACK = object()
+
+
+def run() -> int:
+ """Run the hub menu loop until the user quits. Returns exit code."""
+ import curses
+ while True:
+ try:
+ command = curses.wrapper(_hub_menu)
+ except tui.WizardCancelled:
+ return 0
+ except KeyboardInterrupt:
+ return 130
+ if command is None:
+ return 0
+ kind = command[0]
+ if kind == "quit":
+ return 0
+ if kind == "setup":
+ info = get(command[1])
+ if info is not None:
+ info.setup_tui()
+ elif kind == "configure":
+ info = get(command[1])
+ if info is not None and command[2] < len(info.configure_actions):
+ info.configure_actions[command[2]].run()
+ elif kind == "convert":
+ _run_conversion(command[1], command[2])
+ elif kind == "server":
+ _run_server_action(command[1], command[2])
+
+
+def _hub_menu(stdscr) -> Optional[tuple]:
+ """Show the main menu; return a command tuple, or None to quit."""
+ while True:
+ statuses = detect_all()
+ options = [("Set up a backend...", "setup")]
+ if any(st.installed or st.running for st in statuses):
+ options.insert(0, ("Convert books...", "convert"))
+ options.append(("Configure a backend...", "configure"))
+ options.append(("Server...", "server"))
+ options.append(("Settings...", "settings"))
+ options.append(("Quit", "quit"))
+ rows = [(st.label, *_status_mark(st)) for st in statuses]
+ choice = tui.menu(
+ stdscr, "tts-audiobook-generator", options,
+ table_title="Backend status", table_rows=rows)
+ if choice is None or choice == "quit":
+ return None
+ if choice == "convert":
+ cmd = _convert_menu(stdscr, statuses)
+ if cmd is not None:
+ return cmd
+ elif choice == "setup":
+ cmd = _setup_menu(stdscr, statuses)
+ if cmd is not None:
+ return cmd
+ elif choice == "configure":
+ cmd = _configure_menu(stdscr, statuses)
+ if cmd is not None:
+ return cmd
+ elif choice == "server":
+ cmd = _server_menu(stdscr, statuses)
+ if cmd is not None:
+ return cmd
+ elif choice == "settings":
+ _settings_menu(stdscr)
+
+
+def _setup_menu(stdscr, statuses) -> Optional[tuple]:
+ """Pick a backend to set up. Returns ("setup", key) or None to go back."""
+ by_key = {st.key: st for st in statuses}
+ options = [(f"{info.label} ({_status_mark(by_key.get(info.key))[0]})",
+ info.key) for info in REGISTRY]
+ choice = tui.menu(stdscr, "Set up a backend", options,
+ back_value=_GO_BACK,
+ help_lines=["Clone/build/install a backend so you can "
+ "convert with it."])
+ if choice is _GO_BACK or choice is None:
+ return None
+ return ("setup", choice)
+
+
+def _configure_menu(stdscr, statuses) -> Optional[tuple]:
+ """Pick an installed backend and one of its configure actions."""
+ by_key = {st.key: st for st in statuses}
+ installed = [info for info in REGISTRY
+ if by_key.get(info.key) is not None
+ and by_key[info.key].installed]
+ if not installed:
+ tui.flash(stdscr, "No backend is installed yet — use 'Set up a "
+ "backend' first.")
+ return None
+ options = [(info.label, info.key) for info in installed]
+ key = tui.menu(stdscr, "Configure a backend", options, back_value=_GO_BACK)
+ if key is _GO_BACK or key is None:
+ return None
+ info = get(key)
+ actions = info.configure_actions
+ choice = tui.menu(
+ stdscr, f"Configure {info.label}",
+ [(action.label, index) for index, action in enumerate(actions)],
+ back_value=_GO_BACK)
+ if choice is _GO_BACK or choice is None:
+ return None
+ return ("configure", key, choice)
+
+
+def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]:
+ """Map a backend's state to (status_text, status_kind, name_kind).
+
+ 'running' (green/ok) takes priority — an external server is already up;
+ otherwise 'installed' (orange/warn) when the backend is present on disk,
+ or 'unavailable' (red/err). A backend that is neither installed nor
+ running is unusable, so its name is dimmed (NAME_KIND).
+ CURSES has no true orange, so the theme's yellow 'warn' is used; it
+ renders amber/orange on most terminals.
+ """
+ if status is not None and status.running:
+ return ("running", "ok", "body")
+ if status is not None and status.installed:
+ return ("installed", "warn", "body")
+ return ("unavailable", "err", "dim")
+
+
+def _convert_menu(stdscr, statuses) -> Optional[tuple]:
+ """Pick an available backend and collect per-backend run settings."""
+ available = [st for st in statuses if st.ready or st.running]
+ options = [(st.label, st.key) for st in available]
+ if not available:
+ choice = tui.menu(
+ stdscr, "No backend is available",
+ [("Set up a backend...", "__setup__")],
+ help_lines=["Set up a backend (clone/build/configure) before "
+ "converting."])
+ if choice == "__setup__":
+ return _setup_menu(stdscr, statuses)
+ return None
+ options.append(("Set up a backend...", "__setup__"))
+ key = tui.menu(stdscr, "Convert books with...", options,
+ back_value=_GO_BACK)
+ if key is _GO_BACK or key is None:
+ return None
+ if key == "__setup__":
+ return _setup_menu(stdscr, statuses)
+ if key == BACKEND_AUDIOCPP:
+ cmd = _convert_audiocpp(stdscr, statuses)
+ elif key == BACKEND_QWEN:
+ cmd = _convert_qwen(stdscr)
+ elif key == BACKEND_FASTER:
+ cmd = _convert_faster(stdscr)
+ else:
+ return None
+ if cmd is None:
+ return None
+ _add_autostart(stdscr, cmd, statuses)
+ return cmd
+
+
+def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]:
+ """Collect audio.cpp run settings by reading app/audio.cpp/server.json."""
+ checkout = audiocpp_backend.find_local_checkout()
+ server_json = checkout / "server.json" if checkout else None
+ if not server_json or not server_json.exists():
+ tui.flash(stdscr, "No server.json found in the audio.cpp checkout. "
+ "Run 'Set up a backend' first.")
+ return None
+ try:
+ data = json.loads(server_json.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ tui.flash(stdscr, f"Could not read {server_json}.")
+ return None
+ models = data.get("models") or []
+ if not models:
+ tui.flash(stdscr, "No model entries in server.json. Reconfigure "
+ "audio.cpp first.")
+ return None
+ model_options = [(f"{m.get('id')} ({m.get('family')}, {m.get('task', 'tts')})",
+ m.get("id")) for m in models]
+ model_id = tui.menu(stdscr, "Select the audio.cpp model to use",
+ model_options, back_value=_GO_BACK)
+ if model_id is _GO_BACK or model_id is None:
+ return None
+ entry = next((m for m in models if m.get("id") == model_id), {})
+ family = entry.get("family")
+ task = entry.get("task", "tts")
+
+ # Voice: optional for qwen3_tts (built-in speaker), required otherwise.
+ voice = None
+ voice_dir = data.get("voice_dir")
+ voices = _list_voices(voice_dir) if voice_dir else []
+ if task == "vdes":
+ # Voice design: no voice, instructions required.
+ pass
+ elif family == AUDIOCPP_FAMILY_QWEN3_TTS:
+ # Speaker mode available; voice optional.
+ if voices:
+ opts = [("(built-in speaker)", None)] + [(v, v) for v in voices]
+ voice = tui.menu(stdscr, "Voice", opts, back_value=_GO_BACK)
+ if voice is _GO_BACK:
+ return None
+ else:
+ voice = None
+ else:
+ if not voices:
+ tui.flash(stdscr, f"This model needs a --voice but voice_dir "
+ f"{voice_dir} has no .wav voices. Reconfigure "
+ "audio.cpp or add voices.")
+ return None
+ voice = tui.menu(stdscr, "Select the voice to clone", [(v, v) for v in voices],
+ back_value=_GO_BACK)
+ if voice is _GO_BACK or voice is None:
+ return None
+
+ # Instructions: required for vdes, optional otherwise.
+ instructions = None
+ if task == "vdes":
+ instructions = tui.line_edit(
+ stdscr, "Voice design instructions (required for this model)",
+ config.AUDIOCPP_INSTRUCTIONS,
+ validate=lambda s: None if s.strip()
+ else "Describe the voice, e.g. 'A warm female narrator'",
+ back_value=_GO_BACK)
+ if instructions is _GO_BACK:
+ return None
+ else:
+ instructions = tui.line_edit(
+ stdscr, "Style instructions (optional, blank for none)",
+ config.AUDIOCPP_INSTRUCTIONS, back_value=_GO_BACK)
+ if instructions is _GO_BACK:
+ return None
+ if not instructions.strip():
+ instructions = None
+
+ common_kw = _common_options(stdscr)
+ if common_kw is None:
+ return None
+ return ("convert", BACKEND_AUDIOCPP, {
+ "model_id": model_id, "voice": voice, "instructions": instructions,
+ **common_kw,
+ })
+
+
+def _convert_qwen(stdscr) -> Optional[tuple]:
+ """Collect qwen run settings: built-in speaker or clone a .wav."""
+ mode = tui.menu(
+ stdscr, "qwen-tts mode",
+ [("Custom voice (built-in speaker)", "custom"),
+ ("Voice clone from a .wav file", "clone")],
+ back_value=_GO_BACK,
+ help_lines=[f"Speaker: {config.SPEAKER} (change it via Configure "
+ "qwen-tts)"])
+ if mode is _GO_BACK or mode is None:
+ return None
+ clone = None
+ if mode == "clone":
+ clone = tui.line_edit(
+ stdscr, "Path to a reference .wav (10-15s is ideal)",
+ "",
+ validate=lambda s: None if (s and Path(s).is_file()
+ and s.lower().endswith(".wav"))
+ else "Enter the path to an existing .wav file",
+ back_value=_GO_BACK)
+ if clone is _GO_BACK:
+ return None
+ common_kw = _common_options(stdscr)
+ if common_kw is None:
+ return None
+ return ("convert", BACKEND_QWEN, {"clone": clone, **common_kw})
+
+
+def _convert_faster(stdscr) -> Optional[tuple]:
+ """Collect faster run settings: pick a voice from voices.json."""
+ checkout = faster_backend._checkout()
+ voices_json = checkout / "voices.json"
+ if not voices_json.exists():
+ tui.flash(stdscr, f"No voices.json at {voices_json}. Run 'Set up a "
+ "backend' for faster first.")
+ return None
+ try:
+ voices = json.loads(voices_json.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ tui.flash(stdscr, f"Could not read {voices_json}.")
+ return None
+ if not voices:
+ tui.flash(stdscr, "voices.json has no voices. Reconfigure faster.")
+ return None
+ default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \
+ next(iter(voices))
+ voice = tui.menu(
+ stdscr, "Select the voice to clone",
+ [(k, k) for k in voices],
+ default_index=list(voices).index(default), back_value=_GO_BACK)
+ if voice is _GO_BACK or voice is None:
+ return None
+ common_kw = _common_options(stdscr)
+ if common_kw is None:
+ return None
+ return ("convert", BACKEND_FASTER, {"voice": voice, **common_kw})
+
+
+def _common_options(stdscr) -> Optional[dict]:
+ """Collect output format, speed, single-file, chunk, debug."""
+ fmt_options = [(f, f) for f in AUDIO_FORMATS]
+ fmt_default = AUDIO_FORMATS.index(config.AUDIO_FORMAT) \
+ if config.AUDIO_FORMAT in AUDIO_FORMATS else 0
+ output_format = tui.menu(stdscr, "Output format", fmt_options,
+ default_index=fmt_default, back_value=_GO_BACK)
+ if output_format is _GO_BACK or output_format is None:
+ return None
+ speed_text = tui.line_edit(
+ stdscr, "Playback speed (1.0 = normal)", "1.0",
+ validate=lambda s: None if (_is_float(s) and float(s) > 0)
+ else "Enter a positive number, e.g. 1.0",
+ back_value=_GO_BACK)
+ if speed_text is _GO_BACK:
+ return None
+ single_file = tui.confirm(stdscr, "Combine all chapters into one file?",
+ default=False, cancel_value=_GO_BACK)
+ if single_file is _GO_BACK:
+ return None
+ chunk = tui.confirm(stdscr, "Force client-side chunking (--chunk)?",
+ default=False, cancel_value=_GO_BACK)
+ if chunk is _GO_BACK:
+ return None
+ debug = tui.confirm(stdscr, "Debug mode (dump per-chunk audio/text)?",
+ default=False, cancel_value=_GO_BACK)
+ if debug is _GO_BACK:
+ return None
+ return {
+ "output_format": output_format,
+ "speed": float(speed_text),
+ "single_file": single_file,
+ "chunk": chunk,
+ "debug": debug,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Settings menu (global output options -> app/converter/config.py)
+# ---------------------------------------------------------------------------
+
+def _settings_menu(stdscr) -> None:
+ """Edit the global output settings; Save writes them back to config.py."""
+ fields = [
+ {"key": "audio_format", "label": "Audio format", "kind": "choice",
+ "value": config.AUDIO_FORMAT, "choices": list(AUDIO_FORMATS)},
+ {"key": "audio_bitrate", "label": "Audio bitrate", "kind": "text",
+ "value": config.AUDIO_BITRATE,
+ "validate": _validate_bitrate},
+ {"key": "language", "label": "Language", "kind": "text",
+ "value": config.LANGUAGE, "validate": _validate_language},
+ {"key": "chunk_size", "label": "Chunk size (words)", "kind": "text",
+ "value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size},
+ ]
+ result = tui.form(stdscr, "Settings", fields, back_value=_GO_BACK)
+ if result is None or result is _GO_BACK:
+ return
+ try:
+ _apply_settings(result)
+ except ValueError as exc:
+ tui.flash(stdscr, str(exc), "err")
+ return
+ tui.flash(stdscr, "Settings saved.", "ok")
+
+
+def _validate_bitrate(value: str) -> Optional[str]:
+ """Error message for a blank audio bitrate, or None to accept it."""
+ if value.strip():
+ return None
+ return "Audio bitrate must not be empty"
+
+
+def _validate_language(value: str) -> Optional[str]:
+ """Error message for an unrecognized LANGUAGE, or None to accept it."""
+ try:
+ normalize_language(value)
+ return None
+ except ValueError as exc:
+ return str(exc)
+
+
+def _validate_chunk_size(value: str) -> Optional[str]:
+ """Error message for an invalid CHUNK_SIZE, or None to accept it."""
+ try:
+ number = int(value.strip())
+ except ValueError:
+ return "Enter a whole number of words, e.g. 250"
+ if number < 1:
+ return "Chunk size must be at least 1"
+ return None
+
+
+def _apply_settings(values: dict) -> None:
+ """Write VALUES to app/converter/config.py and reload them in-memory."""
+ chunk_size = int(values["chunk_size"].strip())
+ if chunk_size < 1:
+ raise ValueError("Chunk size must be at least 1")
+ bitrate = values["audio_bitrate"].strip()
+ if not bitrate:
+ raise ValueError("Audio bitrate must not be empty")
+ if values["audio_format"] not in AUDIO_FORMATS:
+ raise ValueError(f"Unsupported audio format: {values['audio_format']}")
+ updates = {
+ "AUDIO_FORMAT": values["audio_format"],
+ "AUDIO_BITRATE": bitrate,
+ "LANGUAGE": normalize_language(values["language"]),
+ "CHUNK_SIZE": chunk_size,
+ }
+ _write_config(updates)
+ for name, value in updates.items():
+ setattr(config, name, value)
+
+
+def _write_config(updates: dict) -> None:
+ """Rewrite the ``NAME = value`` lines for UPDATES in app/converter/config.py.
+
+ Only the value of each named assignment changes: the indentation, the
+ quotes (double, matching the file's style) and any trailing comment on
+ the line are preserved. Every other line is left untouched.
+ """
+ path = Path(config.__file__).resolve()
+ text = path.read_text(encoding="utf-8")
+ for name, value in updates.items():
+ rendered = str(value) if isinstance(value, int) else f'"{value}"'
+ pattern = re.compile(
+ rf"^(\s*{re.escape(name)}\s*=\s*)(\S*)(\s*(#.*))?$",
+ re.MULTILINE)
+ text, count = pattern.subn(
+ lambda m, rendered=rendered:
+ f"{m.group(1)}{rendered}{m.group(3) or ''}", text)
+ if count != 1:
+ raise ValueError(f"Could not find {name} in {path}")
+ path.write_text(text, encoding="utf-8")
+
+
+def _run_conversion(backend: str, kwargs: dict) -> None:
+ """Run a conversion in the plain console (after the TUI returns).
+
+ When the convert menu recorded an ``autostart`` server (the user opted to
+ have the hub start it), spawn it now and abort the conversion if it does
+ not come up. After the conversion, offer to stop a server we started.
+ """
+ autostart = kwargs.pop("autostart", None)
+ status = next((s for s in detect_all() if s.key == backend), None)
+ if status is not None and not status.ready and not status.running:
+ print(f"[WARNING] {status.label} is not fully set up.")
+ if autostart:
+ spec = _find_spec(autostart)
+ if spec is None:
+ print(f"[WARNING] no server named '{autostart}'; continuing")
+ elif not servers.start(spec):
+ print("[ERROR] could not start the server; aborting conversion.")
+ if status is not None and status.launch_hint:
+ print("Start it manually and run the conversion again:")
+ print(f" {status.launch_hint}")
+ return
+ elif status is not None and not status.running and status.launch_hint:
+ print("[INFO] Make sure the server is running. Start it with:")
+ print(f" {status.launch_hint}")
+ try:
+ audiobook.convert(backend=backend, **kwargs)
+ finally:
+ if autostart:
+ _maybe_stop_server(autostart)
+
+
+def _maybe_stop_server(name: str) -> None:
+ """Ask (in the plain console) whether to stop a server we auto-started."""
+ try:
+ ans = input(f"\n[?] Stop the '{name}' server now? [y/N] ").strip().lower()
+ except EOFError:
+ return
+ if ans in ("y", "yes"):
+ servers.stop(name)
+
+
+def _add_autostart(stdscr, cmd: tuple, statuses) -> None:
+ """Offer to auto-start the conversion's target server when it isn't running.
+
+ Records the chosen server spec name as ``kwargs['autostart']`` for
+ ``_run_conversion`` to act on. Mode-aware for qwen (custom vs clone).
+ """
+ _, key, kwargs = cmd
+ status = next((s for s in statuses if s.key == key), None)
+ if status is None or not status.servers:
+ return
+ spec = _select_spec(status, kwargs)
+ if spec is None:
+ return
+ if common.server_running(spec.url):
+ return
+ choice = tui.confirm(stdscr, f"The {status.label} server is not running. "
+ "Start it automatically?", default=True,
+ cancel_value=False)
+ if choice is True:
+ kwargs["autostart"] = spec.name
+
+
+def _select_spec(status, kwargs) -> Optional[ServerSpec]:
+ """The server spec this conversion needs (mode-aware for qwen)."""
+ if status.key == BACKEND_QWEN:
+ wanted = "qwen-clone" if kwargs.get("clone") else "qwen-custom"
+ return next((s for s in status.servers if s.name == wanted), None)
+ return status.servers[0] if status.servers else None
+
+
+def _find_spec(name: str) -> Optional[ServerSpec]:
+ """Look up a server spec by name across every backend's detect()."""
+ for st in detect_all():
+ for spec in st.servers:
+ if spec.name == name:
+ return spec
+ return None
+
+
+def _run_server_action(spec_name: str, action: str) -> None:
+ """Run a Start/Stop action in the plain console (after the TUI returns)."""
+ if action == "start":
+ spec = _find_spec(spec_name)
+ if spec is None:
+ print(f"[ERROR] no server named '{spec_name}'")
+ return
+ servers.start(spec)
+ elif action == "stop":
+ servers.stop(spec_name)
+
+
+def _server_menu(stdscr, statuses) -> Optional[tuple]:
+ """Pick a backend, then one of its servers and a Start/Stop action."""
+ candidates = [st for st in statuses if st.servers or st.running]
+ if not candidates:
+ tui.flash(stdscr, "No backend with a server is available. "
+ "Set one up first.")
+ return None
+ options = [(st.label, st.key) for st in candidates]
+ key = tui.menu(stdscr, "Start / Stop a server", options,
+ back_value=_GO_BACK)
+ if key is _GO_BACK or key is None:
+ return None
+ status = next((s for s in statuses if s.key == key), None)
+ if status is None:
+ return None
+ return _server_actions(stdscr, status)
+
+
+def _server_actions(stdscr, status) -> Optional[tuple]:
+ """Pick a server spec (qwen has two) and a Start or Stop action."""
+ specs = status.servers
+ if not specs:
+ tui.flash(stdscr, f"{status.label} has no server configured. "
+ "Run 'Set up a backend' first.")
+ return None
+ if len(specs) == 1:
+ spec = specs[0]
+ else:
+ options = [(f"{s.name} ({'running' if common.server_running(s.url) else 'stopped'})",
+ s.name) for s in specs]
+ name = tui.menu(stdscr, f"{status.label} server", options,
+ back_value=_GO_BACK)
+ if name is _GO_BACK or name is None:
+ return None
+ spec = next((s for s in specs if s.name == name), None)
+ if spec is None:
+ return None
+ running = common.server_running(spec.url)
+ action = tui.menu(
+ stdscr, f"{spec.name} ({'running' if running else 'stopped'})",
+ [("Start", "start"), ("Stop", "stop")], back_value=_GO_BACK)
+ if action is _GO_BACK or action is None:
+ return None
+ return ("server", spec.name, action)
+
+
+def _list_voices(voice_dir: str) -> list:
+ """Return sorted .wav stems in VOICE_DIR (best-effort)."""
+ try:
+ path = Path(voice_dir)
+ if not path.is_dir():
+ return []
+ return sorted(
+ (p.stem for p in path.iterdir()
+ if p.is_file() and p.suffix.lower() == ".wav"),
+ key=str.lower,
+ )
+ except OSError:
+ return []
+
+
+def _is_float(value: str) -> bool:
+ try:
+ float(value)
+ return True
+ except ValueError:
+ return False
diff --git a/app/ui/tui.py b/app/ui/tui.py
new file mode 100644
index 0000000..9047f36
--- /dev/null
+++ b/app/ui/tui.py
@@ -0,0 +1,1145 @@
+#!/usr/bin/env python3
+"""Colorful DOS-style curses TUI widgets for the interactive tools.
+
+Every screen is a dialog centered on a black desktop, like an old DOS
+TUI: a yellow title, colored status messages (green/yellow/red), a
+bright cyan cursor bar, and Yes/No buttons you switch with Tab for
+every yes/no question. Instructions and prompts are centered while
+lists (directory contents, menu options, checkbox trees) are
+left-justified for readability; the black background matches the
+terminal default, so the full-screen repaints curses performs while
+resizing a dialog never flash. One screen per decision: a directory
+browser, an expandable checkbox tree, a single-line text editor, a
+single-choice menu, and a yes/no confirm. There is no framework —
+every widget is a function that runs its own key loop on a curses
+window and returns the chosen value.
+
+Common key bindings:
+
+ Up/Down (or k/j) move the cursor
+ Enter accept (the highlighted button or row)
+ Tab or Left/Right switch Yes/No buttons (confirmations)
+ Esc abort the whole wizard (raises WizardCancelled);
+ a widget passed back_value returns that sentinel
+ instead, so the caller can fall back a screen
+ (confirm() historically names this cancel_value)
+
+On screens without typed text (menus, confirm, tree, browser) 'q' also
+aborts — even when a back_value is set, so Esc means "back" while 'q'
+still means "quit". Inside text editors 'q' is an ordinary character.
+When the terminal has no color support the theme degrades to
+bold/reverse/dim.
+"""
+
+import contextlib
+import os
+import textwrap
+from pathlib import Path
+from typing import Callable, List, Optional, Sequence, Tuple
+
+# Make Esc register quickly instead of pausing for an escape sequence.
+os.environ.setdefault("ESCDELAY", "25")
+
+
+class WizardCancelled(Exception):
+ """Raised when the user presses Esc to abort the wizard."""
+
+
+@contextlib.contextmanager
+def suspend(scr):
+ """Temporarily leave curses to run plain-console code.
+
+ Long-running steps that stream output to the terminal (cloning a
+ repository, building, pip-installing, transcribing) cannot share the
+ curses screen, so the wizard suspends curses for the duration of the
+ step and repaints the current screen afterward. ``scr`` is the curses
+ window returned to the wrapper callback.
+ """
+ import curses
+ try:
+ curses.endwin()
+ except curses.error:
+ pass
+ try:
+ yield
+ finally:
+ try:
+ scr.redrawwin()
+ scr.refresh()
+ except Exception:
+ pass
+ try:
+ curses.curs_set(0)
+ except curses.error:
+ pass
+
+
+def flash(scr, text: str, kind: str = "warn") -> None:
+ """Show a one-line notice until any key is pressed, then return.
+
+ Used by the hub for "not set up yet"-style messages. KIND is a theme
+ key (warn/err/ok/info). Esc dismisses the notice (it does not abort).
+ """
+ frame = Frame(scr, "Notice", "Press any key to continue Esc = back")
+ frame.mark(text, frame.theme.get(kind, frame.theme["body"]))
+ frame.cursor = None
+ frame.draw()
+ try:
+ key = scr.getch()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ if key == 3: # Ctrl-C still aborts
+ raise WizardCancelled()
+
+
+# Esc and 'q' both abort on screens without typed text ('q' is an
+# ordinary character inside text editors).
+_CANCEL_KEYS = (27, ord("q"))
+
+
+# ---------------------------------------------------------------------------
+# Theme
+# ---------------------------------------------------------------------------
+
+_THEME: dict = {}
+
+
+def _ensure_theme(curses) -> dict:
+ """Build (once) the attribute table for the classic DOS look.
+
+ White text on a black desktop, a cyan border, yellow titles and
+ warnings, green success/check marks, red errors, a black-on-cyan
+ cursor bar and a black-on-green selected button. Black matches the
+ terminal's default background, so the clear-screen repaints curses
+ performs when a dialog changes size never flash. Without colors,
+ everything falls back to bold/reverse/dim attributes.
+ """
+ if _THEME:
+ return _THEME
+ theme = {
+ "desktop": 0,
+ "border": curses.A_BOLD,
+ "title": curses.A_BOLD,
+ "body": 0,
+ "dim": curses.A_DIM,
+ "ok": curses.A_BOLD,
+ "warn": curses.A_BOLD,
+ "err": curses.A_BOLD | curses.A_REVERSE,
+ "info": curses.A_DIM,
+ "input": curses.A_BOLD,
+ "bar": curses.A_REVERSE,
+ "btn_on": curses.A_REVERSE | curses.A_BOLD,
+ "btn_off": curses.A_DIM,
+ "check": curses.A_BOLD,
+ "accent": curses.A_BOLD,
+ }
+ if curses.has_colors():
+ try:
+ curses.start_color()
+ black = curses.COLOR_BLACK
+ pairs = {
+ "desktop": (curses.COLOR_WHITE, black),
+ "border": (curses.COLOR_CYAN, black),
+ "title": (curses.COLOR_YELLOW, black),
+ "ok": (curses.COLOR_GREEN, black),
+ "warn": (curses.COLOR_YELLOW, black),
+ "err": (curses.COLOR_RED, black),
+ "info": (curses.COLOR_WHITE, black),
+ "input": (curses.COLOR_WHITE, black),
+ "bar": (curses.COLOR_BLACK, curses.COLOR_CYAN),
+ "btn_on": (curses.COLOR_BLACK, curses.COLOR_GREEN),
+ "check": (curses.COLOR_GREEN, black),
+ "accent": (curses.COLOR_CYAN, black),
+ }
+ for number, (name, (fg, bg)) in enumerate(pairs.items(), 1):
+ curses.init_pair(number, fg, bg)
+ theme[name] = curses.color_pair(number)
+ theme["dim"] = curses.A_DIM | theme["desktop"]
+ theme["body"] = theme["desktop"]
+ theme["btn_off"] = curses.A_DIM | theme["desktop"]
+ for name in ("title", "ok", "warn", "err", "check", "accent",
+ "input"):
+ theme[name] |= curses.A_BOLD
+ theme["info"] = curses.A_DIM | theme["info"]
+ except curses.error:
+ pass
+ _THEME.clear()
+ _THEME.update(theme)
+ return _THEME
+
+
+# ---------------------------------------------------------------------------
+# Shared drawing helpers
+# ---------------------------------------------------------------------------
+
+def _addstr(scr, y: int, x: int, text: str, attr: int = 0) -> None:
+ """addstr that ignores out-of-bounds and terminal-capability errors."""
+ try:
+ scr.addstr(y, x, text, attr)
+ except Exception:
+ pass
+
+
+def _addch(scr, y: int, x: int, ch, attr: int = 0) -> None:
+ """addch that ignores out-of-bounds and terminal-capability errors."""
+ try:
+ scr.addch(y, x, ch, attr)
+ except Exception:
+ pass
+
+
+def _hline(scr, y: int, x: int, n: int, attr: int = 0) -> None:
+ """hline of ACS_HLINE that ignores terminal-capability errors."""
+ import curses
+ try:
+ scr.hline(y, x, curses.ACS_HLINE, n, attr)
+ except Exception:
+ pass
+
+
+def _fit(text: str, width: int) -> str:
+ """Truncate TEXT to WIDTH columns, appending '~' when cut."""
+ if width < 1:
+ return ""
+ if len(text) <= width:
+ return text
+ return text[: max(0, width - 1)] + "~"
+
+
+class Frame:
+ """A dialog centered on the black desktop, DOS style.
+
+ Widgets append logical rows with mark()/mark_segments() and call
+ draw() after every state change. Rows are centered by default;
+ list rows pass align="left" to start at a fixed margin from the
+ left border. Rows that are not selectable (help text, the current
+ directory, blank lines) are skipped by the cursor. The selected
+ row is drawn as a full-width bright bar. Below the rows sit the
+ optional Yes/No buttons, a colored one-line status, and a dim
+ footer.
+ """
+
+ MIN_HEIGHT = 8
+ MIN_WIDTH = 30
+ # Columns between the left border and align="left" rows.
+ LIST_MARGIN = 2
+
+ def __init__(self, scr, title: str, footer: str):
+ import curses
+ self.curses = curses
+ self.scr = scr
+ self.title = title
+ self.footer = footer
+ self.theme = _ensure_theme(curses)
+ self.rows: List[dict] = []
+ self.cursor: Optional[int] = None # logical row index
+ self.status: Optional[Tuple[str, str]] = None # (text, kind)
+ self.buttons: Optional[Tuple[Sequence[str], int]] = None
+ self.scroll = 0
+ self.page_size = 1
+ try:
+ curses.curs_set(0)
+ except curses.error:
+ pass
+ try:
+ scr.bkgd(" ", self.theme["desktop"])
+ except curses.error:
+ pass
+
+ # -- content ---------------------------------------------------------
+
+ def mark(self, text: str, attr: Optional[int] = None, indent: int = 0,
+ selectable: bool = False, align: str = "center") -> None:
+ """Append a body row (wrapped when longer than the box).
+
+ ALIGN is "center" (the default, for instructions and prompts)
+ or "left" (for lists), which starts the row at a fixed margin
+ from the left border.
+ """
+ if attr is None:
+ attr = self.theme["body"]
+ self.rows.append({"text": text, "segments": None, "attr": attr,
+ "indent": indent, "selectable": selectable,
+ "align": align})
+
+ def mark_segments(self, segments: Sequence[Tuple[str, int]],
+ indent: int = 0, selectable: bool = False,
+ align: str = "center") -> None:
+ """Append a row of (text, attr) segments (truncated, not wrapped)."""
+ self.rows.append({"text": None, "segments": list(segments),
+ "attr": 0, "indent": indent,
+ "selectable": selectable, "align": align})
+
+ def selectable(self) -> List[int]:
+ """Logical indices of the selectable rows, in order."""
+ return [index for index, row in enumerate(self.rows)
+ if row["selectable"]]
+
+ # -- drawing ---------------------------------------------------------
+
+ def _row_width(self, row: dict) -> int:
+ """Logical width of a row, including its indent."""
+ if row["segments"] is not None:
+ return sum(len(text) for text, _ in row["segments"]) \
+ + 2 * row["indent"]
+ return len(row["text"]) + 2 * row["indent"]
+
+ def _measure(self, width: int) -> int:
+ """Dialog width: widest row plus frame, capped to the screen."""
+ longest = max(len(self.title) + 4, len(self.footer) + 4, 40)
+ for row in self.rows:
+ longest = max(longest, self._row_width(row) + 4)
+ if self.status:
+ longest = max(longest, len(self.status[0]) + 6)
+ if self.buttons:
+ labels, _ = self.buttons
+ longest = max(longest,
+ sum(len(label) + 6 for label in labels) + 4)
+ return min(longest + 4, width - 2)
+
+ def _flatten(self, usable: int) -> List[Tuple[int, dict, Optional[str]]]:
+ """Wrap text rows into physical (logical index, row, piece) lines."""
+ flat: List[Tuple[int, dict, Optional[str]]] = []
+ for index, row in enumerate(self.rows):
+ if row["segments"] is not None:
+ flat.append((index, row, None))
+ continue
+ wrap_width = usable
+ if row["align"] == "left":
+ # Leave room for the list margin, the indent and the
+ # right border so a wrapped line is never re-truncated.
+ wrap_width = usable - 1 - 2 * row["indent"]
+ pieces = textwrap.wrap(row["text"], max(10, wrap_width)) or [""]
+ for piece in pieces:
+ flat.append((index, row, piece))
+ return flat
+
+ def _geometry(self, height: int, width: int, dialog_w: int,
+ flat: List[Tuple[int, dict, Optional[str]]]
+ ) -> Tuple[int, int, int, int]:
+ """Place the dialog and scroll the cursor row into view.
+
+ Returns (y0, x0, dialog_h, visible); also refreshes
+ self.scroll and self.page_size.
+ """
+ chrome = 7 if self.buttons else 6 # title/gap/status/footer/borders
+ dialog_h = min(max(self.MIN_HEIGHT, len(flat) + chrome), height)
+ visible = max(1, dialog_h - chrome)
+ self.page_size = max(1, visible)
+ if self.cursor is not None:
+ positions = [i for i, (logical, _, _) in enumerate(flat)
+ if logical == self.cursor]
+ if positions:
+ first, last = positions[0], positions[-1]
+ if first < self.scroll:
+ self.scroll = first
+ elif last >= self.scroll + visible:
+ self.scroll = last - visible + 1
+ self.scroll = max(0, min(self.scroll, max(0, len(flat) - visible)))
+ y0 = max(0, (height - dialog_h) // 2)
+ x0 = max(0, (width - dialog_w) // 2)
+ return y0, x0, dialog_h, visible
+
+ def draw(self) -> None:
+ scr = self.scr
+ scr.erase()
+ height, width = scr.getmaxyx()
+ if height < self.MIN_HEIGHT or width < self.MIN_WIDTH:
+ msg = "Terminal too small"
+ _addstr(scr, height // 2, max(0, (width - len(msg)) // 2),
+ msg, self.curses.A_BOLD)
+ scr.refresh()
+ return
+ dialog_w = self._measure(width)
+ flat = self._flatten(dialog_w - 4)
+ y0, x0, dialog_h, visible = self._geometry(height, width,
+ dialog_w, flat)
+ self._draw_frame(y0, x0, dialog_h, dialog_w, len(flat), visible)
+ self._draw_rows(y0, x0, dialog_w, flat, visible)
+ self._draw_buttons(y0, x0, dialog_h, dialog_w)
+ self._draw_status_footer(y0, x0, dialog_h, dialog_w)
+ scr.refresh()
+
+ def _draw_frame(self, y0: int, x0: int, dialog_h: int, dialog_w: int,
+ total_lines: int, visible: int) -> None:
+ curses, theme = self.curses, self.theme
+ scr = self.scr
+ border = theme["border"]
+ _addch(scr, y0, x0, curses.ACS_ULCORNER, border)
+ _addch(scr, y0, x0 + dialog_w - 1, curses.ACS_URCORNER, border)
+ _addch(scr, y0 + dialog_h - 1, x0, curses.ACS_LLCORNER, border)
+ _addch(scr, y0 + dialog_h - 1, x0 + dialog_w - 1,
+ curses.ACS_LRCORNER, border)
+ _hline(scr, y0, x0 + 1, dialog_w - 2, border)
+ _hline(scr, y0 + dialog_h - 1, x0 + 1, dialog_w - 2, border)
+ for y in range(y0 + 1, y0 + dialog_h - 1):
+ _addch(scr, y, x0, curses.ACS_VLINE, border)
+ _addch(scr, y, x0 + dialog_w - 1, curses.ACS_VLINE, border)
+
+ inner_x = x0 + 1
+ inner_w = dialog_w - 2
+ title = _fit(f" {self.title} ", inner_w)
+ _addstr(scr, y0 + 1, inner_x + max(0, (inner_w - len(title)) // 2),
+ title, theme["title"])
+ if total_lines > visible:
+ indicator = f" {self.scroll + 1}/{total_lines} "
+ _addstr(scr, y0, max(x0 + 1, x0 + dialog_w - 1 - len(indicator)),
+ indicator, theme["dim"])
+
+ def _draw_rows(self, y0: int, x0: int, dialog_w: int,
+ flat: List[Tuple[int, dict, Optional[str]]],
+ visible: int) -> None:
+ theme = self.theme
+ scr = self.scr
+ inner_x = x0 + 1
+ inner_w = dialog_w - 2
+ for line in range(self.scroll, min(len(flat), self.scroll + visible)):
+ logical, row, piece = flat[line]
+ y = y0 + 2 + (line - self.scroll)
+ selected = logical == self.cursor and row["selectable"]
+ if selected:
+ _addstr(scr, y, inner_x, " " * inner_w, theme["bar"])
+ if row["segments"] is not None:
+ self._draw_segments_row(y, row, inner_x, inner_w, selected)
+ else:
+ self._draw_text_row(y, row, piece, inner_x, inner_w,
+ selected)
+
+ def _draw_segments_row(self, y: int, row: dict, inner_x: int,
+ inner_w: int, selected: bool) -> None:
+ scr, theme = self.scr, self.theme
+ total = sum(len(text) for text, _ in row["segments"])
+ if row["align"] == "left":
+ x = inner_x + self.LIST_MARGIN + 2 * row["indent"]
+ else:
+ x = inner_x + max(0, (inner_w - total) // 2) \
+ + 2 * row["indent"]
+ # Never paint over the right border column.
+ room = max(0, inner_x + inner_w - 1 - x)
+ for text, attr in row["segments"]:
+ text = _fit(text, room)
+ if not text:
+ break
+ _addstr(scr, y, x, text, theme["bar"] if selected else attr)
+ x += len(text)
+ room -= len(text)
+
+ def _draw_text_row(self, y: int, row: dict, piece: Optional[str],
+ inner_x: int, inner_w: int, selected: bool) -> None:
+ scr, theme = self.scr, self.theme
+ text = " " * row["indent"] + piece
+ if row["align"] == "left":
+ x = inner_x + self.LIST_MARGIN
+ limit = inner_w - 1 - self.LIST_MARGIN - 2 * row["indent"]
+ else:
+ x = inner_x + max(0, (inner_w - len(text)) // 2)
+ limit = inner_w
+ text = _fit(text, limit)
+ attr = theme["bar"] if selected else row["attr"]
+ _addstr(scr, y, x, text, attr)
+
+ def _draw_buttons(self, y0: int, x0: int, dialog_h: int,
+ dialog_w: int) -> None:
+ if not self.buttons:
+ return
+ theme = self.theme
+ scr = self.scr
+ inner_x = x0 + 1
+ inner_w = dialog_w - 2
+ labels, selected = self.buttons
+ rendered = [f"[ {label} ]" for label in labels]
+ total = sum(len(r) for r in rendered) + 3 * (len(rendered) - 1)
+ x = inner_x + max(0, (inner_w - total) // 2)
+ y = y0 + dialog_h - 4
+ for index, text in enumerate(rendered):
+ if index:
+ x += 3
+ _addstr(scr, y, x, text,
+ theme["btn_on"] if index == selected
+ else theme["btn_off"])
+ x += len(text)
+
+ def _draw_status_footer(self, y0: int, x0: int, dialog_h: int,
+ dialog_w: int) -> None:
+ theme = self.theme
+ scr = self.scr
+ inner_x = x0 + 1
+ inner_w = dialog_w - 2
+ if self.status:
+ text, kind = self.status
+ attr = theme.get(kind, theme["body"])
+ text = _fit(f" {text} ", inner_w)
+ _addstr(scr, y0 + dialog_h - 3,
+ inner_x + max(0, (inner_w - len(text)) // 2),
+ text, attr)
+ footer = _fit(self.footer, inner_w)
+ _addstr(scr, y0 + dialog_h - 2,
+ inner_x + max(0, (inner_w - len(footer)) // 2),
+ footer, theme["dim"])
+
+ # -- key helpers ------------------------------------------------------
+
+ def motion(self, key: int, cursor: int, count: int,
+ wrap: bool = False) -> Optional[int]:
+ """New cursor index for a motion KEY, or None when it moves nothing.
+
+ Up/Down (or k/j) move one row, wrapping around at the ends when
+ WRAP is set (menus and trees) and clamping otherwise (the
+ browser); Home/End jump to the first/last row; PageUp/PageDown
+ move self.page_size rows. COUNT is the number of rows.
+ """
+ curses = self.curses
+ if key in (curses.KEY_UP, ord("k")):
+ if wrap and cursor <= 0:
+ return count - 1
+ return max(0, cursor - 1)
+ if key in (curses.KEY_DOWN, ord("j")):
+ if wrap and cursor >= count - 1:
+ return 0
+ return min(count - 1, cursor + 1)
+ if key == curses.KEY_HOME:
+ return 0
+ if key == curses.KEY_END:
+ return count - 1
+ if key == curses.KEY_PPAGE:
+ return max(0, cursor - self.page_size)
+ if key == curses.KEY_NPAGE:
+ return min(count - 1, cursor + self.page_size)
+ return None
+
+ def get_key(self, cancel_keys: Sequence[int] = (27,)) -> int:
+ """Read one key; cancel keys and Ctrl-C raise WizardCancelled."""
+ try:
+ key = self.scr.getch()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ if key == 3: # Ctrl-C
+ raise WizardCancelled()
+ if key in cancel_keys:
+ raise WizardCancelled()
+ return key
+
+ def flash(self, text: str, kind: str = "err") -> None:
+ """Show TEXT on the status line until any key is pressed."""
+ self.status = (text, kind)
+ self.draw()
+ try:
+ key = self.scr.getch()
+ if key == 3: # Ctrl-C still aborts
+ raise WizardCancelled()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ self.status = None
+
+ def edit_status(self, prompt: str = "") -> Optional[str]:
+ """Edit a line of text on the status line.
+
+ Returns the edited string on Enter, or None when the user backs
+ out with Esc (the caller decides what that means).
+ """
+ curses = self.curses
+ text = ""
+ while True:
+ self.status = (f"{prompt}{text}_", "input")
+ self.draw()
+ try:
+ key = self.scr.getch()
+ except KeyboardInterrupt:
+ raise WizardCancelled() from None
+ if key == 27:
+ return None
+ if key == 3: # Ctrl-C
+ raise WizardCancelled()
+ if key in (10, 13):
+ return text
+ if key in (curses.KEY_BACKSPACE, 8, 127):
+ text = text[:-1]
+ elif 32 <= key < 127:
+ text += chr(key)
+
+
+# ---------------------------------------------------------------------------
+# Widget: yes/no confirm with buttons
+# ---------------------------------------------------------------------------
+
+def confirm(scr, question: str, default: bool = False,
+ body: Optional[Sequence[str]] = None,
+ cancel_value: object = None):
+ """Ask a yes/no QUESTION with centered Yes/No buttons.
+
+ The QUESTION is the dialog title (shown exactly once); optional
+ BODY lines sit centered above the buttons. Tab or the arrow keys
+ switch the buttons, Enter activates the highlighted one (the
+ DEFAULT button starts highlighted, drawn bright against the dim
+ other one), and y/n answer directly. Esc (or 'q') aborts the
+ wizard — unless CANCEL_VALUE is given (not None), in which case it
+ is returned instead, so the caller can fall back to a previous
+ screen rather than aborting the whole wizard.
+ """
+ frame = Frame(scr, question,
+ "Tab/arrows = switch Enter = confirm y/n Esc = cancel")
+ index = 0 if default else 1
+ while True:
+ frame.rows = []
+ for line in body or []:
+ frame.mark(line)
+ frame.cursor = None
+ frame.buttons = (["Yes", "No"], index)
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=())
+ if key in _CANCEL_KEYS:
+ if cancel_value is not None:
+ return cancel_value
+ raise WizardCancelled()
+ if key in (9, curses.KEY_LEFT, curses.KEY_RIGHT, curses.KEY_UP,
+ curses.KEY_DOWN, curses.KEY_BTAB, ord("h"), ord("l")):
+ index = 1 - index
+ elif key in (ord("y"), ord("Y")):
+ return True
+ elif key in (ord("n"), ord("N")):
+ return False
+ elif key in (10, 13):
+ return index == 0
+
+
+# ---------------------------------------------------------------------------
+# Widget: single-choice menu
+# ---------------------------------------------------------------------------
+
+def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0,
+ help_lines: Optional[Sequence[str]] = None,
+ back_value: object = None,
+ table_title: Optional[str] = None,
+ table_rows: Optional[Sequence[tuple]] = None):
+ """Show OPTIONS as (label, value) pairs; return the chosen value.
+
+ The cursor starts on DEFAULT_INDEX; Enter returns the highlighted
+ option's value. Options are left-justified like a DOS list;
+ HELP_LINES are dim, centered explanatory lines shown above them.
+
+ TABLE_TITLE + TABLE_ROWS render an aligned two-column table above the
+ options: each row is (name, status, kind) where KIND is a theme key
+ ("ok"/"warn"/"err"/"info"/...), optionally followed by NAME_KIND, a
+ theme key for the name column ("dim" to fade an unusable entry;
+ "body" — the default — otherwise). The name column is padded to the
+ widest name so every status starts at the same column — a monospace
+ grid. The title is dim and left-aligned with the rows. Used by the
+ hub to show each backend's state (unavailable / installed / running)
+ in matching columns with color.
+
+ Esc (or 'q') aborts the wizard unless BACK_VALUE is given (not None),
+ in which case Esc returns it so the caller can fall back a screen.
+ """
+ if not options:
+ raise ValueError("menu() needs at least one option")
+ frame = Frame(scr, title,
+ "Up/Down = move Enter = select Esc = cancel")
+ cursor = max(0, min(default_index, len(options) - 1))
+ while True:
+ frame.rows = []
+ for line in help_lines or []:
+ frame.mark(line, frame.theme["dim"])
+ if help_lines:
+ frame.mark("")
+ if table_rows:
+ if table_title:
+ frame.mark(table_title, frame.theme["dim"], align="left")
+ name_w = max(len(row[0]) for row in table_rows)
+ for row in table_rows:
+ name, status, kind = row[0], row[1], row[2]
+ name_kind = row[3] if len(row) > 3 else "body"
+ frame.mark_segments(
+ [(name.ljust(name_w),
+ frame.theme.get(name_kind, frame.theme["body"])),
+ (" " + status,
+ frame.theme.get(kind, frame.theme["body"]))],
+ align="left")
+ frame.mark("")
+ base = len(frame.rows)
+ for label, _ in options:
+ frame.mark(label, selectable=True, align="left")
+ frame.cursor = base + cursor
+ frame.draw()
+ key = frame.get_key(cancel_keys=())
+ if key == 27 and back_value is not None:
+ return back_value
+ if key in _CANCEL_KEYS:
+ raise WizardCancelled()
+ moved = frame.motion(key, cursor, len(options), wrap=True)
+ if moved is not None:
+ cursor = moved
+ elif key in (10, 13):
+ return options[cursor][1]
+
+
+# ---------------------------------------------------------------------------
+# Widget: single-line text editor
+# ---------------------------------------------------------------------------
+
+def line_edit(scr, title: str, default: str,
+ validate: Optional[Callable[[str], Optional[str]]] = None,
+ help_lines: Optional[Sequence[str]] = None,
+ back_value: object = None) -> str:
+ """Edit one line of text, pre-filled with DEFAULT; Enter accepts.
+
+ HELP_LINES are dim explanatory lines shown above the input.
+ VALIDATE receives the entered string and returns an error message
+ or None; Enter on an invalid value shows the message in red and
+ keeps editing. Esc aborts the wizard ('q' is an ordinary
+ character here) unless BACK_VALUE is given (not None), in which case
+ Esc returns it so the caller can fall back a screen.
+ """
+ frame = Frame(scr, title,
+ "type to edit Backspace = erase Enter = accept "
+ "Esc = cancel")
+ text = default
+ error = None
+ while True:
+ frame.rows = []
+ for line in help_lines or []:
+ frame.mark(line, frame.theme["dim"])
+ frame.mark("")
+ frame.mark(f"{text}_", frame.theme["input"])
+ frame.cursor = None
+ frame.status = (error, "err") if error else None
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=()) # handle Esc manually below
+ if key == 27 and back_value is not None:
+ return back_value
+ if key == 27:
+ raise WizardCancelled()
+ if key in (10, 13):
+ if validate is None:
+ return text
+ error = validate(text)
+ if error is None:
+ return text
+ continue
+ if key in (curses.KEY_BACKSPACE, 8, 127):
+ text = text[:-1]
+ elif key == 21: # Ctrl-U: clear the line
+ text = ""
+ elif 32 <= key < 127:
+ text += chr(key)
+
+
+# ---------------------------------------------------------------------------
+# Widget: multi-field settings form with Save/Cancel buttons
+# ---------------------------------------------------------------------------
+
+def form(scr, title: str, fields: Sequence[dict],
+ back_value: object = None,
+ help_lines: Optional[Sequence[str]] = None) -> Optional[dict]:
+ """Edit several labeled fields on one screen, then Save or Cancel.
+
+ FIELDS is a list of dicts, one per row, shaped like::
+
+ {"key": "audio_format", "label": "Audio format",
+ "kind": "choice", "value": "m4b",
+ "choices": ["mp3", "m4b", "ogg", "flac"]}
+ {"key": "chunk_size", "label": "Chunk size",
+ "kind": "text", "value": "250",
+ "validate": lambda s: None if s.isdigit() else "digits only"}
+
+ Each field renders as a left-justified ``Label: value`` row. Up/Down
+ (or k/j) move the cursor; Enter on a ``choice`` row opens a single
+ choice menu, Enter on a ``text`` row opens a line editor (reusing its
+ VALIDATE for that one field). Tab or the arrow keys move focus to the
+ Save/Cancel buttons; Enter on Save validates every text field (the
+ first failure flashes in red and re-focuses that row) and returns
+ ``{key: value}``, Enter on Cancel returns BACK_VALUE. Esc (or 'q')
+ returns BACK_VALUE / aborts as in menu(). Values are edited in place
+ in the FIELDS dicts, so Cancel simply discards them.
+ """
+ if not fields:
+ raise ValueError("form() needs at least one field")
+ frame = Frame(scr, title,
+ "Up/Down = move Enter = edit Tab = Save/Cancel "
+ "Esc = cancel")
+ cursor = 0
+ on_buttons = False
+ btn_index = 0
+ edit_cancel = object() # sentinel: backed out of a field editor
+ while True:
+ frame.rows = []
+ for line in help_lines or []:
+ frame.mark(line, frame.theme["dim"])
+ if help_lines:
+ frame.mark("")
+ base = len(frame.rows)
+ for field in fields:
+ frame.mark(f"{field['label']}: {field['value']}",
+ selectable=True, align="left")
+ frame.cursor = None if on_buttons else base + cursor
+ frame.buttons = (["Save", "Cancel"], btn_index if on_buttons else None)
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=())
+ if key == 27 and back_value is not None:
+ return back_value
+ if key in _CANCEL_KEYS:
+ raise WizardCancelled()
+ if on_buttons:
+ if key in (9, curses.KEY_BTAB, curses.KEY_UP, curses.KEY_DOWN):
+ on_buttons = False
+ elif key in (curses.KEY_LEFT, curses.KEY_RIGHT,
+ ord("h"), ord("l")):
+ btn_index = 1 - btn_index
+ elif key in (10, 13):
+ if btn_index == 0: # Save
+ for index, field in enumerate(fields):
+ validate = field.get("validate")
+ if field.get("kind") == "text" and validate:
+ error = validate(field["value"])
+ if error is not None:
+ on_buttons = False
+ cursor = index
+ frame.flash(error, "err")
+ break
+ else:
+ return {field["key"]: field["value"]
+ for field in fields}
+ else: # Cancel
+ return back_value
+ else:
+ moved = frame.motion(key, cursor, len(fields), wrap=True)
+ if moved is not None:
+ cursor = moved
+ elif key in (9, curses.KEY_BTAB, curses.KEY_LEFT,
+ curses.KEY_RIGHT, ord("h"), ord("l")):
+ on_buttons = True
+ btn_index = 0
+ elif key in (10, 13):
+ field = fields[cursor]
+ if field.get("kind") == "choice":
+ choices = list(field.get("choices") or [])
+ default = choices.index(field["value"]) \
+ if field["value"] in choices else 0
+ chosen = menu(scr, field["label"],
+ [(c, c) for c in choices],
+ default_index=default,
+ back_value=edit_cancel)
+ if chosen is not edit_cancel:
+ field["value"] = chosen
+ else:
+ edited = line_edit(scr, field["label"], field["value"],
+ validate=field.get("validate"),
+ back_value=edit_cancel)
+ if edited is not edit_cancel:
+ field["value"] = edited
+
+
+# ---------------------------------------------------------------------------
+# Widget: directory browser
+# ---------------------------------------------------------------------------
+
+def _list_dirs(path: Path) -> List[Path]:
+ """Return the subdirectories of PATH, sorted, dot-dirs excluded."""
+ try:
+ entries = [child for child in path.iterdir()
+ if child.is_dir() and not child.name.startswith(".")]
+ except OSError:
+ return []
+ return sorted(entries, key=lambda child: child.name.lower())
+
+
+def browse_directory(scr, title: str,
+ validate: Optional[Callable[[Path], Optional[str]]] = None,
+ start: Optional[Path] = None,
+ info: Optional[Callable[[Path],
+ Optional[Tuple[str, str]]]] = None,
+ preview: Optional[Callable[[Path],
+ Optional[Tuple[str, str]]]] = None,
+ help_lines: Optional[Sequence[str]] = None,
+ auto_select: Optional[Callable[
+ [Path], Optional[Path]]] = None,
+ back_value: object = None
+ ) -> Path:
+ """Pick a directory DOS-browser style.
+
+ The listing starts with a bright '[ Use this directory ]' row (the
+ cursor starts there; Enter accepts the directory being listed), a
+ dim '..' for the parent, and one row per subdirectory. List rows
+ are left-justified; instructions and the current path stay
+ centered. Enter or Right on a highlighted subdirectory opens it,
+ Left/Backspace goes to the parent, 'e' types a path directly, and
+ Home/End/PageUp/PageDown navigate long listings. Coming back out
+ of a directory highlights the directory you came from.
+
+ VALIDATE receives the listed directory and returns an error message
+ or None; Enter on an invalid directory is refused with that message.
+ INFO(directory) returns a (text, kind) status shown under the
+ listed directory's path — kind is "ok" (green), "warn" (yellow),
+ "err" (red), "info" (dim) or "input". PREVIEW(directory) returns
+ one for the highlighted subdirectory, shown on the status line.
+ AUTO_SELECT receives a highlighted subdirectory when the user
+ opens it (Enter, Right or 'l') and may return a Path to accept
+ immediately — as if '[ Use this directory ]' had been pressed on
+ it — instead of descending; returning None keeps browsing. This
+ lets a subdirectory that already looks like the target (e.g. an
+ 'audio.cpp' checkout containing 'model_specs/') be picked in one
+ keystroke. Esc (or 'q') aborts the wizard unless BACK_VALUE is given
+ (not None), in which case Esc returns it so the caller can fall back
+ a screen.
+ """
+ footer = ("Up/Down = move Enter = open/use Left = parent "
+ "e = type path Esc = cancel")
+ frame = Frame(scr, title, footer)
+ current = Path(start) if start is not None else Path.cwd()
+ try:
+ current = current.resolve()
+ except OSError:
+ current = Path.cwd()
+ sel = 0
+ highlight: Optional[Path] = None
+
+ def validation_error() -> Optional[str]:
+ if validate is None:
+ return None
+ try:
+ return validate(current)
+ except OSError:
+ return "Cannot read this directory"
+
+ def call(callback, path: Path) -> Optional[Tuple[str, str]]:
+ if callback is None:
+ return None
+ try:
+ return callback(path)
+ except OSError:
+ return None
+
+ while True:
+ entries = _list_dirs(current)
+ has_parent = current.parent != current
+ offset = 1 + (1 if has_parent else 0)
+ frame.rows = []
+ for line in help_lines or []:
+ frame.mark(line, frame.theme["dim"])
+ frame.mark(f"Directory: {current}", frame.theme["accent"])
+ current_info = call(info, current)
+ if current_info:
+ frame.mark(current_info[0],
+ frame.theme.get(current_info[1], frame.theme["body"]))
+ frame.mark("")
+ frame.mark("[ Use this directory ]", frame.theme["ok"],
+ selectable=True, align="left")
+ if has_parent:
+ frame.mark("..", frame.theme["dim"], selectable=True,
+ align="left")
+ for entry in entries:
+ frame.mark(f"{entry.name}/", selectable=True, align="left")
+ selectable = frame.selectable()
+ if highlight is not None:
+ sel = 0
+ for index, entry in enumerate(entries):
+ if entry == highlight:
+ sel = offset + index
+ break
+ highlight = None
+ sel = max(0, min(sel, len(selectable) - 1))
+ frame.cursor = selectable[sel] if selectable else None
+
+ if sel == 0:
+ frame.status = ("Enter = use this directory", "info")
+ elif has_parent and sel == 1:
+ frame.status = ("Enter = open the parent directory", "info")
+ else:
+ entry = entries[sel - offset]
+ frame.status = call(preview, entry) \
+ or (f"Enter = open {entry.name}/", "info")
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=())
+ if key == 27 and back_value is not None:
+ return back_value
+ if key in _CANCEL_KEYS:
+ raise WizardCancelled()
+ moved = frame.motion(key, sel, len(selectable))
+ if moved is not None:
+ sel = moved
+ elif key in (10, 13, curses.KEY_RIGHT, ord("l")):
+ if sel == 0:
+ error = validation_error()
+ if error is None:
+ return current
+ frame.flash(f"{error} (keep browsing)", "err")
+ elif has_parent and sel == 1:
+ highlight = current
+ current = current.parent
+ else:
+ entry = entries[sel - offset]
+ if auto_select is not None:
+ picked = auto_select(entry)
+ if picked is not None:
+ return picked
+ current = entry
+ sel = 0
+ elif key in (curses.KEY_LEFT, ord("h"), ord("u"),
+ curses.KEY_BACKSPACE, 8, 127):
+ if has_parent:
+ highlight = current
+ current = current.parent
+ elif key == ord("e"):
+ result = frame.edit_status(prompt="path: ")
+ if result:
+ candidate = Path(os.path.expanduser(result))
+ if not candidate.is_absolute():
+ candidate = current / candidate
+ try:
+ candidate = candidate.resolve()
+ except OSError:
+ pass
+ if candidate.is_dir():
+ current = candidate
+ sel = 0
+ else:
+ frame.flash(f"Not a directory: {candidate}", "err")
+
+
+# ---------------------------------------------------------------------------
+# Widget: expandable checkbox tree
+# ---------------------------------------------------------------------------
+
+def checkbox_tree(scr, title: str, families: List[dict],
+ footer: Optional[str] = None,
+ expand_all: bool = False,
+ back_value: object = None) -> List[Tuple[int, str]]:
+ """Pick model families and packages from an expandable tree.
+
+ FAMILIES is a list of dicts (one per family) shaped like::
+
+ {
+ "label": "Qwen3-TTS (qwen3_tts)",
+ "detail": "tts, cloning, design",
+ "options": [
+ {"key": "Base-GGUF", "label": "base", "recommended": True},
+ {"key": "VoiceDesign-GGUF", "label": "voicedesign",
+ "recommended": False},
+ ],
+ }
+
+ Space on a family row checks its recommended option (or clears every
+ option when one is already checked); Space on an option row toggles
+ that option. Tab/Right expands or collapses the family under the
+ cursor. Enter returns the flat list of (family_index, option_key)
+ pairs for every checked option, in tree order; at least one checked
+ option is required. Nothing is checked by default, and with
+ EXPAND_ALL every family starts expanded. A "[recommended]" tag is
+ shown only when a
+ family has more than one option — a single option needs no tag.
+ Family and option rows are left-justified like a DOS list. Esc (or
+ 'q') aborts the wizard unless BACK_VALUE is given (not None), in
+ which case Esc returns it so the caller can fall back a screen.
+ """
+ if not families:
+ raise ValueError("checkbox_tree() needs at least one family")
+ footer = footer or ("Up/Down = move Tab/Right = expand Space = check "
+ "Enter = accept Esc = cancel")
+ frame = Frame(scr, title, footer)
+ expanded = {index for index in range(len(families))} if expand_all else set()
+ checked = set() # (family_index, option_key)
+
+ expanded.add(0)
+
+ def family_checked(index: int) -> bool:
+ return any(pair[0] == index for pair in checked)
+
+ def accept() -> List[Tuple[int, str]]:
+ return [(index, option["key"])
+ for index, family in enumerate(families)
+ for option in family["options"]
+ if (index, option["key"]) in checked]
+
+ def visible_nodes() -> List[tuple]:
+ nodes: List[tuple] = [] # ("family", i) or ("option", i, key)
+ for index, family in enumerate(families):
+ nodes.append(("family", index))
+ if index in expanded:
+ for option in family["options"]:
+ nodes.append(("option", index, option["key"]))
+ return nodes
+
+ cursor = 0
+ while True:
+ nodes = visible_nodes()
+ cursor = max(0, min(cursor, len(nodes) - 1))
+ frame.rows = []
+ for node in nodes:
+ if node[0] == "family":
+ index = node[1]
+ family = families[index]
+ on = family_checked(index)
+ mark = "x" if on else " "
+ arrow = "-" if index in expanded else "+"
+ frame.mark_segments(
+ [(f"[{mark}] ",
+ frame.theme["check"] if on else frame.theme["dim"]),
+ (f"{arrow} {family['label']}",
+ frame.theme["accent"] if on else frame.theme["body"])],
+ selectable=True, align="left")
+ else:
+ _, index, option_key = node
+ option = next(opt for opt in families[index]["options"]
+ if opt["key"] == option_key)
+ is_on = (index, option_key) in checked
+ mark = "x" if is_on else " "
+ segments = [(f"[{mark}] ",
+ frame.theme["check"] if is_on
+ else frame.theme["dim"]),
+ (option["label"], frame.theme["body"])]
+ if option.get("recommended") \
+ and len(families[index]["options"]) > 1:
+ segments.append((" [recommended]", frame.theme["warn"]))
+ frame.mark_segments(segments, indent=2, selectable=True,
+ align="left")
+ frame.cursor = cursor
+ node = nodes[cursor]
+ frame.status = (families[node[1]].get("detail", ""), "info")
+ frame.draw()
+ curses = frame.curses
+ key = frame.get_key(cancel_keys=())
+ if key == 27 and back_value is not None:
+ return back_value
+ if key in _CANCEL_KEYS:
+ raise WizardCancelled()
+ moved = frame.motion(key, cursor, len(nodes), wrap=True)
+ if moved is not None:
+ cursor = moved
+ elif key in (9, curses.KEY_RIGHT, ord("l")) and node[0] == "family":
+ index = node[1]
+ if index in expanded:
+ expanded.discard(index)
+ else:
+ expanded.add(index)
+ elif key == curses.KEY_LEFT and node[0] == "family":
+ expanded.discard(node[1])
+ elif key == ord(" "):
+ if node[0] == "family":
+ index = node[1]
+ options = families[index]["options"]
+ if family_checked(index):
+ for option in options:
+ checked.discard((index, option["key"]))
+ else:
+ for option in options:
+ if option.get("recommended"):
+ checked.add((index, option["key"]))
+ break
+ else:
+ if options:
+ checked.add((index, options[0]["key"]))
+ expanded.add(index)
+ else:
+ _, index, option_key = node
+ if (index, option_key) in checked:
+ checked.discard((index, option_key))
+ else:
+ checked.add((index, option_key))
+ elif key in (10, 13): # Enter: accept the checked selection
+ selection = accept()
+ if selection:
+ return selection
+ frame.flash("Check at least one model package (Space)", "err")