aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rwxr-xr-xapp/backends/audiocpp.py295
-rw-r--r--app/tests/test_backends_audiocpp.py87
2 files changed, 180 insertions, 202 deletions
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py
index f6121ea..4bcf8fa 100755
--- a/app/backends/audiocpp.py
+++ b/app/backends/audiocpp.py
@@ -17,7 +17,7 @@ catalog works without further changes.
Usage:
python app/backends/audiocpp.py [--wavs WAV_DIR] [--output PATH]
- [--audiocpp-dir PATH] [--clone] [--families FAM1,FAM2]
+ [--clone] [--families FAM1,FAM2]
[--all-packages] [--host HOST] [--port PORT]
[--build-backend {cuda,vulkan,hip,cpu}] [--backend {cuda,vulkan,hip,cpu}]
[--whisper-model NAME] [--force]
@@ -36,6 +36,8 @@ longer selected.
"""
import argparse
+import contextlib
+import io
import json
import os
import re
@@ -68,9 +70,9 @@ from backends.common import (
VOICES_DIR,
detect_wav_dir,
find_wav_files,
- normalize_dir_arg,
read_prompt_text,
resolve_wav_dir_arg,
+ url_with_port,
write_prompt_text,
)
from backends.common import (
@@ -91,7 +93,7 @@ BACKENDS = ("cuda", "vulkan", "hip", "cpu")
TASK_TTS = "tts"
TASK_VDES = "vdes"
-# audio.cpp is cloned into a sibling directory of the audiobook generator.
+# audio.cpp is cloned into the app directory of the audiobook generator.
AUDIOCPP_DIR_NAME = "audio.cpp"
AUDIOCPP_GIT_URL = "https://github.com/0xShug0/audio.cpp"
@@ -144,21 +146,6 @@ def _interactive() -> bool:
return False
-def _resolve_audiocpp_root(directory: Path) -> Optional[Path]:
- """Return the audio.cpp checkout root for DIRECTORY, or None.
-
- Accepts either the checkout root itself (it must contain a
- ``model_specs`` directory) or the ``model_specs`` directory inside
- it (the parent is used), so the file browser cannot pick the wrong
- one of the two.
- """
- if (directory / "model_specs").is_dir():
- return directory
- if directory.name == "model_specs" and directory.is_dir():
- return directory.parent
- return None
-
-
# Backend display order, with short descriptions. The backend name is padded
# so the descriptions' dashes line up in the menu.
_BACKEND_DESCRIPTIONS = (
@@ -199,36 +186,26 @@ def config_port() -> int:
return FALLBACK_PORT
-def _url_with_port(url: str, port: int) -> str:
- parts = urllib.parse.urlsplit(url)
- host = parts.hostname or "127.0.0.1"
- return urllib.parse.urlunsplit(
- (parts.scheme or "http", f"{host}:{port}", parts.path, "", ""))
-
-
def update_config_api_url_port(port: int, config_path: Optional[Path] = None) -> bool:
"""Rewrite the port inside AUDIOCPP_API_URL in app/converter/config.py.
- Only the quoted URL literal is replaced; surrounding lines and the
- trailing comment are preserved. Returns True when the file was changed.
+ Reads the configured URL from the file (not from the imported module,
+ which a long hub session can leave behind), swaps its port for PORT,
+ and writes it back through ``common.update_config_value`` so the
+ imported module mirrors the change immediately. Returns True when the
+ file now holds the new URL.
"""
path = Path(config_path) if config_path is not None else CONFIG_PATH
try:
text = path.read_text(encoding="utf-8")
except OSError:
return False
- match = re.search(r'(?m)^(\s*AUDIOCPP_API_URL\s*=\s*")([^"]*)(")', text)
+ match = re.search(r'(?m)^\s*AUDIOCPP_API_URL\s*=\s*"([^"]*)"', text)
if not match:
return False
- new_url = _url_with_port(match.group(2), port)
- if new_url == match.group(2):
- return False
- text = text[:match.start(2)] + new_url + text[match.end(2):]
- try:
- path.write_text(text, encoding="utf-8")
- except OSError:
- return False
- return True
+ return common.update_config_value("AUDIOCPP_API_URL",
+ url_with_port(match.group(1), port),
+ config_path=path)
def update_server_config_port(port: int) -> bool:
@@ -269,57 +246,18 @@ def update_config_model_ids(model_id: str,
config_path: Optional[Path] = None) -> bool:
"""Rewrite AUDIOCPP_MODEL_ID (and AUDIOCPP_CLONE_MODEL_ID when given).
- Only the quoted id literals are replaced; surrounding lines and
- comments are preserved. Returns True when the file was changed.
+ Goes through ``common.update_config_value`` so the imported config
+ module mirrors the change immediately. Returns True when every named
+ key now holds its value in the file.
"""
path = Path(config_path) if config_path is not None else CONFIG_PATH
- try:
- text = path.read_text(encoding="utf-8")
- except OSError:
- return False
- updates: List[Tuple[str, str]] = [("AUDIOCPP_MODEL_ID", model_id)]
+ ok = common.update_config_value("AUDIOCPP_MODEL_ID", model_id,
+ config_path=path)
if clone_model_id is not None:
- updates.append(("AUDIOCPP_CLONE_MODEL_ID", clone_model_id))
- changed = False
- for name, value in updates:
- match = re.search(r'(?m)^(\s*' + name + r'\s*=\s*")([^"]*)(")', text)
- if match and match.group(2) != value:
- text = text[:match.start(2)] + value + text[match.end(2):]
- changed = True
- if not changed:
- return False
- try:
- path.write_text(text, encoding="utf-8")
- except OSError:
- return False
- return True
-
-
-def detect_audiocpp_dir() -> Optional[Path]:
- """Best-effort location of a local audio.cpp checkout with model_specs.
-
- Checks the AUDIOCPP_DIR environment variable, then ``app/audio.cpp`` in
- the tts-audiobook-generator root, then an ``audio.cpp`` directory in or
- above the current working directory. Returns the path only when it
- contains a ``model_specs`` directory.
- """
- candidates: List[Path] = []
- env_dir = os.environ.get("AUDIOCPP_DIR")
- if env_dir:
- candidates.append(Path(os.path.expanduser(env_dir)))
- candidates.append(APP_DIR / AUDIOCPP_DIR_NAME)
- cwd = Path.cwd()
- candidates.append(cwd / "audio.cpp")
- candidates.append(cwd.parent / "audio.cpp")
- candidates.append(cwd.parent.parent / "audio.cpp")
- for candidate in candidates:
- try:
- resolved = candidate.resolve()
- except OSError:
- continue
- if (resolved / "model_specs").is_dir():
- return resolved
- return None
+ ok = common.update_config_value("AUDIOCPP_CLONE_MODEL_ID",
+ clone_model_id,
+ config_path=path) and ok
+ return ok
# audio.cpp build directories are named ``<platform>-<backend>-<type>`` (e.g.
@@ -398,8 +336,8 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]:
specs_dir = audiocpp_dir / "model_specs"
if not specs_dir.is_dir():
raise NotADirectoryError(
- f"{audiocpp_dir} has no model_specs/ directory; point "
- "--audiocpp-dir at an audio.cpp checkout")
+ f"{audiocpp_dir} has no model_specs/ directory; re-run setup "
+ "to refresh the audio.cpp checkout")
entries: List[dict] = []
for spec_path in sorted(specs_dir.glob("*.json")):
try:
@@ -604,21 +542,17 @@ def _decide_transcription(wav_files: list, existing: Dict[str, str],
return {"mode": mode, "missing": missing, "existing": existing}
-def _transcribe(args: argparse.Namespace, include_clone: bool,
- plan: dict, cancel=None) -> Tuple[Dict[str, str], bool]:
+def _transcribe(args: argparse.Namespace, plan: Optional[dict],
+ cancel=None) -> Tuple[Dict[str, str], bool]:
"""Transcribe the wav directory into a stem -> transcript mapping.
Returns the mapping and a flag indicating whether it should be written to
prompt_text (False when an existing, complete prompt_text is kept as-is).
PLAN is always pre-collected — by the TUI (via _decide_transcription and
its confirm callbacks) or by _flag_plan for a non-interactive run — so no
- questions are asked here. CANCEL is checked between files.
+ questions are asked here; a None PLAN defaults to "transcribe everything".
+ CANCEL is checked between files.
"""
- if not include_clone:
- print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family "
- "selected, so voice presets are not used")
- return {}, False
-
wav_files = find_wav_files(args.input_dir)
if not wav_files:
print(f"[WARNING] No .wav files found in {args.input_dir}; writing the "
@@ -626,9 +560,10 @@ def _transcribe(args: argparse.Namespace, include_clone: bool,
return {}, False
prompt_path = args.input_dir / PROMPT_TEXT_FILENAME
- existing = plan.get("existing") or {} if plan else {}
+ existing = dict((plan or {}).get("existing") or {})
+ mode = plan["mode"] if plan else "all"
- if plan["mode"] == "keep":
+ if mode == "keep":
print(f"[INFO] Kept existing {prompt_path}; all voices were "
"already transcribed, nothing new to transcribe")
return existing, False
@@ -778,7 +713,7 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path],
def _install_models(audiocpp_dir: Path,
install_guidance: List[Tuple[str, str]],
- download: bool, emit=None, cancel=None) -> None:
+ download: bool, emit=None, cancel=None) -> int:
"""Print and optionally run the model install commands.
One ``python <manager> install <id>`` command per hosted model (de-duped
@@ -792,6 +727,9 @@ def _install_models(audiocpp_dir: Path,
to EMIT and — when the checkout's ``model_manager_v2.py`` supports it —
runs with ``--progress --cancel-file`` so the view can show a real byte
progress bar and cancel gracefully. CANCEL aborts a running download.
+
+ Returns 0 when every command succeeded (or nothing needed running),
+ 130 when cancelled, 1 when any download failed.
"""
manager = audiocpp_dir / "tools" / "model_manager_v2.py"
seen: Set[str] = set()
@@ -808,6 +746,7 @@ def _install_models(audiocpp_dir: Path,
"instead of running them")
download = False
+ failed = False
for install_id in install_ids:
command = f"python {manager} install {install_id}"
if not download:
@@ -838,10 +777,14 @@ def _install_models(audiocpp_dir: Path,
cancel_file.unlink()
except OSError:
pass
+ if rc == 130 or (cancel is not None and cancel.is_set()):
+ return 130
if rc != 0:
+ failed = True
print(f"[WARNING] install {install_id} exited with code "
f"{rc}; the model may need to be downloaded "
"by hand")
+ return 1 if failed else 0
def _manager_supports_progress(manager: Path) -> bool:
@@ -932,17 +875,8 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
return result
def resolve_checkout(audiocpp_dir: Path) -> None:
- """Validate AUDIOCPP_DIR and populate the wizard state ``s``."""
+ """Validate the audio.cpp checkout and populate the wizard state ``s``."""
audiocpp_dir = Path(audiocpp_dir).resolve()
- if not audiocpp_dir.is_dir():
- raise _TuiError(f"audio.cpp checkout not found: "
- f"{audiocpp_dir}")
- root = _resolve_audiocpp_root(audiocpp_dir)
- if root is None:
- raise _TuiError(
- f"{audiocpp_dir} has no model_specs/ directory; "
- "select the root of your audio.cpp checkout")
- audiocpp_dir = root
try:
catalog = load_model_catalog(audiocpp_dir)
except NotADirectoryError as exc:
@@ -1336,9 +1270,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
# checkout exists, clone it into ./app/audio.cpp (streaming inside the
# TUI task view, not by dropping to the console) without asking, then
# continue the same way.
- audiocpp_dir = args.audiocpp_dir
- if audiocpp_dir is None:
- audiocpp_dir = find_local_checkout()
+ audiocpp_dir = find_local_checkout()
if audiocpp_dir is None:
target = APP_DIR / AUDIOCPP_DIR_NAME
rc = taskview.run_steps(stdscr, "Clone audio.cpp", [
@@ -1438,7 +1370,7 @@ def _model_path_present(path: Path) -> bool:
def _all_models_present(audiocpp_dir: Path, model_entries: List[dict]) -> bool:
"""True when every selected model entry's path already holds files on disk.
- Paths resolve against AUDIOCPP_DIR (where model_manager_v2.py installs
+ Paths resolve against the checkout (where model_manager_v2.py installs
them), honoring absolute paths. Used by the wizard to skip the
"Automatically download the selected models" prompt when nothing is
actually missing. An empty selection is treated as not-present.
@@ -1571,17 +1503,18 @@ def model_install_hints(audiocpp_dir: Path,
def install_models(audiocpp_dir: Path,
guidance: List[Tuple[str, str]],
- emit=None, cancel=None) -> None:
+ emit=None, cancel=None) -> int:
"""Download the (display name, install id) models via the helper script.
Runs ``model_manager_v2.py install`` for each de-duped install id in the
checkout, streaming to the console (or to EMIT, the in-TUI task view); a
- failing install is reported as a warning and does not abort the rest. Used
- by the hub's "Download Missing Models" action (see
+ failing install is reported as a warning and does not abort the rest.
+ Returns 0 when every download succeeded, 130 when cancelled, 1 when any
+ failed. Used by the hub's "Download Missing Models" action (see
``missing_model_install_guidance`` for the mapping).
"""
- _install_models(audiocpp_dir, guidance, download=True,
- emit=emit, cancel=cancel)
+ return _install_models(audiocpp_dir, guidance, download=True,
+ emit=emit, cancel=cancel)
def hand_install_guidance(audiocpp_dir: Path,
@@ -1590,7 +1523,7 @@ def hand_install_guidance(audiocpp_dir: Path,
Returns a multi-line message listing each missing model's id and the
path its files must be placed in (``rel``, resolved against the
- AUDIOCPP_DIR checkout). Used when the missing models cannot be mapped to
+ checkout). Used when the missing models cannot be mapped to
a ``model_manager_v2.py install`` command, so the user still knows what
to download and where to put it.
"""
@@ -1653,10 +1586,10 @@ def delete_model_files(server_json: Path, entries: List[dict]) -> int:
def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the audio.cpp backend entirely: stop its server, delete the checkout.
- The checkout (``app/audio.cpp``, or wherever ``find_local_checkout``
- resolves it) holds the built binary, the downloaded models, and the
- server.json, so removing the directory uninstalls the backend. A running
- server this tool started is stopped first (best-effort).
+ The checkout (``app/audio.cpp``) holds the built binary, the downloaded
+ models, and the server.json, so removing the directory uninstalls the
+ backend. A running server this tool started is stopped first
+ (best-effort).
EMIT is accepted for registry symmetry with the other backends but is
unused here — this uninstall has no subprocess phase, and its prints are
@@ -1666,7 +1599,11 @@ def uninstall(*, emit=None, cancel=None) -> int:
completes and the uninstall never tears halfway. Returns the exit code
(130 when cancelled before a remaining phase).
"""
- servers.stop("audiocpp")
+ # 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()
@@ -1680,29 +1617,17 @@ def uninstall(*, emit=None, cancel=None) -> int:
def find_local_checkout() -> Optional[Path]:
- """Best-effort location of an audio.cpp checkout with model_specs.
+ """Return the managed audio.cpp checkout at ``app/audio.cpp``.
- Checks the AUDIOCPP_DIR environment variable, then ``app/audio.cpp``
- inside the tts-audiobook-generator root, then an ``audio.cpp`` directory
- in or above the current working directory. Returns the path only when it
- contains a ``model_specs`` directory.
+ Returns the path only when it contains a ``model_specs`` directory;
+ the checkout is installed there by the setup wizard and nowhere else.
"""
- candidates: List[Path] = []
- env_dir = os.environ.get("AUDIOCPP_DIR")
- if env_dir:
- candidates.append(Path(os.path.expanduser(env_dir)))
- candidates.append(APP_DIR / AUDIOCPP_DIR_NAME)
- cwd = Path.cwd()
- candidates.append(cwd / AUDIOCPP_DIR_NAME)
- candidates.append(cwd.parent / AUDIOCPP_DIR_NAME)
- candidates.append(cwd.parent.parent / AUDIOCPP_DIR_NAME)
- for candidate in candidates:
- try:
- resolved = candidate.resolve()
- except OSError:
- continue
- if (resolved / "model_specs").is_dir():
- return resolved
+ try:
+ resolved = (APP_DIR / AUDIOCPP_DIR_NAME).resolve()
+ except OSError:
+ return None
+ if (resolved / "model_specs").is_dir():
+ return resolved
return None
@@ -1886,6 +1811,8 @@ def apply_ggml_patches(audiocpp_dir: Path, *, emit=None, cancel=None) -> int:
"--whitespace=nowarn", str(patch_path)]
check_rc = common.run_console_subprocess(
check_argv, emit=emit, cancel=cancel)
+ if check_rc == 130 or (cancel is not None and cancel.is_set()):
+ return 130
if check_rc != 0:
print(f"[ERROR] {patch['file']}: no longer applies to "
f"{patch['target']} (git apply --check exit {check_rc}). "
@@ -1898,6 +1825,8 @@ def apply_ggml_patches(audiocpp_dir: Path, *, emit=None, cancel=None) -> int:
"--whitespace=nowarn", str(patch_path)]
rc = common.run_console_subprocess(
apply_argv, emit=emit, cancel=cancel)
+ if rc == 130 or (cancel is not None and cancel.is_set()):
+ return 130
if rc != 0:
print(f"[ERROR] {patch['file']}: git apply failed (exit {rc})")
return rc
@@ -1930,20 +1859,19 @@ def build_audiocpp(audiocpp_dir: Path, backend: str, *,
if emit is not None:
common.record_post_tui_notice(message)
return 1
- patch_rc = apply_ggml_patches(audiocpp_dir, emit=emit, cancel=cancel)
- if patch_rc != 0:
- message = ("[ERROR] ggml build patches could not be applied; "
- "aborting audiocpp_server build. See the messages above "
- "and re-evaluate app/backends/patches/.")
- print(message)
- if emit is not None:
- common.record_post_tui_notice(message)
- return patch_rc
argv = ["sh", str(script), "--backend", backend, "--target",
"audiocpp_server", "--deployment-build"]
command = f"cd {audiocpp_dir} && {shlex.join(argv)}"
if emit is None:
print(f"[INFO] Building audiocpp_server for {backend} ({command})...")
+ patch_rc = apply_ggml_patches(audiocpp_dir, cancel=cancel)
+ if patch_rc == 130 or (cancel is not None and cancel.is_set()):
+ return 130
+ if patch_rc != 0:
+ print("[ERROR] ggml build patches could not be applied; "
+ "aborting audiocpp_server build. See the messages above "
+ "and re-evaluate app/backends/patches/.")
+ return patch_rc
return common.run_console_subprocess(argv, cwd=audiocpp_dir)
return _build_audiocpp_tui(emit, cancel, argv, command, audiocpp_dir)
@@ -1952,12 +1880,13 @@ def _build_audiocpp_tui(emit, cancel, argv: List[str], command: str,
audiocpp_dir: Path) -> int:
"""Run the build on the TUI path: tee output to a log file.
- Every emitted line is also written (and flushed) to
+ The ggml patch step runs first, inside the same log: every emitted
+ line (patch status, build output) is also written (and flushed) to
``app/logs/audiocpp_build_<timestamp>.log``. On failure a summary (the
- copy-pastable COMMAND and the log path) is emitted into the TUI, written
- to the log, and queued as a post-TUI console notice. A cancelled build
- (CANCEL set) is not reported as a failure, but its partial output stays
- in the log file.
+ copy-pastable COMMAND and the log path) is emitted into the TUI,
+ written to the log, and queued as a post-TUI console notice. A
+ cancelled build (CANCEL set) is not reported as a failure, but its
+ partial output stays in the log file.
"""
log_path = common.LOG_DIR / (
f"audiocpp_build_{datetime.now():%Y%m%d_%H%M%S}.log")
@@ -1969,9 +1898,29 @@ def _build_audiocpp_tui(emit, cancel, argv: List[str], command: str,
log_handle.flush()
emit(line)
- tee(f"[INFO] Building audiocpp_server ({command})...")
- rc = 0
+ class _TeeWriter(io.TextIOBase):
+ """Route print() output from the patch step into the log too."""
+
+ def write(self, s: str) -> int:
+ for line in s.splitlines():
+ if line:
+ tee(line)
+ return len(s)
+
try:
+ with contextlib.redirect_stdout(_TeeWriter()):
+ patch_rc = apply_ggml_patches(audiocpp_dir, emit=tee,
+ cancel=cancel)
+ if patch_rc == 130 or (cancel is not None and cancel.is_set()):
+ return 130
+ if patch_rc != 0:
+ notice = ("[ERROR] ggml build patches could not be applied; "
+ "aborting audiocpp_server build. See the messages "
+ "above and re-evaluate app/backends/patches/.")
+ tee(notice)
+ common.record_post_tui_notice(notice)
+ return patch_rc
+ tee(f"[INFO] Building audiocpp_server ({command})...")
rc = common.run_console_subprocess(
argv, cwd=audiocpp_dir, emit=tee, cancel=cancel)
if rc != 0 and (cancel is None or not cancel.is_set()):
@@ -2048,7 +1997,7 @@ def _execute_lanes(settings: dict,
args.input_dir = settings["wav_dir"]
if settings["include_clone"] and args.input_dir is not None:
transcripts, write_prompt = _transcribe(
- args, True, plan=settings["plan"], cancel=cancel)
+ args, plan=settings["plan"], cancel=cancel)
elif args.input_dir is not None:
print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable "
"family selected, so voice presets are not used")
@@ -2302,10 +2251,8 @@ def _collect_from_flags(args: argparse.Namespace,
the settings dict, or None when the user declined an overwrite (the
default-location fallback then also exists).
"""
- # Checkout: --audiocpp-dir, else a local checkout, else --clone clones one.
- audiocpp_dir = args.audiocpp_dir
- if audiocpp_dir is None:
- audiocpp_dir = find_local_checkout()
+ # Checkout: ./app/audio.cpp, else --clone clones one there.
+ audiocpp_dir = find_local_checkout()
if audiocpp_dir is None and args.clone:
target = APP_DIR / AUDIOCPP_DIR_NAME
rc = common.git_clone(AUDIOCPP_GIT_URL, target)
@@ -2322,17 +2269,8 @@ def _collect_from_flags(args: argparse.Namespace,
audiocpp_dir = target
if audiocpp_dir is None:
parser.error(
- "An audio.cpp checkout is required. Pass --audiocpp-dir PATH, "
- "or --clone to clone app/audio.cpp, or run without flags for the "
- "TUI wizard.")
- audiocpp_dir = Path(audiocpp_dir).resolve()
- if not audiocpp_dir.is_dir():
- parser.error(f"audio.cpp checkout not found: {audiocpp_dir}")
- root = _resolve_audiocpp_root(audiocpp_dir)
- if root is None:
- parser.error(f"{audiocpp_dir} has no model_specs/ directory; point "
- "--audiocpp-dir at the root of an audio.cpp checkout")
- audiocpp_dir = root
+ "An audio.cpp checkout is required. Pass --clone to clone "
+ "app/audio.cpp, or run without flags for the TUI wizard.")
try:
catalog = load_model_catalog(audiocpp_dir)
except NotADirectoryError as exc:
@@ -2465,11 +2403,6 @@ def build_parser() -> argparse.ArgumentParser:
"server.json inside the audio.cpp checkout; an "
"existing file is overwritten only with --force "
"or a TUI confirm)")
- parser.add_argument("--audiocpp-dir", type=normalize_dir_arg, default=None,
- help="Path to a local audio.cpp checkout containing a "
- "model_specs/ directory (default: detected from "
- "AUDIOCPP_DIR or ./app/audio.cpp; in the TUI you can "
- "clone one instead)")
parser.add_argument("--clone", action="store_true",
help="Non-interactive: clone audio.cpp into "
"./app/audio.cpp when no checkout is found")
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 3063e99..05b47bd 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -13,6 +13,7 @@ from unittest.mock import MagicMock, patch
from converter import config
from backends import audiocpp as make_server
+from backends import common
from ui import tui
FAKE_CONFIG = (
@@ -222,13 +223,14 @@ class ConfigPortTests(unittest.TestCase):
make_server.FALLBACK_PORT)
def test_url_with_port_replaces_port(self):
+ # audiocpp reuses the shared helper (backends.common.url_with_port).
self.assertEqual(
- make_server._url_with_port("http://127.0.0.1:8080", 9000),
+ make_server.url_with_port("http://127.0.0.1:8080", 9000),
"http://127.0.0.1:9000")
def test_url_without_port_adds_port(self):
self.assertEqual(
- make_server._url_with_port("http://localhost", 8080),
+ make_server.url_with_port("http://localhost", 8080),
"http://localhost:8080")
@@ -237,8 +239,11 @@ class UpdateConfigPortTests(unittest.TestCase):
self._tmp = tempfile.TemporaryDirectory()
self.config_path = Path(self._tmp.name) / "config.py"
self.config_path.write_text(FAKE_CONFIG, encoding="utf-8")
+ # The shared helper also mirrors values onto converter.config.
+ self._saved_url = config.AUDIOCPP_API_URL
def tearDown(self):
+ config.AUDIOCPP_API_URL = self._saved_url
self._tmp.cleanup()
def test_rewrites_port_preserving_comment(self):
@@ -258,8 +263,9 @@ class UpdateConfigPortTests(unittest.TestCase):
self.assertFalse(make_server.update_config_api_url_port(
8080, config_path=path))
- def test_returns_false_when_port_unchanged(self):
- self.assertFalse(make_server.update_config_api_url_port(
+ def test_port_unchanged_is_a_success_noop(self):
+ # The file already holds the port: success, nothing rewritten.
+ self.assertTrue(make_server.update_config_api_url_port(
9999, config_path=self.config_path))
self.assertEqual(self.config_path.read_text(encoding="utf-8"),
FAKE_CONFIG)
@@ -275,8 +281,13 @@ class UpdateConfigModelIdsTests(unittest.TestCase):
self.config_path = Path(self._tmp.name) / "config.py"
self.config_path.write_text(FAKE_CONFIG_WITH_MODEL_IDS,
encoding="utf-8")
+ # The shared helper also mirrors values onto converter.config.
+ self._saved_ids = (config.AUDIOCPP_MODEL_ID,
+ config.AUDIOCPP_CLONE_MODEL_ID)
def tearDown(self):
+ (config.AUDIOCPP_MODEL_ID,
+ config.AUDIOCPP_CLONE_MODEL_ID) = self._saved_ids
self._tmp.cleanup()
def test_rewrites_both_ids_preserving_lines(self):
@@ -297,10 +308,11 @@ class UpdateConfigModelIdsTests(unittest.TestCase):
self.assertIn('AUDIOCPP_MODEL_ID = "voxcpm2"', text)
self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"', text)
- def test_returns_false_when_ids_unchanged(self):
+ def test_ids_unchanged_is_a_success_noop(self):
+ # Both ids already hold their values: success, nothing rewritten.
changed = make_server.update_config_model_ids(
"qwen", "qwen-clone", config_path=self.config_path)
- self.assertFalse(changed)
+ self.assertTrue(changed)
self.assertEqual(self.config_path.read_text(encoding="utf-8"),
FAKE_CONFIG_WITH_MODEL_IDS)
@@ -355,22 +367,43 @@ class ResolveWavDirArgTests(unittest.TestCase):
class NormalizeDirArgTests(unittest.TestCase):
- """Path normalization for the audio.cpp checkout argument."""
+ """Path normalization for user-supplied directory arguments."""
def test_expands_tilde_and_resolves(self):
- with patch.object(make_server.os.path, "expanduser",
+ with patch.object(common.os.path, "expanduser",
return_value="/home/u/audio.cpp") as mock_expand:
- result = make_server.normalize_dir_arg("~/audio.cpp")
+ result = common.normalize_dir_arg("~/audio.cpp")
mock_expand.assert_called_once_with("~/audio.cpp")
self.assertEqual(result, Path("/home/u/audio.cpp").resolve())
def test_strips_quotes_and_whitespace(self):
- with patch.object(make_server.os.path, "expanduser",
+ with patch.object(common.os.path, "expanduser",
side_effect=lambda s: s):
- result = make_server.normalize_dir_arg(' "/tmp/foo" ')
+ result = common.normalize_dir_arg(' "/tmp/foo" ')
self.assertEqual(result, Path("/tmp/foo").resolve())
+class FindLocalCheckoutTests(unittest.TestCase):
+ """find_local_checkout resolves ./app/audio.cpp and nothing else."""
+
+ def test_none_when_no_checkout_in_app_dir(self):
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(make_server, "APP_DIR", Path(td)):
+ self.assertIsNone(make_server.find_local_checkout())
+
+ def test_returns_the_managed_checkout(self):
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(make_server, "APP_DIR", Path(td)):
+ checkout = _make_checkout(Path(td))
+ self.assertEqual(make_server.find_local_checkout(), checkout)
+
+ def test_none_when_checkout_lacks_model_specs(self):
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(make_server, "APP_DIR", Path(td)):
+ (Path(td) / "audio.cpp").mkdir()
+ self.assertIsNone(make_server.find_local_checkout())
+
+
class LoadModelCatalogTests(unittest.TestCase):
def setUp(self):
self._td = tempfile.TemporaryDirectory()
@@ -1082,11 +1115,15 @@ class NonInteractiveMainTests(unittest.TestCase):
def tearDown(self):
self._td.cleanup()
- def _run(self, argv, transcribe=None, whisper="faster_whisper"):
+ def _run(self, argv, transcribe=None, whisper="faster_whisper",
+ no_checkout=False):
argv = ["backends/audiocpp.py"] + argv
transcribe_effect = transcribe if transcribe is not None \
else MagicMock()
with patch.object(sys, "argv", argv), \
+ patch.object(make_server, "find_local_checkout",
+ return_value=None if no_checkout
+ else self.checkout), \
patch.object(make_server, "transcribe_reference_audio",
side_effect=transcribe_effect), \
patch.object(make_server, "whisper_backend_available",
@@ -1094,8 +1131,8 @@ class NonInteractiveMainTests(unittest.TestCase):
return make_server.main()
def _args(self, *extra):
- return ["--wavs", str(self.folder), "--output", str(self.output),
- "--audiocpp-dir", str(self.checkout)] + list(extra)
+ return ["--wavs", str(self.folder), "--output", str(self.output)] \
+ + list(extra)
def test_default_run_hosts_recommended_entry(self):
exit_code = self._run(
@@ -1198,18 +1235,16 @@ class NonInteractiveMainTests(unittest.TestCase):
self.assertEqual(ctx.exception.code, 2)
def test_missing_checkout_rejected(self):
- with patch.object(make_server, "find_local_checkout",
- return_value=None), \
- self.assertRaises(SystemExit) as ctx:
+ with self.assertRaises(SystemExit) as ctx:
self._run(["--families", "higgs_audio_tts", "--output",
- str(self.output), "--no-sync-model-ids"])
+ str(self.output), "--no-sync-model-ids"],
+ no_checkout=True)
self.assertEqual(ctx.exception.code, 2)
def test_missing_wav_dir_rejected(self):
missing = self.root / "nope"
with self.assertRaises(SystemExit) as ctx:
self._run(["--wavs", str(missing), "--output", str(self.output),
- "--audiocpp-dir", str(self.checkout),
"--families", "higgs_audio_tts", "--no-sync-model-ids"])
self.assertEqual(ctx.exception.code, 2)
@@ -1822,19 +1857,25 @@ class UninstallTests(unittest.TestCase):
checkout.mkdir()
with patch.object(make_server, "find_local_checkout",
return_value=checkout), \
+ patch.object(make_server.servers, "pid_for",
+ return_value=1234), \
patch.object(make_server.servers, "stop") as mk_stop:
rc = make_server.uninstall()
self.assertEqual(rc, 0)
self.assertFalse(checkout.exists())
mk_stop.assert_called_once_with("audiocpp")
- def test_no_checkout_is_a_noop(self):
+ def test_skips_stop_without_a_pid_file(self):
+ # No pid file: the server was never started by this tool, so
+ # stop (and its "stop it manually" noise) is skipped.
with patch.object(make_server, "find_local_checkout",
return_value=None), \
+ patch.object(make_server.servers, "pid_for",
+ return_value=None), \
patch.object(make_server.servers, "stop") as mk_stop:
rc = make_server.uninstall()
self.assertEqual(rc, 0)
- mk_stop.assert_called_once_with("audiocpp")
+ mk_stop.assert_not_called()
def test_accepts_task_view_kwargs_for_registry_symmetry(self):
# The hub calls uninstall(emit=..., cancel=...); emit is unused here
@@ -1844,6 +1885,8 @@ class UninstallTests(unittest.TestCase):
checkout.mkdir()
with patch.object(make_server, "find_local_checkout",
return_value=checkout), \
+ patch.object(make_server.servers, "pid_for",
+ return_value=1234), \
patch.object(make_server.servers, "stop"):
rc = make_server.uninstall(emit=lambda line: None,
cancel=None)
@@ -1860,6 +1903,8 @@ class UninstallTests(unittest.TestCase):
cancel.set()
with patch.object(make_server, "find_local_checkout",
return_value=checkout), \
+ patch.object(make_server.servers, "pid_for",
+ return_value=1234), \
patch.object(make_server.servers, "stop"):
rc = make_server.uninstall(cancel=cancel)
self.assertEqual(rc, 130)