feat: publish av-tools 0.1.0
This commit is contained in:
329
tests/unit/waveform-runtime-aggregation.test.ts
Normal file
329
tests/unit/waveform-runtime-aggregation.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user