Files

1492 lines
57 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}