feat(dictation): rebuild to Wispr-Flow quality — live waveform, streaming commits, honest insertion, polished text (#888)

* feat(dictation): rebuild to instant-feedback quality — waveform, streaming commits, honest insertion, text polish

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): dictation rebuild entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lint): Array.from over new Array(n) — oxlint no-array-constructor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-02 17:57:56 +05:30
committed by GitHub
co-authored by Claude Fable 5 mergetest
parent da9315815d
commit d58010fe1b
16 changed files with 1837 additions and 296 deletions
+11
View File
@@ -10,6 +10,17 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Added
- **Dictation, rebuilt.** The dictation pill now shows a live waveform the
moment the mic opens, streams words as you speak with real download/loading
progress on first use, and finishes what you say in about half a second of
silence instead of two-and-a-half. Transcripts come out properly
capitalized and punctuated. Text insertion is now honest and safe: your
clipboard is preserved and restored, failures show what to do (including a
one-click jump to macOS Accessibility settings when permission is missing)
instead of a false "Pasted", and Esc cancels cleanly at any point. The
dictation model also pre-warms in the background after launch, so the first
press of the hotkey no longer sits on a cold model load.
- **LLM Providers: one-click connection testing with real diagnostics.** The
Test button in Settings → LLM Providers now measures round-trip latency and
turns failures into plain-language guidance — bad key (401/403), wrong
+131 -35
View File
@@ -19,7 +19,15 @@ Protocol:
"segments": [...], "language": "en",
"duration_s": 4.2, "transcription_time_s": 0.8,
"engine": "mlx-whisper"}
{"type": "error", "detail": "..."} — error
{"type": "status", "stage": "downloading"|"loading"|"ready"}
— model cold-start
{"type": "error", "message": "...", "kind": "...",
"detail": "..."} — error ("detail"
kept for legacy)
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.
"""
from __future__ import annotations
@@ -32,6 +40,7 @@ import time
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from api.dependencies import _LOOPBACK_HOSTS, ws_remote_authorized
from services.text_polish import polish_text
router = APIRouter()
logger = logging.getLogger("omnivoice.capture_ws")
@@ -302,6 +311,9 @@ async def ws_transcribe(websocket: WebSocket):
if total_bytes > MIN_FINAL_BUFFER_BYTES:
try:
result = await _transcribe_buffer_full(audio_chunks, pcm_sr=pcm_sr)
# Dictation v2: deterministic polish so the pasted final reads
# like typed text (leading capital, terminal punctuation).
result["text"] = polish_text(result.get("text", ""))
# Wave 2.1: optional local-LLM refinement of the final text.
# Off-thread (network call, not GPU); pass-through on any
# failure or when no LLM backend is configured. The raw text
@@ -315,7 +327,8 @@ async def ws_transcribe(websocket: WebSocket):
logger.debug("Skipped final send — client already disconnected")
except Exception as e:
logger.error("Final transcription failed: %s", e)
await _safe_send({"type": "error", "detail": str(e)})
await _safe_send({"type": "error", "message": str(e),
"kind": "transcribe", "detail": str(e)})
else:
await _safe_send({
"type": "final",
@@ -340,10 +353,19 @@ async def ws_transcribe(websocket: WebSocket):
# opt-in 1-byte type prefix when ?aec=1, else bare PCM) at ?sr= (default 16000).
# This is the low-latency transport — no WebM/ffmpeg in the hot path.
# How often the offline-kind handler re-decodes the growing buffer for a live
# partial (streaming-kind decodes every frame, no cadence needed).
# How often the offline-kind handler re-decodes the live window for a partial
# (streaming-kind decodes every frame, no cadence needed).
SHERPA_OFFLINE_PARTIAL_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_PARTIAL", "0.8"))
# Utterance gate for the offline-kind handler: once the trailing this-many
# seconds of the live buffer fall below the RMS floor, the utterance is
# COMMITTED — decoded, flushed as a `final`, and dropped from the buffer. Each
# decode is thereby bounded by one utterance instead of the whole session
# (the old full-buffer re-decode was O(n²)), and a sentence commits ~0.6s
# after the user stops speaking instead of only at EOF.
SHERPA_OFFLINE_SILENCE_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_SILENCE", "0.6"))
SHERPA_OFFLINE_RMS_FLOOR = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_RMS", "0.01"))
def _pcm16_to_f32(pcm: bytes):
"""int16 little-endian mono PCM bytes → float32 numpy in [-1, 1]."""
@@ -411,6 +433,43 @@ async def _recv_pcm_frame(websocket: WebSocket, aec):
return "skip", b""
async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
"""Build the recognizer off the event loop, narrating cold-start progress.
Sends ``{"type":"status","stage":"downloading"|"loading"}`` before the
load ("downloading" when the pinned assets aren't in the HF cache yet;
stage-only — HF's per-file progress isn't worth a callback plumb-through)
and ``{"type":"status","stage":"ready"}`` after, so the widget can show
*why* the first dictation takes a moment. Returns False when the load
failed (the error frame is sent and the socket closed here).
"""
try:
from services import sherpa_dictation as _sd
stage = "loading" if _sd.is_installed(spec) else "downloading"
except Exception:
stage = "loading"
try:
await websocket.send_json({"type": "status", "stage": stage})
except Exception:
pass
try:
await asyncio.to_thread(backend.ensure_loaded)
except Exception as e:
logger.error("sherpa dictation load failed (%s): %s", spec.id, e)
try:
await websocket.send_json({"type": "error", "message": str(e),
"kind": "load", "detail": str(e)})
await websocket.close()
except Exception:
pass
return False
try:
await websocket.send_json({"type": "status", "stage": "ready"})
except Exception:
pass
return True
async def _run_sherpa_streaming(websocket: WebSocket, spec):
"""True streaming: feed the OnlineRecognizer frame-by-frame, emit `partial`
every time the decoded text grows, and `final` on sherpa's endpoint (silence)
@@ -425,16 +484,8 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
backend = SherpaDictationBackend(model_id=spec.id)
# Build the recognizer off the event loop (download-on-first-use + ONNX
# session init can take a moment); keep the socket responsive.
try:
await asyncio.to_thread(backend.ensure_loaded)
except Exception as e:
logger.error("sherpa streaming load failed: %s", e)
try:
await websocket.send_json({"type": "error", "detail": str(e)})
await websocket.close()
except Exception:
pass
# session init can take a moment); status frames keep the widget honest.
if not await _sherpa_load_with_status(websocket, backend, spec):
return
rec = backend._rec
stream = rec.create_stream()
@@ -484,7 +535,9 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
continue
text, endpoint = await asyncio.to_thread(_decode_after_feed, pcm)
if endpoint:
# Commit this utterance; reset for the next one.
# Commit this utterance (polished — it gets pasted); reset
# for the next one.
text = polish_text(text)
if text:
committed.append(text)
await _send({"type": "final", "text": text,
@@ -507,9 +560,11 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
except Exception as e:
logger.debug("sherpa streaming flush failed: %s", e)
tail_text = ""
tail_text = polish_text(tail_text)
if tail_text and tail_text != (committed[-1] if committed else None):
committed.append(tail_text)
# 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]
if not client_disconnected:
@@ -534,9 +589,15 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
async def _run_sherpa_offline(websocket: WebSocket, spec):
"""Offline-kind sherpa model with live partials: buffer raw PCM and
re-decode the growing buffer every ~800ms so the user still sees text
appear while speaking; finalize on EOF/silence."""
"""Offline-kind sherpa model with live partials, utterance-windowed.
Raw PCM accumulates in a *live* buffer holding only the current
(uncommitted) utterance. Every ~800ms the live window is re-decoded for a
``partial``; when the trailing ~0.6s of it fall below the RMS floor the
utterance is committed — decoded once more, flushed as a ``final``, and
its samples dropped — so per-partial cost is bounded by one utterance
(not the whole session) and sentences commit as the user pauses instead
of only at EOF."""
from services.asr_backend import SherpaDictationBackend
pcm_sr, aec = await _sherpa_session(websocket)
@@ -544,22 +605,17 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
spec.id, pcm_sr, bool(aec))
backend = SherpaDictationBackend(model_id=spec.id)
try:
await asyncio.to_thread(backend.ensure_loaded)
except Exception as e:
logger.error("sherpa offline load failed: %s", e)
try:
await websocket.send_json({"type": "error", "detail": str(e)})
await websocket.close()
except Exception:
pass
if not await _sherpa_load_with_status(websocket, backend, spec):
return
buf = bytearray()
buf = bytearray() # live (uncommitted) PCM only
committed: list[str] = [] # polished utterances already flushed
last_partial = ""
running = True
client_disconnected = False
last_audio = time.monotonic()
# Trailing-silence gate window, in bytes of int16 mono PCM.
sil_bytes = max(2, int(SHERPA_OFFLINE_SILENCE_S * pcm_sr) * 2)
async def _send(payload) -> bool:
nonlocal client_disconnected
@@ -572,8 +628,14 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
client_disconnected = True
return False
def _decode_buffer() -> str:
samples = _pcm16_to_f32(bytes(buf))
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):
return ""
return backend._decode_offline(samples, pcm_sr)
@@ -597,14 +659,43 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
logger.debug("sherpa offline receive ended: %s", e)
running = False
async def _commit(snapshot: bytes):
"""Finalize one utterance: decode it off-thread, flush a polished
`final`, drop its samples from the live buffer. `receive()` may
append while we decode — only the snapshot's prefix is dropped."""
nonlocal last_partial
try:
text = await asyncio.to_thread(_decode_window, snapshot)
except Exception as e:
logger.debug("sherpa offline commit decode failed: %s", e)
return
del buf[:len(snapshot)]
last_partial = ""
text = polish_text(text)
if text:
committed.append(text)
await _send({"type": "final", "text": text,
"segments": [{"start": 0.0, "end": None, "text": text}],
"language": "auto", "engine": backend.id})
async def partials():
nonlocal last_partial, running
while running:
await asyncio.sleep(SHERPA_OFFLINE_PARTIAL_S)
if not running or len(buf) < 2000:
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:
await _commit(snapshot)
else:
# Pure silence — drop it (keep the gate window for
# continuity) so a long pause can't grow the buffer.
del buf[:len(snapshot) - sil_bytes]
continue
try:
text = await asyncio.to_thread(_decode_buffer)
text = await asyncio.to_thread(_decode_window, snapshot)
except Exception as e:
logger.debug("sherpa offline partial failed: %s", e)
continue
@@ -624,13 +715,18 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
except (asyncio.CancelledError, Exception):
pass
# Drain the trailing (un-committed) utterance on EOF.
try:
full = await asyncio.to_thread(_decode_buffer)
tail = await asyncio.to_thread(_decode_window, bytes(buf))
except Exception as e:
logger.error("sherpa offline final failed: %s", e)
full = ""
full = (full or "").strip()
segments = [{"start": 0.0, "end": None, "text": full}] if full else []
tail = ""
tail = polish_text(tail)
if tail:
committed.append(tail)
# Pieces are already polished; the join is too (polish is idempotent).
full = " ".join(committed).strip()
segments = [{"start": 0.0, "end": None, "text": t} for t in committed]
if not client_disconnected:
payload = {"type": "final", "text": full, "segments": segments,
"language": "auto", "engine": backend.id}
+38 -4
View File
@@ -387,6 +387,32 @@ def _env_flag(name: str, default: bool = False) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}
def _capture_preload_delay_s() -> float:
"""Seconds after boot before the dictation (capture ASR) model warms.
Late enough that it never competes with startup I/O or the TTS preload;
overridable via OMNIVOICE_CAPTURE_PRELOAD_DELAY (mostly for tests)."""
raw = os.environ.get("OMNIVOICE_CAPTURE_PRELOAD_DELAY", "")
try:
v = float(raw)
if v >= 0:
return v
except (TypeError, ValueError):
pass
return 30.0
def _capture_preload_ram_ok(min_free_bytes: int = 4 * 1024**3) -> bool:
"""RAM guard for the dictation warm-up: skip below 4 GB free so the
background load never pushes a small machine into swap. If free memory
can't be measured, warm anyway (the load path has its own error handling)."""
try:
import psutil
return psutil.virtual_memory().available >= min_free_bytes
except Exception:
return True
def _mcp_start_timeout_s() -> float:
"""Seconds to wait for the MCP session manager to start before giving up
and serving without it (#632). Overridable via OMNIVOICE_MCP_START_TIMEOUT_S."""
@@ -519,11 +545,19 @@ async def lifespan(app: FastAPI):
worker_task = asyncio.create_task(task_manager.worker())
# Warm the TTS model in the background so first /generate is instant.
preload_task = asyncio.create_task(preload_model())
# Capture ASR is useful to keep warm, but it is another large model in
# unified memory on Apple Silicon. Keep launch lean by default; users who
# prefer instant dictation can opt in with OMNIVOICE_PRELOAD_CAPTURE_ASR=1.
if _env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR"):
# Dictation v2: the capture ASR warms in the background BY DEFAULT — a
# deferred (~30s post-boot) load off the event loop, so startup stays
# lean and the first dictation is instant instead of a cold model load.
# OMNIVOICE_PRELOAD_CAPTURE_ASR=0 opts out; the warm-up is also skipped
# under 4 GB free RAM (checked at warm time, not boot time).
if _env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR", default=True):
async def _preload_capture_asr():
await asyncio.sleep(_capture_preload_delay_s())
if not _capture_preload_ram_ok():
logger.info(
"Capture ASR preload skipped: <4GB free RAM; "
"dictation ASR will load on first use.")
return
loading_detail = None
prev_loading_detail = None
try:
+22 -4
View File
@@ -34,6 +34,23 @@ _PROVIDER = os.environ.get("OMNIVOICE_SHERPA_ASR_PROVIDER", "cpu")
_NUM_THREADS = int(os.environ.get("OMNIVOICE_SHERPA_ASR_THREADS", "2"))
def _endpoint_rules() -> tuple[float, float]:
"""Trailing-silence endpoint rules (seconds) for streaming recognizers.
Wispr-Flow-speed defaults (dictation v2): rule2 commits ~0.6s after speech
stops, rule1 flushes after 1.0s of trailing non-speech — down from the
upstream 2.4/1.2, which made every committed sentence feel laggy. Read at
call time so the env overrides apply without a restart.
"""
def _f(env: str, default: float) -> float:
try:
return float(os.environ.get(env, "") or default)
except (TypeError, ValueError):
return default
return (_f("OMNIVOICE_DICTATION_ENDPOINT_R1", 1.0),
_f("OMNIVOICE_DICTATION_ENDPOINT_R2", 0.6))
@dataclass(frozen=True)
class SherpaModelSpec:
"""One downloadable sherpa-onnx dictation model.
@@ -282,6 +299,7 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
import sherpa_onnx
d = _resolve_model_dir(spec, download=download)
rule1, rule2 = _endpoint_rules()
def p(role: str) -> str:
return os.path.join(d, spec.files[role])
@@ -296,8 +314,8 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
provider=_PROVIDER,
decoding_method="greedy_search",
enable_endpoint_detection=True,
rule1_min_trailing_silence=2.4,
rule2_min_trailing_silence=1.2,
rule1_min_trailing_silence=rule1,
rule2_min_trailing_silence=rule2,
rule3_min_utterance_length=20,
)
if spec.kind == "online-paraformer":
@@ -309,8 +327,8 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
provider=_PROVIDER,
decoding_method="greedy_search",
enable_endpoint_detection=True,
rule1_min_trailing_silence=2.4,
rule2_min_trailing_silence=1.2,
rule1_min_trailing_silence=rule1,
rule2_min_trailing_silence=rule2,
rule3_min_utterance_length=20,
)
raise ValueError(f"{spec.id} is not a streaming model (kind={spec.kind})")
+79
View File
@@ -0,0 +1,79 @@
"""
Deterministic polish for dictation finals (dictation v2).
Every ``final`` that leaves ``/ws/transcribe`` passes through
:func:`polish_text` so pasted dictation reads like typed text:
* leading capital -- Latin scripts only (CJK/Cyrillic/etc. untouched),
* terminal punctuation -- a period is appended unless the text already
ends with sentence-terminal punctuation (incl. the CJK fullwidth forms),
* doubled spaces collapsed, leading/trailing whitespace stripped.
Purely rule-based -- no model, no locale detection, no network -- so it is
byte-for-byte reproducible and idempotent (``polish(polish(x)) == polish(x)``).
CJK codepoints below are ``\\u``-escaped on purpose: this is functional
punctuation handling (allowed), and the escapes keep this file outside the
literal-CJK scan in ``tests/test_no_hardcoded_cjk.py`` without growing its
allowlist.
"""
from __future__ import annotations
import re
# Sentence-terminal punctuation that already "closes" a final -- Latin plus
# the CJK fullwidth forms (U+3002 ideographic full stop, U+FF01 !, U+FF1F ?)
# and ellipsis. A trailing closing quote/bracket after one of these still
# counts as terminated ("He said \"hi.\"").
_TERMINAL = ".!?\u2026\u3002\uff01\uff1f"
_CLOSERS = "\"'\u201d\u2019\u00bb\u203a)]}\u300d\u300f\uff09\u3011"
# A dangling clause separator at the very end (ASR often stops mid-breath on
# a comma) is swapped for a stop instead of stacking ",." punctuation.
# Latin , ; : plus the CJK forms U+3001 U+FF0C U+FF1B U+FF1A.
_DANGLING = ",;:\u3001\uff0c\uff1b\uff1a"
# CJK codepoints (kana, unified ideographs, compatibility + halfwidth forms)
# -- used to pick the fullwidth stop U+3002 over "." for CJK sentences.
_CJK = re.compile(
"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]"
)
_MULTISPACE = re.compile(r"[ \t]{2,}")
def _is_latin_lower(ch: str) -> bool:
"""Lowercase letter in a Latin block (ASCII, Latin-1, Latin Extended-A/B).
Capitalization is meaningless (CJK) or presumptuous (Cyrillic, Greek --
the model's casing is trusted) outside Latin scripts.
"""
return ch.islower() and ord(ch) <= 0x024F
def polish_text(text: str) -> str:
"""Normalise one dictation final. Empty/whitespace-only input -> ``""``."""
if not text:
return ""
out = _MULTISPACE.sub(" ", text).strip()
if not out:
return ""
# Leading capital (Latin scripts only).
if _is_latin_lower(out[0]):
out = out[0].upper() + out[1:]
# Already terminated -- possibly behind a closing quote/bracket?
body = out.rstrip(_CLOSERS)
if body and body[-1] in _TERMINAL:
return out
# Swap a dangling comma/colon for the stop instead of stacking ",.".
if out[-1] in _DANGLING:
out = out[:-1].rstrip()
if not out:
return ""
# Script-matched stop: fullwidth U+3002 when the sentence ends in CJK.
out += "\u3002" if _CJK.search(out[-1]) else "."
return out
+132 -13
View File
@@ -257,43 +257,121 @@ fn hf_hub_cache_dir() -> PathBuf {
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
/// 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.
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.
#[cfg(target_os = "macos")]
fn accessibility_trusted() -> bool {
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
}
unsafe { AXIsProcessTrusted() }
}
/// True when the app may synthesize keyboard input. On macOS this is the
/// Accessibility grant (System Settings → Privacy & Security → Accessibility);
/// other OSes don't gate synthetic input behind a permission, so always true.
#[tauri::command]
pub fn check_accessibility() -> bool {
#[cfg(target_os = "macos")]
{
accessibility_trusted()
}
#[cfg(not(target_os = "macos"))]
{
true
}
}
/// Deep-link into the macOS Privacy → Accessibility pane so the widget can
/// walk the user straight to the toggle an "a11y:" error asked for. No-op on
/// other OSes (nothing to grant there).
#[tauri::command]
pub fn open_accessibility_settings() {
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.spawn();
}
}
#[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).
#[cfg(target_os = "macos")]
if !accessibility_trusted() {
return Err(kind_err("a11y", "accessibility permission not granted"));
}
// 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| format!("clipboard init failed: {e}"))?;
.map_err(|e| kind_err("clipboard", format!("init failed: {e}")))?;
saved = cb.get_text().ok();
cb.set_text(t)
.map_err(|e| format!("clipboard write failed: {e}"))?;
.map_err(|e| kind_err("clipboard", format!("write failed: {e}")))?;
}
std::thread::sleep(Duration::from_millis(80));
let mut enigo = Enigo::new(&EnigoSettings::default())
.map_err(|e| format!("Failed to init keyboard sim: {e}"))?;
.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| format!("key press failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
enigo.key(Key::Unicode('v'), Direction::Click)
.map_err(|e| format!("key click failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
enigo.key(Key::Meta, Direction::Release)
.map_err(|e| format!("key release failed: {e}"))?;
.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| format!("key press failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
enigo.key(Key::Unicode('v'), Direction::Click)
.map_err(|e| format!("key click failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
enigo.key(Key::Control, Direction::Release)
.map_err(|e| format!("key release failed: {e}"))?;
.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(())
@@ -316,24 +394,32 @@ pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
///
/// 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.
/// that segment without double-inserting. Errors carry the same kind
/// prefixes as `simulate_paste` ("a11y:" | "paste:").
#[tauri::command]
pub fn simulate_type(text: Option<String>, backspaces: Option<u32>) -> Result<(), 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")]
if !accessibility_trusted() {
return Err(kind_err("a11y", "accessibility permission not granted"));
}
let mut enigo = Enigo::new(&EnigoSettings::default())
.map_err(|e| format!("Failed to init keyboard sim: {e}"))?;
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
let n = backspaces.unwrap_or(0);
for _ in 0..n {
enigo
.key(Key::Backspace, Direction::Click)
.map_err(|e| format!("backspace failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("backspace failed: {e}")))?;
}
if let Some(t) = text {
if !t.is_empty() {
enigo
.text(&t)
.map_err(|e| format!("type failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("type failed: {e}")))?;
}
}
@@ -441,3 +527,36 @@ pub fn save_text_file(path: String, contents: String) -> Result<(), String> {
}
std::fs::write(p, contents).map_err(|e| format!("write: {e}"))
}
#[cfg(test)]
mod paste_error_tests {
use super::{kind_err, CLIPBOARD_RESTORE_DELAY};
#[test]
fn kind_err_prefixes_with_kind() {
assert_eq!(kind_err("a11y", "not granted"), "a11y:not granted");
assert_eq!(
kind_err("clipboard", "write failed: busy"),
"clipboard:write failed: busy"
);
assert_eq!(
kind_err("paste", "key press failed"),
"paste:key press failed"
);
}
#[test]
fn kind_survives_colons_in_detail() {
// The widget does `err.split(':')[0]` — details containing ':' (OS
// error strings usually do) must not corrupt the kind.
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);
}
}
+2
View File
@@ -256,6 +256,8 @@ pub fn run() {
commands::hf_cache_scan,
commands::simulate_paste,
commands::simulate_type,
commands::check_accessibility,
commands::open_accessibility_settings,
commands::set_tray_recording,
commands::quit_app,
commands::save_text_file,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
/**
* CaptureWidget pill behaviour mocked WS + Tauri invoke.
*
* Covers the truthfulness rebuild: model status frames render real
* download/load progress, "Pasted" only appears after simulate_paste resolves
* Ok, an "a11y:"-prefixed paste failure renders the actionable Accessibility
* error, Esc aborts without pasting, live retract-retype is opt-in (default
* sessions never call simulate_type), the missing-Accessibility setup state
* shows on mount, and the waveform bars move from real mic frames.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '../i18n';
// Hoisted mock state (vi.mock factories may only reference vi.hoisted vars)
const mocks = vi.hoisted(() => {
const state = {
dictationEnabled: true,
dictationMode: 'toggle',
dictationModelId: 'sherpa-parakeet-tdt-v3', // sherpa raw-PCM live path
aecEnabled: false,
loadDictationPrefs: () => {},
};
const holder = {
// Per-test knobs for the Tauri invoke mock.
a11y: true,
paste: async () => undefined,
calls: [],
// Captured micCapture frame callback (the worklet feed).
onFrame: null,
};
return {
state,
holder,
invoke: async (cmd, args) => {
holder.calls.push([cmd, args]);
if (cmd === 'check_accessibility') return holder.a11y;
if (cmd === 'simulate_paste') return holder.paste();
return undefined;
},
};
});
vi.mock('../store', () => ({
useAppStore: Object.assign((sel) => sel(mocks.state), { getState: () => mocks.state }),
}));
vi.mock('../api/client', () => ({
wsUrl: (p) => `ws://test${p}`,
apiFetch: vi.fn(async () => ({ json: async () => ({}) })),
}));
vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() }));
vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) }));
vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke }));
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => {}) }));
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ hide: vi.fn(async () => {}) }),
}));
vi.mock('../utils/aec/micCapture', () => ({
startMicCapture: async (stream, onFrame) => {
mocks.holder.onFrame = onFrame;
return async () => {};
},
}));
import CaptureWidget from './CaptureWidget';
// Browser API fakes (jsdom has neither WebSocket use here nor MediaRecorder)
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances = [];
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.OPEN; // pretend the connect is instant
this.sent = [];
this._listeners = {};
FakeWebSocket.instances.push(this);
}
addEventListener(type, fn) {
(this._listeners[type] ||= []).push(fn);
}
send(d) {
this.sent.push(d);
}
close() {
if (this.readyState === FakeWebSocket.CLOSED) return;
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.();
}
/** Deliver a backend JSON frame. */
msg(obj) {
this.onmessage?.({ data: JSON.stringify(obj) });
}
}
class FakeMediaRecorder {
static isTypeSupported() {
return true;
}
constructor() {
this.state = 'inactive';
}
start() {
this.state = 'recording';
}
stop() {
this.state = 'inactive';
}
}
function withI18n(node) {
return <I18nextProvider i18n={i18n}>{node}</I18nextProvider>;
}
// Start a session via the in-page shortcut and wait for the live socket.
async function startSession() {
fireEvent.keyDown(window, { code: 'Space', ctrlKey: true, shiftKey: true });
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
await screen.findByText(/Listening/);
return FakeWebSocket.instances[0];
}
describe('CaptureWidget', () => {
beforeEach(() => {
window.__TAURI_INTERNALS__ = {};
mocks.holder.a11y = true;
mocks.holder.paste = async () => undefined;
mocks.holder.calls = [];
mocks.holder.onFrame = null;
FakeWebSocket.instances = [];
global.WebSocket = FakeWebSocket;
global.MediaRecorder = FakeMediaRecorder;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
});
localStorage.clear();
});
afterEach(() => {
delete window.__TAURI_INTERNALS__;
delete global.WebSocket;
delete global.MediaRecorder;
});
const pasteCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_paste');
const typeCalls = () => mocks.holder.calls.filter(([c]) => c === 'simulate_type');
it('renders truthful model status from {type:"status"} frames', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
act(() => ws.msg({ type: 'status', stage: 'downloading', progress: 0.42 }));
expect(screen.getByText(/Downloading voice model/)).toBeInTheDocument();
expect(screen.getByText(/42%/)).toBeInTheDocument();
act(() => ws.msg({ type: 'status', stage: 'loading' }));
expect(screen.getByText(/Loading model/)).toBeInTheDocument();
act(() => ws.msg({ type: 'status', stage: 'ready' }));
expect(screen.getByText(/Listening/)).toBeInTheDocument();
});
it('shows "Pasted" only after simulate_paste resolved Ok', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
// Offline-model shape: one utterance final, then the EOF summary.
act(() => ws.msg({ type: 'final', text: 'hello world' }));
act(() => ws.msg({ type: 'final', text: 'hello world' }));
await screen.findByText(/Pasted/);
expect(pasteCalls().length).toBeGreaterThan(0);
expect(pasteCalls()[0][1]).toEqual({ text: 'hello world' });
});
it('an "a11y:" paste rejection renders the actionable error, never "Pasted"', async () => {
mocks.holder.paste = async () => {
throw 'a11y: process is not trusted';
};
render(withI18n(<CaptureWidget />));
const ws = await startSession();
act(() => ws.msg({ type: 'final', text: 'hello world' }));
act(() => ws.msg({ type: 'final', text: 'hello world' }));
await screen.findByText(/Accessibility access needed/);
expect(screen.queryByText(/Pasted/)).not.toBeInTheDocument();
// The action button opens the OS Accessibility pane.
fireEvent.click(screen.getByText('Open Settings'));
await waitFor(() =>
expect(mocks.holder.calls.some(([c]) => c === 'open_accessibility_settings')).toBe(true),
);
});
it('Esc during recording aborts: socket closed, nothing pasted, pill gone', async () => {
const { container } = render(withI18n(<CaptureWidget />));
const ws = await startSession();
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() => expect(container.querySelector('.capture-pill')).toBeNull());
expect(ws.readyState).toBe(FakeWebSocket.CLOSED);
expect(pasteCalls()).toEqual([]);
});
it('live retract-retype is OFF by default: partials never simulate_type', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
act(() => ws.msg({ type: 'partial', text: 'hel' }));
act(() => ws.msg({ type: 'partial', text: 'hello wor' }));
act(() => ws.msg({ type: 'final', text: 'hello world' }));
act(() => ws.msg({ type: 'final', text: 'hello world' }));
await screen.findByText(/Pasted/);
// Committed final went through the paste path; no keystroke storms.
expect(typeCalls()).toEqual([]);
expect(pasteCalls().length).toBeGreaterThan(0);
});
it('the LS_LIVE_TYPING pref opts back into word-by-word typing', async () => {
localStorage.setItem('omni_capture_live_typing', '1');
render(withI18n(<CaptureWidget />));
const ws = await startSession();
act(() => ws.msg({ type: 'partial', text: 'hello' }));
await waitFor(() => expect(typeCalls().length).toBeGreaterThan(0));
expect(typeCalls()[0][1]).toEqual({ text: 'hello', backspaces: 0 });
});
it('a refined EOF summary is not re-pasted as a new utterance', async () => {
render(withI18n(<CaptureWidget />));
const ws = await startSession();
// Two per-utterance commits paste live
act(() => ws.msg({ type: 'final', text: 'Hello world.' }));
act(() => ws.msg({ type: 'final', text: 'Second bit.' }));
// then the EOF summary arrives with an LLM-refined variant. Its raw
// `text` equals the committed join, so it must finalise never paste
// the whole (refined) transcript a third time.
act(() =>
ws.msg({
type: 'final',
text: 'Hello world. Second bit.',
refined_text: 'Hello world, second bit.',
}),
);
await screen.findByText(/Pasted/);
expect(pasteCalls().map(([, a]) => a.text)).toEqual(['Hello world.', 'Second bit.']);
});
it('renders the one-time Accessibility setup state when the mount probe fails', async () => {
mocks.holder.a11y = false;
render(withI18n(<CaptureWidget />));
await screen.findByText(/Allow Accessibility/);
expect(screen.getByText('Open Settings')).toBeInTheDocument();
// It does not pretend to record.
expect(screen.queryByText(/Listening/)).not.toBeInTheDocument();
});
it('waveform bars move from the worklet mic frames', async () => {
const { container } = render(withI18n(<CaptureWidget />));
await startSession();
expect(mocks.holder.onFrame).toBeTypeOf('function');
// Feed ~5 frames of speech-level audio (100 ms at 20 ms/frame).
for (let i = 0; i < 5; i++) mocks.holder.onFrame(new Float32Array(320).fill(0.5));
await waitFor(() => {
const bars = container.querySelectorAll('.capture-pill__wave-bar');
expect(bars.length).toBe(12);
const heights = [...bars].map((b) => parseInt(b.style.height, 10));
expect(Math.max(...heights)).toBeGreaterThan(12); // above the silence floor
});
});
});
@@ -0,0 +1,83 @@
/**
* captureWaveform pure ring-buffer model behind the capture pill's live
* waveform. No Web Audio, no DOM: the widget feeds it the Float32 frames the
* EXISTING micCapture AudioWorklet already emits (~20 ms each at 16 kHz) and
* polls `getBars(n)` to draw. Kept pure so the envelope math is unit-testable
* without an AudioContext (mirrors utils/aec/pcm.js).
*
* const wave = createWaveform();
* wave.push(frame); // per worklet frame (Float32Array | Int16Array | 0..1 number)
* wave.getBars(12); // → 12 smoothed 0..1 bar heights, oldest → newest
*/
/**
* RMS level of one PCM frame, in [0, 1]. Accepts Float32 samples in [-1, 1]
* or Int16Array samples (normalised by 32768). Empty/absent frames are silence.
*/
export function frameRms(frame) {
if (!frame || !frame.length) return 0;
const scale = frame instanceof Int16Array ? 1 / 32768 : 1;
let sum = 0;
for (let i = 0; i < frame.length; i++) {
const s = frame[i] * scale;
sum += s * s;
}
const rms = Math.sqrt(sum / frame.length);
return rms > 1 ? 1 : rms;
}
/**
* Create a waveform ring buffer.
*
* `attack`/`release` are asymmetric EMA coefficients: a loud frame pulls the
* envelope up fast (bars visibly move within a frame or two of speech the
* ~100 ms liveness budget), silence lets it fall smoothly instead of
* flickering to zero between words.
*
* @param {{capacity?: number, attack?: number, release?: number}} opts
* @returns {{push(frame: Float32Array|Int16Array|number): number,
* getBars(n: number): number[],
* reset(): void}}
*/
export function createWaveform({ capacity = 48, attack = 0.6, release = 0.25 } = {}) {
const ring = new Float32Array(capacity);
let head = 0; // next write index
let count = 0; // total levels stored (caps at capacity)
let level = 0; // smoothed envelope carried across pushes
return {
/** Ingest one frame (or a precomputed 0..1 RMS); returns the new level. */
push(frame) {
const rms = typeof frame === 'number' ? Math.min(Math.max(frame, 0), 1) : frameRms(frame);
level += (rms > level ? attack : release) * (rms - level);
ring[head] = level;
head = (head + 1) % capacity;
if (count < capacity) count++;
return level;
},
/**
* The most recent `n` levels as 0..1 bar heights, oldest newest (newest
* is the last entry, i.e. the right edge of the pill). Slots with no data
* yet render as 0. Heights are sqrt-mapped so quiet-but-real speech
* (RMS 0.05) still reads as movement.
*/
getBars(n) {
const bars = Array.from({ length: n }, () => 0);
const take = Math.min(n, count);
for (let i = 0; i < take; i++) {
const idx = (head - 1 - i + capacity * 2) % capacity;
bars[n - 1 - i] = Math.sqrt(ring[idx]);
}
return bars;
},
/** Clear all state (new recording session). */
reset() {
ring.fill(0);
head = 0;
count = 0;
level = 0;
},
};
}
+9
View File
@@ -552,7 +552,16 @@
"listening_label": "Listening…",
"transcribing_label": "Transcribing…",
"pasted": "Pasted",
"copied": "Copied to clipboard",
"no_speech": "No speech detected",
"model_downloading": "Downloading voice model…",
"model_downloading_pct": "Downloading voice model… {{percent}}%",
"model_loading": "Loading model…",
"a11y_setup": "Allow Accessibility so dictation can type for you",
"a11y_error": "Can't paste — Accessibility access needed",
"open_a11y_settings": "Open Settings",
"clipboard_error": "Couldn't copy to the clipboard",
"paste_error": "Couldn't paste into the active app",
"mic_denied": "Mic access denied",
"mic_denied_toast": "Microphone access denied. {{hint}}",
"mic_hint_mac": "macOS: open System Settings → Privacy & Security → Microphone and enable OmniVoice.",
+35 -1
View File
@@ -2808,7 +2808,9 @@ input[type="file"]::file-selector-button:hover {
gap: 10px;
padding: 10px 16px;
min-width: 180px;
max-width: 280px;
/* Wide enough for the waveform + status labels ("Downloading voice
model 42%") and the Accessibility action button. */
max-width: 340px;
height: 48px;
background: rgba(18, 18, 22, 0.88);
backdrop-filter: blur(24px) saturate(180%);
@@ -2855,6 +2857,10 @@ input[type="file"]::file-selector-button:hover {
border-color: rgba(239, 68, 68, 0.3);
}
.capture-pill--setup {
border-color: rgba(245, 158, 11, 0.35);
}
/* ── Animations ─────────────────────────────────────────────────────── */
@keyframes pill-slide-in {
@@ -2901,11 +2907,38 @@ input[type="file"]::file-selector-button:hover {
animation: none;
}
.capture-pill--setup .capture-pill__dot {
background: #f59e0b;
animation: none;
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.6); }
}
/* Live waveform (recording)
Data-driven bars heights come from the captureWaveform ring buffer
(client-side RMS of the mic worklet frames), not a CSS animation, so the
movement is the actual signal. */
.capture-pill__wave {
display: flex;
align-items: center;
gap: 2px;
width: 44px;
height: 20px;
flex-shrink: 0;
}
.capture-pill__wave-bar {
flex: 1 1 0;
min-height: 2px;
border-radius: 1px;
background: #ef4444;
transition: height 60ms linear;
}
/* Content / timer / dismiss
Layout + (theme-less, hardcoded-white) colors for the pill's content,
label, timer, transcribing spinner, and dismiss button now live as
@@ -2918,6 +2951,7 @@ input[type="file"]::file-selector-button:hover {
@media (prefers-reduced-motion: reduce) {
.capture-pill { animation: none; opacity: 1; }
.capture-pill__dot { animation: none !important; }
.capture-pill__wave-bar { transition: none; }
}
/* ── Widget-mode body (standalone Tauri window) ─────────────────────── */
+124 -1
View File
@@ -7,7 +7,13 @@
* the heart of the "text appears as you speak" behaviour.
*/
import { describe, it, expect } from 'vitest';
import { isSherpaModel, classifySherpaFinal, computeTypeDelta } from '../components/CaptureWidget';
import {
isSherpaModel,
classifySherpaFinal,
computeTypeDelta,
parsePasteError,
} from '../components/CaptureWidget';
import { frameRms, createWaveform } from '../components/captureWaveform';
describe('isSherpaModel', () => {
it('matches the sherpa- dictation ids and nothing else', () => {
@@ -134,3 +140,120 @@ describe('computeTypeDelta', () => {
});
});
});
describe('parsePasteError', () => {
it('splits the Rust kind prefixes off simulate_paste Err strings', () => {
expect(parsePasteError('a11y: accessibility not granted')).toEqual({
kind: 'a11y',
message: 'accessibility not granted',
});
expect(parsePasteError('clipboard: could not restore')).toEqual({
kind: 'clipboard',
message: 'could not restore',
});
expect(parsePasteError('paste: key event failed')).toEqual({
kind: 'paste',
message: 'key event failed',
});
});
it('accepts Error objects (Tauri invoke may reject with either shape)', () => {
expect(parsePasteError(new Error('a11y: nope'))).toEqual({ kind: 'a11y', message: 'nope' });
});
it('falls back to a generic paste kind for unprefixed/unknown errors', () => {
expect(parsePasteError('something exploded')).toEqual({
kind: 'paste',
message: 'something exploded',
});
expect(parsePasteError(undefined)).toEqual({ kind: 'paste', message: '' });
});
});
describe('captureWaveform', () => {
describe('frameRms', () => {
it('is 0 for silence, empty frames, and missing frames', () => {
expect(frameRms(new Float32Array(320))).toBe(0);
expect(frameRms(new Float32Array(0))).toBe(0);
expect(frameRms(undefined)).toBe(0);
});
it('is 1 for a full-scale square wave and clamps beyond it', () => {
expect(frameRms(Float32Array.from({ length: 32 }, (_, i) => (i % 2 ? 1 : -1)))).toBe(1);
expect(frameRms(new Float32Array(8).fill(2))).toBe(1); // out-of-range input clamps
});
it('normalises Int16Array frames like Float32 ones', () => {
const f32 = new Float32Array(64).fill(0.5);
const i16 = new Int16Array(64).fill(0.5 * 32768);
expect(frameRms(i16)).toBeCloseTo(frameRms(f32), 5);
});
it('computes RMS (a half-scale sine-ish constant is its amplitude)', () => {
expect(frameRms(new Float32Array(128).fill(0.25))).toBeCloseTo(0.25, 6);
});
});
describe('createWaveform', () => {
it('returns all-zero bars before any audio arrives', () => {
expect(createWaveform().getBars(8)).toEqual(Array.from({ length: 8 }, () => 0));
});
it('reacts within the ~100 ms liveness budget (bars move after ≤2 frames)', () => {
const wave = createWaveform();
wave.push(new Float32Array(320).fill(0.5)); // one 20 ms frame of speech
const bars = wave.getBars(4);
expect(bars[3]).toBeGreaterThan(0.3); // newest bar clearly off the floor
});
it('fills bars oldest→newest with the newest level last', () => {
const wave = createWaveform();
wave.push(0.1);
wave.push(1.0);
const bars = wave.getBars(4);
expect(bars[0]).toBe(0); // unfilled slots pad the left
expect(bars[1]).toBe(0);
expect(bars[3]).toBeGreaterThan(bars[2]); // rising signal → rising bars
});
it('smooths: attack is fast, release decays gradually (no flicker-to-zero)', () => {
const wave = createWaveform();
const loud = wave.push(1.0);
const quiet1 = wave.push(0);
const quiet2 = wave.push(0);
expect(loud).toBeGreaterThan(0.5); // fast attack
expect(quiet1).toBeGreaterThan(0); // does not slam to zero
expect(quiet1).toBeLessThan(loud); // …but does fall
expect(quiet2).toBeLessThan(quiet1); // monotonic decay
});
it('keeps every bar in [0, 1] even for hot input', () => {
const wave = createWaveform();
for (let i = 0; i < 100; i++) wave.push(5); // out-of-range levels clamp
for (const v of wave.getBars(12)) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
});
it('ring-wraps past capacity, keeping only the most recent levels', () => {
const wave = createWaveform({ capacity: 4 });
for (let i = 0; i < 3; i++) wave.push(1.0); // drive level up (peak ≈ 0.94)…
for (let i = 0; i < 4; i++) wave.push(0); // …then overwrite the whole ring
const bars = wave.getBars(4);
expect(Math.max(...bars)).toBeLessThan(0.9); // the ≈0.97 peak bar rotated out
// and what's left is the decaying tail, oldest→newest
expect(bars[0]).toBeGreaterThan(bars[1]);
expect(bars[2]).toBeGreaterThan(bars[3]);
});
it('reset() clears levels and the smoothing envelope', () => {
const wave = createWaveform();
wave.push(1.0);
wave.reset();
expect(wave.getBars(4)).toEqual([0, 0, 0, 0]);
// envelope reset too: the next quiet frame starts from silence
expect(wave.push(0)).toBe(0);
});
});
});
+49 -1
View File
@@ -72,7 +72,9 @@ def test_eof_text_frame_triggers_final_without_disconnect(client):
final = msg
break
assert final is not None, "server never delivered final after EOF"
assert final["text"] == "hello world"
# Finals are polished (dictation v2): leading capital + terminal
# punctuation. The stub returns "hello world" raw.
assert final["text"] == "Hello world."
assert final["engine"] == "stub"
@@ -100,3 +102,49 @@ def test_empty_binary_frame_acts_as_eof(client):
break
assert final is not None
assert final["engine"] == "stub"
# ── Capture-ASR background warm-up gating (dictation v2) ─────────────────────
#
# The dictation model warms in the background BY DEFAULT (~30s post-boot);
# OMNIVOICE_PRELOAD_CAPTURE_ASR=0 opts out, and the warm-up is skipped when
# the machine is under 4 GB of free RAM.
def test_capture_preload_defaults_on(monkeypatch):
import main
monkeypatch.delenv("OMNIVOICE_PRELOAD_CAPTURE_ASR", raising=False)
assert main._env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR", default=True)
monkeypatch.setenv("OMNIVOICE_PRELOAD_CAPTURE_ASR", "0")
assert not main._env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR", default=True)
monkeypatch.setenv("OMNIVOICE_PRELOAD_CAPTURE_ASR", "1")
assert main._env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR", default=True)
def test_capture_preload_delay_default_and_override(monkeypatch):
import main
monkeypatch.delenv("OMNIVOICE_CAPTURE_PRELOAD_DELAY", raising=False)
assert main._capture_preload_delay_s() == 30.0
monkeypatch.setenv("OMNIVOICE_CAPTURE_PRELOAD_DELAY", "0")
assert main._capture_preload_delay_s() == 0.0
monkeypatch.setenv("OMNIVOICE_CAPTURE_PRELOAD_DELAY", "junk")
assert main._capture_preload_delay_s() == 30.0
def test_capture_preload_ram_guard(monkeypatch):
import types
import main
import psutil
monkeypatch.setattr(psutil, "virtual_memory",
lambda: types.SimpleNamespace(available=2 * 1024**3))
assert not main._capture_preload_ram_ok()
monkeypatch.setattr(psutil, "virtual_memory",
lambda: types.SimpleNamespace(available=8 * 1024**3))
assert main._capture_preload_ram_ok()
# Unmeasurable → warm anyway (the load path has its own error handling).
def _boom():
raise RuntimeError("no vm info")
monkeypatch.setattr(psutil, "virtual_memory", _boom)
assert main._capture_preload_ram_ok()
+167 -2
View File
@@ -113,7 +113,8 @@ def test_partials_before_final(client):
msgs.append(ws.receive_json())
except Exception:
break
if msgs[-1].get("type") == "final" and msgs[-1].get("text") in ("a b c", ""):
# Finals are polished (dictation v2) — "a b c" ships as "A b c."
if msgs[-1].get("type") == "final" and msgs[-1].get("text") in ("A b c.", ""):
# got the endpoint-final; keep draining for the EOF final too
if len([m for m in msgs if m["type"] == "final"]) >= 1:
# try one more receive for trailing final, then stop
@@ -123,6 +124,8 @@ def test_partials_before_final(client):
pass
break
# Cold-start status frames don't count as results.
msgs = [m for m in msgs if m.get("type") != "status"]
types_seen = [m["type"] for m in msgs]
partials = [m for m in msgs if m["type"] == "partial"]
finals = [m for m in msgs if m["type"] == "final"]
@@ -179,5 +182,167 @@ def test_non_streaming_model_uses_offline_handler(monkeypatch):
final = m
break
assert final is not None
assert final["text"] == "offline text"
# Polished final (dictation v2): leading capital + terminal punctuation.
assert final["text"] == "Offline text."
assert final["engine"] == "sherpa-onnx-asr"
# ── Utterance-windowed offline decoding (dictation v2) ───────────────────────
def test_offline_silence_gate_commits_mid_session(monkeypatch):
"""~0.6s of trailing silence must COMMIT the current utterance: a `final`
flushes mid-session (not just at EOF) and the committed samples are
dropped from the live buffer, so no decode ever spans more than one
utterance (the O() full-buffer re-decode fix)."""
import time as _time
import numpy as np
from fastapi.testclient import TestClient
from api.routers import capture_ws as cw
from services import sherpa_dictation as sd
from services import asr_backend as ab
spec = sd.get_spec("sherpa-whisper-tiny") # offline kind
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()))
decoded_lens = []
def fake_decode(self, samples, sr):
decoded_lens.append(len(samples))
return "utterance one"
monkeypatch.setattr(ab.SherpaDictationBackend, "_decode_offline", fake_decode)
monkeypatch.setitem(sys.modules, "services.refinement",
types.SimpleNamespace(maybe_refine=lambda t: None,
collapse_repetitive_artifacts=lambda t: t))
cw.SHERPA_OFFLINE_PARTIAL_S = 0.05 # fast ticks for the test
speech = np.full(4000, 3000, dtype=np.int16).tobytes() # 0.25s speech
silence = b"\x00" * 22400 # 0.7s silence
utt1_samples = (len(speech) + len(silence)) // 2 # 15200
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_bytes(silence)
# Give the gate a few ticks to commit utterance 1, then speak again.
_time.sleep(0.4)
ws.send_bytes(speech)
ws.send_text("EOF")
msgs = []
for _ in range(40):
try:
m = ws.receive_json()
except Exception:
break
msgs.append(m)
finals = [m for m in msgs if m.get("type") == "final"]
# Two finals: the gate-committed utterance mid-session + the EOF trailing
# 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."
# 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
# re-decode of already-committed audio (which would be >utt1_samples).
assert decoded_lens, "decoder never ran"
assert max(decoded_lens) <= utt1_samples
def test_status_frames_precede_results(monkeypatch):
"""A WS session whose model isn't cached yet narrates the cold start:
status 'downloading' (or 'loading' when cached) then 'ready', before any
partial/final."""
from fastapi.testclient import TestClient
from api.routers import capture_ws as cw
from services import sherpa_dictation as sd
from services import asr_backend as ab
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: "hi")
monkeypatch.setattr(sd, "is_installed", lambda spec: False) # cold cache
monkeypatch.setitem(sys.modules, "services.refinement",
types.SimpleNamespace(maybe_refine=lambda t: None,
collapse_repetitive_artifacts=lambda t: t))
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:
msgs = [ws.receive_json(), ws.receive_json()] # the two status frames
ws.send_bytes(b"\x00" * 4000)
ws.send_text("EOF")
for _ in range(20):
try:
m = ws.receive_json()
except Exception:
break
msgs.append(m)
if m.get("type") == "final":
break
assert msgs[0] == {"type": "status", "stage": "downloading"}
assert msgs[1] == {"type": "status", "stage": "ready"}
types_seen = [m["type"] for m in msgs]
assert types_seen.index("status") < types_seen.index("final")
# ── Endpoint-rule tuning (dictation v2) ──────────────────────────────────────
class _KwargsOnlineRecognizer:
last_kwargs = None
@classmethod
def from_transducer(cls, **kw):
cls.last_kwargs = kw
return cls()
@classmethod
def from_paraformer(cls, **kw):
cls.last_kwargs = kw
return cls()
def test_endpoint_rules_fast_defaults_and_env_override(monkeypatch):
"""Streaming endpoint rules commit at 1.0s/0.6s by default (was 2.4/1.2 —
laggy) and honor OMNIVOICE_DICTATION_ENDPOINT_R1/R2."""
from services import sherpa_dictation as sd
fake = types.ModuleType("sherpa_onnx")
fake.OnlineRecognizer = _KwargsOnlineRecognizer
monkeypatch.setitem(sys.modules, "sherpa_onnx", fake)
monkeypatch.setattr(sd, "_resolve_model_dir",
lambda spec, download=True: "/fake/dir")
monkeypatch.delenv("OMNIVOICE_DICTATION_ENDPOINT_R1", raising=False)
monkeypatch.delenv("OMNIVOICE_DICTATION_ENDPOINT_R2", raising=False)
sd.build_online_recognizer(sd.get_spec("sherpa-zipformer-en-20m"))
kw = _KwargsOnlineRecognizer.last_kwargs
assert kw["rule1_min_trailing_silence"] == 1.0
assert kw["rule2_min_trailing_silence"] == 0.6
assert kw["rule3_min_utterance_length"] == 20 # unchanged
monkeypatch.setenv("OMNIVOICE_DICTATION_ENDPOINT_R1", "2.4")
monkeypatch.setenv("OMNIVOICE_DICTATION_ENDPOINT_R2", "1.2")
sd.build_online_recognizer(sd.get_spec("sherpa-paraformer-bilingual-zh-en"))
kw = _KwargsOnlineRecognizer.last_kwargs
assert kw["rule1_min_trailing_silence"] == 2.4
assert kw["rule2_min_trailing_silence"] == 1.2
# Garbage env falls back to the defaults rather than crashing dictation.
monkeypatch.setenv("OMNIVOICE_DICTATION_ENDPOINT_R1", "fast")
monkeypatch.setenv("OMNIVOICE_DICTATION_ENDPOINT_R2", "")
assert sd._endpoint_rules() == (1.0, 0.6)
+90
View File
@@ -0,0 +1,90 @@
"""
Unit tests for services/text_polish.py the deterministic polish applied to
every dictation `final` (dictation v2): leading capital (Latin scripts only),
terminal punctuation (incl. CJK forms), whitespace cleanup. Pure function, no
model so these pin exact strings.
"""
import os
import pytest
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
from services.text_polish import polish_text # noqa: E402
# ── Latin: capitalization + terminal punctuation ─────────────────────────────
@pytest.mark.parametrize("raw,polished", [
("hello world", "Hello world."),
("Hello world", "Hello world."),
("what time is it?", "What time is it?"), # already terminated
("wow!", "Wow!"),
("wait…", "Wait…"), # ellipsis is terminal
("42 is the answer", "42 is the answer."), # non-letter start untouched
("école ouverte", "École ouverte."), # Latin-1 accents capitalize
("čapek wrote robots", "Čapek wrote robots."), # Latin Extended
])
def test_latin_basics(raw, polished):
assert polish_text(raw) == polished
def test_whitespace_collapsed_and_stripped():
assert polish_text(" hello world ") == "Hello world."
assert polish_text("a \t b") == "A b." # mixed space/tab run
def test_dangling_separator_swapped_for_stop():
# ASR often stops mid-breath on a comma — swap it, don't stack ",.".
assert polish_text("see you tomorrow,") == "See you tomorrow."
assert polish_text("first; second;") == "First; second."
def test_terminal_behind_closing_quote_respected():
assert polish_text('he said "stop."') == 'He said "stop."'
assert polish_text("(done!)") == "(done!)"
assert polish_text('he said "stop"') == 'He said "stop".'
# ── CJK: passthrough (no capitalization) + fullwidth stop ────────────────────
def test_cjk_already_punctuated_passes_through():
assert polish_text("你好,世界。") == "你好,世界。"
assert polish_text("すごい!") == "すごい!"
assert polish_text("本当ですか?") == "本当ですか?"
def test_cjk_gets_fullwidth_stop():
assert polish_text("你好世界") == "你好世界。"
assert polish_text("ありがとう") == "ありがとう。"
def test_cjk_dangling_comma_swapped():
assert polish_text("你好,") == "你好。"
# ── Non-Latin alphabets: no capitalization, still get a stop ─────────────────
def test_cyrillic_and_greek_not_capitalized():
assert polish_text("привет мир") == "привет мир."
assert polish_text("γεια σου") == "γεια σου."
# ── Edge cases ───────────────────────────────────────────────────────────────
def test_empty_and_whitespace_only():
assert polish_text("") == ""
assert polish_text(" ") == ""
assert polish_text("\t \t") == ""
assert polish_text(",") == "" # dangling-only → empty
def test_idempotent():
for raw in ["hello world", "你好世界", "привет", "What?!", "a, b,"]:
once = polish_text(raw)
assert polish_text(once) == once