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