perf: harden realtime localization pipeline

This commit is contained in:
Codex
2026-08-28 18:58:48 +09:00
parent 35c33e360e
commit 256558e601
13 changed files with 765 additions and 84 deletions
+33
View File
@@ -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));
+2
View File
@@ -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 () => {
+242 -1
View File
@@ -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");
});
+25
View File
@@ -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 한도를 모두 지킨다", () => {