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
8 changes: 8 additions & 0 deletions docs/calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@

::: voip.audio.AudioCall

## Voice Activity Detection

::: voip.audio.VoiceActivityCall

## Echo Call

::: voip.audio.EchoCall

## AI / Agentic Calls

::: voip.ai.TranscribeCall
Expand Down
46 changes: 30 additions & 16 deletions tests/test_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ def test_audio_received__initializes_vad_state(self):
"""WhisperCall starts with an empty speech buffer and no timer."""
call = make_whisper_call(MagicMock())
assert call.speech_buffer == []
assert call.transcription_handle is None
assert call.silence_handle is None

def test_audio_received__silence_audio_accumulates_in_buffer(self):
"""Silence audio (below speech_threshold) is still buffered for transcription."""
call = make_whisper_call(MagicMock())
with patch("voip.ai.asyncio.get_event_loop"):
with patch("voip.audio.asyncio.get_running_loop"):
call.audio_received(audio=np.zeros(320, dtype=np.float32), rms=0.0)
assert len(call.speech_buffer) == 1

Expand All @@ -133,36 +133,36 @@ def test_audio_received__speech_audio_accumulates_in_buffer(self):
def test_audio_received__silence_arms_transcription_timer(self):
"""Silence arms the transcription debounce timer."""
call = make_whisper_call(MagicMock())
with patch("voip.ai.asyncio.get_event_loop") as mock_loop:
with patch("voip.audio.asyncio.get_running_loop") as mock_loop:
handle = MagicMock()
mock_loop.return_value.call_later.return_value = handle
call.audio_received(audio=np.zeros(320, dtype=np.float32), rms=0.0)
mock_loop.return_value.call_later.assert_called_once_with(
call.silence_gap, call.flush_speech_buffer
)
assert call.transcription_handle is handle
assert call.silence_handle is handle

def test_audio_received__silence_does_not_rearm_when_timer_running(self):
"""A second silence packet does not create a second timer."""
call = make_whisper_call(MagicMock())
call.transcription_handle = MagicMock()
with patch("voip.ai.asyncio.get_event_loop") as mock_loop:
call.silence_handle = MagicMock()
with patch("voip.audio.asyncio.get_running_loop") as mock_loop:
call.audio_received(audio=np.zeros(320, dtype=np.float32), rms=0.0)
mock_loop.return_value.call_later.assert_not_called()

def test_audio_received__speech_cancels_pending_timer(self):
"""Speech audio cancels any running transcription debounce timer."""
call = make_whisper_call(MagicMock())
handle = MagicMock()
call.transcription_handle = handle
call.silence_handle = handle
call.audio_received(audio=np.ones(320, dtype=np.float32) * 0.6, rms=0.6)
handle.cancel.assert_called_once()
assert call.transcription_handle is None
assert call.silence_handle is None

def test_audio_received__empty_array_accumulates_in_buffer(self):
"""Zero-length audio arrays are accepted into the speech buffer."""
call = make_whisper_call(MagicMock())
with patch("voip.ai.asyncio.get_event_loop"):
with patch("voip.audio.asyncio.get_running_loop"):
call.audio_received(audio=np.zeros(0, dtype=np.float32), rms=0.0)
assert len(call.speech_buffer) == 1

Expand All @@ -181,8 +181,6 @@ def transcription_received(self, text: str) -> None:
call = make_whisper_call(model_mock, Capture)
chunk = np.ones(320, dtype=np.float32)
call.speech_buffer = [chunk] * 60
# Set silence_gap=0 so the minimum-length check passes with one chunk.
call.silence_gap = 0
call.flush_speech_buffer()
await asyncio.sleep(0.1)
assert transcriptions == ["hello"]
Expand All @@ -191,18 +189,34 @@ def transcription_received(self, text: str) -> None:
def test_flush_speech_buffer__no_op_when_buffer_empty(self):
"""_flush_speech_buffer does nothing when the speech buffer is empty."""
call = make_whisper_call(MagicMock())
with patch("voip.ai.asyncio.create_task") as mock_ct:
with patch("voip.audio.asyncio.create_task") as mock_ct:
call.flush_speech_buffer()
mock_ct.assert_not_called()

def test_flush_speech_buffer__resets_state(self):
"""_flush_speech_buffer clears _transcription_handle and the speech buffer."""
"""_flush_speech_buffer clears silence_handle and the speech buffer."""
call = make_whisper_call(MagicMock())
call.transcription_handle = MagicMock()
call.silence_handle = MagicMock()
call.speech_buffer = [np.zeros(1, dtype=np.float32)]
with patch("voip.ai.asyncio.create_task"):
with patch("voip.audio.asyncio.create_task", side_effect=lambda c: c.close()):
call.flush_speech_buffer()
assert call.transcription_handle is None
assert call.silence_handle is None

async def test_speech_buffer_ready__skips_short_audio(self):
"""speech_buffer_ready discards audio shorter than one second."""
transcriptions = []
model_mock = MagicMock()

class Capture(TranscribeCall):
def transcription_received(self, text: str) -> None:
transcriptions.append(text)

call = make_whisper_call(model_mock, Capture)
# Feed fewer samples than RESAMPLING_RATE_HZ (1 second)
short_audio = np.ones(100, dtype=np.float32)
await call.speech_buffer_ready(short_audio)
model_mock.transcribe.assert_not_called()
assert transcriptions == []

async def test_transcribe__strips_whitespace(self):
"""Strip leading and trailing whitespace from the transcription text."""
Expand Down
174 changes: 172 additions & 2 deletions tests/test_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
from __future__ import annotations

import asyncio
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

np = pytest.importorskip("numpy")
av = pytest.importorskip("av")

from voip.audio import AudioCall # noqa: E402
from voip.audio import AudioCall, EchoCall, VoiceActivityCall # noqa: E402
from voip.codecs.g722 import G722 # noqa: E402
from voip.codecs.opus import Opus # noqa: E402
from voip.codecs.pcma import PCMA # noqa: E402
Expand Down Expand Up @@ -448,3 +448,173 @@ async def capture_sleep(delay: float) -> None:

assert len(sleep_calls) == 2
assert all(s == call.RTP_PACKET_DURATION_SECS for s in sleep_calls)


def make_echo_call(**kwargs) -> EchoCall:
"""Create an EchoCall with mock rtp/sip for unit testing."""
defaults: dict = {
"rtp": MagicMock(spec=RealtimeTransportProtocol),
"sip": MagicMock(),
"media": PCMU_MEDIA,
"caller": CallerID(""),
}
defaults.update(kwargs)
return EchoCall(**defaults)


def make_vac_call(**kwargs) -> VoiceActivityCall:
"""Create a VoiceActivityCall with mock rtp/sip for unit testing."""
defaults: dict = {
"rtp": MagicMock(spec=RealtimeTransportProtocol),
"sip": MagicMock(),
"media": PCMU_MEDIA,
"caller": CallerID(""),
}
defaults.update(kwargs)
return VoiceActivityCall(**defaults)


class TestVoiceActivityCall:
"""Tests for the shared VAD infrastructure in VoiceActivityCall."""

def test_voice_activity_call__is_audio_call(self):
"""VoiceActivityCall is a subclass of AudioCall."""
assert issubclass(VoiceActivityCall, AudioCall)

def test_collect_audio__returns_true_for_speech(self):
"""collect_audio returns True when RMS exceeds speech_threshold."""
call = make_vac_call()
assert call.collect_audio(np.ones(160, dtype=np.float32), rms=1.0) is True

def test_collect_audio__returns_false_for_silence(self):
"""collect_audio returns False when RMS is at or below speech_threshold."""
call = make_vac_call()
assert call.collect_audio(np.zeros(160, dtype=np.float32), rms=0.0) is False

def test_audio_received__buffers_speech_frames(self):
"""audio_received buffers frames with RMS above speech_threshold."""
call = make_vac_call()
call.audio_received(audio=np.ones(160, dtype=np.float32), rms=1.0)
assert len(call.speech_buffer) == 1

def test_audio_received__does_not_buffer_silence_frames(self):
"""audio_received skips frames with RMS at or below speech_threshold."""
call = make_vac_call()
call.audio_received(audio=np.zeros(160, dtype=np.float32), rms=0.0)
assert len(call.speech_buffer) == 0

def test_on_audio_speech__cancels_silence_timer(self):
"""on_audio_speech cancels a running silence timer."""
call = make_vac_call()
handle = MagicMock()
call.silence_handle = handle
call.on_audio_speech()
handle.cancel.assert_called_once()
assert call.silence_handle is None

def test_on_audio_speech__noop_when_no_timer(self):
"""on_audio_speech does nothing when no silence timer is running."""
call = make_vac_call()
call.on_audio_speech() # must not raise
assert call.silence_handle is None

@pytest.mark.asyncio
async def test_on_audio_silence__arms_timer_when_buffer_has_speech(self):
"""on_audio_silence schedules the silence timer when speech is buffered."""
call = make_vac_call()
call.speech_buffer.append(np.ones(160, dtype=np.float32))
call.on_audio_silence()
assert call.silence_handle is not None
call.silence_handle.cancel()

@pytest.mark.asyncio
async def test_on_audio_silence__noop_when_buffer_is_empty(self):
"""on_audio_silence does not schedule a timer when no speech is buffered."""
call = make_vac_call()
call.on_audio_silence()
assert call.silence_handle is None

@pytest.mark.asyncio
async def test_on_audio_silence__noop_when_timer_already_running(self):
"""on_audio_silence does not replace a running silence timer."""
call = make_vac_call()
call.speech_buffer.append(np.ones(160, dtype=np.float32))
call.on_audio_silence()
first_handle = call.silence_handle
call.on_audio_silence()
assert call.silence_handle is first_handle
call.silence_handle.cancel()

@pytest.mark.asyncio
async def test_flush_speech_buffer__resets_handle_and_schedules_ready(self):
"""flush_speech_buffer clears the buffer and schedules speech_buffer_ready."""
call = make_vac_call()
call.speech_buffer.append(np.ones(160, dtype=np.float32))
with patch.object(
call, "speech_buffer_ready", new_callable=AsyncMock
) as mock_ready:
call.flush_speech_buffer()
await asyncio.sleep(0)
assert call.silence_handle is None
assert len(call.speech_buffer) == 0
mock_ready.assert_awaited_once()

def test_flush_speech_buffer__noop_when_buffer_empty(self):
"""flush_speech_buffer does nothing when no speech is buffered."""
call = make_vac_call()
with patch("voip.audio.asyncio.create_task") as mock_ct:
call.flush_speech_buffer()
mock_ct.assert_not_called()

@pytest.mark.asyncio
async def test_speech_buffer_ready__noop_in_base(self):
"""speech_buffer_ready is a no-op in the base VoiceActivityCall."""
call = make_vac_call()
await call.speech_buffer_ready(
np.zeros(160, dtype=np.float32)
) # must not raise


class TestEchoCall:
"""Tests for EchoCall speech echo playback."""

def test_echo_call__is_voice_activity_call(self):
"""EchoCall is a subclass of VoiceActivityCall."""
assert issubclass(EchoCall, VoiceActivityCall)

@pytest.mark.asyncio
async def test_speech_buffer_ready__sends_resampled_audio(self):
"""speech_buffer_ready resamples from RESAMPLING_RATE_HZ to codec rate and sends via RTP."""
call = make_echo_call(media=PCMU_MEDIA)
audio = np.ones(160, dtype=np.float32)
with patch.object(call, "send_rtp_audio", new_callable=AsyncMock) as mock_send:
await call.speech_buffer_ready(audio)
mock_send.assert_awaited_once()
sent_audio = mock_send.call_args[0][0]
# PCMU sample_rate_hz == 8000; RESAMPLING_RATE_HZ == 16000 → half length
expected_len = round(
len(audio) * call.codec.sample_rate_hz / call.RESAMPLING_RATE_HZ
)
assert len(sent_audio) == expected_len

@pytest.mark.asyncio
async def test_audio_received__echoes_after_sustained_silence(self):
"""Speech followed by silence_gap of silence triggers echo playback."""
call = make_echo_call(silence_gap=0.01)
remote_addr = ("10.0.0.1", 5004)
call.rtp.calls = {remote_addr: call}

speech = np.ones(160, dtype=np.float32) * 0.5
silence = np.zeros(160, dtype=np.float32)

sent: list[np.ndarray] = []

async def capture_send(audio: np.ndarray) -> None:
sent.append(audio)

with patch.object(call, "send_rtp_audio", side_effect=capture_send):
call.audio_received(audio=speech, rms=1.0)
call.audio_received(audio=silence, rms=0.0)
await asyncio.sleep(0.05)

assert len(sent) == 1
Loading
Loading