mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
Add WAVE demuxer
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<input type="file" accept="video/mp4,video/quicktime,audio/x-m4a,video/x-m4v">
|
||||
<input type="file" accept="video/mp4,video/quicktime,audio/x-m4a,video/x-m4v,audio/wav">
|
||||
|
||||
<div id="player"
|
||||
style="display: none; flex-direction: column; align-items: center;
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ export default tseslint.config(
|
||||
}],
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'@stylistic/yield-star-spacing': ['error', { before: false, after: true }]
|
||||
'@stylistic/yield-star-spacing': ['error', { before: false, after: true }],
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+43
-6
@@ -2,6 +2,8 @@ import { Demuxer } from './demuxer';
|
||||
import { Input } from './input';
|
||||
import { IsobmffDemuxer } from './isobmff/isobmff-demuxer';
|
||||
import { IsobmffReader } from './isobmff/isobmff-reader';
|
||||
import { RiffReader } from './wave/riff-reader';
|
||||
import { WaveDemuxer } from './wave/wave-demuxer';
|
||||
|
||||
/** @public */
|
||||
export abstract class InputFormat {
|
||||
@@ -35,7 +37,7 @@ export abstract class IsobmffInputFormat extends InputFormat {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override _createDemuxer(input: Input) {
|
||||
_createDemuxer(input: Input) {
|
||||
return new IsobmffDemuxer(input);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +45,7 @@ export abstract class IsobmffInputFormat extends InputFormat {
|
||||
/** @public */
|
||||
export class Mp4InputFormat extends IsobmffInputFormat {
|
||||
/** @internal */
|
||||
override async _canReadInput(input: Input) {
|
||||
async _canReadInput(input: Input) {
|
||||
const majorBrand = await this._getMajorBrand(input);
|
||||
return !!majorBrand && majorBrand !== 'qt ';
|
||||
}
|
||||
@@ -60,7 +62,7 @@ export class Mp4InputFormat extends IsobmffInputFormat {
|
||||
/** @public */
|
||||
export class QuickTimeInputFormat extends IsobmffInputFormat {
|
||||
/** @internal */
|
||||
override async _canReadInput(input: Input) {
|
||||
async _canReadInput(input: Input) {
|
||||
const majorBrand = await this._getMajorBrand(input);
|
||||
return majorBrand === 'qt ';
|
||||
}
|
||||
@@ -77,12 +79,12 @@ export class QuickTimeInputFormat extends IsobmffInputFormat {
|
||||
/** @public */
|
||||
export class MatroskaInputFormat extends InputFormat {
|
||||
/** @internal */
|
||||
override async _canReadInput() {
|
||||
async _canReadInput() {
|
||||
return false; // TODO
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override _createDemuxer(): never {
|
||||
_createDemuxer(): never {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
@@ -95,6 +97,39 @@ export class MatroskaInputFormat extends InputFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class WaveInputFormat extends InputFormat {
|
||||
async _canReadInput(input: Input) {
|
||||
const sourceSize = await input._mainReader.source._getSize();
|
||||
if (sourceSize < 12) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const riffReader = new RiffReader(input._mainReader);
|
||||
const riffType = riffReader.readAscii(4);
|
||||
if (riffType !== 'RIFF' && riffType !== 'RIFX') {
|
||||
return false;
|
||||
}
|
||||
|
||||
riffReader.pos = 8;
|
||||
const format = riffReader.readAscii(4);
|
||||
return format === 'WAVE';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_createDemuxer(input: Input) {
|
||||
return new WaveDemuxer(input);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'WAVE';
|
||||
}
|
||||
|
||||
getMimeType() {
|
||||
return 'audio/wav';
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const MP4 = new Mp4InputFormat();
|
||||
/** @public */
|
||||
@@ -103,6 +138,8 @@ export const QTFF = new QuickTimeInputFormat();
|
||||
export const MATROSKA = new MatroskaInputFormat();
|
||||
/** @public */
|
||||
export const WEBM = MATROSKA;
|
||||
/** @public */
|
||||
export const WAVE = new WaveInputFormat();
|
||||
|
||||
/** @public */
|
||||
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA];
|
||||
export const ALL_FORMATS: InputFormat[] = [MP4, QTFF, MATROSKA, WAVE];
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Reader } from '../reader';
|
||||
|
||||
export class RiffReader {
|
||||
pos = 0;
|
||||
littleEndian = true;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
readU16() {
|
||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 2);
|
||||
this.pos += 2;
|
||||
|
||||
return view.getUint16(offset, this.littleEndian);
|
||||
}
|
||||
|
||||
readU32() {
|
||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 4);
|
||||
this.pos += 4;
|
||||
|
||||
return view.getUint32(offset, this.littleEndian);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { AudioCodec } from '../codec';
|
||||
import { Demuxer } from '../demuxer';
|
||||
import { Input } from '../input';
|
||||
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
||||
import { SampleRetrievalOptions } from '../media-drain';
|
||||
import { assert } from '../misc';
|
||||
import { Reader } from '../reader';
|
||||
import { EncodedAudioSample, PLACEHOLDER_DATA } from '../sample';
|
||||
import { RiffReader } from './riff-reader';
|
||||
|
||||
export enum WaveFormat {
|
||||
PCM = 0x0001,
|
||||
IEEE_FLOAT = 0x0003,
|
||||
ALAW = 0x0006,
|
||||
MULAW = 0x0007,
|
||||
EXTENSIBLE = 0xFFFE,
|
||||
}
|
||||
|
||||
export class WaveDemuxer extends Demuxer {
|
||||
riffReader: RiffReader;
|
||||
chunkReader: RiffReader;
|
||||
|
||||
littleEndian = true;
|
||||
metadataPromise: Promise<void> | null = null;
|
||||
dataStart = -1;
|
||||
dataSize = -1;
|
||||
audioInfo: {
|
||||
format: number;
|
||||
numberOfChannels: number;
|
||||
sampleRate: number;
|
||||
sampleSizeInBytes: number;
|
||||
blockSizeInBytes: number;
|
||||
} | null = null;
|
||||
|
||||
tracks: InputAudioTrack[] = [];
|
||||
|
||||
constructor(input: Input) {
|
||||
super(input);
|
||||
|
||||
this.riffReader = new RiffReader(input._mainReader);
|
||||
this.chunkReader = new RiffReader(new Reader(input._source, 64 * 2 ** 20));
|
||||
}
|
||||
|
||||
async readMetadata() {
|
||||
return this.metadataPromise ??= (async () => {
|
||||
const riffType = this.riffReader.readAscii(4);
|
||||
this.littleEndian = this.riffReader.littleEndian = riffType === 'RIFF';
|
||||
|
||||
const totalFileSize = this.riffReader.readU32() + 8;
|
||||
const format = this.riffReader.readAscii(4);
|
||||
|
||||
if (format !== 'WAVE') {
|
||||
throw new Error('Invalid WAVE file - wrong format');
|
||||
}
|
||||
|
||||
this.riffReader.pos = 12;
|
||||
while (this.riffReader.pos < totalFileSize) {
|
||||
await this.riffReader.reader.loadRange(this.riffReader.pos, this.riffReader.pos + 8);
|
||||
|
||||
const chunkId = this.riffReader.readAscii(4);
|
||||
const chunkSize = this.riffReader.readU32();
|
||||
const startPos = this.riffReader.pos;
|
||||
|
||||
if (chunkId === 'fmt ') {
|
||||
await this.parseFmtChunk(chunkSize);
|
||||
} else if (chunkId === 'data') {
|
||||
this.dataStart = this.riffReader.pos;
|
||||
this.dataSize = chunkSize;
|
||||
}
|
||||
|
||||
this.riffReader.pos = startPos + chunkSize + (chunkSize & 1); // Handle padding
|
||||
}
|
||||
|
||||
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.tracks.push(new InputAudioTrack(new WaveAudioTrackBacking(this)));
|
||||
})();
|
||||
}
|
||||
|
||||
private async parseFmtChunk(size: number) {
|
||||
await this.riffReader.reader.loadRange(this.riffReader.pos, this.riffReader.pos + size);
|
||||
|
||||
let formatTag = this.riffReader.readU16();
|
||||
const numChannels = this.riffReader.readU16();
|
||||
const sampleRate = this.riffReader.readU32();
|
||||
this.riffReader.pos += 4;
|
||||
const blockAlign = this.riffReader.readU16();
|
||||
|
||||
let bitsPerSample: number;
|
||||
|
||||
if (size === 14) { // Plain WAVEFORMAT
|
||||
bitsPerSample = 8;
|
||||
} else {
|
||||
bitsPerSample = this.riffReader.readU16();
|
||||
}
|
||||
|
||||
// Handle WAVEFORMATEXTENSIBLE
|
||||
if (size >= 18 && formatTag !== 0x0165) {
|
||||
const cbSize = this.riffReader.readU16();
|
||||
const remainingSize = size - 18;
|
||||
const extensionSize = Math.min(remainingSize, cbSize);
|
||||
|
||||
if (extensionSize >= 22 && formatTag === WaveFormat.EXTENSIBLE) {
|
||||
// Parse WAVEFORMATEXTENSIBLE
|
||||
this.riffReader.pos += 2 + 4;
|
||||
const subFormat = this.riffReader.readBytes(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,
|
||||
};
|
||||
}
|
||||
|
||||
getCodec(): AudioCodec | null {
|
||||
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) {
|
||||
if (this.audioInfo.sampleSizeInBytes === 1) {
|
||||
return 'pcm-u8';
|
||||
} else if (this.audioInfo.sampleSizeInBytes === 2) {
|
||||
return this.littleEndian ? 'pcm-s16' : 'pcm-s16be';
|
||||
} else if (this.audioInfo.sampleSizeInBytes === 3) {
|
||||
return this.littleEndian ? 'pcm-s24' : 'pcm-s24be';
|
||||
} else if (this.audioInfo.sampleSizeInBytes === 4) {
|
||||
return this.littleEndian ? 'pcm-s32' : 'pcm-s32be';
|
||||
}
|
||||
}
|
||||
if (this.audioInfo.format === WaveFormat.IEEE_FLOAT) {
|
||||
if (this.audioInfo.sampleSizeInBytes === 4) {
|
||||
return this.littleEndian ? 'pcm-f32' : 'pcm-f32be';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
return 'audio/wav';
|
||||
}
|
||||
|
||||
async computeDuration() {
|
||||
await this.readMetadata();
|
||||
assert(this.audioInfo);
|
||||
|
||||
const numberOfBlocks = this.dataSize / this.audioInfo.blockSizeInBytes;
|
||||
return numberOfBlocks / this.audioInfo.sampleRate;
|
||||
}
|
||||
|
||||
async getTracks() {
|
||||
await this.readMetadata();
|
||||
return this.tracks;
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLE_SIZE_IN_FRAMES = 2048;
|
||||
|
||||
class WaveAudioTrackBacking implements InputAudioTrackBacking {
|
||||
constructor(public demuxer: WaveDemuxer) {}
|
||||
|
||||
async getCodec() {
|
||||
return this.demuxer.getCodec();
|
||||
}
|
||||
|
||||
async getDecoderConfig(): Promise<AudioDecoderConfig | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
computeDuration() {
|
||||
return this.demuxer.computeDuration();
|
||||
}
|
||||
|
||||
async getNumberOfChannels() {
|
||||
assert(this.demuxer.audioInfo);
|
||||
return this.demuxer.audioInfo.numberOfChannels;
|
||||
}
|
||||
|
||||
async getSampleRate() {
|
||||
assert(this.demuxer.audioInfo);
|
||||
return this.demuxer.audioInfo.sampleRate;
|
||||
}
|
||||
|
||||
async getFirstTimestamp() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async getSampleAtIndex(
|
||||
sampleIndex: number,
|
||||
options: SampleRetrievalOptions,
|
||||
): Promise<EncodedAudioSample | null> {
|
||||
assert(this.demuxer.audioInfo);
|
||||
const startOffset = sampleIndex * SAMPLE_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes;
|
||||
if (startOffset >= this.demuxer.dataSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sizeInBytes = Math.min(
|
||||
SAMPLE_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes,
|
||||
this.demuxer.dataSize - startOffset,
|
||||
);
|
||||
|
||||
let data: Uint8Array;
|
||||
if (options.metadataOnly) {
|
||||
data = PLACEHOLDER_DATA;
|
||||
} else {
|
||||
const sizeOfOneSample = SAMPLE_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes;
|
||||
const chunkSize = Math.ceil(2 ** 19 / sizeOfOneSample) * sizeOfOneSample;
|
||||
const chunkStart = Math.floor(startOffset / chunkSize) * chunkSize;
|
||||
const chunkEnd = chunkStart + chunkSize;
|
||||
|
||||
// Always load large 0.5 MiB chunks instead of just the required sample
|
||||
await this.demuxer.chunkReader.reader.loadRange(
|
||||
this.demuxer.dataStart + chunkStart,
|
||||
this.demuxer.dataStart + chunkEnd,
|
||||
);
|
||||
|
||||
this.demuxer.chunkReader.pos = this.demuxer.dataStart + startOffset;
|
||||
data = this.demuxer.chunkReader.readBytes(sizeInBytes);
|
||||
}
|
||||
|
||||
const timestamp = sampleIndex * SAMPLE_SIZE_IN_FRAMES / this.demuxer.audioInfo.sampleRate;
|
||||
const duration = sizeInBytes / this.demuxer.audioInfo.blockSizeInBytes / this.demuxer.audioInfo.sampleRate;
|
||||
|
||||
return new EncodedAudioSample(
|
||||
data,
|
||||
'key',
|
||||
timestamp,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
getFirstSample(options: SampleRetrievalOptions) {
|
||||
return this.getSampleAtIndex(0, options);
|
||||
}
|
||||
|
||||
getSample(timestamp: number, options: SampleRetrievalOptions) {
|
||||
assert(this.demuxer.audioInfo);
|
||||
const sampleIndex = Math.floor(timestamp * this.demuxer.audioInfo.sampleRate / SAMPLE_SIZE_IN_FRAMES);
|
||||
|
||||
return this.getSampleAtIndex(sampleIndex, options);
|
||||
}
|
||||
|
||||
getNextSample(sample: EncodedAudioSample, options: SampleRetrievalOptions) {
|
||||
assert(this.demuxer.audioInfo);
|
||||
const sampleIndex = Math.round(sample.timestamp * this.demuxer.audioInfo.sampleRate / SAMPLE_SIZE_IN_FRAMES);
|
||||
|
||||
return this.getSampleAtIndex(sampleIndex + 1, options);
|
||||
}
|
||||
|
||||
getKeySample(timestamp: number, options: SampleRetrievalOptions) {
|
||||
return this.getSample(timestamp, options);
|
||||
}
|
||||
|
||||
getNextKeySample(sample: EncodedAudioSample, options: SampleRetrievalOptions) {
|
||||
return this.getNextSample(sample, options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user