Skip to content

Commit 95690ca

Browse files
fix(rtc): keep an AudioMixer input alive after a read timeout (#862)
1 parent d5b847c commit 95690ca

2 files changed

Lines changed: 189 additions & 7 deletions

File tree

‎livekit-rtc/livekit/rtc/audio_mixer.py‎

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
_Stream = AsyncIterator[AudioFrame]
1010

11+
# consecutive timeouts after which a stream is dropped once end_input() was called
12+
_MAX_STALLED_ROUNDS = 10
13+
1114

1215
@dataclass
1316
class _Contribution:
@@ -52,6 +55,10 @@ def __init__(
5255
"""
5356
self._streams: set[_Stream] = set()
5457
self._buffers: dict[_Stream, np.ndarray] = {}
58+
# A read that timed out is kept for the next round: cancelling it would close an
59+
# async generator stream for good.
60+
self._pending_reads: dict[_Stream, asyncio.Future[AudioFrame]] = {}
61+
self._stalled_rounds: dict[_Stream, int] = {}
5562
self._sample_rate: int = sample_rate
5663
self._num_channels: int = num_channels
5764
self._chunk_size: int = blocksize if blocksize > 0 else int(sample_rate // 10)
@@ -90,6 +97,10 @@ def remove_stream(self, stream: AsyncIterator[AudioFrame]) -> None:
9097
"""
9198
self._streams.discard(stream)
9299
self._buffers.pop(stream, None)
100+
self._stalled_rounds.pop(stream, None)
101+
pending = self._pending_reads.pop(stream, None)
102+
if pending is not None:
103+
_discard_read(pending)
93104

94105
def __aiter__(self) -> "AudioMixer":
95106
return self
@@ -105,11 +116,19 @@ async def aclose(self) -> None:
105116
Immediately stop mixing and close the mixer.
106117
107118
This cancels the mixing task, and any unconsumed output in the queue may be dropped.
119+
Pending stream reads are cancelled and awaited, so a stream must let
120+
asyncio.CancelledError propagate once its cleanup is done.
108121
"""
109122
self._ending = True
110123
self._mixer_task.cancel()
111124
with contextlib.suppress(asyncio.CancelledError):
112125
await self._mixer_task
126+
pending = list(self._pending_reads.values())
127+
self._pending_reads.clear()
128+
for read in pending:
129+
_discard_read(read)
130+
# let the streams finish their cleanup before the mixer counts as closed
131+
await asyncio.gather(*pending, return_exceptions=True)
113132

114133
def end_input(self) -> None:
115134
"""
@@ -173,17 +192,34 @@ async def _get_contribution(
173192
) -> _Contribution:
174193
had_data = buf.shape[0] > 0
175194
exhausted = False
176-
while buf.shape[0] < self._chunk_size and not exhausted:
177-
try:
178-
frame = await asyncio.wait_for(
179-
stream.__anext__(), timeout=self._stream_timeout_ms / 1000
180-
)
181-
except asyncio.TimeoutError:
195+
# a round scheduled before remove_stream() must not start a new read on that stream
196+
while buf.shape[0] < self._chunk_size and not exhausted and stream in self._streams:
197+
# The read stays in _pending_reads until it completes, so remove_stream()
198+
# and aclose() can always cancel it.
199+
read = self._pending_reads.get(stream)
200+
if read is None:
201+
read = asyncio.ensure_future(stream.__anext__())
202+
self._pending_reads[stream] = read
203+
done, _ = await asyncio.wait({read}, timeout=self._stream_timeout_ms / 1000)
204+
if not done:
182205
logger.warning(f"AudioMixer: stream {stream} timeout, ignoring")
206+
stalled = self._stalled_rounds.get(stream, 0) + 1
207+
self._stalled_rounds[stream] = stalled
208+
# after end_input() a stream that keeps stalling is dropped, so the drain ends
209+
exhausted = self._ending and stalled >= _MAX_STALLED_ROUNDS
183210
break
211+
self._stalled_rounds.pop(stream, None)
212+
if self._pending_reads.get(stream) is read:
213+
del self._pending_reads[stream]
214+
try:
215+
frame = read.result()
184216
except StopAsyncIteration:
185217
exhausted = True
186218
break
219+
except asyncio.TimeoutError:
220+
# raised by the stream itself: handled like the mixer's own timeout, as before
221+
logger.warning(f"AudioMixer: stream {stream} timeout, ignoring")
222+
break
187223
new_data = np.frombuffer(frame.data.tobytes(), dtype=np.int16).reshape(
188224
-1, self._num_channels
189225
)
@@ -198,3 +234,14 @@ async def _get_contribution(
198234
np.empty((0, self._num_channels), dtype=np.int16),
199235
)
200236
return _Contribution(stream, contrib, buf, had_data, exhausted)
237+
238+
239+
def _discard_read(read: "asyncio.Future[AudioFrame]") -> None:
240+
"""Cancel a read that is no longer wanted and consume its outcome whenever it ends."""
241+
read.cancel()
242+
read.add_done_callback(_consume_outcome)
243+
244+
245+
def _consume_outcome(read: "asyncio.Future[AudioFrame]") -> None:
246+
if not read.cancelled():
247+
read.exception() # mark a failed read as handled

‎tests/rtc/test_mixer.py‎

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# type: ignore
22

3+
import asyncio
4+
35
import numpy as np
46
import pytest
57

6-
from livekit.rtc import AudioMixer
8+
from livekit.rtc import AudioFrame, AudioMixer
79
from livekit.rtc.utils import sine_wave_generator
810

911
SAMPLE_RATE = 48000
@@ -56,3 +58,136 @@ async def test_mixer_two_sine_waves():
5658
# Assert that the peaks include 440Hz and 880Hz (with a tolerance of ±5 Hz)
5759
assert any(np.isclose(peak_freqs, 440, atol=5)), f"Expected 440 Hz in peaks, got: {peak_freqs}"
5860
assert any(np.isclose(peak_freqs, 880, atol=5)), f"Expected 880 Hz in peaks, got: {peak_freqs}"
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_mixer_keeps_async_generator_after_a_timeout():
65+
"""
66+
A stream that misses the timeout once must not be closed: cancelling the
67+
pending read of an async generator ends it, which dropped the rest of its audio.
68+
"""
69+
frames, block = 10, 480
70+
71+
async def late_once():
72+
for i in range(frames):
73+
if i == 3:
74+
await asyncio.sleep(0.25)
75+
yield AudioFrame(np.full(block, 1000, dtype=np.int16).tobytes(), SAMPLE_RATE, 1, block)
76+
77+
mixer = AudioMixer(
78+
sample_rate=SAMPLE_RATE, num_channels=1, blocksize=block, stream_timeout_ms=100
79+
)
80+
mixer.add_stream(late_once())
81+
mixer.end_input()
82+
83+
with_audio = 0
84+
async for frame in mixer:
85+
if np.frombuffer(frame.data.tobytes(), dtype=np.int16).any():
86+
with_audio += 1
87+
await mixer.aclose()
88+
89+
assert with_audio == frames
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_mixer_drops_a_stalled_stream_after_end_input():
94+
async def one_frame_then_stall():
95+
yield AudioFrame(np.full(480, 1000, dtype=np.int16).tobytes(), SAMPLE_RATE, 1, 480)
96+
await asyncio.Event().wait()
97+
98+
mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480, stream_timeout_ms=50)
99+
mixer.add_stream(one_frame_then_stall())
100+
mixer.end_input()
101+
102+
async def drain():
103+
return [frame async for frame in mixer]
104+
105+
draining = asyncio.ensure_future(drain())
106+
done, _ = await asyncio.wait({draining}, timeout=1)
107+
await mixer.aclose()
108+
assert done
109+
assert len(draining.result()) == 1
110+
111+
112+
@pytest.mark.asyncio
113+
@pytest.mark.parametrize("how", ["remove_stream", "aclose"])
114+
async def test_mixer_cancels_a_pending_read_when_the_stream_goes_away(how):
115+
closed = asyncio.Event()
116+
117+
async def never_yields():
118+
try:
119+
await asyncio.Event().wait()
120+
yield # pragma: no cover
121+
finally:
122+
closed.set()
123+
124+
mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480, stream_timeout_ms=50)
125+
stream = never_yields()
126+
mixer.add_stream(stream)
127+
await asyncio.sleep(0.12) # at least one round has timed out on the stream
128+
129+
if how == "remove_stream":
130+
mixer.remove_stream(stream)
131+
else:
132+
await mixer.aclose()
133+
134+
await asyncio.wait_for(closed.wait(), timeout=1)
135+
assert not mixer._pending_reads
136+
await mixer.aclose()
137+
138+
139+
@pytest.mark.asyncio
140+
async def test_mixer_starts_no_read_on_a_removed_stream():
141+
# a round can be scheduled before remove_stream() and run after it
142+
async def never_yields():
143+
await asyncio.Event().wait()
144+
yield # pragma: no cover
145+
146+
mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480, stream_timeout_ms=10)
147+
stream = never_yields()
148+
mixer.add_stream(stream)
149+
mixer.remove_stream(stream)
150+
151+
await mixer._get_contribution(stream, np.empty((0, 1), dtype=np.int16))
152+
assert not mixer._pending_reads
153+
await mixer.aclose()
154+
155+
156+
@pytest.mark.asyncio
157+
async def test_mixer_keeps_buffered_audio_when_a_stream_raises_timeout_error():
158+
async def partial_then_timeout():
159+
yield AudioFrame(np.full(240, 1000, dtype=np.int16).tobytes(), SAMPLE_RATE, 1, 240)
160+
raise asyncio.TimeoutError
161+
162+
mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480)
163+
mixer.add_stream(partial_then_timeout())
164+
mixer.end_input()
165+
166+
with_audio = 0
167+
async for frame in mixer:
168+
if np.frombuffer(frame.data.tobytes(), dtype=np.int16).any():
169+
with_audio += 1
170+
await mixer.aclose()
171+
172+
assert with_audio == 1
173+
174+
175+
@pytest.mark.asyncio
176+
async def test_mixer_aclose_waits_for_a_cancelled_read_to_clean_up():
177+
cleaned_up = False
178+
179+
async def slow_cleanup():
180+
nonlocal cleaned_up
181+
try:
182+
await asyncio.Event().wait()
183+
yield # pragma: no cover
184+
finally:
185+
await asyncio.sleep(0.1)
186+
cleaned_up = True
187+
188+
mixer = AudioMixer(sample_rate=SAMPLE_RATE, num_channels=1, blocksize=480, stream_timeout_ms=50)
189+
mixer.add_stream(slow_cleanup())
190+
await asyncio.sleep(0.12)
191+
192+
await mixer.aclose()
193+
assert cleaned_up

0 commit comments

Comments
 (0)