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:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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": (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"maximized": true,
|
||||
|
||||
+32
-23
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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": "قاموس النطق",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’API…",
|
||||
"unreachable_title": "Spécification du backend indisponible",
|
||||
"unreachable_body": "Impossible d’atteindre la spécification OpenAPI du backend local à l’adresse {{url}}. Vérifiez que le backend fonctionne, puis réessayez.",
|
||||
"retry": "Réessayer",
|
||||
"copy_url": "Copier l’URL de la spécification",
|
||||
"copy_url_aria": "Copier l’URL /openapi.json",
|
||||
"copied": "URL de la spécification copiée",
|
||||
"copy_failed": "Échec de la copie — sélectionnez et copiez manuellement l’URL 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 d’entré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 d’entrée du microphone",
|
||||
"input_detected": "Entrée détectée",
|
||||
"no_input_detected": "Aucune entrée détectée"
|
||||
},
|
||||
"pronunciation": {
|
||||
"title": "Dictionnaire de prononciation",
|
||||
|
||||
@@ -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": "उच्चारण शब्दकोश",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 all’indirizzo {{url}}. Verifica che il backend sia in esecuzione, quindi riprova.",
|
||||
"retry": "Riprova",
|
||||
"copy_url": "Copia URL della specifica",
|
||||
"copy_url_aria": "Copia l’URL /openapi.json",
|
||||
"copied": "URL della specifica copiato",
|
||||
"copy_failed": "Copia non riuscita — seleziona e copia manualmente l’URL 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",
|
||||
|
||||
@@ -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": "発音辞典",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Словарь произношения",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "พจนานุกรมการออกเสียง",
|
||||
|
||||
@@ -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üğü",
|
||||
|
||||
@@ -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": "Словник вимови",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "发音词典",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"'));
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user