@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.
Files changed (57) hide show
  1. package/README.md +37 -20
  2. package/dist/chat/views/settings.d.ts.map +1 -1
  3. package/dist/chat/views/settings.js +15 -13
  4. package/dist/chat/views/settings.js.map +1 -1
  5. package/dist/chat/voice-button.d.ts.map +1 -1
  6. package/dist/chat/voice-button.js +46 -7
  7. package/dist/chat/voice-button.js.map +1 -1
  8. package/dist/chat-css.js +1 -1
  9. package/dist/chat.css +1 -1
  10. package/dist/chat.isolated.css +21 -1
  11. package/dist/chat.raw.css +21 -1
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/client.js +11 -2
  14. package/dist/client.js.map +1 -1
  15. package/dist/constants.d.ts +10 -4
  16. package/dist/constants.d.ts.map +1 -1
  17. package/dist/constants.js +10 -4
  18. package/dist/constants.js.map +1 -1
  19. package/dist/errors.d.ts +25 -5
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +35 -10
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/sessions.d.ts +29 -6
  28. package/dist/sessions.d.ts.map +1 -1
  29. package/dist/sessions.js +28 -11
  30. package/dist/sessions.js.map +1 -1
  31. package/dist/types.d.ts +59 -5
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/version.d.ts +9 -0
  34. package/dist/version.d.ts.map +1 -0
  35. package/dist/version.js +9 -0
  36. package/dist/version.js.map +1 -0
  37. package/dist/voice-elevenlabs.d.ts +197 -0
  38. package/dist/voice-elevenlabs.d.ts.map +1 -0
  39. package/dist/voice-elevenlabs.js +358 -0
  40. package/dist/voice-elevenlabs.js.map +1 -0
  41. package/dist/voice-facade.js +1 -1
  42. package/dist/voice-facade.js.map +1 -1
  43. package/dist/voice-fence.d.ts +37 -0
  44. package/dist/voice-fence.d.ts.map +1 -0
  45. package/dist/voice-fence.js +44 -0
  46. package/dist/voice-fence.js.map +1 -0
  47. package/dist/voice-turn.d.ts +119 -0
  48. package/dist/voice-turn.d.ts.map +1 -0
  49. package/dist/voice-turn.js +207 -0
  50. package/dist/voice-turn.js.map +1 -0
  51. package/dist/voice.d.ts +131 -8
  52. package/dist/voice.d.ts.map +1 -1
  53. package/dist/voice.js +492 -114
  54. package/dist/voice.js.map +1 -1
  55. package/docs/chat.md +13 -7
  56. package/docs/voice.md +157 -33
  57. 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 Pipecat libraries, so chat-only partners ship neither.
6
+ * in turn loads the call libraries, so chat-only partners ship none of them.
7
7
  *
8
- * The engine mints a ticket (`POST /v1/voice/sessions`); the Pipecat client
9
- * libraries are loaded on first `start()` so chat-only partners never ship
10
- * them; WebRTC audio goes to the voice agent service; client tools the agent
11
- * calls arrive here over the RTVI data channel and run through the SAME tool
12
- * registry and permission check as chat, so partners write no new code.
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. Logged only
29
- * when `localStorage['t2v:debug'] === '1'`: step names and numbers, nothing
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
- let enabled = false;
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
- /** Settles the in-flight `start()`'s connect wait when the call is hung up mid-start. */
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 ticket is minted; the ICE servers come with the mint.
254
- const transport = new libs.SmallWebRTCTransport({ iceServers: [] });
255
- // A refused first offer (503 at capacity, 502) is retried by the transport
256
- // for several seconds; the ticket cannot succeed on a retry, so allow none
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 devices = pipecat.initDevices();
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 (the
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
- devices.then(() => releaseMic(pipecat), () => undefined);
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
- const systemPrompt = options.systemPrompt?.trim();
350
- const mint = await this.deps.request('/v1/voice/sessions', {
351
- method: 'POST',
352
- ...(systemPrompt ? { body: JSON.stringify({ system_prompt: systemPrompt }) } : {}),
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
- if (stale())
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
- transport.iceServers = mint.ice_servers.map((s) => ({
369
- urls: s.urls,
370
- ...(s.username ? { username: s.username } : {}),
371
- ...(s.credential ? { credential: s.credential } : {}),
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 = pipecat.connect({
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 client disconnected before any
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 client is gone.
862
+ // it, since the call is gone.
485
863
  this.endApproval('The call ended');
486
864
  }
487
865
  attachAudio = (track) => {