fix: Hold the 2-way audio backchannel open for the shortest possible time (#2697)

The card claimed a camera's ONVIF audio backchannel in two places that
had
nothing to do with a call: the capability probe at camera init
(`&microphone`),
and a pre-armed `sendonly` audio transceiver on every live WebRTC offer.
Merely
looking at a dashboard occupied the camera's speaker line. Outbound
audio now
travels on its own audio-only WebRTC connection, opened when a call is
answered
and closed when it ends.

- The backchannel is claimed only for the duration of a call. Idle
viewing
  claims nothing.
- Two-way audio now works in `mse`, `mp4` and `mjpeg` modes (note: the
outbound
  audio still traverses WebRTC).
- No renegotiation and no video blink at call start or end.
- A call that cannot carry audio now reports it and ends, instead of
showing a
  live microphone that goes nowhere.
- `live.microphone.always_connected` is now purely about the browser
microphone
  permission prompt.
- Call setup measured at 66ms (LAN) and ~260ms (cellular) for ICE and
DTLS, plus
  ~300ms for `go2rtc` to open an RTSP backchannel.

Verified against a live Frigate + `go2rtc` instance, and by unit tests
at 100%
coverage.

 - Closes #2691
 - Closes #2039
 - Closes #2178

Ref #2299 -- the probe no longer opens a backchannel, but it still runs
per
camera on every load and reconnect, and still dials the camera on the
direct-`go2rtc` path. Caching remains to be done.

Ref AlexxIT/go2rtc#1860 -- once a call has opened a backchannel,
`go2rtc` keeps
that media set up on the camera's RTSP session for the life of the
producer.

Diagnoses #2678
This commit is contained in:
Dermot Duffy
2026-08-21 20:13:58 -07:00
committed by GitHub
parent 2d7c86c93c
commit 11cc543406
65 changed files with 2251 additions and 1045 deletions
+87 -13
View File
@@ -1,3 +1,8 @@
import { createBackchannel } from '../../components-lib/live/backchannel/factory';
import {
BackchannelError,
type Backchannel,
} from '../../components-lib/live/backchannel/types';
import { createNotificationFromText } from '../../components-lib/notification/factory';
import type { ConditionStateChange } from '../../condition-trigger/conditions/types';
import { localize } from '../../localize/localize';
@@ -16,6 +21,8 @@ export class CallManager {
private _ringtone = new Ringtone();
private _unansweredTimer = new Timer();
private _backchannel: Backchannel | null = null;
// Identifies the current init/uninit cycle so an in-flight `start()` or
// `answer()` resuming from its microphone-connect await can detect that its
// CallManager was torn down -- or torn down and re-initialized -- while it
@@ -200,7 +207,8 @@ export class CallManager {
if (call.inbound && !call.answered && timeoutSeconds > 0) {
this._unansweredTimer.start(timeoutSeconds, () => this.end());
}
return true;
return call.answered ? await this._openBackchannel(call, inbound) : true;
}
// Ends the call and returns to the pre-call view. Returns true iff a call was
@@ -242,10 +250,12 @@ export class CallManager {
// Replace (don't mutate) so Lit identity checks downstream pick up the
// change. The `update()` below forces card.ts to re-render and re-read
// `getCall()`, propagating the new session to the carousel.
this._call = { ...call, answered: true };
const answeredCall = { ...call, answered: true };
this._call = answeredCall;
this._api.getConditionStateManager().setState({ call: 'answered' });
this._api.getCardElementManager().update();
return true;
return await this._openBackchannel(answeredCall, false);
}
// Ends the active call iff every supplied predicate matches the session.
@@ -270,20 +280,81 @@ export class CallManager {
return this.end();
}
// The microphone could not be used for the call, so it is connected but the
// user cannot be heard. `description` is what the reporting layer knows about
// the failure, when it knows anything.
public reportCallMicrophoneError(targetID: string, description?: string): void {
const call = this._call;
// Opens the backchannel for an answered call. Failing to open it ends
// the call, so the user is never left with call controls when they cannot be
// heard. Returns true iff the call is still running.
private async _openBackchannel(call: CallSession, inbound: boolean): Promise<boolean> {
const targetID = call.callCameraID ?? call.cameraID;
const hass = this._api.getHASSManager().getHASS();
const camera = this._api.getCameraManager().getStore().getCamera(targetID);
const stream = this._api.getMicrophoneManager().getStream();
// A report that no longer matches the call in progress describes an attempt
// the user has already moved past, e.g. the call ended before the provider
// finished reporting.
if (!call || !call.answered || call.cameraID !== targetID) {
const backchannel =
hass && camera
? createBackchannel(hass, camera, (error) =>
this._reportBackchannelLoss(call, error),
)
: null;
if (!backchannel || !stream) {
this._notifyError('error.call_no_two_way_audio', { inbound });
this._end(true);
return false;
}
this._backchannel = backchannel;
try {
await backchannel.start(stream);
} catch (error: unknown) {
if (this._call !== call) {
return false;
}
this._closeBackchannel();
this._notifyBackchannelError(error, inbound);
this._end(true);
return false;
}
if (this._call !== call) {
backchannel.stop();
return false;
}
return true;
}
private _closeBackchannel(): void {
this._backchannel?.stop();
this._backchannel = null;
}
private _reportBackchannelLoss(call: CallSession, error: BackchannelError): void {
if (this._call !== call) {
return;
}
this._notifyBackchannelError(error, call.inbound);
}
private _notifyBackchannelError(error: unknown, inbound: boolean): void {
const reason = error instanceof BackchannelError ? error.reason : 'failed';
// Abandonment means this manager closed the backchannel itself, because the
// call ended or was replaced. Nothing failed, so there is nothing to report.
if (reason === 'abandoned') {
return;
}
this._notifyError('error.call_microphone_failed', { context: description });
const messageKey =
reason === 'no_two_way_audio'
? 'error.call_no_two_way_audio'
: reason === 'no_microphone'
? 'error.call_microphone_failed'
: 'error.call_camera_unreachable';
this._notifyError(messageKey, {
inbound,
...(error instanceof BackchannelError &&
error.description && { context: error.description }),
});
}
// Tears down everything `initialize()` set up: stops any in-flight ringtone
@@ -296,6 +367,7 @@ export class CallManager {
this._initGeneration.invalidate();
this._ringtone.stop();
this._unansweredTimer.stop();
this._closeBackchannel();
this._api.getMicrophoneManager().setTransmissionActive(false);
if (this._call) {
this._call = null;
@@ -324,6 +396,8 @@ export class CallManager {
this._ringtone.stop();
this._unansweredTimer.stop();
this._closeBackchannel();
// Clear the session first: ending the call dispatches a view change, and
// the resulting condition-state change must not see this (now-ending) call
// and recurse.