Skip to content

Commit da4ffaf

Browse files
Gate web call creation on cancellable microphone preflight (#189)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 4e199f1 commit da4ffaf

3 files changed

Lines changed: 184 additions & 19 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import Vapi from '../vapi';
2+
import { client } from '../client';
3+
import Daily from '@daily-co/daily-js';
4+
5+
jest.mock('@daily-co/daily-js', () => ({ __esModule: true, default: { createCallObject: jest.fn() } }));
6+
jest.mock('../client', () => ({ client: { setSecurityData: jest.fn(), call: { callControllerCreateWebCall: jest.fn() } } }));
7+
8+
describe('microphone preflight', () => {
9+
let gum: jest.Mock;
10+
let track: any;
11+
let daily: any;
12+
let vapi: Vapi;
13+
let errors: any[];
14+
let failures: any[];
15+
const api = client.call.callControllerCreateWebCall as jest.Mock;
16+
beforeEach(() => {
17+
jest.clearAllMocks();
18+
track = { readyState: 'live', stop: jest.fn() };
19+
gum = jest.fn().mockResolvedValue({ getAudioTracks: () => [track], getTracks: () => [track] });
20+
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'test', mediaDevices: { getUserMedia: gum } } });
21+
daily = { iframe: () => null, on: jest.fn(), join: jest.fn().mockResolvedValue(undefined), destroy: jest.fn().mockResolvedValue(undefined), startRemoteParticipantsAudioLevelObserver: jest.fn(), updateInputSettings: jest.fn().mockResolvedValue(undefined) };
22+
(Daily.createCallObject as jest.Mock).mockReturnValue(daily);
23+
api.mockResolvedValue({ data: { id: 'test', webCallUrl: 'https://test.daily.co/test' } });
24+
vapi = new Vapi('test');
25+
errors = []; failures = [];
26+
vapi.on('error', event => errors.push(event));
27+
vapi.on('call-start-failed', event => failures.push(event));
28+
});
29+
30+
it('waits for access and reuses the acquired track', async () => {
31+
let grant!: (value: any) => void;
32+
gum.mockReturnValue(new Promise(resolve => { grant = resolve; }));
33+
const start = vapi.start('assistant');
34+
expect(api).not.toHaveBeenCalled();
35+
grant({ getAudioTracks: () => [track], getTracks: () => [track] });
36+
expect(await start).not.toBeNull();
37+
expect(gum).toHaveBeenCalledWith({ audio: true });
38+
expect(Daily.createCallObject).toHaveBeenCalledWith(expect.objectContaining({ audioSource: track }));
39+
await vapi.stop();
40+
expect(track.stop).toHaveBeenCalled();
41+
});
42+
43+
it.each(['NotAllowedError', 'NotFoundError', 'NotReadableError'])('does not create a call on %s', async name => {
44+
gum.mockRejectedValue(new DOMException('microphone unavailable', name));
45+
expect(await vapi.start('assistant')).toBeNull();
46+
expect(api).not.toHaveBeenCalled();
47+
expect(errors[0].type).toBe(`microphone-preflight-${name}`);
48+
expect(failures[0].stage).toBe('microphone-preflight');
49+
});
50+
51+
it('rejects an empty stream', async () => {
52+
gum.mockResolvedValue({ getAudioTracks: () => [], getTracks: () => [] });
53+
expect(await vapi.start('assistant')).toBeNull();
54+
expect(api).not.toHaveBeenCalled();
55+
});
56+
57+
it.each(['api', 'join'])('stops the track on %s failure', async stage => {
58+
if (stage === 'api') api.mockRejectedValue(new Error('API failed'));
59+
else daily.join.mockRejectedValue(new Error('join failed'));
60+
expect(await vapi.start('assistant')).toBeNull();
61+
expect(track.stop).toHaveBeenCalled();
62+
});
63+
64+
it('cancels a pending prompt and stops a late track without creating a call', async () => {
65+
let grant!: (value: any) => void;
66+
gum.mockReturnValue(new Promise(resolve => { grant = resolve; }));
67+
const start = vapi.start('assistant');
68+
expect(await vapi.start('assistant')).toBeNull();
69+
await vapi.stop();
70+
expect(await start).toBeNull();
71+
grant({ getAudioTracks: () => [track], getTracks: () => [track] });
72+
await Promise.resolve();
73+
expect(track.stop).toHaveBeenCalled();
74+
expect(api).not.toHaveBeenCalled();
75+
gum.mockResolvedValue({ getAudioTracks: () => [track], getTracks: () => [track] });
76+
expect(await vapi.start('assistant')).not.toBeNull();
77+
await vapi.stop();
78+
});
79+
80+
it('supports opting out', async () => {
81+
expect(await vapi.start('assistant', undefined, undefined, undefined, undefined, { microphonePreflightEnabled: false })).not.toBeNull();
82+
expect(gum).not.toHaveBeenCalled();
83+
await vapi.stop();
84+
});
85+
86+
it('preserves a selected device', async () => {
87+
vapi = new Vapi('test', undefined, undefined, { audioSource: 'device-id' });
88+
await vapi.start('assistant');
89+
expect(gum).toHaveBeenCalledWith({ audio: { deviceId: { exact: 'device-id' } } });
90+
await vapi.stop();
91+
});
92+
93+
it('reuses a caller-owned track without acquiring or stopping it', async () => {
94+
vapi = new Vapi('test', undefined, undefined, { audioSource: track });
95+
await vapi.start('assistant');
96+
expect(gum).not.toHaveBeenCalled();
97+
expect(Daily.createCallObject).toHaveBeenCalledWith(expect.objectContaining({ audioSource: track }));
98+
await vapi.stop();
99+
expect(track.stop).not.toHaveBeenCalled();
100+
});
101+
102+
it('fails locally when mediaDevices is unavailable', async () => {
103+
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'test' } });
104+
expect(await vapi.start('assistant')).toBeNull();
105+
expect(api).not.toHaveBeenCalled();
106+
expect(failures[0].stage).toBe('microphone-preflight');
107+
});
108+
});

‎__tests__/vapi.test.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
import Vapi from "../vapi"; // Adjust the import path based on your project structure
22

3+
const mockGetUserMedia = jest.fn();
4+
beforeEach(() => {
5+
Object.defineProperty(globalThis, 'navigator', {
6+
configurable: true,
7+
value: { userAgent: 'test', mediaDevices: { getUserMedia: mockGetUserMedia } },
8+
});
9+
const track = { readyState: 'live', stop: jest.fn() };
10+
mockGetUserMedia.mockReset().mockResolvedValue({
11+
getAudioTracks: () => [track], getTracks: () => [track],
12+
});
13+
});
14+
315
// The DailyCall object the mocked daily-js factory hands back. It is read when
416
// createCallObject() is called, so each test installs its own fake first.
517
let mockDailyCall: any = null;
@@ -8,9 +20,9 @@ jest.mock("@daily-co/daily-js", () => {
820
return {
921
__esModule: true,
1022
default: {
11-
createCallObject: () => {
23+
createCallObject: jest.fn(() => {
1224
return mockDailyCall;
13-
},
25+
}),
1426
},
1527
};
1628
});
@@ -21,14 +33,14 @@ jest.mock("../client", () => {
2133
baseUrl: "",
2234
setSecurityData: () => {},
2335
call: {
24-
callControllerCreateWebCall: () => {
36+
callControllerCreateWebCall: jest.fn(() => {
2537
return Promise.resolve({
2638
data: {
2739
id: "call_test",
2840
webCallUrl: "https://example.daily.co/test",
2941
},
3042
});
31-
},
43+
}),
3244
},
3345
},
3446
};

‎vapi.ts‎

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ type VapiEventListeners = {
206206
};
207207

208208
type StartCallOptions = {
209+
/** Acquire the microphone before creating a backend call. Defaults to true. */
210+
microphonePreflightEnabled?: boolean;
209211
/**
210212
* This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room.
211213
* If set to `false`, the room will be kept alive even after the user leaves, allowing clients to reconnect to the same room.
@@ -356,6 +358,40 @@ export default class Vapi extends VapiEventEmitter {
356358
private speakingTimeout: NodeJS.Timeout | null = null;
357359
private dailyCallConfig: DailyAdvancedConfig = {};
358360
private dailyCallObject: DailyFactoryOptions = {};
361+
private microphoneTrack: MediaStreamTrack | null = null;
362+
private cancelMicrophonePreflight: (() => void) | null = null;
363+
364+
private async microphoneAcquire(): Promise<MediaStreamTrack> {
365+
const source = this.dailyCallObject.audioSource;
366+
if (source && typeof source === 'object') {
367+
if (source.readyState === 'ended') {
368+
throw new DOMException('The supplied microphone track has ended.', 'NotReadableError');
369+
}
370+
return source;
371+
}
372+
return new Promise<MediaStreamTrack>((resolve, reject) => {
373+
let cancelled = false;
374+
this.cancelMicrophonePreflight = () => {
375+
cancelled = true;
376+
reject(new DOMException('Microphone preflight cancelled.', 'AbortError'));
377+
};
378+
navigator.mediaDevices.getUserMedia({
379+
audio: typeof source === 'string' ? { deviceId: { exact: source } } : true,
380+
}).then((stream) => {
381+
const track = stream.getAudioTracks()[0];
382+
if (cancelled || !track || track.readyState === 'ended') {
383+
stream.getTracks().forEach((item) => item.stop());
384+
reject(new DOMException('No live microphone track available.', 'NotFoundError'));
385+
return;
386+
}
387+
stream.getTracks().filter((item) => item !== track).forEach((item) => item.stop());
388+
this.microphoneTrack = track;
389+
resolve(track);
390+
}, reject);
391+
}).finally(() => {
392+
this.cancelMicrophonePreflight = null;
393+
});
394+
}
359395

360396
private hasEmittedCallEndedStatus: boolean = false;
361397

@@ -376,6 +412,8 @@ export default class Vapi extends VapiEventEmitter {
376412
}
377413

378414
private async cleanup() {
415+
this.microphoneTrack?.stop();
416+
this.microphoneTrack = null;
379417
this.started = false;
380418
this.hasEmittedCallEndedStatus = false;
381419
if (this.call) {
@@ -498,7 +536,16 @@ export default class Vapi extends VapiEventEmitter {
498536

499537
this.started = true;
500538

539+
let failureStage = 'microphone-preflight';
501540
try {
541+
if (this.call) {
542+
await this.cleanup();
543+
this.started = true;
544+
}
545+
const audioSource = options?.microphonePreflightEnabled === false
546+
? this.dailyCallObject.audioSource ?? true
547+
: await this.microphoneAcquire();
548+
failureStage = 'unknown';
502549
// Stage 1: Create web call
503550
this.emit('call-start-progress', {
504551
stage: 'web-call-creation',
@@ -535,16 +582,6 @@ export default class Vapi extends VapiEventEmitter {
535582
}
536583
});
537584

538-
if (this.call) {
539-
this.emit('call-start-progress', {
540-
stage: 'daily-call-object-creation',
541-
status: 'started',
542-
timestamp: new Date().toISOString(),
543-
metadata: { action: 'cleanup-existing' }
544-
});
545-
await this.cleanup();
546-
}
547-
548585
const isVideoRecordingEnabled =
549586
webCall?.artifactPlan?.videoRecordingEnabled ?? false;
550587

@@ -575,7 +612,7 @@ export default class Vapi extends VapiEventEmitter {
575612

576613
try {
577614
this.call = DailyIframe.createCallObject({
578-
audioSource: this.dailyCallObject.audioSource ?? true,
615+
audioSource,
579616
videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled,
580617
dailyConfig: this.dailyCallConfig,
581618
});
@@ -994,9 +1031,10 @@ export default class Vapi extends VapiEventEmitter {
9941031
} catch (e) {
9951032
const totalDuration = Date.now() - startTime;
9961033
const serializedError = serializeError(e);
1034+
await this.cleanup();
9971035

9981036
this.emit('call-start-failed', {
999-
stage: 'unknown',
1037+
stage: failureStage,
10001038
totalDuration,
10011039
error: serializedError.message,
10021040
errorStack: serializedError.stack || 'No stack trace available',
@@ -1011,8 +1049,10 @@ export default class Vapi extends VapiEventEmitter {
10111049

10121050
// Also emit the generic error event for backward compatibility
10131051
this.emit('error', {
1014-
type: 'start-method-error',
1015-
stage: 'unknown',
1052+
type: failureStage === 'microphone-preflight'
1053+
? `microphone-preflight-${e && typeof e === 'object' && 'name' in e ? e.name : 'error'}`
1054+
: 'start-method-error',
1055+
stage: failureStage,
10161056
error: serializedError,
10171057
totalDuration,
10181058
timestamp: new Date().toISOString(),
@@ -1024,7 +1064,6 @@ export default class Vapi extends VapiEventEmitter {
10241064
}
10251065
});
10261066

1027-
await this.cleanup();
10281067
return null;
10291068
}
10301069
}
@@ -1092,6 +1131,12 @@ export default class Vapi extends VapiEventEmitter {
10921131
* If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections.
10931132
*/
10941133
async stop(): Promise<void> {
1134+
if (this.cancelMicrophonePreflight) {
1135+
this.cancelMicrophonePreflight();
1136+
return;
1137+
}
1138+
this.microphoneTrack?.stop();
1139+
this.microphoneTrack = null;
10951140
this.started = false;
10961141
if (this.call) {
10971142
await this.call.destroy();

0 commit comments

Comments
 (0)