billowy-twilight-56224
12/23/2025, 6:12 AMtall-airline-16756
12/23/2025, 6:12 AMtall-airline-16756
12/23/2025, 6:12 AMlk load-test subcommand in the LiveKit CLI. It’s designed for high-concurrency agent benchmarking and is what LiveKit uses for performance tests.
Why that helps
• It’s written in Go and built for high concurrency, avoiding Python/async overhead that can limit Locust+Python SDK.
• Simulates publishers and subscribers, sends audio/video (720p simulcast loops + audio) and supports adaptive subscriber behavior.
• Scales across machines and supports large scenarios (e.g., 1 publisher + 3000 subscribers).
Practical tips
• Run lk load-test from a machine with plenty of CPU, bandwidth, and file handles (set ulimit -n 65535).
• Split load across multiple machines if needed.
• Expect that load testing against LiveKit Cloud will count toward usage quotas.
If you still prefer Locust + Python SDK, tell me:
• the specific scaling errors or bottlenecks you see (CPU, memory, connection limits, event-loop warnings, socket errors, etc.),
• your test shape (publishers/subscribers, media enabled, codecs/bitrates),
• OS and resource limits (ulimit -n, CPU, network),
and I’ll suggest targeted fixes or workarounds.
Would you like steps to run lk load-test for your scenario, or help troubleshooting your current Locust setup?
Sources: Benchmarking | LiveKit docsbillowy-twilight-56224
12/23/2025, 6:16 AMlk load-test , will I be able to send custom prerecorded audios? Or just blank packets?tall-airline-16756
12/23/2025, 6:16 AMlk load-test can send custom prerecorded audio.
It cannot. lk load-test only sends blank (non‑audible) audio packets to simulate a target bitrate.
If you need prerecorded audio in tests, I can help outline two options:
• Build a custom load tester using a LiveKit SDK (Go/JS/Python) that publishes prerecorded audio files.
• Modify or extend a client implementation to play files into a track and run that at scale (run across multiple machines or containers).
Which option would you prefer, and what language/stack do you plan to use?
Sources: Benchmarking | LiveKit docsbillowy-twilight-56224
12/23/2025, 6:17 AMtall-airline-16756
12/23/2025, 6:17 AMulimit -n 65535)?
Answer these and I’ll provide a concise Locust test script that:
• publishes prerecorded audio per user,
• shows efficient client/session handling with the LiveKit Python SDK,
• includes tuning and scaling tips for Locust and OS limits.billowy-twilight-56224
12/23/2025, 6:24 AMtall-airline-16756
12/23/2025, 6:25 AMAudioSource.
c. Waits for agent greeting (agent -> "listening") before sending first segment.
d. For each segment: wait for agent listening signal, stream WAV frames to AudioSource in 20ms chunks, then wait again.
e. Clean disconnect when segments exhausted.
Compact example
• Save as locustfile.py. Set env vars: LIVEKIT_URL, LIVEKIT_TOKEN. Place WAVs under ./audio_segments/.
• I’ll keep this concise; let me know if you want the full file pasted into chat.
Tuning & scaling tips for 100 users (one macOS machine)
• ulimit: set ulimit -n 65535.
• Event loop: install and enable uvloop for better asyncio performance.
• CPU/RAM: 8+ cores and 16GB recommended; 100 users should be feasible on a modern MacBook Pro.
• Network: ensure sufficient upload bandwidth (audio-only is light but concurrent signalling adds load).
• Logging: minimize printing inside hot loops.
• Start small (10 users), verify flow, then ramp to 100.
• If hitting limits, run locust workers across multiple Linux containers/machines.
Agent readiness detection (recommended)
• Best: agent publishes a short data message like "ready_for_input" when in listening state — Locust listens for data messages and sets an asyncio.Event.
• Fallback: consume agent audio track and set listening when audio stops (less precise).
Next steps / choices for me to act on
• I can paste a ready-to-run locustfile.py (complete code) that implements the flow above (WAV loader, AudioSource streaming in 20ms chunks, agent-ready via data message + fallback to silence).
• Or I can provide a Dockerized version and a small script to generate publish-only JWTs.
Which do you want now: the full locustfile code, or the Dockerized + token helper?
Sources: Camera & microphone | LiveKit docs | Agent session | LiveKit docsbillowy-twilight-56224
12/23/2025, 6:36 AMtall-airline-16756
12/23/2025, 6:36 AMAudioSource, and calling capture_frame to push prerecorded audio is the correct pattern for programmatic playback.
• Listening to participant attribute changes can indicate agent state, but it’s not the most robust/specific signal for turn-taking.
Recommended improvements
1. Prefer explicit agent signals
◦ Use the agent’s agent_state_changed event or have the agent publish a small data message (e.g., "ready_for_input" on a data channel) when it’s listening. This is precise and cheap to detect.
1. Fallback to audio-based detection only if needed
◦ If explicit signals aren’t available, consume the agent’s audio track and detect silence/vad to infer listening. This is less precise and more error-prone under noise/jitter.
1. Optimize audio publishing
◦ Stream frames in ~20ms chunks to AudioSource.capture_frame and match sample rate/channels (prefer 48kHz mono). Avoid huge frames or tight loops that block the event loop.
1. Concurrency & performance tips
◦ Minimize logging inside hot loops, use uvloop for better asyncio performance, and set ulimit -n high (e.g., 65535).
◦ Reuse connections/sources where possible; avoid recreating tracks per segment.
1. Error handling & timeouts
◦ Add timeouts when waiting for agent signals, and backoff/retry on capture failures to avoid hanging users.
1. Scaling note
◦ The Python SDK + Locust will work for ~100 users on a well-tuned machine. If you need higher scale, consider the Go-based lk load-test tool or distribute Locust workers across machines.
If you want, I can:
• Provide a small code snippet showing authoritative detection via a data message handler and the ideal capture_frame loop, or
• Show a minimal VAD-based fallback snippet for silence detection.
Which snippet would help you most?billowy-twilight-56224
12/23/2025, 6:37 AMtall-airline-16756
12/23/2025, 6:38 AMagent_state_changed or participant attribute lk.agent.state == "listening").
• Publish a LocalAudioTrack backed by an AudioSource.
• Stream a WAV file in 20ms chunks via AudioSource.capture_frame with an asyncio sleep to simulate real time.
Save as part of your Locust task or run inside an async function after connecting the Room. Adjust env vars, file path, and agent identity.
import asyncio
import wave
from pathlib import Path
from livekit import rtc
# assumptions: room is an already-connected rtc.Room, and local_participant available
# agent_identity is the agent's participant.identity string
# wav_path is Path to a 16-bit PCM WAV (prefer 48kHz mono or resample to 48k)
async def publish_prerecorded_when_agent_listening(room: rtc.Room, agent_identity: str, wav_path: Path):
agent_ready = asyncio.Event()
# Prefer authoritative event
@room.on("agent_state_changed")
def _on_agent_state(ev):
# ev.state expected e.g. "listening", "speaking"
if getattr(ev, "state", None) == "listening" and ev.participant.identity == agent_identity:
agent_ready.set()
# Fallback: watch participant attributes (if agent sets lk.agent.state attribute)
@room.on("participant_updated")
def _on_participant_updated(participant):
if participant.identity == agent_identity:
val = participant.get_attribute("lk.agent.state") # SDK-specific API for attributes
if val == "listening":
agent_ready.set()
# wait for agent greeting / listening
try:
await asyncio.wait_for(agent_ready.wait(), timeout=30.0)
except asyncio.TimeoutError:
raise RuntimeError("Agent did not become listening within 30s")
# create audio source and publish track (48kHz mono)
source = rtc.AudioSource(48000, 1)
track = rtc.LocalAudioTrack.create_audio_track("prerecorded_audio", source)
await room.local_participant.publish_track(track)
# stream WAV in 20ms chunks (960 samples @ 48kHz)
with wave.open(str(wav_path), "rb") as wf:
# If WAV not 48kHz/mono, convert or resample beforehand.
sample_rate = wf.getframerate()
num_channels = wf.getnchannels()
bytes_per_sample = wf.getsampwidth()
chunk_frames = 960 # 20ms @ 48kHz
while True:
frames = wf.readframes(chunk_frames)
if not frames:
break
# If WAV sample_rate != 48000 or num_channels !=1, pre-convert/resample before running test.
audio_frame = rtc.AudioFrame(
data=frames,
sample_rate=48000,
num_channels=1,
samples_per_channel=len(frames) // (bytes_per_sample * 1),
)
await source.capture_frame(audio_frame)
await asyncio.sleep(0.02) # simulate real-time 20ms pacing
# optional: signal end-of-speech (mute/unpublish)
await room.local_participant.set_microphone_muted(True)
await room.local_participant.unpublish_track(track)
Best practices reminders:
• Prefer the agent_state_changed event or an explicit data message from the agent (e.g., "ready_for_input") for reliable turn signaling.
• Use 48kHz mono PCM WAVs or resample before the test to avoid runtime resampling overhead.
• Send frames in ~20ms chunks and use await asyncio.sleep(0.02) to simulate real-time and avoid overwhelming the event loop.
• Keep logging out of the inner loop and set high ulimit -n for many concurrent sessions.
If you want, I can provide:
• a small data-message handler example (agent sends "ready_for_input"), or
• a full minimal Locust task wrapping this snippet. Which would you prefer? Sources: Agent session | LiveKit docs | Camera & microphone | LiveKit docs | Turns overview | LiveKit docs | Web and mobile frontends | LiveKit docs | Capturing the First Few Seconds of a Call for Processingbillowy-twilight-56224
12/23/2025, 10:01 AMtall-airline-16756
12/23/2025, 10:02 AMdef frames_for_ms(sample_rate: int, ms: int) -> int:
return int(sample_rate * (ms / 1000.0))
Revised loop that enforces 20ms chunks based on file metadata:
with wave.open(str(wav_path), "rb") as wf:
sr = wf.getframerate()
chans = wf.getnchannels()
sampwidth = wf.getsampwidth() # bytes per sample per channel
frames_per_chunk = frames_for_ms(sr, 20) # frames == samples per channel
bytes_per_chunk = frames_per_chunk * chans * sampwidth
while True:
data = wf.readframes(frames_per_chunk)
if not data:
break
# If sr != 48000 or chans != 1, resample/convert offline or do it here (costly)
audio_frame = rtc.AudioFrame(
data=data,
sample_rate=sr, # prefer to match AudioSource
num_channels=chans,
samples_per_channel=frames_per_chunk,
)
await source.capture_frame(audio_frame)
await asyncio.sleep(0.02) # 20ms pacing
Recommendation: pre-convert all WAVs to the AudioSource format you choose (48 kHz, mono, 16-bit) to avoid runtime resampling and ensure each captured frame equals ~20ms of audio.billowy-twilight-56224
12/23/2025, 10:06 AM@dataclass
class AudioClip:
"""Audio data from a WAV file, ready to be sent to LiveKit."""
audio_data: bytes # Raw PCM samples (16-bit signed, little-endian)
sample_rate: int
channels: int
# LiveKit frame size: 20ms at 16kHz = 320 samples = 640 bytes
FRAME_SAMPLES: int = 320
FRAME_DURATION_SEC: float = 0.02 # 20ms
@classmethod
def from_wav_file(cls, file_path: str) -> "AudioClip":
with wave.open(file_path, "rb") as wav_file:
sample_rate = wav_file.getframerate()
channels = wav_file.getnchannels()
audio_data = wav_file.readframes(wav_file.getnframes())
return cls(audio_data=audio_data, sample_rate=sample_rate, channels=channels)
@property
def frame_bytes(self) -> int:
"""Bytes per frame (320 samples * 2 bytes per sample)."""
return self.FRAME_SAMPLES * 2
@property
def total_frames(self) -> int:
"""Total number of 20ms frames in this clip."""
return len(self.audio_data) // self.frame_bytes
...
for i in range(0, len(audio_clip.audio_data), frame_bytes):
chunk = audio_clip.audio_data[i:i + frame_bytes]
if len(chunk) < frame_bytes:
break # Skip incomplete final frame
# Unpack 320 samples (16-bit signed, little-endian)
samples = struct.unpack(f"<{AudioClip.FRAME_SAMPLES}h", chunk)
samples_array = array.array("h", samples)
# Create and send frame
frame = rtc.AudioFrame.create(SAMPLE_RATE, NUM_CHANNELS, AudioClip.FRAME_SAMPLES)
frame.data[:] = samples_array
await source.capture_frame(frame)
frames_sent += 1
if frames_sent % 50 == 0:
logger.debug("Audio progress", frames_sent=frames_sent, total=total_frames)
# Real-time pacing: 20ms per frame
await asyncio.sleep(AudioClip.FRAME_DURATION_SEC)
Do you see anything wrong?tall-airline-16756
12/23/2025, 10:06 AMasyncio.sleep(0.02) which drifts):
import time
start = time.monotonic()
frames_sent = 0
for i in range(0, len(audio_clip.audio_data), frame_bytes):
chunk = audio_clip.audio_data[i:i+frame_bytes]
if len(chunk) != frame_bytes:
break
samples = struct.unpack(f"<{AudioClip.FRAME_SAMPLES}h", chunk)
frame = rtc.AudioFrame.create(16000, 1, AudioClip.FRAME_SAMPLES)
frame.data[:] = array.array("h", samples)
await source.capture_frame(frame)
frames_sent += 1
target = frames_sent * AudioClip.FRAME_DURATION_SEC
sleep = target - (time.monotonic() - start)
if sleep > 0:
await asyncio.sleep(sleep)
2) Increase AudioSource buffer to avoid “failed to capture frame” under load:
source = rtc.AudioSource(sample_rate=16000, num_channels=1, queue_size_ms=2000)
Additional checks/best practices
• Validate WAV metadata: ensure sample_rate == 16000 and channels == 1 (or pre-convert).
• Pre-load/decode clips into memory to avoid per-frame I/O.
• Skip or zero-pad the final incomplete frame (you already skip it).
• Minimize logging inside the sending loop.
• On macOS/Linux, raise ulimit -n and use uvloop for better asyncio performance when scaling to ~100 users.
If you want, I can:
• Show a tiny helper that validates/resamples WAVs before test run, or
• Paste a complete minimal Locust task that integrates this loop. Which do you prefer? Sources: Resolving &#34;InvalidState - failed to capture frame&#34; errors in AudioSource | Processing raw media tracks | LiveKit docsbillowy-twilight-56224
12/23/2025, 10:10 AMtall-airline-16756
12/23/2025, 10:10 AMawait source.capture_frame(...) returning or hanging? Add a timestamped log immediately before and after the capture_frame call.
4. Are you using AudioSource(..., queue_size_ms=...)? If not, try increasing it to 2000 ms.
5. Are your WAV files confirmed 16kHz mono 16-bit and frame-aligned (len % frame_bytes == 0)? Log sample_rate/channels/total_frames.
6. Check CPU, memory, and FD usage while a worker is stuck (top, vm_stat, lsof -p <pid>) — do you see resource exhaustion?
7. Are you running under the Locust async runner correctly (async tasks inside Locust User)? Any blocking synchronous code elsewhere?
8. Do you see repeated log messages like “failed to capture frame”, queue full, or asyncio timeouts?
Quick fixes to try now:
• Use monotonic pacing (time-based) instead of fixed asyncio.sleep loops (reduce drift).
• Increase queue_size_ms on AudioSource.
• Ensure you call await room.connect(...) and await publication success before streaming.
• Temporarily reduce concurrency to 1 user and reproduce; attach a verbose log to observe sequence.
• Add a 1–2s small sleep after publish before sending the first frame to let the pipeline warm up.
If you share the small logs from checks 1–3 and the audio file metadata (answer 5), I’ll pinpoint the exact likely cause and a precise fix.billowy-twilight-56224
12/23/2025, 10:35 AMtall-airline-16756
12/23/2025, 10:35 AMcapture_frame intermittently hangs (no errors, low CPU, queue_size_ms=1000), most likely causes are back-pressure from the AudioSource queue or an event-loop/block causing capture to wait. Try these targeted changes and checks to unblock it.
Immediate fixes to try
1. Increase buffer:
◦ Set queue_size_ms=5000 on AudioSource to give more headroom:
source = rtc.AudioSource(sample_rate=16000, num_channels=1, queue_size_ms=5000)
1. Await publish fully and warm up:
◦ After publish_track, wait a short grace period before sending first frame:
pub = await room.local_participant.publish_track(track)
await asyncio.sleep(0.5)
1. Handle back-pressure with a retry and timeout:
◦ Wrap capture_frame in a retry with a timeout so it doesn’t hang forever:
async def safe_capture(source, frame, timeout=2.0):
try:
await asyncio.wait_for(source.capture_frame(frame), timeout=timeout)
except asyncio.TimeoutError:
# backoff and retry once
await asyncio.sleep(0.05)
await asyncio.wait_for(source.capture_frame(frame), timeout=timeout)
1. Use monotonic pacing (you already do) and ensure your pacing logic yields control to the loop (avoid CPU spin).
2. Ensure single AudioSource per Locust user:
◦ Don’t share a source/track between users or reuse one source concurrently from multiple tasks.
Checks to run if it still hangs
• Add timestamped logs immediately before and after capture_frame to confirm it’s that call blocking and to measure duration.
• Temporarily increase log verbosity in the SDK to detect internal warnings.
• Run a single user (no Locust concurrency) and loop the same clip repeatedly to see if it ever hangs—if not, it’s interaction with Locust scheduling.
• Verify you’re not polling a shared variable in a tight loop without await (can starve event loop). Replace polling with an asyncio.Event set by the Room callbacks.
Recommended robust pattern
• Publish track, await publish success, sleep ~0.5s.
• For each 20ms frame:
a. build AudioFrame
b. call await asyncio.wait_for(source.capture_frame(frame), timeout=2.0)
c. if timeout, back off (sleep 50–200ms) and retry once
d. use monotonic clock pacing to schedule next send
Would you like me to:
• paste a minimal instrumented snippet that includes logging, safe_capture, and the warm-up delay so you can drop it into your locustfile; or
• review a small paste of the part that publishes the track + the capture loop (the few hundred lines you can paste here)?billowy-twilight-56224
12/23/2025, 11:28 AMtall-airline-16756
12/23/2025, 11:29 AMcapture_frame) to run in the same asyncio event loop; moving room/SDK calls across threads or different loops can cause deadlocks/hangs even with no errors.
What to do next (practical, minimal changes)
• Stop using a separate thread for the Room/AudioSource. Run all LiveKit async code in the same asyncio event loop that Locust uses.
• Use Locust's async support (async User/task). Example pattern:
◦ async def on_start(): await room.connect(...)
◦ async def task(self): await publish/stream_frames(...)
◦ use asyncio.create_task(...) only for helper coroutines within that same loop.
• Replace any cross-thread polling of shared vars with asyncio primitives: use asyncio.Event, Queue, or callbacks on the Room to set events so the sending loop waits with await event.wait() instead of busy polling.
• Keep one AudioSource per simulated user and do not share it across threads/tasks.
Why this fixes your hanging capture_frame
• capture_frame relies on the SDK's internal async queue and the same event loop for progress; threading breaks that coordination and can block capture_frame unpredictably.
Quick checklist to validate after making the change
• room.connect, publish_track, and capture_frame all execute on the same loop/thread.
• Replace polling with asyncio.Event for agent readiness.
• Add a small warm-up delay after publish (0.25–0.5s).
• Increase queue_size_ms (e.g., 2000) if you still see intermittent backpressure.
• Instrument with timestamped logs before/after capture_frame to confirm it no longer blocks.
If you want, I can:
• Paste a minimal async Locust User example that runs the Room and capture loop in the same event loop and uses asyncio.Event for agent readiness.
Which would you prefer?
Sources: Resolving &#34;InvalidState - failed to capture frame&#34; errors in AudioSource | Going beyond a single-corebillowy-twilight-56224
12/23/2025, 11:39 AMtall-airline-16756
12/23/2025, 11:39 AMcapture_frame) intermittently hang.
Two safe options
Option A — Use Locust’s AsyncUser (recommended)
• Locust supports async users via AsyncUser / FastHttpUser. Run your LiveKit async logic directly inside an async Locust user so everything runs on a single asyncio event loop managed by Locust.
• Advantages: no thread/loop boundaries, predictable scheduling, fewer subtle deadlocks.
• I can provide a minimal AsyncUser example that connects, waits for agent readiness via asyncio.Event, and streams 20ms frames with monotonic pacing.
Option B — If you must keep a separate thread, ensure strict isolation
• Create a single dedicated thread with its own asyncio loop and ensure no gevent/Locust code interacts with sockets or blocking calls used by that thread.
• Use only asyncio primitives inside that thread (no cross-thread polling). Communicate between Locust tasks and that thread via thread-safe queues (e.g., asyncio loop.call_soon_threadsafe to set events) and avoid busy polling.
• Increase AudioSource(queue_size_ms=5000), add capture timeouts and retry, and instrument logs — but this is fragile and still more error-prone than Option A.
Why your current approach likely causes intermittent hangs
• Gevent monkey patching can alter socket/IO behavior used by the SDK or underlying libraries.
• Cross-thread boundaries with an asyncio loop mean any small blocking syscall or scheduling difference can stall the SDK’s internal pump, causing capture_frame to await indefinitely.
• The intermittent nature fits an event-loop scheduling deadlock rather than pure resource exhaustion.
Recommendation
• Move to Locust’s AsyncUser and run the LiveKit flow in that async task. This will almost certainly eliminate the intermittent capture_frame hang.
• If you want, I’ll paste a concise AsyncUser locustfile now (connect → wait for agent via asyncio.Event → publish track → stream 20ms frames with monotonic pacing). Which would you prefer: the full AsyncUser snippet, or a thread‑safe adapter pattern for your current setup?