aboutsummaryrefslogtreecommitdiff
path: root/app/converter
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-31 14:08:36 -0400
committerhistoria <historiavg@proton.me>2026-08-31 14:08:36 -0400
commit3b109185d642319c2c1870815b25ae1c0ad49445 (patch)
tree475bdac0f98de68d99b1c8ce251c74a092d507cb /app/converter
parenta0e3050c6e1e43df3941077afa4ade9a1c4d6ce4 (diff)
downloadtts-audiobook-generator-3b109185d642319c2c1870815b25ae1c0ad49445.tar.gz
feat: model capabilities lined up in tui menu
Diffstat (limited to 'app/converter')
-rw-r--r--app/converter/clients/__init__.py2
-rw-r--r--app/converter/clients/audiocpp.py81
2 files changed, 63 insertions, 20 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: