@vapi-ai/web 2.6.2 → 2.6.3

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 CHANGED
@@ -105,6 +105,47 @@ The `say(message: string, endCallAfterSpoken?: boolean)` can be used to invoke s
105
105
  vapi.say("Our time's up, goodbye!", true)
106
106
  ```
107
107
 
108
+ The `setVolume(volume: number)` controls how loudly the assistant plays, from
109
+ `0` to `1`. This is playback only: it does not change what the assistant hears,
110
+ and it does not affect the levels reported by the `volume-level` event.
111
+
112
+ ```javascript
113
+ vapi.setVolume(0.5);
114
+ ```
115
+
116
+ The setting is remembered, so you can call it before a call has started and it
117
+ will be applied as soon as the assistant's audio arrives. It also persists
118
+ across calls, so a volume your user picked once is not reset by hanging up.
119
+ Values outside `0` to `1` are clamped, and non-finite values are ignored.
120
+
121
+ The `getAudioPlayer()` returns the `<audio>` element the SDK uses to play the
122
+ assistant, or `null` before its track has arrived. Use it for anything the
123
+ setter doesn't cover, such as driving a waveform visualizer.
124
+
125
+ ```javascript
126
+ const player = vapi.getAudioPlayer();
127
+ ```
128
+
129
+ Pair it with the `audio` event when you need the element the moment it exists:
130
+
131
+ ```javascript
132
+ vapi.on('audio', (player) => {
133
+ // e.g. new AudioContext().createMediaElementSource(player)
134
+ });
135
+ ```
136
+
137
+ If the browser blocks playback, usually its autoplay policy when the call was
138
+ not started from a user gesture, no player is attached and an `error` is
139
+ emitted instead. Listen for it to prompt the user:
140
+
141
+ ```javascript
142
+ vapi.on('error', (e) => {
143
+ if (e.type === 'audio-start-failed') {
144
+ // show a "tap to enable audio" affordance
145
+ }
146
+ });
147
+ ```
148
+
108
149
  ## Events
109
150
 
110
151
  You can listen to the following events:
@@ -130,6 +171,11 @@ vapi.on('volume-level', (volume) => {
130
171
  console.log(`Assistant volume level: ${volume}`);
131
172
  });
132
173
 
174
+ // The <audio> element playing the assistant, as soon as it exists
175
+ vapi.on('audio', (player) => {
176
+ console.log(`Assistant audio attached at volume ${player.volume}`);
177
+ });
178
+
133
179
  // Function calls and transcripts will be sent via messages
134
180
  vapi.on('message', (message) => {
135
181
  console.log(message);
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;
@@ -119,6 +120,11 @@ declare class VapiEventEmitter extends EventEmitter {
119
120
  export default class Vapi extends VapiEventEmitter {
120
121
  private started;
121
122
  private call;
123
+ private audioPlayer;
124
+ private desiredVolume;
125
+ private audioBuildSequence;
126
+ private pendingAudioBuilds;
127
+ private attachedAudioBuild;
122
128
  private speakingTimeout;
123
129
  private dailyCallConfig;
124
130
  private dailyCallObject;
@@ -155,6 +161,56 @@ export default class Vapi extends VapiEventEmitter {
155
161
  * Docs: https://docs.vapi.ai/calls/call-features
156
162
  */
157
163
  send(message: VapiClientToServerMessage): void;
164
+ /**
165
+ * The two places this class touches the DOM, behind indirections so the
166
+ * lifecycle logic around them is testable without a browser.
167
+ */
168
+ private audioPlayerBuild;
169
+ private audioPlayerDestroy;
170
+ /**
171
+ * The remembered volume, bounded, or null if there is nothing to apply.
172
+ * Shared by both callers so they cannot disagree: the DOM throws on a
173
+ * non-finite or out-of-range volume, and that throw would stall the call.
174
+ */
175
+ private desiredVolumeResolve;
176
+ /** Shared by the initial and reconnect paths so the two cannot drift. */
177
+ private handleTrackStarted;
178
+ /**
179
+ * Assigns before emitting, since emit is synchronous and a listener may call
180
+ * getAudioPlayer or setVolume during the event.
181
+ */
182
+ private attachAudioPlayer;
183
+ /**
184
+ * A listener that throws must not take down the caller: track-started still
185
+ * has to signal that playback is ready. Not routed to the `error` event,
186
+ * which EventEmitter throws on when nothing is listening.
187
+ */
188
+ private emitToConsumer;
189
+ /** Drops the reference before removing, so nothing hands out a detached element. */
190
+ private detachAudioPlayer;
191
+ /**
192
+ * The <audio> element playing the assistant, or null before its track has
193
+ * arrived. Pair with the `audio` event when you need it the moment it exists.
194
+ */
195
+ getAudioPlayer(): HTMLAudioElement | null;
196
+ /**
197
+ * Sets assistant playback volume, 0 to 1.
198
+ *
199
+ * The setting is remembered, so it can be applied before the assistant's
200
+ * audio track has arrived and it survives across calls and mid-call track
201
+ * replacement. Callers do not have to race the start of the call.
202
+ *
203
+ * Values outside 0 to 1 are clamped and non-finite values are ignored, since
204
+ * the DOM rejects both rather than saturating.
205
+ *
206
+ * Only volume set through this method is remembered. Writing `volume`
207
+ * directly on the element from getAudioPlayer() or the `audio` event applies
208
+ * to that element alone, and is overwritten the next time one is attached.
209
+ *
210
+ * This is playback only. It does not change what the assistant hears, and it
211
+ * does not affect the levels reported by the `volume-level` event.
212
+ */
213
+ setVolume(volume: number): void;
158
214
  setMuted(mute: boolean): void;
159
215
  isMuted(): boolean;
160
216
  say(message: string, endCallAfterSpoken?: boolean, interruptionsEnabled?: boolean, interruptAssistantEnabled?: boolean): void;
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() {
@@ -338,24 +360,7 @@ class Vapi extends VapiEventEmitter {
338
360
  if (event)
339
361
  this.emit('recording-upload-completed', event);
340
362
  });
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
- });
363
+ this.call.on('track-started', (e) => this.handleTrackStarted(e));
359
364
  this.call.on('participant-joined', (e) => {
360
365
  if (!e || !this.call)
361
366
  return;
@@ -371,7 +376,7 @@ class Vapi extends VapiEventEmitter {
371
376
  if (!e) {
372
377
  return;
373
378
  }
374
- destroyAudioPlayer(e.participant.session_id);
379
+ this.detachAudioPlayer(e.participant.session_id);
375
380
  });
376
381
  // Stage 3: Mobile device handling and permissions
377
382
  const isMobile = this.isMobileDevice();
@@ -721,6 +726,8 @@ class Vapi extends VapiEventEmitter {
721
726
  await this.call.destroy();
722
727
  this.call = null;
723
728
  }
729
+ this.audioPlayer = null;
730
+ this.pendingAudioBuilds.clear();
724
731
  this.speakingTimeout = null;
725
732
  }
726
733
  /**
@@ -731,6 +738,155 @@ class Vapi extends VapiEventEmitter {
731
738
  send(message) {
732
739
  this.call?.sendAppMessage(JSON.stringify(message));
733
740
  }
741
+ /**
742
+ * The two places this class touches the DOM, behind indirections so the
743
+ * lifecycle logic around them is testable without a browser.
744
+ */
745
+ audioPlayerBuild(track, participantId, volume) {
746
+ return buildAudioPlayer(track, participantId, volume);
747
+ }
748
+ audioPlayerDestroy(participantId) {
749
+ destroyAudioPlayer(participantId);
750
+ }
751
+ /**
752
+ * The remembered volume, bounded, or null if there is nothing to apply.
753
+ * Shared by both callers so they cannot disagree: the DOM throws on a
754
+ * non-finite or out-of-range volume, and that throw would stall the call.
755
+ */
756
+ desiredVolumeResolve() {
757
+ if (this.desiredVolume === null || !Number.isFinite(this.desiredVolume)) {
758
+ return null;
759
+ }
760
+ return Math.min(1, Math.max(0, this.desiredVolume));
761
+ }
762
+ /** Shared by the initial and reconnect paths so the two cannot drift. */
763
+ async handleTrackStarted(e) {
764
+ if (!e || !e.participant) {
765
+ return;
766
+ }
767
+ if (e.participant?.local) {
768
+ return;
769
+ }
770
+ if (e.participant?.user_name !== 'Vapi Speaker') {
771
+ return;
772
+ }
773
+ if (e.track.kind === 'video') {
774
+ this.emitToConsumer('video', e.track);
775
+ }
776
+ if (e.track.kind === 'audio') {
777
+ // play() pends for the whole of media startup, so the call can end or
778
+ // the participant can leave while the player is still being built.
779
+ const call = this.call;
780
+ const build = ++this.audioBuildSequence;
781
+ this.pendingAudioBuilds.set(build, e.participant.session_id);
782
+ let player;
783
+ try {
784
+ player = await this.audioPlayerBuild(e.track, e.participant.session_id, this.desiredVolumeResolve());
785
+ }
786
+ catch (error) {
787
+ // Usually autoplay policy, which is recoverable, so consumers get a
788
+ // signal to prompt for a gesture. Safe with no 'error' listener:
789
+ // emitToConsumer catches EventEmitter's unlistened-'error' throw.
790
+ this.pendingAudioBuilds.delete(build);
791
+ console.error('[vapi] could not start assistant audio', error);
792
+ this.emitToConsumer('error', {
793
+ type: 'audio-start-failed',
794
+ error: serializeError(error),
795
+ });
796
+ return;
797
+ }
798
+ const stillPending = this.pendingAudioBuilds.delete(build);
799
+ if (!call ||
800
+ this.call !== call ||
801
+ !stillPending ||
802
+ build < this.attachedAudioBuild) {
803
+ player.remove();
804
+ return;
805
+ }
806
+ this.attachedAudioBuild = build;
807
+ this.attachAudioPlayer(player);
808
+ }
809
+ this.call?.sendAppMessage('playable');
810
+ }
811
+ /**
812
+ * Assigns before emitting, since emit is synchronous and a listener may call
813
+ * getAudioPlayer or setVolume during the event.
814
+ */
815
+ attachAudioPlayer(player) {
816
+ // Renegotiation reuses the participant id, so a superseded element would
817
+ // still match the selector teardown uses and be removed in its place.
818
+ if (this.audioPlayer && this.audioPlayer !== player) {
819
+ this.audioPlayer.remove();
820
+ }
821
+ // Reapplied in case the volume changed while the player was building.
822
+ const volume = this.desiredVolumeResolve();
823
+ if (volume !== null) {
824
+ player.volume = volume;
825
+ }
826
+ this.audioPlayer = player;
827
+ this.emitToConsumer('audio', player);
828
+ }
829
+ /**
830
+ * A listener that throws must not take down the caller: track-started still
831
+ * has to signal that playback is ready. Not routed to the `error` event,
832
+ * which EventEmitter throws on when nothing is listening.
833
+ */
834
+ emitToConsumer(event, ...args) {
835
+ try {
836
+ this.emit(event, ...args);
837
+ }
838
+ catch (error) {
839
+ console.error(`[vapi] ${event} event listener threw`, error);
840
+ }
841
+ }
842
+ /** Drops the reference before removing, so nothing hands out a detached element. */
843
+ detachAudioPlayer(participantId) {
844
+ if (this.audioPlayer?.dataset.participantId === participantId) {
845
+ this.audioPlayer = null;
846
+ }
847
+ // Scoped to this participant: the call check cannot catch these, since the
848
+ // call itself has not changed, and an unrelated leave must not invalidate
849
+ // the assistant's build.
850
+ for (const [build, id] of this.pendingAudioBuilds) {
851
+ if (id === participantId) {
852
+ this.pendingAudioBuilds.delete(build);
853
+ }
854
+ }
855
+ this.audioPlayerDestroy(participantId);
856
+ }
857
+ /**
858
+ * The <audio> element playing the assistant, or null before its track has
859
+ * arrived. Pair with the `audio` event when you need it the moment it exists.
860
+ */
861
+ getAudioPlayer() {
862
+ return this.audioPlayer;
863
+ }
864
+ /**
865
+ * Sets assistant playback volume, 0 to 1.
866
+ *
867
+ * The setting is remembered, so it can be applied before the assistant's
868
+ * audio track has arrived and it survives across calls and mid-call track
869
+ * replacement. Callers do not have to race the start of the call.
870
+ *
871
+ * Values outside 0 to 1 are clamped and non-finite values are ignored, since
872
+ * the DOM rejects both rather than saturating.
873
+ *
874
+ * Only volume set through this method is remembered. Writing `volume`
875
+ * directly on the element from getAudioPlayer() or the `audio` event applies
876
+ * to that element alone, and is overwritten the next time one is attached.
877
+ *
878
+ * This is playback only. It does not change what the assistant hears, and it
879
+ * does not affect the levels reported by the `volume-level` event.
880
+ */
881
+ setVolume(volume) {
882
+ if (!Number.isFinite(volume)) {
883
+ return;
884
+ }
885
+ this.desiredVolume = Math.min(1, Math.max(0, volume));
886
+ if (this.audioPlayer) {
887
+ this.audioPlayer.volume = this.desiredVolume;
888
+ }
889
+ }
734
890
  setMuted(mute) {
735
891
  (0, daily_guards_1.safeSetLocalAudio)(this.call, !mute);
736
892
  }
@@ -972,24 +1128,7 @@ class Vapi extends VapiEventEmitter {
972
1128
  if (event)
973
1129
  this.emit('recording-upload-completed', event);
974
1130
  });
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
- });
1131
+ this.call.on('track-started', (e) => this.handleTrackStarted(e));
993
1132
  this.call.on('participant-joined', (e) => {
994
1133
  if (!e || !this.call)
995
1134
  return;
@@ -1005,7 +1144,7 @@ class Vapi extends VapiEventEmitter {
1005
1144
  if (!e) {
1006
1145
  return;
1007
1146
  }
1008
- destroyAudioPlayer(e.participant.session_id);
1147
+ this.detachAudioPlayer(e.participant.session_id);
1009
1148
  });
1010
1149
  this.call.on('remote-participants-audio-level', (e) => {
1011
1150
  if (e)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vapi-ai/web",
3
- "version": "2.6.2",
3
+ "version": "2.6.3",
4
4
  "description": "",
5
5
  "main": "dist/vapi.js",
6
6
  "types": "dist/vapi.d.ts",
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;
@@ -254,11 +257,22 @@ async function startAudioPlayer(
254
257
  async function buildAudioPlayer(
255
258
  track: MediaStreamTrack,
256
259
  participantId: string,
260
+ volume?: number | null,
257
261
  ) {
258
262
  const player = document.createElement('audio');
259
263
  player.dataset.participantId = participantId;
264
+ // Before play(), so an already-flowing track never plays at full volume first.
265
+ if (typeof volume === 'number') {
266
+ player.volume = volume;
267
+ }
260
268
  document.body.appendChild(player);
261
- await startAudioPlayer(player, track);
269
+ try {
270
+ await startAudioPlayer(player, track);
271
+ } catch (error) {
272
+ // Appended before playback was attempted, so a failure would strand it.
273
+ player.remove();
274
+ throw error;
275
+ }
262
276
  return player;
263
277
  }
264
278
 
@@ -322,6 +336,15 @@ class VapiEventEmitter extends EventEmitter {
322
336
  export default class Vapi extends VapiEventEmitter {
323
337
  private started: boolean = false;
324
338
  private call: DailyCall | null = null;
339
+ private audioPlayer: HTMLAudioElement | null = null;
340
+ // Survives teardown so the caller's choice persists across calls.
341
+ private desiredVolume: number | null = null;
342
+ // Builds are tracked individually, not by one counter: several can be in
343
+ // flight, they finish out of order, and a teardown must invalidate only the
344
+ // builds belonging to the participant that left.
345
+ private audioBuildSequence = 0;
346
+ private pendingAudioBuilds = new Map<number, string>();
347
+ private attachedAudioBuild = 0;
325
348
  private speakingTimeout: NodeJS.Timeout | null = null;
326
349
  private dailyCallConfig: DailyAdvancedConfig = {};
327
350
  private dailyCallObject: DailyFactoryOptions = {};
@@ -351,6 +374,8 @@ export default class Vapi extends VapiEventEmitter {
351
374
  await this.call.destroy();
352
375
  this.call = null;
353
376
  }
377
+ this.audioPlayer = null;
378
+ this.pendingAudioBuilds.clear();
354
379
  this.speakingTimeout = null;
355
380
  }
356
381
 
@@ -600,24 +625,7 @@ export default class Vapi extends VapiEventEmitter {
600
625
  if (event) this.emit('recording-upload-completed', event);
601
626
  });
602
627
 
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
- });
628
+ this.call.on('track-started', (e) => this.handleTrackStarted(e));
621
629
 
622
630
  this.call.on('participant-joined', (e) => {
623
631
  if (!e || !this.call) return;
@@ -640,7 +648,7 @@ export default class Vapi extends VapiEventEmitter {
640
648
  if (!e) {
641
649
  return;
642
650
  }
643
- destroyAudioPlayer(e.participant.session_id);
651
+ this.detachAudioPlayer(e.participant.session_id);
644
652
  });
645
653
 
646
654
  // Stage 3: Mobile device handling and permissions
@@ -1019,6 +1027,8 @@ export default class Vapi extends VapiEventEmitter {
1019
1027
  await this.call.destroy();
1020
1028
  this.call = null;
1021
1029
  }
1030
+ this.audioPlayer = null;
1031
+ this.pendingAudioBuilds.clear();
1022
1032
  this.speakingTimeout = null;
1023
1033
  }
1024
1034
 
@@ -1031,6 +1041,177 @@ export default class Vapi extends VapiEventEmitter {
1031
1041
  this.call?.sendAppMessage(JSON.stringify(message));
1032
1042
  }
1033
1043
 
1044
+ /**
1045
+ * The two places this class touches the DOM, behind indirections so the
1046
+ * lifecycle logic around them is testable without a browser.
1047
+ */
1048
+ private audioPlayerBuild(
1049
+ track: MediaStreamTrack,
1050
+ participantId: string,
1051
+ volume?: number | null,
1052
+ ): Promise<HTMLAudioElement> {
1053
+ return buildAudioPlayer(track, participantId, volume);
1054
+ }
1055
+
1056
+ private audioPlayerDestroy(participantId: string): void {
1057
+ destroyAudioPlayer(participantId);
1058
+ }
1059
+
1060
+ /**
1061
+ * The remembered volume, bounded, or null if there is nothing to apply.
1062
+ * Shared by both callers so they cannot disagree: the DOM throws on a
1063
+ * non-finite or out-of-range volume, and that throw would stall the call.
1064
+ */
1065
+ private desiredVolumeResolve(): number | null {
1066
+ if (this.desiredVolume === null || !Number.isFinite(this.desiredVolume)) {
1067
+ return null;
1068
+ }
1069
+ return Math.min(1, Math.max(0, this.desiredVolume));
1070
+ }
1071
+
1072
+ /** Shared by the initial and reconnect paths so the two cannot drift. */
1073
+ private async handleTrackStarted(e?: DailyEventObjectTrack) {
1074
+ if (!e || !e.participant) {
1075
+ return;
1076
+ }
1077
+ if (e.participant?.local) {
1078
+ return;
1079
+ }
1080
+ if (e.participant?.user_name !== 'Vapi Speaker') {
1081
+ return;
1082
+ }
1083
+ if (e.track.kind === 'video') {
1084
+ this.emitToConsumer('video', e.track);
1085
+ }
1086
+ if (e.track.kind === 'audio') {
1087
+ // play() pends for the whole of media startup, so the call can end or
1088
+ // the participant can leave while the player is still being built.
1089
+ const call = this.call;
1090
+ const build = ++this.audioBuildSequence;
1091
+ this.pendingAudioBuilds.set(build, e.participant.session_id);
1092
+
1093
+ let player: HTMLAudioElement;
1094
+ try {
1095
+ player = await this.audioPlayerBuild(
1096
+ e.track,
1097
+ e.participant.session_id,
1098
+ this.desiredVolumeResolve(),
1099
+ );
1100
+ } catch (error) {
1101
+ // Usually autoplay policy, which is recoverable, so consumers get a
1102
+ // signal to prompt for a gesture. Safe with no 'error' listener:
1103
+ // emitToConsumer catches EventEmitter's unlistened-'error' throw.
1104
+ this.pendingAudioBuilds.delete(build);
1105
+ console.error('[vapi] could not start assistant audio', error);
1106
+ this.emitToConsumer('error', {
1107
+ type: 'audio-start-failed',
1108
+ error: serializeError(error),
1109
+ });
1110
+ return;
1111
+ }
1112
+
1113
+ const stillPending = this.pendingAudioBuilds.delete(build);
1114
+ if (
1115
+ !call ||
1116
+ this.call !== call ||
1117
+ !stillPending ||
1118
+ build < this.attachedAudioBuild
1119
+ ) {
1120
+ player.remove();
1121
+ return;
1122
+ }
1123
+ this.attachedAudioBuild = build;
1124
+ this.attachAudioPlayer(player);
1125
+ }
1126
+ this.call?.sendAppMessage('playable');
1127
+ }
1128
+
1129
+ /**
1130
+ * Assigns before emitting, since emit is synchronous and a listener may call
1131
+ * getAudioPlayer or setVolume during the event.
1132
+ */
1133
+ private attachAudioPlayer(player: HTMLAudioElement) {
1134
+ // Renegotiation reuses the participant id, so a superseded element would
1135
+ // still match the selector teardown uses and be removed in its place.
1136
+ if (this.audioPlayer && this.audioPlayer !== player) {
1137
+ this.audioPlayer.remove();
1138
+ }
1139
+ // Reapplied in case the volume changed while the player was building.
1140
+ const volume = this.desiredVolumeResolve();
1141
+ if (volume !== null) {
1142
+ player.volume = volume;
1143
+ }
1144
+ this.audioPlayer = player;
1145
+ this.emitToConsumer('audio', player);
1146
+ }
1147
+
1148
+ /**
1149
+ * A listener that throws must not take down the caller: track-started still
1150
+ * has to signal that playback is ready. Not routed to the `error` event,
1151
+ * which EventEmitter throws on when nothing is listening.
1152
+ */
1153
+ private emitToConsumer<E extends VapiEventNames>(
1154
+ event: E,
1155
+ ...args: Parameters<VapiEventListeners[E]>
1156
+ ) {
1157
+ try {
1158
+ this.emit(event, ...args);
1159
+ } catch (error) {
1160
+ console.error(`[vapi] ${event} event listener threw`, error);
1161
+ }
1162
+ }
1163
+
1164
+ /** Drops the reference before removing, so nothing hands out a detached element. */
1165
+ private detachAudioPlayer(participantId: string) {
1166
+ if (this.audioPlayer?.dataset.participantId === participantId) {
1167
+ this.audioPlayer = null;
1168
+ }
1169
+ // Scoped to this participant: the call check cannot catch these, since the
1170
+ // call itself has not changed, and an unrelated leave must not invalidate
1171
+ // the assistant's build.
1172
+ for (const [build, id] of this.pendingAudioBuilds) {
1173
+ if (id === participantId) {
1174
+ this.pendingAudioBuilds.delete(build);
1175
+ }
1176
+ }
1177
+ this.audioPlayerDestroy(participantId);
1178
+ }
1179
+
1180
+ /**
1181
+ * The <audio> element playing the assistant, or null before its track has
1182
+ * arrived. Pair with the `audio` event when you need it the moment it exists.
1183
+ */
1184
+ public getAudioPlayer(): HTMLAudioElement | null {
1185
+ return this.audioPlayer;
1186
+ }
1187
+
1188
+ /**
1189
+ * Sets assistant playback volume, 0 to 1.
1190
+ *
1191
+ * The setting is remembered, so it can be applied before the assistant's
1192
+ * audio track has arrived and it survives across calls and mid-call track
1193
+ * replacement. Callers do not have to race the start of the call.
1194
+ *
1195
+ * Values outside 0 to 1 are clamped and non-finite values are ignored, since
1196
+ * the DOM rejects both rather than saturating.
1197
+ *
1198
+ * Only volume set through this method is remembered. Writing `volume`
1199
+ * directly on the element from getAudioPlayer() or the `audio` event applies
1200
+ * to that element alone, and is overwritten the next time one is attached.
1201
+ *
1202
+ * This is playback only. It does not change what the assistant hears, and it
1203
+ * does not affect the levels reported by the `volume-level` event.
1204
+ */
1205
+ public setVolume(volume: number) {
1206
+ if (!Number.isFinite(volume)) {
1207
+ return;
1208
+ }
1209
+ this.desiredVolume = Math.min(1, Math.max(0, volume));
1210
+ if (this.audioPlayer) {
1211
+ this.audioPlayer.volume = this.desiredVolume;
1212
+ }
1213
+ }
1214
+
1034
1215
  public setMuted(mute: boolean) {
1035
1216
  safeSetLocalAudio(this.call, !mute);
1036
1217
  }
@@ -1312,24 +1493,7 @@ export default class Vapi extends VapiEventEmitter {
1312
1493
  if (event) this.emit('recording-upload-completed', event);
1313
1494
  });
1314
1495
 
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
- });
1496
+ this.call.on('track-started', (e) => this.handleTrackStarted(e));
1333
1497
 
1334
1498
  this.call.on('participant-joined', (e) => {
1335
1499
  if (!e || !this.call) return;
@@ -1352,7 +1516,7 @@ export default class Vapi extends VapiEventEmitter {
1352
1516
  if (!e) {
1353
1517
  return;
1354
1518
  }
1355
- destroyAudioPlayer(e.participant.session_id);
1519
+ this.detachAudioPlayer(e.participant.session_id);
1356
1520
  });
1357
1521
 
1358
1522
  this.call.on('remote-participants-audio-level', (e) => {