Spaces:
Sleeping
feat(voice): KI-189 + KI-190 + KI-191 — full DIY barge-in stack
Browse filesEnd-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>
|
@@ -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 |
|