diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/common.py | 22 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 6 | ||||
| -rw-r--r-- | app/tests/test_backends_common.py | 41 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 168 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 38 | ||||
| -rw-r--r-- | app/ui/hub.py | 90 | ||||
| -rw-r--r-- | app/ui/tui.py | 24 |
7 files changed, 344 insertions, 45 deletions
diff --git a/app/backends/common.py b/app/backends/common.py index 25d4f31..b9448e0 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -230,6 +230,28 @@ def normalize_remote_url(value: str) -> str: (parts.scheme or "http", parts.netloc, parts.path, "", "")) +def parse_request_options(text: str) -> Dict[str, str]: + """Parse a user-supplied ``KEY=VALUE`` option string into a dict. + + Items are separated by commas or whitespace; each must contain an + ``=`` with a non-empty key. Values are kept verbatim (only the key is + stripped), so e.g. ``speed=1.1`` yields ``{"speed": "1.1"}`` — the + audio.cpp server coerces per-model option values itself. A blank + string yields {}. Raises ValueError with a user-facing message when + an item lacks ``=`` or has an empty key; later duplicates of a key + override earlier ones. + """ + options: Dict[str, str] = {} + for item in text.replace(",", " ").split(): + key, sep, value = item.partition("=") + if not sep or not key.strip(): + raise ValueError( + f"Request options expect KEY=VALUE items " + f"(e.g. emotion=neutral); got {item!r}") + options[key.strip()] = value + return options + + def server_running(url: str, timeout: float = 0.3) -> bool: """True when something accepts TCP connections at URL's host:port. diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index c0c2360..f3b3c54 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -92,8 +92,14 @@ python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-Base-GGUF --v # Qwen-TTS voice design python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF \ --instructions "A warm adult female narrator with a British accent" + +# Per-model options passed through to the family (--option KEY=VALUE, repeatable) +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. + 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). Before converting, `audiobook.py` asks the server to unload all currently loaded models (`POST /v1/tasks/unload_all_models`) so models left resident by earlier runs free their memory (e.g. VRAM on GPU backends) and only the selected entry loads. A server without that endpoint, or one busy unloading, only produces a warning. This behavior is controlled by the **Settings** → "Unload models" option (or `AUDIOCPP_UNLOAD_MODELS` in `app/converter/config.py`), which defaults to **Yes**; set it to **No** to keep other models resident across runs. diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py index 3b4e7f9..5bc967a 100644 --- a/app/tests/test_backends_common.py +++ b/app/tests/test_backends_common.py @@ -73,5 +73,46 @@ class GitCloneTests(unittest.TestCase): self.assertEqual(run.call_args[1]["emit"], emit) +class ParseRequestOptionsTests(unittest.TestCase): + """parse_request_options: the shared --option / TUI-field parser.""" + + def test_single_item(self): + self.assertEqual(common.parse_request_options("speed=1.1"), + {"speed": "1.1"}) + + def test_comma_and_whitespace_separators_mix(self): + self.assertEqual( + common.parse_request_options("emotion=neutral, speed=1.1"), + {"emotion": "neutral", "speed": "1.1"}) + self.assertEqual( + common.parse_request_options("a=1 b=2\tc=3"), + {"a": "1", "b": "2", "c": "3"}) + + def test_keys_are_stripped_and_blank_text_is_empty(self): + self.assertEqual(common.parse_request_options(" "), {}) + self.assertEqual(common.parse_request_options(""), {}) + # Tokens cannot contain whitespace (items split on it), so a lone + # "=" with a blank key is the malformed case, caught below. + self.assertEqual(common.parse_request_options("speed=1"), + {"speed": "1"}) + + def test_value_is_kept_verbatim(self): + self.assertEqual( + common.parse_request_options("url=http://x:8080/path?a=1"), + {"url": "http://x:8080/path?a=1"}) + + def test_later_duplicates_override_earlier_ones(self): + self.assertEqual(common.parse_request_options("a=1,a=2"), + {"a": "2"}) + + def test_item_without_equals_is_rejected(self): + with self.assertRaises(ValueError): + common.parse_request_options("emotion=neutral nonsense") + + def test_item_with_a_blank_key_is_rejected(self): + with self.assertRaises(ValueError): + common.parse_request_options("=value") + + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 73ef075..7e77642 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -652,12 +652,14 @@ class ConvertFlowTests(unittest.TestCase): # 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")) + _COMMON_KEYS = frozenset(("backend", "output_format", "language", + "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", + values = {"output_format": "m4b", "language": "English", + "speed": "1.0", "single_file": False, "debug": False, "stop_and_exit": True} values.update(overrides) @@ -762,7 +764,9 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual([f["key"] for f in fields], ["backend", "audiocpp-remote.model_id", "audiocpp-remote.audiocpp_voice", - "audiocpp-remote.instructions", "output_format", + "audiocpp-remote.instructions", + "audiocpp-remote.request_options", + "output_format", "language", "speed", "single_file", "debug", "stop_and_exit"]) self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel")) self.assertTrue(form_kwargs["start_on_buttons"]) @@ -852,9 +856,10 @@ class ConvertFlowTests(unittest.TestCase): voice_field = self._field("audiocpp_voice") self.assertEqual(voice_field["choices"](fields), [("narrator", "narrator")]) - # Base (clone) ignores instructions, so the field is hidden. + # Instructions are optional on clone entries too (a style/delivery + # instruction, or the voice itself on families that read one). instr = self._field("instructions") - self.assertFalse(instr["visible"](fields)) + self.assertTrue(instr["visible"](fields)) def test_audiocpp_remote_without_voices_refuses_generate_with_hint(self): # A clone-capable entry (e.g. Qwen Base) whose server lists no @@ -943,9 +948,10 @@ class ConvertFlowTests(unittest.TestCase): self.assertIsNotNone(instr["validate"]("")) self.assertIsNone(instr["validate"]("describe me")) - def test_audiocpp_clone_drops_stale_instructions(self): - # A Base/clone entry ignores instructions: even if the form held a - # leftover value, the mapper must not send it to the model. + def test_audiocpp_clone_forwards_instructions(self): + # Instructions are optional on clone entries: the mapper forwards a + # submitted instruction (style/delivery control, or the voice itself + # on families that condition synthesis on instructions alone). self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) @@ -955,7 +961,7 @@ class ConvertFlowTests(unittest.TestCase): instructions="stale description") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) - self.assertIsNone(cmd[2]["instructions"]) + self.assertEqual(cmd[2]["instructions"], "stale description") def test_audiocpp_required_voice_validates(self): # A non-qwen3_tts family needs a --voice; a blank value refuses. @@ -971,6 +977,121 @@ class ConvertFlowTests(unittest.TestCase): self.assertIsNotNone(voice_field["validate"]("")) self.assertIsNone(voice_field["validate"]("narrator")) + def test_audiocpp_builtin_speaker_entry_labels_the_field_built_in(self): + # On a CustomVoice entry the Voice field is labelled "Built-in + # voice" — the pick is one of the model's speakers, not a clone ref. + self._patch_remote( + [{"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts", + "task": "tts"}]) + with patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-CustomVoice-GGUF", + audiocpp_voice="Vivian", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + label = self._field("audiocpp_voice")["label"] + self.assertEqual(label(fields), "Built-in voice") + + def test_audiocpp_clone_entry_labels_the_field_voice_to_clone(self): + # Any non-speaker entry clones a server-side preset: "Voice to clone". + self._patch_remote( + [{"id": "Qwen3-TTS-Base-GGUF", "family": "qwen3_tts", + "task": "tts"}], + voices=["narrator"]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", + model_id="Qwen3-TTS-Base-GGUF", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + label = self._field("audiocpp_voice")["label"] + self.assertEqual(label(fields), "Voice to clone") + + def test_audiocpp_clone_with_instructions_accepts_an_empty_voice(self): + # An Instructions text substitutes for the voice: blank Voice passes + # validation when instructions are present (instruction-voice mode), + # and is still refused without one. + self._patch_remote( + [{"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="higgs", + audiocpp_voice="", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + voice = self._field("audiocpp_voice") + instr = self._field("instructions") + instr["value"] = "an elderly narrator" + self.assertIsNone(voice["validate"]("")) + instr["value"] = "" + self.assertIsNotNone(voice["validate"]("")) + + def test_audiocpp_no_voices_with_instructions_still_converts(self): + # A clone-capable entry whose server lists no voices is refused by + # default — but an instruction provides the voice instead. + self._patch_remote( + [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], + voices=[]) + with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + voice = self._field("audiocpp_voice") + instr = self._field("instructions") + instr["value"] = "" + # No voices and no instruction: the usual refusal hint. + self.assertIsNotNone(voice["validate"]("")) + # An instruction provides the voice instead. + instr["value"] = "designed narrator" + self.assertIsNone(voice["validate"]("")) + + def test_audiocpp_request_options_map_to_kwargs(self): + self._patch_remote( + [{"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="higgs", + audiocpp_voice="narrator", instructions="", + request_options="emotion=neutral, speed=1.1") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertEqual(cmd[2]["request_options"], + {"emotion": "neutral", "speed": "1.1"}) + + def test_audiocpp_request_options_validate_and_recover_from_garbage(self): + self._patch_remote( + [{"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="higgs", + audiocpp_voice="narrator", instructions="", + request_options="oops") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + options_field = self._field("request_options") + self.assertIsNone(options_field["validate"]("emotion=neutral")) + self.assertIsNotNone(options_field["validate"]("oops")) + # The scripted form bypasses validation, so a garbage submit falls + # back to no options instead of crashing the mapper. + self.assertEqual(cmd[2]["request_options"], {}) + + def test_language_passes_through_normalized(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"): + self._answer_form(backend="qwen", mode="custom", + speaker="Vivian", clone="", language="en") + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) + self.assertEqual(cmd[2]["language"], "English") + def test_audiocpp_remote_unreachable_models_flash_and_abort(self): self._patch_remote(None) # endpoint did not answer valid JSON cmd = self._convert( @@ -1172,7 +1293,8 @@ class ConvertFlowTests(unittest.TestCase): fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], ["backend", "mode", "speaker", "clone", - "output_format", "speed", "single_file", "debug", + "output_format", "language", "speed", + "single_file", "debug", "stop_and_exit"]) mode_field = self._field("mode") self.assertEqual(mode_field["choices"], @@ -1214,6 +1336,12 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[1], "faster") self.assertEqual(cmd[2]["voice"], "obama") self.assertEqual(self._field("faster_voice")["kind"], "text") + # faster voices are server-side clone references. + self.assertEqual(self._field("faster_voice")["label"], + "Voice to clone") + # Language is server-owned on faster: the per-run field is hidden. + fields = self.tui.forms_seen[0][1] + self.assertFalse(self._field("language")["visible"](fields)) def test_faster_local_still_lists_voices_json(self): with tempfile.TemporaryDirectory() as td: @@ -1321,32 +1449,36 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual( [f["key"] for f in fields], ["backend", "model_id", "audiocpp_voice", "instructions", - "mode", "speaker", "clone", "output_format", "speed", + "request_options", "mode", "speaker", "clone", + "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 is hidden too: the - # default higgs entry is clone-only, which ignores instructions.) - for key in ("model_id", "audiocpp_voice"): + # 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"): self.assertTrue(self._field(key)["visible"](fields)) - self.assertFalse(self._field("instructions")["visible"](fields)) + # Language shows for every backend except faster entries. + self.assertTrue(self._field("language")["visible"](fields)) for key in ("mode", "speaker", "clone"): self.assertFalse(self._field(key)["visible"](fields)) # Picking qwen in the Backend field swaps which options show. fields[0]["value"] = "qwen" self.assertTrue(self._field("mode")["visible"](fields)) self.assertTrue(self._field("speaker")["visible"](fields)) + self.assertTrue(self._field("language")["visible"](fields)) self.assertFalse(self._field("clone")["visible"](fields)) # qwen's clone mode hides the speaker and shows the .wav path. self._field("mode")["value"] = "clone" self.assertFalse(self._field("speaker")["visible"](fields)) self.assertTrue(self._field("clone")["visible"](fields)) - for key in ("model_id", "audiocpp_voice", "instructions"): + for key in ("model_id", "audiocpp_voice", "instructions", + "request_options"): self.assertFalse(self._field(key)["visible"](fields)) # And back to audio.cpp. fields[0]["value"] = "audiocpp" for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) - self.assertFalse(self._field("instructions")["visible"](fields)) for key in ("mode", "speaker", "clone"): self.assertFalse(self._field(key)["visible"](fields)) diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 4ff70fb..cd4e535 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -624,6 +624,44 @@ class FormTests(TuiTestCase): with self.assertRaises(ValueError): tui.form(self.screen, "Settings", []) + def test_dynamic_label_renders_with_column_alignment(self): + # A callable label is resolved against the field list on every + # redraw; static labels pad to the widest resolved label so all + # value columns line up. + fields = [ + {"key": "fmt", "kind": "choice", "value": "mp3", + "label": lambda fs: "Dynamic-" + fs[0]["value"], + "choices": ["mp3", "ogg"]}, + {"key": "chunk", "label": "Chunk", "kind": "text", + "value": "250"}, + ] + screen = FakeScreen(keys=[9, 10]) + result = tui.form(screen, "Settings", fields) + self.assertEqual(result, {"fmt": "mp3", "chunk": "250"}) + rows = [(y, x, text) for y, x, text, _ in screen.strings] + label_row = next(r for r in rows if r[2].startswith("Dynamic-")) + self.assertEqual(label_row[2], "Dynamic-mp3:") + chunk_row = next(r for r in rows if r[2].startswith("Chunk:")) + self.assertEqual(chunk_row[1], label_row[1]) + self.assert_inside_border(screen) + + def test_sub_dialog_title_uses_resolved_label(self): + # The choice menu (and text/directory editors) are titled with the + # resolved label, not the raw callable. + fields = [ + {"key": "model", "label": "Model", "kind": "text", + "value": "base"}, + {"key": "voice", "kind": "text", "value": "", + "label": lambda fs: "Voice of " + fs[0]["value"]}, + ] + # Down to the second field, Enter opens its editor (titled with the + # resolved label), Esc backs out; Tab -> Save, Enter. + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10, 27, 9, 10]) + tui.form(screen, "Settings", fields) + titles = [text for _, _, text, _ in screen.strings + if "Voice of base" in text] + self.assertTrue(titles) + 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 80fe843..d9d291b 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -879,17 +879,23 @@ def _field_value(fields, key: str, default=None): def _common_fields() -> list: - """Field dicts for output format, speed, single-file, and debug. + """Field dicts for output format, language, speed, single-file, debug. The single-file field is hidden for m4b (always a single file with embedded chapter markers), so its "visible" callable reads the live - output-format value from the field list. + output-format value from the field list. The per-run Language field + mirrors the CLI's --language and is hidden for faster entries (the + faster server owns the language). """ fmt_default = config.AUDIO_FORMAT \ if config.AUDIO_FORMAT in AUDIO_FORMATS else AUDIO_FORMATS[0] return [ {"key": "output_format", "label": "Output format", "kind": "choice", "value": fmt_default, "choices": list(AUDIO_FORMATS)}, + {"key": "language", "label": "Language", "kind": "text", + "value": config.LANGUAGE, "validate": _validate_language, + "visible": lambda fs: not str(_field_value(fs, "backend") or "") + .startswith("faster")}, {"key": "speed", "label": "Speed", "kind": "text", "value": "1.0", "validate": lambda s: None if (_is_float(s) and float(s) > 0) else "Enter a positive number, e.g. 1.0"}, @@ -905,7 +911,15 @@ def _common_fields() -> list: def _common_kwargs(values: dict) -> dict: """Map the common form fields to converter keyword arguments.""" output_format = values["output_format"] + # The Language field is hidden for faster entries and may then hold + # stale, unvalidated text; a failed normalization falls back to None + # so convert() applies config.LANGUAGE instead of failing the run. + try: + language = normalize_language(values.get("language")) + except ValueError: + language = None return { + "language": language, "output_format": output_format, "speed": float(values["speed"]), "single_file": bool(values["single_file"]) @@ -1063,12 +1077,20 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "directory on that machine.") def voice_validate(value): - """Refuse Generate! when this entry's clone voice is unavailable.""" + """Refuse Generate! when this entry's clone voice is unavailable. + + An Instructions text substitutes for the voice: on families that + condition synthesis on instructions alone the client designs the + voice from it (instruction-voice mode), so an empty Voice is + accepted when an instruction is present. + """ if model_capability(fields) != AUDIOCPP_VOICE_CLONE: return None + has_instruction = bool(str(_field_value( + fields, prefix + "instructions") or "").strip()) if not voices_for(_field_value(fields, prefix + "model_id")): - return no_voices_hint() - return None if value \ + return None if has_instruction else no_voices_hint() + return None if (value or has_instruction) \ else "This model needs a voice — pick one or switch models" model_ids = [m.get("id") for m in models] @@ -1102,38 +1124,53 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, "value": default_model, "choices": [(_label(m), m.get("id")) for m in models], "on_change": reset_voice}, - {"key": prefix + "audiocpp_voice", "label": "Voice", "kind": "choice", + # The label tracks the entry's capability: a built-in speaker on + # CustomVoice, otherwise the name of a server-side voice to clone. + {"key": prefix + "audiocpp_voice", + "label": lambda fs: ("Built-in voice" + if model_capability(fs) == AUDIOCPP_VOICE_SPEAKER + else "Voice to clone"), + "kind": "choice", "value": initial_voice, "choices": lambda fs: voice_choices(fs), "visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN, "on_empty_choices": no_voices_hint, "validate": voice_validate}, + # Style/voice-design instruction. Required for design entries (the + # voice comes from it); on every other entry an optional style/ + # delivery instruction — or, on families without built-in speakers + # that read instructions, the voice itself (instruction-voice mode). {"key": prefix + "instructions", "label": "Instructions", "kind": "text", "value": config.AUDIOCPP_INSTRUCTIONS, - "visible": lambda fs: model_capability(fs) in (AUDIOCPP_VOICE_DESIGN, - AUDIOCPP_VOICE_SPEAKER), "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". + {"key": prefix + "request_options", "label": "Request options", + "kind": "text", "value": "", + "validate": _validate_request_options}, ] def mapper(result) -> Optional[tuple]: 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[prefix + "audiocpp_voice"] or None - # design: the voice comes from --instructions - instructions = None - if capability in (AUDIOCPP_VOICE_DESIGN, AUDIOCPP_VOICE_SPEAKER): - instructions = ((result[prefix + "instructions"] or "") - .strip() or None) + # The instruction is forwarded for every capability: required on + # design entries, optional style/delivery control elsewhere. With + # no voice it defines the voice on instruction-conditioned families. + instructions = ((result.get(prefix + "instructions") + or "").strip() or None) + try: + request_options = common.parse_request_options( + result.get(prefix + "request_options") or "") + except ValueError: + request_options = {} # submit-time validation already caught this kwargs = { "model_id": model_id, "voice": voice, "instructions": instructions, + "request_options": request_options, **_common_kwargs(result), } if api_url is not None: @@ -1214,8 +1251,8 @@ def _faster_fields(stdscr, api_url: Optional[str] = None, """faster-specific fields and a result mapper for the Convert form. Returns ``(fields, mapper)`` where FIELDS are the faster options - (Voice, as a picker when a local voices.json lists them, else typed - free text) and MAPPER turns a submitted form values dict into the + (Voice to clone, as a picker when a local voices.json lists them, + else typed 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. PREFIX namespaces the field @@ -1244,7 +1281,8 @@ def _faster_fields(stdscr, api_url: Optional[str] = None, if voices is None: # No local voices.json: prompt for a server-side voice name. fields = [ - {"key": prefix + "faster_voice", "label": "Voice", "kind": "text", + {"key": prefix + "faster_voice", "label": "Voice to clone", + "kind": "text", "value": config.FASTER_VOICE, "validate": lambda s: None if s.strip() else "Enter a voice name"}, ] @@ -1252,7 +1290,8 @@ def _faster_fields(stdscr, api_url: Optional[str] = None, default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ next(iter(voices)) fields = [ - {"key": prefix + "faster_voice", "label": "Voice", "kind": "choice", + {"key": prefix + "faster_voice", "label": "Voice to clone", + "kind": "choice", "value": default, "choices": [(k, k) for k in voices]}, ] @@ -1376,6 +1415,15 @@ def _validate_remote_url(value: str) -> Optional[str]: return str(exc) +def _validate_request_options(value: str) -> Optional[str]: + """Error message for malformed KEY=VALUE request options, or None.""" + try: + common.parse_request_options(value) + return None + except ValueError as exc: + return str(exc) + + def _port_from_url(url: str, default: int) -> int: """Return the port in URL, or DEFAULT when it has none/unparsable.""" try: diff --git a/app/ui/tui.py b/app/ui/tui.py index 56aa79e..f1c5419 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -891,7 +891,11 @@ def form(scr, title: str, fields: Sequence[dict], "kind": "dir", "value": Path("./voices")} Fields render as a two-column table: each label is padded to the - widest label so every value starts in the same column. KINDS: + widest label so every value starts in the same column. A field's + ``label`` may be a callable of the field list (like ``visible`` and + ``choices``); it is re-resolved on every redraw, so a label can track + other fields' values, and sub-dialogs (choice menu, line editor, + directory browser) are titled with the resolved label. KINDS: ``choice`` opens a single choice menu (its ``choices`` may be a callable of the field list, resolved when the menu opens); ``text`` opens a line editor (reusing its VALIDATE); ``bool`` shows Yes/No and @@ -942,7 +946,11 @@ def form(scr, title: str, fields: Sequence[dict], on_buttons = start_on_buttons btn_index = 0 edit_cancel = object() # sentinel: backed out of a field editor - label_w = max(len(field["label"]) for field in fields) + + def field_label(field: dict) -> str: + """Resolve FIELD's label (a string, or a callable of FIELDS).""" + label = field["label"] + return str(label(fields)) if callable(label) else str(label) def shown_fields() -> List[dict]: result: List[dict] = [] @@ -992,13 +1000,17 @@ def form(scr, title: str, fields: Sequence[dict], if help_lines: frame.mark("") field_rows: List[int] = [] # visible field index -> row index + # Labels can be callables, so the pad width is recomputed from the + # visible fields on every redraw (a dynamic label's length may vary). + label_w = max((len(field_label(field)) for field in shown), + default=0) for field in shown: if field.get("note"): frame.mark("") frame.mark(field["note"], frame.theme["dim"], align="left") frame.mark("") field_rows.append(len(frame.rows)) - name = f"{field['label']}:".ljust(label_w + 1) + name = f"{field_label(field)}:".ljust(label_w + 1) frame.mark_segments( [(name, frame.theme["body"]), (" " + display_value(field), frame.theme["input"])], @@ -1089,7 +1101,7 @@ def form(scr, title: str, fields: Sequence[dict], values = [value for _, value in pairs] default = values.index(field["value"]) \ if field["value"] in values else 0 - chosen = menu(scr, field["label"], pairs, + chosen = menu(scr, field_label(field), pairs, default_index=default, back_value=edit_cancel) if chosen is not edit_cancel: @@ -1099,7 +1111,7 @@ def form(scr, title: str, fields: Sequence[dict], start = field["value"] start = Path(start) if start else Path.cwd() picked = browse_directory( - scr, field["label"], start=start, + scr, field_label(field), start=start, validate=field.get("validate"), back_value=edit_cancel) if picked is not edit_cancel: @@ -1111,7 +1123,7 @@ def form(scr, title: str, fields: Sequence[dict], on_buttons = True btn_index = 0 else: - edited = line_edit(scr, field["label"], + edited = line_edit(scr, field_label(field), field["value"], validate=field.get("validate"), back_value=edit_cancel) |
