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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.experiment",
"id": "single-tone-50x50",
"name": "1 kHz · 50×50 baseline",
"arrayId": "rectangular-16x12",
"sceneId": "single-tone",
"scan": {
"z": 2,
"xMin": -0.6,
"xMax": 0.6,
"yMin": -0.45,
"yMax": 0.45,
"columns": 50,
"rows": 50
},
"frequencyBands": [
{ "id": "tone-1k", "minHz": 900, "maxHz": 1100, "color": "#65f2bf" }
],
"stft": { "fftSize": 512, "hopSize": 256, "window": "hann" },
"beamforming": {
"algorithm": "das",
"backend": "auto",
"minimumDistance": 0.01,
"normalization": "peak",
"dynamicRangeDb": 30
}
}
@@ -0,0 +1,24 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.scene",
"id": "single-tone",
"name": "1 kHz 단일 음원",
"description": "CPU/GPU 위치 추정 회귀 테스트용 자유음장 장면",
"units": "m",
"sampleRate": 48000,
"duration": 0.25,
"speedOfSound": 343,
"seed": 20260828,
"sources": [
{
"id": "source-1",
"name": "1 kHz tone",
"position": [0.08, 0.04, 2],
"gainDb": -6,
"startTime": 0,
"duration": 0.25,
"signal": { "type": "tone", "frequency": 1000, "phaseDeg": 0 }
}
],
"ambient": []
}
+8
View File
@@ -0,0 +1,8 @@
# Contracts
네이티브 JSON Schema와 브라우저용 관계 검증기를 함께 둡니다. JSON Schema는 파일 형식과 기본
범위를 정의하고 `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼 스키마만으로
표현하기 불편한 조건을 검사합니다.
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
+22
View File
@@ -0,0 +1,22 @@
export async function readJsonFile(file) {
try {
return JSON.parse(await file.text());
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`${file.name} 파일은 올바른 JSON이 아닙니다: ${reason}`);
}
}
export function downloadJson(value, fileName) {
const body = `${JSON.stringify(value, null, 2)}\n`;
const url = URL.createObjectURL(new Blob([body], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function deepClone(value) {
return structuredClone(value);
}
+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;
}
+9
View File
@@ -1,3 +1,12 @@
# Schemas
마이크 배열, scene, experiment, result의 versioned JSON Schema를 둡니다.
JSON Schema Draft 2020-12 형식입니다.
- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 보정값
- `scene.schema.json`: 재현 가능한 음원 recipe, asset 참조와 ground-truth 위치
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
- `result.schema.json`: 실행 환경, timing, peak와 평가 지표 요약
중복 id, Nyquist, 시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다.
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://micarrsim.local/schemas/array.schema.json",
"title": "micarrsim microphone array",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "kind", "id", "name", "units", "coordinateSystem", "aperture", "microphones"],
"properties": {
"schemaVersion": { "const": "1.0.0" },
"kind": { "const": "micarrsim.array" },
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"description": { "type": "string", "maxLength": 1000 },
"units": { "const": "m" },
"coordinateSystem": { "$ref": "#/$defs/coordinateSystem" },
"aperture": {
"type": "object", "additionalProperties": false, "required": ["width", "height"],
"properties": {
"width": { "type": "number", "exclusiveMinimum": 0, "maximum": 20 },
"height": { "type": "number", "exclusiveMinimum": 0, "maximum": 20 }
}
},
"microphones": { "type": "array", "minItems": 1, "maxItems": 1024, "items": { "$ref": "#/$defs/microphone" } }
},
"$defs": {
"coordinateSystem": {
"type": "object", "additionalProperties": false,
"required": ["handedness", "xAxis", "yAxis", "zAxis"],
"properties": {
"handedness": { "const": "right" }, "xAxis": { "const": "right" },
"yAxis": { "const": "up" }, "zAxis": { "const": "toward-scene" }
}
},
"position": {
"type": "array",
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"items": false, "minItems": 3, "maxItems": 3
},
"microphone": {
"type": "object", "additionalProperties": false, "required": ["id", "position"],
"properties": {
"id": { "type": "string", "minLength": 1, "maxLength": 64 },
"position": { "$ref": "#/$defs/position" },
"sensitivityDb": { "type": "number", "minimum": -120, "maximum": 120 },
"phaseOffsetDeg": { "type": "number", "minimum": -180, "maximum": 180 }
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://micarrsim.local/schemas/experiment.schema.json",
"title": "micarrsim experiment",
"type": "object", "additionalProperties": false,
"required": ["schemaVersion", "kind", "id", "name", "arrayId", "sceneId", "scan", "frequencyBands", "stft", "beamforming"],
"properties": {
"schemaVersion": { "const": "1.0.0" }, "kind": { "const": "micarrsim.experiment" },
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"arrayId": { "type": "string", "minLength": 1 }, "sceneId": { "type": "string", "minLength": 1 },
"scan": {
"type": "object", "additionalProperties": false,
"required": ["z", "xMin", "xMax", "yMin", "yMax", "columns", "rows"],
"properties": {
"z": { "type": "number", "exclusiveMinimum": 0 },
"xMin": { "type": "number" }, "xMax": { "type": "number" },
"yMin": { "type": "number" }, "yMax": { "type": "number" },
"columns": { "type": "integer", "minimum": 8, "maximum": 512 },
"rows": { "type": "integer", "minimum": 8, "maximum": 512 }
}
},
"frequencyBands": {
"type": "array", "minItems": 1, "maxItems": 8,
"items": {
"type": "object", "additionalProperties": false, "required": ["id", "minHz", "maxHz", "color"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"minHz": { "type": "number", "minimum": 0 }, "maxHz": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }
}
}
},
"stft": {
"type": "object", "additionalProperties": false, "required": ["fftSize", "hopSize", "window"],
"properties": {
"fftSize": { "enum": [128, 256, 512, 1024, 2048, 4096] },
"hopSize": { "type": "integer", "minimum": 1, "maximum": 4096 }, "window": { "const": "hann" }
}
},
"beamforming": {
"type": "object", "additionalProperties": false,
"required": ["algorithm", "backend", "minimumDistance", "normalization", "dynamicRangeDb"],
"properties": {
"algorithm": { "const": "das" }, "backend": { "enum": ["auto", "cpu", "webgpu"] },
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
"normalization": { "enum": ["peak", "none"] },
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://micarrsim.local/schemas/result.schema.json",
"title": "micarrsim result summary",
"type": "object", "additionalProperties": false,
"required": ["schemaVersion", "kind", "experimentId", "createdAt", "backend", "inputHash", "timingMs", "bands"],
"properties": {
"schemaVersion": { "const": "1.0.0" }, "kind": { "const": "micarrsim.result" },
"experimentId": { "type": "string", "minLength": 1 },
"createdAt": { "type": "string", "format": "date-time" }, "backend": { "enum": ["cpu", "webgpu"] },
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"timingMs": {
"type": "object", "additionalProperties": false, "required": ["total", "beamforming"],
"properties": {
"total": { "type": "number", "minimum": 0 },
"signalGeneration": { "type": "number", "minimum": 0 },
"beamforming": { "type": "number", "minimum": 0 }
}
},
"environment": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
"bands": {
"type": "array", "minItems": 1,
"items": {
"type": "object", "additionalProperties": false,
"required": ["id", "peakPosition", "peakValue", "metrics"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"peakPosition": {
"type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"items": false, "minItems": 3, "maxItems": 3
},
"peakValue": { "type": "number", "minimum": 0 },
"metrics": {
"type": "object", "additionalProperties": false,
"required": ["localizationErrorM", "peakToSidelobeDb"],
"properties": {
"localizationErrorM": { "type": "number", "minimum": 0 }, "peakToSidelobeDb": { "type": "number" },
"energyInsideTruthRatio": { "type": "number", "minimum": 0, "maximum": 1 }
}
}
}
}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://micarrsim.local/schemas/scene.schema.json",
"title": "micarrsim acoustic scene",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "kind", "id", "name", "units", "sampleRate", "duration", "speedOfSound", "seed", "sources"],
"properties": {
"schemaVersion": { "const": "1.0.0" }, "kind": { "const": "micarrsim.scene" },
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 },
"description": { "type": "string", "maxLength": 1000 }, "units": { "const": "m" },
"sampleRate": { "type": "integer", "minimum": 8000, "maximum": 192000 },
"duration": { "type": "number", "exclusiveMinimum": 0, "maximum": 300 },
"speedOfSound": { "type": "number", "minimum": 250, "maximum": 400 },
"seed": { "type": "integer", "minimum": 0, "maximum": 4294967295 },
"image": { "$ref": "#/$defs/image" },
"assets": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/asset" } },
"sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } },
"ambient": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/ambient" } }
},
"$defs": {
"position": {
"type": "array", "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
"items": false, "minItems": 3, "maxItems": 3
},
"image": {
"type": "object", "additionalProperties": false,
"required": ["assetId", "widthPixels", "heightPixels", "plane"],
"properties": {
"assetId": { "type": "string", "minLength": 1 },
"widthPixels": { "type": "integer", "minimum": 1, "maximum": 32768 },
"heightPixels": { "type": "integer", "minimum": 1, "maximum": 32768 },
"plane": {
"type": "object", "additionalProperties": false,
"required": ["z", "xMin", "xMax", "yMin", "yMax"],
"properties": {
"z": { "type": "number", "exclusiveMinimum": 0 },
"xMin": { "type": "number" }, "xMax": { "type": "number" },
"yMin": { "type": "number" }, "yMax": { "type": "number" }
}
}
}
},
"asset": {
"type": "object", "additionalProperties": false, "required": ["id", "kind", "fileName", "mimeType"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"kind": { "enum": ["audio", "image"] }, "fileName": { "type": "string", "minLength": 1, "maxLength": 255 },
"mimeType": { "type": "string", "minLength": 3, "maxLength": 100 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
},
"signal": {
"oneOf": [
{ "type": "object", "additionalProperties": false, "required": ["type", "frequency"], "properties": {
"type": { "const": "tone" }, "frequency": { "type": "number", "exclusiveMinimum": 0 },
"phaseDeg": { "type": "number", "minimum": -360, "maximum": 360 }
}},
{ "type": "object", "additionalProperties": false, "required": ["type", "color"], "properties": {
"type": { "const": "noise" }, "color": { "enum": ["white", "pink"] },
"seedOffset": { "type": "integer", "minimum": 0, "maximum": 4294967295 }
}},
{ "type": "object", "additionalProperties": false, "required": ["type", "assetId"], "properties": {
"type": { "const": "asset" }, "assetId": { "type": "string", "minLength": 1 },
"channel": { "type": "integer", "minimum": 0, "maximum": 255 }, "loop": { "type": "boolean" }
}}
]
},
"source": {
"type": "object", "additionalProperties": false,
"required": ["id", "name", "position", "gainDb", "startTime", "duration", "signal"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"name": { "type": "string", "minLength": 1, "maxLength": 120 }, "position": { "$ref": "#/$defs/position" },
"gainDb": { "type": "number", "minimum": -120, "maximum": 24 }, "startTime": { "type": "number", "minimum": 0 },
"duration": { "type": "number", "exclusiveMinimum": 0 }, "signal": { "$ref": "#/$defs/signal" }
}
},
"ambient": {
"type": "object", "additionalProperties": false, "required": ["id", "gainDb", "signal"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
"gainDb": { "type": "number", "minimum": -120, "maximum": 24 }, "signal": { "$ref": "#/$defs/signal" }
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
$ErrorActionPreference = 'Stop'
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$outputPath = Join-Path $projectRoot 'assets/array-geometries/rectangular-16x12.array.json'
$microphones = [System.Collections.Generic.List[object]]::new()
$columns = 16
$rows = 12
$width = 0.45
$height = 0.45
for ($row = 0; $row -lt $rows; $row++) {
for ($column = 0; $column -lt $columns; $column++) {
$index = $row * $columns + $column
$x = -$width / 2 + $column * $width / ($columns - 1)
$y = -$height / 2 + $row * $height / ($rows - 1)
$microphones.Add([ordered]@{
id = 'mic-{0:D3}' -f ($index + 1)
position = @([Math]::Round($x, 6), [Math]::Round($y, 6), 0)
})
}
}
$arrayDefinition = [ordered]@{
schemaVersion = '1.0.0'
kind = 'micarrsim.array'
id = 'rectangular-16x12'
name = '16×12 rectangular baseline'
description = '0.45 m square aperture에 균일 배치한 192채널 기준 배열'
units = 'm'
coordinateSystem = [ordered]@{
handedness = 'right'
xAxis = 'right'
yAxis = 'up'
zAxis = 'toward-scene'
}
aperture = [ordered]@{ width = $width; height = $height }
microphones = $microphones
}
$json = $arrayDefinition | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText($outputPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
Write-Host "Generated $($microphones.Count) microphones: $outputPath"
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>micarrsim tests</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<style>
.test-shell { width: min(900px, calc(100% - 32px)); margin: 40px auto; }
.test-summary { color: var(--muted); }
.test-list { display: grid; gap: 8px; padding: 0; list-style: none; }
.test-result { padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
.test-result--pass { border-left: 4px solid var(--mint); }
.test-result--fail { border-left: 4px solid var(--danger); }
pre { overflow: auto; color: var(--danger); white-space: pre-wrap; }
</style>
</head>
<body>
<main class="test-shell">
<p class="eyebrow">Browser test runner</p>
<h1>micarrsim tests</h1>
<p class="test-summary" id="test-summary">테스트 실행 중…</p>
<ol class="test-list" id="test-results"></ol>
</main>
<script type="module" src="/tests/test-runner.js"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
const registeredTests = [];
globalThis.test = (name, callback) => registeredTests.push({ name, callback });
globalThis.assert = (condition, message = "assertion failed") => {
if (!condition) throw new Error(message);
};
globalThis.assertEqual = (actual, expected, message = "values differ") => {
if (!Object.is(actual, expected)) throw new Error(`${message}: expected ${expected}, received ${actual}`);
};
await import("/tests/unit/contracts.test.js");
const resultList = document.querySelector("#test-results");
const summary = document.querySelector("#test-summary");
let passed = 0;
for (const registeredTest of registeredTests) {
const item = document.createElement("li");
item.className = "test-result";
try {
await registeredTest.callback();
passed += 1;
item.classList.add("test-result--pass");
item.textContent = `✓ ${registeredTest.name}`;
} catch (error) {
item.classList.add("test-result--fail");
const title = document.createElement("strong");
title.textContent = `✕ ${registeredTest.name}`;
const details = document.createElement("pre");
details.textContent = error instanceof Error ? error.stack : String(error);
item.append(title, details);
}
resultList.append(item);
}
const failed = registeredTests.length - passed;
summary.textContent = `${registeredTests.length}개 중 ${passed}개 통과, ${failed}개 실패`;
summary.dataset.failed = String(failed);
document.documentElement.dataset.testStatus = failed === 0 ? "pass" : "fail";
document.title = failed === 0 ? "PASS · micarrsim tests" : "FAIL · micarrsim tests";
+61
View File
@@ -0,0 +1,61 @@
import {
validateArrayDefinition,
validateExperiment,
validateScene,
} from "/packages/contracts/validation.js";
async function loadJson(path) {
const response = await fetch(path);
assert(response.ok, `${path} load failed`);
return response.json();
}
test("192채널 기준 배열이 계약을 통과한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
assertEqual(validateArrayDefinition(arrayDefinition).length, 0, "array issues");
});
test("단일 tone 장면과 실험이 관계 검증을 통과한다", 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"),
]);
assertEqual(validateScene(scene).length, 0, "scene issues");
assertEqual(validateExperiment(experiment, { arrayDefinition, scene }).length, 0, "experiment issues");
});
test("중복 마이크 id를 거부한다", () => {
const arrayDefinition = {
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" },
aperture: { width: 1, height: 1 },
microphones: [
{ id: "mic-1", position: [0, 0, 0] },
{ id: "mic-1", position: [0.1, 0, 0] },
],
};
assert(validateArrayDefinition(arrayDefinition).some(({ message }) => message.includes("중복")));
});
test("Nyquist를 넘는 tone을 거부한다", () => {
const scene = {
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1,
sources: [{
id: "source", name: "source", position: [0, 0, 1], gainDb: 0, startTime: 0, duration: 1,
signal: { type: "tone", frequency: 5000 },
}],
};
assert(validateScene(scene).some(({ path }) => path.endsWith("frequency")));
});
test("NaN 좌표를 거부한다", () => {
const arrayDefinition = {
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" },
aperture: { width: 1, height: 1 }, microphones: [{ id: "mic-1", position: [Number.NaN, 0, 0] }],
};
assert(validateArrayDefinition(arrayDefinition).some(({ path }) => path.endsWith("[0]")));
});