Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setup(
name="whatsapp-chatbot-python",
version="1.1.0",
version="1.1.1",
description=(
"This library helps you easily create"
" a Python chatbot with WhatsApp API."
Expand Down
155 changes: 151 additions & 4 deletions tests/calls/test_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@

import asyncio
import unittest
from collections import deque
from fractions import Fraction

import av

from whatsapp_chatbot_python.calls.audio import AudioBridge, BotTrack, CallerSink, RecordingTap
from whatsapp_chatbot_python.calls.models import InputAudioOptions, MediaFailed, PCM
from aiortc.mediastreams import MediaStreamError

from whatsapp_chatbot_python.calls.audio import (
AudioBridge,
BotTrack,
CallerSink,
RecordingTap,
_InputAudioBuffer,
)

from whatsapp_chatbot_python.calls.models import (
AudioLoss, InputAudioOptions, MediaFailed, PCM,
)
from whatsapp_chatbot_python.calls.recorder import Track


Expand Down Expand Up @@ -42,6 +53,130 @@ def disable(self, error):


class AudioBoundaryTest(unittest.IsolatedAsyncioTestCase):
async def test_input_overflow_keeps_recent_audio_and_does_not_fail(self):
buffer = _InputAudioBuffer(InputAudioOptions(
buffer_seconds=PCM.frame_seconds,
enqueue_seconds=0.001,
))

await buffer.put(b"\x01\x00" * PCM.samples_per_frame)
await buffer.put(b"\x02\x00" * PCM.samples_per_frame)
self.assertEqual(buffer.dropped_samples, PCM.samples_per_frame)
self.assertEqual(await buffer.get(), b"\x02\x00" * PCM.samples_per_frame)
await buffer.finish()
self.assertIsNone(await buffer.get())

async def test_sustained_input_overflow_does_not_wait_for_every_frame(self):
buffer = _InputAudioBuffer(InputAudioOptions(
buffer_seconds=PCM.frame_seconds,
enqueue_seconds=0.05,
))

await buffer.put(FRAME)
newer = b"\x05\x00" * PCM.samples_per_frame
await asyncio.wait_for(
buffer.put(newer * 100), timeout=0.5)
self.assertEqual(await buffer.get(), newer)
self.assertEqual(buffer.dropped_samples, 100 * PCM.samples_per_frame)

async def test_caller_reports_audio_loss_and_continues_sending(self):
class ReadyTrack:
def __init__(self):
self.frames_left = 10

async def recv(self):
if not self.frames_left:
raise MediaStreamError
self.frames_left -= 1
frame = av.AudioFrame(format="s16", layout="mono", samples=PCM.samples_per_frame)
frame.planes[0].update(FRAME)
frame.sample_rate = PCM.rate
return frame

class SlowSink:
def __init__(self):
self.sent = 0

async def write_audio(self, data):
await asyncio.sleep(0.005)
self.sent += 1

sink, events = SlowSink(), []
caller = CallerSink(
sink, RecordingTap(Capture()), events.append,
InputAudioOptions(buffer_seconds=PCM.frame_seconds, enqueue_seconds=0.001),
)
await asyncio.wait_for(caller._consume(ReadyTrack()), 1)

self.assertGreater(sink.sent, 0)
self.assertTrue(events)
self.assertTrue(all(isinstance(event, AudioLoss) for event in events))
self.assertGreater(sum(event.dropped_samples for event in events), 0)

async def test_finishing_input_buffer_wakes_waiting_sender(self):
buffer = _InputAudioBuffer(InputAudioOptions())
sender = asyncio.create_task(buffer.get())
await asyncio.sleep(0)
await buffer.finish()
self.assertIsNone(await asyncio.wait_for(sender, 0.1))

async def test_waiting_writer_cannot_reopen_finished_input_buffer(self):
buffer = _InputAudioBuffer(InputAudioOptions(
buffer_seconds=PCM.frame_seconds,
enqueue_seconds=0.1,
))
await buffer.put(FRAME)
writer = asyncio.create_task(buffer.put(FRAME))
await asyncio.sleep(0)
await buffer.finish()
with self.assertRaisesRegex(RuntimeError, "closed"):
await asyncio.wait_for(writer, 0.1)

async def _assert_rtc_frames_sent(self, frame_count: int) -> None:
class ReadyTrack:
def __init__(self, frames):
self.frames = deque(frames)

async def recv(self):
if not self.frames:
raise MediaStreamError
return self.frames.popleft()

class FastSink:
def __init__(self):
self.sent = []

async def write_audio(self, data):
self.sent.append(data)

expected = []
frames = []
for index in range(frame_count):
data = index.to_bytes(2, "little") * PCM.samples_per_frame
frame = av.AudioFrame(format="s16", layout="mono", samples=PCM.samples_per_frame)
frame.planes[0].update(data)
frame.sample_rate = PCM.rate
frames.append(frame)
expected.append(data)

sink = FastSink()
failures = []
caller = CallerSink(sink, RecordingTap(Capture()), failures.append)
try:
await asyncio.wait_for(caller._consume(ReadyTrack(frames)), timeout=1)
self.assertEqual(sink.sent, expected)
self.assertEqual(sum(len(data) for data in sink.sent),
frame_count * PCM.bytes_per_frame)
self.assertEqual(failures, [])
finally:
await caller.close()

async def test_26_ready_rtc_frames_do_not_overflow_with_a_fast_sender(self):
await self._assert_rtc_frames_sent(26)

async def test_100_ready_rtc_frames_are_sent_in_order(self):
await self._assert_rtc_frames_sent(100)

async def test_sender_failure_is_reported_without_another_rtc_frame(self):
class OneFrameTrack:
def __init__(self):
Expand Down Expand Up @@ -98,7 +233,8 @@ async def write_audio(self, data):
track = FastTrack()
caller = CallerSink(
SlowSink(), RecordingTap(Capture()), lambda event: None,
InputAudioOptions(buffer_seconds=PCM.frame_seconds, send_seconds=0.1),
InputAudioOptions(buffer_seconds=PCM.frame_seconds, send_seconds=0.1,
enqueue_seconds=0.01, overflow_policy="fail"),
)

with self.assertRaisesRegex(BufferError, "Inbound audio buffer"):
Expand All @@ -124,6 +260,17 @@ async def test_bot_frames_have_20ms_pcm_timestamps_and_are_recorded(self):
finally:
track.stop()

async def test_bot_track_resets_pacing_after_a_long_pause(self):
source = Source()
track = BotTrack(source, RecordingTap(Capture()), lambda event: None)
try:
track._epoch -= 10
await track.recv()
self.assertLess(abs(track._epoch + track._position / PCM.rate
- asyncio.get_running_loop().time()), 0.1)
finally:
track.stop()

async def test_reconnect_replaces_track_and_stops_old_audio(self):
source, capture = Source(), Capture()
bridge = AudioBridge(source, source, capture, lambda event: None)
Expand Down
Loading
Loading