aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/tests/test_hub.py179
-rw-r--r--app/ui/hub.py154
-rw-r--r--app/ui/runview.py12
3 files changed, 216 insertions, 129 deletions
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index f350072..9f72ad9 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -649,12 +649,23 @@ class ConvertFlowTests(unittest.TestCase):
patcher.start()
self.addCleanup(patcher.stop)
+ # Keys shared by every backend entry; a "-remote" backend's other
+ # option keys are namespaced under "<entry>." in the form dict
+ # (mirroring hub.py), so _form_values maps them automatically.
+ _COMMON_KEYS = frozenset(("backend", "output_format", "speed",
+ "single_file", "debug", "stop_and_exit"))
+
def _form_values(self, **overrides):
"""A fully-populated form result, with sensible defaults."""
values = {"output_format": "m4b", "speed": "1.0",
"single_file": False, "debug": False,
"stop_and_exit": True}
values.update(overrides)
+ backend = values.get("backend") or ""
+ if backend.endswith("-remote"):
+ prefix = f"{backend}."
+ values = {(prefix + key if key not in self._COMMON_KEYS else key):
+ value for key, value in values.items()}
return values
def _answer_form(self, **overrides):
@@ -662,7 +673,8 @@ class ConvertFlowTests(unittest.TestCase):
def _field(self, key, form_index=-1):
_, fields, _ = self.tui.forms_seen[form_index]
- return next(f for f in fields if f["key"] == key)
+ return next(f for f in fields if f["key"] == key
+ or f["key"].endswith("." + key))
def _ready(self, key, label):
"""A backend status that is ready to convert with."""
@@ -748,9 +760,10 @@ class ConvertFlowTests(unittest.TestCase):
title, fields, form_kwargs = self.tui.forms_seen[0]
self.assertEqual(title, "Generate audiobooks")
self.assertEqual([f["key"] for f in fields],
- ["backend", "model_id", "audiocpp_voice",
- "instructions", "output_format", "speed",
- "single_file", "debug", "stop_and_exit"])
+ ["backend", "audiocpp-remote.model_id",
+ "audiocpp-remote.audiocpp_voice",
+ "audiocpp-remote.instructions", "output_format",
+ "speed", "single_file", "debug", "stop_and_exit"])
self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel"))
self.assertTrue(form_kwargs["start_on_buttons"])
# The stop-and-exit toggle ships on by default.
@@ -1002,6 +1015,59 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(fields[0]["choices"],
[("audio.cpp", "audiocpp"),
("audio.cpp [remote]", "audiocpp-remote")])
+ # The two entries' fields are namespaced, so both carry their own
+ # values and picking one never leaks the other's into the run.
+ keys = [f["key"] for f in fields]
+ self.assertIn("model_id", keys)
+ self.assertIn("audiocpp-remote.model_id", keys)
+
+ def test_managed_and_remote_entries_do_not_overwrite_each_other(self):
+ # Regression: the form returns one flat {key: value} dict. When
+ # managed and remote entries shared field keys, the remote entry's
+ # hidden defaults silently won over the user's edits on whichever
+ # entry was selected.
+ self._patch_remote(
+ [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}],
+ voices=["narrator"])
+ statuses = [self._ready("audiocpp", "audio.cpp"),
+ self._remote("audiocpp", "audio.cpp")]
+ common = {"output_format": "m4b", "speed": "1.0",
+ "single_file": False, "debug": False,
+ "stop_and_exit": True}
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ (root / "server.json").write_text(json.dumps({
+ "models": [{"id": "qwen", "family": "qwen3_tts",
+ "task": "tts"}],
+ }), encoding="utf-8")
+ with patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=root), \
+ patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""):
+ # Managed selected: its picks must survive next to the
+ # remote entry's same-shaped fields.
+ self.tui.form_script.append({
+ "backend": "audiocpp", "model_id": "qwen",
+ "audiocpp_voice": "", "instructions": "",
+ "audiocpp-remote.model_id": "higgs",
+ "audiocpp-remote.audiocpp_voice": "narrator",
+ **common})
+ managed_cmd = self._convert(None, statuses)
+ self.assertIsNotNone(managed_cmd)
+ self.assertEqual(managed_cmd[2]["model_id"], "qwen")
+ # The remote entry's hidden "narrator" voice must not leak
+ # into the managed run (the old duplicate-key behavior).
+ self.assertIsNone(managed_cmd[2]["voice"])
+ # Remote selected: its picks win instead.
+ self.tui.form_script.append({
+ "backend": "audiocpp-remote",
+ "audiocpp-remote.model_id": "higgs",
+ "audiocpp-remote.audiocpp_voice": "narrator",
+ "model_id": "qwen",
+ **common})
+ remote_cmd = self._convert(None, statuses)
+ self.assertIsNotNone(remote_cmd)
+ self.assertEqual(remote_cmd[2]["model_id"], "higgs")
+ self.assertEqual(remote_cmd[2]["voice"], "narrator")
def test_audiocpp_remote_mapper_adds_api_url(self):
self._patch_remote(
@@ -1038,15 +1104,20 @@ class ConvertFlowTests(unittest.TestCase):
patch.object(hub.config, "SPEAKER", "Vivian"):
self._answer_form(backend="qwen", mode="custom", speaker="Serena",
clone="")
- with patch.object(hub.common, "update_config_value") as mk_update:
+ # The fake mirrors the real update_config_value contract:
+ # persisting a value also lands it on the imported module.
+ def fake_update(key, value, config_path=None):
+ setattr(hub.config, key, value)
+ return True
+
+ with patch.object(hub.common, "update_config_value",
+ fake_update):
cmd = self._convert(None,
[self._ready("qwen", "qwen-tts")])
speaker_in_memory = hub.config.SPEAKER
self.assertEqual(cmd[0], "convert")
self.assertEqual(cmd[1], hub.BACKEND_QWEN)
self.assertIsNone(cmd[2]["clone"])
- # The speaker choice is persisted for future runs too.
- mk_update.assert_called_once_with("SPEAKER", "Serena")
self.assertEqual(speaker_in_memory, "Serena")
fields = self.tui.forms_seen[0][1]
self.assertEqual([f["key"] for f in fields],
@@ -1512,8 +1583,25 @@ class AddAutostartTests(unittest.TestCase):
class SettingsTests(unittest.TestCase):
"""Settings menu: field collection, validation, config.py writing."""
- def test_write_config_preserves_comments_and_other_lines(self):
+ # Keys _apply_settings persists; every test that triggers a real or
+ # fake config write restores these afterwards.
+ _SETTING_KEYS = ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
+ "CHUNK_SIZE", "STOP_SERVER_AND_EXIT",
+ "AUDIOCPP_UNLOAD_MODELS",
+ "QWEN_API_URL", "CLONE_API_URL",
+ "FASTER_API_URL", "AUDIOCPP_API_URL",
+ "QWEN_REMOTE_URL", "CLONE_REMOTE_URL",
+ "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")
+
+ def _snapshot_settings(self):
+ original = {name: getattr(hub.config, name) for name in
+ self._SETTING_KEYS}
+ self.addCleanup(lambda: [setattr(hub.config, name, value)
+ for name, value in original.items()])
+
+ def test_update_config_value_preserves_comments_and_other_lines(self):
import tempfile
+ self._snapshot_settings()
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "config.py"
path.write_text(
@@ -1524,11 +1612,12 @@ class SettingsTests(unittest.TestCase):
"\n"
"CHUNK_SIZE = 250 # words per request\n",
encoding="utf-8")
- with patch.object(hub.config, "__file__", str(path)):
- hub._write_config({"AUDIO_FORMAT": "mp3",
- "AUDIO_BITRATE": "192k",
- "LANGUAGE": "Japanese",
- "CHUNK_SIZE": 300})
+ for key, value in (("AUDIO_FORMAT", "mp3"),
+ ("AUDIO_BITRATE", "192k"),
+ ("LANGUAGE", "Japanese"),
+ ("CHUNK_SIZE", 300)):
+ self.assertTrue(hub.common.update_config_value(
+ key, value, config_path=path))
text = path.read_text(encoding="utf-8")
self.assertEqual(
text,
@@ -1538,32 +1627,28 @@ class SettingsTests(unittest.TestCase):
'LANGUAGE = "Japanese"\n'
"\n"
"CHUNK_SIZE = 300 # words per request\n")
+ # The imported module mirrors the file immediately.
+ self.assertEqual(hub.config.AUDIO_FORMAT, "mp3")
+ self.assertEqual(hub.config.CHUNK_SIZE, 300)
- def test_write_config_missing_key_raises(self):
+ def test_update_config_value_missing_key_returns_false(self):
import tempfile
+ self._snapshot_settings()
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "config.py"
path.write_text("X = 1\n", encoding="utf-8")
- with patch.object(hub.config, "__file__", str(path)):
- with self.assertRaises(ValueError):
- hub._write_config({"AUDIO_FORMAT": "mp3"})
+ self.assertFalse(hub.common.update_config_value(
+ "AUDIO_FORMAT", "mp3", config_path=path))
def test_apply_settings_writes_and_reloads_in_memory(self):
written = {}
- def fake_write(updates):
- written.update(updates)
+ def fake_update(key, value, config_path=None):
+ written[key] = value
+ setattr(hub.config, key, value)
+ return True
- original = {name: getattr(hub.config, name) for name in
- ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
- "CHUNK_SIZE", "STOP_SERVER_AND_EXIT",
- "AUDIOCPP_UNLOAD_MODELS",
- "QWEN_API_URL", "CLONE_API_URL",
- "FASTER_API_URL", "AUDIOCPP_API_URL",
- "QWEN_REMOTE_URL", "CLONE_REMOTE_URL",
- "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")}
- self.addCleanup(lambda: [setattr(hub.config, name, value)
- for name, value in original.items()])
+ self._snapshot_settings()
values = {"audio_format": "ogg", "audio_bitrate": " 192k ",
"language": "en", "chunk_size": "300",
"stop_and_exit": False,
@@ -1574,7 +1659,8 @@ class SettingsTests(unittest.TestCase):
"faster_remote_url": "http://10.0.0.6:8000",
"qwen_custom_remote_url": "",
"qwen_clone_remote_url": ""}
- with patch.object(hub, "_write_config", fake_write), \
+ with patch.object(hub.common, "update_config_value",
+ fake_update), \
patch.object(hub, "_sync_audiocpp_server_port"):
hub._apply_settings(values)
# Values are trimmed and language normalized to a display name;
@@ -1609,23 +1695,14 @@ class SettingsTests(unittest.TestCase):
"http://10.0.0.5:8080")
def test_apply_settings_rejects_bad_values(self):
- original = {name: getattr(hub.config, name) for name in
- ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE",
- "CHUNK_SIZE", "STOP_SERVER_AND_EXIT",
- "AUDIOCPP_UNLOAD_MODELS",
- "QWEN_API_URL", "CLONE_API_URL",
- "FASTER_API_URL", "AUDIOCPP_API_URL",
- "QWEN_REMOTE_URL", "CLONE_REMOTE_URL",
- "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")}
- self.addCleanup(lambda: [setattr(hub.config, name, value)
- for name, value in original.items()])
+ self._snapshot_settings()
base = {"audio_format": "m4b", "audio_bitrate": "128k",
"language": "English", "chunk_size": "250",
"stop_and_exit": True,
"unload_models": True,
"qwen_custom_port": "7860", "qwen_clone_port": "7861",
"faster_port": "8000", "audiocpp_port": "8080"}
- with patch.object(hub, "_write_config") as mk_write:
+ with patch.object(hub.common, "update_config_value") as mk_update:
with self.assertRaises(ValueError):
hub._apply_settings({**base, "language": "Klingon"})
with self.assertRaises(ValueError):
@@ -1635,7 +1712,7 @@ class SettingsTests(unittest.TestCase):
with self.assertRaises(ValueError):
hub._apply_settings({**base,
"audiocpp_remote_url": "not a url"})
- mk_write.assert_not_called()
+ mk_update.assert_not_called()
def test_field_validators(self):
self.assertIsNone(hub._validate_bitrate("128k"))
@@ -1831,7 +1908,8 @@ class SettingsTests(unittest.TestCase):
'FASTER_REMOTE_URL = "http://127.0.0.1:8000"\n'
'AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080"\n',
encoding="utf-8")
- with patch.object(hub.config, "__file__", str(path)):
+ with patch.object(hub.common, "CONFIG_PATH", path), \
+ patch.object(hub, "_sync_audiocpp_server_port"):
# Down to Chunk size, Enter -> editor, Ctrl-U + '300',
# Enter; Tab -> Save, Enter; a key dismisses the flash.
screen = FakeScreen(keys=[
@@ -1860,14 +1938,13 @@ class SettingsTests(unittest.TestCase):
'FASTER_API_URL = "http://127.0.0.1:8000"\n'
'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n',
encoding="utf-8")
- with patch.object(hub.config, "__file__", str(path)), \
- patch.object(hub, "_sync_audiocpp_server_port"):
- hub._write_config({
- "QWEN_API_URL": "http://127.0.0.1:7862",
- "CLONE_API_URL": "http://127.0.0.1:7863",
- "FASTER_API_URL": "http://127.0.0.1:8001",
- "AUDIOCPP_API_URL": "http://127.0.0.1:8081",
- })
+ self._snapshot_settings()
+ for key, value in (
+ ("QWEN_API_URL", "http://127.0.0.1:7862"),
+ ("CLONE_API_URL", "http://127.0.0.1:7863"),
+ ("FASTER_API_URL", "http://127.0.0.1:8001"),
+ ("AUDIOCPP_API_URL", "http://127.0.0.1:8081")):
+ hub.common.update_config_value(key, value, config_path=path)
text = path.read_text(encoding="utf-8")
self.assertIn('QWEN_API_URL = "http://127.0.0.1:7862"', text)
self.assertIn('CLONE_API_URL = "http://127.0.0.1:7863"', text)
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.