diff options
| -rw-r--r-- | README.md | 60 | ||||
| -rwxr-xr-x | audiobook.py | 19 | ||||
| -rw-r--r-- | converter/config.py | 5 | ||||
| -rw-r--r-- | converter/converter.py | 6 | ||||
| -rw-r--r-- | converter/tts.py | 51 | ||||
| -rw-r--r-- | tests/test_make_audiocpp_server_json.py | 809 | ||||
| -rw-r--r-- | tests/test_tts.py | 49 | ||||
| -rwxr-xr-x | tools/make_audiocpp_server_json.py | 681 |
8 files changed, 1108 insertions, 572 deletions
@@ -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 |
