From 0047352df164b6bb48a2bfbf9c38378cfea05996 Mon Sep 17 00:00:00 2001 From: Jamie Pine <32987599+jamiepine@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:25:28 -0700 Subject: [PATCH] fix(offline): remove inference-path HF_HUB_OFFLINE guards (#524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.4.3 wrapped every inference body (`generate`, `transcribe`, `create_voice_clone_prompt`) with `force_offline_if_cached(True, …)` to prevent lazy HF lookups from hanging when the network drops mid-inference (#462). That trade broke online users: the guard flips `huggingface_hub.constants.HF_HUB_OFFLINE` globally, so any legitimate metadata call the library makes during generation (e.g. revision resolution via `HfApi().model_info`) now raises: Cannot reach https://huggingface.co/api/models/Qwen/Qwen3-TTS-…: offline mode is enabled. Hit by multiple users on 0.4.3 within hours of release. The offline blast radius is much larger than the original hang it fixed. This reverts the inference-path guards. Load-path guards stay — those worked fine in 0.4.2 and aren't the source of the regression. The `force_offline_if_cached` helper itself is unchanged; tests still pass. The #462 hang (network dropping mid-inference) remains unaddressed by this commit and will need a targeted fix that doesn't flip a global flag — most likely per-call timeouts or library-specific `local_files_only` arguments, not a process-wide env mutation. --- backend/backends/mlx_backend.py | 63 +++++------ backend/backends/pytorch_backend.py | 102 ++++++++---------- backend/backends/qwen_custom_voice_backend.py | 11 +- 3 files changed, 80 insertions(+), 96 deletions(-) diff --git a/backend/backends/mlx_backend.py b/backend/backends/mlx_backend.py index ab54f536..aba18856 100644 --- a/backend/backends/mlx_backend.py +++ b/backend/backends/mlx_backend.py @@ -193,8 +193,6 @@ 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,40 +218,38 @@ class MLXTTSBackend: logger.warning("Regenerating without voice prompt.") ref_audio = None - # 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 + # Inference runs with the process's default HF_HUB_OFFLINE + # state. Forcing offline here (previously used to avoid lazy + # mlx_audio lookups hanging when the network drops mid-inference, + # issue #462) regressed online users because libraries make + # legitimate metadata calls during generation. + try: + 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 - 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 + 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: - # No voice prompt, generate normally + # 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 - except Exception as e: - # If voice cloning fails, try without it - logger.warning("Voice cloning failed, generating without voice prompt: %s", e) + else: + # 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 + 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: @@ -347,8 +343,6 @@ 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 @@ -357,11 +351,10 @@ class MLXSTTBackend: if language: decode_options["language"] = language - # 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) + # Inference runs with the process's default HF_HUB_OFFLINE + # state — see the comment in MLXTTSBackend.generate for the + # regression this revert fixes (issue #462). + 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 ec66d5d5..19210cff 100644 --- a/backend/backends/pytorch_backend.py +++ b/backend/backends/pytorch_backend.py @@ -172,19 +172,17 @@ 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.""" - # 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, - ) + # Inference runs with the process's default HF_HUB_OFFLINE + # state. Forcing offline here (issue #462) regressed online + # users whose libraries issue legitimate metadata lookups + # during voice-prompt creation. + 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) @@ -227,24 +225,20 @@ 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) - # 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, - ) + # See _create_prompt_sync comment — inference runs with the + # process's default HF_HUB_OFFLINE state (issue #462). + 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 @@ -342,46 +336,44 @@ 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) - # 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", + # Inference runs with the process's default HF_HUB_OFFLINE + # state — forcing offline here (issue #462) broke online users + # whose `get_decoder_prompt_ids` / tokenizer calls issue + # legitimate metadata lookups. + # 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", ) - inputs = inputs.to(self.device) + generate_kwargs["forced_decoder_ids"] = forced_decoder_ids - # 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 + with torch.no_grad(): + predicted_ids = self.model.generate( + inputs["input_features"], + **generate_kwargs, + ) - 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] + # 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 bad5b3e2..518f8926 100644 --- a/backend/backends/qwen_custom_voice_backend.py +++ b/backend/backends/qwen_custom_voice_backend.py @@ -186,7 +186,6 @@ 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: @@ -206,11 +205,11 @@ class QwenCustomVoiceBackend: if instruct: kwargs["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_custom_voice(**kwargs) + # Inference runs with the process's default HF_HUB_OFFLINE + # state. Forcing offline here (issue #462) regressed online + # users whose libraries issue legitimate metadata lookups + # during generation. + wavs, sample_rate = self.model.generate_custom_voice(**kwargs) return wavs[0], sample_rate audio, sample_rate = await asyncio.to_thread(_generate_sync)