Created
November 30, 2021 21:37
-
-
Save sayjeyhi/158555da01f6dcc969caecdbc3e2a938 to your computer and use it in GitHub Desktop.
Create a wav, from raw data
This file contains 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
'use strict'; | |
export default class WAV { | |
private readonly header: ArrayBuffer; | |
private readonly data: Uint8Array; | |
private view: DataView; | |
private pointer: number; | |
private littleEndian: boolean | undefined; | |
static semitone(note = 'REST') { | |
// matches occurrence of A through G | |
// followed by positive or negative integer | |
// followed by 0 to 2 occurrences of flat or sharp | |
const re = /^([A-G])(-?\d+)(b{0,2}|#{0,2})$/; | |
// if semitone is unrecognized, assume REST | |
if (!re.test(note)) { | |
return -Infinity; | |
} | |
// parse substrings of note | |
const [, tone, octave, accidental] = note.match(re) || []; | |
// semitone indexed relative to A4 == 69 for compatibility with MIDI | |
const tones: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }; | |
const octaves: Record<string | number, number> = { | |
'-1': 0, | |
0: 1, | |
1: 2, | |
2: 3, | |
3: 4, | |
4: 5, | |
5: 6, | |
6: 7, | |
7: 8, | |
8: 9, | |
9: 10, | |
10: 11 | |
}; | |
const accidentals: Record<string | number, number> = { | |
bb: -2, | |
b: -1, | |
'': 0, | |
'#': 1, | |
'##': 2 | |
}; | |
// if semitone is unrecognized, assume REST | |
if ( | |
tones[tone] === undefined || | |
octaves[octave] === undefined || | |
accidentals[accidental] === undefined | |
) { | |
return -Infinity; | |
} | |
// return calculated index | |
return tones[tone] + octaves[octave] * 12 + accidentals[accidental]; | |
} | |
static note(semitone = -Infinity) { | |
const octaves = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; | |
const tones = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; | |
const octaveIndex = Math.floor(semitone / 12); | |
const toneIndex = Math.floor(semitone - octaveIndex * 12); | |
const octave = octaves[octaveIndex]; | |
const tone = tones[toneIndex]; | |
// by default assume REST | |
if (octave === undefined || tone === undefined) { | |
return 'REST'; | |
} | |
// tone followed by octave followed by accidental | |
return tone.charAt(0) + octave.toString() + tone.charAt(1); | |
} | |
// converts semitone index to frequency in Hz | |
static frequency(semitone = -Infinity) { | |
// A4 is 440 Hz, 12 semitones per octave | |
return 440 * Math.pow(2, (semitone - 69) / 12); | |
} | |
constructor( | |
data: Uint8Array, | |
numChannels = 1, | |
sampleRate = 44100, | |
bitsPerSample = 16, | |
littleEndian = true | |
) { | |
const bytesPerSample = bitsPerSample >>> 3; | |
// WAV header is always 44 bytes | |
this.header = new ArrayBuffer(44); | |
// flexible container for reading / writing raw bytes in header | |
this.view = new DataView(this.header); | |
// leave sound data as non typed array for more flexibility | |
this.data = data; | |
// initialize as non-configurable because it | |
// causes script to freeze when using parsed | |
// chunk sizes with wrong endianness assumed | |
Object.defineProperty(this, 'littleEndian', { | |
configurable: false, | |
enumerable: true, | |
value: littleEndian, | |
writable: false | |
}); | |
// initial write index in data array | |
this.pointer = 0; | |
// WAV header properties | |
this.ChunkID = littleEndian ? 'RIFF' : 'RIFX'; | |
this.ChunkSize = this.header.byteLength - 8; | |
this.Format = 'WAVE'; | |
this.SubChunk1ID = 'fmt '; | |
this.SubChunk1Size = 16; | |
this.AudioFormat = 1; | |
this.NumChannels = numChannels; | |
this.SampleRate = sampleRate; | |
this.ByteRate = numChannels * sampleRate * bytesPerSample; | |
this.BlockAlign = numChannels * bytesPerSample; | |
this.BitsPerSample = bitsPerSample; | |
this.SubChunk2ID = 'data'; | |
this.SubChunk2Size = data.length * bytesPerSample; | |
} | |
// internal setter for writing strings as raw bytes to header | |
setString(str: string, byteLength = str.length, byteOffset = 0) { | |
for (let i = 0; i < byteLength; i++) { | |
this.view.setUint8(byteOffset + i, str.charCodeAt(i)); | |
} | |
} | |
// internal getter for reading raw bytes as strings from header | |
getString(byteLength: number, byteOffset = 0) { | |
let str = ''; | |
for (let i = 0; i < byteLength; i++) { | |
str += String.fromCharCode(this.view.getUint8(byteOffset + i)); | |
} | |
return str; | |
} | |
// header property mutators | |
// 4 bytes at offset of 0 bytes | |
set ChunkID(str) { | |
this.setString(str, 4, 0); | |
} | |
get ChunkID() { | |
return this.getString(4, 0); | |
} | |
// 4 bytes at offset of 4 bytes | |
set ChunkSize(uint) { | |
this.view.setUint32(4, uint, this.littleEndian); | |
} | |
get ChunkSize() { | |
return this.view.getUint32(4, this.littleEndian); | |
} | |
// 4 bytes at offset of 8 bytes | |
set Format(str) { | |
this.setString(str, 4, 8); | |
} | |
get Format() { | |
return this.getString(4, 8); | |
} | |
// 4 bytes at offset of 12 bytes | |
set SubChunk1ID(str) { | |
this.setString(str, 4, 12); | |
} | |
get SubChunk1ID() { | |
return this.getString(4, 12); | |
} | |
// 4 bytes at offset of 16 bytes | |
set SubChunk1Size(uint) { | |
this.view.setUint32(16, uint, this.littleEndian); | |
} | |
get SubChunk1Size() { | |
return this.view.getUint32(16, this.littleEndian); | |
} | |
// 2 bytes at offset of 20 bytes | |
set AudioFormat(uint) { | |
this.view.setUint16(20, uint, this.littleEndian); | |
} | |
get AudioFormat() { | |
return this.view.getUint16(20, this.littleEndian); | |
} | |
// 2 bytes at offset of 22 bytes | |
set NumChannels(uint) { | |
this.view.setUint16(22, uint, this.littleEndian); | |
} | |
get NumChannels() { | |
return this.view.getUint16(22, this.littleEndian); | |
} | |
// 4 bytes at offset of 24 bytes | |
set SampleRate(uint) { | |
this.view.setUint32(24, uint, this.littleEndian); | |
} | |
get SampleRate() { | |
return this.view.getUint32(24, this.littleEndian); | |
} | |
// 4 bytes at offset of 28 bytes | |
set ByteRate(uint) { | |
this.view.setUint32(28, uint, this.littleEndian); | |
} | |
get ByteRate() { | |
return this.view.getUint32(28, this.littleEndian); | |
} | |
// 2 bytes at offset of 32 bytes | |
set BlockAlign(uint) { | |
this.view.setUint16(32, uint, this.littleEndian); | |
} | |
get BlockAlign() { | |
return this.view.getUint16(32, this.littleEndian); | |
} | |
// 2 bytes at offset of 34 bytes | |
set BitsPerSample(uint) { | |
this.view.setUint16(34, uint, this.littleEndian); | |
} | |
get BitsPerSample() { | |
return this.view.getUint16(34, this.littleEndian); | |
} | |
// 4 bytes at offset of 36 bytes | |
set SubChunk2ID(str) { | |
this.setString(str, 4, 36); | |
} | |
get SubChunk2ID() { | |
return this.getString(4, 36); | |
} | |
// 4 bytes at offset of 40 bytes | |
set SubChunk2Size(uint) { | |
this.view.setUint32(40, uint, this.littleEndian); | |
} | |
get SubChunk2Size() { | |
return this.view.getUint32(40, this.littleEndian); | |
} | |
// internal getter for sound data as | |
// typed array based on header properties | |
get typedData() { | |
const bytesPerSample = this.BitsPerSample >>> 3; | |
console.log('WAV:bytesPerSample: ', bytesPerSample); | |
const data = this.data; | |
const size = this.SubChunk2Size; | |
console.log('WAV: size: ', size); | |
const samples = size / bytesPerSample; | |
console.log('WAV: samples: ', samples); | |
const buffer = new ArrayBuffer(size); | |
const uint8 = new Uint8Array(buffer); | |
// convert signed normalized sound data to typed integer data | |
// i.e. [-1, 1] -> [INT_MIN, INT_MAX] | |
const amplitude = Math.pow(2, (bytesPerSample << 3) - 1) - 1; | |
let i, d; | |
switch (bytesPerSample) { | |
case 1: | |
// endianness not relevant for 8-bit encoding | |
for (i = 0; i < samples; i++) { | |
// convert by adding 0x80 instead of 0x100 | |
// WAV uses unsigned data for 8-bit encoding | |
// [INT8_MIN, INT8_MAX] -> [0, UINT8_MAX] | |
uint8[i] = (data[i] * amplitude + 0x80) & 0xff; | |
} | |
break; | |
case 2: | |
// LSB first | |
if (this.littleEndian) { | |
for (i = 0; i < samples; i++) { | |
// [INT16_MIN, INT16_MAX] -> [0, UINT16_MAX] | |
d = (data[i] * amplitude + 0x10000) & 0xffff; | |
// unwrap inner loop | |
uint8[i * 2] = d & 0xff; | |
uint8[i * 2 + 1] = d >>> 8; | |
} | |
// MSB first | |
} else { | |
for (i = 0; i < samples; i++) { | |
// [INT16_MIN, INT16_MAX] -> [0, UINT16_MAX] | |
d = (data[i] * amplitude + 0x10000) & 0xffff; | |
// unwrap inner loop | |
uint8[i * 2] = d >>> 8; | |
uint8[i * 2 + 1] = d & 0xff; | |
} | |
} | |
break; | |
case 3: | |
// LSB first | |
if (this.littleEndian) { | |
for (i = 0; i < samples; i++) { | |
// [INT24_MIN, INT24_MAX] -> [0, UINT24_MAX] | |
d = (data[i] * amplitude + 0x1000000) & 0xffffff; | |
// unwrap inner loop | |
uint8[i * 3] = d & 0xff; | |
uint8[i * 3 + 1] = (d >>> 8) & 0xff; | |
uint8[i * 3 + 2] = d >>> 16; | |
} | |
// MSB first | |
} else { | |
for (i = 0; i < samples; i++) { | |
// [INT24_MIN, INT24_MAX] -> [0, UINT24_MAX] | |
d = (data[i] * amplitude + 0x1000000) & 0xffffff; | |
// unwrap inner loop | |
uint8[i * 3] = d >>> 16; | |
uint8[i * 3 + 1] = (d >>> 8) & 0xff; | |
uint8[i * 3 + 2] = d & 0xff; | |
} | |
} | |
// eslint-disable-next-line no-fallthrough | |
case 4: | |
// LSB first | |
if (this.littleEndian) { | |
for (i = 0; i < samples; i++) { | |
// [INT32_MIN, INT32_MAX] -> [0, UINT32_MAX] | |
d = (data[i] * amplitude + 0x100000000) & 0xffffffff; | |
// unwrap inner loop | |
uint8[i * 4] = d & 0xff; | |
uint8[i * 4 + 1] = (d >>> 8) & 0xff; | |
uint8[i * 4 + 2] = (d >>> 16) & 0xff; | |
uint8[i * 4 + 3] = d >>> 24; | |
} | |
// MSB first | |
} else { | |
for (i = 0; i < samples; i++) { | |
// [INT32_MIN, INT32_MAX] -> [0, UINT32_MAX] | |
d = (data[i] * amplitude + 0x100000000) & 0xffffffff; | |
// unwrap inner loop | |
uint8[i * 4] = d >>> 24; | |
uint8[i * 4 + 1] = (d >>> 16) & 0xff; | |
uint8[i * 4 + 2] = (d >>> 8) & 0xff; | |
uint8[i * 4 + 3] = d & 0xff; | |
} | |
} | |
} | |
return buffer; | |
} | |
// binary container outputs | |
// browser-specific | |
// generates blob from concatenated typed arrays | |
toBlob() { | |
return new Blob([this.header, this.data], { type: 'audio/wav' }); | |
} | |
// Node.js-specific | |
// generates buffer from concatenated typed arrays | |
toBuffer() { | |
// return Buffer.concat([Buffer.from(this.header), Buffer.from(this.typedData)]); | |
const tmp = new Uint8Array(this.header.byteLength + this.typedData.byteLength); | |
tmp.set(new Uint8Array(this.header), 0); | |
tmp.set(new Uint8Array(this.typedData), this.header.byteLength); | |
return tmp; | |
} | |
// pointer mutators | |
// gets time (in seconds) of pointer | |
tell() { | |
return this.pointer / this.NumChannels / this.SampleRate; | |
} | |
// sets time (in seconds) of pointer | |
// zero-fills by default | |
seek(time: number, fill = true) { | |
const data = this.data; | |
const sample = Math.round(this.SampleRate * time); | |
this.pointer = this.NumChannels * sample; | |
if (fill) { | |
// zero-fill seek | |
while (data.length < this.pointer) { | |
data[data.length] = 0; | |
} | |
} else { | |
this.pointer = data.length; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment