@alexkroman1/aai-ui 1.9.2 → 1.11.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 → _colors-DYX7XRTr.js} +8 -0
- package/dist/{_utils-5cs73OrA.js → _utils-CN1yVgYS.js} +2 -5
- package/dist/audio.d.ts +6 -0
- package/dist/audio.js +62 -9
- package/dist/{chat-view-C1XbqDk0.js → chat-view-u3yBAlig.js} +16 -14
- package/dist/components/_colors.d.ts +0 -4
- package/dist/components/chat-view.js +1 -1
- package/dist/components/controls.d.ts +2 -2
- package/dist/components/controls.js +1 -1
- package/dist/components/message-list.d.ts +2 -2
- package/dist/components/message-list.js +70 -64
- package/dist/components/start-screen.js +1 -1
- package/dist/components/text-controls.d.ts +2 -2
- package/dist/components/tool-call-block.d.ts +6 -2
- package/dist/components/tool-call-block.js +1 -1
- package/dist/context.d.ts +3 -11
- package/dist/context.js +9 -22
- package/dist/{controls-BngrPbOC.js → controls-B2EPUJDU.js} +12 -9
- package/dist/default-client/assets/audio-CSwpo0gM.js +1 -0
- package/dist/default-client/assets/{capture-processor-C19oBn4L.js → capture-processor-UlKEKyIW.js} +4 -1
- package/dist/default-client/assets/index-B2bISVbm.css +2 -0
- package/dist/default-client/assets/index-BTqSueyz.js +94 -0
- package/dist/default-client/assets/{playback-processor-BtlzAH78.js → playback-processor-C5HVRVbu.js} +11 -4
- package/dist/default-client/index.html +2 -2
- package/dist/define-client.js +3 -3
- package/dist/hooks.js +5 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +354 -5
- package/dist/{session-core-D3NDaySY.js → session-core-KUoY-0JO.js} +251 -114
- package/dist/session-core-audio-setup.d.ts +36 -0
- package/dist/session-core-messages.d.ts +12 -0
- package/dist/session-core-reconnect.d.ts +19 -0
- package/dist/session-core-url.d.ts +2 -0
- package/dist/session-core.js +1 -1
- package/dist/sync-mic.d.ts +49 -0
- package/dist/sync-session.d.ts +49 -0
- package/dist/sync-vad.d.ts +54 -0
- package/dist/{tool-call-block-7f1GTG-P.js → tool-call-block-DIxpG8GM.js} +11 -6
- package/dist/types.d.ts +12 -14
- package/dist/types.js +1 -13
- package/dist/worklets/capture-processor.d.ts +1 -1
- package/dist/worklets/capture-processor.js +4 -1
- package/dist/worklets/playback-processor.d.ts +1 -1
- package/dist/worklets/playback-processor.js +11 -4
- package/package.json +8 -5
- package/dist/default-client/assets/audio-Cs-6t_Wd.js +0 -1
- package/dist/default-client/assets/index-Bf4ZTNcx.js +0 -73
- package/dist/default-client/assets/index-Bzlh9i7w.css +0 -2
package/dist/default-client/assets/{playback-processor-BtlzAH78.js → playback-processor-C5HVRVbu.js}
RENAMED
|
@@ -46,8 +46,11 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
46
46
|
// End the current turn: notify the host and rearm for the next reply.
|
|
47
47
|
// Must NOT return false from process() — a processor that stops is dead
|
|
48
48
|
// for good, forcing a new node (and buffer) per reply.
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// \`reason\` ('interrupt' | 'done') tells the host which turn boundary this
|
|
50
|
+
// stop belongs to: interrupt-stops are dropped host-side (flush() already
|
|
51
|
+
// settled that turn), so they can never resolve a later turn's done() early.
|
|
52
|
+
stopTurn(reason) {
|
|
53
|
+
this.port.postMessage({ event: 'stop', reason });
|
|
51
54
|
this.resetTurn();
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -105,10 +108,14 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
process(inputs, outputs) {
|
|
111
|
+
// No output wired up yet — nothing to render this quantum. Throwing here
|
|
112
|
+
// would permanently kill the processor (the node is persistent per
|
|
113
|
+
// session), so guard like the capture processor does.
|
|
114
|
+
if (!outputs[0] || !outputs[0][0]) return true;
|
|
108
115
|
const out = outputs[0][0];
|
|
109
116
|
if (this.interrupted) {
|
|
110
117
|
out.fill(0);
|
|
111
|
-
this.stopTurn();
|
|
118
|
+
this.stopTurn('interrupt');
|
|
112
119
|
return true;
|
|
113
120
|
}
|
|
114
121
|
|
|
@@ -139,7 +146,7 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
139
146
|
// No data: output silence, end the turn only when done
|
|
140
147
|
out.fill(0);
|
|
141
148
|
if (this.isDone) {
|
|
142
|
-
this.stopTurn();
|
|
149
|
+
this.stopTurn('done');
|
|
143
150
|
}
|
|
144
151
|
return true;
|
|
145
152
|
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<title>aai</title>
|
|
7
7
|
<link rel="icon" href="data:," />
|
|
8
8
|
<style>html, body { background: #FBF8F2; margin: 0; }</style>
|
|
9
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-BTqSueyz.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-B2bISVbm.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<main id="app"></main>
|
package/dist/define-client.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { SessionProvider, ThemeProvider } from "./context.js";
|
|
2
|
-
import { n as ToolConfigContext } from "./tool-call-block-
|
|
3
|
-
import { t as ChatView } from "./chat-view-
|
|
2
|
+
import { n as ToolConfigContext } from "./tool-call-block-DIxpG8GM.js";
|
|
3
|
+
import { t as ChatView } from "./chat-view-u3yBAlig.js";
|
|
4
4
|
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
5
5
|
import { StartScreen } from "./components/start-screen.js";
|
|
6
|
-
import { t as createSessionCore } from "./session-core-
|
|
6
|
+
import { t as createSessionCore } from "./session-core-KUoY-0JO.js";
|
|
7
7
|
import { createElement } from "react";
|
|
8
8
|
import { jsx } from "react/jsx-runtime";
|
|
9
9
|
import { flushSync } from "react-dom";
|
package/dist/hooks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useSessionSelector } from "./context.js";
|
|
2
|
-
import { n as tryParseJSON } from "./_utils-
|
|
2
|
+
import { n as tryParseJSON } from "./_utils-CN1yVgYS.js";
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
4
|
//#region hooks.ts
|
|
5
5
|
/** Index of the first item whose sequence number is above the watermark (tail scan from the end). */
|
|
@@ -51,13 +51,16 @@ function useToolCallEffect(status, args, fire) {
|
|
|
51
51
|
seq: 0,
|
|
52
52
|
fired: /* @__PURE__ */ new Set()
|
|
53
53
|
});
|
|
54
|
+
const mountedRef = useRef(false);
|
|
54
55
|
const callbackRef = useRef(callback);
|
|
55
56
|
callbackRef.current = callback;
|
|
56
57
|
const fireRef = useRef(fire);
|
|
57
58
|
fireRef.current = fire;
|
|
58
59
|
useEffect(() => {
|
|
60
|
+
const firstRun = !mountedRef.current;
|
|
61
|
+
mountedRef.current = true;
|
|
59
62
|
processToolCallTail(toolCalls, cursorRef.current, (tc) => status === "pending" || tc.status === status, (tc) => {
|
|
60
|
-
if (tc.status !== status) return;
|
|
63
|
+
if (tc.status !== status && firstRun) return;
|
|
61
64
|
if (filterName && tc.name !== filterName) return;
|
|
62
65
|
fireRef.current(callbackRef.current, tc, filterName !== null);
|
|
63
66
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { Button } from "./components/button.tsx";
|
|
2
2
|
export { ChatView } from "./components/chat-view.tsx";
|
|
3
|
+
export { Controls } from "./components/controls.tsx";
|
|
3
4
|
export { MessageList } from "./components/message-list.tsx";
|
|
4
5
|
export { SidebarLayout } from "./components/sidebar-layout.tsx";
|
|
5
6
|
export { StartScreen } from "./components/start-screen.tsx";
|
|
@@ -14,4 +15,7 @@ export { client } from "./define-client.tsx";
|
|
|
14
15
|
export { useEvent, useToolCallStart, useToolResult } from "./hooks.ts";
|
|
15
16
|
export type { CustomEvent, SessionCore, SessionCoreOptions, SessionSnapshot, } from "./session-core.ts";
|
|
16
17
|
export { createSessionCore } from "./session-core.ts";
|
|
18
|
+
export { CAPTURE_WORKLET_DATA_URI, DEFAULT_SYNC_MIC_SAMPLE_RATE, floatToPcm16, type SyncMicrophone, type SyncMicrophoneOptions, startSyncMicrophone, } from "./sync-mic.ts";
|
|
19
|
+
export { base64ToPcm16, createSyncSession, pcm16ToBase64, type SyncSession, type SyncSessionOptions, type SyncTurnResult, } from "./sync-session.ts";
|
|
20
|
+
export { createUtteranceDetector, type UtteranceDetector, type UtteranceDetectorOptions, } from "./sync-vad.ts";
|
|
17
21
|
export type { AgentState, ChatMessage, ClientTheme, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, WebSocketConstructor, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,361 @@
|
|
|
1
1
|
import { SessionProvider, ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
|
|
2
2
|
import { Button } from "./components/button.js";
|
|
3
|
-
import { i as UiUrlChip, n as ApiUrlChip, r as SessionUrlChips } from "./controls-
|
|
4
|
-
import { n as ToolConfigContext } from "./tool-call-block-
|
|
3
|
+
import { i as UiUrlChip, n as ApiUrlChip, r as SessionUrlChips, t as Controls } from "./controls-B2EPUJDU.js";
|
|
4
|
+
import { n as ToolConfigContext } from "./tool-call-block-DIxpG8GM.js";
|
|
5
5
|
import { MessageList } from "./components/message-list.js";
|
|
6
|
-
import { n as TextControls, t as ChatView } from "./chat-view-
|
|
6
|
+
import { n as TextControls, t as ChatView } from "./chat-view-u3yBAlig.js";
|
|
7
7
|
import { SidebarLayout } from "./components/sidebar-layout.js";
|
|
8
8
|
import { StartScreen } from "./components/start-screen.js";
|
|
9
|
-
import { t as createSessionCore } from "./session-core-
|
|
9
|
+
import { t as createSessionCore } from "./session-core-KUoY-0JO.js";
|
|
10
10
|
import { client } from "./define-client.js";
|
|
11
11
|
import { useEvent, useToolCallStart, useToolResult } from "./hooks.js";
|
|
12
|
-
|
|
12
|
+
import { errorMessage, safeJsonParse } from "@alexkroman1/aai";
|
|
13
|
+
import { SyncTurnResponseSchema } from "@alexkroman1/aai/protocol";
|
|
14
|
+
//#region sync-vad.ts
|
|
15
|
+
const DEFAULT_SPEECH_RMS = .015;
|
|
16
|
+
const DEFAULT_MAX_UTTERANCE_MS = 3e4;
|
|
17
|
+
function rmsOf(frame) {
|
|
18
|
+
if (frame.length === 0) return 0;
|
|
19
|
+
let sum = 0;
|
|
20
|
+
for (const s of frame) {
|
|
21
|
+
const f = s / 32768;
|
|
22
|
+
sum += f * f;
|
|
23
|
+
}
|
|
24
|
+
return Math.sqrt(sum / frame.length);
|
|
25
|
+
}
|
|
26
|
+
function concat(frames) {
|
|
27
|
+
let total = 0;
|
|
28
|
+
for (const f of frames) total += f.length;
|
|
29
|
+
const out = new Int16Array(total);
|
|
30
|
+
let offset = 0;
|
|
31
|
+
for (const f of frames) {
|
|
32
|
+
out.set(f, offset);
|
|
33
|
+
offset += f.length;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/** Create an energy-based {@link UtteranceDetector}. */
|
|
38
|
+
function createUtteranceDetector(opts) {
|
|
39
|
+
const { sampleRate, speechRms = DEFAULT_SPEECH_RMS, minSpeechMs = 150, hangoverMs = 700, prerollMs = 300, maxUtteranceMs = DEFAULT_MAX_UTTERANCE_MS } = opts;
|
|
40
|
+
const msOf = (frame) => frame.length / sampleRate * 1e3;
|
|
41
|
+
let preroll = [];
|
|
42
|
+
let prerollTotalMs = 0;
|
|
43
|
+
let utterance = [];
|
|
44
|
+
let utteranceMs = 0;
|
|
45
|
+
let speaking = false;
|
|
46
|
+
/** Consecutive voiced ms while still a candidate (below minSpeechMs). */
|
|
47
|
+
let candidateVoicedMs = 0;
|
|
48
|
+
/** Consecutive silent ms while speaking (toward hangoverMs). */
|
|
49
|
+
let silenceMs = 0;
|
|
50
|
+
function toIdle() {
|
|
51
|
+
utterance = [];
|
|
52
|
+
utteranceMs = 0;
|
|
53
|
+
speaking = false;
|
|
54
|
+
candidateVoicedMs = 0;
|
|
55
|
+
silenceMs = 0;
|
|
56
|
+
}
|
|
57
|
+
function pushPreroll(frame) {
|
|
58
|
+
preroll.push(frame);
|
|
59
|
+
prerollTotalMs += msOf(frame);
|
|
60
|
+
while (preroll.length > 0 && prerollTotalMs - msOf(preroll[0]) >= prerollMs) prerollTotalMs -= msOf(preroll.shift());
|
|
61
|
+
}
|
|
62
|
+
function beginCandidate(frame) {
|
|
63
|
+
utterance = [...preroll, frame];
|
|
64
|
+
utteranceMs = prerollTotalMs + msOf(frame);
|
|
65
|
+
preroll = [];
|
|
66
|
+
prerollTotalMs = 0;
|
|
67
|
+
candidateVoicedMs = msOf(frame);
|
|
68
|
+
}
|
|
69
|
+
function finalize() {
|
|
70
|
+
const out = concat(utterance);
|
|
71
|
+
toIdle();
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function pushIdle(frame, voiced) {
|
|
75
|
+
if (!voiced) {
|
|
76
|
+
pushPreroll(frame);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
beginCandidate(frame);
|
|
80
|
+
}
|
|
81
|
+
function pushCandidate(frame, voiced) {
|
|
82
|
+
if (!voiced) {
|
|
83
|
+
for (const f of utterance) pushPreroll(f);
|
|
84
|
+
toIdle();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
utterance.push(frame);
|
|
88
|
+
utteranceMs += msOf(frame);
|
|
89
|
+
candidateVoicedMs += msOf(frame);
|
|
90
|
+
}
|
|
91
|
+
function pushSpeaking(frame, voiced) {
|
|
92
|
+
utterance.push(frame);
|
|
93
|
+
utteranceMs += msOf(frame);
|
|
94
|
+
silenceMs = voiced ? 0 : silenceMs + msOf(frame);
|
|
95
|
+
return silenceMs >= hangoverMs ? finalize() : null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
get speaking() {
|
|
99
|
+
return speaking;
|
|
100
|
+
},
|
|
101
|
+
push(frame) {
|
|
102
|
+
if (frame.length === 0) return null;
|
|
103
|
+
const voiced = rmsOf(frame) >= speechRms;
|
|
104
|
+
let closed = null;
|
|
105
|
+
if (speaking) closed = pushSpeaking(frame, voiced);
|
|
106
|
+
else if (utterance.length > 0) pushCandidate(frame, voiced);
|
|
107
|
+
else pushIdle(frame, voiced);
|
|
108
|
+
if (closed) return closed;
|
|
109
|
+
if (!speaking && candidateVoicedMs >= minSpeechMs) speaking = true;
|
|
110
|
+
if (speaking && utteranceMs >= maxUtteranceMs) return finalize();
|
|
111
|
+
return null;
|
|
112
|
+
},
|
|
113
|
+
flush() {
|
|
114
|
+
const out = speaking ? finalize() : null;
|
|
115
|
+
this.reset();
|
|
116
|
+
return out;
|
|
117
|
+
},
|
|
118
|
+
reset() {
|
|
119
|
+
preroll = [];
|
|
120
|
+
prerollTotalMs = 0;
|
|
121
|
+
toIdle();
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region sync-mic.ts
|
|
127
|
+
/**
|
|
128
|
+
* WebRTC microphone capture for sync mode.
|
|
129
|
+
*
|
|
130
|
+
* Captures voice through `getUserMedia` with the WebRTC voice-processing
|
|
131
|
+
* constraints (echo cancellation, noise suppression, auto gain — the
|
|
132
|
+
* processing that makes the energy VAD in `sync-vad.ts` reliable), runs an
|
|
133
|
+
* AudioWorklet that batches raw frames to the main thread, feeds them
|
|
134
|
+
* through the utterance detector, and hands each completed utterance to
|
|
135
|
+
* the sync session as one HTTP turn. No WebSocket anywhere on the path.
|
|
136
|
+
*
|
|
137
|
+
* The worklet module ships inline as a data URI, so sync mode needs no
|
|
138
|
+
* separately-served processor file.
|
|
139
|
+
*/
|
|
140
|
+
/** Default capture rate — what the STT providers expect. */
|
|
141
|
+
const DEFAULT_SYNC_MIC_SAMPLE_RATE = 16e3;
|
|
142
|
+
/** ~128 ms at 16 kHz: few messages per second, fine-enough VAD granularity. */
|
|
143
|
+
const CAPTURE_BATCH_SAMPLES = 2048;
|
|
144
|
+
/** Data URI form of the capture processor (no served asset, no blob URL). */
|
|
145
|
+
const CAPTURE_WORKLET_DATA_URI = `data:application/javascript;charset=utf-8,${encodeURIComponent(`
|
|
146
|
+
registerProcessor("aai-sync-capture", class extends AudioWorkletProcessor {
|
|
147
|
+
constructor(options) {
|
|
148
|
+
super();
|
|
149
|
+
const batch = (options && options.processorOptions && options.processorOptions.batchSamples) || ${CAPTURE_BATCH_SAMPLES};
|
|
150
|
+
this.buf = new Float32Array(batch);
|
|
151
|
+
this.len = 0;
|
|
152
|
+
}
|
|
153
|
+
process(inputs) {
|
|
154
|
+
const ch = inputs[0] && inputs[0][0];
|
|
155
|
+
if (!ch) return true;
|
|
156
|
+
let read = 0;
|
|
157
|
+
while (read < ch.length) {
|
|
158
|
+
const n = Math.min(ch.length - read, this.buf.length - this.len);
|
|
159
|
+
this.buf.set(ch.subarray(read, read + n), this.len);
|
|
160
|
+
this.len += n;
|
|
161
|
+
read += n;
|
|
162
|
+
if (this.len === this.buf.length) {
|
|
163
|
+
const out = this.buf;
|
|
164
|
+
this.port.postMessage({ event: "chunk", samples: out }, [out.buffer]);
|
|
165
|
+
this.buf = new Float32Array(out.length);
|
|
166
|
+
this.len = 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
`)}`;
|
|
173
|
+
/** Clamp-and-convert one Float32 capture batch to PCM16. */
|
|
174
|
+
function floatToPcm16(samples) {
|
|
175
|
+
const pcm = new Int16Array(samples.length);
|
|
176
|
+
let i = 0;
|
|
177
|
+
for (const sample of samples) {
|
|
178
|
+
const s = Math.max(-1, Math.min(1, sample));
|
|
179
|
+
pcm[i++] = s < 0 ? s * 32768 : s * 32767;
|
|
180
|
+
}
|
|
181
|
+
return pcm;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Open the microphone and stream endpointed utterances into a sync session.
|
|
185
|
+
*
|
|
186
|
+
* @throws If microphone access is denied or worklet registration fails.
|
|
187
|
+
*/
|
|
188
|
+
async function startSyncMicrophone(opts) {
|
|
189
|
+
const sampleRate = opts.sampleRate ?? 16e3;
|
|
190
|
+
const detector = createUtteranceDetector({
|
|
191
|
+
sampleRate,
|
|
192
|
+
...opts.vad
|
|
193
|
+
});
|
|
194
|
+
const fail = (err) => {
|
|
195
|
+
opts.onError?.(err instanceof Error ? err : new Error(errorMessage(err)));
|
|
196
|
+
};
|
|
197
|
+
const streamPromise = navigator.mediaDevices.getUserMedia({ audio: {
|
|
198
|
+
echoCancellation: true,
|
|
199
|
+
noiseSuppression: true,
|
|
200
|
+
autoGainControl: true
|
|
201
|
+
} });
|
|
202
|
+
const ctx = new AudioContext({
|
|
203
|
+
sampleRate,
|
|
204
|
+
latencyHint: "interactive"
|
|
205
|
+
});
|
|
206
|
+
let stream;
|
|
207
|
+
try {
|
|
208
|
+
[stream] = await Promise.all([
|
|
209
|
+
streamPromise,
|
|
210
|
+
ctx.resume(),
|
|
211
|
+
ctx.audioWorklet.addModule(CAPTURE_WORKLET_DATA_URI)
|
|
212
|
+
]);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
streamPromise.then((s) => {
|
|
215
|
+
for (const t of s.getTracks()) t.stop();
|
|
216
|
+
}).catch(() => {});
|
|
217
|
+
await ctx.close().catch(() => {});
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
const mic = ctx.createMediaStreamSource(stream);
|
|
221
|
+
const node = new AudioWorkletNode(ctx, "aai-sync-capture", {
|
|
222
|
+
channelCount: 1,
|
|
223
|
+
channelCountMode: "explicit",
|
|
224
|
+
processorOptions: { batchSamples: CAPTURE_BATCH_SAMPLES }
|
|
225
|
+
});
|
|
226
|
+
mic.connect(node);
|
|
227
|
+
let stopped = false;
|
|
228
|
+
let wasSpeaking = false;
|
|
229
|
+
function dispatch(utterance) {
|
|
230
|
+
if (!utterance) return;
|
|
231
|
+
opts.onSpeechEnd?.();
|
|
232
|
+
opts.session.sendPcm16(utterance, sampleRate).catch(fail);
|
|
233
|
+
}
|
|
234
|
+
function handleFrame(samples) {
|
|
235
|
+
if (stopped) return;
|
|
236
|
+
dispatch(detector.push(floatToPcm16(samples)));
|
|
237
|
+
if (detector.speaking && !wasSpeaking) opts.onSpeechStart?.();
|
|
238
|
+
wasSpeaking = detector.speaking;
|
|
239
|
+
}
|
|
240
|
+
node.port.onmessage = (e) => {
|
|
241
|
+
const data = e.data;
|
|
242
|
+
if (data.event === "chunk" && data.samples) handleFrame(data.samples);
|
|
243
|
+
};
|
|
244
|
+
node.onprocessorerror = () => fail(/* @__PURE__ */ new Error("Sync capture worklet crashed"));
|
|
245
|
+
return {
|
|
246
|
+
get speaking() {
|
|
247
|
+
return detector.speaking;
|
|
248
|
+
},
|
|
249
|
+
async stop() {
|
|
250
|
+
if (stopped) return;
|
|
251
|
+
stopped = true;
|
|
252
|
+
dispatch(detector.flush());
|
|
253
|
+
mic.disconnect();
|
|
254
|
+
node.disconnect();
|
|
255
|
+
for (const t of stream.getTracks()) t.stop();
|
|
256
|
+
await ctx.close().catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region sync-session.ts
|
|
262
|
+
/**
|
|
263
|
+
* Sync-mode browser session — HTTP turns, no WebSocket.
|
|
264
|
+
*
|
|
265
|
+
* The client half of the server's `POST /sync` endpoint (see
|
|
266
|
+
* `host/sync-turn.ts` in `@alexkroman1/aai`): each turn is one request
|
|
267
|
+
* carrying committed text or one endpointed utterance of PCM16 audio plus
|
|
268
|
+
* the conversation history, answered with the transcript, the reply text,
|
|
269
|
+
* and (when the agent's TTS provider supports one-shot synthesis) the
|
|
270
|
+
* spoken reply. The server holds no session state — this object owns the
|
|
271
|
+
* history and replays it every turn.
|
|
272
|
+
*
|
|
273
|
+
* Microphone capture and utterance endpointing live in `sync-mic.ts` /
|
|
274
|
+
* `sync-vad.ts`; this module is transport only, so it also runs in
|
|
275
|
+
* non-browser clients that bring their own audio.
|
|
276
|
+
*/
|
|
277
|
+
/** Base64-encode PCM16 samples (chunked — `btoa` takes a binary string). */
|
|
278
|
+
function pcm16ToBase64(pcm) {
|
|
279
|
+
const bytes = new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
|
280
|
+
let binary = "";
|
|
281
|
+
const CHUNK = 32768;
|
|
282
|
+
for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
283
|
+
return btoa(binary);
|
|
284
|
+
}
|
|
285
|
+
/** Decode base64 PCM16LE (the sync response's `audio` field) into samples. */
|
|
286
|
+
function base64ToPcm16(base64) {
|
|
287
|
+
const binary = atob(base64);
|
|
288
|
+
const samples = binary.length >> 1;
|
|
289
|
+
const bytes = new Uint8Array(samples * 2);
|
|
290
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
291
|
+
return new Int16Array(bytes.buffer, 0, samples);
|
|
292
|
+
}
|
|
293
|
+
/** Create a {@link SyncSession} against a sync-mode agent server. */
|
|
294
|
+
function createSyncSession(opts) {
|
|
295
|
+
const fetchFn = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
296
|
+
const history = [];
|
|
297
|
+
let queue = Promise.resolve();
|
|
298
|
+
async function parseTurn(resp) {
|
|
299
|
+
if (!resp.ok) {
|
|
300
|
+
const detail = safeJsonParse(await resp.text().catch(() => "")) ?? {};
|
|
301
|
+
throw new Error(`Sync turn failed: HTTP ${resp.status}${detail.error ? ` (${detail.error})` : ""}`);
|
|
302
|
+
}
|
|
303
|
+
const parsed = SyncTurnResponseSchema.safeParse(await resp.json());
|
|
304
|
+
if (!parsed.success) throw new Error("Sync turn failed: malformed server response");
|
|
305
|
+
return parsed.data;
|
|
306
|
+
}
|
|
307
|
+
async function runTurn(body) {
|
|
308
|
+
try {
|
|
309
|
+
const turn = await parseTurn(await fetchFn(opts.url, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "Content-Type": "application/json" },
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
...body,
|
|
314
|
+
history: [...history]
|
|
315
|
+
})
|
|
316
|
+
}));
|
|
317
|
+
history.push({
|
|
318
|
+
role: "user",
|
|
319
|
+
content: turn.transcript
|
|
320
|
+
});
|
|
321
|
+
if (turn.reply.length > 0) history.push({
|
|
322
|
+
role: "assistant",
|
|
323
|
+
content: turn.reply
|
|
324
|
+
});
|
|
325
|
+
const result = {
|
|
326
|
+
...turn,
|
|
327
|
+
pcm: turn.audio !== void 0 ? base64ToPcm16(turn.audio) : null
|
|
328
|
+
};
|
|
329
|
+
opts.onTurn?.(result);
|
|
330
|
+
return result;
|
|
331
|
+
} catch (err) {
|
|
332
|
+
const wrapped = err instanceof Error ? err : new Error(errorMessage(err));
|
|
333
|
+
opts.onError?.(wrapped);
|
|
334
|
+
throw wrapped;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function enqueue(body) {
|
|
338
|
+
const next = queue.then(() => runTurn(body), () => runTurn(body));
|
|
339
|
+
queue = next.catch(() => void 0);
|
|
340
|
+
return next;
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
get history() {
|
|
344
|
+
return history;
|
|
345
|
+
},
|
|
346
|
+
sendText(text) {
|
|
347
|
+
return enqueue({ text });
|
|
348
|
+
},
|
|
349
|
+
sendPcm16(pcm, sampleRate) {
|
|
350
|
+
return enqueue({
|
|
351
|
+
audio: pcm16ToBase64(pcm),
|
|
352
|
+
sampleRate
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
reset() {
|
|
356
|
+
history.length = 0;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
export { ApiUrlChip, Button, CAPTURE_WORKLET_DATA_URI, ChatView, Controls, DEFAULT_SYNC_MIC_SAMPLE_RATE, MessageList, SessionProvider, SessionUrlChips, SidebarLayout, StartScreen, TextControls, ThemeProvider, ToolConfigContext, UiUrlChip, base64ToPcm16, client, createSessionCore, createSyncSession, createUtteranceDetector, floatToPcm16, pcm16ToBase64, startSyncMicrophone, useEvent, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult };
|