fix(desktop): make recording and dubbing reliable (#1481)

* fix(ui): keep scaled desktop shell responsive

* fix(linux): support desktop microphone capture

* fix(ui): update the centered VoiceStudio brand

* fix(audio): fall back when recorder start is unsupported

* fix(desktop): use the app header as titlebar

* feat(audio): add live microphone input controls

* fix(dub): recover from missing transcription models

* fix(dub): make pipeline stages actionable

* fix(asr): recover low-memory transcription

* docs: record desktop reliability fixes

* fix(ui): use semantic error banner border

* fix(dub): harden recovery and recording fallbacks
This commit is contained in:
Palash Debnath
2026-08-10 22:43:37 +00:00
committed by GitHub
parent e6728cf068
commit 7cde2fcd06
83 changed files with 2937 additions and 372 deletions
+28
View File
@@ -28,8 +28,24 @@ Read it before opening a proposal; the licence check in particular ends most of
- [Bun](https://bun.sh/) (frontend package manager)
- [uv](https://docs.astral.sh/uv/) (Python environment manager)
- [ffmpeg](https://ffmpeg.org/) (audio/video processing)
- [Rust / Cargo](https://rustup.rs/) (desktop shell only)
- Python 3.10+ (managed automatically by `uv`)
Linux desktop development also needs WebKitGTK/GTK development libraries. On
Debian or Ubuntu, install the same packages used by CI:
```bash
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libgtk-3-dev libpango1.0-dev libcairo2-dev \
libsoup-3.0-dev libgdk-pixbuf-2.0-dev \
libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev \
libasound2-dev build-essential curl wget file
```
See the [Linux source-build guide](../docs/install/linux.md#building-from-source)
for Fedora and Arch packages.
### Clone & Run
```bash
@@ -68,6 +84,18 @@ names: there is no `desktop=prod` (note the **hyphen** in `desktop-prod`).
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
After installing Rust with rustup on macOS/Linux, either open a new terminal or
load Cargo into the current one before starting the desktop app:
```bash
source "$HOME/.cargo/env"
bun desktop
```
On Linux, errors such as `Package gdk-3.0 was not found`, `pango.pc` missing,
or `javascriptcoregtk-4.1` missing mean the native packages above were not
installed; changing `PKG_CONFIG_PATH` does not fix libraries that are absent.
If the app opens but stays on the **setup splash with no buttons**, the Python
backend didn't finish starting — the splash surfaces the stall reason, a log
panel, and a **Retry** button (and Settings → Logs → Backend has the full trace).
+4
View File
@@ -30,6 +30,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Added
- Voice recording now offers microphone and channel selection with a live input-level meter on every desktop platform. (#1481)
- Settings → Appearance → **Navigation style** switches the workspace switcher between the icon rail down the window edge and browser-style tabs across the title bar. Both offer the same workspaces; the choice sticks across launches, and the rail stays the default. Tab labels fold down to icons when the title bar runs out of room — the workspace you're in keeps its name. (#1412)
- Portable mode lets you choose the folder — press **Change…** on the first-run setup screen and put the whole install on an external drive. It also stops being greyed out after a default Program Files install. (#766)
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
@@ -42,6 +43,9 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Fixed
- Basic Dubbing translation remains available without an LLM; Cinematic and Autofit now degrade through the existing Fast translation path instead of blocking the quality choice. (#1481)
- Linux microphone recording now falls back to WAV when WebKit cannot encode MediaRecorder audio, and desktop scaling/titlebar controls remain responsive at every UI scale. (#1481)
- Dubbing can install a missing ASR model and retry the same job, navigate back through completed stages, and finish transcription under low GPU memory without producing an empty transcript. (#1481)
- Filenames and other outside data can no longer forge extra lines or terminal commands in backend and frontend diagnostic logs. (#1457)
- Backend journal, dictation reset, voice-catalog, and crash-notification failures are now visible and retryable instead of being silently ignored. (#1459)
- Backend failures keep raw tracebacks, local paths and credentials in the local log instead of returning them in API responses. (#1454)
+2
View File
@@ -295,6 +295,8 @@ another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
> If Dubbing needs an ASR model that is not installed yet, it offers the recommended download in place, shows its progress, and retries transcription on the same job when the model is ready.
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
</details>
+18 -4
View File
@@ -9,7 +9,9 @@ Protocol:
Client sends binary audio frames (16-bit PCM or WebM/Opus blobs)
Server sends JSON messages:
Opt-in AEC mode (``?aec=1[&sr=16000]``, parity Action 8b): for dictating
Raw PCM mode (``?pcm=1&sr=16000``) is the container-free fallback for
WebViews without MediaRecorder. Opt-in AEC mode
(``?aec=1[&sr=16000]``, parity Action 8b): for dictating
while the app plays audio. Frames must be raw int16 mono PCM, each tagged
with a 1-byte prefix 0x00 = microphone, 0x01 = playback reference. The
server runs an NLMS echo canceller, cleaning the mic against the reference
@@ -68,6 +70,19 @@ _AEC_NEAR = 0x00 # microphone frame (clean it, then buffer for ASR)
_AEC_FAR = 0x01 # playback reference frame (feed the echo model only)
def _requested_pcm_sample_rate(query_params) -> int | None:
"""Return a bounded PCM rate for ``?pcm=1``/``?aec=1`` sessions."""
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
aec = query_params.get("aec") in ("1", "true", "on")
if not raw_pcm and not aec:
return None
try:
sample_rate = int(query_params.get("sr", "16000"))
except (TypeError, ValueError):
return 16000
return sample_rate if 8000 <= sample_rate <= 96000 else 16000
def _demux_aec_frame(data: bytes) -> tuple[str, bytes]:
"""Split a prefixed AEC binary frame into ``(kind, pcm)``.
@@ -210,12 +225,11 @@ async def ws_transcribe(websocket: WebSocket):
# identical legacy behaviour. When on, frames are 1-byte-tagged raw PCM
# and the cleaned mic stream is muxed via stdlib wave (not ffmpeg).
aec = None
pcm_sr: int | None = None
pcm_sr = _requested_pcm_sample_rate(websocket.query_params)
if websocket.query_params.get("aec") in ("1", "true", "on"):
try:
pcm_sr = int(websocket.query_params.get("sr", "16000"))
from services.aec import NlmsEchoCanceller
aec = NlmsEchoCanceller(sample_rate=pcm_sr)
aec = NlmsEchoCanceller(sample_rate=pcm_sr or 16000)
logger.info("AEC enabled for dictation session (sr=%d)", pcm_sr)
except Exception as e:
# Bad sr or import failure → fall back to plain dictation.
+37 -21
View File
@@ -769,6 +769,16 @@ async def dub_transcribe_stream(
preflight_payload = _missing
if _missing is None:
try:
# Free recoverable TTS VRAM before ASR chooses its
# device. Probing first falsely routed Whisper to
# CPU even when this offload made CUDA viable.
try:
await asyncio.get_running_loop().run_in_executor(
_cpu_pool, offload_tts_for_asr
)
_tts_offloaded["v"] = True
except Exception as e:
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
# 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.
@@ -898,17 +908,6 @@ async def dub_transcribe_stream(
"chunk_s": transcribe_chunk_s,
})
# 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).
# 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)
# Restore is now owed on every exit path, not just success (#1191).
_tts_offloaded["v"] = True
except Exception as e:
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
all_segments: list[dict] = []
# Words (global-timeline) retained so diarization can re-split a segment
# that spans two speakers' turns at the word boundary (#486).
@@ -916,6 +915,7 @@ async def dub_transcribe_stream(
detected_lang = None
next_seg_id = 0
chunk_errors: list[str] = []
chunk_error_codes: list[str] = []
# Speaker turns from an ASR backend that diarizes inline (FunASR cam++).
# When present, _diarize() uses them and skips pyannote (Phase 2, #182).
asr_speaker_turns: list[dict] = []
@@ -960,10 +960,20 @@ async def dub_transcribe_stream(
continue
turns.append({"start": s0 + offset, "end": s1 + offset, "speaker": spk})
return {"chunks": shifted, "language": r.get("language"), "speaker_turns": turns}
except Exception:
logger.error("Chunk transcription failed (backend=%s)", _asr_backend.id)
except Exception as exc:
# Keep diagnostics local and fixed-shape. In particular,
# CUDA OOM is a distinct, actionable recovery class rather
# than the generic "no segments" dead end.
is_memory = isinstance(exc, torch.OutOfMemoryError)
logger.error(
"Chunk transcription failed (backend=%s; class=%s; details withheld)",
_asr_backend.id,
type(exc).__name__,
)
from core.public_errors import stream_failure
failure = stream_failure("transcription_failed")
failure = stream_failure(
"transcription_memory" if is_memory else "transcription_failed"
)
return {
"chunks": [],
"language": None,
@@ -986,7 +996,6 @@ async def dub_transcribe_stream(
# worker, and raises the actionable ASRTimeoutError. Run it as
# a task and poll so we can keep yielding pings — the
# EventSource connection drops without them.
pool_reset_by_guard = False
task = asyncio.ensure_future(run_transcribe_guarded(
_gpu_pool, _transcribe_chunk,
what=f"Dub chunk {i + 1}/{chunks_n}",
@@ -1004,7 +1013,6 @@ async def dub_transcribe_stream(
# The guard already reset the pool; keep the actionable
# message (it names the durable fixes, and — after repeated
# timeouts — the crash-isolated engine escape hatch).
pool_reset_by_guard = True
logger.error(
"Transcribe chunk %d/%d timed out after %.0fs (attempt %d/%d, job=%s)",
i + 1, chunks_n, transcribe_timeout_s, _attempt,
@@ -1028,11 +1036,14 @@ async def dub_transcribe_stream(
"Retrying transcribe chunk %d/%d after failure/timeout (next attempt %d/%d, job=%s)",
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, log_safe(job_id),
)
if not pool_reset_by_guard:
reset_pool_after_wedge(
_gpu_pool, what=f"Dub chunk {i + 1}/{chunks_n}")
# A completed exception did not wedge the worker. Resetting
# the pool here leaked a healthy executor on every ordinary
# decode failure; run_transcribe_guarded already resets the
# pool on the only case that needs it: a real timeout.
if part.get("error"):
chunk_errors.append(part["error"])
if part.get("error_code"):
chunk_error_codes.append(part["error_code"])
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, log_safe(part["error"]))
if detected_lang is None and part.get("language"):
detected_lang = part["language"]
@@ -1097,7 +1108,9 @@ async def dub_transcribe_stream(
seen.add(s)
uniq.append(s)
if uniq:
detail = "Transcription produced no segments. " + " | ".join(uniq[:3])
# Chunk failures already carry a complete recovery message.
# Do not prepend another generic sentence to it.
detail = " | ".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")
@@ -1110,7 +1123,10 @@ async def dub_transcribe_stream(
"check that the source has an audible speech track."
)
logger.error("transcribe yielded 0 segments (job=%s): %s", log_safe(job_id), log_safe(detail))
yield _sse_event("error", {"detail": detail, "retryable": True})
payload = {"detail": detail, "retryable": True}
if chunk_error_codes:
payload["code"] = chunk_error_codes[0]
yield _sse_event("error", payload)
yield _sse_event("done", {})
return
+22 -1
View File
@@ -13,6 +13,7 @@ import json
import logging
import os
import sys
import threading
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
@@ -69,6 +70,13 @@ def clear_install_cooldowns() -> None:
# cancelled, and clears the cooldown so a cancel isn't rate-limited.
_cancelled: set[str] = set()
# One worker per repo. Repeated clicks and feature-level recovery can converge
# on the same install; starting a second snapshot_download against the same HF
# cache is wasteful and can corrupt the user-visible progress stream.
_active_installs: set[str] = set()
_active_installs_lock = threading.Lock()
_install_tasks: set[asyncio.Task] = set()
def _download_max_workers() -> int:
"""Parallel-FILES worker count for snapshot_download (FDL-02). Default 8 —
@@ -394,6 +402,10 @@ async def install_model(req: InstallModelRequest):
f"Retry in {remaining}s or check your network."
),
)
with _active_installs_lock:
if req.repo_id in _active_installs:
return {"status": "already_running", "repo_id": req.repo_id}
_active_installs.add(req.repo_id)
loop = asyncio.get_running_loop()
def _do():
@@ -652,8 +664,17 @@ async def install_model(req: InstallModelRequest):
_cancelled.discard(req.repo_id)
download_aggregator.finish(req.repo_id)
hf_progress.current_repo_id.reset(token)
with _active_installs_lock:
_active_installs.discard(req.repo_id)
loop.create_task(asyncio.to_thread(_do))
try:
task = loop.create_task(asyncio.to_thread(_do))
_install_tasks.add(task)
task.add_done_callback(_install_tasks.discard)
except Exception:
with _active_installs_lock:
_active_installs.discard(req.repo_id)
raise
return {"status": "install_started", "repo_id": req.repo_id}
+9
View File
@@ -43,6 +43,15 @@ def stream_failure(code: str) -> dict[str, object]:
"detail": "Transcription failed. Check the selected ASR engine and try again.",
"retryable": True,
},
"transcription_memory": {
"code": "transcription_memory",
"detail": (
"Transcription ran out of GPU memory. Close other GPU apps or "
"Flush models, then try again; VoiceStudio will use CPU when "
"the remaining GPU memory is too low."
),
"retryable": True,
},
"transcription_timeout": {
"code": "transcription_timeout",
"detail": (
+45 -4
View File
@@ -1232,6 +1232,11 @@ class PyTorchWhisperBackend(ASRBackend):
# Reuses the `_asr_pipe` attached to the TTS model when available.
self._pipe = asr_pipe
# whisper-large-v3-turbo occupies roughly 3.2 GiB before generation adds
# its encoder/decoder workspace. Loading it onto a nearly full card works,
# then the first transcribe fails with a CUDA OOM and yields zero segments.
_CUDA_VRAM_BUDGET_GB = 5.0
@classmethod
def is_available(cls) -> tuple[bool, str]:
try:
@@ -1240,6 +1245,40 @@ class PyTorchWhisperBackend(ASRBackend):
except ImportError as e:
return False, f"transformers not installed: {e}"
@classmethod
def _pick_device(cls) -> str:
from services.model_manager import get_best_device
device = str(get_best_device())
if not device.startswith("cuda") or os.environ.get(
"OMNIVOICE_ASR_VRAM_PREFLIGHT", "1"
).strip().lower() in ("0", "false", "no"):
return device
try:
import torch
free, _total = torch.cuda.mem_get_info()
free_gb = free / 1024**3
except Exception: # noqa: BLE001 — an unavailable probe must not block ASR
return device
if free_gb >= cls._CUDA_VRAM_BUDGET_GB:
return device
logger.warning(
"PyTorch Whisper VRAM preflight: %.1f GB free < %.1f GB needed "
"for reliable CUDA transcription — using CPU instead. Close other "
"GPU apps or Flush models to restore GPU-speed ASR.",
free_gb,
cls._CUDA_VRAM_BUDGET_GB,
)
return "cpu"
def ensure_loaded(self) -> None:
# Unlike the CTranslate2 backends, this fallback used to inherit the
# protocol's no-op loader. Import/model failures therefore appeared on
# every chunk as the misleading "produced no segments" result. Load it
# once during the stream preflight so the real failure is reported once.
self._ensure_pipe()
def _ensure_pipe(self):
if self._pipe is not None:
return
@@ -1252,12 +1291,10 @@ class PyTorchWhisperBackend(ASRBackend):
# 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()
device = self._pick_device()
asr_dtype = torch.float16 if str(device).startswith("cuda") else torch.float32
logger.info(
"PyTorchWhisperBackend: loading standalone ASR pipeline %s on %s",
@@ -1268,7 +1305,11 @@ class PyTorchWhisperBackend(ASRBackend):
"automatic-speech-recognition",
model=model_name,
dtype=asr_dtype,
device_map=device,
# `device_map="cpu"` only controls weight placement; the
# pipeline can still choose CUDA as its execution device.
# `device` is the pipeline-level contract and keeps the
# low-VRAM fallback entirely on CPU.
device=device,
)
except Exception as e:
# #549: an incomplete transformers install fails to build the ASR
+1
View File
@@ -85,6 +85,7 @@ def test_float16_unsupported_falls_back_to_int8(monkeypatch):
return object() # cuda int8 succeeds
monkeypatch.setattr(whisperx, "load_model", fake_load_model)
monkeypatch.setattr(WhisperXBackend, "_free_vram_gb", staticmethod(lambda: 10.0))
be = WhisperXBackend()
be._device, be._compute_type = "cuda", "float16"
+2
View File
@@ -62,6 +62,8 @@ Priority: `duration` > `speed`.
>
> **Tip — reference-clip quality transfers.** Zero-shot cloning mirrors the acoustics of the reference clip, not just the voice: a clip recorded in an echoey room clones echoey. Record dry and close-mic for clean output. No effect preset adds reverb unless you choose one that declares it (Cinematic, Warm).
For an in-app recording, choose the microphone and Auto, Mono, or Stereo in the Voice panel. While recording, the input meter confirms whether VoiceStudio is receiving a usable signal; monitoring is visual and never plays the microphone through the speakers.
## Long-Form Generation
To support stable long-form speech generation with low VRAM consumption, the text is automatically split into smaller segments when the estimated duration of the generated speech exceeds `audio_chunk_duration`, with each segment producing approximately `audio_chunk_duration` seconds of audio. This approach allows the model to accept arbitrarily long text and generate arbitrarily long speech with near-constant VRAM consumption.
+28 -4
View File
@@ -33,7 +33,12 @@ Everything above, plus the toolchain:
```bash
# Debian / Ubuntu
sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev build-essential
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libgtk-3-dev libpango1.0-dev libcairo2-dev \
libsoup-3.0-dev libgdk-pixbuf-2.0-dev \
libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev \
libasound2-dev build-essential curl wget file
# Fedora
sudo dnf install webkit2gtk4.1-devel libappindicator-gtk3-devel librsvg2-devel openssl-devel
@@ -51,11 +56,30 @@ Everything above, plus the toolchain:
git clone https://github.com/debpalash/VoiceStudio.git
cd VoiceStudio
bun install
bun run desktop-prod
source "$HOME/.cargo/env" # only needed in a shell opened before rustup finished
bun desktop # development build with hot reload
```
The first launch creates the Python venv via `uv`, syncs deps, and downloads
model weights (~2.4 GB). Subsequent launches start in seconds.
Use `bun run desktop-prod` instead when you need to build and launch the
production bundle. Both commands create the Python environment via `uv`, sync
dependencies, and start the backend automatically; do not start the backend in
a second terminal.
The first Rust build takes longer because Cargo compiles the Tauri shell. If it
fails with `Package gdk-3.0 was not found`, `pango.pc` missing,
`libsoup-3.0` missing, or `javascriptcoregtk-4.1` missing, install the complete
Debian/Ubuntu package block above. Those messages mean the development
libraries are absent, not that `PKG_CONFIG_PATH` needs changing. Verify them
with:
```bash
pkg-config --exists \
gdk-3.0 pango cairo libsoup-3.0 javascriptcoregtk-4.1 gdk-pixbuf-2.0 \
&& echo "Tauri system libraries are ready"
```
The first app launch downloads model weights on demand. Subsequent launches
reuse the Rust build, Python environment, and installed models.
## Install (AppImage)
+8
View File
@@ -681,6 +681,14 @@ up, the shell now prints the exit code and where to look (the cargo/tauri
output above it, plus `omnivoice.log` and `backend_err.log` in your VoiceStudio
data folder) instead of exiting silently.
If Cargo stops before the window is built with `Package gdk-3.0 was not found`,
`pango.pc` missing, `libsoup-3.0` missing, or `javascriptcoregtk-4.1` missing,
the Ubuntu/Debian WebKitGTK development packages are absent. Install the full
package block in the [Linux source-build guide](linux.md#building-from-source),
then rerun `source "$HOME/.cargo/env"` and `bun desktop`. Do not set a custom
`PKG_CONFIG_PATH` unless the libraries were deliberately installed outside the
system package manager.
**Still stuck?** Open the details, copy the output, and file it with **Report**
— that output is the thing that makes the failure diagnosable.
+15 -9
View File
@@ -1,5 +1,5 @@
// AudioWorklet processor for the opt-in dictate-over-playback AEC (parity
// Action 8). Accumulates mono Float32 input into fixed-size frames and posts
// Action 8). Accumulates Float32 input into fixed-size frames and posts
// them to the main thread, which converts them to tagged int16 PCM. The same
// processor serves both the microphone capture and the playback-reference tap
// — only the wiring on the main thread differs.
@@ -12,21 +12,27 @@ class AecFrameEmitter extends AudioWorkletProcessor {
super();
const frame =
(options && options.processorOptions && options.processorOptions.frameSize) || 320;
this._frameSize = frame; // 320 samples = 20 ms @ 16 kHz
this._buf = new Float32Array(frame);
this._frameSize = frame; // 320 frames = 20 ms @ 16 kHz
this._channels = Math.max(
1,
Math.min(2, (options && options.processorOptions && options.processorOptions.channels) || 1),
);
this._buf = new Float32Array(frame * this._channels);
this._n = 0;
}
process(inputs) {
const input = inputs[0];
// input[0] is the first (mono) channel; absent when upstream is idle.
// Interleave requested channels. A missing secondary channel mirrors the
// first so the WAV layout stays valid on single-channel devices.
if (input && input[0]) {
const ch = input[0];
for (let i = 0; i < ch.length; i++) {
this._buf[this._n++] = ch[i];
if (this._n >= this._frameSize) {
for (let i = 0; i < input[0].length; i++) {
for (let channel = 0; channel < this._channels; channel++) {
this._buf[this._n++] = (input[channel] || input[0])[i];
}
if (this._n >= this._buf.length) {
// Copy out — the buffer is reused for the next frame.
this.port.postMessage(this._buf.slice(0, this._frameSize));
this.port.postMessage(this._buf.slice());
this._n = 0;
}
}
@@ -13,6 +13,7 @@
"core:window:allow-set-fullscreen",
"core:window:allow-minimize",
"core:window:allow-close",
"core:webview:allow-set-webview-zoom",
"dialog:allow-save",
"dialog:allow-open",
"dialog:allow-message",
+1
View File
@@ -22,6 +22,7 @@
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"maximized": true,
+32 -23
View File
@@ -72,6 +72,7 @@ import { toastErrorWithReport } from './utils/errorToast';
import { listenDictationNotice, showDictationNotice } from './utils/dictationNotice';
import { addBreadcrumb } from './utils/breadcrumbs';
import { appShellClasses } from './utils/appShellClasses';
import { applyUiScale } from './utils/uiScaleEngine';
import { recordValueMoment } from './utils/donationMoments';
import {
POPULAR_LANGS,
@@ -145,26 +146,13 @@ function App() {
return () => ro.disconnect();
}, []);
// Engine capability probe (#523/#524): does this WebView honor `zoom` as a
// LAYOUT transform? Chromium (WebView2 / macOS WebKit) and modern WebKitGTK
// do; older WebKitGTK (Linux) treats it as a no-op. The .app-container sizing
// branches on the result (index.css) so the shell fills the window on BOTH
// no black band on WebKitGTK, no clipped Generate/Settings CTAs on Chromium.
// Measuring a real zoomed element is robust where @supports(zoom)/UA-sniffing
// aren't (both report "supported" on WebKitGTK even when zoom doesn't lay out).
// Desktop UI scale belongs at the webview boundary. A CSS `zoom` probe can
// report the expected bounding box on WebKitGTK even when the painted shell
// still occupies only the upper-left of the window. Tauri's native zoom keeps
// layout and paint in agreement; browser/dev sessions retain the CSS path.
useLayoutEffect(() => {
let honored = true;
try {
const probe = document.createElement('div');
probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:100px;height:100px;zoom:2';
document.body.appendChild(probe);
honored = Math.round(probe.getBoundingClientRect().width) >= 150;
probe.remove();
} catch {
honored = true;
} // safe default: the existing zoom path
document.documentElement.dataset.zoomLayout = honored ? 'on' : 'off';
}, []);
void applyUiScale(uiScale);
}, [uiScale]);
const shellSizeClass =
shellWidth <= 600 ? 'shell-mini' : shellWidth <= 1100 ? 'shell-narrow' : '';
const theme = useAppStore((s) => s.theme);
@@ -433,8 +421,19 @@ function App() {
const [compareProgress, setCompareProgress] = useState('');
// MIC RECORDING
const { isRecording, isCleaning, recordingTime, startRecording, stopRecording } =
useRecording(ingestRefAudio);
const {
isRecording,
isCleaning,
recordingTime,
audioInputs,
selectedAudioInputId,
setSelectedAudioInputId,
channelMode,
setChannelMode,
inputLevelStore,
startRecording,
stopRecording,
} = useRecording(ingestRefAudio);
// DUB STATE
const dubJobId = useAppStore((s) => s.dubJobId);
@@ -525,10 +524,12 @@ function App() {
setPreviewAudios,
transcribeElapsed,
transcribeProgress,
asrInstall,
handleDubUpload: _handleDubUpload,
handleDubIngestUrl,
handleDubAbort,
handleDubRetryTranscribe,
handleInstallMissingAsr,
handleDubStop,
handleDubGenerate,
handleCleanupSegments,
@@ -1176,7 +1177,7 @@ function App() {
// awaiting_setup stage would never get to render.
if (bootstrapStage === 'awaiting_setup') {
return (
<div style={{ zoom: uiScale }}>
<div className="app-bootstrap-scale" style={{ '--ui-scale': uiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
</div>
);
@@ -1189,7 +1190,7 @@ function App() {
// flash the empty studio before the wizard has a chance to mount.
if (!setupChecked) {
return (
<div style={{ zoom: uiScale }}>
<div className="app-bootstrap-scale" style={{ '--ui-scale': uiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
</div>
);
@@ -1472,6 +1473,7 @@ function App() {
dubLocalBlobUrl={dubLocalBlobUrl}
transcribeElapsed={transcribeElapsed}
transcribeProgress={transcribeProgress}
asrInstall={asrInstall}
translateProvider={translateProvider}
setTranslateProvider={setTranslateProvider}
onGlossaryChange={setGlossaryTerms}
@@ -1487,6 +1489,7 @@ function App() {
handleDubUpload={handleDubUpload}
handleDubIngestUrl={handleDubIngestUrl}
handleDubRetryTranscribe={handleDubRetryTranscribe}
handleInstallMissingAsr={handleInstallMissingAsr}
handleDubStop={handleDubStop}
handleDubGenerate={handleDubGenerate}
handleDubDownload={handleDubDownload}
@@ -1596,6 +1599,12 @@ function App() {
isRecording={isRecording}
isCleaning={isCleaning}
recordingTime={recordingTime}
audioInputs={audioInputs}
selectedAudioInputId={selectedAudioInputId}
setSelectedAudioInputId={setSelectedAudioInputId}
channelMode={channelMode}
setChannelMode={setChannelMode}
inputLevelStore={inputLevelStore}
vdStates={vdStates}
setVdStates={setVdStates}
isGenerating={isGenerating}
+59 -32
View File
@@ -13,6 +13,7 @@ import { showMicDeniedGuide } from '../utils/micDeniedToast';
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
import { createWaveform } from './captureWaveform';
import { emitDictationNotice } from '../utils/dictationNotice';
import { audioFormatForMimeType, startSupportedMediaRecorder } from '../utils/mediaRecorder';
// True inside the Tauri shell (desktop app / widget window); false in the
// browser webui / Docker, where the native commands don't exist. Gating on
@@ -101,8 +102,8 @@ const IDLE_VISIBLE_POLL_MS = 600;
// A dictation model id is a sherpa-onnx live model when it carries the
// `sherpa-` prefix the backend assigns (see services/sherpa_dictation.py). Only
// then do we open the low-latency raw-PCM streaming path; anything else (or no
// selection) falls through to the legacy MediaRecorder/WebM path unchanged.
// then do we open the low-latency raw-PCM streaming path. Other models use a
// supported MediaRecorder container when available, or raw PCM on WebKitGTK.
export function isSherpaModel(id) {
return typeof id === 'string' && id.startsWith('sherpa-');
}
@@ -366,6 +367,7 @@ export default function CaptureWidget({ onDismiss }) {
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
const recordingFormatRef = useRef({ mimeType: 'audio/webm', extension: 'webm' });
const streamRef = useRef(null);
const timerRef = useRef(null);
const wsRef = useRef(null);
@@ -383,6 +385,7 @@ export default function CaptureWidget({ onDismiss }) {
// raw PCM via an AudioWorklet and tag mic/far-end frames instead of using
// MediaRecorder. All AEC state lives in refs so the default path is inert.
const aecModeRef = useRef(false);
const pcmModeRef = useRef(false);
const aecStopRef = useRef(null); // async teardown of the mic worklet graph
const farEndUnsubRef = useRef(null); // unsubscribe from the far-end bus
@@ -401,6 +404,7 @@ export default function CaptureWidget({ onDismiss }) {
console.warn('mic worklet teardown failed:', err);
}
aecModeRef.current = false;
pcmModeRef.current = false;
}, []);
// Hydrate dictation prefs (enabled / mode / model) from the backend once. The
@@ -564,7 +568,7 @@ export default function CaptureWidget({ onDismiss }) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
if (aecModeRef.current || sherpaModeRef.current) teardownAec();
if (aecModeRef.current || sherpaModeRef.current || pcmModeRef.current) teardownAec();
setState('idle');
setTranscript('');
setPartialText('');
@@ -595,7 +599,7 @@ export default function CaptureWidget({ onDismiss }) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
if (aecModeRef.current || sherpaModeRef.current) {
if (aecModeRef.current || sherpaModeRef.current || pcmModeRef.current) {
teardownAec();
}
if (streamRef.current) {
@@ -782,6 +786,7 @@ export default function CaptureWidget({ onDismiss }) {
});
streamRef.current = stream;
chunksRef.current = [];
recordingFormatRef.current = { mimeType: 'audio/webm', extension: 'webm' };
wsPendingRef.current = [];
wsHadFinalRef.current = false;
committedRef.current = [];
@@ -804,10 +809,6 @@ export default function CaptureWidget({ onDismiss }) {
dismissTimerRef.current = null;
}
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
// Read prefs at start time (avoids stale closures). AEC is opt-in; the
// sherpa live engine is selected when the persisted dictation model is a
// sherpa-onnx model that path streams raw int16 PCM and emits live
@@ -815,10 +816,29 @@ export default function CaptureWidget({ onDismiss }) {
const aecOn = useAppStore.getState().aecEnabled === true;
const modelId = useAppStore.getState().dictationModelId;
const sherpaOn = isSherpaModel(modelId);
const supportedRecorder =
aecOn || sherpaOn
? null
: startSupportedMediaRecorder(stream, {
onData: (e) => {
if (e.data.size === 0) return;
if (e.data.type) recordingFormatRef.current = audioFormatForMimeType(e.data.type);
chunksRef.current.push(e.data);
void e.data.arrayBuffer().then((buf) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(buf);
else wsPendingRef.current.push(buf);
});
},
onStop: () => {},
});
const pcmFallback = !aecOn && !sherpaOn && supportedRecorder === null;
if (supportedRecorder) mediaRecorderRef.current = supportedRecorder.recorder;
aecModeRef.current = aecOn;
sherpaModeRef.current = sherpaOn;
pcmModeRef.current = pcmFallback;
// Raw-PCM transport is used whenever AEC or the sherpa live engine is on.
const pcmMode = aecOn || sherpaOn;
const pcmMode = aecOn || sherpaOn || pcmFallback;
// Open WebSocket BEFORE starting capture.
try {
@@ -827,14 +847,31 @@ export default function CaptureWidget({ onDismiss }) {
// sherpa ?model=<id>&sr=16000 (raw int16 PCM, live partials)
// AEC ?aec=1&sr=16000 (tagged raw PCM, NLMS canceller)
// both ?model=<id>&aec=1&sr=16000
// neither /ws/transcribe (legacy MediaRecorder/WebM)
// no recorder ?pcm=1&sr=16000 (WebKitGTK fallback)
// otherwise /ws/transcribe (negotiated media container)
const params = [];
if (sherpaOn) params.push(`model=${encodeURIComponent(modelId)}`);
if (aecOn) params.push('aec=1');
if (pcmFallback) params.push('pcm=1');
if (pcmMode) params.push('sr=16000');
const wsPath = params.length ? `/ws/transcribe?${params.join('&')}` : '/ws/transcribe';
const ws = new WebSocket(buildWsUrl(wsPath));
ws.binaryType = 'arraybuffer';
const failRawPcmSession = () => {
if (
wsHadFinalRef.current ||
!(sherpaModeRef.current || aecModeRef.current || pcmModeRef.current)
) {
return false;
}
wsHadFinalRef.current = true;
stopCaptureGraph();
setTrayRecording(false);
setModelStatus(null);
setErrorInfo({ kind: 'server', message: '' });
setState('error');
return true;
};
ws.onopen = () => {
for (const buf of wsPendingRef.current) {
try {
@@ -975,7 +1012,7 @@ export default function CaptureWidget({ onDismiss }) {
toastAsrModelMissing(asrMissingPayload(msg));
setErrorInfo({ kind: 'transcription', message: t('asr_missing.message') });
setState('error');
} else if (sherpaModeRef.current || aecModeRef.current) {
} else if (sherpaModeRef.current || aecModeRef.current || pcmModeRef.current) {
// Raw-PCM paths have no WebM blob to re-POST surface the
// backend's error instead of leaving the pill wedged in
// "Transcribing" forever.
@@ -992,6 +1029,7 @@ export default function CaptureWidget({ onDismiss }) {
};
ws.onerror = () => {
wsRef.current = null;
failRawPcmSession();
};
ws.onclose = () => {
wsRef.current = null;
@@ -1005,6 +1043,7 @@ export default function CaptureWidget({ onDismiss }) {
}
return;
}
if (failRawPcmSession()) return;
if (
!wsHadFinalRef.current &&
mediaRecorderRef.current &&
@@ -1091,22 +1130,8 @@ export default function CaptureWidget({ onDismiss }) {
}
mediaRecorderRef.current = null;
} else {
const recorder = new MediaRecorder(stream, { mimeType });
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
e.data.arrayBuffer().then((buf) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(buf);
} else {
wsPendingRef.current.push(buf);
}
});
}
};
recorder.onstop = () => {};
recorder.start(250);
const { recorder, mimeType, extension } = supportedRecorder;
recordingFormatRef.current = { mimeType, extension };
mediaRecorderRef.current = recorder;
}
// The session may already have RESOLVED while the mic graph was being
@@ -1141,6 +1166,7 @@ export default function CaptureWidget({ onDismiss }) {
stopCaptureGraph();
return;
}
stopCaptureGraph();
// Distinguish "permission denied" ( per-OS settings hint) from
// "no device" / "device busy" / anything else (#323).
toast.error(micErrorMessage(t, err), { duration: 6000 });
@@ -1193,13 +1219,14 @@ export default function CaptureWidget({ onDismiss }) {
const sendForTranscription = useCallback(async () => {
if (wsHadFinalRef.current) return;
// No WebM blob exists on any raw-PCM path (AEC or sherpa live) the WS is
// the only result channel there.
if (aecModeRef.current || sherpaModeRef.current) return;
// No encoded blob exists on a raw-PCM path the WS is the only result
// channel there.
if (aecModeRef.current || sherpaModeRef.current || pcmModeRef.current) return;
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const { mimeType, extension } = recordingFormatRef.current;
const blob = new Blob(chunksRef.current, { type: mimeType });
const formData = new FormData();
formData.append('audio', blob, 'capture.webm');
formData.append('audio', blob, `capture.${extension}`);
formData.append('mode', captureMode);
try {
@@ -132,6 +132,7 @@ describe('CaptureWidget', () => {
mocks.holder.paste = async () => undefined;
mocks.holder.calls = [];
mocks.holder.onFrame = null;
mocks.state.dictationModelId = 'sherpa-parakeet-tdt-v3';
FakeWebSocket.instances = [];
global.WebSocket = FakeWebSocket;
global.MediaRecorder = FakeMediaRecorder;
@@ -151,6 +152,19 @@ describe('CaptureWidget', () => {
const pasteCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_paste');
const typeCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_type');
it('falls back to raw PCM when MediaRecorder cannot be constructed', async () => {
mocks.state.dictationModelId = null;
delete global.MediaRecorder;
render(withI18n(<CaptureWidget />));
const ws = await startSession();
expect(ws.url).toContain('/ws/transcribe?pcm=1&sr=16000');
expect(mocks.holder.onFrame).toBeTypeOf('function');
act(() => mocks.holder.onFrame(new Float32Array([0.25, -0.25])));
expect(ws.sent.some((value) => value instanceof ArrayBuffer)).toBe(true);
});
it('renders truthful model status from {type:"status"} frames', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
+59 -15
View File
@@ -16,6 +16,9 @@ import {
Library,
FileText,
Trash2,
Minus,
Square,
X,
} from 'lucide-react';
import { Button, Badge } from '../ui';
import NotificationPanel from './NotificationPanel';
@@ -112,6 +115,18 @@ function WaveBars({ color = '#f3a5b6', active }) {
);
}
async function runWindowAction(action) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const appWindow = getCurrentWindow();
if (action === 'minimize') await appWindow.minimize();
else if (action === 'maximize') await appWindow.toggleMaximize();
else if (action === 'close') await appWindow.close();
} catch {
console.warn('Window control action failed');
}
}
export default function Header({
mode,
setMode,
@@ -125,6 +140,7 @@ export default function Header({
// breadcrumb + wordmark normally sit the tabs already say where you are,
// and two answers to that question in one bar is one too many.
const tabsInTitlebar = navStyle === 'tabs';
const showWindowControls = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
const { t } = useTranslation();
// Sysinfo is subscribed here (not in App via useAppData) so the 5s poll
// only re-renders the header chrome, not the whole App tree.
@@ -233,7 +249,6 @@ export default function Header({
) : (
/* Left: view title + breadcrumb */
<div className="flex items-center gap-[14px] justify-self-start min-w-0">
<div className="min-w-[80px] shrink-0" />
<div className="inline-flex items-center gap-[6px] h-[var(--chrome-pill-h)] [font-family:var(--font-sans)] max-[961px]:gap-[5px]">
<span
className="w-[7px] h-[7px] rounded-full shrink-0 [animation:hqPulse_2.4s_ease-in-out_infinite] max-[821px]:hidden"
@@ -281,24 +296,22 @@ export default function Header({
{!tabsInTitlebar && (
<div className="flex items-center gap-2 justify-self-center pointer-events-none whitespace-nowrap">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
viewBox="0 0 32 32"
fill="none"
stroke="#f3a5b6"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
data-testid="voice-studio-logo"
className="size-6 overflow-visible"
>
<circle cx="12" cy="12" r="10" opacity="0.18" fill="#f3a5b6" />
<circle cx="12" cy="12" r="10" />
<path d="M12 6v12" />
<path d="M8 9v6" />
<path d="M16 9v6" />
<path
d="M2 16c2.2 0 2.5-4 4.2-4 2.1 0 2.2 8 4.1 8 2.1 0 2.2-16 4.3-16 2.3 0 2.1 24 4.3 24 2.1 0 2.2-19 4.2-19 2.2 0 2.1 12 4 12 1.7 0 2-5 3.9-5"
stroke="var(--chrome-accent)"
strokeWidth="2.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="text-[0.92rem] font-semibold text-[var(--chrome-fg)] tracking-[0.02em] [font-family:var(--font-sans)] not-italic">
Omni<span className="text-[var(--chrome-accent)]">Voice</span>
Voice<span className="text-[var(--chrome-accent)]">Studio</span>
</span>
</div>
)}
@@ -455,6 +468,37 @@ export default function Header({
)}
</div>
)}
{showWindowControls && (
<div className="ml-1 flex h-full shrink-0 items-stretch" data-testid="window-controls">
<button
type="button"
className="flex h-7 w-9 items-center justify-center border-0 bg-transparent text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
aria-label={t('common.minimize_window')}
title={t('common.minimize_window')}
onClick={() => void runWindowAction('minimize')}
>
<Minus size={13} />
</button>
<button
type="button"
className="flex h-7 w-9 items-center justify-center border-0 bg-transparent text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
aria-label={t('common.maximize_restore_window')}
title={t('common.maximize_restore_window')}
onClick={() => void runWindowAction('maximize')}
>
<Square size={10} />
</button>
<button
type="button"
className="flex h-7 w-9 items-center justify-center border-0 bg-transparent text-[var(--chrome-fg-muted)] hover:bg-[#c42b1c] hover:text-white"
aria-label={t('common.close_window')}
title={t('common.close_window')}
onClick={() => void runWindowAction('close')}
>
<X size={13} />
</button>
</div>
)}
</div>
</div>
);
@@ -1,6 +1,13 @@
import { useSyncExternalStore } from 'react';
import { UploadCloud, X, Save, Dice5 } from 'lucide-react';
import { Button, Input } from '../../ui';
import { Button, Input, Select } from '../../ui';
import MicButton from './MicButton';
import WaveformPlayer from '../WaveformPlayer';
const EMPTY_LEVEL_STORE = {
getSnapshot: () => 0,
subscribe: () => () => {},
};
export default function AudioMethodPanel({
t,
@@ -12,6 +19,12 @@ export default function AudioMethodPanel({
isCleaning,
isRecording,
recordingTime,
audioInputs = [],
selectedAudioInputId = '',
setSelectedAudioInputId,
channelMode = 'auto',
setChannelMode,
inputLevelStore = EMPTY_LEVEL_STORE,
startRecording,
stopRecording,
refText,
@@ -29,61 +42,135 @@ export default function AudioMethodPanel({
setProfileName,
handleSaveProfile,
}) {
const inputLevel = useSyncExternalStore(inputLevelStore.subscribe, inputLevelStore.getSnapshot);
return (
<div>
{/* Saved voices now live in the right-side WorkspaceVoices panel. */}
{!selectedProfile && (
<div className="flex gap-[8px] items-stretch">
<input
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
onChange={(e) => {
const f = e.target.files[0];
ingestRefAudio(f);
e.target.value = '';
}}
className="dub-hidden-file"
id="audio-upload"
/>
<label
htmlFor="audio-upload"
// Migrated `.file-drag` + the old `.clone-drop-zone` padding override
// utilities (fast shadcn, CloneDesignTab.css deleted). `is-dragging` stays
// a JS-toggled state marker, matched via the `[&.is-dragging]:` variant.
className="flex-1 [border:1px_dashed_var(--chrome-border-strong)] rounded-[var(--chrome-radius-pill)] p-[6px] text-center cursor-pointer flex flex-col items-center gap-[4px] bg-transparent [transition:border-color_var(--dur-fast),background_var(--dur-fast)] hover:[border-color:var(--chrome-accent)] hover:bg-[var(--chrome-accent-bg)] [&.is-dragging]:[border-color:var(--chrome-accent)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
const okType =
file &&
(file.type.startsWith('audio/') ||
/\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestRefAudio(file);
}}
>
<UploadCloud color="#a89984" size={18} />
<p className="m-0 text-[0.72rem] text-[color:var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] font-medium">
{refAudio ? <span className="text-fg">{refAudio.name}</span> : t('clone.drop_audio')}
</p>
</label>
<>
<div className="flex gap-[8px] items-stretch">
<input
type="file"
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
onChange={(e) => {
const f = e.target.files[0];
ingestRefAudio(f);
e.target.value = '';
}}
className="dub-hidden-file"
id="audio-upload"
/>
<label
htmlFor="audio-upload"
// Migrated `.file-drag` + the old `.clone-drop-zone` padding override
// utilities (fast shadcn, CloneDesignTab.css deleted). `is-dragging` stays
// a JS-toggled state marker, matched via the `[&.is-dragging]:` variant.
className="flex-1 [border:1px_dashed_var(--chrome-border-strong)] rounded-[var(--chrome-radius-pill)] p-[6px] text-center cursor-pointer flex flex-col items-center gap-[4px] bg-transparent [transition:border-color_var(--dur-fast),background_var(--dur-fast)] hover:[border-color:var(--chrome-accent)] hover:bg-[var(--chrome-accent-bg)] [&.is-dragging]:[border-color:var(--chrome-accent)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
onDragOver={(e) => {
e.preventDefault();
e.currentTarget.classList.add('is-dragging');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('is-dragging');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('is-dragging');
const file = e.dataTransfer.files[0];
const okType =
file &&
(file.type.startsWith('audio/') ||
/\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
if (okType) ingestRefAudio(file);
}}
>
<UploadCloud color="#a89984" size={18} />
<p className="m-0 text-[0.72rem] text-[color:var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] font-medium">
{refAudio ? (
<span className="text-fg">{refAudio.name}</span>
) : (
t('clone.drop_audio')
)}
</p>
</label>
<MicButton
isCleaning={isCleaning}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
<MicButton
isCleaning={isCleaning}
isRecording={isRecording}
recordingTime={recordingTime}
onStart={startRecording}
onStop={stopRecording}
/>
</div>
<div className="mt-2 grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-2 max-[520px]:grid-cols-1">
<label className="min-w-0 text-[length:var(--text-xs)] text-fg-muted">
<span className="mb-1 block">{t('recording.input_device')}</span>
<Select
size="sm"
className="w-full"
value={selectedAudioInputId}
onChange={(event) => setSelectedAudioInputId?.(event.target.value)}
disabled={isRecording || isCleaning}
>
<option value="">{t('recording.default_input')}</option>
{audioInputs.map((device, index) => (
<option key={device.deviceId || `input-${index}`} value={device.deviceId}>
{device.label || t('recording.microphone_number', { number: index + 1 })}
</option>
))}
</Select>
</label>
<label className="min-w-0 text-[length:var(--text-xs)] text-fg-muted">
<span className="mb-1 block">{t('recording.channels')}</span>
<Select
size="sm"
className="w-full"
value={channelMode}
onChange={(event) => setChannelMode?.(event.target.value)}
disabled={isRecording || isCleaning}
>
<option value="auto">{t('recording.channels_auto')}</option>
<option value="mono">{t('recording.channels_mono')}</option>
<option value="stereo">{t('recording.channels_stereo')}</option>
</Select>
</label>
</div>
{isRecording && (
<div
className="mt-2 flex items-center gap-2 text-[length:var(--text-xs)]"
role="status"
aria-live="polite"
>
<span
className={`size-2 shrink-0 rounded-full ${inputLevel >= 0.025 ? 'bg-success' : 'bg-fg-muted'}`}
aria-hidden="true"
/>
<meter
className="h-2 min-w-0 flex-1 accent-[var(--color-success)]"
min="0"
max="1"
value={inputLevel}
aria-label={t('recording.input_level')}
/>
<span className={inputLevel >= 0.025 ? 'text-success' : 'text-fg-muted'}>
{inputLevel >= 0.025
? t('recording.input_detected')
: t('recording.no_input_detected')}
</span>
</div>
)}
</>
)}
{refAudio && !selectedProfile && (
<WaveformPlayer
src={refAudio}
source="clone-reference"
height={34}
compact
className="mt-2"
/>
)}
{selectedProfile && (
@@ -0,0 +1,85 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
vi.mock('../WaveformPlayer', () => ({
default: ({ src }) => <div data-testid="reference-waveform">{src.name}</div>,
}));
import AudioMethodPanel from './AudioMethodPanel';
import { createInputLevelStore } from '../../utils/audioInput';
const baseProps = {
t: (key) => key,
selectedProfile: null,
setSelectedProfile: vi.fn(),
profiles: [],
ingestRefAudio: vi.fn(),
isCleaning: false,
isRecording: false,
recordingTime: 0,
startRecording: vi.fn(),
stopRecording: vi.fn(),
refText: '',
setRefText: vi.fn(),
instruct: '',
setInstruct: vi.fn(),
defineMethod: 'audio',
designSeed: null,
setDesignSeed: vi.fn(),
keepSeed: false,
setKeepSeed: vi.fn(),
showSaveProfile: false,
setShowSaveProfile: vi.fn(),
profileName: '',
setProfileName: vi.fn(),
handleSaveProfile: vi.fn(),
audioInputs: [
{ deviceId: 'built-in', label: 'Built-in microphone' },
{ deviceId: 'usb', label: '' },
],
selectedAudioInputId: '',
setSelectedAudioInputId: vi.fn(),
channelMode: 'auto',
setChannelMode: vi.fn(),
inputLevelStore: createInputLevelStore(),
};
describe('AudioMethodPanel', () => {
it('previews the cleaned reference with the shared waveform player', () => {
const refAudio = new File(['clean'], 'recording_clean.wav', { type: 'audio/wav' });
render(<AudioMethodPanel {...baseProps} refAudio={refAudio} />);
expect(screen.getByTestId('reference-waveform')).toHaveTextContent('recording_clean.wav');
});
it('selects an input device and channel mode', async () => {
const setDevice = vi.fn();
const setChannels = vi.fn();
render(
<AudioMethodPanel
{...baseProps}
setSelectedAudioInputId={setDevice}
setChannelMode={setChannels}
/>,
);
fireEvent.change(screen.getByLabelText('recording.input_device'), {
target: { value: 'built-in' },
});
fireEvent.change(screen.getByLabelText('recording.channels'), { target: { value: 'mono' } });
expect(setDevice).toHaveBeenCalledWith('built-in');
expect(setChannels).toHaveBeenCalledWith('mono');
expect(screen.getByRole('option', { name: 'recording.microphone_number' })).toBeInTheDocument();
});
it('shows whether live microphone input is detected', () => {
const inputLevelStore = createInputLevelStore(0.01);
render(<AudioMethodPanel {...baseProps} isRecording inputLevelStore={inputLevelStore} />);
expect(screen.getByText('recording.no_input_detected')).toBeInTheDocument();
expect(screen.getByRole('meter', { name: 'recording.input_level' })).toHaveValue(0.01);
act(() => inputLevelStore.set(0.3));
expect(screen.getByText('recording.input_detected')).toBeInTheDocument();
});
});
+8 -1
View File
@@ -33,6 +33,8 @@ export default function DubHeader({
qcRunning,
handleDubQc,
setExportOpen,
pipelineSteps,
onPipelineStep,
}) {
return (
<div className="flex flex-col gap-[2px] min-w-0 px-[10px] py-[4px] shrink-0 bg-[var(--color-bg-elev-1)] rounded-md mb-[2px]">
@@ -156,7 +158,12 @@ export default function DubHeader({
</div>
</div>
</div>
<DubPipelineStepper dubStep={dubStep} inline />
<DubPipelineStepper
dubStep={dubStep}
inline
selectableSteps={pipelineSteps}
onStepSelect={onPipelineStep}
/>
</div>
);
}
+1 -37
View File
@@ -99,17 +99,12 @@ export default function DubLeftColumn({
engines,
setTranslateProvider,
setTranslateQuality,
llmEndpoint,
multiLangMode,
setMultiLangMode,
multiLangs,
setMultiLangs,
editSegments,
}) {
// High-quality (Cinematic/Autofit) translation needs an LLM. When one isn't
// configured, we route the user straight to the LLM Providers setup instead
// of dead-ending on a toast (#838).
const openSettingsTab = useAppStore((s) => s.openSettingsTab);
// Two-stage LLM translation quality only meaningful (and only rendered)
// when the LLM engine is the active translator. Persisted prefs.
const autoGlossary = useAppStore((s) => s.autoGlossary);
@@ -675,38 +670,7 @@ export default function DubLeftColumn({
className="w-full"
size="sm"
value={translateQuality}
onChange={(v) => {
// #372/#838: Cinematic AND Autofit need an LLM (Autofit rewrites
// each line to fit its segment's time budget). If none is
// configured, don't dead-end offer a one-click jump to the
// LLM Providers setup and point at the timing payoff.
const needsLLM = v === 'cinematic' || v === 'autofit';
if (needsLLM && llmEndpoint && !llmEndpoint.available) {
toast(
(tt) => (
<span className="flex items-center gap-[10px]">
{t('dub.hq_needs_llm_hint', {
defaultValue:
'High-quality translation fits each line to its segment time using a local or cloud LLM. Set one up to enable it.',
})}
<Button
size="sm"
variant="primary"
onClick={() => {
toast.dismiss(tt.id);
openSettingsTab('llm-providers');
}}
>
{t('dub.set_up_llm', { defaultValue: 'Set up' })}
</Button>
</span>
),
{ icon: '️', duration: 10000 },
);
return;
}
setTranslateQuality(v);
}}
onChange={setTranslateQuality}
items={[
{ value: 'fast', label: t('dub.fast_quality') },
{
@@ -24,6 +24,7 @@ const DUB_PIPELINE = [
const DUB_PHASE_BY_STEP = {
idle: 0,
uploading: 1,
'installing-asr': 2,
transcribing: 2,
editing: 3,
generating: 4,
@@ -31,11 +32,12 @@ const DUB_PHASE_BY_STEP = {
done: 5,
};
function DubPipelineStepper({ dubStep, inline = false }) {
function DubPipelineStepper({ dubStep, inline = false, selectableSteps = [], onStepSelect }) {
const { t } = useTranslation();
const current = DUB_PHASE_BY_STEP[dubStep] ?? 0;
const busy =
dubStep === 'uploading' ||
dubStep === 'installing-asr' ||
dubStep === 'transcribing' ||
dubStep === 'generating' ||
dubStep === 'stopping';
@@ -50,6 +52,16 @@ function DubPipelineStepper({ dubStep, inline = false }) {
const active = i === current;
const spinning = active && busy;
const Icon = done ? Check : spinning ? Loader : p.Icon;
const label = t(p.key, { defaultValue: p.fallback });
const selectable = !active && selectableSteps.includes(p.id) && onStepSelect;
const content = (
<>
<span className="dub-stepper__icon">
<Icon size={13} className={spinning ? 'dub-stepper__spin' : ''} aria-hidden="true" />
</span>
<span className="dub-stepper__label">{label}</span>
</>
);
return (
<div
key={p.id}
@@ -63,10 +75,20 @@ function DubPipelineStepper({ dubStep, inline = false }) {
.filter(Boolean)
.join(' ')}
>
<span className="dub-stepper__icon">
<Icon size={13} className={spinning ? 'dub-stepper__spin' : ''} />
</span>
<span className="dub-stepper__label">{t(p.key, { defaultValue: p.fallback })}</span>
{selectable ? (
<button
type="button"
className="dub-stepper__action"
onClick={() => onStepSelect(p.id)}
title={label}
>
{content}
</button>
) : (
<span className="dub-stepper__action" aria-current={active ? 'step' : undefined}>
{content}
</span>
)}
</div>
);
})}
@@ -0,0 +1,24 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { describe, expect, it, vi } from 'vitest';
import i18n from '../../i18n';
import DubPipelineStepper from './DubPipelineStepper';
describe('DubPipelineStepper navigation', () => {
it('makes reachable stages keyboard-accessible actions', () => {
const onStepSelect = vi.fn();
render(
<I18nextProvider i18n={i18n}>
<DubPipelineStepper
dubStep="idle"
selectableSteps={['prepare', 'transcribe']}
onStepSelect={onStepSelect}
/>
</I18nextProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Transcribe' }));
expect(onStepSelect).toHaveBeenCalledWith('transcribe');
expect(screen.getByText('Edit').closest('button')).toBeNull();
});
});
+60 -6
View File
@@ -18,7 +18,7 @@ import {
Play,
Download,
} from 'lucide-react';
import { Button, Badge } from '../../ui';
import { Button, Progress } from '../../ui';
import { useEffect, useRef } from 'react';
import WaveformTimeline from '../WaveformTimeline';
import DubbingDemo from '../DubbingDemo';
@@ -30,6 +30,33 @@ import { LANG_CODES } from '../../utils/languages';
const SPEAKERS_INPUT =
'w-[52px] ml-[4px] px-[6px] py-[4px] rounded-[6px] border border-[var(--border,#3c3836)] bg-[var(--input-bg,#282828)] text-inherit text-[12px]';
function AsrInstallStatus({ t, install, onAbort }) {
const pct = typeof install?.percent === 'number' ? Math.round(install.percent) : null;
return (
<div
className="flex flex-col items-center gap-[var(--space-5)] w-full"
role="status"
aria-live="polite"
>
<Loader className="spinner" size={20} color="#d3869b" aria-hidden="true" />
<span className="text-fg font-medium text-[var(--text-lg)]">
{t('dub.install_progress', { engine: install?.label })}
</span>
<div className="w-[80%] max-w-[340px]">
<Progress value={pct} tone="brand" size="sm" />
</div>
{pct != null && (
<span className="text-[var(--text-sm)] text-fg-muted [font-variant-numeric:tabular-nums]">
{pct}%
</span>
)}
<Button variant="danger" size="sm" onClick={onAbort}>
{t('dub.prep_stop')}
</Button>
</div>
);
}
export default function IdleSkeleton({
t,
dubVideoFile,
@@ -39,6 +66,8 @@ export default function IdleSkeleton({
dubJobId,
dubStep,
dubFailure,
asrInstall,
handleInstallMissingAsr,
handleDubRetryTranscribe,
handleDubImportSrt,
dubLocalBlobUrl,
@@ -126,11 +155,23 @@ export default function IdleSkeleton({
Surfaces the backend error detail and offers one-click retry,
which re-runs the ASR stream on the same job without re-uploading. */}
{dubError && dubJobId && dubStep === 'idle' && (
<div className="mb-[var(--space-2)]">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
<div
className="mb-[var(--space-2)] flex flex-wrap items-center gap-[var(--space-2)] rounded-md border border-transparent bg-[rgba(251,73,52,0.08)] px-[12px] py-[9px]"
role="alert"
>
<AlertCircle size={15} className="shrink-0 text-danger" aria-hidden="true" />
<span className="min-w-0 flex-1 text-[var(--text-sm)] text-fg break-words">
{dubError}
</span>
<DubFailureNotice failure={dubFailure} />
{asrInstall?.phase === 'missing' && asrInstall.repoId && (
<Button variant="primary" size="sm" onClick={handleInstallMissingAsr}>
{t('asr_missing.download', {
label: asrInstall.label,
size: asrInstall.sizeGb,
})}
</Button>
)}
{handleDubRetryTranscribe && (
<Button
variant="subtle"
@@ -189,6 +230,8 @@ export default function IdleSkeleton({
progress={dubPrepProgress}
onAbort={handleDubAbort}
/>
) : dubStep === 'installing-asr' ? (
<AsrInstallStatus t={t} install={asrInstall} onAbort={handleDubAbort} />
) : dubStep === 'transcribing' ? (
<TranscribeOverlay
elapsed={transcribeElapsed}
@@ -251,7 +294,11 @@ export default function IdleSkeleton({
variant="primary"
className="flex-1"
onClick={handleDubUpload}
disabled={dubStep === 'uploading' || dubStep === 'transcribing'}
disabled={
dubStep === 'uploading' ||
dubStep === 'transcribing' ||
dubStep === 'installing-asr'
}
>
{dubStep === 'uploading' || dubStep === 'transcribing' ? (
<>
@@ -272,6 +319,10 @@ export default function IdleSkeleton({
onAbort={handleDubAbort}
large
/>
) : dubStep === 'installing-asr' ? (
<div className="flex-1 flex flex-col items-center justify-center min-h-0">
<AsrInstallStatus t={t} install={asrInstall} onAbort={handleDubAbort} />
</div>
) : dubStep === 'transcribing' ? (
// URL-ingest / restored jobs have no local `dubVideoFile`, so the
// waveform-overlay branch above never runs for them. Without this
@@ -498,6 +549,9 @@ export default function IdleSkeleton({
accept="video/*,audio/*,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma"
id="video-upload"
className="hidden"
disabled={
dubStep === 'uploading' || dubStep === 'transcribing' || dubStep === 'installing-asr'
}
onChange={(e) => {
const file = e.target.files[0];
if (!file) return;
@@ -88,13 +88,7 @@ export default function OpenApiPanel() {
const loadingLabel = t('openapi.loading', { defaultValue: 'Loading API spec…' });
return (
<SettingsSection
icon={Braces}
title={t('openapi.title', { defaultValue: 'OpenAPI Reference' })}
description={t('openapi.description', {
defaultValue: "Interactive reference for VoiceStudio's local backend API.",
})}
>
<SettingsSection icon={Braces} title={t('openapi.title', { defaultValue: 'VoiceStudio API' })}>
{/* Spec URL + copy / open-raw affordances useful whether the embed
loaded or not, so shown in every phase. */}
<div className="mb-[var(--space-3)] flex flex-wrap items-center gap-[var(--space-2)]">
@@ -43,6 +43,9 @@ describe('OpenApiPanel', () => {
render(<OpenApiPanel />);
expect(screen.getByRole('heading', { name: 'VoiceStudio API' })).toBeInTheDocument();
expect(screen.queryByText('OpenAPI Reference')).not.toBeInTheDocument();
// The spec is fetched from the backend root route (not under /api).
expect(apiFetch).toHaveBeenCalledWith('/openapi.json');
@@ -297,7 +297,7 @@ export const GROUPS = [
{
id: 'openapi',
labelKey: 'settings.openapi',
defaultLabel: 'OpenAPI',
defaultLabel: 'VoiceStudio API',
icon: Braces,
keywords: ['api', 'openapi', 'scalar', 'rest', 'swagger', 'docs', 'reference', 'endpoints'],
},
@@ -22,6 +22,11 @@ describe('matchCategories — search matching', () => {
expect(matchCategories('ui scale')).toContain('appearance');
});
it('uses the concise VoiceStudio API label', () => {
expect(CATEGORY_BY_ID.openapi.defaultLabel).toBe('VoiceStudio API');
expect(en.settings.openapi).toBe('VoiceStudio API');
});
it('keywordKeys match through the active locale, so localized setting names find their category', () => {
// Simulate a German UI: settings.font resolves to "Schriftart".
const t = (key) => (key === 'settings.font' ? 'Schriftart' : key);
+111 -12
View File
@@ -22,7 +22,8 @@ import { streamDropError } from '../utils/backendCrash';
import { playPing } from '../utils/media';
import { toast } from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import { asrMissingPayload, toastAsrModelMissing } from '../utils/asrModelMissing';
import { asrMissingPayload, installRecommendedAsr } from '../utils/asrModelMissing';
import { cancelInstallModel } from '../api/setup';
import { addBreadcrumb } from '../utils/breadcrumbs';
import { recordValueMoment } from '../utils/donationMoments';
import i18next from 'i18next';
@@ -105,9 +106,85 @@ export default function useDubWorkflow({
// true on a CUDA GPU and 50x wrong on a CPU, so it showed "~0s remaining" for
// 45 minutes. A measured fraction is the only thing that can't lie.
const [transcribeProgress, setTranscribeProgress] = useState(0);
const [asrInstall, setAsrInstall] = useState(null);
const dubAbortCtrlRef = useRef(null);
const dubClientJobIdRef = useRef(null);
const asrInstallTaskRef = useRef(null);
const retryTranscribeRef = useRef(null);
const _showMissingAsr = useCallback(
(payload) => {
const rec = payload?.recommended;
setAsrInstall({
phase: 'missing',
percent: null,
payload,
repoId: rec?.repo_id || '',
label: rec?.label || rec?.repo_id || '',
sizeGb: rec?.size_gb,
jobId: useAppStore.getState().dubJobId,
});
setDubError(t('asr_missing.message'));
setDubStep('idle');
useAppStore.getState().dismissPill();
},
[setDubError, setDubStep],
);
const handleInstallMissingAsr = useCallback(async () => {
if (!asrInstall?.payload || asrInstall.phase === 'installing') return;
const initiatingJobId = asrInstall.jobId;
const ctrl = new AbortController();
const installTask = {
ctrl,
repoId: asrInstall.repoId,
jobId: initiatingJobId,
};
asrInstallTaskRef.current = installTask;
setDubError('');
setDubStep('installing-asr');
setAsrInstall((current) => ({ ...current, phase: 'installing', percent: 0 }));
useAppStore
.getState()
.showPill('loading-model', t('dub.install_progress', { engine: asrInstall.label }), {
progress: 0,
cancellable: true,
homeMode: 'dub',
});
try {
await installRecommendedAsr(asrInstall.payload, {
signal: ctrl.signal,
onProgress: ({ percent }) => {
setAsrInstall((current) =>
current ? { ...current, phase: 'installing', percent } : current,
);
useAppStore.getState().setPillProgress(percent);
},
});
if (useAppStore.getState().dubJobId !== initiatingJobId) return;
setAsrInstall(null);
setDubError('');
setDubStep('idle');
useAppStore.getState().completePill(t('dub.install_ok', { engine: asrInstall.label }));
await retryTranscribeRef.current?.();
} catch (error) {
if (useAppStore.getState().dubJobId !== initiatingJobId) return;
const aborted = error?.name === 'AbortError';
const message = aborted
? t('dub_workflow.retry_cancelled')
: t('asr_missing.install_failed', { message: error?.message || String(error) });
setDubStep('idle');
setDubError(message);
setAsrInstall((current) =>
current ? { ...current, phase: 'missing', percent: null } : current,
);
if (aborted) useAppStore.getState().dismissPill();
else useAppStore.getState().errorPill(message);
} finally {
if (asrInstallTaskRef.current === installTask) asrInstallTaskRef.current = null;
}
}, [asrInstall, setDubError, setDubStep]);
// Reset a stale dub session (the persisted job is gone server-side, #660):
// clear the dead id/state, drop any pill, and prompt a fresh upload with a
@@ -117,6 +194,7 @@ export default function useDubWorkflow({
setDubTaskId('');
setDubSegments([]);
setDubError('');
setAsrInstall(null);
setDubStep('idle');
setTranscribeStart(null);
try {
@@ -438,8 +516,14 @@ export default function useDubWorkflow({
const handleDubUpload = useCallback(
async (dubVideoFile) => {
if (!dubVideoFile) return;
const pendingInstall = asrInstallTaskRef.current;
pendingInstall?.ctrl.abort();
if (pendingInstall?.repoId) {
void cancelInstallModel(pendingInstall.repoId).catch(() => {});
}
addBreadcrumb('dub:upload');
setDubStep('uploading');
setAsrInstall(null);
setDubError('');
setDubFailure(null);
setDubTracks([]);
@@ -497,10 +581,7 @@ export default function useDubWorkflow({
_resetStaleDubSession();
} else if (asrMissingPayload(err)) {
// Typed preflight: no ASR model installed → download CTA, not a report.
setDubError(t('asr_missing.message'));
setDubStep('idle');
toastAsrModelMissing(asrMissingPayload(err));
useAppStore.getState().errorPill(t('asr_missing.message'));
_showMissingAsr(asrMissingPayload(err));
} else {
setDubError(err.message);
setDubStep('idle');
@@ -527,6 +608,7 @@ export default function useDubWorkflow({
loadProjects,
loadProfiles,
_resetStaleDubSession,
_showMissingAsr,
],
);
@@ -534,8 +616,14 @@ export default function useDubWorkflow({
async (url, opts = {}) => {
const clean = (url || '').trim();
if (!clean) return;
const pendingInstall = asrInstallTaskRef.current;
pendingInstall?.ctrl.abort();
if (pendingInstall?.repoId) {
void cancelInstallModel(pendingInstall.repoId).catch(() => {});
}
addBreadcrumb('dub:ingest-url');
setDubStep('uploading');
setAsrInstall(null);
setDubError('');
setDubFailure(null);
setDubTracks([]);
@@ -590,10 +678,7 @@ export default function useDubWorkflow({
} else if (isExpiredDubJobError(err)) {
_resetStaleDubSession();
} else if (asrMissingPayload(err)) {
setDubError(t('asr_missing.message'));
setDubStep('idle');
toastAsrModelMissing(asrMissingPayload(err));
useAppStore.getState().errorPill(t('asr_missing.message'));
_showMissingAsr(asrMissingPayload(err));
} else {
const cookieErrorKey =
err?.code === DUB_COOKIE_TRANSPORT_ERROR
@@ -626,10 +711,19 @@ export default function useDubWorkflow({
loadProjects,
loadProfiles,
_resetStaleDubSession,
_showMissingAsr,
],
);
const handleDubAbort = useCallback(async () => {
const pendingInstall = asrInstallTaskRef.current;
if (pendingInstall) {
pendingInstall.ctrl.abort();
if (pendingInstall.repoId) {
await cancelInstallModel(pendingInstall.repoId).catch(() => {});
}
return;
}
const jobId = dubClientJobIdRef.current || dubJobId;
if (dubAbortCtrlRef.current) dubAbortCtrlRef.current.abort();
if (jobId) await apiDubAbort(jobId);
@@ -637,6 +731,7 @@ export default function useDubWorkflow({
const handleDubRetryTranscribe = useCallback(async () => {
if (!dubJobId) return;
setAsrInstall(null);
const ctrl = new AbortController();
dubAbortCtrlRef.current = ctrl;
setDubError('');
@@ -656,9 +751,7 @@ export default function useDubWorkflow({
} else if (isExpiredDubJobError(err)) {
_resetStaleDubSession();
} else if (asrMissingPayload(err)) {
setDubError(t('asr_missing.message'));
setDubStep('idle');
toastAsrModelMissing(asrMissingPayload(err));
_showMissingAsr(asrMissingPayload(err));
} else {
setDubError(err.message);
setDubStep('idle');
@@ -675,7 +768,11 @@ export default function useDubWorkflow({
_waitForTranscribe,
loadProjects,
_resetStaleDubSession,
_showMissingAsr,
]);
useEffect(() => {
retryTranscribeRef.current = handleDubRetryTranscribe;
}, [handleDubRetryTranscribe]);
const handleDubImportSrt = useCallback(
async (file) => {
@@ -1128,10 +1225,12 @@ export default function useDubWorkflow({
setPreviewAudios,
transcribeElapsed,
transcribeProgress,
asrInstall,
handleDubUpload,
handleDubIngestUrl,
handleDubAbort,
handleDubRetryTranscribe,
handleInstallMissingAsr,
handleDubStop,
handleDubGenerate,
handleCleanupSegments,
+137 -14
View File
@@ -3,22 +3,77 @@
*
* Extracted from App.jsx to reduce its useState/useRef count.
*/
import { useState, useRef } from 'react';
import { useEffect, useState, useRef } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { cleanAudio as apiCleanAudio } from '../api/system';
import { micErrorMessage } from '../utils/micError';
import { checkMicrophone } from '../utils/permissions';
import { showMicDeniedGuide } from '../utils/micDeniedToast';
import { startMicCapture } from '../utils/aec/micCapture';
import { encodeWav } from '../utils/audioTrim';
import { audioFormatForMimeType, startSupportedMediaRecorder } from '../utils/mediaRecorder';
import {
buildAudioInputConstraints,
createInputLevelStore,
listAudioInputs,
startInputLevelMonitor,
} from '../utils/audioInput';
function concatFrames(frames) {
const length = frames.reduce((total, frame) => total + frame.length, 0);
const samples = new Float32Array(length);
let offset = 0;
for (const frame of frames) {
samples.set(frame, offset);
offset += frame.length;
}
return samples;
}
export default function useRecording(ingestRefAudio) {
const { t } = useTranslation();
const [isRecording, setIsRecording] = useState(false);
const [isCleaning, setIsCleaning] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioInputs, setAudioInputs] = useState([]);
const [selectedAudioInputId, setSelectedAudioInputId] = useState('');
const [channelMode, setChannelMode] = useState('auto');
const inputLevelStoreRef = useRef(null);
if (!inputLevelStoreRef.current) inputLevelStoreRef.current = createInputLevelStore();
const mediaRecorderRef = useRef(null);
const recordingChunksRef = useRef([]);
const recordingTimerRef = useRef(null);
const stopLevelMonitorRef = useRef(null);
const refreshAudioInputs = async () => {
try {
const inputs = await listAudioInputs();
setAudioInputs(inputs);
setSelectedAudioInputId((current) =>
current && !inputs.some((device) => device.deviceId === current) ? '' : current,
);
} catch {
setAudioInputs([]);
}
};
const stopLevelMonitor = () => {
stopLevelMonitorRef.current?.();
stopLevelMonitorRef.current = null;
inputLevelStoreRef.current.set(0);
};
useEffect(() => {
void refreshAudioInputs();
const mediaDevices = navigator.mediaDevices;
mediaDevices?.addEventListener?.('devicechange', refreshAudioInputs);
return () => {
mediaDevices?.removeEventListener?.('devicechange', refreshAudioInputs);
stopLevelMonitorRef.current?.();
clearInterval(recordingTimerRef.current);
};
}, []);
const startRecording = async () => {
// Pre-flight: an OS-denied mic grant means getUserMedia can only throw an
@@ -29,22 +84,30 @@ export default function useRecording(ingestRefAudio) {
showMicDeniedGuide(t);
return;
}
let stream;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
mediaRecorderRef.current = mediaRecorder;
stream = await navigator.mediaDevices.getUserMedia(
buildAudioInputConstraints(selectedAudioInputId, channelMode),
);
void refreshAudioInputs();
stopLevelMonitor();
try {
stopLevelMonitorRef.current = startInputLevelMonitor(
stream,
inputLevelStoreRef.current.set,
);
} catch {
// Level feedback is optional; recording must still work when a webview
// exposes getUserMedia without a complete Web Audio implementation.
stopLevelMonitorRef.current = null;
}
recordingChunksRef.current = [];
setRecordingTime(0);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) recordingChunksRef.current.push(e.data);
};
mediaRecorder.onstop = async () => {
const finishRecording = async (blob, extension) => {
clearInterval(recordingTimerRef.current);
stopLevelMonitor();
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(recordingChunksRef.current, { type: 'audio/webm' });
if (blob.size < 1000) {
toast.error(t('recording.too_short', { defaultValue: 'Recording too short' }));
return;
@@ -54,7 +117,7 @@ export default function useRecording(ingestRefAudio) {
setIsCleaning(true);
try {
const formData = new FormData();
formData.append('audio', blob, 'recording.webm');
formData.append('audio', blob, `recording.${extension}`);
const res = await apiCleanAudio(formData);
const cleanBlob = await res.blob();
@@ -67,7 +130,7 @@ export default function useRecording(ingestRefAudio) {
);
} catch (e) {
// Fallback: use raw recording without denoising
const rawFile = new File([blob], 'recording.webm', { type: 'audio/webm' });
const rawFile = new File([blob], `recording.${extension}`, { type: blob.type });
await ingestRefAudio(rawFile);
toast.success(
t('recording.loaded_raw', {
@@ -79,7 +142,58 @@ export default function useRecording(ingestRefAudio) {
}
};
mediaRecorder.start(250); // Collect chunks every 250ms
let recordingFormat = { mimeType: 'audio/webm', extension: 'webm' };
const supported = startSupportedMediaRecorder(stream, {
onData: (e) => {
if (e.data.size > 0) {
if (e.data.type) recordingFormat = audioFormatForMimeType(e.data.type);
recordingChunksRef.current.push(e.data);
}
},
onStop: () => {
const blob = new Blob(recordingChunksRef.current, { type: recordingFormat.mimeType });
void finishRecording(blob, recordingFormat.extension);
},
});
if (supported) {
const { recorder, mimeType, extension } = supported;
recordingFormat = { mimeType, extension };
mediaRecorderRef.current = recorder;
} else {
// Some Linux WebKitGTK builds expose MediaRecorder but reject every
// codec/constructor. Web Audio is still available, so record PCM
// and wrap it in a portable WAV instead of failing the microphone.
const frames = [];
const actualChannels = Number(stream.getAudioTracks?.()[0]?.getSettings?.().channelCount);
const pcmChannels =
channelMode === 'mono'
? 1
: channelMode === 'stereo'
? 2
: Math.max(1, Math.min(2, actualChannels || 1));
const stopCapture = await startMicCapture(stream, (frame) => frames.push(frame.slice()), {
sampleRate: 16000,
channels: pcmChannels,
});
const controller = {
state: 'recording',
stop() {
if (controller.state === 'inactive') return;
controller.state = 'inactive';
void Promise.resolve(stopCapture())
.catch(() => {})
.then(() => {
const wav = encodeWav(
concatFrames(frames),
stopCapture.sampleRate || 16000,
stopCapture.channels || pcmChannels,
);
return finishRecording(new Blob([wav], { type: 'audio/wav' }), 'wav');
});
},
};
mediaRecorderRef.current = controller;
}
setIsRecording(true);
// Timer
@@ -88,6 +202,8 @@ export default function useRecording(ingestRefAudio) {
setRecordingTime(((Date.now() - st) / 1000).toFixed(1));
}, 100);
} catch (e) {
stopLevelMonitor();
stream?.getTracks().forEach((track) => track.stop());
// Same actionable mapping as the dictation pill: denied → per-OS
// settings hint; otherwise no-device / device-busy / generic (#323).
toast.error(micErrorMessage(t, e), { duration: 6000 });
@@ -98,6 +214,7 @@ export default function useRecording(ingestRefAudio) {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
stopLevelMonitor();
setIsRecording(false);
};
@@ -105,6 +222,12 @@ export default function useRecording(ingestRefAudio) {
isRecording,
isCleaning,
recordingTime,
audioInputs,
selectedAudioInputId,
setSelectedAudioInputId,
channelMode,
setChannelMode,
inputLevelStore: inputLevelStoreRef.current,
startRecording,
stopRecording,
};
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "مساحات العمل"
},
"settings": {
"openapi": "واجهة برمجة تطبيقات VoiceStudio",
"general": "عام",
"theme": "المظهر",
"language": "اللغة",
@@ -307,6 +308,9 @@
"clear": "واضح",
"refresh": "تحديث",
"close": "إغلاق",
"minimize_window": "تصغير النافذة",
"maximize_restore_window": "تكبير النافذة أو استعادتها",
"close_window": "إغلاق النافذة",
"back": "العودة",
"play": "العب",
"pause": "وقفة",
@@ -2073,10 +2077,33 @@
"filter_starred": "المميزة بنجمة",
"load_take_failed": "تعذر تحميل تلك اللقطة: {{message}}"
},
"openapi": {
"title": "واجهة برمجة تطبيقات VoiceStudio",
"loading": "جارٍ تحميل مواصفات الواجهة…",
"unreachable_title": "مواصفات الخادم غير متاحة",
"unreachable_body": "تعذّر الوصول إلى مواصفات OpenAPI للخادم المحلي على {{url}}. تأكد من تشغيل الخادم، ثم أعد المحاولة.",
"retry": "إعادة المحاولة",
"copy_url": "نسخ رابط المواصفات",
"copy_url_aria": "نسخ رابط /openapi.json",
"copied": "تم نسخ رابط المواصفات",
"copy_failed": "فشل النسخ — حدّد الرابط أعلاه وانسخه يدويًا.",
"open_raw": "فتح المواصفات الخام",
"open_raw_aria": "فتح ملف OpenAPI JSON الخام في المتصفح"
},
"recording": {
"cleaned_loaded": "تم تنظيف التسجيل وتحميله!",
"loaded_raw": "تم تحميل التسجيل (خام — إزالة الضوضاء غير متاحة)",
"too_short": "التسجيل قصير جدًا"
"too_short": "التسجيل قصير جدًا",
"input_device": "جهاز الإدخال",
"default_input": "إعداد النظام الافتراضي",
"microphone_number": "الميكروفون {{number}}",
"channels": "القنوات",
"channels_auto": "تلقائي",
"channels_mono": "أحادي",
"channels_stereo": "ستيريو",
"input_level": "مستوى إدخال الميكروفون",
"input_detected": "تم اكتشاف صوت",
"no_input_detected": "لم يتم اكتشاف صوت"
},
"pronunciation": {
"title": "قاموس النطق",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Arbeitsbereiche"
},
"settings": {
"openapi": "VoiceStudio-API",
"general": "Allgemein",
"theme": "Theme",
"language": "Sprache",
@@ -307,6 +308,9 @@
"clear": "Klar",
"refresh": "Aktualisieren",
"close": "Schließen",
"minimize_window": "Fenster minimieren",
"maximize_restore_window": "Fenster maximieren oder wiederherstellen",
"close_window": "Fenster schließen",
"back": "Zurück",
"play": "Spielen",
"pause": "Pause",
@@ -2073,10 +2077,33 @@
"filter_starred": "Markiert",
"load_take_failed": "Take konnte nicht geladen werden: {{message}}"
},
"openapi": {
"title": "VoiceStudio-API",
"loading": "API-Spezifikation wird geladen…",
"unreachable_title": "Backend-Spezifikation nicht verfügbar",
"unreachable_body": "Die OpenAPI-Spezifikation des lokalen Backends unter {{url}} ist nicht erreichbar. Stelle sicher, dass das Backend läuft, und versuche es erneut.",
"retry": "Erneut versuchen",
"copy_url": "Spezifikations-URL kopieren",
"copy_url_aria": "URL zu /openapi.json kopieren",
"copied": "Spezifikations-URL kopiert",
"copy_failed": "Kopieren fehlgeschlagen — wähle die URL oben aus und kopiere sie manuell.",
"open_raw": "Rohspezifikation öffnen",
"open_raw_aria": "OpenAPI-Rohdaten im JSON-Format im Browser öffnen"
},
"recording": {
"cleaned_loaded": "Aufnahme bereinigt & geladen!",
"loaded_raw": "Aufnahme geladen (roh — Entrauschen nicht verfügbar)",
"too_short": "Aufnahme zu kurz"
"too_short": "Aufnahme zu kurz",
"input_device": "Eingabegerät",
"default_input": "Systemstandard",
"microphone_number": "Mikrofon {{number}}",
"channels": "Kanäle",
"channels_auto": "Automatisch",
"channels_mono": "Mono",
"channels_stereo": "Stereo",
"input_level": "Mikrofonpegel",
"input_detected": "Eingang erkannt",
"no_input_detected": "Kein Eingang erkannt"
},
"pronunciation": {
"title": "Aussprachewörterbuch",
+16 -4
View File
@@ -383,7 +383,7 @@
"engines": "Engines",
"capture": "Capture",
"sharing": "Sharing",
"openapi": "OpenAPI",
"openapi": "VoiceStudio API",
"appearance": "Appearance",
"credentials": "Credentials",
"llm_providers": "LLM Providers",
@@ -1990,6 +1990,9 @@
"clear": "Clear",
"refresh": "Refresh",
"close": "Close",
"minimize_window": "Minimize window",
"maximize_restore_window": "Maximize or restore window",
"close_window": "Close window",
"back": "Back",
"play": "Play",
"pause": "Pause",
@@ -2662,8 +2665,7 @@
"sponsors_tier_bronze": "Bronze"
},
"openapi": {
"title": "OpenAPI Reference",
"description": "Interactive reference for VoiceStudio's local backend API.",
"title": "VoiceStudio API",
"loading": "Loading API spec…",
"unreachable_title": "Backend spec unavailable",
"unreachable_body": "Couldn't reach the local backend's OpenAPI spec at {{url}}. Make sure the backend is running, then retry.",
@@ -2817,7 +2819,17 @@
"recording": {
"cleaned_loaded": "Recording cleaned & loaded!",
"loaded_raw": "Recording loaded (raw — denoising unavailable)",
"too_short": "Recording too short"
"too_short": "Recording too short",
"input_device": "Input device",
"default_input": "System default",
"microphone_number": "Microphone {{number}}",
"channels": "Channels",
"channels_auto": "Auto",
"channels_mono": "Mono",
"channels_stereo": "Stereo",
"input_level": "Microphone input level",
"input_detected": "Input detected",
"no_input_detected": "No input detected"
},
"dictation": {
"title": "Dictation refinement",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Espacios de trabajo"
},
"settings": {
"openapi": "API de VoiceStudio",
"general": "General",
"theme": "Tema",
"language": "Idioma",
@@ -307,6 +308,9 @@
"clear": "Borrar",
"refresh": "Actualizar",
"close": "Cerrar",
"minimize_window": "Minimizar ventana",
"maximize_restore_window": "Maximizar o restaurar ventana",
"close_window": "Cerrar ventana",
"back": "Atrás",
"play": "Jugar",
"pause": "Pausa",
@@ -2073,10 +2077,33 @@
"filter_starred": "Destacadas",
"load_take_failed": "No se pudo cargar esa toma: {{message}}"
},
"openapi": {
"title": "API de VoiceStudio",
"loading": "Cargando la especificación de la API…",
"unreachable_title": "La especificación del backend no está disponible",
"unreachable_body": "No se pudo acceder a la especificación OpenAPI del backend local en {{url}}. Comprueba que el backend esté en ejecución y vuelve a intentarlo.",
"retry": "Reintentar",
"copy_url": "Copiar URL de la especificación",
"copy_url_aria": "Copiar la URL de /openapi.json",
"copied": "URL de la especificación copiada",
"copy_failed": "No se pudo copiar — selecciona y copia manualmente la URL de arriba.",
"open_raw": "Abrir especificación sin formato",
"open_raw_aria": "Abrir el JSON de OpenAPI sin formato en el navegador"
},
"recording": {
"cleaned_loaded": "¡Grabación limpiada y cargada!",
"loaded_raw": "Grabación cargada (sin procesar — eliminación de ruido no disponible)",
"too_short": "Grabación demasiado corta"
"too_short": "Grabación demasiado corta",
"input_device": "Dispositivo de entrada",
"default_input": "Predeterminado del sistema",
"microphone_number": "Micrófono {{number}}",
"channels": "Canales",
"channels_auto": "Automático",
"channels_mono": "Mono",
"channels_stereo": "Estéreo",
"input_level": "Nivel de entrada del micrófono",
"input_detected": "Entrada detectada",
"no_input_detected": "No se detecta entrada"
},
"pronunciation": {
"title": "Diccionario de pronunciación",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Espaces de travail"
},
"settings": {
"openapi": "API VoiceStudio",
"general": "Général",
"theme": "Thème",
"language": "Langue",
@@ -307,6 +308,9 @@
"clear": "Effacer",
"refresh": "Actualiser",
"close": "Fermer",
"minimize_window": "Réduire la fenêtre",
"maximize_restore_window": "Agrandir ou restaurer la fenêtre",
"close_window": "Fermer la fenêtre",
"back": "Retour",
"play": "Jouer",
"pause": "Pause",
@@ -2073,10 +2077,33 @@
"filter_starred": "Étoilées",
"load_take_failed": "Impossible de charger cette prise : {{message}}"
},
"openapi": {
"title": "API VoiceStudio",
"loading": "Chargement de la spécification de lAPI…",
"unreachable_title": "Spécification du backend indisponible",
"unreachable_body": "Impossible datteindre la spécification OpenAPI du backend local à ladresse {{url}}. Vérifiez que le backend fonctionne, puis réessayez.",
"retry": "Réessayer",
"copy_url": "Copier lURL de la spécification",
"copy_url_aria": "Copier lURL /openapi.json",
"copied": "URL de la spécification copiée",
"copy_failed": "Échec de la copie — sélectionnez et copiez manuellement lURL ci-dessus.",
"open_raw": "Ouvrir la spécification brute",
"open_raw_aria": "Ouvrir le JSON OpenAPI brut dans le navigateur"
},
"recording": {
"cleaned_loaded": "Enregistrement nettoyé et chargé !",
"loaded_raw": "Enregistrement chargé (brut — débruitage indisponible)",
"too_short": "Enregistrement trop court"
"too_short": "Enregistrement trop court",
"input_device": "Périphérique dentrée",
"default_input": "Entrée système par défaut",
"microphone_number": "Microphone {{number}}",
"channels": "Canaux",
"channels_auto": "Automatique",
"channels_mono": "Mono",
"channels_stereo": "Stéréo",
"input_level": "Niveau dentrée du microphone",
"input_detected": "Entrée détectée",
"no_input_detected": "Aucune entrée détectée"
},
"pronunciation": {
"title": "Dictionnaire de prononciation",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "वर्कस्पेस"
},
"settings": {
"openapi": "VoiceStudio API",
"general": "सामान्य",
"theme": "थीम",
"language": "भाषा",
@@ -307,6 +308,9 @@
"clear": "स्पष्ट",
"refresh": "ताज़ा करें",
"close": "बंद करें",
"minimize_window": "विंडो को छोटा करें",
"maximize_restore_window": "विंडो को बड़ा करें या पुनर्स्थापित करें",
"close_window": "विंडो बंद करें",
"back": "वापस",
"play": "खेलो",
"pause": "विराम",
@@ -2073,10 +2077,33 @@
"filter_starred": "स्टार किए गए",
"load_take_failed": "वह टेक लोड नहीं हो सका: {{message}}"
},
"openapi": {
"title": "VoiceStudio API",
"loading": "API विनिर्देश लोड हो रहा है…",
"unreachable_title": "बैकएंड विनिर्देश उपलब्ध नहीं है",
"unreachable_body": "{{url}} पर स्थानीय बैकएंड के OpenAPI विनिर्देश तक नहीं पहुँचा जा सका। सुनिश्चित करें कि बैकएंड चल रहा है, फिर दोबारा कोशिश करें।",
"retry": "दोबारा कोशिश करें",
"copy_url": "विनिर्देश URL कॉपी करें",
"copy_url_aria": "/openapi.json URL कॉपी करें",
"copied": "विनिर्देश URL कॉपी हो गया",
"copy_failed": "कॉपी नहीं हो सका — ऊपर दिए URL को चुनकर मैन्युअल रूप से कॉपी करें।",
"open_raw": "मूल विनिर्देश खोलें",
"open_raw_aria": "ब्राउज़र में मूल OpenAPI JSON खोलें"
},
"recording": {
"cleaned_loaded": "रिकॉर्डिंग साफ़ करके लोड हो गई!",
"loaded_raw": "रिकॉर्डिंग लोड हुई (रॉ — डेनोइज़िंग अनुपलब्ध)",
"too_short": "रिकॉर्डिंग बहुत छोटी है"
"too_short": "रिकॉर्डिंग बहुत छोटी है",
"input_device": "इनपुट डिवाइस",
"default_input": "सिस्टम डिफ़ॉल्ट",
"microphone_number": "माइक्रोफ़ोन {{number}}",
"channels": "चैनल",
"channels_auto": "स्वचालित",
"channels_mono": "मोनो",
"channels_stereo": "स्टीरियो",
"input_level": "माइक्रोफ़ोन इनपुट स्तर",
"input_detected": "आवाज़ मिली",
"no_input_detected": "कोई आवाज़ नहीं मिली"
},
"pronunciation": {
"title": "उच्चारण शब्दकोश",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Ruang kerja"
},
"settings": {
"openapi": "API VoiceStudio",
"general": "Umum",
"theme": "Tema",
"language": "Bahasa",
@@ -307,6 +308,9 @@
"clear": "Jelas",
"refresh": "Segarkan",
"close": "Tutup",
"minimize_window": "Minimalkan jendela",
"maximize_restore_window": "Maksimalkan atau pulihkan jendela",
"close_window": "Tutup jendela",
"back": "Kembali",
"play": "Mainkan",
"pause": "Jeda",
@@ -2073,10 +2077,33 @@
"filter_starred": "Berbintang",
"load_take_failed": "Tidak dapat memuat take itu: {{message}}"
},
"openapi": {
"title": "API VoiceStudio",
"loading": "Memuat spesifikasi API…",
"unreachable_title": "Spesifikasi backend tidak tersedia",
"unreachable_body": "Tidak dapat menjangkau spesifikasi OpenAPI backend lokal di {{url}}. Pastikan backend berjalan, lalu coba lagi.",
"retry": "Coba lagi",
"copy_url": "Salin URL spesifikasi",
"copy_url_aria": "Salin URL /openapi.json",
"copied": "URL spesifikasi disalin",
"copy_failed": "Gagal menyalin — pilih dan salin URL di atas secara manual.",
"open_raw": "Buka spesifikasi mentah",
"open_raw_aria": "Buka JSON OpenAPI mentah di browser"
},
"recording": {
"cleaned_loaded": "Rekaman dibersihkan & dimuat!",
"loaded_raw": "Rekaman dimuat (mentah — penghilang derau tidak tersedia)",
"too_short": "Rekaman terlalu pendek"
"too_short": "Rekaman terlalu pendek",
"input_device": "Perangkat masukan",
"default_input": "Bawaan sistem",
"microphone_number": "Mikrofon {{number}}",
"channels": "Kanal",
"channels_auto": "Otomatis",
"channels_mono": "Mono",
"channels_stereo": "Stereo",
"input_level": "Tingkat masukan mikrofon",
"input_detected": "Masukan terdeteksi",
"no_input_detected": "Tidak ada masukan terdeteksi"
},
"pronunciation": {
"title": "Kamus pengucapan",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Aree di lavoro"
},
"settings": {
"openapi": "API VoiceStudio",
"general": "Generale",
"theme": "Tema",
"language": "Lingua",
@@ -307,6 +308,9 @@
"clear": "Chiaro",
"refresh": "Aggiorna",
"close": "Chiudi",
"minimize_window": "Riduci finestra",
"maximize_restore_window": "Ingrandisci o ripristina finestra",
"close_window": "Chiudi finestra",
"back": "Indietro",
"play": "Gioca",
"pause": "Pausa",
@@ -2073,10 +2077,33 @@
"filter_starred": "Con stella",
"load_take_failed": "Impossibile caricare il take: {{message}}"
},
"openapi": {
"title": "API VoiceStudio",
"loading": "Caricamento della specifica API…",
"unreachable_title": "Specifica del backend non disponibile",
"unreachable_body": "Impossibile raggiungere la specifica OpenAPI del backend locale allindirizzo {{url}}. Verifica che il backend sia in esecuzione, quindi riprova.",
"retry": "Riprova",
"copy_url": "Copia URL della specifica",
"copy_url_aria": "Copia lURL /openapi.json",
"copied": "URL della specifica copiato",
"copy_failed": "Copia non riuscita — seleziona e copia manualmente lURL qui sopra.",
"open_raw": "Apri specifica grezza",
"open_raw_aria": "Apri il JSON OpenAPI grezzo nel browser"
},
"recording": {
"cleaned_loaded": "Registrazione pulita e caricata!",
"loaded_raw": "Registrazione caricata (grezza — eliminazione del rumore non disponibile)",
"too_short": "Registrazione troppo breve"
"too_short": "Registrazione troppo breve",
"input_device": "Dispositivo di ingresso",
"default_input": "Predefinito di sistema",
"microphone_number": "Microfono {{number}}",
"channels": "Canali",
"channels_auto": "Automatico",
"channels_mono": "Mono",
"channels_stereo": "Stereo",
"input_level": "Livello di ingresso del microfono",
"input_detected": "Ingresso rilevato",
"no_input_detected": "Nessun ingresso rilevato"
},
"pronunciation": {
"title": "Dizionario di pronuncia",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "ワークスペース"
},
"settings": {
"openapi": "VoiceStudio API",
"general": "一般",
"theme": "テーマ",
"language": "言語",
@@ -307,6 +308,9 @@
"clear": "クリア",
"refresh": "リフレッシュ",
"close": "閉じる",
"minimize_window": "ウィンドウを最小化",
"maximize_restore_window": "ウィンドウを最大化または元に戻す",
"close_window": "ウィンドウを閉じる",
"back": "戻る",
"play": "遊ぶ",
"pause": "一時停止",
@@ -2073,10 +2077,33 @@
"filter_starred": "スター付き",
"load_take_failed": "テイクを読み込めませんでした: {{message}}"
},
"openapi": {
"title": "VoiceStudio API",
"loading": "API 仕様を読み込んでいます…",
"unreachable_title": "バックエンド仕様を利用できません",
"unreachable_body": "{{url}} にあるローカルバックエンドの OpenAPI 仕様に接続できませんでした。バックエンドが実行中であることを確認して、再試行してください。",
"retry": "再試行",
"copy_url": "仕様 URL をコピー",
"copy_url_aria": "/openapi.json の URL をコピー",
"copied": "仕様 URL をコピーしました",
"copy_failed": "コピーに失敗しました。上の URL を選択して手動でコピーしてください。",
"open_raw": "未加工の仕様を開く",
"open_raw_aria": "未加工の OpenAPI JSON をブラウザーで開く"
},
"recording": {
"cleaned_loaded": "録音をクリーンアップして読み込みました!",
"loaded_raw": "録音を読み込みました(未処理 — ノイズ除去は利用できません)",
"too_short": "録音が短すぎます"
"too_short": "録音が短すぎます",
"input_device": "入力デバイス",
"default_input": "システム既定",
"microphone_number": "マイク {{number}}",
"channels": "チャンネル",
"channels_auto": "自動",
"channels_mono": "モノラル",
"channels_stereo": "ステレオ",
"input_level": "マイク入力レベル",
"input_detected": "入力を検出",
"no_input_detected": "入力を検出できません"
},
"pronunciation": {
"title": "発音辞典",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "작업 공간"
},
"settings": {
"openapi": "VoiceStudio API",
"general": "일반",
"theme": "테마",
"language": "언어",
@@ -307,6 +308,9 @@
"clear": "지우기",
"refresh": "새로고침",
"close": "닫기",
"minimize_window": "창 최소화",
"maximize_restore_window": "창 최대화 또는 복원",
"close_window": "창 닫기",
"back": "뒤로",
"play": "플레이",
"pause": "일시중지",
@@ -2073,10 +2077,33 @@
"filter_starred": "별표됨",
"load_take_failed": "테이크를 불러오지 못했습니다: {{message}}"
},
"openapi": {
"title": "VoiceStudio API",
"loading": "API 사양 불러오는 중…",
"unreachable_title": "백엔드 사양을 사용할 수 없음",
"unreachable_body": "{{url}}의 로컬 백엔드 OpenAPI 사양에 연결할 수 없습니다. 백엔드가 실행 중인지 확인한 후 다시 시도하세요.",
"retry": "다시 시도",
"copy_url": "사양 URL 복사",
"copy_url_aria": "/openapi.json URL 복사",
"copied": "사양 URL을 복사했습니다",
"copy_failed": "복사하지 못했습니다. 위 URL을 선택하여 직접 복사하세요.",
"open_raw": "원본 사양 열기",
"open_raw_aria": "원본 OpenAPI JSON을 브라우저에서 열기"
},
"recording": {
"cleaned_loaded": "녹음이 정리되어 로드되었습니다!",
"loaded_raw": "녹음이 로드되었습니다 (원본 — 노이즈 제거 사용 불가)",
"too_short": "녹음이 너무 짧습니다"
"too_short": "녹음이 너무 짧습니다",
"input_device": "입력 장치",
"default_input": "시스템 기본값",
"microphone_number": "마이크 {{number}}",
"channels": "채널",
"channels_auto": "자동",
"channels_mono": "모노",
"channels_stereo": "스테레오",
"input_level": "마이크 입력 레벨",
"input_detected": "입력 감지됨",
"no_input_detected": "입력 감지 안 됨"
},
"pronunciation": {
"title": "Pronunciation dictionary",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Werkruimtes"
},
"settings": {
"openapi": "VoiceStudio-API",
"general": "Algemeen",
"theme": "Thema",
"language": "Taal",
@@ -307,6 +308,9 @@
"clear": "Duidelijk",
"refresh": "Vernieuwen",
"close": "Sluiten",
"minimize_window": "Venster minimaliseren",
"maximize_restore_window": "Venster maximaliseren of herstellen",
"close_window": "Venster sluiten",
"back": "Terug",
"play": "Spelen",
"pause": "Pauze",
@@ -2073,10 +2077,33 @@
"filter_starred": "Met ster",
"load_take_failed": "Kon die take niet laden: {{message}}"
},
"openapi": {
"title": "VoiceStudio-API",
"loading": "API-specificatie laden…",
"unreachable_title": "Backendspecificatie niet beschikbaar",
"unreachable_body": "De OpenAPI-specificatie van de lokale backend op {{url}} is niet bereikbaar. Controleer of de backend actief is en probeer het opnieuw.",
"retry": "Opnieuw proberen",
"copy_url": "Specificatie-URL kopiëren",
"copy_url_aria": "De URL van /openapi.json kopiëren",
"copied": "Specificatie-URL gekopieerd",
"copy_failed": "Kopiëren mislukt — selecteer en kopieer de URL hierboven handmatig.",
"open_raw": "Ruwe specificatie openen",
"open_raw_aria": "De ruwe OpenAPI-JSON in de browser openen"
},
"recording": {
"cleaned_loaded": "Opname opgeschoond en geladen!",
"loaded_raw": "Opname geladen (ruw — ruisonderdrukking niet beschikbaar)",
"too_short": "Opname te kort"
"too_short": "Opname te kort",
"input_device": "Invoerapparaat",
"default_input": "Systeemstandaard",
"microphone_number": "Microfoon {{number}}",
"channels": "Kanalen",
"channels_auto": "Automatisch",
"channels_mono": "Mono",
"channels_stereo": "Stereo",
"input_level": "Microfooningangsniveau",
"input_detected": "Invoer gedetecteerd",
"no_input_detected": "Geen invoer gedetecteerd"
},
"pronunciation": {
"title": "Uitspraakwoordenboek",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Obszary robocze"
},
"settings": {
"openapi": "API VoiceStudio",
"general": "Ogólne",
"theme": "Motyw",
"language": "Język",
@@ -307,6 +308,9 @@
"clear": "Jasne",
"refresh": "Odśwież",
"close": "Zamknij",
"minimize_window": "Minimalizuj okno",
"maximize_restore_window": "Maksymalizuj lub przywróć okno",
"close_window": "Zamknij okno",
"back": "Powrót",
"play": "Zagraj",
"pause": "Pauza",
@@ -2073,10 +2077,33 @@
"filter_starred": "Z gwiazdką",
"load_take_failed": "Nie udało się wczytać nagrania: {{message}}"
},
"openapi": {
"title": "API VoiceStudio",
"loading": "Wczytywanie specyfikacji API…",
"unreachable_title": "Specyfikacja backendu jest niedostępna",
"unreachable_body": "Nie udało się pobrać specyfikacji OpenAPI lokalnego backendu z adresu {{url}}. Upewnij się, że backend działa, i spróbuj ponownie.",
"retry": "Spróbuj ponownie",
"copy_url": "Kopiuj adres specyfikacji",
"copy_url_aria": "Kopiuj adres /openapi.json",
"copied": "Skopiowano adres specyfikacji",
"copy_failed": "Kopiowanie nie powiodło się — zaznacz i skopiuj ręcznie adres powyżej.",
"open_raw": "Otwórz surową specyfikację",
"open_raw_aria": "Otwórz surowy plik JSON OpenAPI w przeglądarce"
},
"recording": {
"cleaned_loaded": "Nagranie oczyszczone i załadowane!",
"loaded_raw": "Nagranie załadowane (surowe — odszumianie niedostępne)",
"too_short": "Nagranie zbyt krótkie"
"too_short": "Nagranie zbyt krótkie",
"input_device": "Urządzenie wejściowe",
"default_input": "Domyślne systemowe",
"microphone_number": "Mikrofon {{number}}",
"channels": "Kanały",
"channels_auto": "Automatycznie",
"channels_mono": "Mono",
"channels_stereo": "Stereo",
"input_level": "Poziom wejścia mikrofonu",
"input_detected": "Wykryto sygnał",
"no_input_detected": "Nie wykryto sygnału"
},
"pronunciation": {
"title": "Słownik wymowy",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Áreas de trabalho"
},
"settings": {
"openapi": "API do VoiceStudio",
"general": "Geral",
"theme": "Tema",
"language": "Idioma",
@@ -307,6 +308,9 @@
"clear": "Limpar",
"refresh": "Atualizar",
"close": "Fechar",
"minimize_window": "Minimizar janela",
"maximize_restore_window": "Maximizar ou restaurar janela",
"close_window": "Fechar janela",
"back": "Voltar",
"play": "Jogar",
"pause": "Pausa",
@@ -2073,10 +2077,33 @@
"filter_starred": "Marcadas",
"load_take_failed": "Não foi possível carregar essa take: {{message}}"
},
"openapi": {
"title": "API do VoiceStudio",
"loading": "Carregando a especificação da API…",
"unreachable_title": "Especificação do backend indisponível",
"unreachable_body": "Não foi possível acessar a especificação OpenAPI do backend local em {{url}}. Verifique se o backend está em execução e tente novamente.",
"retry": "Tentar novamente",
"copy_url": "Copiar URL da especificação",
"copy_url_aria": "Copiar a URL de /openapi.json",
"copied": "URL da especificação copiada",
"copy_failed": "Falha ao copiar — selecione e copie manualmente a URL acima.",
"open_raw": "Abrir especificação bruta",
"open_raw_aria": "Abrir o JSON OpenAPI bruto no navegador"
},
"recording": {
"cleaned_loaded": "Gravação limpa e carregada!",
"loaded_raw": "Gravação carregada (bruta — remoção de ruído indisponível)",
"too_short": "Gravação muito curta"
"too_short": "Gravação muito curta",
"input_device": "Dispositivo de entrada",
"default_input": "Padrão do sistema",
"microphone_number": "Microfone {{number}}",
"channels": "Canais",
"channels_auto": "Automático",
"channels_mono": "Mono",
"channels_stereo": "Estéreo",
"input_level": "Nível de entrada do microfone",
"input_detected": "Entrada detectada",
"no_input_detected": "Nenhuma entrada detectada"
},
"pronunciation": {
"title": "Dicionário de pronúncia",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Рабочие пространства"
},
"settings": {
"openapi": "API VoiceStudio",
"general": "Общие",
"theme": "Тема",
"language": "Язык",
@@ -307,6 +308,9 @@
"clear": "Прозрачный",
"refresh": "Обновить",
"close": "Закрывать",
"minimize_window": "Свернуть окно",
"maximize_restore_window": "Развернуть или восстановить окно",
"close_window": "Закрыть окно",
"back": "Назад",
"play": "Играть",
"pause": "Пауза",
@@ -2073,10 +2077,33 @@
"filter_starred": "Избранные",
"load_take_failed": "Не удалось загрузить дубль: {{message}}"
},
"openapi": {
"title": "API VoiceStudio",
"loading": "Загрузка спецификации API…",
"unreachable_title": "Спецификация бэкенда недоступна",
"unreachable_body": "Не удалось получить спецификацию OpenAPI локального бэкенда по адресу {{url}}. Убедитесь, что бэкенд запущен, и повторите попытку.",
"retry": "Повторить",
"copy_url": "Копировать URL спецификации",
"copy_url_aria": "Копировать URL /openapi.json",
"copied": "URL спецификации скопирован",
"copy_failed": "Не удалось скопировать — выделите и скопируйте URL выше вручную.",
"open_raw": "Открыть исходную спецификацию",
"open_raw_aria": "Открыть исходный JSON OpenAPI в браузере"
},
"recording": {
"cleaned_loaded": "Запись очищена и загружена!",
"loaded_raw": "Запись загружена (без обработки — шумоподавление недоступно)",
"too_short": "Запись слишком короткая"
"too_short": "Запись слишком короткая",
"input_device": "Устройство ввода",
"default_input": "Системное по умолчанию",
"microphone_number": "Микрофон {{number}}",
"channels": "Каналы",
"channels_auto": "Автоматически",
"channels_mono": "Моно",
"channels_stereo": "Стерео",
"input_level": "Уровень входа микрофона",
"input_detected": "Сигнал обнаружен",
"no_input_detected": "Сигнал не обнаружен"
},
"pronunciation": {
"title": "Словарь произношения",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Arbetsytor"
},
"settings": {
"openapi": "VoiceStudio API",
"general": "Allmänt",
"theme": "Tema",
"language": "Språk",
@@ -307,6 +308,9 @@
"clear": "Rensa",
"refresh": "Uppdatera",
"close": "Stäng",
"minimize_window": "Minimera fönstret",
"maximize_restore_window": "Maximera eller återställ fönstret",
"close_window": "Stäng fönstret",
"back": "Tillbaka",
"play": "Spela",
"pause": "Pausa",
@@ -2073,10 +2077,33 @@
"filter_starred": "Stjärnmärkta",
"load_take_failed": "Kunde inte läsa in tagningen: {{message}}"
},
"openapi": {
"title": "VoiceStudio API",
"loading": "Läser in API-specifikationen…",
"unreachable_title": "Backend-specifikationen är inte tillgänglig",
"unreachable_body": "Det gick inte att nå den lokala backendens OpenAPI-specifikation på {{url}}. Kontrollera att backenden körs och försök igen.",
"retry": "Försök igen",
"copy_url": "Kopiera specifikations-URL",
"copy_url_aria": "Kopiera URL:en till /openapi.json",
"copied": "Specifikations-URL kopierad",
"copy_failed": "Kopieringen misslyckades — markera och kopiera URL:en ovan manuellt.",
"open_raw": "Öppna rå specifikation",
"open_raw_aria": "Öppna rå OpenAPI-JSON i webbläsaren"
},
"recording": {
"cleaned_loaded": "Inspelningen rensad och laddad!",
"loaded_raw": "Inspelningen laddad (rå — brusreducering otillgänglig)",
"too_short": "Inspelningen är för kort"
"too_short": "Inspelningen är för kort",
"input_device": "Inmatningsenhet",
"default_input": "Systemstandard",
"microphone_number": "Mikrofon {{number}}",
"channels": "Kanaler",
"channels_auto": "Automatiskt",
"channels_mono": "Mono",
"channels_stereo": "Stereo",
"input_level": "Mikrofonens ingångsnivå",
"input_detected": "Ingång upptäckt",
"no_input_detected": "Ingen ingång upptäckt"
},
"pronunciation": {
"title": "Uttalsordbok",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "พื้นที่ทำงาน"
},
"settings": {
"openapi": "API ของ VoiceStudio",
"general": "ทั่วไป",
"theme": "ธีม",
"language": "ภาษา",
@@ -307,6 +308,9 @@
"clear": "ชัดเจน",
"refresh": "รีเฟรช",
"close": "ปิด",
"minimize_window": "ย่อหน้าต่าง",
"maximize_restore_window": "ขยายหรือคืนค่าหน้าต่าง",
"close_window": "ปิดหน้าต่าง",
"back": "กลับ",
"play": "เล่น",
"pause": "หยุดชั่วคราว",
@@ -2073,10 +2077,33 @@
"filter_starred": "ติดดาว",
"load_take_failed": "โหลดเทคนั้นไม่ได้: {{message}}"
},
"openapi": {
"title": "API ของ VoiceStudio",
"loading": "กำลังโหลดข้อกำหนด API…",
"unreachable_title": "ไม่พบข้อกำหนดของแบ็กเอนด์",
"unreachable_body": "ไม่สามารถเข้าถึงข้อกำหนด OpenAPI ของแบ็กเอนด์ภายในเครื่องที่ {{url}} ได้ โปรดตรวจสอบว่าแบ็กเอนด์กำลังทำงาน แล้วลองอีกครั้ง",
"retry": "ลองอีกครั้ง",
"copy_url": "คัดลอก URL ของข้อกำหนด",
"copy_url_aria": "คัดลอก URL /openapi.json",
"copied": "คัดลอก URL ของข้อกำหนดแล้ว",
"copy_failed": "คัดลอกไม่สำเร็จ — โปรดเลือกและคัดลอก URL ด้านบนด้วยตนเอง",
"open_raw": "เปิดข้อกำหนดดิบ",
"open_raw_aria": "เปิด JSON ของ OpenAPI แบบดิบในเบราว์เซอร์"
},
"recording": {
"cleaned_loaded": "ทำความสะอาดและโหลดการบันทึกแล้ว!",
"loaded_raw": "โหลดการบันทึกแล้ว (ดิบ — ใช้การลดเสียงรบกวนไม่ได้)",
"too_short": "การบันทึกสั้นเกินไป"
"too_short": "การบันทึกสั้นเกินไป",
"input_device": "อุปกรณ์อินพุต",
"default_input": "ค่าเริ่มต้นของระบบ",
"microphone_number": "ไมโครโฟน {{number}}",
"channels": "ช่องสัญญาณ",
"channels_auto": "อัตโนมัติ",
"channels_mono": "โมโน",
"channels_stereo": "สเตอริโอ",
"input_level": "ระดับอินพุตไมโครโฟน",
"input_detected": "ตรวจพบเสียง",
"no_input_detected": "ไม่พบเสียง"
},
"pronunciation": {
"title": "พจนานุกรมการออกเสียง",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Çalışma alanları"
},
"settings": {
"openapi": "VoiceStudio API",
"general": "Genel",
"theme": "Tema",
"language": "Dil",
@@ -307,6 +308,9 @@
"clear": "Temizle",
"refresh": "Yenile",
"close": "Kapat",
"minimize_window": "Pencereyi simge durumuna küçült",
"maximize_restore_window": "Pencereyi büyüt veya geri yükle",
"close_window": "Pencereyi kapat",
"back": "Geri",
"play": "Oynat",
"pause": "Duraklat",
@@ -2073,10 +2077,33 @@
"filter_starred": "Yıldızlı",
"load_take_failed": "Kayıt yüklenemedi: {{message}}"
},
"openapi": {
"title": "VoiceStudio API",
"loading": "API belirtimi yükleniyor…",
"unreachable_title": "Backend belirtimi kullanılamıyor",
"unreachable_body": "{{url}} adresindeki yerel backend OpenAPI belirtimine ulaşılamadı. Backend'in çalıştığından emin olup yeniden deneyin.",
"retry": "Yeniden dene",
"copy_url": "Belirtim URL'sini kopyala",
"copy_url_aria": "/openapi.json URL'sini kopyala",
"copied": "Belirtim URL'si kopyalandı",
"copy_failed": "Kopyalama başarısız — yukarıdaki URL'yi seçip elle kopyalayın.",
"open_raw": "Ham belirtimi aç",
"open_raw_aria": "Ham OpenAPI JSON dosyasını tarayıcıda aç"
},
"recording": {
"cleaned_loaded": "Kayıt temizlendi ve yüklendi!",
"loaded_raw": "Kayıt yüklendi (ham — gürültü giderme kullanılamıyor)",
"too_short": "Kayıt çok kısa"
"too_short": "Kayıt çok kısa",
"input_device": "Giriş aygıtı",
"default_input": "Sistem varsayılanı",
"microphone_number": "Mikrofon {{number}}",
"channels": "Kanallar",
"channels_auto": "Otomatik",
"channels_mono": "Mono",
"channels_stereo": "Stereo",
"input_level": "Mikrofon giriş seviyesi",
"input_detected": "Giriş algılandı",
"no_input_detected": "Giriş algılanmadı"
},
"pronunciation": {
"title": "Telaffuz sözlüğü",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Робочі простори"
},
"settings": {
"openapi": "API VoiceStudio",
"general": "Загальні",
"theme": "Тема",
"language": "Мова",
@@ -307,6 +308,9 @@
"clear": "ясно",
"refresh": "Оновити",
"close": "Закрити",
"minimize_window": "Згорнути вікно",
"maximize_restore_window": "Розгорнути або відновити вікно",
"close_window": "Закрити вікно",
"back": "Назад",
"play": "грати",
"pause": "Пауза",
@@ -2073,10 +2077,33 @@
"filter_starred": "Із зіркою",
"load_take_failed": "Не вдалося завантажити дубль: {{message}}"
},
"openapi": {
"title": "API VoiceStudio",
"loading": "Завантаження специфікації API…",
"unreachable_title": "Специфікація бекенду недоступна",
"unreachable_body": "Не вдалося отримати специфікацію OpenAPI локального бекенду за адресою {{url}}. Переконайтеся, що бекенд працює, і повторіть спробу.",
"retry": "Повторити",
"copy_url": "Копіювати URL специфікації",
"copy_url_aria": "Копіювати URL /openapi.json",
"copied": "URL специфікації скопійовано",
"copy_failed": "Не вдалося скопіювати — виділіть і скопіюйте URL вище вручну.",
"open_raw": "Відкрити вихідну специфікацію",
"open_raw_aria": "Відкрити вихідний JSON OpenAPI у браузері"
},
"recording": {
"cleaned_loaded": "Запис очищено та завантажено!",
"loaded_raw": "Запис завантажено (необроблений — усунення шуму недоступне)",
"too_short": "Запис надто короткий"
"too_short": "Запис надто короткий",
"input_device": "Пристрій введення",
"default_input": "Системний за замовчуванням",
"microphone_number": "Мікрофон {{number}}",
"channels": "Канали",
"channels_auto": "Автоматично",
"channels_mono": "Моно",
"channels_stereo": "Стерео",
"input_level": "Рівень входу мікрофона",
"input_detected": "Сигнал виявлено",
"no_input_detected": "Сигнал не виявлено"
},
"pronunciation": {
"title": "Словник вимови",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "Không gian làm việc"
},
"settings": {
"openapi": "API VoiceStudio",
"general": "Chung",
"theme": "Giao diện",
"language": "Ngôn ngữ",
@@ -307,6 +308,9 @@
"clear": "Xóa",
"refresh": "Làm mới",
"close": "Đóng",
"minimize_window": "Thu nhỏ cửa sổ",
"maximize_restore_window": "Phóng to hoặc khôi phục cửa sổ",
"close_window": "Đóng cửa sổ",
"back": "Quay lại",
"play": "Chơi",
"pause": "Tạm dừng",
@@ -2073,10 +2077,33 @@
"filter_starred": "Đã gắn sao",
"load_take_failed": "Không thể tải bản thu đó: {{message}}"
},
"openapi": {
"title": "API VoiceStudio",
"loading": "Đang tải đặc tả API…",
"unreachable_title": "Không có đặc tả backend",
"unreachable_body": "Không thể truy cập đặc tả OpenAPI của backend cục bộ tại {{url}}. Hãy bảo đảm backend đang chạy rồi thử lại.",
"retry": "Thử lại",
"copy_url": "Sao chép URL đặc tả",
"copy_url_aria": "Sao chép URL /openapi.json",
"copied": "Đã sao chép URL đặc tả",
"copy_failed": "Sao chép thất bại — hãy chọn và sao chép thủ công URL ở trên.",
"open_raw": "Mở đặc tả thô",
"open_raw_aria": "Mở JSON OpenAPI thô trong trình duyệt"
},
"recording": {
"cleaned_loaded": "Đã làm sạch và tải bản ghi âm!",
"loaded_raw": "Đã tải bản ghi âm (thô — không có khử nhiễu)",
"too_short": "Bản ghi âm quá ngắn"
"too_short": "Bản ghi âm quá ngắn",
"input_device": "Thiết bị đầu vào",
"default_input": "Mặc định hệ thống",
"microphone_number": "Micrô {{number}}",
"channels": "Kênh",
"channels_auto": "Tự động",
"channels_mono": "Đơn âm",
"channels_stereo": "Âm thanh nổi",
"input_level": "Mức đầu vào micrô",
"input_detected": "Đã phát hiện âm thanh",
"no_input_detected": "Không phát hiện âm thanh"
},
"pronunciation": {
"title": "Từ điển phát âm",
+28 -1
View File
@@ -90,6 +90,9 @@
"clear": "清除",
"refresh": "刷新",
"close": "关闭",
"minimize_window": "最小化窗口",
"maximize_restore_window": "最大化或还原窗口",
"close_window": "关闭窗口",
"back": "返回",
"play": "播放",
"pause": "暂停",
@@ -246,6 +249,7 @@
"seed_reroll_hint": "滚动一个新的随机种子并保留它"
},
"settings": {
"openapi": "VoiceStudio API",
"title": "设置",
"general": "通用",
"models": "模型",
@@ -2080,10 +2084,33 @@
"filter_starred": "已加星",
"load_take_failed": "无法加载该条生成:{{message}}"
},
"openapi": {
"title": "VoiceStudio API",
"loading": "正在加载 API 规范…",
"unreachable_title": "后端规范不可用",
"unreachable_body": "无法访问 {{url}} 上的本地后端 OpenAPI 规范。请确认后端正在运行,然后重试。",
"retry": "重试",
"copy_url": "复制规范网址",
"copy_url_aria": "复制 /openapi.json 网址",
"copied": "已复制规范网址",
"copy_failed": "复制失败 — 请手动选择并复制上方网址。",
"open_raw": "打开原始规范",
"open_raw_aria": "在浏览器中打开原始 OpenAPI JSON"
},
"recording": {
"cleaned_loaded": "录音已清理并加载!",
"loaded_raw": "录音已加载(原始音频——降噪不可用)",
"too_short": "录音太短"
"too_short": "录音太短",
"input_device": "输入设备",
"default_input": "系统默认",
"microphone_number": "麦克风 {{number}}",
"channels": "声道",
"channels_auto": "自动",
"channels_mono": "单声道",
"channels_stereo": "立体声",
"input_level": "麦克风输入电平",
"input_detected": "检测到输入",
"no_input_detected": "未检测到输入"
},
"pronunciation": {
"title": "发音词典",
+28 -1
View File
@@ -25,6 +25,7 @@
"workspaces": "工作區"
},
"settings": {
"openapi": "VoiceStudio API",
"general": "一般",
"theme": "佈景主題",
"language": "顯示語言",
@@ -307,6 +308,9 @@
"clear": "清除",
"refresh": "重新整理",
"close": "關閉",
"minimize_window": "最小化視窗",
"maximize_restore_window": "最大化或還原視窗",
"close_window": "關閉視窗",
"back": "返回",
"play": "玩",
"pause": "暫停",
@@ -2073,10 +2077,33 @@
"filter_starred": "已加星",
"load_take_failed": "無法載入該次生成:{{message}}"
},
"openapi": {
"title": "VoiceStudio API",
"loading": "正在載入 API 規格…",
"unreachable_title": "後端規格無法使用",
"unreachable_body": "無法存取 {{url}} 上的本機後端 OpenAPI 規格。請確認後端正在執行,然後重試。",
"retry": "重試",
"copy_url": "複製規格網址",
"copy_url_aria": "複製 /openapi.json 網址",
"copied": "已複製規格網址",
"copy_failed": "複製失敗 — 請手動選取並複製上方網址。",
"open_raw": "開啟原始規格",
"open_raw_aria": "在瀏覽器中開啟原始 OpenAPI JSON"
},
"recording": {
"cleaned_loaded": "錄音已清理並載入!",
"loaded_raw": "錄音已載入(原始 — 無法降噪)",
"too_short": "錄音太短"
"too_short": "錄音太短",
"input_device": "輸入裝置",
"default_input": "系統預設",
"microphone_number": "麥克風 {{number}}",
"channels": "聲道",
"channels_auto": "自動",
"channels_mono": "單聲道",
"channels_stereo": "立體聲",
"input_level": "麥克風輸入電平",
"input_detected": "偵測到輸入",
"no_input_detected": "未偵測到輸入"
},
"pronunciation": {
"title": "發音字典",
+47 -2
View File
@@ -748,6 +748,14 @@ html[data-zoom-layout='off'] .app-container {
height: 100vh;
zoom: 1;
}
/* Tauri applies UI scale to the webview itself. Keep the document shell at a
true viewport size there; combining native zoom with this CSS zoom contract
would double-scale and recreate the right/bottom dead bands. */
html[data-ui-scale-engine='native'] .app-container {
width: 100vw;
height: 100vh;
zoom: 1;
}
/* No padding-bottom footer reservation here anymore: the LogsFooter occupies
grid row 3 (see grid-template-rows above), so row-2 content stops at its
top edge by construction. The footer still writes --logs-footer-height for
@@ -865,7 +873,7 @@ html[data-zoom-layout='off'] .app-container {
/* Matches the LogsFooter's chrome: flat bg, hairline bottom border,
no radial glow, no blur, no decorative SVG ribbon. The pulsing
accent dot + italic logo still carry the brand. */
padding: 3px 16px 3px 64px;
padding: 3px 8px 3px 16px;
background: var(--chrome-bg);
border-bottom: 1px solid var(--chrome-border);
user-select: none;
@@ -874,7 +882,6 @@ html[data-zoom-layout='off'] .app-container {
grid-column: 1 / -1;
grid-row: 1;
cursor: default;
padding-right: 8px;
}
@keyframes flush-slide {
@@ -2766,6 +2773,21 @@ html[data-zoom-layout='off'] .app-wizard-wrap {
height: 100vh;
zoom: 1;
}
html[data-ui-scale-engine='native'] .app-wizard-wrap {
width: 100vw;
height: 100vh;
zoom: 1;
}
.app-bootstrap-scale {
width: calc(100vw / var(--ui-scale, 1));
height: calc(100vh / var(--ui-scale, 1));
zoom: var(--ui-scale, 1);
}
html[data-ui-scale-engine='native'] .app-bootstrap-scale {
width: 100vw;
height: 100vh;
zoom: 1;
}
.app-wizard-dragstrip {
position: fixed; top: 0; left: 0; right: 0;
height: 28px; z-index: 10;
@@ -3834,6 +3856,29 @@ body:has(.capture-pill) {
white-space: nowrap;
color: var(--chrome-fg-dim, #665c54);
}
.dub-stepper__action {
display: inline-flex;
align-items: center;
gap: inherit;
padding: 2px;
border: 0;
border-radius: 6px;
color: inherit;
background: transparent;
font: inherit;
}
button.dub-stepper__action {
cursor: pointer;
touch-action: manipulation;
}
button.dub-stepper__action:hover {
color: var(--chrome-fg);
background: var(--chrome-hover-bg, rgba(255, 255, 255, 0.06));
}
button.dub-stepper__action:focus-visible {
outline: 2px solid var(--chrome-accent, #d3869b);
outline-offset: 2px;
}
.dub-stepper__step:not(:first-child)::before {
content: '';
width: 24px;
+12
View File
@@ -58,6 +58,12 @@ export default function CloneDesignTab(props) {
isRecording,
isCleaning,
recordingTime,
audioInputs,
selectedAudioInputId,
setSelectedAudioInputId,
channelMode,
setChannelMode,
inputLevelStore,
vdStates,
setVdStates,
isGenerating,
@@ -350,6 +356,12 @@ export default function CloneDesignTab(props) {
isCleaning={isCleaning}
isRecording={isRecording}
recordingTime={recordingTime}
audioInputs={audioInputs}
selectedAudioInputId={selectedAudioInputId}
setSelectedAudioInputId={setSelectedAudioInputId}
channelMode={channelMode}
setChannelMode={setChannelMode}
inputLevelStore={inputLevelStore}
startRecording={startRecording}
stopRecording={stopRecording}
refText={refText}
+57 -32
View File
@@ -24,6 +24,7 @@ export default function DubTab(props) {
dubLocalBlobUrl,
transcribeElapsed,
transcribeProgress,
asrInstall,
translateProvider,
setTranslateProvider,
showTranscript,
@@ -38,6 +39,7 @@ export default function DubTab(props) {
handleDubUpload,
handleDubIngestUrl,
handleDubRetryTranscribe,
handleInstallMissingAsr,
handleDubStop,
handleDubGenerate,
handleDubImportSrt,
@@ -113,36 +115,6 @@ export default function DubTab(props) {
const activeProjectName = useAppStore((s) => s.activeProjectName);
const translateQuality = useAppStore((s) => s.translateQuality);
const setTranslateQuality = useAppStore((s) => s.setTranslateQuality);
// #372: live LLM availability so the Cinematic toggle can refuse the pick
// (instead of looping the user between two warnings). null until loaded.
const [llmEndpoint, setLlmEndpoint] = useState(null);
useEffect(() => {
let cancelled = false;
const refresh = () =>
import('../api/client').then(({ apiJson }) =>
apiJson('/api/settings/llm-endpoint')
.then((d) => {
if (!cancelled) setLlmEndpoint(d);
})
.catch(() => {
/* backend mid-boot — guard simply stays permissive */
}),
);
refresh();
// Re-poll when the window regains focus / becomes visible configuring a
// provider in Settings LLM Providers otherwise wouldn't lift the Cinematic
// gate until this tab remounted (the fetch used to be mount-only, `[]`).
const onVisible = () => {
if (document.visibilityState === 'visible') refresh();
};
window.addEventListener('focus', refresh);
document.addEventListener('visibilitychange', onVisible);
return () => {
cancelled = true;
window.removeEventListener('focus', refresh);
document.removeEventListener('visibilitychange', onVisible);
};
}, []);
const dualSubs = useAppStore((s) => s.dualSubs);
const setDualSubs = useAppStore((s) => s.setDualSubs);
const burnSubs = useAppStore((s) => s.burnSubs);
@@ -420,6 +392,50 @@ export default function DubTab(props) {
setYoutubeCookieFile(null);
resetDub?.();
}, [resetDub]);
const pipelineBusy =
isTranslating ||
['uploading', 'installing-asr', 'transcribing', 'generating', 'stopping'].includes(dubStep);
const pipelineSteps = pipelineBusy
? []
: [
...(dubJobId || dubStep !== 'idle' ? ['upload'] : []),
...(dubVideoFile ? ['prepare'] : []),
...(dubJobId ? ['transcribe'] : []),
...(dubSegments.length ? ['edit'] : []),
...(dubStep === 'done' ? ['export'] : []),
];
const onPipelineStep = useCallback(
(step) => {
if (pipelineBusy) return;
if (step === 'upload') {
if (dubSegments.length && !window.confirm(`${t('dub.reset')}?`)) return;
resetDubAndCredentials();
} else if (step === 'prepare' && dubVideoFile) {
handleDubUpload?.();
} else if (step === 'transcribe' && dubJobId) {
const transcriptComplete =
dubSegments.length > 0 && ['editing', 'generating', 'done'].includes(dubStep);
if (transcriptComplete && !window.confirm(`${t('dub.retry_transcription')}?`)) return;
handleDubRetryTranscribe?.();
} else if (step === 'edit' && dubSegments.length) {
setDubStep('editing');
} else if (step === 'export' && dubStep === 'done') {
setExportOpen(true);
}
},
[
pipelineBusy,
dubSegments.length,
t,
resetDubAndCredentials,
dubVideoFile,
handleDubUpload,
dubJobId,
handleDubRetryTranscribe,
setDubStep,
dubStep,
],
);
const onIngestUrl = () => {
if (!ingestUrl.trim() || !handleDubIngestUrl) return;
handleDubIngestUrl(ingestUrl.trim(), {
@@ -529,7 +545,13 @@ export default function DubTab(props) {
!(
dubJobId &&
(dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done')
) && <DubPipelineStepper dubStep={dubStep} />}
) && (
<DubPipelineStepper
dubStep={dubStep}
selectableSteps={pipelineSteps}
onStepSelect={onPipelineStep}
/>
)}
{/* ── Idle: show full editor skeleton with drop zone ── */}
{showIdleSkeleton && (
<IdleSkeleton
@@ -541,6 +563,8 @@ export default function DubTab(props) {
dubJobId={dubJobId}
dubStep={dubStep}
dubFailure={dubFailure}
asrInstall={asrInstall}
handleInstallMissingAsr={handleInstallMissingAsr}
handleDubRetryTranscribe={handleDubRetryTranscribe}
handleDubImportSrt={handleDubImportSrt}
dubLocalBlobUrl={dubLocalBlobUrl}
@@ -600,6 +624,8 @@ export default function DubTab(props) {
qcRunning={qcRunning}
handleDubQc={handleDubQc}
setExportOpen={setExportOpen}
pipelineSteps={pipelineSteps}
onPipelineStep={onPipelineStep}
/>
<div className="grid grid-cols-2 max-[1000px]:grid-cols-1 max-[1000px]:grid-rows-[auto_1fr] gap-[6px] flex-1 min-h-0 overflow-hidden">
<DubLeftColumn
@@ -652,7 +678,6 @@ export default function DubTab(props) {
engines={engines}
setTranslateProvider={handleSelectTranslateProvider}
setTranslateQuality={setTranslateQuality}
llmEndpoint={llmEndpoint}
multiLangMode={multiLangMode}
setMultiLangMode={setMultiLangMode}
multiLangs={multiLangs}
+1
View File
@@ -20,6 +20,7 @@ import type { EffectPreset } from '../api/engines';
type DubStep =
| 'idle'
| 'uploading'
| 'installing-asr'
| 'transcribing'
| 'editing'
| 'generating'
@@ -116,6 +116,7 @@ beforeEach(() => {
return undefined;
});
FakeWS.instances = [];
storeState.dictationModelId = 'sherpa-parakeet-v3';
realWebSocket = globalThis.WebSocket;
globalThis.WebSocket = FakeWS;
// jsdom has no MediaRecorder; only isTypeSupported is reached on the
@@ -139,6 +140,21 @@ afterEach(() => {
});
describe('CaptureWidget — connect-time asr_model_missing during mic setup', () => {
it('turns a PCM-fallback socket failure into a terminal error', async () => {
storeState.dictationModelId = 'whisperx';
render(<CaptureWidget />);
pressShortcut();
await waitFor(() => expect(FakeWS.instances.length).toBe(1));
const ws = FakeWS.instances[0];
ws.onerror?.(new Event('error'));
ws.onclose?.();
micControl.resolve(micStop);
await waitFor(() => expect(screen.getByText(/Transcription failed/)).toBeInTheDocument());
expect(invokeMock).not.toHaveBeenCalledWith('set_tray_recording', { recording: true });
});
it('keeps the error pill: no recording state, no tray flag, mic released', async () => {
render(<CaptureWidget />);
pressShortcut();
@@ -36,6 +36,8 @@ function baseProps(overrides = {}) {
dubJobId: null,
dubStep: 'idle',
dubFailure: null,
asrInstall: null,
handleInstallMissingAsr: noop,
handleDubRetryTranscribe: noop,
handleDubImportSrt: noop,
dubLocalBlobUrl: null,
@@ -143,6 +145,39 @@ describe('IdleSkeleton — pipeline-stage vs idle dropzone', () => {
expect(screen.queryByPlaceholderText(URL_PLACEHOLDER)).not.toBeInTheDocument();
});
it('keeps both install and retry actions beside a missing-ASR error', () => {
const install = vi.fn();
const retry = vi.fn();
renderIdle({
dubStep: 'idle',
dubJobId: 'job-needs-asr',
dubError: 'No speech-to-text model is installed.',
asrInstall: {
phase: 'missing',
repoId: 'Systran/faster-whisper-large-v3',
label: 'Whisper large-v3',
sizeGb: 2.9,
},
handleInstallMissingAsr: install,
handleDubRetryTranscribe: retry,
});
fireEvent.click(screen.getByRole('button', { name: /Download Whisper large-v3/ }));
fireEvent.click(screen.getByRole('button', { name: 'Retry transcription' }));
expect(install).toHaveBeenCalledOnce();
expect(retry).toHaveBeenCalledOnce();
});
it('shows inline model progress instead of the upload form while installing ASR', () => {
const { container } = renderIdle({
dubStep: 'installing-asr',
dubJobId: 'job-installing-asr',
asrInstall: { phase: 'installing', label: 'Whisper large-v3', percent: 42 },
});
expect(container.querySelector('.dub-idle-drop')).toBeNull();
expect(screen.getByRole('status')).toHaveTextContent('Installing Whisper large-v3…');
expect(screen.getByText('42%')).toBeInTheDocument();
});
it('never falls back to the dropzone for a non-idle no-file step (e.g. stopping)', () => {
const { container } = renderIdle({ dubStep: 'stopping', dubJobId: 'job-url-3' });
expect(container.querySelector('.dub-idle-drop')).toBeNull();
+64 -5
View File
@@ -24,15 +24,32 @@ vi.mock('../components/dub/DubLeftColumn', () => ({
},
}));
vi.mock('../components/dub/DubHeader', () => ({
default: ({ resetDub }) => (
<button data-testid="reset-dub" onClick={resetDub}>
reset
</button>
default: ({ resetDub, pipelineSteps = [], onPipelineStep }) => (
<div>
<button data-testid="reset-dub" onClick={resetDub}>
reset
</button>
{pipelineSteps.map((step) => (
<button key={step} onClick={() => onPipelineStep(step)}>
{step}
</button>
))}
</div>
),
}));
vi.mock('../components/dub/DubRightColumn', () => ({ default: () => null }));
vi.mock('../components/dub/DubFooter', () => ({ default: () => null }));
vi.mock('../components/dub/DubPipelineStepper', () => ({ default: () => null }));
vi.mock('../components/dub/DubPipelineStepper', () => ({
default: ({ selectableSteps = [], onStepSelect }) => (
<div>
{selectableSteps.map((step) => (
<button key={step} onClick={() => onStepSelect(step)}>
{step}
</button>
))}
</div>
),
}));
vi.mock('../components/dub/IdleSkeleton', () => ({
default: ({ youtubeCookieFile, setYoutubeCookieFile }) => (
<div>
@@ -180,4 +197,46 @@ describe('DubTab — completed tracks always show their tabs (restore P0)', () =
expect(resetDub).toHaveBeenCalledOnce();
expect(screen.getByTestId('cookie-name')).toHaveTextContent('none');
});
it('retries an incomplete transcript directly but confirms before replacing a completed one', () => {
const retry = vi.fn();
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
useAppStore.setState({
dubJobId: 'job1',
dubStep: 'idle',
dubSegments: [],
dubTracks: [],
});
const { rerender } = render(<DubTab {...makeProps()} handleDubRetryTranscribe={retry} />);
fireEvent.click(screen.getByRole('button', { name: 'transcribe' }));
expect(retry).toHaveBeenCalledOnce();
expect(confirm).not.toHaveBeenCalled();
act(() =>
useAppStore.setState({
dubStep: 'idle',
dubSegments: [{ id: 'partial', text: 'Incomplete transcript' }],
}),
);
fireEvent.click(screen.getByRole('button', { name: 'transcribe' }));
expect(retry).toHaveBeenCalledTimes(2);
expect(confirm).not.toHaveBeenCalled();
act(() =>
useAppStore.setState({
dubStep: 'editing',
dubSegments: [{ id: 's1', text: 'Complete transcript' }],
}),
);
rerender(<DubTab {...makeProps()} handleDubRetryTranscribe={retry} />);
fireEvent.click(screen.getByRole('button', { name: 'transcribe' }));
expect(confirm).toHaveBeenCalledOnce();
expect(retry).toHaveBeenCalledTimes(2);
confirm.mockReturnValue(true);
fireEvent.click(screen.getByRole('button', { name: 'transcribe' }));
expect(retry).toHaveBeenCalledTimes(3);
});
});
@@ -127,4 +127,23 @@ describe('Dub translation quality toggles (LLM engine)', () => {
expect(screen.queryByLabelText(t('dub.auto_glossary_label'))).toBeNull();
expect(screen.queryByLabelText(t('dub.reflect_label'))).toBeNull();
});
it('keeps every translation quality selectable without an LLM', () => {
const setTranslateQuality = vi.fn();
render(
<DubLeftColumn
{...makeProps({
translateProvider: 'google',
activeEngineEntry: GOOGLE,
llmEndpoint: { available: false },
setTranslateQuality,
})}
/>,
);
fireEvent.click(screen.getByRole('radio', { name: t('dub.autofit_quality') }));
expect(setTranslateQuality).toHaveBeenCalledWith('autofit');
fireEvent.click(screen.getByRole('radio', { name: t('dub.cinematic_quality') }));
expect(setTranslateQuality).toHaveBeenCalledWith('cinematic');
});
});
+31 -4
View File
@@ -7,13 +7,26 @@
* where you are twice; leave the tabs out in tabs mode and the app has no
* navigation at all (the rail isn't rendered either).
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import Header from '../components/Header';
const windowActions = vi.hoisted(() => ({
minimize: vi.fn(async () => {}),
toggleMaximize: vi.fn(async () => {}),
close: vi.fn(async () => {}),
}));
vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => windowActions }));
afterEach(() => {
delete window.__TAURI_INTERNALS__;
vi.clearAllMocks();
});
function renderHeader(props) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
@@ -30,9 +43,22 @@ describe('Header — rail mode (default)', () => {
expect(container.querySelector('.header-area--tabs')).toBeNull();
expect(screen.queryByTestId('titletab-dub')).toBeNull();
// Breadcrumb (current view) + centred wordmark both stay.
expect(container.textContent).toMatch(/OmniVoice/);
expect(container.textContent).toMatch(/VoiceStudio/);
expect(screen.getByTestId('voice-studio-logo')).toBeInTheDocument();
expect(container.textContent).toMatch(/Dub/);
});
it('uses the app header for native window controls', async () => {
window.__TAURI_INTERNALS__ = {};
renderHeader({});
fireEvent.click(screen.getByRole('button', { name: 'Minimize window' }));
await waitFor(() => expect(windowActions.minimize).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: 'Maximize or restore window' }));
await waitFor(() => expect(windowActions.toggleMaximize).toHaveBeenCalledOnce());
expect(screen.getByTestId('window-controls')).toBeInTheDocument();
});
});
describe('Header — titlebar tabs mode', () => {
@@ -45,6 +71,7 @@ describe('Header — titlebar tabs mode', () => {
it('drops the centred wordmark — the tabs need that room', () => {
const { container } = renderHeader({ navStyle: 'tabs' });
expect(container.textContent).not.toMatch(/OmniVoice/);
expect(container.textContent).not.toMatch(/VoiceStudio/);
expect(screen.queryByTestId('voice-studio-logo')).toBeNull();
});
});
@@ -196,6 +196,13 @@ describe('studio chrome does not appear before the studio', () => {
expect(css).toMatch(/html\[data-zoom-layout='off'\] \.app-wizard-wrap \{[^}]*zoom:\s*1/);
});
it('fills the viewport without CSS zoom when Tauri owns the scale', () => {
const css = readSrc('index.css');
expect(css).toMatch(
/html\[data-ui-scale-engine='native'\] \.app-wizard-wrap \{[^}]*width:\s*100vw[^}]*height:\s*100vh[^}]*zoom:\s*1/,
);
});
it('the mount passes --ui-scale and never a bare inline zoom', () => {
const app = readSrc('App.jsx');
const mount = app.slice(app.indexOf('className="app-wizard-wrap"'));
+6
View File
@@ -46,4 +46,10 @@ describe('app shell scale (black-band + clipping regression guard)', () => {
/\[data-zoom-layout=['"]?off['"]?\][^{]*\.app-container\s*\{[^}]*width:\s*100vw[^}]*height:\s*100vh/,
);
});
it('lets native Tauri zoom own scale while the shell still fills the viewport', () => {
expect(css).toMatch(
/\[data-ui-scale-engine=['"]?native['"]?\][^{]*\.app-container\s*\{[^}]*width:\s*100vw[^}]*height:\s*100vh[^}]*zoom:\s*1/,
);
});
});
+59 -1
View File
@@ -13,16 +13,23 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const toastError = vi.fn();
const toastSuccess = vi.fn();
const toastLoading = vi.fn();
vi.mock('react-hot-toast', () => ({
default: Object.assign(vi.fn(), {
error: (...a) => toastError(...a),
success: (...a) => toastSuccess(...a),
loading: (...a) => toastLoading(...a),
dismiss: vi.fn(),
}),
}));
const installModel = vi.fn();
vi.mock('../api/setup', () => ({ installModel: (...a) => installModel(...a) }));
const listModels = vi.fn();
vi.mock('../api/setup', () => ({
installModel: (...a) => installModel(...a),
listModels: (...a) => listModels(...a),
setupDownloadStreamUrl: () => 'http://localhost/setup/download-stream',
}));
const apiPost = vi.fn();
vi.mock('../api/client', () => ({ apiPost: (...a) => apiPost(...a) }));
@@ -70,11 +77,27 @@ describe('asrMissingPayload', () => {
});
describe('toastAsrModelMissing', () => {
let stream;
beforeEach(() => {
toastError.mockReset();
toastSuccess.mockReset();
installModel.mockReset();
listModels.mockReset();
apiPost.mockReset();
toastLoading.mockReset();
listModels.mockResolvedValue({ models: [] });
globalThis.EventSource = class FakeEventSource {
constructor(url) {
this.url = url;
this.close = vi.fn();
stream = this;
}
emit(event) {
this.onmessage?.({ data: JSON.stringify(event) });
}
};
});
function renderToast(payload) {
@@ -94,6 +117,15 @@ describe('toastAsrModelMissing', () => {
await waitFor(() =>
expect(installModel).toHaveBeenCalledWith('Systran/faster-whisper-large-v3'),
);
expect(toastSuccess).not.toHaveBeenCalled();
stream.emit({
repo_id: 'Systran/faster-whisper-large-v3',
phase: 'aggregate',
bytes_done: 50,
total_bytes: 100,
});
expect(toastLoading.mock.calls.at(-1)[0]).toContain('50%');
stream.emit({ repo_id: 'Systran/faster-whisper-large-v3', phase: 'install_done' });
// Non-dictation pick: no dictation pref write.
expect(apiPost).not.toHaveBeenCalled();
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
@@ -112,6 +144,11 @@ describe('toastAsrModelMissing', () => {
},
});
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(installModel).toHaveBeenCalled());
stream.emit({
repo_id: 'csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8',
phase: 'install_done',
});
await waitFor(() =>
expect(apiPost).toHaveBeenCalledWith('/dictation/prefs', {
model_id: 'sherpa-parakeet-tdt-v3',
@@ -119,6 +156,27 @@ describe('toastAsrModelMissing', () => {
);
});
it('reports a dictation preference write failure instead of claiming install success', async () => {
installModel.mockResolvedValue({ status: 'started' });
apiPost.mockRejectedValue(new Error('prefs unavailable'));
renderToast({
...PAYLOAD,
recommended: {
...PAYLOAD.recommended,
dictation_id: 'sherpa-parakeet-tdt-v3',
},
});
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(installModel).toHaveBeenCalled());
stream.emit({
repo_id: 'Systran/faster-whisper-large-v3',
phase: 'install_done',
});
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(2));
expect(toastSuccess).not.toHaveBeenCalled();
});
it('degrades to a plain toast when no recommendation resolves', () => {
toastAsrModelMissing({ error: 'asr_model_missing', recommended: null });
expect(toastError).toHaveBeenCalledTimes(1);
@@ -0,0 +1,162 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppStore } from '../store';
const dubApi = vi.hoisted(() => ({
dubUpload: vi.fn(),
dubIngestUrl: vi.fn(),
dubAbort: vi.fn(),
dubCleanupSegments: vi.fn(),
dubTranslate: vi.fn(),
dubGenerate: vi.fn(),
tasksStreamUrl: vi.fn(() => '/tasks'),
tasksCancel: vi.fn(),
transcribeStreamUrl: vi.fn((jobId) => `/transcribe/${jobId}`),
dubImportSrt: vi.fn(),
}));
vi.mock('../api/dub', () => dubApi);
const setupApi = vi.hoisted(() => ({
installModel: vi.fn(),
listModels: vi.fn(),
cancelInstallModel: vi.fn(),
}));
vi.mock('../api/setup', () => ({
...setupApi,
setupDownloadStreamUrl: () => '/setup/download-stream',
}));
vi.mock('../api/client', () => ({
apiPost: vi.fn(),
apiFetch: vi.fn(),
apiJson: vi.fn(),
API: '',
}));
import useDubWorkflow from '../hooks/useDubWorkflow';
const baseState = useAppStore.getState();
let streams;
class FakeEventSource {
static CLOSED = 2;
constructor(url) {
this.url = url;
this.readyState = 1;
this.listeners = new Map();
streams.push(this);
}
addEventListener(name, handler) {
this.listeners.set(name, handler);
}
close() {
this.readyState = FakeEventSource.CLOSED;
}
emit(name, data = {}) {
const event = { data: JSON.stringify(data) };
if (name === 'message') this.onmessage?.(event);
else this.listeners.get(name)?.(event);
}
}
function renderWorkflow() {
return renderHook(() =>
useDubWorkflow({
loadProjects: vi.fn(),
loadProfiles: vi.fn(),
loadDubHistory: vi.fn(),
setLastGenFingerprints: vi.fn(),
}),
);
}
describe('Dubbing missing-ASR recovery', () => {
beforeEach(() => {
streams = [];
globalThis.EventSource = FakeEventSource;
useAppStore.setState(baseState, true);
useAppStore.setState({ dubJobId: 'job-kept-for-retry', dubStep: 'idle' });
setupApi.installModel.mockReset().mockResolvedValue({ status: 'install_started' });
setupApi.listModels.mockReset().mockResolvedValue({ models: [] });
setupApi.cancelInstallModel.mockReset().mockResolvedValue({});
dubApi.dubUpload.mockReset();
});
it('never retries an old job after a new upload replaces it', async () => {
const { result } = renderWorkflow();
let firstAttempt;
act(() => {
firstAttempt = result.current.handleDubRetryTranscribe();
});
streams[0].emit('error', {
error: 'asr_model_missing',
detail: 'No speech-to-text model is installed.',
recommended: { repo_id: 'Systran/faster-whisper-large-v3', label: 'Whisper large-v3' },
});
await act(async () => firstAttempt);
let recovery;
act(() => {
recovery = result.current.handleInstallMissingAsr();
});
await waitFor(() => expect(setupApi.installModel).toHaveBeenCalledOnce());
dubApi.dubUpload.mockImplementation(() => new Promise(() => {}));
act(() => {
void result.current.handleDubUpload(new File(['video'], 'new.mp4', { type: 'video/mp4' }));
});
await waitFor(() => expect(setupApi.cancelInstallModel).toHaveBeenCalledOnce());
await act(async () => recovery);
expect(streams).toHaveLength(2);
expect(result.current.asrInstall).toBeNull();
expect(useAppStore.getState().dubJobId).not.toBe('job-kept-for-retry');
});
it('keeps the job, installs inline, then automatically retranscribes it', async () => {
const { result } = renderWorkflow();
let firstAttempt;
act(() => {
firstAttempt = result.current.handleDubRetryTranscribe();
});
streams[0].emit('error', {
error: 'asr_model_missing',
detail: 'No speech-to-text model is installed.',
recommended: {
repo_id: 'Systran/faster-whisper-large-v3',
label: 'Whisper large-v3',
size_gb: 2.9,
},
});
await act(async () => firstAttempt);
expect(result.current.asrInstall).toMatchObject({
phase: 'missing',
repoId: 'Systran/faster-whisper-large-v3',
});
expect(useAppStore.getState().dubJobId).toBe('job-kept-for-retry');
let recovery;
act(() => {
recovery = result.current.handleInstallMissingAsr();
});
await waitFor(() => expect(setupApi.installModel).toHaveBeenCalledOnce());
streams[1].emit('message', {
repo_id: 'Systran/faster-whisper-large-v3',
phase: 'install_done',
});
await waitFor(() => expect(streams).toHaveLength(3));
expect(streams[2].url).toContain('/transcribe/job-kept-for-retry');
streams[2].emit('final', { segments: [{ id: '1', text: 'hello' }] });
streams[2].emit('done');
await act(async () => recovery);
expect(useAppStore.getState().dubStep).toBe('editing');
expect(result.current.asrInstall).toBeNull();
});
});
@@ -6,13 +6,15 @@
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
const { toastMock } = vi.hoisted(() => ({
const { toastMock, cleanAudioMock, startMicCaptureMock } = vi.hoisted(() => ({
toastMock: Object.assign(vi.fn(), {
error: vi.fn(),
success: vi.fn(),
dismiss: vi.fn(),
loading: vi.fn(),
}),
cleanAudioMock: vi.fn(),
startMicCaptureMock: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
@@ -20,24 +22,33 @@ const invokeMock = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args) => invokeMock(...args),
}));
vi.mock('../api/system', () => ({ cleanAudio: vi.fn() }));
vi.mock('../api/system', () => ({ cleanAudio: cleanAudioMock }));
vi.mock('../utils/aec/micCapture', () => ({ startMicCapture: startMicCaptureMock }));
import useRecording from '../hooks/useRecording';
beforeEach(() => {
invokeMock.mockReset();
toastMock.error.mockClear();
cleanAudioMock.mockReset();
startMicCaptureMock.mockReset();
});
afterEach(() => {
delete window.__TAURI_INTERNALS__;
delete navigator.mediaDevices;
delete globalThis.MediaRecorder;
});
function installGum(impl) {
const gum = vi.fn(impl);
Object.defineProperty(navigator, 'mediaDevices', {
value: { getUserMedia: gum },
value: {
getUserMedia: gum,
enumerateDevices: vi.fn(async () => []),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
configurable: true,
});
return gum;
@@ -58,6 +69,27 @@ it('OS-denied → guided toast, getUserMedia skipped', async () => {
expect(result.current.isRecording).toBe(false);
});
it('uses the selected microphone and requested channel mode', async () => {
const gum = installGum(async () => {
const error = new Error('busy');
error.name = 'NotReadableError';
throw error;
});
const { result } = renderHook(() => useRecording(vi.fn()));
act(() => {
result.current.setSelectedAudioInputId('usb-mic');
result.current.setChannelMode('stereo');
});
await act(async () => {
await result.current.startRecording();
});
expect(gum).toHaveBeenCalledWith({
audio: { deviceId: { exact: 'usb-mic' }, channelCount: { ideal: 2 } },
});
});
it('prompt/unknown/granted → getUserMedia proceeds as before', async () => {
window.__TAURI_INTERNALS__ = {};
invokeMock.mockImplementation(async (cmd) => (cmd === 'check_microphone' ? 'prompt' : undefined));
@@ -87,3 +119,56 @@ it('plain browser: no probe, straight to getUserMedia', async () => {
expect(gum).toHaveBeenCalled();
expect(invokeMock).not.toHaveBeenCalled();
});
it('records a WAV through Web Audio when MediaRecorder is unsupported', async () => {
const stopTrack = vi.fn();
installGum(async () => ({
getTracks: () => [{ stop: stopTrack }],
getAudioTracks: () => [{ getSettings: () => ({ channelCount: 2 }) }],
}));
const stopCapture = vi.fn(async () => {});
stopCapture.sampleRate = 16000;
startMicCaptureMock.mockImplementation(async (_stream, onFrame, options) => {
stopCapture.channels = options.channels;
onFrame(new Float32Array(3200).fill(0.25));
return stopCapture;
});
cleanAudioMock.mockResolvedValue(
new Response(new Uint8Array(1200), {
headers: {
'Content-Type': 'audio/wav',
'X-Clean-Filename': 'recording_clean.wav',
},
}),
);
const ingest = vi.fn(async () => {});
const { result } = renderHook(() => useRecording(ingest));
await act(async () => {
result.current.setChannelMode('stereo');
});
await act(async () => {
await result.current.startRecording();
});
expect(result.current.isRecording).toBe(true);
await act(async () => {
result.current.stopRecording();
await Promise.resolve();
await Promise.resolve();
});
expect(startMicCaptureMock).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function),
expect.objectContaining({ channels: 2 }),
);
expect(stopCapture).toHaveBeenCalledOnce();
expect(stopTrack).toHaveBeenCalledOnce();
const form = cleanAudioMock.mock.calls[0][0];
const audio = form.get('audio');
expect(audio.type).toBe('audio/wav');
expect(audio.name).toBe('recording.wav');
expect(new DataView(await audio.arrayBuffer()).getUint16(22, true)).toBe(2);
expect(ingest).toHaveBeenCalledOnce();
});
+13 -8
View File
@@ -1,7 +1,7 @@
// Microphone PCM capture for the opt-in AEC path (parity Action 8). Routes a
// getUserMedia stream through an AudioWorklet that emits fixed-size Float32
// frames; the caller converts/tags/sends them. Only used when AEC is enabled
// — the default dictation path keeps using MediaRecorder/WebM untouched.
// Microphone PCM capture for the opt-in AEC path and for WebViews that cannot
// construct MediaRecorder. Routes a getUserMedia stream through an
// AudioWorklet that emits fixed-size Float32 frames; the caller converts,
// tags, streams, or WAV-encodes them.
const WORKLET_URL = '/aec-worklet.js';
@@ -10,27 +10,27 @@ const WORKLET_URL = '/aec-worklet.js';
*
* @param {MediaStream} stream mic stream from getUserMedia
* @param {(frame: Float32Array) => void} onFrame called per frame
* @param {{sampleRate?: number, frameSize?: number}} opts
* @param {{sampleRate?: number, frameSize?: number, channels?: number}} opts
* @returns {Promise<() => Promise<void>>} async stop() that tears down the graph
*/
export async function startMicCapture(
stream,
onFrame,
{ sampleRate = 16000, frameSize = 320 } = {},
{ sampleRate = 16000, frameSize = 320, channels = 1 } = {},
) {
const Ctx = window.AudioContext || window.webkitAudioContext;
const ctx = new Ctx({ sampleRate });
await ctx.audioWorklet.addModule(WORKLET_URL);
const src = ctx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(ctx, 'aec-frame-emitter', {
processorOptions: { frameSize },
processorOptions: { frameSize, channels },
});
node.port.onmessage = (e) => onFrame(e.data);
// Mic → worklet only. Deliberately NOT connected to destination: we tap the
// mic, we don't want to play it back through the speakers.
src.connect(node);
return async function stop() {
const stop = async function stop() {
try {
node.port.onmessage = null;
} catch {
@@ -52,4 +52,9 @@ export async function startMicCapture(
/* ignore */
}
};
// Existing callers use this value as a function. The property lets generic
// PCM/WAV recording encode the frames at the AudioContext's actual rate.
stop.sampleRate = ctx.sampleRate;
stop.channels = channels;
return stop;
}
+150 -13
View File
@@ -11,17 +11,132 @@
*
* `asrMissingPayload` normalizes all three shapes (plus an Error the SSE
* handler tagged with `.asrModelMissing`); `toastAsrModelMissing` renders the
* one-click "Download {label} ({size} GB)" CTA that starts the install via
* the existing model-install API (progress shows in Settings Models) and
* tells the user to retry. Same toast-with-action pattern as errorToast.jsx.
* one-click "Download {label} ({size} GB)" CTA that follows the install to a
* terminal state instead of treating the background-task acknowledgement as
* success. Same toast-with-action pattern as errorToast.jsx.
*/
import toast from 'react-hot-toast';
import i18next from 'i18next';
import { installModel } from '../api/setup';
import { installModel, listModels, setupDownloadStreamUrl } from '../api/setup';
import { apiPost } from '../api/client';
export const ASR_MODEL_MISSING = 'asr_model_missing';
function installPercent(event) {
if (event.phase === 'aggregate') {
const total = Number(event.total_bytes) || 0;
const files = Number(event.files_total) || 0;
const bytePct = total > 0 ? ((Number(event.bytes_done) || 0) / total) * 100 : 0;
const filePct = files > 0 ? ((Number(event.files_done) || 0) / files) * 100 : 0;
return Math.min(99, Math.max(bytePct, filePct));
}
const raw = Number(event.pct);
if (!Number.isFinite(raw) || raw < 0) return null;
return Math.min(99, raw <= 1 ? raw * 100 : raw);
}
/**
* Start the recommended install and resolve only when the model is usable.
*
* The install endpoint only queues background work. Treating its immediate
* response as success left every ASR consumer showing a stale error and made
* users hunt through Settings before manually retrying. The shared SSE stream
* supplies live progress; model-list polling closes the small subscribe/start
* race when a cached model completes before EventSource receives its terminal
* event.
*/
export async function installRecommendedAsr(payload, { onProgress, signal } = {}) {
const rec = payload?.recommended;
if (!rec?.repo_id) throw new Error(i18next.t('asr_missing.message'));
let settled = false;
let pollId = null;
let abortHandler = null;
const es = new EventSource(setupDownloadStreamUrl());
let finish;
let fail;
const terminal = new Promise((resolve, reject) => {
finish = resolve;
fail = reject;
});
const cleanup = () => {
es.close();
if (pollId) clearInterval(pollId);
if (abortHandler) signal?.removeEventListener('abort', abortHandler);
};
const resolveOnce = () => {
if (settled) return;
settled = true;
cleanup();
finish(rec);
};
const rejectOnce = (error) => {
if (settled) return;
settled = true;
cleanup();
fail(error instanceof Error ? error : new Error(String(error)));
};
es.onmessage = (message) => {
try {
const event = JSON.parse(message.data);
if (event?.repo_id !== rec.repo_id) return;
if (event.phase === 'install_done') {
onProgress?.({ phase: 'ready', percent: 100, event });
resolveOnce();
return;
}
if (event.phase === 'install_error') {
const message = event.error || i18next.t('asr_missing.message');
rejectOnce(new Error(i18next.t('asr_missing.install_failed', { message })));
return;
}
if (event.phase === 'install_cancelled') {
rejectOnce(new DOMException('Model installation cancelled', 'AbortError'));
return;
}
onProgress?.({ phase: 'installing', percent: installPercent(event), event });
} catch {
/* SSE keepalive or malformed progress event */
}
};
if (signal) {
abortHandler = () => rejectOnce(new DOMException('Model installation cancelled', 'AbortError'));
if (signal.aborted) abortHandler();
else signal.addEventListener('abort', abortHandler, { once: true });
}
try {
onProgress?.({ phase: 'installing', percent: 0 });
await installModel(rec.repo_id);
// Authoritative fallback for a cached/very fast install whose terminal SSE
// event raced the subscription. Poll only while this one install is active.
if (!settled) {
pollId = setInterval(async () => {
try {
const data = await listModels();
if (data?.models?.some((model) => model.repo_id === rec.repo_id && model.installed)) {
onProgress?.({ phase: 'ready', percent: 100 });
resolveOnce();
}
} catch {
/* the SSE stream remains authoritative while the backend reconnects */
}
}, 1500);
}
const ready = await terminal;
if (rec.dictation_id) {
await apiPost('/dictation/prefs', { model_id: rec.dictation_id });
}
return ready;
} catch (error) {
if (settled) throw error instanceof Error ? error : new Error(String(error));
rejectOnce(error);
return terminal;
}
}
/** Extract the typed payload from any of the transport shapes, or null. */
export function asrMissingPayload(err) {
if (!err || typeof err !== 'object') return null;
@@ -36,7 +151,10 @@ export function asrMissingPayload(err) {
}
/** Actionable toast: message + one-click download of the recommended model. */
export function toastAsrModelMissing(payload) {
export function toastAsrModelMissing(
payload,
{ onInstallStart, onProgress, onReady, onError } = {},
) {
const t = i18next.t.bind(i18next);
const rec = payload?.recommended;
const message = t('asr_missing.message');
@@ -55,16 +173,35 @@ export function toastAsrModelMissing(payload) {
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
onClick={async () => {
toast.dismiss(tst.id);
const progressId = `asr-install:${rec.repo_id}`;
try {
await installModel(rec.repo_id);
if (rec.dictation_id) {
// Make the retry actually pick the model up: persist it as
// the dictation engine (backend validates + normalizes).
await apiPost('/dictation/prefs', { model_id: rec.dictation_id }).catch(() => {});
}
toast.success(t('asr_missing.started', { label }), { duration: 10000 });
onInstallStart?.(rec);
toast.loading(t('dub.install_progress', { engine: label }), {
id: progressId,
duration: Infinity,
});
await installRecommendedAsr(payload, {
onProgress: (state) => {
onProgress?.(state, rec);
const pct = state.percent == null ? '' : ` ${Math.round(state.percent)}%`;
toast.loading(`${t('dub.install_progress', { engine: label })}${pct}`, {
id: progressId,
duration: Infinity,
});
},
});
toast.success(t('dub.install_ok', { engine: label }), {
id: progressId,
duration: 5000,
});
await onReady?.(rec);
} catch (e) {
toast.error(t('asr_missing.install_failed', { message: String(e?.message || e) }));
const error = e instanceof Error ? e : new Error(String(e));
onError?.(error, rec);
toast.error(t('asr_missing.install_failed', { message: error.message }), {
id: progressId,
duration: 10000,
});
}
}}
>
+82
View File
@@ -0,0 +1,82 @@
const CHANNEL_COUNTS = {
mono: 1,
stereo: 2,
};
export function createInputLevelStore(initialLevel = 0) {
let level = initialLevel;
const listeners = new Set();
return {
getSnapshot: () => level,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
set(nextLevel) {
if (Math.abs(nextLevel - level) < 0.005) return;
level = nextLevel;
listeners.forEach((listener) => listener());
},
};
}
export function buildAudioInputConstraints(deviceId = '', channelMode = 'auto') {
const audio = {};
if (deviceId) audio.deviceId = { exact: deviceId };
if (CHANNEL_COUNTS[channelMode]) audio.channelCount = { ideal: CHANNEL_COUNTS[channelMode] };
return { audio: Object.keys(audio).length ? audio : true };
}
export async function listAudioInputs(mediaDevices = navigator.mediaDevices) {
if (!mediaDevices?.enumerateDevices) return [];
const devices = await mediaDevices.enumerateDevices();
return devices.filter((device) => device.kind === 'audioinput');
}
export function startInputLevelMonitor(
stream,
onLevel,
{
AudioContextClass = globalThis.AudioContext || globalThis.webkitAudioContext,
requestFrame = globalThis.requestAnimationFrame,
cancelFrame = globalThis.cancelAnimationFrame,
} = {},
) {
if (!AudioContextClass || !requestFrame || !cancelFrame) return () => {};
const context = new AudioContextClass();
const source = context.createMediaStreamSource(stream);
const analyser = context.createAnalyser();
const silentGain = context.createGain();
const samples = new Float32Array(512);
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.72;
silentGain.gain.value = 0;
source.connect(analyser);
analyser.connect(silentGain);
silentGain.connect(context.destination);
void Promise.resolve(context.resume?.()).catch(() => {});
let frameId;
let stopped = false;
const sample = () => {
if (stopped) return;
analyser.getFloatTimeDomainData(samples);
let energy = 0;
for (const value of samples) energy += value * value;
onLevel(Math.min(1, Math.sqrt(energy / samples.length) * 4));
frameId = requestFrame(sample);
};
frameId = requestFrame(sample);
return () => {
if (stopped) return;
stopped = true;
cancelFrame(frameId);
source.disconnect();
analyser.disconnect();
silentGain.disconnect();
void Promise.resolve(context.close?.()).catch(() => {});
onLevel(0);
};
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildAudioInputConstraints,
createInputLevelStore,
listAudioInputs,
startInputLevelMonitor,
} from './audioInput';
describe('audio input utilities', () => {
it('publishes microphone levels without rerendering the app root', () => {
const store = createInputLevelStore();
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
store.set(0.3);
expect(store.getSnapshot()).toBe(0.3);
expect(listener).toHaveBeenCalledOnce();
unsubscribe();
store.set(0.6);
expect(listener).toHaveBeenCalledOnce();
});
it('builds portable default, device, and channel constraints', () => {
expect(buildAudioInputConstraints()).toEqual({ audio: true });
expect(buildAudioInputConstraints('mic-2', 'mono')).toEqual({
audio: { deviceId: { exact: 'mic-2' }, channelCount: { ideal: 1 } },
});
expect(buildAudioInputConstraints('', 'stereo')).toEqual({
audio: { channelCount: { ideal: 2 } },
});
});
it('lists only audio input devices', async () => {
const audioInputs = await listAudioInputs({
enumerateDevices: vi.fn(async () => [
{ kind: 'audiooutput', deviceId: 'speaker' },
{ kind: 'audioinput', deviceId: 'mic', label: 'Desk mic' },
]),
});
expect(audioInputs).toEqual([{ kind: 'audioinput', deviceId: 'mic', label: 'Desk mic' }]);
});
it('reports live input energy without audible monitoring and cleans up', async () => {
const frames = [];
const cancelled = vi.fn();
const source = { connect: vi.fn(), disconnect: vi.fn() };
const analyser = {
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn((samples) => samples.fill(0.1)),
};
const silentGain = {
gain: { value: 1 },
connect: vi.fn(),
disconnect: vi.fn(),
};
const context = {
destination: {},
createMediaStreamSource: vi.fn(() => source),
createAnalyser: vi.fn(() => analyser),
createGain: vi.fn(() => silentGain),
resume: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
const levels = [];
function MockAudioContext() {
return context;
}
const stop = startInputLevelMonitor({}, (level) => levels.push(level), {
AudioContextClass: MockAudioContext,
requestFrame: vi.fn((callback) => {
frames.push(callback);
return frames.length;
}),
cancelFrame: cancelled,
});
expect(silentGain.gain.value).toBe(0);
frames.shift()();
expect(levels.at(-1)).toBeCloseTo(0.4);
stop();
expect(cancelled).toHaveBeenCalled();
expect(source.disconnect).toHaveBeenCalled();
expect(context.close).toHaveBeenCalled();
expect(levels.at(-1)).toBe(0);
});
});
+5 -4
View File
@@ -4,7 +4,8 @@ export function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
export function encodeWav(samples, sampleRate) {
export function encodeWav(samples, sampleRate, channels = 1) {
const channelCount = Math.max(1, Math.min(2, Number(channels) || 1));
const buf = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buf);
const writeStr = (off, s) => {
@@ -16,10 +17,10 @@ export function encodeWav(samples, sampleRate) {
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint32(28, sampleRate * channelCount * 2, true);
view.setUint16(32, channelCount * 2, true);
view.setUint16(34, 16, true);
writeStr(36, 'data');
view.setUint32(40, samples.length * 2, true);
+94
View File
@@ -0,0 +1,94 @@
/**
* Pick a microphone container the current WebView can actually encode.
*
* Chromium usually provides WebM/Opus; Safari and WebKitGTK commonly expose
* MP4 or Ogg instead, and some WebKitGTK builds expose MediaRecorder while
* rejecting every constructor. Callers must treat `null` as "use PCM".
*/
const AUDIO_TYPES = [
['audio/webm;codecs=opus', 'webm'],
['audio/webm', 'webm'],
['audio/ogg;codecs=opus', 'ogg'],
['audio/ogg', 'ogg'],
['audio/mp4', 'm4a'],
];
function extensionFor(mimeType) {
const mime = String(mimeType || '').toLowerCase();
if (mime.includes('ogg')) return 'ogg';
if (mime.includes('mp4') || mime.includes('aac')) return 'm4a';
return 'webm';
}
export function audioFormatForMimeType(mimeType) {
return { mimeType: String(mimeType || ''), extension: extensionFor(mimeType) };
}
function recorderCandidates(Recorder) {
const canProbe = typeof Recorder.isTypeSupported === 'function';
const candidates = AUDIO_TYPES.filter(
([mimeType]) => !canProbe || Recorder.isTypeSupported(mimeType),
).map(([mimeType, extension]) => ({ options: { mimeType }, mimeType, extension }));
candidates.push({ options: undefined, mimeType: '', extension: 'webm' });
return candidates;
}
function tryRecorders(stream, Recorder, prepare) {
if (typeof Recorder !== 'function') return null;
for (const candidate of recorderCandidates(Recorder)) {
let recorder;
try {
recorder = candidate.options ? new Recorder(stream, candidate.options) : new Recorder(stream);
prepare(recorder);
const mimeType = recorder.mimeType || candidate.mimeType;
return {
recorder,
mimeType,
extension: candidate.options ? candidate.extension : extensionFor(mimeType),
};
} catch {
// WebKitGTK can accept construction and fail only when start() runs.
// Detach callbacks before cleanup so a rejected candidate cannot ingest
// an empty recording.
if (recorder) {
recorder.ondataavailable = null;
recorder.onstop = null;
try {
if (recorder.state === 'recording') recorder.stop();
} catch {
// Continue to the next format.
}
}
}
}
return null;
}
/**
* @returns {{recorder: MediaRecorder, mimeType: string, extension: string}|null}
*/
export function createSupportedMediaRecorder(
stream,
Recorder = typeof MediaRecorder === 'undefined' ? undefined : MediaRecorder,
) {
return tryRecorders(stream, Recorder, () => {});
}
/**
* Construct and start the first recorder that works. Some WebKitGTK builds
* throw NotSupportedError only from start(), after construction succeeds.
*
* @returns {{recorder: MediaRecorder, mimeType: string, extension: string}|null}
*/
export function startSupportedMediaRecorder(
stream,
{ onData, onStop, timeslice = 250 },
Recorder = typeof MediaRecorder === 'undefined' ? undefined : MediaRecorder,
) {
return tryRecorders(stream, Recorder, (recorder) => {
recorder.ondataavailable = onData;
recorder.onstop = onStop;
recorder.start(timeslice);
});
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import {
audioFormatForMimeType,
createSupportedMediaRecorder,
startSupportedMediaRecorder,
} from './mediaRecorder';
describe('createSupportedMediaRecorder', () => {
it('derives the browser-selected container from the first BlobEvent MIME type', () => {
expect(audioFormatForMimeType('audio/mp4;codecs=mp4a.40.2')).toEqual({
mimeType: 'audio/mp4;codecs=mp4a.40.2',
extension: 'm4a',
});
});
it('uses an Ogg container when WebKit rejects WebM', () => {
class OggRecorder {
static isTypeSupported(type) {
return type === 'audio/ogg';
}
constructor(_stream, options = {}) {
this.mimeType = options.mimeType;
}
}
expect(createSupportedMediaRecorder({}, OggRecorder)).toMatchObject({
mimeType: 'audio/ogg',
extension: 'ogg',
});
});
it('tries another container when a claimed codec fails construction', () => {
const constructions = [];
class WebKitRecorder {
static isTypeSupported(type) {
return type === 'audio/webm;codecs=opus' || type === 'audio/mp4';
}
constructor(_stream, options = {}) {
constructions.push(options.mimeType || 'default');
if (options.mimeType?.startsWith('audio/webm')) {
throw new DOMException('unsupported', 'NotSupportedError');
}
this.mimeType = options.mimeType;
}
}
expect(createSupportedMediaRecorder({}, WebKitRecorder)).toMatchObject({
mimeType: 'audio/mp4',
extension: 'm4a',
});
expect(constructions).toEqual(['audio/webm;codecs=opus', 'audio/mp4']);
});
it('returns null when MediaRecorder is absent or unusable', () => {
expect(createSupportedMediaRecorder({}, undefined)).toBeNull();
const Unsupported = vi.fn(() => {
throw new DOMException('MediaRecorder is unsupported on this platform', 'NotSupportedError');
});
Unsupported.isTypeSupported = () => false;
expect(createSupportedMediaRecorder({}, Unsupported)).toBeNull();
});
it('tries another container when start fails after construction', () => {
const starts = [];
class LateRejectingRecorder {
static isTypeSupported(type) {
return type === 'audio/webm;codecs=opus' || type === 'audio/mp4';
}
constructor(_stream, options = {}) {
this.mimeType = options.mimeType;
this.state = 'inactive';
}
start() {
starts.push(this.mimeType || 'default');
if (this.mimeType?.startsWith('audio/webm')) {
throw new DOMException(
'MediaRecorder is unsupported on this platform',
'NotSupportedError',
);
}
this.state = 'recording';
}
}
const result = startSupportedMediaRecorder(
{},
{ onData: vi.fn(), onStop: vi.fn() },
LateRejectingRecorder,
);
expect(result).toMatchObject({ mimeType: 'audio/mp4', extension: 'm4a' });
expect(starts).toEqual(['audio/webm;codecs=opus', 'audio/mp4']);
});
});
+29
View File
@@ -0,0 +1,29 @@
const loadTauriWebview = () => import('@tauri-apps/api/webview');
/**
* Apply the user's UI scale at the webview boundary when Tauri is available.
* Native zoom keeps the CSS viewport equal to the visible window on every
* platform; CSS zoom remains the browser/dev fallback.
*
* @param {number} scale
* @param {() => Promise<{ getCurrentWebview: () => { setZoom: (scale: number) => Promise<void> } }>} [loadWebview]
*/
export async function applyUiScale(scale, loadWebview = loadTauriWebview) {
const root = document.documentElement;
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) {
root.dataset.uiScaleEngine = 'css';
return 'css';
}
try {
const { getCurrentWebview } = await loadWebview();
await getCurrentWebview().setZoom(scale);
// Switch off CSS zoom only after native zoom succeeds, avoiding an
// unscaled flash during startup or a transient IPC failure.
root.dataset.uiScaleEngine = 'native';
return 'native';
} catch {
root.dataset.uiScaleEngine = 'css';
return 'css';
}
}
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { applyUiScale } from './uiScaleEngine';
describe('applyUiScale', () => {
beforeEach(() => {
delete window.__TAURI_INTERNALS__;
delete document.documentElement.dataset.uiScaleEngine;
});
it('uses native webview zoom in Tauri and only then disables CSS zoom', async () => {
window.__TAURI_INTERNALS__ = {};
const setZoom = vi.fn().mockResolvedValue(undefined);
await expect(
applyUiScale(0.85, async () => ({ getCurrentWebview: () => ({ setZoom }) })),
).resolves.toBe('native');
expect(setZoom).toHaveBeenCalledWith(0.85);
expect(document.documentElement.dataset.uiScaleEngine).toBe('native');
});
it('keeps the viewport-filling CSS path in a browser', async () => {
const loadWebview = vi.fn();
await expect(applyUiScale(1.15, loadWebview)).resolves.toBe('css');
expect(loadWebview).not.toHaveBeenCalled();
expect(document.documentElement.dataset.uiScaleEngine).toBe('css');
});
it('keeps CSS zoom active when native zoom is unavailable', async () => {
window.__TAURI_INTERNALS__ = {};
await expect(
applyUiScale(0.9, async () => {
throw new Error('webview zoom unavailable');
}),
).resolves.toBe('css');
expect(document.documentElement.dataset.uiScaleEngine).toBe('css');
});
});
@@ -10,45 +10,47 @@ from __future__ import annotations
import os
import wave
from api.routers.capture_ws import (
_AEC_FAR,
_AEC_NEAR,
_demux_aec_frame,
_pcm16_to_wav,
)
import pytest
def test_demux_near_frame():
kind, payload = _demux_aec_frame(bytes([_AEC_NEAR]) + b"abcd")
@pytest.fixture
def capture_ws():
from api.routers import capture_ws as module
return module
def test_demux_near_frame(capture_ws):
kind, payload = capture_ws._demux_aec_frame(bytes([capture_ws._AEC_NEAR]) + b"abcd")
assert kind == "near"
assert payload == b"abcd"
def test_demux_far_frame():
kind, payload = _demux_aec_frame(bytes([_AEC_FAR]) + b"xyz")
def test_demux_far_frame(capture_ws):
kind, payload = capture_ws._demux_aec_frame(bytes([capture_ws._AEC_FAR]) + b"xyz")
assert kind == "far"
assert payload == b"xyz"
def test_demux_empty_frame():
assert _demux_aec_frame(b"") == ("near", b"")
def test_demux_empty_frame(capture_ws):
assert capture_ws._demux_aec_frame(b"") == ("near", b"")
def test_demux_prefix_only_frame():
def test_demux_prefix_only_frame(capture_ws):
# A bare far-tag with no payload is valid (kind set, payload empty).
assert _demux_aec_frame(bytes([_AEC_FAR])) == ("far", b"")
assert capture_ws._demux_aec_frame(bytes([capture_ws._AEC_FAR])) == ("far", b"")
def test_demux_unknown_prefix_degrades_to_near():
def test_demux_unknown_prefix_degrades_to_near(capture_ws):
# Any non-0x01 tag is treated as mic audio so a bad tag never drops audio.
kind, payload = _demux_aec_frame(b"\x07hello")
kind, payload = capture_ws._demux_aec_frame(b"\x07hello")
assert kind == "near"
assert payload == b"hello"
def test_pcm16_to_wav_roundtrip():
def test_pcm16_to_wav_roundtrip(capture_ws):
pcm = (b"\x01\x02" * 2000) # 2000 int16 samples
path = _pcm16_to_wav(pcm, 16000)
path = capture_ws._pcm16_to_wav(pcm, 16000)
assert path is not None
try:
with wave.open(path, "rb") as wf:
@@ -60,6 +62,15 @@ def test_pcm16_to_wav_roundtrip():
os.unlink(path)
def test_pcm16_to_wav_rejects_tiny_buffer():
assert _pcm16_to_wav(b"\x00\x01", 16000) is None
assert _pcm16_to_wav(b"", 16000) is None
def test_pcm16_to_wav_rejects_tiny_buffer(capture_ws):
assert capture_ws._pcm16_to_wav(b"\x00\x01", 16000) is None
assert capture_ws._pcm16_to_wav(b"", 16000) is None
def test_plain_pcm_transport_negotiates_a_bounded_sample_rate(capture_ws):
requested = capture_ws._requested_pcm_sample_rate
assert requested({}) is None
assert requested({"pcm": "1", "sr": "48000"}) == 48000
assert requested({"pcm": "true", "sr": "invalid"}) == 16000
assert requested({"pcm": "on", "sr": "1000000"}) == 16000
assert requested({"aec": "1", "sr": "8000"}) == 8000
+6 -3
View File
@@ -62,7 +62,7 @@ def dub(tmp_path, monkeypatch):
from api.routers import dub_core as dc
importlib.reload(dc)
calls = {"get_model": 0}
calls = {"get_model": 0, "order": []}
async def _counting_get_model():
calls["get_model"] += 1
@@ -73,10 +73,12 @@ def dub(tmp_path, monkeypatch):
monkeypatch.setattr(dc, "get_model", _counting_get_model)
monkeypatch.setattr(dc, "get_diarization_pipeline", lambda *a, **k: None)
monkeypatch.setattr(dc, "offload_tts_for_asr", lambda *a, **k: None)
monkeypatch.setattr(dc, "offload_tts_for_asr", lambda *a, **k: calls["order"].append("offload"))
monkeypatch.setattr(dc, "restore_tts_after_asr", lambda *a, **k: None)
monkeypatch.setattr("services.asr_backend.asr_model_missing_error", lambda: None)
monkeypatch.setattr(
"services.asr_backend.get_active_asr_backend", lambda *a, **k: _FakeASR()
"services.asr_backend.load_active_asr_backend",
lambda *a, **k: calls["order"].append("load-asr") or _FakeASR(),
)
job_id = f"test_{uuid.uuid4().hex[:8]}"
@@ -110,6 +112,7 @@ def test_transcribe_does_not_load_the_tts_model(dub):
dc, job_id, calls = dub
body = _drain(dc, job_id)
assert calls["get_model"] == 0, "dub loaded the TTS core it was about to free"
assert calls["order"].index("offload") < calls["order"].index("load-asr")
# And the stream still worked — we didn't just break the preflight.
assert "error" not in body or "segment" in body or "done" in body
+51
View File
@@ -297,6 +297,57 @@ def test_transcribe_chunk_failure_uses_stable_public_metadata(
assert "/home/alice/private-audio.wav" not in caplog.text
def test_transcribe_chunk_oom_has_one_actionable_public_message(
tmp_path, monkeypatch
):
"""CUDA OOM must not collapse into a duplicated no-segments error."""
import asyncio
import torch
from api.routers import dub_core as dc
job_id = "t_chunk_oom"
audio = tmp_path / "a.wav"
_make_wav(audio, seconds=1.0)
dc._dub_jobs[job_id] = {
"audio_path": str(audio), "vocals_path": None, "scene_cuts": [],
}
class _OOMASR:
id = "pytorch-whisper"
def ensure_loaded(self):
pass
def transcribe(self, path, *, word_timestamps=True):
raise torch.OutOfMemoryError("secret diagnostic")
def unload(self):
pass
monkeypatch.setattr(dc, "_CHUNK_TRANSCRIBE_ATTEMPTS", 1)
monkeypatch.setattr(dc, "offload_tts_for_asr", lambda: None)
monkeypatch.setattr(
"services.asr_backend.get_active_asr_backend", lambda **_kw: _OOMASR()
)
async def _collect():
response = await dc.dub_transcribe_stream(job_id)
parts = []
async for chunk in response.body_iterator:
parts.append(chunk.decode() if isinstance(chunk, bytes) else str(chunk))
return "".join(parts)
try:
body = asyncio.run(_collect())
finally:
dc._dub_jobs.pop(job_id, None)
assert body.count('"code": "transcription_memory"') == 1
assert "Transcription ran out of GPU memory." in body
assert "Transcription produced no segments" not in body
assert "secret diagnostic" not in body
def test_transcribe_stream_surfaces_asr_load_failure_at_preflight(tmp_path, monkeypatch):
"""Regression #578: the reported failure mode is the *ASR model* failing to
load (WhisperX: faster-whisper weights / CTranslate2-cuDNN mismatch / the
+26 -4
View File
@@ -99,10 +99,10 @@ _ENGINE_AGNOSTIC_KEYS = (
# Never raise one: if this fails after adding en.json keys, add the keys to
# every locale (translated) in the same change instead.
_MISSING_BASELINE = {
"ar": 517, "de": 517, "es": 517, "fr": 517, "hi": 517, "id": 517,
"it": 517, "ja": 517, "ko": 517, "nl": 517, "pl": 517, "pt": 517,
"ru": 517, "sv": 517, "th": 517, "tr": 517, "uk": 517, "vi": 517,
"zh-CN": 510, "zh-TW": 517,
"ar": 504, "de": 504, "es": 504, "fr": 504, "hi": 504, "id": 504,
"it": 504, "ja": 504, "ko": 504, "nl": 504, "pl": 504, "pt": 504,
"ru": 504, "sv": 504, "th": 504, "tr": 504, "uk": 504, "vi": 504,
"zh-CN": 497, "zh-TW": 504,
}
@@ -139,6 +139,21 @@ def _flatten(d, prefix=""):
_LOCALES = [f[:-5] for f in _locale_files()]
_OTHERS = [loc for loc in _LOCALES if loc != _EN]
_OPENAPI_KEYS = {
"settings.openapi",
"openapi.title",
"openapi.loading",
"openapi.unreachable_title",
"openapi.unreachable_body",
"openapi.retry",
"openapi.copy_url",
"openapi.copy_url_aria",
"openapi.copied",
"openapi.copy_failed",
"openapi.open_raw",
"openapi.open_raw_aria",
}
def test_locale_inventory_matches_baseline():
"""Every locale is ratcheted; a new locale must be added to the baseline
@@ -151,6 +166,13 @@ def test_locale_inventory_matches_baseline():
)
@pytest.mark.parametrize("locale", _OTHERS)
def test_openapi_ui_is_translated_in_every_locale(locale):
translated = _flatten(_load(locale))
missing = sorted(_OPENAPI_KEYS - translated.keys())
assert not missing, f"{locale}.json is missing VoiceStudio API strings: {missing}"
@pytest.mark.parametrize("locale", _LOCALES)
def test_locale_parses_without_duplicate_keys(locale):
data = _load(locale) # raises on invalid JSON or duplicate keys
+17
View File
@@ -0,0 +1,17 @@
import asyncio
def test_duplicate_model_install_reuses_the_running_worker():
from api.routers.setup import download
repo_id = download.KNOWN_MODELS[0]["repo_id"]
download._install_cooldowns.pop(repo_id, None)
with download._active_installs_lock:
download._active_installs.add(repo_id)
try:
result = asyncio.run(download.install_model(download.InstallModelRequest(repo_id=repo_id)))
finally:
with download._active_installs_lock:
download._active_installs.discard(repo_id)
assert result == {"status": "already_running", "repo_id": repo_id}
+32
View File
@@ -64,6 +64,38 @@ def test_lazy_builds_standalone_pipeline(monkeypatch):
assert be._pipe is not None
assert captured["task"] == "automatic-speech-recognition"
assert captured["kw"]["model"] # a concrete model name was chosen
assert captured["kw"]["device"] == "cpu"
assert "device_map" not in captured["kw"]
def test_ensure_loaded_eagerly_builds_pipeline(monkeypatch):
"""Dub preflight must load the fallback before processing every chunk."""
backend = ab.PyTorchWhisperBackend()
calls = []
monkeypatch.setattr(backend, "_ensure_pipe", lambda: calls.append("load"))
backend.ensure_loaded()
assert calls == ["load"]
def test_low_free_vram_routes_pytorch_whisper_to_cpu(monkeypatch):
monkeypatch.delenv("OMNIVOICE_ASR_VRAM_PREFLIGHT", raising=False)
import torch
monkeypatch.setattr("services.model_manager.get_best_device", lambda: "cuda:0")
monkeypatch.setattr(torch.cuda, "mem_get_info", lambda: (4 * 1024**3, 24 * 1024**3))
assert ab.PyTorchWhisperBackend._pick_device() == "cpu"
def test_sufficient_free_vram_keeps_pytorch_whisper_on_cuda(monkeypatch):
import torch
monkeypatch.setattr("services.model_manager.get_best_device", lambda: "cuda:0")
monkeypatch.setattr(torch.cuda, "mem_get_info", lambda: (6 * 1024**3, 24 * 1024**3))
assert ab.PyTorchWhisperBackend._pick_device() == "cuda:0"
def test_pytorch_asr_model_overridable_via_env(monkeypatch):
+7 -2
View File
@@ -1,9 +1,10 @@
"""Launch-window contract (owner decision, 2026-07-02): the app must ALWAYS
open maximized never fullscreen on every platform.
Two halves enforce it, and both must hold:
Three parts enforce it, and all must hold:
1. tauri.conf.json declares `maximized: true` + `fullscreen: false`.
2. lib.rs denylists BOTH "widget" and "main" in tauri-plugin-window-state
2. The native frame stays disabled so the app header is the only title bar.
3. lib.rs denylists BOTH "widget" and "main" in tauri-plugin-window-state
otherwise restored geometry silently overrides the config, and one manual
resize makes every later launch reopen at that smaller size.
"""
@@ -31,6 +32,10 @@ def test_main_window_opens_maximized_not_fullscreen():
assert win.get("fullscreen") is False
def test_main_window_uses_the_custom_titlebar():
assert _main_window().get("decorations") is False
def test_startup_enforces_maximize_in_rust():
"""The conf flag alone isn't reliable: macOS can ignore `maximized: true`
at window creation with the Overlay title-bar style. lib.rs must enforce