Skip to content

Commit 6110c3a

Browse files
author
MargeBot
committed
Merge branch 'recording-audion-sync-fix' into 'main'
Use WebCodecs + AudioWorklet for meet recording See merge request web/clients!25437
2 parents 5c608c7 + 5a856c9 commit 6110c3a

27 files changed

Lines changed: 630 additions & 200 deletions

File tree

applications/meet/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"cross-env": "^10.1.0",
4747
"date-fns-tz": "^2.0.1",
4848
"livekit-client": "2.18.9",
49+
"mediabunny": "^1.0.0",
4950
"react": "^18.3.1",
5051
"react-dom": "^18.3.1",
5152
"react-redux": "^9.2.0",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// AudioWorklet that taps the recording audio mix and posts batched PCM
2+
// (planar Float32) to the main thread for the WebCodecs recorder.
3+
//
4+
// Plain JS on purpose: AudioWorklet modules run in their own global scope and
5+
// are served as a static asset (see audioMixer.startWorkletTap), not bundled.
6+
7+
const TARGET_FRAMES = 1024; // ~21ms at 48kHz; batches quanta to cut messaging.
8+
9+
class RecorderTapProcessor extends AudioWorkletProcessor {
10+
constructor() {
11+
super();
12+
this._buffers = null;
13+
this._filled = 0;
14+
this._startFrame = 0;
15+
}
16+
17+
process(inputs) {
18+
const input = inputs[0];
19+
if (!input || input.length === 0 || !input[0] || input[0].length === 0) {
20+
return true;
21+
}
22+
23+
const numChannels = input.length;
24+
const frames = input[0].length;
25+
26+
if (!this._buffers) {
27+
this._buffers = [];
28+
for (let c = 0; c < numChannels; c++) {
29+
this._buffers.push(new Float32Array(TARGET_FRAMES));
30+
}
31+
this._filled = 0;
32+
this._startFrame = currentFrame;
33+
}
34+
35+
for (let c = 0; c < numChannels; c++) {
36+
this._buffers[c].set(input[c], this._filled);
37+
}
38+
this._filled += frames;
39+
40+
if (this._filled >= TARGET_FRAMES) {
41+
this.port.postMessage({ channels: this._buffers, frame: this._startFrame, sampleRate });
42+
this._buffers = null;
43+
}
44+
45+
return true;
46+
}
47+
}
48+
49+
registerProcessor('recorder-tap', RecorderTapProcessor);

applications/meet/src/app/hooks/useMeetingRecorder/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ export const supportsTrackProcessor = () => {
4848
export const createMediaStreamTrackProcessor = (track: MediaStreamTrack) => {
4949
try {
5050
// In Safari, MediaStreamTrackProcessor is available in Worker context
51-
// @ts-expect-error - MediaStreamTrackProcessor is not yet in TypeScript types
5251
return new MediaStreamTrackProcessor({ track });
5352
} catch (error) {
5453
return null;

applications/meet/src/app/hooks/useMeetingRecorderNew/audioMixer/audioMixer.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { TrackReference } from '@livekit/components-react';
22

3+
import type { AudioTapSamples } from '../mediaEncoder/types';
4+
35
// Delays at which we re-apply the latest audio source set. The browser
46
// sometimes attaches the new track to the AudioContext slightly after the
57
// publication switch notifies us, so reapplying once or twice shortly after
@@ -21,10 +23,13 @@ export class AudioMixer {
2123
private destinationStream: MediaStream;
2224
private audioSourceNodes: Map<string, { source: MediaStreamAudioSourceNode; stream: MediaStream }>;
2325
private resyncTimers: Set<ReturnType<typeof setTimeout>> = new Set();
26+
private workletNode: AudioWorkletNode | null = null;
27+
private workletSink: GainNode | null = null;
2428

2529
constructor() {
2630
this.audioSourceNodes = new Map();
27-
this.audioContext = new AudioContext();
31+
// 48 kHz: the AAC encoder rejects the hardware's native rate on some devices (e.g. 96 kHz).
32+
this.audioContext = new AudioContext({ sampleRate: 48000 });
2833

2934
this.audioCompressor = this.audioContext.createDynamicsCompressor();
3035
this.audioCompressor.threshold.value = -24;
@@ -142,12 +147,29 @@ export class AudioMixer {
142147
return this.destinationStream.getAudioTracks();
143148
}
144149

145-
public getAudioContextCurrentTimeMs(): number {
146-
return this.audioContext.currentTime * 1000;
150+
// Taps the mixed audio via an AudioWorklet, a parallel branch into a muted sink,
151+
// so the main mix is untouched.
152+
public async startWorkletTap(onSamples: (samples: AudioTapSamples) => void): Promise<void> {
153+
await this.audioContext.audioWorklet.addModule('/assets/recording/recorderTapWorklet.js');
154+
this.workletNode = new AudioWorkletNode(this.audioContext, 'recorder-tap');
155+
this.workletNode.port.onmessage = (event: MessageEvent<AudioTapSamples>) => onSamples(event.data);
156+
this.workletSink = this.audioContext.createGain();
157+
this.workletSink.gain.value = 0;
158+
this.audioCompressor.connect(this.workletNode);
159+
this.workletNode.connect(this.workletSink);
160+
this.workletSink.connect(this.audioContext.destination);
161+
}
162+
163+
public stopWorkletTap(): void {
164+
this.workletNode?.disconnect();
165+
this.workletSink?.disconnect();
166+
this.workletNode = null;
167+
this.workletSink = null;
147168
}
148169

149170
public cleanup() {
150171
this.cancelPendingResyncs();
172+
this.stopWorkletTap();
151173
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
152174

153175
this.audioSourceNodes.forEach(({ source }) => {

applications/meet/src/app/hooks/useMeetingRecorderNew/chunkWatchdog/chunkWatchdog.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const createChunkWatchdog = ({
2020
intervalMs = DEFAULT_INTERVAL_MS,
2121
primingThresholdMs = DEFAULT_PRIMING_THRESHOLD_MS,
2222
stallThresholdMs = DEFAULT_STALL_THRESHOLD_MS,
23+
isWebCodecs,
2324
}: ChunkWatchdogOptions): ChunkWatchdog => {
2425
let interval: ReturnType<typeof setInterval> | null = null;
2526
let startedAt = 0;
@@ -42,23 +43,30 @@ export const createChunkWatchdog = ({
4243
console.error(
4344
`[MeetingRecorder] watchdog: no chunk with data in the last ${Math.round(sinceLastChunk)}ms (${phase})`,
4445
{
46+
isWebCodecs,
4547
recordingCodec,
4648
mediaRecorderState,
4749
chunksWithData: snapshot.chunkCount,
4850
emptyChunks: snapshot.emptyChunkCount,
4951
firstChunkAt: snapshot.firstChunkAt,
5052
}
5153
);
52-
reportMeetError('MeetingRecording Error: watchdog detected stalled MediaRecorder', {
53-
context: {
54-
recordingCodec,
55-
mediaRecorderState,
56-
chunksWithData: snapshot.chunkCount,
57-
emptyChunks: snapshot.emptyChunkCount,
58-
sinceLastChunkMs: Math.round(sinceLastChunk),
59-
phase,
60-
},
61-
});
54+
reportMeetError(
55+
isWebCodecs
56+
? 'MeetingRecording Error WebCodecs: watchdog detected stalled MediaRecorder'
57+
: 'MeetingRecording Error: watchdog detected stalled MediaRecorder',
58+
{
59+
context: {
60+
isWebCodecs,
61+
recordingCodec,
62+
mediaRecorderState,
63+
chunksWithData: snapshot.chunkCount,
64+
emptyChunks: snapshot.emptyChunkCount,
65+
sinceLastChunkMs: Math.round(sinceLastChunk),
66+
phase,
67+
},
68+
}
69+
);
6270
}
6371

6472
if (sinceLastChunk <= threshold && warned) {

applications/meet/src/app/hooks/useMeetingRecorderNew/chunkWatchdog/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface ChunkWatchdogOptions {
1111
intervalMs?: number;
1212
primingThresholdMs?: number;
1313
stallThresholdMs?: number;
14+
isWebCodecs: boolean;
1415
}
1516

1617
export interface ChunkWatchdog {

applications/meet/src/app/hooks/useMeetingRecorderNew/hooks/useRecordingCodec.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,30 @@ import { useEffect, useState } from 'react';
22

33
import { getSupportedRecordingCodec } from '../codec/getSupportedCodec';
44
import type { RecordingCodec } from '../codec/types';
5+
import { WEBCODECS_MP4_CODEC } from '../mediaEncoder/webCodecsRecorder';
56

67
// Detects the supported codec exactly once and only when recording is allowed.
78
// The probe is expensive (real MediaRecorder runs against a canvas stream), so
89
// we keep the result cached in component state.
9-
export const useRecordingCodec = (enabled: boolean): RecordingCodec | null => {
10+
export const useRecordingCodec = ({
11+
enabled,
12+
isWebCodecs,
13+
}: {
14+
enabled: boolean;
15+
isWebCodecs: boolean;
16+
}): RecordingCodec | null => {
1017
const [codec, setCodec] = useState<RecordingCodec | null>(null);
1118

1219
useEffect(() => {
1320
if (!enabled || codec) {
1421
return;
1522
}
1623

24+
if (isWebCodecs) {
25+
setCodec(WEBCODECS_MP4_CODEC);
26+
return;
27+
}
28+
1729
let cancelled = false;
1830
void getSupportedRecordingCodec().then((detected) => {
1931
if (!cancelled) {
@@ -24,7 +36,7 @@ export const useRecordingCodec = (enabled: boolean): RecordingCodec | null => {
2436
return () => {
2537
cancelled = true;
2638
};
27-
}, [enabled, codec]);
39+
}, [enabled, codec, isWebCodecs]);
2840

2941
return codec;
3042
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Whether this browser can run the WebCodecs recording backend (mediabunny);
2+
// otherwise we fall back to MediaRecorder.
3+
export const isWebCodecsRecordingSupported = (): boolean => {
4+
return 'VideoEncoder' in globalThis && 'AudioEncoder' in globalThis;
5+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const ENCODER_VIDEO_BITRATE = 2_000_000;
2+
export const ENCODER_AUDIO_BITRATE = 128_000;
3+
// Seconds between forced key frames. Frequent enough for seeking/editing.
4+
export const KEYFRAME_INTERVAL_SEC = 2;
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {
2+
type AudioCodec,
3+
AudioSample,
4+
AudioSampleSource,
5+
CanvasSource,
6+
Mp4OutputFormat,
7+
Output,
8+
StreamTarget,
9+
type StreamTargetChunk,
10+
} from 'mediabunny';
11+
12+
import { ENCODER_AUDIO_BITRATE, ENCODER_VIDEO_BITRATE, KEYFRAME_INTERVAL_SEC } from './constants';
13+
import type { AudioTapSamples } from './types';
14+
15+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
16+
17+
const AUDIO_STALL_MS = 500; // audio silent this long → fall back to the wall clock
18+
const AUDIO_POLL_MS = 4; // re-check cadence while waiting for the clock to advance
19+
20+
// Encodes the live OffscreenCanvas to a fragmented MP4 at constant frame rate:
21+
// evenly spaced timestamps (n / fps), duplicating the canvas on a missed slot.
22+
// The frame count follows the audio clock (wall-clock fallback when there's no
23+
// audio), so the video duration tracks the audio and can't drift from it.
24+
export class EncoderPipeline {
25+
private output: Output;
26+
private videoSource: CanvasSource;
27+
private audioSource: AudioSampleSource;
28+
private audioT0: number | null = null;
29+
private audioTail: Promise<void> = Promise.resolve();
30+
private audioElapsed = 0;
31+
private audioUpdatedMs = 0;
32+
private hasAudio = false;
33+
private fps: number;
34+
private running = false;
35+
private startMs = 0;
36+
private emittedFrames = 0;
37+
private loopPromise: Promise<void> | null = null;
38+
private onError: (error: unknown) => void;
39+
40+
constructor(
41+
canvas: OffscreenCanvas,
42+
fps: number,
43+
audioCodec: AudioCodec,
44+
onChunk: (data: Uint8Array<ArrayBuffer>, position: number) => void,
45+
onError: (error: unknown) => void
46+
) {
47+
this.fps = fps;
48+
this.onError = onError;
49+
50+
const writable = new WritableStream<StreamTargetChunk>({
51+
write(chunk) {
52+
// Copy out of Mediabunny's buffer before handing ownership off.
53+
onChunk(chunk.data.slice(), chunk.position);
54+
},
55+
});
56+
57+
this.output = new Output({
58+
format: new Mp4OutputFormat({ fastStart: 'fragmented' }),
59+
target: new StreamTarget(writable),
60+
});
61+
62+
this.videoSource = new CanvasSource(canvas, {
63+
codec: 'avc',
64+
bitrate: ENCODER_VIDEO_BITRATE,
65+
keyFrameInterval: KEYFRAME_INTERVAL_SEC,
66+
});
67+
this.output.addVideoTrack(this.videoSource);
68+
69+
this.audioSource = new AudioSampleSource({ codec: audioCodec, bitrate: ENCODER_AUDIO_BITRATE });
70+
this.output.addAudioTrack(this.audioSource);
71+
}
72+
73+
public async start(): Promise<void> {
74+
await this.output.start();
75+
this.running = true;
76+
this.startMs = performance.now();
77+
this.loopPromise = this.captureLoop();
78+
}
79+
80+
private async captureLoop(): Promise<void> {
81+
const frameDuration = 1 / this.fps;
82+
while (this.running) {
83+
const nowMs = performance.now();
84+
const wallElapsed = (nowMs - this.startMs) / 1000;
85+
const audioLive = this.hasAudio && nowMs - this.audioUpdatedMs < AUDIO_STALL_MS;
86+
const reference = audioLive ? Math.min(wallElapsed, this.audioElapsed) : wallElapsed;
87+
const dueFrames = Math.floor(reference * this.fps);
88+
if (this.emittedFrames <= dueFrames) {
89+
await this.videoSource.add(this.emittedFrames * frameDuration, frameDuration);
90+
this.emittedFrames += 1;
91+
} else {
92+
const nextSlotMs = this.startMs + (this.emittedFrames * 1000) / this.fps;
93+
await sleep(Math.max(AUDIO_POLL_MS, nextSlotMs - nowMs));
94+
}
95+
}
96+
}
97+
98+
public addAudioSamples({ channels, frame, sampleRate }: AudioTapSamples): void {
99+
if (!this.running || channels.length === 0) {
100+
return;
101+
}
102+
const numberOfChannels = channels.length;
103+
const numberOfFrames = channels[0].length;
104+
const timestamp = frame / sampleRate;
105+
if (this.audioT0 === null) {
106+
this.audioT0 = timestamp;
107+
}
108+
const rebasedTimestamp = timestamp - this.audioT0;
109+
110+
this.audioElapsed = rebasedTimestamp + numberOfFrames / sampleRate;
111+
this.audioUpdatedMs = performance.now();
112+
this.hasAudio = true;
113+
114+
const data = new Float32Array(numberOfChannels * numberOfFrames);
115+
for (let c = 0; c < numberOfChannels; c++) {
116+
data.set(channels[c], c * numberOfFrames);
117+
}
118+
const sample = new AudioSample({
119+
data,
120+
format: 'f32-planar',
121+
numberOfChannels,
122+
sampleRate,
123+
timestamp: rebasedTimestamp,
124+
});
125+
// audioSource.add isn't concurrency-safe — keep these adds serialized.
126+
this.audioTail = this.audioTail.then(() => this.addAudioSample(sample));
127+
}
128+
129+
private async addAudioSample(sample: AudioSample): Promise<void> {
130+
try {
131+
await this.audioSource.add(sample);
132+
} catch (error) {
133+
if (this.running) {
134+
this.onError(error);
135+
}
136+
}
137+
sample.close();
138+
}
139+
140+
public async stop(): Promise<void> {
141+
this.running = false;
142+
await this.loopPromise;
143+
await this.audioTail;
144+
await this.output.finalize();
145+
}
146+
}

0 commit comments

Comments
 (0)