Skip to content

Instantly share code, notes, and snippets.

@ForbesLindesay
Created August 26, 2025 16:52
Show Gist options
  • Select an option

  • Save ForbesLindesay/c667eebfd1839a76bcfc59d3a6b3e815 to your computer and use it in GitHub Desktop.

Select an option

Save ForbesLindesay/c667eebfd1839a76bcfc59d3a6b3e815 to your computer and use it in GitHub Desktop.
import { TransformStream } from "stream/web"
// Based on https://github.com/jremi/twilio-media-stream-save-audio-file/blob/69141d4ca4de3807dae5282ce5e600b110592f40/index.js
// and on https://en.wikipedia.org/wiki/WAV#WAV_file_header
const textEncoder = new TextEncoder()
const text = (input: string) => textEncoder.encode(input)
const int16 = (value: number) => new Uint8Array([value & 0xff, (value >> 8) & 0xff])
const int32 = (value: number) =>
new Uint8Array([
value & 0xff,
(value >> 8) & 0xff,
(value >> 16) & 0xff,
(value >> 24) & 0xff,
])
interface WavHeaderInput {
numberOfChannels: number
sampleRateHz: number
bitsPerSample: number
dataLength: number
}
const DEFAULT_WAV_HEADER_INPUT: WavHeaderInput = {
numberOfChannels: 1,
sampleRateHz: 8_000,
bitsPerSample: 8,
dataLength: Infinity,
}
function saturateInt32(value: number) {
return value < 0 ? 0 : value > 0xffffffff ? 0xffffffff : value
}
function getWavHeader(options: WavHeaderInput, headerByteLength?: number): Uint8Array {
const resolvedHeaderByteLength = headerByteLength ?? getWavHeader(options, 0).length
return new Uint8Array([
// [Master RIFF chunk]
// 1 - 4 = "RIFF"
...text("RIFF"),
// 5 - 8 = File size - Overall file size minus 8 bytes
...int32(saturateInt32(options.dataLength + resolvedHeaderByteLength - 8)),
// 9 - 12 = "WAVE"
...text("WAVE"),
// [Chunk describing the data format]
// 13 - 16 = "fmt "
...text("fmt "),
// 17 - 20 = Chunk size minus 8 bytes, which is 16 bytes here (0x10)
...int32(16),
// 21 - 22 = Audio format (1: PCM integer, 3: IEEE 754 float()
...int16(7),
// 23 - 24 = Number of channels
...int16(options.numberOfChannels),
// 25 - 28 = Sample rate (in hertz)
...int32(options.sampleRateHz),
// 29 - 32 = Number of bytes to read per second = (Sample Rate * BitsPerSample * Channels) / 8 = (Frequency * BytePerBloc)
...int32(
(options.sampleRateHz * options.bitsPerSample * options.numberOfChannels) / 8
),
// 33 - 34 = BytePerBloc = (NbrChannels * BitsPerSample / 8).
...int16((options.numberOfChannels * options.bitsPerSample) / 8),
// 35 - 36 = Bits per sample
...int16(options.bitsPerSample),
// [Data chunk]
// 37 - 40 = "data"
...text("data"),
// 41 - 44 = Size of the data section.
...int32(saturateInt32(options.dataLength)),
])
}
export default class WavFileEncoderStream extends TransformStream<
Uint8Array,
Uint8Array
> {
constructor(options: Partial<WavHeaderInput> = {}) {
super({
start(controller) {
controller.enqueue(
getWavHeader({
...DEFAULT_WAV_HEADER_INPUT,
...options,
})
)
},
transform(chunk, controller) {
controller.enqueue(chunk)
},
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment