diff --git a/src/codec-data.ts b/src/codec-data.ts index 83a040d..8319af3 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -2207,9 +2207,22 @@ const OPUS_FRAME_DURATION_TABLE = [ export const parseOpusTocByte = (packet: Uint8Array) => { 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 { - durationInSamples: OPUS_FRAME_DURATION_TABLE[config]!, + durationInSamples: OPUS_FRAME_DURATION_TABLE[config]! * frameCount, }; }; diff --git a/test/browser/ogg-muxer.test.ts b/test/browser/ogg-muxer.test.ts index ea982e1..215dc7d 100644 --- a/test/browser/ogg-muxer.test.ts +++ b/test/browser/ogg-muxer.test.ts @@ -1,8 +1,14 @@ import { expect, test } from 'vitest'; import { Output } from '../../src/output.js'; import { OggOutputFormat } from '../../src/output-format.js'; -import { NullTarget } from '../../src/target.js'; -import { AudioBufferSource } from '../../src/media-source.js'; +import { BufferTarget, NullTarget } from '../../src/target.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 () => { const sampleRate = 48000; @@ -55,3 +61,82 @@ test('maximumPageDuration option', async () => { expect(pageCountWithoutOption).toBe(3); 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); +});