* 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>
160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
"""NLMS echo canceller (parity Action 8b).
|
|
|
|
Validates the ported ``NlmsEchoCanceller`` against synthetic far-end +
|
|
echo-contaminated near-end signals: the steady-state echo must attenuate, the
|
|
user's own speech (double-talk) must survive, and cold/stale references must
|
|
pass through untouched. Pure-numpy — no torch, no ASR stack.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from services.aec import NlmsEchoCanceller
|
|
|
|
SR = 16000
|
|
FRAME = 320 # 20 ms @ 16 kHz
|
|
|
|
|
|
def _pcm(x: np.ndarray) -> bytes:
|
|
"""float32 [-1, 1] → int16 LE PCM bytes."""
|
|
return np.clip(x * 32768.0, -32768.0, 32767.0).astype(np.int16).tobytes()
|
|
|
|
|
|
def _f32(pcm: bytes) -> np.ndarray:
|
|
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
|
|
|
|
|
def _rms(x: np.ndarray) -> float:
|
|
return float(np.sqrt(np.mean(x ** 2))) if x.size else 0.0
|
|
|
|
|
|
def _tone(freq: float, n: int, *, amp: float = 0.5, phase: float = 0.0) -> np.ndarray:
|
|
t = np.arange(n, dtype=np.float32) / SR
|
|
return amp * np.sin(2 * np.pi * freq * t + phase).astype(np.float32)
|
|
|
|
|
|
# ── Construction / validation ────────────────────────────────────────────────
|
|
|
|
def test_rejects_unsupported_sample_rate():
|
|
with pytest.raises(ValueError):
|
|
NlmsEchoCanceller(sample_rate=44100)
|
|
|
|
|
|
@pytest.mark.parametrize("kw", [
|
|
{"filter_taps": 32},
|
|
{"step_size": 0},
|
|
{"step_size": 1.5},
|
|
{"warmup_step_size": 0},
|
|
{"leakage": 0},
|
|
{"leakage": 2},
|
|
{"warmup_seconds": -1},
|
|
])
|
|
def test_rejects_bad_params(kw):
|
|
with pytest.raises(ValueError):
|
|
NlmsEchoCanceller(sample_rate=SR, **kw)
|
|
|
|
|
|
# ── Pass-through guards ──────────────────────────────────────────────────────
|
|
|
|
def test_passthrough_before_any_far_end():
|
|
"""No playback primed → mic returns byte-identical."""
|
|
aec = NlmsEchoCanceller(sample_rate=SR)
|
|
mic = _pcm(_tone(300, FRAME))
|
|
assert aec.process_near_end(mic) == mic
|
|
|
|
|
|
def test_passthrough_when_far_end_stale(monkeypatch):
|
|
"""Far reference older than the staleness window → no cancellation."""
|
|
aec = NlmsEchoCanceller(sample_rate=SR)
|
|
clock = {"t": 1000.0}
|
|
monkeypatch.setattr("services.aec.time.monotonic", lambda: clock["t"])
|
|
# Prime far-end so the ring is full, then let the clock jump forward.
|
|
for _ in range(4):
|
|
aec.push_far_end(_pcm(_tone(440, FRAME)))
|
|
clock["t"] += 1.0 # > _FAR_STALE_S (0.25 s)
|
|
mic = _pcm(_tone(300, FRAME))
|
|
assert aec.process_near_end(mic) == mic
|
|
|
|
|
|
def test_empty_frames_are_noops():
|
|
aec = NlmsEchoCanceller(sample_rate=SR)
|
|
aec.push_far_end(b"")
|
|
assert aec.process_near_end(b"") == b""
|
|
|
|
|
|
# ── Core behaviour ───────────────────────────────────────────────────────────
|
|
|
|
def test_attenuates_steady_state_echo(monkeypatch):
|
|
"""With the mic carrying a scaled, delayed copy of the playback signal and
|
|
no local speech, the residual after convergence is far quieter than the
|
|
echo it removed."""
|
|
aec = NlmsEchoCanceller(sample_rate=SR)
|
|
clock = {"t": 0.0}
|
|
monkeypatch.setattr("services.aec.time.monotonic", lambda: clock["t"])
|
|
|
|
echo_gain = 0.6
|
|
delay = 80 # samples (5 ms) — within the 512-tap window
|
|
prev_tail = np.zeros(delay, dtype=np.float32)
|
|
|
|
residual_rms = []
|
|
for k in range(200):
|
|
clock["t"] += FRAME / SR
|
|
far = _tone(500, FRAME, amp=0.5, phase=k) # vary phase so it's not periodic-identical
|
|
# Echo = delayed, attenuated far-end (mic hears only the echo here).
|
|
src = np.concatenate((prev_tail, far))
|
|
echo = echo_gain * src[:FRAME]
|
|
prev_tail = far[-delay:]
|
|
|
|
aec.push_far_end(_pcm(far))
|
|
out = _f32(aec.process_near_end(_pcm(echo)))
|
|
residual_rms.append(_rms(out))
|
|
|
|
early = np.mean(residual_rms[5:15]) # just after warm-up starts
|
|
late = np.mean(residual_rms[-20:]) # converged
|
|
assert late < early # it is learning the echo path
|
|
assert late < 0.10 * echo_gain * 0.5 # residual well below the echo level
|
|
|
|
|
|
def test_double_talk_preserves_user_speech(monkeypatch):
|
|
"""When the user speaks over playback, the Geigel detector freezes
|
|
adaptation so the user's voice is not modelled as echo and survives."""
|
|
aec = NlmsEchoCanceller(sample_rate=SR)
|
|
clock = {"t": 0.0}
|
|
monkeypatch.setattr("services.aec.time.monotonic", lambda: clock["t"])
|
|
|
|
# Converge the filter on echo-only frames first.
|
|
echo_gain = 0.5
|
|
for k in range(60):
|
|
clock["t"] += FRAME / SR
|
|
far = _tone(500, FRAME, amp=0.5, phase=k)
|
|
aec.push_far_end(_pcm(far))
|
|
aec.process_near_end(_pcm(echo_gain * far))
|
|
|
|
# Now the user talks (distinct frequency, loud) over the same playback.
|
|
user = _tone(180, FRAME, amp=0.6)
|
|
clock["t"] += FRAME / SR
|
|
far = _tone(500, FRAME, amp=0.5, phase=999)
|
|
aec.push_far_end(_pcm(far))
|
|
out = _f32(aec.process_near_end(_pcm(echo_gain * far + user)))
|
|
|
|
assert aec.double_talk_frames >= 1 # detector fired
|
|
assert _rms(out) > 0.5 * _rms(user) # user energy is largely retained
|
|
|
|
|
|
def test_reset_clears_state(monkeypatch):
|
|
aec = NlmsEchoCanceller(sample_rate=SR)
|
|
clock = {"t": 0.0}
|
|
monkeypatch.setattr("services.aec.time.monotonic", lambda: clock["t"])
|
|
for k in range(10):
|
|
clock["t"] += FRAME / SR
|
|
aec.push_far_end(_pcm(_tone(440, FRAME, phase=k)))
|
|
aec.process_near_end(_pcm(_tone(300, FRAME)))
|
|
aec.reset()
|
|
assert aec.frames_processed == 0
|
|
assert aec._far_filled == 0
|
|
assert not np.any(aec._w)
|
|
# After reset, a cold mic frame passes straight through again.
|
|
mic = _pcm(_tone(300, FRAME))
|
|
assert aec.process_near_end(mic) == mic
|