aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/backends/common.py27
-rw-r--r--app/backends/envs.py122
-rwxr-xr-xapp/backends/faster.py36
-rw-r--r--app/backends/qwen.py43
-rw-r--r--app/docs/backend-faster.md12
-rw-r--r--app/docs/backend-qwen.md2
-rw-r--r--app/tests/test_backends.py19
-rw-r--r--app/tests/test_backends_envs.py84
-rw-r--r--app/tests/test_backends_faster.py9
9 files changed, 246 insertions, 108 deletions
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()