Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa64dcaa92 | ||
|
|
63a0d00f09 | ||
|
|
4e65774610 | ||
|
|
e740786a08 | ||
|
|
35b62ad0c0 | ||
|
|
27b4e151e1 | ||
|
|
b4f1fe18d7 | ||
|
|
cec15070a5 | ||
|
|
2aa6e3502b | ||
|
|
621c263354 | ||
|
|
e2a33e8c40 |
@@ -540,7 +540,13 @@ jobs:
|
||||
|
||||
# Gather artifact paths per matrix leg's `bundles` (msi/app/dmg/deb/appimage/updater).
|
||||
# `find` is portable across all three runners (Git Bash on Windows).
|
||||
mapfile -t ARTIFACTS < <(find "$BUNDLE_DIR" -type f \
|
||||
# NB: macOS runners use /bin/bash 3.2, which has no `mapfile` (a bash 4+
|
||||
# builtin) — using it 127'd this step and dropped the macOS SHA256SUMS
|
||||
# for v0.3.1 and v0.3.2. A `while read` loop is portable to bash 3.2.
|
||||
ARTIFACTS=()
|
||||
while IFS= read -r artifact; do
|
||||
ARTIFACTS+=("$artifact")
|
||||
done < <(find "$BUNDLE_DIR" -type f \
|
||||
\( -name "*.dmg" -o -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" \
|
||||
-o -name "*.msi" -o -name "*.msi.sig" \
|
||||
-o -name "*.AppImage" -o -name "*.AppImage.sig" \
|
||||
|
||||
@@ -6,6 +6,68 @@ 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.4] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **Transcription on Windows + NVIDIA failed with `Could not locate
|
||||
cudnn_ops_infer64_8.dll`.** WhisperX/faster-whisper need cuDNN 8 (via
|
||||
CTranslate2); when the side-loaded `cudnn8_compat` libs are missing, the
|
||||
**PyTorch Whisper** backend (Settings → Models) now works as a drop-in
|
||||
fallback — it builds its own transformers pipeline on PyTorch's cuDNN-9
|
||||
stack, with no CTranslate2/cuDNN-8 dependency and no
|
||||
`OMNIVOICE_PRELOAD_TTS_ASR=1` required. (#255)
|
||||
|
||||
## [0.3.3] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- **Settings → About showed the wrong architecture in the Docker/web build.**
|
||||
The "Architecture" row rendered the *client browser's* platform
|
||||
(`navigator.platform` → e.g. "Win32"); it now reports the **server's** CPU
|
||||
architecture from the backend (`platform.machine()`), correct for both the
|
||||
desktop app and Docker. The blank version/GPU/RAM/VRAM in the same report
|
||||
were the loopback-gate 403s already fixed in v0.3.2. (#262)
|
||||
|
||||
### CI
|
||||
- The release SHA-256 checksum step no longer uses `mapfile` (a bash 4+
|
||||
builtin) — it broke on the macOS runner's bash 3.2 and dropped the macOS
|
||||
`SHA256SUMS` for v0.3.1/v0.3.2. Now portable to bash 3.2.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -384,24 +384,38 @@ 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:
|
||||
# The PyTorch-Whisper backend lazily builds its own pipeline
|
||||
# when no preloaded `_asr_pipe` is present (issue #255), so it
|
||||
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1 — don't reject it
|
||||
# here; any load failure surfaces per-chunk with a real cause.
|
||||
_asr_backend = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
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 +443,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 +559,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, "
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import uuid
|
||||
import psutil
|
||||
import asyncio
|
||||
@@ -15,6 +16,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 +170,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,
|
||||
@@ -179,6 +182,7 @@ def system_info():
|
||||
"device": get_best_device(),
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
"arch": platform.machine(),
|
||||
"ffmpeg_ok": bool(_ffmpeg),
|
||||
"ffmpeg_path": _ffmpeg or "",
|
||||
"proxy_url": os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or "",
|
||||
@@ -193,6 +197,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),
|
||||
@@ -204,6 +209,7 @@ def system_info():
|
||||
"device": "cpu",
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
"arch": platform.machine(),
|
||||
"proxy_url": "",
|
||||
"share_enabled": network_share.get_state().enabled,
|
||||
"share_port": network_share.get_state().share_port,
|
||||
|
||||
@@ -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
|
||||
@@ -36,6 +37,7 @@ class SystemInfoResponse(BaseModel):
|
||||
device: str = "cpu"
|
||||
python: str = ""
|
||||
platform: str = ""
|
||||
arch: str = ""
|
||||
error: str | None = None
|
||||
ffmpeg_ok: bool = False
|
||||
ffmpeg_path: str = ""
|
||||
|
||||
@@ -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.4"
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -577,22 +577,32 @@ class PyTorchWhisperBackend(ASRBackend):
|
||||
def _ensure_pipe(self):
|
||||
if self._pipe is not None:
|
||||
return
|
||||
# Fall back to grabbing the TTS model's ASR head.
|
||||
import asyncio
|
||||
from services.model_manager import get_model
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_running():
|
||||
raise RuntimeError(
|
||||
"PyTorchWhisperBackend needs the ASR pipe — pass it via constructor "
|
||||
"when calling from an async context."
|
||||
)
|
||||
model = loop.run_until_complete(get_model())
|
||||
except RuntimeError:
|
||||
model = asyncio.run(get_model())
|
||||
self._pipe = getattr(model, "_asr_pipe", None)
|
||||
if self._pipe is None:
|
||||
raise RuntimeError("Loaded TTS model has no `_asr_pipe` attribute.")
|
||||
# Build a standalone transformers Whisper pipeline on demand. This runs
|
||||
# on PyTorch's own stack (cuDNN 9 ships with torch), so it works as a
|
||||
# fallback on machines where WhisperX / faster-whisper can't load
|
||||
# cuDNN 8 (the `cudnn_ops_infer64_8.dll` failure, issue #255) — and it
|
||||
# needs neither OMNIVOICE_PRELOAD_TTS_ASR=1 nor a loaded TTS model.
|
||||
# When the TTS model already has an ASR head, dub_core passes it via the
|
||||
# constructor and this path is skipped.
|
||||
import torch
|
||||
from transformers import pipeline as hf_pipeline
|
||||
from services.model_manager import get_best_device
|
||||
|
||||
model_name = os.environ.get(
|
||||
"OMNIVOICE_PYTORCH_ASR_MODEL", "openai/whisper-large-v3-turbo"
|
||||
)
|
||||
device = get_best_device()
|
||||
asr_dtype = torch.float16 if str(device).startswith("cuda") else torch.float32
|
||||
logger.info(
|
||||
"PyTorchWhisperBackend: loading standalone ASR pipeline %s on %s",
|
||||
model_name, device,
|
||||
)
|
||||
self._pipe = hf_pipeline(
|
||||
"automatic-speech-recognition",
|
||||
model=model_name,
|
||||
dtype=asr_dtype,
|
||||
device_map=device,
|
||||
)
|
||||
|
||||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||||
import soundfile as sf
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -121,7 +121,24 @@ falling back to faster-whisper`.
|
||||
path and is still fast. If you want the latest CT2 wheels, run `uv sync`
|
||||
from a fresh source checkout.
|
||||
|
||||
## 10. IndexTTS / CosyVoice / ChatterboxTTS clash
|
||||
## 10. Windows: `Could not locate cudnn_ops_infer64_8.dll` during transcription
|
||||
|
||||
**Symptom:** on Windows + NVIDIA, transcription/dubbing fails and the backend
|
||||
log shows `Could not locate cudnn_ops_infer64_8.dll`. Settings → Models shows
|
||||
WhisperX or faster-whisper selected.
|
||||
|
||||
**Cause:** WhisperX and faster-whisper run on **CTranslate2**, which needs
|
||||
**cuDNN 8**, but PyTorch 2.8 ships cuDNN 9. OmniVoice side-loads a cuDNN-8 copy
|
||||
from `.venv\Lib\site-packages\cudnn8_compat\`; if that folder is missing
|
||||
(some upgrade paths don't install it), CTranslate2 can't find the DLL.
|
||||
|
||||
**Fix:** switch the ASR backend to **PyTorch Whisper** in **Settings → Models**.
|
||||
It runs on PyTorch's own stack (cuDNN 9, bundled with torch) and needs no
|
||||
cuDNN-8 DLL — it loads its Whisper pipeline on demand (no extra env var). To
|
||||
keep using faster-whisper/WhisperX instead, reinstall to restore the bundled
|
||||
`cudnn8_compat` libraries.
|
||||
|
||||
## 11. IndexTTS / CosyVoice / ChatterboxTTS clash
|
||||
|
||||
**Symptom:** installing one of these engines breaks the others — e.g. after
|
||||
installing CosyVoice, IndexTTS errors out with import conflicts.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -2878,7 +2878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.0"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"dirs-next",
|
||||
"enigo",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.3.0"
|
||||
version = "0.3.4"
|
||||
description = "OmniVoice Studio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "OmniVoice Studio",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.4",
|
||||
"identifier": "com.debpalash.omnivoice-studio",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+21
-21
@@ -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 });
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface SystemInfo {
|
||||
app_version?: string;
|
||||
python?: string;
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
device?: string;
|
||||
data_dir?: string;
|
||||
outputs_dir?: string;
|
||||
|
||||
@@ -1352,10 +1352,10 @@ 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 />
|
||||
<Row label={t('about.architecture')} value={info?.arch || '—'} mono />
|
||||
<Row label={t('about.python')} value={info?.python || '—'} mono />
|
||||
<Row label={t('about.compute_device')} value={info?.device || '—'} mono />
|
||||
<Row label={t('about.gpu_active')} value={hw?.gpu_active
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnivoice"
|
||||
version = "0.3.0"
|
||||
version = "0.3.4"
|
||||
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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""PyTorch-Whisper backend must work as a standalone fallback (issue #255).
|
||||
|
||||
On machines where WhisperX / faster-whisper can't load cuDNN 8
|
||||
(`cudnn_ops_infer64_8.dll` missing), the PyTorch-Whisper backend should build
|
||||
its own transformers pipeline on demand — without OMNIVOICE_PRELOAD_TTS_ASR=1
|
||||
and without loading the full TTS model.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from services import asr_backend as ab
|
||||
|
||||
|
||||
def test_is_available_when_transformers_present():
|
||||
ok, msg = ab.PyTorchWhisperBackend.is_available()
|
||||
assert ok is True
|
||||
assert msg == "ready"
|
||||
|
||||
|
||||
def test_reuses_constructor_pipe_without_building(monkeypatch):
|
||||
sentinel = object()
|
||||
be = ab.PyTorchWhisperBackend(asr_pipe=sentinel)
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("must not build a pipeline when one was passed in")
|
||||
|
||||
# transformers.pipeline is imported lazily inside _ensure_pipe.
|
||||
fake_tf = types.ModuleType("transformers")
|
||||
fake_tf.pipeline = _boom
|
||||
monkeypatch.setitem(sys.modules, "transformers", fake_tf)
|
||||
|
||||
be._ensure_pipe()
|
||||
assert be._pipe is sentinel
|
||||
|
||||
|
||||
def test_lazy_builds_standalone_pipeline(monkeypatch):
|
||||
"""No preloaded pipe → build a standalone transformers ASR pipeline, with no
|
||||
call into the TTS model loader (get_model)."""
|
||||
captured = {}
|
||||
|
||||
def fake_pipeline(task, **kw):
|
||||
captured["task"] = task
|
||||
captured["kw"] = kw
|
||||
return lambda *a, **k: {"chunks": []}
|
||||
|
||||
fake_tf = types.ModuleType("transformers")
|
||||
fake_tf.pipeline = fake_pipeline
|
||||
monkeypatch.setitem(sys.modules, "transformers", fake_tf)
|
||||
monkeypatch.setattr("services.model_manager.get_best_device", lambda: "cpu")
|
||||
|
||||
# Guard: building the standalone pipe must NOT pull in the full TTS model.
|
||||
import services.model_manager as mm
|
||||
|
||||
def _no_get_model(*a, **k):
|
||||
raise AssertionError("standalone ASR build must not call get_model()")
|
||||
|
||||
monkeypatch.setattr(mm, "get_model", _no_get_model, raising=False)
|
||||
|
||||
be = ab.PyTorchWhisperBackend(asr_pipe=None)
|
||||
be._ensure_pipe()
|
||||
|
||||
assert be._pipe is not None
|
||||
assert captured["task"] == "automatic-speech-recognition"
|
||||
assert captured["kw"]["model"] # a concrete model name was chosen
|
||||
|
||||
|
||||
def test_pytorch_asr_model_overridable_via_env(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_pipeline(task, **kw):
|
||||
captured["kw"] = kw
|
||||
return object()
|
||||
|
||||
fake_tf = types.ModuleType("transformers")
|
||||
fake_tf.pipeline = fake_pipeline
|
||||
monkeypatch.setitem(sys.modules, "transformers", fake_tf)
|
||||
monkeypatch.setattr("services.model_manager.get_best_device", lambda: "cpu")
|
||||
monkeypatch.setenv("OMNIVOICE_PYTORCH_ASR_MODEL", "openai/whisper-small")
|
||||
|
||||
ab.PyTorchWhisperBackend(asr_pipe=None)._ensure_pipe()
|
||||
assert captured["kw"]["model"] == "openai/whisper-small"
|
||||
@@ -33,6 +33,22 @@ 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
|
||||
# Settings → About → Architecture must reflect the SERVER's machine, not the
|
||||
# client browser's navigator.platform (which showed "Win32" in Docker, #262).
|
||||
import platform as _pf
|
||||
assert body["arch"] == _pf.machine()
|
||||
|
||||
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user