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

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
  *
@@ -1447,6 +1518,11 @@
1447
1518
  startRealtimeWebRtcConversation();
1448
1519
  return;
1449
1520
  }
1521
+
1522
+ // Starting on WebSocket without even attempting WebRTC is a deployment fact worth stating once. The
1523
+ // console is otherwise indistinguishable from a healthy WebRTC session, so "no warning" gets read as
1524
+ // "WebRTC is working" when it can equally mean the server never offered the transport at all.
1525
+ logWebSocketTransportReason(webRtcEnabled ? 'a WebRTC attempt already failed earlier in this browser session' : 'the server did not advertise the WebRTC transport');
1450
1526
  startRealtimeWebSocketConversation();
1451
1527
  }
1452
1528
  function startRealtimeWebSocketConversation() {
@@ -1514,34 +1590,24 @@
1514
1590
  realtimeGain.connect(realtimeAudioCtx.destination);
1515
1591
  realtimeSubject = new window.signalR.Subject();
1516
1592
  var ctxAtStart = realtimeAudioCtx;
1517
- var processor = realtimeAudioCtx.createScriptProcessor(4096, 1, 1);
1518
- realtimeProcessor = processor;
1593
+
1594
+ // A zero-gain node keeps the capture node alive without echoing the mic to the speakers.
1595
+ var zeroGain = realtimeAudioCtx.createGain();
1596
+ zeroGain.gain.value = 0;
1597
+ realtimeZeroGain = zeroGain;
1598
+ zeroGain.connect(realtimeAudioCtx.destination);
1519
1599
 
1520
1600
  // The gate watches the assistant's playback to know when it is audible; on this
1521
1601
  // transport the assistant is a Web Audio graph, so tap the output gain into a stream.
1522
1602
  var monitorDest = realtimeAudioCtx.createMediaStreamDestination();
1523
1603
  realtimeGain.connect(monitorDest);
1524
1604
 
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.
1605
+ // Always send a frame (silence when muted) so the server keeps a continuous audio
1606
+ // stream and its voice-activity detector promptly notices the pause and responds.
1607
+ // Muted cases: push-to-talk not held, or the echo guard while the assistant plays back.
1608
+ // The decision stays on the main thread because that is where the push-to-talk and
1609
+ // playback state lives; the audio thread only frames the samples.
1610
+ var sendCapturedFrame = function sendCapturedFrame(input) {
1545
1611
  var muted;
1546
1612
  if (realtimePushToTalk) {
1547
1613
  muted = !realtimePttActive;
@@ -1565,12 +1631,33 @@
1565
1631
  } catch (err) {/* completed */}
1566
1632
  };
1567
1633
 
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);
1634
+ // The capture node loads a worklet, so it arrives a tick later than the rest of the
1635
+ // graph; it streams silence until the gated microphone is attached below.
1636
+ createRealtimeCaptureNode(realtimeAudioCtx, sendCapturedFrame).then(function (captureNode) {
1637
+ if (!isRealtimeActive || realtimeAudioCtx !== ctxAtStart) {
1638
+ try {
1639
+ captureNode.disconnect();
1640
+ } catch (err) {}
1641
+ return;
1642
+ }
1643
+ realtimeProcessor = captureNode;
1644
+ captureNode.connect(zeroGain);
1645
+
1646
+ // Send the gated microphone rather than the raw one, exactly as the WebRTC transport
1647
+ // does, so this fallback is not the one transport where the model hears its own echo.
1648
+ setupMicGate(stream).then(function (micTrack) {
1649
+ if (!isRealtimeActive || realtimeAudioCtx !== ctxAtStart || !micTrack) {
1650
+ return;
1651
+ }
1652
+ var gatedStream = new MediaStream([micTrack]);
1653
+ var source = realtimeAudioCtx.createMediaStreamSource(gatedStream);
1654
+ realtimeMicSource = source;
1655
+ source.connect(captureNode);
1656
+ if (realtimeGate) {
1657
+ realtimeGate.attachAssistantStream(monitorDest.stream);
1658
+ }
1659
+ });
1660
+ });
1574
1661
 
1575
1662
  // "Auto" means auto-detect: send nothing. Sending the browser's locale instead pinned transcription
1576
1663
  // and the reply language to it, so a bilingual user with an English browser speaking Spanish
@@ -1946,6 +2033,14 @@
1946
2033
  });
1947
2034
  }
1948
2035
 
2036
+ // Says why a realtime session is running on the WebSocket transport when it never attempted WebRTC.
2037
+ // The connect-time fallback reports its own reason instead (see fallbackToWebSocket).
2038
+ function logWebSocketTransportReason(reason) {
2039
+ if (window.console && console.warn) {
2040
+ console.warn('Realtime is using the WebSocket transport (' + reason + '); acoustic echo cancellation is weaker than on WebRTC.');
2041
+ }
2042
+ }
2043
+
1949
2044
  // Connect-time only: tear down the failed WebRTC attempt and restart on the known-good WebSocket path.
1950
2045
  // Never called once a session is established (see the connection-state handlers), so we never migrate audio
1951
2046
  // mid-conversation — we only choose the transport before the model starts responding.
@@ -2013,10 +2108,18 @@
2013
2108
  } catch (err) {/* already completed */}
2014
2109
  realtimeSubject = null;
2015
2110
  stopWebRtcEchoGuard();
2111
+
2112
+ // Detach both shapes the capture node can take: the worklet delivers frames over its port, the
2113
+ // ScriptProcessorNode fallback over onaudioprocess. Leaving either attached keeps pushing frames at
2114
+ // a subject that is already completed.
2016
2115
  try {
2017
2116
  if (realtimeProcessor) {
2018
2117
  realtimeProcessor.disconnect();
2019
2118
  realtimeProcessor.onaudioprocess = null;
2119
+ if (realtimeProcessor.port) {
2120
+ realtimeProcessor.port.onmessage = null;
2121
+ realtimeProcessor.port.close();
2122
+ }
2020
2123
  }
2021
2124
  } catch (err) {}
2022
2125
  try {