fix: expose physical aliasing and delayed arrivals

This commit is contained in:
2026-08-29 10:21:33 +09:00
parent e048ccbadc
commit de0053eae3
38 changed files with 3086 additions and 214 deletions
+8 -2
View File
@@ -1,10 +1,16 @@
# Contracts
네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제
JSON Schema를 실행하고, `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼
JSON Schema를 실행하고, `validation.js`는 중복 id, 시간 구간, 참조 무결성처럼
스키마만으로 표현하기 불편한 조건을 추가 검사합니다.
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
Nyquist 초과 tone과 Experiment band는 거절하지 않습니다. acoustics core가 anti-alias filter 없는
ideal ADC alias를 명시적으로 계산하고 Result에 원/관측 주파수와 선택 bin을 기록합니다.
Array·Scene·Experiment 계약 버전은
`INPUT_SCHEMA_VERSION = "1.0.0"`, sampling provenance가 필수인 Result 계약은
`RESULT_SCHEMA_VERSION = "1.1.0"`입니다. 두 버전은 `validation.js`에서 별도 상수로
공개합니다. 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
+403 -20
View File
@@ -1,6 +1,12 @@
import { validateDocumentStructure } from "./schema-validator.js";
import {
describeFrequencyAliasing,
MAX_PRECISE_ANALOG_FREQUENCY_HZ,
observeFrequencyBand,
} from "../acoustics-core/sampling.js";
export const SCHEMA_VERSION = "1.0.0";
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;
@@ -18,6 +24,30 @@ 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 });
}
@@ -92,8 +122,8 @@ function boundsContain(bounds, position) {
function validateHeader(value, expectedKind, issues) {
if (!requireRecord(value, "$", issues)) return false;
if (value.schemaVersion !== SCHEMA_VERSION) {
addIssue(issues, "$.schemaVersion", `지원 버전은 ${SCHEMA_VERSION}입니다.`);
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);
@@ -194,14 +224,14 @@ function validateArrayRelationships(value) {
return issues;
}
function validateSignal(signal, path, issues, assetsById, sampleRate) {
function validateSignal(signal, path, issues, assetsById) {
if (!requireRecord(signal, path, issues)) return;
if (signal.type === "tone") {
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
if (signal.frequency >= sampleRate / 2) {
addIssue(issues, `${path}.frequency`, `Nyquist 주파수 ${sampleRate / 2} Hz보다 낮아야 합니다.`);
}
}
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 });
}
@@ -358,7 +388,7 @@ function validateSceneRelationships(value) {
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
}
validateSignal(source.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
validateSignal(source.signal, `${path}.signal`, issues, assetsById);
});
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
@@ -369,7 +399,7 @@ function validateSceneRelationships(value) {
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, sampleRateValid ? value.sampleRate : 48000);
validateSignal(ambient.signal, `${path}.signal`, issues, assetsById);
});
for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`);
}
@@ -412,14 +442,18 @@ function validateExperimentRelationships(value, context = {}) {
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 });
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
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 (context.scene && maxValid && band.maxHz >= context.scene.sampleRate / 2) {
addIssue(issues, `${path}.maxHz`, `Nyquist 주파수 ${context.scene.sampleRate / 2} Hz보다 낮아야 합니다.`);
}
if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) {
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
}
@@ -436,6 +470,24 @@ function validateExperimentRelationships(value, context = {}) {
}
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 {
observeFrequencyBand(band, context.scene.sampleRate, value.stft.fftSize);
} 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(
@@ -502,7 +554,15 @@ function validateExperimentRelationships(value, context = {}) {
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
);
}
const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
const sampledSourceCount = context.scene.sources
.filter((source) => source.signal?.type !== "tone")
.length;
const hasSampledAmbient = (context.scene.ambient ?? [])
.some((ambient) => ambient.signal?.type !== "tone");
const retainedBufferCount =
context.arrayDefinition.microphones.length +
sampledSourceCount +
(hasSampledAmbient ? 1 : 0);
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
let maximumDelaySamples = 0;
for (const source of context.scene.sources) {
@@ -524,8 +584,15 @@ function validateExperimentRelationships(value, context = {}) {
}
}
let delayFftSize = 1;
while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) delayFftSize *= 2;
const estimatedBytes = retainedBufferCount * sampleCount * 4 + delayFftSize * 8 * 4;
const hasSampledSignal = sampledSourceCount > 0 || hasSampledAmbient;
if (hasSampledSignal) {
while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) {
delayFftSize *= 2;
}
}
const delayWorkingBytes = hasSampledSignal ? delayFftSize * 8 * 4 : 0;
const estimatedBytes =
retainedBufferCount * sampleCount * 4 + delayWorkingBytes;
const maximumBytes = 512 * 1024 * 1024;
if (estimatedBytes > maximumBytes) {
addIssue(
@@ -556,6 +623,13 @@ function validateResultRelationships(value, context = {}) {
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" && value.backend === "webgpu") {
addIssue(
issues,
"$.backend",
"gcc-phat Result must use the CPU backend.",
);
}
const expectedRawScale =
resultAlgorithm === "das"
? "power"
@@ -568,6 +642,201 @@ function validateResultRelationships(value, context = {}) {
: expectedRawScale === "score"
? "connected-half-maximum-score-weighted-covariance"
: undefined;
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) => {
@@ -577,7 +846,116 @@ function validateResultRelationships(value, context = {}) {
bandIds.push(band.id);
if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다.");
}
const maps = [band.rawMap, band.displayMap, ...(band.processedMaps?.map((item) => item.map) ?? [])].filter(Boolean);
const standaloneObservation = standaloneObservations.get(bandIndex);
if (standaloneObservation && isRecord(band.frequencyObservation)) {
const observation = band.frequencyObservation;
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"
: "spherical-steering-at-positive-adc-bin-frequency",
originalCarrierUsed: standaloneObservation.originalCarrierUsed,
aliasOrderResolution:
standaloneObservation.aliased ? "not-attempted" : "not-applicable",
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) {
@@ -620,6 +998,11 @@ function validateResultRelationships(value, context = {}) {
}
});
}
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;
}