@vapi-ai/web 3.0.1 → 3.1.1

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/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,9 +206,33 @@ 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 = {
209
225
  /** Acquire the microphone before creating a backend call. Defaults to true. */
210
226
  microphonePreflightEnabled?: boolean;
227
+ /**
228
+ * Run Daily's Krisp noise cancellation on the microphone
229
+ * in the browser, before the audio leaves the device. Set to `false` to send
230
+ * the raw microphone audio. Defaults to true.
231
+ *
232
+ * Separate from the assistant's server-side `backgroundSpeechDenoisingPlan`,
233
+ * which this option does not change.
234
+ */
235
+ clientSideNoiseCancellationEnabled?: boolean;
211
236
  /**
212
237
  * This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
213
238
  * If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
@@ -217,6 +242,37 @@ type StartCallOptions = {
217
242
  * @example true
218
243
  */
219
244
  roomDeleteOnUserLeaveEnabled?: boolean;
245
+ /**
246
+ * This determines who starts the video recording. Only applies when video
247
+ * recording is enabled (`artifactPlan.videoRecordingEnabled`).
248
+ *
249
+ * - `client`: the web client starts the recording when it joins.
250
+ * - `server`: Vapi starts the recording once the user has joined, so a weak
251
+ * client connection can't stop it from starting. If Vapi can't set up the
252
+ * server-side start, or the start itself fails, the SDK emits
253
+ * `recording-error`.
254
+ *
255
+ * In `server` mode the SDK never calls `startRecording()` itself. If the
256
+ * server-side start couldn't be set up, calling it yourself may still start
257
+ * the recording.
258
+ *
259
+ * Requires a Vapi API that supports this field; older servers reject the
260
+ * request. The server defaults to `client` when it is not set.
261
+ * @example "server"
262
+ */
263
+ videoRecordingStartMode?: VideoRecordingStartMode;
264
+ }
265
+
266
+ type ReconnectOptions = {
267
+ /**
268
+ * Run Daily's Krisp noise cancellation on the microphone
269
+ * in the browser. Defaults to the value used by the last `start()` or
270
+ * `reconnect()`, or true if neither set it.
271
+ *
272
+ * Separate from the assistant's server-side `backgroundSpeechDenoisingPlan`,
273
+ * which this option does not change.
274
+ */
275
+ clientSideNoiseCancellationEnabled?: boolean;
220
276
  }
221
277
 
222
278
  type WebCall = {
@@ -244,12 +300,22 @@ type WebCall = {
244
300
  assistant?: { voice?: { provider?: string } };
245
301
  /**
246
302
  * The Vapi WebCall transport. When video recording is enabled, the server
247
- * sets `callToken` to a Daily meeting token that auto-starts the cloud
248
- * recording on join.
303
+ * sets `callToken` to the Daily meeting token to join with. In `client` mode
304
+ * the token auto-starts the cloud recording on join; in `server` mode it
305
+ * starts nothing and Vapi starts the recording. If there is no token, the
306
+ * SDK emits `recording-error` and starts nothing, though `startRecording()`
307
+ * may still start the recording from the client.
308
+ *
309
+ * `videoRecordingStartMode` is `server` when Vapi starts the recording, and
310
+ * is absent when video recording is off or the server-side start couldn't
311
+ * be set up.
249
312
  *
250
313
  * call.transport
251
314
  */
252
- transport?: { callToken?: string };
315
+ transport?: {
316
+ callToken?: string;
317
+ videoRecordingStartMode?: VideoRecordingStartMode;
318
+ };
253
319
  }
254
320
 
255
321
  async function startAudioPlayer(
@@ -297,14 +363,13 @@ function subscribeToTracks(
297
363
  e: DailyEventObjectParticipant,
298
364
  call: DailyCall,
299
365
  isVideoRecordingEnabled?: boolean,
300
- isVideoEnabled?: boolean,
301
366
  ) {
302
367
  if (e.participant.local) return;
303
368
 
304
369
  call.updateParticipant(e.participant.session_id, {
305
370
  setSubscribedTracks: {
306
371
  audio: true,
307
- video: isVideoRecordingEnabled || isVideoEnabled,
372
+ video: isVideoRecordingEnabled,
308
373
  },
309
374
  });
310
375
  }
@@ -360,6 +425,7 @@ export default class Vapi extends VapiEventEmitter {
360
425
  private dailyCallObject: DailyFactoryOptions = {};
361
426
  private microphoneTrack: MediaStreamTrack | null = null;
362
427
  private cancelMicrophonePreflight: (() => void) | null = null;
428
+ private clientSideNoiseCancellationEnabled: boolean = true;
363
429
 
364
430
  private async microphoneAcquire(): Promise<MediaStreamTrack> {
365
431
  const source = this.dailyCallObject.audioSource;
@@ -535,6 +601,7 @@ export default class Vapi extends VapiEventEmitter {
535
601
  });
536
602
 
537
603
  this.started = true;
604
+ this.clientSideNoiseCancellationEnabled = options?.clientSideNoiseCancellationEnabled ?? true;
538
605
 
539
606
  let failureStage = 'microphone-preflight';
540
607
  try {
@@ -555,18 +622,23 @@ export default class Vapi extends VapiEventEmitter {
555
622
 
556
623
  const webCallStartTime = Date.now();
557
624
 
625
+ // An unset option stays undefined, so JSON.stringify drops the key and
626
+ // older servers never see it.
627
+ const createWebCallBody: CreateWebCallDTO = {
628
+ assistant: typeof assistant === 'string' ? undefined : assistant,
629
+ assistantId: typeof assistant === 'string' ? assistant : undefined,
630
+ assistantOverrides,
631
+ squad: typeof squad === 'string' ? undefined : squad,
632
+ squadId: typeof squad === 'string' ? squad : undefined,
633
+ workflow: typeof workflow === 'string' ? undefined : workflow,
634
+ workflowId: typeof workflow === 'string' ? workflow : undefined,
635
+ workflowOverrides,
636
+ roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
637
+ videoRecordingStartMode: options?.videoRecordingStartMode,
638
+ };
639
+
558
640
  const webCall = (
559
- await client.call.callControllerCreateWebCall({
560
- assistant: typeof assistant === 'string' ? undefined : assistant,
561
- assistantId: typeof assistant === 'string' ? assistant : undefined,
562
- assistantOverrides,
563
- squad: typeof squad === 'string' ? undefined : squad,
564
- squadId: typeof squad === 'string' ? squad : undefined,
565
- workflow: typeof workflow === 'string' ? undefined : workflow,
566
- workflowId: typeof workflow === 'string' ? workflow : undefined,
567
- workflowOverrides,
568
- roomDeleteOnUserLeaveEnabled: options?.roomDeleteOnUserLeaveEnabled,
569
- })
641
+ await client.call.callControllerCreateWebCall(createWebCallBody)
570
642
  ).data;
571
643
 
572
644
  const webCallDuration = Date.now() - webCallStartTime;
@@ -585,15 +657,17 @@ export default class Vapi extends VapiEventEmitter {
585
657
  const isVideoRecordingEnabled =
586
658
  webCall?.artifactPlan?.videoRecordingEnabled ?? false;
587
659
 
588
- const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
589
-
590
660
  // When video recording is enabled, the server sends a Daily meeting
591
- // token that auto-starts the cloud recording on join — more reliable
592
- // than calling startRecording() from a weak client network. Older
593
- // servers don't send one; then the client starts the recording itself.
594
- const callToken = (
595
- webCall?.transport as { callToken?: string } | undefined
596
- )?.callToken;
661
+ // token. In client mode it auto-starts the cloud recording on join. In
662
+ // server mode it starts nothing: Vapi starts the recording from its
663
+ // servers once the user has joined, which a weak client network can't
664
+ // stop. Without a token the SDK emits recording-error and starts
665
+ // nothing.
666
+ const transport = webCall?.transport as WebCall['transport'];
667
+ const callToken = transport?.callToken;
668
+ const isRecordingStartedByServer =
669
+ transport?.videoRecordingStartMode === 'server';
670
+ const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
597
671
 
598
672
  // Stage 2: Create Daily call object
599
673
  this.emit('call-start-progress', {
@@ -604,7 +678,6 @@ export default class Vapi extends VapiEventEmitter {
604
678
  audioSource: this.dailyCallObject.audioSource ?? true,
605
679
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
606
680
  isVideoRecordingEnabled,
607
- isVideoEnabled
608
681
  }
609
682
  });
610
683
 
@@ -714,12 +787,7 @@ export default class Vapi extends VapiEventEmitter {
714
787
 
715
788
  this.call.on('participant-joined', (e) => {
716
789
  if (!e || !this.call) return;
717
- subscribeToTracks(
718
- e,
719
- this.call,
720
- isVideoRecordingEnabled,
721
- isVideoEnabled,
722
- );
790
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled);
723
791
  });
724
792
 
725
793
  this.call.on('participant-updated', (e) => {
@@ -736,6 +804,11 @@ export default class Vapi extends VapiEventEmitter {
736
804
  this.detachAudioPlayer(e.participant.session_id);
737
805
  });
738
806
 
807
+ // Registered before join(), as in reconnect(): Vapi starts a server-mode
808
+ // recording when it sees the user join, so its start-failed message can
809
+ // arrive while join() is still in flight.
810
+ this.call.on('app-message', (e) => this.onAppMessage(e));
811
+
739
812
  // Registered before join(): a token-auto-started recording can begin
740
813
  // while join() is still in flight, so a listener added after join
741
814
  // could miss the event.
@@ -836,52 +909,28 @@ export default class Vapi extends VapiEventEmitter {
836
909
 
837
910
  // Stage 5: Video recording setup (if enabled)
838
911
  if (isVideoRecordingEnabled && callToken) {
839
- // The meeting token auto-started the recording at join, so there is
840
- // nothing to request from the client.
912
+ // The meeting token auto-started the recording at join, or Vapi
913
+ // starts it from its servers, so there is nothing to request from the
914
+ // client.
841
915
  this.emit('call-start-progress', {
842
916
  stage: 'video-recording-setup',
843
917
  status: 'completed',
844
918
  timestamp: new Date().toISOString(),
845
- metadata: { action: 'auto-started-via-meeting-token' }
919
+ metadata: {
920
+ action: isRecordingStartedByServer
921
+ ? 'started-by-server'
922
+ : 'auto-started-via-meeting-token'
923
+ }
846
924
  });
847
925
  } else if (isVideoRecordingEnabled) {
926
+ // No token: nothing starts the recording. The recording-error is
927
+ // reported once start() has finished.
848
928
  this.emit('call-start-progress', {
849
929
  stage: 'video-recording-setup',
850
- status: 'started',
851
- timestamp: new Date().toISOString()
930
+ status: 'failed',
931
+ timestamp: new Date().toISOString(),
932
+ metadata: { action: 'no-meeting-token' }
852
933
  });
853
-
854
- const recordingStartTime = Date.now();
855
-
856
- try {
857
- recordingRequestedTime = new Date().getTime();
858
- this.startRecording();
859
-
860
- const recordingSetupDuration = Date.now() - recordingStartTime;
861
- this.emit('call-start-progress', {
862
- stage: 'video-recording-setup',
863
- status: 'completed',
864
- duration: recordingSetupDuration,
865
- timestamp: new Date().toISOString()
866
- });
867
- } catch (error) {
868
- const recordingSetupDuration = Date.now() - recordingStartTime;
869
- const serializedError = serializeError(error);
870
- this.emit('call-start-progress', {
871
- stage: 'video-recording-setup',
872
- status: 'failed',
873
- duration: recordingSetupDuration,
874
- timestamp: new Date().toISOString(),
875
- metadata: { error: serializedError.message }
876
- });
877
- this.emit('error', {
878
- type: 'video-recording-setup-error',
879
- stage: 'video-recording-setup',
880
- error: serializedError,
881
- timestamp: new Date().toISOString()
882
- });
883
- // Don't throw here, video recording is optional
884
- }
885
934
  } else {
886
935
  this.emit('call-start-progress', {
887
936
  stage: 'video-recording-setup',
@@ -936,8 +985,6 @@ export default class Vapi extends VapiEventEmitter {
936
985
  if (e) this.handleLocalAudioLevel(e);
937
986
  });
938
987
 
939
- this.call.on('app-message', (e) => this.onAppMessage(e));
940
-
941
988
  this.call.on('nonfatal-error', (e) => {
942
989
  // https://docs.daily.co/reference/daily-js/events/meeting-events#type-audio-processor-error
943
990
  if (e?.type === 'local-audio-level-observer-error') {
@@ -961,63 +1008,69 @@ export default class Vapi extends VapiEventEmitter {
961
1008
  }
962
1009
  });
963
1010
 
964
- // Stage 7: Audio processing setup
965
- this.emit('call-start-progress', {
966
- stage: 'audio-processing-setup',
967
- status: 'started',
968
- timestamp: new Date().toISOString()
969
- });
1011
+ if (this.clientSideNoiseCancellationEnabled) {
1012
+ // Stage 7: Audio processing setup
1013
+ this.emit('call-start-progress', {
1014
+ stage: 'audio-processing-setup',
1015
+ status: 'started',
1016
+ timestamp: new Date().toISOString()
1017
+ });
970
1018
 
971
- const audioProcessingStartTime = Date.now();
1019
+ const audioProcessingStartTime = Date.now();
972
1020
 
973
- try {
974
- const audioProcessingUpdate = this.call.updateInputSettings({
975
- audio: {
976
- processor: {
977
- type: 'noise-cancellation',
1021
+ try {
1022
+ const audioProcessingUpdate = this.call.updateInputSettings({
1023
+ audio: {
1024
+ processor: {
1025
+ type: 'noise-cancellation',
1026
+ },
978
1027
  },
979
- },
980
- });
1028
+ });
981
1029
 
982
- audioProcessingUpdate.catch((error) => {
983
- this.emitAudioProcessingError('audio-processing-setup', error);
984
- });
1030
+ audioProcessingUpdate.catch((error) => {
1031
+ this.emitAudioProcessingError('audio-processing-setup', error);
1032
+ });
985
1033
 
986
- // The observer waits for this update rather than starting alongside the
987
- // remote one above, so that Krisp has already swapped the microphone
988
- // track. See maybeStartLocalAudioLevelObserver().
989
- //
990
- // A separate chain, not another link on the one above: EventEmitter
991
- // rethrows out of emit('error') when a consumer registered no 'error'
992
- // listener, and whether the observer starts must not hinge on that.
993
- audioProcessingUpdate
994
- .catch(() => {})
995
- .then(() => this.maybeStartLocalAudioLevelObserver());
996
-
997
- const audioProcessingDuration = Date.now() - audioProcessingStartTime;
998
- this.emit('call-start-progress', {
999
- stage: 'audio-processing-setup',
1000
- status: 'completed',
1001
- duration: audioProcessingDuration,
1002
- timestamp: new Date().toISOString()
1003
- });
1004
- } catch (error) {
1005
- const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1006
- const serializedError = serializeError(error);
1007
- this.emit('call-start-progress', {
1008
- stage: 'audio-processing-setup',
1009
- status: 'failed',
1010
- duration: audioProcessingDuration,
1011
- timestamp: new Date().toISOString(),
1012
- metadata: { error: serializedError.message }
1013
- });
1014
- this.emit('error', {
1015
- type: 'audio-processing-setup-error',
1016
- stage: 'audio-processing-setup',
1017
- error: serializedError,
1018
- timestamp: new Date().toISOString()
1019
- });
1020
- // Don't throw here, this is non-critical
1034
+ // The observer waits for this update rather than starting alongside the
1035
+ // remote one above, so that Krisp has already swapped the microphone
1036
+ // track. See maybeStartLocalAudioLevelObserver().
1037
+ //
1038
+ // A separate chain, not another link on the one above: EventEmitter
1039
+ // rethrows out of emit('error') when a consumer registered no 'error'
1040
+ // listener, and whether the observer starts must not hinge on that.
1041
+ audioProcessingUpdate
1042
+ .catch(() => {})
1043
+ .then(() => this.maybeStartLocalAudioLevelObserver());
1044
+
1045
+ const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1046
+ this.emit('call-start-progress', {
1047
+ stage: 'audio-processing-setup',
1048
+ status: 'completed',
1049
+ duration: audioProcessingDuration,
1050
+ timestamp: new Date().toISOString()
1051
+ });
1052
+ } catch (error) {
1053
+ const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1054
+ const serializedError = serializeError(error);
1055
+ this.emit('call-start-progress', {
1056
+ stage: 'audio-processing-setup',
1057
+ status: 'failed',
1058
+ duration: audioProcessingDuration,
1059
+ timestamp: new Date().toISOString(),
1060
+ metadata: { error: serializedError.message }
1061
+ });
1062
+ this.emit('error', {
1063
+ type: 'audio-processing-setup-error',
1064
+ stage: 'audio-processing-setup',
1065
+ error: serializedError,
1066
+ timestamp: new Date().toISOString()
1067
+ });
1068
+ // Don't throw here, this is non-critical
1069
+ }
1070
+ } else {
1071
+ // Daily's default processor is 'none', so the microphone track is never
1072
+ // swapped and the observer has nothing to wait for.
1073
+ void this.maybeStartLocalAudioLevelObserver();
1021
1074
  }
1022
1075
 
1023
1076
  const totalDuration = Date.now() - startTime;
@@ -1027,6 +1080,10 @@ export default class Vapi extends VapiEventEmitter {
1027
1080
  timestamp: new Date().toISOString()
1028
1081
  });
1029
1082
 
1083
+ if (recordingTokenMissing) {
1084
+ this.recordingTokenMissingReportSchedule();
1085
+ }
1086
+
1030
1087
  return webCall;
1031
1088
  } catch (e) {
1032
1089
  const totalDuration = Date.now() - startTime;
@@ -1078,6 +1135,12 @@ export default class Vapi extends VapiEventEmitter {
1078
1135
  } else {
1079
1136
  try {
1080
1137
  const parsedMessage = JSON.parse(e.data);
1138
+ // Not a Vapi client message: re-emitted as recording-error, and kept
1139
+ // out of 'message' because its consumers switch on `type`.
1140
+ if (parsedMessage?.type === VIDEO_RECORDING_START_FAILED_MESSAGE_TYPE) {
1141
+ this.videoRecordingStartFailedReport(parsedMessage);
1142
+ return;
1143
+ }
1081
1144
  this.emit('message', parsedMessage);
1082
1145
  if (parsedMessage && 'type' in parsedMessage && 'status' in parsedMessage && parsedMessage.type === 'status-update' && parsedMessage.status === 'ended') {
1083
1146
  this.hasEmittedCallEndedStatus = true;
@@ -1276,6 +1339,41 @@ export default class Vapi extends VapiEventEmitter {
1276
1339
  }
1277
1340
  }
1278
1341
 
1342
+ /**
1343
+ * Reported in a later task, like Daily's own recording-error: start() or
1344
+ * reconnect() has resolved and the caller's await continuation has run, so a
1345
+ * listener registered right after it still hears this, and one that calls
1346
+ * stop() can't interrupt call setup. Skipped if that call was stopped or
1347
+ * replaced.
1348
+ */
1349
+ private recordingTokenMissingReportSchedule() {
1350
+ const call = this.call;
1351
+ if (!call) return;
1352
+ setTimeout(() => {
1353
+ if (!this.started || this.call !== call) return;
1354
+ this.emitToConsumer('recording-error', {
1355
+ action: 'recording-error',
1356
+ callClientId: call.callClientId,
1357
+ errorMsg:
1358
+ 'Vapi returned no recording token, so the recording was not started. Call startRecording() to try starting it from the client.',
1359
+ });
1360
+ }, 0);
1361
+ }
1362
+
1363
+ /** The fields are read defensively: the app message is untrusted JSON. */
1364
+ private videoRecordingStartFailedReport(
1365
+ message: Partial<VideoRecordingStartFailedMessage>,
1366
+ ) {
1367
+ const outcome =
1368
+ typeof message.outcome === 'string' ? message.outcome : 'unknown';
1369
+ const error = typeof message.error === 'string' ? message.error : 'unknown';
1370
+ this.emitToConsumer('recording-error', {
1371
+ action: 'recording-error',
1372
+ callClientId: this.call?.callClientId ?? '',
1373
+ errorMsg: `Vapi couldn't start the recording (${outcome}): ${error}`,
1374
+ });
1375
+ }
1376
+
1279
1377
  /** Drops the reference before removing, so nothing hands out a detached element. */
1280
1378
  private detachAudioPlayer(participantId: string) {
1281
1379
  if (this.audioPlayer?.dataset.participantId === participantId) {
@@ -1498,11 +1596,11 @@ export default class Vapi extends VapiEventEmitter {
1498
1596
 
1499
1597
  /**
1500
1598
  * Reconnects to an active call.
1501
- *
1502
- *
1503
- * @param webCall
1599
+ *
1600
+ * @param webCall
1601
+ * @param options
1504
1602
  */
1505
- async reconnect(webCall: WebCall): Promise<void> {
1603
+ async reconnect(webCall: WebCall, options?: ReconnectOptions): Promise<void> {
1506
1604
  const startTime = Date.now();
1507
1605
 
1508
1606
  if (this.started) {
@@ -1513,6 +1611,10 @@ export default class Vapi extends VapiEventEmitter {
1513
1611
  throw new Error('webCallUrl is required for reconnection.');
1514
1612
  }
1515
1613
 
1614
+ if (options?.clientSideNoiseCancellationEnabled !== undefined) {
1615
+ this.clientSideNoiseCancellationEnabled = options.clientSideNoiseCancellationEnabled;
1616
+ }
1617
+
1516
1618
  this.emit('call-start-progress', {
1517
1619
  stage: 'reconnect-initialization',
1518
1620
  status: 'started',
@@ -1543,12 +1645,16 @@ export default class Vapi extends VapiEventEmitter {
1543
1645
  }
1544
1646
 
1545
1647
  const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false;
1546
- const isVideoEnabled = webCall?.assistant?.voice?.provider === 'tavus';
1547
1648
 
1548
- // Same auto-start token as in start(). Rejoining with it while the
1649
+ // Same token as in start(). In client mode, rejoining with it while the
1549
1650
  // recording is still running is safe; if the recording stopped when the
1550
- // user left, the token starts a new one.
1651
+ // user left, the token starts a new one. In server mode it starts
1652
+ // nothing. Without a token the SDK emits recording-error and starts
1653
+ // nothing.
1551
1654
  const callToken = webCall?.transport?.callToken;
1655
+ const isRecordingStartedByServer =
1656
+ webCall?.transport?.videoRecordingStartMode === 'server';
1657
+ const recordingTokenMissing = isVideoRecordingEnabled && !callToken;
1552
1658
 
1553
1659
  // Stage 1: Create Daily call object
1554
1660
  this.emit('call-start-progress', {
@@ -1559,7 +1665,6 @@ export default class Vapi extends VapiEventEmitter {
1559
1665
  audioSource: this.dailyCallObject.audioSource ?? true,
1560
1666
  videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
1561
1667
  isVideoRecordingEnabled,
1562
- isVideoEnabled
1563
1668
  }
1564
1669
  });
1565
1670
 
@@ -1650,12 +1755,7 @@ export default class Vapi extends VapiEventEmitter {
1650
1755
 
1651
1756
  this.call.on('participant-joined', (e) => {
1652
1757
  if (!e || !this.call) return;
1653
- subscribeToTracks(
1654
- e,
1655
- this.call,
1656
- isVideoRecordingEnabled,
1657
- isVideoEnabled,
1658
- );
1758
+ subscribeToTracks(e, this.call, isVideoRecordingEnabled);
1659
1759
  });
1660
1760
 
1661
1761
  this.call.on('participant-updated', (e) => {
@@ -1783,46 +1883,28 @@ export default class Vapi extends VapiEventEmitter {
1783
1883
 
1784
1884
  // Stage 4: Video recording setup (if enabled)
1785
1885
  if (isVideoRecordingEnabled && callToken) {
1786
- // The meeting token auto-started the recording at join, so there is
1787
- // nothing to request from the client.
1886
+ // The meeting token auto-started the recording at join, or Vapi
1887
+ // starts it from its servers, so there is nothing to request from the
1888
+ // client.
1788
1889
  this.emit('call-start-progress', {
1789
1890
  stage: 'video-recording-setup',
1790
1891
  status: 'completed',
1791
1892
  timestamp: new Date().toISOString(),
1792
- metadata: { action: 'auto-started-via-meeting-token' }
1893
+ metadata: {
1894
+ action: isRecordingStartedByServer
1895
+ ? 'started-by-server'
1896
+ : 'auto-started-via-meeting-token'
1897
+ }
1793
1898
  });
1794
1899
  } else if (isVideoRecordingEnabled) {
1900
+ // No token: nothing starts the recording. The recording-error is
1901
+ // reported once reconnect() has finished.
1795
1902
  this.emit('call-start-progress', {
1796
1903
  stage: 'video-recording-setup',
1797
- status: 'started',
1798
- timestamp: new Date().toISOString()
1904
+ status: 'failed',
1905
+ timestamp: new Date().toISOString(),
1906
+ metadata: { action: 'no-meeting-token' }
1799
1907
  });
1800
-
1801
- const recordingStartTime = Date.now();
1802
-
1803
- try {
1804
- recordingRequestedTime = new Date().getTime();
1805
- this.startRecording();
1806
-
1807
- const recordingSetupDuration = Date.now() - recordingStartTime;
1808
- this.emit('call-start-progress', {
1809
- stage: 'video-recording-setup',
1810
- status: 'completed',
1811
- duration: recordingSetupDuration,
1812
- timestamp: new Date().toISOString()
1813
- });
1814
- } catch (error) {
1815
- const recordingSetupDuration = Date.now() - recordingStartTime;
1816
- const serializedError = serializeError(error);
1817
- this.emit('call-start-progress', {
1818
- stage: 'video-recording-setup',
1819
- status: 'failed',
1820
- duration: recordingSetupDuration,
1821
- timestamp: new Date().toISOString(),
1822
- metadata: { error: serializedError.message }
1823
- });
1824
- // Don't throw here, video recording is optional
1825
- }
1826
1908
  } else {
1827
1909
  this.emit('call-start-progress', {
1828
1910
  stage: 'video-recording-setup',
@@ -1863,57 +1945,63 @@ export default class Vapi extends VapiEventEmitter {
1863
1945
  // Don't throw here, this is non-critical
1864
1946
  }
1865
1947
 
1866
- // Stage 6: Audio processing setup
1867
- this.emit('call-start-progress', {
1868
- stage: 'audio-processing-setup',
1869
- status: 'started',
1870
- timestamp: new Date().toISOString()
1871
- });
1948
+ if (this.clientSideNoiseCancellationEnabled) {
1949
+ // Stage 6: Audio processing setup
1950
+ this.emit('call-start-progress', {
1951
+ stage: 'audio-processing-setup',
1952
+ status: 'started',
1953
+ timestamp: new Date().toISOString()
1954
+ });
1872
1955
 
1873
- const audioProcessingStartTime = Date.now();
1956
+ const audioProcessingStartTime = Date.now();
1874
1957
 
1875
- try {
1876
- const audioProcessingUpdate = this.call.updateInputSettings({
1877
- audio: {
1878
- processor: {
1879
- type: 'noise-cancellation',
1958
+ try {
1959
+ const audioProcessingUpdate = this.call.updateInputSettings({
1960
+ audio: {
1961
+ processor: {
1962
+ type: 'noise-cancellation',
1963
+ },
1880
1964
  },
1881
- },
1882
- });
1965
+ });
1883
1966
 
1884
- audioProcessingUpdate.catch((error) => {
1885
- this.emitAudioProcessingError('audio-processing-setup', error);
1886
- });
1967
+ audioProcessingUpdate.catch((error) => {
1968
+ this.emitAudioProcessingError('audio-processing-setup', error);
1969
+ });
1887
1970
 
1888
- // The observer waits for this update rather than starting alongside the
1889
- // remote one above, so that Krisp has already swapped the microphone
1890
- // track. See maybeStartLocalAudioLevelObserver().
1891
- //
1892
- // A separate chain, not another link on the one above: EventEmitter
1893
- // rethrows out of emit('error') when a consumer registered no 'error'
1894
- // listener, and whether the observer starts must not hinge on that.
1895
- audioProcessingUpdate
1896
- .catch(() => {})
1897
- .then(() => this.maybeStartLocalAudioLevelObserver());
1898
-
1899
- const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1900
- this.emit('call-start-progress', {
1901
- stage: 'audio-processing-setup',
1902
- status: 'completed',
1903
- duration: audioProcessingDuration,
1904
- timestamp: new Date().toISOString()
1905
- });
1906
- } catch (error) {
1907
- const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1908
- const serializedError = serializeError(error);
1909
- this.emit('call-start-progress', {
1910
- stage: 'audio-processing-setup',
1911
- status: 'failed',
1912
- duration: audioProcessingDuration,
1913
- timestamp: new Date().toISOString(),
1914
- metadata: { error: serializedError.message }
1915
- });
1916
- // Don't throw here, this is non-critical
1971
+ // The observer waits for this update rather than starting alongside the
1972
+ // remote one above, so that Krisp has already swapped the microphone
1973
+ // track. See maybeStartLocalAudioLevelObserver().
1974
+ //
1975
+ // A separate chain, not another link on the one above: EventEmitter
1976
+ // rethrows out of emit('error') when a consumer registered no 'error'
1977
+ // listener, and whether the observer starts must not hinge on that.
1978
+ audioProcessingUpdate
1979
+ .catch(() => {})
1980
+ .then(() => this.maybeStartLocalAudioLevelObserver());
1981
+
1982
+ const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1983
+ this.emit('call-start-progress', {
1984
+ stage: 'audio-processing-setup',
1985
+ status: 'completed',
1986
+ duration: audioProcessingDuration,
1987
+ timestamp: new Date().toISOString()
1988
+ });
1989
+ } catch (error) {
1990
+ const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1991
+ const serializedError = serializeError(error);
1992
+ this.emit('call-start-progress', {
1993
+ stage: 'audio-processing-setup',
1994
+ status: 'failed',
1995
+ duration: audioProcessingDuration,
1996
+ timestamp: new Date().toISOString(),
1997
+ metadata: { error: serializedError.message }
1998
+ });
1999
+ // Don't throw here, this is non-critical
2000
+ }
2001
+ } else {
2002
+ // Daily's default processor is 'none', so the microphone track is never
2003
+ // swapped and the observer has nothing to wait for.
2004
+ void this.maybeStartLocalAudioLevelObserver();
1917
2005
  }
1918
2006
 
1919
2007
  const totalDuration = Date.now() - startTime;
@@ -1925,6 +2013,10 @@ export default class Vapi extends VapiEventEmitter {
1925
2013
 
1926
2014
  this.emit('call-start');
1927
2015
 
2016
+ if (recordingTokenMissing) {
2017
+ this.recordingTokenMissingReportSchedule();
2018
+ }
2019
+
1928
2020
  } catch (e) {
1929
2021
  const totalDuration = Date.now() - startTime;
1930
2022
  const serializedError = serializeError(e);