mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6ac8ceba7 | ||
|
|
a179b826cd | ||
|
|
a641ffc919 | ||
|
|
f267cafb80 | ||
|
|
83cc172f71 | ||
|
|
d7285bf23a | ||
|
|
13924741a7 | ||
|
|
1ca0756dc1 | ||
|
|
cf17d94441 |
@@ -1,6 +1,5 @@
|
|||||||
import { useRouterState } from '@tanstack/react-router';
|
import { useRouterState } from '@tanstack/react-router';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
import { AudioKeepAlive } from '@/components/AudioPlayer/AudioKeepAlive';
|
|
||||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||||
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
@@ -27,7 +26,6 @@ export function AppFrame({ children }: AppFrameProps) {
|
|||||||
className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}
|
className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}
|
||||||
>
|
>
|
||||||
<TitleBarDragRegion />
|
<TitleBarDragRegion />
|
||||||
<AudioKeepAlive />
|
|
||||||
{children}
|
{children}
|
||||||
{showTrackEditor ? (
|
{showTrackEditor ? (
|
||||||
<StoryTrackEditor storyId={story.id} items={story.items} />
|
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { debug } from '@/lib/utils/debug';
|
|
||||||
|
|
||||||
// WKWebView tears down the app's CoreAudio output when idle for long enough,
|
|
||||||
// and a JS-level reload (cmd+R) does NOT restore it — only relaunching the
|
|
||||||
// Tauri app does. Keeping a silent <audio> element looping forever prevents
|
|
||||||
// the OS audio session from ever going dormant.
|
|
||||||
//
|
|
||||||
// Real silence (zero PCM samples) at full volume is preferred over a muted
|
|
||||||
// element: browsers/WebKit can optimize muted media away, which defeats the
|
|
||||||
// purpose of holding the session open.
|
|
||||||
|
|
||||||
function buildSilentWavUrl(seconds = 1, sampleRate = 8000): string {
|
|
||||||
const numSamples = seconds * sampleRate;
|
|
||||||
const bytes = 44 + numSamples * 2;
|
|
||||||
const buffer = new ArrayBuffer(bytes);
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
const write = (offset: number, str: string) => {
|
|
||||||
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
|
|
||||||
};
|
|
||||||
write(0, 'RIFF');
|
|
||||||
view.setUint32(4, bytes - 8, true);
|
|
||||||
write(8, 'WAVE');
|
|
||||||
write(12, 'fmt ');
|
|
||||||
view.setUint32(16, 16, true);
|
|
||||||
view.setUint16(20, 1, true);
|
|
||||||
view.setUint16(22, 1, true);
|
|
||||||
view.setUint32(24, sampleRate, true);
|
|
||||||
view.setUint32(28, sampleRate * 2, true);
|
|
||||||
view.setUint16(32, 2, true);
|
|
||||||
view.setUint16(34, 16, true);
|
|
||||||
write(36, 'data');
|
|
||||||
view.setUint32(40, numSamples * 2, true);
|
|
||||||
return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AudioKeepAlive() {
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = buildSilentWavUrl(1, 8000);
|
|
||||||
const el = new Audio(url);
|
|
||||||
el.loop = true;
|
|
||||||
el.volume = 1;
|
|
||||||
el.preload = 'auto';
|
|
||||||
audioRef.current = el;
|
|
||||||
|
|
||||||
const tryPlay = () => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
if (!audioRef.current.paused) return;
|
|
||||||
audioRef.current.play().catch((err) => {
|
|
||||||
debug.log('[AudioKeepAlive] play blocked (will retry on next gesture):', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
tryPlay();
|
|
||||||
|
|
||||||
// Autoplay may be blocked until first user interaction — re-attempt then.
|
|
||||||
const onGesture = () => tryPlay();
|
|
||||||
window.addEventListener('pointerdown', onGesture, { once: false });
|
|
||||||
window.addEventListener('keydown', onGesture, { once: false });
|
|
||||||
|
|
||||||
// If the webview ever pauses the element on background, resume on return.
|
|
||||||
const onWake = () => {
|
|
||||||
if (!document.hidden) tryPlay();
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', onWake);
|
|
||||||
window.addEventListener('focus', onWake);
|
|
||||||
window.addEventListener('pageshow', onWake);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('pointerdown', onGesture);
|
|
||||||
window.removeEventListener('keydown', onGesture);
|
|
||||||
document.removeEventListener('visibilitychange', onWake);
|
|
||||||
window.removeEventListener('focus', onWake);
|
|
||||||
window.removeEventListener('pageshow', onWake);
|
|
||||||
el.pause();
|
|
||||||
el.src = '';
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
audioRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -195,8 +195,6 @@ class MLXTTSBackend:
|
|||||||
|
|
||||||
logger.info("Generating audio for text: %s", text)
|
logger.info("Generating audio for text: %s", text)
|
||||||
|
|
||||||
model_name = f"qwen-tts-{self._current_model_size}"
|
|
||||||
|
|
||||||
def _generate_sync():
|
def _generate_sync():
|
||||||
"""Run synchronous generation in thread pool."""
|
"""Run synchronous generation in thread pool."""
|
||||||
# MLX generate() returns a generator yielding GenerationResult objects
|
# MLX generate() returns a generator yielding GenerationResult objects
|
||||||
@@ -222,40 +220,36 @@ class MLXTTSBackend:
|
|||||||
logger.warning("Regenerating without voice prompt.")
|
logger.warning("Regenerating without voice prompt.")
|
||||||
ref_audio = None
|
ref_audio = None
|
||||||
|
|
||||||
# Model is loaded → weights are on disk. Force offline so
|
# Check if model supports voice cloning via generate method
|
||||||
# lazy tokenizer/config lookups inside mlx_audio don't hang
|
# MLX API may support ref_audio parameter directly
|
||||||
# when the user is disconnected (issue #462).
|
try:
|
||||||
with force_offline_if_cached(True, model_name):
|
# Try with voice cloning parameters if supported
|
||||||
# Check if model supports voice cloning via generate method
|
if ref_audio:
|
||||||
# MLX API may support ref_audio parameter directly
|
# Check if generate accepts ref_audio parameter
|
||||||
try:
|
import inspect
|
||||||
# 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)
|
sig = inspect.signature(self.model.generate)
|
||||||
if "ref_audio" in sig.parameters:
|
if "ref_audio" in sig.parameters:
|
||||||
# Generate with voice cloning
|
# Generate with voice cloning
|
||||||
for result in self.model.generate(text, ref_audio=ref_audio, ref_text=ref_text, lang_code=lang):
|
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))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
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:
|
else:
|
||||||
# No voice prompt, generate normally
|
# Fallback: generate without voice cloning
|
||||||
for result in self.model.generate(text, lang_code=lang):
|
for result in self.model.generate(text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
sample_rate = result.sample_rate
|
||||||
except Exception as e:
|
else:
|
||||||
# If voice cloning fails, try without it
|
# No voice prompt, generate normally
|
||||||
logger.warning("Voice cloning failed, generating without voice prompt: %s", e)
|
|
||||||
for result in self.model.generate(text, lang_code=lang):
|
for result in self.model.generate(text, lang_code=lang):
|
||||||
audio_chunks.append(np.array(result.audio))
|
audio_chunks.append(np.array(result.audio))
|
||||||
sample_rate = result.sample_rate
|
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
|
# Concatenate all chunks
|
||||||
if audio_chunks:
|
if audio_chunks:
|
||||||
@@ -349,8 +343,6 @@ class MLXSTTBackend:
|
|||||||
"""
|
"""
|
||||||
await self.load_model_async(model_size)
|
await self.load_model_async(model_size)
|
||||||
|
|
||||||
progress_model_name = f"whisper-{self.model_size}"
|
|
||||||
|
|
||||||
def _transcribe_sync():
|
def _transcribe_sync():
|
||||||
"""Run synchronous transcription in thread pool."""
|
"""Run synchronous transcription in thread pool."""
|
||||||
# MLX Whisper transcription using generate method
|
# MLX Whisper transcription using generate method
|
||||||
@@ -359,11 +351,7 @@ class MLXSTTBackend:
|
|||||||
if language:
|
if language:
|
||||||
decode_options["language"] = language
|
decode_options["language"] = language
|
||||||
|
|
||||||
# Model is loaded → weights are on disk. Force offline so
|
result = self.model.generate(str(audio_path), **decode_options)
|
||||||
# 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
|
# Extract text from result
|
||||||
if isinstance(result, str):
|
if isinstance(result, str):
|
||||||
|
|||||||
@@ -172,19 +172,13 @@ class PyTorchTTSBackend:
|
|||||||
# This shouldn't happen in practice, but handle it
|
# This shouldn't happen in practice, but handle it
|
||||||
return {"prompt": cached_prompt}, True
|
return {"prompt": cached_prompt}, True
|
||||||
|
|
||||||
model_name = f"qwen-tts-{self._current_model_size}"
|
|
||||||
|
|
||||||
def _create_prompt_sync():
|
def _create_prompt_sync():
|
||||||
"""Run synchronous voice prompt creation in thread pool."""
|
"""Run synchronous voice prompt creation in thread pool."""
|
||||||
# Model is loaded → weights are on disk. Force offline so
|
return self.model.create_voice_clone_prompt(
|
||||||
# lazy tokenizer/config lookups inside qwen_tts don't hang
|
ref_audio=str(audio_path),
|
||||||
# when the user is disconnected (issue #462).
|
ref_text=reference_text,
|
||||||
with force_offline_if_cached(True, model_name):
|
x_vector_only_mode=False,
|
||||||
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
|
# Run blocking operation in thread pool
|
||||||
voice_prompt_items = await asyncio.to_thread(_create_prompt_sync)
|
voice_prompt_items = await asyncio.to_thread(_create_prompt_sync)
|
||||||
@@ -227,24 +221,19 @@ class PyTorchTTSBackend:
|
|||||||
# Load model
|
# Load model
|
||||||
await self.load_model_async(None)
|
await self.load_model_async(None)
|
||||||
|
|
||||||
model_name = f"qwen-tts-{self._current_model_size}"
|
|
||||||
|
|
||||||
def _generate_sync():
|
def _generate_sync():
|
||||||
"""Run synchronous generation in thread pool."""
|
"""Run synchronous generation in thread pool."""
|
||||||
# Set seed if provided
|
# Set seed if provided
|
||||||
if seed is not None:
|
if seed is not None:
|
||||||
manual_seed(seed, self.device)
|
manual_seed(seed, self.device)
|
||||||
|
|
||||||
# Model is loaded → weights are on disk. Force offline so
|
# Generate audio - this is the blocking operation
|
||||||
# lazy tokenizer/config lookups inside qwen_tts don't hang
|
wavs, sample_rate = self.model.generate_voice_clone(
|
||||||
# when the user is disconnected (issue #462).
|
text=text,
|
||||||
with force_offline_if_cached(True, model_name):
|
voice_clone_prompt=voice_prompt,
|
||||||
wavs, sample_rate = self.model.generate_voice_clone(
|
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
|
||||||
text=text,
|
instruct=instruct,
|
||||||
voice_clone_prompt=voice_prompt,
|
)
|
||||||
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
|
|
||||||
instruct=instruct,
|
|
||||||
)
|
|
||||||
return wavs[0], sample_rate
|
return wavs[0], sample_rate
|
||||||
|
|
||||||
# Run blocking inference in thread pool to avoid blocking event loop
|
# Run blocking inference in thread pool to avoid blocking event loop
|
||||||
@@ -342,46 +331,40 @@ class PyTorchSTTBackend:
|
|||||||
"""
|
"""
|
||||||
await self.load_model_async(model_size)
|
await self.load_model_async(model_size)
|
||||||
|
|
||||||
progress_model_name = f"whisper-{self.model_size}"
|
|
||||||
|
|
||||||
def _transcribe_sync():
|
def _transcribe_sync():
|
||||||
"""Run synchronous transcription in thread pool."""
|
"""Run synchronous transcription in thread pool."""
|
||||||
# Load audio
|
# Load audio
|
||||||
audio, _sr = load_audio(audio_path, sample_rate=16000)
|
audio, sr = load_audio(audio_path, sample_rate=16000)
|
||||||
|
|
||||||
# Model is loaded → weights are on disk. Force offline so
|
# Process audio
|
||||||
# `get_decoder_prompt_ids` and any lazy tokenizer lookups
|
inputs = self.processor(
|
||||||
# don't hang when the user is disconnected (issue #462).
|
audio,
|
||||||
with force_offline_if_cached(True, progress_model_name):
|
sampling_rate=16000,
|
||||||
# Process audio
|
return_tensors="pt",
|
||||||
inputs = self.processor(
|
)
|
||||||
audio,
|
inputs = inputs.to(self.device)
|
||||||
sampling_rate=16000,
|
|
||||||
return_tensors="pt",
|
# 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
|
with torch.no_grad():
|
||||||
# If language is provided, force it; otherwise let Whisper auto-detect
|
predicted_ids = self.model.generate(
|
||||||
generate_kwargs = {}
|
inputs["input_features"],
|
||||||
if language:
|
**generate_kwargs,
|
||||||
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():
|
# Decode
|
||||||
predicted_ids = self.model.generate(
|
transcription = self.processor.batch_decode(
|
||||||
inputs["input_features"],
|
predicted_ids,
|
||||||
**generate_kwargs,
|
skip_special_tokens=True,
|
||||||
)
|
)[0]
|
||||||
|
|
||||||
# Decode
|
|
||||||
transcription = self.processor.batch_decode(
|
|
||||||
predicted_ids,
|
|
||||||
skip_special_tokens=True,
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
return transcription.strip()
|
return transcription.strip()
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from .base import (
|
|||||||
combine_voice_prompts as _combine_voice_prompts,
|
combine_voice_prompts as _combine_voice_prompts,
|
||||||
model_load_progress,
|
model_load_progress,
|
||||||
)
|
)
|
||||||
from ..utils.hf_offline_patch import force_offline_if_cached
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -105,19 +104,18 @@ class QwenCustomVoiceBackend:
|
|||||||
model_path = self._get_model_path(model_size)
|
model_path = self._get_model_path(model_size)
|
||||||
logger.info("Loading Qwen CustomVoice %s on %s...", model_size, self.device)
|
logger.info("Loading Qwen CustomVoice %s on %s...", model_size, self.device)
|
||||||
|
|
||||||
with force_offline_if_cached(is_cached, model_name):
|
if self.device == "cpu":
|
||||||
if self.device == "cpu":
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
self.model = Qwen3TTSModel.from_pretrained(
|
model_path,
|
||||||
model_path,
|
torch_dtype=torch.float32,
|
||||||
torch_dtype=torch.float32,
|
low_cpu_mem_usage=False,
|
||||||
low_cpu_mem_usage=False,
|
)
|
||||||
)
|
else:
|
||||||
else:
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
self.model = Qwen3TTSModel.from_pretrained(
|
model_path,
|
||||||
model_path,
|
device_map=self.device,
|
||||||
device_map=self.device,
|
torch_dtype=torch.bfloat16,
|
||||||
torch_dtype=torch.bfloat16,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
self._current_model_size = model_size
|
self._current_model_size = model_size
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
@@ -186,7 +184,6 @@ class QwenCustomVoiceBackend:
|
|||||||
await self.load_model_async(None)
|
await self.load_model_async(None)
|
||||||
|
|
||||||
speaker = voice_prompt.get("preset_voice_id") or QWEN_CV_DEFAULT_SPEAKER
|
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():
|
def _generate_sync():
|
||||||
if seed is not None:
|
if seed is not None:
|
||||||
@@ -206,11 +203,7 @@ class QwenCustomVoiceBackend:
|
|||||||
if instruct:
|
if instruct:
|
||||||
kwargs["instruct"] = instruct
|
kwargs["instruct"] = instruct
|
||||||
|
|
||||||
# Model is loaded → weights are on disk. Force offline so
|
wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
|
||||||
# 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
|
return wavs[0], sample_rate
|
||||||
|
|
||||||
audio, sample_rate = await asyncio.to_thread(_generate_sync)
|
audio, sample_rate = await asyncio.to_thread(_generate_sync)
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
"""
|
|
||||||
Unit tests for the ``force_offline_if_cached`` helper.
|
|
||||||
|
|
||||||
Verifies that the helper mutates the cached module constants in
|
|
||||||
``huggingface_hub.constants`` and ``transformers.utils.hub`` — not just
|
|
||||||
``os.environ`` — and that concurrent users are refcount-coordinated so
|
|
||||||
one thread's exit can't strip another thread's offline protection.
|
|
||||||
|
|
||||||
NOTE: These tests mutate process-global state in ``huggingface_hub.constants``
|
|
||||||
and ``transformers.utils.hub``. They are not safe under cross-process
|
|
||||||
parallelism (e.g. ``pytest-xdist`` with ``--dist=loadfile``/``loadscope``);
|
|
||||||
run this file serially.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from utils.hf_offline_patch import force_offline_if_cached # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _hf_const():
|
|
||||||
import huggingface_hub.constants as hf_const
|
|
||||||
|
|
||||||
return hf_const
|
|
||||||
|
|
||||||
|
|
||||||
def _tf_hub():
|
|
||||||
import transformers.utils.hub as tf_hub
|
|
||||||
|
|
||||||
return tf_hub
|
|
||||||
|
|
||||||
|
|
||||||
def test_mutates_cached_huggingface_hub_constant():
|
|
||||||
original = _hf_const().HF_HUB_OFFLINE
|
|
||||||
with force_offline_if_cached(True, "t"):
|
|
||||||
assert _hf_const().HF_HUB_OFFLINE is True
|
|
||||||
assert original == _hf_const().HF_HUB_OFFLINE
|
|
||||||
|
|
||||||
|
|
||||||
def test_mutates_cached_transformers_constant():
|
|
||||||
original = _tf_hub()._is_offline_mode
|
|
||||||
with force_offline_if_cached(True, "t"):
|
|
||||||
assert _tf_hub()._is_offline_mode is True
|
|
||||||
assert original == _tf_hub()._is_offline_mode
|
|
||||||
|
|
||||||
|
|
||||||
def test_sets_env_variable():
|
|
||||||
original = os.environ.get("HF_HUB_OFFLINE")
|
|
||||||
with force_offline_if_cached(True, "t"):
|
|
||||||
assert "1" == os.environ.get("HF_HUB_OFFLINE")
|
|
||||||
assert original == os.environ.get("HF_HUB_OFFLINE")
|
|
||||||
|
|
||||||
|
|
||||||
def test_noop_when_not_cached():
|
|
||||||
before = _hf_const().HF_HUB_OFFLINE
|
|
||||||
with force_offline_if_cached(False, "t"):
|
|
||||||
assert before == _hf_const().HF_HUB_OFFLINE
|
|
||||||
|
|
||||||
|
|
||||||
def test_nested_contexts_respect_refcount():
|
|
||||||
original = _hf_const().HF_HUB_OFFLINE
|
|
||||||
with force_offline_if_cached(True, "outer"):
|
|
||||||
assert _hf_const().HF_HUB_OFFLINE is True
|
|
||||||
with force_offline_if_cached(True, "inner"):
|
|
||||||
assert _hf_const().HF_HUB_OFFLINE is True
|
|
||||||
# inner exit must not restore while outer is still active
|
|
||||||
assert _hf_const().HF_HUB_OFFLINE is True
|
|
||||||
assert original == _hf_const().HF_HUB_OFFLINE
|
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_threads_share_offline_window():
|
|
||||||
"""A slow thread must keep seeing offline mode even if a peer exits first."""
|
|
||||||
original = _hf_const().HF_HUB_OFFLINE
|
|
||||||
observations: list[bool] = []
|
|
||||||
errors: list[Exception] = []
|
|
||||||
barrier = threading.Barrier(2)
|
|
||||||
fast_exited = threading.Event()
|
|
||||||
|
|
||||||
def slow():
|
|
||||||
try:
|
|
||||||
with force_offline_if_cached(True, "slow"):
|
|
||||||
barrier.wait(timeout=5)
|
|
||||||
assert fast_exited.wait(timeout=5), "fast thread did not exit"
|
|
||||||
observations.append(_hf_const().HF_HUB_OFFLINE)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
def fast():
|
|
||||||
try:
|
|
||||||
with force_offline_if_cached(True, "fast"):
|
|
||||||
barrier.wait(timeout=5)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
errors.append(exc)
|
|
||||||
finally:
|
|
||||||
fast_exited.set()
|
|
||||||
|
|
||||||
t_slow = threading.Thread(target=slow)
|
|
||||||
t_fast = threading.Thread(target=fast)
|
|
||||||
t_slow.start()
|
|
||||||
t_fast.start()
|
|
||||||
t_slow.join(timeout=5)
|
|
||||||
t_fast.join(timeout=5)
|
|
||||||
|
|
||||||
assert not t_slow.is_alive(), "slow thread did not finish"
|
|
||||||
assert not t_fast.is_alive(), "fast thread did not finish"
|
|
||||||
assert not errors, errors
|
|
||||||
assert observations == [True], "slow thread lost offline protection"
|
|
||||||
assert original == _hf_const().HF_HUB_OFFLINE
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
@@ -6,7 +6,6 @@ are already downloaded. Must be imported BEFORE mlx_audio.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
@@ -14,33 +13,13 @@ from typing import Optional, Union
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# huggingface_hub reads ``HF_HUB_OFFLINE`` once at import time into
|
|
||||||
# ``huggingface_hub.constants.HF_HUB_OFFLINE``; transformers mirrors that into
|
|
||||||
# ``transformers.utils.hub._is_offline_mode`` at *its* import time. Toggling
|
|
||||||
# ``os.environ`` after either module is imported does not flip those cached
|
|
||||||
# bools, and the hot paths (``_http._default_backend_factory``,
|
|
||||||
# ``transformers.utils.hub.is_offline_mode``) read the bools — not the env.
|
|
||||||
# We mutate the cached constants directly, guarded by a refcount so
|
|
||||||
# concurrent inference threads share a single offline window safely.
|
|
||||||
|
|
||||||
_offline_lock = threading.RLock()
|
|
||||||
_offline_refcount = 0
|
|
||||||
_saved_env: Optional[str] = None
|
|
||||||
_saved_hf_const: Optional[bool] = None
|
|
||||||
_saved_transformers_const: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def force_offline_if_cached(is_cached: bool, model_label: str = ""):
|
def force_offline_if_cached(is_cached: bool, model_label: str = ""):
|
||||||
"""Force offline mode for the duration of a cached-model operation.
|
"""Context manager that sets ``HF_HUB_OFFLINE=1`` while loading a cached model.
|
||||||
|
|
||||||
Flips ``HF_HUB_OFFLINE`` in the process env **and** in the cached bools
|
|
||||||
inside ``huggingface_hub.constants`` / ``transformers.utils.hub`` so HTTP
|
|
||||||
adapters and offline-mode checks actually see the change. Uses a refcount
|
|
||||||
so multiple concurrent inference threads share a single offline window
|
|
||||||
and the last one to exit restores state.
|
|
||||||
|
|
||||||
If *is_cached* is ``False`` the block runs normally (network allowed).
|
If *is_cached* is ``False`` the block runs normally (network allowed).
|
||||||
|
If the offline load raises an error containing "offline" we automatically
|
||||||
|
retry with network access so a partially-cached model still works.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
is_cached: Whether the model weights are already on disk.
|
is_cached: Whether the model weights are already on disk.
|
||||||
@@ -50,96 +29,34 @@ def force_offline_if_cached(is_cached: bool, model_label: str = ""):
|
|||||||
yield
|
yield
|
||||||
return
|
return
|
||||||
|
|
||||||
global _offline_refcount, _saved_env, _saved_hf_const, _saved_transformers_const
|
original_value = os.environ.get("HF_HUB_OFFLINE")
|
||||||
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
with _offline_lock:
|
logger.info(
|
||||||
if _offline_refcount == 0:
|
"[offline-guard] %s is cached — forcing HF_HUB_OFFLINE=1",
|
||||||
# Snapshot prior state, apply new state, roll back on *any*
|
model_label or "model",
|
||||||
# failure. Catching only ImportError here would let a partially
|
)
|
||||||
# broken install (RuntimeError, AttributeError from a half-init
|
|
||||||
# module, etc.) leave the cached HF constants mutated without
|
|
||||||
# bumping the refcount — a persistent offline leak that outlives
|
|
||||||
# the process and is miserable to debug.
|
|
||||||
prev_env = os.environ.get("HF_HUB_OFFLINE")
|
|
||||||
prev_hf: Optional[bool] = None
|
|
||||||
prev_tf: Optional[bool] = None
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
import huggingface_hub.constants as hf_const
|
|
||||||
|
|
||||||
prev_hf = hf_const.HF_HUB_OFFLINE
|
|
||||||
hf_const.HF_HUB_OFFLINE = True
|
|
||||||
except ImportError:
|
|
||||||
prev_hf = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
import transformers.utils.hub as tf_hub
|
|
||||||
|
|
||||||
prev_tf = tf_hub._is_offline_mode
|
|
||||||
tf_hub._is_offline_mode = True
|
|
||||||
except ImportError:
|
|
||||||
prev_tf = None
|
|
||||||
|
|
||||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
||||||
except BaseException:
|
|
||||||
# Roll back whatever we already changed, then re-raise so
|
|
||||||
# the caller sees the real failure.
|
|
||||||
if prev_hf is not None:
|
|
||||||
try:
|
|
||||||
import huggingface_hub.constants as hf_const
|
|
||||||
|
|
||||||
hf_const.HF_HUB_OFFLINE = prev_hf
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
if prev_tf is not None:
|
|
||||||
try:
|
|
||||||
import transformers.utils.hub as tf_hub
|
|
||||||
|
|
||||||
tf_hub._is_offline_mode = prev_tf
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
if prev_env is not None:
|
|
||||||
os.environ["HF_HUB_OFFLINE"] = prev_env
|
|
||||||
else:
|
|
||||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
|
||||||
raise
|
|
||||||
|
|
||||||
_saved_env = prev_env
|
|
||||||
_saved_hf_const = prev_hf
|
|
||||||
_saved_transformers_const = prev_tf
|
|
||||||
logger.info(
|
|
||||||
"[offline-guard] %s is cached — forcing offline mode",
|
|
||||||
model_label or "model",
|
|
||||||
)
|
|
||||||
_offline_refcount += 1
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
|
except Exception as exc:
|
||||||
|
if "offline" in str(exc).lower():
|
||||||
|
logger.warning(
|
||||||
|
"[offline-guard] Offline load failed for %s, retrying with network: %s",
|
||||||
|
model_label or "model",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
# Restore original env and retry — caller must wrap the load
|
||||||
|
# inside force_offline_if_cached so retrying here isn't possible.
|
||||||
|
# Instead, propagate a flag via the exception so the caller can
|
||||||
|
# decide. For simplicity we just let it fall through to the
|
||||||
|
# finally block and re-raise.
|
||||||
|
raise
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
with _offline_lock:
|
if original_value is not None:
|
||||||
_offline_refcount -= 1
|
os.environ["HF_HUB_OFFLINE"] = original_value
|
||||||
if _offline_refcount == 0:
|
else:
|
||||||
if _saved_env is not None:
|
os.environ.pop("HF_HUB_OFFLINE", None)
|
||||||
os.environ["HF_HUB_OFFLINE"] = _saved_env
|
|
||||||
else:
|
|
||||||
os.environ.pop("HF_HUB_OFFLINE", None)
|
|
||||||
if _saved_hf_const is not None:
|
|
||||||
try:
|
|
||||||
import huggingface_hub.constants as hf_const
|
|
||||||
|
|
||||||
hf_const.HF_HUB_OFFLINE = _saved_hf_const
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
if _saved_transformers_const is not None:
|
|
||||||
try:
|
|
||||||
import transformers.utils.hub as tf_hub
|
|
||||||
|
|
||||||
tf_hub._is_offline_mode = _saved_transformers_const
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
_saved_env = None
|
|
||||||
_saved_hf_const = None
|
|
||||||
_saved_transformers_const = None
|
|
||||||
|
|
||||||
|
|
||||||
def patch_huggingface_hub_offline():
|
def patch_huggingface_hub_offline():
|
||||||
|
|||||||
@@ -15,32 +15,17 @@ const PLATFORM_ALIAS: Record<string, string> = {
|
|||||||
windows: 'windows',
|
windows: 'windows',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPublicOrigin(request: NextRequest): string {
|
|
||||||
const forwardedHost = request.headers.get('x-forwarded-host');
|
|
||||||
const forwardedProto = request.headers.get('x-forwarded-proto');
|
|
||||||
|
|
||||||
if (forwardedHost && forwardedProto) {
|
|
||||||
// Behind reverse proxies/CDNs, request.url can be an internal origin
|
|
||||||
// (for example localhost:8080). Prefer forwarded headers so redirects
|
|
||||||
// keep users on the public domain.
|
|
||||||
return `${forwardedProto}://${forwardedHost}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new URL(request.url).origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ platform: string }> },
|
{ params }: { params: Promise<{ platform: string }> },
|
||||||
) {
|
) {
|
||||||
const origin = getPublicOrigin(request);
|
|
||||||
const { platform } = await params;
|
const { platform } = await params;
|
||||||
// No prebuilt Linux binary yet — send straight to the build-from-source page.
|
// No prebuilt Linux binary yet — send straight to the build-from-source page.
|
||||||
if (platform === 'linux') {
|
if (platform === 'linux') {
|
||||||
return NextResponse.redirect(new URL('/linux-install', origin), 307);
|
return NextResponse.redirect(new URL('/linux-install', request.url), 307);
|
||||||
}
|
}
|
||||||
const normalized = PLATFORM_ALIAS[platform];
|
const normalized = PLATFORM_ALIAS[platform];
|
||||||
const target = new URL('/download', origin);
|
const target = new URL('/download', request.url);
|
||||||
if (normalized) target.searchParams.set('platform', normalized);
|
if (normalized) target.searchParams.set('platform', normalized);
|
||||||
return NextResponse.redirect(target, 307);
|
return NextResponse.redirect(target, 307);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user