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 },
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 검출까지 실행합니다.
@@ -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 { 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) {
@@ -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;
+168 -33
View File
@@ -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;
}
}
+43 -10
View File
@@ -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);
}
}
+8
View File
@@ -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;
+10 -2
View File
@@ -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);