@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.
- package/dist/_colors-DJUordGv.js +19 -0
- package/dist/_utils-5cs73OrA.js +16 -0
- package/dist/_utils.d.ts +4 -0
- package/dist/aai-logo-B8lDmsut.js +84 -0
- package/dist/audio.d.ts +9 -2
- package/dist/audio.js +41 -24
- package/dist/chat-view-C1XbqDk0.js +186 -0
- package/dist/components/_colors.d.ts +26 -0
- package/dist/components/aai-logo.d.ts +7 -6
- package/dist/components/button.d.ts +11 -13
- package/dist/components/button.js +7 -16
- package/dist/components/chat-view.d.ts +8 -19
- package/dist/components/chat-view.js +2 -98
- package/dist/components/controls.d.ts +1 -1
- package/dist/components/controls.js +2 -39
- package/dist/components/eyebrow.d.ts +12 -0
- package/dist/components/message-list.d.ts +1 -1
- package/dist/components/message-list.js +126 -56
- package/dist/components/sidebar-layout.d.ts +1 -1
- package/dist/components/start-screen.d.ts +4 -3
- package/dist/components/start-screen.js +20 -14
- package/dist/components/text-controls.d.ts +14 -0
- package/dist/components/tool-call-block.d.ts +5 -3
- package/dist/components/tool-call-block.js +1 -1
- package/dist/components/url-chips.d.ts +28 -0
- package/dist/context.d.ts +30 -4
- package/dist/context.js +86 -10
- package/dist/controls-BngrPbOC.js +135 -0
- package/dist/default-client/assets/audio-Cs-6t_Wd.js +1 -0
- package/dist/default-client/assets/capture-processor-C19oBn4L.js +105 -0
- package/dist/default-client/assets/index-Bf4ZTNcx.js +73 -0
- package/dist/default-client/assets/index-Bzlh9i7w.css +2 -0
- package/dist/default-client/assets/playback-processor-BtlzAH78.js +149 -0
- package/dist/default-client/index.html +3 -3
- package/dist/define-client.d.ts +2 -2
- package/dist/define-client.js +18 -24
- package/dist/eyebrow-C6ZFuiz6.js +27 -0
- package/dist/hooks.js +71 -42
- package/dist/index.d.ts +3 -1
- package/dist/index.js +6 -5
- package/dist/session-core-D3NDaySY.js +652 -0
- package/dist/session-core-messages.d.ts +50 -0
- package/dist/session-core-types.d.ts +147 -0
- package/dist/session-core-upload.d.ts +16 -0
- package/dist/session-core.d.ts +2 -68
- package/dist/session-core.js +1 -455
- package/dist/{tool-call-block-wby_jyoY.js → tool-call-block-7f1GTG-P.js} +33 -31
- package/dist/types.d.ts +29 -4
- package/dist/types.js +10 -1
- package/dist/worklets/capture-processor.d.ts +2 -0
- package/dist/worklets/capture-processor.js +80 -25
- package/dist/worklets/playback-processor.d.ts +2 -0
- package/dist/worklets/playback-processor.js +80 -29
- package/package.json +18 -18
- package/styles.css +30 -0
- package/dist/_react-test-utils.d.ts +0 -86
- package/dist/aai-logo-BqFv6JU6.js +0 -21
- package/dist/default-client/assets/audio-UowwhmYo.js +0 -1
- package/dist/default-client/assets/capture-processor-C26lSiVr.js +0 -53
- package/dist/default-client/assets/index-BZqVGR2o.css +0 -2
- package/dist/default-client/assets/index-YW9WUhbL.js +0 -49
- 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
|
-
|
|
26
|
-
|
|
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
|
|
33
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
100
|
-
style: { color:
|
|
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-
|
|
105
|
-
style: { color: "
|
|
106
|
-
children:
|
|
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-
|
|
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
|
|
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
|
|
124
|
-
style: { color: "
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
|
113
|
+
/** Background color, also painted on `html`/`body`. Default: `#FBF8F2`. */
|
|
89
114
|
bg?: string;
|
|
90
|
-
/** Primary accent color. Default: `#
|
|
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
|
|
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')
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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,
|
|
140
|
+
// No data: output silence, end the turn only when done
|
|
92
141
|
out.fill(0);
|
|
93
142
|
if (this.isDone) {
|
|
94
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
21
|
+
"@alexkroman1/aai": "1.9.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"react": "^19.
|
|
25
|
-
"react-dom": "^19.
|
|
26
|
-
"tailwindcss": "^4.2
|
|
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.
|
|
35
|
-
"@testing-library/dom": "^10.
|
|
36
|
-
"@testing-library/react": "^16.3.
|
|
37
|
-
"@types/react": "^19.
|
|
38
|
-
"@types/react-dom": "^19.
|
|
39
|
-
"@vitejs/plugin-react": "^6.0.
|
|
40
|
-
"jsdom": "^29.
|
|
41
|
-
"react": "^19.
|
|
42
|
-
"react-dom": "^19.
|
|
43
|
-
"tsdown": "^0.
|
|
44
|
-
"tsx": "^4.
|
|
45
|
-
"vite": "^8.
|
|
46
|
-
"vitest": "^4.1.
|
|
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%,
|