aboutsummaryrefslogtreecommitdiff
path: root/tools/make_audiocpp_server_json.py
diff options
context:
space:
mode:
Diffstat (limited to 'tools/make_audiocpp_server_json.py')
-rwxr-xr-xtools/make_audiocpp_server_json.py415
1 files changed, 301 insertions, 114 deletions
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 <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)
return 0