@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.
@@ -1,4 +1,4 @@
1
- import{i as e}from"./index-fLjOiK97.js";import{t}from"./_module-url-BX0RuRU2.js";var n=t(`
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: interrupt-stops are dropped host-side (flush() already
89
- // settled that turn), so they can never resolve a later turn's done() early.
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-fLjOiK97.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-CuPmIAbL.css">
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>
@@ -1,4 +1,4 @@
1
- import { r as fetchClientConfig, t as createSessionCore } from "./session-core-DqibBzJz.js";
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-DqibBzJz.js";
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 two turn-boundary counters (`turnEpoch` for
211
- * discarding stale async audio completions, `customEventSeq` for event
212
- * dedup) that previously lived as closure locals in `createSessionCore`.
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
- turnEpoch.bump();
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 *so far* and is cumulative
247
- * within a reply (see the protocol schema), so it renders as the live
248
- * assistant bubble and only becomes a message when the reply closes. Pipeline
249
- * mode sends one per piece of speech, so appending each would break a single
250
- * reply into a message per sentence.
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
- turnEpoch.bump();
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
- turnEpoch.bump();
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
- * `turnEpoch` so a completion (or failure) that lands after a turn
394
- * boundary is discarded instead of overwriting the newer turn's state. */
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 = turnEpoch.current();
410
+ const gen = conn.turn.current();
397
411
  io.done().then(() => {
398
- if (!turnEpoch.isCurrent(gen)) return;
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 the `turnEpoch` epoch to discard stale completions from interrupted turns.
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 two turn-boundary counters (`turnEpoch` for
55
- * discarding stale async audio completions, `customEventSeq` for event
56
- * dedup) that previously lived as closure locals in `createSessionCore`.
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
@@ -1,2 +1,2 @@
1
- import { t as createSessionCore } from "./session-core-DqibBzJz.js";
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: interrupt-stops are dropped host-side (flush() already
92
- // settled that turn), so they can never resolve a later turn's done() early.
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.9.0",
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.9.0"
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 && tsx build-default-client.ts",
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}