UNPKG

spessasynth_core

Version:

MIDI and SoundFont2/DLS library with no compromises

198 lines (177 loc) 6.79 kB
/** * @typedef {Object} WaveMetadata * @property {string} title - the song's title * @property {string} artist - the song's artist * @property {string} album - the song's album * @property {string} genre - the song's genre */ import { IndexedByteArray } from "./indexed_array.js"; import { writeStringAsBytes } from "./byte_functions/string.js"; import { writeRIFFChunkParts, writeRIFFChunkRaw } from "../soundfont/basic_soundfont/riff_chunk.js"; import { writeLittleEndian } from "./byte_functions/little_endian.js"; /** * * @param audioData {Float32Array[]} channels * @param sampleRate {number} * @param normalizeAudio {boolean} find the max sample point and set it to 1, and scale others with it * @param metadata {Partial<WaveMetadata>} * @param loop {{start: number, end: number}} loop start and end points in seconds. Undefined if no loop * @returns {ArrayBuffer} */ export function audioToWav(audioData, sampleRate, normalizeAudio = true, metadata = {}, loop = undefined) { const length = audioData[0].length; const numChannels = audioData.length; const bytesPerSample = 2; // 16-bit PCM // prepare INFO chunk let infoChunk = new IndexedByteArray(0); const infoOn = Object.keys(metadata).length > 0; // INFO chunk if (infoOn) { const encoder = new TextEncoder(); const infoChunks = [ writeRIFFChunkRaw("ICMT", encoder.encode("Created with SpessaSynth"), true) ]; if (metadata.artist) { infoChunks.push( writeRIFFChunkRaw("IART", encoder.encode(metadata.artist), true) ); } if (metadata.album) { infoChunks.push( writeRIFFChunkRaw("IPRD", encoder.encode(metadata.album), true) ); } if (metadata.genre) { infoChunks.push( writeRIFFChunkRaw("IGNR", encoder.encode(metadata.genre), true) ); } if (metadata.title) { infoChunks.push( writeRIFFChunkRaw("INAM", encoder.encode(metadata.title), true) ); } infoChunk = writeRIFFChunkParts("INFO", infoChunks, true); } // prepare CUE chunk let cueChunk = new IndexedByteArray(0); const cueOn = loop?.end !== undefined && loop?.start !== undefined; if (cueOn) { const loopStartSamples = Math.floor(loop.start * sampleRate); const loopEndSamples = Math.floor(loop.end * sampleRate); const cueStart = new IndexedByteArray(24); writeLittleEndian(cueStart, 0, 4); // dwIdentifier writeLittleEndian(cueStart, 0, 4); // dwPosition writeStringAsBytes(cueStart, "data"); // cue point ID writeLittleEndian(cueStart, 0, 4); // chunkStart, always 0 writeLittleEndian(cueStart, 0, 4); // BlockStart, always 0 writeLittleEndian(cueStart, loopStartSamples, 4); // sampleOffset const cueEnd = new IndexedByteArray(24); writeLittleEndian(cueEnd, 1, 4); // dwIdentifier writeLittleEndian(cueEnd, 0, 4); // dwPosition writeStringAsBytes(cueEnd, "data"); // cue point ID writeLittleEndian(cueEnd, 0, 4); // chunkStart, always 0 writeLittleEndian(cueEnd, 0, 4); // BlockStart, always 0 writeLittleEndian(cueEnd, loopEndSamples, 4); // sampleOffset cueChunk = writeRIFFChunkParts("cue ", [ new IndexedByteArray([2, 0, 0, 0]), // cue points count cueStart, cueEnd]); } // Prepare the header const headerSize = 44; const dataSize = length * numChannels * bytesPerSample; // 16-bit per channel const fileSize = headerSize + dataSize + infoChunk.length + cueChunk.length - 8; // total file size minus the first 8 bytes const header = new Uint8Array(headerSize); // 'RIFF' header.set([82, 73, 70, 70], 0); // file length header.set( new Uint8Array([fileSize & 0xff, (fileSize >> 8) & 0xff, (fileSize >> 16) & 0xff, (fileSize >> 24) & 0xff]), 4 ); // 'WAVE' header.set([87, 65, 86, 69], 8); // 'fmt ' header.set([102, 109, 116, 32], 12); // fmt chunk length header.set([16, 0, 0, 0], 16); // 16 for PCM // audio format (PCM) header.set([1, 0], 20); // number of channels (2) header.set([numChannels & 255, numChannels >> 8], 22); // sample rate header.set( new Uint8Array([sampleRate & 0xff, (sampleRate >> 8) & 0xff, (sampleRate >> 16) & 0xff, (sampleRate >> 24) & 0xff]), 24 ); // byte rate (sample rate * block align) const byteRate = sampleRate * numChannels * bytesPerSample; // 16-bit per channel header.set( new Uint8Array([byteRate & 0xff, (byteRate >> 8) & 0xff, (byteRate >> 16) & 0xff, (byteRate >> 24) & 0xff]), 28 ); // block align (channels * bytes per sample) header.set([numChannels * bytesPerSample, 0], 32); // n channels * 16-bit per channel / 8 // bits per sample header.set([16, 0], 34); // 16-bit // data chunk identifier 'data' header.set([100, 97, 116, 97], 36); // data chunk length header.set( new Uint8Array([dataSize & 0xff, (dataSize >> 8) & 0xff, (dataSize >> 16) & 0xff, (dataSize >> 24) & 0xff]), 40 ); let wavData = new Uint8Array(fileSize + 8); let offset = headerSize; wavData.set(header, 0); // Interleave audio data (combine channels) let multiplier = 32767; if (normalizeAudio) { // find min and max values to prevent clipping when converting to 16 bits const numSamples = audioData[0].length; let maxAbsValue = 0; for (let ch = 0; ch < numChannels; ch++) { const data = audioData[ch]; for (let i = 0; i < numSamples; i++) { const sample = Math.abs(data[i]); if (sample > maxAbsValue) { maxAbsValue = sample; } } } multiplier = maxAbsValue > 0 ? (32767 / maxAbsValue) : 1; } for (let i = 0; i < length; i++) { // interleave both channels audioData.forEach(d => { const sample = Math.min(32767, Math.max(-32768, d[i] * multiplier)); // convert to 16-bit wavData[offset++] = sample & 0xff; wavData[offset++] = (sample >> 8) & 0xff; }); } if (infoOn) { wavData.set(infoChunk, offset); offset += infoChunk.length; } if (cueOn) { wavData.set(cueChunk, offset); } return wavData.buffer; }