average-australia-24900
02/27/2026, 10:00 AMtall-airline-16756
02/27/2026, 10:00 AMtall-airline-16756
02/27/2026, 10:01 AMaverage-australia-24900
02/27/2026, 10:01 AMtall-airline-16756
02/27/2026, 10:02 AMtrack_subscribed and track_unsubscribed events.
◦ Buffering frames per participant/track.
◦ Converting buffered frames to WAV bytes.
◦ Writing to MongoDB GridFS and saving metadata.
1) Install dependencies
• livekit-client (Python SDK)
• pymongo
• gridfs
• numpy + soundfile or wave (for WAV encoding)
• optionally pydub + ffmpeg if you want MP3
Example pip install:
pip install livekit-client pymongo soundfile numpy
# If you want MP3 support:
pip install pydub
# ensure ffmpeg is installed on the system for pydub MP3 encoding
2) Capture, encode, and save (example)
import asyncio
import io
import datetime
from pymongo import MongoClient
import gridfs
import soundfile as sf
import numpy as np
from livekit import Room, TrackKind, RemoteAudioTrack
# Connect to MongoDB and GridFS
mongo_client = MongoClient("<mongodb://localhost:27017/>")
db = mongo_client["voice_conversations"]
fs = gridfs.GridFS(db)
conversations = db["conversations_metadata"]
# In-memory buffers per track
track_buffers = {} # key: track.sid or (participant.identity, track.sid), value: list of numpy arrays
# Helper to convert frames list to WAV bytes
def frames_to_wav_bytes(frames, sample_rate=48000, channels=1):
"""
frames: list of numpy arrays (dtype=float32 or int16) representing PCM samples
sample_rate: sample rate of frames
channels: number of channels
Returns bytes of a WAV file.
"""
# Concatenate frames
if not frames:
return None
audio = np.concatenate(frames, axis=0)
# Ensure float32 -> write as PCM 16
if audio.dtype == np.float32 or audio.dtype == np.float64:
audio_int16 = (audio * 32767).astype(np.int16)
else:
audio_int16 = audio.astype(np.int16)
buf = io.BytesIO()
# soundfile can write to file-like objects
sf.write(buf, audio_int16, sample_rate, subtype='PCM_16')
buf.seek(0)
return buf.read()
# Event handlers
async def on_track_subscribed(track: RemoteAudioTrack, publication, participant):
# Only audio tracks
if track.kind != TrackKind.AUDIO:
return
key = (participant.identity, track.sid)
track_buffers[key] = []
# Use the LiveKit audio stream helper if available; otherwise assume track provides frames via async iterator
async for frame in track.stream():
# The exact frame type depends on SDK; transform to numpy PCM float32 in range [-1, 1] or int16
# Example assumes frame.to_numpy() exists — adapt to actual SDK API
pcm = frame.to_numpy() # adapt this call to real API
track_buffers[key].append(pcm)
async def on_track_unsubscribed(publication, participant):
key = (participant.identity, publication.track_sid)
frames = track_buffers.pop(key, None)
if not frames:
return
wav_bytes = frames_to_wav_bytes(frames, sample_rate=48000, channels=1)
if not wav_bytes:
return
filename = f"{participant.identity}_{publication.track_sid}_{datetime.datetime.utcnow().isoformat()}.wav"
file_id = fs.put(wav_bytes, filename=filename, metadata={
"participant": participant.identity,
"track_sid": publication.track_sid,
"timestamp": datetime.datetime.utcnow()
})
# Save metadata document linking to file
conversations.insert_one({
"participant": participant.identity,
"track_sid": publication.track_sid,
"file_id": file_id,
"filename": filename,
"created_at": datetime.datetime.utcnow()
})
# Room setup and event wiring (simplified)
async def main():
room = Room()
# connect to your LiveKit server/room (provide url and token per your flow)
await room.connect("<wss://your-livekit-host>", "ACCESS_TOKEN")
room.on("track_subscribed", on_track_subscribed)
room.on("track_unsubscribed", on_track_unsubscribed)
# Keep running
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())
Notes and adaptations
• The exact frame API depends on the Python LiveKit SDK version. If frames are provided as raw bytes or a custom object, adapt frame.to_numpy() accordingly.
• If the SDK exposes an AudioStream helper with an async iterator of frames, use that. The goal is to collect PCM samples (int16 or float32).
• For less memory pressure, consider streaming to GridFS incrementally:
◦ Create a GridFS upload stream (fs.new_file() or fs.put with chunks) and write chunks as frames arrive, instead of buffering the entire conversation in memory.
• If you want MP3 rather than WAV, use pydub to convert WAV bytes to MP3 and store MP3 bytes to GridFS; MP3 reduces storage but requires CPU to encode.
• Save metadata (participant identities, room, start/end times, agent vs user roles) to a separate collection to ease retrieval and searching.
When to finalize/save
• Use track_unsubscribed, participant_left, or on_track_muted events to decide when to flush and write audio for a track.
• If you segment recordings (e.g., per utterance or every N seconds), you can periodically encode and append/write chunks to GridFS.
Performance and storage tips
• Compress to MP3 if storage/transfer is a concern.
• Stream to GridFS instead of buffering large files.
• Record at an appropriate sample rate (48 kHz is common for WebRTC/LiveKit, but you can downsample to 16 kHz for voice-only use to save space).
• Keep metadata minimal but include participant ids, timestamps, room id, and file_id.
If you want, I can:
• Provide a version that streams directly to GridFS incrementally (no full in-memory buffer).
• Provide adapter code for the exact LiveKit Python SDK frame API if you share which SDK version you’re using or paste a sample frame object.
• Show how to combine agent and user audio into a single mixed file or store them as separate files.
Which of those would you like next?average-australia-24900
02/27/2026, 10:03 AMtall-airline-16756
02/27/2026, 10:03 AMfrom pymongo import MongoClient
import gridfs
client = MongoClient("<mongodb://localhost:27017/>")
db = client["voice_conversations"]
fs = gridfs.GridFSBucket(db)
upload_stream = fs.open_upload_stream("session123_participantA.wav", metadata={"participant":"A","room":"room123"})
# when you have encoded bytes chunk:
upload_stream.write(encoded_chunk_bytes)
# repeat until done
upload_stream.close()
# file id available at upload_stream._file_id (or returned)
• Using ffmpeg to encode stream to MP3 while piping PCM frames:
◦ Spawn ffmpeg subprocess with stdin pipe, write raw PCM into stdin, read encoded MP3 from stdout, and write that to GridFS incrementally.
When to finalize/save
• Finalize on track_unsubscribed / participant_left events.
• For egress-based flow, finalize when egress completes.
Which approach should you pick?
• If you want reliability and less engineering effort: use Egress (Option A) and then store egress files into GridFS.
• If you need near-real-time storage, per-utterance processing, or want to do live analytics/transcription before storing: use Option B.
Next steps I can provide for you
• If you prefer Egress: I’ll give a Python example that polls/receives egress completion, downloads the file, and stores it into GridFS and metadata collection.
• If you prefer server subscription and streaming: I’ll provide a more detailed Python sample that subscribes to tracks, encodes chunks with ffmpeg, and streams into GridFS incrementally (including sample code to manage track lifecycle and metadata).
• If you tell me whether you want mixed vs per-participant files and whether you want WAV or MP3/Opus, I’ll tailor the example.
Which server approach and audio format do you want me to prepare detailed code for?
Sources: Why is there extra audio at the end of my recordings of an agent session? |... | Transcriber | LiveKit Documentation | Universal Egressaverage-australia-24900
02/27/2026, 10:09 AMsee my python code
async def entrypoint(ctx: JobContext):
# Set log context fields early
ctx.log_context_fields = {
"room": ctx.room.name,
}
# IMPORTANT: Get participant info first to know student details
await ctx.connect()
participant = await ctx.wait_for_participant()
user_id = participant.identity
student_name = participant.name
<http://logger.info|logger.info>("=" * 80)
<http://logger.info|logger.info>(f":mortar_board: VOICE AGENT SESSION STARTED")
<http://logger.info|logger.info>(f"Participant: {student_name} (ID: {user_id})")
# Get subject and topic from participant metadata (sent by frontend in token)
subject = None
topic = None
subject_id = None
auth_token = None # Extract auth token for reporting
prompt_text = None
prompt_file_used = "default_fallback"
if participant.metadata:
try:
metadata = json.loads(participant.metadata)
subject = metadata.get("subject")
topic = metadata.get("topic")
subject_id = metadata.get("subjectId")
auth_token = metadata.get("authToken")
<http://logger.info|logger.info>(f":books: Subject: {subject or 'Not specified'}")
<http://logger.info|logger.info>(f":id: Subject ID: {subject_id or 'Not specified'}")
<http://logger.info|logger.info>(f":book: Topic: {topic or 'Not specified (will use core)'}")
# Load prompt from file BEFORE creating agent
if subject:
prompt_text = await load_prompt_from_file(subject, topic)
if prompt_text:
if topic:
prompt_file_used = f"prompts/{subject}/{topic}.txt"
<http://logger.info|logger.info>(f":white_check_mark: USING TOPIC PROMPT: prompts/{subject}/{topic}.txt")
else:
prompt_file_used = f"prompts/{subject}/core_{subject}_tutor.txt"
<http://logger.info|logger.info>(f":white_check_mark: USING CORE PROMPT: prompts/{subject}/core_{subject}_tutor.txt")
<http://logger.info|logger.info>(f":memo: Prompt preview (first 200 chars): {prompt_text[:200]}...")
else:
logger.warning(f":warning: No prompt file found for {subject}/{topic}, using fallback")
else:
logger.warning(f":warning: No subject specified, using default prompt")
except Exception as e:
logger.error(f":x: Error parsing participant metadata: {e}")
else:
logger.warning(":warning: No participant metadata found, using default prompt")
<http://logger.info|logger.info>("=" * 80)
# Load chat history
temp_agent = WAECExamAgent(user_id=user_id, student_name=student_name)
chat_ctx = await temp_agent.load_chat_history()
agent = WAECExamAgent(
user_id=user_id,
student_name=student_name,
chat_ctx=chat_ctx,
instructions_text=prompt_text
)
<http://logger.info|logger.info>("=" * 80)
<http://logger.info|logger.info>(f":robot_face: AGENT INITIALIZED")
<http://logger.info|logger.info>(f"Prompt file used: {prompt_file_used}")
<http://logger.info|logger.info>(f"Instructions loaded: {'YES :white_check_mark:' if prompt_text else 'NO - Using fallback :warning:'}")
if prompt_text:
<http://logger.info|logger.info>(f"Instructions length: {len(prompt_text)} characters")
<http://logger.info|logger.info>("=" * 80)
session = AgentSession(
vad=ctx.proc.userdata["vad"],
llm=openai.LLM(model="gpt-4o-mini", api_key=os.getenv("OPENAI_API_KEY") ),
stt=openai.STT(model="whisper-1"),
tts=openai.TTS(
base_url="<https://prepai-tts.publicaai.com/v1>",
model="chatterbox-trubo",
voice="cloned-voice"
),
)
usage_collector = metrics.UsageCollector()
@session.on("metrics_collected")
def _on_metrics_collected(ev: MetricsCollectedEvent):
metrics.log_metrics(ev.metrics)
usage_collector.collect(ev.metrics)
# Start session with standard options
await session.start(
agent=agent,
room=ctx.room,
# Using RoomInputOptions for text/audio input
room_input_options=RoomInputOptions(
text_enabled=True,
audio_enabled=True,
),
room_output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=True
)
)
ctx.log_context_fields["user_id"] = user_id
# Assign session to agent to allow manual triggering
agent.session = session
<http://logger.info|logger.info>(f"Agent initialized with prompt for subject: {subject or 'default'}, topic: {topic or 'core'}")
# --- Vision Logic Restoration ---
def handle_vision_message(reader, participant_identity):
"""Handle vision messages sent via text stream"""
async def _process():
try:
# Use read_all() as per correct API
message_text = await reader.read_all()
<http://logger.info|logger.info>(f"cam:camera_with_flash: Received vision text message from {participant_identity}")
try:
message_data = json.loads(message_text)
await agent.process_vision_message(message_data)
except json.JSONDecodeError:
logger.warning(f"Received non-JSON vision message: {message_text[:100]}...")
except Exception as e:
logger.error(f"Error in vision message handler: {str(e)}", exc_info=True)
asyncio.create_task(_process())
# Register the handler
room = ctx.room
room.register_text_stream_handler('lk.chat.vision', handle_vision_message)
<http://logger.info|logger.info>(":white_check_mark: Registered text stream handler for 'lk.chat.vision'")
# --------------------------------
async def log_usage():
summary = usage_collector.get_summary()
<http://logger.info|logger.info>(f"Usage summary: {summary}")
# Save usage directly to MongoDB (No external API call needed)
try:
# Calculate total tokens (LLM + STT + TTS)
# We combine all metrics into a single "usage unit" for simplicity,
# or you might want to store them separately if the DB schema allowed.
# For now, we sum: LLM Tokens + TTS Characters + STT Seconds
llm_total = summary.llm_prompt_tokens + summary.llm_completion_tokens
tts_total = summary.tts_characters_count
stt_total = int(summary.stt_audio_duration)
total_usage_units = llm_total + tts_total + stt_total
# Prepare payload for external API
# Note: studentId is omitted as the server uses the auth token for identification
external_request_id = str(uuid.uuid4())
payload = {
"studentId": user_id,
"tokensUsed": total_usage_units,
"requestId": external_request_id,
"token": auth_token,
"name": student_name,
"subject": subject,
"topic": topic
}
# Send usage to external API
external_api_url = os.getenv("BACKEND_USAGE_URL")
mask_token = f"{auth_token[:10]}...{auth_token[-10:]}" if auth_token else "NONE"
<http://logger.info|logger.info>(f":outbox_tray: Reporting usage to external API: {external_api_url}")
<http://logger.info|logger.info>(f":package: Payload: studentId={user_id}, tokens={total_usage_units}, token={mask_token}")
# Set up headers with authorization
headers = {"Content-Type": "application/json"}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
else:
logger.warning(":warning: No auth token available for external API reporting")
async with aiohttp.ClientSession() as session:
try:
async with <http://session.post|session.post>(
external_api_url,
json=payload,
headers=headers,
timeout=10
) as response:
if response.status in [200, 201]:
<http://logger.info|logger.info>(f":white_check_mark: Successfully reported usage to external API. Status: {response.status}")
else:
resp_text = await response.text()
logger.warning(f":warning: External API returned error status {response.status}: {resp_text}")
<http://logger.info|logger.info>(f":bulb: Hint: If 403, check if the token/subjectId match an active allocation on the server.")
except Exception as api_err:
logger.error(f":x: Failed to reach external API: {api_err}")
# Keep existing MongoDB storage as backup/local log
store_token_usage(
student_id=user_id,
tokens=total_usage_units,
request_id=external_request_id,
model_name="voice-agent-combined",
subject_id=subject_id,
session_type="voice_tutoring"
)
except Exception as e:
logger.error(f":x: Error in token usage reporting: {e}", exc_info=True)
async def save_conversation_on_shutdown():
try:
await agent.save_chat_history(agent.chat_ctx)
<http://logger.info|logger.info>(f"Conversation saved successfully for user: {user_id}")
except Exception as e:
logger.error(f"Error saving conversation: {str(e)}")
async def delete_room():
try:
async with api.LiveKitAPI(
os.getenv("LIVEKIT_URL"),
os.getenv("LIVEKIT_API_KEY"),
os.getenv("LIVEKIT_API_SECRET")
) as api_client:
await api_client.room.delete_room(api.DeleteRoomRequest(
room=ctx.room.name,
))
<http://logger.info|logger.info>("Room deleted successfully")
except Exception as e:
logger.error(f"Error deleting room: {str(e)}")
ctx.add_shutdown_callback(save_conversation_on_shutdown)
ctx.add_shutdown_callback(log_usage)
ctx.add_shutdown_callback(delete_room)
# Handle data channel messages for interruptions
def on_data_received(data: rtc.DataPacket):
async def _process_data():
try:
if data.topic == "lk-rpc-request":
payload = json.loads(data.data.decode('utf-8'))
if payload.get("method") == "start_turn":
<http://logger.info|logger.info>("Interrupting agent via data channel")
session.interrupt()
await ctx.room.local_participant.publish_data(
json.dumps({"status": "interrupted"}).encode(),
topic="agent_control"
)
except Exception as e:
logger.error(f"Data handling error: {str(e)}", exc_info=True)
asyncio.create_task(_process_data())
ctx.room.on("data_received", on_data_received)
if name == "main":
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint, prewarm_fnc=prewarm))tall-airline-16756
02/27/2026, 10:10 AM// Example: record microphone using MediaRecorder and send chunks to server
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); // or 'audio/wav' via processing
const sessionId = 'session-123'; // generate or get from server
const userId = 'user-abc';
let partIndex = 0;
recorder.ondataavailable = async (e) => {
if (!e.data || e.data.size === 0) return;
const form = new FormData();
form.append('audio', e.data, `chunk-${partIndex}.webm`);
form.append('session_id', sessionId);
form.append('user_id', userId);
form.append('part_index', partIndex);
form.append('timestamp', new Date().toISOString());
// If you have a client-side transcript for this chunk, include it:
// form.append('transcript', transcriptText);
await fetch('/api/upload_audio_chunk', {
method: 'POST',
body: form,
credentials: 'include'
});
partIndex += 1;
};
recorder.start(5000); // timeslice: get dataavailable every 5s
// stop later: recorder.stop()
If you use LiveKit audio tracks rather than local mic, either:
• Use the browser MediaStream from getUserMedia if participants supply mic audio, or
• Use the LiveKit API to capture a remote/local track into an AudioContext and then record via ScriptProcessor/AudioWorklet and encode to WAV/PCM (more code, but feasible).
Transcription on the client:
• If you want transcripts produced on the client, use a browser-based STT (if available), or send audio chunks to your server which runs STT (recommended if you already have server STT).
• Alternatively, you can send audio chunks to your server and let the agent’s stt=openai.STT(model="whisper-1") handle transcription server-side; server returns transcript and saves both audio+transcript to DB.
Python server endpoint (FastAPI + PyMongo + GridFS) — conceptual
• This endpoint accepts uploaded audio chunks (multipart) and optional transcript text, saves audio to GridFS, and stores metadata + transcript in a collection.
from fastapi import FastAPI, File, UploadFile, Form
from pymongo import MongoClient
import gridfs
import datetime
import uuid
app = FastAPI()
mongo = MongoClient("<mongodb://localhost:27017/>")
db = mongo["voice_conversations"]
fs = gridfs.GridFSBucket(db)
transcripts_col = db["transcripts"]
@app.post("/api/upload_audio_chunk")
async def upload_audio_chunk(
session_id: str = Form(...),
user_id: str = Form(...),
part_index: int = Form(...),
timestamp: str = Form(...),
transcript: str = Form(None),
audio: UploadFile = File(...)
):
# read bytes (streaming is better for large files)
audio_bytes = await audio.read()
# store audio in GridFS
filename = f"{session_id}_{user_id}_part{part_index}_{uuid.uuid4().hex}.webm"
upload_stream = fs.open_upload_stream(filename, metadata={
"session_id": session_id,
"user_id": user_id,
"part_index": part_index,
"timestamp": timestamp
})
upload_stream.write(audio_bytes)
upload_stream.close()
file_id = upload_stream._file_id
# store transcript metadata and link to audio file
doc = {
"session_id": session_id,
"user_id": user_id,
"part_index": part_index,
"timestamp": datetime.datetime.fromisoformat(timestamp),
"transcript": transcript,
"audio_file_id": file_id,
"created_at": datetime.datetime.utcnow()
}
transcripts_col.insert_one(doc)
return {"ok": True, "file_id": str(file_id)}
Notes on audio format and encoding
• Browser MediaRecorder often gives WebM/Opus chunks (good for size). You can store those as-is in GridFS and later decode if needed.
• If you prefer WAV or MP3 for compatibility, server-side can transcode (ffmpeg) after upload.
• Keep chunk sizes small (5–30s) to simplify uploads, fault tolerance, and fast transcription.
Transcription options
• Client-side STT: quicker to prototype but lower accuracy and limited models.
• Server-side STT (recommended): send audio chunks to your agent backend (which you’re already running). The agent can run your configured STT (e.g., Whisper through OpenAI STT), produce a transcript, and then call the same MongoDB save with transcript+audio reference. This keeps transcription consistent and centralized.
• Correlate transcripts and audio with the same session_id, part_index, and timestamps.
How to wire this into your existing Python agent code
• You already create and start AgentSession with stt=openai.STT(model="whisper-1"). A robust pattern:
a. Client uploads audio chunks to your FastAPI endpoint.
b. Your endpoint enqueues chunk processing (e.g., Celery or an asyncio task).
c. A server worker uses your openai.STT implementation to transcribe the chunk, then saves transcript + GridFS file id to MongoDB.
d. Optionally, push the transcript to the room or agent via LiveKit data messages so the agent reacts in near real-time.
Example integration idea (in your job flow):
• When participant joins, generate session_id and send it in token/metadata to client.
• Client records and uploads chunks with that session_id.
• Server stores audio + transcript; if needed, agent code (your entrypoint) can query MongoDB for transcripts to build context or continue conversation.
Why agent/backend is often preferable (brief)
• You already have STT configured server-side and session context — easy to pair audio+transcript.
• Better security and consistent transcript quality.
• Avoid browser compatibility and reliability issues.
• You can stream audio straight from tracks on the agent side later for production.
Next step and one quick question
• Do you want a full client+server example that:
◦ Records in the browser (MediaRecorder + LiveKit hint),
◦ Posts chunks to a FastAPI endpoint which then calls your STT and stores results in GridFS + transcripts collection?
• Also: Is your client plain JS or a framework (React/Next)? That lets me tailor the client code snippet. Sources: Speech-to-text (STT) models overview | LiveKit Documentation | Transcriber | LiveKit Documentation | External data and RAG | LiveKit Documentationaverage-australia-24900
02/27/2026, 10:11 AMfrom fastapi import FastAPI, File, UploadFile, Form
from pymongo import MongoClient
import gridfs
import datetime
import uuid
app = FastAPI()
mongo = MongoClient("<mongodb://localhost:27017/>")
db = mongo["voice_conversations"]
fs = gridfs.GridFSBucket(db)
transcripts_col = db["transcripts"]
@app.post("/api/upload_audio_chunk")
async def upload_audio_chunk(
session_id: str = Form(...),
user_id: str = Form(...),
part_index: int = Form(...),
timestamp: str = Form(...),
transcript: str = Form(None),
audio: UploadFile = File(...)
):
# read bytes (streaming is better for large files)
audio_bytes = await audio.read()
# store audio in GridFS
filename = f"{session_id}_{user_id}_part{part_index}_{uuid.uuid4().hex}.webm"
upload_stream = fs.open_upload_stream(filename, metadata={
"session_id": session_id,
"user_id": user_id,
"part_index": part_index,
"timestamp": timestamp
})
upload_stream.write(audio_bytes)
upload_stream.close()
file_id = upload_stream._file_id
# store transcript metadata and link to audio file
doc = {
"session_id": session_id,
"user_id": user_id,
"part_index": part_index,
"timestamp": datetime.datetime.fromisoformat(timestamp),
"transcript": transcript,
"audio_file_id": file_id,
"created_at": datetime.datetime.utcnow()
}
transcripts_col.insert_one(doc)
return {"ok": True, "file_id": str(file_id)}tall-airline-16756
02/27/2026, 10:12 AM// client.js (plain JS)
async function startRecording(sessionId, userId) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
let partIndex = 0;
recorder.ondataavailable = async (e) => {
if (!e.data || e.data.size === 0) return;
const form = new FormData();
form.append('session_id', sessionId);
form.append('user_id', userId);
form.append('part_index', partIndex);
form.append('timestamp', new Date().toISOString());
form.append('audio', e.data, `chunk-${partIndex}.webm`);
// optional: include interim transcript if you have one
await fetch('/api/upload_audio_chunk', {
method: 'POST',
body: form,
credentials: 'include'
});
partIndex += 1;
};
recorder.start(5000); // 5s chunks
return () => recorder.stop(); // returns stop function
}
If you want to record a remote LiveKit audio track instead of the local mic, capture the MediaStream from the LiveKit track or route the track to an AudioContext and record the stream similarly (I can provide details if you need that).
B) FastAPI endpoint (complete, with enqueue) This expands your endpoint so it writes a pending job doc that a worker will transcribe. It returns immediately so client uploads are fast.
# server.py
from fastapi import FastAPI, File, UploadFile, Form
from pymongo import MongoClient
import gridfs
import datetime
import uuid
import asyncio
app = FastAPI()
mongo = MongoClient("<mongodb://localhost:27017/>")
db = mongo["voice_conversations"]
fs = gridfs.GridFSBucket(db)
transcripts_col = db["transcripts"]
jobs_col = db["stt_jobs"] # pending transcription jobs
@app.post("/api/upload_audio_chunk")
async def upload_audio_chunk(
session_id: str = Form(...),
user_id: str = Form(...),
part_index: int = Form(...),
timestamp: str = Form(...),
audio: UploadFile = File(...),
):
# Stream read (for larger files you might read in chunks)
audio_bytes = await audio.read()
filename = f"{session_id}_{user_id}_part{part_index}_{uuid.uuid4().hex}.webm"
upload_stream = fs.open_upload_stream(filename, metadata={
"session_id": session_id,
"user_id": user_id,
"part_index": int(part_index),
"timestamp": timestamp
})
upload_stream.write(audio_bytes)
upload_stream.close()
file_id = upload_stream._file_id
# Insert a pending STT job
job = {
"session_id": session_id,
"user_id": user_id,
"part_index": int(part_index),
"audio_file_id": file_id,
"status": "pending",
"created_at": datetime.datetime.utcnow()
}
jobs_col.insert_one(job)
return {"ok": True, "job_id": str(job["_id"]), "file_id": str(file_id)}
C) Transcription worker (can run alongside your agent) This example runs as an asyncio background task inside your agent process or as a separate worker. It polls pending jobs, downloads audio bytes from GridFS, calls your STT (OpenAI Whisper wrapper), stores transcript, and marks job done. Replace run_stt_on_audio_bytes with your existing STT call.
import time
import threading
from pymongo import ReturnDocument
def run_stt_on_audio_bytes(audio_bytes: bytes) -> str:
# Call your existing STT wrapper, e.g., openai.STT
# For example: session.stt.transcribe_bytes(audio_bytes)
# This function must return the transcript text.
# Placeholder:
return "transcribed text here"
def stt_worker(poll_interval=1.0):
while True:
job = jobs_col.find_one_and_update(
{"status": "pending"},
{"$set": {"status": "processing", "started_at": datetime.datetime.utcnow()}},
sort=[("created_at", 1)],
return_document=ReturnDocument.AFTER
)
if not job:
time.sleep(poll_interval)
continue
try:
# Fetch audio bytes from GridFS
bucket = fs
stream = bucket.open_download_stream(job["audio_file_id"])
audio_bytes = stream.read()
stream.close()
# Run STT (use your configured OpenAI Whisper or other)
transcript_text = run_stt_on_audio_bytes(audio_bytes)
# Store transcript with metadata
transcripts_col.insert_one({
"session_id": job["session_id"],
"user_id": job["user_id"],
"part_index": job["part_index"],
"audio_file_id": job["audio_file_id"],
"transcript": transcript_text,
"created_at": datetime.datetime.utcnow()
})
# Mark job done
jobs_col.update_one({"_id": job["_id"]}, {"$set": {"status": "done", "finished_at": datetime.datetime.utcnow()}})
except Exception as e:
jobs_col.update_one({"_id": job["_id"]}, {"$set": {"status": "error", "error": str(e)}})
# Option: start worker in a background thread/process when your server starts
thread = threading.Thread(target=stt_worker, daemon=True)
thread.start()
D) Integrate with your AgentSession (use transcripts in-session) You can either:
• Let the agent poll the transcripts collection for new entries with the same session_id and part_index, or
• Use MongoDB change streams to get real-time notifications. Below is a simple polling pattern to pull new transcripts and feed them into your WAECExamAgent.
Add this into your entrypoint after session.start():
async def pull_transcripts_and_feed(agent, session_id, seen=set()):
while True:
cursor = transcripts_col.find({"session_id": session_id}).sort("created_at", 1)
for doc in cursor:
key = (doc["user_id"], doc["part_index"], doc["created_at"])
if key in seen:
continue
seen.add(key)
transcript_text = doc["transcript"]
# Now feed to agent: the exact API depends on your agent, example:
await agent.process_user_transcript(transcript_text, metadata={
"user_id": doc["user_id"],
"part_index": doc["part_index"],
"audio_file_id": doc["audio_file_id"]
})
await asyncio.sleep(1.0)
# start background task
asyncio.create_task(pull_transcripts_and_feed(agent, session_id=ctx.room.name))
Alternatively use MongoDB change streams if your Mongo deployment supports it; that delivers low-latency notifications.
E) How agent can save audio+transcript together You already save chat history in your job shutdown code. When a transcript is created (see C), transcripts_col includes audio_file_id. Your agent can query transcripts_col for the session and link audio and transcript when saving conversation summaries.
Production notes and best practices
• Use GridFS for files >16MB. Chunking (5–30s) will avoid huge singleaverage-australia-24900
02/27/2026, 10:17 AMtall-airline-16756
02/27/2026, 10:17 AMaverage-australia-24900
02/27/2026, 10:17 AM