Created
August 26, 2025 16:52
-
-
Save ForbesLindesay/c667eebfd1839a76bcfc59d3a6b3e815 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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