diff options
| author | historia <historiavg@proton.me> | 2026-08-19 03:55:13 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-19 03:55:13 -0400 |
| commit | 87e5216cd287f411b2ffab04dbc435f48c1d4aae (patch) | |
| tree | d24346d6793233a2af6305f8463ae17765e00d44 /converter/tts.py | |
| parent | f1f8e899c46de80d7f9fbb8f0b53983e18167bd2 (diff) | |
| download | tts-audiobook-generator-87e5216cd287f411b2ffab04dbc435f48c1d4aae.tar.gz | |
refactor: config.py simplified
Diffstat (limited to 'converter/tts.py')
| -rw-r--r-- | converter/tts.py | 118 |
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)) |
