mirror of
https://github.com/confident-ai/deepeval.git
synced 2026-10-12 02:12:15 +00:00
660 lines
23 KiB
Python
660 lines
23 KiB
Python
"""Simulate a call against a real streaming voice agent, over a plain WebSocket.
|
|
|
|
`b.py` talks to an in-process callback that is handed one finished utterance,
|
|
transcribes it, thinks, and synthesizes a reply. Nothing about it can start
|
|
before the caller's last frame exists, so it cannot show what streaming the
|
|
uplink buys.
|
|
|
|
The agent here is a separate process that listens the way a real voice agent
|
|
listens: it transcribes the caller's speech as the frames arrive, detects the
|
|
end of the turn from silence in the stream, and streams its own reply back at
|
|
real time so it can be interrupted mid-sentence. That is enough to make the
|
|
difference visible, without WebRTC, a VAD model, or an agent framework.
|
|
|
|
What to look for
|
|
----------------
|
|
Synthesis takes the same few seconds either way, so streaming does not get the
|
|
whole utterance to the agent any sooner — it gets the *beginning* there sooner.
|
|
The agent's own listening then overlaps our synthesis instead of following it:
|
|
|
|
- Buffered uplink: every frame lands at once, so the agent's transcription
|
|
starts only when the caller has finished. Its STT sits in the critical path.
|
|
- Streamed uplink: frames land from the first one onward, so by the end of the
|
|
turn the agent has already transcribed nearly all of it.
|
|
|
|
The agent process prints which of the two happened on every turn.
|
|
|
|
Whisper transcribes whole clips, so the agent approximates continuous listening
|
|
by re-transcribing the audio so far every second or so. A purpose-built
|
|
streaming STT would overlap more completely than this; the point here is that
|
|
the overlap exists at all, which a buffered uplink does not allow.
|
|
|
|
Setup
|
|
-----
|
|
Only OpenAI is needed; the transport is `aiohttp`, which deepeval already
|
|
depends on.
|
|
|
|
export OPENAI_API_KEY=...
|
|
|
|
Start the agent and leave it running:
|
|
|
|
python c.py agent
|
|
|
|
Then, in another shell:
|
|
|
|
python c.py
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
import socket
|
|
import sys
|
|
import textwrap
|
|
import time
|
|
from collections import defaultdict
|
|
from contextlib import contextmanager
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from deepeval.models.llms import OpenAIModel
|
|
from deepeval.models.stt import OpenAISTTModel
|
|
from deepeval.models.tts import OpenAITTSModel
|
|
from deepeval.test_case import Audio
|
|
from deepeval.voice.connectors import audio_utils
|
|
|
|
HOST = "127.0.0.1"
|
|
PORT = 8765
|
|
PATH = "/voice"
|
|
WS_URL = f"ws://{HOST}:{PORT}{PATH}"
|
|
|
|
SAMPLE_RATE = 24000
|
|
BYTES_PER_SECOND = SAMPLE_RATE * 2
|
|
|
|
AGENT_PROMPT = """
|
|
You are Walter, an elderly customer-support representative handling a missing
|
|
order over the phone.
|
|
|
|
Rules:
|
|
- Respond directly to the customer's latest words and remember prior turns.
|
|
- Be blunt, impatient, and somewhat grumpy, but do not use profanity.
|
|
- Do not say "please", "thank you", "sorry", "apologies", "happy", or "glad".
|
|
- Ramble for 3-5 sentences and include an unnecessary old-fashioned anecdote.
|
|
- Still perform the support task: obtain the order number, explain the delay,
|
|
offer refund or replacement, and confirm the selected resolution.
|
|
- Never claim the replacement is confirmed until the customer chooses it.
|
|
|
|
Conversation:
|
|
{conversation}
|
|
|
|
Walter:
|
|
""".strip()
|
|
|
|
|
|
############################################
|
|
### The agent (system under test) ##########
|
|
############################################
|
|
|
|
# Silence in the caller's stream that ends their turn. The connector pads full
|
|
# turns with trailing silence for exactly this.
|
|
END_OF_TURN_SILENCE_S = 0.8
|
|
# A barge carries no trailing padding, so a gap in the frames ends the turn too.
|
|
FRAME_GAP_TIMEOUT_S = 1.0
|
|
# How much new speech to let build up before transcribing again mid-utterance.
|
|
PARTIAL_EVERY_S = 1.2
|
|
# How much speech a partial may be missing and still be used as the transcript.
|
|
STALE_PARTIAL_S = 0.4
|
|
|
|
|
|
class AgentCall:
|
|
"""One call, listened to as it arrives rather than after it finishes."""
|
|
|
|
def __init__(self, ws, stt, llm, tts, log):
|
|
self.ws = ws
|
|
self.stt = stt
|
|
self.llm = llm
|
|
self.tts = tts
|
|
self.log = log
|
|
self.history: List[Tuple[str, str]] = []
|
|
|
|
self.pcm = bytearray()
|
|
self.speech_bytes = 0 # buffer length at the last frame with speech
|
|
self.silence_s = 0.0
|
|
self.heard_speech = False
|
|
self.last_frame_at = time.perf_counter()
|
|
|
|
self.partial: Optional[asyncio.Task] = None
|
|
self.partial_text: Optional[str] = None
|
|
self.partial_covered = 0
|
|
self.reply: Optional[asyncio.Task] = None
|
|
|
|
async def on_frame(self, pcm: bytes) -> None:
|
|
self.last_frame_at = time.perf_counter()
|
|
self.pcm.extend(pcm)
|
|
if audio_utils.is_silent(pcm):
|
|
if self.heard_speech:
|
|
self.silence_s += len(pcm) / BYTES_PER_SECOND
|
|
return
|
|
|
|
self.heard_speech = True
|
|
self.silence_s = 0.0
|
|
self.speech_bytes = len(self.pcm)
|
|
# The caller is talking, so stop talking over them.
|
|
await self._abandon_reply()
|
|
self._transcribe_what_has_arrived()
|
|
|
|
async def tick(self) -> None:
|
|
"""End the turn on silence, or on the caller's frames simply stopping."""
|
|
if not self.heard_speech or self.reply is not None:
|
|
return
|
|
starved = time.perf_counter() - self.last_frame_at > FRAME_GAP_TIMEOUT_S
|
|
if self.silence_s >= END_OF_TURN_SILENCE_S or starved:
|
|
self.reply = asyncio.create_task(self._answer())
|
|
|
|
def _transcribe_what_has_arrived(self) -> None:
|
|
"""Transcribe the speech so far while the rest of it is still coming."""
|
|
if self.partial is not None and not self.partial.done():
|
|
return
|
|
if self.speech_bytes - self.partial_covered < int(
|
|
BYTES_PER_SECOND * PARTIAL_EVERY_S
|
|
):
|
|
return
|
|
snapshot = bytes(self.pcm[: self.speech_bytes])
|
|
self.partial = asyncio.create_task(self._transcribe(snapshot))
|
|
|
|
async def _transcribe(self, snapshot: bytes) -> Tuple[str, int]:
|
|
audio = Audio.from_bytes(
|
|
audio_utils.pcm16_to_wav_bytes(snapshot, SAMPLE_RATE, 1),
|
|
"audio/wav",
|
|
sampleRate=SAMPLE_RATE,
|
|
encoding="wav",
|
|
)
|
|
text, _ = await self.stt.a_transcribe(audio)
|
|
return text, len(snapshot)
|
|
|
|
async def _heard(self) -> str:
|
|
"""The caller's words, reusing whatever was transcribed while they spoke."""
|
|
ended_at = time.perf_counter()
|
|
if self.partial is not None:
|
|
try:
|
|
self.partial_text, self.partial_covered = await self.partial
|
|
except Exception:
|
|
self.log.exception("Partial transcription failed")
|
|
self.partial = None
|
|
|
|
missing = self.speech_bytes - self.partial_covered
|
|
if self.partial_text and missing <= int(
|
|
BYTES_PER_SECOND * STALE_PARTIAL_S
|
|
):
|
|
self.log.info(
|
|
"Listened while the caller spoke: transcript ready %.2fs after "
|
|
"end of turn (%.1fs of speech, %.2fs of it untranscribed)",
|
|
time.perf_counter() - ended_at,
|
|
self.speech_bytes / BYTES_PER_SECOND,
|
|
missing / BYTES_PER_SECOND,
|
|
)
|
|
return self.partial_text
|
|
|
|
text, _ = await self._transcribe(bytes(self.pcm[: self.speech_bytes]))
|
|
self.log.info(
|
|
"Transcribed after the caller finished: %.2fs spent with the "
|
|
"caller waiting (%.1fs of speech)",
|
|
time.perf_counter() - ended_at,
|
|
self.speech_bytes / BYTES_PER_SECOND,
|
|
)
|
|
return text
|
|
|
|
async def _answer(self) -> None:
|
|
try:
|
|
user_text = (await self._heard()).strip()
|
|
self._reset_turn()
|
|
if not user_text:
|
|
return
|
|
self.log.info("Heard: %r", user_text)
|
|
self.history.append(("customer", user_text))
|
|
|
|
conversation = "\n".join(
|
|
f"{role}: {content}" for role, content in self.history[-10:]
|
|
)
|
|
reply, _ = await self.llm.a_generate(
|
|
AGENT_PROMPT.format(conversation=conversation)
|
|
)
|
|
reply = str(reply).strip()
|
|
self.history.append(("Walter", reply))
|
|
self.log.info("Replying: %r", reply)
|
|
|
|
await self._send({"type": "transcript", "transcript": reply})
|
|
await self._speak(reply)
|
|
await self._send({"type": "turn_complete"})
|
|
except asyncio.CancelledError:
|
|
await self._send({"type": "turn_complete"})
|
|
raise
|
|
finally:
|
|
self.reply = None
|
|
|
|
async def _speak(self, reply: str) -> None:
|
|
"""Stream the reply out at real time, starting on the first frame.
|
|
|
|
Sending as fast as synthesis allows would deliver a 20-second reply in
|
|
four seconds, leaving the caller no room to interrupt it. Real time is
|
|
both honest and what makes barge-in possible.
|
|
"""
|
|
started = time.perf_counter()
|
|
spoken_s = 0.0
|
|
async for chunk in self.tts.a_synthesize_stream(reply):
|
|
data = chunk.get_bytes()
|
|
await self._send(
|
|
{
|
|
"type": "audio",
|
|
"audio": base64.b64encode(data).decode("ascii"),
|
|
}
|
|
)
|
|
spoken_s += len(data) / BYTES_PER_SECOND
|
|
ahead = spoken_s - (time.perf_counter() - started)
|
|
if ahead > 0:
|
|
await asyncio.sleep(ahead)
|
|
|
|
async def _send(self, message: dict) -> None:
|
|
if not self.ws.closed:
|
|
await self.ws.send_str(json.dumps(message))
|
|
|
|
def _reset_turn(self) -> None:
|
|
self.pcm = bytearray()
|
|
self.speech_bytes = 0
|
|
self.silence_s = 0.0
|
|
self.heard_speech = False
|
|
self.partial_text = None
|
|
self.partial_covered = 0
|
|
|
|
async def _abandon_reply(self) -> None:
|
|
if self.reply is None or self.reply.done():
|
|
return
|
|
self.reply.cancel()
|
|
try:
|
|
await self.reply
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
self.reply = None
|
|
|
|
async def aclose(self) -> None:
|
|
await self._abandon_reply()
|
|
if self.partial is not None and not self.partial.done():
|
|
self.partial.cancel()
|
|
|
|
|
|
def build_agent_app(stt, llm, tts, log=None):
|
|
"""The agent as an aiohttp app, so its transport can be exercised alone."""
|
|
from aiohttp import WSMsgType, web
|
|
|
|
log = log or logging.getLogger("walter")
|
|
|
|
async def handler(request):
|
|
ws = web.WebSocketResponse(max_msg_size=0, heartbeat=None)
|
|
await ws.prepare(request)
|
|
log.info("Caller connected")
|
|
call = AgentCall(ws, stt, llm, tts, log)
|
|
|
|
async def ticker():
|
|
# End-of-turn also has to fire when the frames simply stop, which
|
|
# no inbound message will announce.
|
|
while not ws.closed:
|
|
await asyncio.sleep(0.05)
|
|
await call.tick()
|
|
|
|
pulse = asyncio.create_task(ticker())
|
|
try:
|
|
async for msg in ws:
|
|
if msg.type == WSMsgType.BINARY:
|
|
await call.on_frame(msg.data)
|
|
await call.tick()
|
|
elif msg.type in (WSMsgType.CLOSED, WSMsgType.ERROR):
|
|
break
|
|
finally:
|
|
pulse.cancel()
|
|
await call.aclose()
|
|
log.info("Caller disconnected")
|
|
return ws
|
|
|
|
app = web.Application()
|
|
app.router.add_get(PATH, handler)
|
|
return app
|
|
|
|
|
|
def run_agent() -> None:
|
|
from aiohttp import web
|
|
|
|
if not os.getenv("OPENAI_API_KEY"):
|
|
raise RuntimeError("Set OPENAI_API_KEY before running the agent.")
|
|
|
|
log = logging.getLogger("walter")
|
|
app = build_agent_app(
|
|
stt=OpenAISTTModel(model="whisper-1"),
|
|
llm=OpenAIModel(model="gpt-4o-mini", temperature=0.7),
|
|
tts=OpenAITTSModel(
|
|
voice="onyx",
|
|
generation_kwargs={
|
|
"speed": 1.15,
|
|
"instructions": (
|
|
"Speak like a friendly, rambling older man. Use a warm, "
|
|
"unhurried tone with occasional reflective pauses."
|
|
),
|
|
},
|
|
),
|
|
log=log,
|
|
)
|
|
log.info("Walter listening on %s", WS_URL)
|
|
web.run_app(app, host=HOST, port=PORT, print=None)
|
|
|
|
|
|
############################################
|
|
### Instrumentation ########################
|
|
############################################
|
|
|
|
TIMINGS: Dict[str, List[float]] = defaultdict(list)
|
|
|
|
# Only the caller's side is visible from here. The agent is another process, so
|
|
# it is measured the way a user measures a vendor: by how long it takes to
|
|
# answer.
|
|
CALLER_STAGES = (
|
|
"caller TTS (to first frame)",
|
|
"caller TTS (whole utterance)",
|
|
"user turn (LLM)",
|
|
"stopping check (LLM)",
|
|
"barge judge (LLM)",
|
|
"agent STT",
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def timed(stage: str):
|
|
started = time.perf_counter()
|
|
try:
|
|
yield
|
|
finally:
|
|
TIMINGS[stage].append(time.perf_counter() - started)
|
|
|
|
|
|
class TimedTTSModel(OpenAITTSModel):
|
|
"""Separates the wait for the first frame from the whole utterance.
|
|
|
|
The first number is the silence the caller leaves before speaking; the gap
|
|
between the two is how much synthesis happens while they are already
|
|
talking, which is the agent's chance to work in parallel.
|
|
"""
|
|
|
|
async def a_synthesize_stream(self, text: str, **kwargs):
|
|
started = time.perf_counter()
|
|
first_frame = False
|
|
async for chunk in super().a_synthesize_stream(text, **kwargs):
|
|
if not first_frame:
|
|
first_frame = True
|
|
TIMINGS["caller TTS (to first frame)"].append(
|
|
time.perf_counter() - started
|
|
)
|
|
yield chunk
|
|
TIMINGS["caller TTS (whole utterance)"].append(
|
|
time.perf_counter() - started
|
|
)
|
|
|
|
async def a_synthesize(self, text: str, **kwargs):
|
|
with timed("caller TTS (whole utterance)"):
|
|
return await super().a_synthesize(text, **kwargs)
|
|
|
|
|
|
class TimedSTTModel(OpenAISTTModel):
|
|
async def a_transcribe(self, audio, **kwargs):
|
|
with timed("agent STT"):
|
|
return await super().a_transcribe(audio, **kwargs)
|
|
|
|
|
|
class TimedSimulatorModel(OpenAIModel):
|
|
_STAGE_BY_SCHEMA = {
|
|
"ConversationCompletion": "stopping check (LLM)",
|
|
"SimulatedInput": "user turn (LLM)",
|
|
"InterruptDecision": "barge judge (LLM)",
|
|
}
|
|
|
|
async def a_generate(self, prompt: str, *args, **kwargs):
|
|
schema = kwargs.get("schema") or (args[0] if args else None)
|
|
stage = self._STAGE_BY_SCHEMA.get(
|
|
getattr(schema, "__name__", ""), "other simulator (LLM)"
|
|
)
|
|
with timed(stage):
|
|
return await super().a_generate(prompt, *args, **kwargs)
|
|
|
|
|
|
############################################
|
|
### Simulation #############################
|
|
############################################
|
|
|
|
|
|
def _require_agent() -> None:
|
|
"""Say plainly that the agent is not up, rather than failing mid-dial."""
|
|
with socket.socket() as probe:
|
|
probe.settimeout(1.0)
|
|
if probe.connect_ex((HOST, PORT)) == 0:
|
|
return
|
|
raise RuntimeError(
|
|
f"Nothing is listening on {WS_URL}. The agent runs as its own process: "
|
|
"start it in another shell with `python c.py agent`, wait for it to say "
|
|
"it is listening, then run this again."
|
|
)
|
|
|
|
|
|
def run_simulation() -> None:
|
|
from deepeval.dataset import (
|
|
ConversationalGolden,
|
|
InterruptionBehavior,
|
|
Persona,
|
|
)
|
|
from deepeval.simulator import ConversationSimulator
|
|
from deepeval.voice import VoiceConfig
|
|
from deepeval.voice.connectors.transports.websocket import (
|
|
WebSocketConnector,
|
|
)
|
|
|
|
if not os.getenv("OPENAI_API_KEY"):
|
|
raise RuntimeError("Set OPENAI_API_KEY before running this script.")
|
|
_require_agent()
|
|
|
|
caller_tts_model = TimedTTSModel(
|
|
voice="coral",
|
|
generation_kwargs={
|
|
"speed": 1.4,
|
|
"instructions": "Speak clearly, briskly, and with mild frustration.",
|
|
},
|
|
)
|
|
connector = WebSocketConnector(
|
|
WS_URL,
|
|
sample_rate=SAMPLE_RATE,
|
|
# Raw PCM frames up, JSON down so the agent can send its transcript and
|
|
# say when its turn is over.
|
|
binary_outbound=True,
|
|
receive_audio_key="audio",
|
|
receive_transcript_key="transcript",
|
|
turn_complete_type="turn_complete",
|
|
# Padding on full turns is what the agent's silence detection listens
|
|
# for; it has to outlast the agent's own end-of-turn threshold.
|
|
trailing_silence_ms=1200,
|
|
# Walter pauses mid-reply, and his replies run long. This agent closes
|
|
# each turn explicitly, so silence is only the fallback, but the ceiling
|
|
# still has to be generous enough to hear a whole answer out.
|
|
turn_detection="patient",
|
|
)
|
|
simulator = ConversationSimulator(
|
|
simulator_model=TimedSimulatorModel(),
|
|
voice_config=VoiceConfig(
|
|
connector=connector,
|
|
tts_model=caller_tts_model,
|
|
stt_model=TimedSTTModel(model="whisper-1"),
|
|
output_dir="voice_simulations_ws",
|
|
combine_audio_files=True,
|
|
),
|
|
)
|
|
|
|
logger.info("Connecting to the agent at %s", WS_URL)
|
|
started = time.perf_counter()
|
|
conversations = simulator.simulate(
|
|
[
|
|
ConversationalGolden(
|
|
name="missing-order-websocket",
|
|
scenario=(
|
|
"The user calls customer support because an important "
|
|
"order has not arrived. They must identify the order, "
|
|
"understand what happened, and request a replacement."
|
|
),
|
|
persona=Persona(
|
|
characteristics=(
|
|
"An angry, blunt, impatient customer who makes terse "
|
|
"demands, interrupts rambling, and never says please "
|
|
"or thank you."
|
|
),
|
|
voice="coral",
|
|
interruption_behavior=InterruptionBehavior(
|
|
frequency="rare", overlap="adaptive"
|
|
),
|
|
),
|
|
expected_outcome="A replacement order is confirmed.",
|
|
)
|
|
],
|
|
max_user_simulations=5,
|
|
)
|
|
wall_seconds = time.perf_counter() - started
|
|
logger.info("Simulation finished after %.2fs", wall_seconds)
|
|
|
|
turns = conversations[0].turns
|
|
print_stage_breakdown(wall_seconds)
|
|
print_agent_latency(turns)
|
|
print_transcript(turns)
|
|
print("\nAudio saved under ./voice_simulations_ws/")
|
|
|
|
|
|
############################################
|
|
### Reporting ##############################
|
|
############################################
|
|
|
|
|
|
def print_stage_breakdown(wall_seconds: float) -> None:
|
|
print(f"\nCaller-side stages ({wall_seconds:.1f}s wall clock)")
|
|
print(f"{'stage':<30}{'calls':>6}{'total':>9}{'mean':>8}")
|
|
for stage in CALLER_STAGES:
|
|
durations = TIMINGS.get(stage)
|
|
if not durations:
|
|
continue
|
|
total = sum(durations)
|
|
print(
|
|
f"{stage:<30}{len(durations):>6}{total:>8.1f}s"
|
|
f"{total / len(durations):>7.1f}s"
|
|
)
|
|
|
|
first_frame = TIMINGS.get("caller TTS (to first frame)")
|
|
whole = TIMINGS.get("caller TTS (whole utterance)")
|
|
if first_frame and whole:
|
|
overlapped = sum(whole) / len(whole) - sum(first_frame) / len(
|
|
first_frame
|
|
)
|
|
print(
|
|
f"\n{overlapped:.1f}s per turn of synthesis happened while the "
|
|
"caller was already speaking, which the agent could listen through."
|
|
)
|
|
|
|
|
|
def print_agent_latency(turns) -> None:
|
|
"""The agent is a black box here, so it is judged on how fast it answers."""
|
|
latencies = [
|
|
turn.latency_ms / 1000
|
|
for turn in turns
|
|
if turn.role == "assistant" and turn.latency_ms is not None
|
|
]
|
|
if not latencies:
|
|
return
|
|
print(
|
|
f"\nAgent reply latency: mean {sum(latencies) / len(latencies):.1f}s, "
|
|
f"best {min(latencies):.1f}s, worst {max(latencies):.1f}s "
|
|
f"({len(latencies)} replies)"
|
|
)
|
|
|
|
|
|
def print_transcript(turns) -> None:
|
|
"""Print what was said against when it was heard."""
|
|
from deepeval.voice.timeline import build_audio_timeline
|
|
|
|
timeline = build_audio_timeline(turns, require_start_times=False)
|
|
entries = {entry.turn_index: entry for entry in timeline}
|
|
# Gaps are measured against whatever precedes each clip in *time*, which is
|
|
# not always what precedes it in the transcript: a barge is recorded after
|
|
# the reply it cut into.
|
|
preceding_end = {}
|
|
cursor = 0.0
|
|
for entry in timeline:
|
|
preceding_end[entry.turn_index] = cursor
|
|
cursor = max(cursor, entry.end_time)
|
|
|
|
print("\nAnnotated transcript (gaps are what you hear as silence)")
|
|
for index, turn in enumerate(turns):
|
|
entry = entries.get(index)
|
|
if entry is None:
|
|
timing = " no audio recorded "
|
|
else:
|
|
gap = entry.start_time - preceding_end[index]
|
|
marker = "overlap" if gap < 0 else "gap"
|
|
timing = (
|
|
f"{entry.start_time:>7.1f}s -{entry.end_time:>7.1f}s"
|
|
f" ({entry.duration:>4.1f}s) {marker}{abs(gap):>5.1f}s"
|
|
)
|
|
|
|
metadata = turn.metadata or {}
|
|
flags = []
|
|
if turn.interrupted:
|
|
flags.append("CUT SHORT BY BARGE")
|
|
if metadata.get("barge_in"):
|
|
flags.append("BARGE-IN")
|
|
if metadata.get("frustrated"):
|
|
flags.append("FRUSTRATED")
|
|
if metadata.get("ended_without_agent_signal"):
|
|
flags.append("WE STOPPED LISTENING")
|
|
if metadata.get("grace_missed_ms") is not None:
|
|
flags.append(f"grace missed {metadata['grace_missed_ms']:.0f}ms")
|
|
if turn.latency_ms is not None:
|
|
flags.append(f"latency {turn.latency_ms / 1000:.1f}s")
|
|
|
|
suffix = f" {' | '.join(flags)}" if flags else ""
|
|
print(f"\n[{index + 1}] {turn.role:<10}{timing}{suffix}")
|
|
for line in textwrap.wrap(turn.content or "(no speech)", width=88):
|
|
print(f" {line}")
|
|
intended = metadata.get("intended_content")
|
|
if intended:
|
|
if metadata.get("ended_without_agent_signal"):
|
|
print(" ...still saying when we stopped listening:")
|
|
else:
|
|
print(" ...cut off from saying:")
|
|
for line in textwrap.wrap(intended, width=84):
|
|
print(f" {line}")
|
|
|
|
speech = sum(entry.duration for entry in entries.values())
|
|
barges = sum(1 for turn in turns if (turn.metadata or {}).get("barge_in"))
|
|
cut_short = sum(1 for turn in turns if turn.interrupted)
|
|
print(
|
|
f"\nspeech {speech:.1f}s of {cursor:.1f}s recorded | "
|
|
f"{barges} barge attempt(s) | {cut_short} agent turn(s) cut short"
|
|
)
|
|
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s | %(levelname)s | %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
logger = logging.getLogger("voice-simulator-ws")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) > 1 and sys.argv[1] == "agent":
|
|
run_agent()
|
|
else:
|
|
logging.getLogger("deepeval.simulator.conversation_simulator").setLevel(
|
|
logging.DEBUG
|
|
)
|
|
run_simulation()
|