aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 13:28:42 -0400
committerhistoria <historiavg@proton.me>2026-08-25 13:28:42 -0400
commitd4dbc1a158d1dd6babcba7333a4ed7d719b65d3e (patch)
treeccba2efaef9a265f7cbc754cf70d8071d09e9612 /app
parent0cc01d1da0a629e104202053feb0bb0db91d578d (diff)
downloadtts-audiobook-generator-d4dbc1a158d1dd6babcba7333a4ed7d719b65d3e.tar.gz
feat: automatically name audio.cpp model ids with long name
Diffstat (limited to 'app')
-rwxr-xr-xapp/backends/audiocpp.py73
-rw-r--r--app/converter/config.py27
-rw-r--r--app/docs/backend-audiocpp.md16
-rw-r--r--app/tests/test_backends_audiocpp.py38
-rw-r--r--app/tests/test_tts.py17
-rw-r--r--app/ui/hub.py7
6 files changed, 76 insertions, 102 deletions
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py
index f95216e..0b82a4e 100755
--- a/app/backends/audiocpp.py
+++ b/app/backends/audiocpp.py
@@ -123,16 +123,6 @@ class _GoBack(Exception):
# Package names that mark a voice-design model (hosted with task "vdes").
DESIGN_PACKAGE_RE = re.compile(r"voice[\s_\-]?design", re.IGNORECASE)
-# Short, friendly default entry ids for selected families. Other families
-# derive an id from their family name (see default_model_id). All families
-# are listed equally, in alphabetical order.
-PREFERRED_IDS = {
- "qwen3_tts": "qwen",
- "higgs_audio_tts": "higgs",
- "voxcpm2": "voxcpm2",
- "index_tts2": "indextts2",
-}
-
class _TuiError(Exception):
"""A fatal error raised from inside the TUI wizard.
@@ -305,16 +295,6 @@ def update_config_model_ids(model_id: str,
return True
-def default_model_id(family: str) -> str:
- """Derive a default server entry id from a family name."""
- if family in PREFERRED_IDS:
- return PREFERRED_IDS[family]
- name = family
- if name.endswith("_tts"):
- name = name[:-4]
- return name.replace("_", "") or family
-
-
def detect_audiocpp_dir() -> Optional[Path]:
"""Best-effort location of a local audio.cpp checkout with model_specs.
@@ -411,9 +391,9 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]:
Each returned entry has: family, display_name, description, languages,
clone_capable, packages (the full list from the spec), install_id
- (recommended package id), default_path (``models/<target_directory>``),
- and preferred_id. All families are treated equally and listed in
- alphabetical order by display name.
+ (recommended package id), and default_path (``models/<target_directory>``).
+ All families are treated equally and listed in alphabetical order by
+ display name.
"""
specs_dir = audiocpp_dir / "model_specs"
if not specs_dir.is_dir():
@@ -449,7 +429,6 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]:
"packages": packages,
"install_id": package.get("id") or family,
"default_path": f"models/{target_directory}",
- "preferred_id": default_model_id(family),
})
# All families are treated equally: alphabetical by display name.
@@ -716,17 +695,18 @@ def _offer_config_model_id_sync(model_id: str, accepted: Optional[bool]) -> None
def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]],
catalog_by_family: Dict[str, dict],
task_picker: Callable[[str], str],
- id_picker: Callable[[str, str, str], str],
known_tasks: Optional[Dict[Tuple[str, str], str]] = None
) -> Tuple[List[dict], List[str], List[Tuple[str, str]],
List[str], bool]:
"""Build server.json model entries from the selected families/packages.
- TASK_PICKER is called for each design package to choose vdes/tts;
- ID_PICKER resolves a duplicate server entry id. KNOWN_TASKS maps
- ``(family, target_directory)`` to a previously-stored task ("tts" or
- "vdes") so a modify run preserves how a design package was hosted
- instead of re-asking. Returns (model_entries, entry_ids,
+ TASK_PICKER is called for each design package to choose vdes/tts.
+ KNOWN_TASKS maps ``(family, target_directory)`` to a previously-stored
+ task ("tts" or "vdes") so a modify run preserves how a design package
+ was hosted instead of re-asking. Each entry's server id is its package
+ ``target_directory`` (flattened to a token), so packages from the same
+ family never collide; an id that does collide (across families) is
+ auto-suffixed without prompting. Returns (model_entries, entry_ids,
install_guidance, design_entry_ids, include_clone).
"""
model_entries: List[dict] = []
@@ -745,12 +725,13 @@ def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]],
task = task_picker(opt["install_id"])
else:
task = TASK_TTS
- base_id = (f"{entry['preferred_id']}-design"
- if task == TASK_VDES else entry["preferred_id"])
+ base_id = opt["target_directory"].replace("/", "-")
model_id = base_id
if model_id in entry_ids:
- model_id = id_picker(entry["display_name"], opt["install_id"],
- f"{base_id}-2")
+ n = 2
+ while f"{base_id}-{n}" in entry_ids:
+ n += 1
+ model_id = f"{base_id}-{n}"
entry_ids.append(model_id)
model_entries.append(build_model_entry(
family, model_id, f"models/{opt['target_directory']}",
@@ -1014,9 +995,8 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
s["family_keys"] = family_keys
def _compute_entries() -> None:
- # Design task menus and duplicate-id renames. Esc on any of them
- # raises _GoBack, which the caller turns into Wizard.BACK (the
- # design/duplicate-id prompts are grouped: Esc returns to the
+ # Design task menu. Esc raises _GoBack, which the caller turns into
+ # Wizard.BACK (the design prompts are grouped: Esc returns to the
# families tree).
def task_picker(install_id: str) -> str:
result = tui.menu(
@@ -1031,20 +1011,10 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
raise _GoBack()
return result
- def id_picker(display_name: str, install_id: str,
- default: str) -> str:
- result = tui.line_edit(
- stdscr,
- f"Server model id for {display_name} package "
- f"'{install_id}'", default, back_value=_GO_BACK)
- if result is _GO_BACK:
- raise _GoBack()
- return result
-
model_entries, entry_ids, install_guidance, \
design_entry_ids, include_clone = _build_entries(
s["family_keys"], s["chosen"], s["catalog_by_family"],
- task_picker, id_picker, known_tasks=s["existing_tasks"])
+ task_picker, known_tasks=s["existing_tasks"])
s.update({
"model_entries": model_entries,
"entry_ids": entry_ids,
@@ -2373,16 +2343,13 @@ def _collect_from_flags(args: argparse.Namespace,
else:
chosen[family] = [opt for opt in opts if opt["recommended"]]
- # Non-interactive pickers: design packages default to vdes, dup ids get -2.
+ # Non-interactive picker: design packages default to vdes.
def task_picker(install_id: str) -> str:
return TASK_VDES
- def id_picker(display_name: str, install_id: str, default: str) -> str:
- return default
-
model_entries, entry_ids, install_guidance, design_entry_ids, include_clone = \
_build_entries(family_keys, chosen, catalog_by_family,
- task_picker, id_picker)
+ task_picker)
# Server settings.
host = args.host or DEFAULT_HOST
diff --git a/app/converter/config.py b/app/converter/config.py
index 6eacb41..3caa1ab 100644
--- a/app/converter/config.py
+++ b/app/converter/config.py
@@ -63,19 +63,20 @@ AUDIOCPP_API_URL = "http://127.0.0.1:8080" # audio.cpp audiocpp_server
AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080" # externally-run audiocpp_server ("" disables probing)
# Model ids in the audio.cpp server.json config. AUDIOCPP_MODEL_ID may point
-# at any TTS model entry the server hosts (qwen3_tts, higgs_audio_tts,
-# voxcpm2, index_tts2, ...); the family is detected from the server at
-# startup and adapts the request automatically. Only qwen3_tts has built-in
-# speakers (speaker mode); every other family needs --voice with a
-# server-side voice preset. For single-model servers, set
-# AUDIOCPP_CLONE_MODEL_ID to the same id as AUDIOCPP_MODEL_ID (or leave it
-# empty); for Qwen3-TTS it typically names a second entry with the Base
-# (cloning) model. A multi-model server (one server.json hosting several
-# lazily-loaded entries) does not need editing here: leave AUDIOCPP_MODEL_ID
-# unset to auto-select when only one entry is hosted, or pick the entry per
-# run with the --model CLI flag.
-AUDIOCPP_MODEL_ID = "qwen"
-AUDIOCPP_CLONE_MODEL_ID = "qwen"
+# at any TTS model entry the server hosts; the family is detected from the
+# server at startup and adapts the request automatically. Only qwen3_tts has
+# built-in speakers (speaker mode); every other family needs --voice with a
+# server-side voice preset. The server entry id is the model package's
+# target_directory name (e.g. "Qwen3-TTS-12Hz-1.7B-Base-GGUF"). For
+# single-model servers, set AUDIOCPP_CLONE_MODEL_ID to the same id as
+# AUDIOCPP_MODEL_ID (or leave it empty); for Qwen3-TTS it typically names a
+# second entry with the Base (cloning) model. Both default to empty so a
+# single-entry server is auto-selected; a multi-model server (one server.json
+# hosting several lazily-loaded entries) needs no editing here either: leave
+# AUDIOCPP_MODEL_ID unset to auto-select when only one entry is hosted, or
+# pick the entry per run with the --model CLI flag.
+AUDIOCPP_MODEL_ID = ""
+AUDIOCPP_CLONE_MODEL_ID = ""
# Voice design / style instruction sent with every audio.cpp request when
# the --instructions CLI flag is not given. Required for server entries
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index c9ef883..3323887 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -32,7 +32,7 @@ You can run `python tools/model_manager_v2.py list` to see all available models.
### Create server.json
-Create a `server.json` config file. One server can host multiple models and multiple cloned voices. The `id:` fields are the model names you will set for `tts-audiobook-generator` with `--model`.
+Create a `server.json` config file. One server can host multiple models and multiple cloned voices. The `id:` fields are the model names you will set for `tts-audiobook-generator` with `--model` (the setup wizard names each entry after its model package directory).
```json
{
@@ -43,21 +43,21 @@ Create a `server.json` config file. One server can host multiple models and mult
"voice_dir": "/path/to/clone/wavs",
"models": [
{
- "id": "higgs",
+ "id": "Higgs-Audio-v3-TTS-4B-GGUF",
"family": "higgs_audio_tts",
"path": "models/Higgs-Audio-v3-TTS-4B-GGUF",
"task": "tts",
"mode": "offline"
},
{
- "id": "qwen",
+ "id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
"family": "qwen3_tts",
"path": "models/Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF",
"task": "tts",
"mode": "offline"
},
{
- "id": "qwen-clone",
+ "id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
"family": "qwen3_tts",
"path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF",
"task": "tts",
@@ -79,16 +79,16 @@ In a different terminal, run `audiobook.py`. Pick the TTS `--model` and `--voice
```bash
# Higgs Audio (clone-only)
-python audiobook.py --backend audiocpp --model higgs --voice narrator
+python audiobook.py --backend audiocpp --model Higgs-Audio-v3-TTS-4B-GGUF --voice narrator
# Qwen3-TTS built-in speaker
-python audiobook.py --backend audiocpp --model qwen
+python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF
# Qwen3-TTS voice cloning
-python audiobook.py --backend audiocpp --model qwen-clone --voice narrator
+python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-Base-GGUF --voice narrator
# Qwen-TTS voice design
-python audiobook.py --backend audiocpp --model qwen-design \
+python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF \
--instructions "A warm adult female narrator with a British accent"
```
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 2f41131..be8fead 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -371,20 +371,6 @@ class NormalizeDirArgTests(unittest.TestCase):
self.assertEqual(result, Path("/tmp/foo").resolve())
-class DefaultModelIdTests(unittest.TestCase):
- def test_preferred_ids_for_tested_families(self):
- self.assertEqual(make_server.default_model_id("qwen3_tts"), "qwen")
- self.assertEqual(make_server.default_model_id("higgs_audio_tts"), "higgs")
- self.assertEqual(make_server.default_model_id("voxcpm2"), "voxcpm2")
- self.assertEqual(make_server.default_model_id("index_tts2"), "indextts2")
-
- def test_derived_id_strips_trailing_tts_and_underscores(self):
- self.assertEqual(make_server.default_model_id("pocket_tts"), "pocket")
- self.assertEqual(make_server.default_model_id("dots_tts"), "dots")
- self.assertEqual(make_server.default_model_id("moss_tts_local"),
- "mossttslocal")
-
-
class LoadModelCatalogTests(unittest.TestCase):
def setUp(self):
self._td = tempfile.TemporaryDirectory()
@@ -1067,7 +1053,8 @@ class NonInteractiveMainTests(unittest.TestCase):
self.assertEqual(data["port"], make_server.config_port())
self.assertEqual(data["backend"], "cuda")
self.assertTrue(data["lazy_load"])
- self.assertEqual([m["id"] for m in data["models"]], ["higgs"])
+ self.assertEqual([m["id"] for m in data["models"]],
+ ["Higgs-Audio-v3-TTS-4B-GGUF"])
self.assertNotIn("voice_dir", data)
def test_port_sync_accepted_updates_config(self):
@@ -1098,8 +1085,9 @@ class NonInteractiveMainTests(unittest.TestCase):
exit_code = self._run(self._args("--families", "higgs_audio_tts"))
self.assertEqual(exit_code, 0)
text = self.fake_config.read_text(encoding="utf-8")
- self.assertIn('AUDIOCPP_MODEL_ID = "higgs"', text)
- self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text)
+ self.assertIn('AUDIOCPP_MODEL_ID = "Higgs-Audio-v3-TTS-4B-GGUF"', text)
+ self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "Higgs-Audio-v3-TTS-4B-GGUF"',
+ text)
def test_multi_family_lazy_with_voice_dir(self):
(self.folder / "narrator.wav").write_bytes(b"x")
@@ -1109,7 +1097,9 @@ class NonInteractiveMainTests(unittest.TestCase):
transcribe=lambda path, model_name="base": "a transcript")
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual([m["id"] for m in data["models"]], ["qwen", "higgs"])
+ self.assertEqual([m["id"] for m in data["models"]],
+ ["Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ "Higgs-Audio-v3-TTS-4B-GGUF"])
self.assertTrue(data["lazy_load"])
self.assertEqual(data["voice_dir"], str(self.folder.resolve()))
prompt = (self.folder / make_server.PROMPT_TEXT_FILENAME).read_text(
@@ -1140,11 +1130,13 @@ class NonInteractiveMainTests(unittest.TestCase):
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
by_id = {m["id"]: m for m in data["models"]}
- self.assertIn("qwen-design", by_id)
- self.assertEqual(by_id["qwen-design"]["task"], "vdes")
+ self.assertIn("Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF", by_id)
+ self.assertEqual(by_id["Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF"]["task"],
+ "vdes")
# The non-design packages are hosted with task "tts".
- self.assertTrue(any(m["id"] in ("qwen", "qwen-2") and m["task"] == "tts"
- for m in data["models"]))
+ self.assertEqual(by_id["Qwen3-TTS-12Hz-1.7B-Base-GGUF"]["task"], "tts")
+ self.assertEqual(by_id["Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"]["task"],
+ "tts")
def test_unknown_family_rejected(self):
with self.assertRaises(SystemExit) as ctx:
@@ -1765,7 +1757,7 @@ class WizardNavigationTests(unittest.TestCase):
self.assertEqual(len(tree_calls), 2)
self.assertEqual(settings["host"], "127.0.0.1")
self.assertEqual([m["id"] for m in settings["model_entries"]],
- ["supertonic"])
+ ["Supertonic-GGUF"])
class UninstallTests(unittest.TestCase):
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 77f0ee8..b43919d 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -477,6 +477,13 @@ class QwenTTSClientGenerateTests(unittest.TestCase):
class AudioCppTTSClientHealthTests(unittest.TestCase):
"""Connection behavior of the audio.cpp client."""
+ def setUp(self):
+ # The default AUDIOCPP_MODEL_ID is empty (auto-select); these tests
+ # exercise a configured single-model server, so pin a concrete id.
+ patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen")
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
@staticmethod
def _json_response(payload):
response = MagicMock()
@@ -670,6 +677,11 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
class AudioCppTaskDetectionTests(unittest.TestCase):
"""Task auto-detection (tts/clon/vdes) and voice design validation."""
+ def setUp(self):
+ patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen")
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
@staticmethod
def _json_response(payload):
response = MagicMock()
@@ -808,6 +820,11 @@ class AudioCppTaskDetectionTests(unittest.TestCase):
class AudioCppFamilyDetectionTests(unittest.TestCase):
"""Family auto-detection and per-family adaptations."""
+ def setUp(self):
+ patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen")
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
@staticmethod
def _json_response(payload):
response = MagicMock()
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 6862b0a..285a1cf 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -1052,8 +1052,7 @@ def _settings_fields() -> list:
"value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size},
{"key": "unload_models", "label": "Unload models", "kind": "bool",
"value": config.AUDIOCPP_UNLOAD_MODELS,
- "note": "audio.cpp only. Ask the server to unload resident models "
- "before converting."},
+ "note": "audio.cpp: Unload previously-loaded models before converting to prevent VRAM exhaustion."},
{"key": "audiocpp_port", "label": "audio.cpp port",
"kind": "text",
"value": str(_port_from_url(config.AUDIOCPP_API_URL, 8080)),
@@ -1075,9 +1074,7 @@ def _settings_fields() -> list:
"kind": "text",
"value": config.AUDIOCPP_REMOTE_URL,
"validate": _validate_remote_url,
- "note": "Remote (externally-run) servers. The hub probes each URL and "
- "offers a \"[remote]\" backend entry when one answers. "
- "Empty disables probing."},
+ "note": "Remote (externally-run) servers. Empty disables probing."},
{"key": "faster_remote_url", "label": "faster-qwen3-tts remote URL",
"kind": "text",
"value": config.FASTER_REMOTE_URL,