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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user