@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 +46 -0
- package/dist/vapi.d.ts +57 -1
- package/dist/vapi.js +179 -40
- package/package.json +1 -1
- package/vapi.ts +203 -39
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
|
-
|
|
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',
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
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
|
-
|
|
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',
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
1519
|
+
this.detachAudioPlayer(e.participant.session_id);
|
|
1356
1520
|
});
|
|
1357
1521
|
|
|
1358
1522
|
this.call.on('remote-participants-audio-level', (e) => {
|