Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions __tests__/microphone-preflight.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
20 changes: 16 additions & 4 deletions __tests__/vapi.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,9 +20,9 @@ jest.mock("@daily-co/daily-js", () => {
return {
__esModule: true,
default: {
createCallObject: () => {
createCallObject: jest.fn(() => {
return mockDailyCall;
},
}),
},
};
});
Expand All @@ -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",
},
});
},
}),
},
},
};
Expand Down
75 changes: 60 additions & 15 deletions vapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<MediaStreamTrack> {
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<MediaStreamTrack>((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;

Expand All @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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',
Expand All @@ -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(),
Expand All @@ -1024,7 +1064,6 @@ export default class Vapi extends VapiEventEmitter {
}
});

await this.cleanup();
return null;
}
}
Expand Down Expand Up @@ -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<void> {
if (this.cancelMicrophonePreflight) {
this.cancelMicrophonePreflight();
return;
}
this.microphoneTrack?.stop();
this.microphoneTrack = null;
this.started = false;
if (this.call) {
await this.call.destroy();
Expand Down
Loading