@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.
- 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 +198 -37
- 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
|
*
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1464
|
+
realtimeAttemptIceServers = DEFAULT_ICE_SERVERS;
|
|
1465
|
+
return realtimeAttemptIceServers;
|
|
1383
1466
|
});
|
|
1384
1467
|
}
|
|
1385
|
-
|
|
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) ===
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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 {
|