perf: harden realtime localization pipeline

This commit is contained in:
Codex
2026-08-28 18:58:48 +09:00
parent 35c33e360e
commit 256558e601
13 changed files with 765 additions and 84 deletions
+40 -9
View File
@@ -24,6 +24,8 @@ const localizer = createStreamingLocalizer({
scan: { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 50, rows: 50 }, scan: { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 50, rows: 50 },
bands: [{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" }], bands: [{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" }],
stft: { fftSize: 512, hopSize: 256 }, stft: { fftSize: 512, hopSize: 256 },
maxCompensationDb: 24,
peakDetection: { thresholdRatio: 0.5, maximumDetections: 8 },
analysisWindowSamples: 4_800, analysisWindowSamples: 4_800,
analysisHopSamples: 4_800, analysisHopSamples: 4_800,
maximumBufferedSamples: 19_200, maximumBufferedSamples: 19_200,
@@ -38,20 +40,47 @@ const results = await localizer.analyzeAvailable({ signal: abortController.signa
for (const result of results) { for (const result of results) {
const { position, rawPower } = result.locations[0]; const { position, rawPower } = result.locations[0];
drawMarker(position, rawPower, result.centerTime); 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()`을 사용합니다. 한 블록만 처리할 때는 `createLocalizationPlan()`과 `localizeMultichannel()`을 사용합니다.
`getState()`은 absolute sample origin, buffered/dropped sample, 대기 window 수와 고정 input `getState()`은 absolute sample origin, buffered/dropped sample, 대기 window 수와 고정 input
buffer byte 수를 돌려주므로 capture adapter가 지연을 감시할 수 있습니다. 실제 장치의 clock, buffer byte 수를 돌려주므로 capture adapter가 지연을 감시할 수 있습니다. 실제 장치의 clock,
channel framing과 transport는 하드웨어별 adapter 책임이며 이 모듈은 그 다음 단계부터 시작합니다. channel framing과 transport는 하드웨어별 adapter 책임이며 이 모듈은 그 다음 단계부터 시작합니다.
반복 CPU 분석 계획은 거리 행렬, bin calibration, 제한 내의 Float64 steering matrix와 임시 bin map을 반복 CPU 분석 계획은 거리 행렬, bin calibration, Hann window, Float64 FFT workspace, 임시 bin map과
재사용합니다. 예를 들어 192채널·50×50·2 bins를 10회 분석하면 거리 계산은 4,800,000회에서 예산 내의 Float64 steering matrix를 재사용합니다. 예를 들어 192채널·50×50·2 bins를 10회 분석하면
480,000회로, steering point/microphone pair는 9,600,000회에서 960,000회로 줄어듭니다 거리 계산은 4,800,000회에서 480,000회로, steering point/microphone pair는 9,600,000회에서
(sin/cos 호출 17,280,000회 회피). 이 경우 약 16.5 MiB를 유지하며, 기본 64 MiB steering 한도를 960,000회로 줄어듭니다(sin/cos 호출 17,280,000회 회피). 이 경우 약 16.5 MiB를 유지합니다.
넘는 설정은 steering cache만 끕니다. 자동 테스트는 cache 전후 raw/display Float32 map의 전 요소
bit-exact 일치를 고정합니다. 기본 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에서는 알려진 마이크 `sensitivityDb`와 `phaseOffsetDeg`는 수신 channel에 편차로 적용하고, DAS에서는 알려진
calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용 normalized map은 서로 다른 calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용 normalized map은 서로 다른
buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를 buffer로 보존합니다. bin spectrum은 제한된 batch로 처리합니다. cooperative yield는 지원 환경에서
양보해 진행 callback과 `AbortSignal` 취소가 동작합니다. `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? }` 점으로 정의합니다. 마이크별 `frequencyResponse`는 2~64개의 `{ frequencyHz, gainDb, phaseDeg? }` 점으로 정의합니다.
`interpolateFrequencyResponse`는 dB와 wrapped phase를 구간 선형 보간하고 측정 범위 밖에서는 가장 `interpolateFrequencyResponse`는 dB와 wrapped phase를 구간 선형 보간하고 측정 범위 밖에서는 가장
@@ -112,6 +143,6 @@ Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면
snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을 snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을
512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로 512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로
전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB 전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
hard cap을 할당 전에 검사하며 steering cache는 별도의 64 MiB 한도를 넘으면 자동으로 끕니다. hard cap을 할당 전에 검사하며 steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지합니다.
192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다. 192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다.
@@ -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));
}
+125 -28
View File
@@ -1,6 +1,11 @@
import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js"; import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js";
import { interpolateFrequencyResponse } from "./frequency-response.js"; import {
import { binsForBand, extractStftBins, getFrameCount } from "./stft.js"; interpolateFrequencyResponse,
inverseResponseGainDb,
requireMaxCompensationDb,
} from "./frequency-response.js";
import { cooperativeYield } from "./cooperative-yield.js";
import { binsForBand, createStftWorkspace, extractStftBins, getFrameCount } from "./stft.js";
function throwIfAborted(signal) { function throwIfAborted(signal) {
if (signal?.aborted) { 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_STEERING_CACHE_BYTES = 64 * 1024 * 1024;
export const DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES = 512 * 1024 * 1024; export const DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES = 512 * 1024 * 1024;
const DAS_ANALYSIS_PLAN_KIND = "micarrsim.das-analysis-plan/1"; const DAS_ANALYSIS_PLAN_KIND = "micarrsim.das-analysis-plan/1";
@@ -47,12 +48,20 @@ function createBandIndexLookup(bandBins) {
return lookup; return lookup;
} }
function createBinCalibration(arrayDefinition, bin, sampleRate, fftSize, compensateFrequencyResponse) { function createBinCalibration(
arrayDefinition,
bin,
sampleRate,
fftSize,
compensateFrequencyResponse,
maximumCompensationDb,
) {
const microphoneCount = arrayDefinition.microphones.length; const microphoneCount = arrayDefinition.microphones.length;
const gains = new Float64Array(microphoneCount); const gains = new Float64Array(microphoneCount);
const phases = new Float64Array(microphoneCount); const phases = new Float64Array(microphoneCount);
const frequencyHz = (bin * sampleRate) / fftSize; const frequencyHz = (bin * sampleRate) / fftSize;
const response = {}; const response = {};
const maxCompensationDb = requireMaxCompensationDb(maximumCompensationDb);
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const microphone = arrayDefinition.microphones[microphoneIndex]; const microphone = arrayDefinition.microphones[microphoneIndex];
let responseGainDb = 0; let responseGainDb = 0;
@@ -62,8 +71,11 @@ function createBinCalibration(arrayDefinition, bin, sampleRate, fftSize, compens
responseGainDb = response.gainDb; responseGainDb = response.gainDb;
responsePhaseDeg = response.phaseDeg; responsePhaseDeg = response.phaseDeg;
} }
const responseCompensationDb = compensateFrequencyResponse
? inverseResponseGainDb(responseGainDb, maxCompensationDb)
: 0;
gains[microphoneIndex] = dbToLinear( gains[microphoneIndex] = dbToLinear(
-(microphone.sensitivityDb ?? 0) - responseGainDb, -(microphone.sensitivityDb ?? 0) + responseCompensationDb,
); );
phases[microphoneIndex] = phases[microphoneIndex] =
((microphone.phaseOffsetDeg ?? 0) + responsePhaseDeg) * Math.PI / 180; ((microphone.phaseOffsetDeg ?? 0) + responsePhaseDeg) * Math.PI / 180;
@@ -119,7 +131,9 @@ async function createSteeringMatrix(options) {
outputImaginary: imaginary, outputImaginary: imaginary,
outputOffset: offset, outputOffset: offset,
}); });
if ((pointIndex + 1) % pointBatchSize === 0) await yieldToEventLoop(); if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) {
await cooperativeYield();
}
} }
return { real, imaginary }; return { real, imaginary };
} }
@@ -136,6 +150,7 @@ export async function computeBinMap(options) {
fftSize, fftSize,
speedOfSound, speedOfSound,
compensateFrequencyResponse = true, compensateFrequencyResponse = true,
maxCompensationDb,
calibration: providedCalibration, calibration: providedCalibration,
steeringMatrix, steeringMatrix,
output: providedOutput, output: providedOutput,
@@ -156,6 +171,7 @@ export async function computeBinMap(options) {
sampleRate, sampleRate,
fftSize, fftSize,
compensateFrequencyResponse, compensateFrequencyResponse,
maxCompensationDb,
); );
const phaseRealValues = steeringMatrix?.real ?? steeringReal; const phaseRealValues = steeringMatrix?.real ?? steeringReal;
const phaseImaginaryValues = steeringMatrix?.imaginary ?? steeringImaginary; const phaseImaginaryValues = steeringMatrix?.imaginary ?? steeringImaginary;
@@ -212,7 +228,9 @@ export async function computeBinMap(options) {
accumulatedPower += (sumReal * sumReal + sumImaginary * sumImaginary) * microphoneScale * microphoneScale; accumulatedPower += (sumReal * sumReal + sumImaginary * sumImaginary) * microphoneScale * microphoneScale;
} }
output[pointIndex] = accumulatedPower / frameCount; output[pointIndex] = accumulatedPower / frameCount;
if ((pointIndex + 1) % pointBatchSize === 0) await yieldToEventLoop(); if ((pointIndex + 1) % pointBatchSize === 0 && pointIndex + 1 < pointCount) {
await cooperativeYield();
}
} }
return output; return output;
} }
@@ -255,32 +273,45 @@ export function createDasAnalysisPlan(options) {
const pointCount = experiment.scan.columns * experiment.scan.rows; const pointCount = experiment.scan.columns * experiment.scan.rows;
const pointMicrophonePairs = pointCount * microphoneCount; const pointMicrophonePairs = pointCount * microphoneCount;
const distanceMatrixBytes = pointMicrophonePairs * Float32Array.BYTES_PER_ELEMENT; 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 = const retainedBaseBytes =
distanceMatrixBytes + distanceMatrixBytes +
pointCount * Float32Array.BYTES_PER_ELEMENT + pointCount * Float32Array.BYTES_PER_ELEMENT +
microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2 +
stftWorkspaceBytes;
const retainedNonSteeringBytes = retainedBaseBytes + calibrationBytes;
if ( if (
!Number.isSafeInteger(pointMicrophonePairs) || !Number.isSafeInteger(pointMicrophonePairs) ||
!Number.isSafeInteger(retainedBaseBytes) || !Number.isSafeInteger(retainedBaseBytes) ||
retainedBaseBytes > maximumPlanBytes !Number.isSafeInteger(calibrationBytes) ||
!Number.isSafeInteger(retainedNonSteeringBytes) ||
retainedNonSteeringBytes > maximumPlanBytes
) { ) {
throw new RangeError( throw new RangeError(
"CPU analysis plan requires " + retainedBaseBytes.toLocaleString() + "CPU analysis plan requires " + retainedNonSteeringBytes.toLocaleString() +
" base bytes, above maximumPlanBytes " + maximumPlanBytes.toLocaleString() + ".", " non-steering bytes including " + calibrationBytes.toLocaleString() +
" calibration bytes, above maximumPlanBytes " +
maximumPlanBytes.toLocaleString() + ".",
); );
} }
const distances = createDistanceMatrix(arrayDefinition, experiment.scan); const distances = createDistanceMatrix(arrayDefinition, experiment.scan);
const binMap = new Float32Array(pointCount); const binMap = new Float32Array(pointCount);
const steeringReal = new Float64Array(microphoneCount); const steeringReal = new Float64Array(microphoneCount);
const steeringImaginary = new Float64Array(microphoneCount); const steeringImaginary = new Float64Array(microphoneCount);
const stftWorkspace = createStftWorkspace(fftSize);
const steeringBytesPerBin = const steeringBytesPerBin =
pointCount * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; pointCount * microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
const requestedSteeringCacheBytes = steeringBytesPerBin * selectedBins.length; const requestedSteeringCacheBytes = steeringBytesPerBin * selectedBins.length;
const steeringCacheEnabled = const steeringCacheBinCapacity = Math.min(
Number.isSafeInteger(requestedSteeringCacheBytes) && selectedBins.length,
requestedSteeringCacheBytes > 0 && Math.floor(maximumSteeringCacheBytes / steeringBytesPerBin),
requestedSteeringCacheBytes <= maximumSteeringCacheBytes && Math.floor((maximumPlanBytes - retainedNonSteeringBytes) / steeringBytesPerBin),
retainedBaseBytes + requestedSteeringCacheBytes <= maximumPlanBytes; );
const steeringCacheBytes = steeringCacheBinCapacity * steeringBytesPerBin;
const steeringCacheEnabled = steeringCacheBinCapacity > 0;
const steeringCachedBins = new Set(selectedBins.slice(0, steeringCacheBinCapacity));
return { return {
kind: DAS_ANALYSIS_PLAN_KIND, kind: DAS_ANALYSIS_PLAN_KIND,
@@ -297,13 +328,20 @@ export function createDasAnalysisPlan(options) {
calibrations: new Map(), calibrations: new Map(),
steeringMatrices: new Map(), steeringMatrices: new Map(),
steeringCacheEnabled, steeringCacheEnabled,
steeringCacheBinCapacity,
steeringCachedBins,
steeringBytesPerBin, steeringBytesPerBin,
steeringCacheBytes,
requestedSteeringCacheBytes, requestedSteeringCacheBytes,
retainedBaseBytes,
calibrationBytes,
retainedNonSteeringBytes,
maximumSteeringCacheBytes, maximumSteeringCacheBytes,
maximumPlanBytes, maximumPlanBytes,
binMap, binMap,
steeringReal, steeringReal,
steeringImaginary, steeringImaginary,
stftWorkspace,
busy: false, busy: false,
statistics: { statistics: {
analysisAttempts: 0, analysisAttempts: 0,
@@ -315,8 +353,20 @@ export function createDasAnalysisPlan(options) {
calibrationCacheHits: 0, calibrationCacheHits: 0,
steeringMatrixBuilds: 0, steeringMatrixBuilds: 0,
steeringCacheHits: 0, steeringCacheHits: 0,
uncachedSteeringUses: 0,
steeringPointMicrophonePairs: 0, steeringPointMicrophonePairs: 0,
uncachedSteeringPointMicrophonePairs: 0,
avoidedSteeringPointMicrophonePairs: 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, binMapUses: 0,
avoidedBinMapAllocations: 0, avoidedBinMapAllocations: 0,
retainedBytes: retainedBaseBytes, retainedBytes: retainedBaseBytes,
@@ -350,6 +400,7 @@ function planCalibration(plan, bin) {
plan.sampleRate, plan.sampleRate,
plan.experiment.stft.fftSize, plan.experiment.stft.fftSize,
plan.experiment.beamforming.compensateFrequencyResponse !== false, plan.experiment.beamforming.compensateFrequencyResponse !== false,
plan.experiment.beamforming.maxCompensationDb,
); );
plan.calibrations.set(bin, calibration); plan.calibrations.set(bin, calibration);
plan.statistics.calibrationBuilds += 1; plan.statistics.calibrationBuilds += 1;
@@ -358,7 +409,12 @@ function planCalibration(plan, bin) {
} }
async function planSteeringMatrix(plan, bin, calibration, signal, pointBatchSize) { 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); let matrix = plan.steeringMatrices.get(bin);
if (matrix) { if (matrix) {
plan.statistics.steeringCacheHits += 1; plan.statistics.steeringCacheHits += 1;
@@ -393,12 +449,31 @@ function analysisCacheSummary(plan) {
avoidedDistanceEvaluations: plan.statistics.avoidedDistanceEvaluations, avoidedDistanceEvaluations: plan.statistics.avoidedDistanceEvaluations,
calibrationBuilds: plan.statistics.calibrationBuilds, calibrationBuilds: plan.statistics.calibrationBuilds,
calibrationCacheHits: plan.statistics.calibrationCacheHits, calibrationCacheHits: plan.statistics.calibrationCacheHits,
calibrationBytes: plan.calibrationBytes,
steeringCacheEnabled: plan.steeringCacheEnabled, 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, steeringMatrixBuilds: plan.statistics.steeringMatrixBuilds,
steeringCacheHits: plan.statistics.steeringCacheHits, steeringCacheHits: plan.statistics.steeringCacheHits,
uncachedSteeringUses: plan.statistics.uncachedSteeringUses,
steeringPointMicrophonePairs: plan.statistics.steeringPointMicrophonePairs, steeringPointMicrophonePairs: plan.statistics.steeringPointMicrophonePairs,
uncachedSteeringPointMicrophonePairs:
plan.statistics.uncachedSteeringPointMicrophonePairs,
avoidedSteeringPointMicrophonePairs: avoidedSteeringPointMicrophonePairs:
plan.statistics.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, avoidedBinMapAllocations: plan.statistics.avoidedBinMapAllocations,
retainedBytes: plan.statistics.retainedBytes, retainedBytes: plan.statistics.retainedBytes,
}; };
@@ -419,12 +494,13 @@ export function estimateDasWorkload(options) {
const outputBytes = bandCount * pointCount * Float32Array.BYTES_PER_ELEMENT * 2; const outputBytes = bandCount * pointCount * Float32Array.BYTES_PER_ELEMENT * 2;
const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT; const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT;
const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2;
const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 2; const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
const steeringCacheBytes = analysisPlan?.steeringCacheEnabled const steeringCacheBytes = analysisPlan?.steeringCacheBytes ?? 0;
? analysisPlan.requestedSteeringCacheBytes const calibrationBytes = analysisPlan?.calibrationBytes ??
: 0; microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2;
const baseWorkingBytes = const baseWorkingBytes =
distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes + steeringCacheBytes; distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes +
steeringCacheBytes + calibrationBytes;
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length; const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length;
return { return {
bandBins, bandBins,
@@ -434,7 +510,10 @@ export function estimateDasWorkload(options) {
pointCount, pointCount,
distanceBytes, distanceBytes,
outputBytes, outputBytes,
fftWorkspaceBytes,
steeringCacheBytes, steeringCacheBytes,
calibrationBytes,
steeringCacheBinCapacity: analysisPlan?.steeringCacheBinCapacity ?? 0,
spectrumBytesPerBin, spectrumBytesPerBin,
baseWorkingBytes, baseWorkingBytes,
complexMacCount, complexMacCount,
@@ -537,16 +616,21 @@ async function computeDasMapInternal(options, analysisPlan) {
let completedBins = 0; let completedBins = 0;
for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) { for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) {
throwIfAborted(signal);
await yieldToEventLoop();
throwIfAborted(signal); throwIfAborted(signal);
const batchBins = workload.selectedBins.slice(batchStart, batchStart + batchSize); 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, { const stft = await extractStftBins(channels, {
fftSize: experiment.stft.fftSize, fftSize: experiment.stft.fftSize,
hopSize: experiment.stft.hopSize, hopSize: experiment.stft.hopSize,
bins: batchBins, bins: batchBins,
signal, signal,
transformBatchSize: stftTransformBatchSize, transformBatchSize: stftTransformBatchSize,
workspace: analysisPlan?.stftWorkspace,
onProgress: ({ completedTransforms, totalTransforms }) => onProgress: ({ completedTransforms, totalTransforms }) =>
onProgress?.({ onProgress?.({
stage: "stft", stage: "stft",
@@ -558,7 +642,15 @@ async function computeDasMapInternal(options, analysisPlan) {
spectrumBatchCount, 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); throwIfAborted(signal);
for (const bin of batchBins) { for (const bin of batchBins) {
@@ -585,6 +677,7 @@ async function computeDasMapInternal(options, analysisPlan) {
speedOfSound: scene.speedOfSound, speedOfSound: scene.speedOfSound,
compensateFrequencyResponse: compensateFrequencyResponse:
experiment.beamforming.compensateFrequencyResponse !== false, experiment.beamforming.compensateFrequencyResponse !== false,
maxCompensationDb: experiment.beamforming.maxCompensationDb,
calibration, calibration,
steeringMatrix, steeringMatrix,
output: analysisPlan?.binMap, output: analysisPlan?.binMap,
@@ -593,6 +686,10 @@ async function computeDasMapInternal(options, analysisPlan) {
signal, signal,
pointBatchSize, pointBatchSize,
}); });
if (analysisPlan) {
analysisPlan.statistics.beamformingComplexMacs +=
workload.pointCount * workload.microphoneCount * stft.frameCount;
}
for (const bandIndex of bandIndexesByBin.get(bin)) { for (const bandIndex of bandIndexesByBin.get(bin)) {
const rawMap = rawMaps[bandIndex]; const rawMap = rawMaps[bandIndex];
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
@@ -2,6 +2,33 @@ import { dbToLinear } from "./math.js";
export const MAX_FREQUENCY_RESPONSE_POINTS = 64; export const MAX_FREQUENCY_RESPONSE_POINTS = 64;
export const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000; 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) { function phaseAt(point) {
return point.phaseDeg ?? 0; return point.phaseDeg ?? 0;
+168 -33
View File
@@ -1,13 +1,18 @@
import { computeDasMap, createDasAnalysisPlan } from "./das.js"; import { computeDasMap, createDasAnalysisPlan } from "./das.js";
import { findLocalPeaks } from "./evaluation.js";
import { import {
DEFAULT_MAX_COMPENSATION_DB,
MAX_FREQUENCY_RESPONSE_HZ, MAX_FREQUENCY_RESPONSE_HZ,
MAX_FREQUENCY_RESPONSE_POINTS, MAX_FREQUENCY_RESPONSE_POINTS,
requireMaxCompensationDb,
} from "./frequency-response.js"; } from "./frequency-response.js";
export const REALTIME_LOCALIZATION_PLAN_KIND = "micarrsim.realtime-localization-plan/1"; export const REALTIME_LOCALIZATION_PLAN_KIND = "micarrsim.realtime-localization-plan/1";
export const DEFAULT_STREAM_RESULTS_PER_CALL = 1; export const DEFAULT_STREAM_RESULTS_PER_CALL = 1;
export const MAX_STREAM_RESULTS_PER_CALL = 32; export const MAX_STREAM_RESULTS_PER_CALL = 32;
export const DEFAULT_MAXIMUM_STREAM_INPUT_BYTES = 256 * 1024 * 1024; 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) { function requirePositiveFinite(value, label) {
if (!Number.isFinite(value) || value <= 0) { if (!Number.isFinite(value) || value <= 0) {
@@ -31,6 +36,10 @@ function requirePowerOfTwo(value, label) {
return value; return value;
} }
function monotonicNow() {
return globalThis.performance?.now?.() ?? Date.now();
}
function freezePosition(position, label) { function freezePosition(position, label) {
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) { if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
throw new TypeError(`${label} must be a finite [x, y, z] array.`); 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) { function validateChannels(channels, expectedChannelCount) {
if (!Array.isArray(channels) || channels.length !== expectedChannelCount) { if (!Array.isArray(channels) || channels.length !== expectedChannelCount) {
throw new Error( throw new Error(
@@ -182,13 +213,42 @@ function throwIfAborted(signal) {
if (signal?.aborted) throw createAbortError(signal); if (signal?.aborted) throw createAbortError(signal);
} }
function locationsFromBands(bands) { export function detectBandPeaks(rawMap, scan, options = {}) {
return bands.map((band) => ({ const peakDetection = copyPeakDetection(options, scan);
bandId: band.id, const localPeaks = findLocalPeaks(rawMap, scan);
index: band.peak.index, const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
position: [...band.peak.position], const detections = [];
rawPower: band.peak.rawPower, 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 sampleRate = requirePositiveFinite(config.sampleRate, "sampleRate");
const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition); const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition);
const scan = copyScan(config.scan); const scan = copyScan(config.scan);
const peakDetection = copyPeakDetection(config.peakDetection, scan);
const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate); const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate);
const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize"); const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize");
const hopSize = requirePositiveInteger( const hopSize = requirePositiveInteger(
@@ -221,13 +282,22 @@ export function createLocalizationPlan(config) {
if (typeof compensateFrequencyResponse !== "boolean") { if (typeof compensateFrequencyResponse !== "boolean") {
throw new TypeError("compensateFrequencyResponse must be 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 scene = Object.freeze({ speedOfSound });
const experiment = Object.freeze({ const experiment = Object.freeze({
stft: Object.freeze({ fftSize, hopSize, window: "hann" }), stft: Object.freeze({ fftSize, hopSize, window: "hann" }),
scan, scan,
frequencyBands, frequencyBands,
beamforming: Object.freeze({ normalization, compensateFrequencyResponse }), beamforming: Object.freeze({
normalization,
compensateFrequencyResponse,
maxCompensationDb,
}),
}); });
const dasPlan = createDasAnalysisPlan({ const dasPlan = createDasAnalysisPlan({
sampleRate, sampleRate,
@@ -245,13 +315,19 @@ export function createLocalizationPlan(config) {
arrayDefinition, arrayDefinition,
scan, scan,
bands: frequencyBands, bands: frequencyBands,
peakDetection,
stft: experiment.stft, stft: experiment.stft,
speedOfSound, speedOfSound,
normalization, normalization,
maxCompensationDb,
scene, scene,
experiment, experiment,
dasPlan, 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); const sampleCount = validateChannels(options.channels, plan.channelCount);
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample."); if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
throwIfAborted(options.signal); throwIfAborted(options.signal);
const processingStartedAt = monotonicNow();
const das = await computeDasMap({ const das = await computeDasMap({
...plan.computeOptions, ...plan.computeOptions,
...(options.computeOptions ?? {}), ...(options.computeOptions ?? {}),
@@ -285,12 +362,15 @@ export async function localizeMultichannel(options) {
signal: options.signal, signal: options.signal,
onProgress: options.onProgress, onProgress: options.onProgress,
}); });
const locations = locationsFromBands(das.bands, plan.scan, plan.peakDetection);
const processingTimeMs = Math.max(0, monotonicNow() - processingStartedAt);
return { return {
...das, ...das,
sampleRate: plan.sampleRate, sampleRate: plan.sampleRate,
sampleCount, sampleCount,
duration: sampleCount / plan.sampleRate, 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. * A fixed-capacity chunk buffer for real microphone adapters.
* *
* append() is synchronous and remains safe while an analysis is awaiting CPU * append() is synchronous and remains safe while an analysis is awaiting CPU
* batches. analyzeAvailable() copies one window before yielding, consumes its * batches. analyzeAvailable() copies one window before yielding, commits its
* hop, and returns no more than maximumResultsPerCall maps. * hop only after successful localization, and returns no more than
* maximumResultsPerCall maps.
*/ */
export class StreamingDasLocalizer { export class StreamingDasLocalizer {
constructor(config) { constructor(config) {
@@ -319,6 +400,7 @@ export class StreamingDasLocalizer {
if (this.analysisHopSamples > this.analysisWindowSamples) { if (this.analysisHopSamples > this.analysisWindowSamples) {
throw new RangeError("analysisHopSamples must not exceed analysisWindowSamples."); throw new RangeError("analysisHopSamples must not exceed analysisWindowSamples.");
} }
this.analysisDeadlineMs = this.analysisHopSamples / this.plan.sampleRate * 1000;
this.maximumBufferedSamples = requirePositiveInteger( this.maximumBufferedSamples = requirePositiveInteger(
config.maximumBufferedSamples ?? this.analysisWindowSamples * 4, config.maximumBufferedSamples ?? this.analysisWindowSamples * 4,
"maximumBufferedSamples", "maximumBufferedSamples",
@@ -370,15 +452,37 @@ export class StreamingDasLocalizer {
this.analysisAttempts = 0; this.analysisAttempts = 0;
this.completedAnalyses = 0; this.completedAnalyses = 0;
this.failedAnalyses = 0; this.failedAnalyses = 0;
this.lastProcessingTimeMs = null;
this.maximumProcessingTimeMs = 0;
this.deadlineMisses = 0;
this.nextSequence = 0; this.nextSequence = 0;
this.pendingAnalysis = null;
this.busy = false; this.busy = false;
} }
availableAnalysisCount() { availableAnalysisCount() {
if (this.bufferedSamples < this.analysisWindowSamples) return 0; const bufferEndSample = this.bufferStartSample + this.bufferedSamples;
return 1 + Math.floor( if (!this.pendingAnalysis) {
(this.bufferedSamples - this.analysisWindowSamples) / this.analysisHopSamples, 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() { getState() {
@@ -387,6 +491,7 @@ export class StreamingDasLocalizer {
channelCount: this.plan.channelCount, channelCount: this.plan.channelCount,
analysisWindowSamples: this.analysisWindowSamples, analysisWindowSamples: this.analysisWindowSamples,
analysisHopSamples: this.analysisHopSamples, analysisHopSamples: this.analysisHopSamples,
analysisDeadlineMs: this.analysisDeadlineMs,
maximumBufferedSamples: this.maximumBufferedSamples, maximumBufferedSamples: this.maximumBufferedSamples,
maximumInputBufferBytes: this.maximumInputBufferBytes, maximumInputBufferBytes: this.maximumInputBufferBytes,
maximumResultsPerCall: this.maximumResultsPerCall, maximumResultsPerCall: this.maximumResultsPerCall,
@@ -398,6 +503,9 @@ export class StreamingDasLocalizer {
analysisAttempts: this.analysisAttempts, analysisAttempts: this.analysisAttempts,
completedAnalyses: this.completedAnalyses, completedAnalyses: this.completedAnalyses,
failedAnalyses: this.failedAnalyses, failedAnalyses: this.failedAnalyses,
lastProcessingTimeMs: this.lastProcessingTimeMs,
maximumProcessingTimeMs: this.maximumProcessingTimeMs,
deadlineMisses: this.deadlineMisses,
nextSequence: this.nextSequence, nextSequence: this.nextSequence,
busy: this.busy, busy: this.busy,
retainedInputBytes: this.retainedInputBytes, retainedInputBytes: this.retainedInputBytes,
@@ -481,39 +589,62 @@ export class StreamingDasLocalizer {
try { try {
while ( while (
results.length < maximumResults && results.length < maximumResults &&
this.bufferedSamples >= this.analysisWindowSamples (this.pendingAnalysis || this.bufferedSamples >= this.analysisWindowSamples)
) { ) {
throwIfAborted(options.signal); throwIfAborted(options.signal);
const startSample = this.bufferStartSample; const retry = this.pendingAnalysis;
const startSample = retry?.startSample ?? this.bufferStartSample;
const endSample = startSample + this.analysisWindowSamples; const endSample = startSample + this.analysisWindowSamples;
this.copyAnalysisWindow(); if (!retry) this.copyAnalysisWindow();
this.consume(this.analysisHopSamples, false);
this.analysisAttempts += 1; this.analysisAttempts += 1;
let localized;
try { try {
const localized = await localizeMultichannel({ localized = await localizeMultichannel({
plan: this.plan, plan: this.plan,
channels: this.analysisChannels, channels: this.analysisChannels,
signal: options.signal, signal: options.signal,
onProgress: options.onProgress, onProgress: options.onProgress,
computeOptions: options.computeOptions, 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) { } catch (error) {
this.pendingAnalysis = { startSample, endSample };
this.failedAnalyses += 1; this.failedAnalyses += 1;
throw error; 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; return results;
} finally { } finally {
@@ -536,7 +667,11 @@ export class StreamingDasLocalizer {
this.analysisAttempts = 0; this.analysisAttempts = 0;
this.completedAnalyses = 0; this.completedAnalyses = 0;
this.failedAnalyses = 0; this.failedAnalyses = 0;
this.lastProcessingTimeMs = null;
this.maximumProcessingTimeMs = 0;
this.deadlineMisses = 0;
this.nextSequence = 0; this.nextSequence = 0;
this.pendingAnalysis = null;
} }
} }
+43 -10
View File
@@ -1,4 +1,5 @@
import { fftInPlace } from "./fft.js"; import { fftInPlace } from "./fft.js";
import { cooperativeYield } from "./cooperative-yield.js";
function throwIfAborted(signal) { function throwIfAborted(signal) {
if (signal?.aborted) { if (signal?.aborted) {
@@ -8,10 +9,6 @@ function throwIfAborted(signal) {
} }
} }
function yieldToEventLoop() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
export function hannWindow(size) { export function hannWindow(size) {
if (!Number.isInteger(size) || size < 2) throw new Error("Hann window 크기는 2 이상이어야 합니다."); if (!Number.isInteger(size) || size < 2) throw new Error("Hann window 크기는 2 이상이어야 합니다.");
const window = new Float64Array(size); const window = new Float64Array(size);
@@ -21,6 +18,32 @@ export function hannWindow(size) {
return window; 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) { export function getFrameCount(sampleCount, fftSize, hopSize) {
if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) { if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) {
throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다."); throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다.");
@@ -29,7 +52,15 @@ export function getFrameCount(sampleCount, fftSize, hopSize) {
} }
export async function extractStftBins(channels, options) { 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("마이크 채널이 필요합니다."); if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다.");
const sampleCount = channels[0].length; const sampleCount = channels[0].length;
if (channels.some((channel) => channel.length !== sampleCount)) throw new Error("마이크 채널 길이가 다릅니다."); 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 workspace = resolveStftWorkspace(fftSize, providedWorkspace);
const real = new Float64Array(fftSize); const { window, real, imaginary } = workspace;
const imaginary = new Float64Array(fftSize);
const totalTransforms = frameCount * microphoneCount; const totalTransforms = frameCount * microphoneCount;
let completedTransforms = 0; let completedTransforms = 0;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
@@ -73,9 +103,12 @@ export async function extractStftBins(channels, options) {
spectrum.imaginary[spectrumIndex] = imaginary[bin]; spectrum.imaginary[spectrumIndex] = imaginary[bin];
} }
completedTransforms += 1; completedTransforms += 1;
if (completedTransforms % transformBatchSize === 0) { if (
completedTransforms % transformBatchSize === 0 &&
completedTransforms < totalTransforms
) {
onProgress?.({ completedTransforms, totalTransforms }); onProgress?.({ completedTransforms, totalTransforms });
await yieldToEventLoop(); await cooperativeYield();
throwIfAborted(signal); throwIfAborted(signal);
} }
} }
+8
View File
@@ -455,6 +455,14 @@ function validateExperimentRelationships(value, context = {}) {
"주파수 응답 보정 사용 여부는 true 또는 false여야 합니다.", "주파수 응답 보정 사용 여부는 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)) { if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
const threshold = value.evaluation.detectionThresholdRatio; const threshold = value.evaluation.detectionThresholdRatio;
+10 -2
View File
@@ -3,7 +3,11 @@ import {
estimateDasWorkload, estimateDasWorkload,
finalizeDasBands, finalizeDasBands,
} from "../acoustics-core/das.js"; } 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 { dbToLinear } from "../acoustics-core/math.js";
import { extractStftBins } from "../acoustics-core/stft.js"; import { extractStftBins } from "../acoustics-core/stft.js";
import { import {
@@ -77,6 +81,7 @@ export function packStftBatch(stft, bins, options = {}) {
const packed = new Float32Array(bins.length * valuesPerBin * 2); const packed = new Float32Array(bins.length * valuesPerBin * 2);
const microphones = options.microphones; const microphones = options.microphones;
const compensateFrequencyResponse = options.compensateFrequencyResponse !== false; const compensateFrequencyResponse = options.compensateFrequencyResponse !== false;
const maxCompensationDb = requireMaxCompensationDb(options.maxCompensationDb);
const hasFrequencyResponse = const hasFrequencyResponse =
compensateFrequencyResponse && compensateFrequencyResponse &&
Array.isArray(microphones) && Array.isArray(microphones) &&
@@ -100,7 +105,9 @@ export function packStftBatch(stft, bins, options = {}) {
microphones.forEach((microphone, microphoneIndex) => { microphones.forEach((microphone, microphoneIndex) => {
if (!Array.isArray(microphone.frequencyResponse)) return; if (!Array.isArray(microphone.frequencyResponse)) return;
interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response); interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response);
const inverseGain = dbToLinear(-response.gainDb); const inverseGain = dbToLinear(
inverseResponseGainDb(response.gainDb, maxCompensationDb),
);
const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2; const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2;
inverseResponses[responseOffset] = inverseGain * Math.cos(response.phaseRadians); inverseResponses[responseOffset] = inverseGain * Math.cos(response.phaseRadians);
inverseResponses[responseOffset + 1] = -inverseGain * Math.sin(response.phaseRadians); inverseResponses[responseOffset + 1] = -inverseGain * Math.sin(response.phaseRadians);
@@ -541,6 +548,7 @@ export async function computeDasWebGpu(options) {
fftSize: experiment.stft.fftSize, fftSize: experiment.stft.fftSize,
compensateFrequencyResponse: compensateFrequencyResponse:
experiment.beamforming.compensateFrequencyResponse !== false, experiment.beamforming.compensateFrequencyResponse !== false,
maxCompensationDb: experiment.beamforming.maxCompensationDb,
}); });
stftMs += now() - stftStartedAt; stftMs += now() - stftStartedAt;
const binIndices = Uint32Array.from(batchBins); const binIndices = Uint32Array.from(batchBins);
+2 -1
View File
@@ -46,7 +46,8 @@
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 }, "minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
"normalization": { "enum": ["peak", "none"] }, "normalization": { "enum": ["peak", "none"] },
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }, "dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 },
"compensateFrequencyResponse": { "type": "boolean" } "compensateFrequencyResponse": { "type": "boolean" },
"maxCompensationDb": { "type": "number", "minimum": 0, "maximum": 120 }
} }
}, },
"evaluation": { "evaluation": {
+33
View File
@@ -219,6 +219,39 @@ test("마이크 주파수 응답을 dB와 wrapped phase로 보간하고 끝점
assertEqual(above.phaseDeg, -170, "upper endpoint 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에 대해 결정적이다", () => { test("index 기반 noise가 seed에 대해 결정적이다", () => {
const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index)); const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index)); const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
+2
View File
@@ -220,12 +220,14 @@ test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한
delete experiment.beamforming.normalization; delete experiment.beamforming.normalization;
experiment.beamforming.dynamicRangeDb = 4; experiment.beamforming.dynamicRangeDb = 4;
experiment.beamforming.compensateFrequencyResponse = "false"; experiment.beamforming.compensateFrequencyResponse = "false";
experiment.beamforming.maxCompensationDb = 121;
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg"))); assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected")); assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene }); const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(experimentIssues.some(({ path }) => path.endsWith("normalization"))); assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb"))); assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse"))); assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse")));
assert(experimentIssues.some(({ path }) => path.endsWith("maxCompensationDb")));
}); });
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => { test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
+242 -1
View File
@@ -2,6 +2,7 @@ import { computeDasMap } from "/packages/acoustics-core/das.js";
import { import {
createLocalizationPlan, createLocalizationPlan,
createStreamingLocalizer, createStreamingLocalizer,
detectBandPeaks,
localizeMultichannel, localizeMultichannel,
} from "/packages/acoustics-core/realtime-localizer.js"; } from "/packages/acoustics-core/realtime-localizer.js";
import { distance3 } from "/packages/acoustics-core/math.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"); 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.bands[0].peak.index, baseline.bands[0].peak.index, "peak index");
assertEqual(portable.locations[0].bandId, "tone", "portable location band"); 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.sampleCount, 128, "sample count");
assertEqual(portable.duration, 128 / fixture.sampleRate, "block duration"); 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 () => { 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"); 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 () => { test("streaming localizer 결과가 임의 chunk 경계와 무관하다", async () => {
const fixture = createFixture(320); const fixture = createFixture(320);
const streamConfig = { const streamConfig = {
@@ -184,6 +317,32 @@ test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지
planLimitError.message.includes("maximumPlanBytes"), planLimitError.message.includes("maximumPlanBytes"),
"analysis plan allocation hard limit", "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); const invalidResponseConfig = structuredClone(fixture.config);
invalidResponseConfig.array.microphones[0].frequencyResponse = [ invalidResponseConfig.array.microphones[0].frequencyResponse = [
{ frequencyHz: 1000, gainDb: 0 }, { frequencyHz: 1000, gainDb: 0 },
@@ -234,6 +393,23 @@ test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지
const boundedResults = await bounded.analyzeAvailable(); const boundedResults = await bounded.analyzeAvailable();
assertEqual(boundedResults.length, 1, "one bounded result"); assertEqual(boundedResults.length, 1, "one bounded result");
assertEqual(boundedResults[0].startSample, 272, "latest retained window analyzed"); 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({ const resultBound = createStreamingLocalizer({
...fixture.config, ...fixture.config,
@@ -281,5 +457,70 @@ test("streaming 분석 취소가 AbortError를 전달하고 계획 lock을 해
assertEqual(caught?.name, "AbortError", "AbortError propagated"); assertEqual(caught?.name, "AbortError", "AbortError propagated");
assertEqual(stream.getState().busy, false, "stream lock released"); assertEqual(stream.getState().busy, false, "stream lock released");
assertEqual(stream.plan.dasPlan.busy, false, "DAS plan 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");
}); });
+25
View File
@@ -109,6 +109,31 @@ test("WebGPU STFT packing이 microphone 주파수 응답을 bin별로 역보정
}); });
assertEqual(uncompensated[0], 0, "disabled compensation real"); assertEqual(uncompensated[0], 0, "disabled compensation real");
assertEqual(uncompensated[1], 2, "disabled compensation imaginary"); 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 한도를 모두 지킨다", () => { test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {