src/audiomixcontroller/DefaultAudioMixController.ts (133 lines of code) (raw):
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import AudioMixObserver from '../audiomixobserver/AudioMixObserver';
import BrowserBehavior from '../browserbehavior/BrowserBehavior';
import DefaultBrowserBehavior from '../browserbehavior/DefaultBrowserBehavior';
import Logger from '../logger/Logger';
import MediaStreamBrokerObserver from '../mediastreambrokerobserver/MediaStreamBrokerObserver';
import AsyncScheduler from '../scheduler/AsyncScheduler';
import AudioMixController from './AudioMixController';
/** @internal */
interface AudioElementWithSinkId extends HTMLAudioElement {
sinkId: string;
setSinkId: (id: string) => void;
}
export default class DefaultAudioMixController
implements AudioMixController, MediaStreamBrokerObserver {
private audioDevice: MediaDeviceInfo | null = null;
private audioElement: HTMLAudioElement | null = null;
private audioStream: MediaStream | null = null;
private browserBehavior: BrowserBehavior = new DefaultBrowserBehavior();
private observers: Set<AudioMixObserver> = new Set<AudioMixObserver>();
constructor(private logger?: Logger) {}
async bindAudioElement(element: HTMLAudioElement): Promise<void> {
if (!element) {
throw new Error(`Cannot bind audio element: ${element}`);
}
this.audioElement = element;
this.audioElement.autoplay = true;
return this.bindAudioMix();
}
unbindAudioElement(): void {
if (!this.audioElement) {
return;
}
this.audioElement.srcObject = null;
this.audioElement = null;
this.forEachObserver((observer: AudioMixObserver) => {
if (this.audioStream) {
observer.meetingAudioStreamBecameInactive(this.audioStream);
}
});
}
async bindAudioStream(stream: MediaStream): Promise<void> {
if (!stream) {
return;
}
this.audioStream = stream;
try {
await this.bindAudioMix();
} catch (error) {
/* istanbul ignore else */
if (this.logger) {
this.logger.warn(`Failed to bind audio stream: ${error}`);
}
}
}
async bindAudioDevice(device: MediaDeviceInfo | null): Promise<void> {
/**
* Throw error if browser doesn't even support setSinkId
* Read more: https://caniuse.com/?search=setSinkId
*/
if (device && !this.browserBehavior.supportsSetSinkId()) {
throw new Error(
'Cannot select audio output device. This browser does not support setSinkId.'
);
}
// Always set device -- we might be setting it back to `null` to reselect
// the default, and even in that case we need to call `bindAudioMix` in
// order to update the sink ID to the empty string.
this.audioDevice = device;
return this.bindAudioMix();
}
private forEachObserver(observerFunc: (observer: AudioMixObserver) => void): void {
for (const observer of this.observers) {
AsyncScheduler.nextTick(() => {
observerFunc(observer);
});
}
}
private async bindAudioMix(): Promise<void> {
if (!this.audioElement) {
return;
}
const previousStream = this.audioElement.srcObject as MediaStream;
if (this.audioStream) {
this.audioElement.srcObject = this.audioStream;
}
if (previousStream !== this.audioStream) {
this.forEachObserver((observer: AudioMixObserver) => {
if (previousStream) {
observer.meetingAudioStreamBecameInactive(previousStream);
}
if (this.audioStream) {
observer.meetingAudioStreamBecameActive(this.audioStream);
}
});
}
// In usual operation, the output device is undefined, and so is the element
// sink ID. In this case, don't throw an error -- we're being called as a side
// effect of just binding the audio element, not choosing an output device.
const shouldSetSinkId =
this.audioDevice?.deviceId !== (this.audioElement as AudioElementWithSinkId).sinkId;
if (
shouldSetSinkId &&
typeof (this.audioElement as AudioElementWithSinkId).sinkId === 'undefined'
) {
throw new Error(
'Cannot select audio output device. This browser does not support setSinkId.'
);
}
const newSinkId = this.audioDevice ? this.audioDevice.deviceId : '';
const oldSinkId: string = (this.audioElement as AudioElementWithSinkId).sinkId;
if (newSinkId === oldSinkId) {
return;
}
// Take the existing stream and temporarily unbind it while we change
// the sink ID.
const existingAudioElement: AudioElementWithSinkId = this
.audioElement as AudioElementWithSinkId;
const existingStream = this.audioStream;
if (this.browserBehavior.hasChromiumWebRTC()) {
existingAudioElement.srcObject = null;
}
if (shouldSetSinkId) {
try {
await existingAudioElement.setSinkId(newSinkId);
} catch (error) {
this.logger?.error(`Failed to set sinkId for audio element: ${error}`);
throw error;
}
}
if (this.browserBehavior.hasChromiumWebRTC()) {
existingAudioElement.srcObject = existingStream;
}
}
async getCurrentMeetingAudioStream(): Promise<MediaStream | null> {
return this.audioStream;
}
async addAudioMixObserver(observer: AudioMixObserver): Promise<void> {
this.observers.add(observer);
}
async removeAudioMixObserver(observer: AudioMixObserver): Promise<void> {
this.observers.delete(observer);
}
async audioOutputDidChange(device: MediaDeviceInfo | null): Promise<void> {
this.logger.info('Receive an audio output change event');
return this.bindAudioDevice(device);
}
}