From 1e9f26687119789dfbec19868ae7b9f80be5ae4b Mon Sep 17 00:00:00 2001 From: Murat Toygar Date: Fri, 30 Oct 2026 11:39:07 +0000 Subject: [PATCH 1/4] feat: add noiseCancellationEnabled option to start() and reconnect() (#194) Default true, so current callers still get Krisp. reconnect() reuses the value from the last start() unless told otherwise. Co-Authored-By: Claude Opus 5.5 (1M context) --- __tests__/vapi.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++++ vapi.ts | 63 +++++++++++++++++++-------- 2 files changed, 141 insertions(+), 18 deletions(-) diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index 00e6fb09d..157706329 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -567,3 +567,99 @@ describe("Vapi local audio level observer", () => { expect(mockDailyCall.startLocalAudioLevelObserver).toHaveBeenCalledWith(100); }); }); + +describe("Vapi noiseCancellationEnabled", () => { + 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?: { noiseCancellationEnabled?: 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, { noiseCancellationEnabled: 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("keeps the value from start() on reconnect()", async () => { + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + const vapi = new Vapi("dummy_token"); + await startCall(vapi, { noiseCancellationEnabled: 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, { noiseCancellationEnabled: false }); + await vapi.stop(); + + mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); + await vapi.reconnect(webCall, { noiseCancellationEnabled: 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, { noiseCancellationEnabled: false }); + + expect(mockDailyCall.updateInputSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/vapi.ts b/vapi.ts index af3c9c914..bcd93f5fd 100644 --- a/vapi.ts +++ b/vapi.ts @@ -208,6 +208,11 @@ type VapiEventListeners = { type StartCallOptions = { /** Acquire the microphone before creating a backend call. Defaults to true. */ microphonePreflightEnabled?: boolean; + /** + * Run Daily's client-side Krisp noise cancellation on the microphone. + * Set to `false` to send the raw microphone audio. Defaults to true. + */ + noiseCancellationEnabled?: 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 +224,14 @@ type StartCallOptions = { roomDeleteOnUserLeaveEnabled?: boolean; } +type ReconnectOptions = { + /** + * Run Daily's client-side Krisp noise cancellation on the microphone. + * Defaults to the value passed to the last `start()`, or true. + */ + noiseCancellationEnabled?: boolean; +} + type WebCall = { /** * The Vapi WebCall URL. This is the URL that the call will be joined on. @@ -360,6 +373,7 @@ export default class Vapi extends VapiEventEmitter { private dailyCallObject: DailyFactoryOptions = {}; private microphoneTrack: MediaStreamTrack | null = null; private cancelMicrophonePreflight: (() => void) | null = null; + private noiseCancellationEnabled: boolean = true; private async microphoneAcquire(): Promise { const source = this.dailyCallObject.audioSource; @@ -535,6 +549,7 @@ export default class Vapi extends VapiEventEmitter { }); this.started = true; + this.noiseCancellationEnabled = options?.noiseCancellationEnabled ?? true; let failureStage = 'microphone-preflight'; try { @@ -971,13 +986,17 @@ export default class Vapi extends VapiEventEmitter { const audioProcessingStartTime = Date.now(); try { - const audioProcessingUpdate = this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', - }, - }, - }); + // Daily's default processor is 'none', so skipping the update leaves + // the raw microphone track in place and avoids loading Krisp at all. + const audioProcessingUpdate = this.noiseCancellationEnabled + ? this.call.updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', + }, + }, + }) + : Promise.resolve(); audioProcessingUpdate.catch((error) => { this.emitAudioProcessingError('audio-processing-setup', error); @@ -1498,11 +1517,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 +1532,10 @@ export default class Vapi extends VapiEventEmitter { throw new Error('webCallUrl is required for reconnection.'); } + if (options?.noiseCancellationEnabled !== undefined) { + this.noiseCancellationEnabled = options.noiseCancellationEnabled; + } + this.emit('call-start-progress', { stage: 'reconnect-initialization', status: 'started', @@ -1873,13 +1896,17 @@ export default class Vapi extends VapiEventEmitter { const audioProcessingStartTime = Date.now(); try { - const audioProcessingUpdate = this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', - }, - }, - }); + // Daily's default processor is 'none', so skipping the update leaves + // the raw microphone track in place and avoids loading Krisp at all. + const audioProcessingUpdate = this.noiseCancellationEnabled + ? this.call.updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', + }, + }, + }) + : Promise.resolve(); audioProcessingUpdate.catch((error) => { this.emitAudioProcessingError('audio-processing-setup', error); From 8df23f0638d5b149f245a4b691450e64e27a39f3 Mon Sep 17 00:00:00 2001 From: Murat Toygar Date: Fri, 30 Oct 2026 11:45:24 +0000 Subject: [PATCH 2/4] docs: say noiseCancellationEnabled is client-side only Co-Authored-By: Claude Opus 5.5 (1M context) --- vapi.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/vapi.ts b/vapi.ts index bcd93f5fd..a9676c8b8 100644 --- a/vapi.ts +++ b/vapi.ts @@ -209,8 +209,12 @@ type StartCallOptions = { /** Acquire the microphone before creating a backend call. Defaults to true. */ microphonePreflightEnabled?: boolean; /** - * Run Daily's client-side Krisp noise cancellation on the microphone. - * Set to `false` to send the raw microphone audio. Defaults to true. + * Client-side only: 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. */ noiseCancellationEnabled?: boolean; /** @@ -226,8 +230,11 @@ type StartCallOptions = { type ReconnectOptions = { /** - * Run Daily's client-side Krisp noise cancellation on the microphone. - * Defaults to the value passed to the last `start()`, or true. + * Client-side only: 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. */ noiseCancellationEnabled?: boolean; } From 47561ad22dfd7517acf3110612150b00bf8453f4 Mon Sep 17 00:00:00 2001 From: Murat Toygar Date: Fri, 30 Oct 2026 11:46:51 +0000 Subject: [PATCH 3/4] refactor: rename the option to clientSideNoiseCancellationEnabled Co-Authored-By: Claude Opus 5.5 (1M context) --- __tests__/vapi.test.ts | 14 +++++++------- vapi.ts | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index 157706329..4ffe5c66f 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -568,7 +568,7 @@ describe("Vapi local audio level observer", () => { }); }); -describe("Vapi noiseCancellationEnabled", () => { +describe("Vapi clientSideNoiseCancellationEnabled", () => { const webCall = { id: "call_test", webCallUrl: "https://example.daily.co/test", @@ -581,7 +581,7 @@ describe("Vapi noiseCancellationEnabled", () => { mockDailyCall = null; }); - function startCall(vapi: Vapi, options?: { noiseCancellationEnabled?: boolean }) { + function startCall(vapi: Vapi, options?: { clientSideNoiseCancellationEnabled?: boolean }) { return vapi.start( "dummy_assistant_id", undefined, @@ -608,7 +608,7 @@ describe("Vapi noiseCancellationEnabled", () => { const vapi = new Vapi("dummy_token"); vapi.on("local-volume-level", () => {}); - const call = await startCall(vapi, { noiseCancellationEnabled: false }); + const call = await startCall(vapi, { clientSideNoiseCancellationEnabled: false }); await flushRejections(); expect(call).not.toBeNull(); @@ -620,7 +620,7 @@ describe("Vapi noiseCancellationEnabled", () => { it("keeps the value from start() on reconnect()", async () => { mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); const vapi = new Vapi("dummy_token"); - await startCall(vapi, { noiseCancellationEnabled: false }); + await startCall(vapi, { clientSideNoiseCancellationEnabled: false }); await vapi.stop(); mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); @@ -632,11 +632,11 @@ describe("Vapi noiseCancellationEnabled", () => { it("lets reconnect() override the value from start()", async () => { mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); const vapi = new Vapi("dummy_token"); - await startCall(vapi, { noiseCancellationEnabled: false }); + await startCall(vapi, { clientSideNoiseCancellationEnabled: false }); await vapi.stop(); mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); - await vapi.reconnect(webCall, { noiseCancellationEnabled: true }); + await vapi.reconnect(webCall, { clientSideNoiseCancellationEnabled: true }); expect(mockDailyCall.updateInputSettings).toHaveBeenCalledWith( noiseCancellation @@ -658,7 +658,7 @@ describe("Vapi noiseCancellationEnabled", () => { mockDailyCall = createMockDailyCall(jest.fn().mockResolvedValue(undefined)); const vapi = new Vapi("dummy_token"); - await vapi.reconnect(webCall, { noiseCancellationEnabled: false }); + await vapi.reconnect(webCall, { clientSideNoiseCancellationEnabled: false }); expect(mockDailyCall.updateInputSettings).not.toHaveBeenCalled(); }); diff --git a/vapi.ts b/vapi.ts index a9676c8b8..b19c9b7ea 100644 --- a/vapi.ts +++ b/vapi.ts @@ -209,14 +209,14 @@ type StartCallOptions = { /** Acquire the microphone before creating a backend call. Defaults to true. */ microphonePreflightEnabled?: boolean; /** - * Client-side only: run Daily's Krisp noise cancellation on the microphone + * 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. */ - noiseCancellationEnabled?: boolean; + 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. @@ -230,13 +230,13 @@ type StartCallOptions = { type ReconnectOptions = { /** - * Client-side only: run Daily's Krisp noise cancellation on the microphone + * 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. */ - noiseCancellationEnabled?: boolean; + clientSideNoiseCancellationEnabled?: boolean; } type WebCall = { @@ -380,7 +380,7 @@ export default class Vapi extends VapiEventEmitter { private dailyCallObject: DailyFactoryOptions = {}; private microphoneTrack: MediaStreamTrack | null = null; private cancelMicrophonePreflight: (() => void) | null = null; - private noiseCancellationEnabled: boolean = true; + private clientSideNoiseCancellationEnabled: boolean = true; private async microphoneAcquire(): Promise { const source = this.dailyCallObject.audioSource; @@ -556,7 +556,7 @@ export default class Vapi extends VapiEventEmitter { }); this.started = true; - this.noiseCancellationEnabled = options?.noiseCancellationEnabled ?? true; + this.clientSideNoiseCancellationEnabled = options?.clientSideNoiseCancellationEnabled ?? true; let failureStage = 'microphone-preflight'; try { @@ -995,7 +995,7 @@ export default class Vapi extends VapiEventEmitter { try { // Daily's default processor is 'none', so skipping the update leaves // the raw microphone track in place and avoids loading Krisp at all. - const audioProcessingUpdate = this.noiseCancellationEnabled + const audioProcessingUpdate = this.clientSideNoiseCancellationEnabled ? this.call.updateInputSettings({ audio: { processor: { @@ -1539,8 +1539,8 @@ export default class Vapi extends VapiEventEmitter { throw new Error('webCallUrl is required for reconnection.'); } - if (options?.noiseCancellationEnabled !== undefined) { - this.noiseCancellationEnabled = options.noiseCancellationEnabled; + if (options?.clientSideNoiseCancellationEnabled !== undefined) { + this.clientSideNoiseCancellationEnabled = options.clientSideNoiseCancellationEnabled; } this.emit('call-start-progress', { @@ -1905,7 +1905,7 @@ export default class Vapi extends VapiEventEmitter { try { // Daily's default processor is 'none', so skipping the update leaves // the raw microphone track in place and avoids loading Krisp at all. - const audioProcessingUpdate = this.noiseCancellationEnabled + const audioProcessingUpdate = this.clientSideNoiseCancellationEnabled ? this.call.updateInputSettings({ audio: { processor: { From d8d6c29cb74ad4f9c65cebfe42557d13e9afaca2 Mon Sep 17 00:00:00 2001 From: Murat Toygar Date: Fri, 30 Oct 2026 12:01:26 +0000 Subject: [PATCH 4/4] refactor: skip the audio processing stage when noise cancellation is off With it off, start() and reconnect() no longer report an audio-processing-setup stage that never ran. Co-Authored-By: Claude Opus 5.5 (1M context) --- __tests__/vapi.test.ts | 13 +++ vapi.ts | 216 +++++++++++++++++++++-------------------- 2 files changed, 123 insertions(+), 106 deletions(-) diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index 4ffe5c66f..26d5b887d 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -617,6 +617,19 @@ describe("Vapi clientSideNoiseCancellationEnabled", () => { 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"); diff --git a/vapi.ts b/vapi.ts index b19c9b7ea..bbd2bc76f 100644 --- a/vapi.ts +++ b/vapi.ts @@ -983,67 +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 { - // Daily's default processor is 'none', so skipping the update leaves - // the raw microphone track in place and avoids loading Krisp at all. - const audioProcessingUpdate = this.clientSideNoiseCancellationEnabled - ? this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', - }, + try { + const audioProcessingUpdate = this.call.updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', }, - }) - : Promise.resolve(); + }, + }); - 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; @@ -1893,61 +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 { - // Daily's default processor is 'none', so skipping the update leaves - // the raw microphone track in place and avoids loading Krisp at all. - const audioProcessingUpdate = this.clientSideNoiseCancellationEnabled - ? this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', - }, + try { + const audioProcessingUpdate = this.call.updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', }, - }) - : Promise.resolve(); + }, + }); - 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;