diff --git a/app/pages/live-atc.vue b/app/pages/live-atc.vue index afa946d..b753aa7 100644 --- a/app/pages/live-atc.vue +++ b/app/pages/live-atc.vue @@ -1525,6 +1525,7 @@ import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils import { createAtisAudioLoop, type AtisAudioLoop } from '../../shared/utils/atisAudioLoop' import { normalizeManualFreq } from '../../shared/utils/frequency' import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry' +import { encodeWav } from '../../shared/utils/wavEncoder' import type { CandidateTraceElimination, CandidateTraceEntry, @@ -4037,35 +4038,6 @@ const snapshotPrerecPCM = (): Float32Array => { return out } -const encodeWav = (pcm: Float32Array, sampleRate: number): Blob => { - const length = pcm.length - const buffer = new ArrayBuffer(44 + length * 2) - const view = new DataView(buffer) - const writeStr = (off: number, s: string) => { - for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)) - } - writeStr(0, 'RIFF') - view.setUint32(4, 36 + length * 2, true) - writeStr(8, 'WAVE') - writeStr(12, 'fmt ') - view.setUint32(16, 16, true) - view.setUint16(20, 1, true) - view.setUint16(22, 1, true) - view.setUint32(24, sampleRate, true) - view.setUint32(28, sampleRate * 2, true) - view.setUint16(32, 2, true) - view.setUint16(34, 16, true) - writeStr(36, 'data') - view.setUint32(40, length * 2, true) - let off = 44 - for (let i = 0; i < length; i++) { - const s = Math.max(-1, Math.min(1, pcm[i])) - view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true) - off += 2 - } - return new Blob([buffer], { type: 'audio/wav' }) -} - const stopPrerecCapture = () => { prerecLiveCapture = false prerecLiveChunks = [] diff --git a/shared/utils/wavEncoder.ts b/shared/utils/wavEncoder.ts new file mode 100644 index 0000000..43b7445 --- /dev/null +++ b/shared/utils/wavEncoder.ts @@ -0,0 +1,28 @@ +export const encodeWav = (pcm: Float32Array, sampleRate: number): Blob => { + const length = pcm.length + const buffer = new ArrayBuffer(44 + length * 2) + const view = new DataView(buffer) + const writeStr = (off: number, s: string) => { + for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)) + } + writeStr(0, 'RIFF') + view.setUint32(4, 36 + length * 2, true) + writeStr(8, 'WAVE') + writeStr(12, 'fmt ') + view.setUint32(16, 16, true) + view.setUint16(20, 1, true) + view.setUint16(22, 1, true) + view.setUint32(24, sampleRate, true) + view.setUint32(28, sampleRate * 2, true) + view.setUint16(32, 2, true) + view.setUint16(34, 16, true) + writeStr(36, 'data') + view.setUint32(40, length * 2, true) + let off = 44 + for (let i = 0; i < length; i++) { + const s = Math.max(-1, Math.min(1, pcm[i])) + view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true) + off += 2 + } + return new Blob([buffer], { type: 'audio/wav' }) +} diff --git a/tests/shared/wavEncoder.test.ts b/tests/shared/wavEncoder.test.ts new file mode 100644 index 0000000..b1dfc13 --- /dev/null +++ b/tests/shared/wavEncoder.test.ts @@ -0,0 +1,36 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { encodeWav } from '../../shared/utils/wavEncoder.ts' + +test('encodeWav produces a valid RIFF/WAVE header', async () => { + const pcm = new Float32Array([0, 0.5, -0.5, 1, -1]) + const blob = encodeWav(pcm, 16000) + const buf = new Uint8Array(await blob.arrayBuffer()) + const ascii = (start: number, len: number) => + String.fromCharCode(...buf.slice(start, start + len)) + assert.equal(ascii(0, 4), 'RIFF') + assert.equal(ascii(8, 4), 'WAVE') + assert.equal(ascii(12, 4), 'fmt ') + // sample rate is little-endian at byte offset 24 + const view = new DataView(buf.buffer) + assert.equal(view.getUint32(24, true), 16000) +}) + +test('encodeWav clamps out-of-range samples instead of wrapping', async () => { + const pcm = new Float32Array([2, -2]) + const blob = encodeWav(pcm, 16000) + const buf = new Uint8Array(await blob.arrayBuffer()) + const view = new DataView(buf.buffer) + // 16-bit PCM data starts at byte 44 in a standard WAV header + assert.equal(view.getInt16(44, true), 32767) + assert.equal(view.getInt16(46, true), -32768) +}) + +test('encodeWav sets the correct data chunk size', async () => { + const pcm = new Float32Array(10) + const blob = encodeWav(pcm, 8000) + const buf = new Uint8Array(await blob.arrayBuffer()) + assert.equal(buf.length, 44 + 20) + const view = new DataView(buf.buffer) + assert.equal(view.getUint32(40, true), 20) +})