|
1 | 1 | # type: ignore |
2 | 2 |
|
| 3 | +import asyncio |
| 4 | + |
3 | 5 | import numpy as np |
4 | 6 | import pytest |
5 | 7 |
|
6 | | -from livekit.rtc import AudioMixer |
| 8 | +from livekit.rtc import AudioFrame, AudioMixer |
7 | 9 | from livekit.rtc.utils import sine_wave_generator |
8 | 10 |
|
9 | 11 | SAMPLE_RATE = 48000 |
@@ -56,3 +58,136 @@ async def test_mixer_two_sine_waves(): |
56 | 58 | # Assert that the peaks include 440Hz and 880Hz (with a tolerance of ±5 Hz) |
57 | 59 | assert any(np.isclose(peak_freqs, 440, atol=5)), f"Expected 440 Hz in peaks, got: {peak_freqs}" |
58 | 60 | 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