perf: harden realtime localization pipeline
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
|
||||
"normalization": { "enum": ["peak", "none"] },
|
||||
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 },
|
||||
"compensateFrequencyResponse": { "type": "boolean" }
|
||||
"compensateFrequencyResponse": { "type": "boolean" },
|
||||
"maxCompensationDb": { "type": "number", "minimum": 0, "maximum": 120 }
|
||||
}
|
||||
},
|
||||
"evaluation": {
|
||||
|
||||
@@ -219,6 +219,39 @@ test("마이크 주파수 응답을 dB와 wrapped phase로 보간하고 끝점
|
||||
assertEqual(above.phaseDeg, -170, "upper endpoint phase");
|
||||
});
|
||||
|
||||
test("DAS 역응답은 깊은 notch의 boost를 기본 24 dB로 제한한다", async () => {
|
||||
const arrayDefinition = {
|
||||
microphones: [{
|
||||
id: "notched",
|
||||
position: [0, 0, 0],
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: -120 },
|
||||
{ frequencyHz: 4000, gainDb: -120 },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const options = {
|
||||
bin: 8,
|
||||
spectrum: { real: Float32Array.of(1), imaginary: Float32Array.of(0) },
|
||||
frameCount: 1,
|
||||
arrayDefinition,
|
||||
pointCount: 1,
|
||||
distances: Float32Array.of(0),
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
speedOfSound: 343,
|
||||
compensateFrequencyResponse: true,
|
||||
pointBatchSize: 64,
|
||||
};
|
||||
const limited = await computeBinMap(options);
|
||||
const noBoost = await computeBinMap({ ...options, maxCompensationDb: 0 });
|
||||
const explicitLegacyLimit = await computeBinMap({ ...options, maxCompensationDb: 120 });
|
||||
|
||||
assertNear(limited[0], dbToLinear(24) ** 2, 1e-3, "default limited power");
|
||||
assertNear(noBoost[0], 1, 1e-9, "zero boost limit");
|
||||
assert(explicitLegacyLimit[0] > 1e11, "explicit 120 dB limit permits legacy boost");
|
||||
});
|
||||
|
||||
test("index 기반 noise가 seed에 대해 결정적이다", () => {
|
||||
const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
||||
const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
|
||||
|
||||
@@ -220,12 +220,14 @@ test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한
|
||||
delete experiment.beamforming.normalization;
|
||||
experiment.beamforming.dynamicRangeDb = 4;
|
||||
experiment.beamforming.compensateFrequencyResponse = "false";
|
||||
experiment.beamforming.maxCompensationDb = 121;
|
||||
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
|
||||
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
|
||||
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse")));
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("maxCompensationDb")));
|
||||
});
|
||||
|
||||
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computeDasMap } from "/packages/acoustics-core/das.js";
|
||||
import {
|
||||
createLocalizationPlan,
|
||||
createStreamingLocalizer,
|
||||
detectBandPeaks,
|
||||
localizeMultichannel,
|
||||
} from "/packages/acoustics-core/realtime-localizer.js";
|
||||
import { distance3 } from "/packages/acoustics-core/math.js";
|
||||
@@ -102,8 +103,42 @@ test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map
|
||||
assertFloat32Exact(portable.bands[0].displayMap, baseline.bands[0].displayMap, "display map");
|
||||
assertEqual(portable.bands[0].peak.index, baseline.bands[0].peak.index, "peak index");
|
||||
assertEqual(portable.locations[0].bandId, "tone", "portable location band");
|
||||
assertEqual(portable.locations[0].rank, 1, "strongest portable location rank");
|
||||
assertEqual(portable.sampleCount, 128, "sample count");
|
||||
assertEqual(portable.duration, 128 / fixture.sampleRate, "block duration");
|
||||
assert(
|
||||
Number.isFinite(portable.processingTimeMs) && portable.processingTimeMs >= 0,
|
||||
"block processing time telemetry",
|
||||
);
|
||||
});
|
||||
|
||||
test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => {
|
||||
const scan = {
|
||||
z: 1,
|
||||
xMin: 0,
|
||||
xMax: 4,
|
||||
yMin: 0,
|
||||
yMax: 4,
|
||||
columns: 5,
|
||||
rows: 5,
|
||||
};
|
||||
const rawMap = new Float32Array(25);
|
||||
rawMap[6] = 1;
|
||||
rawMap[18] = 0.75;
|
||||
rawMap[3] = 0.49;
|
||||
|
||||
const peaks = detectBandPeaks(rawMap, scan);
|
||||
assertEqual(peaks.length, 2, "two peaks above default threshold");
|
||||
assertEqual(peaks[0].index, 6, "strongest peak first");
|
||||
assertEqual(peaks[0].rank, 1, "strongest rank");
|
||||
assertEqual(peaks[1].index, 18, "second source peak retained");
|
||||
assertEqual(peaks[1].rank, 2, "second rank");
|
||||
assert(Math.abs(peaks[1].rawPower - 0.75) <= 1e-7, "second raw power");
|
||||
assertEqual(
|
||||
detectBandPeaks(rawMap, scan, { maximumDetections: 1 }).length,
|
||||
1,
|
||||
"maximum detection bound",
|
||||
);
|
||||
});
|
||||
|
||||
test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 실제로 제거한다", async () => {
|
||||
@@ -136,6 +171,104 @@ test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을
|
||||
assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "cached repeat raw map");
|
||||
});
|
||||
|
||||
test("wide-band 분석 계획이 예산만큼 steering bin과 STFT workspace를 재사용한다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const pointMicrophonePairs =
|
||||
fixture.scan.columns * fixture.scan.rows * fixture.array.microphones.length;
|
||||
const steeringBytesPerBin =
|
||||
pointMicrophonePairs * Float64Array.BYTES_PER_ELEMENT * 2;
|
||||
const wideBands = [{ id: "wide", minHz: 500, maxHz: 580, color: "#65f2bf" }];
|
||||
const partialPlan = createLocalizationPlan({
|
||||
...fixture.config,
|
||||
bands: wideBands,
|
||||
maximumSteeringCacheBytes: steeringBytesPerBin,
|
||||
});
|
||||
const uncachedPlan = createLocalizationPlan({
|
||||
...fixture.config,
|
||||
bands: wideBands,
|
||||
maximumSteeringCacheBytes: 0,
|
||||
});
|
||||
|
||||
const first = await localizeMultichannel({ plan: partialPlan, channels: fixture.channels });
|
||||
const second = await localizeMultichannel({ plan: partialPlan, channels: fixture.channels });
|
||||
const uncached = await localizeMultichannel({ plan: uncachedPlan, channels: fixture.channels });
|
||||
const cache = second.analysisCache;
|
||||
|
||||
assertFloat32Exact(first.bands[0].rawMap, uncached.bands[0].rawMap, "partial cache raw map");
|
||||
assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "partial repeat raw map");
|
||||
assertEqual(cache.steeringCacheBinCapacity, 1, "one Float64 steering bin retained");
|
||||
assertEqual(cache.steeringCachedBinCount, 1, "one steering matrix built");
|
||||
assertEqual(cache.steeringUncachedBinCount, 1, "one selected bin remains uncached");
|
||||
assertEqual(cache.steeringCacheBytes, steeringBytesPerBin, "steering byte budget used exactly");
|
||||
assertEqual(cache.requestedSteeringCacheBytes, steeringBytesPerBin * 2, "full request reported");
|
||||
assertEqual(cache.steeringMatrixBuilds, 1, "cached bin built once");
|
||||
assertEqual(cache.steeringCacheHits, 1, "cached bin reused once");
|
||||
assertEqual(cache.uncachedSteeringUses, 2, "uncached bin computed once per analysis");
|
||||
assertEqual(
|
||||
cache.uncachedSteeringPointMicrophonePairs,
|
||||
pointMicrophonePairs * 2,
|
||||
"uncached steering operations counted",
|
||||
);
|
||||
assertEqual(cache.stftWindowBuilds, 1, "Hann window built once");
|
||||
assertEqual(cache.stftWorkspaceAllocations, 1, "FFT workspace allocated once");
|
||||
assertEqual(cache.stftWorkspaceUses, 2, "workspace used by both analyses");
|
||||
assertEqual(cache.avoidedStftWindowBuilds, 1, "second Hann build avoided");
|
||||
assertEqual(cache.avoidedStftWorkspaceAllocations, 1, "second FFT allocation avoided");
|
||||
assertEqual(cache.stftTransforms, 24, "STFT transform operation count");
|
||||
assertEqual(cache.fftButterflies, 4608, "FFT butterfly operation count");
|
||||
assertEqual(cache.beamformingComplexMacs, 720, "beamforming operation count");
|
||||
assertEqual(cache.binMapAllocations, 1, "one reusable bin map allocation");
|
||||
assertEqual(cache.steeringWorkspaceAllocations, 1, "one reusable steering workspace");
|
||||
assert(cache.retainedBytes <= partialPlan.dasPlan.maximumPlanBytes, "plan hard cap retained");
|
||||
});
|
||||
|
||||
test("portable plan은 취소 응답성과 처리량을 균형 잡은 기본 batch를 사용한다", () => {
|
||||
const fixture = createFixture(128);
|
||||
const config = { ...fixture.config };
|
||||
delete config.computeOptions;
|
||||
const defaults = createLocalizationPlan(config);
|
||||
assertEqual(defaults.computeOptions.pointBatchSize, 256, "default point batch");
|
||||
assertEqual(defaults.computeOptions.stftTransformBatchSize, 128, "default STFT batch");
|
||||
assertEqual(defaults.maxCompensationDb, 24, "default inverse-response boost limit");
|
||||
assertEqual(defaults.peakDetection.thresholdRatio, 0.5, "default peak threshold");
|
||||
assertEqual(defaults.peakDetection.maximumDetections, 8, "default peak count bound");
|
||||
|
||||
const custom = createLocalizationPlan({
|
||||
...config,
|
||||
computeOptions: { pointBatchSize: 7, stftTransformBatchSize: 9 },
|
||||
});
|
||||
assertEqual(custom.computeOptions.pointBatchSize, 7, "point batch override");
|
||||
assertEqual(custom.computeOptions.stftTransformBatchSize, 9, "STFT batch override");
|
||||
|
||||
const calibrated = createLocalizationPlan({
|
||||
...config,
|
||||
maxCompensationDb: 0,
|
||||
peakDetection: {
|
||||
thresholdRatio: 0.25,
|
||||
minimumPeakDistanceM: 0,
|
||||
maximumDetections: 3,
|
||||
},
|
||||
});
|
||||
assertEqual(calibrated.maxCompensationDb, 0, "portable compensation alias");
|
||||
assertEqual(calibrated.peakDetection.thresholdRatio, 0.25, "custom peak threshold");
|
||||
assertEqual(calibrated.peakDetection.minimumPeakDistanceM, 0, "custom peak spacing");
|
||||
assertEqual(calibrated.peakDetection.maximumDetections, 3, "custom peak count");
|
||||
|
||||
for (const invalidConfig of [
|
||||
{ ...config, maxCompensationDb: 121 },
|
||||
{ ...config, peakDetection: { thresholdRatio: -0.1 } },
|
||||
{ ...config, peakDetection: { maximumDetections: 129 } },
|
||||
]) {
|
||||
let caught;
|
||||
try {
|
||||
createLocalizationPlan(invalidConfig);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
assert(caught instanceof RangeError, "invalid portable localization option rejected");
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming localizer 결과가 임의 chunk 경계와 무관하다", async () => {
|
||||
const fixture = createFixture(320);
|
||||
const streamConfig = {
|
||||
@@ -184,6 +317,32 @@ test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지
|
||||
planLimitError.message.includes("maximumPlanBytes"),
|
||||
"analysis plan allocation hard limit",
|
||||
);
|
||||
let calibrationLimitError;
|
||||
try {
|
||||
createLocalizationPlan({
|
||||
...fixture.config,
|
||||
maximumSteeringCacheBytes: 0,
|
||||
maximumPlanBytes: 1963,
|
||||
});
|
||||
} catch (error) {
|
||||
calibrationLimitError = error;
|
||||
}
|
||||
assert(
|
||||
calibrationLimitError instanceof RangeError &&
|
||||
calibrationLimitError.message.includes("calibration bytes"),
|
||||
"all calibration arrays are reserved by the plan hard limit",
|
||||
);
|
||||
const exactMemoryPlan = createLocalizationPlan({
|
||||
...fixture.config,
|
||||
maximumSteeringCacheBytes: 0,
|
||||
maximumPlanBytes: 1964,
|
||||
});
|
||||
const exactMemoryResult = await localizeMultichannel({
|
||||
plan: exactMemoryPlan,
|
||||
channels: fixture.channels,
|
||||
});
|
||||
assertEqual(exactMemoryPlan.dasPlan.calibrationBytes, 64, "calibration byte reservation");
|
||||
assertEqual(exactMemoryResult.analysisCache.retainedBytes, 1964, "exact retained plan bytes");
|
||||
const invalidResponseConfig = structuredClone(fixture.config);
|
||||
invalidResponseConfig.array.microphones[0].frequencyResponse = [
|
||||
{ frequencyHz: 1000, gainDb: 0 },
|
||||
@@ -234,6 +393,23 @@ test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지
|
||||
const boundedResults = await bounded.analyzeAvailable();
|
||||
assertEqual(boundedResults.length, 1, "one bounded result");
|
||||
assertEqual(boundedResults[0].startSample, 272, "latest retained window analyzed");
|
||||
assertEqual(boundedResults[0].deadlineMs, 64 / fixture.sampleRate * 1000, "hop deadline");
|
||||
assertEqual(typeof boundedResults[0].deadlineMet, "boolean", "deadline verdict");
|
||||
const boundedState = bounded.getState();
|
||||
assertEqual(
|
||||
boundedState.lastProcessingTimeMs,
|
||||
boundedResults[0].processingTimeMs,
|
||||
"last processing time telemetry",
|
||||
);
|
||||
assert(
|
||||
boundedState.maximumProcessingTimeMs >= boundedState.lastProcessingTimeMs,
|
||||
"maximum processing time telemetry",
|
||||
);
|
||||
assertEqual(
|
||||
boundedState.deadlineMisses,
|
||||
boundedResults[0].deadlineMet ? 0 : 1,
|
||||
"deadline miss telemetry",
|
||||
);
|
||||
|
||||
const resultBound = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
@@ -281,5 +457,70 @@ test("streaming 분석 취소가 AbortError를 전달하고 계획 lock을 해
|
||||
assertEqual(caught?.name, "AbortError", "AbortError propagated");
|
||||
assertEqual(stream.getState().busy, false, "stream lock released");
|
||||
assertEqual(stream.plan.dasPlan.busy, false, "DAS plan lock released");
|
||||
assertEqual(stream.getState().failedAnalyses, 1, "failed analysis counted");
|
||||
let state = stream.getState();
|
||||
assertEqual(state.bufferStartSample, 0, "failed window origin retained");
|
||||
assertEqual(state.bufferedSamples, 128, "failed window samples retained");
|
||||
assertEqual(state.availableAnalysisCount, 1, "failed window remains retryable");
|
||||
assertEqual(state.analysisAttempts, 1, "failed attempt counted");
|
||||
assertEqual(state.completedAnalyses, 0, "no completed analysis on abort");
|
||||
assertEqual(state.failedAnalyses, 1, "failed analysis counted");
|
||||
assertEqual(state.nextSequence, 0, "sequence is not consumed by abort");
|
||||
|
||||
const retried = await stream.analyzeAvailable();
|
||||
assertEqual(retried.length, 1, "failed window retried once");
|
||||
assertEqual(retried[0].startSample, 0, "same window retried");
|
||||
assertEqual(retried[0].sequence, 0, "first success receives first sequence");
|
||||
state = stream.getState();
|
||||
assertEqual(state.bufferStartSample, 64, "hop committed after retry success");
|
||||
assertEqual(state.bufferedSamples, 64, "only successful hop consumed");
|
||||
assertEqual(state.analysisAttempts, 2, "retry attempt counted");
|
||||
assertEqual(state.completedAnalyses, 1, "retry completion counted");
|
||||
assertEqual(state.failedAnalyses, 1, "historical failure retained");
|
||||
assertEqual(state.nextSequence, 1, "sequence committed after success");
|
||||
});
|
||||
|
||||
test("onResult 예외는 성공한 window를 실패나 재시도로 되돌리지 않는다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const stream = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 256,
|
||||
maximumResultsPerCall: 1,
|
||||
});
|
||||
stream.append(fixture.channels);
|
||||
let callbackResult;
|
||||
let callbackError;
|
||||
try {
|
||||
await stream.analyzeAvailable({
|
||||
onResult: (result) => {
|
||||
callbackResult = result;
|
||||
throw new Error("consumer failed");
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
callbackError = error;
|
||||
}
|
||||
|
||||
assertEqual(callbackError?.message, "consumer failed", "consumer error propagated");
|
||||
assertEqual(callbackResult?.sequence, 0, "committed result reached consumer");
|
||||
let state = stream.getState();
|
||||
assertEqual(state.bufferStartSample, 64, "callback failure does not roll back hop");
|
||||
assertEqual(state.bufferedSamples, 64, "callback failure leaves committed buffer");
|
||||
assertEqual(state.analysisAttempts, 1, "one localization attempt");
|
||||
assertEqual(state.completedAnalyses, 1, "localization remains completed");
|
||||
assertEqual(state.failedAnalyses, 0, "consumer error is not localization failure");
|
||||
assertEqual(state.nextSequence, 1, "sequence remains committed");
|
||||
|
||||
stream.append(sliceChannels(fixture.channels, 64, 128));
|
||||
const next = await stream.analyzeAvailable();
|
||||
assertEqual(next[0].startSample, 64, "next window advances instead of retrying");
|
||||
assertEqual(next[0].sequence, 1, "next sequence remains contiguous");
|
||||
state = stream.getState();
|
||||
assertEqual(state.bufferStartSample, 128, "second successful hop committed");
|
||||
assertEqual(state.bufferedSamples, 64, "overlap retained after second window");
|
||||
assertEqual(state.analysisAttempts, 2, "second attempt counted");
|
||||
assertEqual(state.completedAnalyses, 2, "second completion counted");
|
||||
assertEqual(state.failedAnalyses, 0, "no false failure after recovery");
|
||||
assertEqual(state.nextSequence, 2, "sequence advanced twice");
|
||||
});
|
||||
|
||||
@@ -109,6 +109,31 @@ test("WebGPU STFT packing이 microphone 주파수 응답을 bin별로 역보정
|
||||
});
|
||||
assertEqual(uncompensated[0], 0, "disabled compensation real");
|
||||
assertEqual(uncompensated[1], 2, "disabled compensation imaginary");
|
||||
|
||||
const notchedMicrophones = [{
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: -120 },
|
||||
{ frequencyHz: 4000, gainDb: -120 },
|
||||
],
|
||||
}];
|
||||
const notched = {
|
||||
frameCount: 1,
|
||||
microphoneCount: 1,
|
||||
spectra: new Map([[8, { real: Float32Array.of(1), imaginary: Float32Array.of(0) }]]),
|
||||
};
|
||||
const limited = packStftBatch(notched, [8], {
|
||||
microphones: notchedMicrophones,
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
});
|
||||
const explicitLegacyLimit = packStftBatch(notched, [8], {
|
||||
microphones: notchedMicrophones,
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
maxCompensationDb: 120,
|
||||
});
|
||||
assertNear(limited[0], 10 ** (24 / 20), 1e-5, "default notch boost limit");
|
||||
assert(explicitLegacyLimit[0] > 999_000, "explicit 120 dB notch boost");
|
||||
});
|
||||
|
||||
test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {
|
||||
|
||||
Reference in New Issue
Block a user