UNPKG

synth-js

Version:

high performance MIDI parser and WAV encoder

509 lines (414 loc) 15.1 kB
'use strict'; class WAV { static semitone(note = 'REST') { // matches occurence of A through G // followed by positive or negative integer // followed by 0 to 2 occurences 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 = {C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11}; const octaves = {'-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 = {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(numChannels = 1, sampleRate = 44100, bitsPerSample = 16, littleEndian = true, data = []) { var 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 endianess 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, byteLength = str.length, byteOffset = 0) { for (var 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, byteOffset = 0) { for (var i = 0, str = ''; 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() { var bytesPerSample = this.BitsPerSample >>> 3; var data = this.data; var size = this.SubChunk2Size; var samples = size / bytesPerSample; var buffer = new ArrayBuffer(size); var uint8 = new Uint8Array(buffer); // convert signed normalized sound data to typed integer data // i.e. [-1, 1] -> [INT_MIN, INT_MAX] var amplitude = Math.pow(2, (bytesPerSample << 3) - 1) - 1; var i, d; switch (bytesPerSample) { case 1: // endianess 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; } } 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.typedData], {type: 'audio/wav'}); } // Node.js-specific // generates buffer from concatenated typed arrays toBuffer() { return Buffer.concat([Buffer.from(this.header), Buffer.from(this.typedData)]); } // 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, fill = true) { var data = this.data; var 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; } } // sound data mutators // writes the specified note to the sound data // for amount of time in seconds // at given normalized amplitude // to channels listed (or all by default) // adds to existing data by default // and does not reset write index after operation by default writeNote({note, time, amplitude = 1}, channels = [], blend = true, reset = false) { // creating local references to properties var data = this.data; var numChannels = this.NumChannels; var sampleRate = this.SampleRate; // to prevent sound artifacts const fadeSeconds = 0.001; // calculating properties of given note var semitone = WAV.semitone(note); var frequency = WAV.frequency(semitone) * Math.PI * 2 / sampleRate; var period = Math.PI * 2 / frequency; // amount of blocks to be written var blocksOut = Math.round(sampleRate * time); // reduces sound artifacts by fading at last fadeSeconds var nonZero = blocksOut - sampleRate * fadeSeconds; // fade interval in samples var fade = blocksOut - nonZero + 1; // index of start and stop samples var start = this.pointer; var stop = data.length; // determines amount of blocks to be updated var blocksIn = Math.min(Math.floor((stop - start) / numChannels), blocksOut); // i = index of each sample block // j = index of each channel in a block // k = cached index of data // d = sample data value var i, j, k, d; // by default write to all channels if (channels.length === 0) { // don't overwrite passed array channels = []; for (i = 0; i < numChannels; i++) { channels[i] = i; } } // inline .indexOf() function calls into array references var skipChannel = []; for (i = 0; i < numChannels; i++) { skipChannel[i] = (channels.indexOf(i) === -1); } // update existing data for (i = 0; i < blocksIn; i++) { // iterate through specified channels for (j = 0; j < channels.length; j++) { k = start + i * numChannels + channels[j]; d = 0; if (frequency > 0) { d = amplitude * Math.sin(frequency * i) * ((i < fade) ? i : (i > nonZero) ? blocksOut - i + 1 : fade) / fade; } data[k] = d + (blend ? data[k] : 0); } } // append data for (i = blocksIn; i < blocksOut; i++) { k = start + i * numChannels; // iterate through all channels for (j = 0; j < numChannels; j++) { d = 0; // only write non-zero data to specified channels if (frequency > 0 || !skipChannel[j]) { d = amplitude * Math.sin(frequency * i) * ((i < fade) ? i : (i > nonZero) ? blocksOut - i + 1 : fade) / fade; } data[k + j] = d; } } // update header properties var end = Math.max(start + blocksOut * numChannels, stop) * this.BitsPerSample >>> 3; this.ChunkSize = end + this.header.byteLength - 8; this.SubChunk2Size = end; if (!reset) { // move write index to end of written data this.pointer = start + blocksOut * numChannels; } } // adds specified notes in series // (or asynchronously if offset property is specified in a note) // each playing for time * relativeDuration seconds // followed by a time * (1 - relativeDuration) second rest writeProgression(notes, amplitude = 1, channels = [], blend = true, reset = false, relativeDuration = 1) { var start = this.pointer; for (var i = 0, note, time, amp, off, secs, rest; i < notes.length; i++) { ({note, time, amplitude: amp, offset: off} = notes[i]); // for asynchronous progression if (off !== undefined) { this.seek(off); } if (relativeDuration === 1 || note === 'REST') { this.writeNote({note, time, amplitude: amp === undefined ? amplitude : amp * amplitude}, channels, blend, false); } else { secs = time * relativeDuration; rest = time - secs; this.writeNote({note: note, time: secs, amplitude: amp === undefined ? amplitude : amp * amplitude}, channels, blend, false); this.writeNote({note: 'REST', time: rest}, channels, blend, false); } } if (reset) { this.pointer = start; } } }; module.exports = WAV;