@vapi-ai/web 2.6.2 → 2.7.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 +60 -0
- package/api.ts +25665 -11101
- package/dist/api.d.ts +22973 -13375
- package/dist/api.js +2171 -697
- package/dist/vapi.d.ts +82 -1
- package/dist/vapi.js +295 -88
- package/package.json +1 -1
- package/vapi.ts +339 -95
package/dist/vapi.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface SayMessage {
|
|
|
23
23
|
interruptAssistantEnabled?: boolean;
|
|
24
24
|
}
|
|
25
25
|
type VapiClientToServerMessage = AddMessageMessage | ControlMessages | SayMessage | EndCallMessage;
|
|
26
|
-
type VapiEventNames = 'call-end' | 'call-start' | 'volume-level' | 'local-volume-level' | 'local-audio-level-observer-error' | 'speech-start' | 'speech-end' | 'message' | 'video' | 'error' | 'camera-error' | 'network-quality-change' | 'network-connection' | 'daily-participant-updated' | 'call-start-progress' | 'call-start-success' | 'call-start-failed' | 'recording-started' | 'recording-stopped' | 'recording-stats' | 'recording-error' | 'recording-upload-completed';
|
|
26
|
+
type VapiEventNames = 'call-end' | 'call-start' | 'volume-level' | 'local-volume-level' | 'local-audio-level-observer-error' | 'speech-start' | 'speech-end' | 'message' | 'video' | 'audio' | 'error' | 'camera-error' | 'network-quality-change' | 'network-connection' | 'daily-participant-updated' | 'call-start-progress' | 'call-start-success' | 'call-start-failed' | 'recording-started' | 'recording-stopped' | 'recording-stats' | 'recording-error' | 'recording-upload-completed';
|
|
27
27
|
interface CallStartProgressEvent {
|
|
28
28
|
stage: string;
|
|
29
29
|
status: 'started' | 'completed' | 'failed';
|
|
@@ -53,6 +53,7 @@ type VapiEventListeners = {
|
|
|
53
53
|
'speech-start': () => void;
|
|
54
54
|
'speech-end': () => void;
|
|
55
55
|
video: (track: MediaStreamTrack) => void;
|
|
56
|
+
audio: (player: HTMLAudioElement) => void;
|
|
56
57
|
message: (message: any) => void;
|
|
57
58
|
error: (error: any) => void;
|
|
58
59
|
'camera-error': (error: any) => void;
|
|
@@ -108,6 +109,16 @@ type WebCall = {
|
|
|
108
109
|
provider?: string;
|
|
109
110
|
};
|
|
110
111
|
};
|
|
112
|
+
/**
|
|
113
|
+
* The Vapi WebCall transport. When video recording is enabled, the server
|
|
114
|
+
* sets `callToken` to a Daily meeting token that auto-starts the cloud
|
|
115
|
+
* recording on join.
|
|
116
|
+
*
|
|
117
|
+
* call.transport
|
|
118
|
+
*/
|
|
119
|
+
transport?: {
|
|
120
|
+
callToken?: string;
|
|
121
|
+
};
|
|
111
122
|
};
|
|
112
123
|
declare class VapiEventEmitter extends EventEmitter {
|
|
113
124
|
on<E extends VapiEventNames>(event: E, listener: VapiEventListeners[E]): this;
|
|
@@ -119,6 +130,11 @@ declare class VapiEventEmitter extends EventEmitter {
|
|
|
119
130
|
export default class Vapi extends VapiEventEmitter {
|
|
120
131
|
private started;
|
|
121
132
|
private call;
|
|
133
|
+
private audioPlayer;
|
|
134
|
+
private desiredVolume;
|
|
135
|
+
private audioBuildSequence;
|
|
136
|
+
private pendingAudioBuilds;
|
|
137
|
+
private attachedAudioBuild;
|
|
122
138
|
private speakingTimeout;
|
|
123
139
|
private dailyCallConfig;
|
|
124
140
|
private dailyCallObject;
|
|
@@ -155,6 +171,56 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
155
171
|
* Docs: https://docs.vapi.ai/calls/call-features
|
|
156
172
|
*/
|
|
157
173
|
send(message: VapiClientToServerMessage): void;
|
|
174
|
+
/**
|
|
175
|
+
* The two places this class touches the DOM, behind indirections so the
|
|
176
|
+
* lifecycle logic around them is testable without a browser.
|
|
177
|
+
*/
|
|
178
|
+
private audioPlayerBuild;
|
|
179
|
+
private audioPlayerDestroy;
|
|
180
|
+
/**
|
|
181
|
+
* The remembered volume, bounded, or null if there is nothing to apply.
|
|
182
|
+
* Shared by both callers so they cannot disagree: the DOM throws on a
|
|
183
|
+
* non-finite or out-of-range volume, and that throw would stall the call.
|
|
184
|
+
*/
|
|
185
|
+
private desiredVolumeResolve;
|
|
186
|
+
/** Shared by the initial and reconnect paths so the two cannot drift. */
|
|
187
|
+
private handleTrackStarted;
|
|
188
|
+
/**
|
|
189
|
+
* Assigns before emitting, since emit is synchronous and a listener may call
|
|
190
|
+
* getAudioPlayer or setVolume during the event.
|
|
191
|
+
*/
|
|
192
|
+
private attachAudioPlayer;
|
|
193
|
+
/**
|
|
194
|
+
* A listener that throws must not take down the caller: track-started still
|
|
195
|
+
* has to signal that playback is ready. Not routed to the `error` event,
|
|
196
|
+
* which EventEmitter throws on when nothing is listening.
|
|
197
|
+
*/
|
|
198
|
+
private emitToConsumer;
|
|
199
|
+
/** Drops the reference before removing, so nothing hands out a detached element. */
|
|
200
|
+
private detachAudioPlayer;
|
|
201
|
+
/**
|
|
202
|
+
* The <audio> element playing the assistant, or null before its track has
|
|
203
|
+
* arrived. Pair with the `audio` event when you need it the moment it exists.
|
|
204
|
+
*/
|
|
205
|
+
getAudioPlayer(): HTMLAudioElement | null;
|
|
206
|
+
/**
|
|
207
|
+
* Sets assistant playback volume, 0 to 1.
|
|
208
|
+
*
|
|
209
|
+
* The setting is remembered, so it can be applied before the assistant's
|
|
210
|
+
* audio track has arrived and it survives across calls and mid-call track
|
|
211
|
+
* replacement. Callers do not have to race the start of the call.
|
|
212
|
+
*
|
|
213
|
+
* Values outside 0 to 1 are clamped and non-finite values are ignored, since
|
|
214
|
+
* the DOM rejects both rather than saturating.
|
|
215
|
+
*
|
|
216
|
+
* Only volume set through this method is remembered. Writing `volume`
|
|
217
|
+
* directly on the element from getAudioPlayer() or the `audio` event applies
|
|
218
|
+
* to that element alone, and is overwritten the next time one is attached.
|
|
219
|
+
*
|
|
220
|
+
* This is playback only. It does not change what the assistant hears, and it
|
|
221
|
+
* does not affect the levels reported by the `volume-level` event.
|
|
222
|
+
*/
|
|
223
|
+
setVolume(volume: number): void;
|
|
158
224
|
setMuted(mute: boolean): void;
|
|
159
225
|
isMuted(): boolean;
|
|
160
226
|
say(message: string, endCallAfterSpoken?: boolean, interruptionsEnabled?: boolean, interruptAssistantEnabled?: boolean): void;
|
|
@@ -200,6 +266,21 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
200
266
|
isLocalAudioLevelObserverRunning(): boolean;
|
|
201
267
|
startScreenSharing(displayMediaOptions?: DisplayMediaStreamOptions, screenVideoSendSettings?: DailyVideoSendSettings): void;
|
|
202
268
|
stopScreenSharing(): void;
|
|
269
|
+
/**
|
|
270
|
+
* Starts (or retries) a cloud recording of the call.
|
|
271
|
+
*
|
|
272
|
+
* Recording start is asynchronous: success is signaled by the
|
|
273
|
+
* 'recording-started' event and failure by 'recording-error'. Daily does not
|
|
274
|
+
* retry automatically, so if you receive 'recording-error' (e.g. the
|
|
275
|
+
* recording start timed out), you can call this method again a few seconds
|
|
276
|
+
* later to retry.
|
|
277
|
+
*/
|
|
278
|
+
startRecording(): void;
|
|
279
|
+
/**
|
|
280
|
+
* Stops the in-progress recording. Completion is signaled by the
|
|
281
|
+
* 'recording-stopped' event.
|
|
282
|
+
*/
|
|
283
|
+
stopRecording(): void;
|
|
203
284
|
/**
|
|
204
285
|
* Reconnects to an active call.
|
|
205
286
|
*
|
package/dist/vapi.js
CHANGED
|
@@ -66,11 +66,22 @@ async function startAudioPlayer(player, track) {
|
|
|
66
66
|
await player.play();
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
async function buildAudioPlayer(track, participantId) {
|
|
69
|
+
async function buildAudioPlayer(track, participantId, volume) {
|
|
70
70
|
const player = document.createElement('audio');
|
|
71
71
|
player.dataset.participantId = participantId;
|
|
72
|
+
// Before play(), so an already-flowing track never plays at full volume first.
|
|
73
|
+
if (typeof volume === 'number') {
|
|
74
|
+
player.volume = volume;
|
|
75
|
+
}
|
|
72
76
|
document.body.appendChild(player);
|
|
73
|
-
|
|
77
|
+
try {
|
|
78
|
+
await startAudioPlayer(player, track);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// Appended before playback was attempted, so a failure would strand it.
|
|
82
|
+
player.remove();
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
74
85
|
return player;
|
|
75
86
|
}
|
|
76
87
|
function destroyAudioPlayer(participantId) {
|
|
@@ -111,6 +122,15 @@ class VapiEventEmitter extends events_1.default {
|
|
|
111
122
|
class Vapi extends VapiEventEmitter {
|
|
112
123
|
started = false;
|
|
113
124
|
call = null;
|
|
125
|
+
audioPlayer = null;
|
|
126
|
+
// Survives teardown so the caller's choice persists across calls.
|
|
127
|
+
desiredVolume = null;
|
|
128
|
+
// Builds are tracked individually, not by one counter: several can be in
|
|
129
|
+
// flight, they finish out of order, and a teardown must invalidate only the
|
|
130
|
+
// builds belonging to the participant that left.
|
|
131
|
+
audioBuildSequence = 0;
|
|
132
|
+
pendingAudioBuilds = new Map();
|
|
133
|
+
attachedAudioBuild = 0;
|
|
114
134
|
speakingTimeout = null;
|
|
115
135
|
dailyCallConfig = {};
|
|
116
136
|
dailyCallObject = {};
|
|
@@ -129,6 +149,8 @@ class Vapi extends VapiEventEmitter {
|
|
|
129
149
|
await this.call.destroy();
|
|
130
150
|
this.call = null;
|
|
131
151
|
}
|
|
152
|
+
this.audioPlayer = null;
|
|
153
|
+
this.pendingAudioBuilds.clear();
|
|
132
154
|
this.speakingTimeout = null;
|
|
133
155
|
}
|
|
134
156
|
isMobileDevice() {
|
|
@@ -234,6 +256,11 @@ class Vapi extends VapiEventEmitter {
|
|
|
234
256
|
}
|
|
235
257
|
const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
|
|
236
258
|
const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
|
|
259
|
+
// When video recording is enabled, the server sends a Daily meeting
|
|
260
|
+
// token that auto-starts the cloud recording on join — more reliable
|
|
261
|
+
// than calling startRecording() from a weak client network. Older
|
|
262
|
+
// servers don't send one; then the client starts the recording itself.
|
|
263
|
+
const callToken = webCall?.transport?.callToken;
|
|
237
264
|
// Stage 2: Create Daily call object
|
|
238
265
|
this.emit('call-start-progress', {
|
|
239
266
|
stage: 'daily-call-object-creation',
|
|
@@ -291,6 +318,8 @@ class Vapi extends VapiEventEmitter {
|
|
|
291
318
|
this.hasEmittedCallEndedStatus = true;
|
|
292
319
|
}
|
|
293
320
|
if (isVideoRecordingEnabled) {
|
|
321
|
+
// When using a token, the recording doesn't stop automatically on
|
|
322
|
+
// leave, so still stop it manually to preserve existing behavior.
|
|
294
323
|
this.call?.stopRecording();
|
|
295
324
|
}
|
|
296
325
|
this.cleanup().catch(console.error);
|
|
@@ -338,24 +367,7 @@ class Vapi extends VapiEventEmitter {
|
|
|
338
367
|
if (event)
|
|
339
368
|
this.emit('recording-upload-completed', event);
|
|
340
369
|
});
|
|
341
|
-
this.call.on('track-started',
|
|
342
|
-
if (!e || !e.participant) {
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
if (e.participant?.local) {
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
if (e.participant?.user_name !== 'Vapi Speaker') {
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
if (e.track.kind === 'video') {
|
|
352
|
-
this.emit('video', e.track);
|
|
353
|
-
}
|
|
354
|
-
if (e.track.kind === 'audio') {
|
|
355
|
-
await buildAudioPlayer(e.track, e.participant.session_id);
|
|
356
|
-
}
|
|
357
|
-
this.call?.sendAppMessage('playable');
|
|
358
|
-
});
|
|
370
|
+
this.call.on('track-started', (e) => this.handleTrackStarted(e));
|
|
359
371
|
this.call.on('participant-joined', (e) => {
|
|
360
372
|
if (!e || !this.call)
|
|
361
373
|
return;
|
|
@@ -371,8 +383,28 @@ class Vapi extends VapiEventEmitter {
|
|
|
371
383
|
if (!e) {
|
|
372
384
|
return;
|
|
373
385
|
}
|
|
374
|
-
|
|
386
|
+
this.detachAudioPlayer(e.participant.session_id);
|
|
375
387
|
});
|
|
388
|
+
// Registered before join(): a token-auto-started recording can begin
|
|
389
|
+
// while join() is still in flight, so a listener added after join
|
|
390
|
+
// could miss the event.
|
|
391
|
+
let recordingRequestedTime = 0;
|
|
392
|
+
if (isVideoRecordingEnabled) {
|
|
393
|
+
this.call.once('recording-started', () => {
|
|
394
|
+
const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
|
|
395
|
+
this.emit('call-start-progress', {
|
|
396
|
+
stage: 'video-recording-started',
|
|
397
|
+
status: 'completed',
|
|
398
|
+
timestamp: new Date().toISOString(),
|
|
399
|
+
metadata: { delaySeconds: totalRecordingDelay }
|
|
400
|
+
});
|
|
401
|
+
this.send({
|
|
402
|
+
type: 'control',
|
|
403
|
+
control: 'say-first-message',
|
|
404
|
+
videoRecordingStartDelaySeconds: totalRecordingDelay,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
}
|
|
376
408
|
// Stage 3: Mobile device handling and permissions
|
|
377
409
|
const isMobile = this.isMobileDevice();
|
|
378
410
|
this.emit('call-start-progress', {
|
|
@@ -408,10 +440,14 @@ class Vapi extends VapiEventEmitter {
|
|
|
408
440
|
timestamp: new Date().toISOString()
|
|
409
441
|
});
|
|
410
442
|
const joinStartTime = Date.now();
|
|
443
|
+
recordingRequestedTime = joinStartTime;
|
|
411
444
|
try {
|
|
412
445
|
await this.call.join({
|
|
413
446
|
// @ts-expect-error This exists
|
|
414
447
|
url: webCall.webCallUrl,
|
|
448
|
+
// daily-js rejects a `token` key that is present but undefined, so
|
|
449
|
+
// only include it when the server actually sent one.
|
|
450
|
+
...(callToken ? { token: callToken } : {}),
|
|
415
451
|
subscribeToTracksAutomatically: false,
|
|
416
452
|
});
|
|
417
453
|
const joinDuration = Date.now() - joinStartTime;
|
|
@@ -442,23 +478,26 @@ class Vapi extends VapiEventEmitter {
|
|
|
442
478
|
throw error;
|
|
443
479
|
}
|
|
444
480
|
// Stage 5: Video recording setup (if enabled)
|
|
445
|
-
if (isVideoRecordingEnabled) {
|
|
481
|
+
if (isVideoRecordingEnabled && callToken) {
|
|
482
|
+
// The meeting token auto-started the recording at join, so there is
|
|
483
|
+
// nothing to request from the client.
|
|
484
|
+
this.emit('call-start-progress', {
|
|
485
|
+
stage: 'video-recording-setup',
|
|
486
|
+
status: 'completed',
|
|
487
|
+
timestamp: new Date().toISOString(),
|
|
488
|
+
metadata: { action: 'auto-started-via-meeting-token' }
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
else if (isVideoRecordingEnabled) {
|
|
446
492
|
this.emit('call-start-progress', {
|
|
447
493
|
stage: 'video-recording-setup',
|
|
448
494
|
status: 'started',
|
|
449
495
|
timestamp: new Date().toISOString()
|
|
450
496
|
});
|
|
451
|
-
const recordingRequestedTime = new Date().getTime();
|
|
452
497
|
const recordingStartTime = Date.now();
|
|
453
498
|
try {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
height: 720,
|
|
457
|
-
backgroundColor: '#FF1F2D3D',
|
|
458
|
-
layout: {
|
|
459
|
-
preset: 'default',
|
|
460
|
-
},
|
|
461
|
-
});
|
|
499
|
+
recordingRequestedTime = new Date().getTime();
|
|
500
|
+
this.startRecording();
|
|
462
501
|
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
463
502
|
this.emit('call-start-progress', {
|
|
464
503
|
stage: 'video-recording-setup',
|
|
@@ -466,20 +505,6 @@ class Vapi extends VapiEventEmitter {
|
|
|
466
505
|
duration: recordingSetupDuration,
|
|
467
506
|
timestamp: new Date().toISOString()
|
|
468
507
|
});
|
|
469
|
-
this.call.on('recording-started', () => {
|
|
470
|
-
const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
|
|
471
|
-
this.emit('call-start-progress', {
|
|
472
|
-
stage: 'video-recording-started',
|
|
473
|
-
status: 'completed',
|
|
474
|
-
timestamp: new Date().toISOString(),
|
|
475
|
-
metadata: { delaySeconds: totalRecordingDelay }
|
|
476
|
-
});
|
|
477
|
-
this.send({
|
|
478
|
-
type: 'control',
|
|
479
|
-
control: 'say-first-message',
|
|
480
|
-
videoRecordingStartDelaySeconds: totalRecordingDelay,
|
|
481
|
-
});
|
|
482
|
-
});
|
|
483
508
|
}
|
|
484
509
|
catch (error) {
|
|
485
510
|
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
@@ -721,6 +746,8 @@ class Vapi extends VapiEventEmitter {
|
|
|
721
746
|
await this.call.destroy();
|
|
722
747
|
this.call = null;
|
|
723
748
|
}
|
|
749
|
+
this.audioPlayer = null;
|
|
750
|
+
this.pendingAudioBuilds.clear();
|
|
724
751
|
this.speakingTimeout = null;
|
|
725
752
|
}
|
|
726
753
|
/**
|
|
@@ -731,6 +758,155 @@ class Vapi extends VapiEventEmitter {
|
|
|
731
758
|
send(message) {
|
|
732
759
|
this.call?.sendAppMessage(JSON.stringify(message));
|
|
733
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* The two places this class touches the DOM, behind indirections so the
|
|
763
|
+
* lifecycle logic around them is testable without a browser.
|
|
764
|
+
*/
|
|
765
|
+
audioPlayerBuild(track, participantId, volume) {
|
|
766
|
+
return buildAudioPlayer(track, participantId, volume);
|
|
767
|
+
}
|
|
768
|
+
audioPlayerDestroy(participantId) {
|
|
769
|
+
destroyAudioPlayer(participantId);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* The remembered volume, bounded, or null if there is nothing to apply.
|
|
773
|
+
* Shared by both callers so they cannot disagree: the DOM throws on a
|
|
774
|
+
* non-finite or out-of-range volume, and that throw would stall the call.
|
|
775
|
+
*/
|
|
776
|
+
desiredVolumeResolve() {
|
|
777
|
+
if (this.desiredVolume === null || !Number.isFinite(this.desiredVolume)) {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
return Math.min(1, Math.max(0, this.desiredVolume));
|
|
781
|
+
}
|
|
782
|
+
/** Shared by the initial and reconnect paths so the two cannot drift. */
|
|
783
|
+
async handleTrackStarted(e) {
|
|
784
|
+
if (!e || !e.participant) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (e.participant?.local) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (e.participant?.user_name !== 'Vapi Speaker') {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (e.track.kind === 'video') {
|
|
794
|
+
this.emitToConsumer('video', e.track);
|
|
795
|
+
}
|
|
796
|
+
if (e.track.kind === 'audio') {
|
|
797
|
+
// play() pends for the whole of media startup, so the call can end or
|
|
798
|
+
// the participant can leave while the player is still being built.
|
|
799
|
+
const call = this.call;
|
|
800
|
+
const build = ++this.audioBuildSequence;
|
|
801
|
+
this.pendingAudioBuilds.set(build, e.participant.session_id);
|
|
802
|
+
let player;
|
|
803
|
+
try {
|
|
804
|
+
player = await this.audioPlayerBuild(e.track, e.participant.session_id, this.desiredVolumeResolve());
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
// Usually autoplay policy, which is recoverable, so consumers get a
|
|
808
|
+
// signal to prompt for a gesture. Safe with no 'error' listener:
|
|
809
|
+
// emitToConsumer catches EventEmitter's unlistened-'error' throw.
|
|
810
|
+
this.pendingAudioBuilds.delete(build);
|
|
811
|
+
console.error('[vapi] could not start assistant audio', error);
|
|
812
|
+
this.emitToConsumer('error', {
|
|
813
|
+
type: 'audio-start-failed',
|
|
814
|
+
error: serializeError(error),
|
|
815
|
+
});
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const stillPending = this.pendingAudioBuilds.delete(build);
|
|
819
|
+
if (!call ||
|
|
820
|
+
this.call !== call ||
|
|
821
|
+
!stillPending ||
|
|
822
|
+
build < this.attachedAudioBuild) {
|
|
823
|
+
player.remove();
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
this.attachedAudioBuild = build;
|
|
827
|
+
this.attachAudioPlayer(player);
|
|
828
|
+
}
|
|
829
|
+
this.call?.sendAppMessage('playable');
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Assigns before emitting, since emit is synchronous and a listener may call
|
|
833
|
+
* getAudioPlayer or setVolume during the event.
|
|
834
|
+
*/
|
|
835
|
+
attachAudioPlayer(player) {
|
|
836
|
+
// Renegotiation reuses the participant id, so a superseded element would
|
|
837
|
+
// still match the selector teardown uses and be removed in its place.
|
|
838
|
+
if (this.audioPlayer && this.audioPlayer !== player) {
|
|
839
|
+
this.audioPlayer.remove();
|
|
840
|
+
}
|
|
841
|
+
// Reapplied in case the volume changed while the player was building.
|
|
842
|
+
const volume = this.desiredVolumeResolve();
|
|
843
|
+
if (volume !== null) {
|
|
844
|
+
player.volume = volume;
|
|
845
|
+
}
|
|
846
|
+
this.audioPlayer = player;
|
|
847
|
+
this.emitToConsumer('audio', player);
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* A listener that throws must not take down the caller: track-started still
|
|
851
|
+
* has to signal that playback is ready. Not routed to the `error` event,
|
|
852
|
+
* which EventEmitter throws on when nothing is listening.
|
|
853
|
+
*/
|
|
854
|
+
emitToConsumer(event, ...args) {
|
|
855
|
+
try {
|
|
856
|
+
this.emit(event, ...args);
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
console.error(`[vapi] ${event} event listener threw`, error);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/** Drops the reference before removing, so nothing hands out a detached element. */
|
|
863
|
+
detachAudioPlayer(participantId) {
|
|
864
|
+
if (this.audioPlayer?.dataset.participantId === participantId) {
|
|
865
|
+
this.audioPlayer = null;
|
|
866
|
+
}
|
|
867
|
+
// Scoped to this participant: the call check cannot catch these, since the
|
|
868
|
+
// call itself has not changed, and an unrelated leave must not invalidate
|
|
869
|
+
// the assistant's build.
|
|
870
|
+
for (const [build, id] of this.pendingAudioBuilds) {
|
|
871
|
+
if (id === participantId) {
|
|
872
|
+
this.pendingAudioBuilds.delete(build);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
this.audioPlayerDestroy(participantId);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* The <audio> element playing the assistant, or null before its track has
|
|
879
|
+
* arrived. Pair with the `audio` event when you need it the moment it exists.
|
|
880
|
+
*/
|
|
881
|
+
getAudioPlayer() {
|
|
882
|
+
return this.audioPlayer;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Sets assistant playback volume, 0 to 1.
|
|
886
|
+
*
|
|
887
|
+
* The setting is remembered, so it can be applied before the assistant's
|
|
888
|
+
* audio track has arrived and it survives across calls and mid-call track
|
|
889
|
+
* replacement. Callers do not have to race the start of the call.
|
|
890
|
+
*
|
|
891
|
+
* Values outside 0 to 1 are clamped and non-finite values are ignored, since
|
|
892
|
+
* the DOM rejects both rather than saturating.
|
|
893
|
+
*
|
|
894
|
+
* Only volume set through this method is remembered. Writing `volume`
|
|
895
|
+
* directly on the element from getAudioPlayer() or the `audio` event applies
|
|
896
|
+
* to that element alone, and is overwritten the next time one is attached.
|
|
897
|
+
*
|
|
898
|
+
* This is playback only. It does not change what the assistant hears, and it
|
|
899
|
+
* does not affect the levels reported by the `volume-level` event.
|
|
900
|
+
*/
|
|
901
|
+
setVolume(volume) {
|
|
902
|
+
if (!Number.isFinite(volume)) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
this.desiredVolume = Math.min(1, Math.max(0, volume));
|
|
906
|
+
if (this.audioPlayer) {
|
|
907
|
+
this.audioPlayer.volume = this.desiredVolume;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
734
910
|
setMuted(mute) {
|
|
735
911
|
(0, daily_guards_1.safeSetLocalAudio)(this.call, !mute);
|
|
736
912
|
}
|
|
@@ -845,6 +1021,35 @@ class Vapi extends VapiEventEmitter {
|
|
|
845
1021
|
stopScreenSharing() {
|
|
846
1022
|
this.call?.stopScreenShare();
|
|
847
1023
|
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Starts (or retries) a cloud recording of the call.
|
|
1026
|
+
*
|
|
1027
|
+
* Recording start is asynchronous: success is signaled by the
|
|
1028
|
+
* 'recording-started' event and failure by 'recording-error'. Daily does not
|
|
1029
|
+
* retry automatically, so if you receive 'recording-error' (e.g. the
|
|
1030
|
+
* recording start timed out), you can call this method again a few seconds
|
|
1031
|
+
* later to retry.
|
|
1032
|
+
*/
|
|
1033
|
+
startRecording() {
|
|
1034
|
+
if (!this.call) {
|
|
1035
|
+
throw new Error('Call object is not available.');
|
|
1036
|
+
}
|
|
1037
|
+
this.call.startRecording({
|
|
1038
|
+
width: 1280,
|
|
1039
|
+
height: 720,
|
|
1040
|
+
backgroundColor: '#FF1F2D3D',
|
|
1041
|
+
layout: {
|
|
1042
|
+
preset: 'default',
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Stops the in-progress recording. Completion is signaled by the
|
|
1048
|
+
* 'recording-stopped' event.
|
|
1049
|
+
*/
|
|
1050
|
+
stopRecording() {
|
|
1051
|
+
this.call?.stopRecording();
|
|
1052
|
+
}
|
|
848
1053
|
/**
|
|
849
1054
|
* Reconnects to an active call.
|
|
850
1055
|
*
|
|
@@ -887,6 +1092,10 @@ class Vapi extends VapiEventEmitter {
|
|
|
887
1092
|
}
|
|
888
1093
|
const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
|
|
889
1094
|
const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
|
|
1095
|
+
// Same auto-start token as in start(). Rejoining with it while the
|
|
1096
|
+
// recording is still running is safe; if the recording stopped when the
|
|
1097
|
+
// user left, the token starts a new one.
|
|
1098
|
+
const callToken = webCall?.transport?.callToken;
|
|
890
1099
|
// Stage 1: Create Daily call object
|
|
891
1100
|
this.emit('call-start-progress', {
|
|
892
1101
|
stage: 'daily-call-object-creation',
|
|
@@ -925,6 +1134,8 @@ class Vapi extends VapiEventEmitter {
|
|
|
925
1134
|
this.hasEmittedCallEndedStatus = true;
|
|
926
1135
|
}
|
|
927
1136
|
if (isVideoRecordingEnabled) {
|
|
1137
|
+
// When using a token, the recording doesn't stop automatically on
|
|
1138
|
+
// leave, so still stop it manually to preserve existing behavior.
|
|
928
1139
|
this.call?.stopRecording();
|
|
929
1140
|
}
|
|
930
1141
|
this.cleanup().catch(console.error);
|
|
@@ -972,24 +1183,7 @@ class Vapi extends VapiEventEmitter {
|
|
|
972
1183
|
if (event)
|
|
973
1184
|
this.emit('recording-upload-completed', event);
|
|
974
1185
|
});
|
|
975
|
-
this.call.on('track-started',
|
|
976
|
-
if (!e || !e.participant) {
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
if (e.participant?.local) {
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
|
-
if (e.participant?.user_name !== 'Vapi Speaker') {
|
|
983
|
-
return;
|
|
984
|
-
}
|
|
985
|
-
if (e.track.kind === 'video') {
|
|
986
|
-
this.emit('video', e.track);
|
|
987
|
-
}
|
|
988
|
-
if (e.track.kind === 'audio') {
|
|
989
|
-
await buildAudioPlayer(e.track, e.participant.session_id);
|
|
990
|
-
}
|
|
991
|
-
this.call?.sendAppMessage('playable');
|
|
992
|
-
});
|
|
1186
|
+
this.call.on('track-started', (e) => this.handleTrackStarted(e));
|
|
993
1187
|
this.call.on('participant-joined', (e) => {
|
|
994
1188
|
if (!e || !this.call)
|
|
995
1189
|
return;
|
|
@@ -1005,7 +1199,7 @@ class Vapi extends VapiEventEmitter {
|
|
|
1005
1199
|
if (!e) {
|
|
1006
1200
|
return;
|
|
1007
1201
|
}
|
|
1008
|
-
|
|
1202
|
+
this.detachAudioPlayer(e.participant.session_id);
|
|
1009
1203
|
});
|
|
1010
1204
|
this.call.on('remote-participants-audio-level', (e) => {
|
|
1011
1205
|
if (e)
|
|
@@ -1038,6 +1232,26 @@ class Vapi extends VapiEventEmitter {
|
|
|
1038
1232
|
});
|
|
1039
1233
|
}
|
|
1040
1234
|
});
|
|
1235
|
+
// Registered before join(): a token-auto-started recording can begin
|
|
1236
|
+
// while join() is still in flight, so a listener added after join
|
|
1237
|
+
// could miss the event.
|
|
1238
|
+
let recordingRequestedTime = 0;
|
|
1239
|
+
if (isVideoRecordingEnabled) {
|
|
1240
|
+
this.call.once('recording-started', () => {
|
|
1241
|
+
const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
|
|
1242
|
+
this.emit('call-start-progress', {
|
|
1243
|
+
stage: 'video-recording-started',
|
|
1244
|
+
status: 'completed',
|
|
1245
|
+
timestamp: new Date().toISOString(),
|
|
1246
|
+
metadata: { delaySeconds: totalRecordingDelay }
|
|
1247
|
+
});
|
|
1248
|
+
this.send({
|
|
1249
|
+
type: 'control',
|
|
1250
|
+
control: 'say-first-message',
|
|
1251
|
+
videoRecordingStartDelaySeconds: totalRecordingDelay,
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1041
1255
|
// Stage 2: Mobile device handling and permissions
|
|
1042
1256
|
const isMobile = this.isMobileDevice();
|
|
1043
1257
|
this.emit('call-start-progress', {
|
|
@@ -1073,8 +1287,12 @@ class Vapi extends VapiEventEmitter {
|
|
|
1073
1287
|
timestamp: new Date().toISOString()
|
|
1074
1288
|
});
|
|
1075
1289
|
const joinStartTime = Date.now();
|
|
1290
|
+
recordingRequestedTime = joinStartTime;
|
|
1076
1291
|
await this.call.join({
|
|
1077
1292
|
url: webCall.webCallUrl,
|
|
1293
|
+
// daily-js rejects a `token` key that is present but undefined, so
|
|
1294
|
+
// only include it when the server actually sent one.
|
|
1295
|
+
...(callToken ? { token: callToken } : {}),
|
|
1078
1296
|
subscribeToTracksAutomatically: false,
|
|
1079
1297
|
});
|
|
1080
1298
|
const joinDuration = Date.now() - joinStartTime;
|
|
@@ -1085,23 +1303,26 @@ class Vapi extends VapiEventEmitter {
|
|
|
1085
1303
|
timestamp: new Date().toISOString()
|
|
1086
1304
|
});
|
|
1087
1305
|
// Stage 4: Video recording setup (if enabled)
|
|
1088
|
-
if (isVideoRecordingEnabled) {
|
|
1306
|
+
if (isVideoRecordingEnabled && callToken) {
|
|
1307
|
+
// The meeting token auto-started the recording at join, so there is
|
|
1308
|
+
// nothing to request from the client.
|
|
1309
|
+
this.emit('call-start-progress', {
|
|
1310
|
+
stage: 'video-recording-setup',
|
|
1311
|
+
status: 'completed',
|
|
1312
|
+
timestamp: new Date().toISOString(),
|
|
1313
|
+
metadata: { action: 'auto-started-via-meeting-token' }
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
else if (isVideoRecordingEnabled) {
|
|
1089
1317
|
this.emit('call-start-progress', {
|
|
1090
1318
|
stage: 'video-recording-setup',
|
|
1091
1319
|
status: 'started',
|
|
1092
1320
|
timestamp: new Date().toISOString()
|
|
1093
1321
|
});
|
|
1094
1322
|
const recordingStartTime = Date.now();
|
|
1095
|
-
const recordingRequestedTime = new Date().getTime();
|
|
1096
1323
|
try {
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
height: 720,
|
|
1100
|
-
backgroundColor: '#FF1F2D3D',
|
|
1101
|
-
layout: {
|
|
1102
|
-
preset: 'default',
|
|
1103
|
-
},
|
|
1104
|
-
});
|
|
1324
|
+
recordingRequestedTime = new Date().getTime();
|
|
1325
|
+
this.startRecording();
|
|
1105
1326
|
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
1106
1327
|
this.emit('call-start-progress', {
|
|
1107
1328
|
stage: 'video-recording-setup',
|
|
@@ -1109,20 +1330,6 @@ class Vapi extends VapiEventEmitter {
|
|
|
1109
1330
|
duration: recordingSetupDuration,
|
|
1110
1331
|
timestamp: new Date().toISOString()
|
|
1111
1332
|
});
|
|
1112
|
-
this.call.on('recording-started', () => {
|
|
1113
|
-
const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
|
|
1114
|
-
this.emit('call-start-progress', {
|
|
1115
|
-
stage: 'video-recording-started',
|
|
1116
|
-
status: 'completed',
|
|
1117
|
-
timestamp: new Date().toISOString(),
|
|
1118
|
-
metadata: { delaySeconds: totalRecordingDelay }
|
|
1119
|
-
});
|
|
1120
|
-
this.send({
|
|
1121
|
-
type: 'control',
|
|
1122
|
-
control: 'say-first-message',
|
|
1123
|
-
videoRecordingStartDelaySeconds: totalRecordingDelay,
|
|
1124
|
-
});
|
|
1125
|
-
});
|
|
1126
1333
|
}
|
|
1127
1334
|
catch (error) {
|
|
1128
1335
|
const recordingSetupDuration = Date.now() - recordingStartTime;
|