@vapi-ai/web 3.0.0 → 3.1.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 +11 -1
- package/api.ts +8610 -5557
- package/dist/api.d.ts +6568 -4374
- package/dist/api.js +345 -17
- package/dist/jest.config.js +3 -0
- package/dist/vapi.d.ts +46 -3
- package/dist/vapi.js +157 -95
- package/package.json +2 -2
- package/vapi.ts +224 -126
package/vapi.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
CreateSquadDTO,
|
|
24
24
|
CreateAssistantDTO,
|
|
25
25
|
AssistantOverrides,
|
|
26
|
+
CreateWebCallDTO,
|
|
26
27
|
CreateWorkflowDTO,
|
|
27
28
|
WorkflowOverrides,
|
|
28
29
|
} from './api';
|
|
@@ -205,7 +206,24 @@ type VapiEventListeners = {
|
|
|
205
206
|
'recording-upload-completed': (event: DailyEventObjectNoPayload) => void;
|
|
206
207
|
};
|
|
207
208
|
|
|
209
|
+
type VideoRecordingStartMode = NonNullable<
|
|
210
|
+
CreateWebCallDTO['videoRecordingStartMode']
|
|
211
|
+
>;
|
|
212
|
+
|
|
213
|
+
// The app message Vapi sends when it couldn't start a server-mode recording.
|
|
214
|
+
const VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE = 'video-recording.start-failed';
|
|
215
|
+
|
|
216
|
+
type VideoRecordingStartFailedMessage = {
|
|
217
|
+
type: typeof VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE;
|
|
218
|
+
/** `not-hosting` when Daily saw nobody in the room; `failed` for any other failure. */
|
|
219
|
+
outcome: 'not-hosting' | 'failed';
|
|
220
|
+
/** A short description of the failure. */
|
|
221
|
+
error: string;
|
|
222
|
+
};
|
|
223
|
+
|
|
208
224
|
type StartCallOptions = {
|
|
225
|
+
/** Acquire the microphone before creating a backend call. Defaults to true. */
|
|
226
|
+
microphonePreflightEnabled?: boolean;
|
|
209
227
|
/**
|
|
210
228
|
* This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
|
|
211
229
|
* If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
|
|
@@ -215,6 +233,25 @@ type StartCallOptions = {
|
|
|
215
233
|
* @example true
|
|
216
234
|
*/
|
|
217
235
|
roomDeleteOnUserLeaveEnabled?: boolean;
|
|
236
|
+
/**
|
|
237
|
+
* This determines who starts the video recording. Only applies when video
|
|
238
|
+
* recording is enabled (`artifactPlan.videoRecordingEnabled`).
|
|
239
|
+
*
|
|
240
|
+
* - `client`: the web client starts the recording when it joins.
|
|
241
|
+
* - `server`: Vapi starts the recording once the user has joined, so a weak
|
|
242
|
+
* client connection can't stop it from starting. If Vapi can't set up the
|
|
243
|
+
* server-side start, or the start itself fails, the SDK emits
|
|
244
|
+
* `recording-error`.
|
|
245
|
+
*
|
|
246
|
+
* In `server` mode the SDK never calls `startRecording()` itself. If the
|
|
247
|
+
* server-side start couldn't be set up, calling it yourself may still start
|
|
248
|
+
* the recording.
|
|
249
|
+
*
|
|
250
|
+
* Requires a Vapi API that supports this field; older servers reject the
|
|
251
|
+
* request. The server defaults to `client` when it is not set.
|
|
252
|
+
* @example "server"
|
|
253
|
+
*/
|
|
254
|
+
videoRecordingStartMode?: VideoRecordingStartMode;
|
|
218
255
|
}
|
|
219
256
|
|
|
220
257
|
type WebCall = {
|
|
@@ -242,12 +279,22 @@ type WebCall = {
|
|
|
242
279
|
assistant?: { voice?: { provider?: string } };
|
|
243
280
|
/**
|
|
244
281
|
* The Vapi WebCall transport. When video recording is enabled, the server
|
|
245
|
-
* sets `callToken` to
|
|
246
|
-
* recording on join
|
|
282
|
+
* sets `callToken` to the Daily meeting token to join with. In `client` mode
|
|
283
|
+
* the token auto-starts the cloud recording on join; in `server` mode it
|
|
284
|
+
* starts nothing and Vapi starts the recording. If there is no token, the
|
|
285
|
+
* SDK emits `recording-error` and starts nothing, though `startRecording()`
|
|
286
|
+
* may still start the recording from the client.
|
|
287
|
+
*
|
|
288
|
+
* `videoRecordingStartMode` is `server` when Vapi starts the recording, and
|
|
289
|
+
* is absent when video recording is off or the server-side start couldn't
|
|
290
|
+
* be set up.
|
|
247
291
|
*
|
|
248
292
|
* call.transport
|
|
249
293
|
*/
|
|
250
|
-
transport?: {
|
|
294
|
+
transport?: {
|
|
295
|
+
callToken?: string;
|
|
296
|
+
videoRecordingStartMode?: VideoRecordingStartMode;
|
|
297
|
+
};
|
|
251
298
|
}
|
|
252
299
|
|
|
253
300
|
async function startAudioPlayer(
|
|
@@ -295,14 +342,13 @@ function subscribeToTracks(
|
|
|
295
342
|
e: DailyEventObjectParticipant,
|
|
296
343
|
call: DailyCall,
|
|
297
344
|
isVideoRecordingEnabled?: boolean,
|
|
298
|
-
isVideoEnabled?: boolean,
|
|
299
345
|
) {
|
|
300
346
|
if (e.participant.local) return;
|
|
301
347
|
|
|
302
348
|
call.updateParticipant(e.participant.session_id, {
|
|
303
349
|
setSubscribedTracks: {
|
|
304
350
|
audio: true,
|
|
305
|
-
video: isVideoRecordingEnabled
|
|
351
|
+
video: isVideoRecordingEnabled,
|
|
306
352
|
},
|
|
307
353
|
});
|
|
308
354
|
}
|
|
@@ -356,6 +402,40 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
356
402
|
private speakingTimeout: NodeJS.Timeout | null = null;
|
|
357
403
|
private dailyCallConfig: DailyAdvancedConfig = {};
|
|
358
404
|
private dailyCallObject: DailyFactoryOptions = {};
|
|
405
|
+
private microphoneTrack: MediaStreamTrack | null = null;
|
|
406
|
+
private cancelMicrophonePreflight: (() => void) | null = null;
|
|
407
|
+
|
|
408
|
+
private async microphoneAcquire(): Promise<MediaStreamTrack> {
|
|
409
|
+
const source = this.dailyCallObject.audioSource;
|
|
410
|
+
if (source && typeof source === 'object') {
|
|
411
|
+
if (source.readyState === 'ended') {
|
|
412
|
+
throw new DOMException('The supplied microphone track has ended.', 'NotReadableError');
|
|
413
|
+
}
|
|
414
|
+
return source;
|
|
415
|
+
}
|
|
416
|
+
return new Promise<MediaStreamTrack>((resolve, reject) => {
|
|
417
|
+
let cancelled = false;
|
|
418
|
+
this.cancelMicrophonePreflight = () => {
|
|
419
|
+
cancelled = true;
|
|
420
|
+
reject(new DOMException('Microphone preflight cancelled.', 'AbortError'));
|
|
421
|
+
};
|
|
422
|
+
navigator.mediaDevices.getUserMedia({
|
|
423
|
+
audio: typeof source === 'string' ? { deviceId: { exact: source } } : true,
|
|
424
|
+
}).then((stream) => {
|
|
425
|
+
const track = stream.getAudioTracks()[0];
|
|
426
|
+
if (cancelled || !track || track.readyState === 'ended') {
|
|
427
|
+
stream.getTracks().forEach((item) => item.stop());
|
|
428
|
+
reject(new DOMException('No live microphone track available.', 'NotFoundError'));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
stream.getTracks().filter((item) => item !== track).forEach((item) => item.stop());
|
|
432
|
+
this.microphoneTrack = track;
|
|
433
|
+
resolve(track);
|
|
434
|
+
}, reject);
|
|
435
|
+
}).finally(() => {
|
|
436
|
+
this.cancelMicrophonePreflight = null;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
359
439
|
|
|
360
440
|
private hasEmittedCallEndedStatus: boolean = false;
|
|
361
441
|
|
|
@@ -376,6 +456,8 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
376
456
|
}
|
|
377
457
|
|
|
378
458
|
private async cleanup() {
|
|
459
|
+
this.microphoneTrack?.stop();
|
|
460
|
+
this.microphoneTrack = null;
|
|
379
461
|
this.started = false;
|
|
380
462
|
this.hasEmittedCallEndedStatus = false;
|
|
381
463
|
if (this.call) {
|
|
@@ -498,7 +580,16 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
498
580
|
|
|
499
581
|
this.started = true;
|
|
500
582
|
|
|
583
|
+
let failureStage = 'microphone-preflight';
|
|
501
584
|
try {
|
|
585
|
+
if (this.call) {
|
|
586
|
+
await this.cleanup();
|
|
587
|
+
this.started = true;
|
|
588
|
+
}
|
|
589
|
+
const audioSource = options?.microphonePreflightEnabled === false
|
|
590
|
+
? this.dailyCallObject.audioSource ?? true
|
|
591
|
+
: await this.microphoneAcquire();
|
|
592
|
+
failureStage = 'unknown';
|
|
502
593
|
// Stage 1: Create web call
|
|
503
594
|
this.emit('call-start-progress', {
|
|
504
595
|
stage: 'web-call-creation',
|
|
@@ -508,18 +599,23 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
508
599
|
|
|
509
600
|
const webCallStartTime = Date.now();
|
|
510
601
|
|
|
602
|
+
// An unset option stays undefined, so JSON.stringify drops the key and
|
|
603
|
+
// older servers never see it.
|
|
604
|
+
const createWebCallBody: CreateWebCallDTO = {
|
|
605
|
+
assistant: typeof assistant === 'string' ? undefined : assistant,
|
|
606
|
+
assistantId: typeof assistant === 'string' ? assistant : undefined,
|
|
607
|
+
assistantOverrides,
|
|
608
|
+
squad: typeof squad === 'string' ? undefined : squad,
|
|
609
|
+
squadId: typeof squad === 'string' ? squad : undefined,
|
|
610
|
+
workflow: typeof workflow === 'string' ? undefined : workflow,
|
|
611
|
+
workflowId: typeof workflow === 'string' ? workflow : undefined,
|
|
612
|
+
workflowOverrides,
|
|
613
|
+
roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
|
|
614
|
+
videoRecordingStartMode: options?.videoRecordingStartMode,
|
|
615
|
+
};
|
|
616
|
+
|
|
511
617
|
const webCall = (
|
|
512
|
-
await client.call.callControllerCreateWebCall(
|
|
513
|
-
assistant: typeof assistant === 'string' ? undefined : assistant,
|
|
514
|
-
assistantId: typeof assistant === 'string' ? assistant : undefined,
|
|
515
|
-
assistantOverrides,
|
|
516
|
-
squad: typeof squad === 'string' ? undefined : squad,
|
|
517
|
-
squadId: typeof squad === 'string' ? squad : undefined,
|
|
518
|
-
workflow: typeof workflow === 'string' ? undefined : workflow,
|
|
519
|
-
workflowId: typeof workflow === 'string' ? workflow : undefined,
|
|
520
|
-
workflowOverrides,
|
|
521
|
-
roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
|
|
522
|
-
})
|
|
618
|
+
await client.call.callControllerCreateWebCall(createWebCallBody)
|
|
523
619
|
).data;
|
|
524
620
|
|
|
525
621
|
const webCallDuration = Date.now() - webCallStartTime;
|
|
@@ -535,28 +631,20 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
535
631
|
}
|
|
536
632
|
});
|
|
537
633
|
|
|
538
|
-
if (this.call) {
|
|
539
|
-
this.emit('call-start-progress', {
|
|
540
|
-
stage: 'daily-call-object-creation',
|
|
541
|
-
status: 'started',
|
|
542
|
-
timestamp: new Date().toISOString(),
|
|
543
|
-
metadata: { action: 'cleanup-existing' }
|
|
544
|
-
});
|
|
545
|
-
await this.cleanup();
|
|
546
|
-
}
|
|
547
|
-
|
|
548
634
|
const isVideoRecordingEnabled =
|
|
549
635
|
webCall?.artifactPlan?.videoRecordingEnabled ?? false;
|
|
550
636
|
|
|
551
|
-
const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
|
|
552
|
-
|
|
553
637
|
// When video recording is enabled, the server sends a Daily meeting
|
|
554
|
-
// token
|
|
555
|
-
//
|
|
556
|
-
// servers
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
638
|
+
// token. In client mode it auto-starts the cloud recording on join. In
|
|
639
|
+
// server mode it starts nothing: Vapi starts the recording from its
|
|
640
|
+
// servers once the user has joined, which a weak client network can't
|
|
641
|
+
// stop. Without a token the SDK emits recording-error and starts
|
|
642
|
+
// nothing.
|
|
643
|
+
const transport = webCall?.transport as WebCall['transport'];
|
|
644
|
+
const callToken = transport?.callToken;
|
|
645
|
+
const isRecordingStartedByServer =
|
|
646
|
+
transport?.videoRecordingStartMode === 'server';
|
|
647
|
+
const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
|
|
560
648
|
|
|
561
649
|
// Stage 2: Create Daily call object
|
|
562
650
|
this.emit('call-start-progress', {
|
|
@@ -567,7 +655,6 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
567
655
|
audioSource: this.dailyCallObject.audioSource ?? true,
|
|
568
656
|
videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
|
|
569
657
|
isVideoRecordingEnabled,
|
|
570
|
-
isVideoEnabled
|
|
571
658
|
}
|
|
572
659
|
});
|
|
573
660
|
|
|
@@ -575,7 +662,7 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
575
662
|
|
|
576
663
|
try {
|
|
577
664
|
this.call = DailyIframe.createCallObject({
|
|
578
|
-
audioSource
|
|
665
|
+
audioSource,
|
|
579
666
|
videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
|
|
580
667
|
dailyConfig: this.dailyCallConfig,
|
|
581
668
|
});
|
|
@@ -677,12 +764,7 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
677
764
|
|
|
678
765
|
this.call.on('participant-joined', (e) => {
|
|
679
766
|
if (!e || !this.call) return;
|
|
680
|
-
subscribeToTracks(
|
|
681
|
-
e,
|
|
682
|
-
this.call,
|
|
683
|
-
isVideoRecordingEnabled,
|
|
684
|
-
isVideoEnabled,
|
|
685
|
-
);
|
|
767
|
+
subscribeToTracks(e, this.call, isVideoRecordingEnabled);
|
|
686
768
|
});
|
|
687
769
|
|
|
688
770
|
this.call.on('participant-updated', (e) => {
|
|
@@ -699,6 +781,11 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
699
781
|
this.detachAudioPlayer(e.participant.session_id);
|
|
700
782
|
});
|
|
701
783
|
|
|
784
|
+
// Registered before join(), as in reconnect(): Vapi starts a server-mode
|
|
785
|
+
// recording when it sees the user join, so its start-failed message can
|
|
786
|
+
// arrive while join() is still in flight.
|
|
787
|
+
this.call.on('app-message', (e) => this.onAppMessage(e));
|
|
788
|
+
|
|
702
789
|
// Registered before join(): a token-auto-started recording can begin
|
|
703
790
|
// while join() is still in flight, so a listener added after join
|
|
704
791
|
// could miss the event.
|
|
@@ -799,52 +886,28 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
799
886
|
|
|
800
887
|
// Stage 5: Video recording setup (if enabled)
|
|
801
888
|
if (isVideoRecordingEnabled && callToken) {
|
|
802
|
-
// The meeting token auto-started the recording at join,
|
|
803
|
-
// nothing to request from the
|
|
889
|
+
// The meeting token auto-started the recording at join, or Vapi
|
|
890
|
+
// starts it from its servers, so there is nothing to request from the
|
|
891
|
+
// client.
|
|
804
892
|
this.emit('call-start-progress', {
|
|
805
893
|
stage: 'video-recording-setup',
|
|
806
894
|
status: 'completed',
|
|
807
895
|
timestamp: new Date().toISOString(),
|
|
808
|
-
metadata: {
|
|
896
|
+
metadata: {
|
|
897
|
+
action: isRecordingStartedByServer
|
|
898
|
+
? 'started-by-server'
|
|
899
|
+
: 'auto-started-via-meeting-token'
|
|
900
|
+
}
|
|
809
901
|
});
|
|
810
902
|
} else if (isVideoRecordingEnabled) {
|
|
903
|
+
// No token: nothing starts the recording. The recording-error is
|
|
904
|
+
// reported once start() has finished.
|
|
811
905
|
this.emit('call-start-progress', {
|
|
812
906
|
stage: 'video-recording-setup',
|
|
813
|
-
status: '
|
|
814
|
-
timestamp: new Date().toISOString()
|
|
907
|
+
status: 'failed',
|
|
908
|
+
timestamp: new Date().toISOString(),
|
|
909
|
+
metadata: { action: 'no-meeting-token' }
|
|
815
910
|
});
|
|
816
|
-
|
|
817
|
-
const recordingStartTime = Date.now();
|
|
818
|
-
|
|
819
|
-
try {
|
|
820
|
-
recordingRequestedTime = new Date().getTime();
|
|
821
|
-
this.startRecording();
|
|
822
|
-
|
|
823
|
-
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
824
|
-
this.emit('call-start-progress', {
|
|
825
|
-
stage: 'video-recording-setup',
|
|
826
|
-
status: 'completed',
|
|
827
|
-
duration: recordingSetupDuration,
|
|
828
|
-
timestamp: new Date().toISOString()
|
|
829
|
-
});
|
|
830
|
-
} catch (error) {
|
|
831
|
-
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
832
|
-
const serializedError = serializeError(error);
|
|
833
|
-
this.emit('call-start-progress', {
|
|
834
|
-
stage: 'video-recording-setup',
|
|
835
|
-
status: 'failed',
|
|
836
|
-
duration: recordingSetupDuration,
|
|
837
|
-
timestamp: new Date().toISOString(),
|
|
838
|
-
metadata: { error: serializedError.message }
|
|
839
|
-
});
|
|
840
|
-
this.emit('error', {
|
|
841
|
-
type: 'video-recording-setup-error',
|
|
842
|
-
stage: 'video-recording-setup',
|
|
843
|
-
error: serializedError,
|
|
844
|
-
timestamp: new Date().toISOString()
|
|
845
|
-
});
|
|
846
|
-
// Don't throw here, video recording is optional
|
|
847
|
-
}
|
|
848
911
|
} else {
|
|
849
912
|
this.emit('call-start-progress', {
|
|
850
913
|
stage: 'video-recording-setup',
|
|
@@ -899,8 +962,6 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
899
962
|
if (e) this.handleLocalAudioLevel(e);
|
|
900
963
|
});
|
|
901
964
|
|
|
902
|
-
this.call.on('app-message', (e) => this.onAppMessage(e));
|
|
903
|
-
|
|
904
965
|
this.call.on('nonfatal-error', (e) => {
|
|
905
966
|
// https://docs.daily.co/reference/daily-js/events/meeting-events#type-audio-processor-error
|
|
906
967
|
if (e?.type === 'local-audio-level-observer-error') {
|
|
@@ -990,13 +1051,18 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
990
1051
|
timestamp: new Date().toISOString()
|
|
991
1052
|
});
|
|
992
1053
|
|
|
1054
|
+
if (recordingTokenMissing) {
|
|
1055
|
+
this.recordingTokenMissingReportSchedule();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
993
1058
|
return webCall;
|
|
994
1059
|
} catch (e) {
|
|
995
1060
|
const totalDuration = Date.now() - startTime;
|
|
996
1061
|
const serializedError = serializeError(e);
|
|
1062
|
+
await this.cleanup();
|
|
997
1063
|
|
|
998
1064
|
this.emit('call-start-failed', {
|
|
999
|
-
stage:
|
|
1065
|
+
stage: failureStage,
|
|
1000
1066
|
totalDuration,
|
|
1001
1067
|
error: serializedError.message,
|
|
1002
1068
|
errorStack: serializedError.stack || 'No stack trace available',
|
|
@@ -1011,8 +1077,10 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1011
1077
|
|
|
1012
1078
|
// Also emit the generic error event for backward compatibility
|
|
1013
1079
|
this.emit('error', {
|
|
1014
|
-
type: '
|
|
1015
|
-
|
|
1080
|
+
type: failureStage === 'microphone-preflight'
|
|
1081
|
+
? `microphone-preflight-${e && typeof e === 'object' && 'name' in e ? e.name : 'error'}`
|
|
1082
|
+
: 'start-method-error',
|
|
1083
|
+
stage: failureStage,
|
|
1016
1084
|
error: serializedError,
|
|
1017
1085
|
totalDuration,
|
|
1018
1086
|
timestamp: new Date().toISOString(),
|
|
@@ -1024,7 +1092,6 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1024
1092
|
}
|
|
1025
1093
|
});
|
|
1026
1094
|
|
|
1027
|
-
await this.cleanup();
|
|
1028
1095
|
return null;
|
|
1029
1096
|
}
|
|
1030
1097
|
}
|
|
@@ -1039,6 +1106,12 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1039
1106
|
} else {
|
|
1040
1107
|
try {
|
|
1041
1108
|
const parsedMessage = JSON.parse(e.data);
|
|
1109
|
+
// Not a Vapi client message: re-emitted as recording-error, and kept
|
|
1110
|
+
// out of 'message' because its consumers switch on `type`.
|
|
1111
|
+
if (parsedMessage?.type === VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE) {
|
|
1112
|
+
this.videoRecordingStartFailedReport(parsedMessage);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1042
1115
|
this.emit('message', parsedMessage);
|
|
1043
1116
|
if (parsedMessage && 'type' in parsedMessage && 'status' in parsedMessage && parsedMessage.type === 'status-update' && parsedMessage.status === 'ended') {
|
|
1044
1117
|
this.hasEmittedCallEndedStatus = true;
|
|
@@ -1092,6 +1165,12 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1092
1165
|
* If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
|
|
1093
1166
|
*/
|
|
1094
1167
|
async stop(): Promise<void> {
|
|
1168
|
+
if (this.cancelMicrophonePreflight) {
|
|
1169
|
+
this.cancelMicrophonePreflight();
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
this.microphoneTrack?.stop();
|
|
1173
|
+
this.microphoneTrack = null;
|
|
1095
1174
|
this.started = false;
|
|
1096
1175
|
if (this.call) {
|
|
1097
1176
|
await this.call.destroy();
|
|
@@ -1231,6 +1310,41 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1231
1310
|
}
|
|
1232
1311
|
}
|
|
1233
1312
|
|
|
1313
|
+
/**
|
|
1314
|
+
* Reported in a later task, like Daily's own recording-error: start() or
|
|
1315
|
+
* reconnect() has resolved and the caller's await continuation has run, so a
|
|
1316
|
+
* listener registered right after it still hears this, and one that calls
|
|
1317
|
+
* stop() can't interrupt call setup. Skipped if that call was stopped or
|
|
1318
|
+
* replaced.
|
|
1319
|
+
*/
|
|
1320
|
+
private recordingTokenMissingReportSchedule() {
|
|
1321
|
+
const call = this.call;
|
|
1322
|
+
if (!call) return;
|
|
1323
|
+
setTimeout(() => {
|
|
1324
|
+
if (!this.started || this.call !== call) return;
|
|
1325
|
+
this.emitToConsumer('recording-error', {
|
|
1326
|
+
action: 'recording-error',
|
|
1327
|
+
callClientId: call.callClientId,
|
|
1328
|
+
errorMsg:
|
|
1329
|
+
'Vapi returned no recording token, so the recording was not started. Call startRecording() to try starting it from the client.',
|
|
1330
|
+
});
|
|
1331
|
+
}, 0);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** The fields are read defensively: the app message is untrusted JSON. */
|
|
1335
|
+
private videoRecordingStartFailedReport(
|
|
1336
|
+
message: Partial<VideoRecordingStartFailedMessage>,
|
|
1337
|
+
) {
|
|
1338
|
+
const outcome =
|
|
1339
|
+
typeof message.outcome === 'string' ? message.outcome : 'unknown';
|
|
1340
|
+
const error = typeof message.error === 'string' ? message.error : 'unknown';
|
|
1341
|
+
this.emitToConsumer('recording-error', {
|
|
1342
|
+
action: 'recording-error',
|
|
1343
|
+
callClientId: this.call?.callClientId ?? '',
|
|
1344
|
+
errorMsg: `Vapi couldn't start the recording (${outcome}): ${error}`,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1234
1348
|
/** Drops the reference before removing, so nothing hands out a detached element. */
|
|
1235
1349
|
private detachAudioPlayer(participantId: string) {
|
|
1236
1350
|
if (this.audioPlayer?.dataset.participantId === participantId) {
|
|
@@ -1498,12 +1612,16 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1498
1612
|
}
|
|
1499
1613
|
|
|
1500
1614
|
const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
|
|
1501
|
-
const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
|
|
1502
1615
|
|
|
1503
|
-
// Same
|
|
1616
|
+
// Same token as in start(). In client mode, rejoining with it while the
|
|
1504
1617
|
// recording is still running is safe; if the recording stopped when the
|
|
1505
|
-
// user left, the token starts a new one.
|
|
1618
|
+
// user left, the token starts a new one. In server mode it starts
|
|
1619
|
+
// nothing. Without a token the SDK emits recording-error and starts
|
|
1620
|
+
// nothing.
|
|
1506
1621
|
const callToken = webCall?.transport?.callToken;
|
|
1622
|
+
const isRecordingStartedByServer =
|
|
1623
|
+
webCall?.transport?.videoRecordingStartMode === 'server';
|
|
1624
|
+
const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
|
|
1507
1625
|
|
|
1508
1626
|
// Stage 1: Create Daily call object
|
|
1509
1627
|
this.emit('call-start-progress', {
|
|
@@ -1514,7 +1632,6 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1514
1632
|
audioSource: this.dailyCallObject.audioSource ?? true,
|
|
1515
1633
|
videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
|
|
1516
1634
|
isVideoRecordingEnabled,
|
|
1517
|
-
isVideoEnabled
|
|
1518
1635
|
}
|
|
1519
1636
|
});
|
|
1520
1637
|
|
|
@@ -1605,12 +1722,7 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1605
1722
|
|
|
1606
1723
|
this.call.on('participant-joined', (e) => {
|
|
1607
1724
|
if (!e || !this.call) return;
|
|
1608
|
-
subscribeToTracks(
|
|
1609
|
-
e,
|
|
1610
|
-
this.call,
|
|
1611
|
-
isVideoRecordingEnabled,
|
|
1612
|
-
isVideoEnabled,
|
|
1613
|
-
);
|
|
1725
|
+
subscribeToTracks(e, this.call, isVideoRecordingEnabled);
|
|
1614
1726
|
});
|
|
1615
1727
|
|
|
1616
1728
|
this.call.on('participant-updated', (e) => {
|
|
@@ -1738,46 +1850,28 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1738
1850
|
|
|
1739
1851
|
// Stage 4: Video recording setup (if enabled)
|
|
1740
1852
|
if (isVideoRecordingEnabled && callToken) {
|
|
1741
|
-
// The meeting token auto-started the recording at join,
|
|
1742
|
-
// nothing to request from the
|
|
1853
|
+
// The meeting token auto-started the recording at join, or Vapi
|
|
1854
|
+
// starts it from its servers, so there is nothing to request from the
|
|
1855
|
+
// client.
|
|
1743
1856
|
this.emit('call-start-progress', {
|
|
1744
1857
|
stage: 'video-recording-setup',
|
|
1745
1858
|
status: 'completed',
|
|
1746
1859
|
timestamp: new Date().toISOString(),
|
|
1747
|
-
metadata: {
|
|
1860
|
+
metadata: {
|
|
1861
|
+
action: isRecordingStartedByServer
|
|
1862
|
+
? 'started-by-server'
|
|
1863
|
+
: 'auto-started-via-meeting-token'
|
|
1864
|
+
}
|
|
1748
1865
|
});
|
|
1749
1866
|
} else if (isVideoRecordingEnabled) {
|
|
1867
|
+
// No token: nothing starts the recording. The recording-error is
|
|
1868
|
+
// reported once reconnect() has finished.
|
|
1750
1869
|
this.emit('call-start-progress', {
|
|
1751
1870
|
stage: 'video-recording-setup',
|
|
1752
|
-
status: '
|
|
1753
|
-
timestamp: new Date().toISOString()
|
|
1871
|
+
status: 'failed',
|
|
1872
|
+
timestamp: new Date().toISOString(),
|
|
1873
|
+
metadata: { action: 'no-meeting-token' }
|
|
1754
1874
|
});
|
|
1755
|
-
|
|
1756
|
-
const recordingStartTime = Date.now();
|
|
1757
|
-
|
|
1758
|
-
try {
|
|
1759
|
-
recordingRequestedTime = new Date().getTime();
|
|
1760
|
-
this.startRecording();
|
|
1761
|
-
|
|
1762
|
-
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
1763
|
-
this.emit('call-start-progress', {
|
|
1764
|
-
stage: 'video-recording-setup',
|
|
1765
|
-
status: 'completed',
|
|
1766
|
-
duration: recordingSetupDuration,
|
|
1767
|
-
timestamp: new Date().toISOString()
|
|
1768
|
-
});
|
|
1769
|
-
} catch (error) {
|
|
1770
|
-
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
1771
|
-
const serializedError = serializeError(error);
|
|
1772
|
-
this.emit('call-start-progress', {
|
|
1773
|
-
stage: 'video-recording-setup',
|
|
1774
|
-
status: 'failed',
|
|
1775
|
-
duration: recordingSetupDuration,
|
|
1776
|
-
timestamp: new Date().toISOString(),
|
|
1777
|
-
metadata: { error: serializedError.message }
|
|
1778
|
-
});
|
|
1779
|
-
// Don't throw here, video recording is optional
|
|
1780
|
-
}
|
|
1781
1875
|
} else {
|
|
1782
1876
|
this.emit('call-start-progress', {
|
|
1783
1877
|
stage: 'video-recording-setup',
|
|
@@ -1880,6 +1974,10 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1880
1974
|
|
|
1881
1975
|
this.emit('call-start');
|
|
1882
1976
|
|
|
1977
|
+
if (recordingTokenMissing) {
|
|
1978
|
+
this.recordingTokenMissingReportSchedule();
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1883
1981
|
} catch (e) {
|
|
1884
1982
|
const totalDuration = Date.now() - startTime;
|
|
1885
1983
|
const serializedError = serializeError(e);
|