diff --git a/src/media/local-audio-stream.spec.ts b/src/media/local-audio-stream.spec.ts new file mode 100644 index 0000000..9549805 --- /dev/null +++ b/src/media/local-audio-stream.spec.ts @@ -0,0 +1,422 @@ +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'; +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; + let newMockStream: MediaStream; + + // 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); + + 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; + + 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(); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: originalMediaDevices, + }); + }); + + 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 drop unsupported settings names before passing them to getUserMedia', async () => { + 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, + 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(); + + 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 preserve the saved baseline across multiple constraints-required events', async () => { + expect.hasAssertions(); + + await constraintsRequiredHandler({ autoGainControl: false }); + + (audioStream.getTracks as jest.Mock).mockReturnValue([newAudioTrack]); + jest.spyOn(newAudioTrack, 'getSettings').mockReturnValue({ + ...audioSettings, + autoGainControl: false, + }); + + const secondStream = createMockedAudioStream(); + const [secondTrack] = secondStream.getAudioTracks(); + jest.spyOn(secondTrack, 'getSettings').mockReturnValue({ + ...audioSettings, + autoGainControl: false, + noiseSuppression: false, + }); + getUserMediaSpy.mockResolvedValueOnce(secondStream); + + await constraintsRequiredHandler({ noiseSuppression: false }); + + (audioStream.getTracks as jest.Mock).mockReturnValue([secondTrack]); + getUserMediaSpy.mockClear(); + + await constraintsReleasedHandler(); + + expect(getUserMediaSpy).toHaveBeenCalledTimes(1); + expect(getUserMediaSpy).toHaveBeenLastCalledWith({ + audio: expect.objectContaining({ + autoGainControl: true, + noiseSuppression: true, + }), + }); + }); + + it('should call replaceInputTrack on the effect with the new track', 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 before calling getUserMedia', 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(['stop', 'getUserMedia']); + }); + + it('should emit Ended when getUserMedia fails after stopping the current track', async () => { + expect.hasAssertions(); + + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + + getUserMediaSpy.mockRejectedValue(new Error('NotFoundError')); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(endedSpy).toHaveBeenCalledWith(); + expect(effect.dispose).toHaveBeenCalledWith(); + }); + + it('should clear the effects array and not emit ConstraintsChange when getUserMedia fails', async () => { + expect.hasAssertions(); + + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + + getUserMediaSpy.mockRejectedValueOnce(new Error('NotFoundError')); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(effect.dispose).toHaveBeenCalledWith(); + expect((audioLocalStream as unknown as { effects: TrackEffect[] }).effects).not.toContain( + effect + ); + expect(constraintsChangeSpy).not.toHaveBeenCalled(); + expect(endedSpy).toHaveBeenCalledWith(); + }); + + it('should stop the new track, clear effects, and emit Ended when effect wiring fails', async () => { + expect.hasAssertions(); + + const newTrackStopSpy = jest.spyOn(newAudioTrack, 'stop'); + const constraintsChangeSpy = jest.spyOn( + audioLocalStream[LocalStreamEventNames.ConstraintsChange], + 'emit' + ); + const endedSpy = jest.spyOn(audioLocalStream[StreamEventNames.Ended], 'emit'); + + getUserMediaSpy.mockResolvedValueOnce(newMockStream); + + (effect.replaceInputTrack as jest.Mock).mockRejectedValueOnce(new Error('wiring failed')); + + await constraintsRequiredHandler({ autoGainControl: false }); + + expect(effect.replaceInputTrack).toHaveBeenCalledWith(newAudioTrack); + expect(newTrackStopSpy).toHaveBeenCalledWith(); + 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 () => { + 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'); + + let resolveGetUserMedia!: (stream: MediaStream) => void; + getUserMediaSpy.mockReturnValueOnce( + new Promise((resolve) => { + resolveGetUserMedia = resolve; + }) + ); + + const handlerPromise = constraintsRequiredHandler({ autoGainControl: false }); + + await audioLocalStream.disposeEffects(); + + 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(); + }); + + 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(); + + 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..d8a7b19 100644 --- a/src/media/local-audio-stream.ts +++ b/src/media/local-audio-stream.ts @@ -1,21 +1,61 @@ +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. +// Audio constraints that can be applied via applyConstraints. export type AppliableAudioConstraints = Pick< MediaTrackConstraints, - 'autoGainControl' | 'echoCancellation' | 'noiseSuppression' + | 'autoGainControl' + | 'echoCancellation' + | 'noiseSuppression' + | 'sampleRate' + | 'sampleSize' + | 'channelCount' >; +/** + * 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. */ export class LocalAudioStream extends LocalStream { /** - * Apply constraints to the stream. + * @inheritdoc + */ + async addEffect(effect: TrackEffect): Promise { + if (this.effects.some((e) => e.id === effect.id)) { + return; + } + await super.addEffect(effect); + this.addConstraintHandlers(effect); + } + + /** + * 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); @@ -23,4 +63,170 @@ export class LocalAudioStream extends LocalStream { this[LocalStreamEventNames.ConstraintsChange].emit(); }); } + + /** + * 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. + * + * This is a workaround for Chrome ignoring `applyConstraints` on audio + * processing properties: https://issues.chromium.org/issues/40555809. + * + * @param effect - The effect to listen to. + */ + private addConstraintHandlers(effect: TrackEffect): void { + let savedTrackSettings: MediaTrackSettings = {}; + + /** + * Replace the current mic track with a new one obtained via getUserMedia, + * 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 => { + if (!this.effects.includes(effect)) { + logger.log(`Effect ${effect.id} is no longer active, skipping constraint handling.`); + return false; + } + + if (this.inputTrack.readyState === 'ended') { + logger.log(`Track already ended, ignoring constraints change.`); + return false; + } + + const currentTrack = this.inputTrack; + const currentSettings = currentTrack.getSettings(); + + 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 true; + } + + const deviceId = currentSettings.deviceId ? { exact: currentSettings.deviceId } : undefined; + const baselineConstraints = filterToSupportedConstraints(currentSettings); + + try { + this.removeTrackHandlers(currentTrack); + currentTrack.stop(); + + const newStream = await getUserMedia({ + audio: { ...baselineConstraints, ...constraintsToApply, deviceId }, + }); + + const [newTrack] = newStream.getAudioTracks(); + if (!newTrack) { + throw new Error('getUserMedia returned a stream with no audio tracks.'); + } + + if (!this.effects.includes(effect)) { + newTrack.stop(); + logger.log(`Effect was disposed during getUserMedia, emitting Ended.`); + this[StreamEventNames.Ended].emit(); + return false; + } + + newTrack.enabled = currentTrack.enabled; + + try { + await effect.replaceInputTrack(newTrack); + } catch (wireErr) { + newTrack.stop(); + throw wireErr; + } + + this.inputStream.removeTrack(currentTrack); + this.inputStream.addTrack(newTrack); + this.addTrackHandlers(newTrack); + + this[LocalStreamEventNames.ConstraintsChange].emit(); + logger.log( + `Constraints applied via track re-acquisition. Settings:`, + newTrack.getSettings() + ); + return true; + } catch (err: unknown) { + if (!this.effects.includes(effect)) { + return false; + } + + 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; + } + }; + + /** + * 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 the effect needs. + */ + const handleConstraintsRequired = async (constraints: MediaTrackConstraints): Promise => { + logger.log(`Effect ${effect.id} constraints required:`, constraints); + + const currentSettings = this.inputTrack.getSettings(); + /** + * 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); + }; + + /** + * 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.`); + + if (!Object.keys(savedTrackSettings).length) { + logger.log(`No settings to restore, skipping re-acquisition.`); + return; + } + + const toRestore = { ...savedTrackSettings }; + const restored = await reacquireInputTrack(toRestore); + if (restored) { + savedTrackSettings = {}; + } + }; + + /** + * 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.ts b/src/media/local-stream.ts index 8acf3e9..83e665a 100644 --- a/src/media/local-stream.ts +++ b/src/media/local-stream.ts @@ -36,9 +36,9 @@ abstract class _LocalStream extends Stream { [LocalStreamEventNames.EffectAdded] = new TypedEvent<(effect: TrackEffect) => void>(); - private effects: TrackEffect[] = []; + 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 @@ -189,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 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(); 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 { const track = mocked(new MediaStreamTrackStub()); + track.kind = MediaStreamTrackKind.Audio; + track.getSettings.mockReturnValue({}); 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; +};