Skip to content

Commit

Permalink
FG-2933: Add mp4a.40.2 capture to MCAP recording demo
Browse files Browse the repository at this point in the history
  • Loading branch information
fgwt202412 committed Dec 16, 2024
1 parent 0bb7c29 commit 107767e
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 38 deletions.
30 changes: 27 additions & 3 deletions website/src/components/McapRecordingDemo/McapRecordingDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
CompressedAudioData,
startAudioCapture,
supportsOpusEncoding,
supportsMP4AEncoding,
} from "./audioCapture";
import {
CompressedVideoFrame,
Expand Down Expand Up @@ -144,6 +145,7 @@ export function McapRecordingDemo(): JSX.Element {
const [recordH265, setRecordH265] = useState(false);
const [recordVP9, setRecordVP9] = useState(false);
const [recordAV1, setRecordAV1] = useState(false);
const [recordMP4A, setRecordMP4A] = useState(false);
const [recordOpus, setRecordOpus] = useState(false);
const [recordMouse, setRecordMouse] = useState(true);
const [recordOrientation, setRecordOrientation] = useState(true);
Expand All @@ -164,6 +166,7 @@ export function McapRecordingDemo(): JSX.Element {
const { data: h265Support } = useAsync(supportsH265Encoding);
const { data: vp9Support } = useAsync(supportsVP9Encoding);
const { data: av1Support } = useAsync(supportsAV1Encoding);
const { data: mp4aSupport } = useAsync(supportsMP4AEncoding);
const { data: opusSupport } = useAsync(supportsOpusEncoding);

const canStartRecording =
Expand All @@ -174,6 +177,7 @@ export function McapRecordingDemo(): JSX.Element {
(recordH265 && !videoError) ||
(recordH264 && !videoError) ||
(recordJpeg && !videoError) ||
(recordMP4A && !audioError) ||
(recordOpus && !audioError);

// Automatically pause recording after 30 seconds to avoid unbounded growth
Expand Down Expand Up @@ -304,7 +308,7 @@ export function McapRecordingDemo(): JSX.Element {
undefined,
);

const enableMicrophone = recordOpus;
const enableMicrophone = recordMP4A || recordOpus;
useEffect(() => {
const progress = audioProgressRef.current;
if (!progress || !enableMicrophone) {
Expand All @@ -327,14 +331,15 @@ export function McapRecordingDemo(): JSX.Element {
setAudioStream(undefined);
setAudioError(undefined);
};
}, [enableMicrophone, recordOpus]);
}, [enableMicrophone]);

useEffect(() => {
if (!enableMicrophone || !recording || !audioStream) {
return;
}

const cleanup = startAudioCapture({
enableMP4A: recordMP4A,
enableOpus: recordOpus,
stream: audioStream,
onAudioData: (data) => {
Expand All @@ -348,7 +353,14 @@ export function McapRecordingDemo(): JSX.Element {
return () => {
cleanup?.();
};
}, [addAudioData, enableMicrophone, recordOpus, audioStream, recording]);
}, [
addAudioData,
enableMicrophone,
recordMP4A,
recordOpus,
audioStream,
recording,
]);

const onRecordClick = useCallback(
(event: React.MouseEvent) => {
Expand Down Expand Up @@ -488,6 +500,18 @@ export function McapRecordingDemo(): JSX.Element {
/>
Camera (JPEG)
</label>
{mp4aSupport === true && (
<label>
<input
type="checkbox"
checked={recordMP4A}
onChange={(event) => {
setRecordMP4A(event.target.checked);
}}
/>
Microphone (mp4a.40.2)
</label>
)}
{opusSupport === true && (
<label>
<input
Expand Down
12 changes: 12 additions & 0 deletions website/src/components/McapRecordingDemo/Recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export class Recorder extends EventEmitter<RecorderEvents> {
#vp9ChannelSeq = 0;
#av1Channel?: ProtobufChannelInfo;
#av1ChannelSeq = 0;
#mp4aChannel?: ProtobufChannelInfo;
#mp4aChannelSeq = 0;
#opusChannel?: ProtobufChannelInfo;
#opusChannelSeq = 0;

Expand Down Expand Up @@ -118,6 +120,8 @@ export class Recorder extends EventEmitter<RecorderEvents> {
this.#h265ChannelSeq = 0;
this.#av1Channel = undefined;
this.#av1ChannelSeq = 0;
this.#mp4aChannel = undefined;
this.#mp4aChannelSeq = 0;
this.#opusChannel = undefined;
this.#opusChannelSeq = 0;
}
Expand Down Expand Up @@ -293,6 +297,14 @@ export class Recorder extends EventEmitter<RecorderEvents> {
let channel: ProtobufChannelInfo;
let sequence: number;
switch (data.format) {
case "mp4a.40.2":
channel = this.#mp4aChannel ??= await addProtobufChannel(
this.#writer,
"microphone_mp4a",
foxgloveMessageSchemas.CompressedAudio,
);
sequence = this.#mp4aChannelSeq++;
break;
case "opus":
channel = this.#opusChannel ??= await addProtobufChannel(
this.#writer,
Expand Down
130 changes: 95 additions & 35 deletions website/src/components/McapRecordingDemo/audioCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,66 @@ export const supportsOpusEncoding = async (): Promise<boolean> => {
return support.supported === true;
};

type CompressedAudioFormat = "opus";
const DEFAULT_MP4A_CONFIG: AudioEncoderConfig = {
codec: "mp4a.40.2",
numberOfChannels: 1,
sampleRate: 48000,
};

/**
* Determine whether AudioEncoder can be used to encode audio with mp4a.40.2.
*/
export const supportsMP4AEncoding = async (): Promise<boolean> => {
if (!supportsMediaCaptureTransformAndWebCodecs()) {
return false;
}

const support = await AudioEncoder.isConfigSupported(DEFAULT_MP4A_CONFIG);
return support.supported === true;
};

const configureEncoder = ({
config,
framePool,
onAudioData,
onError,
}: {
config: AudioEncoderConfig;
framePool: ArrayBuffer[];
onAudioData: (data: CompressedAudioData) => void;
onError: (error: Error) => void;
}): AudioEncoder => {
const encoder = new AudioEncoder({
output: (chunk) => {
let buffer = framePool.pop();
if (!buffer || buffer.byteLength < chunk.byteLength) {
buffer = new ArrayBuffer(chunk.byteLength);
}
chunk.copyTo(buffer);
onAudioData({
format: config.codec as CompressedAudioFormat,
type: chunk.type as CompressedAudioType,
timestamp: chunk.timestamp,
data: new Uint8Array(buffer, 0, chunk.byteLength),
sampleRate: config.sampleRate,
numberOfChannels: config.numberOfChannels,
release() {
if (buffer) {
framePool.push(buffer);
}
},
});
},
error: (error) => {
onError(error);
},
});
encoder.configure(config);

return encoder;
};

type CompressedAudioFormat = "opus" | "mp4a.40.2";
type CompressedAudioType = "key" | "delta";
export type CompressedAudioData = {
format: CompressedAudioFormat;
Expand All @@ -132,6 +191,7 @@ export type CompressedAudioData = {
};

interface AudioCaptureParams {
enableMP4A: boolean;
enableOpus: boolean;
/** MediaStream from startAudioStream */
stream: MediaStream;
Expand All @@ -141,12 +201,13 @@ interface AudioCaptureParams {
}

export function startAudioCapture({
enableMP4A,
enableOpus,
stream,
onAudioData,
onError,
}: AudioCaptureParams): (() => void) | undefined {
if (!enableOpus) {
if (!enableMP4A && !enableOpus) {
onError(new Error("Invariant: expected Opus encoding to be enabled"));
return undefined;
}
Expand All @@ -170,52 +231,49 @@ export function startAudioCapture({
track,
});

const settings = track.getSettings();
const framePool: ArrayBuffer[] = [];

const encoder = new AudioEncoder({
output: (chunk) => {
let buffer = framePool.pop();
if (!buffer || buffer.byteLength < chunk.byteLength) {
buffer = new ArrayBuffer(chunk.byteLength);
}
chunk.copyTo(buffer);
onAudioData({
format: "opus",
type: chunk.type as CompressedAudioType,
timestamp: chunk.timestamp,
data: new Uint8Array(buffer, 0, chunk.byteLength),
sampleRate: settings.sampleRate ?? 0,
numberOfChannels: settings.channelCount ?? 0,
release() {
if (buffer) {
framePool.push(buffer);
}
},
});
},
error: (error) => {
onError(error);
},
});
encoder.configure({
codec: "opus",
sampleRate: settings.sampleRate ?? 0,
numberOfChannels: settings.channelCount ?? 0,
});
const encoders = [
...(enableMP4A
? [
configureEncoder({
config: DEFAULT_MP4A_CONFIG,
framePool,
onAudioData,
onError,
}),
]
: []),
...(enableOpus
? [
configureEncoder({
config: DEFAULT_OPUS_CONFIG,
framePool,
onAudioData,
onError,
}),
]
: []),
];

const reader = trackProcessor.readable.getReader();
let canceled = false;

const readAndEncode = () => {
if (canceled) {
return;
}

reader
.read()
.then((result) => {
if (result.done || canceled) {
return;
}

encoder.encode(result.value);
for (const encoder of encoders) {
encoder.encode(result.value);
}

readAndEncode();
})
Expand All @@ -228,6 +286,8 @@ export function startAudioCapture({

return () => {
canceled = true;
encoder.close();
for (const encoder of encoders) {
encoder.close();
}
};
}

0 comments on commit 107767e

Please sign in to comment.