feat: add physically explicit narrowband MUSIC

This commit is contained in:
2026-08-29 12:40:48 +09:00
parent 044be3cf92
commit 11fee76280
40 changed files with 6473 additions and 275 deletions
+495 -21
View File
@@ -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를 적용할 수 없습니다.",
);
}
});
}
});