129 lines
5.6 KiB
JavaScript
129 lines
5.6 KiB
JavaScript
export const MAX_DECODED_AUDIO_BYTES = 192 * 1024 * 1024;
|
|
const MAX_WAV_HEADER_BYTES = 1024 * 1024;
|
|
const CONSERVATIVE_COMPRESSED_CHANNELS = 32;
|
|
|
|
function ascii(view, offset, length) {
|
|
let value = "";
|
|
for (let index = 0; index < length; index += 1) value += String.fromCharCode(view.getUint8(offset + index));
|
|
return value;
|
|
}
|
|
|
|
async function readWavMetadata(file, signal) {
|
|
throwIfAborted(signal);
|
|
const header = await file.slice(0, Math.min(file.size, MAX_WAV_HEADER_BYTES)).arrayBuffer();
|
|
throwIfAborted(signal);
|
|
const view = new DataView(header);
|
|
if (view.byteLength < 12 || ascii(view, 0, 4) !== "RIFF" || ascii(view, 8, 4) !== "WAVE") {
|
|
throw new Error(`${file.name} WAV의 RIFF/WAVE header가 올바르지 않습니다.`);
|
|
}
|
|
let offset = 12;
|
|
let format = null;
|
|
let dataByteLength = null;
|
|
while (offset + 8 <= view.byteLength) {
|
|
const chunkId = ascii(view, offset, 4);
|
|
const chunkSize = view.getUint32(offset + 4, true);
|
|
const payloadOffset = offset + 8;
|
|
if (chunkId === "fmt " && chunkSize >= 16 && payloadOffset + 16 <= view.byteLength) {
|
|
format = {
|
|
channels: view.getUint16(payloadOffset + 2, true),
|
|
sampleRate: view.getUint32(payloadOffset + 4, true),
|
|
byteRate: view.getUint32(payloadOffset + 8, true),
|
|
};
|
|
} else if (chunkId === "data") {
|
|
dataByteLength = chunkSize;
|
|
break;
|
|
}
|
|
const nextOffset = payloadOffset + chunkSize + (chunkSize % 2);
|
|
if (!Number.isSafeInteger(nextOffset) || nextOffset <= offset) break;
|
|
offset = nextOffset;
|
|
}
|
|
if (!format || dataByteLength === null || format.channels < 1 || format.channels > 32 || format.byteRate < 1) {
|
|
throw new Error(`${file.name} WAV의 fmt/data chunk를 안전하게 읽지 못했습니다.`);
|
|
}
|
|
return { channels: format.channels, duration: dataByteLength / format.byteRate };
|
|
}
|
|
|
|
function throwIfAborted(signal) {
|
|
if (signal?.aborted) throw new DOMException("오디오 decode가 취소됐습니다.", "AbortError");
|
|
}
|
|
|
|
async function probeAudioDuration(file, signal) {
|
|
throwIfAborted(signal);
|
|
const url = URL.createObjectURL(file);
|
|
const audio = document.createElement("audio");
|
|
audio.preload = "metadata";
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const finish = (value, error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
signal?.removeEventListener("abort", onAbort);
|
|
if (error) reject(error);
|
|
else resolve(value);
|
|
};
|
|
const onAbort = () => finish(null, new DOMException("오디오 metadata 확인이 취소됐습니다.", "AbortError"));
|
|
const timeout = setTimeout(() => finish(null), 10000);
|
|
audio.addEventListener("loadedmetadata", () => finish(Number.isFinite(audio.duration) ? audio.duration : null), { once: true });
|
|
audio.addEventListener("error", () => finish(null), { once: true });
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
audio.src = url;
|
|
});
|
|
} finally {
|
|
audio.removeAttribute("src");
|
|
audio.load();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
async function estimateDecodedBytes(file, targetSampleRate, signal) {
|
|
const isWav = /(?:audio\/(?:x-)?wav)/i.test(file.type) || /\.wav$/i.test(file.name);
|
|
if (isWav) {
|
|
const metadata = await readWavMetadata(file, signal);
|
|
return Math.ceil(metadata.duration * targetSampleRate) * metadata.channels * Float32Array.BYTES_PER_ELEMENT;
|
|
}
|
|
const duration = await probeAudioDuration(file, signal);
|
|
if (duration === null) {
|
|
throw new Error(`${file.name}의 decode 크기를 미리 확인할 수 없습니다. PCM WAV로 변환해 사용하세요.`);
|
|
}
|
|
return Math.ceil(duration * targetSampleRate) * CONSERVATIVE_COMPRESSED_CHANNELS * Float32Array.BYTES_PER_ELEMENT;
|
|
}
|
|
|
|
export async function decodeAudioFile(file, targetSampleRate, maximumDecodedBytes = MAX_DECODED_AUDIO_BYTES, signal) {
|
|
if (!Number.isInteger(targetSampleRate) || targetSampleRate < 8000 || targetSampleRate > 192000) {
|
|
throw new Error(`지원하지 않는 decode sample rate입니다: ${targetSampleRate}`);
|
|
}
|
|
try {
|
|
const estimatedBytes = await estimateDecodedBytes(file, targetSampleRate, signal);
|
|
throwIfAborted(signal);
|
|
if (!Number.isSafeInteger(estimatedBytes) || estimatedBytes < 0 || estimatedBytes > maximumDecodedBytes) {
|
|
throw new Error(
|
|
`${file.name} 예상 decode 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
|
`${(maximumDecodedBytes / 1024 ** 2).toFixed(1)} MiB 잔여 한도를 넘습니다.`,
|
|
);
|
|
}
|
|
const context = new OfflineAudioContext(1, 1, targetSampleRate);
|
|
const fileBytes = await file.arrayBuffer();
|
|
throwIfAborted(signal);
|
|
const decoded = await context.decodeAudioData(fileBytes);
|
|
throwIfAborted(signal);
|
|
const decodedBytes = decoded.length * decoded.numberOfChannels * Float32Array.BYTES_PER_ELEMENT;
|
|
if (decodedBytes > maximumDecodedBytes) {
|
|
throw new Error(
|
|
`${file.name} decode 크기 ${(decodedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
|
`${(maximumDecodedBytes / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다.`,
|
|
);
|
|
}
|
|
const channels = [];
|
|
for (let channel = 0; channel < decoded.numberOfChannels; channel += 1) {
|
|
channels.push(decoded.getChannelData(channel));
|
|
}
|
|
return { sampleRate: decoded.sampleRate, channels };
|
|
} catch (error) {
|
|
if (error?.name === "AbortError") throw error;
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`${file.name} 오디오를 ${targetSampleRate} Hz로 decode하지 못했습니다: ${reason}`);
|
|
}
|
|
}
|