330 lines
9.3 KiB
TypeScript
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);
|
|
}
|
|
}
|