diff options
| author | historia <historiavg@proton.me> | 2026-08-26 00:22:34 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 00:22:34 -0400 |
| commit | 29aa2c8f18516e82429a9e751a74d48284f67e9c (patch) | |
| tree | b0dd507f2d3f72f3458add549922cef3561f4b13 /app/ui/hub.py | |
| parent | 0b8485a5c8a87d3975cf03cd2a4af965848eb030 (diff) | |
| download | tts-audiobook-generator-29aa2c8f18516e82429a9e751a74d48284f67e9c.tar.gz | |
fix: stop remote form entries overwriting each other
Diffstat (limited to 'app/ui/hub.py')
| -rw-r--r-- | app/ui/hub.py | 154 |
1 files changed, 81 insertions, 73 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py index de8b861..1adb54e 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -23,7 +23,6 @@ import contextlib import functools import io import json -import re import shutil import sys import urllib.parse @@ -31,7 +30,6 @@ from datetime import datetime from pathlib import Path from typing import Callable, Optional, Tuple -import audiobook from backends import ( REGISTRY, BackendStatus, @@ -467,7 +465,7 @@ class _Hub: return tui.Wizard.BACK spec = tui.menu(self.stdscr, "Start / Stop a server", options, back_value=tui.Wizard.BACK, - help_lines=["Select a server to start or stop it."], + help_lines=["Start/stop local servers manually."], table_title="Server status", table_rows=rows, notice_lines=_notice_lines()) @@ -628,14 +626,22 @@ def _download_models_action(stdscr) -> None: return def run(emit, cancel): - audiocpp_backend.install_models(checkout, guidance, - emit=emit, cancel=cancel) - return 0 - - taskview.run_steps(stdscr, "Download models", - [taskview.TaskStep("Download missing models", run)]) - tui.flash(stdscr, "Model download finished. Any warnings were shown in " - "the log.", "ok") + return audiocpp_backend.install_models(checkout, guidance, + emit=emit, cancel=cancel) + + rc = taskview.run_steps(stdscr, "Download models", + [taskview.TaskStep("Download missing models", + run)]) + if rc == 0: + tui.flash(stdscr, "Model download finished. Any warnings were shown " + "in the log.", "ok") + elif rc == 130: + tui.flash(stdscr, "Model download cancelled — re-run it any time.", + "warn") + else: + tui.flash(stdscr, "Some model downloads failed. Re-run 'Download " + "Missing Models' or install them by hand (see the log).", + "err") def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: @@ -729,17 +735,24 @@ def _convert_form(stdscr) -> Optional[tuple]: "'Configure backends' first.") return None builders = {} + # A backend can appear twice (managed + "[remote]"), so the remote + # entry's fields are keyed under "<entry>." (e.g. "audiocpp-remote. + # model_id"): the form returns one flat {key: value} dict, and duplicate + # keys would make one entry's value silently win over the other's. for key, _label, st, remote in entries: + prefix = f"{key}." if remote else "" if remote: if st.key == BACKEND_AUDIOCPP: built = _audiocpp_fields( - stdscr, api_url=st.remote_urls.get("audiocpp")) + stdscr, api_url=st.remote_urls.get("audiocpp"), + prefix=prefix) elif st.key == BACKEND_QWEN: built = _qwen_fields(remote_modes=st.remote_models, - urls=st.remote_urls) + urls=st.remote_urls, prefix=prefix) elif st.key == BACKEND_FASTER: built = _faster_fields( - stdscr, api_url=st.remote_urls.get("faster")) + stdscr, api_url=st.remote_urls.get("faster"), + prefix=prefix) else: continue else: @@ -880,14 +893,17 @@ def _common_kwargs(values: dict) -> dict: } -def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: +def _audiocpp_fields(stdscr, api_url: Optional[str] = None, + prefix: str = "") -> Optional[tuple]: """audio.cpp-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the audio.cpp options (Model / Voice / Instructions) and MAPPER turns a submitted form values dict into the audio.cpp converter kwargs. Returns None when the model list cannot be gathered (a flash explains why), so the - caller drops audio.cpp from the Backend choices. + caller drops audio.cpp from the Backend choices. PREFIX namespaces + the field keys ("" for the managed entry) so two entries of this + backend can share one form without overwriting each other. With API_URL None (the managed entry) the model list is fed from the local checkout's server.json — the config of the server this tool @@ -972,7 +988,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: return voice_cache[model_id] def model_entry(fields): - model_id = _field_value(fields, "model_id") + model_id = _field_value(fields, prefix + "model_id") return next((m for m in models if m.get("id") == model_id), models[0]) @@ -985,7 +1001,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: def reset_voice(fields) -> None: """Re-point the Voice field at the newly selected model's voice.""" voice_field = next(f for f in fields - if f.get("key") == "audiocpp_voice") + if f.get("key") == prefix + "audiocpp_voice") capability = model_capability(fields) if capability == AUDIOCPP_VOICE_DESIGN: voice_field["value"] = None @@ -994,7 +1010,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: if config.SPEAKER in QWEN3_TTS_SPEAKERS else QWEN3_TTS_SPEAKERS[0]) else: # clone - voices = voices_for(_field_value(fields, "model_id")) + voices = voices_for(_field_value(fields, prefix + "model_id")) voice_field["value"] = voices[0] if voices else "" def voice_choices(fields) -> list: @@ -1003,8 +1019,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: # Built-in Qwen3-TTS CustomVoice speakers; no server query needed. return [(s, s) for s in QWEN3_TTS_SPEAKERS] if capability == AUDIOCPP_VOICE_CLONE: - return [(v, v) for v in voices_for(_field_value(fields, - "model_id"))] + return [(v, v) for v in voices_for(_field_value( + fields, prefix + "model_id"))] return [] # design: the field is hidden model_ids = [m.get("id") for m in models] @@ -1034,18 +1050,18 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: return f"{entry.get('id') or '':<{id_width}} ({capability})" fields = [ - {"key": "model_id", "label": "Model", "kind": "choice", + {"key": prefix + "model_id", "label": "Model", "kind": "choice", "value": default_model, "choices": [(_label(m), m.get("id")) for m in models], "on_change": reset_voice}, - {"key": "audiocpp_voice", "label": "Voice", "kind": "choice", + {"key": prefix + "audiocpp_voice", "label": "Voice", "kind": "choice", "value": initial_voice, "choices": lambda fs: voice_choices(fs), "visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN, "validate": lambda value: None if (model_capability(fields) != AUDIOCPP_VOICE_CLONE or value) else "This model needs a voice — pick one or switch models"}, - {"key": "instructions", "label": "Instructions", "kind": "text", + {"key": prefix + "instructions", "label": "Instructions", "kind": "text", "value": config.AUDIOCPP_INSTRUCTIONS, "visible": lambda fs: model_capability(fs) in (AUDIOCPP_VOICE_DESIGN, AUDIOCPP_VOICE_SPEAKER), @@ -1055,18 +1071,19 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: ] def mapper(result) -> Optional[tuple]: - model_id = result["model_id"] + model_id = result[prefix + "model_id"] entry = next((m for m in models if m.get("id") == model_id), {}) capability = audiocpp_entry_voice_capability( entry.get("family") or "", entry.get("task") or "tts", entry.get("id") or "") # The picked voice (a built-in speaker name on a CustomVoice entry, # a server-side preset otherwise); the client resolves which it is. - voice = result["audiocpp_voice"] or None + voice = result[prefix + "audiocpp_voice"] or None # design: the voice comes from --instructions instructions = None if capability in (AUDIOCPP_VOICE_DESIGN, AUDIOCPP_VOICE_SPEAKER): - instructions = (result["instructions"] or "").strip() or None + instructions = ((result[prefix + "instructions"] or "") + .strip() or None) kwargs = { "model_id": model_id, "voice": voice, "instructions": instructions, @@ -1080,13 +1097,17 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: def _qwen_fields(remote_modes: Optional[list] = None, - urls: Optional[dict] = None) -> Optional[tuple]: + urls: Optional[dict] = None, + prefix: str = "") -> Optional[tuple]: """qwen-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the qwen options (Voice mode / Speaker / Clone .wav path) and MAPPER turns a submitted form values dict into the qwen converter kwargs. qwen always has options to offer, so it never signals unavailability. + PREFIX namespaces the field keys ("" for the managed entry) so two + entries of this backend can share one form without overwriting each + other. For the managed entry REMOTE_MODES/URLS are None and the mode picker offers both modes, targeting the configured local URLs. For a @@ -1107,31 +1128,32 @@ def _qwen_fields(remote_modes: Optional[list] = None, default_speaker = config.SPEAKER if config.SPEAKER in speakers \ else speakers[0] fields = [ - {"key": "mode", "label": "Voice mode", "kind": "choice", + {"key": prefix + "mode", "label": "Voice mode", "kind": "choice", "value": default_mode, "choices": mode_choices}, - {"key": "speaker", "label": "Speaker", "kind": "choice", + {"key": prefix + "speaker", "label": "Speaker", "kind": "choice", "value": default_speaker, "choices": speakers, - "visible": lambda fs: _field_value(fs, "mode") == "custom"}, - {"key": "clone", "label": "Clone .wav path", "kind": "text", + "visible": lambda fs: _field_value(fs, prefix + "mode") == "custom"}, + {"key": prefix + "clone", "label": "Clone .wav path", "kind": "text", "value": "", "validate": lambda s: None if (s and Path(s).is_file() and s.lower().endswith(".wav")) else "Enter the path to an existing .wav file", - "visible": lambda fs: _field_value(fs, "mode") == "clone"}, + "visible": lambda fs: _field_value(fs, prefix + "mode") == "clone"}, ] def mapper(result) -> Optional[tuple]: - clone = result["clone"].strip() if result["mode"] == "clone" else None - speaker = result["speaker"] - if result["mode"] == "custom" and speaker != config.SPEAKER: + clone = result[prefix + "clone"].strip() \ + if result[prefix + "mode"] == "clone" else None + speaker = result[prefix + "speaker"] + if result[prefix + "mode"] == "custom" and speaker != config.SPEAKER: # Persist the speaker choice for this and future runs (mirrors - # the qwen setup wizard), so the converter picks it up at - # request time. + # the qwen setup wizard); update_config_value keeps both the + # file and the imported module in sync. common.update_config_value("SPEAKER", speaker) - config.SPEAKER = speaker kwargs = {"clone": clone, **_common_kwargs(result)} if urls: - api_url = urls.get("qwen-clone") if result["mode"] == "clone" \ + api_url = urls.get("qwen-clone") \ + if result[prefix + "mode"] == "clone" \ else urls.get("qwen-custom") if api_url: kwargs["api_url"] = api_url @@ -1140,7 +1162,8 @@ def _qwen_fields(remote_modes: Optional[list] = None, return fields, mapper -def _faster_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: +def _faster_fields(stdscr, api_url: Optional[str] = None, + prefix: str = "") -> Optional[tuple]: """faster-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the faster options @@ -1148,7 +1171,9 @@ def _faster_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: free text) and MAPPER turns a submitted form values dict into the faster converter kwargs. Returns None when a local voices.json exists but cannot be read/used (a flash explains why), so the caller - drops faster from the Backend choices. + drops faster from the Backend choices. PREFIX namespaces the field + keys ("" for the managed entry) so two entries of this backend can + share one form without overwriting each other. With API_URL None (the managed entry) a local checkout's voices.json drives the picker. With API_URL set (the "[remote]" entry) the running @@ -1172,7 +1197,7 @@ def _faster_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: if voices is None: # No local voices.json: prompt for a server-side voice name. fields = [ - {"key": "faster_voice", "label": "Voice", "kind": "text", + {"key": prefix + "faster_voice", "label": "Voice", "kind": "text", "value": config.FASTER_VOICE, "validate": lambda s: None if s.strip() else "Enter a voice name"}, ] @@ -1180,14 +1205,14 @@ def _faster_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ next(iter(voices)) fields = [ - {"key": "faster_voice", "label": "Voice", "kind": "choice", + {"key": prefix + "faster_voice", "label": "Voice", "kind": "choice", "value": default, "choices": [(k, k) for k in voices]}, ] def mapper(result) -> Optional[tuple]: - voice = result["faster_voice"].strip() \ - if isinstance(result["faster_voice"], str) \ - else result["faster_voice"] + voice_value = result[prefix + "faster_voice"] + voice = voice_value.strip() \ + if isinstance(voice_value, str) else voice_value kwargs = { "voice": voice or None, **_common_kwargs(result), @@ -1356,11 +1381,16 @@ def _apply_settings(values: dict) -> None: config.AUDIOCPP_API_URL, ports["audiocpp_port"]), **remote_urls, } - _write_config(updates) - for name, value in updates.items(): - setattr(config, name, value) - + # Sync the audio.cpp server.json first: if it fails, neither the file + # nor the in-memory settings are touched, so the save is not reported + # as successful while the two are out of sync. _sync_audiocpp_server_port(ports["audiocpp_port"]) + # update_config_value rewrites app/converter/config.py AND mirrors + # each value onto the imported config module. + for name, value in updates.items(): + if not common.update_config_value(name, value): + raise ValueError(f"Could not save {name} to " + f"{common.CONFIG_PATH}") def _read_port(values: dict, key: str) -> int: @@ -1394,28 +1424,6 @@ def _sync_audiocpp_server_port(port: int) -> None: "left as-is") -def _write_config(updates: dict) -> None: - """Rewrite the ``NAME = value`` lines for UPDATES in app/converter/config.py. - - Only the value of each named assignment changes: the indentation, the - quotes (double, matching the file's style) and any trailing comment on - the line are preserved. Every other line is left untouched. - """ - path = Path(config.__file__).resolve() - text = path.read_text(encoding="utf-8") - for name, value in updates.items(): - rendered = str(value) if isinstance(value, int) else f'"{value}"' - pattern = re.compile( - rf"^(\s*{re.escape(name)}\s*=\s*)(\S*)(\s*(#.*))?$", - re.MULTILINE) - text, count = pattern.subn( - lambda m, rendered=rendered: - f"{m.group(1)}{rendered}{m.group(3) or ''}", text) - if count != 1: - raise ValueError(f"Could not find {name} in {path}") - path.write_text(text, encoding="utf-8") - - def _prepare_run_config(backend: str, kwargs: dict ) -> Optional[runview.RunConfig]: """Build the run view's config from the accepted conversion kwargs. |
