aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rwxr-xr-xtools/make_audiocpp_server_json.py443
-rwxr-xr-xtools/make_faster_voices_json.py (renamed from tools/make_voices.py)5
2 files changed, 446 insertions, 2 deletions
diff --git a/tools/make_audiocpp_server_json.py b/tools/make_audiocpp_server_json.py
new file mode 100755
index 0000000..40354eb
--- /dev/null
+++ b/tools/make_audiocpp_server_json.py
@@ -0,0 +1,443 @@
+#!/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
+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
+
+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.
+
+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
+127.0.0.1:8080 with the cuda backend.
+
+Usage:
+ python tools/make_audiocpp_server_json.py [WAV_DIR] [--output PATH]
+ [--host HOST] [--port PORT] [--models {both,custom,clone}]
+ [--backend {cuda,vulkan,hip,cpu}] [--lazy-load]
+ [--whisper-model NAME] [--force]
+"""
+
+import argparse
+import json
+import re
+import sys
+import urllib.parse
+from pathlib import Path
+from typing import Dict, Optional
+
+# Allow running from any working directory.
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from converter import config
+from converter.tts import transcribe_reference_audio
+
+DEFAULT_HOST = "127.0.0.1"
+FALLBACK_PORT = 8080
+DEFAULT_CUSTOM_VOICE_PATH = "models/Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"
+DEFAULT_BASE_PATH = "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF"
+CONFIG_PATH = Path(__file__).resolve().parent.parent / "converter" / "config.py"
+
+MODEL_SELECTIONS = ("both", "custom", "clone")
+BACKENDS = ("cuda", "vulkan", "hip", "cpu")
+
+
+def find_wav_files(input_dir: Path) -> list:
+ """Return the .wav files in INPUT_DIR, sorted alphabetically by name."""
+ return sorted(
+ (path for path in input_dir.iterdir()
+ if path.is_file() and path.suffix.lower() == ".wav"),
+ key=lambda path: path.name.lower(),
+ )
+
+
+def prompt_overwrite(output_path: Path) -> bool:
+ """Ask whether to overwrite an existing output file."""
+ while True:
+ try:
+ answer = input(f"{output_path} already exists. Overwrite? (y/n): ").strip().lower()
+ except EOFError:
+ print("\n[WARNING] No interactive input available; keeping existing file")
+ return False
+ if answer in ("y", "yes"):
+ return True
+ if answer in ("n", "no"):
+ return False
+ print("Please answer 'y' or 'n'.")
+
+
+def ask(prompt: str, default: Optional[str] = None) -> Optional[str]:
+ """Prompt for a free-text value with a default; EOF returns the default."""
+ suffix = f" [{default}]" if default is not None else ""
+ try:
+ answer = input(f"{prompt}{suffix}: ").strip()
+ except EOFError:
+ return default
+ return answer or default
+
+
+def ask_bool(prompt: str, default: bool = False) -> bool:
+ """Prompt for a yes/no answer; Enter or EOF accepts the default."""
+ suffix = " [Y/n]" if default else " [y/N]"
+ while True:
+ try:
+ answer = input(f"{prompt}{suffix}: ").strip().lower()
+ except EOFError:
+ return default
+ if not answer:
+ return default
+ if answer in ("y", "yes"):
+ return True
+ if answer in ("n", "no"):
+ return False
+ print("Please answer 'y' or 'n'.")
+
+
+def ask_port(default: int) -> int:
+ """Prompt for a port number; Enter or EOF accepts the default."""
+ while True:
+ try:
+ answer = input(f"Port [{default}]: ").strip()
+ except EOFError:
+ return default
+ if not answer:
+ return default
+ try:
+ value = int(answer)
+ except ValueError:
+ value = None
+ if value is not None and 1 <= value <= 65535:
+ return value
+ print("Please enter a port number between 1 and 65535.")
+
+
+def ask_menu(title: str, options: list, default_index: int = 1) -> str:
+ """Show a numbered menu and return the chosen option's value."""
+ print(title)
+ for number, (label, _) in enumerate(options, 1):
+ print(f" {number}) {label}")
+ while True:
+ try:
+ answer = input(f"Choice [{default_index}]: ").strip()
+ except EOFError:
+ return options[default_index - 1][1]
+ if not answer:
+ return options[default_index - 1][1]
+ if answer.isdigit() and 1 <= int(answer) <= len(options):
+ return options[int(answer) - 1][1]
+ print(f"Please enter a number between 1 and {len(options)}.")
+
+
+def ask_models() -> str:
+ return ask_menu(
+ "Which models should the server host?",
+ [
+ ("Both (recommended) - built-in speakers + voice cloning", "both"),
+ ("CustomVoice only - built-in speakers", "custom"),
+ ("Base only - voice cloning (converting then requires --voice)", "clone"),
+ ])
+
+
+def ask_backend() -> str:
+ return ask_menu(
+ "Which inference backend was audiocpp_server built for?",
+ [
+ ("cuda - NVIDIA GPUs (fastest)", "cuda"),
+ ("vulkan - cross-vendor GPU", "vulkan"),
+ ("hip - AMD GPUs", "hip"),
+ ("cpu - no GPU required", "cpu"),
+ ])
+
+
+def ask_distinct_clone_id(primary_id: str) -> str:
+ """Prompt until a non-empty id different from PRIMARY_ID is entered."""
+ prompt = (f"Enter a new id for the cloning (Base) model "
+ f"(must differ from '{primary_id}'): ")
+ while True:
+ try:
+ answer = input(prompt).strip()
+ except EOFError:
+ print()
+ sys.exit("[FATAL] No interactive input available to resolve the "
+ "duplicate model id; give the two models distinct "
+ "AUDIOCPP_MODEL_ID / AUDIOCPP_CLONE_MODEL_ID values in "
+ "converter/config.py first")
+ if answer and answer != primary_id:
+ return answer
+ print(f"[WARNING] The id must be unique; it cannot be empty or "
+ f"equal to '{primary_id}'.")
+
+
+def ask_wav_dir() -> Optional[Path]:
+ """Prompt for a directory of .wav clone references; Enter skips."""
+ while True:
+ try:
+ answer = input("Directory with .wav files to clone "
+ "(Enter to skip): ").strip()
+ except EOFError:
+ return None
+ if not answer:
+ return None
+ path = Path(answer)
+ if path.is_dir():
+ return path
+ print(f"[WARNING] {answer} is not a directory; try again "
+ "(or press Enter to skip).")
+
+
+def config_port() -> int:
+ """Return the port of AUDIOCPP_API_URL in converter/config.py."""
+ try:
+ return urllib.parse.urlsplit(config.AUDIOCPP_API_URL).port or FALLBACK_PORT
+ except ValueError:
+ return FALLBACK_PORT
+
+
+def _url_with_port(url: str, port: int) -> str:
+ parts = urllib.parse.urlsplit(url)
+ host = parts.hostname or "127.0.0.1"
+ return urllib.parse.urlunsplit(
+ (parts.scheme or "http", f"{host}:{port}", parts.path, "", ""))
+
+
+def update_config_api_url_port(port: int, config_path: Optional[Path] = None) -> bool:
+ """Rewrite the port inside AUDIOCPP_API_URL in converter/config.py.
+
+ Only the quoted URL literal is replaced; surrounding lines and the
+ trailing comment 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
+ match = re.search(r'(?m)^(\s*AUDIOCPP_API_URL\s*=\s*")([^"]*)(")', text)
+ if not match:
+ return False
+ new_url = _url_with_port(match.group(2), port)
+ if new_url == match.group(2):
+ return False
+ text = text[:match.start(2)] + new_url + text[match.end(2):]
+ 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] = {}
+ 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 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 server.json document."""
+ models = []
+ if include_custom:
+ models.append({
+ "id": custom_voice_id,
+ "family": "qwen3_tts",
+ "path": custom_voice_path,
+ "task": "tts",
+ "mode": "offline",
+ })
+ 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 _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 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.")
+ 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("--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)")
+ 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)")
+ 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")
+ args = parser.parse_args()
+
+ if args.input_dir is not None and not args.input_dir.is_dir():
+ parser.error(f"WAV directory not found: {args.input_dir}")
+
+ if args.output.exists() and not args.force \
+ and not prompt_overwrite(args.output):
+ print("[INFO] Aborted; existing server.json kept")
+ return 1
+
+ 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 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:
+ 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,
+ )
+
+ print("\nGenerated server.json:")
+ print(json.dumps(server_config, indent=2, ensure_ascii=False))
+ if not ask_bool(f"\nWrite this to {args.output}", True):
+ print("[INFO] Aborted; nothing written")
+ return 1
+
+ with args.output.open("w", encoding="utf-8") as handle:
+ 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)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/make_voices.py b/tools/make_faster_voices_json.py
index 913c454..2e00f72 100755
--- a/tools/make_voices.py
+++ b/tools/make_faster_voices_json.py
@@ -5,8 +5,9 @@ Scans a directory for .wav files, transcribes each with a local Whisper
backend (faster_whisper or whisper), and writes a voices.json
Usage:
- python tools/make_voices.py INPUT_DIR [--output PATH] [--language LANG]
- [--whisper-model NAME] [--force]
+ python tools/make_faster_voices_json.py INPUT_DIR [--output PATH]
+ [--language LANG]
+ [--whisper-model NAME] [--force]
The output can be passed to the faster server:
python examples/openai_server.py --voices voices.json --port 8000