@vapi-ai/web 2.6.1 → 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;
@@ -127,6 +133,17 @@ export default class Vapi extends VapiEventEmitter {
127
133
  private cleanup;
128
134
  private isMobileDevice;
129
135
  private sleep;
136
+ /**
137
+ * Reports a failed audio processing update on the SDK's `error` event.
138
+ *
139
+ * Daily applies input settings inside its remotely loaded call machine, so a
140
+ * failure - Krisp's `KrispInitError: Canceled`, for example - arrives as a
141
+ * rejected promise, which a synchronous try/catch around
142
+ * `updateInputSettings()` cannot see. Without this the rejection escapes to
143
+ * the page as an unhandled rejection instead of reaching the listeners
144
+ * consumers register. Audio processing is non-critical, so the call continues.
145
+ */
146
+ private emitAudioProcessingError;
130
147
  start(assistant?: CreateAssistantDTO | string, assistantOverrides?: AssistantOverrides, squad?: CreateSquadDTO | string, workflow?: CreateWorkflowDTO | string, workflowOverrides?: WorkflowOverrides, options?: StartCallOptions): Promise<Call | null>;
131
148
  private onAppMessage;
132
149
  private handleRemoteParticipantsAudioLevel;
@@ -144,6 +161,56 @@ export default class Vapi extends VapiEventEmitter {
144
161
  * Docs: https://docs.vapi.ai/calls/call-features
145
162
  */
146
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;
147
214
  setMuted(mute: boolean): void;
148
215
  isMuted(): boolean;
149
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() {
@@ -141,6 +163,24 @@ class Vapi extends VapiEventEmitter {
141
163
  async sleep(ms) {
142
164
  return new Promise((resolve) => setTimeout(resolve, ms));
143
165
  }
166
+ /**
167
+ * Reports a failed audio processing update on the SDK's `error` event.
168
+ *
169
+ * Daily applies input settings inside its remotely loaded call machine, so a
170
+ * failure - Krisp's `KrispInitError: Canceled`, for example - arrives as a
171
+ * rejected promise, which a synchronous try/catch around
172
+ * `updateInputSettings()` cannot see. Without this the rejection escapes to
173
+ * the page as an unhandled rejection instead of reaching the listeners
174
+ * consumers register. Audio processing is non-critical, so the call continues.
175
+ */
176
+ emitAudioProcessingError(stage, error) {
177
+ this.emit('error', {
178
+ type: `${stage}-error`,
179
+ stage,
180
+ error: serializeError(error),
181
+ timestamp: new Date().toISOString(),
182
+ });
183
+ }
144
184
  async start(assistant, assistantOverrides, squad, workflow, workflowOverrides, options) {
145
185
  const startTime = Date.now();
146
186
  // Input validation with detailed error messages
@@ -320,24 +360,7 @@ class Vapi extends VapiEventEmitter {
320
360
  if (event)
321
361
  this.emit('recording-upload-completed', event);
322
362
  });
323
- this.call.on('track-started', async (e) => {
324
- if (!e || !e.participant) {
325
- return;
326
- }
327
- if (e.participant?.local) {
328
- return;
329
- }
330
- if (e.participant?.user_name !== 'Vapi Speaker') {
331
- return;
332
- }
333
- if (e.track.kind === 'video') {
334
- this.emit('video', e.track);
335
- }
336
- if (e.track.kind === 'audio') {
337
- await buildAudioPlayer(e.track, e.participant.session_id);
338
- }
339
- this.call?.sendAppMessage('playable');
340
- });
363
+ this.call.on('track-started', (e) => this.handleTrackStarted(e));
341
364
  this.call.on('participant-joined', (e) => {
342
365
  if (!e || !this.call)
343
366
  return;
@@ -353,7 +376,7 @@ class Vapi extends VapiEventEmitter {
353
376
  if (!e) {
354
377
  return;
355
378
  }
356
- destroyAudioPlayer(e.participant.session_id);
379
+ this.detachAudioPlayer(e.participant.session_id);
357
380
  });
358
381
  // Stage 3: Mobile device handling and permissions
359
382
  const isMobile = this.isMobileDevice();
@@ -551,6 +574,9 @@ class Vapi extends VapiEventEmitter {
551
574
  })
552
575
  .then(() => {
553
576
  (0, daily_guards_1.safeSetLocalAudio)(this.call, true);
577
+ })
578
+ .catch((error) => {
579
+ this.emitAudioProcessingError('audio-processor-recovery', error);
554
580
  });
555
581
  }
556
582
  });
@@ -562,12 +588,16 @@ class Vapi extends VapiEventEmitter {
562
588
  });
563
589
  const audioProcessingStartTime = Date.now();
564
590
  try {
565
- this.call.updateInputSettings({
591
+ this.call
592
+ .updateInputSettings({
566
593
  audio: {
567
594
  processor: {
568
595
  type: 'noise-cancellation',
569
596
  },
570
597
  },
598
+ })
599
+ .catch((error) => {
600
+ this.emitAudioProcessingError('audio-processing-setup', error);
571
601
  });
572
602
  const audioProcessingDuration = Date.now() - audioProcessingStartTime;
573
603
  this.emit('call-start-progress', {
@@ -696,6 +726,8 @@ class Vapi extends VapiEventEmitter {
696
726
  await this.call.destroy();
697
727
  this.call = null;
698
728
  }
729
+ this.audioPlayer = null;
730
+ this.pendingAudioBuilds.clear();
699
731
  this.speakingTimeout = null;
700
732
  }
701
733
  /**
@@ -706,6 +738,155 @@ class Vapi extends VapiEventEmitter {
706
738
  send(message) {
707
739
  this.call?.sendAppMessage(JSON.stringify(message));
708
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
+ }
709
890
  setMuted(mute) {
710
891
  (0, daily_guards_1.safeSetLocalAudio)(this.call, !mute);
711
892
  }
@@ -947,24 +1128,7 @@ class Vapi extends VapiEventEmitter {
947
1128
  if (event)
948
1129
  this.emit('recording-upload-completed', event);
949
1130
  });
950
- this.call.on('track-started', async (e) => {
951
- if (!e || !e.participant) {
952
- return;
953
- }
954
- if (e.participant?.local) {
955
- return;
956
- }
957
- if (e.participant?.user_name !== 'Vapi Speaker') {
958
- return;
959
- }
960
- if (e.track.kind === 'video') {
961
- this.emit('video', e.track);
962
- }
963
- if (e.track.kind === 'audio') {
964
- await buildAudioPlayer(e.track, e.participant.session_id);
965
- }
966
- this.call?.sendAppMessage('playable');
967
- });
1131
+ this.call.on('track-started', (e) => this.handleTrackStarted(e));
968
1132
  this.call.on('participant-joined', (e) => {
969
1133
  if (!e || !this.call)
970
1134
  return;
@@ -980,7 +1144,7 @@ class Vapi extends VapiEventEmitter {
980
1144
  if (!e) {
981
1145
  return;
982
1146
  }
983
- destroyAudioPlayer(e.participant.session_id);
1147
+ this.detachAudioPlayer(e.participant.session_id);
984
1148
  });
985
1149
  this.call.on('remote-participants-audio-level', (e) => {
986
1150
  if (e)
@@ -1007,6 +1171,9 @@ class Vapi extends VapiEventEmitter {
1007
1171
  })
1008
1172
  .then(() => {
1009
1173
  (0, daily_guards_1.safeSetLocalAudio)(this.call, true);
1174
+ })
1175
+ .catch((error) => {
1176
+ this.emitAudioProcessingError('audio-processor-recovery', error);
1010
1177
  });
1011
1178
  }
1012
1179
  });
@@ -1155,12 +1322,16 @@ class Vapi extends VapiEventEmitter {
1155
1322
  });
1156
1323
  const audioProcessingStartTime = Date.now();
1157
1324
  try {
1158
- this.call.updateInputSettings({
1325
+ this.call
1326
+ .updateInputSettings({
1159
1327
  audio: {
1160
1328
  processor: {
1161
1329
  type: 'noise-cancellation',
1162
1330
  },
1163
1331
  },
1332
+ })
1333
+ .catch((error) => {
1334
+ this.emitAudioProcessingError('audio-processing-setup', error);
1164
1335
  });
1165
1336
  const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1166
1337
  this.emit('call-start-progress', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vapi-ai/web",
3
- "version": "2.6.1",
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
 
@@ -368,6 +393,28 @@ export default class Vapi extends VapiEventEmitter {
368
393
  return new Promise((resolve) => setTimeout(resolve, ms));
369
394
  }
370
395
 
396
+ /**
397
+ * Reports a failed audio processing update on the SDK's `error` event.
398
+ *
399
+ * Daily applies input settings inside its remotely loaded call machine, so a
400
+ * failure - Krisp's `KrispInitError: Canceled`, for example - arrives as a
401
+ * rejected promise, which a synchronous try/catch around
402
+ * `updateInputSettings()` cannot see. Without this the rejection escapes to
403
+ * the page as an unhandled rejection instead of reaching the listeners
404
+ * consumers register. Audio processing is non-critical, so the call continues.
405
+ */
406
+ private emitAudioProcessingError(
407
+ stage: 'audio-processing-setup' | 'audio-processor-recovery',
408
+ error: unknown,
409
+ ) {
410
+ this.emit('error', {
411
+ type: `${stage}-error`,
412
+ stage,
413
+ error: serializeError(error),
414
+ timestamp: new Date().toISOString(),
415
+ });
416
+ }
417
+
371
418
  async start(
372
419
  assistant?: CreateAssistantDTO | string,
373
420
  assistantOverrides?: AssistantOverrides,
@@ -578,24 +625,7 @@ export default class Vapi extends VapiEventEmitter {
578
625
  if (event) this.emit('recording-upload-completed', event);
579
626
  });
580
627
 
581
- this.call.on('track-started', async (e) => {
582
- if (!e || !e.participant) {
583
- return;
584
- }
585
- if (e.participant?.local) {
586
- return;
587
- }
588
- if (e.participant?.user_name !== 'Vapi Speaker') {
589
- return;
590
- }
591
- if (e.track.kind === 'video') {
592
- this.emit('video', e.track);
593
- }
594
- if (e.track.kind === 'audio') {
595
- await buildAudioPlayer(e.track, e.participant.session_id);
596
- }
597
- this.call?.sendAppMessage('playable');
598
- });
628
+ this.call.on('track-started', (e) => this.handleTrackStarted(e));
599
629
 
600
630
  this.call.on('participant-joined', (e) => {
601
631
  if (!e || !this.call) return;
@@ -618,7 +648,7 @@ export default class Vapi extends VapiEventEmitter {
618
648
  if (!e) {
619
649
  return;
620
650
  }
621
- destroyAudioPlayer(e.participant.session_id);
651
+ this.detachAudioPlayer(e.participant.session_id);
622
652
  });
623
653
 
624
654
  // Stage 3: Mobile device handling and permissions
@@ -828,6 +858,9 @@ export default class Vapi extends VapiEventEmitter {
828
858
  })
829
859
  .then(() => {
830
860
  safeSetLocalAudio(this.call, true);
861
+ })
862
+ .catch((error) => {
863
+ this.emitAudioProcessingError('audio-processor-recovery', error);
831
864
  });
832
865
  }
833
866
  });
@@ -842,14 +875,18 @@ export default class Vapi extends VapiEventEmitter {
842
875
  const audioProcessingStartTime = Date.now();
843
876
 
844
877
  try {
845
- this.call.updateInputSettings({
846
- audio: {
847
- processor: {
848
- type: 'noise-cancellation',
878
+ this.call
879
+ .updateInputSettings({
880
+ audio: {
881
+ processor: {
882
+ type: 'noise-cancellation',
883
+ },
849
884
  },
850
- },
851
- });
852
-
885
+ })
886
+ .catch((error) => {
887
+ this.emitAudioProcessingError('audio-processing-setup', error);
888
+ });
889
+
853
890
  const audioProcessingDuration = Date.now() - audioProcessingStartTime;
854
891
  this.emit('call-start-progress', {
855
892
  stage: 'audio-processing-setup',
@@ -990,6 +1027,8 @@ export default class Vapi extends VapiEventEmitter {
990
1027
  await this.call.destroy();
991
1028
  this.call = null;
992
1029
  }
1030
+ this.audioPlayer = null;
1031
+ this.pendingAudioBuilds.clear();
993
1032
  this.speakingTimeout = null;
994
1033
  }
995
1034
 
@@ -1002,6 +1041,177 @@ export default class Vapi extends VapiEventEmitter {
1002
1041
  this.call?.sendAppMessage(JSON.stringify(message));
1003
1042
  }
1004
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
+
1005
1215
  public setMuted(mute: boolean) {
1006
1216
  safeSetLocalAudio(this.call, !mute);
1007
1217
  }
@@ -1283,24 +1493,7 @@ export default class Vapi extends VapiEventEmitter {
1283
1493
  if (event) this.emit('recording-upload-completed', event);
1284
1494
  });
1285
1495
 
1286
- this.call.on('track-started', async (e) => {
1287
- if (!e || !e.participant) {
1288
- return;
1289
- }
1290
- if (e.participant?.local) {
1291
- return;
1292
- }
1293
- if (e.participant?.user_name !== 'Vapi Speaker') {
1294
- return;
1295
- }
1296
- if (e.track.kind === 'video') {
1297
- this.emit('video', e.track);
1298
- }
1299
- if (e.track.kind === 'audio') {
1300
- await buildAudioPlayer(e.track, e.participant.session_id);
1301
- }
1302
- this.call?.sendAppMessage('playable');
1303
- });
1496
+ this.call.on('track-started', (e) => this.handleTrackStarted(e));
1304
1497
 
1305
1498
  this.call.on('participant-joined', (e) => {
1306
1499
  if (!e || !this.call) return;
@@ -1323,7 +1516,7 @@ export default class Vapi extends VapiEventEmitter {
1323
1516
  if (!e) {
1324
1517
  return;
1325
1518
  }
1326
- destroyAudioPlayer(e.participant.session_id);
1519
+ this.detachAudioPlayer(e.participant.session_id);
1327
1520
  });
1328
1521
 
1329
1522
  this.call.on('remote-participants-audio-level', (e) => {
@@ -1352,6 +1545,9 @@ export default class Vapi extends VapiEventEmitter {
1352
1545
  })
1353
1546
  .then(() => {
1354
1547
  safeSetLocalAudio(this.call, true);
1548
+ })
1549
+ .catch((error) => {
1550
+ this.emitAudioProcessingError('audio-processor-recovery', error);
1355
1551
  });
1356
1552
  }
1357
1553
  });
@@ -1513,14 +1709,18 @@ export default class Vapi extends VapiEventEmitter {
1513
1709
  const audioProcessingStartTime = Date.now();
1514
1710
 
1515
1711
  try {
1516
- this.call.updateInputSettings({
1517
- audio: {
1518
- processor: {
1519
- type: 'noise-cancellation',
1712
+ this.call
1713
+ .updateInputSettings({
1714
+ audio: {
1715
+ processor: {
1716
+ type: 'noise-cancellation',
1717
+ },
1520
1718
  },
1521
- },
1522
- });
1523
-
1719
+ })
1720
+ .catch((error) => {
1721
+ this.emitAudioProcessingError('audio-processing-setup', error);
1722
+ });
1723
+
1524
1724
  const audioProcessingDuration = Date.now() - audioProcessingStartTime;
1525
1725
  this.emit('call-start-progress', {
1526
1726
  stage: 'audio-processing-setup',