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, })); };