226 lines
10 KiB
JavaScript
226 lines
10 KiB
JavaScript
import { coordinateToPixel, pixelToCoordinate } from "/apps/dataset-builder/canvas-view.js";
|
|
import {
|
|
assetDescriptorForFile,
|
|
disableSourceTrajectory,
|
|
enableSourceTrajectory,
|
|
createSceneId,
|
|
createSource,
|
|
describeSignal,
|
|
insertTrajectoryKeyframe,
|
|
matchAssetDescriptor,
|
|
prepareSceneForEditing,
|
|
removeTrajectoryKeyframe,
|
|
referencedAssetIds,
|
|
scaleSceneTrajectories,
|
|
updateTrajectoryKeyframeTime,
|
|
} from "/apps/dataset-builder/model.js";
|
|
import { decodeAudioFile } from "/packages/audio-io/browser-audio.js";
|
|
import {
|
|
MAX_MIX_SAMPLE_OPERATIONS,
|
|
MAX_MIX_WORKING_BYTES,
|
|
renderSceneMix,
|
|
} from "/packages/acoustics-core/mixdown.js";
|
|
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
|
|
|
|
function assertNear(actual, expected, tolerance, label) {
|
|
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, received ${actual}`);
|
|
}
|
|
|
|
test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든다", () => {
|
|
const original = {
|
|
id: "scene",
|
|
sampleRate: 8000,
|
|
duration: 1,
|
|
sources: [{ id: "source-1", position: [0, 0, 2] }],
|
|
};
|
|
const scene = prepareSceneForEditing(original);
|
|
const added = createSource(scene);
|
|
assertEqual(added.id, "source-2", "unique source id");
|
|
assertEqual(original.assets, undefined, "input remains immutable");
|
|
assertEqual(scene.mix.targetPeak, 0.95, "default target peak");
|
|
|
|
const descriptor = assetDescriptorForFile(
|
|
{ name: "기계음 샘플.WAV", type: "audio/wav" },
|
|
"audio",
|
|
new Set(["audio"]),
|
|
);
|
|
assert(/^[a-z0-9][a-z0-9._-]*$/.test(descriptor.id), "schema-safe asset id");
|
|
assertEqual(descriptor.id, "audio-2", "collision suffix");
|
|
assertEqual(createSceneId(123456, 789), "scene-2n9c-lx", "deterministic scene id");
|
|
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
|
|
});
|
|
|
|
test("Builder tone summary exposes the ideal ADC alias above Nyquist", () => {
|
|
assertEqual(
|
|
describeSignal({ type: "tone", frequency: 5_000 }, 8_000),
|
|
"5000 Hz tone → ADC 3000 Hz alias",
|
|
"above-Nyquist source summary",
|
|
);
|
|
assertEqual(
|
|
describeSignal({ type: "tone", frequency: 1_000 }, 8_000),
|
|
"1000 Hz tone",
|
|
"baseband source summary",
|
|
);
|
|
assertEqual(
|
|
describeSignal({ type: "tone", frequency: 8_000 }, 8_000),
|
|
"8000 Hz tone → ADC 0 Hz alias · DC phase-degenerate edge",
|
|
"aliased exact-DC source summary",
|
|
);
|
|
assertEqual(
|
|
describeSignal({ type: "tone", frequency: 12_000 }, 8_000),
|
|
"12000 Hz tone → ADC 4000 Hz alias · Nyquist phase-degenerate edge",
|
|
"aliased exact-Nyquist source summary",
|
|
);
|
|
});
|
|
|
|
test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
|
|
const source = { position: [0.5, 0.1, 2] };
|
|
const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
|
|
assertEqual(trajectory.length, 2, "default keyframe count");
|
|
assertEqual(trajectory[0].time, 0, "path starts at scene start");
|
|
assertEqual(trajectory[1].time, 2, "path spans scene duration");
|
|
assert(trajectory[1].position[0] < trajectory[0].position[0], "edge path turns inward");
|
|
|
|
const inserted = insertTrajectoryKeyframe(source);
|
|
assertEqual(inserted, 1, "midpoint insertion index");
|
|
assertNear(source.trajectory[1].time, 1, 1e-12, "midpoint time");
|
|
assertNear(source.trajectory[1].position[0], 0.35, 1e-12, "midpoint x");
|
|
assertNear(updateTrajectoryKeyframeTime(source, 1, 3, 2), 1.999999998, 1e-12, "ordered time clamp");
|
|
|
|
source.trajectory[0].position = [-0.25, 0.2, 1.5];
|
|
scaleSceneTrajectories({ sources: [source] }, 2, 4);
|
|
assertNear(source.trajectory[1].time, 3.999999996, 1e-9, "duration scales keyframes");
|
|
assertEqual(source.position.join(","), "-0.25,0.2,1.5", "first point fallback sync");
|
|
assert(removeTrajectoryKeyframe(source, 1), "remove optional midpoint");
|
|
assertEqual(removeTrajectoryKeyframe(source, 0), false, "two-point minimum");
|
|
disableSourceTrajectory(source);
|
|
assertEqual(source.trajectory, undefined, "motion disabled");
|
|
assertEqual(source.position.join(","), "-0.25,0.2,1.5", "fallback remains after disable");
|
|
});
|
|
|
|
test("asset 재연결은 fileName보다 SHA-256을 우선하고 충돌을 분리한다", () => {
|
|
const assets = [
|
|
{ id: "old", kind: "audio", fileName: "audio/machine.wav", sha256: "a".repeat(64) },
|
|
{ id: "unhashed", kind: "audio", fileName: "ambient.wav" },
|
|
];
|
|
const hashMatch = matchAssetDescriptor(assets, new Set(), "renamed.wav", "audio", "a".repeat(64));
|
|
assertEqual(hashMatch.descriptor.id, "old", "hash match");
|
|
const nameMatch = matchAssetDescriptor(assets, new Set(), "ambient.wav", "audio", "b".repeat(64));
|
|
assertEqual(nameMatch.descriptor.id, "unhashed", "unhashed name match");
|
|
const conflict = matchAssetDescriptor(assets, new Set(), "machine.wav", "audio", "c".repeat(64));
|
|
assertEqual(conflict.descriptor, null, "different hash does not reconnect");
|
|
assertEqual(conflict.conflict, true, "same-name conflict reported");
|
|
|
|
const duplicateHashAssets = [
|
|
{ id: "copy-1", kind: "audio", fileName: "copy-1.wav", sha256: "d".repeat(64) },
|
|
{ id: "copy-2", kind: "audio", fileName: "copy-2.wav", sha256: "d".repeat(64) },
|
|
];
|
|
const secondCopy = matchAssetDescriptor(duplicateHashAssets, new Set(["copy-1"]), "same.wav", "audio", "d".repeat(64));
|
|
assertEqual(secondCopy.descriptor.id, "copy-2", "unbound duplicate hash descriptor");
|
|
});
|
|
|
|
test("Builder가 image/source/ambient에서 참조하는 asset을 모두 찾는다", () => {
|
|
const ids = referencedAssetIds({
|
|
image: { assetId: "image" },
|
|
sources: [
|
|
{ signal: { type: "asset", assetId: "machine" } },
|
|
{ signal: { type: "tone", frequency: 1000 } },
|
|
],
|
|
ambient: [{ signal: { type: "asset", assetId: "ambient" } }],
|
|
});
|
|
assertEqual([...ids].join(","), "image,machine,ambient", "referenced assets");
|
|
});
|
|
|
|
test("canvas 좌표 변환이 y-up 평면의 중앙과 모서리를 왕복한다", () => {
|
|
const plane = { xMin: -2, xMax: 2, yMin: -1, yMax: 3 };
|
|
const topLeft = pixelToCoordinate({ x: 0, y: 0 }, plane, 400, 200);
|
|
assertEqual(topLeft.x, -2, "left is xMin");
|
|
assertEqual(topLeft.y, 3, "top is yMax");
|
|
const bottomRight = pixelToCoordinate({ x: 400, y: 200 }, plane, 400, 200);
|
|
assertEqual(bottomRight.x, 2, "right is xMax");
|
|
assertEqual(bottomRight.y, -1, "bottom is yMin");
|
|
const pixel = coordinateToPixel([0.75, 1.25, 2], plane, 400, 200);
|
|
const roundTrip = pixelToCoordinate(pixel, plane, 400, 200);
|
|
assertNear(roundTrip.x, 0.75, 1e-12, "x round trip");
|
|
assertNear(roundTrip.y, 1.25, 1e-12, "y round trip");
|
|
});
|
|
|
|
test("확인용 mix가 과도한 메모리와 sample 연산을 allocation 전에 거부한다", () => {
|
|
const signal = { type: "tone", frequency: 1000 };
|
|
const largeMemory = {
|
|
sampleRate: 192000,
|
|
duration: 300,
|
|
seed: 1,
|
|
mix: { normalization: "none", targetPeak: 0.95 },
|
|
sources: [{ id: "source", gainDb: 0, startTime: 0, duration: 300, signal }],
|
|
ambient: [],
|
|
};
|
|
let memoryError;
|
|
try { renderSceneMix(largeMemory); } catch (error) { memoryError = error; }
|
|
assert(memoryError?.message.includes("작업 메모리"), "memory guard message");
|
|
assert(MAX_MIX_WORKING_BYTES < 192000 * 300 * 8, "fixture exceeds memory guard");
|
|
|
|
const sources = Array.from({ length: 18 }, (_, index) => ({
|
|
id: `source-${index}`, gainDb: 0, startTime: 0, duration: 300, signal,
|
|
}));
|
|
const largeOperations = { ...largeMemory, sampleRate: 48000, sources };
|
|
let operationError;
|
|
try { renderSceneMix(largeOperations); } catch (error) { operationError = error; }
|
|
assert(operationError?.message.includes("연산량"), "operation guard message");
|
|
assert(MAX_MIX_SAMPLE_OPERATIONS < 48000 * 300 * sources.length, "fixture exceeds operation guard");
|
|
});
|
|
|
|
test("WAV encoder가 잘못된 sample rate와 비유한 sample을 거부한다", () => {
|
|
let rateError;
|
|
try { encodeMonoPcm16Wav(Float32Array.of(0), 0); } catch (error) { rateError = error; }
|
|
assert(rateError?.message.includes("sample rate"), "sample-rate guard");
|
|
let sampleError;
|
|
try { encodeMonoPcm16Wav(Float32Array.of(0, Number.NaN), 48000); } catch (error) { sampleError = error; }
|
|
assert(sampleError?.message.includes("유한한"), "finite-sample guard");
|
|
});
|
|
|
|
test("브라우저 audio decoder가 caller의 누적 메모리 budget을 지킨다", async () => {
|
|
const wav = encodeMonoPcm16Wav(Float32Array.of(0, 0.25, -0.25, 0), 8000);
|
|
const file = new File([wav], "tiny.wav", { type: "audio/wav" });
|
|
let budgetError;
|
|
try { await decodeAudioFile(file, 8000, 8); } catch (error) { budgetError = error; }
|
|
assert(budgetError?.message.includes("decode 크기"), "decoded-byte budget error");
|
|
|
|
const header = new ArrayBuffer(44);
|
|
const view = new DataView(header);
|
|
const write = (offset, text) => [...text].forEach((character, index) => view.setUint8(offset + index, character.charCodeAt(0)));
|
|
const channels = 4;
|
|
const sampleRate = 48000;
|
|
const dataByteLength = sampleRate * 300 * channels * 2;
|
|
write(0, "RIFF"); view.setUint32(4, 36 + dataByteLength, true); write(8, "WAVE"); write(12, "fmt ");
|
|
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, channels, true);
|
|
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * channels * 2, true);
|
|
view.setUint16(32, channels * 2, true); view.setUint16(34, 16, true); write(36, "data");
|
|
view.setUint32(40, dataByteLength, true);
|
|
const multichannelFile = new File([header], "four-channel.wav", { type: "audio/wav" });
|
|
let channelError;
|
|
try { await decodeAudioFile(multichannelFile, sampleRate); } catch (error) { channelError = error; }
|
|
assert(channelError?.message.includes("예상 decode 크기 219.7 MiB"), "WAV channel-count preflight");
|
|
});
|
|
|
|
test("Builder mix Worker가 기본 tone 장면을 별도 thread에서 생성한다", async () => {
|
|
const response = await fetch("/assets/sample-scenes/single-tone.scene.json");
|
|
const scene = await response.json();
|
|
const result = await new Promise((resolve, reject) => {
|
|
const worker = new Worker("/apps/dataset-builder/mix-worker.js", { type: "module" });
|
|
worker.addEventListener("message", ({ data }) => {
|
|
worker.terminate();
|
|
if (data.ok) resolve(data.result);
|
|
else reject(new Error(data.message));
|
|
});
|
|
worker.addEventListener("error", (event) => {
|
|
worker.terminate();
|
|
reject(new Error(event.message));
|
|
});
|
|
worker.postMessage({ scene, assetBuffers: [] });
|
|
});
|
|
assertEqual(result.samples.length, 12000, "worker sample count");
|
|
assert(result.peak > 0.5 && result.peak < 0.502, "worker tone peak");
|
|
assertEqual(result.clippedSampleCount, 0, "worker clipping count");
|
|
});
|