mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
Implement Ogg demuxer
This commit is contained in:
@@ -967,6 +967,27 @@ export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// From https://datatracker.ietf.org/doc/html/rfc6716, in 48 kHz samples
|
||||||
|
const OPUS_FRAME_DURATION_TABLE = [
|
||||||
|
480, 960, 1920, 2880,
|
||||||
|
480, 960, 1920, 2880,
|
||||||
|
480, 960, 1920, 2880,
|
||||||
|
480, 960,
|
||||||
|
480, 960,
|
||||||
|
120, 240, 480, 960,
|
||||||
|
120, 240, 480, 960,
|
||||||
|
120, 240, 480, 960,
|
||||||
|
120, 240, 480, 960,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const parseOpusTocByte = (packet: Uint8Array) => {
|
||||||
|
const config = packet[0]! >> 3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationInSamples: OPUS_FRAME_DURATION_TABLE[config]!,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
|
const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
|
||||||
export const parsePcmCodec = (codec: PcmAudioCodec) => {
|
export const parsePcmCodec = (codec: PcmAudioCodec) => {
|
||||||
assert(PCM_AUDIO_CODECS.includes(codec));
|
assert(PCM_AUDIO_CODECS.includes(codec));
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export {
|
|||||||
WebMInputFormat,
|
WebMInputFormat,
|
||||||
Mp3InputFormat,
|
Mp3InputFormat,
|
||||||
WaveInputFormat,
|
WaveInputFormat,
|
||||||
|
OggInputFormat,
|
||||||
ALL_FORMATS,
|
ALL_FORMATS,
|
||||||
MP4,
|
MP4,
|
||||||
QTFF,
|
QTFF,
|
||||||
@@ -84,6 +85,7 @@ export {
|
|||||||
WEBM,
|
WEBM,
|
||||||
MP3,
|
MP3,
|
||||||
WAVE,
|
WAVE,
|
||||||
|
OGG,
|
||||||
} from './input-format';
|
} from './input-format';
|
||||||
export { Input, InputOptions } from './input';
|
export { Input, InputOptions } from './input';
|
||||||
export { InputTrack, InputVideoTrack, InputAudioTrack, SampleStats } from './input-track';
|
export { InputTrack, InputVideoTrack, InputAudioTrack, SampleStats } from './input-track';
|
||||||
|
|||||||
+57
-2
@@ -6,6 +6,8 @@ import { EBMLId, EBMLReader } from './matroska/ebml';
|
|||||||
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
|
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
|
||||||
import { Mp3Demuxer } from './mp3/mp3-demuxer';
|
import { Mp3Demuxer } from './mp3/mp3-demuxer';
|
||||||
import { Mp3Reader } from './mp3/mp3-reader';
|
import { Mp3Reader } from './mp3/mp3-reader';
|
||||||
|
import { OggDemuxer } from './ogg/ogg-demuxer';
|
||||||
|
import { OggReader } from './ogg/ogg-reader';
|
||||||
import { RiffReader } from './wave/riff-reader';
|
import { RiffReader } from './wave/riff-reader';
|
||||||
import { WaveDemuxer } from './wave/wave-demuxer';
|
import { WaveDemuxer } from './wave/wave-demuxer';
|
||||||
|
|
||||||
@@ -196,9 +198,34 @@ export class Mp3InputFormat extends InputFormat {
|
|||||||
mp3Reader.pos += id3Tag.size;
|
mp3Reader.pos += id3Tag.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const framesStartPos = mp3Reader.pos;
|
||||||
await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + 4096);
|
await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + 4096);
|
||||||
|
|
||||||
return mp3Reader.readNextFrameHeader(mp3Reader.pos + 4096) !== null;
|
const firstHeader = mp3Reader.readNextFrameHeader(framesStartPos + 4096);
|
||||||
|
if (!firstHeader) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id3Tag) {
|
||||||
|
// If there was an ID3 tag at the start, we can be pretty sure this is MP3 by now
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
|
||||||
|
// another header nearby:
|
||||||
|
mp3Reader.pos = firstHeader.startPos + firstHeader.totalSize;
|
||||||
|
const secondHeader = mp3Reader.readNextFrameHeader(framesStartPos + 4096);
|
||||||
|
if (!secondHeader) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a well-formed MP3 file, we'd expect these two frames to share some similarities:
|
||||||
|
if (firstHeader.channel !== secondHeader.channel || firstHeader.sampleRate !== secondHeader.sampleRate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have found two matching MP3 frames, a strong indicator that this is an MP3 file
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
@@ -248,6 +275,32 @@ export class WaveInputFormat extends InputFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export class OggInputFormat extends InputFormat {
|
||||||
|
async _canReadInput(input: Input) {
|
||||||
|
const sourceSize = await input._mainReader.source._getSize();
|
||||||
|
if (sourceSize < 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oggReader = new OggReader(input._mainReader);
|
||||||
|
return oggReader.readAscii(4) === 'OggS';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_createDemuxer(input: Input) {
|
||||||
|
return new OggDemuxer(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
getName() {
|
||||||
|
return 'Ogg';
|
||||||
|
}
|
||||||
|
|
||||||
|
getMimeType() {
|
||||||
|
return 'application/ogg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export const MP4 = new Mp4InputFormat();
|
export const MP4 = new Mp4InputFormat();
|
||||||
/** @public */
|
/** @public */
|
||||||
@@ -260,6 +313,8 @@ export const WEBM = new WebMInputFormat();
|
|||||||
export const MP3 = new Mp3InputFormat();
|
export const MP3 = new Mp3InputFormat();
|
||||||
/** @public */
|
/** @public */
|
||||||
export const WAVE = new WaveInputFormat();
|
export const WAVE = new WaveInputFormat();
|
||||||
|
/** @public */
|
||||||
|
export const OGG = new OggInputFormat();
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, MP3, WAVE];
|
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, MP3];
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
UNDETERMINED_LANGUAGE,
|
UNDETERMINED_LANGUAGE,
|
||||||
TransformationMatrix,
|
TransformationMatrix,
|
||||||
extractRotationFromMatrix,
|
extractRotationFromMatrix,
|
||||||
|
roundToPrecision,
|
||||||
} from '../misc';
|
} from '../misc';
|
||||||
import { Reader } from '../reader';
|
import { Reader } from '../reader';
|
||||||
import { EncodedAudioSample, EncodedVideoSample, PLACEHOLDER_DATA, SampleType } from '../sample';
|
import { EncodedAudioSample, EncodedVideoSample, PLACEHOLDER_DATA, SampleType } from '../sample';
|
||||||
@@ -1792,16 +1793,10 @@ abstract class IsobmffTrackBacking<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private intoTimescale(timestamp: number) {
|
private intoTimescale(timestamp: number) {
|
||||||
const result = timestamp * this.internalTrack.timescale;
|
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
|
||||||
const rounded = Math.round(result);
|
// that the number was originally an integer divided by the timescale. For stability, it's best
|
||||||
|
// to return the integer in this case.
|
||||||
if (Math.abs(1 - (result / rounded)) < 10 * Number.EPSILON) {
|
return roundToPrecision(timestamp * this.internalTrack.timescale, 14);
|
||||||
// The result is very close to an integer, meaning the number likely originated by an integer being divided
|
|
||||||
// by the timescale. For stability, it's best to return the integer in this case.
|
|
||||||
return rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSample(timestamp: number, options: SampleRetrievalOptions) {
|
async getSample(timestamp: number, options: SampleRetrievalOptions) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
last,
|
last,
|
||||||
MATRIX_COEFFICIENTS_MAP_INVERSE,
|
MATRIX_COEFFICIENTS_MAP_INVERSE,
|
||||||
Rotation,
|
Rotation,
|
||||||
|
roundToPrecision,
|
||||||
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
||||||
UNDETERMINED_LANGUAGE,
|
UNDETERMINED_LANGUAGE,
|
||||||
} from '../misc';
|
} from '../misc';
|
||||||
@@ -1043,16 +1044,10 @@ abstract class MatroskaTrackBacking<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private intoTimescale(timestamp: number) {
|
private intoTimescale(timestamp: number) {
|
||||||
const result = timestamp * this.internalTrack.segment.timestampFactor;
|
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
|
||||||
const rounded = Math.round(result);
|
// that the number was originally an integer divided by the timescale. For stability, it's best
|
||||||
|
// to return the integer in this case.
|
||||||
if (Math.abs(1 - (result / rounded)) < 10 * Number.EPSILON) {
|
return roundToPrecision(timestamp * this.internalTrack.segment.timestampFactor, 14);
|
||||||
// The result is very close to an integer, meaning the number likely originated by an integer being divided
|
|
||||||
// by the timestamp factor. For stability, it's best to return the integer in this case.
|
|
||||||
return rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSample(timestamp: number, options: SampleRetrievalOptions) {
|
async getSample(timestamp: number, options: SampleRetrievalOptions) {
|
||||||
|
|||||||
+24
@@ -237,6 +237,16 @@ export const removeItem = <T>(arr: T[], item: T) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const findLast = <T>(arr: T[], predicate: (x: T) => boolean) => {
|
||||||
|
for (let i = arr.length - 1; i >= 0; i--) {
|
||||||
|
if (predicate(arr[i]!)) {
|
||||||
|
return arr[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export const findLastIndex = <T>(arr: T[], predicate: (x: T) => boolean) => {
|
export const findLastIndex = <T>(arr: T[], predicate: (x: T) => boolean) => {
|
||||||
for (let i = arr.length - 1; i >= 0; i--) {
|
for (let i = arr.length - 1; i >= 0; i--) {
|
||||||
if (predicate(arr[i]!)) {
|
if (predicate(arr[i]!)) {
|
||||||
@@ -367,3 +377,17 @@ export const setVideoFrameTiming = (frame: VideoFrame, timing: {
|
|||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const roundToPrecision = (value: number, digits: number) => {
|
||||||
|
const factor = 10 ** digits;
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ilog = (x: number) => {
|
||||||
|
let ret = 0;
|
||||||
|
while (x) {
|
||||||
|
ret++;
|
||||||
|
x >>= 1;
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,986 @@
|
|||||||
|
import { AudioCodec, parseOpusTocByte } from '../codec';
|
||||||
|
import { Demuxer } from '../demuxer';
|
||||||
|
import { Input } from '../input';
|
||||||
|
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
||||||
|
import { SampleRetrievalOptions } from '../media-sink';
|
||||||
|
import { assert, findLast, ilog, roundToPrecision, toDataView, UNDETERMINED_LANGUAGE } from '../misc';
|
||||||
|
import { Reader } from '../reader';
|
||||||
|
import { EncodedAudioSample, PLACEHOLDER_DATA } from '../sample';
|
||||||
|
import { computeOggPageCrc } from './ogg-misc';
|
||||||
|
import { MAX_PAGE_HEADER_SIZE, MAX_PAGE_SIZE, MIN_PAGE_HEADER_SIZE, OggReader, Page } from './ogg-reader';
|
||||||
|
import { parseModesFromSetupPacket } from './vorbis';
|
||||||
|
|
||||||
|
type LogicalBitstream = {
|
||||||
|
serialNumber: number;
|
||||||
|
bosPage: Page;
|
||||||
|
codec: AudioCodec | null;
|
||||||
|
description: Uint8Array | null;
|
||||||
|
numberOfChannels: number;
|
||||||
|
sampleRate: number;
|
||||||
|
vorbisInfo: {
|
||||||
|
blocksizes: number[];
|
||||||
|
modeBlockflags: number[];
|
||||||
|
} | null;
|
||||||
|
opusInfo: {
|
||||||
|
preSkip: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
lastMetadataPacket: Packet | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Packet = {
|
||||||
|
data: Uint8Array;
|
||||||
|
endPage: Page;
|
||||||
|
endSegmentIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OggDemuxer extends Demuxer {
|
||||||
|
reader: OggReader;
|
||||||
|
|
||||||
|
metadataPromise: Promise<void> | null = null;
|
||||||
|
fileSize: number | null = null;
|
||||||
|
bitstreams: LogicalBitstream[] = [];
|
||||||
|
tracks: InputAudioTrack[] = [];
|
||||||
|
|
||||||
|
constructor(input: Input) {
|
||||||
|
super(input);
|
||||||
|
|
||||||
|
// We don't need a persistent metadata reader as we read all metadata once at the start and then never again
|
||||||
|
this.reader = new OggReader(new Reader(input._source, 64 * 2 ** 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
async readMetadata() {
|
||||||
|
return this.metadataPromise ??= (async () => {
|
||||||
|
this.fileSize = await this.input._source._getSize();
|
||||||
|
|
||||||
|
while (this.reader.pos < this.fileSize - MIN_PAGE_HEADER_SIZE) {
|
||||||
|
await this.reader.reader.loadRange(
|
||||||
|
this.reader.pos,
|
||||||
|
this.reader.pos + MAX_PAGE_HEADER_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = this.reader.readPageHeader();
|
||||||
|
if (!page) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBos = !!(page.headerType & 0x02);
|
||||||
|
if (!isBos) {
|
||||||
|
// All bos pages for all bitstreams are required to be at the start, so if the page is not bos then
|
||||||
|
// we know we've seen all bitstreams (minus chaining)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bitstreams.push({
|
||||||
|
serialNumber: page.serialNumber,
|
||||||
|
bosPage: page,
|
||||||
|
codec: null,
|
||||||
|
description: null,
|
||||||
|
numberOfChannels: -1,
|
||||||
|
sampleRate: -1,
|
||||||
|
vorbisInfo: null,
|
||||||
|
opusInfo: null,
|
||||||
|
lastMetadataPacket: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.reader.pos = page.headerStartPos + page.totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bitstream of this.bitstreams) {
|
||||||
|
const firstPacket = await this.readPacket(this.reader, bitstream.bosPage, 0);
|
||||||
|
if (!firstPacket) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
// Check for Vorbis
|
||||||
|
firstPacket.data.byteLength >= 7
|
||||||
|
&& firstPacket.data[0] === 0x01 // Packet type 1 = identification header
|
||||||
|
&& firstPacket.data[1] === 0x76 // 'v'
|
||||||
|
&& firstPacket.data[2] === 0x6f // 'o'
|
||||||
|
&& firstPacket.data[3] === 0x72 // 'r'
|
||||||
|
&& firstPacket.data[4] === 0x62 // 'b'
|
||||||
|
&& firstPacket.data[5] === 0x69 // 'i'
|
||||||
|
&& firstPacket.data[6] === 0x73 // 's'
|
||||||
|
) {
|
||||||
|
await this.readVorbisMetadata(firstPacket, bitstream);
|
||||||
|
} else if (
|
||||||
|
// Check for Opus
|
||||||
|
firstPacket.data.byteLength >= 8
|
||||||
|
&& firstPacket.data[0] === 0x4f // 'O'
|
||||||
|
&& firstPacket.data[1] === 0x70 // 'p'
|
||||||
|
&& firstPacket.data[2] === 0x75 // 'u'
|
||||||
|
&& firstPacket.data[3] === 0x73 // 's'
|
||||||
|
&& firstPacket.data[4] === 0x48 // 'H'
|
||||||
|
&& firstPacket.data[5] === 0x65 // 'e'
|
||||||
|
&& firstPacket.data[6] === 0x61 // 'a'
|
||||||
|
&& firstPacket.data[7] === 0x64 // 'd'
|
||||||
|
) {
|
||||||
|
await this.readOpusMetadata(firstPacket, bitstream);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitstream.codec !== null) {
|
||||||
|
this.tracks.push(new InputAudioTrack(new OggAudioTrackBacking(bitstream, this)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
async readVorbisMetadata(firstPacket: Packet, bitstream: LogicalBitstream) {
|
||||||
|
let nextPacketPosition = await this.findNextPacketStart(this.reader, firstPacket);
|
||||||
|
if (!nextPacketPosition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondPacket = await this.readPacket(
|
||||||
|
this.reader,
|
||||||
|
nextPacketPosition.startPage,
|
||||||
|
nextPacketPosition.startSegmentIndex,
|
||||||
|
);
|
||||||
|
if (!secondPacket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPacketPosition = await this.findNextPacketStart(this.reader, secondPacket);
|
||||||
|
if (!nextPacketPosition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thirdPacket = await this.readPacket(
|
||||||
|
this.reader,
|
||||||
|
nextPacketPosition.startPage,
|
||||||
|
nextPacketPosition.startSegmentIndex,
|
||||||
|
);
|
||||||
|
if (!thirdPacket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondPacket.data[0] !== 0x03 || thirdPacket.data[0] !== 0x05) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lacingValues: number[] = [];
|
||||||
|
const addBytesToSegmentTable = (bytes: number) => {
|
||||||
|
while (true) {
|
||||||
|
lacingValues.push(Math.min(255, bytes));
|
||||||
|
|
||||||
|
if (bytes < 255) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes -= 255;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addBytesToSegmentTable(firstPacket.data.length);
|
||||||
|
addBytesToSegmentTable(secondPacket.data.length);
|
||||||
|
// We don't add the last packet to the segment table, as it is assumed to be whatever bytes remain
|
||||||
|
|
||||||
|
const description = new Uint8Array(
|
||||||
|
1 + lacingValues.length
|
||||||
|
+ firstPacket.data.length + secondPacket.data.length + thirdPacket.data.length,
|
||||||
|
);
|
||||||
|
description[0] = lacingValues.length;
|
||||||
|
description.set(
|
||||||
|
lacingValues, 1,
|
||||||
|
);
|
||||||
|
description.set(
|
||||||
|
firstPacket.data, 1 + lacingValues.length,
|
||||||
|
);
|
||||||
|
description.set(
|
||||||
|
secondPacket.data, 1 + lacingValues.length + firstPacket.data.length,
|
||||||
|
);
|
||||||
|
description.set(
|
||||||
|
thirdPacket.data, 1 + lacingValues.length + firstPacket.data.length + secondPacket.data.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
bitstream.codec = 'vorbis';
|
||||||
|
bitstream.description = description;
|
||||||
|
bitstream.lastMetadataPacket = thirdPacket;
|
||||||
|
|
||||||
|
const view = toDataView(firstPacket.data);
|
||||||
|
bitstream.numberOfChannels = view.getUint8(11);
|
||||||
|
bitstream.sampleRate = view.getUint32(12, true);
|
||||||
|
|
||||||
|
const blockSizeByte = view.getUint8(28);
|
||||||
|
bitstream.vorbisInfo = {
|
||||||
|
blocksizes: [
|
||||||
|
1 << (blockSizeByte & 0xf),
|
||||||
|
1 << (blockSizeByte >> 4),
|
||||||
|
],
|
||||||
|
modeBlockflags: parseModesFromSetupPacket(thirdPacket.data).modeBlockflags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async readOpusMetadata(firstPacket: Packet, bitstream: LogicalBitstream) {
|
||||||
|
// From https://datatracker.ietf.org/doc/html/rfc7845#section-5:
|
||||||
|
// "An Ogg Opus logical stream contains exactly two mandatory header packets: an identification header and a
|
||||||
|
// comment header."
|
||||||
|
const nextPacketPosition = await this.findNextPacketStart(this.reader, firstPacket);
|
||||||
|
if (!nextPacketPosition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondPacket = await this.readPacket(
|
||||||
|
this.reader,
|
||||||
|
nextPacketPosition.startPage,
|
||||||
|
nextPacketPosition.startSegmentIndex,
|
||||||
|
);
|
||||||
|
if (!secondPacket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't make use of the comment header's data
|
||||||
|
|
||||||
|
bitstream.codec = 'opus';
|
||||||
|
bitstream.description = firstPacket.data;
|
||||||
|
bitstream.lastMetadataPacket = secondPacket;
|
||||||
|
|
||||||
|
const view = toDataView(firstPacket.data);
|
||||||
|
bitstream.numberOfChannels = view.getUint8(9);
|
||||||
|
bitstream.sampleRate = view.getUint32(12, true);
|
||||||
|
|
||||||
|
bitstream.opusInfo = {
|
||||||
|
preSkip: view.getUint16(10, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPacket(reader: OggReader, startPage: Page, startSegmentIndex: number): Promise<Packet | null> {
|
||||||
|
assert(startSegmentIndex < startPage.lacingValues.length);
|
||||||
|
assert(this.fileSize);
|
||||||
|
|
||||||
|
let startDataOffset = 0;
|
||||||
|
for (let i = 0; i < startSegmentIndex; i++) {
|
||||||
|
startDataOffset += startPage.lacingValues[i]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage: Page = startPage;
|
||||||
|
let currentDataOffset = startDataOffset;
|
||||||
|
let currentSegmentIndex = startSegmentIndex;
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
outer:
|
||||||
|
while (true) {
|
||||||
|
// Load the entire page data
|
||||||
|
await reader.reader.loadRange(
|
||||||
|
currentPage.dataStartPos,
|
||||||
|
currentPage.dataStartPos + currentPage.dataSize,
|
||||||
|
);
|
||||||
|
reader.pos = currentPage.dataStartPos;
|
||||||
|
const pageData = reader.readBytes(currentPage.dataSize);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (currentSegmentIndex === currentPage.lacingValues.length) {
|
||||||
|
chunks.push(pageData.subarray(startDataOffset, currentDataOffset));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lacingValue = currentPage.lacingValues[currentSegmentIndex]!;
|
||||||
|
currentDataOffset += lacingValue;
|
||||||
|
|
||||||
|
if (lacingValue < 255) {
|
||||||
|
chunks.push(pageData.subarray(startDataOffset, currentDataOffset));
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSegmentIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The packet extends to the next page; let's find it
|
||||||
|
while (true) {
|
||||||
|
reader.pos = currentPage.headerStartPos + currentPage.totalSize;
|
||||||
|
if (reader.pos >= this.fileSize - MIN_PAGE_HEADER_SIZE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
|
||||||
|
const nextPage = reader.readPageHeader();
|
||||||
|
if (!nextPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage = nextPage;
|
||||||
|
if (currentPage.serialNumber === startPage.serialNumber) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startDataOffset = 0;
|
||||||
|
currentDataOffset = 0;
|
||||||
|
currentSegmentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPacketSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const packetData = new Uint8Array(totalPacketSize);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
const chunk = chunks[i]!;
|
||||||
|
packetData.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: packetData,
|
||||||
|
endPage: currentPage,
|
||||||
|
endSegmentIndex: currentSegmentIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findNextPacketStart(reader: OggReader, lastPacket: Packet) {
|
||||||
|
assert(this.fileSize !== null);
|
||||||
|
|
||||||
|
// If there's another segment in the same page, return it
|
||||||
|
if (lastPacket.endSegmentIndex < lastPacket.endPage.lacingValues.length - 1) {
|
||||||
|
return { startPage: lastPacket.endPage, startSegmentIndex: lastPacket.endSegmentIndex + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEos = !!(lastPacket.endPage.headerType & 0x04);
|
||||||
|
if (isEos) {
|
||||||
|
// The page is marked as the last page of the logical bitstream, so we won't find anything beyond it
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, search for the next page belonging to the same bitstream
|
||||||
|
reader.pos = lastPacket.endPage.headerStartPos + lastPacket.endPage.totalSize;
|
||||||
|
while (true) {
|
||||||
|
if (reader.pos >= this.fileSize - MIN_PAGE_HEADER_SIZE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
|
||||||
|
const nextPage = reader.readPageHeader();
|
||||||
|
if (!nextPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPage.serialNumber === lastPacket.endPage.serialNumber) {
|
||||||
|
return { startPage: nextPage, startSegmentIndex: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.pos = nextPage.headerStartPos + nextPage.totalSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMimeType() {
|
||||||
|
await this.readMetadata();
|
||||||
|
|
||||||
|
let string = 'audio/ogg';
|
||||||
|
|
||||||
|
if (this.tracks.length > 0) {
|
||||||
|
const codecMimeTypes = await Promise.all(this.tracks.map(x => x.getCodecMimeType()));
|
||||||
|
const uniqueCodecMimeTypes = [...new Set(codecMimeTypes.filter(Boolean))];
|
||||||
|
|
||||||
|
string += `; codecs="${uniqueCodecMimeTypes.join(', ')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTracks() {
|
||||||
|
await this.readMetadata();
|
||||||
|
return this.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async computeDuration() {
|
||||||
|
const tracks = await this.getTracks();
|
||||||
|
const trackDurations = await Promise.all(tracks.map(x => x.computeDuration()));
|
||||||
|
return Math.max(0, ...trackDurations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SampleMetadata = {
|
||||||
|
packet: Packet;
|
||||||
|
timestampInSamples: number;
|
||||||
|
durationInSamples: number;
|
||||||
|
vorbisBlockSize: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class OggAudioTrackBacking implements InputAudioTrackBacking {
|
||||||
|
internalSampleRate: number;
|
||||||
|
sampleToMetadata = new WeakMap<EncodedAudioSample, SampleMetadata>();
|
||||||
|
|
||||||
|
constructor(public bitstream: LogicalBitstream, public demuxer: OggDemuxer) {
|
||||||
|
// Opus always uses 48000 for its internal calculations, even if the actual sample rate is different
|
||||||
|
this.internalSampleRate = bitstream.codec === 'opus' ? 48000 : bitstream.sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
return this.bitstream.serialNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNumberOfChannels() {
|
||||||
|
return this.bitstream.numberOfChannels;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSampleRate() {
|
||||||
|
return this.bitstream.sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCodec() {
|
||||||
|
return this.bitstream.codec;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDecoderConfig(): Promise<AudioDecoderConfig | null> {
|
||||||
|
assert(this.bitstream.codec);
|
||||||
|
|
||||||
|
return {
|
||||||
|
codec: this.bitstream.codec,
|
||||||
|
numberOfChannels: this.bitstream.numberOfChannels,
|
||||||
|
sampleRate: this.bitstream.sampleRate,
|
||||||
|
description: this.bitstream.description ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLanguageCode() {
|
||||||
|
return UNDETERMINED_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFirstTimestamp() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async computeDuration() {
|
||||||
|
const lastSample = await this.getSample(Infinity, { metadataOnly: true });
|
||||||
|
return (lastSample?.timestamp ?? 0) + (lastSample?.duration ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
granulePositionToTimestampInSamples(granulePosition: number) {
|
||||||
|
if (this.bitstream.codec === 'opus') {
|
||||||
|
assert(this.bitstream.opusInfo);
|
||||||
|
return granulePosition - this.bitstream.opusInfo.preSkip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return granulePosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSampleFromPacket(
|
||||||
|
packet: Packet | null,
|
||||||
|
additional: {
|
||||||
|
timestampInSamples: number;
|
||||||
|
vorbisLastBlocksize: number | null;
|
||||||
|
},
|
||||||
|
options: SampleRetrievalOptions,
|
||||||
|
) {
|
||||||
|
if (!packet) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let durationInSamples = 0;
|
||||||
|
let currentBlocksize: number | null = null;
|
||||||
|
|
||||||
|
if (packet.data.length > 0) {
|
||||||
|
// To know sample duration, we'll need to peak inside the packet
|
||||||
|
|
||||||
|
if (this.bitstream.codec === 'vorbis') {
|
||||||
|
assert(this.bitstream.vorbisInfo);
|
||||||
|
|
||||||
|
const vorbisModeCount = this.bitstream.vorbisInfo.modeBlockflags.length;
|
||||||
|
const bitCount = ilog(vorbisModeCount - 1);
|
||||||
|
const modeMask = ((1 << bitCount) - 1) << 1;
|
||||||
|
const modeNumber = (packet.data[0]! & modeMask) >> 1;
|
||||||
|
|
||||||
|
if (modeNumber >= this.bitstream.vorbisInfo.modeBlockflags.length) {
|
||||||
|
throw new Error('Invalid mode number.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In Vorbis, packet duration also depends on the blocksize of the previous packet
|
||||||
|
let prevBlocksize = additional.vorbisLastBlocksize;
|
||||||
|
|
||||||
|
const blockflag = this.bitstream.vorbisInfo.modeBlockflags[modeNumber]!;
|
||||||
|
currentBlocksize = this.bitstream.vorbisInfo.blocksizes[blockflag]!;
|
||||||
|
|
||||||
|
if (blockflag === 1) {
|
||||||
|
const prevMask = (modeMask | 0x1) + 1;
|
||||||
|
const flag = packet.data[0]! & prevMask ? 1 : 0;
|
||||||
|
prevBlocksize = this.bitstream.vorbisInfo.blocksizes[flag]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
durationInSamples = prevBlocksize !== null
|
||||||
|
? (prevBlocksize + currentBlocksize) >> 2
|
||||||
|
: 0; // The first sample outputs no audio data and therefore has a duration of 0
|
||||||
|
} else if (this.bitstream.codec === 'opus') {
|
||||||
|
assert(this.bitstream.opusInfo);
|
||||||
|
|
||||||
|
const toc = parseOpusTocByte(packet.data);
|
||||||
|
durationInSamples = toc.durationInSamples;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = new EncodedAudioSample(
|
||||||
|
options.metadataOnly ? PLACEHOLDER_DATA : packet.data,
|
||||||
|
'key',
|
||||||
|
Math.max(0, additional.timestampInSamples) / this.internalSampleRate,
|
||||||
|
durationInSamples / this.internalSampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sampleToMetadata.set(sample, {
|
||||||
|
packet,
|
||||||
|
timestampInSamples: additional.timestampInSamples,
|
||||||
|
durationInSamples,
|
||||||
|
vorbisBlockSize: currentBlocksize,
|
||||||
|
});
|
||||||
|
return sample;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFirstSample(options: SampleRetrievalOptions) {
|
||||||
|
assert(this.bitstream.lastMetadataPacket);
|
||||||
|
const packetPosition = await this.demuxer.findNextPacketStart(
|
||||||
|
this.demuxer.reader,
|
||||||
|
this.bitstream.lastMetadataPacket,
|
||||||
|
);
|
||||||
|
if (!packetPosition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestampInSamples = 0;
|
||||||
|
if (this.bitstream.codec === 'opus') {
|
||||||
|
assert(this.bitstream.opusInfo);
|
||||||
|
timestampInSamples -= this.bitstream.opusInfo.preSkip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packet = await this.demuxer.readPacket(
|
||||||
|
this.demuxer.reader,
|
||||||
|
packetPosition.startPage,
|
||||||
|
packetPosition.startSegmentIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.createSampleFromPacket(
|
||||||
|
packet,
|
||||||
|
{
|
||||||
|
timestampInSamples,
|
||||||
|
vorbisLastBlocksize: null,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNextSample(prevSample: EncodedAudioSample, options: SampleRetrievalOptions) {
|
||||||
|
const prevMetadata = this.sampleToMetadata.get(prevSample);
|
||||||
|
if (!prevMetadata) {
|
||||||
|
throw new Error('Sample was not created from this track.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const packetPosition = await this.demuxer.findNextPacketStart(this.demuxer.reader, prevMetadata.packet);
|
||||||
|
if (!packetPosition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampInSamples = prevMetadata.timestampInSamples + prevMetadata.durationInSamples;
|
||||||
|
|
||||||
|
const packet = await this.demuxer.readPacket(
|
||||||
|
this.demuxer.reader,
|
||||||
|
packetPosition.startPage,
|
||||||
|
packetPosition.startSegmentIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.createSampleFromPacket(
|
||||||
|
packet,
|
||||||
|
{
|
||||||
|
timestampInSamples,
|
||||||
|
vorbisLastBlocksize: prevMetadata.vorbisBlockSize,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSample(timestamp: number, options: SampleRetrievalOptions) {
|
||||||
|
assert(this.demuxer.fileSize !== null);
|
||||||
|
|
||||||
|
const timestampInSamples = roundToPrecision(timestamp * this.internalSampleRate, 14);
|
||||||
|
if (timestampInSamples === 0) {
|
||||||
|
// Fast path for timestamp 0 - avoids binary search when playing back from the start
|
||||||
|
return this.getFirstSample(options);
|
||||||
|
}
|
||||||
|
if (timestampInSamples < 0) {
|
||||||
|
// There's nothing here
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = this.demuxer.reader;
|
||||||
|
|
||||||
|
assert(this.bitstream.lastMetadataPacket);
|
||||||
|
const startPosition = await this.demuxer.findNextPacketStart(
|
||||||
|
reader,
|
||||||
|
this.bitstream.lastMetadataPacket,
|
||||||
|
);
|
||||||
|
if (!startPosition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lowPage = startPosition.startPage;
|
||||||
|
let high = this.demuxer.fileSize;
|
||||||
|
|
||||||
|
const lowPages: Page[] = [lowPage];
|
||||||
|
|
||||||
|
// First, let's perform a binary serach (bisection search) on the file to find the approximate page where we'll
|
||||||
|
// find the sample. We want to find a page whose end sample position is less than or equal to the
|
||||||
|
// sample position we're searching for.
|
||||||
|
|
||||||
|
// Outer loop: Does the binary serach
|
||||||
|
outer:
|
||||||
|
while (lowPage.headerStartPos + lowPage.totalSize < high) {
|
||||||
|
const low = lowPage.headerStartPos;
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
|
||||||
|
let searchStartPos = mid;
|
||||||
|
|
||||||
|
// Inner loop: Does a linear forward scan if the page cannot be found immediately
|
||||||
|
while (true) {
|
||||||
|
const until = Math.min(
|
||||||
|
searchStartPos + MAX_PAGE_SIZE,
|
||||||
|
high - MIN_PAGE_HEADER_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
await reader.reader.loadRange(searchStartPos, until);
|
||||||
|
|
||||||
|
reader.pos = searchStartPos;
|
||||||
|
const found = reader.findNextPageHeader(until);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
high = mid + MIN_PAGE_HEADER_SIZE;
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
|
||||||
|
const page = reader.readPageHeader();
|
||||||
|
assert(page);
|
||||||
|
|
||||||
|
let pageValid = false;
|
||||||
|
if (page.serialNumber === this.bitstream.serialNumber) {
|
||||||
|
// Serial numbers are basically random numbers, and the chance of finding a fake page with matching
|
||||||
|
// serial number is astronomically low, so we can be pretty sure this page is legit.
|
||||||
|
pageValid = true;
|
||||||
|
} else {
|
||||||
|
await reader.reader.loadRange(page.headerStartPos, page.headerStartPos + page.totalSize);
|
||||||
|
|
||||||
|
// Validate the page by checking checksum
|
||||||
|
reader.pos = page.headerStartPos;
|
||||||
|
const bytes = reader.readBytes(page.totalSize);
|
||||||
|
const crc = computeOggPageCrc(bytes);
|
||||||
|
|
||||||
|
pageValid = crc === page.checksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageValid) {
|
||||||
|
// Keep searching for a valid page
|
||||||
|
searchStartPos = page.headerStartPos + 4; // 'OggS' is 4 bytes
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageValid && page.serialNumber !== this.bitstream.serialNumber) {
|
||||||
|
// Page is valid but from a different bitstream, so keep searching forward until we find one
|
||||||
|
// belonging to the our bitstream
|
||||||
|
searchStartPos = page.headerStartPos + page.totalSize;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isContinuationPage = page.granulePosition === -1;
|
||||||
|
if (isContinuationPage) {
|
||||||
|
// No sample ends on this page - keep looking
|
||||||
|
searchStartPos = page.headerStartPos + page.totalSize;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page is valid and belongs to our bitstream; let's check its granule position to see where we need
|
||||||
|
// to take the bisection search.
|
||||||
|
if (this.granulePositionToTimestampInSamples(page.granulePosition) > timestampInSamples) {
|
||||||
|
high = page.headerStartPos;
|
||||||
|
} else {
|
||||||
|
lowPage = page;
|
||||||
|
lowPages.push(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now we have the last page with a sample position <= the sample position we're looking for, but there might
|
||||||
|
// be multiple pages with the sample position, in which case we actually need to find the first of such pages.
|
||||||
|
// We'll do this in two steps: First, let's find the latest page we know with an earlier sample position, and
|
||||||
|
// then linear scan ourselves forward until we find the correct page.
|
||||||
|
|
||||||
|
let lowerPage = startPosition.startPage;
|
||||||
|
for (const otherLowPage of lowPages) {
|
||||||
|
if (otherLowPage.granulePosition === lowPage.granulePosition) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lowerPage || otherLowPage.headerStartPos > lowerPage.headerStartPos) {
|
||||||
|
lowerPage = otherLowPage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage: Page | null = lowerPage;
|
||||||
|
// Keep track of the pages we traversed, we need these later for backwards seeking
|
||||||
|
const previousPages: Page[] = [currentPage];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// This loop must terminate as we'll eventually reach lowPage
|
||||||
|
if (
|
||||||
|
currentPage.serialNumber === this.bitstream.serialNumber
|
||||||
|
&& currentPage.granulePosition === lowPage.granulePosition
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.pos = currentPage.headerStartPos + currentPage.totalSize;
|
||||||
|
await reader.reader.loadRange(reader.pos, reader.pos + MAX_PAGE_HEADER_SIZE);
|
||||||
|
|
||||||
|
const nextPage = reader.readPageHeader();
|
||||||
|
assert(nextPage);
|
||||||
|
|
||||||
|
currentPage = nextPage;
|
||||||
|
|
||||||
|
if (currentPage.serialNumber === this.bitstream.serialNumber) {
|
||||||
|
previousPages.push(currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(currentPage.granulePosition !== -1);
|
||||||
|
|
||||||
|
let currentSegmentIndex: number | null = null;
|
||||||
|
let currentTimestampInSamples: number;
|
||||||
|
let currentTimestampIsCorrect: boolean;
|
||||||
|
|
||||||
|
// These indicate the end position of the packet that the granule position belongs to
|
||||||
|
let endPage = currentPage;
|
||||||
|
let endSegmentIndex = 0;
|
||||||
|
|
||||||
|
if (currentPage.headerStartPos === startPosition.startPage.headerStartPos) {
|
||||||
|
currentTimestampInSamples = this.granulePositionToTimestampInSamples(0);
|
||||||
|
currentTimestampIsCorrect = true;
|
||||||
|
currentSegmentIndex = 0;
|
||||||
|
} else {
|
||||||
|
currentTimestampInSamples = 0; // Placeholder value! We'll refine it once we can
|
||||||
|
currentTimestampIsCorrect = false;
|
||||||
|
|
||||||
|
// Find the segment index of the next packet
|
||||||
|
for (let i = currentPage.lacingValues.length - 1; i >= 0; i--) {
|
||||||
|
const value = currentPage.lacingValues[i]!;
|
||||||
|
if (value < 255) {
|
||||||
|
// We know the last packet ended at i, so the next one starts at i + 1
|
||||||
|
currentSegmentIndex = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This must hold: Since this page has a granule position set, that means there must be a packet that ends
|
||||||
|
// in this page.
|
||||||
|
if (currentSegmentIndex === null) {
|
||||||
|
throw new Error('Invalid page with granule position: no packets end on this page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
endSegmentIndex = currentSegmentIndex - 1;
|
||||||
|
const nextPosition = await this.demuxer.findNextPacketStart(reader, {
|
||||||
|
data: PLACEHOLDER_DATA,
|
||||||
|
endPage,
|
||||||
|
endSegmentIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextPosition) {
|
||||||
|
// Let's rewind a single step (packet) - this previous packet ensures that we'll correctly compute the
|
||||||
|
// duration for the packet we're looking for.
|
||||||
|
const endPosition = findPreviousPacketEndPosition(previousPages, currentPage, currentSegmentIndex);
|
||||||
|
assert(endPosition);
|
||||||
|
|
||||||
|
const startPosition = findPacketStartPosition(
|
||||||
|
previousPages, endPosition.page, endPosition.segmentIndex,
|
||||||
|
);
|
||||||
|
if (startPosition) {
|
||||||
|
currentPage = startPosition.page;
|
||||||
|
currentSegmentIndex = startPosition.segmentIndex;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// There is no next position, which means we're looking for the last sample in the bitstream. The
|
||||||
|
// granule position on the last page tends to be fucky, so let's instead start the search on the page
|
||||||
|
// before that. So let's loop until we find a packet that ends in a previous page.
|
||||||
|
while (true) {
|
||||||
|
const endPosition = findPreviousPacketEndPosition(previousPages, currentPage, currentSegmentIndex);
|
||||||
|
if (!endPosition) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPosition = findPacketStartPosition(
|
||||||
|
previousPages, endPosition.page, endPosition.segmentIndex,
|
||||||
|
);
|
||||||
|
if (!startPosition) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage = startPosition.page;
|
||||||
|
currentSegmentIndex = startPosition.segmentIndex;
|
||||||
|
|
||||||
|
if (endPosition.page.headerStartPos !== endPage.headerStartPos) {
|
||||||
|
endPage = endPosition.page;
|
||||||
|
endSegmentIndex = endPosition.segmentIndex;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSample: EncodedAudioSample | null = null;
|
||||||
|
let lastSampleMetadata: SampleMetadata | null = null;
|
||||||
|
|
||||||
|
// Alright, now it's time for the final, granular seek: We keep iterating over packets until we've found the one
|
||||||
|
// with the correct timestamp - i.e., the last one with a timestamp <= the timestamp we're looking for.
|
||||||
|
while (currentPage !== null) {
|
||||||
|
assert(currentSegmentIndex !== null);
|
||||||
|
|
||||||
|
const packet = await this.demuxer.readPacket(reader, currentPage, currentSegmentIndex);
|
||||||
|
if (!packet) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We might need to skip the packet if it's a metadata one
|
||||||
|
const skipPacket = currentPage.headerStartPos === startPosition.startPage.headerStartPos
|
||||||
|
&& currentSegmentIndex < startPosition.startSegmentIndex;
|
||||||
|
|
||||||
|
if (!skipPacket) {
|
||||||
|
let sample = this.createSampleFromPacket(
|
||||||
|
packet,
|
||||||
|
{
|
||||||
|
timestampInSamples: currentTimestampInSamples,
|
||||||
|
vorbisLastBlocksize: lastSampleMetadata?.vorbisBlockSize ?? null,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
assert(sample);
|
||||||
|
|
||||||
|
let sampleMetadata = this.sampleToMetadata.get(sample);
|
||||||
|
assert(sampleMetadata);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!currentTimestampIsCorrect
|
||||||
|
&& packet.endPage.headerStartPos === endPage.headerStartPos
|
||||||
|
&& packet.endSegmentIndex === endSegmentIndex
|
||||||
|
) {
|
||||||
|
// We know this packet end timestamp can be derived from the page's granule position
|
||||||
|
currentTimestampInSamples = this.granulePositionToTimestampInSamples(currentPage.granulePosition);
|
||||||
|
currentTimestampIsCorrect = true;
|
||||||
|
|
||||||
|
// Let's backpatch the sample we just created with the correct timestamp
|
||||||
|
sample = this.createSampleFromPacket(
|
||||||
|
packet,
|
||||||
|
{
|
||||||
|
timestampInSamples: currentTimestampInSamples - sampleMetadata.durationInSamples,
|
||||||
|
vorbisLastBlocksize: lastSampleMetadata?.vorbisBlockSize ?? null,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
assert(sample);
|
||||||
|
|
||||||
|
sampleMetadata = this.sampleToMetadata.get(sample);
|
||||||
|
assert(sampleMetadata);
|
||||||
|
} else {
|
||||||
|
currentTimestampInSamples += sampleMetadata.durationInSamples;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSample = sample;
|
||||||
|
lastSampleMetadata = sampleMetadata;
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentTimestampIsCorrect
|
||||||
|
&& (
|
||||||
|
// Next timestamp will be too late
|
||||||
|
Math.max(currentTimestampInSamples, 0) > timestampInSamples
|
||||||
|
// This timestamp already matches
|
||||||
|
|| Math.max(sampleMetadata.timestampInSamples, 0) === timestampInSamples
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPosition = await this.demuxer.findNextPacketStart(reader, packet);
|
||||||
|
if (!nextPosition) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage = nextPosition.startPage;
|
||||||
|
currentSegmentIndex = nextPosition.startSegmentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastSample;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeySample(timestamp: number, options: SampleRetrievalOptions) {
|
||||||
|
return this.getSample(timestamp, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextKeySample(sample: EncodedAudioSample, options: SampleRetrievalOptions) {
|
||||||
|
return this.getNextSample(sample, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds the start position of a packet given its end position. */
|
||||||
|
const findPacketStartPosition = (pageList: Page[], endPage: Page, endSegmentIndex: number) => {
|
||||||
|
let page = endPage;
|
||||||
|
let segmentIndex = endSegmentIndex;
|
||||||
|
|
||||||
|
outer:
|
||||||
|
while (true) {
|
||||||
|
segmentIndex--;
|
||||||
|
|
||||||
|
for (segmentIndex; segmentIndex >= 0; segmentIndex--) {
|
||||||
|
const lacingValue = page.lacingValues[segmentIndex]!;
|
||||||
|
if (lacingValue < 255) {
|
||||||
|
segmentIndex++; // We know the last sample starts here
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(segmentIndex === -1);
|
||||||
|
|
||||||
|
const pageStartsWithFreshPacket = !(page.headerType & 0x01);
|
||||||
|
if (pageStartsWithFreshPacket) {
|
||||||
|
// Fast exit: We know we don't need to look in the previous page
|
||||||
|
segmentIndex = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPage = findLast(
|
||||||
|
pageList,
|
||||||
|
x => x.headerStartPos < page.headerStartPos,
|
||||||
|
);
|
||||||
|
if (!previousPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
page = previousPage;
|
||||||
|
segmentIndex = page.lacingValues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(segmentIndex !== -1);
|
||||||
|
|
||||||
|
if (segmentIndex === page.lacingValues.length) {
|
||||||
|
// Wrap back around to the first segment of the next page
|
||||||
|
const nextPage = pageList[pageList.indexOf(page) + 1];
|
||||||
|
assert(nextPage);
|
||||||
|
|
||||||
|
page = nextPage;
|
||||||
|
segmentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { page, segmentIndex };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Finds the end position of a packet given the start position of the following packet. */
|
||||||
|
const findPreviousPacketEndPosition = (pageList: Page[], startPage: Page, startSegmentIndex: number) => {
|
||||||
|
if (startSegmentIndex > 0) {
|
||||||
|
// Easy
|
||||||
|
return { page: startPage, segmentIndex: startSegmentIndex - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPage = findLast(
|
||||||
|
pageList,
|
||||||
|
x => x.headerStartPos < startPage.headerStartPos,
|
||||||
|
);
|
||||||
|
if (!previousPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { page: previousPage, segmentIndex: previousPage.lacingValues.length - 1 };
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { toDataView } from '../misc';
|
||||||
|
|
||||||
|
const OGG_CRC_POLYNOMIAL = 0x04c11db7;
|
||||||
|
const OGG_CRC_TABLE = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let crc = n << 24;
|
||||||
|
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
crc = (crc & 0x80000000)
|
||||||
|
? ((crc << 1) ^ OGG_CRC_POLYNOMIAL)
|
||||||
|
: (crc << 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
OGG_CRC_TABLE[n] = (crc >>> 0) & 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const computeOggPageCrc = (bytes: Uint8Array) => {
|
||||||
|
const view = toDataView(bytes);
|
||||||
|
|
||||||
|
const originalChecksum = view.getUint32(22, true);
|
||||||
|
view.setUint32(22, 0, true); // Zero out checksum field
|
||||||
|
|
||||||
|
let crc = 0;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const byte = bytes[i]!;
|
||||||
|
crc = ((crc << 8) ^ OGG_CRC_TABLE[(crc >>> 24) ^ byte]!) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.setUint32(22, originalChecksum, true); // Restore checksum field
|
||||||
|
|
||||||
|
return crc;
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Reader } from '../reader';
|
||||||
|
|
||||||
|
const OGGS = 0x5367674f; // 'OggS'
|
||||||
|
export const MIN_PAGE_HEADER_SIZE = 27;
|
||||||
|
export const MAX_PAGE_HEADER_SIZE = 27 + 255;
|
||||||
|
export const MAX_PAGE_SIZE = MAX_PAGE_HEADER_SIZE + 255 * 255;
|
||||||
|
|
||||||
|
export type Page = {
|
||||||
|
headerStartPos: number;
|
||||||
|
totalSize: number;
|
||||||
|
dataStartPos: number;
|
||||||
|
dataSize: number;
|
||||||
|
headerType: number;
|
||||||
|
granulePosition: number;
|
||||||
|
serialNumber: number;
|
||||||
|
sequenceNumber: number;
|
||||||
|
checksum: number;
|
||||||
|
lacingValues: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class OggReader {
|
||||||
|
pos = 0;
|
||||||
|
constructor(public reader: Reader) {}
|
||||||
|
|
||||||
|
readBytes(length: number) {
|
||||||
|
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
||||||
|
this.pos += length;
|
||||||
|
|
||||||
|
return new Uint8Array(view.buffer, offset, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
readU8() {
|
||||||
|
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
|
||||||
|
this.pos += 1;
|
||||||
|
|
||||||
|
return view.getUint8(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
readU32() {
|
||||||
|
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
|
||||||
|
this.pos += 4;
|
||||||
|
|
||||||
|
return view.getUint32(offset, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
readI32() {
|
||||||
|
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
|
||||||
|
this.pos += 4;
|
||||||
|
|
||||||
|
return view.getInt32(offset, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
readI64() {
|
||||||
|
const low = this.readU32();
|
||||||
|
const high = this.readI32();
|
||||||
|
return high * 0x100000000 + low;
|
||||||
|
}
|
||||||
|
|
||||||
|
readAscii(length: number) {
|
||||||
|
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
||||||
|
this.pos += length;
|
||||||
|
|
||||||
|
let str = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
str += String.fromCharCode(view.getUint8(offset + i));
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
readPageHeader(): Page | null {
|
||||||
|
const startPos = this.pos;
|
||||||
|
|
||||||
|
const capturePattern = this.readU32();
|
||||||
|
if (capturePattern !== OGGS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pos += 1; // Version
|
||||||
|
const headerType = this.readU8();
|
||||||
|
const granulePosition = this.readI64();
|
||||||
|
const serialNumber = this.readU32();
|
||||||
|
const sequenceNumber = this.readU32();
|
||||||
|
const checksum = this.readU32();
|
||||||
|
|
||||||
|
const numberPageSegments = this.readU8();
|
||||||
|
const lacingValues: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numberPageSegments; i++) {
|
||||||
|
lacingValues.push(this.readU8());
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerSize = 27 + numberPageSegments;
|
||||||
|
const dataSize = lacingValues.reduce((a, b) => a + b, 0);
|
||||||
|
const totalSize = headerSize + dataSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
headerStartPos: startPos,
|
||||||
|
totalSize,
|
||||||
|
dataStartPos: startPos + headerSize,
|
||||||
|
dataSize,
|
||||||
|
headerType,
|
||||||
|
granulePosition,
|
||||||
|
serialNumber,
|
||||||
|
sequenceNumber,
|
||||||
|
checksum,
|
||||||
|
lacingValues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
findNextPageHeader(until: number) {
|
||||||
|
while (this.pos < until - (4 - 1)) { // Size of word minus 1
|
||||||
|
const word = this.readU32();
|
||||||
|
const firstByte = word & 0xff;
|
||||||
|
const secondByte = (word >>> 8) & 0xff;
|
||||||
|
const thirdByte = (word >>> 16) & 0xff;
|
||||||
|
const fourthByte = (word >>> 24) & 0xff;
|
||||||
|
|
||||||
|
const O = 0x4f; // 'O'
|
||||||
|
if (firstByte !== O && secondByte !== O && thirdByte !== O && fourthByte !== O) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pos -= 4;
|
||||||
|
|
||||||
|
if (word === OGGS) {
|
||||||
|
// We have found the capture pattern
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pos += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { readBits } from '../misc';
|
||||||
|
|
||||||
|
class Bitstream {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
pos: number;
|
||||||
|
|
||||||
|
constructor(bytes: Uint8Array) {
|
||||||
|
this.bytes = bytes;
|
||||||
|
this.pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
read(n: number): number {
|
||||||
|
const result = readBits(this.bytes, this.pos, this.pos + n);
|
||||||
|
this.pos += n;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
skip(n: number): void {
|
||||||
|
this.pos += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBitsLeft(): number {
|
||||||
|
return this.bytes.length * 8 - this.pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBitCount(): number {
|
||||||
|
return this.pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): Bitstream {
|
||||||
|
const clone = new Bitstream(this.bytes);
|
||||||
|
clone.pos = this.pos;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on vorbis_parser.c from FFmpeg.
|
||||||
|
export const parseModesFromSetupPacket = (setupHeader: Uint8Array) => {
|
||||||
|
// Verify that this is a Setup header.
|
||||||
|
if (setupHeader.length < 7) {
|
||||||
|
throw new Error('Setup header is too short.');
|
||||||
|
}
|
||||||
|
if (setupHeader[0] !== 5) {
|
||||||
|
throw new Error('Wrong packet type in Setup header.');
|
||||||
|
}
|
||||||
|
const signature = String.fromCharCode(...setupHeader.slice(1, 7));
|
||||||
|
if (signature !== 'vorbis') {
|
||||||
|
throw new Error('Invalid packet signature in Setup header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse the entire buffer.
|
||||||
|
const bufSize = setupHeader.length;
|
||||||
|
const revBuffer = new Uint8Array(bufSize);
|
||||||
|
for (let i = 0; i < bufSize; i++) {
|
||||||
|
revBuffer[i] = setupHeader[bufSize - 1 - i]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a Bitstream on the reversed buffer.
|
||||||
|
const bs = new Bitstream(revBuffer);
|
||||||
|
|
||||||
|
// --- Find the framing bit.
|
||||||
|
// In FFmpeg code, we scan until get_bits1() returns 1.
|
||||||
|
let gotFramingBit = 0;
|
||||||
|
while (bs.getBitsLeft() > 97) {
|
||||||
|
if (bs.read(1) === 1) {
|
||||||
|
gotFramingBit = bs.getBitCount();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gotFramingBit === 0) {
|
||||||
|
throw new Error('Invalid Setup header: framing bit not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search backwards for a valid mode header.
|
||||||
|
// We try to “guess” the number of modes by reading a fixed pattern.
|
||||||
|
let modeCount = 0;
|
||||||
|
let gotModeHeader = false;
|
||||||
|
let lastModeCount = 0;
|
||||||
|
while (bs.getBitsLeft() >= 97) {
|
||||||
|
const tempPos = bs.pos;
|
||||||
|
const a = bs.read(8);
|
||||||
|
const b = bs.read(16);
|
||||||
|
const c = bs.read(16);
|
||||||
|
// If a > 63 or b or c nonzero, assume we’ve gone too far.
|
||||||
|
if (a > 63 || b !== 0 || c !== 0) {
|
||||||
|
bs.pos = tempPos;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bs.skip(1);
|
||||||
|
modeCount++;
|
||||||
|
if (modeCount > 64)
|
||||||
|
break;
|
||||||
|
const bsClone = bs.clone();
|
||||||
|
const candidate = bsClone.read(6) + 1;
|
||||||
|
if (candidate === modeCount) {
|
||||||
|
gotModeHeader = true;
|
||||||
|
lastModeCount = modeCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!gotModeHeader) {
|
||||||
|
throw new Error('Invalid Setup header: mode header not found.');
|
||||||
|
}
|
||||||
|
if (lastModeCount > 63) {
|
||||||
|
throw new Error(`Unsupported mode count: ${lastModeCount}.`);
|
||||||
|
}
|
||||||
|
const finalModeCount = lastModeCount;
|
||||||
|
|
||||||
|
// --- Reinitialize the bitstream.
|
||||||
|
bs.pos = 0;
|
||||||
|
// Skip the bits up to the found framing bit.
|
||||||
|
bs.skip(gotFramingBit);
|
||||||
|
|
||||||
|
// --- Now read, for each mode (in reverse order), 40 bits then one bit.
|
||||||
|
// That one bit is the mode blockflag.
|
||||||
|
const modeBlockflags = Array(finalModeCount).fill(0) as number[];
|
||||||
|
for (let i = finalModeCount - 1; i >= 0; i--) {
|
||||||
|
bs.skip(40);
|
||||||
|
modeBlockflags[i] = bs.read(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modeBlockflags };
|
||||||
|
};
|
||||||
+34
-2
@@ -11,7 +11,23 @@ export class EncodedVideoSample {
|
|||||||
public readonly timestamp: number,
|
public readonly timestamp: number,
|
||||||
public readonly duration: number,
|
public readonly duration: number,
|
||||||
public readonly byteLength = data.byteLength,
|
public readonly byteLength = data.byteLength,
|
||||||
) {}
|
) {
|
||||||
|
if (!(data instanceof Uint8Array)) {
|
||||||
|
throw new TypeError('data must be a Uint8Array.');
|
||||||
|
}
|
||||||
|
if (type !== 'key' && type !== 'delta') {
|
||||||
|
throw new TypeError('type must be either "key" or "delta".');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(timestamp)) {
|
||||||
|
throw new TypeError('timestamp must be a number.');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(duration) || duration < 0) {
|
||||||
|
throw new TypeError('duration must be a non-negative number.');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(byteLength) || byteLength < 0) {
|
||||||
|
throw new TypeError('byteLength must be a non-negative integer.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get isMetadataOnly() {
|
get isMetadataOnly() {
|
||||||
return this.data === PLACEHOLDER_DATA;
|
return this.data === PLACEHOLDER_DATA;
|
||||||
@@ -103,7 +119,23 @@ export class EncodedAudioSample {
|
|||||||
public readonly timestamp: number,
|
public readonly timestamp: number,
|
||||||
public readonly duration: number,
|
public readonly duration: number,
|
||||||
public readonly byteLength = data.byteLength,
|
public readonly byteLength = data.byteLength,
|
||||||
) {}
|
) {
|
||||||
|
if (!(data instanceof Uint8Array)) {
|
||||||
|
throw new TypeError('data must be a Uint8Array.');
|
||||||
|
}
|
||||||
|
if (type !== 'key' && type !== 'delta') {
|
||||||
|
throw new TypeError('type must be either "key" or "delta".');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(timestamp)) {
|
||||||
|
throw new TypeError('timestamp must be a number.');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(duration) || duration < 0) {
|
||||||
|
throw new TypeError('duration must be a non-negative number.');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(byteLength) || byteLength < 0) {
|
||||||
|
throw new TypeError('byteLength must be a non-negative integer.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get isMetadataOnly() {
|
get isMetadataOnly() {
|
||||||
return this.data === PLACEHOLDER_DATA;
|
return this.data === PLACEHOLDER_DATA;
|
||||||
|
|||||||
Reference in New Issue
Block a user