From 5c3df0a434059bd0d541bda35a51e49e3c44dd55 Mon Sep 17 00:00:00 2001 From: historia Date: Thu, 20 Aug 2026 22:58:52 -0400 Subject: feat: experimental support for non-qwen models --- tools/make_audiocpp_server_json.py | 415 +++++++++++++++++++++++++++---------- 1 file changed, 301 insertions(+), 114 deletions(-) (limited to 'tools') diff --git a/tools/make_audiocpp_server_json.py b/tools/make_audiocpp_server_json.py index c999f49..1273e43 100755 --- a/tools/make_audiocpp_server_json.py +++ b/tools/make_audiocpp_server_json.py @@ -1,26 +1,34 @@ #!/usr/bin/env python3 """Interactively generate a server.json for the audio.cpp audiocpp_server. -Asks which Qwen3-TTS models to host, pulls the model ids expected by this +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 (a directory argument or an interactive prompt) are transcribed with a local Whisper backend -(faster_whisper or whisper) and added as voice_presets on the Base-model -entry. +(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, so running the tool with no arguments and pressing -Enter through produces a server.json hosting both models on +Enter through produces a server.json hosting both Qwen3-TTS models on 127.0.0.1:8080 with the cuda backend. 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}] [--backend {cuda,vulkan,hip,cpu}] [--lazy-load] [--whisper-model NAME] [--force] @@ -32,7 +40,7 @@ import re import sys import urllib.parse from pathlib import Path -from typing import Dict, Optional +from typing import Dict, List, Optional, Tuple # Allow running from any working directory. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -49,6 +57,56 @@ CONFIG_PATH = Path(__file__).resolve().parent.parent / "converter" / "config.py" 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} + def find_wav_files(input_dir: Path) -> list: """Return the .wav files in INPUT_DIR, sorted alphabetically by name.""" @@ -136,6 +194,13 @@ 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?", @@ -233,6 +298,37 @@ def update_config_api_url_port(port: int, config_path: Optional[Path] = None) -> return True +def update_config_model_ids(model_id: str, + clone_model_id: Optional[str] = None, + config_path: Optional[Path] = None) -> bool: + """Rewrite AUDIOCPP_MODEL_ID (and AUDIOCPP_CLONE_MODEL_ID when given). + + Only the quoted id literals are replaced; surrounding lines and + comments are preserved. Returns True when the file was changed. + """ + path = Path(config_path) if config_path is not None else CONFIG_PATH + try: + text = path.read_text(encoding="utf-8") + except OSError: + return False + updates: List[Tuple[str, str]] = [("AUDIOCPP_MODEL_ID", model_id)] + if clone_model_id is not None: + updates.append(("AUDIOCPP_CLONE_MODEL_ID", clone_model_id)) + changed = False + for name, value in updates: + match = re.search(r'(?m)^(\s*' + name + r'\s*=\s*")([^"]*)(")', text) + if match and match.group(2) != value: + text = text[:match.start(2)] + value + text[match.end(2):] + changed = True + if not changed: + return False + try: + path.write_text(text, encoding="utf-8") + except OSError: + return False + 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] = {} @@ -258,7 +354,7 @@ def build_server_config(host: str, port: int, backend: str, lazy_load: bool, custom_voice_id: str, clone_model_id: str, custom_voice_path: str, base_path: str, voice_presets: Dict[str, dict]) -> dict: - """Assemble the server.json document.""" + """Assemble the Qwen3-TTS server.json document.""" models = [] if include_custom: models.append({ @@ -288,34 +384,31 @@ def build_server_config(host: str, port: int, backend: str, lazy_load: bool, } -def _print_next_steps(output_path: Path, include_custom: bool, - include_clone: bool, voice_presets: Dict[str, dict]) -> None: - print("\nNext steps:") - print(" 1. Start the server (build path varies by platform, e.g.") - print(" ./build/linux-cuda-release/bin/):") - print(f" audiocpp_server --config {output_path}") - print(" 2. Convert a book from this repository:") - if include_custom: - print(" python audiobook.py --backend audiocpp" - " # built-in speaker") - if include_clone: - names = ", ".join(voice_presets) or "none configured yet" - print(" python audiobook.py --backend audiocpp --voice NAME" - f" # cloned voice ({names})") - if include_clone and not include_custom: - print("[INFO] Only the Base model is hosted: --voice is required, " - "since speaker mode needs the CustomVoice model.") +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 = { + "id": model_id, + "family": family, + "path": model_path, + "task": "tts", + "mode": "offline", + } + if voice_presets: + entry["voice_presets"] = voice_presets + return { + "host": host, + "port": port, + "backend": backend, + "lazy_load": lazy_load, + "models": [entry], + } def print_empty_transcript_warning(voice_presets: Dict[str, dict]) -> None: - """Print a loud, final warning for voices whose transcript is empty. - - A cloning preset with an empty ``reference_text`` will not produce a - usable voice (the server has nothing to match the reference audio - against for in-context cloning), so the user must edit server.json by - hand. This is printed last, after the next-steps, so it is the last - thing seen and hardest to miss. - """ + """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")) if not empty: @@ -334,25 +427,117 @@ def print_empty_transcript_warning(voice_presets: Dict[str, dict]) -> None: print(bar) +def _ask_host_port_backend_lazy(args: argparse.Namespace + ) -> 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) + port = args.port if args.port is not None else ask_port(config_port()) + if port != config_port(): + if ask_bool(f"Update AUDIOCPP_API_URL in converter/config.py to port " + f"{port} so audiobook.py talks to this server", True): + if update_config_api_url_port(port): + print(f"[OK] Updated AUDIOCPP_API_URL in {CONFIG_PATH}") + else: + print(f"[WARNING] Could not update {CONFIG_PATH}; edit " + "AUDIOCPP_API_URL by hand so audiobook.py uses the " + "new port") + else: + print("[WARNING] Left AUDIOCPP_API_URL unchanged; audiobook.py " + 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) + return host, port, backend, lazy_load + + +def _collect_voice_presets(args: argparse.Namespace, + include_clone: bool) -> Dict[str, dict]: + """Resolve the clone-reference wav directory and transcribe it. + + Returns the voice_presets mapping (empty when no wavs were given or + found). Cloning entries only: a run without any cloning model ignores + the wav directory entirely. + """ + wav_dir: Optional[Path] = None + if args.input_dir is not None: + if include_clone: + wav_dir = args.input_dir + else: + print(f"[WARNING] Ignoring {args.input_dir}: no cloning model " + "selected, so voice presets are not used") + elif include_clone: + wav_dir = ask_wav_dir() + if wav_dir is None: + return {} + + wav_files = find_wav_files(wav_dir) + if not wav_files: + print(f"[WARNING] No .wav files found in {wav_dir}; writing the " + "config without voice presets") + 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.") + 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) + + +def _offer_config_model_id_sync(model_id: str) -> None: + """Offer to point converter/config.py at a 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 + ids are rewritten together. + """ + if config.AUDIOCPP_MODEL_ID == model_id \ + and config.AUDIOCPP_CLONE_MODEL_ID == model_id: + return + if ask_bool("Update AUDIOCPP_MODEL_ID and AUDIOCPP_CLONE_MODEL_ID in " + f"converter/config.py to '{model_id}' so audiobook.py uses " + "this model", True): + if update_config_model_ids(model_id, model_id): + print(f"[OK] Updated the model ids in {CONFIG_PATH}") + else: + print(f"[WARNING] Could not update {CONFIG_PATH}; edit " + "AUDIOCPP_MODEL_ID and AUDIOCPP_CLONE_MODEL_ID by hand so " + "audiobook.py uses this model") + else: + print("[WARNING] Left the model ids unchanged; audiobook.py will " + f"still request model '{config.AUDIOCPP_MODEL_ID}'") + + def main() -> int: parser = argparse.ArgumentParser( description="Generate a server.json for the audio.cpp audiocpp_server " - "hosting the Qwen3-TTS models used by this converter.") + "hosting a TTS model used by this converter.") parser.add_argument("input_dir", type=Path, nargs="?", default=None, help="Optional directory with .wav reference files " "to add as voice cloning presets") 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("--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 models to host: both (default), custom " - "(CustomVoice speakers only), or clone " - "(Base voice cloning only)") + 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)") @@ -374,87 +559,77 @@ def main() -> int: 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 + if not is_qwen and args.models is not None: + parser.error("--models only applies to --family qwen3_tts") + 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}'") - selection = args.models if args.models is not None else ask_models() - include_custom = selection in ("both", "custom") - include_clone = selection in ("both", "clone") - - custom_voice_id = config.AUDIOCPP_MODEL_ID - clone_model_id = config.AUDIOCPP_CLONE_MODEL_ID - if include_custom and include_clone and custom_voice_id == clone_model_id: - print(f"[WARNING] AUDIOCPP_MODEL_ID and AUDIOCPP_CLONE_MODEL_ID are " - 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) - - host = args.host if args.host else ask("Bind host", DEFAULT_HOST) - port = args.port if args.port is not None else ask_port(config_port()) - if port != config_port(): - if ask_bool(f"Update AUDIOCPP_API_URL in converter/config.py to port " - f"{port} so audiobook.py talks to this server", True): - if update_config_api_url_port(port): - print(f"[OK] Updated AUDIOCPP_API_URL in {CONFIG_PATH}") - else: - print(f"[WARNING] Could not update {CONFIG_PATH}; edit " - "AUDIOCPP_API_URL by hand so audiobook.py uses the " - "new port") - else: - print("[WARNING] Left AUDIOCPP_API_URL unchanged; audiobook.py " - 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) - - 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) - if include_clone: - base_path = ask("Path to the Qwen3-TTS Base GGUF package", - DEFAULT_BASE_PATH) - - wav_dir: Optional[Path] = None - if args.input_dir is not None: + if is_qwen: + selection = args.models if args.models is not None else ask_models() + include_custom = selection in ("both", "custom") + include_clone = selection in ("both", "clone") + + custom_voice_id = config.AUDIOCPP_MODEL_ID + clone_model_id = config.AUDIOCPP_CLONE_MODEL_ID + if include_custom and include_clone and custom_voice_id == clone_model_id: + print(f"[WARNING] AUDIOCPP_MODEL_ID and AUDIOCPP_CLONE_MODEL_ID are " + 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) if include_clone: - wav_dir = args.input_dir - else: - print(f"[WARNING] Ignoring {args.input_dir}: no cloning (Base) " - "model selected, so voice presets are not used") - elif include_clone: - wav_dir = ask_wav_dir() - - voice_presets: Dict[str, dict] = {} - if wav_dir is not None: - wav_files = find_wav_files(wav_dir) - if wav_files: - 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.") - print(' Did you remember to "conda activate qwen3-tts"? ' - "Transcripts must be added by hand (see the warning at the end).") - voice_presets = build_voice_presets(wav_files, args.whisper_model) - else: - print(f"[WARNING] No .wav files found in {wav_dir}; writing the " - "config without voice presets") - - 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, - ) + base_path = ask("Path to the Qwen3-TTS Base GGUF package", + DEFAULT_BASE_PATH) + else: + model_path = args.model_path if args.model_path else ask( + f"Path to the {entry['label']} package", entry["default_path"]) + + 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, + ) print("\nGenerated server.json:") print(json.dumps(server_config, indent=2, ensure_ascii=False)) @@ -466,9 +641,21 @@ def main() -> int: json.dump(server_config, handle, indent=2, ensure_ascii=False) handle.write("\n") - print(f"\n[OK] Wrote {args.output} with {len(server_config['models'])} " - f"model(s) and {len(voice_presets)} voice preset(s)") - _print_next_steps(args.output, include_custom, include_clone, voice_presets) + 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 ") + 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) return 0 -- cgit v1.2.3