feat: add physically explicit narrowband MUSIC
This commit is contained in:
@@ -8,6 +8,10 @@ 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";
|
||||
@@ -16,7 +20,7 @@ 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"];
|
||||
const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat", "music"];
|
||||
|
||||
/** @typedef {{ path: string, message: string }} ValidationIssue */
|
||||
|
||||
@@ -416,13 +420,6 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
|
||||
requireString(value.arrayId, "$.arrayId", issues);
|
||||
requireString(value.sceneId, "$.sceneId", issues);
|
||||
if (context.arrayDefinition && value.arrayId !== context.arrayDefinition.id) {
|
||||
addIssue(issues, "$.arrayId", "불러온 배열 id와 다릅니다.");
|
||||
}
|
||||
if (context.scene && value.sceneId !== context.scene.id) {
|
||||
addIssue(issues, "$.sceneId", "불러온 장면 id와 다릅니다.");
|
||||
}
|
||||
|
||||
if (requireRecord(value.scan, "$.scan", issues)) {
|
||||
requireFinite(value.scan.z, "$.scan.z", issues, { min: 0, exclusiveMin: true });
|
||||
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
|
||||
@@ -482,7 +479,53 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
) {
|
||||
value.frequencyBands.forEach((band, index) => {
|
||||
try {
|
||||
observeFrequencyBand(band, context.scene.sampleRate, value.stft.fftSize);
|
||||
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,
|
||||
@@ -497,20 +540,20 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.beamforming.algorithm",
|
||||
"das, das-phat 또는 gcc-phat이어야 합니다.",
|
||||
"das, das-phat, gcc-phat 또는 music이어야 합니다.",
|
||||
);
|
||||
}
|
||||
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
|
||||
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
|
||||
}
|
||||
if (
|
||||
value.beamforming.algorithm === "gcc-phat" &&
|
||||
["gcc-phat", "music"].includes(value.beamforming.algorithm) &&
|
||||
value.beamforming.backend === "webgpu"
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.beamforming.backend",
|
||||
"gcc-phat는 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
|
||||
"선택한 위치 추정 알고리즘은 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
|
||||
);
|
||||
}
|
||||
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
|
||||
@@ -536,6 +579,44 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
{ 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;
|
||||
@@ -558,6 +639,101 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
"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,
|
||||
@@ -591,8 +767,6 @@ function validateResultRelationships(value, context = {}) {
|
||||
if (!isRecord(value)) return issues;
|
||||
if (context.experiment) {
|
||||
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
|
||||
if (value.arrayId !== context.experiment.arrayId) addIssue(issues, "$.arrayId", "실험의 배열 id와 다릅니다.");
|
||||
if (value.sceneId !== context.experiment.sceneId) addIssue(issues, "$.sceneId", "실험의 장면 id와 다릅니다.");
|
||||
if (
|
||||
value.algorithm !== undefined &&
|
||||
value.algorithm !== context.experiment.beamforming?.algorithm
|
||||
@@ -600,14 +774,34 @@ function validateResultRelationships(value, context = {}) {
|
||||
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" && value.backend === "webgpu") {
|
||||
if (
|
||||
(resultAlgorithm === "gcc-phat" || resultAlgorithm === "music") &&
|
||||
value.backend === "webgpu"
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.backend",
|
||||
"gcc-phat Result must use the CPU backend.",
|
||||
"The selected Result algorithm must use the CPU backend.",
|
||||
);
|
||||
}
|
||||
const expectedRawScale =
|
||||
@@ -615,13 +809,60 @@ function validateResultRelationships(value, context = {}) {
|
||||
? "power"
|
||||
: resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat"
|
||||
? "score"
|
||||
: undefined;
|
||||
: 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) &&
|
||||
@@ -829,6 +1070,19 @@ function validateResultRelationships(value, context = {}) {
|
||||
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)
|
||||
@@ -848,10 +1102,31 @@ function validateResultRelationships(value, context = {}) {
|
||||
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: standaloneObservation.originalCarrierUsed,
|
||||
originalCarrierUsed:
|
||||
resultAlgorithm === "music",
|
||||
aliasOrderResolution:
|
||||
standaloneObservation.aliased ? "not-attempted" : "not-applicable",
|
||||
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,
|
||||
@@ -945,8 +1220,15 @@ function validateResultRelationships(value, context = {}) {
|
||||
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
|
||||
}
|
||||
});
|
||||
if (isRecord(band.rawMap) && !["power", "score"].includes(band.rawMap.scale)) {
|
||||
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power 또는 score scale이어야 합니다.");
|
||||
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 &&
|
||||
@@ -958,6 +1240,188 @@ function validateResultRelationships(value, context = {}) {
|
||||
`${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)) {
|
||||
@@ -974,6 +1438,16 @@ function validateResultRelationships(value, context = {}) {
|
||||
`${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
resultAlgorithm === "music" &&
|
||||
isRecord(peak?.beamSpread)
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.peaks[${peakIndex}].beamSpread`,
|
||||
"MUSIC noise-projection에는 DAS/PHAT half-power beamSpread를 적용할 수 없습니다.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user