@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/vapi.ts
CHANGED
|
@@ -8,6 +8,7 @@ import DailyIframe, {
|
|
|
8
8
|
DailyEventObjectLocalAudioLevel,
|
|
9
9
|
DailyEventObjectNoPayload,
|
|
10
10
|
DailyEventObjectParticipant,
|
|
11
|
+
DailyEventObjectTrack,
|
|
11
12
|
DailyEventObjectRecordingError,
|
|
12
13
|
DailyEventObjectRecordingStarted,
|
|
13
14
|
DailyEventObjectRecordingStopped,
|
|
@@ -73,6 +74,7 @@ type VapiEventNames =
|
|
|
73
74
|
| 'speech-end'
|
|
74
75
|
| 'message'
|
|
75
76
|
| 'video'
|
|
77
|
+
| 'audio'
|
|
76
78
|
| 'error'
|
|
77
79
|
| 'camera-error'
|
|
78
80
|
| 'network-quality-change'
|
|
@@ -186,6 +188,7 @@ type VapiEventListeners = {
|
|
|
186
188
|
'speech-start': () => void;
|
|
187
189
|
'speech-end': () => void;
|
|
188
190
|
video: (track: MediaStreamTrack) => void;
|
|
191
|
+
audio: (player: HTMLAudioElement) => void;
|
|
189
192
|
message: (message: any) => void;
|
|
190
193
|
error: (error: any) => void;
|
|
191
194
|
'camera-error': (error: any) => void;
|
|
@@ -233,10 +236,18 @@ type WebCall = {
|
|
|
233
236
|
artifactPlan?: { videoRecordingEnabled?: boolean };
|
|
234
237
|
/**
|
|
235
238
|
* The Vapi WebCall assistant. This is the assistant of the call.
|
|
236
|
-
*
|
|
239
|
+
*
|
|
237
240
|
* call.assistant
|
|
238
241
|
*/
|
|
239
242
|
assistant?: { voice?: { provider?: string } };
|
|
243
|
+
/**
|
|
244
|
+
* The Vapi WebCall transport. When video recording is enabled, the server
|
|
245
|
+
* sets `callToken` to a Daily meeting token that auto-starts the cloud
|
|
246
|
+
* recording on join.
|
|
247
|
+
*
|
|
248
|
+
* call.transport
|
|
249
|
+
*/
|
|
250
|
+
transport?: { callToken?: string };
|
|
240
251
|
}
|
|
241
252
|
|
|
242
253
|
async function startAudioPlayer(
|
|
@@ -254,11 +265,22 @@ async function startAudioPlayer(
|
|
|
254
265
|
async function buildAudioPlayer(
|
|
255
266
|
track: MediaStreamTrack,
|
|
256
267
|
participantId: string,
|
|
268
|
+
volume?: number | null,
|
|
257
269
|
) {
|
|
258
270
|
const player = document.createElement('audio');
|
|
259
271
|
player.dataset.participantId = participantId;
|
|
272
|
+
// Before play(), so an already-flowing track never plays at full volume first.
|
|
273
|
+
if (typeof volume === 'number') {
|
|
274
|
+
player.volume = volume;
|
|
275
|
+
}
|
|
260
276
|
document.body.appendChild(player);
|
|
261
|
-
|
|
277
|
+
try {
|
|
278
|
+
await startAudioPlayer(player, track);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
// Appended before playback was attempted, so a failure would strand it.
|
|
281
|
+
player.remove();
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
262
284
|
return player;
|
|
263
285
|
}
|
|
264
286
|
|
|
@@ -322,6 +344,15 @@ class VapiEventEmitter extends EventEmitter {
|
|
|
322
344
|
export default class Vapi extends VapiEventEmitter {
|
|
323
345
|
private started: boolean = false;
|
|
324
346
|
private call: DailyCall | null = null;
|
|
347
|
+
private audioPlayer: HTMLAudioElement | null = null;
|
|
348
|
+
// Survives teardown so the caller's choice persists across calls.
|
|
349
|
+
private desiredVolume: number | null = null;
|
|
350
|
+
// Builds are tracked individually, not by one counter: several can be in
|
|
351
|
+
// flight, they finish out of order, and a teardown must invalidate only the
|
|
352
|
+
// builds belonging to the participant that left.
|
|
353
|
+
private audioBuildSequence = 0;
|
|
354
|
+
private pendingAudioBuilds = new Map<number, string>();
|
|
355
|
+
private attachedAudioBuild = 0;
|
|
325
356
|
private speakingTimeout: NodeJS.Timeout | null = null;
|
|
326
357
|
private dailyCallConfig: DailyAdvancedConfig = {};
|
|
327
358
|
private dailyCallObject: DailyFactoryOptions = {};
|
|
@@ -351,6 +382,8 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
351
382
|
await this.call.destroy();
|
|
352
383
|
this.call = null;
|
|
353
384
|
}
|
|
385
|
+
this.audioPlayer = null;
|
|
386
|
+
this.pendingAudioBuilds.clear();
|
|
354
387
|
this.speakingTimeout = null;
|
|
355
388
|
}
|
|
356
389
|
|
|
@@ -487,6 +520,14 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
487
520
|
|
|
488
521
|
const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
|
|
489
522
|
|
|
523
|
+
// When video recording is enabled, the server sends a Daily meeting
|
|
524
|
+
// token that auto-starts the cloud recording on join — more reliable
|
|
525
|
+
// than calling startRecording() from a weak client network. Older
|
|
526
|
+
// servers don't send one; then the client starts the recording itself.
|
|
527
|
+
const callToken = (
|
|
528
|
+
webCall?.transport as { callToken?: string } | undefined
|
|
529
|
+
)?.callToken;
|
|
530
|
+
|
|
490
531
|
// Stage 2: Create Daily call object
|
|
491
532
|
this.emit('call-start-progress', {
|
|
492
533
|
stage: 'daily-call-object-creation',
|
|
@@ -548,6 +589,8 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
548
589
|
this.hasEmittedCallEndedStatus = true;
|
|
549
590
|
}
|
|
550
591
|
if (isVideoRecordingEnabled) {
|
|
592
|
+
// When using a token, the recording doesn't stop automatically on
|
|
593
|
+
// leave, so still stop it manually to preserve existing behavior.
|
|
551
594
|
this.call?.stopRecording();
|
|
552
595
|
}
|
|
553
596
|
this.cleanup().catch(console.error);
|
|
@@ -600,24 +643,7 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
600
643
|
if (event) this.emit('recording-upload-completed', event);
|
|
601
644
|
});
|
|
602
645
|
|
|
603
|
-
this.call.on('track-started',
|
|
604
|
-
if (!e || !e.participant) {
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
if (e.participant?.local) {
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
if (e.participant?.user_name !== 'Vapi Speaker') {
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
if (e.track.kind === 'video') {
|
|
614
|
-
this.emit('video', e.track);
|
|
615
|
-
}
|
|
616
|
-
if (e.track.kind === 'audio') {
|
|
617
|
-
await buildAudioPlayer(e.track, e.participant.session_id);
|
|
618
|
-
}
|
|
619
|
-
this.call?.sendAppMessage('playable');
|
|
620
|
-
});
|
|
646
|
+
this.call.on('track-started', (e) => this.handleTrackStarted(e));
|
|
621
647
|
|
|
622
648
|
this.call.on('participant-joined', (e) => {
|
|
623
649
|
if (!e || !this.call) return;
|
|
@@ -640,9 +666,31 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
640
666
|
if (!e) {
|
|
641
667
|
return;
|
|
642
668
|
}
|
|
643
|
-
|
|
669
|
+
this.detachAudioPlayer(e.participant.session_id);
|
|
644
670
|
});
|
|
645
671
|
|
|
672
|
+
// Registered before join(): a token-auto-started recording can begin
|
|
673
|
+
// while join() is still in flight, so a listener added after join
|
|
674
|
+
// could miss the event.
|
|
675
|
+
let recordingRequestedTime = 0;
|
|
676
|
+
if (isVideoRecordingEnabled) {
|
|
677
|
+
this.call.once('recording-started', () => {
|
|
678
|
+
const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
|
|
679
|
+
this.emit('call-start-progress', {
|
|
680
|
+
stage: 'video-recording-started',
|
|
681
|
+
status: 'completed',
|
|
682
|
+
timestamp: new Date().toISOString(),
|
|
683
|
+
metadata: { delaySeconds: totalRecordingDelay }
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
this.send({
|
|
687
|
+
type: 'control',
|
|
688
|
+
control: 'say-first-message',
|
|
689
|
+
videoRecordingStartDelaySeconds: totalRecordingDelay,
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
646
694
|
// Stage 3: Mobile device handling and permissions
|
|
647
695
|
const isMobile = this.isMobileDevice();
|
|
648
696
|
this.emit('call-start-progress', {
|
|
@@ -680,11 +728,15 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
680
728
|
});
|
|
681
729
|
|
|
682
730
|
const joinStartTime = Date.now();
|
|
683
|
-
|
|
731
|
+
recordingRequestedTime = joinStartTime;
|
|
732
|
+
|
|
684
733
|
try {
|
|
685
734
|
await this.call.join({
|
|
686
735
|
// @ts-expect-error This exists
|
|
687
736
|
url: webCall.webCallUrl,
|
|
737
|
+
// daily-js rejects a `token` key that is present but undefined, so
|
|
738
|
+
// only include it when the server actually sent one.
|
|
739
|
+
...(callToken ? { token: callToken } : {}),
|
|
688
740
|
subscribeToTracksAutomatically: false,
|
|
689
741
|
});
|
|
690
742
|
|
|
@@ -716,25 +768,27 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
716
768
|
}
|
|
717
769
|
|
|
718
770
|
// Stage 5: Video recording setup (if enabled)
|
|
719
|
-
if (isVideoRecordingEnabled) {
|
|
771
|
+
if (isVideoRecordingEnabled && callToken) {
|
|
772
|
+
// The meeting token auto-started the recording at join, so there is
|
|
773
|
+
// nothing to request from the client.
|
|
774
|
+
this.emit('call-start-progress', {
|
|
775
|
+
stage: 'video-recording-setup',
|
|
776
|
+
status: 'completed',
|
|
777
|
+
timestamp: new Date().toISOString(),
|
|
778
|
+
metadata: { action: 'auto-started-via-meeting-token' }
|
|
779
|
+
});
|
|
780
|
+
} else if (isVideoRecordingEnabled) {
|
|
720
781
|
this.emit('call-start-progress', {
|
|
721
782
|
stage: 'video-recording-setup',
|
|
722
783
|
status: 'started',
|
|
723
784
|
timestamp: new Date().toISOString()
|
|
724
785
|
});
|
|
725
|
-
|
|
726
|
-
const recordingRequestedTime = new Date().getTime();
|
|
786
|
+
|
|
727
787
|
const recordingStartTime = Date.now();
|
|
728
788
|
|
|
729
789
|
try {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
height: 720,
|
|
733
|
-
backgroundColor: '#FF1F2D3D',
|
|
734
|
-
layout: {
|
|
735
|
-
preset: 'default',
|
|
736
|
-
},
|
|
737
|
-
});
|
|
790
|
+
recordingRequestedTime = new Date().getTime();
|
|
791
|
+
this.startRecording();
|
|
738
792
|
|
|
739
793
|
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
740
794
|
this.emit('call-start-progress', {
|
|
@@ -743,22 +797,6 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
743
797
|
duration: recordingSetupDuration,
|
|
744
798
|
timestamp: new Date().toISOString()
|
|
745
799
|
});
|
|
746
|
-
|
|
747
|
-
this.call.on('recording-started', () => {
|
|
748
|
-
const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
|
|
749
|
-
this.emit('call-start-progress', {
|
|
750
|
-
stage: 'video-recording-started',
|
|
751
|
-
status: 'completed',
|
|
752
|
-
timestamp: new Date().toISOString(),
|
|
753
|
-
metadata: { delaySeconds: totalRecordingDelay }
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
this.send({
|
|
757
|
-
type: 'control',
|
|
758
|
-
control: 'say-first-message',
|
|
759
|
-
videoRecordingStartDelaySeconds: totalRecordingDelay,
|
|
760
|
-
});
|
|
761
|
-
});
|
|
762
800
|
} catch (error) {
|
|
763
801
|
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
764
802
|
const serializedError = serializeError(error);
|
|
@@ -1019,6 +1057,8 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1019
1057
|
await this.call.destroy();
|
|
1020
1058
|
this.call = null;
|
|
1021
1059
|
}
|
|
1060
|
+
this.audioPlayer = null;
|
|
1061
|
+
this.pendingAudioBuilds.clear();
|
|
1022
1062
|
this.speakingTimeout = null;
|
|
1023
1063
|
}
|
|
1024
1064
|
|
|
@@ -1031,6 +1071,177 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1031
1071
|
this.call?.sendAppMessage(JSON.stringify(message));
|
|
1032
1072
|
}
|
|
1033
1073
|
|
|
1074
|
+
/**
|
|
1075
|
+
* The two places this class touches the DOM, behind indirections so the
|
|
1076
|
+
* lifecycle logic around them is testable without a browser.
|
|
1077
|
+
*/
|
|
1078
|
+
private audioPlayerBuild(
|
|
1079
|
+
track: MediaStreamTrack,
|
|
1080
|
+
participantId: string,
|
|
1081
|
+
volume?: number | null,
|
|
1082
|
+
): Promise<HTMLAudioElement> {
|
|
1083
|
+
return buildAudioPlayer(track, participantId, volume);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
private audioPlayerDestroy(participantId: string): void {
|
|
1087
|
+
destroyAudioPlayer(participantId);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* The remembered volume, bounded, or null if there is nothing to apply.
|
|
1092
|
+
* Shared by both callers so they cannot disagree: the DOM throws on a
|
|
1093
|
+
* non-finite or out-of-range volume, and that throw would stall the call.
|
|
1094
|
+
*/
|
|
1095
|
+
private desiredVolumeResolve(): number | null {
|
|
1096
|
+
if (this.desiredVolume === null || !Number.isFinite(this.desiredVolume)) {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
return Math.min(1, Math.max(0, this.desiredVolume));
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** Shared by the initial and reconnect paths so the two cannot drift. */
|
|
1103
|
+
private async handleTrackStarted(e?: DailyEventObjectTrack) {
|
|
1104
|
+
if (!e || !e.participant) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (e.participant?.local) {
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (e.participant?.user_name !== 'Vapi Speaker') {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (e.track.kind === 'video') {
|
|
1114
|
+
this.emitToConsumer('video', e.track);
|
|
1115
|
+
}
|
|
1116
|
+
if (e.track.kind === 'audio') {
|
|
1117
|
+
// play() pends for the whole of media startup, so the call can end or
|
|
1118
|
+
// the participant can leave while the player is still being built.
|
|
1119
|
+
const call = this.call;
|
|
1120
|
+
const build = ++this.audioBuildSequence;
|
|
1121
|
+
this.pendingAudioBuilds.set(build, e.participant.session_id);
|
|
1122
|
+
|
|
1123
|
+
let player: HTMLAudioElement;
|
|
1124
|
+
try {
|
|
1125
|
+
player = await this.audioPlayerBuild(
|
|
1126
|
+
e.track,
|
|
1127
|
+
e.participant.session_id,
|
|
1128
|
+
this.desiredVolumeResolve(),
|
|
1129
|
+
);
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
// Usually autoplay policy, which is recoverable, so consumers get a
|
|
1132
|
+
// signal to prompt for a gesture. Safe with no 'error' listener:
|
|
1133
|
+
// emitToConsumer catches EventEmitter's unlistened-'error' throw.
|
|
1134
|
+
this.pendingAudioBuilds.delete(build);
|
|
1135
|
+
console.error('[vapi] could not start assistant audio', error);
|
|
1136
|
+
this.emitToConsumer('error', {
|
|
1137
|
+
type: 'audio-start-failed',
|
|
1138
|
+
error: serializeError(error),
|
|
1139
|
+
});
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const stillPending = this.pendingAudioBuilds.delete(build);
|
|
1144
|
+
if (
|
|
1145
|
+
!call ||
|
|
1146
|
+
this.call !== call ||
|
|
1147
|
+
!stillPending ||
|
|
1148
|
+
build < this.attachedAudioBuild
|
|
1149
|
+
) {
|
|
1150
|
+
player.remove();
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
this.attachedAudioBuild = build;
|
|
1154
|
+
this.attachAudioPlayer(player);
|
|
1155
|
+
}
|
|
1156
|
+
this.call?.sendAppMessage('playable');
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Assigns before emitting, since emit is synchronous and a listener may call
|
|
1161
|
+
* getAudioPlayer or setVolume during the event.
|
|
1162
|
+
*/
|
|
1163
|
+
private attachAudioPlayer(player: HTMLAudioElement) {
|
|
1164
|
+
// Renegotiation reuses the participant id, so a superseded element would
|
|
1165
|
+
// still match the selector teardown uses and be removed in its place.
|
|
1166
|
+
if (this.audioPlayer && this.audioPlayer !== player) {
|
|
1167
|
+
this.audioPlayer.remove();
|
|
1168
|
+
}
|
|
1169
|
+
// Reapplied in case the volume changed while the player was building.
|
|
1170
|
+
const volume = this.desiredVolumeResolve();
|
|
1171
|
+
if (volume !== null) {
|
|
1172
|
+
player.volume = volume;
|
|
1173
|
+
}
|
|
1174
|
+
this.audioPlayer = player;
|
|
1175
|
+
this.emitToConsumer('audio', player);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* A listener that throws must not take down the caller: track-started still
|
|
1180
|
+
* has to signal that playback is ready. Not routed to the `error` event,
|
|
1181
|
+
* which EventEmitter throws on when nothing is listening.
|
|
1182
|
+
*/
|
|
1183
|
+
private emitToConsumer<E extends VapiEventNames>(
|
|
1184
|
+
event: E,
|
|
1185
|
+
...args: Parameters<VapiEventListeners[E]>
|
|
1186
|
+
) {
|
|
1187
|
+
try {
|
|
1188
|
+
this.emit(event, ...args);
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
console.error(`[vapi] ${event} event listener threw`, error);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** Drops the reference before removing, so nothing hands out a detached element. */
|
|
1195
|
+
private detachAudioPlayer(participantId: string) {
|
|
1196
|
+
if (this.audioPlayer?.dataset.participantId === participantId) {
|
|
1197
|
+
this.audioPlayer = null;
|
|
1198
|
+
}
|
|
1199
|
+
// Scoped to this participant: the call check cannot catch these, since the
|
|
1200
|
+
// call itself has not changed, and an unrelated leave must not invalidate
|
|
1201
|
+
// the assistant's build.
|
|
1202
|
+
for (const [build, id] of this.pendingAudioBuilds) {
|
|
1203
|
+
if (id === participantId) {
|
|
1204
|
+
this.pendingAudioBuilds.delete(build);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
this.audioPlayerDestroy(participantId);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* The <audio> element playing the assistant, or null before its track has
|
|
1212
|
+
* arrived. Pair with the `audio` event when you need it the moment it exists.
|
|
1213
|
+
*/
|
|
1214
|
+
public getAudioPlayer(): HTMLAudioElement | null {
|
|
1215
|
+
return this.audioPlayer;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Sets assistant playback volume, 0 to 1.
|
|
1220
|
+
*
|
|
1221
|
+
* The setting is remembered, so it can be applied before the assistant's
|
|
1222
|
+
* audio track has arrived and it survives across calls and mid-call track
|
|
1223
|
+
* replacement. Callers do not have to race the start of the call.
|
|
1224
|
+
*
|
|
1225
|
+
* Values outside 0 to 1 are clamped and non-finite values are ignored, since
|
|
1226
|
+
* the DOM rejects both rather than saturating.
|
|
1227
|
+
*
|
|
1228
|
+
* Only volume set through this method is remembered. Writing `volume`
|
|
1229
|
+
* directly on the element from getAudioPlayer() or the `audio` event applies
|
|
1230
|
+
* to that element alone, and is overwritten the next time one is attached.
|
|
1231
|
+
*
|
|
1232
|
+
* This is playback only. It does not change what the assistant hears, and it
|
|
1233
|
+
* does not affect the levels reported by the `volume-level` event.
|
|
1234
|
+
*/
|
|
1235
|
+
public setVolume(volume: number) {
|
|
1236
|
+
if (!Number.isFinite(volume)) {
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
this.desiredVolume = Math.min(1, Math.max(0, volume));
|
|
1240
|
+
if (this.audioPlayer) {
|
|
1241
|
+
this.audioPlayer.volume = this.desiredVolume;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1034
1245
|
public setMuted(mute: boolean) {
|
|
1035
1246
|
safeSetLocalAudio(this.call, !mute);
|
|
1036
1247
|
}
|
|
@@ -1169,6 +1380,37 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1169
1380
|
this.call?.stopScreenShare();
|
|
1170
1381
|
}
|
|
1171
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* Starts (or retries) a cloud recording of the call.
|
|
1385
|
+
*
|
|
1386
|
+
* Recording start is asynchronous: success is signaled by the
|
|
1387
|
+
* 'recording-started' event and failure by 'recording-error'. Daily does not
|
|
1388
|
+
* retry automatically, so if you receive 'recording-error' (e.g. the
|
|
1389
|
+
* recording start timed out), you can call this method again a few seconds
|
|
1390
|
+
* later to retry.
|
|
1391
|
+
*/
|
|
1392
|
+
public startRecording(): void {
|
|
1393
|
+
if (!this.call) {
|
|
1394
|
+
throw new Error('Call object is not available.');
|
|
1395
|
+
}
|
|
1396
|
+
this.call.startRecording({
|
|
1397
|
+
width: 1280,
|
|
1398
|
+
height: 720,
|
|
1399
|
+
backgroundColor: '#FF1F2D3D',
|
|
1400
|
+
layout: {
|
|
1401
|
+
preset: 'default',
|
|
1402
|
+
},
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Stops the in-progress recording. Completion is signaled by the
|
|
1408
|
+
* 'recording-stopped' event.
|
|
1409
|
+
*/
|
|
1410
|
+
public stopRecording(): void {
|
|
1411
|
+
this.call?.stopRecording();
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1172
1414
|
/**
|
|
1173
1415
|
* Reconnects to an active call.
|
|
1174
1416
|
*
|
|
@@ -1218,6 +1460,11 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1218
1460
|
const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
|
|
1219
1461
|
const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
|
|
1220
1462
|
|
|
1463
|
+
// Same auto-start token as in start(). Rejoining with it while the
|
|
1464
|
+
// recording is still running is safe; if the recording stopped when the
|
|
1465
|
+
// user left, the token starts a new one.
|
|
1466
|
+
const callToken = webCall?.transport?.callToken;
|
|
1467
|
+
|
|
1221
1468
|
// Stage 1: Create Daily call object
|
|
1222
1469
|
this.emit('call-start-progress', {
|
|
1223
1470
|
stage: 'daily-call-object-creation',
|
|
@@ -1260,6 +1507,8 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1260
1507
|
this.hasEmittedCallEndedStatus = true;
|
|
1261
1508
|
}
|
|
1262
1509
|
if (isVideoRecordingEnabled) {
|
|
1510
|
+
// When using a token, the recording doesn't stop automatically on
|
|
1511
|
+
// leave, so still stop it manually to preserve existing behavior.
|
|
1263
1512
|
this.call?.stopRecording();
|
|
1264
1513
|
}
|
|
1265
1514
|
this.cleanup().catch(console.error);
|
|
@@ -1312,24 +1561,7 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1312
1561
|
if (event) this.emit('recording-upload-completed', event);
|
|
1313
1562
|
});
|
|
1314
1563
|
|
|
1315
|
-
this.call.on('track-started',
|
|
1316
|
-
if (!e || !e.participant) {
|
|
1317
|
-
return;
|
|
1318
|
-
}
|
|
1319
|
-
if (e.participant?.local) {
|
|
1320
|
-
return;
|
|
1321
|
-
}
|
|
1322
|
-
if (e.participant?.user_name !== 'Vapi Speaker') {
|
|
1323
|
-
return;
|
|
1324
|
-
}
|
|
1325
|
-
if (e.track.kind === 'video') {
|
|
1326
|
-
this.emit('video', e.track);
|
|
1327
|
-
}
|
|
1328
|
-
if (e.track.kind === 'audio') {
|
|
1329
|
-
await buildAudioPlayer(e.track, e.participant.session_id);
|
|
1330
|
-
}
|
|
1331
|
-
this.call?.sendAppMessage('playable');
|
|
1332
|
-
});
|
|
1564
|
+
this.call.on('track-started', (e) => this.handleTrackStarted(e));
|
|
1333
1565
|
|
|
1334
1566
|
this.call.on('participant-joined', (e) => {
|
|
1335
1567
|
if (!e || !this.call) return;
|
|
@@ -1352,7 +1584,7 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1352
1584
|
if (!e) {
|
|
1353
1585
|
return;
|
|
1354
1586
|
}
|
|
1355
|
-
|
|
1587
|
+
this.detachAudioPlayer(e.participant.session_id);
|
|
1356
1588
|
});
|
|
1357
1589
|
|
|
1358
1590
|
this.call.on('remote-participants-audio-level', (e) => {
|
|
@@ -1388,6 +1620,28 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1388
1620
|
}
|
|
1389
1621
|
});
|
|
1390
1622
|
|
|
1623
|
+
// Registered before join(): a token-auto-started recording can begin
|
|
1624
|
+
// while join() is still in flight, so a listener added after join
|
|
1625
|
+
// could miss the event.
|
|
1626
|
+
let recordingRequestedTime = 0;
|
|
1627
|
+
if (isVideoRecordingEnabled) {
|
|
1628
|
+
this.call.once('recording-started', () => {
|
|
1629
|
+
const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
|
|
1630
|
+
this.emit('call-start-progress', {
|
|
1631
|
+
stage: 'video-recording-started',
|
|
1632
|
+
status: 'completed',
|
|
1633
|
+
timestamp: new Date().toISOString(),
|
|
1634
|
+
metadata: { delaySeconds: totalRecordingDelay }
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
this.send({
|
|
1638
|
+
type: 'control',
|
|
1639
|
+
control: 'say-first-message',
|
|
1640
|
+
videoRecordingStartDelaySeconds: totalRecordingDelay,
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1391
1645
|
// Stage 2: Mobile device handling and permissions
|
|
1392
1646
|
const isMobile = this.isMobileDevice();
|
|
1393
1647
|
this.emit('call-start-progress', {
|
|
@@ -1425,8 +1679,12 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1425
1679
|
});
|
|
1426
1680
|
|
|
1427
1681
|
const joinStartTime = Date.now();
|
|
1682
|
+
recordingRequestedTime = joinStartTime;
|
|
1428
1683
|
await this.call.join({
|
|
1429
1684
|
url: webCall.webCallUrl,
|
|
1685
|
+
// daily-js rejects a `token` key that is present but undefined, so
|
|
1686
|
+
// only include it when the server actually sent one.
|
|
1687
|
+
...(callToken ? { token: callToken } : {}),
|
|
1430
1688
|
subscribeToTracksAutomatically: false,
|
|
1431
1689
|
});
|
|
1432
1690
|
|
|
@@ -1439,25 +1697,27 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1439
1697
|
});
|
|
1440
1698
|
|
|
1441
1699
|
// Stage 4: Video recording setup (if enabled)
|
|
1442
|
-
if (isVideoRecordingEnabled) {
|
|
1700
|
+
if (isVideoRecordingEnabled && callToken) {
|
|
1701
|
+
// The meeting token auto-started the recording at join, so there is
|
|
1702
|
+
// nothing to request from the client.
|
|
1703
|
+
this.emit('call-start-progress', {
|
|
1704
|
+
stage: 'video-recording-setup',
|
|
1705
|
+
status: 'completed',
|
|
1706
|
+
timestamp: new Date().toISOString(),
|
|
1707
|
+
metadata: { action: 'auto-started-via-meeting-token' }
|
|
1708
|
+
});
|
|
1709
|
+
} else if (isVideoRecordingEnabled) {
|
|
1443
1710
|
this.emit('call-start-progress', {
|
|
1444
1711
|
stage: 'video-recording-setup',
|
|
1445
1712
|
status: 'started',
|
|
1446
1713
|
timestamp: new Date().toISOString()
|
|
1447
1714
|
});
|
|
1448
|
-
|
|
1715
|
+
|
|
1449
1716
|
const recordingStartTime = Date.now();
|
|
1450
|
-
const recordingRequestedTime = new Date().getTime();
|
|
1451
1717
|
|
|
1452
1718
|
try {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
height: 720,
|
|
1456
|
-
backgroundColor: '#FF1F2D3D',
|
|
1457
|
-
layout: {
|
|
1458
|
-
preset: 'default',
|
|
1459
|
-
},
|
|
1460
|
-
});
|
|
1719
|
+
recordingRequestedTime = new Date().getTime();
|
|
1720
|
+
this.startRecording();
|
|
1461
1721
|
|
|
1462
1722
|
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
1463
1723
|
this.emit('call-start-progress', {
|
|
@@ -1466,22 +1726,6 @@ export default class Vapi extends VapiEventEmitter {
|
|
|
1466
1726
|
duration: recordingSetupDuration,
|
|
1467
1727
|
timestamp: new Date().toISOString()
|
|
1468
1728
|
});
|
|
1469
|
-
|
|
1470
|
-
this.call.on('recording-started', () => {
|
|
1471
|
-
const totalRecordingDelay = (new Date().getTime() - recordingRequestedTime) / 1000;
|
|
1472
|
-
this.emit('call-start-progress', {
|
|
1473
|
-
stage: 'video-recording-started',
|
|
1474
|
-
status: 'completed',
|
|
1475
|
-
timestamp: new Date().toISOString(),
|
|
1476
|
-
metadata: { delaySeconds: totalRecordingDelay }
|
|
1477
|
-
});
|
|
1478
|
-
|
|
1479
|
-
this.send({
|
|
1480
|
-
type: 'control',
|
|
1481
|
-
control: 'say-first-message',
|
|
1482
|
-
videoRecordingStartDelaySeconds: totalRecordingDelay,
|
|
1483
|
-
});
|
|
1484
|
-
});
|
|
1485
1729
|
} catch (error) {
|
|
1486
1730
|
const recordingSetupDuration = Date.now() - recordingStartTime;
|
|
1487
1731
|
const serializedError = serializeError(error);
|