UNPKG

twilio-video

Version:

Twilio Video JavaScript Library

349 lines (317 loc) 14.8 kB
'use strict'; const { isIOS } = require('../../util/browserdetection'); const detectSilentAudio = require('../../util/detectsilentaudio'); const { isIOSChrome } = require('../../webrtc/util'); const AudioTrack = require('./audiotrack'); const mixinLocalMediaTrack = require('./localmediatrack'); const LocalMediaAudioTrack = mixinLocalMediaTrack(AudioTrack); /** * A {@link LocalAudioTrack} is an {@link AudioTrack} representing audio that * your {@link LocalParticipant} can publish to a {@link Room}. It can be * enabled and disabled with {@link LocalAudioTrack#enable} and * {@link LocalAudioTrack#disable} or stopped completely with * {@link LocalAudioTrack#stop}. * @extends AudioTrack * @property {Track.ID} id - The {@link LocalAudioTrack}'s ID * @property {boolean} isMuted - Whether or not the audio source has stopped sending samples to the * {@link LocalAudioTrack}; This can happen when the microphone is taken over by another application, * mainly on mobile devices; When this property toggles, then <code>muted</code> and <code>unmuted</code> * events are fired appropriately * @property {boolean} isStopped - Whether or not the {@link LocalAudioTrack} is * stopped * @property {NoiseCancellation?} noiseCancellation - When a LocalAudioTrack is created * with {@link NoiseCancellationOptions}, this property provides interface * to enable or disable the noise cancellation at runtime. * @emits LocalAudioTrack#disabled * @emits LocalAudioTrack#enabled * @emits LocalAudioTrack#muted * @emits LocalAudioTrack#started * @emits LocalAudioTrack#stopped * @emits LocalAudioTrack#unmuted */ class LocalAudioTrack extends LocalMediaAudioTrack { /** * Construct a {@link LocalAudioTrack} from a MediaStreamTrack. * @param {MediaStreamTrack} mediaStreamTrack - An audio MediaStreamTrack * @param {LocalTrackOptions} [options] - {@link LocalTrack} options */ constructor(mediaStreamTrack, options) { const noiseCancellation = options?.noiseCancellation || null; super(mediaStreamTrack, options); const { _log: log } = this; const { label: defaultDeviceLabel = '' } = mediaStreamTrack; const { deviceId: defaultDeviceId = '', groupId: defaultGroupId = '' } = mediaStreamTrack.getSettings(); Object.defineProperties(this, { _currentDefaultDeviceInfo: { value: { deviceId: defaultDeviceId, groupId: defaultGroupId, label: defaultDeviceLabel }, writable: true }, _enumerateDevices: { value: typeof options?.enumerateDevices === 'function' ? options.enumerateDevices : navigator.mediaDevices.enumerateDevices }, _defaultDeviceCaptureMode: { value: (!isIOS() || !!noiseCancellation) && this._isCreatedByCreateLocalTracks && typeof navigator === 'object' && typeof navigator.mediaDevices === 'object' && typeof navigator.mediaDevices.addEventListener === 'function' && (typeof options?.enumerateDevices === 'function' || typeof navigator.mediaDevices.enumerateDevices === 'function') ? options?.defaultDeviceCaptureMode || 'auto' : 'manual' }, _onDeviceChange: { value: () => { this._enumerateDevices().then(deviceInfos => { // NOTE(mmalavalli): In Chrome, when the default device changes, and we restart the LocalAudioTrack with // device ID "default", it will not switch to the new default device unless all LocalAudioTracks capturing // from the old default device are stopped. So, we restart the LocalAudioTrack with the actual device ID of // the new default device instead. const defaultDeviceInfo = deviceInfos.find(({ deviceId, kind }) => { return kind === 'audioinput' && deviceId !== 'default'; }); if (defaultDeviceInfo && ['deviceId', 'groupId'].some(prop => { return defaultDeviceInfo[prop] !== this._currentDefaultDeviceInfo[prop]; })) { log.info('Default device changed, restarting the LocalAudioTrack'); log.debug(`Old default device: "${this._currentDefaultDeviceInfo.deviceId}" => "${this._currentDefaultDeviceInfo.label}"`); log.debug(`New default device: "${defaultDeviceInfo.deviceId}" => "${defaultDeviceInfo.label}"`); this._currentDefaultDeviceInfo = defaultDeviceInfo; this._restartDefaultDevice().catch(error => log.warn(`Failed to restart: ${error.message}`)); } }, error => { log.warn(`Failed to run enumerateDevices(): ${error.message}`); }); } }, _restartOnDefaultDeviceChangeCleanup: { value: null, writable: true }, noiseCancellation: { enumerable: true, value: noiseCancellation, writable: false }, }); log.debug('defaultDeviceCaptureMode:', this._defaultDeviceCaptureMode); this._maybeRestartOnDefaultDeviceChange(); } toString() { return `[LocalAudioTrack #${this._instanceId}: ${this.id}]`; } attach(el) { el = super.attach.call(this, el); el.muted = true; return el; } /** * @private */ _end() { return super._end.apply(this, arguments); } /** * @private */ _maybeRestartOnDefaultDeviceChange() { const { _constraints: constraints, _defaultDeviceCaptureMode: defaultDeviceCaptureMode, _log: log } = this; const mediaStreamTrack = this.noiseCancellation ? this.noiseCancellation.sourceTrack : this.mediaStreamTrack; const { deviceId } = mediaStreamTrack.getSettings(); const isNotEqualToCapturedDeviceIdOrEqualToDefault = requestedDeviceId => { return requestedDeviceId !== deviceId || requestedDeviceId === 'default'; }; const isCapturingFromDefaultDevice = (function checkIfCapturingFromDefaultDevice(deviceIdConstraint = {}) { if (typeof deviceIdConstraint === 'string') { return isNotEqualToCapturedDeviceIdOrEqualToDefault(deviceIdConstraint); } else if (Array.isArray(deviceIdConstraint)) { return deviceIdConstraint.every(isNotEqualToCapturedDeviceIdOrEqualToDefault); } else if (deviceIdConstraint.exact) { return checkIfCapturingFromDefaultDevice(deviceIdConstraint.exact); } else if (deviceIdConstraint.ideal) { return checkIfCapturingFromDefaultDevice(deviceIdConstraint.ideal); } return true; }(constraints.deviceId)); if (defaultDeviceCaptureMode === 'auto' && isCapturingFromDefaultDevice) { if (!this._restartOnDefaultDeviceChangeCleanup) { log.info('LocalAudioTrack will be restarted if the default device changes'); navigator.mediaDevices.addEventListener('devicechange', this._onDeviceChange); this._restartOnDefaultDeviceChangeCleanup = () => { log.info('Cleaning up the listener to restart the LocalAudioTrack if the default device changes'); navigator.mediaDevices.removeEventListener('devicechange', this._onDeviceChange); this._restartOnDefaultDeviceChangeCleanup = null; }; } } else { log.info('LocalAudioTrack will NOT be restarted if the default device changes'); if (this._restartOnDefaultDeviceChangeCleanup) { this._restartOnDefaultDeviceChangeCleanup(); } } } /** * @private */ _reacquireTrack(constraints) { this._log.debug('_reacquireTrack: ', constraints); if (this.noiseCancellation) { return this.noiseCancellation.reacquireTrack(() => { return super._reacquireTrack.call(this, constraints); }); } return super._reacquireTrack.call(this, constraints); } /** * @private */ _restartDefaultDevice() { const constraints = Object.assign({}, this._constraints); const restartConstraints = Object.assign({}, constraints, { deviceId: this._currentDefaultDeviceInfo.deviceId }); return this.restart(restartConstraints).then(() => { // NOTE(mmalavalli): Since we used the new default device's ID while restarting the LocalAudioTrack, // we reset the constraints to the original constraints so that the default device detection logic in // _maybeRestartOnDefaultDeviceChange() still works. this._constraints = constraints; this._maybeRestartOnDefaultDeviceChange(); }); } /** * NOTE(mmalavalli): On iOS 17 Chrome, a LocalAudioTrack with Krisp Noise Cancellation * enabled that is restarted due to foregrounding the browser is silent for as-of-yet * unknown reason. We work around this by discarding the Krisp MediaStreamTrack and using * the source MediaStreamTrack. (VIDEO-13006) * @private */ _setMediaStreamTrack(mediaStreamTrack) { const { _log: log, noiseCancellation } = this; let promise = super._setMediaStreamTrack.call(this, mediaStreamTrack); if (isIOSChrome() && !!noiseCancellation) { log.debug('iOS Chrome detected, checking if the restarted Krisp audio is silent'); promise = promise.then(() => detectSilentAudio(this._dummyEl)).then(isSilent => { log.debug(`Krisp audio is ${isSilent ? 'silent, using source audio' : 'not silent'}`); return isSilent && noiseCancellation.disablePermanently().then(() => { return super._setMediaStreamTrack.call(this, noiseCancellation.sourceTrack); }); }); } return promise; } /** * Disable the {@link LocalAudioTrack}. This is equivalent to muting the audio source. * @returns {this} * @fires LocalAudioTrack#disabled */ disable() { return super.disable.apply(this, arguments); } /** * Enable the {@link LocalAudioTrack}. This is equivalent to unmuting the audio source. * @returns {this} * @fires LocalAudioTrack#enabled *//** * Enable or disable the {@link LocalAudioTrack}. This is equivalent to unmuting or muting * the audio source respectively. * @param {boolean} [enabled] - Specify false to disable the * {@link LocalAudioTrack} * @returns {this} * @fires LocalAudioTrack#disabled * @fires LocalAudioTrack#enabled */ enable() { return super.enable.apply(this, arguments); } /** * Restart the {@link LocalAudioTrack}. This stops the existing MediaStreamTrack * and creates a new MediaStreamTrack. If the {@link LocalAudioTrack} is being published * to a {@link Room}, then all the {@link RemoteParticipant}s will start receiving media * from the newly created MediaStreamTrack. You can access the new MediaStreamTrack via * the <code>mediaStreamTrack</code> property. If you want to listen to events on * the MediaStreamTrack directly, please do so in the "started" event handler. Also, * the {@link LocalAudioTrack}'s ID is no longer guaranteed to be the same as the * underlying MediaStreamTrack's ID. * @param {MediaTrackConstraints} [constraints] - The optional <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints" target="_blank">MediaTrackConstraints</a> * for restarting the {@link LocalAudioTrack}; If not specified, then the current MediaTrackConstraints * will be used; If <code>{}</code> (empty object) is specified, then the default MediaTrackConstraints * will be used * @returns {Promise<void>} Rejects with a TypeError if the {@link LocalAudioTrack} was not created * using an one of <code>createLocalAudioTrack</code>, <code>createLocalTracks</code> or <code>connect</code>; * Also rejects with the <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Exceptions" target="_blank">DOMException</a> * raised by <code>getUserMedia</code> when it fails * @fires LocalAudioTrack#stopped * @fires LocalAudioTrack#started * @example * const { connect, createLocalAudioTrack } = require('twilio-video'); * * // Create a LocalAudioTrack that captures audio from a USB microphone. * createLocalAudioTrack({ deviceId: 'usb-mic-id' }).then(function(localAudioTrack) { * return connect('token', { * name: 'my-cool-room', * tracks: [localAudioTrack] * }); * }).then(function(room) { * // Restart the LocalAudioTrack to capture audio from the default microphone. * const localAudioTrack = Array.from(room.localParticipant.audioTracks.values())[0].track; * return localAudioTrack.restart({ deviceId: 'default-mic-id' }); * }); */ restart() { return super.restart.apply(this, arguments); } /** * Calls stop on the underlying MediaStreamTrack. If you choose to stop a * {@link LocalAudioTrack}, you should unpublish it after stopping. * @returns {this} * @fires LocalAudioTrack#stopped */ stop() { if (this.noiseCancellation) { this.noiseCancellation.stop(); } if (this._restartOnDefaultDeviceChangeCleanup) { this._restartOnDefaultDeviceChangeCleanup(); } return super.stop.apply(this, arguments); } } /** * The {@link LocalAudioTrack} was disabled, i.e. the audio source was muted by the user. * @param {LocalAudioTrack} track - The {@link LocalAudioTrack} that was * disabled * @event LocalAudioTrack#disabled */ /** * The {@link LocalAudioTrack} was enabled, i.e. the audio source was unmuted by the user. * @param {LocalAudioTrack} track - The {@link LocalAudioTrack} that was enabled * @event LocalAudioTrack#enabled */ /** * The {@link LocalAudioTrack} was muted because the audio source stopped sending samples, most * likely due to another application taking said audio source, especially on mobile devices. * @param {LocalAudioTrack} track - The {@link LocalAudioTrack} that was muted * @event LocalAudioTrack#muted */ /** * The {@link LocalAudioTrack} started. This means there is enough audio data to * begin playback. * @param {LocalAudioTrack} track - The {@link LocalAudioTrack} that started * @event LocalAudioTrack#started */ /** * The {@link LocalAudioTrack} stopped, either because {@link LocalAudioTrack#stop} * or {@link LocalAudioTrack#restart} was called or because the underlying * MediaStreamTrack ended. * @param {LocalAudioTrack} track - The {@link LocalAudioTrack} that stopped * @event LocalAudioTrack#stopped */ /** * The {@link LocalAudioTrack} was unmuted because the audio source resumed sending samples, * most likely due to the application that took over the said audio source has released it * back to the application, especially on mobile devices. This event is also fired when * {@link LocalAudioTrack#restart} is called on a muted {@link LocalAudioTrack} with a * new audio source. * @param {LocalAudioTrack} track - The {@link LocalAudioTrack} that was unmuted * @event LocalAudioTrack#unmuted */ module.exports = LocalAudioTrack;