Plan 3: USB device discovery + persistent audio streams
This commit is contained in:
+114
-1
@@ -4,11 +4,26 @@ This module is split into two layers:
|
|||||||
|
|
||||||
* Deterministic helpers (no hardware) — `resample_24k_to_16k`. Tested.
|
* Deterministic helpers (no hardware) — `resample_24k_to_16k`. Tested.
|
||||||
* `AudioStreams` — owns the persistent PortAudio InputStream / OutputStream.
|
* `AudioStreams` — owns the persistent PortAudio InputStream / OutputStream.
|
||||||
Filled in by Task 7. Smoke-tested by `main.py` running on the Pi.
|
Smoke-tested by `main.py` running on the Pi.
|
||||||
"""
|
"""
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.signal import resample_poly
|
from scipy.signal import resample_poly
|
||||||
|
|
||||||
|
from client.log import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("audio")
|
||||||
|
|
||||||
|
SAMPLE_RATE = 24_000
|
||||||
|
FRAME_SAMPLES = 1920 # 80 ms at 24 kHz
|
||||||
|
CHANNELS = 1
|
||||||
|
DTYPE = "int16"
|
||||||
|
BYTES_PER_FRAME = FRAME_SAMPLES * 2 # int16 -> 2 bytes
|
||||||
|
|
||||||
|
|
||||||
def resample_24k_to_16k(frame: np.ndarray) -> np.ndarray:
|
def resample_24k_to_16k(frame: np.ndarray) -> np.ndarray:
|
||||||
"""Downsample a 24 kHz int16 mono frame to 16 kHz int16 mono (2:3 poly).
|
"""Downsample a 24 kHz int16 mono frame to 16 kHz int16 mono (2:3 poly).
|
||||||
@@ -24,3 +39,101 @@ def resample_24k_to_16k(frame: np.ndarray) -> np.ndarray:
|
|||||||
floats = frame.astype(np.float32)
|
floats = frame.astype(np.float32)
|
||||||
resampled = resample_poly(floats, up=2, down=3)
|
resampled = resample_poly(floats, up=2, down=3)
|
||||||
return np.clip(resampled, -32768, 32767).astype(np.int16)
|
return np.clip(resampled, -32768, 32767).astype(np.int16)
|
||||||
|
|
||||||
|
|
||||||
|
def find_usb_device(sd) -> int:
|
||||||
|
"""Return the PortAudio device index of the USB Speaker Phone.
|
||||||
|
|
||||||
|
`sd` is the imported `sounddevice` module — passed in so the deferred
|
||||||
|
import of sounddevice (which requires PA_ALSA_PLUGHW already set in env)
|
||||||
|
stays in `main.py`.
|
||||||
|
"""
|
||||||
|
devices = sd.query_devices()
|
||||||
|
for idx, dev in enumerate(devices):
|
||||||
|
name = dev["name"].lower()
|
||||||
|
if "usb" in name and dev["max_input_channels"] >= 1:
|
||||||
|
return idx
|
||||||
|
print("USB audio device not found. Devices:", file=sys.stderr)
|
||||||
|
print(devices, file=sys.stderr)
|
||||||
|
raise SystemExit("no USB audio device found")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioStreams:
|
||||||
|
"""Wrapper around persistent 24 kHz/mono/int16 InputStream + OutputStream.
|
||||||
|
|
||||||
|
The InputStream is callback-driven; each 1920-sample frame is pushed onto
|
||||||
|
`mic_queue` (a `queue.Queue[bytes]`). The bridge thread drains it. The
|
||||||
|
OutputStream is opened but writes are driven by `PlaybackWorker`.
|
||||||
|
"""
|
||||||
|
sd: object # sounddevice module
|
||||||
|
device_index: int
|
||||||
|
mic_queue: queue.Queue
|
||||||
|
overflow_counter: int = 0
|
||||||
|
_in_stream: Optional[object] = field(default=None, repr=False)
|
||||||
|
_out_stream: Optional[object] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(cls, sd, mic_queue: queue.Queue) -> "AudioStreams":
|
||||||
|
device = find_usb_device(sd)
|
||||||
|
_log.info("opening audio on device %d: %r", device, sd.query_devices(device)["name"])
|
||||||
|
|
||||||
|
streams = cls(sd=sd, device_index=device, mic_queue=mic_queue)
|
||||||
|
|
||||||
|
def _on_input(indata, frames, time_info, status): # PortAudio thread
|
||||||
|
if status:
|
||||||
|
streams.overflow_counter += 1
|
||||||
|
_log.warning("input status: %s", status)
|
||||||
|
mono = indata[:, 0].tobytes()
|
||||||
|
try:
|
||||||
|
mic_queue.put_nowait(mono)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
mic_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
mic_queue.put_nowait(mono)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
_log.warning("mic_queue full; dropped oldest frame")
|
||||||
|
|
||||||
|
streams._in_stream = sd.InputStream(
|
||||||
|
samplerate=SAMPLE_RATE,
|
||||||
|
channels=CHANNELS,
|
||||||
|
dtype=DTYPE,
|
||||||
|
device=device,
|
||||||
|
blocksize=FRAME_SAMPLES,
|
||||||
|
callback=_on_input,
|
||||||
|
)
|
||||||
|
streams._out_stream = sd.OutputStream(
|
||||||
|
samplerate=SAMPLE_RATE,
|
||||||
|
channels=CHANNELS,
|
||||||
|
dtype=DTYPE,
|
||||||
|
device=device,
|
||||||
|
blocksize=FRAME_SAMPLES,
|
||||||
|
)
|
||||||
|
return streams
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._in_stream.start()
|
||||||
|
self._out_stream.start()
|
||||||
|
|
||||||
|
def write(self, pcm16_bytes: bytes) -> None:
|
||||||
|
"""Blocking write to the OutputStream (from the playback worker thread)."""
|
||||||
|
if not pcm16_bytes:
|
||||||
|
return
|
||||||
|
buf = np.frombuffer(pcm16_bytes, dtype=np.int16)
|
||||||
|
self._out_stream.write(buf)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
for s in (self._in_stream, self._out_stream):
|
||||||
|
if s is not None:
|
||||||
|
try:
|
||||||
|
s.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user