@alexkroman1/aai-ui 5.9.0 → 5.10.0
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/audio.d.ts +10 -0
- package/dist/audio.js +42 -15
- package/dist/client-dir.d.ts +42 -0
- package/dist/client-dir.js +56 -0
- package/dist/default-client/assets/audio-DJF-gC7e.js +1 -0
- package/dist/default-client/assets/{capture-processor-kAhuEwHv.js → capture-processor-D_A9RUeB.js} +1 -1
- package/dist/default-client/assets/index-1JIz81CD.css +2 -0
- package/dist/default-client/assets/index-D_RNiTIh.js +278 -0
- package/dist/default-client/assets/{playback-processor-X5id-na1.js → playback-processor-GPTQTyza.js} +37 -4
- package/dist/default-client/index.html +2 -2
- package/dist/define-client.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{session-core-DqibBzJz.js → session-core-CS37WE1G.js} +36 -18
- package/dist/session-core-messages.d.ts +5 -3
- package/dist/session-core-types.d.ts +13 -0
- package/dist/session-core.js +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +2 -2
- package/dist/worklets/playback-processor.d.ts +1 -1
- package/dist/worklets/playback-processor.js +37 -4
- package/package.json +9 -4
- package/dist/default-client/assets/audio-v_cWeUOV.js +0 -1
- package/dist/default-client/assets/index-CuPmIAbL.css +0 -2
- package/dist/default-client/assets/index-fLjOiK97.js +0 -192
package/dist/default-client/assets/{playback-processor-X5id-na1.js → playback-processor-GPTQTyza.js}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{i as e}from"./index-
|
|
1
|
+
import{i as e}from"./index-D_RNiTIh.js";import{t}from"./_module-url-BX0RuRU2.js";var n=t(`
|
|
2
2
|
class PlaybackProcessor extends AudioWorkletProcessor {
|
|
3
3
|
constructor(options) {
|
|
4
4
|
super();
|
|
@@ -26,6 +26,11 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
26
26
|
// playing instead of writing past the end and going silent.
|
|
27
27
|
this.capacity = rate * 60;
|
|
28
28
|
this.samples = new Float32Array(this.capacity);
|
|
29
|
+
// Cadence for the 'progress' report in process(), counted in samples so
|
|
30
|
+
// the hot path never reads a clock. Survives resetTurn(): the host clamps
|
|
31
|
+
// upward only, so a stale count costs at most one extra report.
|
|
32
|
+
this.reportIntervalSamples = Math.floor((rate * 500) / 1000);
|
|
33
|
+
this.sinceReportSamples = 0;
|
|
29
34
|
// Platform endianness probe: the wire format is PCM16 little-endian, so
|
|
30
35
|
// the Int16Array fast path in ingestBytes is only valid on LE hosts
|
|
31
36
|
// (every shipping browser target; the DataView path is the fallback).
|
|
@@ -45,6 +50,10 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
45
50
|
this.stopTurn('interrupt');
|
|
46
51
|
} else if (d.event === 'done') {
|
|
47
52
|
this.isDone = true;
|
|
53
|
+
// Echoed back on this turn's 'stop' so the host can tell WHICH turn
|
|
54
|
+
// drained: a stop already in flight when a barge-in lands would
|
|
55
|
+
// otherwise settle the next turn's done() the moment it arrives.
|
|
56
|
+
this.doneTurn = d.turn ?? null;
|
|
48
57
|
}
|
|
49
58
|
};
|
|
50
59
|
}
|
|
@@ -53,6 +62,8 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
53
62
|
// reallocating the sample buffer or re-instantiating the worklet.
|
|
54
63
|
resetTurn() {
|
|
55
64
|
this.isDone = false;
|
|
65
|
+
// Host turn id carried by this turn's 'done' (null until one arrives).
|
|
66
|
+
this.doneTurn = null;
|
|
56
67
|
this.playing = false;
|
|
57
68
|
// Whether any real audio has been rendered this turn. Separates a turn's
|
|
58
69
|
// pre-roll (nothing to extrapolate from, and not a defect) from a
|
|
@@ -85,10 +96,12 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
85
96
|
// Must NOT return false from process() — a processor that stops is dead
|
|
86
97
|
// for good, forcing a new node (and buffer) per reply.
|
|
87
98
|
// \`reason\` ('interrupt' | 'done') tells the host which turn boundary this
|
|
88
|
-
// stop belongs to
|
|
89
|
-
// settled that turn
|
|
99
|
+
// stop belongs to — interrupt-stops are dropped host-side, flush() having
|
|
100
|
+
// already settled that turn — and \`turn\` names WHICH turn drained (the id
|
|
101
|
+
// this turn's 'done' carried), so a drain-stop still in flight when a
|
|
102
|
+
// barge-in lands cannot settle the next turn's done() early.
|
|
90
103
|
stopTurn(reason) {
|
|
91
|
-
this.port.postMessage({ event: 'stop', reason, stats: this.stats });
|
|
104
|
+
this.port.postMessage({ event: 'stop', reason, turn: this.doneTurn, stats: this.stats });
|
|
92
105
|
this.resetTurn();
|
|
93
106
|
}
|
|
94
107
|
|
|
@@ -225,6 +238,26 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
225
238
|
const out = outputs[0][0];
|
|
226
239
|
const avail = this.writePos - this.readPos;
|
|
227
240
|
|
|
241
|
+
// Report the unplayed backlog to the host (\`playback_progress\`). This IS
|
|
242
|
+
// the closed-loop signal: the host otherwise models playback open-loop —
|
|
243
|
+
// every forwarded chunk assumed to start playing on arrival at exactly
|
|
244
|
+
// 1.0x — and cannot see a buffer that has run ahead of the wall clock.
|
|
245
|
+
// Measured against a client draining at 0.6x, the host declared the line
|
|
246
|
+
// silent while it still held seconds of the reply, and then opened the
|
|
247
|
+
// speaking-edge gate over speech the caller had not heard.
|
|
248
|
+
//
|
|
249
|
+
// Sent from process() rather than a timer because this is the only place
|
|
250
|
+
// that sees the buffer on the audio thread; the counter is in QUANTA, so
|
|
251
|
+
// it costs an integer compare per render and no clock read. Silence is not
|
|
252
|
+
// reported: an empty buffer is what the host already assumes.
|
|
253
|
+
this.sinceReportSamples += out.length;
|
|
254
|
+
if (this.sinceReportSamples >= this.reportIntervalSamples) {
|
|
255
|
+
this.sinceReportSamples = 0;
|
|
256
|
+
if (avail > 0) {
|
|
257
|
+
this.port.postMessage({ event: 'progress', bufferedMs: (avail / sampleRate) * 1000 });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
228
261
|
// Filling: wait for the target. 'done' short-circuits it — what is
|
|
229
262
|
// buffered is all there will be, so there is nothing left to wait for.
|
|
230
263
|
if (!this.playing) {
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<title>aai</title>
|
|
7
7
|
<link rel="icon" href="./favicon.ico" />
|
|
8
8
|
<style>html, body { background: #FBF8F2; margin: 0; }</style>
|
|
9
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-D_RNiTIh.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-1JIz81CD.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<main id="app"></main>
|
package/dist/define-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as fetchClientConfig, t as createSessionCore } from "./session-core-
|
|
1
|
+
import { r as fetchClientConfig, t as createSessionCore } from "./session-core-CS37WE1G.js";
|
|
2
2
|
import { SessionProvider, ThemeProvider } from "./context.js";
|
|
3
3
|
import { t as ChatView } from "./chat-view-DFBmX5eX.js";
|
|
4
4
|
import { t as pageBaseUrl } from "./_utils-CsqbIVGI.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as loadClientConfig, n as buildAgentUrl, r as fetchClientConfig, t as createSessionCore } from "./session-core-
|
|
1
|
+
import { i as loadClientConfig, n as buildAgentUrl, r as fetchClientConfig, t as createSessionCore } from "./session-core-CS37WE1G.js";
|
|
2
2
|
import { SessionProvider, ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
|
|
3
3
|
import { Button } from "./components/button.js";
|
|
4
4
|
import { t as ChatView } from "./chat-view-DFBmX5eX.js";
|
|
@@ -136,6 +136,15 @@ async function initAudioCapture(conn, msg, deps) {
|
|
|
136
136
|
console.debug("[aai-ui] sendAudio dropped: connection closed");
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
|
+
onPlaybackProgress: (bufferedMs) => {
|
|
140
|
+
if (!conn.generation.isCurrent(gen)) return;
|
|
141
|
+
try {
|
|
142
|
+
deps.sendJson({
|
|
143
|
+
type: "playback_progress",
|
|
144
|
+
bufferedMs
|
|
145
|
+
});
|
|
146
|
+
} catch {}
|
|
147
|
+
},
|
|
139
148
|
onError: (err) => {
|
|
140
149
|
if (!conn.generation.isCurrent(gen)) return;
|
|
141
150
|
reportAudioFailure(err.message);
|
|
@@ -207,14 +216,14 @@ function appendCapped(list, item, cap) {
|
|
|
207
216
|
/**
|
|
208
217
|
* Create the server→client message handlers for one session core.
|
|
209
218
|
*
|
|
210
|
-
* Encapsulates the
|
|
211
|
-
*
|
|
212
|
-
*
|
|
219
|
+
* Encapsulates the per-session dedup counters (`customEventSeq`,
|
|
220
|
+
* `messageSeq`, `toolCallSeq`) that previously lived as closure locals in
|
|
221
|
+
* `createSessionCore`. The turn-boundary epoch is NOT one of them: it lives
|
|
222
|
+
* on `conn` because the session core bumps it on teardown too (see
|
|
223
|
+
* `ConnState.turn`).
|
|
213
224
|
*/
|
|
214
225
|
function createMessageHandlers(deps) {
|
|
215
226
|
const { getSnapshot, updateState, conn, cleanupAudio } = deps;
|
|
216
|
-
/** Bumped on each turn boundary -- stale async callbacks check against this. */
|
|
217
|
-
const turnEpoch = createEpoch();
|
|
218
227
|
/** Monotonically increasing counter for custom events -- used by useEvent to deduplicate. */
|
|
219
228
|
let customEventSeq = 0;
|
|
220
229
|
/** Monotonically increasing counter for chat messages -- stable render keys
|
|
@@ -231,7 +240,7 @@ function createMessageHandlers(deps) {
|
|
|
231
240
|
}, MAX_CUSTOM_EVENTS) });
|
|
232
241
|
}
|
|
233
242
|
function handleUserTranscriptEvent(text) {
|
|
234
|
-
|
|
243
|
+
conn.turn.bump();
|
|
235
244
|
updateState({
|
|
236
245
|
userTranscript: null,
|
|
237
246
|
messages: appendCapped(getSnapshot().messages, {
|
|
@@ -243,11 +252,15 @@ function createMessageHandlers(deps) {
|
|
|
243
252
|
});
|
|
244
253
|
}
|
|
245
254
|
/**
|
|
246
|
-
* `agent_transcript` carries the reply's text
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
255
|
+
* `agent_transcript` carries the reply's text so far as a full-replacement
|
|
256
|
+
* snapshot (see the protocol schema), so it renders as the live assistant
|
|
257
|
+
* bubble and only becomes a message when the reply closes. Pipeline mode sends
|
|
258
|
+
* one per piece of speech, so appending each would break a single reply into a
|
|
259
|
+
* message per sentence.
|
|
260
|
+
*
|
|
261
|
+
* Replace rather than merge: a snapshot is not append-only. A pipeline reply's
|
|
262
|
+
* closing snapshot drops the dead-air filler the interim ones carried, so it
|
|
263
|
+
* can be shorter than its predecessor and differ mid-string.
|
|
251
264
|
*/
|
|
252
265
|
function handleAgentTranscriptEvent(text) {
|
|
253
266
|
updateState({ agentTranscript: text });
|
|
@@ -350,7 +363,7 @@ function createMessageHandlers(deps) {
|
|
|
350
363
|
updateState({ state: "listening" });
|
|
351
364
|
break;
|
|
352
365
|
case "cancelled":
|
|
353
|
-
|
|
366
|
+
conn.turn.bump();
|
|
354
367
|
conn.voiceIO?.flush();
|
|
355
368
|
commitAgentTranscript();
|
|
356
369
|
updateState({
|
|
@@ -359,7 +372,7 @@ function createMessageHandlers(deps) {
|
|
|
359
372
|
});
|
|
360
373
|
break;
|
|
361
374
|
case "reset":
|
|
362
|
-
|
|
375
|
+
conn.turn.bump();
|
|
363
376
|
conn.voiceIO?.flush();
|
|
364
377
|
updateState({
|
|
365
378
|
...CLEARED_SESSION_STATE,
|
|
@@ -390,12 +403,13 @@ function createMessageHandlers(deps) {
|
|
|
390
403
|
else if (conn.preInitAudio.length < MAX_PREINIT_AUDIO_CHUNKS) conn.preInitAudio.push(chunk);
|
|
391
404
|
}
|
|
392
405
|
/** See {@link MessageHandlers.settleWhenAudioDrained}. Captures
|
|
393
|
-
* `
|
|
394
|
-
* boundary
|
|
406
|
+
* `conn.turn` so a completion (or failure) that lands after a turn
|
|
407
|
+
* boundary — including an audio-path teardown — is discarded instead of
|
|
408
|
+
* overwriting the newer turn's (or the dead session's) state. */
|
|
395
409
|
function settleWhenAudioDrained(io) {
|
|
396
|
-
const gen =
|
|
410
|
+
const gen = conn.turn.current();
|
|
397
411
|
io.done().then(() => {
|
|
398
|
-
if (!
|
|
412
|
+
if (!conn.turn.isCurrent(gen)) return;
|
|
399
413
|
updateState({ state: "listening" });
|
|
400
414
|
}).catch((err) => {
|
|
401
415
|
console.warn("Audio playback done failed:", err);
|
|
@@ -404,7 +418,7 @@ function createMessageHandlers(deps) {
|
|
|
404
418
|
/**
|
|
405
419
|
* Signal that the server has finished sending audio for this turn.
|
|
406
420
|
* Waits for the audio queue to drain, then transitions state to `"listening"`.
|
|
407
|
-
* Uses
|
|
421
|
+
* Uses `conn.turn` to discard stale completions from interrupted turns.
|
|
408
422
|
*/
|
|
409
423
|
function playAudioDone() {
|
|
410
424
|
const io = conn.voiceIO;
|
|
@@ -598,6 +612,7 @@ function createSessionCore(options) {
|
|
|
598
612
|
voiceIO: null,
|
|
599
613
|
audioSetupInFlight: false,
|
|
600
614
|
generation: createEpoch(),
|
|
615
|
+
turn: createEpoch(),
|
|
601
616
|
preInitAudio: [],
|
|
602
617
|
preInitDone: false
|
|
603
618
|
};
|
|
@@ -623,6 +638,7 @@ function createSessionCore(options) {
|
|
|
623
638
|
let serverIsBroker;
|
|
624
639
|
function cleanupAudio() {
|
|
625
640
|
conn.audioSetupInFlight = false;
|
|
641
|
+
conn.turn.bump();
|
|
626
642
|
conn.voiceIO?.close().catch(() => {});
|
|
627
643
|
conn.voiceIO = null;
|
|
628
644
|
conn.preInitAudio = [];
|
|
@@ -775,11 +791,13 @@ function createSessionCore(options) {
|
|
|
775
791
|
}
|
|
776
792
|
function cancel() {
|
|
777
793
|
if (!conn.ws || conn.ws.readyState !== WS_OPEN) return;
|
|
794
|
+
conn.turn.bump();
|
|
778
795
|
conn.voiceIO?.flush();
|
|
779
796
|
updateState({ state: "listening" });
|
|
780
797
|
sendJson({ type: "cancel" });
|
|
781
798
|
}
|
|
782
799
|
function reset() {
|
|
800
|
+
conn.turn.bump();
|
|
783
801
|
conn.voiceIO?.flush();
|
|
784
802
|
if (conn.ws && conn.ws.readyState === WS_OPEN) {
|
|
785
803
|
sendJson({ type: "reset" });
|
|
@@ -51,9 +51,11 @@ type MessageHandlers = {
|
|
|
51
51
|
/**
|
|
52
52
|
* Create the server→client message handlers for one session core.
|
|
53
53
|
*
|
|
54
|
-
* Encapsulates the
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* Encapsulates the per-session dedup counters (`customEventSeq`,
|
|
55
|
+
* `messageSeq`, `toolCallSeq`) that previously lived as closure locals in
|
|
56
|
+
* `createSessionCore`. The turn-boundary epoch is NOT one of them: it lives
|
|
57
|
+
* on `conn` because the session core bumps it on teardown too (see
|
|
58
|
+
* `ConnState.turn`).
|
|
57
59
|
*/
|
|
58
60
|
export declare function createMessageHandlers(deps: MessageHandlerDeps): MessageHandlers;
|
|
59
61
|
export {};
|
|
@@ -157,6 +157,19 @@ export type ConnState = {
|
|
|
157
157
|
* Prevents a stale initAudioCapture from assigning its voiceIO to a newer
|
|
158
158
|
* connection. */
|
|
159
159
|
generation: Epoch;
|
|
160
|
+
/**
|
|
161
|
+
* Turn epoch, bumped at every turn boundary — a committed user turn, a
|
|
162
|
+
* barge-in, a reset, AND every audio-path teardown (`cleanupAudio`).
|
|
163
|
+
*
|
|
164
|
+
* Async playback completions (`settleWhenAudioDrained`) capture it and
|
|
165
|
+
* discard themselves when it has moved. The teardown bumps are why it lives
|
|
166
|
+
* here rather than as a closure local in the message handlers: a reply's
|
|
167
|
+
* drain resolves whenever the AudioContext closes, so a session torn down
|
|
168
|
+
* mid-speech (hang up, fatal error, reconnect) would otherwise have
|
|
169
|
+
* `state: "listening"` written over its "disconnected"/"error" a moment
|
|
170
|
+
* later — a dead session reporting a live mic.
|
|
171
|
+
*/
|
|
172
|
+
turn: Epoch;
|
|
160
173
|
/** Audio chunks that arrived before `voiceIO` was initialized — drained into
|
|
161
174
|
* the playback worklet once init completes. Closes the race between the
|
|
162
175
|
* server starting greeting audio (immediately on S2S connect) and the
|
package/dist/session-core.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as createSessionCore } from "./session-core-
|
|
1
|
+
import { t as createSessionCore } from "./session-core-CS37WE1G.js";
|
|
2
2
|
export { createSessionCore };
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DefaultToolResult } from "@alexkroman1/aai";
|
|
2
2
|
import type { SessionErrorCode } from "@alexkroman1/aai/protocol";
|
|
3
|
-
export { CAPTURE_STOP_ACK_TIMEOUT_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_DONE_MAX_WAIT_MS, PLAYBACK_DONE_POLL_MS, PLAYBACK_JITTER_MS, PLAYBACK_REFILL_MS, } from "@alexkroman1/aai";
|
|
3
|
+
export { CAPTURE_STOP_ACK_TIMEOUT_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_DONE_MAX_WAIT_MS, PLAYBACK_DONE_POLL_MS, PLAYBACK_JITTER_MS, PLAYBACK_PROGRESS_INTERVAL_MS, PLAYBACK_REFILL_MS, } from "@alexkroman1/aai";
|
|
4
4
|
/**
|
|
5
5
|
* `getUserMedia` audio constraints for every capture path in this package.
|
|
6
6
|
*
|
package/dist/types.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CAPTURE_STOP_ACK_TIMEOUT_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_DONE_MAX_WAIT_MS, PLAYBACK_DONE_POLL_MS, PLAYBACK_JITTER_MS, PLAYBACK_REFILL_MS } from "@alexkroman1/aai";
|
|
1
|
+
import { CAPTURE_STOP_ACK_TIMEOUT_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_DONE_MAX_WAIT_MS, PLAYBACK_DONE_POLL_MS, PLAYBACK_JITTER_MS, PLAYBACK_PROGRESS_INTERVAL_MS, PLAYBACK_REFILL_MS } from "@alexkroman1/aai";
|
|
2
2
|
//#region types.ts
|
|
3
3
|
/**
|
|
4
4
|
* `getUserMedia` audio constraints for every capture path in this package.
|
|
@@ -32,4 +32,4 @@ const VOICE_CAPTURE_CONSTRAINTS = {
|
|
|
32
32
|
voiceIsolation: false
|
|
33
33
|
};
|
|
34
34
|
//#endregion
|
|
35
|
-
export { CAPTURE_STOP_ACK_TIMEOUT_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_DONE_MAX_WAIT_MS, PLAYBACK_DONE_POLL_MS, PLAYBACK_JITTER_MS, PLAYBACK_REFILL_MS, VOICE_CAPTURE_CONSTRAINTS };
|
|
35
|
+
export { CAPTURE_STOP_ACK_TIMEOUT_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_DONE_MAX_WAIT_MS, PLAYBACK_DONE_POLL_MS, PLAYBACK_JITTER_MS, PLAYBACK_PROGRESS_INTERVAL_MS, PLAYBACK_REFILL_MS, VOICE_CAPTURE_CONSTRAINTS };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Raw worklet source — exported so tests can evaluate the processor directly. */
|
|
2
|
-
export declare const playbackProcessorSource = "\nclass PlaybackProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n const opts = options.processorOptions || {};\n // The node's context IS the playback context (audio.ts asserts its rate),\n // so the worklet-global sampleRate is authoritative; the option exists\n // for the node-less test harness.\n const rate = opts.sampleRate ?? sampleRate;\n // Fill target for the start of a turn. If 'done' arrives first (short\n // utterance), start immediately instead of waiting for audio that is\n // never coming.\n this.jitterSamples = Math.floor((rate * (opts.jitterMs ?? 400)) / 1000);\n // Fill target after an underrun \u2014 see PLAYBACK_REFILL_MS.\n this.refillSamples = Math.floor((rate * (opts.refillMs ?? 200)) / 1000);\n // Concealment source: a ring of the most recently played samples, looped\n // under a decaying gain to cover a gap. Sized to the fade window, with a\n // per-sample decay that reaches the floor exactly at its end.\n this.concealCapacity = Math.max(1, Math.floor((rate * 40) / 1000));\n this.concealBuf = new Float32Array(this.concealCapacity);\n this.concealDecay = Math.exp(Math.log(0.001) / this.concealCapacity);\n // Float32 ring buffer \u2014 PLAYBACK_BUFFER_SECONDS at the context sample\n // rate. Allocated once for the node's lifetime; per-turn state resets via\n // resetTurn(). writePos and readPos are absolute (monotonic) sample\n // counts; the buffer is indexed modulo capacity so a longer reply keeps\n // playing instead of writing past the end and going silent.\n this.capacity = rate * 60;\n this.samples = new Float32Array(this.capacity);\n // Platform endianness probe: the wire format is PCM16 little-endian, so\n // the Int16Array fast path in ingestBytes is only valid on LE hosts\n // (every shipping browser target; the DataView path is the fallback).\n this.littleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;\n this.resetTurn();\n\n this.port.onmessage = (e) => {\n const d = e.data;\n if (d.event === 'write') {\n this.ingestBytes(d.buffer);\n } else if (d.event === 'interrupt') {\n // Applied eagerly, not deferred to the next process(): onmessage and\n // process() never interleave (one audio thread), and a deferred\n // interrupt would let 'write'/'done' frames for the NEXT turn\n // coalesce in ahead of it and be wiped by the reset along with the\n // cancelled turn's audio.\n this.stopTurn('interrupt');\n } else if (d.event === 'done') {\n this.isDone = true;\n }\n };\n }\n\n // Reset per-turn state so the node is reusable across replies without\n // reallocating the sample buffer or re-instantiating the worklet.\n resetTurn() {\n this.isDone = false;\n this.playing = false;\n // Whether any real audio has been rendered this turn. Separates a turn's\n // pre-roll (nothing to extrapolate from, and not a defect) from a\n // mid-turn underrun.\n this.hasPlayed = false;\n this.fillTarget = this.jitterSamples;\n // Carry-over byte for split samples across chunks\n this.carry = null;\n this.writePos = 0;\n this.readPos = 0;\n // Concealment ring state and the current fade position.\n this.concealLen = 0;\n this.concealWrite = 0;\n this.concealPos = 0;\n this.concealGain = 1;\n // Episode flags, so a multi-quantum gap counts as one event.\n this.concealing = false;\n this.concealedSilence = false;\n // Reported to the host on 'stop'. A fresh object per turn: the one just\n // posted must not be mutated by the next turn.\n this.stats = {\n concealedSamples: 0,\n silentConcealedSamples: 0,\n concealmentEvents: 0,\n silentConcealmentEvents: 0,\n };\n }\n\n // End the current turn: notify the host and rearm for the next reply.\n // Must NOT return false from process() \u2014 a processor that stops is dead\n // for good, forcing a new node (and buffer) per reply.\n // `reason` ('interrupt' | 'done') tells the host which turn boundary this\n // stop belongs to: interrupt-stops are dropped host-side (flush() already\n // settled that turn), so they can never resolve a later turn's done() early.\n stopTurn(reason) {\n this.port.postMessage({ event: 'stop', reason, stats: this.stats });\n this.resetTurn();\n }\n\n // Cover a quantum (from `start`) where real audio should have been.\n //\n // Before the turn's first samples there is nothing to extrapolate from, so\n // the gap is plain silence and counted as nothing \u2014 WebRTC likewise only\n // counts concealment once playout has begun. After that, loop the retained\n // tail under a decaying gain: a hard zero-fill is a discontinuity mid-word,\n // which is the click that makes a brief stall sound like breakage.\n coverGap(out, start) {\n if (!this.hasPlayed) {\n out.fill(0, start);\n return;\n }\n if (!this.concealing) {\n this.concealing = true;\n this.concealedSilence = false;\n this.stats.concealmentEvents++;\n }\n const total = out.length - start;\n const len = this.concealLen;\n let silent = 0;\n // Second condition: once the fade has decayed to the floor, every sample\n // the loop would emit is 0 anyway \u2014 bulk-fill instead of running 128\n // branchy iterations per quantum for the whole tail of a long stall.\n if (len === 0 || this.concealGain < 0.001) {\n out.fill(0, start);\n silent = total;\n } else {\n let g = this.concealGain;\n for (let i = start; i < out.length; i++) {\n if (g < 0.001) {\n // The fade has run out: keep counting the gap, but stop looping a\n // fragment that is now inaudible anyway.\n out[i] = 0;\n silent++;\n continue;\n }\n out[i] = this.concealBuf[this.concealPos] * g;\n this.concealPos = this.concealPos + 1 === len ? 0 : this.concealPos + 1;\n g *= this.concealDecay;\n }\n this.concealGain = g;\n }\n this.stats.concealedSamples += total;\n if (silent > 0) {\n this.stats.silentConcealedSamples += silent;\n if (!this.concealedSilence) {\n this.concealedSilence = true;\n this.stats.silentConcealmentEvents++;\n }\n }\n }\n\n // Retain the tail of a rendered quantum as the next gap's concealment\n // source, and close any episode the real audio just ended.\n rememberTail(out, n) {\n const cap = this.concealCapacity;\n const take = Math.min(n, cap);\n // Bulk copies (this runs on every cleanly rendered quantum): the tail is\n // one contiguous source run, landing in at most two ring runs.\n const tail = out.subarray(n - take, n);\n const first = Math.min(take, cap - this.concealWrite);\n this.concealBuf.set(tail.subarray(0, first), this.concealWrite);\n if (take > first) this.concealBuf.set(tail.subarray(first), 0);\n this.concealWrite = (this.concealWrite + take) % cap;\n this.concealLen = Math.min(cap, this.concealLen + take);\n // Read the loop oldest-first; once the ring is full the write cursor is\n // the oldest retained sample.\n this.concealPos = this.concealLen === cap ? this.concealWrite : 0;\n this.concealing = false;\n this.concealGain = 1;\n }\n\n ingestBytes(uint8) {\n let bytes = uint8;\n\n if (this.carry !== null) {\n const merged = new Uint8Array(1 + bytes.length);\n merged[0] = this.carry;\n merged.set(bytes, 1);\n bytes = merged;\n this.carry = null;\n }\n\n if (bytes.length % 2 !== 0) {\n this.carry = bytes[bytes.length - 1];\n bytes = bytes.subarray(0, bytes.length - 1);\n }\n\n if (bytes.length === 0) return;\n const numSamples = bytes.length / 2;\n const cap = this.capacity;\n const samples = this.samples;\n if (this.littleEndian && (bytes.byteOffset & 1) === 0) {\n // Fast path: 2-byte-aligned LE bytes wrap directly as an Int16Array;\n // copy wrap-aware in at most two runs with no per-sample DataView call\n // or modulo. This runs on the realtime audio thread.\n const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, numSamples);\n let src = 0;\n let dst = this.writePos % cap;\n while (src < numSamples) {\n const run = Math.min(numSamples - src, cap - dst);\n for (let j = 0; j < run; j++) {\n samples[dst + j] = int16[src + j] / 0x8000;\n }\n src += run;\n dst = 0;\n }\n } else {\n // Odd byte offset (or big-endian host): fall back to per-sample reads.\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);\n let dst = this.writePos % cap;\n for (let i = 0; i < numSamples; i++) {\n samples[dst] = view.getInt16(i * 2, true) / 0x8000;\n dst++;\n if (dst === cap) dst = 0;\n }\n }\n this.writePos += numSamples;\n // If the producer outran the consumer by more than the buffer holds, drop\n // the oldest unplayed audio rather than reading samples we've overwritten.\n if (this.writePos - this.readPos > this.capacity) {\n this.readPos = this.writePos - this.capacity;\n }\n }\n\n process(inputs, outputs) {\n // No output wired up yet \u2014 nothing to render this quantum. Throwing here\n // would permanently kill the processor (the node is persistent per\n // session), so guard like the capture processor does.\n if (!outputs[0] || !outputs[0][0]) return true;\n const out = outputs[0][0];\n const avail = this.writePos - this.readPos;\n\n // Filling: wait for the target. 'done' short-circuits it \u2014 what is\n // buffered is all there will be, so there is nothing left to wait for.\n if (!this.playing) {\n if (avail >= this.fillTarget || this.isDone) {\n this.playing = true;\n } else {\n this.coverGap(out, 0);\n return true;\n }\n }\n\n // Underrun: this quantum cannot be filled and more audio is still coming.\n // Go back to filling (at the refill target) and cover the gap, leaving\n // readPos untouched \u2014 the fragment stays buffered and plays intact once\n // the buffer recovers, instead of being dribbled out a few samples at a\n // time for the rest of the turn.\n if (avail < out.length && !this.isDone) {\n this.playing = false;\n this.fillTarget = this.refillSamples;\n this.coverGap(out, 0);\n return true;\n }\n\n if (avail > 0) {\n const n = Math.min(avail, out.length);\n // Copy from the ring buffer, splitting across the wrap boundary.\n const start = this.readPos % this.capacity;\n const first = Math.min(n, this.capacity - start);\n out.set(this.samples.subarray(start, start + first), 0);\n if (n > first) out.set(this.samples.subarray(0, n - first), first);\n this.readPos += n;\n // Only reachable with n < out.length on the turn's final partial\n // quantum (the underrun branch above catches every other case).\n out.fill(0, n);\n this.hasPlayed = true;\n this.rememberTail(out, n);\n return true;\n }\n\n // Drained and done: end the turn. Not reachable mid-turn \u2014 an empty\n // buffer with audio still coming is the underrun branch above.\n out.fill(0);\n if (this.isDone) {\n this.stopTurn('done');\n }\n return true;\n }\n}\n\nregisterProcessor('playback-processor', PlaybackProcessor);\n";
|
|
2
|
+
export declare const playbackProcessorSource = "\nclass PlaybackProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n const opts = options.processorOptions || {};\n // The node's context IS the playback context (audio.ts asserts its rate),\n // so the worklet-global sampleRate is authoritative; the option exists\n // for the node-less test harness.\n const rate = opts.sampleRate ?? sampleRate;\n // Fill target for the start of a turn. If 'done' arrives first (short\n // utterance), start immediately instead of waiting for audio that is\n // never coming.\n this.jitterSamples = Math.floor((rate * (opts.jitterMs ?? 400)) / 1000);\n // Fill target after an underrun \u2014 see PLAYBACK_REFILL_MS.\n this.refillSamples = Math.floor((rate * (opts.refillMs ?? 200)) / 1000);\n // Concealment source: a ring of the most recently played samples, looped\n // under a decaying gain to cover a gap. Sized to the fade window, with a\n // per-sample decay that reaches the floor exactly at its end.\n this.concealCapacity = Math.max(1, Math.floor((rate * 40) / 1000));\n this.concealBuf = new Float32Array(this.concealCapacity);\n this.concealDecay = Math.exp(Math.log(0.001) / this.concealCapacity);\n // Float32 ring buffer \u2014 PLAYBACK_BUFFER_SECONDS at the context sample\n // rate. Allocated once for the node's lifetime; per-turn state resets via\n // resetTurn(). writePos and readPos are absolute (monotonic) sample\n // counts; the buffer is indexed modulo capacity so a longer reply keeps\n // playing instead of writing past the end and going silent.\n this.capacity = rate * 60;\n this.samples = new Float32Array(this.capacity);\n // Cadence for the 'progress' report in process(), counted in samples so\n // the hot path never reads a clock. Survives resetTurn(): the host clamps\n // upward only, so a stale count costs at most one extra report.\n this.reportIntervalSamples = Math.floor((rate * 500) / 1000);\n this.sinceReportSamples = 0;\n // Platform endianness probe: the wire format is PCM16 little-endian, so\n // the Int16Array fast path in ingestBytes is only valid on LE hosts\n // (every shipping browser target; the DataView path is the fallback).\n this.littleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;\n this.resetTurn();\n\n this.port.onmessage = (e) => {\n const d = e.data;\n if (d.event === 'write') {\n this.ingestBytes(d.buffer);\n } else if (d.event === 'interrupt') {\n // Applied eagerly, not deferred to the next process(): onmessage and\n // process() never interleave (one audio thread), and a deferred\n // interrupt would let 'write'/'done' frames for the NEXT turn\n // coalesce in ahead of it and be wiped by the reset along with the\n // cancelled turn's audio.\n this.stopTurn('interrupt');\n } else if (d.event === 'done') {\n this.isDone = true;\n // Echoed back on this turn's 'stop' so the host can tell WHICH turn\n // drained: a stop already in flight when a barge-in lands would\n // otherwise settle the next turn's done() the moment it arrives.\n this.doneTurn = d.turn ?? null;\n }\n };\n }\n\n // Reset per-turn state so the node is reusable across replies without\n // reallocating the sample buffer or re-instantiating the worklet.\n resetTurn() {\n this.isDone = false;\n // Host turn id carried by this turn's 'done' (null until one arrives).\n this.doneTurn = null;\n this.playing = false;\n // Whether any real audio has been rendered this turn. Separates a turn's\n // pre-roll (nothing to extrapolate from, and not a defect) from a\n // mid-turn underrun.\n this.hasPlayed = false;\n this.fillTarget = this.jitterSamples;\n // Carry-over byte for split samples across chunks\n this.carry = null;\n this.writePos = 0;\n this.readPos = 0;\n // Concealment ring state and the current fade position.\n this.concealLen = 0;\n this.concealWrite = 0;\n this.concealPos = 0;\n this.concealGain = 1;\n // Episode flags, so a multi-quantum gap counts as one event.\n this.concealing = false;\n this.concealedSilence = false;\n // Reported to the host on 'stop'. A fresh object per turn: the one just\n // posted must not be mutated by the next turn.\n this.stats = {\n concealedSamples: 0,\n silentConcealedSamples: 0,\n concealmentEvents: 0,\n silentConcealmentEvents: 0,\n };\n }\n\n // End the current turn: notify the host and rearm for the next reply.\n // Must NOT return false from process() \u2014 a processor that stops is dead\n // for good, forcing a new node (and buffer) per reply.\n // `reason` ('interrupt' | 'done') tells the host which turn boundary this\n // stop belongs to \u2014 interrupt-stops are dropped host-side, flush() having\n // already settled that turn \u2014 and `turn` names WHICH turn drained (the id\n // this turn's 'done' carried), so a drain-stop still in flight when a\n // barge-in lands cannot settle the next turn's done() early.\n stopTurn(reason) {\n this.port.postMessage({ event: 'stop', reason, turn: this.doneTurn, stats: this.stats });\n this.resetTurn();\n }\n\n // Cover a quantum (from `start`) where real audio should have been.\n //\n // Before the turn's first samples there is nothing to extrapolate from, so\n // the gap is plain silence and counted as nothing \u2014 WebRTC likewise only\n // counts concealment once playout has begun. After that, loop the retained\n // tail under a decaying gain: a hard zero-fill is a discontinuity mid-word,\n // which is the click that makes a brief stall sound like breakage.\n coverGap(out, start) {\n if (!this.hasPlayed) {\n out.fill(0, start);\n return;\n }\n if (!this.concealing) {\n this.concealing = true;\n this.concealedSilence = false;\n this.stats.concealmentEvents++;\n }\n const total = out.length - start;\n const len = this.concealLen;\n let silent = 0;\n // Second condition: once the fade has decayed to the floor, every sample\n // the loop would emit is 0 anyway \u2014 bulk-fill instead of running 128\n // branchy iterations per quantum for the whole tail of a long stall.\n if (len === 0 || this.concealGain < 0.001) {\n out.fill(0, start);\n silent = total;\n } else {\n let g = this.concealGain;\n for (let i = start; i < out.length; i++) {\n if (g < 0.001) {\n // The fade has run out: keep counting the gap, but stop looping a\n // fragment that is now inaudible anyway.\n out[i] = 0;\n silent++;\n continue;\n }\n out[i] = this.concealBuf[this.concealPos] * g;\n this.concealPos = this.concealPos + 1 === len ? 0 : this.concealPos + 1;\n g *= this.concealDecay;\n }\n this.concealGain = g;\n }\n this.stats.concealedSamples += total;\n if (silent > 0) {\n this.stats.silentConcealedSamples += silent;\n if (!this.concealedSilence) {\n this.concealedSilence = true;\n this.stats.silentConcealmentEvents++;\n }\n }\n }\n\n // Retain the tail of a rendered quantum as the next gap's concealment\n // source, and close any episode the real audio just ended.\n rememberTail(out, n) {\n const cap = this.concealCapacity;\n const take = Math.min(n, cap);\n // Bulk copies (this runs on every cleanly rendered quantum): the tail is\n // one contiguous source run, landing in at most two ring runs.\n const tail = out.subarray(n - take, n);\n const first = Math.min(take, cap - this.concealWrite);\n this.concealBuf.set(tail.subarray(0, first), this.concealWrite);\n if (take > first) this.concealBuf.set(tail.subarray(first), 0);\n this.concealWrite = (this.concealWrite + take) % cap;\n this.concealLen = Math.min(cap, this.concealLen + take);\n // Read the loop oldest-first; once the ring is full the write cursor is\n // the oldest retained sample.\n this.concealPos = this.concealLen === cap ? this.concealWrite : 0;\n this.concealing = false;\n this.concealGain = 1;\n }\n\n ingestBytes(uint8) {\n let bytes = uint8;\n\n if (this.carry !== null) {\n const merged = new Uint8Array(1 + bytes.length);\n merged[0] = this.carry;\n merged.set(bytes, 1);\n bytes = merged;\n this.carry = null;\n }\n\n if (bytes.length % 2 !== 0) {\n this.carry = bytes[bytes.length - 1];\n bytes = bytes.subarray(0, bytes.length - 1);\n }\n\n if (bytes.length === 0) return;\n const numSamples = bytes.length / 2;\n const cap = this.capacity;\n const samples = this.samples;\n if (this.littleEndian && (bytes.byteOffset & 1) === 0) {\n // Fast path: 2-byte-aligned LE bytes wrap directly as an Int16Array;\n // copy wrap-aware in at most two runs with no per-sample DataView call\n // or modulo. This runs on the realtime audio thread.\n const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, numSamples);\n let src = 0;\n let dst = this.writePos % cap;\n while (src < numSamples) {\n const run = Math.min(numSamples - src, cap - dst);\n for (let j = 0; j < run; j++) {\n samples[dst + j] = int16[src + j] / 0x8000;\n }\n src += run;\n dst = 0;\n }\n } else {\n // Odd byte offset (or big-endian host): fall back to per-sample reads.\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);\n let dst = this.writePos % cap;\n for (let i = 0; i < numSamples; i++) {\n samples[dst] = view.getInt16(i * 2, true) / 0x8000;\n dst++;\n if (dst === cap) dst = 0;\n }\n }\n this.writePos += numSamples;\n // If the producer outran the consumer by more than the buffer holds, drop\n // the oldest unplayed audio rather than reading samples we've overwritten.\n if (this.writePos - this.readPos > this.capacity) {\n this.readPos = this.writePos - this.capacity;\n }\n }\n\n process(inputs, outputs) {\n // No output wired up yet \u2014 nothing to render this quantum. Throwing here\n // would permanently kill the processor (the node is persistent per\n // session), so guard like the capture processor does.\n if (!outputs[0] || !outputs[0][0]) return true;\n const out = outputs[0][0];\n const avail = this.writePos - this.readPos;\n\n // Report the unplayed backlog to the host (`playback_progress`). This IS\n // the closed-loop signal: the host otherwise models playback open-loop \u2014\n // every forwarded chunk assumed to start playing on arrival at exactly\n // 1.0x \u2014 and cannot see a buffer that has run ahead of the wall clock.\n // Measured against a client draining at 0.6x, the host declared the line\n // silent while it still held seconds of the reply, and then opened the\n // speaking-edge gate over speech the caller had not heard.\n //\n // Sent from process() rather than a timer because this is the only place\n // that sees the buffer on the audio thread; the counter is in QUANTA, so\n // it costs an integer compare per render and no clock read. Silence is not\n // reported: an empty buffer is what the host already assumes.\n this.sinceReportSamples += out.length;\n if (this.sinceReportSamples >= this.reportIntervalSamples) {\n this.sinceReportSamples = 0;\n if (avail > 0) {\n this.port.postMessage({ event: 'progress', bufferedMs: (avail / sampleRate) * 1000 });\n }\n }\n\n // Filling: wait for the target. 'done' short-circuits it \u2014 what is\n // buffered is all there will be, so there is nothing left to wait for.\n if (!this.playing) {\n if (avail >= this.fillTarget || this.isDone) {\n this.playing = true;\n } else {\n this.coverGap(out, 0);\n return true;\n }\n }\n\n // Underrun: this quantum cannot be filled and more audio is still coming.\n // Go back to filling (at the refill target) and cover the gap, leaving\n // readPos untouched \u2014 the fragment stays buffered and plays intact once\n // the buffer recovers, instead of being dribbled out a few samples at a\n // time for the rest of the turn.\n if (avail < out.length && !this.isDone) {\n this.playing = false;\n this.fillTarget = this.refillSamples;\n this.coverGap(out, 0);\n return true;\n }\n\n if (avail > 0) {\n const n = Math.min(avail, out.length);\n // Copy from the ring buffer, splitting across the wrap boundary.\n const start = this.readPos % this.capacity;\n const first = Math.min(n, this.capacity - start);\n out.set(this.samples.subarray(start, start + first), 0);\n if (n > first) out.set(this.samples.subarray(0, n - first), first);\n this.readPos += n;\n // Only reachable with n < out.length on the turn's final partial\n // quantum (the underrun branch above catches every other case).\n out.fill(0, n);\n this.hasPlayed = true;\n this.rememberTail(out, n);\n return true;\n }\n\n // Drained and done: end the turn. Not reachable mid-turn \u2014 an empty\n // buffer with audio still coming is the underrun branch above.\n out.fill(0);\n if (this.isDone) {\n this.stopTurn('done');\n }\n return true;\n }\n}\n\nregisterProcessor('playback-processor', PlaybackProcessor);\n";
|
|
3
3
|
declare const _default: string;
|
|
4
4
|
export default _default;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_JITTER_MS, PLAYBACK_REFILL_MS } from "../types.js";
|
|
1
|
+
import { PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_JITTER_MS, PLAYBACK_PROGRESS_INTERVAL_MS, PLAYBACK_REFILL_MS } from "../types.js";
|
|
2
2
|
import { t as workletModuleUrl } from "../_module-url-C_4gRVL0.js";
|
|
3
3
|
//#region worklets/playback-processor.ts
|
|
4
4
|
const PlaybackProcessorWorklet = `
|
|
@@ -29,6 +29,11 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
29
29
|
// playing instead of writing past the end and going silent.
|
|
30
30
|
this.capacity = rate * ${PLAYBACK_BUFFER_SECONDS};
|
|
31
31
|
this.samples = new Float32Array(this.capacity);
|
|
32
|
+
// Cadence for the 'progress' report in process(), counted in samples so
|
|
33
|
+
// the hot path never reads a clock. Survives resetTurn(): the host clamps
|
|
34
|
+
// upward only, so a stale count costs at most one extra report.
|
|
35
|
+
this.reportIntervalSamples = Math.floor((rate * ${PLAYBACK_PROGRESS_INTERVAL_MS}) / 1000);
|
|
36
|
+
this.sinceReportSamples = 0;
|
|
32
37
|
// Platform endianness probe: the wire format is PCM16 little-endian, so
|
|
33
38
|
// the Int16Array fast path in ingestBytes is only valid on LE hosts
|
|
34
39
|
// (every shipping browser target; the DataView path is the fallback).
|
|
@@ -48,6 +53,10 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
48
53
|
this.stopTurn('interrupt');
|
|
49
54
|
} else if (d.event === 'done') {
|
|
50
55
|
this.isDone = true;
|
|
56
|
+
// Echoed back on this turn's 'stop' so the host can tell WHICH turn
|
|
57
|
+
// drained: a stop already in flight when a barge-in lands would
|
|
58
|
+
// otherwise settle the next turn's done() the moment it arrives.
|
|
59
|
+
this.doneTurn = d.turn ?? null;
|
|
51
60
|
}
|
|
52
61
|
};
|
|
53
62
|
}
|
|
@@ -56,6 +65,8 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
56
65
|
// reallocating the sample buffer or re-instantiating the worklet.
|
|
57
66
|
resetTurn() {
|
|
58
67
|
this.isDone = false;
|
|
68
|
+
// Host turn id carried by this turn's 'done' (null until one arrives).
|
|
69
|
+
this.doneTurn = null;
|
|
59
70
|
this.playing = false;
|
|
60
71
|
// Whether any real audio has been rendered this turn. Separates a turn's
|
|
61
72
|
// pre-roll (nothing to extrapolate from, and not a defect) from a
|
|
@@ -88,10 +99,12 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
88
99
|
// Must NOT return false from process() — a processor that stops is dead
|
|
89
100
|
// for good, forcing a new node (and buffer) per reply.
|
|
90
101
|
// \`reason\` ('interrupt' | 'done') tells the host which turn boundary this
|
|
91
|
-
// stop belongs to
|
|
92
|
-
// settled that turn
|
|
102
|
+
// stop belongs to — interrupt-stops are dropped host-side, flush() having
|
|
103
|
+
// already settled that turn — and \`turn\` names WHICH turn drained (the id
|
|
104
|
+
// this turn's 'done' carried), so a drain-stop still in flight when a
|
|
105
|
+
// barge-in lands cannot settle the next turn's done() early.
|
|
93
106
|
stopTurn(reason) {
|
|
94
|
-
this.port.postMessage({ event: 'stop', reason, stats: this.stats });
|
|
107
|
+
this.port.postMessage({ event: 'stop', reason, turn: this.doneTurn, stats: this.stats });
|
|
95
108
|
this.resetTurn();
|
|
96
109
|
}
|
|
97
110
|
|
|
@@ -228,6 +241,26 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
228
241
|
const out = outputs[0][0];
|
|
229
242
|
const avail = this.writePos - this.readPos;
|
|
230
243
|
|
|
244
|
+
// Report the unplayed backlog to the host (\`playback_progress\`). This IS
|
|
245
|
+
// the closed-loop signal: the host otherwise models playback open-loop —
|
|
246
|
+
// every forwarded chunk assumed to start playing on arrival at exactly
|
|
247
|
+
// 1.0x — and cannot see a buffer that has run ahead of the wall clock.
|
|
248
|
+
// Measured against a client draining at 0.6x, the host declared the line
|
|
249
|
+
// silent while it still held seconds of the reply, and then opened the
|
|
250
|
+
// speaking-edge gate over speech the caller had not heard.
|
|
251
|
+
//
|
|
252
|
+
// Sent from process() rather than a timer because this is the only place
|
|
253
|
+
// that sees the buffer on the audio thread; the counter is in QUANTA, so
|
|
254
|
+
// it costs an integer compare per render and no clock read. Silence is not
|
|
255
|
+
// reported: an empty buffer is what the host already assumes.
|
|
256
|
+
this.sinceReportSamples += out.length;
|
|
257
|
+
if (this.sinceReportSamples >= this.reportIntervalSamples) {
|
|
258
|
+
this.sinceReportSamples = 0;
|
|
259
|
+
if (avail > 0) {
|
|
260
|
+
this.port.postMessage({ event: 'progress', bufferedMs: (avail / sampleRate) * 1000 });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
231
264
|
// Filling: wait for the target. 'done' short-circuits it — what is
|
|
232
265
|
// buffered is all there will be, so there is nothing left to wait for.
|
|
233
266
|
if (!this.playing) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.js"
|
|
14
14
|
},
|
|
15
|
+
"./client-dir": {
|
|
16
|
+
"@dev/source": "./client-dir.ts",
|
|
17
|
+
"types": "./dist/client-dir.d.ts",
|
|
18
|
+
"import": "./dist/client-dir.js"
|
|
19
|
+
},
|
|
15
20
|
"./styles.css": "./styles.css",
|
|
16
21
|
"./package.json": "./package.json",
|
|
17
22
|
"./default-client/*": "./dist/default-client/*"
|
|
@@ -23,7 +28,7 @@
|
|
|
23
28
|
"remark-gfm": "^4.0.1",
|
|
24
29
|
"use-stick-to-bottom": "^1.1.6",
|
|
25
30
|
"use-sync-external-store": "^1.6.0",
|
|
26
|
-
"@alexkroman1/aai": "5.
|
|
31
|
+
"@alexkroman1/aai": "5.10.0"
|
|
27
32
|
},
|
|
28
33
|
"peerDependencies": {
|
|
29
34
|
"react": "^19.0.0",
|
|
@@ -43,11 +48,11 @@
|
|
|
43
48
|
"@types/react-dom": "^19.2.3",
|
|
44
49
|
"@types/use-sync-external-store": "^1.5.0",
|
|
45
50
|
"@vitejs/plugin-react": "^6.0.4",
|
|
51
|
+
"fast-check": "^4.9.0",
|
|
46
52
|
"jsdom": "^29.1.1",
|
|
47
53
|
"react": "^19.2.8",
|
|
48
54
|
"react-dom": "^19.2.8",
|
|
49
55
|
"tsdown": "^0.22.13",
|
|
50
|
-
"tsx": "^4.23.1",
|
|
51
56
|
"vite": "^8.1.5",
|
|
52
57
|
"vitest": "^4.1.10"
|
|
53
58
|
},
|
|
@@ -62,7 +67,7 @@
|
|
|
62
67
|
"scripts": {
|
|
63
68
|
"test": "vitest run",
|
|
64
69
|
"test:coverage": "vitest run --coverage",
|
|
65
|
-
"build": "tsdown && tsc -p tsconfig.build.json &&
|
|
70
|
+
"build": "tsdown && tsc -p tsconfig.build.json && node build-default-client.ts",
|
|
66
71
|
"typecheck": "tsc --noEmit",
|
|
67
72
|
"lint": "biome check .",
|
|
68
73
|
"check:publint": "publint",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{a as e,o as t,t as n}from"./index-fLjOiK97.js";function r(e,t,n){if(e!==t)throw Error(`Browser refused the ${n} sample rate: asked for ${t} Hz, got ${e} Hz`)}function i(e){e.then(e=>{for(let t of e.getTracks())t.stop()}).catch(()=>{})}function a(e,t,n){let r=new AudioWorkletNode(e,`capture-processor`,{channelCount:1,channelCountMode:`explicit`}),i=null;return r.port.onmessage=e=>{let r=e.data;r.event===`chunk`&&r.buffer?t(r.buffer):r.event===`silent`?n?.():r.event===`stopped`&&(i?.(),i=null)},{node:r,start(){r.port.postMessage({event:`start`})},stop(){return new Promise(e=>{let t=setTimeout(e,250);i=()=>{clearTimeout(t),e()},r.port.postMessage({event:`stop`})})}}}async function o(o){let{sttSampleRate:s,ttsSampleRate:c,captureWorkletSrc:l,playbackWorkletSrc:u,onMicData:d,onError:f,onPlaybackStats:p,onMicSilent:m}=o,h=new AudioContext({sampleRate:c,latencyHint:`playback`}),g=s===c,_=g?h:new AudioContext({sampleRate:s,latencyHint:`interactive`});async function v(){let e=g?[h]:[h,_];await Promise.all(e.map(e=>e.close().catch(e=>{console.warn(`AudioContext close failed:`,e)})))}let y=navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...n}}),b;try{[b]=await Promise.all([y,h.resume(),_.resume(),_.audioWorklet.addModule(l),h.audioWorklet.addModule(u)]),r(_.sampleRate,s,`capture`),r(h.sampleRate,c,`playback`)}catch(e){throw i(y),await v(),e}let x=_.createMediaStreamSource(b),S=a(_,d,m);x.connect(S.node),S.node.onprocessorerror=()=>{let e=Error(`Audio capture worklet crashed`);console.error(`[aai-ui]`,e.message),f?.(e)},S.start();let C=null,w=null,T=new AbortController;function E(){if(C)return C;let e=new AudioWorkletNode(h,`playback-processor`);return e.connect(h.destination),e.port.onmessage=e=>{if(e.data.event===`stop`){let t=e.data.stats;if(t&&t.concealedSamples>0&&p?.(t),e.data.reason===`interrupt`)return;w?.(),w=null}},e.onprocessorerror=()=>{let e=Error(`Audio playback worklet crashed`);console.error(`[aai-ui]`,e.message),w?.(),w=null,f?.(e)},C=e,e}let D={enqueue(e){T.signal.aborted||e.byteLength!==0&&E().port.postMessage({event:`write`,buffer:new Uint8Array(e)},[e])},done(){return!C||(C.port.postMessage({event:`done`}),h.state!==`running`)?Promise.resolve():new Promise(n=>{w?.();let r=()=>{clearInterval(i),clearTimeout(a),w===r&&(w=null),n()},i=setInterval(()=>{h.state!==`running`&&r()},t),a=setTimeout(r,e);w=r})},flush(){C&&(w?.(),w=null,C.port.postMessage({event:`interrupt`}))},async close(){if(!T.signal.aborted){T.abort(),await S.stop(),x.disconnect(),S.node.disconnect(),C&&C.disconnect();for(let e of b.getTracks())e.stop();await v()}},async[Symbol.asyncDispose](){await D.close()}};return D}export{o as createVoiceIO};
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
|
2
|
-
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--font-aai:"Monument Grotesk", "ABC Monument Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-aai-serif:"Source Serif 4", "Source Serif Pro", Charter, "Iowan Old Style", Georgia, serif;--font-aai-mono:"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;--radius-aai:4px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html,body{margin:0;padding:0}}@layer components;@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.static{position:static}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-0\.5{margin-block:calc(var(--spacing) * .5)}.my-1\.5{margin-block:calc(var(--spacing) * 1.5)}.my-2\.5{margin-block:calc(var(--spacing) * 2.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.block{display:block}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-4{height:calc(var(--spacing) * 4)}.h-9{height:calc(var(--spacing) * 9)}.h-11{height:calc(var(--spacing) * 11)}.h-\[7px\]{height:7px}.h-screen{height:100vh}.max-h-64{max-height:calc(var(--spacing) * 64)}.min-h-0{min-height:0}.min-h-5{min-height:calc(var(--spacing) * 5)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-4{width:calc(var(--spacing) * 4)}.w-\[7px\]{width:7px}.w-fit{width:fit-content}.w-full{width:100%}.max-w-75{max-width:calc(var(--spacing) * 75)}.max-w-105{max-width:calc(var(--spacing) * 105)}.max-w-190{max-width:calc(var(--spacing) * 190)}.max-w-\[60\%\]{max-width:60%}.max-w-\[82\%\]{max-width:82%}.max-w-\[min\(78\%\,64ch\)\]{max-width:min(78%,64ch)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.rotate-90{rotate:90deg}.cursor-pointer{cursor:pointer}.resize{resize:both}.\[scrollbar-width\:none\]{scrollbar-width:none}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.appearance-none{appearance:none}.flex-col{flex-direction:column}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-aai{border-radius:var(--radius-aai)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-none{--tw-border-style:none;border-style:none}.bg-transparent{background-color:#0000}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-7{padding:calc(var(--spacing) * 7)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-10{padding-inline:calc(var(--spacing) * 10)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.font-aai{font-family:var(--font-aai)}.font-aai-mono{font-family:var(--font-aai-mono)}.font-aai-serif{font-family:var(--font-aai-serif)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.text-\[17px\]{font-size:17px}.text-\[22px\]{font-size:22px}.text-\[32px\]{font-size:32px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-\[1\.2\]{--tw-leading:1.2;line-height:1.2}.leading-\[1\.15\]{--tw-leading:1.15;line-height:1.15}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-\[23px\]{--tw-leading:23px;line-height:23px}.leading-\[130\%\]{--tw-leading:130%;line-height:130%}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.2px\]{--tw-tracking:-.2px;letter-spacing:-.2px}.tracking-\[1\.2px\]{--tw-tracking:1.2px;letter-spacing:1.2px}.tracking-\[1\.4px\]{--tw-tracking:1.4px;letter-spacing:1.4px}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-balance{text-wrap:balance}.wrap-break-word{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.first\:mt-0:first-child{margin-top:0}.last\:mb-0:last-child{margin-bottom:0}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:px-16{padding-inline:calc(var(--spacing) * 16)}.sm\:py-14{padding-block:calc(var(--spacing) * 14)}}}@keyframes aai-pulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.45;transform:scale(.82)}}@keyframes aai-bounce{0%,80%,to{opacity:.3;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}@keyframes aai-shimmer{0%{background-position:-200% 0}to{background-position:200% 0}}.tool-shimmer{-webkit-text-fill-color:transparent;background:linear-gradient(90deg,currentColor 25%,#0000 50%,currentColor 75%) 0 0/200% 100%;-webkit-background-clip:text;background-clip:text;animation:2s infinite aai-shimmer}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
|