diff options
Diffstat (limited to 'tools')
| -rwxr-xr-x | tools/make_audiocpp_server_json.py | 681 |
1 files changed, 442 insertions, 239 deletions
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 |
