"""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()