Skip to content

Commit 5e19a3c

Browse files
author
kigner
committed
fix(speaktype): capture microphone through WASAPI
1 parent 98102de commit 5e19a3c

2 files changed

Lines changed: 106 additions & 16 deletions

File tree

SpeakType/app/audio_capture.py

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import queue
5+
import sys
56
import time
67

78
import numpy as np
@@ -22,11 +23,25 @@ def __init__(self, sample_rate: int, channels: int, frame_ms: int, queue_seconds
2223
self._stream: sd.RawInputStream | None = None
2324
self._capture_rate = sample_rate
2425
self._last_drop_log = 0.0
26+
self._resample_source_rate = sample_rate
27+
self._resample_buffer = np.empty(0, dtype=np.float32)
28+
self._resample_position = 0.0
2529

2630
@property
2731
def active(self) -> bool:
2832
return self._stream is not None and self._stream.active
2933

34+
@staticmethod
35+
def _preferred_input_device() -> tuple[int | None, object]:
36+
if sys.platform == "win32":
37+
for hostapi in sd.query_hostapis():
38+
if str(hostapi["name"]).casefold() != "windows wasapi":
39+
continue
40+
device_index = int(hostapi["default_input_device"])
41+
if device_index >= 0:
42+
return device_index, sd.query_devices(device_index)
43+
return None, sd.query_devices(kind="input")
44+
3045
def _callback(self, indata: bytes, frames: int, time_info: object, status: sd.CallbackFlags) -> None:
3146
del frames, time_info
3247
frame = (bytes(indata), self._capture_rate)
@@ -51,23 +66,34 @@ def _callback(self, indata: bytes, frames: int, time_info: object, status: sd.Ca
5166
def start(self) -> None:
5267
self.stop()
5368
self.clear()
54-
device = sd.query_devices(kind="input")
69+
device_index, device = self._preferred_input_device()
5570
native_rate = int(round(float(device["default_samplerate"])))
56-
try:
57-
sd.check_input_settings(channels=self.channels, dtype="int16", samplerate=self.sample_rate)
58-
self._capture_rate = self.sample_rate
59-
except sd.PortAudioError:
60-
self._capture_rate = native_rate
61-
LOGGER.info("microphone does not accept %s Hz; capturing at %s Hz", self.sample_rate, native_rate)
71+
self._capture_rate = native_rate
72+
sd.check_input_settings(
73+
device=device_index,
74+
channels=self.channels,
75+
dtype="int16",
76+
samplerate=self._capture_rate,
77+
)
6278
blocksize = max(1, round(self._capture_rate * self.frame_ms / 1000))
6379
self._stream = sd.RawInputStream(
80+
device=device_index,
6481
samplerate=self._capture_rate,
6582
blocksize=blocksize,
6683
channels=self.channels,
6784
dtype="int16",
6885
callback=self._callback,
6986
)
7087
self._stream.start()
88+
hostapi = sd.query_hostapis(int(device["hostapi"]))
89+
LOGGER.info(
90+
"microphone opened device=%s name=%r hostapi=%s capture_rate=%s output_rate=%s",
91+
device_index if device_index is not None else "<default>",
92+
device["name"],
93+
hostapi["name"],
94+
self._capture_rate,
95+
self.sample_rate,
96+
)
7197

7298
def stop(self) -> None:
7399
stream, self._stream = self._stream, None
@@ -82,20 +108,40 @@ def clear(self) -> None:
82108
try:
83109
self._queue.get_nowait()
84110
except queue.Empty:
85-
return
111+
break
112+
self._resample_source_rate = self.sample_rate
113+
self._resample_buffer = np.empty(0, dtype=np.float32)
114+
self._resample_position = 0.0
86115

87-
def get_frame(self, timeout: float = 0.1) -> bytes | None:
88-
try:
89-
pcm, source_rate = self._queue.get(timeout=timeout)
90-
except queue.Empty:
91-
return None
116+
def _resample(self, pcm: bytes, source_rate: int) -> bytes:
92117
if source_rate == self.sample_rate:
93118
return pcm
94119
samples = np.frombuffer(pcm, dtype="<i2").astype(np.float32)
120+
if source_rate != self._resample_source_rate:
121+
self._resample_source_rate = source_rate
122+
self._resample_buffer = np.empty(0, dtype=np.float32)
123+
self._resample_position = 0.0
124+
if self._resample_buffer.size:
125+
samples = np.concatenate((self._resample_buffer, samples))
95126
if samples.size < 2:
96-
return pcm
97-
output_size = max(1, round(samples.size * self.sample_rate / source_rate))
98-
positions = np.linspace(0, samples.size - 1, output_size)
127+
self._resample_buffer = samples
128+
return b""
129+
130+
step = source_rate / float(self.sample_rate)
131+
positions = np.arange(self._resample_position, samples.size - 1, step, dtype=np.float64)
132+
if positions.size == 0:
133+
self._resample_buffer = samples
134+
return b""
99135
resampled = np.interp(positions, np.arange(samples.size), samples)
136+
next_position = float(positions[-1] + step)
137+
consumed = min(int(np.floor(next_position)), samples.size)
138+
self._resample_buffer = samples[consumed:]
139+
self._resample_position = next_position - consumed
100140
return np.clip(resampled, -32768, 32767).astype("<i2").tobytes()
101141

142+
def get_frame(self, timeout: float = 0.1) -> bytes | None:
143+
try:
144+
pcm, source_rate = self._queue.get(timeout=timeout)
145+
except queue.Empty:
146+
return None
147+
return self._resample(pcm, source_rate)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
5+
import numpy as np
6+
7+
from app.audio_capture import AudioCapture
8+
9+
10+
def test_windows_prefers_wasapi_default_input(monkeypatch) -> None:
11+
hostapis = (
12+
{"name": "MME", "default_input_device": 1},
13+
{"name": "Windows WASAPI", "default_input_device": 12},
14+
)
15+
devices = {
16+
12: {
17+
"name": "WASAPI microphone",
18+
"hostapi": 1,
19+
"default_samplerate": 48000.0,
20+
}
21+
}
22+
monkeypatch.setattr(sys, "platform", "win32")
23+
monkeypatch.setattr("app.audio_capture.sd.query_hostapis", lambda *args: hostapis)
24+
monkeypatch.setattr("app.audio_capture.sd.query_devices", lambda index=None, **kwargs: devices[index])
25+
26+
index, device = AudioCapture._preferred_input_device() # noqa: SLF001
27+
28+
assert index == 12
29+
assert device["name"] == "WASAPI microphone"
30+
31+
32+
def test_streaming_resample_preserves_rate_and_chunk_continuity() -> None:
33+
capture = AudioCapture(sample_rate=16000, channels=1, frame_ms=20)
34+
source_rate = 48000
35+
source = (np.sin(np.arange(source_rate // 10) * 2 * np.pi * 440 / source_rate) * 12000).astype("<i2")
36+
37+
output = bytearray()
38+
chunk_samples = source_rate * capture.frame_ms // 1000
39+
for offset in range(0, source.size, chunk_samples):
40+
output.extend(capture._resample(source[offset : offset + chunk_samples].tobytes(), source_rate)) # noqa: SLF001
41+
42+
resampled = np.frombuffer(output, dtype="<i2")
43+
assert resampled.size == 1600
44+
assert np.max(np.abs(np.diff(resampled.astype(np.int32)))) < 2500

0 commit comments

Comments
 (0)