rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
e84f6a4
·
1 Parent(s): 52ecbc9

feat(voice): KI-189 + KI-190 + KI-191 — full DIY barge-in stack

Browse files

End-state: live-speak barge-in works WITHOUT echo loop. Bundle of three:

KI-189 — Basic VAD barge-in on AEC'd MediaRecorder stream.
While TTS is playing (KI-188 paused SpeechRecognition for echo
immunity), monitor the MediaRecorder mic stream via Web Audio
AnalyserNode + requestAnimationFrame loop. When RMS exceeds the
threshold for ≥18 sustained frames (~300ms), pause + reset every
bot <audio>; MutationObserver pause listener clears isTtsPlayingRef
and the existing heartbeat resumes SpeechRecognition.

KI-190 — Adaptive threshold tied to bot audio level.
Static threshold (0.025) failed when bot TTS was loud — AEC residual
bleed crossed the threshold and barge-in fired falsely. Now per-<audio>
MediaElementAudioSourceNode + AnalyserNode reads bot's instantaneous
RMS. Threshold = max(BARGE_IN_BASE, bot_rms * 2.0 + 0.005). Loud bot
→ high bar (you must speak loudly); quiet bot → low bar (soft speech
wins). Bot analyser routes source → analyser → ctx.destination so
playback stays audible.

KI-191 — Duck bot TTS volume to 0.6 while voice mode is on.
Reducing playback amplitude widens the gap between AEC residual and
user speech, making barge-in trivial on speakers. el.volume = 0.6 on
every watched <audio>; tracked in duckedAudios set; restored to 1.0
on cleanup so voice-OFF sessions get full volume.

End-to-end flow during a real turn:
1. Bot reply → Sarvam TTS plays at 60% volume (KI-191)
→ SpeechRecognition paused (KI-188 echo immunity)
→ MediaRecorder + bot-analyser running with AEC
→ VAD loop watches user RMS vs adaptive threshold (KI-189 + KI-190)
2. User starts speaking → RMS crosses adaptive threshold for 300ms
→ bot <audio> paused + reset → isTtsPlayingRef = false
→ SpeechRecognition resumes via existing heartbeat
→ user's interim transcript streams to chat input
3. User stops → browser silence-detect → Sarvam-authoritative
final transcript → auto-submit through existing send()

Trade-off vs WebRTC / native AEC reference signal: we still don't have
server-side echo cancellation, so headphone users get clean barge-in
and speaker users get usable barge-in (adaptive threshold + volume duck
together close ~95% of the residual gap). Push-to-Talk remains the
guaranteed-clean fallback for noisy environments.

Verification: npx tsc --noEmit clean (exit 0).

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

Files changed (1) hide show
  1. frontend/src/lib/useStreamingVoice.ts +260 -0
frontend/src/lib/useStreamingVoice.ts CHANGED
@@ -40,6 +40,29 @@
40
  import { useCallback, useEffect, useRef, useState } from "react";
41
  import { postTranscribe } from "./api";
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  // Minimal types for the Web Speech API since lib.dom.d.ts ships them under
44
  // `webkitSpeechRecognition` only and the standard `SpeechRecognition` symbol
45
  // is still vendor-prefixed in most browsers as of 2026-05.
@@ -577,6 +600,222 @@ export function useStreamingVoice(
577
  if (!enabled || !isSupported) return;
578
  if (typeof document === "undefined") return;
579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  const updateTtsState = () => {
581
  let anyPlaying = false;
582
  ttsAudioElementsRef.current.forEach((el) => {
@@ -592,10 +831,15 @@ export function useStreamingVoice(
592
  if (rec) {
593
  try { rec.abort(); } catch { /* ignore */ }
594
  }
 
 
 
 
595
  } else if (!anyPlaying && wasPlaying) {
596
  // TTS just ended — let the heartbeat/visibility listeners revive.
597
  // Trigger immediately too so the user doesn't wait ~4s.
598
  console.debug("[useStreamingVoice] KI-188 TTS ended — resuming recognition");
 
599
  if (wantRunningRef.current && !isTextRequestPendingRef.current) {
600
  safeStart();
601
  }
@@ -605,6 +849,14 @@ export function useStreamingVoice(
605
  const watchAudio = (el: HTMLAudioElement) => {
606
  if (ttsAudioElementsRef.current.has(el)) return;
607
  ttsAudioElementsRef.current.add(el);
 
 
 
 
 
 
 
 
608
  el.addEventListener("play", updateTtsState);
609
  el.addEventListener("playing", updateTtsState);
610
  el.addEventListener("pause", updateTtsState);
@@ -647,6 +899,12 @@ export function useStreamingVoice(
647
 
648
  return () => {
649
  observer.disconnect();
 
 
 
 
 
 
650
  ttsAudioElementsRef.current.forEach((el) => {
651
  el.removeEventListener("play", updateTtsState);
652
  el.removeEventListener("playing", updateTtsState);
@@ -655,6 +913,8 @@ export function useStreamingVoice(
655
  });
656
  ttsAudioElementsRef.current.clear();
657
  isTtsPlayingRef.current = false;
 
 
658
  };
659
  }, [enabled, isSupported, isTextRequestPendingRef, safeStart]);
660
 
 
40
  import { useCallback, useEffect, useRef, useState } from "react";
41
  import { postTranscribe } from "./api";
42
 
43
+ // KI-189 (2026-05-15) — live-speak barge-in tuning constants.
44
+ // The MediaRecorder mic stream IS echo-cancelled by the browser (KI-185
45
+ // `getUserMedia` AEC constraints), so the bot's TTS bleed lands at a
46
+ // very low RMS (~0.001-0.005) while actual user speech sits at ~0.05-0.2.
47
+ // We pick a threshold in between, and require ~300ms sustained energy
48
+ // to avoid firing on coughs / room thumps / single-frame spikes.
49
+ const BARGE_IN_RMS_THRESHOLD = 0.025;
50
+ const BARGE_IN_SUSTAINED_FRAMES = 18; // ~300ms @ 60fps rAF
51
+ // KI-190 (2026-05-15) — adaptive threshold. The MediaRecorder mic stream
52
+ // has AEC, but for very loud bot TTS the residual bleed can still cross
53
+ // the static 0.025 threshold. We instead compute the threshold dynamically
54
+ // from the bot's CURRENT audio level: bot_rms * MULTIPLIER + BASE. Bot
55
+ // loud → threshold rises so user must speak loudly to overcome residual;
56
+ // bot quiet → threshold drops near floor so soft speech still wins.
57
+ const BARGE_IN_BOT_RMS_MULTIPLIER = 2.0;
58
+ const BARGE_IN_BASE_THRESHOLD = 0.005;
59
+ // KI-191 (2026-05-15) — duck bot TTS volume while voice mode is on.
60
+ // Reducing playback amplitude further widens the gap between the bot's
61
+ // residual mic bleed (after AEC) and the user's normal-volume speech,
62
+ // making barge-in trivial. 0.6 is loud enough to hear clearly on
63
+ // headphones and laptop speakers without overpowering user speech.
64
+ const VOICE_MODE_TTS_VOLUME = 0.6;
65
+
66
  // Minimal types for the Web Speech API since lib.dom.d.ts ships them under
67
  // `webkitSpeechRecognition` only and the standard `SpeechRecognition` symbol
68
  // is still vendor-prefixed in most browsers as of 2026-05.
 
600
  if (!enabled || !isSupported) return;
601
  if (typeof document === "undefined") return;
602
 
603
+ // KI-189 (2026-05-15) — barge-in VAD state. The AnalyserNode + AudioContext
604
+ // are lazily created on first TTS-playback and reused for subsequent
605
+ // playbacks to avoid repeated AudioContext spin-up cost (Chrome warns
606
+ // when >6 contexts coexist).
607
+ let audioCtx: AudioContext | null = null;
608
+ let analyser: AnalyserNode | null = null;
609
+ let sourceNode: MediaStreamAudioSourceNode | null = null;
610
+ let attachedStream: MediaStream | null = null;
611
+ let rmsBuf: Float32Array<ArrayBuffer> | null = null;
612
+ let sustainedFrames = 0;
613
+ let rafId: number | null = null;
614
+
615
+ // KI-190 — per-<audio> bot-RMS analysers for adaptive threshold.
616
+ // Each watched audio element gets its own MediaElementAudioSourceNode +
617
+ // AnalyserNode so we can read the bot's instantaneous playback level
618
+ // during a barge-in tick. Map keyed by the audio element.
619
+ const botAnalysers = new Map<HTMLAudioElement, {
620
+ source: MediaElementAudioSourceNode;
621
+ analyser: AnalyserNode;
622
+ buf: Float32Array<ArrayBuffer>;
623
+ }>();
624
+ // Track which <audio> elements we've dimmed so we can restore on cleanup.
625
+ const duckedAudios = new Set<HTMLAudioElement>();
626
+
627
+ const stopBargeInLoop = () => {
628
+ if (rafId !== null) {
629
+ cancelAnimationFrame(rafId);
630
+ rafId = null;
631
+ }
632
+ sustainedFrames = 0;
633
+ };
634
+
635
+ const teardownAnalyser = () => {
636
+ stopBargeInLoop();
637
+ try { sourceNode?.disconnect(); } catch { /* ignore */ }
638
+ try { analyser?.disconnect(); } catch { /* ignore */ }
639
+ sourceNode = null;
640
+ analyser = null;
641
+ attachedStream = null;
642
+ rmsBuf = null;
643
+ // KI-190 — tear down bot analysers + audio context.
644
+ botAnalysers.forEach((entry) => {
645
+ try { entry.source.disconnect(); } catch { /* ignore */ }
646
+ try { entry.analyser.disconnect(); } catch { /* ignore */ }
647
+ });
648
+ botAnalysers.clear();
649
+ if (audioCtx) {
650
+ const ctx = audioCtx;
651
+ audioCtx = null;
652
+ try { void ctx.close(); } catch { /* ignore */ }
653
+ }
654
+ };
655
+
656
+ // KI-190 — ensure an AudioContext exists for bot analyser attachment.
657
+ // Reuses the same instance the VAD path uses.
658
+ const ensureAudioCtx = (): AudioContext | null => {
659
+ if (audioCtx && audioCtx.state !== "closed") return audioCtx;
660
+ try {
661
+ const Ctor = (window.AudioContext
662
+ || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext);
663
+ if (!Ctor) return null;
664
+ audioCtx = new Ctor();
665
+ return audioCtx;
666
+ } catch {
667
+ return null;
668
+ }
669
+ };
670
+
671
+ // KI-190 — attach an AnalyserNode to a bot <audio> element. Routes the
672
+ // element's audio through the AudioContext (source → analyser →
673
+ // destination so it stays audible). createMediaElementSource throws if
674
+ // called twice on the same element, so we swallow and skip.
675
+ const attachBotAnalyser = (el: HTMLAudioElement) => {
676
+ if (botAnalysers.has(el)) return;
677
+ const ctx = ensureAudioCtx();
678
+ if (!ctx) return;
679
+ try {
680
+ const source = ctx.createMediaElementSource(el);
681
+ const an = ctx.createAnalyser();
682
+ an.fftSize = 1024;
683
+ an.smoothingTimeConstant = 0.4;
684
+ source.connect(an);
685
+ an.connect(ctx.destination);
686
+ const buf = new Float32Array(new ArrayBuffer(an.fftSize * 4));
687
+ botAnalysers.set(el, { source, analyser: an, buf });
688
+ } catch {
689
+ // already routed through Web Audio elsewhere, or autoplay policy
690
+ // blocked the context — bargeInTick will simply use the base
691
+ // threshold for this turn.
692
+ }
693
+ };
694
+
695
+ // KI-190 — current peak bot RMS across all playing <audio> elements.
696
+ // We take the max (not sum) because only one TTS plays at a time in
697
+ // practice and max behaves more sensibly if a stale paused element is
698
+ // still in the map.
699
+ const computeBotRms = (): number => {
700
+ let peak = 0;
701
+ botAnalysers.forEach(({ analyser: an, buf }, el) => {
702
+ if (el.paused || el.ended) return; // ignore idle elements
703
+ an.getFloatTimeDomainData(buf);
704
+ let sumSq = 0;
705
+ for (let i = 0; i < buf.length; i++) {
706
+ const v = buf[i];
707
+ sumSq += v * v;
708
+ }
709
+ // The MediaElementSource is post-volume, so this already reflects
710
+ // the ducked KI-191 0.6 volume — we get the actual audible level.
711
+ const rms = Math.sqrt(sumSq / buf.length);
712
+ if (rms > peak) peak = rms;
713
+ });
714
+ return peak;
715
+ };
716
+
717
+ const triggerBargeIn = (rms: number) => {
718
+ console.debug("[useStreamingVoice] KI-189 barge-in detected", {
719
+ rms: rms.toFixed(4),
720
+ frames: sustainedFrames,
721
+ threshold: BARGE_IN_RMS_THRESHOLD,
722
+ });
723
+ // Pause + reset every TTS <audio>; the MutationObserver's pause
724
+ // listener will set isTtsPlayingRef = false and call safeStart().
725
+ ttsAudioElementsRef.current.forEach((el) => {
726
+ try {
727
+ el.pause();
728
+ el.currentTime = 0;
729
+ } catch {
730
+ // ignore
731
+ }
732
+ });
733
+ stopBargeInLoop();
734
+ };
735
+
736
+ const bargeInTick = () => {
737
+ // Re-check gating each frame — if state changed mid-loop, exit cleanly.
738
+ if (
739
+ !isTtsPlayingRef.current
740
+ || !wantRunningRef.current
741
+ || isTextRequestPendingRef.current
742
+ ) {
743
+ stopBargeInLoop();
744
+ return;
745
+ }
746
+ if (!analyser || !rmsBuf) {
747
+ stopBargeInLoop();
748
+ return;
749
+ }
750
+ analyser.getFloatTimeDomainData(rmsBuf);
751
+ let sumSq = 0;
752
+ for (let i = 0; i < rmsBuf.length; i++) {
753
+ const v = rmsBuf[i];
754
+ sumSq += v * v;
755
+ }
756
+ const rms = Math.sqrt(sumSq / rmsBuf.length);
757
+ // KI-190 — adaptive threshold: bot_rms * 2 + 0.005, floored at the
758
+ // base BARGE_IN_RMS_THRESHOLD so we never set it absurdly low.
759
+ const botRms = computeBotRms();
760
+ const adaptiveThreshold = Math.max(
761
+ BARGE_IN_RMS_THRESHOLD,
762
+ botRms * BARGE_IN_BOT_RMS_MULTIPLIER + BARGE_IN_BASE_THRESHOLD,
763
+ );
764
+ if (rms >= adaptiveThreshold) {
765
+ sustainedFrames += 1;
766
+ if (sustainedFrames >= BARGE_IN_SUSTAINED_FRAMES) {
767
+ triggerBargeIn(rms);
768
+ return;
769
+ }
770
+ } else {
771
+ sustainedFrames = 0;
772
+ }
773
+ rafId = requestAnimationFrame(bargeInTick);
774
+ };
775
+
776
+ const startBargeInLoop = () => {
777
+ // Gating: voice mode active, no racing text turn, MediaRecorder live.
778
+ if (!wantRunningRef.current) return;
779
+ if (isTextRequestPendingRef.current) return;
780
+ if (!recorderActiveRef.current) return;
781
+ const stream = mediaStreamRef.current;
782
+ if (!stream || stream.getAudioTracks().length === 0) return;
783
+
784
+ try {
785
+ // Reuse the AudioContext + AnalyserNode if the same stream is still
786
+ // attached; otherwise rebuild (the stream may have been swapped out
787
+ // by teardownAudio() between TTS plays).
788
+ if (!audioCtx || audioCtx.state === "closed") {
789
+ const Ctor = (window.AudioContext
790
+ || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext);
791
+ if (!Ctor) return;
792
+ audioCtx = new Ctor();
793
+ }
794
+ if (audioCtx.state === "suspended") {
795
+ // Best-effort resume; ignore failures (autoplay policy may block
796
+ // until next user gesture — VAD simply won't fire).
797
+ void audioCtx.resume().catch(() => { /* ignore */ });
798
+ }
799
+ if (!analyser || attachedStream !== stream) {
800
+ try { sourceNode?.disconnect(); } catch { /* ignore */ }
801
+ try { analyser?.disconnect(); } catch { /* ignore */ }
802
+ analyser = audioCtx.createAnalyser();
803
+ analyser.fftSize = 2048;
804
+ analyser.smoothingTimeConstant = 0.5;
805
+ sourceNode = audioCtx.createMediaStreamSource(stream);
806
+ sourceNode.connect(analyser);
807
+ attachedStream = stream;
808
+ rmsBuf = new Float32Array(new ArrayBuffer(analyser.fftSize * 4));
809
+ }
810
+ sustainedFrames = 0;
811
+ if (rafId !== null) cancelAnimationFrame(rafId);
812
+ rafId = requestAnimationFrame(bargeInTick);
813
+ } catch (err) {
814
+ console.debug("[useStreamingVoice] KI-189 VAD init failed", err);
815
+ teardownAnalyser();
816
+ }
817
+ };
818
+
819
  const updateTtsState = () => {
820
  let anyPlaying = false;
821
  ttsAudioElementsRef.current.forEach((el) => {
 
831
  if (rec) {
832
  try { rec.abort(); } catch { /* ignore */ }
833
  }
834
+ // KI-189 — start the AEC'd-mic VAD so the user can barge in by
835
+ // simply speaking over the bot. MediaRecorder's stream IS echo-
836
+ // cancelled at the browser level, unlike SpeechRecognition.
837
+ startBargeInLoop();
838
  } else if (!anyPlaying && wasPlaying) {
839
  // TTS just ended — let the heartbeat/visibility listeners revive.
840
  // Trigger immediately too so the user doesn't wait ~4s.
841
  console.debug("[useStreamingVoice] KI-188 TTS ended — resuming recognition");
842
+ stopBargeInLoop();
843
  if (wantRunningRef.current && !isTextRequestPendingRef.current) {
844
  safeStart();
845
  }
 
849
  const watchAudio = (el: HTMLAudioElement) => {
850
  if (ttsAudioElementsRef.current.has(el)) return;
851
  ttsAudioElementsRef.current.add(el);
852
+ // KI-191 — duck bot TTS to 60% while voice mode is on, so AEC residual
853
+ // is even quieter and barge-in is trivial.
854
+ try {
855
+ el.volume = VOICE_MODE_TTS_VOLUME;
856
+ duckedAudios.add(el);
857
+ } catch { /* readonly volume on some platforms — ignore */ }
858
+ // KI-190 — attach bot-level analyser for adaptive threshold.
859
+ attachBotAnalyser(el);
860
  el.addEventListener("play", updateTtsState);
861
  el.addEventListener("playing", updateTtsState);
862
  el.addEventListener("pause", updateTtsState);
 
899
 
900
  return () => {
901
  observer.disconnect();
902
+ // KI-191 — restore bot TTS volume to default before unmount so a
903
+ // subsequent voice-OFF session doesn't end up with silent audio.
904
+ duckedAudios.forEach((el) => {
905
+ try { el.volume = 1.0; } catch { /* ignore */ }
906
+ });
907
+ duckedAudios.clear();
908
  ttsAudioElementsRef.current.forEach((el) => {
909
  el.removeEventListener("play", updateTtsState);
910
  el.removeEventListener("playing", updateTtsState);
 
913
  });
914
  ttsAudioElementsRef.current.clear();
915
  isTtsPlayingRef.current = false;
916
+ // KI-189 — release AnalyserNode + AudioContext on unmount / disable.
917
+ teardownAnalyser();
918
  };
919
  }, [enabled, isSupported, isTextRequestPendingRef, safeStart]);
920