feat: define simulator data contracts
This commit is contained in:
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": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Contracts
|
||||||
|
|
||||||
|
네이티브 JSON Schema와 브라우저용 관계 검증기를 함께 둡니다. JSON Schema는 파일 형식과 기본
|
||||||
|
범위를 정의하고 `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼 스키마만으로
|
||||||
|
표현하기 불편한 조건을 검사합니다.
|
||||||
|
|
||||||
|
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
|
||||||
|
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
# Schemas
|
# Schemas
|
||||||
|
|
||||||
마이크 배열, scene, experiment, result의 versioned JSON Schema를 둡니다.
|
마이크 배열, 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`에서 추가로 검증합니다.
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
@@ -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]")));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user