# Copyright 2026 Umbra Tools (umbratools.com) # SPDX-License-Identifier: Apache-2.0 """Generate a narrator reference voice for each target language. Specification change (2026-10-13): * The narrator speaks with the accent of the *translation language*. An Italian book is narrated by an Italian-sounding narrator, not by an American reading Italian. * Every other character keeps whatever accent is defined on the speaker page - Deal Hunter stays Australian, Duchess Victoria stays Canadian, and so on. Why a reference per language is needed: the narrator reference is generated by the VoiceDesign model from a description that used to hardcode "{accent} English", which baked an American-English timbre into the ref. Cloning that ref into German gives German phonology from an American voice. Passing the target language fixes the phonology but not the identity. Characters are a different problem and do NOT need new references: their accents are already encoded in their existing refs. What was erasing them was the `language` parameter forcing the target language onto every utterance. The renderer now passes the character's accent as an explicit instruction alongside the clone, so phonology follows the translation while prosody and accent character follow the speaker page. Usage: python scripts/generate_narrator_refs.py --project DealHunterShip_4.0e_whole-to-audio python scripts/generate_narrator_refs.py --languages DE FR --dry-run """ from __future__ import annotations import argparse import sys import time import urllib.request import uuid from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from umbraaudio.config import load_config # noqa: E402 TTS_URL = "http://127.0.0.1:8881" # Qwen3-TTS language names, from the model's own codec_language_id keys. LANGUAGES = { "EN": ("English", "english"), "ZH": ("Chinese", "chinese"), "JA": ("Japanese", "japanese"), "KO": ("Korean", "korean"), "DE": ("German", "german"), "ES": ("Spanish", "spanish"), "FR": ("French", "french"), "RU": ("Russian", "russian"), "IT": ("Italian", "italian"), "PT": ("Portuguese", "portuguese"), } # The narrator line from the sample chapter, per language. The text must be in # the target language: it is what the model reads while shaping the voice. NARRATOR_TEXT = { "EN": "Welcome to the audiobook Deal Hunter. I am your narrator.", "DE": "Willkommen zum Hörbuch Deal Hunter. Ich bin Ihr Erzähler.", "FR": "Bienvenue dans le livre audio Deal Hunter. Je suis votre narrateur.", "ES": "Bienvenidos al audiolibro Deal Hunter. Soy su narrador.", "IT": "Benvenuti all'audiolibro Deal Hunter. Sono il vostro narratore.", "PT": "Bem-vindos ao audiolivro Deal Hunter. Sou o seu narrador.", "RU": "Добро пожаловать в аудиокнигу «Deal Hunter». Я ваш рассказчик.", "JA": "オードブック『Deal Hunter』へようこそ。ナレーターの私です。", "KO": "오디오북 『Deal Hunter』에 오신 것을 환영합니다. 내레이션은 제가 맡고 있습니다.", "ZH": "欢迎收听有声书《Deal Hunter》。我是您的旁白。", } # The narrator's character, held constant across every language. Only the # language and its accent change, so the voice stays recognisably the narrator. NARRATOR_TIMBRE = ( "A male narrator with a deep, resonant, authoritative baritone voice. " "Calm, measured delivery, warm and trustworthy, unhurried pacing." ) def build_instruct(language_name: str) -> str: return ( f"{NARRATOR_TIMBRE} " f"Speaking {language_name} as a native {language_name} speaker, with a " f"natural {language_name} accent and native {language_name} prosody. " f"This is an audiobook narration, not a translation being read aloud." ) def post_design(text: str, instruct: str, language: str, timeout: int) -> bytes: """Ask the server for a designed voice. Returns raw WAV bytes.""" boundary = f"----umbra{uuid.uuid4().hex}" parts: list[bytes] = [] def field(name: str, value: str) -> None: parts.append( f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n' f"{value}\r\n".encode() ) field("text", text) field("voice_description", instruct) field("language", language) parts.append(f"--{boundary}--\r\n".encode()) req = urllib.request.Request( f"{TTS_URL}/v1/tts", data=b"".join(parts), headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, method="POST", ) with urllib.request.urlopen(req, timeout=timeout) as resp: body = resp.read() if not body.startswith(b"RIFF"): raise RuntimeError(f"expected WAV, got {body[:120]!r}") return body def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--project") ap.add_argument("--languages", help="comma-separated suffixes, default all") ap.add_argument("--timeout", type=int, default=900) ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() config = load_config(args.project) voices_dir = config.data_dir / "voices" voices_dir.mkdir(parents=True, exist_ok=True) wanted = ( {s.strip().upper() for s in args.languages.split(",")} if args.languages else set(LANGUAGES) ) targets = [(k, v) for k, v in LANGUAGES.items() if k in wanted] print(f" narrator references -> {voices_dir}") for suffix, (name, code) in targets: out = voices_dir / f"narrator_{suffix}.wav" text = NARRATOR_TEXT.get(suffix, "") if not text: print(f" {suffix}: no narrator line available, skipped") continue instruct = build_instruct(name) if args.dry_run: print(f"\n {suffix} ({code}) -> {out.name}") print(f" text : {text}") print(f" instruct: {instruct}") continue t0 = time.time() try: wav = post_design(text, instruct, code, args.timeout) out.write_bytes(wav) kb = len(wav) / 1024 print(f" {suffix} {name:11s} {kb:6.0f} KB {time.time()-t0:5.1f}s -> {out.name}") except Exception as e: # surfaced, not swallowed print(f" {suffix} {name:11s} FAILED: {e}") return 0 if __name__ == "__main__": raise SystemExit(main())