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
+122 -27
View File
@@ -1,4 +1,7 @@
import { findLocalPeaks } from "./evaluation.js";
import {
findLocalPeaks,
normalizedMusicPseudospectrum,
} from "./evaluation.js";
import {
computeLocalizationMap,
createLocalizationAnalysisPlan,
@@ -17,6 +20,8 @@ import {
MAX_FREQUENCY_RESPONSE_POINTS,
requireMaxCompensationDb,
} from "./frequency-response.js";
import { isMusicUnavailableError } from "./music.js";
import { getCompleteFrameCount } from "./stft.js";
export const REALTIME_LOCALIZATION_PLAN_KIND = "micarrsim.realtime-localization-plan/1";
export const DEFAULT_STREAM_RESULTS_PER_CALL = 1;
@@ -224,11 +229,36 @@ function throwIfAborted(signal) {
export function detectBandPeaks(rawMap, scan, options = {}) {
const rawScale = options.rawScale ?? rawMap?.scale ?? "power";
if (rawScale !== "power" && rawScale !== "score") {
throw new RangeError('peak map rawScale must be "power" or "score".');
if (rawScale !== "power" && rawScale !== "score" && rawScale !== "noise-projection") {
throw new RangeError(
'peak map rawScale must be "power", "score", or "noise-projection".',
);
}
const objectiveDirection =
options.objectiveDirection ??
rawMap?.objectiveDirection ??
(rawScale === "noise-projection" ? "minimum" : "maximum");
if (
(rawScale === "noise-projection" && objectiveDirection !== "minimum") ||
(rawScale !== "noise-projection" && objectiveDirection !== "maximum")
) {
throw new RangeError(
"noise-projection uses a minimum objective; power and score use a maximum objective.",
);
}
const rawValues =
rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap)
? rawMap.values
: rawMap;
let detectionMap = rawMap;
if (objectiveDirection === "minimum") {
const pseudospectrum =
normalizedMusicPseudospectrum(rawValues);
if (pseudospectrum.flat) return [];
detectionMap = pseudospectrum.values;
}
const peakDetection = copyPeakDetection(options, scan);
const localPeaks = findLocalPeaks(rawMap, scan);
const localPeaks = findLocalPeaks(detectionMap, scan);
const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
const detections = [];
for (const peak of localPeaks) {
@@ -248,11 +278,15 @@ export function detectBandPeaks(rawMap, scan, options = {}) {
rank: detections.length + 1,
index: peak.index,
position: [...peak.position],
rawValue: peak.value,
rawValue: rawValues[peak.index],
rawScale,
objectiveDirection,
detectionMerit: peak.value,
...(rawScale === "power"
? { rawPower: peak.value }
: { rawScore: peak.value }),
? { rawPower: rawValues[peak.index] }
: rawScale === "score"
? { rawScore: rawValues[peak.index] }
: { noiseProjection: rawValues[peak.index] }),
});
if (detections.length === peakDetection.maximumDetections) break;
}
@@ -291,10 +325,21 @@ export function createLocalizationPlan(config) {
);
if (hopSize > fftSize) throw new RangeError("stftHopSize must not exceed fftSize.");
const speedOfSound = requirePositiveFinite(config.speedOfSound ?? 343, "speedOfSound");
const normalization = config.normalization ?? config.beamforming?.normalization ?? "peak";
const algorithm = requireLocalizationAlgorithmDefinition(
config.algorithm ?? config.beamforming?.algorithm,
);
const normalization =
config.normalization ??
config.beamforming?.normalization ??
(algorithm.id === "music" ? "none" : "peak");
if (normalization !== "peak" && normalization !== "none") {
throw new RangeError('normalization must be "peak" or "none".');
}
if (algorithm.id === "music" && normalization !== "none") {
throw new RangeError(
'MUSIC normalization must be "none"; raw noise projection q is not peak-normalized.',
);
}
const compensateFrequencyResponse =
config.compensateFrequencyResponse ??
config.beamforming?.compensateFrequencyResponse ??
@@ -307,9 +352,20 @@ export function createLocalizationPlan(config) {
config.beamforming?.maxCompensationDb ??
DEFAULT_MAX_COMPENSATION_DB,
);
const algorithm = requireLocalizationAlgorithmDefinition(
config.algorithm ?? config.beamforming?.algorithm,
const minimumDistance = requirePositiveFinite(
config.minimumDistance ??
config.beamforming?.minimumDistance ??
0.01,
"minimumDistance",
);
const musicSourceCountMethod =
config.musicSourceCountMethod ??
config.beamforming?.musicSourceCountMethod ??
"fixed";
const musicSourceCount =
config.musicSourceCount ??
config.beamforming?.musicSourceCount ??
1;
const phatRelativeFloor =
config.phatRelativeFloor ?? config.beamforming?.phatRelativeFloor;
const phatFloorRatio =
@@ -326,6 +382,10 @@ export function createLocalizationPlan(config) {
normalization,
compensateFrequencyResponse,
maxCompensationDb,
minimumDistance,
...(algorithm.id === "music"
? { musicSourceCountMethod, musicSourceCount }
: {}),
}),
});
const analysisPlan = createLocalizationAnalysisPlan({
@@ -350,18 +410,22 @@ export function createLocalizationPlan(config) {
speedOfSound,
normalization,
maxCompensationDb,
minimumDistance,
scene,
experiment,
analysisPlan,
...(isDasAlgorithm(algorithm.id)
? { dasPlan: analysisPlan }
: { gccPhatPlan: analysisPlan }),
: algorithm.id === "gcc-phat"
? { gccPhatPlan: analysisPlan }
: { musicPlan: analysisPlan }),
computeOptions: Object.freeze({
pointBatchSize: isDasAlgorithm(algorithm.id)
? DEFAULT_REALTIME_POINT_BATCH_SIZE
: DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
pointBatchSize:
algorithm.id === "gcc-phat"
? DEFAULT_GCC_PHAT_POINT_BATCH_SIZE
: DEFAULT_REALTIME_POINT_BATCH_SIZE,
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
...(!isDasAlgorithm(algorithm.id)
...(algorithm.id === "gcc-phat"
? { crossSpectrumBatchSize: DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE }
: {}),
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
@@ -390,18 +454,49 @@ export async function localizeMultichannel(options) {
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
throwIfAborted(options.signal);
const processingStartedAt = monotonicNow();
const localization = await computeLocalizationMap({
...plan.computeOptions,
...(options.computeOptions ?? {}),
channels: options.channels,
sampleRate: plan.sampleRate,
arrayDefinition: plan.arrayDefinition,
scene: plan.scene,
experiment: plan.experiment,
analysisPlan: plan.analysisPlan,
signal: options.signal,
onProgress: options.onProgress,
});
let localization;
try {
localization = await computeLocalizationMap({
...plan.computeOptions,
...(options.computeOptions ?? {}),
channels: options.channels,
sampleRate: plan.sampleRate,
arrayDefinition: plan.arrayDefinition,
scene: plan.scene,
experiment: plan.experiment,
analysisPlan: plan.analysisPlan,
signal: options.signal,
onProgress: options.onProgress,
});
} catch (error) {
if (
plan.algorithm !== "music" ||
!isMusicUnavailableError(error)
) {
throw error;
}
localization = {
algorithm: "music",
backend: "cpu",
status: "unavailable",
unavailable: {
code: error.code,
reason: error.reason,
message: error.message,
details: { ...error.details },
},
frameCount: getCompleteFrameCount(
sampleCount,
plan.stft.fftSize,
plan.stft.hopSize,
),
microphoneCount: plan.channelCount,
pointCount: plan.scan.rows * plan.scan.columns,
objectiveDirection: "minimum",
rawScale: "noise-projection",
bands: [],
};
}
const locations = locationsFromBands(
localization.bands,
plan.scan,