Compare commits

...
6 Commits
Author SHA1 Message Date
Palash DebnathandClaude Opus 4.8 27b4e151e1 chore(release): v0.3.2 (#264)
Patch release. Bumps the version across all sources + lock files and adds the
[0.3.2] CHANGELOG section.

Ships:
- #261 — "Loopback origin required" 403s in the Docker admin UI (and blank
  version): the image now runs in OMNIVOICE_SERVER_MODE so the loopback gate
  is relaxed for the headless deployment; desktop loopback boundary unchanged.

Tagging v0.3.2 triggers release.yml (desktop) and docker.yml (GHCR
:0.3.2/:0.3/:latest).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:30:27 +05:30
Palash DebnathandClaude Opus 4.8 b4f1fe18d7 fix(server): relax loopback gate in headless server mode so Docker admin UI works (#261) (#263)
In Docker the loopback origin gate (`require_loopback`) is unenforceable:
Docker's NAT rewrites `request.client.host` to the bridge gateway (e.g.
172.17.0.1) even for a localhost-only `-p 127.0.0.1:3900:3900` mapping, so every
request looks non-loopback. The gate then 403s the operator out of the routes
the web UI needs — `/system/*` (incl. `/system/info`, which left the version
blank, re-breaking #249 in Docker) and `/api/settings/*` (HF-token entry) —
surfacing as "Loopback origin required" all over the UI.

Fix: add an explicit, opt-in `OMNIVOICE_SERVER_MODE` flag. When set,
`require_loopback` becomes a no-op; exposure is then governed by the operator's
port mapping plus the optional share PIN (NetworkAccessMiddleware still 401s
unauthenticated non-loopback clients whenever a PIN is set). The Docker image
sets `OMNIVOICE_SERVER_MODE=1` (Dockerfile + documented in compose).

Security: the desktop build NEVER sets this, so its loopback boundary is
unchanged — LAN share guests are still denied the admin/system routes. New
unit tests lock the contract (strict 403 by default incl. the PR #81 vectors;
relaxed only under the flag). Existing non-loopback 403 tests still pass.

Docs: docker.md troubleshooting entry for "Loopback origin required".

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:22:25 +05:30
Palash DebnathandClaude Opus 4.8 cec15070a5 chore(release): v0.3.1 (#260)
First tagged build of the 0.3 line off main. Bumps the version across all
sources (pyproject + frontend package.json + Tauri conf + Cargo + both lock
files + the source-checkout fallback in core/version.py) and adds the [0.3.1]
CHANGELOG section.

Ships:
- #256 — browser/Docker file-export crash (invoke undefined)
- #249 — version surfaced in web/Docker UI + desktop-only updater hidden
- #255 — transcribe stream now surfaces the real ASR/model-load failure

Tagging v0.3.1 triggers release.yml (desktop binaries) and docker.yml
(GHCR :0.3.1 / :0.3 / :latest).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:37:50 +05:30
Palash DebnathandClaude Opus 4.8 2aa6e3502b fix(dub): surface real ASR/model-load failures instead of dropping the stream (#255) (#259)
* fix(dub): surface real ASR/model-load failures instead of dropping the stream (#255)

When the transcribe SSE stream died before emitting any event, the UI showed a
misleading generic "Transcribe stream dropped before emitting any segments.
Likely ASR backend failed to load" — hiding the real cause (e.g. a faster-
whisper/CTranslate2 cuDNN load failure, or a missing pkg_resources).

The per-chunk transcribe was already wrapped, but two preflight/setup calls in
the stream generator were not — if either raised, the connection dropped with
no structured error event:

- `get_model()` (preflight) — now wrapped; failures emit a structured `error`
  event built via `core.failure.build_failure` (sanitized reason + actionable
  hint, e.g. the pkg_resources→setuptools hint).
- `offload_tts_for_asr()` — now non-fatal; an offload hiccup logs and continues
  rather than killing the stream.
- The empty-segments guard now sanitizes each chunk error (no home-path/token
  leakage) and appends the recognized-failure-class hint.

Adds a regression test: a raising `get_model()` must yield a structured `error`
SSE event carrying the real message, not a dropped connection.

Does NOT close #255 — this makes the underlying cause visible (pending the
reporter's backend log) rather than asserting a specific Windows-CUDA fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(dub): drive transcribe-stream gen directly (avoid cross-loop teardown)

The regression test for #255 hit the SSE streaming endpoint through TestClient,
whose lifespan created an asyncio Queue bound to a different event loop than the
streaming request — erroring at teardown in the full-suite run. Drive the
route's async generator directly instead: the preflight-error path yields a
single event with no executor/Queue, so it stays isolated from any app loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:29:50 +05:30
Palash DebnathandClaude Opus 4.8 621c263354 fix(version): surface running version in web/Docker UI + hide desktop-only updater (#249) (#258)
The Docker web build has no Tauri runtime, so Settings → About → Version read
`getVersion()` (Tauri-only) and rendered a dash — leaving Docker users unable
to tell which version they were running (issue #249). The update-channel toggle
was also shown there even though the auto-updater is desktop-only.

- Backend: expose the single-source `APP_VERSION` over HTTP — add it to
  `/system/info` (`app_version`) and `/health` (`version`). Both are model-free
  and the latter is zero-auth.
- Frontend: Settings → About → Version falls back to `info.app_version` when
  no Tauri `getVersion()` is available, so Docker shows the real 0.3.x version.
- Frontend: hide the update-channel toggle, update-endpoint row, and the
  "Check for updates" button outside Tauri — the Docker image updates by
  pulling a new tag, not via the in-app updater.
- Docs: fix the wrong package name in the version-check command
  (`omnivoice-studio` → `omnivoice`) and document the new `/health` version
  field + the in-UI version row.

Tests: assert `/system/info.app_version` and `/health.version` equal
APP_VERSION (test_router_smoke.py). The stale `:latest` tag itself was already
fixed in #252; cutting a v0.3.x release repopulates it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:15:11 +05:30
Palash DebnathandClaude Opus 4.8 e2a33e8c40 fix(frontend): browser/Docker fallback for file export (closes #256) (#257)
The history-item export button (and the dub/audio export path) called the
Tauri `save` dialog unconditionally. In the Docker web-server build there is
no Tauri shell, so the plugin's internal invoke() dereferences an undefined
__TAURI_INTERNALS__ and crashes with:

    TypeError: Cannot read properties of undefined (reading 'invoke')

…which is exactly what users hit when downloading a freshly cloned voice from
the browser/Docker UI.

Fix: extract a shared `browserDownload` helper (utils/download.js) that does a
plain HTTP-blob download via a temporary <a download>, and guard
`handleNativeExport` on `isTauri` — falling back to that helper (streaming the
file already served at /audio/<path>) when no Tauri runtime is present.
`triggerDownload`'s browser branch now reuses the same helper instead of
duplicating the blob-download logic.

Adds utils/download.test.js covering the Content-Disposition parser and the
no-Tauri download path (regression guard for #256).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:07:18 +05:30
23 changed files with 450 additions and 88 deletions
+36
View File
@@ -6,6 +6,42 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [0.3.2] — 2026-06-03
### Fixed
- **"Loopback origin required" all over the Docker UI** (and a blank version).
The `/system/*` and `/api/settings/*` routes are restricted to a loopback
origin, but Docker's NAT makes every request look non-loopback, so the gate
403'd the operator out of the admin UI — including `/system/info` (blanking
the version) and HF-token entry. The Docker image now runs with
`OMNIVOICE_SERVER_MODE=1`, which relaxes the gate for the headless
deployment; exposure is governed by the `-p` port mapping plus the optional
share PIN. Desktop builds are unaffected — their loopback boundary (and the
denial of admin routes to LAN share guests) is unchanged. (#261)
## [0.3.1] — 2026-06-03
First tagged build of the 0.3 line off `main` — it ships the accumulated
`[0.3.0]` work below plus the fixes here. (The `[0.3.0]` milestone heading is
kept for the qualitative "actually useful" release.)
### Fixed
- **Voice-clone / export download crashed in the Docker & browser build** with
`TypeError: Cannot read properties of undefined (reading 'invoke')`. The
export button called the Tauri save dialog unconditionally; outside the
desktop shell it now falls back to a standard browser download of the file
served at `/audio/<path>`. (#256)
- **Docker container showed no version** (a dash) in Settings → About, and the
desktop-only update-channel toggle appeared in the web build. The running
version is now read from the backend (`/system/info` `app_version`, `/health`
`version`); the updater UI is hidden outside Tauri. Also corrected the
version-check command in the Docker docs (`omnivoice`, not
`omnivoice-studio`). (#249)
- **Transcription failures were masked** by a generic "Transcribe stream
dropped" message. The transcribe SSE stream now surfaces the real, sanitized
cause (with an actionable hint) instead of silently dropping when model load
or VRAM offload fails. (#255)
## [0.3.0] — Unreleased
### Added
+35 -3
View File
@@ -5,9 +5,12 @@ These are intentionally tiny — one concern per dependency — so they can be
composed at the route or router level without surprises.
Currently exposed:
- `require_loopback`: 403 unless the request came from a loopback origin.
- `require_loopback`: 403 unless the request came from a loopback origin
(bypassed in explicit server mode — see `_server_mode`).
"""
import os
from fastapi import HTTPException, Request
@@ -19,6 +22,28 @@ from fastapi import HTTPException, Request
# the guard: nothing here matches a non-loopback origin.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
_TRUTHY = frozenset({"1", "true", "yes", "on"})
def _server_mode() -> bool:
"""Whether this process is a headless server deployment (Docker image).
In Docker the loopback gate is *unenforceable*: Docker's network NAT
rewrites ``request.client.host`` to the bridge gateway (e.g. 172.17.0.1)
even for a localhost-only ``-p 127.0.0.1:3900:3900`` mapping, so every
request looks non-loopback and the gate 403s the operator out of the
system/settings routes they need (issue #261 — incl. ``/system/info``,
which blanks the version display).
The Docker image sets ``OMNIVOICE_SERVER_MODE=1`` to opt out of the gate.
Network exposure then rests on the operator's port mapping plus the
optional share PIN (``NetworkAccessMiddleware`` still 401s unauthenticated
non-loopback clients whenever a PIN is set). The desktop build never sets
this, so its loopback boundary — including denying LAN share guests access
to admin routes — is unchanged. Read at call time so it stays testable.
"""
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
def require_loopback(request: Request) -> None:
"""Reject any request whose `client.host` is not a loopback address.
@@ -35,7 +60,14 @@ def require_loopback(request: Request) -> None:
Returns None on success (FastAPI dependency convention). Raises 403
on rejection — the response body is `{"detail": "loopback origin required"}`
so existing tests for `/system/set-env` keep passing without modification.
In server mode (Docker, see `_server_mode`) the gate is a no-op: the
loopback origin is unenforceable there and exposure is governed by the
deployment's port mapping + the optional share PIN instead.
"""
host = request.client.host if request.client else None
if host not in _LOOPBACK_HOSTS:
raise HTTPException(status_code=403, detail="loopback origin required")
if host in _LOOPBACK_HOSTS:
return
if _server_mode():
return
raise HTTPException(status_code=403, detail="loopback origin required")
+50 -22
View File
@@ -384,24 +384,39 @@ async def dub_transcribe_stream(job_id: str):
if not job:
preflight_error = "Job not found. It may have been cleaned up or was never created."
else:
_model = await get_model()
asr_audio_target = job.get("vocals_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
asr_audio_target = job.get("audio_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
preflight_error = "No audio available for transcription."
else:
from services.asr_backend import get_active_asr_backend
try:
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
if _asr_backend.id == "pytorch-whisper" and getattr(_model, "_asr_pipe", None) is None:
preflight_error = (
"No ASR backend is ready. Install WhisperX/faster-whisper/MLX Whisper "
"or set OMNIVOICE_PRELOAD_TTS_ASR=1 before launch to use the PyTorch fallback."
# Guard the model load: if it raises, the SSE stream would otherwise die
# before emitting any event, and the UI shows a misleading generic
# "stream dropped" message instead of the real cause (issue #255).
try:
_model = await get_model()
except Exception as e:
logger.exception("transcribe preflight: model load failed (job=%s)", job_id)
from core.failure import build_failure
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
preflight_error = f["reason"] + (f"{f['hint']}" if f.get("hint") else "")
_model = None
if _model is not None:
asr_audio_target = job.get("vocals_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
asr_audio_target = job.get("audio_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
preflight_error = "No audio available for transcription."
else:
from services.asr_backend import get_active_asr_backend
try:
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
if _asr_backend.id == "pytorch-whisper" and getattr(_model, "_asr_pipe", None) is None:
preflight_error = (
"No ASR backend is ready. Install WhisperX/faster-whisper/MLX Whisper "
"or set OMNIVOICE_PRELOAD_TTS_ASR=1 before launch to use the PyTorch fallback."
)
except Exception as e:
from core.failure import build_failure
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
preflight_error = "ASR backend initialization failed: " + f["reason"] + (
f"{f['hint']}" if f.get("hint") else ""
)
except Exception as e:
preflight_error = f"ASR backend initialization failed: {e}"
scene_cuts = job.get("scene_cuts") or []
scene_cuts = job.get("scene_cuts") or []
async def gen():
if preflight_error:
@@ -429,7 +444,12 @@ async def dub_transcribe_stream(job_id: str):
# Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
# Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
# Non-fatal: an offload failure must not drop the stream (#255) —
# transcription can still proceed (it just has less headroom).
try:
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
except Exception as e:
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
all_segments: list[dict] = []
detected_lang = None
@@ -540,15 +560,23 @@ async def dub_transcribe_stream(job_id: str):
# whisperx's VAD load, or an unsupported audio format.
if not all_segments:
# Deduplicate while preserving order so one root cause doesn't
# repeat N times in the UI toast.
# repeat N times in the UI toast. Sanitize each message so home
# paths / tokens from a backend traceback never leak (#255).
from core.failure import sanitize, build_failure
seen = set()
uniq: list[str] = []
for msg in chunk_errors:
if msg and msg not in seen:
seen.add(msg)
uniq.append(msg)
s = sanitize(msg)
if s and s not in seen:
seen.add(s)
uniq.append(s)
if uniq:
detail = "Transcription produced no segments. " + " | ".join(uniq[:3])
# Add the actionable hint for a recognized failure class
# (e.g. pkg_resources missing → install setuptools).
hint = build_failure(" ".join(uniq), stage="transcribe", include_diagnostic=False).get("hint")
if hint:
detail += f"{hint}"
else:
detail = (
"Transcription produced no segments. The audio may be silent, "
+3
View File
@@ -15,6 +15,7 @@ import torch
import shutil
from core.config import OUTPUTS_DIR, DATA_DIR, CRASH_LOG_PATH, LOG_PATH, IDLE_TIMEOUT_SECONDS
from core.version import APP_VERSION
from services.model_manager import get_model_status, get_best_device
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
@@ -168,6 +169,7 @@ def system_info():
try:
_ffmpeg = find_ffmpeg()
return {
"app_version": APP_VERSION,
"data_dir": DATA_DIR,
"outputs_dir": OUTPUTS_DIR,
"crash_log_path": CRASH_LOG_PATH,
@@ -193,6 +195,7 @@ def system_info():
except Exception as e:
logger.exception("system_info failed — returning safe defaults")
return {
"app_version": APP_VERSION,
"data_dir": DATA_DIR,
"outputs_dir": OUTPUTS_DIR,
"crash_log_path": str(CRASH_LOG_PATH),
+1
View File
@@ -25,6 +25,7 @@ class SystemInfoResponse(BaseModel):
"""GET /system/info"""
model_config = ConfigDict(extra="allow")
app_version: str = ""
data_dir: str
outputs_dir: str
crash_log_path: str
+1 -1
View File
@@ -12,4 +12,4 @@ from importlib.metadata import PackageNotFoundError, version
try:
APP_VERSION = version("omnivoice")
except PackageNotFoundError: # non-installed source checkout
APP_VERSION = "0.3.0"
APP_VERSION = "0.3.2"
+1 -1
View File
@@ -606,7 +606,7 @@ def health():
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
return {"status": "ok", "device": device}
return {"status": "ok", "device": device, "version": APP_VERSION}
app.include_router(system.router)
+7
View File
@@ -29,6 +29,13 @@ ENV HF_HOME=/app/omnivoice_data/huggingface
# Allow bare imports (from core.config, from services.*, etc.) when
# uvicorn is started as `backend.main:app` from WORKDIR /app.
ENV PYTHONPATH=/app/backend
# Headless server deployment: relax the desktop-only loopback origin gate.
# Docker's network NAT rewrites the client host to the bridge gateway, so the
# gate would otherwise 403 the operator out of /system/* and /api/settings/*
# ("Loopback origin required", issue #261). Exposure is governed by the
# operator's `-p` port mapping plus the optional share PIN. Desktop builds
# never set this, so their loopback boundary is unchanged.
ENV OMNIVOICE_SERVER_MODE=1
# Install system dependencies (FFmpeg is critical for torchaudio/scene splitting)
RUN apt-get update && apt-get install -y --no-install-recommends \
+9
View File
@@ -46,6 +46,12 @@ services:
# OMNIVOICE_BIND_HOST=0.0.0.0 here only opens the container's own
# interface. The backend default is 127.0.0.1 (see backend/main.py).
- OMNIVOICE_BIND_HOST=0.0.0.0
# Headless server: relax the desktop-only loopback origin gate so the
# web UI's /system/* and /api/settings/* routes work through Docker's
# NAT (issue #261). Already baked into the image; shown here so it's
# discoverable. If you front the container with your own auth proxy on
# loopback, set this to 0 to re-enable the strict gate.
- OMNIVOICE_SERVER_MODE=1
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3900/health"]
interval: 30s
@@ -76,6 +82,9 @@ services:
# service above. The host-side `127.0.0.1:3900:3900` mapping keeps
# LAN reachability off by default.
- OMNIVOICE_BIND_HOST=0.0.0.0
# See the CPU service above — relaxes the loopback origin gate for the
# headless Docker deployment (issue #261). Set to 0 to re-enable it.
- OMNIVOICE_SERVER_MODE=1
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3900/health"]
interval: 30s
+12 -1
View File
@@ -115,7 +115,18 @@ Two paths are worth persisting across container restarts:
- **Container reports 0.2.7 but image is tagged 0.3.x:** This was a workflow bug
(fixes #249, #251) — the `:latest` tag was not being updated on release tag
pushes. Pull the image again after the fix is merged: `docker pull ghcr.io/debpalash/omnivoice-studio:latest`.
- **Checking which version is running:** `docker exec omnivoice python -c "import importlib.metadata; print(importlib.metadata.version('omnivoice-studio'))"` or visit the `/health` endpoint which includes the version field.
The running version is now shown in **Settings → About → Version** (read live
from the backend), so the web UI no longer displays a dash in Docker.
- **Checking which version is running:** `docker exec omnivoice python -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`.
- **"Loopback origin required" errors (and a blank version):** the desktop
build restricts the `/system/*` and `/api/settings/*` routes to a loopback
origin, but Docker's NAT makes every request look non-loopback, so the gate
used to 403 the whole admin UI (issue #261). The image now ships with
`OMNIVOICE_SERVER_MODE=1`, which relaxes that gate for the headless
deployment — exposure is instead governed by your `-p` port mapping (keep the
`127.0.0.1:` prefix to stay local) plus the optional share PIN. If you front
the container with your own auth proxy on loopback, set `OMNIVOICE_SERVER_MODE=0`
to re-enable the strict gate.
- **Media-preview 404 in LAN mode:** see the [LAN access](#lan-access) section
above — the `window.location.host` fix shipped in v0.3.
- **GPU not detected:** verify `docker run --rm --gpus all nvidia/cuda:12.8.0-base-ubuntu22.04 nvidia-smi` succeeds first.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "omnivoice-studio",
"private": true,
"version": "0.3.0",
"version": "0.3.2",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2878,7 +2878,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"dirs-next",
"enigo",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.3.0"
version = "0.3.2"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "OmniVoice Studio",
"version": "0.3.0",
"version": "0.3.2",
"identifier": "com.debpalash.omnivoice-studio",
"build": {
"frontendDist": "../dist",
+21 -21
View File
@@ -60,6 +60,7 @@ import { saveProject as apiSaveProject, loadProject as apiLoadProject, deletePro
import { exportAction, exportReveal, exportRecord } from './api/exports';
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio, playPing } from './utils/media';
import { browserDownload } from './utils/download';
import { checkForUpdate, fetchAppVersion } from './utils/updater';
import { syncChannel } from './utils/channelControl';
import i18n from './i18n';
@@ -506,6 +507,25 @@ function App() {
const handleNativeExport = async (e, sourceIdentifier, fallbackName, mode) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
// Browser / Docker web build: there is no Tauri shell, so the native save
// dialog is unavailable — invoking it throws "Cannot read properties of
// undefined (reading 'invoke')" (issue #256). Fall back to a plain HTTP
// blob download of the file already served at /audio/<path>.
if (!isTauri) {
const niceName = (fallbackName || sourceIdentifier || 'audio').split('/').pop();
try {
const finalName = await browserDownload(`${API}/audio/${sourceIdentifier}`, niceName);
toast.success(i18n.t('app.toast_downloaded', { name: finalName }));
try {
await exportRecord({ filename: finalName, destination_path: `~/Downloads/${finalName}`, mode });
loadExportHistory();
} catch (err) { console.warn('exportRecord (browser export path) failed:', err); }
} catch (err) {
console.error(err);
toast.error(i18n.t('app.toast_export_failed', { message: err?.message || err }));
}
return;
}
try {
const { save } = await import('@tauri-apps/plugin-dialog');
const ext = fallbackName.includes('.') ? fallbackName.split('.').pop() : 'wav';
@@ -527,14 +547,6 @@ function App() {
toast.error(i18n.t('app.toast_open_folder_failed', { message: err.message }));
}
};
const parseFilenameFromContentDisposition = (header) => {
if (!header) return null;
const utf8 = header.match(/filename\*=(?:UTF-8|utf-8)''([^;]+)/i);
if (utf8) { try { return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, '')); } catch { /* ignore */ } }
const plain = header.match(/filename="?([^";]+)"?/i);
return plain ? plain[1].trim() : null;
};
const triggerDownload = async (url, fallbackName) => {
const extGuess = (fallbackName.includes('.') ? fallbackName.split('.').pop() : 'bin').toLowerCase();
const modeGuess = ['mp4','mov','mkv','webm'].includes(extGuess)
@@ -573,19 +585,7 @@ function App() {
// Browser path: standard blob download.
try {
toast.loading(i18n.t('app.toast_processing', { name: fallbackName }), { id: fallbackName });
const response = await fetch(url);
if (!response.ok) throw new Error("Download failed");
const serverName = parseFilenameFromContentDisposition(response.headers.get('content-disposition'));
const finalName = serverName || fallbackName || 'download';
const blob = await response.blob();
const localUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = localUrl;
a.download = finalName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(localUrl);
const finalName = await browserDownload(url, fallbackName);
toast.success(i18n.t('app.toast_downloaded', { name: finalName }), { id: fallbackName });
try {
await exportRecord({ filename: finalName, destination_path: `~/Downloads/${finalName}`, mode: modeGuess });
+41 -33
View File
@@ -1352,7 +1352,7 @@ export default function Settings() {
<section className="settings-section">
<h2><Info size={16} color="#8ec07c" /> {t('settings.about')}</h2>
<Row label={t('about.app')} value="OmniVoice Studio" />
<Row label={t('about.version')} value={appVersion || '—'} mono />
<Row label={t('about.version')} value={appVersion || info?.app_version || '—'} mono />
<Row label={t('about.tauri_runtime')} value={tauriVersion || (isTauri() ? '—' : t('about.web_preview'))} mono />
<Row label={t('about.platform')} value={info?.platform || '—'} />
<Row label={t('about.architecture')} value={typeof navigator !== 'undefined' ? (navigator.userAgentData?.platform || navigator.platform || '—') : '—'} mono />
@@ -1371,41 +1371,49 @@ export default function Settings() {
<Row label={t('about.data_dir')} value={info?.data_dir || '—'} mono />
<Row label={t('about.outputs')} value={info?.outputs_dir || '—'} mono />
<Row label={t('about.crash_log')} value={info?.crash_log_path || '—'} mono />
<Row
label={t('about.update_channel')}
value={
<Segmented
size="xs"
value={updateChannel}
onChange={changeChannel}
items={[
{ value: 'stable', label: t('about.channel_stable') },
{ value: 'preview', label: t('about.channel_preview') },
]}
{/* Auto-updater + channel toggle are desktop-only (Tauri). The Docker
web build updates by pulling a new image tag, so hide these rows
there to avoid a non-functional control (issue #249). */}
{isTauri() && (
<>
<Row
label={t('about.update_channel')}
value={
<Segmented
size="xs"
value={updateChannel}
onChange={changeChannel}
items={[
{ value: 'stable', label: t('about.channel_stable') },
{ value: 'preview', label: t('about.channel_preview') },
]}
/>
}
/>
}
/>
<Row
label={t('about.update_endpoint')}
value={updateChannel === 'preview'
? 'releases/download/preview/latest.json'
: 'releases/latest/download/latest.json'}
mono
/>
{updateChannel === 'preview' && (
<p className="settings-muted">{t('about.channel_preview_hint')}</p>
<Row
label={t('about.update_endpoint')}
value={updateChannel === 'preview'
? 'releases/download/preview/latest.json'
: 'releases/latest/download/latest.json'}
mono
/>
{updateChannel === 'preview' && (
<p className="settings-muted">{t('about.channel_preview_hint')}</p>
)}
</>
)}
<div className="settings-link-row">
<Button
variant="primary"
size="md"
leading={<Download size={12} />}
onClick={checkForUpdates}
loading={updateState === 'checking' || updateState === 'downloading'}
disabled={!isTauri()}
>
{updateState === 'downloading' ? t('about.downloading') : t('about.check_updates')}
</Button>
{isTauri() && (
<Button
variant="primary"
size="md"
leading={<Download size={12} />}
onClick={checkForUpdates}
loading={updateState === 'checking' || updateState === 'downloading'}
>
{updateState === 'downloading' ? t('about.downloading') : t('about.check_updates')}
</Button>
)}
<Button
variant="subtle"
size="md"
+57
View File
@@ -0,0 +1,57 @@
// Shared browser-download helpers.
//
// Used by every "save this file" path that runs outside the Tauri desktop
// shell (browser dev mode + the Docker web-server build). In Tauri we use the
// native save dialog instead; calling that dialog when no Tauri runtime is
// present throws "Cannot read properties of undefined (reading 'invoke')"
// (issue #256), so callers must guard on isTauri and route here otherwise.
/**
* Parse a download filename out of a Content-Disposition header.
* Returns null when the header is absent or unparseable.
*/
export function parseFilenameFromContentDisposition(header) {
if (!header) return null;
const utf8 = header.match(/filename\*=(?:UTF-8|utf-8)''([^;]+)/i);
if (utf8) {
try {
return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, ''));
} catch {
/* fall through to the plain match */
}
}
const plain = header.match(/filename="?([^";]+)"?/i);
return plain ? plain[1].trim() : null;
}
/**
* Fetch `url` and trigger a standard browser blob download via a temporary
* <a download> element. Prefers the server-provided Content-Disposition
* filename, falling back to `fallbackName`. Returns the filename used.
*
* `deps` lets tests inject fetch/document/url without a real DOM + network.
*/
export async function browserDownload(url, fallbackName, deps = {}) {
const _fetch = deps.fetch ?? globalThis.fetch;
const doc = deps.document ?? globalThis.document;
const urlApi = deps.url ?? globalThis.URL;
const response = await _fetch(url);
if (!response.ok) throw new Error('Download failed');
const serverName = parseFilenameFromContentDisposition(
response.headers?.get?.('content-disposition'),
);
const finalName = serverName || fallbackName || 'download';
const blob = await response.blob();
const localUrl = urlApi.createObjectURL(blob);
const a = doc.createElement('a');
a.href = localUrl;
a.download = finalName;
doc.body.appendChild(a);
a.click();
doc.body.removeChild(a);
urlApi.revokeObjectURL(localUrl);
return finalName;
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect, vi } from 'vitest';
import { parseFilenameFromContentDisposition, browserDownload } from './download';
describe('parseFilenameFromContentDisposition', () => {
it('returns null for missing/empty headers', () => {
expect(parseFilenameFromContentDisposition(null)).toBe(null);
expect(parseFilenameFromContentDisposition('')).toBe(null);
expect(parseFilenameFromContentDisposition('inline')).toBe(null);
});
it('parses a plain filename', () => {
expect(parseFilenameFromContentDisposition('attachment; filename="clip.wav"')).toBe('clip.wav');
expect(parseFilenameFromContentDisposition('attachment; filename=clip.wav')).toBe('clip.wav');
});
it('prefers and decodes the RFC 5987 UTF-8 form', () => {
expect(parseFilenameFromContentDisposition("attachment; filename*=UTF-8''my%20clip.wav")).toBe('my clip.wav');
});
});
describe('browserDownload', () => {
function makeDeps({ ok = true, disposition = null } = {}) {
const anchor = { href: '', download: '', click: vi.fn() };
const body = { appendChild: vi.fn(), removeChild: vi.fn() };
const fetch = vi.fn(async () => ({
ok,
headers: { get: () => disposition },
blob: async () => new Blob(['data']),
}));
const document = { createElement: vi.fn(() => anchor), body };
const url = { createObjectURL: vi.fn(() => 'blob:local'), revokeObjectURL: vi.fn() };
return { deps: { fetch, document, url }, anchor, body, fetch, url };
}
it('downloads via a temporary <a> using the fallback name', async () => {
const { deps, anchor, url } = makeDeps();
const name = await browserDownload('http://x/audio/foo.wav', 'foo.wav', deps);
expect(name).toBe('foo.wav');
expect(anchor.download).toBe('foo.wav');
expect(anchor.href).toBe('blob:local');
expect(anchor.click).toHaveBeenCalledOnce();
expect(url.revokeObjectURL).toHaveBeenCalledWith('blob:local');
});
it('prefers the server-provided Content-Disposition filename', async () => {
const { deps, anchor } = makeDeps({ disposition: 'attachment; filename="server.mp3"' });
const name = await browserDownload('http://x/audio/foo.wav', 'foo.wav', deps);
expect(name).toBe('server.mp3');
expect(anchor.download).toBe('server.mp3');
});
it('throws when the response is not ok (so callers can surface an error toast)', async () => {
const { deps } = makeDeps({ ok: false });
await expect(browserDownload('http://x/missing', 'foo.wav', deps)).rejects.toThrow('Download failed');
});
// Regression for #256: in the Docker/browser build there is no Tauri shell,
// so the download path must never call the native save dialog (which throws
// "Cannot read properties of undefined (reading 'invoke')"). This helper is
// pure HTTP + DOM and works without any Tauri runtime present.
it('works with no Tauri globals defined', async () => {
expect(typeof window === 'undefined' || window.__TAURI_INTERNALS__).toBeFalsy();
const { deps } = makeDeps();
await expect(browserDownload('http://x/audio/foo.wav', 'foo.wav', deps)).resolves.toBe('foo.wav');
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "omnivoice"
version = "0.3.0"
version = "0.3.2"
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
readme = "README.md"
# Source-available under FSL-1.1-ALv2 (see LICENSE); each release converts to
+37
View File
@@ -109,6 +109,43 @@ def _seed_job(dc_module, tmp_path: Path, duration: float, scene_cuts=None) -> st
# Tests
# ---------------------------------------------------------------------------
def test_transcribe_stream_surfaces_model_load_failure(tmp_path, monkeypatch):
"""Regression #255: when the model fails to load, the SSE transcribe stream
must emit a structured `error` event carrying the real cause not silently
drop the connection (the UI renders a dropped stream as a misleading generic
"Transcribe stream dropped … Likely ASR backend failed to load").
Drives the route's async generator directly (no TestClient/lifespan) — the
preflight-error path yields a single event with no executor/Queue, so it
stays isolated from the app event loop.
"""
import asyncio
from api.routers import dub_core as dc
job_id = "t_modelfail"
dc._dub_jobs[job_id] = {"audio_path": str(tmp_path / "a.wav"), "vocals_path": None}
async def _boom():
raise RuntimeError("CUDA driver init failed: simulated")
monkeypatch.setattr(dc, "get_model", _boom)
async def _collect():
resp = await dc.dub_transcribe_stream(job_id)
parts = []
async for chunk in resp.body_iterator:
parts.append(chunk.decode() if isinstance(chunk, (bytes, bytearray)) else str(chunk))
return "".join(parts)
try:
body = asyncio.run(_collect())
finally:
dc._dub_jobs.pop(job_id, None)
assert "event: error" in body, body
assert "CUDA driver init failed: simulated" in body, body
@pytest.mark.xfail(
reason="dub_core._transcribe was refactored to route through "
"services.asr_backend.get_active_asr_backend; the MagicMock fixture "
+55
View File
@@ -0,0 +1,55 @@
"""`require_loopback` gate contract (issue #261).
The gate must stay strict on the desktop build (non-loopback 403, which is the
PR #81 trust boundary), but become a no-op in the headless Docker server mode,
where Docker's NAT makes the loopback origin unenforceable and exposure is
governed by the port mapping + the share PIN instead.
"""
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from api.dependencies import require_loopback
def _req(host):
"""Minimal stand-in for a Starlette Request — the gate only reads client.host."""
return SimpleNamespace(client=SimpleNamespace(host=host) if host else None)
@pytest.fixture(autouse=True)
def _clear_server_mode(monkeypatch):
# Start each test from the desktop default regardless of the ambient env.
monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False)
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"])
def test_loopback_always_allowed(host):
require_loopback(_req(host)) # must not raise
def test_non_loopback_rejected_by_default():
with pytest.raises(HTTPException) as exc:
require_loopback(_req("172.17.0.1")) # Docker bridge gateway
assert exc.value.status_code == 403
assert "loopback" in str(exc.value.detail).lower()
def test_missing_client_rejected_by_default():
with pytest.raises(HTTPException):
require_loopback(_req(None))
@pytest.mark.parametrize("val", ["1", "true", "TRUE", "yes", "on"])
def test_server_mode_allows_non_loopback(monkeypatch, val):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", val)
require_loopback(_req("172.17.0.1")) # must not raise
require_loopback(_req("127.0.0.1")) # loopback still fine
@pytest.mark.parametrize("val", ["0", "false", "no", "", "off"])
def test_falsey_server_mode_keeps_gate_strict(monkeypatch, val):
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", val)
with pytest.raises(HTTPException):
require_loopback(_req("10.0.0.5"))
+12
View File
@@ -33,6 +33,18 @@ def test_system_info_smoke(client):
body = r.json()
assert "data_dir" in body
assert "device" in body
# The Docker/web build has no Tauri getVersion(); Settings → About reads the
# running version from here so it shows the real version, not a dash (#249).
from core.version import APP_VERSION
assert body["app_version"] == APP_VERSION
def test_health_exposes_version(client):
"""`/health` is the zero-auth way to confirm the running version (#249)."""
from core.version import APP_VERSION
body = client.get("/health").json()
assert body["status"] == "ok"
assert body["version"] == APP_VERSION
def test_system_logs_smoke(client):
Generated
+1 -1
View File
@@ -3058,7 +3058,7 @@ wheels = [
[[package]]
name = "omnivoice"
version = "0.3.0"
version = "0.3.2"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },