Initial Commit 🚀 🚀

This commit is contained in:
Abhishek Kumar 2025-09-09 14:37:32 +05:30
commit 4f2a629340
444 changed files with 76863 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from .orchestrator import LoopTalkTestOrchestrator
__all__ = ["LoopTalkTestOrchestrator"]

View file

@ -0,0 +1,220 @@
"""
Audio streaming processor for LoopTalk real-time audio monitoring.
This processor captures audio from both actor and adversary agents and streams
it to connected WebRTC clients for real-time monitoring.
"""
import asyncio
from typing import Dict, Set
from loguru import logger
from pipecat.audio.utils import mix_audio
from pipecat.frames.frames import (
Frame,
InputAudioRawFrame,
OutputAudioRawFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
class LoopTalkAudioStreamer(FrameProcessor):
"""
Processes audio frames from LoopTalk conversations and streams to WebRTC clients.
This processor sits in the pipeline and captures all audio frames, then
forwards them to connected WebRTC clients for real-time monitoring.
"""
def __init__(
self,
test_session_id: str,
role: str, # "actor" or "adversary"
**kwargs,
):
super().__init__(**kwargs)
self._test_session_id = test_session_id
self._role = role
self._listeners: Set[asyncio.Queue] = set()
self._sample_rate = 16000 # Default sample rate
self._num_channels = 1
def add_listener(self, queue: asyncio.Queue):
"""Add a listener queue for streaming audio."""
self._listeners.add(queue)
def remove_listener(self, queue: asyncio.Queue):
"""Remove a listener queue."""
self._listeners.discard(queue)
async def process_frame(self, frame: Frame, direction: FrameDirection):
"""Process audio frames and stream to listeners."""
await super().process_frame(frame, direction)
# Capture both input and output audio
if isinstance(frame, (InputAudioRawFrame, OutputAudioRawFrame)):
# Extract audio data
audio_data = frame.audio
sample_rate = frame.sample_rate
num_channels = frame.num_channels
# Store sample rate for reference
if sample_rate:
self._sample_rate = sample_rate
if num_channels:
self._num_channels = num_channels
# Stream to all listeners
if self._listeners and audio_data:
# Create a packet with metadata
packet = {
"test_session_id": self._test_session_id,
"role": self._role,
"audio": audio_data,
"sample_rate": sample_rate,
"num_channels": num_channels,
"is_input": isinstance(frame, InputAudioRawFrame),
}
# Send to all listeners without blocking
for queue in list(self._listeners):
try:
queue.put_nowait(packet)
except asyncio.QueueFull:
logger.warning(
f"Audio queue full for session {self._test_session_id}"
)
except Exception as e:
logger.error(f"Error streaming audio: {e}")
self._listeners.discard(queue)
elif self._listeners and not audio_data:
logger.warning(
f"Audio streamer {self._role} received frame with no audio data"
)
elif audio_data and not self._listeners:
# This is expected early in the session before WebSocket connects
pass
# Always forward the frame
await self.push_frame(frame, direction)
class LoopTalkAudioMixer:
"""
Mixes audio from actor and adversary streams for combined playback.
This class manages the mixing of two audio streams (actor and adversary)
to create a combined audio stream for monitoring.
"""
def __init__(self, test_session_id: str):
self._test_session_id = test_session_id
self._actor_buffer = bytearray()
self._adversary_buffer = bytearray()
self._listeners: Set[asyncio.Queue] = set()
self._sample_rate = 16000
self._num_channels = 1
self._buffer_size = 8000 # 0.5 seconds at 16kHz
def add_listener(self, queue: asyncio.Queue):
"""Add a listener for mixed audio."""
self._listeners.add(queue)
def remove_listener(self, queue: asyncio.Queue):
"""Remove a listener."""
self._listeners.discard(queue)
async def add_audio(
self, role: str, audio_data: bytes, sample_rate: int, num_channels: int
):
"""Add audio data from actor or adversary."""
if role == "actor":
self._actor_buffer.extend(audio_data)
elif role == "adversary":
self._adversary_buffer.extend(audio_data)
# Update audio parameters
self._sample_rate = sample_rate
self._num_channels = num_channels
# Check if we have enough data to mix
await self._check_and_mix()
async def _check_and_mix(self):
"""Check buffers and mix audio when enough data is available."""
# Mix when we have at least buffer_size in both buffers
while (
len(self._actor_buffer) >= self._buffer_size
and len(self._adversary_buffer) >= self._buffer_size
):
# Extract chunks
actor_chunk = bytes(self._actor_buffer[: self._buffer_size])
adversary_chunk = bytes(self._adversary_buffer[: self._buffer_size])
# Remove from buffers
del self._actor_buffer[: self._buffer_size]
del self._adversary_buffer[: self._buffer_size]
# Mix audio
mixed_audio = mix_audio(actor_chunk, adversary_chunk)
# Stream to listeners
if self._listeners and mixed_audio:
packet = {
"test_session_id": self._test_session_id,
"role": "mixed",
"audio": mixed_audio,
"sample_rate": self._sample_rate,
"num_channels": self._num_channels,
"is_input": False,
}
for queue in list(self._listeners):
try:
queue.put_nowait(packet)
except asyncio.QueueFull:
logger.warning(
f"Mixed audio queue full for session {self._test_session_id}"
)
except Exception as e:
logger.error(f"Error streaming mixed audio: {e}")
self._listeners.discard(queue)
# Global registry for audio streamers and mixers
_audio_streamers: Dict[str, Dict[str, LoopTalkAudioStreamer]] = {}
_audio_mixers: Dict[str, LoopTalkAudioMixer] = {}
def get_or_create_audio_streamer(
test_session_id: str, role: str
) -> LoopTalkAudioStreamer:
"""Get or create an audio streamer for a test session and role."""
if test_session_id not in _audio_streamers:
_audio_streamers[test_session_id] = {}
if role not in _audio_streamers[test_session_id]:
_audio_streamers[test_session_id][role] = LoopTalkAudioStreamer(
test_session_id=test_session_id, role=role
)
return _audio_streamers[test_session_id][role]
def get_or_create_audio_mixer(test_session_id: str) -> LoopTalkAudioMixer:
"""Get or create an audio mixer for a test session."""
if test_session_id not in _audio_mixers:
_audio_mixers[test_session_id] = LoopTalkAudioMixer(test_session_id)
return _audio_mixers[test_session_id]
def cleanup_audio_streamers(test_session_id: str):
"""Clean up audio streamers and mixers for a test session."""
if test_session_id in _audio_streamers:
del _audio_streamers[test_session_id]
if test_session_id in _audio_mixers:
del _audio_mixers[test_session_id]
logger.info(f"Cleaned up audio streamers for test session {test_session_id}")

View file

@ -0,0 +1 @@
"""Core modules for LoopTalk orchestration."""

View file

@ -0,0 +1,167 @@
"""Pipeline building logic for LoopTalk agents."""
from typing import Any, Dict
from loguru import logger
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.filters.stt_mute_filter import (
STTMuteConfig,
STTMuteFilter,
STTMuteStrategy,
)
from pipecat.transports import InternalTransport
from api.db.db_client import DBClient
from api.services.looptalk.audio_streamer import get_or_create_audio_streamer
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.pipeline_builder import (
create_pipeline_components,
create_pipeline_task,
)
from api.services.pipecat.pipeline_engine_callbacks_processor import (
PipelineEngineCallbacksProcessor,
)
from api.services.pipecat.service_factory import (
create_llm_service,
create_stt_service,
create_tts_service,
)
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow import WorkflowGraph
class LoopTalkPipelineBuilder:
"""Builds pipelines for LoopTalk agents."""
def __init__(self, db_client: DBClient):
"""Initialize the pipeline builder.
Args:
db_client: Database client for fetching user configurations
"""
self.db_client = db_client
async def create_agent_pipeline(
self,
transport: InternalTransport,
workflow: Any,
test_session_id: int,
agent_id: str,
role: str,
) -> Dict[str, Any]:
"""Create a pipeline for an agent (actor or adversary).
Args:
transport: Internal transport for the agent
workflow: Workflow model from database
test_session_id: ID of the test session
agent_id: Unique identifier for the agent
role: Either "actor" or "adversary"
Returns:
Dictionary containing pipeline task, engine, and components
"""
# Get user configuration from database
user_config = await self.db_client.get_user_configurations(workflow.user_id)
# Create pipeline components
audio_config = AudioConfig(
transport_in_sample_rate=16000,
transport_out_sample_rate=16000,
vad_sample_rate=16000,
pipeline_sample_rate=16000,
)
# Create services
stt = create_stt_service(user_config)
llm = create_llm_service(user_config)
tts = create_tts_service(user_config, audio_config)
logger.debug(f"Created services for {role}: STT={stt}, LLM={llm}, TTS={tts}")
audio_buffer, audio_synchronizer, transcript, context = (
create_pipeline_components(audio_config)
)
context_aggregator = llm.create_context_aggregator(context)
# Get workflow graph
workflow_graph = WorkflowGraph(
ReactFlowDTO.model_validate(workflow.workflow_definition_with_fallback)
)
# Create engine
engine = PipecatEngine(
task=None, # Will be set after creating the task
llm=llm,
context=context,
tts=tts,
workflow=workflow_graph,
call_context_vars={},
audio_buffer=audio_buffer,
workflow_run_id=None, # LoopTalk doesn't have workflow runs
)
# Create STT mute filter
stt_mute_filter = STTMuteFilter(
config=STTMuteConfig(
strategies={STTMuteStrategy.FIRST_SPEECH},
)
)
# Create pipeline engine callback processor
pipeline_engine_callback_processor = PipelineEngineCallbacksProcessor(
max_call_duration_seconds=300,
max_duration_end_task_callback=engine.create_max_duration_callback(),
llm_generated_text_callback=engine.create_llm_generated_text_callback(),
generation_started_callback=engine.create_generation_started_callback(),
)
# Get aggregators
user_context_aggregator = context_aggregator.user()
assistant_context_aggregator = context_aggregator.assistant()
# Register processors with synchronizer for merged audio
audio_synchronizer.register_processors(
audio_buffer.input(), audio_buffer.output()
)
# Get audio streamer for real-time streaming
audio_streamer = get_or_create_audio_streamer(str(test_session_id), role)
# Create pipeline
pipeline = Pipeline(
[
transport.input(),
audio_buffer.input(), # Record input audio
audio_streamer, # Stream audio to connected clients
stt_mute_filter,
stt,
transcript.user(),
user_context_aggregator,
llm,
pipeline_engine_callback_processor,
tts,
transport.output(),
audio_buffer.output(), # Record output audio
transcript.assistant(),
assistant_context_aggregator,
]
)
# Create pipeline task with unique conversation ID for tracing
conversation_id = f"{test_session_id}-{role}-{agent_id}"
task = create_pipeline_task(pipeline, conversation_id, audio_config)
# Set the task on the engine
engine.task = task
return {
"task": task,
"engine": engine,
"audio_buffer": audio_buffer,
"audio_synchronizer": audio_synchronizer,
"transcript": transcript,
"assistant_context_aggregator": assistant_context_aggregator,
"audio_streamer": audio_streamer,
}

View file

@ -0,0 +1,216 @@
"""Recording management for LoopTalk sessions."""
import wave
from pathlib import Path
from typing import Dict, Optional, Tuple
from loguru import logger
from api.enums import StorageBackend
from api.services.storage import storage_fs
class RecordingManager:
"""Manages audio recording and transcript files for LoopTalk sessions."""
def __init__(self, base_dir: Path):
"""Initialize the recording manager.
Args:
base_dir: Base directory for temporary recordings
"""
self.base_dir = base_dir
self.base_dir.mkdir(parents=True, exist_ok=True)
def get_recording_paths(self, test_session_id: int, role: str) -> Dict[str, Path]:
"""Get file paths for recordings.
Args:
test_session_id: ID of the test session
role: Either "actor" or "adversary"
Returns:
Dictionary with paths for audio, transcript, and temp audio files
"""
session_dir = self.base_dir / f"session_{test_session_id}"
session_dir.mkdir(parents=True, exist_ok=True)
return {
"audio": session_dir / f"{role}_audio.wav",
"transcript": session_dir / f"{role}_transcript.txt",
"temp_audio": session_dir / f"{role}_audio_temp.pcm",
}
def convert_pcm_to_wav(
self,
test_session_id: int,
role: str,
sample_rate: int = 16000,
num_channels: int = 1,
) -> Optional[Path]:
"""Convert PCM audio file to WAV format.
Args:
test_session_id: ID of the test session
role: Either "actor" or "adversary"
sample_rate: Sample rate of the audio
num_channels: Number of audio channels
Returns:
Path to the WAV file if successful, None otherwise
"""
paths = self.get_recording_paths(test_session_id, role)
# Check if PCM file exists
if not paths["temp_audio"].exists():
logger.warning(f"No audio recorded for {role} in session {test_session_id}")
return None
try:
# Read PCM data
with open(paths["temp_audio"], "rb") as f:
pcm_data = f.read()
# Write WAV file
with wave.open(str(paths["audio"]), "wb") as wav_file:
wav_file.setnchannels(num_channels)
wav_file.setsampwidth(2) # 16-bit audio
wav_file.setframerate(sample_rate)
wav_file.writeframes(pcm_data)
# Remove temporary PCM file
paths["temp_audio"].unlink()
logger.info(
f"Converted audio to WAV for {role} in session {test_session_id}: {paths['audio']}"
)
return paths["audio"]
except Exception as e:
logger.error(
f"Failed to convert audio to WAV for {role} in session {test_session_id}: {e}"
)
return None
async def upload_recording_to_s3(
self, test_session_id: int, role: str
) -> Tuple[Optional[str], Optional[str]]:
"""Upload recording and transcript to S3.
Args:
test_session_id: ID of the test session
role: Either "actor" or "adversary"
Returns:
Tuple of (audio_url, transcript_url) or (None, None) if failed
"""
paths = self.get_recording_paths(test_session_id, role)
audio_url = None
transcript_url = None
# Import here to avoid circular imports
current_backend = StorageBackend.get_current_backend()
logger.info(
f"LOOPTALK UPLOAD: Using {current_backend.label} (code: {current_backend.code}) for session {test_session_id}, role: {role}"
)
# Upload audio if exists
if paths["audio"].exists():
audio_key = f"looptalk/recordings/{test_session_id}/{role}_audio.wav"
try:
success = await storage_fs.aupload_file(str(paths["audio"]), audio_key)
if success:
audio_url = audio_key
logger.info(
f"Uploaded {role} audio to {current_backend.label}: {audio_key}"
)
else:
logger.error(
f"Failed to upload {role} audio to {current_backend.label}"
)
except Exception as e:
logger.error(
f"Error uploading {role} audio to {current_backend.label}: {e}"
)
# Upload transcript if exists
if paths["transcript"].exists():
transcript_key = (
f"looptalk/transcripts/{test_session_id}/{role}_transcript.txt"
)
try:
success = await storage_fs.aupload_file(
str(paths["transcript"]), transcript_key
)
if success:
transcript_url = transcript_key
logger.info(
f"Uploaded {role} transcript to {current_backend.label}: {transcript_key}"
)
else:
logger.error(
f"Failed to upload {role} transcript to {current_backend.label}"
)
except Exception as e:
logger.error(
f"Error uploading {role} transcript to {current_backend.label}: {e}"
)
return audio_url, transcript_url
def cleanup_session_files(self, test_session_id: int):
"""Clean up local files for a session.
Args:
test_session_id: ID of the test session
"""
session_dir = self.base_dir / f"session_{test_session_id}"
if session_dir.exists():
try:
import shutil
shutil.rmtree(session_dir)
logger.debug(f"Cleaned up local files for session {test_session_id}")
except Exception as e:
logger.error(f"Failed to clean up session files: {e}")
def get_recording_info(self, test_session_id: int) -> Dict[str, any]:
"""Get information about recordings for a test session.
Args:
test_session_id: ID of the test session
Returns:
Dictionary with recording information
"""
session_dir = self.base_dir / f"session_{test_session_id}"
info = {
"test_session_id": test_session_id,
"recording_dir": str(session_dir),
"files": {},
}
for role in ["actor", "adversary"]:
paths = self.get_recording_paths(test_session_id, role)
role_info = {}
# Check audio file
if paths["audio"].exists():
role_info["audio"] = {
"path": str(paths["audio"]),
"size_bytes": paths["audio"].stat().st_size,
}
# Check transcript file
if paths["transcript"].exists():
role_info["transcript"] = {
"path": str(paths["transcript"]),
"size_bytes": paths["transcript"].stat().st_size,
}
if role_info:
info["files"][role] = role_info
return info

View file

@ -0,0 +1,184 @@
"""Session management for LoopTalk test sessions."""
import asyncio
from datetime import UTC, datetime
from typing import Any, Dict, Optional
from loguru import logger
class SessionManager:
"""Manages running LoopTalk test sessions."""
def __init__(self):
"""Initialize the session manager."""
self._running_sessions: Dict[int, Dict[str, Any]] = {}
self._disconnect_handlers: Dict[int, asyncio.Task] = {}
def add_session(self, test_session_id: int, session_info: Dict[str, Any]):
"""Add a new session to the manager.
Args:
test_session_id: ID of the test session
session_info: Dictionary containing session information
"""
self._running_sessions[test_session_id] = session_info
def get_session(self, test_session_id: int) -> Optional[Dict[str, Any]]:
"""Get session information.
Args:
test_session_id: ID of the test session
Returns:
Session information dictionary or None if not found
"""
return self._running_sessions.get(test_session_id)
def remove_session(self, test_session_id: int):
"""Remove a session from the manager.
Args:
test_session_id: ID of the test session
"""
if test_session_id in self._running_sessions:
del self._running_sessions[test_session_id]
# Cancel any disconnect handler for this session
if test_session_id in self._disconnect_handlers:
handler = self._disconnect_handlers.pop(test_session_id)
if not handler.done():
handler.cancel()
def get_active_count(self) -> int:
"""Get the number of currently active sessions."""
return len(self._running_sessions)
def get_active_info(self) -> Dict[str, Any]:
"""Get information about all active sessions."""
return {
"count": len(self._running_sessions),
"sessions": [
{
"test_session_id": session_id,
"conversation_id": info["conversation"].id,
"start_time": info["start_time"],
"duration_seconds": int(
(datetime.now(UTC) - info["start_time"]).total_seconds()
),
}
for session_id, info in self._running_sessions.items()
],
}
async def handle_agent_disconnect(
self, test_session_id: int, disconnected_role: str, stop_callback: callable
):
"""Handle when one agent disconnects.
This will cancel the other agent as well to ensure clean shutdown.
Args:
test_session_id: ID of the test session
disconnected_role: Role that disconnected ("actor" or "adversary")
stop_callback: Callback to stop the session
"""
logger.info(
f"Handling {disconnected_role} disconnect for session {test_session_id}"
)
# Check if we already have a disconnect handler running
if test_session_id in self._disconnect_handlers:
logger.debug(
f"Disconnect handler already running for session {test_session_id}"
)
return
# Create a task to handle the disconnect
async def _handle_disconnect():
try:
# Wait a short time to avoid race conditions
await asyncio.sleep(0.5)
# Check if session still exists
session_info = self.get_session(test_session_id)
if not session_info:
logger.debug(f"Session {test_session_id} already stopped")
return
# Stop the session (which will cancel both agents)
logger.info(
f"Stopping session {test_session_id} due to {disconnected_role} disconnect"
)
await stop_callback(test_session_id)
except asyncio.CancelledError:
logger.debug(
f"Disconnect handler cancelled for session {test_session_id}"
)
raise
except Exception as e:
logger.error(
f"Error handling disconnect for session {test_session_id}: {e}"
)
# Store the task so we can cancel it if needed
self._disconnect_handlers[test_session_id] = asyncio.create_task(
_handle_disconnect()
)
def update_audio_metadata(
self,
test_session_id: int,
role: str,
sample_rate: Optional[int] = None,
num_channels: Optional[int] = None,
):
"""Update audio metadata for a role in a session.
Args:
test_session_id: ID of the test session
role: Either "actor" or "adversary"
sample_rate: Sample rate of the audio
num_channels: Number of audio channels
"""
if test_session_id not in self._running_sessions:
return
if "audio_metadata" not in self._running_sessions[test_session_id]:
self._running_sessions[test_session_id]["audio_metadata"] = {}
if role not in self._running_sessions[test_session_id]["audio_metadata"]:
self._running_sessions[test_session_id]["audio_metadata"][role] = {}
metadata = self._running_sessions[test_session_id]["audio_metadata"][role]
if sample_rate is not None:
metadata["sample_rate"] = sample_rate
if num_channels is not None:
metadata["num_channels"] = num_channels
def get_audio_metadata(self, test_session_id: int, role: str) -> Dict[str, Any]:
"""Get audio metadata for a role in a session.
Args:
test_session_id: ID of the test session
role: Either "actor" or "adversary"
Returns:
Dictionary with sample_rate and num_channels
"""
default = {"sample_rate": 16000, "num_channels": 1}
if test_session_id not in self._running_sessions:
return default
metadata = (
self._running_sessions.get(test_session_id, {})
.get("audio_metadata", {})
.get(role, {})
)
return {
"sample_rate": metadata.get("sample_rate", 16000),
"num_channels": metadata.get("num_channels", 1),
}

View file

@ -0,0 +1,553 @@
import asyncio
import os
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Dict, Optional
from loguru import logger
from pipecat.pipeline.task import PipelineTask
from pipecat.transports import (
InternalTransport,
InternalTransportManager,
)
from pipecat.utils.context import set_current_run_id
from api.db.db_client import DBClient
from api.services.pipecat.transport_setup import create_internal_transport
from .core.pipeline_builder import LoopTalkPipelineBuilder
from .core.recording_manager import RecordingManager
from .core.session_manager import SessionManager
class LoopTalkTestOrchestrator:
"""Orchestrates LoopTalk testing sessions with agent-to-agent conversations."""
def __init__(
self, db_client: DBClient, network_latency_seconds: Optional[float] = None
):
self.db_client = db_client
self.transport_manager = InternalTransportManager()
self.session_manager = SessionManager()
self.pipeline_builder = LoopTalkPipelineBuilder(db_client)
self.recording_manager = RecordingManager(Path("/tmp/looptalk_recordings"))
# Default network latency (can be overridden per session)
# Priority: constructor param > env var > default (100ms)
if network_latency_seconds is not None:
self._default_network_latency = network_latency_seconds
else:
env_latency = os.environ.get("LOOPTALK_NETWORK_LATENCY_MS")
if env_latency:
try:
self._default_network_latency = (
float(env_latency) / 1000.0
) # Convert ms to seconds
except ValueError:
logger.warning(
f"Invalid LOOPTALK_NETWORK_LATENCY_MS value: {env_latency}, using default 100ms"
)
self._default_network_latency = 0.1
else:
self._default_network_latency = 0.1 # 100ms default
async def start_test_session(
self,
test_session_id: int,
organization_id: int,
network_latency_seconds: Optional[float] = None,
) -> Dict[str, Any]:
"""Start a LoopTalk test session."""
# Get test session details
test_session = await self.db_client.get_test_session(
test_session_id=test_session_id, organization_id=organization_id
)
if not test_session:
raise ValueError(f"Test session {test_session_id} not found")
if test_session.status != "pending":
raise ValueError(f"Test session {test_session_id} is not in pending state")
try:
# Update status to running
await self.db_client.update_test_session_status(
test_session_id=test_session_id, status="running"
)
# Create conversation record
conversation = await self.db_client.create_conversation(
test_session_id=test_session_id
)
# Create audio configuration for LoopTalk
from api.services.pipecat.audio_config import AudioConfig
audio_config = AudioConfig(
transport_in_sample_rate=16000,
transport_out_sample_rate=16000,
pipeline_sample_rate=16000,
)
# Use provided latency or fall back to default
latency = (
network_latency_seconds
if network_latency_seconds is not None
else self._default_network_latency
)
logger.info(
f"Using network latency of {latency}s for test session {test_session_id}"
)
# Generate unique workflow run IDs for each agent
actor_workflow_run_id = int(str(test_session_id) + "1")
adversary_workflow_run_id = int(str(test_session_id) + "2")
# Create transports using the new method with turn analyzer
actor_transport = create_internal_transport(
workflow_run_id=actor_workflow_run_id,
audio_config=audio_config,
latency_seconds=latency,
)
adversary_transport = create_internal_transport(
workflow_run_id=adversary_workflow_run_id,
audio_config=audio_config,
latency_seconds=latency,
)
# Connect the transports
actor_transport.connect_partner(adversary_transport)
# Store the transport pair in the manager
self.transport_manager._transport_pairs[str(test_session_id)] = (
actor_transport,
adversary_transport,
)
# Generate unique identifiers for actor and adversary
actor_id = f"actor_{test_session_id}_{str(uuid.uuid4())[:8]}"
adversary_id = f"adversary_{test_session_id}_{str(uuid.uuid4())[:8]}"
# Create pipelines for both agents
actor_pipeline_info = await self.pipeline_builder.create_agent_pipeline(
transport=actor_transport,
workflow=test_session.actor_workflow,
test_session_id=test_session_id,
agent_id=actor_id,
role="actor",
)
actor_pipeline_task = actor_pipeline_info["task"]
adversary_pipeline_info = await self.pipeline_builder.create_agent_pipeline(
transport=adversary_transport,
workflow=test_session.adversary_workflow,
test_session_id=test_session_id,
agent_id=adversary_id,
role="adversary",
)
adversary_pipeline_task = adversary_pipeline_info["task"]
# Register event handlers for both pipelines
await self._register_transport_handlers(
actor_transport, actor_pipeline_info, test_session_id, "actor"
)
await self._register_transport_handlers(
adversary_transport,
adversary_pipeline_info,
test_session_id,
"adversary",
)
# Store session info
session_info = {
"test_session": test_session,
"conversation": conversation,
"actor_task": actor_pipeline_task,
"adversary_task": adversary_pipeline_task,
"actor_transport": actor_transport,
"adversary_transport": adversary_transport,
"start_time": datetime.now(UTC),
}
self.session_manager.add_session(test_session_id, session_info)
# Start both pipelines in background tasks
from pipecat.pipeline.base_task import PipelineTaskParams
params = PipelineTaskParams(loop=asyncio.get_event_loop())
# Start the pipelines - this will trigger initialization through the normal pipeline start process
# The workflow engines will be initialized when the pipeline starts
# Create conversation IDs for tracing
actor_conversation_id = f"{test_session_id}-actor-{actor_id}"
adversary_conversation_id = f"{test_session_id}-adversary-{adversary_id}"
# Create tasks but don't await them - they'll run in the background
logger.debug(f"Running actor task with ID: {actor_id}")
actor_task_future = asyncio.create_task(
self._run_pipeline_with_context(
actor_pipeline_task,
params,
actor_id,
actor_conversation_id,
"actor",
)
)
logger.debug(f"Running adversary task with ID: {adversary_id}")
adversary_task_future = asyncio.create_task(
self._run_pipeline_with_context(
adversary_pipeline_task,
params,
adversary_id,
adversary_conversation_id,
"adversary",
)
)
# Store the futures so we can monitor them
session_info["actor_task_future"] = actor_task_future
session_info["adversary_task_future"] = adversary_task_future
logger.info(f"Started LoopTalk test session {test_session_id}")
return {
"test_session_id": test_session_id,
"conversation_id": conversation.id,
"status": "running",
}
except Exception as e:
logger.error(f"Failed to start test session {test_session_id}: {e}")
await self.db_client.update_test_session_status(
test_session_id=test_session_id, status="failed", error=str(e)
)
raise
async def _register_transport_handlers(
self,
transport: InternalTransport,
pipeline_info: Dict[str, Any],
test_session_id: int,
role: str,
):
"""Register transport event handlers for a pipeline.
Args:
transport: The transport to register handlers on
pipeline_info: Dictionary containing pipeline components
test_session_id: ID of the test session
role: Either "actor" or "adversary"
"""
engine = pipeline_info["engine"]
task = pipeline_info["task"]
audio_buffer = pipeline_info["audio_buffer"]
audio_synchronizer = pipeline_info["audio_synchronizer"]
transcript = pipeline_info["transcript"]
assistant_context_aggregator = pipeline_info["assistant_context_aggregator"]
# Register transport event handlers
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, participant):
logger.debug(f"LoopTalk {role} client connected - initializing workflow")
# Start audio recording
await audio_buffer.start_recording()
await audio_synchronizer.start_recording()
await engine.initialize()
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, participant):
logger.debug(f"LoopTalk {role} client disconnected")
# Stop audio recording
await audio_buffer.stop_recording()
await audio_synchronizer.stop_recording()
# Handle disconnect propagation - stop the other agent too
await self.session_manager.handle_agent_disconnect(
test_session_id, role, self.stop_test_session
)
await task.cancel()
# Connect the context aggregator events to engine
@assistant_context_aggregator.event_handler("on_push_aggregation")
async def on_assistant_aggregator_push_context(_aggregator):
logger.debug(
"Assistant aggregator push context flushing pending transitions"
)
await engine.flush_pending_transitions()
# Register custom audio and transcript handlers for LoopTalk
await self._register_looptalk_handlers(
audio_synchronizer, transcript, test_session_id, role
)
async def _register_looptalk_handlers(
self, audio_synchronizer, transcript, test_session_id: int, role: str
):
"""Register LoopTalk-specific handlers for audio and transcript recording"""
paths = self.recording_manager.get_recording_paths(test_session_id, role)
# Store audio metadata for later WAV conversion
audio_metadata = {"sample_rate": None, "num_channels": None}
# Audio handler - writes directly to PCM file
@audio_synchronizer.event_handler("on_merged_audio")
async def on_merged_audio(_, pcm, sample_rate, num_channels):
if not pcm:
return
# Store metadata on first write
if audio_metadata["sample_rate"] is None:
audio_metadata["sample_rate"] = sample_rate
audio_metadata["num_channels"] = num_channels
# Append PCM data to temporary file
try:
with open(paths["temp_audio"], "ab") as f:
f.write(pcm)
except Exception as e:
logger.error(
f"Failed to write audio for {role} in session {test_session_id}: {e}"
)
# Transcript handler - writes directly to text file
@transcript.event_handler("on_transcript_update")
async def on_transcript_update(processor, frame):
transcript_text = ""
for msg in frame.messages:
timestamp = f"[{msg.timestamp}] " if msg.timestamp else ""
line = f"{timestamp}{msg.role}: {msg.content}\n"
transcript_text += line
# Append transcript to file
try:
with open(paths["transcript"], "a") as f:
f.write(transcript_text)
except Exception as e:
logger.error(
f"Failed to write transcript for {role} in session {test_session_id}: {e}"
)
# Store metadata in session info for later WAV conversion
# Set default values if not yet captured
if audio_metadata["sample_rate"] is None:
audio_metadata["sample_rate"] = 16000 # Default sample rate
audio_metadata["num_channels"] = 1 # Default channels
self.session_manager.update_audio_metadata(
test_session_id,
role,
sample_rate=audio_metadata["sample_rate"],
num_channels=audio_metadata["num_channels"],
)
async def _run_pipeline_with_context(
self,
pipeline_task: PipelineTask,
params,
agent_id: str,
conversation_id: str,
role: str,
):
"""Run a pipeline task with the agent_id set in context"""
set_current_run_id(agent_id)
return await pipeline_task.run(params)
async def stop_test_session(self, test_session_id: int) -> Dict[str, Any]:
"""Stop a running test session."""
session_info = self.session_manager.get_session(test_session_id)
if not session_info:
raise ValueError(f"Test session {test_session_id} is not running")
try:
# Cancel both pipeline tasks
await session_info["actor_task"].cancel()
await session_info["adversary_task"].cancel()
# Also cancel the task futures if they exist
if "actor_task_future" in session_info:
session_info["actor_task_future"].cancel()
if "adversary_task_future" in session_info:
session_info["adversary_task_future"].cancel()
# Calculate duration
duration_seconds = int(
(datetime.now(UTC) - session_info["start_time"]).total_seconds()
)
# Update conversation
await self.db_client.update_conversation(
conversation_id=session_info["conversation"].id,
duration_seconds=duration_seconds,
ended_at=datetime.now(UTC),
)
# Update test session status
await self.db_client.update_test_session_status(
test_session_id=test_session_id,
status="completed",
results={
"duration_seconds": duration_seconds,
"conversation_id": session_info["conversation"].id,
},
)
# Finalize recordings for both actor and adversary
# Convert PCM files to WAV
actor_metadata = self.session_manager.get_audio_metadata(
test_session_id, "actor"
)
adversary_metadata = self.session_manager.get_audio_metadata(
test_session_id, "adversary"
)
self.recording_manager.convert_pcm_to_wav(
test_session_id,
"actor",
sample_rate=actor_metadata["sample_rate"],
num_channels=actor_metadata["num_channels"],
)
self.recording_manager.convert_pcm_to_wav(
test_session_id,
"adversary",
sample_rate=adversary_metadata["sample_rate"],
num_channels=adversary_metadata["num_channels"],
)
# Upload recordings to S3 (synchronously for load testing)
(
actor_audio_url,
actor_transcript_url,
) = await self.recording_manager.upload_recording_to_s3(
test_session_id, "actor"
)
(
adversary_audio_url,
adversary_transcript_url,
) = await self.recording_manager.upload_recording_to_s3(
test_session_id, "adversary"
)
# Update conversation with recording URLs
await self.db_client.update_conversation(
conversation_id=session_info["conversation"].id,
actor_recording_url=actor_audio_url,
adversary_recording_url=adversary_audio_url,
transcript={
"actor_transcript_url": actor_transcript_url,
"adversary_transcript_url": adversary_transcript_url,
},
)
# Log recording locations
logger.info(f"LoopTalk recordings uploaded to S3:")
if actor_audio_url:
logger.info(f" - Actor audio: {actor_audio_url}")
if actor_transcript_url:
logger.info(f" - Actor transcript: {actor_transcript_url}")
if adversary_audio_url:
logger.info(f" - Adversary audio: {adversary_audio_url}")
if adversary_transcript_url:
logger.info(f" - Adversary transcript: {adversary_transcript_url}")
# Clean up local files after successful upload
self.recording_manager.cleanup_session_files(test_session_id)
# Clean up
self.transport_manager.remove_transport_pair(str(test_session_id))
self.session_manager.remove_session(test_session_id)
# Clean up audio streamers
from api.services.looptalk.audio_streamer import cleanup_audio_streamers
cleanup_audio_streamers(str(test_session_id))
logger.info(f"Stopped LoopTalk test session {test_session_id}")
return {
"test_session_id": test_session_id,
"status": "completed",
"duration_seconds": duration_seconds,
}
except Exception as e:
logger.error(f"Failed to stop test session {test_session_id}: {e}")
await self.db_client.update_test_session_status(
test_session_id=test_session_id, status="failed", error=str(e)
)
raise
async def start_load_test(
self,
organization_id: int,
name_prefix: str,
actor_workflow_id: int,
adversary_workflow_id: int,
config: Dict[str, Any],
test_count: int,
) -> Dict[str, Any]:
"""Start a load test with multiple concurrent test sessions."""
# Validate test count
if test_count < 1 or test_count > 10:
raise ValueError("Test count must be between 1 and 10")
# Create test sessions
test_sessions = await self.db_client.create_load_test_group(
organization_id=organization_id,
name_prefix=name_prefix,
actor_workflow_id=actor_workflow_id,
adversary_workflow_id=adversary_workflow_id,
config=config,
test_count=test_count,
)
# Start all test sessions concurrently
tasks = []
for test_session in test_sessions:
task = asyncio.create_task(
self.start_test_session(
test_session_id=test_session.id, organization_id=organization_id
)
)
tasks.append(task)
# Wait for all to start
results = await asyncio.gather(*tasks, return_exceptions=True)
# Count successes and failures
started = sum(1 for r in results if not isinstance(r, Exception))
failed = sum(1 for r in results if isinstance(r, Exception))
load_test_group_id = test_sessions[0].load_test_group_id
logger.info(
f"Started load test {load_test_group_id}: "
f"{started} started, {failed} failed out of {test_count}"
)
return {
"load_test_group_id": load_test_group_id,
"total": test_count,
"started": started,
"failed": failed,
"test_session_ids": [ts.id for ts in test_sessions],
}
def get_active_test_count(self) -> int:
"""Get the number of currently active test sessions."""
return self.session_manager.get_active_count()
def get_active_test_info(self) -> Dict[str, Any]:
"""Get information about all active test sessions."""
return self.session_manager.get_active_info()
def get_recording_info(self, test_session_id: int) -> Dict[str, Any]:
"""Get information about recordings for a test session"""
return self.recording_manager.get_recording_info(test_session_id)