aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 20:43:05 -0400
committerhistoria <historiavg@proton.me>2026-08-26 20:43:05 -0400
commit65c6f737f1545ef225768af897acd20f163a4fb4 (patch)
tree9c0974944191e50b9caa90591dfcb7f97c659770
parent975bd960fd07e75799b8e3adc4c0033046b34792 (diff)
downloadtts-audiobook-generator-65c6f737f1545ef225768af897acd20f163a4fb4.tar.gz
fix: settings menu only prompts to save after change
-rw-r--r--README.md2
-rw-r--r--app/backends/audiocpp/__init__.py3
-rw-r--r--app/backends/audiocpp/catalog.py46
-rw-r--r--app/converter/config.py2
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/tests/test_backends_audiocpp.py73
-rw-r--r--app/tests/test_hub.py171
-rw-r--r--app/tests/test_tui.py26
-rw-r--r--app/ui/hub.py69
-rw-r--r--app/ui/tui.py14
10 files changed, 397 insertions, 11 deletions
diff --git a/README.md b/README.md
index 72a8842..31495a3 100644
--- a/README.md
+++ b/README.md
@@ -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