From bcfa1bac814a0f076319d66bfa8fed0e47a73b5e Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 2 Apr 2026 16:11:22 +0200 Subject: [PATCH 01/28] fix: add handleConstraintsRequired handler for audio constraints fix --- src/media/local-stream.spec.ts | 107 +++++++++++++++++++++++++++++++++ src/media/local-stream.ts | 46 ++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index 5d52e03..7179d76 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -1,4 +1,5 @@ import { WebrtcCoreError } from '../errors'; +import * as media from '.'; import { createMockedStream } from '../util/test-utils'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; @@ -187,6 +188,112 @@ describe('LocalStream', () => { }); }); + describe('handleConstraintsRequired', () => { + const audioSettings: MediaTrackSettings = { + deviceId: 'test-device-id', + sampleRate: 48000, + channelCount: 1, + sampleSize: 16, + echoCancellation: true, + autoGainControl: true, + noiseSuppression: true, + }; + + let effect: TrackEffect; + let constraintsHandler: (constraints: MediaTrackConstraints) => Promise; + let getUserMediaSpy: jest.SpyInstance; + let newAudioTrack: MediaStreamTrack; + + beforeEach(async () => { + const inputTrack = mockStream.getTracks()[0]; + jest.spyOn(inputTrack, 'getSettings').mockReturnValue(audioSettings); + + const eventHandlers = new Map void>(); + effect = { + id: 'nr-effect', + kind: 'noise-reduction', + isEnabled: false, + dispose: jest.fn().mockResolvedValue(undefined), + load: jest.fn().mockResolvedValue(undefined), + replaceInputTrack: jest.fn().mockResolvedValue(undefined), + on: jest.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + eventHandlers.set(event, handler); + }), + off: jest.fn(), + } as unknown as TrackEffect; + + const newMockStream = createMockedStream(); + [newAudioTrack] = newMockStream.getTracks(); + (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); + + getUserMediaSpy = jest.spyOn(media, 'getUserMedia').mockResolvedValue(newMockStream); + + await localStream.addEffect(effect); + constraintsHandler = eventHandlers.get('constraints-required') as ( + constraints: MediaTrackConstraints + ) => Promise; + }); + + afterEach(() => { + getUserMediaSpy.mockRestore(); + }); + + it('should call getUserMedia with old settings and effect constraints', async () => { + expect.hasAssertions(); + + await constraintsHandler({ autoGainControl: false, noiseSuppression: false }); + + expect(getUserMediaSpy).toHaveBeenCalledWith({ + audio: { + deviceId: { exact: 'test-device-id' }, + sampleRate: 48000, + channelCount: 1, + sampleSize: 16, + echoCancellation: true, + autoGainControl: false, + noiseSuppression: false, + }, + }); + }); + + it('should call getUserMedia with old settings when constraints are empty', async () => { + expect.hasAssertions(); + + await constraintsHandler({}); + + expect(getUserMediaSpy).toHaveBeenCalledWith({ + audio: { + deviceId: { exact: 'test-device-id' }, + sampleRate: 48000, + channelCount: 1, + sampleSize: 16, + echoCancellation: true, + autoGainControl: true, + noiseSuppression: true, + }, + }); + }); + + it('should replace the input track on the first effect', async () => { + expect.hasAssertions(); + + await constraintsHandler({ autoGainControl: false }); + + expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); + }); + + it('should stop the old track', async () => { + expect.hasAssertions(); + + const oldTrack = mockStream.getTracks()[0]; + const stopSpy = jest.spyOn(oldTrack, 'stop'); + + await constraintsHandler({ autoGainControl: false }); + + expect(stopSpy).toHaveBeenCalledWith(); + }); + }); + describe('toJSON', () => { it('should correctly serialize data', () => { expect.assertions(1); diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index 8acf3e9..0d0fe73 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -1,6 +1,7 @@ import { AddEvents, TypedEvent, WithEventsDummyType } from '@webex/ts-events'; import { BaseEffect, EffectEvent } from '@webex/web-media-effects'; import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors'; +import { getUserMedia } from '.'; import { logger } from '../util/logger'; import { Stream, StreamEventNames } from './stream'; @@ -267,12 +268,56 @@ abstract class _LocalStream extends Stream { } }; + /** + * Handle when an effect requests specific constraints on the input track. + * + * Re-acquires the mic track via getUserMedia with the desired constraints, + * since MediaStreamTrack.applyConstraints() is silently ignored by Chrome + * for audio processing constraints. + * See https://issues.chromium.org/issues/40555809. + * + * @param constraints - The constraints requested by the effect. + */ + const handleConstraintsRequired = async (constraints: MediaTrackConstraints) => { + logger.log(`Effect ${effect.id} constraints required:`, constraints); + + try { + const oldTrack = this.inputTrack; + const oldSettings = oldTrack.getSettings(); + + const newStream = await getUserMedia({ + audio: { + ...oldSettings, + deviceId: oldSettings.deviceId ? { exact: oldSettings.deviceId } : undefined, + ...constraints, + }, + }); + const [newTrack] = newStream.getAudioTracks(); + + this.removeTrackHandlers(oldTrack); + this.inputStream.removeTrack(oldTrack); + this.inputStream.addTrack(newTrack); + this.addTrackHandlers(newTrack); + + if (this.effects.length > 0) { + await this.effects[0].replaceInputTrack(newTrack); + } + + oldTrack.stop(); + this[LocalStreamEventNames.ConstraintsChange].emit(); + logger.log(`Effect constraints applied via track re-acquisition.`); + } catch (err: unknown) { + logger.error(`Failed to re-acquire track with required constraints:`, err); + } + }; + /** * Handle when the effect has been disposed. This will remove all event listeners from the * effect. */ const handleEffectDisposed = () => { effect.off('track-updated' as EffectEvent, handleEffectTrackUpdated); + effect.off('constraints-required' as EffectEvent, handleConstraintsRequired as never); effect.off('disposed' as EffectEvent, handleEffectDisposed); }; @@ -280,6 +325,7 @@ abstract class _LocalStream extends Stream { // web-media-effects lib to be rebuilt and inflates the size of the webrtc-core build, so // we use type assertion here as a temporary workaround. effect.on('track-updated' as EffectEvent, handleEffectTrackUpdated); + effect.on('constraints-required' as EffectEvent, handleConstraintsRequired as never); effect.on('disposed' as EffectEvent, handleEffectDisposed); // Add the effect to the effects list. If an effect of the same kind has already been added, From e903658ce5ffdd70be61518a16f33fa918307ecb Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 3 Apr 2026 17:50:21 +0200 Subject: [PATCH 02/28] fix: update handleConstraintsRequired --- src/media/local-stream.spec.ts | 99 +++++++++++++++++++++++++++++++--- src/media/local-stream.ts | 60 ++++++++++++++++++--- 2 files changed, 144 insertions(+), 15 deletions(-) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index 7179d76..44dab61 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -256,24 +256,65 @@ describe('LocalStream', () => { }); }); - it('should call getUserMedia with old settings when constraints are empty', async () => { + it('should skip re-acquisition when constraints are empty and nothing saved', async () => { expect.hasAssertions(); await constraintsHandler({}); + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it('should skip re-acquisition when constraints are already satisfied', async () => { + expect.hasAssertions(); + + await constraintsHandler({ autoGainControl: true, noiseSuppression: true }); + + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it('should restore saved user constraints when empty constraints are received', async () => { + expect.hasAssertions(); + + await constraintsHandler({ autoGainControl: false, noiseSuppression: false }); + getUserMediaSpy.mockClear(); + + (mockStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); + jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ + ...audioSettings, + autoGainControl: false, + noiseSuppression: false, + }); + + await constraintsHandler({}); + expect(getUserMediaSpy).toHaveBeenCalledWith({ - audio: { - deviceId: { exact: 'test-device-id' }, - sampleRate: 48000, - channelCount: 1, - sampleSize: 16, - echoCancellation: true, + audio: expect.objectContaining({ autoGainControl: true, noiseSuppression: true, - }, + }), }); }); + it('should not restore a second time after saved constraints are cleared', async () => { + expect.hasAssertions(); + + await constraintsHandler({ autoGainControl: false }); + getUserMediaSpy.mockClear(); + + (mockStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); + jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ + ...audioSettings, + autoGainControl: false, + }); + + await constraintsHandler({}); + getUserMediaSpy.mockClear(); + + await constraintsHandler({}); + + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + it('should replace the input track on the first effect', async () => { expect.hasAssertions(); @@ -292,6 +333,48 @@ describe('LocalStream', () => { expect(stopSpy).toHaveBeenCalledWith(); }); + + it('should remove track handlers before stopping the old track', async () => { + expect.hasAssertions(); + + const oldTrack = mockStream.getTracks()[0]; + const callOrder: string[] = []; + + jest.spyOn(oldTrack, 'removeEventListener').mockImplementation(() => { + callOrder.push('removeEventListener'); + }); + jest.spyOn(oldTrack, 'stop').mockImplementation(() => { + callOrder.push('stop'); + }); + + await constraintsHandler({ autoGainControl: false }); + + const firstRemove = callOrder.indexOf('removeEventListener'); + const firstStop = callOrder.indexOf('stop'); + expect(firstRemove).toBeGreaterThanOrEqual(0); + expect(firstStop).toBeGreaterThan(firstRemove); + }); + + it('should stop the old track before calling getUserMedia', async () => { + expect.hasAssertions(); + + const oldTrack = mockStream.getTracks()[0]; + const callOrder: string[] = []; + + jest.spyOn(oldTrack, 'stop').mockImplementation(() => { + callOrder.push('stop'); + }); + getUserMediaSpy.mockImplementation(async () => { + callOrder.push('getUserMedia'); + const stream = createMockedStream(); + (stream.getAudioTracks as jest.Mock).mockReturnValue(stream.getTracks()); + return stream; + }); + + await constraintsHandler({ autoGainControl: false }); + + expect(callOrder).toStrictEqual(['stop', 'getUserMedia']); + }); }); describe('toJSON', () => { diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index 0d0fe73..d4d0566 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -268,12 +268,25 @@ abstract class _LocalStream extends Stream { } }; + /** + * Track settings saved before the effect changed them, keyed by constraint + * property name. Used to restore the user's original values when the effect + * emits empty constraints (disable / dispose / model switch to one with no + * special requirements). + */ + let savedConstraints: Record = {}; + /** * Handle when an effect requests specific constraints on the input track. * - * Re-acquires the mic track via getUserMedia with the desired constraints, - * since MediaStreamTrack.applyConstraints() is silently ignored by Chrome - * for audio processing constraints. + * Non-empty constraints: save the current values for those properties, then + * re-acquire the mic track with the requested constraints. + * + * Empty constraints ({}): restore the previously saved values so the track + * returns to the user's original settings. + * + * Re-acquires via getUserMedia because MediaStreamTrack.applyConstraints() + * is silently ignored by Chrome for audio processing constraints. * See https://issues.chromium.org/issues/40555809. * * @param constraints - The constraints requested by the effect. @@ -282,19 +295,54 @@ abstract class _LocalStream extends Stream { logger.log(`Effect ${effect.id} constraints required:`, constraints); try { + const isEmptyConstraints = !Object.keys(constraints).length; + + let constraintsToApply: MediaTrackConstraints; + + if (isEmptyConstraints) { + if (!Object.keys(savedConstraints).length) { + logger.log(`No constraints to restore, skipping re-acquisition.`); + return; + } + constraintsToApply = { ...savedConstraints } as MediaTrackConstraints; + savedConstraints = {}; + logger.log(`Restoring saved constraints:`, constraintsToApply); + } else { + constraintsToApply = constraints; + } + const oldTrack = this.inputTrack; const oldSettings = oldTrack.getSettings(); + const entriesToApply = Object.entries(constraintsToApply); + const alreadySatisfied = entriesToApply.every( + ([key, value]) => oldSettings[key as keyof MediaTrackSettings] === value + ); + if (alreadySatisfied) { + logger.log(`Effect constraints already satisfied, skipping re-acquisition.`); + return; + } + + if (!isEmptyConstraints) { + Object.keys(constraints).forEach((key) => { + if (!(key in savedConstraints)) { + savedConstraints[key] = oldSettings[key as keyof MediaTrackSettings]; + } + }); + } + + this.removeTrackHandlers(oldTrack); + oldTrack.stop(); + const newStream = await getUserMedia({ audio: { ...oldSettings, + ...constraintsToApply, deviceId: oldSettings.deviceId ? { exact: oldSettings.deviceId } : undefined, - ...constraints, }, }); const [newTrack] = newStream.getAudioTracks(); - this.removeTrackHandlers(oldTrack); this.inputStream.removeTrack(oldTrack); this.inputStream.addTrack(newTrack); this.addTrackHandlers(newTrack); @@ -302,8 +350,6 @@ abstract class _LocalStream extends Stream { if (this.effects.length > 0) { await this.effects[0].replaceInputTrack(newTrack); } - - oldTrack.stop(); this[LocalStreamEventNames.ConstraintsChange].emit(); logger.log(`Effect constraints applied via track re-acquisition.`); } catch (err: unknown) { From 54f77fd9d6b51b8015d714e79ff76ca84367fc57 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 3 Apr 2026 18:10:48 +0200 Subject: [PATCH 03/28] fix: update recoveryStream flow --- src/media/local-stream.ts | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index d4d0566..b892c31 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -334,14 +334,31 @@ abstract class _LocalStream extends Stream { this.removeTrackHandlers(oldTrack); oldTrack.stop(); - const newStream = await getUserMedia({ - audio: { - ...oldSettings, - ...constraintsToApply, - deviceId: oldSettings.deviceId ? { exact: oldSettings.deviceId } : undefined, - }, - }); - const [newTrack] = newStream.getAudioTracks(); + let newTrack: MediaStreamTrack; + + try { + const newStream = await getUserMedia({ + audio: { + ...oldSettings, + ...constraintsToApply, + deviceId: oldSettings.deviceId ? { exact: oldSettings.deviceId } : undefined, + }, + }); + [newTrack] = newStream.getAudioTracks(); + } catch (acquireErr) { + logger.warn( + `Failed to re-acquire track with effect constraints, recovering:`, + acquireErr + ); + const recoveryStream = await getUserMedia({ + audio: { + ...oldSettings, + deviceId: oldSettings.deviceId ? { exact: oldSettings.deviceId } : undefined, + }, + }); + [newTrack] = recoveryStream.getAudioTracks(); + savedConstraints = {}; + } this.inputStream.removeTrack(oldTrack); this.inputStream.addTrack(newTrack); @@ -353,7 +370,9 @@ abstract class _LocalStream extends Stream { this[LocalStreamEventNames.ConstraintsChange].emit(); logger.log(`Effect constraints applied via track re-acquisition.`); } catch (err: unknown) { - logger.error(`Failed to re-acquire track with required constraints:`, err); + logger.error(`Failed to re-acquire track after constraint change:`, err); + savedConstraints = {}; + this[StreamEventNames.Ended].emit(); } }; From 0769169e4f22cbacb89949b3ed445412004c73fe Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 3 Apr 2026 18:17:55 +0200 Subject: [PATCH 04/28] fix: update newTrack.enabled --- src/media/local-stream.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index b892c31..e465ce4 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -292,6 +292,11 @@ abstract class _LocalStream extends Stream { * @param constraints - The constraints requested by the effect. */ const handleConstraintsRequired = async (constraints: MediaTrackConstraints) => { + if (!this.effects.includes(effect)) { + logger.log(`Effect ${effect.id} is no longer active, ignoring constraints-required.`); + return; + } + logger.log(`Effect ${effect.id} constraints required:`, constraints); try { @@ -313,6 +318,7 @@ abstract class _LocalStream extends Stream { const oldTrack = this.inputTrack; const oldSettings = oldTrack.getSettings(); + const wasEnabled = oldTrack.enabled; const entriesToApply = Object.entries(constraintsToApply); const alreadySatisfied = entriesToApply.every( @@ -360,6 +366,7 @@ abstract class _LocalStream extends Stream { savedConstraints = {}; } + newTrack.enabled = wasEnabled; this.inputStream.removeTrack(oldTrack); this.inputStream.addTrack(newTrack); this.addTrackHandlers(newTrack); From ed4b73969e14700f8983023d196f34246b803cd9 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Tue, 7 Apr 2026 20:27:05 +0200 Subject: [PATCH 05/28] fix: update logic, namings, tests --- src/media/local-stream.spec.ts | 114 +++++++++++++++++++++++++-------- src/media/local-stream.ts | 100 ++++++++++++++--------------- src/util/test-utils.ts | 18 ++++++ 3 files changed, 153 insertions(+), 79 deletions(-) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index 44dab61..d5ccb48 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -1,7 +1,8 @@ import { WebrtcCoreError } from '../errors'; import * as media from '.'; -import { createMockedStream } from '../util/test-utils'; +import { createMockedAudioStream, createMockedStream } from '../util/test-utils'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; +import { StreamEventNames } from './stream'; /** * A dummy LocalStream implementation, so we can instantiate it for testing. @@ -199,13 +200,18 @@ describe('LocalStream', () => { noiseSuppression: true, }; + let audioStream: MediaStream; + let audioLocalStream: LocalStream; let effect: TrackEffect; let constraintsHandler: (constraints: MediaTrackConstraints) => Promise; let getUserMediaSpy: jest.SpyInstance; let newAudioTrack: MediaStreamTrack; beforeEach(async () => { - const inputTrack = mockStream.getTracks()[0]; + audioStream = createMockedAudioStream(); + audioLocalStream = new TestLocalStream(audioStream); + + const inputTrack = audioStream.getTracks()[0]; jest.spyOn(inputTrack, 'getSettings').mockReturnValue(audioSettings); const eventHandlers = new Map void>(); @@ -222,13 +228,13 @@ describe('LocalStream', () => { off: jest.fn(), } as unknown as TrackEffect; - const newMockStream = createMockedStream(); + const newMockStream = createMockedAudioStream(); [newAudioTrack] = newMockStream.getTracks(); (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); getUserMediaSpy = jest.spyOn(media, 'getUserMedia').mockResolvedValue(newMockStream); - await localStream.addEffect(effect); + await audioLocalStream.addEffect(effect); constraintsHandler = eventHandlers.get('constraints-required') as ( constraints: MediaTrackConstraints ) => Promise; @@ -238,7 +244,7 @@ describe('LocalStream', () => { getUserMediaSpy.mockRestore(); }); - it('should call getUserMedia with old settings and effect constraints', async () => { + it('should call getUserMedia with current settings and effect constraints', async () => { expect.hasAssertions(); await constraintsHandler({ autoGainControl: false, noiseSuppression: false }); @@ -278,7 +284,7 @@ describe('LocalStream', () => { await constraintsHandler({ autoGainControl: false, noiseSuppression: false }); getUserMediaSpy.mockClear(); - (mockStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); + (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ ...audioSettings, autoGainControl: false, @@ -301,7 +307,7 @@ describe('LocalStream', () => { await constraintsHandler({ autoGainControl: false }); getUserMediaSpy.mockClear(); - (mockStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); + (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ ...audioSettings, autoGainControl: false, @@ -323,27 +329,16 @@ describe('LocalStream', () => { expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); }); - it('should stop the old track', async () => { - expect.hasAssertions(); - - const oldTrack = mockStream.getTracks()[0]; - const stopSpy = jest.spyOn(oldTrack, 'stop'); - - await constraintsHandler({ autoGainControl: false }); - - expect(stopSpy).toHaveBeenCalledWith(); - }); - - it('should remove track handlers before stopping the old track', async () => { + it('should remove track handlers before stopping the current track', async () => { expect.hasAssertions(); - const oldTrack = mockStream.getTracks()[0]; + const currentTrack = audioStream.getTracks()[0]; const callOrder: string[] = []; - jest.spyOn(oldTrack, 'removeEventListener').mockImplementation(() => { + jest.spyOn(currentTrack, 'removeEventListener').mockImplementation(() => { callOrder.push('removeEventListener'); }); - jest.spyOn(oldTrack, 'stop').mockImplementation(() => { + jest.spyOn(currentTrack, 'stop').mockImplementation(() => { callOrder.push('stop'); }); @@ -355,26 +350,89 @@ describe('LocalStream', () => { expect(firstStop).toBeGreaterThan(firstRemove); }); - it('should stop the old track before calling getUserMedia', async () => { + it('should stop the current track before calling getUserMedia', async () => { expect.hasAssertions(); - const oldTrack = mockStream.getTracks()[0]; + const currentTrack = audioStream.getTracks()[0]; const callOrder: string[] = []; - jest.spyOn(oldTrack, 'stop').mockImplementation(() => { + jest.spyOn(currentTrack, 'stop').mockImplementation(() => { callOrder.push('stop'); }); getUserMediaSpy.mockImplementation(async () => { callOrder.push('getUserMedia'); - const stream = createMockedStream(); - (stream.getAudioTracks as jest.Mock).mockReturnValue(stream.getTracks()); - return stream; + return createMockedAudioStream(); }); await constraintsHandler({ autoGainControl: false }); expect(callOrder).toStrictEqual(['stop', 'getUserMedia']); }); + + it('should fall back to getUserMedia without effect constraints when first call fails', async () => { + expect.hasAssertions(); + + const fallbackStream = createMockedAudioStream(); + getUserMediaSpy + .mockRejectedValueOnce(new Error('OverconstrainedError')) + .mockResolvedValueOnce(fallbackStream); + + await constraintsHandler({ autoGainControl: false }); + + expect(getUserMediaSpy).toHaveBeenCalledTimes(2); + expect(getUserMediaSpy).toHaveBeenLastCalledWith({ + audio: { + deviceId: { exact: 'test-device-id' }, + sampleRate: 48000, + channelCount: 1, + sampleSize: 16, + echoCancellation: true, + autoGainControl: true, + noiseSuppression: true, + }, + }); + }); + + it('should emit Ended when both getUserMedia calls fail', async () => { + expect.hasAssertions(); + + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + + getUserMediaSpy + .mockRejectedValueOnce(new Error('OverconstrainedError')) + .mockRejectedValueOnce(new Error('NotFoundError')); + + await constraintsHandler({ autoGainControl: false }); + + expect(getUserMediaSpy).toHaveBeenCalledTimes(2); + expect(endedSpy).toHaveBeenCalledWith(); + }); + + it('should not register constraints-required handler for video tracks', async () => { + expect.hasAssertions(); + + const videoStream = createMockedStream(); + const videoLocalStream = new TestLocalStream(videoStream); + + const videoEventHandlers = new Map void>(); + const videoEffect = { + id: 'video-effect', + kind: 'video-kind', + isEnabled: false, + dispose: jest.fn().mockResolvedValue(undefined), + load: jest.fn().mockResolvedValue(undefined), + on: jest.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + videoEventHandlers.set(event, handler); + }), + off: jest.fn(), + } as unknown as TrackEffect; + + await videoLocalStream.addEffect(videoEffect); + + expect(videoEventHandlers.has('constraints-required')).toBe(false); + expect(videoEventHandlers.has('track-updated')).toBe(true); + expect(videoEventHandlers.has('disposed')).toBe(true); + }); }); describe('toJSON', () => { diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index e465ce4..dd33b2a 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -274,13 +274,13 @@ abstract class _LocalStream extends Stream { * emits empty constraints (disable / dispose / model switch to one with no * special requirements). */ - let savedConstraints: Record = {}; + let savedTrackSettings: MediaTrackSettings = {}; /** - * Handle when an effect requests specific constraints on the input track. + * Handle when an audio effect requests specific constraints on the input track. * * Non-empty constraints: save the current values for those properties, then - * re-acquire the mic track with the requested constraints. + * re-acquire the audio track with the requested constraints. * * Empty constraints ({}): restore the previously saved values so the track * returns to the user's original settings. @@ -293,7 +293,7 @@ abstract class _LocalStream extends Stream { */ const handleConstraintsRequired = async (constraints: MediaTrackConstraints) => { if (!this.effects.includes(effect)) { - logger.log(`Effect ${effect.id} is no longer active, ignoring constraints-required.`); + logger.log(`Effect ${effect.id} not in effects list, ignoring constraints-required.`); return; } @@ -305,69 +305,63 @@ abstract class _LocalStream extends Stream { let constraintsToApply: MediaTrackConstraints; if (isEmptyConstraints) { - if (!Object.keys(savedConstraints).length) { - logger.log(`No constraints to restore, skipping re-acquisition.`); + if (!Object.keys(savedTrackSettings).length) { + logger.log(`No settings to restore, skipping re-acquisition.`); return; } - constraintsToApply = { ...savedConstraints } as MediaTrackConstraints; - savedConstraints = {}; - logger.log(`Restoring saved constraints:`, constraintsToApply); + constraintsToApply = { ...savedTrackSettings }; + savedTrackSettings = {}; + logger.log(`Restoring saved settings:`, constraintsToApply); } else { constraintsToApply = constraints; } - const oldTrack = this.inputTrack; - const oldSettings = oldTrack.getSettings(); - const wasEnabled = oldTrack.enabled; + const currentTrack = this.inputTrack; + const currentSettings = currentTrack.getSettings(); + const isEnabled = currentTrack.enabled; - const entriesToApply = Object.entries(constraintsToApply); - const alreadySatisfied = entriesToApply.every( - ([key, value]) => oldSettings[key as keyof MediaTrackSettings] === value + const constraintEntries = Object.entries(constraintsToApply); + const isAlreadySatisfied = constraintEntries.every( + ([key, value]) => currentSettings[key as keyof MediaTrackSettings] === value ); - if (alreadySatisfied) { + if (isAlreadySatisfied) { logger.log(`Effect constraints already satisfied, skipping re-acquisition.`); return; } if (!isEmptyConstraints) { Object.keys(constraints).forEach((key) => { - if (!(key in savedConstraints)) { - savedConstraints[key] = oldSettings[key as keyof MediaTrackSettings]; + if (!(key in savedTrackSettings)) { + Object.assign(savedTrackSettings, { + [key]: currentSettings[key as keyof MediaTrackSettings], + }); } }); } - this.removeTrackHandlers(oldTrack); - oldTrack.stop(); + this.removeTrackHandlers(currentTrack); + currentTrack.stop(); - let newTrack: MediaStreamTrack; + const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; - try { - const newStream = await getUserMedia({ - audio: { - ...oldSettings, - ...constraintsToApply, - deviceId: oldSettings.deviceId ? { exact: oldSettings.deviceId } : undefined, - }, - }); - [newTrack] = newStream.getAudioTracks(); - } catch (acquireErr) { - logger.warn( - `Failed to re-acquire track with effect constraints, recovering:`, - acquireErr - ); - const recoveryStream = await getUserMedia({ - audio: { - ...oldSettings, - deviceId: oldSettings.deviceId ? { exact: oldSettings.deviceId } : undefined, - }, + let newStream = await getUserMedia({ + audio: { ...currentSettings, ...constraintsToApply, deviceId }, + }).catch((err) => { + logger.warn(`Failed to re-acquire track with effect constraints, recovering:`, err); + return null; + }); + + if (!newStream) { + newStream = await getUserMedia({ + audio: { ...currentSettings, deviceId }, }); - [newTrack] = recoveryStream.getAudioTracks(); - savedConstraints = {}; + savedTrackSettings = {}; } - newTrack.enabled = wasEnabled; - this.inputStream.removeTrack(oldTrack); + const [newTrack] = newStream.getAudioTracks(); + + newTrack.enabled = isEnabled; + this.inputStream.removeTrack(currentTrack); this.inputStream.addTrack(newTrack); this.addTrackHandlers(newTrack); @@ -378,7 +372,7 @@ abstract class _LocalStream extends Stream { logger.log(`Effect constraints applied via track re-acquisition.`); } catch (err: unknown) { logger.error(`Failed to re-acquire track after constraint change:`, err); - savedConstraints = {}; + savedTrackSettings = {}; this[StreamEventNames.Ended].emit(); } }; @@ -388,17 +382,21 @@ abstract class _LocalStream extends Stream { * effect. */ const handleEffectDisposed = () => { - effect.off('track-updated' as EffectEvent, handleEffectTrackUpdated); - effect.off('constraints-required' as EffectEvent, handleConstraintsRequired as never); - effect.off('disposed' as EffectEvent, handleEffectDisposed); + effect.off(EffectEvent.TrackUpdated, handleEffectTrackUpdated); + if (this.outputTrack.kind === 'audio') { + effect.off(EffectEvent.ConstraintsRequired, handleConstraintsRequired); + } + effect.off(EffectEvent.Disposed, handleEffectDisposed); }; // TODO: using EffectEvent.TrackUpdated or EffectEvent.Disposed will cause the entire // web-media-effects lib to be rebuilt and inflates the size of the webrtc-core build, so // we use type assertion here as a temporary workaround. - effect.on('track-updated' as EffectEvent, handleEffectTrackUpdated); - effect.on('constraints-required' as EffectEvent, handleConstraintsRequired as never); - effect.on('disposed' as EffectEvent, handleEffectDisposed); + effect.on(EffectEvent.TrackUpdated, handleEffectTrackUpdated); + if (this.outputTrack.kind === 'audio') { + effect.on(EffectEvent.ConstraintsRequired, handleConstraintsRequired); + } + effect.on(EffectEvent.Disposed, handleEffectDisposed); // Add the effect to the effects list. If an effect of the same kind has already been added, // dispose the existing effect and replace it with the new effect. If the existing effect was diff --git a/src/util/test-utils.ts b/src/util/test-utils.ts index a9b0366..64e9e9a 100644 --- a/src/util/test-utils.ts +++ b/src/util/test-utils.ts @@ -2,6 +2,7 @@ import MediaStreamStub from '../mocks/media-stream-stub'; import MediaStreamTrackStub from '../mocks/media-stream-track-stub'; import { mocked } from '../mocks/mock'; +import { MediaStreamTrackKind } from '../peer-connection'; jest.mock('../mocks/media-stream-stub'); jest.mock('../mocks/media-stream-track-stub'); @@ -72,5 +73,22 @@ const createMockedVideoTrack = (width: number, height: number): MediaStreamTrack */ const createMockedAudioTrack = (): MediaStreamTrack => { const track = mocked(new MediaStreamTrackStub()); + track.kind = MediaStreamTrackKind.Audio; return track as unknown as MediaStreamTrack; }; + +/** + * Create a mocked stream with a single audio MediaStreamTrack. + * + * @returns A Mocked MediaStreamStub type coerced to a MediaStream. + */ +export const createMockedAudioStream = (): MediaStream => { + const mockStream = mocked(new MediaStreamStub()); + const audioTrack = createMockedAudioTrack(); + + mockStream.getVideoTracks.mockReturnValue([]); + mockStream.getAudioTracks.mockReturnValue([audioTrack]); + mockStream.getTracks.mockReturnValue([audioTrack]); + + return mockStream as unknown as MediaStream; +}; From 45268c4b4f8d1c825d116e0550fcadc9a9b5bce2 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Tue, 7 Apr 2026 20:43:44 +0200 Subject: [PATCH 06/28] fix: update logic with readyState --- src/media/local-stream.spec.ts | 11 +++++++++++ src/media/local-stream.ts | 5 +++++ src/mocks/media-stream-track-stub.ts | 1 + 3 files changed, 17 insertions(+) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index d5ccb48..dc7a168 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -408,6 +408,17 @@ describe('LocalStream', () => { expect(endedSpy).toHaveBeenCalledWith(); }); + it('should skip re-acquisition when the track is already ended', async () => { + expect.hasAssertions(); + + const currentTrack = audioStream.getTracks()[0]; + (currentTrack as { readyState: string }).readyState = 'ended'; + + await constraintsHandler({ autoGainControl: false }); + + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + it('should not register constraints-required handler for video tracks', async () => { expect.hasAssertions(); diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index dd33b2a..e3aebd4 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -297,6 +297,11 @@ abstract class _LocalStream extends Stream { return; } + if (this.inputTrack.readyState === 'ended') { + logger.log(`Track already ended, ignoring constraints-required.`); + return; + } + logger.log(`Effect ${effect.id} constraints required:`, constraints); try { diff --git a/src/mocks/media-stream-track-stub.ts b/src/mocks/media-stream-track-stub.ts index ad0dc04..70649f0 100644 --- a/src/mocks/media-stream-track-stub.ts +++ b/src/mocks/media-stream-track-stub.ts @@ -9,6 +9,7 @@ import { MediaStreamTrackKind } from '../peer-connection'; class MediaStreamTrackStub { // default MediaStreamTrack value enabled = true; + readyState: MediaStreamTrackState = 'live'; // Technically this should map to a list of handlers, but for now modeling a single handler should // be fine. eventListeners: Map = new Map(); From 5c0479d79f0e4841e48167a04ae96f00da6f5aeb Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 9 Apr 2026 12:39:51 +0200 Subject: [PATCH 07/28] chore: renaming for handleConstraintsRequired --- src/media/local-stream.spec.ts | 2 +- src/media/local-stream.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index dc7a168..65630e6 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -189,7 +189,7 @@ describe('LocalStream', () => { }); }); - describe('handleConstraintsRequired', () => { + describe('handleAudioConstraintsRequired', () => { const audioSettings: MediaTrackSettings = { deviceId: 'test-device-id', sampleRate: 48000, diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index e3aebd4..d23caec 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -291,7 +291,7 @@ abstract class _LocalStream extends Stream { * * @param constraints - The constraints requested by the effect. */ - const handleConstraintsRequired = async (constraints: MediaTrackConstraints) => { + const handleAudioConstraintsRequired = async (constraints: MediaTrackConstraints) => { if (!this.effects.includes(effect)) { logger.log(`Effect ${effect.id} not in effects list, ignoring constraints-required.`); return; @@ -389,7 +389,7 @@ abstract class _LocalStream extends Stream { const handleEffectDisposed = () => { effect.off(EffectEvent.TrackUpdated, handleEffectTrackUpdated); if (this.outputTrack.kind === 'audio') { - effect.off(EffectEvent.ConstraintsRequired, handleConstraintsRequired); + effect.off(EffectEvent.ConstraintsRequired, handleAudioConstraintsRequired); } effect.off(EffectEvent.Disposed, handleEffectDisposed); }; @@ -399,7 +399,7 @@ abstract class _LocalStream extends Stream { // we use type assertion here as a temporary workaround. effect.on(EffectEvent.TrackUpdated, handleEffectTrackUpdated); if (this.outputTrack.kind === 'audio') { - effect.on(EffectEvent.ConstraintsRequired, handleConstraintsRequired); + effect.on(EffectEvent.ConstraintsRequired, handleAudioConstraintsRequired); } effect.on(EffectEvent.Disposed, handleEffectDisposed); From 160e7ed94d375c0b8fb34617959bd288f45ad54a Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 9 Apr 2026 13:10:06 +0200 Subject: [PATCH 08/28] chore: small refactor changes --- src/media/local-stream.spec.ts | 11 +++++++++++ src/media/local-stream.ts | 12 ++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index 65630e6..b505356 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -408,6 +408,17 @@ describe('LocalStream', () => { expect(endedSpy).toHaveBeenCalledWith(); }); + it('should preserve the enabled state of the track after re-acquisition', async () => { + expect.hasAssertions(); + + const currentTrack = audioStream.getTracks()[0]; + currentTrack.enabled = false; + + await constraintsHandler({ autoGainControl: false }); + + expect(newAudioTrack.enabled).toBe(false); + }); + it('should skip re-acquisition when the track is already ended', async () => { expect.hasAssertions(); diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index d23caec..a5b795b 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -387,21 +387,21 @@ abstract class _LocalStream extends Stream { * effect. */ const handleEffectDisposed = () => { - effect.off(EffectEvent.TrackUpdated, handleEffectTrackUpdated); + effect.off('track-updated' as EffectEvent, handleEffectTrackUpdated); if (this.outputTrack.kind === 'audio') { - effect.off(EffectEvent.ConstraintsRequired, handleAudioConstraintsRequired); + effect.off('constraints-required' as EffectEvent, handleAudioConstraintsRequired); } - effect.off(EffectEvent.Disposed, handleEffectDisposed); + effect.off('disposed' as EffectEvent, handleEffectDisposed); }; // TODO: using EffectEvent.TrackUpdated or EffectEvent.Disposed will cause the entire // web-media-effects lib to be rebuilt and inflates the size of the webrtc-core build, so // we use type assertion here as a temporary workaround. - effect.on(EffectEvent.TrackUpdated, handleEffectTrackUpdated); + effect.on('track-updated' as EffectEvent, handleEffectTrackUpdated); if (this.outputTrack.kind === 'audio') { - effect.on(EffectEvent.ConstraintsRequired, handleAudioConstraintsRequired); + effect.on('constraints-required' as EffectEvent, handleAudioConstraintsRequired); } - effect.on(EffectEvent.Disposed, handleEffectDisposed); + effect.on('disposed' as EffectEvent, handleEffectDisposed); // Add the effect to the effects list. If an effect of the same kind has already been added, // dispose the existing effect and replace it with the new effect. If the existing effect was From abe135c7957a28526bdecaadafa99aa131e29a03 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 9 Apr 2026 18:47:19 +0200 Subject: [PATCH 09/28] chore: update path for catch (err: unknown) --- src/media/local-stream.spec.ts | 29 +++++++++++++++++++++++++++++ src/media/local-stream.ts | 10 ++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index b505356..96880f5 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -213,6 +213,9 @@ describe('LocalStream', () => { const inputTrack = audioStream.getTracks()[0]; jest.spyOn(inputTrack, 'getSettings').mockReturnValue(audioSettings); + jest.spyOn(inputTrack, 'stop').mockImplementation(() => { + (inputTrack as { readyState: string }).readyState = 'ended'; + }); const eventHandlers = new Map void>(); effect = { @@ -397,6 +400,10 @@ describe('LocalStream', () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + const outputTrackChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.OutputTrackChange], + 'emit' + ); getUserMediaSpy .mockRejectedValueOnce(new Error('OverconstrainedError')) @@ -406,6 +413,28 @@ describe('LocalStream', () => { expect(getUserMediaSpy).toHaveBeenCalledTimes(2); expect(endedSpy).toHaveBeenCalledWith(); + expect(outputTrackChangeSpy).not.toHaveBeenCalled(); + }); + + it('should fall back to raw mic track when replaceInputTrack fails', async () => { + expect.hasAssertions(); + + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + + (effect.replaceInputTrack as jest.Mock).mockRejectedValueOnce( + new Error('AudioContext closed') + ); + + (newAudioTrack as { readyState: string }).readyState = 'live'; + + (audioStream.addTrack as jest.Mock).mockImplementation((track: MediaStreamTrack) => { + (audioStream.getTracks as jest.Mock).mockReturnValue([track]); + }); + + await constraintsHandler({ autoGainControl: false }); + + expect(endedSpy).not.toHaveBeenCalled(); + expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); }); it('should preserve the enabled state of the track after re-acquisition', async () => { diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index a5b795b..3d7bd9b 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -376,9 +376,15 @@ abstract class _LocalStream extends Stream { this[LocalStreamEventNames.ConstraintsChange].emit(); logger.log(`Effect constraints applied via track re-acquisition.`); } catch (err: unknown) { - logger.error(`Failed to re-acquire track after constraint change:`, err); savedTrackSettings = {}; - this[StreamEventNames.Ended].emit(); + + if (this.inputTrack.readyState === 'live') { + this.changeOutputTrack(this.inputTrack); + logger.warn(`Effect wiring failed, continuing with raw mic track:`, err); + } else { + logger.error(`Failed to re-acquire mic track, stream ended:`, err); + this[StreamEventNames.Ended].emit(); + } } }; From 5e0bf72667d02bd3fd691616b9e4306dce460be9 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 10 Apr 2026 17:43:18 +0200 Subject: [PATCH 10/28] fix: update with effect was disposed check --- src/media/local-stream.spec.ts | 38 ++++++++++++++++++++++++++++++++++ src/media/local-stream.ts | 9 ++++++++ 2 files changed, 47 insertions(+) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index 96880f5..09e1280 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -437,6 +437,44 @@ describe('LocalStream', () => { expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); }); + it('should discard new track when effect is disposed during getUserMedia', async () => { + expect.hasAssertions(); + + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); + + // Make getUserMedia resolve only after we dispose effects, simulating + // the race where the user hangs up while getUserMedia is pending. + // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function + let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; + getUserMediaSpy.mockReturnValueOnce( + new Promise((resolve) => { + resolveGetUserMedia = resolve; + }) + ); + + const handlerPromise = constraintsHandler({ autoGainControl: false }); + + // Dispose effects while getUserMedia is pending + await audioLocalStream.disposeEffects(); + + // Now let getUserMedia resolve with the new track + const newMockStream = createMockedAudioStream(); + (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); + resolveGetUserMedia(newMockStream); + + await handlerPromise; + + expect(newTrackStopSpy).toHaveBeenCalledWith(); + expect(endedSpy).not.toHaveBeenCalled(); + expect(constraintsChangeSpy).not.toHaveBeenCalled(); + expect(effect.replaceInputTrack).not.toHaveBeenCalled(); + }); + it('should preserve the enabled state of the track after re-acquisition', async () => { expect.hasAssertions(); diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index 3d7bd9b..8790ea0 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -365,6 +365,15 @@ abstract class _LocalStream extends Stream { const [newTrack] = newStream.getAudioTracks(); + // If the effect was disposed while getUserMedia was pending, stop the + // newly acquired track and bail out to avoid reinserting a live mic + // track into a stopped stream (same pattern as addEffect). + if (!this.effects.includes(effect)) { + newTrack.stop(); + logger.log(`Effect was disposed during track re-acquisition, discarding new track.`); + return; + } + newTrack.enabled = isEnabled; this.inputStream.removeTrack(currentTrack); this.inputStream.addTrack(newTrack); From ed3d0a68aed3a2dfc0f6f63df2d70ea1bb3a08e8 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Tue, 14 Apr 2026 17:38:19 +0200 Subject: [PATCH 11/28] fix: update local stream with constraints-released --- src/media/local-stream.spec.ts | 99 +++++++---------------- src/media/local-stream.ts | 141 ++++++++++++++++++--------------- 2 files changed, 107 insertions(+), 133 deletions(-) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index 09e1280..4f5feb7 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -1,5 +1,5 @@ -import { WebrtcCoreError } from '../errors'; import * as media from '.'; +import { WebrtcCoreError } from '../errors'; import { createMockedAudioStream, createMockedStream } from '../util/test-utils'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; import { StreamEventNames } from './stream'; @@ -189,7 +189,7 @@ describe('LocalStream', () => { }); }); - describe('handleAudioConstraintsRequired', () => { + describe('audio constraints handling', () => { const audioSettings: MediaTrackSettings = { deviceId: 'test-device-id', sampleRate: 48000, @@ -203,7 +203,8 @@ describe('LocalStream', () => { let audioStream: MediaStream; let audioLocalStream: LocalStream; let effect: TrackEffect; - let constraintsHandler: (constraints: MediaTrackConstraints) => Promise; + let constraintsRequiredHandler: (constraints: MediaTrackConstraints) => Promise; + let constraintsReleasedHandler: () => Promise; let getUserMediaSpy: jest.SpyInstance; let newAudioTrack: MediaStreamTrack; @@ -213,9 +214,6 @@ describe('LocalStream', () => { const inputTrack = audioStream.getTracks()[0]; jest.spyOn(inputTrack, 'getSettings').mockReturnValue(audioSettings); - jest.spyOn(inputTrack, 'stop').mockImplementation(() => { - (inputTrack as { readyState: string }).readyState = 'ended'; - }); const eventHandlers = new Map void>(); effect = { @@ -238,9 +236,10 @@ describe('LocalStream', () => { getUserMediaSpy = jest.spyOn(media, 'getUserMedia').mockResolvedValue(newMockStream); await audioLocalStream.addEffect(effect); - constraintsHandler = eventHandlers.get('constraints-required') as ( + constraintsRequiredHandler = eventHandlers.get('constraints-required') as ( constraints: MediaTrackConstraints ) => Promise; + constraintsReleasedHandler = eventHandlers.get('constraints-released') as () => Promise; }); afterEach(() => { @@ -250,7 +249,7 @@ describe('LocalStream', () => { it('should call getUserMedia with current settings and effect constraints', async () => { expect.hasAssertions(); - await constraintsHandler({ autoGainControl: false, noiseSuppression: false }); + await constraintsRequiredHandler({ autoGainControl: false, noiseSuppression: false }); expect(getUserMediaSpy).toHaveBeenCalledWith({ audio: { @@ -265,10 +264,10 @@ describe('LocalStream', () => { }); }); - it('should skip re-acquisition when constraints are empty and nothing saved', async () => { + it('should skip re-acquisition when nothing is saved and constraints are released', async () => { expect.hasAssertions(); - await constraintsHandler({}); + await constraintsReleasedHandler(); expect(getUserMediaSpy).not.toHaveBeenCalled(); }); @@ -276,15 +275,15 @@ describe('LocalStream', () => { it('should skip re-acquisition when constraints are already satisfied', async () => { expect.hasAssertions(); - await constraintsHandler({ autoGainControl: true, noiseSuppression: true }); + await constraintsRequiredHandler({ autoGainControl: true, noiseSuppression: true }); expect(getUserMediaSpy).not.toHaveBeenCalled(); }); - it('should restore saved user constraints when empty constraints are received', async () => { + it('should restore saved user constraints when constraints are released', async () => { expect.hasAssertions(); - await constraintsHandler({ autoGainControl: false, noiseSuppression: false }); + await constraintsRequiredHandler({ autoGainControl: false, noiseSuppression: false }); getUserMediaSpy.mockClear(); (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); @@ -294,7 +293,7 @@ describe('LocalStream', () => { noiseSuppression: false, }); - await constraintsHandler({}); + await constraintsReleasedHandler(); expect(getUserMediaSpy).toHaveBeenCalledWith({ audio: expect.objectContaining({ @@ -307,7 +306,7 @@ describe('LocalStream', () => { it('should not restore a second time after saved constraints are cleared', async () => { expect.hasAssertions(); - await constraintsHandler({ autoGainControl: false }); + await constraintsRequiredHandler({ autoGainControl: false }); getUserMediaSpy.mockClear(); (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); @@ -316,10 +315,10 @@ describe('LocalStream', () => { autoGainControl: false, }); - await constraintsHandler({}); + await constraintsReleasedHandler(); getUserMediaSpy.mockClear(); - await constraintsHandler({}); + await constraintsReleasedHandler(); expect(getUserMediaSpy).not.toHaveBeenCalled(); }); @@ -327,7 +326,7 @@ describe('LocalStream', () => { it('should replace the input track on the first effect', async () => { expect.hasAssertions(); - await constraintsHandler({ autoGainControl: false }); + await constraintsRequiredHandler({ autoGainControl: false }); expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); }); @@ -345,7 +344,7 @@ describe('LocalStream', () => { callOrder.push('stop'); }); - await constraintsHandler({ autoGainControl: false }); + await constraintsRequiredHandler({ autoGainControl: false }); const firstRemove = callOrder.indexOf('removeEventListener'); const firstStop = callOrder.indexOf('stop'); @@ -367,7 +366,7 @@ describe('LocalStream', () => { return createMockedAudioStream(); }); - await constraintsHandler({ autoGainControl: false }); + await constraintsRequiredHandler({ autoGainControl: false }); expect(callOrder).toStrictEqual(['stop', 'getUserMedia']); }); @@ -380,7 +379,7 @@ describe('LocalStream', () => { .mockRejectedValueOnce(new Error('OverconstrainedError')) .mockResolvedValueOnce(fallbackStream); - await constraintsHandler({ autoGainControl: false }); + await constraintsRequiredHandler({ autoGainControl: false }); expect(getUserMediaSpy).toHaveBeenCalledTimes(2); expect(getUserMediaSpy).toHaveBeenLastCalledWith({ @@ -400,41 +399,26 @@ describe('LocalStream', () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); - const outputTrackChangeSpy = jest.spyOn( - audioLocalStream[LocalStreamEventNames.OutputTrackChange], - 'emit' - ); getUserMediaSpy .mockRejectedValueOnce(new Error('OverconstrainedError')) .mockRejectedValueOnce(new Error('NotFoundError')); - await constraintsHandler({ autoGainControl: false }); + await constraintsRequiredHandler({ autoGainControl: false }); expect(getUserMediaSpy).toHaveBeenCalledTimes(2); expect(endedSpy).toHaveBeenCalledWith(); - expect(outputTrackChangeSpy).not.toHaveBeenCalled(); }); - it('should fall back to raw mic track when replaceInputTrack fails', async () => { + it('should skip re-acquisition when the track is already ended', async () => { expect.hasAssertions(); - const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); - - (effect.replaceInputTrack as jest.Mock).mockRejectedValueOnce( - new Error('AudioContext closed') - ); - - (newAudioTrack as { readyState: string }).readyState = 'live'; - - (audioStream.addTrack as jest.Mock).mockImplementation((track: MediaStreamTrack) => { - (audioStream.getTracks as jest.Mock).mockReturnValue([track]); - }); + const currentTrack = audioStream.getTracks()[0]; + (currentTrack as { readyState: string }).readyState = 'ended'; - await constraintsHandler({ autoGainControl: false }); + await constraintsRequiredHandler({ autoGainControl: false }); - expect(endedSpy).not.toHaveBeenCalled(); - expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); + expect(getUserMediaSpy).not.toHaveBeenCalled(); }); it('should discard new track when effect is disposed during getUserMedia', async () => { @@ -447,8 +431,6 @@ describe('LocalStream', () => { ); const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); - // Make getUserMedia resolve only after we dispose effects, simulating - // the race where the user hangs up while getUserMedia is pending. // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; getUserMediaSpy.mockReturnValueOnce( @@ -457,12 +439,10 @@ describe('LocalStream', () => { }) ); - const handlerPromise = constraintsHandler({ autoGainControl: false }); + const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); - // Dispose effects while getUserMedia is pending await audioLocalStream.disposeEffects(); - // Now let getUserMedia resolve with the new track const newMockStream = createMockedAudioStream(); (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); resolveGetUserMedia(newMockStream); @@ -475,29 +455,7 @@ describe('LocalStream', () => { expect(effect.replaceInputTrack).not.toHaveBeenCalled(); }); - it('should preserve the enabled state of the track after re-acquisition', async () => { - expect.hasAssertions(); - - const currentTrack = audioStream.getTracks()[0]; - currentTrack.enabled = false; - - await constraintsHandler({ autoGainControl: false }); - - expect(newAudioTrack.enabled).toBe(false); - }); - - it('should skip re-acquisition when the track is already ended', async () => { - expect.hasAssertions(); - - const currentTrack = audioStream.getTracks()[0]; - (currentTrack as { readyState: string }).readyState = 'ended'; - - await constraintsHandler({ autoGainControl: false }); - - expect(getUserMediaSpy).not.toHaveBeenCalled(); - }); - - it('should not register constraints-required handler for video tracks', async () => { + it('should not register audio constraint handlers for video tracks', async () => { expect.hasAssertions(); const videoStream = createMockedStream(); @@ -519,6 +477,7 @@ describe('LocalStream', () => { await videoLocalStream.addEffect(videoEffect); expect(videoEventHandlers.has('constraints-required')).toBe(false); + expect(videoEventHandlers.has('constraints-released')).toBe(false); expect(videoEventHandlers.has('track-updated')).toBe(true); expect(videoEventHandlers.has('disposed')).toBe(true); }); diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index 8790ea0..e80feb1 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -1,7 +1,7 @@ import { AddEvents, TypedEvent, WithEventsDummyType } from '@webex/ts-events'; import { BaseEffect, EffectEvent } from '@webex/web-media-effects'; -import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors'; import { getUserMedia } from '.'; +import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors'; import { logger } from '../util/logger'; import { Stream, StreamEventNames } from './stream'; @@ -271,79 +271,45 @@ abstract class _LocalStream extends Stream { /** * Track settings saved before the effect changed them, keyed by constraint * property name. Used to restore the user's original values when the effect - * emits empty constraints (disable / dispose / model switch to one with no - * special requirements). + * emits {@link EffectEvent.ConstraintsReleased}. */ let savedTrackSettings: MediaTrackSettings = {}; /** - * Handle when an audio effect requests specific constraints on the input track. - * - * Non-empty constraints: save the current values for those properties, then - * re-acquire the audio track with the requested constraints. + * Re-acquire the audio input track with the given constraints via + * getUserMedia (required because Chrome ignores applyConstraints for + * audio processing properties). * - * Empty constraints ({}): restore the previously saved values so the track - * returns to the user's original settings. - * - * Re-acquires via getUserMedia because MediaStreamTrack.applyConstraints() - * is silently ignored by Chrome for audio processing constraints. * See https://issues.chromium.org/issues/40555809. * - * @param constraints - The constraints requested by the effect. + * @param constraintsToApply - The constraints to apply to the new track. */ - const handleAudioConstraintsRequired = async (constraints: MediaTrackConstraints) => { + const reacquireInputTrack = async ( + constraintsToApply: MediaTrackConstraints + ): Promise => { if (!this.effects.includes(effect)) { - logger.log(`Effect ${effect.id} not in effects list, ignoring constraints-required.`); + logger.log(`Effect ${effect.id} not in effects list, ignoring constraints change.`); return; } if (this.inputTrack.readyState === 'ended') { - logger.log(`Track already ended, ignoring constraints-required.`); + logger.log(`Track already ended, ignoring constraints change.`); return; } - logger.log(`Effect ${effect.id} constraints required:`, constraints); - - try { - const isEmptyConstraints = !Object.keys(constraints).length; - - let constraintsToApply: MediaTrackConstraints; + const currentTrack = this.inputTrack; + const currentSettings = currentTrack.getSettings(); + const isEnabled = currentTrack.enabled; - if (isEmptyConstraints) { - if (!Object.keys(savedTrackSettings).length) { - logger.log(`No settings to restore, skipping re-acquisition.`); - return; - } - constraintsToApply = { ...savedTrackSettings }; - savedTrackSettings = {}; - logger.log(`Restoring saved settings:`, constraintsToApply); - } else { - constraintsToApply = constraints; - } - - const currentTrack = this.inputTrack; - const currentSettings = currentTrack.getSettings(); - const isEnabled = currentTrack.enabled; - - const constraintEntries = Object.entries(constraintsToApply); - const isAlreadySatisfied = constraintEntries.every( - ([key, value]) => currentSettings[key as keyof MediaTrackSettings] === value - ); - if (isAlreadySatisfied) { - logger.log(`Effect constraints already satisfied, skipping re-acquisition.`); - return; - } - - if (!isEmptyConstraints) { - Object.keys(constraints).forEach((key) => { - if (!(key in savedTrackSettings)) { - Object.assign(savedTrackSettings, { - [key]: currentSettings[key as keyof MediaTrackSettings], - }); - } - }); - } + const isAlreadySatisfied = Object.entries(constraintsToApply).every( + ([key, value]) => currentSettings[key as keyof MediaTrackSettings] === value + ); + if (isAlreadySatisfied) { + logger.log(`Constraints already satisfied, skipping re-acquisition.`); + return; + } + try { this.removeTrackHandlers(currentTrack); currentTrack.stop(); @@ -383,10 +349,15 @@ abstract class _LocalStream extends Stream { await this.effects[0].replaceInputTrack(newTrack); } this[LocalStreamEventNames.ConstraintsChange].emit(); - logger.log(`Effect constraints applied via track re-acquisition.`); + logger.log(`Constraints applied via track re-acquisition.`); } catch (err: unknown) { savedTrackSettings = {}; + if (!this.effects.includes(effect)) { + logger.log(`Effect was disposed during constraint handling, ignoring error.`); + return; + } + if (this.inputTrack.readyState === 'live') { this.changeOutputTrack(this.inputTrack); logger.warn(`Effect wiring failed, continuing with raw mic track:`, err); @@ -397,26 +368,70 @@ abstract class _LocalStream extends Stream { } }; + /** + * Handle when an audio effect requests specific constraints on the input + * track. Saves the current values for the requested properties so they can + * be restored later, then re-acquires the track with the new constraints. + * + * @param constraints - The constraints requested by the effect. + */ + const handleAudioConstraintsRequired = async ( + constraints: MediaTrackConstraints + ): Promise => { + logger.log(`Effect ${effect.id} constraints required:`, constraints); + + const currentSettings = this.inputTrack.getSettings(); + Object.keys(constraints).forEach((key) => { + if (!(key in savedTrackSettings)) { + Object.assign(savedTrackSettings, { + [key]: currentSettings[key as keyof MediaTrackSettings], + }); + } + }); + + await reacquireInputTrack(constraints); + }; + + /** + * Handle when an audio effect releases its constraint requirements + * (disable / dispose / model switch). Restores the previously saved + * track settings. + */ + const handleAudioConstraintsReleased = async (): Promise => { + logger.log(`Effect ${effect.id} constraints released.`); + + if (!Object.keys(savedTrackSettings).length) { + logger.log(`No settings to restore, skipping re-acquisition.`); + return; + } + + const toRestore = { ...savedTrackSettings }; + savedTrackSettings = {}; + await reacquireInputTrack(toRestore); + }; + /** * Handle when the effect has been disposed. This will remove all event listeners from the * effect. */ const handleEffectDisposed = () => { - effect.off('track-updated' as EffectEvent, handleEffectTrackUpdated); + effect.off('track-updated' as EffectEvent, handleEffectTrackUpdated as never); if (this.outputTrack.kind === 'audio') { - effect.off('constraints-required' as EffectEvent, handleAudioConstraintsRequired); + effect.off('constraints-required' as EffectEvent, handleAudioConstraintsRequired as never); + effect.off('constraints-released' as EffectEvent, handleAudioConstraintsReleased as never); } - effect.off('disposed' as EffectEvent, handleEffectDisposed); + effect.off('disposed' as EffectEvent, handleEffectDisposed as never); }; // TODO: using EffectEvent.TrackUpdated or EffectEvent.Disposed will cause the entire // web-media-effects lib to be rebuilt and inflates the size of the webrtc-core build, so // we use type assertion here as a temporary workaround. - effect.on('track-updated' as EffectEvent, handleEffectTrackUpdated); + effect.on('track-updated' as EffectEvent, handleEffectTrackUpdated as never); if (this.outputTrack.kind === 'audio') { - effect.on('constraints-required' as EffectEvent, handleAudioConstraintsRequired); + effect.on('constraints-required' as EffectEvent, handleAudioConstraintsRequired as never); + effect.on('constraints-released' as EffectEvent, handleAudioConstraintsReleased as never); } - effect.on('disposed' as EffectEvent, handleEffectDisposed); + effect.on('disposed' as EffectEvent, handleEffectDisposed as never); // Add the effect to the effects list. If an effect of the same kind has already been added, // dispose the existing effect and replace it with the new effect. If the existing effect was From 8510c5c0faf89e7a0782e5e9238d9d0291eac33f Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Wed, 15 Apr 2026 12:49:13 +0200 Subject: [PATCH 12/28] fix: update removeTrackHandlers order and logs --- src/media/local-stream.spec.ts | 39 ++++++++++++++++++++++++++++++++-- src/media/local-stream.ts | 18 +++++++++------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index 4f5feb7..50211d8 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -352,7 +352,7 @@ describe('LocalStream', () => { expect(firstStop).toBeGreaterThan(firstRemove); }); - it('should stop the current track before calling getUserMedia', async () => { + it('should stop the current track after getUserMedia succeeds', async () => { expect.hasAssertions(); const currentTrack = audioStream.getTracks()[0]; @@ -368,7 +368,7 @@ describe('LocalStream', () => { await constraintsRequiredHandler({ autoGainControl: false }); - expect(callOrder).toStrictEqual(['stop', 'getUserMedia']); + expect(callOrder).toStrictEqual(['getUserMedia', 'stop']); }); it('should fall back to getUserMedia without effect constraints when first call fails', async () => { @@ -455,6 +455,41 @@ describe('LocalStream', () => { expect(effect.replaceInputTrack).not.toHaveBeenCalled(); }); + it('should discard new track when stream is stopped during getUserMedia', async () => { + expect.hasAssertions(); + + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); + + // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function + let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; + getUserMediaSpy.mockReturnValueOnce( + new Promise((resolve) => { + resolveGetUserMedia = resolve; + }) + ); + + const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); + + // Simulate the user stopping the stream while getUserMedia is pending. + // This stops the track but disposeEffects hasn't cleared this.effects yet. + const currentTrack = audioStream.getTracks()[0]; + (currentTrack as { readyState: string }).readyState = 'ended'; + + const newMockStream = createMockedAudioStream(); + (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); + resolveGetUserMedia(newMockStream); + + await handlerPromise; + + expect(newTrackStopSpy).toHaveBeenCalledWith(); + expect(constraintsChangeSpy).not.toHaveBeenCalled(); + expect(effect.replaceInputTrack).not.toHaveBeenCalled(); + }); + it('should not register audio constraint handlers for video tracks', async () => { expect.hasAssertions(); diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index e80feb1..3bd69a1 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -288,7 +288,7 @@ abstract class _LocalStream extends Stream { constraintsToApply: MediaTrackConstraints ): Promise => { if (!this.effects.includes(effect)) { - logger.log(`Effect ${effect.id} not in effects list, ignoring constraints change.`); + logger.log(`Effect ${effect.id} was replaced or disposed, skipping constraint handling.`); return; } @@ -310,9 +310,6 @@ abstract class _LocalStream extends Stream { } try { - this.removeTrackHandlers(currentTrack); - currentTrack.stop(); - const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; let newStream = await getUserMedia({ @@ -331,15 +328,20 @@ abstract class _LocalStream extends Stream { const [newTrack] = newStream.getAudioTracks(); - // If the effect was disposed while getUserMedia was pending, stop the - // newly acquired track and bail out to avoid reinserting a live mic - // track into a stopped stream (same pattern as addEffect). - if (!this.effects.includes(effect)) { + // Skip if the effect or stream became inactive while + // getUserMedia was pending (e.g. effect replaced, user hung up). + if (!this.effects.includes(effect) || currentTrack.readyState === 'ended') { newTrack.stop(); + savedTrackSettings = {}; logger.log(`Effect was disposed during track re-acquisition, discarding new track.`); return; } + // Stop the old track only after we confirmed the effect is still + // active and the replacement track is ready. + this.removeTrackHandlers(currentTrack); + currentTrack.stop(); + newTrack.enabled = isEnabled; this.inputStream.removeTrack(currentTrack); this.inputStream.addTrack(newTrack); From 1551b0830ee993c0bdd7774e14cdffae94d37686 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 16 Apr 2026 16:48:23 +0200 Subject: [PATCH 13/28] refactor: put reacquireInputTrack to local audio stream --- src/media/local-audio-stream.spec.ts | 340 +++++++++++++++++++++++++++ src/media/local-audio-stream.ts | 174 +++++++++++++- src/media/local-stream.spec.ts | 333 +------------------------- src/media/local-stream.ts | 157 +------------ 4 files changed, 516 insertions(+), 488 deletions(-) create mode 100644 src/media/local-audio-stream.spec.ts diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts new file mode 100644 index 0000000..a51b6ca --- /dev/null +++ b/src/media/local-audio-stream.spec.ts @@ -0,0 +1,340 @@ +import * as media from '.'; +import { createMockedAudioStream, createMockedStream } from '../util/test-utils'; +import { LocalAudioStream } from './local-audio-stream'; +import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; +import { StreamEventNames } from './stream'; + +/** + * A dummy LocalStream implementation for testing that video streams + * do not register audio constraint handlers. + */ +class TestLocalStream extends LocalStream {} + +describe('LocalAudioStream', () => { + describe('audio constraints handling', () => { + const audioSettings: MediaTrackSettings = { + deviceId: 'test-device-id', + sampleRate: 48000, + channelCount: 1, + sampleSize: 16, + echoCancellation: true, + autoGainControl: true, + noiseSuppression: true, + }; + + let audioStream: MediaStream; + let audioLocalStream: LocalAudioStream; + let effect: TrackEffect; + let constraintsRequiredHandler: (constraints: MediaTrackConstraints) => Promise; + let constraintsReleasedHandler: () => Promise; + let getUserMediaSpy: jest.SpyInstance; + let newAudioTrack: MediaStreamTrack; + + beforeEach(async () => { + audioStream = createMockedAudioStream(); + audioLocalStream = new LocalAudioStream(audioStream); + + const inputTrack = audioStream.getTracks()[0]; + jest.spyOn(inputTrack, 'getSettings').mockReturnValue(audioSettings); + + const eventHandlers = new Map void>(); + effect = { + id: 'nr-effect', + kind: 'noise-reduction', + isEnabled: false, + dispose: jest.fn().mockResolvedValue(undefined), + load: jest.fn().mockResolvedValue(undefined), + replaceInputTrack: jest.fn().mockResolvedValue(undefined), + on: jest.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + eventHandlers.set(event, handler); + }), + off: jest.fn(), + } as unknown as TrackEffect; + + const newMockStream = createMockedAudioStream(); + [newAudioTrack] = newMockStream.getTracks(); + (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); + + getUserMediaSpy = jest.spyOn(media, 'getUserMedia').mockResolvedValue(newMockStream); + + await audioLocalStream.addEffect(effect); + constraintsRequiredHandler = eventHandlers.get('constraints-required') as ( + constraints: MediaTrackConstraints + ) => Promise; + constraintsReleasedHandler = eventHandlers.get('constraints-released') as () => Promise; + }); + + afterEach(() => { + getUserMediaSpy.mockRestore(); + }); + + it('should call getUserMedia with current settings and effect constraints', async () => { + expect.hasAssertions(); + + await constraintsRequiredHandler({ autoGainControl: false, noiseSuppression: false }); + + expect(getUserMediaSpy).toHaveBeenCalledWith({ + audio: { + deviceId: { exact: 'test-device-id' }, + sampleRate: 48000, + channelCount: 1, + sampleSize: 16, + echoCancellation: true, + autoGainControl: false, + noiseSuppression: false, + }, + }); + }); + + it('should skip re-acquisition when nothing is saved and constraints are released', async () => { + expect.hasAssertions(); + + await constraintsReleasedHandler(); + + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it('should skip re-acquisition when constraints are already satisfied', async () => { + expect.hasAssertions(); + + await constraintsRequiredHandler({ autoGainControl: true, noiseSuppression: true }); + + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it('should restore saved user constraints when constraints are released', async () => { + expect.hasAssertions(); + + await constraintsRequiredHandler({ autoGainControl: false, noiseSuppression: false }); + getUserMediaSpy.mockClear(); + + (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); + jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ + ...audioSettings, + autoGainControl: false, + noiseSuppression: false, + }); + + await constraintsReleasedHandler(); + + expect(getUserMediaSpy).toHaveBeenCalledWith({ + audio: expect.objectContaining({ + autoGainControl: true, + noiseSuppression: true, + }), + }); + }); + + it('should not restore a second time after saved constraints are cleared', async () => { + expect.hasAssertions(); + + await constraintsRequiredHandler({ autoGainControl: false }); + getUserMediaSpy.mockClear(); + + (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); + jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ + ...audioSettings, + autoGainControl: false, + }); + + await constraintsReleasedHandler(); + getUserMediaSpy.mockClear(); + + await constraintsReleasedHandler(); + + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it('should replace the input track on the first effect', async () => { + expect.hasAssertions(); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); + }); + + it('should remove track handlers before stopping the current track', async () => { + expect.hasAssertions(); + + const currentTrack = audioStream.getTracks()[0]; + const callOrder: string[] = []; + + jest.spyOn(currentTrack, 'removeEventListener').mockImplementation(() => { + callOrder.push('removeEventListener'); + }); + jest.spyOn(currentTrack, 'stop').mockImplementation(() => { + callOrder.push('stop'); + }); + + await constraintsRequiredHandler({ autoGainControl: false }); + + const firstRemove = callOrder.indexOf('removeEventListener'); + const firstStop = callOrder.indexOf('stop'); + expect(firstRemove).toBeGreaterThanOrEqual(0); + expect(firstStop).toBeGreaterThan(firstRemove); + }); + + it('should stop the current track after getUserMedia succeeds', async () => { + expect.hasAssertions(); + + const currentTrack = audioStream.getTracks()[0]; + const callOrder: string[] = []; + + jest.spyOn(currentTrack, 'stop').mockImplementation(() => { + callOrder.push('stop'); + }); + getUserMediaSpy.mockImplementation(async () => { + callOrder.push('getUserMedia'); + return createMockedAudioStream(); + }); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(callOrder).toStrictEqual(['getUserMedia', 'stop']); + }); + + it('should fall back to getUserMedia without effect constraints when first call fails', async () => { + expect.hasAssertions(); + + const fallbackStream = createMockedAudioStream(); + getUserMediaSpy + .mockRejectedValueOnce(new Error('OverconstrainedError')) + .mockResolvedValueOnce(fallbackStream); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(getUserMediaSpy).toHaveBeenCalledTimes(2); + expect(getUserMediaSpy).toHaveBeenLastCalledWith({ + audio: { + deviceId: { exact: 'test-device-id' }, + sampleRate: 48000, + channelCount: 1, + sampleSize: 16, + echoCancellation: true, + autoGainControl: true, + noiseSuppression: true, + }, + }); + }); + + it('should emit Ended when both getUserMedia calls fail', async () => { + expect.hasAssertions(); + + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + + getUserMediaSpy + .mockRejectedValueOnce(new Error('OverconstrainedError')) + .mockRejectedValueOnce(new Error('NotFoundError')); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(getUserMediaSpy).toHaveBeenCalledTimes(2); + expect(endedSpy).toHaveBeenCalledWith(); + }); + + it('should skip re-acquisition when the track is already ended', async () => { + expect.hasAssertions(); + + const currentTrack = audioStream.getTracks()[0]; + (currentTrack as { readyState: string }).readyState = 'ended'; + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it('should discard new track when effect is disposed during getUserMedia', async () => { + expect.hasAssertions(); + + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); + + // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function + let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; + getUserMediaSpy.mockReturnValueOnce( + new Promise((resolve) => { + resolveGetUserMedia = resolve; + }) + ); + + const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); + + await audioLocalStream.disposeEffects(); + + const newMockStream = createMockedAudioStream(); + (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); + resolveGetUserMedia(newMockStream); + + await handlerPromise; + + expect(newTrackStopSpy).toHaveBeenCalledWith(); + expect(endedSpy).not.toHaveBeenCalled(); + expect(constraintsChangeSpy).not.toHaveBeenCalled(); + expect(effect.replaceInputTrack).not.toHaveBeenCalled(); + }); + + it('should discard new track when stream is stopped during getUserMedia', async () => { + expect.hasAssertions(); + + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); + + // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function + let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; + getUserMediaSpy.mockReturnValueOnce( + new Promise((resolve) => { + resolveGetUserMedia = resolve; + }) + ); + + const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); + + const currentTrack = audioStream.getTracks()[0]; + (currentTrack as { readyState: string }).readyState = 'ended'; + + const newMockStream = createMockedAudioStream(); + (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); + resolveGetUserMedia(newMockStream); + + await handlerPromise; + + expect(newTrackStopSpy).toHaveBeenCalledWith(); + expect(constraintsChangeSpy).not.toHaveBeenCalled(); + expect(effect.replaceInputTrack).not.toHaveBeenCalled(); + }); + + it('should not register audio constraint handlers for video tracks', async () => { + expect.hasAssertions(); + + const videoStream = createMockedStream(); + const videoLocalStream = new TestLocalStream(videoStream); + + const videoEventHandlers = new Map void>(); + const videoEffect = { + id: 'video-effect', + kind: 'video-kind', + isEnabled: false, + dispose: jest.fn().mockResolvedValue(undefined), + load: jest.fn().mockResolvedValue(undefined), + on: jest.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + videoEventHandlers.set(event, handler); + }), + off: jest.fn(), + } as unknown as TrackEffect; + + await videoLocalStream.addEffect(videoEffect); + + expect(videoEventHandlers.has('constraints-required')).toBe(false); + expect(videoEventHandlers.has('constraints-released')).toBe(false); + expect(videoEventHandlers.has('track-updated')).toBe(true); + expect(videoEventHandlers.has('disposed')).toBe(true); + }); + }); +}); diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index ad0ed8e..e005f39 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -1,5 +1,8 @@ +import { EffectEvent } from '@webex/web-media-effects'; +import { getUserMedia } from '.'; import { logger } from '../util/logger'; -import { LocalStream, LocalStreamEventNames } from './local-stream'; +import { StreamEventNames } from './stream'; +import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; // These are the audio constraints that can be applied via applyConstraints. export type AppliableAudioConstraints = Pick< @@ -11,6 +14,14 @@ export type AppliableAudioConstraints = Pick< * An audio LocalStream. */ export class LocalAudioStream extends LocalStream { + /** + * @inheritdoc + */ + async addEffect(effect: TrackEffect): Promise { + await super.addEffect(effect); + this.addConstraintHandlers(effect); + } + /** * Apply constraints to the stream. * @@ -23,4 +34,165 @@ export class LocalAudioStream extends LocalStream { this[LocalStreamEventNames.ConstraintsChange].emit(); }); } + + /** + * Wire constraint event handlers for an audio effect. When the effect emits + * {@link EffectEvent.ConstraintsRequired}, the input track is re-acquired via + * getUserMedia with the requested settings. When the effect emits + * {@link EffectEvent.ConstraintsReleased}, the original settings are restored. + * + * Re-acquisition is needed because Chrome ignores applyConstraints for audio + * processing properties (https://issues.chromium.org/issues/40555809). + * + * @param effect - The effect to add handlers for. + */ + private addConstraintHandlers(effect: TrackEffect): void { + let savedTrackSettings: MediaTrackSettings = {}; + + /** + * Re-acquire the audio input track with the given constraints via + * getUserMedia, since Chrome ignores applyConstraints for audio + * processing properties. + * + * @param constraintsToApply - The constraints to apply to the new track. + */ + const reacquireInputTrack = async ( + constraintsToApply: MediaTrackConstraints + ): Promise => { + if (!this.effects.includes(effect)) { + logger.log(`Effect ${effect.id} was replaced or disposed, skipping constraint handling.`); + return; + } + + if (this.inputTrack.readyState === 'ended') { + logger.log(`Track already ended, ignoring constraints change.`); + return; + } + + const currentTrack = this.inputTrack; + const currentSettings = currentTrack.getSettings(); + const isEnabled = currentTrack.enabled; + + const isAlreadySatisfied = Object.entries(constraintsToApply).every( + ([key, value]) => currentSettings[key as keyof MediaTrackSettings] === value + ); + if (isAlreadySatisfied) { + logger.log(`Constraints already satisfied, skipping re-acquisition.`); + return; + } + + try { + const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; + + let newStream = await getUserMedia({ + audio: { ...currentSettings, ...constraintsToApply, deviceId }, + }).catch((err) => { + logger.warn(`Failed to re-acquire track with effect constraints, recovering:`, err); + return null; + }); + + if (!newStream) { + newStream = await getUserMedia({ + audio: { ...currentSettings, deviceId }, + }); + savedTrackSettings = {}; + } + + const [newTrack] = newStream.getAudioTracks(); + + // Skip if the effect or stream became inactive while + // getUserMedia was pending (e.g. effect replaced, user hung up). + if (!this.effects.includes(effect) || currentTrack.readyState === 'ended') { + newTrack.stop(); + savedTrackSettings = {}; + logger.log(`Effect was disposed during track re-acquisition, discarding new track.`); + return; + } + + // Stop the old track only after we confirmed the effect is still + // active and the replacement track is ready. + this.removeTrackHandlers(currentTrack); + currentTrack.stop(); + + newTrack.enabled = isEnabled; + this.inputStream.removeTrack(currentTrack); + this.inputStream.addTrack(newTrack); + this.addTrackHandlers(newTrack); + + if (this.effects.length > 0) { + await this.effects[0].replaceInputTrack(newTrack); + } + this[LocalStreamEventNames.ConstraintsChange].emit(); + logger.log(`Constraints applied via track re-acquisition.`); + } catch (err: unknown) { + savedTrackSettings = {}; + + if (!this.effects.includes(effect)) { + logger.log(`Effect was disposed during constraint handling, ignoring error.`); + return; + } + + if (this.inputTrack.readyState === 'live') { + this.changeOutputTrack(this.inputTrack); + logger.warn(`Effect wiring failed, continuing with raw mic track:`, err); + } else { + logger.error(`Failed to re-acquire mic track, stream ended:`, err); + this[StreamEventNames.Ended].emit(); + } + } + }; + + /** + * Handle when an audio effect requests specific constraints on the input + * track. Saves the current values for the requested properties so they can + * be restored later, then re-acquires the track with the new constraints. + * + * @param constraints - The constraints requested by the effect. + */ + const handleConstraintsRequired = async (constraints: MediaTrackConstraints): Promise => { + logger.log(`Effect ${effect.id} constraints required:`, constraints); + + const currentSettings = this.inputTrack.getSettings(); + Object.keys(constraints).forEach((key) => { + if (!(key in savedTrackSettings)) { + Object.assign(savedTrackSettings, { + [key]: currentSettings[key as keyof MediaTrackSettings], + }); + } + }); + + await reacquireInputTrack(constraints); + }; + + /** + * Handle when an audio effect releases its constraint requirements. + * Restores the previously saved track settings. + */ + const handleConstraintsReleased = async (): Promise => { + logger.log(`Effect ${effect.id} constraints released.`); + + if (!Object.keys(savedTrackSettings).length) { + logger.log(`No settings to restore, skipping re-acquisition.`); + return; + } + + const toRestore = { ...savedTrackSettings }; + savedTrackSettings = {}; + await reacquireInputTrack(toRestore); + }; + + /** + * Remove constraint listeners when the effect is disposed. + * The base class handles its own listener cleanup separately. + */ + const removeConstraintHandlers = () => { + effect.off('constraints-required' as EffectEvent, handleConstraintsRequired as never); + effect.off('constraints-released' as EffectEvent, handleConstraintsReleased as never); + effect.off('disposed' as EffectEvent, removeConstraintHandlers as never); + }; + + effect.on('constraints-required' as EffectEvent, handleConstraintsRequired as never); + effect.on('constraints-released' as EffectEvent, handleConstraintsReleased as never); + effect.on('disposed' as EffectEvent, removeConstraintHandlers as never); + } } diff --git a/src/media/local-stream.spec.ts b/src/media/local-stream.spec.ts index 50211d8..5d52e03 100644 --- a/src/media/local-stream.spec.ts +++ b/src/media/local-stream.spec.ts @@ -1,8 +1,6 @@ -import * as media from '.'; import { WebrtcCoreError } from '../errors'; -import { createMockedAudioStream, createMockedStream } from '../util/test-utils'; +import { createMockedStream } from '../util/test-utils'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; -import { StreamEventNames } from './stream'; /** * A dummy LocalStream implementation, so we can instantiate it for testing. @@ -189,335 +187,6 @@ describe('LocalStream', () => { }); }); - describe('audio constraints handling', () => { - const audioSettings: MediaTrackSettings = { - deviceId: 'test-device-id', - sampleRate: 48000, - channelCount: 1, - sampleSize: 16, - echoCancellation: true, - autoGainControl: true, - noiseSuppression: true, - }; - - let audioStream: MediaStream; - let audioLocalStream: LocalStream; - let effect: TrackEffect; - let constraintsRequiredHandler: (constraints: MediaTrackConstraints) => Promise; - let constraintsReleasedHandler: () => Promise; - let getUserMediaSpy: jest.SpyInstance; - let newAudioTrack: MediaStreamTrack; - - beforeEach(async () => { - audioStream = createMockedAudioStream(); - audioLocalStream = new TestLocalStream(audioStream); - - const inputTrack = audioStream.getTracks()[0]; - jest.spyOn(inputTrack, 'getSettings').mockReturnValue(audioSettings); - - const eventHandlers = new Map void>(); - effect = { - id: 'nr-effect', - kind: 'noise-reduction', - isEnabled: false, - dispose: jest.fn().mockResolvedValue(undefined), - load: jest.fn().mockResolvedValue(undefined), - replaceInputTrack: jest.fn().mockResolvedValue(undefined), - on: jest.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { - eventHandlers.set(event, handler); - }), - off: jest.fn(), - } as unknown as TrackEffect; - - const newMockStream = createMockedAudioStream(); - [newAudioTrack] = newMockStream.getTracks(); - (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); - - getUserMediaSpy = jest.spyOn(media, 'getUserMedia').mockResolvedValue(newMockStream); - - await audioLocalStream.addEffect(effect); - constraintsRequiredHandler = eventHandlers.get('constraints-required') as ( - constraints: MediaTrackConstraints - ) => Promise; - constraintsReleasedHandler = eventHandlers.get('constraints-released') as () => Promise; - }); - - afterEach(() => { - getUserMediaSpy.mockRestore(); - }); - - it('should call getUserMedia with current settings and effect constraints', async () => { - expect.hasAssertions(); - - await constraintsRequiredHandler({ autoGainControl: false, noiseSuppression: false }); - - expect(getUserMediaSpy).toHaveBeenCalledWith({ - audio: { - deviceId: { exact: 'test-device-id' }, - sampleRate: 48000, - channelCount: 1, - sampleSize: 16, - echoCancellation: true, - autoGainControl: false, - noiseSuppression: false, - }, - }); - }); - - it('should skip re-acquisition when nothing is saved and constraints are released', async () => { - expect.hasAssertions(); - - await constraintsReleasedHandler(); - - expect(getUserMediaSpy).not.toHaveBeenCalled(); - }); - - it('should skip re-acquisition when constraints are already satisfied', async () => { - expect.hasAssertions(); - - await constraintsRequiredHandler({ autoGainControl: true, noiseSuppression: true }); - - expect(getUserMediaSpy).not.toHaveBeenCalled(); - }); - - it('should restore saved user constraints when constraints are released', async () => { - expect.hasAssertions(); - - await constraintsRequiredHandler({ autoGainControl: false, noiseSuppression: false }); - getUserMediaSpy.mockClear(); - - (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); - jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ - ...audioSettings, - autoGainControl: false, - noiseSuppression: false, - }); - - await constraintsReleasedHandler(); - - expect(getUserMediaSpy).toHaveBeenCalledWith({ - audio: expect.objectContaining({ - autoGainControl: true, - noiseSuppression: true, - }), - }); - }); - - it('should not restore a second time after saved constraints are cleared', async () => { - expect.hasAssertions(); - - await constraintsRequiredHandler({ autoGainControl: false }); - getUserMediaSpy.mockClear(); - - (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); - jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ - ...audioSettings, - autoGainControl: false, - }); - - await constraintsReleasedHandler(); - getUserMediaSpy.mockClear(); - - await constraintsReleasedHandler(); - - expect(getUserMediaSpy).not.toHaveBeenCalled(); - }); - - it('should replace the input track on the first effect', async () => { - expect.hasAssertions(); - - await constraintsRequiredHandler({ autoGainControl: false }); - - expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); - }); - - it('should remove track handlers before stopping the current track', async () => { - expect.hasAssertions(); - - const currentTrack = audioStream.getTracks()[0]; - const callOrder: string[] = []; - - jest.spyOn(currentTrack, 'removeEventListener').mockImplementation(() => { - callOrder.push('removeEventListener'); - }); - jest.spyOn(currentTrack, 'stop').mockImplementation(() => { - callOrder.push('stop'); - }); - - await constraintsRequiredHandler({ autoGainControl: false }); - - const firstRemove = callOrder.indexOf('removeEventListener'); - const firstStop = callOrder.indexOf('stop'); - expect(firstRemove).toBeGreaterThanOrEqual(0); - expect(firstStop).toBeGreaterThan(firstRemove); - }); - - it('should stop the current track after getUserMedia succeeds', async () => { - expect.hasAssertions(); - - const currentTrack = audioStream.getTracks()[0]; - const callOrder: string[] = []; - - jest.spyOn(currentTrack, 'stop').mockImplementation(() => { - callOrder.push('stop'); - }); - getUserMediaSpy.mockImplementation(async () => { - callOrder.push('getUserMedia'); - return createMockedAudioStream(); - }); - - await constraintsRequiredHandler({ autoGainControl: false }); - - expect(callOrder).toStrictEqual(['getUserMedia', 'stop']); - }); - - it('should fall back to getUserMedia without effect constraints when first call fails', async () => { - expect.hasAssertions(); - - const fallbackStream = createMockedAudioStream(); - getUserMediaSpy - .mockRejectedValueOnce(new Error('OverconstrainedError')) - .mockResolvedValueOnce(fallbackStream); - - await constraintsRequiredHandler({ autoGainControl: false }); - - expect(getUserMediaSpy).toHaveBeenCalledTimes(2); - expect(getUserMediaSpy).toHaveBeenLastCalledWith({ - audio: { - deviceId: { exact: 'test-device-id' }, - sampleRate: 48000, - channelCount: 1, - sampleSize: 16, - echoCancellation: true, - autoGainControl: true, - noiseSuppression: true, - }, - }); - }); - - it('should emit Ended when both getUserMedia calls fail', async () => { - expect.hasAssertions(); - - const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); - - getUserMediaSpy - .mockRejectedValueOnce(new Error('OverconstrainedError')) - .mockRejectedValueOnce(new Error('NotFoundError')); - - await constraintsRequiredHandler({ autoGainControl: false }); - - expect(getUserMediaSpy).toHaveBeenCalledTimes(2); - expect(endedSpy).toHaveBeenCalledWith(); - }); - - it('should skip re-acquisition when the track is already ended', async () => { - expect.hasAssertions(); - - const currentTrack = audioStream.getTracks()[0]; - (currentTrack as { readyState: string }).readyState = 'ended'; - - await constraintsRequiredHandler({ autoGainControl: false }); - - expect(getUserMediaSpy).not.toHaveBeenCalled(); - }); - - it('should discard new track when effect is disposed during getUserMedia', async () => { - expect.hasAssertions(); - - const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); - const constraintsChangeSpy = jest.spyOn( - audioLocalStream[LocalStreamEventNames.ConstraintsChange], - 'emit' - ); - const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); - - // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function - let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; - getUserMediaSpy.mockReturnValueOnce( - new Promise((resolve) => { - resolveGetUserMedia = resolve; - }) - ); - - const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); - - await audioLocalStream.disposeEffects(); - - const newMockStream = createMockedAudioStream(); - (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); - resolveGetUserMedia(newMockStream); - - await handlerPromise; - - expect(newTrackStopSpy).toHaveBeenCalledWith(); - expect(endedSpy).not.toHaveBeenCalled(); - expect(constraintsChangeSpy).not.toHaveBeenCalled(); - expect(effect.replaceInputTrack).not.toHaveBeenCalled(); - }); - - it('should discard new track when stream is stopped during getUserMedia', async () => { - expect.hasAssertions(); - - const constraintsChangeSpy = jest.spyOn( - audioLocalStream[LocalStreamEventNames.ConstraintsChange], - 'emit' - ); - const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); - - // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function - let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; - getUserMediaSpy.mockReturnValueOnce( - new Promise((resolve) => { - resolveGetUserMedia = resolve; - }) - ); - - const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); - - // Simulate the user stopping the stream while getUserMedia is pending. - // This stops the track but disposeEffects hasn't cleared this.effects yet. - const currentTrack = audioStream.getTracks()[0]; - (currentTrack as { readyState: string }).readyState = 'ended'; - - const newMockStream = createMockedAudioStream(); - (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); - resolveGetUserMedia(newMockStream); - - await handlerPromise; - - expect(newTrackStopSpy).toHaveBeenCalledWith(); - expect(constraintsChangeSpy).not.toHaveBeenCalled(); - expect(effect.replaceInputTrack).not.toHaveBeenCalled(); - }); - - it('should not register audio constraint handlers for video tracks', async () => { - expect.hasAssertions(); - - const videoStream = createMockedStream(); - const videoLocalStream = new TestLocalStream(videoStream); - - const videoEventHandlers = new Map void>(); - const videoEffect = { - id: 'video-effect', - kind: 'video-kind', - isEnabled: false, - dispose: jest.fn().mockResolvedValue(undefined), - load: jest.fn().mockResolvedValue(undefined), - on: jest.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { - videoEventHandlers.set(event, handler); - }), - off: jest.fn(), - } as unknown as TrackEffect; - - await videoLocalStream.addEffect(videoEffect); - - expect(videoEventHandlers.has('constraints-required')).toBe(false); - expect(videoEventHandlers.has('constraints-released')).toBe(false); - expect(videoEventHandlers.has('track-updated')).toBe(true); - expect(videoEventHandlers.has('disposed')).toBe(true); - }); - }); - describe('toJSON', () => { it('should correctly serialize data', () => { expect.assertions(1); diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index 3bd69a1..a75259f 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -1,6 +1,5 @@ import { AddEvents, TypedEvent, WithEventsDummyType } from '@webex/ts-events'; import { BaseEffect, EffectEvent } from '@webex/web-media-effects'; -import { getUserMedia } from '.'; import { WebrtcCoreError, WebrtcCoreErrorType } from '../errors'; import { logger } from '../util/logger'; import { Stream, StreamEventNames } from './stream'; @@ -37,7 +36,7 @@ abstract class _LocalStream extends Stream { [LocalStreamEventNames.EffectAdded] = new TypedEvent<(effect: TrackEffect) => void>(); - private effects: TrackEffect[] = []; + protected effects: TrackEffect[] = []; private loadingEffects: Map = new Map(); @@ -190,7 +189,7 @@ abstract class _LocalStream extends Stream { * * @param newTrack - The track to be used in the output stream. */ - private changeOutputTrack(newTrack: MediaStreamTrack): void { + protected changeOutputTrack(newTrack: MediaStreamTrack): void { if (this.outputTrack.id !== newTrack.id) { // If the input track and the *old* output track are currently the same, then the streams must // be the same too. We want to apply the new track to the output stream without affecting the @@ -268,160 +267,12 @@ abstract class _LocalStream extends Stream { } }; - /** - * Track settings saved before the effect changed them, keyed by constraint - * property name. Used to restore the user's original values when the effect - * emits {@link EffectEvent.ConstraintsReleased}. - */ - let savedTrackSettings: MediaTrackSettings = {}; - - /** - * Re-acquire the audio input track with the given constraints via - * getUserMedia (required because Chrome ignores applyConstraints for - * audio processing properties). - * - * See https://issues.chromium.org/issues/40555809. - * - * @param constraintsToApply - The constraints to apply to the new track. - */ - const reacquireInputTrack = async ( - constraintsToApply: MediaTrackConstraints - ): Promise => { - if (!this.effects.includes(effect)) { - logger.log(`Effect ${effect.id} was replaced or disposed, skipping constraint handling.`); - return; - } - - if (this.inputTrack.readyState === 'ended') { - logger.log(`Track already ended, ignoring constraints change.`); - return; - } - - const currentTrack = this.inputTrack; - const currentSettings = currentTrack.getSettings(); - const isEnabled = currentTrack.enabled; - - const isAlreadySatisfied = Object.entries(constraintsToApply).every( - ([key, value]) => currentSettings[key as keyof MediaTrackSettings] === value - ); - if (isAlreadySatisfied) { - logger.log(`Constraints already satisfied, skipping re-acquisition.`); - return; - } - - try { - const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; - - let newStream = await getUserMedia({ - audio: { ...currentSettings, ...constraintsToApply, deviceId }, - }).catch((err) => { - logger.warn(`Failed to re-acquire track with effect constraints, recovering:`, err); - return null; - }); - - if (!newStream) { - newStream = await getUserMedia({ - audio: { ...currentSettings, deviceId }, - }); - savedTrackSettings = {}; - } - - const [newTrack] = newStream.getAudioTracks(); - - // Skip if the effect or stream became inactive while - // getUserMedia was pending (e.g. effect replaced, user hung up). - if (!this.effects.includes(effect) || currentTrack.readyState === 'ended') { - newTrack.stop(); - savedTrackSettings = {}; - logger.log(`Effect was disposed during track re-acquisition, discarding new track.`); - return; - } - - // Stop the old track only after we confirmed the effect is still - // active and the replacement track is ready. - this.removeTrackHandlers(currentTrack); - currentTrack.stop(); - - newTrack.enabled = isEnabled; - this.inputStream.removeTrack(currentTrack); - this.inputStream.addTrack(newTrack); - this.addTrackHandlers(newTrack); - - if (this.effects.length > 0) { - await this.effects[0].replaceInputTrack(newTrack); - } - this[LocalStreamEventNames.ConstraintsChange].emit(); - logger.log(`Constraints applied via track re-acquisition.`); - } catch (err: unknown) { - savedTrackSettings = {}; - - if (!this.effects.includes(effect)) { - logger.log(`Effect was disposed during constraint handling, ignoring error.`); - return; - } - - if (this.inputTrack.readyState === 'live') { - this.changeOutputTrack(this.inputTrack); - logger.warn(`Effect wiring failed, continuing with raw mic track:`, err); - } else { - logger.error(`Failed to re-acquire mic track, stream ended:`, err); - this[StreamEventNames.Ended].emit(); - } - } - }; - - /** - * Handle when an audio effect requests specific constraints on the input - * track. Saves the current values for the requested properties so they can - * be restored later, then re-acquires the track with the new constraints. - * - * @param constraints - The constraints requested by the effect. - */ - const handleAudioConstraintsRequired = async ( - constraints: MediaTrackConstraints - ): Promise => { - logger.log(`Effect ${effect.id} constraints required:`, constraints); - - const currentSettings = this.inputTrack.getSettings(); - Object.keys(constraints).forEach((key) => { - if (!(key in savedTrackSettings)) { - Object.assign(savedTrackSettings, { - [key]: currentSettings[key as keyof MediaTrackSettings], - }); - } - }); - - await reacquireInputTrack(constraints); - }; - - /** - * Handle when an audio effect releases its constraint requirements - * (disable / dispose / model switch). Restores the previously saved - * track settings. - */ - const handleAudioConstraintsReleased = async (): Promise => { - logger.log(`Effect ${effect.id} constraints released.`); - - if (!Object.keys(savedTrackSettings).length) { - logger.log(`No settings to restore, skipping re-acquisition.`); - return; - } - - const toRestore = { ...savedTrackSettings }; - savedTrackSettings = {}; - await reacquireInputTrack(toRestore); - }; - /** * Handle when the effect has been disposed. This will remove all event listeners from the * effect. */ const handleEffectDisposed = () => { effect.off('track-updated' as EffectEvent, handleEffectTrackUpdated as never); - if (this.outputTrack.kind === 'audio') { - effect.off('constraints-required' as EffectEvent, handleAudioConstraintsRequired as never); - effect.off('constraints-released' as EffectEvent, handleAudioConstraintsReleased as never); - } effect.off('disposed' as EffectEvent, handleEffectDisposed as never); }; @@ -429,10 +280,6 @@ abstract class _LocalStream extends Stream { // web-media-effects lib to be rebuilt and inflates the size of the webrtc-core build, so // we use type assertion here as a temporary workaround. effect.on('track-updated' as EffectEvent, handleEffectTrackUpdated as never); - if (this.outputTrack.kind === 'audio') { - effect.on('constraints-required' as EffectEvent, handleAudioConstraintsRequired as never); - effect.on('constraints-released' as EffectEvent, handleAudioConstraintsReleased as never); - } effect.on('disposed' as EffectEvent, handleEffectDisposed as never); // Add the effect to the effects list. If an effect of the same kind has already been added, From 3ec062382561c2d4a16776df1307982072284678 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 16 Apr 2026 16:52:54 +0200 Subject: [PATCH 14/28] chore: fix type --- src/media/local-audio-stream.ts | 12 ++++++------ src/media/local-stream.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index e005f39..60919df 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -186,13 +186,13 @@ export class LocalAudioStream extends LocalStream { * The base class handles its own listener cleanup separately. */ const removeConstraintHandlers = () => { - effect.off('constraints-required' as EffectEvent, handleConstraintsRequired as never); - effect.off('constraints-released' as EffectEvent, handleConstraintsReleased as never); - effect.off('disposed' as EffectEvent, removeConstraintHandlers as never); + effect.off('constraints-required' as EffectEvent, handleConstraintsRequired); + effect.off('constraints-released' as EffectEvent, handleConstraintsReleased); + effect.off('disposed' as EffectEvent, removeConstraintHandlers); }; - effect.on('constraints-required' as EffectEvent, handleConstraintsRequired as never); - effect.on('constraints-released' as EffectEvent, handleConstraintsReleased as never); - effect.on('disposed' as EffectEvent, removeConstraintHandlers as never); + effect.on('constraints-required' as EffectEvent, handleConstraintsRequired); + effect.on('constraints-released' as EffectEvent, handleConstraintsReleased); + effect.on('disposed' as EffectEvent, removeConstraintHandlers); } } diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index a75259f..52e5194 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -272,15 +272,15 @@ abstract class _LocalStream extends Stream { * effect. */ const handleEffectDisposed = () => { - effect.off('track-updated' as EffectEvent, handleEffectTrackUpdated as never); - effect.off('disposed' as EffectEvent, handleEffectDisposed as never); + effect.off('track-updated' as EffectEvent, handleEffectTrackUpdated); + effect.off('disposed' as EffectEvent, handleEffectDisposed); }; // TODO: using EffectEvent.TrackUpdated or EffectEvent.Disposed will cause the entire // web-media-effects lib to be rebuilt and inflates the size of the webrtc-core build, so // we use type assertion here as a temporary workaround. - effect.on('track-updated' as EffectEvent, handleEffectTrackUpdated as never); - effect.on('disposed' as EffectEvent, handleEffectDisposed as never); + effect.on('track-updated' as EffectEvent, handleEffectTrackUpdated); + effect.on('disposed' as EffectEvent, handleEffectDisposed); // Add the effect to the effects list. If an effect of the same kind has already been added, // dispose the existing effect and replace it with the new effect. If the existing effect was From fe0b727341c84432a202f6191a7c1dc0c587d5c7 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 16 Apr 2026 18:18:29 +0200 Subject: [PATCH 15/28] chore: update addEffect --- src/media/local-audio-stream.spec.ts | 17 +++++++++++++++++ src/media/local-audio-stream.ts | 17 ++++++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts index a51b6ca..4df30ee 100644 --- a/src/media/local-audio-stream.spec.ts +++ b/src/media/local-audio-stream.spec.ts @@ -310,6 +310,23 @@ describe('LocalAudioStream', () => { expect(effect.replaceInputTrack).not.toHaveBeenCalled(); }); + it('should not register duplicate constraint handlers when addEffect is called with the same effect', async () => { + expect.hasAssertions(); + + const onCalls = (effect.on as jest.Mock).mock.calls; + const initialConstraintsRequiredCount = onCalls.filter( + ([event]: [string]) => event === 'constraints-required' + ).length; + + await audioLocalStream.addEffect(effect); + + const afterConstraintsRequiredCount = onCalls.filter( + ([event]: [string]) => event === 'constraints-required' + ).length; + + expect(afterConstraintsRequiredCount).toBe(initialConstraintsRequiredCount); + }); + it('should not register audio constraint handlers for video tracks', async () => { expect.hasAssertions(); diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index 60919df..ebcb5a6 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -18,8 +18,11 @@ export class LocalAudioStream extends LocalStream { * @inheritdoc */ async addEffect(effect: TrackEffect): Promise { - await super.addEffect(effect); + if (this.effects.some((e) => e.id === effect.id)) { + return; + } this.addConstraintHandlers(effect); + await super.addEffect(effect); } /** @@ -186,13 +189,13 @@ export class LocalAudioStream extends LocalStream { * The base class handles its own listener cleanup separately. */ const removeConstraintHandlers = () => { - effect.off('constraints-required' as EffectEvent, handleConstraintsRequired); - effect.off('constraints-released' as EffectEvent, handleConstraintsReleased); - effect.off('disposed' as EffectEvent, removeConstraintHandlers); + effect.off('constraints-required' as EffectEvent, handleConstraintsRequired as never); + effect.off('constraints-released' as EffectEvent, handleConstraintsReleased as never); + effect.off('disposed' as EffectEvent, removeConstraintHandlers as never); }; - effect.on('constraints-required' as EffectEvent, handleConstraintsRequired); - effect.on('constraints-released' as EffectEvent, handleConstraintsReleased); - effect.on('disposed' as EffectEvent, removeConstraintHandlers); + effect.on('constraints-required' as EffectEvent, handleConstraintsRequired as never); + effect.on('constraints-released' as EffectEvent, handleConstraintsReleased as never); + effect.on('disposed' as EffectEvent, removeConstraintHandlers as never); } } From 20ec267740642de4823379aec0ec196db5e49e78 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 16 Apr 2026 18:52:16 +0200 Subject: [PATCH 16/28] chore: update ordering --- src/media/local-audio-stream.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index ebcb5a6..30aca48 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -21,8 +21,8 @@ export class LocalAudioStream extends LocalStream { if (this.effects.some((e) => e.id === effect.id)) { return; } - this.addConstraintHandlers(effect); await super.addEffect(effect); + this.addConstraintHandlers(effect); } /** From f09c51068568af7242062546d0e67fb8065232dd Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Mon, 20 Apr 2026 14:52:26 +0300 Subject: [PATCH 17/28] refactor: update logic for filterToSupportedConstraints --- src/media/local-audio-stream.spec.ts | 132 +++++++++++++++++- src/media/local-audio-stream.ts | 123 +++++++++------- .../media-track-supported-constraints.ts | 22 +++ src/mocks/navigator-stub.ts | 2 + 4 files changed, 229 insertions(+), 50 deletions(-) create mode 100644 src/mocks/media-track-supported-constraints.ts diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts index 4df30ee..6a4d2cd 100644 --- a/src/media/local-audio-stream.spec.ts +++ b/src/media/local-audio-stream.spec.ts @@ -1,4 +1,5 @@ import * as media from '.'; +import { getSupportedConstraints } from '../mocks/media-track-supported-constraints'; import { createMockedAudioStream, createMockedStream } from '../util/test-utils'; import { LocalAudioStream } from './local-audio-stream'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; @@ -30,7 +31,20 @@ describe('LocalAudioStream', () => { let getUserMediaSpy: jest.SpyInstance; let newAudioTrack: MediaStreamTrack; + // Stub navigator.mediaDevices.getSupportedConstraints (absent in jsdom) + // so the filter in reacquireInputTrack mirrors a spec-compliant browser. + let originalMediaDevices: MediaDevices | undefined; + beforeEach(async () => { + originalMediaDevices = navigator.mediaDevices; + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + ...(originalMediaDevices ?? {}), + getSupportedConstraints, + }, + }); + audioStream = createMockedAudioStream(); audioLocalStream = new LocalAudioStream(audioStream); @@ -66,6 +80,10 @@ describe('LocalAudioStream', () => { afterEach(() => { getUserMediaSpy.mockRestore(); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: originalMediaDevices, + }); }); it('should call getUserMedia with current settings and effect constraints', async () => { @@ -86,6 +104,31 @@ describe('LocalAudioStream', () => { }); }); + it('should drop unsupported settings names before passing them to getUserMedia', async () => { + expect.hasAssertions(); + + const inputTrack = audioStream.getTracks()[0]; + (inputTrack.getSettings as jest.Mock).mockReturnValue({ + ...audioSettings, + // Vendor / non-standard properties that may appear in MediaTrackSettings + // but are not in MediaTrackSupportedConstraints. These must not reach + // getUserMedia, since they would be silently dropped by WebIDL anyway + // and only add noise to the constraints dictionary. + restrictOwnAudio: true, + suppressLocalAudioPlayback: false, + } as MediaTrackSettings); + + await constraintsRequiredHandler({ autoGainControl: false }); + + const passedConstraints = getUserMediaSpy.mock.calls[0][0].audio; + expect(passedConstraints).not.toHaveProperty('restrictOwnAudio'); + expect(passedConstraints).not.toHaveProperty('suppressLocalAudioPlayback'); + expect(passedConstraints).toMatchObject({ + deviceId: { exact: 'test-device-id' }, + autoGainControl: false, + }); + }); + it('should skip re-acquisition when nothing is saved and constraints are released', async () => { expect.hasAssertions(); @@ -145,6 +188,48 @@ describe('LocalAudioStream', () => { expect(getUserMediaSpy).not.toHaveBeenCalled(); }); + it('should preserve the saved baseline when a later constraints-required falls back', async () => { + expect.hasAssertions(); + + // First required: succeeds and saves { autoGainControl: true } as the user baseline. + await constraintsRequiredHandler({ autoGainControl: false }); + + // Track now reflects the effect-modified AGC=false state. + (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); + jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ + ...audioSettings, + autoGainControl: false, + }); + + // Second required: first getUserMedia fails, fallback succeeds. + const fallbackStream = createMockedAudioStream(); + const [fallbackTrack] = fallbackStream.getAudioTracks(); + jest.spyOn(fallbackTrack, 'getSettings').mockReturnValue({ + ...audioSettings, + autoGainControl: false, + }); + getUserMediaSpy + .mockRejectedValueOnce(new Error('OverconstrainedError')) + .mockResolvedValueOnce(fallbackStream); + + await constraintsRequiredHandler({ noiseSuppression: false }); + + (audioStream.getTracks as jest.Mock).mockReturnValue([fallbackTrack]); + getUserMediaSpy.mockClear(); + + // Released: must restore both user-baseline AGC=true and NS=true, + // not skip restoration because of a cleared baseline. + await constraintsReleasedHandler(); + + expect(getUserMediaSpy).toHaveBeenCalledTimes(1); + expect(getUserMediaSpy).toHaveBeenLastCalledWith({ + audio: expect.objectContaining({ + autoGainControl: true, + noiseSuppression: true, + }), + }); + }); + it('should replace the input track on the first effect', async () => { expect.hasAssertions(); @@ -217,11 +302,43 @@ describe('LocalAudioStream', () => { }); }); - it('should emit Ended when both getUserMedia calls fail', async () => { + it('should emit Ended when both getUserMedia calls fail and the input track is ended', async () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + const inputTrack = audioStream.getTracks()[0]; + getUserMediaSpy.mockImplementationOnce(async () => { + // Mimic the device disappearing: the original track ends before the + // fallback getUserMedia resolves, so the catch path sees a non-live + // input track and must emit Ended instead of silently bypassing. + (inputTrack as { readyState: string }).readyState = 'ended'; + throw new Error('OverconstrainedError'); + }); + getUserMediaSpy.mockRejectedValueOnce(new Error('NotFoundError')); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(getUserMediaSpy).toHaveBeenCalledTimes(2); + expect(endedSpy).toHaveBeenCalledWith(); + }); + + it('should fall back to raw mic and dispose the effect when both getUserMedia calls fail but the track is still live', async () => { + expect.hasAssertions(); + + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + const changeOutputTrackSpy = jest.spyOn( + audioLocalStream as unknown as { changeOutputTrack: (t: MediaStreamTrack) => void }, + 'changeOutputTrack' + ); + + const inputTrack = audioStream.getTracks()[0]; + (inputTrack as { readyState: string }).readyState = 'live'; + getUserMediaSpy .mockRejectedValueOnce(new Error('OverconstrainedError')) .mockRejectedValueOnce(new Error('NotFoundError')); @@ -229,7 +346,18 @@ describe('LocalAudioStream', () => { await constraintsRequiredHandler({ autoGainControl: false }); expect(getUserMediaSpy).toHaveBeenCalledTimes(2); - expect(endedSpy).toHaveBeenCalledWith(); + // Output is rewired to the original (effect-bypassed) mic track. + expect(changeOutputTrackSpy).toHaveBeenCalledWith(inputTrack); + // The failing effect is disposed and removed from the chain so it + // stops consuming CPU while running in bypass mode. + expect(effect.dispose).toHaveBeenCalledWith(); + expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( + effect + ); + // No new track was wired, so no ConstraintsChange; the stream is not + // ended, so no Ended. + expect(constraintsChangeSpy).not.toHaveBeenCalled(); + expect(endedSpy).not.toHaveBeenCalled(); }); it('should skip re-acquisition when the track is already ended', async () => { diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index 30aca48..fb2cd5b 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -4,12 +4,27 @@ import { logger } from '../util/logger'; import { StreamEventNames } from './stream'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; -// These are the audio constraints that can be applied via applyConstraints. +// Subset of audio constraints that can be applied to a live track. export type AppliableAudioConstraints = Pick< MediaTrackConstraints, 'autoGainControl' | 'echoCancellation' | 'noiseSuppression' >; +/** + * Keep only the keys the browser recognizes as valid getUserMedia constraints. + * + * @param settings - Current track settings. + * @returns Filtered settings safe for getUserMedia. + */ +const filterToSupportedConstraints = (settings: MediaTrackSettings): MediaTrackConstraints => { + const supported = navigator.mediaDevices?.getSupportedConstraints?.() ?? {}; + return Object.fromEntries( + Object.entries(settings).filter( + ([key]) => supported[key as keyof MediaTrackSupportedConstraints] + ) + ); +}; + /** * An audio LocalStream. */ @@ -26,10 +41,16 @@ export class LocalAudioStream extends LocalStream { } /** - * Apply constraints to the stream. + * Apply constraints to the existing input track. + * + * Note: on Chrome, `applyConstraints` silently ignores `autoGainControl`, + * `echoCancellation`, and `noiseSuppression` — the promise resolves but the + * value stays unchanged. To change those reliably, use an effect that emits + * {@link EffectEvent.ConstraintsRequired}. + * See https://issues.chromium.org/issues/40555809. * * @param constraints - The constraints to apply. - * @returns A promise which resolves when the constraints have been successfully applied. + * @returns Resolves when the browser finishes processing the request. */ async applyConstraints(constraints?: AppliableAudioConstraints): Promise { logger.log(`Applying constraints to local track:`, constraints); @@ -39,31 +60,29 @@ export class LocalAudioStream extends LocalStream { } /** - * Wire constraint event handlers for an audio effect. When the effect emits - * {@link EffectEvent.ConstraintsRequired}, the input track is re-acquired via - * getUserMedia with the requested settings. When the effect emits - * {@link EffectEvent.ConstraintsReleased}, the original settings are restored. + * Listen for constraint events from an audio effect and re-acquire the mic + * track via getUserMedia when needed. Restores original settings when the + * effect releases its constraints. * - * Re-acquisition is needed because Chrome ignores applyConstraints for audio - * processing properties (https://issues.chromium.org/issues/40555809). + * This is a workaround for Chrome ignoring `applyConstraints` on audio + * processing properties: https://issues.chromium.org/issues/40555809. * - * @param effect - The effect to add handlers for. + * @param effect - The effect to listen to. */ private addConstraintHandlers(effect: TrackEffect): void { let savedTrackSettings: MediaTrackSettings = {}; /** - * Re-acquire the audio input track with the given constraints via - * getUserMedia, since Chrome ignores applyConstraints for audio - * processing properties. + * Replace the current mic track with a new one obtained via getUserMedia, + * applying the given constraints on top of the current settings. * - * @param constraintsToApply - The constraints to apply to the new track. + * @param constraintsToApply - Constraints to merge into the current settings. */ const reacquireInputTrack = async ( constraintsToApply: MediaTrackConstraints ): Promise => { if (!this.effects.includes(effect)) { - logger.log(`Effect ${effect.id} was replaced or disposed, skipping constraint handling.`); + logger.log(`Effect ${effect.id} is no longer active, skipping constraint handling.`); return; } @@ -74,7 +93,6 @@ export class LocalAudioStream extends LocalStream { const currentTrack = this.inputTrack; const currentSettings = currentTrack.getSettings(); - const isEnabled = currentTrack.enabled; const isAlreadySatisfied = Object.entries(constraintsToApply).every( ([key, value]) => currentSettings[key as keyof MediaTrackSettings] === value @@ -86,9 +104,10 @@ export class LocalAudioStream extends LocalStream { try { const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; + const baselineConstraints = filterToSupportedConstraints(currentSettings); let newStream = await getUserMedia({ - audio: { ...currentSettings, ...constraintsToApply, deviceId }, + audio: { ...baselineConstraints, ...constraintsToApply, deviceId }, }).catch((err) => { logger.warn(`Failed to re-acquire track with effect constraints, recovering:`, err); return null; @@ -96,15 +115,15 @@ export class LocalAudioStream extends LocalStream { if (!newStream) { newStream = await getUserMedia({ - audio: { ...currentSettings, deviceId }, + audio: { ...baselineConstraints, deviceId }, }); - savedTrackSettings = {}; } const [newTrack] = newStream.getAudioTracks(); - // Skip if the effect or stream became inactive while - // getUserMedia was pending (e.g. effect replaced, user hung up). + // The effect may have been removed or the track may have ended while + // getUserMedia was running. Discard the new track so it doesn't keep + // the microphone open in the background. if (!this.effects.includes(effect) || currentTrack.readyState === 'ended') { newTrack.stop(); savedTrackSettings = {}; @@ -112,32 +131,36 @@ export class LocalAudioStream extends LocalStream { return; } - // Stop the old track only after we confirmed the effect is still - // active and the replacement track is ready. this.removeTrackHandlers(currentTrack); currentTrack.stop(); - newTrack.enabled = isEnabled; + // Preserve the mute state across the track swap. + newTrack.enabled = currentTrack.enabled; this.inputStream.removeTrack(currentTrack); this.inputStream.addTrack(newTrack); this.addTrackHandlers(newTrack); - if (this.effects.length > 0) { - await this.effects[0].replaceInputTrack(newTrack); - } + await this.effects[0].replaceInputTrack(newTrack); this[LocalStreamEventNames.ConstraintsChange].emit(); logger.log(`Constraints applied via track re-acquisition.`); } catch (err: unknown) { - savedTrackSettings = {}; - if (!this.effects.includes(effect)) { logger.log(`Effect was disposed during constraint handling, ignoring error.`); return; } if (this.inputTrack.readyState === 'live') { + // Both getUserMedia attempts failed but the original mic is still alive. + // Bypass the effect and send audio straight from the mic. + logger.error(`Effect wiring failed, disposing effect and continuing with raw mic:`, err); this.changeOutputTrack(this.inputTrack); - logger.warn(`Effect wiring failed, continuing with raw mic track:`, err); + const index = this.effects.indexOf(effect); + if (index >= 0) { + this.effects.splice(index, 1); + } + await effect.dispose().catch((disposeErr) => { + logger.error(`Failed to dispose effect after constraint failure:`, disposeErr); + }); } else { logger.error(`Failed to re-acquire mic track, stream ended:`, err); this[StreamEventNames.Ended].emit(); @@ -146,30 +169,34 @@ export class LocalAudioStream extends LocalStream { }; /** - * Handle when an audio effect requests specific constraints on the input - * track. Saves the current values for the requested properties so they can - * be restored later, then re-acquires the track with the new constraints. + * Called when the effect needs specific audio constraints. Saves the + * current values (so they can be restored later) and re-acquires the track. + * Only saves each key once — later events won't overwrite the original baseline. * - * @param constraints - The constraints requested by the effect. + * @param constraints - The constraints the effect needs. */ const handleConstraintsRequired = async (constraints: MediaTrackConstraints): Promise => { logger.log(`Effect ${effect.id} constraints required:`, constraints); const currentSettings = this.inputTrack.getSettings(); - Object.keys(constraints).forEach((key) => { - if (!(key in savedTrackSettings)) { - Object.assign(savedTrackSettings, { - [key]: currentSettings[key as keyof MediaTrackSettings], - }); + /** + * Save a single setting if not already saved. + * + * @param key - The setting key to save. + */ + const snapshot = (key: K): void => { + if (!(key in savedTrackSettings) && key in currentSettings) { + savedTrackSettings[key] = currentSettings[key]; } - }); + }; + (Object.keys(constraints) as Array).forEach(snapshot); await reacquireInputTrack(constraints); }; /** - * Handle when an audio effect releases its constraint requirements. - * Restores the previously saved track settings. + * Called when the effect no longer needs its constraints. + * Restores the settings that were saved by handleConstraintsRequired. */ const handleConstraintsReleased = async (): Promise => { logger.log(`Effect ${effect.id} constraints released.`); @@ -189,13 +216,13 @@ export class LocalAudioStream extends LocalStream { * The base class handles its own listener cleanup separately. */ const removeConstraintHandlers = () => { - effect.off('constraints-required' as EffectEvent, handleConstraintsRequired as never); - effect.off('constraints-released' as EffectEvent, handleConstraintsReleased as never); - effect.off('disposed' as EffectEvent, removeConstraintHandlers as never); + effect.off('constraints-required' as EffectEvent, handleConstraintsRequired); + effect.off('constraints-released' as EffectEvent, handleConstraintsReleased); + effect.off('disposed' as EffectEvent, removeConstraintHandlers); }; - effect.on('constraints-required' as EffectEvent, handleConstraintsRequired as never); - effect.on('constraints-released' as EffectEvent, handleConstraintsReleased as never); - effect.on('disposed' as EffectEvent, removeConstraintHandlers as never); + effect.on('constraints-required' as EffectEvent, handleConstraintsRequired); + effect.on('constraints-released' as EffectEvent, handleConstraintsReleased); + effect.on('disposed' as EffectEvent, removeConstraintHandlers); } } diff --git a/src/mocks/media-track-supported-constraints.ts b/src/mocks/media-track-supported-constraints.ts new file mode 100644 index 0000000..5308625 --- /dev/null +++ b/src/mocks/media-track-supported-constraints.ts @@ -0,0 +1,22 @@ +/** + * Test stub for navigator.mediaDevices.getSupportedConstraints. Lives in its + * own module so specs can use it without importing navigator-stub, which + * replaces window.navigator as a side effect. + * + * @returns A stub where every constraint reports as supported. + */ +export const getSupportedConstraints = (): MediaTrackSupportedConstraints => ({ + aspectRatio: true, + autoGainControl: true, + channelCount: true, + deviceId: true, + echoCancellation: true, + facingMode: true, + frameRate: true, + groupId: true, + height: true, + noiseSuppression: true, + sampleRate: true, + sampleSize: true, + width: true, +}); diff --git a/src/mocks/navigator-stub.ts b/src/mocks/navigator-stub.ts index 6bcd9d9..59ee959 100644 --- a/src/mocks/navigator-stub.ts +++ b/src/mocks/navigator-stub.ts @@ -1,5 +1,6 @@ import { createPermissionStatus } from './create-permission-status'; import MediaStream from './media-stream-stub'; +import { getSupportedConstraints } from './media-track-supported-constraints'; /** * A getUserMedia stub, returns a MediaStream with tracks according to the constraints passed in. @@ -46,6 +47,7 @@ const permissionsQuery = async (descriptor: PermissionDescriptor): Promise Date: Mon, 20 Apr 2026 15:12:29 +0300 Subject: [PATCH 18/28] refactor: update for savedTrackSettings and as never for tests --- src/media/local-audio-stream.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index fb2cd5b..baca8f6 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -126,7 +126,6 @@ export class LocalAudioStream extends LocalStream { // the microphone open in the background. if (!this.effects.includes(effect) || currentTrack.readyState === 'ended') { newTrack.stop(); - savedTrackSettings = {}; logger.log(`Effect was disposed during track re-acquisition, discarding new track.`); return; } @@ -216,13 +215,13 @@ export class LocalAudioStream extends LocalStream { * The base class handles its own listener cleanup separately. */ const removeConstraintHandlers = () => { - effect.off('constraints-required' as EffectEvent, handleConstraintsRequired); - effect.off('constraints-released' as EffectEvent, handleConstraintsReleased); - effect.off('disposed' as EffectEvent, removeConstraintHandlers); + effect.off('constraints-required' as EffectEvent, handleConstraintsRequired as never); + effect.off('constraints-released' as EffectEvent, handleConstraintsReleased as never); + effect.off('disposed' as EffectEvent, removeConstraintHandlers as never); }; - effect.on('constraints-required' as EffectEvent, handleConstraintsRequired); - effect.on('constraints-released' as EffectEvent, handleConstraintsReleased); - effect.on('disposed' as EffectEvent, removeConstraintHandlers); + effect.on('constraints-required' as EffectEvent, handleConstraintsRequired as never); + effect.on('constraints-released' as EffectEvent, handleConstraintsReleased as never); + effect.on('disposed' as EffectEvent, removeConstraintHandlers as never); } } From 34689a22a90646b34304c98195b9b16fca01e35a Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Mon, 20 Apr 2026 15:29:34 +0300 Subject: [PATCH 19/28] refactor: update disposeEffects fir live readyState catch --- src/media/local-audio-stream.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index baca8f6..c69b369 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -149,16 +149,12 @@ export class LocalAudioStream extends LocalStream { } if (this.inputTrack.readyState === 'live') { - // Both getUserMedia attempts failed but the original mic is still alive. - // Bypass the effect and send audio straight from the mic. - logger.error(`Effect wiring failed, disposing effect and continuing with raw mic:`, err); - this.changeOutputTrack(this.inputTrack); - const index = this.effects.indexOf(effect); - if (index >= 0) { - this.effects.splice(index, 1); - } - await effect.dispose().catch((disposeErr) => { - logger.error(`Failed to dispose effect after constraint failure:`, disposeErr); + // Mic is still live but the effect chain is broken. Tear down all + // effects so the raw mic plays through and getEffects() matches the + // actual audio path. + logger.error(`Effect wiring failed, falling back to raw mic:`, err); + await this.disposeEffects().catch((disposeErr) => { + logger.error(`Failed to dispose effects after fallback:`, disposeErr); }); } else { logger.error(`Failed to re-acquire mic track, stream ended:`, err); From ea33c2da6015fd9cb7d2fa30d5b182dd7837d270 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Mon, 20 Apr 2026 16:24:21 +0300 Subject: [PATCH 20/28] test: add additional tests and logic for effectsToDispose --- src/media/local-audio-stream.spec.ts | 73 +++++++++++++++++++--------- src/media/local-audio-stream.ts | 19 +++++--- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts index 6a4d2cd..926ffd9 100644 --- a/src/media/local-audio-stream.spec.ts +++ b/src/media/local-audio-stream.spec.ts @@ -5,10 +5,7 @@ import { LocalAudioStream } from './local-audio-stream'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; import { StreamEventNames } from './stream'; -/** - * A dummy LocalStream implementation for testing that video streams - * do not register audio constraint handlers. - */ +/** Bare LocalStream subclass — used to assert non-audio streams don't get constraint handlers. */ class TestLocalStream extends LocalStream {} describe('LocalAudioStream', () => { @@ -31,8 +28,7 @@ describe('LocalAudioStream', () => { let getUserMediaSpy: jest.SpyInstance; let newAudioTrack: MediaStreamTrack; - // Stub navigator.mediaDevices.getSupportedConstraints (absent in jsdom) - // so the filter in reacquireInputTrack mirrors a spec-compliant browser. + // jsdom doesn't expose getSupportedConstraints; stub it so the filter behaves like a real browser. let originalMediaDevices: MediaDevices | undefined; beforeEach(async () => { @@ -108,12 +104,10 @@ describe('LocalAudioStream', () => { expect.hasAssertions(); const inputTrack = audioStream.getTracks()[0]; + // Non-standard keys that show up in getSettings() but aren't valid constraints — + // the filter should strip them before they reach getUserMedia. (inputTrack.getSettings as jest.Mock).mockReturnValue({ ...audioSettings, - // Vendor / non-standard properties that may appear in MediaTrackSettings - // but are not in MediaTrackSupportedConstraints. These must not reach - // getUserMedia, since they would be silently dropped by WebIDL anyway - // and only add noise to the constraints dictionary. restrictOwnAudio: true, suppressLocalAudioPlayback: false, } as MediaTrackSettings); @@ -191,17 +185,17 @@ describe('LocalAudioStream', () => { it('should preserve the saved baseline when a later constraints-required falls back', async () => { expect.hasAssertions(); - // First required: succeeds and saves { autoGainControl: true } as the user baseline. + // First required: succeeds, saves AGC=true as the baseline. await constraintsRequiredHandler({ autoGainControl: false }); - // Track now reflects the effect-modified AGC=false state. + // Track now reports the effect-modified AGC=false. (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ ...audioSettings, autoGainControl: false, }); - // Second required: first getUserMedia fails, fallback succeeds. + // Second required: primary getUserMedia fails, fallback succeeds. const fallbackStream = createMockedAudioStream(); const [fallbackTrack] = fallbackStream.getAudioTracks(); jest.spyOn(fallbackTrack, 'getSettings').mockReturnValue({ @@ -217,8 +211,7 @@ describe('LocalAudioStream', () => { (audioStream.getTracks as jest.Mock).mockReturnValue([fallbackTrack]); getUserMediaSpy.mockClear(); - // Released: must restore both user-baseline AGC=true and NS=true, - // not skip restoration because of a cleared baseline. + // Released: must restore the original AGC=true and NS=true baseline. await constraintsReleasedHandler(); expect(getUserMediaSpy).toHaveBeenCalledTimes(1); @@ -238,6 +231,19 @@ describe('LocalAudioStream', () => { expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); }); + it('should emit ConstraintsChange after a successful re-acquisition', async () => { + expect.hasAssertions(); + + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(constraintsChangeSpy).toHaveBeenCalledWith(); + }); + it('should remove track handlers before stopping the current track', async () => { expect.hasAssertions(); @@ -309,9 +315,7 @@ describe('LocalAudioStream', () => { const inputTrack = audioStream.getTracks()[0]; getUserMediaSpy.mockImplementationOnce(async () => { - // Mimic the device disappearing: the original track ends before the - // fallback getUserMedia resolves, so the catch path sees a non-live - // input track and must emit Ended instead of silently bypassing. + // Device disappears mid-flight: track ends before gUM resolves. (inputTrack as { readyState: string }).readyState = 'ended'; throw new Error('OverconstrainedError'); }); @@ -346,20 +350,43 @@ describe('LocalAudioStream', () => { await constraintsRequiredHandler({ autoGainControl: false }); expect(getUserMediaSpy).toHaveBeenCalledTimes(2); - // Output is rewired to the original (effect-bypassed) mic track. + // Output is routed back to the raw mic. expect(changeOutputTrackSpy).toHaveBeenCalledWith(inputTrack); - // The failing effect is disposed and removed from the chain so it - // stops consuming CPU while running in bypass mode. + // The whole chain is torn down so getEffects() matches the actual audio path. expect(effect.dispose).toHaveBeenCalledWith(); expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( effect ); - // No new track was wired, so no ConstraintsChange; the stream is not - // ended, so no Ended. + // No track swap → no ConstraintsChange; mic is still live → no Ended. expect(constraintsChangeSpy).not.toHaveBeenCalled(); expect(endedSpy).not.toHaveBeenCalled(); }); + it('should ignore constraints-released emitted from dispose during fallback', async () => { + // NoiseReductionEffect emits constraints-released from dispose(). The fallback drains + // this.effects first, so the released handler must bail and not trigger a third gUM call. + expect.hasAssertions(); + + const inputTrack = audioStream.getTracks()[0]; + (inputTrack as { readyState: string }).readyState = 'live'; + + (effect.dispose as jest.Mock).mockImplementation(async () => { + await constraintsReleasedHandler(); + }); + + getUserMediaSpy + .mockRejectedValueOnce(new Error('OverconstrainedError')) + .mockRejectedValueOnce(new Error('NotFoundError')); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(getUserMediaSpy).toHaveBeenCalledTimes(2); + expect(effect.dispose).toHaveBeenCalledWith(); + expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( + effect + ); + }); + it('should skip re-acquisition when the track is already ended', async () => { expect.hasAssertions(); diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index c69b369..0a441a9 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -149,13 +149,20 @@ export class LocalAudioStream extends LocalStream { } if (this.inputTrack.readyState === 'live') { - // Mic is still live but the effect chain is broken. Tear down all - // effects so the raw mic plays through and getEffects() matches the - // actual audio path. + // Mic is still live but the effect chain is broken — fall back to raw audio. + // Clear this.effects before disposing so any constraints-released events + // fired from inside dispose() are skipped by the reacquire guards. logger.error(`Effect wiring failed, falling back to raw mic:`, err); - await this.disposeEffects().catch((disposeErr) => { - logger.error(`Failed to dispose effects after fallback:`, disposeErr); - }); + this.changeOutputTrack(this.inputTrack); + const effectsToDispose = this.effects; + this.effects = []; + await Promise.all( + effectsToDispose.map((e) => + e.dispose().catch((disposeErr) => { + logger.error(`Failed to dispose effect after fallback:`, disposeErr); + }) + ) + ); } else { logger.error(`Failed to re-acquire mic track, stream ended:`, err); this[StreamEventNames.Ended].emit(); From 69e7aadc78426d370c0305431827a853acdf465d Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Mon, 20 Apr 2026 16:54:19 +0300 Subject: [PATCH 21/28] test: improve tests --- src/media/local-audio-stream.spec.ts | 41 +++++++++------------------- src/media/local-audio-stream.ts | 3 ++ src/media/local-stream.ts | 2 +- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts index 926ffd9..c1eeb12 100644 --- a/src/media/local-audio-stream.spec.ts +++ b/src/media/local-audio-stream.spec.ts @@ -327,7 +327,7 @@ describe('LocalAudioStream', () => { expect(endedSpy).toHaveBeenCalledWith(); }); - it('should fall back to raw mic and dispose the effect when both getUserMedia calls fail but the track is still live', async () => { + it('should fall back to raw mic and tear down the chain when both getUserMedia calls fail but the track is still live', async () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); @@ -343,37 +343,18 @@ describe('LocalAudioStream', () => { const inputTrack = audioStream.getTracks()[0]; (inputTrack as { readyState: string }).readyState = 'live'; - getUserMediaSpy - .mockRejectedValueOnce(new Error('OverconstrainedError')) - .mockRejectedValueOnce(new Error('NotFoundError')); - - await constraintsRequiredHandler({ autoGainControl: false }); - - expect(getUserMediaSpy).toHaveBeenCalledTimes(2); - // Output is routed back to the raw mic. - expect(changeOutputTrackSpy).toHaveBeenCalledWith(inputTrack); - // The whole chain is torn down so getEffects() matches the actual audio path. - expect(effect.dispose).toHaveBeenCalledWith(); - expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( - effect - ); - // No track swap → no ConstraintsChange; mic is still live → no Ended. - expect(constraintsChangeSpy).not.toHaveBeenCalled(); - expect(endedSpy).not.toHaveBeenCalled(); - }); - - it('should ignore constraints-released emitted from dispose during fallback', async () => { - // NoiseReductionEffect emits constraints-released from dispose(). The fallback drains - // this.effects first, so the released handler must bail and not trigger a third gUM call. - expect.hasAssertions(); - - const inputTrack = audioStream.getTracks()[0]; - (inputTrack as { readyState: string }).readyState = 'live'; - + // Simulate NoiseReductionEffect, which emits constraints-released from dispose(). (effect.dispose as jest.Mock).mockImplementation(async () => { await constraintsReleasedHandler(); }); + // Simulate a second effect still being loaded by addEffect() at the moment of fallback. + const { loadingEffects } = audioLocalStream as unknown as { + loadingEffects: Map; + }; + const pendingEffect = { id: 'pending', kind: 'noise-reduction' } as unknown as TrackEffect; + loadingEffects.set(pendingEffect.kind, pendingEffect); + getUserMediaSpy .mockRejectedValueOnce(new Error('OverconstrainedError')) .mockRejectedValueOnce(new Error('NotFoundError')); @@ -381,10 +362,14 @@ describe('LocalAudioStream', () => { await constraintsRequiredHandler({ autoGainControl: false }); expect(getUserMediaSpy).toHaveBeenCalledTimes(2); + expect(changeOutputTrackSpy).toHaveBeenCalledWith(inputTrack); expect(effect.dispose).toHaveBeenCalledWith(); expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( effect ); + expect(loadingEffects.size).toBe(0); + expect(constraintsChangeSpy).not.toHaveBeenCalled(); + expect(endedSpy).not.toHaveBeenCalled(); }); it('should skip re-acquisition when the track is already ended', async () => { diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index 0a441a9..0c2856d 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -154,6 +154,9 @@ export class LocalAudioStream extends LocalStream { // fired from inside dispose() are skipped by the reacquire guards. logger.error(`Effect wiring failed, falling back to raw mic:`, err); this.changeOutputTrack(this.inputTrack); + // If another effect is still loading, drop it too so it can't + // slip back into the chain once we've fallen back to raw mic. + this.loadingEffects.clear(); const effectsToDispose = this.effects; this.effects = []; await Promise.all( diff --git a/src/media/local-stream.ts b/src/media/local-stream.ts index 52e5194..83e665a 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -38,7 +38,7 @@ abstract class _LocalStream extends Stream { protected effects: TrackEffect[] = []; - private loadingEffects: Map = new Map(); + protected loadingEffects: Map = new Map(); // The output stream can change to reflect any effects that have // been added. This member will always point to the MediaStream From 908a40b6feaf71a422155872f3e356fe341b7c64 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Mon, 20 Apr 2026 18:47:49 +0300 Subject: [PATCH 22/28] chore: improve edge cases --- src/media/local-audio-stream.spec.ts | 52 ++++++++++------------------ src/media/local-audio-stream.ts | 28 +++++++++------ 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts index c1eeb12..36b7a34 100644 --- a/src/media/local-audio-stream.spec.ts +++ b/src/media/local-audio-stream.spec.ts @@ -5,7 +5,10 @@ import { LocalAudioStream } from './local-audio-stream'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; import { StreamEventNames } from './stream'; -/** Bare LocalStream subclass — used to assert non-audio streams don't get constraint handlers. */ +/** + * A dummy LocalStream implementation for testing that video streams + * do not register audio constraint handlers. + */ class TestLocalStream extends LocalStream {} describe('LocalAudioStream', () => { @@ -28,7 +31,8 @@ describe('LocalAudioStream', () => { let getUserMediaSpy: jest.SpyInstance; let newAudioTrack: MediaStreamTrack; - // jsdom doesn't expose getSupportedConstraints; stub it so the filter behaves like a real browser. + // Stub navigator.mediaDevices.getSupportedConstraints (absent in jsdom) + // so the filter in reacquireInputTrack mirrors a spec-compliant browser. let originalMediaDevices: MediaDevices | undefined; beforeEach(async () => { @@ -185,17 +189,17 @@ describe('LocalAudioStream', () => { it('should preserve the saved baseline when a later constraints-required falls back', async () => { expect.hasAssertions(); - // First required: succeeds, saves AGC=true as the baseline. + // First required: succeeds and saves { autoGainControl: true } as the user baseline. await constraintsRequiredHandler({ autoGainControl: false }); - // Track now reports the effect-modified AGC=false. + // Track now reflects the effect-modified AGC=false state. (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ ...audioSettings, autoGainControl: false, }); - // Second required: primary getUserMedia fails, fallback succeeds. + // Second required: first getUserMedia fails, fallback succeeds. const fallbackStream = createMockedAudioStream(); const [fallbackTrack] = fallbackStream.getAudioTracks(); jest.spyOn(fallbackTrack, 'getSettings').mockReturnValue({ @@ -211,7 +215,8 @@ describe('LocalAudioStream', () => { (audioStream.getTracks as jest.Mock).mockReturnValue([fallbackTrack]); getUserMediaSpy.mockClear(); - // Released: must restore the original AGC=true and NS=true baseline. + // Released: must restore both user-baseline AGC=true and NS=true, + // not skip restoration because of a cleared baseline. await constraintsReleasedHandler(); expect(getUserMediaSpy).toHaveBeenCalledTimes(1); @@ -231,19 +236,6 @@ describe('LocalAudioStream', () => { expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); }); - it('should emit ConstraintsChange after a successful re-acquisition', async () => { - expect.hasAssertions(); - - const constraintsChangeSpy = jest.spyOn( - audioLocalStream[LocalStreamEventNames.ConstraintsChange], - 'emit' - ); - - await constraintsRequiredHandler({ autoGainControl: false }); - - expect(constraintsChangeSpy).toHaveBeenCalledWith(); - }); - it('should remove track handlers before stopping the current track', async () => { expect.hasAssertions(); @@ -315,7 +307,9 @@ describe('LocalAudioStream', () => { const inputTrack = audioStream.getTracks()[0]; getUserMediaSpy.mockImplementationOnce(async () => { - // Device disappears mid-flight: track ends before gUM resolves. + // Mimic the device disappearing: the original track ends before the + // fallback getUserMedia resolves, so the catch path sees a non-live + // input track and must emit Ended instead of silently bypassing. (inputTrack as { readyState: string }).readyState = 'ended'; throw new Error('OverconstrainedError'); }); @@ -327,7 +321,7 @@ describe('LocalAudioStream', () => { expect(endedSpy).toHaveBeenCalledWith(); }); - it('should fall back to raw mic and tear down the chain when both getUserMedia calls fail but the track is still live', async () => { + it('should fall back to raw mic and dispose the effect when both getUserMedia calls fail but the track is still live', async () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); @@ -343,18 +337,6 @@ describe('LocalAudioStream', () => { const inputTrack = audioStream.getTracks()[0]; (inputTrack as { readyState: string }).readyState = 'live'; - // Simulate NoiseReductionEffect, which emits constraints-released from dispose(). - (effect.dispose as jest.Mock).mockImplementation(async () => { - await constraintsReleasedHandler(); - }); - - // Simulate a second effect still being loaded by addEffect() at the moment of fallback. - const { loadingEffects } = audioLocalStream as unknown as { - loadingEffects: Map; - }; - const pendingEffect = { id: 'pending', kind: 'noise-reduction' } as unknown as TrackEffect; - loadingEffects.set(pendingEffect.kind, pendingEffect); - getUserMediaSpy .mockRejectedValueOnce(new Error('OverconstrainedError')) .mockRejectedValueOnce(new Error('NotFoundError')); @@ -367,7 +349,8 @@ describe('LocalAudioStream', () => { expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( effect ); - expect(loadingEffects.size).toBe(0); + // No new track was wired, so no ConstraintsChange; the stream is not + // ended, so no Ended. expect(constraintsChangeSpy).not.toHaveBeenCalled(); expect(endedSpy).not.toHaveBeenCalled(); }); @@ -435,6 +418,7 @@ describe('LocalAudioStream', () => { ); const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); + await Promise.resolve(); const currentTrack = audioStream.getTracks()[0]; (currentTrack as { readyState: string }).readyState = 'ended'; diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index 0c2856d..ed54055 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -77,18 +77,19 @@ export class LocalAudioStream extends LocalStream { * applying the given constraints on top of the current settings. * * @param constraintsToApply - Constraints to merge into the current settings. + * @returns Whether constraints were applied (or already satisfied). */ const reacquireInputTrack = async ( constraintsToApply: MediaTrackConstraints - ): Promise => { + ): Promise => { if (!this.effects.includes(effect)) { logger.log(`Effect ${effect.id} is no longer active, skipping constraint handling.`); - return; + return false; } if (this.inputTrack.readyState === 'ended') { logger.log(`Track already ended, ignoring constraints change.`); - return; + return false; } const currentTrack = this.inputTrack; @@ -99,7 +100,7 @@ export class LocalAudioStream extends LocalStream { ); if (isAlreadySatisfied) { logger.log(`Constraints already satisfied, skipping re-acquisition.`); - return; + return true; } try { @@ -120,6 +121,10 @@ export class LocalAudioStream extends LocalStream { } const [newTrack] = newStream.getAudioTracks(); + if (!newTrack) { + logger.warn(`Re-acquire returned no audio track, skipping replacement.`); + return false; + } // The effect may have been removed or the track may have ended while // getUserMedia was running. Discard the new track so it doesn't keep @@ -127,7 +132,7 @@ export class LocalAudioStream extends LocalStream { if (!this.effects.includes(effect) || currentTrack.readyState === 'ended') { newTrack.stop(); logger.log(`Effect was disposed during track re-acquisition, discarding new track.`); - return; + return false; } this.removeTrackHandlers(currentTrack); @@ -139,13 +144,14 @@ export class LocalAudioStream extends LocalStream { this.inputStream.addTrack(newTrack); this.addTrackHandlers(newTrack); - await this.effects[0].replaceInputTrack(newTrack); + await effect.replaceInputTrack(newTrack); this[LocalStreamEventNames.ConstraintsChange].emit(); logger.log(`Constraints applied via track re-acquisition.`); + return true; } catch (err: unknown) { if (!this.effects.includes(effect)) { logger.log(`Effect was disposed during constraint handling, ignoring error.`); - return; + return false; } if (this.inputTrack.readyState === 'live') { @@ -170,6 +176,7 @@ export class LocalAudioStream extends LocalStream { logger.error(`Failed to re-acquire mic track, stream ended:`, err); this[StreamEventNames.Ended].emit(); } + return false; } }; @@ -212,8 +219,10 @@ export class LocalAudioStream extends LocalStream { } const toRestore = { ...savedTrackSettings }; - savedTrackSettings = {}; - await reacquireInputTrack(toRestore); + const restored = await reacquireInputTrack(toRestore); + if (restored) { + savedTrackSettings = {}; + } }; /** @@ -225,7 +234,6 @@ export class LocalAudioStream extends LocalStream { effect.off('constraints-released' as EffectEvent, handleConstraintsReleased as never); effect.off('disposed' as EffectEvent, removeConstraintHandlers as never); }; - effect.on('constraints-required' as EffectEvent, handleConstraintsRequired as never); effect.on('constraints-released' as EffectEvent, handleConstraintsReleased as never); effect.on('disposed' as EffectEvent, removeConstraintHandlers as never); From a7786f2e844f03b840c14c8a522dbae8db45e809 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 8 May 2026 14:54:42 +0200 Subject: [PATCH 23/28] chore: update tests --- src/media/local-audio-stream.spec.ts | 62 +++++++--------------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts index 36b7a34..23f2045 100644 --- a/src/media/local-audio-stream.spec.ts +++ b/src/media/local-audio-stream.spec.ts @@ -186,7 +186,7 @@ describe('LocalAudioStream', () => { expect(getUserMediaSpy).not.toHaveBeenCalled(); }); - it('should preserve the saved baseline when a later constraints-required falls back', async () => { + it('should preserve the saved baseline across multiple constraints-required events', async () => { expect.hasAssertions(); // First required: succeeds and saves { autoGainControl: true } as the user baseline. @@ -199,24 +199,22 @@ describe('LocalAudioStream', () => { autoGainControl: false, }); - // Second required: first getUserMedia fails, fallback succeeds. - const fallbackStream = createMockedAudioStream(); - const [fallbackTrack] = fallbackStream.getAudioTracks(); - jest.spyOn(fallbackTrack, 'getSettings').mockReturnValue({ + // Second required: succeeds and saves { noiseSuppression: true } as the user baseline. + const secondStream = createMockedAudioStream(); + const [secondTrack] = secondStream.getAudioTracks(); + jest.spyOn(secondTrack, 'getSettings').mockReturnValue({ ...audioSettings, autoGainControl: false, + noiseSuppression: false, }); - getUserMediaSpy - .mockRejectedValueOnce(new Error('OverconstrainedError')) - .mockResolvedValueOnce(fallbackStream); + getUserMediaSpy.mockResolvedValueOnce(secondStream); await constraintsRequiredHandler({ noiseSuppression: false }); - (audioStream.getTracks as jest.Mock).mockReturnValue([fallbackTrack]); + (audioStream.getTracks as jest.Mock).mockReturnValue([secondTrack]); getUserMediaSpy.mockClear(); - // Released: must restore both user-baseline AGC=true and NS=true, - // not skip restoration because of a cleared baseline. + // Released: must restore both user-baseline AGC=true and NS=true. await constraintsReleasedHandler(); expect(getUserMediaSpy).toHaveBeenCalledTimes(1); @@ -228,7 +226,7 @@ describe('LocalAudioStream', () => { }); }); - it('should replace the input track on the first effect', async () => { + it('should call replaceInputTrack on the effect with the new track', async () => { expect.hasAssertions(); await constraintsRequiredHandler({ autoGainControl: false }); @@ -276,31 +274,7 @@ describe('LocalAudioStream', () => { expect(callOrder).toStrictEqual(['getUserMedia', 'stop']); }); - it('should fall back to getUserMedia without effect constraints when first call fails', async () => { - expect.hasAssertions(); - - const fallbackStream = createMockedAudioStream(); - getUserMediaSpy - .mockRejectedValueOnce(new Error('OverconstrainedError')) - .mockResolvedValueOnce(fallbackStream); - - await constraintsRequiredHandler({ autoGainControl: false }); - - expect(getUserMediaSpy).toHaveBeenCalledTimes(2); - expect(getUserMediaSpy).toHaveBeenLastCalledWith({ - audio: { - deviceId: { exact: 'test-device-id' }, - sampleRate: 48000, - channelCount: 1, - sampleSize: 16, - echoCancellation: true, - autoGainControl: true, - noiseSuppression: true, - }, - }); - }); - - it('should emit Ended when both getUserMedia calls fail and the input track is ended', async () => { + it('should emit Ended when getUserMedia fails and the input track is ended', async () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); @@ -311,17 +285,15 @@ describe('LocalAudioStream', () => { // fallback getUserMedia resolves, so the catch path sees a non-live // input track and must emit Ended instead of silently bypassing. (inputTrack as { readyState: string }).readyState = 'ended'; - throw new Error('OverconstrainedError'); + throw new Error('NotFoundError'); }); - getUserMediaSpy.mockRejectedValueOnce(new Error('NotFoundError')); await constraintsRequiredHandler({ autoGainControl: false }); - expect(getUserMediaSpy).toHaveBeenCalledTimes(2); expect(endedSpy).toHaveBeenCalledWith(); }); - it('should fall back to raw mic and dispose the effect when both getUserMedia calls fail but the track is still live', async () => { + it('should fall back to raw mic and dispose the effect when getUserMedia fails but the track is still live', async () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); @@ -336,14 +308,10 @@ describe('LocalAudioStream', () => { const inputTrack = audioStream.getTracks()[0]; (inputTrack as { readyState: string }).readyState = 'live'; - - getUserMediaSpy - .mockRejectedValueOnce(new Error('OverconstrainedError')) - .mockRejectedValueOnce(new Error('NotFoundError')); + getUserMediaSpy.mockRejectedValueOnce(new Error('NotFoundError')); await constraintsRequiredHandler({ autoGainControl: false }); - expect(getUserMediaSpy).toHaveBeenCalledTimes(2); expect(changeOutputTrackSpy).toHaveBeenCalledWith(inputTrack); expect(effect.dispose).toHaveBeenCalledWith(); expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( @@ -400,7 +368,7 @@ describe('LocalAudioStream', () => { expect(effect.replaceInputTrack).not.toHaveBeenCalled(); }); - it('should discard new track when stream is stopped during getUserMedia', async () => { + it('should discard new track when the input track ends during getUserMedia', async () => { expect.hasAssertions(); const constraintsChangeSpy = jest.spyOn( From f5ab8833f7a6ea342683add9f4f0f8b4e5e24659 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Fri, 8 May 2026 14:55:17 +0200 Subject: [PATCH 24/28] chore: simplify logic in a file --- src/media/local-audio-stream.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index ed54055..a723623 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -4,10 +4,15 @@ import { logger } from '../util/logger'; import { StreamEventNames } from './stream'; import { LocalStream, LocalStreamEventNames, TrackEffect } from './local-stream'; -// Subset of audio constraints that can be applied to a live track. +// Audio constraints that can be applied via applyConstraints. export type AppliableAudioConstraints = Pick< MediaTrackConstraints, - 'autoGainControl' | 'echoCancellation' | 'noiseSuppression' + | 'autoGainControl' + | 'echoCancellation' + | 'noiseSuppression' + | 'sampleRate' + | 'sampleSize' + | 'channelCount' >; /** @@ -107,31 +112,26 @@ export class LocalAudioStream extends LocalStream { const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; const baselineConstraints = filterToSupportedConstraints(currentSettings); - let newStream = await getUserMedia({ + const newStream = await getUserMedia({ audio: { ...baselineConstraints, ...constraintsToApply, deviceId }, - }).catch((err) => { - logger.warn(`Failed to re-acquire track with effect constraints, recovering:`, err); - return null; }); - if (!newStream) { - newStream = await getUserMedia({ - audio: { ...baselineConstraints, deviceId }, - }); - } - const [newTrack] = newStream.getAudioTracks(); if (!newTrack) { logger.warn(`Re-acquire returned no audio track, skipping replacement.`); return false; } - // The effect may have been removed or the track may have ended while - // getUserMedia was running. Discard the new track so it doesn't keep - // the microphone open in the background. + // The effect may have been disposed or the track may have ended while + // we were awaiting getUserMedia. Discard the new track immediately so + // it doesn't hold the microphone open in the background. if (!this.effects.includes(effect) || currentTrack.readyState === 'ended') { newTrack.stop(); - logger.log(`Effect was disposed during track re-acquisition, discarding new track.`); + logger.log( + `Discarding new track: effect disposed=${!this.effects.includes(effect)}, track ended=${ + currentTrack.readyState === 'ended' + }.` + ); return false; } From 4c6a0386a07c5487bd963f8db6b2f36988f4b57c Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Mon, 11 May 2026 11:46:00 +0200 Subject: [PATCH 25/28] chore: add try catch for effect.replaceInputTrack(newTrack) --- src/media/local-audio-stream.spec.ts | 32 ++++++++++++++++++++++++---- src/media/local-audio-stream.ts | 11 +++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts index 23f2045..eab664b 100644 --- a/src/media/local-audio-stream.spec.ts +++ b/src/media/local-audio-stream.spec.ts @@ -323,6 +323,32 @@ describe('LocalAudioStream', () => { expect(endedSpy).not.toHaveBeenCalled(); }); + it('should stop the new track and fall back to raw mic when effect wiring fails', async () => { + expect.hasAssertions(); + + const currentTrack = audioStream.getTracks()[0]; + (currentTrack as { readyState: string }).readyState = 'live'; + const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + const changeOutputTrackSpy = jest.spyOn( + audioLocalStream as unknown as { changeOutputTrack: (t: MediaStreamTrack) => void }, + 'changeOutputTrack' + ); + + (effect.replaceInputTrack as jest.Mock).mockRejectedValueOnce(new Error('wiring failed')); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); + expect(newTrackStopSpy).toHaveBeenCalledWith(); + expect(changeOutputTrackSpy).toHaveBeenCalledWith(currentTrack); + expect(effect.dispose).toHaveBeenCalledWith(); + expect(constraintsChangeSpy).not.toHaveBeenCalled(); + }); + it('should skip re-acquisition when the track is already ended', async () => { expect.hasAssertions(); @@ -344,8 +370,7 @@ describe('LocalAudioStream', () => { ); const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); - // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function - let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; + let resolveGetUserMedia!: (stream: MediaStream) => void; getUserMediaSpy.mockReturnValueOnce( new Promise((resolve) => { resolveGetUserMedia = resolve; @@ -377,8 +402,7 @@ describe('LocalAudioStream', () => { ); const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); - // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/no-empty-function - let resolveGetUserMedia: (stream: MediaStream) => void = () => {}; + let resolveGetUserMedia!: (stream: MediaStream) => void; getUserMediaSpy.mockReturnValueOnce( new Promise((resolve) => { resolveGetUserMedia = resolve; diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index a723623..b47e3d8 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -135,6 +135,16 @@ export class LocalAudioStream extends LocalStream { return false; } + // Try wiring the effect before committing the stream swap. If this + // fails, close the unused new track and let the outer catch fall back + // to the still-live current track. + try { + await effect.replaceInputTrack(newTrack); + } catch (wireErr) { + newTrack.stop(); + throw wireErr; + } + this.removeTrackHandlers(currentTrack); currentTrack.stop(); @@ -144,7 +154,6 @@ export class LocalAudioStream extends LocalStream { this.inputStream.addTrack(newTrack); this.addTrackHandlers(newTrack); - await effect.replaceInputTrack(newTrack); this[LocalStreamEventNames.ConstraintsChange].emit(); logger.log(`Constraints applied via track re-acquisition.`); return true; From df4388a855222166c87137cabd9244917e9d029a Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Mon, 11 May 2026 11:58:49 +0200 Subject: [PATCH 26/28] chore: update the mute state --- src/media/local-audio-stream.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index b47e3d8..cb60d30 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -135,6 +135,9 @@ export class LocalAudioStream extends LocalStream { return false; } + // Preserve the mute state before the effect receives the new track. + newTrack.enabled = currentTrack.enabled; + // Try wiring the effect before committing the stream swap. If this // fails, close the unused new track and let the outer catch fall back // to the still-live current track. @@ -148,8 +151,6 @@ export class LocalAudioStream extends LocalStream { this.removeTrackHandlers(currentTrack); currentTrack.stop(); - // Preserve the mute state across the track swap. - newTrack.enabled = currentTrack.enabled; this.inputStream.removeTrack(currentTrack); this.inputStream.addTrack(newTrack); this.addTrackHandlers(newTrack); From a772121c127cba530552e2949b0bb097a421e9ab Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Wed, 13 May 2026 17:52:02 +0200 Subject: [PATCH 27/28] fix: update order for stopping track to fix issue with getSettings --- src/media/local-audio-stream.spec.ts | 93 +++++++--------------------- src/media/local-audio-stream.ts | 70 ++++++++------------- src/util/test-utils.ts | 1 + 3 files changed, 47 insertions(+), 117 deletions(-) diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts index eab664b..9549805 100644 --- a/src/media/local-audio-stream.spec.ts +++ b/src/media/local-audio-stream.spec.ts @@ -30,6 +30,7 @@ describe('LocalAudioStream', () => { let constraintsReleasedHandler: () => Promise; let getUserMediaSpy: jest.SpyInstance; let newAudioTrack: MediaStreamTrack; + let newMockStream: MediaStream; // Stub navigator.mediaDevices.getSupportedConstraints (absent in jsdom) // so the filter in reacquireInputTrack mirrors a spec-compliant browser. @@ -65,7 +66,7 @@ describe('LocalAudioStream', () => { off: jest.fn(), } as unknown as TrackEffect; - const newMockStream = createMockedAudioStream(); + newMockStream = createMockedAudioStream(); [newAudioTrack] = newMockStream.getTracks(); (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); @@ -189,17 +190,14 @@ describe('LocalAudioStream', () => { it('should preserve the saved baseline across multiple constraints-required events', async () => { expect.hasAssertions(); - // First required: succeeds and saves { autoGainControl: true } as the user baseline. await constraintsRequiredHandler({ autoGainControl: false }); - // Track now reflects the effect-modified AGC=false state. (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ ...audioSettings, autoGainControl: false, }); - // Second required: succeeds and saves { noiseSuppression: true } as the user baseline. const secondStream = createMockedAudioStream(); const [secondTrack] = secondStream.getAudioTracks(); jest.spyOn(secondTrack, 'getSettings').mockReturnValue({ @@ -214,7 +212,6 @@ describe('LocalAudioStream', () => { (audioStream.getTracks as jest.Mock).mockReturnValue([secondTrack]); getUserMediaSpy.mockClear(); - // Released: must restore both user-baseline AGC=true and NS=true. await constraintsReleasedHandler(); expect(getUserMediaSpy).toHaveBeenCalledTimes(1); @@ -255,7 +252,7 @@ describe('LocalAudioStream', () => { expect(firstStop).toBeGreaterThan(firstRemove); }); - it('should stop the current track after getUserMedia succeeds', async () => { + it('should stop the current track before calling getUserMedia', async () => { expect.hasAssertions(); const currentTrack = audioStream.getTracks()[0]; @@ -271,29 +268,23 @@ describe('LocalAudioStream', () => { await constraintsRequiredHandler({ autoGainControl: false }); - expect(callOrder).toStrictEqual(['getUserMedia', 'stop']); + expect(callOrder).toStrictEqual(['stop', 'getUserMedia']); }); - it('should emit Ended when getUserMedia fails and the input track is ended', async () => { + it('should emit Ended when getUserMedia fails after stopping the current track', async () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); - const inputTrack = audioStream.getTracks()[0]; - getUserMediaSpy.mockImplementationOnce(async () => { - // Mimic the device disappearing: the original track ends before the - // fallback getUserMedia resolves, so the catch path sees a non-live - // input track and must emit Ended instead of silently bypassing. - (inputTrack as { readyState: string }).readyState = 'ended'; - throw new Error('NotFoundError'); - }); + getUserMediaSpy.mockRejectedValue(new Error('NotFoundError')); await constraintsRequiredHandler({ autoGainControl: false }); expect(endedSpy).toHaveBeenCalledWith(); + expect(effect.dispose).toHaveBeenCalledWith(); }); - it('should fall back to raw mic and dispose the effect when getUserMedia fails but the track is still live', async () => { + it('should clear the effects array and not emit ConstraintsChange when getUserMedia fails', async () => { expect.hasAssertions(); const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); @@ -301,42 +292,30 @@ describe('LocalAudioStream', () => { audioLocalStream[LocalStreamEventNames.ConstraintsChange], 'emit' ); - const changeOutputTrackSpy = jest.spyOn( - audioLocalStream as unknown as { changeOutputTrack: (t: MediaStreamTrack) => void }, - 'changeOutputTrack' - ); - const inputTrack = audioStream.getTracks()[0]; - (inputTrack as { readyState: string }).readyState = 'live'; getUserMediaSpy.mockRejectedValueOnce(new Error('NotFoundError')); await constraintsRequiredHandler({ autoGainControl: false }); - expect(changeOutputTrackSpy).toHaveBeenCalledWith(inputTrack); expect(effect.dispose).toHaveBeenCalledWith(); expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( effect ); - // No new track was wired, so no ConstraintsChange; the stream is not - // ended, so no Ended. expect(constraintsChangeSpy).not.toHaveBeenCalled(); - expect(endedSpy).not.toHaveBeenCalled(); + expect(endedSpy).toHaveBeenCalledWith(); }); - it('should stop the new track and fall back to raw mic when effect wiring fails', async () => { + it('should stop the new track, clear effects, and emit Ended when effect wiring fails', async () => { expect.hasAssertions(); - const currentTrack = audioStream.getTracks()[0]; - (currentTrack as { readyState: string }).readyState = 'live'; const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); const constraintsChangeSpy = jest.spyOn( audioLocalStream[LocalStreamEventNames.ConstraintsChange], 'emit' ); - const changeOutputTrackSpy = jest.spyOn( - audioLocalStream as unknown as { changeOutputTrack: (t: MediaStreamTrack) => void }, - 'changeOutputTrack' - ); + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + + getUserMediaSpy.mockResolvedValueOnce(newMockStream); (effect.replaceInputTrack as jest.Mock).mockRejectedValueOnce(new Error('wiring failed')); @@ -344,9 +323,12 @@ describe('LocalAudioStream', () => { expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); expect(newTrackStopSpy).toHaveBeenCalledWith(); - expect(changeOutputTrackSpy).toHaveBeenCalledWith(currentTrack); expect(effect.dispose).toHaveBeenCalledWith(); + expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( + effect + ); expect(constraintsChangeSpy).not.toHaveBeenCalled(); + expect(endedSpy).toHaveBeenCalledWith(); }); it('should skip re-acquisition when the track is already ended', async () => { @@ -381,47 +363,14 @@ describe('LocalAudioStream', () => { await audioLocalStream.disposeEffects(); - const newMockStream = createMockedAudioStream(); - (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); - resolveGetUserMedia(newMockStream); - - await handlerPromise; - - expect(newTrackStopSpy).toHaveBeenCalledWith(); - expect(endedSpy).not.toHaveBeenCalled(); - expect(constraintsChangeSpy).not.toHaveBeenCalled(); - expect(effect.replaceInputTrack).not.toHaveBeenCalled(); - }); - - it('should discard new track when the input track ends during getUserMedia', async () => { - expect.hasAssertions(); - - const constraintsChangeSpy = jest.spyOn( - audioLocalStream[LocalStreamEventNames.ConstraintsChange], - 'emit' - ); - const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); - - let resolveGetUserMedia!: (stream: MediaStream) => void; - getUserMediaSpy.mockReturnValueOnce( - new Promise((resolve) => { - resolveGetUserMedia = resolve; - }) - ); - - const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); - await Promise.resolve(); - - const currentTrack = audioStream.getTracks()[0]; - (currentTrack as { readyState: string }).readyState = 'ended'; - - const newMockStream = createMockedAudioStream(); - (newMockStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); - resolveGetUserMedia(newMockStream); + const resolvedStream = createMockedAudioStream(); + (resolvedStream.getAudioTracks as jest.Mock).mockReturnValue([newAudioTrack]); + resolveGetUserMedia(resolvedStream); await handlerPromise; expect(newTrackStopSpy).toHaveBeenCalledWith(); + expect(endedSpy).toHaveBeenCalledWith(); expect(constraintsChangeSpy).not.toHaveBeenCalled(); expect(effect.replaceInputTrack).not.toHaveBeenCalled(); }); diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index cb60d30..1f26794 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -58,7 +58,6 @@ export class LocalAudioStream extends LocalStream { * @returns Resolves when the browser finishes processing the request. */ async applyConstraints(constraints?: AppliableAudioConstraints): Promise { - logger.log(`Applying constraints to local track:`, constraints); return this.inputTrack.applyConstraints(constraints).then(() => { this[LocalStreamEventNames.ConstraintsChange].emit(); }); @@ -108,9 +107,12 @@ export class LocalAudioStream extends LocalStream { return true; } + const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; + const baselineConstraints = filterToSupportedConstraints(currentSettings); + try { - const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; - const baselineConstraints = filterToSupportedConstraints(currentSettings); + this.removeTrackHandlers(currentTrack); + currentTrack.stop(); const newStream = await getUserMedia({ audio: { ...baselineConstraints, ...constraintsToApply, deviceId }, @@ -118,29 +120,18 @@ export class LocalAudioStream extends LocalStream { const [newTrack] = newStream.getAudioTracks(); if (!newTrack) { - logger.warn(`Re-acquire returned no audio track, skipping replacement.`); - return false; + throw new Error('getUserMedia returned a stream with no audio tracks.'); } - // The effect may have been disposed or the track may have ended while - // we were awaiting getUserMedia. Discard the new track immediately so - // it doesn't hold the microphone open in the background. - if (!this.effects.includes(effect) || currentTrack.readyState === 'ended') { + if (!this.effects.includes(effect)) { newTrack.stop(); - logger.log( - `Discarding new track: effect disposed=${!this.effects.includes(effect)}, track ended=${ - currentTrack.readyState === 'ended' - }.` - ); + logger.log(`Effect was disposed during getUserMedia, emitting Ended.`); + this[StreamEventNames.Ended].emit(); return false; } - // Preserve the mute state before the effect receives the new track. newTrack.enabled = currentTrack.enabled; - // Try wiring the effect before committing the stream swap. If this - // fails, close the unused new track and let the outer catch fall back - // to the still-live current track. try { await effect.replaceInputTrack(newTrack); } catch (wireErr) { @@ -148,44 +139,33 @@ export class LocalAudioStream extends LocalStream { throw wireErr; } - this.removeTrackHandlers(currentTrack); - currentTrack.stop(); - this.inputStream.removeTrack(currentTrack); this.inputStream.addTrack(newTrack); this.addTrackHandlers(newTrack); this[LocalStreamEventNames.ConstraintsChange].emit(); - logger.log(`Constraints applied via track re-acquisition.`); + logger.log( + `Constraints applied via track re-acquisition. Settings:`, + newTrack.getSettings() + ); return true; } catch (err: unknown) { if (!this.effects.includes(effect)) { - logger.log(`Effect was disposed during constraint handling, ignoring error.`); return false; } - if (this.inputTrack.readyState === 'live') { - // Mic is still live but the effect chain is broken — fall back to raw audio. - // Clear this.effects before disposing so any constraints-released events - // fired from inside dispose() are skipped by the reacquire guards. - logger.error(`Effect wiring failed, falling back to raw mic:`, err); - this.changeOutputTrack(this.inputTrack); - // If another effect is still loading, drop it too so it can't - // slip back into the chain once we've fallen back to raw mic. - this.loadingEffects.clear(); - const effectsToDispose = this.effects; - this.effects = []; - await Promise.all( - effectsToDispose.map((e) => - e.dispose().catch((disposeErr) => { - logger.error(`Failed to dispose effect after fallback:`, disposeErr); - }) - ) - ); - } else { - logger.error(`Failed to re-acquire mic track, stream ended:`, err); - this[StreamEventNames.Ended].emit(); - } + logger.error(`Track re-acquisition failed, stream ended:`, err); + this.loadingEffects.clear(); + const effectsToDispose = this.effects; + this.effects = []; + await Promise.all( + effectsToDispose.map((e) => + e.dispose().catch((disposeErr) => { + logger.error(`Failed to dispose effect after stream ended:`, disposeErr); + }) + ) + ); + this[StreamEventNames.Ended].emit(); return false; } }; diff --git a/src/util/test-utils.ts b/src/util/test-utils.ts index 64e9e9a..c574d36 100644 --- a/src/util/test-utils.ts +++ b/src/util/test-utils.ts @@ -74,6 +74,7 @@ const createMockedVideoTrack = (width: number, height: number): MediaStreamTrack const createMockedAudioTrack = (): MediaStreamTrack => { const track = mocked(new MediaStreamTrackStub()); track.kind = MediaStreamTrackKind.Audio; + track.getSettings.mockReturnValue({}); return track as unknown as MediaStreamTrack; }; From 0981ddc090c30156fc0e0dd10eba1376b47ca251 Mon Sep 17 00:00:00 2001 From: Anna Tsukanova Date: Thu, 14 May 2026 11:51:59 +0200 Subject: [PATCH 28/28] chore: return log forapplyConstraints --- src/media/local-audio-stream.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/media/local-audio-stream.ts b/src/media/local-audio-stream.ts index 1f26794..d8a7b19 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -58,6 +58,7 @@ export class LocalAudioStream extends LocalStream { * @returns Resolves when the browser finishes processing the request. */ async applyConstraints(constraints?: AppliableAudioConstraints): Promise { + logger.log(`Applying constraints to local track:`, constraints); return this.inputTrack.applyConstraints(constraints).then(() => { this[LocalStreamEventNames.ConstraintsChange].emit(); });