mediabunny
Version:
Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.
468 lines (467 loc) • 18.4 kB
JavaScript
/*!
* Copyright (c) 2026-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Demuxer } from '../demuxer.js';
import { DEFAULT_TRACK_DISPOSITION } from '../metadata.js';
import { assert, UNDETERMINED_LANGUAGE } from '../misc.js';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet.js';
import { readAscii, readBytes, readU16, readU32, readU64 } from '../reader.js';
import { ID3_V2_HEADER_SIZE, parseId3V2Tag, readId3V2Header } from '../id3.js';
export var WaveFormat;
(function (WaveFormat) {
WaveFormat[WaveFormat["PCM"] = 1] = "PCM";
WaveFormat[WaveFormat["IEEE_FLOAT"] = 3] = "IEEE_FLOAT";
WaveFormat[WaveFormat["ALAW"] = 6] = "ALAW";
WaveFormat[WaveFormat["MULAW"] = 7] = "MULAW";
WaveFormat[WaveFormat["EXTENSIBLE"] = 65534] = "EXTENSIBLE";
})(WaveFormat || (WaveFormat = {}));
export class WaveDemuxer extends Demuxer {
constructor(input) {
super(input);
this.metadataPromise = null;
this.dataStart = -1;
this.dataSize = -1;
this.audioInfo = null;
this.trackBackings = [];
this.lastKnownPacketIndex = 0;
this.metadataTags = {};
this.reader = input._reader;
}
async readMetadata() {
return this.metadataPromise ??= (async () => {
let slice = this.reader.requestSlice(0, 12);
if (slice instanceof Promise)
slice = await slice;
assert(slice);
const riffType = readAscii(slice, 4);
const littleEndian = riffType !== 'RIFX';
const isRf64 = riffType === 'RF64';
const outerChunkSize = readU32(slice, littleEndian);
let totalFileSize = isRf64
? this.reader.fileSize
: Math.min(outerChunkSize + 8, this.reader.fileSize ?? Infinity);
const format = readAscii(slice, 4);
if (format !== 'WAVE') {
throw new Error('Invalid WAVE file - wrong format');
}
let chunksRead = 0;
let dataChunkSize = null;
let currentPos = slice.filePos;
while (totalFileSize === null || currentPos < totalFileSize) {
let slice = this.reader.requestSlice(currentPos, 8);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
break;
const chunkId = readAscii(slice, 4);
const chunkSize = readU32(slice, littleEndian);
const startPos = slice.filePos;
if (isRf64 && chunksRead === 0 && chunkId !== 'ds64') {
throw new Error('Invalid RF64 file: First chunk must be "ds64".');
}
if (chunkId === 'fmt ') {
await this.parseFmtChunk(startPos, chunkSize, littleEndian);
}
else if (chunkId === 'data') {
dataChunkSize ??= chunkSize;
this.dataStart = slice.filePos;
this.dataSize = Math.min(dataChunkSize, (totalFileSize ?? Infinity) - this.dataStart);
if (this.reader.fileSize === null) {
break; // Stop once we hit the data chunk
}
}
else if (chunkId === 'ds64') {
// File and data chunk sizes are defined in here instead
let ds64Slice = this.reader.requestSlice(startPos, chunkSize);
if (ds64Slice instanceof Promise)
ds64Slice = await ds64Slice;
if (!ds64Slice)
break;
const riffChunkSize = readU64(ds64Slice, littleEndian);
dataChunkSize = readU64(ds64Slice, littleEndian);
totalFileSize = Math.min(riffChunkSize + 8, this.reader.fileSize ?? Infinity);
}
else if (chunkId === 'LIST') {
await this.parseListChunk(startPos, chunkSize, littleEndian);
}
else if (chunkId === 'ID3 ' || chunkId === 'id3 ') {
await this.parseId3Chunk(startPos, chunkSize);
}
currentPos = startPos + chunkSize + (chunkSize & 1); // Handle padding
chunksRead++;
}
if (!this.audioInfo) {
throw new Error('Invalid WAVE file - missing "fmt " chunk');
}
if (this.dataStart === -1) {
throw new Error('Invalid WAVE file - missing "data" chunk');
}
const blockSize = this.audioInfo.blockSizeInBytes;
this.dataSize = Math.floor(this.dataSize / blockSize) * blockSize;
this.trackBackings.push(new WaveAudioTrackBacking(this));
})();
}
async parseFmtChunk(startPos, size, littleEndian) {
let slice = this.reader.requestSlice(startPos, size);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
return; // File too short
let formatTag = readU16(slice, littleEndian);
const numChannels = readU16(slice, littleEndian);
const sampleRate = readU32(slice, littleEndian);
slice.skip(4); // Bytes per second
const blockAlign = readU16(slice, littleEndian);
let bitsPerSample;
if (size === 14) { // Plain WAVEFORMAT
bitsPerSample = 8;
}
else {
bitsPerSample = readU16(slice, littleEndian);
}
// Handle WAVEFORMATEXTENSIBLE
if (size >= 18 && formatTag !== 0x0165) {
const cbSize = readU16(slice, littleEndian);
const remainingSize = size - 18;
const extensionSize = Math.min(remainingSize, cbSize);
if (extensionSize >= 22 && formatTag === WaveFormat.EXTENSIBLE) {
// Parse WAVEFORMATEXTENSIBLE
slice.skip(2 + 4);
const subFormat = readBytes(slice, 16);
// Get actual format from subFormat GUID
formatTag = subFormat[0] | (subFormat[1] << 8);
}
}
if (formatTag === WaveFormat.MULAW || formatTag === WaveFormat.ALAW) {
bitsPerSample = 8;
}
this.audioInfo = {
format: formatTag,
numberOfChannels: numChannels,
sampleRate,
sampleSizeInBytes: Math.ceil(bitsPerSample / 8),
blockSizeInBytes: blockAlign,
};
}
async parseListChunk(startPos, size, littleEndian) {
let slice = this.reader.requestSlice(startPos, size);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
return; // File too short
const infoType = readAscii(slice, 4);
if (infoType !== 'INFO' && infoType !== 'INF0') { // exiftool.org claims INF0 can happen
return; // Not an INFO chunk
}
let currentPos = slice.filePos;
while (currentPos <= startPos + size - 8) {
slice.filePos = currentPos;
const chunkName = readAscii(slice, 4);
const chunkSize = readU32(slice, littleEndian);
const bytes = readBytes(slice, chunkSize);
let stringLength = 0;
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) {
break;
}
stringLength++;
}
const value = String.fromCharCode(...bytes.subarray(0, stringLength));
this.metadataTags.raw ??= {};
this.metadataTags.raw[chunkName] = value;
switch (chunkName) {
case 'INAM':
case 'TITL':
{
this.metadataTags.title ??= value;
}
;
break;
case 'TIT3':
{
this.metadataTags.description ??= value;
}
;
break;
case 'IART':
{
this.metadataTags.artist ??= value;
}
;
break;
case 'IPRD':
{
this.metadataTags.album ??= value;
}
;
break;
case 'IPRT':
case 'ITRK':
case 'TRCK':
{
const parts = value.split('/');
const trackNum = Number.parseInt(parts[0], 10);
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
if (Number.isInteger(trackNum) && trackNum > 0) {
this.metadataTags.trackNumber ??= trackNum;
}
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
this.metadataTags.tracksTotal ??= tracksTotal;
}
}
;
break;
case 'ICRD':
case 'IDIT':
{
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
this.metadataTags.date ??= date;
}
}
;
break;
case 'YEAR':
{
const year = Number.parseInt(value, 10);
if (Number.isInteger(year) && year > 0) {
this.metadataTags.date ??= new Date(year, 0, 1);
}
}
;
break;
case 'IGNR':
case 'GENR':
{
this.metadataTags.genre ??= value;
}
;
break;
case 'ICMT':
case 'CMNT':
case 'COMM':
{
this.metadataTags.comment ??= value;
}
;
break;
}
currentPos += 8 + chunkSize + (chunkSize & 1); // Handle padding
}
}
async parseId3Chunk(startPos, size) {
// Parse ID3 tag embedded in WAV file (non-default, but used a lot in practice anyway)
let slice = this.reader.requestSlice(startPos, size);
if (slice instanceof Promise)
slice = await slice;
if (!slice)
return; // File too short
const id3V2Header = readId3V2Header(slice);
if (id3V2Header) {
// Clamp to the available data in case the ID3 header claims more than the WAV chunk provides
// https://github.com/Vanilagy/mediabunny/issues/300
const availableSize = size - ID3_V2_HEADER_SIZE;
id3V2Header.size = Math.min(id3V2Header.size, availableSize);
if (id3V2Header.size > 0) {
const contentSlice = slice.slice(startPos + ID3_V2_HEADER_SIZE, id3V2Header.size);
parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);
}
}
}
getCodec() {
assert(this.audioInfo);
if (this.audioInfo.format === WaveFormat.MULAW) {
return 'ulaw';
}
if (this.audioInfo.format === WaveFormat.ALAW) {
return 'alaw';
}
if (this.audioInfo.format === WaveFormat.PCM) {
// All formats are little-endian
if (this.audioInfo.sampleSizeInBytes === 1) {
return 'pcm-u8';
}
else if (this.audioInfo.sampleSizeInBytes === 2) {
return 'pcm-s16';
}
else if (this.audioInfo.sampleSizeInBytes === 3) {
return 'pcm-s24';
}
else if (this.audioInfo.sampleSizeInBytes === 4) {
return 'pcm-s32';
}
}
if (this.audioInfo.format === WaveFormat.IEEE_FLOAT) {
if (this.audioInfo.sampleSizeInBytes === 4) {
return 'pcm-f32';
}
}
return null;
}
async getMimeType() {
return 'audio/wav';
}
async getTrackBackings() {
await this.readMetadata();
return this.trackBackings;
}
async getMetadataTags() {
await this.readMetadata();
return this.metadataTags;
}
}
const PACKET_SIZE_IN_FRAMES = 2048;
class WaveAudioTrackBacking {
constructor(demuxer) {
this.demuxer = demuxer;
}
getType() {
return 'audio';
}
getId() {
return 1;
}
getNumber() {
return 1;
}
getCodec() {
return this.demuxer.getCodec();
}
getInternalCodecId() {
assert(this.demuxer.audioInfo);
return this.demuxer.audioInfo.format;
}
async getDecoderConfig() {
const codec = this.demuxer.getCodec();
if (!codec) {
return null;
}
assert(this.demuxer.audioInfo);
return {
codec,
numberOfChannels: this.demuxer.audioInfo.numberOfChannels,
sampleRate: this.demuxer.audioInfo.sampleRate,
};
}
getNumberOfChannels() {
assert(this.demuxer.audioInfo);
return this.demuxer.audioInfo.numberOfChannels;
}
getSampleRate() {
assert(this.demuxer.audioInfo);
return this.demuxer.audioInfo.sampleRate;
}
getTimeResolution() {
assert(this.demuxer.audioInfo);
return this.demuxer.audioInfo.sampleRate;
}
isRelativeToUnixEpoch() {
return false;
}
getPairingMask() {
return 1n;
}
getBitrate() {
return null;
}
getAverageBitrate() {
return null;
}
async getDurationFromMetadata() {
assert(this.demuxer.dataSize !== -1);
return this.demuxer.dataSize / this.demuxer.audioInfo.blockSizeInBytes / this.demuxer.audioInfo.sampleRate;
}
async getLiveRefreshInterval() {
return null;
}
getName() {
return null;
}
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
getDisposition() {
return {
...DEFAULT_TRACK_DISPOSITION,
};
}
async getPacketAtIndex(packetIndex, options) {
assert(packetIndex >= 0);
assert(this.demuxer.audioInfo);
const startOffset = packetIndex * PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes;
if (startOffset >= this.demuxer.dataSize) {
return null;
}
const sizeInBytes = Math.min(PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes, this.demuxer.dataSize - startOffset);
if (this.demuxer.reader.fileSize === null) {
// If the file size is unknown, we weren't able to cap the dataSize in the init logic and we instead have to
// rely on the headers telling us how large the file is. But, these might be wrong, so let's check if the
// requested slice actually exists.
let slice = this.demuxer.reader.requestSlice(this.demuxer.dataStart + startOffset, sizeInBytes);
if (slice instanceof Promise)
slice = await slice;
if (!slice) {
return null;
}
}
let data;
if (options.metadataOnly) {
data = PLACEHOLDER_DATA;
}
else {
let slice = this.demuxer.reader.requestSlice(this.demuxer.dataStart + startOffset, sizeInBytes);
if (slice instanceof Promise)
slice = await slice;
assert(slice);
data = readBytes(slice, sizeInBytes);
}
const timestamp = packetIndex * PACKET_SIZE_IN_FRAMES / this.demuxer.audioInfo.sampleRate;
const duration = sizeInBytes / this.demuxer.audioInfo.blockSizeInBytes / this.demuxer.audioInfo.sampleRate;
this.demuxer.lastKnownPacketIndex = Math.max(packetIndex, this.demuxer.lastKnownPacketIndex);
return new EncodedPacket(data, 'key', timestamp, duration, packetIndex, sizeInBytes);
}
getFirstPacket(options) {
return this.getPacketAtIndex(0, options);
}
async getPacket(timestamp, options) {
assert(this.demuxer.audioInfo);
const packetIndex = Math.floor(Math.min(timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES, (this.demuxer.dataSize - 1) / (PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes)));
if (packetIndex < 0) {
return null;
}
const packet = await this.getPacketAtIndex(packetIndex, options);
if (packet) {
return packet;
}
if (packetIndex === 0) {
return null; // Empty data chunk
}
assert(this.demuxer.reader.fileSize === null);
// The file is shorter than we thought, meaning the packet we were looking for doesn't exist. So, let's find
// the last packet by doing a sequential scan, instead.
let currentPacket = await this.getPacketAtIndex(this.demuxer.lastKnownPacketIndex, options);
while (currentPacket) {
const nextPacket = await this.getNextPacket(currentPacket, options);
if (!nextPacket) {
break;
}
currentPacket = nextPacket;
}
return currentPacket;
}
getNextPacket(packet, options) {
assert(this.demuxer.audioInfo);
const packetIndex = Math.round(packet.timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES);
return this.getPacketAtIndex(packetIndex + 1, options);
}
getKeyPacket(timestamp, options) {
return this.getPacket(timestamp, options);
}
getNextKeyPacket(packet, options) {
return this.getNextPacket(packet, options);
}
}