feat: add timestamped TTS logging, unify terminal+GUI output, fix Qwen3 hang
Three changes:
1. Timestamped logging — every TTS message now shows wall-clock time
and delta since last message:
[TTS 12:34:56.789 +3.2s] Synthesizing chunk 1/1 (42 chars)...
This goes to BOTH terminal and GUI (via on_progress callback),
eliminating the split where some info was terminal-only and some
GUI-only.
2. Unified output — controller._log() sends every message to tts_log()
(terminal with timestamps) AND on_progress (GUI status), so the
user sees the same info in both places.
3. Qwen3-TTS hang prevention — added max_new_tokens=2048 to both
generate_custom_voice() and generate_voice_clone() calls. The
Qwen3 model can enter infinite generation loops when it fails to
emit an EOS token; this caps the output and prevents the hang.
https://claude.ai/code/session_01965m1v3f1cK6gDV1JHioA8
+89 −32 · 2 files changed
-
src/tts_controller.py+22 −19 -
src/tts_provider.py+67 −13
@@ -19,6 +19,8 @@ from tts_provider import ( BaseTTSProvider, create_provider, get_available_backends, + tts_log, + tts_timer_reset, ) @@ -82,6 +84,12 @@ class TTSController: def is_playing(self) -> bool: return self._is_playing + def _log(self, msg: str): + """Log to terminal (with timestamp/delta) AND to the GUI callback.""" + tts_log(msg) + if self.on_progress: + self.on_progress(msg) + def set_reference_voice(self, audio_path: Optional[str]): """Set reference voice for voice cloning.""" if audio_path and not os.path.exists(audio_path): @@ -211,18 +219,13 @@ class TTSController: ) -> Optional[str]: """Worker function for synthesis (can run async).""" try: - if self.on_progress: - self.on_progress("Chunking text...") - chunks = self.chunk_text(text) - - if self.on_progress: - self.on_progress(f"Synthesizing {len(chunks)} chunk(s)...") + tts_timer_reset() + self._log(f"Synthesizing {len(chunks)} chunk(s) to file...") audio_segments = [] for i, chunk in enumerate(chunks): - if self.on_progress: - self.on_progress(f"Chunk {i+1}/{len(chunks)}: {chunk[:50]}...") + self._log(f"Chunk {i+1}/{len(chunks)} ({len(chunk)} chars)...") audio, sr = self.provider.synthesize( text=chunk, @@ -240,9 +243,7 @@ class TTSController: full_audio = audio_segments[0] output_path = self.output_dir / f"{output_filename}.{file_format}" - - if self.on_progress: - self.on_progress(f"Saving to {output_path}...") + self._log(f"Saving to {output_path}...") sf.write( str(output_path), @@ -251,6 +252,7 @@ class TTSController: format=file_format.upper(), ) + self._log(f"Saved {output_path}") if self.on_complete: self.on_complete(str(output_path)) @@ -258,10 +260,9 @@ class TTSController: except Exception as e: error_msg = f"TTS synthesis failed: {e}" + tts_log(error_msg) if self.on_error: self.on_error(error_msg) - else: - print(error_msg) return None def synthesize_to_array(self, text: str) -> Optional[tuple[np.ndarray, int]]: @@ -343,11 +344,12 @@ class TTSController: """Yield (audio_chunk, sample_rate) for each text chunk as it finishes.""" chunks = self.chunk_text(text) total = len(chunks) + tts_timer_reset() + self._log(f"Starting synthesis: {len(text)} chars, {total} chunk(s)") for i, chunk in enumerate(chunks): if self._playback_stop.is_set(): return - if self.on_progress: - self.on_progress(f"Synthesizing chunk {i + 1}/{total}...") + self._log(f"Synthesizing chunk {i + 1}/{total} ({len(chunk)} chars)...") audio, sr = self.provider.synthesize( text=chunk, reference_audio_path=self.reference_voice_path, @@ -356,6 +358,8 @@ class TTSController: cfg=self.cfg, speaker=self.speaker, ) + dur = len(audio) / sr + self._log(f"Chunk {i + 1}/{total} done ({dur:.1f}s audio)") yield audio, sr def _playback_loop(self): @@ -446,8 +450,7 @@ class TTSController: latency="low", ) stream.start() - if self.on_progress: - self.on_progress("Playing...") + self._log("Playing...") # write() blocks until the device has consumed the data, # so the next chunk synthesizes while this one finishes playing. @@ -470,15 +473,15 @@ class TTSController: if self.on_complete: self.on_complete(str(output_path)) + self._log("Playback complete") if self.on_progress: self.on_progress("") except Exception as e: error_msg = f"TTS playback error: {e}" + tts_log(error_msg) if self.on_error: self.on_error(error_msg) - else: - print(error_msg) # Clean up stream on error if stream is not None: try: @@ -8,6 +8,7 @@ Supports ChatterboxTTS and Qwen3-TTS with graceful fallback. import ctypes import glob import os +import time as _time import warnings from typing import Optional, Literal @@ -19,6 +20,41 @@ warnings.filterwarnings('ignore', category=FutureWarning) warnings.filterwarnings('ignore', category=UserWarning) +# --------------------------------------------------------------------------- +# Timestamped TTS logger — prints to terminal with wall-clock + delta +# --------------------------------------------------------------------------- + +class _TTSTimer: + """Lightweight logger that prefixes every message with a wall-clock + timestamp and the seconds elapsed since the previous log call.""" + + def __init__(self): + self._last = _time.monotonic() + + def log(self, msg: str) -> str: + """Print *msg* to terminal with ``[TTS HH:MM:SS.mmm +Δs]`` prefix. + + Returns *msg* unchanged so callers can forward it to the GUI. + """ + now_mono = _time.monotonic() + delta = now_mono - self._last + self._last = now_mono + now_wall = _time.time() + ts = _time.strftime("%H:%M:%S", _time.localtime(now_wall)) + ms = f".{int((now_wall % 1) * 1000):03d}" + print(f"[TTS {ts}{ms} +{delta:.1f}s] {msg}") + return msg + + def reset(self): + """Reset the delta clock (e.g. at the start of a new synthesis).""" + self._last = _time.monotonic() + + +_tts_timer = _TTSTimer() +tts_log = _tts_timer.log +tts_timer_reset = _tts_timer.reset + + def _fix_cudnn_library_path(): """Preload all cuDNN sub-libraries from the pip nvidia-cudnn package. @@ -197,7 +233,8 @@ class ChatterboxProvider(BaseTTSProvider): try: load_device = self.device - print(f"Loading ChatterboxTTS on {load_device}...") + tts_timer_reset() + tts_log(f"Loading ChatterboxTTS on {load_device}...") # Force eager attention — SDPA doesn't support output_attentions=True # which Chatterbox requires internally for voice reference processing. @@ -220,15 +257,15 @@ class ChatterboxProvider(BaseTTSProvider): self.model = self._load_model("cuda") except (RuntimeError, OSError) as cuda_err: # Retry 1: disable cuDNN entirely - print(f"CUDA error during model load: {cuda_err}") - print("Disabling cuDNN and retrying on CUDA...") + tts_log(f"CUDA error during model load: {cuda_err}") + tts_log("Disabling cuDNN and retrying on CUDA...") torch.backends.cudnn.enabled = False try: self.model = self._load_model("cuda") except (RuntimeError, OSError) as retry_err: # Retry 2: fall back to CPU - print(f"CUDA still failing: {retry_err}") - print("Falling back to CPU (model will run slower)...") + tts_log(f"CUDA still failing: {retry_err}") + tts_log("Falling back to CPU (model will run slower)...") torch.backends.cudnn.enabled = True self.model = self._load_model("cpu") self.device = "cpu" @@ -236,7 +273,7 @@ class ChatterboxProvider(BaseTTSProvider): self.model = self._load_model(load_device) self._initialized = True - print(f"ChatterboxTTS loaded on {self.device}") + tts_log(f"ChatterboxTTS loaded on {self.device}") except ImportError as e: raise ImportError( @@ -273,14 +310,14 @@ class ChatterboxProvider(BaseTTSProvider): cfg_weight=cfg, temperature=0.8, ) + tts_log(f"Chatterbox synthesize: {len(text)} chars \"{text[:60]}...\"") try: audio = self.model.generate(**generate_kwargs) except (RuntimeError, OSError) as e: err_str = str(e).lower() if "cudnn" in err_str or "cuda" in err_str: - # cuDNN/CUDA failed during inference — disable cuDNN and retry - print(f"CUDA error during synthesis: {e}") - print("Disabling cuDNN and retrying...") + tts_log(f"CUDA error during synthesis: {e}") + tts_log("Disabling cuDNN and retrying...") torch.backends.cudnn.enabled = False audio = self.model.generate(**generate_kwargs) else: @@ -291,6 +328,8 @@ class ChatterboxProvider(BaseTTSProvider): if len(audio.shape) > 1: audio = audio.flatten() + dur = len(audio) / self.sample_rate + tts_log(f"Chatterbox synthesis done: {len(audio)} samples, {dur:.1f}s audio") return audio, self.sample_rate @@ -359,7 +398,8 @@ class Qwen3TTSProvider(BaseTTSProvider): _sys.stdout, _sys.stderr = _old_stdout, _old_stderr model_name = self._get_model_name(clone=False) - print(f"Loading Qwen3-TTS ({model_name}) on {self.device}...") + tts_timer_reset() + tts_log(f"Loading Qwen3-TTS ({model_name}) on {self.device}...") compute_dtype = torch.bfloat16 if self.device == "cuda" else torch.float32 kwargs = {"device_map": f"{self.device}:0" if self.device == "cuda" else self.device} @@ -379,7 +419,7 @@ class Qwen3TTSProvider(BaseTTSProvider): self.model = Qwen3TTSModel.from_pretrained(model_name, **kwargs) self._initialized = True - print(f"Qwen3-TTS loaded on {self.device}") + tts_log(f"Qwen3-TTS loaded on {self.device}") except ImportError as e: raise ImportError(str(e)) from e @@ -403,7 +443,7 @@ class Qwen3TTSProvider(BaseTTSProvider): import flash_attn # noqa: F401 model_name = self._get_model_name(clone=True) - print(f"Loading Qwen3-TTS clone model ({model_name})...") + tts_log(f"Loading Qwen3-TTS clone model ({model_name})...") compute_dtype = torch.bfloat16 if self.device == "cuda" else torch.float32 kwargs = {"device_map": f"{self.device}:0" if self.device == "cuda" else self.device} @@ -411,7 +451,7 @@ class Qwen3TTSProvider(BaseTTSProvider): kwargs["attn_implementation"] = "flash_attention_2" self._clone_model = Qwen3TTSModel.from_pretrained(model_name, **kwargs) - print("Qwen3-TTS clone model loaded") + tts_log("Qwen3-TTS clone model loaded") except ImportError as e: raise ImportError( @@ -464,12 +504,17 @@ class Qwen3TTSProvider(BaseTTSProvider): if not speaker: speaker = _QWEN3_DEFAULT_SPEAKERS.get(language, self.default_speaker) + tts_log(f"Qwen3 synthesize (custom_voice, speaker={speaker}): " + f"{len(text)} chars \"{text[:60]}...\"") + wavs, sr = self.model.generate_custom_voice( text=text, language=language, speaker=speaker, instruct="", + max_new_tokens=2048, ) + tts_log(f"Qwen3 generate_custom_voice returned (sr={sr})") audio = wavs[0] if isinstance(audio, torch.Tensor): @@ -480,6 +525,8 @@ class Qwen3TTSProvider(BaseTTSProvider): # Resample to 24kHz for consistent output audio = self._resample(audio, sr, self.sample_rate) + dur = len(audio) / self.sample_rate + tts_log(f"Qwen3 synthesis done: {len(audio)} samples, {dur:.1f}s audio") return audio, self.sample_rate def _synthesize_clone( @@ -487,12 +534,17 @@ class Qwen3TTSProvider(BaseTTSProvider): ) -> tuple[np.ndarray, int]: self._ensure_clone_model() + tts_log(f"Qwen3 synthesize (voice_clone): " + f"{len(text)} chars \"{text[:60]}...\"") + wavs, sr = self._clone_model.generate_voice_clone( text=text, language=language, ref_audio=ref_audio_path, ref_text="", # Qwen3 can work without ref_text + max_new_tokens=2048, ) + tts_log(f"Qwen3 generate_voice_clone returned (sr={sr})") audio = wavs[0] if isinstance(audio, torch.Tensor): @@ -502,6 +554,8 @@ class Qwen3TTSProvider(BaseTTSProvider): audio = self._resample(audio, sr, self.sample_rate) + dur = len(audio) / self.sample_rate + tts_log(f"Qwen3 clone done: {len(audio)} samples, {dur:.1f}s audio") return audio, self.sample_rate def unload(self):