@alia.onl/sdk 13.2.0 → 15.2.1
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/CHANGELOG.md +129 -0
- package/README.md +52 -20
- package/package.json +8 -4
- package/src/components/AliaAvatarIcon.tsx +30 -0
- package/src/components/AliaChatContent.tsx +4 -7
- package/src/components/AliaChatMessageList.tsx +2 -10
- package/src/components/AliaChatScreen.tsx +1 -1
- package/src/components/AliaChatSheet.tsx +15 -4
- package/src/components/AliaComposer.tsx +57 -11
- package/src/components/voice/VoiceControls.tsx +28 -4
- package/src/components/voice/VoiceSession.tsx +23 -7
- package/src/hooks/useAliaActivity.ts +31 -0
- package/src/hooks/useAliaChat.ts +14 -3
- package/src/hooks/useAmbientWave.ts +3 -3
- package/src/hooks/useAudioLevelMonitor.ts +2 -2
- package/src/hooks/useDictation.ts +295 -0
- package/src/hooks/useTTS.ts +24 -63
- package/src/hooks/useVoiceRoom.ts +148 -736
- package/src/index.ts +9 -3
- package/src/lib/activity-socket.ts +102 -0
- package/src/lib/activity-store.ts +53 -0
- package/src/lib/api-url.ts +2 -0
- package/src/lib/audio-level.ts +2 -2
- package/src/lib/dictation-recorder-types.ts +37 -0
- package/src/lib/dictation-recorder.native.ts +90 -0
- package/src/lib/dictation-recorder.ts +131 -0
- package/src/lib/dictation.ts +208 -0
- package/src/lib/labels.tsx +3 -0
- package/src/lib/pcm.ts +57 -0
- package/src/lib/speech-language.ts +1 -1
- package/src/lib/speech-messages.ts +43 -97
- package/src/lib/speech-synthesis.ts +5 -1
- package/src/lib/turn-state.ts +15 -0
- package/src/lib/voice-audio-engine-types.ts +59 -0
- package/src/lib/voice-audio-engine.native.ts +232 -0
- package/src/lib/voice-audio-engine.ts +350 -0
- package/src/lib/voice-call.ts +539 -0
- package/src/lib/voice-levels.ts +7 -3
- package/src/lib/voice-protocol.ts +309 -0
- package/src/types.ts +49 -4
- package/src/voice.ts +31 -12
- package/tsconfig.json +2 -0
- package/src/hooks/useSpeechToText.ts +0 -268
- package/src/lib/speech-recognition-types.ts +0 -108
- package/src/lib/speech-recognition.native.ts +0 -237
- package/src/lib/speech-recognition.ts +0 -308
- package/src/lib/voice-text.ts +0 -198
- package/src/lib/voice-turn.ts +0 -89
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One voice call over Alia's `/v1/voice/session` socket (ADR 0017 §3–§4),
|
|
3
|
+
* without React: the hook (`useVoiceRoom`) owns one and renders its snapshots.
|
|
4
|
+
*
|
|
5
|
+
* Alia holds the realtime session with Kaana; turn detection and barge-in are
|
|
6
|
+
* the model's, on the server. The device only:
|
|
7
|
+
*
|
|
8
|
+
* - captures the microphone and sends it as PCM16 frames (nothing while muted);
|
|
9
|
+
* - plays the answer's PCM16 frames from a jitter-buffered queue;
|
|
10
|
+
* - when the server says the person started speaking, drops what it has not
|
|
11
|
+
* played and tells Alia how much of the answer was HEARD
|
|
12
|
+
* (`playback.interrupted`), so the model remembers what was said, not what it
|
|
13
|
+
* generated;
|
|
14
|
+
* - shows a state it can prove (`VoiceCallState`). "Listening" needs the
|
|
15
|
+
* session ready upstream, a first captured buffer and Alia's acknowledgement
|
|
16
|
+
* of the first appended audio; "speaking" needs the first answer frame
|
|
17
|
+
* actually playing.
|
|
18
|
+
*
|
|
19
|
+
* A socket that drops without `session.closed` is resumed within the session's
|
|
20
|
+
* resume window (`session.resume { afterSeq }`), and the call reads
|
|
21
|
+
* `reconnecting` meanwhile — never `listening`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { VoiceCallState, VoiceMessage } from '../types';
|
|
25
|
+
import type { VoiceErrorCode } from './speech-messages';
|
|
26
|
+
import type { VoiceLevelChannel } from './voice-levels';
|
|
27
|
+
import {
|
|
28
|
+
VoiceAudioEngineError,
|
|
29
|
+
type VoiceAudioEngine,
|
|
30
|
+
type VoiceAudioEngineHandlers,
|
|
31
|
+
} from './voice-audio-engine-types';
|
|
32
|
+
import {
|
|
33
|
+
decodeAssistantAudioFrame,
|
|
34
|
+
parseVoiceServerEvent,
|
|
35
|
+
VOICE_PROTOCOL_VERSION,
|
|
36
|
+
voiceSessionUrl,
|
|
37
|
+
type ProductVoice,
|
|
38
|
+
type ReceivedVoiceEvent,
|
|
39
|
+
type VoiceClientEvent,
|
|
40
|
+
type VoiceCloseReason,
|
|
41
|
+
} from './voice-protocol';
|
|
42
|
+
|
|
43
|
+
/** The part of a WebSocket a call uses: the browser's and React Native's both fit. */
|
|
44
|
+
export interface VoiceSocket {
|
|
45
|
+
binaryType: string;
|
|
46
|
+
readonly readyState: number;
|
|
47
|
+
onopen: ((event: unknown) => void) | null;
|
|
48
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
49
|
+
onclose: ((event: unknown) => void) | null;
|
|
50
|
+
onerror: ((event: unknown) => void) | null;
|
|
51
|
+
send(data: string | ArrayBufferLike | ArrayBufferView): void;
|
|
52
|
+
close(code?: number, reason?: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type VoiceSocketFactory = (url: string) => VoiceSocket;
|
|
56
|
+
|
|
57
|
+
const OPEN = 1;
|
|
58
|
+
|
|
59
|
+
/** How often the Oxy token is re-read, and re-sent when it changed. */
|
|
60
|
+
const TOKEN_REFRESH_MS = 4 * 60_000;
|
|
61
|
+
/** Between two attempts to resume a dropped socket. */
|
|
62
|
+
const RESUME_RETRY_MS = 1_000;
|
|
63
|
+
/** Until the server says otherwise in `session.ready`. */
|
|
64
|
+
const DEFAULT_RESUME_WINDOW_MS = 30_000;
|
|
65
|
+
|
|
66
|
+
export interface VoiceCallSnapshot {
|
|
67
|
+
readonly state: VoiceCallState;
|
|
68
|
+
readonly messages: readonly VoiceMessage[];
|
|
69
|
+
readonly isMuted: boolean;
|
|
70
|
+
/** Milliseconds left when the server warned that the call is about to end. */
|
|
71
|
+
readonly endingSoonMs: number | null;
|
|
72
|
+
/** The model serving the call, once the session is ready. */
|
|
73
|
+
readonly model: { id: string; displayName: string; publisher: string } | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface VoiceCallOptions {
|
|
77
|
+
readonly apiUrl: string;
|
|
78
|
+
/** The person's Oxy session token, read at connect and every few minutes. */
|
|
79
|
+
readonly getToken: () => string | null;
|
|
80
|
+
readonly conversationId: string;
|
|
81
|
+
readonly agentId?: string;
|
|
82
|
+
readonly voice: ProductVoice;
|
|
83
|
+
/** BCP-47. */
|
|
84
|
+
readonly locale: string;
|
|
85
|
+
readonly createEngine: () => VoiceAudioEngine;
|
|
86
|
+
readonly openSocket: VoiceSocketFactory;
|
|
87
|
+
readonly levels: VoiceLevelChannel;
|
|
88
|
+
readonly onChange: (snapshot: VoiceCallSnapshot) => void;
|
|
89
|
+
/** Alia stored messages of the call in the conversation. */
|
|
90
|
+
readonly onMessagesSaved?: (conversationId: string, messageIds: readonly string[]) => void;
|
|
91
|
+
/** A tool filed an action for the person's OK; the card is in the conversation. */
|
|
92
|
+
readonly onApprovalRequested?: (toolName: string) => void;
|
|
93
|
+
readonly tokenRefreshMs?: number;
|
|
94
|
+
readonly resumeRetryMs?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const LIVE: ReadonlySet<VoiceCallState['kind']> = new Set([
|
|
98
|
+
'listening',
|
|
99
|
+
'user-speaking',
|
|
100
|
+
'thinking',
|
|
101
|
+
'using-tool',
|
|
102
|
+
'speaking',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function asBytes(data: unknown): Uint8Array | null {
|
|
106
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
107
|
+
if (ArrayBuffer.isView(data))
|
|
108
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class VoiceCall {
|
|
113
|
+
private readonly options: VoiceCallOptions;
|
|
114
|
+
private state: VoiceCallState = { kind: 'idle' };
|
|
115
|
+
private messages: VoiceMessage[] = [];
|
|
116
|
+
private muted = false;
|
|
117
|
+
private endingSoonMs: number | null = null;
|
|
118
|
+
private model: VoiceCallSnapshot['model'] = null;
|
|
119
|
+
|
|
120
|
+
private engine: VoiceAudioEngine | null = null;
|
|
121
|
+
private socket: VoiceSocket | null = null;
|
|
122
|
+
private authenticated = false;
|
|
123
|
+
private lastToken: string | null = null;
|
|
124
|
+
private voiceSessionId: string | null = null;
|
|
125
|
+
private lastSeq = -1;
|
|
126
|
+
private resumeWindowMs = DEFAULT_RESUME_WINDOW_MS;
|
|
127
|
+
private droppedAt: number | null = null;
|
|
128
|
+
private resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
129
|
+
private tokenTimer: ReturnType<typeof setInterval> | null = null;
|
|
130
|
+
private finished = false;
|
|
131
|
+
|
|
132
|
+
/** The three facts "listening" needs. */
|
|
133
|
+
private ready = false;
|
|
134
|
+
private captured = false;
|
|
135
|
+
private inputReady = false;
|
|
136
|
+
|
|
137
|
+
/** Assistant items the person talked over: late frames of them are dropped. */
|
|
138
|
+
private readonly interrupted = new Set<string>();
|
|
139
|
+
private lastReceivedItem: string | null = null;
|
|
140
|
+
private playingItem: string | null = null;
|
|
141
|
+
private readonly activeTools = new Map<string, string>();
|
|
142
|
+
/** A tool ran since the last response ended: another response follows it. */
|
|
143
|
+
private toolRan = false;
|
|
144
|
+
/** The error Alia sent before closing, which the close then ends the call with. */
|
|
145
|
+
private serverError: VoiceErrorCode | null = null;
|
|
146
|
+
|
|
147
|
+
constructor(options: VoiceCallOptions) {
|
|
148
|
+
this.options = options;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
snapshot(): VoiceCallSnapshot {
|
|
152
|
+
return {
|
|
153
|
+
state: this.state,
|
|
154
|
+
messages: this.messages,
|
|
155
|
+
isMuted: this.muted,
|
|
156
|
+
endingSoonMs: this.endingSoonMs,
|
|
157
|
+
model: this.model,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Ask for the microphone, then open the socket and the session. */
|
|
162
|
+
async start(): Promise<void> {
|
|
163
|
+
if (this.state.kind !== 'idle') return;
|
|
164
|
+
this.setState({ kind: 'connecting' });
|
|
165
|
+
const token = this.options.getToken();
|
|
166
|
+
if (token === null || token === '') {
|
|
167
|
+
this.fail('not-authenticated');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const engine = this.options.createEngine();
|
|
171
|
+
this.engine = engine;
|
|
172
|
+
try {
|
|
173
|
+
await engine.start(this.engineHandlers());
|
|
174
|
+
} catch (caught: unknown) {
|
|
175
|
+
if (this.finished) return;
|
|
176
|
+
this.fail(caught instanceof VoiceAudioEngineError ? caught.code : 'voice-unavailable');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (this.finished) return;
|
|
180
|
+
this.openSocket(false);
|
|
181
|
+
this.tokenTimer = setInterval(
|
|
182
|
+
() => this.refreshToken(),
|
|
183
|
+
this.options.tokenRefreshMs ?? TOKEN_REFRESH_MS,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The person hung up, or the screen holding the call went away. */
|
|
188
|
+
hangUp(): void {
|
|
189
|
+
if (this.finished) return;
|
|
190
|
+
this.send({ type: 'session.end' });
|
|
191
|
+
this.finish({ kind: 'ended', reason: 'hangup' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setMuted(muted: boolean): void {
|
|
195
|
+
if (this.muted === muted) return;
|
|
196
|
+
this.muted = muted;
|
|
197
|
+
if (muted) this.options.levels.setCapture(0);
|
|
198
|
+
this.emit();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ------------------------------------------------------------------------ */
|
|
202
|
+
|
|
203
|
+
private engineHandlers(): VoiceAudioEngineHandlers {
|
|
204
|
+
return {
|
|
205
|
+
onCaptureFrame: (pcm) => {
|
|
206
|
+
if (this.finished) return;
|
|
207
|
+
if (!this.captured) {
|
|
208
|
+
this.captured = true;
|
|
209
|
+
this.maybeListening();
|
|
210
|
+
}
|
|
211
|
+
if (this.muted || !this.ready || !this.authenticated) return;
|
|
212
|
+
if (this.socket?.readyState !== OPEN) return;
|
|
213
|
+
this.socket.send(pcm);
|
|
214
|
+
},
|
|
215
|
+
onCaptureLevel: (level) => this.options.levels.setCapture(this.muted ? 0 : level),
|
|
216
|
+
onPlaybackStarted: (itemId) => {
|
|
217
|
+
if (this.finished || this.interrupted.has(itemId)) return;
|
|
218
|
+
this.playingItem = itemId;
|
|
219
|
+
if (LIVE.has(this.state.kind)) this.setState({ kind: 'speaking' });
|
|
220
|
+
},
|
|
221
|
+
onPlaybackLevel: (level) => this.options.levels.setPlayback(level),
|
|
222
|
+
onPlaybackIdle: () => {
|
|
223
|
+
this.playingItem = null;
|
|
224
|
+
if (this.state.kind === 'speaking') this.afterSpeech();
|
|
225
|
+
},
|
|
226
|
+
onError: (error) => this.fail(error.code),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private openSocket(resuming: boolean): void {
|
|
231
|
+
this.authenticated = false;
|
|
232
|
+
let socket: VoiceSocket;
|
|
233
|
+
try {
|
|
234
|
+
socket = this.options.openSocket(voiceSessionUrl(this.options.apiUrl));
|
|
235
|
+
} catch {
|
|
236
|
+
if (resuming) this.scheduleResume();
|
|
237
|
+
else this.fail('connection-lost');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
socket.binaryType = 'arraybuffer';
|
|
241
|
+
this.socket = socket;
|
|
242
|
+
socket.onopen = () => {
|
|
243
|
+
const token = this.options.getToken();
|
|
244
|
+
if (token === null || token === '') {
|
|
245
|
+
this.fail('not-authenticated');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
this.lastToken = token;
|
|
249
|
+
socket.send(JSON.stringify({ type: 'auth', token } satisfies VoiceClientEvent));
|
|
250
|
+
};
|
|
251
|
+
socket.onmessage = (event) => {
|
|
252
|
+
if (this.socket !== socket || this.finished) return;
|
|
253
|
+
if (typeof event.data === 'string') {
|
|
254
|
+
const parsed = parseVoiceServerEvent(event.data);
|
|
255
|
+
if (parsed !== null) this.handle(parsed, resuming);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const bytes = asBytes(event.data);
|
|
259
|
+
if (bytes !== null) this.handleAudio(bytes);
|
|
260
|
+
};
|
|
261
|
+
socket.onerror = () => undefined;
|
|
262
|
+
socket.onclose = () => {
|
|
263
|
+
if (this.socket !== socket || this.finished) return;
|
|
264
|
+
this.socket = null;
|
|
265
|
+
this.authenticated = false;
|
|
266
|
+
if (this.serverError !== null) {
|
|
267
|
+
this.fail(this.serverError);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (this.voiceSessionId === null) {
|
|
271
|
+
this.fail('connection-lost');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.droppedAt ??= Date.now();
|
|
275
|
+
this.setState({ kind: 'reconnecting' });
|
|
276
|
+
this.scheduleResume();
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private scheduleResume(): void {
|
|
281
|
+
if (this.finished) return;
|
|
282
|
+
const droppedAt = this.droppedAt ?? Date.now();
|
|
283
|
+
this.droppedAt = droppedAt;
|
|
284
|
+
if (Date.now() - droppedAt >= this.resumeWindowMs) {
|
|
285
|
+
this.fail('connection-lost');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (this.resumeTimer !== null) clearTimeout(this.resumeTimer);
|
|
289
|
+
this.resumeTimer = setTimeout(() => {
|
|
290
|
+
this.resumeTimer = null;
|
|
291
|
+
if (!this.finished) this.openSocket(true);
|
|
292
|
+
}, this.options.resumeRetryMs ?? RESUME_RETRY_MS);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private refreshToken(): void {
|
|
296
|
+
if (!this.authenticated || this.socket?.readyState !== OPEN) return;
|
|
297
|
+
const token = this.options.getToken();
|
|
298
|
+
if (token === null || token === '' || token === this.lastToken) return;
|
|
299
|
+
this.lastToken = token;
|
|
300
|
+
this.send({ type: 'auth', token });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private send(event: VoiceClientEvent): void {
|
|
304
|
+
if (this.socket?.readyState === OPEN) this.socket.send(JSON.stringify(event));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private handle(event: ReceivedVoiceEvent, resuming: boolean): void {
|
|
308
|
+
if (event.seq !== undefined) {
|
|
309
|
+
if (event.seq <= this.lastSeq) return;
|
|
310
|
+
this.lastSeq = event.seq;
|
|
311
|
+
}
|
|
312
|
+
switch (event.type) {
|
|
313
|
+
case 'auth.ok':
|
|
314
|
+
if (this.authenticated) return;
|
|
315
|
+
this.authenticated = true;
|
|
316
|
+
if (resuming && this.voiceSessionId !== null) {
|
|
317
|
+
this.send({
|
|
318
|
+
type: 'session.resume',
|
|
319
|
+
protocol: VOICE_PROTOCOL_VERSION,
|
|
320
|
+
voiceSessionId: this.voiceSessionId,
|
|
321
|
+
afterSeq: this.lastSeq,
|
|
322
|
+
});
|
|
323
|
+
} else {
|
|
324
|
+
this.send({
|
|
325
|
+
type: 'session.start',
|
|
326
|
+
protocol: VOICE_PROTOCOL_VERSION,
|
|
327
|
+
conversationId: this.options.conversationId,
|
|
328
|
+
...(this.options.agentId === undefined ? {} : { agentId: this.options.agentId }),
|
|
329
|
+
voice: this.options.voice,
|
|
330
|
+
locale: this.options.locale,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
case 'session.accepted':
|
|
335
|
+
this.voiceSessionId = event.voiceSessionId;
|
|
336
|
+
return;
|
|
337
|
+
case 'session.ready':
|
|
338
|
+
this.ready = true;
|
|
339
|
+
this.model = event.model;
|
|
340
|
+
this.resumeWindowMs = event.resumeWindowMs;
|
|
341
|
+
if (this.state.kind === 'reconnecting') this.setState({ kind: 'listening' });
|
|
342
|
+
else this.maybeListening();
|
|
343
|
+
this.emit();
|
|
344
|
+
return;
|
|
345
|
+
case 'input.ready':
|
|
346
|
+
this.inputReady = true;
|
|
347
|
+
this.maybeListening();
|
|
348
|
+
return;
|
|
349
|
+
case 'session.resumed':
|
|
350
|
+
this.droppedAt = null;
|
|
351
|
+
if (this.state.kind === 'reconnecting') this.setState({ kind: 'listening' });
|
|
352
|
+
return;
|
|
353
|
+
case 'session.reconnecting':
|
|
354
|
+
this.setState({ kind: 'reconnecting' });
|
|
355
|
+
return;
|
|
356
|
+
case 'user.speech_started':
|
|
357
|
+
this.bargeIn();
|
|
358
|
+
if (LIVE.has(this.state.kind)) this.setState({ kind: 'user-speaking' });
|
|
359
|
+
return;
|
|
360
|
+
case 'user.speech_stopped':
|
|
361
|
+
if (this.state.kind === 'user-speaking' || this.state.kind === 'listening')
|
|
362
|
+
this.setState({ kind: 'thinking' });
|
|
363
|
+
return;
|
|
364
|
+
case 'transcript.delta':
|
|
365
|
+
this.upsertMessage(event.role, event.itemId, (previous) => previous + event.text, true);
|
|
366
|
+
return;
|
|
367
|
+
case 'transcript.done':
|
|
368
|
+
this.upsertMessage(event.role, event.itemId, () => event.text, false);
|
|
369
|
+
return;
|
|
370
|
+
case 'assistant.audio_done':
|
|
371
|
+
return;
|
|
372
|
+
case 'assistant.done':
|
|
373
|
+
this.settleStreaming(event.itemId);
|
|
374
|
+
if (this.toolRan) {
|
|
375
|
+
// A response that called tools is followed by the one that answers.
|
|
376
|
+
this.toolRan = false;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (this.state.kind === 'thinking' && this.playingItem === null) this.afterSpeech();
|
|
380
|
+
return;
|
|
381
|
+
case 'tool.started':
|
|
382
|
+
this.activeTools.set(event.callId, event.name);
|
|
383
|
+
this.toolRan = true;
|
|
384
|
+
if (LIVE.has(this.state.kind)) this.setState({ kind: 'using-tool', tool: event.name });
|
|
385
|
+
return;
|
|
386
|
+
case 'tool.finished': {
|
|
387
|
+
this.activeTools.delete(event.callId);
|
|
388
|
+
if (this.state.kind !== 'using-tool') return;
|
|
389
|
+
const still = [...this.activeTools.values()].at(-1);
|
|
390
|
+
this.setState(
|
|
391
|
+
still === undefined ? { kind: 'thinking' } : { kind: 'using-tool', tool: still },
|
|
392
|
+
);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
case 'approval.requested':
|
|
396
|
+
this.options.onApprovalRequested?.(event.toolName);
|
|
397
|
+
return;
|
|
398
|
+
case 'messages.saved':
|
|
399
|
+
this.options.onMessagesSaved?.(event.conversationId, event.messageIds);
|
|
400
|
+
return;
|
|
401
|
+
case 'session.warning':
|
|
402
|
+
this.endingSoonMs = event.remainingMs;
|
|
403
|
+
this.emit();
|
|
404
|
+
return;
|
|
405
|
+
case 'error':
|
|
406
|
+
this.serverError = event.code;
|
|
407
|
+
return;
|
|
408
|
+
case 'session.closed':
|
|
409
|
+
this.closedByServer(event.reason);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private handleAudio(frame: Uint8Array): void {
|
|
415
|
+
const decoded = decodeAssistantAudioFrame(frame);
|
|
416
|
+
if (decoded === null || this.interrupted.has(decoded.itemId)) return;
|
|
417
|
+
this.lastReceivedItem = decoded.itemId;
|
|
418
|
+
// Copied: the socket's buffer is not the engine's to keep.
|
|
419
|
+
this.engine?.enqueue(decoded.itemId, decoded.pcm.slice());
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The person spoke: stop the answer now and report what of it was heard. */
|
|
423
|
+
private bargeIn(): void {
|
|
424
|
+
const received = this.lastReceivedItem;
|
|
425
|
+
if (received !== null) this.interrupted.add(received);
|
|
426
|
+
const engine = this.engine;
|
|
427
|
+
if (engine === null) return;
|
|
428
|
+
this.playingItem = null;
|
|
429
|
+
void engine.flush().then((heard) => {
|
|
430
|
+
if (heard === null || this.finished) return;
|
|
431
|
+
this.interrupted.add(heard.itemId);
|
|
432
|
+
this.send({ type: 'playback.interrupted', itemId: heard.itemId, playedMs: heard.playedMs });
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Back to listening once the answer has been said, or nothing more is coming. */
|
|
437
|
+
private afterSpeech(): void {
|
|
438
|
+
if (this.activeTools.size > 0) {
|
|
439
|
+
this.setState({ kind: 'using-tool', tool: [...this.activeTools.values()].at(-1)! });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
this.setState({ kind: 'listening' });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private maybeListening(): void {
|
|
446
|
+
if (this.state.kind !== 'connecting') return;
|
|
447
|
+
if (this.ready && this.captured && this.inputReady) this.setState({ kind: 'listening' });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private closedByServer(reason: VoiceCloseReason): void {
|
|
451
|
+
if (reason === 'error') {
|
|
452
|
+
this.fail(this.serverError ?? 'realtime-failed');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
this.finish({ kind: 'ended', reason });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private upsertMessage(
|
|
459
|
+
role: 'user' | 'assistant',
|
|
460
|
+
itemId: string,
|
|
461
|
+
content: (previous: string) => string,
|
|
462
|
+
isStreaming: boolean,
|
|
463
|
+
): void {
|
|
464
|
+
const id = `${role}-${itemId}`;
|
|
465
|
+
const index = this.messages.findIndex((message) => message.id === id);
|
|
466
|
+
if (index === -1) {
|
|
467
|
+
this.messages = [
|
|
468
|
+
...this.messages,
|
|
469
|
+
{
|
|
470
|
+
id,
|
|
471
|
+
role,
|
|
472
|
+
...(role === 'assistant' ? { speaker: 'primary' as const } : {}),
|
|
473
|
+
content: content(''),
|
|
474
|
+
timestamp: Date.now(),
|
|
475
|
+
isStreaming,
|
|
476
|
+
},
|
|
477
|
+
];
|
|
478
|
+
} else {
|
|
479
|
+
const previous = this.messages[index]!;
|
|
480
|
+
const next = [...this.messages];
|
|
481
|
+
next[index] = { ...previous, content: content(previous.content), isStreaming };
|
|
482
|
+
this.messages = next;
|
|
483
|
+
}
|
|
484
|
+
this.emit();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private settleStreaming(itemId: string | null): void {
|
|
488
|
+
if (itemId === null) return;
|
|
489
|
+
const id = `assistant-${itemId}`;
|
|
490
|
+
if (!this.messages.some((message) => message.id === id && message.isStreaming)) return;
|
|
491
|
+
this.messages = this.messages.map((message) =>
|
|
492
|
+
message.id === id ? { ...message, isStreaming: false } : message,
|
|
493
|
+
);
|
|
494
|
+
this.emit();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private fail(code: VoiceErrorCode): void {
|
|
498
|
+
this.finish({ kind: 'error', code });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private finish(state: VoiceCallState): void {
|
|
502
|
+
if (this.finished) return;
|
|
503
|
+
this.finished = true;
|
|
504
|
+
if (this.resumeTimer !== null) clearTimeout(this.resumeTimer);
|
|
505
|
+
if (this.tokenTimer !== null) clearInterval(this.tokenTimer);
|
|
506
|
+
this.resumeTimer = null;
|
|
507
|
+
this.tokenTimer = null;
|
|
508
|
+
this.engine?.stop();
|
|
509
|
+
this.engine = null;
|
|
510
|
+
const socket = this.socket;
|
|
511
|
+
this.socket = null;
|
|
512
|
+
if (socket !== null) {
|
|
513
|
+
socket.onopen = null;
|
|
514
|
+
socket.onmessage = null;
|
|
515
|
+
socket.onclose = null;
|
|
516
|
+
socket.onerror = null;
|
|
517
|
+
try {
|
|
518
|
+
socket.close(1000, 'call ended');
|
|
519
|
+
} catch {
|
|
520
|
+
// Already closed.
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
this.options.levels.reset();
|
|
524
|
+
this.messages = this.messages.map((message) =>
|
|
525
|
+
message.isStreaming ? { ...message, isStreaming: false } : message,
|
|
526
|
+
);
|
|
527
|
+
this.setState(state);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private setState(state: VoiceCallState): void {
|
|
531
|
+
if (this.finished && state.kind !== 'ended' && state.kind !== 'error') return;
|
|
532
|
+
this.state = state;
|
|
533
|
+
this.emit();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private emit(): void {
|
|
537
|
+
this.options.onChange(this.snapshot());
|
|
538
|
+
}
|
|
539
|
+
}
|
package/src/lib/voice-levels.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
/** A level reading clamped to 0..1; anything not a finite number is 0. */
|
|
2
|
+
export function clampLevel(level: number): number {
|
|
3
|
+
if (!Number.isFinite(level)) return 0;
|
|
4
|
+
return Math.min(1, Math.max(0, level));
|
|
5
|
+
}
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* The two live levels of a voice call — what the microphone hears and what the
|
|
5
9
|
* call is saying — for whatever draws them.
|
|
6
10
|
*
|
|
7
11
|
* It replaced the LiveKit `Room` that `useAudioLevelMonitor` used to take and
|
|
8
|
-
* put analysers on. The voice
|
|
9
|
-
*
|
|
12
|
+
* put analysers on. The voice call writes here from its audio engine's
|
|
13
|
+
* captured and played buffers; the monitor subscribes and hands React
|
|
10
14
|
* a throttled copy. Writes are cheap and never render anything by themselves.
|
|
11
15
|
*/
|
|
12
16
|
export interface VoiceLevelSource {
|