aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 00:22:34 -0400
committerhistoria <historiavg@proton.me>2026-08-26 00:22:34 -0400
commit29aa2c8f18516e82429a9e751a74d48284f67e9c (patch)
treeb0dd507f2d3f72f3458add549922cef3561f4b13 /app/ui
parent0b8485a5c8a87d3975cf03cd2a4af965848eb030 (diff)
downloadtts-audiobook-generator-29aa2c8f18516e82429a9e751a74d48284f67e9c.tar.gz
fix: stop remote form entries overwriting each other
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py154
-rw-r--r--app/ui/runview.py12
2 files changed, 88 insertions, 78 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.
diff --git a/app/ui/runview.py b/app/ui/runview.py
index 60c43b0..7151ecb 100644
--- a/app/ui/runview.py
+++ b/app/ui/runview.py
@@ -338,7 +338,7 @@ class RunView:
return False
if key in (27, ord("q"), 3) and not self.cancelling:
if self._prompt_cancel():
- return
+ return False
finally:
self._monitor_stop.set()
self._cancel.set()
@@ -380,12 +380,14 @@ class RunView:
return False
self.cancelling = True
self._cancel.set()
+ # Wind the worker down BEFORE offering the server stop: killing the
+ # server under a still-running request turns the cancellation into
+ # request failures (reported as "failed" instead of "cancelled").
+ self._worker.join(timeout=60)
# When this run booted the server, offer to shut it down too (the
- # boot path kills it itself when cancelled before ready).
+ # boot path kills it itself when cancelled before ready); by now
+ # the worker is done, so nothing is mid-request.
self._confirm_stop_server()
- # Wait for the worker to wind down so the hub menu shows the real
- # backend state (and the summary screen is drawn at least once).
- self._worker.join(timeout=60)
self._drain()
self.render()
# One more key press acknowledges the final screen.