Skip to content

Commit 0abb42b

Browse files
authored
Merge pull request #53 from green-api/feature/vad
Добавить VAD в библиотеку чат-бота и обновить зависимости в демо-чатботе
2 parents 3b91315 + 55bf157 commit 0abb42b

12 files changed

Lines changed: 247 additions & 50 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -540,10 +540,13 @@ credentials:
540540

541541
```python
542542
import asyncio
543-
import logging
544543

545544
from whatsapp_chatbot_python.calls import (
546-
CallEvent, CallSession, CallStateMachine,
545+
CallEvent,
546+
CallOptions,
547+
CallSession,
548+
CallStateMachine,
549+
VadOptions,
547550
)
548551
from whatsapp_chatbot_python.calls.service import WhatsAppCallService
549552

@@ -560,10 +563,17 @@ service = WhatsAppCallService(
560563
openai_api_key=openai_api_key,
561564
realtime_model=realtime_model,
562565
realtime_voice=realtime_voice,
563-
ring_timeout_seconds=30,
564-
talk_timeout_seconds=120,
565-
shutdown_timeout_seconds=10,
566-
logger=logging.getLogger("calls"),
566+
options=CallOptions(
567+
ring_seconds=30,
568+
talk_seconds=120,
569+
shutdown_seconds=10,
570+
vad=VadOptions(
571+
threshold=0.70,
572+
prefix_padding_ms=400,
573+
silence_duration_ms=800,
574+
noise_reduction="near_field",
575+
),
576+
),
567577
)
568578

569579
async def call_once():
@@ -611,7 +621,7 @@ session = CallSession.incoming(
611621
call_id=call_id,
612622
language="en",
613623
incoming_deadline=monotonic() + remaining_seconds,
614-
greeting_text="Hello! This is a WhatsApp demo call. What would you like to discuss?",
624+
prompt="Greet the user: 'Hello! This is a WhatsApp demo call. What would you like to discuss?'",
615625
)
616626
fsm = CallStateMachine()
617627
fsm.apply(session, CallEvent.ENQUEUED)

‎docs/README.md‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -532,10 +532,13 @@ python -m pip install 'whatsapp-chatbot-python[voip]'
532532

533533
```python
534534
import asyncio
535-
import logging
536535

537536
from whatsapp_chatbot_python.calls import (
538-
CallEvent, CallSession, CallStateMachine,
537+
CallEvent,
538+
CallOptions,
539+
CallSession,
540+
CallStateMachine,
541+
VadOptions,
539542
)
540543
from whatsapp_chatbot_python.calls.service import WhatsAppCallService
541544

@@ -552,10 +555,17 @@ service = WhatsAppCallService(
552555
openai_api_key=openai_api_key,
553556
realtime_model=realtime_model,
554557
realtime_voice=realtime_voice,
555-
ring_timeout_seconds=30,
556-
talk_timeout_seconds=120,
557-
shutdown_timeout_seconds=10,
558-
logger=logging.getLogger("calls"),
558+
options=CallOptions(
559+
ring_seconds=30,
560+
talk_seconds=120,
561+
shutdown_seconds=10,
562+
vad=VadOptions(
563+
threshold=0.70,
564+
prefix_padding_ms=400,
565+
silence_duration_ms=800,
566+
noise_reduction="near_field",
567+
),
568+
),
559569
)
560570

561571
async def call_once():
@@ -603,7 +613,7 @@ session = CallSession.incoming(
603613
call_id=call_id,
604614
language="ru",
605615
incoming_deadline=monotonic() + remaining_seconds,
606-
greeting_text="Здравствуйте! Это демонстрационный звонок WhatsApp. Что хотите обсудить?",
616+
prompt="Поприветствуй пользователя: 'Здравствуйте! Это демонстрационный звонок WhatsApp. Что хотите обсудить?'",
607617
)
608618
fsm = CallStateMachine()
609619
fsm.apply(session, CallEvent.ENQUEUED)

‎setup.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setup(
77
name="whatsapp-chatbot-python",
8-
version="1.1.1",
8+
version="1.2.0",
99
description=(
1010
"This library helps you easily create"
1111
" a Python chatbot with WhatsApp API."

‎tests/calls/fakes.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@ async def hangUpAsync(self):
126126

127127

128128
class FakeVoice:
129-
def __init__(self, scenario, *args):
129+
def __init__(self, scenario, *args, vad=None):
130130
self.scenario = scenario
131+
self.vad = vad
131132
self.greetings = []
132133
self.closed = False
133134
self.started = False
@@ -147,9 +148,9 @@ async def start(self):
147148
raise RuntimeError("voice unavailable")
148149
self.started = True
149150

150-
async def greet_once(self, text):
151+
async def prompt_once(self, prompt):
151152
if not self.greetings:
152-
self.greetings.append(text)
153+
self.greetings.append(prompt)
153154
self.scenario.actions.append("greet")
154155

155156
def read_frame(self):

‎tests/calls/test_call_cleanup.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ def api_factory(*args, **kwargs):
2626
self.apis.append(api)
2727
return api
2828

29-
def voice_factory(*args):
30-
voice = FakeVoice(self.scenario, *args)
29+
def voice_factory(*args, **kwargs):
30+
voice = FakeVoice(self.scenario, *args, **kwargs)
3131
self.voices.append(voice)
3232
return voice
3333

‎tests/calls/test_call_service.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from whatsapp_chatbot_python.calls.models import (
1010
CallEndReason, CallEvent, CallOptions, CallSession, CallState, ProviderError,
11-
RecordingOptions,
11+
RecordingOptions, VadOptions,
1212
)
1313
from whatsapp_chatbot_python.calls.service import WhatsAppCallService
1414
from whatsapp_chatbot_python.calls.state_machine import CallStateMachine
@@ -28,8 +28,8 @@ def create_api(*args, **kwargs):
2828
self.apis.append(api)
2929
return api
3030

31-
def create_voice(*args):
32-
voice = FakeVoice(self.scenario, *args)
31+
def create_voice(*args, **kwargs):
32+
voice = FakeVoice(self.scenario, *args, **kwargs)
3333
self.voices.append(voice)
3434
return voice
3535

@@ -41,7 +41,7 @@ def create_voice(*args):
4141
patcher.start()
4242
self.addCleanup(patcher.stop)
4343

44-
def service(self, *, ring=0.2, talk=0.2):
44+
def service(self, *, ring=0.2, talk=0.2, vad=None):
4545
return WhatsAppCallService(
4646
api_url="https://example.invalid", id_instance="1",
4747
api_token_instance="token", openai_api_key="explicit-key",
@@ -50,6 +50,7 @@ def service(self, *, ring=0.2, talk=0.2):
5050
ring_seconds=ring, talk_seconds=talk, setup_seconds=0.1,
5151
command_seconds=0.1, shutdown_seconds=0.1,
5252
recording=RecordingOptions(enabled=False),
53+
vad=vad or VadOptions(),
5354
),
5455
)
5556

@@ -59,10 +60,11 @@ def outgoing(self):
5960
self.fsm.apply(session, CallEvent.DEQUEUED)
6061
return session
6162

62-
def incoming(self, *, call_id="call-1", deadline=None):
63+
def incoming(self, *, call_id="call-1", deadline=None, prompt=None):
6364
session = CallSession.incoming(
6465
sender_id="sender@c.us", call_id=call_id, language="ru",
6566
incoming_deadline=deadline if deadline is not None else monotonic() + 1,
67+
prompt=prompt,
6668
)
6769
self.fsm.apply(session, CallEvent.ENQUEUED)
6870
self.fsm.apply(session, CallEvent.DEQUEUED)
@@ -94,13 +96,19 @@ async def test_outgoing_answer_greets_once_and_preserves_remote_reason(self):
9496

9597
async def test_incoming_accepts_matching_offer_and_greets_after_answer(self):
9698
self.scenario.state = "inc-call"
97-
session = self.incoming()
99+
session = self.incoming(prompt="Introduce yourself briefly.")
98100
await self.execute(self.service(), session)
99101
self.assertEqual(session.state, CallState.REMOTE_ENDED)
100102
self.assertTrue(session.incoming_accept_completed)
101103
self.assertLess(self.scenario.actions.index("accept"), self.scenario.actions.index("greet"))
104+
self.assertEqual(self.voices[0].greetings, ["Introduce yourself briefly."])
102105
self.assertTrue(self.apis[0].session_closed)
103106

107+
async def test_service_passes_vad_options_to_voice(self):
108+
vad = VadOptions(threshold=0.85, noise_reduction="far_field")
109+
await self.execute(self.service(vad=vad), self.outgoing())
110+
self.assertIs(self.voices[0].vad, vad)
111+
104112
async def test_incoming_call_id_takes_priority_over_pn_or_lid_address(self):
105113
self.scenario.state = "inc-call"
106114
self.scenario.wid = "sender@lid"

‎tests/calls/test_call_state_machine.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ def setUp(self) -> None:
1717
self.fsm = CallStateMachine()
1818
self.session = CallSession("sender@c.us", "sender@c.us", "ru")
1919

20+
def test_incoming_call_keeps_its_prompt(self) -> None:
21+
session = CallSession.incoming(
22+
sender_id="sender@c.us", call_id="call-1", language="adaptive",
23+
incoming_deadline=monotonic() + 1,
24+
prompt="Introduce yourself briefly.",
25+
)
26+
self.assertEqual(session.prompt, "Introduce yourself briefly.")
27+
28+
def test_empty_prompt_is_rejected(self) -> None:
29+
with self.assertRaisesRegex(ValueError, "Call prompt must not be empty"):
30+
CallSession("sender@c.us", "sender@c.us", "adaptive", prompt=" ")
31+
2032
def apply(self, event: CallEvent) -> CallState:
2133
self.fsm.apply(self.session, event)
2234
return self.session.state

‎tests/calls/test_realtime_voice.py‎

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from unittest.mock import patch
88

99
from whatsapp_chatbot_python.calls.models import (
10-
AudioLoss, MediaFailed, PCM, PlaybackOptions, ProviderConfig,
10+
AudioLoss, CallOptions, MediaFailed, PCM, PlaybackOptions, ProviderConfig, VadOptions,
1111
)
1212
from whatsapp_chatbot_python.calls.openai import RealtimeConversation
1313
from 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)

‎whatsapp_chatbot_python/calls/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
RecordingOptions,
2525
StateTransition,
2626
TransitionCallback,
27+
VadOptions,
2728
ACTIVE_STATES,
2829
TERMINAL_STATES,
2930
WAITING_STATES,
@@ -37,5 +38,5 @@
3738
"CallExecutionResult", "CallExecutor", "CallOptions", "CallSession", "CallServiceUnavailable", "CallState",
3839
"CallStateMachine", "IncomingCallExecutor", "InputAudioOptions", "InputOverflowPolicy", "PlaybackOptions",
3940
"PlaybackOverflowPolicy", "ProviderError", "RecordingOptions", "StateTransition", "TransitionCallback",
40-
"WhatsAppCallService",
41+
"VadOptions", "WhatsAppCallService",
4142
]

0 commit comments

Comments
 (0)