aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-27 00:04:16 -0400
committerhistoria <historiavg@proton.me>2026-08-27 00:04:16 -0400
commit5b98993b13dafe9a85495e4c68ad9b42863ef2bf (patch)
tree57db1566deb5351ec6bfb8c70de4126bbc5fc2da /app/backends
parentf18f421d9180ae0e3bff9496b1fdaf53d3624a75 (diff)
downloadtts-audiobook-generator-5b98993b13dafe9a85495e4c68ad9b42863ef2bf.tar.gz
feat: separate venvs for qwen-tts and faster, manage (un)installs
Diffstat (limited to 'app/backends')
-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
4 files changed, 137 insertions, 91 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