fix(wake-word): capture at native input rate

Open the selected microphone at its reported default rate and convert each capture block to the 16 kHz frame expected by wake-word engines. Add a regression covering a 48 kHz WASAPI device.

Co-authored-by: clyu168 <clyu168@126.com>
This commit is contained in:
Gille 2026-08-07 17:36:48 -06:00 committed by kshitij
parent 2ddd24ec1f
commit e3be3b0481
2 changed files with 72 additions and 8 deletions

View File

@ -437,10 +437,22 @@ class _LoudStream(_FakeStream):
def test_detector_opens_configured_input_device_and_reports_backend(monkeypatch):
opened = []
reads = []
processed = []
class _NativeRateStream(_LoudStream):
def read(self, n):
reads.append(n)
return super().read(n)
class _RecordingEngine(_FakeEngine):
def process(self, frame):
processed.append(frame)
return False
def _stream(**kwargs):
opened.append(kwargs)
return _LoudStream(**kwargs)
return _NativeRateStream(**kwargs)
fake_sd = types.SimpleNamespace(
InputStream=_stream,
@ -452,16 +464,24 @@ def test_detector_opens_configured_input_device_and_reports_backend(monkeypatch)
},
query_hostapis=lambda index: {"name": "Windows WASAPI"},
)
monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, None))
np = pytest.importorskip("numpy")
monkeypatch.setattr(ww, "_import_audio", lambda: (fake_sd, np))
det = ww.WakeWordDetector(
_FakeEngine(fire=False),
_RecordingEngine(fire=False),
lambda: None,
input_device="Microphone Array",
)
det.start()
try:
assert opened[0]["device"] == "Microphone Array"
assert opened[0]["samplerate"] == 48000
assert opened[0]["blocksize"] == 12
deadline = time.monotonic() + 2.0
while not processed and time.monotonic() < deadline:
time.sleep(0.01)
assert reads[0] == 12
assert len(processed[0]) == 4
assert det.input_device_details == {
"selector": "Microphone Array",
"name": "Microphone Array",

View File

@ -438,6 +438,40 @@ def _device_label(details: Dict[str, Any]) -> str:
return f"{label} ({hostapi})" if hostapi else label
def _capture_sample_rate(details: Dict[str, Any]) -> int:
"""Use the selected device's native rate when PortAudio reports one."""
rate = details.get("default_samplerate")
if isinstance(rate, (int, float)) and not isinstance(rate, bool) and rate > 0:
try:
return int(round(rate))
except (OverflowError, ValueError):
pass
return SAMPLE_RATE
def _resample_audio_frame(np, frame, output_length: int):
"""Convert one native-rate capture block to an exact engine frame."""
source = np.asarray(frame, dtype=np.float64).reshape(-1)
if source.size == output_length:
return np.asarray(frame, dtype=np.int16).reshape(-1)
if source.size == 0:
return np.zeros(output_length, dtype=np.int16)
if source.size > output_length:
# Match the desktop wake capture path: average each source window when
# reducing the rate so speech energy is retained instead of decimating.
edges = np.linspace(0, source.size, output_length + 1, dtype=np.int64)
values = np.add.reduceat(source, edges[:-1]) / np.diff(edges)
else:
# Unusual low-rate devices need interpolation to reach the 16 kHz
# frame size expected by every wake-word engine.
source_positions = np.arange(source.size, dtype=np.float64)
target_positions = np.linspace(0, source.size - 1, output_length)
values = np.interp(target_positions, source_positions, source)
return np.rint(values).clip(-32768, 32767).astype(np.int16)
def silent_audio_hint(details: Dict[str, Any]) -> str:
"""Platform-specific remediation for an armed stream delivering silence."""
if sys.platform == "darwin":
@ -1091,6 +1125,9 @@ class WakeWordDetector:
def _run(self, ready: threading.Event,
startup_errors: list[BaseException]) -> None:
frame_length = self.engine.frame_length
capture_frame_length = frame_length
capture_rate = SAMPLE_RATE
np = None
stream = None
if self.external_audio:
@ -1106,7 +1143,7 @@ class WakeWordDetector:
)
else:
try:
sd, _ = _import_audio()
sd, np = _import_audio()
except (ImportError, OSError) as e:
logger.error("wake word: audio libraries unavailable: %s", e)
startup_errors.append(e)
@ -1114,22 +1151,27 @@ class WakeWordDetector:
return
self.input_device_details = _describe_input_device(sd, self.input_device)
capture_rate = _capture_sample_rate(self.input_device_details)
capture_frame_length = max(
1, int(round(frame_length * capture_rate / SAMPLE_RATE))
)
logger.info(
"wake word: opening microphone device=%s selector=%r hostapi=%s "
"default_rate=%s requested_rate=%d",
"default_rate=%s capture_rate=%d engine_rate=%d",
self.input_device_details.get("name") or "system default",
self.input_device,
self.input_device_details.get("hostapi") or "unknown",
self.input_device_details.get("default_samplerate") or "unknown",
capture_rate,
SAMPLE_RATE,
)
try:
stream = sd.InputStream(
device=self.input_device,
samplerate=SAMPLE_RATE,
samplerate=capture_rate,
channels=1,
dtype="int16",
blocksize=frame_length,
blocksize=capture_frame_length,
)
stream.start()
except Exception as e:
@ -1167,12 +1209,14 @@ class WakeWordDetector:
continue
data = frame
else:
data, _overflow = stream.read(frame_length)
data, _overflow = stream.read(capture_frame_length)
except Exception as e:
logger.warning("wake word: stream read error: %s", e)
failed = not self._stop.is_set()
break
frame = data[:, 0] if getattr(data, "ndim", 1) == 2 else data
if capture_rate != SAMPLE_RATE:
frame = _resample_audio_frame(np, frame, frame_length)
try:
peak = int(abs(frame).max()) if len(frame) else 0
except Exception: