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 | |
| parent | 0b8485a5c8a87d3975cf03cd2a4af965848eb030 (diff) | |
| download | tts-audiobook-generator-29aa2c8f18516e82429a9e751a74d48284f67e9c.tar.gz | |
fix: stop remote form entries overwriting each other
Diffstat (limited to 'app')
| -rw-r--r-- | app/tests/test_hub.py | 179 | ||||
| -rw-r--r-- | app/ui/hub.py | 154 | ||||
| -rw-r--r-- | app/ui/runview.py | 12 |
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. |
