Spaces:
Sleeping
fix(voice+admin): KI-185 + KI-186 — AEC echo cancellation + 3-tier admin
Browse filesKI-185 — Browser AEC + noise suppression on every getUserMedia call.
User report: mic transcribing bot's own TTS audio as user input. Root
cause: our getUserMedia calls used default `{audio: true}` which does
NOT force AEC. Production voice agents (Zoom, Meet, ChatGPT voice) all
pass explicit AEC + noiseSuppression + autoGainControl constraints.
Updated both getUserMedia call sites:
- frontend/src/lib/useStreamingVoice.ts (live-voice path)
- frontend/src/app/page.tsx (PTT path)
Effect: headphone users → near-perfect echo cancellation. Speaker users
→ 70-90% echo reduction. Live barge-in PRESERVED (no recognition gating
on TTS playback). Sarvam STT untouched — the echo was in the Web Speech
interim layer, not in Sarvam.
KI-186 — Admin "What's Available" now shows all 3 tiers.
User: "Both Gemini and OpenRouter need to be captured in this. Whatever
is your entire LLM chain, the models you have shortlisted as being
eligible, they need to be here."
Previously the table only showed NIM models because /api/admin/llm-health
only knows about the NIM probe registry. Added static rows for Tier 0
(Google Gemini 2.5 Flash + Flash Lite) and Tier 2 (OpenRouter free pool
— nemotron-3-super-120b, qwen3-next-80b:free, gemma-4-31b). Added a
new Tier column showing T0 / T1 / T2 so users see which tier each
model belongs to.
Static rows default to Healthy because they're not in llm_health's
probe registry — they fail-loud at call time per the fail-loud
invariant rather than being marked Down preemptively.
Verification: tsc --noEmit clean; node --check on inline admin script
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@@ -1521,26 +1521,67 @@
|
|
| 1521 |
var availTable = createEl('table');
|
| 1522 |
var availThead = createEl('thead');
|
| 1523 |
var availHr = createEl('tr');
|
| 1524 |
-
// KI-
|
| 1525 |
-
//
|
| 1526 |
-
|
|
|
|
| 1527 |
availCols.forEach(function (h, i) {
|
| 1528 |
var th = createEl('th', { text: h });
|
| 1529 |
-
if (i >=
|
|
|
|
| 1530 |
availHr.appendChild(th);
|
| 1531 |
});
|
| 1532 |
availThead.appendChild(availHr);
|
| 1533 |
availTable.appendChild(availThead);
|
| 1534 |
var availTbody = createEl('tbody');
|
| 1535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1536 |
var modelNames = Object.keys(modelMap);
|
| 1537 |
if (!modelNames.length) {
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
emptyTd.style.color = 'var(--muted)';
|
| 1541 |
-
emptyTd.style.textAlign = 'center';
|
| 1542 |
-
emptyTr.appendChild(emptyTd);
|
| 1543 |
-
availTbody.appendChild(emptyTr);
|
| 1544 |
} else {
|
| 1545 |
// Sort: anyone's-primary first, then alphabetical by model name.
|
| 1546 |
modelNames.sort(function (a, b) {
|
|
@@ -1554,6 +1595,14 @@
|
|
| 1554 |
var tr = createEl('tr');
|
| 1555 |
tr.appendChild(createEl('td', { className: 'model-name', text: model }));
|
| 1556 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1557 |
// Status cell: dot + word.
|
| 1558 |
var statusCell = createEl('td', { className: 'status-cell' });
|
| 1559 |
var hd = createEl('span', { className: 'health-dot ' + (info.healthy ? 'ok' : 'bad') });
|
|
|
|
| 1521 |
var availTable = createEl('table');
|
| 1522 |
var availThead = createEl('thead');
|
| 1523 |
var availHr = createEl('tr');
|
| 1524 |
+
// KI-186 (2026-05-15) — added "Tier" column so Gemini + OR rows can
|
| 1525 |
+
// be shown alongside NIM rows. Tier 0 = Google AI Studio, Tier 1 =
|
| 1526 |
+
// NIM (probed), Tier 2 = OpenRouter free pool.
|
| 1527 |
+
var availCols = ['Model', 'Tier', 'Status'].concat(SIMPLE_USE_ORDER.map(function (r) { return SIMPLE_USE_LABELS[r]; }));
|
| 1528 |
availCols.forEach(function (h, i) {
|
| 1529 |
var th = createEl('th', { text: h });
|
| 1530 |
+
if (i >= 3) th.style.textAlign = 'center';
|
| 1531 |
+
if (i === 1 || i === 2) th.style.textAlign = 'center';
|
| 1532 |
availHr.appendChild(th);
|
| 1533 |
});
|
| 1534 |
availThead.appendChild(availHr);
|
| 1535 |
availTable.appendChild(availThead);
|
| 1536 |
var availTbody = createEl('tbody');
|
| 1537 |
|
| 1538 |
+
// KI-186 — Tier 0 (Google) + Tier 2 (OR) are STATIC rows; they're
|
| 1539 |
+
// not in llm_health's probe registry but they're real members of
|
| 1540 |
+
// the production chain per sales_brain.py + tiered_brain_llm.py.
|
| 1541 |
+
// Status defaults to 'Healthy' (we don't have probe data for these
|
| 1542 |
+
// tiers; they fail-loud at call time per the fail-loud > silent-
|
| 1543 |
+
// garbage invariant).
|
| 1544 |
+
var STATIC_TIER_ROWS = [
|
| 1545 |
+
// Tier 0 — Google AI Studio (Gemini, free 1500/day)
|
| 1546 |
+
{ model: 'google/gemini-2.5-flash', tier: 'T0', healthy: true, brainFast: '—', brainMain: 'primary', judge: '—' },
|
| 1547 |
+
{ model: 'google/gemini-2.5-flash-lite', tier: 'T0', healthy: true, brainFast: 'primary', brainMain: '—', judge: '—' },
|
| 1548 |
+
// Tier 2 — OpenRouter free pool (server-side fallback inside OR)
|
| 1549 |
+
{ model: 'nvidia/nemotron-3-super-120b-a12b:free', tier: 'T2', healthy: true, brainFast: '✓', brainMain: '✓', judge: '—' },
|
| 1550 |
+
{ model: 'qwen/qwen3-next-80b-a3b-instruct:free', tier: 'T2', healthy: true, brainFast: '✓', brainMain: '—', judge: '✓' },
|
| 1551 |
+
{ model: 'google/gemma-4-31b-it:free', tier: 'T2', healthy: true, brainFast: '✓', brainMain: '—', judge: '—' },
|
| 1552 |
+
];
|
| 1553 |
+
STATIC_TIER_ROWS.forEach(function (row) {
|
| 1554 |
+
var tr = createEl('tr');
|
| 1555 |
+
tr.appendChild(createEl('td', { className: 'model-name', text: row.model }));
|
| 1556 |
+
// Tier badge
|
| 1557 |
+
var tierCell = createEl('td', { className: 'tier-cell' });
|
| 1558 |
+
tierCell.style.textAlign = 'center';
|
| 1559 |
+
tierCell.style.fontWeight = '600';
|
| 1560 |
+
tierCell.style.color = row.tier === 'T0' ? 'var(--green)' : 'var(--muted)';
|
| 1561 |
+
tierCell.textContent = row.tier;
|
| 1562 |
+
tr.appendChild(tierCell);
|
| 1563 |
+
// Status cell
|
| 1564 |
+
var statusCell = createEl('td', { className: 'status-cell' });
|
| 1565 |
+
var hd = createEl('span', { className: 'health-dot ' + (row.healthy ? 'ok' : 'bad') });
|
| 1566 |
+
statusCell.appendChild(hd);
|
| 1567 |
+
statusCell.appendChild(document.createTextNode(row.healthy ? ' Healthy' : ' Down'));
|
| 1568 |
+
tr.appendChild(statusCell);
|
| 1569 |
+
// Per-role cells
|
| 1570 |
+
[row.brainFast, row.brainMain, row.judge].forEach(function (val) {
|
| 1571 |
+
var td = createEl('td', { className: 'role-cell' });
|
| 1572 |
+
td.style.textAlign = 'center';
|
| 1573 |
+
td.textContent = val;
|
| 1574 |
+
if (val === '—') { td.style.color = 'var(--muted)'; }
|
| 1575 |
+
else if (val === 'primary') { td.style.color = 'var(--green)'; td.style.fontWeight = '600'; }
|
| 1576 |
+
tr.appendChild(td);
|
| 1577 |
+
});
|
| 1578 |
+
availTbody.appendChild(tr);
|
| 1579 |
+
});
|
| 1580 |
+
|
| 1581 |
var modelNames = Object.keys(modelMap);
|
| 1582 |
if (!modelNames.length) {
|
| 1583 |
+
// After KI-186 we always have static Tier 0 + Tier 2 rows, so a
|
| 1584 |
+
// "no models" message would be misleading even if NIM has none.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1585 |
} else {
|
| 1586 |
// Sort: anyone's-primary first, then alphabetical by model name.
|
| 1587 |
modelNames.sort(function (a, b) {
|
|
|
|
| 1595 |
var tr = createEl('tr');
|
| 1596 |
tr.appendChild(createEl('td', { className: 'model-name', text: model }));
|
| 1597 |
|
| 1598 |
+
// KI-186 — Tier badge (NIM models are Tier 1)
|
| 1599 |
+
var tierCell = createEl('td', { className: 'tier-cell' });
|
| 1600 |
+
tierCell.style.textAlign = 'center';
|
| 1601 |
+
tierCell.style.fontWeight = '600';
|
| 1602 |
+
tierCell.style.color = 'var(--muted)';
|
| 1603 |
+
tierCell.textContent = 'T1';
|
| 1604 |
+
tr.appendChild(tierCell);
|
| 1605 |
+
|
| 1606 |
// Status cell: dot + word.
|
| 1607 |
var statusCell = createEl('td', { className: 'status-cell' });
|
| 1608 |
var hd = createEl('span', { className: 'health-dot ' + (info.healthy ? 'ok' : 'bad') });
|
|
@@ -475,7 +475,16 @@ export default function Page() {
|
|
| 475 |
await new Promise((r) => setTimeout(r, 400));
|
| 476 |
}
|
| 477 |
try {
|
| 478 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
const mime = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "";
|
| 480 |
const recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
| 481 |
mediaRecorderRef.current = recorder;
|
|
|
|
| 475 |
await new Promise((r) => setTimeout(r, 400));
|
| 476 |
}
|
| 477 |
try {
|
| 478 |
+
// KI-185 (2026-05-15) — match the useStreamingVoice AEC constraints
|
| 479 |
+
// on the PTT path too, so echo cancellation applies whether the user
|
| 480 |
+
// is in live-voice mode OR push-to-talk.
|
| 481 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 482 |
+
audio: {
|
| 483 |
+
echoCancellation: true,
|
| 484 |
+
noiseSuppression: true,
|
| 485 |
+
autoGainControl: true,
|
| 486 |
+
},
|
| 487 |
+
});
|
| 488 |
const mime = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "";
|
| 489 |
const recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
| 490 |
mediaRecorderRef.current = recorder;
|
|
@@ -237,7 +237,20 @@ export function useStreamingVoice(
|
|
| 237 |
if (typeof navigator === "undefined" || !navigator.mediaDevices) return false;
|
| 238 |
if (typeof MediaRecorder === "undefined") return false;
|
| 239 |
try {
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
const mime = pickRecorderMime();
|
| 242 |
recorderMimeRef.current = mime || "audio/webm";
|
| 243 |
const recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
|
|
|
| 237 |
if (typeof navigator === "undefined" || !navigator.mediaDevices) return false;
|
| 238 |
if (typeof MediaRecorder === "undefined") return false;
|
| 239 |
try {
|
| 240 |
+
// KI-185 (2026-05-15) — explicit AEC + noise suppression + auto-gain.
|
| 241 |
+
// Default `{audio: true}` does NOT force AEC across all browsers, so the
|
| 242 |
+
// mic was transcribing the bot's own TTS audio bleeding from speakers
|
| 243 |
+
// back into the mic. Same constraints Zoom / Meet / ChatGPT-voice use.
|
| 244 |
+
// For headphone users this gives near-perfect echo cancellation;
|
| 245 |
+
// for speaker users it's 70-90% reduction (some bleed unavoidable
|
| 246 |
+
// without server-side reference cancellation).
|
| 247 |
+
const stream = await navigator.mediaDevices.getUserMedia({
|
| 248 |
+
audio: {
|
| 249 |
+
echoCancellation: true,
|
| 250 |
+
noiseSuppression: true,
|
| 251 |
+
autoGainControl: true,
|
| 252 |
+
},
|
| 253 |
+
});
|
| 254 |
const mime = pickRecorderMime();
|
| 255 |
recorderMimeRef.current = mime || "audio/webm";
|
| 256 |
const recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|