From 256558e601c53d478e5a154e2cf004d952fa7784 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 18:58:48 +0900 Subject: [PATCH] perf: harden realtime localization pipeline --- packages/acoustics-core/README.md | 49 +++- packages/acoustics-core/cooperative-yield.js | 40 +++ packages/acoustics-core/das.js | 153 +++++++++-- packages/acoustics-core/frequency-response.js | 27 ++ packages/acoustics-core/realtime-localizer.js | 201 ++++++++++++--- packages/acoustics-core/stft.js | 53 +++- packages/contracts/validation.js | 8 + packages/webgpu-kernels/das.js | 12 +- schemas/experiment.schema.json | 3 +- tests/unit/acoustics.test.js | 33 +++ tests/unit/contracts.test.js | 2 + tests/unit/realtime-localizer.test.js | 243 +++++++++++++++++- tests/unit/webgpu.test.js | 25 ++ 13 files changed, 765 insertions(+), 84 deletions(-) create mode 100644 packages/acoustics-core/cooperative-yield.js diff --git a/packages/acoustics-core/README.md b/packages/acoustics-core/README.md index 46edc3a..c8c53d2 100644 --- a/packages/acoustics-core/README.md +++ b/packages/acoustics-core/README.md @@ -24,6 +24,8 @@ const localizer = createStreamingLocalizer({ scan: { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 50, rows: 50 }, bands: [{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" }], stft: { fftSize: 512, hopSize: 256 }, + maxCompensationDb: 24, + peakDetection: { thresholdRatio: 0.5, maximumDetections: 8 }, analysisWindowSamples: 4_800, analysisHopSamples: 4_800, maximumBufferedSamples: 19_200, @@ -38,20 +40,47 @@ const results = await localizer.analyzeAvailable({ signal: abortController.signa for (const result of results) { const { position, rawPower } = result.locations[0]; drawMarker(position, rawPower, result.centerTime); + if (!result.deadlineMet) reportOverload(result.processingTimeMs, result.deadlineMs); } ``` +`locations`는 band 순서, 같은 band 안에서는 raw power 내림차순의 평평한 목록입니다. 각 항목은 +기존 `{ bandId, index, position, rawPower }`에 `rank`를 더하며 같은 band의 여러 local peak를 +포함할 수 있습니다. `peakDetection.thresholdRatio` 기본값은 최강 peak의 0.5배, +`minimumPeakDistanceM` 기본값은 scan cell 대각선, `maximumDetections` 기본값은 8입니다. +기존처럼 `locations[0]`만 읽으면 최강 후보를 그대로 얻습니다. + +Streaming 계산이 중단되거나 실패하면 해당 분석창과 sequence를 commit하지 않고 다음 호출에서 같은 +창을 재시도합니다. 성공한 뒤에만 hop·sequence·`completedAnalyses`를 commit하며, 그 뒤 호출되는 +`onResult` callback의 예외는 `failedAnalyses`에 포함하지 않습니다. 계산 중 capture overflow가 나면 +ring의 오래된 sample은 평소와 같이 drop telemetry에 기록됩니다. + +한 블록 결과의 `processingTimeMs`는 DAS와 local-peak 검출에 걸린 실제 시간을 기록합니다. Streaming +결과는 `deadlineMs = analysisHopSamples / sampleRate × 1000`과 `deadlineMet`을 더하며, +`getState()`는 최근/최대 처리 시간과 `deadlineMisses` 누계를 제공합니다. `deadlineMisses` 또는 +`droppedSamples`가 증가하면 grid·대역 bin·분석창·update rate를 장치 성능에 맞게 줄여야 합니다. + +깊은 응답 notch의 역필터가 sensor noise까지 폭증시키지 않도록 inverse response의 양의 boost는 기본 +24 dB로 제한합니다. Experiment의 `beamforming.maxCompensationDb` 또는 portable plan의 top-level +`maxCompensationDb`를 0~120 dB로 조절할 수 있습니다. 이 값은 boost만 제한하며 attenuation과 phase +보정은 유지합니다. 선택 bin 전체의 gain/phase calibration byte도 plan 생성 전에 산정하므로 lazy +cache가 나중에 `maximumPlanBytes`를 넘지 않습니다. + 한 블록만 처리할 때는 `createLocalizationPlan()`과 `localizeMultichannel()`을 사용합니다. `getState()`은 absolute sample origin, buffered/dropped sample, 대기 window 수와 고정 input buffer byte 수를 돌려주므로 capture adapter가 지연을 감시할 수 있습니다. 실제 장치의 clock, channel framing과 transport는 하드웨어별 adapter 책임이며 이 모듈은 그 다음 단계부터 시작합니다. -반복 CPU 분석 계획은 거리 행렬, bin calibration, 제한 내의 Float64 steering matrix와 임시 bin map을 -재사용합니다. 예를 들어 192채널·50×50·2 bins를 10회 분석하면 거리 계산은 4,800,000회에서 -480,000회로, steering point/microphone pair는 9,600,000회에서 960,000회로 줄어듭니다 -(sin/cos 호출 17,280,000회 회피). 이 경우 약 16.5 MiB를 유지하며, 기본 64 MiB steering 한도를 -넘는 설정은 steering cache만 끕니다. 자동 테스트는 cache 전후 raw/display Float32 map의 전 요소 -bit-exact 일치를 고정합니다. +반복 CPU 분석 계획은 거리 행렬, bin calibration, Hann window, Float64 FFT workspace, 임시 bin map과 +예산 내의 Float64 steering matrix를 재사용합니다. 예를 들어 192채널·50×50·2 bins를 10회 분석하면 +거리 계산은 4,800,000회에서 480,000회로, steering point/microphone pair는 9,600,000회에서 +960,000회로 줄어듭니다(sin/cos 호출 17,280,000회 회피). 이 경우 약 16.5 MiB를 유지합니다. + +기본 64 MiB보다 전체 wide-band steering matrix가 크더라도 cache 전체를 끄지 않습니다. 선택 bin +앞쪽부터 예산에 들어가는 만큼만 Float64로 유지하고 나머지는 고정 microphone workspace에서 계산합니다. +`analysisCache`는 cached bin capacity, uncached 사용 횟수·pair 수, STFT transform·FFT butterfly·DAS +complex MAC 수와 실제 workspace allocation/reuse 수를 제공합니다. 자동 테스트는 부분 cache와 +cache 없음 사이 raw/display Float32 map의 전 요소 bit-exact 일치를 고정합니다. ## 이동 음원과 분석 시간창 @@ -75,8 +104,10 @@ ground-truth 위치에서 peak가 생기는 회귀 테스트로 이 부호를 마이크 `sensitivityDb`와 `phaseOffsetDeg`는 수신 channel에 편차로 적용하고, DAS에서는 알려진 calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용 normalized map은 서로 다른 -buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를 -양보해 진행 callback과 `AbortSignal` 취소가 동작합니다. +buffer로 보존합니다. bin spectrum은 제한된 batch로 처리합니다. cooperative yield는 지원 환경에서 +`scheduler.yield()`, 그 외에는 `MessageChannel`을 사용하고 timer task에도 주기적으로 기회를 주므로 +진행 callback과 `AbortSignal` 취소를 유지하면서 연속 `setTimeout(0)` clamp를 피합니다. portable +plan의 기본 batch는 point 256개·STFT transform 128개이며 `computeOptions`로 조정할 수 있습니다. 마이크별 `frequencyResponse`는 2~64개의 `{ frequencyHz, gainDb, phaseDeg? }` 점으로 정의합니다. `interpolateFrequencyResponse`는 dB와 wrapped phase를 구간 선형 보간하고 측정 범위 밖에서는 가장 @@ -112,6 +143,6 @@ Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면 snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을 512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로 전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB -hard cap을 할당 전에 검사하며 steering cache는 별도의 64 MiB 한도를 넘으면 자동으로 끕니다. +hard cap을 할당 전에 검사하며 steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지합니다. 192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다. diff --git a/packages/acoustics-core/cooperative-yield.js b/packages/acoustics-core/cooperative-yield.js new file mode 100644 index 0000000..d4e694c --- /dev/null +++ b/packages/acoustics-core/cooperative-yield.js @@ -0,0 +1,40 @@ +let messageChannelYield; +let cooperativeTurn = 0; + +export const COOPERATIVE_TIMER_TURN_INTERVAL = 8; + +function createMessageChannelYield() { + if (typeof globalThis.MessageChannel !== "function") return undefined; + const channel = new globalThis.MessageChannel(); + const queue = []; + channel.port1.onmessage = () => queue.shift()?.(); + channel.port1.unref?.(); + channel.port2.unref?.(); + return () => new Promise((resolve) => { + queue.push(resolve); + channel.port2.postMessage(0); + }); +} + +/** + * Yield one cooperative task without relying on nested zero-delay timers. + * scheduler.yield() is preferred when the host implements it; MessageChannel + * keeps the fallback task-based and avoids browser timer clamping. + */ +export function cooperativeYield() { + cooperativeTurn += 1; + if (cooperativeTurn % COOPERATIVE_TIMER_TURN_INTERVAL === 0) { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + const hostScheduler = globalThis.scheduler; + if (typeof hostScheduler?.yield === "function") { + try { + return hostScheduler.yield(); + } catch { + // A partial scheduler implementation must not make computation unusable. + } + } + messageChannelYield ??= createMessageChannelYield(); + if (messageChannelYield) return messageChannelYield(); + return new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/packages/acoustics-core/das.js b/packages/acoustics-core/das.js index 70fc7c4..0a63760 100644 --- a/packages/acoustics-core/das.js +++ b/packages/acoustics-core/das.js @@ -1,6 +1,11 @@ import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js"; -import { interpolateFrequencyResponse } from "./frequency-response.js"; -import { binsForBand, extractStftBins, getFrameCount } from "./stft.js"; +import { + interpolateFrequencyResponse, + inverseResponseGainDb, + requireMaxCompensationDb, +} from "./frequency-response.js"; +import { cooperativeYield } from "./cooperative-yield.js"; +import { binsForBand, createStftWorkspace, extractStftBins, getFrameCount } from "./stft.js"; function throwIfAborted(signal) { if (signal?.aborted) { @@ -10,10 +15,6 @@ function throwIfAborted(signal) { } } -function yieldToEventLoop() { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - export const DEFAULT_MAXIMUM_CPU_STEERING_CACHE_BYTES = 64 * 1024 * 1024; export const DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES = 512 * 1024 * 1024; const DAS_ANALYSIS_PLAN_KIND = "micarrsim.das-analysis-plan/1"; @@ -47,12 +48,20 @@ function createBandIndexLookup(bandBins) { return lookup; } -function createBinCalibration(arrayDefinition, bin, sampleRate, fftSize, compensateFrequencyResponse) { +function createBinCalibration( + arrayDefinition, + bin, + sampleRate, + fftSize, + compensateFrequencyResponse, + maximumCompensationDb, +) { const microphoneCount = arrayDefinition.microphones.length; const gains = new Float64Array(microphoneCount); const phases = new Float64Array(microphoneCount); const frequencyHz = (bin * sampleRate) / fftSize; const response = {}; + const maxCompensationDb = requireMaxCompensationDb(maximumCompensationDb); for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { const microphone = arrayDefinition.microphones[microphoneIndex]; let responseGainDb = 0; @@ -62,8 +71,11 @@ function createBinCalibration(arrayDefinition, bin, sampleRate, fftSize, compens responseGainDb = response.gainDb; responsePhaseDeg = response.phaseDeg; } + const responseCompensationDb = compensateFrequencyResponse + ? inverseResponseGainDb(responseGainDb, maxCompensationDb) + : 0; gains[microphoneIndex] = dbToLinear( - -(microphone.sensitivityDb ?? 0) - responseGainDb, + -(microphone.sensitivityDb ?? 0) + responseCompensationDb, ); phases[microphoneIndex] = ((microphone.phaseOffsetDeg ?? 0) + responsePhaseDeg) * Math.PI / 180; @@ -119,7 +131,9 @@ async function createSteeringMatrix(options) { outputImaginary: imaginary, outputOffset: offset, }); - if ((pointIndex + 1) % pointBatchSize === 0) await yieldToEventLoop(); + if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) { + await cooperativeYield(); + } } return { real, imaginary }; } @@ -136,6 +150,7 @@ export async function computeBinMap(options) { fftSize, speedOfSound, compensateFrequencyResponse = true, + maxCompensationDb, calibration: providedCalibration, steeringMatrix, output: providedOutput, @@ -156,6 +171,7 @@ export async function computeBinMap(options) { sampleRate, fftSize, compensateFrequencyResponse, + maxCompensationDb, ); const phaseRealValues = steeringMatrix?.real ?? steeringReal; const phaseImaginaryValues = steeringMatrix?.imaginary ?? steeringImaginary; @@ -212,7 +228,9 @@ export async function computeBinMap(options) { accumulatedPower += (sumReal * sumReal + sumImaginary * sumImaginary) * microphoneScale * microphoneScale; } output[pointIndex] = accumulatedPower / frameCount; - if ((pointIndex + 1) % pointBatchSize === 0) await yieldToEventLoop(); + if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) { + await cooperativeYield(); + } } return output; } @@ -255,32 +273,45 @@ export function createDasAnalysisPlan(options) { const pointCount = experiment.scan.columns * experiment.scan.rows; const pointMicrophonePairs = pointCount * microphoneCount; const distanceMatrixBytes = pointMicrophonePairs * Float32Array.BYTES_PER_ELEMENT; + const stftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3; + const calibrationBytes = + selectedBins.length * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; const retainedBaseBytes = distanceMatrixBytes + pointCount * Float32Array.BYTES_PER_ELEMENT + - microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; + microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2 + + stftWorkspaceBytes; + const retainedNonSteeringBytes = retainedBaseBytes + calibrationBytes; if ( !Number.isSafeInteger(pointMicrophonePairs) || !Number.isSafeInteger(retainedBaseBytes) || - retainedBaseBytes > maximumPlanBytes + !Number.isSafeInteger(calibrationBytes) || + !Number.isSafeInteger(retainedNonSteeringBytes) || + retainedNonSteeringBytes > maximumPlanBytes ) { throw new RangeError( - "CPU analysis plan requires " + retainedBaseBytes.toLocaleString() + - " base bytes, above maximumPlanBytes " + maximumPlanBytes.toLocaleString() + ".", + "CPU analysis plan requires " + retainedNonSteeringBytes.toLocaleString() + + " non-steering bytes including " + calibrationBytes.toLocaleString() + + " calibration bytes, above maximumPlanBytes " + + maximumPlanBytes.toLocaleString() + ".", ); } const distances = createDistanceMatrix(arrayDefinition, experiment.scan); const binMap = new Float32Array(pointCount); const steeringReal = new Float64Array(microphoneCount); const steeringImaginary = new Float64Array(microphoneCount); + const stftWorkspace = createStftWorkspace(fftSize); const steeringBytesPerBin = pointCount * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; const requestedSteeringCacheBytes = steeringBytesPerBin * selectedBins.length; - const steeringCacheEnabled = - Number.isSafeInteger(requestedSteeringCacheBytes) && - requestedSteeringCacheBytes > 0 && - requestedSteeringCacheBytes <= maximumSteeringCacheBytes && - retainedBaseBytes + requestedSteeringCacheBytes <= maximumPlanBytes; + const steeringCacheBinCapacity = Math.min( + selectedBins.length, + Math.floor(maximumSteeringCacheBytes / steeringBytesPerBin), + Math.floor((maximumPlanBytes - retainedNonSteeringBytes) / steeringBytesPerBin), + ); + const steeringCacheBytes = steeringCacheBinCapacity * steeringBytesPerBin; + const steeringCacheEnabled = steeringCacheBinCapacity > 0; + const steeringCachedBins = new Set(selectedBins.slice(0, steeringCacheBinCapacity)); return { kind: DAS_ANALYSIS_PLAN_KIND, @@ -297,13 +328,20 @@ export function createDasAnalysisPlan(options) { calibrations: new Map(), steeringMatrices: new Map(), steeringCacheEnabled, + steeringCacheBinCapacity, + steeringCachedBins, steeringBytesPerBin, + steeringCacheBytes, requestedSteeringCacheBytes, + retainedBaseBytes, + calibrationBytes, + retainedNonSteeringBytes, maximumSteeringCacheBytes, maximumPlanBytes, binMap, steeringReal, steeringImaginary, + stftWorkspace, busy: false, statistics: { analysisAttempts: 0, @@ -315,8 +353,20 @@ export function createDasAnalysisPlan(options) { calibrationCacheHits: 0, steeringMatrixBuilds: 0, steeringCacheHits: 0, + uncachedSteeringUses: 0, steeringPointMicrophonePairs: 0, + uncachedSteeringPointMicrophonePairs: 0, avoidedSteeringPointMicrophonePairs: 0, + stftWindowBuilds: 1, + stftWorkspaceAllocations: 1, + stftWorkspaceUses: 0, + avoidedStftWindowBuilds: 0, + avoidedStftWorkspaceAllocations: 0, + stftTransforms: 0, + fftButterflies: 0, + beamformingComplexMacs: 0, + binMapAllocations: 1, + steeringWorkspaceAllocations: 1, binMapUses: 0, avoidedBinMapAllocations: 0, retainedBytes: retainedBaseBytes, @@ -350,6 +400,7 @@ function planCalibration(plan, bin) { plan.sampleRate, plan.experiment.stft.fftSize, plan.experiment.beamforming.compensateFrequencyResponse !== false, + plan.experiment.beamforming.maxCompensationDb, ); plan.calibrations.set(bin, calibration); plan.statistics.calibrationBuilds += 1; @@ -358,7 +409,12 @@ function planCalibration(plan, bin) { } async function planSteeringMatrix(plan, bin, calibration, signal, pointBatchSize) { - if (!plan.steeringCacheEnabled) return undefined; + if (!plan.steeringCachedBins.has(bin)) { + plan.statistics.uncachedSteeringUses += 1; + plan.statistics.uncachedSteeringPointMicrophonePairs += + plan.pointCount * plan.microphoneCount; + return undefined; + } let matrix = plan.steeringMatrices.get(bin); if (matrix) { plan.statistics.steeringCacheHits += 1; @@ -393,12 +449,31 @@ function analysisCacheSummary(plan) { avoidedDistanceEvaluations: plan.statistics.avoidedDistanceEvaluations, calibrationBuilds: plan.statistics.calibrationBuilds, calibrationCacheHits: plan.statistics.calibrationCacheHits, + calibrationBytes: plan.calibrationBytes, steeringCacheEnabled: plan.steeringCacheEnabled, + steeringCacheBinCapacity: plan.steeringCacheBinCapacity, + steeringCachedBinCount: plan.steeringMatrices.size, + steeringUncachedBinCount: plan.selectedBins.length - plan.steeringCacheBinCapacity, + steeringCacheBytes: plan.steeringCacheBytes, + requestedSteeringCacheBytes: plan.requestedSteeringCacheBytes, steeringMatrixBuilds: plan.statistics.steeringMatrixBuilds, steeringCacheHits: plan.statistics.steeringCacheHits, + uncachedSteeringUses: plan.statistics.uncachedSteeringUses, steeringPointMicrophonePairs: plan.statistics.steeringPointMicrophonePairs, + uncachedSteeringPointMicrophonePairs: + plan.statistics.uncachedSteeringPointMicrophonePairs, avoidedSteeringPointMicrophonePairs: plan.statistics.avoidedSteeringPointMicrophonePairs, + stftWindowBuilds: plan.statistics.stftWindowBuilds, + stftWorkspaceAllocations: plan.statistics.stftWorkspaceAllocations, + stftWorkspaceUses: plan.statistics.stftWorkspaceUses, + avoidedStftWindowBuilds: plan.statistics.avoidedStftWindowBuilds, + avoidedStftWorkspaceAllocations: plan.statistics.avoidedStftWorkspaceAllocations, + stftTransforms: plan.statistics.stftTransforms, + fftButterflies: plan.statistics.fftButterflies, + beamformingComplexMacs: plan.statistics.beamformingComplexMacs, + binMapAllocations: plan.statistics.binMapAllocations, + steeringWorkspaceAllocations: plan.statistics.steeringWorkspaceAllocations, avoidedBinMapAllocations: plan.statistics.avoidedBinMapAllocations, retainedBytes: plan.statistics.retainedBytes, }; @@ -419,12 +494,13 @@ export function estimateDasWorkload(options) { const outputBytes = bandCount * pointCount * Float32Array.BYTES_PER_ELEMENT * 2; const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT; const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; - const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 2; - const steeringCacheBytes = analysisPlan?.steeringCacheEnabled - ? analysisPlan.requestedSteeringCacheBytes - : 0; + const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3; + const steeringCacheBytes = analysisPlan?.steeringCacheBytes ?? 0; + const calibrationBytes = analysisPlan?.calibrationBytes ?? + microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; const baseWorkingBytes = - distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes + steeringCacheBytes; + distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes + + steeringCacheBytes + calibrationBytes; const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length; return { bandBins, @@ -434,7 +510,10 @@ export function estimateDasWorkload(options) { pointCount, distanceBytes, outputBytes, + fftWorkspaceBytes, steeringCacheBytes, + calibrationBytes, + steeringCacheBinCapacity: analysisPlan?.steeringCacheBinCapacity ?? 0, spectrumBytesPerBin, baseWorkingBytes, complexMacCount, @@ -537,16 +616,21 @@ async function computeDasMapInternal(options, analysisPlan) { let completedBins = 0; for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) { - throwIfAborted(signal); - await yieldToEventLoop(); throwIfAborted(signal); const batchBins = workload.selectedBins.slice(batchStart, batchStart + batchSize); + if (analysisPlan) { + analysisPlan.statistics.stftWorkspaceUses += 1; + const repeatedUses = Math.max(0, analysisPlan.statistics.stftWorkspaceUses - 1); + analysisPlan.statistics.avoidedStftWindowBuilds = repeatedUses; + analysisPlan.statistics.avoidedStftWorkspaceAllocations = repeatedUses; + } const stft = await extractStftBins(channels, { fftSize: experiment.stft.fftSize, hopSize: experiment.stft.hopSize, bins: batchBins, signal, transformBatchSize: stftTransformBatchSize, + workspace: analysisPlan?.stftWorkspace, onProgress: ({ completedTransforms, totalTransforms }) => onProgress?.({ stage: "stft", @@ -558,7 +642,15 @@ async function computeDasMapInternal(options, analysisPlan) { spectrumBatchCount, }), }); - await yieldToEventLoop(); + if (analysisPlan) { + const completedTransformCount = stft.frameCount * stft.microphoneCount; + analysisPlan.statistics.stftTransforms += completedTransformCount; + analysisPlan.statistics.fftButterflies += + completedTransformCount * + (experiment.stft.fftSize / 2) * + Math.log2(experiment.stft.fftSize); + } + await cooperativeYield(); throwIfAborted(signal); for (const bin of batchBins) { @@ -585,6 +677,7 @@ async function computeDasMapInternal(options, analysisPlan) { speedOfSound: scene.speedOfSound, compensateFrequencyResponse: experiment.beamforming.compensateFrequencyResponse !== false, + maxCompensationDb: experiment.beamforming.maxCompensationDb, calibration, steeringMatrix, output: analysisPlan?.binMap, @@ -593,6 +686,10 @@ async function computeDasMapInternal(options, analysisPlan) { signal, pointBatchSize, }); + if (analysisPlan) { + analysisPlan.statistics.beamformingComplexMacs += + workload.pointCount * workload.microphoneCount * stft.frameCount; + } for (const bandIndex of bandIndexesByBin.get(bin)) { const rawMap = rawMaps[bandIndex]; for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { diff --git a/packages/acoustics-core/frequency-response.js b/packages/acoustics-core/frequency-response.js index cb8e60a..a12c5f0 100644 --- a/packages/acoustics-core/frequency-response.js +++ b/packages/acoustics-core/frequency-response.js @@ -2,6 +2,33 @@ import { dbToLinear } from "./math.js"; export const MAX_FREQUENCY_RESPONSE_POINTS = 64; export const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000; +export const DEFAULT_MAX_COMPENSATION_DB = 24; +export const MAX_COMPENSATION_DB = 120; + +export function requireMaxCompensationDb( + value = DEFAULT_MAX_COMPENSATION_DB, + label = "maxCompensationDb", +) { + if (!Number.isFinite(value) || value < 0 || value > MAX_COMPENSATION_DB) { + throw new RangeError(`${label} must lie inside 0..${MAX_COMPENSATION_DB} dB.`); + } + return value; +} + +/** + * Return the inverse response gain in dB while limiting only positive boost. + * Attenuation remains exact because it cannot amplify a response notch or the + * microphone self-noise that occupies it. + */ +export function inverseResponseGainDb(responseGainDb, maxCompensationDb) { + if (!Number.isFinite(responseGainDb)) { + throw new RangeError("responseGainDb must be finite."); + } + return Math.min( + -responseGainDb, + requireMaxCompensationDb(maxCompensationDb), + ); +} function phaseAt(point) { return point.phaseDeg ?? 0; diff --git a/packages/acoustics-core/realtime-localizer.js b/packages/acoustics-core/realtime-localizer.js index 0a6bd31..cfe3a43 100644 --- a/packages/acoustics-core/realtime-localizer.js +++ b/packages/acoustics-core/realtime-localizer.js @@ -1,13 +1,18 @@ import { computeDasMap, createDasAnalysisPlan } from "./das.js"; +import { findLocalPeaks } from "./evaluation.js"; import { + DEFAULT_MAX_COMPENSATION_DB, MAX_FREQUENCY_RESPONSE_HZ, MAX_FREQUENCY_RESPONSE_POINTS, + requireMaxCompensationDb, } from "./frequency-response.js"; export const REALTIME_LOCALIZATION_PLAN_KIND = "micarrsim.realtime-localization-plan/1"; export const DEFAULT_STREAM_RESULTS_PER_CALL = 1; export const MAX_STREAM_RESULTS_PER_CALL = 32; export const DEFAULT_MAXIMUM_STREAM_INPUT_BYTES = 256 * 1024 * 1024; +export const DEFAULT_REALTIME_POINT_BATCH_SIZE = 256; +export const DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE = 128; function requirePositiveFinite(value, label) { if (!Number.isFinite(value) || value <= 0) { @@ -31,6 +36,10 @@ function requirePowerOfTwo(value, label) { return value; } +function monotonicNow() { + return globalThis.performance?.now?.() ?? Date.now(); +} + function freezePosition(position, label) { if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) { throw new TypeError(`${label} must be a finite [x, y, z] array.`); @@ -155,6 +164,28 @@ function copyBands(bands, sampleRate) { })); } +function copyPeakDetection(value, scan) { + const options = value ?? {}; + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("peakDetection must be an object."); + } + const thresholdRatio = options.thresholdRatio ?? 0.5; + if (!Number.isFinite(thresholdRatio) || thresholdRatio < 0 || thresholdRatio > 1) { + throw new RangeError("peakDetection.thresholdRatio must lie inside 0..1."); + } + const xStep = scan.columns > 1 ? (scan.xMax - scan.xMin) / (scan.columns - 1) : 0; + const yStep = scan.rows > 1 ? (scan.yMax - scan.yMin) / (scan.rows - 1) : 0; + const minimumPeakDistanceM = options.minimumPeakDistanceM ?? Math.hypot(xStep, yStep); + if (!Number.isFinite(minimumPeakDistanceM) || minimumPeakDistanceM < 0) { + throw new RangeError("peakDetection.minimumPeakDistanceM must be a non-negative finite number."); + } + const maximumDetections = options.maximumDetections ?? 8; + if (!Number.isInteger(maximumDetections) || maximumDetections < 1 || maximumDetections > 128) { + throw new RangeError("peakDetection.maximumDetections must be an integer from 1 through 128."); + } + return Object.freeze({ thresholdRatio, minimumPeakDistanceM, maximumDetections }); +} + function validateChannels(channels, expectedChannelCount) { if (!Array.isArray(channels) || channels.length !== expectedChannelCount) { throw new Error( @@ -182,13 +213,42 @@ function throwIfAborted(signal) { if (signal?.aborted) throw createAbortError(signal); } -function locationsFromBands(bands) { - return bands.map((band) => ({ - bandId: band.id, - index: band.peak.index, - position: [...band.peak.position], - rawPower: band.peak.rawPower, - })); +export function detectBandPeaks(rawMap, scan, options = {}) { + const peakDetection = copyPeakDetection(options, scan); + const localPeaks = findLocalPeaks(rawMap, scan); + const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio; + const detections = []; + for (const peak of localPeaks) { + if (peak.value < threshold) continue; + if ( + peakDetection.minimumPeakDistanceM > 0 && + detections.some((selected) => + Math.hypot( + selected.position[0] - peak.position[0], + selected.position[1] - peak.position[1], + ) <= peakDetection.minimumPeakDistanceM + ) + ) { + continue; + } + detections.push({ + rank: detections.length + 1, + index: peak.index, + position: [...peak.position], + rawPower: peak.value, + }); + if (detections.length === peakDetection.maximumDetections) break; + } + return detections; +} + +function locationsFromBands(bands, scan, peakDetection) { + return bands.flatMap((band) => + detectBandPeaks(band.rawMap, scan, peakDetection).map((peak) => ({ + bandId: band.id, + ...peak, + })), + ); } /** @@ -202,6 +262,7 @@ export function createLocalizationPlan(config) { const sampleRate = requirePositiveFinite(config.sampleRate, "sampleRate"); const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition); const scan = copyScan(config.scan); + const peakDetection = copyPeakDetection(config.peakDetection, scan); const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate); const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize"); const hopSize = requirePositiveInteger( @@ -221,13 +282,22 @@ export function createLocalizationPlan(config) { if (typeof compensateFrequencyResponse !== "boolean") { throw new TypeError("compensateFrequencyResponse must be boolean."); } + const maxCompensationDb = requireMaxCompensationDb( + config.maxCompensationDb ?? + config.beamforming?.maxCompensationDb ?? + DEFAULT_MAX_COMPENSATION_DB, + ); const scene = Object.freeze({ speedOfSound }); const experiment = Object.freeze({ stft: Object.freeze({ fftSize, hopSize, window: "hann" }), scan, frequencyBands, - beamforming: Object.freeze({ normalization, compensateFrequencyResponse }), + beamforming: Object.freeze({ + normalization, + compensateFrequencyResponse, + maxCompensationDb, + }), }); const dasPlan = createDasAnalysisPlan({ sampleRate, @@ -245,13 +315,19 @@ export function createLocalizationPlan(config) { arrayDefinition, scan, bands: frequencyBands, + peakDetection, stft: experiment.stft, speedOfSound, normalization, + maxCompensationDb, scene, experiment, dasPlan, - computeOptions: Object.freeze({ ...(config.computeOptions ?? {}) }), + computeOptions: Object.freeze({ + pointBatchSize: DEFAULT_REALTIME_POINT_BATCH_SIZE, + stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE, + ...(config.computeOptions ?? {}), + }), }); } @@ -273,6 +349,7 @@ export async function localizeMultichannel(options) { const sampleCount = validateChannels(options.channels, plan.channelCount); if (sampleCount === 0) throw new RangeError("channels must contain at least one sample."); throwIfAborted(options.signal); + const processingStartedAt = monotonicNow(); const das = await computeDasMap({ ...plan.computeOptions, ...(options.computeOptions ?? {}), @@ -285,12 +362,15 @@ export async function localizeMultichannel(options) { signal: options.signal, onProgress: options.onProgress, }); + const locations = locationsFromBands(das.bands, plan.scan, plan.peakDetection); + const processingTimeMs = Math.max(0, monotonicNow() - processingStartedAt); return { ...das, sampleRate: plan.sampleRate, sampleCount, duration: sampleCount / plan.sampleRate, - locations: locationsFromBands(das.bands), + processingTimeMs, + locations, }; } @@ -298,8 +378,9 @@ export async function localizeMultichannel(options) { * A fixed-capacity chunk buffer for real microphone adapters. * * append() is synchronous and remains safe while an analysis is awaiting CPU - * batches. analyzeAvailable() copies one window before yielding, consumes its - * hop, and returns no more than maximumResultsPerCall maps. + * batches. analyzeAvailable() copies one window before yielding, commits its + * hop only after successful localization, and returns no more than + * maximumResultsPerCall maps. */ export class StreamingDasLocalizer { constructor(config) { @@ -319,6 +400,7 @@ export class StreamingDasLocalizer { if (this.analysisHopSamples > this.analysisWindowSamples) { throw new RangeError("analysisHopSamples must not exceed analysisWindowSamples."); } + this.analysisDeadlineMs = this.analysisHopSamples / this.plan.sampleRate * 1000; this.maximumBufferedSamples = requirePositiveInteger( config.maximumBufferedSamples ?? this.analysisWindowSamples * 4, "maximumBufferedSamples", @@ -370,15 +452,37 @@ export class StreamingDasLocalizer { this.analysisAttempts = 0; this.completedAnalyses = 0; this.failedAnalyses = 0; + this.lastProcessingTimeMs = null; + this.maximumProcessingTimeMs = 0; + this.deadlineMisses = 0; this.nextSequence = 0; + this.pendingAnalysis = null; this.busy = false; } availableAnalysisCount() { - if (this.bufferedSamples < this.analysisWindowSamples) return 0; - return 1 + Math.floor( - (this.bufferedSamples - this.analysisWindowSamples) / this.analysisHopSamples, + const bufferEndSample = this.bufferStartSample + this.bufferedSamples; + if (!this.pendingAnalysis) { + if (this.bufferedSamples < this.analysisWindowSamples) return 0; + return 1 + Math.floor( + (this.bufferedSamples - this.analysisWindowSamples) / this.analysisHopSamples, + ); + } + + // The failed window remains in analysisChannels even if capture overflow + // advances the ring while localization is running. Count it once, then + // count windows that will remain after its successful hop commit. + const nextRingStart = Math.max( + this.bufferStartSample, + this.pendingAnalysis.startSample + this.analysisHopSamples, ); + const futureSamples = bufferEndSample - nextRingStart; + const futureCount = futureSamples < this.analysisWindowSamples + ? 0 + : 1 + Math.floor( + (futureSamples - this.analysisWindowSamples) / this.analysisHopSamples, + ); + return 1 + futureCount; } getState() { @@ -387,6 +491,7 @@ export class StreamingDasLocalizer { channelCount: this.plan.channelCount, analysisWindowSamples: this.analysisWindowSamples, analysisHopSamples: this.analysisHopSamples, + analysisDeadlineMs: this.analysisDeadlineMs, maximumBufferedSamples: this.maximumBufferedSamples, maximumInputBufferBytes: this.maximumInputBufferBytes, maximumResultsPerCall: this.maximumResultsPerCall, @@ -398,6 +503,9 @@ export class StreamingDasLocalizer { analysisAttempts: this.analysisAttempts, completedAnalyses: this.completedAnalyses, failedAnalyses: this.failedAnalyses, + lastProcessingTimeMs: this.lastProcessingTimeMs, + maximumProcessingTimeMs: this.maximumProcessingTimeMs, + deadlineMisses: this.deadlineMisses, nextSequence: this.nextSequence, busy: this.busy, retainedInputBytes: this.retainedInputBytes, @@ -481,39 +589,62 @@ export class StreamingDasLocalizer { try { while ( results.length < maximumResults && - this.bufferedSamples >= this.analysisWindowSamples + (this.pendingAnalysis || this.bufferedSamples >= this.analysisWindowSamples) ) { throwIfAborted(options.signal); - const startSample = this.bufferStartSample; + const retry = this.pendingAnalysis; + const startSample = retry?.startSample ?? this.bufferStartSample; const endSample = startSample + this.analysisWindowSamples; - this.copyAnalysisWindow(); - this.consume(this.analysisHopSamples, false); + if (!retry) this.copyAnalysisWindow(); this.analysisAttempts += 1; + let localized; try { - const localized = await localizeMultichannel({ + localized = await localizeMultichannel({ plan: this.plan, channels: this.analysisChannels, signal: options.signal, onProgress: options.onProgress, computeOptions: options.computeOptions, }); - const result = { - sequence: this.nextSequence, - startSample, - endSample, - startTime: startSample / this.plan.sampleRate, - endTime: endSample / this.plan.sampleRate, - centerTime: (startSample + endSample) / (2 * this.plan.sampleRate), - ...localized, - }; - this.nextSequence += 1; - this.completedAnalyses += 1; - results.push(result); - options.onResult?.(result); } catch (error) { + this.pendingAnalysis = { startSample, endSample }; this.failedAnalyses += 1; throw error; } + + const result = { + sequence: this.nextSequence, + startSample, + endSample, + startTime: startSample / this.plan.sampleRate, + endTime: endSample / this.plan.sampleRate, + centerTime: (startSample + endSample) / (2 * this.plan.sampleRate), + ...localized, + deadlineMs: this.analysisDeadlineMs, + deadlineMet: localized.processingTimeMs <= this.analysisDeadlineMs, + }; + this.pendingAnalysis = null; + const commitThroughSample = startSample + this.analysisHopSamples; + this.consume( + Math.min( + this.bufferedSamples, + Math.max(0, commitThroughSample - this.bufferStartSample), + ), + false, + ); + this.nextSequence += 1; + this.completedAnalyses += 1; + this.lastProcessingTimeMs = localized.processingTimeMs; + this.maximumProcessingTimeMs = Math.max( + this.maximumProcessingTimeMs, + localized.processingTimeMs, + ); + if (!result.deadlineMet) this.deadlineMisses += 1; + results.push(result); + + // Consumer failures must not turn a successfully committed analysis + // into a localization failure or make its window eligible for retry. + options.onResult?.(result); } return results; } finally { @@ -536,7 +667,11 @@ export class StreamingDasLocalizer { this.analysisAttempts = 0; this.completedAnalyses = 0; this.failedAnalyses = 0; + this.lastProcessingTimeMs = null; + this.maximumProcessingTimeMs = 0; + this.deadlineMisses = 0; this.nextSequence = 0; + this.pendingAnalysis = null; } } diff --git a/packages/acoustics-core/stft.js b/packages/acoustics-core/stft.js index 75eab40..ae09381 100644 --- a/packages/acoustics-core/stft.js +++ b/packages/acoustics-core/stft.js @@ -1,4 +1,5 @@ import { fftInPlace } from "./fft.js"; +import { cooperativeYield } from "./cooperative-yield.js"; function throwIfAborted(signal) { if (signal?.aborted) { @@ -8,10 +9,6 @@ function throwIfAborted(signal) { } } -function yieldToEventLoop() { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - export function hannWindow(size) { if (!Number.isInteger(size) || size < 2) throw new Error("Hann window 크기는 2 이상이어야 합니다."); const window = new Float64Array(size); @@ -21,6 +18,32 @@ export function hannWindow(size) { return window; } +const STFT_WORKSPACE_KIND = "micarrsim.stft-workspace/1"; + +export function createStftWorkspace(fftSize) { + return { + kind: STFT_WORKSPACE_KIND, + fftSize, + window: hannWindow(fftSize), + real: new Float64Array(fftSize), + imaginary: new Float64Array(fftSize), + }; +} + +function resolveStftWorkspace(fftSize, workspace) { + if (workspace === undefined) return createStftWorkspace(fftSize); + if ( + workspace?.kind !== STFT_WORKSPACE_KIND || + workspace.fftSize !== fftSize || + workspace.window?.length !== fftSize || + workspace.real?.length !== fftSize || + workspace.imaginary?.length !== fftSize + ) { + throw new RangeError("STFT workspace must match fftSize."); + } + return workspace; +} + export function getFrameCount(sampleCount, fftSize, hopSize) { if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) { throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다."); @@ -29,7 +52,15 @@ export function getFrameCount(sampleCount, fftSize, hopSize) { } export async function extractStftBins(channels, options) { - const { fftSize, hopSize, bins, signal, onProgress, transformBatchSize = 32 } = options; + const { + fftSize, + hopSize, + bins, + signal, + onProgress, + transformBatchSize = 32, + workspace: providedWorkspace, + } = options; if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다."); const sampleCount = channels[0].length; if (channels.some((channel) => channel.length !== sampleCount)) throw new Error("마이크 채널 길이가 다릅니다."); @@ -51,9 +82,8 @@ export async function extractStftBins(channels, options) { }); } - const window = hannWindow(fftSize); - const real = new Float64Array(fftSize); - const imaginary = new Float64Array(fftSize); + const workspace = resolveStftWorkspace(fftSize, providedWorkspace); + const { window, real, imaginary } = workspace; const totalTransforms = frameCount * microphoneCount; let completedTransforms = 0; for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { @@ -73,9 +103,12 @@ export async function extractStftBins(channels, options) { spectrum.imaginary[spectrumIndex] = imaginary[bin]; } completedTransforms += 1; - if (completedTransforms % transformBatchSize === 0) { + if ( + completedTransforms % transformBatchSize === 0 && + completedTransforms < totalTransforms + ) { onProgress?.({ completedTransforms, totalTransforms }); - await yieldToEventLoop(); + await cooperativeYield(); throwIfAborted(signal); } } diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js index f800255..49a67e5 100644 --- a/packages/contracts/validation.js +++ b/packages/contracts/validation.js @@ -455,6 +455,14 @@ function validateExperimentRelationships(value, context = {}) { "주파수 응답 보정 사용 여부는 true 또는 false여야 합니다.", ); } + if (value.beamforming.maxCompensationDb !== undefined) { + requireFinite( + value.beamforming.maxCompensationDb, + "$.beamforming.maxCompensationDb", + issues, + { min: 0, max: 120 }, + ); + } } if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) { const threshold = value.evaluation.detectionThresholdRatio; diff --git a/packages/webgpu-kernels/das.js b/packages/webgpu-kernels/das.js index fab1523..520fa86 100644 --- a/packages/webgpu-kernels/das.js +++ b/packages/webgpu-kernels/das.js @@ -3,7 +3,11 @@ import { estimateDasWorkload, finalizeDasBands, } from "../acoustics-core/das.js"; -import { interpolateFrequencyResponse } from "../acoustics-core/frequency-response.js"; +import { + interpolateFrequencyResponse, + inverseResponseGainDb, + requireMaxCompensationDb, +} from "../acoustics-core/frequency-response.js"; import { dbToLinear } from "../acoustics-core/math.js"; import { extractStftBins } from "../acoustics-core/stft.js"; import { @@ -77,6 +81,7 @@ export function packStftBatch(stft, bins, options = {}) { const packed = new Float32Array(bins.length * valuesPerBin * 2); const microphones = options.microphones; const compensateFrequencyResponse = options.compensateFrequencyResponse !== false; + const maxCompensationDb = requireMaxCompensationDb(options.maxCompensationDb); const hasFrequencyResponse = compensateFrequencyResponse && Array.isArray(microphones) && @@ -100,7 +105,9 @@ export function packStftBatch(stft, bins, options = {}) { microphones.forEach((microphone, microphoneIndex) => { if (!Array.isArray(microphone.frequencyResponse)) return; interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response); - const inverseGain = dbToLinear(-response.gainDb); + const inverseGain = dbToLinear( + inverseResponseGainDb(response.gainDb, maxCompensationDb), + ); const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2; inverseResponses[responseOffset] = inverseGain * Math.cos(response.phaseRadians); inverseResponses[responseOffset + 1] = -inverseGain * Math.sin(response.phaseRadians); @@ -541,6 +548,7 @@ export async function computeDasWebGpu(options) { fftSize: experiment.stft.fftSize, compensateFrequencyResponse: experiment.beamforming.compensateFrequencyResponse !== false, + maxCompensationDb: experiment.beamforming.maxCompensationDb, }); stftMs += now() - stftStartedAt; const binIndices = Uint32Array.from(batchBins); diff --git a/schemas/experiment.schema.json b/schemas/experiment.schema.json index c4e6ce3..8959ec6 100644 --- a/schemas/experiment.schema.json +++ b/schemas/experiment.schema.json @@ -46,7 +46,8 @@ "minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 }, "normalization": { "enum": ["peak", "none"] }, "dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }, - "compensateFrequencyResponse": { "type": "boolean" } + "compensateFrequencyResponse": { "type": "boolean" }, + "maxCompensationDb": { "type": "number", "minimum": 0, "maximum": 120 } } }, "evaluation": { diff --git a/tests/unit/acoustics.test.js b/tests/unit/acoustics.test.js index 97c76ea..272a596 100644 --- a/tests/unit/acoustics.test.js +++ b/tests/unit/acoustics.test.js @@ -219,6 +219,39 @@ test("마이크 주파수 응답을 dB와 wrapped phase로 보간하고 끝점 assertEqual(above.phaseDeg, -170, "upper endpoint phase"); }); +test("DAS 역응답은 깊은 notch의 boost를 기본 24 dB로 제한한다", async () => { + const arrayDefinition = { + microphones: [{ + id: "notched", + position: [0, 0, 0], + frequencyResponse: [ + { frequencyHz: 0, gainDb: -120 }, + { frequencyHz: 4000, gainDb: -120 }, + ], + }], + }; + const options = { + bin: 8, + spectrum: { real: Float32Array.of(1), imaginary: Float32Array.of(0) }, + frameCount: 1, + arrayDefinition, + pointCount: 1, + distances: Float32Array.of(0), + sampleRate: 8000, + fftSize: 256, + speedOfSound: 343, + compensateFrequencyResponse: true, + pointBatchSize: 64, + }; + const limited = await computeBinMap(options); + const noBoost = await computeBinMap({ ...options, maxCompensationDb: 0 }); + const explicitLegacyLimit = await computeBinMap({ ...options, maxCompensationDb: 120 }); + + assertNear(limited[0], dbToLinear(24) ** 2, 1e-3, "default limited power"); + assertNear(noBoost[0], 1, 1e-9, "zero boost limit"); + assert(explicitLegacyLimit[0] > 1e11, "explicit 120 dB limit permits legacy boost"); +}); + test("index 기반 noise가 seed에 대해 결정적이다", () => { const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index)); const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index)); diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js index daebbac..39e350c 100644 --- a/tests/unit/contracts.test.js +++ b/tests/unit/contracts.test.js @@ -220,12 +220,14 @@ test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한 delete experiment.beamforming.normalization; experiment.beamforming.dynamicRangeDb = 4; experiment.beamforming.compensateFrequencyResponse = "false"; + experiment.beamforming.maxCompensationDb = 121; assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg"))); assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected")); const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene }); assert(experimentIssues.some(({ path }) => path.endsWith("normalization"))); assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb"))); assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse"))); + assert(experimentIssues.some(({ path }) => path.endsWith("maxCompensationDb"))); }); test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => { diff --git a/tests/unit/realtime-localizer.test.js b/tests/unit/realtime-localizer.test.js index fe781af..81cbb77 100644 --- a/tests/unit/realtime-localizer.test.js +++ b/tests/unit/realtime-localizer.test.js @@ -2,6 +2,7 @@ import { computeDasMap } from "/packages/acoustics-core/das.js"; import { createLocalizationPlan, createStreamingLocalizer, + detectBandPeaks, localizeMultichannel, } from "/packages/acoustics-core/realtime-localizer.js"; import { distance3 } from "/packages/acoustics-core/math.js"; @@ -102,8 +103,42 @@ test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map assertFloat32Exact(portable.bands[0].displayMap, baseline.bands[0].displayMap, "display map"); assertEqual(portable.bands[0].peak.index, baseline.bands[0].peak.index, "peak index"); assertEqual(portable.locations[0].bandId, "tone", "portable location band"); + assertEqual(portable.locations[0].rank, 1, "strongest portable location rank"); assertEqual(portable.sampleCount, 128, "sample count"); assertEqual(portable.duration, 128 / fixture.sampleRate, "block duration"); + assert( + Number.isFinite(portable.processingTimeMs) && portable.processingTimeMs >= 0, + "block processing time telemetry", + ); +}); + +test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => { + const scan = { + z: 1, + xMin: 0, + xMax: 4, + yMin: 0, + yMax: 4, + columns: 5, + rows: 5, + }; + const rawMap = new Float32Array(25); + rawMap[6] = 1; + rawMap[18] = 0.75; + rawMap[3] = 0.49; + + const peaks = detectBandPeaks(rawMap, scan); + assertEqual(peaks.length, 2, "two peaks above default threshold"); + assertEqual(peaks[0].index, 6, "strongest peak first"); + assertEqual(peaks[0].rank, 1, "strongest rank"); + assertEqual(peaks[1].index, 18, "second source peak retained"); + assertEqual(peaks[1].rank, 2, "second rank"); + assert(Math.abs(peaks[1].rawPower - 0.75) <= 1e-7, "second raw power"); + assertEqual( + detectBandPeaks(rawMap, scan, { maximumDetections: 1 }).length, + 1, + "maximum detection bound", + ); }); test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 실제로 제거한다", async () => { @@ -136,6 +171,104 @@ test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "cached repeat raw map"); }); +test("wide-band 분석 계획이 예산만큼 steering bin과 STFT workspace를 재사용한다", async () => { + const fixture = createFixture(128); + const pointMicrophonePairs = + fixture.scan.columns * fixture.scan.rows * fixture.array.microphones.length; + const steeringBytesPerBin = + pointMicrophonePairs * Float64Array.BYTES_PER_ELEMENT * 2; + const wideBands = [{ id: "wide", minHz: 500, maxHz: 580, color: "#65f2bf" }]; + const partialPlan = createLocalizationPlan({ + ...fixture.config, + bands: wideBands, + maximumSteeringCacheBytes: steeringBytesPerBin, + }); + const uncachedPlan = createLocalizationPlan({ + ...fixture.config, + bands: wideBands, + maximumSteeringCacheBytes: 0, + }); + + const first = await localizeMultichannel({ plan: partialPlan, channels: fixture.channels }); + const second = await localizeMultichannel({ plan: partialPlan, channels: fixture.channels }); + const uncached = await localizeMultichannel({ plan: uncachedPlan, channels: fixture.channels }); + const cache = second.analysisCache; + + assertFloat32Exact(first.bands[0].rawMap, uncached.bands[0].rawMap, "partial cache raw map"); + assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "partial repeat raw map"); + assertEqual(cache.steeringCacheBinCapacity, 1, "one Float64 steering bin retained"); + assertEqual(cache.steeringCachedBinCount, 1, "one steering matrix built"); + assertEqual(cache.steeringUncachedBinCount, 1, "one selected bin remains uncached"); + assertEqual(cache.steeringCacheBytes, steeringBytesPerBin, "steering byte budget used exactly"); + assertEqual(cache.requestedSteeringCacheBytes, steeringBytesPerBin * 2, "full request reported"); + assertEqual(cache.steeringMatrixBuilds, 1, "cached bin built once"); + assertEqual(cache.steeringCacheHits, 1, "cached bin reused once"); + assertEqual(cache.uncachedSteeringUses, 2, "uncached bin computed once per analysis"); + assertEqual( + cache.uncachedSteeringPointMicrophonePairs, + pointMicrophonePairs * 2, + "uncached steering operations counted", + ); + assertEqual(cache.stftWindowBuilds, 1, "Hann window built once"); + assertEqual(cache.stftWorkspaceAllocations, 1, "FFT workspace allocated once"); + assertEqual(cache.stftWorkspaceUses, 2, "workspace used by both analyses"); + assertEqual(cache.avoidedStftWindowBuilds, 1, "second Hann build avoided"); + assertEqual(cache.avoidedStftWorkspaceAllocations, 1, "second FFT allocation avoided"); + assertEqual(cache.stftTransforms, 24, "STFT transform operation count"); + assertEqual(cache.fftButterflies, 4608, "FFT butterfly operation count"); + assertEqual(cache.beamformingComplexMacs, 720, "beamforming operation count"); + assertEqual(cache.binMapAllocations, 1, "one reusable bin map allocation"); + assertEqual(cache.steeringWorkspaceAllocations, 1, "one reusable steering workspace"); + assert(cache.retainedBytes <= partialPlan.dasPlan.maximumPlanBytes, "plan hard cap retained"); +}); + +test("portable plan은 취소 응답성과 처리량을 균형 잡은 기본 batch를 사용한다", () => { + const fixture = createFixture(128); + const config = { ...fixture.config }; + delete config.computeOptions; + const defaults = createLocalizationPlan(config); + assertEqual(defaults.computeOptions.pointBatchSize, 256, "default point batch"); + assertEqual(defaults.computeOptions.stftTransformBatchSize, 128, "default STFT batch"); + assertEqual(defaults.maxCompensationDb, 24, "default inverse-response boost limit"); + assertEqual(defaults.peakDetection.thresholdRatio, 0.5, "default peak threshold"); + assertEqual(defaults.peakDetection.maximumDetections, 8, "default peak count bound"); + + const custom = createLocalizationPlan({ + ...config, + computeOptions: { pointBatchSize: 7, stftTransformBatchSize: 9 }, + }); + assertEqual(custom.computeOptions.pointBatchSize, 7, "point batch override"); + assertEqual(custom.computeOptions.stftTransformBatchSize, 9, "STFT batch override"); + + const calibrated = createLocalizationPlan({ + ...config, + maxCompensationDb: 0, + peakDetection: { + thresholdRatio: 0.25, + minimumPeakDistanceM: 0, + maximumDetections: 3, + }, + }); + assertEqual(calibrated.maxCompensationDb, 0, "portable compensation alias"); + assertEqual(calibrated.peakDetection.thresholdRatio, 0.25, "custom peak threshold"); + assertEqual(calibrated.peakDetection.minimumPeakDistanceM, 0, "custom peak spacing"); + assertEqual(calibrated.peakDetection.maximumDetections, 3, "custom peak count"); + + for (const invalidConfig of [ + { ...config, maxCompensationDb: 121 }, + { ...config, peakDetection: { thresholdRatio: -0.1 } }, + { ...config, peakDetection: { maximumDetections: 129 } }, + ]) { + let caught; + try { + createLocalizationPlan(invalidConfig); + } catch (error) { + caught = error; + } + assert(caught instanceof RangeError, "invalid portable localization option rejected"); + } +}); + test("streaming localizer 결과가 임의 chunk 경계와 무관하다", async () => { const fixture = createFixture(320); const streamConfig = { @@ -184,6 +317,32 @@ test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지 planLimitError.message.includes("maximumPlanBytes"), "analysis plan allocation hard limit", ); + let calibrationLimitError; + try { + createLocalizationPlan({ + ...fixture.config, + maximumSteeringCacheBytes: 0, + maximumPlanBytes: 1963, + }); + } catch (error) { + calibrationLimitError = error; + } + assert( + calibrationLimitError instanceof RangeError && + calibrationLimitError.message.includes("calibration bytes"), + "all calibration arrays are reserved by the plan hard limit", + ); + const exactMemoryPlan = createLocalizationPlan({ + ...fixture.config, + maximumSteeringCacheBytes: 0, + maximumPlanBytes: 1964, + }); + const exactMemoryResult = await localizeMultichannel({ + plan: exactMemoryPlan, + channels: fixture.channels, + }); + assertEqual(exactMemoryPlan.dasPlan.calibrationBytes, 64, "calibration byte reservation"); + assertEqual(exactMemoryResult.analysisCache.retainedBytes, 1964, "exact retained plan bytes"); const invalidResponseConfig = structuredClone(fixture.config); invalidResponseConfig.array.microphones[0].frequencyResponse = [ { frequencyHz: 1000, gainDb: 0 }, @@ -234,6 +393,23 @@ test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지 const boundedResults = await bounded.analyzeAvailable(); assertEqual(boundedResults.length, 1, "one bounded result"); assertEqual(boundedResults[0].startSample, 272, "latest retained window analyzed"); + assertEqual(boundedResults[0].deadlineMs, 64 / fixture.sampleRate * 1000, "hop deadline"); + assertEqual(typeof boundedResults[0].deadlineMet, "boolean", "deadline verdict"); + const boundedState = bounded.getState(); + assertEqual( + boundedState.lastProcessingTimeMs, + boundedResults[0].processingTimeMs, + "last processing time telemetry", + ); + assert( + boundedState.maximumProcessingTimeMs >= boundedState.lastProcessingTimeMs, + "maximum processing time telemetry", + ); + assertEqual( + boundedState.deadlineMisses, + boundedResults[0].deadlineMet ? 0 : 1, + "deadline miss telemetry", + ); const resultBound = createStreamingLocalizer({ ...fixture.config, @@ -281,5 +457,70 @@ test("streaming 분석 취소가 AbortError를 전달하고 계획 lock을 해 assertEqual(caught?.name, "AbortError", "AbortError propagated"); assertEqual(stream.getState().busy, false, "stream lock released"); assertEqual(stream.plan.dasPlan.busy, false, "DAS plan lock released"); - assertEqual(stream.getState().failedAnalyses, 1, "failed analysis counted"); + let state = stream.getState(); + assertEqual(state.bufferStartSample, 0, "failed window origin retained"); + assertEqual(state.bufferedSamples, 128, "failed window samples retained"); + assertEqual(state.availableAnalysisCount, 1, "failed window remains retryable"); + assertEqual(state.analysisAttempts, 1, "failed attempt counted"); + assertEqual(state.completedAnalyses, 0, "no completed analysis on abort"); + assertEqual(state.failedAnalyses, 1, "failed analysis counted"); + assertEqual(state.nextSequence, 0, "sequence is not consumed by abort"); + + const retried = await stream.analyzeAvailable(); + assertEqual(retried.length, 1, "failed window retried once"); + assertEqual(retried[0].startSample, 0, "same window retried"); + assertEqual(retried[0].sequence, 0, "first success receives first sequence"); + state = stream.getState(); + assertEqual(state.bufferStartSample, 64, "hop committed after retry success"); + assertEqual(state.bufferedSamples, 64, "only successful hop consumed"); + assertEqual(state.analysisAttempts, 2, "retry attempt counted"); + assertEqual(state.completedAnalyses, 1, "retry completion counted"); + assertEqual(state.failedAnalyses, 1, "historical failure retained"); + assertEqual(state.nextSequence, 1, "sequence committed after success"); +}); + +test("onResult 예외는 성공한 window를 실패나 재시도로 되돌리지 않는다", async () => { + const fixture = createFixture(128); + const stream = createStreamingLocalizer({ + ...fixture.config, + analysisWindowSamples: 128, + analysisHopSamples: 64, + maximumBufferedSamples: 256, + maximumResultsPerCall: 1, + }); + stream.append(fixture.channels); + let callbackResult; + let callbackError; + try { + await stream.analyzeAvailable({ + onResult: (result) => { + callbackResult = result; + throw new Error("consumer failed"); + }, + }); + } catch (error) { + callbackError = error; + } + + assertEqual(callbackError?.message, "consumer failed", "consumer error propagated"); + assertEqual(callbackResult?.sequence, 0, "committed result reached consumer"); + let state = stream.getState(); + assertEqual(state.bufferStartSample, 64, "callback failure does not roll back hop"); + assertEqual(state.bufferedSamples, 64, "callback failure leaves committed buffer"); + assertEqual(state.analysisAttempts, 1, "one localization attempt"); + assertEqual(state.completedAnalyses, 1, "localization remains completed"); + assertEqual(state.failedAnalyses, 0, "consumer error is not localization failure"); + assertEqual(state.nextSequence, 1, "sequence remains committed"); + + stream.append(sliceChannels(fixture.channels, 64, 128)); + const next = await stream.analyzeAvailable(); + assertEqual(next[0].startSample, 64, "next window advances instead of retrying"); + assertEqual(next[0].sequence, 1, "next sequence remains contiguous"); + state = stream.getState(); + assertEqual(state.bufferStartSample, 128, "second successful hop committed"); + assertEqual(state.bufferedSamples, 64, "overlap retained after second window"); + assertEqual(state.analysisAttempts, 2, "second attempt counted"); + assertEqual(state.completedAnalyses, 2, "second completion counted"); + assertEqual(state.failedAnalyses, 0, "no false failure after recovery"); + assertEqual(state.nextSequence, 2, "sequence advanced twice"); }); diff --git a/tests/unit/webgpu.test.js b/tests/unit/webgpu.test.js index bed01b5..fe88210 100644 --- a/tests/unit/webgpu.test.js +++ b/tests/unit/webgpu.test.js @@ -109,6 +109,31 @@ test("WebGPU STFT packing이 microphone 주파수 응답을 bin별로 역보정 }); assertEqual(uncompensated[0], 0, "disabled compensation real"); assertEqual(uncompensated[1], 2, "disabled compensation imaginary"); + + const notchedMicrophones = [{ + frequencyResponse: [ + { frequencyHz: 0, gainDb: -120 }, + { frequencyHz: 4000, gainDb: -120 }, + ], + }]; + const notched = { + frameCount: 1, + microphoneCount: 1, + spectra: new Map([[8, { real: Float32Array.of(1), imaginary: Float32Array.of(0) }]]), + }; + const limited = packStftBatch(notched, [8], { + microphones: notchedMicrophones, + sampleRate: 8000, + fftSize: 256, + }); + const explicitLegacyLimit = packStftBatch(notched, [8], { + microphones: notchedMicrophones, + sampleRate: 8000, + fftSize: 256, + maxCompensationDb: 120, + }); + assertNear(limited[0], 10 ** (24 / 20), 1e-5, "default notch boost limit"); + assert(explicitLegacyLimit[0] > 999_000, "explicit 120 dB notch boost"); }); test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {