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
+364 -5
View File
@@ -3,6 +3,10 @@ import {
createLocalizationAnalysisPlan,
estimateLocalizationWorkload,
} from "/packages/acoustics-core/localization.js";
import {
createMusicBinCalibration,
isMusicUnavailableError,
} from "/packages/acoustics-core/music.js";
import {
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
@@ -58,9 +62,9 @@ function createFixture(algorithm = "das") {
columns: 3,
rows: 3,
};
const frequencyBands = [
{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" },
];
const frequencyBands = algorithm === "music"
? [{ id: "single-bin", minHz: 500, maxHz: 530, color: "#65f2bf" }]
: [{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" }];
const experiment = {
stft: { fftSize: 64, hopSize: 32, window: "hann" },
scan,
@@ -68,8 +72,11 @@ function createFixture(algorithm = "das") {
beamforming: {
algorithm,
backend: "cpu",
normalization: "peak",
normalization: algorithm === "music" ? "none" : "peak",
compensateFrequencyResponse: true,
...(algorithm === "music"
? { musicSourceCountMethod: "fixed", musicSourceCount: 1 }
: {}),
},
};
return {
@@ -83,12 +90,22 @@ function createFixture(algorithm = "das") {
test("localization algorithm registry exposes explicit scale and backend semantics", () => {
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
assertEqual(LOCALIZATION_ALGORITHM_IDS.join(","), "das,das-phat,gcc-phat", "registered ids");
assertEqual(
LOCALIZATION_ALGORITHM_IDS.join(","),
"das,das-phat,gcc-phat,music",
"registered ids",
);
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS power scale");
assertEqual(localizationAlgorithmDefinition("das-phat").rawScale, "score", "DAS-PHAT score scale");
assertEqual(localizationAlgorithmDefinition("gcc-phat").rawScale, "score", "GCC-PHAT score scale");
assertEqual(
localizationAlgorithmDefinition("music").rawScale,
"noise-projection",
"MUSIC raw noise-projection scale",
);
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU support");
assert(!localizationAlgorithmSupportsBackend("gcc-phat", "webgpu"), "GCC-PHAT CPU-only support");
assert(!localizationAlgorithmSupportsBackend("music", "webgpu"), "MUSIC CPU-only support");
});
test("generic CPU dispatcher runs all algorithms with explicit raw-map scales", async () => {
@@ -156,6 +173,348 @@ test("backend dispatcher keeps GCC-PHAT on CPU and rejects an explicit GPU reque
assert(legacyError instanceof RangeError, "legacy DAS entry rejects GCC");
});
test("backend dispatcher keeps MUSIC on CPU and rejects an explicit GPU request", async () => {
const fixture = createFixture("music");
fixture.experiment.beamforming.backend = "auto";
const calls = [];
const result = await computeLocalizationWithBackend(fixture, {
computeGpu: async () => {
calls.push("gpu");
throw new Error("MUSIC must not call WebGPU");
},
computeMusicCpu: async () => {
calls.push("music-cpu");
return { backend: "cpu", algorithm: "music", bands: [] };
},
});
assertEqual(calls.join(","), "music-cpu", "MUSIC auto routing");
assertEqual(result.algorithm, "music", "MUSIC result");
fixture.experiment.beamforming.backend = "webgpu";
let backendError;
try {
await computeLocalizationWithBackend(fixture);
} catch (error) {
backendError = error;
}
assert(backendError instanceof TypeError, "explicit MUSIC WebGPU is rejected");
});
test("fixed-K MUSIC reports silent rank loss instead of choosing a grid cell", async () => {
const fixture = createFixture("music");
fixture.channels = fixture.channels.map(
(channel) => new Float32Array(channel.length),
);
let unavailable;
try {
await computeLocalizationMap(fixture);
} catch (error) {
unavailable = error;
}
assert(
isMusicUnavailableError(unavailable),
"silence is a typed MUSIC unavailable condition",
);
assertEqual(
unavailable.reason,
"signal-noise-partition-unidentifiable",
"silence has an explicit identifiability reason",
);
});
test("MUSIC uses an explicit above-Nyquist carrier hypothesis without Scene truth", async () => {
const baseband = createFixture("music");
const aliased = createFixture("music");
aliased.channels = baseband.channels;
aliased.experiment.frequencyBands = [
{ id: "aliased-single-bin", minHz: 4590, maxHz: 4620, color: "#65f2bf" },
];
const basebandResult = await computeLocalizationMap(baseband);
const aliasedResult = await computeLocalizationMap(aliased);
assertEqual(aliasedResult.bands[0].bins.join(","), "8", "aliased hypothesis maps to bin 8");
assertEqual(
aliasedResult.bands[0].observedFrequencyHz,
512,
"covariance uses the observed ADC bin center",
);
assertEqual(
aliasedResult.bands[0].analogFrequencyHz,
4608,
"band config supplies the analog FFT-grid carrier",
);
assertEqual(
aliasedResult.bands[0].signedAdcFrequencyHz,
512,
"signed ADC frequency is retained",
);
assertEqual(
aliasedResult.model.originalCarrierUsed,
true,
"configured analog carrier is used",
);
assertEqual(
aliasedResult.model.hypothesisSource,
"experiment-band-not-scene-truth",
"Scene truth is never used to choose the carrier",
);
assertEqual(
aliasedResult.model.multibandFusion,
"none-each-band-is-separate-single-analog-hypothesis",
"no cross-band fusion is invented",
);
assert(
JSON.stringify(Array.from(aliasedResult.bands[0].rawMap)) !==
JSON.stringify(Array.from(basebandResult.bands[0].rawMap)),
"same ADC data is tested against a distinct configured spatial wavelength",
);
});
test("near-field MUSIC applies the configured analog carrier and inversion without hiding grating lobes", async () => {
const sampleRate = 8192;
const fftSize = 128;
const speedOfSound = 343;
const basebandFrequency = 1024;
const invertedAnalogFrequency = sampleRate - basebandFrequency;
const source = [0.1, -0.1, 1];
const arrayDefinition = {
id: "physical-music-array",
microphones: [
{ id: "nw", position: [-0.12, 0.12, 0] },
{ id: "ne", position: [0.12, 0.12, 0] },
{ id: "sw", position: [-0.12, -0.12, 0] },
{ id: "se", position: [0.12, -0.12, 0] },
],
};
const channelsForAnalogFrequency = (frequency) =>
arrayDefinition.microphones.map((microphone) => {
const distance = Math.hypot(
source[0] - microphone.position[0],
source[1] - microphone.position[1],
source[2] - microphone.position[2],
);
return Float64Array.from(
{ length: 384 },
(_, sampleIndex) =>
Math.cos(
2 * Math.PI * frequency *
(sampleIndex / sampleRate - distance / speedOfSound),
) / distance,
);
});
const experimentForBand = (band) => ({
stft: { fftSize, hopSize: 64, window: "hann" },
scan: {
z: 1,
xMin: -0.2,
xMax: 0.2,
yMin: -0.2,
yMax: 0.2,
columns: 5,
rows: 5,
},
frequencyBands: [{ id: "tone", color: "#65f2bf", ...band }],
beamforming: {
algorithm: "music",
backend: "cpu",
minimumDistance: 0.01,
normalization: "none",
compensateFrequencyResponse: true,
musicSourceCountMethod: "fixed",
musicSourceCount: 1,
},
});
const common = {
sampleRate,
arrayDefinition,
scene: { speedOfSound },
};
const baseband = await computeLocalizationMap({
...common,
channels: channelsForAnalogFrequency(basebandFrequency),
experiment: experimentForBand({ minHz: 1000, maxHz: 1050 }),
});
const truthIndex = 18;
assertEqual(baseband.bands[0].peak.index, truthIndex, "baseband physical truth minimizes q");
const truthQ = baseband.bands[0].rawMap[truthIndex];
assert(
baseband.bands[0].rawMap.every((value) => value >= truthQ),
"truth q is the global minimum",
);
const aliased = await computeLocalizationMap({
...common,
channels: channelsForAnalogFrequency(invertedAnalogFrequency),
experiment: experimentForBand({ minHz: 7140, maxHz: 7190 }),
});
assertEqual(aliased.bands[0].bins.join(","), "16", "analog carrier folds to observed bin 16");
assertEqual(
aliased.bands[0].observedFrequencyHz,
basebandFrequency,
"covariance still uses the observed ADC frequency",
);
assertEqual(
aliased.bands[0].analogFrequencyHz,
invertedAnalogFrequency,
"manifold uses the configured analog carrier",
);
assertEqual(
aliased.bands[0].signedAdcFrequencyHz,
-basebandFrequency,
"inverted signed ADC frequency is explicit",
);
assert(aliased.bands[0].spectralInversion, "full received manifold is conjugated");
const aliasedTruthQ = aliased.bands[0].rawMap[truthIndex];
const aliasedMinimumQ = Math.min(...aliased.bands[0].rawMap);
assert(
Math.abs(aliasedTruthQ - aliasedMinimumQ) <= 1e-10,
"physical truth is a q minimum while grating-lobe ties remain allowed",
);
assertEqual(aliased.model.originalCarrierUsed, true, "configured carrier is used");
assertEqual(
aliased.model.aliasOrderResolution,
"configured-single-hypothesis-no-selection",
"no data-driven alias-order choice is claimed",
);
});
test("MUSIC conjugates analog microphone phase and preserves capped residual gain in inverted zones", () => {
const arrayDefinition = {
microphones: [{
id: "calibrated",
position: [0, 0, 0],
phaseOffsetDeg: 20,
frequencyResponse: [
{ frequencyHz: 3_000, gainDb: 0, phaseDeg: 0 },
{ frequencyHz: 5_000, gainDb: -30, phaseDeg: 40 },
],
}],
};
const corrected = createMusicBinCalibration({
arrayDefinition,
bin: 48,
sampleRate: 8_000,
fftSize: 128,
analogFrequencyHz: 5_000,
signedAdcFrequencyHz: -3_000,
spectralInversion: true,
compensateFrequencyResponse: true,
maxCompensationDb: 24,
});
assert(
Math.abs(corrected.dataPhases[0] - Math.PI / 3) <= 1e-12,
"inverted positive-bin calibration conjugates 20° + 40°",
);
assert(
Math.abs(
20 * Math.log10(corrected.manifoldGains[0]) + 6
) <= 1e-9,
"-30 dB response with 24 dB inverse cap leaves -6 dB",
);
assertEqual(corrected.manifoldPhases[0], 0, "compensated phase has no residual");
const raw = createMusicBinCalibration({
arrayDefinition,
bin: 48,
sampleRate: 8_000,
fftSize: 128,
analogFrequencyHz: 5_000,
signedAdcFrequencyHz: -3_000,
spectralInversion: true,
compensateFrequencyResponse: false,
});
assert(
Math.abs(raw.dataPhases[0] - 20 * Math.PI / 180) <= 1e-12,
"fixed phase offset correction follows inversion sign",
);
assert(
Math.abs(raw.manifoldPhases[0] + 40 * Math.PI / 180) <= 1e-12,
"uncompensated analog response phase remains conjugated in the manifold",
);
});
test("portable MUSIC plan retains its physical and source-count configuration", () => {
const fixture = createFixture("music");
const plan = createLocalizationPlan({
sampleRate: fixture.sampleRate,
array: fixture.arrayDefinition,
scan: fixture.experiment.scan,
bands: fixture.experiment.frequencyBands,
speedOfSound: fixture.scene.speedOfSound,
stft: fixture.experiment.stft,
algorithm: "music",
minimumDistance: 0.025,
musicSourceCountMethod: "fixed",
musicSourceCount: 1,
});
assertEqual(plan.normalization, "none", "portable MUSIC defaults to no map normalization");
assertEqual(plan.minimumDistance, 0.025, "minimum distance is retained");
assertEqual(plan.experiment.beamforming.musicSourceCount, 1, "fixed K reaches core experiment");
assertEqual(plan.musicPlan, plan.analysisPlan, "portable MUSIC plan has a dedicated alias");
assert(!("gccPhatPlan" in plan), "MUSIC is not mislabeled as a GCC plan");
assert(!("crossSpectrumBatchSize" in plan.computeOptions), "GCC-only batching is omitted");
});
test("MUSIC rejects peak normalization at both direct and portable API boundaries", () => {
const direct = createFixture("music");
direct.experiment.beamforming.normalization = "peak";
let directError;
try {
createLocalizationAnalysisPlan(direct);
} catch (error) {
directError = error;
}
assert(
directError instanceof RangeError &&
directError.message.includes("normalization"),
"direct core rejects peak normalization",
);
let portableError;
try {
createLocalizationPlan({
sampleRate: direct.sampleRate,
array: direct.arrayDefinition,
scan: direct.experiment.scan,
bands: direct.experiment.frequencyBands,
speedOfSound: direct.scene.speedOfSound,
stft: direct.experiment.stft,
algorithm: "music",
normalization: "peak",
musicSourceCount: 1,
});
} catch (error) {
portableError = error;
}
assert(
portableError instanceof RangeError &&
portableError.message.includes("normalization"),
"portable API rejects peak normalization",
);
});
test("MUSIC MDL/AIC workload excludes a zero-padded partial snapshot", () => {
const fixture = createFixture("music");
const completeSampleCount = 6 * fixture.experiment.stft.fftSize;
const partialSampleCount = completeSampleCount + 10;
fixture.channels = fixture.channels.map((channel) =>
Float64Array.from(
{ length: partialSampleCount },
(_, index) => channel[index % channel.length],
));
fixture.experiment.stft.hopSize = fixture.experiment.stft.fftSize;
fixture.experiment.beamforming.musicSourceCountMethod = "mdl";
delete fixture.experiment.beamforming.musicSourceCount;
const workload = estimateLocalizationWorkload(fixture);
assertEqual(workload.frameCount, 6, "only six complete non-overlapping frames");
assertEqual(
workload.sampleCount,
partialSampleCount,
"input length remains disclosed without treating its tail as a snapshot",
);
});
test("portable and streaming API accept an algorithm choice without Scene synthesis", async () => {
const fixture = createFixture("gcc-phat");
const config = {