Compare commits

...
Author SHA1 Message Date
Jamie Pine bcab47f25a fix(offline): remove inference-path HF_HUB_OFFLINE guards
0.4.3 wrapped every inference body (`generate`, `transcribe`,
`create_voice_clone_prompt`) with `force_offline_if_cached(True, …)` to
prevent lazy HF lookups from hanging when the network drops
mid-inference (#462). That trade broke online users: the guard flips
`huggingface_hub.constants.HF_HUB_OFFLINE` globally, so any legitimate
metadata call the library makes during generation (e.g. revision
resolution via `HfApi().model_info`) now raises:

    Cannot reach https://huggingface.co/api/models/Qwen/Qwen3-TTS-…:
    offline mode is enabled.

Hit by multiple users on 0.4.3 within hours of release. The offline
blast radius is much larger than the original hang it fixed.

This reverts the inference-path guards. Load-path guards stay — those
worked fine in 0.4.2 and aren't the source of the regression. The
`force_offline_if_cached` helper itself is unchanged; tests still pass.

The #462 hang (network dropping mid-inference) remains unaddressed by
this commit and will need a targeted fix that doesn't flip a global
flag — most likely per-call timeouts or library-specific
`local_files_only` arguments, not a process-wide env mutation.
2026-04-21 04:18:53 -07:00
Jamie Pine 7e7feeac54 chore(landing): swap 4th tutorial for Tech指南 Voicebox review 2026-04-21 01:43:22 -07:00
Jamie Pine 328bdca61c Bump version: 0.4.2 → 0.4.3 2026-04-20 23:34:06 -07:00
Jamie PineandGitHub abb752d623 fix(release): notarize and staple macOS DMGs (#523)
* fix(release): notarize and staple macOS DMGs

Tauri's bundler signs the .app and notarizes it, but ships the .dmg
wrapper unnotarized. Gatekeeper rejects that on macOS 15 Sequoia
(caught by Homebrew Cask CI) and causes the 'app isn't signed'
dialog on older Intel Macs when Apple's notarization servers are
slow (issue #509).

New step submits each built DMG to notarytool, staples the ticket,
verifies with spctl, then overwrites the release asset tauri-action
already uploaded to the draft release.

Adds ~5-10 min per macOS job (notarytool round-trip).

* fix(release): fail loudly when no DMG is found to notarize

Empty glob + nullglob was silently skipping the loop body, so if
Tauri's bundler output path changed we'd re-publish the unnotarized
DMGs with a green CI. Assert the glob matched at least one file.

* fix(release): resolve release tag from tauri.conf.json, not GITHUB_REF_NAME

GITHUB_REF_NAME is the branch name when the workflow runs via
workflow_dispatch, so the gh release upload targeted the wrong thing
on manual runs. tauri-action derives its tag from tauri.conf.json's
version field via the v__VERSION__ template; use the same source so
the two always agree.
2026-04-20 23:30:35 -07:00
Jamie PineandGitHub f0924d19d3 fix(backend): bundle unidic-lite for misaki Japanese G2P (#514) (#521)
fugashi (pulled in by misaki[ja]) needs a MeCab dictionary at runtime.
The `unidic` package that ships today contains no data — it relies on
`python -m unidic download` (~526MB), which isn't run by `just setup`
and won't survive PyInstaller freezing.

Switch to `unidic-lite`, which bundles a MeCab-compatible dict inside
the wheel (~50MB). Collect its data files in build_binary.py so frozen
builds also pick up the dicdir. Same failure mode and same fix shape as
the existing en_core_web_sm pre-install.
2026-04-20 23:00:55 -07:00
20 changed files with 159 additions and 111 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.4.2 current_version = 0.4.3
commit = True commit = True
tag = True tag = True
tag_name = v{new_version} tag_name = v{new_version}
+40
View File
@@ -226,6 +226,46 @@ jobs:
args: ${{ matrix.args }} args: ${{ matrix.args }}
includeUpdaterJson: true 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: build-cuda-windows:
runs-on: windows-latest runs-on: windows-latest
permissions: permissions:
+14 -1
View File
@@ -7,6 +7,18 @@
## [Unreleased] ## [Unreleased]
## [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 ## [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. 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 +638,8 @@ The first public release of Voicebox — an open-source voice synthesis studio p
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite 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.3...HEAD
[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.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.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 [0.4.0]: https://github.com/jamiepine/voicebox/compare/v0.3.0...v0.4.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@voicebox/app", "name": "@voicebox/app",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,3 +1,3 @@
# Backend package # Backend package
__version__ = "0.4.1" __version__ = "0.4.3"
+8 -15
View File
@@ -193,8 +193,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
@@ -220,14 +218,12 @@ 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 # Inference runs with the process's default HF_HUB_OFFLINE
# lazy tokenizer/config lookups inside mlx_audio don't hang # state. Forcing offline here (previously used to avoid lazy
# when the user is disconnected (issue #462). # mlx_audio lookups hanging when the network drops mid-inference,
with force_offline_if_cached(True, model_name): # issue #462) regressed online users because libraries make
# Check if model supports voice cloning via generate method # legitimate metadata calls during generation.
# MLX API may support ref_audio parameter directly
try: try:
# Try with voice cloning parameters if supported
if ref_audio: if ref_audio:
# Check if generate accepts ref_audio parameter # Check if generate accepts ref_audio parameter
import inspect import inspect
@@ -347,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
@@ -357,10 +351,9 @@ class MLXSTTBackend:
if language: if language:
decode_options["language"] = language decode_options["language"] = language
# Model is loaded → weights are on disk. Force offline so # Inference runs with the process's default HF_HUB_OFFLINE
# lazy tokenizer/config lookups don't hang when the user is # state — see the comment in MLXTTSBackend.generate for the
# disconnected (issue #462). # regression this revert fixes (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 # Extract text from result
+10 -18
View File
@@ -172,14 +172,12 @@ 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 # Inference runs with the process's default HF_HUB_OFFLINE
# lazy tokenizer/config lookups inside qwen_tts don't hang # state. Forcing offline here (issue #462) regressed online
# when the user is disconnected (issue #462). # users whose libraries issue legitimate metadata lookups
with force_offline_if_cached(True, model_name): # during voice-prompt creation.
return self.model.create_voice_clone_prompt( return self.model.create_voice_clone_prompt(
ref_audio=str(audio_path), ref_audio=str(audio_path),
ref_text=reference_text, ref_text=reference_text,
@@ -227,18 +225,14 @@ 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 # See _create_prompt_sync comment — inference runs with the
# lazy tokenizer/config lookups inside qwen_tts don't hang # process's default HF_HUB_OFFLINE state (issue #462).
# when the user is disconnected (issue #462).
with force_offline_if_cached(True, model_name):
wavs, sample_rate = self.model.generate_voice_clone( wavs, sample_rate = self.model.generate_voice_clone(
text=text, text=text,
voice_clone_prompt=voice_prompt, voice_clone_prompt=voice_prompt,
@@ -342,17 +336,15 @@ 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 # Inference runs with the process's default HF_HUB_OFFLINE
# `get_decoder_prompt_ids` and any lazy tokenizer lookups # state — forcing offline here (issue #462) broke online users
# don't hang when the user is disconnected (issue #462). # whose `get_decoder_prompt_ids` / tokenizer calls issue
with force_offline_if_cached(True, progress_model_name): # legitimate metadata lookups.
# Process audio # Process audio
inputs = self.processor( inputs = self.processor(
audio, audio,
@@ -186,7 +186,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,10 +205,10 @@ class QwenCustomVoiceBackend:
if instruct: if instruct:
kwargs["instruct"] = instruct kwargs["instruct"] = instruct
# Model is loaded → weights are on disk. Force offline so # Inference runs with the process's default HF_HUB_OFFLINE
# lazy tokenizer/config lookups inside qwen_tts don't hang # state. Forcing offline here (issue #462) regressed online
# when the user is disconnected (issue #462). # users whose libraries issue legitimate metadata lookups
with force_offline_if_cached(True, model_name): # during generation.
wavs, sample_rate = self.model.generate_custom_voice(**kwargs) wavs, sample_rate = self.model.generate_custom_voice(**kwargs)
return wavs[0], sample_rate return wavs[0], sample_rate
+6
View File
@@ -287,6 +287,12 @@ def build_server(cuda=False):
"en_core_web_sm", "en_core_web_sm",
"--hidden-import", "--hidden-import",
"en_core_web_sm", "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", "--hidden-import",
"loguru", "loguru",
] ]
+5
View File
@@ -46,6 +46,11 @@ misaki[en,ja,zh]>=0.9.4
# spacy model for misaki English G2P — must be pre-installed or misaki # spacy model for misaki English G2P — must be pre-installed or misaki
# tries spacy.cli.download() at runtime which crashes frozen builds # 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 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 # Audio processing
librosa>=0.10.0 librosa>=0.10.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@voicebox/landing", "name": "@voicebox/landing",
"version": "0.4.2", "version": "0.4.3",
"description": "Landing page for voicebox.sh", "description": "Landing page for voicebox.sh",
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev --turbo",
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

+4 -4
View File
@@ -29,10 +29,10 @@ const TUTORIALS: (Tutorial | null)[] = [
thumbnail: "/tutorials/kqxqjRsdD5E.jpg", thumbnail: "/tutorials/kqxqjRsdD5E.jpg",
}, },
{ {
id: "yu9QHqOEqqA", id: "05YBqrWTLQ0",
title: "This FREE AI Tool Just Destroyed ElevenLabs Voice Cloning (VoiceBox)", title: "2026年最好的声音克隆工具?Voicebox完整测评:从下载到API调用,附速度对比",
author: "Danish Sofi", author: "Tech指南",
thumbnail: "/tutorials/yu9QHqOEqqA.jpg", thumbnail: "/tutorials/05YBqrWTLQ0.jpg",
}, },
{ {
id: "RRRBxNXgeKQ", id: "RRRBxNXgeKQ",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "voicebox", "name": "voicebox",
"version": "0.4.2", "version": "0.4.3",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"app", "app",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@voicebox/tauri", "name": "@voicebox/tauri",
"private": true, "private": true,
"version": "0.4.2", "version": "0.4.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "voicebox" name = "voicebox"
version = "0.4.2" version = "0.4.3"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"core-foundation-sys", "core-foundation-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "voicebox" name = "voicebox"
version = "0.4.2" version = "0.4.3"
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation" description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
authors = ["you"] authors = ["you"]
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Voicebox", "productName": "Voicebox",
"version": "0.4.2", "version": "0.4.3",
"identifier": "sh.voicebox.app", "identifier": "sh.voicebox.app",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@voicebox/web", "name": "@voicebox/web",
"private": true, "private": true,
"version": "0.4.2", "version": "0.4.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",