From f00249db9d1ea051d29aa1bcca869fc4b88e83eb Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 02:59:26 -0400 Subject: refactor: add app directory, dir structure change --- .gitignore | 6 +- README.md | 18 +- app/backends/__init__.py | 146 +++ app/backends/audiocpp.py | 1731 +++++++++++++++++++++++++++++++++++ app/backends/common.py | 269 ++++++ app/backends/envs.py | 185 ++++ app/backends/faster.py | 416 +++++++++ app/backends/qwen.py | 272 ++++++ app/backends/servers.py | 244 +++++ app/converter/__init__.py | 1 + app/converter/audio.py | 616 +++++++++++++ app/converter/chunking.py | 91 ++ app/converter/config.py | 77 ++ app/converter/converter.py | 782 ++++++++++++++++ app/converter/cover.py | 279 ++++++ app/converter/extractors.py | 328 +++++++ app/converter/tts.py | 1305 ++++++++++++++++++++++++++ app/docs/backend-audiocpp.md | 91 ++ app/docs/backend-faster.md | 45 + app/docs/backend-qwen.md | 67 ++ app/tests/__init__.py | 0 app/tests/cover_test.png | Bin 0 -> 6801 bytes app/tests/gen_test_cover.py | 8 + app/tests/test_audio.py | 524 +++++++++++ app/tests/test_backends.py | 178 ++++ app/tests/test_backends_audiocpp.py | 1062 +++++++++++++++++++++ app/tests/test_backends_envs.py | 204 +++++ app/tests/test_backends_faster.py | 172 ++++ app/tests/test_backends_servers.py | 146 +++ app/tests/test_chunking.py | 118 +++ app/tests/test_cleaning.py | 54 ++ app/tests/test_converter.py | 619 +++++++++++++ app/tests/test_cover.py | 189 ++++ app/tests/test_extractors.py | 213 +++++ app/tests/test_hub.py | 502 ++++++++++ app/tests/test_tts.py | 1513 ++++++++++++++++++++++++++++++ app/tests/test_tui.py | 661 +++++++++++++ app/ui/__init__.py | 9 + app/ui/hub.py | 637 +++++++++++++ app/ui/tui.py | 1145 +++++++++++++++++++++++ audiobook.py | 23 +- backends/__init__.py | 146 --- backends/audiocpp.py | 1727 ---------------------------------- backends/common.py | 265 ------ backends/envs.py | 185 ---- backends/faster.py | 416 --------- backends/qwen.py | 272 ------ backends/servers.py | 244 ----- converter/__init__.py | 1 - converter/audio.py | 616 ------------- converter/chunking.py | 91 -- converter/config.py | 77 -- converter/converter.py | 780 ---------------- converter/cover.py | 279 ------ converter/extractors.py | 328 ------- converter/tts.py | 1305 -------------------------- docs/backend-audiocpp.md | 91 -- docs/backend-faster.md | 45 - docs/backend-qwen.md | 67 -- tests/__init__.py | 0 tests/cover_test.png | Bin 6801 -> 0 bytes tests/gen_test_cover.py | 8 - tests/test_audio.py | 524 ----------- tests/test_backends.py | 178 ---- tests/test_backends_audiocpp.py | 1062 --------------------- tests/test_backends_envs.py | 204 ----- tests/test_backends_faster.py | 172 ---- tests/test_backends_servers.py | 146 --- tests/test_chunking.py | 118 --- tests/test_cleaning.py | 54 -- tests/test_converter.py | 619 ------------- tests/test_cover.py | 189 ---- tests/test_extractors.py | 213 ----- tests/test_hub.py | 502 ---------- tests/test_tts.py | 1513 ------------------------------ tests/test_tui.py | 661 ------------- ui/__init__.py | 9 - ui/hub.py | 637 ------------- ui/tui.py | 1145 ----------------------- 79 files changed, 14926 insertions(+), 14909 deletions(-) create mode 100644 app/backends/__init__.py create mode 100755 app/backends/audiocpp.py create mode 100644 app/backends/common.py create mode 100644 app/backends/envs.py create mode 100755 app/backends/faster.py create mode 100644 app/backends/qwen.py create mode 100644 app/backends/servers.py create mode 100644 app/converter/__init__.py create mode 100644 app/converter/audio.py create mode 100644 app/converter/chunking.py create mode 100644 app/converter/config.py create mode 100644 app/converter/converter.py create mode 100644 app/converter/cover.py create mode 100644 app/converter/extractors.py create mode 100644 app/converter/tts.py create mode 100644 app/docs/backend-audiocpp.md create mode 100644 app/docs/backend-faster.md create mode 100644 app/docs/backend-qwen.md create mode 100644 app/tests/__init__.py create mode 100644 app/tests/cover_test.png create mode 100644 app/tests/gen_test_cover.py create mode 100644 app/tests/test_audio.py create mode 100644 app/tests/test_backends.py create mode 100644 app/tests/test_backends_audiocpp.py create mode 100644 app/tests/test_backends_envs.py create mode 100644 app/tests/test_backends_faster.py create mode 100644 app/tests/test_backends_servers.py create mode 100644 app/tests/test_chunking.py create mode 100644 app/tests/test_cleaning.py create mode 100644 app/tests/test_converter.py create mode 100644 app/tests/test_cover.py create mode 100644 app/tests/test_extractors.py create mode 100644 app/tests/test_hub.py create mode 100644 app/tests/test_tts.py create mode 100644 app/tests/test_tui.py create mode 100644 app/ui/__init__.py create mode 100644 app/ui/hub.py create mode 100644 app/ui/tui.py delete mode 100644 backends/__init__.py delete mode 100755 backends/audiocpp.py delete mode 100644 backends/common.py delete mode 100644 backends/envs.py delete mode 100755 backends/faster.py delete mode 100644 backends/qwen.py delete mode 100644 backends/servers.py delete mode 100644 converter/__init__.py delete mode 100644 converter/audio.py delete mode 100644 converter/chunking.py delete mode 100644 converter/config.py delete mode 100644 converter/converter.py delete mode 100644 converter/cover.py delete mode 100644 converter/extractors.py delete mode 100644 converter/tts.py delete mode 100644 docs/backend-audiocpp.md delete mode 100644 docs/backend-faster.md delete mode 100644 docs/backend-qwen.md delete mode 100644 tests/__init__.py delete mode 100644 tests/cover_test.png delete mode 100644 tests/gen_test_cover.py delete mode 100644 tests/test_audio.py delete mode 100644 tests/test_backends.py delete mode 100644 tests/test_backends_audiocpp.py delete mode 100644 tests/test_backends_envs.py delete mode 100644 tests/test_backends_faster.py delete mode 100644 tests/test_backends_servers.py delete mode 100644 tests/test_chunking.py delete mode 100644 tests/test_cleaning.py delete mode 100644 tests/test_converter.py delete mode 100644 tests/test_cover.py delete mode 100644 tests/test_extractors.py delete mode 100644 tests/test_hub.py delete mode 100644 tests/test_tts.py delete mode 100644 tests/test_tui.py delete mode 100644 ui/__init__.py delete mode 100644 ui/hub.py delete mode 100644 ui/tui.py diff --git a/.gitignore b/.gitignore index ca8252f..313ac60 100644 --- a/.gitignore +++ b/.gitignore @@ -10,12 +10,12 @@ voices/* !voices/.gitkeep # Backend checkouts cloned by the setup wizards (backends.audiocpp / .faster) -/audio.cpp/ -/faster-qwen3-tts/ +/app/audio.cpp/ +/app/faster-qwen3-tts/ # Managed Python environment created by audiobook.py (envs.py) for the app # requirements and the backend TTS packages (qwen-tts, faster-qwen3-tts). -/envs/ +/app/envs/ *.epub input/*.txt diff --git a/README.md b/README.md index 3f61655..5d96ff8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The converter sends text extracted from your books to a locally running TTS serv ## Installation Clone the repo. No manual environment setup is needed — `audiobook.py` -creates and manages its own virtual environment (`envs/tts`) the first time +creates and manages its own virtual environment (`app/envs/tts`) the first time it runs, installing its requirements and any backend TTS packages into it. ```bash @@ -30,7 +30,7 @@ cd tts-audiobook-generator python audiobook.py ``` -On first launch `audiobook.py` creates `envs/tts` (via `python -m venv`), +On first launch `audiobook.py` creates `app/envs/tts` (via `python -m venv`), installs `requirements.txt` into it, and re-launches itself inside that environment. Backend packages (`qwen-tts`, `faster-qwen3-tts[demo]`) are pip-installed into the same venv by their setup wizards. @@ -39,7 +39,7 @@ To add extras manually (e.g. FlashAttention), pip-install into the managed venv directly: ```bash -envs/tts/bin/python -m pip install flash-attn +app/envs/tts/bin/python -m pip install flash-attn ``` Put your book files (epub, etc.) in the `input/` directory. The output goes to `output/`. @@ -61,7 +61,7 @@ A full-screen TUI opens and shows each backend's status in a table — **unavail **Configure a backend…** and **Server…** only appear once at least one backend is installed or running. -Everything the TUI does can also be scripted with flags: `python audiobook.py --backend audiocpp --model higgs --voice narrator`, or `python -m backends.audiocpp --families higgs_audio_tts --clone --build-backend cuda`. +Everything the TUI does can also be scripted with flags: `python audiobook.py --backend audiocpp --model higgs --voice narrator`, or `python app/backends/audiocpp.py --families higgs_audio_tts --clone --build-backend cuda`. You need one of the following backends (the TUI sets them up for you; manual steps below): @@ -90,15 +90,15 @@ You need one of the following backends (the TUI sets them up for you; manual ste | `--transcription "..."` | `qwen`: Override whisper auto-transcription with manual audio transcript. | | `--no-transcription` | `qwen`: Skip auto-transcription of the reference audio. | -Other options including backend server URLs/ports are configured in `converter/config.py` +Other options including backend server URLs/ports are configured in `app/converter/config.py` ## TTS Backend Setup -Installation and usage documentation for each supported TTS backend is in the `docs/` directory: +Installation and usage documentation for each supported TTS backend is in the `app/docs/` directory: -- [audio.cpp instructions](docs/backend-audiocpp.md) -- [qwen-tts instructions](docs/backend-qwen.md) -- [faster-qwen-tts instructions](docs/backend-faster.md) +- [audio.cpp instructions](app/docs/backend-audiocpp.md) +- [qwen-tts instructions](app/docs/backend-qwen.md) +- [faster-qwen-tts instructions](app/docs/backend-faster.md) ## Tips 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/.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 ``--`` (e.g. +# ``linux-cuda-release``, ``windows-vulkan-debug``, ``macos-metal-release``) +# and the built server lands in ``/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/``), + 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 install `` 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 " + "--target audiocpp_server") + print(f" then run: ./build/--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/--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 ``|``; 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 ``|`` 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/-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/-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 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"]*>.*?", "", html_content, flags=re.DOTALL | re.IGNORECASE) + html_content = re.sub(r"]*>.*?", "", 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 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 diff --git a/app/tests/cover_test.png b/app/tests/cover_test.png new file mode 100644 index 0000000..0c252db Binary files /dev/null and b/app/tests/cover_test.png 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/.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("

Hello world

"), "Hello world") + + def test_removes_script_and_style(self): + html = "

Text

" + self.assertEqual(clean_html(html), "Text") + + def test_unescapes_entities(self): + self.assertEqual(clean_html("Tom & 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"

{text}

" + 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") diff --git a/audiobook.py b/audiobook.py index 792b274..6c075ea 100755 --- a/audiobook.py +++ b/audiobook.py @@ -5,12 +5,13 @@ Converts TXT, PDF and EPUB files into audiobooks using a local TTS server. Run with no arguments in a terminal for the full TUI (set up backends, process the input directory); pass flags to script a conversion directly. -Edit converter/config.py to change voice and processing settings. +Edit app/converter/config.py to change voice and processing settings. """ import argparse import sys import traceback +from pathlib import Path # Fix Windows console encoding for unicode output if sys.platform == "win32": @@ -20,6 +21,12 @@ if sys.platform == "win32": except AttributeError: pass +# Everything non-user-facing (source packages, generated dirs, venv, backend +# checkouts) lives under ./app so the checkout root stays clean. Put it on +# sys.path before importing the packages below. +APP_DIR = Path(__file__).resolve().parent / "app" +sys.path.insert(0, str(APP_DIR)) + # The managed-environment bootstrap (backends.envs) is stdlib-only and is # imported here so main() can launch it before any third-party dependency is # touched. It must NOT run at import time (importing this module must stay @@ -174,7 +181,7 @@ Examples: "With --backend audiocpp the language is adapted to the model " "family: sent as a code (e.g. 'en') for families that take one, or " "omitted when the model detects the language itself. Defaults to " - "the LANGUAGE setting in converter/config.py (English).") + "the LANGUAGE setting in app/converter/config.py (English).") ) parser.add_argument( "--speed", type=float, default=1.0, @@ -198,7 +205,7 @@ Examples: "audio.cpp audiocpp_server (audiocpp) hosting any of its TTS " "model families — Qwen3-TTS, Higgs Audio, VoxCPM2, IndexTTS2, " "and more. Defaults to the BACKEND setting in " - "converter/config.py (audiocpp).") + "app/converter/config.py (audiocpp).") ) parser.add_argument( "--voice", type=str, default=None, metavar="NAME", @@ -207,7 +214,7 @@ Examples: "with --ref-audio). audiocpp: a voice_preset or voice_dir entry " "(cloning); required for audio.cpp families without built-in " "speakers (everything except Qwen3-TTS CustomVoice). Not used by " - "the qwen backend (use converter/config.py SPEAKER or --clone " + "the qwen backend (use app/converter/config.py SPEAKER or --clone " "there).") ) parser.add_argument( @@ -219,7 +226,7 @@ Examples: parser.add_argument( "--chunk", action="store_true", help=("Force client-side chunking into CHUNK_SIZE-word requests (see " - "converter/config.py). Only matters for --backend audiocpp, which " + "app/converter/config.py). Only matters for --backend audiocpp, which " "otherwise sends each chapter as one request and lets the server " "chunk long text itself; the qwen and faster backends always " "chunk.") @@ -228,7 +235,7 @@ Examples: "--model", type=str, default=None, metavar="ID", help=("audio.cpp server model entry id to use for this run " "(--backend audiocpp only). Overrides AUDIOCPP_MODEL_ID in " - "converter/config.py, which is useful for a server hosting " + "app/converter/config.py, which is useful for a server hosting " "several lazily-loaded models: generate one server.json with " "backends.audiocpp, then pick the model per run with --model. " "Leave unset to use the config id, or to auto-select when the " @@ -243,7 +250,7 @@ Examples: "'A warm adult female narrator with a British accent'. On " "other families it acts as a style/delivery instruction when " "the model supports one and is ignored otherwise. Defaults to " - "AUDIOCPP_INSTRUCTIONS in converter/config.py (empty).") + "AUDIOCPP_INSTRUCTIONS in app/converter/config.py (empty).") ) parser.add_argument( "--option", action="append", type=str, default=None, metavar="KEY=VALUE", @@ -306,7 +313,7 @@ Examples: if args.voice is not None: parser.error("--voice requires --backend faster or audiocpp; the " "qwen backend uses built-in speakers " - "(converter/config.py SPEAKER) or --clone") + "(app/converter/config.py SPEAKER) or --clone") if args.language is not None: try: args.language = normalize_language(args.language) diff --git a/backends/__init__.py b/backends/__init__.py deleted file mode 100644 index 6a23ab7..0000000 --- a/backends/__init__.py +++ /dev/null @@ -1,146 +0,0 @@ -"""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/.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 a 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/backends/audiocpp.py b/backends/audiocpp.py deleted file mode 100755 index 486017f..0000000 --- a/backends/audiocpp.py +++ /dev/null @@ -1,1727 +0,0 @@ -#!/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 ``./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 ``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 -m backends.audiocpp [--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 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 ( - 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 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 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 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))) - 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 ``--`` (e.g. -# ``linux-cuda-release``, ``windows-vulkan-debug``, ``macos-metal-release``) -# and the built server lands in ``/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/``), - 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 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 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 install `` 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 - # ./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 ./{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 directory, or " - "point at an existing checkout."]) - if choice == "clone": - target = TTS_ROOT / 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 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 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 ``./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(TTS_ROOT / 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 " - "--target audiocpp_server") - print(f" then run: ./build/--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 = TTS_ROOT / 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 ./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 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 ./audio.cpp; in the TUI you can " - "clone one instead)") - parser.add_argument("--clone", action="store_true", - help="Non-interactive: clone audio.cpp into " - "./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 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 " - "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 " - "./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/--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/backends/common.py b/backends/common.py deleted file mode 100644 index 2529a8f..0000000 --- a/backends/common.py +++ /dev/null @@ -1,265 +0,0 @@ -"""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 ``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). -# Backend checkouts are cloned into subdirectories of this root -# (./audio.cpp, ./faster-qwen3-tts) so a single tree holds everything. -TTS_ROOT = Path(__file__).resolve().parent.parent - -# 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" - -# converter/config.py — rewritten in place by update_config_value so the -# converter picks up the host/port/voice a wizard configured. -CONFIG_PATH = TTS_ROOT / "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 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 ``|``; 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 ``|`` 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/backends/envs.py b/backends/envs.py deleted file mode 100644 index 7596db6..0000000 --- a/backends/envs.py +++ /dev/null @@ -1,185 +0,0 @@ -"""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 -``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 -(``envs/tts/bin/python``, ``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 - -# One shared venv for the app requirements and every pip-installed backend. -ENV_DIR = TTS_ROOT / "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/backends/faster.py b/backends/faster.py deleted file mode 100755 index 50c6102..0000000 --- a/backends/faster.py +++ /dev/null @@ -1,416 +0,0 @@ -#!/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 ``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 -m backends.faster [--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 ( - TTS_ROOT, - 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 TTS_ROOT / 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"./{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 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 " - "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 " - "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 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: " - "./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 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 ./{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/backends/qwen.py b/backends/qwen.py deleted file mode 100644 index 52f7a3f..0000000 --- a/backends/qwen.py +++ /dev/null @@ -1,272 +0,0 @@ -#!/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 ``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 -m backends.qwen [--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 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 " - "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 " - "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 " - "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/backends/servers.py b/backends/servers.py deleted file mode 100644 index 12846f0..0000000 --- a/backends/servers.py +++ /dev/null @@ -1,244 +0,0 @@ -"""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 -``logs/-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 ``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 TTS_ROOT - -LOG_DIR = TTS_ROOT / "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/-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/converter/__init__.py b/converter/__init__.py deleted file mode 100644 index 86a827f..0000000 --- a/converter/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""TTS audiobook generator package.""" diff --git a/converter/audio.py b/converter/audio.py deleted file mode 100644 index d1dc28b..0000000 --- a/converter/audio.py +++ /dev/null @@ -1,616 +0,0 @@ -"""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 / "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/converter/chunking.py b/converter/chunking.py deleted file mode 100644 index 800b76a..0000000 --- a/converter/chunking.py +++ /dev/null @@ -1,91 +0,0 @@ -"""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 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/converter/config.py b/converter/config.py deleted file mode 100644 index 6a98136..0000000 --- a/converter/config.py +++ /dev/null @@ -1,77 +0,0 @@ -# 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/converter/converter.py b/converter/converter.py deleted file mode 100644 index b24ac09..0000000 --- a/converter/converter.py +++ /dev/null @@ -1,780 +0,0 @@ -"""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. -BASE_DIR = Path(__file__).resolve().parent.parent - -BOOKS_FOLDER = BASE_DIR / "input" -AUDIOBOOKS_FOLDER = BASE_DIR / "output" -CHUNKS_FOLDER = BASE_DIR / "chunks" # Per-chunk scratch audio, cleaned per book -LOGS_FOLDER = BASE_DIR / "logs" -DEBUG_FOLDER = BASE_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 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/converter/cover.py b/converter/cover.py deleted file mode 100644 index b2d3cb5..0000000 --- a/converter/cover.py +++ /dev/null @@ -1,279 +0,0 @@ -"""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/converter/extractors.py b/converter/extractors.py deleted file mode 100644 index a564333..0000000 --- a/converter/extractors.py +++ /dev/null @@ -1,328 +0,0 @@ -"""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"]*>.*?", "", html_content, flags=re.DOTALL | re.IGNORECASE) - html_content = re.sub(r"]*>.*?", "", 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/converter/tts.py b/converter/tts.py deleted file mode 100644 index f5d54e2..0000000 --- a/converter/tts.py +++ /dev/null @@ -1,1305 +0,0 @@ -"""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 / "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 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 when converting, or set " - "AUDIOCPP_MODEL_ID in 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 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 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 " - "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/docs/backend-audiocpp.md b/docs/backend-audiocpp.md deleted file mode 100644 index ee511bb..0000000 --- a/docs/backend-audiocpp.md +++ /dev/null @@ -1,91 +0,0 @@ -# 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 `./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 `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 -m backends.audiocpp` (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). The TUI runs in the managed `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/docs/backend-faster.md b/docs/backend-faster.md deleted file mode 100644 index c407a70..0000000 --- a/docs/backend-faster.md +++ /dev/null @@ -1,45 +0,0 @@ -# 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 -m backends.faster path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its managed venv (`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/docs/backend-qwen.md b/docs/backend-qwen.md deleted file mode 100644 index 028d6f3..0000000 --- a/docs/backend-qwen.md +++ /dev/null @@ -1,67 +0,0 @@ -# Backend Option 2: Qwen3-TTS - -The easiest way is to run `python audiobook.py` → **Set up a backend… → qwen-tts** (or `python -m backends.qwen`): the TUI pip-installs `qwen-tts` into its managed venv (`envs/tts`), configures the two ports and the built-in speaker in `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 `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/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/cover_test.png b/tests/cover_test.png deleted file mode 100644 index 0c252db..0000000 Binary files a/tests/cover_test.png and /dev/null differ diff --git a/tests/gen_test_cover.py b/tests/gen_test_cover.py deleted file mode 100644 index 292469a..0000000 --- a/tests/gen_test_cover.py +++ /dev/null @@ -1,8 +0,0 @@ -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/tests/test_audio.py b/tests/test_audio.py deleted file mode 100644 index ef5e92a..0000000 --- a/tests/test_audio.py +++ /dev/null @@ -1,524 +0,0 @@ -"""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/tests/test_backends.py b/tests/test_backends.py deleted file mode 100644 index c0e8d4a..0000000 --- a/tests/test_backends.py +++ /dev/null @@ -1,178 +0,0 @@ -"""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/tests/test_backends_audiocpp.py b/tests/test_backends_audiocpp.py deleted file mode 100644 index 9882ce1..0000000 --- a/tests/test_backends_audiocpp.py +++ /dev/null @@ -1,1062 +0,0 @@ -"""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/.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/tests/test_backends_envs.py b/tests/test_backends_envs.py deleted file mode 100644 index cf4ecc6..0000000 --- a/tests/test_backends_envs.py +++ /dev/null @@ -1,204 +0,0 @@ -"""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/tests/test_backends_faster.py b/tests/test_backends_faster.py deleted file mode 100644 index 641f6ee..0000000 --- a/tests/test_backends_faster.py +++ /dev/null @@ -1,172 +0,0 @@ -"""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/tests/test_backends_servers.py b/tests/test_backends_servers.py deleted file mode 100644 index 02b65e6..0000000 --- a/tests/test_backends_servers.py +++ /dev/null @@ -1,146 +0,0 @@ -"""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/tests/test_chunking.py b/tests/test_chunking.py deleted file mode 100644 index 2904e40..0000000 --- a/tests/test_chunking.py +++ /dev/null @@ -1,118 +0,0 @@ -"""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/tests/test_cleaning.py b/tests/test_cleaning.py deleted file mode 100644 index 41f4ed7..0000000 --- a/tests/test_cleaning.py +++ /dev/null @@ -1,54 +0,0 @@ -"""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("

Hello world

"), "Hello world") - - def test_removes_script_and_style(self): - html = "

Text

" - self.assertEqual(clean_html(html), "Text") - - def test_unescapes_entities(self): - self.assertEqual(clean_html("Tom & Jerry"), "Tom & Jerry") - - def test_empty(self): - self.assertEqual(clean_html(""), "") - self.assertEqual(clean_html(None), "") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_converter.py b/tests/test_converter.py deleted file mode 100644 index 2fe0f5d..0000000 --- a/tests/test_converter.py +++ /dev/null @@ -1,619 +0,0 @@ -"""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/tests/test_cover.py b/tests/test_cover.py deleted file mode 100644 index f19db5a..0000000 --- a/tests/test_cover.py +++ /dev/null @@ -1,189 +0,0 @@ -"""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/tests/test_extractors.py b/tests/test_extractors.py deleted file mode 100644 index ae1794c..0000000 --- a/tests/test_extractors.py +++ /dev/null @@ -1,213 +0,0 @@ -"""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"

{text}

" - 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/tests/test_hub.py b/tests/test_hub.py deleted file mode 100644 index b3a17e8..0000000 --- a/tests/test_hub.py +++ /dev/null @@ -1,502 +0,0 @@ -"""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/tests/test_tts.py b/tests/test_tts.py deleted file mode 100644 index a2df07f..0000000 --- a/tests/test_tts.py +++ /dev/null @@ -1,1513 +0,0 @@ -"""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/tests/test_tui.py b/tests/test_tui.py deleted file mode 100644 index c121d55..0000000 --- a/tests/test_tui.py +++ /dev/null @@ -1,661 +0,0 @@ -"""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/ui/__init__.py b/ui/__init__.py deleted file mode 100644 index 4986a10..0000000 --- a/ui/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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/ui/hub.py b/ui/hub.py deleted file mode 100644 index b2a2b08..0000000 --- a/ui/hub.py +++ /dev/null @@ -1,637 +0,0 @@ -#!/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 ./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 -> 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 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 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/ui/tui.py b/ui/tui.py deleted file mode 100644 index 9047f36..0000000 --- a/ui/tui.py +++ /dev/null @@ -1,1145 +0,0 @@ -#!/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") -- cgit v1.2.3