Files
mediabunny/test/browser/audio-samples.test.ts

245 lines
7.4 KiB
TypeScript

import { expect, test } from 'vitest';
import { AudioSample } from '../../src/sample.js';
const SAMPLE_RATE = 48000;
const NUM_CHANNELS = 2;
const NUM_FRAMES = 10;
const TIMESTAMP = 1;
const cellValue = (frame: number, channel: number) => frame * 10 + channel;
const makeInterleavedSample = () => {
const data = new Int16Array(NUM_FRAMES * NUM_CHANNELS);
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
data[f * NUM_CHANNELS + c] = cellValue(f, c);
}
}
return new AudioSample({
data,
format: 's16',
sampleRate: SAMPLE_RATE,
numberOfChannels: NUM_CHANNELS,
timestamp: TIMESTAMP,
});
};
const makePlanarSample = () => {
const data = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
for (let c = 0; c < NUM_CHANNELS; c++) {
for (let f = 0; f < NUM_FRAMES; f++) {
data[c * NUM_FRAMES + f] = cellValue(f, c);
}
}
return new AudioSample({
data,
format: 'f32-planar',
sampleRate: SAMPLE_RATE,
numberOfChannels: NUM_CHANNELS,
timestamp: TIMESTAMP,
});
};
const readCell = (sample: AudioSample, frame: number, channel: number) => {
if (sample.format === 's16') {
const out = new Int16Array(sample.numberOfFrames * sample.numberOfChannels);
sample.copyTo(out, { planeIndex: 0, format: 's16' });
return out[frame * sample.numberOfChannels + channel];
} else {
const out = new Float32Array(sample.numberOfFrames);
sample.copyTo(out, { planeIndex: channel, format: 'f32-planar' });
return out[frame];
}
};
for (const [label, makeSample] of [
['interleaved', makeInterleavedSample],
['planar', makePlanarSample],
] as const) {
test(`trim, normal case (${label})`, () => {
using sample = makeSample();
using trimmed = sample.trim(3, 8);
expect(trimmed).not.toBe(sample);
expect(trimmed.format).toBe(sample.format);
expect(trimmed.sampleRate).toBe(SAMPLE_RATE);
expect(trimmed.numberOfChannels).toBe(NUM_CHANNELS);
expect(trimmed.numberOfFrames).toBe(5);
expect(trimmed.duration).toBeCloseTo(5 / SAMPLE_RATE);
expect(trimmed.timestamp).toBeCloseTo(TIMESTAMP + 3 / SAMPLE_RATE);
// The trimmed frame i corresponds to the original frame i + 3
for (let f = 0; f < trimmed.numberOfFrames; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
expect(readCell(trimmed, f, c)).toBe(cellValue(f + 3, c));
}
}
// The original sample must be untouched
expect(sample.numberOfFrames).toBe(NUM_FRAMES);
expect(readCell(sample, 3, 0)).toBe(cellValue(3, 0));
});
test(`trim, full range is an independent copy (${label})`, () => {
using sample = makeSample();
using trimmed = sample.trim(0, NUM_FRAMES);
expect(trimmed.numberOfFrames).toBe(NUM_FRAMES);
expect(trimmed.timestamp).toBeCloseTo(TIMESTAMP);
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
expect(readCell(trimmed, f, c)).toBe(cellValue(f, c));
}
}
});
test(`trim, pathological zero-sample case (${label})`, () => {
using sample = makeSample();
// Empty range in the middle
using empty = sample.trim(4, 4);
expect(empty.numberOfFrames).toBe(0);
expect(empty.duration).toBe(0);
expect(empty.format).toBe(sample.format);
expect(empty.numberOfChannels).toBe(NUM_CHANNELS);
expect(empty.timestamp).toBeCloseTo(TIMESTAMP + 4 / SAMPLE_RATE);
// Empty range right at the end (startSample === numberOfFrames) is still valid
using emptyAtEnd = sample.trim(NUM_FRAMES, NUM_FRAMES);
expect(emptyAtEnd.numberOfFrames).toBe(0);
expect(emptyAtEnd.timestamp).toBeCloseTo(TIMESTAMP + NUM_FRAMES / SAMPLE_RATE);
});
test(`trim, illegal params (${label})`, () => {
using sample = makeSample();
// Non-integer
expect(() => sample.trim(1.5, 5)).toThrow(TypeError);
expect(() => sample.trim(1, 5.5)).toThrow(TypeError);
// Negative
expect(() => sample.trim(-1, 5)).toThrow(TypeError);
expect(() => sample.trim(0, -1)).toThrow(TypeError);
// Out of range
expect(() => sample.trim(0, NUM_FRAMES + 1)).toThrow(RangeError);
expect(() => sample.trim(NUM_FRAMES + 1, NUM_FRAMES + 1)).toThrow(RangeError);
// End before start
expect(() => sample.trim(6, 3)).toThrow(RangeError);
});
}
test('trim, throws on a closed sample', () => {
const sample = makeInterleavedSample();
sample.close();
expect(() => sample.trim(0, 5)).toThrow('AudioSample is closed.');
});
test('copyTo, AudioData-backed, planar destination falls back to manual conversion', () => {
withMockedNativeCopyTo((calls) => {
using sample = makeAudioDataBackedSample();
for (let c = 0; c < NUM_CHANNELS; c++) {
const out = new Int16Array(NUM_FRAMES);
sample.copyTo(out, { planeIndex: c, format: 's16-planar' });
for (let f = 0; f < NUM_FRAMES; f++) {
expect(out[f]).toBe(cellValue(f, c));
}
}
// With a frame offset
const out = new Int16Array(NUM_FRAMES - 3);
sample.copyTo(out, { planeIndex: 1, format: 's16-planar', frameOffset: 3 });
for (let f = 0; f < NUM_FRAMES - 3; f++) {
expect(out[f]).toBe(cellValue(f + 3, 1));
}
// The native conversion must have been attempted (and thrown)
expect(calls.some(options => options.format === 's16-planar')).toBe(true);
});
});
test('copyTo, AudioData-backed, interleaved destination falls back to manual conversion', () => {
withMockedNativeCopyTo((calls) => {
using sample = makeAudioDataBackedSample();
const out = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
sample.copyTo(out, { planeIndex: 0, format: 'f32' });
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
expect(out[f * NUM_CHANNELS + c]).toBe(Math.fround(cellValue(f, c) / 32767));
}
}
// The native conversion must have been attempted (and thrown)
expect(calls.some(options => options.format === 'f32')).toBe(true);
});
});
test('copyTo, AudioData-backed, control case with native format conversion', () => {
using sample = makeAudioDataBackedSample();
for (let c = 0; c < NUM_CHANNELS; c++) {
const out = new Int16Array(NUM_FRAMES);
sample.copyTo(out, { planeIndex: c, format: 's16-planar' });
for (let f = 0; f < NUM_FRAMES; f++) {
// Allow off-by-one
expect(Math.abs(out[f]! - cellValue(f, c))).toBeLessThanOrEqual(1);
}
}
const out = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
sample.copyTo(out, { planeIndex: 0, format: 'f32' });
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
expect(out[f * NUM_CHANNELS + c]).toBe(Math.fround(cellValue(f, c) / 32767));
}
}
});
const withMockedNativeCopyTo = (fn: (calls: AudioDataCopyToOptions[]) => void) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const original = AudioData.prototype.copyTo;
const calls: AudioDataCopyToOptions[] = [];
AudioData.prototype.copyTo = function (this: AudioData, destination, options) {
calls.push(options);
if (options.format !== undefined && options.format !== 'f32-planar') {
throw new DOMException('Format conversion not supported.', 'NotSupportedError');
}
return original.call(this, destination, options);
};
try {
fn(calls);
} finally {
AudioData.prototype.copyTo = original;
}
};
const makeAudioDataBackedSample = () => {
const data = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
for (let f = 0; f < NUM_FRAMES; f++) {
for (let c = 0; c < NUM_CHANNELS; c++) {
data[f * NUM_CHANNELS + c] = cellValue(f, c) / 32767;
}
}
return new AudioSample(new AudioData({
format: 'f32',
sampleRate: SAMPLE_RATE,
numberOfFrames: NUM_FRAMES,
numberOfChannels: NUM_CHANNELS,
timestamp: TIMESTAMP * 1e6,
data,
}));
};