mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 06:05:14 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2eec591a | ||
|
|
d61e884104 | ||
|
|
74e004400f | ||
|
|
0047352df1 | ||
|
|
7e7feeac54 | ||
|
|
328bdca61c | ||
|
|
abb752d623 | ||
|
|
f0924d19d3 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.4.2
|
||||
current_version = 0.4.5
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -226,6 +226,46 @@ jobs:
|
||||
args: ${{ matrix.args }}
|
||||
includeUpdaterJson: true
|
||||
|
||||
# Tauri's bundler signs the .app and notarizes it, but the .dmg wrapper
|
||||
# ships unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (caught
|
||||
# by Homebrew Cask CI) and causes "app isn't signed" dialogs on older
|
||||
# Intel Macs when Apple's notarization servers are slow (see issue #509).
|
||||
# Submit the .dmg to notarytool, staple the ticket, and overwrite the
|
||||
# release asset uploaded by tauri-action.
|
||||
- name: Notarize and staple DMG (macOS)
|
||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY_ID}.p8"
|
||||
TARGET=$(echo "${{ matrix.args }}" | sed -n 's/.*--target \([a-z0-9_-]*\).*/\1/p')
|
||||
DMG_DIR="tauri/src-tauri/target/${TARGET}/release/bundle/dmg"
|
||||
# Match the release tag tauri-action resolved from tauri.conf.json's
|
||||
# version field; GITHUB_REF_NAME is a branch name under workflow_dispatch.
|
||||
RELEASE_TAG="v$(jq -r '.version' tauri/src-tauri/tauri.conf.json)"
|
||||
shopt -s nullglob
|
||||
dmgs=("${DMG_DIR}"/*.dmg)
|
||||
if [ ${#dmgs[@]} -eq 0 ]; then
|
||||
echo "::error::No DMGs found in ${DMG_DIR} — tauri bundler output path may have changed"
|
||||
exit 1
|
||||
fi
|
||||
for dmg in "${dmgs[@]}"; do
|
||||
echo "::group::Notarize $(basename "$dmg")"
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--key "$KEY_PATH" \
|
||||
--key-id "$APPLE_API_KEY_ID" \
|
||||
--issuer "$APPLE_API_ISSUER" \
|
||||
--wait --timeout 20m
|
||||
xcrun stapler staple "$dmg"
|
||||
spctl -a -t open --context context:primary-signature -vv "$dmg"
|
||||
gh release upload "${RELEASE_TAG}" "$dmg" --clobber \
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
build-cuda-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
|
||||
+35
-1
@@ -7,6 +7,37 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.5] - 2026-04-22
|
||||
|
||||
Second hotfix for the "offline mode is enabled" crash on model load. 0.4.4 reverted the inference-path offline guards but kept the same trap on the load path, so users who updated to 0.4.4 kept hitting the exact error the release was supposed to fix ([#526](https://github.com/jamiepine/voicebox/issues/526)). This release removes the load-path guards and patches the transformers tokenizer load to be robust to HuggingFace metadata failures at the source, so the class of bug can't recur.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Load no longer fails with "offline mode is enabled"** ([#530](https://github.com/jamiepine/voicebox/pull/530), fixes [#526](https://github.com/jamiepine/voicebox/issues/526)). transformers 4.57.x added an unconditional `huggingface_hub.model_info()` call inside `AutoTokenizer.from_pretrained` (via `_patch_mistral_regex`) that runs for every non-local repo load, regardless of cache state or whether the target model is actually a Mistral variant. The load-time `HF_HUB_OFFLINE` guard from 0.4.2 turned that into a hard crash for cached online users the moment 0.4.4 removed the inference-path guard that had been masking the problem. Fix wraps `_patch_mistral_regex` so any exception from the HF metadata check is caught and the tokenizer is returned unchanged — matching the success-path behavior for non-Mistral repos. The wrapper installs at `backend.backends` import time so it covers Qwen Base, Qwen CustomVoice, TADA, and every other transformers-backed engine on Windows, Linux, and CUDA alike. The load-time `force_offline_if_cached` guards were removed — with the wrapper in place they provide zero value and only risk re-introducing the same failure mode.
|
||||
- **No more 30s pause when generating without a network.** The HuggingFace metadata timeout called out as a known caveat in 0.4.4 is covered by the same patch; offline users no longer wait for the check to time out before load completes.
|
||||
|
||||
## [0.4.4] - 2026-04-21
|
||||
|
||||
Hotfix for a regression in 0.4.3 where generation and transcription could fail outright with "offline mode is enabled" even when the user was online.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Inference no longer fails with "offline mode is enabled" while online** ([#524](https://github.com/jamiepine/voicebox/pull/524), reverts the inference-path guards from [#503](https://github.com/jamiepine/voicebox/pull/503)). 0.4.3 wrapped every inference body (`generate`, `transcribe`, `create_voice_clone_prompt`) with a process-wide `HF_HUB_OFFLINE` flip to stop lazy HuggingFace lookups from hanging when the network drops mid-inference ([#462](https://github.com/jamiepine/voicebox/issues/462)). That flag also blocks legitimate metadata calls (e.g. `HfApi().model_info` for revision resolution) so online users started seeing generation fail outright. Inference now runs with the process's default HF state. Load-time offline guards — which weren't the source of the regression — stay in place.
|
||||
|
||||
**Known caveat**: users generating without an internet connection may see brief pauses during inference while HuggingFace metadata lookups time out (typically ~30s, after which the library recovers). A proper offline-mode toggle is planned for 0.4.5.
|
||||
|
||||
## [0.4.3] - 2026-04-20
|
||||
|
||||
A patch focused on two user-impacting reliability fixes: macOS DMG notarization (unblocks `brew install voicebox` on macOS 15 Sequoia and fixes spurious "app isn't signed" Gatekeeper dialogs on older Intel Macs) and Kokoro Japanese voice initialization on fresh installs.
|
||||
|
||||
### macOS
|
||||
|
||||
- **DMGs are now notarized and stapled** ([#523](https://github.com/jamiepine/voicebox/pull/523)). Tauri's bundler notarizes the `.app` inside the DMG but ships the DMG wrapper itself unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (confirmed by Homebrew Cask CI failing on both arm and intel Sequoia runners) and causes the "the app is not signed" dialog on older Intel Macs when Apple's notarization servers are slow or unreachable ([#509](https://github.com/jamiepine/voicebox/issues/509)). The release workflow now submits each DMG to `notarytool`, staples the ticket, verifies with `spctl`, and overwrites the draft-release asset `tauri-action` uploaded. Adds ~5-10 min per macOS job.
|
||||
|
||||
### Backend
|
||||
|
||||
- **Kokoro Japanese voices no longer crash on fresh installs** ([#521](https://github.com/jamiepine/voicebox/pull/521), fixes [#514](https://github.com/jamiepine/voicebox/issues/514)). `misaki[ja]` pulls in `fugashi`, which needs a MeCab dictionary on disk. The `unidic` package that was being installed ships no data and expects a ~526MB runtime download that `just setup` doesn't run (and which wouldn't survive PyInstaller anyway). Swapped to `unidic-lite`, which bundles a MeCab-compatible dict inside the wheel (~50MB). Collected in `build_binary.py` so frozen builds pick up `unidic_lite/dicdir/`.
|
||||
|
||||
## [0.4.2] - 2026-04-20
|
||||
|
||||
This release localizes the entire app. English, Simplified Chinese (zh-CN), Traditional Chinese (zh-TW), and Japanese (ja) are wired up end-to-end across every tab, modal, dialog, and toast — 559 translation keys per locale, parity verified. Plus a batch of reliability fixes: offline-mode now actually stays offline, Chatterbox accepts reference samples it used to reject, MLX Qwen 0.6B points at the right repo, and macOS system audio survives backgrounding.
|
||||
@@ -626,7 +657,10 @@ The first public release of Voicebox — an open-source voice synthesis studio p
|
||||
|
||||
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
|
||||
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.2...HEAD
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.4.5...HEAD
|
||||
[0.4.5]: https://github.com/jamiepine/voicebox/compare/v0.4.4...v0.4.5
|
||||
[0.4.4]: https://github.com/jamiepine/voicebox/compare/v0.4.3...v0.4.4
|
||||
[0.4.3]: https://github.com/jamiepine/voicebox/compare/v0.4.2...v0.4.3
|
||||
[0.4.2]: https://github.com/jamiepine/voicebox/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/jamiepine/voicebox/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/jamiepine/voicebox/compare/v0.3.0...v0.4.0
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.4.1"
|
||||
__version__ = "0.4.5"
|
||||
|
||||
@@ -5,6 +5,13 @@ Provides a unified interface for MLX and PyTorch backends,
|
||||
and a model config registry that eliminates per-engine dispatch maps.
|
||||
"""
|
||||
|
||||
# Install HF compatibility patches before any backend imports transformers /
|
||||
# huggingface_hub. The module runs ``patch_transformers_mistral_regex`` at
|
||||
# import time, which wraps transformers' tokenizer load against the
|
||||
# unconditional HuggingFace metadata call that otherwise raises on
|
||||
# HF_HUB_OFFLINE=1 and on network failures.
|
||||
from ..utils import hf_offline_patch # noqa: F401
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, Optional, Tuple, List
|
||||
|
||||
@@ -20,7 +20,6 @@ ensure_original_qwen_config_cached()
|
||||
from . import TTSBackend, STTBackend, LANGUAGE_CODE_TO_NAME, WHISPER_HF_REPOS
|
||||
from .base import is_model_cached, combine_voice_prompts as _combine_voice_prompts, model_load_progress
|
||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||
from ..utils.hf_offline_patch import force_offline_if_cached
|
||||
|
||||
|
||||
class MLXTTSBackend:
|
||||
@@ -99,8 +98,7 @@ class MLXTTSBackend:
|
||||
|
||||
logger.info("Loading MLX TTS model %s...", model_size)
|
||||
|
||||
with force_offline_if_cached(is_cached, model_name):
|
||||
self.model = load(model_path)
|
||||
self.model = load(model_path)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
@@ -193,8 +191,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 +216,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:
|
||||
@@ -315,8 +309,7 @@ class MLXSTTBackend:
|
||||
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
logger.info("Loading MLX Whisper model %s...", model_size)
|
||||
|
||||
with force_offline_if_cached(is_cached, progress_model_name):
|
||||
self.model = load(model_name)
|
||||
self.model = load(model_name)
|
||||
|
||||
self.model_size = model_size
|
||||
logger.info("MLX Whisper model %s loaded successfully", model_size)
|
||||
@@ -347,8 +340,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 +348,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):
|
||||
|
||||
@@ -21,7 +21,6 @@ from .base import (
|
||||
)
|
||||
from ..utils.cache import get_cache_key, get_cached_voice_prompt, cache_voice_prompt
|
||||
from ..utils.audio import load_audio
|
||||
from ..utils.hf_offline_patch import force_offline_if_cached
|
||||
|
||||
|
||||
class PyTorchTTSBackend:
|
||||
@@ -106,21 +105,20 @@ class PyTorchTTSBackend:
|
||||
from huggingface_hub import constants as hf_constants
|
||||
tts_cache_dir = hf_constants.HF_HUB_CACHE
|
||||
|
||||
with force_offline_if_cached(is_cached, model_name):
|
||||
if self.device == "cpu":
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
cache_dir=tts_cache_dir,
|
||||
torch_dtype=torch.float32,
|
||||
low_cpu_mem_usage=False,
|
||||
)
|
||||
else:
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
cache_dir=tts_cache_dir,
|
||||
device_map=self.device,
|
||||
torch_dtype=torch.bfloat16,
|
||||
)
|
||||
if self.device == "cpu":
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
cache_dir=tts_cache_dir,
|
||||
torch_dtype=torch.float32,
|
||||
low_cpu_mem_usage=False,
|
||||
)
|
||||
else:
|
||||
self.model = Qwen3TTSModel.from_pretrained(
|
||||
model_path,
|
||||
cache_dir=tts_cache_dir,
|
||||
device_map=self.device,
|
||||
torch_dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
@@ -172,19 +170,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 +223,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
|
||||
@@ -303,9 +295,8 @@ class PyTorchSTTBackend:
|
||||
model_name = WHISPER_HF_REPOS.get(model_size, f"openai/whisper-{model_size}")
|
||||
logger.info("Loading Whisper model %s on %s...", model_size, self.device)
|
||||
|
||||
with force_offline_if_cached(is_cached, progress_model_name):
|
||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||
|
||||
self.model.to(self.device)
|
||||
self.model_size = model_size
|
||||
@@ -342,46 +333,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()
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -287,6 +287,12 @@ def build_server(cuda=False):
|
||||
"en_core_web_sm",
|
||||
"--hidden-import",
|
||||
"en_core_web_sm",
|
||||
# unidic-lite ships the MeCab dictionary used by fugashi (pulled in
|
||||
# by misaki[ja]). The dict lives in unidic_lite/dicdir/ and is
|
||||
# discovered via the package's DICDIR constant, so the data files
|
||||
# must be collected or Japanese Kokoro voices crash at runtime.
|
||||
"--collect-all",
|
||||
"unidic_lite",
|
||||
"--hidden-import",
|
||||
"loguru",
|
||||
]
|
||||
|
||||
@@ -46,6 +46,11 @@ misaki[en,ja,zh]>=0.9.4
|
||||
# spacy model for misaki English G2P — must be pre-installed or misaki
|
||||
# tries spacy.cli.download() at runtime which crashes frozen builds
|
||||
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
||||
# fugashi (pulled in by misaki[ja]) needs a MeCab dictionary on disk.
|
||||
# unidic-lite ships one inside the wheel (~50MB); the full `unidic` package
|
||||
# requires `python -m unidic download` (~526MB) which breaks frozen builds
|
||||
# for the same reason en_core_web_sm does.
|
||||
unidic-lite>=1.0.8
|
||||
|
||||
# Audio processing
|
||||
librosa>=0.10.0
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Unit tests for ``patch_transformers_mistral_regex``.
|
||||
|
||||
Verifies that our wrapper around
|
||||
``transformers.PreTrainedTokenizerBase._patch_mistral_regex`` catches
|
||||
exceptions from the unconditional ``huggingface_hub.model_info()`` lookup
|
||||
and returns the tokenizer unchanged — matching the success-path behavior
|
||||
for non-Mistral repos (transformers 4.57.3, ``tokenization_utils_base.py:2503``).
|
||||
|
||||
NOTE: These tests mutate ``transformers.PreTrainedTokenizerBase`` globally;
|
||||
run serially, not under ``pytest-xdist`` with per-worker process isolation.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from huggingface_hub.errors import OfflineModeIsEnabled # noqa: E402
|
||||
from transformers.tokenization_utils_base import PreTrainedTokenizerBase # noqa: E402
|
||||
|
||||
import utils.hf_offline_patch as hf_offline_patch # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_mistral_regex():
|
||||
"""Snapshot the current ``_patch_mistral_regex`` and restore after each test."""
|
||||
saved = PreTrainedTokenizerBase.__dict__.get("_patch_mistral_regex")
|
||||
saved_flag = hf_offline_patch._mistral_regex_patched
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if saved is not None:
|
||||
PreTrainedTokenizerBase._patch_mistral_regex = saved
|
||||
hf_offline_patch._mistral_regex_patched = saved_flag
|
||||
|
||||
|
||||
def _apply_patch():
|
||||
hf_offline_patch._mistral_regex_patched = False
|
||||
hf_offline_patch.patch_transformers_mistral_regex()
|
||||
|
||||
|
||||
def test_suppresses_offline_mode_is_enabled(monkeypatch):
|
||||
_apply_patch()
|
||||
|
||||
import huggingface_hub
|
||||
|
||||
def raise_offline(*_args, **_kwargs):
|
||||
raise OfflineModeIsEnabled("offline")
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "model_info", raise_offline)
|
||||
|
||||
sentinel = object()
|
||||
result = PreTrainedTokenizerBase._patch_mistral_regex(
|
||||
sentinel, "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
)
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
def test_suppresses_connection_errors(monkeypatch):
|
||||
_apply_patch()
|
||||
|
||||
import huggingface_hub
|
||||
|
||||
def raise_connection(*_args, **_kwargs):
|
||||
raise ConnectionError("network unreachable")
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "model_info", raise_connection)
|
||||
|
||||
sentinel = object()
|
||||
result = PreTrainedTokenizerBase._patch_mistral_regex(
|
||||
sentinel, "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
)
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
def test_passthrough_on_success(monkeypatch):
|
||||
"""When model_info returns non-Mistral tags the original falls through and returns the tokenizer unchanged."""
|
||||
_apply_patch()
|
||||
|
||||
import huggingface_hub
|
||||
|
||||
class FakeInfo:
|
||||
tags = ["model-type:qwen", "language:en"]
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "model_info", lambda *_a, **_kw: FakeInfo())
|
||||
|
||||
sentinel = object()
|
||||
result = PreTrainedTokenizerBase._patch_mistral_regex(
|
||||
sentinel, "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
)
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
def test_idempotent():
|
||||
_apply_patch()
|
||||
first = PreTrainedTokenizerBase._patch_mistral_regex
|
||||
hf_offline_patch.patch_transformers_mistral_regex()
|
||||
second = PreTrainedTokenizerBase._patch_mistral_regex
|
||||
assert first.__func__ is second.__func__
|
||||
|
||||
|
||||
def test_missing_method_is_noop(monkeypatch):
|
||||
monkeypatch.delattr(PreTrainedTokenizerBase, "_patch_mistral_regex", raising=False)
|
||||
hf_offline_patch._mistral_regex_patched = False
|
||||
hf_offline_patch.patch_transformers_mistral_regex()
|
||||
assert hf_offline_patch._mistral_regex_patched is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -142,6 +142,57 @@ def force_offline_if_cached(is_cached: bool, model_label: str = ""):
|
||||
_saved_transformers_const = None
|
||||
|
||||
|
||||
_mistral_regex_patched = False
|
||||
|
||||
|
||||
def patch_transformers_mistral_regex():
|
||||
"""Make transformers' tokenizer load robust to HuggingFace metadata failures.
|
||||
|
||||
transformers 4.57.x added ``PreTrainedTokenizerBase._patch_mistral_regex``
|
||||
which unconditionally calls ``huggingface_hub.model_info(repo_id)`` during
|
||||
every non-local tokenizer load to check whether the model is a Mistral
|
||||
variant. That call raises on ``HF_HUB_OFFLINE=1`` and on plain network
|
||||
failures, killing unrelated loads (Qwen TTS, TADA, etc.).
|
||||
|
||||
Voicebox never loads Mistral models, so the rewrite the function would
|
||||
apply is a no-op for us anyway. Wrap the method so any exception from the
|
||||
metadata lookup returns the tokenizer unchanged — matching the success-path
|
||||
behavior for non-Mistral repos (transformers 4.57.3,
|
||||
``tokenization_utils_base.py:2503``).
|
||||
"""
|
||||
global _mistral_regex_patched
|
||||
if _mistral_regex_patched:
|
||||
return
|
||||
|
||||
try:
|
||||
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
|
||||
except ImportError:
|
||||
logger.debug("transformers not available, skipping mistral-regex patch")
|
||||
return
|
||||
|
||||
original = getattr(PreTrainedTokenizerBase, "_patch_mistral_regex", None)
|
||||
if original is None:
|
||||
logger.debug(
|
||||
"transformers has no _patch_mistral_regex attribute, skipping patch",
|
||||
)
|
||||
return
|
||||
|
||||
def safe_patch_mistral_regex(cls, tokenizer, pretrained_model_name_or_path, *args, **kwargs):
|
||||
try:
|
||||
return original(tokenizer, pretrained_model_name_or_path, *args, **kwargs)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[mistral-regex-patch] suppressed %s for %r, returning tokenizer as-is",
|
||||
type(exc).__name__,
|
||||
pretrained_model_name_or_path,
|
||||
)
|
||||
return tokenizer
|
||||
|
||||
PreTrainedTokenizerBase._patch_mistral_regex = classmethod(safe_patch_mistral_regex)
|
||||
_mistral_regex_patched = True
|
||||
logger.debug("installed _patch_mistral_regex wrapper")
|
||||
|
||||
|
||||
def patch_huggingface_hub_offline():
|
||||
"""Monkey-patch huggingface_hub to force offline mode."""
|
||||
try:
|
||||
@@ -215,4 +266,5 @@ def ensure_original_qwen_config_cached():
|
||||
|
||||
if os.environ.get("VOICEBOX_OFFLINE_PATCH", "1") != "0":
|
||||
patch_huggingface_hub_offline()
|
||||
patch_transformers_mistral_regex()
|
||||
ensure_original_qwen_config_cached()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.5",
|
||||
"description": "Landing page for voicebox.sh",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -29,10 +29,10 @@ const TUTORIALS: (Tutorial | null)[] = [
|
||||
thumbnail: "/tutorials/kqxqjRsdD5E.jpg",
|
||||
},
|
||||
{
|
||||
id: "yu9QHqOEqqA",
|
||||
title: "This FREE AI Tool Just Destroyed ElevenLabs Voice Cloning (VoiceBox)",
|
||||
author: "Danish Sofi",
|
||||
thumbnail: "/tutorials/yu9QHqOEqqA.jpg",
|
||||
id: "05YBqrWTLQ0",
|
||||
title: "2026年最好的声音克隆工具?Voicebox完整测评:从下载到API调用,附速度对比",
|
||||
author: "Tech指南",
|
||||
thumbnail: "/tutorials/05YBqrWTLQ0.jpg",
|
||||
},
|
||||
{
|
||||
id: "RRRBxNXgeKQ",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "voicebox",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"app",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/tauri",
|
||||
"private": true,
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voicebox"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"core-foundation-sys",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "voicebox"
|
||||
version = "0.4.2"
|
||||
version = "0.4.5"
|
||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Voicebox",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.5",
|
||||
"identifier": "sh.voicebox.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/web",
|
||||
"private": true,
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user