billowy-twilight-56224
04/14/2026, 1:39 PMtall-airline-16756
04/14/2026, 1:39 PMtall-airline-16756
04/14/2026, 1:40 PMuser_last_spoke_time).
◦ If silence exceeds a threshold (commonly ~3–7 seconds), treat the call as voicemail/answering machine and take action (hang up, play a prompt, record, etc.).
◦ Run a background monitor task that checks elapsed silence and calls your room disconnect/flow logic when threshold is reached.
Example concept (pseudo):
SILENCE_THRESHOLD = 5 # seconds
user_last_spoke_time = time.time()
@agent.on("user_started_speaking")
def on_user_started_speaking(_msg):
user_last_spoke_time = time.time()
# background monitor
while in_call:
if time.time() - user_last_spoke_time > SILENCE_THRESHOLD:
handle_answering_machine()
1. LLM / speech‑content detection (optional, for better accuracy)
◦ Transcribe the early portion of the call.
◦ Use an LLM or classifier to detect voicemail prompts or phrases (e.g., “leave a message”, beep, recorded greeting).
◦ Combine with silence detection to reduce false positives (e.g., only mark voicemail if both long silence and greeting-like text are present).
◦ Expect ~70–80% accuracy depending on model and training; test with your call corpus.
Practical notes
• Tune your silence threshold to your use case and test with real calls. Outbound robo‑calls and human greetings differ in timing.
• Combining silence + content analysis reduces false positives.
• Implement actions you want after detection: disconnect, playback, record, escalate to human agent, or retry later.
If you want, tell me:
• whether you prefer a minimal silence‑only solution or want to combine LLM transcription, and
• what action you want to take when voicemail is detected (hang up, record, play message, escalate).
I’ll provide a concrete code example or a step‑by‑step plan for your chosen approach.
Sources: How to detect and handle silence during calls | Agents Framework | livekit/livekit_composite | DeepWiki, with interactive elementsbillowy-twilight-56224
04/14/2026, 1:42 PMtall-airline-16756
04/14/2026, 1:43 PMlast_spoke_time and triggers a short‑timeout action (e.g., hold, play prompt, hang up) if silence > threshold.
• Async analyzer: simultaneously capture a short initial audio buffer (e.g., first 5–15s) and stream it to a transcription service. When transcription is ready, send text to the LLM classifier to detect voicemail/answering‑machine cues.
• Decision reconciler: when LLM result returns, merge with silence decision:
◦ If real‑time silence already triggered an action, allow a cancel/override if LLM indicates a human (to avoid false positives).
◦ If silence triggered nothing yet, LLM can proactively mark voicemail and perform the chosen action.
Implementation notes / best practices
• Keep immediate path minimal:
◦ Use VAD or last‑spoken timestamp and a short threshold (3–7s) for quick action.
◦ Do not block on transcription or LLM calls in the media thread.
• Capture audio non‑intrusively:
◦ Duplicate the incoming audio stream to a short circular buffer (5–15s). This buffer feeds the async transcription without affecting live forwarding.
◦ Use WebRTC/LiveKit APIs to subscribe to the participant’s audio and copy frames to the buffer in a separate worker/goroutine/task.
• Transcription/LLM pipeline:
◦ Stream audio to a low‑latency speech‑to‑text service (or run a local model) to reduce turnaround.
◦ Send the earliest transcription segment(s) to the LLM classifier rather than waiting for full audio.
◦ Keep the LLM prompt focused and small—binary classify “voicemail vs human” with confidence score.
• Use confidence thresholds and hysteresis:
◦ Require both long silence and a voicemail classification (or allow one to suffice depending on risk tolerance).
◦ If LLM confidence is low, prefer to keep the call open or play a clarifying prompt.
• Actions and UX:
◦ For false positive safety, prefer non‑destructive actions first (play a short prompt asking user to press a key) before hanging up.
◦ If you auto‑hang, allow a small grace period where LLM can override.
• Performance and cost:
◦ Batch or stream transcriptions to reduce latency and cost.
◦ Cache LLM decisions for similar greeting patterns in your call set.
Concrete flow (step sequence)
1. On call connect, start VAD and start filling a 10s circular audio buffer.
2. If now - last_spoke_time > SILENCE_THRESHOLD (3–7s), take the quick action (e.g., mark probable voicemail and play a prompt or hang up).
3. Meanwhile, stream the buffered audio to STT; as soon as the first segment is ready, call the LLM classifier.
4. When LLM result returns:
◦ If it indicates human and you already took a destructive action, cancel/undo if possible (e.g., rejoin or alert agent).
◦ Otherwise, apply/confirm the voicemail decision (log, metrics, or hang up).
1. Record decisions and confidence for tuning.
If you’d like, I can:
• Provide a sample code sketch for capturing audio from LiveKit and populating a circular buffer.
• Show an example LLM prompt for voicemail classification and thresholds to use. Which would you prefer first?, with interactive elementsbillowy-twilight-56224
04/14/2026, 1:59 PMtall-airline-16756
04/14/2026, 1:59 PMimport time
import threading
import collections
import numpy as np
import soundfile as sf # pip install soundfile for WAV encoding
# Placeholder for your STT/LLM client
# from my_stt_client import async_transcribe
# from my_llm_client import classify_voicemail
SAMPLE_RATE = 48000
CHANNELS = 1
BUFFER_SECONDS = 12
SILENCE_THRESHOLD = 5.0 # seconds
# Circular buffer storing PCM int16 frames
class CircularAudioBuffer:
def __init__(self, max_seconds, sample_rate):
self.max_samples = int(max_seconds * sample_rate)
self.buf = collections.deque(maxlen=self.max_samples)
self.lock = threading.Lock()
def write_frames(self, samples: np.ndarray):
with self.lock:
self.buf.extend(samples.tolist())
def read_all(self):
with self.lock:
data = np.array(self.buf, dtype=np.int16)
return data
def clear(self):
with self.lock:
self.buf.clear()
# Global state for example
audio_buffer = CircularAudioBuffer(BUFFER_SECONDS, SAMPLE_RATE)
last_spoke_time = time.time()
in_call = True
# This function should be called by the LiveKit audio callback whenever you receive audio frames
def on_audio_frame(pcm_int16_bytes: bytes):
# Convert raw bytes to int16 numpy array (mono)
samples = np.frombuffer(pcm_int16_bytes, dtype=np.int16)
audio_buffer.write_frames(samples)
# Update last spoke time using simple energy check (fast VAD)
# Compute RMS energy quickly
rms = np.sqrt(np.mean(samples.astype(np.float32) ** 2) + 1e-8)
ENERGY_THRESHOLD = 500 # tune this for your audio level
global last_spoke_time
if rms > ENERGY_THRESHOLD:
last_spoke_time = time.time()
# Lightweight real-time monitor (non-blocking)
def silence_monitor_loop():
global in_call
while in_call:
now = time.time()
if now - last_spoke_time > SILENCE_THRESHOLD:
# Quick real-time action: mark probable voicemail (non-destructive by default)
print("[silence_monitor] Silence threshold exceeded — probable voicemail. Trigger quick action.")
# Example quick action: play prompt or flag; do NOT block here.
# You can spawn a task to confirm with LLM result or to perform destructive action.
# After triggering once, wait longer to avoid repeated triggers
time.sleep(2)
time.sleep(0.1)
# Async worker: periodically send buffered audio to STT then LLM classifier
def async_analysis_worker():
global in_call
while in_call:
# Wait a short time to collect initial audio (e.g., every 2s)
time.sleep(2.0)
wav_samples = audio_buffer.read_all()
if wav_samples.size == 0:
continue
# Encode to WAV in-memory for STT if needed
wav_bytes = None
with sf.SoundFile('temp.wav', mode='w', samplerate=SAMPLE_RATE, channels=CHANNELS, subtype='PCM_16') as f:
f.write(wav_samples.reshape(-1, CHANNELS))
with open('temp.wav', 'rb') as f:
wav_bytes = f.read()
# Kick off async transcription (non-blocking). Replace with your async client.
# transcription = await async_transcribe(wav_bytes)
# For sketch: simulate
transcription = fake_transcribe(wav_bytes)
# Call LLM classifier (non-blocking)
# llm_result = await classify_voicemail(transcription)
llm_result = fake_classify(transcription)
# Decide: combine silence and llm_result
now = time.time()
silence = (now - last_spoke_time) > SILENCE_THRESHOLD
if llm_result['prob_voicemail'] > 0.85 or (silence and llm_result['prob_voicemail'] > 0.6):
print("[async_analysis] Confirmed voicemail (confidence=%s). Taking action." % llm_result['prob_voicemail'])
# Take your action (e.g., hang up, record message). Destructive actions should consider a short grace period to allow override.
else:
print("[async_analysis] Likely human (confidence=%s). No hangup." % llm_result['prob_voicemail'])
# Optionally clear buffer after successful analysis to save memory
# audio_buffer.clear()
# Fake helper functions for sketch
def fake_transcribe(wav_bytes):
# return simulated transcription text quickly
return "Hello, you have reached the voicemail. Leave a message after the beep."
def fake_classify(transcription_text):
# very basic keyword check to simulate
keywords = ["leave a message", "beep", "voicemail"]
score = 0.0
for kw in keywords:
if kw in transcription_text.lower():
score += 0.5
score = min(score, 0.99)
return {"prob_voicemail": score}
# Start threads (in your server init)
monitor_thread = threading.Thread(target=silence_monitor_loop, daemon=True)
analyze_thread = threading.Thread(target=async_analysis_worker, daemon=True)
monitor_thread.start()
analyze_thread.start()
# Example: simulate incoming audio frames from LiveKit
def simulate_incoming_audio():
# Generate silence frames then a greeting
silence_frames = (np.zeros(int(SAMPLE_RATE * 6), dtype=np.int16)).tobytes()
greeting_frames = (np.random.randint(-1000, 1000, size=int(SAMPLE_RATE * 3)).astype(np.int16)).tobytes()
on_audio_frame(silence_frames)
time.sleep(1)
on_audio_frame(greeting_frames)
# Call simulate_incoming_audio() manually in tests
Notes and tuning tips
• Energy threshold and sample rate must match your audio source—tune ENERGY_THRESHOLD.
• Use an efficient in-memory WAV encoder or raw PCM streaming to your STT to reduce disk I/O; the example writes a temp file for simplicity.
• Use streaming STT to reduce latency: send the buffer progressively and call the LLM as soon as the first partial transcript arrives.
• Keep the immediate media callback minimal: just write frames and update last_spoke_time. All heavier work must be in background threads or async tasks.
• For production, use robust thread-safe ring buffers and avoid disk writes.
If you want, I can:
• Convert this sketch to Go (server SDK).
• Replace the fake STT/LLM with code for a recommended low‑latency STT provider and a sample LLM prompt for classification. Which would you like next?, with interactive elementsbillowy-twilight-56224
04/14/2026, 2:01 PMtall-airline-16756
04/14/2026, 2:02 PMlk.transcription). Interruption settings do not prevent transcription from being produced.
How to integrate (concise)
1. Register a text stream handler for lk.transcription in your room/agent.
2. Consume interim messages as they arrive, and replace them with the final message when lk.transcription_final is true.
3. Use those early interim transcripts for LLM classification while continuing silence detection in the media path. Do not block on final transcripts — accept partial results to avoid latency.
Minimal TypeScript sketch
// register once when your agent/room starts
room.registerTextStreamHandler('lk.transcription', async (reader, participantInfo) => {
// read messages as they arrive (includes interim and final)
for await (const msg of reader) {
const text = msg.data; // transcription text chunk
const isFinal = reader.info.attributes['lk.transcription_final'] === 'true';
// Forward interim text immediately to your async LLM classifier (non-blocking)
processTranscriptionChunk(participantInfo.participantSid, text, isFinal);
}
});
async function processTranscriptionChunk(participantSid: string, text: string, isFinal: boolean) {
// 1) Feed partial transcript to an LLM classifier for quick voicemail/human scoring
// 2) Merge LLM score with silence detector state (last_spoke_time)
// 3) If classifier strongly indicates voicemail, take your action (or flag for confirm)
// Note: keep this non-blocking so you don't delay live responses
}
Design tips to avoid added latency
• Use interim transcripts immediately; do not wait for final.
• Run transcription→LLM classification asynchronously off the media thread.
• Combine quick VAD/silence logic (3–7s) with incremental LLM scores; require a confidence threshold to act destructively.
• If you need guaranteed “from the very first word”, subscribe before connecting the participant so you start receiving interim chunks immediately at call start.
If you want, I can:
• Provide a Go sketch or a concrete LLM prompt for voicemail classification.
• Show how to merge interim transcript confidence with your silence monitor. Which would you like next? Sources: Sequential Pipeline Architecture for Voice Agents | LiveKit | Text and transcriptions | LiveKit Documentation | Turns overview | LiveKit Documentation, with interactive elementsbillowy-twilight-56224
04/14/2026, 2:06 PMtranscription (instead of audio stream) be a god decision?tall-airline-16756
04/14/2026, 2:06 PMlk.transcription is a good decision. It gives fast, low‑latency signals from VAD plus semantic evidence from STT/LLM without needing to process raw audio in your pipeline.
Why this is a good approach
• Low latency: VAD provides immediate signals (last_spoke_time) for quick decisions (3–7s) so you can act without waiting for STT.
• Richer accuracy: lk.transcription supplies interim transcripts from the very start so you can run lightweight LLM/classification on text to confirm voicemail patterns and reduce false positives.
• Lighter pipeline: Processing text is typically cheaper and simpler than streaming raw audio to STT/LLM yourself, and avoids heavy media processing on the critical media path.
• Non-blocking: You can make real‑time actions from VAD and let the transcription/LLM run asynchronously to confirm or override.
Trade-offs and pitfalls
• STT errors: Interim transcripts can be noisy; use confidence thresholds and prefer conservative destructive actions.
• Timing mismatch: Interim transcripts arrive after some audio; ensure your logic tolerates a small lag (use VAD for immediate decisions).
• Partial context: Short transcripts may be ambiguous—combine text score with silence and require combined confidence for hangups.
• Cost/latency of LLMs: Use small targeted prompts or classifiers for low latency; stream partial transcripts to the LLM as they arrive.
Practical recommendations
• Keep the media callback minimal: only update last_spoke_time (VAD) and register lk.transcription handler to receive interim/final text.
• Use interim transcripts immediately and call LLM/classifier asynchronously (don’t block the room thread).
• Use combined rules, for example:
◦ Quick flag if silence > 5s.
◦ Confirm voicemail if (silence AND transcription_score > 0.6) OR (transcription_score > 0.9).
• Prefer non‑destructive first actions (play prompt, pause, record) and only hang up when confidence is high.
• Tune thresholds with real call samples and log decisions to iterate.
• If you need near‑real‑time text, subscribe to lk.transcription before connecting the participant so interim chunks start from the first audio.
Tiny code outline (concept)
1. VAD updates last_spoke_time in audio callback.
2. Register lk.transcription handler; forward interim text to async classifier.
3. Decision logic (async):
◦ silence = now - last_spoke_time > SILENCE_THRESHOLD
◦ text_score = classifier(interim_text)
◦ if text_score high OR (silence AND moderate_text_score) → take action
If you want, I can produce:
• A Go or TypeScript snippet that wires lk.transcription + VAD together with the exact decision thresholds you prefer. Which language?, with interactive elements