From d002a2bd70b29268f093510da7d9039d6b6f6848 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 18:19:31 +0900 Subject: [PATCH] feat: add realistic portable localization core --- packages/acoustics-core/README.md | 87 ++- packages/acoustics-core/das.js | 426 +++++++++++++- packages/acoustics-core/environment.js | 244 ++++++++ packages/acoustics-core/fractional-delay.js | 98 +++- packages/acoustics-core/frequency-response.js | 62 ++ packages/acoustics-core/index.js | 3 + packages/acoustics-core/propagation.js | 68 ++- packages/acoustics-core/realtime-localizer.js | 545 ++++++++++++++++++ packages/contracts/validation.js | 113 +++- packages/webgpu-kernels/das.js | 61 +- schemas/README.md | 14 +- schemas/array.schema.json | 15 +- schemas/experiment.schema.json | 3 +- schemas/scene.schema.json | 62 ++ tests/test-runner.js | 1 + tests/unit/README.md | 2 + tests/unit/acoustics.test.js | 231 ++++++++ tests/unit/contracts.test.js | 65 +++ tests/unit/realtime-localizer.test.js | 285 +++++++++ tests/unit/webgpu.test.js | 30 + 20 files changed, 2345 insertions(+), 70 deletions(-) create mode 100644 packages/acoustics-core/environment.js create mode 100644 packages/acoustics-core/frequency-response.js create mode 100644 packages/acoustics-core/realtime-localizer.js create mode 100644 tests/unit/realtime-localizer.test.js diff --git a/packages/acoustics-core/README.md b/packages/acoustics-core/README.md index 140b678..46edc3a 100644 --- a/packages/acoustics-core/README.md +++ b/packages/acoustics-core/README.md @@ -3,11 +3,56 @@ 두 앱이 공유하는 무의존성 수치 기준 구현입니다. - 결정적 tone/white/pink noise 및 오디오 asset resampling -- 자유음장 점 음원의 `delay = r / c`, `amplitude = 1 / r` 구면 전파 -- zero-padded FFT phase shift 기반 fractional sample delay +- 자유음장 직접음과 선택적 저비용 shoebox multipath +- zero-padded FFT 전달함수 기반 fractional sample delay와 마이크 주파수 응답 - radix-2 FFT, Hann STFT와 선택 bin 추출 - scan grid 거리 cache 및 conventional frequency-domain DAS +## 실측 다채널 스트림 위치 추정 + +`realtime-localizer.js`는 Scene·음원 합성 없이 이미 sampling된 동기식 마이크 채널을 직접 +받습니다. 외부 package 의존성이 없는 ES module이며, 다른 프로젝트에는 `acoustics-core` +디렉터리를 그대로 복사해 사용할 수 있습니다. 채널 순서는 `array.microphones` 순서와 같아야 하고 +각 chunk는 동일 길이의 `Float32Array` 목록이어야 합니다. + +```js +import { createStreamingLocalizer } from "./packages/acoustics-core/realtime-localizer.js"; + +const localizer = createStreamingLocalizer({ + sampleRate: 48_000, + array, + 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 }, + analysisWindowSamples: 4_800, + analysisHopSamples: 4_800, + maximumBufferedSamples: 19_200, + maximumResultsPerCall: 1, +}); + +// Capture callback: 복사 후 즉시 반환하며, 오래 밀린 sample은 oldest-first로 버립니다. +localizer.append(channels); + +// Analysis loop: 호출마다 설정된 상한 이하의 map만 반환합니다. +const results = await localizer.analyzeAvailable({ signal: abortController.signal }); +for (const result of results) { + const { position, rawPower } = result.locations[0]; + drawMarker(position, rawPower, result.centerTime); +} +``` + +한 블록만 처리할 때는 `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 일치를 고정합니다. + ## 이동 음원과 분석 시간창 `sourcePositionAtTime`은 scene 시작 기준의 절대 시각에서 trajectory keyframe 사이를 선형 보간하며, @@ -33,8 +78,40 @@ calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용 buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를 양보해 진행 callback과 `AbortSignal` 취소가 동작합니다. -현재 전파 모델은 자유음장·무지향성 점 음원이며 잔향은 포함하지 않습니다. 전체 signal을 -메모리에 보관하는 CPU 기준 구현이므로 source/channel과 delay FFT working set을 512 MiB로 제한합니다. -장시간 입력은 이후 chunk/STFT streaming backend에서 지원합니다. +마이크별 `frequencyResponse`는 2~64개의 `{ frequencyHz, gainDb, phaseDeg? }` 점으로 정의합니다. +`interpolateFrequencyResponse`는 dB와 wrapped phase를 구간 선형 보간하고 측정 범위 밖에서는 가장 +가까운 끝점을 사용합니다. 합성 시 기존 sensitivity/phase 편차에 이 응답을 추가하며, CPU DAS는 +선택 FFT bin의 역응답을 calibration/steering cache에 포함합니다. WebGPU 경로는 GPU 전송 직전의 +packed STFT에 같은 역응답을 적용합니다. `beamforming.compensateFrequencyResponse: false`는 +sensitivity/phase 보정은 유지한 채 곡선 보정만 꺼 robustness 비교를 재현합니다. + +응답 곡선이 없는 배열은 기존 계산 경로와 결과를 유지합니다. 곡선이 있으면 합성 FFT마다 bin당 +보간·복소 곱이 추가되고, WebGPU 준비 단계에는 `선택 bin × 마이크` calibration과 STFT 복소 곱이 +추가되지만 GPU buffer layout이나 dispatch 크기는 늘어나지 않습니다. + +## 저비용 환경 근사 + +Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면 기존 직접음 +`delay = r/c`, `gain = 1/r`만 사용합니다. `model: "shoebox"`는 다음 경로를 선택적으로 +추가합니다. + +- 직육면체 `room`의 여섯 면에 대한 1차 image-source 반사 +- 반사 에너지 일부를 deterministic 지연 경로로 나누는 scattering 근사 +- 축 정렬 box `occluders`를 통과하는 직접음 감쇠와 가장 짧은 corner detour 회절 경로 +- RT60·gain·tap 수로 제한한 deterministic diffuse-reverb tail + +모든 경로의 위상과 gain은 마이크마다 하나의 frequency-domain 전달함수로 합친 뒤 IFFT를 한 번만 +수행합니다. 경로 위상은 recurrence로 갱신하고 1,024 bin마다 재고정하므로 각 bin·path마다 +삼각함수를 다시 호출하지 않습니다. UI preset도 수신점당 sparse path 상한을 표시합니다. + +이 모델은 배열 배치와 위치추정 robustness를 빠르게 비교하기 위한 근사입니다. 파동 방정식, +고차 반사, 재질별 주파수 의존 흡음, 복잡한 mesh 회절, Doppler, 음원·마이크 지향성, 압력/SPL, +센서 self-noise, ADC quantization·clock jitter는 계산하지 않습니다. 그런 효과가 중요한 +검증에서는 실측 다채널 데이터를 아래 streaming API에 넣어 결과를 비교해야 합니다. + +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 한도를 넘으면 자동으로 끕니다. 192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다. diff --git a/packages/acoustics-core/das.js b/packages/acoustics-core/das.js index 81cd978..70fc7c4 100644 --- a/packages/acoustics-core/das.js +++ b/packages/acoustics-core/das.js @@ -1,4 +1,5 @@ import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js"; +import { interpolateFrequencyResponse } from "./frequency-response.js"; import { binsForBand, extractStftBins, getFrameCount } from "./stft.js"; function throwIfAborted(signal) { @@ -13,6 +14,10 @@ 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"; + export function createDistanceMatrix(arrayDefinition, scan) { const points = createScanPoints(scan); const microphoneCount = arrayDefinition.microphones.length; @@ -31,6 +36,94 @@ export function createDistanceMatrix(arrayDefinition, scan) { return distances; } +function createBandIndexLookup(bandBins) { + const lookup = new Map(); + bandBins.forEach((bins, bandIndex) => { + bins.forEach((bin) => { + if (!lookup.has(bin)) lookup.set(bin, []); + lookup.get(bin).push(bandIndex); + }); + }); + return lookup; +} + +function createBinCalibration(arrayDefinition, bin, sampleRate, fftSize, compensateFrequencyResponse) { + const microphoneCount = arrayDefinition.microphones.length; + const gains = new Float64Array(microphoneCount); + const phases = new Float64Array(microphoneCount); + const frequencyHz = (bin * sampleRate) / fftSize; + const response = {}; + for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { + const microphone = arrayDefinition.microphones[microphoneIndex]; + let responseGainDb = 0; + let responsePhaseDeg = 0; + if (compensateFrequencyResponse && microphone.frequencyResponse) { + interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response); + responseGainDb = response.gainDb; + responsePhaseDeg = response.phaseDeg; + } + gains[microphoneIndex] = dbToLinear( + -(microphone.sensitivityDb ?? 0) - responseGainDb, + ); + phases[microphoneIndex] = + ((microphone.phaseOffsetDeg ?? 0) + responsePhaseDeg) * Math.PI / 180; + } + return { gains, phases }; +} + +function fillSteeringVector({ + angularWaveNumber, + distances, + distanceOffset, + calibration, + outputReal, + outputImaginary, + outputOffset, +}) { + for (let microphoneIndex = 0; microphoneIndex < calibration.gains.length; microphoneIndex += 1) { + const phase = + angularWaveNumber * distances[distanceOffset + microphoneIndex] - + calibration.phases[microphoneIndex]; + outputReal[outputOffset + microphoneIndex] = + Math.cos(phase) * calibration.gains[microphoneIndex]; + outputImaginary[outputOffset + microphoneIndex] = + Math.sin(phase) * calibration.gains[microphoneIndex]; + } +} + +async function createSteeringMatrix(options) { + const { + bin, + pointCount, + microphoneCount, + distances, + sampleRate, + fftSize, + speedOfSound, + calibration, + signal, + pointBatchSize, + } = options; + const real = new Float64Array(pointCount * microphoneCount); + const imaginary = new Float64Array(pointCount * microphoneCount); + const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound); + for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { + throwIfAborted(signal); + const offset = pointIndex * microphoneCount; + fillSteeringVector({ + angularWaveNumber, + distances, + distanceOffset: offset, + calibration, + outputReal: real, + outputImaginary: imaginary, + outputOffset: offset, + }); + if ((pointIndex + 1) % pointBatchSize === 0) await yieldToEventLoop(); + } + return { real, imaginary }; +} + export async function computeBinMap(options) { const { bin, @@ -42,26 +135,64 @@ export async function computeBinMap(options) { sampleRate, fftSize, speedOfSound, + compensateFrequencyResponse = true, + calibration: providedCalibration, + steeringMatrix, + output: providedOutput, + steeringReal: providedSteeringReal, + steeringImaginary: providedSteeringImaginary, signal, - pointBatchSize, + pointBatchSize = 64, } = options; const microphoneCount = arrayDefinition.microphones.length; - const output = new Float32Array(pointCount); - const steeringReal = new Float64Array(microphoneCount); - const steeringImaginary = new Float64Array(microphoneCount); + const output = providedOutput ?? new Float32Array(pointCount); + const steeringReal = providedSteeringReal ?? new Float64Array(microphoneCount); + const steeringImaginary = providedSteeringImaginary ?? new Float64Array(microphoneCount); const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound); const microphoneScale = 1 / microphoneCount; + const calibration = providedCalibration ?? createBinCalibration( + arrayDefinition, + bin, + sampleRate, + fftSize, + compensateFrequencyResponse, + ); + const phaseRealValues = steeringMatrix?.real ?? steeringReal; + const phaseImaginaryValues = steeringMatrix?.imaginary ?? steeringImaginary; + if (output.length !== pointCount) throw new RangeError("output length must match pointCount."); + if (steeringReal.length !== microphoneCount || steeringImaginary.length !== microphoneCount) { + throw new RangeError("steering workspace length must match microphone count."); + } + if ( + calibration.gains.length !== microphoneCount || + calibration.phases.length !== microphoneCount + ) { + throw new RangeError("calibration length must match microphone count."); + } + if ( + steeringMatrix && + ( + steeringMatrix.real.length !== pointCount * microphoneCount || + steeringMatrix.imaginary.length !== pointCount * microphoneCount + ) + ) { + throw new RangeError("steering matrix length must match point and microphone counts."); + } for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { throwIfAborted(signal); const distanceOffset = pointIndex * microphoneCount; - for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { - const microphone = arrayDefinition.microphones[microphoneIndex]; - const calibrationPhase = ((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180; - const phase = angularWaveNumber * distances[distanceOffset + microphoneIndex] - calibrationPhase; - const calibrationGain = dbToLinear(-(microphone.sensitivityDb ?? 0)); - steeringReal[microphoneIndex] = Math.cos(phase) * calibrationGain; - steeringImaginary[microphoneIndex] = Math.sin(phase) * calibrationGain; + const steeringOffset = steeringMatrix ? distanceOffset : 0; + if (!steeringMatrix) { + fillSteeringVector({ + angularWaveNumber, + distances, + distanceOffset, + calibration, + outputReal: steeringReal, + outputImaginary: steeringImaginary, + outputOffset: 0, + }); } let accumulatedPower = 0; @@ -73,8 +204,8 @@ export async function computeBinMap(options) { const spectrumIndex = spectrumOffset + microphoneIndex; const inputReal = spectrum.real[spectrumIndex]; const inputImaginary = spectrum.imaginary[spectrumIndex]; - const phaseReal = steeringReal[microphoneIndex]; - const phaseImaginary = steeringImaginary[microphoneIndex]; + const phaseReal = phaseRealValues[steeringOffset + microphoneIndex]; + const phaseImaginary = phaseImaginaryValues[steeringOffset + microphoneIndex]; sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary; sumImaginary += inputReal * phaseImaginary + inputImaginary * phaseReal; } @@ -86,11 +217,200 @@ export async function computeBinMap(options) { return output; } -export function estimateDasWorkload(options) { - const { channels, sampleRate, arrayDefinition, experiment } = options; - const { fftSize, hopSize } = experiment.stft; +/** + * Cache geometry and optional steering matrices for repeated CPU DAS analyses. + * + * Plans are intentionally stateful and must not be used concurrently. They are + * local runtime objects (Maps and workspaces), not serializable data contracts. + */ +export function createDasAnalysisPlan(options) { + const { + sampleRate, + arrayDefinition, + scene, + experiment, + maximumSteeringCacheBytes = DEFAULT_MAXIMUM_CPU_STEERING_CACHE_BYTES, + maximumPlanBytes = DEFAULT_MAXIMUM_CPU_ANALYSIS_PLAN_BYTES, + } = options; + if (!Number.isFinite(sampleRate) || sampleRate <= 0) { + throw new RangeError("sampleRate must be a positive finite number."); + } + if (!Array.isArray(arrayDefinition?.microphones) || arrayDefinition.microphones.length === 0) { + throw new TypeError("arrayDefinition.microphones must be a non-empty array."); + } + if (!Number.isFinite(scene?.speedOfSound) || scene.speedOfSound <= 0) { + throw new RangeError("scene.speedOfSound must be a positive finite number."); + } + if (!Number.isFinite(maximumSteeringCacheBytes) || maximumSteeringCacheBytes < 0) { + throw new RangeError("maximumSteeringCacheBytes must be a non-negative finite number."); + } + if (!Number.isSafeInteger(maximumPlanBytes) || maximumPlanBytes <= 0) { + throw new RangeError("maximumPlanBytes must be a positive safe integer."); + } + + const { fftSize } = experiment.stft; const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize)); const selectedBins = [...new Set(bandBins.flat())].sort((a, b) => a - b); + const microphoneCount = arrayDefinition.microphones.length; + const pointCount = experiment.scan.columns * experiment.scan.rows; + const pointMicrophonePairs = pointCount * microphoneCount; + const distanceMatrixBytes = pointMicrophonePairs * Float32Array.BYTES_PER_ELEMENT; + const retainedBaseBytes = + distanceMatrixBytes + + pointCount * Float32Array.BYTES_PER_ELEMENT + + microphoneCount * Float64Array.BYTES_PER_ELEMENT * 2; + if ( + !Number.isSafeInteger(pointMicrophonePairs) || + !Number.isSafeInteger(retainedBaseBytes) || + retainedBaseBytes > maximumPlanBytes + ) { + throw new RangeError( + "CPU analysis plan requires " + retainedBaseBytes.toLocaleString() + + " base 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 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; + + return { + kind: DAS_ANALYSIS_PLAN_KIND, + sampleRate, + arrayDefinition, + scene, + experiment, + microphoneCount, + pointCount, + bandBins, + selectedBins, + distances, + bandIndexesByBin: createBandIndexLookup(bandBins), + calibrations: new Map(), + steeringMatrices: new Map(), + steeringCacheEnabled, + steeringBytesPerBin, + requestedSteeringCacheBytes, + maximumSteeringCacheBytes, + maximumPlanBytes, + binMap, + steeringReal, + steeringImaginary, + busy: false, + statistics: { + analysisAttempts: 0, + completedAnalyses: 0, + distanceMatrixBuilds: 1, + distanceEvaluations: pointCount * microphoneCount, + avoidedDistanceEvaluations: 0, + calibrationBuilds: 0, + calibrationCacheHits: 0, + steeringMatrixBuilds: 0, + steeringCacheHits: 0, + steeringPointMicrophonePairs: 0, + avoidedSteeringPointMicrophonePairs: 0, + binMapUses: 0, + avoidedBinMapAllocations: 0, + retainedBytes: retainedBaseBytes, + }, + }; +} + +function assertCompatibleAnalysisPlan(plan, options) { + if (plan?.kind !== DAS_ANALYSIS_PLAN_KIND) { + throw new TypeError("analysisPlan was not created by createDasAnalysisPlan()."); + } + if ( + plan.sampleRate !== options.sampleRate || + plan.arrayDefinition !== options.arrayDefinition || + plan.scene !== options.scene || + plan.experiment !== options.experiment + ) { + throw new Error("analysisPlan must be used with the exact configuration objects that created it."); + } +} + +function planCalibration(plan, bin) { + let calibration = plan.calibrations.get(bin); + if (calibration) { + plan.statistics.calibrationCacheHits += 1; + return calibration; + } + calibration = createBinCalibration( + plan.arrayDefinition, + bin, + plan.sampleRate, + plan.experiment.stft.fftSize, + plan.experiment.beamforming.compensateFrequencyResponse !== false, + ); + plan.calibrations.set(bin, calibration); + plan.statistics.calibrationBuilds += 1; + plan.statistics.retainedBytes += calibration.gains.byteLength + calibration.phases.byteLength; + return calibration; +} + +async function planSteeringMatrix(plan, bin, calibration, signal, pointBatchSize) { + if (!plan.steeringCacheEnabled) return undefined; + let matrix = plan.steeringMatrices.get(bin); + if (matrix) { + plan.statistics.steeringCacheHits += 1; + plan.statistics.avoidedSteeringPointMicrophonePairs += + plan.pointCount * plan.microphoneCount; + return matrix; + } + matrix = await createSteeringMatrix({ + bin, + pointCount: plan.pointCount, + microphoneCount: plan.microphoneCount, + distances: plan.distances, + sampleRate: plan.sampleRate, + fftSize: plan.experiment.stft.fftSize, + speedOfSound: plan.scene.speedOfSound, + calibration, + signal, + pointBatchSize, + }); + plan.steeringMatrices.set(bin, matrix); + plan.statistics.steeringMatrixBuilds += 1; + plan.statistics.steeringPointMicrophonePairs += + plan.pointCount * plan.microphoneCount; + plan.statistics.retainedBytes += matrix.real.byteLength + matrix.imaginary.byteLength; + return matrix; +} + +function analysisCacheSummary(plan) { + return { + distanceMatrixBuilds: plan.statistics.distanceMatrixBuilds, + distanceEvaluations: plan.statistics.distanceEvaluations, + avoidedDistanceEvaluations: plan.statistics.avoidedDistanceEvaluations, + calibrationBuilds: plan.statistics.calibrationBuilds, + calibrationCacheHits: plan.statistics.calibrationCacheHits, + steeringCacheEnabled: plan.steeringCacheEnabled, + steeringMatrixBuilds: plan.statistics.steeringMatrixBuilds, + steeringCacheHits: plan.statistics.steeringCacheHits, + steeringPointMicrophonePairs: plan.statistics.steeringPointMicrophonePairs, + avoidedSteeringPointMicrophonePairs: + plan.statistics.avoidedSteeringPointMicrophonePairs, + avoidedBinMapAllocations: plan.statistics.avoidedBinMapAllocations, + retainedBytes: plan.statistics.retainedBytes, + }; +} + +export function estimateDasWorkload(options) { + const { channels, sampleRate, arrayDefinition, experiment, analysisPlan } = options; + const { fftSize, hopSize } = experiment.stft; + const bandBins = analysisPlan?.bandBins ?? + experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize)); + const selectedBins = analysisPlan?.selectedBins ?? + [...new Set(bandBins.flat())].sort((a, b) => a - b); const frameCount = getFrameCount(channels[0].length, fftSize, hopSize); const microphoneCount = arrayDefinition.microphones.length; const pointCount = experiment.scan.columns * experiment.scan.rows; @@ -100,7 +420,11 @@ export function estimateDasWorkload(options) { 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 baseWorkingBytes = distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes; + const steeringCacheBytes = analysisPlan?.steeringCacheEnabled + ? analysisPlan.requestedSteeringCacheBytes + : 0; + const baseWorkingBytes = + distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes + steeringCacheBytes; const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length; return { bandBins, @@ -110,6 +434,7 @@ export function estimateDasWorkload(options) { pointCount, distanceBytes, outputBytes, + steeringCacheBytes, spectrumBytesPerBin, baseWorkingBytes, complexMacCount, @@ -147,7 +472,7 @@ export function finalizeDasBands(rawMaps, workload, experiment) { }); } -export async function computeDasMap(options) { +async function computeDasMapInternal(options, analysisPlan) { const { channels, sampleRate, @@ -167,7 +492,13 @@ export async function computeDasMap(options) { throw new Error(`채널 수 ${channels.length}와 마이크 수 ${arrayDefinition.microphones.length}가 다릅니다.`); } if (!Number.isInteger(pointBatchSize) || pointBatchSize < 1) throw new Error("pointBatchSize는 1 이상의 정수여야 합니다."); - const workload = estimateDasWorkload({ channels, sampleRate, arrayDefinition, experiment }); + const workload = estimateDasWorkload({ + channels, + sampleRate, + arrayDefinition, + experiment, + analysisPlan, + }); if (workload.complexMacCount > maximumComplexMacs) { throw new Error( `CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` + @@ -199,15 +530,10 @@ export async function computeDasMap(options) { `현재 한도 ${maximumFftButterflies.toLocaleString()}회를 넘습니다. duration, FFT 크기 또는 대역폭을 줄이세요.`, ); } - const distances = createDistanceMatrix(arrayDefinition, experiment.scan); + const distances = analysisPlan?.distances ?? createDistanceMatrix(arrayDefinition, experiment.scan); const rawMaps = experiment.frequencyBands.map(() => new Float32Array(workload.pointCount)); - const bandIndexesByBin = new Map(); - workload.bandBins.forEach((bins, bandIndex) => { - bins.forEach((bin) => { - if (!bandIndexesByBin.has(bin)) bandIndexesByBin.set(bin, []); - bandIndexesByBin.get(bin).push(bandIndex); - }); - }); + const bandIndexesByBin = + analysisPlan?.bandIndexesByBin ?? createBandIndexLookup(workload.bandBins); let completedBins = 0; for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) { @@ -236,6 +562,17 @@ export async function computeDasMap(options) { throwIfAborted(signal); for (const bin of batchBins) { + const calibration = analysisPlan ? planCalibration(analysisPlan, bin) : undefined; + const steeringMatrix = analysisPlan + ? await planSteeringMatrix(analysisPlan, bin, calibration, signal, pointBatchSize) + : undefined; + if (analysisPlan) { + analysisPlan.statistics.binMapUses += 1; + analysisPlan.statistics.avoidedBinMapAllocations = Math.max( + 0, + analysisPlan.statistics.binMapUses - 1, + ); + } const binMap = await computeBinMap({ bin, spectrum: stft.spectra.get(bin), @@ -246,6 +583,13 @@ export async function computeDasMap(options) { sampleRate, fftSize: experiment.stft.fftSize, speedOfSound: scene.speedOfSound, + compensateFrequencyResponse: + experiment.beamforming.compensateFrequencyResponse !== false, + calibration, + steeringMatrix, + output: analysisPlan?.binMap, + steeringReal: analysisPlan?.steeringReal, + steeringImaginary: analysisPlan?.steeringImaginary, signal, pointBatchSize, }); @@ -270,5 +614,33 @@ export async function computeDasMap(options) { workload, fftButterflyCount, bands, + ...(analysisPlan ? { analysisCache: analysisCacheSummary(analysisPlan) } : {}), }; } + +export async function computeDasMap(options) { + const analysisPlan = options.analysisPlan; + if (!analysisPlan) return computeDasMapInternal(options, undefined); + assertCompatibleAnalysisPlan(analysisPlan, options); + if (analysisPlan.busy) { + throw new Error("analysisPlan is already in use by another DAS analysis."); + } + analysisPlan.busy = true; + analysisPlan.statistics.analysisAttempts += 1; + if (analysisPlan.statistics.analysisAttempts > 1) { + analysisPlan.statistics.avoidedDistanceEvaluations += + analysisPlan.pointCount * analysisPlan.microphoneCount; + } + try { + const result = await computeDasMapInternal(options, analysisPlan); + analysisPlan.statistics.completedAnalyses += 1; + result.analysisCache = { + ...result.analysisCache, + analysisAttempts: analysisPlan.statistics.analysisAttempts, + completedAnalyses: analysisPlan.statistics.completedAnalyses, + }; + return result; + } finally { + analysisPlan.busy = false; + } +} diff --git a/packages/acoustics-core/environment.js b/packages/acoustics-core/environment.js new file mode 100644 index 0000000..db8fbad --- /dev/null +++ b/packages/acoustics-core/environment.js @@ -0,0 +1,244 @@ +import { dbToLinear, distance3 } from "./math.js"; +import { hashString, whiteNoiseAt } from "./noise.js"; + +const WALLS = [ + { id: "xMin", axis: 0, boundary: "min" }, + { id: "xMax", axis: 0, boundary: "max" }, + { id: "yMin", axis: 1, boundary: "min" }, + { id: "yMax", axis: 1, boundary: "max" }, + { id: "zMin", axis: 2, boundary: "min" }, + { id: "zMax", axis: 2, boundary: "max" }, +]; + +const GEOMETRY_EPSILON = 1e-9; + +function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) { + return position.every( + (coordinate, axis) => + coordinate >= bounds.min[axis] - epsilon && coordinate <= bounds.max[axis] + epsilon, + ); +} + +function segmentIntersectsBox(start, end, box) { + let entry = 0; + let exit = 1; + for (let axis = 0; axis < 3; axis += 1) { + const delta = end[axis] - start[axis]; + if (Math.abs(delta) <= GEOMETRY_EPSILON) { + if (start[axis] < box.min[axis] || start[axis] > box.max[axis]) return false; + continue; + } + const first = (box.min[axis] - start[axis]) / delta; + const second = (box.max[axis] - start[axis]) / delta; + entry = Math.max(entry, Math.min(first, second)); + exit = Math.min(exit, Math.max(first, second)); + if (entry > exit) return false; + } + return exit > GEOMETRY_EPSILON && entry < 1 - GEOMETRY_EPSILON; +} + +function shortestCornerDetour(start, end, box) { + let shortestDistance = Infinity; + for (let mask = 0; mask < 8; mask += 1) { + const corner = [0, 1, 2].map((axis) => + mask & (1 << axis) ? box.max[axis] : box.min[axis], + ); + shortestDistance = Math.min( + shortestDistance, + distance3(start, corner) + distance3(corner, end), + ); + } + return shortestDistance; +} + +function imageSourceDistance(source, receiver, room, wall) { + if (!insideBounds(source, room) || !insideBounds(receiver, room)) return null; + const plane = room[wall.boundary][wall.axis]; + const image = [...source]; + image[wall.axis] = 2 * plane - source[wall.axis]; + const denominator = receiver[wall.axis] - image[wall.axis]; + if (Math.abs(denominator) <= GEOMETRY_EPSILON) return null; + const amount = (plane - image[wall.axis]) / denominator; + if (amount <= GEOMETRY_EPSILON || amount >= 1 - GEOMETRY_EPSILON) return null; + const reflectionPoint = image.map( + (coordinate, axis) => coordinate + (receiver[axis] - coordinate) * amount, + ); + for (let axis = 0; axis < 3; axis += 1) { + if (axis === wall.axis) continue; + if ( + reflectionPoint[axis] < room.min[axis] - GEOMETRY_EPSILON || + reflectionPoint[axis] > room.max[axis] + GEOMETRY_EPSILON + ) { + return null; + } + } + return distance3(image, receiver); +} + +function pathGain(distance, coefficient, referenceDistance, minimumDistance) { + return coefficient * referenceDistance / Math.max(distance, minimumDistance); +} + +function deterministicUnit(seed, index) { + return (whiteNoiseAt(seed, index) + 1) / 2; +} + +function stableSeed(scene, sourceId, receiverId) { + return ( + (scene.seed ?? 0) ^ + hashString(String(sourceId ?? "source")) ^ + Math.imul(hashString(String(receiverId ?? "receiver")), 0x45d9f3b) + ) >>> 0; +} + +/** + * Build a sparse, deterministic set of propagation paths for one source/receiver pair. + * + * The optional shoebox model is intentionally bounded: direct sound, six first-order + * image sources, at most one edge-detour per occluder, and a capped diffuse tail. + * It is useful for array/algorithm comparisons; it is not a room-acoustics solver. + */ +export function buildAcousticPaths(options) { + const { + scene, + sourcePosition, + receiverPosition, + sourceId, + receiverId, + referenceDistance = 1, + minimumDistance = 0.01, + } = options; + const speedOfSound = scene.speedOfSound; + const directDistance = distance3(sourcePosition, receiverPosition); + const acoustics = scene.acoustics; + if (!acoustics || acoustics.model === "free-field") { + return [{ + kind: "direct", + distance: directDistance, + delaySeconds: directDistance / speedOfSound, + gain: pathGain(directDistance, 1, referenceDistance, minimumDistance), + }]; + } + + const paths = []; + const blockers = (acoustics.occluders ?? []).filter((occluder) => + segmentIntersectsBox(sourcePosition, receiverPosition, occluder), + ); + const transmission = blockers.reduce( + (coefficient, occluder) => coefficient * dbToLinear(occluder.transmissionDb), + 1, + ); + paths.push({ + kind: "direct", + distance: directDistance, + delaySeconds: directDistance / speedOfSound, + gain: pathGain(directDistance, transmission, referenceDistance, minimumDistance), + blockedBy: blockers.map(({ id }) => id), + }); + + for (const occluder of blockers) { + const distance = shortestCornerDetour(sourcePosition, receiverPosition, occluder); + paths.push({ + kind: "diffraction", + occluderId: occluder.id, + distance, + delaySeconds: distance / speedOfSound, + gain: pathGain( + distance, + dbToLinear(occluder.diffractionGainDb), + referenceDistance, + minimumDistance, + ), + }); + } + + const reflections = acoustics.reflections; + const seed = stableSeed(scene, sourceId, receiverId); + if (reflections?.order === 1 && acoustics.room) { + const scattering = reflections.scattering ?? 0; + for (let wallIndex = 0; wallIndex < WALLS.length; wallIndex += 1) { + const wall = WALLS[wallIndex]; + const distance = imageSourceDistance( + sourcePosition, + receiverPosition, + acoustics.room, + wall, + ); + if (distance === null) continue; + const wallDb = reflections.perWallDb?.[wall.id] ?? reflections.wallGainDb; + const reflectionCoefficient = dbToLinear(wallDb); + const specularCoefficient = reflectionCoefficient * Math.sqrt(1 - scattering); + if (specularCoefficient > 0) { + paths.push({ + kind: "reflection", + surface: wall.id, + distance, + delaySeconds: distance / speedOfSound, + gain: pathGain(distance, specularCoefficient, referenceDistance, minimumDistance), + }); + } + if (scattering > 0) { + const jitterSeconds = 0.0002 + deterministicUnit(seed, wallIndex) * 0.0018; + const sign = whiteNoiseAt(seed ^ 0x9e3779b9, wallIndex) < 0 ? -1 : 1; + paths.push({ + kind: "scattering", + surface: wall.id, + distance: distance + jitterSeconds * speedOfSound, + delaySeconds: distance / speedOfSound + jitterSeconds, + gain: pathGain( + distance, + reflectionCoefficient * Math.sqrt(scattering) * sign, + referenceDistance, + minimumDistance, + ), + }); + } + } + } + + const diffuse = acoustics.diffuseReverb; + if (diffuse?.enabled) { + const tapGain = dbToLinear(diffuse.gainDb) / Math.sqrt(diffuse.tapCount); + for (let tapIndex = 0; tapIndex < diffuse.tapCount; tapIndex += 1) { + const progress = (tapIndex + 1) / (diffuse.tapCount + 1); + const tailTime = progress * diffuse.rt60Seconds; + const jitterLimit = Math.min(0.001, diffuse.rt60Seconds / (diffuse.tapCount * 4)); + const jitter = deterministicUnit(seed ^ 0xa511e9b3, tapIndex) * jitterLimit; + const decay = 10 ** ((-60 * tailTime / diffuse.rt60Seconds) / 20); + const sign = whiteNoiseAt(seed ^ 0x63d83595, tapIndex) < 0 ? -1 : 1; + paths.push({ + kind: "diffuse", + distance: directDistance + (tailTime + jitter) * speedOfSound, + delaySeconds: directDistance / speedOfSound + tailTime + jitter, + gain: pathGain( + directDistance, + tapGain * decay * sign, + referenceDistance, + minimumDistance, + ), + }); + } + } + + return paths; +} + +export function maximumAcousticDelaySeconds(options) { + const { scene, arrayDefinition, referenceDistance, minimumDistance } = options; + let maximum = 0; + for (const source of scene.sources) { + for (const microphone of arrayDefinition.microphones) { + const paths = buildAcousticPaths({ + scene, + sourcePosition: source.position, + receiverPosition: microphone.position, + sourceId: source.id, + receiverId: microphone.id, + referenceDistance, + minimumDistance, + }); + for (const path of paths) maximum = Math.max(maximum, path.delaySeconds); + } + } + return maximum; +} diff --git a/packages/acoustics-core/fractional-delay.js b/packages/acoustics-core/fractional-delay.js index f3e8c04..93724d8 100644 --- a/packages/acoustics-core/fractional-delay.js +++ b/packages/acoustics-core/fractional-delay.js @@ -1,4 +1,5 @@ import { fftInPlace } from "./fft.js"; +import { interpolateFrequencyResponse } from "./frequency-response.js"; import { TAU } from "./math.js"; export function nextPowerOfTwo(value) { @@ -21,25 +22,96 @@ export function createDelayWorkspace(fftSize) { return { real: new Float64Array(fftSize), imaginary: new Float64Array(fftSize) }; } -export function addFrequencyDomainDelay(options) { - const { output, spectrum, delaySamples, gain = 1, phaseOffsetDeg = 0, workspace } = options; - if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) throw new Error(`유효하지 않은 delay: ${delaySamples}`); +export function addFrequencyDomainPaths(options) { + const { + output, + spectrum, + paths, + gain = 1, + phaseOffsetDeg = 0, + frequencyResponse, + sampleRate, + workspace, + } = options; + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error("frequency-domain path가 하나 이상 필요합니다."); + } + for (const path of paths) { + if (!(path.delaySamples >= 0) || !Number.isFinite(path.delaySamples)) { + throw new Error("유효하지 않은 delay: " + path.delaySamples); + } + if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다."); + } if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) { throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다."); } + const hasFrequencyResponse = Array.isArray(frequencyResponse) && frequencyResponse.length > 0; + if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) { + throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다."); + } const size = spectrum.fftSize; const calibrationPhase = (phaseOffsetDeg * Math.PI) / 180; + const response = {}; + workspace.real.fill(0); + workspace.imaginary.fill(0); + const halfSize = size / 2; + const phaseReanchorInterval = 1024; + for (const path of paths) { + const phaseStep = (-TAU * path.delaySamples) / size; + const stepReal = Math.cos(phaseStep); + const stepImaginary = Math.sin(phaseStep); + let phaseReal = 1; + let phaseImaginary = 0; + for (let bin = 0; bin <= halfSize; bin += 1) { + workspace.real[bin] += phaseReal * path.gain; + workspace.imaginary[bin] += phaseImaginary * path.gain; + if (bin > 0 && bin < halfSize) { + const negativeBin = size - bin; + workspace.real[negativeBin] += phaseReal * path.gain; + workspace.imaginary[negativeBin] -= phaseImaginary * path.gain; + } + const nextBin = bin + 1; + if (nextBin % phaseReanchorInterval === 0) { + phaseReal = Math.cos(phaseStep * nextBin); + phaseImaginary = Math.sin(phaseStep * nextBin); + } else { + const nextReal = phaseReal * stepReal - phaseImaginary * stepImaginary; + phaseImaginary = phaseReal * stepImaginary + phaseImaginary * stepReal; + phaseReal = nextReal; + } + } + } + for (let bin = 0; bin < size; bin += 1) { const signedBin = bin <= size / 2 ? bin : bin - size; const phaseSign = signedBin === 0 ? 0 : signedBin > 0 ? 1 : -1; - const phase = (-TAU * signedBin * delaySamples) / size + phaseSign * calibrationPhase; - const phaseReal = Math.cos(phase); - const phaseImaginary = Math.sin(phase); + let responseGain = 1; + let responsePhase = 0; + if (hasFrequencyResponse) { + interpolateFrequencyResponse( + frequencyResponse, + (Math.abs(signedBin) * sampleRate) / size, + response, + ); + responseGain = response.linearGain; + responsePhase = response.phaseRadians; + } + const pathReal = workspace.real[bin]; + const pathImaginary = workspace.imaginary[bin]; + const responseAndCalibrationPhase = phaseSign * (calibrationPhase + responsePhase); + const calibrationReal = Math.cos(responseAndCalibrationPhase) * responseGain * gain; + const calibrationImaginary = Math.sin(responseAndCalibrationPhase) * responseGain * gain; + const transferReal = + pathReal * calibrationReal - pathImaginary * calibrationImaginary; + const transferImaginary = + pathReal * calibrationImaginary + pathImaginary * calibrationReal; const inputReal = spectrum.real[bin]; const inputImaginary = spectrum.imaginary[bin]; - workspace.real[bin] = inputReal * phaseReal - inputImaginary * phaseImaginary; - workspace.imaginary[bin] = inputReal * phaseImaginary + inputImaginary * phaseReal; + workspace.real[bin] = + inputReal * transferReal - inputImaginary * transferImaginary; + workspace.imaginary[bin] = + inputReal * transferImaginary + inputImaginary * transferReal; } // A real sequence has a real-valued Nyquist coefficient. Projecting this single self-conjugate @@ -48,6 +120,14 @@ export function addFrequencyDomainDelay(options) { fftInPlace(workspace.real, workspace.imaginary, true); const outputLength = Math.min(output.length, workspace.real.length); for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) { - output[sampleIndex] += workspace.real[sampleIndex] * gain; + output[sampleIndex] += workspace.real[sampleIndex]; } } + +export function addFrequencyDomainDelay(options) { + const { delaySamples, gain = 1, ...shared } = options; + return addFrequencyDomainPaths({ + ...shared, + paths: [{ delaySamples, gain }], + }); +} diff --git a/packages/acoustics-core/frequency-response.js b/packages/acoustics-core/frequency-response.js new file mode 100644 index 0000000..cb8e60a --- /dev/null +++ b/packages/acoustics-core/frequency-response.js @@ -0,0 +1,62 @@ +import { dbToLinear } from "./math.js"; + +export const MAX_FREQUENCY_RESPONSE_POINTS = 64; +export const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000; + +function phaseAt(point) { + return point.phaseDeg ?? 0; +} + +function wrapPhaseDeg(value) { + const wrapped = ((value + 180) % 360 + 360) % 360 - 180; + return Object.is(wrapped, -0) ? 0 : wrapped; +} + +function writeResponse(output, gainDb, phaseDeg) { + output.gainDb = gainDb; + output.phaseDeg = wrapPhaseDeg(phaseDeg); + output.linearGain = dbToLinear(gainDb); + output.phaseRadians = (output.phaseDeg * Math.PI) / 180; + return output; +} + +/** + * Piecewise-linear microphone response interpolation. + * + * Frequencies outside the measured curve use the nearest endpoint. Phase follows + * the shortest wrapped path between points, avoiding a discontinuity at ±180°. + * Passing an output object lets FFT loops reuse storage and avoid per-bin garbage. + */ +export function interpolateFrequencyResponse(points, frequencyHz, output = {}) { + if (!Number.isFinite(frequencyHz) || frequencyHz < 0) { + throw new RangeError(`frequencyHz must be a finite non-negative number: ${frequencyHz}`); + } + if (points === undefined || points === null || points.length === 0) { + return writeResponse(output, 0, 0); + } + if (!Array.isArray(points)) throw new TypeError("frequency response points must be an array."); + + const first = points[0]; + const last = points[points.length - 1]; + if (frequencyHz <= first.frequencyHz) { + return writeResponse(output, first.gainDb, phaseAt(first)); + } + if (frequencyHz >= last.frequencyHz) { + return writeResponse(output, last.gainDb, phaseAt(last)); + } + + let low = 0; + let high = points.length - 1; + while (high - low > 1) { + const middle = Math.floor((low + high) / 2); + if (frequencyHz < points[middle].frequencyHz) high = middle; + else low = middle; + } + const left = points[low]; + const right = points[high]; + const ratio = (frequencyHz - left.frequencyHz) / (right.frequencyHz - left.frequencyHz); + const gainDb = left.gainDb + (right.gainDb - left.gainDb) * ratio; + const leftPhase = phaseAt(left); + const phaseDelta = wrapPhaseDeg(phaseAt(right) - leftPhase); + return writeResponse(output, gainDb, leftPhase + phaseDelta * ratio); +} diff --git a/packages/acoustics-core/index.js b/packages/acoustics-core/index.js index 5085ad4..ef034eb 100644 --- a/packages/acoustics-core/index.js +++ b/packages/acoustics-core/index.js @@ -1,11 +1,14 @@ export * from "./das.js"; export * from "./evaluation.js"; +export * from "./environment.js"; export * from "./fft.js"; +export * from "./frequency-response.js"; export * from "./fractional-delay.js"; export * from "./math.js"; export * from "./mixdown.js"; export * from "./noise.js"; export * from "./propagation.js"; +export * from "./realtime-localizer.js"; export * from "./signal.js"; export * from "./stft.js"; export * from "./trajectory.js"; diff --git a/packages/acoustics-core/propagation.js b/packages/acoustics-core/propagation.js index 27d3af8..8ad0b0f 100644 --- a/packages/acoustics-core/propagation.js +++ b/packages/acoustics-core/propagation.js @@ -1,10 +1,12 @@ import { dbToLinear, distance3 } from "./math.js"; import { addFrequencyDomainDelay, + addFrequencyDomainPaths, createDelayWorkspace, nextPowerOfTwo, prepareDelaySpectrum, } from "./fractional-delay.js"; +import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js"; import { renderSignal } from "./signal.js"; export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) { @@ -27,15 +29,13 @@ export function synthesizeMicrophoneSignals(options) { } = options; const sampleCount = Math.ceil(scene.duration * scene.sampleRate); const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1; - let maximumDelaySamples = 0; - for (const source of scene.sources) { - for (const microphone of arrayDefinition.microphones) { - maximumDelaySamples = Math.max( - maximumDelaySamples, - (distance3(source.position, microphone.position) / scene.speedOfSound) * scene.sampleRate, - ); - } - } + const maximumDelaySamples = + maximumAcousticDelaySeconds({ + scene, + arrayDefinition, + referenceDistance, + minimumDistance, + }) * scene.sampleRate; const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2); const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4; const estimatedBufferBytes = @@ -56,22 +56,49 @@ export function synthesizeMicrophoneSignals(options) { }), })); + const acousticPathCounts = {}; + let acousticPathCount = 0; renderedSources.forEach(({ definition, samples }) => { - const distances = arrayDefinition.microphones.map((microphone) => distance3(definition.position, microphone.position)); - const maximumDelaySamples = (Math.max(...distances) / scene.speedOfSound) * scene.sampleRate; - const spectrum = prepareDelaySpectrum(samples, maximumDelaySamples); + const microphonePaths = arrayDefinition.microphones.map((microphone) => + buildAcousticPaths({ + scene, + sourcePosition: definition.position, + receiverPosition: microphone.position, + sourceId: definition.id, + receiverId: microphone.id, + referenceDistance, + minimumDistance, + }), + ); + let sourceMaximumDelaySamples = 0; + for (const paths of microphonePaths) { + for (const path of paths) { + sourceMaximumDelaySamples = Math.max( + sourceMaximumDelaySamples, + path.delaySeconds * scene.sampleRate, + ); + } + } + const spectrum = prepareDelaySpectrum(samples, sourceMaximumDelaySamples); const workspace = createDelayWorkspace(spectrum.fftSize); arrayDefinition.microphones.forEach((microphone, microphoneIndex) => { - const distance = distances[microphoneIndex]; - const delaySamples = (distance / scene.speedOfSound) * scene.sampleRate; + const paths = microphonePaths[microphoneIndex]; + for (const path of paths) { + acousticPathCount += 1; + acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1; + } const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0); - const amplitude = sphericalAttenuation(distance, referenceDistance, minimumDistance) * sensitivity; - addFrequencyDomainDelay({ + addFrequencyDomainPaths({ output: channels[microphoneIndex], spectrum, - delaySamples, - gain: amplitude, + paths: paths.map((path) => ({ + delaySamples: path.delaySeconds * scene.sampleRate, + gain: path.gain, + })), + gain: sensitivity, phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, + frequencyResponse: microphone.frequencyResponse, + sampleRate: scene.sampleRate, workspace, }); }); @@ -93,6 +120,8 @@ export function synthesizeMicrophoneSignals(options) { delaySamples: 0, gain: dbToLinear(microphone.sensitivityDb ?? 0), phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, + frequencyResponse: microphone.frequencyResponse, + sampleRate: scene.sampleRate, workspace: createDelayWorkspace(spectrum.fftSize), }); }); @@ -127,5 +156,8 @@ export function synthesizeMicrophoneSignals(options) { appliedScale, clippedSampleCount, estimatedBufferBytes, + acousticModel: scene.acoustics?.model ?? "free-field", + acousticPathCount, + acousticPathCounts, }; } diff --git a/packages/acoustics-core/realtime-localizer.js b/packages/acoustics-core/realtime-localizer.js new file mode 100644 index 0000000..0a6bd31 --- /dev/null +++ b/packages/acoustics-core/realtime-localizer.js @@ -0,0 +1,545 @@ +import { computeDasMap, createDasAnalysisPlan } from "./das.js"; +import { + MAX_FREQUENCY_RESPONSE_HZ, + MAX_FREQUENCY_RESPONSE_POINTS, +} 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; + +function requirePositiveFinite(value, label) { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${label} must be a positive finite number.`); + } + return value; +} + +function requirePositiveInteger(value, label) { + if (!Number.isInteger(value) || value <= 0) { + throw new RangeError(`${label} must be a positive integer.`); + } + return value; +} + +function requirePowerOfTwo(value, label) { + requirePositiveInteger(value, label); + if (value < 2 || (value & (value - 1)) !== 0) { + throw new RangeError(`${label} must be a power of two greater than one.`); + } + return value; +} + +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.`); + } + return Object.freeze([...position]); +} + +function copyFrequencyResponse(value, label) { + if (value === undefined) return undefined; + if ( + !Array.isArray(value) || + value.length < 2 || + value.length > MAX_FREQUENCY_RESPONSE_POINTS + ) { + throw new RangeError( + label + " must contain 2-" + MAX_FREQUENCY_RESPONSE_POINTS + " points.", + ); + } + let previousFrequency = -Infinity; + return Object.freeze(value.map((point, index) => { + const pointLabel = label + "[" + index + "]"; + if (!point || typeof point !== "object") { + throw new TypeError(pointLabel + " must be an object."); + } + if ( + !Number.isFinite(point.frequencyHz) || + point.frequencyHz < 0 || + point.frequencyHz > MAX_FREQUENCY_RESPONSE_HZ || + point.frequencyHz <= previousFrequency + ) { + throw new RangeError(pointLabel + ".frequencyHz must be finite and strictly increasing."); + } + if (!Number.isFinite(point.gainDb) || point.gainDb < -120 || point.gainDb > 120) { + throw new RangeError(pointLabel + ".gainDb must lie inside -120..120 dB."); + } + if ( + point.phaseDeg !== undefined && + (!Number.isFinite(point.phaseDeg) || point.phaseDeg < -180 || point.phaseDeg > 180) + ) { + throw new RangeError(pointLabel + ".phaseDeg must lie inside -180..180 degrees."); + } + previousFrequency = point.frequencyHz; + return Object.freeze({ ...point }); + })); +} + +function copyArrayDefinition(value) { + if (!value || !Array.isArray(value.microphones) || value.microphones.length === 0) { + throw new TypeError("array.microphones must be a non-empty array."); + } + const microphoneIds = new Set(); + const microphones = value.microphones.map((microphone, index) => { + const id = microphone.id ?? "mic-" + index; + if (microphoneIds.has(id)) throw new Error("duplicate microphone id: " + id); + microphoneIds.add(id); + if ( + microphone.sensitivityDb !== undefined && + !Number.isFinite(microphone.sensitivityDb) + ) { + throw new TypeError("array.microphones[" + index + "].sensitivityDb must be finite."); + } + if ( + microphone.phaseOffsetDeg !== undefined && + !Number.isFinite(microphone.phaseOffsetDeg) + ) { + throw new TypeError("array.microphones[" + index + "].phaseOffsetDeg must be finite."); + } + const frequencyResponse = copyFrequencyResponse( + microphone.frequencyResponse, + "array.microphones[" + index + "].frequencyResponse", + ); + return Object.freeze({ + ...microphone, + id, + position: freezePosition( + microphone.position, + "array.microphones[" + index + "].position", + ), + ...(frequencyResponse ? { frequencyResponse: Object.freeze(frequencyResponse) } : {}), + }); + }); + return Object.freeze({ + ...value, + microphones: Object.freeze(microphones), + }); +} + +function copyScan(scan) { + if (!scan || typeof scan !== "object") throw new TypeError("scan must be an object."); + const numericFields = ["xMin", "xMax", "yMin", "yMax", "z"]; + for (const field of numericFields) { + if (!Number.isFinite(scan[field])) throw new TypeError(`scan.${field} must be finite.`); + } + const columns = requirePositiveInteger(scan.columns, "scan.columns"); + const rows = requirePositiveInteger(scan.rows, "scan.rows"); + if (scan.xMax < scan.xMin || scan.yMax < scan.yMin) { + throw new RangeError("scan bounds must satisfy min <= max."); + } + return Object.freeze({ ...scan, columns, rows }); +} + +function copyBands(bands, sampleRate) { + if (!Array.isArray(bands) || bands.length === 0) { + throw new TypeError("bands must be a non-empty array."); + } + const nyquist = sampleRate / 2; + return Object.freeze(bands.map((band, index) => { + if ( + !Number.isFinite(band?.minHz) || + !Number.isFinite(band?.maxHz) || + band.minHz < 0 || + band.maxHz <= band.minHz || + band.maxHz >= nyquist + ) { + throw new RangeError(`bands[${index}] must lie inside 0–${nyquist} Hz.`); + } + return Object.freeze({ + ...band, + id: band.id ?? `band-${index + 1}`, + color: band.color ?? "#65f2bf", + }); + })); +} + +function validateChannels(channels, expectedChannelCount) { + if (!Array.isArray(channels) || channels.length !== expectedChannelCount) { + throw new Error( + `channel count ${channels?.length ?? 0} does not match microphone count ${expectedChannelCount}.`, + ); + } + if (!channels.every((channel) => channel instanceof Float32Array)) { + throw new TypeError("every channel must be a Float32Array."); + } + const sampleCount = channels[0].length; + if (channels.some((channel) => channel.length !== sampleCount)) { + throw new Error("all channels must have the same sample count."); + } + return sampleCount; +} + +function createAbortError(signal) { + if (signal?.reason instanceof Error && signal.reason.name === "AbortError") return signal.reason; + const error = new Error("localization was canceled."); + error.name = "AbortError"; + return error; +} + +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, + })); +} + +/** + * Compile immutable microphone/scan/band configuration and CPU geometry caches. + * + * This API deliberately has no Scene or synthesis dependency: callers provide + * sampled microphone channels directly to localizeMultichannel(). + */ +export function createLocalizationPlan(config) { + if (!config || typeof config !== "object") throw new TypeError("config must be an object."); + const sampleRate = requirePositiveFinite(config.sampleRate, "sampleRate"); + const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition); + const scan = copyScan(config.scan); + const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate); + const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize"); + const hopSize = requirePositiveInteger( + config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2, + "stftHopSize", + ); + if (hopSize > fftSize) throw new RangeError("stftHopSize must not exceed fftSize."); + const speedOfSound = requirePositiveFinite(config.speedOfSound ?? 343, "speedOfSound"); + const normalization = config.normalization ?? config.beamforming?.normalization ?? "peak"; + if (normalization !== "peak" && normalization !== "none") { + throw new RangeError('normalization must be "peak" or "none".'); + } + const compensateFrequencyResponse = + config.compensateFrequencyResponse ?? + config.beamforming?.compensateFrequencyResponse ?? + true; + if (typeof compensateFrequencyResponse !== "boolean") { + throw new TypeError("compensateFrequencyResponse must be boolean."); + } + + const scene = Object.freeze({ speedOfSound }); + const experiment = Object.freeze({ + stft: Object.freeze({ fftSize, hopSize, window: "hann" }), + scan, + frequencyBands, + beamforming: Object.freeze({ normalization, compensateFrequencyResponse }), + }); + const dasPlan = createDasAnalysisPlan({ + sampleRate, + arrayDefinition, + scene, + experiment, + maximumSteeringCacheBytes: config.maximumSteeringCacheBytes, + maximumPlanBytes: config.maximumPlanBytes, + }); + + return Object.freeze({ + kind: REALTIME_LOCALIZATION_PLAN_KIND, + sampleRate, + channelCount: arrayDefinition.microphones.length, + arrayDefinition, + scan, + bands: frequencyBands, + stft: experiment.stft, + speedOfSound, + normalization, + scene, + experiment, + dasPlan, + computeOptions: Object.freeze({ ...(config.computeOptions ?? {}) }), + }); +} + +function requireLocalizationPlan(plan) { + if (plan?.kind !== REALTIME_LOCALIZATION_PLAN_KIND) { + throw new TypeError("plan must be created by createLocalizationPlan()."); + } + return plan; +} + +/** + * Localize one already-sampled multichannel block. + */ +export async function localizeMultichannel(options) { + if (!options || typeof options !== "object") throw new TypeError("options must be an object."); + const plan = options.plan + ? requireLocalizationPlan(options.plan) + : createLocalizationPlan(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 das = await computeDasMap({ + ...plan.computeOptions, + ...(options.computeOptions ?? {}), + channels: options.channels, + sampleRate: plan.sampleRate, + arrayDefinition: plan.arrayDefinition, + scene: plan.scene, + experiment: plan.experiment, + analysisPlan: plan.dasPlan, + signal: options.signal, + onProgress: options.onProgress, + }); + return { + ...das, + sampleRate: plan.sampleRate, + sampleCount, + duration: sampleCount / plan.sampleRate, + locations: locationsFromBands(das.bands), + }; +} + +/** + * 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. + */ +export class StreamingDasLocalizer { + constructor(config) { + this.plan = createLocalizationPlan(config); + this.analysisWindowSamples = requirePositiveInteger( + config.analysisWindowSamples ?? + Math.max(this.plan.stft.fftSize, Math.ceil(this.plan.sampleRate / 10)), + "analysisWindowSamples", + ); + if (this.analysisWindowSamples < this.plan.stft.fftSize) { + throw new RangeError("analysisWindowSamples must be at least fftSize."); + } + this.analysisHopSamples = requirePositiveInteger( + config.analysisHopSamples ?? this.analysisWindowSamples, + "analysisHopSamples", + ); + if (this.analysisHopSamples > this.analysisWindowSamples) { + throw new RangeError("analysisHopSamples must not exceed analysisWindowSamples."); + } + this.maximumBufferedSamples = requirePositiveInteger( + config.maximumBufferedSamples ?? this.analysisWindowSamples * 4, + "maximumBufferedSamples", + ); + if (this.maximumBufferedSamples < this.analysisWindowSamples) { + throw new RangeError("maximumBufferedSamples must be at least analysisWindowSamples."); + } + this.maximumResultsPerCall = requirePositiveInteger( + config.maximumResultsPerCall ?? DEFAULT_STREAM_RESULTS_PER_CALL, + "maximumResultsPerCall", + ); + if (this.maximumResultsPerCall > MAX_STREAM_RESULTS_PER_CALL) { + throw new RangeError( + `maximumResultsPerCall must not exceed ${MAX_STREAM_RESULTS_PER_CALL}.`, + ); + } + this.maximumInputBufferBytes = requirePositiveInteger( + config.maximumInputBufferBytes ?? DEFAULT_MAXIMUM_STREAM_INPUT_BYTES, + "maximumInputBufferBytes", + ); + this.retainedInputBytes = + this.plan.channelCount * + (this.maximumBufferedSamples + this.analysisWindowSamples) * + Float32Array.BYTES_PER_ELEMENT; + if ( + !Number.isSafeInteger(this.retainedInputBytes) || + this.retainedInputBytes > this.maximumInputBufferBytes + ) { + throw new RangeError( + "stream input buffers require " + this.retainedInputBytes.toLocaleString() + + " bytes, above maximumInputBufferBytes " + + this.maximumInputBufferBytes.toLocaleString() + ".", + ); + } + + this.buffers = Array.from( + { length: this.plan.channelCount }, + () => new Float32Array(this.maximumBufferedSamples), + ); + this.analysisChannels = Array.from( + { length: this.plan.channelCount }, + () => new Float32Array(this.analysisWindowSamples), + ); + this.startIndex = 0; + this.bufferedSamples = 0; + this.bufferStartSample = 0; + this.totalReceivedSamples = 0; + this.droppedSamples = 0; + this.analysisAttempts = 0; + this.completedAnalyses = 0; + this.failedAnalyses = 0; + this.nextSequence = 0; + this.busy = false; + } + + availableAnalysisCount() { + if (this.bufferedSamples < this.analysisWindowSamples) return 0; + return 1 + Math.floor( + (this.bufferedSamples - this.analysisWindowSamples) / this.analysisHopSamples, + ); + } + + getState() { + return { + sampleRate: this.plan.sampleRate, + channelCount: this.plan.channelCount, + analysisWindowSamples: this.analysisWindowSamples, + analysisHopSamples: this.analysisHopSamples, + maximumBufferedSamples: this.maximumBufferedSamples, + maximumInputBufferBytes: this.maximumInputBufferBytes, + maximumResultsPerCall: this.maximumResultsPerCall, + bufferedSamples: this.bufferedSamples, + availableAnalysisCount: this.availableAnalysisCount(), + bufferStartSample: this.bufferStartSample, + totalReceivedSamples: this.totalReceivedSamples, + droppedSamples: this.droppedSamples, + analysisAttempts: this.analysisAttempts, + completedAnalyses: this.completedAnalyses, + failedAnalyses: this.failedAnalyses, + nextSequence: this.nextSequence, + busy: this.busy, + retainedInputBytes: this.retainedInputBytes, + }; + } + + consume(sampleCount, countAsDropped) { + if (sampleCount <= 0) return; + this.startIndex = (this.startIndex + sampleCount) % this.maximumBufferedSamples; + this.bufferedSamples -= sampleCount; + this.bufferStartSample += sampleCount; + if (countAsDropped) this.droppedSamples += sampleCount; + } + + append(channels) { + const chunkSamples = validateChannels(channels, this.plan.channelCount); + if (chunkSamples === 0) return this.getState(); + const capacity = this.maximumBufferedSamples; + const receivedBefore = this.totalReceivedSamples; + + if (chunkSamples >= capacity) { + const retainedStart = chunkSamples - capacity; + for (let channelIndex = 0; channelIndex < this.plan.channelCount; channelIndex += 1) { + this.buffers[channelIndex].set(channels[channelIndex].subarray(retainedStart)); + } + this.droppedSamples += this.bufferedSamples + retainedStart; + this.startIndex = 0; + this.bufferedSamples = capacity; + this.bufferStartSample = receivedBefore + retainedStart; + this.totalReceivedSamples += chunkSamples; + return this.getState(); + } + + const overflow = Math.max(0, this.bufferedSamples + chunkSamples - capacity); + this.consume(overflow, true); + const writeIndex = (this.startIndex + this.bufferedSamples) % capacity; + const firstLength = Math.min(chunkSamples, capacity - writeIndex); + for (let channelIndex = 0; channelIndex < this.plan.channelCount; channelIndex += 1) { + const source = channels[channelIndex]; + const target = this.buffers[channelIndex]; + target.set(source.subarray(0, firstLength), writeIndex); + if (firstLength < chunkSamples) { + target.set(source.subarray(firstLength), 0); + } + } + this.bufferedSamples += chunkSamples; + this.totalReceivedSamples += chunkSamples; + return this.getState(); + } + + copyAnalysisWindow() { + const firstLength = Math.min( + this.analysisWindowSamples, + this.maximumBufferedSamples - this.startIndex, + ); + for (let channelIndex = 0; channelIndex < this.plan.channelCount; channelIndex += 1) { + const source = this.buffers[channelIndex]; + const target = this.analysisChannels[channelIndex]; + target.set(source.subarray(this.startIndex, this.startIndex + firstLength), 0); + if (firstLength < this.analysisWindowSamples) { + target.set( + source.subarray(0, this.analysisWindowSamples - firstLength), + firstLength, + ); + } + } + } + + async analyzeAvailable(options = {}) { + if (this.busy) throw new Error("analyzeAvailable() is already running."); + const maximumResults = options.maximumResults ?? this.maximumResultsPerCall; + requirePositiveInteger(maximumResults, "maximumResults"); + if (maximumResults > this.maximumResultsPerCall) { + throw new RangeError( + `maximumResults cannot exceed configured bound ${this.maximumResultsPerCall}.`, + ); + } + + this.busy = true; + const results = []; + try { + while ( + results.length < maximumResults && + this.bufferedSamples >= this.analysisWindowSamples + ) { + throwIfAborted(options.signal); + const startSample = this.bufferStartSample; + const endSample = startSample + this.analysisWindowSamples; + this.copyAnalysisWindow(); + this.consume(this.analysisHopSamples, false); + this.analysisAttempts += 1; + try { + const 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.failedAnalyses += 1; + throw error; + } + } + return results; + } finally { + this.busy = false; + } + } + + async push(channels, options = {}) { + this.append(channels); + return this.analyzeAvailable(options); + } + + reset() { + if (this.busy) throw new Error("cannot reset while analysis is running."); + this.startIndex = 0; + this.bufferedSamples = 0; + this.bufferStartSample = 0; + this.totalReceivedSamples = 0; + this.droppedSamples = 0; + this.analysisAttempts = 0; + this.completedAnalyses = 0; + this.failedAnalyses = 0; + this.nextSequence = 0; + } +} + +export function createStreamingLocalizer(config) { + return new StreamingDasLocalizer(config); +} diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js index 1fad070..f800255 100644 --- a/packages/contracts/validation.js +++ b/packages/contracts/validation.js @@ -4,6 +4,8 @@ export const SCHEMA_VERSION = "1.0.0"; const MAX_MICROPHONES = 1024; const MAX_SOURCES = 32; const MAX_GRID_SIDE = 512; +const MAX_FREQUENCY_RESPONSE_POINTS = 64; +const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000; /** @typedef {{ path: string, message: string }} ValidationIssue */ @@ -65,6 +67,28 @@ function requirePosition(value, path, issues) { return true; } +function requireBounds(value, path, issues) { + if (!requireRecord(value, path, issues)) return false; + const minimumValid = requirePosition(value.min, path + ".min", issues); + const maximumValid = requirePosition(value.max, path + ".max", issues); + if (!minimumValid || !maximumValid) return false; + let ordered = true; + for (let axis = 0; axis < 3; axis += 1) { + if (value.min[axis] >= value.max[axis]) { + addIssue(issues, path, "min 좌표는 모든 축에서 max 좌표보다 작아야 합니다."); + ordered = false; + break; + } + } + return ordered; +} + +function boundsContain(bounds, position) { + return position.every( + (coordinate, axis) => coordinate >= bounds.min[axis] && coordinate <= bounds.max[axis], + ); +} + function validateHeader(value, expectedKind, issues) { if (!requireRecord(value, "$", issues)) return false; if (value.schemaVersion !== SCHEMA_VERSION) { @@ -131,6 +155,37 @@ function validateArrayRelationships(value) { if (microphone.sensitivityDb !== undefined) { requireFinite(microphone.sensitivityDb, `${path}.sensitivityDb`, issues, { min: -120, max: 120 }); } + if (microphone.frequencyResponse !== undefined) { + const responsePath = `${path}.frequencyResponse`; + if (!Array.isArray(microphone.frequencyResponse)) { + addIssue(issues, responsePath, "주파수 응답점 배열이어야 합니다."); + } else { + if (microphone.frequencyResponse.length < 2 || microphone.frequencyResponse.length > MAX_FREQUENCY_RESPONSE_POINTS) { + addIssue(issues, responsePath, `주파수 응답점은 2~${MAX_FREQUENCY_RESPONSE_POINTS}개여야 합니다.`); + } + let previousFrequency = -Infinity; + microphone.frequencyResponse.forEach((point, pointIndex) => { + const pointPath = `${responsePath}[${pointIndex}]`; + if (!requireRecord(point, pointPath, issues)) return; + const frequencyValid = requireFinite(point.frequencyHz, `${pointPath}.frequencyHz`, issues, { + min: 0, + max: MAX_FREQUENCY_RESPONSE_HZ, + }); + requireFinite(point.gainDb, `${pointPath}.gainDb`, issues, { min: -120, max: 120 }); + if (point.phaseDeg !== undefined) { + requireFinite(point.phaseDeg, `${pointPath}.phaseDeg`, issues, { min: -180, max: 180 }); + } + if (frequencyValid && point.frequencyHz <= previousFrequency) { + addIssue( + issues, + `${pointPath}.frequencyHz`, + "주파수 응답점은 frequencyHz가 중복 없이 엄격히 증가하도록 정렬하세요.", + ); + } + if (frequencyValid) previousFrequency = point.frequencyHz; + }); + } + } }); for (const duplicate of findDuplicates(ids)) { addIssue(issues, "$.microphones", `중복 마이크 id: ${duplicate}`); @@ -178,6 +233,44 @@ function validateSceneRelationships(value) { requireFinite(value.speedOfSound, "$.speedOfSound", issues, { min: 250, max: 400 }); requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff }); + let acousticRoom = null; + if (value.acoustics !== undefined && requireRecord(value.acoustics, "$.acoustics", issues)) { + const acoustics = value.acoustics; + if (acoustics.model === "free-field") { + const unusedFields = ["room", "reflections", "diffuseReverb", "occluders"] + .filter((key) => acoustics[key] !== undefined); + if (unusedFields.length > 0) { + addIssue( + issues, + "$.acoustics", + "free-field 모델에서는 사용되지 않는 설정을 제거하세요: " + unusedFields.join(", "), + ); + } + } else if (acoustics.model === "shoebox") { + if (acoustics.room === undefined) { + addIssue(issues, "$.acoustics.room", "shoebox 모델에는 room 경계가 필요합니다."); + } else if (requireBounds(acoustics.room, "$.acoustics.room", issues)) { + acousticRoom = acoustics.room; + } + } + if (Array.isArray(acoustics.occluders)) { + const occluderIds = []; + acoustics.occluders.forEach((occluder, index) => { + const path = "$.acoustics.occluders[" + index + "]"; + if (!requireRecord(occluder, path, issues)) return; + if (requireString(occluder.id, path + ".id", issues)) occluderIds.push(occluder.id); + if (requireBounds(occluder, path, issues) && acousticRoom) { + if (!boundsContain(acousticRoom, occluder.min) || !boundsContain(acousticRoom, occluder.max)) { + addIssue(issues, path, "occluder는 room 경계 안에 있어야 합니다."); + } + } + }); + for (const duplicate of findDuplicates(occluderIds)) { + addIssue(issues, "$.acoustics.occluders", "중복 occluder id: " + duplicate); + } + } + } + const assets = Array.isArray(value.assets) ? value.assets : []; const assetsById = new Map(); const allIds = []; @@ -231,7 +324,10 @@ function validateSceneRelationships(value) { if (!requireRecord(source, path, issues)) return; if (requireString(source.id, `${path}.id`, issues)) sourceIds.push(source.id); requireString(source.name, `${path}.name`, issues); - requirePosition(source.position, `${path}.position`, issues); + const sourcePositionValid = requirePosition(source.position, `${path}.position`, issues); + if (sourcePositionValid && acousticRoom && !boundsContain(acousticRoom, source.position)) { + addIssue(issues, `${path}.position`, "shoebox room 경계 안에 있어야 합니다."); + } if (Array.isArray(source.trajectory)) { let previousTime = -Infinity; source.trajectory.forEach((keyframe, keyframeIndex) => { @@ -241,7 +337,10 @@ function validateSceneRelationships(value) { min: 0, max: sceneDurationValid ? value.duration : 300, }); - requirePosition(keyframe.position, `${keyframePath}.position`, issues); + const keyframePositionValid = requirePosition(keyframe.position, `${keyframePath}.position`, issues); + if (keyframePositionValid && acousticRoom && !boundsContain(acousticRoom, keyframe.position)) { + addIssue(issues, `${keyframePath}.position`, "shoebox room 경계 안에 있어야 합니다."); + } if (timeValid && keyframe.time <= previousTime) { addIssue(issues, `${keyframePath}.time`, "trajectory time은 앞 keyframe보다 커야 합니다."); } @@ -346,6 +445,16 @@ function validateExperimentRelationships(value, context = {}) { max: 10, exclusiveMin: true, }); + if ( + value.beamforming.compensateFrequencyResponse !== undefined && + typeof value.beamforming.compensateFrequencyResponse !== "boolean" + ) { + addIssue( + issues, + "$.beamforming.compensateFrequencyResponse", + "주파수 응답 보정 사용 여부는 true 또는 false여야 합니다.", + ); + } } if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) { const threshold = value.evaluation.detectionThresholdRatio; diff --git a/packages/webgpu-kernels/das.js b/packages/webgpu-kernels/das.js index 74fea32..fab1523 100644 --- a/packages/webgpu-kernels/das.js +++ b/packages/webgpu-kernels/das.js @@ -3,6 +3,8 @@ import { estimateDasWorkload, finalizeDasBands, } from "../acoustics-core/das.js"; +import { interpolateFrequencyResponse } from "../acoustics-core/frequency-response.js"; +import { dbToLinear } from "../acoustics-core/math.js"; import { extractStftBins } from "../acoustics-core/stft.js"; import { DAS_PARAMS_BYTE_LENGTH, @@ -66,13 +68,45 @@ function minimumBufferSize(byteLength) { return Math.max(4, Math.ceil(byteLength / 4) * 4); } -export function packStftBatch(stft, bins) { +export function packStftBatch(stft, bins, options = {}) { if (!stft || !Number.isInteger(stft.frameCount) || !Number.isInteger(stft.microphoneCount)) { throw new TypeError("유효한 STFT 결과가 필요합니다."); } if (!Array.isArray(bins) || bins.length === 0) throw new TypeError("하나 이상의 FFT bin이 필요합니다."); const valuesPerBin = stft.frameCount * stft.microphoneCount; const packed = new Float32Array(bins.length * valuesPerBin * 2); + const microphones = options.microphones; + const compensateFrequencyResponse = options.compensateFrequencyResponse !== false; + const hasFrequencyResponse = + compensateFrequencyResponse && + Array.isArray(microphones) && + microphones.some((microphone) => Array.isArray(microphone.frequencyResponse)); + if (hasFrequencyResponse) { + if (microphones.length !== stft.microphoneCount) { + throw new Error("STFT microphone 수와 주파수 응답 calibration 수가 다릅니다."); + } + if (!(options.sampleRate > 0) || !(options.fftSize > 0)) { + throw new Error("주파수 응답 보정에는 유효한 sampleRate와 fftSize가 필요합니다."); + } + } + const inverseResponses = hasFrequencyResponse + ? new Float64Array(bins.length * stft.microphoneCount * 2) + : null; + if (hasFrequencyResponse) { + for (let index = 0; index < inverseResponses.length; index += 2) inverseResponses[index] = 1; + const response = {}; + bins.forEach((bin, binIndex) => { + const frequencyHz = (bin * options.sampleRate) / options.fftSize; + microphones.forEach((microphone, microphoneIndex) => { + if (!Array.isArray(microphone.frequencyResponse)) return; + interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response); + const inverseGain = dbToLinear(-response.gainDb); + const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2; + inverseResponses[responseOffset] = inverseGain * Math.cos(response.phaseRadians); + inverseResponses[responseOffset + 1] = -inverseGain * Math.sin(response.phaseRadians); + }); + }); + } bins.forEach((bin, binIndex) => { const spectrum = stft.spectra.get(bin); if (!spectrum || spectrum.real.length !== valuesPerBin || spectrum.imaginary.length !== valuesPerBin) { @@ -80,8 +114,21 @@ export function packStftBatch(stft, bins) { } const binOffset = binIndex * valuesPerBin * 2; for (let index = 0; index < valuesPerBin; index += 1) { - packed[binOffset + index * 2] = spectrum.real[index]; - packed[binOffset + index * 2 + 1] = spectrum.imaginary[index]; + if (!inverseResponses) { + packed[binOffset + index * 2] = spectrum.real[index]; + packed[binOffset + index * 2 + 1] = spectrum.imaginary[index]; + continue; + } + const microphoneIndex = index % stft.microphoneCount; + const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2; + const inverseReal = inverseResponses[responseOffset]; + const inverseImaginary = inverseResponses[responseOffset + 1]; + const inputReal = spectrum.real[index]; + const inputImaginary = spectrum.imaginary[index]; + packed[binOffset + index * 2] = + inputReal * inverseReal - inputImaginary * inverseImaginary; + packed[binOffset + index * 2 + 1] = + inputReal * inverseImaginary + inputImaginary * inverseReal; } }); return packed; @@ -488,7 +535,13 @@ export async function computeDasWebGpu(options) { spectrumBatchCount: plan.binBatchCount, }), }); - const packedSpectrum = packStftBatch(stft, batchBins); + const packedSpectrum = packStftBatch(stft, batchBins, { + microphones: arrayDefinition.microphones, + sampleRate, + fftSize: experiment.stft.fftSize, + compensateFrequencyResponse: + experiment.beamforming.compensateFrequencyResponse !== false, + }); stftMs += now() - stftStartedAt; const binIndices = Uint32Array.from(batchBins); const binResources = []; diff --git a/schemas/README.md b/schemas/README.md index 74fc09f..c1ff2f1 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -4,9 +4,17 @@ JSON Schema Draft 2020-12 형식입니다. -- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 보정값 -- `scene.schema.json`: 재현 가능한 음원 recipe, asset 참조, 정적 위치와 선택적 trajectory +- `array.schema.json`: 배열 크기, 좌표계, 마이크 위치와 선택적 scalar/주파수 응답 보정값 +- `scene.schema.json`: 음원 recipe, asset·trajectory와 선택적 sparse shoebox 음향 환경 - `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값 - `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표 -중복 id, Nyquist, 시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다. +마이크 주파수 응답점은 배열 계약 자체이므로 특정 Scene의 Nyquist와 결합하지 않고 0~1 MHz의 +안전 범위에서 엄격한 오름차순을 요구합니다. 실제 분석에서는 선택한 Scene/STFT의 bin 주파수만 +보간합니다. 중복 id, Nyquist, 시간 구간과 참조 무결성은 +`packages/contracts/validation.js`에서 추가로 검증합니다. + +Scene `acoustics`는 기본 `free-field` 또는 `shoebox`입니다. shoebox는 room bounds, 0/1차 +반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다. +room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에 +있어야 합니다. 이 계약은 전체 파동 해석이 아니라 sparse path 근사의 입력입니다. diff --git a/schemas/array.schema.json b/schemas/array.schema.json index 036d952..00f2373 100644 --- a/schemas/array.schema.json +++ b/schemas/array.schema.json @@ -42,7 +42,20 @@ "id": { "type": "string", "minLength": 1, "maxLength": 64 }, "position": { "$ref": "#/$defs/position" }, "sensitivityDb": { "type": "number", "minimum": -120, "maximum": 120 }, - "phaseOffsetDeg": { "type": "number", "minimum": -180, "maximum": 180 } + "phaseOffsetDeg": { "type": "number", "minimum": -180, "maximum": 180 }, + "frequencyResponse": { + "type": "array", "minItems": 2, "maxItems": 64, + "items": { "$ref": "#/$defs/frequencyResponsePoint" } + } + } + }, + "frequencyResponsePoint": { + "type": "object", "additionalProperties": false, + "required": ["frequencyHz", "gainDb"], + "properties": { + "frequencyHz": { "type": "number", "minimum": 0, "maximum": 1000000 }, + "gainDb": { "type": "number", "minimum": -120, "maximum": 120 }, + "phaseDeg": { "type": "number", "minimum": -180, "maximum": 180 } } } } diff --git a/schemas/experiment.schema.json b/schemas/experiment.schema.json index 4026898..c4e6ce3 100644 --- a/schemas/experiment.schema.json +++ b/schemas/experiment.schema.json @@ -45,7 +45,8 @@ "algorithm": { "const": "das" }, "backend": { "enum": ["auto", "cpu", "webgpu"] }, "minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 }, "normalization": { "enum": ["peak", "none"] }, - "dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 } + "dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 }, + "compensateFrequencyResponse": { "type": "boolean" } } }, "evaluation": { diff --git a/schemas/scene.schema.json b/schemas/scene.schema.json index 2cf008a..60b65d2 100644 --- a/schemas/scene.schema.json +++ b/schemas/scene.schema.json @@ -21,6 +21,7 @@ "targetPeak": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 } } }, + "acoustics": { "$ref": "#/$defs/acoustics" }, "image": { "$ref": "#/$defs/image" }, "assets": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/asset" } }, "sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } }, @@ -38,6 +39,67 @@ "position": { "$ref": "#/$defs/position" } } }, + "wallLevels": { + "type": "object", "additionalProperties": false, + "properties": { + "xMin": { "type": "number", "minimum": -60, "maximum": 0 }, + "xMax": { "type": "number", "minimum": -60, "maximum": 0 }, + "yMin": { "type": "number", "minimum": -60, "maximum": 0 }, + "yMax": { "type": "number", "minimum": -60, "maximum": 0 }, + "zMin": { "type": "number", "minimum": -60, "maximum": 0 }, + "zMax": { "type": "number", "minimum": -60, "maximum": 0 } + } + }, + "room": { + "type": "object", "additionalProperties": false, "required": ["min", "max"], + "properties": { + "min": { "$ref": "#/$defs/position" }, + "max": { "$ref": "#/$defs/position" } + } + }, + "reflections": { + "type": "object", "additionalProperties": false, + "required": ["order", "wallGainDb", "scattering"], + "properties": { + "order": { "enum": [0, 1] }, + "wallGainDb": { "type": "number", "minimum": -60, "maximum": 0 }, + "perWallDb": { "$ref": "#/$defs/wallLevels" }, + "scattering": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, + "diffuseReverb": { + "type": "object", "additionalProperties": false, + "required": ["enabled", "rt60Seconds", "gainDb", "tapCount"], + "properties": { + "enabled": { "type": "boolean" }, + "rt60Seconds": { "type": "number", "minimum": 0.05, "maximum": 5 }, + "gainDb": { "type": "number", "minimum": -60, "maximum": 0 }, + "tapCount": { "type": "integer", "minimum": 1, "maximum": 32 } + } + }, + "occluder": { + "type": "object", "additionalProperties": false, + "required": ["id", "min", "max", "transmissionDb", "diffractionGainDb"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "min": { "$ref": "#/$defs/position" }, + "max": { "$ref": "#/$defs/position" }, + "transmissionDb": { "type": "number", "minimum": -120, "maximum": 0 }, + "diffractionGainDb": { "type": "number", "minimum": -120, "maximum": 0 } + } + }, + "acoustics": { + "type": "object", "additionalProperties": false, "required": ["model"], + "properties": { + "model": { "enum": ["free-field", "shoebox"] }, + "room": { "$ref": "#/$defs/room" }, + "reflections": { "$ref": "#/$defs/reflections" }, + "diffuseReverb": { "$ref": "#/$defs/diffuseReverb" }, + "occluders": { + "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/occluder" } + } + } + }, "image": { "type": "object", "additionalProperties": false, "required": ["assetId", "widthPixels", "heightPixels", "plane"], diff --git a/tests/test-runner.js b/tests/test-runner.js index 47fc81f..8083379 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -11,6 +11,7 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => { try { await import("/tests/unit/contracts.test.js"); await import("/tests/unit/acoustics.test.js"); + await import("/tests/unit/realtime-localizer.test.js"); await import("/tests/unit/builder.test.js"); await import("/tests/unit/evaluation.test.js"); await import("/tests/unit/camera.test.js"); diff --git a/tests/unit/README.md b/tests/unit/README.md index 7898684..5648586 100644 --- a/tests/unit/README.md +++ b/tests/unit/README.md @@ -20,6 +20,8 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩 - `contracts.test.js`: Array·Scene·Experiment·Result schema, 관계 검증, 안전한 asset 경로와 이동 fixture - `acoustics.test.js`: seed 재현성, 자유음장 전파, trajectory 보간, sample 정렬 시간창과 tone/noise/asset 절대 시간 연속성, calibration, STFT와 CPU DAS +- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성, + bounded ring/result batch, 취소와 geometry·steering cache 연산 절감 - `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증 - `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적 beam-spread covariance ellipse diff --git a/tests/unit/acoustics.test.js b/tests/unit/acoustics.test.js index 3e7d8bf..97c76ea 100644 --- a/tests/unit/acoustics.test.js +++ b/tests/unit/acoustics.test.js @@ -1,7 +1,13 @@ import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js"; import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js"; +import { + buildAcousticPaths, + maximumAcousticDelaySeconds, +} from "/packages/acoustics-core/environment.js"; +import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js"; import { addFrequencyDomainDelay, + addFrequencyDomainPaths, createDelayWorkspace, prepareDelaySpectrum, } from "/packages/acoustics-core/fractional-delay.js"; @@ -119,6 +125,100 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => { assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation"); }); +test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => { + const scene = { speedOfSound: 400, seed: 11 }; + const paths = buildAcousticPaths({ + scene, + sourcePosition: [0, 0, 2], + receiverPosition: [0, 0, 0], + sourceId: "source", + receiverId: "mic", + }); + assertEqual(paths.length, 1, "direct path only"); + assertEqual(paths[0].kind, "direct", "path kind"); + assertNear(paths[0].delaySeconds, 0.005, 1e-12, "direct delay"); + assertNear(paths[0].gain, 0.5, 1e-12, "direct spherical gain"); +}); + +test("shoebox가 1차 반사와 산란을 고정된 sparse path로 만든다", () => { + const scene = { + speedOfSound: 343, + seed: 23, + sources: [{ id: "source", position: [0, 0, 1] }], + acoustics: { + model: "shoebox", + room: { min: [-2, -2, -1], max: [2, 2, 3] }, + reflections: { order: 1, wallGainDb: -6, scattering: 0.25 }, + diffuseReverb: { enabled: true, rt60Seconds: 0.2, gainDb: -18, tapCount: 4 }, + }, + }; + const options = { + scene, + sourcePosition: scene.sources[0].position, + receiverPosition: [0, 0, 0], + sourceId: "source", + receiverId: "mic", + }; + const first = buildAcousticPaths(options); + const second = buildAcousticPaths(options); + assertEqual(first.length, 17, "direct + six reflection + six scatter + four diffuse"); + assertEqual(first.filter(({ kind }) => kind === "reflection").length, 6, "first-order reflection count"); + assertEqual(first.filter(({ kind }) => kind === "scattering").length, 6, "scattering count"); + assertEqual(first.filter(({ kind }) => kind === "diffuse").length, 4, "diffuse count"); + assertEqual(JSON.stringify(first), JSON.stringify(second), "seeded path set is deterministic"); + const maximum = maximumAcousticDelaySeconds({ + scene, + arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] }, + }); + assertNear(maximum, Math.max(...first.map(({ delaySeconds }) => delaySeconds)), 1e-12, "maximum delay"); +}); + +test("차폐물은 직접음을 감쇠하고 더 긴 edge diffraction 경로를 추가한다", () => { + const scene = { + speedOfSound: 400, + seed: 1, + acoustics: { + model: "shoebox", + room: { min: [-2, -2, -2], max: [2, 2, 2] }, + occluders: [{ + id: "panel", + min: [-0.1, -0.5, -0.5], + max: [0.1, 0.5, 0.5], + transmissionDb: -20, + diffractionGainDb: -12, + }], + }, + }; + const paths = buildAcousticPaths({ + scene, + sourcePosition: [-1, 0, 0], + receiverPosition: [1, 0, 0], + }); + assertEqual(paths.length, 2, "direct and diffraction paths"); + const direct = paths.find(({ kind }) => kind === "direct"); + const diffraction = paths.find(({ kind }) => kind === "diffraction"); + assertNear(direct.gain, 0.05, 1e-12, "-20 dB direct transmission"); + assert(diffraction.distance > direct.distance, "edge detour is longer than line of sight"); + assert(diffraction.delaySeconds > direct.delaySeconds, "diffraction arrives later"); +}); + +test("마이크 주파수 응답을 dB와 wrapped phase로 보간하고 끝점 밖에서 고정한다", () => { + const curve = [ + { frequencyHz: 100, gainDb: -6, phaseDeg: 170 }, + { frequencyHz: 300, gainDb: 6, phaseDeg: -170 }, + ]; + const below = interpolateFrequencyResponse(curve, 20); + const middle = interpolateFrequencyResponse(curve, 200); + const above = interpolateFrequencyResponse(curve, 10_000); + assertEqual(below.gainDb, -6, "lower endpoint gain"); + assertEqual(below.phaseDeg, 170, "lower endpoint phase"); + assertNear(middle.gainDb, 0, 1e-12, "interpolated dB"); + assertNear(Math.abs(middle.phaseDeg), 180, 1e-12, "phase follows shortest wrapped path"); + assertNear(middle.linearGain, 1, 1e-12, "interpolated linear gain"); + assertEqual(above.gainDb, 6, "upper endpoint gain"); + assertEqual(above.phaseDeg, -170, "upper endpoint phase"); +}); + test("index 기반 noise가 seed에 대해 결정적이다", () => { const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index)); const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index)); @@ -174,6 +274,24 @@ test("FFT phase shift가 integer와 fractional delay를 적용한다", () => { assert(Math.sqrt(squaredError / count) < 1e-3, "fractional delay RMS error"); }); +test("여러 음향 경로를 하나의 frequency-domain transfer로 합성한다", () => { + const impulse = new Float32Array(16); + impulse[2] = 1; + const spectrum = prepareDelaySpectrum(impulse, 5); + const output = new Float32Array(16); + addFrequencyDomainPaths({ + output, + spectrum, + paths: [ + { delaySamples: 3, gain: 1 }, + { delaySamples: 5, gain: 0.5 }, + ], + workspace: createDelayWorkspace(spectrum.fftSize), + }); + assertNear(output[5], 1, 1e-6, "direct impulse"); + assertNear(output[7], 0.5, 1e-6, "reflected impulse"); +}); + test("STFT frame count가 마지막 partial tail을 포함한다", () => { assertEqual(getFrameCount(512, 512, 256), 1); assertEqual(getFrameCount(513, 512, 256), 2); @@ -207,6 +325,35 @@ test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () => assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio"); }); +test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => { + const scene = { + sampleRate: 8000, + duration: 0.02, + speedOfSound: 343, + seed: 7, + mix: { normalization: "none", targetPeak: 0.95 }, + acoustics: { + model: "shoebox", + room: { min: [-2, -2, -1], max: [2, 2, 3] }, + reflections: { order: 1, wallGainDb: -12, scattering: 0 }, + }, + sources: [{ + id: "tone", name: "tone", position: [0, 0, 1], gainDb: -30, + startTime: 0, duration: 0.02, + signal: { type: "tone", frequency: 500, phaseDeg: 0 }, + }], + ambient: [], + }; + const result = synthesizeMicrophoneSignals({ + scene, + arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] }, + }); + assertEqual(result.acousticModel, "shoebox", "reported acoustic model"); + assertEqual(result.acousticPathCount, 7, "one direct and six image-source paths"); + assertEqual(result.acousticPathCounts.reflection, 6, "reflection path count"); + assert(result.preNormalizationPeak > 0, "multipath channel contains signal"); +}); + test("peak mix 정규화와 channel buffer 한도를 적용한다", () => { const scene = { sampleRate: 8000, @@ -251,11 +398,65 @@ test("ambient에도 microphone gain과 phase calibration 편차를 적용한다" assertNear(result.channels[0][80], expected, 4e-3, "ambient calibrated sample"); }); +test("합성 microphone channel에 주파수별 응답 gain을 적용한다", () => { + const scene = { + sampleRate: 8000, + duration: 0.256, + speedOfSound: 343, + seed: 1, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [ + { + id: "lower", name: "lower", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256, + signal: { type: "tone", frequency: 500, phaseDeg: 0 }, + }, + { + id: "upper", name: "upper", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256, + signal: { type: "tone", frequency: 1000, phaseDeg: 0 }, + }, + ], + ambient: [], + }; + const baselineArray = { microphones: [{ id: "mic", position: [0, 0, 0] }] }; + const responseArray = { + microphones: [{ + id: "mic", + position: [0, 0, 0], + frequencyResponse: [ + { frequencyHz: 500, gainDb: -6.020599913 }, + { frequencyHz: 1000, gainDb: 6.020599913 }, + ], + }], + }; + const baseline = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: baselineArray }).channels[0]); + const filtered = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: responseArray }).channels[0]); + const magnitudeAt = (spectrum, frequency) => { + const bin = Math.round((frequency * spectrum.real.length) / scene.sampleRate); + return Math.hypot(spectrum.real[bin], spectrum.imaginary[bin]); + }; + assertNear( + magnitudeAt(filtered, 500) / magnitudeAt(baseline, 500), + 0.5, + 0.015, + "500 Hz channel response", + ); + assertNear( + magnitudeAt(filtered, 1000) / magnitudeAt(baseline, 1000), + 2, + 0.03, + "1 kHz channel response", + ); +}); + test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾는다", async () => { const arrayDefinition = createArray(4, 4, 0.3, 0.3); arrayDefinition.microphones.forEach((microphone, index) => { microphone.sensitivityDb = (index % 3) * 2 - 2; microphone.phaseOffsetDeg = (index % 5) * 8 - 16; + microphone.frequencyResponse = [ + { frequencyHz: 0, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 }, + { frequencyHz: 4000, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 }, + ]; }); const scene = { id: "das-scene", @@ -292,6 +493,21 @@ test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾 assertNear(Math.max(...result.bands[0].rawMap), result.bands[0].peak.rawPower, 1e-4, "raw map peak"); assertNear(Math.max(...result.bands[0].displayMap), 1, 1e-6, "normalized display peak"); assert(progressEvents.some(({ stage }) => stage === "beamforming"), "beamforming progress"); + + const uncompensated = await computeDasMap({ + channels: synthesis.channels, + sampleRate: synthesis.sampleRate, + arrayDefinition, + scene, + experiment: { + ...experiment, + beamforming: { ...experiment.beamforming, compensateFrequencyResponse: false }, + }, + }); + assert( + result.bands[0].peak.rawPower > uncompensated.bands[0].peak.rawPower * 1.5, + "known response compensation improves coherent peak power", + ); }); test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async () => { @@ -329,6 +545,21 @@ test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async }); assertNear(map[1], 1, 1e-6, "target coherent power"); assert(map[1] > map[0] * 1.1, `wrong-sign alternative power=${map[0]}`); + const legacyMap = await computeBinMap({ + bin, + spectrum, + frameCount: 1, + arrayDefinition, + pointCount: 2, + distances: createDistanceMatrix(arrayDefinition, scan), + sampleRate, + fftSize, + speedOfSound, + pointBatchSize: 64, + compensateFrequencyResponse: false, + }); + assertEqual(legacyMap[0], map[0], "response-free map remains unchanged when compensation is disabled"); + assertEqual(legacyMap[1], map[1], "response-free target power remains unchanged"); }); test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async () => { diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js index 6a68b19..daebbac 100644 --- a/tests/unit/contracts.test.js +++ b/tests/unit/contracts.test.js @@ -61,6 +61,43 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () = assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues"); }); +test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => { + const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); + scene.acoustics = { + model: "shoebox", + room: { min: [-2, -2, -1], max: [2, 2, 3] }, + reflections: { order: 1, wallGainDb: -8, scattering: 0.2 }, + diffuseReverb: { enabled: true, rt60Seconds: 0.35, gainDb: -20, tapCount: 12 }, + occluders: [{ + id: "screen", + min: [-0.2, -0.2, 0.8], + max: [0.2, 0.2, 1], + transmissionDb: -18, + diffractionGainDb: -12, + }], + }; + assertEqual((await validateScene(scene)).length, 0, "valid shoebox scene"); + + scene.acoustics.room.max[0] = scene.acoustics.room.min[0]; + let issues = await validateScene(scene); + assert(issues.some(({ path }) => path === "$.acoustics.room"), "unordered room bounds"); + + scene.acoustics.room.max[0] = 2; + scene.acoustics.occluders[0].max[2] = 4; + issues = await validateScene(scene); + assert( + issues.some(({ path, message }) => path.includes("occluders[0]") && message.includes("room")), + "occluder outside room", + ); + + scene.acoustics = { + model: "free-field", + room: { min: [-2, -2, -1], max: [2, 2, 3] }, + }; + issues = await validateScene(scene); + assert(issues.some(({ path }) => path === "$.acoustics"), "unused free-field settings"); +}); + test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-16x12.array.json"), @@ -125,6 +162,32 @@ test("중복 마이크 id를 거부한다", async () => { assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복"))); }); +test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름차순이어야 한다", async () => { + const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json"); + arrayDefinition.microphones[0].frequencyResponse = [ + { frequencyHz: 20, gainDb: -3 }, + { frequencyHz: 1000, gainDb: 1.5, phaseDeg: 12 }, + { frequencyHz: 20_000, gainDb: -2 }, + ]; + assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "valid response curve"); + + arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1000; + let issues = await validateArrayDefinition(arrayDefinition); + assert( + issues.some(({ path, message }) => + path === "$.microphones[0].frequencyResponse[2].frequencyHz" && + message.includes("엄격히 증가")), + "duplicate frequency is rejected with sorting guidance", + ); + + arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1_000_001; + issues = await validateArrayDefinition(arrayDefinition); + assert( + issues.some(({ path }) => path === "$.microphones[0].frequencyResponse[2].frequencyHz"), + "globally bounded frequency is enforced without a Scene Nyquist dependency", + ); +}); + test("Nyquist를 넘는 tone을 거부한다", async () => { const scene = { schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m", @@ -156,11 +219,13 @@ test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한 scene.unexpected = true; delete experiment.beamforming.normalization; experiment.beamforming.dynamicRangeDb = 4; + experiment.beamforming.compensateFrequencyResponse = "false"; 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"))); }); test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => { diff --git a/tests/unit/realtime-localizer.test.js b/tests/unit/realtime-localizer.test.js new file mode 100644 index 0000000..fe781af --- /dev/null +++ b/tests/unit/realtime-localizer.test.js @@ -0,0 +1,285 @@ +import { computeDasMap } from "/packages/acoustics-core/das.js"; +import { + createLocalizationPlan, + createStreamingLocalizer, + 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.sampleCount, 128, "sample count"); + assertEqual(portable.duration, 128 / fixture.sampleRate, "block duration"); +}); + +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("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", + ); + 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"); + + 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"); + assertEqual(stream.getState().failedAnalyses, 1, "failed analysis counted"); +}); diff --git a/tests/unit/webgpu.test.js b/tests/unit/webgpu.test.js index d409e3a..bed01b5 100644 --- a/tests/unit/webgpu.test.js +++ b/tests/unit/webgpu.test.js @@ -81,6 +81,36 @@ test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () => expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`)); }); +test("WebGPU STFT packing이 microphone 주파수 응답을 bin별로 역보정한다", () => { + const stft = { + frameCount: 1, + microphoneCount: 1, + spectra: new Map([[8, { real: Float32Array.of(0), imaginary: Float32Array.of(2) }]]), + }; + const microphones = [{ + frequencyResponse: [ + { frequencyHz: 0, gainDb: 6.020599913, phaseDeg: 90 }, + { frequencyHz: 4000, gainDb: 6.020599913, phaseDeg: 90 }, + ], + }]; + const compensated = packStftBatch(stft, [8], { + microphones, + sampleRate: 8000, + fftSize: 256, + }); + assertNear(compensated[0], 1, 1e-6, "inverse response real"); + assertNear(compensated[1], 0, 1e-6, "inverse response imaginary"); + + const uncompensated = packStftBatch(stft, [8], { + microphones, + sampleRate: 8000, + fftSize: 256, + compensateFrequencyResponse: false, + }); + assertEqual(uncompensated[0], 0, "disabled compensation real"); + assertEqual(uncompensated[1], 2, "disabled compensation imaginary"); +}); + test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => { const workload = { selectedBins: [4, 5, 6, 7, 8],