diff --git a/backend/backends/mlx_backend.py b/backend/backends/mlx_backend.py index c6157a40..1cb7571a 100644 --- a/backend/backends/mlx_backend.py +++ b/backend/backends/mlx_backend.py @@ -195,6 +195,8 @@ class MLXTTSBackend: logger.info("Generating audio for text: %s", text) + model_name = f"qwen-tts-{self._current_model_size}" + def _generate_sync(): """Run synchronous generation in thread pool.""" # MLX generate() returns a generator yielding GenerationResult objects @@ -220,36 +222,40 @@ class MLXTTSBackend: logger.warning("Regenerating without voice prompt.") ref_audio = None - # Check if model supports voice cloning via generate method - # MLX API may support ref_audio parameter directly - try: - # Try with voice cloning parameters if supported - if ref_audio: - # Check if generate accepts ref_audio parameter - import inspect + # Model is loaded → weights are on disk. Force offline so + # lazy tokenizer/config lookups inside mlx_audio don't hang + # when the user is disconnected (issue #462). + with force_offline_if_cached(True, model_name): + # Check if model supports voice cloning via generate method + # MLX API may support ref_audio parameter directly + try: + # Try with voice cloning parameters if supported + if ref_audio: + # Check if generate accepts ref_audio parameter + import inspect - sig = inspect.signature(self.model.generate) - if "ref_audio" in sig.parameters: - # Generate with voice cloning - for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang): - audio_chunks.append(np.array(result.audio)) - sample_rate = result.sample_rate + sig = inspect.signature(self.model.generate) + if "ref_audio" in sig.parameters: + # Generate with voice cloning + for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang): + audio_chunks.append(np.array(result.audio)) + sample_rate = result.sample_rate + else: + # Fallback: generate without voice cloning + for result in self.model.generate(text, lang_code=lang): + audio_chunks.append(np.array(result.audio)) + sample_rate = result.sample_rate else: - # Fallback: generate without voice cloning + # No voice prompt, generate normally for result in self.model.generate(text, lang_code=lang): audio_chunks.append(np.array(result.audio)) sample_rate = result.sample_rate - else: - # No voice prompt, generate normally + except Exception as e: + # If voice cloning fails, try without it + logger.warning("Voice cloning failed, generating without voice prompt: %s", e) for result in self.model.generate(text, lang_code=lang): audio_chunks.append(np.array(result.audio)) sample_rate = result.sample_rate - except Exception as e: - # If voice cloning fails, try without it - logger.warning("Voice cloning failed, generating without voice prompt: %s", e) - for result in self.model.generate(text, lang_code=lang): - audio_chunks.append(np.array(result.audio)) - sample_rate = result.sample_rate # Concatenate all chunks if audio_chunks: @@ -343,6 +349,8 @@ class MLXSTTBackend: """ await self.load_model_async(model_size) + progress_model_name = f"whisper-{self.model_size}" + def _transcribe_sync(): """Run synchronous transcription in thread pool.""" # MLX Whisper transcription using generate method @@ -351,7 +359,11 @@ class MLXSTTBackend: if language: decode_options["language"] = language - result = self.model.generate(str(audio_path), **decode_options) + # Model is loaded → weights are on disk. Force offline so + # lazy tokenizer/config lookups don't hang when the user is + # disconnected (issue #462). + with force_offline_if_cached(True, progress_model_name): + result = self.model.generate(str(audio_path), **decode_options) # Extract text from result if isinstance(result, str): diff --git a/backend/backends/pytorch_backend.py b/backend/backends/pytorch_backend.py index b43a943e..b014ec43 100644 --- a/backend/backends/pytorch_backend.py +++ b/backend/backends/pytorch_backend.py @@ -172,13 +172,19 @@ class PyTorchTTSBackend: # This shouldn't happen in practice, but handle it return {"prompt": cached_prompt}, True + model_name = f"qwen-tts-{self._current_model_size}" + def _create_prompt_sync(): """Run synchronous voice prompt creation in thread pool.""" - return self.model.create_voice_clone_prompt( - ref_audio=str(audio_path), - ref_text=reference_text, - x_vector_only_mode=False, - ) + # Model is loaded → weights are on disk. Force offline so + # lazy tokenizer/config lookups inside qwen_tts don't hang + # when the user is disconnected (issue #462). + with force_offline_if_cached(True, model_name): + return self.model.create_voice_clone_prompt( + ref_audio=str(audio_path), + ref_text=reference_text, + x_vector_only_mode=False, + ) # Run blocking operation in thread pool voice_prompt_items = await asyncio.to_thread(_create_prompt_sync) @@ -221,19 +227,24 @@ class PyTorchTTSBackend: # Load model await self.load_model_async(None) + model_name = f"qwen-tts-{self._current_model_size}" + def _generate_sync(): """Run synchronous generation in thread pool.""" # Set seed if provided if seed is not None: manual_seed(seed, self.device) - # Generate audio - this is the blocking operation - wavs, sample_rate = self.model.generate_voice_clone( - text=text, - voice_clone_prompt=voice_prompt, - language=LANGUAGE_CODE_TO_NAME.get(language, "auto"), - instruct=instruct, - ) + # Model is loaded → weights are on disk. Force offline so + # lazy tokenizer/config lookups inside qwen_tts don't hang + # when the user is disconnected (issue #462). + with force_offline_if_cached(True, model_name): + wavs, sample_rate = self.model.generate_voice_clone( + text=text, + voice_clone_prompt=voice_prompt, + language=LANGUAGE_CODE_TO_NAME.get(language, "auto"), + instruct=instruct, + ) return wavs[0], sample_rate # Run blocking inference in thread pool to avoid blocking event loop @@ -331,40 +342,46 @@ class PyTorchSTTBackend: """ await self.load_model_async(model_size) + progress_model_name = f"whisper-{self.model_size}" + def _transcribe_sync(): """Run synchronous transcription in thread pool.""" # Load audio audio, sr = load_audio(audio_path, sample_rate=16000) - # Process audio - inputs = self.processor( - audio, - sampling_rate=16000, - return_tensors="pt", - ) - inputs = inputs.to(self.device) - - # Generate transcription - # If language is provided, force it; otherwise let Whisper auto-detect - generate_kwargs = {} - if language: - forced_decoder_ids = self.processor.get_decoder_prompt_ids( - language=language, - task="transcribe", + # Model is loaded → weights are on disk. Force offline so + # `get_decoder_prompt_ids` and any lazy tokenizer lookups + # don't hang when the user is disconnected (issue #462). + with force_offline_if_cached(True, progress_model_name): + # Process audio + inputs = self.processor( + audio, + sampling_rate=16000, + return_tensors="pt", ) - generate_kwargs["forced_decoder_ids"] = forced_decoder_ids + inputs = inputs.to(self.device) - with torch.no_grad(): - predicted_ids = self.model.generate( - inputs["input_features"], - **generate_kwargs, - ) + # Generate transcription + # If language is provided, force it; otherwise let Whisper auto-detect + generate_kwargs = {} + if language: + forced_decoder_ids = self.processor.get_decoder_prompt_ids( + language=language, + task="transcribe", + ) + generate_kwargs["forced_decoder_ids"] = forced_decoder_ids - # Decode - transcription = self.processor.batch_decode( - predicted_ids, - skip_special_tokens=True, - )[0] + with torch.no_grad(): + predicted_ids = self.model.generate( + inputs["input_features"], + **generate_kwargs, + ) + + # Decode + transcription = self.processor.batch_decode( + predicted_ids, + skip_special_tokens=True, + )[0] return transcription.strip() diff --git a/backend/backends/qwen_custom_voice_backend.py b/backend/backends/qwen_custom_voice_backend.py index fbbf9f30..bad5b3e2 100644 --- a/backend/backends/qwen_custom_voice_backend.py +++ b/backend/backends/qwen_custom_voice_backend.py @@ -28,6 +28,7 @@ from .base import ( combine_voice_prompts as _combine_voice_prompts, model_load_progress, ) +from ..utils.hf_offline_patch import force_offline_if_cached logger = logging.getLogger(__name__) @@ -104,18 +105,19 @@ class QwenCustomVoiceBackend: model_path = self._get_model_path(model_size) logger.info("Loading Qwen CustomVoice %s on %s...", model_size, self.device) - if self.device == "cpu": - self.model = Qwen3TTSModel.from_pretrained( - model_path, - torch_dtype=torch.float32, - low_cpu_mem_usage=False, - ) - else: - self.model = Qwen3TTSModel.from_pretrained( - model_path, - device_map=self.device, - torch_dtype=torch.bfloat16, - ) + with force_offline_if_cached(is_cached, model_name): + if self.device == "cpu": + self.model = Qwen3TTSModel.from_pretrained( + model_path, + torch_dtype=torch.float32, + low_cpu_mem_usage=False, + ) + else: + self.model = Qwen3TTSModel.from_pretrained( + model_path, + device_map=self.device, + torch_dtype=torch.bfloat16, + ) self._current_model_size = model_size self.model_size = model_size @@ -184,6 +186,7 @@ class QwenCustomVoiceBackend: await self.load_model_async(None) speaker = voice_prompt.get("preset_voice_id") or QWEN_CV_DEFAULT_SPEAKER + model_name = f"qwen-custom-voice-{self._current_model_size}" def _generate_sync(): if seed is not None: @@ -203,7 +206,11 @@ class QwenCustomVoiceBackend: if instruct: kwargs["instruct"] = instruct - wavs, sample_rate = self.model.generate_custom_voice(**kwargs) + # Model is loaded → weights are on disk. Force offline so + # lazy tokenizer/config lookups inside qwen_tts don't hang + # when the user is disconnected (issue #462). + with force_offline_if_cached(True, model_name): + wavs, sample_rate = self.model.generate_custom_voice(**kwargs) return wavs[0], sample_rate audio, sample_rate = await asyncio.to_thread(_generate_sync)