Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bcfa1ba
fix: add handleConstraintsRequired handler for audio constraints fix
antsukanova Apr 2, 2026
e903658
fix: update handleConstraintsRequired
antsukanova Apr 3, 2026
54f77fd
fix: update recoveryStream flow
antsukanova Apr 3, 2026
0769169
fix: update newTrack.enabled
antsukanova Apr 3, 2026
ed4b739
fix: update logic, namings, tests
antsukanova Apr 7, 2026
45268c4
fix: update logic with readyState
antsukanova Apr 7, 2026
5c0479d
chore: renaming for handleConstraintsRequired
antsukanova Apr 9, 2026
160e7ed
chore: small refactor changes
antsukanova Apr 9, 2026
abe135c
chore: update path for catch (err: unknown)
antsukanova Apr 9, 2026
5e0bf72
fix: update with effect was disposed check
antsukanova Apr 10, 2026
ed3d0a6
fix: update local stream with constraints-released
antsukanova Apr 14, 2026
8510c5c
fix: update removeTrackHandlers order and logs
antsukanova Apr 15, 2026
1551b08
refactor: put reacquireInputTrack to local audio stream
antsukanova Apr 16, 2026
3ec0623
chore: fix type
antsukanova Apr 16, 2026
fe0b727
chore: update addEffect
antsukanova Apr 16, 2026
20ec267
chore: update ordering
antsukanova Apr 16, 2026
f09c510
refactor: update logic for filterToSupportedConstraints
antsukanova Apr 20, 2026
08c970b
refactor: update for savedTrackSettings and as never for tests
antsukanova Apr 20, 2026
34689a2
refactor: update disposeEffects fir live readyState catch
antsukanova Apr 20, 2026
ea33c2d
test: add additional tests and logic for effectsToDispose
antsukanova Apr 20, 2026
69e7aad
test: improve tests
antsukanova Apr 20, 2026
908a40b
chore: improve edge cases
antsukanova Apr 20, 2026
a7786f2
chore: update tests
antsukanova May 8, 2026
f5ab883
chore: simplify logic in a file
antsukanova May 8, 2026
4c6a038
chore: add try catch for effect.replaceInputTrack(newTrack)
antsukanova May 11, 2026
df4388a
chore: update the mute state
antsukanova May 11, 2026
a772121
fix: update order for stopping track to fix issue with getSettings
antsukanova May 13, 2026
0981ddc
chore: return log forapplyConstraints
antsukanova May 14, 2026
734d374
chore: merge main
antsukanova Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions src/media/local-stream.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -187,6 +188,195 @@ 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<void>;
let getUserMediaSpy: jest.SpyInstance;
let newAudioTrack: MediaStreamTrack;

beforeEach(async () => {
const inputTrack = mockStream.getTracks()[0];
jest.spyOn(inputTrack, 'getSettings').mockReturnValue(audioSettings);

const eventHandlers = new Map<string, (...args: unknown[]) => 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<void>;
});

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 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: 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();

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();
});

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', () => {
it('should correctly serialize data', () => {
expect.assertions(1);
Expand Down
111 changes: 111 additions & 0 deletions src/media/local-stream.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -267,19 +268,129 @@ 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<string, MediaTrackConstraints[keyof MediaTrackConstraints]> = {};
Comment thread
antsukanova marked this conversation as resolved.
Outdated

/**
* Handle when an 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.
*
* Empty constraints ({}): restore the previously saved values so the track
* returns to the user's original settings.
Comment thread
antsukanova marked this conversation as resolved.
Outdated
*
* 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.
*/
const handleConstraintsRequired = async (constraints: MediaTrackConstraints) => {
Comment thread
antsukanova marked this conversation as resolved.
Outdated
logger.log(`Effect ${effect.id} constraints required:`, constraints);
Comment thread
antsukanova marked this conversation as resolved.
Outdated

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();

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);
this.addTrackHandlers(newTrack);
Comment thread
antsukanova marked this conversation as resolved.
Outdated

if (this.effects.length > 0) {
await this.effects[0].replaceInputTrack(newTrack);
}
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);
savedConstraints = {};
this[StreamEventNames.Ended].emit();
}
};

/**
* 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);
};

// 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);

// Add the effect to the effects list. If an effect of the same kind has already been added,
Expand Down
Loading