rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
792acb1
·
1 Parent(s): 755ba01

fix(voice+admin): KI-185 + KI-186 — AEC echo cancellation + 3-tier admin

Browse files

KI-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>

frontend/public/admin/llm-control.html CHANGED
@@ -1521,26 +1521,67 @@
1521
  var availTable = createEl('table');
1522
  var availThead = createEl('thead');
1523
  var availHr = createEl('tr');
1524
- // KI-164: per-role columns. Each model row shows where it can be used
1525
- // (— / / primary) plus a single Status column.
1526
- var availCols = ['Model', 'Status'].concat(SIMPLE_USE_ORDER.map(function (r) { return SIMPLE_USE_LABELS[r]; }));
 
1527
  availCols.forEach(function (h, i) {
1528
  var th = createEl('th', { text: h });
1529
- if (i >= 2) th.style.textAlign = 'center';
 
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
- var emptyTr = createEl('tr');
1539
- var emptyTd = createEl('td', { attrs: { colspan: String(availCols.length) }, text: 'No models in any chain.' });
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') });
frontend/src/app/page.tsx CHANGED
@@ -475,7 +475,16 @@ export default function Page() {
475
  await new Promise((r) => setTimeout(r, 400));
476
  }
477
  try {
478
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
 
 
 
 
 
 
 
 
 
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;
frontend/src/lib/useStreamingVoice.ts CHANGED
@@ -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
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
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);