551 lines
22 KiB
JavaScript
551 lines
22 KiB
JavaScript
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";
|
|
|
|
function createFixture(sampleCount = 320) {
|
|
const sampleRate = 4096;
|
|
const speedOfSound = 343;
|
|
const frequency = 512;
|
|
const target = [0.1, -0.1, 1];
|
|
const array = {
|
|
id: "portable-array",
|
|
microphones: [
|
|
{
|
|
id: "nw",
|
|
position: [-0.12, 0.12, 0],
|
|
sensitivityDb: 1.5,
|
|
phaseOffsetDeg: -4,
|
|
frequencyResponse: [
|
|
{ frequencyHz: 400, gainDb: -1, phaseDeg: -3 },
|
|
{ frequencyHz: 600, gainDb: 2, phaseDeg: 5 },
|
|
],
|
|
},
|
|
{ id: "ne", position: [0.12, 0.12, 0], sensitivityDb: -0.5, phaseOffsetDeg: 2 },
|
|
{ id: "sw", position: [-0.12, -0.12, 0] },
|
|
{ id: "se", position: [0.12, -0.12, 0] },
|
|
],
|
|
};
|
|
const channels = array.microphones.map((microphone) => {
|
|
const delay = distance3(target, microphone.position) / speedOfSound;
|
|
return Float32Array.from(
|
|
{ length: sampleCount },
|
|
(_, sampleIndex) =>
|
|
Math.sin(2 * Math.PI * frequency * (sampleIndex / sampleRate - delay)),
|
|
);
|
|
});
|
|
const scan = {
|
|
z: 1,
|
|
xMin: -0.2,
|
|
xMax: 0.2,
|
|
yMin: -0.1,
|
|
yMax: 0.1,
|
|
columns: 5,
|
|
rows: 3,
|
|
};
|
|
const bands = [{ id: "tone", minHz: 500, maxHz: 524, color: "#65f2bf" }];
|
|
const config = {
|
|
sampleRate,
|
|
array,
|
|
scan,
|
|
bands,
|
|
speedOfSound,
|
|
stft: { fftSize: 64, hopSize: 32 },
|
|
normalization: "peak",
|
|
maximumSteeringCacheBytes: 1024 * 1024,
|
|
computeOptions: {
|
|
pointBatchSize: 1024,
|
|
stftTransformBatchSize: 1024,
|
|
},
|
|
};
|
|
return { sampleRate, speedOfSound, target, array, channels, scan, bands, config };
|
|
}
|
|
|
|
function assertFloat32Exact(actual, expected, label) {
|
|
assertEqual(actual.length, expected.length, `${label} length`);
|
|
for (let index = 0; index < actual.length; index += 1) {
|
|
assertEqual(actual[index], expected[index], `${label}[${index}]`);
|
|
}
|
|
}
|
|
|
|
function sliceChannels(channels, start, end) {
|
|
return channels.map((channel) => channel.slice(start, end));
|
|
}
|
|
|
|
test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map을 만든다", async () => {
|
|
const fixture = createFixture(128);
|
|
const experiment = {
|
|
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
|
scan: fixture.scan,
|
|
frequencyBands: fixture.bands,
|
|
beamforming: { normalization: "peak", compensateFrequencyResponse: true },
|
|
};
|
|
const baseline = await computeDasMap({
|
|
channels: fixture.channels,
|
|
sampleRate: fixture.sampleRate,
|
|
arrayDefinition: fixture.array,
|
|
scene: { speedOfSound: fixture.speedOfSound },
|
|
experiment,
|
|
pointBatchSize: 1024,
|
|
stftTransformBatchSize: 1024,
|
|
});
|
|
const plan = createLocalizationPlan(fixture.config);
|
|
const portable = await localizeMultichannel({
|
|
plan,
|
|
channels: fixture.channels,
|
|
});
|
|
|
|
assertFloat32Exact(portable.bands[0].rawMap, baseline.bands[0].rawMap, "raw 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 above-Nyquist band observes the folded ADC bin without hidden carrier steering", async () => {
|
|
const fixture = createFixture(128);
|
|
const basebandPlan = createLocalizationPlan(fixture.config);
|
|
const aliasedPlan = createLocalizationPlan({
|
|
...fixture.config,
|
|
bands: [{ id: "aliased", minHz: 3_572, maxHz: 3_596, color: "#58c7ff" }],
|
|
});
|
|
const baseband = await localizeMultichannel({
|
|
plan: basebandPlan,
|
|
channels: fixture.channels,
|
|
});
|
|
const aliased = await localizeMultichannel({
|
|
plan: aliasedPlan,
|
|
channels: fixture.channels,
|
|
});
|
|
|
|
assertEqual(aliasedPlan.bands[0].minHz, 3_572, "requested analog band is retained");
|
|
assertFloat32Exact(
|
|
aliased.bands[0].rawMap,
|
|
baseband.bands[0].rawMap,
|
|
"same ADC bin produces the same conventional DAS map",
|
|
);
|
|
});
|
|
|
|
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 () => {
|
|
const fixture = createFixture(128);
|
|
const plan = createLocalizationPlan(fixture.config);
|
|
const first = await localizeMultichannel({ plan, channels: fixture.channels });
|
|
const second = await localizeMultichannel({ plan, channels: fixture.channels });
|
|
const cache = second.analysisCache;
|
|
const pointMicrophonePairs = fixture.scan.columns * fixture.scan.rows * fixture.array.microphones.length;
|
|
|
|
assertEqual(cache.analysisAttempts, 2, "analysis attempts");
|
|
assertEqual(cache.completedAnalyses, 2, "completed analyses");
|
|
assertEqual(cache.distanceMatrixBuilds, 1, "one retained distance matrix");
|
|
assertEqual(cache.distanceEvaluations, pointMicrophonePairs, "distance evaluations performed");
|
|
assertEqual(
|
|
cache.avoidedDistanceEvaluations,
|
|
pointMicrophonePairs,
|
|
"second distance pass avoided",
|
|
);
|
|
assert(cache.steeringCacheEnabled, "bounded steering cache enabled");
|
|
assertEqual(cache.steeringMatrixBuilds, 1, "one steering matrix build");
|
|
assertEqual(cache.steeringCacheHits, 1, "second steering pass is a cache hit");
|
|
assertEqual(
|
|
cache.avoidedSteeringPointMicrophonePairs,
|
|
pointMicrophonePairs,
|
|
"second trigonometric steering pass avoided",
|
|
);
|
|
assertEqual(cache.avoidedBinMapAllocations, 1, "second temporary bin map allocation avoided");
|
|
assert(cache.retainedBytes > 0, "retained cache bytes are reported");
|
|
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 = {
|
|
...fixture.config,
|
|
analysisWindowSamples: 128,
|
|
analysisHopSamples: 64,
|
|
maximumBufferedSamples: 512,
|
|
maximumResultsPerCall: 4,
|
|
};
|
|
const whole = createStreamingLocalizer(streamConfig);
|
|
whole.append(fixture.channels);
|
|
const wholeResults = await whole.analyzeAvailable({ maximumResults: 4 });
|
|
|
|
const chunked = createStreamingLocalizer(streamConfig);
|
|
const chunkSizes = [17, 83, 1, 91, 128];
|
|
const chunkedResults = [];
|
|
let offset = 0;
|
|
for (const chunkSize of chunkSizes) {
|
|
const end = offset + chunkSize;
|
|
chunkedResults.push(...await chunked.push(sliceChannels(fixture.channels, offset, end)));
|
|
offset = end;
|
|
}
|
|
|
|
assertEqual(wholeResults.length, 4, "whole block window count");
|
|
assertEqual(chunkedResults.length, wholeResults.length, "chunked window count");
|
|
wholeResults.forEach((expected, index) => {
|
|
const actual = chunkedResults[index];
|
|
assertEqual(actual.startSample, index * 64, `chunked start sample ${index}`);
|
|
assertEqual(actual.endSample, index * 64 + 128, `chunked end sample ${index}`);
|
|
assertEqual(actual.sequence, index, `chunked sequence ${index}`);
|
|
assertFloat32Exact(actual.bands[0].rawMap, expected.bands[0].rawMap, `chunked map ${index}`);
|
|
});
|
|
assertEqual(chunked.getState().droppedSamples, 0, "no samples dropped under capacity");
|
|
});
|
|
|
|
test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지킨다", async () => {
|
|
const fixture = createFixture(400);
|
|
let planLimitError;
|
|
try {
|
|
createLocalizationPlan({ ...fixture.config, maximumPlanBytes: 64 });
|
|
} catch (error) {
|
|
planLimitError = error;
|
|
}
|
|
assert(
|
|
planLimitError instanceof RangeError &&
|
|
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 },
|
|
{ frequencyHz: 1000, gainDb: -3 },
|
|
];
|
|
let responseError;
|
|
try {
|
|
createLocalizationPlan(invalidResponseConfig);
|
|
} catch (error) {
|
|
responseError = error;
|
|
}
|
|
assert(
|
|
responseError instanceof RangeError && responseError.message.includes("strictly increasing"),
|
|
"portable microphone response validation",
|
|
);
|
|
let inputLimitError;
|
|
try {
|
|
createStreamingLocalizer({
|
|
...fixture.config,
|
|
analysisWindowSamples: 128,
|
|
maximumBufferedSamples: 128,
|
|
maximumInputBufferBytes: 1024,
|
|
});
|
|
} catch (error) {
|
|
inputLimitError = error;
|
|
}
|
|
assert(
|
|
inputLimitError instanceof RangeError &&
|
|
inputLimitError.message.includes("maximumInputBufferBytes"),
|
|
"input allocation hard limit",
|
|
);
|
|
const bounded = createStreamingLocalizer({
|
|
...fixture.config,
|
|
analysisWindowSamples: 128,
|
|
analysisHopSamples: 64,
|
|
maximumBufferedSamples: 128,
|
|
maximumResultsPerCall: 1,
|
|
});
|
|
const appended = bounded.append(fixture.channels);
|
|
assertEqual(appended.bufferedSamples, 128, "ring capacity");
|
|
assertEqual(appended.droppedSamples, 272, "oldest samples dropped");
|
|
assertEqual(appended.bufferStartSample, 272, "retained absolute sample origin");
|
|
assertEqual(
|
|
appended.retainedInputBytes,
|
|
fixture.array.microphones.length * (128 + 128) * Float32Array.BYTES_PER_ELEMENT,
|
|
"fixed ring and analysis workspace bytes",
|
|
);
|
|
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,
|
|
analysisWindowSamples: 128,
|
|
analysisHopSamples: 64,
|
|
maximumBufferedSamples: 512,
|
|
maximumResultsPerCall: 2,
|
|
});
|
|
resultBound.append(fixture.channels);
|
|
const first = await resultBound.analyzeAvailable();
|
|
const second = await resultBound.analyzeAvailable();
|
|
assertEqual(first.length, 2, "first result batch bound");
|
|
assertEqual(second.length, 2, "second result batch bound");
|
|
assertEqual(resultBound.getState().availableAnalysisCount, 1, "remaining work stays buffered");
|
|
});
|
|
|
|
test("streaming 분석 취소가 AbortError를 전달하고 계획 lock을 해제한다", async () => {
|
|
const fixture = createFixture(128);
|
|
const stream = createStreamingLocalizer({
|
|
...fixture.config,
|
|
analysisWindowSamples: 128,
|
|
analysisHopSamples: 64,
|
|
maximumBufferedSamples: 256,
|
|
maximumResultsPerCall: 1,
|
|
});
|
|
stream.append(fixture.channels);
|
|
const controller = new AbortController();
|
|
let cancellationRequested = false;
|
|
let caught;
|
|
try {
|
|
await stream.analyzeAvailable({
|
|
signal: controller.signal,
|
|
computeOptions: { stftTransformBatchSize: 1 },
|
|
onProgress: ({ stage, completedTransforms }) => {
|
|
if (stage === "stft" && completedTransforms > 0 && !cancellationRequested) {
|
|
cancellationRequested = true;
|
|
controller.abort();
|
|
}
|
|
},
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
assert(cancellationRequested, "cancellation requested during STFT");
|
|
assertEqual(caught?.name, "AbortError", "AbortError propagated");
|
|
assertEqual(stream.getState().busy, false, "stream lock released");
|
|
assertEqual(stream.plan.dasPlan.busy, false, "DAS plan lock released");
|
|
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");
|
|
});
|