fix: Release the microphone to the browser when a call ends (#2685)

The microphone is connected when a call needs it and released the moment
that
call ends, so the browser stops reporting it as in use at hangup rather
than
`disconnect_seconds` later.

Existing configurations are migrated automatically by the visual editor.

 - Closes: #2681 

BREAKING CHANGE: `live.microphone.disconnect_seconds` is removed. The
microphone is released when a call ends, so there is no idle countdown
to
configure. Use `live.microphone.always_connected` to hold it open
instead.

BREAKING CHANGE: The `microphone_connect` and `microphone_disconnect`
actions
are removed. The card owns the microphone lifecycle; `microphone_mute`
and
`microphone_unmute` remain.

BREAKING CHANGE: `call` is removed from `live.microphone.auto_mute`,
whose
default is now `[]`. The microphone is muted when a call ends regardless
of
this option.

BREAKING CHANGE: `microphone_unmute` has no effect outside a call.
Nothing
carries the audio at any other time, so the request is ignored rather
than
opening the microphone.
This commit is contained in:
Dermot Duffy
2026-08-14 16:17:31 -07:00
committed by GitHub
parent 65bc52d18e
commit 85d6811761
33 changed files with 1480 additions and 572 deletions
@@ -1,11 +0,0 @@
import type { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import type { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class MicrophoneConnectAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getMicrophoneManager().connect();
}
}
@@ -1,11 +0,0 @@
import type { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import type { CardActionsAPI } from '../../types';
import { AdvancedCameraCardAction } from './base';
export class MicrophoneDisconnectAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getMicrophoneManager().disconnect();
}
}
-6
View File
@@ -28,8 +28,6 @@ import { InternalCallbackAction } from './actions/internal-callback';
import { LogAction } from './actions/log';
import { MediaPlayerAction } from './actions/media-player';
import { MenuToggleAction } from './actions/menu-toggle';
import { MicrophoneConnectAction } from './actions/microphone-connect';
import { MicrophoneDisconnectAction } from './actions/microphone-disconnect';
import { MicrophoneMuteAction } from './actions/microphone-mute';
import { MicrophoneUnmuteAction } from './actions/microphone-unmute';
import { MoreInfoAction } from './actions/more-info';
@@ -159,10 +157,6 @@ export class ActionFactory {
return new SubstreamOnAction(context, action, options?.config);
case 'media_player':
return new MediaPlayerAction(context, action, options?.config);
case 'microphone_connect':
return new MicrophoneConnectAction(context, action, options?.config);
case 'microphone_disconnect':
return new MicrophoneDisconnectAction(context, action, options?.config);
case 'microphone_mute':
return new MicrophoneMuteAction(context, action, options?.config);
case 'microphone_unmute':
+48 -18
View File
@@ -109,7 +109,7 @@ export class CallManager {
// granting microphone access -- so connecting here would let a refusal stop
// the call from ever ringing. The connect is deferred to `answer()`. An
// outbound call is answered by construction and needs it immediately.
if (!inbound && !(await this._connectMicrophone())) {
if (!inbound && !(await this._grantTransmissionAndConnect())) {
return false;
}
@@ -137,7 +137,8 @@ export class CallManager {
if (inbound && (existingCall.answered || !existingCall.inbound)) {
return false;
}
this._end(false);
// The replacement call inherits the microphone.
this._end(false, { retainMicrophone: true });
}
// Outbound calls are answered by construction (the user initiated them);
@@ -152,9 +153,16 @@ export class CallManager {
answered,
};
// The microphone's own idle timeout knows nothing about calls, so without
// this the tracks would be stopped mid-conversation.
this._api.getMicrophoneManager().startUsing();
// `_grantTransmissionAndConnect` above reported the need for transmission
// before awaiting the microphone connect; a call ending during that await
// may have withdrawn it since, so an answered session reports it again --
// ahead of the view and condition state below, whose listeners may replace
// the session. A ringing session reports nothing: it transmits nothing,
// and reporting inactive could end a transmission another request is
// using.
if (answered) {
this._api.getMicrophoneManager().setTransmissionActive(true);
}
this._api.getViewManager().setViewByParameters({
...(needsNavigation && {
@@ -219,13 +227,14 @@ export class CallManager {
// An inbound call rings without the microphone, so this is where it is
// connected -- under the user gesture that answering provides. The call is
// left ringing on failure so it can be answered again.
if (!(await this._connectMicrophone())) {
if (!(await this._grantTransmissionAndConnect())) {
return false;
}
// The call may have ended, or been superseded by another, while the
// microphone connect was in flight -- there is then nothing left to answer.
if (this._call !== call) {
this._revokeTransmissionIfNoAnsweredCall();
return false;
}
@@ -287,9 +296,9 @@ export class CallManager {
this._initGeneration.invalidate();
this._ringtone.stop();
this._unansweredTimer.stop();
this._api.getMicrophoneManager().setTransmissionActive(false);
if (this._call) {
this._call = null;
this._api.getMicrophoneManager().stopUsing();
this._api.getConditionStateManager().setState({ call: 'idle' });
}
this._api
@@ -302,8 +311,9 @@ export class CallManager {
// is `false` for auto-ends (navigating away, camera/substream change), where
// the user has already chosen a destination and the pre-call view is
// deliberately not reinstated; only the manager's own auto-end paths pass it.
// `retainMicrophone` does not relinquish the microphone.
// Returns true iff a call was actually ended.
private _end(restoreView: boolean): boolean {
private _end(restoreView: boolean, options?: { retainMicrophone?: boolean }): boolean {
if (!this._call) {
return false;
}
@@ -319,9 +329,9 @@ export class CallManager {
// and recurse.
this._call = null;
// The call no longer needs the microphone, so the normal
// `disconnect_seconds` countdown restarts from here.
this._api.getMicrophoneManager().stopUsing();
if (!options?.retainMicrophone && call.answered) {
this._api.getMicrophoneManager().setTransmissionActive(false);
}
const viewManager = this._api.getViewManager();
@@ -424,9 +434,29 @@ export class CallManager {
return true;
}
private _revokeTransmissionIfNoAnsweredCall(): void {
if (!this._call?.answered) {
this._api.getMicrophoneManager().setTransmissionActive(false);
}
}
// The microphone manager releases a stream that connects while transmission
// is inactive, so transmission is activated first and revoked on failure.
// Returns true iff the microphone is connected and this request is still
// current.
private async _grantTransmissionAndConnect(): Promise<boolean> {
this._api.getMicrophoneManager().setTransmissionActive(true);
if (!(await this._connectMicrophone())) {
this._revokeTransmissionIfNoAnsweredCall();
return false;
}
return true;
}
// Connects the microphone for a call. Returns true iff it is connected and
// this request still belongs to the current init/uninit lifecycle. A connect
// failure is surfaced as a notification.
// this request still belongs to the current init/uninit lifecycle. A denied
// connect is surfaced as a notification; a connect superseded by a newer
// request fails silently (the newer request owns the outcome).
private async _connectMicrophone(): Promise<boolean> {
const microphoneManager = this._api.getMicrophoneManager();
if (microphoneManager.isConnected()) {
@@ -435,11 +465,12 @@ export class CallManager {
const initGeneration = this._initGeneration.current();
let connected = false;
let forbidden = false;
try {
await microphoneManager.connect();
connected = true;
connected = await microphoneManager.connect();
} catch {
// Reported below, once this request is known to still be the current one.
forbidden = true;
}
// If the init/uninit lifecycle advanced while the connect was in flight,
@@ -450,11 +481,10 @@ export class CallManager {
return false;
}
if (!connected) {
if (forbidden) {
this._notifyError('error.call_microphone_forbidden');
return false;
}
return true;
return connected;
}
private _hasCallCapability(cameraID: string): boolean {
+94 -59
View File
@@ -1,6 +1,6 @@
import { localize } from '../localize/localize';
import { AdvancedCameraCardError } from '../types';
import { Timer } from '../utils/timer';
import { Generation } from '../utils/concurrency/generation';
import type { CardMicrophoneAPI, MicrophoneState } from './types';
export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
@@ -11,8 +11,11 @@ export class MicrophoneNotSupportedError extends AdvancedCameraCardError {
export class MicrophoneManager {
private _api: CardMicrophoneAPI;
private _stream?: MediaStream | null;
private _timer = new Timer();
private _stream: MediaStream | 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,
@@ -25,9 +28,14 @@ export class MicrophoneManager {
// it's created it will have the right mute status.
private _desireMute = true;
// Whether something is actively using the microphone, and so when the
// connection can safely be closed.
private _inUse = false;
// 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;
@@ -57,72 +65,92 @@ export class MicrophoneManager {
return !!navigator.mediaDevices?.getUserMedia;
}
public async connect(): Promise<void> {
// 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 {
this._stream = await navigator.mediaDevices.getUserMedia({
stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
} catch (e: unknown) {
this._stream = null;
this._setState();
// A stale rejection must not mark the microphone forbidden.
if (this._connectGeneration.isCurrent(generation)) {
this._releaseStream();
this._forbidden = true;
this._setState();
}
throw e;
}
this._setDesiredMuteOnStream();
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._stopTracks(this._stream);
this._stream = 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 disconnect(): void {
this._timer.stop();
this._stream?.getTracks().forEach((track) => track.stop());
this._stream = undefined;
this._setState();
}
// Marks the microphone as in use (e.g. by an in-progress call). It stays
// connected regardless of `disconnect_seconds` until `stopUsing`, since
// stopping the tracks under a user would silently cut their audio.
public startUsing(): void {
this._inUse = true;
this._timer.stop();
}
// Marks the microphone as no longer in use. It is idle again, so the
// disconnect countdown restarts from the full `disconnect_seconds`.
public stopUsing(): void {
this._inUse = false;
this._startDisconnectTimer();
}
public getStream(): MediaStream | undefined {
return this._stream ?? undefined;
public getStream(): MediaStream | null {
return this._stream;
}
public mute(): void {
this._desireMute = true;
this._setDesiredMuteOnStream();
this._reconcile();
this._setState();
}
public async unmute(): Promise<void> {
if (!this.isSupported()) {
// 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 will automatically set the desired mute.
// Connecting applies the desired mute to the new stream.
await this.connect();
} else if (this.isConnected()) {
this._setDesiredMuteOnStream();
this._setState();
return;
}
this._reconcile();
this._setState();
}
public isConnected(): boolean {
@@ -130,7 +158,7 @@ export class MicrophoneManager {
}
public isForbidden(): boolean {
return this._stream === null;
return this._forbidden;
}
public isMuted(): boolean {
@@ -139,28 +167,35 @@ export class MicrophoneManager {
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
}
private _setDesiredMuteOnStream(): void {
this._stream?.getTracks().forEach((track) => {
track.enabled = !this._desireMute;
});
this._startDisconnectTimer();
private _stopTracks(stream: MediaStream | null): void {
stream?.getTracks().forEach((track) => track.stop());
}
private _startDisconnectTimer(): void {
const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone;
private _releaseStream(): void {
this._connectGeneration.invalidate();
this._stopTracks(this._stream);
this._stream = null;
}
if (microphoneConfig?.always_connected || this._inUse) {
// 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 disconnectSeconds = microphoneConfig?.disconnect_seconds ?? 0;
if (disconnectSeconds) {
this._timer.start(disconnectSeconds, () => {
this.disconnect();
});
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 {