dv-pipecat-ai 0.0.85.dev824__py3-none-any.whl → 0.0.85.dev858__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dv-pipecat-ai might be problematic. Click here for more details.

Files changed (31) hide show
  1. {dv_pipecat_ai-0.0.85.dev824.dist-info → dv_pipecat_ai-0.0.85.dev858.dist-info}/METADATA +2 -1
  2. {dv_pipecat_ai-0.0.85.dev824.dist-info → dv_pipecat_ai-0.0.85.dev858.dist-info}/RECORD +31 -29
  3. pipecat/audio/turn/smart_turn/local_smart_turn_v3.py +5 -1
  4. pipecat/frames/frames.py +22 -0
  5. pipecat/metrics/connection_metrics.py +45 -0
  6. pipecat/processors/aggregators/llm_response.py +15 -9
  7. pipecat/processors/dtmf_aggregator.py +17 -21
  8. pipecat/processors/frame_processor.py +44 -1
  9. pipecat/processors/metrics/frame_processor_metrics.py +108 -0
  10. pipecat/processors/transcript_processor.py +2 -1
  11. pipecat/serializers/__init__.py +2 -0
  12. pipecat/serializers/asterisk.py +16 -2
  13. pipecat/serializers/convox.py +2 -2
  14. pipecat/serializers/custom.py +2 -2
  15. pipecat/serializers/vi.py +326 -0
  16. pipecat/services/cartesia/tts.py +75 -10
  17. pipecat/services/deepgram/stt.py +317 -17
  18. pipecat/services/elevenlabs/stt.py +487 -19
  19. pipecat/services/elevenlabs/tts.py +28 -4
  20. pipecat/services/google/llm.py +26 -11
  21. pipecat/services/openai/base_llm.py +79 -14
  22. pipecat/services/salesforce/llm.py +64 -59
  23. pipecat/services/sarvam/tts.py +0 -1
  24. pipecat/services/soniox/stt.py +45 -10
  25. pipecat/services/vistaar/llm.py +97 -6
  26. pipecat/transcriptions/language.py +50 -0
  27. pipecat/transports/base_input.py +15 -11
  28. pipecat/transports/base_output.py +26 -3
  29. {dv_pipecat_ai-0.0.85.dev824.dist-info → dv_pipecat_ai-0.0.85.dev858.dist-info}/WHEEL +0 -0
  30. {dv_pipecat_ai-0.0.85.dev824.dist-info → dv_pipecat_ai-0.0.85.dev858.dist-info}/licenses/LICENSE +0 -0
  31. {dv_pipecat_ai-0.0.85.dev824.dist-info → dv_pipecat_ai-0.0.85.dev858.dist-info}/top_level.txt +0 -0
@@ -20,6 +20,9 @@ from pipecat.metrics.metrics import (
20
20
  TTFBMetricsData,
21
21
  TTSUsageMetricsData,
22
22
  )
23
+ from pipecat.metrics.connection_metrics import (
24
+ ConnectionMetricsData,
25
+ )
23
26
  from pipecat.utils.asyncio.task_manager import BaseTaskManager
24
27
  from pipecat.utils.base_object import BaseObject
25
28
 
@@ -46,6 +49,13 @@ class FrameProcessorMetrics(BaseObject):
46
49
  self._last_ttfb_time = 0
47
50
  self._should_report_ttfb = True
48
51
  self._logger = logger
52
+
53
+ # Connection metrics state
54
+ self._start_connection_time = 0
55
+ self._connection_attempts = 0
56
+ self._last_connection_error = None
57
+ self._reconnection_start_time = 0
58
+ self._reconnect_count = 0
49
59
 
50
60
  async def setup(self, task_manager: BaseTaskManager):
51
61
  """Set up the metrics collector with a task manager.
@@ -195,3 +205,101 @@ class FrameProcessorMetrics(BaseObject):
195
205
  )
196
206
  self._logger.debug(f"{self._processor_name()} usage characters: {characters.value}")
197
207
  return MetricsFrame(data=[characters])
208
+
209
+ async def start_connection_metrics(self):
210
+ """Start measuring connection establishment time."""
211
+ self._start_connection_time = time.time()
212
+ self._connection_attempts += 1
213
+ self._last_connection_error = None
214
+
215
+ async def stop_connection_metrics(
216
+ self,
217
+ success: bool = True,
218
+ error: str = None,
219
+ connection_type: str = None
220
+ ):
221
+ """Stop connection measurement and generate metrics frame.
222
+
223
+ Args:
224
+ success: Whether the connection was successful.
225
+ error: Error message if connection failed.
226
+ connection_type: Type of connection (websocket, http, etc.).
227
+
228
+ Returns:
229
+ MetricsFrame containing connection data, or None if not measuring.
230
+ """
231
+ if self._start_connection_time == 0:
232
+ return None
233
+
234
+ connect_time = time.time() - self._start_connection_time
235
+
236
+ if not success:
237
+ self._last_connection_error = error
238
+
239
+ logstr = f"{self._processor_name()} connection "
240
+ logstr += "successful" if success else f"failed: {error}"
241
+ logstr += f" (attempt #{self._connection_attempts}, {connect_time:.3f}s)"
242
+
243
+ if success:
244
+ self._logger.debug(logstr)
245
+ else:
246
+ self._logger.warning(logstr)
247
+
248
+ connection_data = ConnectionMetricsData(
249
+ processor=self._processor_name(),
250
+ model=self._model_name(),
251
+ connect_time=round(connect_time, 3),
252
+ success=success,
253
+ connection_attempts=self._connection_attempts,
254
+ error_message=error,
255
+ connection_type=connection_type
256
+ )
257
+
258
+ self._start_connection_time = 0
259
+ return MetricsFrame(data=[connection_data])
260
+
261
+
262
+ async def start_reconnection_metrics(self):
263
+ """Start measuring reconnection downtime."""
264
+ self._reconnection_start_time = time.time()
265
+ self._reconnect_count += 1
266
+
267
+ async def stop_reconnection_metrics(
268
+ self,
269
+ success: bool = True,
270
+ reason: str = None
271
+ ):
272
+ """Stop reconnection measurement and generate metrics frame.
273
+
274
+ Args:
275
+ success: Whether the reconnection was successful.
276
+ reason: Reason for reconnection.
277
+
278
+ Returns:
279
+ MetricsFrame containing reconnection data, or None if not measuring.
280
+ """
281
+ if self._reconnection_start_time == 0:
282
+ return None
283
+
284
+ downtime = time.time() - self._reconnection_start_time
285
+
286
+ logstr = f"{self._processor_name()} reconnection #{self._reconnect_count} "
287
+ logstr += "successful" if success else "failed"
288
+ logstr += f" (downtime: {downtime:.3f}s)"
289
+ if reason:
290
+ logstr += f" - {reason}"
291
+
292
+ self._logger.debug(logstr)
293
+
294
+ reconnection_data = ConnectionMetricsData(
295
+ processor=self._processor_name(),
296
+ model=self._model_name(),
297
+ reconnect_count=self._reconnect_count,
298
+ downtime=round(downtime, 3),
299
+ reconnect_success=success,
300
+ reason=reason
301
+ )
302
+
303
+ self._reconnection_start_time = 0
304
+ return MetricsFrame(data=[reconnection_data])
305
+
@@ -64,12 +64,13 @@ class BaseTranscriptProcessor(FrameProcessor):
64
64
  if not frame.transcript_ids:
65
65
  return
66
66
 
67
+ await self._call_event_handler("on_transcript_drop", frame)
68
+
67
69
  drop_ids = set(frame.transcript_ids)
68
70
  if drop_ids:
69
71
  self._processed_messages = [
70
72
  msg for msg in self._processed_messages if msg.message_id not in drop_ids
71
73
  ]
72
- await self._call_event_handler("on_transcript_drop", frame)
73
74
 
74
75
 
75
76
  class UserTranscriptProcessor(BaseTranscriptProcessor):
@@ -5,6 +5,7 @@ from .exotel import ExotelFrameSerializer
5
5
  from .plivo import PlivoFrameSerializer
6
6
  from .telnyx import TelnyxFrameSerializer
7
7
  from .twilio import TwilioFrameSerializer
8
+ from .vi import VIFrameSerializer
8
9
 
9
10
  __all__ = [
10
11
  "FrameSerializer",
@@ -15,6 +16,7 @@ __all__ = [
15
16
  "PlivoFrameSerializer",
16
17
  "TelnyxFrameSerializer",
17
18
  "TwilioFrameSerializer",
19
+ "VIFrameSerializer",
18
20
  ]
19
21
 
20
22
  # Optional imports
@@ -1,4 +1,6 @@
1
1
  # asterisk_ws_serializer.py
2
+ """Frame serializer for Asterisk WebSocket communication."""
3
+
2
4
  import base64
3
5
  import json
4
6
  from typing import Literal, Optional
@@ -12,8 +14,8 @@ from pipecat.frames.frames import (
12
14
  EndFrame,
13
15
  Frame,
14
16
  InputAudioRawFrame,
17
+ InterruptionFrame,
15
18
  StartFrame,
16
- StartInterruptionFrame,
17
19
  TransportMessageFrame,
18
20
  TransportMessageUrgentFrame,
19
21
  )
@@ -21,6 +23,8 @@ from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializer
21
23
 
22
24
 
23
25
  class AsteriskFrameSerializer(FrameSerializer):
26
+ """Serializes Pipecat frames to/from Asterisk WebSocket JSON messages."""
27
+
24
28
  class InputParams(BaseModel):
25
29
  """Configuration parameters for AsteriskFrameSerializer.
26
30
 
@@ -39,6 +43,12 @@ class AsteriskFrameSerializer(FrameSerializer):
39
43
  auto_hang_up: bool = False # no-op here; adapter handles hangup
40
44
 
41
45
  def __init__(self, stream_id: str, params: Optional[InputParams] = None):
46
+ """Initialize the Asterisk frame serializer.
47
+
48
+ Args:
49
+ stream_id: Unique identifier for the media stream.
50
+ params: Configuration parameters for the serializer.
51
+ """
42
52
  self._stream_id = stream_id
43
53
  self._params = params or AsteriskFrameSerializer.InputParams()
44
54
  self._tel_rate = self._params.telephony_sample_rate
@@ -49,13 +59,16 @@ class AsteriskFrameSerializer(FrameSerializer):
49
59
 
50
60
  @property
51
61
  def type(self) -> FrameSerializerType:
62
+ """Return the serializer type (TEXT for JSON messages)."""
52
63
  return FrameSerializerType.TEXT # we send/recv JSON strings
53
64
 
54
65
  async def setup(self, frame: StartFrame):
66
+ """Setup the serializer with audio parameters from the StartFrame."""
55
67
  self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate
56
68
 
57
69
  # Pipecat -> Adapter (play to caller)
58
70
  async def serialize(self, frame: Frame) -> str | bytes | None:
71
+ """Serialize Pipecat frames to Asterisk WebSocket JSON messages."""
59
72
  # On pipeline end, ask bridge to hang up
60
73
  if (
61
74
  self._params.auto_hang_up
@@ -64,7 +77,7 @@ class AsteriskFrameSerializer(FrameSerializer):
64
77
  ):
65
78
  self._hangup_sent = True
66
79
  return json.dumps({"event": "hangup"})
67
- if isinstance(frame, StartInterruptionFrame):
80
+ if isinstance(frame, InterruptionFrame):
68
81
  return json.dumps({"event": "clear", "streamId": self._stream_id})
69
82
  if isinstance(frame, AudioRawFrame):
70
83
  pcm = frame.audio
@@ -114,6 +127,7 @@ class AsteriskFrameSerializer(FrameSerializer):
114
127
 
115
128
  # Adapter -> Pipecat (audio from caller)
116
129
  async def deserialize(self, data: str | bytes) -> Frame | None:
130
+ """Deserialize Asterisk WebSocket JSON messages to Pipecat frames."""
117
131
  try:
118
132
  msg = json.loads(data)
119
133
  except Exception:
@@ -22,9 +22,9 @@ from pipecat.frames.frames import (
22
22
  Frame,
23
23
  InputAudioRawFrame,
24
24
  InputDTMFFrame,
25
+ InterruptionFrame,
25
26
  KeypadEntry,
26
27
  StartFrame,
27
- StartInterruptionFrame,
28
28
  TransportMessageFrame,
29
29
  TransportMessageUrgentFrame,
30
30
  )
@@ -117,7 +117,7 @@ class ConVoxFrameSerializer(FrameSerializer):
117
117
  self._call_ended = True
118
118
  # Return the callEnd event to be sent via the WebSocket
119
119
  return await self._send_call_end_event()
120
- elif isinstance(frame, StartInterruptionFrame):
120
+ elif isinstance(frame, InterruptionFrame):
121
121
  # Clear/interrupt command for ConVox
122
122
  message = {
123
123
  "event": "clear",
@@ -28,8 +28,8 @@ from pipecat.frames.frames import (
28
28
  EndFrame,
29
29
  Frame,
30
30
  InputAudioRawFrame,
31
+ InterruptionFrame,
31
32
  StartFrame,
32
- StartInterruptionFrame,
33
33
  TransportMessageFrame,
34
34
  TransportMessageUrgentFrame,
35
35
  )
@@ -121,7 +121,7 @@ class CustomFrameSerializer(FrameSerializer):
121
121
  Returns:
122
122
  Serialized data as JSON string, or None if the frame isn't handled.
123
123
  """
124
- if isinstance(frame, StartInterruptionFrame):
124
+ if isinstance(frame, InterruptionFrame):
125
125
  # Send clear event to instruct client to discard buffered audio
126
126
  answer = {"event": "clear", "stream_sid": self._stream_sid}
127
127
  return json.dumps(answer)
@@ -0,0 +1,326 @@
1
+ #
2
+ # Copyright (c) 2024–2025, Daily
3
+ #
4
+ # SPDX-License-Identifier: BSD 2-Clause License
5
+ #
6
+
7
+ """Vodafone Idea (VI) WebSocket frame serializer for audio streaming and call management."""
8
+
9
+ import base64
10
+ import json
11
+ from datetime import datetime, timezone
12
+ from typing import Optional
13
+
14
+ from loguru import logger
15
+ from pydantic import BaseModel
16
+
17
+ from pipecat.audio.utils import create_default_resampler
18
+ from pipecat.frames.frames import (
19
+ AudioRawFrame,
20
+ CancelFrame,
21
+ EndFrame,
22
+ Frame,
23
+ InputAudioRawFrame,
24
+ InputDTMFFrame,
25
+ KeypadEntry,
26
+ StartFrame,
27
+ StartInterruptionFrame,
28
+ TransportMessageFrame,
29
+ TransportMessageUrgentFrame,
30
+ )
31
+ from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializerType
32
+
33
+
34
+ class VIFrameSerializer(FrameSerializer):
35
+ """Serializer for Vodafone Idea (VI) WebSocket protocol.
36
+
37
+ This serializer handles converting between Pipecat frames and VI's WebSocket
38
+ protocol for bidirectional audio streaming. It supports audio conversion, DTMF events,
39
+ and real-time communication with VI telephony systems.
40
+
41
+ VI WebSocket protocol requirements:
42
+ - PCM audio format at 8kHz sample rate
43
+ - 16-bit Linear PCM encoding
44
+ - Base64 encoded audio payloads
45
+ - JSON message format for control and media events
46
+ - Bitrate: 128 Kbps
47
+
48
+ Events (VI → Endpoint):
49
+ - connected: WebSocket connection established
50
+ - start: Stream session started with call/stream IDs
51
+ - media: Audio data in Base64-encoded PCM
52
+ - dtmf: Keypad digit pressed
53
+ - stop: Stream ended
54
+ - mark: Audio playback checkpoint confirmation
55
+
56
+ Events (Endpoint → VI):
57
+ - media: Send audio back to VI
58
+ - mark: Request acknowledgment for audio playback
59
+ - clear: Clear queued audio (interruption)
60
+ - exit: Terminate session gracefully
61
+ """
62
+
63
+ class InputParams(BaseModel):
64
+ """Configuration parameters for VIFrameSerializer.
65
+
66
+ Attributes:
67
+ vi_sample_rate: Sample rate used by VI, defaults to 8000 Hz (telephony standard).
68
+ sample_rate: Optional override for pipeline input sample rate.
69
+ auto_hang_up: Whether to automatically terminate call on EndFrame.
70
+ """
71
+
72
+ vi_sample_rate: int = 8000
73
+ sample_rate: Optional[int] = None
74
+ auto_hang_up: bool = False
75
+
76
+ def __init__(
77
+ self,
78
+ stream_id: str,
79
+ call_id: Optional[str] = None,
80
+ params: Optional[InputParams] = None,
81
+ ):
82
+ """Initialize the VIFrameSerializer.
83
+
84
+ Args:
85
+ stream_id: The VI stream identifier.
86
+ call_id: The associated VI call identifier.
87
+ params: Configuration parameters.
88
+ """
89
+ self._stream_id = stream_id
90
+ self._call_id = call_id
91
+ self._params = params or VIFrameSerializer.InputParams()
92
+
93
+ self._vi_sample_rate = self._params.vi_sample_rate
94
+ self._sample_rate = 0 # Pipeline input rate
95
+ self._call_ended = False
96
+
97
+ self._resampler = create_default_resampler()
98
+
99
+ @property
100
+ def type(self) -> FrameSerializerType:
101
+ """Gets the serializer type.
102
+
103
+ Returns:
104
+ The serializer type as TEXT for JSON WebSocket messages.
105
+ """
106
+ return FrameSerializerType.TEXT
107
+
108
+ async def setup(self, frame: StartFrame):
109
+ """Sets up the serializer with pipeline configuration.
110
+
111
+ Args:
112
+ frame: The StartFrame containing pipeline configuration.
113
+ """
114
+ self._sample_rate = self._params.sample_rate or frame.audio_in_sample_rate
115
+
116
+ async def serialize(self, frame: Frame) -> str | bytes | None:
117
+ """Serializes a Pipecat frame to VI WebSocket format.
118
+
119
+ Handles conversion of various frame types to VI WebSocket messages.
120
+ For EndFrames, initiates call termination if auto_hang_up is enabled.
121
+
122
+ Args:
123
+ frame: The Pipecat frame to serialize.
124
+
125
+ Returns:
126
+ Serialized data as JSON string, or None if the frame isn't handled.
127
+ """
128
+ if (
129
+ self._params.auto_hang_up
130
+ and not self._call_ended
131
+ and isinstance(frame, (EndFrame, CancelFrame))
132
+ ):
133
+ self._call_ended = True
134
+ # Return the exit event to terminate the VI session
135
+ return await self._send_exit_event()
136
+
137
+ elif isinstance(frame, StartInterruptionFrame):
138
+ # Clear/interrupt command for VI - clears queued audio
139
+ message = {
140
+ "event": "clear",
141
+ "stream_id": self._stream_id,
142
+ "call_id": self._call_id,
143
+ }
144
+ logger.debug(f"VI: Sending clear event for stream_id: {self._stream_id}")
145
+ return json.dumps(message)
146
+
147
+ elif isinstance(frame, AudioRawFrame):
148
+ if self._call_ended:
149
+ logger.debug("VI SERIALIZE: Skipping audio - call has ended")
150
+ return None
151
+
152
+ # Convert PCM audio to VI format
153
+ data = frame.audio
154
+
155
+ # Resample to VI sample rate (8kHz)
156
+ serialized_data = await self._resampler.resample(
157
+ data, frame.sample_rate, self._vi_sample_rate
158
+ )
159
+
160
+ # Encode as base64 for transmission
161
+ payload = base64.b64encode(serialized_data).decode("ascii")
162
+
163
+ # VI expects media event format with Base64-encoded PCM audio
164
+ timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
165
+
166
+ message = {
167
+ "event": "media",
168
+ "stream_id": self._stream_id,
169
+ "media": {
170
+ "timestamp": timestamp,
171
+ "chunk": len(serialized_data), # Chunk size in bytes
172
+ "payload": payload,
173
+ },
174
+ }
175
+
176
+ logger.debug(f"VI: Sending media event {message} for stream_id: {self._stream_id}")
177
+
178
+ return json.dumps(message)
179
+
180
+ elif isinstance(frame, (TransportMessageFrame, TransportMessageUrgentFrame)):
181
+ # Pass through transport messages (for mark events, etc.)
182
+ return json.dumps(frame.message)
183
+
184
+ return None
185
+
186
+ async def _send_exit_event(self):
187
+ """Send an exit event to VI to terminate the session gracefully.
188
+
189
+ This method is called when auto_hang_up is enabled and an EndFrame or
190
+ CancelFrame is received. The exit event allows IVR logic to continue
191
+ after the WebSocket session ends.
192
+ """
193
+ try:
194
+ exit_event = {
195
+ "event": "exit",
196
+ "stream_id": self._stream_id,
197
+ "call_id": self._call_id,
198
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
199
+ }
200
+
201
+ logger.info(
202
+ f"VI auto_hang_up: Sending exit event for stream_id: {self._stream_id}, call_id: {self._call_id}"
203
+ )
204
+ return json.dumps(exit_event)
205
+ except Exception as e:
206
+ logger.error(f"VI auto_hang_up: Failed to create exit event: {e}")
207
+ return None
208
+
209
+ async def deserialize(self, data: str | bytes) -> Frame | None:
210
+ """Deserializes VI WebSocket data to Pipecat frames.
211
+
212
+ Handles conversion of VI media events to appropriate Pipecat frames.
213
+
214
+ Args:
215
+ data: The raw WebSocket data from VI.
216
+
217
+ Returns:
218
+ A Pipecat frame corresponding to the VI event, or None if unhandled.
219
+ """
220
+ try:
221
+ message = json.loads(data)
222
+ except json.JSONDecodeError:
223
+ logger.error(f"Invalid JSON received from VI: {data}")
224
+ return None
225
+
226
+ # Log all incoming events for debugging and monitoring
227
+ event = message.get("event")
228
+ logger.debug(
229
+ f"VI INCOMING EVENT: {event} - stream_id: {self._stream_id}, call_id: {self._call_id}"
230
+ )
231
+
232
+ if event == "media":
233
+ # Handle incoming audio data from VI
234
+ media = message.get("media", {})
235
+ payload_base64 = media.get("payload")
236
+
237
+ if not payload_base64:
238
+ logger.warning("VI DESERIALIZE: No payload in VI media message")
239
+ return None
240
+
241
+ try:
242
+ payload = base64.b64decode(payload_base64)
243
+ chunk_size = len(payload)
244
+
245
+ # Log chunk info (optional)
246
+ logger.debug(
247
+ f"VI DESERIALIZE: Received audio from VI - {chunk_size} bytes at {self._vi_sample_rate}Hz"
248
+ )
249
+
250
+ except Exception as e:
251
+ logger.error(f"VI DESERIALIZE: Error decoding VI audio payload: {e}")
252
+ return None
253
+
254
+ # Convert from VI sample rate (8kHz) to pipeline sample rate
255
+ deserialized_data = await self._resampler.resample(
256
+ payload,
257
+ self._vi_sample_rate,
258
+ self._sample_rate,
259
+ )
260
+
261
+ audio_frame = InputAudioRawFrame(
262
+ audio=deserialized_data,
263
+ num_channels=1, # VI uses mono audio
264
+ sample_rate=self._sample_rate,
265
+ )
266
+ return audio_frame
267
+
268
+ elif event == "dtmf":
269
+ # Handle DTMF events
270
+ dtmf_data = message.get("dtmf", {})
271
+ digit = dtmf_data.get("digit")
272
+
273
+ if digit:
274
+ try:
275
+ logger.info(f"VI: Received DTMF digit: {digit}")
276
+ return InputDTMFFrame(KeypadEntry(digit))
277
+ except ValueError:
278
+ logger.warning(f"Invalid DTMF digit from VI: {digit}")
279
+ return None
280
+
281
+ elif event == "connected":
282
+ # Handle connection event
283
+ logger.info(f"VI connection established: {message}")
284
+ return None
285
+
286
+ elif event == "start":
287
+ # Handle stream start event
288
+ logger.info(f"VI stream started: {message}")
289
+ return None
290
+
291
+ elif event == "stop":
292
+ # Handle stream stop event
293
+ logger.info(f"VI stream stopped: {message}")
294
+ # Don't end the call here, wait for explicit exit or call end
295
+ return None
296
+
297
+ elif event == "mark":
298
+ # Handle mark event - checkpoint confirming audio playback completion
299
+ mark_data = message.get("mark", {})
300
+ mark_name = mark_data.get("name", "unknown")
301
+ logger.info(f"VI mark event received: {mark_name}")
302
+ # Mark events are informational, no frame to return
303
+ return None
304
+
305
+ elif event == "error":
306
+ # Handle error events
307
+ error_msg = message.get("error", "Unknown error")
308
+ logger.error(f"VI error: {error_msg}")
309
+ return None
310
+
311
+ elif event == "exit":
312
+ # Handle exit event from VI
313
+ logger.info("VI exit event received - terminating session")
314
+ self._call_ended = True
315
+ return CancelFrame()
316
+
317
+ elif event == "call_end" or event == "callEnd":
318
+ # Handle call end event (if VI sends this)
319
+ logger.info("VI call end event received")
320
+ self._call_ended = True
321
+ return CancelFrame()
322
+
323
+ else:
324
+ logger.debug(f"VI UNHANDLED EVENT: {event}")
325
+
326
+ return None