@talk2view/sdk 0.21.4 → 0.23.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/README.md +37 -20
- package/dist/chat/views/settings.d.ts.map +1 -1
- package/dist/chat/views/settings.js +15 -13
- package/dist/chat/views/settings.js.map +1 -1
- package/dist/chat/voice-button.d.ts.map +1 -1
- package/dist/chat/voice-button.js +46 -7
- package/dist/chat/voice-button.js.map +1 -1
- package/dist/chat-css.js +1 -1
- package/dist/chat.css +1 -1
- package/dist/chat.isolated.css +21 -1
- package/dist/chat.raw.css +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -2
- package/dist/client.js.map +1 -1
- package/dist/constants.d.ts +10 -4
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +10 -4
- package/dist/constants.js.map +1 -1
- package/dist/errors.d.ts +25 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +35 -10
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/sessions.d.ts +29 -6
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +28 -11
- package/dist/sessions.js.map +1 -1
- package/dist/types.d.ts +59 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +9 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +9 -0
- package/dist/version.js.map +1 -0
- package/dist/voice-elevenlabs.d.ts +197 -0
- package/dist/voice-elevenlabs.d.ts.map +1 -0
- package/dist/voice-elevenlabs.js +358 -0
- package/dist/voice-elevenlabs.js.map +1 -0
- package/dist/voice-facade.js +1 -1
- package/dist/voice-facade.js.map +1 -1
- package/dist/voice-fence.d.ts +37 -0
- package/dist/voice-fence.d.ts.map +1 -0
- package/dist/voice-fence.js +44 -0
- package/dist/voice-fence.js.map +1 -0
- package/dist/voice-turn.d.ts +119 -0
- package/dist/voice-turn.d.ts.map +1 -0
- package/dist/voice-turn.js +207 -0
- package/dist/voice-turn.js.map +1 -0
- package/dist/voice.d.ts +131 -8
- package/dist/voice.d.ts.map +1 -1
- package/dist/voice.js +492 -114
- package/dist/voice.js.map +1 -1
- package/docs/chat.md +13 -7
- package/docs/voice.md +157 -33
- package/package.json +4 -3
package/dist/voice.js
CHANGED
|
@@ -3,16 +3,43 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Core never imports this module statically: the `T2VVoice` facade
|
|
5
5
|
* (`voice-facade.ts`) loads it with `import()` on the first `start()`, and it
|
|
6
|
-
* in turn loads the
|
|
6
|
+
* in turn loads the call libraries, so chat-only partners ship none of them.
|
|
7
7
|
*
|
|
8
|
-
* The engine mints
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* The engine mints the call (`POST /v1/voice/sessions`) on one of the voice
|
|
9
|
+
* models this SDK runs, and the mint says which. A Gemini Live call gets a
|
|
10
|
+
* ticket: the Pipecat client libraries, loaded on every `start()` because the
|
|
11
|
+
* microphone opens through them before the mint, carry its WebRTC audio to
|
|
12
|
+
* the voice agent service, and the client tools the agent calls arrive here
|
|
13
|
+
* over the RTVI data channel. An ElevenLabs call gets a conversation token:
|
|
14
|
+
* it runs in `voice-elevenlabs.ts` on the `@elevenlabs/client` library, and
|
|
15
|
+
* the agent's requests become engine turns on the end-user's chat session
|
|
16
|
+
* (`voice-turn.ts`), all three loaded with `import()` only for such a call. An
|
|
17
|
+
* ElevenLabs call that can't connect is tried once more, on Gemini Live.
|
|
18
|
+
* Either way, client tools run through the SAME tool registry and permission
|
|
19
|
+
* check as chat, so partners write no new code.
|
|
13
20
|
*/
|
|
14
21
|
import { TypedEventEmitter } from './event-emitter.js';
|
|
15
22
|
import { VoiceStartError } from './voice-error.js';
|
|
23
|
+
// ── Voice models ──
|
|
24
|
+
/** The voice models this SDK can run, as it asks the engine for them. */
|
|
25
|
+
export const VOICE_MODELS_THIS_SDK_RUNS = ['gemini-live', 'elevenlabs'];
|
|
26
|
+
/** What the end-user is told when no voice model this app allows can take the call. */
|
|
27
|
+
export const NO_VOICE_MESSAGE = 'Voice isn’t available right now.';
|
|
28
|
+
/**
|
|
29
|
+
* The longest language the engine takes on a mint (35 characters). A longer
|
|
30
|
+
* one is left out, so the engine uses its default instead of refusing the
|
|
31
|
+
* whole mint. `.length` counts UTF-16 units, never fewer than the engine's
|
|
32
|
+
* code points, so anything sent fits.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_LANGUAGE_LENGTH = 35;
|
|
35
|
+
/** Each voice model's name, as the notes say it. */
|
|
36
|
+
export const VOICE_MODEL_NAMES = { 'gemini-live': 'Gemini Live', elevenlabs: 'ElevenLabs' };
|
|
37
|
+
/** The note when a voice model couldn't connect and another took the call: the engine's text. */
|
|
38
|
+
export const NOTE_COULD_NOT_CONNECT = 'Using {fallback} — {wanted} couldn’t connect.';
|
|
39
|
+
/** {@link NOTE_COULD_NOT_CONNECT} for the voice model used (`fallback`) and the one given up (`wanted`). */
|
|
40
|
+
export function couldNotConnectNote(fallback, wanted) {
|
|
41
|
+
return NOTE_COULD_NOT_CONNECT.replace('{fallback}', VOICE_MODEL_NAMES[fallback]).replace('{wanted}', VOICE_MODEL_NAMES[wanted]);
|
|
42
|
+
}
|
|
16
43
|
/** Dynamic imports: a bundler splits these into their own chunk. */
|
|
17
44
|
export const loadPipecatLibs = async () => {
|
|
18
45
|
const [client, transport] = await Promise.all([
|
|
@@ -24,10 +51,21 @@ export const loadPipecatLibs = async () => {
|
|
|
24
51
|
SmallWebRTCTransport: transport.SmallWebRTCTransport,
|
|
25
52
|
};
|
|
26
53
|
};
|
|
54
|
+
/** The voice debug log: written only when `localStorage['t2v:debug'] === '1'`. */
|
|
55
|
+
function debugLog(label, detail) {
|
|
56
|
+
let enabled = false;
|
|
57
|
+
try {
|
|
58
|
+
enabled = globalThis.localStorage?.getItem('t2v:debug') === '1';
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// storage blocked: stay quiet
|
|
62
|
+
}
|
|
63
|
+
if (enabled)
|
|
64
|
+
console.debug(`[Talk2View] ${label}`, detail);
|
|
65
|
+
}
|
|
27
66
|
/**
|
|
28
|
-
* Where a call's start spends its time, in ms since the click
|
|
29
|
-
*
|
|
30
|
-
* about the user.
|
|
67
|
+
* Where a call's start spends its time, in ms since the click, for the debug
|
|
68
|
+
* log: step names and numbers, nothing about the user.
|
|
31
69
|
*/
|
|
32
70
|
function startTimings() {
|
|
33
71
|
const clock = globalThis.performance;
|
|
@@ -41,15 +79,7 @@ function startTimings() {
|
|
|
41
79
|
mark,
|
|
42
80
|
done(step) {
|
|
43
81
|
mark(step);
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
enabled = globalThis.localStorage?.getItem('t2v:debug') === '1';
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
// storage blocked: stay quiet
|
|
50
|
-
}
|
|
51
|
-
if (enabled)
|
|
52
|
-
console.debug('[Talk2View] voice start, ms since click', steps);
|
|
82
|
+
debugLog('voice start, ms since click', steps);
|
|
53
83
|
},
|
|
54
84
|
};
|
|
55
85
|
}
|
|
@@ -71,6 +101,8 @@ const AUTH_EXPIRED = {
|
|
|
71
101
|
type: 'auth_expired',
|
|
72
102
|
message: 'Your sign-in expired. Sign in again to keep talking.',
|
|
73
103
|
};
|
|
104
|
+
/** A `transport_error`'s message when the call library gives none, or gives one only a developer should read. */
|
|
105
|
+
const VOICE_CONNECTION_ERROR = 'Voice connection error';
|
|
74
106
|
/** The microphone's permission state, or 'unknown' where the browser cannot say. */
|
|
75
107
|
async function queryMicPermission() {
|
|
76
108
|
try {
|
|
@@ -94,6 +126,14 @@ function releaseMic(client) {
|
|
|
94
126
|
// no tracks yet, or already released
|
|
95
127
|
}
|
|
96
128
|
}
|
|
129
|
+
/** A Gemini Live mint's ICE servers, as the transport takes them. */
|
|
130
|
+
function iceServersOf(mint) {
|
|
131
|
+
return (mint.ice_servers ?? []).map((s) => ({
|
|
132
|
+
urls: s.urls,
|
|
133
|
+
...(s.username ? { username: s.username } : {}),
|
|
134
|
+
...(s.credential ? { credential: s.credential } : {}),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
97
137
|
const DEFAULT_APPROVAL_TIMEOUT_MS = 25_000;
|
|
98
138
|
/** What the voice service's refusal statuses mean when its JSON body is unreadable. */
|
|
99
139
|
const OFFER_STATUS = {
|
|
@@ -137,6 +177,26 @@ export class T2VVoiceController {
|
|
|
137
177
|
emitter = new TypedEventEmitter();
|
|
138
178
|
_state = 'idle';
|
|
139
179
|
client = null;
|
|
180
|
+
/** The ElevenLabs call, from its start until the call ends. It reports its own end. */
|
|
181
|
+
elCall = null;
|
|
182
|
+
/**
|
|
183
|
+
* The conversation id of an ElevenLabs call the engine minted that no
|
|
184
|
+
* {@link elCall} has taken over yet: an ending reports its end.
|
|
185
|
+
*/
|
|
186
|
+
mintedCall = null;
|
|
187
|
+
/**
|
|
188
|
+
* The mint in flight, until the start that sent it reads the answer. It
|
|
189
|
+
* may yet return an ElevenLabs call, so an ending meanwhile reports that
|
|
190
|
+
* call's end once it returns.
|
|
191
|
+
*/
|
|
192
|
+
mintInFlight = null;
|
|
193
|
+
/**
|
|
194
|
+
* ElevenLabs end reports the engine has not answered yet, and mints an
|
|
195
|
+
* ending came during. The next start waits for them before it mints, since
|
|
196
|
+
* the engine releases a call's credit when it hears of the end. Never
|
|
197
|
+
* rejects.
|
|
198
|
+
*/
|
|
199
|
+
pendingEndReports = null;
|
|
140
200
|
audio = null;
|
|
141
201
|
libs = null;
|
|
142
202
|
pendingApproval = null;
|
|
@@ -148,7 +208,13 @@ export class T2VVoiceController {
|
|
|
148
208
|
* epoch belongs to a call that was hung up, and must not touch this one.
|
|
149
209
|
*/
|
|
150
210
|
epoch = 0;
|
|
151
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Bumped by every start as it asks for the microphone. Daily's call object
|
|
213
|
+
* is one per page, so every Pipecat client's microphone is the same track,
|
|
214
|
+
* and it belongs to the latest start that asked for it.
|
|
215
|
+
*/
|
|
216
|
+
micOpens = 0;
|
|
217
|
+
/** Settles the in-flight `start()`'s waits when the call is hung up mid-start. */
|
|
152
218
|
abortStart = null;
|
|
153
219
|
/**
|
|
154
220
|
* The hang-up in progress; a concurrent `stop()` joins it. Tagged with the
|
|
@@ -232,8 +298,23 @@ export class T2VVoiceController {
|
|
|
232
298
|
return;
|
|
233
299
|
connectCall.then(() => orphan.disconnect(), () => undefined).catch(() => undefined);
|
|
234
300
|
};
|
|
301
|
+
// Once the microphone is asked for: a start that ends before it opens
|
|
302
|
+
// releases it as soon as it opens, since the teardown found none to
|
|
303
|
+
// release, unless a newer start has asked for it since.
|
|
304
|
+
let releaseMicWhenOpen = () => undefined;
|
|
235
305
|
const timings = startTimings();
|
|
236
306
|
try {
|
|
307
|
+
// An ElevenLabs call's end report the engine has not answered yet, or a
|
|
308
|
+
// mint hung up in flight that may yet return such a call: the engine
|
|
309
|
+
// releases that call's credit as it hears of the end, and this call's
|
|
310
|
+
// mint must find it released. A slow engine holds back this start,
|
|
311
|
+
// which shows `connecting`, never the last call's `ended`.
|
|
312
|
+
const endReports = this.pendingEndReports;
|
|
313
|
+
if (endReports) {
|
|
314
|
+
await Promise.race([endReports, aborted]);
|
|
315
|
+
if (stale())
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
237
318
|
// The call libraries load while the session is checked: the same chunk
|
|
238
319
|
// whatever the answer. Awaited before the mint, so a chunk that fails
|
|
239
320
|
// to load still never burns a ticket.
|
|
@@ -250,73 +331,10 @@ export class T2VVoiceController {
|
|
|
250
331
|
if (stale())
|
|
251
332
|
return;
|
|
252
333
|
// The client is built before the mint so the microphone can open while
|
|
253
|
-
// (or before) the
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// until connected, then restore the default for mid-call ICE recovery.
|
|
258
|
-
// `maxReconnectionAttempts` is a public field of the pinned 1.10.8
|
|
259
|
-
// transport that its typings do not declare.
|
|
260
|
-
const retry = transport;
|
|
261
|
-
const retries = retry.maxReconnectionAttempts;
|
|
262
|
-
if (typeof retries === 'number')
|
|
263
|
-
retry.maxReconnectionAttempts = 0;
|
|
264
|
-
const pipecat = new libs.PipecatClient({
|
|
265
|
-
transport,
|
|
266
|
-
enableMic: true,
|
|
267
|
-
enableCam: false,
|
|
268
|
-
callbacks: {
|
|
269
|
-
onConnected: () => {
|
|
270
|
-
// A call that comes up after it was hung up (see `abandon`) is
|
|
271
|
-
// closed at once, not left streaming the mic until bot-ready.
|
|
272
|
-
if (stale()) {
|
|
273
|
-
pipecat.disconnect().catch(() => undefined);
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
if (typeof retries === 'number')
|
|
277
|
-
retry.maxReconnectionAttempts = retries;
|
|
278
|
-
this.setState('listening');
|
|
279
|
-
},
|
|
280
|
-
onDisconnected: () => {
|
|
281
|
-
if (!stale())
|
|
282
|
-
void this.finish('disconnected');
|
|
283
|
-
},
|
|
284
|
-
onError: (msg) => {
|
|
285
|
-
if (!stale())
|
|
286
|
-
this.emitError('transport_error', msg?.data?.message ?? 'Voice connection error');
|
|
287
|
-
},
|
|
288
|
-
onServerMessage: (data) => {
|
|
289
|
-
if (stale())
|
|
290
|
-
return;
|
|
291
|
-
this.handleServerMessage(data).catch(() => {
|
|
292
|
-
if (!stale())
|
|
293
|
-
this.emitError('voice_error', 'Something went wrong in the voice call');
|
|
294
|
-
});
|
|
295
|
-
},
|
|
296
|
-
onUserTranscript: (d) => {
|
|
297
|
-
if (!stale())
|
|
298
|
-
this.safeEmit('transcript', { role: 'user', text: d.text, final: !!d.final });
|
|
299
|
-
},
|
|
300
|
-
onBotTranscript: (d) => {
|
|
301
|
-
if (!stale())
|
|
302
|
-
this.safeEmit('transcript', { role: 'bot', text: d.text, final: true });
|
|
303
|
-
},
|
|
304
|
-
onTrackStarted: (track, participant) => {
|
|
305
|
-
if (stale() || participant?.local || track.kind !== 'audio')
|
|
306
|
-
return;
|
|
307
|
-
(this.deps.attachAudio ?? this.attachAudio)(track);
|
|
308
|
-
},
|
|
309
|
-
onDeviceError: (error) => {
|
|
310
|
-
if (stale())
|
|
311
|
-
return;
|
|
312
|
-
// The call is audio-only: a camera error is not ours to report.
|
|
313
|
-
if (Array.isArray(error?.devices) && !error.devices.includes('mic'))
|
|
314
|
-
return;
|
|
315
|
-
// No microphone means a call that listens to nothing: end it and say why.
|
|
316
|
-
void this.finish('error', MIC_UNAVAILABLE);
|
|
317
|
-
},
|
|
318
|
-
},
|
|
319
|
-
});
|
|
334
|
+
// (or before) the call is minted; a Gemini Live call's ICE servers come
|
|
335
|
+
// with the mint.
|
|
336
|
+
const first = this.buildPipecat(libs, stale);
|
|
337
|
+
let { pipecat, transport } = first;
|
|
320
338
|
client = pipecat;
|
|
321
339
|
this.client = pipecat;
|
|
322
340
|
// The microphone. A ticket lives 60 s, so while the browser will still
|
|
@@ -326,16 +344,24 @@ export class T2VVoiceController {
|
|
|
326
344
|
const micAllowed = (await (this.deps.micPermission ?? queryMicPermission)()) === 'granted';
|
|
327
345
|
if (stale())
|
|
328
346
|
return;
|
|
329
|
-
const
|
|
347
|
+
const micOpen = ++this.micOpens;
|
|
348
|
+
const devices = first.pipecat.initDevices();
|
|
330
349
|
devices.catch(() => undefined); // awaited below
|
|
350
|
+
releaseMicWhenOpen = () => {
|
|
351
|
+
// Not once a newer start has asked: the one track is that start's now.
|
|
352
|
+
const release = () => {
|
|
353
|
+
if (this.micOpens === micOpen)
|
|
354
|
+
releaseMic(first.pipecat);
|
|
355
|
+
};
|
|
356
|
+
devices.then(release, () => undefined);
|
|
357
|
+
};
|
|
331
358
|
// A hang-up must not wait on the prompt: start() returns at once, and
|
|
332
|
-
// the mic the prompt opens later is released as soon as it opens
|
|
333
|
-
// teardown that ran first found none to release).
|
|
359
|
+
// the mic the prompt opens later is released as soon as it opens.
|
|
334
360
|
const micOpened = async () => {
|
|
335
361
|
await Promise.race([devices, aborted]);
|
|
336
362
|
if (!stale())
|
|
337
363
|
return true;
|
|
338
|
-
|
|
364
|
+
releaseMicWhenOpen();
|
|
339
365
|
return false;
|
|
340
366
|
};
|
|
341
367
|
if (!micAllowed) {
|
|
@@ -346,18 +372,44 @@ export class T2VVoiceController {
|
|
|
346
372
|
// The access token is fetched alongside the mint, not after it.
|
|
347
373
|
const tokenFetch = this.deps.getValidAccessToken();
|
|
348
374
|
tokenFetch.catch(() => undefined); // awaited below
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
375
|
+
// An ending while the mint is in flight takes it over: once the mint
|
|
376
|
+
// returns, that ending reports the end of the ElevenLabs call it made.
|
|
377
|
+
const minting = this.mintVoice(options, []);
|
|
378
|
+
this.mintInFlight = minting;
|
|
379
|
+
let mint;
|
|
380
|
+
try {
|
|
381
|
+
mint = await minting;
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
// In the same step as the check below: no ending falls between them.
|
|
385
|
+
if (this.mintInFlight === minting)
|
|
386
|
+
this.mintInFlight = null;
|
|
387
|
+
}
|
|
388
|
+
// An ElevenLabs call's time limit counts from here, however long its library takes to load.
|
|
389
|
+
const mintedAt = Date.now();
|
|
354
390
|
timings.mark('mint');
|
|
355
|
-
|
|
391
|
+
const elevenlabs = mint.voice_model === 'elevenlabs' ? (mint.elevenlabs ?? null) : null;
|
|
392
|
+
// Hung up during the mint: the ending took the mint over, and reports its end.
|
|
393
|
+
if (stale()) {
|
|
394
|
+
releaseMicWhenOpen();
|
|
356
395
|
return;
|
|
396
|
+
}
|
|
397
|
+
// An ElevenLabs call's module and library load while the token and the
|
|
398
|
+
// microphone are awaited. Until the call takes over, an ending reports
|
|
399
|
+
// the end of the call the engine minted.
|
|
400
|
+
const elevenlabsLoad = elevenlabs ? this.loadElevenLabsCall() : null;
|
|
401
|
+
elevenlabsLoad?.catch(() => undefined); // awaited below
|
|
402
|
+
if (elevenlabs)
|
|
403
|
+
this.mintedCall = elevenlabs.conversation_id;
|
|
404
|
+
// The engine's word on the voice model it chose, shown once, at the start.
|
|
405
|
+
if (mint.note)
|
|
406
|
+
this.safeEmit('note', mint.note);
|
|
357
407
|
const token = await tokenFetch;
|
|
358
408
|
timings.mark('token');
|
|
359
|
-
if (stale())
|
|
409
|
+
if (stale()) {
|
|
410
|
+
releaseMicWhenOpen();
|
|
360
411
|
return;
|
|
412
|
+
}
|
|
361
413
|
if (!token)
|
|
362
414
|
throw new VoiceStartError('auth_expired', 'Sign in again to use voice');
|
|
363
415
|
if (micAllowed) {
|
|
@@ -365,18 +417,30 @@ export class T2VVoiceController {
|
|
|
365
417
|
return;
|
|
366
418
|
timings.mark('mic');
|
|
367
419
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
420
|
+
if (elevenlabs && elevenlabsLoad) {
|
|
421
|
+
// ElevenLabs opens its own microphone: Pipecat's is released first,
|
|
422
|
+
// once it is open, and that client never connects.
|
|
423
|
+
releaseMic(first.pipecat);
|
|
424
|
+
this.client = null;
|
|
425
|
+
client = null;
|
|
426
|
+
if (await this.startElevenLabs(elevenlabs, mintedAt, mint.session_id, options, elevenlabsLoad, stale, aborted)) {
|
|
427
|
+
if (!stale())
|
|
428
|
+
timings.done('connected');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// It couldn't connect: tried once more, on Gemini Live, with a fresh client.
|
|
432
|
+
const retry = await this.fallBackToGeminiLive(elevenlabs.conversation_id, options, stale);
|
|
433
|
+
if (!retry || stale())
|
|
434
|
+
return;
|
|
435
|
+
mint = retry;
|
|
436
|
+
({ pipecat, transport } = this.buildPipecat(libs, stale));
|
|
437
|
+
client = pipecat;
|
|
438
|
+
this.client = pipecat;
|
|
439
|
+
}
|
|
440
|
+
// A Gemini Live call: the mint carries its ticket, offer URL and ICE servers.
|
|
441
|
+
transport.iceServers = iceServersOf(mint);
|
|
373
442
|
connecting = true;
|
|
374
|
-
connectCall =
|
|
375
|
-
webrtcRequestParams: {
|
|
376
|
-
endpoint: mint.voice_url,
|
|
377
|
-
requestData: { ticket: mint.ticket, partner_key: this.deps.partnerKey, access_token: token },
|
|
378
|
-
},
|
|
379
|
-
});
|
|
443
|
+
connectCall = this.connectGemini(pipecat, mint, token);
|
|
380
444
|
// connect() waits for the bot; a hang-up mid-connect settles `aborted`.
|
|
381
445
|
await Promise.race([connectCall, aborted]);
|
|
382
446
|
if (stale())
|
|
@@ -390,11 +454,13 @@ export class T2VVoiceController {
|
|
|
390
454
|
abandon();
|
|
391
455
|
if (client)
|
|
392
456
|
releaseMic(client);
|
|
457
|
+
releaseMicWhenOpen();
|
|
393
458
|
return;
|
|
394
459
|
}
|
|
395
460
|
this.abortStart = null;
|
|
396
461
|
this.epoch += 1; // late callbacks from this failed client are not this call's
|
|
397
|
-
await this.teardown();
|
|
462
|
+
await this.teardown('error');
|
|
463
|
+
releaseMicWhenOpen();
|
|
398
464
|
const error = await describeStartError(err, connecting);
|
|
399
465
|
this.emitError(error.type, error.message);
|
|
400
466
|
this.setState('error');
|
|
@@ -407,6 +473,298 @@ export class T2VVoiceController {
|
|
|
407
473
|
this.abortStart = null;
|
|
408
474
|
}
|
|
409
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* A Pipecat client for a Gemini Live call, its callbacks bound to the
|
|
478
|
+
* start that built it (`stale`). They do nothing once the client is no
|
|
479
|
+
* longer the controller's: an ElevenLabs call took over, or the call
|
|
480
|
+
* ended. A refused first offer (503 at capacity, 502) is retried by the
|
|
481
|
+
* transport for several seconds; the ticket cannot succeed on a retry, so
|
|
482
|
+
* it allows none until connected, then gets its default back for mid-call
|
|
483
|
+
* ICE recovery.
|
|
484
|
+
*/
|
|
485
|
+
buildPipecat(libs, stale) {
|
|
486
|
+
const transport = new libs.SmallWebRTCTransport({ iceServers: [] });
|
|
487
|
+
// `maxReconnectionAttempts` is a public field of the pinned 1.10.8
|
|
488
|
+
// transport that its typings do not declare.
|
|
489
|
+
const retry = transport;
|
|
490
|
+
const retries = retry.maxReconnectionAttempts;
|
|
491
|
+
if (typeof retries === 'number')
|
|
492
|
+
retry.maxReconnectionAttempts = 0;
|
|
493
|
+
const current = () => !stale() && this.client === pipecat;
|
|
494
|
+
const pipecat = new libs.PipecatClient({
|
|
495
|
+
transport,
|
|
496
|
+
enableMic: true,
|
|
497
|
+
enableCam: false,
|
|
498
|
+
callbacks: {
|
|
499
|
+
onConnected: () => {
|
|
500
|
+
// A call that comes up after it was hung up (see `abandon`) is
|
|
501
|
+
// closed at once, not left streaming the mic until bot-ready.
|
|
502
|
+
if (stale()) {
|
|
503
|
+
pipecat.disconnect().catch(() => undefined);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (typeof retries === 'number')
|
|
507
|
+
retry.maxReconnectionAttempts = retries;
|
|
508
|
+
this.setState('listening');
|
|
509
|
+
this.safeEmit('callStarted', { voiceModel: 'gemini-live', greeting: null });
|
|
510
|
+
},
|
|
511
|
+
onDisconnected: () => {
|
|
512
|
+
if (current())
|
|
513
|
+
void this.finish('disconnected');
|
|
514
|
+
},
|
|
515
|
+
onError: (msg) => {
|
|
516
|
+
if (current())
|
|
517
|
+
this.emitError('transport_error', msg?.data?.message ?? VOICE_CONNECTION_ERROR);
|
|
518
|
+
},
|
|
519
|
+
onServerMessage: (data) => {
|
|
520
|
+
if (!current())
|
|
521
|
+
return;
|
|
522
|
+
this.handleServerMessage(data).catch(() => {
|
|
523
|
+
if (!stale())
|
|
524
|
+
this.emitError('voice_error', 'Something went wrong in the voice call');
|
|
525
|
+
});
|
|
526
|
+
},
|
|
527
|
+
onUserTranscript: (d) => {
|
|
528
|
+
if (current())
|
|
529
|
+
this.safeEmit('transcript', { role: 'user', text: d.text, final: !!d.final });
|
|
530
|
+
},
|
|
531
|
+
onBotTranscript: (d) => {
|
|
532
|
+
if (current())
|
|
533
|
+
this.safeEmit('transcript', { role: 'bot', text: d.text, final: true });
|
|
534
|
+
},
|
|
535
|
+
onTrackStarted: (track, participant) => {
|
|
536
|
+
if (!current() || participant?.local || track.kind !== 'audio')
|
|
537
|
+
return;
|
|
538
|
+
(this.deps.attachAudio ?? this.attachAudio)(track);
|
|
539
|
+
},
|
|
540
|
+
onDeviceError: (error) => {
|
|
541
|
+
if (!current())
|
|
542
|
+
return;
|
|
543
|
+
// The call is audio-only: a camera error is not ours to report.
|
|
544
|
+
if (Array.isArray(error?.devices) && !error.devices.includes('mic'))
|
|
545
|
+
return;
|
|
546
|
+
// No microphone means a call that listens to nothing: end it and say why.
|
|
547
|
+
void this.finish('error', MIC_UNAVAILABLE);
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
return { pipecat, transport };
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Connect a Gemini Live call with the mint's ticket and offer URL, its ICE
|
|
555
|
+
* servers already on the transport. Resolves once the bot is ready.
|
|
556
|
+
*/
|
|
557
|
+
connectGemini(pipecat, mint, token) {
|
|
558
|
+
return pipecat.connect({
|
|
559
|
+
webrtcRequestParams: {
|
|
560
|
+
endpoint: mint.voice_url,
|
|
561
|
+
requestData: { ticket: mint.ticket, partner_key: this.deps.partnerKey, access_token: token },
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Ask the engine for a call (`POST /v1/voice/sessions`) on any voice model
|
|
567
|
+
* this SDK runs but `exclude`, with the page's instructions and the
|
|
568
|
+
* browser's language (left out when longer than {@link MAX_LANGUAGE_LENGTH}).
|
|
569
|
+
* The engine chooses the voice model. When none is left for this app and
|
|
570
|
+
* this SDK (403 `voice_model_unavailable`), the start fails with
|
|
571
|
+
* {@link NO_VOICE_MESSAGE}.
|
|
572
|
+
*/
|
|
573
|
+
async mintVoice(options, exclude) {
|
|
574
|
+
const systemPrompt = options.systemPrompt?.trim();
|
|
575
|
+
const language = (this.deps.language ?? (() => globalThis.navigator?.language))();
|
|
576
|
+
const body = {
|
|
577
|
+
...(systemPrompt ? { system_prompt: systemPrompt } : {}),
|
|
578
|
+
voice_models: VOICE_MODELS_THIS_SDK_RUNS.filter((model) => !exclude.includes(model)),
|
|
579
|
+
...(language && language.length <= MAX_LANGUAGE_LENGTH ? { language } : {}),
|
|
580
|
+
};
|
|
581
|
+
try {
|
|
582
|
+
return await this.deps.request('/v1/voice/sessions', {
|
|
583
|
+
method: 'POST',
|
|
584
|
+
body: JSON.stringify(body),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
if (err?.type === 'voice_model_unavailable') {
|
|
589
|
+
throw new VoiceStartError('voice_model_unavailable', NO_VOICE_MESSAGE, { cause: err });
|
|
590
|
+
}
|
|
591
|
+
throw err;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* The ElevenLabs call's module, with the voice turn runner, and the
|
|
596
|
+
* library it runs on, both loaded with `import()`.
|
|
597
|
+
*/
|
|
598
|
+
async loadElevenLabsCall() {
|
|
599
|
+
const { ElevenLabsCall, VoiceTurnRunner, loadElevenLabsLibs } = await import('./voice-elevenlabs.js');
|
|
600
|
+
const libs = await (this.deps.loadElevenLabs ?? loadElevenLabsLibs)();
|
|
601
|
+
return { ElevenLabsCall, VoiceTurnRunner, libs };
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Start the ElevenLabs call the engine minted, once its module and library
|
|
605
|
+
* have loaded. Its time limit counts from `mintedAt`, when the mint
|
|
606
|
+
* answered. Its requests run as engine turns on the chat session the
|
|
607
|
+
* mint names, with the page prompt as their system message; the agent gets
|
|
608
|
+
* the page prompt unfenced (`voice-elevenlabs.ts`). Resolves `true` when
|
|
609
|
+
* the call started, or the start was hung up (that ending reported the
|
|
610
|
+
* call's end); `false` when it couldn't connect, and its end is still to
|
|
611
|
+
* be reported.
|
|
612
|
+
*/
|
|
613
|
+
async startElevenLabs(info, mintedAt, sessionId, options, load, stale, aborted) {
|
|
614
|
+
let loaded;
|
|
615
|
+
try {
|
|
616
|
+
loaded = await load;
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
// Not loaded: the call can't connect. Hung up meanwhile, the ending reported it.
|
|
620
|
+
return stale();
|
|
621
|
+
}
|
|
622
|
+
if (stale())
|
|
623
|
+
return true;
|
|
624
|
+
const pagePrompt = options.systemPrompt?.trim() || undefined;
|
|
625
|
+
const turns = new loaded.VoiceTurnRunner({
|
|
626
|
+
session: this.deps.sessionFor(sessionId),
|
|
627
|
+
pagePrompt,
|
|
628
|
+
onApproval: async (toolCallId, toolName, args) => {
|
|
629
|
+
const answer = await this.awaitApproval(toolCallId, toolName, args);
|
|
630
|
+
// Remembered as on a Gemini Live call, but not once the call has ended.
|
|
631
|
+
if (!stale() && answer.decision.action === 'always')
|
|
632
|
+
this.alwaysAllowed.add(toolName);
|
|
633
|
+
return answer;
|
|
634
|
+
},
|
|
635
|
+
isAlwaysAllowed: (toolName) => this.alwaysAllowed.has(toolName),
|
|
636
|
+
// A turn aborted as the call ended settles after it: its `idle` is not this call's.
|
|
637
|
+
onState: (state) => {
|
|
638
|
+
if (!stale())
|
|
639
|
+
this.safeEmit('agentState', state);
|
|
640
|
+
},
|
|
641
|
+
// Called mid-turn: the goodbye neither throws nor rejects.
|
|
642
|
+
onBudgetExceeded: () => {
|
|
643
|
+
if (!stale())
|
|
644
|
+
void call.goodbye();
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
const call = new loaded.ElevenLabsCall({
|
|
648
|
+
libs: loaded.libs,
|
|
649
|
+
info,
|
|
650
|
+
startedAt: mintedAt,
|
|
651
|
+
pagePrompt,
|
|
652
|
+
turns,
|
|
653
|
+
onConnected: () => {
|
|
654
|
+
if (stale())
|
|
655
|
+
return;
|
|
656
|
+
this.setState('listening');
|
|
657
|
+
this.safeEmit('callStarted', { voiceModel: 'elevenlabs', greeting: info.greeting });
|
|
658
|
+
},
|
|
659
|
+
onTranscript: (transcript) => {
|
|
660
|
+
if (!stale())
|
|
661
|
+
this.safeEmit('transcript', transcript);
|
|
662
|
+
},
|
|
663
|
+
// The library's own words are for developers. An error about the
|
|
664
|
+
// microphone ends the call with the microphone's copy, as a Gemini Live
|
|
665
|
+
// device error does (1.29.0 sends none on a WebRTC call; a later version
|
|
666
|
+
// may). Any other gets a transport error's copy, and the call goes on.
|
|
667
|
+
onError: (message) => {
|
|
668
|
+
if (stale())
|
|
669
|
+
return;
|
|
670
|
+
debugLog('voice: the ElevenLabs library reported an error', message);
|
|
671
|
+
if (/microphone/i.test(message))
|
|
672
|
+
void this.finish('error', MIC_UNAVAILABLE);
|
|
673
|
+
else
|
|
674
|
+
this.emitError('transport_error', VOICE_CONNECTION_ERROR);
|
|
675
|
+
},
|
|
676
|
+
onEnded: (reason, error) => {
|
|
677
|
+
if (!stale())
|
|
678
|
+
void this.finish(reason, error);
|
|
679
|
+
},
|
|
680
|
+
reportEnd: (reason) => this.reportEnd(info.conversation_id, reason),
|
|
681
|
+
});
|
|
682
|
+
// From here on the call reports its own end.
|
|
683
|
+
this.mintedCall = null;
|
|
684
|
+
this.elCall = call;
|
|
685
|
+
const starting = call.start();
|
|
686
|
+
starting.catch(() => undefined); // raced below
|
|
687
|
+
try {
|
|
688
|
+
await Promise.race([starting, aborted]);
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
// It never connected, and reported nothing.
|
|
693
|
+
if (!stale())
|
|
694
|
+
this.elCall = null;
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* The ElevenLabs call couldn't connect, and nothing has reported its end.
|
|
700
|
+
* The engine is told (it releases the credit it holds for the call), then
|
|
701
|
+
* asked for the call once more, without ElevenLabs, and its note shown.
|
|
702
|
+
* Resolves that mint, or `null` once the start is hung up.
|
|
703
|
+
*/
|
|
704
|
+
async fallBackToGeminiLive(conversationId, options, stale) {
|
|
705
|
+
if (stale()) {
|
|
706
|
+
this.keepEndReport(this.reportEnd(conversationId, 'connect_failed'));
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
this.mintedCall = null;
|
|
710
|
+
// Awaited, and kept: a start after a hang-up meanwhile waits for it too.
|
|
711
|
+
const report = this.reportEnd(conversationId, 'connect_failed');
|
|
712
|
+
this.keepEndReport(report);
|
|
713
|
+
await report;
|
|
714
|
+
if (stale())
|
|
715
|
+
return null;
|
|
716
|
+
const mint = await this.mintVoice(options, ['elevenlabs']);
|
|
717
|
+
if (stale())
|
|
718
|
+
return null;
|
|
719
|
+
this.safeEmit('note', mint.note ?? couldNotConnectNote(mint.voice_model, 'elevenlabs'));
|
|
720
|
+
return mint;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Keep an end report until the engine answers it: the next start waits for
|
|
724
|
+
* it. A report that fails counts as answered.
|
|
725
|
+
*/
|
|
726
|
+
keepEndReport(report) {
|
|
727
|
+
const answered = report.catch(() => undefined);
|
|
728
|
+
const pending = this.pendingEndReports
|
|
729
|
+
? Promise.all([this.pendingEndReports, answered]).then(() => undefined)
|
|
730
|
+
: answered;
|
|
731
|
+
this.pendingEndReports = pending;
|
|
732
|
+
void pending.then(() => {
|
|
733
|
+
if (this.pendingEndReports === pending)
|
|
734
|
+
this.pendingEndReports = null;
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* The end report for a mint that was in flight as the call ended. Once the
|
|
739
|
+
* mint returns, the end of the ElevenLabs call it made is reported, with
|
|
740
|
+
* the ending's reason. Settles once the engine answers that report, or as
|
|
741
|
+
* soon as the mint fails or returns a Gemini Live call. Never rejects.
|
|
742
|
+
*/
|
|
743
|
+
async reportEndAfterMint(minting, reason) {
|
|
744
|
+
const mint = await minting.catch(() => null);
|
|
745
|
+
const elevenlabs = mint?.voice_model === 'elevenlabs' ? (mint.elevenlabs ?? null) : null;
|
|
746
|
+
if (elevenlabs)
|
|
747
|
+
await this.reportEnd(elevenlabs.conversation_id, reason);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Tell the engine an ElevenLabs call has ended (`POST
|
|
751
|
+
* /v1/voice/calls/{id}/end`), so it settles the credit it holds for the
|
|
752
|
+
* call. Sent with `keepalive`, so it outlives the page. A failure is
|
|
753
|
+
* swallowed: the engine settles a call nobody reported from ElevenLabs'
|
|
754
|
+
* bill.
|
|
755
|
+
*/
|
|
756
|
+
async reportEnd(conversationId, reason) {
|
|
757
|
+
try {
|
|
758
|
+
await this.deps.request(`/v1/voice/calls/${encodeURIComponent(conversationId)}/end`, {
|
|
759
|
+
method: 'POST',
|
|
760
|
+
body: JSON.stringify({ reason }),
|
|
761
|
+
keepalive: true,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
// Swallowed, as above.
|
|
766
|
+
}
|
|
767
|
+
}
|
|
410
768
|
/** Hang up. Safe to call in any state. */
|
|
411
769
|
async stop() {
|
|
412
770
|
await this.finish('stopped');
|
|
@@ -428,7 +786,7 @@ export class T2VVoiceController {
|
|
|
428
786
|
return Promise.resolve();
|
|
429
787
|
// teardown() nulls the client before disconnecting, so the onDisconnected
|
|
430
788
|
// our own hang-up triggers is not a second, spurious ending.
|
|
431
|
-
if (reason === 'disconnected' && this.client === null)
|
|
789
|
+
if (reason === 'disconnected' && this.client === null && this.elCall === null)
|
|
432
790
|
return Promise.resolve();
|
|
433
791
|
// A transport that fails to connect fires onDisconnected before connect()
|
|
434
792
|
// rejects; start()'s catch reports that failure as an error, not an ending.
|
|
@@ -450,9 +808,9 @@ export class T2VVoiceController {
|
|
|
450
808
|
this.epoch += 1;
|
|
451
809
|
const abortStart = this.abortStart;
|
|
452
810
|
this.abortStart = null;
|
|
453
|
-
// Privacy first: the mic is released and the
|
|
811
|
+
// Privacy first: the mic is released and the call hung up before any
|
|
454
812
|
// partner listener runs.
|
|
455
|
-
await this.teardown();
|
|
813
|
+
await this.teardown(reason);
|
|
456
814
|
abortStart?.();
|
|
457
815
|
if (error) {
|
|
458
816
|
this.emitError(error.type, error.message);
|
|
@@ -466,9 +824,15 @@ export class T2VVoiceController {
|
|
|
466
824
|
this.setState('ended');
|
|
467
825
|
this.safeEmit('ended', reason);
|
|
468
826
|
}
|
|
469
|
-
async teardown() {
|
|
827
|
+
async teardown(reason) {
|
|
470
828
|
const client = this.client;
|
|
471
829
|
this.client = null;
|
|
830
|
+
const call = this.elCall;
|
|
831
|
+
this.elCall = null;
|
|
832
|
+
const minted = this.mintedCall;
|
|
833
|
+
this.mintedCall = null;
|
|
834
|
+
const minting = this.mintInFlight;
|
|
835
|
+
this.mintInFlight = null;
|
|
472
836
|
this.detachAudio();
|
|
473
837
|
if (client) {
|
|
474
838
|
try {
|
|
@@ -480,8 +844,22 @@ export class T2VVoiceController {
|
|
|
480
844
|
// A client that never connected keeps its mic after disconnect().
|
|
481
845
|
releaseMic(client);
|
|
482
846
|
}
|
|
847
|
+
// An ElevenLabs call ends its session and reports its own end; the end of
|
|
848
|
+
// one minted but not started yet is reported here, and so is the end of
|
|
849
|
+
// one the mint in flight may yet return. None waits for the engine's
|
|
850
|
+
// answer: the next start does.
|
|
851
|
+
if (call) {
|
|
852
|
+
await call.stop(reason);
|
|
853
|
+
this.keepEndReport(call.reported());
|
|
854
|
+
}
|
|
855
|
+
else if (minted) {
|
|
856
|
+
this.keepEndReport(this.reportEnd(minted, reason));
|
|
857
|
+
}
|
|
858
|
+
else if (minting) {
|
|
859
|
+
this.keepEndReport(this.reportEndAfterMint(minting, reason));
|
|
860
|
+
}
|
|
483
861
|
// Last: closing the card emits `approvalChange(null)`. Nothing is sent for
|
|
484
|
-
// it, since the
|
|
862
|
+
// it, since the call is gone.
|
|
485
863
|
this.endApproval('The call ended');
|
|
486
864
|
}
|
|
487
865
|
attachAudio = (track) => {
|