@alexkroman1/aai-ui 2.0.0 → 3.0.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.
Files changed (45) hide show
  1. package/dist/_module-url-C_4gRVL0.js +15 -0
  2. package/dist/audio.d.ts +42 -6
  3. package/dist/audio.js +79 -69
  4. package/dist/{chat-view-DKFhxMAT.js → chat-view-C1oJxsWz.js} +4 -4
  5. package/dist/client-config.d.ts +5 -6
  6. package/dist/components/chat-view.js +1 -1
  7. package/dist/components/console-shell.d.ts +2 -2
  8. package/dist/components/controls.js +1 -1
  9. package/dist/components/url-chips.d.ts +0 -3
  10. package/dist/context.d.ts +1 -1
  11. package/dist/context.js +1 -4
  12. package/dist/{controls-4OJoekj6.js → controls-DV368uhb.js} +1 -4
  13. package/dist/default-client/assets/_module-url-BX0RuRU2.js +1 -0
  14. package/dist/default-client/assets/audio-BGHiiDY_.js +1 -0
  15. package/dist/default-client/assets/{capture-processor-DLHxAIfT.js → capture-processor-BLPsqnGl.js} +4 -4
  16. package/dist/default-client/assets/index-DXf7-M0Q.js +244 -0
  17. package/dist/default-client/assets/index-Dv-Q5VRL.css +2 -0
  18. package/dist/default-client/assets/{playback-processor-bMTdFp-8.js → playback-processor-B_T_1KQP.js} +28 -21
  19. package/dist/default-client/index.html +2 -5
  20. package/dist/define-client-Cyf1jqAl.js +150 -0
  21. package/dist/define-client.js +1 -1
  22. package/dist/index.d.ts +1 -3
  23. package/dist/index.js +7 -6
  24. package/dist/{session-core-BLhiQ18c.js → session-core-BM3WHeoY.js} +30 -128
  25. package/dist/session-core-messages.d.ts +0 -4
  26. package/dist/session-core-types.d.ts +1 -27
  27. package/dist/session-core.js +1 -1
  28. package/dist/types.d.ts +1 -1
  29. package/dist/types.js +2 -2
  30. package/dist/worklets/_module-url.d.ts +10 -0
  31. package/dist/worklets/capture-processor.d.ts +2 -2
  32. package/dist/worklets/capture-processor.js +5 -5
  33. package/dist/worklets/playback-processor.d.ts +3 -3
  34. package/dist/worklets/playback-processor.js +31 -24
  35. package/package.json +2 -2
  36. package/dist/components/workflow-view.d.ts +0 -13
  37. package/dist/default-client/assets/audio-Cgviqo9t.js +0 -1
  38. package/dist/default-client/assets/index-BgbIWnfG.css +0 -2
  39. package/dist/default-client/assets/index-DbKKR3UE.js +0 -62
  40. package/dist/default-client/assets/rolldown-runtime-BpQH8Ho1.js +0 -1
  41. package/dist/default-client/assets/types-Bpg3ZIZK.js +0 -64
  42. package/dist/define-client-yoGybEYR.js +0 -710
  43. package/dist/session-core-upload.d.ts +0 -16
  44. package/dist/sync-mic.d.ts +0 -61
  45. package/dist/sync-session.d.ts +0 -49
@@ -27,17 +27,7 @@ export type CustomEvent = {
27
27
  */
28
28
  export type SessionSnapshot = {
29
29
  readonly state: AgentState;
30
- /**
31
- * False when the server declared the session text-only (`tts: none()`):
32
- * no audio frames will arrive and replies render as text. True until the
33
- * server's `config` message says otherwise (voice is the default).
34
- */
35
- readonly audioOut: boolean;
36
- /**
37
- * True while the microphone is live and streaming to the server. Voice
38
- * sessions record for their whole lifetime; text-only sessions toggle this
39
- * via `startRecording()` / `stopRecording()` (the record button).
40
- */
30
+ /** True while the microphone is live and streaming to the server. */
41
31
  readonly recording: boolean;
42
32
  /**
43
33
  * The WebSocket URL a program can connect to directly (the same endpoint
@@ -94,22 +84,6 @@ export type SessionCore = {
94
84
  start(): void;
95
85
  /** Toggle between connected and disconnected states. */
96
86
  toggle(): void;
97
- /**
98
- * Start streaming microphone audio (text-only sessions). Requests mic
99
- * access on first use. No-op in voice sessions, where the mic is always on.
100
- */
101
- startRecording(): void;
102
- /** Stop streaming microphone audio (text-only sessions). No-op in voice sessions. */
103
- stopRecording(): void;
104
- /**
105
- * Decode an audio file (any format the browser can decode), resample it to
106
- * the session's STT rate, and stream it to the server for transcription.
107
- * Text-only sessions (`tts: none()`) only — voice sessions stream the
108
- * microphone instead and reject. Also rejects when no session is connected,
109
- * the mic is recording, another upload is in flight, or the file cannot be
110
- * decoded. Resolves once the audio has been handed to the socket.
111
- */
112
- sendAudioFile(file: Blob): Promise<void>;
113
87
  /** Alias for `disconnect` for use with `using`. */
114
88
  [Symbol.dispose](): void;
115
89
  };
@@ -1,2 +1,2 @@
1
- import { t as createSessionCore } from "./session-core-BLhiQ18c.js";
1
+ import { t as createSessionCore } from "./session-core-BM3WHeoY.js";
2
2
  export { createSessionCore };
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { SessionErrorCode } from "@alexkroman1/aai/protocol";
2
- export { FILE_SEND_BACKOFF_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_JITTER_MS, PLAYBACK_REFILL_MS, } from "@alexkroman1/aai";
2
+ export { CAPTURE_STOP_ACK_TIMEOUT_MS, DEFAULT_STT_SAMPLE_RATE, 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
3
  /**
4
4
  * `getUserMedia` audio constraints for every capture path in this package.
5
5
  *
package/dist/types.js CHANGED
@@ -1,4 +1,4 @@
1
- import { FILE_SEND_BACKOFF_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_JITTER_MS, PLAYBACK_REFILL_MS } from "@alexkroman1/aai";
1
+ import { CAPTURE_STOP_ACK_TIMEOUT_MS, DEFAULT_STT_SAMPLE_RATE, 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";
2
2
  //#region types.ts
3
3
  /**
4
4
  * `getUserMedia` audio constraints for every capture path in this package.
@@ -29,4 +29,4 @@ const VOICE_CAPTURE_CONSTRAINTS = {
29
29
  voiceIsolation: false
30
30
  };
31
31
  //#endregion
32
- export { FILE_SEND_BACKOFF_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_JITTER_MS, PLAYBACK_REFILL_MS, VOICE_CAPTURE_CONSTRAINTS };
32
+ export { CAPTURE_STOP_ACK_TIMEOUT_MS, DEFAULT_STT_SAMPLE_RATE, 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 };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Blob-URL module for an inlined AudioWorklet processor source.
3
+ *
4
+ * A blob URL rather than a data URI because the agent page's CSP allows
5
+ * `script-src blob:` but not `data:` — a data-URI module fails `addModule`
6
+ * with the opaque "Unable to load a worklet's module". Every inline worklet
7
+ * in this package must load through this helper so that constraint lives in
8
+ * one place.
9
+ */
10
+ export declare function workletModuleUrl(source: string): string;
@@ -1,4 +1,4 @@
1
1
  /** Raw worklet source — exported so tests can evaluate the processor directly. */
2
2
  export declare const captureProcessorSource = "\nclass CaptureProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n this.recording = false;\n const opts = options.processorOptions || {};\n // The context runs at the STT rate, so this is both the input and the\n // output rate \u2014 there is nothing to convert.\n this.rate = opts.sampleRate || sampleRate;\n // Int16 accumulation buffer: flushed to the main thread as one transferred\n // ArrayBuffer once ~bufferSeconds of samples are batched. Sized 2x the\n // flush target so a whole render quantum always fits before flushing.\n this.targetSamples = Math.max(1, Math.round(this.rate * (opts.bufferSeconds || 0.1)));\n this.pending = new Int16Array(this.targetSamples * 2);\n this.pendingLen = 0;\n // Dead-mic probe: samples left to inspect before concluding the device\n // delivers nothing but digital silence. Only consumed while recording, so\n // the cost disappears after the window (or after the first real sample).\n this.probeSamplesLeft = Math.round(\n (this.rate * (opts.silenceProbeMs ?? 1500)) / 1000,\n );\n this.port.onmessage = (e) => {\n if (e.data.event === 'start') this.recording = true;\n else if (e.data.event === 'stop') {\n // Final flush so the tail of speech isn't dropped on close, then ack\n // so the host knows the tail chunk (if any) has been posted and it is\n // safe to tear the context down.\n this.flush();\n this.recording = false;\n this.port.postMessage({ event: 'stopped' });\n }\n };\n }\n\n // Convert Float32 -> Int16 and append to the pending batch. Writes through\n // an Int16Array directly (assignment truncates like DataView.setInt16).\n accumulate(samples) {\n let buf = this.pending;\n if (this.pendingLen + samples.length > buf.length) {\n // Defensive: only reachable if a render quantum outproduces the 1x\n // headroom above the flush target (never with 128-sample quanta).\n const grown = new Int16Array((this.pendingLen + samples.length) * 2);\n grown.set(buf.subarray(0, this.pendingLen));\n this.pending = grown;\n buf = grown;\n }\n for (let i = 0; i < samples.length; i++) {\n const s = Math.max(-1, Math.min(1, samples[i]));\n buf[this.pendingLen++] = s < 0 ? s * 0x8000 : s * 0x7fff;\n }\n }\n\n // Post the batched samples as one transferred ArrayBuffer and reset.\n flush() {\n if (this.pendingLen === 0) return;\n const buffer = this.pending.buffer.slice(0, this.pendingLen * 2);\n this.pendingLen = 0;\n this.port.postMessage({ event: 'chunk', buffer }, [buffer]);\n }\n\n // Watch the first window of input for any nonzero sample. One is enough to\n // prove the device is live \u2014 a real mic in a quiet room still carries a\n // noise floor, so all-zeros means muted, wrong input, or no input at all.\n probeForSilence(channel) {\n for (let i = 0; i < channel.length; i++) {\n if (channel[i] !== 0) {\n this.probeSamplesLeft = 0;\n return;\n }\n }\n this.probeSamplesLeft -= channel.length;\n if (this.probeSamplesLeft <= 0) {\n this.port.postMessage({ event: 'silent' });\n }\n }\n\n process(inputs) {\n const input = inputs[0];\n if (!input || !input[0] || !this.recording) return true;\n\n if (this.probeSamplesLeft > 0) this.probeForSilence(input[0]);\n\n this.accumulate(input[0]);\n if (this.pendingLen >= this.targetSamples) this.flush();\n return true;\n }\n}\n\nregisterProcessor('capture-processor', CaptureProcessor);\n";
3
- declare const src: string;
4
- export default src;
3
+ declare const _default: string;
4
+ export default _default;
@@ -1,4 +1,5 @@
1
- import { MIC_SILENCE_PROBE_MS } from "../types.js";
1
+ import { MIC_BUFFER_SECONDS, MIC_SILENCE_PROBE_MS } from "../types.js";
2
+ import { t as workletModuleUrl } from "../_module-url-C_4gRVL0.js";
2
3
  //#region worklets/capture-processor.ts
3
4
  const CaptureProcessorWorklet = `
4
5
  class CaptureProcessor extends AudioWorkletProcessor {
@@ -12,7 +13,7 @@ class CaptureProcessor extends AudioWorkletProcessor {
12
13
  // Int16 accumulation buffer: flushed to the main thread as one transferred
13
14
  // ArrayBuffer once ~bufferSeconds of samples are batched. Sized 2x the
14
15
  // flush target so a whole render quantum always fits before flushing.
15
- this.targetSamples = Math.max(1, Math.round(this.rate * (opts.bufferSeconds || 0.1)));
16
+ this.targetSamples = Math.max(1, Math.round(this.rate * (opts.bufferSeconds || ${MIC_BUFFER_SECONDS})));
16
17
  this.pending = new Int16Array(this.targetSamples * 2);
17
18
  this.pendingLen = 0;
18
19
  // Dead-mic probe: samples left to inspect before concluding the device
@@ -92,7 +93,6 @@ registerProcessor('capture-processor', CaptureProcessor);
92
93
  `;
93
94
  /** Raw worklet source — exported so tests can evaluate the processor directly. */
94
95
  const captureProcessorSource = CaptureProcessorWorklet;
95
- const script = new Blob([CaptureProcessorWorklet], { type: "application/javascript" });
96
- const src = URL.createObjectURL(script);
96
+ var capture_processor_default = workletModuleUrl(CaptureProcessorWorklet);
97
97
  //#endregion
98
- export { captureProcessorSource, src as default };
98
+ export { captureProcessorSource, capture_processor_default as default };
@@ -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 const rate = opts.sampleRate ?? 24000;\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 60s at the context sample rate. Allocated once for\n // the node's lifetime; per-turn state resets via resetTurn(). writePos and\n // readPos are absolute (monotonic) sample counts; the buffer is indexed\n // modulo capacity so a reply longer than 60s keeps playing instead of\n // 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 this.interrupted = true;\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.interrupted = false;\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 if (len === 0) {\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 for (let i = n - take; i < n; i++) {\n this.concealBuf[this.concealWrite] = out[i];\n this.concealWrite = this.concealWrite + 1 === cap ? 0 : this.concealWrite + 1;\n }\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 if (this.interrupted) {\n out.fill(0);\n this.stopTurn('interrupt');\n return true;\n }\n\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";
3
- declare const src: string;
4
- export default src;
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";
3
+ declare const _default: string;
4
+ export default _default;
@@ -1,11 +1,15 @@
1
- import { 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_REFILL_MS } from "../types.js";
2
+ import { t as workletModuleUrl } from "../_module-url-C_4gRVL0.js";
2
3
  //#region worklets/playback-processor.ts
3
4
  const PlaybackProcessorWorklet = `
4
5
  class PlaybackProcessor extends AudioWorkletProcessor {
5
6
  constructor(options) {
6
7
  super();
7
8
  const opts = options.processorOptions || {};
8
- const rate = opts.sampleRate ?? 24000;
9
+ // The node's context IS the playback context (audio.ts asserts its rate),
10
+ // so the worklet-global sampleRate is authoritative; the option exists
11
+ // for the node-less test harness.
12
+ const rate = opts.sampleRate ?? sampleRate;
9
13
  // Fill target for the start of a turn. If 'done' arrives first (short
10
14
  // utterance), start immediately instead of waiting for audio that is
11
15
  // never coming.
@@ -18,12 +22,12 @@ class PlaybackProcessor extends AudioWorkletProcessor {
18
22
  this.concealCapacity = Math.max(1, Math.floor((rate * ${PLAYBACK_CONCEAL_FADE_MS}) / 1000));
19
23
  this.concealBuf = new Float32Array(this.concealCapacity);
20
24
  this.concealDecay = Math.exp(Math.log(${PLAYBACK_CONCEAL_FLOOR}) / this.concealCapacity);
21
- // Float32 ring buffer — 60s at the context sample rate. Allocated once for
22
- // the node's lifetime; per-turn state resets via resetTurn(). writePos and
23
- // readPos are absolute (monotonic) sample counts; the buffer is indexed
24
- // modulo capacity so a reply longer than 60s keeps playing instead of
25
- // writing past the end and going silent.
26
- this.capacity = rate * 60;
25
+ // Float32 ring buffer — PLAYBACK_BUFFER_SECONDS at the context sample
26
+ // rate. Allocated once for the node's lifetime; per-turn state resets via
27
+ // resetTurn(). writePos and readPos are absolute (monotonic) sample
28
+ // counts; the buffer is indexed modulo capacity so a longer reply keeps
29
+ // playing instead of writing past the end and going silent.
30
+ this.capacity = rate * ${PLAYBACK_BUFFER_SECONDS};
27
31
  this.samples = new Float32Array(this.capacity);
28
32
  // Platform endianness probe: the wire format is PCM16 little-endian, so
29
33
  // the Int16Array fast path in ingestBytes is only valid on LE hosts
@@ -36,7 +40,12 @@ class PlaybackProcessor extends AudioWorkletProcessor {
36
40
  if (d.event === 'write') {
37
41
  this.ingestBytes(d.buffer);
38
42
  } else if (d.event === 'interrupt') {
39
- this.interrupted = true;
43
+ // Applied eagerly, not deferred to the next process(): onmessage and
44
+ // process() never interleave (one audio thread), and a deferred
45
+ // interrupt would let 'write'/'done' frames for the NEXT turn
46
+ // coalesce in ahead of it and be wiped by the reset along with the
47
+ // cancelled turn's audio.
48
+ this.stopTurn('interrupt');
40
49
  } else if (d.event === 'done') {
41
50
  this.isDone = true;
42
51
  }
@@ -46,7 +55,6 @@ class PlaybackProcessor extends AudioWorkletProcessor {
46
55
  // Reset per-turn state so the node is reusable across replies without
47
56
  // reallocating the sample buffer or re-instantiating the worklet.
48
57
  resetTurn() {
49
- this.interrupted = false;
50
58
  this.isDone = false;
51
59
  this.playing = false;
52
60
  // Whether any real audio has been rendered this turn. Separates a turn's
@@ -107,7 +115,10 @@ class PlaybackProcessor extends AudioWorkletProcessor {
107
115
  const total = out.length - start;
108
116
  const len = this.concealLen;
109
117
  let silent = 0;
110
- if (len === 0) {
118
+ // Second condition: once the fade has decayed to the floor, every sample
119
+ // the loop would emit is 0 anyway — bulk-fill instead of running 128
120
+ // branchy iterations per quantum for the whole tail of a long stall.
121
+ if (len === 0 || this.concealGain < ${PLAYBACK_CONCEAL_FLOOR}) {
111
122
  out.fill(0, start);
112
123
  silent = total;
113
124
  } else {
@@ -141,10 +152,13 @@ class PlaybackProcessor extends AudioWorkletProcessor {
141
152
  rememberTail(out, n) {
142
153
  const cap = this.concealCapacity;
143
154
  const take = Math.min(n, cap);
144
- for (let i = n - take; i < n; i++) {
145
- this.concealBuf[this.concealWrite] = out[i];
146
- this.concealWrite = this.concealWrite + 1 === cap ? 0 : this.concealWrite + 1;
147
- }
155
+ // Bulk copies (this runs on every cleanly rendered quantum): the tail is
156
+ // one contiguous source run, landing in at most two ring runs.
157
+ const tail = out.subarray(n - take, n);
158
+ const first = Math.min(take, cap - this.concealWrite);
159
+ this.concealBuf.set(tail.subarray(0, first), this.concealWrite);
160
+ if (take > first) this.concealBuf.set(tail.subarray(first), 0);
161
+ this.concealWrite = (this.concealWrite + take) % cap;
148
162
  this.concealLen = Math.min(cap, this.concealLen + take);
149
163
  // Read the loop oldest-first; once the ring is full the write cursor is
150
164
  // the oldest retained sample.
@@ -212,12 +226,6 @@ class PlaybackProcessor extends AudioWorkletProcessor {
212
226
  // session), so guard like the capture processor does.
213
227
  if (!outputs[0] || !outputs[0][0]) return true;
214
228
  const out = outputs[0][0];
215
- if (this.interrupted) {
216
- out.fill(0);
217
- this.stopTurn('interrupt');
218
- return true;
219
- }
220
-
221
229
  const avail = this.writePos - this.readPos;
222
230
 
223
231
  // Filling: wait for the target. 'done' short-circuits it — what is
@@ -273,7 +281,6 @@ registerProcessor('playback-processor', PlaybackProcessor);
273
281
  `;
274
282
  /** Raw worklet source — exported so tests can evaluate the processor directly. */
275
283
  const playbackProcessorSource = PlaybackProcessorWorklet;
276
- const script = new Blob([PlaybackProcessorWorklet], { type: "application/javascript" });
277
- const src = URL.createObjectURL(script);
284
+ var playback_processor_default = workletModuleUrl(PlaybackProcessorWorklet);
278
285
  //#endregion
279
- export { src as default, playbackProcessorSource };
286
+ export { playback_processor_default as default, playbackProcessorSource };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -20,7 +20,7 @@
20
20
  "clsx": "^2.1.1",
21
21
  "partysocket": "^1.3.0",
22
22
  "use-sync-external-store": "^1.6.0",
23
- "@alexkroman1/aai": "2.0.0"
23
+ "@alexkroman1/aai": "3.0.0"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "react": "^19.0.0",
@@ -1,13 +0,0 @@
1
- /**
2
- * Workflow run surface: push-to-talk or audio upload stages one instruction
3
- * clip; **Go** runs it as a single sync turn and shows what was heard plus
4
- * the tool calls that executed — no greeting, no assistant messages.
5
- *
6
- * @public
7
- */
8
- export declare function WorkflowView({ syncUrl, title, }: {
9
- /** The workflow server's sync endpoint, e.g. `https://host/slug/sync`. */
10
- syncUrl: string;
11
- /** Workflow name shown in the header. */
12
- title?: string | undefined;
13
- }): import("react").JSX.Element;
@@ -1 +0,0 @@
1
- import{n as e}from"./rolldown-runtime-BpQH8Ho1.js";import{d as t,t as n}from"./types-Bpg3ZIZK.js";var r=e({createVoiceIO:()=>l,decodeAudioToPcm16:()=>s}),i=1e3,a=65e3,o=250;async function s(e,t){let n=await new OfflineAudioContext(1,1,t).decodeAudioData(e),r=Math.ceil(n.duration*t),i=new OfflineAudioContext(1,r,t),a=i.createBufferSource();a.buffer=n,a.connect(i.destination),a.start();let o=(await i.startRendering()).getChannelData(0),s=new Int16Array(o.length),c=0;for(let e of o){let t=Math.max(-1,Math.min(1,e));s[c++]=t<0?t*32768:t*32767}return s}function c(e,t,n){if(e!==t)throw Error(`Browser refused the ${n} sample rate: asked for ${t} Hz, got ${e} Hz`)}async function l(e){let{sttSampleRate:r,ttsSampleRate:s,captureWorkletSrc:l,playbackWorkletSrc:u,onMicData:d,onError:f,onPlaybackStats:p,onMicSilent:m}=e,h=new AudioContext({sampleRate:s,latencyHint:`playback`}),g=r===s,_=g?h:new AudioContext({sampleRate:r,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)]),c(_.sampleRate,r,`capture`),c(h.sampleRate,s,`playback`)}catch(e){throw y.then(e=>{for(let t of e.getTracks())t.stop()}).catch(()=>{}),await v(),e}let x=_.createMediaStreamSource(b),S=new AudioWorkletNode(_,`capture-processor`,{channelCount:1,channelCountMode:`explicit`,processorOptions:{sampleRate:r,bufferSeconds:t}});x.connect(S),S.onprocessorerror=()=>{let e=Error(`Audio capture worklet crashed`);console.error(`[aai-ui]`,e.message),f?.(e)},S.port.postMessage({event:`start`});let C=null;S.port.onmessage=e=>{e.data.event===`chunk`?d(e.data.buffer):e.data.event===`silent`?m?.():e.data.event===`stopped`&&(C?.(),C=null)};let w=null,T=null,E=new AbortController;function D(){if(w)return w;let e=new AudioWorkletNode(h,`playback-processor`,{processorOptions:{sampleRate:h.sampleRate}});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;T?.(),T=null}},e.onprocessorerror=()=>{let e=Error(`Audio playback worklet crashed`);console.error(`[aai-ui]`,e.message),T?.(),T=null,f?.(e)},w=e,e}let O={enqueue(e){E.signal.aborted||e.byteLength!==0&&D().port.postMessage({event:`write`,buffer:new Uint8Array(e)},[e])},done(){return!w||h.state!==`running`?Promise.resolve():new Promise(e=>{T?.();let t=()=>{clearInterval(n),clearTimeout(r),T===t&&(T=null),e()},n=setInterval(()=>{h.state!==`running`&&t()},i),r=setTimeout(t,a);T=t,w?.port.postMessage({event:`done`})})},flush(){w&&(T?.(),T=null,w.port.postMessage({event:`interrupt`}))},async close(){if(!E.signal.aborted){E.abort(),await new Promise(e=>{let t=setTimeout(e,o);C=()=>{clearTimeout(t),e()},S.port.postMessage({event:`stop`})}),x.disconnect(),S.disconnect(),w&&w.disconnect();for(let e of b.getTracks())e.stop();await v()}},async[Symbol.asyncDispose](){await O.close()}};return O}export{s as n,r as t};
@@ -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;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--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}.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}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mr-2{margin-right:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.block{display:block}.flex{display:flex}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.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-2{width:calc(var(--spacing) * 2)}.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-\[40\%\]{max-width:40%}.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}.grow{flex-grow:1}.rotate-90{rotate:90deg}.animate-pulse{animation:var(--animate-pulse)}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.\[scrollbar-width\:none\]{scrollbar-width:none}.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-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-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-none{--tw-border-style:none;border-style:none}.bg-transparent{background-color:#0000}.p-7{padding:calc(var(--spacing) * 7)}.px-2{padding-inline:calc(var(--spacing) * 2)}.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-1{padding-block:var(--spacing)}.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)}.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-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.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)}.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}.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}.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}@keyframes pulse{50%{opacity:.5}}