Skip to content
Open
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
142 changes: 142 additions & 0 deletions __tests__/micBoost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import Vapi from "../vapi";

let mockDailyCall: any = null;

jest.mock("@daily-co/daily-js", () => {
return {
__esModule: true,
default: {
createCallObject: () => mockDailyCall,
},
};
});

jest.mock("../client", () => {
return {
client: {
baseUrl: "",
setSecurityData: () => {},
call: {},
},
};
});

function createMockDailyCall() {
return {
on: jest.fn(),
once: jest.fn(),
join: jest.fn().mockResolvedValue(undefined),
destroy: jest.fn().mockResolvedValue(undefined),
iframe: jest.fn().mockReturnValue(null),
setLocalAudio: jest.fn(),
localAudio: jest.fn(),
setInputDevicesAsync: jest.fn().mockResolvedValue(undefined),
updateInputSettings: jest.fn().mockResolvedValue(undefined),
startRemoteParticipantsAudioLevelObserver: jest.fn(),
startLocalAudioLevelObserver: jest.fn(),
};
}

const webCall = {
id: "call_test",
webCallUrl: "https://example.daily.co/test",
} as any;

// Records every microphone stream and audio context the SDK opens, so the test
// can check that each one is released.
const streams: { track: { stop: jest.Mock } }[] = [];
const contexts: { close: jest.Mock }[] = [];

function installWebAudioFakes() {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
userAgent: "node",
mediaDevices: {
getUserMedia: jest.fn(async () => {
const track = { stop: jest.fn() };
const stream = { getTracks: () => [track], track };
streams.push(stream);
return stream;
}),
},
},
});
(globalThis as any).AudioContext = class {
close = jest.fn().mockResolvedValue(undefined);
constructor() {
contexts.push(this);
}
createMediaStreamSource() {
return { connect: jest.fn() };
}
createGain() {
return { gain: { value: 1 }, connect: jest.fn() };
}
createMediaStreamDestination() {
return { stream: { getAudioTracks: () => [{}] } };
}
};
}

describe("Vapi increaseMicLevel()", () => {
let vapi: Vapi;

beforeEach(async () => {
streams.length = 0;
contexts.length = 0;
installWebAudioFakes();
mockDailyCall = createMockDailyCall();
vapi = new Vapi("dummy_token");
vapi.on("error", () => {});
await vapi.reconnect(webCall);
});

it("releases the previous microphone stream and audio context when called again", async () => {
await vapi.increaseMicLevel(2);
await vapi.increaseMicLevel(3);

expect(streams).toHaveLength(2);
expect(streams[0].track.stop).toHaveBeenCalledTimes(1);
expect(contexts[0].close).toHaveBeenCalledTimes(1);
expect(streams[1].track.stop).not.toHaveBeenCalled();
expect(contexts[1].close).not.toHaveBeenCalled();
});

it("releases the microphone stream and audio context when the call stops", async () => {
await vapi.increaseMicLevel(2);
await vapi.stop();

expect(streams[0].track.stop).toHaveBeenCalledTimes(1);
expect(contexts[0].close).toHaveBeenCalledTimes(1);
});

it("releases the microphone stream and audio context when the call is torn down while the boost is being set up", async () => {
let release!: () => void;
mockDailyCall.setInputDevicesAsync.mockReturnValue(
new Promise<void>((resolve) => {
release = resolve;
}),
);

const boosting = vapi.increaseMicLevel(2);
await new Promise((resolve) => setImmediate(resolve));
await vapi.stop();
release();
await boosting;

expect(streams[0].track.stop).toHaveBeenCalledTimes(1);
expect(contexts[0].close).toHaveBeenCalledTimes(1);
});

it("releases the microphone stream and audio context when setting the input fails", async () => {
mockDailyCall.setInputDevicesAsync.mockRejectedValue(new Error("boom"));
const spy = jest.spyOn(console, "error").mockImplementation(() => {});

await vapi.increaseMicLevel(2);
spy.mockRestore();

expect(streams[0].track.stop).toHaveBeenCalledTimes(1);
expect(contexts[0].close).toHaveBeenCalledTimes(1);
});
});
28 changes: 27 additions & 1 deletion vapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ class VapiEventEmitter extends EventEmitter {
export default class Vapi extends VapiEventEmitter {
private started: boolean = false;
private call: DailyCall | null = null;
// Microphone stream and audio graph behind the last increaseMicLevel() call.
private micBoost: { stream: MediaStream; audioContext: AudioContext } | null = null;
private audioPlayer: HTMLAudioElement | null = null;
// Survives teardown so the caller's choice persists across calls.
private desiredVolume: number | null = null;
Expand Down Expand Up @@ -382,11 +384,20 @@ export default class Vapi extends VapiEventEmitter {
await this.call.destroy();
this.call = null;
}
this.releaseMicBoost();
this.audioPlayer = null;
this.pendingAudioBuilds.clear();
this.speakingTimeout = null;
}

private releaseMicBoost() {
const boost = this.micBoost;
if (!boost) return;
this.micBoost = null;
boost.stream.getTracks().forEach((track) => track.stop());
boost.audioContext.close().catch(() => {});
}

private isMobileDevice() {
if (typeof navigator === 'undefined') {
return false;
Expand Down Expand Up @@ -1097,6 +1108,7 @@ export default class Vapi extends VapiEventEmitter {
await this.call.destroy();
this.call = null;
}
this.releaseMicBoost();
this.audioPlayer = null;
this.pendingAudioBuilds.clear();
this.speakingTimeout = null;
Expand Down Expand Up @@ -1327,9 +1339,11 @@ export default class Vapi extends VapiEventEmitter {
throw new Error('Call object is not available.');
}

let boost: { stream: MediaStream; audioContext: AudioContext } | null = null;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioContext = new AudioContext();
boost = { stream, audioContext };

const source = audioContext.createMediaStreamSource(stream);

Expand All @@ -1342,8 +1356,20 @@ export default class Vapi extends VapiEventEmitter {
gainNode.connect(destination);

const [boostedTrack] = destination.stream.getAudioTracks();
await safeSetInputDevicesAsync(this.call, { audioSource: boostedTrack });
await safeSetInputDevicesAsync(this.call, { audioSource: boostedTrack });

// The previous boost is no longer the active input. Keep this one alive
// until the call ends, unless stop() ran while it was being set up.
this.releaseMicBoost();
if (this.call) {
this.micBoost = boost;
} else {
boost.stream.getTracks().forEach((track) => track.stop());
boost.audioContext.close().catch(() => {});
}
} catch (error) {
boost?.stream.getTracks().forEach((track) => track.stop());
boost?.audioContext.close().catch(() => {});
console.error("Error adjusting microphone level:", error);
}
}
Expand Down