@crestapps/ai-chat-ui 2.0.0-preview.168 → 2.0.0-preview.172

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.
@@ -264,6 +264,77 @@
264
264
  // Ramp over ~10 ms rather than switching instantly, so opening and closing never clicks.
265
265
  ' var step = 1 / (sampleRate * 0.01);', ' var delay = this.delay;', ' var pos = this.delayPos;', ' var len = delay.length;', ' for (var i = 0; i < out.length; i++) {', ' var delayed = delay[pos];', ' delay[pos] = mic ? mic[i] : 0;', ' pos = (pos + 1) % len;', ' if (this.gain < target) { this.gain = Math.min(target, this.gain + step); }', ' else if (this.gain > target) { this.gain = Math.max(target, this.gain - step); }', ' out[i] = delayed * this.gain;', ' }', ' this.delayPos = pos;', ' if (currentTime * 1000 - this.lastPost > 100) {', ' this.lastPost = currentTime * 1000;', ' this.port.postMessage({ micDb: micDb, floorDb: this.state.floorDb, echoReturnDb: this.state.echoReturnDb, open: this.state.open, assistantSpeaking: this.state.assistantSpeaking });', ' }', ' return true;', ' }', '}', 'registerProcessor("coreai-mic-gate", CoreAiMicGateProcessor);'].join('\n');
266
266
 
267
+ // How many samples make up one frame sent to the server. Unchanged from the ScriptProcessorNode this
268
+ // replaces, so the server sees exactly the cadence it always has.
269
+ var REALTIME_CAPTURE_FRAME_SAMPLES = 4096;
270
+
271
+ // Microphone capture as an AudioWorkletProcessor. ScriptProcessorNode, which this replaces, has been
272
+ // deprecated for years and ran its callback on the main thread, where a long task shows up as a gap in the
273
+ // captured audio. This buffers on the audio thread and posts whole frames instead. It deliberately keeps
274
+ // emitting frames when nothing is connected to its input: the server's voice-activity detector needs a
275
+ // continuous stream to notice a pause, and the gate's own worklet takes a moment to come up.
276
+ var REALTIME_CAPTURE_WORKLET_SOURCE = ['class CoreAiCaptureProcessor extends AudioWorkletProcessor {', ' constructor(options) {', ' super();', ' var o = (options && options.processorOptions) || {};', ' this.size = Math.max(128, o.frameSamples || 4096);', ' this.buf = new Float32Array(this.size);', ' this.pos = 0;', ' }', ' process(inputs) {', ' var input = (inputs[0] && inputs[0][0]) || null;',
277
+ // A render quantum is 128 frames; with no input connected there are no channels to read, so emit the
278
+ // same length of silence rather than stalling the stream.
279
+ ' var n = input ? input.length : 128;', ' for (var i = 0; i < n; i++) {', ' this.buf[this.pos++] = input ? input[i] : 0;', ' if (this.pos === this.size) {',
280
+ // postMessage structured-clones the buffer, so the processor keeps filling its own copy.
281
+ ' this.port.postMessage(this.buf);', ' this.pos = 0;', ' }', ' }', ' return true;', ' }', '}', 'registerProcessor("coreai-pcm-capture", CoreAiCaptureProcessor);'].join('\n');
282
+
283
+ /*
284
+ * Resolves with the node that turns captured microphone audio into fixed-size frames, handing each one to
285
+ * onFrame as a Float32Array. Prefers an AudioWorkletNode and falls back to a ScriptProcessorNode only where
286
+ * AudioWorklet is unavailable, so an older browser keeps working (and keeps its deprecation warning).
287
+ */
288
+ function createRealtimeCaptureNode(ctx, onFrame) {
289
+ function scriptProcessorNode() {
290
+ var node = ctx.createScriptProcessor(REALTIME_CAPTURE_FRAME_SAMPLES, 1, 1);
291
+ node.onaudioprocess = function (event) {
292
+ onFrame(event.inputBuffer.getChannelData(0));
293
+ };
294
+ return node;
295
+ }
296
+ var blobUrl = null;
297
+ if (ctx.audioWorklet && typeof ctx.audioWorklet.addModule === 'function' && typeof AudioWorkletNode === 'function') {
298
+ try {
299
+ blobUrl = URL.createObjectURL(new Blob([REALTIME_CAPTURE_WORKLET_SOURCE], {
300
+ type: 'application/javascript'
301
+ }));
302
+ } catch (err) {
303
+ blobUrl = null;
304
+ }
305
+ }
306
+ if (!blobUrl) {
307
+ return Promise.resolve(scriptProcessorNode());
308
+ }
309
+ return ctx.audioWorklet.addModule(blobUrl).then(function () {
310
+ try {
311
+ URL.revokeObjectURL(blobUrl);
312
+ } catch (e) {}
313
+ var node = new AudioWorkletNode(ctx, 'coreai-pcm-capture', {
314
+ numberOfInputs: 1,
315
+ numberOfOutputs: 1,
316
+ outputChannelCount: [1],
317
+ processorOptions: {
318
+ frameSamples: REALTIME_CAPTURE_FRAME_SAMPLES
319
+ }
320
+ });
321
+ node.port.onmessage = function (e) {
322
+ if (e.data) {
323
+ onFrame(e.data);
324
+ }
325
+ };
326
+ return node;
327
+ })["catch"](function (err) {
328
+ try {
329
+ URL.revokeObjectURL(blobUrl);
330
+ } catch (e) {}
331
+ if (window.console && console.warn) {
332
+ console.warn('The realtime capture worklet could not be loaded; falling back to a ScriptProcessorNode.', err);
333
+ }
334
+ return scriptProcessorNode();
335
+ });
336
+ }
337
+
267
338
  /*
268
339
  * Builds a microphone gate for a captured stream.
269
340
  *
@@ -691,6 +762,13 @@
691
762
  // network where WebRTC cannot work pays the full connection timeout — and a second microphone prompt —
692
763
  // at the start of every single conversation.
693
764
  var REALTIME_WEBRTC_BLOCKED_KEY = 'coreai.realtime.webrtcBlocked';
765
+ // The ICE servers resolved for the conversation currently starting. Scoped to one attempt, not to the page:
766
+ // TURN credentials are short-lived, so every conversation resolves its own set — but within an attempt the
767
+ // transport decision and the peer must see the same servers, and one hub round-trip is enough.
768
+ var realtimeAttemptIceServers = null;
769
+ // Identifies the conversation currently starting, so an ICE resolution that lands after the user gave up
770
+ // (or started again) cannot open a session for an attempt that no longer exists.
771
+ var realtimeAttemptToken = 0;
694
772
  var realtimeSawRelayCandidate = false;
695
773
  // WebRTC half-duplex echo guard: the shared microphone gate (see createMicGate) watches both the mic and
696
774
  // the assistant's remote audio and silences the outbound track unless the user is genuinely speaking, so
@@ -1370,28 +1448,63 @@
1370
1448
  if (webRtcIceServers) {
1371
1449
  return Promise.resolve(webRtcIceServers);
1372
1450
  }
1451
+ if (realtimeAttemptIceServers) {
1452
+ return Promise.resolve(realtimeAttemptIceServers);
1453
+ }
1373
1454
  if (!connection || typeof connection.invoke !== 'function') {
1374
1455
  return Promise.resolve(DEFAULT_ICE_SERVERS);
1375
1456
  }
1376
1457
  return connection.invoke('GetRealtimeIceServers').then(function (servers) {
1377
- return Array.isArray(servers) && servers.length ? servers : DEFAULT_ICE_SERVERS;
1458
+ realtimeAttemptIceServers = Array.isArray(servers) && servers.length ? servers : DEFAULT_ICE_SERVERS;
1459
+ return realtimeAttemptIceServers;
1378
1460
  })["catch"](function (err) {
1379
1461
  if (window.console && console.warn) {
1380
1462
  console.warn('Could not resolve the realtime ICE servers; using the default STUN server.', err);
1381
1463
  }
1382
- return DEFAULT_ICE_SERVERS;
1464
+ realtimeAttemptIceServers = DEFAULT_ICE_SERVERS;
1465
+ return realtimeAttemptIceServers;
1383
1466
  });
1384
1467
  }
1385
- function isWebRtcKnownBlocked() {
1468
+
1469
+ // Identifies an ICE configuration by its server URLs, deliberately ignoring usernames and credentials:
1470
+ // ephemeral TURN credentials (Cloudflare, coturn HMAC) are reissued for every session, so including them
1471
+ // would change the fingerprint on each attempt and make the remembered failure worthless. Adding, removing
1472
+ // or repointing a STUN/TURN server does change it — which is the case that must invalidate the memory.
1473
+ function iceServersFingerprint(servers) {
1474
+ var urls = [];
1475
+ try {
1476
+ (servers || []).forEach(function (server) {
1477
+ var list = server && server.urls;
1478
+ if (typeof list === 'string') {
1479
+ urls.push(list);
1480
+ } else if (Array.isArray(list)) {
1481
+ list.forEach(function (url) {
1482
+ if (url) {
1483
+ urls.push(url);
1484
+ }
1485
+ });
1486
+ }
1487
+ });
1488
+ } catch (err) {
1489
+ return 'none';
1490
+ }
1491
+ return urls.length ? urls.sort().join('|') : 'none';
1492
+ }
1493
+
1494
+ // A remembered failure only applies to the ICE configuration that produced it. Anything else strands a
1495
+ // deployment that has since been given a TURN server on the WebSocket transport until every open tab is
1496
+ // closed, with no way for the user to tell why. Values written by older builds ('1') match no fingerprint,
1497
+ // so upgrading clears the flag on its own.
1498
+ function isWebRtcKnownBlocked(servers) {
1386
1499
  try {
1387
- return window.sessionStorage.getItem(REALTIME_WEBRTC_BLOCKED_KEY) === '1';
1500
+ return window.sessionStorage.getItem(REALTIME_WEBRTC_BLOCKED_KEY) === iceServersFingerprint(servers);
1388
1501
  } catch (err) {
1389
1502
  return false;
1390
1503
  }
1391
1504
  }
1392
1505
  function rememberWebRtcBlocked() {
1393
1506
  try {
1394
- window.sessionStorage.setItem(REALTIME_WEBRTC_BLOCKED_KEY, '1');
1507
+ window.sessionStorage.setItem(REALTIME_WEBRTC_BLOCKED_KEY, iceServersFingerprint(realtimeAttemptIceServers || webRtcIceServers));
1395
1508
  } catch (err) {}
1396
1509
  }
1397
1510
 
@@ -1432,6 +1545,8 @@
1432
1545
  return;
1433
1546
  }
1434
1547
  applyRealtimeAudioPrefs(loadRealtimeAudioPrefs());
1548
+ realtimeAttemptIceServers = null;
1549
+ var attempt = ++realtimeAttemptToken;
1435
1550
  realtimeFellBack = false;
1436
1551
  realtimeSessionReady = false;
1437
1552
  realtimeEndedNotice = null;
@@ -1439,15 +1554,34 @@
1439
1554
  bindRealtimeLifecycleHandlers();
1440
1555
  setRealtimeState('requesting-mic');
1441
1556
 
1557
+ // Starting on WebSocket without even attempting WebRTC is a deployment fact worth stating once. The
1558
+ // console is otherwise indistinguishable from a healthy WebRTC session, so "no warning" gets read as
1559
+ // "WebRTC is working" when it can equally mean the server never offered the transport at all.
1560
+ if (!webRtcEnabled) {
1561
+ logWebSocketTransportReason('the server did not advertise the WebRTC transport');
1562
+ startRealtimeWebSocketConversation();
1563
+ return;
1564
+ }
1565
+
1442
1566
  // Prefer the WebRTC transport when the server advertises it: the browser's echo canceller references
1443
1567
  // the assistant's media track, so the model can ignore its own voice with the mic open (open rooms).
1444
1568
  // If the peer cannot connect (blocked UDP, no TURN, unsupported), we fall back to WebSocket at connect
1445
1569
  // time — see fallbackToWebSocket. The decision is made once, before the session starts.
1446
- if (webRtcEnabled && !isWebRtcKnownBlocked()) {
1570
+ //
1571
+ // Resolving the ICE servers before choosing a transport costs no extra round-trip — the peer reuses
1572
+ // realtimeAttemptIceServers — and it is what lets a remembered failure be scoped to the configuration
1573
+ // that actually caused it.
1574
+ resolveIceServers().then(function (servers) {
1575
+ if (attempt !== realtimeAttemptToken || !isRealtimeMode || isRealtimeActive) {
1576
+ return;
1577
+ }
1578
+ if (isWebRtcKnownBlocked(servers)) {
1579
+ logWebSocketTransportReason('a WebRTC attempt already failed earlier in this browser session with the same ICE configuration');
1580
+ startRealtimeWebSocketConversation();
1581
+ return;
1582
+ }
1447
1583
  startRealtimeWebRtcConversation();
1448
- return;
1449
- }
1450
- startRealtimeWebSocketConversation();
1584
+ });
1451
1585
  }
1452
1586
  function startRealtimeWebSocketConversation() {
1453
1587
  if (webRtcEnabled) {
@@ -1514,34 +1648,24 @@
1514
1648
  realtimeGain.connect(realtimeAudioCtx.destination);
1515
1649
  realtimeSubject = new window.signalR.Subject();
1516
1650
  var ctxAtStart = realtimeAudioCtx;
1517
- var processor = realtimeAudioCtx.createScriptProcessor(4096, 1, 1);
1518
- realtimeProcessor = processor;
1651
+
1652
+ // A zero-gain node keeps the capture node alive without echoing the mic to the speakers.
1653
+ var zeroGain = realtimeAudioCtx.createGain();
1654
+ zeroGain.gain.value = 0;
1655
+ realtimeZeroGain = zeroGain;
1656
+ zeroGain.connect(realtimeAudioCtx.destination);
1519
1657
 
1520
1658
  // The gate watches the assistant's playback to know when it is audible; on this
1521
1659
  // transport the assistant is a Web Audio graph, so tap the output gain into a stream.
1522
1660
  var monitorDest = realtimeAudioCtx.createMediaStreamDestination();
1523
1661
  realtimeGain.connect(monitorDest);
1524
1662
 
1525
- // Send the gated microphone rather than the raw one, exactly as the WebRTC transport
1526
- // does, so this fallback is not the one transport where the model hears its own echo.
1527
- // Until the gate's worklet is ready the processor has no input and streams silence.
1528
- setupMicGate(stream).then(function (micTrack) {
1529
- if (!isRealtimeActive || realtimeAudioCtx !== ctxAtStart || !micTrack) {
1530
- return;
1531
- }
1532
- var gatedStream = new MediaStream([micTrack]);
1533
- var source = realtimeAudioCtx.createMediaStreamSource(gatedStream);
1534
- realtimeMicSource = source;
1535
- source.connect(processor);
1536
- if (realtimeGate) {
1537
- realtimeGate.attachAssistantStream(monitorDest.stream);
1538
- }
1539
- });
1540
- processor.onaudioprocess = function (event) {
1541
- var input = event.inputBuffer.getChannelData(0);
1542
- // Always send a frame (silence when muted) so the server keeps a continuous audio
1543
- // stream and its voice-activity detector promptly notices the pause and responds.
1544
- // Muted cases: push-to-talk not held, or the echo guard while the assistant plays back.
1663
+ // Always send a frame (silence when muted) so the server keeps a continuous audio
1664
+ // stream and its voice-activity detector promptly notices the pause and responds.
1665
+ // Muted cases: push-to-talk not held, or the echo guard while the assistant plays back.
1666
+ // The decision stays on the main thread because that is where the push-to-talk and
1667
+ // playback state lives; the audio thread only frames the samples.
1668
+ var sendCapturedFrame = function sendCapturedFrame(input) {
1545
1669
  var muted;
1546
1670
  if (realtimePushToTalk) {
1547
1671
  muted = !realtimePttActive;
@@ -1565,12 +1689,33 @@
1565
1689
  } catch (err) {/* completed */}
1566
1690
  };
1567
1691
 
1568
- // A zero-gain node keeps the processor alive without echoing the mic to the speakers.
1569
- var zeroGain = realtimeAudioCtx.createGain();
1570
- zeroGain.gain.value = 0;
1571
- realtimeZeroGain = zeroGain;
1572
- processor.connect(zeroGain);
1573
- zeroGain.connect(realtimeAudioCtx.destination);
1692
+ // The capture node loads a worklet, so it arrives a tick later than the rest of the
1693
+ // graph; it streams silence until the gated microphone is attached below.
1694
+ createRealtimeCaptureNode(realtimeAudioCtx, sendCapturedFrame).then(function (captureNode) {
1695
+ if (!isRealtimeActive || realtimeAudioCtx !== ctxAtStart) {
1696
+ try {
1697
+ captureNode.disconnect();
1698
+ } catch (err) {}
1699
+ return;
1700
+ }
1701
+ realtimeProcessor = captureNode;
1702
+ captureNode.connect(zeroGain);
1703
+
1704
+ // Send the gated microphone rather than the raw one, exactly as the WebRTC transport
1705
+ // does, so this fallback is not the one transport where the model hears its own echo.
1706
+ setupMicGate(stream).then(function (micTrack) {
1707
+ if (!isRealtimeActive || realtimeAudioCtx !== ctxAtStart || !micTrack) {
1708
+ return;
1709
+ }
1710
+ var gatedStream = new MediaStream([micTrack]);
1711
+ var source = realtimeAudioCtx.createMediaStreamSource(gatedStream);
1712
+ realtimeMicSource = source;
1713
+ source.connect(captureNode);
1714
+ if (realtimeGate) {
1715
+ realtimeGate.attachAssistantStream(monitorDest.stream);
1716
+ }
1717
+ });
1718
+ });
1574
1719
 
1575
1720
  // "Auto" means auto-detect: send nothing. Sending the browser's locale instead pinned transcription
1576
1721
  // and the reply language to it, so a bilingual user with an English browser speaking Spanish
@@ -1946,6 +2091,14 @@
1946
2091
  });
1947
2092
  }
1948
2093
 
2094
+ // Says why a realtime session is running on the WebSocket transport when it never attempted WebRTC.
2095
+ // The connect-time fallback reports its own reason instead (see fallbackToWebSocket).
2096
+ function logWebSocketTransportReason(reason) {
2097
+ if (window.console && console.warn) {
2098
+ console.warn('Realtime is using the WebSocket transport (' + reason + '); acoustic echo cancellation is weaker than on WebRTC.');
2099
+ }
2100
+ }
2101
+
1949
2102
  // Connect-time only: tear down the failed WebRTC attempt and restart on the known-good WebSocket path.
1950
2103
  // Never called once a session is established (see the connection-state handlers), so we never migrate audio
1951
2104
  // mid-conversation — we only choose the transport before the model starts responding.
@@ -2013,10 +2166,18 @@
2013
2166
  } catch (err) {/* already completed */}
2014
2167
  realtimeSubject = null;
2015
2168
  stopWebRtcEchoGuard();
2169
+
2170
+ // Detach both shapes the capture node can take: the worklet delivers frames over its port, the
2171
+ // ScriptProcessorNode fallback over onaudioprocess. Leaving either attached keeps pushing frames at
2172
+ // a subject that is already completed.
2016
2173
  try {
2017
2174
  if (realtimeProcessor) {
2018
2175
  realtimeProcessor.disconnect();
2019
2176
  realtimeProcessor.onaudioprocess = null;
2177
+ if (realtimeProcessor.port) {
2178
+ realtimeProcessor.port.onmessage = null;
2179
+ realtimeProcessor.port.close();
2180
+ }
2020
2181
  }
2021
2182
  } catch (err) {}
2022
2183
  try {