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