fix: harden simulator data contracts
This commit is contained in:
@@ -2,8 +2,8 @@
|
|||||||
"schemaVersion": "1.0.0",
|
"schemaVersion": "1.0.0",
|
||||||
"kind": "micarrsim.array",
|
"kind": "micarrsim.array",
|
||||||
"id": "rectangular-16x12",
|
"id": "rectangular-16x12",
|
||||||
"name": "16×12 rectangular baseline",
|
"name": "16x12 rectangular baseline",
|
||||||
"description": "0.45 m square apertureì— ê· ì¼ ë°°ì¹˜í•œ 192ì±„ë„ ê¸°ì¤€ ë°°ì—´",
|
"description": "192-channel uniform rectangular layout on a 0.45 m square aperture",
|
||||||
"units": "m",
|
"units": "m",
|
||||||
"coordinateSystem": {
|
"coordinateSystem": {
|
||||||
"handedness": "right",
|
"handedness": "right",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"duration": 0.25,
|
"duration": 0.25,
|
||||||
"speedOfSound": 343,
|
"speedOfSound": 343,
|
||||||
"seed": 20260828,
|
"seed": 20260828,
|
||||||
|
"mix": { "normalization": "none", "targetPeak": 0.95 },
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"id": "source-1",
|
"id": "source-1",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 데이터 계약
|
||||||
|
|
||||||
|
micarrsim의 저장 형식은 JSON Schema Draft 2020-12와 추가 관계 검증으로 정의한다. 모든 길이는 m,
|
||||||
|
시간은 s, 주파수는 Hz다. 배열 중심이 원점이고 `+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서
|
||||||
|
장면을 향하는 우수 좌표계를 사용한다.
|
||||||
|
|
||||||
|
## 파일 종류
|
||||||
|
|
||||||
|
- `micarrsim.array`: aperture와 마이크별 `[x, y, z]` 좌표
|
||||||
|
- `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest
|
||||||
|
- `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
|
||||||
|
- `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보
|
||||||
|
|
||||||
|
scene JSON에는 192채널 원시 sample을 넣지 않는다. 합성 tone/noise는 seed가 포함된 recipe로
|
||||||
|
재생성하고, 녹음 파일과 이미지는 manifest 기준의 안전한 상대 경로로 참조한다. 사용자 컴퓨터의
|
||||||
|
절대 경로, URL, `..` 경로는 저장하지 않는다.
|
||||||
|
|
||||||
|
## 오디오와 map 정규화
|
||||||
|
|
||||||
|
`scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래
|
||||||
|
gain을 보존하고, `peak`는 합성 전 peak를 `targetPeak`에 맞춘다. result의 `signalStats`에는
|
||||||
|
정규화 전 peak, 실제 scale, scale 적용 후에도 범위를 넘은 sample 수를 기록한다.
|
||||||
|
|
||||||
|
이는 acoustic map 표시 정규화와 별개다. result의 `rawMap`은 반드시 선형 power이고,
|
||||||
|
`displayMap`은 peak-normalized 값 또는 dB 값일 수 있다. 후처리 결과는 `processedMaps`에 넣어
|
||||||
|
raw DAS map을 덮어쓰지 않는다.
|
||||||
|
|
||||||
|
map은 row-major 배열이다. 첫 행은 `yMax`, 마지막 행은 `yMin`, 각 행은 `xMin`에서 `xMax`
|
||||||
|
방향이다. `values.length`는 반드시 `rows × columns`와 같아야 한다.
|
||||||
|
|
||||||
|
## 재현성 hash
|
||||||
|
|
||||||
|
`result.inputHash`는 실행에 사용한 `{ array, scene, experiment }`를 객체 key 사전순으로 재귀
|
||||||
|
정렬하고 공백 없이 JSON 직렬화한 UTF-8 byte의 SHA-256 소문자 hex다. 사용한 asset은 실행 전에
|
||||||
|
SHA-256을 계산해 scene의 해당 asset `sha256` 필드를 채운 상태로 canonical input을 만든다.
|
||||||
|
따라서 파일 이름이 같아도 내용이 바뀌면 hash가 달라진다.
|
||||||
|
|
||||||
|
## 버전과 검증
|
||||||
|
|
||||||
|
현재 계약 버전은 `1.0.0`이다. 브라우저는 `packages/contracts/schema-validator.js`로 저장소의
|
||||||
|
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
|
||||||
|
|
||||||
|
- 중복 id와 array/scene/experiment/result 참조
|
||||||
|
- asset 종류와 안전한 상대 경로
|
||||||
|
- Nyquist 및 시간·scan 범위
|
||||||
|
- map shape와 source peak 매칭
|
||||||
|
- 현재 비스트리밍 CPU backend의 512 MiB channel-buffer 예산
|
||||||
|
|
||||||
|
지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# Contracts
|
# Contracts
|
||||||
|
|
||||||
네이티브 JSON Schema와 브라우저용 관계 검증기를 함께 둡니다. JSON Schema는 파일 형식과 기본
|
네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제
|
||||||
범위를 정의하고 `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼 스키마만으로
|
JSON Schema를 실행하고, `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼
|
||||||
표현하기 불편한 조건을 검사합니다.
|
스키마만으로 표현하기 불편한 조건을 추가 검사합니다.
|
||||||
|
|
||||||
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
|
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
|
||||||
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
|
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
|
||||||
|
|
||||||
|
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
function isRecord(value) {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valuesEqual(left, right) {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePointer(rootSchema, reference) {
|
||||||
|
if (!reference.startsWith("#/")) throw new Error(`외부 schema ref는 지원하지 않습니다: ${reference}`);
|
||||||
|
return reference
|
||||||
|
.slice(2)
|
||||||
|
.split("/")
|
||||||
|
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||||
|
.reduce((current, key) => current?.[key], rootSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesType(value, type) {
|
||||||
|
if (type === "null") return value === null;
|
||||||
|
if (type === "array") return Array.isArray(value);
|
||||||
|
if (type === "object") return isRecord(value);
|
||||||
|
if (type === "integer") return Number.isInteger(value);
|
||||||
|
if (type === "number") return typeof value === "number" && Number.isFinite(value);
|
||||||
|
return typeof value === type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIssue(issues, path, message) {
|
||||||
|
issues.push({ path, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNode(value, schema, rootSchema, path, issues) {
|
||||||
|
if (schema === false) {
|
||||||
|
addIssue(issues, path, "허용되지 않는 값입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (schema === true) return;
|
||||||
|
if (schema.$ref) {
|
||||||
|
const target = resolvePointer(rootSchema, schema.$ref);
|
||||||
|
if (!target) throw new Error(`schema ref를 찾을 수 없습니다: ${schema.$ref}`);
|
||||||
|
validateNode(value, target, rootSchema, path, issues);
|
||||||
|
}
|
||||||
|
if (schema.oneOf) {
|
||||||
|
const branchResults = schema.oneOf.map((branch) => {
|
||||||
|
const branchIssues = [];
|
||||||
|
validateNode(value, branch, rootSchema, path, branchIssues);
|
||||||
|
return branchIssues;
|
||||||
|
});
|
||||||
|
if (branchResults.filter((branchIssues) => branchIssues.length === 0).length !== 1) {
|
||||||
|
addIssue(issues, path, "허용된 형식 중 정확히 하나와 일치해야 합니다.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(schema, "const") && !valuesEqual(value, schema.const)) {
|
||||||
|
addIssue(issues, path, `${JSON.stringify(schema.const)} 값이어야 합니다.`);
|
||||||
|
}
|
||||||
|
if (schema.enum && !schema.enum.some((candidate) => valuesEqual(value, candidate))) {
|
||||||
|
addIssue(issues, path, `허용 값: ${schema.enum.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedTypes = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||||
|
if (expectedTypes.length > 0 && !expectedTypes.some((type) => matchesType(value, type))) {
|
||||||
|
addIssue(issues, path, `${expectedTypes.join(" 또는 ")} 형식이어야 합니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
if (schema.minimum !== undefined && value < schema.minimum) addIssue(issues, path, `${schema.minimum} 이상이어야 합니다.`);
|
||||||
|
if (schema.maximum !== undefined && value > schema.maximum) addIssue(issues, path, `${schema.maximum} 이하여야 합니다.`);
|
||||||
|
if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) {
|
||||||
|
addIssue(issues, path, `${schema.exclusiveMinimum}보다 커야 합니다.`);
|
||||||
|
}
|
||||||
|
if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) {
|
||||||
|
addIssue(issues, path, `${schema.exclusiveMaximum}보다 작아야 합니다.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (schema.minLength !== undefined && value.length < schema.minLength) addIssue(issues, path, `길이가 ${schema.minLength} 이상이어야 합니다.`);
|
||||||
|
if (schema.maxLength !== undefined && value.length > schema.maxLength) addIssue(issues, path, `길이가 ${schema.maxLength} 이하여야 합니다.`);
|
||||||
|
if (schema.pattern && !new RegExp(schema.pattern, "u").test(value)) addIssue(issues, path, `형식 ${schema.pattern}과 일치해야 합니다.`);
|
||||||
|
if (schema.format === "date-time" && (!/^\d{4}-\d{2}-\d{2}T/.test(value) || !Number.isFinite(Date.parse(value)))) {
|
||||||
|
addIssue(issues, path, "유효한 ISO 8601 date-time이어야 합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (schema.minItems !== undefined && value.length < schema.minItems) addIssue(issues, path, `항목이 ${schema.minItems}개 이상이어야 합니다.`);
|
||||||
|
if (schema.maxItems !== undefined && value.length > schema.maxItems) addIssue(issues, path, `항목이 ${schema.maxItems}개 이하여야 합니다.`);
|
||||||
|
if (schema.prefixItems) {
|
||||||
|
schema.prefixItems.forEach((itemSchema, index) => {
|
||||||
|
if (index < value.length) validateNode(value[index], itemSchema, rootSchema, `${path}[${index}]`, issues);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (schema.items !== undefined) {
|
||||||
|
const startIndex = schema.prefixItems?.length ?? 0;
|
||||||
|
for (let index = startIndex; index < value.length; index += 1) {
|
||||||
|
validateNode(value[index], schema.items, rootSchema, `${path}[${index}]`, issues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(value)) {
|
||||||
|
for (const requiredKey of schema.required ?? []) {
|
||||||
|
if (!Object.hasOwn(value, requiredKey)) addIssue(issues, `${path}.${requiredKey}`, "필수 항목입니다.");
|
||||||
|
}
|
||||||
|
const properties = schema.properties ?? {};
|
||||||
|
for (const [key, childValue] of Object.entries(value)) {
|
||||||
|
if (Object.hasOwn(properties, key)) {
|
||||||
|
validateNode(childValue, properties[key], rootSchema, `${path}.${key}`, issues);
|
||||||
|
} else if (schema.additionalProperties === false) {
|
||||||
|
addIssue(issues, `${path}.${key}`, "알 수 없는 항목입니다.");
|
||||||
|
} else if (isRecord(schema.additionalProperties)) {
|
||||||
|
validateNode(childValue, schema.additionalProperties, rootSchema, `${path}.${key}`, issues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAgainstSchema(value, schema) {
|
||||||
|
const issues = [];
|
||||||
|
validateNode(value, schema, schema, "$", issues);
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaPaths = {
|
||||||
|
array: "/schemas/array.schema.json",
|
||||||
|
scene: "/schemas/scene.schema.json",
|
||||||
|
experiment: "/schemas/experiment.schema.json",
|
||||||
|
result: "/schemas/result.schema.json",
|
||||||
|
};
|
||||||
|
const schemaPromises = new Map();
|
||||||
|
|
||||||
|
export async function loadSchema(name) {
|
||||||
|
if (!Object.hasOwn(schemaPaths, name)) throw new Error(`알 수 없는 schema: ${name}`);
|
||||||
|
if (!schemaPromises.has(name)) {
|
||||||
|
schemaPromises.set(
|
||||||
|
name,
|
||||||
|
fetch(schemaPaths[name]).then((response) => {
|
||||||
|
if (!response.ok) throw new Error(`${schemaPaths[name]} 로드 실패: HTTP ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return schemaPromises.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateDocumentStructure(name, value) {
|
||||||
|
return validateAgainstSchema(value, await loadSchema(name));
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { validateDocumentStructure } from "./schema-validator.js";
|
||||||
|
|
||||||
export const SCHEMA_VERSION = "1.0.0";
|
export const SCHEMA_VERSION = "1.0.0";
|
||||||
const MAX_MICROPHONES = 1024;
|
const MAX_MICROPHONES = 1024;
|
||||||
const MAX_SOURCES = 32;
|
const MAX_SOURCES = 32;
|
||||||
@@ -84,7 +86,16 @@ function findDuplicates(values) {
|
|||||||
return [...duplicates];
|
return [...duplicates];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateArrayDefinition(value) {
|
function isSafeRelativeAssetPath(value) {
|
||||||
|
if (typeof value !== "string" || value.trim() === "") return false;
|
||||||
|
const normalized = value.split(String.fromCharCode(92)).join("/");
|
||||||
|
return !normalized.startsWith("/") &&
|
||||||
|
!/^[a-z][a-z0-9+.-]*:/i.test(normalized) &&
|
||||||
|
!normalized.split("/").includes("..") &&
|
||||||
|
!normalized.includes("\0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArrayRelationships(value) {
|
||||||
/** @type {ValidationIssue[]} */
|
/** @type {ValidationIssue[]} */
|
||||||
const issues = [];
|
const issues = [];
|
||||||
if (!validateHeader(value, "micarrsim.array", issues)) return issues;
|
if (!validateHeader(value, "micarrsim.array", issues)) return issues;
|
||||||
@@ -127,7 +138,7 @@ export function validateArrayDefinition(value) {
|
|||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSignal(signal, path, issues, assetIds, sampleRate) {
|
function validateSignal(signal, path, issues, assetsById, sampleRate) {
|
||||||
if (!requireRecord(signal, path, issues)) return;
|
if (!requireRecord(signal, path, issues)) return;
|
||||||
if (signal.type === "tone") {
|
if (signal.type === "tone") {
|
||||||
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
|
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
|
||||||
@@ -143,15 +154,17 @@ function validateSignal(signal, path, issues, assetIds, sampleRate) {
|
|||||||
addIssue(issues, `${path}.color`, "white 또는 pink여야 합니다.");
|
addIssue(issues, `${path}.color`, "white 또는 pink여야 합니다.");
|
||||||
}
|
}
|
||||||
} else if (signal.type === "asset") {
|
} else if (signal.type === "asset") {
|
||||||
if (requireString(signal.assetId, `${path}.assetId`, issues) && !assetIds.has(signal.assetId)) {
|
if (requireString(signal.assetId, `${path}.assetId`, issues)) {
|
||||||
addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`);
|
const asset = assetsById.get(signal.assetId);
|
||||||
|
if (!asset) addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`);
|
||||||
|
else if (asset.kind !== "audio") addIssue(issues, `${path}.assetId`, "audio asset을 참조해야 합니다.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
addIssue(issues, `${path}.type`, "tone, noise 또는 asset이어야 합니다.");
|
addIssue(issues, `${path}.type`, "tone, noise 또는 asset이어야 합니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateScene(value) {
|
function validateSceneRelationships(value) {
|
||||||
/** @type {ValidationIssue[]} */
|
/** @type {ValidationIssue[]} */
|
||||||
const issues = [];
|
const issues = [];
|
||||||
if (!validateHeader(value, "micarrsim.scene", issues)) return issues;
|
if (!validateHeader(value, "micarrsim.scene", issues)) return issues;
|
||||||
@@ -166,24 +179,28 @@ export function validateScene(value) {
|
|||||||
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
|
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
|
||||||
|
|
||||||
const assets = Array.isArray(value.assets) ? value.assets : [];
|
const assets = Array.isArray(value.assets) ? value.assets : [];
|
||||||
const assetIds = new Set();
|
const assetsById = new Map();
|
||||||
const allIds = [];
|
const allIds = [];
|
||||||
assets.forEach((asset, index) => {
|
assets.forEach((asset, index) => {
|
||||||
const path = `$.assets[${index}]`;
|
const path = `$.assets[${index}]`;
|
||||||
if (!requireRecord(asset, path, issues)) return;
|
if (!requireRecord(asset, path, issues)) return;
|
||||||
if (requireString(asset.id, `${path}.id`, issues)) {
|
if (requireString(asset.id, `${path}.id`, issues)) {
|
||||||
assetIds.add(asset.id);
|
assetsById.set(asset.id, asset);
|
||||||
allIds.push(asset.id);
|
allIds.push(asset.id);
|
||||||
}
|
}
|
||||||
requireString(asset.fileName, `${path}.fileName`, issues);
|
if (requireString(asset.fileName, `${path}.fileName`, issues) && !isSafeRelativeAssetPath(asset.fileName)) {
|
||||||
|
addIssue(issues, `${path}.fileName`, "manifest 기준의 안전한 상대 경로여야 합니다.");
|
||||||
|
}
|
||||||
requireString(asset.mimeType, `${path}.mimeType`, issues);
|
requireString(asset.mimeType, `${path}.mimeType`, issues);
|
||||||
if (!["audio", "image"].includes(asset.kind)) addIssue(issues, `${path}.kind`, "audio 또는 image여야 합니다.");
|
if (!["audio", "image"].includes(asset.kind)) addIssue(issues, `${path}.kind`, "audio 또는 image여야 합니다.");
|
||||||
});
|
});
|
||||||
for (const duplicate of findDuplicates(allIds)) addIssue(issues, "$.assets", `중복 asset id: ${duplicate}`);
|
for (const duplicate of findDuplicates(allIds)) addIssue(issues, "$.assets", `중복 asset id: ${duplicate}`);
|
||||||
|
|
||||||
if (value.image !== undefined && requireRecord(value.image, "$.image", issues)) {
|
if (value.image !== undefined && requireRecord(value.image, "$.image", issues)) {
|
||||||
if (requireString(value.image.assetId, "$.image.assetId", issues) && !assetIds.has(value.image.assetId)) {
|
if (requireString(value.image.assetId, "$.image.assetId", issues)) {
|
||||||
addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`);
|
const asset = assetsById.get(value.image.assetId);
|
||||||
|
if (!asset) addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`);
|
||||||
|
else if (asset.kind !== "image") addIssue(issues, "$.image.assetId", "image asset을 참조해야 합니다.");
|
||||||
}
|
}
|
||||||
if (requireRecord(value.image.plane, "$.image.plane", issues)) {
|
if (requireRecord(value.image.plane, "$.image.plane", issues)) {
|
||||||
const plane = value.image.plane;
|
const plane = value.image.plane;
|
||||||
@@ -225,23 +242,25 @@ export function validateScene(value) {
|
|||||||
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
|
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
|
||||||
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
|
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
|
||||||
}
|
}
|
||||||
validateSignal(source.signal, `${path}.signal`, issues, assetIds, sampleRateValid ? value.sampleRate : 48000);
|
validateSignal(source.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
|
||||||
});
|
});
|
||||||
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
|
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
|
||||||
|
|
||||||
if (Array.isArray(value.ambient)) {
|
if (Array.isArray(value.ambient)) {
|
||||||
|
const ambientIds = [];
|
||||||
value.ambient.forEach((ambient, index) => {
|
value.ambient.forEach((ambient, index) => {
|
||||||
const path = `$.ambient[${index}]`;
|
const path = `$.ambient[${index}]`;
|
||||||
if (!requireRecord(ambient, path, issues)) return;
|
if (!requireRecord(ambient, path, issues)) return;
|
||||||
requireString(ambient.id, `${path}.id`, issues);
|
if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id);
|
||||||
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
|
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
|
||||||
validateSignal(ambient.signal, `${path}.signal`, issues, assetIds, sampleRateValid ? value.sampleRate : 48000);
|
validateSignal(ambient.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
|
||||||
});
|
});
|
||||||
|
for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`);
|
||||||
}
|
}
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateExperiment(value, context = {}) {
|
function validateExperimentRelationships(value, context = {}) {
|
||||||
/** @type {ValidationIssue[]} */
|
/** @type {ValidationIssue[]} */
|
||||||
const issues = [];
|
const issues = [];
|
||||||
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
|
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
|
||||||
@@ -272,10 +291,11 @@ export function validateExperiment(value, context = {}) {
|
|||||||
if (!Array.isArray(value.frequencyBands) || value.frequencyBands.length === 0) {
|
if (!Array.isArray(value.frequencyBands) || value.frequencyBands.length === 0) {
|
||||||
addIssue(issues, "$.frequencyBands", "주파수 대역이 하나 이상 필요합니다.");
|
addIssue(issues, "$.frequencyBands", "주파수 대역이 하나 이상 필요합니다.");
|
||||||
} else {
|
} else {
|
||||||
|
const bandIds = [];
|
||||||
value.frequencyBands.forEach((band, index) => {
|
value.frequencyBands.forEach((band, index) => {
|
||||||
const path = `$.frequencyBands[${index}]`;
|
const path = `$.frequencyBands[${index}]`;
|
||||||
if (!requireRecord(band, path, issues)) return;
|
if (!requireRecord(band, path, issues)) return;
|
||||||
requireString(band.id, `${path}.id`, issues);
|
if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id);
|
||||||
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 });
|
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 });
|
||||||
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
|
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
|
||||||
if (minValid && maxValid && band.minHz >= band.maxHz) {
|
if (minValid && maxValid && band.minHz >= band.maxHz) {
|
||||||
@@ -288,6 +308,7 @@ export function validateExperiment(value, context = {}) {
|
|||||||
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
|
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.frequencyBands", `중복 band id: ${duplicate}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireRecord(value.stft, "$.stft", issues)) {
|
if (requireRecord(value.stft, "$.stft", issues)) {
|
||||||
@@ -310,11 +331,89 @@ export function validateExperiment(value, context = {}) {
|
|||||||
exclusiveMin: true,
|
exclusiveMin: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (context.arrayDefinition && context.scene) {
|
||||||
|
const estimatedBytes =
|
||||||
|
context.arrayDefinition.microphones.length * Math.ceil(context.scene.duration * context.scene.sampleRate) * 4;
|
||||||
|
const maximumBytes = 512 * 1024 * 1024;
|
||||||
|
if (estimatedBytes > maximumBytes) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$",
|
||||||
|
`현재 비스트리밍 CPU 모델의 채널 buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertValid(value, validator, label = "데이터") {
|
function validateResultRelationships(value, context = {}) {
|
||||||
const issues = validator(value);
|
/** @type {ValidationIssue[]} */
|
||||||
|
const issues = [];
|
||||||
|
if (!isRecord(value)) return issues;
|
||||||
|
if (context.experiment) {
|
||||||
|
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
|
||||||
|
if (value.arrayId !== context.experiment.arrayId) addIssue(issues, "$.arrayId", "실험의 배열 id와 다릅니다.");
|
||||||
|
if (value.sceneId !== context.experiment.sceneId) addIssue(issues, "$.sceneId", "실험의 장면 id와 다릅니다.");
|
||||||
|
}
|
||||||
|
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
||||||
|
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
||||||
|
const bandIds = [];
|
||||||
|
if (Array.isArray(value.bands)) {
|
||||||
|
value.bands.forEach((band, bandIndex) => {
|
||||||
|
if (!isRecord(band)) return;
|
||||||
|
const path = `$.bands[${bandIndex}]`;
|
||||||
|
if (typeof band.id === "string") {
|
||||||
|
bandIds.push(band.id);
|
||||||
|
if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다.");
|
||||||
|
}
|
||||||
|
const maps = [band.rawMap, band.displayMap, ...(band.processedMaps?.map((item) => item.map) ?? [])].filter(Boolean);
|
||||||
|
maps.forEach((map, mapIndex) => {
|
||||||
|
if (!isRecord(map) || !Array.isArray(map.values)) return;
|
||||||
|
if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) {
|
||||||
|
addIssue(issues, `${path}.maps[${mapIndex}].values`, "values 길이는 rows × columns와 같아야 합니다.");
|
||||||
|
}
|
||||||
|
if (context.experiment && (map.rows !== context.experiment.scan.rows || map.columns !== context.experiment.scan.columns)) {
|
||||||
|
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (isRecord(band.rawMap) && band.rawMap.scale !== "power") {
|
||||||
|
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power scale이어야 합니다.");
|
||||||
|
}
|
||||||
|
if (Array.isArray(band.peaks)) {
|
||||||
|
band.peaks.forEach((peak, peakIndex) => {
|
||||||
|
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
|
||||||
|
addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`);
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function combineValidation(name, value, relationshipIssues) {
|
||||||
|
return [...(await validateDocumentStructure(name, value)), ...relationshipIssues];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateArrayDefinition(value) {
|
||||||
|
return combineValidation("array", value, validateArrayRelationships(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateScene(value) {
|
||||||
|
return combineValidation("scene", value, validateSceneRelationships(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateExperiment(value, context = {}) {
|
||||||
|
return combineValidation("experiment", value, validateExperimentRelationships(value, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateResult(value, context = {}) {
|
||||||
|
return combineValidation("result", value, validateResultRelationships(value, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertValid(value, validator, label = "데이터") {
|
||||||
|
const issues = await validator(value);
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
const details = issues.map(({ path, message }) => `${path}: ${message}`).join("\n");
|
const details = issues.map(({ path, message }) => `${path}: ${message}`).join("\n");
|
||||||
throw new Error(`${label} 검증 실패\n${details}`);
|
throw new Error(`${label} 검증 실패\n${details}`);
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@ JSON Schema Draft 2020-12 형식입니다.
|
|||||||
- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 보정값
|
- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 보정값
|
||||||
- `scene.schema.json`: 재현 가능한 음원 recipe, asset 참조와 ground-truth 위치
|
- `scene.schema.json`: 재현 가능한 음원 recipe, asset 참조와 ground-truth 위치
|
||||||
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
|
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
|
||||||
- `result.schema.json`: 실행 환경, timing, peak와 평가 지표 요약
|
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 실행 timing과 평가 지표
|
||||||
|
|
||||||
중복 id, Nyquist, 시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다.
|
중복 id, Nyquist, 시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다.
|
||||||
|
|||||||
+74
-18
@@ -1,14 +1,31 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://micarrsim.local/schemas/result.schema.json",
|
"$id": "https://micarrsim.local/schemas/result.schema.json",
|
||||||
"title": "micarrsim result summary",
|
"title": "micarrsim simulation result",
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object",
|
||||||
"required": ["schemaVersion", "kind", "experimentId", "createdAt", "backend", "inputHash", "timingMs", "bands"],
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "backend",
|
||||||
|
"inputHash", "signalStats", "timingMs", "bands"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"schemaVersion": { "const": "1.0.0" }, "kind": { "const": "micarrsim.result" },
|
"schemaVersion": { "const": "1.0.0" },
|
||||||
|
"kind": { "const": "micarrsim.result" },
|
||||||
"experimentId": { "type": "string", "minLength": 1 },
|
"experimentId": { "type": "string", "minLength": 1 },
|
||||||
"createdAt": { "type": "string", "format": "date-time" }, "backend": { "enum": ["cpu", "webgpu"] },
|
"arrayId": { "type": "string", "minLength": 1 },
|
||||||
|
"sceneId": { "type": "string", "minLength": 1 },
|
||||||
|
"createdAt": { "type": "string", "format": "date-time" },
|
||||||
|
"backend": { "enum": ["cpu", "webgpu"] },
|
||||||
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||||
|
"signalStats": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount"],
|
||||||
|
"properties": {
|
||||||
|
"preNormalizationPeak": { "type": "number", "minimum": 0 },
|
||||||
|
"appliedScale": { "type": "number", "minimum": 0 },
|
||||||
|
"clippedSampleCount": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
"timingMs": {
|
"timingMs": {
|
||||||
"type": "object", "additionalProperties": false, "required": ["total", "beamforming"],
|
"type": "object", "additionalProperties": false, "required": ["total", "beamforming"],
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -19,24 +36,63 @@
|
|||||||
},
|
},
|
||||||
"environment": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
|
"environment": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
|
||||||
"bands": {
|
"bands": {
|
||||||
"type": "array", "minItems": 1,
|
"type": "array", "minItems": 1, "maxItems": 8,
|
||||||
"items": {
|
"items": { "$ref": "#/$defs/bandResult" }
|
||||||
"type": "object", "additionalProperties": false,
|
}
|
||||||
"required": ["id", "peakPosition", "peakValue", "metrics"],
|
},
|
||||||
"properties": {
|
"$defs": {
|
||||||
"id": { "type": "string", "minLength": 1 },
|
"position": {
|
||||||
"peakPosition": {
|
"type": "array",
|
||||||
"type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
|
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
|
||||||
"items": false, "minItems": 3, "maxItems": 3
|
"items": false, "minItems": 3, "maxItems": 3
|
||||||
},
|
},
|
||||||
"peakValue": { "type": "number", "minimum": 0 },
|
"map": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["rows", "columns", "scale", "values"],
|
||||||
|
"properties": {
|
||||||
|
"rows": { "type": "integer", "minimum": 1, "maximum": 512 },
|
||||||
|
"columns": { "type": "integer", "minimum": 1, "maximum": 512 },
|
||||||
|
"scale": { "enum": ["power", "normalized", "db"] },
|
||||||
|
"values": {
|
||||||
|
"type": "array", "minItems": 1, "maxItems": 262144,
|
||||||
|
"items": { "type": "number" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"peak": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["position", "value"],
|
||||||
|
"properties": {
|
||||||
|
"sourceId": { "type": "string", "minLength": 1 },
|
||||||
|
"position": { "$ref": "#/$defs/position" },
|
||||||
|
"value": { "type": "number", "minimum": 0 },
|
||||||
|
"localizationErrorM": { "type": "number", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"processedMap": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["algorithm", "map"],
|
||||||
|
"properties": {
|
||||||
|
"algorithm": { "enum": ["damas", "clean-sc", "custom"] },
|
||||||
|
"map": { "$ref": "#/$defs/map" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bandResult": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["id", "color", "rawMap", "peaks", "metrics"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "minLength": 1 },
|
||||||
|
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
|
||||||
|
"rawMap": { "$ref": "#/$defs/map" },
|
||||||
|
"displayMap": { "$ref": "#/$defs/map" },
|
||||||
|
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
|
||||||
|
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["localizationErrorM", "peakToSidelobeDb"],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"localizationErrorM": { "type": "number", "minimum": 0 }, "peakToSidelobeDb": { "type": "number" },
|
"peakToSidelobeDb": { "type": "number" },
|
||||||
"energyInsideTruthRatio": { "type": "number", "minimum": 0, "maximum": 1 }
|
"energyInsideTruthRatio": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||||
}
|
"precision": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||||
|
"recall": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"title": "micarrsim acoustic scene",
|
"title": "micarrsim acoustic scene",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["schemaVersion", "kind", "id", "name", "units", "sampleRate", "duration", "speedOfSound", "seed", "sources"],
|
"required": ["schemaVersion", "kind", "id", "name", "units", "sampleRate", "duration", "speedOfSound", "seed", "mix", "sources"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"schemaVersion": { "const": "1.0.0" }, "kind": { "const": "micarrsim.scene" },
|
"schemaVersion": { "const": "1.0.0" }, "kind": { "const": "micarrsim.scene" },
|
||||||
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
|
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
"duration": { "type": "number", "exclusiveMinimum": 0, "maximum": 300 },
|
"duration": { "type": "number", "exclusiveMinimum": 0, "maximum": 300 },
|
||||||
"speedOfSound": { "type": "number", "minimum": 250, "maximum": 400 },
|
"speedOfSound": { "type": "number", "minimum": 250, "maximum": 400 },
|
||||||
"seed": { "type": "integer", "minimum": 0, "maximum": 4294967295 },
|
"seed": { "type": "integer", "minimum": 0, "maximum": 4294967295 },
|
||||||
|
"mix": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["normalization", "targetPeak"],
|
||||||
|
"properties": {
|
||||||
|
"normalization": { "enum": ["none", "peak"] },
|
||||||
|
"targetPeak": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
"image": { "$ref": "#/$defs/image" },
|
"image": { "$ref": "#/$defs/image" },
|
||||||
"assets": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/asset" } },
|
"assets": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/asset" } },
|
||||||
"sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } },
|
"sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } },
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ $arrayDefinition = [ordered]@{
|
|||||||
schemaVersion = '1.0.0'
|
schemaVersion = '1.0.0'
|
||||||
kind = 'micarrsim.array'
|
kind = 'micarrsim.array'
|
||||||
id = 'rectangular-16x12'
|
id = 'rectangular-16x12'
|
||||||
name = '16×12 rectangular baseline'
|
name = '16x12 rectangular baseline'
|
||||||
description = '0.45 m square aperture에 균일 배치한 192채널 기준 배열'
|
description = '192-channel uniform rectangular layout on a 0.45 m square aperture'
|
||||||
units = 'm'
|
units = 'm'
|
||||||
coordinateSystem = [ordered]@{
|
coordinateSystem = [ordered]@{
|
||||||
handedness = 'right'
|
handedness = 'right'
|
||||||
|
|||||||
@@ -8,7 +8,25 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
|||||||
if (!Object.is(actual, expected)) throw new Error(`${message}: expected ${expected}, received ${actual}`);
|
if (!Object.is(actual, expected)) throw new Error(`${message}: expected ${expected}, received ${actual}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
await import("/tests/unit/contracts.test.js");
|
await import("/tests/unit/contracts.test.js");
|
||||||
|
} catch (error) {
|
||||||
|
registeredTests.push({
|
||||||
|
name: "테스트 모듈을 불러온다",
|
||||||
|
callback: () => {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registeredTests.length === 0) {
|
||||||
|
registeredTests.push({
|
||||||
|
name: "하나 이상의 테스트가 등록된다",
|
||||||
|
callback: () => {
|
||||||
|
throw new Error("등록된 테스트가 없습니다.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const resultList = document.querySelector("#test-results");
|
const resultList = document.querySelector("#test-results");
|
||||||
const summary = document.querySelector("#test-summary");
|
const summary = document.querySelector("#test-summary");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
validateArrayDefinition,
|
validateArrayDefinition,
|
||||||
validateExperiment,
|
validateExperiment,
|
||||||
|
validateResult,
|
||||||
validateScene,
|
validateScene,
|
||||||
} from "/packages/contracts/validation.js";
|
} from "/packages/contracts/validation.js";
|
||||||
|
|
||||||
@@ -13,7 +14,8 @@ async function loadJson(path) {
|
|||||||
test("192채널 기준 배열이 계약을 통과한다", async () => {
|
test("192채널 기준 배열이 계약을 통과한다", async () => {
|
||||||
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
||||||
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
|
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
|
||||||
assertEqual(validateArrayDefinition(arrayDefinition).length, 0, "array issues");
|
assertEqual(arrayDefinition.name, "16x12 rectangular baseline", "fixture encoding");
|
||||||
|
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => {
|
test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => {
|
||||||
@@ -22,11 +24,11 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
|
|||||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||||
]);
|
]);
|
||||||
assertEqual(validateScene(scene).length, 0, "scene issues");
|
assertEqual((await validateScene(scene)).length, 0, "scene issues");
|
||||||
assertEqual(validateExperiment(experiment, { arrayDefinition, scene }).length, 0, "experiment issues");
|
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("중복 마이크 id를 거부한다", () => {
|
test("중복 마이크 id를 거부한다", async () => {
|
||||||
const arrayDefinition = {
|
const arrayDefinition = {
|
||||||
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m",
|
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m",
|
||||||
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
|
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
|
||||||
@@ -36,26 +38,95 @@ test("중복 마이크 id를 거부한다", () => {
|
|||||||
{ id: "mic-1", position: [0.1, 0, 0] },
|
{ id: "mic-1", position: [0.1, 0, 0] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
assert(validateArrayDefinition(arrayDefinition).some(({ message }) => message.includes("중복")));
|
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Nyquist를 넘는 tone을 거부한다", () => {
|
test("Nyquist를 넘는 tone을 거부한다", async () => {
|
||||||
const scene = {
|
const scene = {
|
||||||
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
||||||
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1,
|
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 },
|
||||||
sources: [{
|
sources: [{
|
||||||
id: "source", name: "source", position: [0, 0, 1], gainDb: 0, startTime: 0, duration: 1,
|
id: "source", name: "source", position: [0, 0, 1], gainDb: 0, startTime: 0, duration: 1,
|
||||||
signal: { type: "tone", frequency: 5000 },
|
signal: { type: "tone", frequency: 5000 },
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
assert(validateScene(scene).some(({ path }) => path.endsWith("frequency")));
|
assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NaN 좌표를 거부한다", () => {
|
test("NaN 좌표를 거부한다", async () => {
|
||||||
const arrayDefinition = {
|
const arrayDefinition = {
|
||||||
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m",
|
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m",
|
||||||
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
|
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
|
||||||
aperture: { width: 1, height: 1 }, microphones: [{ id: "mic-1", position: [Number.NaN, 0, 0] }],
|
aperture: { width: 1, height: 1 }, microphones: [{ id: "mic-1", position: [Number.NaN, 0, 0] }],
|
||||||
};
|
};
|
||||||
assert(validateArrayDefinition(arrayDefinition).some(({ path }) => path.endsWith("[0]")));
|
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("[0]")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한다", async () => {
|
||||||
|
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||||
|
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||||
|
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||||
|
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||||
|
]);
|
||||||
|
arrayDefinition.microphones[0].phaseOffsetDeg = 999;
|
||||||
|
scene.unexpected = true;
|
||||||
|
delete experiment.beamforming.normalization;
|
||||||
|
experiment.beamforming.dynamicRangeDb = 4;
|
||||||
|
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
|
||||||
|
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
|
||||||
|
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
|
||||||
|
assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
|
||||||
|
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
|
||||||
|
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
|
||||||
|
scene.assets = [
|
||||||
|
{ id: "audio", kind: "audio", fileName: "../outside.wav", mimeType: "audio/wav" },
|
||||||
|
{ id: "image", kind: "image", fileName: "machine.png", mimeType: "image/png" },
|
||||||
|
];
|
||||||
|
scene.image = {
|
||||||
|
assetId: "audio",
|
||||||
|
widthPixels: 600,
|
||||||
|
heightPixels: 400,
|
||||||
|
plane: { z: 2, xMin: -0.6, xMax: 0.6, yMin: -0.4, yMax: 0.4 },
|
||||||
|
};
|
||||||
|
scene.sources[0].signal = { type: "asset", assetId: "image" };
|
||||||
|
const issues = await validateScene(scene);
|
||||||
|
assert(issues.some(({ path }) => path.endsWith("fileName")));
|
||||||
|
assertEqual(issues.filter(({ message }) => message.includes("asset을 참조")).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", async () => {
|
||||||
|
const [scene, experiment] = await Promise.all([
|
||||||
|
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||||
|
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||||
|
]);
|
||||||
|
const result = {
|
||||||
|
schemaVersion: "1.0.0",
|
||||||
|
kind: "micarrsim.result",
|
||||||
|
experimentId: experiment.id,
|
||||||
|
arrayId: experiment.arrayId,
|
||||||
|
sceneId: experiment.sceneId,
|
||||||
|
createdAt: "2026-08-28T00:00:00.000Z",
|
||||||
|
backend: "cpu",
|
||||||
|
inputHash: "a".repeat(64),
|
||||||
|
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
|
||||||
|
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
|
||||||
|
bands: [{
|
||||||
|
id: "tone-1k",
|
||||||
|
color: "#65f2bf",
|
||||||
|
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
||||||
|
peaks: [{ sourceId: "source-1", position: [0.08, 0.04, 2], value: 1, localizationErrorM: 0 }],
|
||||||
|
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
|
||||||
|
result.bands[0].rawMap.values.pop();
|
||||||
|
result.bands[0].rawMap.scale = "normalized";
|
||||||
|
result.bands[0].peaks[0].sourceId = "missing-source";
|
||||||
|
const issues = await validateResult(result, { experiment, scene });
|
||||||
|
assert(issues.some(({ message }) => message.includes("rows × columns")));
|
||||||
|
assert(issues.some(({ path }) => path.endsWith("rawMap.scale")));
|
||||||
|
assert(issues.some(({ path }) => path.endsWith("sourceId")));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user