140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
"""Audio helpers for the Pi client.
|
|
|
|
This module is split into two layers:
|
|
|
|
* Deterministic helpers (no hardware) — `resample_24k_to_16k`. Tested.
|
|
* `AudioStreams` — owns the persistent PortAudio InputStream / OutputStream.
|
|
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).
|
|
|
|
Input length is preserved as `len(frame) * 2 // 3`. For the 1920-sample
|
|
frames produced by our 80 ms blocksize at 24 kHz, this yields 1280 samples
|
|
(80 ms at 16 kHz), which is exactly what openwakeword expects.
|
|
"""
|
|
if frame.dtype != np.int16:
|
|
raise TypeError(f"expected int16, got {frame.dtype}")
|
|
if frame.size == 0:
|
|
raise ValueError("empty input")
|
|
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
|