import { computeLocalizationMap, 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, } from "/packages/acoustics-core/gcc-phat.js"; import { DEFAULT_LOCALIZATION_ALGORITHM, LOCALIZATION_ALGORITHM_IDS, localizationAlgorithmDefinition, localizationAlgorithmSupportsBackend, } from "/packages/acoustics-core/localization-algorithms.js"; import { StreamingDasLocalizer, StreamingLocalizer, createLocalizationPlan, detectBandPeaks, localizeMultichannel, } from "/packages/acoustics-core/realtime-localizer.js"; import { computeDasWithBackend, computeLocalizationWithBackend, } from "/packages/webgpu-kernels/compute.js"; function createFixture(algorithm = "das") { const sampleRate = 4096; const arrayDefinition = { id: "algorithm-fixture", microphones: [ { id: "nw", position: [-0.1, 0.1, 0] }, { id: "ne", position: [0.1, 0.1, 0] }, { id: "sw", position: [-0.1, -0.1, 0] }, { id: "se", position: [0.1, -0.1, 0] }, ], }; let state = 0x9e3779b9; const emitted = Float32Array.from({ length: 128 }, () => { state ^= state << 13; state ^= state >>> 17; state ^= state << 5; return ((state >>> 0) / 0xffff_ffff) * 2 - 1; }); const channels = arrayDefinition.microphones.map((_, microphoneIndex) => Float32Array.from( { length: emitted.length }, (_, sampleIndex) => emitted[Math.max(0, sampleIndex - microphoneIndex)], ) ); const scan = { z: 1, xMin: -0.2, xMax: 0.2, yMin: -0.2, yMax: 0.2, columns: 3, rows: 3, }; 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, frequencyBands, beamforming: { algorithm, backend: "cpu", normalization: algorithm === "music" ? "none" : "peak", compensateFrequencyResponse: true, ...(algorithm === "music" ? { musicSourceCountMethod: "fixed", musicSourceCount: 1 } : {}), }, }; return { channels, sampleRate, arrayDefinition, scene: { speedOfSound: 343 }, experiment, }; } 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,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 () => { for (const algorithm of LOCALIZATION_ALGORITHM_IDS) { const fixture = createFixture(algorithm); const analysisPlan = createLocalizationAnalysisPlan(fixture); const workload = estimateLocalizationWorkload({ ...fixture, analysisPlan }); const result = await computeLocalizationMap({ ...fixture, analysisPlan, pointBatchSize: 1024, stftTransformBatchSize: 1024, }); const definition = localizationAlgorithmDefinition(algorithm); assertEqual(result.algorithm, algorithm, `${algorithm} result id`); assertEqual(result.backend, "cpu", `${algorithm} backend`); assertEqual(result.bands[0].rawScale, definition.rawScale, `${algorithm} raw scale`); assertEqual(result.bands[0].rawMap.length, 9, `${algorithm} map shape`); assertEqual(result.bands[0].peak.rawScale, definition.rawScale, `${algorithm} peak scale`); assertEqual( "rawPower" in result.bands[0].peak, definition.rawScale === "power", `${algorithm} peak power field`, ); assert( result.bands[0].rawMap.every((value) => Number.isFinite(value) && value >= 0), `${algorithm} finite nonnegative map`, ); assert(workload.pointCount === 9 && workload.microphoneCount === 4, `${algorithm} workload`); } }); test("backend dispatcher keeps GCC-PHAT on CPU and rejects an explicit GPU request", async () => { const fixture = createFixture("gcc-phat"); const calls = []; const result = await computeLocalizationWithBackend(fixture, { computeGpu: async () => { calls.push("gpu"); throw new Error("GCC-PHAT must not call WebGPU"); }, computeGccCpu: async () => { calls.push("gcc-cpu"); return { backend: "cpu", algorithm: "gcc-phat", bands: [] }; }, }); assertEqual(calls.join(","), "gcc-cpu", "GCC auto routing"); assertEqual(result.algorithm, "gcc-phat", "GCC result"); fixture.experiment.beamforming.backend = "webgpu"; let backendError; try { await computeLocalizationWithBackend(fixture); } catch (error) { backendError = error; } assert(backendError instanceof TypeError, "explicit GCC WebGPU is rejected"); let legacyError; try { await computeDasWithBackend(fixture); } catch (error) { legacyError = error; } 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 = { sampleRate: fixture.sampleRate, array: fixture.arrayDefinition, scan: fixture.experiment.scan, bands: fixture.experiment.frequencyBands, speedOfSound: fixture.scene.speedOfSound, stft: fixture.experiment.stft, algorithm: "gcc-phat", phatFloorRatio: 1e-5, normalization: "peak", computeOptions: { pointBatchSize: 1024, stftTransformBatchSize: 1024 }, }; const plan = createLocalizationPlan(config); const defaultBatchPlan = createLocalizationPlan({ ...config, computeOptions: undefined }); const localized = await localizeMultichannel({ plan, channels: fixture.channels }); assertEqual(plan.algorithm, "gcc-phat", "portable plan algorithm"); assertEqual(plan.computeOptions.phatFloorRatio, 1e-5, "portable PHAT floor forwarding"); assertEqual(plan.analysisPlan, plan.gccPhatPlan, "portable GCC plan alias"); assertEqual(plan.computeOptions.pointBatchSize, 1024, "explicit portable point batch override"); assertEqual( defaultBatchPlan.computeOptions.pointBatchSize, DEFAULT_GCC_PHAT_POINT_BATCH_SIZE, "GCC lookup-oriented default batch", ); assertEqual( defaultBatchPlan.computeOptions.crossSpectrumBatchSize, DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE, "GCC cross-spectrum cancellation batch", ); assertEqual(localized.algorithm, "gcc-phat", "portable result algorithm"); assertEqual(localized.phatFloorRatio, 1e-5, "portable PHAT floor reaches GCC core"); assertEqual(localized.bands[0].rawScale, "score", "portable score map"); assertEqual(StreamingLocalizer, StreamingDasLocalizer, "streaming compatibility alias"); const stream = new StreamingLocalizer({ ...config, analysisWindowSamples: 128, analysisHopSamples: 128, maximumBufferedSamples: 256, }); const streamed = await stream.push(fixture.channels); assertEqual(streamed.length, 1, "one GCC streaming result"); assertEqual(streamed[0].algorithm, "gcc-phat", "streaming result algorithm"); assertEqual(stream.getState().algorithm, "gcc-phat", "streaming state algorithm"); assertEqual(stream.plan.gccPhatPlan.busy, false, "streaming GCC plan lock released"); const scorePeaks = detectBandPeaks( Float32Array.of(0, 2, 0, 1), { z: 1, xMin: 0, xMax: 1, yMin: 0, yMax: 1, columns: 2, rows: 2 }, { rawScale: "score", minimumPeakDistanceM: 0 }, ); assertEqual(scorePeaks[0].rawScale, "score", "portable peak scale"); assertEqual(scorePeaks[0].rawScore, scorePeaks[0].rawValue, "portable score alias"); assert(!("rawPower" in scorePeaks[0]), "score is not mislabeled as acoustic power"); });