@lokutor/sdk 1.1.17 → 1.1.20
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/index.d.mts +193 -4
- package/dist/index.d.ts +193 -4
- package/dist/index.js +963 -219
- package/dist/index.mjs +960 -63
- package/package.json +13 -4
- package/src/audio-utils.ts +253 -0
- package/src/browser-audio.ts +395 -0
- package/src/client.ts +886 -0
- package/src/conversational-panel.ts +606 -0
- package/src/index.ts +27 -0
- package/src/node-audio.ts +115 -0
- package/src/types.ts +270 -0
- package/dist/chunk-UI24THO7.mjs +0 -44
- package/dist/node-audio-5HOWE6MC.mjs +0 -94
package/src/client.ts
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VoiceStyle,
|
|
3
|
+
Language,
|
|
4
|
+
DEFAULT_URLS,
|
|
5
|
+
LokutorConfig,
|
|
6
|
+
SynthesizeOptions,
|
|
7
|
+
Viseme,
|
|
8
|
+
ToolDefinition,
|
|
9
|
+
ToolCall,
|
|
10
|
+
LokutorError,
|
|
11
|
+
ErrorCode,
|
|
12
|
+
isRetryable,
|
|
13
|
+
VoiceInfo,
|
|
14
|
+
LanguageInfo,
|
|
15
|
+
ModelInfo,
|
|
16
|
+
ServerConfig,
|
|
17
|
+
ServerStatus,
|
|
18
|
+
HealthStatus,
|
|
19
|
+
} from './types';
|
|
20
|
+
import { BrowserAudioManager } from './browser-audio';
|
|
21
|
+
|
|
22
|
+
function sdkTraceEnabled(): boolean {
|
|
23
|
+
try {
|
|
24
|
+
if (typeof window === 'undefined') return false;
|
|
25
|
+
const w = window as any;
|
|
26
|
+
return Boolean(w.LOKUTOR_TRACE) || window.localStorage?.getItem('lokutorTrace') === '1';
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sdkTrace(...args: any[]) {
|
|
33
|
+
if (sdkTraceEnabled()) {
|
|
34
|
+
console.log('[SDK TRACE]', ...args);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nowMs(): number {
|
|
39
|
+
if (typeof performance !== 'undefined' && performance.now) {
|
|
40
|
+
return performance.now();
|
|
41
|
+
}
|
|
42
|
+
return Date.now();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function wsToHttp(url: string): string {
|
|
46
|
+
return url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T> {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(url, {
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
headers: { Accept: 'application/json' },
|
|
56
|
+
});
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
throw new LokutorError('internal.error', `HTTP ${res.status} from ${url}`, {
|
|
60
|
+
detail: await res.text().catch(() => ''),
|
|
61
|
+
retryable: res.status >= 500,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return (await res.json()) as T;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
if (err instanceof LokutorError) throw err;
|
|
68
|
+
throw new LokutorError('internal.error', `Failed to fetch ${url}`, {
|
|
69
|
+
original: err,
|
|
70
|
+
retryable: true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Interface for audio hardware management (Browser/Node parity)
|
|
77
|
+
*/
|
|
78
|
+
export interface AudioManager {
|
|
79
|
+
init(): Promise<void>;
|
|
80
|
+
startMicrophone(onAudioInput: (pcm16Data: Uint8Array) => void): Promise<void>;
|
|
81
|
+
stopMicrophone(): void;
|
|
82
|
+
playAudio(pcm16Data: Uint8Array): void;
|
|
83
|
+
stopPlayback(): void;
|
|
84
|
+
cleanup(): void;
|
|
85
|
+
isMicMuted(): boolean;
|
|
86
|
+
setMuted(muted: boolean): void;
|
|
87
|
+
getAmplitude(): number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Browser-compatible base64 to Uint8Array
|
|
91
|
+
function base64ToUint8Array(base64: string): Uint8Array {
|
|
92
|
+
const binaryString = atob(base64);
|
|
93
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
94
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
95
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
96
|
+
}
|
|
97
|
+
return bytes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeVisemes(payload: any): Viseme[] {
|
|
101
|
+
if (!Array.isArray(payload)) return [];
|
|
102
|
+
const normalized: Viseme[] = [];
|
|
103
|
+
for (const item of payload) {
|
|
104
|
+
if (!item || typeof item !== 'object') continue;
|
|
105
|
+
const c = String(item.c ?? item.char ?? 'sil').toLowerCase();
|
|
106
|
+
const t = Number(item.t ?? item.timestamp ?? 0);
|
|
107
|
+
const v = Number(item.v ?? item.id ?? 0);
|
|
108
|
+
normalized.push({
|
|
109
|
+
v: Number.isFinite(v) ? v : 0,
|
|
110
|
+
c,
|
|
111
|
+
t: Number.isFinite(t) ? t : 0,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extractVisemePayload(msg: any): Viseme[] {
|
|
118
|
+
if (Array.isArray(msg?.data)) {
|
|
119
|
+
return normalizeVisemes(msg.data);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (Array.isArray(msg?.data?.visemes)) {
|
|
123
|
+
return normalizeVisemes(msg.data.visemes);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (msg?.data && !Array.isArray(msg.data) && typeof msg.data === 'object') {
|
|
127
|
+
const singularInData = normalizeVisemes([msg.data]);
|
|
128
|
+
if (singularInData.length > 0) return singularInData;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (msg && !Array.isArray(msg) && typeof msg === 'object') {
|
|
132
|
+
const singularAtRoot = normalizeVisemes([msg]);
|
|
133
|
+
if (singularAtRoot.length > 0) return singularAtRoot;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Main client for Lokutor Voice Agent SDK
|
|
141
|
+
*
|
|
142
|
+
* Provides a high-level interface for real-time voice conversations.
|
|
143
|
+
*/
|
|
144
|
+
export class VoiceAgentClient {
|
|
145
|
+
private ws: WebSocket | null = null;
|
|
146
|
+
private apiKey: string;
|
|
147
|
+
public prompt: string;
|
|
148
|
+
public voice: VoiceStyle;
|
|
149
|
+
public language: Language;
|
|
150
|
+
public tools: ToolDefinition[] = [];
|
|
151
|
+
|
|
152
|
+
// Callbacks
|
|
153
|
+
private onTranscription?: (text: string) => void;
|
|
154
|
+
private onResponse?: (text: string) => void;
|
|
155
|
+
private onAudioCallback?: (data: Uint8Array) => void;
|
|
156
|
+
private onVisemesCallback?: (visemes: Viseme[]) => void;
|
|
157
|
+
private onStatus?: (status: string) => void;
|
|
158
|
+
private onError?: (error: LokutorError) => void;
|
|
159
|
+
|
|
160
|
+
private isConnected: boolean = false;
|
|
161
|
+
private messages: Array<{ role: 'user' | 'agent'; text: string; timestamp: number }> = [];
|
|
162
|
+
private visemeListeners: ((visemes: Viseme[]) => void)[] = [];
|
|
163
|
+
private wantVisemes: boolean = false;
|
|
164
|
+
|
|
165
|
+
private audioManager: AudioManager | null = null;
|
|
166
|
+
private enableAudio: boolean = false;
|
|
167
|
+
private currentGeneration: number = 0;
|
|
168
|
+
private listeners: Record<string, Function[]> = {};
|
|
169
|
+
|
|
170
|
+
// Connection resilience
|
|
171
|
+
private isUserDisconnect: boolean = false;
|
|
172
|
+
private reconnecting: boolean = false;
|
|
173
|
+
private reconnectAttempts: number = 0;
|
|
174
|
+
private maxReconnectAttempts: number = 5;
|
|
175
|
+
|
|
176
|
+
private serverUrl: string;
|
|
177
|
+
|
|
178
|
+
constructor(config: LokutorConfig & {
|
|
179
|
+
prompt: string,
|
|
180
|
+
voice?: VoiceStyle,
|
|
181
|
+
language?: Language,
|
|
182
|
+
visemes?: boolean,
|
|
183
|
+
onVisemes?: (visemes: Viseme[]) => void,
|
|
184
|
+
enableAudio?: boolean,
|
|
185
|
+
tools?: ToolDefinition[],
|
|
186
|
+
serverUrl?: string,
|
|
187
|
+
}) {
|
|
188
|
+
this.apiKey = config.apiKey;
|
|
189
|
+
this.prompt = config.prompt;
|
|
190
|
+
this.voice = config.voice || VoiceStyle.F1;
|
|
191
|
+
this.language = config.language || Language.ENGLISH;
|
|
192
|
+
this.serverUrl = config.serverUrl || DEFAULT_URLS.VOICE_AGENT;
|
|
193
|
+
|
|
194
|
+
this.onTranscription = config.onTranscription;
|
|
195
|
+
this.onResponse = config.onResponse;
|
|
196
|
+
this.onAudioCallback = config.onAudio;
|
|
197
|
+
this.onVisemesCallback = config.onVisemes;
|
|
198
|
+
this.onStatus = config.onStatus;
|
|
199
|
+
this.onError = config.onError;
|
|
200
|
+
this.wantVisemes = config.visemes || false;
|
|
201
|
+
this.enableAudio = config.enableAudio ?? false;
|
|
202
|
+
this.tools = config.tools || [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Connect to the Lokutor Voice Agent server
|
|
207
|
+
* @param customAudioManager Optional replacement for the default audio hardware handler
|
|
208
|
+
*/
|
|
209
|
+
public async connect(customAudioManager?: AudioManager): Promise<boolean> {
|
|
210
|
+
this.isUserDisconnect = false;
|
|
211
|
+
|
|
212
|
+
if (this.enableAudio || customAudioManager) {
|
|
213
|
+
if (customAudioManager) {
|
|
214
|
+
this.audioManager = customAudioManager;
|
|
215
|
+
} else if (!this.audioManager && typeof window !== 'undefined') {
|
|
216
|
+
this.audioManager = new BrowserAudioManager();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (this.audioManager) {
|
|
220
|
+
await this.audioManager.init();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
let settled = false;
|
|
226
|
+
const settle = (fn: () => void) => {
|
|
227
|
+
if (!settled) {
|
|
228
|
+
settled = true;
|
|
229
|
+
fn();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
let url = this.serverUrl;
|
|
235
|
+
if (this.apiKey) {
|
|
236
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
237
|
+
url += `${separator}api_key=${this.apiKey}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const redactedUrl = url.replace(/api_key=[^&]+/, 'api_key=***');
|
|
241
|
+
sdkTrace('ws.connect', {
|
|
242
|
+
endpoint: this.serverUrl,
|
|
243
|
+
url: redactedUrl,
|
|
244
|
+
enableAudio: this.enableAudio,
|
|
245
|
+
wantVisemes: this.wantVisemes,
|
|
246
|
+
hasAudioManager: Boolean(this.audioManager)
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
console.log(`🔗 Connecting to ${this.serverUrl}...`);
|
|
250
|
+
|
|
251
|
+
this.ws = new WebSocket(url);
|
|
252
|
+
this.ws.binaryType = 'arraybuffer';
|
|
253
|
+
|
|
254
|
+
this.ws.onopen = async () => {
|
|
255
|
+
this.isConnected = true;
|
|
256
|
+
this.reconnectAttempts = 0;
|
|
257
|
+
this.reconnecting = false;
|
|
258
|
+
console.log('✅ Connected to voice agent!');
|
|
259
|
+
sdkTrace('ws.open');
|
|
260
|
+
this.sendConfig();
|
|
261
|
+
|
|
262
|
+
if (this.audioManager) {
|
|
263
|
+
await this.audioManager.startMicrophone((data) => {
|
|
264
|
+
if (this.isConnected) {
|
|
265
|
+
this.sendAudio(data);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
settle(() => resolve(true));
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
this.ws.onmessage = async (event) => {
|
|
274
|
+
if (event.data instanceof ArrayBuffer) {
|
|
275
|
+
sdkTrace('ws.message.binary', { bytes: event.data.byteLength });
|
|
276
|
+
this.handleBinaryMessage(new Uint8Array(event.data));
|
|
277
|
+
} else {
|
|
278
|
+
sdkTrace('ws.message.text', { length: String(event.data).length });
|
|
279
|
+
this.handleTextMessage(event.data.toString());
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
this.ws.onerror = (err) => {
|
|
284
|
+
const error = new LokutorError('ws.close', 'WebSocket connection error', {
|
|
285
|
+
detail: `readyState=${this.ws?.readyState}, bufferedAmount=${this.ws?.bufferedAmount}`,
|
|
286
|
+
original: err,
|
|
287
|
+
retryable: true,
|
|
288
|
+
});
|
|
289
|
+
console.error('❌ WebSocket error:', error.message);
|
|
290
|
+
sdkTrace('ws.error', { code: error.code, message: error.message });
|
|
291
|
+
if (this.onError) this.onError(error);
|
|
292
|
+
if (!this.isConnected) {
|
|
293
|
+
settle(() => reject(error));
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
this.ws.onclose = (event) => {
|
|
298
|
+
this.isConnected = false;
|
|
299
|
+
const diagnostic = {
|
|
300
|
+
code: event.code,
|
|
301
|
+
reason: event.reason,
|
|
302
|
+
wasClean: event.wasClean,
|
|
303
|
+
url: this.serverUrl,
|
|
304
|
+
isUserDisconnect: this.isUserDisconnect,
|
|
305
|
+
reconnectAttempts: this.reconnectAttempts
|
|
306
|
+
};
|
|
307
|
+
sdkTrace('ws.close', diagnostic);
|
|
308
|
+
|
|
309
|
+
if (!settled && !this.isUserDisconnect) {
|
|
310
|
+
const error = new LokutorError('ws.close', `WebSocket closed unexpectedly (code ${event.code})`, {
|
|
311
|
+
detail: event.reason || 'No reason provided',
|
|
312
|
+
retryable: event.code !== 1008,
|
|
313
|
+
});
|
|
314
|
+
settle(() => reject(error));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!event.wasClean && event.code === 1006 && this.reconnectAttempts === 0 && !this.isUserDisconnect) {
|
|
319
|
+
console.error('❌ Connection rejected (code 1006). Likely causes: invalid API key, endpoint unavailable, or CORS blocked.');
|
|
320
|
+
console.error(' URL:', this.serverUrl.replace(/api_key=[^&]+/, 'api_key=***'));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
console.log(`🔌 WebSocket closed — code: ${event.code}, reason: "${event.reason || 'none'}", clean: ${event.wasClean}`);
|
|
324
|
+
|
|
325
|
+
if (!this.isUserDisconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
326
|
+
this.reconnecting = true;
|
|
327
|
+
this.reconnectAttempts++;
|
|
328
|
+
const backoffDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 10000);
|
|
329
|
+
|
|
330
|
+
console.warn(`Connection lost. Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
331
|
+
|
|
332
|
+
if (this.onStatus) this.onStatus('reconnecting');
|
|
333
|
+
|
|
334
|
+
setTimeout(() => {
|
|
335
|
+
this.connect().catch(e => console.error("Reconnect failed", e));
|
|
336
|
+
}, backoffDelay);
|
|
337
|
+
} else {
|
|
338
|
+
console.log('Disconnected');
|
|
339
|
+
if (this.onStatus) this.onStatus('disconnected');
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
} catch (err) {
|
|
344
|
+
const error = err instanceof LokutorError ? err : new LokutorError('internal.error', 'Failed to create WebSocket connection', { original: err });
|
|
345
|
+
if (this.onError) this.onError(error);
|
|
346
|
+
settle(() => reject(error));
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The "Golden Path" - Starts a managed session with hardware handled automatically.
|
|
353
|
+
* This is the recommended way to start a conversation in browser environments.
|
|
354
|
+
*/
|
|
355
|
+
public async startManaged(config?: { audioManager?: AudioManager }): Promise<this> {
|
|
356
|
+
this.enableAudio = true;
|
|
357
|
+
if (config?.audioManager) {
|
|
358
|
+
this.audioManager = config.audioManager;
|
|
359
|
+
} else if (!this.audioManager) {
|
|
360
|
+
if (typeof window === 'undefined') {
|
|
361
|
+
throw new LokutorError('internal.error', 'startManaged() requires a browser environment. Pass a custom audioManager for non-browser runtimes.', { retryable: false });
|
|
362
|
+
}
|
|
363
|
+
this.audioManager = new BrowserAudioManager();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await this.connect();
|
|
367
|
+
|
|
368
|
+
return this;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Send initial configuration to the server
|
|
373
|
+
*/
|
|
374
|
+
private sendConfig() {
|
|
375
|
+
if (!this.ws || !this.isConnected) return;
|
|
376
|
+
|
|
377
|
+
// Send feature/config flags first so the first generated response uses them.
|
|
378
|
+
this.ws.send(JSON.stringify({ type: 'visemes', data: this.wantVisemes }));
|
|
379
|
+
this.ws.send(JSON.stringify({ type: 'voice', data: this.voice }));
|
|
380
|
+
this.ws.send(JSON.stringify({ type: 'language', data: this.language }));
|
|
381
|
+
this.ws.send(JSON.stringify({ type: 'prompt', data: this.prompt }));
|
|
382
|
+
|
|
383
|
+
// Inform the server of our sample rates for echo cancellation.
|
|
384
|
+
// These match the SDK defaults: 16 kHz mic input, 44.1 kHz speaker output.
|
|
385
|
+
this.ws.send(JSON.stringify({ type: 'rates', playback: 44100, input: 16000 }));
|
|
386
|
+
|
|
387
|
+
sdkTrace('ws.send.config', {
|
|
388
|
+
promptLen: this.prompt?.length || 0,
|
|
389
|
+
voice: this.voice,
|
|
390
|
+
language: this.language,
|
|
391
|
+
visemes: this.wantVisemes,
|
|
392
|
+
tools: this.tools?.length || 0
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (this.tools && this.tools.length > 0) {
|
|
396
|
+
this.ws.send(JSON.stringify({ type: 'tools', data: this.tools }));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log(`⚙️ Configured: voice=${this.voice}, language=${this.language}, visemes=${this.wantVisemes}, tools=${this.tools.length}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Send raw PCM audio data to the server
|
|
404
|
+
* @param audioData Int16 PCM audio buffer
|
|
405
|
+
*/
|
|
406
|
+
public sendAudio(audioData: Uint8Array) {
|
|
407
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
|
|
408
|
+
this.ws.send(audioData);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Handle incoming binary data (audio response)
|
|
414
|
+
*/
|
|
415
|
+
private handleBinaryMessage(data: Uint8Array, generation?: number) {
|
|
416
|
+
if (generation !== undefined && generation < this.currentGeneration) {
|
|
417
|
+
console.log(`🗑️ Discarding ghost audio (Gen ${generation} < ${this.currentGeneration})`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (this.audioManager) {
|
|
421
|
+
this.audioManager.playAudio(data);
|
|
422
|
+
}
|
|
423
|
+
this.emit('audio', data);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Handle incoming text messages (metadata/transcriptions)
|
|
428
|
+
*/
|
|
429
|
+
private handleTextMessage(text: string) {
|
|
430
|
+
try {
|
|
431
|
+
const msg = JSON.parse(text);
|
|
432
|
+
if (!msg || typeof msg !== 'object') {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
sdkTrace('ws.recv.type', {
|
|
436
|
+
type: msg.type,
|
|
437
|
+
hasData: Object.prototype.hasOwnProperty.call(msg, 'data'),
|
|
438
|
+
dataKind: Array.isArray(msg.data) ? 'array' : typeof msg.data,
|
|
439
|
+
generation: msg.generation ?? null
|
|
440
|
+
});
|
|
441
|
+
switch (msg.type) {
|
|
442
|
+
case 'audio':
|
|
443
|
+
if (msg.data) {
|
|
444
|
+
const buffer = base64ToUint8Array(msg.data);
|
|
445
|
+
this.handleBinaryMessage(buffer, msg.generation);
|
|
446
|
+
}
|
|
447
|
+
break;
|
|
448
|
+
case 'transcript':
|
|
449
|
+
const role = msg.role === 'user' ? 'user' : 'agent';
|
|
450
|
+
// Store in history
|
|
451
|
+
this.messages.push({
|
|
452
|
+
role,
|
|
453
|
+
text: msg.data,
|
|
454
|
+
timestamp: nowMs()
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
if (msg.role === 'user') {
|
|
458
|
+
if (this.onTranscription) this.onTranscription(msg.data);
|
|
459
|
+
console.log(`💬 You: ${msg.data}`);
|
|
460
|
+
} else {
|
|
461
|
+
if (this.onResponse) this.onResponse(msg.data);
|
|
462
|
+
console.log(`🤖 Agent: ${msg.data}`);
|
|
463
|
+
}
|
|
464
|
+
break;
|
|
465
|
+
case 'status':
|
|
466
|
+
if (msg.data === 'thinking') {
|
|
467
|
+
const newGen = msg.generation || 0;
|
|
468
|
+
if (newGen > this.currentGeneration) {
|
|
469
|
+
console.log(`🧠 New thought (Gen ${newGen}) - Clearing audio queue`);
|
|
470
|
+
this.currentGeneration = newGen;
|
|
471
|
+
if (this.audioManager) this.audioManager.stopPlayback();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (msg.data === 'interrupted' && this.audioManager) {
|
|
475
|
+
this.audioManager.stopPlayback();
|
|
476
|
+
}
|
|
477
|
+
if (this.onStatus) this.onStatus(msg.data);
|
|
478
|
+
const icons: Record<string, string> = {
|
|
479
|
+
'interrupted': '⚡',
|
|
480
|
+
'thinking': '🧠',
|
|
481
|
+
'speaking': '🔊',
|
|
482
|
+
'listening': '👂'
|
|
483
|
+
};
|
|
484
|
+
console.log(`${icons[msg.data] || ''} Status: ${msg.data}`);
|
|
485
|
+
break;
|
|
486
|
+
case 'visemes':
|
|
487
|
+
case 'viseme': {
|
|
488
|
+
const msgGen = msg.generation ?? this.currentGeneration;
|
|
489
|
+
if (msgGen < this.currentGeneration) {
|
|
490
|
+
sdkTrace('visemes.discard', { msgGen, currentGen: this.currentGeneration });
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
const normalized = extractVisemePayload(msg);
|
|
494
|
+
const explicitlyEmptyArray = Array.isArray(msg?.data) || Array.isArray(msg?.data?.visemes);
|
|
495
|
+
sdkTrace('visemes.recv', {
|
|
496
|
+
rawType: msg.type,
|
|
497
|
+
normalizedCount: normalized.length,
|
|
498
|
+
first: normalized[0] ?? null
|
|
499
|
+
});
|
|
500
|
+
if (normalized.length > 0 || explicitlyEmptyArray) {
|
|
501
|
+
this.emit('visemes', normalized);
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case 'error': {
|
|
506
|
+
const backendCode = msg.data?.code ?? 'internal.error';
|
|
507
|
+
const backendMessage = msg.data?.message ?? msg.data ?? 'Unknown server error';
|
|
508
|
+
const backendDetail = msg.data?.detail;
|
|
509
|
+
const backendRetryable = msg.data?.retryable ?? true;
|
|
510
|
+
const error = new LokutorError(backendCode as ErrorCode, backendMessage, {
|
|
511
|
+
detail: backendDetail,
|
|
512
|
+
retryable: backendRetryable,
|
|
513
|
+
});
|
|
514
|
+
if (this.onError) this.onError(error);
|
|
515
|
+
console.error(`❌ Server error: [${error.code}] ${error.message}`);
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
case 'tool_call':
|
|
519
|
+
console.log(`🛠️ Tool Call: ${msg.name}(${msg.arguments})`);
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
} catch (e) {
|
|
523
|
+
sdkTrace('ws.recv.parse_error', { preview: text?.slice(0, 120) });
|
|
524
|
+
console.debug('Failed to parse message:', e);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Register an event listener (for Python parity)
|
|
530
|
+
*/
|
|
531
|
+
public on(event: string, callback: Function): this {
|
|
532
|
+
if (!this.listeners[event]) {
|
|
533
|
+
this.listeners[event] = [];
|
|
534
|
+
}
|
|
535
|
+
this.listeners[event].push(callback);
|
|
536
|
+
return this;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Internal emitter for all events
|
|
541
|
+
*/
|
|
542
|
+
private emit(event: string, ...args: any[]) {
|
|
543
|
+
// Legacy property-style callbacks
|
|
544
|
+
const legacyMap: Record<string, string> = {
|
|
545
|
+
'transcription': 'onTranscription',
|
|
546
|
+
'response': 'onResponse',
|
|
547
|
+
'audio': 'onAudioCallback',
|
|
548
|
+
'visemes': 'onVisemesCallback',
|
|
549
|
+
'status': 'onStatus',
|
|
550
|
+
'error': 'onError',
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const legacyKey = legacyMap[event];
|
|
554
|
+
if (legacyKey && (this as any)[legacyKey]) {
|
|
555
|
+
try {
|
|
556
|
+
(this as any)[legacyKey](...args);
|
|
557
|
+
} catch (e) {
|
|
558
|
+
console.error(`Error in legacy callback ${legacyKey}:`, e);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// New style listeners
|
|
563
|
+
if (this.listeners[event]) {
|
|
564
|
+
this.listeners[event].forEach(cb => {
|
|
565
|
+
try {
|
|
566
|
+
cb(...args);
|
|
567
|
+
} catch (e) {
|
|
568
|
+
console.error(`Error in listener for ${event}:`, e);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
public onAudio(callback: (data: Uint8Array) => void) {
|
|
575
|
+
this.on('audio', callback);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
public onVisemes(callback: (visemes: Viseme[]) => void) {
|
|
579
|
+
this.on('visemes', callback);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Disconnect from the server
|
|
584
|
+
*/
|
|
585
|
+
public disconnect() {
|
|
586
|
+
this.isUserDisconnect = true;
|
|
587
|
+
if (this.ws) {
|
|
588
|
+
this.ws.close();
|
|
589
|
+
this.ws = null;
|
|
590
|
+
}
|
|
591
|
+
if (this.audioManager) {
|
|
592
|
+
this.audioManager.cleanup();
|
|
593
|
+
}
|
|
594
|
+
this.isConnected = false;
|
|
595
|
+
this.reconnecting = false;
|
|
596
|
+
this.reconnectAttempts = 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Returns true if the client is currently connected.
|
|
601
|
+
*/
|
|
602
|
+
public get connected(): boolean {
|
|
603
|
+
return this.isConnected;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Returns the current generation counter.
|
|
608
|
+
* Useful for correlating audio/viseme chunks with utterances.
|
|
609
|
+
*/
|
|
610
|
+
public get generation(): number {
|
|
611
|
+
return this.currentGeneration;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Toggles the microphone mute state (if managed by client)
|
|
616
|
+
* returns the new mute state
|
|
617
|
+
*/
|
|
618
|
+
public toggleMute(): boolean {
|
|
619
|
+
if (this.audioManager) {
|
|
620
|
+
const isMuted = this.audioManager.isMicMuted();
|
|
621
|
+
this.audioManager.setMuted(!isMuted);
|
|
622
|
+
return !isMuted;
|
|
623
|
+
}
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Gets the microphone volume amplitude 0-1 (if managed by client)
|
|
629
|
+
*/
|
|
630
|
+
public getAmplitude(): number {
|
|
631
|
+
if (this.audioManager) {
|
|
632
|
+
return this.audioManager.getAmplitude();
|
|
633
|
+
}
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Fetch available voice styles from the server.
|
|
639
|
+
* No authentication required.
|
|
640
|
+
*/
|
|
641
|
+
static async fetchVoices(baseUrl?: string): Promise<VoiceInfo[]> {
|
|
642
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
643
|
+
const data = await fetchJson<{ voices: VoiceInfo[] }>(`${url}/voices`);
|
|
644
|
+
return data.voices || [];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Fetch supported languages from the server.
|
|
649
|
+
* No authentication required.
|
|
650
|
+
*/
|
|
651
|
+
static async fetchLanguages(baseUrl?: string): Promise<LanguageInfo[]> {
|
|
652
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
653
|
+
const data = await fetchJson<{ languages: LanguageInfo[] }>(`${url}/languages`);
|
|
654
|
+
return data.languages || [];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Fetch loaded TTS model versions from the server.
|
|
659
|
+
* No authentication required.
|
|
660
|
+
*/
|
|
661
|
+
static async fetchModels(baseUrl?: string): Promise<ModelInfo[]> {
|
|
662
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
663
|
+
const data = await fetchJson<{ models: ModelInfo[] }>(`${url}/models`);
|
|
664
|
+
return data.models || [];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Fetch server configuration (limits and defaults).
|
|
669
|
+
* No authentication required.
|
|
670
|
+
*/
|
|
671
|
+
static async fetchConfig(baseUrl?: string): Promise<ServerConfig> {
|
|
672
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
673
|
+
return fetchJson<ServerConfig>(`${url}/config`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Fetch rich runtime status from the server.
|
|
678
|
+
* No authentication required.
|
|
679
|
+
*/
|
|
680
|
+
static async fetchStatus(baseUrl?: string): Promise<ServerStatus> {
|
|
681
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
682
|
+
return fetchJson<ServerStatus>(`${url}/status`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Fetch health/liveness status from the server.
|
|
687
|
+
* No authentication required.
|
|
688
|
+
*/
|
|
689
|
+
static async fetchHealth(baseUrl?: string): Promise<HealthStatus> {
|
|
690
|
+
const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
|
|
691
|
+
return fetchJson<HealthStatus>(`${url}/health`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Update the system prompt mid-conversation
|
|
696
|
+
*/
|
|
697
|
+
public updatePrompt(newPrompt: string) {
|
|
698
|
+
this.prompt = newPrompt;
|
|
699
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
|
|
700
|
+
try {
|
|
701
|
+
this.ws.send(JSON.stringify({ type: 'prompt', data: newPrompt }));
|
|
702
|
+
console.log(`⚙️ Updated prompt: ${newPrompt.substring(0, 50)}...`);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
const err = new LokutorError('internal.error', 'Failed to update prompt', { original: error });
|
|
705
|
+
if (this.onError) this.onError(err);
|
|
706
|
+
console.error('Error updating prompt:', err.message);
|
|
707
|
+
}
|
|
708
|
+
} else {
|
|
709
|
+
console.warn('Not connected - prompt will be updated on next connection');
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Get full conversation transcript
|
|
715
|
+
*/
|
|
716
|
+
public getTranscript(): Array<{ role: 'user' | 'agent'; text: string; timestamp: number }> {
|
|
717
|
+
return this.messages.slice();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Get conversation as formatted text
|
|
722
|
+
*/
|
|
723
|
+
public getTranscriptText(): string {
|
|
724
|
+
return this.messages
|
|
725
|
+
.map(msg => `${msg.role === 'user' ? 'You' : 'Agent'}: ${msg.text}`)
|
|
726
|
+
.join('\n');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Client for standalone Text-to-Speech synthesis
|
|
732
|
+
*/
|
|
733
|
+
export class TTSClient {
|
|
734
|
+
private apiKey: string;
|
|
735
|
+
|
|
736
|
+
constructor(config: {
|
|
737
|
+
apiKey: string;
|
|
738
|
+
}) {
|
|
739
|
+
this.apiKey = config.apiKey;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Synthesize text to speech
|
|
744
|
+
*
|
|
745
|
+
* This opens a temporary WebSocket connection, sends the request,
|
|
746
|
+
* and streams back the audio.
|
|
747
|
+
*/
|
|
748
|
+
public synthesize(options: {
|
|
749
|
+
text: string;
|
|
750
|
+
voice?: VoiceStyle;
|
|
751
|
+
language?: Language;
|
|
752
|
+
speed?: number;
|
|
753
|
+
steps?: number;
|
|
754
|
+
visemes?: boolean;
|
|
755
|
+
onAudio?: (data: Uint8Array) => void;
|
|
756
|
+
onVisemes?: (visemes: any[]) => void;
|
|
757
|
+
onTTFB?: (ms: number) => void;
|
|
758
|
+
onError?: (error: any) => void;
|
|
759
|
+
}): Promise<void> {
|
|
760
|
+
return new Promise((resolve, reject) => {
|
|
761
|
+
let activityTimeout: any;
|
|
762
|
+
let ws: WebSocket;
|
|
763
|
+
let startTime: number;
|
|
764
|
+
let firstByteReceived = false;
|
|
765
|
+
|
|
766
|
+
const refreshTimeout = () => {
|
|
767
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
768
|
+
activityTimeout = setTimeout(() => {
|
|
769
|
+
console.log("⏱️ TTS synthesis reached inactivity timeout (2s) - resolving");
|
|
770
|
+
if (ws) ws.close();
|
|
771
|
+
resolve();
|
|
772
|
+
}, 2000);
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
let url = DEFAULT_URLS.TTS;
|
|
777
|
+
if (this.apiKey) {
|
|
778
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
779
|
+
url += `${separator}api_key=${this.apiKey}`;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
ws = new WebSocket(url);
|
|
783
|
+
ws.binaryType = 'arraybuffer';
|
|
784
|
+
|
|
785
|
+
ws.onopen = () => {
|
|
786
|
+
refreshTimeout();
|
|
787
|
+
const req = {
|
|
788
|
+
text: options.text,
|
|
789
|
+
voice: options.voice || VoiceStyle.F1,
|
|
790
|
+
lang: options.language || Language.ENGLISH,
|
|
791
|
+
speed: options.speed || 1.05,
|
|
792
|
+
steps: options.steps || 24,
|
|
793
|
+
visemes: options.visemes || false
|
|
794
|
+
};
|
|
795
|
+
ws.send(JSON.stringify(req));
|
|
796
|
+
startTime = nowMs();
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
ws.onmessage = async (event) => {
|
|
800
|
+
refreshTimeout();
|
|
801
|
+
if (event.data instanceof ArrayBuffer) {
|
|
802
|
+
if (!firstByteReceived) {
|
|
803
|
+
const ttfb = nowMs() - startTime;
|
|
804
|
+
if (options.onTTFB) options.onTTFB(ttfb);
|
|
805
|
+
firstByteReceived = true;
|
|
806
|
+
}
|
|
807
|
+
if (options.onAudio) options.onAudio(new Uint8Array(event.data));
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const text = event.data.toString();
|
|
811
|
+
if (text === 'EOS') {
|
|
812
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
813
|
+
ws.close();
|
|
814
|
+
resolve();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
const msg = JSON.parse(text);
|
|
820
|
+
|
|
821
|
+
if (msg.type === 'audio' && msg.data) {
|
|
822
|
+
const audioBuffer = base64ToUint8Array(msg.data);
|
|
823
|
+
if (!firstByteReceived) {
|
|
824
|
+
const ttfb = nowMs() - startTime;
|
|
825
|
+
if (options.onTTFB) options.onTTFB(ttfb);
|
|
826
|
+
firstByteReceived = true;
|
|
827
|
+
}
|
|
828
|
+
if (options.onAudio) options.onAudio(audioBuffer);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (msg.type === 'visemes' && Array.isArray(msg.data) && options.onVisemes) {
|
|
833
|
+
options.onVisemes(normalizeVisemes(msg.data));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (Array.isArray(msg) && options.onVisemes) {
|
|
838
|
+
options.onVisemes(normalizeVisemes(msg));
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (msg.type === 'eos') {
|
|
843
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
844
|
+
ws.close();
|
|
845
|
+
resolve();
|
|
846
|
+
}
|
|
847
|
+
} catch (e) {
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
ws.onerror = (err) => {
|
|
852
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
853
|
+
if (options.onError) options.onError(err);
|
|
854
|
+
reject(err);
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
ws.onclose = () => {
|
|
858
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
859
|
+
resolve();
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
} catch (err) {
|
|
863
|
+
if (activityTimeout) clearTimeout(activityTimeout);
|
|
864
|
+
if (options.onError) options.onError(err);
|
|
865
|
+
reject(err);
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Quick function to start a conversation (requires manual audio piping in JS)
|
|
873
|
+
*/
|
|
874
|
+
export async function simpleConversation(config: LokutorConfig & { prompt: string }) {
|
|
875
|
+
const client = new VoiceAgentClient(config);
|
|
876
|
+
await client.connect();
|
|
877
|
+
return client;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Quick function for standalone TTS synthesis
|
|
882
|
+
*/
|
|
883
|
+
export async function simpleTTS(options: SynthesizeOptions & { apiKey: string, onAudio: (buf: Uint8Array) => void }) {
|
|
884
|
+
const client = new TTSClient({ apiKey: options.apiKey });
|
|
885
|
+
return client.synthesize(options);
|
|
886
|
+
}
|