mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
import { expect, test } from 'vitest';
|
|
import path from 'node:path';
|
|
import { Input } from '../../src/input.js';
|
|
import { BufferSource, FilePathSource } from '../../src/source.js';
|
|
import { ALL_FORMATS } from '../../src/input-format.js';
|
|
import { Output } from '../../src/output.js';
|
|
import { MkvOutputFormat, Mp4OutputFormat, MpegTsOutputFormat, OutputFormat } from '../../src/output-format.js';
|
|
import { BufferTarget } from '../../src/target.js';
|
|
import { Conversion } from '../../src/conversion.js';
|
|
import { EncodedPacketSink } from '../../src/media-sink.js';
|
|
import { EncodedPacket } from '../../src/packet.js';
|
|
import { assert, uint8ArraysAreEqual } from '../../src/misc.js';
|
|
import { AudioSampleSink } from '../../src/media-sink.js';
|
|
import { AudioSampleSource } from '../../src/media-source.js';
|
|
import { AudioSample } from '../../src/sample.js';
|
|
import { canEncode, Quality } from '../../src/encode.js';
|
|
import { registerDtsDecoder, registerDtsEncoder } from '@mediabunny/dts';
|
|
|
|
const __dirname = new URL('.', import.meta.url).pathname;
|
|
|
|
const DTSC_FILE = 'toothsome-dts.mp4';
|
|
const ESDS_FILE = 'toothsome-dts-esds.mp4';
|
|
|
|
const MP4_TIMESTAMP_TOLERANCE = 0;
|
|
const MATROSKA_TIMESTAMP_TOLERANCE = 1 / 1000;
|
|
const MPEG_TS_TIMESTAMP_TOLERANCE = 1 / 90_000;
|
|
|
|
test('Read from MP4 with a dtsc sample entry', async () => {
|
|
using input = new Input({
|
|
source: new FilePathSource(path.join(__dirname, '..', 'public', DTSC_FILE)),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const track = await input.getPrimaryAudioTrack();
|
|
assert(track);
|
|
|
|
const decoderConfig = await track.getDecoderConfig();
|
|
assert(decoderConfig);
|
|
|
|
expect(await track.getCodec()).toBe('dts');
|
|
expect(decoderConfig.codec).toBe('dtsc');
|
|
expect(decoderConfig.description).toBeUndefined();
|
|
|
|
// This file declares 48000 in its sample entry, which the ddts box then corrects to the real rate
|
|
expect(await track.getSampleRate()).toBe(24000);
|
|
expect(await track.getNumberOfChannels()).toBe(2);
|
|
|
|
await expectStreamShape(input);
|
|
});
|
|
|
|
test('Read from MP4 with an esds sample entry', async () => {
|
|
using input = new Input({
|
|
source: new FilePathSource(path.join(__dirname, '..', 'public', ESDS_FILE)),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const track = await input.getPrimaryAudioTrack();
|
|
assert(track);
|
|
|
|
const decoderConfig = await track.getDecoderConfig();
|
|
assert(decoderConfig);
|
|
|
|
expect(await track.getCodec()).toBe('dts');
|
|
expect(decoderConfig.codec).toBe('dtsc');
|
|
expect(decoderConfig.description).toBeUndefined();
|
|
|
|
expect(await track.getSampleRate()).toBe(24000);
|
|
expect(await track.getNumberOfChannels()).toBe(2);
|
|
|
|
await expectStreamShape(input);
|
|
});
|
|
|
|
test('Transmux dtsc MP4 into MP4', async () => {
|
|
await expectTransmuxToPreservePackets(DTSC_FILE, new Mp4OutputFormat(), MP4_TIMESTAMP_TOLERANCE);
|
|
});
|
|
|
|
test('Transmux dtsc MP4 into Matroska', async () => {
|
|
await expectTransmuxToPreservePackets(DTSC_FILE, new MkvOutputFormat(), MATROSKA_TIMESTAMP_TOLERANCE);
|
|
});
|
|
|
|
test('Transmux dtsc MP4 into MPEG-TS', async () => {
|
|
await expectTransmuxToPreservePackets(DTSC_FILE, new MpegTsOutputFormat(), MPEG_TS_TIMESTAMP_TOLERANCE);
|
|
});
|
|
|
|
test('Transmux esds MP4 into MP4', async () => {
|
|
await expectTransmuxToPreservePackets(ESDS_FILE, new Mp4OutputFormat(), MP4_TIMESTAMP_TOLERANCE);
|
|
});
|
|
|
|
test('Transmux esds MP4 into Matroska', async () => {
|
|
await expectTransmuxToPreservePackets(ESDS_FILE, new MkvOutputFormat(), MATROSKA_TIMESTAMP_TOLERANCE);
|
|
});
|
|
|
|
test('Transmux esds MP4 into MPEG-TS', async () => {
|
|
await expectTransmuxToPreservePackets(ESDS_FILE, new MpegTsOutputFormat(), MPEG_TS_TIMESTAMP_TOLERANCE);
|
|
});
|
|
|
|
test('Custom coder registration', async () => {
|
|
using input = new Input({
|
|
source: new FilePathSource(path.join(__dirname, '..', 'public', DTSC_FILE)),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const track = await input.getPrimaryAudioTrack();
|
|
assert(track);
|
|
|
|
expect(await track.canDecode()).toBe(false);
|
|
expect(await canEncode('dts')).toBe(false);
|
|
|
|
registerDtsDecoder();
|
|
registerDtsEncoder();
|
|
|
|
expect(await track.canDecode()).toBe(true);
|
|
expect(await canEncode('dts')).toBe(true);
|
|
});
|
|
|
|
test('Decode with the extension', async () => {
|
|
registerDtsDecoder();
|
|
|
|
using input = new Input({
|
|
source: new FilePathSource(path.join(__dirname, '..', 'public', DTSC_FILE)),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const track = await input.getPrimaryAudioTrack();
|
|
assert(track);
|
|
|
|
const { packetCount } = await track.computePacketStats();
|
|
const sink = new AudioSampleSink(track);
|
|
|
|
let sampleCount = 0;
|
|
let nextTimestamp = 0;
|
|
|
|
for await (using sample of sink.samples()) {
|
|
expect(sample.timestamp).toBeCloseTo(nextTimestamp);
|
|
expect(sample.duration).toBeCloseTo(512 / 24000);
|
|
expect(sample.format).toBe('f32-planar');
|
|
expect(sample.numberOfChannels).toBe(2);
|
|
expect(sample.sampleRate).toBe(24000);
|
|
|
|
nextTimestamp += sample.duration;
|
|
sampleCount++;
|
|
}
|
|
|
|
expect(sampleCount).toBe(packetCount);
|
|
});
|
|
|
|
test('Encode with the extension', async () => {
|
|
registerDtsEncoder();
|
|
|
|
const output = await encodeSineWaveToMp4(0, new Mp4OutputFormat());
|
|
|
|
using input = new Input({
|
|
source: new BufferSource(output),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const track = await input.getPrimaryAudioTrack();
|
|
assert(track);
|
|
|
|
expect(await track.getCodec()).toBe('dts');
|
|
expect(await track.getSampleRate()).toBe(ENCODE_SAMPLE_RATE);
|
|
expect(await track.getNumberOfChannels()).toBe(ENCODE_CHANNELS);
|
|
|
|
const decoderConfig = await track.getDecoderConfig();
|
|
assert(decoderConfig);
|
|
expect(decoderConfig.codec).toBe('dtsc');
|
|
expect(decoderConfig.description).toBeUndefined();
|
|
|
|
const sink = new EncodedPacketSink(track);
|
|
let packetCount = 0;
|
|
|
|
for await (const packet of sink.packets()) {
|
|
expect(packet.type).toBe('key');
|
|
packetCount++;
|
|
}
|
|
|
|
expect(packetCount).toBeGreaterThan(0);
|
|
expect(await track.computeDuration()).toBeCloseTo(ENCODE_DURATION, 1);
|
|
});
|
|
|
|
test('Round-trip through the extension', async () => {
|
|
registerDtsDecoder();
|
|
registerDtsEncoder();
|
|
|
|
const output = await encodeSineWaveToMp4(0, new Mp4OutputFormat());
|
|
|
|
using input = new Input({
|
|
source: new BufferSource(output),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const track = await input.getPrimaryAudioTrack();
|
|
assert(track);
|
|
|
|
const sink = new AudioSampleSink(track);
|
|
const chunks: Float32Array[] = [];
|
|
let decodedFrames = 0;
|
|
|
|
for await (using sample of sink.samples()) {
|
|
expect(sample.numberOfChannels).toBe(ENCODE_CHANNELS);
|
|
expect(sample.sampleRate).toBe(ENCODE_SAMPLE_RATE);
|
|
decodedFrames += sample.numberOfFrames;
|
|
|
|
const chunk = new Float32Array(
|
|
new ArrayBuffer(sample.allocationSize({ format: 'f32-planar', planeIndex: 0 })),
|
|
);
|
|
sample.copyTo(chunk, { format: 'f32-planar', planeIndex: 0 });
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
expect(decodedFrames).toBeGreaterThanOrEqual(ENCODE_SAMPLE_RATE * ENCODE_DURATION);
|
|
|
|
const signal = new Float32Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0));
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
signal.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
|
|
expect(sine440Score(signal, ENCODE_SAMPLE_RATE, 440)).toBeGreaterThan(0.98);
|
|
});
|
|
|
|
test('Encode with huge timestamps', async () => {
|
|
registerDtsDecoder();
|
|
registerDtsEncoder();
|
|
|
|
const timestamp = 1e9;
|
|
const output = await encodeSineWaveToMp4(timestamp, new Mp4OutputFormat({ fastStart: 'fragmented' }));
|
|
|
|
using input = new Input({
|
|
source: new BufferSource(output),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const track = await input.getPrimaryAudioTrack();
|
|
assert(track);
|
|
|
|
const packetSink = new EncodedPacketSink(track);
|
|
const firstPacket = await packetSink.getFirstPacket();
|
|
assert(firstPacket);
|
|
|
|
expect(firstPacket.timestamp).toBe(timestamp);
|
|
|
|
const sampleSink = new AudioSampleSink(track);
|
|
const firstSample = (await sampleSink.samples(timestamp).next()).value;
|
|
assert(firstSample);
|
|
|
|
expect(firstSample.timestamp).toBe(timestamp);
|
|
});
|
|
|
|
const expectStreamShape = async (input: Input) => {
|
|
const packets = await readAllPackets(input);
|
|
|
|
expect(packets.length).toBe(1805);
|
|
expect(packets.every(packet => packet.type === 'key')).toBe(true);
|
|
expect(packets.every(packet => packet.data.byteLength === 512)).toBe(true);
|
|
};
|
|
|
|
/** Converts the file without re-encoding and checks that every packet comes back out unchanged. */
|
|
const expectTransmuxToPreservePackets = async (
|
|
fileName: string,
|
|
outputFormat: OutputFormat,
|
|
timestampTolerance: number,
|
|
) => {
|
|
using originalInput = new Input({
|
|
source: new FilePathSource(path.join(__dirname, '..', 'public', fileName)),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const originalPackets = await readAllPackets(originalInput);
|
|
|
|
const output = new Output({
|
|
format: outputFormat,
|
|
target: new BufferTarget(),
|
|
});
|
|
|
|
const conversion = await Conversion.init({ input: originalInput, output });
|
|
await conversion.execute();
|
|
|
|
const { buffer } = output.target;
|
|
assert(buffer);
|
|
|
|
using newInput = new Input({
|
|
source: new BufferSource(buffer),
|
|
formats: ALL_FORMATS,
|
|
});
|
|
|
|
const newTrack = await newInput.getPrimaryAudioTrack();
|
|
assert(newTrack);
|
|
|
|
const newDecoderConfig = await newTrack.getDecoderConfig();
|
|
assert(newDecoderConfig);
|
|
|
|
expect(await newTrack.getCodec()).toBe('dts');
|
|
expect(await newTrack.getSampleRate()).toBe(24000);
|
|
expect(await newTrack.getNumberOfChannels()).toBe(2);
|
|
|
|
// Only the MP4 sample entry can carry this, so for the other formats it comes back out of the bitstream
|
|
expect(newDecoderConfig.codec).toBe('dtsc');
|
|
|
|
const newPackets = await readAllPackets(newInput);
|
|
expectPacketsToMatch(newPackets, originalPackets, timestampTolerance);
|
|
};
|
|
|
|
const expectPacketsToMatch = (actual: EncodedPacket[], expected: EncodedPacket[], timestampTolerance: number) => {
|
|
expect(actual.length).toBe(expected.length);
|
|
|
|
for (let i = 0; i < expected.length; i++) {
|
|
const actualPacket = actual[i]!;
|
|
const expectedPacket = expected[i]!;
|
|
|
|
expect(actualPacket.type).toBe(expectedPacket.type);
|
|
expect(uint8ArraysAreEqual(actualPacket.data, expectedPacket.data)).toBe(true);
|
|
expect(Math.abs(actualPacket.timestamp - expectedPacket.timestamp)).toBeLessThanOrEqual(timestampTolerance);
|
|
}
|
|
};
|
|
|
|
const readAllPackets = async (input: Input) => {
|
|
const track = await input.getPrimaryAudioTrack();
|
|
assert(track);
|
|
|
|
const sink = new EncodedPacketSink(track);
|
|
const packets: EncodedPacket[] = [];
|
|
|
|
for await (const packet of sink.packets()) {
|
|
packets.push(packet);
|
|
}
|
|
|
|
return packets;
|
|
};
|
|
|
|
const ENCODE_SAMPLE_RATE = 48000;
|
|
const ENCODE_CHANNELS = 2;
|
|
const ENCODE_DURATION = 2;
|
|
const ENCODE_BITRATE = 768000;
|
|
|
|
const encodeSineWaveToMp4 = async (startTimestamp: number, format: Mp4OutputFormat) => {
|
|
const totalFrames = ENCODE_SAMPLE_RATE * ENCODE_DURATION;
|
|
const data = new Float32Array(totalFrames * ENCODE_CHANNELS);
|
|
|
|
for (let i = 0; i < totalFrames; i++) {
|
|
const value = Math.sin(2 * Math.PI * 440 * i / ENCODE_SAMPLE_RATE);
|
|
for (let channel = 0; channel < ENCODE_CHANNELS; channel++) {
|
|
data[i * ENCODE_CHANNELS + channel] = value;
|
|
}
|
|
}
|
|
|
|
const output = new Output({ format, target: new BufferTarget() });
|
|
const source = new AudioSampleSource({ codec: 'dts', quality: new Quality({ bitrate: ENCODE_BITRATE }) });
|
|
output.addAudioTrack(source);
|
|
|
|
await output.start();
|
|
|
|
const sample = new AudioSample({
|
|
data,
|
|
format: 'f32',
|
|
numberOfChannels: ENCODE_CHANNELS,
|
|
sampleRate: ENCODE_SAMPLE_RATE,
|
|
timestamp: startTimestamp,
|
|
});
|
|
await source.add(sample);
|
|
sample.close();
|
|
source.close();
|
|
|
|
await output.finalize();
|
|
|
|
const { buffer } = output.target;
|
|
assert(buffer);
|
|
|
|
return buffer;
|
|
};
|
|
|
|
/** Returns the fraction of the signal's energy explained by a pure sine at `freq`. */
|
|
const sine440Score = (x: Float32Array, sampleRate: number, freq: number) => {
|
|
const w = 2 * Math.PI * freq / sampleRate;
|
|
|
|
let ss = 0, cc = 0, sc = 0;
|
|
let xs = 0, xc = 0;
|
|
let xx = 0;
|
|
|
|
for (let n = 0; n < x.length; n++) {
|
|
const s = Math.sin(w * n);
|
|
const c = Math.cos(w * n);
|
|
|
|
ss += s * s;
|
|
cc += c * c;
|
|
sc += s * c;
|
|
|
|
xs += x[n]! * s;
|
|
xc += x[n]! * c;
|
|
xx += x[n]! * x[n]!;
|
|
}
|
|
|
|
const det = ss * cc - sc * sc;
|
|
|
|
const a = (xs * cc - xc * sc) / det;
|
|
const b = (xc * ss - xs * sc) / det;
|
|
|
|
let fitEnergy = 0;
|
|
|
|
for (let n = 0; n < x.length; n++) {
|
|
const y = a * Math.sin(w * n) + b * Math.cos(w * n);
|
|
fitEnergy += y * y;
|
|
}
|
|
|
|
return fitEnergy / xx;
|
|
};
|