Tray + lifecycle: - tauri-plugin-single-instance — second launch focuses existing window instead of racing for port 3900. - Window close hides instead of destroying; backend shutdown moved to RunEvent::ExitRequested so only the tray "Quit" item (or Cmd+Q on macOS) actually exits. - Tray icon flips to red-dot variant during dictation recording. Hotkey customization: - Settings → Capture tab. Records any modifier+key combo, persists to app config, re-registers on launch. - set_dictation_shortcut rolls back to the previous binding on register failure so a bad combo never leaves the user with no shortcut. Dictation latency / correctness: - WS-final treated as source of truth; HTTP POST /transcribe runs only as fallback (WS error / timeout / no-WS path). Audio transcribed once instead of twice. Server accepts an "EOF" text frame (or empty binary frame) so the socket stays open for `final` to be delivered before the client closes. - MediaRecorder chunks queued during the WS handshake are drained in ws.onopen — the server's final transcript no longer drops the first ~250 ms of audio. - Fallback timeout scales with recording length (max(15s, recordedMs+10s)) so long-form dictations don't trip duplicate transcription. Donate page: - Drop Patreon, Bitcoin / Ethereum / Solana cards. Drop qrcode.react. - Move "Commercial License" CTA from page bottom to top-right header bar. Docker hygiene: - docker-compose binds 127.0.0.1 by default. README documents the LAN exposure trade-off + recommends a reverse proxy with auth. CI: - New cross-platform `tauri-cross-platform` job runs `cargo check` against the Tauri shell on macOS / Windows / Linux per PR. Catches platform cfg-gate regressions without paying the full ~15min/platform bundle cost (full bundling stays in release.yml on tag push). Tests: - tests/test_capture_ws.py (3 cases) covers EOF text-frame, empty-binary EOF, and legacy disconnect-finalize paths. Includes the user's previously-staged 0.2.5 polish: cross-platform desktop-prod.sh, Dockerfile base-image fix, bun.lock churn. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
"""
|
|
Tests for the streaming-ASR WebSocket endpoint.
|
|
|
|
Focus: the EOF text-frame protocol (added so the React `CaptureButton` can
|
|
treat the WS `final` message as the source of truth and skip the duplicate
|
|
HTTP POST that used to run on every dictation). Ground truth: an EOF text
|
|
frame must let the server deliver `final` over the still-open socket
|
|
*without* the client having to disconnect first.
|
|
|
|
The ASR backends are mocked — we're testing protocol, not transcription
|
|
quality.
|
|
"""
|
|
import os
|
|
import pytest
|
|
|
|
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
|
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
|
|
# Tighten the partial-tick so the test doesn't sit waiting 2 s for the
|
|
# silence path.
|
|
os.environ["OMNIVOICE_STREAM_INTERVAL"] = "0.1"
|
|
os.environ["OMNIVOICE_STREAM_SILENCE"] = "0.2"
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
from fastapi.testclient import TestClient
|
|
|
|
# Stub the heavy transcription helpers so the test stays in-process.
|
|
from api.routers import capture_ws as cw
|
|
|
|
async def fake_partial(_chunks):
|
|
return "hello"
|
|
|
|
async def fake_full(_chunks):
|
|
return {
|
|
"text": "hello world",
|
|
"segments": [{"start": 0.0, "end": 1.0, "text": "hello world"}],
|
|
"language": "en",
|
|
"duration_s": 1.0,
|
|
"transcription_time_s": 0.01,
|
|
"engine": "stub",
|
|
}
|
|
|
|
monkeypatch.setattr(cw, "_transcribe_buffer", fake_partial)
|
|
monkeypatch.setattr(cw, "_transcribe_buffer_full", fake_full)
|
|
|
|
from main import app
|
|
return TestClient(app)
|
|
|
|
|
|
def _audio_chunk(n_bytes: int = 20_000) -> bytes:
|
|
# MIN_BUFFER_BYTES is 16_000 — give the server enough to trigger a partial
|
|
# AND a final.
|
|
return b"\x00" * n_bytes
|
|
|
|
|
|
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:
|
|
ws.send_bytes(_audio_chunk())
|
|
ws.send_text("EOF")
|
|
# Drain whatever the server sends (partials may or may not arrive
|
|
# depending on timing). The first message we care about is `final`.
|
|
final = None
|
|
for _ in range(10):
|
|
msg = ws.receive_json()
|
|
if msg.get("type") == "final":
|
|
final = msg
|
|
break
|
|
assert final is not None, "server never delivered final after EOF"
|
|
assert final["text"] == "hello world"
|
|
assert final["engine"] == "stub"
|
|
|
|
|
|
def test_legacy_disconnect_still_finalizes(client):
|
|
"""Closing the socket without EOF should still deliver final (legacy path)."""
|
|
# Even if the client closes, the server runs final and *attempts* to send
|
|
# before the close handshake completes. Whether the test client receives
|
|
# it is timing-dependent — we mostly care that no exception bubbles up
|
|
# and the server doesn't deadlock.
|
|
with client.websocket_connect("/ws/transcribe") as ws:
|
|
ws.send_bytes(_audio_chunk())
|
|
# Just close — don't wait. Endpoint should clean up gracefully.
|
|
|
|
|
|
def test_empty_binary_frame_acts_as_eof(client):
|
|
"""An empty binary frame is the same end-of-audio signal as 'EOF' text."""
|
|
with client.websocket_connect("/ws/transcribe") as ws:
|
|
ws.send_bytes(_audio_chunk())
|
|
ws.send_bytes(b"")
|
|
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["engine"] == "stub"
|