diff options
| -rw-r--r-- | app/converter/clients/__init__.py | 2 | ||||
| -rw-r--r-- | app/converter/clients/audiocpp.py | 81 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 2 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 111 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 8 | ||||
| -rw-r--r-- | app/ui/hub.py | 67 |
6 files changed, 207 insertions, 64 deletions
diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py index fd3df64..68b988d 100644 --- a/app/converter/clients/__init__.py +++ b/app/converter/clients/__init__.py @@ -44,6 +44,7 @@ from .audiocpp import ( AUDIOCPP_VOICE_SPEAKER, AudioCppFamilyProfile, AudioCppTTSClient, + audiocpp_entry_supports_design, audiocpp_entry_voice_capability, audiocpp_family_spec_tasks, audiocpp_family_voice_policy, @@ -77,6 +78,7 @@ __all__ = [ "AUDIOCPP_VOICE_OPTIONAL", "AUDIOCPP_VOICE_NONE", "AudioCppFamilyProfile", "AUDIOCPP_DEFAULT_FAMILY_PROFILE", "AUDIOCPP_FAMILY_PROFILES", "audiocpp_entry_voice_capability", + "audiocpp_entry_supports_design", "audiocpp_family_spec_tasks", "audiocpp_family_voice_policy", "audiocpp_request_error", ] diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py index c441047..a1888bc 100644 --- a/app/converter/clients/audiocpp.py +++ b/app/converter/clients/audiocpp.py @@ -102,24 +102,23 @@ AUDIOCPP_VOICE_REQUIRED = "required" # clone-only: a reference voice is mandato AUDIOCPP_VOICE_OPTIONAL = "optional" # tts + clone: blank voice means plain TTS AUDIOCPP_VOICE_NONE = "none" # pure TTS: no cloning, no voice at all -# Spec-task cache for audiocpp_family_voice_policy (family -> tasks or -# None for unknown). The form consults the policy on every menu render, -# so each family's spec is read at most once per process. -_FAMILY_SPEC_TASKS: Dict[str, Optional[Set[str]]] = {} +# Spec cache (family -> parsed spec dict, or None for unknown). The form +# consults the policy and capability tags on every menu render, so each +# family's spec is read at most once per process. +_FAMILY_SPECS: Dict[str, Optional[dict]] = {} -def audiocpp_family_spec_tasks(family: str) -> Optional[Set[str]]: - """FAMILY's task set from the local audio.cpp checkout's model_specs. +def _family_spec(family: str) -> Optional[dict]: + """FAMILY's parsed model spec from the local audio.cpp checkout. Reads ``<checkout>/model_specs/<family>.json`` (the checkout the setup - wizard manages, which also ships the specs for remote servers) and - returns its "tasks" list as a set, or None when the checkout is - missing, the family is not described, or the spec is unparsable. - Results are cached per process. + wizard manages, which also ships the specs for remote servers), or + None when the checkout is missing, the family is not described, or the + spec is unparsable. Results are cached per process. """ - if family in _FAMILY_SPEC_TASKS: - return _FAMILY_SPEC_TASKS[family] - tasks: Optional[Set[str]] = None + if family in _FAMILY_SPECS: + return _FAMILY_SPECS[family] + spec: Optional[dict] = None try: # Imported lazily: backends.audiocpp imports this package (its # voices module), so a module-level import would cycle. @@ -129,14 +128,56 @@ def audiocpp_family_spec_tasks(family: str) -> Optional[Set[str]]: checkout = None if checkout is not None: try: - spec = json.loads((checkout / "model_specs" / f"{family}.json") - .read_text(encoding="utf-8")) + parsed = json.loads((checkout / "model_specs" / f"{family}.json") + .read_text(encoding="utf-8")) except (OSError, ValueError): - spec = None - if isinstance(spec, dict) and isinstance(spec.get("tasks"), list): - tasks = {str(task) for task in spec["tasks"]} - _FAMILY_SPEC_TASKS[family] = tasks - return tasks + parsed = None + if isinstance(parsed, dict): + spec = parsed + _FAMILY_SPECS[family] = spec + return spec + + +def audiocpp_family_spec_tasks(family: str) -> Optional[Set[str]]: + """FAMILY's task set from the local audio.cpp checkout's model_specs. + + Returns the spec's "tasks" list as a set, or None when the family is + not described (see _family_spec). + """ + spec = _family_spec(family) + if spec is None or not isinstance(spec.get("tasks"), list): + return None + return {str(task) for task in spec["tasks"]} + + +def audiocpp_entry_supports_design(family: str, task: str, + model_id: str) -> bool: + """Whether a server model entry can design a voice from a description. + + True for task-"vdes" entries (the model *is* a voice-design model) and + for entries of families whose spec advertises a design task — those + families design on the regular entry from the request's instructions + text (e.g. OmniVoice, VoxCPM2). Qwen3-TTS is the exception: its + design support lives only in a separate VoiceDesign model entry (also + task "vdes"), while its Base/CustomVoice entries cannot design. Model + IDs play no role today but stay in the signature for parity with + audiocpp_entry_voice_capability. Unknown families (no local specs) + conservatively report no design support. + """ + if task == AUDIOCPP_TASK_VDES: + return True + if family == AUDIOCPP_FAMILY_QWEN3_TTS: + return False + spec = _family_spec(family) + if spec is None: + return False + design_markers = {"design", AUDIOCPP_TASK_VDES} + tasks = audiocpp_family_spec_tasks(family) + if tasks and tasks & design_markers: + return True + capabilities = spec.get("capabilities") + return isinstance(capabilities, dict) \ + and bool(set(map(str, capabilities)) & design_markers) def audiocpp_family_voice_policy(family: str) -> str: diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index aab98b0..5f38e56 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -159,7 +159,7 @@ If accurate transcripts are not available, cloning without one is possible per run with `--option x_vector_only_mode=true` (speaker-embedding-only cloning — no transcript needed, noticeably lower speaker similarity). -In the hub's **Generate Audiobooks** form the Model picker shows each entry's voice capability: `speaker` (built-in Qwen3-TTS speakers), `tts` (pure-TTS families that need no voice at all), `tts/clone` (mixed families that work either way), `clone` (clone-only families) or `design`. The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** on clone-capable entries (listing the server's preset/voice_dir entries) — it is hidden entirely on pure-TTS families, and on mixed families it leads with a blank **(built-in)** pick that means plain TTS without a reference voice (the default). Clone-only families keep the voice required. 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 is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only. +In the hub's **Generate Audiobooks** form the Model picker reads as a table: each entry's id is padded to the widest one and its capabilities are rendered as fixed columns — `tts` (pure-TTS families that need no voice at all) or `speaker` (built-in Qwen3-TTS speakers) in the first column, `clone` (the entry clones a reference voice) in the second, `design` (the entry can design a voice from an Instructions description) in the third — so every capability word lines up down its own column. The `design` column is filled for `vdes` design-model entries and for families whose audio.cpp spec advertises design (e.g. OmniVoice, VoxCPM2); Qwen3-TTS designs only through its separate VoiceDesign entry, so its Base/CustomVoice rows stay without it. The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** on clone-capable entries (listing the server's preset/voice_dir entries) — it is hidden entirely on pure-TTS families, and on mixed tts+clone families it leads with a **<built-in> (no clone)** pick that means plain TTS with the model's own default voice (no reference cloned; the default). Clone-only families keep the voice required. 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 is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and 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_hub.py b/app/tests/test_hub.py index 6ce1943..a822f1a 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -827,11 +827,11 @@ class ConvertFlowTests(unittest.TestCase): # checkout is downloaded by setup): seed the client's spec cache # with the classifications these tests rely on, so they stay # hermetic. Unknown families keep the clone-only default. - spec_cache = audiocpp_client._FAMILY_SPEC_TASKS + spec_cache = audiocpp_client._FAMILY_SPECS spec_cache.clear() spec_cache.update({ - "higgs_audio_tts": {"tts", "clone"}, - "supertonic": {"tts"}, + "higgs_audio_tts": {"tasks": ["tts", "clone"]}, + "supertonic": {"tasks": ["tts"]}, }) self.addCleanup(spec_cache.clear) @@ -965,14 +965,15 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(fields[0]["choices"], [("audio.cpp [remote]", "audiocpp-remote")]) # The model menu was fed from the live query (label, id); ids are - # padded so the type column lines up across entries. A mixed - # tts+clone family reads as "(tts/clone)". + # padded so the capability columns line up across entries. A mixed + # tts+clone family reads as the "tts" and "clone" columns. self.assertEqual(self._field("model_id")["choices"], - [("higgs (tts/clone)", "higgs")]) + [("higgs tts clone", "higgs")]) def test_model_menu_lines_the_type_column_up(self): - # Ids are padded to the widest id: every (type) starts on the same - # column, so the picker reads as a two-column table. + # Ids are padded to the widest id, and every capability word sits + # in its own fixed column (tts | clone | design): the picker reads + # as a table where "tts", "clone" (and "design") line up. self._patch_remote( [{"id": "short", "family": "higgs_audio_tts", "task": "tts"}, {"id": "a-much-longer-model-id", "family": "qwen3_tts", @@ -983,17 +984,84 @@ class ConvertFlowTests(unittest.TestCase): None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) choices = self._field("model_id")["choices"] - # "a-much-longer-model-id" is 22 columns wide; both types open at - # column 24 ("(" right after the two-space gutter). + # "a-much-longer-model-id" is 22 columns wide; both capabilities + # start at the same offsets: "tts" at 24, "clone" at 29. The + # clone-only qwen3_tts entry leaves the tts column blank. self.assertEqual(choices[0], - ("short".ljust(22) + " (tts/clone)", "short")) + ("short".ljust(22) + " tts clone", "short")) self.assertEqual(choices[1], - ("a-much-longer-model-id (clone)", - "a-much-longer-model-id")) - self.assertEqual({label.index("(") for label, _ in choices}, {24}) + ("a-much-longer-model-id".ljust(22) + + " clone", "a-much-longer-model-id")) + self.assertEqual({label.index("clone") for label, _ in choices}, + {29}) + self.assertEqual({label.index("tts") for label, _ in choices + if "tts" in label}, {24}) # A plain qwen3_tts entry (no CustomVoice in the id) is clone-only. self.assertEqual(choices[1][1], "a-much-longer-model-id") - self.assertTrue(choices[1][0].endswith("(clone)")) + self.assertTrue(choices[1][0].endswith("clone")) + self.assertNotIn("design", choices[1][0]) + + def test_model_menu_shows_design_for_design_capable_families(self): + # A family whose spec advertises a design task designs on the + # regular entry from the Instructions text: the model menu grows + # a "design" column entry behind tts/clone. + spec_cache = audiocpp_client._FAMILY_SPECS + spec_cache["omnivoice"] = {"tasks": ["tts", "clone", "design"]} + self.addCleanup(spec_cache.pop, "omnivoice", None) + self._patch_remote( + [{"id": "omnivoice", "family": "omnivoice", "task": "tts"}], + voices=["narrator"]) + self._answer_form(backend="audiocpp-remote", model_id="omnivoice", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertEqual(self._field("model_id")["choices"], + [("omnivoice tts clone design", "omnivoice")]) + + def test_model_menu_shows_design_only_on_vdes_entries(self): + # A task-"vdes" entry is a design model: its row shows only the + # design column (no tts, no clone), aligned with other rows. + self._patch_remote( + [{"id": "moss_voicegen", "family": "moss_voicegen", + "task": "vdes"}]) + self._answer_form(backend="audiocpp-remote", model_id="moss_voicegen", + audiocpp_voice=None, instructions="a warm narrator") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + self.assertEqual( + self._field("model_id")["choices"], + [("moss_voicegen design", "moss_voicegen")]) + + def test_model_menu_keeps_design_off_qwen3_tts_nondesign_entries(self): + # Qwen3-TTS designs only through its separate VoiceDesign entry: + # the Base row stays "clone" while the vdes row shows "design", + # both words on the same columns. + self._patch_remote( + [{"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", "family": "qwen3_tts", + "task": "tts"}, + {"id": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF", + "family": "qwen3_tts", "task": "vdes"}]) + self._answer_form( + backend="audiocpp-remote", model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) + self.assertIsNotNone(cmd) + choices = self._field("model_id")["choices"] + base = "Qwen3-TTS-12Hz-1.7B-Base-GGUF" + design = "Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF" + labels = {value: label for label, value in choices} + self.assertEqual(labels, + {base: base.ljust(len(design)) + " clone", + design: design.ljust(len(design)) + + " design"}) + # "clone" and "design" each sit on one shared column. + self.assertEqual({label.index("clone") for label, _ in choices + if "clone" in label}, + {label.index("design") - 7 + for label, _ in choices if "design" in label}) def test_audiocpp_customvoice_entry_lists_builtin_speakers(self): # A CustomVoice entry populates the Voice menu with the Qwen3-TTS @@ -1270,7 +1338,7 @@ class ConvertFlowTests(unittest.TestCase): voice_field["value"] = "narrator" model_field["value"] = "higgs" model_field["on_change"](fields) - # Mixed family: the blank (built-in) pick is valid. + # Mixed family: the blank <built-in> (no clone) pick is valid. self.assertIsNone(voice_field["validate"]("")) def test_audiocpp_builtin_speaker_entry_labels_the_field_built_in(self): @@ -1346,7 +1414,7 @@ class ConvertFlowTests(unittest.TestCase): def test_audiocpp_pure_tts_entry_hides_the_voice_menu(self): # Pure-TTS families (spec tasks without "clone") synthesize with # no voice at all: the Voice menu is hidden entirely, the model - # menu reads "(tts)", and Generate! sends no voice. + # menu reads "tts", and Generate! sends no voice. self._patch_remote( [{"id": "supertonic", "family": "supertonic", "task": "tts"}]) self._answer_form(backend="audiocpp-remote", model_id="supertonic", @@ -1359,12 +1427,12 @@ class ConvertFlowTests(unittest.TestCase): voice_field = self._field("audiocpp_voice") self.assertFalse(voice_field["visible"](fields)) self.assertEqual(self._field("model_id")["choices"], - [("supertonic (tts)", "supertonic")]) + [("supertonic tts", "supertonic")]) def test_audiocpp_mixed_family_offers_a_built_in_blank_pick(self): # Mixed tts+clone families lead the Voice menu with a blank - # "(built-in)" pick meaning plain TTS (no reference voice), and - # the blank pick is the default. + # "<built-in> (no clone)" pick meaning plain TTS (the model's own + # default voice, no reference cloned), and it is the default. self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) @@ -1378,7 +1446,8 @@ class ConvertFlowTests(unittest.TestCase): voice_field = self._field("audiocpp_voice") self.assertTrue(voice_field["visible"](fields)) self.assertEqual(voice_field["choices"](fields), - [("", "(built-in)"), ("narrator", "narrator")]) + [("<built-in> (no clone)", ""), + ("narrator", "narrator")]) # A kept clone pick survives a mixed-family switch; blank is valid. voice_field["value"] = "narrator" self.assertIsNone(voice_field["validate"]("narrator")) diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 9067443..67c81f8 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -1135,12 +1135,12 @@ class AudioCppFamilyVoicePolicyTests(unittest.TestCase): def setUp(self): # Seed the spec cache instead of reading the (gitignored, setup- # downloaded) checkout's model_specs, so the tests are hermetic. - cache = audiocpp_client._FAMILY_SPEC_TASKS + cache = audiocpp_client._FAMILY_SPECS cache.clear() cache.update({ - "higgs_audio_tts": {"tts", "clone"}, - "supertonic": {"tts"}, - "confucius4_tts": {"clone"}, + "higgs_audio_tts": {"tasks": ["tts", "clone"]}, + "supertonic": {"tasks": ["tts"]}, + "confucius4_tts": {"tasks": ["clone"]}, }) self.addCleanup(cache.clear) diff --git a/app/ui/hub.py b/app/ui/hub.py index 29d2229..abb375a 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -65,6 +65,7 @@ from converter.clients import ( BACKEND_QWEN, LANGUAGE_CHOICES, QWEN3_TTS_SPEAKERS, + audiocpp_entry_supports_design, audiocpp_entry_voice_capability, audiocpp_family_voice_policy, normalize_language, @@ -1221,8 +1222,10 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, fields, prefix + "model_id"))] if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL: # Mixed tts+clone family: the blank pick means plain TTS - # (no reference voice), so it always leads the menu. - return [("", "(built-in)")] + voices + # (the model's own built-in voice, no reference cloned), so + # it always leads the menu. The pair is (label, value): the + # readable label describes the blank value. + return [("<built-in> (no clone)", "")] + voices return voices return [] # design or pure TTS: the field is hidden @@ -1282,25 +1285,53 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, initial = voices_for(default_model) initial_voice = initial[0] if initial else "" - # The Model picker reads as a two-column table: pad every id to the - # widest one so the (type) column starts on the same position. + # The Model picker reads as a table: pad every id to the widest one, + # then render each entry's capabilities as fixed columns (how plain + # synthesis is voiced | clone | design) so every capability word sits + # in its own column across rows — easy to scan at a glance. id_width = max(len(entry.get("id") or "") for entry in models) - def _label(entry: dict) -> str: + def _capabilities(entry: dict) -> tuple: + """The entry's capability words in fixed column order. + + Column 1 voices plain synthesis ("speaker" for built-in speakers, + "tts" for families that need no voice at all), column 2 is + "clone" when the entry clones a reference, column 3 "design" when + it can design a voice from an Instructions description. + """ family = entry.get("family") or "" - capability = audiocpp_entry_voice_capability( - family, entry.get("task") or "tts", entry.get("id") or "") - if capability == AUDIOCPP_VOICE_CLONE: - # The generic clone capability is refined by the family's - # voice policy: pure-TTS families need no voice at all, mixed - # families may run with or without one, clone-only families - # (and unknown families) always clone a reference. - capability = { - AUDIOCPP_VOICE_NONE: "tts", - AUDIOCPP_VOICE_OPTIONAL: "tts/clone", - AUDIOCPP_VOICE_REQUIRED: "clone", - }[audiocpp_family_voice_policy(family)] - return f"{entry.get('id') or '':<{id_width}} ({capability})" + task = entry.get("task") or "tts" + model_id = entry.get("id") or "" + capability = audiocpp_entry_voice_capability(family, task, model_id) + if capability == AUDIOCPP_VOICE_SPEAKER: + return ("speaker", "", "") + if capability == AUDIOCPP_VOICE_DESIGN: + return ("", "", "design") + # The generic clone capability is refined by the family's voice + # policy: pure-TTS families need no voice at all, mixed families + # may run with or without one, clone-only families (and unknown + # families) always clone a reference. + words = { + AUDIOCPP_VOICE_NONE: ("tts", "", ""), + AUDIOCPP_VOICE_OPTIONAL: ("tts", "clone", ""), + AUDIOCPP_VOICE_REQUIRED: ("", "clone", ""), + }[audiocpp_family_voice_policy(family)] + if audiocpp_entry_supports_design(family, task, model_id): + return words[:2] + ("design",) + return words + + _capability_words = [_capabilities(entry) for entry in models] + _column_widths = [max((len(words[index]) + for words in _capability_words), default=0) + for index in range(3)] + + def _label(entry: dict) -> str: + words = _capabilities(entry) + row = f"{entry.get('id') or '':<{id_width}}" + for word, width in zip(words, _column_widths): + if width: + row += f" {word:<{width}}" + return row.rstrip() def entry_supports_options(fs) -> bool: """True when the selected entry's family defines request options. |
