From da3f1843166142bcf9f599f2ea116c12f901525b Mon Sep 17 00:00:00 2001 From: filipi87 Date: Wed, 25 Mar 2026 15:38:21 -0300 Subject: [PATCH] Automated tests to validate the silence injection guards. --- tests/test_audio_buffer_processor.py | 119 ++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/tests/test_audio_buffer_processor.py b/tests/test_audio_buffer_processor.py index 4d3c588bc..a163a94ef 100644 --- a/tests/test_audio_buffer_processor.py +++ b/tests/test_audio_buffer_processor.py @@ -8,7 +8,15 @@ import asyncio import struct import unittest -from pipecat.frames.frames import InputAudioRawFrame, OutputAudioRawFrame, StartFrame +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + InputAudioRawFrame, + OutputAudioRawFrame, + StartFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, +) from pipecat.processors.audio.audio_buffer_processor import AudioBufferProcessor @@ -117,5 +125,114 @@ class TestAudioBufferProcessor(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(self.processor._bot_audio_buffer), 0) +class TestSilenceInjectionGuards(unittest.IsolatedAsyncioTestCase): + """Tests that silence is not injected mid-utterance (fix for crackling artifacts).""" + + async def _make_processor(self) -> AudioBufferProcessor: + """Return a processor with no auto-flush and a passthrough resampler.""" + processor = AudioBufferProcessor(sample_rate=16000, num_channels=2, buffer_size=0) + processor._input_resampler = _PassthroughResampler() + processor._output_resampler = _PassthroughResampler() + processor._update_sample_rate(StartFrame(audio_out_sample_rate=16000)) + await processor.start_recording() + return processor + + async def asyncTearDown(self): + # Processors created inside each test are cleaned up there; nothing shared. + pass + + async def test_no_silence_injected_into_bot_buffer_while_bot_speaking(self): + """Bot buffer must not receive silence padding while the bot is actively speaking.""" + p = await self._make_processor() + + # Give user buffer a head-start so a sync *would* pad the bot buffer normally. + p._user_audio_buffer = bytearray(b"\x01\x02\x03\x04") + + # Bot starts speaking. + await p._process_recording(BotStartedSpeakingFrame()) + + # User audio arrives — without the fix the bot buffer would be padded to 4 bytes. + user_audio = b"\x05\x06\x07\x08" + await p._process_recording( + InputAudioRawFrame(audio=user_audio, sample_rate=16000, num_channels=1) + ) + + self.assertEqual(len(p._bot_audio_buffer), 0, "Bot buffer must not be padded while bot is speaking") + self.assertEqual(bytes(p._user_audio_buffer), b"\x01\x02\x03\x04" + user_audio) + + await p.stop_recording() + await p.cleanup() + + async def test_no_silence_injected_into_user_buffer_while_user_speaking(self): + """User buffer must not receive silence padding while the user is actively speaking.""" + p = await self._make_processor() + + # Give bot buffer a head-start so a sync *would* pad the user buffer normally. + p._bot_audio_buffer = bytearray(b"\x01\x02\x03\x04") + + # User starts speaking. + await p._process_recording(UserStartedSpeakingFrame()) + + # Bot audio arrives — without the fix the user buffer would be padded to 4 bytes. + bot_audio = b"\x05\x06\x07\x08" + await p._process_recording( + OutputAudioRawFrame(audio=bot_audio, sample_rate=16000, num_channels=1) + ) + + self.assertEqual(len(p._user_audio_buffer), 0, "User buffer must not be padded while user is speaking") + self.assertEqual(bytes(p._bot_audio_buffer), b"\x01\x02\x03\x04" + bot_audio) + + await p.stop_recording() + await p.cleanup() + + async def test_silence_resumes_into_bot_buffer_after_bot_stops_speaking(self): + """After bot stops speaking, silence injection into the bot buffer resumes.""" + p = await self._make_processor() + + # Give user buffer a head-start. + p._user_audio_buffer = bytearray(b"\x01\x02\x03\x04") + + # Bot speaks and stops. + await p._process_recording(BotStartedSpeakingFrame()) + await p._process_recording(BotStoppedSpeakingFrame()) + + # User audio arrives now — bot is no longer speaking so sync should run. + user_audio = b"\x05\x06\x07\x08" + await p._process_recording( + InputAudioRawFrame(audio=user_audio, sample_rate=16000, num_channels=1) + ) + + # Bot buffer should have been padded with silence to match the 4-byte head-start. + self.assertEqual(len(p._bot_audio_buffer), 4, "Bot buffer should be padded after bot stops speaking") + self.assertEqual(bytes(p._bot_audio_buffer), b"\x00\x00\x00\x00") + + await p.stop_recording() + await p.cleanup() + + async def test_silence_resumes_into_user_buffer_after_user_stops_speaking(self): + """After user stops speaking, silence injection into the user buffer resumes.""" + p = await self._make_processor() + + # Give bot buffer a head-start. + p._bot_audio_buffer = bytearray(b"\x01\x02\x03\x04") + + # User speaks and stops. + await p._process_recording(UserStartedSpeakingFrame()) + await p._process_recording(UserStoppedSpeakingFrame()) + + # Bot audio arrives now — user is no longer speaking so sync should run. + bot_audio = b"\x05\x06\x07\x08" + await p._process_recording( + OutputAudioRawFrame(audio=bot_audio, sample_rate=16000, num_channels=1) + ) + + # User buffer should have been padded with silence to match the 4-byte head-start. + self.assertEqual(len(p._user_audio_buffer), 4, "User buffer should be padded after user stops speaking") + self.assertEqual(bytes(p._user_audio_buffer), b"\x00\x00\x00\x00") + + await p.stop_recording() + await p.cleanup() + + if __name__ == "__main__": unittest.main()