aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--app/backends/common.py22
-rw-r--r--app/docs/backend-audiocpp.md6
-rw-r--r--app/tests/test_backends_common.py41
-rw-r--r--app/tests/test_hub.py168
-rw-r--r--app/tests/test_tui.py38
-rw-r--r--app/ui/hub.py90
-rw-r--r--app/ui/tui.py24
-rwxr-xr-xaudiobook.py13
9 files changed, 354 insertions, 50 deletions
diff --git a/README.md b/README.md
index 8e95659..72a8842 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,8 @@ 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).
+
## Manual TTS Backend Setup
If the TUI auto-install doesn't work, you may need to set up the backends manually.
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)
diff --git a/audiobook.py b/audiobook.py
index 5569070..f95e1d3 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -371,11 +371,14 @@ Examples:
if args.backend != BACKEND_AUDIOCPP:
parser.error("--option requires --backend audiocpp; the options "
"are passed through to the audio.cpp model")
- for item in args.option:
- key, sep, value = item.partition("=")
- if not sep or not key.strip():
- parser.error(f"--option expects KEY=VALUE (got {item!r})")
- request_options[key.strip()] = value
+ from backends.common import parse_request_options
+ try:
+ # Joined with spaces so both "--option a=1 --option b=2" and a
+ # single quoted "a=1, b=2" parse identically (the shared parser
+ # splits on commas or whitespace).
+ request_options = parse_request_options(" ".join(args.option))
+ except ValueError as exc:
+ parser.error(str(exc))
api_url = None
if args.api_url is not None: