model / spessasynth_lib /utils /buffer_to_wav.js
KEXEL's picture
1.1
b0bfea8 verified
/**
* @typedef {Object} WaveMetadata
* @property {string|undefined} title - the song's title
* @property {string|undefined} artist - the song's artist
* @property {string|undefined} album - the song's album
* @property {string|undefined} genre - the song's genre
*/
import { combineArrays, IndexedByteArray } from "./indexed_array.js";
import { getStringBytes, writeStringAsBytes } from "./byte_functions/string.js";
import { writeRIFFOddSize } from "../soundfont/basic_soundfont/riff_chunk.js";
import { writeLittleEndian } from "./byte_functions/little_endian.js";
/**
*
* @param audioBuffer {AudioBuffer}
* @param normalizeAudio {boolean} find the max sample point and set it to 1, and scale others with it
* @param channelOffset {number} channel offset and channel offset + 1 get saved
* @param metadata {WaveMetadata}
* @param loop {{start: number, end: number}} loop start and end points in seconds. Undefined if no loop
* @returns {Blob}
*/
export function audioBufferToWav(audioBuffer, normalizeAudio = true, channelOffset = 0, metadata = {}, loop = undefined)
{
const channel1Data = audioBuffer.getChannelData(channelOffset);
const channel2Data = audioBuffer.getChannelData(channelOffset + 1);
const length = channel1Data.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 = [
getStringBytes("INFO"),
writeRIFFOddSize("ICMT", encoder.encode("Created with SpessaSynth"), true)
];
if (metadata.artist)
{
infoChunks.push(
writeRIFFOddSize("IART", encoder.encode(metadata.artist), true)
);
}
if (metadata.album)
{
infoChunks.push(
writeRIFFOddSize("IPRD", encoder.encode(metadata.album), true)
);
}
if (metadata.genre)
{
infoChunks.push(
writeRIFFOddSize("IGNR", encoder.encode(metadata.genre), true)
);
}
if (metadata.title)
{
infoChunks.push(
writeRIFFOddSize("INAM", encoder.encode(metadata.title), true)
);
}
infoChunk = writeRIFFOddSize("LIST", combineArrays(infoChunks));
}
// prepare CUE chunk
let cueChunk = new IndexedByteArray(0);
const cueOn = loop?.end !== undefined && loop?.start !== undefined;
if (cueOn)
{
const loopStartSamples = Math.floor(loop.start * audioBuffer.sampleRate);
const loopEndSamples = Math.floor(loop.end * audioBuffer.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
const out = combineArrays([
new IndexedByteArray([2, 0, 0, 0]), // cue points count,
cueStart,
cueEnd
]);
cueChunk = writeRIFFOddSize("cue ", out);
}
// Prepare the header
const headerSize = 44;
const dataSize = length * 2 * bytesPerSample; // 2 channels, 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([2, 0], 22);
// sample rate
const sampleRate = audioBuffer.sampleRate;
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 * 2 * bytesPerSample; // 2 channels, 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([4, 0], 32); // 2 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 maxAbsValue = channel1Data.map((v, i) => Math.max(Math.abs(v), Math.abs(channel2Data[i])))
.reduce((a, b) => Math.max(a, b));
multiplier = maxAbsValue > 0 ? (32767 / maxAbsValue) : 1;
}
for (let i = 0; i < length; i++)
{
// interleave both channels
const sample1 = Math.min(32767, Math.max(-32768, channel1Data[i] * multiplier));
const sample2 = Math.min(32767, Math.max(-32768, channel2Data[i] * multiplier));
// convert to 16-bit
wavData[offset++] = sample1 & 0xff;
wavData[offset++] = (sample1 >> 8) & 0xff;
wavData[offset++] = sample2 & 0xff;
wavData[offset++] = (sample2 >> 8) & 0xff;
}
if (infoOn)
{
wavData.set(infoChunk, offset);
offset += infoChunk.length;
}
if (cueOn)
{
wavData.set(cueChunk, offset);
}
return new Blob([wavData.buffer], { type: "audio/wav" });
}