## Summary
- add optional microphone constraints for echo cancellation, noise
suppression, automatic gain control, and channel count
- request configured values as non-mandatory `ideal` constraints
- expose privacy-safe microphone capabilities, requested constraints,
and applied settings in card diagnostics
- document the new configuration and add schema, microphone manager, and
diagnostics tests
## Motivation
The card currently calls `getUserMedia()` with `audio: true`. This
leaves echo cancellation, noise suppression, automatic gain control, and
channel count implicit.
Browser and device behavior differs. Explicit processing defaults can
regress microphone gain or amplify noise on some devices. This change
therefore keeps all processing constraints optional and configurable.
## Configuration
```yaml
live:
microphone:
constraints:
echo_cancellation: true
noise_suppression: true
auto_gain_control: false
channel_count: 1
```
Configured values use `ideal` constraints. A browser can ignore
unsupported values. Card diagnostics show the browser capabilities, the
requested constraints, and the reported applied settings.
## Backward compatibility
- existing configurations still use `audio: true`
- no audio-processing defaults are added
- explicit `false` values are preserved
- diagnostic output excludes device and group identifiers
## Validation
- focused microphone, schema, and diagnostics tests: 46 passed
- full test suite: 7,177 passed
- lint passed
- format check passed
- typecheck passed
- unused-code check passed
- production build passed
The optional constraints were also tested successfully with an iOS Home
Assistant Companion client and a go2rtc-based full-duplex intercom. This
is a client microphone-processing change only. It does not add backend
audio denoise.
---------
Co-authored-by: dermotduffy <dermot.duffy@gmail.com>
302 lines
9.8 KiB
TypeScript
302 lines
9.8 KiB
TypeScript
import { omit } from 'lodash-es';
|
|
|
|
import { localize } from '../localize/localize';
|
|
import { AdvancedCameraCardError } from '../types';
|
|
import { Generation } from '../utils/concurrency/generation';
|
|
import type { CardMicrophoneAPI, MicrophoneDiagnostics, MicrophoneState } from './types';
|
|
|
|
const MICROPHONE_DEVICE_IDENTIFIERS = ['deviceId', 'groupId'] as const;
|
|
|
|
export type MicrophoneDeviceIdentifier = (typeof MICROPHONE_DEVICE_IDENTIFIERS)[number];
|
|
|
|
export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
|
|
constructor() {
|
|
super(localize('error.microphone_not_supported'));
|
|
}
|
|
}
|
|
|
|
export class MicrophoneManager {
|
|
private _api: CardMicrophoneAPI;
|
|
private _stream: MediaStream | null = null;
|
|
|
|
// The most recent microphone connection's diagnostics.
|
|
private _diagnostics: MicrophoneDiagnostics | null = null;
|
|
|
|
// Whether the browser denied the most recent microphone request. Cleared by
|
|
// a later successful connect.
|
|
private _forbidden = false;
|
|
|
|
private _state: MicrophoneState = {
|
|
connected: false,
|
|
muted: true,
|
|
forbidden: false,
|
|
};
|
|
|
|
// We keep desired mute state separate from the overall state so that
|
|
// mute/unmute can be expressed before the stream is even created -- and when
|
|
// it's created it will have the right mute status.
|
|
private _desireMute = true;
|
|
|
|
// Whether an outgoing audio path is active, i.e. something is consuming the
|
|
// microphone stream. While active, mute only disables the tracks so unmute is
|
|
// instant; while inactive, a muted microphone is released outright.
|
|
private _transmissionActive = false;
|
|
|
|
// Guards in-flight getUserMedia requests: a result that resolves after a
|
|
// newer connect or a release must not be installed.
|
|
private _connectGeneration = new Generation();
|
|
|
|
constructor(api: CardMicrophoneAPI) {
|
|
this._api = api;
|
|
}
|
|
|
|
public getState(): MicrophoneState {
|
|
return this._state;
|
|
}
|
|
|
|
public getDiagnostics(): MicrophoneDiagnostics | null {
|
|
return this._diagnostics;
|
|
}
|
|
|
|
public initialize(): void {
|
|
this._setState();
|
|
}
|
|
|
|
public shouldConnectOnInitialization(): boolean {
|
|
return (
|
|
!!this._api.getConfigManager().getConfig()?.live.microphone?.always_connected &&
|
|
// If it won't be possible to connect the microphone at all, we do not
|
|
// block the initialization of the card (the microphone just won't work)
|
|
this.isSupported()
|
|
);
|
|
}
|
|
|
|
public isSupported(): boolean {
|
|
// Some browsers will have mediaDevices/getUserMedia as undefined if
|
|
// accessed over http.
|
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1543
|
|
return !!navigator.mediaDevices?.getUserMedia;
|
|
}
|
|
|
|
// Returns true iff the microphone is connected when this request completes:
|
|
// a request superseded by a newer connect or a release resolves false, as
|
|
// does one whose stream is immediately released for want of an active
|
|
// transmission. A denied request throws.
|
|
public async connect(): Promise<boolean> {
|
|
if (!this.isSupported()) {
|
|
throw new MicrophoneNotSupportedError();
|
|
}
|
|
|
|
const generation = this._connectGeneration.next();
|
|
|
|
let stream: MediaStream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: this._getAudioProcessingConstraints(),
|
|
video: false,
|
|
});
|
|
} catch (e: unknown) {
|
|
// A stale rejection must not mark the microphone forbidden.
|
|
if (this._connectGeneration.isCurrent(generation)) {
|
|
this._releaseStream();
|
|
this._forbidden = true;
|
|
this._setState();
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
if (!this._connectGeneration.isCurrent(generation)) {
|
|
// Superseded while the permission prompt was up: this stream must not
|
|
// survive as an open capture nothing is tracking.
|
|
this._stopTracks(stream);
|
|
return false;
|
|
}
|
|
|
|
// A connect over an existing stream must not leak the tracks of the
|
|
// stream it replaces.
|
|
this._removeEndedListeners(this._stream);
|
|
this._stopTracks(this._stream);
|
|
this._stream = stream;
|
|
this._diagnostics = this._getTrackDiagnostics(stream.getAudioTracks()[0]);
|
|
this._addEndedListeners(stream);
|
|
this._forbidden = false;
|
|
this._reconcile();
|
|
this._setState();
|
|
return this.isConnected();
|
|
}
|
|
|
|
// Reports whether an outgoing audio path is active. When transmission ends,
|
|
// the microphone returns to muted and -- unless `always_connected` -- the
|
|
// device is released.
|
|
public setTransmissionActive(transmissionActive: boolean): void {
|
|
if (this._transmissionActive === transmissionActive) {
|
|
return;
|
|
}
|
|
this._transmissionActive = transmissionActive;
|
|
if (!transmissionActive) {
|
|
this._desireMute = true;
|
|
}
|
|
this._reconcile();
|
|
this._setState();
|
|
}
|
|
|
|
public getStream(): MediaStream | null {
|
|
return this._stream;
|
|
}
|
|
|
|
public mute(): void {
|
|
this._desireMute = true;
|
|
this._reconcile();
|
|
this._setState();
|
|
}
|
|
|
|
public async unmute(): Promise<void> {
|
|
// An unmute without an active outgoing audio path is meaningless: nothing
|
|
// consumes the stream, so enabling (or creating) a capture would only light
|
|
// the browser recording indicator.
|
|
if (!this.isSupported() || !this._transmissionActive) {
|
|
return;
|
|
}
|
|
|
|
this._desireMute = false;
|
|
|
|
if (!this.isConnected() && !this.isForbidden()) {
|
|
// Connecting applies the desired mute to the new stream.
|
|
await this.connect();
|
|
return;
|
|
}
|
|
this._reconcile();
|
|
this._setState();
|
|
}
|
|
|
|
public isConnected(): boolean {
|
|
return !!this._stream;
|
|
}
|
|
|
|
public isForbidden(): boolean {
|
|
return this._forbidden;
|
|
}
|
|
|
|
public isMuted(): boolean {
|
|
// For safety, this function always returns the stream mute status directly
|
|
// (rather the desired internal state).
|
|
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
|
|
}
|
|
|
|
private _getAudioProcessingConstraints(): true | MediaTrackConstraints {
|
|
const audioProcessing = this._api.getConfigManager().getConfig()?.live
|
|
.microphone?.audio_processing;
|
|
|
|
const constraints: MediaTrackConstraints = {};
|
|
if (typeof audioProcessing?.auto_gain_control === 'boolean') {
|
|
constraints.autoGainControl = { ideal: audioProcessing.auto_gain_control };
|
|
}
|
|
if (audioProcessing?.channel_count !== undefined) {
|
|
constraints.channelCount = { ideal: audioProcessing.channel_count };
|
|
}
|
|
if (typeof audioProcessing?.echo_cancellation === 'boolean') {
|
|
constraints.echoCancellation = { ideal: audioProcessing.echo_cancellation };
|
|
}
|
|
if (typeof audioProcessing?.noise_suppression === 'boolean') {
|
|
constraints.noiseSuppression = { ideal: audioProcessing.noise_suppression };
|
|
}
|
|
|
|
return Object.keys(constraints).length ? constraints : true;
|
|
}
|
|
|
|
private _getTrackDiagnostics(track?: MediaStreamTrack): MicrophoneDiagnostics | null {
|
|
if (!track) {
|
|
return null;
|
|
}
|
|
|
|
// Remove values not suitable for sharing.
|
|
const getReportableValues = <
|
|
T extends Partial<Record<MicrophoneDeviceIdentifier, unknown>>,
|
|
>(
|
|
values?: T,
|
|
): Omit<T, MicrophoneDeviceIdentifier> | null => {
|
|
if (!values) {
|
|
return null;
|
|
}
|
|
|
|
const reportable = omit(values, MICROPHONE_DEVICE_IDENTIFIERS);
|
|
return Object.keys(reportable).length ? reportable : null;
|
|
};
|
|
|
|
const capabilities = getReportableValues(track.getCapabilities?.());
|
|
const settings = getReportableValues(track.getSettings());
|
|
const diagnostics = {
|
|
...(capabilities && { capabilities }),
|
|
...(settings && { settings }),
|
|
};
|
|
return Object.keys(diagnostics).length ? diagnostics : null;
|
|
}
|
|
|
|
private _stopTracks(stream: MediaStream | null): void {
|
|
stream?.getTracks().forEach((track) => track.stop());
|
|
}
|
|
|
|
// A device that disappears -- unplugged, or its permission revoked -- ends
|
|
// its tracks. Nothing can revive them, so the stream is dropped and the new
|
|
// state published, rather than leaving the card reporting a connected
|
|
// microphone that captures nothing. `stop()` does not fire this event, so
|
|
// releasing the stream cannot re-enter.
|
|
private _handleTrackEnded = (): void => {
|
|
this._releaseStream();
|
|
this._setState();
|
|
};
|
|
|
|
private _addEndedListeners(stream: MediaStream): void {
|
|
stream
|
|
.getTracks()
|
|
.forEach((track) => track.addEventListener('ended', this._handleTrackEnded));
|
|
}
|
|
|
|
private _removeEndedListeners(stream: MediaStream | null): void {
|
|
stream
|
|
?.getTracks()
|
|
.forEach((track) => track.removeEventListener('ended', this._handleTrackEnded));
|
|
}
|
|
|
|
private _releaseStream(): void {
|
|
this._connectGeneration.invalidate();
|
|
this._removeEndedListeners(this._stream);
|
|
this._stopTracks(this._stream);
|
|
this._stream = null;
|
|
}
|
|
|
|
// The single place that applies microphone policy to the device: whether
|
|
// the device is held or released, and whether its tracks are live. A muted
|
|
// microphone with no active transmission is released entirely (turning the
|
|
// browser recording indicator off) unless `always_connected`; while
|
|
// transmission is active, mute only disables the tracks so unmute needs no
|
|
// new permission request or renegotiation.
|
|
private _reconcile(): void {
|
|
if (!this._stream) {
|
|
return;
|
|
}
|
|
const alwaysConnected = !!this._api.getConfigManager().getConfig()?.live.microphone
|
|
?.always_connected;
|
|
if (this._desireMute && !this._transmissionActive && !alwaysConnected) {
|
|
this._releaseStream();
|
|
return;
|
|
}
|
|
this._stream.getTracks().forEach((track) => {
|
|
track.enabled = !this._desireMute;
|
|
});
|
|
}
|
|
|
|
private _setState(): void {
|
|
this._state = {
|
|
stream: this._stream,
|
|
connected: this.isConnected(),
|
|
muted: this.isMuted(),
|
|
forbidden: this.isForbidden(),
|
|
};
|
|
this._api.getConditionStateManager().setState({
|
|
microphone: this._state,
|
|
});
|
|
this._api.getCardElementManager().update();
|
|
}
|
|
}
|