From e8dac792d96f31acbf9175b2a6ec988e6b409ab4 Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Fri, 28 Aug 2026 20:57:13 +0900 Subject: [PATCH] fix: stabilize localization comparison semantics --- packages/acoustics-core/das.js | 15 +- packages/acoustics-core/gcc-phat.js | 137 ++++++++++++++++-- packages/acoustics-core/localization.js | 18 +-- packages/acoustics-core/realtime-localizer.js | 11 +- packages/contracts/validation.js | 34 +++++ packages/webgpu-kernels/compute.js | 3 + tests/integration/webgpu.test.js | 32 ++++ tests/unit/contracts.test.js | 47 +++++- tests/unit/gcc-phat.test.js | 58 +++++++- tests/unit/localization.test.js | 16 ++ 10 files changed, 340 insertions(+), 31 deletions(-) diff --git a/packages/acoustics-core/das.js b/packages/acoustics-core/das.js index 7847f93..8b78cf6 100644 --- a/packages/acoustics-core/das.js +++ b/packages/acoustics-core/das.js @@ -601,14 +601,23 @@ export function estimateDasWorkload(options) { }; } -export function finalizeDasBands(rawMaps, workload, experiment, rawScale = "power") { +export function finalizeDasBands( + rawMaps, + workload, + experiment, + rawScale = "power", + options = {}, +) { if (rawScale !== "power" && rawScale !== "score") { throw new RangeError('rawScale must be "power" or "score".'); } + const averageBandBins = options.averageBandBins !== false; return experiment.frequencyBands.map((band, bandIndex) => { const rawMap = rawMaps[bandIndex]; - for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { - rawMap[pointIndex] /= workload.bandBins[bandIndex].length; + if (averageBandBins) { + for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { + rawMap[pointIndex] /= workload.bandBins[bandIndex].length; + } } const rawPeak = findPeak(rawMap); const displayMap = Float32Array.from(rawMap); diff --git a/packages/acoustics-core/gcc-phat.js b/packages/acoustics-core/gcc-phat.js index 88b2996..6942251 100644 --- a/packages/acoustics-core/gcc-phat.js +++ b/packages/acoustics-core/gcc-phat.js @@ -14,7 +14,10 @@ import { export const GCC_PHAT_ANALYSIS_PLAN_KIND = "micarrsim.gcc-phat-analysis-plan/1"; export const DEFAULT_GCC_PHAT_FLOOR_RATIO = 1e-6; +export const DEFAULT_GCC_PHAT_POINT_BATCH_SIZE = 4096; +export const DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE = 16384; export const DEFAULT_MAXIMUM_GCC_PHAT_PLAN_BYTES = 512 * 1024 * 1024; +export const DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES = 512 * 1024 * 1024; function createAbortError(signal) { if (signal?.reason instanceof Error && signal.reason.name === "AbortError") { @@ -241,6 +244,60 @@ function requirePhatFloorRatio(value) { return ratio; } +function requireMaximumWorkingBytes(value) { + const maximumWorkingBytes = value ?? DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES; + if (!Number.isSafeInteger(maximumWorkingBytes) || maximumWorkingBytes <= 0) { + throw new RangeError("maximumWorkingBytes must be a positive safe integer."); + } + return maximumWorkingBytes; +} + +/** + * Reject an oversized no-plan analysis before createDistanceMatrix allocates + * its P x M table. This mirrors estimateWithGeometry's retained/spectrum/ + * calibration/output accounting without requiring geometry to exist first. + */ +function preflightUnplannedWorkingSet(options, configuration) { + const microphoneCount = configuration.arrayDefinition.microphones.length; + const sampleCount = validateChannels(options.channels, microphoneCount); + const { bandBins, selectedBins } = createBandGeometry(configuration); + const frameCount = getFrameCount( + sampleCount, + configuration.fftSize, + configuration.hopSize, + ); + const bandCount = bandBins.length; + const distanceBytes = + configuration.pointCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT; + const stftWorkspaceBytes = + configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 3; + const correlationWorkspaceBytes = + configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 2; + const spectrumBytes = + selectedBins.length * frameCount * microphoneCount * + Float32Array.BYTES_PER_ELEMENT * 2; + const calibrationPhaseBytes = + selectedBins.length * microphoneCount * Float64Array.BYTES_PER_ELEMENT; + const outputBytes = + bandCount * configuration.pointCount * Float32Array.BYTES_PER_ELEMENT * 2; + const estimatedWorkingBytes = + distanceBytes + stftWorkspaceBytes + correlationWorkspaceBytes + + spectrumBytes + calibrationPhaseBytes + outputBytes; + if (!Number.isSafeInteger(estimatedWorkingBytes)) { + throw new RangeError("GCC-PHAT estimated working set exceeds the safe integer range."); + } + const maximumWorkingBytes = requireMaximumWorkingBytes(options.maximumWorkingBytes); + if (estimatedWorkingBytes > maximumWorkingBytes) { + throw new RangeError( + "GCC-PHAT estimated working set " + + (estimatedWorkingBytes / 1024 ** 2).toFixed(1) + + " MiB exceeds maximumWorkingBytes " + + (maximumWorkingBytes / 1024 ** 2).toFixed(1) + " MiB before geometry allocation.", + ); + } + return { estimatedWorkingBytes, maximumWorkingBytes }; +} + function estimateWithGeometry(options, configuration, geometry) { const sampleCount = validateChannels(options.channels, geometry.microphoneCount); const frameCount = getFrameCount( @@ -332,6 +389,7 @@ export function estimateGccPhatWorkload(options) { assertCompatibleAnalysisPlan(options.analysisPlan, options); geometry = options.analysisPlan; } else { + preflightUnplannedWorkingSet(options, configuration); geometry = createGeometry(configuration); } return estimateWithGeometry(options, configuration, geometry); @@ -467,7 +525,7 @@ function circularLinearSample(values, lagSamples) { return values[firstIndex] * (1 - fraction) + values[secondIndex] * fraction; } -function fillBandPairCorrelation({ +async function fillBandPairCorrelation({ correlationReal, correlationImaginary, bins, @@ -476,11 +534,14 @@ function fillBandPairCorrelation({ referenceIndex, phasesByBin, phatFloorRatio, + signal, + crossSpectrumBatchSize, }) { correlationReal.fill(0); correlationImaginary.fill(0); const microphoneCount = stft.microphoneCount; let maximumMagnitude = 0; + let operationsUntilYield = crossSpectrumBatchSize; for (const bin of bins) { const spectrum = stft.spectra.get(bin); @@ -498,6 +559,12 @@ function fillBandPairCorrelation({ maximumMagnitude, Math.hypot(crossReal, crossImaginary), ); + operationsUntilYield -= 1; + if (operationsUntilYield === 0) { + await cooperativeYield(); + throwIfAborted(signal); + operationsUntilYield = crossSpectrumBatchSize; + } } } @@ -535,6 +602,12 @@ function fillBandPairCorrelation({ normalizedReal * correctionReal - normalizedImaginary * correctionImaginary; accumulatedImaginary += normalizedReal * correctionImaginary + normalizedImaginary * correctionReal; + operationsUntilYield -= 1; + if (operationsUntilYield === 0) { + await cooperativeYield(); + throwIfAborted(signal); + operationsUntilYield = crossSpectrumBatchSize; + } } const averagedReal = accumulatedReal / stft.frameCount; @@ -552,13 +625,38 @@ function fillBandPairCorrelation({ fftInPlace(correlationReal, correlationImaginary, true); } -function shiftAndSquareScores(rawMaps) { - for (const rawMap of rawMaps) { - let minimum = Infinity; - for (const value of rawMap) minimum = Math.min(minimum, value); +async function normalizePositiveScores( + rawMaps, + bandBins, + fftSize, + pairCount, + pointBatchSize, + signal, +) { + for (let bandIndex = 0; bandIndex < rawMaps.length; bandIndex += 1) { + const rawMap = rawMaps[bandIndex]; + const coefficientCount = bandBins[bandIndex].reduce( + (count, bin) => count + (bin === 0 || bin === fftSize / 2 ? 1 : 2), + 0, + ); + const normalization = fftSize / (pairCount * coefficientCount); for (let pointIndex = 0; pointIndex < rawMap.length; pointIndex += 1) { - const shifted = rawMap[pointIndex] - minimum; - rawMap[pointIndex] = shifted * shifted; + // PHAT coefficients have magnitude <= 1. After Hermitian completion and + // inverse-FFT scaling, coefficientCount / fftSize is therefore a strict + // per-pair bound. Pair averaging makes the positive score stable across + // microphone count, band width, and scan extent without implying a + // probability. Negative correlation is evidence against the hypothesis. + rawMap[pointIndex] = Math.min( + 1, + Math.max(0, rawMap[pointIndex] * normalization), + ); + if ( + (pointIndex + 1) % pointBatchSize === 0 && + pointIndex + 1 < rawMap.length + ) { + await cooperativeYield(); + throwIfAborted(signal); + } } } } @@ -568,16 +666,16 @@ async function computeGccPhatMapInternal(options, configuration, geometry, plan) channels, signal, onProgress, - maximumWorkingBytes = 512 * 1024 * 1024, + maximumWorkingBytes = DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES, maximumFftButterflies = 1_000_000_000, - pointBatchSize = 256, + pointBatchSize = DEFAULT_GCC_PHAT_POINT_BATCH_SIZE, + crossSpectrumBatchSize = DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE, stftTransformBatchSize = 128, } = options; requirePositiveInteger(pointBatchSize, "pointBatchSize"); + requirePositiveInteger(crossSpectrumBatchSize, "crossSpectrumBatchSize"); requirePositiveInteger(stftTransformBatchSize, "stftTransformBatchSize"); - if (!Number.isSafeInteger(maximumWorkingBytes) || maximumWorkingBytes <= 0) { - throw new RangeError("maximumWorkingBytes must be a positive safe integer."); - } + requireMaximumWorkingBytes(maximumWorkingBytes); const phatFloorRatio = requirePhatFloorRatio( options.phatFloorRatio ?? options.phatRelativeFloor ?? @@ -666,7 +764,7 @@ async function computeGccPhatMapInternal(options, configuration, geometry, plan) const rawMap = rawMaps[bandIndex]; for (const microphoneIndex of geometry.pairMicrophoneIndexes) { throwIfAborted(signal); - fillBandPairCorrelation({ + await fillBandPairCorrelation({ correlationReal, correlationImaginary, bins, @@ -675,6 +773,8 @@ async function computeGccPhatMapInternal(options, configuration, geometry, plan) referenceIndex: geometry.reference.index, phasesByBin, phatFloorRatio, + signal, + crossSpectrumBatchSize, }); if (plan) { plan.statistics.inverseFftTransforms += 1; @@ -715,12 +815,20 @@ async function computeGccPhatMapInternal(options, configuration, geometry, plan) } throwIfAborted(signal); - shiftAndSquareScores(rawMaps); + await normalizePositiveScores( + rawMaps, + geometry.bandBins, + configuration.fftSize, + geometry.pairCount, + pointBatchSize, + signal, + ); const bands = finalizeDasBands( rawMaps, workload, configuration.experiment, "score", + { averageBandBins: false }, ); return { backend: "cpu", @@ -753,6 +861,7 @@ export async function computeGccPhatMap(options) { const configuration = validateConfiguration(options); const plan = options.analysisPlan; if (!plan) { + preflightUnplannedWorkingSet(options, configuration); const geometry = createGeometry(configuration); return computeGccPhatMapInternal(options, configuration, geometry, undefined); } diff --git a/packages/acoustics-core/localization.js b/packages/acoustics-core/localization.js index 939f47c..d45e78e 100644 --- a/packages/acoustics-core/localization.js +++ b/packages/acoustics-core/localization.js @@ -25,9 +25,9 @@ function requestedAlgorithm(options) { */ export function estimateLocalizationWorkload(options) { const algorithm = requestedAlgorithm(options); - return isDasAlgorithm(algorithm.id) - ? estimateDasWorkload(options) - : estimateGccPhatWorkload(options); + if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(options); + if (algorithm.id === "gcc-phat") return estimateGccPhatWorkload(options); + throw new RangeError("No workload estimator is registered for " + algorithm.id + "."); } /** @@ -35,9 +35,9 @@ export function estimateLocalizationWorkload(options) { */ export function createLocalizationAnalysisPlan(options) { const algorithm = requestedAlgorithm(options); - return isDasAlgorithm(algorithm.id) - ? createDasAnalysisPlan(options) - : createGccPhatAnalysisPlan(options); + if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(options); + if (algorithm.id === "gcc-phat") return createGccPhatAnalysisPlan(options); + throw new RangeError("No analysis-plan factory is registered for " + algorithm.id + "."); } /** @@ -47,7 +47,7 @@ export function createLocalizationAnalysisPlan(options) { */ export async function computeLocalizationMap(options) { const algorithm = requestedAlgorithm(options); - return isDasAlgorithm(algorithm.id) - ? computeDasMap(options) - : computeGccPhatMap(options); + if (isDasAlgorithm(algorithm.id)) return computeDasMap(options); + if (algorithm.id === "gcc-phat") return computeGccPhatMap(options); + throw new RangeError("No CPU localization implementation is registered for " + algorithm.id + "."); } diff --git a/packages/acoustics-core/realtime-localizer.js b/packages/acoustics-core/realtime-localizer.js index 913ca56..af1eb86 100644 --- a/packages/acoustics-core/realtime-localizer.js +++ b/packages/acoustics-core/realtime-localizer.js @@ -3,6 +3,10 @@ import { computeLocalizationMap, createLocalizationAnalysisPlan, } from "./localization.js"; +import { + DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE, + DEFAULT_GCC_PHAT_POINT_BATCH_SIZE, +} from "./gcc-phat.js"; import { isDasAlgorithm, requireLocalizationAlgorithmDefinition, @@ -355,8 +359,13 @@ export function createLocalizationPlan(config) { ? { dasPlan: analysisPlan } : { gccPhatPlan: analysisPlan }), computeOptions: Object.freeze({ - pointBatchSize: DEFAULT_REALTIME_POINT_BATCH_SIZE, + pointBatchSize: isDasAlgorithm(algorithm.id) + ? DEFAULT_REALTIME_POINT_BATCH_SIZE + : DEFAULT_GCC_PHAT_POINT_BATCH_SIZE, stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE, + ...(!isDasAlgorithm(algorithm.id) + ? { crossSpectrumBatchSize: DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE } + : {}), ...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}), ...(phatFloorRatio !== undefined ? { phatFloorRatio } : {}), ...(config.computeOptions ?? {}), diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js index b5aa2c3..5db8258 100644 --- a/packages/contracts/validation.js +++ b/packages/contracts/validation.js @@ -555,6 +555,19 @@ function validateResultRelationships(value, context = {}) { } 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; + const expectedRawScale = + resultAlgorithm === "das" + ? "power" + : resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat" + ? "score" + : undefined; + const expectedBeamSpreadMethod = + expectedRawScale === "power" + ? "connected-half-power-power-weighted-covariance" + : expectedRawScale === "score" + ? "connected-half-maximum-score-weighted-covariance" + : undefined; const bandIds = []; if (Array.isArray(value.bands)) { value.bands.forEach((band, bandIndex) => { @@ -576,12 +589,33 @@ function validateResultRelationships(value, context = {}) { }); if (isRecord(band.rawMap) && !["power", "score"].includes(band.rawMap.scale)) { addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power 또는 score scale이어야 합니다."); + } else if ( + isRecord(band.rawMap) && + expectedRawScale && + band.rawMap.scale !== expectedRawScale + ) { + addIssue( + issues, + `${path}.rawMap.scale`, + `${resultAlgorithm} 결과의 rawMap scale은 ${expectedRawScale}여야 합니다.`, + ); } if (Array.isArray(band.peaks)) { band.peaks.forEach((peak, peakIndex) => { if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) { addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다."); } + if ( + expectedBeamSpreadMethod && + isRecord(peak?.beamSpread) && + peak.beamSpread.method !== expectedBeamSpreadMethod + ) { + addIssue( + issues, + `${path}.peaks[${peakIndex}].beamSpread.method`, + `${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`, + ); + } }); } }); diff --git a/packages/webgpu-kernels/compute.js b/packages/webgpu-kernels/compute.js index 11ee408..772f1b5 100644 --- a/packages/webgpu-kernels/compute.js +++ b/packages/webgpu-kernels/compute.js @@ -90,6 +90,9 @@ export async function computeLocalizationWithBackend(options, dependencies = {}) if (isDasAlgorithm(algorithm.id)) { return computeDasBackend(options, dependencies); } + if (algorithm.id !== "gcc-phat") { + throw new RangeError("No backend dispatcher is registered for " + algorithm.id + "."); + } const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto"; if (requestedBackend === "webgpu") { diff --git a/tests/integration/webgpu.test.js b/tests/integration/webgpu.test.js index ebdc42e..4500691 100644 --- a/tests/integration/webgpu.test.js +++ b/tests/integration/webgpu.test.js @@ -118,6 +118,38 @@ test("WebGPU DAS가 CPU 수치 기준과 일치하거나 auto CPU fallback한다 document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3); }); +test("WebGPU DAS-PHAT가 CPU score map과 일치하거나 auto CPU fallback한다", async () => { + const adapter = await globalThis.navigator?.gpu?.requestAdapter(); + const fixture = createFixture(adapter ? "webgpu" : "auto"); + fixture.experiment.beamforming.algorithm = "das-phat"; + fixture.experiment.frequencyBands = [ + { id: "phase-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" }, + ]; + + if (!adapter) { + const result = await computeDasWithBackend(fixture); + assertEqual(result.backend, "cpu", "DAS-PHAT auto fallback backend"); + assertEqual(result.algorithm, "das-phat", "DAS-PHAT fallback algorithm"); + assertEqual(result.bands[0].rawScale, "score", "DAS-PHAT fallback scale"); + return; + } + + const cpuResult = await computeDasMap(fixture); + const gpuResult = await computeDasWebGpu(fixture); + const cpuBand = cpuResult.bands[0]; + const gpuBand = gpuResult.bands[0]; + const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap); + + assertEqual(gpuResult.algorithm, "das-phat", "actual WebGPU DAS-PHAT algorithm"); + assertEqual(gpuBand.rawScale, "score", "actual WebGPU DAS-PHAT scale"); + assert(!("rawPower" in gpuBand.peak), "DAS-PHAT GPU peak is not mislabeled as power"); + assert(error.rms <= 0.005, `DAS-PHAT CPU/GPU normalized RMS error=${error.rms}`); + assert( + error.maximumAbsoluteError <= 0.02, + `DAS-PHAT CPU/GPU normalized max error=${error.maximumAbsoluteError}`, + ); +}); + test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => { const adapter = await globalThis.navigator?.gpu?.requestAdapter(); if (!adapter) { diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js index 641d346..370c425 100644 --- a/tests/unit/contracts.test.js +++ b/tests/unit/contracts.test.js @@ -410,7 +410,28 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a id: "tone-1k", color: "#65f2bf", rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) }, - peaks: [{ sourceId: "source-1", position: [0.08, 0.04, 2], value: 1, localizationErrorM: 0 }], + peaks: [{ + sourceId: "source-1", + position: [0.08, 0.04, 2], + value: 1, + localizationErrorM: 0, + beamSpread: { + calibrated: false, + method: "connected-half-power-power-weighted-covariance", + halfPowerRatio: 0.5, + thresholdValue: 0.5, + pixelCount: 1, + centroid: [0.08, 0.04, 2], + covarianceM2: { xx: 0, xy: 0, yy: 0 }, + ellipse: { + axisScale: "rms", + angleReference: "counterclockwise-from-positive-x", + majorRmsRadiusM: 0, + minorRmsRadiusM: 0, + angleDeg: 0, + }, + }, + }], metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 }, }], }; @@ -419,17 +440,39 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a experiment.beamforming.algorithm = "das-phat"; result.algorithm = "das-phat"; result.bands[0].rawMap.scale = "score"; + result.bands[0].peaks[0].beamSpread.method = + "connected-half-maximum-score-weighted-covariance"; assertEqual( (await validateResult(result, { experiment, scene })).length, 0, "phase-weighted score result issues", ); - result.algorithm = "das"; + result.bands[0].rawMap.scale = "power"; let issues = await validateResult(result, { experiment, scene }); + assert( + issues.some(({ path }) => path.endsWith("rawMap.scale")), + "algorithm and raw map scale must agree", + ); + result.bands[0].rawMap.scale = "score"; + result.bands[0].peaks[0].beamSpread.method = + "connected-half-power-power-weighted-covariance"; + issues = await validateResult(result, { experiment, scene }); + assert( + issues.some(({ path }) => path.endsWith("beamSpread.method")), + "score map and beam spread method must agree", + ); + result.bands[0].peaks[0].beamSpread.method = + "connected-half-maximum-score-weighted-covariance"; + + result.algorithm = "das"; + issues = await validateResult(result, { experiment, scene }); assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment"); experiment.beamforming.algorithm = "das"; + result.bands[0].rawMap.scale = "power"; + result.bands[0].peaks[0].beamSpread.method = + "connected-half-power-power-weighted-covariance"; result.bands[0].rawMap.values.pop(); result.bands[0].rawMap.scale = "normalized"; result.bands[0].peaks[0].sourceId = "missing-source"; diff --git a/tests/unit/gcc-phat.test.js b/tests/unit/gcc-phat.test.js index ffba682..09bbe56 100644 --- a/tests/unit/gcc-phat.test.js +++ b/tests/unit/gcc-phat.test.js @@ -174,6 +174,18 @@ test("GCC-PHAT workload reports reference-pair scan reduction and rejects aliase "maximum geometric lag is exposed and unaliased", ); + let preflightError; + try { + estimateGccPhatWorkload({ ...fixture, maximumWorkingBytes: 1 }); + } catch (error) { + preflightError = error; + } + assert( + preflightError instanceof RangeError && + preflightError.message.includes("before geometry allocation"), + "unplanned workload is rejected before allocating its distance matrix", + ); + let memoryError; try { createGccPhatAnalysisPlan({ ...fixture, maximumPlanBytes: 1 }); @@ -241,8 +253,8 @@ test("GCC-PHAT corrected lag sign finds a deterministic broadband source and emi assertNear(result.maximumLagSamples, result.workload.maximumLagSamples, 0, "lag metadata"); assertEqual(band.rawScale, "score", "raw map scale"); assert( - band.rawMap.every((value) => Number.isFinite(value) && value >= 0), - "shifted and squared score map is finite and nonnegative", + band.rawMap.every((value) => Number.isFinite(value) && value >= 0 && value <= 1), + "pair- and bandwidth-normalized score map is finite and bounded", ); assert( distance3(peakPosition, fixture.target) <= 0.15, @@ -263,6 +275,48 @@ test("GCC-PHAT corrected lag sign finds a deterministic broadband source and emi "X_m * conj(X_ref) correlation uses the physical arrival-lag sign", ); assertNear(Math.max(...band.displayMap), 1, 1e-6, "normalized score display"); + + const expandedFixture = createBroadbandFixture(); + expandedFixture.experiment.scan = { + ...expandedFixture.experiment.scan, + xMin: -0.8, + xMax: 0.8, + yMin: -0.8, + yMax: 0.8, + columns: 17, + rows: 17, + }; + const expanded = await computeGccPhatMap({ + ...expandedFixture, + phatFloorRatio: 1e-5, + pointBatchSize: 1024, + stftTransformBatchSize: 1024, + }); + const expandedTargetIndex = scanIndexFor( + expandedFixture.experiment.scan, + expandedFixture.target[0], + expandedFixture.target[1], + ); + assertNear( + expanded.bands[0].rawMap[expandedTargetIndex], + band.rawMap[targetIndex], + 1e-6, + "raw score at a fixed hypothesis is independent of scan extent", + ); +}); + +test("GCC-PHAT compute rejects an oversized unplanned working set before geometry", async () => { + const fixture = createBroadbandFixture(); + let error; + try { + await computeGccPhatMap({ ...fixture, maximumWorkingBytes: 1 }); + } catch (candidate) { + error = candidate; + } + assert( + error instanceof RangeError && error.message.includes("before geometry allocation"), + "compute preflight error", + ); }); test("GCC-PHAT analysis plan reuses geometry and FFT workspaces and releases its busy lock", async () => { diff --git a/tests/unit/localization.test.js b/tests/unit/localization.test.js index 2985b0c..63b3d01 100644 --- a/tests/unit/localization.test.js +++ b/tests/unit/localization.test.js @@ -3,6 +3,10 @@ import { createLocalizationAnalysisPlan, estimateLocalizationWorkload, } from "/packages/acoustics-core/localization.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, @@ -167,10 +171,22 @@ test("portable and streaming API accept an algorithm choice without Scene synthe 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");