fix: cross-platform dictation delivery (#1610)
Makes dictation delivery, capture, recovery, model fallback, AEC, and localized status behavior reliable across macOS, Windows, and Linux.
This commit is contained in:
+11
-3
@@ -10,12 +10,14 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The backend now answers within a second of launch and narrates its startup step by step
|
||||
- Reporting a bug from an outdated build now offers the latest release first
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves
|
||||
- Dictation now stays bound to the app where it started and recovers locally from silent recognizer output (#1175)
|
||||
- The backend now answers within a second of launch and narrates its startup step by step (#1550)
|
||||
- Reporting a bug from an outdated build now offers the latest release first (#1547)
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves (#1548)
|
||||
- Invisible watermarking no longer stalls — or silently skips — the first take of a session (#1615)
|
||||
|
||||
### Changed
|
||||
- Dictation now carries one native output session from shortcut-down through final delivery, restores text, HTML, image, or file-list clipboards only when untouched, keeps Wayland copy-safe unless current-focus insertion is explicitly enabled, and retries silent Sherpa speech only through an already-installed local ASR model (#1175)
|
||||
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
|
||||
|
||||
### Added
|
||||
@@ -37,6 +39,12 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- The OmniVoice guide now covers combining style attributes with a reference clip (consistent instruct stabilizes cloning; the reference wins conflicts), inline pronunciation control (pinyin / CMU phonemes), and corrects the claim that the default engine can't do voice design — it can, from attributes (#1565)
|
||||
|
||||
### Fixed
|
||||
- Dictation on a WebView that refuses a 16 kHz audio context (WKWebView) now low-passes before downsampling, so frequencies above 8 kHz stop folding into the speech the recognizer is fed (#1610)
|
||||
- A microphone context that cannot be resumed now reports a mic error instead of leaving the dictation pill on "Listening" while capturing nothing (#1610)
|
||||
- Dictation no longer retains a whole session's audio for silent-model recovery — an open mic grew that buffer by ~115 MB an hour; the recent two minutes are kept instead (#1610)
|
||||
- The clipboard-delivery status is now translated in all 21 languages, so Wayland users — where clipboard delivery is the default — no longer see an English string (#1610)
|
||||
- A native sherpa-onnx load failure of any exception type now degrades to "engine unavailable" instead of taking the dictation WebSocket down (#1610)
|
||||
- Dictation now ships Whisper Tiny as its one cross-platform default, avoiding Parakeet's measured empty decoding on Windows while keeping Parakeet selectable behind runtime fallback (#1175)
|
||||
- Re-mixing a dub no longer decodes, rewrites, and re-reads every cached segment — same-rate cached audio is reused directly (and rejected if truncated), switching timing modes can't reuse slot-truncated audio as natural-rate, and RVC respects natural-rate modes (#1594)
|
||||
- PocketTTS French works again — pocket-tts only ships a 24-layer French model and rejected the name the sidecar asked for, so every French request failed at model load; French now always loads `french_24l` (#1613) — thanks @paoloantinori!
|
||||
- Installing IndexTTS 2.5 no longer fails claiming an interrupted download — the weights repo ships `config.yaml` and VoiceStudio demanded a `config_v2_5.yaml` that exists in no upstream release; both names are accepted, so a hand-renamed checkout keeps working (#1611) — thanks @zuiaiyutu!
|
||||
|
||||
@@ -103,7 +103,7 @@ Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md
|
||||
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions |
|
||||
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video |
|
||||
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
|
||||
| **Dictation Widget** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **Vocal Isolation** | Demucs speech/background separation |
|
||||
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment |
|
||||
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress |
|
||||
|
||||
@@ -27,6 +27,10 @@ Protocol:
|
||||
"detail": "..."} — error ("detail"
|
||||
kept for legacy)
|
||||
|
||||
Sherpa ``final`` frames additionally carry
|
||||
``"final_kind": "utterance"|"summary"``. Utterances are mid-session
|
||||
commits; the summary is the authoritative whole-session result at EOF.
|
||||
|
||||
Every ``final`` text is normalised by services.text_polish (leading
|
||||
capital for Latin scripts, terminal punctuation, single-spaced) so the
|
||||
pasted result reads like typed text. Partials are raw.
|
||||
@@ -35,6 +39,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
@@ -70,17 +75,46 @@ _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
|
||||
# Client-supplied ``?sr=`` values outside the range real capture devices use
|
||||
# are replaced with 16 kHz. The rate sizes server-side state — RecoveryTail
|
||||
# multiplies it by RECOVERY_TAIL_SECONDS to compute its byte ceiling — so an
|
||||
# absurd rate must never be believed: it would re-open the unbounded-memory
|
||||
# path the recovery-tail cap closed.
|
||||
SR_MIN, SR_MAX = 8000, 96000
|
||||
|
||||
|
||||
def _bounded_sample_rate(query_params) -> int:
|
||||
try:
|
||||
sample_rate = int(query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
return 16000
|
||||
return sample_rate if 8000 <= sample_rate <= 96000 else 16000
|
||||
return sample_rate if SR_MIN <= sample_rate <= SR_MAX else 16000
|
||||
|
||||
|
||||
def _requested_pcm_sample_rate(query_params) -> int | None:
|
||||
"""Return the bounded rate when the client transport is raw PCM.
|
||||
|
||||
Sherpa clients omit ``pcm=1`` because the selected model already defines
|
||||
that transport. If the model is demoted or its runtime is unavailable, the
|
||||
legacy recognizer fallback must still decode those same bytes as PCM.
|
||||
"""
|
||||
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
|
||||
aec = query_params.get("aec") in ("1", "true", "on")
|
||||
sherpa_pcm = False
|
||||
requested_model = query_params.get("model")
|
||||
if requested_model:
|
||||
try:
|
||||
from services.sherpa_dictation import is_sherpa_model
|
||||
sherpa_pcm = is_sherpa_model(requested_model)
|
||||
except Exception: # noqa: BLE001
|
||||
# A broken sherpa install must not decide the framing question —
|
||||
# sherpa_pcm stays False and the session negotiates the
|
||||
# MediaRecorder path; availability is re-probed (and reported)
|
||||
# when the model is actually selected.
|
||||
sherpa_pcm = False
|
||||
if not raw_pcm and not aec and not sherpa_pcm:
|
||||
return None
|
||||
return _bounded_sample_rate(query_params)
|
||||
|
||||
|
||||
def _demux_aec_frame(data: bytes) -> tuple[str, bytes]:
|
||||
@@ -137,16 +171,27 @@ def _select_sherpa_spec(websocket: WebSocket):
|
||||
from services import sherpa_dictation as sd
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _usable_spec(model_id):
|
||||
spec = sd.get_spec(model_id)
|
||||
if spec is not None and sd.is_demoted(spec.id):
|
||||
logger.warning(
|
||||
"dictation model %s is demoted — using the capture ASR fallback",
|
||||
spec.id,
|
||||
)
|
||||
return None
|
||||
return spec
|
||||
|
||||
requested = websocket.query_params.get("model")
|
||||
if requested:
|
||||
return sd.get_spec(requested) # explicit selection (may be None if bad)
|
||||
return _usable_spec(requested) # explicit selection (may be unavailable)
|
||||
# Fall back to the persisted dictation pref.
|
||||
try:
|
||||
from services.asr_backend import dictation_model_id
|
||||
mid = dictation_model_id()
|
||||
except Exception:
|
||||
mid = None
|
||||
return sd.get_spec(mid) if mid else None
|
||||
return _usable_spec(mid) if mid else None
|
||||
|
||||
|
||||
@router.websocket("/ws/transcribe")
|
||||
@@ -422,6 +467,64 @@ SHERPA_OFFLINE_SILENCE_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_SILENC
|
||||
SHERPA_OFFLINE_RMS_FLOOR = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_RMS", "0.01"))
|
||||
|
||||
|
||||
#: Seconds of audio retained for silent-model recovery. Recovery only needs
|
||||
#: enough speech to prove the model is broken and to re-transcribe what was
|
||||
#: said; retaining the whole session grew ~115 MB/hour at 16 kHz on an open
|
||||
#: mic, unbounded, and only ever got read when the fallback fired.
|
||||
RECOVERY_TAIL_DEFAULT_SECONDS = 120.0
|
||||
RECOVERY_TAIL_MAX_SECONDS = 300.0
|
||||
|
||||
|
||||
def _bounded_recovery_tail_seconds(value: str | None) -> float:
|
||||
"""Parse the recovery tail override without allowing unbounded buffers."""
|
||||
try:
|
||||
seconds = float(value) if value is not None else RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
except (TypeError, ValueError):
|
||||
return RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
if not math.isfinite(seconds) or seconds <= 0:
|
||||
return RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
return min(seconds, RECOVERY_TAIL_MAX_SECONDS)
|
||||
|
||||
|
||||
RECOVERY_TAIL_SECONDS = _bounded_recovery_tail_seconds(
|
||||
os.environ.get("OMNIVOICE_DICTATION_RECOVERY_TAIL_S")
|
||||
)
|
||||
|
||||
|
||||
class RecoveryTail:
|
||||
"""The most recent ``RECOVERY_TAIL_SECONDS`` of session audio.
|
||||
|
||||
Keeps the *tail* rather than the head: a long dictation's useful speech is
|
||||
what the user just said, and the silent-model check cares about how much
|
||||
audio the session carried overall — which ``total_bytes`` still reports
|
||||
truthfully after trimming.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buf", "_max", "total_bytes")
|
||||
|
||||
def __init__(self, sample_rate: int, seconds: float = RECOVERY_TAIL_SECONDS):
|
||||
# int16 mono → 2 bytes/sample. Floor of one frame so a nonsense rate
|
||||
# or seconds value can't produce a zero-length buffer.
|
||||
self._max = max(2, int(seconds * max(1, sample_rate)) * 2)
|
||||
self._buf = bytearray()
|
||||
self.total_bytes = 0
|
||||
|
||||
def extend(self, pcm: bytes) -> None:
|
||||
self._buf.extend(pcm)
|
||||
self.total_bytes += len(pcm)
|
||||
excess = len(self._buf) - self._max
|
||||
if excess > 0:
|
||||
# int16 mono: trim whole samples only. A split frame can carry an
|
||||
# odd byte count, and an odd trim would leave the tail starting
|
||||
# mid-sample — every later sample byte-shifted, and the recovery
|
||||
# transcription fed noise.
|
||||
excess += excess % 2
|
||||
del self._buf[:excess]
|
||||
|
||||
def tail(self) -> bytes:
|
||||
return bytes(self._buf)
|
||||
|
||||
|
||||
def is_model_silent(text: str, heard_speech: bool, pcm_bytes: int) -> bool:
|
||||
"""True when the dictation model produced NO text despite real speech.
|
||||
|
||||
@@ -448,19 +551,74 @@ def _pcm16_to_f32(pcm: bytes):
|
||||
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
async def _sherpa_session(websocket: WebSocket):
|
||||
"""Shared WS receive setup for the sherpa handlers.
|
||||
def _pcm16_rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
Returns ``(get_frame, state)`` where ``get_frame`` is an async callable
|
||||
that yields the next near-end (mic) PCM bytes, ``b""`` for a keepalive/ref
|
||||
frame, or ``None`` on EOF/disconnect. ``state`` carries sample rate, AEC,
|
||||
and the disconnect flag for the caller's finaliser.
|
||||
"""
|
||||
pcm_sr = 16000
|
||||
|
||||
async def _recover_silent_sherpa(
|
||||
spec, pcm: bytes, pcm_sr: int,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Retry a token-silent Sherpa session through an installed local ASR."""
|
||||
logger.warning(
|
||||
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
|
||||
"— falling back to the capture ASR engine for this session",
|
||||
spec.id, len(pcm) / float(max(1, pcm_sr) * 2),
|
||||
)
|
||||
try:
|
||||
pcm_sr = int(websocket.query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
pcm_sr = 16000
|
||||
from services.asr_backend import asr_model_missing_error
|
||||
fallback_missing = await asyncio.to_thread(
|
||||
asr_model_missing_error,
|
||||
purpose="dictation",
|
||||
skip_sherpa=True,
|
||||
require_installed=True,
|
||||
)
|
||||
if fallback_missing is not None:
|
||||
logger.warning(
|
||||
"dictation silent-model fallback is not installed (%s); "
|
||||
"skipping recovery to avoid an automatic download",
|
||||
fallback_missing.get("missing_repo_id", "unknown"),
|
||||
)
|
||||
return "", []
|
||||
|
||||
result = await _transcribe_buffer_full(
|
||||
[pcm], pcm_sr=pcm_sr, skip_sherpa=True,
|
||||
)
|
||||
text = polish_text(_result_text(result))
|
||||
if not text:
|
||||
return "", []
|
||||
# The RMS gate can fire on fan/keyboard noise. Only another recognizer
|
||||
# producing words proves the audio held speech and makes persistent
|
||||
# demotion safe.
|
||||
try:
|
||||
from services.sherpa_dictation import demote_model
|
||||
if await asyncio.to_thread(demote_model, spec.id):
|
||||
logger.error(
|
||||
"dictation model %s demoted on this machine — it will no longer be "
|
||||
"auto-selected. Pick it again in Settings to give it another chance.",
|
||||
spec.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("silent-model demotion failed")
|
||||
segments = (result or {}).get("segments") or [
|
||||
{"start": 0.0, "end": None, "text": text}
|
||||
]
|
||||
return text, segments
|
||||
except Exception:
|
||||
logger.exception("dictation silent-model fallback failed")
|
||||
return "", []
|
||||
|
||||
|
||||
async def _sherpa_session(websocket: WebSocket):
|
||||
"""Shared WS setup for the sherpa handlers.
|
||||
|
||||
Returns ``(pcm_sr, aec)``: the bounded PCM sample rate for the session
|
||||
and the echo canceller when ``?aec=1`` requested one (``None`` otherwise
|
||||
or when AEC setup fails).
|
||||
"""
|
||||
pcm_sr = _bounded_sample_rate(websocket.query_params)
|
||||
aec = None
|
||||
if websocket.query_params.get("aec") in ("1", "true", "on"):
|
||||
try:
|
||||
@@ -569,6 +727,8 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
|
||||
last_partial = ""
|
||||
committed: list[str] = [] # finalized utterances this session
|
||||
session_pcm = RecoveryTail(pcm_sr) # bounded audio for silent-model recovery
|
||||
heard_speech = False
|
||||
client_disconnected = False
|
||||
|
||||
async def _send(payload) -> bool:
|
||||
@@ -610,6 +770,9 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
break
|
||||
if kind == "skip":
|
||||
continue
|
||||
session_pcm.extend(pcm)
|
||||
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
heard_speech = True
|
||||
text, endpoint = await asyncio.to_thread(_decode_after_feed, pcm)
|
||||
if endpoint:
|
||||
# Commit this utterance (polished — it gets pasted); reset
|
||||
@@ -618,6 +781,7 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"final_kind": "utterance",
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
rec.reset(stream)
|
||||
@@ -644,7 +808,28 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
# Pieces are already polished; the join is too (polish is idempotent).
|
||||
full = " ".join(t for t in committed if t).strip()
|
||||
segments = [{"start": 0.0, "end": None, "text": t} for t in committed if t]
|
||||
|
||||
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
|
||||
if model_silent:
|
||||
recovered, recovered_segments = await _recover_silent_sherpa(
|
||||
spec, session_pcm.tail(), pcm_sr,
|
||||
)
|
||||
if recovered:
|
||||
full = recovered
|
||||
segments = recovered_segments
|
||||
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "final_kind": "summary",
|
||||
"segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if model_silent:
|
||||
payload["engine"] = "capture-asr-fallback" if full else backend.id
|
||||
payload["model_silent"] = spec.id
|
||||
payload["warning"] = (
|
||||
f"The selected dictation model ({spec.id}) produced no text from your "
|
||||
"speech. Switched to the fallback engine for this session — pick a "
|
||||
"different model in Settings → Dictation."
|
||||
)
|
||||
if full:
|
||||
# Hard-bounded refinement (~4s): never delays this summary `final`
|
||||
# beyond OMNIVOICE_REFINE_TIMEOUT_S even with a dead LLM endpoint.
|
||||
@@ -653,14 +838,9 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
refined = await maybe_refine_async(full)
|
||||
except Exception:
|
||||
refined = None
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if refined and refined != full:
|
||||
payload["refined_text"] = refined
|
||||
await _send(payload)
|
||||
else:
|
||||
await _send({"type": "final", "text": "", "segments": [],
|
||||
"language": "auto", "engine": backend.id})
|
||||
await _send(payload)
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
@@ -697,7 +877,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
# whisper/zipformer transcribe the same bytes). Keep the whole session's
|
||||
# audio and whether any of it was speech-level, so the finaliser can tell
|
||||
# "user said nothing" (fine) from "model produced nothing" (broken).
|
||||
session_pcm = bytearray()
|
||||
session_pcm = RecoveryTail(pcm_sr)
|
||||
heard_speech = False
|
||||
running = True
|
||||
client_disconnected = False
|
||||
@@ -716,12 +896,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
client_disconnected = True
|
||||
return False
|
||||
|
||||
def _rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
def _decode_window(pcm: bytes) -> str:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
@@ -740,7 +914,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
continue
|
||||
buf.extend(pcm)
|
||||
session_pcm.extend(pcm)
|
||||
if not heard_speech and _rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
heard_speech = True
|
||||
last_audio = time.monotonic()
|
||||
except WebSocketDisconnect:
|
||||
@@ -766,6 +940,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"final_kind": "utterance",
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
|
||||
@@ -777,8 +952,8 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
continue
|
||||
snapshot = bytes(buf)
|
||||
if len(snapshot) > sil_bytes and \
|
||||
_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
_pcm16_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _pcm16_rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
await _commit(snapshot)
|
||||
else:
|
||||
# Pure silence — drop it (keep the gate window for
|
||||
@@ -824,39 +999,18 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
# quiet user — hand the session to the capture ASR backend so the user
|
||||
# still gets their words, and say which model let them down. Bounded to
|
||||
# this session; the pref is left alone so the user stays in control.
|
||||
model_silent = is_model_silent(full, heard_speech, len(session_pcm))
|
||||
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
|
||||
if model_silent:
|
||||
logger.warning(
|
||||
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
|
||||
"— falling back to the capture ASR engine for this session",
|
||||
spec.id, len(session_pcm) / float(max(1, pcm_sr) * 2),
|
||||
recovered, recovered_segments = await _recover_silent_sherpa(
|
||||
spec, session_pcm.tail(), pcm_sr,
|
||||
)
|
||||
# Demote it so the NEXT session doesn't repeat this round trip. The
|
||||
# curated default can be broken on a platform we never tested (the
|
||||
# NeMo-TDT decoder is, on Windows), and observing it beats guessing.
|
||||
try:
|
||||
from services.sherpa_dictation import demote_model
|
||||
if demote_model(spec.id):
|
||||
logger.error(
|
||||
"dictation model %s demoted on this machine — it will no longer be "
|
||||
"auto-selected. Pick it again in Settings to give it another chance.",
|
||||
spec.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("silent-model demotion failed")
|
||||
try:
|
||||
result = await _transcribe_buffer_full([bytes(session_pcm)], pcm_sr=pcm_sr)
|
||||
fb_text = polish_text((result or {}).get("text", "") or "")
|
||||
if fb_text:
|
||||
full = fb_text
|
||||
segments = (result or {}).get("segments") or [
|
||||
{"start": 0.0, "end": None, "text": fb_text}
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("dictation silent-model fallback failed")
|
||||
if recovered:
|
||||
full = recovered
|
||||
segments = recovered_segments
|
||||
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
payload = {"type": "final", "text": full, "final_kind": "summary",
|
||||
"segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if model_silent:
|
||||
# The client surfaces this so a silently-broken model can't look
|
||||
@@ -884,6 +1038,35 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
pass
|
||||
|
||||
|
||||
def _result_text(result: dict | None) -> str:
|
||||
"""Normalize text from every ASR backend result shape.
|
||||
|
||||
Some backends return a top-level ``text`` value, while WhisperX, Faster
|
||||
Whisper, Moonshine, and OpenAI-compatible ASR expose only ``segments`` and
|
||||
``chunks``. Dictation partials and finals must interpret both contracts the
|
||||
same way.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return ""
|
||||
|
||||
text = result.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text.strip()
|
||||
|
||||
for key in ("segments", "chunks"):
|
||||
items = result.get(key)
|
||||
if not isinstance(items, (list, tuple)):
|
||||
continue
|
||||
text = " ".join(
|
||||
str(item.get("text", "")).strip()
|
||||
for item in items
|
||||
if isinstance(item, dict) and item.get("text")
|
||||
).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None) -> str:
|
||||
"""Quick partial transcription of the current audio buffer."""
|
||||
|
||||
@@ -898,7 +1081,7 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
|
||||
def _run():
|
||||
backend = get_capture_asr_backend()
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
return result.get("text", "")
|
||||
return _result_text(result)
|
||||
|
||||
# Bound dictation transcribes (#730): a wedged whisperx/CTranslate2 call
|
||||
# must not hold its GPU-pool worker forever and starve TTS / other ASR
|
||||
@@ -912,7 +1095,9 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
|
||||
pass
|
||||
|
||||
|
||||
async def _transcribe_buffer_full(chunks: list[bytes], *, pcm_sr: int | None = None) -> dict:
|
||||
async def _transcribe_buffer_full(
|
||||
chunks: list[bytes], *, pcm_sr: int | None = None, skip_sherpa: bool = False,
|
||||
) -> dict:
|
||||
"""Full transcription with timing info for the final result."""
|
||||
tmp = _pcm16_to_wav(b"".join(chunks), pcm_sr) if pcm_sr else _chunks_to_wav(chunks)
|
||||
if tmp is None:
|
||||
@@ -924,15 +1109,13 @@ async def _transcribe_buffer_full(chunks: list[bytes], *, pcm_sr: int | None = N
|
||||
from services.asr_backend import get_capture_asr_backend, run_transcribe_guarded
|
||||
|
||||
def _run():
|
||||
backend = get_capture_asr_backend()
|
||||
backend = get_capture_asr_backend(skip_sherpa=skip_sherpa)
|
||||
t0 = time.perf_counter()
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
elapsed = round(time.perf_counter() - t0, 2)
|
||||
|
||||
segments = result.get("segments", [])
|
||||
full_text = result.get("text", "")
|
||||
if not full_text and segments:
|
||||
full_text = " ".join(s.get("text", "") for s in segments).strip()
|
||||
full_text = _result_text(result)
|
||||
|
||||
# Wave 1.1: strip Whisper hallucination loops from the final
|
||||
# text (the string that gets auto-pasted). Segments keep the
|
||||
|
||||
+10
-10
@@ -159,17 +159,16 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
|
||||
label: "Parakeet TDT v3 (sherpa-onnx — dictation, 25 EU langs)"
|
||||
role: ASR
|
||||
size_gb: 0.18
|
||||
size_gb: 0.67
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-parakeet-tdt-v3
|
||||
tag: offline
|
||||
curated_on: [all]
|
||||
note: "Recommended live-dictation default. CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
note: "Multilingual European-language dictation. CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
|
||||
- repo_id: "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"
|
||||
label: "Parakeet TDT v2 (sherpa-onnx — dictation, English)"
|
||||
role: ASR
|
||||
size_gb: 0.17
|
||||
size_gb: 0.66
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-parakeet-tdt-v2
|
||||
tag: offline
|
||||
@@ -178,7 +177,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
|
||||
label: "Zipformer Bilingual (sherpa-onnx — streaming, zh+en)"
|
||||
role: ASR
|
||||
size_gb: 0.13
|
||||
size_gb: 0.2
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-bilingual-zh-en
|
||||
tag: streaming
|
||||
@@ -187,7 +186,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en"
|
||||
label: "Paraformer Bilingual (sherpa-onnx — streaming, zh+en)"
|
||||
role: ASR
|
||||
size_gb: 0.115
|
||||
size_gb: 0.24
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-paraformer-bilingual-zh-en
|
||||
tag: streaming
|
||||
@@ -196,7 +195,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17"
|
||||
label: "Zipformer Streaming EN 20M (sherpa-onnx — streaming, English)"
|
||||
role: ASR
|
||||
size_gb: 0.128
|
||||
size_gb: 0.044
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-en-20m
|
||||
tag: streaming
|
||||
@@ -205,7 +204,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23"
|
||||
label: "Zipformer Streaming ZH 14M (sherpa-onnx — streaming, Chinese)"
|
||||
role: ASR
|
||||
size_gb: 0.074
|
||||
size_gb: 0.025
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-zh-14m
|
||||
tag: streaming
|
||||
@@ -214,11 +213,12 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-whisper-tiny"
|
||||
label: "Whisper Tiny (sherpa-onnx — dictation, 90+ langs)"
|
||||
role: ASR
|
||||
size_gb: 0.116
|
||||
size_gb: 0.104
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-whisper-tiny
|
||||
tag: offline
|
||||
note: "Multilingual offline dictation (auto-detect). CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
curated_on: [all]
|
||||
note: "Recommended cross-platform dictation default (auto-detect). CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
|
||||
# ── Diarisation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2995,7 +2995,7 @@ def _capture_prefers_parakeet() -> bool:
|
||||
return _parakeet_mlx_installed()
|
||||
|
||||
|
||||
def get_capture_asr_backend() -> ASRBackend:
|
||||
def get_capture_asr_backend(*, skip_sherpa: bool = False) -> ASRBackend:
|
||||
"""Pick the fastest ASR engine for capture / dictation.
|
||||
|
||||
Selection order:
|
||||
@@ -3020,6 +3020,9 @@ def get_capture_asr_backend() -> ASRBackend:
|
||||
|
||||
Returns a cached singleton so the model stays warm between calls; the
|
||||
singleton is rebuilt if the selected sherpa model changes.
|
||||
|
||||
``skip_sherpa`` is used only to validate a token-silent Sherpa result with
|
||||
the installed capture fallback before persisting model demotion.
|
||||
"""
|
||||
global _capture_backend, _capture_backend_key
|
||||
|
||||
@@ -3028,7 +3031,7 @@ def get_capture_asr_backend() -> ASRBackend:
|
||||
# call get_sherpa_dictation_backend concurrently) can't both build a model.
|
||||
with _capture_backend_lock:
|
||||
# 0. Honor an explicit sherpa dictation model selection.
|
||||
sherpa_id = dictation_model_id()
|
||||
sherpa_id = None if skip_sherpa else dictation_model_id()
|
||||
if sherpa_id:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
@@ -3195,7 +3198,10 @@ def _capture_whisper_repo() -> str | None:
|
||||
return os.environ.get("OMNIVOICE_PYTORCH_ASR_MODEL", _PYTORCH_ASR_DEFAULT)
|
||||
|
||||
|
||||
def _recommended_asr_model(purpose: str, missing_repo: str | None) -> dict | None:
|
||||
def _recommended_asr_model(
|
||||
purpose: str, missing_repo: str | None, *, prefer_sherpa: bool = True,
|
||||
excluded_sherpa_model_id: str | None = None,
|
||||
) -> dict | None:
|
||||
"""The catalog entry to offer in the download CTA.
|
||||
|
||||
Offline: the missing repo itself when it's in the catalog (guarantees
|
||||
@@ -3215,20 +3221,38 @@ def _recommended_asr_model(purpose: str, missing_repo: str | None) -> dict | Non
|
||||
|
||||
by_id = {m["repo_id"]: m for m in KNOWN_MODELS}
|
||||
exact = by_id.get(missing_repo) if missing_repo else None
|
||||
want_sherpa = False
|
||||
if purpose == "dictation":
|
||||
if exact is not None and exact.get("engine") == "sherpa-onnx":
|
||||
|
||||
def _eligible(m: dict, *, sherpa: bool) -> bool:
|
||||
if (m.get("engine") == "sherpa-onnx") != sherpa:
|
||||
return False
|
||||
if sherpa and m.get("dictation_id") == excluded_sherpa_model_id:
|
||||
return False
|
||||
return _model_supported(m)
|
||||
|
||||
if purpose != "dictation":
|
||||
if exact is not None and _model_supported(exact):
|
||||
return _shape(exact)
|
||||
prefer_sherpa = False
|
||||
|
||||
if purpose == "dictation" and prefer_sherpa:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
want_sherpa = ok
|
||||
if not want_sherpa and exact is not None and _model_supported(exact):
|
||||
if ok:
|
||||
if exact is not None and _eligible(exact, sherpa=True):
|
||||
return _shape(exact)
|
||||
for m in KNOWN_MODELS:
|
||||
if (m.get("role") == "ASR" and _eligible(m, sherpa=True)
|
||||
and _model_curated(m)):
|
||||
return _shape(m)
|
||||
|
||||
# No usable Sherpa recommendation remains (runtime unavailable, explicit
|
||||
# fallback probe, or the sole curated entry is the demoted model). Offer
|
||||
# the exact capture fallback so download → retry cannot loop.
|
||||
if exact is not None and _eligible(exact, sherpa=False):
|
||||
return _shape(exact)
|
||||
for m in KNOWN_MODELS:
|
||||
if m.get("role") != "ASR":
|
||||
continue
|
||||
if (m.get("engine") == "sherpa-onnx") != want_sherpa:
|
||||
continue
|
||||
if _model_curated(m) and _model_supported(m):
|
||||
if _eligible(m, sherpa=False) and _model_curated(m):
|
||||
return _shape(m)
|
||||
return None
|
||||
|
||||
@@ -3259,7 +3283,9 @@ def _repo_installed(repo: str) -> bool:
|
||||
|
||||
def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
sherpa_model_id: str | None = None,
|
||||
backend_id: str | None = None) -> dict | None:
|
||||
backend_id: str | None = None,
|
||||
skip_sherpa: bool = False,
|
||||
require_installed: bool = False) -> dict | None:
|
||||
"""None when the active ASR selection can transcribe without downloading
|
||||
anything; otherwise the typed ``{"error": "asr_model_missing", ...}``
|
||||
payload for a 409 / SSE / WS error with a download CTA.
|
||||
@@ -3271,6 +3297,11 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
``?model=`` override. Installed state comes from the same HF-cache helpers
|
||||
the model store uses (see :func:`_repo_installed`), so the answer matches
|
||||
the Model Catalogue → Models install badges.
|
||||
``skip_sherpa`` probes only the non-Sherpa capture fallback; silent-model
|
||||
recovery uses it before deciding whether persistent demotion is warranted.
|
||||
``require_installed`` makes unknown/custom selections fail closed for that
|
||||
recovery path so it can never turn the normal fail-open policy into an
|
||||
implicit model download.
|
||||
|
||||
FAIL-OPEN rule: a repo the model catalog doesn't know (a custom
|
||||
``ASR_MODEL_*`` pin, pytorch-whisper's default repo, an unrecognized
|
||||
@@ -3280,27 +3311,55 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
a broken preflight must degrade to the old behaviour, not block ASR.
|
||||
"""
|
||||
try:
|
||||
prefer_sherpa_recommendation = not skip_sherpa
|
||||
excluded_sherpa_model_id = None
|
||||
if purpose == "dictation":
|
||||
sid = sherpa_model_id or dictation_model_id()
|
||||
sid = None if skip_sherpa else (sherpa_model_id or dictation_model_id())
|
||||
if sid:
|
||||
ok, _ = SherpaDictationBackend.is_available()
|
||||
if ok:
|
||||
from services import sherpa_dictation as _sd
|
||||
spec = _sd.get_spec(sid)
|
||||
# A recognizer observed returning silence must follow the
|
||||
# same capture fallback as execution, even when the
|
||||
# frontend keeps sending its persisted `?model=` value.
|
||||
if spec is not None:
|
||||
if _sd.is_installed(spec):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": spec.repo_id,
|
||||
"recommended": _recommended_asr_model(purpose, spec.repo_id),
|
||||
}
|
||||
if _sd.is_demoted(spec.id):
|
||||
excluded_sherpa_model_id = spec.id
|
||||
else:
|
||||
if _sd.is_installed(spec):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": spec.repo_id,
|
||||
"recommended": _recommended_asr_model(
|
||||
purpose, spec.repo_id,
|
||||
),
|
||||
}
|
||||
repo = _capture_whisper_repo()
|
||||
else:
|
||||
repo = _offline_asr_repo(backend_id)
|
||||
if repo is None:
|
||||
if require_installed:
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": "unresolved-capture-fallback",
|
||||
"recommended": None,
|
||||
}
|
||||
return None # explicit opt-in engine — can't (and shouldn't) preflight
|
||||
from api.routers.setup.models import get_model_catalog
|
||||
if require_installed:
|
||||
if _repo_installed(repo):
|
||||
return None
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": repo,
|
||||
"recommended": _recommended_asr_model(
|
||||
purpose, repo,
|
||||
prefer_sherpa=prefer_sherpa_recommendation,
|
||||
excluded_sherpa_model_id=excluded_sherpa_model_id,
|
||||
),
|
||||
}
|
||||
if get_model_catalog().get(repo) is None:
|
||||
return None # not installable from the CTA — fail open (see docstring)
|
||||
if _repo_installed(repo):
|
||||
@@ -3308,7 +3367,11 @@ def asr_model_missing_error(*, purpose: str = "transcribe",
|
||||
return {
|
||||
"error": ASR_MODEL_MISSING,
|
||||
"missing_repo_id": repo,
|
||||
"recommended": _recommended_asr_model(purpose, repo),
|
||||
"recommended": _recommended_asr_model(
|
||||
purpose, repo,
|
||||
prefer_sherpa=prefer_sherpa_recommendation,
|
||||
excluded_sherpa_model_id=excluded_sherpa_model_id,
|
||||
),
|
||||
}
|
||||
except Exception: # noqa: BLE001 — preflight is best-effort, never a blocker
|
||||
logger.warning("ASR install preflight failed — proceeding without it",
|
||||
|
||||
@@ -110,7 +110,7 @@ class SherpaModelSpec:
|
||||
# the same HF tree API on 2026-08-07 — not estimated. Every one of the seven
|
||||
# was wrong before, and in both directions, which is worse than uniformly
|
||||
# optimistic: the two Parakeets under-reported by ~3.8x (0.18 -> 0.67 GB),
|
||||
# so the recommended default quietly downloaded four times what the picker
|
||||
# so installing v3 quietly downloaded four times what the picker
|
||||
# promised on a metered or small-disk machine; but the two low-RAM
|
||||
# zipformers OVER-reported by ~3x (0.128 -> 0.044), making the fallback
|
||||
# models look bulkier than the heavyweights they exist to rescue users
|
||||
@@ -129,7 +129,6 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
kind="offline-transducer",
|
||||
size_gb=0.67,
|
||||
languages="25 European languages",
|
||||
recommended=True,
|
||||
heavy=True,
|
||||
model_type="nemo_transducer",
|
||||
files={
|
||||
@@ -223,6 +222,7 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
kind="offline-whisper",
|
||||
size_gb=0.104,
|
||||
languages="90+ languages (auto-detect)",
|
||||
recommended=True,
|
||||
files={
|
||||
"encoder": "tiny-encoder.int8.onnx",
|
||||
"decoder": "tiny-decoder.int8.onnx",
|
||||
@@ -231,7 +231,7 @@ _MODELS: dict[str, SherpaModelSpec] = {
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_MODEL_ID = "sherpa-parakeet-tdt-v3"
|
||||
DEFAULT_MODEL_ID = "sherpa-whisper-tiny"
|
||||
|
||||
# repo_id → model id, so the model-store list (keyed by repo_id) can be
|
||||
# enriched with the dictation metadata, and so capture can map either key.
|
||||
@@ -261,6 +261,16 @@ def sherpa_available() -> tuple[bool, str]:
|
||||
return True, "ready"
|
||||
except ImportError as e:
|
||||
return False, f"sherpa-onnx not installed: {e}. Install with: uv add sherpa-onnx"
|
||||
except Exception as e: # noqa: BLE001 — an availability probe must fail closed
|
||||
# Native wheel failures surface as OSError/RuntimeError rather than
|
||||
# ImportError (missing DLL/dylib/so, loader or runtime init failure) —
|
||||
# but the set is open-ended: an extension module is free to raise
|
||||
# anything at init. This is an availability question, so ANY failure to
|
||||
# import means "not available", never an exception escaping to the
|
||||
# caller. SherpaDictationBackend.is_available() calls this directly and
|
||||
# capture_ws.ws_transcribe calls that without a guard, so an unexpected
|
||||
# type here took the WebSocket down instead of falling back (#1610).
|
||||
return False, f"sherpa-onnx unavailable ({type(e).__name__}): {e}"
|
||||
|
||||
|
||||
def _resolve_model_dir(spec: SherpaModelSpec, *, download: bool = True) -> str:
|
||||
@@ -397,13 +407,10 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
|
||||
# transcribe the same bytes. It is a defect inside sherpa-onnx that the app
|
||||
# cannot fix by configuration.
|
||||
#
|
||||
# The curated default therefore cannot be trusted to WORK just because it is
|
||||
# installed — and which platforms are affected is not knowable up front, so
|
||||
# hard-coding a different default per OS would only be a guess. Instead the app
|
||||
# learns from what it observes: when a session hears real speech and the model
|
||||
# returns nothing, that model is demoted on THIS machine and stops being
|
||||
# selected. Self-correcting wherever the breakage actually is, and a no-op
|
||||
# everywhere it isn't.
|
||||
# Installation alone therefore cannot prove that a recognizer works. When a
|
||||
# session hears real speech and the model returns nothing, that model is
|
||||
# demoted on this machine and stops being selected. This self-corrects wherever
|
||||
# the decoder defect appears and is a no-op everywhere it does not.
|
||||
|
||||
#: prefs key holding the list of model ids demoted on this machine.
|
||||
PREF_SILENT_MODELS = "dictation.silent_models"
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"""A dictation model that decodes nothing gets demoted, not re-selected forever.
|
||||
|
||||
`sherpa-parakeet-tdt-v3` is the curated default, and on Windows it installs
|
||||
cleanly, loads without error, and returns an empty token list for clear speech
|
||||
On Windows, `sherpa-parakeet-tdt-v3` installs cleanly, loads without error,
|
||||
and returns an empty token list for clear speech
|
||||
(both quantisations, both decoding methods, sherpa-onnx 1.13.3 and 1.13.4)
|
||||
while whisper and zipformer transcribe the same bytes. The defect is inside
|
||||
sherpa-onnx's NeMo-TDT decoder — unfixable from here by configuration.
|
||||
|
||||
Hard-coding a different default per OS would be a guess: we have evidence for
|
||||
one platform only. So the app observes instead. When a session hears real
|
||||
speech and the model returns nothing, that model is demoted ON THIS MACHINE and
|
||||
stops being auto-selected, which self-corrects wherever the breakage actually
|
||||
is and is a no-op everywhere it isn't.
|
||||
Whisper Tiny is now the cross-platform default, while Parakeet remains
|
||||
selectable. Runtime demotion still protects users who select a recognizer that
|
||||
loads successfully but decodes nothing: it is demoted on this machine and the
|
||||
next session follows the capture fallback.
|
||||
|
||||
These tests pin the demotion round trip and, critically, that the user can
|
||||
always take back control by re-picking the model.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""A dictation model that decodes NOTHING must fall back, not fail silently.
|
||||
|
||||
Found on Windows with the curated default `sherpa-parakeet-tdt-v3`: the model
|
||||
Found on Windows with `sherpa-parakeet-tdt-v3`: the model
|
||||
downloads, loads with zero errors, and is correctly detected as a TDT model
|
||||
(`num_durations: 5`) — then returns an empty token list for clear speech.
|
||||
Measured against the same 18.9s WAV, on the same machine, same sherpa-onnx:
|
||||
|
||||
sherpa-whisper-tiny -> "Alright, here we are. I hope that's all..."
|
||||
sherpa-zipformer-en-20m -> "ANTS BOTH IN WHAT DISGUISED THIS THAT..."
|
||||
parakeet-tdt-v3 (int8) -> '' <-- the curated default
|
||||
parakeet-tdt-v3 (int8) -> ''
|
||||
parakeet-tdt-v3 (fp32) -> ''
|
||||
parakeet-tdt-v2 (int8) -> ''
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ instead — same model family, no NeMo dependency:
|
||||
|
||||
- **Apple Silicon:** [parakeet-mlx](parakeet-mlx.md) (installed by default on
|
||||
mac-ARM source installs).
|
||||
- **Any platform, CPU:** [sherpa-onnx-asr](sherpa-onnx-asr.md) — its default
|
||||
dictation model is an int8 ONNX export of Parakeet TDT v3.
|
||||
- **Any platform, CPU:** [sherpa-onnx-asr](sherpa-onnx-asr.md) — selectable
|
||||
int8 ONNX exports of Parakeet TDT v2/v3; Whisper Tiny remains the
|
||||
cross-platform dictation default.
|
||||
|
||||
## Selecting it
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ partials either way.
|
||||
|
||||
- Ensure `sherpa-onnx` is installed (`uv add sherpa-onnx` on source installs).
|
||||
- Pick a dictation model in the app (Model Catalogue → Models lists the
|
||||
curated set below), or **Model Catalogue → Engines**, ASR tab → **Use**, or
|
||||
selectable set below), or **Model Catalogue → Engines**, ASR tab → **Use**, or
|
||||
pin `OMNIVOICE_ASR_BACKEND=sherpa-onnx-asr`.
|
||||
- `OMNIVOICE_SHERPA_ASR_MODEL` selects the model — default
|
||||
`sherpa-parakeet-tdt-v3`.
|
||||
`sherpa-whisper-tiny`.
|
||||
|
||||
## Best at
|
||||
|
||||
@@ -25,17 +25,17 @@ partials either way.
|
||||
timestamps, which makes it a dictation/notes tool rather than a dubbing
|
||||
engine.
|
||||
|
||||
## The 7 curated models
|
||||
## The 7 selectable models
|
||||
|
||||
| Id | Type | Languages | Download |
|
||||
| --- | --- | --- | --- |
|
||||
| `sherpa-parakeet-tdt-v3` (default) | offline | 25 European languages | 0.67 GB |
|
||||
| `sherpa-parakeet-tdt-v3` | offline | 25 European languages | 0.67 GB |
|
||||
| `sherpa-parakeet-tdt-v2` | offline | English | 0.66 GB |
|
||||
| `sherpa-zipformer-bilingual-zh-en` | streaming | Chinese + English | 0.20 GB |
|
||||
| `sherpa-paraformer-bilingual-zh-en` | streaming | Chinese + English | 0.24 GB |
|
||||
| `sherpa-zipformer-en-20m` | streaming | English | 0.044 GB |
|
||||
| `sherpa-zipformer-zh-14m` | streaming | Chinese | 0.025 GB |
|
||||
| `sherpa-whisper-tiny` | offline | 90+ languages (auto-detect) | 0.104 GB |
|
||||
| `sherpa-whisper-tiny` (default, recommended) | offline | 90+ languages (auto-detect) | 0.104 GB |
|
||||
|
||||
Sizes are measured on-disk download sizes. Weights are int8 ONNX checkpoints
|
||||
that download on first use through the same HF cache as everything else —
|
||||
@@ -45,7 +45,10 @@ allocator holds onto freed blocks).
|
||||
|
||||
## Platform support
|
||||
|
||||
CPU on every platform, by the strict cross-platform default-parity rule.
|
||||
CPU on every platform, by the strict cross-platform default-parity rule. The
|
||||
[upstream CPU wheels](https://k2-fsa.github.io/sherpa/onnx/python/install.html)
|
||||
cover Linux, macOS, and Windows, and upstream documents Whisper as a
|
||||
[supported non-streaming model family](https://k2-fsa.github.io/sherpa/onnx/pretrained_models/whisper/index.html).
|
||||
`OMNIVOICE_SHERPA_ASR_PROVIDER` can override the ONNX provider on a verified
|
||||
GPU build, but the default never diverges.
|
||||
|
||||
|
||||
@@ -95,4 +95,5 @@ docs:
|
||||
- docs/install/linux.md
|
||||
- docs/install/docker.md
|
||||
- docs/install/troubleshooting.md
|
||||
- docs/features/dictation.md
|
||||
- docs/migration/real-time-voice-cloning.md
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Dictation
|
||||
|
||||
VoiceStudio dictation records from the system-wide shortcut, transcribes
|
||||
locally, and—where the desktop permits it—inserts the result into the app where
|
||||
the shortcut was pressed. The pill never needs keyboard focus.
|
||||
|
||||
## Use it
|
||||
|
||||
1. Choose an installed dictation model in the Model Catalogue.
|
||||
2. Set the shortcut and hold/toggle behavior in **Settings → Hotkey**.
|
||||
3. Put the cursor in a text field, press the shortcut, speak, then release or
|
||||
press again.
|
||||
|
||||
Whisper Tiny is the recommended default on macOS, Windows, and Linux. It
|
||||
auto-detects more than 90 languages. Parakeet TDT v3 remains available for its
|
||||
25 supported European languages, but it is not selected automatically.
|
||||
|
||||
The pill reports **Inserted** only after native delivery succeeds. **Copied**
|
||||
means automatic insertion was unavailable and the complete final transcript is
|
||||
ready for a normal paste. VoiceStudio retries a speech-level empty Sherpa decode
|
||||
only through another ASR model whose weights are already installed; when that
|
||||
fallback confirms the audio contains words, the silent model is demoted. This
|
||||
recovery never starts a download.
|
||||
|
||||
## Destination and clipboard safety
|
||||
|
||||
The desktop captures the target at shortcut-down and carries its session ID
|
||||
through partial, utterance, and summary messages. A late result from an older
|
||||
session cannot use a newer session's target. macOS, Windows, and X11 validate
|
||||
and reactivate the captured process/window before insertion. Wayland does not
|
||||
expose a portable target identity or arbitrary foreign-window activation, so
|
||||
the safe default leaves the transcript copied instead of guessing which app
|
||||
should receive it. The GTK pill remains non-focusable.
|
||||
|
||||
For paste delivery, VoiceStudio snapshots text, HTML (with its plain-text
|
||||
alternative), image, or file-list clipboard content, stages the transcript,
|
||||
and restores the snapshot after the target consumes it.
|
||||
Streaming segments share a generation-tracked lease: a stale restore cannot
|
||||
win over a newer segment, and VoiceStudio never overwrites clipboard content
|
||||
you copied during transcription. Unsupported clipboard formats cannot be
|
||||
round-tripped; in that case the transcript remains on the clipboard instead of
|
||||
attempting a lossy restore.
|
||||
|
||||
## Platform behavior
|
||||
|
||||
| Platform | Automatic insertion |
|
||||
| --- | --- |
|
||||
| macOS | Reactivates the captured application and sends Command-V. Without Accessibility permission, the result stays copied. |
|
||||
| Windows | Validates the captured window and process, requests foreground activation, then sends Ctrl-V. If Windows denies activation, the result stays copied. |
|
||||
| Linux X11 | Reactivates the captured X11 window through EWMH, verifies it, then sends Ctrl-V. |
|
||||
| Wayland | Leaves the complete transcript copied because a portable captured-window identity is unavailable. |
|
||||
| Browser mode | Copies the transcript; browsers cannot target another desktop app. |
|
||||
|
||||
Advanced Wayland users can set `VOICESTUDIO_WAYLAND_UNTARGETED_INSERT=1` to
|
||||
insert into whichever client owns keyboard focus when transcription finishes.
|
||||
wlroots compositors use `wtype`; KDE Plasma and GNOME can use clipboard paste
|
||||
through `dotool` or `ydotool`. Helpers run with host loader variables from an
|
||||
AppImage, have a bounded timeout, and never retry after one may have emitted
|
||||
partial input. This opt-in cannot promise the shortcut-down target if focus
|
||||
changes. `dotool` needs direct write access to `/dev/uinput`; `ydotool` 1.0+
|
||||
needs a running `ydotoold` with that access and a user-readable socket.
|
||||
VoiceStudio checks these prerequisites before selection. Tray-started Wayland
|
||||
dictation always stays copy-only.
|
||||
@@ -100,6 +100,22 @@ where the protocol gives applications no say in their own placement and the
|
||||
compositor decides where it appears. The capsule works the same either way; only
|
||||
its position is out of the app's hands there.
|
||||
|
||||
Wayland does not expose a portable identity for the app focused at shortcut
|
||||
down, so VoiceStudio safely leaves the complete transcript on the clipboard and
|
||||
the pill says **Copied** instead of risking insertion into a different app.
|
||||
|
||||
Advanced users can opt into current-focus insertion with
|
||||
`VOICESTUDIO_WAYLAND_UNTARGETED_INSERT=1`. wlroots compositors such as Sway and
|
||||
Hyprland use `wtype`; KDE Plasma and GNOME can use `dotool` or `ydotool` to
|
||||
paste the Unicode clipboard payload. The
|
||||
opt-in targets whichever client owns keyboard focus when transcription
|
||||
finishes, not necessarily the app where dictation started. Tray-started
|
||||
dictation remains copy-only. `dotool` needs direct write access to
|
||||
`/dev/uinput` (normally through a distribution udev rule/group). `ydotool`
|
||||
1.0+ needs the `ydotoold` daemon running with that access and its socket
|
||||
available to the desktop user. VoiceStudio skips either helper when its
|
||||
readiness check fails.
|
||||
|
||||
If the global shortcut stops working, restart your desktop's portal service,
|
||||
then save the shortcut again in **Settings → Hotkey** to reopen consent. Portal
|
||||
packages and support vary by desktop; use the backend recommended by your
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Dictation Flow Program — local WhisperFlow-class dictation on Parakeet
|
||||
# Dictation Flow Program — local cross-platform flow dictation
|
||||
|
||||
*Spec, 2026-07-16. Research inputs: three-agent study — product landscape (Wispr Flow, jamiepine/voicebox, Handy, VoiceInk, Whispering, Talon, Claude Code `/voice`), in-repo capability map, and Parakeet TDT/Nemotron feasibility (sherpa-onnx). Sources cited inline where load-bearing.*
|
||||
*Spec, 2026-07-16. Research inputs: a multi-agent product-landscape study, in-repo capability map, and local ASR feasibility review. Sources are cited inline where load-bearing.*
|
||||
|
||||
## Why
|
||||
|
||||
Dictating prompts to AI agents is the fastest-growing text-input workload (Claude Code shipped built-in `/voice`; Wispr Flow raised at ~$2B on it) — and every polished option is **cloud** (Wispr: cloud-only, no Linux, one privacy scandal already; Claude Code voice: cloud-only, no SSH). The best open competitor, **jamiepine/voicebox** (41.7k★, MIT — our refinement layer is already adapted from it), only ships reliable auto-paste on macOS. VoiceStudio already has the hard parts: a Wispr-style pill, global hotkey, sherpa-onnx streaming WS, **Parakeet TDT v3 int8 as the shipped default**, clipboard-restoring paste, and local-LLM refinement. A local, cross-platform, private flow-dictation experience is reachable and strategically differentiating — the wedge is **local + Linux/Wayland + agent-prompting**, where nobody credible plays.
|
||||
Dictating prompts to AI agents is a rapidly growing text-input workload, while polished options remain cloud-first and Linux support is uneven. VoiceStudio already has the hard parts: a compact capture pill, global hotkey, sherpa-onnx streaming WS, **Whisper Tiny int8 as the shipped cross-platform default**, clipboard-restoring paste, and local-LLM refinement. A local, cross-platform, private flow-dictation experience is reachable and strategically differentiating — the wedge is **local + Linux/Wayland + agent-prompting**.
|
||||
|
||||
## Current state (verified in-repo)
|
||||
|
||||
Widget: pill webview + `tauri-plugin-global-shortcut` (`CmdOrCtrl+Shift+Space`, toggle/hold) on macOS, Windows and X11 + the GlobalShortcuts desktop portal on Wayland + browser-mode keyboard fallback; `getUserMedia` → raw-PCM WS `/ws/transcribe`; paste via arboard+enigo with clipboard restore, macOS a11y fail-loud, Windows no-activate. Backend: 7 sherpa models (Parakeet TDT v3 default), streaming path (zipformer/paraformer) + chunked-offline path (0.8 s partial cadence, **RMS silence gate**), `text_polish` on finals, opt-in LLM refinement (Ollama/LM Studio, ≤4 s wall clock). Gaps: no real VAD, no dictionary/hotwords, no per-app awareness, no command grammar, no language picker, enigo-only Linux insertion, no comprehensive dictation feature guide beyond the Linux installation note, picker understates model size ~4×.
|
||||
Widget: pill webview + `tauri-plugin-global-shortcut` (`CmdOrCtrl+Shift+Space`, toggle/hold) on macOS, Windows and X11 + the GlobalShortcuts desktop portal on Wayland + browser-mode keyboard fallback; `getUserMedia` → 16 kHz raw-PCM WS `/ws/transcribe`; a native session captures the destination before the pill appears, restores an untouched clipboard by generation, reactivates macOS/Windows/X11 targets, and uses a truthful copy fallback on Wayland unless current-focus insertion is explicitly enabled. Backend: 7 sherpa models (Whisper Tiny default), streaming path (zipformer/paraformer) + chunked-offline path (0.8 s partial cadence, **RMS silence gate**), shared speech-evidence model demotion with installed-only ASR fallback, `text_polish` on finals, opt-in LLM refinement (Ollama/LM Studio, ≤4 s wall clock). Gaps: no real VAD, no dictionary/hotwords, no per-app formatting profiles, no command grammar, no language picker, picker understates model size ~4×.
|
||||
|
||||
## Program phases
|
||||
|
||||
@@ -41,7 +41,7 @@ Parakeet's one real weakness is OOV technical terms — and the dictionary is Wi
|
||||
|
||||
### Phase 4 — insertion reliability + Wayland (beat everyone on Linux)
|
||||
- **Reliability engineering** (the boring 20% that reads professional; Wispr does 5 retries): retry-with-backoff on paste, transcript stays on clipboard + toast on failure, password-field refusal, Windows elevated-window detection.
|
||||
- **Wayland insertion chain** replacing bare enigo on Linux: kwtype→wtype→dotool→ydotool→wl-copy+notify fallback (Handy's proven cascade), IBus/Fcitx5 input-method commit path evaluated for GNOME (highest quality, nobody mainstream ships it), libei/RemoteDesktop-portal as the forward bet. Wispr has no Linux at all; voicebox has no Linux paste — this is the moat.
|
||||
- **Wayland insertion chain** replacing bare enigo on Linux: wtype→dotool→ydotool→wl-copy+notify fallback, IBus/Fcitx5 input-method commit path evaluated for GNOME (highest quality, nobody mainstream ships it), libei/RemoteDesktop-portal as the forward bet. Reliable local Linux insertion is the moat.
|
||||
|
||||
### Phase 5 — command mode (headline, local-only differentiator)
|
||||
Second hotkey → speak an instruction over selected text → local LLM rewrite → explicit Apply. Wispr charges for this; ours is local and free. Requires configured LLM; hidden otherwise (existing `llm_ready` plumbing).
|
||||
@@ -56,10 +56,10 @@ Second hotkey → speak an instruction over selected text → local LLM rewrite
|
||||
| Use case | Model | Partials | Final after pause | Disk/RAM |
|
||||
|---|---|---|---|---|
|
||||
| English, best feel | nemotron-streaming-en 160 ms (new, Ph. 1) | 200–400 ms | ~0.5–0.7 s | 0.66 GB / ~1.2 GB |
|
||||
| Multilingual default | parakeet-tdt-v3 + silero-VAD (upgraded path) | 0.8 s cadence | ~0.4–0.7 s | 0.67 GB / ~1.2 GB |
|
||||
| European languages (opt-in) | parakeet-tdt-v3 + silero-VAD (upgraded path) | 0.8 s cadence | ~0.4–0.7 s | 0.67 GB / ~1.2 GB |
|
||||
| Multilingual streaming (opt-in) | Nemotron-3.5 320 ms (new) | ~400 ms | ~0.7 s | 0.68 GB / ~1.2 GB |
|
||||
| Low-RAM | zipformer-20M (existing) | ~100 ms | ~0.6 s | 0.13 GB / ~0.3 GB |
|
||||
| CJK / 90+ langs | whisper-tiny (existing; consider small) | n/a | seconds | 0.12 GB |
|
||||
| Multilingual default / CJK | whisper-tiny (existing; consider small) | n/a | seconds | 0.104 GB |
|
||||
|
||||
## Top risks
|
||||
|
||||
|
||||
Generated
+157
-1
@@ -101,6 +101,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -1040,6 +1041,12 @@ dependencies = [
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -1287,6 +1294,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -2582,6 +2595,15 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
@@ -2687,6 +2709,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -2948,8 +2971,11 @@ dependencies = [
|
||||
"enigo",
|
||||
"fs4",
|
||||
"getrandom 0.3.4",
|
||||
"gtk",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -2973,6 +2999,7 @@ dependencies = [
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
@@ -3017,6 +3044,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
@@ -3097,6 +3134,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -3181,7 +3229,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -3369,6 +3417,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -5285,6 +5342,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -5633,6 +5701,76 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.102"
|
||||
@@ -6468,6 +6606,24 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix",
|
||||
"thiserror 2.0.18",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
@@ -67,7 +67,13 @@ dirs-next = "2"
|
||||
# Native (OS-side) clipboard write for dictation auto-paste: the widget window
|
||||
# is unfocused on macOS so the simulated ⌘V reaches the target app, which makes
|
||||
# the WebView clipboard APIs fail silently there (#287)
|
||||
arboard = "3"
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Capture the frontmost application at shortcut-down and reactivate that exact
|
||||
# process before transcript delivery.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "libc", "NSRunningApplication", "NSWorkspace"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
@@ -84,13 +90,15 @@ windows-core = "0.61"
|
||||
# `HWND` type — no second copy of the crate enters the dependency graph.
|
||||
# Win32_System_Registry: check_microphone reads the CapabilityAccessManager
|
||||
# ConsentStore mic toggle (RegGetValueW) for the permissions UX.
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry"] }
|
||||
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0"
|
||||
gtk = "0.18"
|
||||
x11rb = "0.13"
|
||||
# Wayland compositors do not expose global keys through X11 grabs. Use the
|
||||
# standard xdg-desktop-portal GlobalShortcuts interface there; zbus is already
|
||||
# present transitively through Tauri's opener/single-instance plugins.
|
||||
|
||||
+185
-161
@@ -7,13 +7,13 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::image::Image;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
use crate::dictation_shortcut::{DictationShortcutManager, ShortcutInfo, update_tray_hint};
|
||||
use crate::config::{load_config, save_config};
|
||||
use crate::dictation_shortcut::{update_tray_hint, DictationShortcutManager, ShortcutInfo};
|
||||
use crate::{AppFlags, TrayHandle};
|
||||
use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING};
|
||||
use crate::config::{load_config, save_config};
|
||||
|
||||
// ── Native host-path authorization ───────────────────────────────────────
|
||||
|
||||
@@ -65,10 +65,7 @@ fn remember_reveal_path<R: tauri::Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reveal_path_is_authorized<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
target: &Path,
|
||||
) -> bool {
|
||||
fn reveal_path_is_authorized<R: tauri::Runtime>(app: &tauri::AppHandle<R>, target: &Path) -> bool {
|
||||
if let Ok(data_root) = fs::canonicalize(
|
||||
crate::setup::resolved_data_dir(app).unwrap_or_else(crate::setup::default_data_dir),
|
||||
) {
|
||||
@@ -89,12 +86,7 @@ fn reveal_path_is_authorized<R: tauri::Runtime>(
|
||||
fn validate_host_path(kind: &str, path: PathBuf) -> Result<PathBuf, String> {
|
||||
if !matches!(
|
||||
kind,
|
||||
"models_dir"
|
||||
| "ffmpeg"
|
||||
| "ffprobe"
|
||||
| "dub_export"
|
||||
| "soni_input"
|
||||
| "soni_output_dir"
|
||||
"models_dir" | "ffmpeg" | "ffprobe" | "dub_export" | "soni_input" | "soni_output_dir"
|
||||
) {
|
||||
return Err("Unsupported host-path capability".into());
|
||||
}
|
||||
@@ -216,8 +208,11 @@ pub async fn authorize_host_path(
|
||||
kind,
|
||||
path: validated.to_string_lossy().into_owned(),
|
||||
};
|
||||
fs::write(&target, serde_json::to_vec(&payload).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| format!("Could not authorize path: {e}"))?;
|
||||
fs::write(
|
||||
&target,
|
||||
serde_json::to_vec(&payload).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| format!("Could not authorize path: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -247,7 +242,10 @@ mod host_path_authorization_tests {
|
||||
|
||||
#[test]
|
||||
fn empty_models_path_is_the_authorized_default_reset() {
|
||||
assert_eq!(validate_host_path("models_dir", PathBuf::new()).unwrap(), PathBuf::new());
|
||||
assert_eq!(
|
||||
validate_host_path("models_dir", PathBuf::new()).unwrap(),
|
||||
PathBuf::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -259,11 +257,9 @@ mod host_path_authorization_tests {
|
||||
destination,
|
||||
);
|
||||
assert!(validate_host_path("dub_export", PathBuf::from("relative/export.wav")).is_err());
|
||||
assert!(validate_host_path(
|
||||
"dub_export",
|
||||
parent.join("missing-directory/export.wav"),
|
||||
)
|
||||
.is_err());
|
||||
assert!(
|
||||
validate_host_path("dub_export", parent.join("missing-directory/export.wav"),).is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,12 +312,14 @@ pub fn read_log_tail(source: String, tail: Option<usize>) -> LogTailPayload {
|
||||
let path = match source.as_str() {
|
||||
"backend" => backend_runtime_log_path(),
|
||||
"tauri" => tauri_log_path(),
|
||||
_ => return LogTailPayload {
|
||||
lines: vec![],
|
||||
path: String::new(),
|
||||
exists: false,
|
||||
total_lines: 0,
|
||||
},
|
||||
_ => {
|
||||
return LogTailPayload {
|
||||
lines: vec![],
|
||||
path: String::new(),
|
||||
exists: false,
|
||||
total_lines: 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
@@ -363,15 +361,11 @@ fn backend_runtime_log_path() -> PathBuf {
|
||||
let data_dir = if cfg!(target_os = "macos") {
|
||||
dirs_data_dir().join("OmniVoice")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
PathBuf::from(
|
||||
std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()),
|
||||
)
|
||||
.join("OmniVoice")
|
||||
PathBuf::from(std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()))
|
||||
.join("OmniVoice")
|
||||
} else {
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
.join(".omnivoice")
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
|
||||
.join(".omnivoice")
|
||||
};
|
||||
data_dir.join("omnivoice.log")
|
||||
}
|
||||
@@ -379,16 +373,12 @@ fn backend_runtime_log_path() -> PathBuf {
|
||||
fn dirs_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
.join("Library/Application Support")
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
|
||||
.join("Library/Application Support")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,7 +393,10 @@ fn tauri_log_path() -> PathBuf {
|
||||
.join("tauri.log")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| home.clone());
|
||||
PathBuf::from(appdata).join(bid).join("logs").join("tauri.log")
|
||||
PathBuf::from(appdata)
|
||||
.join(bid)
|
||||
.join("logs")
|
||||
.join("tauri.log")
|
||||
} else {
|
||||
PathBuf::from(&home)
|
||||
.join(".local/share")
|
||||
@@ -508,22 +501,16 @@ fn hf_hub_cache_dir() -> PathBuf {
|
||||
.join("hub")
|
||||
}
|
||||
|
||||
// ── Simulate paste ────────────────────────────────────────────────────────
|
||||
|
||||
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
|
||||
// ── Dictation output ─────────────────────────────────────────────────────
|
||||
|
||||
/// Error-kind builder the dictation widget switches on. Kinds are a plain
|
||||
/// string prefix ("a11y:" | "clipboard:" | "paste:") so the JS side can do
|
||||
/// `err.split(':')[0]` without a serde enum crossing the IPC boundary.
|
||||
/// string prefix ("a11y:" | "clipboard:" | "paste:" | "preflight:") so the
|
||||
/// JS side can do `err.split(':')[0]` without a serde enum crossing the IPC
|
||||
/// boundary.
|
||||
fn kind_err(kind: &str, detail: impl std::fmt::Display) -> String {
|
||||
format!("{kind}:{detail}")
|
||||
}
|
||||
|
||||
/// How long the transcript must sit on the clipboard before the user's
|
||||
/// previous clipboard is restored: ~300ms covers slow paste consumers
|
||||
/// (Electron apps, remote desktops) without being user-noticeable.
|
||||
const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
/// macOS Accessibility grant check — CGEvent key synthesis silently no-ops
|
||||
/// without it. Direct FFI against ApplicationServices: one symbol, not worth
|
||||
/// a crate.
|
||||
@@ -705,7 +692,12 @@ pub fn open_microphone_settings() -> Result<(), String> {
|
||||
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| kind_err("settings", format!("failed to open microphone settings: {e}")))
|
||||
.map_err(|e| {
|
||||
kind_err(
|
||||
"settings",
|
||||
format!("failed to open microphone settings: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -718,7 +710,12 @@ pub fn open_microphone_settings() -> Result<(), String> {
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| kind_err("settings", format!("failed to open microphone settings: {e}")))
|
||||
.map_err(|e| {
|
||||
kind_err(
|
||||
"settings",
|
||||
format!("failed to open microphone settings: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
@@ -744,7 +741,10 @@ pub fn open_input_monitoring_settings() -> Result<(), String> {
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| {
|
||||
kind_err("settings", format!("failed to open input monitoring settings: {e}"))
|
||||
kind_err(
|
||||
"settings",
|
||||
format!("failed to open input monitoring settings: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -757,71 +757,44 @@ pub fn open_input_monitoring_settings() -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
|
||||
// macOS: fail loud BEFORE touching the clipboard if Accessibility isn't
|
||||
// granted — otherwise the ⌘V below silently goes nowhere and the caller
|
||||
// can't tell (the old fire-and-forget behavior).
|
||||
pub async fn simulate_paste(
|
||||
text: String,
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<crate::dictation_output::DeliveryOutcome, String> {
|
||||
// macOS: a revoked/missing Accessibility grant prevents synthesis, but it
|
||||
// must not discard the result. Keep the full transcript copied and report
|
||||
// the fallback truthfully.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !accessibility_trusted() {
|
||||
return Err(kind_err("a11y", "accessibility permission not granted"));
|
||||
let output = flags.output.clone();
|
||||
return tauri::async_runtime::spawn_blocking(move || {
|
||||
output.copy_for_session(session_id, &text)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| kind_err("clipboard", format!("output worker failed: {error}")))?;
|
||||
}
|
||||
|
||||
// Write the transcript to the clipboard natively first: the widget window
|
||||
// is intentionally unfocused on macOS (so the simulated ⌘V reaches the
|
||||
// target app), which makes the WebView clipboard APIs (navigator.clipboard
|
||||
// / execCommand('copy')) fail silently there (#287). `text` is optional so
|
||||
// call sites that already populated the clipboard keep working.
|
||||
//
|
||||
// Save what the user had there first (text only — restoring images/files
|
||||
// isn't worth the platform-specific surface) so dictation doesn't clobber
|
||||
// their clipboard.
|
||||
let mut saved: Option<String> = None;
|
||||
if let Some(t) = text {
|
||||
let mut cb = arboard::Clipboard::new()
|
||||
.map_err(|e| kind_err("clipboard", format!("init failed: {e}")))?;
|
||||
saved = cb.get_text().ok();
|
||||
cb.set_text(t)
|
||||
.map_err(|e| kind_err("clipboard", format!("write failed: {e}")))?;
|
||||
}
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.deliver(session_id, &text))
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(80));
|
||||
|
||||
let mut enigo = Enigo::new(&EnigoSettings::default())
|
||||
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
enigo.key(Key::Meta, Direction::Press)
|
||||
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
|
||||
enigo.key(Key::Unicode('v'), Direction::Click)
|
||||
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
|
||||
enigo.key(Key::Meta, Direction::Release)
|
||||
.map_err(|e| kind_err("paste", format!("key release failed: {e}")))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
enigo.key(Key::Control, Direction::Press)
|
||||
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
|
||||
enigo.key(Key::Unicode('v'), Direction::Click)
|
||||
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
|
||||
enigo.key(Key::Control, Direction::Release)
|
||||
.map_err(|e| kind_err("paste", format!("key release failed: {e}")))?;
|
||||
}
|
||||
|
||||
// Best-effort restore of the user's clipboard once the target app has
|
||||
// consumed the paste. Only on success — on a paste error the transcript
|
||||
// stays on the clipboard so the user can ⌘V it manually as a fallback.
|
||||
if let Some(prev) = saved {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(CLIPBOARD_RESTORE_DELAY);
|
||||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
||||
let _ = cb.set_text(prev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
/// Preserve the authoritative transcript without emitting any keyboard input.
|
||||
/// Used after live typing may have left an unknown prefix in the target: a
|
||||
/// second insertion would duplicate text, but losing the complete result is
|
||||
/// not an acceptable fallback.
|
||||
#[tauri::command]
|
||||
pub async fn copy_dictation_output_session(
|
||||
text: String,
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<crate::dictation_output::DeliveryOutcome, String> {
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.copy_for_session(session_id, &text))
|
||||
.await
|
||||
.map_err(|error| kind_err("clipboard", format!("output worker failed: {error}")))?
|
||||
}
|
||||
|
||||
// ── Simulate live typing ──────────────────────────────────────────────────
|
||||
@@ -833,18 +806,22 @@ pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
|
||||
/// revised), then `text` is typed. Either may be empty/zero, so a single call
|
||||
/// can correct-then-type in one round trip.
|
||||
///
|
||||
/// Cross-platform: `enigo`'s `.text()` synthesizes Unicode key events on macOS
|
||||
/// (CGEvent), Windows (`SendInput` w/ `KEYEVENTF_UNICODE`), and Linux (X11/
|
||||
/// libei). Backspace is a plain virtual-key `Click`, identical on all three.
|
||||
/// On macOS this reuses the SAME accessibility permission `simulate_paste`
|
||||
/// already requires (both go through `enigo` → CGEvent); no new grant needed.
|
||||
/// The session-bound output layer reactivates the destination captured at
|
||||
/// shortcut-down before emitting input. On Wayland it selects one compatible
|
||||
/// compositor helper before emission and never retries after a possible
|
||||
/// partial write.
|
||||
///
|
||||
/// Returns `Err` if the input layer is unavailable (e.g. accessibility not
|
||||
/// granted) so the JS caller can fall back to the clipboard+paste path for
|
||||
/// that segment without double-inserting. Errors carry the same kind
|
||||
/// prefixes as `simulate_paste` ("a11y:" | "paste:").
|
||||
/// granted). Because a failed input call may already have emitted a prefix,
|
||||
/// the JS caller suppresses later insertion for that session. `preflight:`
|
||||
/// explicitly means nothing was emitted and a final paste remains safe.
|
||||
#[tauri::command]
|
||||
pub fn simulate_type(text: Option<String>, backspaces: Option<u32>) -> Result<(), String> {
|
||||
pub async fn simulate_type(
|
||||
text: String,
|
||||
backspaces: Option<u32>,
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<crate::dictation_output::DeliveryOutcome, String> {
|
||||
// Same a11y gate as simulate_paste — `.text()`/`.key()` go through the
|
||||
// identical CGEvent path on macOS and would silently no-op without it.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -852,24 +829,46 @@ pub fn simulate_type(text: Option<String>, backspaces: Option<u32>) -> Result<()
|
||||
return Err(kind_err("a11y", "accessibility permission not granted"));
|
||||
}
|
||||
|
||||
let mut enigo = Enigo::new(&EnigoSettings::default())
|
||||
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
output.type_delta(session_id, &text, backspaces.unwrap_or(0))
|
||||
})
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?
|
||||
}
|
||||
|
||||
let n = backspaces.unwrap_or(0);
|
||||
for _ in 0..n {
|
||||
enigo
|
||||
.key(Key::Backspace, Direction::Click)
|
||||
.map_err(|e| kind_err("paste", format!("backspace failed: {e}")))?;
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn activate_dictation_output_session(
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<(), String> {
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.activate_session(session_id))
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?
|
||||
}
|
||||
|
||||
if let Some(t) = text {
|
||||
if !t.is_empty() {
|
||||
enigo
|
||||
.text(&t)
|
||||
.map_err(|e| kind_err("paste", format!("type failed: {e}")))?;
|
||||
}
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn reject_dictation_output_session(
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<(), String> {
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.reject_session_candidate(session_id))
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn finish_dictation_output_session(
|
||||
session_id: u64,
|
||||
flags: tauri::State<'_, AppFlags>,
|
||||
) -> Result<(), String> {
|
||||
let output = flags.output.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || output.finish_session(session_id))
|
||||
.await
|
||||
.map_err(|error| kind_err("paste", format!("output worker failed: {error}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -889,11 +888,16 @@ pub fn set_tray_recording(
|
||||
// permanently-hidden widget made meaningless.)
|
||||
flags.dictating.store(recording, Ordering::SeqCst);
|
||||
log::info!("Dictation recording state: {recording}");
|
||||
let bytes = if recording { TRAY_ICON_RECORDING } else { TRAY_ICON_DEFAULT };
|
||||
let bytes = if recording {
|
||||
TRAY_ICON_RECORDING
|
||||
} else {
|
||||
TRAY_ICON_DEFAULT
|
||||
};
|
||||
let img = Image::from_bytes(bytes).map_err(|e| format!("decode tray icon: {e}"))?;
|
||||
let lock = tray_handle.tray.lock().map_err(|_| "tray lock poisoned")?;
|
||||
if let Some(ref tray) = *lock {
|
||||
tray.set_icon(Some(img)).map_err(|e| format!("set_icon: {e}"))?;
|
||||
tray.set_icon(Some(img))
|
||||
.map_err(|e| format!("set_icon: {e}"))?;
|
||||
}
|
||||
update_tray_hint(&app, &shortcuts.info().display, recording);
|
||||
Ok(())
|
||||
@@ -987,7 +991,10 @@ fn place_dictation_pill(app: &tauri::AppHandle, win: &tauri::WebviewWindow) {
|
||||
}
|
||||
log::info!(
|
||||
"pill: placed at {x},{y} ({}x{} on a {}x{} monitor)",
|
||||
size.width, size.height, area.width, area.height
|
||||
size.width,
|
||||
size.height,
|
||||
area.width,
|
||||
area.height
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1049,9 +1056,15 @@ pub fn mark_dictation_capture_ready(app: tauri::AppHandle) {
|
||||
return;
|
||||
};
|
||||
capture.ready = true;
|
||||
if let Some(action) = capture.pending.take() {
|
||||
drop(capture);
|
||||
crate::dispatch_dictation_capture(&app, &action);
|
||||
let pending = std::mem::take(&mut capture.pending);
|
||||
drop(capture);
|
||||
for event in pending {
|
||||
if let Err(error) = app.emit(event.name, event.payload) {
|
||||
log::warn!(
|
||||
"Queued dictation event {} could not emit: {error}",
|
||||
event.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1198,7 +1211,8 @@ pub fn reveal_host_path(app: tauri::AppHandle, path: String) -> Result<(), Strin
|
||||
let folder = if target.is_dir() {
|
||||
target.clone()
|
||||
} else {
|
||||
target.parent()
|
||||
target
|
||||
.parent()
|
||||
.ok_or_else(|| "That path has no containing folder".to_string())?
|
||||
.to_path_buf()
|
||||
};
|
||||
@@ -1268,7 +1282,10 @@ const CLEAR_WEBVIEW_RETRY_DELAY: Duration = Duration::from_millis(500);
|
||||
/// Windows — because step 2 runs before an `AppHandle` exists.
|
||||
fn webview_cache_paths() -> Option<(PathBuf, PathBuf)> {
|
||||
let base = dirs_next::data_local_dir()?.join(crate::config::BUNDLE_IDENTIFIER);
|
||||
Some((base.join(CLEAR_WEBVIEW_MARKER), base.join(WEBVIEW_CACHE_DIR)))
|
||||
Some((
|
||||
base.join(CLEAR_WEBVIEW_MARKER),
|
||||
base.join(WEBVIEW_CACHE_DIR),
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1281,8 +1298,11 @@ pub fn clear_webview_cache_and_relaunch(app: tauri::AppHandle) -> Result<(), Str
|
||||
if let Some(parent) = marker.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::write(&marker, b"requested by the splash recovery panel (issue #879)\n")
|
||||
.map_err(|e| format!("write {}: {e}", marker.display()))?;
|
||||
fs::write(
|
||||
&marker,
|
||||
b"requested by the splash recovery panel (issue #879)\n",
|
||||
)
|
||||
.map_err(|e| format!("write {}: {e}", marker.display()))?;
|
||||
log::warn!(
|
||||
"WebView cache repair requested (#879) — relaunching to clear {}",
|
||||
cache.display()
|
||||
@@ -1301,7 +1321,12 @@ pub fn clear_webview_cache_if_marked() {
|
||||
let Some((marker, cache)) = webview_cache_paths() else {
|
||||
return;
|
||||
};
|
||||
clear_webview_cache_at(&marker, &cache, CLEAR_WEBVIEW_ATTEMPTS, CLEAR_WEBVIEW_RETRY_DELAY);
|
||||
clear_webview_cache_at(
|
||||
&marker,
|
||||
&cache,
|
||||
CLEAR_WEBVIEW_ATTEMPTS,
|
||||
CLEAR_WEBVIEW_RETRY_DELAY,
|
||||
);
|
||||
}
|
||||
|
||||
/// Filesystem half of [`clear_webview_cache_if_marked`], parameterized over
|
||||
@@ -1403,7 +1428,10 @@ mod webview_cache_repair_tests {
|
||||
let cache = dir.path().join(super::WEBVIEW_CACHE_DIR);
|
||||
fs::write(&marker, b"test").unwrap();
|
||||
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
|
||||
assert!(!marker.exists(), "marker consumed even with nothing to clear");
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"marker consumed even with nothing to clear"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cache that can't be deleted (Windows: WebView2 file locks; simulated
|
||||
@@ -1419,7 +1447,10 @@ mod webview_cache_repair_tests {
|
||||
// Deny writes on the cache dir so its entries can't be unlinked.
|
||||
fs::set_permissions(&cache, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
clear_webview_cache_at(&marker, &cache, FEW, NO_WAIT);
|
||||
assert!(!marker.exists(), "one-shot: marker consumed even on failure");
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"one-shot: marker consumed even on failure"
|
||||
);
|
||||
assert!(cache.exists(), "a locked cache survives the failed repair");
|
||||
// Restore permissions so TempDir can clean up.
|
||||
fs::set_permissions(&cache, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
@@ -1428,7 +1459,7 @@ mod webview_cache_repair_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod paste_error_tests {
|
||||
use super::{kind_err, CLIPBOARD_RESTORE_DELAY};
|
||||
use super::kind_err;
|
||||
|
||||
#[test]
|
||||
fn kind_err_prefixes_with_kind() {
|
||||
@@ -1450,11 +1481,4 @@ mod paste_error_tests {
|
||||
let e = kind_err("clipboard", "init failed: os error 5");
|
||||
assert_eq!(e.split_once(':').map(|(k, _)| k), Some("clipboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_delay_is_about_300ms() {
|
||||
// Contract with the widget layer: previous clipboard comes back
|
||||
// ~300ms after the paste, long enough for slow paste consumers.
|
||||
assert_eq!(CLIPBOARD_RESTORE_DELAY.as_millis(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+118
-26
@@ -7,33 +7,36 @@
|
||||
//! backend – spawn backend process, port probing, log paths
|
||||
//! commands – Tauri IPC commands (sysinfo, logs, HF cache, paste, tray, dictation)
|
||||
|
||||
pub mod config;
|
||||
pub mod setup;
|
||||
pub mod bootstrap;
|
||||
pub mod tools;
|
||||
pub mod backend;
|
||||
pub mod blank_guard;
|
||||
pub mod bootstrap;
|
||||
pub mod commands;
|
||||
pub mod dictation_shortcut;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
pub mod dictation_output;
|
||||
pub mod dictation_shortcut;
|
||||
pub mod reset;
|
||||
pub mod setup;
|
||||
pub mod tools;
|
||||
pub mod uninstall;
|
||||
pub mod updater_channel;
|
||||
pub mod blank_guard;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod wayland_shortcut;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Child;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_positioner::{Position, WindowExt};
|
||||
|
||||
use crate::bootstrap::{BootstrapStage, BootstrapState, set_stage};
|
||||
use crate::bootstrap::{set_stage, BootstrapStage, BootstrapState};
|
||||
use crate::config::load_config;
|
||||
use crate::dictation_output::CaptureOrigin;
|
||||
use crate::dictation_shortcut::DictationShortcutManager;
|
||||
|
||||
// ── Port ──────────────────────────────────────────────────────────────────
|
||||
@@ -63,11 +66,32 @@ pub struct AppFlags {
|
||||
/// the tray icon), so that same call keeps this in step.
|
||||
pub dictating: AtomicBool,
|
||||
pub capture: Mutex<CaptureDispatchState>,
|
||||
pub output: dictation_output::DictationOutput,
|
||||
}
|
||||
|
||||
pub struct CaptureDispatchState {
|
||||
pub ready: bool,
|
||||
pub pending: Option<String>,
|
||||
pub(crate) ready: bool,
|
||||
pub(crate) pending: VecDeque<CaptureEvent>,
|
||||
}
|
||||
|
||||
impl Default for CaptureDispatchState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ready: false,
|
||||
pending: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DictationCapturePayload {
|
||||
pub(crate) session_id: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct CaptureEvent {
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) payload: DictationCapturePayload,
|
||||
}
|
||||
|
||||
pub struct TrayHandle {
|
||||
@@ -84,8 +108,24 @@ fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
|
||||
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
|
||||
}
|
||||
|
||||
fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin: CaptureOrigin) {
|
||||
let flags = app.state::<AppFlags>();
|
||||
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
|
||||
let session_id = if event == "tray-dictate" {
|
||||
flags.output.begin_session(origin)
|
||||
} else if let Some(session_id) = flags.output.current_session_id() {
|
||||
session_id
|
||||
} else {
|
||||
log::warn!("Dictation capture '{action}' ignored — no active output session");
|
||||
return;
|
||||
};
|
||||
let capture_event = CaptureEvent {
|
||||
name: event,
|
||||
payload: DictationCapturePayload { session_id },
|
||||
};
|
||||
let Ok(mut capture) = flags.capture.lock() else {
|
||||
log::warn!("Dictation capture state lock poisoned");
|
||||
return;
|
||||
@@ -94,7 +134,7 @@ pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
|
||||
// A press that reaches Rust but produces no recording is otherwise
|
||||
// indistinguishable from one the compositor never delivered, so say
|
||||
// which side of the handshake the press left on.
|
||||
if let Err(error) = app.emit(event, ()) {
|
||||
if let Err(error) = app.emit(event, capture_event.payload) {
|
||||
log::warn!("Dictation capture '{action}' could not emit {event}: {error}");
|
||||
} else {
|
||||
log::info!("Dictation capture '{action}' emitted as {event}");
|
||||
@@ -103,21 +143,35 @@ pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
|
||||
log::warn!(
|
||||
"Dictation capture '{action}' queued — the capture window has not registered yet"
|
||||
);
|
||||
capture.pending = Some(action.to_owned());
|
||||
capture.pending.push_back(capture_event);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dictation_capture_tests {
|
||||
use super::dictation_capture_event;
|
||||
use super::{
|
||||
dictation_capture_event, CaptureDispatchState, CaptureEvent, DictationCapturePayload,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn toggle_starts_when_idle_and_stops_when_recording() {
|
||||
assert_eq!(dictation_capture_event("toggle", false), "tray-dictate");
|
||||
assert_eq!(
|
||||
dictation_capture_event("toggle", true),
|
||||
"tray-dictate-stop"
|
||||
);
|
||||
assert_eq!(dictation_capture_event("toggle", true), "tray-dictate-stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_queue_preserves_press_then_release() {
|
||||
let mut state = CaptureDispatchState::default();
|
||||
state.pending.push_back(CaptureEvent {
|
||||
name: "tray-dictate",
|
||||
payload: DictationCapturePayload { session_id: 7 },
|
||||
});
|
||||
state.pending.push_back(CaptureEvent {
|
||||
name: "tray-dictate-stop",
|
||||
payload: DictationCapturePayload { session_id: 7 },
|
||||
});
|
||||
let names: Vec<_> = state.pending.into_iter().map(|event| event.name).collect();
|
||||
assert_eq!(names, ["tray-dictate", "tray-dictate-stop"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,12 +428,19 @@ mod pill_noactivate_tests {
|
||||
WS_EX_NOACTIVATE_BIT,
|
||||
"NOACTIVATE bit must be set"
|
||||
);
|
||||
assert_eq!(updated & topmost, topmost, "pre-existing style bits must survive");
|
||||
assert_eq!(
|
||||
updated & topmost,
|
||||
topmost,
|
||||
"pre-existing style bits must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_if_already_noactivate() {
|
||||
assert_eq!(with_noactivate_style(WS_EX_NOACTIVATE_BIT), WS_EX_NOACTIVATE_BIT);
|
||||
assert_eq!(
|
||||
with_noactivate_style(WS_EX_NOACTIVATE_BIT),
|
||||
WS_EX_NOACTIVATE_BIT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -408,7 +469,11 @@ pub fn run() {
|
||||
if pill_mode {
|
||||
log::info!(
|
||||
"Starting in pill (dictation-only) mode (source: {})",
|
||||
if cli_pill { "--pill flag" } else { "config.launch_as_widget" }
|
||||
if cli_pill {
|
||||
"--pill flag"
|
||||
} else {
|
||||
"config.launch_as_widget"
|
||||
}
|
||||
);
|
||||
// On macOS, hide the Dock icon in pill mode so only the tray shows.
|
||||
// This is handled after the app builds via set_activation_policy.
|
||||
@@ -476,7 +541,11 @@ pub fn run() {
|
||||
commands::read_log_tail,
|
||||
commands::hf_cache_scan,
|
||||
commands::simulate_paste,
|
||||
commands::copy_dictation_output_session,
|
||||
commands::simulate_type,
|
||||
commands::activate_dictation_output_session,
|
||||
commands::reject_dictation_output_session,
|
||||
commands::finish_dictation_output_session,
|
||||
commands::check_accessibility,
|
||||
commands::open_accessibility_settings,
|
||||
commands::check_microphone,
|
||||
@@ -562,6 +631,7 @@ pub fn run() {
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.skip_taskbar(true)
|
||||
.center()
|
||||
// Stamp the window's identity BEFORE any app script runs.
|
||||
@@ -586,15 +656,23 @@ pub fn run() {
|
||||
if let Ok(win) = &result {
|
||||
mark_pill_noactivate(win);
|
||||
}
|
||||
// Wayland cannot reactivate an arbitrary foreign client. The
|
||||
// GTK toplevel therefore must never accept focus when mapped.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(win) = &result {
|
||||
use gtk::prelude::GtkWindowExt;
|
||||
if let Ok(gtk_window) = win.gtk_window() {
|
||||
gtk_window.set_accept_focus(false);
|
||||
gtk_window.set_focus_on_map(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.manage(AppFlags {
|
||||
quitting: AtomicBool::new(false),
|
||||
dictating: AtomicBool::new(false),
|
||||
capture: Mutex::new(CaptureDispatchState {
|
||||
ready: false,
|
||||
pending: None,
|
||||
}),
|
||||
capture: Mutex::new(CaptureDispatchState::default()),
|
||||
output: dictation_output::DictationOutput::default(),
|
||||
});
|
||||
app.manage(TrayHandle {
|
||||
tray: Mutex::new(None),
|
||||
@@ -700,6 +778,20 @@ pub fn run() {
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&tray_menu)
|
||||
.tooltip(if pill_mode_tray { "VoiceStudio Dictation" } else { "VoiceStudio" })
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if matches!(
|
||||
event,
|
||||
tauri::tray::TrayIconEvent::Click {
|
||||
button_state: tauri::tray::MouseButtonState::Down,
|
||||
..
|
||||
}
|
||||
) {
|
||||
tray.app_handle()
|
||||
.state::<AppFlags>()
|
||||
.output
|
||||
.prime_tray_target();
|
||||
}
|
||||
})
|
||||
.on_menu_event(move |app, event| {
|
||||
match event.id().as_ref() {
|
||||
"show" => {
|
||||
@@ -760,9 +852,9 @@ pub fn run() {
|
||||
// current by the frontend's existing
|
||||
// `set_tray_recording` call on every start and stop.
|
||||
if app.state::<AppFlags>().dictating.load(Ordering::SeqCst) {
|
||||
dispatch_dictation_capture(app, "stop");
|
||||
dispatch_dictation_capture_from(app, "stop", CaptureOrigin::Tray);
|
||||
} else {
|
||||
dispatch_dictation_capture(app, "start");
|
||||
dispatch_dictation_capture_from(app, "start", CaptureOrigin::Tray);
|
||||
}
|
||||
}
|
||||
"settings" => {
|
||||
|
||||
@@ -217,7 +217,8 @@ impl TestApp {
|
||||
app.manage(AppFlags {
|
||||
quitting: AtomicBool::new(false),
|
||||
dictating: AtomicBool::new(false),
|
||||
capture: Mutex::new(CaptureDispatchState { ready: false, pending: None }),
|
||||
capture: Mutex::new(CaptureDispatchState::default()),
|
||||
output: app_lib::dictation_output::DictationOutput::default(),
|
||||
});
|
||||
let stage = Arc::new(Mutex::new(BootstrapStage::Checking));
|
||||
let logs: Arc<Mutex<Vec<LogPayload>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "جارٍ النسخ…",
|
||||
"a11y_setup": "اسمح بتسهيلات الاستخدام حتى يتمكّن الإملاء من الكتابة نيابةً عنك",
|
||||
"pasted": "تم لصقه",
|
||||
"inserted": "تم الإدخال",
|
||||
"copied": "تم النسخ إلى الحافظة",
|
||||
"no_speech": "لم يتم اكتشاف أي كلام",
|
||||
"mic_denied": "تم رفض الوصول إلى الميكروفون",
|
||||
"mic_denied_toast": "تم رفض الوصول إلى الميكروفون. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "حذف نموذج الكلام",
|
||||
"engine_unavailable": "محرك الإملاء المباشر غير متوفر في هذا التثبيت. يعود الإملاء إلى مسار النسخ القياسي.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "موصى به. إملاء سريع ودقيق عبر 25 لغة أوروبية.",
|
||||
"sherpa-parakeet-tdt-v3": "إملاء سريع ودقيق عبر 25 لغة أوروبية.",
|
||||
"sherpa-parakeet-tdt-v2": "إملاء سريع ودقيق باللغة الإنجليزية فقط.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "بث الصينية + الإنجليزية مع أجزاء حية.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "تدفق الصينية + الإنجليزية، نموذج مدمج.",
|
||||
"sherpa-zipformer-en-20m": "نموذج صغير للبث باللغة الإنجليزية – أقل زمن وصول.",
|
||||
"sherpa-zipformer-zh-14m": "النموذج الصيني المتدفق الصغير – أقل زمن وصول.",
|
||||
"sherpa-whisper-tiny": "متعدد اللغات (+90 لغة) مع الكشف التلقائي عن اللغة."
|
||||
"sherpa-whisper-tiny": "موصى به. إملاء متعدد اللغات لأكثر من 90 لغة مع اكتشاف اللغة تلقائيًا."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transkribieren…",
|
||||
"a11y_setup": "Bedienungshilfen erlauben, damit das Diktat für Sie tippen kann",
|
||||
"pasted": "Eingefügt",
|
||||
"inserted": "Eingegeben",
|
||||
"copied": "In die Zwischenablage kopiert",
|
||||
"no_speech": "Keine Sprache erkannt",
|
||||
"mic_denied": "Mikrofonzugriff verweigert",
|
||||
"mic_denied_toast": "Mikrofonzugriff verweigert. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Sprachmodell löschen",
|
||||
"engine_unavailable": "Die Live-Diktier-Engine ist bei dieser Installation nicht verfügbar. Beim Diktat wird auf den Standard-Transkriptionspfad zurückgegriffen.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Empfohlen. Schnelles und genaues Diktieren in 25 europäischen Sprachen.",
|
||||
"sherpa-parakeet-tdt-v3": "Schnelles und genaues Diktieren in 25 europäischen Sprachen.",
|
||||
"sherpa-parakeet-tdt-v2": "Schnelles, genaues Diktat nur auf Englisch.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming Chinesisch + Englisch mit Live-Teilabschnitten.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming Chinesisch + Englisch, kompaktes Modell.",
|
||||
"sherpa-zipformer-en-20m": "Winziges englisches Streaming-Modell – niedrigste Latenz.",
|
||||
"sherpa-zipformer-zh-14m": "Winziges chinesisches Streaming-Modell – niedrigste Latenz.",
|
||||
"sherpa-whisper-tiny": "Mehrsprachig (über 90 Sprachen) mit automatischer Spracherkennung."
|
||||
"sherpa-whisper-tiny": "Empfohlen. Mehrsprachiges Diktieren in über 90 Sprachen mit automatischer Spracherkennung."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -1038,6 +1038,7 @@
|
||||
"listening_label": "Listening…",
|
||||
"transcribing_label": "Transcribing…",
|
||||
"pasted": "Pasted",
|
||||
"inserted": "Inserted",
|
||||
"copied": "Copied to clipboard",
|
||||
"no_speech": "No speech detected",
|
||||
"model_downloading": "Downloading voice model…",
|
||||
@@ -1080,13 +1081,13 @@
|
||||
"delete_confirm_title": "Delete speech model",
|
||||
"engine_unavailable": "The live-dictation engine isn't available on this install. Dictation falls back to the standard transcription path.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Recommended. Fast, accurate dictation across 25 European languages.",
|
||||
"sherpa-parakeet-tdt-v3": "Fast, accurate dictation across 25 European languages.",
|
||||
"sherpa-parakeet-tdt-v2": "Fast, accurate English-only dictation.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming Chinese + English with live partials.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming Chinese + English, compact model.",
|
||||
"sherpa-zipformer-en-20m": "Tiny streaming English model — lowest latency.",
|
||||
"sherpa-zipformer-zh-14m": "Tiny streaming Chinese model — lowest latency.",
|
||||
"sherpa-whisper-tiny": "Multilingual (90+ languages) with auto language detection."
|
||||
"sherpa-whisper-tiny": "Recommended. Multilingual dictation across 90+ languages with automatic language detection."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transcribiendo…",
|
||||
"a11y_setup": "Permite Accesibilidad para que el dictado pueda escribir por ti",
|
||||
"pasted": "Pegado",
|
||||
"inserted": "Insertado",
|
||||
"copied": "Copiado al portapapeles",
|
||||
"no_speech": "No se detectó voz",
|
||||
"mic_denied": "Acceso al micrófono denegado",
|
||||
"mic_denied_toast": "Acceso al micrófono denegado. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Eliminar modelo de voz",
|
||||
"engine_unavailable": "El motor de dictado en vivo no está disponible en esta instalación. El dictado vuelve a la ruta de transcripción estándar.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Recomendado. Dictado rápido y preciso en 25 idiomas europeos.",
|
||||
"sherpa-parakeet-tdt-v3": "Dictado rápido y preciso en 25 idiomas europeos.",
|
||||
"sherpa-parakeet-tdt-v2": "Dictado rápido y preciso solo en inglés.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming chino + inglés con parciales en vivo.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming chino + inglés, modelo compacto.",
|
||||
"sherpa-zipformer-en-20m": "Pequeño modelo de transmisión en inglés: latencia más baja.",
|
||||
"sherpa-zipformer-zh-14m": "Pequeño modelo chino de transmisión: latencia más baja.",
|
||||
"sherpa-whisper-tiny": "Multilingüe (más de 90 idiomas) con detección automática de idioma."
|
||||
"sherpa-whisper-tiny": "Recomendado. Dictado multilingüe en más de 90 idiomas con detección automática del idioma."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transcription…",
|
||||
"a11y_setup": "Autorisez l'accessibilité pour que la dictée puisse écrire à votre place",
|
||||
"pasted": "Collé",
|
||||
"inserted": "Inséré",
|
||||
"copied": "Copié dans le presse-papiers",
|
||||
"no_speech": "Aucune parole détectée",
|
||||
"mic_denied": "Accès au micro refusé",
|
||||
"mic_denied_toast": "Accès au microphone refusé. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Supprimer le modèle vocal",
|
||||
"engine_unavailable": "Le moteur de dictée en direct n'est pas disponible sur cette installation. La dictée revient au chemin de transcription standard.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Recommandé. Dictée rapide et précise dans 25 langues européennes.",
|
||||
"sherpa-parakeet-tdt-v3": "Dictée rapide et précise dans 25 langues européennes.",
|
||||
"sherpa-parakeet-tdt-v2": "Dictée rapide et précise en anglais uniquement.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming chinois + anglais avec partiels en direct.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming chinois + anglais, modèle compact.",
|
||||
"sherpa-zipformer-en-20m": "Petit modèle anglais de streaming – latence la plus faible.",
|
||||
"sherpa-zipformer-zh-14m": "Petit modèle chinois de streaming – latence la plus faible.",
|
||||
"sherpa-whisper-tiny": "Multilingue (plus de 90 langues) avec détection automatique de la langue."
|
||||
"sherpa-whisper-tiny": "Recommandé. Dictée multilingue dans plus de 90 langues avec détection automatique de la langue."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "प्रतिलेखन...",
|
||||
"a11y_setup": "एक्सेसिबिलिटी की अनुमति दें ताकि श्रुतलेख आपके लिए टाइप कर सके",
|
||||
"pasted": "चिपकाया गया",
|
||||
"inserted": "दर्ज किया गया",
|
||||
"copied": "क्लिपबोर्ड पर कॉपी किया गया",
|
||||
"no_speech": "कोई भाषण नहीं मिला",
|
||||
"mic_denied": "माइक का उपयोग अस्वीकृत",
|
||||
"mic_denied_toast": "माइक्रोफ़ोन पहुंच अस्वीकृत. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "वाक् मॉडल हटाएँ",
|
||||
"engine_unavailable": "इस इंस्टाल पर लाइव-डिक्टेशन इंजन उपलब्ध नहीं है। श्रुतलेखन मानक प्रतिलेखन पथ पर वापस आ जाता है।",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "अनुशंसित. 25 यूरोपीय भाषाओं में तेज़, सटीक श्रुतलेख।",
|
||||
"sherpa-parakeet-tdt-v3": "25 यूरोपीय भाषाओं में तेज़ और सटीक श्रुतलेख।",
|
||||
"sherpa-parakeet-tdt-v2": "तेज़, सटीक केवल अंग्रेज़ी श्रुतलेख।",
|
||||
"sherpa-zipformer-bilingual-zh-en": "लाइव आंशिक भाग के साथ चीनी + अंग्रेजी स्ट्रीमिंग।",
|
||||
"sherpa-paraformer-bilingual-zh-en": "स्ट्रीमिंग चीनी + अंग्रेजी, कॉम्पैक्ट मॉडल।",
|
||||
"sherpa-zipformer-en-20m": "छोटा स्ट्रीमिंग अंग्रेजी मॉडल - सबसे कम विलंबता।",
|
||||
"sherpa-zipformer-zh-14m": "छोटा स्ट्रीमिंग चीनी मॉडल - सबसे कम विलंबता।",
|
||||
"sherpa-whisper-tiny": "ऑटो भाषा पहचान के साथ बहुभाषी (90+ भाषाएँ)।"
|
||||
"sherpa-whisper-tiny": "अनुशंसित। स्वचालित भाषा पहचान के साथ 90 से अधिक भाषाओं में बहुभाषी श्रुतलेख।"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Mentranskripsikan…",
|
||||
"a11y_setup": "Izinkan Aksesibilitas agar dikte dapat mengetik untuk Anda",
|
||||
"pasted": "Ditempel",
|
||||
"inserted": "Dimasukkan",
|
||||
"copied": "Disalin ke papan klip",
|
||||
"no_speech": "Tidak ada ucapan yang terdeteksi",
|
||||
"mic_denied": "Akses mikrofon ditolak",
|
||||
"mic_denied_toast": "Akses mikrofon ditolak. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Hapus model ucapan",
|
||||
"engine_unavailable": "Mesin pendiktean langsung tidak tersedia pada instalasi ini. Dikte kembali ke jalur transkripsi standar.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Direkomendasikan. Dikte yang cepat dan akurat dalam 25 bahasa Eropa.",
|
||||
"sherpa-parakeet-tdt-v3": "Dikte cepat dan akurat dalam 25 bahasa Eropa.",
|
||||
"sherpa-parakeet-tdt-v2": "Dikte khusus bahasa Inggris yang cepat dan akurat.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming bahasa Mandarin + Inggris dengan siaran langsung sebagian.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming bahasa Mandarin + Inggris, model ringkas.",
|
||||
"sherpa-zipformer-en-20m": "Model streaming bahasa Inggris yang kecil — latensi terendah.",
|
||||
"sherpa-zipformer-zh-14m": "Model streaming kecil Tiongkok — latensi terendah.",
|
||||
"sherpa-whisper-tiny": "Multibahasa (90+ bahasa) dengan deteksi bahasa otomatis."
|
||||
"sherpa-whisper-tiny": "Direkomendasikan. Dikte multibahasa dalam lebih dari 90 bahasa dengan deteksi bahasa otomatis."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Trascrizione…",
|
||||
"a11y_setup": "Consenti Accessibilità così la dettatura può digitare per te",
|
||||
"pasted": "Incollato",
|
||||
"inserted": "Inserito",
|
||||
"copied": "Copiato negli appunti",
|
||||
"no_speech": "Nessun parlato rilevato",
|
||||
"mic_denied": "Accesso al microfono negato",
|
||||
"mic_denied_toast": "Accesso al microfono negato. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Elimina modello vocale",
|
||||
"engine_unavailable": "Il motore di dettatura dal vivo non è disponibile su questa installazione. La dettatura ritorna al percorso di trascrizione standard.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Consigliato. Dettatura rapida e accurata in 25 lingue europee.",
|
||||
"sherpa-parakeet-tdt-v3": "Dettatura rapida e accurata in 25 lingue europee.",
|
||||
"sherpa-parakeet-tdt-v2": "Dettatura veloce e accurata solo in inglese.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming cinese + inglese con parziali dal vivo.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming cinese + inglese, modello compatto.",
|
||||
"sherpa-zipformer-en-20m": "Piccolo modello inglese di streaming: latenza più bassa.",
|
||||
"sherpa-zipformer-zh-14m": "Piccolo modello cinese in streaming: latenza più bassa.",
|
||||
"sherpa-whisper-tiny": "Multilingue (oltre 90 lingue) con rilevamento automatico della lingua."
|
||||
"sherpa-whisper-tiny": "Consigliato. Dettatura multilingue in oltre 90 lingue con rilevamento automatico della lingua."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "文字起こし中…",
|
||||
"a11y_setup": "音声入力が代わりに入力できるよう、アクセシビリティを許可してください",
|
||||
"pasted": "貼り付けた",
|
||||
"inserted": "入力しました",
|
||||
"copied": "クリップボードにコピーしました",
|
||||
"no_speech": "音声が検出されませんでした",
|
||||
"mic_denied": "マイクアクセスが拒否されました",
|
||||
"mic_denied_toast": "マイクへのアクセスが拒否されました。 {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "音声モデルの削除",
|
||||
"engine_unavailable": "このインストールではライブディクテーション エンジンは利用できません。ディクテーションは標準の文字起こしパスにフォールバックします。",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "おすすめです。 25 のヨーロッパ言語にわたる高速かつ正確なディクテーション。",
|
||||
"sherpa-parakeet-tdt-v3": "25のヨーロッパ言語に対応した高速で正確な音声入力。",
|
||||
"sherpa-parakeet-tdt-v2": "高速かつ正確な英語のみのディクテーション。",
|
||||
"sherpa-zipformer-bilingual-zh-en": "中国語 + 英語のライブ部分をストリーミングします。",
|
||||
"sherpa-paraformer-bilingual-zh-en": "中国語+英語ストリーミング、コンパクトモデル。",
|
||||
"sherpa-zipformer-en-20m": "小さなストリーミング英語モデル — レイテンシが最も低い。",
|
||||
"sherpa-zipformer-zh-14m": "小さなストリーミング中国語モデル - 遅延が最も低い。",
|
||||
"sherpa-whisper-tiny": "自動言語検出機能を備えた多言語 (90 以上の言語)。"
|
||||
"sherpa-whisper-tiny": "おすすめ。90以上の言語に対応し、言語を自動検出する多言語音声入力。"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "스크립트 작성 중…",
|
||||
"a11y_setup": "받아쓰기가 대신 입력할 수 있도록 손쉬운 사용을 허용하세요",
|
||||
"pasted": "붙여넣음",
|
||||
"inserted": "입력됨",
|
||||
"copied": "클립보드에 복사됨",
|
||||
"no_speech": "음성이 감지되지 않았습니다.",
|
||||
"mic_denied": "마이크 액세스가 거부되었습니다.",
|
||||
"mic_denied_toast": "마이크 액세스가 거부되었습니다. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "음성 모델 삭제",
|
||||
"engine_unavailable": "The live-dictation engine isn't available on this install. Dictation falls back to the standard transcription path.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "추천합니다. Fast, accurate dictation across 25 European languages.",
|
||||
"sherpa-parakeet-tdt-v3": "25개 유럽 언어를 지원하는 빠르고 정확한 받아쓰기입니다.",
|
||||
"sherpa-parakeet-tdt-v2": "빠르고 정확한 영어 전용 받아쓰기.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "라이브 부분으로 중국어 + 영어 스트리밍.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "중국어 + 영어 스트리밍, 컴팩트 모델.",
|
||||
"sherpa-zipformer-en-20m": "작은 스트리밍 영어 모델 - 지연 시간이 가장 낮습니다.",
|
||||
"sherpa-zipformer-zh-14m": "작은 스트리밍 중국 모델 - 지연 시간이 가장 낮습니다.",
|
||||
"sherpa-whisper-tiny": "Multilingual (90+ languages) with auto language detection."
|
||||
"sherpa-whisper-tiny": "추천. 90개 이상의 언어를 지원하며 언어를 자동으로 감지하는 다국어 받아쓰기입니다."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
"lines_one": "{{count}} regel",
|
||||
"lines_other": "{{count}} regels",
|
||||
"copy": "Kopiëren",
|
||||
"copied": "Gekonieerd!",
|
||||
"copied": "Gekopieerd!",
|
||||
"waiting_output": "Wachten op uitvoer…",
|
||||
"auto_detect": "Automatisch detecteren",
|
||||
"suggest_lang": "Overschakelen naar het Nederlands?",
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transcriberen…",
|
||||
"a11y_setup": "Sta Toegankelijkheid toe zodat dicteren voor je kan typen",
|
||||
"pasted": "Geplakt",
|
||||
"inserted": "Ingevoegd",
|
||||
"copied": "Gekopieerd naar klembord",
|
||||
"no_speech": "Geen spraak gedetecteerd",
|
||||
"mic_denied": "Microfoontoegang geweigerd",
|
||||
"mic_denied_toast": "Microfoontoegang geweigerd. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Spraakmodel verwijderen",
|
||||
"engine_unavailable": "De live-dicteerengine is niet beschikbaar bij deze installatie. Het dicteren valt terug op het standaard transcriptiepad.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Aanbevolen. Snel en nauwkeurig dicteren in 25 Europese talen.",
|
||||
"sherpa-parakeet-tdt-v3": "Snel en nauwkeurig dicteren in 25 Europese talen.",
|
||||
"sherpa-parakeet-tdt-v2": "Snel, nauwkeurig dicteren in het Engels.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming Chinees + Engels met live gedeeltelijke beelden.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming Chinees + Engels met live tussentijdse resultaten.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming Chinees + Engels, compact model.",
|
||||
"sherpa-zipformer-en-20m": "Klein streaming Engels model - laagste latentie.",
|
||||
"sherpa-zipformer-zh-14m": "Klein Chinees streamingmodel – laagste latentie.",
|
||||
"sherpa-whisper-tiny": "Meertalig (90+ talen) met automatische taaldetectie."
|
||||
"sherpa-whisper-tiny": "Aanbevolen. Meertalig dicteren in meer dan 90 talen met automatische taaldetectie."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transkrypcja…",
|
||||
"a11y_setup": "Zezwól na Dostępność, aby dyktowanie mogło pisać za Ciebie",
|
||||
"pasted": "Wklejony",
|
||||
"inserted": "Wstawiono",
|
||||
"copied": "Skopiowano do schowka",
|
||||
"no_speech": "Nie wykryto mowy",
|
||||
"mic_denied": "Odmowa dostępu do mikrofonu",
|
||||
"mic_denied_toast": "Odmowa dostępu do mikrofonu. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Usuń model mowy",
|
||||
"engine_unavailable": "Mechanizm dyktowania na żywo nie jest dostępny w tej instalacji. Dyktowanie wraca do standardowej ścieżki transkrypcji.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Zalecane. Szybkie i dokładne dyktowanie w 25 językach europejskich.",
|
||||
"sherpa-parakeet-tdt-v3": "Szybkie i dokładne dyktowanie w 25 językach europejskich.",
|
||||
"sherpa-parakeet-tdt-v2": "Szybkie i dokładne dyktowanie wyłącznie w języku angielskim.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Transmisja strumieniowa języka chińskiego i angielskiego z fragmentami na żywo.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Przesyłanie strumieniowe w języku chińskim i angielskim, model kompaktowy.",
|
||||
"sherpa-zipformer-en-20m": "Mały, angielski model przesyłania strumieniowego — najniższe opóźnienie.",
|
||||
"sherpa-zipformer-zh-14m": "Mały chiński model do przesyłania strumieniowego — najniższe opóźnienie.",
|
||||
"sherpa-whisper-tiny": "Wielojęzyczny (ponad 90 języków) z automatycznym wykrywaniem języka."
|
||||
"sherpa-whisper-tiny": "Zalecane. Wielojęzyczne dyktowanie w ponad 90 językach z automatycznym wykrywaniem języka."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transcrevendo…",
|
||||
"a11y_setup": "Permita Acessibilidade para que o ditado possa digitar por você",
|
||||
"pasted": "Colado",
|
||||
"inserted": "Inserido",
|
||||
"copied": "Copiado para a área de transferência",
|
||||
"no_speech": "Nenhuma fala detectada",
|
||||
"mic_denied": "Acesso ao microfone negado",
|
||||
"mic_denied_toast": "Acesso ao microfone negado. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Excluir modelo de fala",
|
||||
"engine_unavailable": "O mecanismo de ditado ao vivo não está disponível nesta instalação. O ditado volta ao caminho de transcrição padrão.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Recomendado. Ditado rápido e preciso em 25 idiomas europeus.",
|
||||
"sherpa-parakeet-tdt-v3": "Ditado rápido e preciso em 25 idiomas europeus.",
|
||||
"sherpa-parakeet-tdt-v2": "Ditado rápido e preciso somente em inglês.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Streaming de chinês + inglês com parciais ao vivo.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming Chinês + Inglês, modelo compacto.",
|
||||
"sherpa-zipformer-en-20m": "Modelo inglês de streaming minúsculo – latência mais baixa.",
|
||||
"sherpa-zipformer-zh-14m": "Modelo chinês de streaming minúsculo – latência mais baixa.",
|
||||
"sherpa-whisper-tiny": "Multilíngue (mais de 90 idiomas) com detecção automática de idioma."
|
||||
"sherpa-whisper-tiny": "Recomendado. Ditado multilíngue em mais de 90 idiomas com detecção automática de idioma."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Расшифровка…",
|
||||
"a11y_setup": "Разрешите Универсальный доступ, чтобы диктовка могла печатать за вас",
|
||||
"pasted": "Вставлено",
|
||||
"inserted": "Введено",
|
||||
"copied": "Скопировано в буфер обмена",
|
||||
"no_speech": "Речь не обнаружена",
|
||||
"mic_denied": "Доступ к микрофону запрещен",
|
||||
"mic_denied_toast": "Доступ к микрофону запрещен. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Удалить речевую модель",
|
||||
"engine_unavailable": "Механизм живой диктовки недоступен в этой установке. Диктовка возвращается к стандартному пути транскрипции.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Рекомендуется. Быстрая и точная диктовка на 25 европейских языках.",
|
||||
"sherpa-parakeet-tdt-v3": "Быстрая и точная диктовка на 25 европейских языках.",
|
||||
"sherpa-parakeet-tdt-v2": "Быстрый и точный диктант только на английском языке.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Потоковое вещание на китайском и английском языках с живыми фрагментами.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Потоковое вещание на китайском и английском языках, компактная модель.",
|
||||
"sherpa-zipformer-en-20m": "Миниатюрная потоковая английская модель — минимальная задержка.",
|
||||
"sherpa-zipformer-zh-14m": "Миниатюрная потоковая китайская модель — самая низкая задержка.",
|
||||
"sherpa-whisper-tiny": "Многоязычный (более 90 языков) с автоматическим определением языка."
|
||||
"sherpa-whisper-tiny": "Рекомендуется. Многоязычная диктовка на более чем 90 языках с автоматическим определением языка."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Transkriberar...",
|
||||
"a11y_setup": "Tillåt Hjälpmedel så att diktering kan skriva åt dig",
|
||||
"pasted": "Klistras in",
|
||||
"inserted": "Infogat",
|
||||
"copied": "Kopierat till urklipp",
|
||||
"no_speech": "Inget tal upptäckt",
|
||||
"mic_denied": "Mikrofonåtkomst nekad",
|
||||
"mic_denied_toast": "Mikrofonåtkomst nekad. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Ta bort talmodell",
|
||||
"engine_unavailable": "Live-dikteringsmotorn är inte tillgänglig på den här installationen. Diktering faller tillbaka till standardtranskriptionsvägen.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Rekommenderas. Snabb, exakt diktering på 25 europeiska språk.",
|
||||
"sherpa-parakeet-tdt-v3": "Snabb och exakt diktering på 25 europeiska språk.",
|
||||
"sherpa-parakeet-tdt-v2": "Snabb, exakt diktering endast på engelska.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Strömmande kinesiska + engelska med livepartialer.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Streaming kinesiska + engelska, kompakt modell.",
|
||||
"sherpa-zipformer-en-20m": "Liten strömmande engelsk modell — lägsta latens.",
|
||||
"sherpa-zipformer-zh-14m": "Liten strömmande kinesisk modell — lägsta latens.",
|
||||
"sherpa-whisper-tiny": "Flerspråkig (90+ språk) med automatisk språkdetektering."
|
||||
"sherpa-whisper-tiny": "Rekommenderas. Flerspråkig diktering på över 90 språk med automatisk språkidentifiering."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "กำลังถอดเสียง...",
|
||||
"a11y_setup": "อนุญาตการช่วยการเข้าถึงเพื่อให้การป้อนตามคำบอกพิมพ์แทนคุณได้",
|
||||
"pasted": "วางแล้ว",
|
||||
"inserted": "แทรกแล้ว",
|
||||
"copied": "คัดลอกไปยังคลิปบอร์ดแล้ว",
|
||||
"no_speech": "ไม่พบคำพูด",
|
||||
"mic_denied": "การเข้าถึงไมค์ถูกปฏิเสธ",
|
||||
"mic_denied_toast": "การเข้าถึงไมโครโฟนถูกปฏิเสธ {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "ลบโมเดลคำพูด",
|
||||
"engine_unavailable": "กลไกการเขียนตามคำบอกสดไม่พร้อมใช้งานในการติดตั้งนี้ การเขียนตามคำบอกจะกลับไปใช้เส้นทางการถอดเสียงมาตรฐาน",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "แนะนำ. การเขียนตามคำบอกที่รวดเร็วและแม่นยำใน 25 ภาษายุโรป",
|
||||
"sherpa-parakeet-tdt-v3": "การเขียนตามคำบอกที่รวดเร็วและแม่นยำใน 25 ภาษายุโรป",
|
||||
"sherpa-parakeet-tdt-v2": "การเขียนตามคำบอกภาษาอังกฤษเท่านั้นที่รวดเร็วและแม่นยำ",
|
||||
"sherpa-zipformer-bilingual-zh-en": "สตรีมมิ่งภาษาจีน + อังกฤษพร้อมถ่ายทอดสดบางส่วน",
|
||||
"sherpa-paraformer-bilingual-zh-en": "สตรีมมิ่งภาษาจีน+อังกฤษ รุ่นกะทัดรัด",
|
||||
"sherpa-zipformer-en-20m": "โมเดลสตรีมมิ่งภาษาอังกฤษขนาดเล็ก — เวลาแฝงต่ำที่สุด",
|
||||
"sherpa-zipformer-zh-14m": "โมเดลสตรีมมิ่งจีนขนาดเล็ก — เวลาแฝงต่ำที่สุด",
|
||||
"sherpa-whisper-tiny": "หลายภาษา (90+ ภาษา) พร้อมการตรวจจับภาษาอัตโนมัติ"
|
||||
"sherpa-whisper-tiny": "แนะนำ การเขียนตามคำบอกหลายภาษากว่า 90 ภาษา พร้อมการตรวจจับภาษาอัตโนมัติ"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Metne dönüştürülüyor…",
|
||||
"a11y_setup": "Dikte sizin yerinize yazabilsin diye Erişilebilirlik'e izin verin",
|
||||
"pasted": "Yapıştırıldı",
|
||||
"inserted": "Eklendi",
|
||||
"copied": "Panoya kopyalandı",
|
||||
"no_speech": "Konuşma algılanmadı",
|
||||
"mic_denied": "Mikrofon erişimi reddedildi",
|
||||
"mic_denied_toast": "Mikrofon erişimi reddedildi. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Konuşma modelini sil",
|
||||
"engine_unavailable": "Canlı dikte motoru bu kurulumda mevcut değil. Dikte, standart transkripsiyon yoluna geri döner.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Tavsiye edilir. 25 Avrupa dilinde hızlı, doğru dikte.",
|
||||
"sherpa-parakeet-tdt-v3": "25 Avrupa dilinde hızlı ve doğru dikte.",
|
||||
"sherpa-parakeet-tdt-v2": "Hızlı, doğru yalnızca İngilizce dikte.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Canlı bölümlerle Çince + İngilizce akışı.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Çince + İngilizce akışı, kompakt model.",
|
||||
"sherpa-zipformer-en-20m": "Küçük akışlı İngilizce modeli — en düşük gecikme.",
|
||||
"sherpa-zipformer-zh-14m": "Küçük akışlı Çin modeli — en düşük gecikme.",
|
||||
"sherpa-whisper-tiny": "Otomatik dil algılamalı çok dilli (90'dan fazla dil)."
|
||||
"sherpa-whisper-tiny": "Tavsiye edilir. Otomatik dil algılama ile 90'dan fazla dilde çok dilli dikte."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Транскрибування…",
|
||||
"a11y_setup": "Дозвольте Доступність, щоб диктування могло друкувати за вас",
|
||||
"pasted": "Вставив",
|
||||
"inserted": "Введено",
|
||||
"copied": "Скопійовано в буфер обміну",
|
||||
"no_speech": "Мовлення не виявлено",
|
||||
"mic_denied": "Доступ до мікрофона заборонено",
|
||||
"mic_denied_toast": "Доступ до мікрофона заборонено. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Видалити модель мовлення",
|
||||
"engine_unavailable": "Система живого диктування недоступна під час цієї інсталяції. Диктування повертається до стандартного шляху транскрипції.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Рекомендовано. Швидкий і точний диктант 25 європейськими мовами.",
|
||||
"sherpa-parakeet-tdt-v3": "Швидке й точне диктування 25 європейськими мовами.",
|
||||
"sherpa-parakeet-tdt-v2": "Швидкий і точний диктант лише англійською мовою.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Потокова трансляція китайською та англійською мовами з живими частками.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Потоковий китайський + англійський, компактна модель.",
|
||||
"sherpa-zipformer-en-20m": "Маленька потокова англійська модель — найменша затримка.",
|
||||
"sherpa-zipformer-zh-14m": "Маленька потокова китайська модель — найменша затримка.",
|
||||
"sherpa-whisper-tiny": "Багатомовний (90+ мов) з автоматичним визначенням мови."
|
||||
"sherpa-whisper-tiny": "Рекомендовано. Багатомовне диктування понад 90 мовами з автоматичним визначенням мови."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "Phiên âm…",
|
||||
"a11y_setup": "Cho phép Trợ năng để đọc chính tả có thể gõ thay bạn",
|
||||
"pasted": "Đã dán",
|
||||
"inserted": "Đã nhập",
|
||||
"copied": "Đã sao chép vào bảng nhớ tạm",
|
||||
"no_speech": "Không phát hiện thấy giọng nói nào",
|
||||
"mic_denied": "Quyền truy cập micrô bị từ chối",
|
||||
"mic_denied_toast": "Quyền truy cập micrô bị từ chối. {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "Xóa mẫu giọng nói",
|
||||
"engine_unavailable": "Công cụ đọc chính tả trực tiếp không khả dụng trên bản cài đặt này. Đọc chính tả quay trở lại đường dẫn phiên âm tiêu chuẩn.",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "Khuyến nghị. Đọc chính tả nhanh, chính xác trên 25 ngôn ngữ Châu Âu.",
|
||||
"sherpa-parakeet-tdt-v3": "Đọc chính tả nhanh, chính xác bằng 25 ngôn ngữ châu Âu.",
|
||||
"sherpa-parakeet-tdt-v2": "Đọc chính tả chỉ bằng tiếng Anh nhanh chóng, chính xác.",
|
||||
"sherpa-zipformer-bilingual-zh-en": "Truyền phát tiếng Trung + tiếng Anh với các phần trực tiếp.",
|
||||
"sherpa-paraformer-bilingual-zh-en": "Truyền phát tiếng Trung + tiếng Anh, model nhỏ gọn.",
|
||||
"sherpa-zipformer-en-20m": "Mô hình phát trực tuyến nhỏ bằng tiếng Anh — độ trễ thấp nhất.",
|
||||
"sherpa-zipformer-zh-14m": "Mô hình phát trực tuyến nhỏ của Trung Quốc — độ trễ thấp nhất.",
|
||||
"sherpa-whisper-tiny": "Đa ngôn ngữ (hơn 90 ngôn ngữ) với tính năng tự động phát hiện ngôn ngữ."
|
||||
"sherpa-whisper-tiny": "Khuyến nghị. Đọc chính tả đa ngôn ngữ với hơn 90 ngôn ngữ và tự động phát hiện ngôn ngữ."
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -744,6 +744,8 @@
|
||||
"transcribing_label": "正在抄写…",
|
||||
"a11y_setup": "允许辅助功能,以便听写为您输入文字",
|
||||
"pasted": "粘贴的",
|
||||
"inserted": "已输入",
|
||||
"copied": "已复制到剪贴板",
|
||||
"no_speech": "未检测到语音",
|
||||
"mic_denied": "麦克风访问被拒绝",
|
||||
"mic_denied_toast": "麦克风访问被拒绝。 {{hint}}",
|
||||
@@ -2477,13 +2479,13 @@
|
||||
"delete_confirm_title": "删除语音模型",
|
||||
"engine_unavailable": "实时听写引擎在此安装中不可用。听写将回退到标准转录流程。",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "推荐。快速、准确地听写 25 种欧洲语言。",
|
||||
"sherpa-parakeet-tdt-v3": "支持 25 种欧洲语言的快速、准确听写。",
|
||||
"sherpa-parakeet-tdt-v2": "快速、准确的纯英语听写。",
|
||||
"sherpa-zipformer-bilingual-zh-en": "流式中英,带实时部分结果。",
|
||||
"sherpa-paraformer-bilingual-zh-en": "流式中英,紧凑型模型。",
|
||||
"sherpa-zipformer-en-20m": "小型流式英文模型 — 最低延迟。",
|
||||
"sherpa-zipformer-zh-14m": "小型流式中文模型——最低延迟。",
|
||||
"sherpa-whisper-tiny": "具有自动语言检测功能的多语言(90 多种语言)。"
|
||||
"sherpa-whisper-tiny": "推荐。支持 90 多种语言并可自动检测语言的多语言听写。"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -785,6 +785,8 @@
|
||||
"transcribing_label": "正在抄寫…",
|
||||
"a11y_setup": "允許輔助使用,讓聽寫功能為您輸入文字",
|
||||
"pasted": "貼上的",
|
||||
"inserted": "已輸入",
|
||||
"copied": "已複製到剪貼簿",
|
||||
"no_speech": "未偵測到語音",
|
||||
"mic_denied": "麥克風存取被拒絕",
|
||||
"mic_denied_toast": "麥克風存取被拒絕。 {{hint}}",
|
||||
@@ -2470,13 +2472,13 @@
|
||||
"delete_confirm_title": "刪除語音模型",
|
||||
"engine_unavailable": "即時聽寫引擎在此安裝中不可用。聽寫回到標準轉錄路徑。",
|
||||
"model_desc": {
|
||||
"sherpa-parakeet-tdt-v3": "推薦。快速、準確地聽寫 25 種歐洲語言。",
|
||||
"sherpa-parakeet-tdt-v3": "支援 25 種歐洲語言的快速、準確聽寫。",
|
||||
"sherpa-parakeet-tdt-v2": "快速、準確的純英語聽寫。",
|
||||
"sherpa-zipformer-bilingual-zh-en": "串流中文 + 英文,並附有現場部分內容。",
|
||||
"sherpa-paraformer-bilingual-zh-en": "串流中文+英文,緊湊型。",
|
||||
"sherpa-zipformer-en-20m": "小型串流英文模型 — 最低延遲。",
|
||||
"sherpa-zipformer-zh-14m": "小型串流媒體中國模型—最低延遲。",
|
||||
"sherpa-whisper-tiny": "具有自動語言偵測功能的多語言(90 多種語言)。"
|
||||
"sherpa-whisper-tiny": "推薦。支援 90 多種語言並可自動偵測語言的多語言聽寫。"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
|
||||
@@ -18,7 +18,7 @@ type DictationMode = 'toggle' | 'hold';
|
||||
/** Default sherpa dictation model id — matches the backend
|
||||
* `sherpa_dictation.DEFAULT_MODEL_ID`. Used only as the pre-hydration seed;
|
||||
* the authoritative value comes from `GET /dictation/prefs`. */
|
||||
const DEFAULT_DICTATION_MODEL_ID = 'sherpa-parakeet-tdt-v3';
|
||||
const DEFAULT_DICTATION_MODEL_ID = 'sherpa-whisper-tiny';
|
||||
|
||||
/**
|
||||
* Global UI font. Applied app-wide by overriding the `--font-sans` CSS custom
|
||||
@@ -202,7 +202,7 @@ export interface PrefsSlice {
|
||||
* • dictationMode — 'toggle' (press to start, press to stop) | 'hold'
|
||||
* (record while the key is held).
|
||||
* • dictationModelId — the selected sherpa-onnx model id (e.g.
|
||||
* 'sherpa-parakeet-tdt-v3'); drives `?model=` on the
|
||||
* 'sherpa-whisper-tiny'); drives `?model=` on the
|
||||
* live `/ws/transcribe` socket.
|
||||
*/
|
||||
dictationEnabled: boolean;
|
||||
@@ -289,7 +289,7 @@ export const createPrefsSlice: StateCreator<PrefsSlice, [], [], PrefsSlice> = (s
|
||||
autoPlayPreview: true,
|
||||
|
||||
// Seeds only — overwritten by loadDictationPrefs() on init. The backend
|
||||
// default is enabled:true / mode:'toggle' / model:Parakeet TDT v3.
|
||||
// default is enabled:true / mode:'toggle' / model:Whisper Tiny.
|
||||
dictationEnabled: true,
|
||||
dictationMode: 'toggle',
|
||||
dictationModelId: DEFAULT_DICTATION_MODEL_ID,
|
||||
|
||||
@@ -72,14 +72,16 @@ function stubInvoke({ mic = 'granted' } = {}) {
|
||||
if (cmd === 'check_accessibility') return true;
|
||||
if (cmd === 'request_dictation_capture') {
|
||||
const event = payload?.action === 'stop' ? 'tray-dictate-stop' : 'tray-dictate';
|
||||
if (eventHandlers[event]) return eventHandlers[event]();
|
||||
captureState.pending = event;
|
||||
const eventPayload =
|
||||
event === 'tray-dictate' ? { payload: { sessionId: 'mic-preflight-session' } } : undefined;
|
||||
if (eventHandlers[event]) return eventHandlers[event](eventPayload);
|
||||
captureState.pending = { event, eventPayload };
|
||||
return undefined;
|
||||
}
|
||||
if (cmd === 'mark_dictation_capture_ready' && captureState.pending) {
|
||||
const pending = captureState.pending;
|
||||
const { event, eventPayload } = captureState.pending;
|
||||
captureState.pending = null;
|
||||
return eventHandlers[pending]?.();
|
||||
return eventHandlers[event]?.(eventPayload);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
@@ -152,7 +154,7 @@ describe('CaptureWidget — mic permission pre-flight (Tauri)', () => {
|
||||
});
|
||||
render(<CaptureWidget />);
|
||||
await waitFor(() => expect(eventHandlers['tray-dictate']).toBeTypeOf('function'));
|
||||
eventHandlers['tray-dictate']();
|
||||
eventHandlers['tray-dictate']({ payload: { sessionId: 'mic-preflight-session' } });
|
||||
|
||||
expect(await screen.findByText(/Mic access denied/)).toBeInTheDocument();
|
||||
expect(gum).not.toHaveBeenCalled();
|
||||
|
||||
@@ -8,15 +8,17 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const { toastMock } = vi.hoisted(() => ({
|
||||
const { toastMock, eventHandlers, eventState } = vi.hoisted(() => ({
|
||||
toastMock: Object.assign(vi.fn(), {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
}),
|
||||
eventHandlers: {},
|
||||
eventState: { pendingStart: false },
|
||||
}));
|
||||
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
|
||||
|
||||
@@ -25,7 +27,10 @@ vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args) => invokeMock(...args),
|
||||
}));
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
listen: vi.fn(async (name, handler) => {
|
||||
eventHandlers[name] = handler;
|
||||
return () => delete eventHandlers[name];
|
||||
}),
|
||||
}));
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ hide: async () => {} }),
|
||||
@@ -103,7 +108,9 @@ class FakeWS {
|
||||
}
|
||||
|
||||
function pressShortcut() {
|
||||
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
|
||||
const handler = eventHandlers['tray-dictate'];
|
||||
if (handler) handler({ payload: { sessionId: 'setup-race-session' } });
|
||||
else eventState.pendingStart = true;
|
||||
}
|
||||
|
||||
let realWebSocket;
|
||||
@@ -114,8 +121,15 @@ beforeEach(() => {
|
||||
invokeMock.mockImplementation(async (cmd) => {
|
||||
if (cmd === 'check_microphone') return 'granted';
|
||||
if (cmd === 'check_accessibility') return true;
|
||||
if (cmd === 'mark_dictation_capture_ready' && eventState.pendingStart) {
|
||||
eventState.pendingStart = false;
|
||||
return eventHandlers['tray-dictate']?.({
|
||||
payload: { sessionId: 'setup-race-session' },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
eventState.pendingStart = false;
|
||||
FakeWS.instances = [];
|
||||
storeState.dictationModelId = 'sherpa-parakeet-v3';
|
||||
realWebSocket = globalThis.WebSocket;
|
||||
|
||||
@@ -38,25 +38,25 @@ const MODELS = {
|
||||
repo_id: 'org/parakeet-v3',
|
||||
label: 'Parakeet TDT v3',
|
||||
tag: 'offline',
|
||||
recommended: true,
|
||||
size_gb: 0.18,
|
||||
recommended: false,
|
||||
size_gb: 0.67,
|
||||
languages: '25 European languages',
|
||||
installed: true,
|
||||
installed: false,
|
||||
},
|
||||
{
|
||||
id: 'sherpa-whisper-tiny',
|
||||
repo_id: 'org/whisper-tiny',
|
||||
label: 'Whisper Tiny',
|
||||
tag: 'offline',
|
||||
recommended: false,
|
||||
size_gb: 0.116,
|
||||
recommended: true,
|
||||
size_gb: 0.104,
|
||||
languages: '90+ languages',
|
||||
installed: false,
|
||||
installed: true,
|
||||
},
|
||||
],
|
||||
engine_available: true,
|
||||
engine_reason: null,
|
||||
default_model_id: 'sherpa-parakeet-tdt-v3',
|
||||
default_model_id: 'sherpa-whisper-tiny',
|
||||
};
|
||||
|
||||
function withI18n(node) {
|
||||
@@ -83,7 +83,7 @@ describe('VoicePanel', () => {
|
||||
return Promise.resolve({
|
||||
enabled: true,
|
||||
mode: 'toggle',
|
||||
model_id: 'sherpa-parakeet-tdt-v3',
|
||||
model_id: 'sherpa-whisper-tiny',
|
||||
});
|
||||
return Promise.resolve({});
|
||||
});
|
||||
@@ -91,7 +91,7 @@ describe('VoicePanel', () => {
|
||||
useAppStore.setState({
|
||||
dictationEnabled: true,
|
||||
dictationMode: 'toggle',
|
||||
dictationModelId: 'sherpa-parakeet-tdt-v3',
|
||||
dictationModelId: 'sherpa-whisper-tiny',
|
||||
dictationLoaded: true,
|
||||
});
|
||||
});
|
||||
@@ -107,39 +107,41 @@ describe('VoicePanel', () => {
|
||||
expect(screen.getByRole('switch', { name: 'Enable Voice Dictation' })).toBeChecked();
|
||||
// The dropdown trigger shows the selected model once models load.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('dictation-model-trigger')).toHaveTextContent('Parakeet TDT v3'),
|
||||
expect(screen.getByTestId('dictation-model-trigger')).toHaveTextContent('Whisper Tiny'),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists models with badges, size and install/delete affordances when expanded', async () => {
|
||||
render(withI18n(<VoicePanel />));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('dictation-model-trigger')).toHaveTextContent('Parakeet TDT v3'),
|
||||
expect(screen.getByTestId('dictation-model-trigger')).toHaveTextContent('Whisper Tiny'),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('dictation-model-trigger'));
|
||||
|
||||
const v3 = screen.getByTestId('dictation-model-sherpa-parakeet-tdt-v3').closest('li');
|
||||
expect(within(v3).getByText('recommended')).toBeInTheDocument();
|
||||
expect(within(v3).getByText('offline')).toBeInTheDocument();
|
||||
expect(within(v3).getByText('180 MB')).toBeInTheDocument();
|
||||
const whisper = screen.getByTestId('dictation-model-sherpa-whisper-tiny').closest('li');
|
||||
expect(within(whisper).getByText('recommended')).toBeInTheDocument();
|
||||
expect(within(whisper).getByText('offline')).toBeInTheDocument();
|
||||
expect(within(whisper).getByText('104 MB')).toBeInTheDocument();
|
||||
// Installed → delete affordance.
|
||||
expect(screen.getByTestId('dictation-delete-sherpa-parakeet-tdt-v3')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dictation-delete-sherpa-whisper-tiny')).toBeInTheDocument();
|
||||
// Not installed → download affordance.
|
||||
expect(screen.getByTestId('dictation-install-sherpa-whisper-tiny')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dictation-install-sherpa-parakeet-tdt-v3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the model pref and kicks off install when picking an uninstalled model', async () => {
|
||||
render(withI18n(<VoicePanel />));
|
||||
await waitFor(() => expect(screen.getByTestId('dictation-model-trigger')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('dictation-model-trigger'));
|
||||
fireEvent.click(screen.getByTestId('dictation-model-sherpa-whisper-tiny'));
|
||||
fireEvent.click(screen.getByTestId('dictation-model-sherpa-parakeet-tdt-v3'));
|
||||
|
||||
// Pref write-through (POST /dictation/prefs with the new model id).
|
||||
await waitFor(() =>
|
||||
expect(apiPost).toHaveBeenCalledWith('/dictation/prefs', { model_id: 'sherpa-whisper-tiny' }),
|
||||
expect(apiPost).toHaveBeenCalledWith('/dictation/prefs', {
|
||||
model_id: 'sherpa-parakeet-tdt-v3',
|
||||
}),
|
||||
);
|
||||
// Uninstalled → download started via the model-store install mutation.
|
||||
expect(installMutate).toHaveBeenCalledWith('org/whisper-tiny');
|
||||
expect(installMutate).toHaveBeenCalledWith('org/parakeet-v3');
|
||||
});
|
||||
|
||||
it('toggles the enable switch through the store write-through', async () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isSherpaModel,
|
||||
classifySherpaFinal,
|
||||
sherpaSummaryTail,
|
||||
aggregateDeliveryKind,
|
||||
computeTypeDelta,
|
||||
parsePasteError,
|
||||
} from '../components/CaptureWidget';
|
||||
@@ -26,30 +28,54 @@ describe('isSherpaModel', () => {
|
||||
});
|
||||
|
||||
describe('classifySherpaFinal', () => {
|
||||
it('treats the first non-empty offline final as a new utterance (then close finalises)', () => {
|
||||
// Offline model (Parakeet v3 default): one final, nothing committed yet.
|
||||
expect(classifySherpaFinal('hello world', [])).toBe('utterance');
|
||||
it('uses the explicit utterance kind even when the text repeats', () => {
|
||||
expect(
|
||||
classifySherpaFinal({ type: 'final', final_kind: 'utterance', text: 'yes' }, ['yes']),
|
||||
).toBe('utterance');
|
||||
});
|
||||
|
||||
it('treats a streaming per-utterance final as an utterance', () => {
|
||||
expect(classifySherpaFinal('second sentence', ['first sentence'])).toBe('utterance');
|
||||
it('uses the explicit summary kind when EOF includes an uncommitted tail', () => {
|
||||
expect(
|
||||
classifySherpaFinal(
|
||||
{ type: 'final', final_kind: 'summary', text: 'first sentence tail words' },
|
||||
['first sentence'],
|
||||
),
|
||||
).toBe('summary');
|
||||
});
|
||||
|
||||
it('detects the EOF summary (text === the committed join)', () => {
|
||||
const committed = ['first sentence', 'second sentence'];
|
||||
expect(classifySherpaFinal('first sentence second sentence', committed)).toBe('summary');
|
||||
it('finalises on an explicit empty summary', () => {
|
||||
expect(classifySherpaFinal({ type: 'final', final_kind: 'summary', text: '' }, [])).toBe(
|
||||
'terminator',
|
||||
);
|
||||
});
|
||||
|
||||
it('detects a single-utterance summary (summary equals the one commit)', () => {
|
||||
expect(classifySherpaFinal('hello world', ['hello world'])).toBe('summary');
|
||||
it('ignores empty utterance and unknown final kinds', () => {
|
||||
expect(classifySherpaFinal({ final_kind: 'utterance', text: '' }, [])).toBe('ignore');
|
||||
expect(classifySherpaFinal({ text: 'legacy guess' }, [])).toBe('ignore');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sherpaSummaryTail', () => {
|
||||
it('returns only text not already committed', () => {
|
||||
expect(sherpaSummaryTail('First sentence. Tail words.', ['First sentence.'])).toBe(
|
||||
'Tail words.',
|
||||
);
|
||||
});
|
||||
|
||||
it('finalises on an empty no-speech terminator', () => {
|
||||
expect(classifySherpaFinal('', [])).toBe('terminator');
|
||||
it('returns the whole summary when no utterance was committed', () => {
|
||||
expect(sherpaSummaryTail('Offline final.', [])).toBe('Offline final.');
|
||||
});
|
||||
|
||||
it('ignores an empty final once utterances were committed (the summary covers it)', () => {
|
||||
expect(classifySherpaFinal('', ['something'])).toBe('ignore');
|
||||
it('does not duplicate a summary already delivered in full', () => {
|
||||
expect(sherpaSummaryTail('One. Two.', ['One.', 'Two.'])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateDeliveryKind', () => {
|
||||
it('keeps copied as the truthful session outcome', () => {
|
||||
expect(aggregateDeliveryKind('pasted', 'inserted')).toBe('inserted');
|
||||
expect(aggregateDeliveryKind('inserted', 'copied')).toBe('copied');
|
||||
expect(aggregateDeliveryKind('copied', 'inserted')).toBe('copied');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +181,10 @@ describe('parsePasteError', () => {
|
||||
kind: 'paste',
|
||||
message: 'key event failed',
|
||||
});
|
||||
expect(parsePasteError('preflight: clipboard-only session')).toEqual({
|
||||
kind: 'preflight',
|
||||
message: 'clipboard-only session',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Error objects (Tauri invoke may reject with either shape)', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('../api/client', () => ({
|
||||
}));
|
||||
|
||||
import { useAppStore } from '../store';
|
||||
import { createPrefsSlice } from '../store/prefsSlice';
|
||||
|
||||
function flush() {
|
||||
// Let the write-through promise (.then) settle.
|
||||
@@ -27,6 +28,11 @@ function flush() {
|
||||
}
|
||||
|
||||
describe('dictation prefs store wiring', () => {
|
||||
it('seeds the cross-platform default before backend hydration', () => {
|
||||
const slice = createPrefsSlice(vi.fn() as any, vi.fn() as any, {} as any);
|
||||
expect(slice.dictationModelId).toBe('sherpa-whisper-tiny');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiJson.mockReset();
|
||||
apiPost.mockReset();
|
||||
@@ -34,7 +40,7 @@ describe('dictation prefs store wiring', () => {
|
||||
useAppStore.setState({
|
||||
dictationEnabled: true,
|
||||
dictationMode: 'toggle',
|
||||
dictationModelId: 'sherpa-parakeet-tdt-v3',
|
||||
dictationModelId: 'sherpa-whisper-tiny',
|
||||
dictationLoaded: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,58 @@
|
||||
|
||||
const WORKLET_URL = '/aec-worklet.js';
|
||||
|
||||
// Anti-alias filtering for the decimation below. resampleInterleavedFrame
|
||||
// picks samples by linear interpolation, which is not a low-pass: taking a
|
||||
// 48 kHz stream to 16 kHz that way folds everything above 8 kHz back down
|
||||
// into the speech band as tones that were never spoken, and the ASR is fed
|
||||
// the result. The browser only hands us 48 kHz when it refuses the requested
|
||||
// 16 kHz AudioContext — WKWebView does — so this is the normal path there,
|
||||
// not an edge case.
|
||||
//
|
||||
// Three cascaded Butterworth-Q biquads (~36 dB/octave) run in the audio
|
||||
// graph rather than per frame, so the filter keeps its state across frame
|
||||
// boundaries instead of restarting 50 times a second. The cutoff sits below
|
||||
// Nyquist to leave room for the rolloff; speech has little energy up there.
|
||||
const ANTIALIAS_STAGES = 3;
|
||||
const ANTIALIAS_CUTOFF_RATIO = 0.4;
|
||||
|
||||
export function buildAntiAliasChain(ctx, targetRate) {
|
||||
if (typeof ctx.createBiquadFilter !== 'function') return [];
|
||||
const stages = [];
|
||||
for (let stage = 0; stage < ANTIALIAS_STAGES; stage += 1) {
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = ANTIALIAS_CUTOFF_RATIO * targetRate;
|
||||
filter.Q.value = Math.SQRT1_2; // Butterworth — flat passband, no resonant peak
|
||||
stages.push(filter);
|
||||
}
|
||||
return stages;
|
||||
}
|
||||
|
||||
export function resampleInterleavedFrame(frame, inputRate, outputRate, channels) {
|
||||
if (inputRate === outputRate || frame.length === 0) return frame;
|
||||
|
||||
const inputFrames = Math.floor(frame.length / channels);
|
||||
const outputFrames = Math.max(1, Math.round((inputFrames * outputRate) / inputRate));
|
||||
const output = new Float32Array(outputFrames * channels);
|
||||
const sourceStep = inputRate / outputRate;
|
||||
|
||||
for (let outputIndex = 0; outputIndex < outputFrames; outputIndex += 1) {
|
||||
const sourcePosition = outputIndex * sourceStep;
|
||||
const lowerIndex = Math.min(Math.floor(sourcePosition), inputFrames - 1);
|
||||
const upperIndex = Math.min(lowerIndex + 1, inputFrames - 1);
|
||||
const mix = sourcePosition - lowerIndex;
|
||||
|
||||
for (let channel = 0; channel < channels; channel += 1) {
|
||||
const lower = frame[lowerIndex * channels + channel];
|
||||
const upper = frame[upperIndex * channels + channel];
|
||||
output[outputIndex * channels + channel] = lower + (upper - lower) * mix;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start capturing ``stream`` as Float32 mono frames at ``sampleRate``.
|
||||
*
|
||||
@@ -20,15 +72,37 @@ export async function startMicCapture(
|
||||
) {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
const ctx = new Ctx({ sampleRate });
|
||||
if (ctx.state === 'suspended') {
|
||||
try {
|
||||
await ctx.resume();
|
||||
} catch {
|
||||
/* reported below — a context that never runs emits no frames at all */
|
||||
}
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
// Failing loudly beats the alternative: a suspended context runs no
|
||||
// worklet, so the pill sits on "Listening" forever while not one frame
|
||||
// is captured. The caller can surface this; silence cannot be surfaced.
|
||||
try {
|
||||
await ctx.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error('mic-suspended: the audio context could not be resumed');
|
||||
}
|
||||
await ctx.audioWorklet.addModule(WORKLET_URL);
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
const sourceFrameSize = Math.max(1, Math.round((frameSize * ctx.sampleRate) / sampleRate));
|
||||
const node = new AudioWorkletNode(ctx, 'aec-frame-emitter', {
|
||||
processorOptions: { frameSize, channels },
|
||||
processorOptions: { frameSize: sourceFrameSize, 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);
|
||||
node.port.onmessage = (e) =>
|
||||
onFrame(resampleInterleavedFrame(e.data, ctx.sampleRate, sampleRate, channels));
|
||||
// Mic → [anti-alias] → worklet. Only when the browser refused the requested
|
||||
// rate; when it honors it there is no decimation and nothing to filter.
|
||||
const antiAlias = ctx.sampleRate > sampleRate ? buildAntiAliasChain(ctx, sampleRate) : [];
|
||||
const chain = [src, ...antiAlias, node];
|
||||
for (let i = 0; i < chain.length - 1; i += 1) chain[i].connect(chain[i + 1]);
|
||||
|
||||
const stop = async function stop() {
|
||||
try {
|
||||
@@ -46,6 +120,13 @@ export async function startMicCapture(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const filter of antiAlias) {
|
||||
try {
|
||||
filter.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
await ctx.close();
|
||||
} catch {
|
||||
@@ -53,8 +134,8 @@ export async function startMicCapture(
|
||||
}
|
||||
};
|
||||
// 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;
|
||||
// PCM/WAV recording encode the delivered frames at their actual rate.
|
||||
stop.sampleRate = sampleRate;
|
||||
stop.channels = channels;
|
||||
return stop;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { startMicCapture } from './micCapture';
|
||||
|
||||
let context;
|
||||
let workletNode;
|
||||
let contextSampleRate;
|
||||
|
||||
class FakeAudioContext {
|
||||
constructor() {
|
||||
context = this;
|
||||
this.sampleRate = contextSampleRate;
|
||||
this.state = 'suspended';
|
||||
this.audioWorklet = { addModule: vi.fn().mockResolvedValue(undefined) };
|
||||
this.resume = vi.fn(async () => {
|
||||
this.state = 'running';
|
||||
});
|
||||
this.close = vi.fn().mockResolvedValue(undefined);
|
||||
this.source = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
this.filters = [];
|
||||
}
|
||||
|
||||
createMediaStreamSource() {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
createBiquadFilter() {
|
||||
const filter = {
|
||||
type: '',
|
||||
frequency: { value: 0 },
|
||||
Q: { value: 0 },
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
this.filters.push(filter);
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioWorkletNode {
|
||||
constructor(_context, _name, options) {
|
||||
workletNode = this;
|
||||
this.options = options;
|
||||
this.port = { onmessage: null };
|
||||
this.disconnect = vi.fn();
|
||||
}
|
||||
}
|
||||
|
||||
describe('startMicCapture', () => {
|
||||
beforeEach(() => {
|
||||
contextSampleRate = 48000;
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||
vi.stubGlobal('AudioWorkletNode', FakeAudioWorkletNode);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
context = undefined;
|
||||
workletNode = undefined;
|
||||
contextSampleRate = undefined;
|
||||
});
|
||||
|
||||
it('resumes a suspended AudioContext before capturing microphone frames', async () => {
|
||||
const stop = await startMicCapture({}, vi.fn());
|
||||
|
||||
expect(context.resume).toHaveBeenCalledOnce();
|
||||
expect(context.state).toBe('running');
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
it('delivers fixed-size 16 kHz frames when the AudioContext runs at 48 kHz', async () => {
|
||||
const frames = [];
|
||||
const stop = await startMicCapture({}, (frame) => frames.push(frame), {
|
||||
sampleRate: 16000,
|
||||
frameSize: 4,
|
||||
});
|
||||
|
||||
expect(workletNode.options.processorOptions).toEqual({ frameSize: 12, channels: 1 });
|
||||
workletNode.port.onmessage({
|
||||
data: new Float32Array([-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1, 0.75, 0.5, 0.25]),
|
||||
});
|
||||
|
||||
expect(frames).toEqual([new Float32Array([-1, -0.25, 0.5, 0.75])]);
|
||||
expect(stop.sampleRate).toBe(16000);
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
it('preserves worklet frames when the AudioContext honors the requested rate', async () => {
|
||||
contextSampleRate = 16000;
|
||||
const onFrame = vi.fn();
|
||||
const stop = await startMicCapture({}, onFrame, { sampleRate: 16000, frameSize: 4 });
|
||||
const frame = new Float32Array([-1, -0.25, 0.5, 0.75]);
|
||||
|
||||
workletNode.port.onmessage({ data: frame });
|
||||
|
||||
expect(onFrame).toHaveBeenCalledOnce();
|
||||
expect(onFrame).toHaveBeenCalledWith(frame);
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
it('low-passes before decimating when the browser refuses the requested rate', async () => {
|
||||
// WKWebView hands back 48 kHz whatever we ask for; interpolating straight
|
||||
// down to 16 kHz would fold everything above 8 kHz into the speech band.
|
||||
const stop = await startMicCapture({}, vi.fn(), { sampleRate: 16000, frameSize: 4 });
|
||||
|
||||
expect(context.filters).toHaveLength(3);
|
||||
for (const filter of context.filters) {
|
||||
expect(filter.type).toBe('lowpass');
|
||||
expect(filter.frequency.value).toBeLessThan(16000 / 2); // below the fold frequency
|
||||
expect(filter.Q.value).toBeCloseTo(Math.SQRT1_2, 5); // Butterworth, no resonant peak
|
||||
}
|
||||
|
||||
// Mic → filters → worklet, in order.
|
||||
expect(context.source.connect).toHaveBeenCalledWith(context.filters[0]);
|
||||
expect(context.filters[0].connect).toHaveBeenCalledWith(context.filters[1]);
|
||||
expect(context.filters[1].connect).toHaveBeenCalledWith(context.filters[2]);
|
||||
expect(context.filters[2].connect).toHaveBeenCalledWith(workletNode);
|
||||
|
||||
await stop();
|
||||
for (const filter of context.filters) expect(filter.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the filter chain when the AudioContext honors the requested rate', async () => {
|
||||
contextSampleRate = 16000;
|
||||
const stop = await startMicCapture({}, vi.fn(), { sampleRate: 16000, frameSize: 4 });
|
||||
|
||||
// No decimation happens, so there is nothing to anti-alias.
|
||||
expect(context.filters).toHaveLength(0);
|
||||
expect(context.source.connect).toHaveBeenCalledWith(workletNode);
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
it('fails loudly when the AudioContext cannot be resumed', async () => {
|
||||
// A suspended context runs no worklet: every frame is silently lost and
|
||||
// the pill sits on "Listening" forever. Better to surface it.
|
||||
const failing = class extends FakeAudioContext {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.resume = vi.fn(async () => {
|
||||
throw new Error('user gesture required');
|
||||
});
|
||||
}
|
||||
};
|
||||
vi.stubGlobal('AudioContext', failing);
|
||||
|
||||
await expect(startMicCapture({}, vi.fn())).rejects.toThrow(/mic-suspended/);
|
||||
expect(context.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails loudly when resume resolves but the context stays suspended', async () => {
|
||||
const stuck = class extends FakeAudioContext {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.resume = vi.fn(async () => {}); // resolves, state never changes
|
||||
}
|
||||
};
|
||||
vi.stubGlobal('AudioContext', stuck);
|
||||
|
||||
await expect(startMicCapture({}, vi.fn())).rejects.toThrow(/mic-suspended/);
|
||||
expect(context.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
// the player never silences playback.
|
||||
|
||||
import { publishFarEnd } from './farEndBus';
|
||||
import { buildAntiAliasChain, resampleInterleavedFrame } from './micCapture';
|
||||
|
||||
const WORKLET_URL = '/aec-worklet.js';
|
||||
|
||||
@@ -44,11 +45,21 @@ export async function attachPlaybackTap(mediaEl, { sampleRate = 16000, frameSize
|
||||
/* gesture may be required; harmless */
|
||||
}
|
||||
}
|
||||
const channels = 1;
|
||||
const sourceFrameSize = Math.max(1, Math.round((frameSize * ctx.sampleRate) / sampleRate));
|
||||
const node = new AudioWorkletNode(ctx, 'aec-frame-emitter', {
|
||||
processorOptions: { frameSize },
|
||||
processorOptions: { frameSize: sourceFrameSize, channels },
|
||||
});
|
||||
node.port.onmessage = (e) => publishFarEnd(e.data);
|
||||
src.connect(node);
|
||||
node.port.onmessage = (e) =>
|
||||
publishFarEnd(resampleInterleavedFrame(e.data, ctx.sampleRate, sampleRate, channels));
|
||||
// The far-end reference decimates exactly like the mic path, so it needs the
|
||||
// same anti-alias low-pass before resampling — an aliased reference makes
|
||||
// the AEC subtract tones the speaker never played. Filters sit only on the
|
||||
// tap branch: the src → destination edge stays untouched, so what the user
|
||||
// hears is unchanged.
|
||||
const antiAlias = ctx.sampleRate > sampleRate ? buildAntiAliasChain(ctx, sampleRate) : [];
|
||||
const chain = [src, ...antiAlias, node];
|
||||
for (let i = 0; i < chain.length - 1; i += 1) chain[i].connect(chain[i + 1]);
|
||||
|
||||
return async function detach() {
|
||||
try {
|
||||
@@ -61,6 +72,20 @@ export async function attachPlaybackTap(mediaEl, { sampleRate = 16000, frameSize
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Intentionally leave ctx + src→destination intact (see header note).
|
||||
for (const filter of antiAlias) {
|
||||
try {
|
||||
filter.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// Detach the tap edge too: the ctx and src are memoised per element, so a
|
||||
// filter left hanging off src would accumulate one dead chain per AEC
|
||||
// toggle. The audible src→destination edge stays (see header note).
|
||||
try {
|
||||
src.disconnect(antiAlias[0] ?? node);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./farEndBus', () => ({ publishFarEnd: vi.fn() }));
|
||||
|
||||
import { publishFarEnd } from './farEndBus';
|
||||
import { attachPlaybackTap } from './playbackTap';
|
||||
|
||||
let context;
|
||||
let workletNode;
|
||||
let contextSampleRate;
|
||||
|
||||
class FakeAudioContext {
|
||||
constructor() {
|
||||
context = this;
|
||||
this.sampleRate = contextSampleRate;
|
||||
this.state = 'running';
|
||||
this.destination = {};
|
||||
this.audioWorklet = { addModule: vi.fn().mockResolvedValue(undefined) };
|
||||
this.source = { connect: vi.fn(), disconnect: vi.fn() };
|
||||
this.filters = [];
|
||||
}
|
||||
|
||||
createMediaElementSource() {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
createBiquadFilter() {
|
||||
const filter = {
|
||||
type: '',
|
||||
frequency: { value: 0 },
|
||||
Q: { value: 0 },
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
this.filters.push(filter);
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioWorkletNode {
|
||||
constructor(_context, _name, options) {
|
||||
workletNode = this;
|
||||
this.options = options;
|
||||
this.port = { onmessage: null };
|
||||
this.disconnect = vi.fn();
|
||||
}
|
||||
}
|
||||
|
||||
describe('attachPlaybackTap', () => {
|
||||
beforeEach(() => {
|
||||
contextSampleRate = 48000;
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||
vi.stubGlobal('AudioWorkletNode', FakeAudioWorkletNode);
|
||||
publishFarEnd.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
context = undefined;
|
||||
workletNode = undefined;
|
||||
contextSampleRate = undefined;
|
||||
});
|
||||
|
||||
it('delivers fixed-size 16 kHz reference frames from a 48 kHz context', async () => {
|
||||
const detach = await attachPlaybackTap({}, { sampleRate: 16000, frameSize: 4 });
|
||||
|
||||
expect(workletNode.options.processorOptions).toEqual({ frameSize: 12, channels: 1 });
|
||||
workletNode.port.onmessage({
|
||||
data: new Float32Array([-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1, 0.75, 0.5, 0.25]),
|
||||
});
|
||||
|
||||
expect(publishFarEnd).toHaveBeenCalledWith(new Float32Array([-1, -0.25, 0.5, 0.75]));
|
||||
|
||||
await detach();
|
||||
expect(workletNode.disconnect).toHaveBeenCalledOnce();
|
||||
expect(context.source.connect).toHaveBeenCalledWith(context.destination);
|
||||
});
|
||||
|
||||
it('low-passes the far-end reference before decimating (48 kHz context)', async () => {
|
||||
// The AEC reference decimates exactly like the mic path; without the
|
||||
// filter, content above 8 kHz folds into the reference and the canceller
|
||||
// subtracts tones the speaker never played.
|
||||
const detach = await attachPlaybackTap({}, { sampleRate: 16000, frameSize: 4 });
|
||||
|
||||
expect(context.filters).toHaveLength(3);
|
||||
for (const filter of context.filters) {
|
||||
expect(filter.type).toBe('lowpass');
|
||||
expect(filter.frequency.value).toBeLessThan(16000 / 2);
|
||||
}
|
||||
// Tap branch only: element → filters → worklet, while the audible
|
||||
// element → destination edge stays direct.
|
||||
expect(context.source.connect).toHaveBeenCalledWith(context.destination);
|
||||
expect(context.source.connect).toHaveBeenCalledWith(context.filters[0]);
|
||||
expect(context.filters[0].connect).toHaveBeenCalledWith(context.filters[1]);
|
||||
expect(context.filters[1].connect).toHaveBeenCalledWith(context.filters[2]);
|
||||
expect(context.filters[2].connect).toHaveBeenCalledWith(workletNode);
|
||||
|
||||
await detach();
|
||||
for (const filter of context.filters) expect(filter.disconnect).toHaveBeenCalled();
|
||||
// Only the tap edge is removed; the audible src→destination edge stays.
|
||||
expect(context.source.disconnect).toHaveBeenCalledWith(context.filters[0]);
|
||||
expect(context.source.disconnect).not.toHaveBeenCalledWith(context.destination);
|
||||
});
|
||||
|
||||
it('skips the filter chain when the context honors the requested rate', async () => {
|
||||
contextSampleRate = 16000;
|
||||
const detach = await attachPlaybackTap({}, { sampleRate: 16000, frameSize: 4 });
|
||||
|
||||
expect(context.filters).toHaveLength(0);
|
||||
expect(context.source.connect).toHaveBeenCalledWith(workletNode);
|
||||
|
||||
await detach();
|
||||
expect(context.source.disconnect).toHaveBeenCalledWith(workletNode);
|
||||
expect(context.source.disconnect).not.toHaveBeenCalledWith(context.destination);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,19 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_installed_repo_memo():
|
||||
"""_repo_installed memoizes positives module-globally and never
|
||||
invalidates, and test_installed_positive_is_memoized writes the very repo
|
||||
the missing-model tests probe — so run order decided what these tests saw
|
||||
(CodeRabbit on #1610). Deterministic now: empty before, empty after."""
|
||||
from services import asr_backend
|
||||
|
||||
asr_backend._INSTALLED_REPO_MEMO.clear()
|
||||
yield
|
||||
asr_backend._INSTALLED_REPO_MEMO.clear()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
from main import app
|
||||
@@ -94,8 +107,8 @@ class TestHelper:
|
||||
assert payload is not None
|
||||
assert payload["error"] == "asr_model_missing"
|
||||
rec = payload["recommended"]
|
||||
# The curated sherpa dictation entry, with the dictation_id the client
|
||||
# needs to also set dictation.model_id so the retry picks it up.
|
||||
# The explicitly selected sherpa entry, with the dictation_id the
|
||||
# client needs to set so the retry picks it up.
|
||||
assert rec["repo_id"] == "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
|
||||
assert rec["dictation_id"] == "sherpa-parakeet-tdt-v3"
|
||||
|
||||
@@ -109,6 +122,55 @@ class TestHelper:
|
||||
patch.object(sd, "is_installed", return_value=True):
|
||||
assert asr_backend.asr_model_missing_error(purpose="dictation") is None
|
||||
|
||||
def test_demoted_sherpa_preflights_capture_fallback(self):
|
||||
"""The next session follows demotion even when `?model=` persists."""
|
||||
from api.routers.setup import models as setup_models
|
||||
from services import asr_backend
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
fallback_repo = "Systran/faster-whisper-large-v3"
|
||||
with patch.object(asr_backend.SherpaDictationBackend, "is_available",
|
||||
return_value=(True, "ready")), \
|
||||
patch.object(sd, "is_demoted", return_value=True), \
|
||||
patch.object(sd, "is_installed", return_value=True), \
|
||||
patch.object(asr_backend, "_capture_whisper_repo",
|
||||
return_value=fallback_repo), \
|
||||
patch.object(setup_models, "is_cached", return_value=False), \
|
||||
patch.object(setup_models, "cache_is_complete", return_value=True):
|
||||
payload = asr_backend.asr_model_missing_error(
|
||||
purpose="dictation",
|
||||
sherpa_model_id="sherpa-parakeet-tdt-v3",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["missing_repo_id"] == fallback_repo
|
||||
assert payload["recommended"]["repo_id"] == "csukuangfj/sherpa-onnx-whisper-tiny"
|
||||
|
||||
def test_demoted_default_never_recommends_itself(self):
|
||||
"""Installing the CTA recommendation must escape, not clear, a demotion loop."""
|
||||
from api.routers.setup import models as setup_models
|
||||
from services import asr_backend
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
fallback_repo = "Systran/faster-whisper-large-v3"
|
||||
with patch.object(asr_backend.SherpaDictationBackend, "is_available",
|
||||
return_value=(True, "ready")), \
|
||||
patch.object(sd, "is_demoted",
|
||||
side_effect=lambda mid: mid == "sherpa-whisper-tiny"), \
|
||||
patch.object(asr_backend, "_capture_whisper_repo",
|
||||
return_value=fallback_repo), \
|
||||
patch.object(setup_models, "is_cached", return_value=False), \
|
||||
patch.object(setup_models, "cache_is_complete", return_value=True):
|
||||
payload = asr_backend.asr_model_missing_error(
|
||||
purpose="dictation",
|
||||
sherpa_model_id="sherpa-whisper-tiny",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["missing_repo_id"] == fallback_repo
|
||||
assert payload["recommended"]["repo_id"] == fallback_repo
|
||||
assert "dictation_id" not in payload["recommended"]
|
||||
|
||||
def test_never_raises(self):
|
||||
from services import asr_backend
|
||||
with patch.object(asr_backend, "active_backend_id",
|
||||
@@ -125,6 +187,24 @@ class TestHelper:
|
||||
patch.dict(os.environ, {"ASR_MODEL_FASTER": "someorg/custom-whisper"}):
|
||||
assert asr_backend.asr_model_missing_error() is None
|
||||
|
||||
def test_silent_recovery_requires_even_a_custom_fallback_to_be_installed(self):
|
||||
"""The recovery path never turns fail-open into an implicit download."""
|
||||
from api.routers.setup import models as setup_models
|
||||
from services import asr_backend
|
||||
|
||||
with patch.object(asr_backend, "_capture_whisper_repo",
|
||||
return_value="someorg/custom-whisper"), \
|
||||
patch.object(setup_models, "is_cached", return_value=False), \
|
||||
patch.object(setup_models, "cache_is_complete", return_value=False):
|
||||
payload = asr_backend.asr_model_missing_error(
|
||||
purpose="dictation",
|
||||
skip_sherpa=True,
|
||||
require_installed=True,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["missing_repo_id"] == "someorg/custom-whisper"
|
||||
|
||||
def test_pytorch_whisper_default_repo_fails_open(self):
|
||||
"""openai/whisper-large-v3-turbo (the pytorch-whisper default) is not
|
||||
a catalog entry — the preflight stays out of the way (auto-download,
|
||||
@@ -168,7 +248,7 @@ class TestHelper:
|
||||
payload = asr_backend.asr_model_missing_error()
|
||||
assert payload is not None
|
||||
assert payload["missing_repo_id"] == (
|
||||
"csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
|
||||
"csukuangfj/sherpa-onnx-whisper-tiny"
|
||||
)
|
||||
|
||||
def test_installed_positive_is_memoized(self):
|
||||
|
||||
@@ -12,6 +12,7 @@ quality.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -67,6 +68,96 @@ def _audio_chunk(n_bytes: int = 20_000) -> bytes:
|
||||
return b"\x00" * n_bytes
|
||||
|
||||
|
||||
def test_select_sherpa_spec_ignores_demoted_query_override(monkeypatch):
|
||||
"""A persisted frontend query must not resurrect a silent recognizer."""
|
||||
from api.routers import capture_ws as cw
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
model_id = "sherpa-parakeet-tdt-v3"
|
||||
websocket = types.SimpleNamespace(query_params={"model": model_id})
|
||||
monkeypatch.setattr(sd, "is_demoted", lambda mid: mid == model_id)
|
||||
|
||||
assert cw._select_sherpa_spec(websocket) is None
|
||||
|
||||
|
||||
def test_demoted_sherpa_query_keeps_pcm_transport_for_legacy_fallback(
|
||||
client, monkeypatch,
|
||||
):
|
||||
"""Demotion changes the recognizer, not the bytes already sent by the UI."""
|
||||
from api.routers import capture_ws as cw
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
model_id = "sherpa-parakeet-tdt-v3"
|
||||
monkeypatch.setattr(sd, "is_demoted", lambda mid: mid == model_id)
|
||||
sample_rates = []
|
||||
|
||||
async def fallback(_chunks, *, pcm_sr=None):
|
||||
sample_rates.append(pcm_sr)
|
||||
return {
|
||||
"text": "legacy fallback heard pcm",
|
||||
"segments": [],
|
||||
"language": "en",
|
||||
"engine": "stub",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cw, "_transcribe_buffer_full", fallback)
|
||||
with client.websocket_connect(
|
||||
f"/ws/transcribe?model={model_id}&sr=16000"
|
||||
) as ws:
|
||||
ws.send_bytes(_audio_chunk())
|
||||
ws.send_text("EOF")
|
||||
for _ in range(10):
|
||||
if ws.receive_json().get("type") == "final":
|
||||
break
|
||||
|
||||
assert sample_rates == [16000]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("result", "expected"),
|
||||
[
|
||||
({"text": "top-level text"}, "top-level text"),
|
||||
(
|
||||
{"segments": [{"text": "segment one"}, {"text": "segment two"}]},
|
||||
"segment one segment two",
|
||||
),
|
||||
(
|
||||
{"chunks": [{"text": "chunk one"}, {"text": "chunk two"}]},
|
||||
"chunk one chunk two",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_partial_text_normalizes_every_asr_result_shape(
|
||||
monkeypatch, tmp_path, result, expected,
|
||||
):
|
||||
"""Live partials work for backends that expose only segments/chunks.
|
||||
|
||||
WhisperX, Faster Whisper, Moonshine, and OpenAI-compatible ASR do not add a
|
||||
top-level ``text`` field. The capture seam must consume the shared ASR
|
||||
result contract instead of silently dropping their partial transcript.
|
||||
"""
|
||||
from api.routers import capture_ws as cw
|
||||
from services import asr_backend
|
||||
|
||||
wav = tmp_path / "partial.wav"
|
||||
wav.write_bytes(b"placeholder")
|
||||
|
||||
class StubBackend:
|
||||
def transcribe(self, _path, *, word_timestamps=False):
|
||||
assert word_timestamps is False
|
||||
return result
|
||||
|
||||
async def run_inline(_executor, fn, **_kwargs):
|
||||
return fn()
|
||||
|
||||
monkeypatch.setattr(cw, "_pcm16_to_wav", lambda _pcm, _sr: str(wav))
|
||||
monkeypatch.setattr(asr_backend, "get_capture_asr_backend", lambda: StubBackend())
|
||||
monkeypatch.setattr(asr_backend, "run_transcribe_guarded", run_inline)
|
||||
|
||||
assert await cw._transcribe_buffer([b"\x00" * 4000], pcm_sr=16000) == expected
|
||||
|
||||
|
||||
def test_eof_text_frame_triggers_final_without_disconnect(client):
|
||||
"""Client sends audio + 'EOF' text frame, expects `final` over open socket."""
|
||||
with client.websocket_connect("/ws/transcribe") as ws:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Keep localized dictation recommendation copy aligned with model policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_LOCALES = Path(__file__).resolve().parents[1] / "frontend" / "src" / "i18n" / "locales"
|
||||
|
||||
|
||||
def test_recommended_badge_copy_belongs_to_whisper_tiny_in_every_locale():
|
||||
natural_prefixes = {
|
||||
"ja": "おすすめ",
|
||||
"uk": "рекомендовано",
|
||||
"vi": "khuyến nghị",
|
||||
}
|
||||
for path in sorted(_LOCALES.glob("*.json")):
|
||||
panel = json.loads(path.read_text(encoding="utf-8"))["voicePanel"]
|
||||
badge = panel["badge_recommended"].casefold()
|
||||
recommendation = natural_prefixes.get(path.stem, badge)
|
||||
descriptions = panel["model_desc"]
|
||||
assert recommendation not in descriptions["sherpa-parakeet-tdt-v3"].casefold(), path.name
|
||||
assert recommendation in descriptions["sherpa-whisper-tiny"].casefold(), path.name
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Silent-model recovery audio must be bounded (#1610 review).
|
||||
|
||||
Both dictation WebSocket paths retained every PCM byte of a session so the
|
||||
silent-model fallback could re-transcribe it. Nothing capped that: an open mic
|
||||
at 16 kHz mono int16 added ~115 MB per hour, held for the life of the session
|
||||
and only ever read when the fallback actually fired. Streaming and offline
|
||||
both did it.
|
||||
|
||||
The tail is what matters — recovery re-transcribes what the user just said —
|
||||
while the silent-model gate measures how much audio the session carried, so
|
||||
the true total is tracked separately and stays truthful after trimming.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws():
|
||||
return importlib.import_module("api.routers.capture_ws")
|
||||
|
||||
|
||||
SR = 16000
|
||||
BYTES_PER_S = SR * 2
|
||||
|
||||
|
||||
def test_a_long_session_stops_growing(ws):
|
||||
tail = ws.RecoveryTail(SR, seconds=2.0)
|
||||
for _ in range(600): # 60 s of 100 ms frames
|
||||
tail.extend(b"\x01\x02" * (SR // 10))
|
||||
assert len(tail.tail()) == 2 * BYTES_PER_S
|
||||
|
||||
|
||||
def test_the_true_total_survives_trimming(ws):
|
||||
"""is_model_silent gates on how much audio the session carried; capping
|
||||
the buffer must not make a long session look too short to be recoverable."""
|
||||
tail = ws.RecoveryTail(SR, seconds=1.0)
|
||||
for _ in range(30):
|
||||
tail.extend(b"\x00\x01" * SR) # 1 s each
|
||||
assert tail.total_bytes == 30 * BYTES_PER_S
|
||||
assert len(tail.tail()) == BYTES_PER_S
|
||||
assert ws.is_model_silent("", True, tail.total_bytes) is True
|
||||
|
||||
|
||||
def test_the_retained_audio_is_the_most_recent(ws):
|
||||
"""Head-trimming, not head-keeping — the useful speech is the latest."""
|
||||
tail = ws.RecoveryTail(SR, seconds=1.0)
|
||||
tail.extend(b"\xaa\xaa" * SR) # older
|
||||
tail.extend(b"\xbb\xbb" * SR) # newer
|
||||
assert tail.tail() == b"\xbb\xbb" * SR
|
||||
|
||||
|
||||
def test_a_short_session_is_kept_whole(ws):
|
||||
tail = ws.RecoveryTail(SR, seconds=120.0)
|
||||
tail.extend(b"\x01\x02" * SR)
|
||||
assert tail.tail() == b"\x01\x02" * SR
|
||||
assert tail.total_bytes == BYTES_PER_S
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, 120.0), ("bad", 120.0), ("nan", 120.0), ("inf", 120.0),
|
||||
("-1", 120.0), ("0", 120.0), ("60", 60.0), ("999999", 300.0),
|
||||
],
|
||||
)
|
||||
def test_recovery_tail_environment_override_is_finite_and_bounded(ws, value, expected):
|
||||
assert ws._bounded_recovery_tail_seconds(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sample_rate,seconds", [(0, 120.0), (16000, 0.0), (-1, -1.0)])
|
||||
def test_a_nonsense_bound_still_yields_a_usable_buffer(ws, sample_rate, seconds):
|
||||
"""A bad sr query param or env override must not produce a zero-length
|
||||
buffer that silently disables recovery."""
|
||||
tail = ws.RecoveryTail(sample_rate, seconds=seconds)
|
||||
tail.extend(b"\x01\x02" * 100)
|
||||
assert len(tail.tail()) >= 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sr", ["1000000000", "4000", "0", "-16000", "junk", ""])
|
||||
def test_an_absurd_client_sample_rate_is_not_believed(ws, sr):
|
||||
"""`?sr=` sizes RecoveryTail's byte ceiling (rate × RECOVERY_TAIL_SECONDS),
|
||||
so an unclamped client value re-opens the unbounded-memory path (#1610
|
||||
review). Out-of-range and garbage rates fall back to 16 kHz."""
|
||||
assert ws._bounded_sample_rate({"sr": sr}) == 16000
|
||||
|
||||
|
||||
def test_supported_client_sample_rates_pass_through(ws):
|
||||
for sr in (8000, 16000, 44100, 48000, 96000):
|
||||
assert ws._bounded_sample_rate({"sr": str(sr)}) == sr
|
||||
|
||||
|
||||
def test_the_sherpa_paths_use_the_bounded_rate(ws):
|
||||
"""Structural: both sherpa handlers get their rate from _sherpa_session,
|
||||
which must parse via the clamped helper — a raw int() of the query param
|
||||
is exactly the bug."""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(ws._sherpa_session)
|
||||
assert "_bounded_sample_rate(" in src
|
||||
assert 'int(websocket.query_params.get("sr"' not in src
|
||||
|
||||
|
||||
def test_both_socket_paths_use_the_bounded_buffer(ws):
|
||||
"""Structural: the streaming path and the offline path both had the leak,
|
||||
so a fix applied to only one of them is not a fix."""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(ws)
|
||||
assert src.count("RecoveryTail(pcm_sr)") == 2
|
||||
assert "session_pcm = bytearray()" not in src
|
||||
|
||||
|
||||
def test_trimming_never_splits_a_sample(ws):
|
||||
"""int16 mono PCM: transport frames can carry odd byte counts (a sample
|
||||
split across two WebSocket messages), but the *stream* stays aligned — a
|
||||
sample starts at every even global offset. Trimming must remove an even
|
||||
number of bytes so the retained tail still starts on a sample boundary.
|
||||
|
||||
The failure needs a stream that ends mid-sample (the session closed on a
|
||||
torn frame): with an odd total, an odd-trimming buffer ends with an odd
|
||||
cumulative removal, the tail starts mid-sample, and every decoded value
|
||||
is byte-shifted garbage. (An even total self-rebalances across trims,
|
||||
which is why the obvious version of this test cannot fail.)
|
||||
"""
|
||||
import struct
|
||||
|
||||
n = SR * 2 # 2 s of samples; sample k holds the value k
|
||||
stream = b"".join(struct.pack("<h", k % 32000) for k in range(n)) + b"\x7f"
|
||||
|
||||
tail = ws.RecoveryTail(SR, seconds=1.0)
|
||||
# Odd-sized chunks so extend() boundaries never align with samples.
|
||||
i = 0
|
||||
for size in (1, 3331, 7777, 32001):
|
||||
tail.extend(stream[i:i + size])
|
||||
i += size
|
||||
tail.extend(stream[i:])
|
||||
|
||||
kept = tail.tail()
|
||||
whole = kept[: (len(kept) // 2) * 2] # the torn final byte is half a sample
|
||||
values = [v for (v,) in struct.iter_unpack("<h", whole)]
|
||||
# Sample-aligned ⟺ the decoded values are a contiguous ascending run; a
|
||||
# mid-sample start turns them into byte-shifted noise.
|
||||
assert values == list(range(values[0], values[0] + len(values))), values[:5]
|
||||
assert values[-1] == (n - 1) % 32000
|
||||
@@ -35,7 +35,7 @@ def test_list_models_shape(client):
|
||||
r = client.get("/dictation/models")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["default_model_id"] == "sherpa-parakeet-tdt-v3"
|
||||
assert body["default_model_id"] == "sherpa-whisper-tiny"
|
||||
assert len(body["models"]) == 7
|
||||
keys = {"id", "repo_id", "label", "tag", "recommended", "size_gb",
|
||||
"languages", "kind", "installed"}
|
||||
@@ -43,7 +43,7 @@ def test_list_models_shape(client):
|
||||
assert keys <= set(m), f"missing keys in {m}"
|
||||
assert m["tag"] in ("offline", "streaming")
|
||||
rec = [m for m in body["models"] if m["recommended"]]
|
||||
assert [m["id"] for m in rec] == ["sherpa-parakeet-tdt-v3"]
|
||||
assert [m["id"] for m in rec] == ["sherpa-whisper-tiny"]
|
||||
|
||||
|
||||
def test_list_models_omits_probe_diagnostic(client, monkeypatch):
|
||||
@@ -64,7 +64,7 @@ def test_get_prefs_defaults(client):
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body == {"enabled": True, "mode": "toggle",
|
||||
"model_id": "sherpa-parakeet-tdt-v3"}
|
||||
"model_id": "sherpa-whisper-tiny"}
|
||||
|
||||
|
||||
def test_set_prefs_persists_and_validates(client):
|
||||
|
||||
@@ -99,12 +99,25 @@ _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": 494, "de": 494, "es": 494, "fr": 494, "hi": 494, "id": 494,
|
||||
"it": 494, "ja": 494, "ko": 494, "nl": 494, "pl": 494, "pt": 494,
|
||||
"ru": 494, "sv": 494, "th": 494, "tr": 494, "uk": 494, "vi": 494,
|
||||
"zh-CN": 487, "zh-TW": 494,
|
||||
"ar": 493, "de": 493, "es": 493, "fr": 493, "hi": 493, "id": 493,
|
||||
"it": 493, "ja": 493, "ko": 493, "nl": 493, "pl": 493, "pt": 493,
|
||||
"ru": 493, "sv": 493, "th": 493, "tr": 493, "uk": 493, "vi": 493,
|
||||
"zh-CN": 486, "zh-TW": 493,
|
||||
}
|
||||
|
||||
#: Keys every locale must carry regardless of the aggregate ratchet above.
|
||||
#: The count alone is a weak guarantee — a locale can translate one new key
|
||||
#: while dropping another and the total never moves. These are strings a user
|
||||
#: on a default path actually reads, so they get pinned by name.
|
||||
#: capture.copied is the Wayland clipboard-delivery status, and clipboard
|
||||
#: delivery IS the Wayland default, so leaving it English broke that path for
|
||||
#: every non-English Linux user (#1610 review).
|
||||
_REQUIRED_IN_EVERY_LOCALE = (
|
||||
"capture.copied",
|
||||
"capture.inserted",
|
||||
"capture.pasted",
|
||||
)
|
||||
|
||||
|
||||
def _locale_files():
|
||||
return sorted(f for f in os.listdir(_LOCALES_DIR) if f.endswith(".json"))
|
||||
@@ -388,3 +401,33 @@ def test_transliterated_brands_are_a_known_gap():
|
||||
delete this test rather than weakening the one above.
|
||||
"""
|
||||
assert not _ENGINE_BRANDS.search("\u30a6\u30a3\u30b9\u30d1\u30fc\u3067\u6587\u5b57\u8d77\u3053\u3057\u4e2d")
|
||||
|
||||
|
||||
def _lookup(tree, dotted):
|
||||
node = tree
|
||||
for part in dotted.split("."):
|
||||
if not isinstance(node, dict) or part not in node:
|
||||
return None
|
||||
node = node[part]
|
||||
return node
|
||||
|
||||
|
||||
@pytest.mark.parametrize("locale", sorted(_MISSING_BASELINE))
|
||||
@pytest.mark.parametrize("key", _REQUIRED_IN_EVERY_LOCALE)
|
||||
def test_every_locale_carries_the_user_facing_dictation_status(locale, key):
|
||||
"""Named-key parity, not just the aggregate count.
|
||||
|
||||
The ratchet in _MISSING_BASELINE measures totals, so a locale can gain one
|
||||
translation and lose another without the number moving. These keys are
|
||||
read by users on default paths, so they are asserted individually.
|
||||
"""
|
||||
value = _lookup(_load(locale), key)
|
||||
english = _lookup(_load("en"), key)
|
||||
assert isinstance(value, str) and value.strip(), (
|
||||
f"{locale}.json is missing {key!r} — users on that locale see the raw key "
|
||||
f"or the English string"
|
||||
)
|
||||
assert value != english, (
|
||||
f"{locale}.json copies the English {key!r} verbatim ({english!r}); "
|
||||
f"translate it or the ratchet is measuring nothing"
|
||||
)
|
||||
|
||||
@@ -101,3 +101,10 @@ def test_every_platform_has_a_curated_asr_pick():
|
||||
and (not m.get("platforms") or set(m["platforms"]) & family_tags)
|
||||
]
|
||||
assert curated_asr, f"no curated ASR pick resolves for host tags {family_tags}"
|
||||
|
||||
|
||||
def test_single_cross_platform_dictation_recommendation_is_whisper_tiny():
|
||||
"""Installer curation must match the runtime/UI dictation default."""
|
||||
sherpa = [m for m in _models() if m.get("engine") == "sherpa-onnx"]
|
||||
recommended = [m["dictation_id"] for m in sherpa if "all" in (m.get("curated_on") or [])]
|
||||
assert recommended == ["sherpa-whisper-tiny"]
|
||||
|
||||
@@ -74,7 +74,8 @@ def test_mac_arm_curates_mlx_whisper_not_ct2(client):
|
||||
# The CT2 build stays available in the full catalog but is not the
|
||||
# Apple Silicon curated pick — MLX is Metal-accelerated, CT2 is CPU-only there.
|
||||
assert "Systran/faster-whisper-large-v3" not in ids
|
||||
assert "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8" in ids
|
||||
assert "csukuangfj/sherpa-onnx-whisper-tiny" in ids
|
||||
assert "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8" not in ids
|
||||
|
||||
|
||||
def test_cuda_curates_ct2_whisper_and_turbo(client):
|
||||
@@ -82,7 +83,8 @@ def test_cuda_curates_ct2_whisper_and_turbo(client):
|
||||
assert "k2-fsa/OmniVoice" in ids
|
||||
assert "Systran/faster-whisper-large-v3" in ids
|
||||
assert "deepdml/faster-whisper-large-v3-turbo-ct2" in ids
|
||||
assert "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8" in ids
|
||||
assert "csukuangfj/sherpa-onnx-whisper-tiny" in ids
|
||||
assert "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8" not in ids
|
||||
# MLX models never resolve off Apple Silicon.
|
||||
assert not any(rid.startswith("mlx-community/") for rid in ids)
|
||||
|
||||
@@ -100,12 +102,13 @@ def test_rocm_curates_pytorch_whisper_gpu_path(client):
|
||||
assert "deepdml/faster-whisper-large-v3-turbo-ct2" not in ids
|
||||
|
||||
|
||||
def test_cpu_only_curates_ct2_and_parakeet(client):
|
||||
def test_cpu_only_curates_ct2_and_whisper_tiny(client):
|
||||
payload = _recommend(client, ["win32", "win32-AMD64", "cpu"])
|
||||
ids = _ids(payload)
|
||||
assert "Systran/faster-whisper-large-v3" in ids
|
||||
assert "deepdml/faster-whisper-large-v3-turbo-ct2" in ids
|
||||
assert "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8" in ids
|
||||
assert "csukuangfj/sherpa-onnx-whisper-tiny" in ids
|
||||
assert "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8" not in ids
|
||||
assert "openai/whisper-large-v3" not in ids # 3.1 GB PyTorch build: GPU hosts only
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Also pins the verified ONNX asset filenames so a silent registry typo (the
|
||||
streaming zipformer repos use plain `encoder-epoch-99-avg-1.int8.onnx`, NOT a
|
||||
`-chunk-16-left-64` variant) fails loudly.
|
||||
"""
|
||||
import builtins
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
@@ -139,8 +140,8 @@ def test_seven_models_registered():
|
||||
}
|
||||
# Exactly one recommended default.
|
||||
rec = [s for s in specs if s.recommended]
|
||||
assert [s.id for s in rec] == ["sherpa-parakeet-tdt-v3"]
|
||||
assert sd.DEFAULT_MODEL_ID == "sherpa-parakeet-tdt-v3"
|
||||
assert [s.id for s in rec] == ["sherpa-whisper-tiny"]
|
||||
assert sd.DEFAULT_MODEL_ID == "sherpa-whisper-tiny"
|
||||
|
||||
|
||||
def test_verified_filenames_pinned():
|
||||
@@ -174,6 +175,31 @@ def test_get_spec_accepts_repo_id():
|
||||
assert not sd.is_sherpa_model(None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"native_error",
|
||||
[
|
||||
OSError("native library could not be loaded"),
|
||||
RuntimeError("native runtime initialization failed"),
|
||||
],
|
||||
)
|
||||
def test_sherpa_available_degrades_native_loader_failures(monkeypatch, native_error):
|
||||
"""A broken platform DLL/dylib/so disables Sherpa without crashing APIs."""
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def import_with_broken_native(name, *args, **kwargs):
|
||||
if name == "sherpa_onnx":
|
||||
raise native_error
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", import_with_broken_native)
|
||||
|
||||
available, reason = sd.sherpa_available()
|
||||
assert available is False
|
||||
assert type(native_error).__name__ in reason
|
||||
|
||||
|
||||
def test_model_resolution_pins_offline_probe_and_download(monkeypatch, tmp_path):
|
||||
from services import hf_revisions, sherpa_dictation as sd
|
||||
import huggingface_hub
|
||||
|
||||
@@ -83,6 +83,39 @@ def test_declared_size_is_within_tolerance_of_the_measurement(sd, model_id):
|
||||
)
|
||||
|
||||
|
||||
def test_the_model_catalogue_carries_the_same_sizes(sd):
|
||||
"""`backend/config/models.yaml` is a second copy of these numbers.
|
||||
|
||||
The Model Catalogue's download accounting reads the yaml, the dictation
|
||||
picker reads `sd._MODELS` — the 2026-08 re-measure fixed only the latter
|
||||
and the catalogue kept advertising the old 3-4x-wrong figures (#1610
|
||||
review). Every yaml entry with a `dictation_id` must match the spec.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"backend", "config", "models.yaml",
|
||||
)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
catalog = yaml.safe_load(fh)
|
||||
entries = {
|
||||
e["dictation_id"]: e for e in catalog["models"]
|
||||
if isinstance(e, dict) and e.get("dictation_id")
|
||||
}
|
||||
assert set(entries) == set(sd._MODELS), (
|
||||
"models.yaml and sherpa_dictation._MODELS list different models"
|
||||
)
|
||||
for model_id, entry in entries.items():
|
||||
assert entry["size_gb"] == pytest.approx(
|
||||
sd._MODELS[model_id].size_gb
|
||||
), (
|
||||
f"models.yaml advertises {entry['size_gb']} GB for {model_id} but "
|
||||
f"the measured spec says {sd._MODELS[model_id].size_gb} GB — "
|
||||
f"update the catalogue in the same change as the spec"
|
||||
)
|
||||
|
||||
|
||||
def test_the_small_fallbacks_are_smaller_than_the_heavy_models(sd):
|
||||
"""The property that made the old numbers actively misleading.
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""The sherpa availability probe must fail closed (#1610 review).
|
||||
|
||||
``sherpa_available()`` caught ImportError plus OSError/RuntimeError, the types
|
||||
a broken native wheel usually raises. That set is open-ended — an extension
|
||||
module can raise anything during init. ``SherpaDictationBackend.is_available()``
|
||||
calls this directly and ``capture_ws.ws_transcribe`` calls that without a
|
||||
guard, so an unlisted exception type didn't degrade to "engine unavailable",
|
||||
it took the dictation WebSocket down.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sherpa():
|
||||
return importlib.import_module("services.sherpa_dictation")
|
||||
|
||||
|
||||
class _Boom(Exception):
|
||||
"""A native init failure that is neither OSError nor RuntimeError."""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc", [
|
||||
ImportError("no module named sherpa_onnx"),
|
||||
OSError("cannot load libonnxruntime.so"),
|
||||
RuntimeError("failed to initialize backend"),
|
||||
_Boom("ctypes ArgumentError-shaped failure"),
|
||||
ValueError("unexpected init failure"),
|
||||
])
|
||||
def test_any_import_failure_reports_unavailable(sherpa, monkeypatch, exc):
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "sherpa_onnx":
|
||||
raise exc
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
ok, detail = sherpa.sherpa_available()
|
||||
assert ok is False
|
||||
assert detail # the reason is always reported, never swallowed silently
|
||||
assert type(exc).__name__ in detail or "not installed" in detail
|
||||
|
||||
|
||||
def test_a_working_install_still_reports_ready(sherpa, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
monkeypatch.setitem(sys.modules, "sherpa_onnx", types.ModuleType("sherpa_onnx"))
|
||||
assert sherpa.sherpa_available() == (True, "ready")
|
||||
@@ -63,6 +63,25 @@ class _GrowingOnlineRecognizer:
|
||||
self._endpoint_at = 999
|
||||
|
||||
|
||||
class _SilentOnlineRecognizer:
|
||||
"""Accepts clear speech but never returns a token."""
|
||||
|
||||
def create_stream(self):
|
||||
return _GrowingStream()
|
||||
|
||||
def is_ready(self, s):
|
||||
return False
|
||||
|
||||
def decode_stream(self, s):
|
||||
pass
|
||||
|
||||
def get_result(self, s):
|
||||
return ""
|
||||
|
||||
def is_endpoint(self, s):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -140,12 +159,176 @@ def test_partials_before_final(client):
|
||||
assert partials, f"no partials emitted; saw {types_seen}"
|
||||
assert finals, f"no final emitted; saw {types_seen}"
|
||||
assert types_seen.index("partial") < types_seen.index("final")
|
||||
assert [m["final_kind"] for m in finals] == ["utterance", "summary"]
|
||||
|
||||
# Partials grow monotonically in length.
|
||||
lengths = [len(p["text"]) for p in partials]
|
||||
assert lengths == sorted(lengths)
|
||||
|
||||
|
||||
def test_streaming_silent_model_falls_back_and_demotes(monkeypatch):
|
||||
"""Speech into a token-silent streaming recognizer still returns text.
|
||||
|
||||
The offline Sherpa handler already detects this runtime failure class. The
|
||||
streaming handler must expose the same recovery contract instead of
|
||||
returning a successful-looking empty summary.
|
||||
"""
|
||||
import numpy as np
|
||||
from fastapi.testclient import TestClient
|
||||
from api.routers import capture_ws as cw
|
||||
from services import asr_backend as ab
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
spec = sd.get_spec("sherpa-zipformer-en-20m")
|
||||
monkeypatch.setattr(cw, "_select_sherpa_spec", lambda ws: spec)
|
||||
monkeypatch.setattr(ab.SherpaDictationBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab.SherpaDictationBackend, "ensure_loaded",
|
||||
lambda self: setattr(self, "_rec", _SilentOnlineRecognizer()))
|
||||
monkeypatch.setattr(sd, "is_installed", lambda _spec: True)
|
||||
|
||||
demoted = []
|
||||
monkeypatch.setattr(sd, "demote_model", lambda model_id: demoted.append(model_id) or True)
|
||||
|
||||
fallback_calls = []
|
||||
|
||||
async def fallback(chunks, *, pcm_sr=None, skip_sherpa=False):
|
||||
fallback_calls.append((b"".join(chunks), pcm_sr, skip_sherpa))
|
||||
return {
|
||||
"text": "fallback heard me",
|
||||
"segments": [{"start": 0.0, "end": 0.25, "text": "fallback heard me"}],
|
||||
"language": "en",
|
||||
"engine": "stub-fallback",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cw, "_transcribe_buffer_full", fallback)
|
||||
monkeypatch.setitem(sys.modules, "services.refinement",
|
||||
types.SimpleNamespace(maybe_refine_async=lambda t: None,
|
||||
collapse_repetitive_artifacts=lambda t: t))
|
||||
|
||||
speech = np.full(3000, 3000, dtype=np.int16).tobytes()
|
||||
from main import app
|
||||
client = TestClient(app, client=("127.0.0.1", 50000))
|
||||
with client.websocket_connect(
|
||||
"/ws/transcribe?model=sherpa-zipformer-en-20m&sr=16000"
|
||||
) as ws:
|
||||
ws.send_bytes(speech)
|
||||
ws.send_text("EOF")
|
||||
final = None
|
||||
for _ in range(10):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") == "final":
|
||||
final = msg
|
||||
break
|
||||
|
||||
assert final is not None
|
||||
assert final["text"] == "Fallback heard me."
|
||||
assert final["final_kind"] == "summary"
|
||||
assert final["engine"] == "capture-asr-fallback"
|
||||
assert final["model_silent"] == spec.id
|
||||
assert final["warning"]
|
||||
assert demoted == [spec.id]
|
||||
assert fallback_calls == [(speech, 16000, True)]
|
||||
|
||||
|
||||
def test_streaming_silent_model_does_not_download_a_fallback(monkeypatch):
|
||||
"""An installed silent Sherpa model must not trigger another model pull.
|
||||
|
||||
The session-start probe validates the selected Sherpa weights, but that
|
||||
says nothing about the fallback. Before demoting to it, recovery must
|
||||
separately prove the capture fallback is installed — otherwise it invokes
|
||||
an ASR backend that auto-downloads on a cache miss, turning a failed
|
||||
dictation into a surprise multi-gigabyte pull.
|
||||
"""
|
||||
import numpy as np
|
||||
from fastapi.testclient import TestClient
|
||||
from api.routers import capture_ws as cw
|
||||
from services import asr_backend as ab
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
spec = sd.get_spec("sherpa-zipformer-en-20m")
|
||||
monkeypatch.setattr(cw, "_select_sherpa_spec", lambda ws: spec)
|
||||
monkeypatch.setattr(ab.SherpaDictationBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab.SherpaDictationBackend, "ensure_loaded",
|
||||
lambda self: setattr(self, "_rec", _SilentOnlineRecognizer()))
|
||||
monkeypatch.setattr(sd, "is_installed", lambda _spec: True)
|
||||
monkeypatch.setattr(sd, "demote_model", lambda _model_id: True)
|
||||
|
||||
probes = []
|
||||
|
||||
def probe(**kwargs):
|
||||
probes.append(kwargs)
|
||||
if kwargs.get("sherpa_model_id") == spec.id:
|
||||
return None # selected Sherpa model is installed
|
||||
return {
|
||||
"error": "asr_model_missing",
|
||||
"missing_repo_id": "local/fallback-not-installed",
|
||||
"recommended": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(ab, "asr_model_missing_error", probe)
|
||||
fallback_calls = []
|
||||
|
||||
async def fallback(_chunks, *, pcm_sr=None, skip_sherpa=False):
|
||||
fallback_calls.append(pcm_sr)
|
||||
return {"text": "this required a download", "segments": []}
|
||||
|
||||
monkeypatch.setattr(cw, "_transcribe_buffer_full", fallback)
|
||||
|
||||
speech = np.full(3000, 3000, dtype=np.int16).tobytes()
|
||||
from main import app
|
||||
client = TestClient(app, client=("127.0.0.1", 50000))
|
||||
with client.websocket_connect(
|
||||
"/ws/transcribe?model=sherpa-zipformer-en-20m&sr=16000"
|
||||
) as ws:
|
||||
ws.send_bytes(speech)
|
||||
ws.send_text("EOF")
|
||||
final = None
|
||||
for _ in range(10):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") == "final":
|
||||
final = msg
|
||||
break
|
||||
|
||||
assert final is not None
|
||||
assert final["text"] == ""
|
||||
assert final["model_silent"] == spec.id
|
||||
assert fallback_calls == []
|
||||
assert probes == [
|
||||
{"purpose": "dictation", "sherpa_model_id": spec.id},
|
||||
{"purpose": "dictation", "skip_sherpa": True, "require_installed": True},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_silent_recovery_needs_fallback_speech_before_demotion(monkeypatch):
|
||||
"""Noise alone must not persistently disable an otherwise healthy model."""
|
||||
from api.routers import capture_ws as cw
|
||||
from services import asr_backend as ab
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
spec = sd.get_spec("sherpa-zipformer-en-20m")
|
||||
monkeypatch.setattr(ab, "asr_model_missing_error", lambda **_kwargs: None)
|
||||
demoted = []
|
||||
monkeypatch.setattr(sd, "demote_model", lambda model_id: demoted.append(model_id) or True)
|
||||
|
||||
async def silent_fallback(_chunks, *, pcm_sr=None, skip_sherpa=False):
|
||||
assert pcm_sr == 16000
|
||||
assert skip_sherpa is True
|
||||
return {"text": "", "segments": []}
|
||||
|
||||
monkeypatch.setattr(cw, "_transcribe_buffer_full", silent_fallback)
|
||||
|
||||
recovered, segments = await cw._recover_silent_sherpa(
|
||||
spec, b"\x01\x00" * 3000, 16000,
|
||||
)
|
||||
|
||||
assert recovered == ""
|
||||
assert segments == []
|
||||
assert demoted == []
|
||||
|
||||
|
||||
def test_non_streaming_model_uses_offline_handler(monkeypatch):
|
||||
"""An offline-kind sherpa model routes to the offline cadence handler and
|
||||
still finalizes (sanity that the kind branch wires up)."""
|
||||
@@ -190,6 +373,72 @@ def test_non_streaming_model_uses_offline_handler(monkeypatch):
|
||||
# Polished final (dictation v2): leading capital + terminal punctuation.
|
||||
assert final["text"] == "Offline text."
|
||||
assert final["engine"] == "sherpa-onnx-asr"
|
||||
assert final["final_kind"] == "summary"
|
||||
|
||||
|
||||
def test_offline_silent_model_does_not_download_a_fallback(monkeypatch):
|
||||
"""Offline silent-model recovery observes the same local-only gate."""
|
||||
import numpy as np
|
||||
from fastapi.testclient import TestClient
|
||||
from api.routers import capture_ws as cw
|
||||
from services import asr_backend as ab
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
spec = sd.get_spec("sherpa-whisper-tiny")
|
||||
monkeypatch.setattr(cw, "_select_sherpa_spec", lambda ws: spec)
|
||||
monkeypatch.setattr(ab.SherpaDictationBackend, "is_available",
|
||||
classmethod(lambda cls: (True, "ready")))
|
||||
monkeypatch.setattr(ab.SherpaDictationBackend, "ensure_loaded",
|
||||
lambda self: setattr(self, "_rec", object()))
|
||||
monkeypatch.setattr(ab.SherpaDictationBackend, "_decode_offline",
|
||||
lambda self, samples, sr: "")
|
||||
monkeypatch.setattr(sd, "is_installed", lambda _spec: True)
|
||||
monkeypatch.setattr(sd, "demote_model", lambda _model_id: True)
|
||||
|
||||
probes = []
|
||||
|
||||
def probe(**kwargs):
|
||||
probes.append(kwargs)
|
||||
if kwargs.get("sherpa_model_id") == spec.id:
|
||||
return None
|
||||
return {
|
||||
"error": "asr_model_missing",
|
||||
"missing_repo_id": "local/fallback-not-installed",
|
||||
"recommended": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(ab, "asr_model_missing_error", probe)
|
||||
fallback_calls = []
|
||||
|
||||
async def fallback(_chunks, *, pcm_sr=None, skip_sherpa=False):
|
||||
fallback_calls.append(pcm_sr)
|
||||
return {"text": "this required a download", "segments": []}
|
||||
|
||||
monkeypatch.setattr(cw, "_transcribe_buffer_full", fallback)
|
||||
|
||||
speech = np.full(3000, 3000, dtype=np.int16).tobytes()
|
||||
from main import app
|
||||
client = TestClient(app, client=("127.0.0.1", 50000))
|
||||
with client.websocket_connect(
|
||||
"/ws/transcribe?model=sherpa-whisper-tiny&sr=16000"
|
||||
) as ws:
|
||||
ws.send_bytes(speech)
|
||||
ws.send_text("EOF")
|
||||
final = None
|
||||
for _ in range(10):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") == "final":
|
||||
final = msg
|
||||
break
|
||||
|
||||
assert final is not None
|
||||
assert final["text"] == ""
|
||||
assert final["model_silent"] == spec.id
|
||||
assert fallback_calls == []
|
||||
assert probes == [
|
||||
{"purpose": "dictation", "sherpa_model_id": spec.id},
|
||||
{"purpose": "dictation", "skip_sherpa": True, "require_installed": True},
|
||||
]
|
||||
|
||||
|
||||
# ── Utterance-windowed offline decoding (dictation v2) ───────────────────────
|
||||
@@ -253,6 +502,8 @@ def test_offline_silence_gate_commits_mid_session(monkeypatch):
|
||||
# final. The old behavior produced exactly one (everything at EOF).
|
||||
assert len(finals) >= 2, f"silence gate never committed mid-session: {msgs}"
|
||||
assert finals[0]["text"] == "Utterance one."
|
||||
assert all(m["final_kind"] == "utterance" for m in finals[:-1])
|
||||
assert finals[-1]["final_kind"] == "summary"
|
||||
# EOF final = committed pieces + the drained live tail (utterance 2).
|
||||
assert finals[-1]["text"] == "Utterance one. Utterance one."
|
||||
# O(n²) fix: every decode was bounded by ONE utterance window — never a
|
||||
|
||||
Reference in New Issue
Block a user