diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index 00e6fb09d..26d5b887d 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -567,3 +567,112 @@ describe("Vapi local audio level observer", () => { expect(mockDailyCall.startLocalAudioLevelObserver).toHaveBeenCalledWith(100); }); }); + +describe("Vapi clientSideNoiseCancellationEnabled", () => { + const webCall = { + id: "call_test", + webCallUrl: "https://example.daily.co/test", + }; + const noiseCancellation = { + audio: { processor: { type: "noise-cancellation" } }, + }; + + afterEach(() => { + mockDailyCall = null; + }); + + function startCall(vapi: Vapi, options?: { clientSideNoiseCancellationEnabled?: boolean }) { + return vapi.start( + "dummy_assistant_id", + undefined, + undefined, + undefined, + undefined, + options + ); + } + + it("turns noise cancellation on by default", async () => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + const vapi = new Vapi("dummy_token"); + + await startCall(vapi); + + expect(mockDailyCall.updateInputSettings).toHaveBeenCalledWith( + noiseCancellation + ); + }); + + it("skips noise cancellation when start() is told not to", async () => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + const vapi = new Vapi("dummy_token"); + vapi.on("local-volume-level", () => {}); + + const call = await startCall(vapi, { clientSideNoiseCancellationEnabled: false }); + await flushRejections(); + + expect(call).not.toBeNull(); + expect(mockDailyCall.updateInputSettings).not.toHaveBeenCalled(); + // Nothing swaps the track, so the observer starts without waiting. + expect(mockDailyCall.startLocalAudioLevelObserver).toHaveBeenCalledWith(100); + }); + + it("reports no audio processing stage when start() skips it", async () => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + const vapi = new Vapi("dummy_token"); + const stages: string[] = []; + vapi.on("call-start-progress", (event) => { + stages.push(event.stage); + }); + + await startCall(vapi, { clientSideNoiseCancellationEnabled: false }); + + expect(stages).not.toContain("audio-processing-setup"); + }); + + it("keeps the value from start() on reconnect()", async () => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + const vapi = new Vapi("dummy_token"); + await startCall(vapi, { clientSideNoiseCancellationEnabled: false }); + await vapi.stop(); + + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + await vapi.reconnect(webCall); + + expect(mockDailyCall.updateInputSettings).not.toHaveBeenCalled(); + }); + + it("lets reconnect() override the value from start()", async () => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + const vapi = new Vapi("dummy_token"); + await startCall(vapi, { clientSideNoiseCancellationEnabled: false }); + await vapi.stop(); + + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + await vapi.reconnect(webCall, { clientSideNoiseCancellationEnabled: true }); + + expect(mockDailyCall.updateInputSettings).toHaveBeenCalledWith( + noiseCancellation + ); + }); + + it("turns noise cancellation on for reconnect() without a prior start()", async () => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + const vapi = new Vapi("dummy_token"); + + await vapi.reconnect(webCall); + + expect(mockDailyCall.updateInputSettings).toHaveBeenCalledWith( + noiseCancellation + ); + }); + + it("skips noise cancellation when reconnect() is told not to", async () => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + const vapi = new Vapi("dummy_token"); + + await vapi.reconnect(webCall, { clientSideNoiseCancellationEnabled: false }); + + expect(mockDailyCall.updateInputSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/vapi.ts b/vapi.ts index af3c9c914..bbd2bc76f 100644 --- a/vapi.ts +++ b/vapi.ts @@ -208,6 +208,15 @@ type VapiEventListeners = { type StartCallOptions = { /** Acquire the microphone before creating a backend call. Defaults to true. */ microphonePreflightEnabled?: boolean; + /** + * Run Daily's Krisp noise cancellation on the microphone + * in the browser, before the audio leaves the device. Set to `false` to send + * the raw microphone audio. Defaults to true. + * + * Separate from the assistant's server-side `backgroundSpeechDenoisingPlan`, + * which this option does not change. + */ + clientSideNoiseCancellationEnabled?: 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. @@ -219,6 +228,17 @@ type StartCallOptions = { roomDeleteOnUserLeaveEnabled?: boolean; } +type ReconnectOptions = { + /** + * Run Daily's Krisp noise cancellation on the microphone + * in the browser. Defaults to the value passed to the last `start()`, or true. + * + * Separate from the assistant's server-side `backgroundSpeechDenoisingPlan`, + * which this option does not change. + */ + clientSideNoiseCancellationEnabled?: boolean; +} + type WebCall = { /** * The Vapi WebCall URL. This is the URL that the call will be joined on. @@ -360,6 +380,7 @@ export default class Vapi extends VapiEventEmitter { private dailyCallObject: DailyFactoryOptions = {}; private microphoneTrack: MediaStreamTrack | null = null; private cancelMicrophonePreflight: (() => void) | null = null; + private clientSideNoiseCancellationEnabled: boolean = true; private async microphoneAcquire(): Promise { const source = this.dailyCallObject.audioSource; @@ -535,6 +556,7 @@ export default class Vapi extends VapiEventEmitter { }); this.started = true; + this.clientSideNoiseCancellationEnabled = options?.clientSideNoiseCancellationEnabled ?? true; let failureStage = 'microphone-preflight'; try { @@ -961,63 +983,69 @@ export default class Vapi extends VapiEventEmitter { } }); - // Stage 7: Audio processing setup - this.emit('call-start-progress', { - stage: 'audio-processing-setup', - status: 'started', - timestamp: new Date().toISOString() - }); + if (this.clientSideNoiseCancellationEnabled) { + // Stage 7: Audio processing setup + this.emit('call-start-progress', { + stage: 'audio-processing-setup', + status: 'started', + timestamp: new Date().toISOString() + }); - const audioProcessingStartTime = Date.now(); + const audioProcessingStartTime = Date.now(); - try { - const audioProcessingUpdate = this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', + try { + const audioProcessingUpdate = this.call.updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', + }, }, - }, - }); + }); - audioProcessingUpdate.catch((error) => { - this.emitAudioProcessingError('audio-processing-setup', error); - }); + audioProcessingUpdate.catch((error) => { + this.emitAudioProcessingError('audio-processing-setup', error); + }); - // The observer waits for this update rather than starting alongside the - // remote one above, so that Krisp has already swapped the microphone - // track. See maybeStartLocalAudioLevelObserver(). - // - // A separate chain, not another link on the one above: EventEmitter - // rethrows out of emit('error') when a consumer registered no 'error' - // listener, and whether the observer starts must not hinge on that. - audioProcessingUpdate - .catch(() => {}) - .then(() => this.maybeStartLocalAudioLevelObserver()); - - const audioProcessingDuration = Date.now() - audioProcessingStartTime; - this.emit('call-start-progress', { - stage: 'audio-processing-setup', - status: 'completed', - duration: audioProcessingDuration, - timestamp: new Date().toISOString() - }); - } catch (error) { - const audioProcessingDuration = Date.now() - audioProcessingStartTime; - const serializedError = serializeError(error); - this.emit('call-start-progress', { - stage: 'audio-processing-setup', - status: 'failed', - duration: audioProcessingDuration, - timestamp: new Date().toISOString(), - metadata: { error: serializedError.message } - }); - this.emit('error', { - type: 'audio-processing-setup-error', - stage: 'audio-processing-setup', - error: serializedError, - timestamp: new Date().toISOString() - }); - // Don't throw here, this is non-critical + // The observer waits for this update rather than starting alongside the + // remote one above, so that Krisp has already swapped the microphone + // track. See maybeStartLocalAudioLevelObserver(). + // + // A separate chain, not another link on the one above: EventEmitter + // rethrows out of emit('error') when a consumer registered no 'error' + // listener, and whether the observer starts must not hinge on that. + audioProcessingUpdate + .catch(() => {}) + .then(() => this.maybeStartLocalAudioLevelObserver()); + + const audioProcessingDuration = Date.now() - audioProcessingStartTime; + this.emit('call-start-progress', { + stage: 'audio-processing-setup', + status: 'completed', + duration: audioProcessingDuration, + timestamp: new Date().toISOString() + }); + } catch (error) { + const audioProcessingDuration = Date.now() - audioProcessingStartTime; + const serializedError = serializeError(error); + this.emit('call-start-progress', { + stage: 'audio-processing-setup', + status: 'failed', + duration: audioProcessingDuration, + timestamp: new Date().toISOString(), + metadata: { error: serializedError.message } + }); + this.emit('error', { + type: 'audio-processing-setup-error', + stage: 'audio-processing-setup', + error: serializedError, + timestamp: new Date().toISOString() + }); + // Don't throw here, this is non-critical + } + } else { + // Daily's default processor is 'none', so the microphone track is never + // swapped and the observer has nothing to wait for. + void this.maybeStartLocalAudioLevelObserver(); } const totalDuration = Date.now() - startTime; @@ -1498,11 +1526,11 @@ export default class Vapi extends VapiEventEmitter { /** * Reconnects to an active call. - * - * - * @param webCall + * + * @param webCall + * @param options */ - async reconnect(webCall: WebCall): Promise { + async reconnect(webCall: WebCall, options?: ReconnectOptions): Promise { const startTime = Date.now(); if (this.started) { @@ -1513,6 +1541,10 @@ export default class Vapi extends VapiEventEmitter { throw new Error('webCallUrl is required for reconnection.'); } + if (options?.clientSideNoiseCancellationEnabled !== undefined) { + this.clientSideNoiseCancellationEnabled = options.clientSideNoiseCancellationEnabled; + } + this.emit('call-start-progress', { stage: 'reconnect-initialization', status: 'started', @@ -1863,57 +1895,63 @@ export default class Vapi extends VapiEventEmitter { // Don't throw here, this is non-critical } - // Stage 6: Audio processing setup - this.emit('call-start-progress', { - stage: 'audio-processing-setup', - status: 'started', - timestamp: new Date().toISOString() - }); + if (this.clientSideNoiseCancellationEnabled) { + // Stage 6: Audio processing setup + this.emit('call-start-progress', { + stage: 'audio-processing-setup', + status: 'started', + timestamp: new Date().toISOString() + }); - const audioProcessingStartTime = Date.now(); + const audioProcessingStartTime = Date.now(); - try { - const audioProcessingUpdate = this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', + try { + const audioProcessingUpdate = this.call.updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', + }, }, - }, - }); + }); - audioProcessingUpdate.catch((error) => { - this.emitAudioProcessingError('audio-processing-setup', error); - }); + audioProcessingUpdate.catch((error) => { + this.emitAudioProcessingError('audio-processing-setup', error); + }); - // The observer waits for this update rather than starting alongside the - // remote one above, so that Krisp has already swapped the microphone - // track. See maybeStartLocalAudioLevelObserver(). - // - // A separate chain, not another link on the one above: EventEmitter - // rethrows out of emit('error') when a consumer registered no 'error' - // listener, and whether the observer starts must not hinge on that. - audioProcessingUpdate - .catch(() => {}) - .then(() => this.maybeStartLocalAudioLevelObserver()); - - const audioProcessingDuration = Date.now() - audioProcessingStartTime; - this.emit('call-start-progress', { - stage: 'audio-processing-setup', - status: 'completed', - duration: audioProcessingDuration, - timestamp: new Date().toISOString() - }); - } catch (error) { - const audioProcessingDuration = Date.now() - audioProcessingStartTime; - const serializedError = serializeError(error); - this.emit('call-start-progress', { - stage: 'audio-processing-setup', - status: 'failed', - duration: audioProcessingDuration, - timestamp: new Date().toISOString(), - metadata: { error: serializedError.message } - }); - // Don't throw here, this is non-critical + // The observer waits for this update rather than starting alongside the + // remote one above, so that Krisp has already swapped the microphone + // track. See maybeStartLocalAudioLevelObserver(). + // + // A separate chain, not another link on the one above: EventEmitter + // rethrows out of emit('error') when a consumer registered no 'error' + // listener, and whether the observer starts must not hinge on that. + audioProcessingUpdate + .catch(() => {}) + .then(() => this.maybeStartLocalAudioLevelObserver()); + + const audioProcessingDuration = Date.now() - audioProcessingStartTime; + this.emit('call-start-progress', { + stage: 'audio-processing-setup', + status: 'completed', + duration: audioProcessingDuration, + timestamp: new Date().toISOString() + }); + } catch (error) { + const audioProcessingDuration = Date.now() - audioProcessingStartTime; + const serializedError = serializeError(error); + this.emit('call-start-progress', { + stage: 'audio-processing-setup', + status: 'failed', + duration: audioProcessingDuration, + timestamp: new Date().toISOString(), + metadata: { error: serializedError.message } + }); + // Don't throw here, this is non-critical + } + } else { + // Daily's default processor is 'none', so the microphone track is never + // swapped and the observer has nothing to wait for. + void this.maybeStartLocalAudioLevelObserver(); } const totalDuration = Date.now() - startTime;