diff --git a/client/audio.py b/client/audio.py index 3d23060..45cff7e 100644 --- a/client/audio.py +++ b/client/audio.py @@ -4,11 +4,26 @@ This module is split into two layers: * Deterministic helpers (no hardware) — `resample_24k_to_16k`. Tested. * `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 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: """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) resampled = resample_poly(floats, up=2, down=3) 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