@lokutor/sdk 1.1.43 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -60,7 +60,8 @@ var AUDIO_CONFIG = {
60
60
  };
61
61
  var DEFAULT_URLS = {
62
62
  VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
63
- TTS: "wss://api.lokutor.com/ws/tts"
63
+ TTS: "wss://api.lokutor.com/ws/tts",
64
+ STT: "wss://api.lokutor.com/ws/stt"
64
65
  };
65
66
  var LokutorError = class extends Error {
66
67
  code;
@@ -240,6 +241,10 @@ var BrowserAudioManager = class {
240
241
  mediaStreamAudioSourceNode = null;
241
242
  scriptProcessor = null;
242
243
  analyserNode = null;
244
+ // Reused across getAmplitude() calls instead of allocating a new
245
+ // Uint8Array on every call — this runs once per animation frame (~60/sec)
246
+ // from the visualizer, on the same main thread as audio capture.
247
+ amplitudeBuffer = null;
243
248
  mediaStream = null;
244
249
  resampler = null;
245
250
  // Playback scheduling
@@ -306,7 +311,7 @@ var BrowserAudioManager = class {
306
311
  }
307
312
  });
308
313
  this.mediaStreamAudioSourceNode = this.audioContext.createMediaStreamSource(this.mediaStream);
309
- const bufferSize = 4096;
314
+ const bufferSize = 1024;
310
315
  this.scriptProcessor = this.audioContext.createScriptProcessor(
311
316
  bufferSize,
312
317
  1,
@@ -340,7 +345,6 @@ var BrowserAudioManager = class {
340
345
  */
341
346
  _processAudioInput(event) {
342
347
  if (!this.onAudioInput || !this.audioContext || !this.isListening) return;
343
- if (this.isMuted) return;
344
348
  const inputBuffer = event.inputBuffer;
345
349
  const inputData = inputBuffer.getChannelData(0);
346
350
  const outputBuffer = event.outputBuffer;
@@ -352,6 +356,9 @@ var BrowserAudioManager = class {
352
356
  processedData = this.resampler.process(processedData);
353
357
  }
354
358
  if (processedData.length === 0) return;
359
+ if (this.isMuted) {
360
+ processedData = new Float32Array(processedData.length);
361
+ }
355
362
  const int16Data = float32ToPcm16(processedData);
356
363
  const uint8Data = new Uint8Array(
357
364
  int16Data.buffer,
@@ -475,9 +482,11 @@ var BrowserAudioManager = class {
475
482
  */
476
483
  getAmplitude() {
477
484
  if (!this.analyserNode) return 0;
478
- const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
479
- this.analyserNode.getByteTimeDomainData(dataArray);
480
- const rms = calculateRMS(dataArray);
485
+ if (!this.amplitudeBuffer || this.amplitudeBuffer.length !== this.analyserNode.frequencyBinCount) {
486
+ this.amplitudeBuffer = new Uint8Array(this.analyserNode.frequencyBinCount);
487
+ }
488
+ this.analyserNode.getByteTimeDomainData(this.amplitudeBuffer);
489
+ const rms = calculateRMS(this.amplitudeBuffer);
481
490
  return Math.min(rms * 10, 1);
482
491
  }
483
492
  /**
@@ -584,6 +593,14 @@ function base64ToUint8Array(base64) {
584
593
  }
585
594
  return bytes;
586
595
  }
596
+ function uint8ArrayToBase64(bytes) {
597
+ let binaryString = "";
598
+ const chunkSize = 32768;
599
+ for (let i = 0; i < bytes.length; i += chunkSize) {
600
+ binaryString += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
601
+ }
602
+ return btoa(binaryString);
603
+ }
587
604
  function normalizeVisemes(payload) {
588
605
  if (!Array.isArray(payload)) return [];
589
606
  const normalized = [];
@@ -620,6 +637,7 @@ function extractVisemePayload(msg) {
620
637
  var VoiceAgentClient = class {
621
638
  ws = null;
622
639
  apiKey;
640
+ agentId = "";
623
641
  prompt;
624
642
  voice;
625
643
  language;
@@ -652,6 +670,7 @@ var VoiceAgentClient = class {
652
670
  serverUrl;
653
671
  constructor(config) {
654
672
  this.apiKey = config.apiKey;
673
+ this.agentId = config.agentId || "";
655
674
  this.prompt = config.prompt;
656
675
  this.voice = config.voice || "F1" /* F1 */;
657
676
  this.language = config.language || "en" /* ENGLISH */;
@@ -696,6 +715,10 @@ var VoiceAgentClient = class {
696
715
  const separator = url.includes("?") ? "&" : "?";
697
716
  url += `${separator}api_key=${this.apiKey}`;
698
717
  }
718
+ if (this.agentId) {
719
+ const separator = url.includes("?") ? "&" : "?";
720
+ url += `${separator}agent_id=${this.agentId}`;
721
+ }
699
722
  const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
700
723
  sdkTrace("ws.connect", {
701
724
  endpoint: this.serverUrl,
@@ -1315,6 +1338,164 @@ var TTSClient = class {
1315
1338
  });
1316
1339
  }
1317
1340
  };
1341
+ var STTClient = class {
1342
+ apiKey;
1343
+ baseUrl;
1344
+ constructor(config) {
1345
+ this.apiKey = config.apiKey;
1346
+ this.baseUrl = wsToHttp(config.serverUrl || DEFAULT_URLS.STT).replace(/\/ws\/stt\/?$/, "");
1347
+ }
1348
+ /**
1349
+ * Transcribe a complete audio clip. Pass a Blob/File (e.g. from a file
1350
+ * input or MediaRecorder) for WAV/compressed audio, or a raw PCM16
1351
+ * buffer with `format: "pcm16"` and `sampleRate` set.
1352
+ */
1353
+ async transcribe(options) {
1354
+ const url = `${this.baseUrl}/stt/transcribe`;
1355
+ let res;
1356
+ if (typeof Blob !== "undefined" && options.audio instanceof Blob) {
1357
+ const form = new FormData();
1358
+ form.append("audio", options.audio, "audio.wav");
1359
+ if (options.language) form.append("lang", options.language);
1360
+ if (options.sampleRate) form.append("sample_rate", String(options.sampleRate));
1361
+ res = await fetch(url, {
1362
+ method: "POST",
1363
+ headers: { "X-API-Key": this.apiKey },
1364
+ body: form
1365
+ });
1366
+ } else {
1367
+ const bytes = options.audio instanceof Uint8Array ? options.audio : new Uint8Array(options.audio);
1368
+ res = await fetch(url, {
1369
+ method: "POST",
1370
+ headers: { "X-API-Key": this.apiKey, "Content-Type": "application/json" },
1371
+ body: JSON.stringify({
1372
+ audio: uint8ArrayToBase64(bytes),
1373
+ format: options.format || "pcm16",
1374
+ sample_rate: options.sampleRate,
1375
+ lang: options.language
1376
+ })
1377
+ });
1378
+ }
1379
+ if (!res.ok) {
1380
+ const detail = await res.text().catch(() => "");
1381
+ throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
1382
+ detail,
1383
+ retryable: res.status >= 500
1384
+ });
1385
+ }
1386
+ const data = await res.json();
1387
+ return {
1388
+ text: data.text ?? "",
1389
+ latencyMs: data.latency_ms ?? 0,
1390
+ engine: data.engine ?? "",
1391
+ sampleRate: data.sample_rate ?? 0,
1392
+ language: data.language ?? "",
1393
+ durationSeconds: data.duration_seconds ?? 0,
1394
+ segments: data.segments
1395
+ };
1396
+ }
1397
+ };
1398
+ var SpeechToTextClient = class {
1399
+ apiKey;
1400
+ serverUrl;
1401
+ language;
1402
+ vad;
1403
+ onPartialTranscript;
1404
+ onFinalTranscript;
1405
+ onError;
1406
+ onStatusChange;
1407
+ ws = null;
1408
+ audioManager = null;
1409
+ isConnected = false;
1410
+ constructor(config) {
1411
+ this.apiKey = config.apiKey;
1412
+ this.serverUrl = config.serverUrl || DEFAULT_URLS.STT;
1413
+ this.language = config.language;
1414
+ this.vad = config.vad || "silero";
1415
+ this.onPartialTranscript = config.onPartialTranscript;
1416
+ this.onFinalTranscript = config.onFinalTranscript;
1417
+ this.onError = config.onError;
1418
+ this.onStatusChange = config.onStatusChange;
1419
+ }
1420
+ /**
1421
+ * Connect and start streaming microphone audio.
1422
+ * @param customAudioManager Optional replacement for the default audio hardware handler (e.g. NodeAudioManager for CLI use)
1423
+ */
1424
+ async connect(customAudioManager) {
1425
+ this.audioManager = customAudioManager || (typeof window !== "undefined" ? new BrowserAudioManager() : null);
1426
+ if (!this.audioManager) {
1427
+ throw new LokutorError("internal.error", "No audio manager available \u2014 pass one explicitly outside the browser (e.g. NodeAudioManager).");
1428
+ }
1429
+ await this.audioManager.init();
1430
+ this.onStatusChange?.("connecting");
1431
+ return new Promise((resolve, reject) => {
1432
+ let settled = false;
1433
+ const settle = (fn) => {
1434
+ if (!settled) {
1435
+ settled = true;
1436
+ fn();
1437
+ }
1438
+ };
1439
+ try {
1440
+ let url = this.serverUrl;
1441
+ const separator = url.includes("?") ? "&" : "?";
1442
+ url += `${separator}api_key=${this.apiKey}`;
1443
+ this.ws = new WebSocket(url);
1444
+ this.ws.onopen = async () => {
1445
+ this.isConnected = true;
1446
+ this.onStatusChange?.("connected");
1447
+ this.ws.send(JSON.stringify({ lang: this.language || "en" /* ENGLISH */, vad: this.vad }));
1448
+ await this.audioManager.startMicrophone((data) => {
1449
+ if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
1450
+ this.ws.send(data);
1451
+ }
1452
+ });
1453
+ settle(() => resolve(true));
1454
+ };
1455
+ this.ws.onmessage = (event) => {
1456
+ if (typeof event.data !== "string") return;
1457
+ try {
1458
+ const msg = JSON.parse(event.data);
1459
+ if (msg.type === "transcript") {
1460
+ if (msg.isFinal) {
1461
+ this.onFinalTranscript?.(msg.data ?? "");
1462
+ } else {
1463
+ this.onPartialTranscript?.(msg.data ?? "");
1464
+ }
1465
+ } else if (msg.type === "error") {
1466
+ this.onError?.(new LokutorError("internal.error", msg.data ?? "STT error"));
1467
+ }
1468
+ } catch {
1469
+ }
1470
+ };
1471
+ this.ws.onerror = (err) => {
1472
+ const lokutorErr = new LokutorError("internal.error", "WebSocket error", { original: err });
1473
+ this.onError?.(lokutorErr);
1474
+ settle(() => reject(lokutorErr));
1475
+ };
1476
+ this.ws.onclose = () => {
1477
+ this.isConnected = false;
1478
+ this.onStatusChange?.("disconnected");
1479
+ };
1480
+ } catch (err) {
1481
+ settle(() => reject(err));
1482
+ }
1483
+ });
1484
+ }
1485
+ /** Force-finalize whatever utterance is currently in progress. */
1486
+ endUtterance() {
1487
+ if (this.ws?.readyState === WebSocket.OPEN) {
1488
+ this.ws.send(JSON.stringify({ type: "end" }));
1489
+ }
1490
+ }
1491
+ disconnect() {
1492
+ this.isConnected = false;
1493
+ this.audioManager?.stopMicrophone();
1494
+ this.audioManager?.cleanup();
1495
+ this.ws?.close();
1496
+ this.ws = null;
1497
+ }
1498
+ };
1318
1499
  async function simpleConversation(config) {
1319
1500
  const client = new VoiceAgentClient(config);
1320
1501
  await client.connect();
@@ -1324,6 +1505,102 @@ async function simpleTTS(options) {
1324
1505
  const client = new TTSClient({ apiKey: options.apiKey });
1325
1506
  return client.synthesize(options);
1326
1507
  }
1508
+ async function simpleTranscribe(options) {
1509
+ const client = new STTClient({ apiKey: options.apiKey, serverUrl: options.serverUrl });
1510
+ return client.transcribe(options);
1511
+ }
1512
+
1513
+ // src/node-audio.ts
1514
+ function optionalImport(moduleName) {
1515
+ return import(moduleName);
1516
+ }
1517
+ var NodeAudioManager = class {
1518
+ speaker = null;
1519
+ recorder = null;
1520
+ recordingStream = null;
1521
+ isMuted = false;
1522
+ isListening = false;
1523
+ constructor() {
1524
+ }
1525
+ async init() {
1526
+ try {
1527
+ const Speaker = await optionalImport("speaker").catch(() => null);
1528
+ if (!Speaker) {
1529
+ console.warn('\u26A0\uFE0F Package "speaker" is missing. Hardware output will be disabled.');
1530
+ console.warn("\u{1F449} Run: npm install speaker");
1531
+ }
1532
+ } catch (e) {
1533
+ console.error("Error initializing Node audio:", e);
1534
+ }
1535
+ }
1536
+ async startMicrophone(onAudioInput) {
1537
+ if (this.isListening) return;
1538
+ try {
1539
+ const recorder = await optionalImport("node-record-lpcm16").catch(() => null);
1540
+ if (!recorder) {
1541
+ throw new Error('Package "node-record-lpcm16" is missing. Microphone input failed.\n\u{1F449} Run: npm install node-record-lpcm16');
1542
+ }
1543
+ console.log("\u{1F3A4} Starting microphone (Node.js)...");
1544
+ this.recordingStream = recorder.record({
1545
+ sampleRate: AUDIO_CONFIG.SAMPLE_RATE,
1546
+ threshold: 0,
1547
+ verbose: false,
1548
+ recordProgram: "sox"
1549
+ // default
1550
+ });
1551
+ this.recordingStream.stream().on("data", (chunk) => {
1552
+ if (!this.isMuted && onAudioInput) {
1553
+ onAudioInput(new Uint8Array(chunk));
1554
+ }
1555
+ });
1556
+ this.isListening = true;
1557
+ } catch (e) {
1558
+ console.error("Failed to start microphone:", e.message);
1559
+ throw e;
1560
+ }
1561
+ }
1562
+ stopMicrophone() {
1563
+ if (this.recordingStream) {
1564
+ this.recordingStream.stop();
1565
+ this.recordingStream = null;
1566
+ }
1567
+ this.isListening = false;
1568
+ }
1569
+ async playAudio(pcm16Data) {
1570
+ try {
1571
+ if (!this.speaker) {
1572
+ const Speaker = (await optionalImport("speaker")).default;
1573
+ this.speaker = new Speaker({
1574
+ channels: AUDIO_CONFIG.CHANNELS,
1575
+ bitDepth: 16,
1576
+ sampleRate: AUDIO_CONFIG.SPEAKER_SAMPLE_RATE
1577
+ });
1578
+ }
1579
+ this.speaker.write(Buffer.from(pcm16Data));
1580
+ } catch (e) {
1581
+ console.error("NodeAudioManager: speaker playback failed:", e);
1582
+ }
1583
+ }
1584
+ stopPlayback() {
1585
+ if (this.speaker) {
1586
+ this.speaker.end();
1587
+ this.speaker = null;
1588
+ }
1589
+ }
1590
+ cleanup() {
1591
+ this.stopMicrophone();
1592
+ this.stopPlayback();
1593
+ }
1594
+ isMicMuted() {
1595
+ return this.isMuted;
1596
+ }
1597
+ setMuted(muted) {
1598
+ this.isMuted = muted;
1599
+ }
1600
+ getAmplitude() {
1601
+ return 0;
1602
+ }
1603
+ };
1327
1604
 
1328
1605
  // src/conversational-panel.ts
1329
1606
  var PANEL_CSS = (
@@ -1419,7 +1696,7 @@ var PANEL_CSS = (
1419
1696
  }
1420
1697
  .cv-curtain-btn svg { transition: transform 0.3s ease; }
1421
1698
  .cv-curtain-btn:hover svg { transform: translateX(4px); }
1422
- .cv-header {
1699
+ .cv-error {
1423
1700
  display: flex;
1424
1701
  flex-direction: column;
1425
1702
  align-items: center;
@@ -1545,12 +1822,47 @@ var PANEL_CSS = (
1545
1822
  }
1546
1823
  .cv-error.is-visible { display: flex; }
1547
1824
  .cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
1825
+ .cv-voice-picker {
1826
+ display: flex;
1827
+ flex-wrap: wrap;
1828
+ justify-content: center;
1829
+ gap: 0.35rem;
1830
+ z-index: 2;
1831
+ }
1832
+ .cv-voice-btn {
1833
+ padding: 0.25rem 0.5rem;
1834
+ border-radius: 100px;
1835
+ border: 1px solid rgba(255,255,255,0.15);
1836
+ background: rgba(255,255,255,0.05);
1837
+ color: rgba(255,255,255,0.6);
1838
+ font-size: 0.65rem;
1839
+ font-weight: 600;
1840
+ cursor: pointer;
1841
+ transition: all 0.2s ease;
1842
+ }
1843
+ .cv-voice-btn:hover {
1844
+ background: rgba(255,255,255,0.12);
1845
+ color: #fff;
1846
+ }
1847
+ .cv-voice-btn.is-selected {
1848
+ background: #fff;
1849
+ color: #000;
1850
+ border-color: #fff;
1851
+ }
1852
+ .cv-voice-label {
1853
+ font-size: 0.55rem;
1854
+ text-transform: uppercase;
1855
+ letter-spacing: 0.08em;
1856
+ color: rgba(255,255,255,0.35);
1857
+ z-index: 2;
1858
+ }
1548
1859
 
1549
1860
  /* === COMPACT MODE: < 300px width === */
1550
1861
  @container (max-width: 299px) {
1551
1862
  .cv-curtain-content { padding: 0.5rem; gap: 0.3rem; }
1552
1863
  .cv-curtain-title { font-size: 0.8rem; }
1553
1864
  .cv-curtain-desc { display: none; }
1865
+ .cv-voice-picker { display: none; }
1554
1866
  .cv-curtain-btn { padding: 0.3rem 0.6rem; font-size: 0.6rem; gap: 0.3rem; }
1555
1867
  .cv-curtain-btn svg { display: none; }
1556
1868
  .cv-visualizer-wrap { width: 60px; height: 60px; }
@@ -1635,11 +1947,13 @@ var ConversationalPanel = class {
1635
1947
  isRunning = false;
1636
1948
  _locked = false;
1637
1949
  _lastSpeechTime = 0;
1638
- // Cached DOM refs
1950
+ selectedVoice = "M1";
1639
1951
  el;
1640
1952
  curtain;
1641
1953
  curtainTitle;
1642
1954
  curtainDesc;
1955
+ curtainBg;
1956
+ curtainOverlay;
1643
1957
  startBtn;
1644
1958
  errorEl;
1645
1959
  errorText;
@@ -1659,6 +1973,7 @@ var ConversationalPanel = class {
1659
1973
  constructor(cfg) {
1660
1974
  this.cfg = cfg;
1661
1975
  this.container = cfg.container;
1976
+ this.selectedVoice = cfg.voice || "M1";
1662
1977
  injectStyles();
1663
1978
  this.buildDOM();
1664
1979
  }
@@ -1674,9 +1989,9 @@ var ConversationalPanel = class {
1674
1989
  <div class="cv-curtain-bg"></div>
1675
1990
  <div class="cv-curtain-overlay"></div>
1676
1991
  <div class="cv-curtain-content">
1677
- <h3 class="cv-curtain-title">${this.esc(this.cfg.title)}</h3>
1678
- <p class="cv-curtain-desc">${this.esc(this.cfg.description)}</p>
1679
- <button class="cv-curtain-btn">
1992
+ <h3 class="cv-curtain-title">${this.esc(this.cfg.title || "Voice Chat")}</h3>
1993
+ <p class="cv-curtain-desc">${this.esc(this.cfg.description || "")}</p>
1994
+ <button class="cv-curtain-btn">
1680
1995
  <span>Start Conversation</span>
1681
1996
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
1682
1997
  <path d="M5 12h14M12 5l7 7-7 7"/>
@@ -1694,7 +2009,7 @@ var ConversationalPanel = class {
1694
2009
  </div>
1695
2010
  </div>
1696
2011
  <div class="cv-header">
1697
- <h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
2012
+ <h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")} <span class="cv-timer">00:00</span></h2>
1698
2013
  </div>
1699
2014
  <div class="cv-visualizer-wrap">
1700
2015
  <canvas class="cv-canvas"></canvas>
@@ -1772,7 +2087,7 @@ var ConversationalPanel = class {
1772
2087
  apiKey: this.cfg.apiKey,
1773
2088
  tools: this.cfg.tools,
1774
2089
  prompt: this.cfg.prompt,
1775
- voice: this.cfg.voice || "M1",
2090
+ voice: this.selectedVoice,
1776
2091
  language: this.cfg.language || "en",
1777
2092
  onStatusChange: (status) => {
1778
2093
  this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
@@ -1905,6 +2220,7 @@ var ConversationalPanel = class {
1905
2220
  const h2 = this.el.querySelector(".cv-title");
1906
2221
  if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || "00:00"}</span>`;
1907
2222
  }
2223
+ /** Select a voice from the picker */
1908
2224
  /** Update description text */
1909
2225
  setDescription(desc) {
1910
2226
  this.cfg.description = desc;
@@ -2034,6 +2350,9 @@ export {
2034
2350
  DEFAULT_URLS,
2035
2351
  Language,
2036
2352
  LokutorError,
2353
+ NodeAudioManager,
2354
+ STTClient,
2355
+ SpeechToTextClient,
2037
2356
  StreamResampler,
2038
2357
  TTSClient,
2039
2358
  VoiceAgentClient,
@@ -2049,5 +2368,6 @@ export {
2049
2368
  resample,
2050
2369
  resampleWithAntiAliasing,
2051
2370
  simpleConversation,
2052
- simpleTTS
2371
+ simpleTTS,
2372
+ simpleTranscribe
2053
2373
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lokutor/sdk",
3
- "version": "1.1.43",
3
+ "version": "1.2.1",
4
4
  "description": "JavaScript/TypeScript SDK for Lokutor Real-time Voice AI",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -30,6 +30,10 @@ export class BrowserAudioManager {
30
30
  private mediaStreamAudioSourceNode: MediaStreamAudioSourceNode | null = null;
31
31
  private scriptProcessor: ScriptProcessorNode | null = null;
32
32
  private analyserNode: AnalyserNode | null = null;
33
+ // Reused across getAmplitude() calls instead of allocating a new
34
+ // Uint8Array on every call — this runs once per animation frame (~60/sec)
35
+ // from the visualizer, on the same main thread as audio capture.
36
+ private amplitudeBuffer: Uint8Array<ArrayBuffer> | null = null;
33
37
  private mediaStream: MediaStream | null = null;
34
38
  private resampler: StreamResampler | null = null;
35
39
 
@@ -123,7 +127,17 @@ export class BrowserAudioManager {
123
127
  // Create script processor for PCM extraction
124
128
  // Note: ScriptProcessorNode is deprecated but widely supported.
125
129
  // AudioWorklet would be better but requires additional setup.
126
- const bufferSize = 4096;
130
+ //
131
+ // 1024 samples is ~21ms at a typical 48kHz hardware rate — matched to
132
+ // AUDIO_CONFIG.CHUNK_DURATION_MS (20ms), the granularity the backend's
133
+ // VAD is tuned around. The previous 4096 was ~85ms of audio buffered
134
+ // before a single byte reached the server: on top of adding raw
135
+ // latency, ScriptProcessorNode callbacks run on the main thread, so
136
+ // that much buffering meant audio could arrive in large, uneven
137
+ // bursts whenever the main thread was briefly busy (a re-render, the
138
+ // visualizer) instead of a steady stream — exactly the kind of
139
+ // mistimed input that can trip server-side VAD into a false barge-in.
140
+ const bufferSize = 1024;
127
141
  this.scriptProcessor = this.audioContext!.createScriptProcessor(
128
142
  bufferSize,
129
143
  1, // input channels
@@ -165,7 +179,6 @@ export class BrowserAudioManager {
165
179
  */
166
180
  private _processAudioInput(event: AudioProcessingEvent): void {
167
181
  if (!this.onAudioInput || !this.audioContext || !this.isListening) return;
168
- if (this.isMuted) return;
169
182
 
170
183
  const inputBuffer = event.inputBuffer;
171
184
  const inputData = inputBuffer.getChannelData(0);
@@ -185,6 +198,18 @@ export class BrowserAudioManager {
185
198
 
186
199
  if (processedData.length === 0) return; // Need more data for resampler
187
200
 
201
+ // While muted, keep sending — silent — chunks instead of sending
202
+ // nothing at all (the old `if (this.isMuted) return` above this).
203
+ // The server's turn-taking/VAD is purely reactive to incoming chunks:
204
+ // if the client stops sending entirely mid-utterance, the server never
205
+ // observes the silence it needs to close out the turn, so muting
206
+ // mid-sentence left the conversation stuck instead of handing off to
207
+ // the agent. Explicitly zeroed here rather than relying solely on the
208
+ // disabled MediaStreamTrack to already read as silence.
209
+ if (this.isMuted) {
210
+ processedData = new Float32Array(processedData.length);
211
+ }
212
+
188
213
  // Convert Float32 to Int16 PCM
189
214
  const int16Data = float32ToPcm16(processedData);
190
215
  const uint8Data = new Uint8Array(
@@ -339,10 +364,12 @@ export class BrowserAudioManager {
339
364
  getAmplitude(): number {
340
365
  if (!this.analyserNode) return 0;
341
366
 
342
- const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
343
- this.analyserNode.getByteTimeDomainData(dataArray);
367
+ if (!this.amplitudeBuffer || this.amplitudeBuffer.length !== this.analyserNode.frequencyBinCount) {
368
+ this.amplitudeBuffer = new Uint8Array(this.analyserNode.frequencyBinCount);
369
+ }
370
+ this.analyserNode.getByteTimeDomainData(this.amplitudeBuffer);
344
371
 
345
- const rms = calculateRMS(dataArray);
372
+ const rms = calculateRMS(this.amplitudeBuffer);
346
373
  return Math.min(rms * 10, 1); // Boost for visualization
347
374
  }
348
375