@pexip/media 20.2.0 → 20.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,80 @@
1
1
  # @pexip/media
2
2
 
3
+ ## 20.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - a93f6b9: Fix audio mixer muted state
8
+ - @pexip/media-control@20.3.3
9
+ - @pexip/media-processor@20.3.3
10
+
11
+ ## 20.3.2
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [aac2518]
16
+ - Updated dependencies [aac2518]
17
+ - @pexip/utils@17.1.1
18
+ - @pexip/signal@16.9.2
19
+ - @pexip/media-control@20.3.2
20
+ - @pexip/media-processor@20.3.2
21
+
22
+ ## 20.3.1
23
+
24
+ ### Patch Changes
25
+
26
+ - af038f3: Update Biome v2
27
+ - Updated dependencies [4068f02]
28
+ - Updated dependencies [a551f01]
29
+ - Updated dependencies [af038f3]
30
+ - @pexip/utils@17.1.0
31
+ - @pexip/signal@16.9.1
32
+ - @pexip/media-processor@20.3.1
33
+ - @pexip/media-control@20.3.1
34
+
35
+ ## 20.3.0
36
+
37
+ ### Minor Changes
38
+
39
+ - 69d6be4: Refactor `MediaTrack` to decouple system/hardware mute from mute
40
+
41
+ - Add `stopped` attributes to `MediaTrack` to indicate the track `live` or
42
+ `ended`
43
+ - Add `suspended` attributes to
44
+ `MediaTrack to indicate the track is being system/hardware muted`
45
+ - Add signal supports to `MediaTrack` to be able to subscribe track related
46
+ signals
47
+ - Implement Two-hand control to secure mute consistency
48
+
49
+ When there is `muted` event from `MediaStreamTrack`, the track will follow
50
+ the event to set the `enabled` attribute to `false` to avoid any
51
+ possibilities of leaking the local media to remote parties. When the
52
+ `unmuted` event is triggered, it depends on whether the `enabled` attribute
53
+ is set before the `muted` event or not to unmute the track.
54
+
55
+ Breaking changes:
56
+
57
+ - Replace `MediaStreamTrack` related signal with the new `MediaTrack` signals
58
+
59
+ - `onStreamTrackEnded` -> `onMediaTrackStopped`
60
+ - `onStreamTrackMuted` -> `onMediaTrackSuspended`
61
+ - `onStreamTrackUnmuted` -> `onMediaTrackResumed`
62
+ - `onStreamTrackEnabled` -> `onMediaTrackMuted`
63
+ - `onAudioMuteStateChanged` -> `onMediaTrackMuted`
64
+ - `onVideoMuteStateChanged` -> `onMediaTrackMuted`
65
+
66
+ ### Patch Changes
67
+
68
+ - f8a8159: Fix Subsequant mute/unmute events handling
69
+ - e347770: Handle suspended state fro preview controller
70
+ - 0a375c7: Delay stopping video track to ensure last frame being black
71
+ - Updated dependencies [69d6be4]
72
+ - Updated dependencies [adc1a50]
73
+ - @pexip/media-control@20.3.0
74
+ - @pexip/utils@17.0.1
75
+ - @pexip/media-processor@20.3.0
76
+ - @pexip/signal@16.9.0
77
+
3
78
  ## 20.2.0
4
79
 
5
80
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  import type { MediaDeviceRequest } from '@pexip/media-control';
2
2
  import type { AudioGraph, AudioNodeInit } from '@pexip/media-processor';
3
- import type { TrackProcessor } from './types';
3
+ import type { MediaSignals, TrackProcessor } from './types';
4
4
  interface AudioStreamProcessorProps {
5
5
  mixWithAdditionalMedia?: boolean;
6
6
  merger?: AudioNodeInit<ChannelMergerNode, ChannelMergerNode>;
@@ -12,7 +12,7 @@ type FeaturePropKeys = (typeof FEATURE_KEYS)[number];
12
12
  type FeatureProps = Pick<AudioStreamProcessorProps, FeaturePropKeys>;
13
13
  export declare const updateFeatureProps: (constraints: MediaDeviceRequest["audio"], props: FeatureProps) => FeatureProps;
14
14
  /**
15
- * Create a Audio Mixing Processor and will own the stream passed-in
15
+ * Create an Audio Mixing Processor and will own the stream passed-in
16
16
  */
17
- export declare const createAudioMixingProcess: (getCurrentMedia: () => MediaStream | undefined, label?: "AudioMixingProcessor") => TrackProcessor;
17
+ export declare const createAudioMixingProcess: (getCurrentMedia: () => MediaStream | undefined, signals?: MediaSignals, label?: "AudioMixingProcessor") => TrackProcessor;
18
18
  export {};
@@ -2,7 +2,7 @@ import { extractConstraintsWithKeys } from '@pexip/media-control';
2
2
  import { isEmpty, assert } from '@pexip/utils';
3
3
  import { createAudioGraph, createAudioGraphProxy, createChannelMergerGraphNode, createStreamDestinationGraphNode, createStreamSourceGraphNode, resumeAudioOnUnmute, } from '@pexip/media-processor';
4
4
  import { logger } from './logger';
5
- import { createMediaTrack, isTrackMuted } from './utils';
5
+ import { createMediaTrack } from './utils';
6
6
  import { PROCESSOR_LABELS } from './constants';
7
7
  const FEATURE_KEYS = ['mixWithAdditionalMedia'];
8
8
  const getAudioConstraints = extractConstraintsWithKeys(FEATURE_KEYS);
@@ -19,9 +19,9 @@ export const updateFeatureProps = (constraints, props) => {
19
19
  }, {});
20
20
  };
21
21
  /**
22
- * Create a Audio Mixing Processor and will own the stream passed-in
22
+ * Create an Audio Mixing Processor and will own the stream passed-in
23
23
  */
24
- export const createAudioMixingProcess = (getCurrentMedia, label = PROCESSOR_LABELS.AudioMixingProcessor) => {
24
+ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCESSOR_LABELS.AudioMixingProcessor) => {
25
25
  const props = {};
26
26
  return async (prevMediaTrack) => {
27
27
  updateFeatureProps(prevMediaTrack.getConstraints(), props);
@@ -154,25 +154,22 @@ export const createAudioMixingProcess = (getCurrentMedia, label = PROCESSOR_LABE
154
154
  expectedInput: prevMediaTrack.expectedInput,
155
155
  track,
156
156
  overrideMute: true,
157
- mute: toMute => {
158
- prevMediaTrack.mute(toMute);
157
+ mute: (toMute, _self, soft) => {
159
158
  for (const track of mainSource.node?.mediaStream.getAudioTracks() ??
160
159
  []) {
161
160
  track.enabled = !toMute;
162
161
  }
162
+ prevMediaTrack.mute(toMute, soft);
163
163
  },
164
164
  get muted() {
165
- return (
166
- // If the source track muted, the following processed track will
167
- // be muted as well since there is no data to process
168
- !!prevMediaTrack.muted ||
169
- mainSource.node?.mediaStream
170
- .getAudioTracks()
171
- .some(isTrackMuted));
165
+ return mainSource.node?.mediaStream
166
+ .getAudioTracks()
167
+ .some(track => track.enabled === false);
172
168
  },
173
169
  constraints: prevMediaTrack.getConstraints(),
174
170
  applyConstraints,
175
171
  release,
172
+ signals,
176
173
  getSettings: () => {
177
174
  const mixWithAdditionalMedia = !!props.mixWithAdditionalMedia;
178
175
  const contentHint = track.contentHint ?? '';
@@ -1,6 +1,6 @@
1
- import type { AudioGraphOptions, AudioNodeInit, ThrottleOptions, DenoiseWorkletNodeInit, AnalyzerNodeInit, AudioGraph } from '@pexip/media-processor';
1
+ import type { AnalyzerNodeInit, AudioGraph, AudioGraphOptions, AudioNodeInit, DenoiseWorkletNodeInit, ThrottleOptions } from '@pexip/media-processor';
2
2
  import type { MediaDeviceRequest } from '@pexip/media-control';
3
- import type { TrackProcessor, DenoiseParams, AudioContentHint } from './types';
3
+ import type { AudioContentHint, DenoiseParams, MediaSignals, TrackProcessor } from './types';
4
4
  type AudioNodeInits = AudioNodeInit[];
5
5
  /**
6
6
  * A function to be called to create the AudioNodes needed for the graph
@@ -60,6 +60,7 @@ interface AudioProcessOptions {
60
60
  */
61
61
  silentThreshold?: number;
62
62
  label?: string;
63
+ signals?: MediaSignals;
63
64
  }
64
65
  interface AudioStreamProcessorProps {
65
66
  audioGraphOptions?: AudioGraphOptions;
@@ -80,7 +81,7 @@ type FeaturePropKeys = (typeof FEATURE_KEYS)[number];
80
81
  type FeatureProps = Pick<AudioStreamProcessorProps, FeaturePropKeys>;
81
82
  export declare const updateFeatureProps: (constraints: MediaDeviceRequest["audio"], props: FeatureProps) => FeatureProps;
82
83
  /**
83
- * Create a Audio Stream Processor and will own the stream passed-in
84
+ * Create an Audio Stream Processor and will own the stream passed-in
84
85
  */
85
- export declare const createAudioStreamProcess: ({ analyzerUpdateFrequency, audioGraphOptions, audioSignalDetectionDuration, clock, createNodes, denoiseParams, fftSize, onAudioSignalDetected, onVoiceActivityDetected, shouldEnable, silentThreshold, throttleMs, label, }: AudioProcessOptions & ThrottleOptions) => TrackProcessor;
86
+ export declare const createAudioStreamProcess: ({ analyzerUpdateFrequency, audioGraphOptions, audioSignalDetectionDuration, clock, createNodes, denoiseParams, fftSize, onAudioSignalDetected, onVoiceActivityDetected, shouldEnable, signals, silentThreshold, throttleMs, label, }: AudioProcessOptions & ThrottleOptions) => TrackProcessor;
86
87
  export {};
@@ -1,6 +1,6 @@
1
1
  import { extractConstraintsWithKeys } from '@pexip/media-control';
2
2
  import { createQueue, isEmpty, assert } from '@pexip/utils';
3
- import { createAudioGraph, createAudioGraphProxy, createStreamSourceGraphNode, createStreamDestinationGraphNode, createAnalyzerSubscribableGraphNode, createDenoiseWorkletGraphNode, createAudioSignalDetector, createVADetector, createVoiceDetectorFromTimeData, createVoiceDetectorFromProbability, avg, } from '@pexip/media-processor';
3
+ import { avg, createAnalyzerSubscribableGraphNode, createAudioGraph, createAudioGraphProxy, createAudioSignalDetector, createDenoiseWorkletGraphNode, createStreamDestinationGraphNode, createStreamSourceGraphNode, createVADetector, createVoiceDetectorFromProbability, createVoiceDetectorFromTimeData, } from '@pexip/media-processor';
4
4
  import { PROCESSOR_LABELS } from './constants';
5
5
  import { logger } from './logger';
6
6
  import { isAudioContentHint } from './typeGuard';
@@ -54,12 +54,12 @@ export const updateFeatureProps = (constraints, props) => {
54
54
  }, {});
55
55
  };
56
56
  /**
57
- * Create a Audio Stream Processor and will own the stream passed-in
57
+ * Create an Audio Stream Processor and will own the stream passed-in
58
58
  */
59
59
  export const createAudioStreamProcess = ({ analyzerUpdateFrequency = 0.5, // 0.5 Hz
60
60
  audioGraphOptions, audioSignalDetectionDuration = 4.0, // 4 seconds
61
61
  clock, createNodes, denoiseParams, fftSize = 2048, // FFT size
62
- onAudioSignalDetected, onVoiceActivityDetected, shouldEnable, silentThreshold = 10.0 / 32767, // At least one LSB 16-bit data (compare is on absolute value).
62
+ onAudioSignalDetected, onVoiceActivityDetected, shouldEnable, signals, silentThreshold = 10.0 / 32767, // At least one LSB 16-bit data (compare is on absolute value).
63
63
  throttleMs = 3000, // 3 seconds
64
64
  label = PROCESSOR_LABELS.AudioProcessor, }) => {
65
65
  const props = {
@@ -198,11 +198,6 @@ label = PROCESSOR_LABELS.AudioProcessor, }) => {
198
198
  props.analyzer = undefined;
199
199
  props.audioGraph = undefined;
200
200
  };
201
- const mute = (mute) => {
202
- if (track) {
203
- track.enabled = !mute;
204
- }
205
- };
206
201
  return createMediaTrack({
207
202
  label,
208
203
  kind: 'audioinput',
@@ -210,9 +205,18 @@ label = PROCESSOR_LABELS.AudioProcessor, }) => {
210
205
  previousMediaTrack: prevMediaTrack,
211
206
  input: prevMediaTrack.input,
212
207
  expectedInput: prevMediaTrack.expectedInput,
213
- mute,
208
+ overrideMute: true,
209
+ mute(toMute) {
210
+ if (track) {
211
+ track.enabled = !toMute;
212
+ }
213
+ },
214
+ get muted() {
215
+ return !track.enabled;
216
+ },
214
217
  track,
215
218
  release,
219
+ signals,
216
220
  applyConstraints: async (constraints) => {
217
221
  if (isEmpty(constraints)) {
218
222
  return;
package/dist/media.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createIndexedDevices, createStreamTrackEventSubscriptions, getDevices, getInputDevicePermissionState, isIndexedDevices, isRequestedResolution, mergeConstraints, } from '@pexip/media-control';
1
+ import { createIndexedDevices, getDevices, getInputDevicePermissionState, isIndexedDevices, isRequestedResolution, mergeConstraints, } from '@pexip/media-control';
2
2
  import { createAsyncQueue, isEmpty, assert } from '@pexip/utils';
3
3
  import { internalSignals } from './signals';
4
4
  import { UserMediaStatus } from './types';
@@ -283,28 +283,6 @@ export const createMedia = ({ getMuteState, signals, audioProcessors, videoProce
283
283
  props.devices = createIndexedDevices(await getDevices());
284
284
  return props.devices;
285
285
  };
286
- const syncMuteState = (tracks) => {
287
- const inputMuted = getMuteState();
288
- for (const track of tracks) {
289
- switch (track.kind) {
290
- case 'audioinput': {
291
- track.mute(inputMuted.audio);
292
- // FIXME: this can fire too often, but we want to make sure we signal initial state as well.
293
- // We should only fire this when needed.
294
- signals.onAudioMuteStateChanged?.emit(track.muted);
295
- break;
296
- }
297
- case 'videoinput':
298
- track.mute(inputMuted.video);
299
- // FIXME: this can fire too often, but we want to make sure we signal initial state as well.
300
- // We should only fire this when needed.
301
- signals.onVideoMuteStateChanged?.emit(track.muted);
302
- break;
303
- default:
304
- break;
305
- }
306
- }
307
- };
308
286
  const processMedia = createMediaProcessor({
309
287
  audioProcessors,
310
288
  videoProcessors,
@@ -332,13 +310,14 @@ export const createMedia = ({ getMuteState, signals, audioProcessors, videoProce
332
310
  });
333
311
  const processAndUpdateMedia = async (media, tracks) => {
334
312
  const processedTracks = await processMedia(tracks);
335
- // Sync mute state to processed tracks
336
- syncMuteState(processedTracks);
337
313
  for (const [idx, track] of processedTracks.entries()) {
338
314
  const originTrack = tracks.at(idx);
339
315
  assert(originTrack, 'Processed track should always has the original track in the same order');
340
316
  // Only replace track when they are not the same
341
317
  if (originTrack.id !== track.id) {
318
+ if (originTrack.muted !== undefined) {
319
+ track.mute(originTrack.muted);
320
+ }
342
321
  media.removeTrack(originTrack);
343
322
  media.addTrack(track);
344
323
  }
@@ -351,19 +330,12 @@ export const createMedia = ({ getMuteState, signals, audioProcessors, videoProce
351
330
  shouldDiscardMedia: () => props.discardMedia,
352
331
  getDefaultConstraints,
353
332
  onMediaTracksChanged: (media, tracks) => {
354
- // Sync mute state to input tracks
355
- syncMuteState(tracks);
333
+ const inputMuted = getMuteState();
356
334
  for (const track of tracks) {
357
- if (track.track) {
358
- const unsubscribe = createStreamTrackEventSubscriptions(track.track, {
359
- ended: track => {
360
- signals.onStreamTrackEnded?.emit(track);
361
- unsubscribe();
362
- },
363
- mute: signals.onStreamTrackMuted?.emit,
364
- unmute: signals.onStreamTrackUnmuted?.emit,
365
- });
366
- }
335
+ // Sync mute state to input tracks
336
+ track.mute(track.kind === 'audioinput'
337
+ ? inputMuted.audio
338
+ : inputMuted.video);
367
339
  }
368
340
  logger.debug({ media, tracks }, 'Update media tracks');
369
341
  props.media = media;
@@ -12,11 +12,13 @@ export interface PreviewEventHandler {
12
12
  audioInputError?: EventErrorCallback;
13
13
  applyChangesError?: EventErrorCallback;
14
14
  revertChangesError?: EventErrorCallback;
15
+ audioMuted?: EventCallback<boolean | undefined>;
16
+ audioSuspended?: EventCallback<boolean | undefined>;
17
+ updatingMain?: EventCallback<boolean>;
15
18
  updatingPreviewAudio?: EventCallback<boolean>;
16
19
  updatingPreviewVideo?: EventCallback<boolean>;
17
- audioMuted?: EventCallback<boolean | undefined>;
18
20
  videoMuted?: EventCallback<boolean | undefined>;
19
- updatingMain?: EventCallback<boolean>;
21
+ videoSuspended?: EventCallback<boolean | undefined>;
20
22
  unsubscribeMain?: Unsubscribe;
21
23
  }
22
24
  export interface PreviewStreamParams {
@@ -24,6 +26,7 @@ export interface PreviewStreamParams {
24
26
  getCurrentMedia: () => Media | undefined;
25
27
  updateMainStream: (request: MediaDeviceRequest) => Promise<void>;
26
28
  mainMediaSignal: MediaSignals['onMediaChanged'];
29
+ signals: MediaSignals;
27
30
  onEnded?: () => void;
28
31
  fftSize?: number;
29
32
  queueOptions?: Partial<AsyncQueueOptions>;
@@ -76,8 +79,10 @@ export interface PreviewStreamController {
76
79
  onApplyChangesError(callback: EventErrorCallback): Unsubscribe;
77
80
  onRevertChangesError(callback: EventErrorCallback): Unsubscribe;
78
81
  onAudioMuted(callback: EventCallback<boolean | undefined>): Unsubscribe;
82
+ onAudioSuspended(callback: EventCallback<boolean | undefined>): Unsubscribe;
79
83
  onVideoMuted(callback: EventCallback<boolean | undefined>): Unsubscribe;
84
+ onVideoSuspended(callback: EventCallback<boolean | undefined>): Unsubscribe;
80
85
  }
81
- export declare const createPreviewStreamController: ({ getCurrentDevices, getCurrentMedia, updateMainStream, onEnded, mainMediaSignal, queueOptions, audioProcessors, videoProcessors, getDefaultConstraints, }: PreviewStreamParams) => PreviewStreamController;
86
+ export declare const createPreviewStreamController: ({ getCurrentDevices, getCurrentMedia, updateMainStream, onEnded, mainMediaSignal, queueOptions, audioProcessors, videoProcessors, getDefaultConstraints, signals, }: PreviewStreamParams) => PreviewStreamController;
82
87
  export type CreatePreviewStreamController = typeof createPreviewStreamController;
83
88
  export {};
@@ -4,7 +4,6 @@ import { AUDIO_SETTINGS_KEYS, VIDEO_SETTINGS_KEYS, createMediaProcessor, hasSett
4
4
  import { isMedia } from './typeGuard';
5
5
  import { createModuleLogger } from './logger';
6
6
  import { createGetUserMediaProcess, requestUserMediaWithRetry, } from './userMedia';
7
- import { createMediaSignals } from './signals';
8
7
  import { createMediaUpdater } from './media';
9
8
  const DEFAULT_QUEUE_DELAY_MS = 100;
10
9
  const DEFAULT_QUEUE_DROP_LAST = false;
@@ -52,7 +51,7 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
52
51
  throttleInMS: DEFAULT_QUEUE_THROTTLE_MS,
53
52
  delayInMS: DEFAULT_QUEUE_DELAY_MS,
54
53
  dropLast: DEFAULT_QUEUE_DROP_LAST,
55
- }, audioProcessors, videoProcessors, getDefaultConstraints = () => ({}), }) => {
54
+ }, audioProcessors, videoProcessors, getDefaultConstraints = () => ({}), signals, }) => {
56
55
  const queue = createAsyncQueue(queueOptions);
57
56
  const eventHandlers = {};
58
57
  const internalProps = {
@@ -62,15 +61,7 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
62
61
  updatingMain: false,
63
62
  discardMedia: false,
64
63
  initialized: false,
65
- signals: createMediaSignals([
66
- 'onAddTrack',
67
- 'onAudioMuteStateChanged',
68
- 'onRemoveTrack',
69
- 'onStatusChanged',
70
- 'onUpdatingAudio',
71
- 'onUpdatingVideo',
72
- 'onVideoMuteStateChanged',
73
- ], 'PreviewStreamController'),
64
+ signals,
74
65
  };
75
66
  const logger = createModuleLogger({
76
67
  module: 'PreviewStreamController',
@@ -129,11 +120,29 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
129
120
  },
130
121
  });
131
122
  const subscriptions = [
132
- props.signals.onAudioMuteStateChanged?.add(muted => {
133
- eventHandlers.audioMuted?.(muted);
123
+ props.signals.onMediaTrackMuted?.add(track => {
124
+ if (track.kind === 'audioinput') {
125
+ eventHandlers.audioMuted?.(track.muted);
126
+ }
127
+ else {
128
+ eventHandlers.videoMuted?.(track.muted);
129
+ }
130
+ }),
131
+ props.signals.onMediaTrackSuspended?.add(track => {
132
+ if (track.kind === 'audioinput') {
133
+ eventHandlers.audioSuspended?.(track.suspended);
134
+ }
135
+ else {
136
+ eventHandlers.videoSuspended?.(track.suspended);
137
+ }
134
138
  }),
135
- props.signals.onVideoMuteStateChanged?.add(muted => {
136
- eventHandlers.videoMuted?.(muted);
139
+ props.signals.onMediaTrackResumed?.add(track => {
140
+ if (track.kind === 'audioinput') {
141
+ eventHandlers.audioSuspended?.(track.suspended);
142
+ }
143
+ else {
144
+ eventHandlers.videoSuspended?.(track.suspended);
145
+ }
137
146
  }),
138
147
  props.signals.onAddTrack?.add(track => {
139
148
  switch (track.kind) {
@@ -228,7 +237,7 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
228
237
  return;
229
238
  }
230
239
  try {
231
- const clonedMedia = mainMedia.clone(props.signals);
240
+ const clonedMedia = mainMedia.clone(props.signals, 'Preview');
232
241
  props.media = clonedMedia;
233
242
  queue.enqueue(async () => {
234
243
  await processAndUpdateMedia(clonedMedia, clonedMedia.getTracks(), true);
@@ -364,7 +373,7 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
364
373
  const audioSettings = extractFeaturesToConstraints(AUDIO_SETTINGS_KEYS, previewAudio);
365
374
  // Omit the width & height settings from the preview so that the one
366
375
  // from the main can be applied
367
- const { width, height, ...videoSettings } = extractFeaturesToConstraints(VIDEO_SETTINGS_KEYS, previewVideo);
376
+ const { width: _width, height: _height, ...videoSettings } = extractFeaturesToConstraints(VIDEO_SETTINGS_KEYS, previewVideo);
368
377
  logger.info({ audioInput, videoInput, audioSettings, videoSettings }, 'Apply changes to main');
369
378
  try {
370
379
  await replaceMainStream({
@@ -484,6 +493,8 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
484
493
  onUpdatingMain: toEvenHandler('updatingMain'),
485
494
  onAudioMuted: toEvenHandler('audioMuted'),
486
495
  onVideoMuted: toEvenHandler('videoMuted'),
496
+ onAudioSuspended: toEvenHandler('audioSuspended'),
497
+ onVideoSuspended: toEvenHandler('videoSuspended'),
487
498
  applyChanges,
488
499
  revertChanges,
489
500
  cleanup,
package/dist/types.d.ts CHANGED
@@ -127,8 +127,9 @@ export interface Media extends MediaAttributes {
127
127
  getAudioTracks(): MediaTrack[];
128
128
  getVideoTracks(): MediaTrack[];
129
129
  addTrack(track: MediaTrack): void;
130
+ isCurrentTrack(track: MediaTrack): boolean;
130
131
  removeTrack(track: MediaTrack): void;
131
- clone(signals?: MediaSignals): Media;
132
+ clone(signals?: MediaSignals, label?: string): Media;
132
133
  }
133
134
  export interface MediaInit {
134
135
  constraints: MediaDeviceRequest;
@@ -149,26 +150,35 @@ export interface MediaTrack {
149
150
  * `track` is available otherwise it uses the `id` from the construction or the
150
151
  * `kind`. @see MediaTrackInit
151
152
  */
152
- id: string;
153
- stopped: boolean;
154
- kind: 'audioinput' | 'videoinput';
155
- label?: string;
156
- previousMediaTrack?: MediaTrack;
153
+ readonly id: string;
154
+ /**
155
+ * It maps to MediaStreamTrack['readyState']
156
+ * When `true` the track's `readyState` is `ended`, `false` means `live` otherwise the track is not available
157
+ */
158
+ readonly stopped?: boolean;
159
+ /**
160
+ * Indicate that the media has been suspended by the User Agent, and it could happen at any time.
161
+ * `undefined` means the track is not available
162
+ */
163
+ readonly suspended?: boolean;
164
+ readonly kind: 'audioinput' | 'videoinput';
165
+ readonly label?: string;
166
+ readonly previousMediaTrack?: MediaTrack;
157
167
  /**
158
168
  * The MediaStreamTrack object representing the track
159
169
  * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack
160
170
  */
161
- track?: MediaStreamTrack;
171
+ readonly track?: MediaStreamTrack;
162
172
  /**
163
173
  * The `MediaDeviceInfo` object representing the device that the track is
164
174
  * connected to. @see https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo
165
175
  */
166
- input?: MediaDeviceInfoLike;
176
+ readonly input?: MediaDeviceInfoLike;
167
177
  /**
168
178
  * The `MediaDeviceInfo` object representing the device that the track is
169
179
  * expected to be connected to. @see https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo
170
180
  */
171
- expectedInput?: MediaDeviceInfoLike;
181
+ readonly expectedInput?: MediaDeviceInfoLike;
172
182
  /**
173
183
  * The current mute state of the track. `undefined` means the track is not available
174
184
  */
@@ -176,13 +186,20 @@ export interface MediaTrack {
176
186
  /**
177
187
  * The source track that it is originated from
178
188
  */
179
- source: MediaTrack;
180
- mute(toMute: boolean): void;
189
+ readonly source: MediaTrack;
190
+ /**
191
+ * Mute the track
192
+ *
193
+ * @param toMute - `true` to mute the track, `false` to unmute
194
+ * @param soft - `true` to indicate a soft mute i.e. setting the track to NOT `enabled`
195
+ * `false` or `undefined` for default mute
196
+ */
197
+ mute(toMute: boolean, soft?: boolean): void;
181
198
  getSettings(): ExtendedMediaTrackSettings;
182
199
  getConstraints(): InputDeviceConstraint;
183
200
  applyConstraints(constraints: InputDeviceConstraint): Promise<void>;
184
201
  release(): Promise<void>;
185
- clone(signals?: MediaSignals): MediaTrack;
202
+ clone(signals?: MediaSignals, label?: string): MediaTrack;
186
203
  toJSON(): unknown;
187
204
  }
188
205
  export interface MediaTrackInit extends Partial<Omit<MediaTrack, 'mute'>> {
@@ -192,7 +209,15 @@ export interface MediaTrackInit extends Partial<Omit<MediaTrack, 'mute'>> {
192
209
  constraints: InputDeviceConstraint;
193
210
  overrideMute?: boolean;
194
211
  signals?: MediaSignals;
195
- mute?: (toMute: boolean, self: MediaTrack) => void;
212
+ /**
213
+ * Overriding Mute the track
214
+ *
215
+ * @param toMute - `true` to mute the track, `false` to unmute
216
+ * @param self - The media track itself
217
+ * @param soft - `true` to indicate a soft mute i.e. setting the track to NOT `enabled`
218
+ * `false` or `undefined` for default mute
219
+ */
220
+ mute?: (toMute: boolean, self: MediaTrack, soft?: boolean) => void;
196
221
  }
197
222
  export type Process<T> = (a: T) => Promise<MediaTrack>;
198
223
  export type TrackProcessor = Process<MediaTrack>;
@@ -521,44 +546,16 @@ export type VideoRenderParams = Omit<RendererOptions, 'effects'> & {
521
546
  tilt?: boolean;
522
547
  zoom?: boolean;
523
548
  };
524
- export interface StreamTrackSignals {
525
- /**
526
- * MediaStreamTrack events: mute
527
- * https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack#events
528
- */
529
- onStreamTrackMuted: Signal<MediaStreamTrack>;
530
- /**
531
- * MediaStreamTrack events: unmute
532
- * https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack#events
533
- */
534
- onStreamTrackUnmuted: Signal<MediaStreamTrack>;
535
- /**
536
- * MediaStreamTrack events: ended
537
- * https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack#events
538
- */
539
- onStreamTrackEnded: Signal<MediaStreamTrack>;
540
- /**
541
- * Emit `MediaStreamTrack` whenever a call to `Media['muteAudio']` or
542
- * `Media['muteVideo']`
543
- */
544
- onStreamTrackEnabled: Signal<MediaStreamTrack>;
545
- }
546
- /**
547
- * Track state signals that used for the final track that the media pipeline
548
- * produced
549
- */
550
- export type StreamTrackSignalFinals = {
551
- [key in keyof StreamTrackSignals as `${key}Final`]: Signal<MediaStreamTrack>;
552
- };
553
549
  export interface MediaChangesSignals {
554
550
  onAddTrack: Signal<MediaStreamTrack>;
555
- onAudioMuteStateChanged: Signal<boolean | undefined>;
556
- onTrackReleased: Signal<MediaStreamTrack>;
557
551
  onDevicesChanged: Signal<IndexedDevices>;
558
552
  onMediaChanged: Signal<Media | undefined>;
553
+ onMediaTrackMuted: Signal<MediaTrack>;
554
+ onMediaTrackResumed: Signal<MediaTrack>;
555
+ onMediaTrackStopped: Signal<MediaTrack>;
556
+ onMediaTrackSuspended: Signal<MediaTrack>;
559
557
  onRemoveTrack: Signal<MediaStreamTrack>;
560
558
  onStatusChanged: Signal<UserMediaStatus>;
561
- onVideoMuteStateChanged: Signal<boolean | undefined>;
562
559
  onUpdatingAudio: Signal<boolean>;
563
560
  onUpdatingVideo: Signal<boolean>;
564
561
  }
@@ -566,7 +563,7 @@ export interface AudioDetectionSignals {
566
563
  onVAD: Signal<undefined>;
567
564
  onSilentDetected: Signal<boolean>;
568
565
  }
569
- export type MediaSignalsOptional = Omit<Partial<MediaChangesSignals>, 'onMediaChanged'> & Partial<StreamTrackSignals> & Partial<StreamTrackSignalFinals>;
566
+ export type MediaSignalsOptional = Omit<Partial<MediaChangesSignals>, 'onMediaChanged'>;
570
567
  export type MediaSignalsRequired = Pick<MediaChangesSignals, 'onMediaChanged'> & AudioDetectionSignals;
571
568
  export type MediaSignals = MediaSignalsRequired & MediaSignalsOptional;
572
569
  export declare enum DeniedDevices {
package/dist/userMedia.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { MediaDeviceFailure, extractConstraintsWithKeys, findMediaInputFromMediaStreamTrack, getUserMedia, isExactDeviceConstraint, isStreamingRequestedDevices, relaxInputConstraint, } from '@pexip/media-control';
2
2
  import { assert } from '@pexip/utils';
3
3
  import { UserMediaStatus } from './types';
4
- import { buildMedia, createMediaTrack, findExpectedInput, isTrackMuted, makeDeriveDeviceStatus, } from './utils';
4
+ import { buildMedia, createMediaTrack, findExpectedInput, makeDeriveDeviceStatus, } from './utils';
5
5
  import { logger } from './logger';
6
6
  import { isUnknownError } from './status';
7
7
  import { PROCESSOR_LABELS } from './constants';
@@ -473,7 +473,9 @@ export const createGetUserMediaProcess = ({ getCurrentDevices, getUserMedia, sco
473
473
  return async ({ constraints, permission, originalConstraints, currentMedia, }) => {
474
474
  // Release the current track(s)
475
475
  if (currentMedia) {
476
- await Promise.all(currentMedia.getTracks().map(track => {
476
+ await Promise.all(
477
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: False positive
478
+ currentMedia.getTracks().map(track => {
477
479
  switch (track.kind) {
478
480
  case 'audioinput':
479
481
  // When the constraints is undefined we should do nothing
@@ -556,20 +558,33 @@ export const createGetUserMediaProcess = ({ getCurrentDevices, getUserMedia, sco
556
558
  let audioMediaTrack;
557
559
  let videoMediaTrack;
558
560
  const extractContentHint = extractConstraintsWithKeys(['contentHint']);
561
+ let trackReleaseTimeoutID = 0;
559
562
  /**
560
563
  * Release the track as mute
561
564
  */
562
- const releaseTrackAsMute = async (track, toMute) => {
565
+ const releaseTrackAsMute = async (track, toMute, soft) => {
563
566
  if (!track?.track || track.muted === toMute) {
564
567
  return;
565
568
  }
566
569
  track.track.enabled = !toMute;
570
+ if (soft) {
571
+ return;
572
+ }
567
573
  if (toMute) {
568
574
  if (!track.stopped) {
569
- await track.release();
575
+ // Track cannot be stopped immediately as we need to ensure
576
+ // the last frame is black to avoid frozen frame in a gateway call
577
+ trackReleaseTimeoutID = window.setTimeout(() => {
578
+ track.release();
579
+ trackReleaseTimeoutID = 0;
580
+ }, 500);
570
581
  }
571
582
  }
572
583
  else {
584
+ if (trackReleaseTimeoutID) {
585
+ window.clearTimeout(trackReleaseTimeoutID);
586
+ trackReleaseTimeoutID = 0;
587
+ }
573
588
  if (track.stopped) {
574
589
  await updateMedia({
575
590
  [track.kind === 'audioinput' ? 'audio' : 'video']: track.getConstraints(),
@@ -586,7 +601,7 @@ export const createGetUserMediaProcess = ({ getCurrentDevices, getUserMedia, sco
586
601
  audioTrack.contentHint = contentHint ?? '';
587
602
  }
588
603
  audioMediaTrack = createMediaTrack({
589
- label: PROCESSOR_LABELS.GetUserMedia,
604
+ label: [scope, PROCESSOR_LABELS.GetUserMedia].join('|'),
590
605
  kind: 'audioinput',
591
606
  track: audioTrack,
592
607
  input: audioInput,
@@ -605,17 +620,19 @@ export const createGetUserMediaProcess = ({ getCurrentDevices, getUserMedia, sco
605
620
  videoTrack.contentHint = contentHint ?? '';
606
621
  }
607
622
  videoMediaTrack = createMediaTrack({
608
- label: PROCESSOR_LABELS.GetUserMedia,
623
+ label: [scope, PROCESSOR_LABELS.GetUserMedia].join('|'),
609
624
  kind: 'videoinput',
610
625
  track: videoTrack,
611
626
  input: videoInput,
612
627
  expectedInput: findExpectedInput(devices, constraints.video, videoInput, 'videoinput'),
613
- overrideMute: stopVideoTrackAsMute(),
628
+ get overrideMute() {
629
+ return stopVideoTrackAsMute();
630
+ },
614
631
  get muted() {
615
- return isTrackMuted(videoTrack);
632
+ return !videoTrack?.enabled;
616
633
  },
617
- mute(toMute, self) {
618
- releaseTrackAsMute(self, toMute);
634
+ mute(toMute, self, soft) {
635
+ releaseTrackAsMute(self, toMute, soft);
619
636
  },
620
637
  constraints: constraints.video,
621
638
  signals,
package/dist/utils.js CHANGED
@@ -19,10 +19,10 @@ export const createMediaTrack = (trackInit) => {
19
19
  (trackInit.kind === trackInit.input?.kind &&
20
20
  trackInit.kind === toMediaDeviceInputKind(trackInit.track));
21
21
  assert(trackConsistency, `Inconsistent track kind: ${trackInit.input?.kind} ${trackInit.track && toMediaDeviceInputKind(trackInit.track)} vs ${trackInit.kind}`);
22
- assert(!trackInit.overrideMute ||
23
- (trackInit.overrideMute &&
24
- Object.hasOwn(trackInit, 'mute') &&
25
- Object.hasOwn(trackInit, 'muted')), 'Inconsistent overrideMute');
22
+ assert(!hasOwn(trackInit, 'mute') ||
23
+ (hasOwn(trackInit, 'mute') && hasOwn(trackInit, 'overrideMute')), 'Inconsistent overrideMute mute()');
24
+ assert(!hasOwn(trackInit, 'muted') ||
25
+ (hasOwn(trackInit, 'muted') && hasOwn(trackInit, 'overrideMute')), 'Inconsistent overrideMute muted');
26
26
  const currentConstraints = typeof trackInit.constraints === 'boolean' ? {} : trackInit.constraints;
27
27
  // Find the original source track through the linked list
28
28
  let sourceMediaTrack = trackInit.previousMediaTrack;
@@ -45,6 +45,25 @@ export const createMediaTrack = (trackInit) => {
45
45
  ...(trackInit.getSettings?.() ?? {}),
46
46
  };
47
47
  };
48
+ // Suscribe track events
49
+ let trackUnsubscribe;
50
+ if (trackInit.track) {
51
+ trackUnsubscribe = createStreamTrackEventSubscriptions(trackInit.track, {
52
+ ended(track) {
53
+ assert(track.id === trackInit.track?.id, 'Same Track');
54
+ void mediaTrack.release();
55
+ trackInit.signals?.onMediaTrackStopped?.emit(mediaTrack);
56
+ },
57
+ mute(track) {
58
+ assert(track.id === trackInit.track?.id, 'Same Track');
59
+ trackInit.signals?.onMediaTrackSuspended?.emit(mediaTrack);
60
+ },
61
+ unmute(track) {
62
+ assert(track.id === trackInit.track?.id, 'Same Track');
63
+ trackInit.signals?.onMediaTrackResumed?.emit(mediaTrack);
64
+ },
65
+ });
66
+ }
48
67
  const mediaTrack = {
49
68
  get kind() {
50
69
  return trackInit.kind;
@@ -74,36 +93,50 @@ export const createMediaTrack = (trackInit) => {
74
93
  if (trackInit.overrideMute) {
75
94
  return trackInit.muted;
76
95
  }
77
- // The source is itself, just access the track's state
78
- if (this.source === this) {
79
- return isTrackMuted(trackInit.track);
96
+ if (trackInit.track) {
97
+ return !trackInit.track.enabled;
80
98
  }
81
- return this.source.muted;
99
+ return undefined;
82
100
  },
83
101
  get stopped() {
84
- return trackInit.track ? isTrackEnded(trackInit.track) : true;
102
+ return trackInit.track && isTrackEnded(trackInit.track);
103
+ },
104
+ get suspended() {
105
+ // Any track that is suspended in the chain will cause this track to be suspended
106
+ return (trackInit.previousMediaTrack?.suspended ||
107
+ trackInit.track?.muted);
85
108
  },
86
109
  get source() {
87
- return sourceMediaTrack ?? mediaTrack;
110
+ return sourceMediaTrack ?? this;
88
111
  },
89
- mute(toMute) {
112
+ mute(toMute, soft) {
113
+ if (this.muted === toMute) {
114
+ return;
115
+ }
90
116
  if (trackInit.overrideMute) {
91
- return trackInit.mute?.(toMute, this);
117
+ trackInit.mute?.(toMute, this, soft);
92
118
  }
93
- if (trackInit.track) {
94
- trackInit.track.enabled = !toMute;
119
+ else {
120
+ if (trackInit.track) {
121
+ trackInit.track.enabled = !toMute;
122
+ }
123
+ trackInit.previousMediaTrack?.mute(toMute, soft);
95
124
  }
96
- trackInit.previousMediaTrack?.mute(toMute);
125
+ trackInit.signals?.onMediaTrackMuted?.emit(this);
97
126
  },
98
127
  getSettings,
99
128
  getConstraints() {
100
129
  return getConstraints();
101
130
  },
102
- clone(signals) {
131
+ clone(signals, label) {
103
132
  return createMediaTrack({
104
- ...trackInit,
133
+ kind: this.kind,
134
+ label: [label, this.label]
135
+ .flatMap(a => (a ? [a] : []))
136
+ .join('|'),
137
+ input: this.input,
105
138
  constraints: getConstraints(),
106
- track: trackInit.track?.clone(),
139
+ track: this.track?.clone(),
107
140
  // When Cloning a MediaTrack, overrideMute should be turned off since
108
141
  // it might corrupt the logic of its original media pipeline
109
142
  overrideMute: false,
@@ -146,23 +179,20 @@ export const createMediaTrack = (trackInit) => {
146
179
  Object.assign(currentConstraints, resolvedConstraints);
147
180
  },
148
181
  async release() {
182
+ trackUnsubscribe?.();
149
183
  trackInit.track?.stop();
150
- if (trackInit.track) {
151
- trackInit.signals?.onTrackReleased?.emit(trackInit.track);
152
- trackInit.signals?.[trackInit.track.kind === 'audio'
153
- ? 'onAudioMuteStateChanged'
154
- : 'onVideoMuteStateChanged']?.emit(undefined);
155
- }
156
- await trackInit.previousMediaTrack?.release();
157
184
  await trackInit.release?.();
185
+ await trackInit.previousMediaTrack?.release();
186
+ trackInit.signals?.onMediaTrackStopped?.emit(this);
158
187
  },
159
188
  toJSON() {
160
189
  return {
161
- label: trackInit.label,
162
- track: trackInit.track,
163
- previousMediaTrack: trackInit.previousMediaTrack,
164
- constraints: getConstraints(),
165
- settings: getSettings(),
190
+ label: this.label,
191
+ track: this.track,
192
+ input: this.input,
193
+ previousMediaTrack: this.previousMediaTrack,
194
+ constraints: this.getConstraints(),
195
+ settings: this.getSettings(),
166
196
  };
167
197
  },
168
198
  };
@@ -231,6 +261,10 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
231
261
  status: mediaInit.status,
232
262
  devices: mediaInit.devices,
233
263
  stream: mediaInit.stream ?? new MediaStream(),
264
+ // `undefined` is used to avoid double state overriding,
265
+ // because 2 subsequent `susppend` events could invalidate the previous assignment
266
+ audioAlreadyMuted: undefined,
267
+ videoAlreadyMuted: undefined,
234
268
  audioTrack: mediaInit.tracks
235
269
  .flatMap(track => {
236
270
  return track.kind === 'audioinput' ? [track] : [];
@@ -240,73 +274,76 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
240
274
  .flatMap(track => (track.kind === 'videoinput' ? [track] : []))
241
275
  .at(0),
242
276
  };
243
- const subscribeTrackEvents = (track) => createStreamTrackEventSubscriptions(track, {
244
- ended: track => {
245
- mediaInit.signals?.[track.kind === 'audio'
246
- ? 'onAudioMuteStateChanged'
247
- : 'onVideoMuteStateChanged']?.emit(undefined);
248
- mediaInit.signals?.onStreamTrackEndedFinal?.emit(track);
249
- },
250
- mute: track => {
251
- mediaInit.signals?.[track.kind === 'audio'
252
- ? 'onAudioMuteStateChanged'
253
- : 'onVideoMuteStateChanged']?.emit(true);
254
- mediaInit.signals?.onStreamTrackMuted?.emit(track);
255
- },
256
- unmute: track => {
257
- mediaInit.signals?.[track.kind === 'audio'
258
- ? 'onAudioMuteStateChanged'
259
- : 'onVideoMuteStateChanged']?.emit(!track.enabled);
260
- mediaInit.signals?.onStreamTrackUnmuted?.emit(track);
261
- },
262
- });
263
- const subscribeTrackAndSourceTrackEvents = (track) => {
264
- let trackUnsubscribe;
265
- let sourceTrackUnsubscribe;
266
- if (track.track) {
267
- trackUnsubscribe = subscribeTrackEvents(track.track);
268
- if (track.source.track && track.source.track !== track.track) {
269
- sourceTrackUnsubscribe = subscribeTrackEvents(track.source.track);
270
- }
271
- }
272
- return () => {
273
- trackUnsubscribe?.();
274
- sourceTrackUnsubscribe?.();
275
- };
276
- };
277
- const trackSubscriptions = new Map(mediaInit.tracks.flatMap(track => {
278
- if (track.track) {
279
- return [
280
- [track.track, subscribeTrackAndSourceTrackEvents(track)],
281
- ];
282
- }
283
- return [];
284
- }));
285
277
  const getConstraints = () => {
286
278
  const audio = props.audioTrack?.getConstraints() ?? mediaInit.constraints.audio;
287
279
  const video = props.videoTrack?.getConstraints() ?? mediaInit.constraints.video;
288
280
  return { audio: audio, video: video };
289
281
  };
290
- let unsubscribe = onDevicesChanged.add(devices => {
282
+ const updateDevices = (devices) => {
291
283
  props.devices = devices;
292
- });
293
- const muteTrack = (track, mute) => {
294
- const previousMuteState = track.muted;
295
- const previousEnabledState = track.track?.enabled;
296
- track.mute(mute);
297
- const currentMuteState = track.muted;
298
- const currentEnabledState = track.track?.enabled;
299
- if (track.track &&
300
- previousEnabledState !== undefined &&
301
- currentEnabledState !== undefined &&
302
- previousEnabledState !== currentEnabledState) {
303
- mediaInit.signals?.onStreamTrackEnabled?.emit(track.track);
284
+ };
285
+ // Two-hand control to avoid leaking of the media
286
+ // When the track is suspended by the system, we also mute it if it is not already muted.
287
+ // As it is possible that the track could still flow data even if it is system muted
288
+ const handleSuspended = (track) => {
289
+ switch (track.kind) {
290
+ case 'audioinput': {
291
+ if (props.audioAlreadyMuted === undefined) {
292
+ props.audioAlreadyMuted = props.audioTrack?.muted === true;
293
+ }
294
+ if (!props.audioAlreadyMuted) {
295
+ props.audioTrack?.mute(true, true);
296
+ }
297
+ break;
298
+ }
299
+ case 'videoinput': {
300
+ if (props.videoAlreadyMuted === undefined) {
301
+ props.videoAlreadyMuted = props.videoTrack?.muted === true;
302
+ }
303
+ if (!props.videoAlreadyMuted) {
304
+ props.videoTrack?.mute(true, true);
305
+ }
306
+ break;
307
+ }
308
+ }
309
+ };
310
+ // When the track is resumed by the system, we only unmute the track
311
+ // if an only if it is muted by the suspended event
312
+ const handleResumed = (track) => {
313
+ switch (track.kind) {
314
+ case 'audioinput': {
315
+ assert(props.audioAlreadyMuted !== undefined, 'Audio Resume should not be triggerred before Suspended');
316
+ if (props.audioTrack?.suspended === false) {
317
+ if (!props.audioAlreadyMuted) {
318
+ props.audioTrack.mute(false);
319
+ }
320
+ props.audioAlreadyMuted = undefined;
321
+ }
322
+ break;
323
+ }
324
+ case 'videoinput': {
325
+ assert(props.videoAlreadyMuted !== undefined, 'Video Resume should not be triggerred before Suspended');
326
+ if (props.videoTrack?.suspended === false) {
327
+ if (!props.videoAlreadyMuted) {
328
+ props.videoTrack.mute(false);
329
+ }
330
+ props.videoAlreadyMuted = undefined;
331
+ }
332
+ break;
333
+ }
304
334
  }
305
- if (previousMuteState !== currentMuteState) {
306
- mediaInit.signals?.[track.kind === 'audioinput'
307
- ? 'onAudioMuteStateChanged'
308
- : 'onVideoMuteStateChanged']?.emit(currentMuteState);
335
+ };
336
+ const subscribe = () => [
337
+ onDevicesChanged.add(updateDevices),
338
+ mediaInit.signals?.onMediaTrackSuspended?.add(handleSuspended),
339
+ mediaInit.signals?.onMediaTrackResumed?.add(handleResumed),
340
+ ];
341
+ let subscriptions = subscribe();
342
+ const unsubscribe = () => {
343
+ for (const unsubscribe of subscriptions) {
344
+ unsubscribe?.();
309
345
  }
346
+ subscriptions.length = 0;
310
347
  };
311
348
  return {
312
349
  get id() {
@@ -372,16 +409,16 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
372
409
  props.devices.size('videoinput') > 0));
373
410
  },
374
411
  muteAudio(mute) {
375
- if (!props.audioTrack || props.audioTrack.muted === mute) {
412
+ if (!props.audioTrack || props.audioTrack.suspended) {
376
413
  return;
377
414
  }
378
- muteTrack(props.audioTrack, mute);
415
+ props.audioTrack.mute(mute);
379
416
  },
380
417
  muteVideo(mute) {
381
- if (!props.videoTrack || props.videoTrack.muted === mute) {
418
+ if (!props.videoTrack || props.videoTrack.suspended) {
382
419
  return;
383
420
  }
384
- muteTrack(props.videoTrack, mute);
421
+ props.videoTrack?.mute(mute);
385
422
  },
386
423
  applyConstraints: async (constraints) => {
387
424
  await Promise.all([
@@ -394,12 +431,7 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
394
431
  ]);
395
432
  },
396
433
  async release() {
397
- unsubscribe?.();
398
- unsubscribe = undefined;
399
- for (const unsubscribe of trackSubscriptions.values()) {
400
- unsubscribe();
401
- }
402
- trackSubscriptions.clear();
434
+ unsubscribe();
403
435
  await Promise.all([
404
436
  props.audioTrack?.release(),
405
437
  props.videoTrack?.release(),
@@ -409,13 +441,13 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
409
441
  audio: props.audioTrack?.getSettings(),
410
442
  video: props.videoTrack?.getSettings(),
411
443
  }),
412
- clone(signals) {
444
+ clone(signals, label) {
413
445
  const tracks = [props.audioTrack, props.videoTrack].flatMap(track => {
414
446
  if (!track?.source) {
415
447
  return [];
416
448
  }
417
- const cloned = track.source.clone(signals);
418
- return cloned;
449
+ const cloned = track.source.clone(signals, label);
450
+ return [cloned];
419
451
  });
420
452
  const stream = this.stream &&
421
453
  new MediaStream(tracks.flatMap(track => (track.track ? [track.track] : [])));
@@ -440,6 +472,9 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
440
472
  getVideoTracks() {
441
473
  return props.videoTrack ? [props.videoTrack] : [];
442
474
  },
475
+ isCurrentTrack(track) {
476
+ return track === props.audioTrack || track === props.videoTrack;
477
+ },
443
478
  addTrack(track) {
444
479
  switch (track.kind) {
445
480
  case 'audioinput':
@@ -457,13 +492,22 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
457
492
  default:
458
493
  break;
459
494
  }
495
+ if (subscriptions.length === 0) {
496
+ subscriptions = subscribe();
497
+ }
460
498
  if (track.track) {
461
499
  props.stream.addTrack(track.track);
462
- if (!trackSubscriptions.has(track.track)) {
463
- trackSubscriptions.set(track.track, subscribeTrackAndSourceTrackEvents(track));
464
- }
465
500
  mediaInit.signals?.onAddTrack?.emit(track.track);
466
501
  props.stream.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track: track.track }));
502
+ if (track.muted) {
503
+ mediaInit.signals?.onMediaTrackMuted?.emit(track);
504
+ }
505
+ if (track.suspended) {
506
+ mediaInit.signals?.onMediaTrackSuspended?.emit(track);
507
+ }
508
+ if (track.stopped) {
509
+ mediaInit.signals?.onMediaTrackStopped?.emit(track);
510
+ }
467
511
  }
468
512
  },
469
513
  removeTrack(track) {
@@ -478,13 +522,14 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
478
522
  }
479
523
  if (removed && track.track) {
480
524
  props.stream.removeTrack(track.track);
481
- trackSubscriptions.get(track.track)?.();
482
- trackSubscriptions.delete(track.track);
483
525
  mediaInit.signals?.onRemoveTrack?.emit(track.track);
484
526
  props.stream.dispatchEvent(new MediaStreamTrackEvent('removetrack', {
485
527
  track: track.track,
486
528
  }));
487
529
  }
530
+ if (subscriptions.length) {
531
+ unsubscribe();
532
+ }
488
533
  },
489
534
  toJSON() {
490
535
  return {
@@ -1,10 +1,10 @@
1
- import type { VideoProcessor, SegmentationTransform, SegmentationModel, RenderBackend } from '@pexip/media-processor';
1
+ import type { RenderBackend, SegmentationModel, SegmentationTransform, VideoProcessor } from '@pexip/media-processor';
2
2
  import type { MediaDeviceRequest } from '@pexip/media-control';
3
- import type { TrackProcessor, VideoRenderParams, Segmenters, VideoStreamTrackProcessorAPIs, VideoContentHint } from './types';
3
+ import type { MediaSignals, Segmenters, TrackProcessor, VideoContentHint, VideoRenderParams, VideoStreamTrackProcessorAPIs } from './types';
4
4
  interface ProcessorDeps {
5
- videoProcessor?: () => VideoProcessor;
6
- transformer?: SegmentationTransform;
7
5
  segmenters: Partial<Segmenters>;
6
+ transformer?: SegmentationTransform;
7
+ videoProcessor?: () => VideoProcessor;
8
8
  videoSegmentationModel?: SegmentationModel;
9
9
  }
10
10
  interface VideoStreamProcessOptions extends Partial<VideoRenderParams>, Omit<ProcessorDeps, 'videoProcessor'> {
@@ -28,6 +28,7 @@ interface VideoStreamProcessOptions extends Partial<VideoRenderParams>, Omit<Pro
28
28
  stopAsMute?: () => boolean;
29
29
  dynamicProcessingDimensions?: () => boolean;
30
30
  gpuAPI?: () => RenderBackend;
31
+ signals?: MediaSignals;
31
32
  }
32
33
  interface VideoStreamProcessProps extends Partial<VideoRenderParams>, Required<ProcessorDeps> {
33
34
  hasInitialized: boolean;
@@ -37,5 +38,5 @@ declare const FEATURE_KEYS: readonly ["backgroundBlurAmount", "backgroundImageUr
37
38
  type FeaturePropKeys = (typeof FEATURE_KEYS)[number];
38
39
  type FeatureProps = Pick<Partial<VideoStreamProcessProps>, FeaturePropKeys>;
39
40
  export declare const updateFeatureProps: (constraints: MediaDeviceRequest["video"], props: FeatureProps) => FeatureProps;
40
- export declare const createVideoStreamProcess: ({ backgroundImageUrl, dynamicProcessingDimensions, edgeBlurAmount, foregroundThreshold, frameRate, gpuAPI, label, lowestProcessingHeight, maskCombineRatio, processingHeight, processingWidth, shouldEnable, stopAsMute, trackProcessorAPI, videoSegmentation, ...options }: VideoStreamProcessOptions) => TrackProcessor;
41
+ export declare const createVideoStreamProcess: ({ backgroundImageUrl, dynamicProcessingDimensions, edgeBlurAmount, foregroundThreshold, frameRate, gpuAPI, label, lowestProcessingHeight, maskCombineRatio, processingHeight, processingWidth, shouldEnable, stopAsMute, trackProcessorAPI, videoSegmentation, signals, ...options }: VideoStreamProcessOptions) => TrackProcessor;
41
42
  export {};
@@ -1,5 +1,5 @@
1
- import { createVideoProcessor, createCanvasTransform, createVideoTrackProcessor, createVideoTrackProcessorWithFallback, isRenderEffects, isSegmentationModel, } from '@pexip/media-processor';
2
- import { muteStreamTrack, extractConstraintsWithKeys, getValueFromConstrainNumber, } from '@pexip/media-control';
1
+ import { createCanvasTransform, createVideoProcessor, createVideoTrackProcessor, createVideoTrackProcessorWithFallback, isRenderEffects, isSegmentationModel, } from '@pexip/media-processor';
2
+ import { extractConstraintsWithKeys, getValueFromConstrainNumber, muteStreamTrack, } from '@pexip/media-control';
3
3
  import { isEmpty, assert } from '@pexip/utils';
4
4
  import { createMediaTrack, getBlurKernelSize } from './utils';
5
5
  import { logger, proxyWithLog } from './logger';
@@ -91,6 +91,8 @@ export const updateFeatureProps = (constraints, props) => {
91
91
  }
92
92
  return accm;
93
93
  }
94
+ default:
95
+ return accm;
94
96
  }
95
97
  }, {});
96
98
  };
@@ -142,7 +144,7 @@ const getTrackProcessor = (shouldUseStreamTrackProcessor, ...params) => {
142
144
  return createVideoTrackProcessorWithFallback(...params);
143
145
  };
144
146
  export const createVideoStreamProcess = ({ backgroundImageUrl, dynamicProcessingDimensions = () => false, edgeBlurAmount, foregroundThreshold, frameRate, gpuAPI = () => 'webgl', // dynamically changing the config will not be picked up, not a priority for now
145
- label = PROCESSOR_LABELS.VideoProcessor, lowestProcessingHeight, maskCombineRatio, processingHeight, processingWidth, shouldEnable, stopAsMute = () => false, trackProcessorAPI = () => 'stream', videoSegmentation, ...options }) => {
147
+ label = PROCESSOR_LABELS.VideoProcessor, lowestProcessingHeight, maskCombineRatio, processingHeight, processingWidth, shouldEnable, stopAsMute = () => false, trackProcessorAPI = () => 'stream', videoSegmentation, signals, ...options }) => {
146
148
  const videoSegmentationModel = options.videoSegmentationModel ?? 'selfie';
147
149
  const segmenter = options.segmenters[videoSegmentationModel];
148
150
  if (!segmenter) {
@@ -253,36 +255,61 @@ label = PROCESSOR_LABELS.VideoProcessor, lowestProcessingHeight, maskCombineRati
253
255
  assert(track, 'Video track should be there');
254
256
  // Inherit contentHint from previous track
255
257
  track.contentHint = prevMediaTrack.track.contentHint;
256
- const release = async () => {
257
- props.videoProcessor().close();
258
- await prevMediaTrack.release();
259
- _videoProcessor = undefined;
260
- props.hasInitialized = false;
261
- };
262
- const mute = (mute) => {
263
- props.transformer.effects = mute
264
- ? 'none'
265
- : props.videoSegmentation ?? 'none';
266
- muteStreamTrack(stream)(mute, 'video');
267
- if (mute && stopAsMute()) {
268
- for (const track of stream.getVideoTracks()) {
269
- track.stop();
270
- }
271
- release();
272
- }
273
- prevMediaTrack.mute(mute);
274
- };
258
+ let trackReleaseTimeoutID = 0;
275
259
  return createMediaTrack({
276
260
  label,
277
261
  kind: 'videoinput',
278
- constraints: prevMediaTrack.getConstraints(),
279
- previousMediaTrack: prevMediaTrack,
280
- input: prevMediaTrack.input,
281
- expectedInput: prevMediaTrack.expectedInput,
262
+ get constraints() {
263
+ return this.previousMediaTrack?.getConstraints();
264
+ },
265
+ get previousMediaTrack() {
266
+ return prevMediaTrack;
267
+ },
268
+ get input() {
269
+ return this.previousMediaTrack?.input;
270
+ },
271
+ get expectedInput() {
272
+ return this.previousMediaTrack?.expectedInput;
273
+ },
282
274
  track,
283
- mute,
284
- release,
285
- applyConstraints: async (constraints) => {
275
+ overrideMute: true,
276
+ get muted() {
277
+ return this.previousMediaTrack?.muted;
278
+ },
279
+ mute(mute, _self, soft) {
280
+ props.transformer.effects = mute
281
+ ? 'none'
282
+ : props.videoSegmentation ?? 'none';
283
+ muteStreamTrack(stream)(mute, 'video');
284
+ if (stopAsMute() && !soft) {
285
+ if (mute) {
286
+ // Track cannot be stopped immediately as we need to ensure
287
+ // the last frame is black to avoid frozen frame in a gateway call
288
+ trackReleaseTimeoutID = window.setTimeout(() => {
289
+ for (const track of stream.getVideoTracks()) {
290
+ track.stop();
291
+ }
292
+ void this?.release?.();
293
+ trackReleaseTimeoutID = 0;
294
+ }, 500);
295
+ }
296
+ else {
297
+ if (trackReleaseTimeoutID) {
298
+ window.clearTimeout(trackReleaseTimeoutID);
299
+ trackReleaseTimeoutID = 0;
300
+ }
301
+ }
302
+ }
303
+ this.previousMediaTrack?.mute(mute, soft);
304
+ },
305
+ signals,
306
+ async release() {
307
+ props.videoProcessor().close();
308
+ await this.previousMediaTrack?.release();
309
+ _videoProcessor = undefined;
310
+ props.hasInitialized = false;
311
+ },
312
+ async applyConstraints(constraints) {
286
313
  if (isEmpty(constraints)) {
287
314
  return;
288
315
  }
@@ -293,8 +320,8 @@ label = PROCESSOR_LABELS.VideoProcessor, lowestProcessingHeight, maskCombineRati
293
320
  }
294
321
  applyFeatures(props.transformer, features, lowestProcessingHeight);
295
322
  if (dynamicProcessingDimensions()) {
296
- const acceptedSettings = prevMediaTrack.source.getSettings();
297
- if (acceptedSettings.width && acceptedSettings.height) {
323
+ const acceptedSettings = this.previousMediaTrack?.source.getSettings();
324
+ if (acceptedSettings?.width && acceptedSettings?.height) {
298
325
  props.transformer.update({
299
326
  processingHeight: acceptedSettings.height,
300
327
  processingWidth: acceptedSettings.width,
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@pexip/media",
3
- "version": "20.2.0",
3
+ "version": "20.3.3",
4
4
  "description": "Home for media related stuff",
5
5
  "homepage": "https://gitlab.com/pexip/zoo",
6
6
  "bugs": "https://gitlab.com/pexip/zoo/issues",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://gitlab.com/pexip/zoo.git",
10
- "directory": "src/aquila/packages/media"
9
+ "url": "https://github.com/pexip/aquila.git",
10
+ "directory": "packages/media"
11
11
  },
12
12
  "type": "module",
13
13
  "license": "Apache-2.0",
@@ -44,23 +44,22 @@
44
44
  "typecheck": "yarn tsc --noEmit -p ."
45
45
  },
46
46
  "dependencies": {
47
- "@pexip/media-control": "20.2.0",
48
- "@pexip/media-processor": "20.2.0",
49
- "@pexip/signal": "16.9.0",
50
- "@pexip/utils": "17.0.0"
47
+ "@pexip/media-control": "20.3.3",
48
+ "@pexip/media-processor": "20.3.3",
49
+ "@pexip/signal": "16.9.2",
50
+ "@pexip/utils": "17.1.1"
51
51
  },
52
52
  "devDependencies": {
53
- "@jest/globals": "^29.7.0",
54
- "@pexip/bundler": "18.1.1",
53
+ "@jest/globals": "^30.2.0",
54
+ "@pexip/bundler": "18.1.2",
55
55
  "@swc/core": "^1.10.12",
56
- "@swc/jest": "^0.2.37",
56
+ "@swc/jest": "^0.2.39",
57
57
  "jest": "^29.5.12",
58
58
  "jest-junit": "^16.0.0",
59
59
  "prettier": "^3.2.5",
60
60
  "typescript": "~5.7.3"
61
61
  },
62
62
  "publishConfig": {
63
- "access": "public",
64
- "registry": "https://registry.npmjs.org/"
63
+ "access": "public"
65
64
  }
66
65
  }