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.
- axio_audio-0.9.2/.gitignore +14 -0
- axio_audio-0.9.2/PKG-INFO +13 -0
- axio_audio-0.9.2/pyproject.toml +45 -0
- axio_audio-0.9.2/src/axio_audio/__init__.py +7 -0
- axio_audio-0.9.2/src/axio_audio/duplex.py +291 -0
- axio_audio-0.9.2/src/axio_audio/microphone.py +99 -0
- axio_audio-0.9.2/src/axio_audio/py.typed +1 -0
- axio_audio-0.9.2/src/axio_audio/speaker.py +94 -0
- axio_audio-0.9.2/tests/test_microphone.py +49 -0
- axio_audio-0.9.2/tests/test_speaker.py +51 -0
- axio_audio-0.9.2/uv.lock +521 -0
|
@@ -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,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
|