enough-dentist-75383
12/15/2025, 7:16 AMtall-airline-16756
12/15/2025, 7:16 AMtall-airline-16756
12/15/2025, 7:16 AMenough-dentist-75383
12/15/2025, 7:19 AMlastHumanMessageTextRef to track last human message text
◦ Added duplicate check comparing against all previous messages
• Result: Partially fixed duplicates, but removed valid short responses
2. Simplified duplicate check (exact previous message only)
• Approach: Only check the immediately preceding message
• Changes:
◦ Changed duplicate check to only compare prev[prev.length - 1]
◦ Applied to both human and bot messages
◦ Removed history search
• Result: Fixed removal of short responses, but continuous speech still created new messages
3. Standardized BOT_MESSAGE_GAP_THRESHOLD usage
• Approach: Use the same 2000ms threshold for both human and bot
• Changes:
◦ Removed local BOT_MESSAGE_GAP_THRESHOLD = 1000 inside setTranscripts
◦ Used global BOT_MESSAGE_GAP_THRESHOLD = 2000 everywhere
• Result: Consistent threshold, but human messages still split incorrectly
4. Fixed lastHumanMessageTimeRef update strategy
• Approach: Only update time ref when creating a new message, not when appending
• Changes:
◦ lastHumanMessageTimeRef.current only updated in else (new message) branch
◦ Not updated when appending to preserve original creation time
• Result: Still created new messages for continuous speech
5. Added explicit null check
• Approach: Added lastHumanMessageTimeRef.current !== null check
• Changes:
◦ Added explicit null check in shouldAppend condition
◦ Added debug logging
• Result: No improvement; issue persisted
6. Introduced isProcessingBotMessagesRef guard
• Approach: Prevent duplicate useEffect runs (React Strict Mode)
• Changes:
◦ Added isProcessingBotMessagesRef to guard bot message processing
◦ Wrapped bot message processing in guard check
• Result: User reverted; not the root cause
7. Moved duplicate check after shouldAppend logic
• Approach: Only run duplicate check when about to create a new message
• Changes:
◦ Moved duplicate check from beginning to after shouldAppend evaluation
• Result: User reverted; didn't solve the issue
8. Deep dive and debug logging
• Approach: Added logging to trace decision-making
• Changes:
• Added [HUMAN MSG DEBUG] logs showing:
▪︎ lastMessage, isLastMessageHuman, timeRef, timeSinceLast, shouldAppend
◦ Reason for creating new message (noLastMessage, lastIsNotHuman, timeRefNull, gapExceeded)
• Result: Identified isLastHuman: false as the issue
9. Find last human message in array (first attempt)
• Approach: Search backwards to find the last human message, even if bot messages come after
• Changes:
◦ Added loop to find lastHumanMessageIndex by searching backwards
◦ Append to that human message if found
• Result: User clarified requirement: only append if bot hasn't spoken in between
10. Check if last message is human (bot hasn't interrupted)
• Approach: Only append if the last message in the array is human
• Changes:
◦ Reverted to checking prev[prev.length - 1] is human
◦ Logic: isLastMessageHuman && gap < threshold
• Result: Still created new messages due to gap calculation issue
11. Track last update time instead of creation time (final fix)
• Approach: Track when we last received a transcript update, not when message was created
• Changes:
◦ Added lastHumanUpdateTimeRef to track last received update time
◦ Changed gap calculation from currentTime - lastHumanMessageTimeRef.current to currentTime - lastHumanUpdateTimeRef.current
◦ Update lastHumanUpdateTimeRef on every transcript update (both append and create)
• Result: Fixed continuous speech detection, but user then refactored to unified approach
12. User's unified refactor (current state)
• Approach: Unified tracking system for both human and bot
• Changes:
• Replaced individual refs with unified objects:
▪︎ lastMessageTimeRef = { human: null, bot: null } (creation time)
◦ lastUpdateTimeRef = { human: null, bot: null } (update time)
◦ Created shouldAppendToLastMessage() helper function
◦ Added useUpdateTime parameter to distinguish real-time vs batch processing
◦ Added 5-second debounce timer for human messages
• Result: Current implementation (user's refactor)
Final root cause analysis (RCA)
Root cause: LiveKit SDK sets isFinal = true mid-speech.
• Location: LiveKitProvider.ts line 68
•
•
• c*`onst`*
• isFinal = <http://reader.info?.attributes?.['lk.transcription_final']|reader.info?.attributes?.['lk.transcription_final']> === 'true';
• Issue: LiveKit marks segments as final during continuous human speech, causing:
◦ onTranscriptUpdate to receive isFinal: true mid-speech
◦ The code to treat these as complete messages
◦ New message entries being created instead of appending
• Impact: This is not a logic issue in our code; it's how LiveKit sends transcript updates.
Why this matters:
• Our logic correctly handles continuous speech when isFinal: false
• When LiveKit sends isFinal: true mid-speech, it triggers new message creation
• The 5-second debounce timer (Approach #12) helps, but the underlying issue is LiveKit's isFinal flag
Potential solutions:
1. Ignore isFinal for human messages and use time-based batching
2. Buffer human messages and only finalize after a pause (e.g., 2+ seconds)
3. Use LiveKit's transcription attributes to better detect true finality
4. Keep the debounce timer but adjust the logic to handle mid-speech isFinal: true
5.
6. To implement potential solutions - Tried implementing 1 + 2 - Failedtall-airline-16756
12/15/2025, 7:19 AMisFinal can be set mid-speech, so treating isFinal === true as “message complete” causes the splits you see. Below are concise, practical options you can apply to keep continuous human speech in one message, plus a minimal reference implementation you can adapt.
Acknowledgement
• You correctly identified the root cause: LiveKit marks segments final based on STT pauses, not semantic turn completion.
• Your unified refactor (separate lastMessageTimeRef and lastUpdateTimeRef + debounce) is the right direction.
Practical solutions (pick one or combine)
• Time-based batching (recommended): Ignore isFinal for aggregation; append transcripts while updates arrive within a short gap (1.0–2.0s), and finalize when silence exceeds that gap.
• Use last-update time (not creation time): Track the timestamp of the last transcript update for that speaker and compare gap against threshold.
• Debounce finalization: Use a debounce (e.g., 1.5–2s) to send the final UI message only after no new updates arrive for that speaker.
• Use EOU/metrics when available: If agent metrics or EOUMetrics are available, finalize on a true end-of-utterance event instead of time-only logic.
• Combine segment IDs: If transcripts carry `segment_id`/`transcribed_track_id`, group consecutive updates with the same segment id and still use debounce for robustness.
Minimal implementation sketch (TypeScript/React style) 1) Refs and constants
const HUMAN_GAP_MS = 1500;
const lastUpdateTimeRef = useRef<{ human: number | null, bot: number | null }>({ human: null, bot: null });
const bufferRef = useRef<{ human: string, bot: string }>({ human: '', bot: '' });
let finalizeTimer: number | null = null;
2) onTranscriptUpdate handler
function onTranscriptUpdate({ speaker, text, segment_id }: { speaker: 'human'|'bot', text: string, segment_id?: string }) {
const now = Date.now();
const lastUpdate = lastUpdateTimeRef.current[speaker];
const gap = lastUpdate ? now - lastUpdate : Infinity;
// Append if within gap
if (gap < HUMAN_GAP_MS && bufferRef.current[speaker]) {
bufferRef.current[speaker] += ' ' + text;
} else {
// Commit previous buffer if it exists
if (bufferRef.current[speaker]) commitFinalMessage(speaker, bufferRef.current[speaker]);
bufferRef.current[speaker] = text;
}
lastUpdateTimeRef.current[speaker] = now;
// Debounce finalization
if (finalizeTimer) clearTimeout(finalizeTimer);
finalizeTimer = window.setTimeout(() => {
if (bufferRef.current[speaker]) {
commitFinalMessage(speaker, bufferRef.current[speaker]);
bufferRef.current[speaker] = '';
lastUpdateTimeRef.current[speaker] = null;
}
}, HUMAN_GAP_MS);
}
3) commitFinalMessage: push to UI/state as one message. Log segment_id when present for debugging.
Notes and hardening
• Use separate timers/refs per speaker to avoid cross-speaker interference.
• If you need near-real-time UI updates, emit interim appends to the current last message while still deferring the “final” marker via debounce.
• Reduce false splits by updating lastUpdateTimeRef on every update (append OR create). That fixes the common pitfall of using creation time only.
• If LiveKit supplies segment_id, use it to group consecutive interim+final updates for the same segment before relying on timeouts.
• If EOUMetrics become available in your pipeline, prefer them for true finalization and cancel debounce.
Next step Would you like a full copy-pasteable module (with per-speaker timers and test hooks) matching your unified-refactor pattern so you can drop it into your codebase?
Sources: Text and transcriptions | LiveKit docs | Observability data hooks | LiveKit docs | Improving voice AI&#39;s turn detection with transformersenough-dentist-75383
12/15/2025, 7:21 AMtall-airline-16756
12/15/2025, 7:22 AMisFinal for aggregation, track per-speaker last update time (not creation time), and finalize a message only after a short inactivity debounce (1.5–2s). If EOUMetrics (true end-of-utterance) are available, prefer them to finalize immediately. This approach prevents mid-speech isFinal:true splits while keeping UI responsive.
Why this is best
• LiveKit’s isFinal can be set mid-speech; time-based batching decouples UI finalization from STT segment finalization.
• Using last-update time avoids splitting when a message is appended vs when it’s created.
• Per-speaker timers prevent cross-speaker interference.
• EOUMetrics (when available) give definitive turn completion and should override the debounce.
Copy-pasteable reference implementation (TypeScript / React style)
• One module you can drop in. It:
◦ Tracks per-speaker buffers and last update times
◦ Appends interim text to the last shown message for live UI
◦ Finalizes (commits) after inactivity gap
◦ Optionally finalizes immediately if eou metric arrives
// TranscriptAggregator.ts
import { useRef, useEffect } from 'react';
type Speaker = 'human' | 'bot';
type TranscriptUpdate = { speaker: Speaker; text: string; segment_id?: string; isFinal?: boolean };
type CommitFn = (speaker: Speaker, text: string, meta?: { segment_id?: string }) => void;
type Options = { gapMs?: number; useEou?: boolean; commit: CommitFn };
export function useTranscriptAggregator({ gapMs = 1500, useEou = true, commit }: Options) {
const bufferRef = useRef<Record<Speaker, string>>({ human: '', bot: '' });
const lastUpdateRef = useRef<Record<Speaker, number | null>>({ human: null, bot: null });
const timerRef = useRef<Record<Speaker, number | null>>({ human: null, bot: null });
function clearTimer(s: Speaker) {
if (timerRef.current[s]) {
window.clearTimeout(timerRef.current[s] as number);
timerRef.current[s] = null;
}
}
function scheduleFinalize(s: Speaker, segment_id?: string) {
clearTimer(s);
timerRef.current[s] = window.setTimeout(() => {
const buf = bufferRef.current[s];
if (buf) {
commit(s, buf, { segment_id });
bufferRef.current[s] = '';
lastUpdateRef.current[s] = null;
}
timerRef.current[s] = null;
}, gapMs);
}
function onTranscriptUpdate(update: TranscriptUpdate) {
const s = update.speaker;
const now = Date.now();
const last = lastUpdateRef.current[s];
const gap = last ? now - last : Infinity;
// Append if within gap; otherwise finalize previous and start new buffer
if (gap < gapMs && bufferRef.current[s]) {
bufferRef.current[s] = bufferRef.current[s] + ' ' + update.text;
} else {
if (bufferRef.current[s]) {
// commit previous buffer as final (best-effort) before starting new
commit(s, bufferRef.current[s], { segment_id: undefined });
}
bufferRef.current[s] = update.text;
}
// update last update time
lastUpdateRef.current[s] = now;
// update live UI: emit interim append (not final)
// commit can treat meta or a flag to mark interim vs final in your UI.
commit(s, bufferRef.current[s], { segment_id: update.segment_id });
// schedule debounce-based finalization
scheduleFinalize(s, update.segment_id);
}
// Optional: call this when you receive an explicit EOU metric for a speaker
function onEouReceived(s: Speaker, segment_id?: string) {
if (!useEou) return;
clearTimer(s);
const buf = bufferRef.current[s];
if (buf) {
commit(s, buf, { segment_id });
bufferRef.current[s] = '';
lastUpdateRef.current[s] = null;
}
}
// cleanup on unmount
useEffect(() => {
return () => {
(Object.keys(timerRef.current) as Speaker[]).forEach(clearTimer);
};
}, []);
return { onTranscriptUpdate, onEouReceived };
}
Integration notes
• Set gapMs 1500–2000ms. Start at 1500ms; raise to 2000ms if you still see splits in noisy networks.
• Let interim UI updates reuse the same message id so the user sees a single growing message.
• If LiveKit supplies segment_id, pass it to commit for debugging and grouping.
• If EOUMetrics are available (agent metrics with eou), call onEouReceived('human') to immediately finalize and cancel debounce.
• Keep per-speaker timers; do not use a single global timer.
If you want, I can:
• Produce a commit function example that updates React state with stable message IDs and interim vs final flags.
• Adjust the module to support streaming multiple speakers dynamically (not just human/bot).enough-dentist-75383
12/15/2025, 7:40 AMenough-dentist-75383
12/15/2025, 7:40 AMtall-airline-16756
12/15/2025, 7:40 AMisFinal:true segments from creating final messages in the UI.
• Server-side buffering + reassembly: Collect interim and final segments server-side, group them by speaker/segment_id/span, use last-update timestamps to merge segments, then emit consolidated transcripts to the client. This centralizes logic and avoids per-client inconsistencies.
• Use AI-based post-processing: Run a lightweight aggregator model (or rules + confidence thresholds) to join segments that likely belong to the same utterance (e.g., based on short gaps, punctuation heuristics, or STT confidence). The AI can also fix punctuation/casing and remove duplicates before sending a single message downstream.
• Use STT provider features server-side: Some STT providers offer word-level timing, punctuation, or stronger EOU signals. Processing those server-side gives you deterministic finalization.
When backend/AI is limited or not ideal
• Latency sensitivity: Server-side buffering adds round-trip delay; you must balance aggregation delay (1–2s) vs. UI responsiveness.
• Data flow and access: You need access to the raw transcription stream, segment IDs, timestamps, or EOU metrics. If LiveKit streams are only available client-side in your architecture, backend fixes are harder.
• Complexity & cost: Running AI post-processing or additional servers increases complexity and possibly cost.
Practical hybrid patterns (best-of-both)
• Server-side authoritative finalization + client-side interim UI:
◦ Clients display interim appends locally for responsive UI.
◦ Server sends a single “final” consolidated transcript when EOU or debounce logic fires; clients replace/merge interim messages with server final.
• Server-side EOU + client-side fallback:
◦ If server EOU is available, use it to finalize immediately.
◦ If not, clients use time-based debounce as fallback.
Implementation options
• Backend aggregator service:
◦ Ingest transcripts (interim/final) with metadata (speaker, segment_id, timestamps).
◦ Maintain per-speaker buffers and last-update times.
◦ Emit consolidated “conversation_item_added” or similar events to clients.
• AI post-processor:
◦ Run a lightweight model or heuristic pipeline that merges segments into utterances and decides finalization.
◦ Prefer rule-based + confidence thresholds first; add ML only if heuristic failure rate is high.
• Use or emit EOUMetrics:
◦ If LiveKit agent metrics expose EOU, forward those to your backend and use them as authoritative finalization triggers.
Recommendation (practical choice)
• If you can access STT/segment streams or LiveKit agent metrics on the backend: implement server-side EOU/finalization + send final transcript to clients. This is the most robust fix.
• If backend access is limited or you need low-latency interim updates: use the hybrid pattern—client shows interim, backend sends authoritative final; client replaces interim with final.
• If you must choose one quick fix: server-side buffering + 1.5–2s inactivity finalization gives consistent results with modest latency.
Do you have server-side access to LiveKit/STT streams or agent metrics (EOU)? If yes, I can outline a concise server-side design (API shape, buffering logic, event flow) you can implement.enough-dentist-75383
12/15/2025, 7:44 AMenough-dentist-75383
12/15/2025, 7:44 AMtall-airline-16756
12/15/2025, 7:44 AMisFinal === true as message-final for UI aggregation. LiveKit marks segments final mid-speech.
• Aggregate transcripts by last-update time (not creation time). Update the "last update" timestamp on every transcript event (append or create).
• Use a per-speaker inactivity debounce (1.5–2s) to finalize a message only after no new updates arrive.
• Keep interim appends live: update the same message id for interim text so the UI shows a single growing message.
• Use per-speaker timers/refs so speakers don’t interfere with each other.
• Optionally use segment_id to group related updates for debug/correlation, but still rely on debounce for finalization.
• If EOUMetrics are available in the SDK, accept them as immediate finalization and cancel the debounce.
Why on SDK
• Clients need instant interim UI updates without waiting for server round trips.
• Multiple clients may have different network conditions; local aggregation ensures consistent UX per client.
• SDK can update the visible message id in real time while deferring finalization centrally only if desired.
Compact reference (TypeScript / React pattern)
• Behavior: append interim text to a per-speaker buffer, update UI with same message id, reset per-speaker debounce timer on each update, finalize (commit) when timer fires or when EOU arrives.
Key pieces to implement: 1) Refs & constants
const GAP_MS = 1500; // 1500–2000ms
const bufferRef = useRef<{[speaker:string]: string}>({});
const lastUpdateRef = useRef<{[speaker:string]: number | null}>({});
const timerRef = useRef<{[speaker:string]: number | null}>({});
2) onTranscriptUpdate(update) — called for each LiveKit transcript event
const now = Date.now();
const s = update.speaker; // unique speaker id
const last = lastUpdateRef.current[s] ?? null;
const gap = last ? now - last : Infinity;
// Append vs new buffer
if (gap < GAP_MS && bufferRef.current[s]) {
bufferRef.current[s] += ' ' + update.text;
} else {
if (bufferRef.current[s]) commitFinal(s, bufferRef.current[s]); // optional best-effort commit
bufferRef.current[s] = update.text;
// create or reuse message id in UI so interim appends show under same message
ensureUIMessageIdForSpeaker(s);
}
lastUpdateRef.current[s] = now;
commitInterimToUI(s, bufferRef.current[s], { segment_id: update.segment_id });
// debounce finalize
clearTimeout(timerRef.current[s]);
timerRef.current[s] = window.setTimeout(() => {
commitFinal(s, bufferRef.current[s]);
bufferRef.current[s] = '';
lastUpdateRef.current[s] = null;
timerRef.current[s] = null;
}, GAP_MS);
3) onEouReceived(s) — if SDK exposes EOU/agent metrics
clearTimeout(timerRef.current[s]);
if (bufferRef.current[s]) {
commitFinal(s, bufferRef.current[s]);
bufferRef.current[s] = '';
lastUpdateRef.current[s] = null;
}
Tuning and edge cases
• Set GAP_MS = 1500 initially; increase to 2000ms if you still see premature splits in poor networks.
• Keep separate timers per speaker id (not just 'human'/'bot') if multiple concurrent humans are possible.
• Use segment_id to help diagnose but do not rely on it exclusively.
• Ensure interim commits reuse the same UI message id so the user sees a single continuous message.
If you want, I can produce:
• A drop-in React hook that manages message IDs and state updates (interim vs final).
Which would you prefer?