77from unittest .mock import patch
88
99from whatsapp_chatbot_python .calls .models import (
10- AudioLoss , MediaFailed , PCM , PlaybackOptions , ProviderConfig ,
10+ AudioLoss , CallOptions , MediaFailed , PCM , PlaybackOptions , ProviderConfig , VadOptions ,
1111)
1212from whatsapp_chatbot_python .calls .openai import RealtimeConversation
1313from whatsapp_chatbot_python .calls .playback_state_machine import PlaybackController
@@ -146,25 +146,92 @@ async def audio(self, payload=FRAME, response="response-1", item="item-1"):
146146 delta = base64 .b64encode (payload ).decode ("ascii" ),
147147 ))
148148
149- async def test_session_uses_explicit_key_and_greets_only_once (self ):
149+ async def test_explicit_prompt_is_sent_unchanged_only_once (self ):
150150 self .assertEqual (self .client .api_key , "explicit-key" )
151151 settings = self .client .connection .actions [0 ][1 ]["session" ]
152152 self .assertEqual (settings ["audio" ]["output" ]["voice" ], "marin" )
153153 self .assertEqual (settings ["audio" ]["input" ]["format" ]["rate" ], PCM .rate )
154- await self .voice .greet_once ("Привет" )
155- await self .voice .greet_once ("Ещё раз" )
154+ self .assertEqual (settings ["audio" ]["input" ]["noise_reduction" ],
155+ {"type" : "near_field" })
156+ self .assertEqual (settings ["audio" ]["input" ]["turn_detection" ], {
157+ "type" : "server_vad" ,
158+ "threshold" : 0.70 ,
159+ "prefix_padding_ms" : 400 ,
160+ "silence_duration_ms" : 800 ,
161+ "create_response" : True ,
162+ "interrupt_response" : True ,
163+ })
164+ await self .voice .prompt_once ("Introduce yourself and ask how you can help." )
165+ await self .voice .prompt_once ("This prompt must be ignored." )
156166 self .assertEqual ([action for action , _ in self .client .connection .actions ].count ("create" ), 1 )
157167 self .assertEqual (
158168 self .client .connection .actions [- 1 ][1 ]["response" ]["instructions" ],
159- 'Greet the caller: "Привет" in Russian.' ,
169+ "Introduce yourself and ask how you can help." ,
160170 )
161171
172+ async def test_custom_vad_settings_reach_realtime (self ):
173+ vad = VadOptions (
174+ threshold = 0.85 , prefix_padding_ms = 250 ,
175+ silence_duration_ms = 1200 , noise_reduction = "far_field" ,
176+ )
177+ config = ProviderConfig ("https://example.invalid" , "1" , "token" , "key" , "model" , "marin" )
178+ async with RealtimeConversation (
179+ config , PlaybackOptions (), self ._record_event , "ru" , 0.1 , vad = vad ,
180+ ) as voice :
181+ await voice .start ()
182+ audio_input = voice ._client .connection .actions [0 ][1 ]["session" ]["audio" ]["input" ]
183+
184+ self .assertEqual (audio_input ["noise_reduction" ], {"type" : "far_field" })
185+ self .assertEqual (audio_input ["turn_detection" ], {
186+ "type" : "server_vad" ,
187+ "threshold" : 0.85 ,
188+ "prefix_padding_ms" : 250 ,
189+ "silence_duration_ms" : 1200 ,
190+ "create_response" : True ,
191+ "interrupt_response" : True ,
192+ })
193+
194+ async def test_none_disables_noise_reduction (self ):
195+ self .voice ._vad = VadOptions (noise_reduction = None )
196+ self .assertIsNone (self .voice ._session_settings ()["audio" ]["input" ]["noise_reduction" ])
197+
162198 async def test_default_greeting_instruction_uses_language_name (self ):
163- await self .voice .greet_once (None )
199+ await self .voice .prompt_once (None )
164200 self .assertEqual (
165201 self .client .connection .actions [- 1 ][1 ]["response" ]["instructions" ],
166- "Greet the caller briefly in Russian." ,
202+ "Greet the user briefly in Russian." ,
203+ )
204+
205+ async def test_default_greeting_is_not_used_as_session_instruction (self ):
206+ self .assertNotIn ("instructions" , self .voice ._session_settings ())
207+
208+ async def test_explicit_conversation_instructions_remain_session_wide (self ):
209+ config = ProviderConfig (
210+ "https://example.invalid" , "1" , "token" , "key" , "model" , "marin" ,
211+ "You are a helpful Russian-speaking phone assistant." ,
167212 )
213+ async with RealtimeConversation (
214+ config , PlaybackOptions (), self ._record_event , "ru" , 0.1 ,
215+ ) as voice :
216+ await voice .start ()
217+
218+ settings = self .client .connection .actions [0 ][1 ]["session" ]
219+ self .assertEqual (
220+ settings ["instructions" ],
221+ "You are a helpful Russian-speaking phone assistant." ,
222+ )
223+
224+ async def test_adaptive_language_uses_switching_fallbacks (self ):
225+ self .voice ._language = "adaptive"
226+ settings = self .voice ._session_settings ()
227+ required_phrases = ("another language" , "switch to" , "in English" )
228+ self .assertNotIn ("instructions" , settings )
229+
230+ await self .voice .prompt_once (None )
231+ greeting_instructions = self .client .connection .actions [- 1 ][1 ]["response" ]["instructions" ].lower ()
232+ for phrase in required_phrases :
233+ with self .subTest (target = "greeting" , phrase = phrase ):
234+ self .assertIn (phrase .lower (), greeting_instructions )
168235
169236 async def test_incoming_pcm_is_base64_encoded (self ):
170237 await self .voice .write_audio (FRAME )
@@ -182,6 +249,16 @@ async def test_barge_in_truncates_to_audio_actually_heard(self):
182249 self .assertEqual (actions [- 1 ][1 ], dict (item_id = "item-1" , content_index = 0 ,
183250 audio_end_ms = PCM .frame_ms ))
184251
252+ async def test_provider_cancel_error_after_speech_started_is_ignored (self ):
253+ await self .dispatch (dict (type = "response.created" , response = SimpleNamespace (id = "response-1" )))
254+ await self .dispatch (dict (type = "input_audio_buffer.speech_started" ))
255+ cancel = next (data for name , data in self .client .connection .actions if name == "cancel" )
256+ await self .dispatch (dict (type = "error" , error = SimpleNamespace (
257+ event_id = cancel ["event_id" ], code = "response_cancel_failed" , message = "Already cancelled" ,
258+ )))
259+ self .assertEqual (self .failures , [])
260+
261+
185262 async def test_replaced_track_drops_old_audio_and_new_response_plays (self ):
186263 await self .dispatch (dict (type = "response.created" , response = SimpleNamespace (id = "response-1" )))
187264 await self .audio (FRAME * 2 )
@@ -292,3 +369,21 @@ async def test_finished_cancelled_response_releases_tracking(self):
292369 await self .dispatch (dict (type = "response.done" , response = SimpleNamespace (
293370 id = "response-1" , status = "cancelled" )))
294371 self .assertNotIn ("response-1" , self .voice ._cancelled )
372+
373+
374+ class VadOptionsTest (unittest .TestCase ):
375+ def test_invalid_vad_settings_are_rejected_before_call_start (self ):
376+ invalid_settings = (
377+ {"threshold" : - 0.1 }, {"threshold" : 1.1 },
378+ {"threshold" : float ("nan" )}, {"threshold" : float ("inf" )},
379+ {"threshold" : True }, {"threshold" : "0.7" },
380+ {"prefix_padding_ms" : - 1 }, {"prefix_padding_ms" : True },
381+ {"prefix_padding_ms" : 0.5 }, {"silence_duration_ms" : 0 },
382+ {"silence_duration_ms" : True }, {"noise_reduction" : "unknown" },
383+ )
384+ for settings in invalid_settings :
385+ with self .subTest (settings = settings ), self .assertRaises (ValueError ):
386+ VadOptions (** settings )
387+
388+ with self .assertRaisesRegex (ValueError , "VAD options must be VadOptions" ):
389+ CallOptions (vad = None )
0 commit comments