voiceground 0.1.4__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.
@@ -0,0 +1,238 @@
1
+ """HTMLReporter - Generate HTML reports from conversation events."""
2
+
3
+ import json
4
+ import webbrowser
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from voiceground.events import VoicegroundEvent
9
+ from voiceground.reporters.base import BaseReporter
10
+
11
+
12
+ def _get_version() -> str:
13
+ """Get the package version, avoiding circular imports."""
14
+ try:
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ return version("voiceground")
18
+ except (PackageNotFoundError, Exception):
19
+ return "0.0.0+dev"
20
+
21
+
22
+ class HTMLReporter(BaseReporter):
23
+ """Reporter that records events and generates self-contained HTML reports.
24
+
25
+ Collects all events during pipeline execution and generates an interactive
26
+ HTML report when the pipeline ends. The report is named
27
+ "voiceground_report_{conversation_id}.html".
28
+
29
+ Args:
30
+ output_dir: Directory to write output files. Defaults to "./reports".
31
+ auto_open: Whether to open the HTML report in browser after generation.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ output_dir: str | Path = "./reports",
37
+ auto_open: bool = False,
38
+ ):
39
+ self._output_dir = Path(output_dir)
40
+ self._auto_open = auto_open
41
+ self._events: list[VoicegroundEvent] = []
42
+ self._finalized = False
43
+ self._conversation_id: str | None = None
44
+
45
+ async def on_start(self, conversation_id: str) -> None:
46
+ """Set the conversation ID when the pipeline starts."""
47
+ self._conversation_id = conversation_id
48
+
49
+ async def on_event(self, event: VoicegroundEvent) -> None:
50
+ """Record an event."""
51
+ self._events.append(event)
52
+
53
+ async def on_end(self) -> None:
54
+ """Generate HTML report."""
55
+ # Guard against multiple calls
56
+ if self._finalized:
57
+ return
58
+ self._finalized = True
59
+
60
+ self._output_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ # Generate HTML report
63
+ events_data = [event.to_dict() for event in self._events]
64
+ html_path = self._generate_html_report(events_data)
65
+
66
+ if self._auto_open and html_path:
67
+ # Convert path to file:// URL format (works cross-platform)
68
+ file_url = html_path.absolute().as_uri()
69
+ webbrowser.open(file_url)
70
+
71
+ # Reset events for potential reuse
72
+ self._events = []
73
+
74
+ def _generate_html_report(self, events_data: list[dict[str, Any]]) -> Path | None:
75
+ """Generate an HTML report with embedded events data.
76
+
77
+ Returns the path to the generated HTML file, or None if the
78
+ bundled client is not available.
79
+ """
80
+ # Try to load bundled client template
81
+ template_path = Path(__file__).parent.parent / "_static" / "index.html"
82
+
83
+ if template_path.exists():
84
+ with open(template_path) as f:
85
+ template = f.read()
86
+
87
+ # Inject events data, conversation_id, and version into the template
88
+ events_json = json.dumps(events_data)
89
+ conversation_id_json = (
90
+ json.dumps(self._conversation_id) if self._conversation_id else "null"
91
+ )
92
+ version_json = json.dumps(_get_version())
93
+ script_content = f"""<script>
94
+ window.__VOICEGROUND_EVENTS__ = {events_json};
95
+ window.__VOICEGROUND_CONVERSATION_ID__ = {conversation_id_json};
96
+ window.__VOICEGROUND_VERSION__ = {version_json};
97
+ </script>"""
98
+ html_content = template.replace(
99
+ "<!-- VOICEGROUND_EVENTS_PLACEHOLDER -->",
100
+ script_content,
101
+ )
102
+ else:
103
+ # Fallback: generate a simple HTML page
104
+ events_json = json.dumps(events_data, indent=2)
105
+ html_content = self._generate_fallback_html(events_json)
106
+
107
+ # Generate filename with conversation_id
108
+ if self._conversation_id:
109
+ filename = f"voiceground_report_{self._conversation_id}.html"
110
+ else:
111
+ filename = "voiceground_report.html"
112
+
113
+ html_path = self._output_dir / filename
114
+ with open(html_path, "w") as f:
115
+ f.write(html_content)
116
+
117
+ return html_path
118
+
119
+ def _generate_fallback_html(self, events_json: str) -> str:
120
+ """Generate a simple fallback HTML report."""
121
+ return f"""<!DOCTYPE html>
122
+ <html lang="en">
123
+ <head>
124
+ <meta charset="UTF-8">
125
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
126
+ <title>Voiceground Report</title>
127
+ <style>
128
+ :root {{
129
+ --bg: #0a0a0a;
130
+ --surface: #141414;
131
+ --border: #262626;
132
+ --text: #fafafa;
133
+ --text-muted: #a1a1aa;
134
+ --accent: #22c55e;
135
+ }}
136
+ * {{
137
+ margin: 0;
138
+ padding: 0;
139
+ box-sizing: border-box;
140
+ }}
141
+ body {{
142
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
143
+ background: var(--bg);
144
+ color: var(--text);
145
+ padding: 2rem;
146
+ line-height: 1.6;
147
+ }}
148
+ h1 {{
149
+ color: var(--accent);
150
+ margin-bottom: 1rem;
151
+ font-size: 1.5rem;
152
+ }}
153
+ .info {{
154
+ color: var(--text-muted);
155
+ margin-bottom: 2rem;
156
+ font-size: 0.875rem;
157
+ }}
158
+ table {{
159
+ width: 100%;
160
+ border-collapse: collapse;
161
+ background: var(--surface);
162
+ border-radius: 8px;
163
+ overflow: hidden;
164
+ }}
165
+ th, td {{
166
+ padding: 0.75rem 1rem;
167
+ text-align: left;
168
+ border-bottom: 1px solid var(--border);
169
+ }}
170
+ th {{
171
+ background: var(--border);
172
+ font-weight: 600;
173
+ color: var(--text-muted);
174
+ text-transform: uppercase;
175
+ font-size: 0.75rem;
176
+ letter-spacing: 0.05em;
177
+ }}
178
+ tr:hover {{
179
+ background: rgba(34, 197, 94, 0.05);
180
+ }}
181
+ .category {{
182
+ padding: 0.25rem 0.5rem;
183
+ border-radius: 4px;
184
+ font-size: 0.75rem;
185
+ font-weight: 600;
186
+ }}
187
+ .user_speak {{ background: #3b82f620; color: #60a5fa; }}
188
+ .bot_speak {{ background: #22c55e20; color: #4ade80; }}
189
+ .stt {{ background: #f59e0b20; color: #fbbf24; }}
190
+ .llm {{ background: #a855f720; color: #c084fc; }}
191
+ .tts {{ background: #ec489920; color: #f472b6; }}
192
+ .type {{
193
+ color: var(--text-muted);
194
+ font-size: 0.875rem;
195
+ }}
196
+ .data {{
197
+ font-size: 0.75rem;
198
+ color: var(--text-muted);
199
+ max-width: 300px;
200
+ overflow: hidden;
201
+ text-overflow: ellipsis;
202
+ white-space: nowrap;
203
+ }}
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <h1>Voiceground Report</h1>
208
+ <p class="info">Conversation events captured by Voiceground observer</p>
209
+ <table>
210
+ <thead>
211
+ <tr>
212
+ <th>Timestamp</th>
213
+ <th>Category</th>
214
+ <th>Type</th>
215
+ <th>Data</th>
216
+ </tr>
217
+ </thead>
218
+ <tbody id="events-body">
219
+ </tbody>
220
+ </table>
221
+ <script>
222
+ const events = {events_json};
223
+ const tbody = document.getElementById('events-body');
224
+ events.forEach(event => {{
225
+ const row = document.createElement('tr');
226
+ const timestamp = new Date(event.timestamp * 1000).toISOString();
227
+ row.innerHTML = `
228
+ <td>${{timestamp}}</td>
229
+ <td><span class="category ${{event.category}}">${{event.category}}</span></td>
230
+ <td class="type">${{event.type}}</td>
231
+ <td class="data">${{JSON.stringify(event.data)}}</td>
232
+ `;
233
+ tbody.appendChild(row);
234
+ }});
235
+ </script>
236
+ </body>
237
+ </html>
238
+ """
@@ -0,0 +1,468 @@
1
+ """MetricsReporter - Generate MetricsFrame objects from conversation events."""
2
+
3
+ import inspect
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any
6
+
7
+ from pipecat.frames.frames import MetricsFrame
8
+ from pydantic import BaseModel
9
+
10
+ from voiceground.events import EventCategory, EventType, VoicegroundEvent
11
+ from voiceground.metrics import (
12
+ VoicegroundLLMResponseTimeFrame,
13
+ VoicegroundResponseTimeFrame,
14
+ VoicegroundSystemOverheadFrame,
15
+ VoicegroundToolUsageFrame,
16
+ VoicegroundTranscriptionOverheadFrame,
17
+ VoicegroundTurnDurationFrame,
18
+ VoicegroundVoiceSynthesisOverheadFrame,
19
+ )
20
+ from voiceground.reporters.base import BaseReporter
21
+
22
+
23
+ class ToolCallData(BaseModel):
24
+ """Type-safe model for tool call data.
25
+
26
+ Attributes:
27
+ name: Name of the tool/function that was called.
28
+ duration: Duration of the tool call in milliseconds.
29
+ """
30
+
31
+ name: str
32
+ duration: float
33
+
34
+
35
+ class SystemOverheadData(BaseModel):
36
+ """Type-safe model for system overhead data.
37
+
38
+ Attributes:
39
+ name: Name/type of the system operation (e.g., "context_aggregation_timeout").
40
+ duration: Duration of the system operation in milliseconds.
41
+ """
42
+
43
+ name: str
44
+ duration: float
45
+
46
+
47
+ class TurnMetricsData(BaseModel):
48
+ """Type-safe model for calculated turn metrics.
49
+
50
+ Attributes:
51
+ turn_duration: Total turn duration in milliseconds.
52
+ response_time: Response time in milliseconds (or None).
53
+ transcription_overhead: Transcription overhead in milliseconds (or None).
54
+ voice_synthesis_overhead: Voice synthesis overhead in milliseconds (or None).
55
+ llm_response_time: LLM response time in milliseconds (or None).
56
+ llm_net_time: LLM net time (without tools) in milliseconds (or None).
57
+ system_overheads: List of individual system overhead data.
58
+ tool_calls: List of individual tool call data.
59
+ """
60
+
61
+ turn_duration: float
62
+ response_time: float | None
63
+ transcription_overhead: float | None
64
+ voice_synthesis_overhead: float | None
65
+ llm_response_time: float | None
66
+ llm_net_time: float | None
67
+ system_overheads: list[SystemOverheadData]
68
+ tool_calls: list[ToolCallData]
69
+
70
+
71
+ # Pure functional helpers for event processing
72
+
73
+
74
+ def find_event(
75
+ events: list[VoicegroundEvent], category: EventCategory, event_type: EventType
76
+ ) -> VoicegroundEvent | None:
77
+ """Find the first event matching category and type."""
78
+ return next(
79
+ (e for e in events if e.category == category and e.type == event_type),
80
+ None,
81
+ )
82
+
83
+
84
+ def find_all_events(
85
+ events: list[VoicegroundEvent], category: EventCategory, event_type: EventType
86
+ ) -> list[VoicegroundEvent]:
87
+ """Find all events matching category and type."""
88
+ return [e for e in events if e.category == category and e.type == event_type]
89
+
90
+
91
+ def calculate_duration_ms(
92
+ start: VoicegroundEvent | None, end: VoicegroundEvent | None
93
+ ) -> float | None:
94
+ """Calculate duration between two events in milliseconds."""
95
+ if start and end:
96
+ return (end.timestamp - start.timestamp) * 1000
97
+ return None
98
+
99
+
100
+ def extract_operation_name(start_event: VoicegroundEvent, end_event: VoicegroundEvent) -> str:
101
+ """Extract operation name from event data, with fallback."""
102
+ return (
103
+ end_event.data.get("operation", "")
104
+ or start_event.data.get("operation", "")
105
+ or "unknown_operation"
106
+ )
107
+
108
+
109
+ def find_matching_end_event(
110
+ events: list[VoicegroundEvent],
111
+ start_event: VoicegroundEvent,
112
+ category: EventCategory,
113
+ min_timestamp: float,
114
+ max_timestamp: float,
115
+ ) -> VoicegroundEvent | None:
116
+ """Find the matching end event for a start event within a time range."""
117
+ return next(
118
+ (
119
+ e
120
+ for e in events
121
+ if e.category == category
122
+ and e.type == EventType.END
123
+ and e.timestamp > start_event.timestamp
124
+ and min_timestamp <= e.timestamp <= max_timestamp
125
+ ),
126
+ None,
127
+ )
128
+
129
+
130
+ def calculate_tool_calls(
131
+ events: list[VoicegroundEvent], llm_start: VoicegroundEvent, llm_end: VoicegroundEvent
132
+ ) -> list[ToolCallData]:
133
+ """Calculate individual tool calls that start during LLM phase.
134
+
135
+ Tool calls can start during the LLM phase and may end after the LLM phase ends.
136
+ """
137
+ tool_call_starts = [
138
+ e
139
+ for e in find_all_events(events, EventCategory.TOOL_CALL, EventType.START)
140
+ if llm_start.timestamp <= e.timestamp <= llm_end.timestamp
141
+ ]
142
+
143
+ tool_calls = []
144
+ used_end_events = set()
145
+
146
+ for start_event in tool_call_starts:
147
+ # Find matching end event - it can be after llm_end
148
+ # Use a large max_timestamp to allow tool calls to end after LLM phase
149
+ end_event = next(
150
+ (
151
+ e
152
+ for e in events
153
+ if e.id not in used_end_events
154
+ and e.category == EventCategory.TOOL_CALL
155
+ and e.type == EventType.END
156
+ and e.timestamp > start_event.timestamp
157
+ and (
158
+ (e.data.get("operation") == start_event.data.get("operation"))
159
+ or (e.data.get("name") == start_event.data.get("name"))
160
+ )
161
+ ),
162
+ None,
163
+ )
164
+
165
+ if end_event:
166
+ used_end_events.add(end_event.id)
167
+ duration_ms = calculate_duration_ms(start_event, end_event)
168
+ if duration_ms is not None:
169
+ tool_name = extract_operation_name(start_event, end_event).replace(
170
+ "unknown_operation", "unknown_tool"
171
+ )
172
+ tool_calls.append(ToolCallData(name=tool_name, duration=duration_ms))
173
+
174
+ return tool_calls
175
+
176
+
177
+ def calculate_system_overheads(
178
+ events: list[VoicegroundEvent], stt_end: VoicegroundEvent, llm_start: VoicegroundEvent
179
+ ) -> list[SystemOverheadData]:
180
+ """Calculate individual system overheads between stt:end and llm:start."""
181
+ system_starts = [
182
+ e
183
+ for e in find_all_events(events, EventCategory.SYSTEM, EventType.START)
184
+ if stt_end.timestamp <= e.timestamp <= llm_start.timestamp
185
+ ]
186
+
187
+ system_overheads = []
188
+ for start_event in system_starts:
189
+ end_event = find_matching_end_event(
190
+ events, start_event, EventCategory.SYSTEM, stt_end.timestamp, llm_start.timestamp
191
+ )
192
+ if end_event:
193
+ duration_ms = calculate_duration_ms(start_event, end_event)
194
+ if duration_ms is not None:
195
+ operation_name = extract_operation_name(start_event, end_event)
196
+ system_overheads.append(
197
+ SystemOverheadData(name=operation_name, duration=duration_ms)
198
+ )
199
+
200
+ return system_overheads
201
+
202
+
203
+ def calculate_response_time(
204
+ events: list[VoicegroundEvent],
205
+ user_speak_end: VoicegroundEvent | None,
206
+ bot_speak_start: VoicegroundEvent | None,
207
+ ) -> float | None:
208
+ """Calculate response time from user_speak:end to bot_speak:start."""
209
+ if user_speak_end and bot_speak_start:
210
+ return calculate_duration_ms(user_speak_end, bot_speak_start)
211
+
212
+ if not user_speak_end and bot_speak_start:
213
+ first_event_time = min((e.timestamp for e in events), default=0.0)
214
+ return (bot_speak_start.timestamp - first_event_time) * 1000
215
+
216
+ return None
217
+
218
+
219
+ def calculate_llm_net_time(
220
+ llm_response_time: float | None, tools_total_duration: float
221
+ ) -> float | None:
222
+ """Calculate LLM net time (response time minus tools overhead)."""
223
+ if llm_response_time is None:
224
+ return None
225
+
226
+ if tools_total_duration > 0:
227
+ return llm_response_time - tools_total_duration
228
+
229
+ # If no tools, net time equals total time
230
+ return llm_response_time
231
+
232
+
233
+ def calculate_turn_metrics(events: list[VoicegroundEvent]) -> TurnMetricsData:
234
+ """Calculate all metrics for a turn from events."""
235
+ # Find key events
236
+ user_speak_end = find_event(events, EventCategory.USER_SPEAK, EventType.END)
237
+ stt_end = find_event(events, EventCategory.STT, EventType.END)
238
+ llm_first_byte = find_event(events, EventCategory.LLM, EventType.FIRST_BYTE)
239
+ tts_start = find_event(events, EventCategory.TTS, EventType.START)
240
+ bot_speak_start = find_event(events, EventCategory.BOT_SPEAK, EventType.START)
241
+
242
+ # Use the first LLM start for metrics
243
+ llm_starts = find_all_events(events, EventCategory.LLM, EventType.START)
244
+ llm_start = min(llm_starts, key=lambda e: e.timestamp) if llm_starts else None
245
+ llm_end = find_event(events, EventCategory.LLM, EventType.END)
246
+
247
+ # Calculate time ranges
248
+ first_event_time = min((e.timestamp for e in events), default=0.0)
249
+ last_event_time = max((e.timestamp for e in events), default=0.0)
250
+ turn_duration_ms = (last_event_time - first_event_time) * 1000
251
+
252
+ # Calculate metrics
253
+ response_time = calculate_response_time(events, user_speak_end, bot_speak_start)
254
+ transcription_overhead = calculate_duration_ms(user_speak_end, stt_end)
255
+ voice_synthesis_overhead = calculate_duration_ms(tts_start, bot_speak_start)
256
+ llm_response_time = calculate_duration_ms(llm_start, llm_first_byte)
257
+
258
+ # Calculate tool calls and system overheads
259
+ tool_calls = calculate_tool_calls(events, llm_start, llm_end) if llm_start and llm_end else []
260
+ tools_total_duration = sum(tc.duration for tc in tool_calls)
261
+ llm_net_time = calculate_llm_net_time(llm_response_time, tools_total_duration)
262
+
263
+ system_overheads = (
264
+ calculate_system_overheads(events, stt_end, llm_start) if stt_end and llm_start else []
265
+ )
266
+
267
+ return TurnMetricsData(
268
+ turn_duration=turn_duration_ms,
269
+ response_time=response_time,
270
+ transcription_overhead=transcription_overhead,
271
+ voice_synthesis_overhead=voice_synthesis_overhead,
272
+ llm_response_time=llm_response_time,
273
+ llm_net_time=llm_net_time,
274
+ system_overheads=system_overheads,
275
+ tool_calls=tool_calls,
276
+ )
277
+
278
+
279
+ # Pure functional helpers for metric frame creation
280
+
281
+
282
+ def create_all_metric_frames(metrics: TurnMetricsData) -> list[MetricsFrame]:
283
+ """Create all MetricsFrame objects for a turn's metrics."""
284
+ frames: list[MetricsFrame] = []
285
+
286
+ # Turn duration (always present)
287
+ frames.append(VoicegroundTurnDurationFrame(value=metrics.turn_duration / 1000))
288
+
289
+ # Optional metrics
290
+ if metrics.response_time is not None:
291
+ frames.append(VoicegroundResponseTimeFrame(value=metrics.response_time / 1000))
292
+
293
+ if metrics.transcription_overhead is not None:
294
+ frames.append(
295
+ VoicegroundTranscriptionOverheadFrame(value=metrics.transcription_overhead / 1000)
296
+ )
297
+
298
+ if metrics.voice_synthesis_overhead is not None:
299
+ frames.append(
300
+ VoicegroundVoiceSynthesisOverheadFrame(value=metrics.voice_synthesis_overhead / 1000)
301
+ )
302
+
303
+ if metrics.llm_response_time is not None:
304
+ frames.append(
305
+ VoicegroundLLMResponseTimeFrame(
306
+ value=metrics.llm_response_time / 1000,
307
+ net_value=metrics.llm_net_time / 1000 if metrics.llm_net_time is not None else None,
308
+ )
309
+ )
310
+
311
+ # System overheads
312
+ for so in metrics.system_overheads:
313
+ frames.append(
314
+ VoicegroundSystemOverheadFrame(
315
+ value=so.duration / 1000,
316
+ operation_name=so.name,
317
+ )
318
+ )
319
+
320
+ # Tool usage
321
+ for tc in metrics.tool_calls:
322
+ frames.append(
323
+ VoicegroundToolUsageFrame(
324
+ value=tc.duration / 1000,
325
+ tool_name=tc.name,
326
+ )
327
+ )
328
+
329
+ return frames
330
+
331
+
332
+ class MetricsReporter(BaseReporter):
333
+ """Reporter that creates MetricsFrame objects from conversation events.
334
+
335
+ Calculates opinionated metrics per turn and creates MetricsFrame objects
336
+ with custom Voiceground metric classes. Supports an optional callback
337
+ for real-time metric processing (e.g., for Prometheus integration).
338
+
339
+ Args:
340
+ on_metric_reported: Optional callback function called immediately after
341
+ each MetricsFrame is created. Can be sync or async.
342
+ Signature: Callable[[MetricsFrame], Awaitable[None] | None]
343
+ """
344
+
345
+ def __init__(
346
+ self,
347
+ on_metric_reported: Callable[[MetricsFrame], Awaitable[None] | None] | None = None,
348
+ ):
349
+ self._events: list[VoicegroundEvent] = []
350
+ self._metrics_frames: list[MetricsFrame] = []
351
+ self._on_metric_reported = on_metric_reported
352
+ self._conversation_id: str | None = None
353
+
354
+ async def on_start(self, conversation_id: str) -> None:
355
+ """Set the conversation ID when the pipeline starts."""
356
+ self._conversation_id = conversation_id
357
+ self._events = []
358
+ self._metrics_frames = []
359
+
360
+ async def on_event(self, event: VoicegroundEvent) -> None:
361
+ """Record an event."""
362
+ self._events.append(event)
363
+
364
+ async def on_end(self) -> None:
365
+ """Calculate metrics and create MetricsFrame objects."""
366
+ if not self._events:
367
+ return
368
+
369
+ turns = self._parse_turns(self._events)
370
+
371
+ for turn in turns:
372
+ metrics = calculate_turn_metrics(turn["events"])
373
+ await self._create_metrics_frames(metrics, turn["turn_id"])
374
+
375
+ def get_metrics_frames(self) -> list[MetricsFrame]:
376
+ """Get all generated MetricsFrame objects.
377
+
378
+ Returns:
379
+ List of MetricsFrame objects, one per metric per turn.
380
+ """
381
+ return self._metrics_frames.copy()
382
+
383
+ def _parse_turns(self, events: list[VoicegroundEvent]) -> list[dict[str, Any]]:
384
+ """Parse events into turns, matching UI logic from TurnsView.tsx."""
385
+ if not events:
386
+ return []
387
+
388
+ sorted_events = sorted(events, key=lambda e: e.timestamp)
389
+ turns: list[dict[str, Any]] = []
390
+ current_turn: dict[str, Any] | None = None
391
+ turn_id = 0
392
+
393
+ for event in sorted_events:
394
+ if event.category == EventCategory.USER_SPEAK and event.type == EventType.START:
395
+ if current_turn:
396
+ # Close the current turn
397
+ if current_turn["events"]:
398
+ current_turn["end_time"] = current_turn["events"][-1].timestamp
399
+ turns.append(current_turn)
400
+ current_turn = {
401
+ "turn_id": turn_id,
402
+ "start_time": event.timestamp,
403
+ "end_time": event.timestamp,
404
+ "events": [event],
405
+ }
406
+ turn_id += 1
407
+ elif current_turn:
408
+ # Check if this event belongs to the previous turn
409
+ if (
410
+ event.category == EventCategory.BOT_SPEAK
411
+ and event.type == EventType.END
412
+ and turns
413
+ ):
414
+ last_turn = turns[-1]
415
+ if event.timestamp - last_turn["end_time"] < 0.1:
416
+ last_turn["events"].append(event)
417
+ last_turn["end_time"] = event.timestamp
418
+ continue
419
+
420
+ current_turn["events"].append(event)
421
+ current_turn["end_time"] = event.timestamp
422
+
423
+ if event.category == EventCategory.BOT_SPEAK and event.type == EventType.END:
424
+ turns.append(current_turn)
425
+ current_turn = None
426
+ else:
427
+ # If no current turn and no turns yet, start a turn on the first event
428
+ if not turns:
429
+ current_turn = {
430
+ "turn_id": turn_id,
431
+ "start_time": event.timestamp,
432
+ "end_time": event.timestamp,
433
+ "events": [event],
434
+ }
435
+ turn_id += 1
436
+ else:
437
+ # Try to add to the last turn if within 2 seconds
438
+ last_turn = turns[-1]
439
+ if event.timestamp - last_turn["end_time"] < 2:
440
+ last_turn["events"].append(event)
441
+ last_turn["end_time"] = event.timestamp
442
+
443
+ if current_turn:
444
+ turns.append(current_turn)
445
+
446
+ return turns
447
+
448
+ async def _create_metrics_frames(self, metrics: TurnMetricsData, turn_id: int) -> None:
449
+ """Create MetricsFrame objects for all metrics and call callback if provided."""
450
+ metric_frames = create_all_metric_frames(metrics)
451
+
452
+ for metrics_frame in metric_frames:
453
+ self._metrics_frames.append(metrics_frame)
454
+
455
+ if self._on_metric_reported:
456
+ await self._invoke_callback(metrics_frame)
457
+
458
+ async def _invoke_callback(self, metrics_frame: MetricsFrame) -> None:
459
+ """Invoke the callback function, handling both sync and async cases."""
460
+ if self._on_metric_reported is None:
461
+ return
462
+
463
+ if inspect.iscoroutinefunction(self._on_metric_reported):
464
+ await self._on_metric_reported(metrics_frame)
465
+ else:
466
+ result = self._on_metric_reported(metrics_frame)
467
+ if inspect.isawaitable(result):
468
+ await result