diff --git a/__tests__/micBoost.test.ts b/__tests__/micBoost.test.ts new file mode 100644 index 000000000..b3b977dc1 --- /dev/null +++ b/__tests__/micBoost.test.ts @@ -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((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); + }); +}); diff --git a/vapi.ts b/vapi.ts index 6fa5e7f47..272d5b837 100644 --- a/vapi.ts +++ b/vapi.ts @@ -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; @@ -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; @@ -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; @@ -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); @@ -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); } }