rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
d31e132
Β·
1 Parent(s): 9a1b321

feat(voice): full-duplex Live conversation mode with VAD barge-in (D-025)

Browse files

User can now speak ANY time β€” including while the bot is mid-sentence β€”
and the bot stops speaking immediately and listens. No button press.

New file: frontend/src/lib/useLiveConversation.ts
- Continuous getUserMedia stream (echo cancellation, noise suppression,
auto gain control all enabled).
- AudioContext + AnalyserNode RMS-based VAD on a requestAnimationFrame
loop. Thresholds: 28/255 byte FFT magnitude floor, 5 frames (~80ms)
of loudness to confirm speech, 40 frames (~640ms) of silence to end.
- On speech-start: pause every <audio> element (kills bot TTS mid-
sentence), abort the in-flight /api/chat fetch via AbortController,
start a MediaRecorder.
- On utterance-end: stop recorder, call onUtterance(blob, abort).
- Caller-managed inflightAbortRef so the page can register fetches
that VAD should cancel on barge-in.

api.ts: postChat() and postTranscribe() now accept an optional
AbortSignal so the live-mode handler can plumb the VAD abort signal
end-to-end. Existing PTT callers unaffected.

page.tsx: import the hook, add a Go Live button next to Voice reply +
Hands-free in the chat-input footer. Status indicator: green pulse =
listening for speech, red pulse = actively recording an utterance.
useEffect rebinds the onUtterance closure on relevant state changes
(messages, sessionId, ttsLang, view_context inputs) so live-mode turns
flow into the same pushUser β†’ postChat β†’ pushAssistant path the existing
send() uses.

Closes Issue 1 from the 2026-05-14 live-bot review.

v1 limitations (planned for v1.1):
- Conservative RMS threshold; quiet speakers may need it lowered.
Future: Silero VAD via @ricky0123 /vad-web for accuracy.
- No background-noise calibration on entry.
- TTS isn't streaming yet β€” full reply plays before next utterance
window opens. Streaming TTS makes barge-in feel even more conversational.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

frontend/src/app/page.tsx CHANGED
@@ -29,6 +29,7 @@ import {
29
  UserProfile,
30
  } from "@/lib/api";
31
  import { translate, UILang, StringKey, GLOSSARY } from "@/lib/i18n";
 
32
 
33
  type DisplayMessage = ChatMessage & {
34
  id: string;
@@ -127,6 +128,17 @@ export default function Page() {
127
  const fileInputRef = useRef<HTMLInputElement>(null);
128
  const scrollRef = useRef<HTMLDivElement>(null);
129
 
 
 
 
 
 
 
 
 
 
 
 
130
  useEffect(() => {
131
  getHealth()
132
  .then((h) => setHealth({ status: h.status, missing: h.missing_keys }))
@@ -248,6 +260,58 @@ export default function Page() {
248
  }
249
  }
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  async function startRecording() {
252
  try {
253
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -561,6 +625,25 @@ export default function Page() {
561
  <label className="flex items-center gap-1.5 cursor-pointer" title="Hands-free voice β€” auto-submits when you stop speaking">
562
  <input type="checkbox" checked={handsFree} onChange={(e) => setHandsFree(e.target.checked)} className="w-3.5 h-3.5 accent-[var(--primary)]" /> Hands-free
563
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  <label className="flex items-center gap-1.5">
565
  Lang:
566
  <select value={ttsLang} onChange={(e) => setTtsLang(e.target.value as "en-IN" | "hi-IN")} className="bg-transparent border border-[var(--border)] rounded px-1.5 py-0.5">
 
29
  UserProfile,
30
  } from "@/lib/api";
31
  import { translate, UILang, StringKey, GLOSSARY } from "@/lib/i18n";
32
+ import { useLiveConversation } from "@/lib/useLiveConversation";
33
 
34
  type DisplayMessage = ChatMessage & {
35
  id: string;
 
128
  const fileInputRef = useRef<HTMLInputElement>(null);
129
  const scrollRef = useRef<HTMLDivElement>(null);
130
 
131
+ // Live-conversation mode (full-duplex VAD barge-in). Defined further down
132
+ // in the component body so it can reference `sessionId`, `messages`, etc.
133
+ // via closure when the onUtterance handler fires. See useLiveConversation.ts.
134
+ const liveOnUtteranceRef = useRef<((blob: Blob, abort: AbortController) => Promise<void>) | null>(null);
135
+ const live = useLiveConversation({
136
+ onUtterance: async (blob, abort) => {
137
+ const fn = liveOnUtteranceRef.current;
138
+ if (fn) await fn(blob, abort);
139
+ },
140
+ });
141
+
142
  useEffect(() => {
143
  getHealth()
144
  .then((h) => setHealth({ status: h.status, missing: h.missing_keys }))
 
260
  }
261
  }
262
 
263
+ // Live-conversation onUtterance binding. Rebinds when relevant state changes
264
+ // so the latest sessionId / history / view are captured in the closure.
265
+ useEffect(() => {
266
+ liveOnUtteranceRef.current = async (blob, abort) => {
267
+ try {
268
+ const transcribed = await postTranscribe(blob, ttsLang, abort.signal);
269
+ const text = (transcribed.text || "").trim();
270
+ if (text.length < 2) return;
271
+
272
+ pushUser(text);
273
+ const history: ChatMessage[] = messages.map((m) => ({ role: m.role, content: m.content }));
274
+ const active_view: "chat" | "marketplace" | "profile" | "premium" | "policy_detail" =
275
+ openPolicy ? "policy_detail" :
276
+ showMarketplace ? "marketplace" :
277
+ showProfile ? "profile" :
278
+ showPremium ? "premium" :
279
+ "chat";
280
+
281
+ const res = await postChat({
282
+ user_text: text,
283
+ session_id: sessionId,
284
+ chat_history: history,
285
+ return_audio: true,
286
+ tts_language_code: ttsLang,
287
+ view_context: { active_view, active_policy_id: openPolicy?.policy_id },
288
+ signal: abort.signal,
289
+ });
290
+ setSessionId(res.session_id);
291
+ getProfileCompleteness(res.session_id)
292
+ .then(setProfileCompleteness)
293
+ .catch(() => {});
294
+ const audioUrl = res.audio_base64 ? audioBlobURLFromBase64(res.audio_base64) : undefined;
295
+ pushAssistant(res.reply_text, {
296
+ citations: res.citations,
297
+ audioUrl,
298
+ brain: res.brain_used,
299
+ latencyMs: res.latency_ms,
300
+ blocked: res.blocked,
301
+ });
302
+ if (audioUrl) {
303
+ const audio = new Audio(audioUrl);
304
+ audio.play().catch(() => {});
305
+ }
306
+ } catch (e: unknown) {
307
+ const name = (e as { name?: string })?.name;
308
+ if (name === "AbortError") return; // user barged in; intentional
309
+ // eslint-disable-next-line no-console
310
+ console.error("[live mode] turn failed:", e);
311
+ }
312
+ };
313
+ }, [messages, sessionId, ttsLang, openPolicy, showMarketplace, showProfile, showPremium]);
314
+
315
  async function startRecording() {
316
  try {
317
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
 
625
  <label className="flex items-center gap-1.5 cursor-pointer" title="Hands-free voice β€” auto-submits when you stop speaking">
626
  <input type="checkbox" checked={handsFree} onChange={(e) => setHandsFree(e.target.checked)} className="w-3.5 h-3.5 accent-[var(--primary)]" /> Hands-free
627
  </label>
628
+ {/* Live conversation β€” full-duplex with VAD barge-in. Speak any
629
+ time, even while the bot is still talking, and it stops mid-
630
+ sentence and listens to you. No button press needed. */}
631
+ <button
632
+ type="button"
633
+ onClick={() => live.setLive(!live.live)}
634
+ className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full border text-xs font-medium transition ${
635
+ live.live
636
+ ? "bg-red-500/15 border-red-400 text-red-600"
637
+ : "border-[var(--border)] hover:border-[var(--primary)] hover:text-[var(--primary)]"
638
+ }`}
639
+ title="Live conversation: continuous mic, interrupt the bot by speaking"
640
+ >
641
+ <span className={`inline-block w-2 h-2 rounded-full ${live.live ? (live.recording ? "bg-red-500 animate-pulse" : "bg-green-500 animate-pulse") : "bg-gray-400"}`} />
642
+ {live.live ? (live.recording ? "Listening…" : "Live βœ“") : "Go Live"}
643
+ </button>
644
+ {live.micPermissionDenied && (
645
+ <span className="text-red-500" title="Browser blocked microphone access β€” check site permissions">mic blocked</span>
646
+ )}
647
  <label className="flex items-center gap-1.5">
648
  Lang:
649
  <select value={ttsLang} onChange={(e) => setTtsLang(e.target.value as "en-IN" | "hi-IN")} className="bg-transparent border border-[var(--border)] rounded px-1.5 py-0.5">
frontend/src/lib/api.ts CHANGED
@@ -56,6 +56,7 @@ export async function postChat(args: {
56
  return_audio?: boolean;
57
  tts_language_code?: string;
58
  view_context?: ViewContext;
 
59
  }): Promise<ChatResponse> {
60
  const resp = await fetch(`${BACKEND_URL}/api/chat`, {
61
  method: "POST",
@@ -70,6 +71,7 @@ export async function postChat(args: {
70
  tts_language_code: args.tts_language_code ?? "en-IN",
71
  view_context: args.view_context,
72
  }),
 
73
  });
74
  if (!resp.ok) {
75
  const t = await resp.text();
@@ -81,6 +83,7 @@ export async function postChat(args: {
81
  export async function postTranscribe(
82
  blob: Blob,
83
  language_code?: string,
 
84
  ): Promise<{ text: string; language_code?: string; latency_ms: number }> {
85
  const fd = new FormData();
86
  // Use blob's mime to derive extension; default to wav
@@ -98,6 +101,7 @@ export async function postTranscribe(
98
  const resp = await fetch(`${BACKEND_URL}/api/transcribe`, {
99
  method: "POST",
100
  body: fd,
 
101
  });
102
  if (!resp.ok) {
103
  const t = await resp.text();
 
56
  return_audio?: boolean;
57
  tts_language_code?: string;
58
  view_context?: ViewContext;
59
+ signal?: AbortSignal;
60
  }): Promise<ChatResponse> {
61
  const resp = await fetch(`${BACKEND_URL}/api/chat`, {
62
  method: "POST",
 
71
  tts_language_code: args.tts_language_code ?? "en-IN",
72
  view_context: args.view_context,
73
  }),
74
+ signal: args.signal,
75
  });
76
  if (!resp.ok) {
77
  const t = await resp.text();
 
83
  export async function postTranscribe(
84
  blob: Blob,
85
  language_code?: string,
86
+ signal?: AbortSignal,
87
  ): Promise<{ text: string; language_code?: string; latency_ms: number }> {
88
  const fd = new FormData();
89
  // Use blob's mime to derive extension; default to wav
 
101
  const resp = await fetch(`${BACKEND_URL}/api/transcribe`, {
102
  method: "POST",
103
  body: fd,
104
+ signal,
105
  });
106
  if (!resp.ok) {
107
  const t = await resp.text();
frontend/src/lib/useLiveConversation.ts ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /**
4
+ * useLiveConversation β€” full-duplex voice mode with barge-in.
5
+ *
6
+ * Differences from the existing push-to-talk + Hands-free toggle:
7
+ *
8
+ * - Mic is OPEN continuously while live mode is on (single getUserMedia
9
+ * stream, not opened/closed per turn).
10
+ * - VAD (RMS on AnalyserNode + frame counters) detects when the user
11
+ * starts and stops speaking, with no button press.
12
+ * - When VAD detects speech start, we immediately:
13
+ * * pause + clear every <audio> currently playing (kill the bot's
14
+ * in-progress TTS reply mid-sentence),
15
+ * * abort the in-flight /api/chat fetch via AbortController,
16
+ * * start a new MediaRecorder for the user's utterance.
17
+ * - When VAD detects silence for >600ms after speech, we stop the
18
+ * recorder and POST the blob to the supplied `onUtterance` handler
19
+ * (which the page wires to transcribe β†’ chat).
20
+ *
21
+ * The hook returns an AbortController slot the caller assigns to its
22
+ * own in-flight fetches so barge-in can cancel them.
23
+ */
24
+
25
+ import { useCallback, useEffect, useRef, useState } from "react";
26
+
27
+ export type LiveConversationOptions = {
28
+ /** Called when the user finishes an utterance β€” pass it the audio blob. */
29
+ onUtterance: (blob: Blob, abort: AbortController) => Promise<void>;
30
+ /** Called when VAD detects speech start (so the UI can show "listening…"). */
31
+ onSpeechStart?: () => void;
32
+ /** Called when VAD detects speech end (so the UI can show "thinking…"). */
33
+ onSpeechEnd?: () => void;
34
+ /** RMS threshold above which we declare "speech". Tune in browser. */
35
+ rmsThreshold?: number;
36
+ /** Consecutive loud frames needed to start recording (debounce). */
37
+ speechStartFrames?: number;
38
+ /** Consecutive quiet frames needed to stop recording (~16 ms/frame). */
39
+ silenceEndFrames?: number;
40
+ };
41
+
42
+ export type LiveConversationState = {
43
+ live: boolean;
44
+ recording: boolean;
45
+ micPermissionDenied: boolean;
46
+ setLive: (v: boolean) => void;
47
+ /** Caller-managed abort slot for in-flight fetches; VAD aborts it on speech. */
48
+ inflightAbortRef: React.MutableRefObject<AbortController | null>;
49
+ };
50
+
51
+ const DEFAULTS = {
52
+ rmsThreshold: 28, // 0-255 byte FFT magnitude floor; tune in browser
53
+ speechStartFrames: 5, // ~80 ms of consistent loudness to declare speech
54
+ silenceEndFrames: 40, // ~640 ms of silence to declare utterance end
55
+ };
56
+
57
+ export function useLiveConversation(opts: LiveConversationOptions): LiveConversationState {
58
+ const [live, setLive] = useState(false);
59
+ const [recording, setRecording] = useState(false);
60
+ const [micPermissionDenied, setMicPermissionDenied] = useState(false);
61
+
62
+ const streamRef = useRef<MediaStream | null>(null);
63
+ const audioCtxRef = useRef<AudioContext | null>(null);
64
+ const analyserRef = useRef<AnalyserNode | null>(null);
65
+ const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
66
+ const recorderRef = useRef<MediaRecorder | null>(null);
67
+ const chunksRef = useRef<Blob[]>([]);
68
+ const recordingRef = useRef(false); // sync ref for VAD loop
69
+ const rafIdRef = useRef<number | null>(null);
70
+ const inflightAbortRef = useRef<AbortController | null>(null);
71
+
72
+ const onUtteranceRef = useRef(opts.onUtterance);
73
+ const onSpeechStartRef = useRef(opts.onSpeechStart);
74
+ const onSpeechEndRef = useRef(opts.onSpeechEnd);
75
+ useEffect(() => {
76
+ onUtteranceRef.current = opts.onUtterance;
77
+ onSpeechStartRef.current = opts.onSpeechStart;
78
+ onSpeechEndRef.current = opts.onSpeechEnd;
79
+ }, [opts.onUtterance, opts.onSpeechStart, opts.onSpeechEnd]);
80
+
81
+ const cfg = {
82
+ rmsThreshold: opts.rmsThreshold ?? DEFAULTS.rmsThreshold,
83
+ speechStartFrames: opts.speechStartFrames ?? DEFAULTS.speechStartFrames,
84
+ silenceEndFrames: opts.silenceEndFrames ?? DEFAULTS.silenceEndFrames,
85
+ };
86
+
87
+ const stopRecording = useCallback(() => {
88
+ if (recorderRef.current && recorderRef.current.state !== "inactive") {
89
+ try { recorderRef.current.stop(); } catch {}
90
+ }
91
+ }, []);
92
+
93
+ const interruptBotAudio = useCallback(() => {
94
+ // Pause + reset every audio element in the DOM. Bot replies use plain
95
+ // <audio> elements; killing src forces the loaded buffer to drop.
96
+ if (typeof document !== "undefined") {
97
+ document.querySelectorAll("audio").forEach((a) => {
98
+ try {
99
+ a.pause();
100
+ // Don't blank src β€” let the existing buffer GC but leave the
101
+ // element so the chat history scroll position doesn't jump.
102
+ a.currentTime = a.duration || 0;
103
+ } catch {}
104
+ });
105
+ }
106
+ }, []);
107
+
108
+ const startRecording = useCallback(() => {
109
+ if (!streamRef.current) return;
110
+ const mime = MediaRecorder.isTypeSupported("audio/webm")
111
+ ? "audio/webm"
112
+ : "";
113
+ const rec = mime
114
+ ? new MediaRecorder(streamRef.current, { mimeType: mime })
115
+ : new MediaRecorder(streamRef.current);
116
+ chunksRef.current = [];
117
+ rec.ondataavailable = (e) => {
118
+ if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
119
+ };
120
+ rec.onstop = async () => {
121
+ recordingRef.current = false;
122
+ setRecording(false);
123
+ if (chunksRef.current.length === 0) return;
124
+ const blob = new Blob(chunksRef.current, {
125
+ type: rec.mimeType || "audio/webm",
126
+ });
127
+ // Reject blobs that are almost certainly silence or VAD false-trips.
128
+ if (blob.size < 3000) return;
129
+ onSpeechEndRef.current?.();
130
+ const abort = new AbortController();
131
+ inflightAbortRef.current = abort;
132
+ try {
133
+ await onUtteranceRef.current(blob, abort);
134
+ } catch (e) {
135
+ const name = (e as { name?: string })?.name;
136
+ if (name !== "AbortError") {
137
+ // surface to console; the UI's existing error toast will fire too
138
+ // eslint-disable-next-line no-console
139
+ console.error("[live-mode] utterance handler failed:", e);
140
+ }
141
+ } finally {
142
+ if (inflightAbortRef.current === abort) {
143
+ inflightAbortRef.current = null;
144
+ }
145
+ }
146
+ };
147
+ recorderRef.current = rec;
148
+ recordingRef.current = true;
149
+ setRecording(true);
150
+ onSpeechStartRef.current?.();
151
+ rec.start();
152
+ }, []);
153
+
154
+ // VAD loop β€” runs while `live` is true.
155
+ const tickVAD = useCallback(() => {
156
+ if (!analyserRef.current) return;
157
+ const a = analyserRef.current;
158
+ const buf = new Uint8Array(a.frequencyBinCount);
159
+ let loud = 0;
160
+ let quiet = 0;
161
+ const loop = () => {
162
+ if (!analyserRef.current) return;
163
+ a.getByteFrequencyData(buf);
164
+ let sum = 0;
165
+ for (let i = 0; i < buf.length; i++) sum += buf[i];
166
+ const avg = sum / buf.length;
167
+
168
+ if (avg > cfg.rmsThreshold) {
169
+ loud++;
170
+ quiet = 0;
171
+ if (loud === cfg.speechStartFrames && !recordingRef.current) {
172
+ // Barge in: kill bot audio + cancel in-flight chat + start recording.
173
+ interruptBotAudio();
174
+ if (inflightAbortRef.current) {
175
+ try { inflightAbortRef.current.abort(); } catch {}
176
+ inflightAbortRef.current = null;
177
+ }
178
+ startRecording();
179
+ }
180
+ } else {
181
+ quiet++;
182
+ loud = 0;
183
+ if (quiet === cfg.silenceEndFrames && recordingRef.current) {
184
+ stopRecording();
185
+ }
186
+ }
187
+
188
+ rafIdRef.current = requestAnimationFrame(loop);
189
+ };
190
+ rafIdRef.current = requestAnimationFrame(loop);
191
+ }, [cfg.rmsThreshold, cfg.silenceEndFrames, cfg.speechStartFrames, interruptBotAudio, startRecording, stopRecording]);
192
+
193
+ useEffect(() => {
194
+ let cancelled = false;
195
+
196
+ const tearDown = () => {
197
+ if (rafIdRef.current !== null) {
198
+ cancelAnimationFrame(rafIdRef.current);
199
+ rafIdRef.current = null;
200
+ }
201
+ stopRecording();
202
+ if (streamRef.current) {
203
+ streamRef.current.getTracks().forEach((t) => t.stop());
204
+ streamRef.current = null;
205
+ }
206
+ if (sourceRef.current) {
207
+ try { sourceRef.current.disconnect(); } catch {}
208
+ sourceRef.current = null;
209
+ }
210
+ analyserRef.current = null;
211
+ if (audioCtxRef.current) {
212
+ audioCtxRef.current.close().catch(() => {});
213
+ audioCtxRef.current = null;
214
+ }
215
+ };
216
+
217
+ if (!live) {
218
+ tearDown();
219
+ return;
220
+ }
221
+
222
+ (async () => {
223
+ try {
224
+ const stream = await navigator.mediaDevices.getUserMedia({
225
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
226
+ });
227
+ if (cancelled) {
228
+ stream.getTracks().forEach((t) => t.stop());
229
+ return;
230
+ }
231
+ streamRef.current = stream;
232
+ const AudioCtx =
233
+ (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext ||
234
+ window.AudioContext;
235
+ const ctx = new AudioCtx();
236
+ audioCtxRef.current = ctx;
237
+ const source = ctx.createMediaStreamSource(stream);
238
+ const analyser = ctx.createAnalyser();
239
+ analyser.fftSize = 512;
240
+ analyser.smoothingTimeConstant = 0.5;
241
+ source.connect(analyser);
242
+ sourceRef.current = source;
243
+ analyserRef.current = analyser;
244
+ setMicPermissionDenied(false);
245
+ tickVAD();
246
+ } catch (e) {
247
+ // eslint-disable-next-line no-console
248
+ console.error("[live-mode] mic permission denied or unavailable", e);
249
+ setMicPermissionDenied(true);
250
+ setLive(false);
251
+ }
252
+ })();
253
+
254
+ return () => {
255
+ cancelled = true;
256
+ tearDown();
257
+ };
258
+ }, [live, stopRecording, tickVAD]);
259
+
260
+ return {
261
+ live,
262
+ recording,
263
+ micPermissionDenied,
264
+ setLive,
265
+ inflightAbortRef,
266
+ };
267
+ }