Plan 3: playback worker + wake/sleep wav + software volume gain
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user