chromium/ash/webui/recorder_app_ui/resources/core/microphone_manager.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {ReadonlySignal, signal} from './reactive/signal.js';
import {assert, assertExists} from './utils/assert.js';
import {AsyncJobQueue} from './utils/async_job_queue.js';

const DEFAULT_MIC_ID = 'default';

export interface InternalMicInfo {
  // Whether the microphone is the system default microphone.
  isDefault: boolean;
  // Whether the microphone is an internal microphone.
  isInternal: boolean;
}

// A subset of info from MediaDeviceInfo.
export interface BasicMicInfo {
  deviceId: string;
  label: string;
}

export type MicrophoneInfo = BasicMicInfo&InternalMicInfo;

type MicInfoCallback = (deviceId: string) => Promise<InternalMicInfo>;

async function listAllMicrophones(infoCallback: MicInfoCallback
): Promise<MicrophoneInfo[]> {
  const allDevices = await navigator.mediaDevices.enumerateDevices();

  // Use only audioinput, and remove the device with the deviceId "default"
  // since it's a duplicated entry and can't be used to get the internal info.
  const devices = allDevices.filter(
    (d) => d.kind === 'audioinput' && d.deviceId !== DEFAULT_MIC_ID
  );

  // Retrieve the internal info from mojo.
  const devicesWithInfo =
    await Promise.all(devices.map(async ({deviceId, label}) => {
      const internalMicInfo = await infoCallback(deviceId);
      return {...internalMicInfo, deviceId, label};
    }));

  // Microphones sorting order: Default mic, Internal mics, then by label.
  const sortedDevices = devicesWithInfo.sort((a, b) => {
    if (a.isDefault !== b.isDefault) {
      return a.isDefault ? -1 : 1;
    } else if (a.isInternal !== b.isInternal) {
      return a.isInternal ? -1 : 1;
    }
    return a.label.localeCompare(b.label);
  });

  return sortedDevices;
}

/**
 * Returns whether the given `micId` is a valid microphone deviceId.
 *
 * @param microphones List of all connected microphones.
 * @param micId DeviceId of the microphone.
 * @return Whether the given `micId` is a valid microphone deviceId.
 */
function isValidMicId(microphones: MicrophoneInfo[], micId: string|null):
  boolean {
  return microphones.some((device) => device.deviceId === micId);
}

/**
 * Return the deviceId of the selected microphone.
 *
 * In case the latest selected microphone is still connected, returns
 * `currentMicId`, otherwise, returns the deviceId of the default microphone.
 *
 * @param microphoneList List of all connected microphones.
 * @param currentMicId DeviceId of the current selected microphone.
 * @return DeviceId of the selected microphone.
 */
function getSelectedMicId(
  microphoneList: MicrophoneInfo[], currentMicId: string|null
): string|null {
  if (isValidMicId(microphoneList, currentMicId)) {
    return currentMicId;
  }

  // TODO(kamchonlathorn): Handle the case when there are no microphones,
  // probably show an error dialog.
  if (microphoneList.length === 0) {
    console.error('There are no connected microphones.');
    return '';
  }

  // In case the microphone is unplugged, fall back to the default device.
  return assertExists(microphoneList[0]).deviceId;
}

export class MicrophoneManager {
  private readonly cachedMicrophoneList = signal<MicrophoneInfo[]>([]);

  private readonly selectedMicId = signal<string|null>(null);

  private readonly updateMicListQueue = new AsyncJobQueue('keepLatest');

  static async create(infoCallback: MicInfoCallback
  ): Promise<MicrophoneManager> {
    const microphoneList = await listAllMicrophones(infoCallback);
    return new MicrophoneManager(microphoneList, infoCallback);
  }

  private constructor(
    microphoneList: MicrophoneInfo[],
    private readonly infoCallback: MicInfoCallback
  ) {
    this.cachedMicrophoneList.value = microphoneList;
    this.selectedMicId.value = getSelectedMicId(microphoneList, null);
    navigator.mediaDevices.addEventListener('devicechange', () => {
      this.updateMicListQueue.push(() => this.updateActiveMicrophones());
    });
  }

  getMicrophoneList(): ReadonlySignal<MicrophoneInfo[]> {
    return this.cachedMicrophoneList;
  }

  getSelectedMicId(): ReadonlySignal<string|null> {
    return this.selectedMicId;
  }

  setSelectedMicId(micId: string): void {
    assert(
      isValidMicId(this.cachedMicrophoneList.value, micId),
      `Invalid microphone deviceId: ${micId}`
    );
    this.selectedMicId.value = micId;
  }

  private async updateActiveMicrophones(): Promise<void> {
    const microphones = await listAllMicrophones(this.infoCallback);
    this.cachedMicrophoneList.value = microphones;
    this.selectedMicId.value =
      getSelectedMicId(microphones, this.selectedMicId.value);
  }
}