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:
parent
2ddd24ec1f
commit
e3be3b0481
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue