From 5b98993b13dafe9a85495e4c68ad9b42863ef2bf Mon Sep 17 00:00:00 2001 From: historia Date: Thu, 27 Aug 2026 00:04:16 -0400 Subject: feat: separate venvs for qwen-tts and faster, manage (un)installs --- .gitignore | 5 +- README.md | 2 +- app/backends/common.py | 27 +++++---- app/backends/envs.py | 122 +++++++++++++++++++++++--------------- app/backends/faster.py | 36 +++++++---- app/backends/qwen.py | 43 ++++++++------ app/docs/backend-faster.md | 12 ++-- app/docs/backend-qwen.md | 2 +- app/tests/test_backends.py | 19 +++--- app/tests/test_backends_envs.py | 84 +++++++++++++++++++++++++- app/tests/test_backends_faster.py | 9 ++- 11 files changed, 250 insertions(+), 111 deletions(-) diff --git a/.gitignore b/.gitignore index 538fd12..cba50dd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,9 @@ voices/* /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). +# Managed Python environments created by audiobook.py (envs.py): the app +# env (envs/tts) and one per pip-installed backend (envs/qwen for qwen-tts, +# envs/faster for faster-qwen3-tts). /app/envs/ *.epub diff --git a/README.md b/README.md index 775b9db..e1fa32d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ cd tts-audiobook-generator - `./output` - Audio files will output here - `./voices` - `.wav` files of voices to clone (10-20 seconds) -3. Run `audiobook.py`. It will automatically created a virtual environment and install its requirements. +3. Run `audiobook.py`. It will automatically create the app's virtual environment and install its requirements (each pip-installed TTS backend later gets a managed venv of its own under `app/envs/`). ``` python audiobook.py ``` diff --git a/app/backends/common.py b/app/backends/common.py index b9448e0..098dfb2 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -496,22 +496,27 @@ def git_clone(url: str, target: Path, *, emit=None, cancel=None) -> int: emit=emit, cancel=cancel) -def pip_install(packages: List[str], *, emit=None, cancel=None) -> int: - """pip install PACKAGES into the managed venv (``envs/tts``). Returns exit code. +def pip_install(packages: List[str], *, emit=None, cancel=None, + env_dir: Optional[Path] = None) -> int: + """pip install PACKAGES into a managed venv. 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. - With EMIT given (the in-TUI task view) pip runs with its output streamed - into EMIT; CANCEL aborts it. The import is local to avoid a circular - import (envs imports this module). + installed into their dedicated tool-managed environments (``envs/tts`` + default; ``envs/qwen`` / ``envs/faster`` via ENV_DIR) rather than into + whatever interpreter happens to be running the wizard — and never two + conflicting stacks into the same env. With EMIT given (the in-TUI task + view) pip runs with its output streamed into EMIT; CANCEL aborts it. + The import is local to avoid a circular import (envs imports this + module). """ from backends import envs - return envs.pip_install(packages, emit=emit, cancel=cancel) + return envs.pip_install(packages, emit=emit, cancel=cancel, + env_dir=env_dir) -def pip_uninstall(packages: List[str], *, emit=None) -> int: - """pip uninstall PACKAGES from the managed venv. Returns exit code. +def pip_uninstall(packages: List[str], *, emit=None, + env_dir: Optional[Path] = None) -> int: + """pip uninstall PACKAGES from a managed venv. Returns exit code. Delegates to ``backends.envs.pip_uninstall`` (local import to avoid a circular import). Used by the backends' ``uninstall`` action. With EMIT @@ -519,4 +524,4 @@ def pip_uninstall(packages: List[str], *, emit=None) -> int: its output never touches the terminal behind curses. """ from backends import envs - return envs.pip_uninstall(packages, emit=emit) + return envs.pip_uninstall(packages, emit=emit, env_dir=env_dir) diff --git a/app/backends/envs.py b/app/backends/envs.py index f120eb9..50154c2 100644 --- a/app/backends/envs.py +++ b/app/backends/envs.py @@ -1,18 +1,23 @@ -"""The managed Python environment for the audiobook generator and its backends. +"""The managed Python environments 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 +is fine): on startup it bootstraps a single tool-managed app venv at +``app/envs/tts`` and re-execs itself inside it. That venv holds the audiobook +app's own ``requirements.txt`` dependencies only. The backend TTS packages the +setup wizards pip install get dedicated venvs of their own — +``app/envs/qwen`` for ``qwen-tts``, ``app/envs/faster`` for +``faster-qwen3-tts[demo]`` — so their heavyweight torch/transformers stacks +never share an environment with each other or with the app (qwen-tts and +faster-qwen3-tts both ship a ``qwen_tts`` module with conflicting +transformers pins, which makes a shared install break whichever distribution +lands last). 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. +helper here resolves an env's binaries by absolute path +(``app/envs/tts/bin/python``, ``app/envs/qwen/bin/qwen-tts-demo``), so the hub can +spawn servers in these envs 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 @@ -33,8 +38,12 @@ 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. +# One app venv for the app requirements; one venv per pip-installed TTS +# backend. The per-backend split keeps qwen-tts's transformers 4 pin away +# from faster-qwen3-tts's transformers 5 requirement (and away from the app). ENV_DIR = TTS_ROOT / "app" / "envs" / "tts" +QWEN_ENV_DIR = TTS_ROOT / "app" / "envs" / "qwen" +FASTER_ENV_DIR = TTS_ROOT / "app" / "envs" / "faster" REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt" # requirements.txt lines whose comment starts with this tag are installed @@ -57,44 +66,51 @@ 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_python(env_dir: Optional[Path] = None) -> Path: + """Absolute path to an env's python interpreter (the app env by default).""" + base = env_dir if env_dir is not None else ENV_DIR + return base / ("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).""" +def env_script(name: str, env_dir: Optional[Path] = None) -> Path: + """Absolute path to a console script installed in an env (e.g. qwen-tts-demo). + + ENV_DIR defaults to the app env; pass a backend's env dir (QWEN_ENV_DIR, + FASTER_ENV_DIR) for its scripts. + """ subdir = "Scripts" if _is_windows() else "bin" suffix = ".exe" if _is_windows() else "" - return ENV_DIR / subdir / f"{name}{suffix}" + return (env_dir if env_dir is not None else 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 env_exists(env_dir: Optional[Path] = None) -> bool: + """True when the given env's python interpreter is present on disk.""" + return env_python(env_dir).is_file() def is_managed_env() -> bool: - """True when the current process is already running inside the managed venv.""" + """True when the current process is already running inside the app 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). +def create_env(env_dir: Optional[Path] = None) -> int: + """Create ENV (an env dir, defaulting to the app one) 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. """ + target = env_dir if env_dir is not None else ENV_DIR print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print(" Setting up your environment for the first time...") print(" This may take a minute.") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print(f"[INFO] creating managed environment at {ENV_DIR}...") + print(f"[INFO] creating managed environment at {target}...") rc = common.run_console_subprocess( - [sys.executable, "-m", "venv", str(ENV_DIR)]) + [sys.executable, "-m", "venv", str(target)]) if rc != 0: print(f"[ERROR] python -m venv failed (exit {rc}).") if _is_windows(): @@ -173,18 +189,23 @@ def install_requirements(skip_optional: bool = False) -> int: [str(env_python()), "-m", "pip", "install", *installable]) -def pip_install(packages: List[str], *, emit=None, cancel=None) -> int: - """pip install PACKAGES into the venv, creating it first if needed. +def pip_install(packages: List[str], *, emit=None, cancel=None, + env_dir: Optional[Path] = None) -> int: + """pip install PACKAGES into ENV (an env dir, default the app env), + 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. With EMIT given - (the in-TUI task view) pip runs with ``--progress-bar off`` so its output - is clean status lines rather than carriage-return progress spam. + Used by the qwen/faster setup wizards to install their TTS packages into + their dedicated backend venvs (QWEN_ENV_DIR / FASTER_ENV_DIR), never + alongside each other or the app requirements. Returns pip's exit code. + With EMIT given (the in-TUI task view) pip runs with ``--progress-bar + off`` so its output is clean status lines rather than carriage-return + progress spam. """ - if not env_exists() and create_env() != 0: + if not env_exists(env_dir) and create_env(env_dir) != 0: return 1 - print(f"[INFO] pip install {' '.join(packages)} into {ENV_DIR}...") - argv = [str(env_python()), "-m", "pip", "install"] + target = env_dir if env_dir is not None else ENV_DIR + print(f"[INFO] pip install {' '.join(packages)} into {target}...") + argv = [str(env_python(env_dir)), "-m", "pip", "install"] if emit is not None: argv.append("--progress-bar") argv.append("off") @@ -192,35 +213,38 @@ def pip_install(packages: List[str], *, emit=None, cancel=None) -> int: return common.run_console_subprocess(argv, emit=emit, cancel=cancel) -def pip_uninstall(packages: List[str], *, emit=None) -> int: - """pip uninstall PACKAGES from the venv. Returns pip's exit code. +def pip_uninstall(packages: List[str], *, emit=None, + env_dir: Optional[Path] = None) -> int: + """pip uninstall PACKAGES from ENV (an env dir, default the app env). + Returns pip's exit code. Used by the backends' ``uninstall`` action to remove pip-installed TTS - packages from the managed environment. A missing env is a no-op (there - is nothing to uninstall from), reported as success. With EMIT given - (the in-TUI task view) pip runs with its output piped and streamed to - EMIT, so nothing writes to the terminal behind curses. + packages from their dedicated environments. A missing env is a no-op + (there is nothing to uninstall from), reported as success. With EMIT + given (the in-TUI task view) pip runs with its output piped and streamed + to EMIT, so nothing writes to the terminal behind curses. """ - if not env_exists(): + if not env_exists(env_dir): return 0 - print(f"[INFO] pip uninstall {' '.join(packages)} from {ENV_DIR}...") + target = env_dir if env_dir is not None else ENV_DIR + print(f"[INFO] pip uninstall {' '.join(packages)} from {target}...") return common.run_console_subprocess( - [str(env_python()), "-m", "pip", "uninstall", "-y", *packages], + [str(env_python(env_dir)), "-m", "pip", "uninstall", "-y", *packages], emit=emit) -def module_available(module: str) -> bool: - """True when MODULE imports inside the venv (e.g. qwen_tts, faster_qwen3_tts). +def module_available(module: str, env_dir: Optional[Path] = None) -> bool: + """True when MODULE imports inside ENV (e.g. qwen_tts in QWEN_ENV_DIR). - 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``. + A short subprocess probe against the env's interpreter — the equivalent + of importlib.util.find_spec, but for a managed env rather than the + current one. Used by each backend's ``_is_installed``. """ - if not env_exists(): + if not env_exists(env_dir): return False try: result = subprocess.run( - [str(env_python()), "-c", f"import {module}"], + [str(env_python(env_dir)), "-c", f"import {module}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=False) except (OSError, subprocess.TimeoutExpired): diff --git a/app/backends/faster.py b/app/backends/faster.py index 6cfa53e..3631153 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -5,9 +5,11 @@ 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 +package into its own managed venv (``app/envs/faster`` — separate from the +app venv and from qwen-tts's: both stacks ship a ``qwen_tts`` module whose +transformers requirements conflict), 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: @@ -57,6 +59,10 @@ from ui import taskview, tui FASTER_DIR_NAME = "faster-qwen3-tts" FASTER_GIT_URL = "https://github.com/andimarafioti/faster-qwen3-tts" FASTER_PIP_PKG = "faster-qwen3-tts[demo]" +# The dedicated venv faster-qwen3-tts is installed into (never the app env +# or the qwen backend's; the wheel pulls its own qwen-tts-hf dependency, +# which ships the same qwen_tts module upstream qwen-tts does). +FASTER_ENV = envs.FASTER_ENV_DIR WHISPER_MODELS = ("tiny", "base", "small", "medium", "large-v3") @@ -65,7 +71,7 @@ def _checkout() -> Path: def _is_installed() -> bool: - return envs.module_available("faster_qwen3_tts") + return envs.module_available("faster_qwen3_tts", FASTER_ENV) def _is_cloned() -> bool: @@ -297,7 +303,8 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]: if settings["do_install"]: def install(emit, cancel): - rc = common.pip_install([FASTER_PIP_PKG], emit=emit, cancel=cancel) + rc = common.pip_install([FASTER_PIP_PKG], emit=emit, + cancel=cancel, env_dir=FASTER_ENV) if rc != 0: print(f"[WARNING] pip install failed (exit {rc}); install " f"{FASTER_PIP_PKG} manually") @@ -446,7 +453,8 @@ def build_parser() -> argparse.ArgumentParser: "prompting; in the TUI, re-transcribe every " "voice instead of reusing the existing file") parser.add_argument("--skip-install", action="store_true", - help="Do not pip install faster-qwen3-tts[demo]") + help="Do not pip install faster-qwen3-tts[demo] " + "into app/envs/faster") parser.add_argument("--skip-clone", action="store_true", help="Do not clone the faster-qwen3-tts repo") return parser @@ -468,7 +476,7 @@ def detect() -> BackendStatus: launch = "" specs: List[ServerSpec] = [] if cloned and voices_json.exists(): - argv = [str(envs.env_python()), + argv = [str(envs.env_python(FASTER_ENV)), str(_checkout() / "examples" / "openai_server.py"), "--voices", str(voices_json), "--port", str(_config_port())] # identity: /health must report model_loaded before the server is @@ -506,10 +514,11 @@ def _detect_remote(managed: bool = False): def uninstall(*, emit=None, cancel=None) -> int: """Remove the faster-qwen3-tts backend entirely. - Uninstalls the pip package (``faster-qwen3-tts``) from the managed venv - and deletes the cloned checkout (``app/faster-qwen3-tts``, which holds - examples/openai_server.py and voices.json). A running server this tool - started is stopped first (best-effort). + Uninstalls the pip package (``faster-qwen3-tts``) from its managed venv + (``app/envs/faster``, FASTER_ENV — never the app env or the qwen + backend's) and deletes the cloned checkout (``app/faster-qwen3-tts``, + which holds examples/openai_server.py and voices.json). A running server + this tool started is stopped first (best-effort). With EMIT given (the in-TUI task view) pip runs piped, streaming into EMIT, so its output never touches the terminal behind curses. CANCEL is @@ -525,10 +534,11 @@ def uninstall(*, emit=None, cancel=None) -> int: servers.stop("faster") if common.cancel_requested(cancel): return 130 - rc = common.pip_uninstall(["faster-qwen3-tts"], emit=emit) + rc = common.pip_uninstall(["faster-qwen3-tts"], emit=emit, + env_dir=FASTER_ENV) if rc != 0: print("[WARNING] pip uninstall failed (exit " - f"{rc}); remove faster-qwen3-tts from the managed venv manually") + f"{rc}); remove faster-qwen3-tts from {FASTER_ENV} manually") else: print("[OK] faster-qwen3-tts removed.") if common.cancel_requested(cancel): diff --git a/app/backends/qwen.py b/app/backends/qwen.py index 3309c69..b2a1df4 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -4,8 +4,9 @@ qwen-tts is a pip package providing the ``qwen-tts-demo`` server, which hosts ONE Qwen3-TTS model per process — CustomVoice (built-in speakers), Base (voice cloning) or VoiceDesign (described voice). This module sets it up -end-to-end: pip-install the package into the managed venv. There are no -questions to ask — the port and which model to run live in +end-to-end: pip-install the package into its own managed venv +(``app/envs/qwen`` — the app venv and the faster backend's never receive +it). There are no questions to ask — the port and which model to run live in ``app/converter/config.py`` (the model is chosen per run on the hub's Generate-audiobooks screen), and only one server runs at a time. @@ -46,6 +47,9 @@ from converter.clients import QWEN3_TTS_SPEAKERS from ui import taskview, tui QWEN_PIP_PKG = "qwen-tts" +# The dedicated venv qwen-tts is installed into (never the app env or the +# faster backend's). +QWEN_ENV = envs.QWEN_ENV_DIR DEFAULT_PORT = 7860 # The models a single demo server can host, by config.QWEN_MODEL name. @@ -161,9 +165,9 @@ def delete_model_weights(models: Optional[List[str]] = None) -> int: def _is_installed() -> bool: - if envs.env_script("qwen-tts-demo").is_file(): + if envs.env_script("qwen-tts-demo", QWEN_ENV).is_file(): return True - return envs.module_available("qwen_tts") + return envs.module_available("qwen_tts", QWEN_ENV) def _config_port(url: str, fallback: int) -> int: @@ -218,7 +222,8 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]: return steps def install(emit, cancel): - rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel) + rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel, + env_dir=QWEN_ENV) if rc != 0: print(f"[WARNING] pip install failed (exit {rc}); install " f"{QWEN_PIP_PKG} manually") @@ -268,7 +273,7 @@ def _collect_from_flags(args: argparse.Namespace, def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Set up the Qwen3-TTS demo backend: pip install " - "qwen-tts into the managed venv.") + "qwen-tts into its managed venv (app/envs/qwen).") parser.add_argument("--skip-install", action="store_true", help="Do not pip install qwen-tts") return parser @@ -279,7 +284,7 @@ def _build_spec(model: str) -> ServerSpec: url = config.QWEN_API_URL return ServerSpec( "qwen", url, - [str(envs.env_script("qwen-tts-demo")), MODEL_REPOS[model], + [str(envs.env_script("qwen-tts-demo", QWEN_ENV)), MODEL_REPOS[model], "--ip", "127.0.0.1", "--port", str(_config_port(url, DEFAULT_PORT))], identity=desired_identity(model)) @@ -363,9 +368,9 @@ def _detect_remote(managed: bool = False): def _hf_download_prefix() -> Optional[List[str]]: - """The venv's hf CLI argv prefix (None when neither script is present).""" + """The qwen env's hf CLI argv prefix (None when neither script is present).""" for name in ("hf", "huggingface-cli"): - candidate = envs.env_script(name) + candidate = envs.env_script(name, QWEN_ENV) if candidate.is_file(): return [str(candidate)] return None @@ -381,7 +386,7 @@ def install_model(model: str, *, emit=None, cancel=None) -> int: """ prefix = _hf_download_prefix() if prefix is None: - print("[ERROR] No hf CLI found in the managed venv; pip install " + print("[ERROR] No hf CLI found in the qwen venv; pip install " f"{QWEN_PIP_PKG} first") return 1 repo = MODEL_REPOS[model] @@ -415,12 +420,14 @@ def uninstall(*, emit=None, cancel=None) -> int: then delete every downloaded model. qwen-tts is a pip package (``qwen_tts`` + the ``qwen-tts-demo`` script) - installed into the managed venv, so uninstalling it removes the backend. - Any server this tool started is stopped first (best-effort). The three - models' weight snapshots — multi-GB directories lazily fetched into the - HuggingFace cache (~/.cache/huggingface/hub) — are deleted too, matching - the hub's confirmation dialog; only those three directories are removed, - never the shared cache itself. + installed into its own managed venv (``app/envs/qwen``, QWEN_ENV), so + uninstalling it removes the backend — the app venv and the faster + backend's env are never touched. Any server this tool started is stopped + first (best-effort). The three models' weight snapshots — multi-GB + directories lazily fetched into the HuggingFace cache + (~/.cache/huggingface/hub) — are deleted too, matching the hub's + confirmation dialog; only those three directories are removed, never the + shared cache itself. With EMIT given (the in-TUI task view) pip runs piped, streaming into EMIT, so its output never touches the terminal behind curses. CANCEL is @@ -436,10 +443,10 @@ def uninstall(*, emit=None, cancel=None) -> int: servers.stop("qwen") if common.cancel_requested(cancel): return 130 - rc = common.pip_uninstall([QWEN_PIP_PKG], emit=emit) + rc = common.pip_uninstall([QWEN_PIP_PKG], emit=emit, env_dir=QWEN_ENV) if rc != 0: print(f"[WARNING] pip uninstall failed (exit {rc}); remove " - f"{QWEN_PIP_PKG} from the managed venv manually") + f"{QWEN_PIP_PKG} from {QWEN_ENV} manually") else: print(f"[OK] {QWEN_PIP_PKG} removed.") # Weights go even when the pip step failed: the package can be diff --git a/app/docs/backend-faster.md b/app/docs/backend-faster.md index 37d0f23..9aa297e 100644 --- a/app/docs/backend-faster.md +++ b/app/docs/backend-faster.md @@ -2,16 +2,18 @@ `--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` → **Configure backends… → Install 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 (faster-whisper, installed when wheels exist for your platform — otherwise you type the transcripts), and writes `voices.json` for you — all on one options screen (voices directory, language, whisper model, and what to re-transcribe on a modify run). The server port is not asked: it lives in `FASTER_API_URL` (edit it in **Settings**). You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → faster-qwen3-tts** (or `python app/backends/faster.py path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its own managed venv (`app/envs/faster`, separate from the app's venv and from the qwen backend's — both TTS stacks ship conflicting versions of a shared `qwen_tts` module; the faster wheel pulls its own `qwen-tts-hf` build of it automatically), clones the repo, transcribes the `.wav` files with whisper (faster-whisper, installed when wheels exist for your platform — otherwise you type the transcripts), and writes `voices.json` for you — all on one options screen (voices directory, language, whisper model, and what to re-transcribe on a modify run). The server port is not asked: it lives in `FASTER_API_URL` (edit it in **Settings**). You can also start the server from the hub's **Start/Stop Backend Servers** 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. To use a server on another machine, set `FASTER_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:8000`) — the hub probes it and offers a `faster-qwen3-tts [remote]` entry — or pass `--api-url` on the CLI. -Install into your environment (the same one used for qwen-tts is fine): +Install into your environment (this backend does **not** need the `qwen-tts` +pip package — the wheel pulls the compatible `qwen-tts-hf` build of the +`qwen_tts` library automatically, so keep it out of any venv that also has +upstream `qwen-tts` installed): ```bash -python -m venv audiobook -source audiobook/bin/activate -pip install -U qwen-tts +python -m venv audiobook-faster +source audiobook-faster/bin/activate pip install "faster-qwen3-tts[demo]" ``` diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md index 3fd55ca..8325d95 100644 --- a/app/docs/backend-qwen.md +++ b/app/docs/backend-qwen.md @@ -1,6 +1,6 @@ # Backend Option 2: Qwen3-TTS -The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate audiobooks** screen (the choice is remembered in `QWEN_MODEL` and re-used by the next autostart; switching models while a managed server is up restarts it with the newly-selected model). You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate audiobooks** screen (the choice is remembered in `QWEN_MODEL` and re-used by the next autostart; switching models while a managed server is up restarts it with the newly-selected model). You can also start the server from the hub's **Start/Stop Backend Servers** 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 (its `GET /info` names which of the three demos answers), so a manually-installed backend works once its server is up. To use a demo server on another machine, set `QWEN_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:7860`) — the hub probes it and offers the matching `qwen-tts [remote]` mode limited to the model that server hosts — or pass `--api-url` on the CLI. diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 1e1a3f9..7fdcce6 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -420,7 +420,8 @@ class QwenUninstallWeightsTests(unittest.TestCase): return_value=0) as mk_pip: rc = qwen.uninstall(emit="EMIT") self.assertEqual(rc, 0) - mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT") + mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT", + env_dir=qwen.QWEN_ENV) self.assertEqual(list(Path(td).iterdir()), []) def test_weights_deleted_even_when_pip_failed(self): @@ -459,7 +460,8 @@ class QwenUninstallWeightsTests(unittest.TestCase): side_effect=pip_flips_cancel) as mk_pip: rc = qwen.uninstall(cancel=cancel) self.assertEqual(rc, 130) - mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit=None) + mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit=None, + env_dir=qwen.QWEN_ENV) # Cancelled between phases: the weights stay untouched... self.assertEqual(len(list(Path(td).iterdir())), 3) @@ -549,13 +551,14 @@ class QwenInstallModelTests(unittest.TestCase): def test_hf_cli_prefers_hf_then_falls_back(self): from backends import qwen with tempfile.TemporaryDirectory() as td: - with patch.object(qwen.envs, "ENV_DIR", Path(td)): + # The CLI is looked up in the qwen backend's own venv. + with patch.object(qwen, "QWEN_ENV", Path(td)): self.assertIsNone(qwen._hf_download_prefix()) - cli = qwen.envs.env_script("huggingface-cli") + cli = qwen.envs.env_script("huggingface-cli", qwen.QWEN_ENV) cli.parent.mkdir(parents=True) cli.write_bytes(b"x") self.assertEqual(qwen._hf_download_prefix(), [str(cli)]) - hf = qwen.envs.env_script("hf") + hf = qwen.envs.env_script("hf", qwen.QWEN_ENV) hf.write_bytes(b"x") self.assertEqual(qwen._hf_download_prefix(), [str(hf)]) @@ -827,8 +830,10 @@ class QwenUninstallTests(unittest.TestCase): self.assertEqual(rc, 0) self.assertEqual([c.args[0] for c in mk_stop.call_args_list], ["qwen"]) - # The task view's emit is forwarded so pip never touches the terminal. - mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT") + # The task view's emit is forwarded so pip never touches the terminal, + # and the package comes out of the qwen backend's own venv. + mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT", + env_dir=qwen.QWEN_ENV) def test_skips_stop_when_no_server_was_started(self): # No pid files: stop() is not called (no "not started by this diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py index 184a3b3..8b17458 100644 --- a/app/tests/test_backends_envs.py +++ b/app/tests/test_backends_envs.py @@ -16,25 +16,47 @@ class EnvPathTests(unittest.TestCase): self.assertEqual(envs.ENV_DIR.name, "tts") self.assertEqual(envs.ENV_DIR.parent.name, "envs") + def test_backend_env_dirs_are_separate_from_the_app_env(self): + # Each pip-installed backend gets its own venv next to the app's: + # qwen-tts and faster-qwen3-tts both ship a qwen_tts module with + # conflicting transformers pins, so they must never share one. + self.assertEqual(envs.QWEN_ENV_DIR.name, "qwen") + self.assertEqual(envs.FASTER_ENV_DIR.name, "faster") + self.assertEqual(envs.QWEN_ENV_DIR.parent, envs.ENV_DIR.parent) + distinct = {envs.ENV_DIR, envs.QWEN_ENV_DIR, envs.FASTER_ENV_DIR} + self.assertEqual(len(distinct), 3) + 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") + self.assertEqual( + envs.env_python(envs.QWEN_ENV_DIR), + envs.QWEN_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") + self.assertEqual( + envs.env_python(envs.FASTER_ENV_DIR), + envs.FASTER_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") + self.assertEqual( + envs.env_script("qwen-tts-demo", envs.QWEN_ENV_DIR), + envs.QWEN_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") + self.assertEqual( + envs.env_script("qwen-tts-demo", envs.QWEN_ENV_DIR), + envs.QWEN_ENV_DIR / "Scripts" / "qwen-tts-demo.exe") def test_env_exists_false_when_python_missing(self): with patch.object(envs, "env_python", @@ -63,6 +85,13 @@ class CreateEnvTests(unittest.TestCase): self.assertEqual(argv[2], "venv") self.assertEqual(argv[3], str(envs.ENV_DIR)) + def test_create_env_targets_the_requested_env_dir(self): + with patch.object(envs.common, "run_console_subprocess", + return_value=0) as run: + envs.create_env(envs.FASTER_ENV_DIR) + argv = run.call_args[0][0] + self.assertEqual(argv[3], str(envs.FASTER_ENV_DIR)) + def test_create_env_reports_remediation_on_failure(self): with patch.object(envs.common, "run_console_subprocess", return_value=1): @@ -84,12 +113,31 @@ class PipInstallTests(unittest.TestCase): side_effect=fake_run): rc = envs.pip_install(["qwen-tts"]) self.assertEqual(rc, 0) - mk.assert_called_once_with() + mk.assert_called_once_with(None) # 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_backend_env_targets_the_backend_python_and_env(self): + calls = [] + + def fake_run(argv, **kwargs): + 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"], env_dir=envs.QWEN_ENV_DIR) + self.assertEqual(rc, 0) + # Both create-if-missing and pip itself are scoped to the qwen env; + # the app env is never touched. + mk.assert_called_once_with(envs.QWEN_ENV_DIR) + self.assertEqual(calls[0][0], + str(envs.env_python(envs.QWEN_ENV_DIR))) + def test_skips_create_when_env_exists(self): with patch.object(envs, "env_exists", return_value=True), \ patch.object(envs, "create_env") as mk, \ @@ -115,6 +163,13 @@ class PipUninstallTests(unittest.TestCase): self.assertEqual(rc, 0) run.assert_not_called() + def test_missing_backend_env_is_also_a_noop(self): + with patch.object(envs, "env_exists", return_value=False), \ + patch.object(envs.common, "run_console_subprocess") as run: + rc = envs.pip_uninstall(["qwen-tts"], env_dir=envs.QWEN_ENV_DIR) + self.assertEqual(rc, 0) + run.assert_not_called() + def test_runs_pip_uninstall_against_the_venv_python(self): calls = [] @@ -133,6 +188,21 @@ class PipUninstallTests(unittest.TestCase): self.assertIn("-y", calls[0]) self.assertIn("qwen-tts", calls[0]) + def test_targets_the_requested_env_python(self): + calls = [] + + def fake_run(argv, **kwargs): + calls.append(list(argv)) + return 0 + + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs.common, "run_console_subprocess", + side_effect=fake_run): + envs.pip_uninstall(["faster-qwen3-tts"], + env_dir=envs.FASTER_ENV_DIR) + self.assertEqual(calls[0][0], + str(envs.env_python(envs.FASTER_ENV_DIR))) + def test_streams_to_emit_when_given(self): with patch.object(envs, "env_exists", return_value=True), \ patch.object(envs.common, "run_console_subprocess", @@ -167,6 +237,18 @@ class ModuleAvailableTests(unittest.TestCase): self.assertEqual(argv[0], str(envs.env_python())) self.assertIn("import qwen_tts", argv[2]) + def test_probes_the_requested_env_interpreter(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", envs.QWEN_ENV_DIR)) + argv = run.call_args[0][0] + self.assertEqual(argv[0], + str(envs.env_python(envs.QWEN_ENV_DIR))) + self.assertIn("import qwen_tts", argv[2]) + def test_false_when_subprocess_exits_nonzero(self): import subprocess fake = subprocess.CompletedProcess(args=["x"], returncode=1) diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index a093fbe..8f024bf 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -419,8 +419,10 @@ class UninstallTests(unittest.TestCase): rc = make_voices.uninstall(emit="EMIT") self.assertEqual(rc, 0) mk_stop.assert_called_once_with("faster") - # The task view's emit is forwarded so pip never touches the terminal. - mk_pip.assert_called_once_with(["faster-qwen3-tts"], emit="EMIT") + # The task view's emit is forwarded so pip never touches the terminal, + # and the package comes out of the faster backend's own venv. + mk_pip.assert_called_once_with(["faster-qwen3-tts"], emit="EMIT", + env_dir=make_voices.FASTER_ENV) self.assertFalse(checkout.exists()) def test_no_checkout_still_uninstalls_the_package(self): @@ -435,7 +437,8 @@ class UninstallTests(unittest.TestCase): self.assertEqual(rc, 0) # No pid file: no stop attempt (and no noise about it). mk_stop.assert_not_called() - mk_pip.assert_called_once_with(["faster-qwen3-tts"], emit=None) + mk_pip.assert_called_once_with(["faster-qwen3-tts"], emit=None, + env_dir=make_voices.FASTER_ENV) def test_cancel_before_pip_skips_everything_after_stopping(self): cancel = threading.Event() -- cgit v1.2.3