diff --git a/assets/array-geometries/rectangular-16x12.array.json b/assets/array-geometries/rectangular-16x12.array.json new file mode 100644 index 0000000..abafcdd --- /dev/null +++ b/assets/array-geometries/rectangular-16x12.array.json @@ -0,0 +1,1556 @@ +{ + "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": { + "handedness": "right", + "xAxis": "right", + "yAxis": "up", + "zAxis": "toward-scene" + }, + "aperture": { + "width": 0.45, + "height": 0.45 + }, + "microphones": [ + { + "id": "mic-001", + "position": [ + -0.225, + -0.225, + 0 + ] + }, + { + "id": "mic-002", + "position": [ + -0.195, + -0.225, + 0 + ] + }, + { + "id": "mic-003", + "position": [ + -0.165, + -0.225, + 0 + ] + }, + { + "id": "mic-004", + "position": [ + -0.135, + -0.225, + 0 + ] + }, + { + "id": "mic-005", + "position": [ + -0.105, + -0.225, + 0 + ] + }, + { + "id": "mic-006", + "position": [ + -0.075, + -0.225, + 0 + ] + }, + { + "id": "mic-007", + "position": [ + -0.045, + -0.225, + 0 + ] + }, + { + "id": "mic-008", + "position": [ + -0.015, + -0.225, + 0 + ] + }, + { + "id": "mic-009", + "position": [ + 0.015, + -0.225, + 0 + ] + }, + { + "id": "mic-010", + "position": [ + 0.045, + -0.225, + 0 + ] + }, + { + "id": "mic-011", + "position": [ + 0.075, + -0.225, + 0 + ] + }, + { + "id": "mic-012", + "position": [ + 0.105, + -0.225, + 0 + ] + }, + { + "id": "mic-013", + "position": [ + 0.135, + -0.225, + 0 + ] + }, + { + "id": "mic-014", + "position": [ + 0.165, + -0.225, + 0 + ] + }, + { + "id": "mic-015", + "position": [ + 0.195, + -0.225, + 0 + ] + }, + { + "id": "mic-016", + "position": [ + 0.225, + -0.225, + 0 + ] + }, + { + "id": "mic-017", + "position": [ + -0.225, + -0.184091, + 0 + ] + }, + { + "id": "mic-018", + "position": [ + -0.195, + -0.184091, + 0 + ] + }, + { + "id": "mic-019", + "position": [ + -0.165, + -0.184091, + 0 + ] + }, + { + "id": "mic-020", + "position": [ + -0.135, + -0.184091, + 0 + ] + }, + { + "id": "mic-021", + "position": [ + -0.105, + -0.184091, + 0 + ] + }, + { + "id": "mic-022", + "position": [ + -0.075, + -0.184091, + 0 + ] + }, + { + "id": "mic-023", + "position": [ + -0.045, + -0.184091, + 0 + ] + }, + { + "id": "mic-024", + "position": [ + -0.015, + -0.184091, + 0 + ] + }, + { + "id": "mic-025", + "position": [ + 0.015, + -0.184091, + 0 + ] + }, + { + "id": "mic-026", + "position": [ + 0.045, + -0.184091, + 0 + ] + }, + { + "id": "mic-027", + "position": [ + 0.075, + -0.184091, + 0 + ] + }, + { + "id": "mic-028", + "position": [ + 0.105, + -0.184091, + 0 + ] + }, + { + "id": "mic-029", + "position": [ + 0.135, + -0.184091, + 0 + ] + }, + { + "id": "mic-030", + "position": [ + 0.165, + -0.184091, + 0 + ] + }, + { + "id": "mic-031", + "position": [ + 0.195, + -0.184091, + 0 + ] + }, + { + "id": "mic-032", + "position": [ + 0.225, + -0.184091, + 0 + ] + }, + { + "id": "mic-033", + "position": [ + -0.225, + -0.143182, + 0 + ] + }, + { + "id": "mic-034", + "position": [ + -0.195, + -0.143182, + 0 + ] + }, + { + "id": "mic-035", + "position": [ + -0.165, + -0.143182, + 0 + ] + }, + { + "id": "mic-036", + "position": [ + -0.135, + -0.143182, + 0 + ] + }, + { + "id": "mic-037", + "position": [ + -0.105, + -0.143182, + 0 + ] + }, + { + "id": "mic-038", + "position": [ + -0.075, + -0.143182, + 0 + ] + }, + { + "id": "mic-039", + "position": [ + -0.045, + -0.143182, + 0 + ] + }, + { + "id": "mic-040", + "position": [ + -0.015, + -0.143182, + 0 + ] + }, + { + "id": "mic-041", + "position": [ + 0.015, + -0.143182, + 0 + ] + }, + { + "id": "mic-042", + "position": [ + 0.045, + -0.143182, + 0 + ] + }, + { + "id": "mic-043", + "position": [ + 0.075, + -0.143182, + 0 + ] + }, + { + "id": "mic-044", + "position": [ + 0.105, + -0.143182, + 0 + ] + }, + { + "id": "mic-045", + "position": [ + 0.135, + -0.143182, + 0 + ] + }, + { + "id": "mic-046", + "position": [ + 0.165, + -0.143182, + 0 + ] + }, + { + "id": "mic-047", + "position": [ + 0.195, + -0.143182, + 0 + ] + }, + { + "id": "mic-048", + "position": [ + 0.225, + -0.143182, + 0 + ] + }, + { + "id": "mic-049", + "position": [ + -0.225, + -0.102273, + 0 + ] + }, + { + "id": "mic-050", + "position": [ + -0.195, + -0.102273, + 0 + ] + }, + { + "id": "mic-051", + "position": [ + -0.165, + -0.102273, + 0 + ] + }, + { + "id": "mic-052", + "position": [ + -0.135, + -0.102273, + 0 + ] + }, + { + "id": "mic-053", + "position": [ + -0.105, + -0.102273, + 0 + ] + }, + { + "id": "mic-054", + "position": [ + -0.075, + -0.102273, + 0 + ] + }, + { + "id": "mic-055", + "position": [ + -0.045, + -0.102273, + 0 + ] + }, + { + "id": "mic-056", + "position": [ + -0.015, + -0.102273, + 0 + ] + }, + { + "id": "mic-057", + "position": [ + 0.015, + -0.102273, + 0 + ] + }, + { + "id": "mic-058", + "position": [ + 0.045, + -0.102273, + 0 + ] + }, + { + "id": "mic-059", + "position": [ + 0.075, + -0.102273, + 0 + ] + }, + { + "id": "mic-060", + "position": [ + 0.105, + -0.102273, + 0 + ] + }, + { + "id": "mic-061", + "position": [ + 0.135, + -0.102273, + 0 + ] + }, + { + "id": "mic-062", + "position": [ + 0.165, + -0.102273, + 0 + ] + }, + { + "id": "mic-063", + "position": [ + 0.195, + -0.102273, + 0 + ] + }, + { + "id": "mic-064", + "position": [ + 0.225, + -0.102273, + 0 + ] + }, + { + "id": "mic-065", + "position": [ + -0.225, + -0.061364, + 0 + ] + }, + { + "id": "mic-066", + "position": [ + -0.195, + -0.061364, + 0 + ] + }, + { + "id": "mic-067", + "position": [ + -0.165, + -0.061364, + 0 + ] + }, + { + "id": "mic-068", + "position": [ + -0.135, + -0.061364, + 0 + ] + }, + { + "id": "mic-069", + "position": [ + -0.105, + -0.061364, + 0 + ] + }, + { + "id": "mic-070", + "position": [ + -0.075, + -0.061364, + 0 + ] + }, + { + "id": "mic-071", + "position": [ + -0.045, + -0.061364, + 0 + ] + }, + { + "id": "mic-072", + "position": [ + -0.015, + -0.061364, + 0 + ] + }, + { + "id": "mic-073", + "position": [ + 0.015, + -0.061364, + 0 + ] + }, + { + "id": "mic-074", + "position": [ + 0.045, + -0.061364, + 0 + ] + }, + { + "id": "mic-075", + "position": [ + 0.075, + -0.061364, + 0 + ] + }, + { + "id": "mic-076", + "position": [ + 0.105, + -0.061364, + 0 + ] + }, + { + "id": "mic-077", + "position": [ + 0.135, + -0.061364, + 0 + ] + }, + { + "id": "mic-078", + "position": [ + 0.165, + -0.061364, + 0 + ] + }, + { + "id": "mic-079", + "position": [ + 0.195, + -0.061364, + 0 + ] + }, + { + "id": "mic-080", + "position": [ + 0.225, + -0.061364, + 0 + ] + }, + { + "id": "mic-081", + "position": [ + -0.225, + -0.020455, + 0 + ] + }, + { + "id": "mic-082", + "position": [ + -0.195, + -0.020455, + 0 + ] + }, + { + "id": "mic-083", + "position": [ + -0.165, + -0.020455, + 0 + ] + }, + { + "id": "mic-084", + "position": [ + -0.135, + -0.020455, + 0 + ] + }, + { + "id": "mic-085", + "position": [ + -0.105, + -0.020455, + 0 + ] + }, + { + "id": "mic-086", + "position": [ + -0.075, + -0.020455, + 0 + ] + }, + { + "id": "mic-087", + "position": [ + -0.045, + -0.020455, + 0 + ] + }, + { + "id": "mic-088", + "position": [ + -0.015, + -0.020455, + 0 + ] + }, + { + "id": "mic-089", + "position": [ + 0.015, + -0.020455, + 0 + ] + }, + { + "id": "mic-090", + "position": [ + 0.045, + -0.020455, + 0 + ] + }, + { + "id": "mic-091", + "position": [ + 0.075, + -0.020455, + 0 + ] + }, + { + "id": "mic-092", + "position": [ + 0.105, + -0.020455, + 0 + ] + }, + { + "id": "mic-093", + "position": [ + 0.135, + -0.020455, + 0 + ] + }, + { + "id": "mic-094", + "position": [ + 0.165, + -0.020455, + 0 + ] + }, + { + "id": "mic-095", + "position": [ + 0.195, + -0.020455, + 0 + ] + }, + { + "id": "mic-096", + "position": [ + 0.225, + -0.020455, + 0 + ] + }, + { + "id": "mic-097", + "position": [ + -0.225, + 0.020455, + 0 + ] + }, + { + "id": "mic-098", + "position": [ + -0.195, + 0.020455, + 0 + ] + }, + { + "id": "mic-099", + "position": [ + -0.165, + 0.020455, + 0 + ] + }, + { + "id": "mic-100", + "position": [ + -0.135, + 0.020455, + 0 + ] + }, + { + "id": "mic-101", + "position": [ + -0.105, + 0.020455, + 0 + ] + }, + { + "id": "mic-102", + "position": [ + -0.075, + 0.020455, + 0 + ] + }, + { + "id": "mic-103", + "position": [ + -0.045, + 0.020455, + 0 + ] + }, + { + "id": "mic-104", + "position": [ + -0.015, + 0.020455, + 0 + ] + }, + { + "id": "mic-105", + "position": [ + 0.015, + 0.020455, + 0 + ] + }, + { + "id": "mic-106", + "position": [ + 0.045, + 0.020455, + 0 + ] + }, + { + "id": "mic-107", + "position": [ + 0.075, + 0.020455, + 0 + ] + }, + { + "id": "mic-108", + "position": [ + 0.105, + 0.020455, + 0 + ] + }, + { + "id": "mic-109", + "position": [ + 0.135, + 0.020455, + 0 + ] + }, + { + "id": "mic-110", + "position": [ + 0.165, + 0.020455, + 0 + ] + }, + { + "id": "mic-111", + "position": [ + 0.195, + 0.020455, + 0 + ] + }, + { + "id": "mic-112", + "position": [ + 0.225, + 0.020455, + 0 + ] + }, + { + "id": "mic-113", + "position": [ + -0.225, + 0.061364, + 0 + ] + }, + { + "id": "mic-114", + "position": [ + -0.195, + 0.061364, + 0 + ] + }, + { + "id": "mic-115", + "position": [ + -0.165, + 0.061364, + 0 + ] + }, + { + "id": "mic-116", + "position": [ + -0.135, + 0.061364, + 0 + ] + }, + { + "id": "mic-117", + "position": [ + -0.105, + 0.061364, + 0 + ] + }, + { + "id": "mic-118", + "position": [ + -0.075, + 0.061364, + 0 + ] + }, + { + "id": "mic-119", + "position": [ + -0.045, + 0.061364, + 0 + ] + }, + { + "id": "mic-120", + "position": [ + -0.015, + 0.061364, + 0 + ] + }, + { + "id": "mic-121", + "position": [ + 0.015, + 0.061364, + 0 + ] + }, + { + "id": "mic-122", + "position": [ + 0.045, + 0.061364, + 0 + ] + }, + { + "id": "mic-123", + "position": [ + 0.075, + 0.061364, + 0 + ] + }, + { + "id": "mic-124", + "position": [ + 0.105, + 0.061364, + 0 + ] + }, + { + "id": "mic-125", + "position": [ + 0.135, + 0.061364, + 0 + ] + }, + { + "id": "mic-126", + "position": [ + 0.165, + 0.061364, + 0 + ] + }, + { + "id": "mic-127", + "position": [ + 0.195, + 0.061364, + 0 + ] + }, + { + "id": "mic-128", + "position": [ + 0.225, + 0.061364, + 0 + ] + }, + { + "id": "mic-129", + "position": [ + -0.225, + 0.102273, + 0 + ] + }, + { + "id": "mic-130", + "position": [ + -0.195, + 0.102273, + 0 + ] + }, + { + "id": "mic-131", + "position": [ + -0.165, + 0.102273, + 0 + ] + }, + { + "id": "mic-132", + "position": [ + -0.135, + 0.102273, + 0 + ] + }, + { + "id": "mic-133", + "position": [ + -0.105, + 0.102273, + 0 + ] + }, + { + "id": "mic-134", + "position": [ + -0.075, + 0.102273, + 0 + ] + }, + { + "id": "mic-135", + "position": [ + -0.045, + 0.102273, + 0 + ] + }, + { + "id": "mic-136", + "position": [ + -0.015, + 0.102273, + 0 + ] + }, + { + "id": "mic-137", + "position": [ + 0.015, + 0.102273, + 0 + ] + }, + { + "id": "mic-138", + "position": [ + 0.045, + 0.102273, + 0 + ] + }, + { + "id": "mic-139", + "position": [ + 0.075, + 0.102273, + 0 + ] + }, + { + "id": "mic-140", + "position": [ + 0.105, + 0.102273, + 0 + ] + }, + { + "id": "mic-141", + "position": [ + 0.135, + 0.102273, + 0 + ] + }, + { + "id": "mic-142", + "position": [ + 0.165, + 0.102273, + 0 + ] + }, + { + "id": "mic-143", + "position": [ + 0.195, + 0.102273, + 0 + ] + }, + { + "id": "mic-144", + "position": [ + 0.225, + 0.102273, + 0 + ] + }, + { + "id": "mic-145", + "position": [ + -0.225, + 0.143182, + 0 + ] + }, + { + "id": "mic-146", + "position": [ + -0.195, + 0.143182, + 0 + ] + }, + { + "id": "mic-147", + "position": [ + -0.165, + 0.143182, + 0 + ] + }, + { + "id": "mic-148", + "position": [ + -0.135, + 0.143182, + 0 + ] + }, + { + "id": "mic-149", + "position": [ + -0.105, + 0.143182, + 0 + ] + }, + { + "id": "mic-150", + "position": [ + -0.075, + 0.143182, + 0 + ] + }, + { + "id": "mic-151", + "position": [ + -0.045, + 0.143182, + 0 + ] + }, + { + "id": "mic-152", + "position": [ + -0.015, + 0.143182, + 0 + ] + }, + { + "id": "mic-153", + "position": [ + 0.015, + 0.143182, + 0 + ] + }, + { + "id": "mic-154", + "position": [ + 0.045, + 0.143182, + 0 + ] + }, + { + "id": "mic-155", + "position": [ + 0.075, + 0.143182, + 0 + ] + }, + { + "id": "mic-156", + "position": [ + 0.105, + 0.143182, + 0 + ] + }, + { + "id": "mic-157", + "position": [ + 0.135, + 0.143182, + 0 + ] + }, + { + "id": "mic-158", + "position": [ + 0.165, + 0.143182, + 0 + ] + }, + { + "id": "mic-159", + "position": [ + 0.195, + 0.143182, + 0 + ] + }, + { + "id": "mic-160", + "position": [ + 0.225, + 0.143182, + 0 + ] + }, + { + "id": "mic-161", + "position": [ + -0.225, + 0.184091, + 0 + ] + }, + { + "id": "mic-162", + "position": [ + -0.195, + 0.184091, + 0 + ] + }, + { + "id": "mic-163", + "position": [ + -0.165, + 0.184091, + 0 + ] + }, + { + "id": "mic-164", + "position": [ + -0.135, + 0.184091, + 0 + ] + }, + { + "id": "mic-165", + "position": [ + -0.105, + 0.184091, + 0 + ] + }, + { + "id": "mic-166", + "position": [ + -0.075, + 0.184091, + 0 + ] + }, + { + "id": "mic-167", + "position": [ + -0.045, + 0.184091, + 0 + ] + }, + { + "id": "mic-168", + "position": [ + -0.015, + 0.184091, + 0 + ] + }, + { + "id": "mic-169", + "position": [ + 0.015, + 0.184091, + 0 + ] + }, + { + "id": "mic-170", + "position": [ + 0.045, + 0.184091, + 0 + ] + }, + { + "id": "mic-171", + "position": [ + 0.075, + 0.184091, + 0 + ] + }, + { + "id": "mic-172", + "position": [ + 0.105, + 0.184091, + 0 + ] + }, + { + "id": "mic-173", + "position": [ + 0.135, + 0.184091, + 0 + ] + }, + { + "id": "mic-174", + "position": [ + 0.165, + 0.184091, + 0 + ] + }, + { + "id": "mic-175", + "position": [ + 0.195, + 0.184091, + 0 + ] + }, + { + "id": "mic-176", + "position": [ + 0.225, + 0.184091, + 0 + ] + }, + { + "id": "mic-177", + "position": [ + -0.225, + 0.225, + 0 + ] + }, + { + "id": "mic-178", + "position": [ + -0.195, + 0.225, + 0 + ] + }, + { + "id": "mic-179", + "position": [ + -0.165, + 0.225, + 0 + ] + }, + { + "id": "mic-180", + "position": [ + -0.135, + 0.225, + 0 + ] + }, + { + "id": "mic-181", + "position": [ + -0.105, + 0.225, + 0 + ] + }, + { + "id": "mic-182", + "position": [ + -0.075, + 0.225, + 0 + ] + }, + { + "id": "mic-183", + "position": [ + -0.045, + 0.225, + 0 + ] + }, + { + "id": "mic-184", + "position": [ + -0.015, + 0.225, + 0 + ] + }, + { + "id": "mic-185", + "position": [ + 0.015, + 0.225, + 0 + ] + }, + { + "id": "mic-186", + "position": [ + 0.045, + 0.225, + 0 + ] + }, + { + "id": "mic-187", + "position": [ + 0.075, + 0.225, + 0 + ] + }, + { + "id": "mic-188", + "position": [ + 0.105, + 0.225, + 0 + ] + }, + { + "id": "mic-189", + "position": [ + 0.135, + 0.225, + 0 + ] + }, + { + "id": "mic-190", + "position": [ + 0.165, + 0.225, + 0 + ] + }, + { + "id": "mic-191", + "position": [ + 0.195, + 0.225, + 0 + ] + }, + { + "id": "mic-192", + "position": [ + 0.225, + 0.225, + 0 + ] + } + ] +} diff --git a/assets/sample-scenes/single-tone.experiment.json b/assets/sample-scenes/single-tone.experiment.json new file mode 100644 index 0000000..9b9a166 --- /dev/null +++ b/assets/sample-scenes/single-tone.experiment.json @@ -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 + } +} diff --git a/assets/sample-scenes/single-tone.scene.json b/assets/sample-scenes/single-tone.scene.json new file mode 100644 index 0000000..253ac66 --- /dev/null +++ b/assets/sample-scenes/single-tone.scene.json @@ -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": [] +} diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 0000000..8387704 --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,8 @@ +# Contracts + +네이티브 JSON Schema와 브라우저용 관계 검증기를 함께 둡니다. JSON Schema는 파일 형식과 기본 +범위를 정의하고 `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼 스키마만으로 +표현하기 불편한 조건을 검사합니다. + +계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다. +`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다. diff --git a/packages/contracts/io.js b/packages/contracts/io.js new file mode 100644 index 0000000..a9962e9 --- /dev/null +++ b/packages/contracts/io.js @@ -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); +} diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js new file mode 100644 index 0000000..5844b5a --- /dev/null +++ b/packages/contracts/validation.js @@ -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; +} diff --git a/schemas/README.md b/schemas/README.md index ef5f5c7..2770739 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -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`에서 추가로 검증합니다. diff --git a/schemas/array.schema.json b/schemas/array.schema.json new file mode 100644 index 0000000..036d952 --- /dev/null +++ b/schemas/array.schema.json @@ -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 } + } + } + } +} diff --git a/schemas/experiment.schema.json b/schemas/experiment.schema.json new file mode 100644 index 0000000..1b688f0 --- /dev/null +++ b/schemas/experiment.schema.json @@ -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 } + } + } + } +} diff --git a/schemas/result.schema.json b/schemas/result.schema.json new file mode 100644 index 0000000..72057e0 --- /dev/null +++ b/schemas/result.schema.json @@ -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 } + } + } + } + } + } + } +} diff --git a/schemas/scene.schema.json b/schemas/scene.schema.json new file mode 100644 index 0000000..792900d --- /dev/null +++ b/schemas/scene.schema.json @@ -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" } + } + } + } +} diff --git a/scripts/generate-array-fixture.ps1 b/scripts/generate-array-fixture.ps1 new file mode 100644 index 0000000..98b6adf --- /dev/null +++ b/scripts/generate-array-fixture.ps1 @@ -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" diff --git a/tests/index.html b/tests/index.html new file mode 100644 index 0000000..6659f83 --- /dev/null +++ b/tests/index.html @@ -0,0 +1,27 @@ + + + + + + micarrsim tests + + + + +
+

Browser test runner

+

micarrsim tests

+

테스트 실행 중…

+
    +
    + + + diff --git a/tests/test-runner.js b/tests/test-runner.js new file mode 100644 index 0000000..8b5767b --- /dev/null +++ b/tests/test-runner.js @@ -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"; diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js new file mode 100644 index 0000000..8c3bd2e --- /dev/null +++ b/tests/unit/contracts.test.js @@ -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]"))); +});