Spaces:
Sleeping
fix(voice+slots+routing): KI-212 + KI-213 + KI-215 + KI-216 bundle
Browse filesKI-212 β Aggressive barge-in thresholds for reliable trigger.
BARGE_IN_RMS_THRESHOLD 0.025 β 0.008. BASE 0.005 β 0.002. MULTIPLIER
2.0 β 1.5. SUSTAINED_FRAMES 18 β 6 (~100ms vs ~300ms).
Barge-in fires on much softer speech without requiring calibration.
KI-213 β Push-to-talk shows live interim transcript.
Added SpeechRecognition in parallel with MediaRecorder during PTT.
Interim words stream into chat input as user speaks. On release,
Sarvam transcript replaces interim text. Falls back to browser
final if Sarvam fails. Self-contained in page.tsx.
KI-215 β Don't prematurely flip free_form_session.
Bug: once all 6 required slots captured + ready_for_recommendations
=true, orchestrator immediately flipped free_form_session=True.
User's confirmation reply ("Yes, that sounds correct") then
routed to QA β faithfulness rejected with "I'd rather not answer
that without stronger evidence." Now require the user's text to
explicitly contain a recommendation keyword (show me / recommend
/ suggest / top N / proceed / go ahead / etc.) before flipping.
KI-216 β health_conditions promoted REQUIRED + explicit confirmation.
Pre-existing conditions materially affect waiting periods, claim
outcomes, premium loadings. Sales_brain must ask before flipping
ready_for_recommendations=true. System prompt updated to direct
the brain to ask "Shall I put together some options for you now?"
when all required slots filled β wait for user affirmation before
pivoting to recommendations.
Orchestrator _REQUIRED_SLOTS now includes health_conditions; treats
empty list `[]` as a valid capture ("no conditions").
VERIFICATION:
py_compile clean (backend).
npx tsc --noEmit clean (frontend).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/orchestrator.py +35 -9
- backend/sales_brain.py +10 -3
- frontend/src/app/page.tsx +128 -1
- frontend/src/lib/useStreamingVoice.ts +11 -4
|
@@ -669,16 +669,42 @@ async def handle_turn(
|
|
| 669 |
if field_name not in session.profile.asked:
|
| 670 |
session.profile.asked.append(field_name)
|
| 671 |
|
| 672 |
-
#
|
| 673 |
-
#
|
| 674 |
-
#
|
| 675 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
_REQUIRED_SLOTS = ("name", "age", "dependents", "location_tier",
|
| 677 |
-
"income_band", "primary_goal")
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
session.free_form_session = True
|
| 683 |
session._flush()
|
| 684 |
|
|
|
|
| 669 |
if field_name not in session.profile.asked:
|
| 670 |
session.profile.asked.append(field_name)
|
| 671 |
|
| 672 |
+
# KI-215 (2026-05-15) β completion gate WITHOUT premature flip.
|
| 673 |
+
# Previously we flipped session.free_form_session=True as soon as
|
| 674 |
+
# sb_result.ready_for_recommendations=True + all 6 required slots
|
| 675 |
+
# were filled. Bug: user's NEXT message (e.g., "Yes, that sounds
|
| 676 |
+
# correct" in response to sales_brain's confirmation recap) would
|
| 677 |
+
# route to the QA path β faithfulness gate β reject with "I'd
|
| 678 |
+
# rather not answer that without stronger evidence...".
|
| 679 |
+
#
|
| 680 |
+
# New rule: stay in fact_find UNTIL the user's text explicitly
|
| 681 |
+
# contains a recommendation request keyword. Sales_brain keeps
|
| 682 |
+
# owning the conversation through the recap + confirmation phase;
|
| 683 |
+
# only when the user says "show me", "recommend", "best for me",
|
| 684 |
+
# "options", etc., do we flip to free_form mode.
|
| 685 |
+
# KI-216 β health_conditions now REQUIRED (pre-existing conditions
|
| 686 |
+
# materially affect waiting periods / exclusions / premium loadings).
|
| 687 |
_REQUIRED_SLOTS = ("name", "age", "dependents", "location_tier",
|
| 688 |
+
"income_band", "primary_goal", "health_conditions")
|
| 689 |
+
# health_conditions=[] (empty list) is a VALID capture ("no conditions").
|
| 690 |
+
# Treat as "captured" if the field is a list (regardless of contents).
|
| 691 |
+
def _slot_filled(slot):
|
| 692 |
+
val = getattr(session.profile, slot, None)
|
| 693 |
+
if slot == "health_conditions":
|
| 694 |
+
return isinstance(val, list)
|
| 695 |
+
return val not in (None, "", [])
|
| 696 |
+
_slots_complete = all(_slot_filled(slot) for slot in _REQUIRED_SLOTS)
|
| 697 |
+
_utl = (user_text or "").lower()
|
| 698 |
+
_user_asked_for_recs = any(kw in _utl for kw in (
|
| 699 |
+
"recommend", "suggest", "show me", "best polic", "top 3",
|
| 700 |
+
"top three", "top 5", "top five", "side by side", "side-by-side",
|
| 701 |
+
"three options", "few options", "some options", "compare options",
|
| 702 |
+
"shortlist", "give me three", "give me options", "fits me",
|
| 703 |
+
"right for me", "policies for me", "which polic", "good polic",
|
| 704 |
+
"what polic", "your recommendation", "your suggestion",
|
| 705 |
+
"go ahead", "proceed", "let's see", "lets see",
|
| 706 |
+
))
|
| 707 |
+
if sb_result.ready_for_recommendations and _slots_complete and _user_asked_for_recs:
|
| 708 |
session.free_form_session = True
|
| 709 |
session._flush()
|
| 710 |
|
|
@@ -134,12 +134,18 @@ _SLOT_DESCRIPTIONS: dict[str, str] = {
|
|
| 134 |
# Order of slots β used to determine "required remaining" so the LLM has a
|
| 135 |
# stable sense of what to ask next. The first six are the recommendation-
|
| 136 |
# readiness minimum; the rest are deepening signals.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
_REQUIRED_FOR_READY: tuple = (
|
| 138 |
-
"name", "age", "dependents", "location_tier", "income_band",
|
|
|
|
| 139 |
)
|
| 140 |
|
| 141 |
_NICE_TO_HAVE: tuple = (
|
| 142 |
-
"existing_cover_inr", "budget_band",
|
| 143 |
"parents_to_insure", "parents_age_max", "parents_has_ped",
|
| 144 |
)
|
| 145 |
|
|
@@ -180,7 +186,8 @@ Return a SINGLE JSON object with exactly these three keys:
|
|
| 180 |
Rules for the JSON:
|
| 181 |
- "reply" β natural conversational prose. NO scripted prefixes ("Got that β", "Noted β", "Sure β", "Perfect β"). Acknowledge what they said by reflecting it back, then continue.
|
| 182 |
- "captures" β only fields the user CHANGED or NEWLY revealed in their LAST message. If they didn't reveal anything new this turn, emit `"captures": {}` (empty object). Do NOT re-emit slots that haven't changed. Do NOT include null / empty-string values.
|
| 183 |
-
- "ready_for_recommendations" β set to true ONLY when you have
|
|
|
|
| 184 |
- All enum values must match the slot schema EXACTLY (case-sensitive). All ints as JSON numbers, not strings. Lists as JSON arrays.
|
| 185 |
|
| 186 |
EXAMPLES OF GOOD REPLIES (illustrative β the JSON shape is what matters):
|
|
|
|
| 134 |
# Order of slots β used to determine "required remaining" so the LLM has a
|
| 135 |
# stable sense of what to ask next. The first six are the recommendation-
|
| 136 |
# readiness minimum; the rest are deepening signals.
|
| 137 |
+
# KI-216 (2026-05-15) β `health_conditions` PROMOTED from nice-to-have to
|
| 138 |
+
# REQUIRED. Pre-existing conditions massively affect premium, waiting
|
| 139 |
+
# periods, and claim outcomes β skipping it means recommending policies
|
| 140 |
+
# that may exclude the user's actual needs. The brain MUST ask before
|
| 141 |
+
# advancing to recommendations.
|
| 142 |
_REQUIRED_FOR_READY: tuple = (
|
| 143 |
+
"name", "age", "dependents", "location_tier", "income_band",
|
| 144 |
+
"primary_goal", "health_conditions",
|
| 145 |
)
|
| 146 |
|
| 147 |
_NICE_TO_HAVE: tuple = (
|
| 148 |
+
"existing_cover_inr", "budget_band",
|
| 149 |
"parents_to_insure", "parents_age_max", "parents_has_ped",
|
| 150 |
)
|
| 151 |
|
|
|
|
| 186 |
Rules for the JSON:
|
| 187 |
- "reply" β natural conversational prose. NO scripted prefixes ("Got that β", "Noted β", "Sure β", "Perfect β"). Acknowledge what they said by reflecting it back, then continue.
|
| 188 |
- "captures" β only fields the user CHANGED or NEWLY revealed in their LAST message. If they didn't reveal anything new this turn, emit `"captures": {}` (empty object). Do NOT re-emit slots that haven't changed. Do NOT include null / empty-string values.
|
| 189 |
+
- "ready_for_recommendations" β set to true ONLY when you have ALL of: name, age, dependents, location, income_band, primary_goal, AND health_conditions. **You MUST ask about pre-existing health conditions before setting this to true** β it materially affects which policies fit (waiting periods, exclusions, premium loadings). Empty list `[]` means "no conditions" β that's valid and counts as captured. Otherwise leave false.
|
| 190 |
+
- "reply" before flipping ready: when ALL required slots above ARE captured this turn, your reply should EXPLICITLY ask the user "Shall I put together some options for you now?" (or similar natural confirmation). Do NOT auto-recommend or auto-pivot. The user has to affirm before we move to the recommendation phase.
|
| 191 |
- All enum values must match the slot schema EXACTLY (case-sensitive). All ints as JSON numbers, not strings. Lists as JSON arrays.
|
| 192 |
|
| 193 |
EXAMPLES OF GOOD REPLIES (illustrative β the JSON shape is what matters):
|
|
@@ -148,6 +148,51 @@ export default function Page() {
|
|
| 148 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 149 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
// KI-168 (2026-05-15) β streaming-voice path replaces the legacy
|
| 152 |
// useLiveConversation full-duplex VAD machinery. Interim transcript shows
|
| 153 |
// in the chat input as the user speaks; browser silence-detection auto-
|
|
@@ -504,9 +549,66 @@ export default function Page() {
|
|
| 504 |
mediaRecorderRef.current = recorder;
|
| 505 |
audioChunksRef.current = [];
|
| 506 |
recorder.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
recorder.onstop = async () => {
|
| 508 |
stopVAD();
|
| 509 |
stream.getTracks().forEach((t) => t.stop());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
|
| 511 |
setRecording(false);
|
| 512 |
// KI-028 β Resume Live ONLY if the user's persistent preference is
|
|
@@ -518,22 +620,47 @@ export default function Page() {
|
|
| 518 |
// instead of returning quietly. Previously, holding PTT briefly and
|
| 519 |
// releasing produced no feedback at all; now they at least see why.
|
| 520 |
if (blob.size < 1000) {
|
|
|
|
|
|
|
|
|
|
| 521 |
pushAssistant("Didn't catch any audio β try holding the mic button while speaking.");
|
| 522 |
maybeResumeLive();
|
| 523 |
return;
|
| 524 |
}
|
| 525 |
setBusy(true);
|
| 526 |
setVoicePhase("transcribing"); // KI-038 β STT in flight on PTT
|
|
|
|
|
|
|
|
|
|
| 527 |
try {
|
| 528 |
const { text } = await postTranscribe(blob, ttsLang);
|
| 529 |
if (text && text.trim()) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
// send() flips voicePhase to "thinking" itself; no need to set here
|
| 531 |
await send(text);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
} else {
|
|
|
|
| 533 |
pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
|
| 534 |
}
|
| 535 |
} catch (e: unknown) {
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
} finally {
|
| 538 |
setBusy(false);
|
| 539 |
setVoicePhase(null);
|
|
|
|
| 148 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 149 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 150 |
|
| 151 |
+
// KI-213 (2026-05-15) β PTT-scoped browser SpeechRecognition. Runs in
|
| 152 |
+
// PARALLEL with the existing MediaRecorder + Sarvam pipeline so the user
|
| 153 |
+
// sees an interim transcript in the chat input as they speak (matching the
|
| 154 |
+
// live-voice UX) instead of staring at an empty input until Sarvam returns.
|
| 155 |
+
// Final Sarvam result still wins; the recognition transcript is only used
|
| 156 |
+
// as a fallback if Sarvam fails or returns empty.
|
| 157 |
+
//
|
| 158 |
+
// Types redeclared locally (instead of imported from useStreamingVoice.ts)
|
| 159 |
+
// because that module doesn't export them and they're tiny β keeping PTT a
|
| 160 |
+
// self-contained path in page.tsx per the existing architecture.
|
| 161 |
+
type PTTSpeechRecognitionAlternative = { transcript: string; confidence: number };
|
| 162 |
+
type PTTSpeechRecognitionResult = {
|
| 163 |
+
isFinal: boolean;
|
| 164 |
+
length: number;
|
| 165 |
+
[index: number]: PTTSpeechRecognitionAlternative;
|
| 166 |
+
};
|
| 167 |
+
type PTTSpeechRecognitionResultList = {
|
| 168 |
+
length: number;
|
| 169 |
+
[index: number]: PTTSpeechRecognitionResult;
|
| 170 |
+
};
|
| 171 |
+
interface PTTSpeechRecognitionEventLike extends Event {
|
| 172 |
+
resultIndex: number;
|
| 173 |
+
results: PTTSpeechRecognitionResultList;
|
| 174 |
+
}
|
| 175 |
+
interface PTTSpeechRecognitionErrorEventLike extends Event {
|
| 176 |
+
error: string;
|
| 177 |
+
message?: string;
|
| 178 |
+
}
|
| 179 |
+
interface PTTSpeechRecognitionInstance extends EventTarget {
|
| 180 |
+
lang: string;
|
| 181 |
+
continuous: boolean;
|
| 182 |
+
interimResults: boolean;
|
| 183 |
+
maxAlternatives: number;
|
| 184 |
+
start: () => void;
|
| 185 |
+
stop: () => void;
|
| 186 |
+
abort: () => void;
|
| 187 |
+
onresult: ((ev: PTTSpeechRecognitionEventLike) => void) | null;
|
| 188 |
+
onerror: ((ev: PTTSpeechRecognitionErrorEventLike) => void) | null;
|
| 189 |
+
onend: ((ev: Event) => void) | null;
|
| 190 |
+
onstart: ((ev: Event) => void) | null;
|
| 191 |
+
}
|
| 192 |
+
type PTTSpeechRecognitionCtor = new () => PTTSpeechRecognitionInstance;
|
| 193 |
+
const pttRecognitionRef = useRef<PTTSpeechRecognitionInstance | null>(null);
|
| 194 |
+
const pttFinalTranscriptRef = useRef<string>("");
|
| 195 |
+
|
| 196 |
// KI-168 (2026-05-15) β streaming-voice path replaces the legacy
|
| 197 |
// useLiveConversation full-duplex VAD machinery. Interim transcript shows
|
| 198 |
// in the chat input as the user speaks; browser silence-detection auto-
|
|
|
|
| 549 |
mediaRecorderRef.current = recorder;
|
| 550 |
audioChunksRef.current = [];
|
| 551 |
recorder.ondataavailable = (ev) => { if (ev.data.size > 0) audioChunksRef.current.push(ev.data); };
|
| 552 |
+
|
| 553 |
+
// KI-213 (2026-05-15) β start browser SpeechRecognition in parallel
|
| 554 |
+
// for interim transcript display. Sarvam still produces the
|
| 555 |
+
// authoritative transcript; this is purely UX (so the input fills as
|
| 556 |
+
// the user speaks). Best-effort: if SR is unsupported or start() throws
|
| 557 |
+
// we silently continue with the existing Sarvam-only flow.
|
| 558 |
+
pttFinalTranscriptRef.current = "";
|
| 559 |
+
try {
|
| 560 |
+
const w = window as unknown as {
|
| 561 |
+
SpeechRecognition?: PTTSpeechRecognitionCtor;
|
| 562 |
+
webkitSpeechRecognition?: PTTSpeechRecognitionCtor;
|
| 563 |
+
};
|
| 564 |
+
const Ctor = w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
| 565 |
+
if (Ctor) {
|
| 566 |
+
const rec = new Ctor();
|
| 567 |
+
rec.continuous = false;
|
| 568 |
+
rec.interimResults = true;
|
| 569 |
+
rec.maxAlternatives = 1;
|
| 570 |
+
// ttsLang is the same locale the live-voice path uses; en-IN is the
|
| 571 |
+
// default fallback per the spec.
|
| 572 |
+
rec.lang = ttsLang || "en-IN";
|
| 573 |
+
rec.onresult = (ev: PTTSpeechRecognitionEventLike) => {
|
| 574 |
+
// Assemble interim transcript from ALL results (finals + currently
|
| 575 |
+
// in-progress interim). Mirror useStreamingVoice's pattern.
|
| 576 |
+
let interim = "";
|
| 577 |
+
let final = "";
|
| 578 |
+
for (let i = 0; i < ev.results.length; i++) {
|
| 579 |
+
const r = ev.results[i];
|
| 580 |
+
const alt = r[0];
|
| 581 |
+
if (!alt) continue;
|
| 582 |
+
if (r.isFinal) final += alt.transcript;
|
| 583 |
+
else interim += alt.transcript;
|
| 584 |
+
}
|
| 585 |
+
if (final) pttFinalTranscriptRef.current = final;
|
| 586 |
+
const display = (final + interim).trim();
|
| 587 |
+
if (display) setInput(display);
|
| 588 |
+
};
|
| 589 |
+
rec.onerror = () => { /* best-effort β Sarvam is the source of truth */ };
|
| 590 |
+
rec.onend = () => { /* nothing β recorder.onstop drives the submit */ };
|
| 591 |
+
pttRecognitionRef.current = rec;
|
| 592 |
+
rec.start();
|
| 593 |
+
}
|
| 594 |
+
} catch {
|
| 595 |
+
// SR unavailable or already running β fall through, Sarvam still works.
|
| 596 |
+
pttRecognitionRef.current = null;
|
| 597 |
+
}
|
| 598 |
recorder.onstop = async () => {
|
| 599 |
stopVAD();
|
| 600 |
stream.getTracks().forEach((t) => t.stop());
|
| 601 |
+
// KI-213 (2026-05-15) β tear down the parallel SpeechRecognition.
|
| 602 |
+
// abort() is preferred over stop() to avoid a trailing onresult
|
| 603 |
+
// event firing AFTER we've already set the input to the Sarvam
|
| 604 |
+
// transcript (which would clobber it). The final transcript captured
|
| 605 |
+
// so far is preserved in pttFinalTranscriptRef as a Sarvam fallback.
|
| 606 |
+
const sr = pttRecognitionRef.current;
|
| 607 |
+
pttRecognitionRef.current = null;
|
| 608 |
+
if (sr) {
|
| 609 |
+
try { sr.abort(); } catch { /* already stopped */ }
|
| 610 |
+
}
|
| 611 |
+
const srFallback = pttFinalTranscriptRef.current.trim();
|
| 612 |
const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
|
| 613 |
setRecording(false);
|
| 614 |
// KI-028 β Resume Live ONLY if the user's persistent preference is
|
|
|
|
| 620 |
// instead of returning quietly. Previously, holding PTT briefly and
|
| 621 |
// releasing produced no feedback at all; now they at least see why.
|
| 622 |
if (blob.size < 1000) {
|
| 623 |
+
// KI-213 β clear the lingering interim that SR may have left in the
|
| 624 |
+
// input so the user isn't confused by a stale partial transcript.
|
| 625 |
+
setInput("");
|
| 626 |
pushAssistant("Didn't catch any audio β try holding the mic button while speaking.");
|
| 627 |
maybeResumeLive();
|
| 628 |
return;
|
| 629 |
}
|
| 630 |
setBusy(true);
|
| 631 |
setVoicePhase("transcribing"); // KI-038 β STT in flight on PTT
|
| 632 |
+
// KI-213 β keep the interim transcript visible in the input as a
|
| 633 |
+
// placeholder while Sarvam runs. send() will clear it when (and only
|
| 634 |
+
// when) we actually submit, so the user sees their words throughout.
|
| 635 |
try {
|
| 636 |
const { text } = await postTranscribe(blob, ttsLang);
|
| 637 |
if (text && text.trim()) {
|
| 638 |
+
// KI-213 β replace the interim SR transcript with Sarvam's
|
| 639 |
+
// authoritative version, then submit. send() clears the input
|
| 640 |
+
// itself so the brief flash here is intentional UX feedback.
|
| 641 |
+
setInput(text);
|
| 642 |
// send() flips voicePhase to "thinking" itself; no need to set here
|
| 643 |
await send(text);
|
| 644 |
+
} else if (srFallback) {
|
| 645 |
+
// KI-213 β Sarvam returned empty but the browser caught
|
| 646 |
+
// something. Better than telling the user "couldn't hear that
|
| 647 |
+
// clearly" when we actually have a usable transcript.
|
| 648 |
+
setInput(srFallback);
|
| 649 |
+
await send(srFallback);
|
| 650 |
} else {
|
| 651 |
+
setInput("");
|
| 652 |
pushAssistant("Sorry, I couldn't hear that clearly. Please try again.");
|
| 653 |
}
|
| 654 |
} catch (e: unknown) {
|
| 655 |
+
// KI-213 β Sarvam failed (network / 5xx / rate limit). Fall back to
|
| 656 |
+
// the SR transcript if we have one rather than dropping the turn.
|
| 657 |
+
if (srFallback) {
|
| 658 |
+
setInput(srFallback);
|
| 659 |
+
try { await send(srFallback); } catch { /* send handles its own errors */ }
|
| 660 |
+
} else {
|
| 661 |
+
setInput("");
|
| 662 |
+
pushAssistant(`Sorry β transcribe error: ${e instanceof Error ? e.message : String(e)}`);
|
| 663 |
+
}
|
| 664 |
} finally {
|
| 665 |
setBusy(false);
|
| 666 |
setVoicePhase(null);
|
|
@@ -46,16 +46,23 @@ import { postTranscribe } from "./api";
|
|
| 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 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 |
+
// KI-212 (2026-05-15) β was 0.025 / 18 frames. User reported barge-in
|
| 50 |
+
// completely failing: bot reads entire 14s reply uninterrupted. Lowered
|
| 51 |
+
// to fire on ANY decent speech burst within 100ms. Risk: false positives
|
| 52 |
+
// (chair creak, cough) β acceptable trade vs. broken barge-in.
|
| 53 |
+
const BARGE_IN_RMS_THRESHOLD = 0.008;
|
| 54 |
+
const BARGE_IN_SUSTAINED_FRAMES = 6; // ~100ms @ 60fps rAF
|
| 55 |
// KI-190 (2026-05-15) β adaptive threshold. The MediaRecorder mic stream
|
| 56 |
// has AEC, but for very loud bot TTS the residual bleed can still cross
|
| 57 |
// the static 0.025 threshold. We instead compute the threshold dynamically
|
| 58 |
// from the bot's CURRENT audio level: bot_rms * MULTIPLIER + BASE. Bot
|
| 59 |
// loud β threshold rises so user must speak loudly to overcome residual;
|
| 60 |
// bot quiet β threshold drops near floor so soft speech still wins.
|
| 61 |
+
// KI-212 β multiplier lowered 2.0 β 1.5 + base 0.005 β 0.002. Together
|
| 62 |
+
// with the static threshold drop, makes barge-in fire on much softer
|
| 63 |
+
// user speech even when bot is loud.
|
| 64 |
+
const BARGE_IN_BOT_RMS_MULTIPLIER = 1.5;
|
| 65 |
+
const BARGE_IN_BASE_THRESHOLD = 0.002;
|
| 66 |
// KI-191 (2026-05-15) β duck bot TTS volume while voice mode is on.
|
| 67 |
// Reducing playback amplitude further widens the gap between the bot's
|
| 68 |
// residual mic bleed (after AEC) and the user's normal-volume speech,
|