axio-audio 0.9.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .pytest_cache/
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ _build/
12
+ .DS_Store
13
+ *.sqlite
14
+ *.db
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: axio-audio
3
+ Version: 0.9.2
4
+ Summary: Microphone and speaker helpers for Axio realtime agents
5
+ Project-URL: Documentation, https://docs.axio-agent.com
6
+ Project-URL: Homepage, https://github.com/mosquito/axio-agent
7
+ Project-URL: Repository, https://github.com/mosquito/axio-agent
8
+ License: MIT
9
+ Keywords: agent,ai,audio,llm,realtime,sounddevice
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: axio
12
+ Requires-Dist: numpy>=2
13
+ Requires-Dist: sounddevice>=0.5
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "axio-audio"
3
+ version = "0.9.2"
4
+ description = "Microphone and speaker helpers for Axio realtime agents"
5
+ requires-python = ">=3.12"
6
+ license = {text = "MIT"}
7
+ keywords = ["llm", "agent", "ai", "audio", "realtime", "sounddevice"]
8
+ dependencies = ["axio", "sounddevice>=0.5", "numpy>=2"]
9
+
10
+ [project.urls]
11
+ Documentation = "https://docs.axio-agent.com"
12
+ Homepage = "https://github.com/mosquito/axio-agent"
13
+ Repository = "https://github.com/mosquito/axio-agent"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/axio_audio"]
21
+
22
+ [tool.pytest.ini_options]
23
+ asyncio_mode = "auto"
24
+ testpaths = ["tests"]
25
+
26
+ [tool.ruff]
27
+ line-length = 119
28
+ output-format = "concise"
29
+ target-version = "py312"
30
+
31
+ [tool.ruff.lint]
32
+ select = ["E", "F", "I", "UP"]
33
+
34
+ [tool.mypy]
35
+ strict = true
36
+ python_version = "3.12"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=8",
41
+ "pytest-asyncio>=0.24",
42
+ "mypy>=1.14",
43
+ "ruff>=0.9",
44
+ "pytest-cov>=7.1.0",
45
+ ]
@@ -0,0 +1,7 @@
1
+ """Microphone and speaker helpers for axio realtime agents."""
2
+
3
+ from .duplex import DuplexAudio
4
+ from .microphone import Microphone
5
+ from .speaker import Speaker
6
+
7
+ __all__ = ["DuplexAudio", "Microphone", "Speaker"]
@@ -0,0 +1,291 @@
1
+ """Sample-aligned duplex audio: one PortAudio stream, one callback, one clock.
2
+
3
+ ``Microphone`` and ``Speaker`` open independent ``sd.RawInputStream`` and
4
+ ``sd.RawOutputStream`` instances — each on its own PortAudio host clock.
5
+ On most consumer audio stacks (PipeWire/PulseAudio over ALSA) those two
6
+ clocks drift by tens of ppm relative to each other, accumulating
7
+ ~10–25 ms/sec of skew between captured mic samples and played speaker
8
+ samples. Echo cancellers — Speex linear AEC, AEC3, anything — depend on
9
+ the relative timing of the far-end reference (speaker output) and the
10
+ near-end signal (mic input). Drift past the algorithm's filter / search
11
+ window destroys cancellation quality after only a few minutes of
12
+ continuous use.
13
+
14
+ ``DuplexAudio`` opens **one** ``sd.RawStream`` with both directions
15
+ hooked into a single callback. Mic and speaker share the same
16
+ PortAudio clock; relative timing is sample-exact for the lifetime of
17
+ the stream. This is the same architecture PipeWire's
18
+ ``module-echo-cancel`` uses internally and is what lets in-process
19
+ AEC3 reach production-grade suppression without an external graph.
20
+
21
+ Usage mirrors the ``Microphone`` / ``Speaker`` pair so callers can
22
+ swap with minimal change::
23
+
24
+ async with DuplexAudio(sample_rate=48000) as duplex:
25
+ async def consume_mic() -> None:
26
+ async for chunk in duplex.mic_chunks():
27
+ await agent.send(chunk)
28
+
29
+ async def play_audio_output() -> None:
30
+ async for ev in agent.events():
31
+ if isinstance(ev, AudioOutputDelta):
32
+ await duplex.feed_speaker(ev.data)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import threading
39
+ from collections.abc import AsyncIterator, Callable
40
+ from dataclasses import dataclass, field
41
+ from types import TracebackType
42
+ from typing import Any, Self
43
+
44
+ import numpy as np
45
+ import sounddevice as sd # type: ignore[import-untyped]
46
+ from axio.blocks import AudioBlock
47
+
48
+
49
+ @dataclass
50
+ class DuplexAudio:
51
+ """One sd.RawStream, both directions, sample-aligned by PortAudio.
52
+
53
+ The callback runs on PortAudio's audio thread for every block of
54
+ ``chunk_ms`` worth of frames; it (a) pulls the next chunk of
55
+ speaker bytes out of an internal buffer into ``outdata`` and (b)
56
+ forwards ``indata`` to a thread-safe queue that the asyncio side
57
+ drains via :meth:`mic_chunks`.
58
+
59
+ ``channels`` is how many channels we open the **device** with. On
60
+ a stereo-only device (e.g. PipeWire's ``Echo-Cancel Sink/Source``,
61
+ or most laptop default outputs) you must set ``channels=2`` to
62
+ drive both speakers correctly. The ``feed_speaker`` /
63
+ ``mic_chunks`` API itself is mono — ``feed_speaker`` upmixes by
64
+ duplicating samples across channels and ``mic_chunks`` downmixes by
65
+ averaging. Set ``mono_io=False`` to skip the conversions and
66
+ pass interleaved PCM through unchanged.
67
+ """
68
+
69
+ sample_rate: int = 48000
70
+ chunk_ms: int = 20
71
+ channels: int = 1
72
+ device: int | str | tuple[int | str | None, int | str | None] | None = None
73
+ mono_io: bool = True
74
+ queue_maxsize: int = 100
75
+ playback_tap: Callable[[bytes], None] | None = None
76
+ """Optional sync callback fired from the audio thread with the *mono*
77
+ bytes we just handed to the speaker (silence-padded when the buffer
78
+ underruns). Useful as a far-end reference for an external AEC, or
79
+ for level meters that need real playback timing rather than enqueue
80
+ timing. Receives the same bytes the mono ``feed_speaker`` API
81
+ accepts, so it's symmetric with ``mic_chunks``."""
82
+
83
+ _stream: sd.RawStream | None = field(default=None, init=False, repr=False)
84
+ _mic_queue: asyncio.Queue[bytes] | None = field(default=None, init=False, repr=False)
85
+ _spk_buffer: bytearray = field(default_factory=bytearray, init=False, repr=False)
86
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
87
+ _loop: asyncio.AbstractEventLoop | None = field(default=None, init=False, repr=False)
88
+
89
+ async def __aenter__(self) -> Self:
90
+ self._loop = asyncio.get_running_loop()
91
+ self._mic_queue = asyncio.Queue(maxsize=self.queue_maxsize)
92
+ loop = self._loop
93
+ queue = self._mic_queue
94
+ ch = self.channels
95
+ bytes_per_device_sample = 2 * ch
96
+
97
+ def callback(
98
+ indata: Any,
99
+ outdata: Any,
100
+ frames: int,
101
+ time_info: Any,
102
+ status: sd.CallbackFlags,
103
+ ) -> None:
104
+ wanted = frames * bytes_per_device_sample
105
+ with self._lock:
106
+ # ``_spk_buffer`` is laid out for the device (multi-channel
107
+ # if ch > 1, see ``feed_speaker``). Copy what we have, pad
108
+ # with silence on underflow so the device never starves.
109
+ available = len(self._spk_buffer)
110
+ n = min(wanted, available)
111
+ if n:
112
+ outdata[:n] = bytes(self._spk_buffer[:n])
113
+ del self._spk_buffer[:n]
114
+ if n < wanted:
115
+ outdata[n:wanted] = b"\x00" * (wanted - n)
116
+
117
+ mono_far: bytes
118
+ mono_near: bytes
119
+ if self.mono_io and ch > 1:
120
+ mono_far = (
121
+ np.frombuffer(bytes(outdata[:wanted]), dtype="<i2")
122
+ .reshape(-1, ch)
123
+ .mean(axis=1)
124
+ .astype("<i2")
125
+ .tobytes()
126
+ )
127
+ mono_near = (
128
+ np.frombuffer(bytes(indata), dtype="<i2").reshape(-1, ch).mean(axis=1).astype("<i2").tobytes()
129
+ )
130
+ else:
131
+ mono_far = bytes(outdata[:wanted])
132
+ mono_near = bytes(indata)
133
+
134
+ # Tap fires before the mic-queue push so the consumer can
135
+ # use it as a perfectly time-aligned far reference. Cheap
136
+ # ops only — this is the audio thread.
137
+ if self.playback_tap is not None:
138
+ try:
139
+ self.playback_tap(mono_far)
140
+ except Exception:
141
+ # Don't propagate — a buggy tap shouldn't kill audio.
142
+ pass
143
+
144
+ loop.call_soon_threadsafe(_put_or_drop, queue, mono_near)
145
+
146
+ chunk_frames = max(1, self.sample_rate * self.chunk_ms // 1000)
147
+ self._stream = sd.RawStream(
148
+ samplerate=self.sample_rate,
149
+ channels=(ch, ch),
150
+ dtype="int16",
151
+ blocksize=chunk_frames,
152
+ device=self.device,
153
+ callback=callback,
154
+ )
155
+ self._stream.start()
156
+ return self
157
+
158
+ async def __aexit__(
159
+ self,
160
+ exc_type: type[BaseException] | None,
161
+ exc: BaseException | None,
162
+ tb: TracebackType | None,
163
+ ) -> None:
164
+ if self._stream is not None:
165
+ self._stream.stop()
166
+ self._stream.close()
167
+ self._stream = None
168
+ if self._mic_queue is not None:
169
+ # Same evict-then-sentinel dance Microphone uses: don't deadlock
170
+ # close on a slow consumer.
171
+ try:
172
+ self._mic_queue.put_nowait(b"")
173
+ except asyncio.QueueFull:
174
+ self._mic_queue.get_nowait()
175
+ self._mic_queue.put_nowait(b"")
176
+
177
+ # ── mic side ─────────────────────────────────────────────────────
178
+
179
+ def mic_chunks(self) -> AsyncIterator[AudioBlock]:
180
+ """Async-iterate captured mic data as :class:`AudioBlock` chunks.
181
+
182
+ Chunk size is whatever PortAudio hands the callback (typically
183
+ ``chunk_ms`` worth of frames). Yields mono PCM16 when
184
+ ``mono_io`` is True, otherwise interleaved as opened.
185
+ """
186
+ return _MicChunks(self)
187
+
188
+ # ── speaker side ─────────────────────────────────────────────────
189
+
190
+ async def feed_speaker(self, pcm: bytes) -> None:
191
+ """Append PCM16 to the speaker buffer.
192
+
193
+ Input is mono when ``mono_io`` is True (samples are duplicated
194
+ across channels for the device), otherwise must be in the
195
+ device's exact channel layout.
196
+ """
197
+ if not pcm:
198
+ return
199
+ if self.mono_io and self.channels > 1:
200
+ mono = np.frombuffer(pcm, dtype="<i2")
201
+ interleaved = np.repeat(mono, self.channels).tobytes()
202
+ else:
203
+ interleaved = pcm
204
+ with self._lock:
205
+ self._spk_buffer.extend(interleaved)
206
+
207
+ async def stop_speaker(self) -> None:
208
+ """Drop everything queued for playback (use on user interruption)."""
209
+ with self._lock:
210
+ self._spk_buffer.clear()
211
+
212
+ def speaker_pending_bytes(self) -> int:
213
+ """Mono-equivalent bytes still waiting to be played."""
214
+ with self._lock:
215
+ n = len(self._spk_buffer)
216
+ if self.mono_io and self.channels > 1:
217
+ return n // self.channels
218
+ return n
219
+
220
+ # ── Microphone / Speaker compatibility views ─────────────────────
221
+ # Existing realtime examples are built around the ``Microphone`` and
222
+ # ``Speaker`` classes; ``duplex.mic`` / ``duplex.speaker`` expose the
223
+ # same surface (async-iter on the mic, ``feed`` / ``stop`` /
224
+ # ``pending_bytes`` on the speaker) so callers can swap to duplex
225
+ # by replacing two ``async with`` arms with one.
226
+
227
+ @property
228
+ def mic(self) -> _MicView:
229
+ return _MicView(self)
230
+
231
+ @property
232
+ def speaker(self) -> _SpeakerView:
233
+ return _SpeakerView(self)
234
+
235
+
236
+ class _MicView:
237
+ """Quacks like :class:`Microphone` — async-iterable of AudioBlocks."""
238
+
239
+ __slots__ = ("_duplex",)
240
+
241
+ def __init__(self, duplex: DuplexAudio) -> None:
242
+ self._duplex = duplex
243
+
244
+ def __aiter__(self) -> _MicChunks:
245
+ return _MicChunks(self._duplex)
246
+
247
+
248
+ class _SpeakerView:
249
+ """Quacks like :class:`Speaker` — feed/stop/pending_bytes."""
250
+
251
+ __slots__ = ("_duplex",)
252
+
253
+ def __init__(self, duplex: DuplexAudio) -> None:
254
+ self._duplex = duplex
255
+
256
+ async def feed(self, pcm: bytes) -> None:
257
+ await self._duplex.feed_speaker(pcm)
258
+
259
+ async def stop(self) -> None:
260
+ await self._duplex.stop_speaker()
261
+
262
+ def pending_bytes(self) -> int:
263
+ return self._duplex.speaker_pending_bytes()
264
+
265
+
266
+ class _MicChunks:
267
+ """Lightweight iterator wrapper so callers don't accidentally exhaust
268
+ the duplex object itself."""
269
+
270
+ __slots__ = ("_duplex",)
271
+
272
+ def __init__(self, duplex: DuplexAudio) -> None:
273
+ self._duplex = duplex
274
+
275
+ def __aiter__(self) -> _MicChunks:
276
+ return self
277
+
278
+ async def __anext__(self) -> AudioBlock:
279
+ if self._duplex._mic_queue is None:
280
+ raise RuntimeError("DuplexAudio not started — use 'async with DuplexAudio() as d:'.")
281
+ chunk = await self._duplex._mic_queue.get()
282
+ if not chunk:
283
+ raise StopAsyncIteration
284
+ return AudioBlock(media_type="audio/pcm", data=chunk)
285
+
286
+
287
+ def _put_or_drop(queue: asyncio.Queue[bytes], data: bytes) -> None:
288
+ try:
289
+ queue.put_nowait(data)
290
+ except asyncio.QueueFull:
291
+ pass
@@ -0,0 +1,99 @@
1
+ """Microphone capture: yields :class:`AudioBlock` chunks from the system microphone."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+ from dataclasses import dataclass, field
8
+ from types import TracebackType
9
+ from typing import Any, Self
10
+
11
+ import sounddevice as sd # type: ignore[import-untyped]
12
+ from axio.blocks import AudioBlock
13
+
14
+
15
+ @dataclass
16
+ class Microphone:
17
+ """Async-iterable wrapper around a sounddevice ``RawInputStream``.
18
+
19
+ Captures PCM16 mono audio at ``sample_rate`` and yields
20
+ :class:`AudioBlock` chunks of approximately ``chunk_ms`` milliseconds
21
+ each. The defaults match the OpenAI Realtime API (24 kHz PCM16).
22
+
23
+ Usage::
24
+
25
+ async with Microphone() as mic:
26
+ async for chunk in mic:
27
+ await agent.send(chunk)
28
+ """
29
+
30
+ sample_rate: int = 24000
31
+ chunk_ms: int = 50
32
+ device: int | str | None = None
33
+ queue_maxsize: int = 100 # cap to avoid unbounded growth on slow consumers
34
+ _stream: sd.RawInputStream | None = field(default=None, init=False, repr=False)
35
+ _queue: asyncio.Queue[bytes] | None = field(default=None, init=False, repr=False)
36
+ _loop: asyncio.AbstractEventLoop | None = field(default=None, init=False, repr=False)
37
+
38
+ async def __aenter__(self) -> Self:
39
+ self._loop = asyncio.get_running_loop()
40
+ self._queue = asyncio.Queue(maxsize=self.queue_maxsize)
41
+ chunk_frames = max(1, self.sample_rate * self.chunk_ms // 1000)
42
+ loop = self._loop
43
+ queue = self._queue
44
+
45
+ def callback(indata: Any, frames: int, time_info: Any, status: sd.CallbackFlags) -> None:
46
+ data = bytes(indata)
47
+ loop.call_soon_threadsafe(_put_or_drop, queue, data)
48
+
49
+ self._stream = sd.RawInputStream(
50
+ samplerate=self.sample_rate,
51
+ channels=1,
52
+ dtype="int16",
53
+ blocksize=chunk_frames,
54
+ device=self.device,
55
+ callback=callback,
56
+ )
57
+ self._stream.start()
58
+ return self
59
+
60
+ async def __aexit__(
61
+ self,
62
+ exc_type: type[BaseException] | None,
63
+ exc: BaseException | None,
64
+ tb: TracebackType | None,
65
+ ) -> None:
66
+ if self._stream is not None:
67
+ self._stream.stop()
68
+ self._stream.close()
69
+ self._stream = None
70
+ if self._queue is not None:
71
+ # Stream is now stopped, so the callback won't push more. Push the
72
+ # sentinel non-blockingly and evict the oldest chunk if the queue
73
+ # is full — a blocking ``put`` here would deadlock close when the
74
+ # consumer has already stopped draining.
75
+ try:
76
+ self._queue.put_nowait(b"")
77
+ except asyncio.QueueFull:
78
+ self._queue.get_nowait()
79
+ self._queue.put_nowait(b"")
80
+
81
+ def __aiter__(self) -> AsyncIterator[AudioBlock]:
82
+ return self
83
+
84
+ async def __anext__(self) -> AudioBlock:
85
+ if self._queue is None:
86
+ raise RuntimeError("Microphone not started — use 'async with Microphone() as mic:'.")
87
+ chunk = await self._queue.get()
88
+ if not chunk:
89
+ raise StopAsyncIteration
90
+ return AudioBlock(media_type="audio/pcm", data=chunk)
91
+
92
+
93
+ def _put_or_drop(queue: asyncio.Queue[bytes], data: bytes) -> None:
94
+ """Push ``data`` into ``queue``; silently drop if full to keep the audio
95
+ callback non-blocking under back-pressure."""
96
+ try:
97
+ queue.put_nowait(data)
98
+ except asyncio.QueueFull:
99
+ pass
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,94 @@
1
+ """Speaker playback: consumes PCM16 chunks and plays them through the default output device."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from types import TracebackType
9
+ from typing import Any, Self
10
+
11
+ import sounddevice as sd # type: ignore[import-untyped]
12
+
13
+
14
+ @dataclass
15
+ class Speaker:
16
+ """Async-friendly wrapper around a sounddevice ``RawOutputStream``.
17
+
18
+ Holds an internal byte buffer; ``feed()`` appends, the audio callback
19
+ drains as the device asks for samples. ``stop()`` clears the buffer
20
+ (use it to honour user interruptions — drops everything still queued
21
+ so the assistant goes silent immediately).
22
+
23
+ Usage::
24
+
25
+ async with Speaker() as spk:
26
+ async for ev in agent.events():
27
+ if isinstance(ev, AudioOutputDelta):
28
+ await spk.feed(ev.data)
29
+ """
30
+
31
+ sample_rate: int = 24000
32
+ device: int | str | None = None
33
+ playback_tap: Callable[[bytes], None] | None = None
34
+ """Optional callback invoked from the audio thread with each chunk that
35
+ is actually being played. Useful as the far-end reference for an echo
36
+ canceller — the timing here matches what hits the speaker driver, not
37
+ when the application called :meth:`feed`."""
38
+
39
+ _stream: sd.RawOutputStream | None = field(default=None, init=False, repr=False)
40
+ _buffer: bytearray = field(default_factory=bytearray, init=False, repr=False)
41
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
42
+
43
+ def _callback(self, outdata: Any, frames: int, time_info: Any, status: sd.CallbackFlags) -> None:
44
+ wanted = frames * 2 # int16 mono = 2 bytes per frame
45
+ with self._lock:
46
+ available = len(self._buffer)
47
+ n = min(wanted, available)
48
+ if n:
49
+ outdata[:n] = bytes(self._buffer[:n])
50
+ del self._buffer[:n]
51
+ if n < wanted:
52
+ # Pad with silence so the device never starves.
53
+ outdata[n:wanted] = b"\x00" * (wanted - n)
54
+ # Notify the tap with exactly what hit the device this tick — including
55
+ # any silence padding — so the consumer's clock matches real playback.
56
+ if self.playback_tap is not None:
57
+ self.playback_tap(bytes(outdata[:wanted]))
58
+
59
+ async def __aenter__(self) -> Self:
60
+ self._stream = sd.RawOutputStream(
61
+ samplerate=self.sample_rate,
62
+ channels=1,
63
+ dtype="int16",
64
+ device=self.device,
65
+ callback=self._callback,
66
+ )
67
+ self._stream.start()
68
+ return self
69
+
70
+ async def __aexit__(
71
+ self,
72
+ exc_type: type[BaseException] | None,
73
+ exc: BaseException | None,
74
+ tb: TracebackType | None,
75
+ ) -> None:
76
+ if self._stream is not None:
77
+ self._stream.stop()
78
+ self._stream.close()
79
+ self._stream = None
80
+
81
+ async def feed(self, pcm: bytes) -> None:
82
+ """Append PCM16 mono bytes to the playback buffer."""
83
+ with self._lock:
84
+ self._buffer.extend(pcm)
85
+
86
+ async def stop(self) -> None:
87
+ """Drop everything queued for playback (user interrupted)."""
88
+ with self._lock:
89
+ self._buffer.clear()
90
+
91
+ def pending_bytes(self) -> int:
92
+ """Bytes still waiting to be played — useful for back-pressure decisions."""
93
+ with self._lock:
94
+ return len(self._buffer)
@@ -0,0 +1,49 @@
1
+ """Tests for Microphone helpers that don't require an actual audio device."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import pytest
8
+
9
+ from axio_audio.microphone import Microphone, _put_or_drop
10
+
11
+
12
+ async def test_put_or_drop_pushes_when_space_available() -> None:
13
+ queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=2)
14
+ _put_or_drop(queue, b"a")
15
+ _put_or_drop(queue, b"b")
16
+ assert queue.qsize() == 2
17
+ assert await queue.get() == b"a"
18
+ assert await queue.get() == b"b"
19
+
20
+
21
+ async def test_put_or_drop_silently_drops_when_full() -> None:
22
+ queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=1)
23
+ _put_or_drop(queue, b"a")
24
+ _put_or_drop(queue, b"dropped") # full — must not raise
25
+ assert queue.qsize() == 1
26
+ assert await queue.get() == b"a"
27
+
28
+
29
+ async def test_close_does_not_hang_when_queue_full_and_no_consumer() -> None:
30
+ """Regression: ``__aexit__`` used to ``await put(sentinel)`` which deadlocks
31
+ if the consumer has already stopped draining and the queue is at capacity.
32
+ Closing must complete promptly and leave the sentinel in the queue."""
33
+ mic = Microphone(queue_maxsize=2)
34
+ mic._queue = asyncio.Queue(maxsize=2)
35
+ mic._queue.put_nowait(b"chunk1")
36
+ mic._queue.put_nowait(b"chunk2") # queue is full, no one is draining
37
+
38
+ await asyncio.wait_for(mic.__aexit__(None, None, None), timeout=1.0)
39
+
40
+ drained: list[bytes] = []
41
+ while not mic._queue.empty():
42
+ drained.append(mic._queue.get_nowait())
43
+ assert b"" in drained, "sentinel must be present after close"
44
+
45
+
46
+ async def test_anext_raises_before_aenter() -> None:
47
+ mic = Microphone()
48
+ with pytest.raises(RuntimeError):
49
+ await mic.__anext__()
@@ -0,0 +1,51 @@
1
+ """Tests for Speaker buffer semantics — exercise the callback directly without
2
+ actually opening an audio device."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import pytest
7
+
8
+ from axio_audio.speaker import Speaker
9
+
10
+
11
+ @pytest.fixture
12
+ def speaker() -> Speaker:
13
+ return Speaker()
14
+
15
+
16
+ def test_callback_drains_buffer(speaker: Speaker) -> None:
17
+ pcm = bytes(range(20)) # 10 int16 frames
18
+ speaker._buffer.extend(pcm)
19
+ out = bytearray(20)
20
+ speaker._callback(out, frames=10, time_info=None, status=0)
21
+ assert bytes(out) == pcm
22
+ assert len(speaker._buffer) == 0
23
+
24
+
25
+ def test_callback_pads_with_silence_when_underflowing(speaker: Speaker) -> None:
26
+ speaker._buffer.extend(b"\x01\x02\x03\x04") # 2 frames of audio
27
+ out = bytearray(20) # caller wants 10 frames = 20 bytes
28
+ speaker._callback(out, frames=10, time_info=None, status=0)
29
+ assert bytes(out[:4]) == b"\x01\x02\x03\x04"
30
+ assert bytes(out[4:]) == b"\x00" * 16
31
+
32
+
33
+ def test_callback_only_consumes_requested_frames(speaker: Speaker) -> None:
34
+ speaker._buffer.extend(bytes(range(40)))
35
+ out = bytearray(10)
36
+ speaker._callback(out, frames=5, time_info=None, status=0)
37
+ assert bytes(out) == bytes(range(10))
38
+ assert len(speaker._buffer) == 30 # remainder still queued
39
+
40
+
41
+ async def test_feed_appends_bytes(speaker: Speaker) -> None:
42
+ await speaker.feed(b"\xaa\xbb")
43
+ await speaker.feed(b"\xcc\xdd")
44
+ assert bytes(speaker._buffer) == b"\xaa\xbb\xcc\xdd"
45
+
46
+
47
+ async def test_stop_clears_buffer(speaker: Speaker) -> None:
48
+ await speaker.feed(b"\xff" * 100)
49
+ assert speaker.pending_bytes() == 100
50
+ await speaker.stop()
51
+ assert speaker.pending_bytes() == 0