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
+174 -15
View File
@@ -1,5 +1,17 @@
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
import { observeFrequencyBand } from "/packages/acoustics-core/sampling.js";
import {
binsForBand,
getCompleteFrameCount,
getFrameCount,
} from "/packages/acoustics-core/stft.js";
import {
DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS,
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS,
} from "/packages/acoustics-core/music.js";
import {
describeFrequencyAliasing,
observeFrequencyBand,
} from "/packages/acoustics-core/sampling.js";
import {
DEFAULT_LOCALIZATION_ALGORITHM,
localizationAlgorithmDefinition,
@@ -35,9 +47,16 @@ export function createComparisonSignature(scene, sourceExperiment) {
if (!scene || !sourceExperiment) return "";
const experiment = structuredClone(sourceExperiment);
experiment.arrayId = "$array";
experiment.sceneId = "$scene";
if (experiment.beamforming) {
experiment.beamforming.algorithm = "$algorithm";
experiment.beamforming.backend = "$backend";
experiment.beamforming.normalization =
"$algorithm-specific-normalization";
delete experiment.beamforming.musicSourceCountMethod;
delete experiment.beamforming.musicSourceCount;
delete experiment.beamforming.phatRelativeFloor;
delete experiment.beamforming.phatFloorRatio;
}
return canonicalStringify({ scene, experiment });
}
@@ -51,19 +70,39 @@ export async function hashSimulationInputs({ arrayDefinition, scene, experiment
}
export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
const frameCount = getFrameCount(
Math.ceil(scene.duration * scene.sampleRate),
experiment.stft.fftSize,
experiment.stft.hopSize,
);
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const algorithm =
experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
const musicSourceCountMethod =
experiment.beamforming?.musicSourceCountMethod ?? "fixed";
const frameCount =
algorithm === "music" && musicSourceCountMethod !== "fixed"
? getCompleteFrameCount(
sampleCount,
experiment.stft.fftSize,
experiment.stft.hopSize,
)
: getFrameCount(
sampleCount,
experiment.stft.fftSize,
experiment.stft.hopSize,
);
const bandBins = experiment.frequencyBands.map((band) =>
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
);
const bandBinSelectionCount = bandBins.reduce((total, bins) => total + bins.length, 0);
const selectedBinCount = new Set(bandBins.flat()).size;
const binWidthHz =
scene.sampleRate / experiment.stft.fftSize;
const musicHypothesisCount = new Set(
experiment.frequencyBands.map((band, bandIndex) => {
const analogBin = Math.ceil(band.minHz / binWidthHz);
const adcBin = bandBins[bandIndex]?.[0];
return `${analogBin}|${adcBin}`;
}),
).size;
const pointCount = experiment.scan.columns * experiment.scan.rows;
const microphoneCount = arrayDefinition.microphones.length;
const algorithm = experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
const algorithmDefinition = localizationAlgorithmDefinition(algorithm);
const referencePairCount = Math.max(0, microphoneCount - 1);
const stftTransformCount = frameCount * microphoneCount;
@@ -72,7 +111,50 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
2 * referencePairCount * frameCount * bandBinSelectionCount;
const gccScanScoreOps = pointCount * referencePairCount * bandBins.length;
const inverseFftTransformCount = referencePairCount * bandBins.length;
const scanOperationCount = algorithm === "gcc-phat" ? gccScanScoreOps : dasComplexMacCount;
const musicSourceCount = experiment.beamforming?.musicSourceCount ?? 1;
if (
algorithm === "music" &&
musicSourceCountMethod === "fixed" &&
(
!Number.isInteger(musicSourceCount) ||
musicSourceCount < 1 ||
musicSourceCount >= microphoneCount
)
) {
throw new RangeError(
`MUSIC source count K must be inside 1..${microphoneCount - 1}.`,
);
}
const musicNoiseSubspaceDimension =
musicSourceCountMethod === "fixed"
? microphoneCount - musicSourceCount
: microphoneCount;
const musicCovarianceComplexAccumulations =
musicHypothesisCount *
frameCount *
microphoneCount *
(microphoneCount + 1) /
2;
const musicJacobiWorstCasePairUpdates =
musicHypothesisCount *
32 *
(microphoneCount * (microphoneCount - 1) / 2) *
(2 * microphoneCount - 2);
const musicProjectionComplexMacs =
musicHypothesisCount *
pointCount *
microphoneCount *
musicNoiseSubspaceDimension;
const musicTotalOperationCount =
musicCovarianceComplexAccumulations +
musicJacobiWorstCasePairUpdates +
musicProjectionComplexMacs;
const scanOperationCount =
algorithm === "gcc-phat"
? gccScanScoreOps
: algorithm === "music"
? musicTotalOperationCount
: dasComplexMacCount;
return {
algorithm,
algorithmLabel: algorithmDefinition?.label ?? algorithm,
@@ -87,12 +169,42 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
stftTransformCount,
referencePairCount,
scanOperationCount,
scanOperationKind: algorithm === "gcc-phat" ? "reference-pair lookup" : "complex MAC",
scanOperationKind:
algorithm === "gcc-phat"
? "reference-pair lookup"
: algorithm === "music"
? "covariance + Jacobi upper bound + projection"
: "complex MAC",
gccCrossSpectrumComplexOps: algorithm === "gcc-phat" ? gccCrossSpectrumComplexOps : 0,
inverseFftTransformCount: algorithm === "gcc-phat" ? inverseFftTransformCount : 0,
complexMacCount: algorithm === "gcc-phat" ? 0 : dasComplexMacCount,
complexMacCount:
algorithm === "gcc-phat" || algorithm === "music"
? 0
: dasComplexMacCount,
musicSourceCountMethod,
musicSourceCount,
musicHypothesisCount,
musicNoiseSubspaceDimension,
musicCovarianceComplexAccumulations:
algorithm === "music" ? musicCovarianceComplexAccumulations : 0,
musicJacobiWorstCasePairUpdates:
algorithm === "music" ? musicJacobiWorstCasePairUpdates : 0,
musicProjectionComplexMacs:
algorithm === "music" ? musicProjectionComplexMacs : 0,
musicJacobiPairUpdateLimit:
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
musicCovarianceAccumulationLimit:
DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS,
musicProjectionMacLimit:
DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS,
musicJacobiLimitExceeded:
algorithm === "music" &&
musicJacobiWorstCasePairUpdates >
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
scalingVs50: pointCount / 2500,
distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2,
distanceMiB:
(pointCount * microphoneCount * (algorithm === "music" ? 8 : 4)) /
1024 ** 2,
};
}
@@ -245,6 +357,9 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture;
if (adapter?.device) environment.gpuDevice = adapter.device;
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
if (Number.isInteger(run.frameCount) && run.frameCount >= 0) {
environment.stftFrameCount = run.frameCount;
}
const referenceMic =
run.referenceMic ??
run.referenceMicrophoneId ??
@@ -291,6 +406,18 @@ function frequencyObservationForResult(scene, experiment, band, algorithm) {
scene.sampleRate,
experiment.stft.fftSize,
);
const music = algorithm === "music";
const analogBin = Math.ceil(
band.minHz / observation.binWidthHz,
);
const configuredAnalogFrequencyHz =
analogBin * observation.binWidthHz;
const configuredAlias = music
? describeFrequencyAliasing(
configuredAnalogFrequencyHz,
scene.sampleRate,
)
: null;
return {
model: observation.model,
sampleRate: observation.sampleRate,
@@ -306,9 +433,28 @@ function frequencyObservationForResult(scene, experiment, band, algorithm) {
localizationModel:
algorithm === "gcc-phat"
? "band-limited-gcc-phat-geometric-lag"
: music
? "near-field-music-at-configured-analog-frequency"
: "spherical-steering-at-positive-adc-bin-frequency",
originalCarrierUsed: observation.originalCarrierUsed,
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
originalCarrierUsed: music,
aliasOrderResolution:
music && observation.aliased
? "configured-single-hypothesis"
: observation.aliased
? "not-attempted"
: "not-applicable",
...(music
? {
configuredAnalogFrequencyHz,
signedAdcFrequencyHz:
configuredAlias.signedAdcFrequencyHz,
spectralInversion:
configuredAlias.spectralInversion,
frequencyHypothesisCount: 1,
frequencyHypothesisSource:
"experiment-band-not-scene-truth",
}
: {}),
foldBoundaryCount: observation.foldBoundaryCount,
continuousManyToOne: observation.continuousManyToOne,
gridManyToOne: observation.gridManyToOne,
@@ -387,21 +533,31 @@ export function createResultDocument(options) {
beamforming: run.timingMs.beamforming ?? run.timingMs.localization,
},
environment: environmentForResult({ arrayDefinition, experiment, run }),
...(algorithm === "music" && run.model
? { musicModel: sortForCanonicalJson(run.model) }
: {}),
bands: run.bands.map((band) => {
const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} };
const bandRawScale = band.rawScale ?? rawScale;
const objectiveDirection =
band.objectiveDirection ??
(bandRawScale === "noise-projection" ? "minimum" : "maximum");
const bandResult = {
id: band.id,
color: band.color,
rawMap: {
rows: experiment.scan.rows,
columns: experiment.scan.columns,
scale: band.rawScale ?? rawScale,
scale: bandRawScale,
objectiveDirection,
values: Array.from(band.rawMap),
},
displayMap: {
rows: experiment.scan.rows,
columns: experiment.scan.columns,
scale: band.displayScale,
objectiveDirection:
band.displayObjectiveDirection ?? objectiveDirection,
values: Array.from(band.displayMap),
},
peaks: evaluation.peaks.map(peakForResult),
@@ -409,6 +565,9 @@ export function createResultDocument(options) {
};
const frequencyObservation = frequencyObservations.get(band.id);
if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation;
if (algorithm === "music" && band.diagnostics) {
bandResult.musicDiagnostics = sortForCanonicalJson(band.diagnostics);
}
if (evaluation.evaluation) {
bandResult.evaluation = {
...evaluation.evaluation,