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 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"); });