diff options
| author | historia <historiavg@proton.me> | 2026-08-27 00:04:16 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-27 00:04:16 -0400 |
| commit | 5b98993b13dafe9a85495e4c68ad9b42863ef2bf (patch) | |
| tree | 57db1566deb5351ec6bfb8c70de4126bbc5fc2da /app/backends | |
| parent | f18f421d9180ae0e3bff9496b1fdaf53d3624a75 (diff) | |
| download | tts-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.py | 27 | ||||
| -rw-r--r-- | app/backends/envs.py | 122 | ||||
| -rwxr-xr-x | app/backends/faster.py | 36 | ||||
| -rw-r--r-- | app/backends/qwen.py | 43 |
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 |
