Files
av-tools/tests/unit/waveform-runtime-aggregation.test.ts

330 lines
9.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
aggregatePcmPeaks,
decodePcmSamples,
MAX_WAVEFORM_BUCKET_COUNT,
WaveformCancellationError,
WaveformDataError,
} from '../../src/waveform';
describe('waveform PCM aggregation', () => {
it('uses deterministic bucket boundaries for signed 16-bit PCM', () => {
const samples = new Int16Array([
-32_768, -16_384, 0, 16_384, 32_767, 8_192, -8_192, 0,
]);
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: samples,
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 4 }
);
expect(peaks.sampleCount).toBe(8);
expect(peaks.bucketCount).toBe(4);
expect([...peaks.minimum]).toEqual([-1, 0, 0.25, -0.25]);
expect([...peaks.maximum]).toEqual([-0.5, 0.5, 32_767 / 32_768, 0]);
expect([...peaks.rms]).toEqual(
expect.arrayContaining([
expect.closeTo(Math.sqrt(0.625), 6),
expect.closeTo(Math.sqrt(0.125), 6),
expect.closeTo(Math.sqrt(((32_767 / 32_768) ** 2 + 0.25 ** 2) / 2), 6),
expect.closeTo(Math.sqrt(0.03125), 6),
])
);
});
it('never creates empty buckets when fewer samples than requested', () => {
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: float32Bytes([0.1, -0.2, 0.3]),
encoding: 'f32le',
sampleRate: 10,
},
{ bucketCount: 100 }
);
expect(peaks.bucketCount).toBe(3);
expect([...peaks.minimum]).toEqual([
expect.closeTo(0.1, 6),
expect.closeTo(-0.2, 6),
expect.closeTo(0.3, 6),
]);
expect([...peaks.maximum]).toEqual([...peaks.minimum]);
});
it('assigns every sample exactly once when bucket sizes are uneven', () => {
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([
1_000, 2_000, 3_000, -4_000, -5_000, -6_000, 7_000, 8_000, 9_000,
10_000,
]),
encoding: 's16le',
sampleRate: 10,
},
{ bucketCount: 3 }
);
expect([...peaks.minimum]).toEqual([
1_000 / 32_768,
-6_000 / 32_768,
7_000 / 32_768,
]);
expect([...peaks.maximum]).toEqual([
3_000 / 32_768,
-4_000 / 32_768,
10_000 / 32_768,
]);
});
it('handles finite float PCM above full scale without RMS overflow', () => {
const maximumFloat = 3.4028234663852886e38;
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: float32Bytes([maximumFloat, -maximumFloat]),
encoding: 'f32le',
sampleRate: 8_000,
},
{ bucketCount: 1 }
);
expect(peaks.minimum[0]).toBe(-maximumFloat);
expect(peaks.maximum[0]).toBe(maximumFloat);
expect(peaks.rms[0]).toBe(maximumFloat);
expect(Number.isFinite(peaks.rms[0])).toBe(true);
});
it('rejects malformed, non-finite, and resource-exceeding PCM', () => {
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: new Uint8Array([0]),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 1 }
)
).toThrow(WaveformDataError);
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: float32Bytes([Number.NaN]),
encoding: 'f32le',
sampleRate: 8_000,
},
{ bucketCount: 1 }
)
).toThrow(/not finite/u);
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([1, 2, 3]),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 1, maxSamples: 2 }
)
).toThrow(/sample analysis limit/u);
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: new Int16Array([1]),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: MAX_WAVEFORM_BUCKET_COUNT + 1 }
)
).toThrow(RangeError);
});
it('parses FFmpeg-style mono RIFF/WAVE PCM and rejects stereo', () => {
const wav = createWaveFile({
samples: new Int16Array([-32_768, 0, 16_384, 32_767]),
sampleRate: 8_000,
channels: 1,
addOddJunkChunk: true,
});
const decoded = decodePcmSamples({ container: 'wav', data: wav });
expect(decoded.encoding).toBe('s16le');
expect(decoded.sampleRate).toBe(8_000);
expect([...decoded.samples]).toEqual([-1, 0, 0.5, 32_767 / 32_768]);
const stereo = createWaveFile({
samples: new Int16Array([1, 2]),
sampleRate: 8_000,
channels: 2,
});
expect(() => decodePcmSamples({ container: 'wav', data: stereo })).toThrow(
/requires mono/u
);
});
it('parses little-endian float PCM from a sliced WAVE buffer', () => {
const wav = createFloatWaveFile([1.25, -0.5], 16_000);
const enclosing = new Uint8Array(wav.byteLength + 6);
enclosing.set(wav, 3);
const sliced = enclosing.subarray(3, 3 + wav.byteLength);
const decoded = decodePcmSamples({ container: 'wav', data: sliced });
expect(decoded.encoding).toBe('f32le');
expect(decoded.sampleRate).toBe(16_000);
expect([...decoded.samples]).toEqual([1.25, -0.5]);
});
it('rejects truncated WAVE chunks', () => {
const wav = createWaveFile({
samples: new Int16Array([1, 2]),
sampleRate: 8_000,
channels: 1,
});
expect(() =>
decodePcmSamples({
container: 'wav',
data: wav.subarray(0, wav.byteLength - 1),
})
).toThrow(/truncated|invalid size/u);
});
it('supports cooperative cancellation and bounded progress callbacks', () => {
const pcm = new Int16Array(40_000);
let cancelled = false;
const progress: number[] = [];
expect(() =>
aggregatePcmPeaks(
{
container: 'raw',
data: pcm,
encoding: 's16le',
sampleRate: 8_000,
},
{
bucketCount: 100,
isCancelled: () => cancelled,
onProgress: (value) => {
progress.push(value);
cancelled = true;
},
}
)
).toThrow(WaveformCancellationError);
expect(progress[0]).toBe(0);
expect(progress.every((value) => value >= 0 && value <= 1)).toBe(true);
});
it('reports completion for empty PCM without allocating peak buckets', () => {
const progress: number[] = [];
const peaks = aggregatePcmPeaks(
{
container: 'raw',
data: new Uint8Array(),
encoding: 's16le',
sampleRate: 8_000,
},
{ bucketCount: 10, onProgress: (value) => progress.push(value) }
);
expect(peaks.bucketCount).toBe(0);
expect(peaks.durationSeconds).toBe(0);
expect(progress).toEqual([1]);
});
});
function float32Bytes(values: readonly number[]): Uint8Array {
const bytes = new Uint8Array(values.length * 4);
const view = new DataView(bytes.buffer);
values.forEach((value, index) => view.setFloat32(index * 4, value, true));
return bytes;
}
function createWaveFile(options: {
readonly samples: Int16Array;
readonly sampleRate: number;
readonly channels: number;
readonly addOddJunkChunk?: boolean;
}): Uint8Array {
const junkBytes = options.addOddJunkChunk ? 10 : 0;
const result = new Uint8Array(44 + junkBytes + options.samples.byteLength);
const view = new DataView(result.buffer);
writeAscii(result, 0, 'RIFF');
view.setUint32(4, result.byteLength - 8, true);
writeAscii(result, 8, 'WAVE');
writeAscii(result, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, options.channels, true);
view.setUint32(24, options.sampleRate, true);
view.setUint32(
28,
options.sampleRate * options.channels * Int16Array.BYTES_PER_ELEMENT,
true
);
view.setUint16(32, options.channels * Int16Array.BYTES_PER_ELEMENT, true);
view.setUint16(34, 16, true);
let dataHeaderOffset = 36;
if (options.addOddJunkChunk) {
writeAscii(result, 36, 'JUNK');
view.setUint32(40, 1, true);
result[44] = 0x2a;
result[45] = 0;
dataHeaderOffset += junkBytes;
}
writeAscii(result, dataHeaderOffset, 'data');
view.setUint32(dataHeaderOffset + 4, options.samples.byteLength, true);
const sampleOffset = dataHeaderOffset + 8;
for (let index = 0; index < options.samples.length; index += 1) {
view.setInt16(
sampleOffset + index * Int16Array.BYTES_PER_ELEMENT,
options.samples[index] ?? 0,
true
);
}
return result;
}
function createFloatWaveFile(
samples: readonly number[],
sampleRate: number
): Uint8Array {
const result = new Uint8Array(44 + samples.length * 4);
const view = new DataView(result.buffer);
writeAscii(result, 0, 'RIFF');
view.setUint32(4, result.byteLength - 8, true);
writeAscii(result, 8, 'WAVE');
writeAscii(result, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 3, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 4, true);
view.setUint16(32, 4, true);
view.setUint16(34, 32, true);
writeAscii(result, 36, 'data');
view.setUint32(40, samples.length * 4, true);
samples.forEach((sample, index) =>
view.setFloat32(44 + index * 4, sample, true)
);
return result;
}
function writeAscii(target: Uint8Array, offset: number, text: string): void {
for (let index = 0; index < text.length; index += 1) {
target[offset + index] = text.charCodeAt(index);
}
}