@alexkroman1/aai-ui 1.8.2 → 1.9.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 (62) hide show
  1. package/dist/_colors-DJUordGv.js +19 -0
  2. package/dist/_utils-5cs73OrA.js +16 -0
  3. package/dist/_utils.d.ts +4 -0
  4. package/dist/aai-logo-B8lDmsut.js +84 -0
  5. package/dist/audio.d.ts +9 -2
  6. package/dist/audio.js +41 -24
  7. package/dist/chat-view-C1XbqDk0.js +186 -0
  8. package/dist/components/_colors.d.ts +26 -0
  9. package/dist/components/aai-logo.d.ts +7 -6
  10. package/dist/components/button.d.ts +11 -13
  11. package/dist/components/button.js +7 -16
  12. package/dist/components/chat-view.d.ts +8 -19
  13. package/dist/components/chat-view.js +2 -98
  14. package/dist/components/controls.d.ts +1 -1
  15. package/dist/components/controls.js +2 -39
  16. package/dist/components/eyebrow.d.ts +12 -0
  17. package/dist/components/message-list.d.ts +1 -1
  18. package/dist/components/message-list.js +126 -56
  19. package/dist/components/sidebar-layout.d.ts +1 -1
  20. package/dist/components/start-screen.d.ts +4 -3
  21. package/dist/components/start-screen.js +20 -14
  22. package/dist/components/text-controls.d.ts +14 -0
  23. package/dist/components/tool-call-block.d.ts +5 -3
  24. package/dist/components/tool-call-block.js +1 -1
  25. package/dist/components/url-chips.d.ts +28 -0
  26. package/dist/context.d.ts +30 -4
  27. package/dist/context.js +86 -10
  28. package/dist/controls-BngrPbOC.js +135 -0
  29. package/dist/default-client/assets/audio-Cs-6t_Wd.js +1 -0
  30. package/dist/default-client/assets/capture-processor-C19oBn4L.js +105 -0
  31. package/dist/default-client/assets/index-Bf4ZTNcx.js +73 -0
  32. package/dist/default-client/assets/index-Bzlh9i7w.css +2 -0
  33. package/dist/default-client/assets/playback-processor-BtlzAH78.js +149 -0
  34. package/dist/default-client/index.html +3 -3
  35. package/dist/define-client.d.ts +2 -2
  36. package/dist/define-client.js +18 -24
  37. package/dist/eyebrow-C6ZFuiz6.js +27 -0
  38. package/dist/hooks.js +71 -42
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +6 -5
  41. package/dist/session-core-D3NDaySY.js +652 -0
  42. package/dist/session-core-messages.d.ts +50 -0
  43. package/dist/session-core-types.d.ts +147 -0
  44. package/dist/session-core-upload.d.ts +16 -0
  45. package/dist/session-core.d.ts +2 -68
  46. package/dist/session-core.js +1 -455
  47. package/dist/{tool-call-block-wby_jyoY.js → tool-call-block-7f1GTG-P.js} +33 -31
  48. package/dist/types.d.ts +29 -4
  49. package/dist/types.js +10 -1
  50. package/dist/worklets/capture-processor.d.ts +2 -0
  51. package/dist/worklets/capture-processor.js +80 -25
  52. package/dist/worklets/playback-processor.d.ts +2 -0
  53. package/dist/worklets/playback-processor.js +80 -29
  54. package/package.json +18 -18
  55. package/styles.css +30 -0
  56. package/dist/_react-test-utils.d.ts +0 -86
  57. package/dist/aai-logo-BqFv6JU6.js +0 -21
  58. package/dist/default-client/assets/audio-UowwhmYo.js +0 -1
  59. package/dist/default-client/assets/capture-processor-C26lSiVr.js +0 -53
  60. package/dist/default-client/assets/index-BZqVGR2o.css +0 -2
  61. package/dist/default-client/assets/index-YW9WUhbL.js +0 -49
  62. package/dist/default-client/assets/playback-processor-dSA8Im99.js +0 -101
@@ -1,4 +1,7 @@
1
1
  import { useTheme } from "./context.js";
2
+ import { r as TEXT_FAINT } from "./_colors-DJUordGv.js";
3
+ import { t as Eyebrow } from "./eyebrow-C6ZFuiz6.js";
4
+ import { n as tryParseJSON, t as truncate } from "./_utils-5cs73OrA.js";
2
5
  import clsx from "clsx";
3
6
  import { createContext, useContext, useMemo, useState } from "react";
4
7
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -22,21 +25,20 @@ function useToolConfig() {
22
25
  //#region components/tool-call-block.tsx
23
26
  /** @jsxImportSource react */
24
27
  function formatResult(result) {
25
- try {
26
- return JSON.stringify(JSON.parse(result), null, 2);
27
- } catch {
28
- return result;
29
- }
28
+ const parsed = tryParseJSON(result);
29
+ return parsed === result ? result : JSON.stringify(parsed, null, 2);
30
30
  }
31
31
  /**
32
- * Renders a tool invocation with an optional icon/emoji, title, subtitle, and a
33
- * collapsible result viewer.
32
+ * Renders a tool invocation as the design system's console row: a small
33
+ * outlined "TOOL" chip (or the tool's configured icon), the tool name in
34
+ * mono, a truncated args preview, and a rotating chevron that expands the
35
+ * formatted result.
34
36
  *
35
37
  * Tool display is configured via `ToolConfigContext`. If no config is found
36
38
  * for a tool name, the raw tool name is shown as the title.
37
39
  *
38
40
  * While the tool call is pending a shimmer animation is shown. Once
39
- * complete, clicking the block expands the formatted JSON result.
41
+ * complete, clicking the row expands the formatted JSON result.
40
42
  *
41
43
  * @example
42
44
  * ```tsx
@@ -59,69 +61,69 @@ function ToolCallBlock({ toolCall, className }) {
59
61
  const formatted = useMemo(() => toolCall.result ? formatResult(toolCall.result) : "", [toolCall.result]);
60
62
  const subtitle = useMemo(() => {
61
63
  const args = toolCall.args;
62
- if (toolCall.name === "run_code" && args.code) {
63
- const firstLine = String(args.code).split("\n")[0] ?? "";
64
- return firstLine.length > 80 ? `${firstLine.slice(0, 80)}...` : firstLine;
65
- }
64
+ if (toolCall.name === "run_code" && args.code) return truncate(String(args.code).split("\n")[0] ?? "");
66
65
  for (const key of [
67
66
  "query",
68
67
  "url",
69
68
  "question"
70
69
  ]) if (args[key]) return String(args[key]);
71
- const summary = JSON.stringify(args);
72
- return summary.length > 80 ? `${summary.slice(0, 80)}...` : summary;
70
+ return truncate(JSON.stringify(args));
73
71
  }, [toolCall.name, toolCall.args]);
74
72
  return /* @__PURE__ */ jsxs("div", {
75
- className: clsx("flex flex-col", className),
73
+ className: clsx("flex flex-col rounded-md border overflow-hidden", className),
74
+ style: {
75
+ borderColor: theme.border,
76
+ background: theme.bg
77
+ },
76
78
  children: [/* @__PURE__ */ jsxs("button", {
77
79
  type: "button",
78
80
  "aria-expanded": canExpand ? isOpen : void 0,
79
81
  disabled: isPending,
80
- className: clsx("flex items-center gap-2 px-3 py-2 rounded-aai border select-none text-left w-full", canExpand && "cursor-pointer"),
81
- style: {
82
- borderColor: theme.border,
83
- background: "rgba(255,255,255,0.031)"
84
- },
82
+ className: clsx("flex items-center gap-2.5 px-3.5 py-2.5 select-none text-left w-full appearance-none border-none bg-transparent", canExpand && "cursor-pointer"),
85
83
  onClick: () => {
86
84
  if (canExpand) setIsOpen(!isOpen);
87
85
  },
88
86
  children: [
89
- icon && /* @__PURE__ */ jsx("span", {
87
+ icon ? /* @__PURE__ */ jsx("span", {
90
88
  className: "w-4 h-4 shrink-0 text-center leading-4",
91
89
  children: icon
90
+ }) : /* @__PURE__ */ jsx(Eyebrow, {
91
+ className: "shrink-0",
92
+ style: { color: TEXT_FAINT },
93
+ children: "Tool"
92
94
  }),
93
95
  /* @__PURE__ */ jsx("span", {
94
- className: clsx("text-sm font-medium", isPending && "tool-shimmer"),
96
+ className: clsx("font-aai-mono text-[13px] font-medium", isPending && "tool-shimmer"),
95
97
  style: { color: theme.text },
96
98
  children: title
97
99
  }),
98
100
  /* @__PURE__ */ jsx("span", {
99
- className: "text-sm truncate flex-1 min-w-0",
100
- style: { color: "rgba(255,255,255,0.422)" },
101
+ className: "font-aai-mono text-[13px] truncate flex-1 min-w-0",
102
+ style: { color: TEXT_FAINT },
101
103
  children: subtitle
102
104
  }),
103
105
  canExpand && /* @__PURE__ */ jsx("span", {
104
- className: "text-xs shrink-0",
105
- style: { color: "rgba(255,255,255,0.422)" },
106
- children: isOpen ? "▾" : "▸"
106
+ className: clsx("text-[10px] shrink-0 transition-transform duration-150", isOpen && "rotate-90"),
107
+ style: { color: "#6F6A60" },
108
+ children: "▶"
107
109
  })
108
110
  ]
109
111
  }), isOpen && /* @__PURE__ */ jsxs("div", {
110
- className: "border-x border-b rounded-b-aai max-h-64 overflow-auto",
112
+ className: "border-t max-h-64 overflow-auto",
111
113
  style: {
112
114
  borderColor: theme.border,
113
115
  background: theme.surface
114
116
  },
115
117
  children: [toolCall.name === "run_code" && Boolean(toolCall.args.code) && /* @__PURE__ */ jsx("pre", {
116
- className: "text-xs p-2 whitespace-pre-wrap border-b font-mono",
118
+ className: "font-aai-mono text-xs px-3.5 py-3 m-0 whitespace-pre-wrap wrap-break-word border-b",
117
119
  style: {
118
120
  color: theme.text,
119
121
  borderColor: theme.border
120
122
  },
121
123
  children: String(toolCall.args.code)
122
124
  }), formatted && /* @__PURE__ */ jsx("pre", {
123
- className: "text-xs p-2 whitespace-pre-wrap",
124
- style: { color: "rgba(255,255,255,0.422)" },
125
+ className: "font-aai-mono text-xs px-3.5 py-3 m-0 whitespace-pre-wrap wrap-break-word",
126
+ style: { color: "#57534B" },
125
127
  children: formatted
126
128
  })]
127
129
  })]
package/dist/types.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import type { SessionErrorCode } from "@alexkroman1/aai/protocol";
2
2
  /** Microphone buffer duration in seconds before sending to the server. */
3
3
  export declare const MIC_BUFFER_SECONDS = 0.1;
4
+ /**
5
+ * Backpressure threshold for outbound mic audio. When the WebSocket's
6
+ * `bufferedAmount` exceeds this many bytes (~2s of 16 kHz PCM16), mic frames
7
+ * are dropped instead of queued — for live voice, stale audio flushed into
8
+ * STT on recovery is worse than a gap.
9
+ */
10
+ export declare const MIC_SEND_MAX_BUFFERED_BYTES: number;
11
+ /** Poll interval while waiting out socket backpressure during a file send. */
12
+ export declare const FILE_SEND_BACKOFF_MS = 50;
4
13
  /**
5
14
  * Current state of the voice agent session.
6
15
  *
@@ -13,6 +22,12 @@ export type AgentState = "disconnected" | "connecting" | "ready" | "listening" |
13
22
  * @public
14
23
  */
15
24
  export type ChatMessage = {
25
+ /**
26
+ * Monotonically increasing, session-unique message id assigned at append
27
+ * time. Stable across snapshot updates and window slides — use it as a
28
+ * render key.
29
+ */
30
+ id: number;
16
31
  /** The sender of the message. */
17
32
  role: "user" | "assistant";
18
33
  /** The text content of the message. */
@@ -29,8 +44,18 @@ export type ToolCallInfo = {
29
44
  args: Record<string, unknown>;
30
45
  status: "pending" | "done";
31
46
  result?: string | undefined;
32
- /** Index in the messages array where this tool call should appear. */
33
- afterMessageIndex: number;
47
+ /**
48
+ * Monotonically increasing, session-unique insertion sequence number.
49
+ * Tool calls in a snapshot are always sorted ascending by `seq`.
50
+ */
51
+ seq: number;
52
+ /**
53
+ * `id` of the last {@link ChatMessage} present when this tool call was
54
+ * inserted (`-1` when there were none). The tool call renders immediately
55
+ * after that message; if the anchor message has slid out of the retained
56
+ * window, the tool call renders before all messages.
57
+ */
58
+ afterMessageId: number;
34
59
  };
35
60
  export type { SessionErrorCode } from "@alexkroman1/aai/protocol";
36
61
  /**
@@ -85,9 +110,9 @@ export type WebSocketConstructor = {
85
110
  * @public
86
111
  */
87
112
  export type ClientTheme = {
88
- /** Background color. Default: `#101010`. */
113
+ /** Background color, also painted on `html`/`body`. Default: `#FBF8F2`. */
89
114
  bg?: string;
90
- /** Primary accent color. Default: `#fab283`. */
115
+ /** Primary accent color. Default: `#3F2BC1`. */
91
116
  primary?: string;
92
117
  /** Main text color. */
93
118
  text?: string;
package/dist/types.js CHANGED
@@ -1,5 +1,14 @@
1
1
  //#region types.ts
2
2
  /** Microphone buffer duration in seconds before sending to the server. */
3
3
  const MIC_BUFFER_SECONDS = .1;
4
+ /**
5
+ * Backpressure threshold for outbound mic audio. When the WebSocket's
6
+ * `bufferedAmount` exceeds this many bytes (~2s of 16 kHz PCM16), mic frames
7
+ * are dropped instead of queued — for live voice, stale audio flushed into
8
+ * STT on recovery is worse than a gap.
9
+ */
10
+ const MIC_SEND_MAX_BUFFERED_BYTES = 64 * 1024;
11
+ /** Poll interval while waiting out socket backpressure during a file send. */
12
+ const FILE_SEND_BACKOFF_MS = 50;
4
13
  //#endregion
5
- export { MIC_BUFFER_SECONDS };
14
+ export { FILE_SEND_BACKOFF_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES };
@@ -1,2 +1,4 @@
1
+ /** Raw worklet source — exported so tests can evaluate the processor directly. */
2
+ export declare const captureProcessorSource = "\nclass CaptureProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n this.recording = false;\n const opts = options.processorOptions || {};\n this.fromRate = opts.contextRate || sampleRate;\n this.toRate = opts.sttSampleRate || sampleRate;\n this.ratio = this.fromRate / this.toRate;\n this.needsResample = this.fromRate !== this.toRate;\n // Streaming-resampler state carried across process() blocks so the output\n // clock doesn't drift and there's no discontinuity at 128-sample block\n // boundaries. `prev` is the previous block's last input sample (used to\n // interpolate across the boundary); `pos` is the fractional read position,\n // in input samples, relative to an extended [prev, ...input] frame. Start\n // at 1 so the first output is input[0] (not the bogus initial prev).\n this.prev = 0;\n this.pos = 1;\n // Output buffer reused across process() calls -- the render quantum is a\n // fixed size, so allocating per call would churn the realtime audio thread.\n this.resampleBuf = null;\n // Int16 accumulation buffer: flushed to the main thread as one transferred\n // ArrayBuffer once ~bufferSeconds of output samples are batched. Sized 2x\n // the flush target so a whole render quantum always fits before flushing.\n this.targetSamples = Math.max(1, Math.round(this.toRate * (opts.bufferSeconds || 0.1)));\n this.pending = new Int16Array(this.targetSamples * 2);\n this.pendingLen = 0;\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.\n this.flush();\n this.recording = false;\n }\n };\n }\n\n resample(input) {\n const ratio = this.ratio;\n const n = input.length;\n if (n === 0) return new Float32Array(0);\n // At most ceil(n / ratio) + 1 outputs per block (pos starts in [0, ratio)).\n const maxLen = Math.ceil(n / ratio) + 1;\n if (this.resampleBuf === null || this.resampleBuf.length < maxLen) {\n this.resampleBuf = new Float32Array(maxLen);\n }\n const out = this.resampleBuf;\n // Extended frame: index 0 = prev (previous block's last sample),\n // index k>=1 = input[k-1]. Interpolate at fractional positions stepping by\n // ratio, carrying `pos` across calls to preserve the sample clock.\n let count = 0;\n let pos = this.pos;\n while (pos < n) {\n const idx = pos | 0;\n const frac = pos - idx;\n const a = idx === 0 ? this.prev : input[idx - 1];\n const b = input[idx];\n out[count++] = a + frac * (b - a);\n pos += ratio;\n }\n // Shift the origin to this block's last sample for the next call.\n this.prev = input[n - 1];\n this.pos = pos - n;\n return out.subarray(0, count);\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 process(inputs) {\n const input = inputs[0];\n if (!input || !input[0] || !this.recording) return true;\n\n const samples = this.needsResample ? this.resample(input[0]) : input[0];\n this.accumulate(samples);\n if (this.pendingLen >= this.targetSamples) this.flush();\n return true;\n }\n}\n\nregisterProcessor('capture-processor', CaptureProcessor);\n";
1
3
  declare const src: string;
2
4
  export default src;
@@ -1,5 +1,5 @@
1
1
  //#region worklets/capture-processor.ts
2
- const script = new Blob([`
2
+ const CaptureProcessorWorklet = `
3
3
  class CaptureProcessor extends AudioWorkletProcessor {
4
4
  constructor(options) {
5
5
  super();
@@ -9,49 +9,104 @@ class CaptureProcessor extends AudioWorkletProcessor {
9
9
  this.toRate = opts.sttSampleRate || sampleRate;
10
10
  this.ratio = this.fromRate / this.toRate;
11
11
  this.needsResample = this.fromRate !== this.toRate;
12
+ // Streaming-resampler state carried across process() blocks so the output
13
+ // clock doesn't drift and there's no discontinuity at 128-sample block
14
+ // boundaries. \`prev\` is the previous block's last input sample (used to
15
+ // interpolate across the boundary); \`pos\` is the fractional read position,
16
+ // in input samples, relative to an extended [prev, ...input] frame. Start
17
+ // at 1 so the first output is input[0] (not the bogus initial prev).
18
+ this.prev = 0;
19
+ this.pos = 1;
20
+ // Output buffer reused across process() calls -- the render quantum is a
21
+ // fixed size, so allocating per call would churn the realtime audio thread.
22
+ this.resampleBuf = null;
23
+ // Int16 accumulation buffer: flushed to the main thread as one transferred
24
+ // ArrayBuffer once ~bufferSeconds of output samples are batched. Sized 2x
25
+ // the flush target so a whole render quantum always fits before flushing.
26
+ this.targetSamples = Math.max(1, Math.round(this.toRate * (opts.bufferSeconds || 0.1)));
27
+ this.pending = new Int16Array(this.targetSamples * 2);
28
+ this.pendingLen = 0;
12
29
  this.port.onmessage = (e) => {
13
30
  if (e.data.event === 'start') this.recording = true;
14
- else if (e.data.event === 'stop') this.recording = false;
31
+ else if (e.data.event === 'stop') {
32
+ // Final flush so the tail of speech isn't dropped on close.
33
+ this.flush();
34
+ this.recording = false;
35
+ }
15
36
  };
16
37
  }
17
38
 
18
39
  resample(input) {
19
40
  const ratio = this.ratio;
20
- const outLen = Math.ceil(input.length / ratio);
21
- const out = new Float32Array(outLen);
22
- for (let i = 0; i < outLen; i++) {
23
- const srcIdx = i * ratio;
24
- const idx = srcIdx | 0;
25
- const frac = srcIdx - idx;
26
- const a = input[idx];
27
- const b = idx + 1 < input.length ? input[idx + 1] : a;
28
- out[i] = a + frac * (b - a);
41
+ const n = input.length;
42
+ if (n === 0) return new Float32Array(0);
43
+ // At most ceil(n / ratio) + 1 outputs per block (pos starts in [0, ratio)).
44
+ const maxLen = Math.ceil(n / ratio) + 1;
45
+ if (this.resampleBuf === null || this.resampleBuf.length < maxLen) {
46
+ this.resampleBuf = new Float32Array(maxLen);
29
47
  }
30
- return out;
48
+ const out = this.resampleBuf;
49
+ // Extended frame: index 0 = prev (previous block's last sample),
50
+ // index k>=1 = input[k-1]. Interpolate at fractional positions stepping by
51
+ // ratio, carrying \`pos\` across calls to preserve the sample clock.
52
+ let count = 0;
53
+ let pos = this.pos;
54
+ while (pos < n) {
55
+ const idx = pos | 0;
56
+ const frac = pos - idx;
57
+ const a = idx === 0 ? this.prev : input[idx - 1];
58
+ const b = input[idx];
59
+ out[count++] = a + frac * (b - a);
60
+ pos += ratio;
61
+ }
62
+ // Shift the origin to this block's last sample for the next call.
63
+ this.prev = input[n - 1];
64
+ this.pos = pos - n;
65
+ return out.subarray(0, count);
31
66
  }
32
67
 
33
- process(inputs) {
34
- const input = inputs[0];
35
- if (!input || !input[0] || !this.recording) return true;
36
-
37
- const raw = input[0];
38
- const samples = this.needsResample ? this.resample(raw) : raw;
39
-
40
- // Convert Float32 -> Int16
41
- const buffer = new ArrayBuffer(samples.length * 2);
42
- const view = new DataView(buffer);
68
+ // Convert Float32 -> Int16 and append to the pending batch. Writes through
69
+ // an Int16Array directly (assignment truncates like DataView.setInt16).
70
+ accumulate(samples) {
71
+ let buf = this.pending;
72
+ if (this.pendingLen + samples.length > buf.length) {
73
+ // Defensive: only reachable if a render quantum outproduces the 1x
74
+ // headroom above the flush target (never with 128-sample quanta).
75
+ const grown = new Int16Array((this.pendingLen + samples.length) * 2);
76
+ grown.set(buf.subarray(0, this.pendingLen));
77
+ this.pending = grown;
78
+ buf = grown;
79
+ }
43
80
  for (let i = 0; i < samples.length; i++) {
44
81
  const s = Math.max(-1, Math.min(1, samples[i]));
45
- view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
82
+ buf[this.pendingLen++] = s < 0 ? s * 0x8000 : s * 0x7fff;
46
83
  }
84
+ }
47
85
 
86
+ // Post the batched samples as one transferred ArrayBuffer and reset.
87
+ flush() {
88
+ if (this.pendingLen === 0) return;
89
+ const buffer = this.pending.buffer.slice(0, this.pendingLen * 2);
90
+ this.pendingLen = 0;
48
91
  this.port.postMessage({ event: 'chunk', buffer }, [buffer]);
92
+ }
93
+
94
+ process(inputs) {
95
+ const input = inputs[0];
96
+ if (!input || !input[0] || !this.recording) return true;
97
+
98
+ const samples = this.needsResample ? this.resample(input[0]) : input[0];
99
+ this.accumulate(samples);
100
+ if (this.pendingLen >= this.targetSamples) this.flush();
49
101
  return true;
50
102
  }
51
103
  }
52
104
 
53
105
  registerProcessor('capture-processor', CaptureProcessor);
54
- `], { type: "application/javascript" });
106
+ `;
107
+ /** Raw worklet source — exported so tests can evaluate the processor directly. */
108
+ const captureProcessorSource = CaptureProcessorWorklet;
109
+ const script = new Blob([CaptureProcessorWorklet], { type: "application/javascript" });
55
110
  const src = URL.createObjectURL(script);
56
111
  //#endregion
57
- export { src as default };
112
+ export { captureProcessorSource, src as default };
@@ -1,2 +1,4 @@
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 rate = options.processorOptions?.sampleRate ?? 24000;\n // Wait for ~400ms of audio before starting.\n // If 'done' arrives first (short utterance), start immediately.\n this.jitterSamples = Math.floor(rate * 0.4);\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 // Carry-over byte for split samples across chunks\n this.carry = null;\n this.writePos = 0;\n this.readPos = 0;\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 stopTurn() {\n this.port.postMessage({ event: 'stop' });\n this.resetTurn();\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 const out = outputs[0][0];\n if (this.interrupted) {\n out.fill(0);\n this.stopTurn();\n return true;\n }\n\n const avail = this.writePos - this.readPos;\n\n // Wait for jitter buffer to fill, unless done (short utterance)\n if (!this.playing) {\n if (avail >= this.jitterSamples || this.isDone) {\n this.playing = true;\n } else {\n out.fill(0);\n return true;\n }\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 out.fill(0, n);\n return true;\n }\n\n // No data: output silence, end the turn only when done\n out.fill(0);\n if (this.isDone) {\n this.stopTurn();\n }\n return true;\n }\n}\n\nregisterProcessor('playback-processor', PlaybackProcessor);\n";
1
3
  declare const src: string;
2
4
  export default src;
@@ -1,24 +1,24 @@
1
1
  //#region worklets/playback-processor.ts
2
- const script = new Blob([`
2
+ const PlaybackProcessorWorklet = `
3
3
  class PlaybackProcessor extends AudioWorkletProcessor {
4
4
  constructor(options) {
5
5
  super();
6
- this.interrupted = false;
7
- this.isDone = false;
8
- this.playing = false;
9
6
  const rate = options.processorOptions?.sampleRate ?? 24000;
10
7
  // Wait for ~400ms of audio before starting.
11
8
  // If 'done' arrives first (short utterance), start immediately.
12
9
  this.jitterSamples = Math.floor(rate * 0.4);
13
- // Carry-over byte for split samples across chunks
14
- this.carry = null;
15
- // Float32 sample buffer — 60s at the context sample rate
16
- this.samples = new Float32Array(rate * 60);
17
- this.writePos = 0;
18
- this.readPos = 0;
19
- // Report playback position every ~50ms for word-level text sync
20
- this.progressInterval = Math.floor(rate * 0.05);
21
- this.samplesSinceProgress = 0;
10
+ // Float32 ring buffer — 60s at the context sample rate. Allocated once for
11
+ // the node's lifetime; per-turn state resets via resetTurn(). writePos and
12
+ // readPos are absolute (monotonic) sample counts; the buffer is indexed
13
+ // modulo capacity so a reply longer than 60s keeps playing instead of
14
+ // writing past the end and going silent.
15
+ this.capacity = rate * 60;
16
+ this.samples = new Float32Array(this.capacity);
17
+ // Platform endianness probe: the wire format is PCM16 little-endian, so
18
+ // the Int16Array fast path in ingestBytes is only valid on LE hosts
19
+ // (every shipping browser target; the DataView path is the fallback).
20
+ this.littleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;
21
+ this.resetTurn();
22
22
 
23
23
  this.port.onmessage = (e) => {
24
24
  const d = e.data;
@@ -32,6 +32,26 @@ class PlaybackProcessor extends AudioWorkletProcessor {
32
32
  };
33
33
  }
34
34
 
35
+ // Reset per-turn state so the node is reusable across replies without
36
+ // reallocating the sample buffer or re-instantiating the worklet.
37
+ resetTurn() {
38
+ this.interrupted = false;
39
+ this.isDone = false;
40
+ this.playing = false;
41
+ // Carry-over byte for split samples across chunks
42
+ this.carry = null;
43
+ this.writePos = 0;
44
+ this.readPos = 0;
45
+ }
46
+
47
+ // End the current turn: notify the host and rearm for the next reply.
48
+ // Must NOT return false from process() — a processor that stops is dead
49
+ // for good, forcing a new node (and buffer) per reply.
50
+ stopTurn() {
51
+ this.port.postMessage({ event: 'stop' });
52
+ this.resetTurn();
53
+ }
54
+
35
55
  ingestBytes(uint8) {
36
56
  let bytes = uint8;
37
57
 
@@ -49,18 +69,48 @@ class PlaybackProcessor extends AudioWorkletProcessor {
49
69
  }
50
70
 
51
71
  if (bytes.length === 0) return;
52
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
53
72
  const numSamples = bytes.length / 2;
54
- for (let i = 0; i < numSamples; i++) {
55
- this.samples[this.writePos++] = view.getInt16(i * 2, true) / 0x8000;
73
+ const cap = this.capacity;
74
+ const samples = this.samples;
75
+ if (this.littleEndian && (bytes.byteOffset & 1) === 0) {
76
+ // Fast path: 2-byte-aligned LE bytes wrap directly as an Int16Array;
77
+ // copy wrap-aware in at most two runs with no per-sample DataView call
78
+ // or modulo. This runs on the realtime audio thread.
79
+ const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, numSamples);
80
+ let src = 0;
81
+ let dst = this.writePos % cap;
82
+ while (src < numSamples) {
83
+ const run = Math.min(numSamples - src, cap - dst);
84
+ for (let j = 0; j < run; j++) {
85
+ samples[dst + j] = int16[src + j] / 0x8000;
86
+ }
87
+ src += run;
88
+ dst = 0;
89
+ }
90
+ } else {
91
+ // Odd byte offset (or big-endian host): fall back to per-sample reads.
92
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
93
+ let dst = this.writePos % cap;
94
+ for (let i = 0; i < numSamples; i++) {
95
+ samples[dst] = view.getInt16(i * 2, true) / 0x8000;
96
+ dst++;
97
+ if (dst === cap) dst = 0;
98
+ }
99
+ }
100
+ this.writePos += numSamples;
101
+ // If the producer outran the consumer by more than the buffer holds, drop
102
+ // the oldest unplayed audio rather than reading samples we've overwritten.
103
+ if (this.writePos - this.readPos > this.capacity) {
104
+ this.readPos = this.writePos - this.capacity;
56
105
  }
57
106
  }
58
107
 
59
108
  process(inputs, outputs) {
60
109
  const out = outputs[0][0];
61
110
  if (this.interrupted) {
62
- this.port.postMessage({ event: 'stop' });
63
- return false;
111
+ out.fill(0);
112
+ this.stopTurn();
113
+ return true;
64
114
  }
65
115
 
66
116
  const avail = this.writePos - this.readPos;
@@ -77,29 +127,30 @@ class PlaybackProcessor extends AudioWorkletProcessor {
77
127
 
78
128
  if (avail > 0) {
79
129
  const n = Math.min(avail, out.length);
80
- out.set(this.samples.subarray(this.readPos, this.readPos + n));
130
+ // Copy from the ring buffer, splitting across the wrap boundary.
131
+ const start = this.readPos % this.capacity;
132
+ const first = Math.min(n, this.capacity - start);
133
+ out.set(this.samples.subarray(start, start + first), 0);
134
+ if (n > first) out.set(this.samples.subarray(0, n - first), first);
81
135
  this.readPos += n;
82
- this.samplesSinceProgress += n;
83
- if (this.samplesSinceProgress >= this.progressInterval) {
84
- this.port.postMessage({ event: 'progress', readPos: this.readPos });
85
- this.samplesSinceProgress = 0;
86
- }
87
136
  out.fill(0, n);
88
137
  return true;
89
138
  }
90
139
 
91
- // No data: output silence, stop only when done
140
+ // No data: output silence, end the turn only when done
92
141
  out.fill(0);
93
142
  if (this.isDone) {
94
- this.port.postMessage({ event: 'stop' });
95
- return false;
143
+ this.stopTurn();
96
144
  }
97
145
  return true;
98
146
  }
99
147
  }
100
148
 
101
149
  registerProcessor('playback-processor', PlaybackProcessor);
102
- `], { type: "application/javascript" });
150
+ `;
151
+ /** Raw worklet source — exported so tests can evaluate the processor directly. */
152
+ const playbackProcessorSource = PlaybackProcessorWorklet;
153
+ const script = new Blob([PlaybackProcessorWorklet], { type: "application/javascript" });
103
154
  const src = URL.createObjectURL(script);
104
155
  //#endregion
105
- export { src as default };
156
+ export { src as default, playbackProcessorSource };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -18,12 +18,12 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "clsx": "^2.1.1",
21
- "@alexkroman1/aai": "1.8.2"
21
+ "@alexkroman1/aai": "1.9.0"
22
22
  },
23
23
  "peerDependencies": {
24
- "react": "^19.0.0",
25
- "react-dom": "^19.0.0",
26
- "tailwindcss": "^4.2.1"
24
+ "react": "^19.2.8",
25
+ "react-dom": "^19.2.8",
26
+ "tailwindcss": "^4.3.2"
27
27
  },
28
28
  "peerDependenciesMeta": {
29
29
  "tailwindcss": {
@@ -31,19 +31,19 @@
31
31
  }
32
32
  },
33
33
  "devDependencies": {
34
- "@tailwindcss/vite": "^4.2.1",
35
- "@testing-library/dom": "^10.0.0",
36
- "@testing-library/react": "^16.3.0",
37
- "@types/react": "^19.0.0",
38
- "@types/react-dom": "^19.0.0",
39
- "@vitejs/plugin-react": "^6.0.1",
40
- "jsdom": "^29.0.2",
41
- "react": "^19.0.0",
42
- "react-dom": "^19.0.0",
43
- "tsdown": "^0.21.7",
44
- "tsx": "^4.21.0",
45
- "vite": "^8.0.8",
46
- "vitest": "^4.1.3"
34
+ "@tailwindcss/vite": "^4.3.3",
35
+ "@testing-library/dom": "^10.4.1",
36
+ "@testing-library/react": "^16.3.2",
37
+ "@types/react": "^19.2.17",
38
+ "@types/react-dom": "^19.2.3",
39
+ "@vitejs/plugin-react": "^6.0.4",
40
+ "jsdom": "^29.1.1",
41
+ "react": "^19.2.8",
42
+ "react-dom": "^19.2.8",
43
+ "tsdown": "^0.22.13",
44
+ "tsx": "^4.23.1",
45
+ "vite": "^8.1.5",
46
+ "vitest": "^4.1.10"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=24"
package/styles.css CHANGED
@@ -3,6 +3,24 @@
3
3
  @source "./";
4
4
  @source "../dist/ui/";
5
5
 
6
+ /*
7
+ * AssemblyAI design system ("website refresh") tokens.
8
+ * - font-aai: Monument Grotesk when the host page loads it, warm system
9
+ * grotesque otherwise (the package ships no font binaries).
10
+ * - font-aai-serif: editorial serif for headings (Source Serif 4 stand-in,
11
+ * graceful Georgia-class fallbacks).
12
+ * - font-aai-mono: JetBrains Mono stand-in for code/tool rows.
13
+ * - radius-aai: the refresh's squared-off 4px control radius.
14
+ */
15
+ @theme {
16
+ --font-aai:
17
+ "Monument Grotesk", "ABC Monument Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;
18
+ --font-aai-serif:
19
+ "Source Serif 4", "Source Serif Pro", Charter, "Iowan Old Style", Georgia, serif;
20
+ --font-aai-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
21
+ --radius-aai: 4px;
22
+ }
23
+
6
24
  @layer base {
7
25
  html,
8
26
  body {
@@ -11,6 +29,18 @@
11
29
  }
12
30
  }
13
31
 
32
+ @keyframes aai-pulse {
33
+ 0%,
34
+ 100% {
35
+ opacity: 1;
36
+ transform: scale(1);
37
+ }
38
+ 50% {
39
+ opacity: 0.45;
40
+ transform: scale(0.82);
41
+ }
42
+ }
43
+
14
44
  @keyframes aai-bounce {
15
45
  0%,
16
46
  80%,