@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.
Files changed (8) hide show
  1. package/README.md +60 -0
  2. package/api.ts +25665 -11101
  3. package/dist/api.d.ts +22973 -13375
  4. package/dist/api.js +2171 -697
  5. package/dist/vapi.d.ts +82 -1
  6. package/dist/vapi.js +295 -88
  7. package/package.json +1 -1
  8. 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
- await startAudioPlayer(player, track);
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', async (e) => {
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
- destroyAudioPlayer(e.participant.session_id);
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
- this.call.startRecording({
731
- width: 1280,
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', async (e) => {
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
- destroyAudioPlayer(e.participant.session_id);
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
- this.call.startRecording({
1454
- width: 1280,
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);