steep-balloon-41261
12/18/2025, 2:00 PMtall-airline-16756
12/18/2025, 2:00 PMtall-airline-16756
12/18/2025, 2:01 PMtall-airline-16756
12/18/2025, 2:07 PMAudioStream from the remote track.
2. Capture agent TTS audio by intercepting your agent's TTS node (emit frames to a local buffer/sink).
3. Real-time mix frames (ensure same sample rate/channels), scale to avoid clipping, and align by timestamp.
4. Push mixed frames into Soniox's streaming API and use speaker diarization to ignore agent segments.
Minimal Python pattern (adapt to your app structure)
• Use numpy for PCM mixing.
• Use a small ring buffer for agent frames to align with incoming user frames.
• Prefer 16-bit PCM with same sample rate (e.g., 48kHz mono) for both sources.
Example pattern (concise):
import asyncio
import numpy as np
from livekit import rtc
from livekit.plugins import soniox
# Shared buffer for agent frames (thread-safe)
agent_frames = asyncio.Queue()
# In your agent TTS node, push frames to agent_frames
async def tts_node_capture(base_stream):
async for frame in base_stream:
await agent_frames.put(frame) # capture for mixing
yield frame # still publish to room
async def handle_user_track(track: rtc.RemoteAudioTrack):
audio_stream = rtc.AudioStream(track)
stt = soniox.STT()
stt_stream = stt.stream()
async def stt_consume():
async for evt in stt_stream:
if evt.is_final:
print("FINAL:", evt.alternatives[0].text)
else:
print("INTERIM:", evt.alternatives[0].text)
asyncio.create_task(stt_consume())
async for event in audio_stream: # event.frame is rtc.AudioFrame
user_frame = event.frame
try:
agent_frame = agent_frames.get_nowait()
except asyncio.QueueEmpty:
agent_frame = None
if agent_frame and frames_compatible(user_frame, agent_frame):
user_data = np.frombuffer(user_frame.data, dtype=np.int16).astype(np.int32)
agent_data = np.frombuffer(agent_frame.data, dtype=np.int16).astype(np.int32)
# Ensure same length: trim or pad
n = min(len(user_data), len(agent_data))
mixed = (user_data[:n] // 2) + (agent_data[:n] // 2)
mixed = np.clip(mixed, -32768, 32767).astype(np.int16)
mixed_frame = rtc.AudioFrame(
data=mixed.tobytes(),
sample_rate=user_frame.sample_rate,
num_channels=user_frame.num_channels,
samples_per_channel=n // user_frame.num_channels,
)
stt_stream.push_frame(mixed_frame)
else:
# No agent audio currently, send user frame
stt_stream.push_frame(user_frame)
def frames_compatible(a: rtc.AudioFrame, b: rtc.AudioFrame) -> bool:
return a.sample_rate == b.sample_rate and a.num_channels == b.num_channels
Key practical points
• Synchronization: For best results align frames by timestamp; simple queueing works if TTS frames are emitted close to real-time.
• Resampling: If sample rates differ, resample before mixing.
• Avoid feedback: Route synthesized agent audio directly into the mixer (don’t re-capture from speaker output).
• Level control: Scale sources (e.g., 0.5 each) to avoid clipping and favor user intelligibility.
• Latency: Keep buffers small (one or two frames) to minimize added latency.
• Soniox: Use the Soniox streaming interface (stt.stream().push_frame(...)) and rely on diarization to filter agent segments.
If you want, I can:
• Provide a complete runnable example that wires into your agent entrypoint and TTS node.
• Show a timestamp-aligned mixing variant or a resampling helper.
Which of those would help most?
Sources: Soniox STT plugin guide | LiveKit Docs | Processing raw media tracks | LiveKit docs | Pipeline nodes and hooks | LiveKit docstall-airline-16756
12/18/2025, 2:10 PMtts_node and push frames into an asyncio Queue.
• Subscribe to user audio via track_subscribed and mix user frames with queued agent frames.
• Push mixed frames to Soniox streaming via stt.stream().push_frame(...).
• Keep mixing simple (equal gain, clip) and keep buffers small to reduce latency.
Example (quickstart-style):
import asyncio
import numpy as np
from livekit import rtc, cli
from livekit.agents import Agent, WorkerOptions
from livekit.plugins import soniox
# Small queue to hold recent agent TTS frames for mixing
AGENT_FRAMES_Q = asyncio.Queue(maxsize=100)
# Helper: check compatibility and simple mixer
def frames_compatible(a: rtc.AudioFrame, b: rtc.AudioFrame) -> bool:
return a.sample_rate == b.sample_rate and a.num_channels == b.num_channels
def mix_pcm_frames(user_frame: rtc.AudioFrame, agent_frame: rtc.AudioFrame) -> rtc.AudioFrame:
user = np.frombuffer(user_frame.data, dtype=np.int16).astype(np.int32)
agent = np.frombuffer(agent_frame.data, dtype=np.int16).astype(np.int32)
# Align lengths
n = min(len(user), len(agent))
mixed = (user[:n] // 2) + (agent[:n] // 2)
mixed = np.clip(mixed, -32768, 32767).astype(np.int16)
return rtc.AudioFrame(
data=mixed.tobytes(),
sample_rate=user_frame.sample_rate,
num_channels=user_frame.num_channels,
samples_per_channel=n // user_frame.num_channels,
)
# Agent class override to capture TTS output
class MyAgent(Agent):
async def tts_node(self, text_async, model_settings):
# Call default TTS node to get audio frames
base_stream = await super().tts_node(text_async, model_settings)
async for frame in base_stream:
# push a copy into buffer for mixing
try:
AGENT_FRAMES_Q.put_nowait(frame)
except asyncio.QueueFull:
# drop oldest if full
try:
AGENT_FRAMES_Q.get_nowait()
AGENT_FRAMES_Q.put_nowait(frame)
except asyncio.QueueEmpty:
pass
yield frame # still let frame continue to room publish
async def run_agent_entrypoint():
stt = soniox.STT()
stt_stream = stt.stream()
async def stt_consumer():
async for evt in stt_stream:
if evt.is_final:
print("SONIOX FINAL:", evt.alternatives[0].text)
else:
print("SONIOX INTERIM:", evt.alternatives[0].text)
asyncio.create_task(stt_consumer())
# Connect and subscribe to tracks
async def on_track_subscribed(track: rtc.Track, publication, participant):
if track.kind != rtc.TrackKind.KIND_AUDIO:
return
audio_stream = rtc.AudioStream(track)
async for evt in audio_stream:
user_frame = evt.frame
# Try to get a recent agent frame to mix
try:
agent_frame = AGENT_FRAMES_Q.get_nowait()
except asyncio.QueueEmpty:
agent_frame = None
if agent_frame and frames_compatible(user_frame, agent_frame):
mixed = mix_pcm_frames(user_frame, agent_frame)
stt_stream.push_frame(mixed)
else:
# No agent context available, push user frame alone
stt_stream.push_frame(user_frame)
# Register room-level handler (Room object available in quickstart env)
rtc.Room.on("track_subscribed", on_track_subscribed)
# Keep running
await asyncio.Event().wait()
if __name__ == "__main__":
# Run agent worker: adapt WorkerOptions to your quickstart setup
opts = WorkerOptions(agent_class=MyAgent, entrypoint=run_agent_entrypoint)
cli.run_app(opts)
Notes and tweaks to consider
• Timestamp alignment: This example uses a simple queue; for overlapping or precise alignment, align by frame timestamps.
• Resampling: If agent TTS uses different sample rate or channels, resample before mixing.
• Gain control: Adjust weights (e.g., 0.7 user / 0.3 agent) to prioritize user audio.
• Feedback: Ensure agent audio is routed directly into the queue (not captured from speakers) to avoid echo.
• Latency: Keep AGENT_FRAMES_Q small and process frames immediately to minimize added delay.
If you'd like, I can:
• Produce a runnable example wired to your exact quickstart repo structure.
• Replace the queue with timestamp-based alignment code or add resampling helpers.
Which would you prefer?tall-airline-16756
12/18/2025, 2:40 PMstt.stream().push_frame(...).
• Keeps buffers small for low latency and performs simple PCM mixing with clipping protection.
Code (AgentSession pattern)
import asyncio
import numpy as np
from livekit import rtc, cli
from livekit.agents import AgentSession, WorkerOptions, Agent
from livekit.plugins import soniox
class SessionAgent(Agent):
# Agent class unchanged; session logic handled in AgentSession below
pass
class MyAgentSession(AgentSession):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# session-local FIFO for agent TTS frames (small to keep latency low)
self.agent_frames_q = asyncio.Queue(maxsize=8)
self.stt = soniox.STT()
self.stt_stream = None
async def start(self):
# Start STT streaming consumer
self.stt_stream = self.stt.stream()
asyncio.create_task(self._consume_stt_events())
# Register track subscription handler
self.room.on("track_subscribed", self._on_track_subscribed)
# continue normal session startup
await super().start()
async def _consume_stt_events(self):
async for evt in self.stt_stream:
if evt.is_final:
print("SONIOX FINAL:", evt.alternatives[0].text)
else:
print("SONIOX INTERIM:", evt.alternatives[0].text)
# Override/extend TTS output at session level to capture agent audio
async def tts_node(self, text_async, model_settings):
# Get base TTS stream from default node
base_stream = await super().tts_node(text_async, model_settings)
async for frame in base_stream:
# push frame into session queue for mixing (drop oldest if full)
try:
self.agent_frames_q.put_nowait(frame)
except asyncio.QueueFull:
try:
_ = self.agent_frames_q.get_nowait()
self.agent_frames_q.put_nowait(frame)
except asyncio.QueueEmpty:
pass
# still yield frame to be published by the agent into the room
yield frame
async def _on_track_subscribed(self, track, publication, participant):
if track.kind != rtc.TrackKind.KIND_AUDIO:
return
audio_stream = rtc.AudioStream(track)
async for evt in audio_stream:
user_frame = evt.frame
# try to get the most recent agent frame (non-blocking)
try:
agent_frame = self.agent_frames_q.get_nowait()
except asyncio.QueueEmpty:
agent_frame = None
# If available and compatible, mix; else forward user frame alone
if agent_frame and self._frames_compatible(user_frame, agent_frame):
mixed = self._mix_frames(user_frame, agent_frame)
self.stt_stream.push_frame(mixed)
else:
self.stt_stream.push_frame(user_frame)
# Helpers
def _frames_compatible(self, a: rtc.AudioFrame, b: rtc.AudioFrame) -> bool:
return a.sample_rate == b.sample_rate and a.num_channels == b.num_channels
def _mix_frames(self, user_frame: rtc.AudioFrame, agent_frame: rtc.AudioFrame) -> rtc.AudioFrame:
u = np.frombuffer(user_frame.data, dtype=np.int16).astype(np.int32)
v = np.frombuffer(agent_frame.data, dtype=np.int16).astype(np.int32)
n = min(len(u), len(v))
mixed = (u[:n] // 2) + (v[:n] // 2)
mixed = np.clip(mixed, -32768, 32767).astype(np.int16)
return rtc.AudioFrame(
data=mixed.tobytes(),
sample_rate=user_frame.sample_rate,
num_channels=user_frame.num_channels,
samples_per_channel=n // user_frame.num_channels,
)
async def agent_entrypoint():
# Called when worker starts; create session lifecycle managed by Fast Agent worker
# AgentSession creation is managed by the framework; this function can wait
await asyncio.Event().wait()
if __name__ == "__main__":
opts = WorkerOptions(agent_class=SessionAgent, session_class=MyAgentSession, entrypoint=agent_entrypoint)
cli.run_app(opts)
Practical notes
• Alignment: This uses a simple queue to match agent frames to the next user frame. For precise sync, align by timestamps.
• Sample rates: Ensure TTS and incoming audio share sample rate/channels; resample if needed.
• Gains: Adjust mixing weights to favor user audio for clarity (e.g., 0.7 user / 0.3 agent).
• Feedback: Ensure TTS frames are injected directly (not re-captured from speaker output) to avoid echo loops.
• Latency: Keep queue small (1–4 frames) to minimize added delay.
Would you like a timestamp-aligned mixing variant or a runnable repo-style example wired to your quickstart files?tall-airline-16756
12/18/2025, 2:43 PMtts_node) and push frames into a session-local buffer/queue.
2. When mixing user frames, pull agent frames from that buffer and mix them with user frames before pushing to Soniox.
3. Ensure the agent frames you inject are the same format (sample rate, channels, bitdepth) as user frames; resample/convert if necessary.
4. Do not route agent audio to system speakers or any capture loop that the microphone can hear. If you must play audio locally for debugging, mute playback or use virtual audio devices isolated from microphone capture.
5. Use short buffers and timestamp alignment to avoid duplicating agent audio or mixing stale frames.
AgentSession pattern (focused snippet)
# inside MyAgentSession (simplified)
# session-local queue for TTS frames (injected at source)
self.agent_frames_q = asyncio.Queue(maxsize=8)
# override tts_node to capture frames
async def tts_node(self, text_async, model_settings):
base = await super().tts_node(text_async, model_settings)
async for frame in base:
# Inject frame into the session queue (direct source capture)
try:
self.agent_frames_q.put_nowait(frame)
except asyncio.QueueFull:
# drop oldest to keep latency low
try:
_ = self.agent_frames_q.get_nowait()
self.agent_frames_q.put_nowait(frame)
except asyncio.QueueEmpty:
pass
yield frame # still publish to the room
# mixing loop for incoming user frames
async for evt in rtc.AudioStream(user_track):
user_frame = evt.frame
agent_frame = None
try:
agent_frame = self.agent_frames_q.get_nowait()
except asyncio.QueueEmpty:
pass
if agent_frame and frames_compatible(user_frame, agent_frame):
mixed = mix_frames(user_frame, agent_frame) # mix PCM directly
stt_stream.push_frame(mixed)
else:
stt_stream.push_frame(user_frame)
Practical safeguards to prevent feedback
• Do not play TTS to the same machine microphone path. If you need local monitoring:
◦ Play TTS on a separate device/community test setup that is not routed into the capture pipeline.
◦ Use an isolated virtual audio output that is not captured by the mic.
◦ Use Push-to-Monitor or muted playback in production.
• If your deployment mixes agent audio server-side (preferred), keep TTS generation and mixing entirely on the server or agent process so client microphones never capture it.
• Add a short-integer session ID or metadata to agent frames if you need to recognize and drop re-captured agent audio (defensive measure).
• Use voice activity detection (VAD) and a small grace window to avoid sending short re-captured snippets if they do appear.
Troubleshooting tips
• If you still see echoes in STT, check whether the client publishes system audio or stereo-mixes. Disable “Stereo Mix” or similar on client machines.
• Log timestamps and sample counts for both sources to confirm alignment and detect stale frames being mixed.
• Temporarily mute agent playback locally; if echoes disappear, the feedback path is via local playback.
If you want, I can:
• Provide a timestamp-aligned mixing example (matching and trimming frames by timestamp).
• Show a small resampling helper if TTS uses a different sample rate.
Which would you prefer?narrow-nail-41580
12/18/2025, 6:29 PMnarrow-nail-41580
12/18/2025, 6:30 PMtall-airline-16756
12/18/2025, 6:32 PMtrack_published / local_track_published, mix agent + user frames, and push the mixed frames into the current STT stream.
Notes before you use it
• This example assumes 48 kHz, mono 16-bit PCM for both sources. If your TTS or user audio uses different formats, add resampling/conversion.
• Keep queues small to limit latency and avoid feedback by capturing TTS frames at the source (do not re-capture playback).
• The example uses a simple equal-weight mix and non-blocking queueing; for production, align by timestamps.
Example (AgentSession + TTS node + track events + mixing -> STT)
import asyncio
import numpy as np
from livekit import rtc, cli
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions
from livekit.plugins import soniox # or your STT plugin
SAMPLE_RATE = 48000
CHANNELS = 1
PCM_DTYPE = np.int16
class MyAgent(Agent):
# Keep agent logic minimal; TTS is handled by session override below
pass
class MyAgentSession(AgentSession):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# small queue to hold recent agent TTS frames (captured at source)
self.agent_frames_q = asyncio.Queue(maxsize=8)
# create STT stream instance on session start
self._stt_stream = None
async def start(self, *args, **kwargs):
# create plugin STT stream (replace with your STT init if different)
stt_plugin = soniox.STT() # or inference.STT(...)
self._stt_stream = stt_plugin.stream()
# consume STT events (interim/final)
asyncio.create_task(self._consume_stt_events())
# register room-level event handlers
self.room.on("track_published", self._on_track_published)
self.room.on("local_track_published", self._on_local_track_published)
self.room.on("track_subscribed", self._on_track_subscribed)
await super().start(*args, **kwargs)
async def _consume_stt_events(self):
async for evt in self._stt_stream:
if evt.is_final:
print("STT FINAL:", evt.alternatives[0].text)
else:
print("STT INTERIM:", evt.alternatives[0].text)
# Override tts_node to capture agent TTS frames at the source
async def tts_node(self, text_async, model_settings):
# Use default TTS node to get audio frames to play to participants
base_stream = await Agent.default.tts_node(self, text_async, model_settings)
async for frame in base_stream:
# capture frame for mixing (non-blocking; drop oldest if full)
try:
self.agent_frames_q.put_nowait(frame)
except asyncio.QueueFull:
try:
_ = self.agent_frames_q.get_nowait()
self.agent_frames_q.put_nowait(frame)
except asyncio.QueueEmpty:
pass
# still yield so session publishes this audio to the room
yield frame
# When any participant publishes a track
def _on_track_published(self, publication: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant):
if publication.kind == rtc.TrackKind.KIND_AUDIO:
# auto-subscribe to audio tracks (user mics)
asyncio.create_task(publication.track.subscribe())
# When local (agent) track is published
def _on_local_track_published(self, publication: rtc.LocalTrackPublication, track: rtc.LocalAudioTrack):
print("Local agent track published:", publication.sid, track.name)
# You may subscribe to it here if you want to process it separately.
# We already capture TTS frames directly via tts_node, so no need to re-capture.
# When we subscribe to a remote audio track (user mic), process incoming frames
def _on_track_subscribed(self, track: rtc.RemoteAudioTrack, *args):
if track.kind != rtc.TrackKind.KIND_AUDIO:
return
stream = rtc.AudioStream(track)
asyncio.create_task(self._process_user_stream(stream))
async def _process_user_stream(self, stream: rtc.AudioStream):
async for evt in stream:
user_frame = evt.frame
# Try to get an agent frame to mix (non-blocking)
try:
agent_frame = self.agent_frames_q.get_nowait()
except asyncio.QueueEmpty:
agent_frame = None
if agent_frame and self._frames_compatible(user_frame, agent_frame):
mixed = self._mix_frames(user_frame, agent_frame)
self._stt_stream.push_frame(mixed)
else:
# No agent context available; send user frame alone
self._stt_stream.push_frame(user_frame)
# Helpers
def _frames_compatible(self, a: rtc.AudioFrame, b: rtc.AudioFrame) -> bool:
return a.sample_rate == b.sample_rate and a.num_channels == b.num_channels
def _mix_frames(self, user_frame: rtc.AudioFrame, agent_frame: rtc.AudioFrame) -> rtc.AudioFrame:
u = np.frombuffer(user_frame.data, dtype=PCM_DTYPE).astype(np.int32)
v = np.frombuffer(agent_frame.data, dtype=PCM_DTYPE).astype(np.int32)
n = min(len(u), len(v))
mixed = (u[:n] // 2) + (v[:n] // 2)
mixed = np.clip(mixed, -32768, 32767).astype(PCM_DTYPE)
return rtc.AudioFrame(
data=mixed.tobytes(),
sample_rate=user_frame.sample_rate,
num_channels=user_frame.num_channels,
samples_per_channel=(n // user_frame.num_channels),
)
# Entrypoint for worker
async def entrypoint(ctx: JobContext):
session = MyAgentSession(
stt=soniox.STT(), # session needs a valid STT plugin; kept for completeness
llm=None,
tts=None,
)
await session.start(agent=MyAgent(), room=ctx.room)
await ctx.connect()
await asyncio.Event().wait()
if __name__ == "__main__":
opts = WorkerOptions(agent_class=MyAgent, session_class=MyAgentSession, entrypoint=entrypoint)
cli.run_app(opts)
If you want next:
• I can provide a timestamp-aligned mixer (match by frame timestamps).
• I can add resampling helpers if your TTS uses a different sample rate.
Which would you like?
Sources: Pipeline nodes and hooks | LiveKit docs | Models | LiveKit Docs | Camera & microphone | LiveKit docs | TTS Metrics | LiveKit docs | Repeater | LiveKit docsnarrow-nail-41580
12/18/2025, 6:34 PMtall-airline-16756
12/18/2025, 6:35 PMstt_plugin.stream() creates a new STT stream and does not return the internal stream AgentSession uses. AgentSession does not expose its internal STT stream. You can’t push frames into that internal stream directly.
What you can do (recommended options)
• Capture the agent TTS audio at the source by overriding tts_node, publish it as a LocalAudioTrack (or buffer frames), and either:
◦ Mix agent frames with user frames and send the mixed audio to a separate STT stream you manage (so the STT sees both sides and diarization can filter agent segments), or
◦ Let AgentSession continue using its internal STT for user transcription and separately send agent frames to your external STT/mixer for joint-context models.
• Use session events (user_input_transcribed) to get the session’s transcription results rather than trying to reuse the internal stream object.
Concise Python pattern (AgentSession + tts_node + mixing -> external STT)
# key idea: capture TTS frames in tts_node, queue them, subscribe to user audio,
# mix frames and push to your own STT stream (do NOT try to access AgentSession's internal stream)
import asyncio, numpy as np
from livekit import rtc
from livekit.agents import Agent, AgentSession
from livekit.plugins import soniox # external STT plugin
class MyAgent(Agent): pass
class MySession(AgentSession):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.agent_q = asyncio.Queue(maxsize=8)
self.external_stt = soniox.STT()
self.external_stt_stream = self.external_stt.stream() # your separate STT stream
async def tts_node(self, text_async, model_settings):
base = await Agent.default.tts_node(self, text_async, model_settings)
async for frame in base:
# capture agent frame (non-blocking)
try:
self.agent_q.put_nowait(frame)
except asyncio.QueueFull:
_ = self.agent_q.get_nowait()
self.agent_q.put_nowait(frame)
yield frame # still let session publish to room
def _on_track_subscribed(self, track: rtc.RemoteAudioTrack, *args):
if track.kind != rtc.TrackKind.KIND_AUDIO:
return
asyncio.create_task(self._handle_user_audio(rtc.AudioStream(track)))
async def _handle_user_audio(self, stream: rtc.AudioStream):
async for ev in stream:
user = ev.frame
try:
agent = self.agent_q.get_nowait()
except asyncio.QueueEmpty:
agent = None
if agent and user.sample_rate == agent.sample_rate and user.num_channels == agent.num_channels:
u = np.frombuffer(user.data, dtype=np.int16).astype(np.int32)
v = np.frombuffer(agent.data, dtype=np.int16).astype(np.int32)
n = min(len(u), len(v))
mixed = (u[:n]//2 + v[:n]//2).clip(-32768,32767).astype(np.int16)
mixed_frame = rtc.AudioFrame(
data=mixed.tobytes(),
sample_rate=user.sample_rate,
num_channels=user.num_channels,
samples_per_channel=n//user.num_channels
)
self.external_stt_stream.push_frame(mixed_frame)
else:
self.external_stt_stream.push_frame(user)
When to use which approach
• Low-latency joint context for STT: mix and send to your own STT stream as shown.
• Simpler: keep AgentSession’s internal STT for user transcription and separately send agent frames to an external STT or logging store; use diarization on the external STT to ignore agent segments.
If you want, I can:
• Provide a timestamp-aligned mixing variant.
• Add resampling helpers if TTS/user audio sample rates differ.
Which one should I show next?
Sources: Camera & microphone | LiveKit docs | Text-to-speech (TTS) models | LiveKit Docs | Speech-to-text (STT) models | LiveKit Docs | Transcriber | LiveKit docs | Server-side | livekit/livekit_composite | DeepWiki