Files
UmbraAudio/scripts/generate_narrator_refs.py
2026-10-18 19:01:04 -07:00

166 lines
6.4 KiB
Python

# 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())