feat: build reproducible dataset editor

This commit is contained in:
Codex
2026-08-28 10:46:33 +09:00
parent 6a1197d60e
commit 08fcf578de
16 changed files with 2579 additions and 11 deletions
+2
View File
@@ -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";
+64
View File
@@ -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 };
}
+43
View File
@@ -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" });
}