@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.
- package/dist/ai-chat.js +1 -1
- package/dist/ai-chat.js.map +1 -1
- package/dist/ai-chat.min.js +1 -1
- package/dist/ai-chat.min.js.map +1 -1
- package/dist/realtime-audio.js +131 -28
- package/dist/realtime-audio.js.map +1 -1
- package/dist/realtime-audio.min.js +1 -1
- package/dist/realtime-audio.min.js.map +1 -1
- package/package.json +1 -1
package/dist/realtime-audio.js
CHANGED
|
@@ -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
|
-
|
|
1518
|
-
|
|
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
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
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
|
-
//
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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 {
|