Plan 3: playback worker + wake/sleep wav + software volume gain

This commit is contained in:
2026-06-11 22:50:13 +00:00
parent d0bd141082
commit fa245d87a5
+117
View File
@@ -0,0 +1,117 @@
"""Playback worker thread + wake/sleep cue + software volume.
Items pushed to the queue:
("audio", bytes) - PCM16 LE 24 kHz mono data to write
("wav", (Path, label)) - WAV file to read and play; triggers on_done(label) after
("done_marker", str) - synthetic marker: after this is popped, call on_done(label)
("stop", None) - shut the worker down
Volume is applied as a software gain in [0..1.0] on int16 buffers because the
USB Speaker Phone has no hardware playback volume control (only `PCM Playback
Switch`, a mute).
"""
import queue
import threading
import wave
from pathlib import Path
from typing import Callable, Tuple
import numpy as np
from client.audio import SAMPLE_RATE
from client.log import get_logger
_log = get_logger("playback")
class PlaybackWorker:
def __init__(self, audio_streams, on_done: Callable[[str], None]):
"""
`audio_streams.write(pcm16_bytes)` is the blocking write into the
persistent OutputStream. `on_done(label)` is invoked when a tagged
cue (e.g. "wake", "sleep") finishes playing.
"""
self._streams = audio_streams
self._on_done = on_done
self._q: "queue.Queue[Tuple[str, object]]" = queue.Queue()
self._volume = 1.0
self._thread = threading.Thread(target=self._run, name="playback", daemon=True)
self._running = False
def start(self) -> None:
self._running = True
self._thread.start()
def stop(self) -> None:
self._running = False
self._q.put(("stop", None))
self._thread.join(timeout=2.0)
def set_volume(self, level: int) -> None:
"""Set software gain. `level` is 0..100."""
if not 0 <= level <= 100:
raise ValueError("level must be 0..100")
self._volume = level / 100.0
_log.info("volume set to %d%%", level)
def enqueue_audio(self, pcm16_bytes: bytes) -> None:
self._q.put(("audio", pcm16_bytes))
def enqueue_wav(self, path: Path, label: str) -> None:
"""Queue a WAV cue. When fully drained, on_done(label) fires."""
self._q.put(("wav", (path, label)))
def enqueue_done_marker(self, label: str) -> None:
"""Fire on_done(label) once this marker is processed (after all prior audio)."""
self._q.put(("done_marker", label))
def _apply_gain(self, pcm16: bytes) -> bytes:
if self._volume == 1.0:
return pcm16
arr = np.frombuffer(pcm16, dtype=np.int16).astype(np.int32)
arr = (arr * int(self._volume * 1024)) >> 10
arr = np.clip(arr, -32768, 32767).astype(np.int16)
return arr.tobytes()
def _play_wav(self, path: Path, label: str) -> None:
if not path.exists():
_log.info("wav %s not present at %s; skipping", label, path)
self._on_done(label)
return
try:
with wave.open(str(path), "rb") as w:
if w.getframerate() != SAMPLE_RATE or w.getnchannels() != 1 or w.getsampwidth() != 2:
_log.warning(
"wav %s is %dHz/%dch/%dB; expected %dHz/mono/16-bit",
path, w.getframerate(), w.getnchannels(), w.getsampwidth() * 8, SAMPLE_RATE,
)
self._on_done(label)
return
data = w.readframes(w.getnframes())
except wave.Error as exc:
_log.warning("could not read wav %s: %s", path, exc)
self._on_done(label)
return
chunk = 3840
for i in range(0, len(data), chunk):
self._streams.write(self._apply_gain(data[i:i + chunk]))
self._on_done(label)
def _run(self) -> None:
while self._running:
try:
kind, payload = self._q.get(timeout=0.5)
except queue.Empty:
continue
try:
if kind == "stop":
return
if kind == "audio":
self._streams.write(self._apply_gain(payload))
elif kind == "wav":
path, label = payload
self._play_wav(path, label)
elif kind == "done_marker":
self._on_done(payload)
except Exception as exc: # pragma: no cover - hardware path
_log.exception("playback worker error: %s", exc)