File size: 6,921 Bytes
b0bfea8 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
/**
* @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" });
}
|