1492 lines
57 KiB
JavaScript
1492 lines
57 KiB
JavaScript
import { validateDocumentStructure } from "./schema-validator.js";
|
||
import {
|
||
describeFrequencyAliasing,
|
||
MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||
observeFrequencyBand,
|
||
} from "../acoustics-core/sampling.js";
|
||
import {
|
||
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||
estimateSynthesisWorkingSet,
|
||
} from "../acoustics-core/propagation.js";
|
||
import { DEFAULT_MAX_COMPENSATION_DB } from "../acoustics-core/frequency-response.js";
|
||
import {
|
||
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
|
||
} from "../acoustics-core/music.js";
|
||
|
||
export const INPUT_SCHEMA_VERSION = "1.0.0";
|
||
export const RESULT_SCHEMA_VERSION = "1.1.0";
|
||
const MAX_MICROPHONES = 1024;
|
||
const MAX_SOURCES = 32;
|
||
const MAX_GRID_SIDE = 512;
|
||
const MAX_FREQUENCY_RESPONSE_POINTS = 64;
|
||
const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
|
||
const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat", "music"];
|
||
|
||
/** @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 samplingMetadataEqual(actual, expected) {
|
||
if (typeof actual === "number" && typeof expected === "number") {
|
||
const tolerance =
|
||
1e-9 + 1e-12 * Math.max(Math.abs(actual), Math.abs(expected));
|
||
return Number.isFinite(actual) &&
|
||
Number.isFinite(expected) &&
|
||
Math.abs(actual - expected) <= tolerance;
|
||
}
|
||
if (Array.isArray(actual) || Array.isArray(expected)) {
|
||
return Array.isArray(actual) &&
|
||
Array.isArray(expected) &&
|
||
actual.length === expected.length &&
|
||
actual.every((value, index) =>
|
||
samplingMetadataEqual(value, expected[index]));
|
||
}
|
||
if (isRecord(actual) || isRecord(expected)) {
|
||
if (!isRecord(actual) || !isRecord(expected)) return false;
|
||
const expectedKeys = Object.keys(expected);
|
||
return expectedKeys.every((key) =>
|
||
samplingMetadataEqual(actual[key], expected[key]));
|
||
}
|
||
return actual === expected;
|
||
}
|
||
|
||
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 requireBounds(value, path, issues) {
|
||
if (!requireRecord(value, path, issues)) return false;
|
||
const minimumValid = requirePosition(value.min, path + ".min", issues);
|
||
const maximumValid = requirePosition(value.max, path + ".max", issues);
|
||
if (!minimumValid || !maximumValid) return false;
|
||
let ordered = true;
|
||
for (let axis = 0; axis < 3; axis += 1) {
|
||
if (value.min[axis] >= value.max[axis]) {
|
||
addIssue(issues, path, "min 좌표는 모든 축에서 max 좌표보다 작아야 합니다.");
|
||
ordered = false;
|
||
break;
|
||
}
|
||
}
|
||
return ordered;
|
||
}
|
||
|
||
function boundsContain(bounds, position) {
|
||
return position.every(
|
||
(coordinate, axis) => coordinate >= bounds.min[axis] && coordinate <= bounds.max[axis],
|
||
);
|
||
}
|
||
|
||
function validateHeader(value, expectedKind, issues) {
|
||
if (!requireRecord(value, "$", issues)) return false;
|
||
if (value.schemaVersion !== INPUT_SCHEMA_VERSION) {
|
||
addIssue(issues, "$.schemaVersion", `지원 버전은 ${INPUT_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];
|
||
}
|
||
|
||
function isSafeRelativeAssetPath(value) {
|
||
if (typeof value !== "string" || value.trim() === "") return false;
|
||
const normalized = value.split(String.fromCharCode(92)).join("/");
|
||
return !normalized.startsWith("/") &&
|
||
!/^[a-z][a-z0-9+.-]*:/i.test(normalized) &&
|
||
!normalized.split("/").includes("..") &&
|
||
!normalized.includes("\0");
|
||
}
|
||
|
||
function validateArrayRelationships(value) {
|
||
/** @type {ValidationIssue[]} */
|
||
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 });
|
||
}
|
||
if (microphone.frequencyResponse !== undefined) {
|
||
const responsePath = `${path}.frequencyResponse`;
|
||
if (!Array.isArray(microphone.frequencyResponse)) {
|
||
addIssue(issues, responsePath, "주파수 응답점 배열이어야 합니다.");
|
||
} else {
|
||
if (microphone.frequencyResponse.length < 2 || microphone.frequencyResponse.length > MAX_FREQUENCY_RESPONSE_POINTS) {
|
||
addIssue(issues, responsePath, `주파수 응답점은 2~${MAX_FREQUENCY_RESPONSE_POINTS}개여야 합니다.`);
|
||
}
|
||
let previousFrequency = -Infinity;
|
||
microphone.frequencyResponse.forEach((point, pointIndex) => {
|
||
const pointPath = `${responsePath}[${pointIndex}]`;
|
||
if (!requireRecord(point, pointPath, issues)) return;
|
||
const frequencyValid = requireFinite(point.frequencyHz, `${pointPath}.frequencyHz`, issues, {
|
||
min: 0,
|
||
max: MAX_FREQUENCY_RESPONSE_HZ,
|
||
});
|
||
requireFinite(point.gainDb, `${pointPath}.gainDb`, issues, { min: -120, max: 120 });
|
||
if (point.phaseDeg !== undefined) {
|
||
requireFinite(point.phaseDeg, `${pointPath}.phaseDeg`, issues, { min: -180, max: 180 });
|
||
}
|
||
if (frequencyValid && point.frequencyHz <= previousFrequency) {
|
||
addIssue(
|
||
issues,
|
||
`${pointPath}.frequencyHz`,
|
||
"주파수 응답점은 frequencyHz가 중복 없이 엄격히 증가하도록 정렬하세요.",
|
||
);
|
||
}
|
||
if (frequencyValid) previousFrequency = point.frequencyHz;
|
||
});
|
||
}
|
||
}
|
||
});
|
||
for (const duplicate of findDuplicates(ids)) {
|
||
addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`);
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
function validateSignal(signal, path, issues, assetsById) {
|
||
if (!requireRecord(signal, path, issues)) return;
|
||
if (signal.type === "tone") {
|
||
requireFinite(signal.frequency, `${path}.frequency`, issues, {
|
||
min: 0,
|
||
max: MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||
exclusiveMin: true,
|
||
});
|
||
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)) {
|
||
const asset = assetsById.get(signal.assetId);
|
||
if (!asset) addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`);
|
||
else if (asset.kind !== "audio") addIssue(issues, `${path}.assetId`, "audio asset을 참조해야 합니다.");
|
||
}
|
||
} else {
|
||
addIssue(issues, `${path}.type`, "tone, noise 또는 asset이어야 합니다.");
|
||
}
|
||
}
|
||
|
||
function validateSceneRelationships(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 });
|
||
|
||
let acousticRoom = null;
|
||
if (value.acoustics !== undefined && requireRecord(value.acoustics, "$.acoustics", issues)) {
|
||
const acoustics = value.acoustics;
|
||
if (acoustics.model === "free-field") {
|
||
const unusedFields = ["room", "reflections", "diffuseReverb", "occluders"]
|
||
.filter((key) => acoustics[key] !== undefined);
|
||
if (unusedFields.length > 0) {
|
||
addIssue(
|
||
issues,
|
||
"$.acoustics",
|
||
"free-field 모델에서는 사용되지 않는 설정을 제거하세요: " + unusedFields.join(", "),
|
||
);
|
||
}
|
||
} else if (acoustics.model === "shoebox") {
|
||
if (acoustics.room === undefined) {
|
||
addIssue(issues, "$.acoustics.room", "shoebox 모델에는 room 경계가 필요합니다.");
|
||
} else if (requireBounds(acoustics.room, "$.acoustics.room", issues)) {
|
||
acousticRoom = acoustics.room;
|
||
}
|
||
}
|
||
if (Array.isArray(acoustics.occluders)) {
|
||
const occluderIds = [];
|
||
acoustics.occluders.forEach((occluder, index) => {
|
||
const path = "$.acoustics.occluders[" + index + "]";
|
||
if (!requireRecord(occluder, path, issues)) return;
|
||
if (requireString(occluder.id, path + ".id", issues)) occluderIds.push(occluder.id);
|
||
if (requireBounds(occluder, path, issues) && acousticRoom) {
|
||
if (!boundsContain(acousticRoom, occluder.min) || !boundsContain(acousticRoom, occluder.max)) {
|
||
addIssue(issues, path, "occluder는 room 경계 안에 있어야 합니다.");
|
||
}
|
||
}
|
||
});
|
||
for (const duplicate of findDuplicates(occluderIds)) {
|
||
addIssue(issues, "$.acoustics.occluders", "중복 occluder id: " + duplicate);
|
||
}
|
||
}
|
||
}
|
||
|
||
const assets = Array.isArray(value.assets) ? value.assets : [];
|
||
const assetsById = new Map();
|
||
const allIds = [];
|
||
assets.forEach((asset, index) => {
|
||
const path = `$.assets[${index}]`;
|
||
if (!requireRecord(asset, path, issues)) return;
|
||
if (requireString(asset.id, `${path}.id`, issues)) {
|
||
assetsById.set(asset.id, asset);
|
||
allIds.push(asset.id);
|
||
}
|
||
if (requireString(asset.fileName, `${path}.fileName`, issues) && !isSafeRelativeAssetPath(asset.fileName)) {
|
||
addIssue(issues, `${path}.fileName`, "manifest 기준의 안전한 상대 경로여야 합니다.");
|
||
}
|
||
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)) {
|
||
const asset = assetsById.get(value.image.assetId);
|
||
if (!asset) addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`);
|
||
else if (asset.kind !== "image") addIssue(issues, "$.image.assetId", "image asset을 참조해야 합니다.");
|
||
}
|
||
if (requireRecord(value.image.plane, "$.image.plane", issues)) {
|
||
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);
|
||
const sourcePositionValid = requirePosition(source.position, `${path}.position`, issues);
|
||
if (sourcePositionValid && acousticRoom && !boundsContain(acousticRoom, source.position)) {
|
||
addIssue(issues, `${path}.position`, "shoebox room 경계 안에 있어야 합니다.");
|
||
}
|
||
if (Array.isArray(source.trajectory)) {
|
||
let previousTime = -Infinity;
|
||
source.trajectory.forEach((keyframe, keyframeIndex) => {
|
||
const keyframePath = `${path}.trajectory[${keyframeIndex}]`;
|
||
if (!requireRecord(keyframe, keyframePath, issues)) return;
|
||
const timeValid = requireFinite(keyframe.time, `${keyframePath}.time`, issues, {
|
||
min: 0,
|
||
max: sceneDurationValid ? value.duration : 300,
|
||
});
|
||
const keyframePositionValid = requirePosition(keyframe.position, `${keyframePath}.position`, issues);
|
||
if (keyframePositionValid && acousticRoom && !boundsContain(acousticRoom, keyframe.position)) {
|
||
addIssue(issues, `${keyframePath}.position`, "shoebox room 경계 안에 있어야 합니다.");
|
||
}
|
||
if (timeValid && keyframe.time <= previousTime) {
|
||
addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다.");
|
||
}
|
||
if (timeValid) previousTime = keyframe.time;
|
||
});
|
||
}
|
||
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, assetsById);
|
||
});
|
||
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
|
||
|
||
if (Array.isArray(value.ambient)) {
|
||
const ambientIds = [];
|
||
value.ambient.forEach((ambient, index) => {
|
||
const path = `$.ambient[${index}]`;
|
||
if (!requireRecord(ambient, path, issues)) return;
|
||
if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id);
|
||
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
|
||
validateSignal(ambient.signal, `${path}.signal`, issues, assetsById);
|
||
});
|
||
for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`);
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
function validateExperimentRelationships(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 (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 {
|
||
const bandIds = [];
|
||
value.frequencyBands.forEach((band, index) => {
|
||
const path = `$.frequencyBands[${index}]`;
|
||
if (!requireRecord(band, path, issues)) return;
|
||
if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id);
|
||
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, {
|
||
min: 0,
|
||
max: MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||
});
|
||
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, {
|
||
min: 0,
|
||
max: MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||
exclusiveMin: true,
|
||
});
|
||
if (minValid && maxValid && band.minHz >= band.maxHz) {
|
||
addIssue(issues, path, "minHz는 maxHz보다 작아야 합니다.");
|
||
}
|
||
if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) {
|
||
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
|
||
}
|
||
});
|
||
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.frequencyBands", `중복 band id: ${duplicate}`);
|
||
}
|
||
|
||
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 (
|
||
context.scene &&
|
||
Number.isFinite(context.scene.sampleRate) &&
|
||
Number.isInteger(value.stft?.fftSize) &&
|
||
Array.isArray(value.frequencyBands)
|
||
) {
|
||
value.frequencyBands.forEach((band, index) => {
|
||
try {
|
||
const observation = observeFrequencyBand(
|
||
band,
|
||
context.scene.sampleRate,
|
||
value.stft.fftSize,
|
||
);
|
||
if (
|
||
value.beamforming?.algorithm === "music" &&
|
||
observation.bins.length !== 1
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`$.frequencyBands[${index}]`,
|
||
"표준 narrowband MUSIC band는 관측 ADC FFT bin center를 정확히 하나만 선택해야 합니다.",
|
||
);
|
||
}
|
||
if (value.beamforming?.algorithm === "music") {
|
||
const binWidthHz =
|
||
context.scene.sampleRate /
|
||
value.stft.fftSize;
|
||
const firstAnalogBin = Math.ceil(
|
||
band.minHz / binWidthHz,
|
||
);
|
||
const lastAnalogBin = Math.floor(
|
||
band.maxHz / binWidthHz,
|
||
);
|
||
if (firstAnalogBin !== lastAnalogBin) {
|
||
addIssue(
|
||
issues,
|
||
`$.frequencyBands[${index}]`,
|
||
"Standard narrowband MUSIC band must contain exactly one configured analog FFT-grid hypothesis. Narrow the band or create separate bands; no alias-order selection or map fusion is applied.",
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
value.beamforming?.algorithm === "music" &&
|
||
observation.bins.length === 1 &&
|
||
(
|
||
observation.bins[0] === 0 ||
|
||
observation.bins[0] === value.stft.fftSize / 2
|
||
)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`$.frequencyBands[${index}]`,
|
||
"DC와 Nyquist FFT bin은 실수 샘플의 quadrature 위상이 퇴화하므로 narrowband MUSIC에 사용할 수 없습니다.",
|
||
);
|
||
}
|
||
} catch (error) {
|
||
addIssue(
|
||
issues,
|
||
`$.frequencyBands[${index}]`,
|
||
`선택한 FFT grid의 bin center가 없는 대역입니다: ${error.message}`,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
if (requireRecord(value.beamforming, "$.beamforming", issues)) {
|
||
if (!LOCALIZATION_ALGORITHMS.includes(value.beamforming.algorithm)) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.algorithm",
|
||
"das, das-phat, gcc-phat 또는 music이어야 합니다.",
|
||
);
|
||
}
|
||
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
|
||
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
|
||
}
|
||
if (
|
||
["gcc-phat", "music"].includes(value.beamforming.algorithm) &&
|
||
value.beamforming.backend === "webgpu"
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.backend",
|
||
"선택한 위치 추정 알고리즘은 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
|
||
);
|
||
}
|
||
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
|
||
min: 0,
|
||
max: 10,
|
||
exclusiveMin: true,
|
||
});
|
||
if (
|
||
value.beamforming.compensateFrequencyResponse !== undefined &&
|
||
typeof value.beamforming.compensateFrequencyResponse !== "boolean"
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.compensateFrequencyResponse",
|
||
"주파수 응답 보정 사용 여부는 true 또는 false여야 합니다.",
|
||
);
|
||
}
|
||
if (value.beamforming.maxCompensationDb !== undefined) {
|
||
requireFinite(
|
||
value.beamforming.maxCompensationDb,
|
||
"$.beamforming.maxCompensationDb",
|
||
issues,
|
||
{ min: 0, max: 120 },
|
||
);
|
||
}
|
||
if (value.beamforming.algorithm === "music") {
|
||
const method = value.beamforming.musicSourceCountMethod ?? "fixed";
|
||
if (value.beamforming.normalization !== "none") {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.normalization",
|
||
"MUSIC raw noise-projection은 정규화하거나 뒤집지 않으므로 normalization은 none이어야 합니다.",
|
||
);
|
||
}
|
||
if (!["fixed", "mdl", "aic"].includes(method)) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.musicSourceCountMethod",
|
||
"fixed, mdl 또는 aic여야 합니다.",
|
||
);
|
||
}
|
||
if (
|
||
value.beamforming.musicSourceCount !== undefined ||
|
||
method === "fixed"
|
||
) {
|
||
requireInteger(
|
||
value.beamforming.musicSourceCount,
|
||
"$.beamforming.musicSourceCount",
|
||
issues,
|
||
{ min: 1, max: MAX_MICROPHONES - 1 },
|
||
);
|
||
}
|
||
if (
|
||
(method === "mdl" || method === "aic") &&
|
||
value.stft?.hopSize !== value.stft?.fftSize
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.stft.hopSize",
|
||
"Wax–Kailath MDL/AIC의 IID snapshot 모델을 적용하려면 최소한 frame이 겹치지 않아야 하므로 hopSize가 fftSize와 같아야 합니다. 비중첩 자체가 통계적 독립성을 증명하지는 않습니다.",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
|
||
const threshold = value.evaluation.detectionThresholdRatio;
|
||
if (!isFiniteNumber(threshold) || threshold <= 0 || threshold > 1) {
|
||
addIssue(
|
||
issues,
|
||
"$.evaluation.detectionThresholdRatio",
|
||
"피크 검출 임계값 비율은 0보다 크고 1 이하여야 합니다. 더 약한 피크를 찾으려면 값을 낮추세요.",
|
||
);
|
||
}
|
||
}
|
||
if (context.arrayDefinition && context.scene) {
|
||
if (
|
||
value.beamforming?.algorithm === "gcc-phat" &&
|
||
context.arrayDefinition.microphones.length < 2
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.algorithm",
|
||
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
|
||
);
|
||
}
|
||
if (value.beamforming?.algorithm === "music") {
|
||
const microphoneCount = context.arrayDefinition.microphones.length;
|
||
const method = value.beamforming.musicSourceCountMethod ?? "fixed";
|
||
const distinctPositions = new Set(
|
||
context.arrayDefinition.microphones.map((microphone) =>
|
||
microphone.position?.join(",")),
|
||
);
|
||
if (microphoneCount < 2 || distinctPositions.size < 2) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.algorithm",
|
||
"MUSIC에는 서로 다른 위치의 마이크가 두 개 이상 필요합니다.",
|
||
);
|
||
}
|
||
if (
|
||
method === "fixed" &&
|
||
Number.isInteger(value.beamforming.musicSourceCount) &&
|
||
value.beamforming.musicSourceCount >= microphoneCount
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.musicSourceCount",
|
||
"고정 MUSIC source count는 마이크 수보다 작아야 합니다.",
|
||
);
|
||
}
|
||
if (
|
||
Number.isInteger(value.stft?.fftSize) &&
|
||
Array.isArray(value.frequencyBands)
|
||
) {
|
||
const binWidthHz =
|
||
context.scene.sampleRate /
|
||
value.stft.fftSize;
|
||
const hypothesisKeys = new Set();
|
||
for (const band of value.frequencyBands) {
|
||
try {
|
||
const observation = observeFrequencyBand(
|
||
band,
|
||
context.scene.sampleRate,
|
||
value.stft.fftSize,
|
||
);
|
||
if (observation.bins.length === 1) {
|
||
hypothesisKeys.add(
|
||
`${Math.ceil(band.minHz / binWidthHz)}|${observation.bins[0]}`,
|
||
);
|
||
}
|
||
} catch {
|
||
// The band-specific validation above reports this.
|
||
}
|
||
}
|
||
const worstCasePairUpdates =
|
||
hypothesisKeys.size *
|
||
32 *
|
||
(
|
||
microphoneCount *
|
||
(microphoneCount - 1) /
|
||
2
|
||
) *
|
||
(2 * microphoneCount - 2);
|
||
if (
|
||
worstCasePairUpdates >
|
||
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.algorithm",
|
||
`MUSIC Jacobi worst-case work ${worstCasePairUpdates.toLocaleString()} pair updates exceeds the explicit CPU limit ${DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES.toLocaleString()}. Reduce microphones or configured hypotheses; no partial eigensystem is returned.`,
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
(method === "mdl" || method === "aic") &&
|
||
Number.isFinite(context.scene.duration) &&
|
||
Number.isFinite(context.scene.sampleRate) &&
|
||
Number.isInteger(value.stft?.fftSize) &&
|
||
Number.isInteger(value.stft?.hopSize)
|
||
) {
|
||
const sampleCount = Math.ceil(
|
||
context.scene.duration * context.scene.sampleRate,
|
||
);
|
||
const frameCount =
|
||
sampleCount < value.stft.fftSize
|
||
? 0
|
||
: 1 + Math.floor(
|
||
(sampleCount - value.stft.fftSize) /
|
||
value.stft.hopSize,
|
||
);
|
||
if (frameCount <= microphoneCount) {
|
||
addIssue(
|
||
issues,
|
||
"$.beamforming.musicSourceCountMethod",
|
||
`Wax–Kailath ${method.toUpperCase()}의 IID snapshot 가정 아래에서도 microphone 수보다 많은 frame이 필요합니다 (현재 ${frameCount} frames / ${microphoneCount} microphones).`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
try {
|
||
const workingSet = estimateSynthesisWorkingSet({
|
||
scene: context.scene,
|
||
arrayDefinition: context.arrayDefinition,
|
||
minimumDistance: value.beamforming?.minimumDistance ?? 0.01,
|
||
});
|
||
if (
|
||
workingSet.estimatedBufferBytes >
|
||
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$",
|
||
`source-streaming signal buffer 예상 크기 ${(workingSet.estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
|
||
);
|
||
}
|
||
} catch (error) {
|
||
addIssue(
|
||
issues,
|
||
"$",
|
||
`signal synthesis preflight failed: ${error.message}`,
|
||
);
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
function validateResultRelationships(value, context = {}) {
|
||
/** @type {ValidationIssue[]} */
|
||
const issues = [];
|
||
if (!isRecord(value)) return issues;
|
||
if (context.experiment) {
|
||
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
|
||
if (
|
||
value.algorithm !== undefined &&
|
||
value.algorithm !== context.experiment.beamforming?.algorithm
|
||
) {
|
||
addIssue(issues, "$.algorithm", "실험의 위치 추정 알고리즘과 다릅니다.");
|
||
}
|
||
}
|
||
if (
|
||
context.arrayDefinition &&
|
||
value.arrayId !== context.arrayDefinition.id
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.arrayId",
|
||
"Result arrayId must identify the array actually used for this run.",
|
||
);
|
||
}
|
||
if (context.scene && value.sceneId !== context.scene.id) {
|
||
addIssue(
|
||
issues,
|
||
"$.sceneId",
|
||
"Result sceneId must identify the Scene actually used for this run.",
|
||
);
|
||
}
|
||
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
||
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
||
const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm;
|
||
if (
|
||
(resultAlgorithm === "gcc-phat" || resultAlgorithm === "music") &&
|
||
value.backend === "webgpu"
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.backend",
|
||
"The selected Result algorithm must use the CPU backend.",
|
||
);
|
||
}
|
||
const expectedRawScale =
|
||
resultAlgorithm === "das"
|
||
? "power"
|
||
: resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat"
|
||
? "score"
|
||
: resultAlgorithm === "music"
|
||
? "noise-projection"
|
||
: undefined;
|
||
const expectedBeamSpreadMethod =
|
||
expectedRawScale === "power"
|
||
? "connected-half-power-power-weighted-covariance"
|
||
: expectedRawScale === "score"
|
||
? "connected-half-maximum-score-weighted-covariance"
|
||
: undefined;
|
||
if (resultAlgorithm === "music" && !isRecord(value.musicModel)) {
|
||
addIssue(
|
||
issues,
|
||
"$.musicModel",
|
||
"MUSIC Result에는 사용한 manifold·loading·fusion 모델 설명이 필요합니다.",
|
||
);
|
||
}
|
||
if (resultAlgorithm !== "music" && value.musicModel !== undefined) {
|
||
addIssue(
|
||
issues,
|
||
"$.musicModel",
|
||
"musicModel은 MUSIC Result에만 사용할 수 있습니다.",
|
||
);
|
||
}
|
||
if (
|
||
resultAlgorithm === "music" &&
|
||
isRecord(value.musicModel) &&
|
||
isRecord(context.experiment?.beamforming)
|
||
) {
|
||
const beamforming = context.experiment.beamforming;
|
||
for (const [field, expected] of [
|
||
["requestedNormalization", beamforming.normalization],
|
||
["minimumDistance", beamforming.minimumDistance],
|
||
[
|
||
"microphoneCalibration.compensateFrequencyResponse",
|
||
beamforming.compensateFrequencyResponse ?? true,
|
||
],
|
||
[
|
||
"microphoneCalibration.maxCompensationDb",
|
||
beamforming.maxCompensationDb ?? DEFAULT_MAX_COMPENSATION_DB,
|
||
],
|
||
]) {
|
||
const actual = field.split(".").reduce(
|
||
(current, key) => current?.[key],
|
||
value.musicModel,
|
||
);
|
||
if (!samplingMetadataEqual(actual, expected)) {
|
||
addIssue(
|
||
issues,
|
||
`$.musicModel.${field}`,
|
||
`${field} does not match the Experiment MUSIC configuration.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if (
|
||
Number.isFinite(value.timingMs?.localization) &&
|
||
Number.isFinite(value.timingMs?.beamforming) &&
|
||
!samplingMetadataEqual(
|
||
value.timingMs.localization,
|
||
value.timingMs.beamforming,
|
||
)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.timingMs.localization",
|
||
"localization must equal the compatibility beamforming timing alias.",
|
||
);
|
||
}
|
||
const expectedObservations = new Map();
|
||
const expectedEquivalentGroups = new Map();
|
||
if (
|
||
context.scene &&
|
||
context.experiment &&
|
||
Number.isFinite(context.scene.sampleRate) &&
|
||
Number.isInteger(context.experiment.stft?.fftSize)
|
||
) {
|
||
const definitions = Array.isArray(context.experiment.frequencyBands)
|
||
? context.experiment.frequencyBands
|
||
: [];
|
||
for (const definition of definitions) {
|
||
try {
|
||
const observation = observeFrequencyBand(
|
||
definition,
|
||
context.scene.sampleRate,
|
||
context.experiment.stft.fftSize,
|
||
);
|
||
expectedObservations.set(definition.id, observation);
|
||
const group = expectedEquivalentGroups.get(observation.adcBinSetKey) ?? [];
|
||
group.push(definition.id);
|
||
expectedEquivalentGroups.set(observation.adcBinSetKey, group);
|
||
} catch {
|
||
// Experiment validation reports an unusable band separately.
|
||
}
|
||
}
|
||
}
|
||
const sampling = value.signalStats?.sampling;
|
||
const contextSampleRate =
|
||
Number.isFinite(context.scene?.sampleRate) && context.scene.sampleRate > 0
|
||
? context.scene.sampleRate
|
||
: null;
|
||
const toneDefinitions = new Map();
|
||
if (context.scene) {
|
||
const sources = Array.isArray(context.scene.sources) ? context.scene.sources : [];
|
||
const ambientEntries = Array.isArray(context.scene.ambient)
|
||
? context.scene.ambient
|
||
: [];
|
||
for (const source of sources) {
|
||
if (source.signal?.type === "tone") {
|
||
toneDefinitions.set(`source:${source.id}`, source.signal.frequency);
|
||
}
|
||
}
|
||
for (const ambient of ambientEntries) {
|
||
if (ambient.signal?.type === "tone") {
|
||
toneDefinitions.set(`ambient:${ambient.id}`, ambient.signal.frequency);
|
||
}
|
||
}
|
||
}
|
||
if (isRecord(sampling)) {
|
||
const samplingRateValid =
|
||
Number.isFinite(sampling.sampleRate) && sampling.sampleRate > 0;
|
||
if (
|
||
samplingRateValid &&
|
||
!samplingMetadataEqual(sampling.nyquistHz, sampling.sampleRate / 2)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
"$.signalStats.sampling.nyquistHz",
|
||
"sampling Nyquist must equal sampling sampleRate / 2.",
|
||
);
|
||
}
|
||
if (
|
||
contextSampleRate !== null &&
|
||
!samplingMetadataEqual(sampling.sampleRate, contextSampleRate)
|
||
) {
|
||
addIssue(issues, "$.signalStats.sampling.sampleRate", "sampling sampleRate must match Scene.");
|
||
}
|
||
if (
|
||
contextSampleRate !== null &&
|
||
!samplingMetadataEqual(sampling.nyquistHz, contextSampleRate / 2)
|
||
) {
|
||
addIssue(issues, "$.signalStats.sampling.nyquistHz", "sampling Nyquist must equal sampleRate / 2.");
|
||
}
|
||
const observedToneKeys = new Set();
|
||
const toneObservations = Array.isArray(sampling.toneObservations)
|
||
? sampling.toneObservations
|
||
: [];
|
||
for (const [observationIndex, observation] of toneObservations.entries()) {
|
||
if (!isRecord(observation)) continue;
|
||
const path = `$.signalStats.sampling.toneObservations[${observationIndex}]`;
|
||
const observationKey = `${observation.scope}:${observation.id}`;
|
||
if (observedToneKeys.has(observationKey)) {
|
||
addIssue(issues, path, `duplicate tone observation: ${observationKey}`);
|
||
}
|
||
observedToneKeys.add(observationKey);
|
||
if (samplingRateValid && Number.isFinite(observation.analogFrequencyHz)) {
|
||
try {
|
||
const expected = describeFrequencyAliasing(
|
||
observation.analogFrequencyHz,
|
||
sampling.sampleRate,
|
||
);
|
||
for (const key of [
|
||
"analogFrequencyHz",
|
||
"adcFrequencyHz",
|
||
"signedAdcFrequencyHz",
|
||
"aliased",
|
||
"spectralInversion",
|
||
"dcEdge",
|
||
"nyquistEdge",
|
||
"phaseDegenerate",
|
||
]) {
|
||
if (!samplingMetadataEqual(observation[key], expected[key])) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.${key}`,
|
||
`${key} does not match ideal ADC sampling.`,
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
addIssue(issues, `${path}.analogFrequencyHz`, error.message);
|
||
}
|
||
}
|
||
if (context.scene) {
|
||
const analogFrequencyHz = toneDefinitions.get(observationKey);
|
||
if (analogFrequencyHz === undefined) {
|
||
addIssue(issues, path, "tone observation must reference a Scene tone.");
|
||
} else if (
|
||
!samplingMetadataEqual(observation.analogFrequencyHz, analogFrequencyHz)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.analogFrequencyHz`,
|
||
"analogFrequencyHz must match the Scene tone.",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if (context.scene) {
|
||
for (const toneKey of toneDefinitions.keys()) {
|
||
if (!observedToneKeys.has(toneKey)) {
|
||
addIssue(
|
||
issues,
|
||
"$.signalStats.sampling.toneObservations",
|
||
`missing Scene tone observation: ${toneKey}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} else if (contextSampleRate !== null) {
|
||
addIssue(issues, "$.signalStats.sampling", "sampling metadata is required.");
|
||
}
|
||
const standaloneObservations = new Map();
|
||
const standaloneEquivalentGroups = new Map();
|
||
if (Array.isArray(value.bands)) {
|
||
value.bands.forEach((band, bandIndex) => {
|
||
const observation = band?.frequencyObservation;
|
||
if (!isRecord(observation)) return;
|
||
if (
|
||
!Number.isFinite(observation.sampleRate) ||
|
||
!Number.isInteger(observation.fftSize) ||
|
||
!Number.isFinite(observation.requestedMinHz) ||
|
||
!Number.isFinite(observation.requestedMaxHz)
|
||
) {
|
||
return;
|
||
}
|
||
try {
|
||
const expected = observeFrequencyBand(
|
||
{
|
||
minHz: observation.requestedMinHz,
|
||
maxHz: observation.requestedMaxHz,
|
||
},
|
||
observation.sampleRate,
|
||
observation.fftSize,
|
||
);
|
||
standaloneObservations.set(bandIndex, expected);
|
||
if (typeof band.id === "string") {
|
||
const group = standaloneEquivalentGroups.get(expected.adcBinSetKey) ?? [];
|
||
group.push(band.id);
|
||
standaloneEquivalentGroups.set(expected.adcBinSetKey, group);
|
||
}
|
||
} catch (error) {
|
||
addIssue(
|
||
issues,
|
||
`$.bands[${bandIndex}].frequencyObservation`,
|
||
`invalid self-described frequency observation: ${error.message}`,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
const bandIds = [];
|
||
if (Array.isArray(value.bands)) {
|
||
value.bands.forEach((band, bandIndex) => {
|
||
if (!isRecord(band)) return;
|
||
const path = `$.bands[${bandIndex}]`;
|
||
if (typeof band.id === "string") {
|
||
bandIds.push(band.id);
|
||
if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다.");
|
||
}
|
||
const standaloneObservation = standaloneObservations.get(bandIndex);
|
||
if (standaloneObservation && isRecord(band.frequencyObservation)) {
|
||
const observation = band.frequencyObservation;
|
||
const musicAnalogBin = Math.ceil(
|
||
standaloneObservation.requestedMinHz /
|
||
standaloneObservation.binWidthHz,
|
||
);
|
||
const musicAnalogFrequencyHz =
|
||
musicAnalogBin *
|
||
standaloneObservation.binWidthHz;
|
||
const musicAlias = resultAlgorithm === "music"
|
||
? describeFrequencyAliasing(
|
||
musicAnalogFrequencyHz,
|
||
standaloneObservation.sampleRate,
|
||
)
|
||
: null;
|
||
const expectedEquivalentBandIds = (standaloneEquivalentGroups
|
||
.get(standaloneObservation.adcBinSetKey) ?? [])
|
||
.filter((candidateId) => candidateId !== band.id)
|
||
.sort();
|
||
const expectedFields = {
|
||
model: standaloneObservation.model,
|
||
sampleRate: standaloneObservation.sampleRate,
|
||
nyquistHz: standaloneObservation.nyquistHz,
|
||
fftSize: standaloneObservation.fftSize,
|
||
binWidthHz: standaloneObservation.binWidthHz,
|
||
requestedMinHz: standaloneObservation.requestedMinHz,
|
||
requestedMaxHz: standaloneObservation.requestedMaxHz,
|
||
aliased: standaloneObservation.aliased,
|
||
crossesFirstNyquist: standaloneObservation.crossesFirstNyquist,
|
||
localizationInputModel: standaloneObservation.localizationInputModel,
|
||
selectionModel: standaloneObservation.selectionModel,
|
||
localizationModel:
|
||
resultAlgorithm === "gcc-phat"
|
||
? "band-limited-gcc-phat-geometric-lag"
|
||
: resultAlgorithm === "music"
|
||
? "near-field-music-at-configured-analog-frequency"
|
||
: "spherical-steering-at-positive-adc-bin-frequency",
|
||
originalCarrierUsed:
|
||
resultAlgorithm === "music",
|
||
aliasOrderResolution:
|
||
resultAlgorithm === "music" &&
|
||
standaloneObservation.aliased
|
||
? "configured-single-hypothesis"
|
||
: standaloneObservation.aliased
|
||
? "not-attempted"
|
||
: "not-applicable",
|
||
...(resultAlgorithm === "music"
|
||
? {
|
||
configuredAnalogFrequencyHz:
|
||
musicAnalogFrequencyHz,
|
||
signedAdcFrequencyHz:
|
||
musicAlias.signedAdcFrequencyHz,
|
||
spectralInversion:
|
||
musicAlias.spectralInversion,
|
||
frequencyHypothesisCount: 1,
|
||
frequencyHypothesisSource:
|
||
"experiment-band-not-scene-truth",
|
||
}
|
||
: {}),
|
||
foldBoundaryCount: standaloneObservation.foldBoundaryCount,
|
||
continuousManyToOne: standaloneObservation.continuousManyToOne,
|
||
gridManyToOne: standaloneObservation.gridManyToOne,
|
||
adcBinSetKey: standaloneObservation.adcBinSetKey,
|
||
};
|
||
for (const [key, expected] of Object.entries(expectedFields)) {
|
||
if (!samplingMetadataEqual(observation[key], expected)) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.frequencyObservation.${key}`,
|
||
`${key} is not self-consistent with the Result sampling inputs.`,
|
||
);
|
||
}
|
||
}
|
||
for (const [key, expected] of [
|
||
["adcBins", standaloneObservation.bins],
|
||
["adcBinCenterRanges", standaloneObservation.adcBinCenterRanges],
|
||
["foldSegments", standaloneObservation.foldSegments],
|
||
["equivalentBandIds", expectedEquivalentBandIds],
|
||
]) {
|
||
if (!samplingMetadataEqual(observation[key], expected)) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.frequencyObservation.${key}`,
|
||
`${key} does not match the deterministic ADC fold.`,
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
isRecord(sampling) &&
|
||
Number.isFinite(sampling.sampleRate) &&
|
||
!samplingMetadataEqual(observation.sampleRate, sampling.sampleRate)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.frequencyObservation.sampleRate`,
|
||
"band sampleRate must match signalStats.sampling.sampleRate.",
|
||
);
|
||
}
|
||
} else if (!isRecord(band.frequencyObservation)) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.frequencyObservation`,
|
||
"frequency observation metadata is required for every Result band.",
|
||
);
|
||
}
|
||
const contextObservation = expectedObservations.get(band.id);
|
||
if (contextObservation && isRecord(band.frequencyObservation)) {
|
||
const observation = band.frequencyObservation;
|
||
for (const [key, expected] of Object.entries({
|
||
sampleRate: contextObservation.sampleRate,
|
||
fftSize: contextObservation.fftSize,
|
||
requestedMinHz: contextObservation.requestedMinHz,
|
||
requestedMaxHz: contextObservation.requestedMaxHz,
|
||
})) {
|
||
if (!samplingMetadataEqual(observation[key], expected)) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.frequencyObservation.${key}`,
|
||
`${key} does not match Scene/Experiment.`,
|
||
);
|
||
}
|
||
}
|
||
const expectedEquivalentBandIds = expectedEquivalentGroups
|
||
.get(contextObservation.adcBinSetKey)
|
||
.filter((candidateId) => candidateId !== band.id)
|
||
.sort();
|
||
if (
|
||
!samplingMetadataEqual(
|
||
observation.equivalentBandIds,
|
||
expectedEquivalentBandIds,
|
||
)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.frequencyObservation.equivalentBandIds`,
|
||
"equivalentBandIds does not match the Experiment band set.",
|
||
);
|
||
}
|
||
}
|
||
const processedMaps = Array.isArray(band.processedMaps)
|
||
? band.processedMaps.map((item) => item?.map)
|
||
: [];
|
||
const maps = [band.rawMap, band.displayMap, ...processedMaps].filter(Boolean);
|
||
maps.forEach((map, mapIndex) => {
|
||
if (!isRecord(map) || !Array.isArray(map.values)) return;
|
||
if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) {
|
||
addIssue(issues, `${path}.maps[${mapIndex}].values`, "values 길이는 rows × columns와 같아야 합니다.");
|
||
}
|
||
if (context.experiment && (map.rows !== context.experiment.scan.rows || map.columns !== context.experiment.scan.columns)) {
|
||
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
|
||
}
|
||
});
|
||
if (
|
||
isRecord(band.rawMap) &&
|
||
!["power", "score", "noise-projection"].includes(band.rawMap.scale)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.rawMap.scale`,
|
||
"rawMap은 power, score 또는 noise-projection scale이어야 합니다.",
|
||
);
|
||
} else if (
|
||
isRecord(band.rawMap) &&
|
||
expectedRawScale &&
|
||
band.rawMap.scale !== expectedRawScale
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.rawMap.scale`,
|
||
`${resultAlgorithm} 결과의 rawMap scale은 ${expectedRawScale}여야 합니다.`,
|
||
);
|
||
}
|
||
if (
|
||
isRecord(band.rawMap) &&
|
||
band.rawMap.scale === "noise-projection" &&
|
||
band.rawMap.objectiveDirection !== "minimum"
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.rawMap.objectiveDirection`,
|
||
"MUSIC noise-projection map의 optimum은 minimum이어야 합니다.",
|
||
);
|
||
}
|
||
if (
|
||
isRecord(band.rawMap) &&
|
||
band.rawMap.scale !== "noise-projection" &&
|
||
band.rawMap.objectiveDirection !== undefined &&
|
||
band.rawMap.objectiveDirection !== "maximum"
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.rawMap.objectiveDirection`,
|
||
"power와 score map의 optimum은 maximum이어야 합니다.",
|
||
);
|
||
}
|
||
if (resultAlgorithm === "music") {
|
||
const diagnostics = band.musicDiagnostics;
|
||
if (!isRecord(diagnostics)) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics`,
|
||
"MUSIC band에는 covariance/EVD/source-count 수치 진단이 필요합니다.",
|
||
);
|
||
} else {
|
||
const frequencyHypothesis = diagnostics.frequencyHypothesis;
|
||
const frequencyObservation = band.frequencyObservation;
|
||
if (
|
||
isRecord(frequencyHypothesis) &&
|
||
isRecord(frequencyObservation)
|
||
) {
|
||
const adcBin =
|
||
Array.isArray(frequencyObservation.adcBins) &&
|
||
frequencyObservation.adcBins.length === 1
|
||
? frequencyObservation.adcBins[0]
|
||
: null;
|
||
const analogFrequencyHz =
|
||
frequencyObservation.configuredAnalogFrequencyHz;
|
||
const signedAdcFrequencyHz =
|
||
frequencyObservation.signedAdcFrequencyHz;
|
||
const sampleRate = frequencyObservation.sampleRate;
|
||
const expectedHypothesis = {
|
||
model:
|
||
"configured-single-analog-fft-grid-hypothesis",
|
||
hypothesisCount:
|
||
frequencyObservation.frequencyHypothesisCount,
|
||
analogFrequencyHz,
|
||
observedFrequencyHz:
|
||
Number.isInteger(adcBin) &&
|
||
Number.isFinite(frequencyObservation.binWidthHz)
|
||
? adcBin * frequencyObservation.binWidthHz
|
||
: null,
|
||
signedAdcFrequencyHz,
|
||
spectralInversion:
|
||
frequencyObservation.spectralInversion,
|
||
aliasOrder:
|
||
Number.isFinite(analogFrequencyHz) &&
|
||
Number.isFinite(signedAdcFrequencyHz) &&
|
||
Number.isFinite(sampleRate) &&
|
||
sampleRate > 0
|
||
? Math.round(
|
||
(analogFrequencyHz - signedAdcFrequencyHz) /
|
||
sampleRate,
|
||
)
|
||
: null,
|
||
sharedObservationKey:
|
||
Number.isInteger(adcBin) &&
|
||
Number.isFinite(sampleRate) &&
|
||
Number.isInteger(frequencyObservation.fftSize)
|
||
? `fs:${sampleRate}|fft:${frequencyObservation.fftSize}|bin:${adcBin}`
|
||
: null,
|
||
};
|
||
for (const [field, expected] of Object.entries(
|
||
expectedHypothesis,
|
||
)) {
|
||
if (
|
||
expected !== null &&
|
||
!samplingMetadataEqual(
|
||
frequencyHypothesis[field],
|
||
expected,
|
||
)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics.frequencyHypothesis.${field}`,
|
||
`${field} must match the configured analog frequency hypothesis.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
const microphoneCount =
|
||
context.arrayDefinition?.microphones?.length ??
|
||
value.environment?.microphoneCount;
|
||
if (
|
||
Number.isInteger(microphoneCount) &&
|
||
(
|
||
diagnostics.eigenvalues?.length !== microphoneCount ||
|
||
diagnostics.auditedEigenvalues?.length !== microphoneCount
|
||
)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics.eigenvalues`,
|
||
"MUSIC eigenvalue 수는 microphone 수와 같아야 합니다.",
|
||
);
|
||
}
|
||
const sourceCount = diagnostics.sourceCount?.value;
|
||
const expectedSourceCountMethod =
|
||
context.experiment?.beamforming?.musicSourceCountMethod ??
|
||
"fixed";
|
||
if (
|
||
diagnostics.sourceCount?.method !== expectedSourceCountMethod ||
|
||
diagnostics.sourceCount?.criterion !==
|
||
(expectedSourceCountMethod === "fixed"
|
||
? null
|
||
: expectedSourceCountMethod)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics.sourceCount.method`,
|
||
"source-count method/criterion은 Experiment 설정과 같아야 합니다.",
|
||
);
|
||
}
|
||
if (
|
||
expectedSourceCountMethod !== "fixed" &&
|
||
!isRecord(
|
||
diagnostics.sourceCount?.assumptions?.statisticalDataModel,
|
||
)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics.sourceCount.assumptions.statisticalDataModel`,
|
||
"MDL/AIC 진단에는 white-noise/Gaussian/stationarity 모델 가정과 비확률 해석이 필요합니다.",
|
||
);
|
||
}
|
||
if (
|
||
Number.isInteger(value.environment?.stftFrameCount) &&
|
||
diagnostics.snapshotCount !== value.environment.stftFrameCount
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics.snapshotCount`,
|
||
"MUSIC snapshotCount는 실행 STFT frame 수와 같아야 합니다.",
|
||
);
|
||
}
|
||
if (
|
||
Number.isInteger(microphoneCount) &&
|
||
Number.isInteger(sourceCount) &&
|
||
sourceCount >= microphoneCount
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics.sourceCount.value`,
|
||
"MUSIC source count는 microphone 수보다 작아야 합니다.",
|
||
);
|
||
}
|
||
if (
|
||
context.experiment?.beamforming?.musicSourceCountMethod === "fixed" &&
|
||
Number.isInteger(context.experiment.beamforming.musicSourceCount) &&
|
||
sourceCount !== context.experiment.beamforming.musicSourceCount
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics.sourceCount.value`,
|
||
"고정 source count 진단은 Experiment K와 같아야 합니다.",
|
||
);
|
||
}
|
||
}
|
||
} else if (band.musicDiagnostics !== undefined) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.musicDiagnostics`,
|
||
"musicDiagnostics는 MUSIC band에만 사용할 수 있습니다.",
|
||
);
|
||
}
|
||
if (Array.isArray(band.peaks)) {
|
||
band.peaks.forEach((peak, peakIndex) => {
|
||
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
|
||
addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다.");
|
||
}
|
||
if (
|
||
expectedBeamSpreadMethod &&
|
||
isRecord(peak?.beamSpread) &&
|
||
peak.beamSpread.method !== expectedBeamSpreadMethod
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.peaks[${peakIndex}].beamSpread.method`,
|
||
`${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`,
|
||
);
|
||
}
|
||
if (
|
||
resultAlgorithm === "music" &&
|
||
isRecord(peak?.beamSpread)
|
||
) {
|
||
addIssue(
|
||
issues,
|
||
`${path}.peaks[${peakIndex}].beamSpread`,
|
||
"MUSIC noise-projection에는 DAS/PHAT half-power beamSpread를 적용할 수 없습니다.",
|
||
);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
for (const expectedBandId of expectedBandIds) {
|
||
if (!bandIds.includes(expectedBandId)) {
|
||
addIssue(issues, "$.bands", `missing Experiment band result: ${expectedBandId}`);
|
||
}
|
||
}
|
||
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`);
|
||
return issues;
|
||
}
|
||
|
||
async function combineValidation(name, value, relationshipIssues) {
|
||
return [...(await validateDocumentStructure(name, value)), ...relationshipIssues];
|
||
}
|
||
|
||
export async function validateArrayDefinition(value) {
|
||
return combineValidation("array", value, validateArrayRelationships(value));
|
||
}
|
||
|
||
export async function validateScene(value) {
|
||
return combineValidation("scene", value, validateSceneRelationships(value));
|
||
}
|
||
|
||
export async function validateExperiment(value, context = {}) {
|
||
return combineValidation("experiment", value, validateExperimentRelationships(value, context));
|
||
}
|
||
|
||
export async function validateResult(value, context = {}) {
|
||
return combineValidation("result", value, validateResultRelationships(value, context));
|
||
}
|
||
|
||
export async function assertValid(value, validator, label = "데이터") {
|
||
const issues = await validator(value);
|
||
if (issues.length > 0) {
|
||
const details = issues.map(({ path, message }) => `${path}: ${message}`).join("\n");
|
||
throw new Error(`${label} 검증 실패\n${details}`);
|
||
}
|
||
return value;
|
||
}
|