feat: build reproducible dataset editor
This commit is contained in:
@@ -2,7 +2,9 @@ export * from "./das.js";
|
||||
export * from "./fft.js";
|
||||
export * from "./fractional-delay.js";
|
||||
export * from "./math.js";
|
||||
export * from "./mixdown.js";
|
||||
export * from "./noise.js";
|
||||
export * from "./propagation.js";
|
||||
export * from "./signal.js";
|
||||
export * from "./stft.js";
|
||||
export * from "./wav.js";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { renderSignal } from "./signal.js";
|
||||
|
||||
export const MAX_MIX_WORKING_BYTES = 384 * 1024 * 1024;
|
||||
export const MAX_MIX_SAMPLE_OPERATIONS = 250_000_000;
|
||||
|
||||
export function estimateSceneMix(scene, assetBuffers = new Map()) {
|
||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||
const entryCount = scene.sources.length + (scene.ambient?.length ?? 0);
|
||||
let assetBytes = 0;
|
||||
for (const asset of assetBuffers.values()) {
|
||||
for (const channel of asset.channels ?? []) assetBytes += channel.byteLength ?? 0;
|
||||
}
|
||||
return {
|
||||
sampleCount,
|
||||
entryCount,
|
||||
assetBytes,
|
||||
workingBytes: sampleCount * Float32Array.BYTES_PER_ELEMENT * 2 + assetBytes,
|
||||
sampleOperations: sampleCount * entryCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSceneMix(scene, assetBuffers = new Map()) {
|
||||
const estimate = estimateSceneMix(scene, assetBuffers);
|
||||
if (!Number.isSafeInteger(estimate.sampleCount) || estimate.sampleCount <= 0) {
|
||||
throw new Error("장면 sample 수가 올바르지 않습니다.");
|
||||
}
|
||||
if (estimate.workingBytes > MAX_MIX_WORKING_BYTES) {
|
||||
throw new Error(
|
||||
`확인용 mix 작업 메모리 ${(estimate.workingBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`${(MAX_MIX_WORKING_BYTES / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
if (estimate.sampleOperations > MAX_MIX_SAMPLE_OPERATIONS) {
|
||||
throw new Error(
|
||||
`확인용 mix 연산량 ${estimate.sampleOperations.toLocaleString()} samples가 ` +
|
||||
`${MAX_MIX_SAMPLE_OPERATIONS.toLocaleString()} 한도를 넘습니다. duration 또는 음원 수를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
const { sampleCount } = estimate;
|
||||
const samples = new Float32Array(sampleCount);
|
||||
for (const source of scene.sources) {
|
||||
const sourceSamples = renderSignal(source, scene, assetBuffers);
|
||||
for (let index = 0; index < sampleCount; index += 1) samples[index] += sourceSamples[index];
|
||||
}
|
||||
for (const ambient of scene.ambient ?? []) {
|
||||
const ambientSamples = renderSignal(ambient, scene, assetBuffers);
|
||||
for (let index = 0; index < sampleCount; index += 1) samples[index] += ambientSamples[index];
|
||||
}
|
||||
|
||||
let preNormalizationPeak = 0;
|
||||
for (const value of samples) preNormalizationPeak = Math.max(preNormalizationPeak, Math.abs(value));
|
||||
const appliedScale =
|
||||
scene.mix?.normalization === "peak" && preNormalizationPeak > 0
|
||||
? scene.mix.targetPeak / preNormalizationPeak
|
||||
: 1;
|
||||
let peak = 0;
|
||||
let clippedSampleCount = 0;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
samples[index] *= appliedScale;
|
||||
peak = Math.max(peak, Math.abs(samples[index]));
|
||||
if (Math.abs(samples[index]) > 1) clippedSampleCount += 1;
|
||||
}
|
||||
return { samples, sampleRate: scene.sampleRate, preNormalizationPeak, appliedScale, peak, clippedSampleCount };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
function writeAscii(view, offset, value) {
|
||||
for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
|
||||
export const MAX_PCM_WAV_BYTES = 384 * 1024 * 1024;
|
||||
|
||||
export function encodeMonoPcm16Wav(samples, sampleRate) {
|
||||
if (!Number.isInteger(sampleRate) || sampleRate < 1 || sampleRate > 0xffffffff) {
|
||||
throw new Error(`WAV sample rate가 올바르지 않습니다: ${sampleRate}`);
|
||||
}
|
||||
if (!samples || !Number.isSafeInteger(samples.length)) {
|
||||
throw new Error("WAV samples는 길이가 있는 배열이어야 합니다.");
|
||||
}
|
||||
const bytesPerSample = 2;
|
||||
const dataByteLength = samples.length * bytesPerSample;
|
||||
if (!Number.isSafeInteger(dataByteLength) || dataByteLength > 0xffffffff - 36) {
|
||||
throw new Error("PCM16 WAV가 RIFF 4 GiB 한도를 넘습니다.");
|
||||
}
|
||||
if (44 + dataByteLength > MAX_PCM_WAV_BYTES) {
|
||||
throw new Error(`PCM16 WAV가 ${(MAX_PCM_WAV_BYTES / 1024 ** 2).toFixed(0)} MiB 메모리 한도를 넘습니다.`);
|
||||
}
|
||||
const buffer = new ArrayBuffer(44 + dataByteLength);
|
||||
const view = new DataView(buffer);
|
||||
writeAscii(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataByteLength, true);
|
||||
writeAscii(view, 8, "WAVE");
|
||||
writeAscii(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * bytesPerSample, true);
|
||||
view.setUint16(32, bytesPerSample, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeAscii(view, 36, "data");
|
||||
view.setUint32(40, dataByteLength, true);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
if (!Number.isFinite(samples[index])) throw new Error(`WAV sample ${index}가 유한한 숫자가 아닙니다.`);
|
||||
const value = Math.max(-1, Math.min(1, samples[index]));
|
||||
view.setInt16(44 + index * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
|
||||
}
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,20 @@ export function downloadJson(value, fileName) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function downloadBlob(blob, fileName) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export async function sha256File(file) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function deepClone(value) {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user