* feat(dictation): opt-in NLMS AEC for dictate-over-playback (Wave 8b) Dictating while OmniVoice plays audio (TTS preview, dub, video) leaks the loudspeaker signal into the mic, and the streaming ASR transcribes that bleed. Browser echoCancellation varies per platform/webview — it can't be a cross-platform default — so this adds a server-side canceller that behaves identically everywhere. services/aec.py ports Patter's NlmsEchoCanceller (MIT): a time-domain NLMS adaptive filter with a Geigel double-talk detector, warm-up step ramp, and far-end staleness pass-through. /ws/transcribe gains an opt-in '?aec=1[&sr=]' mode: frames are raw int16 mono PCM tagged with a 1-byte prefix (0x00 mic, 0x01 playback reference); the mic is cleaned against the reference before buffering, and the cleaned PCM is muxed via stdlib wave (not ffmpeg). Without the param the protocol and behaviour are byte-for-byte unchanged. Backend ships dark (no new deps — numpy already pinned); frontend far-end streaming is a follow-up. Tests cover echo attenuation, double-talk preservation, cold/stale pass-through, param validation, and the framing helpers — all pure-numpy/stdlib so they skip the torch ASR stack. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(capture_ws): stubs accept the new pcm_sr kwarg _transcribe_buffer/_transcribe_buffer_full gained an optional pcm_sr kwarg for the AEC PCM path; the protocol-test stubs had fixed signatures and raised TypeError on it, so the handler sent 'error' instead of 'final'. Accept **kw in the stubs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
103 lines
3.8 KiB
Python
103 lines
3.8 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, **_kw):
|
|
return "hello"
|
|
|
|
async def fake_full(_chunks, **_kw):
|
|
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
|
|
# client=("127.0.0.1", 50000) matches the loopback allow-list in
|
|
# backend/api/routers/capture_ws.py:_LOOPBACK_HOSTS. Starlette's default
|
|
# TestClient uses client=("testclient", 50000), which the WS guard rejects.
|
|
# Matches the pattern PR #84 established for HTTP TestClient fixtures.
|
|
return TestClient(app, client=("127.0.0.1", 50000))
|
|
|
|
|
|
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"
|