166 lines
6.4 KiB
Python
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())
|