diff options
| author | historia <historiavg@proton.me> | 2026-08-26 20:43:05 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 20:43:05 -0400 |
| commit | 65c6f737f1545ef225768af897acd20f163a4fb4 (patch) | |
| tree | 9c0974944191e50b9caa90591dfcb7f97c659770 | |
| parent | 975bd960fd07e75799b8e3adc4c0033046b34792 (diff) | |
| download | tts-audiobook-generator-65c6f737f1545ef225768af897acd20f163a4fb4.tar.gz | |
fix: settings menu only prompts to save after change
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | app/backends/audiocpp/__init__.py | 3 | ||||
| -rw-r--r-- | app/backends/audiocpp/catalog.py | 46 | ||||
| -rw-r--r-- | app/converter/config.py | 2 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 2 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 73 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 171 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 26 | ||||
| -rw-r--r-- | app/ui/hub.py | 69 | ||||
| -rw-r--r-- | app/ui/tui.py | 14 |
10 files changed, 397 insertions, 11 deletions
@@ -77,7 +77,7 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py -- Other options — including backend server URLs, ports, and the remote-server URLs the hub probes for `[remote]` entries — are configured in `app/converter/config.py` (or the TUI's **Settings** menu). -The **Generate audiobooks** TUI form exposes the same per-run controls as these flags: for `audiocpp` the Model picker labels each entry's voice capability (`speaker` / `clone` / `design`), the Voice field is labelled **Built-in voice** on CustomVoice entries and **Voice to clone** everywhere else, Instructions work on every entry (required for `vdes`, optional style/delivery control elsewhere — or the voice itself on families without built-in speakers), a Request options field accepts `KEY=VALUE` items (the `--option` equivalent), and Language overrides the global setting per run (hidden for `faster`, which owns language server-side). +The **Generate audiobooks** TUI form exposes the same per-run controls as these flags: for `audiocpp` the Model picker labels each entry's voice capability (`speaker` / `clone` / `design`), the Voice field is labelled **Built-in voice** on CustomVoice entries and **Voice to clone** everywhere else, Instructions work on every entry (required for `vdes`, optional style/delivery control elsewhere — or the voice itself on families without built-in speakers), a Request options field accepts `KEY=VALUE` items (the `--option` equivalent, shown only for model families whose audio.cpp spec declares request options), and Language overrides the global setting per run (hidden for `faster`, which owns language server-side). The Instructions and Request options editors show dim hints with examples while editing. ## Manual TTS Backend Setup diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py index a0fd16b..b97032b 100644 --- a/app/backends/audiocpp/__init__.py +++ b/app/backends/audiocpp/__init__.py @@ -35,6 +35,8 @@ from .catalog import ( build_server_config, load_server_config, server_config_selections, + request_options_families, + supports_request_options, ) from .models import ( delete_model_files, @@ -83,6 +85,7 @@ __all__ = [ "detect_backend", "load_model_catalog", "is_design_package", "package_dir_options", "build_model_entry", "build_server_config", "load_server_config", "server_config_selections", + "request_options_families", "supports_request_options", # models "missing_model_entries", "installed_model_entries", "unused_installed_entries", "delete_model_files", "install_models", diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py index f1989e9..2892b9c 100644 --- a/app/backends/audiocpp/catalog.py +++ b/app/backends/audiocpp/catalog.py @@ -10,6 +10,52 @@ from .constants import TASK_TTS DESIGN_PACKAGE_RE = re.compile(r"voice[\s_\-]?design", re.IGNORECASE) +def request_options_families(audiocpp_dir: Path) -> Dict[str, dict]: + """Map the families whose spec defines per-request options. + + Reads every ``model_specs/<family>.json`` in AUDIOCPP_DIR once and + returns ``{family_key: {"display_name": ...}}`` for the specs that + list request options (a non-empty ``options.request`` array) — the + families a "Request options" field makes sense for. The key is the + spec's ``family`` field (falling back to the file stem), matching + what ``GET /v1/models`` reports, so callers can look an entry up by + its family id. A missing or unreadable specs directory yields {} + (every family then counts as unknown rather than unsupported). + """ + specs_dir = audiocpp_dir / "model_specs" + if not specs_dir.is_dir(): + return {} + families: Dict[str, dict] = {} + for spec_path in sorted(specs_dir.glob("*.json")): + try: + spec = json.loads(spec_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + options = spec.get("options") + request = options.get("request") if isinstance(options, dict) else None + if not isinstance(request, list) or not request: + continue + family = str(spec.get("family") or spec_path.stem) + families[family] = { + "display_name": str(spec.get("display_name") or family), + } + return families + + +def supports_request_options(families: Dict[str, dict], + family: str) -> Optional[bool]: + """Whether FAMILY accepts per-request options — None when unknown. + + True only when FAMILIES (from request_options_families) lists the + family; False when it was read but does not define request options; + None when support cannot be determined from the local specs (no + checkout, or a family the specs do not describe). + """ + if not families: + return None + return family in families + + _BACKEND_DESCRIPTIONS = ( ("cuda", "NVIDIA GPUs (fastest)"), ("vulkan", "cross-vendor GPU"), diff --git a/app/converter/config.py b/app/converter/config.py index 9b09e7a..ae7189f 100644 --- a/app/converter/config.py +++ b/app/converter/config.py @@ -12,7 +12,7 @@ CHUNK_SIZE = 250 # Default for "Stop server and exit" on the Generate audiobooks form # (TUI Settings menu: "Default stop server and exit"). -STOP_SERVER_AND_EXIT = False +STOP_SERVER_AND_EXIT = True # Default TTS backend. # audiocpp: audiocpp_server diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index f3b3c54..1d76648 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -98,7 +98,7 @@ python audiobook.py --backend audiocpp --model <id> --voice narrator \ --option emotion=neutral --option speed=1.1 ``` -In the hub's **Generate audiobooks** form the Model picker shows each entry's voice capability (`speaker` / `clone` / `design`). The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** everywhere else (listing the server's preset/voice_dir entries). Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option` (e.g. `emotion=neutral, speed=1.1`), and Language overrides the global setting for this run only. +In the hub's **Generate audiobooks** form the Model picker shows each entry's voice capability (`speaker` / `clone` / `design`). The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** everywhere else (listing the server's preset/voice_dir entries). Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language overrides the global setting for this run only. The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine): set `AUDIOCPP_REMOTE_URL` in `app/converter/config.py` (or the TUI **Settings** → "audio.cpp remote URL") to its `host:port`. The hub probes that URL and, when it answers, offers an `audio.cpp [remote]` entry in **Generate audiobooks…** whose models and voices are queried live (`GET /v1/models` and `GET /v1/audio/voices`) — alongside the managed `audio.cpp` entry, which keeps reading the local `server.json`. The remote URL defaults to `127.0.0.1:8080`, so a server started outside this tool on the local port is found automatically. On the CLI, pass `--api-url http://host:port` (and `--model`/`--voice` matching that server's config). diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index d364c06..ab405aa 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -406,6 +406,18 @@ class FindLocalCheckoutTests(unittest.TestCase): self.assertIsNone(make_server.build.find_local_checkout()) +def _add_options_to_spec(checkout: Path, family: str, *, + options=None) -> None: + """Rewrite one family spec with an (optional) options block.""" + path = checkout / "model_specs" / f"{family}.json" + spec = json.loads(path.read_text(encoding="utf-8")) + if options is not None: + spec["options"] = options + elif "options" in spec: + del spec["options"] + path.write_text(json.dumps(spec), encoding="utf-8") + + class LoadModelCatalogTests(unittest.TestCase): def setUp(self): self._td = tempfile.TemporaryDirectory() @@ -468,6 +480,67 @@ class LoadModelCatalogTests(unittest.TestCase): make_server.catalog.load_model_catalog(empty) +class RequestOptionsFamiliesTests(unittest.TestCase): + """request_options_families: which specs declare request options.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.checkout = _make_checkout(Path(self._td.name)) + _add_options_to_spec( + self.checkout, "higgs_audio_tts", + options={"request": [{"id": "temperature", "default": 0.8}, + {"id": "speed"}]}) + + def tearDown(self): + self._td.cleanup() + + def test_family_with_request_options_listed_with_display_name(self): + families = make_server.request_options_families(self.checkout) + self.assertEqual(families.get("higgs_audio_tts"), + {"display_name": "Higgs Audio v3 TTS 4B"}) + + def test_family_without_options_block_absent(self): + families = make_server.request_options_families(self.checkout) + self.assertNotIn("qwen3_tts", families) + self.assertNotIn("voxcpm2", families) + + def test_empty_request_list_does_not_count_as_support(self): + _add_options_to_spec(self.checkout, "supertonic", + options={"request": []}) + families = make_server.request_options_families(self.checkout) + self.assertNotIn("supertonic", families) + + def test_missing_specs_dir_yields_empty_map(self): + self.assertEqual(make_server.request_options_families( + Path(self._td.name)), {}) + + def test_unparsable_spec_skipped(self): + (self.checkout / "model_specs" / "broken.json").write_text( + "{not json", encoding="utf-8") + families = make_server.request_options_families(self.checkout) + self.assertNotIn("broken", families) + self.assertIn("higgs_audio_tts", families) + + +class SupportsRequestOptionsTests(unittest.TestCase): + """supports_request_options: True / False / unknown tri-state.""" + + FAMILIES = {"higgs_audio_tts": {"display_name": "Higgs"}} + + def test_true_only_for_a_listed_family(self): + self.assertTrue(make_server.supports_request_options( + self.FAMILIES, "higgs_audio_tts")) + + def test_false_for_a_read_but_unlisted_family(self): + self.assertFalse(make_server.supports_request_options( + self.FAMILIES, "qwen3_tts")) + + def test_none_when_no_local_specs_exist(self): + self.assertIsNone(make_server.supports_request_options({}, "any")) + # An entry with no family at all is unclassifiable too. + self.assertIsNone(make_server.supports_request_options({}, "")) + + class DetectBackendTests(unittest.TestCase): """Backend detection from audio.cpp build directory names.""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 7e77642..48cef0b 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1083,6 +1083,116 @@ class ConvertFlowTests(unittest.TestCase): # back to no options instead of crashing the mapper. self.assertEqual(cmd[2]["request_options"], {}) + # ------------------------------------------------------------------ + # audio.cpp: Request options gated by model_specs option support + # ------------------------------------------------------------------ + + def _specs_checkout(self, families_with_options=()): + """A fake checkout whose specs mark FAMILIES_WITH_OPTIONS supportive.""" + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + specs = root / "model_specs" + specs.mkdir() + for family in ("higgs_audio_tts", "qwen3_tts"): + request = ([{"id": "temperature"}] + if family in families_with_options else []) + spec = {"family": family, "display_name": family.title(), + "packages": [{"id": f"{family}_q8_0", "default": True, + "format": "gguf", + "target_directory": f"{family}-GGUF"}]} + if request: + spec["options"] = {"request": request} + (specs / f"{family}.json").write_text(json.dumps(spec), + encoding="utf-8") + return root + + def test_options_field_visible_when_spec_proves_support(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=self._specs_checkout( + ("higgs_audio_tts",))), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + self.assertTrue(self._field("request_options")["visible"](fields)) + self.assertIsNotNone(cmd) + + def test_options_field_hidden_when_spec_lacks_the_family(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + # A checkout exists but only qwen3_tts declares request options: + # higgs is provably unsupported -> hidden. + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=self._specs_checkout( + ("qwen3_tts",))), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + self.assertFalse(self._field("request_options")["visible"](fields)) + + def test_options_field_hidden_without_a_local_checkout(self): + # Unknown support (no specs anywhere) hides the field — strict. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=None), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + self.assertFalse(self._field("request_options")["visible"](fields)) + + def test_instructions_help_is_short_and_shared(self): + # One compact static help text for every capability: two lines, + # naming style instructions, partial clone-model support, and an + # example. (Design entries enforce their requirement by validation.) + self._patch_remote([ + {"id": "design", "family": "qwen3_tts", "task": "vdes"}, + {"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}, + ], voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="design", + audiocpp_voice=None, + instructions="A warm British narrator") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + instr = self._field("instructions") + self.assertEqual(instr["help"], [ + "TTS style instructions. Supported by some clone models. Example:", + '"Speak in a calm, soothing, and happy tone."', + ]) + + def test_options_help_is_two_lines_with_examples(self): + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=self._specs_checkout( + ("qwen3_tts", "higgs_audio_tts"))), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + options = self._field("request_options") + fields = self.tui.forms_seen[0][1] + self.assertTrue(options["visible"](fields)) + self.assertEqual(len(options["help"]), 2) + help_text = "\n".join(options["help"]) + self.assertIn("KEY=VALUE", help_text) + self.assertIn("emotion=neutral", help_text) + def test_language_passes_through_normalized(self): with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.config, "SPEAKER", "Vivian"): @@ -1453,11 +1563,13 @@ class ConvertFlowTests(unittest.TestCase): "output_format", "language", "speed", "single_file", "debug", "stop_and_exit"]) # The form opens on the configured default (audio.cpp): its fields - # show, the other backend's hide. (Instructions shows too: optional - # style/delivery control even on the clone-only higgs entry.) - for key in ("model_id", "audiocpp_voice", "instructions", - "request_options"): + # show, the other backend's hide. Instructions shows too (optional + # style/delivery control even on the clone-only higgs entry), while + # Request options stays hidden — higgs has no option-supporting + # spec on this machine's checkout, so its support is unknown. + for key in ("model_id", "audiocpp_voice", "instructions"): self.assertTrue(self._field(key)["visible"](fields)) + self.assertFalse(self._field("request_options")["visible"](fields)) # Language shows for every backend except faster entries. self.assertTrue(self._field("language")["visible"](fields)) for key in ("mode", "speaker", "clone"): @@ -1976,7 +2088,10 @@ class SettingsTests(unittest.TestCase): self.assertNotIn("flash", captured) def test_settings_menu_cancel_does_not_apply(self): + # An actual edit triggers the save prompt; "no" discards it. def fake_form(stdscr, title, fields, back_value=None): + next(f for f in fields + if f["key"] == "chunk_size")["value"] = "300" return back_value # user pressed Cancel / q / Esc applied = [] @@ -1992,6 +2107,54 @@ class SettingsTests(unittest.TestCase): mk_prompt.assert_called_once_with(None, "Save settings?") self.assertEqual(applied, []) + def test_settings_menu_exit_without_changes_skips_prompt(self): + # Leaving with untouched fields never asks about saving. + def fake_form(stdscr, title, fields, back_value=None): + return back_value # user pressed Cancel / q / Esc + + applied = [] + + with patch.object(hub.tui, "form", fake_form), \ + patch.object(hub.tui, "confirm_yn_cancel") as mk_prompt, \ + patch.object(hub, "_apply_settings", + lambda values: applied.append(values)): + hub._Hub(None).screen_settings() + mk_prompt.assert_not_called() + self.assertEqual(applied, []) + + def test_settings_menu_reverted_edit_skips_the_prompt(self): + # Typing a value and typing it back leaves nothing to save. + def fake_form(stdscr, title, fields, back_value=None): + field = next(f for f in fields if f["key"] == "chunk_size") + untouched = field["value"] + field["value"] = "300" + field["value"] = untouched + return back_value + + applied = [] + + with patch.object(hub.tui, "form", fake_form), \ + patch.object(hub.tui, "confirm_yn_cancel") as mk_prompt, \ + patch.object(hub, "_apply_settings", + lambda values: applied.append(values)): + hub._Hub(None).screen_settings() + mk_prompt.assert_not_called() + self.assertEqual(applied, []) + + def test_settings_menu_whitespace_edit_skips_the_prompt(self): + # Surrounding whitespace alone is not a change: _apply_settings + # trims text values, so saving would be a no-op. + def fake_form(stdscr, title, fields, back_value=None): + field = next(f for f in fields if f["key"] == "language") + field["value"] = " " + field["value"] + " " + return back_value + + with patch.object(hub.tui, "form", fake_form), \ + patch.object(hub.tui, "confirm_yn_cancel") as mk_prompt, \ + patch.object(hub, "_apply_settings", lambda values: None): + hub._Hub(None).screen_settings() + mk_prompt.assert_not_called() + def test_settings_menu_exit_yes_applies_the_edited_fields(self): # Leaving via Esc and answering Yes applies a values dict built # from the (edited) field list. diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index cd4e535..c836178 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -662,6 +662,32 @@ class FormTests(TuiTestCase): if "Voice of base" in text] self.assertTrue(titles) + def test_help_lines_render_inside_the_edit_dialog(self): + # A field's optional "help" list appears as dim lines in its line + # editor (and callables resolve against the field list). + fields = [ + {"key": "opt", "kind": "text", "value": "", + "label": "Options", + "help": lambda fs: ["First hint.", f"Value: {fs[0]['value']!r}"]}, + {"key": "plain", "label": "Plain", "kind": "text", + "value": "", "help": ["Static hint."]}, + ] + # Down to the second field, Enter opens it, Esc backs out; Tab -> + # Save, Enter. The first field's editor is never opened, so only + # the static second field's hints must appear. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10, 27, 9, 10]) + tui.form(screen, "Settings", fields) + texts = [text for _, _, text, _ in screen.strings] + self.assertIn("Static hint.", texts) + self.assertNotIn("First hint.", texts) + # Open the first field's editor: dynamic help resolves per redraw. + screen = FakeScreen(keys=[10, 27, 9, 10]) + tui.form(screen, "Settings", fields) + texts = [text for _, _, text, _ in screen.strings] + self.assertIn("First hint.", texts) + self.assertIn("Value: ''", texts) + self.assert_inside_border(screen) + def test_field_note_renders_and_save(self): fields = self._fields() fields[1]["note"] = "A short section note" diff --git a/app/ui/hub.py b/app/ui/hub.py index d9d291b..0fd1b4d 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -414,6 +414,7 @@ class _Hub: def screen_settings(self): fields = _settings_fields() + original = {field["key"]: field["value"] for field in fields} while True: result = tui.form(self.stdscr, "Settings", fields, back_value=tui.Wizard.BACK) @@ -425,7 +426,10 @@ class _Hub: tui.flash(self.stdscr, str(exc), "err") return tui.Wizard.BACK # q/Esc (or the Cancel button) left the form without saving: - # ask whether the edits should be kept before discarding them. + # with no edits there is nothing to keep, so go straight back; + # otherwise ask whether the edits should be preserved. + if not _settings_changed(fields, original): + return tui.Wizard.BACK answer = tui.confirm_yn_cancel(self.stdscr, "Save settings?") if answer == "cancel": continue # back into the form, edits intact @@ -1022,6 +1026,17 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, if data.get("voice_dir") else [] voice_cache: dict = {} # model id -> voices (local: shared list) + # Per-family request-option support comes from this machine's audio.cpp + # checkout model_specs, best effort for both entries: the server's HTTP + # API does not report it. A "[remote]" entry is classified from the same + # local specs when a family matches; with no checkout every family counts + # as unknown and the Request options field stays hidden. + specs_checkout = checkout if local \ + else audiocpp_backend.find_local_checkout() + option_families = ( + audiocpp_backend.request_options_families(specs_checkout) + if specs_checkout is not None else {}) + def voices_for(model_id: str) -> list: if local: return local_voices @@ -1119,6 +1134,31 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, entry.get("id") or "") return f"{entry.get('id') or '':<{id_width}} ({capability})" + def entry_supports_options(fs) -> bool: + """True when the selected entry's family defines request options. + + Resolved strictly from this machine's model_specs: a family the + specs prove unable to read options, or cannot classify at all, + keeps the field hidden (unknown support is treated as no). + """ + family = model_entry(fs).get("family") or "" + return audiocpp_backend.supports_request_options( + option_families, family) is True + + # Edit-dialog help lines: short, and identical for every capability + # (design-model validation already explains its own requirement). + INSTRUCTIONS_HELP = [ + "TTS style instructions. Supported by some clone models. Example:", + '"Speak in a calm, soothing, and happy tone."', + ] + # Edit-dialog help for the Request options field — at most 2 lines; + # unsupported keys are ignored server-side, so nothing else needs + # spelling out here. + OPTIONS_HELP = [ + "KEY=VALUE items, comma/space separated; unsupported keys ignored.", + "Examples: emotion=neutral, speed=1.1, temperature=0.8", + ] + fields = [ {"key": prefix + "model_id", "label": "Model", "kind": "choice", "value": default_model, @@ -1142,13 +1182,17 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, # that read instructions, the voice itself (instruction-voice mode). {"key": prefix + "instructions", "label": "Instructions", "kind": "text", "value": config.AUDIOCPP_INSTRUCTIONS, + "help": INSTRUCTIONS_HELP, "validate": lambda value: None if (model_capability(fields) != AUDIOCPP_VOICE_DESIGN or str(value).strip()) else "Describe the voice, e.g. 'A warm female narrator'"}, - # Free-form per-model controls (--option KEY=VALUE on the CLI), - # e.g. "emotion=neutral, speed=1.1". + # Free-form per-model controls (--option KEY=VALUE on the CLI). + # Shown only for families whose audio.cpp spec declares request + # options; unknown-support families keep it hidden. {"key": prefix + "request_options", "label": "Request options", "kind": "text", "value": "", + "visible": entry_supports_options, + "help": OPTIONS_HELP, "validate": _validate_request_options}, ] @@ -1314,6 +1358,25 @@ def _faster_fields(stdscr, api_url: Optional[str] = None, # Settings menu (global output options -> app/converter/config.py) # --------------------------------------------------------------------------- +def _settings_changed(fields: list, original: dict) -> bool: + """True when any field's current value differs from its ORIGINAL. + + Text values compare whitespace-stripped (the form's editor and + _apply_settings trim them anyway), so retyping a setting with stray + spaces does not count as a change. + """ + for field in fields: + value = field["value"] + base = original[field["key"]] + if isinstance(value, str) and isinstance(base, str): + changed = value.strip() != base.strip() + else: + changed = value != base + if changed: + return True + return False + + def _settings_fields() -> list: """The global output-settings field list (Save writes to config.py).""" return [ diff --git a/app/ui/tui.py b/app/ui/tui.py index f1c5419..319c123 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -920,7 +920,11 @@ def form(scr, title: str, fields: Sequence[dict], An optional ``note`` string on a field renders as a dim, non-selectable line in a blank-line frame above that field's row — a - section divider with a short explanation. Up/Down (or k/j) move the + section divider with a short explanation. An optional ``help`` list + of strings is shown as dim lines inside the field's edit dialog + (line editor / choice menu / directory browser title screens), + letting a field explain itself at edit time; like ``label`` it may + be a callable of the field list. Up/Down (or k/j) move the cursor; Enter edits or toggles the highlighted field. Tab, Left/Right, j or k at the ends of the list move focus to the BUTTONS — Down from the last field and Up from the first field both land on the first @@ -952,6 +956,13 @@ def form(scr, title: str, fields: Sequence[dict], label = field["label"] return str(label(fields)) if callable(label) else str(label) + def field_help(field: dict) -> Optional[List[str]]: + """Resolve FIELD's optional help lines (static or computed).""" + help_lines = field.get("help") + if callable(help_lines): + help_lines = help_lines(fields) + return list(help_lines) if help_lines else None + def shown_fields() -> List[dict]: result: List[dict] = [] for field in fields: @@ -1126,6 +1137,7 @@ def form(scr, title: str, fields: Sequence[dict], edited = line_edit(scr, field_label(field), field["value"], validate=field.get("validate"), + help_lines=field_help(field), back_value=edit_cancel) if edited is not edit_cancel: field["value"] = edited |
