feat: define simulator data contracts

This commit is contained in:
Codex
2026-08-28 09:07:58 +09:00
parent 4c5756a5a9
commit 582d0013e6
15 changed files with 2373 additions and 0 deletions
+323
View File
@@ -0,0 +1,323 @@
export const SCHEMA_VERSION = "1.0.0";
const MAX_MICROPHONES = 1024;
const MAX_SOURCES = 32;
const MAX_GRID_SIDE = 512;
/** @typedef {{ path: string, message: string }} ValidationIssue */
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isFiniteNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function addIssue(issues, path, message) {
issues.push({ path, message });
}
function requireRecord(value, path, issues) {
if (!isRecord(value)) {
addIssue(issues, path, "객체여야 합니다.");
return false;
}
return true;
}
function requireString(value, path, issues) {
if (typeof value !== "string" || value.trim() === "") {
addIssue(issues, path, "비어 있지 않은 문자열이어야 합니다.");
return false;
}
return true;
}
function requireFinite(value, path, issues, options = {}) {
const { min = -Infinity, max = Infinity, exclusiveMin = false } = options;
if (!isFiniteNumber(value)) {
addIssue(issues, path, "유한한 숫자여야 합니다.");
return false;
}
if ((exclusiveMin && value <= min) || (!exclusiveMin && value < min) || value > max) {
addIssue(issues, path, `${exclusiveMin ? ">" : "≥"} ${min}, ≤ ${max} 범위여야 합니다.`);
return false;
}
return true;
}
function requireInteger(value, path, issues, options = {}) {
if (!Number.isInteger(value)) {
addIssue(issues, path, "정수여야 합니다.");
return false;
}
return requireFinite(value, path, issues, options);
}
function requirePosition(value, path, issues) {
if (!Array.isArray(value) || value.length !== 3) {
addIssue(issues, path, "[x, y, z] 숫자 3개여야 합니다.");
return false;
}
value.forEach((coordinate, index) => requireFinite(coordinate, `${path}[${index}]`, issues));
return true;
}
function validateHeader(value, expectedKind, issues) {
if (!requireRecord(value, "$", issues)) return false;
if (value.schemaVersion !== SCHEMA_VERSION) {
addIssue(issues, "$.schemaVersion", `지원 버전은 ${SCHEMA_VERSION}입니다.`);
}
if (value.kind !== expectedKind) addIssue(issues, "$.kind", `${expectedKind}이어야 합니다.`);
requireString(value.id, "$.id", issues);
requireString(value.name, "$.name", issues);
return true;
}
function findDuplicates(values) {
const seen = new Set();
const duplicates = new Set();
for (const value of values) {
if (seen.has(value)) duplicates.add(value);
seen.add(value);
}
return [...duplicates];
}
export function validateArrayDefinition(value) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.array", issues)) return issues;
if (value.units !== "m") addIssue(issues, "$.units", "m이어야 합니다.");
if (requireRecord(value.coordinateSystem, "$.coordinateSystem", issues)) {
const expected = { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" };
for (const [key, expectedValue] of Object.entries(expected)) {
if (value.coordinateSystem[key] !== expectedValue) {
addIssue(issues, `$.coordinateSystem.${key}`, `${expectedValue}이어야 합니다.`);
}
}
}
if (requireRecord(value.aperture, "$.aperture", issues)) {
requireFinite(value.aperture.width, "$.aperture.width", issues, { min: 0, max: 20, exclusiveMin: true });
requireFinite(value.aperture.height, "$.aperture.height", issues, { min: 0, max: 20, exclusiveMin: true });
}
if (!Array.isArray(value.microphones) || value.microphones.length === 0) {
addIssue(issues, "$.microphones", "마이크가 하나 이상 필요합니다.");
return issues;
}
if (value.microphones.length > MAX_MICROPHONES) {
addIssue(issues, "$.microphones", `마이크는 최대 ${MAX_MICROPHONES}개까지 지원합니다.`);
}
const ids = [];
value.microphones.forEach((microphone, index) => {
const path = `$.microphones[${index}]`;
if (!requireRecord(microphone, path, issues)) return;
if (requireString(microphone.id, `${path}.id`, issues)) ids.push(microphone.id);
requirePosition(microphone.position, `${path}.position`, issues);
if (microphone.sensitivityDb !== undefined) {
requireFinite(microphone.sensitivityDb, `${path}.sensitivityDb`, issues, { min: -120, max: 120 });
}
});
for (const duplicate of findDuplicates(ids)) {
addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`);
}
return issues;
}
function validateSignal(signal, path, issues, assetIds, sampleRate) {
if (!requireRecord(signal, path, issues)) return;
if (signal.type === "tone") {
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
if (signal.frequency >= sampleRate / 2) {
addIssue(issues, `${path}.frequency`, `Nyquist 주파수 ${sampleRate / 2} Hz보다 낮아야 합니다.`);
}
}
if (signal.phaseDeg !== undefined) {
requireFinite(signal.phaseDeg, `${path}.phaseDeg`, issues, { min: -360, max: 360 });
}
} else if (signal.type === "noise") {
if (!["white", "pink"].includes(signal.color)) {
addIssue(issues, `${path}.color`, "white 또는 pink여야 합니다.");
}
} else if (signal.type === "asset") {
if (requireString(signal.assetId, `${path}.assetId`, issues) && !assetIds.has(signal.assetId)) {
addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`);
}
} else {
addIssue(issues, `${path}.type`, "tone, noise 또는 asset이어야 합니다.");
}
}
export function validateScene(value) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.scene", issues)) return issues;
if (value.units !== "m") addIssue(issues, "$.units", "m이어야 합니다.");
const sampleRateValid = requireInteger(value.sampleRate, "$.sampleRate", issues, { min: 8000, max: 192000 });
const sceneDurationValid = requireFinite(value.duration, "$.duration", issues, {
min: 0,
max: 300,
exclusiveMin: true,
});
requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 });
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
const assets = Array.isArray(value.assets) ? value.assets : [];
const assetIds = new Set();
const allIds = [];
assets.forEach((asset, index) => {
const path = `$.assets[${index}]`;
if (!requireRecord(asset, path, issues)) return;
if (requireString(asset.id, `${path}.id`, issues)) {
assetIds.add(asset.id);
allIds.push(asset.id);
}
requireString(asset.fileName, `${path}.fileName`, issues);
requireString(asset.mimeType, `${path}.mimeType`, issues);
if (!["audio", "image"].includes(asset.kind)) addIssue(issues, `${path}.kind`, "audio 또는 image여야 합니다.");
});
for (const duplicate of findDuplicates(allIds)) addIssue(issues, "$.assets", `중복 asset id: ${duplicate}`);
if (value.image !== undefined && requireRecord(value.image, "$.image", issues)) {
if (requireString(value.image.assetId, "$.image.assetId", issues) && !assetIds.has(value.image.assetId)) {
addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`);
}
if (requireRecord(value.image.plane, "$.image.plane", issues)) {
const plane = value.image.plane;
requireFinite(plane.z, "$.image.plane.z", issues, { min: 0, exclusiveMin: true });
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
requireFinite(plane[key], `$.image.plane.${key}`, issues);
}
if (isFiniteNumber(plane.xMin) && isFiniteNumber(plane.xMax) && plane.xMin >= plane.xMax) {
addIssue(issues, "$.image.plane", "xMin은 xMax보다 작아야 합니다.");
}
if (isFiniteNumber(plane.yMin) && isFiniteNumber(plane.yMax) && plane.yMin >= plane.yMax) {
addIssue(issues, "$.image.plane", "yMin은 yMax보다 작아야 합니다.");
}
}
}
if (!Array.isArray(value.sources) || value.sources.length === 0) {
addIssue(issues, "$.sources", "음원이 하나 이상 필요합니다.");
return issues;
}
if (value.sources.length > MAX_SOURCES) {
addIssue(issues, "$.sources", `음원은 최대 ${MAX_SOURCES}개까지 지원합니다.`);
}
const sourceIds = [];
value.sources.forEach((source, index) => {
const path = `$.sources[${index}]`;
if (!requireRecord(source, path, issues)) return;
if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id);
requireString(source.name, `${path}.name`, issues);
requirePosition(source.position, `${path}.position`, issues);
requireFinite(source.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
const startValid = requireFinite(source.startTime, `${path}.startTime`, issues, { min: 0 });
const durationValid = requireFinite(source.duration, `${path}.duration`, issues, {
min: 0,
max: 300,
exclusiveMin: true,
});
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
}
validateSignal(source.signal, `${path}.signal`, issues, assetIds, sampleRateValid ? value.sampleRate : 48000);
});
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
if (Array.isArray(value.ambient)) {
value.ambient.forEach((ambient, index) => {
const path = `$.ambient[${index}]`;
if (!requireRecord(ambient, path, issues)) return;
requireString(ambient.id, `${path}.id`, issues);
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
validateSignal(ambient.signal, `${path}.signal`, issues, assetIds, sampleRateValid ? value.sampleRate : 48000);
});
}
return issues;
}
export function validateExperiment(value, context = {}) {
/** @type {ValidationIssue[]} */
const issues = [];
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
requireString(value.arrayId, "$.arrayId", issues);
requireString(value.sceneId, "$.sceneId", issues);
if (context.arrayDefinition && value.arrayId !== context.arrayDefinition.id) {
addIssue(issues, "$.arrayId", "불러온 배열 id와 다릅니다.");
}
if (context.scene && value.sceneId !== context.scene.id) {
addIssue(issues, "$.sceneId", "불러온 장면 id와 다릅니다.");
}
if (requireRecord(value.scan, "$.scan", issues)) {
requireFinite(value.scan.z, "$.scan.z", issues, { min: 0, exclusiveMin: true });
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
requireFinite(value.scan[key], `$.scan.${key}`, issues);
}
requireInteger(value.scan.columns, "$.scan.columns", issues, { min: 8, max: MAX_GRID_SIDE });
requireInteger(value.scan.rows, "$.scan.rows", issues, { min: 8, max: MAX_GRID_SIDE });
if (isFiniteNumber(value.scan.xMin) && isFiniteNumber(value.scan.xMax) && value.scan.xMin >= value.scan.xMax) {
addIssue(issues, "$.scan", "xMin은 xMax보다 작아야 합니다.");
}
if (isFiniteNumber(value.scan.yMin) && isFiniteNumber(value.scan.yMax) && value.scan.yMin >= value.scan.yMax) {
addIssue(issues, "$.scan", "yMin은 yMax보다 작아야 합니다.");
}
}
if (!Array.isArray(value.frequencyBands) || value.frequencyBands.length === 0) {
addIssue(issues, "$.frequencyBands", "주파수 대역이 하나 이상 필요합니다.");
} else {
value.frequencyBands.forEach((band, index) => {
const path = `$.frequencyBands[${index}]`;
if (!requireRecord(band, path, issues)) return;
requireString(band.id, `${path}.id`, issues);
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 });
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
if (minValid && maxValid && band.minHz >= band.maxHz) {
addIssue(issues, path, "minHz는 maxHz보다 작아야 합니다.");
}
if (context.scene && maxValid && band.maxHz >= context.scene.sampleRate / 2) {
addIssue(issues, `${path}.maxHz`, `Nyquist 주파수 ${context.scene.sampleRate / 2} Hz보다 낮아야 합니다.`);
}
if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) {
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
}
});
}
if (requireRecord(value.stft, "$.stft", issues)) {
if (![128, 256, 512, 1024, 2048, 4096].includes(value.stft.fftSize)) {
addIssue(issues, "$.stft.fftSize", "지원되는 2의 거듭제곱 크기가 아닙니다.");
}
if (!Number.isInteger(value.stft.hopSize) || value.stft.hopSize < 1 || value.stft.hopSize > value.stft.fftSize) {
addIssue(issues, "$.stft.hopSize", "1 이상 fftSize 이하의 정수여야 합니다.");
}
if (value.stft.window !== "hann") addIssue(issues, "$.stft.window", "hann이어야 합니다.");
}
if (requireRecord(value.beamforming, "$.beamforming", issues)) {
if (value.beamforming.algorithm !== "das") addIssue(issues, "$.beamforming.algorithm", "das이어야 합니다.");
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
}
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
min: 0,
max: 10,
exclusiveMin: true,
});
}
return issues;
}
export function assertValid(value, validator, label = "데이터") {
const issues = validator(value);
if (issues.length > 0) {
const details = issues.map(({ path, message }) => `${path}: ${message}`).join("\n");
throw new Error(`${label} 검증 실패\n${details}`);
}
return value;
}