From 77beadb0099e4c64a47f881b85bcb483bc9da09c Mon Sep 17 00:00:00 2001 From: dhruva Date: Thu, 22 Oct 2026 19:59:47 +0000 Subject: [PATCH] Gate web call creation on cancellable microphone preflight Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- __tests__/microphone-preflight.test.ts | 108 +++++++++++++++++++++++++ __tests__/vapi.test.ts | 20 ++++- vapi.ts | 75 +++++++++++++---- 3 files changed, 184 insertions(+), 19 deletions(-) create mode 100644 __tests__/microphone-preflight.test.ts diff --git a/__tests__/microphone-preflight.test.ts b/__tests__/microphone-preflight.test.ts new file mode 100644 index 000000000..931119baa --- /dev/null +++ b/__tests__/microphone-preflight.test.ts @@ -0,0 +1,108 @@ +import Vapi from '../vapi'; +import { client } from '../client'; +import Daily from '@daily-co/daily-js'; + +jest.mock('@daily-co/daily-js', () => ({ __esModule: true, default: { createCallObject: jest.fn() } })); +jest.mock('../client', () => ({ client: { setSecurityData: jest.fn(), call: { callControllerCreateWebCall: jest.fn() } } })); + +describe('microphone preflight', () => { + let gum: jest.Mock; + let track: any; + let daily: any; + let vapi: Vapi; + let errors: any[]; + let failures: any[]; + const api = client.call.callControllerCreateWebCall as jest.Mock; + beforeEach(() => { + jest.clearAllMocks(); + track = { readyState: 'live', stop: jest.fn() }; + gum = jest.fn().mockResolvedValue({ getAudioTracks: () => [track], getTracks: () => [track] }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'test', mediaDevices: { getUserMedia: gum } } }); + daily = { iframe: () => null, on: jest.fn(), join: jest.fn().mockResolvedValue(undefined), destroy: jest.fn().mockResolvedValue(undefined), startRemoteParticipantsAudioLevelObserver: jest.fn(), updateInputSettings: jest.fn().mockResolvedValue(undefined) }; + (Daily.createCallObject as jest.Mock).mockReturnValue(daily); + api.mockResolvedValue({ data: { id: 'test', webCallUrl: 'https://test.daily.co/test' } }); + vapi = new Vapi('test'); + errors = []; failures = []; + vapi.on('error', event => errors.push(event)); + vapi.on('call-start-failed', event => failures.push(event)); + }); + + it('waits for access and reuses the acquired track', async () => { + let grant!: (value: any) => void; + gum.mockReturnValue(new Promise(resolve => { grant = resolve; })); + const start = vapi.start('assistant'); + expect(api).not.toHaveBeenCalled(); + grant({ getAudioTracks: () => [track], getTracks: () => [track] }); + expect(await start).not.toBeNull(); + expect(gum).toHaveBeenCalledWith({ audio: true }); + expect(Daily.createCallObject).toHaveBeenCalledWith(expect.objectContaining({ audioSource: track })); + await vapi.stop(); + expect(track.stop).toHaveBeenCalled(); + }); + + it.each(['NotAllowedError', 'NotFoundError', 'NotReadableError'])('does not create a call on %s', async name => { + gum.mockRejectedValue(new DOMException('microphone unavailable', name)); + expect(await vapi.start('assistant')).toBeNull(); + expect(api).not.toHaveBeenCalled(); + expect(errors[0].type).toBe(`microphone-preflight-${name}`); + expect(failures[0].stage).toBe('microphone-preflight'); + }); + + it('rejects an empty stream', async () => { + gum.mockResolvedValue({ getAudioTracks: () => [], getTracks: () => [] }); + expect(await vapi.start('assistant')).toBeNull(); + expect(api).not.toHaveBeenCalled(); + }); + + it.each(['api', 'join'])('stops the track on %s failure', async stage => { + if (stage === 'api') api.mockRejectedValue(new Error('API failed')); + else daily.join.mockRejectedValue(new Error('join failed')); + expect(await vapi.start('assistant')).toBeNull(); + expect(track.stop).toHaveBeenCalled(); + }); + + it('cancels a pending prompt and stops a late track without creating a call', async () => { + let grant!: (value: any) => void; + gum.mockReturnValue(new Promise(resolve => { grant = resolve; })); + const start = vapi.start('assistant'); + expect(await vapi.start('assistant')).toBeNull(); + await vapi.stop(); + expect(await start).toBeNull(); + grant({ getAudioTracks: () => [track], getTracks: () => [track] }); + await Promise.resolve(); + expect(track.stop).toHaveBeenCalled(); + expect(api).not.toHaveBeenCalled(); + gum.mockResolvedValue({ getAudioTracks: () => [track], getTracks: () => [track] }); + expect(await vapi.start('assistant')).not.toBeNull(); + await vapi.stop(); + }); + + it('supports opting out', async () => { + expect(await vapi.start('assistant', undefined, undefined, undefined, undefined, { microphonePreflightEnabled: false })).not.toBeNull(); + expect(gum).not.toHaveBeenCalled(); + await vapi.stop(); + }); + + it('preserves a selected device', async () => { + vapi = new Vapi('test', undefined, undefined, { audioSource: 'device-id' }); + await vapi.start('assistant'); + expect(gum).toHaveBeenCalledWith({ audio: { deviceId: { exact: 'device-id' } } }); + await vapi.stop(); + }); + + it('reuses a caller-owned track without acquiring or stopping it', async () => { + vapi = new Vapi('test', undefined, undefined, { audioSource: track }); + await vapi.start('assistant'); + expect(gum).not.toHaveBeenCalled(); + expect(Daily.createCallObject).toHaveBeenCalledWith(expect.objectContaining({ audioSource: track })); + await vapi.stop(); + expect(track.stop).not.toHaveBeenCalled(); + }); + + it('fails locally when mediaDevices is unavailable', async () => { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'test' } }); + expect(await vapi.start('assistant')).toBeNull(); + expect(api).not.toHaveBeenCalled(); + expect(failures[0].stage).toBe('microphone-preflight'); + }); +}); diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index f50c75341..00e6fb09d 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -1,5 +1,17 @@ import Vapi from "../vapi"; // Adjust the import path based on your project structure +const mockGetUserMedia = jest.fn(); +beforeEach(() => { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { userAgent: 'test', mediaDevices: { getUserMedia: mockGetUserMedia } }, + }); + const track = { readyState: 'live', stop: jest.fn() }; + mockGetUserMedia.mockReset().mockResolvedValue({ + getAudioTracks: () => [track], getTracks: () => [track], + }); +}); + // The DailyCall object the mocked daily-js factory hands back. It is read when // createCallObject() is called, so each test installs its own fake first. let mockDailyCall: any = null; @@ -8,9 +20,9 @@ jest.mock("@daily-co/daily-js", () => { return { __esModule: true, default: { - createCallObject: () => { + createCallObject: jest.fn(() => { return mockDailyCall; - }, + }), }, }; }); @@ -21,14 +33,14 @@ jest.mock("../client", () => { baseUrl: "", setSecurityData: () => {}, call: { - callControllerCreateWebCall: () => { + callControllerCreateWebCall: jest.fn(() => { return Promise.resolve({ data: { id: "call_test", webCallUrl: "https://example.daily.co/test", }, }); - }, + }), }, }, }; diff --git a/vapi.ts b/vapi.ts index 6fa5e7f47..af3c9c914 100644 --- a/vapi.ts +++ b/vapi.ts @@ -206,6 +206,8 @@ type VapiEventListeners = { }; type StartCallOptions = { + /** Acquire the microphone before creating a backend call. Defaults to true. */ + microphonePreflightEnabled?: boolean; /** * This determines whether the daily room will be deleted and all participants will be kicked once the user leaves the room. * 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 { private speakingTimeout: NodeJS.Timeout | null = null; private dailyCallConfig: DailyAdvancedConfig = {}; private dailyCallObject: DailyFactoryOptions = {}; + private microphoneTrack: MediaStreamTrack | null = null; + private cancelMicrophonePreflight: (() => void) | null = null; + + private async microphoneAcquire(): Promise { + const source = this.dailyCallObject.audioSource; + if (source && typeof source === 'object') { + if (source.readyState === 'ended') { + throw new DOMException('The supplied microphone track has ended.', 'NotReadableError'); + } + return source; + } + return new Promise((resolve, reject) => { + let cancelled = false; + this.cancelMicrophonePreflight = () => { + cancelled = true; + reject(new DOMException('Microphone preflight cancelled.', 'AbortError')); + }; + navigator.mediaDevices.getUserMedia({ + audio: typeof source === 'string' ? { deviceId: { exact: source } } : true, + }).then((stream) => { + const track = stream.getAudioTracks()[0]; + if (cancelled || !track || track.readyState === 'ended') { + stream.getTracks().forEach((item) => item.stop()); + reject(new DOMException('No live microphone track available.', 'NotFoundError')); + return; + } + stream.getTracks().filter((item) => item !== track).forEach((item) => item.stop()); + this.microphoneTrack = track; + resolve(track); + }, reject); + }).finally(() => { + this.cancelMicrophonePreflight = null; + }); + } private hasEmittedCallEndedStatus: boolean = false; @@ -376,6 +412,8 @@ export default class Vapi extends VapiEventEmitter { } private async cleanup() { + this.microphoneTrack?.stop(); + this.microphoneTrack = null; this.started = false; this.hasEmittedCallEndedStatus = false; if (this.call) { @@ -498,7 +536,16 @@ export default class Vapi extends VapiEventEmitter { this.started = true; + let failureStage = 'microphone-preflight'; try { + if (this.call) { + await this.cleanup(); + this.started = true; + } + const audioSource = options?.microphonePreflightEnabled === false + ? this.dailyCallObject.audioSource ?? true + : await this.microphoneAcquire(); + failureStage = 'unknown'; // Stage 1: Create web call this.emit('call-start-progress', { stage: 'web-call-creation', @@ -535,16 +582,6 @@ export default class Vapi extends VapiEventEmitter { } }); - if (this.call) { - this.emit('call-start-progress', { - stage: 'daily-call-object-creation', - status: 'started', - timestamp: new Date().toISOString(), - metadata: { action: 'cleanup-existing' } - }); - await this.cleanup(); - } - const isVideoRecordingEnabled = webCall?.artifactPlan?.videoRecordingEnabled ?? false; @@ -575,7 +612,7 @@ export default class Vapi extends VapiEventEmitter { try { this.call = DailyIframe.createCallObject({ - audioSource: this.dailyCallObject.audioSource ?? true, + audioSource, videoSource: this.dailyCallObject.videoSource ?? isVideoRecordingEnabled, dailyConfig: this.dailyCallConfig, }); @@ -994,9 +1031,10 @@ export default class Vapi extends VapiEventEmitter { } catch (e) { const totalDuration = Date.now() - startTime; const serializedError = serializeError(e); + await this.cleanup(); this.emit('call-start-failed', { - stage: 'unknown', + stage: failureStage, totalDuration, error: serializedError.message, errorStack: serializedError.stack || 'No stack trace available', @@ -1011,8 +1049,10 @@ export default class Vapi extends VapiEventEmitter { // Also emit the generic error event for backward compatibility this.emit('error', { - type: 'start-method-error', - stage: 'unknown', + type: failureStage === 'microphone-preflight' + ? `microphone-preflight-${e && typeof e === 'object' && 'name' in e ? e.name : 'error'}` + : 'start-method-error', + stage: failureStage, error: serializedError, totalDuration, timestamp: new Date().toISOString(), @@ -1024,7 +1064,6 @@ export default class Vapi extends VapiEventEmitter { } }); - await this.cleanup(); return null; } } @@ -1092,6 +1131,12 @@ export default class Vapi extends VapiEventEmitter { * If `roomDeleteOnUserLeaveEnabled` is set to `true`, the Vapi call will also be destroyed, preventing any reconnections. */ async stop(): Promise { + if (this.cancelMicrophonePreflight) { + this.cancelMicrophonePreflight(); + return; + } + this.microphoneTrack?.stop(); + this.microphoneTrack = null; this.started = false; if (this.call) { await this.call.destroy();