Compare commits

..
Author SHA1 Message Date
James PineandClaude Opus 4.7 4ffbc03d15 fix(audio): raise trim threshold, cap pad at net-neutral
Review feedback on the preprocessor:

1. ``trim_top_db=30`` was labelled "conservative" in the docstring but is
   actually *more* aggressive than librosa's default of 60. Normal
   speech dynamic range sits around 30 dB, so 30 dB would eat quiet
   trailing syllables and soft consonants. Raise the default to 40 dB —
   below normal speech dynamic range but still catching obvious edge
   silence — and fix the docstring.

2. Unconditional 100 ms edge padding ran even when ``librosa.effects.trim``
   removed nothing. For a well-recorded 29.9 s upload that path would
   push the waveform past the 30 s ceiling and trigger a spurious "too
   long" rejection. Only pad when trimming actually shortened the
   audio, and cap the pad so the output never exceeds the input length.

Adds a regression test for the net-neutral length behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 18:25:52 -07:00
James PineandClaude Opus 4.7 a49cc6afbb fix(audio): preprocess reference samples instead of rejecting them
Uploaded/recorded voice samples were rejected outright whenever the peak
exceeded 0.99 ("Audio is clipping (reduce input gain)"). That wasn't
actionable: a recording in the app has no pre-gain control, and an
already-captured file can't be re-taken by the user. The Settings
"Normalize audio" toggle only affects generated TTS output, so users who
enabled it expecting it to help with sample uploads were still blocked.

Replace the hard reject with a small, always-on preprocess step that
runs right after load:
  - DC-offset removal
  - Conservative edge-silence trim (top_db=30) with 100 ms padding kept
  - Peak cap at 0.95 if the input peak exceeds that

Duration and RMS checks now run on the preprocessed waveform, so
samples that were previously rejected for being "hot" are accepted and
stored with safe headroom. True in-waveform clipping artifacts still
can't be repaired — peak scaling only prevents downstream re-clipping
during multi-sample combination and TTS inference.

Adds a unit-test file (previously none existed for audio.py).

Fixes #456.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-19 16:41:10 -07:00
19 changed files with 140 additions and 423 deletions
+2 -61
View File
@@ -26,45 +26,12 @@ jobs:
args: ""
python-version: "3.12"
backend: "pytorch"
- platform: "ubuntu-22.04"
# --config override disables updater-artifact generation on Linux.
# tauri.conf.json has createUpdaterArtifacts: "v1Compatible" which
# on Linux wants to synthesize a .AppImage.tar.gz by downloading
# linuxdeploy at build time — this is what silently hangs CI
# (see v0.4.2 round 2, 25 min of no output after rpm bundling).
# We ship deb+rpm only; Linux users update via apt/dnf, not the
# Tauri in-app updater.
args: '--target x86_64-unknown-linux-gnu --bundles deb,rpm --verbose --config {"bundle":{"createUpdaterArtifacts":false}}'
python-version: "3.12"
backend: "pytorch"
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
# Ubuntu runners ship with ~14 GB free; pip + PyInstaller + torch can
# peak well above that during the build. Reclaim ~25 GB by pruning
# preinstalled toolchains we don't use. This is what likely tripped
# the March 2026 Linux release attempts (see commit 103e98b
# "github runners suck") — not a code issue, a disk-pressure one.
- name: Free up disk space (ubuntu)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
# Pinned to v1.3.1 (SHA) — this job runs with contents: write and
# handles signing secrets later, so we don't want a floating ref.
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
with:
tool-cache: false
android: true
dotnet: true
haskell: true
# large-packages: true would `apt-get remove '^llvm-.*'`, which
# cascade-removes reverse deps that won't be pulled back in by the
# `llvm-dev` install below. The other flags already free ~20 GB,
# enough for the Python + torch + PyInstaller build.
large-packages: false
swap-storage: true
- name: Install dependencies (ubuntu only)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
@@ -106,10 +73,8 @@ jobs:
# fine on transformers 4.57.x in practice (verified in dev), so install
# them --no-deps. mlx-audio's other runtime deps (huggingface_hub,
# librosa, numpy, numba, pyloudnorm) are already in requirements.txt;
# miniaudio is in requirements-mlx.txt (needed by mlx_audio.stt,
# not transitively pulled by anything else — see issue #505); the
# rest (sounddevice, protobuf, sentencepiece, pyyaml, jinja2) are
# pulled in by other engines.
# the rest (sounddevice, miniaudio, protobuf, sentencepiece, pyyaml,
# jinja2) are pulled in by other engines.
pip install --no-deps mlx-lm==0.31.1
pip install --no-deps mlx-audio==0.4.1
@@ -168,21 +133,6 @@ jobs:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Disk / environment snapshot (pre-bundle debug)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
echo "=== df -h ==="
df -h
echo "=== free -h ==="
free -h
echo "=== Rust / Cargo ==="
rustc --version
cargo --version
echo "=== Bun ==="
bun --version
echo "=== Tauri CLI ==="
cd tauri && bun run tauri --version
- name: Extract release notes from CHANGELOG.md
id: changelog
shell: bash
@@ -206,13 +156,7 @@ jobs:
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
# Linux hang watchdog: previous releases silently wedged inside tauri
# bundling (possibly linuxdeploy/AppImage download, possibly cargo link).
# Cap the step at 30 min so we get logs instead of waiting out the 6hr
# job timeout. Other platforms historically complete in ~25 min, so 45
# is comfortable.
- uses: tauri-apps/[email protected]
timeout-minutes: ${{ (contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')) && 30 || 45 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -224,9 +168,6 @@ jobs:
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
# Stream subprocess stdout/stderr so the hang is visible in logs.
CARGO_TERM_VERBOSE: "true"
RUST_BACKTRACE: "1"
with:
projectPath: tauri
tagName: v__VERSION__
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/app",
"version": "0.4.2",
"version": "0.4.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -177,7 +177,7 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
backend_type = get_backend_type()
if backend_type == "mlx":
repo_1_7b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
repo_0_6b = "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
repo_0_6b = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # 0.6B not available in MLX, falls back
else:
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
+26 -36
View File
@@ -45,9 +45,11 @@ class MLXTTSBackend:
Returns:
HuggingFace Hub model ID for MLX
"""
# MLX model mapping
mlx_model_map = {
"1.7B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16",
"0.6B": "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16",
# 0.6B not yet converted to MLX format
"0.6B": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16", # Fallback to 1.7B
}
if model_size not in mlx_model_map:
@@ -193,8 +195,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 +220,36 @@ 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
# 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
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,7 @@ 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)
result = self.model.generate(str(audio_path), **decode_options)
# Extract text from result
if isinstance(result, str):
+39 -56
View File
@@ -172,19 +172,13 @@ 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,
)
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 +221,19 @@ 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,
)
# 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,
)
return wavs[0], sample_rate
# Run blocking inference in thread pool to avoid blocking event loop
@@ -342,46 +331,40 @@ 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)
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",
# 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()
+13 -20
View File
@@ -28,7 +28,6 @@ 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__)
@@ -105,19 +104,18 @@ class QwenCustomVoiceBackend:
model_path = self._get_model_path(model_size)
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":
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,
)
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
@@ -186,7 +184,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 +203,7 @@ 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)
wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
return wavs[0], sample_rate
audio, sample_rate = await asyncio.to_thread(_generate_sync)
+3 -10
View File
@@ -3,12 +3,6 @@
mlx>=0.30.0
# miniaudio is a runtime dep of mlx-audio's STT path (mlx_audio.stt).
# mlx-audio itself is installed --no-deps (see comment below), so we
# must list miniaudio explicitly here or transcription fails on fresh
# M1 installs with `ModuleNotFoundError: miniaudio` (issue #505).
miniaudio>=1.59
# NOTE: mlx-audio is intentionally not listed here. From 0.3.1 onward it
# declares `transformers==5.0.0rc3` / `>=5.0.0`, which conflicts with the
# `transformers<=4.57.6` cap in requirements.txt and breaks CI's clean
@@ -16,7 +10,6 @@ miniaudio>=1.59
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
#
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
# (see .github/workflows/release.yml). Most other mlx-audio runtime deps
# (huggingface_hub, librosa, mlx-lm, numba, numpy, protobuf, pyloudnorm,
# sounddevice, tqdm) are already in requirements.txt or pulled in by
# other engines.
# (see .github/workflows/release.yml). All other mlx-audio runtime deps
# (huggingface_hub, librosa, miniaudio, mlx-lm, numba, numpy, protobuf,
# pyloudnorm, sounddevice, tqdm) are already in requirements.txt.
-118
View File
@@ -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"])
+1 -1
View File
@@ -175,7 +175,7 @@ async def main():
print(" ✅ Server is running")
# Test model
model_name = "qwen-tts-0.6B"
model_name = "qwen-tts-0.6B" # Note: 0.6B currently maps to 1.7B on MLX
# Check current status
print(f"\n📊 Checking status of {model_name}...")
+27 -110
View File
@@ -6,7 +6,6 @@ are already downloaded. Must be imported BEFORE mlx_audio.
import logging
import os
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Optional, Union
@@ -14,33 +13,13 @@ from typing import Optional, Union
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
def force_offline_if_cached(is_cached: bool, model_label: str = ""):
"""Force offline mode for the duration of a cached-model operation.
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.
"""Context manager that sets ``HF_HUB_OFFLINE=1`` while loading a cached model.
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:
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
return
global _offline_refcount, _saved_env, _saved_hf_const, _saved_transformers_const
with _offline_lock:
if _offline_refcount == 0:
# Snapshot prior state, apply new state, roll back on *any*
# 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
original_value = os.environ.get("HF_HUB_OFFLINE")
os.environ["HF_HUB_OFFLINE"] = "1"
logger.info(
"[offline-guard] %s is cached — forcing HF_HUB_OFFLINE=1",
model_label or "model",
)
try:
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:
with _offline_lock:
_offline_refcount -= 1
if _offline_refcount == 0:
if _saved_env is not 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
if original_value is not None:
os.environ["HF_HUB_OFFLINE"] = original_value
else:
os.environ.pop("HF_HUB_OFFLINE", None)
def patch_huggingface_hub_offline():
+6 -2
View File
@@ -309,7 +309,11 @@ Still reported. Users get stuck downloads, can't resume, offline mode edge cases
**Fix path:** PR #443 addresses infinite offline retry. CustomVoice-specific download failures (#475, #445) need triage — likely related to frozen-binary import fixes in PR #438. TADA cluster (#336, #348) and macOS ARM import regressions (#287, #275, #304) need a dedicated triage pass.
**Qwen 0.6B-downloads-1.7B reports:** **#485** (2026-04-19), **#423** (macOS M1), **#329**. Originally a stale-fallback bug: `mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16` wasn't published when MLX support shipped, so the 0.6B slot was aliased to the 1.7B repo. The 0.6B bf16 conversion is live now and both `backend/backends/mlx_backend.py` and `backend/backends/__init__.py` point at their correct repos. Qwen CustomVoice is unaffected — it runs via PyTorch on all platforms, both sizes always have dedicated repos.
**Qwen 0.6B-downloads-1.7B reports:** **#485** (2026-04-19), **#423** (macOS M1), **#329**. Platform-dependent:
- **On MLX (Apple Silicon) — not a bug.** `mlx-community` only publishes 1.7B-Base-bf16 weights, so the 0.6B Base option intentionally resolves to the same repo (`backend/backends/__init__.py:180` — `# 0.6B not available in MLX, falls back`). UX gap: the selector offers a size that doesn't exist on the active backend. Fix: (a) hide the 0.6B option on MLX, or (b) label it "0.6B (uses 1.7B on Apple Silicon)".
- **On PyTorch (Windows/Linux/CUDA/ROCm/XPU/CPU) — real bug if reported.** Both 0.6B and 1.7B have distinct repos (`Qwen/Qwen3-TTS-12Hz-0.6B-Base` vs `-1.7B-Base`). Triage each report by platform before merging into the MLX cluster.
- **Qwen CustomVoice (either platform)** — no fallback, both sizes always have dedicated repos.
### Language Requests (ongoing)
@@ -389,7 +393,7 @@ Notable:
| **#306** ("voice model"), **#389** ("New model"), **#473** ("New functionality") | Title-only issues, no content. Request details or close. |
| **#309** | Uninstall/cleanup question. Answer and close. |
| **#241** | "How to use in Colab" — support question, not a bug. |
| **#423** / **#485** / **#329** | Stale MLX fallback to 1.7B repo — fixed; 0.6B bf16 conversion now live on `mlx-community`, registry points at correct repo on both backends. |
| **#423** / **#485** / **#329** | Platform-dependent. On MLX: not a bug (0.6B weights don't exist upstream, fallback is intentional — fix UX). On PyTorch: real bug if reproducible. Classify each by reporter's platform before deduping. |
| **#336** / **#348** | TADA download/registration cluster — triage together. |
| **#287** / **#275** / **#304** | macOS ARM import regressions on new version — likely one root cause. |
| **#292**, **#349** | Possibly already fixed by merged PRs (#321/#412 and #345). Verify + close. |
@@ -446,6 +446,20 @@ Restart the app to create a fresh database.
huggingface-cli download Qwen/Qwen3-TTS-12Hz-1.7B-Base
```
### Qwen 0.6B Downloads the Same Files as 1.7B on Apple Silicon
**Symptoms:**
- You select Qwen 0.6B on an Apple Silicon Mac and the download is the same size as 1.7B
- Generation speed and VRAM usage match 1.7B, not the expected smaller model
**Explanation:**
This is intentional, not a bug. The MLX community only publishes `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` — there is no 0.6B MLX build. Voicebox's model registry falls back to the 1.7B weights when 0.6B is selected on MLX (see `backend/backends/__init__.py`).
**Solution:**
- On Apple Silicon, both size options use the 1.7B model — pick either.
- If you specifically need a smaller model, switch to **Kokoro 82M** (~350 MB) or **LuxTTS** (~300 MB) — both CPU-realtime.
- On Windows/Linux with PyTorch, 0.6B and 1.7B are distinct repos and behave differently.
### Wrong Model Version
**Symptoms:**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@voicebox/landing",
"version": "0.4.2",
"version": "0.4.1",
"description": "Landing page for voicebox.sh",
"scripts": {
"dev": "next dev --turbo",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "voicebox",
"version": "0.4.2",
"version": "0.4.1",
"private": true,
"workspaces": [
"app",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@voicebox/tauri",
"private": true,
"version": "0.4.2",
"version": "0.4.1",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voicebox"
version = "0.4.2"
version = "0.4.1"
dependencies = [
"base64 0.22.1",
"core-foundation-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "voicebox"
version = "0.4.2"
version = "0.4.1"
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
authors = ["you"]
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voicebox",
"version": "0.4.2",
"version": "0.4.1",
"identifier": "sh.voicebox.app",
"build": {
"beforeDevCommand": "bun run dev",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@voicebox/web",
"private": true,
"version": "0.4.2",
"version": "0.4.1",
"type": "module",
"scripts": {
"dev": "vite",