aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md60
-rwxr-xr-xaudiobook.py19
-rw-r--r--converter/config.py5
-rw-r--r--converter/converter.py6
-rw-r--r--converter/tts.py51
-rw-r--r--tests/test_make_audiocpp_server_json.py809
-rw-r--r--tests/test_tts.py49
-rwxr-xr-xtools/make_audiocpp_server_json.py681
8 files changed, 1108 insertions, 572 deletions
diff --git a/README.md b/README.md
index da0925f..17b5f9b 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ You need to install one of the following backends (see below for installation/us
| `--backend {gradio,faster,audiocpp}` | TTS server to use (default `gradio`). `faster` and `audiocpp` require their server running first — see the backend sections above. With `audiocpp` the server may host any audio.cpp TTS model family (see Option 4). |
| `--voice <name>` | Voice to request from a server-side voice configuration (`--backend faster` or `audiocpp` only). Required for audio.cpp families without built-in speakers (everything except Qwen3-TTS). |
| `--chunk` | Force client-side chunking into `CHUNK_SIZE`-word requests. Only matters for `--backend audiocpp`, which otherwise sends each chapter as one request and lets the server chunk long text itself (may double-chunk); the `gradio` and `faster` backends always chunk. |
+| `--model <id>` | `--backend audiocpp` only: audio.cpp server model entry id to use for this run. Overrides `AUDIOCPP_MODEL_ID` in `converter/config.py`, so a server hosting several lazily-loaded models (one `server.json`, see Option 4) can be used without editing config — pick the model per run. Leave unset to use the config id, or to auto-select when the server hosts exactly one entry. |
| `--debug` | Troubleshooting: dump each chunk's raw audio and sent text to `debug/` and log every request. |
Other options including backend server URLs/ports are configured in `converter/config.py`
@@ -164,7 +165,9 @@ python3 tools/model_manager_v2.py install qwen3_tts_1_7b_base_q8_0
python3 tools/model_manager_v2.py install qwen3_tts_1_7b_customvoice_q8_0
```
-Create a `server.json` file. One server can host multiple models. Note that the `id:` field(s) must match `AUDIOCPP_MODEL_ID` and `AUDIOCPP_CLONE_MODEL_ID` in qwen3_ebook_converter's .`converter/config.py`. Optionally run `tools/make_audiocpp_server_json.py path/to/clone/wavs` to make `server.json` for you with automatic whisper transcription.
+Create a `server.json` file. One server can host multiple models. Note that the `id:` field(s) must match `AUDIOCPP_MODEL_ID` and `AUDIOCPP_CLONE_MODEL_ID` in qwen3_ebook_converter's .`converter/config.py`. Optionally run `tools/make_audiocpp_server_json.py path/to/clone/wavs --audiocpp-dir /path/to/audio.cpp` to make `server.json` for you with automatic whisper transcription (see [Generating server.json](#generating-serverjson-with-make_audiocpp_server_json)).
+
+Cloning voices can be configured per model entry (`voice_presets`) or once at the server level (`voice_dir` + a `prompt_text` file), which every hosted model can clone from. The generator uses the server-level form:
```json
{
@@ -172,6 +175,7 @@ Create a `server.json` file. One server can host multiple models. Note that the
"port": 8080,
"backend": "cuda",
"lazy_load": false,
+ "voice_dir": "/path/to/clone/wavs",
"models": [
{
"id": "qwen",
@@ -185,22 +189,21 @@ Create a `server.json` file. One server can host multiple models. Note that the
"family": "qwen3_tts",
"path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF",
"task": "tts",
- "mode": "offline",
- "voice_presets": {
- "narrator": {
- "voice_ref": "/path/to/reference.wav",
- "reference_text": "Transcript of the reference audio."
- },
- "obama": {
- "voice_ref": "/path/to/reference2.wav",
- "reference_text": "Transcript of reference audio."
- }
- }
+ "mode": "offline"
}
]
}
```
+`voice_dir` points at a directory of `.wav` reference files plus a `prompt_text` file with one `<basename>|<transcript>` line per voice:
+
+```
+narrator|Transcript of the reference audio.
+obama|Transcript of reference audio 2.
+```
+
+A request with `"voice": "narrator"` then clones `voice_dir/narrator.wav` using that transcript. (Per-entry `voice_presets` work too — see audio.cpp's server readme.)
+
Run the server. The `audiocpp_server` path will be slightly different depending on your platform and build options:
```bash
@@ -232,7 +235,7 @@ Supported families (see [audio.cpp's model list](https://github.com/0xShug0/audi
| `index_tts2` | IndexTTS-2 | zh, en | Top-tier cloning quality |
| `index_tts2` (package `index_tts2_5_*`) | IndexTTS-2.5 | zh, en, ja, es, ar | Multilingual IndexTTS variant |
-The converter also works with families not in this table (Fish Audio, Chatterbox, DotTTS, OmniVoice, ...) through its generic profile: clone-only, voice from `--voice`, language detected by the model itself. Anything you can host in `audiocpp_server` with `"task": "tts"` should work.
+The converter also works with families not in this table (Fish Audio, Chatterbox, DotTTS, OmniVoice, ...) through its generic profile: clone-only, voice from `--voice`, language detected by the model itself. Anything you can host in `audiocpp_server` with `"task": "tts"` should work, and `make_audiocpp_server_json.py` reads the full catalog from your audio.cpp checkout, so every TTS family audio.cpp supports is offered — not just the ones listed above.
### Install and run
@@ -245,7 +248,7 @@ python3 tools/model_manager_v2.py install higgs_audio_tts_4b_q8_0
# or: python3 tools/model_manager_v2.py install index_tts2_5_q8_0
```
-Create a `server.json` hosting the model plus your cloning voices as `voice_presets`. Note that `id` must match `AUDIOCPP_MODEL_ID` in `converter/config.py` (set `AUDIOCPP_CLONE_MODEL_ID` to the same id — single-model servers use one entry for both):
+Create a `server.json` hosting the model. Cloning voices go in a server-level `voice_dir` (a directory of `.wav` files plus a `prompt_text` file — see Option 3) so every hosted model can use them. Note that `id` must match `AUDIOCPP_MODEL_ID` in `converter/config.py` (set `AUDIOCPP_CLONE_MODEL_ID` to the same id — single-model servers use one entry for both):
```json
{
@@ -253,24 +256,21 @@ Create a `server.json` hosting the model plus your cloning voices as `voice_pres
"port": 8080,
"backend": "cuda",
"lazy_load": false,
+ "voice_dir": "/path/to/clone/wavs",
"models": [
{
"id": "higgs",
"family": "higgs_audio_tts",
"path": "models/Higgs-Audio-v3-TTS-4B-GGUF",
"task": "tts",
- "mode": "offline",
- "voice_presets": {
- "narrator": {
- "voice_ref": "/path/to/reference.wav",
- "reference_text": "Transcript of the reference audio."
- }
- }
+ "mode": "offline"
}
]
}
```
+One `server.json` can host several families at once (add more entries to `models` and set `"lazy_load": true` so each loads only on first use). Then pick the entry per run with `--model <id>` (see below).
+
Start the server and convert:
```bash
@@ -278,26 +278,30 @@ Start the server and convert:
# In another terminal
python audiobook.py --backend audiocpp --voice narrator
+# or, on a multi-model server:
+python audiobook.py --backend audiocpp --model higgs --voice narrator
```
VRAM note: the 4B Higgs Audio Q8_0 package needs roughly 2.5x the memory of the 1.7B Qwen3-TTS packages; VoxCPM2-2B and IndexTTS-2 sit in between. BF16/F16 packages roughly double the footprint again.
### Generating server.json with make_audiocpp_server_json
-`tools/make_audiocpp_server_json.py` supports the families above directly, including automatic whisper transcription of your reference wavs and updating `converter/config.py` to point at the generated entry:
+`tools/make_audiocpp_server_json.py` reads the model catalog (`model_specs/*.json`) from a local audio.cpp checkout and offers every TTS family it supports as a multi-select checklist, so one `server.json` can host several lazily-loaded models. It transcribes your reference wavs with whisper and writes a server-level `voice_dir` + `prompt_text` file automatically.
```bash
-# Interactive: pick the family from a menu
-python tools/make_audiocpp_server_json.py path/to/clone/wavs
+# Interactive: point at your audio.cpp checkout and pick families from a checklist
+python tools/make_audiocpp_server_json.py path/to/clone/wavs --audiocpp-dir /path/to/audio.cpp
-# Fully specified: Higgs Audio with wavs transcribed into voice presets
+# Non-interactive: host Higgs Audio + VoxCPM2 in one lazily-loaded server
python tools/make_audiocpp_server_json.py path/to/clone/wavs \
- --family higgs_audio_tts --model-id higgs \
- --model-path models/Higgs-Audio-v3-TTS-4B-GGUF \
+ --audiocpp-dir /path/to/audio.cpp \
+ --families higgs_audio_tts,voxcpm2 \
--backend cuda --output server.json --force
```
-The tool offers to rewrite `AUDIOCPP_MODEL_ID`/`AUDIOCPP_CLONE_MODEL_ID` in `converter/config.py` to the new entry id so `audiobook.py` talks to it without manual editing (answer "y" at the prompt). As with the Qwen flow, transcripts matter a lot for cloning quality — fill in any empty `reference_text` fields by hand before starting the server.
+The checkout can also be auto-detected (an `audio.cpp` directory next to/above your working directory, or the `AUDIOCPP_DIR` environment variable), so `--audiocpp-dir` is optional when you run from there. Pressing Enter at the checklist selects the default Qwen3-TTS flow (built-in speakers + cloning); otherwise enter comma-separated numbers for any combination of families. With more than one family the tool defaults to `"lazy_load": true` (models load on first use and stay in memory until the server exits — restart the server, or `POST /v1/tasks/unload_models`, before switching to a large model to free VRAM).
+
+For a single hosted entry the tool offers to rewrite `AUDIOCPP_MODEL_ID`/`AUDIOCPP_CLONE_MODEL_ID` in `converter/config.py` to the new id so `audiobook.py` talks to it without manual editing. With several entries it instead prints the available ids — pick one per run with `--model` (or set `AUDIOCPP_MODEL_ID`). Transcripts matter a lot for cloning quality — fill in any empty lines in `prompt_text` by hand before starting the server.
### Language handling
diff --git a/audiobook.py b/audiobook.py
index a984a0a..f177268 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -159,6 +159,20 @@ Examples:
"chunk.")
)
+ parser.add_argument(
+ "--model",
+ type=str,
+ default=None,
+ metavar="ID",
+ help=("audio.cpp server model entry id to use for this run "
+ "(--backend audiocpp only). Overrides AUDIOCPP_MODEL_ID in "
+ "converter/config.py, which is useful for a server hosting "
+ "several lazily-loaded models: generate one server.json with "
+ "tools/make_audiocpp_server_json.py, then pick the model per "
+ "run with --model. Leave unset to use the config id, or to "
+ "auto-select when the server hosts exactly one entry.")
+ )
+
args = parser.parse_args()
if args.speed <= 0:
@@ -221,6 +235,10 @@ Examples:
print("[WARNING] --transcription/--no-transcription "
"are ignored without --clone")
+ if args.model is not None and args.backend != BACKEND_AUDIOCPP:
+ parser.error("--model requires --backend audiocpp; it selects an "
+ "audio.cpp server model entry id")
+
setup_logging(debug=args.debug)
setup_directories()
@@ -265,6 +283,7 @@ Examples:
voice=args.voice,
debug=args.debug,
chunk=args.chunk,
+ model_id=args.model,
)
converter._book_files = book_files
converter._planned = planned
diff --git a/converter/config.py b/converter/config.py
index 5cf4a8f..8e60250 100644
--- a/converter/config.py
+++ b/converter/config.py
@@ -62,6 +62,9 @@ AUDIOCPP_API_URL = "http://127.0.0.1:8080" # audio.cpp audiocpp_server
# 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.
+# (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-clone"
diff --git a/converter/converter.py b/converter/converter.py
index c5f1788..4da3626 100644
--- a/converter/converter.py
+++ b/converter/converter.py
@@ -138,7 +138,7 @@ class AudiobookConverter:
speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT,
language: Optional[str] = None, backend: str = BACKEND_GRADIO,
voice: Optional[str] = None, debug: bool = False,
- chunk: bool = False):
+ chunk: bool = False, model_id: Optional[str] = None):
if speed <= 0:
raise ValueError(f"Speed must be a positive number, got {speed}")
if output_format not in AUDIO_FORMATS:
@@ -172,8 +172,10 @@ class AudiobookConverter:
elif backend == BACKEND_AUDIOCPP:
# Speaker mode (no voice) uses a built-in CustomVoice speaker;
# an explicit voice selects a server-side preset (cloning).
+ # model_id overrides AUDIOCPP_MODEL_ID for multi-model servers.
self.tts = AudioCppTTSClient(voice=voice, language=self.language,
- chunk_text=self.client_chunks)
+ chunk_text=self.client_chunks,
+ model_id=model_id)
else:
self.tts = QwenTTSClient(
voice_mode=voice_mode,
diff --git a/converter/tts.py b/converter/tts.py
index 0803bb9..83284a7 100644
--- a/converter/tts.py
+++ b/converter/tts.py
@@ -783,9 +783,15 @@ class AudioCppTTSClient(_BaseTTSClient):
"""
def __init__(self, voice: Optional[str] = None, language: Optional[str] = None,
- api_url: Optional[str] = None, chunk_text: bool = False):
+ api_url: Optional[str] = None, chunk_text: bool = False,
+ model_id: Optional[str] = None):
self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/")
- self.model_id = config.AUDIOCPP_MODEL_ID
+ # Per-run model selection: the --model CLI flag overrides config; an
+ # empty value is resolved at connect time when the server hosts exactly
+ # one entry, so multi-model servers don't require editing config.py.
+ self.model_id = (model_id if model_id is not None
+ else config.AUDIOCPP_MODEL_ID) or ""
+ self._model_id_explicit = bool(self.model_id)
# Validate before connecting so bad values fail fast without a server.
self.language = normalize_language(
language if language is not None else config.LANGUAGE)
@@ -820,6 +826,7 @@ class AudioCppTTSClient(_BaseTTSClient):
"""
self._check_health()
models = self._list_models()
+ self._auto_pick_model_id(models)
if self.preset_mode:
self._select_model(models)
self._require_model_id(models)
@@ -894,6 +901,29 @@ class AudioCppTTSClient(_BaseTTSClient):
})
return models
+ def _auto_pick_model_id(self, models: List[Dict[str, str]]) -> None:
+ """Resolve an empty model id when the server hosts exactly one entry.
+
+ Multi-model servers generated with several lazily-loaded entries can
+ be used without editing converter/config.py: leave AUDIOCPP_MODEL_ID
+ (and ``--model``) unset, and the single hosted entry is chosen
+ automatically. With more than one entry an explicit choice is required
+ (via ``--model`` or AUDIOCPP_MODEL_ID), since guessing would risk
+ synthesizing a whole book with the wrong family.
+ """
+ if self.model_id:
+ return
+ if len(models) == 1:
+ self.model_id = models[0]["id"]
+ logger.info(
+ "AUDIOCPP_MODEL_ID is unset; using the only server entry '%s'",
+ self.model_id)
+ else:
+ logger.debug(
+ "AUDIOCPP_MODEL_ID is unset and the server hosts %d entries; "
+ "an explicit --model or config id is required",
+ len(models))
+
def _require_model_id(self, models: List[Dict[str, str]]) -> None:
"""Verify the model id chosen for this run exists on the server.
@@ -902,9 +932,17 @@ class AudioCppTTSClient(_BaseTTSClient):
server hosting only a cloning model works for --voice.
"""
model_ids = [model["id"] for model in models]
- if self.model_id in model_ids:
+ if self.model_id and self.model_id in model_ids:
return
configured = ", ".join(model_ids) or "none"
+ if not self.model_id:
+ raise RuntimeError(
+ f"The audio.cpp server at {self.api_url} hosts {len(model_ids)} "
+ f"model entries ({configured}); audiobook.py needs to know which "
+ "one to use. Pass --model <id> when converting, or set "
+ "AUDIOCPP_MODEL_ID in converter/config.py to one of them "
+ "(see README)."
+ )
if self.preset_mode:
raise RuntimeError(
f"The audio.cpp server at {self.api_url} has no model id "
@@ -912,15 +950,16 @@ class AudioCppTTSClient(_BaseTTSClient):
f"'{config.AUDIOCPP_CLONE_MODEL_ID}' (configured: {configured}). "
"Add a TTS model entry for the family you want to the server "
"config and match AUDIOCPP_MODEL_ID / AUDIOCPP_CLONE_MODEL_ID "
- "in converter/config.py to its id (see README)."
+ "in converter/config.py to its id, or select it per run with "
+ "--model (see README)."
)
raise RuntimeError(
f"The audio.cpp server at {self.api_url} has no model id "
f"'{self.model_id}' (configured: {configured}). Speaker mode needs "
"the Qwen3-TTS CustomVoice model: add a qwen3_tts model entry to "
"the server config and match AUDIOCPP_MODEL_ID in converter/config.py to its "
- "id, or rerun with --voice to use a voice preset on any TTS "
- "model (see README)."
+ "id (or pass --model), or rerun with --voice to use a voice preset "
+ "on any TTS model (see README)."
)
def _select_model(self, models: List[Dict[str, str]]) -> None:
diff --git a/tests/test_make_audiocpp_server_json.py b/tests/test_make_audiocpp_server_json.py
index 2bd262a..d2d93bb 100644
--- a/tests/test_make_audiocpp_server_json.py
+++ b/tests/test_make_audiocpp_server_json.py
@@ -28,6 +28,81 @@ FAKE_CONFIG_WITH_MODEL_IDS = (
)
+def _write_spec(checkout: Path, family: str, *, display_name=None,
+ tasks=("tts", "clone"), languages=("en",), packages=None,
+ category="tts"):
+ """Write a minimal model_specs/<family>.json into a fake checkout."""
+ specs = checkout / "model_specs"
+ specs.mkdir(parents=True, exist_ok=True)
+ if packages is None:
+ packages = [{
+ "id": f"{family}_q8_0", "default": True, "format": "gguf",
+ "target_directory": f"{family}-GGUF",
+ }]
+ spec = {
+ "family": family,
+ "display_name": display_name or family,
+ "category": category,
+ "tasks": list(tasks),
+ "languages": list(languages),
+ "packages": packages,
+ }
+ (specs / f"{family}.json").write_text(json.dumps(spec), encoding="utf-8")
+ return spec
+
+
+def _make_checkout(tmp: Path) -> Path:
+ """Create a fake audio.cpp checkout with a realistic model_specs set."""
+ checkout = tmp / "audio.cpp"
+ checkout.mkdir()
+ _write_spec(checkout, "qwen3_tts", display_name="Qwen3-TTS",
+ tasks=("tts", "clone", "design"),
+ languages=("zh", "en", "ja"),
+ packages=[{
+ "id": "qwen3_tts_1_7b_base_q8_0", "default": True,
+ "format": "gguf",
+ "target_directory": "Qwen3-TTS-12Hz-1.7B-Base-GGUF",
+ }])
+ _write_spec(checkout, "higgs_audio_tts", display_name="Higgs Audio v3 TTS 4B",
+ languages=("auto",),
+ packages=[{
+ "id": "higgs_audio_tts_4b_q8_0", "default": True,
+ "format": "gguf",
+ "target_directory": "Higgs-Audio-v3-TTS-4B-GGUF",
+ }])
+ _write_spec(checkout, "voxcpm2", display_name="VoxCPM2-2B",
+ languages=("en", "zh"),
+ packages=[{
+ "id": "voxcpm2_q8_0", "default": True, "format": "gguf",
+ "target_directory": "VoxCPM2-GGUF",
+ }])
+ _write_spec(checkout, "index_tts2", display_name="IndexTTS-2",
+ languages=("zh", "en"),
+ packages=[{
+ "id": "index_tts2_q8_0", "default": True, "format": "gguf",
+ "target_directory": "IndexTTS2-GGUF",
+ }])
+ _write_spec(checkout, "pocket_tts", display_name="PocketTTS-100M",
+ tasks=("tts", "clone"), languages=("en", "de"),
+ packages=[{
+ "id": "pocket_tts_q8_0", "default": True, "format": "gguf",
+ "target_directory": "PocketTTS-GGUF",
+ }])
+ _write_spec(checkout, "supertonic", display_name="Supertonic 3",
+ tasks=("tts",), languages=("en", "ko"),
+ packages=[{
+ "id": "supertonic_q8_0", "default": True, "format": "gguf",
+ "target_directory": "Supertonic-GGUF",
+ }])
+ # An ASR family that must be filtered out.
+ _write_spec(checkout, "qwen3_asr", display_name="Qwen3-ASR",
+ tasks=("asr",), category="asr")
+ # A TTS family with no installable packages (must be skipped).
+ _write_spec(checkout, "empty_tts", display_name="Empty TTS",
+ tasks=("tts",), packages=[])
+ return checkout
+
+
class FindWavFilesTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
@@ -171,129 +246,249 @@ class UpdateConfigModelIdsTests(unittest.TestCase):
config_path=Path(self._tmp.name) / "nope.py"))
-class BuildSingleFamilyServerConfigTests(unittest.TestCase):
- def test_single_entry_with_presets(self):
- presets = {"narrator": {"voice_ref": "/x.wav",
- "reference_text": "hi"}}
- server_config = make_server.build_single_family_server_config(
- host="127.0.0.1", port=8080, backend="cuda", lazy_load=False,
- family="higgs_audio_tts", model_id="higgs",
- model_path="models/Higgs-Audio-v3-TTS-4B-GGUF",
- voice_presets=presets)
- self.assertEqual(server_config["host"], "127.0.0.1")
- self.assertEqual(server_config["port"], 8080)
- self.assertEqual(server_config["backend"], "cuda")
- self.assertFalse(server_config["lazy_load"])
- self.assertEqual(len(server_config["models"]), 1)
- entry = server_config["models"][0]
- self.assertEqual(entry["id"], "higgs")
- self.assertEqual(entry["family"], "higgs_audio_tts")
- self.assertEqual(entry["path"], "models/Higgs-Audio-v3-TTS-4B-GGUF")
- self.assertEqual(entry["task"], "tts")
- self.assertEqual(entry["mode"], "offline")
- self.assertEqual(entry["voice_presets"], presets)
-
- def test_no_presets_omits_key(self):
- server_config = make_server.build_single_family_server_config(
- host="127.0.0.1", port=8080, backend="cpu", lazy_load=True,
- family="index_tts2", model_id="indextts2",
- model_path="models/IndexTTS2-GGUF", voice_presets={})
- self.assertNotIn("voice_presets", server_config["models"][0])
-
- def test_family_entries_reference_real_families(self):
- for entry in make_server.FAMILY_ENTRIES:
- if entry["key"] == make_server.FAMILY_QWEN3_TTS:
- continue
- self.assertIn("install", entry)
- self.assertIn("default_id", entry)
- self.assertIn("default_path", entry)
- self.assertIn("family", entry)
-
-
-class BuildVoicePresetsTests(unittest.TestCase):
+class ResolveWavDirArgTests(unittest.TestCase):
+ """Path normalization for the required WAV_DIR argument."""
+
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.folder = Path(self._tmp.name)
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def test_resolves_to_absolute(self):
+ self.assertEqual(make_server.resolve_wav_dir_arg(str(self.folder)),
+ self.folder.resolve())
+
+ def test_strips_surrounding_quotes(self):
+ quoted = f'"{self.folder}"'
+ self.assertEqual(make_server.resolve_wav_dir_arg(quoted),
+ self.folder.resolve())
+
+ def test_strips_single_quotes(self):
+ quoted = f"'{self.folder}'"
+ self.assertEqual(make_server.resolve_wav_dir_arg(quoted),
+ self.folder.resolve())
+
+ def test_strips_whitespace(self):
+ self.assertEqual(make_server.resolve_wav_dir_arg(f" {self.folder} "),
+ self.folder.resolve())
+
+ def test_expands_tilde(self):
+ with patch.object(make_server.os.path, "expanduser",
+ return_value=str(self.folder)) as mock_expand:
+ result = make_server.resolve_wav_dir_arg("~/voices")
+ mock_expand.assert_called_once_with("~/voices")
+ self.assertEqual(result, self.folder.resolve())
+
+ def test_trailing_slash_preserved_as_dir(self):
+ self.assertEqual(make_server.resolve_wav_dir_arg(f"{self.folder}/"),
+ self.folder.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")
+ # Families without a _tts suffix just drop underscores.
+ self.assertEqual(make_server.default_model_id("moss_tts_local"),
+ "mossttslocal")
+
+
+class LoadModelCatalogTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = list(tempfile._mkdtemp() and 0 for _ in range(0)) # noqa
+ self._td = tempfile.TemporaryDirectory()
+ self.checkout = _make_checkout(Path(self._td.name))
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def test_includes_tts_families_excludes_asr(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ families = [entry["family"] for entry in catalog]
+ self.assertIn("qwen3_tts", families)
+ self.assertIn("higgs_audio_tts", families)
+ self.assertIn("pocket_tts", families)
+ self.assertIn("supertonic", families)
+ self.assertNotIn("qwen3_asr", families)
+
+ def test_skips_families_with_no_packages(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ self.assertNotIn("empty_tts",
+ [entry["family"] for entry in catalog])
+
+ def test_tested_families_come_first_in_order(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ tested = [entry["family"] for entry in catalog
+ if entry["tested"]]
+ self.assertEqual(tested, list(make_server.TESTED_FAMILIES))
+
+ def test_non_tested_families_follow_alphabetically(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ non_tested = [entry["family"] for entry in catalog
+ if not entry["tested"]]
+ self.assertEqual(non_tested, sorted(non_tested))
+
+ def test_default_package_and_target_directory_resolved(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ by_family = {entry["family"]: entry for entry in catalog}
+ higgs = by_family["higgs_audio_tts"]
+ self.assertEqual(higgs["install_id"], "higgs_audio_tts_4b_q8_0")
+ self.assertEqual(higgs["default_path"],
+ "models/Higgs-Audio-v3-TTS-4B-GGUF")
+
+ def test_picks_first_gguf_when_no_default_flag(self):
+ # Rewrite the voxcpm2 spec so no package is flagged default.
+ _write_spec(self.checkout, "voxcpm2", display_name="VoxCPM2-2B",
+ packages=[
+ {"id": "voxcpm2_bf16", "format": "gguf",
+ "target_directory": "VoxCPM2-GGUF"},
+ {"id": "voxcpm2_q8_0", "format": "gguf",
+ "target_directory": "VoxCPM2-GGUF"},
+ ])
+ catalog = make_server.load_model_catalog(self.checkout)
+ by_family = {entry["family"]: entry for entry in catalog}
+ # No default:true -> first gguf package wins.
+ self.assertEqual(by_family["voxcpm2"]["install_id"], "voxcpm2_bf16")
+
+ def test_clone_capability_from_tasks(self):
+ catalog = make_server.load_model_catalog(self.checkout)
+ by_family = {entry["family"]: entry for entry in catalog}
+ self.assertTrue(by_family["higgs_audio_tts"]["clone_capable"])
+ self.assertFalse(by_family["supertonic"]["clone_capable"])
+
+ def test_missing_model_specs_dir_raises(self):
+ empty = Path(self._td.name) / "empty"
+ empty.mkdir()
+ with self.assertRaises(NotADirectoryError):
+ make_server.load_model_catalog(empty)
+
+
+class AskFamiliesTests(unittest.TestCase):
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.checkout = _make_checkout(Path(self._td.name))
+ self.catalog = make_server.load_model_catalog(self.checkout)
+
+ def tearDown(self):
+ self._td.cleanup()
+
+ def _ids(self):
+ return [entry["family"] for entry in self.catalog]
+
+ def test_enter_selects_first_family(self):
+ with patch("builtins.input", side_effect=[""]):
+ self.assertEqual(make_server.ask_families(self.catalog),
+ [self.catalog[0]["family"]])
+
+ def test_eof_selects_first_family(self):
+ with patch("builtins.input", side_effect=EOFError):
+ self.assertEqual(make_server.ask_families(self.catalog),
+ [self.catalog[0]["family"]])
+
+ def test_comma_separated_numbers(self):
+ # 1 and 3 (qwen3_tts and voxcpm2 in the tested-first ordering).
+ with patch("builtins.input", side_effect=["1,3"]):
+ chosen = make_server.ask_families(self.catalog)
+ self.assertEqual(chosen, ["qwen3_tts", "voxcpm2"])
+
+ def test_space_separated_numbers(self):
+ with patch("builtins.input", side_effect=["2 4"]):
+ chosen = make_server.ask_families(self.catalog)
+ self.assertEqual(chosen, ["higgs_audio_tts", "index_tts2"])
+
+ def test_dedupes_repeated_choices(self):
+ with patch("builtins.input", side_effect=["1,1,2"]):
+ chosen = make_server.ask_families(self.catalog)
+ self.assertEqual(chosen, ["qwen3_tts", "higgs_audio_tts"])
+
+ def test_invalid_input_reprompts(self):
+ with patch("builtins.input", side_effect=["foo", "0", "2"]):
+ chosen = make_server.ask_families(self.catalog)
+ self.assertEqual(chosen, ["higgs_audio_tts"])
+
+
+class BuildServerConfigTests(unittest.TestCase):
+ def test_single_entry_without_voice_dir(self):
+ entry = make_server.build_model_entry(
+ "higgs_audio_tts", "higgs", "models/Higgs-GGUF")
+ cfg = make_server.build_server_config(
+ "127.0.0.1", 8080, "cuda", False, [entry])
+ self.assertEqual(cfg["host"], "127.0.0.1")
+ self.assertEqual(cfg["port"], 8080)
+ self.assertEqual(cfg["backend"], "cuda")
+ self.assertFalse(cfg["lazy_load"])
+ self.assertEqual(cfg["models"], [entry])
+ self.assertNotIn("voice_dir", cfg)
+
+ def test_voice_dir_added_when_given(self):
+ entry = make_server.build_model_entry("voxcpm2", "voxcpm2", "models/V")
+ cfg = make_server.build_server_config(
+ "0.0.0.0", 9000, "cpu", True, [entry],
+ voice_dir="/abs/voices")
+ self.assertTrue(cfg["lazy_load"])
+ self.assertEqual(cfg["voice_dir"], "/abs/voices")
+
+ def test_model_entry_shape(self):
+ entry = make_server.build_model_entry("index_tts2", "indextts2", "p")
+ self.assertEqual(entry["id"], "indextts2")
+ self.assertEqual(entry["family"], "index_tts2")
+ self.assertEqual(entry["path"], "p")
+ self.assertEqual(entry["task"], "tts")
+ self.assertEqual(entry["mode"], "offline")
+
+
+class TranscribeWavDirTests(unittest.TestCase):
+ def setUp(self):
+ self._td = tempfile.TemporaryDirectory()
+ self.folder = Path(self._td.name)
self.narrator = self.folder / "narrator.wav"
self.narrator.write_bytes(b"x")
self.other = self.folder / "other.wav"
self.other.write_bytes(b"x")
def tearDown(self):
- self._tmp.cleanup()
+ self._td.cleanup()
- def test_presets_named_after_basenames_with_absolute_paths(self):
- transcripts = {str(self.narrator): "First transcript.",
- str(self.other): "Second transcript."}
+ def test_transcribes_to_stem_map_with_absolute_paths(self):
+ transcripts = {str(self.narrator): "First.",
+ str(self.other): "Second."}
with patch.object(make_server, "transcribe_reference_audio",
side_effect=lambda path, model_name="base":
transcripts[path]):
- presets = make_server.build_voice_presets(
+ result = make_server.transcribe_wav_dir(
[self.narrator, self.other], "base")
- self.assertEqual(list(presets), ["narrator", "other"])
- self.assertEqual(presets["narrator"]["reference_text"],
- "First transcript.")
- self.assertEqual(Path(presets["narrator"]["voice_ref"]),
- self.narrator.resolve())
+ self.assertEqual(list(result), ["narrator", "other"])
+ self.assertEqual(result["narrator"], "First.")
- def test_failed_transcription_keeps_entry_with_empty_text(self):
+ def test_failed_transcription_keeps_empty_string(self):
with patch.object(make_server, "transcribe_reference_audio",
return_value=None):
- presets = make_server.build_voice_presets([self.narrator], "base")
- self.assertEqual(presets["narrator"]["reference_text"], "")
+ result = make_server.transcribe_wav_dir([self.narrator], "base")
+ self.assertEqual(result["narrator"], "")
- def test_whisper_model_name_is_passed_through(self):
+ def test_whisper_model_name_passed_through(self):
with patch.object(make_server, "transcribe_reference_audio",
return_value="text") as mock_transcribe:
- make_server.build_voice_presets([self.narrator], "large-v3")
+ make_server.transcribe_wav_dir([self.narrator], "large-v3")
self.assertEqual(mock_transcribe.call_args.kwargs["model_name"],
"large-v3")
-
-class BuildServerConfigTests(unittest.TestCase):
- def test_both_models_with_presets(self):
- presets = {"narrator": {"voice_ref": "/x.wav",
- "reference_text": "hi"}}
- server_config = make_server.build_server_config(
- host="127.0.0.1", port=8080, backend="cuda", lazy_load=False,
- include_custom=True, include_clone=True,
- custom_voice_id="qwen", clone_model_id="qwen-clone",
- custom_voice_path="models/custom", base_path="models/base",
- voice_presets=presets)
- self.assertEqual(server_config["host"], "127.0.0.1")
- self.assertEqual(server_config["port"], 8080)
- self.assertEqual(server_config["backend"], "cuda")
- self.assertFalse(server_config["lazy_load"])
- self.assertEqual([model["id"] for model in server_config["models"]],
- ["qwen", "qwen-clone"])
- custom_entry, clone_entry = server_config["models"]
- self.assertNotIn("voice_presets", custom_entry)
- self.assertEqual(custom_entry["family"], "qwen3_tts")
- self.assertEqual(custom_entry["path"], "models/custom")
- self.assertEqual(clone_entry["path"], "models/base")
- self.assertEqual(clone_entry["voice_presets"], presets)
-
- def test_custom_only_has_single_entry(self):
- server_config = make_server.build_server_config(
- host="0.0.0.0", port=9000, backend="cpu", lazy_load=True,
- include_custom=True, include_clone=False,
- custom_voice_id="qwen", clone_model_id="qwen-clone",
- custom_voice_path="models/custom", base_path=None,
- voice_presets={})
- self.assertEqual(len(server_config["models"]), 1)
- self.assertEqual(server_config["models"][0]["id"], "qwen")
- self.assertNotIn("voice_presets", server_config["models"][0])
-
- def test_clone_only_without_presets_omits_key(self):
- server_config = make_server.build_server_config(
- host="127.0.0.1", port=8080, backend="vulkan", lazy_load=False,
- include_custom=False, include_clone=True,
- custom_voice_id="qwen", clone_model_id="qwen-clone",
- custom_voice_path=None, base_path="models/base",
- voice_presets={})
- self.assertEqual(len(server_config["models"]), 1)
- self.assertEqual(server_config["models"][0]["id"], "qwen-clone")
- self.assertNotIn("voice_presets", server_config["models"][0])
+ def test_write_prompt_text_format(self):
+ path = make_server.write_prompt_text(
+ self.folder, {"narrator": "Hello.", "other": "World."})
+ self.assertEqual(path, self.folder / make_server.PROMPT_TEXT_FILENAME)
+ text = path.read_text(encoding="utf-8")
+ # One "name|transcript" line per voice, in insertion order.
+ self.assertIn("narrator|Hello.", text)
+ self.assertIn("other|World.", text)
class PromptHelperTests(unittest.TestCase):
@@ -327,61 +522,26 @@ class PromptHelperTests(unittest.TestCase):
"one")
-class ResolveWavDirArgTests(unittest.TestCase):
- """Path normalization for the required WAV_DIR argument."""
-
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
-
- def tearDown(self):
- self._tmp.cleanup()
-
- def test_resolves_to_absolute(self):
- self.assertEqual(make_server.resolve_wav_dir_arg(str(self.folder)),
- self.folder.resolve())
-
- def test_strips_surrounding_quotes(self):
- quoted = f'"{self.folder}"'
- self.assertEqual(make_server.resolve_wav_dir_arg(quoted),
- self.folder.resolve())
-
- def test_strips_single_quotes(self):
- quoted = f"'{self.folder}'"
- self.assertEqual(make_server.resolve_wav_dir_arg(quoted),
- self.folder.resolve())
-
- def test_strips_whitespace(self):
- self.assertEqual(make_server.resolve_wav_dir_arg(f" {self.folder} "),
- self.folder.resolve())
-
- def test_expands_tilde(self):
- with patch.object(make_server.os.path, "expanduser",
- return_value=str(self.folder)) as mock_expand:
- result = make_server.resolve_wav_dir_arg("~/voices")
- mock_expand.assert_called_once_with("~/voices")
- self.assertEqual(result, self.folder.resolve())
-
- def test_trailing_slash_preserved_as_dir(self):
- self.assertEqual(make_server.resolve_wav_dir_arg(f"{self.folder}/"),
- self.folder.resolve())
-
+class _MainTestBase(unittest.TestCase):
+ """Shared fixtures for end-to-end main() tests."""
-class MainTests(unittest.TestCase):
def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
- self.output = self.folder / "server.json"
+ self._td = tempfile.TemporaryDirectory()
+ self.root = Path(self._td.name)
+ self.folder = self.root / "wavs"
+ self.folder.mkdir()
+ self.output = self.root / "server.json"
+ self.checkout = _make_checkout(self.root)
# Isolate the config.py rewrite target so no test can ever
# modify the repository's real converter/config.py.
- self.fake_config = self.folder / "config.py"
+ self.fake_config = self.root / "config.py"
self.fake_config.write_text(FAKE_CONFIG, encoding="utf-8")
patcher = patch.object(make_server, "CONFIG_PATH", self.fake_config)
patcher.start()
self.addCleanup(patcher.stop)
def tearDown(self):
- self._tmp.cleanup()
+ self._td.cleanup()
def _run(self, argv, inputs=None, transcribe=None, whisper="faster_whisper"):
argv = ["make_audiocpp_server_json.py"] + argv
@@ -395,28 +555,53 @@ class MainTests(unittest.TestCase):
return_value=whisper):
return make_server.main()
- def _defaults(self, models="", host="", port="", backend="",
- lazy="", custom_path="", clone_path="",
- confirm="y", prefix=()):
- # First input selects the model family (default: Qwen3-TTS). The
- # wav directory is always a positional argument, never prompted.
- return list(prefix) + ["", models, host, port, backend, lazy,
- custom_path, clone_path, confirm]
+
+class MainTests(_MainTestBase):
+ """The default Qwen3-TTS flow and shared server settings."""
+
+ def _args(self, *extra):
+ return [str(self.folder), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout)] + list(extra)
+
+ # Default Qwen3-TTS "both" run inputs (no flags, port matches config):
+ # families, models, custom_path, base_path, host, port, backend, lazy, confirm
+ def _defaults(self, confirm="y"):
+ return ["", "", "", "", "", "", "", "", confirm]
def test_required_wav_dir_missing_prints_usage(self):
with self.assertRaises(SystemExit) as ctx:
- self._run(["--output", str(self.output)], inputs=[])
+ self._run(["--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout)], inputs=[])
self.assertEqual(ctx.exception.code, 2)
self.assertFalse(self.output.exists())
+ def test_missing_audiocpp_dir_errors(self):
+ with self.assertRaises(SystemExit) as ctx:
+ self._run([str(self.folder), "--output", str(self.output),
+ "--audiocpp-dir", str(self.root / "nope")],
+ inputs=[])
+ self.assertEqual(ctx.exception.code, 2)
+
+ def test_empty_audiocpp_dir_prompted_errors(self):
+ # No --audiocpp-dir and EOF at the prompt -> hard error.
+ buf = io.StringIO()
+ with patch.object(sys, "argv",
+ ["make_audiocpp_server_json.py",
+ str(self.folder), "--output", str(self.output)]), \
+ patch("builtins.input", side_effect=EOFError), \
+ redirect_stdout(buf):
+ with self.assertRaises(SystemExit) as ctx:
+ make_server.main()
+ self.assertEqual(ctx.exception.code, 2)
+
def test_default_run_hosts_both_models(self):
- exit_code = self._run([str(self.folder), "--output", str(self.output)],
- inputs=self._defaults())
+ exit_code = self._run(self._args(), inputs=self._defaults())
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
self.assertEqual(data["host"], "127.0.0.1")
self.assertEqual(data["port"], make_server.config_port())
self.assertEqual(data["backend"], "cuda")
+ # Single family (qwen3_tts) -> lazy defaults to False.
self.assertFalse(data["lazy_load"])
self.assertEqual(
[model["id"] for model in data["models"]],
@@ -425,24 +610,23 @@ class MainTests(unittest.TestCase):
[model["path"] for model in data["models"]],
[make_server.DEFAULT_CUSTOM_VOICE_PATH,
make_server.DEFAULT_BASE_PATH])
- self.assertNotIn("voice_presets", data["models"][1])
+ # voice_dir only when wavs are present; this run has none.
+ self.assertNotIn("voice_dir", data)
def test_eof_uses_all_defaults(self):
- exit_code = self._run([str(self.folder), "--output", str(self.output)])
+ exit_code = self._run(self._args())
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual(data["host"], "127.0.0.1")
- self.assertEqual(data["port"], make_server.config_port())
- self.assertEqual(data["backend"], "cuda")
- self.assertFalse(data["lazy_load"])
self.assertEqual(len(data["models"]), 2)
- def test_clone_only_with_positional_wav_dir(self):
+ def test_clone_only_run(self):
(self.folder / "narrator.wav").write_bytes(b"x")
(self.folder / "alpha.wav").write_bytes(b"x")
+ # families=default, models=3(clone), custom_path skipped, base_path,
+ # host, port, backend, lazy, confirm
inputs = ["", "3", "", "", "", "", "", "y"]
exit_code = self._run(
- [str(self.folder), "--output", str(self.output)],
+ self._args(),
inputs=inputs,
transcribe=lambda path, model_name="base":
f"transcript of {Path(path).name}")
@@ -451,17 +635,21 @@ class MainTests(unittest.TestCase):
self.assertEqual(len(data["models"]), 1)
clone_entry = data["models"][0]
self.assertEqual(clone_entry["id"], config.AUDIOCPP_CLONE_MODEL_ID)
- self.assertEqual(sorted(clone_entry["voice_presets"]),
- ["alpha", "narrator"])
- self.assertEqual(clone_entry["voice_presets"]["narrator"],
- {"voice_ref": str((self.folder / "narrator.wav").resolve()),
- "reference_text": "transcript of narrator.wav"})
+ # Voice presets now live in a server-level voice_dir + prompt_text,
+ # not per-entry voice_presets.
+ self.assertNotIn("voice_presets", clone_entry)
+ self.assertIn("voice_dir", data)
+ self.assertEqual(data["voice_dir"], str(self.folder.resolve()))
+ prompt = (self.folder / make_server.PROMPT_TEXT_FILENAME).read_text(
+ encoding="utf-8")
+ self.assertIn("narrator|transcript of narrator.wav", prompt)
+ self.assertIn("alpha|transcript of alpha.wav", prompt)
def test_custom_only_single_model(self):
- inputs = ["", "", "", "", "", "", "y"]
+ # families=default, models=2(custom), host, port, backend, lazy, confirm
+ inputs = ["", "2", "", "", "", "", "y"]
exit_code = self._run(
- [str(self.folder), "--output", str(self.output), "--models", "custom"],
- inputs=inputs)
+ self._args("--models", "custom"), inputs=inputs)
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
self.assertEqual([model["id"] for model in data["models"]],
@@ -470,9 +658,10 @@ class MainTests(unittest.TestCase):
def test_duplicate_ids_prompt_for_distinct_clone_id(self):
with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"), \
patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen"):
- inputs = ["", "1", "qwen-clone-2", "", "", "", "", "", "", "y"]
- exit_code = self._run([str(self.folder), "--output", str(self.output)],
- inputs=inputs)
+ # families, models(default both), distinct_clone_id, custom_path,
+ # base_path, host, port, backend, lazy, confirm
+ inputs = ["", "", "qwen-clone-2", "", "", "", "", "", "", "y"]
+ exit_code = self._run(self._args(), inputs=inputs)
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
self.assertEqual([model["id"] for model in data["models"]],
@@ -482,17 +671,19 @@ class MainTests(unittest.TestCase):
with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"), \
patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen"):
with self.assertRaises(SystemExit) as ctx:
- self._run([str(self.folder), "--output", str(self.output)])
+ self._run(self._args())
self.assertNotEqual(ctx.exception.code, 0)
self.assertFalse(self.output.exists())
def test_port_sync_accepted_updates_config(self):
with patch.object(config, "AUDIOCPP_API_URL",
"http://127.0.0.1:9999"):
- inputs = ["", "", "", "y", "", "", "", "", "", "y"]
- exit_code = self._run([str(self.folder), "--output", str(self.output),
- "--port", "8080"],
- inputs=inputs)
+ # --port 8080 differs from config port 9999 -> sync prompt fires.
+ # families, models, custom_path, base_path, host, port_sync(y),
+ # backend, lazy, confirm
+ inputs = ["", "", "", "", "", "y", "", "", "y"]
+ exit_code = self._run(
+ self._args("--port", "8080"), inputs=inputs)
self.assertEqual(exit_code, 0)
self.assertIn('"http://127.0.0.1:8080"',
self.fake_config.read_text(encoding="utf-8"))
@@ -502,44 +693,30 @@ class MainTests(unittest.TestCase):
def test_port_sync_declined_keeps_config(self):
with patch.object(config, "AUDIOCPP_API_URL",
"http://127.0.0.1:9999"):
- inputs = ["", "", "", "n", "", "", "", "", "", "y"]
- exit_code = self._run([str(self.folder), "--output", str(self.output),
- "--port", "8080"],
- inputs=inputs)
+ inputs = ["", "", "", "", "", "n", "", "", "y"]
+ exit_code = self._run(
+ self._args("--port", "8080"), inputs=inputs)
self.assertEqual(exit_code, 0)
self.assertIn('"http://127.0.0.1:9999"',
self.fake_config.read_text(encoding="utf-8"))
def test_matching_port_does_not_prompt_for_sync(self):
- with patch.object(config, "AUDIOCPP_API_URL",
- "http://127.0.0.1:8080"):
- inputs = self._defaults()
- exit_code = self._run([str(self.folder), "--output", str(self.output)],
- inputs=inputs)
+ # config_port() is 8080 (real config); default port matches -> no sync.
+ inputs = self._defaults()
+ exit_code = self._run(self._args(), inputs=inputs)
self.assertEqual(exit_code, 0)
self.assertEqual(self.fake_config.read_text(encoding="utf-8"),
FAKE_CONFIG)
- def test_invalid_menu_choice_reprompts(self):
- # Family menu default, then an invalid models-menu choice retried.
- inputs = ["", "9", "", "", "", "", "", "", "", "y"]
- exit_code = self._run([str(self.folder), "--output", str(self.output)],
- inputs=inputs)
- self.assertEqual(exit_code, 0)
- data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertEqual(len(data["models"]), 2)
-
def test_confirm_declined_writes_nothing(self):
inputs = self._defaults(confirm="n")
- exit_code = self._run([str(self.folder), "--output", str(self.output)],
- inputs=inputs)
+ exit_code = self._run(self._args(), inputs=inputs)
self.assertEqual(exit_code, 1)
self.assertFalse(self.output.exists())
def test_existing_output_declined_keeps_file(self):
self.output.write_text('{"old": true}', encoding="utf-8")
- exit_code = self._run([str(self.folder), "--output", str(self.output)],
- inputs=["n"])
+ exit_code = self._run(self._args(), inputs=["n"])
self.assertEqual(exit_code, 1)
self.assertEqual(json.loads(self.output.read_text(encoding="utf-8")),
{"old": True})
@@ -547,8 +724,7 @@ class MainTests(unittest.TestCase):
def test_existing_output_accepted_overwrites(self):
self.output.write_text('{"old": true}', encoding="utf-8")
inputs = ["y"] + self._defaults()
- exit_code = self._run([str(self.folder), "--output", str(self.output)],
- inputs=inputs)
+ exit_code = self._run(self._args(), inputs=inputs)
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
self.assertEqual(len(data["models"]), 2)
@@ -556,22 +732,19 @@ class MainTests(unittest.TestCase):
def test_force_overwrites_without_prompt(self):
self.output.write_text('{"old": true}', encoding="utf-8")
inputs = self._defaults()
- exit_code = self._run([str(self.folder), "--output", str(self.output),
- "--force"],
- inputs=inputs)
+ exit_code = self._run(self._args("--force"), inputs=inputs)
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
self.assertEqual(len(data["models"]), 2)
def test_flags_skip_prompts(self):
- # Family still asked (no --family flag); port 9000 differs from the
- # config port so its sync prompt fires; custom/clone paths use
- # their defaults.
+ # --families qwen3_tts --models both + server flags; port 9000 differs
+ # from config port 8080 -> the port sync prompt still fires.
exit_code = self._run(
- [str(self.folder), "--output", str(self.output), "--models", "both",
- "--host", "0.0.0.0", "--port", "9000", "--backend", "cpu",
- "--lazy-load"],
- inputs=["", "y", "", "", "y"])
+ self._args("--families", "qwen3_tts", "--models", "both",
+ "--host", "0.0.0.0", "--port", "9000",
+ "--backend", "cpu", "--lazy-load"),
+ inputs=["y", "", "", "y"])
self.assertEqual(exit_code, 0)
self.assertIn('"http://127.0.0.1:9000"',
self.fake_config.read_text(encoding="utf-8"))
@@ -582,55 +755,48 @@ class MainTests(unittest.TestCase):
self.assertTrue(data["lazy_load"])
def test_missing_positional_wav_dir_errors(self):
- missing = self.folder / "nope"
+ missing = self.root / "nope"
with self.assertRaises(SystemExit) as ctx, \
patch("sys.stderr") as mock_stderr:
- self._run([str(missing), "--output", str(self.output)],
+ self._run([str(missing), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout)],
inputs=self._defaults())
self.assertEqual(ctx.exception.code, 2)
shown = "".join(call[0][0] for call in mock_stderr.write.call_args_list)
self.assertIn(f"WAV directory not found: {missing.resolve()}", shown)
self.assertIn("directory containing the .wav", shown)
+ def test_models_flag_rejected_without_qwen(self):
+ with self.assertRaises(SystemExit) as ctx:
+ self._run(self._args("--families", "higgs_audio_tts",
+ "--models", "both"),
+ inputs=[])
+ self.assertEqual(ctx.exception.code, 2)
+
-class NonQwenFamilyMainTests(unittest.TestCase):
- """The --family flow for clone-only model families."""
+class NonQwenFamilyMainTests(_MainTestBase):
+ """The --families flow for clone-only model families."""
def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
- self.output = self.folder / "server.json"
- self.fake_config = self.folder / "config.py"
+ super().setUp()
+ # These tests exercise AUDIOCPP_MODEL_ID rewriting, so the fake
+ # config must contain the model id lines to rewrite.
self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS,
encoding="utf-8")
- patcher = patch.object(make_server, "CONFIG_PATH", self.fake_config)
- patcher.start()
- self.addCleanup(patcher.stop)
- def tearDown(self):
- self._tmp.cleanup()
-
- def _run(self, argv, inputs=None, transcribe=None, whisper="faster_whisper"):
- argv = ["make_audiocpp_server_json.py"] + argv
- input_effect = inputs if inputs is not None else EOFError
- transcribe_effect = transcribe if transcribe is not None else MagicMock()
- with patch.object(sys, "argv", argv), \
- patch("builtins.input", side_effect=input_effect), \
- patch.object(make_server, "transcribe_reference_audio",
- side_effect=transcribe_effect), \
- patch.object(make_server, "whisper_backend_available",
- return_value=whisper):
- return make_server.main()
+ def _args(self, family, *extra):
+ return [str(self.folder), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout),
+ "--families", family] + list(extra)
def test_higgs_family_run(self):
(self.folder / "narrator.wav").write_bytes(b"x")
- # Inputs: model-id sync accepted, host, port, backend, lazy, confirm.
- inputs = ["y", "", "", "", "", "y"]
+ # Single non-qwen family -> path is asked; then host, port, backend,
+ # lazy, confirm, model-id sync(y). prompt_text is written (no overwrite
+ # prompt on a fresh directory).
+ inputs = ["", "", "", "", "", "y", "y"]
exit_code = self._run(
- [str(self.folder), "--output", str(self.output),
- "--family", "higgs_audio_tts", "--model-id", "higgs",
- "--model-path", "models/Higgs-Audio-v3-TTS-4B-GGUF"],
- inputs=inputs,
+ self._args("higgs_audio_tts"), inputs=inputs,
transcribe=lambda path, model_name="base": "a transcript")
self.assertEqual(exit_code, 0)
data = json.loads(self.output.read_text(encoding="utf-8"))
@@ -641,22 +807,24 @@ class NonQwenFamilyMainTests(unittest.TestCase):
self.assertEqual(entry["path"], "models/Higgs-Audio-v3-TTS-4B-GGUF")
self.assertEqual(entry["task"], "tts")
self.assertEqual(entry["mode"], "offline")
- self.assertEqual(entry["voice_presets"]["narrator"],
- {"voice_ref": str((self.folder / "narrator.wav").resolve()),
- "reference_text": "a transcript"})
- # Both converter model ids point at the single server entry.
- self.assertIn('AUDIOCPP_MODEL_ID = "higgs"',
- self.fake_config.read_text(encoding="utf-8"))
- self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"',
- self.fake_config.read_text(encoding="utf-8"))
+ # Voice presets live in the server-level voice_dir, not per entry.
+ self.assertNotIn("voice_presets", entry)
+ self.assertEqual(data["voice_dir"], str(self.folder.resolve()))
+ prompt = (self.folder / make_server.PROMPT_TEXT_FILENAME).read_text(
+ encoding="utf-8")
+ self.assertIn("narrator|a transcript", prompt)
+ # Single non-qwen entry -> both converter ids are synced to it.
+ text = self.fake_config.read_text(encoding="utf-8")
+ self.assertIn('AUDIOCPP_MODEL_ID = "higgs"', text)
+ self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text)
def test_model_id_sync_declined_keeps_config(self):
- # sync declined, host, port, backend, lazy, confirm
- inputs = ["n", "", "", "", "", "y"]
+ (self.folder / "narrator.wav").write_bytes(b"x")
+ # path, host, port, backend, lazy, confirm, sync(n)
+ inputs = ["", "", "", "", "", "y", "n"]
exit_code = self._run(
- [str(self.folder), "--output", str(self.output), "--family", "voxcpm2",
- "--model-id", "voxcpm2", "--model-path", "models/VoxCPM2-GGUF"],
- inputs=inputs)
+ self._args("voxcpm2"), inputs=inputs,
+ transcribe=lambda path, model_name="base": "t")
self.assertEqual(exit_code, 0)
text = self.fake_config.read_text(encoding="utf-8")
self.assertIn('AUDIOCPP_MODEL_ID = "qwen"', text)
@@ -664,16 +832,16 @@ class NonQwenFamilyMainTests(unittest.TestCase):
data = json.loads(self.output.read_text(encoding="utf-8"))
self.assertEqual(data["models"][0]["family"], "voxcpm2")
- def test_no_voice_presets_warns(self):
+ def test_no_wavs_warns_and_omits_voice_dir(self):
buf = io.StringIO()
- # sync accepted, host, port, backend, lazy, confirm
+ # path, host, port, backend, lazy, confirm, sync(y)
+ inputs = ["", "", "", "", "", "y", "y"]
with patch.object(sys, "argv",
["make_audiocpp_server_json.py",
- str(self.folder),
- "--output", str(self.output),
- "--family", "index_tts2", "--model-id", "indextts2",
- "--model-path", "models/IndexTTS2-GGUF"]), \
- patch("builtins.input", side_effect=["y", "", "", "", "", "y"]), \
+ str(self.folder), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout),
+ "--families", "index_tts2"]), \
+ patch("builtins.input", side_effect=inputs), \
patch.object(make_server, "transcribe_reference_audio"), \
patch.object(make_server, "whisper_backend_available",
return_value="faster_whisper"), \
@@ -681,33 +849,92 @@ class NonQwenFamilyMainTests(unittest.TestCase):
code = make_server.main()
self.assertEqual(code, 0)
out = buf.getvalue()
- self.assertIn("No voice presets were configured", out)
+ self.assertIn("No .wav files found", out)
self.assertIn("model_manager_v2.py install index_tts2_q8_0", out)
data = json.loads(self.output.read_text(encoding="utf-8"))
- self.assertNotIn("voice_presets", data["models"][0])
+ self.assertNotIn("voice_dir", data)
- def test_models_flag_rejected_for_non_qwen_family(self):
+ def test_unknown_family_rejected(self):
with self.assertRaises(SystemExit) as ctx:
- self._run([str(self.folder), "--output", str(self.output),
- "--family", "higgs_audio_tts", "--models", "both"])
+ self._run(self._args("not_a_family"), inputs=[])
self.assertEqual(ctx.exception.code, 2)
-class TranscriptWarningTests(unittest.TestCase):
- """Empty transcripts and a missing Whisper backend produce loud warnings."""
+class MultiFamilyMainTests(_MainTestBase):
+ """Hosting several families in one server.json."""
- def setUp(self):
- self._tmp = tempfile.TemporaryDirectory()
- self.folder = Path(self._tmp.name)
- self.output = self.folder / "server.json"
- self.fake_config = self.folder / "config.py"
- self.fake_config.write_text(FAKE_CONFIG, encoding="utf-8")
- patcher = patch.object(make_server, "CONFIG_PATH", self.fake_config)
- patcher.start()
- self.addCleanup(patcher.stop)
+ def _args(self, *extra):
+ return [str(self.folder), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout)] + list(extra)
- def tearDown(self):
- self._tmp.cleanup()
+ def test_multiple_families_lazy_by_default_with_voice_dir(self):
+ (self.folder / "narrator.wav").write_bytes(b"x")
+ # --families selects qwen3_tts + higgs_audio_tts. qwen is among them
+ # with others -> qwen sub-flow forced to "both" (no models prompt).
+ # custom_path, base_path, host, port, backend, lazy(default True->Enter),
+ # prompt_text overwrite(none yet->writes), confirm
+ inputs = ["", "", "", "", "", "", "", "y"]
+ exit_code = self._run(
+ self._args("--families", "qwen3_tts,higgs_audio_tts"),
+ inputs=inputs,
+ transcribe=lambda path, model_name="base": "a transcript")
+ self.assertEqual(exit_code, 0)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ ids = [model["id"] for model in data["models"]]
+ self.assertEqual(ids, ["qwen", "qwen-clone", "higgs"])
+ # Two families -> lazy defaults to True.
+ self.assertTrue(data["lazy_load"])
+ self.assertEqual(data["voice_dir"], str(self.folder.resolve()))
+ # Multi-entry -> the tool prints a --model note instead of syncing.
+ higgs = data["models"][2]
+ self.assertEqual(higgs["path"], "models/Higgs-Audio-v3-TTS-4B-GGUF")
+
+ def test_two_non_qwen_families_use_catalog_paths(self):
+ # Multiple non-qwen families -> paths are NOT prompted (catalog defaults).
+ # qwen absent -> no models prompt; host, port, backend, lazy, confirm
+ inputs = ["", "", "", "", "y"]
+ exit_code = self._run(
+ self._args("--families", "higgs_audio_tts,voxcpm2"),
+ inputs=inputs)
+ self.assertEqual(exit_code, 0)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ by_id = {model["id"]: model for model in data["models"]}
+ self.assertEqual(by_id["higgs"]["path"],
+ "models/Higgs-Audio-v3-TTS-4B-GGUF")
+ self.assertEqual(by_id["voxcpm2"]["path"], "models/VoxCPM2-GGUF")
+ # No wavs and both clone-capable, but no wavs present -> no voice_dir.
+ self.assertNotIn("voice_dir", data)
+
+ def test_non_clone_family_selected_warns_about_wav_dir(self):
+ buf = io.StringIO()
+ # supertonic is TTS-only (no clone): wav dir is ignored.
+ # path, host, port, backend, lazy, confirm, sync(n)
+ inputs = ["", "", "", "", "y", "y", "n"]
+ with patch.object(sys, "argv",
+ ["make_audiocpp_server_json.py",
+ str(self.folder), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout),
+ "--families", "supertonic"]), \
+ patch("builtins.input", side_effect=inputs), \
+ patch.object(make_server, "transcribe_reference_audio"), \
+ patch.object(make_server, "whisper_backend_available",
+ return_value="faster_whisper"), \
+ redirect_stdout(buf):
+ code = make_server.main()
+ self.assertEqual(code, 0)
+ out = buf.getvalue()
+ self.assertIn("no clone-capable family selected", out)
+ data = json.loads(self.output.read_text(encoding="utf-8"))
+ self.assertNotIn("voice_dir", data)
+ self.assertEqual(data["models"][0]["family"], "supertonic")
+
+
+class TranscriptWarningTests(_MainTestBase):
+ """Empty transcripts and a missing Whisper backend produce loud warnings."""
+
+ def _args(self, *extra):
+ return [str(self.folder), "--output", str(self.output),
+ "--audiocpp-dir", str(self.checkout)] + list(extra)
def _run_capturing(self, argv, inputs, transcribe, whisper):
argv = ["make_audiocpp_server_json.py"] + argv
@@ -725,24 +952,24 @@ class TranscriptWarningTests(unittest.TestCase):
def test_empty_transcript_prints_loud_end_warning(self):
(self.folder / "narrator.wav").write_bytes(b"x")
(self.folder / "alpha.wav").write_bytes(b"x")
- # Qwen family default, clone-only run (menu choice 3); transcribe
- # returns None (empty).
+ # Qwen clone-only (menu 3); custom_path skipped, base_path, host, port,
+ # backend, lazy, prompt_text write, confirm
+ inputs = ["", "3", "", "", "", "", "", "", "y"]
code, out = self._run_capturing(
- [str(self.folder), "--output", str(self.output)],
- inputs=["", "3", "", "", "", "", "", "y"],
+ self._args(), inputs=inputs,
transcribe=lambda path, model_name="base": None,
whisper="faster_whisper")
self.assertEqual(code, 0)
self.assertIn("MANUAL TRANSCRIPTION REQUIRED", out)
self.assertIn("narrator", out)
self.assertIn("alpha", out)
- self.assertIn("will NOT work", out)
+ self.assertIn("prompt_text", out)
def test_missing_whisper_backend_prints_conda_warning(self):
(self.folder / "narrator.wav").write_bytes(b"x")
+ inputs = ["", "3", "", "", "", "", "", "", "y"]
code, out = self._run_capturing(
- [str(self.folder), "--output", str(self.output)],
- inputs=["", "3", "", "", "", "", "", "y"],
+ self._args(), inputs=inputs,
transcribe=lambda path, model_name="base": "a transcript",
whisper=None)
self.assertEqual(code, 0)
@@ -751,9 +978,9 @@ class TranscriptWarningTests(unittest.TestCase):
def test_all_transcripts_present_prints_no_end_warning(self):
(self.folder / "narrator.wav").write_bytes(b"x")
+ inputs = ["", "3", "", "", "", "", "", "", "y"]
code, out = self._run_capturing(
- [str(self.folder), "--output", str(self.output)],
- inputs=["", "3", "", "", "", "", "", "y"],
+ self._args(), inputs=inputs,
transcribe=lambda path, model_name="base": "a real transcript",
whisper="faster_whisper")
self.assertEqual(code, 0)
diff --git a/tests/test_tts.py b/tests/test_tts.py
index a0828de..33834d2 100644
--- a/tests/test_tts.py
+++ b/tests/test_tts.py
@@ -508,10 +508,11 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
raise AssertionError(f"unexpected URL: {url}")
return _dispatch
- def _client(self, voice=None, language=None, **kwargs):
+ def _client(self, voice=None, language=None, model_id=None, **kwargs):
with patch("converter.tts.urllib.request.urlopen",
side_effect=self._get_responses(**kwargs)):
- return AudioCppTTSClient(voice=voice, language=language)
+ return AudioCppTTSClient(voice=voice, language=language,
+ model_id=model_id)
def test_unreachable_server_raises_with_readme_pointer(self):
import urllib.error
@@ -600,6 +601,35 @@ class AudioCppTTSClientHealthTests(unittest.TestCase):
self.assertEqual(client.model_id, "qwen3-tts")
self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output))
+ def test_empty_model_id_auto_picks_single_server_entry(self):
+ # A multi-model server used without editing config.py: an empty
+ # --model resolves to the only hosted entry automatically.
+ client = self._client(
+ voice="narrator", model_id="",
+ models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]},
+ voices={"voices": ["narrator"]})
+ self.assertEqual(client.model_id, "higgs")
+
+ def test_empty_model_id_with_multiple_entries_requires_explicit_choice(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(
+ voice="narrator", model_id="",
+ models={"data": [{"id": "higgs"}, {"id": "voxcpm2"}]},
+ voices={"voices": ["narrator"]})
+ message = str(ctx.exception)
+ self.assertIn("--model", message)
+ self.assertIn("higgs", message)
+ self.assertIn("voxcpm2", message)
+
+ def test_model_id_override_reaches_request(self):
+ # --model overrides AUDIOCPP_MODEL_ID for the run.
+ with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"):
+ client = self._client(
+ voice="narrator", model_id="higgs",
+ models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]},
+ voices={"voices": ["narrator"]})
+ self.assertEqual(client.model_id, "higgs")
+
def test_clone_model_id_ignored_for_speaker_mode(self):
with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \
patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"):
@@ -1101,7 +1131,7 @@ class BackendWiringTests(unittest.TestCase):
backend=tts.BACKEND_AUDIOCPP, voice="narrator",
language="ja")
mock_audiocpp.assert_called_once_with(voice="narrator", language="Japanese",
- chunk_text=False)
+ chunk_text=False, model_id=None)
mock_faster.assert_not_called()
mock_qwen.assert_not_called()
@@ -1110,7 +1140,7 @@ class BackendWiringTests(unittest.TestCase):
AudiobookConverter(voice_mode=tts.VOICE_MODE_CUSTOM,
backend=tts.BACKEND_AUDIOCPP)
mock_audiocpp.assert_called_once_with(voice=None, language=config.LANGUAGE,
- chunk_text=False)
+ chunk_text=False, model_id=None)
def test_audiocpp_backend_chunk_flag_forces_client_chunking(self):
with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
@@ -1119,9 +1149,18 @@ class BackendWiringTests(unittest.TestCase):
voice="narrator", chunk=True)
mock_audiocpp.assert_called_once_with(voice="narrator",
language=config.LANGUAGE,
- chunk_text=True)
+ chunk_text=True, model_id=None)
self.assertTrue(converter.client_chunks)
+ def test_audiocpp_backend_model_id_is_wired_through(self):
+ with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp:
+ AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE,
+ backend=tts.BACKEND_AUDIOCPP, voice="narrator",
+ model_id="higgs")
+ mock_audiocpp.assert_called_once_with(
+ voice="narrator", language=config.LANGUAGE,
+ chunk_text=False, model_id="higgs")
+
def test_gradio_backend_uses_qwen_client(self):
with patch("converter.converter.FasterTTSClient") as mock_faster, \
patch("converter.converter.QwenTTSClient") as mock_qwen, \
diff --git a/tools/make_audiocpp_server_json.py b/tools/make_audiocpp_server_json.py
index d24f895..bda50e3 100755
--- a/tools/make_audiocpp_server_json.py
+++ b/tools/make_audiocpp_server_json.py
@@ -1,38 +1,37 @@
#!/usr/bin/env python3
"""Interactively generate a server.json for the audio.cpp audiocpp_server.
-Asks which TTS model family to host, pulls the model ids expected by this
-converter (AUDIOCPP_MODEL_ID / AUDIOCPP_CLONE_MODEL_ID) from
-converter/config.py, and writes a server.json that can be passed to
-audiocpp_server:
-
- audiocpp_server --config server.json
-
-Hostable families: Qwen3-TTS (built-in CustomVoice speakers plus voice
-cloning through the Base model) and the clone-only families Higgs Audio
-v3 TTS 4B, VoxCPM2-2B, and IndexTTS-2 / 2.5 (see the "Option 4" section
-of the README). The converter works with other audio.cpp TTS families
-too; host them by writing server.json by hand.
-
-Reference .wav files for voice cloning (the required WAV_DIR argument)
-are transcribed with a local Whisper backend (faster_whisper or whisper)
-and added as voice_presets on the cloning model entry.
-
-Every value can also be supplied as a command-line flag; anything missing
-is asked interactively with the default shown in brackets. Pressing Enter
-accepts the default.
+Reads the model catalog (``model_specs/*.json``) from a local audio.cpp
+checkout and offers every TTS model family audio.cpp supports as a
+multi-select checklist, so one server.json can host several lazily-loaded
+model entries at once. The converter itself is family-agnostic (it detects
+the family of the selected entry from ``GET /v1/models`` at startup), so any
+TTS family listed in the catalog works without further changes.
+
+Cloning reference .wav files (the required WAV_DIR argument) are transcribed
+with a local Whisper backend (faster_whisper or whisper) and published as a
+server-level ``voice_dir`` plus a ``prompt_text`` mapping file written into
+WAV_DIR, so every hosted clone-capable family can use them with ``--voice``.
+
+Every value can also be supplied as a command-line flag; anything missing is
+asked interactively with the default shown in brackets. Pressing Enter accepts
+the default (the Qwen3-TTS built-in-speakers + voice-cloning flow).
Usage:
python tools/make_audiocpp_server_json.py WAV_DIR [--output PATH]
- [--family {qwen3_tts,higgs_audio_tts,voxcpm2,index_tts2,index_tts2_5}]
- [--model-id ID] [--model-path PATH]
- [--host HOST] [--port PORT] [--models {both,custom,clone}]
+ [--audiocpp-dir PATH] [--families FAM1,FAM2]
+ [--models {both,custom,clone}] [--host HOST] [--port PORT]
[--backend {cuda,vulkan,hip,cpu}] [--lazy-load]
[--whisper-model NAME] [--force]
WAV_DIR is required: a directory of .wav reference files used as voice
cloning presets. It is checked up front and reported with its resolved
absolute path if it does not exist.
+
+--audiocpp-dir defaults to a detected audio.cpp checkout (the AUDIOCPP_DIR
+environment variable, or an ``audio.cpp`` directory next to or above the
+current working directory); if none is found it is asked interactively. The
+checkout must contain a ``model_specs/`` directory.
"""
import argparse
@@ -60,54 +59,26 @@ MODEL_SELECTIONS = ("both", "custom", "clone")
BACKENDS = ("cuda", "vulkan", "hip", "cpu")
FAMILY_QWEN3_TTS = "qwen3_tts"
-
-# Families this tool can host, in menu order. "family" is the audio.cpp
-# family name written to server.json (IndexTTS-2.5 uses the index_tts2
-# family; its variant is selected by the downloaded model package);
-# "install" is the model_manager_v2.py package that downloads the model;
-# "default_id" is the suggested server entry id; "default_path" is where
-# the package lands relative to the audio.cpp checkout.
-FAMILY_ENTRIES = [
- {
- "key": FAMILY_QWEN3_TTS,
- "label": "Qwen3-TTS 1.7B - built-in speakers + voice cloning",
- "family": "qwen3_tts",
- },
- {
- "key": "higgs_audio_tts",
- "label": "Higgs Audio v3 TTS 4B - voice cloning, 100+ languages",
- "family": "higgs_audio_tts",
- "install": "higgs_audio_tts_4b_q8_0",
- "default_id": "higgs",
- "default_path": "models/Higgs-Audio-v3-TTS-4B-GGUF",
- },
- {
- "key": "voxcpm2",
- "label": "VoxCPM2-2B - voice cloning, multilingual, 48 kHz audio",
- "family": "voxcpm2",
- "install": "voxcpm2_q8_0",
- "default_id": "voxcpm2",
- "default_path": "models/VoxCPM2-GGUF",
- },
- {
- "key": "index_tts2",
- "label": "IndexTTS-2 - voice cloning, Chinese/English",
- "family": "index_tts2",
- "install": "index_tts2_q8_0",
- "default_id": "indextts2",
- "default_path": "models/IndexTTS2-GGUF",
- },
- {
- "key": "index_tts2_5",
- "label": "IndexTTS-2.5 - voice cloning, zh/en/ja/es/ar",
- "family": "index_tts2",
- "install": "index_tts2_5_q8_0",
- "default_id": "indextts25",
- "default_path": "models/IndexTTS2.5-GGUF",
- },
-]
-FAMILY_KEYS = tuple(entry["key"] for entry in FAMILY_ENTRIES)
-FAMILY_BY_KEY = {entry["key"]: entry for entry in FAMILY_ENTRIES}
+PROMPT_TEXT_FILENAME = "prompt_text"
+
+# Families explicitly tested with this converter, in display order. These are
+# listed first in the checklist and marked "[tested]"; every other TTS family
+# in the catalog is offered too through the converter's generic profile.
+TESTED_FAMILIES = (
+ "qwen3_tts",
+ "higgs_audio_tts",
+ "voxcpm2",
+ "index_tts2",
+)
+
+# Short, friendly default entry ids for tested families. Other families derive
+# an id from their family name (see default_model_id).
+PREFERRED_IDS = {
+ "qwen3_tts": "qwen",
+ "higgs_audio_tts": "higgs",
+ "voxcpm2": "voxcpm2",
+ "index_tts2": "indextts2",
+}
def resolve_wav_dir_arg(value: str) -> Path:
@@ -209,16 +180,9 @@ def ask_menu(title: str, options: list, default_index: int = 1) -> str:
print(f"Please enter a number between 1 and {len(options)}.")
-def ask_family() -> str:
- """Ask which model family the server should host."""
- return ask_menu(
- "Which model family should the server host?",
- [(entry["label"], entry["key"]) for entry in FAMILY_ENTRIES])
-
-
def ask_models() -> str:
return ask_menu(
- "Which models should the server host?",
+ "Which Qwen3-TTS models should the server host?",
[
("Both (recommended) - built-in speakers + voice cloning", "both"),
("CustomVoice only - built-in speakers", "custom"),
@@ -327,88 +291,226 @@ def update_config_model_ids(model_id: str,
return True
-def build_voice_presets(wav_files: list, whisper_model: str) -> Dict[str, dict]:
- """Transcribe each wav file and build the voice_presets mapping."""
- presets: Dict[str, dict] = {}
- for wav_file in wav_files:
- name = wav_file.stem
- print(f"[INFO] Transcribing {wav_file.name} (voice '{name}')...")
- text = transcribe_reference_audio(str(wav_file), model_name=whisper_model)
- if text:
- print(f"[OK] {name}: {text}")
- else:
- print(f"[WARNING] No transcript for '{name}'; cloning works best "
- "with an accurate transcript — consider editing server.json "
- "by hand before starting the server")
- presets[name] = {
- "voice_ref": str(wav_file.resolve()),
- "reference_text": text or "",
- }
- return presets
+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 build_server_config(host: str, port: int, backend: str, lazy_load: bool,
- include_custom: bool, include_clone: bool,
- custom_voice_id: str, clone_model_id: str,
- custom_voice_path: str, base_path: str,
- voice_presets: Dict[str, dict]) -> dict:
- """Assemble the Qwen3-TTS server.json document."""
- models = []
- if include_custom:
- models.append({
- "id": custom_voice_id,
- "family": "qwen3_tts",
- "path": custom_voice_path,
- "task": "tts",
- "mode": "offline",
+def detect_audiocpp_dir() -> Optional[Path]:
+ """Best-effort location of a local audio.cpp checkout with model_specs.
+
+ Checks the AUDIOCPP_DIR environment variable, then an ``audio.cpp``
+ directory in or above the current working directory. Returns the path
+ only when it contains a ``model_specs`` directory.
+ """
+ candidates: List[Path] = []
+ env_dir = os.environ.get("AUDIOCPP_DIR")
+ if env_dir:
+ candidates.append(Path(env_dir))
+ cwd = Path.cwd()
+ candidates.append(cwd / "audio.cpp")
+ candidates.append(cwd.parent / "audio.cpp")
+ candidates.append(cwd.parent.parent / "audio.cpp")
+ for candidate in candidates:
+ try:
+ resolved = candidate.resolve()
+ except OSError:
+ continue
+ if (resolved / "model_specs").is_dir():
+ return resolved
+ return None
+
+
+def _default_package(spec: dict) -> Optional[dict]:
+ """Pick the default installable package from a model spec.
+
+ Prefers the package flagged ``default: true``, then the first GGUF
+ package, then the first package overall. Returns None if the spec
+ declares no packages.
+ """
+ packages = spec.get("packages") or []
+ if not packages:
+ return None
+ for package in packages:
+ if package.get("default"):
+ return package
+ for package in packages:
+ if package.get("format") == "gguf":
+ return package
+ return packages[0]
+
+
+def load_model_catalog(audiocpp_dir: Path) -> List[dict]:
+ """Read model_specs/*.json and return the TTS-capable families.
+
+ Each returned entry has: family, display_name, description, languages,
+ clone_capable, install_id (default package id), default_path
+ (``models/<target_directory>``), tested, and preferred_id. Tested
+ families come first (in TESTED_FAMILIES order), the rest follow
+ alphabetically by display name.
+ """
+ specs_dir = audiocpp_dir / "model_specs"
+ if not specs_dir.is_dir():
+ raise NotADirectoryError(
+ f"{audiocpp_dir} has no model_specs/ directory; point "
+ "--audiocpp-dir at an audio.cpp checkout")
+ entries: List[dict] = []
+ for spec_path in sorted(specs_dir.glob("*.json")):
+ try:
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ continue
+ tasks = spec.get("tasks") or []
+ if "tts" not in tasks and spec.get("category") != "tts":
+ continue
+ family = spec.get("family") or spec_path.stem
+ package = _default_package(spec)
+ if package is None:
+ # No installable package: skip (cannot be hosted from a path).
+ continue
+ target_directory = package.get("target_directory") or family
+ languages = spec.get("languages") or []
+ display_name = spec.get("display_name") or family
+ description = spec.get("description") or ""
+ entries.append({
+ "family": family,
+ "display_name": display_name,
+ "description": description,
+ "languages": languages,
+ "clone_capable": "clone" in tasks,
+ "install_id": package.get("id") or family,
+ "default_path": f"models/{target_directory}",
+ "tested": family in TESTED_FAMILIES,
+ "preferred_id": default_model_id(family),
})
- if include_clone:
- clone_entry = {
- "id": clone_model_id,
- "family": "qwen3_tts",
- "path": base_path,
- "task": "tts",
- "mode": "offline",
- }
- if voice_presets:
- clone_entry["voice_presets"] = voice_presets
- models.append(clone_entry)
- return {
- "host": host,
- "port": port,
- "backend": backend,
- "lazy_load": lazy_load,
- "models": models,
- }
+
+ def sort_key(entry: dict) -> tuple:
+ family = entry["family"]
+ if family in TESTED_FAMILIES:
+ return (0, TESTED_FAMILIES.index(family), "")
+ return (1, 0, entry["display_name"].lower())
+
+ entries.sort(key=sort_key)
+ return entries
-def build_single_family_server_config(host: str, port: int, backend: str,
- lazy_load: bool, family: str,
- model_id: str, model_path: str,
- voice_presets: Dict[str, dict]) -> dict:
- """Assemble a server.json hosting one clone-only model family entry."""
- entry = {
+def ask_families(catalog: List[dict]) -> List[str]:
+ """Show a numbered checklist and return the chosen family keys.
+
+ Input is comma/space-separated numbers; Enter alone selects the first
+ entry (the default Qwen3-TTS flow). At least one family is required.
+ """
+ print("Select TTS model families to host (comma-separated numbers,")
+ print("or press Enter for the default Qwen3-TTS flow):")
+ for number, entry in enumerate(catalog, 1):
+ marker = " [tested with this converter]" if entry["tested"] else ""
+ langs = entry["languages"]
+ lang_text = ", ".join(langs[:6]) + ("..." if len(langs) > 6 else "")
+ if entry["family"] == FAMILY_QWEN3_TTS:
+ caps = "built-in speakers + voice cloning"
+ elif entry["clone_capable"]:
+ caps = "voice cloning"
+ else:
+ caps = "TTS (no cloning)"
+ detail = f"({lang_text}; {caps})" if lang_text else f"({caps})"
+ print(f" {number}) {entry['display_name']}{marker} {detail}")
+ while True:
+ try:
+ answer = input("Choice [1]: ").strip()
+ except EOFError:
+ return [catalog[0]["family"]]
+ if not answer:
+ return [catalog[0]["family"]]
+ parts = [p for p in re.split(r"[,\s]+", answer) if p]
+ indices: List[int] = []
+ valid = True
+ for part in parts:
+ if part.isdigit() and 1 <= int(part) <= len(catalog):
+ indices.append(int(part))
+ else:
+ valid = False
+ break
+ if valid and indices:
+ chosen: List[str] = []
+ seen = set()
+ for index in indices:
+ family = catalog[index - 1]["family"]
+ if family not in seen:
+ seen.add(family)
+ chosen.append(family)
+ return chosen
+ print(f"Please enter comma-separated numbers between 1 and {len(catalog)}.")
+
+
+def build_model_entry(family: str, model_id: str, model_path: str) -> dict:
+ """Assemble one server.json model entry."""
+ return {
"id": model_id,
"family": family,
"path": model_path,
"task": "tts",
"mode": "offline",
}
- if voice_presets:
- entry["voice_presets"] = voice_presets
- return {
+
+
+def build_server_config(host: str, port: int, backend: str, lazy_load: bool,
+ model_entries: List[dict],
+ voice_dir: Optional[str] = None) -> dict:
+ """Assemble the server.json document.
+
+ ``voice_dir`` is a server-level cloning voice library; when set, every
+ hosted clone-capable family can use its voices with ``--voice``.
+ """
+ config_doc = {
"host": host,
"port": port,
"backend": backend,
"lazy_load": lazy_load,
- "models": [entry],
+ "models": model_entries,
}
+ if voice_dir:
+ config_doc["voice_dir"] = voice_dir
+ return config_doc
-def print_empty_transcript_warning(voice_presets: Dict[str, dict]) -> None:
+def transcribe_wav_dir(wav_files: list, whisper_model: str) -> Dict[str, str]:
+ """Transcribe each wav file and return a mapping of stem -> transcript."""
+ transcripts: Dict[str, str] = {}
+ for wav_file in wav_files:
+ name = wav_file.stem
+ print(f"[INFO] Transcribing {wav_file.name} (voice '{name}')...")
+ text = transcribe_reference_audio(str(wav_file), model_name=whisper_model)
+ if text:
+ print(f"[OK] {name}: {text}")
+ else:
+ print(f"[WARNING] No transcript for '{name}'; cloning works best "
+ "with an accurate transcript — consider editing prompt_text "
+ "by hand before starting the server")
+ transcripts[name] = text or ""
+ return transcripts
+
+
+def write_prompt_text(wav_dir: Path,
+ transcripts: Dict[str, str]) -> Path:
+ """Write the voice_dir prompt_text mapping into WAV_DIR.
+
+ One ``<basename-without-extension>|<transcript>`` line per voice.
+ Returns the path of the written file.
+ """
+ prompt_path = wav_dir / PROMPT_TEXT_FILENAME
+ lines = [f"{name}|{text}" for name, text in transcripts.items()]
+ prompt_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ return prompt_path
+
+
+def print_empty_transcript_warning(transcripts: Dict[str, str]) -> None:
"""Print a loud, final warning for voices whose transcript is empty."""
- empty = sorted(name for name, preset in voice_presets.items()
- if not preset.get("reference_text"))
+ empty = sorted(name for name, text in transcripts.items() if not text)
if not empty:
return
bar = "=" * 70
@@ -417,15 +519,16 @@ def print_empty_transcript_warning(voice_presets: Dict[str, dict]) -> None:
print("[WARNING] MANUAL TRANSCRIPTION REQUIRED")
print(bar)
listing = " - " + "\n - ".join(empty) if len(empty) > 1 else f" - {empty[0]}"
- print(f"The following voice preset(s) have an EMPTY reference_text in "
- f"server.json:\n{listing}")
- print("Those voices will NOT work until you add a manual transcription.")
- print('Edit server.json and fill in the "reference_text" field for each '
- "voice above with an accurate transcript of its reference .wav.")
+ print(f"The following voice(s) have an EMPTY transcript in prompt_text:\n"
+ f"{listing}")
+ print("Those voices will NOT work until you add an accurate transcript.")
+ print(f"Edit {PROMPT_TEXT_FILENAME} in your voice directory and fill in the "
+ "text after '|' for each voice above.")
print(bar)
-def _ask_host_port_backend_lazy(args: argparse.Namespace
+def _ask_host_port_backend_lazy(args: argparse.Namespace,
+ default_lazy: bool
) -> Tuple[str, int, str, bool]:
"""Ask for (or take from flags) the shared server settings."""
host = args.host if args.host else ask("Bind host", DEFAULT_HOST)
@@ -444,39 +547,40 @@ def _ask_host_port_backend_lazy(args: argparse.Namespace
f"will still use port {config_port()}")
backend = args.backend if args.backend else ask_backend()
lazy_load = args.lazy_load or ask_bool(
- "Load models lazily (on first use instead of at startup)", False)
+ "Load models lazily (on first use instead of at startup)", default_lazy)
return host, port, backend, lazy_load
-def _collect_voice_presets(args: argparse.Namespace,
- include_clone: bool) -> Dict[str, dict]:
- """Transcribe the wav directory into the voice_presets mapping.
+def _collect_transcripts(args: argparse.Namespace,
+ include_clone: bool) -> Dict[str, str]:
+ """Transcribe the wav directory into a stem -> transcript mapping.
- Returns the voice_presets mapping (empty when no wavs were found).
- Cloning entries only: a run without any cloning model ignores the wav
- directory entirely.
+ Returns the mapping (empty when no wavs were found or cloning is not
+ used by any selected family). Runs only when a cloning voice library is
+ needed; a run without any clone-capable family ignores the wav directory
+ entirely.
"""
if not include_clone:
- print(f"[WARNING] Ignoring {args.input_dir}: no cloning model "
+ print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family "
"selected, so voice presets are not used")
return {}
wav_files = find_wav_files(args.input_dir)
if not wav_files:
print(f"[WARNING] No .wav files found in {args.input_dir}; writing the "
- "config without voice presets")
+ "config without a voice_dir")
return {}
if whisper_backend_available() is None:
print("[WARNING] Neither faster_whisper nor whisper was found, so "
"reference .wav files cannot be transcribed automatically and "
- "every reference_text will be empty.")
+ "every transcript will be empty.")
print(' Did you remember to "conda activate qwen3-tts"? '
"Transcripts must be added by hand (see the warning at the end).")
- return build_voice_presets(wav_files, args.whisper_model)
+ return transcribe_wav_dir(wav_files, args.whisper_model)
def _offer_config_model_id_sync(model_id: str) -> None:
- """Offer to point converter/config.py at a non-Qwen model entry.
+ """Offer to point converter/config.py at a single non-Qwen model entry.
The converter requests the model id configured in AUDIOCPP_MODEL_ID,
and single-model servers use the same id for the clone entry, so both
@@ -499,47 +603,55 @@ def _offer_config_model_id_sync(model_id: str) -> None:
f"still request model '{config.AUDIOCPP_MODEL_ID}'")
+def _print_multi_model_model_id_note(entry_ids: List[str]) -> None:
+ """Tell the user how to select one entry per run for a multi-model server."""
+ print("[INFO] Several model entries were configured. audiobook.py uses one "
+ "entry per run: pass --model <id> when converting, or set "
+ "AUDIOCPP_MODEL_ID in converter/config.py to one of: "
+ f"{', '.join(entry_ids)}")
+
+
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate a server.json for the audio.cpp audiocpp_server "
- "hosting a TTS model used by this converter.")
+ "hosting one or more TTS model families used by this converter.")
parser.add_argument("input_dir", type=resolve_wav_dir_arg, metavar="WAV_DIR",
- help="Directory with .wav reference files to add as "
- "voice cloning presets (required)")
+ help="Directory with .wav reference files to publish as "
+ "a server-level voice_dir cloning library (required)")
parser.add_argument("--output", type=Path, default=Path("server.json"),
help="Output path for server.json (default: "
"server.json in the current directory)")
- parser.add_argument("--family", choices=FAMILY_KEYS, default=None,
- help="Model family to host (default: Qwen3-TTS). "
- "Non-Qwen families are clone-only and host a "
- "single model entry")
- parser.add_argument("--model-id", type=str, default=None,
- help="Server model id for a non-Qwen family entry "
- "(default: a family-based name such as 'higgs')")
- parser.add_argument("--model-path", type=str, default=None,
- help="Path to a non-Qwen family model package "
- "(default: the model manager install location)")
+ parser.add_argument("--audiocpp-dir", type=Path, default=None,
+ help="Path to a local audio.cpp checkout containing a "
+ "model_specs/ directory (default: detected from "
+ "AUDIOCPP_DIR or an audio.cpp directory next to/above "
+ "the current working directory; prompted otherwise)")
+ parser.add_argument("--families", type=str, default=None,
+ help="Comma-separated model families to host, as named "
+ "in the audio.cpp catalog (e.g. "
+ "qwen3_tts,higgs_audio_tts). Skips the family checklist")
+ parser.add_argument("--models", choices=MODEL_SELECTIONS, default=None,
+ help="Which Qwen3-TTS models to host: both (default), "
+ "custom (CustomVoice speakers only), or clone "
+ "(Base voice cloning only). Only valid when the "
+ "qwen3_tts family is selected")
parser.add_argument("--host", type=str, default=None,
help="Bind host for the server (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=None,
help="Port for the server (default: the port in "
"AUDIOCPP_API_URL from converter/config.py)")
- parser.add_argument("--models", choices=MODEL_SELECTIONS, default=None,
- help="Which Qwen3-TTS models to host: both (default), "
- "custom (CustomVoice speakers only), or clone "
- "(Base voice cloning only). Only valid with "
- "--family qwen3_tts")
parser.add_argument("--backend", choices=BACKENDS, default=None,
help="Inference backend audiocpp_server was built "
"for (default: cuda)")
parser.add_argument("--lazy-load", action="store_true",
help="Load models on first use instead of at startup "
- "(default: load at startup)")
+ "(default: on when more than one model is hosted)")
parser.add_argument("--whisper-model", type=str, default="base",
help="Whisper model size for transcription "
"(default: base)")
parser.add_argument("--force", action="store_true",
- help="Overwrite the output file without prompting")
+ help="Overwrite the output file (and prompt_text) "
+ "without prompting")
args = parser.parse_args()
if not args.input_dir.is_dir():
@@ -550,22 +662,76 @@ def main() -> int:
" WAV_DIR must be a directory containing the .wav "
"reference files to use as voice cloning presets")
+ # Resolve the audio.cpp checkout and load its model catalog.
+ audiocpp_dir = args.audiocpp_dir
+ if audiocpp_dir is None:
+ audiocpp_dir = detect_audiocpp_dir()
+ if audiocpp_dir is None:
+ audiocpp_dir = Path(ask("Path to your audio.cpp checkout", "") or "")
+ if not audiocpp_dir:
+ parser.error(
+ "An audio.cpp checkout is required to read the model catalog. "
+ "Clone one with `git clone https://github.com/0xShug0/audio.cpp` "
+ "and pass --audiocpp-dir PATH (or set the AUDIOCPP_DIR environment "
+ "variable)")
+ audiocpp_dir = audiocpp_dir.resolve()
+ if not audiocpp_dir.is_dir():
+ parser.error(f"audio.cpp checkout not found: {audiocpp_dir}")
+ try:
+ catalog = load_model_catalog(audiocpp_dir)
+ except NotADirectoryError as exc:
+ parser.error(str(exc))
+ if not catalog:
+ parser.error(
+ f"No TTS model families found in {audiocpp_dir}/model_specs; "
+ "check the checkout is up to date")
+
if args.output.exists() and not args.force \
and not prompt_overwrite(args.output):
print("[INFO] Aborted; existing server.json kept")
return 1
- family_key = args.family if args.family is not None else ask_family()
- is_qwen = family_key == FAMILY_QWEN3_TTS
+ # Select families.
+ if args.families is not None:
+ requested = [f.strip() for f in args.families.split(",") if f.strip()]
+ catalog_families = {entry["family"] for entry in catalog}
+ unknown = [f for f in requested if f not in catalog_families]
+ if unknown:
+ parser.error(
+ f"Unknown family in --families: {', '.join(unknown)}. "
+ f"Available: {', '.join(entry['family'] for entry in catalog)}")
+ family_keys: List[str] = []
+ for fam in requested:
+ if fam not in family_keys:
+ family_keys.append(fam)
+ else:
+ family_keys = ask_families(catalog)
+
+ catalog_by_family = {entry["family"]: entry for entry in catalog}
+ is_qwen = FAMILY_QWEN3_TTS in family_keys
if not is_qwen and args.models is not None:
- parser.error("--models only applies to --family qwen3_tts")
+ parser.error("--models only applies to the qwen3_tts family")
+ if is_qwen and args.models is not None and len(family_keys) > 1 \
+ and args.models != "both":
+ parser.error(
+ "--models custom/clone selects Qwen3-TTS sub-entries and is only "
+ "valid when qwen3_tts is the sole selected family")
print("[INFO] Model ids from converter/config.py:")
print(f" built-in speakers (CustomVoice): '{config.AUDIOCPP_MODEL_ID}'")
print(f" voice cloning (Base): '{config.AUDIOCPP_CLONE_MODEL_ID}'")
+ model_entries: List[dict] = []
+ entry_ids: List[str] = []
+ non_qwen_single_id: Optional[str] = None
+
if is_qwen:
selection = args.models if args.models is not None else ask_models()
+ # When qwen3_tts is selected with other families, keep both entries so
+ # speaker mode and cloning are both available; custom/clone sub-choice
+ # is only honored when qwen3_tts is the sole family.
+ if len(family_keys) > 1 and args.models is None:
+ selection = "both"
include_custom = selection in ("both", "custom")
include_clone = selection in ("both", "clone")
@@ -576,56 +742,77 @@ def main() -> int:
f"both '{custom_voice_id}' in converter/config.py, but server "
"model ids must be unique.")
clone_model_id = ask_distinct_clone_id(custom_voice_id)
- else:
- entry = FAMILY_BY_KEY[family_key]
- include_custom = False
- include_clone = True
- model_id = args.model_id if args.model_id else ask(
- f"Server model id for the {entry['label']} entry",
- entry["default_id"])
- _offer_config_model_id_sync(model_id)
-
- host, port, backend, lazy_load = _ask_host_port_backend_lazy(args)
- if is_qwen:
custom_voice_path = base_path = None
if include_custom:
custom_voice_path = ask("Path to the Qwen3-TTS CustomVoice GGUF package",
DEFAULT_CUSTOM_VOICE_PATH)
+ model_entries.append(build_model_entry(
+ FAMILY_QWEN3_TTS, custom_voice_id, custom_voice_path))
+ entry_ids.append(custom_voice_id)
if include_clone:
base_path = ask("Path to the Qwen3-TTS Base GGUF package",
DEFAULT_BASE_PATH)
+ model_entries.append(build_model_entry(
+ FAMILY_QWEN3_TTS, clone_model_id, base_path))
+ entry_ids.append(clone_model_id)
+ qwen_include_clone = include_clone
else:
- model_path = args.model_path if args.model_path else ask(
- f"Path to the {entry['label']} package", entry["default_path"])
+ qwen_include_clone = False
+
+ # Non-Qwen families: one entry each.
+ for family in family_keys:
+ if family == FAMILY_QWEN3_TTS:
+ continue
+ entry = catalog_by_family[family]
+ model_id = entry["preferred_id"]
+ # Ensure uniqueness against already-chosen ids.
+ if model_id in entry_ids:
+ model_id = ask(f"Server model id for {entry['display_name']}",
+ f"{model_id}-2")
+ model_path = entry["default_path"]
+ # For a single non-Qwen family, ask the path (matching the old flow);
+ # for several, use the catalog default to keep the prompt count sane.
+ if len(family_keys) == 1:
+ model_path = ask(f"Path to the {entry['display_name']} package",
+ model_path)
+ model_entries.append(build_model_entry(family, model_id, model_path))
+ entry_ids.append(model_id)
+ if len(family_keys) == 1:
+ non_qwen_single_id = model_id
+
+ # Whether any selected family can clone (drives voice_dir / wav transcription).
+ include_clone = qwen_include_clone or any(
+ catalog_by_family[f]["clone_capable"]
+ for f in family_keys if f != FAMILY_QWEN3_TTS)
+
+ # Default to lazy loading only when hosting more than one family: a
+ # single-family server (including the Qwen3-TTS CustomVoice+Base pair)
+ # loads at startup as before, while a multi-family server avoids loading
+ # every model until it is actually used.
+ default_lazy = len(family_keys) > 1
+ host, port, backend, lazy_load = _ask_host_port_backend_lazy(args, default_lazy)
+
+ transcripts = _collect_transcripts(args, include_clone)
+
+ voice_dir: Optional[str] = None
+ if transcripts:
+ prompt_path = args.input_dir / PROMPT_TEXT_FILENAME
+ if prompt_path.exists() and not args.force:
+ if not ask_bool(f"Overwrite existing {prompt_path}", True):
+ print(f"[INFO] Kept existing {prompt_path}; new transcripts "
+ "were not written")
+ else:
+ write_prompt_text(args.input_dir, transcripts)
+ print(f"[OK] Wrote {prompt_path}")
+ else:
+ write_prompt_text(args.input_dir, transcripts)
+ print(f"[OK] Wrote {prompt_path}")
+ voice_dir = str(args.input_dir.resolve())
- voice_presets = _collect_voice_presets(args, include_clone)
-
- if is_qwen:
- server_config = build_server_config(
- host=host,
- port=port,
- backend=backend,
- lazy_load=lazy_load,
- include_custom=include_custom,
- include_clone=include_clone,
- custom_voice_id=custom_voice_id,
- clone_model_id=clone_model_id,
- custom_voice_path=custom_voice_path,
- base_path=base_path,
- voice_presets=voice_presets,
- )
- else:
- server_config = build_single_family_server_config(
- host=host,
- port=port,
- backend=backend,
- lazy_load=lazy_load,
- family=entry["family"],
- model_id=model_id,
- model_path=model_path,
- voice_presets=voice_presets,
- )
+ server_config = build_server_config(
+ host=host, port=port, backend=backend, lazy_load=lazy_load,
+ model_entries=model_entries, voice_dir=voice_dir)
print("\nGenerated server.json:")
print(json.dumps(server_config, indent=2, ensure_ascii=False))
@@ -637,22 +824,38 @@ def main() -> int:
json.dump(server_config, handle, indent=2, ensure_ascii=False)
handle.write("\n")
- if is_qwen:
- print(f"\n[OK] Wrote {args.output} with {len(server_config['models'])} "
- f"model(s) and {len(voice_presets)} voice preset(s)")
- else:
- print(f"\n[OK] Wrote {args.output} hosting {entry['label']} "
- f"(model id '{model_id}') with {len(voice_presets)} "
- f"voice preset(s)")
- print(f"[INFO] Install the model package from the audio.cpp checkout: "
- f"python3 tools/model_manager_v2.py install {entry['install']}")
- print("[INFO] Clone-only family: run audiobook.py with "
- f"--backend audiocpp --voice <preset name>")
- if not voice_presets:
- print("[WARNING] No voice presets were configured; clone-only "
- "families have no built-in speakers, so add voice_presets "
- "(or a voice_dir) to server.json before converting")
- print_empty_transcript_warning(voice_presets)
+ # Post-generation guidance.
+ print(f"\n[OK] Wrote {args.output} with {len(model_entries)} model entry/entries"
+ + (f" and voice_dir '{voice_dir}'" if voice_dir else ""))
+ for family in family_keys:
+ entry = catalog_by_family[family]
+ if family == FAMILY_QWEN3_TTS:
+ print("[INFO] Install the Qwen3-TTS packages from the audio.cpp "
+ "checkout:")
+ print(" python3 tools/model_manager_v2.py install "
+ "qwen3_tts_1_7b_customvoice_q8_0")
+ print(" python3 tools/model_manager_v2.py install "
+ "qwen3_tts_1_7b_base_q8_0")
+ else:
+ print(f"[INFO] Install {entry['display_name']} from the audio.cpp "
+ f"checkout: python3 tools/model_manager_v2.py install "
+ f"{entry['install_id']}")
+ if len(model_entries) > 1:
+ print("[INFO] Models load lazily and stay in memory until the server "
+ "exits; restart the server (or POST /v1/tasks/unload_models) "
+ "before switching to a large model to free VRAM.")
+ if family_keys != [FAMILY_QWEN3_TTS]:
+ for family in family_keys:
+ if family == FAMILY_QWEN3_TTS:
+ continue
+ entry = catalog_by_family[family]
+ print(f"[INFO] Clone-only family {entry['display_name']}: run "
+ "audiobook.py with --backend audiocpp --voice <preset name>")
+ if len(entry_ids) == 1 and non_qwen_single_id is not None:
+ _offer_config_model_id_sync(non_qwen_single_id)
+ elif len(entry_ids) > 1:
+ _print_multi_model_model_id_note(entry_ids)
+ print_empty_transcript_warning(transcripts)
return 0