aboutsummaryrefslogtreecommitdiff
path: root/converter/tts.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-19 03:55:13 -0400
committerhistoria <historiavg@proton.me>2026-08-19 03:55:13 -0400
commit87e5216cd287f411b2ffab04dbc435f48c1d4aae (patch)
treed24346d6793233a2af6305f8463ae17765e00d44 /converter/tts.py
parentf1f8e899c46de80d7f9fbb8f0b53983e18167bd2 (diff)
downloadtts-audiobook-generator-87e5216cd287f411b2ffab04dbc435f48c1d4aae.tar.gz
refactor: config.py simplified
Diffstat (limited to 'converter/tts.py')
-rw-r--r--converter/tts.py118
1 files changed, 90 insertions, 28 deletions
diff --git a/converter/tts.py b/converter/tts.py
index 07cfa65..9930558 100644
--- a/converter/tts.py
+++ b/converter/tts.py
@@ -25,18 +25,81 @@ from .chunking import split_into_chunks
logger = logging.getLogger(__name__)
+# Voice modes (re-exported for the CLI and the converter orchestrator).
+VOICE_MODE_CUSTOM = "custom_voice"
+VOICE_MODE_CLONE = "voice_clone"
+VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE)
+
+# Languages understood by the Qwen3-TTS API. Display names must match the
+# demo dropdown exactly (the demo silently falls back to "Auto" for
+# unrecognized values, so languages are validated client-side first).
+TTS_LANGUAGES = (
+ "Auto",
+ "Chinese",
+ "English",
+ "German",
+ "Italian",
+ "Portuguese",
+ "Spanish",
+ "Japanese",
+ "Korean",
+ "French",
+ "Russian",
+)
+
+# Short aliases accepted on the command line (ISO 639-1 codes and common
+# shorthands), mapped to the display names above.
+TTS_LANGUAGE_ALIASES = {
+ "zh": "Chinese",
+ "en": "English",
+ "de": "German",
+ "it": "Italian",
+ "pt": "Portuguese",
+ "es": "Spanish",
+ "ja": "Japanese",
+ "ko": "Korean",
+ "fr": "French",
+ "ru": "Russian",
+ "zh-cn": "Chinese",
+ "zh-tw": "Chinese",
+ "pt-br": "Portuguese",
+ "en-us": "English",
+ "en-gb": "English",
+}
+
+# Canonical speaker names -> display names used by the qwen-tts demo.
+SPEAKER_DISPLAY_NAMES = {
+ "ryan": "Ryan",
+ "serena": "Serena",
+ "vivian": "Vivian",
+ "uncle_fu": "Uncle Fu",
+ "aiden": "Aiden",
+ "ono_anna": "Ono Anna",
+ "sohee": "Sohee",
+ "eric": "Eric",
+ "dylan": "Dylan",
+}
+
+# Fixed model facts: both demos run the 1.7B model (the CustomVoice demo
+# takes its full HuggingFace id), and the 12Hz codec outputs 24 kHz audio.
+MODEL_SIZE = "1.7B"
+CUSTOM_VOICE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
+SAMPLE_RATE = 24000
+
+CHUNKS_FOLDER = Path(__file__).resolve().parent.parent / "chunks"
+
def speaker_display_name() -> str:
"""Return the Gradio display name for the configured custom speaker."""
- return config.SPEAKER_DISPLAY_NAMES.get(
+ return SPEAKER_DISPLAY_NAMES.get(
config.CUSTOM_VOICE_SPEAKER.lower(), config.CUSTOM_VOICE_SPEAKER)
def normalize_language(value: Optional[str]) -> str:
"""Normalize a user-provided language name to a Qwen3-TTS display name.
- Accepts the display names in config.TTS_LANGUAGES case-insensitively as
- well as the short aliases in config.TTS_LANGUAGE_ALIASES (ISO 639-1 codes
+ Accepts the display names in TTS_LANGUAGES case-insensitively as
+ well as the short aliases in TTS_LANGUAGE_ALIASES (ISO 639-1 codes
and common shorthands). Raises ValueError for anything else, since the
Qwen3-TTS demo silently falls back to "Auto" for unrecognized languages.
"""
@@ -45,16 +108,16 @@ def normalize_language(value: Optional[str]) -> str:
candidate = value.strip()
if not candidate:
raise ValueError("Language must not be empty")
- for name in config.TTS_LANGUAGES:
+ for name in TTS_LANGUAGES:
if candidate.lower() == name.lower():
return name
- alias = config.TTS_LANGUAGE_ALIASES.get(candidate.lower())
+ alias = TTS_LANGUAGE_ALIASES.get(candidate.lower())
if alias:
return alias
raise ValueError(
f"Unknown language: {value!r}. Expected one of "
- f"{', '.join(config.TTS_LANGUAGES)} (or an alias: "
- f"{', '.join(sorted(config.TTS_LANGUAGE_ALIASES))})."
+ f"{', '.join(TTS_LANGUAGES)} (or an alias: "
+ f"{', '.join(sorted(TTS_LANGUAGE_ALIASES))})."
)
@@ -101,12 +164,12 @@ class _BaseTTSClient:
Any stale chunk file for this index is removed so a retry or extension
change can never leave two files matching chunk_NNNN.*.
"""
- for stale in config.CHUNKS_FOLDER.glob(f"chunk_{chunk_num:04d}.*"):
+ for stale in CHUNKS_FOLDER.glob(f"chunk_{chunk_num:04d}.*"):
try:
stale.unlink()
except OSError as exc:
logger.debug("Could not remove stale chunk file %s: %s", stale, exc)
- return config.CHUNKS_FOLDER / f"chunk_{chunk_num:04d}{suffix}"
+ return CHUNKS_FOLDER / f"chunk_{chunk_num:04d}{suffix}"
def process_chunk_with_retry(self, chunk_num: int, text: str) -> Optional[Path]:
"""Process a chunk with retry logic and rate limiting.
@@ -162,17 +225,16 @@ class QwenTTSClient(_BaseTTSClient):
def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None,
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
language: Optional[str] = None):
- if voice_mode not in config.VOICE_MODES:
+ if voice_mode not in VOICE_MODES:
raise ValueError(
- f"Unknown voice mode: {voice_mode!r} (expected one of {config.VOICE_MODES})"
+ f"Unknown voice mode: {voice_mode!r} (expected one of {VOICE_MODES})"
)
self.voice_mode = voice_mode
self.voice_clone_ref_audio = voice_clone_ref_audio
self.voice_clone_ref_text = (voice_clone_ref_text or "").strip()
self.skip_transcription = skip_transcription
if language is None:
- language = (config.VOICE_CLONE_LANGUAGE if voice_mode == config.VOICE_MODE_CLONE
- else config.CUSTOM_VOICE_LANGUAGE)
+ language = config.LANGUAGE
# Validate before connecting so bad values fail fast without a server.
self.language = normalize_language(language)
self.client = None
@@ -187,9 +249,9 @@ class QwenTTSClient(_BaseTTSClient):
# ------------------------------------------------------------------
def _connect(self) -> None:
- api_url = config.VOICE_CLONE_API_URL if self.voice_mode == config.VOICE_MODE_CLONE else config.QWEN_API_URL
+ api_url = config.VOICE_CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE else config.QWEN_API_URL
try:
- if self.voice_mode == config.VOICE_MODE_CLONE:
+ if self.voice_mode == VOICE_MODE_CLONE:
# Voice clone uses the Base-model demo, which is a separate server
# from the CustomVoice demo (that one only exposes /run_instruct).
self._init_client(config.VOICE_CLONE_API_URL, clone=True)
@@ -295,10 +357,10 @@ class QwenTTSClient(_BaseTTSClient):
def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
"""Generate one audio chunk; returns its path in the chunks folder."""
try:
- if self.voice_mode == config.VOICE_MODE_CUSTOM:
+ if self.voice_mode == VOICE_MODE_CUSTOM:
with self._chunk_heartbeat(chunk_num):
result = self._generate_custom_voice(text)
- elif self.voice_mode == config.VOICE_MODE_CLONE:
+ elif self.voice_mode == VOICE_MODE_CLONE:
with self._chunk_heartbeat(chunk_num):
result = self._generate_voice_clone(text)
else:
@@ -348,12 +410,12 @@ class QwenTTSClient(_BaseTTSClient):
instruct=config.CUSTOM_VOICE_INSTRUCT,
)
if self._endpoint_accepts_param(custom_api, "model_id_cv"):
- payload["model_id_cv"] = config.CUSTOM_VOICE_MODEL_ID
+ payload["model_id_cv"] = CUSTOM_VOICE_MODEL_ID
elif self._endpoint_accepts_param(custom_api, "model_size"):
- payload["model_size"] = config.CUSTOM_VOICE_MODEL_SIZE
+ payload["model_size"] = MODEL_SIZE
if self._endpoint_accepts_param(custom_api, "seed"):
- payload["seed"] = config.CUSTOM_VOICE_SEED
+ payload["seed"] = config.SEED
return self.client.predict(**payload, api_name=custom_api)
@@ -393,10 +455,10 @@ class QwenTTSClient(_BaseTTSClient):
use_xvector_only=use_xvector,
)
optional_params = {
- "model_size": config.VOICE_CLONE_MODEL_SIZE,
+ "model_size": MODEL_SIZE,
"max_chunk_chars": config.VOICE_CLONE_MAX_CHUNK_CHARS,
"chunk_gap": config.VOICE_CLONE_CHUNK_GAP,
- "seed": config.VOICE_CLONE_SEED,
+ "seed": config.SEED,
}
for name, value in optional_params.items():
if self._endpoint_accepts_param(clone_api, name, api_info=self.clone_api_info):
@@ -458,7 +520,7 @@ class FasterTTSClient(_BaseTTSClient):
request = urllib.request.Request(
url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
try:
- with urllib.request.urlopen(request, timeout=config.FASTER_HTTP_TIMEOUT) as response:
+ with urllib.request.urlopen(request, timeout=config.API_TIMEOUT) as response:
pcm = response.read()
except urllib.error.HTTPError as exc:
detail = ""
@@ -476,16 +538,16 @@ class FasterTTSClient(_BaseTTSClient):
def _request_pcm_with_retry(self, text: str, chunk_num: int, sub_num: int,
sub_total: int) -> bytes:
"""Request one sub-chunk, retrying transient failures."""
- for attempt in range(config.FASTER_SUBCHUNK_RETRIES):
+ for attempt in range(config.MAX_RETRIES):
try:
return self._request_pcm(text)
except Exception as exc:
logger.warning("Chunk %d sub-chunk %d/%d attempt %d failed: %s",
chunk_num, sub_num, sub_total, attempt + 1, exc)
- if attempt < config.FASTER_SUBCHUNK_RETRIES - 1:
+ if attempt < config.MAX_RETRIES - 1:
time.sleep(2 + 2 * attempt)
raise RuntimeError(f"Sub-chunk {sub_num}/{sub_total} failed after "
- f"{config.FASTER_SUBCHUNK_RETRIES} attempts")
+ f"{config.MAX_RETRIES} attempts")
# ------------------------------------------------------------------
# Chunk generation
@@ -494,7 +556,7 @@ class FasterTTSClient(_BaseTTSClient):
def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
"""Generate one audio chunk; returns its path in the chunks folder."""
try:
- sub_chunks = split_into_chunks(text, max_words=config.FASTER_SUBCHUNK_WORDS)
+ sub_chunks = split_into_chunks(text, max_words=config.CHUNK_SIZE_WORDS)
if not sub_chunks:
raise RuntimeError("No text to synthesize")
@@ -508,7 +570,7 @@ class FasterTTSClient(_BaseTTSClient):
with wave.open(str(output_path), "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
- wav_file.setframerate(config.FASTER_TTS_SAMPLE_RATE)
+ wav_file.setframerate(SAMPLE_RATE)
wav_file.writeframes(b"".join(pcm_parts))
logger.debug("Chunk %d generated (%d sub-chunks)", chunk_num, len(sub_chunks))