aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-27 17:03:57 -0400
committerhistoria <historiavg@proton.me>2026-08-27 17:03:57 -0400
commitcef2352a5e81b272d067c2c02eb9588e54edfcfd (patch)
tree83af416a82d9e854f44b6f3207eb8ea648b0896d /app/backends
parent6527240aa69a08f06e36e796818721abeec9f592 (diff)
downloadtts-audiobook-generator-cef2352a5e81b272d067c2c02eb9588e54edfcfd.tar.gz
feat: automatic updates added to configure backend menu
Diffstat (limited to 'app/backends')
-rw-r--r--app/backends/__init__.py8
-rw-r--r--app/backends/audiocpp/__init__.py6
-rw-r--r--app/backends/audiocpp/build.py114
-rw-r--r--app/backends/common.py114
-rw-r--r--app/backends/envs.py16
-rwxr-xr-xapp/backends/faster.py38
-rw-r--r--app/backends/qwen.py27
7 files changed, 308 insertions, 15 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
index e59c464..8c9f045 100644
--- a/app/backends/__init__.py
+++ b/app/backends/__init__.py
@@ -160,6 +160,10 @@ class BackendInfo:
removes the backend (stops its servers, pip-uninstalls, deletes its
files); the hub runs it inside the task view, calling it with optional
``emit``/``cancel`` keywords (cancel honored between phases only).
+ UPDATE refreshes the installed backend to the latest upstream version
+ (pip -U / git fetch+reset, rebuilding where a binary must match the
+ sources); same calling convention as UNINSTALL. Without UPDATE a
+ backend is skipped by the hub's "Update backends" action.
CONFIGURE_SCREEN, when given, is what the hub's "Configure <label>"
menu entry runs instead of SETUP_SCREEN once the backend exists — a
@@ -173,6 +177,7 @@ class BackendInfo:
detect: Callable[[], BackendStatus]
setup_screen: Callable[[object], int]
uninstall: Callable[..., int] = lambda *args, **kwargs: 0
+ update: Optional[Callable[..., int]] = None
configure_screen: Optional[Callable[[object], int]] = None
@@ -192,6 +197,7 @@ def _build_registry() -> None:
detect=audiocpp.detect,
setup_screen=audiocpp.setup_screen,
uninstall=audiocpp.uninstall,
+ update=audiocpp.update,
))
REGISTRY.append(BackendInfo(
key="qwen",
@@ -199,6 +205,7 @@ def _build_registry() -> None:
detect=qwen.detect,
setup_screen=qwen.setup_screen,
uninstall=qwen.uninstall,
+ update=qwen.update,
configure_screen=qwen.models_screen,
))
REGISTRY.append(BackendInfo(
@@ -207,6 +214,7 @@ def _build_registry() -> None:
detect=faster.detect,
setup_screen=faster.setup_screen,
uninstall=faster.uninstall,
+ update=faster.update,
))
for info in REGISTRY:
_BY_KEY[info.key] = info
diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py
index b97032b..f55cf35 100644
--- a/app/backends/audiocpp/__init__.py
+++ b/app/backends/audiocpp/__init__.py
@@ -10,7 +10,8 @@ Modules:
models install state on disk, missing-model guidance, downloads
voices reference-.wav transcription planning and execution
configsync app/converter/config.py + server.json port/id/backend sync
- build checkout lifecycle: ggml patches, binary build, uninstall
+ build checkout lifecycle: ggml patches, binary build, update,
+ uninstall
remote querying a running server for its models/voices
status detect() for the hub's backend menu
wizard the TUI wizard and the CLI entry points
@@ -66,6 +67,7 @@ from .build import (
find_local_checkout,
find_audiocpp_server_bin,
uninstall,
+ update,
)
from .remote import fetch_server_models, fetch_server_voices
from .wizard import (
@@ -99,7 +101,7 @@ __all__ = [
"update_server_backend",
# build
"find_local_checkout", "find_audiocpp_server_bin", "find_build_script",
- "apply_ggml_patches", "build_audiocpp", "uninstall",
+ "apply_ggml_patches", "build_audiocpp", "uninstall", "update",
# remote
"fetch_server_models", "fetch_server_voices",
# wizard / status
diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py
index e63a799..b850859 100644
--- a/app/backends/audiocpp/build.py
+++ b/app/backends/audiocpp/build.py
@@ -11,8 +11,8 @@ from typing import List, Optional
from backends import common, servers
from backends.common import APP_DIR
-from .catalog import _BACKEND_TOKEN_RE
-from .constants import AUDIOCPP_DIR_NAME, PATCH_DIR
+from .catalog import _BACKEND_TOKEN_RE, detect_backend, load_server_config
+from .constants import AUDIOCPP_DIR_NAME, BACKENDS, PATCH_DIR
def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the audio.cpp backend entirely: stop its server, delete the checkout.
@@ -47,6 +47,116 @@ def uninstall(*, emit=None, cancel=None) -> int:
return 0
+def update(*, emit=None, cancel=None) -> int:
+ """Update the audio.cpp backend: refresh the checkout, rebuild if stale.
+
+ A managed server that is running is stopped first (best-effort): it
+ serves the binary whose sources are being replaced. Phases: stop
+ server / git update / rebuild — CANCEL is honored between phases only,
+ so a started phase always completes. The git update is a fetch plus
+ hard reset to origin's HEAD (see ``common.git_update``): everything
+ that matters lives untracked in the checkout (models, build trees,
+ server.json) and survives, while the vendored-ggml patch edit is
+ intentionally wiped — the rebuild re-applies it (the patch step is
+ idempotent and fails loudly when upstream re-shaped the file).
+
+ The rebuild target is the backend recorded in server.json, else the
+ one detected from existing build directories; when neither names one
+ (nothing was ever built) the update stops after the checkout refresh
+ — 'Build audio.cpp server' handles a first build. The rebuild itself
+ runs when the sources changed (HEAD moved) or the on-disk binary is
+ missing or older than HEAD's commit time — the latter heals an
+ interrupted (cancelled or failed) earlier rebuild, which leaves the
+ previous binary in place against already-updated sources. An
+ up-to-date checkout with a fresh binary costs one fetch. Returns the
+ exit code (130 when cancelled before a remaining phase).
+ """
+ # Only stop when a pid file exists: without one this tool never
+ # started the server, so the "not started by this tool" notice would
+ # be uninstall-time noise.
+ if servers.pid_for("audiocpp") is not None:
+ servers.stop("audiocpp")
+ if common.cancel_requested(cancel):
+ return 130
+ checkout = find_local_checkout()
+ if checkout is None:
+ print("[INFO] No audio.cpp checkout to update.")
+ return 0
+ head_before = common.git_head(checkout)
+ rc = common.git_update(checkout, emit=emit, cancel=cancel)
+ if rc != 0:
+ print(f"[WARNING] checkout update failed (exit {rc}); update "
+ f"manually: git -C {checkout} pull")
+ return rc
+ head_after = common.git_head(checkout)
+ if common.cancel_requested(cancel):
+ return 130
+ backend = _rebuild_backend(checkout)
+ if backend is None:
+ print("[INFO] audiocpp_server was never built for a known "
+ "backend; skipping the rebuild. 'Build audio.cpp server' "
+ "builds one.")
+ return 0
+ binary = built_server_binary(checkout, backend)
+ if not _rebuild_needed(checkout, binary,
+ moved=head_after not in (None, head_before)):
+ print(f"[OK] {checkout} is already at origin's HEAD with an "
+ "up-to-date audiocpp_server.")
+ return 0
+ if head_after in (None, head_before):
+ print(f"[INFO] audiocpp_server on disk is older than the "
+ f"checked-out sources (earlier build interrupted?); "
+ f"rebuilding for {backend}.")
+ else:
+ print(f"[OK] Updated {checkout} to {head_after[:12]}; rebuilding "
+ f"audiocpp_server for {backend}.")
+ build_rc = build_audiocpp(checkout, backend, emit=emit, cancel=cancel)
+ if build_rc != 0:
+ print(f"[WARNING] rebuild exited with code {build_rc}; see the "
+ "messages above (the build log under app/logs/ has the "
+ "full output). The binary on disk is now older than the "
+ "checked-out sources; re-running 'Update backends' will "
+ "retry the rebuild.")
+ else:
+ print("[OK] rebuild complete.")
+ return build_rc
+
+
+def _rebuild_needed(checkout: Path, binary: Optional[Path],
+ *, moved: bool) -> bool:
+ """True when audiocpp_server must be (re)built after an update.
+
+ True when the checkout moved, the binary is missing, its age cannot
+ be compared (no commit time), or it predates HEAD's commit — the
+ last case is what a cancelled or failed earlier rebuild leaves
+ behind (old binary, already-updated sources).
+ """
+ if moved or binary is None:
+ return True
+ commit_time = common.git_commit_time(checkout)
+ if commit_time is None:
+ return True
+ try:
+ return binary.stat().st_mtime <= commit_time
+ except OSError:
+ return True
+
+
+def _rebuild_backend(checkout: Path) -> Optional[str]:
+ """The inference backend to rebuild for after an update, or None.
+
+ server.json's recorded backend wins (it is what the managed server
+ launches); an existing build directory's token is the fallback for a
+ checkout that was built but never configured. None means neither
+ names a valid backend — there is no binary to keep fresh.
+ """
+ server_config = load_server_config(checkout / "server.json") or {}
+ recorded = server_config.get("backend")
+ if recorded in BACKENDS:
+ return recorded
+ return detect_backend(checkout)
+
+
def find_local_checkout() -> Optional[Path]:
"""Return the managed audio.cpp checkout at ``app/audio.cpp``.
diff --git a/app/backends/common.py b/app/backends/common.py
index 098dfb2..4edfd61 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -496,22 +496,124 @@ def git_clone(url: str, target: Path, *, emit=None, cancel=None) -> int:
emit=emit, cancel=cancel)
+def git_head(checkout: Path) -> Optional[str]:
+ """CHECKOUT's current HEAD commit sha, or None when it is not a repo."""
+ proc = run_console_subprocess_quiet(["git", "-C", str(checkout),
+ "rev-parse", "HEAD"])
+ if proc is None or proc.returncode != 0:
+ return None
+ return proc.stdout.decode("utf-8", errors="replace").strip() or None
+
+
+def git_commit_time(checkout: Path) -> Optional[int]:
+ """CHECKOUT's HEAD commit time as a unix timestamp, or None.
+
+ Uses the *committer* time (``%ct``): a rebase or cherry-pick rewrites
+ it to when the rewrite happened, so a force-pushed or rebased branch
+ always looks newer than binaries built from the pre-rewrite sources.
+ None (not a repo, probe failed) leaves the decision to the caller.
+ """
+ proc = run_console_subprocess_quiet(["git", "-C", str(checkout),
+ "show", "-s", "--format=%ct",
+ "HEAD"])
+ if proc is None or proc.returncode != 0:
+ return None
+ try:
+ return int(proc.stdout.decode("ascii", errors="replace").strip())
+ except ValueError:
+ return None
+
+
+def git_update(checkout: Path, *, emit=None, cancel=None) -> int:
+ """Update CHECKOUT to its remote's HEAD: fetch, then hard reset.
+
+ The backend checkouts are read-only working copies of upstream repos —
+ all state that matters (models, build trees, server.json, voices.json)
+ is untracked and survives the reset, while local edits the installers
+ made (the vendored-ggml patch in the audio.cpp checkout) are meant to
+ be re-applied by the caller afterwards. ``git reset --hard`` is used
+ instead of ``git pull`` because a pull merges against the working tree
+ and would conflict on exactly those re-applied-by-design edits.
+
+ The branch reset to is the remote's default (``refs/remotes/origin/
+ HEAD``), falling back to ``main`` when the symbolic ref is missing (a
+ bare-ish mirror or a restrictive server). EMIT/CANCEL behave like
+ git_clone's (fetch runs with --progress so the task view sees updates).
+ Returns the exit code of the first failing step (0 when the checkout
+ now matches origin's HEAD).
+ """
+ if emit is None:
+ print(f"[INFO] Updating git checkout {checkout}...")
+ else:
+ emit(f"[INFO] Updating git checkout {checkout}...")
+ fetch_argv = ["git", "-C", str(checkout), "fetch"]
+ reset_argv = ["git", "-C", str(checkout), "reset", "--hard"]
+ if emit is not None:
+ # --progress makes git report percentage updates even though stderr
+ # is piped (it normally only does so on a terminal), feeding the
+ # task view.
+ fetch_argv.append("--progress")
+ fetch_argv.append("origin")
+ fetch_rc = run_console_subprocess(fetch_argv, emit=emit, cancel=cancel)
+ if fetch_rc != 0:
+ return fetch_rc
+ branch = _origin_default_branch(checkout)
+ return run_console_subprocess(reset_argv + [f"origin/{branch}"],
+ emit=emit, cancel=cancel)
+
+
+def _origin_default_branch(checkout: Path) -> str:
+ """The remote's default branch name for CHECKOUT ("main" as fallback)."""
+ proc = run_console_subprocess_quiet(
+ ["git", "-C", str(checkout), "symbolic-ref",
+ "refs/remotes/origin/HEAD"])
+ if proc is not None and proc.returncode == 0:
+ ref = proc.stdout.decode("utf-8", errors="replace").strip()
+ # refs/remotes/origin/HEAD -> refs/remotes/origin/main
+ name = ref.rpartition("/")[2]
+ if name:
+ return name
+ return "main"
+
+
+def run_console_subprocess_quiet(argv: List[str],
+ cwd: Optional[Path] = None):
+ """Run ARGV silently and return the completed result.
+
+ Unlike run_console_subprocess (which streams or returns only an exit
+ code) this captures stdout and needs the process object itself, for the
+ small git probes (rev-parse, symbolic-ref) whose *output* matters and
+ whose failure is a normal, non-fatal outcome. Returns None when the
+ process could not be started.
+ """
+ import subprocess
+ try:
+ return subprocess.run(
+ argv, capture_output=True,
+ cwd=str(cwd) if cwd is not None else None, check=False)
+ except OSError:
+ return None
+
+
def pip_install(packages: List[str], *, emit=None, cancel=None,
- env_dir: Optional[Path] = None) -> int:
+ env_dir: Optional[Path] = None,
+ upgrade: bool = False) -> int:
"""pip install PACKAGES into a managed venv. Returns exit code.
Delegates to ``backends.envs.pip_install`` so backend TTS packages are
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).
+ conflicting stacks into the same env. With UPGRADE pip runs with
+ ``-U`` (the backend update action's freshness check: pip only installs
+ when a newer version resolves, else reports "already satisfied").
+ 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,
- env_dir=env_dir)
+ env_dir=env_dir, upgrade=upgrade)
def pip_uninstall(packages: List[str], *, emit=None,
diff --git a/app/backends/envs.py b/app/backends/envs.py
index 50154c2..ff6d1bf 100644
--- a/app/backends/envs.py
+++ b/app/backends/envs.py
@@ -190,22 +190,28 @@ def install_requirements(skip_optional: bool = False) -> int:
def pip_install(packages: List[str], *, emit=None, cancel=None,
- env_dir: Optional[Path] = None) -> int:
+ env_dir: Optional[Path] = None,
+ upgrade: bool = False) -> 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 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.
+ alongside each other or the app requirements. With UPGRADE the install
+ runs with ``-U``: pip then resolves the latest version itself and
+ reports "Requirement already satisfied" when the env already holds it —
+ the backend update action's cheap freshness check. 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(env_dir) and create_env(env_dir) != 0:
return 1
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 upgrade:
+ argv.append("-U")
if emit is not None:
argv.append("--progress-bar")
argv.append("off")
diff --git a/app/backends/faster.py b/app/backends/faster.py
index 3631153..e52a023 100755
--- a/app/backends/faster.py
+++ b/app/backends/faster.py
@@ -511,6 +511,44 @@ def _detect_remote(managed: bool = False):
return False, {}
+def update(*, emit=None, cancel=None) -> int:
+ """Update the faster-qwen3-tts backend: pip upgrade + checkout refresh.
+
+ A managed server that is running is stopped first (best-effort): the
+ server runs ``examples/openai_server.py`` from the checkout being
+ reset and imports the package being upgraded. Phases: stop server /
+ pip install -U / git update — CANCEL is honored between phases only,
+ so a started phase always completes. The pip package (into
+ FASTER_ENV) and the cloned checkout are refreshed independently: the
+ checkout only holds ``examples/openai_server.py`` (and the untracked
+ voices.json, which a hard reset leaves alone), so a failed phase is
+ warned about and reflected in the exit code without undoing the
+ other. Returns the exit code (130 when cancelled before a remaining
+ phase).
+ """
+ if servers.pid_for("faster") is not None:
+ servers.stop("faster")
+ if common.cancel_requested(cancel):
+ return 130
+ rc = common.pip_install([FASTER_PIP_PKG], emit=emit, cancel=cancel,
+ env_dir=FASTER_ENV, upgrade=True)
+ if rc != 0:
+ print(f"[WARNING] pip install -U failed (exit {rc}); update "
+ f"{FASTER_PIP_PKG} manually")
+ else:
+ print(f"[OK] {FASTER_PIP_PKG} is up to date (or just upgraded).")
+ if common.cancel_requested(cancel):
+ return 130
+ if _is_cloned():
+ clone_rc = common.git_update(_checkout(), emit=emit, cancel=cancel)
+ if clone_rc != 0:
+ print(f"[WARNING] checkout update failed (exit {clone_rc}); "
+ f"run 'git -C {_checkout()} pull' manually")
+ return clone_rc
+ print(f"[OK] {_checkout()} is at origin's HEAD.")
+ return rc
+
+
def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the faster-qwen3-tts backend entirely.
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index b2a1df4..4f3afd9 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -457,6 +457,33 @@ def uninstall(*, emit=None, cancel=None) -> int:
return rc
+def update(*, emit=None, cancel=None) -> int:
+ """Update the qwen-tts backend: pip install -U qwen-tts in its venv.
+
+ A managed server that is running is stopped first (best-effort): it
+ imports ``qwen_tts`` from the very venv being upgraded, so an in-place
+ upgrade under a live process would leave it serving stale code.
+ CANCEL is a ``threading.Event`` honored between phases only (stop
+ server / pip) — a started phase always completes, so pip is never
+ killed mid-run. pip itself is the freshness check: it resolves the
+ latest version, upgrades when there is one, and reports "Requirement
+ already satisfied" otherwise. Returns the exit code (130 when
+ cancelled before a remaining phase).
+ """
+ if servers.pid_for("qwen") is not None:
+ servers.stop("qwen")
+ if common.cancel_requested(cancel):
+ return 130
+ rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel,
+ env_dir=QWEN_ENV, upgrade=True)
+ if rc != 0:
+ print(f"[WARNING] pip install -U failed (exit {rc}); update "
+ f"{QWEN_PIP_PKG} manually")
+ else:
+ print(f"[OK] {QWEN_PIP_PKG} is up to date (or just upgraded).")
+ return rc
+
+
def models_screen(stdscr) -> int:
"""Per-model (un)install screen: the hub's Configure-qwen-tts leaf.