diff --git a/src/tuimble/audio.py b/src/tuimble/audio.py index 0de932f..bb40631 100644 --- a/src/tuimble/audio.py +++ b/src/tuimble/audio.py @@ -7,9 +7,9 @@ Playback path: pymumble (decodes) -> raw PCM -> queue -> speakers. from __future__ import annotations +import array import logging import queue -import struct log = logging.getLogger(__name__) @@ -21,13 +21,13 @@ DTYPE = "int16" def _apply_gain(pcm: bytes, gain: float) -> bytes: """Scale int16 PCM samples by gain factor with clipping.""" - n = len(pcm) // 2 - if n == 0: + if len(pcm) < 2: return pcm - fmt = f"<{n}h" - samples = struct.unpack(fmt, pcm[: n * 2]) - scaled = [max(-32768, min(32767, int(s * gain))) for s in samples] - return struct.pack(fmt, *scaled) + samples = array.array("h") + samples.frombytes(pcm[: len(pcm) & ~1]) + for i in range(len(samples)): + samples[i] = max(-32768, min(32767, int(samples[i] * gain))) + return samples.tobytes() class AudioPipeline: @@ -82,13 +82,19 @@ class AudioPipeline: log.info("audio pipeline started (rate=%d)", self._sample_rate) def stop(self): - """Close audio streams.""" + """Close audio streams and drain stale frames.""" for stream in (self._input_stream, self._output_stream): if stream is not None: stream.stop() stream.close() self._input_stream = None self._output_stream = None + for q in (self._capture_queue, self._playback_queue): + while not q.empty(): + try: + q.get_nowait() + except queue.Empty: + break log.info("audio pipeline stopped") @property @@ -154,9 +160,15 @@ class AudioPipeline: except queue.Empty: outdata[:] = b"\x00" * len(outdata) - def get_capture_frame(self) -> bytes | None: - """Retrieve next captured PCM frame for transmission.""" + def get_capture_frame(self, timeout: float = 0.0) -> bytes | None: + """Retrieve next captured PCM frame for transmission. + + Args: + timeout: Seconds to wait for a frame. 0 returns immediately. + """ try: + if timeout > 0: + return self._capture_queue.get(timeout=timeout) return self._capture_queue.get_nowait() except queue.Empty: return None