aloof-scooter-47164
04/21/2026, 8:00 PMtall-airline-16756
04/21/2026, 8:00 PMtall-airline-16756
04/21/2026, 8:00 PMAudioFrame) during generation. You can capture those frames and convert/assemble them into a WAV on the agent or client side (for example using AudioFrame.to_wav_bytes() or buffering frames and writing a WAV), but the model itself returns a real-time audio stream rather than a file attachment.
Would you like a short example (agent-side or client-side) showing how to capture the stream and save a WAV?
Sources: Voice Agent Architecture: STT, LLM, and TTS Pipelines Explained | LiveKit | Voice Agent Architecture: STT, LLM, and TTS Pipelines Explained | LiveKit | Build Your First AI Voice Agent in Python: Complete Tutorial | LiveKit | Pipeline nodes and hooks | LiveKit Documentation | examples/voice_agents | livekit/livekit_composite | DeepWiki, with interactive elementsaloof-scooter-47164
04/21/2026, 8:01 PMtall-airline-16756
04/21/2026, 8:02 PMaudio_frames_from_file() / audioFramesFromFile()), cache those frames, and pass them to session.say() as an AsyncIterable[rtc.AudioFrame]. You can include optional transcript text and manage playback with the returned SpeechHandle (interrupt/cancel if needed). This lets a tool play a static recording (hold message, sound effect, etc.) instead of synthesizing speech.
Would you like a short Python or Node example showing preload + playback inside a tool?
Sources: Audio customization | LiveKit Documentation | Agent speech and audio | LiveKit Documentation | Background audio | LiveKit Documentation, with interactive elementsaloof-scooter-47164
04/21/2026, 8:04 PMtall-airline-16756
04/21/2026, 8:04 PMaudio_frames_from_file() / audioFramesFromFile()), then in the tool call use session.say() with those frames as an AsyncIterable[rtc.AudioFrame]. That plays the prerecorded WAV as the tool response (you can include text, and use the returned SpeechHandle to interrupt/cancel if needed).
Would you like a short Python or Node sample of preload + tool playback?
, with interactive elementsaloof-scooter-47164
04/21/2026, 8:05 PMtall-airline-16756
04/21/2026, 8:05 PMaudio_frames_from_file() / audioFramesFromFile()).
◦ Use session.say() with those frames (or with text + audio) to play the prerecorded sound as the tool response.
◦ Run session.say() concurrently with the Realtime model’s flow (capture the returned SpeechHandle so you can interrupt/cancel playback if the model replies early).
• For true mixing/processing (volume, blending, concatenation) handle it on the agent side: either
◦ stream frames in sequence (play prerecorded frames then let model stream), or
◦ process/merge frames in a custom realtime_audio_output_node before sending to the client.
Would you like a short Python or Node example showing preload + concurrent playback + interruption?
Sources: Realtime models overview | LiveKit Documentation | Audio customization | LiveKit Documentation | Agent speech and audio | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentation | Pipeline vs. Realtime - Which is the better Voice Agent Architecture? | Liv..., with interactive elementsaloof-scooter-47164
04/28/2026, 3:08 AMtall-airline-16756
04/28/2026, 3:08 AMsession.say() can cause a tool call to timeout if you await it. Don’t await session.say() if you need the tool to continue running without blocking; instead capture the returned SpeechHandle and let playback run concurrently.
Details and guidance:
• If you await session.say(), the tool will block until playback finishes (which can trigger tool-call timeouts).
• Recommended pattern:
a. Start playback without awaiting:
• Python: handle = context.session.say(... )
• Node: const handle = context.session.say(...);
a. Continue the async work (e.g., call external APIs).
b. If the tool finishes before playback should continue, call handle.interrupt() or handle.cancel() to stop playback early.
• If you need playback to be part of the tool result (i.e., ensure it finishes before returning), then explicitly await it—but ensure your tool timeout is long enough.
Would you like a brief Python or Node snippet showing the non-blocking pattern with interruption?
, with interactive elementsaloof-scooter-47164
04/28/2026, 3:10 AMtall-airline-16756
04/28/2026, 3:10 AMaloof-scooter-47164
04/28/2026, 3:11 AMtall-airline-16756
04/28/2026, 3:11 AMfrom livekit.agents import AgentSession
from livekit import rtc
from livekit.agents.utils.audio import audio_frames_from_file
async def play_long_audio_as_speech(session: AgentSession, path: str):
# play and treat as agent speech; awaiting keeps tool blocked until finished
await session.say(
"Playing requested audio.",
audio=audio_frames_from_file(path, sample_rate=24000, num_channels=1),
allow_interruptions=True, # optional: allow user to interrupt via VAD
)
• Node
import { AgentSession } from "@livekit/agents";
import { audioFramesFromFile } from "@livekit/agents";
async function playLongAudioAsSpeech(session: AgentSession, path: string) {
await session.say("Playing requested audio.", {
audio: audioFramesFromFile(path, { sampleRate: 24000, numChannels: 1 }),
allowInterruptions: true,
});
}
Notes:
• Awaiting the returned playout ensures the agent stays in the speaking state until completion.
• Preload large files at startup into memory if you need low-latency playback.
2) Custom mixing / processing via realtime_audio_output_node (merge, chain, or alter streams before sending to client)
• Concept: write a custom realtime_audio_output_node that consumes multiple sources (realtime model frames + prerecorded frames), mixes or sequences them, and yields final `AudioFrame`s. Plug this node into your agent’s audio pipeline.
• Python (sketch)
from livekit import rtc
async def realtime_audio_output_node(model_audio_async_iter, prerecorded_iter):
# model_audio_async_iter: AsyncIterable[rtc.AudioFrame] from realtime model
# prerecorded_iter: AsyncIterable[rtc.AudioFrame] from preloaded WAV
async for model_frame in model_audio_async_iter:
# Example: yield model frames normally
yield model_frame
# After model finishes, play prerecorded frames
async for frame in prerecorded_iter:
yield frame
• More advanced mixing (overlay example)
import numpy as np
def mix_frames(frame_a: rtc.AudioFrame, frame_b: rtc.AudioFrame) -> rtc.AudioFrame:
# convert to numpy, mix, clip, convert back — pseudocode; follow actual AudioFrame API
a = frame_a.to_numpy()
b = frame_b.to_numpy()
mixed = a + b
mixed = np.clip(mixed, -1.0, 1.0)
return rtc.AudioFrame.from_numpy(mixed, sample_rate=frame_a.sample_rate, num_channels=frame_a.num_channels)
Implementation notes:
• The node must respect frame formats (sample rate, channels) — convert/decimate as needed.
• Use this node when you need sample-level control (volume, blending, seamless concatenation).
• This runs agent-side before frames are sent to the client, so the client sees a single processed audio stream (agent speaking state is preserved if you tie this into the agent’s playout logic).
If you want, I can:
• Provide a full runnable Python example that preloads a WAV, uses session.say(await) in a tool, and caches frames, or
• Expand the realtime_audio_output_node into a complete mixing example handling resampling and frame buffers.
Which follow-up would you like?
, with interactive elementsaloof-scooter-47164
04/28/2026, 3:15 AMtall-airline-16756
04/28/2026, 3:15 AMaloof-scooter-47164
04/28/2026, 3:15 AMtall-airline-16756
04/28/2026, 3:15 AMfrom livekit.agents import AgentSession
from livekit import rtc
from livekit.agents.utils.audio import audio_frames_from_file
async def tool_sequential_play(context, model_audio_async_iter, wav_path: str):
# 1) Stream model audio to client (this is your realtime model output async iterator)
# Ensure this iterator is routed into the agent playout pipeline so the client hears it.
async for frame in model_audio_async_iter:
await context.session.push_audio_frame(frame) # pseudocode: send model frames
# 2) After model finishes, play prerecorded WAV as agent speech and await completion
await context.session.say(
"Playing recorded audio now.",
audio=audio_frames_from_file(wav_path, sample_rate=24000, num_channels=1),
allow_interruptions=True,
)
Node (same sequence):
import { audioFramesFromFile } from "@livekit/agents";
async function toolSequentialPlay(context, modelAudioAsyncIter, wavPath: string) {
// 1) Send model frames sequentially
for await (const frame of modelAudioAsyncIter) {
await context.session.pushAudioFrame(frame); // pseudocode: send model frames
}
// 2) Then play prerecorded WAV and await playout
await context.session.say("Playing recorded audio now.", {
audio: audioFramesFromFile(wavPath, { sampleRate: 24000, numChannels: 1 }),
allowInterruptions: true,
});
}
Notes and key points:
• The important part is to let the model audio finish (or detect its end) before calling session.say() for the WAV.
• If model audio is produced via the agent’s normal playout pipeline, wait for that playout to complete (e.g., await its SpeechHandle or finish iterating model frames) before calling session.say().
• Use allow_interruptions and the returned SpeechHandle if you need to allow/capture user interruptions during the prerecorded playback.
• Preload/ cache WAV frames (with audio_frames_from_file / audioFramesFromFile) for low latency if files are large.
If you want, I can convert the pseudocode push_audio_frame into the exact API call for your setup (AgentSession streaming hook or realtime_audio_output_node). Which runtime (Python or Node) and how you receive model frames (agent playout, model iterator, or other) should I target?
, with interactive elementsaloof-scooter-47164
04/28/2026, 3:17 AMtall-airline-16756
04/28/2026, 3:17 AMsession.say() with the preloaded WAV and await it.
4. Optionally call session.say() with a short follow-up, or return so the agent goes back to listening.
Python (async) — sketch with concrete APIs:
from livekit.agents import AgentSession, RunContext
from livekit.agents.utils.audio import audio_frames_from_file
# Preload (run at startup)
CACHE = {}
async def preload_wav(key: str, path: str):
CACHE[key] = audio_frames_from_file(path, sample_rate=24000, num_channels=1)
# Tool that orchestrates sequential play
@function_tool()
async def play_seq(context: RunContext, wav_key: str, model_playout_handle):
session: AgentSession = context.session
# 1) Wait for realtime model playout to finish.
# model_playout_handle should be a SpeechHandle or similar you get when starting model playout.
# If you have an async iterator of model frames, await its completion or await the handle.
if model_playout_handle is not None:
await model_playout_handle # ensure model audio finished
# 2) Play fetched prerecorded WAV as agent speech (await to keep agent in speaking state)
wav_frames = CACHE[wav_key]
await session.say(
"Playing the requested audio now.", # optional transcript text
audio=wav_frames, # AsyncIterable[AudioFrame]
allow_interruptions=True,
)
# 3) Optional follow-up utterance, or simply return to go back to listening
await session.say("That clip has finished. Anything else?", allow_interruptions=True)
return {"status": "done"}
Node (async) — sketch with concrete APIs:
import { audioFramesFromFile } from "@livekit/agents";
// Preload (at startup)
const CACHE: Record<string, AsyncIterable<any>> = {};
function preloadWav(key: string, path: string) {
CACHE[key] = audioFramesFromFile(path, { sampleRate: 24000, numChannels: 1 });
}
// Tool that orchestrates sequential play
export async function playSeq(context, wavKey: string, modelPlayoutHandle) {
const session = context.session;
// 1) Wait for realtime model playout to finish
if (modelPlayoutHandle) {
await modelPlayoutHandle; // SpeechHandle from model playout
}
// 2) Play prerecorded WAV as agent speech (await to keep speaking state)
const wavFrames = CACHE[wavKey];
await session.say("Playing the requested audio now.", {
audio: wavFrames,
allowInterruptions: true,
});
// 3) Optional follow-up
await session.say("That clip has finished. Anything else?", { allowInterruptions: true });
return { status: "done" };
}
Key integration points and notes:
• How to detect model playout end:
◦ If your Realtime model playout is started via a function that returns a SpeechHandle, await that handle.
◦ If you have an async iterator of `AudioFrame`s from the model, finish iterating (or await the wrapper that pushes frames) before calling session.say().
• Always await the WAV session.say() if you want the WAV to be treated as agent speech (keeps agent in speaking state).
• Use allow_interruptions=True (or equivalent) so user speech can stop playback; capture/handle interruption if needed.
• Preload and cache large WAVs using audio_frames_from_file / audioFramesFromFile at startup to avoid fetch/decoding latency inside the tool.
• If you fetch the WAV during the tool call, fetch & convert to frames first, then await model playout, then call session.say() with those frames.
• If you want the optional follow-up to be skipped when user interrupts the WAV, check the returned SpeechHandle state or handle the interruption event before issuing the follow-up.
If you want, I can:
• Convert the sketches into a complete runnable example tied to how you currently start model playout (provide the code that starts model audio so I can show exact handle usage), or
• Show a variant that fetches the WAV from a remote URL inside the tool and caches it before playback.
Which follow-up would you like?
, with interactive elementsaloof-scooter-47164
04/28/2026, 3:18 AMtall-airline-16756
04/28/2026, 3:19 AMsession.say() with prerecorded WAV and await it if you want to know when finished, or don’t await if you want to remain non-blocking.
1. Optionally issue a short follow-up session.say() after WAV finishes (await or run as separate task).
Key APIs & behaviors to use
• session.say(audio=...) with preloaded AsyncIterable[AudioFrame] to play prerecorded WAV as agent speech.
• session.say(...) returns a SpeechHandle — use it to interrupt/cancel or to await completion.
• Don’t await long SpeechHandle inside the tool if you must avoid tool timeouts; instead manage playback asynchronously after tool returns.
• Preload via audio_frames_from_file / audioFramesFromFile at startup or during a non-critical path.
Minimal Python sketch (non-blocking tool, agent continues playback)
# run at startup
CACHE["clip"] = audio_frames_from_file("clip.wav", sample_rate=24000, num_channels=1)
@function_tool()
async def orchestrate_play(context: RunContext, wav_key: str, model_playout_handle=None):
session = context.session
# Ensure WAV is available (decoded frames cached)
wav_frames = CACHE[wav_key]
# 1) If model playout is started elsewhere and you have a handle, wait for it to finish *only if* you want server-side sequencing.
# To avoid tool timeout, do NOT await a long playout here. Instead, start a background task.
async def background_sequence():
# If you have a model SpeechHandle, await it here to ensure model audio finishes before WAV
if model_playout_handle:
await model_playout_handle
# 2) Play prerecorded WAV as agent speech (awaiting here is fine because this is background)
wav_handle = session.say("Playing clip now.", audio=wav_frames, allow_interruptions=True)
await wav_handle
# 3) Optional follow-up utterance
await session.say("That clip finished. Anything else?", allow_interruptions=True)
# 3) Signal client to stop thinking sound and switch to agent playout (send event in tool response)
# Return quickly to avoid timeout; spawn background task to perform actual long playout sequence.
context.loop.create_task(background_sequence()) # or asyncio.create_task(...)
return {"status": "started", "action": "stop_thinking_and_listen_for_agent"}
Minimal Node sketch (same idea)
// preload at startup
CACHE["clip"] = audioFramesFromFile("clip.wav", { sampleRate: 24000, numChannels: 1 });
export async function orchestratePlay(context, wavKey, modelPlayoutHandle) {
const session = context.session;
const wavFrames = CACHE[wavKey];
const backgroundSequence = async () => {
if (modelPlayoutHandle) {
await modelPlayoutHandle;
}
const wavHandle = session.say("Playing clip now.", { audio: wavFrames, allowInterruptions: true });
await wavHandle;
await session.say("That clip finished. Anything else?", { allowInterruptions: true });
};
// start background, return immediately so tool doesn't time out
void backgroundSequence();
return { status: "started", action: "stop_thinking_and_listen_for_agent" };
}
Client-side coordination
• While tool runs: play local short thinking sound.
• When tool response arrives: stop thinking sound and switch to agent audio playout (so user hears agent audio coming from server).
• Use a small event field in tool response (e.g., action: "stop_thinking_and_listen_for_agent") so client knows to stop local audio and allow server audio through.
Notes & best practices
• Preload WAV frames to avoid decoding time inside the background task.
• If you need guaranteed sequencing (model audio → WAV → follow-up) do sequencing in the background task by awaiting SpeechHandles in order.
• Use SpeechHandle interruption if user speaks during the WAV and you want to cancel follow-ups.
• Keep tool execution fast (fetch/cache then spawn background task) to prevent platform timeouts.
If you want, I can:
• Produce a complete runnable example tied to your exact runtime (how you currently start model playout and signal the client). Which runtime and client signaling mechanism are you using?, with interactive elementsaloof-scooter-47164
04/28/2026, 3:22 AMtall-airline-16756
04/28/2026, 3:22 AMsession.say() does not require a different model. You can use it alongside a Realtime model.
Details:
• Realtime models stream/generated audio frames; they don’t natively play prerecorded WAVs.
• Use the agent/session APIs together: run your Realtime model playout, then call session.say(audio=...) (with preloaded WAV frames) to play the prerecorded clip as agent speech. session.say() simply plays audio frames (or synthesizes TTS) — it’s independent of which model produced prior audio.
• For sequencing without timeouts: start the model playout, capture/await its SpeechHandle (or await the model frame iterator) in a background task, then call session.say() for the WAV and optionally follow-up speech — spawn that background task from the tool so the tool returns immediately.
If you want, I can give a minimal runnable example that shows exactly how to start model playout, capture its handle, and then call session.say() for the WAV in a background task (Python or Node). Which language?
, with interactive elementsaloof-scooter-47164
04/28/2026, 3:27 AMtall-airline-16756
04/28/2026, 3:27 AMsession.say(). session.say() is an agent/session API that plays audio frames (or synthesizes TTS) and can be used alongside a Realtime model — sequencing and playback are handled by your agent/session code, not by switching models.
Key points
• Realtime models stream generated audio frames; they don’t themselves play prerecorded WAVs or issue playback commands.
• session.say(audio=...) accepts an AsyncIterable of AudioFrame (preloaded WAV frames) and will play that audio as agent speech — independent of which model produced prior audio.
• To avoid tool timeouts and exit the client “thinking” state:
◦ Have the tool preload/fetch the WAV, spawn a background task that awaits the realtime playout SpeechHandle, then calls session.say() for the WAV and any follow-up.
◦ Return from the tool immediately with a small signal (e.g., action: "stop_thinking_and_listen_for_agent") so the client stops the local thinking sound and switches to server playout.
• session.say() returns a SpeechHandle you can await or keep in background to manage interruption/cancellation.
If you want, I’ll produce a complete runnable example tailored to your stack (Python or Node) that:
• starts realtime playout,
• returns from the tool immediately (so no timeout),
• runs the sequential background task that awaits model playout, plays the WAV via session.say(), and optionally does a follow-up.
Which language (Python or Node) and how do you currently start realtime playout (do you get a SpeechHandle or an async iterator)?
, with interactive elements