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
@@ -1,15 +1,15 @@
import { vi, type Mock } from 'vitest';
import { vi } from 'vitest';
import type {
MediaSourceFactory,
MediaSourceInterface,
} from '../../../../../src/components-lib/live/providers/go2rtc-experimental/adapters/media-source';
import type { StreamSourceChannel } from '../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
import type {
BinaryCallback,
Go2RTCMessage,
MessageCallback,
StreamSourceChannel,
} from '../../../../../src/components-lib/live/providers/go2rtc-experimental/types';
} from '../../../../../src/go2rtc/messages';
import type { UnsubscribeCallback } from '../../../../../src/types';
// ===========================================================================
@@ -28,32 +28,6 @@ export const SAFARI_17_USER_AGENT =
// Fakes for browser APIs jsdom does not provide.
// ===========================================================================
export class FakeWebSocket extends EventTarget {
public binaryType = '';
public sent: string[] = [];
public close = vi.fn();
public send = vi.fn((data: string): void => {
this.sent.push(data);
});
public asWebSocket(): WebSocket {
return this as unknown as WebSocket;
}
public fireOpen(): void {
this.dispatchEvent(new Event('open'));
}
public fireClose(): void {
this.dispatchEvent(new Event('close'));
}
public fireMessage(data: unknown): void {
this.dispatchEvent(new MessageEvent('message', { data }));
}
}
export const createTimeRanges = (ranges: [number, number][]): TimeRanges => ({
length: ranges.length,
start: (index: number) => ranges[index][0],
@@ -77,129 +51,6 @@ class FakeSourceBuffer extends EventTarget {
}
}
export class FakeMediaStreamTrack extends EventTarget {
public muted = false;
public kind: string;
constructor(kind: string) {
super();
this.kind = kind;
}
public asTrack(): MediaStreamTrack {
return this as unknown as MediaStreamTrack;
}
public setMuted(muted: boolean): void {
this.muted = muted;
this.dispatchEvent(new Event(muted ? 'mute' : 'unmute'));
}
}
export class FakeMediaStream {
private _tracks: FakeMediaStreamTrack[];
constructor(tracks: FakeMediaStreamTrack[] = []) {
this._tracks = tracks;
}
public getTracks(): FakeMediaStreamTrack[] {
return this._tracks;
}
public getVideoTracks(): FakeMediaStreamTrack[] {
return this._tracks.filter((track) => track.kind === 'video');
}
public getAudioTracks(): FakeMediaStreamTrack[] {
return this._tracks.filter((track) => track.kind === 'audio');
}
public asMediaStream(): MediaStream {
return this as unknown as MediaStream;
}
}
class FakeRTCTransceiver {
public direction: string;
public currentDirection: string;
public sender: {
track: FakeMediaStreamTrack | null;
replaceTrack: Mock<(track: MediaStreamTrack | null) => Promise<void>>;
};
public receiver: { track: FakeMediaStreamTrack };
constructor(direction: string, kind: string, track: FakeMediaStreamTrack | null) {
this.direction = direction;
this.currentDirection = direction;
this.sender = {
track,
replaceTrack: vi.fn<(track: MediaStreamTrack | null) => Promise<void>>(() =>
Promise.resolve(),
),
};
this.receiver = { track: new FakeMediaStreamTrack(kind) };
}
}
export class FakeRTCPeerConnection extends EventTarget {
public connectionState: RTCPeerConnectionState = 'new';
public remoteDescription: { sdp: string } | null = null;
public transceivers: FakeRTCTransceiver[] = [];
public createOffer = vi.fn(
(): Promise<{ type: string; sdp?: string }> =>
Promise.resolve({ type: 'offer', sdp: 'v=0\r\noffer' }),
);
public setLocalDescription = vi.fn(() => Promise.resolve());
public setRemoteDescription = vi.fn((description: { sdp: string }) => {
this.remoteDescription = description;
return Promise.resolve();
});
public addIceCandidate = vi.fn(() => Promise.resolve());
public close = vi.fn();
public addTransceiver(
trackOrKind: FakeMediaStreamTrack | string,
init: { direction: string },
): FakeRTCTransceiver {
const kind = typeof trackOrKind === 'string' ? trackOrKind : trackOrKind.kind;
const track = typeof trackOrKind === 'string' ? null : trackOrKind;
const transceiver = new FakeRTCTransceiver(init.direction, kind, track);
this.transceivers.push(transceiver);
return transceiver;
}
public getTransceivers(): FakeRTCTransceiver[] {
return this.transceivers;
}
public getReceivers(): { track: FakeMediaStreamTrack }[] {
return this.transceivers.map((transceiver) => transceiver.receiver);
}
public asPeerConnection(): RTCPeerConnection {
return this as unknown as RTCPeerConnection;
}
public getMicrophoneTransceiver(): FakeRTCTransceiver {
return this.transceivers[0];
}
public fireConnectionStateChange(state: RTCPeerConnectionState): void {
this.connectionState = state;
this.dispatchEvent(new Event('connectionstatechange'));
}
public fireIceCandidate(candidate: string | null): void {
const event = new Event('icecandidate');
Object.assign(event, {
candidate: candidate === null ? null : { candidate },
});
this.dispatchEvent(event);
}
}
// ===========================================================================
// Fakes for custom interfaces.
// ===========================================================================