@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 +46 -0
- package/dist/vapi.d.ts +68 -1
- package/dist/vapi.js +213 -42
- package/package.json +1 -1
- package/vapi.ts +253 -53
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
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
|
|
|
@@ -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',
|
|
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
|
-
|
|
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
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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',
|