mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-02 05:13:50 +02:00
Account for multi-frame Opus packets when computing packet duration (#439)
* Account for multi-frame Opus packets when computing packet duration parseOpusTocByte only read the config field of the TOC byte and always assumed a single frame per packet. Per RFC 6716 section 3.2, a packet may carry 1, 2 or an arbitrary number of frames, encoded in the two low bits of the TOC byte (plus the frame count byte for code 3), and its duration is the frame duration times the frame count. As a result, the Ogg muxer wrote granule positions that advanced slower than the actual audio. Chromium's MediaRecorder packs three 20 ms frames per Opus packet, so remuxing WebM/Opus to Ogg/Opus produced files declaring a third of their real duration: a 5.7 s recording ended with a final granule position of 99840 (2.08 s). Decoders that trust the container then truncate the audio. * Clean up --------- Co-authored-by: Vanilagy <[email protected]>
This commit is contained in:
co-authored by
Vanilagy
parent
1183b542ee
commit
bfbd90e1d8
+14
-1
@@ -2207,9 +2207,22 @@ const OPUS_FRAME_DURATION_TABLE = [
|
|||||||
|
|
||||||
export const parseOpusTocByte = (packet: Uint8Array) => {
|
export const parseOpusTocByte = (packet: Uint8Array) => {
|
||||||
const config = packet[0]! >> 3;
|
const config = packet[0]! >> 3;
|
||||||
|
const code = packet[0]! & 0b11;
|
||||||
|
|
||||||
|
// A packet may pack more than one frame, in which case its duration is the frame duration times the number of
|
||||||
|
// frames it carries. See https://datatracker.ietf.org/doc/html/rfc6716, section 3.2.
|
||||||
|
let frameCount: number;
|
||||||
|
if (code === 0) {
|
||||||
|
frameCount = 1;
|
||||||
|
} else if (code === 1 || code === 2) {
|
||||||
|
frameCount = 2;
|
||||||
|
} else {
|
||||||
|
// Code 3: the frame count sits in the six low bits of the frame count byte
|
||||||
|
frameCount = packet[1]! & 0b111111;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
durationInSamples: OPUS_FRAME_DURATION_TABLE[config]!,
|
durationInSamples: OPUS_FRAME_DURATION_TABLE[config]! * frameCount,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { expect, test } from 'vitest';
|
import { expect, test } from 'vitest';
|
||||||
import { Output } from '../../src/output.js';
|
import { Output } from '../../src/output.js';
|
||||||
import { OggOutputFormat } from '../../src/output-format.js';
|
import { OggOutputFormat } from '../../src/output-format.js';
|
||||||
import { NullTarget } from '../../src/target.js';
|
import { BufferTarget, NullTarget } from '../../src/target.js';
|
||||||
import { AudioBufferSource } from '../../src/media-source.js';
|
import { AudioBufferSource, EncodedAudioPacketSource } from '../../src/media-source.js';
|
||||||
|
import { EncodedPacket } from '../../src/packet.js';
|
||||||
|
import { assert } from '../../src/misc.js';
|
||||||
|
import { Input } from '../../src/input.js';
|
||||||
|
import { BufferSource } from '../../src/source.js';
|
||||||
|
import { ALL_FORMATS, OggInputFormat } from '../../src/input-format.js';
|
||||||
|
import { EncodedPacketSink } from '../../src/media-sink.js';
|
||||||
|
|
||||||
test('maximumPageDuration option', async () => {
|
test('maximumPageDuration option', async () => {
|
||||||
const sampleRate = 48000;
|
const sampleRate = 48000;
|
||||||
@@ -55,3 +61,82 @@ test('maximumPageDuration option', async () => {
|
|||||||
expect(pageCountWithoutOption).toBe(3);
|
expect(pageCountWithoutOption).toBe(3);
|
||||||
expect(pageCountWithOption).toBe(23); // It created more pages
|
expect(pageCountWithOption).toBe(23); // It created more pages
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Multi-frame Opus packets', async () => {
|
||||||
|
const SAMPLE_RATE = 48000;
|
||||||
|
const SAMPLES_PER_FRAME = 960; // 20 ms at 48 kHz
|
||||||
|
|
||||||
|
const createOpusHead = () => {
|
||||||
|
const bytes = new Uint8Array(19);
|
||||||
|
const view = new DataView(bytes.buffer);
|
||||||
|
|
||||||
|
bytes.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // 'OpusHead'
|
||||||
|
bytes[8] = 1; // Version
|
||||||
|
bytes[9] = 1; // Channel count
|
||||||
|
view.setUint16(10, 312, true); // Pre-skip
|
||||||
|
view.setUint32(12, SAMPLE_RATE, true); // Input sample rate
|
||||||
|
view.setInt16(16, 0, true); // Output gain
|
||||||
|
bytes[18] = 0; // Channel mapping family
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOpusPacket = (frameCount: number) => {
|
||||||
|
const data = new Uint8Array(2 + 3 * frameCount);
|
||||||
|
|
||||||
|
data[0] = (31 << 3) | 0b11; // TOC byte: config 31 (CELT fullband, 20 ms), code 3
|
||||||
|
data[1] = frameCount; // CBR, no padding, `frameCount` frames
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const framesPerPacket = 3;
|
||||||
|
const packetCount = 10;
|
||||||
|
const packetDuration = (framesPerPacket * SAMPLES_PER_FRAME) / SAMPLE_RATE;
|
||||||
|
|
||||||
|
const output = new Output({
|
||||||
|
format: new OggOutputFormat(),
|
||||||
|
target: new BufferTarget(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioSource = new EncodedAudioPacketSource('opus');
|
||||||
|
output.addAudioTrack(audioSource);
|
||||||
|
|
||||||
|
await output.start();
|
||||||
|
|
||||||
|
for (let i = 0; i < packetCount; i++) {
|
||||||
|
await audioSource.add(
|
||||||
|
new EncodedPacket(
|
||||||
|
createOpusPacket(framesPerPacket),
|
||||||
|
'key',
|
||||||
|
i * packetDuration,
|
||||||
|
packetDuration,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
decoderConfig: {
|
||||||
|
codec: 'opus',
|
||||||
|
numberOfChannels: 1,
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
description: createOpusHead(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
audioSource.close();
|
||||||
|
await output.finalize();
|
||||||
|
|
||||||
|
assert(output.target.buffer);
|
||||||
|
|
||||||
|
const input = new Input({
|
||||||
|
source: new BufferSource(output.target.buffer),
|
||||||
|
formats: ALL_FORMATS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await input.getFormat()).toBeInstanceOf(OggInputFormat);
|
||||||
|
|
||||||
|
const sink = new EncodedPacketSink((await input.getPrimaryAudioTrack())!);
|
||||||
|
const packet = await sink.getFirstPacket();
|
||||||
|
|
||||||
|
expect(packet?.duration).toBe(packetDuration);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user