|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
let infoChunk = new IndexedByteArray(0); |
|
|
const infoOn = Object.keys(metadata).length > 0; |
|
|
|
|
|
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)); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
writeLittleEndian(cueStart, 0, 4); |
|
|
writeStringAsBytes(cueStart, "data"); |
|
|
writeLittleEndian(cueStart, 0, 4); |
|
|
writeLittleEndian(cueStart, 0, 4); |
|
|
writeLittleEndian(cueStart, loopStartSamples, 4); |
|
|
|
|
|
const cueEnd = new IndexedByteArray(24); |
|
|
writeLittleEndian(cueEnd, 1, 4); |
|
|
writeLittleEndian(cueEnd, 0, 4); |
|
|
writeStringAsBytes(cueEnd, "data"); |
|
|
writeLittleEndian(cueEnd, 0, 4); |
|
|
writeLittleEndian(cueEnd, 0, 4); |
|
|
writeLittleEndian(cueEnd, loopEndSamples, 4); |
|
|
|
|
|
const out = combineArrays([ |
|
|
new IndexedByteArray([2, 0, 0, 0]), |
|
|
cueStart, |
|
|
cueEnd |
|
|
]); |
|
|
cueChunk = writeRIFFOddSize("cue ", out); |
|
|
} |
|
|
|
|
|
|
|
|
const headerSize = 44; |
|
|
const dataSize = length * 2 * bytesPerSample; |
|
|
const fileSize = headerSize + dataSize + infoChunk.length + cueChunk.length - 8; |
|
|
const header = new Uint8Array(headerSize); |
|
|
|
|
|
|
|
|
header.set([82, 73, 70, 70], 0); |
|
|
|
|
|
header.set( |
|
|
new Uint8Array([fileSize & 0xff, (fileSize >> 8) & 0xff, (fileSize >> 16) & 0xff, (fileSize >> 24) & 0xff]), |
|
|
4 |
|
|
); |
|
|
|
|
|
header.set([87, 65, 86, 69], 8); |
|
|
|
|
|
header.set([102, 109, 116, 32], 12); |
|
|
|
|
|
header.set([16, 0, 0, 0], 16); |
|
|
|
|
|
header.set([1, 0], 20); |
|
|
|
|
|
header.set([2, 0], 22); |
|
|
|
|
|
const sampleRate = audioBuffer.sampleRate; |
|
|
header.set( |
|
|
new Uint8Array([sampleRate & 0xff, (sampleRate >> 8) & 0xff, (sampleRate >> 16) & 0xff, (sampleRate >> 24) & 0xff]), |
|
|
24 |
|
|
); |
|
|
|
|
|
const byteRate = sampleRate * 2 * bytesPerSample; |
|
|
header.set( |
|
|
new Uint8Array([byteRate & 0xff, (byteRate >> 8) & 0xff, (byteRate >> 16) & 0xff, (byteRate >> 24) & 0xff]), |
|
|
28 |
|
|
); |
|
|
|
|
|
header.set([4, 0], 32); |
|
|
|
|
|
header.set([16, 0], 34); |
|
|
|
|
|
|
|
|
header.set([100, 97, 116, 97], 36); |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
let multiplier = 32767; |
|
|
if (normalizeAudio) |
|
|
{ |
|
|
|
|
|
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++) |
|
|
{ |
|
|
|
|
|
const sample1 = Math.min(32767, Math.max(-32768, channel1Data[i] * multiplier)); |
|
|
const sample2 = Math.min(32767, Math.max(-32768, channel2Data[i] * multiplier)); |
|
|
|
|
|
|
|
|
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" }); |
|
|
} |
|
|
|