1 parent
4a0695ab57
commit
9b2a8b4b30
24 files changed
+3002
-24
No files matched your search
@@ -0,0 +1,195 @@
|
||||
"""Unit tests for WebRTCProcessor."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FRAME_SIZE = 320 # 160 samples * 2 bytes (16-bit PCM)
|
||||
PATCH_TARGET = "webrtc_noise_gain.AudioProcessor"
|
||||
|
||||
|
||||
def make_audio(n_bytes: int, fill: int = 0xAB) -> bytes:
|
||||
"""Return n_bytes of dummy PCM data."""
|
||||
return bytes([fill]) * n_bytes
|
||||
|
||||
|
||||
def make_mock_apm(output_fill: int = 0x00):
|
||||
"""Return a mock AudioProcessor whose Process10ms returns a frame-sized result."""
|
||||
mock_apm = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.audio = bytes([output_fill]) * FRAME_SIZE
|
||||
mock_apm.Process10ms.return_value = mock_result
|
||||
return mock_apm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor():
|
||||
"""WebRTCProcessor with a mocked AudioProcessor so no C extension is needed."""
|
||||
mock_apm = make_mock_apm()
|
||||
with patch(PATCH_TARGET, return_value=mock_apm):
|
||||
from linux_voice_assistant.webrtc import WebRTCProcessor
|
||||
|
||||
proc = WebRTCProcessor(agc_level=3, ns_level=2)
|
||||
proc._mock_apm = mock_apm
|
||||
yield proc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_frame_size_is_320(self, processor):
|
||||
assert processor.FRAME_SIZE_BYTES == FRAME_SIZE
|
||||
|
||||
def test_agc_stored(self, processor):
|
||||
assert processor.agc_level == 3
|
||||
|
||||
def test_ns_stored(self, processor):
|
||||
assert processor.ns_level == 2
|
||||
|
||||
def test_buffer_starts_empty(self, processor):
|
||||
assert len(processor._buffer) == 0
|
||||
|
||||
def test_audio_processor_constructed_with_correct_levels(self):
|
||||
mock_apm = make_mock_apm()
|
||||
with patch(PATCH_TARGET, return_value=mock_apm) as mock_cls:
|
||||
from linux_voice_assistant.webrtc import WebRTCProcessor
|
||||
|
||||
WebRTCProcessor(agc_level=5, ns_level=1)
|
||||
mock_cls.assert_called_once_with(5, 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process() — buffering behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessBuffering:
|
||||
def test_exact_one_frame_returns_processed_bytes(self, processor):
|
||||
result = processor.process(make_audio(FRAME_SIZE))
|
||||
assert len(result) == FRAME_SIZE
|
||||
processor._mock_apm.Process10ms.assert_called_once()
|
||||
|
||||
def test_less_than_one_frame_returns_empty(self, processor):
|
||||
result = processor.process(make_audio(FRAME_SIZE - 1))
|
||||
assert result == b""
|
||||
processor._mock_apm.Process10ms.assert_not_called()
|
||||
|
||||
def test_two_frames_returns_two_processed_chunks(self, processor):
|
||||
result = processor.process(make_audio(FRAME_SIZE * 2))
|
||||
assert len(result) == FRAME_SIZE * 2
|
||||
assert processor._mock_apm.Process10ms.call_count == 2
|
||||
|
||||
def test_partial_input_buffers_remainder(self, processor):
|
||||
# 500 bytes = 1 full frame (320) + 180 leftover
|
||||
result = processor.process(make_audio(500))
|
||||
assert len(result) == FRAME_SIZE
|
||||
assert len(processor._buffer) == 500 - FRAME_SIZE
|
||||
|
||||
def test_accumulated_calls_eventually_flush(self, processor):
|
||||
# Two calls of 160 bytes each should flush one frame total
|
||||
processor.process(make_audio(160))
|
||||
assert processor._mock_apm.Process10ms.call_count == 0
|
||||
processor.process(make_audio(160))
|
||||
assert processor._mock_apm.Process10ms.call_count == 1
|
||||
|
||||
def test_buffer_drains_in_place(self, processor):
|
||||
"""After processing, only the remainder stays in the buffer."""
|
||||
processor.process(make_audio(500))
|
||||
assert len(processor._buffer) == 180
|
||||
|
||||
def test_empty_input_returns_empty(self, processor):
|
||||
result = processor.process(b"")
|
||||
assert result == b""
|
||||
processor._mock_apm.Process10ms.assert_not_called()
|
||||
|
||||
def test_output_is_concatenation_of_processed_chunks(self, processor):
|
||||
"""Each frame gets processed independently and results are joined."""
|
||||
apm = processor._mock_apm
|
||||
apm.Process10ms.side_effect = [
|
||||
MagicMock(audio=b"\x01" * FRAME_SIZE),
|
||||
MagicMock(audio=b"\x02" * FRAME_SIZE),
|
||||
]
|
||||
result = processor.process(make_audio(FRAME_SIZE * 2))
|
||||
assert result == b"\x01" * FRAME_SIZE + b"\x02" * FRAME_SIZE
|
||||
|
||||
def test_multiple_process_calls_accumulate_buffer(self, processor):
|
||||
"""Remainder from first call is used in second call."""
|
||||
processor.process(make_audio(200)) # 200 buffered, no flush
|
||||
processor.process(make_audio(200)) # 400 total, 1 flush, 80 remain
|
||||
assert processor._mock_apm.Process10ms.call_count == 1
|
||||
assert len(processor._buffer) == 80
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_settings()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateSettings:
|
||||
def test_reinitializes_when_agc_changes(self, processor):
|
||||
new_apm = make_mock_apm()
|
||||
with patch(PATCH_TARGET, return_value=new_apm):
|
||||
processor.update_settings(agc_level=10, ns_level=2)
|
||||
assert processor.apm is new_apm
|
||||
assert processor.agc_level == 10
|
||||
|
||||
def test_reinitializes_when_ns_changes(self, processor):
|
||||
new_apm = make_mock_apm()
|
||||
with patch(PATCH_TARGET, return_value=new_apm):
|
||||
processor.update_settings(agc_level=3, ns_level=4)
|
||||
assert processor.apm is new_apm
|
||||
assert processor.ns_level == 4
|
||||
|
||||
def test_no_reinitialize_when_settings_unchanged(self, processor):
|
||||
original_apm = processor.apm
|
||||
with patch(PATCH_TARGET) as mock_cls:
|
||||
processor.update_settings(agc_level=3, ns_level=2)
|
||||
mock_cls.assert_not_called()
|
||||
assert processor.apm is original_apm
|
||||
|
||||
def test_stores_new_agc_level(self, processor):
|
||||
with patch(PATCH_TARGET, return_value=make_mock_apm()):
|
||||
processor.update_settings(agc_level=15, ns_level=2)
|
||||
assert processor.agc_level == 15
|
||||
|
||||
def test_stores_new_ns_level(self, processor):
|
||||
with patch(PATCH_TARGET, return_value=make_mock_apm()):
|
||||
processor.update_settings(agc_level=3, ns_level=3)
|
||||
assert processor.ns_level == 3
|
||||
|
||||
def test_new_apm_called_with_updated_levels(self, processor):
|
||||
with patch(PATCH_TARGET, return_value=make_mock_apm()) as mock_cls:
|
||||
processor.update_settings(agc_level=7, ns_level=1)
|
||||
mock_cls.assert_called_once_with(7, 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process() after update_settings()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessAfterUpdate:
|
||||
def test_buffer_preserved_across_settings_update(self, processor):
|
||||
"""Buffered bytes should survive a settings change."""
|
||||
processor.process(make_audio(160)) # half frame, stays buffered
|
||||
assert len(processor._buffer) == 160
|
||||
|
||||
with patch(PATCH_TARGET, return_value=make_mock_apm()):
|
||||
processor.update_settings(agc_level=10, ns_level=2)
|
||||
|
||||
assert len(processor._buffer) == 160
|
||||
|
||||
def test_process_uses_new_apm_after_update(self, processor):
|
||||
new_apm = make_mock_apm(output_fill=0xFF)
|
||||
with patch(PATCH_TARGET, return_value=new_apm):
|
||||
processor.update_settings(agc_level=10, ns_level=2)
|
||||
|
||||
processor.process(make_audio(FRAME_SIZE))
|
||||
new_apm.Process10ms.assert_called_once()
|
||||
Reference in new issue
Block a user