From 6a1197d60e0c27bf426340de53b45a559bb71abb Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 09:48:34 +0900 Subject: [PATCH] feat: implement free-field CPU beamforming --- docs/data-contracts.md | 4 + packages/acoustics-core/README.md | 23 +- packages/acoustics-core/das.js | 270 +++++++++++++++++ packages/acoustics-core/fft.js | 63 ++++ packages/acoustics-core/fractional-delay.js | 53 ++++ packages/acoustics-core/index.js | 8 + packages/acoustics-core/math.js | 62 ++++ packages/acoustics-core/noise.js | 34 +++ packages/acoustics-core/propagation.js | 123 ++++++++ packages/acoustics-core/signal.js | 53 ++++ packages/acoustics-core/stft.js | 96 +++++++ packages/contracts/validation.js | 23 +- tests/integration/cpu-baseline.test.js | 34 +++ tests/test-runner.js | 2 + tests/unit/acoustics.test.js | 304 ++++++++++++++++++++ 15 files changed, 1148 insertions(+), 4 deletions(-) create mode 100644 packages/acoustics-core/das.js create mode 100644 packages/acoustics-core/fft.js create mode 100644 packages/acoustics-core/fractional-delay.js create mode 100644 packages/acoustics-core/index.js create mode 100644 packages/acoustics-core/math.js create mode 100644 packages/acoustics-core/noise.js create mode 100644 packages/acoustics-core/propagation.js create mode 100644 packages/acoustics-core/signal.js create mode 100644 packages/acoustics-core/stft.js create mode 100644 tests/integration/cpu-baseline.test.js create mode 100644 tests/unit/acoustics.test.js diff --git a/docs/data-contracts.md b/docs/data-contracts.md index c7342ac..6fc7142 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -15,6 +15,10 @@ scene JSON에는 192채널 원시 sample을 넣지 않는다. 합성 tone/noise 재생성하고, 녹음 파일과 이미지는 manifest 기준의 안전한 상대 경로로 참조한다. 사용자 컴퓨터의 절대 경로, URL, `..` 경로는 저장하지 않는다. +array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에 적용되는 편차다. 합성기는 +이를 신호에 주입하고, beamformer는 알려진 calibration 값의 역 gain/phase를 적용한다. 따라서 +두 값이 0이 아닌 배열도 오류 없이 조용히 무시되지 않는다. + ## 오디오와 map 정규화 `scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래 diff --git a/packages/acoustics-core/README.md b/packages/acoustics-core/README.md index afee959..5849d39 100644 --- a/packages/acoustics-core/README.md +++ b/packages/acoustics-core/README.md @@ -1,3 +1,24 @@ # Acoustics Core -두 앱이 공유하는 좌표계, 자유음장 전파, STFT, CPU 기준 DAS 및 평가 로직을 둡니다. +두 앱이 공유하는 무의존성 수치 기준 구현입니다. + +- 결정적 tone/white/pink noise 및 오디오 asset resampling +- 자유음장 점 음원의 `delay = r / c`, `amplitude = 1 / r` 구면 전파 +- zero-padded FFT phase shift 기반 fractional sample delay +- radix-2 FFT, Hann STFT와 선택 bin 추출 +- scan grid 거리 cache 및 conventional frequency-domain DAS + +FFT는 `X[k] = Σ x[n] exp(-j2πkn/N)` 규약을 사용합니다. 마이크 신호의 전파 phase +`exp(-j2πfr/c)`를 정렬하기 위해 DAS steering은 `exp(+j2πfr/c)`를 곱합니다. 단일 tone의 +ground-truth 위치에서 peak가 생기는 회귀 테스트로 이 부호를 고정합니다. + +마이크 `sensitivityDb`와 `phaseOffsetDeg`는 수신 channel에 편차로 적용하고, DAS에서는 알려진 +calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용 normalized map은 서로 다른 +buffer로 보존합니다. bin spectrum은 제한된 batch로 처리하며 point batch 사이에서 event loop를 +양보해 진행 callback과 `AbortSignal` 취소가 동작합니다. + +현재 전파 모델은 자유음장·무지향성 점 음원이며 잔향은 포함하지 않습니다. 전체 signal을 +메모리에 보관하는 CPU 기준 구현이므로 source/channel과 delay FFT working set을 512 MiB로 제한합니다. +장시간 입력은 이후 chunk/STFT streaming backend에서 지원합니다. + +192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다. diff --git a/packages/acoustics-core/das.js b/packages/acoustics-core/das.js new file mode 100644 index 0000000..0b191af --- /dev/null +++ b/packages/acoustics-core/das.js @@ -0,0 +1,270 @@ +import { dbToLinear, createScanPoints, distance3, findPeak, scanIndexToPosition, TAU } from "./math.js"; +import { binsForBand, extractStftBins, getFrameCount } from "./stft.js"; + +function throwIfAborted(signal) { + if (signal?.aborted) { + const error = new Error("계산이 취소되었습니다."); + error.name = "AbortError"; + throw error; + } +} + +function yieldToEventLoop() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +export function createDistanceMatrix(arrayDefinition, scan) { + const points = createScanPoints(scan); + const microphoneCount = arrayDefinition.microphones.length; + const pointCount = scan.columns * scan.rows; + const distances = new Float32Array(pointCount * microphoneCount); + for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { + const pointOffset = pointIndex * 4; + const point = [points[pointOffset], points[pointOffset + 1], points[pointOffset + 2]]; + for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { + distances[pointIndex * microphoneCount + microphoneIndex] = distance3( + point, + arrayDefinition.microphones[microphoneIndex].position, + ); + } + } + return distances; +} + +export async function computeBinMap(options) { + const { + bin, + spectrum, + frameCount, + arrayDefinition, + pointCount, + distances, + sampleRate, + fftSize, + speedOfSound, + signal, + pointBatchSize, + } = options; + const microphoneCount = arrayDefinition.microphones.length; + const output = new Float32Array(pointCount); + const steeringReal = new Float64Array(microphoneCount); + const steeringImaginary = new Float64Array(microphoneCount); + const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound); + const microphoneScale = 1 / microphoneCount; + + 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; + } + + let accumulatedPower = 0; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const spectrumOffset = frameIndex * microphoneCount; + let sumReal = 0; + let sumImaginary = 0; + for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { + const spectrumIndex = spectrumOffset + microphoneIndex; + const inputReal = spectrum.real[spectrumIndex]; + const inputImaginary = spectrum.imaginary[spectrumIndex]; + const phaseReal = steeringReal[microphoneIndex]; + const phaseImaginary = steeringImaginary[microphoneIndex]; + sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary; + sumImaginary += inputReal * phaseImaginary + inputImaginary * phaseReal; + } + accumulatedPower += (sumReal * sumReal + sumImaginary * sumImaginary) * microphoneScale * microphoneScale; + } + output[pointIndex] = accumulatedPower / frameCount; + if ((pointIndex + 1) % pointBatchSize === 0) await yieldToEventLoop(); + } + return output; +} + +export function estimateDasWorkload(options) { + const { channels, sampleRate, arrayDefinition, experiment } = options; + const { fftSize, hopSize } = experiment.stft; + const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize)); + const 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; + const bandCount = experiment.frequencyBands.length; + const distanceBytes = pointCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT; + const outputBytes = bandCount * pointCount * Float32Array.BYTES_PER_ELEMENT * 2; + const binMapBytes = pointCount * Float32Array.BYTES_PER_ELEMENT; + const spectrumBytesPerBin = frameCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT * 2; + const fftWorkspaceBytes = fftSize * Float64Array.BYTES_PER_ELEMENT * 2; + const baseWorkingBytes = distanceBytes + outputBytes + binMapBytes + fftWorkspaceBytes; + const complexMacCount = pointCount * microphoneCount * frameCount * selectedBins.length; + return { + bandBins, + selectedBins, + frameCount, + microphoneCount, + pointCount, + distanceBytes, + outputBytes, + spectrumBytesPerBin, + baseWorkingBytes, + complexMacCount, + }; +} + +export async function computeDasMap(options) { + const { + channels, + sampleRate, + arrayDefinition, + scene, + experiment, + signal, + onProgress, + maximumWorkingBytes = 512 * 1024 * 1024, + maximumComplexMacs = 1_000_000_000, + maximumFftButterflies = 1_000_000_000, + maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER, + pointBatchSize = 64, + stftTransformBatchSize = 32, + } = options; + if (channels.length !== arrayDefinition.microphones.length) { + 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 }); + if (workload.complexMacCount > maximumComplexMacs) { + throw new Error( + `CPU DAS 예상 복소 누산 ${workload.complexMacCount.toLocaleString()}회가 ` + + `현재 한도 ${maximumComplexMacs.toLocaleString()}회를 넘습니다. grid, duration 또는 대역폭을 줄이세요.`, + ); + } + const availableSpectrumBytes = maximumWorkingBytes - workload.baseWorkingBytes; + const batchCapacity = Math.floor(availableSpectrumBytes / workload.spectrumBytesPerBin); + if (batchCapacity < 1) { + throw new Error( + `CPU DAS 기본 working set ${(workload.baseWorkingBytes / 1024 ** 2).toFixed(1)} MiB가 ` + + `현재 한도 ${(maximumWorkingBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다.`, + ); + } + const batchSize = Math.max( + 1, + Math.min(workload.selectedBins.length, batchCapacity, Math.max(1, maximumSpectrumBatchBins)), + ); + const spectrumBatchCount = Math.ceil(workload.selectedBins.length / batchSize); + const fftButterflyCount = + spectrumBatchCount * + workload.frameCount * + workload.microphoneCount * + (experiment.stft.fftSize / 2) * + Math.log2(experiment.stft.fftSize); + if (fftButterflyCount > maximumFftButterflies) { + throw new Error( + `CPU STFT 예상 butterfly ${Math.ceil(fftButterflyCount).toLocaleString()}회가 ` + + `현재 한도 ${maximumFftButterflies.toLocaleString()}회를 넘습니다. duration, FFT 크기 또는 대역폭을 줄이세요.`, + ); + } + const 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); + }); + }); + + let completedBins = 0; + for (let batchStart = 0; batchStart < workload.selectedBins.length; batchStart += batchSize) { + throwIfAborted(signal); + await yieldToEventLoop(); + throwIfAborted(signal); + const batchBins = workload.selectedBins.slice(batchStart, batchStart + batchSize); + const stft = await extractStftBins(channels, { + fftSize: experiment.stft.fftSize, + hopSize: experiment.stft.hopSize, + bins: batchBins, + signal, + transformBatchSize: stftTransformBatchSize, + onProgress: ({ completedTransforms, totalTransforms }) => + onProgress?.({ + stage: "stft", + completedBins, + totalBins: workload.selectedBins.length, + completedTransforms, + totalTransforms, + spectrumBatch: Math.floor(batchStart / batchSize) + 1, + spectrumBatchCount, + }), + }); + await yieldToEventLoop(); + throwIfAborted(signal); + + for (const bin of batchBins) { + const binMap = await computeBinMap({ + bin, + spectrum: stft.spectra.get(bin), + frameCount: stft.frameCount, + arrayDefinition, + pointCount: workload.pointCount, + distances, + sampleRate, + fftSize: experiment.stft.fftSize, + speedOfSound: scene.speedOfSound, + signal, + pointBatchSize, + }); + for (const bandIndex of bandIndexesByBin.get(bin)) { + const rawMap = rawMaps[bandIndex]; + for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { + rawMap[pointIndex] += binMap[pointIndex]; + } + } + completedBins += 1; + onProgress?.({ stage: "beamforming", completedBins, totalBins: workload.selectedBins.length }); + } + } + + const bands = experiment.frequencyBands.map((band, bandIndex) => { + const rawMap = rawMaps[bandIndex]; + for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { + rawMap[pointIndex] /= workload.bandBins[bandIndex].length; + } + const rawPeak = findPeak(rawMap); + const displayMap = Float32Array.from(rawMap); + let displayScale = "power"; + if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) { + displayScale = "normalized"; + for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) { + displayMap[pointIndex] /= rawPeak.value; + } + } + return { + id: band.id, + color: band.color, + bins: workload.bandBins[bandIndex], + rawMap, + displayMap, + displayScale, + peak: { + index: rawPeak.index, + position: scanIndexToPosition(rawPeak.index, experiment.scan), + rawPower: rawPeak.value, + }, + }; + }); + + return { + backend: "cpu", + frameCount: workload.frameCount, + microphoneCount: workload.microphoneCount, + pointCount: workload.pointCount, + workload, + fftButterflyCount, + bands, + }; +} diff --git a/packages/acoustics-core/fft.js b/packages/acoustics-core/fft.js new file mode 100644 index 0000000..7230b83 --- /dev/null +++ b/packages/acoustics-core/fft.js @@ -0,0 +1,63 @@ +function assertPowerOfTwo(value) { + if (!Number.isInteger(value) || value < 2 || (value & (value - 1)) !== 0) { + throw new Error(`FFT 크기는 2 이상의 2의 거듭제곱이어야 합니다: ${value}`); + } +} + +export function fftInPlace(real, imaginary, inverse = false) { + const size = real.length; + if (imaginary.length !== size) throw new Error("FFT real/imaginary 길이가 다릅니다."); + assertPowerOfTwo(size); + + for (let index = 1, reversed = 0; index < size; index += 1) { + let bit = size >> 1; + while (reversed & bit) { + reversed ^= bit; + bit >>= 1; + } + reversed ^= bit; + if (index < reversed) { + [real[index], real[reversed]] = [real[reversed], real[index]]; + [imaginary[index], imaginary[reversed]] = [imaginary[reversed], imaginary[index]]; + } + } + + const direction = inverse ? 1 : -1; + for (let length = 2; length <= size; length <<= 1) { + const angle = (direction * 2 * Math.PI) / length; + const stepReal = Math.cos(angle); + const stepImaginary = Math.sin(angle); + for (let start = 0; start < size; start += length) { + let twiddleReal = 1; + let twiddleImaginary = 0; + const halfLength = length >> 1; + for (let offset = 0; offset < halfLength; offset += 1) { + const evenIndex = start + offset; + const oddIndex = evenIndex + halfLength; + const oddReal = real[oddIndex] * twiddleReal - imaginary[oddIndex] * twiddleImaginary; + const oddImaginary = real[oddIndex] * twiddleImaginary + imaginary[oddIndex] * twiddleReal; + real[oddIndex] = real[evenIndex] - oddReal; + imaginary[oddIndex] = imaginary[evenIndex] - oddImaginary; + real[evenIndex] += oddReal; + imaginary[evenIndex] += oddImaginary; + const nextTwiddleReal = twiddleReal * stepReal - twiddleImaginary * stepImaginary; + twiddleImaginary = twiddleReal * stepImaginary + twiddleImaginary * stepReal; + twiddleReal = nextTwiddleReal; + } + } + } + + if (inverse) { + for (let index = 0; index < size; index += 1) { + real[index] /= size; + imaginary[index] /= size; + } + } +} + +export function fftReal(samples) { + const real = Float64Array.from(samples); + const imaginary = new Float64Array(real.length); + fftInPlace(real, imaginary); + return { real, imaginary }; +} diff --git a/packages/acoustics-core/fractional-delay.js b/packages/acoustics-core/fractional-delay.js new file mode 100644 index 0000000..f3e8c04 --- /dev/null +++ b/packages/acoustics-core/fractional-delay.js @@ -0,0 +1,53 @@ +import { fftInPlace } from "./fft.js"; +import { TAU } from "./math.js"; + +export function nextPowerOfTwo(value) { + if (!Number.isFinite(value) || value < 1) throw new Error(`유효하지 않은 FFT 길이: ${value}`); + let result = 1; + while (result < value) result *= 2; + return result; +} + +export function prepareDelaySpectrum(samples, maximumDelaySamples = 0) { + const fftSize = nextPowerOfTwo(samples.length + Math.ceil(maximumDelaySamples) + 2); + const real = new Float64Array(fftSize); + const imaginary = new Float64Array(fftSize); + real.set(samples); + fftInPlace(real, imaginary); + return { real, imaginary, fftSize }; +} + +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}`); + if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) { + throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다."); + } + + const size = spectrum.fftSize; + const calibrationPhase = (phaseOffsetDeg * Math.PI) / 180; + 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); + const inputReal = spectrum.real[bin]; + const inputImaginary = spectrum.imaginary[bin]; + workspace.real[bin] = inputReal * phaseReal - inputImaginary * phaseImaginary; + workspace.imaginary[bin] = inputReal * phaseImaginary + inputImaginary * phaseReal; + } + + // A real sequence has a real-valued Nyquist coefficient. Projecting this single self-conjugate + // bin back to the real axis preserves Hermitian symmetry for fractional delays. + workspace.imaginary[size / 2] = 0; + 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; + } +} diff --git a/packages/acoustics-core/index.js b/packages/acoustics-core/index.js new file mode 100644 index 0000000..db3cee1 --- /dev/null +++ b/packages/acoustics-core/index.js @@ -0,0 +1,8 @@ +export * from "./das.js"; +export * from "./fft.js"; +export * from "./fractional-delay.js"; +export * from "./math.js"; +export * from "./noise.js"; +export * from "./propagation.js"; +export * from "./signal.js"; +export * from "./stft.js"; diff --git a/packages/acoustics-core/math.js b/packages/acoustics-core/math.js new file mode 100644 index 0000000..6581216 --- /dev/null +++ b/packages/acoustics-core/math.js @@ -0,0 +1,62 @@ +export const TAU = Math.PI * 2; + +export function distance3(a, b) { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +} + +export function dbToLinear(decibels) { + return 10 ** (decibels / 20); +} + +export function powerToDb(power, floorDb = -120) { + if (!(power > 0)) return floorDb; + return Math.max(floorDb, 10 * Math.log10(power)); +} + +export function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +export function lerp(start, end, amount) { + return start + (end - start) * amount; +} + +export function createScanPoints(scan) { + const points = new Float32Array(scan.columns * scan.rows * 4); + let offset = 0; + for (let row = 0; row < scan.rows; row += 1) { + const y = scan.rows === 1 ? scan.yMax : lerp(scan.yMax, scan.yMin, row / (scan.rows - 1)); + for (let column = 0; column < scan.columns; column += 1) { + const x = scan.columns === 1 ? scan.xMin : lerp(scan.xMin, scan.xMax, column / (scan.columns - 1)); + points[offset] = x; + points[offset + 1] = y; + points[offset + 2] = scan.z; + points[offset + 3] = 0; + offset += 4; + } + } + return points; +} + +export function scanIndexToPosition(index, scan) { + const row = Math.floor(index / scan.columns); + const column = index % scan.columns; + return [ + scan.columns === 1 ? scan.xMin : lerp(scan.xMin, scan.xMax, column / (scan.columns - 1)), + scan.rows === 1 ? scan.yMax : lerp(scan.yMax, scan.yMin, row / (scan.rows - 1)), + scan.z, + ]; +} + +export function findPeak(values) { + if (values.length === 0) throw new Error("빈 map에서는 peak를 찾을 수 없습니다."); + let peakIndex = 0; + let peakValue = values[0]; + for (let index = 1; index < values.length; index += 1) { + if (values[index] > peakValue) { + peakValue = values[index]; + peakIndex = index; + } + } + return { index: peakIndex, value: peakValue }; +} diff --git a/packages/acoustics-core/noise.js b/packages/acoustics-core/noise.js new file mode 100644 index 0000000..50e98a1 --- /dev/null +++ b/packages/acoustics-core/noise.js @@ -0,0 +1,34 @@ +function mix32(value) { + let mixed = value >>> 0; + mixed ^= mixed >>> 16; + mixed = Math.imul(mixed, 0x7feb352d); + mixed ^= mixed >>> 15; + mixed = Math.imul(mixed, 0x846ca68b); + mixed ^= mixed >>> 16; + return mixed >>> 0; +} + +export function hashString(value) { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +export function whiteNoiseAt(seed, sampleIndex) { + const mixed = mix32((seed >>> 0) ^ Math.imul(sampleIndex + 1, 0x9e3779b1)); + return (mixed / 0xffffffff) * 2 - 1; +} + +export function pinkNoiseAt(seed, sampleIndex) { + let total = 0; + let weight = 0; + for (let octave = 0; octave < 8; octave += 1) { + const octaveWeight = 1 / (octave + 1); + total += whiteNoiseAt(seed + octave * 0x1f123bb5, sampleIndex >> octave) * octaveWeight; + weight += octaveWeight; + } + return total / weight; +} diff --git a/packages/acoustics-core/propagation.js b/packages/acoustics-core/propagation.js new file mode 100644 index 0000000..87f6a7e --- /dev/null +++ b/packages/acoustics-core/propagation.js @@ -0,0 +1,123 @@ +import { dbToLinear, distance3 } from "./math.js"; +import { + addFrequencyDomainDelay, + createDelayWorkspace, + nextPowerOfTwo, + prepareDelaySpectrum, +} from "./fractional-delay.js"; +import { renderSignal } from "./signal.js"; + +export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) { + return distance3(sourcePosition, receiverPosition) / speedOfSound; +} + +export function sphericalAttenuation(distance, referenceDistance = 1, minimumDistance = 0.01) { + return referenceDistance / Math.max(distance, minimumDistance); +} + +export function synthesizeMicrophoneSignals(options) { + const { + scene, + arrayDefinition, + assetBuffers = new Map(), + referenceDistance = 1, + minimumDistance = 0.01, + maximumBufferBytes = 512 * 1024 * 1024, + } = 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 delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2); + const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4; + const estimatedBufferBytes = + retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + delayWorkingBytes; + if (estimatedBufferBytes > maximumBufferBytes) { + throw new Error( + `예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` + + `현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`, + ); + } + const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount)); + const renderedSources = scene.sources.map((source) => ({ + definition: source, + samples: renderSignal(source, scene, assetBuffers), + })); + + 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 workspace = createDelayWorkspace(spectrum.fftSize); + arrayDefinition.microphones.forEach((microphone, microphoneIndex) => { + const distance = distances[microphoneIndex]; + const delaySamples = (distance / scene.speedOfSound) * scene.sampleRate; + const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0); + const amplitude = sphericalAttenuation(distance, referenceDistance, minimumDistance) * sensitivity; + addFrequencyDomainDelay({ + output: channels[microphoneIndex], + spectrum, + delaySamples, + gain: amplitude, + phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, + workspace, + }); + }); + }); + + if (Array.isArray(scene.ambient)) { + scene.ambient.forEach((ambient, ambientIndex) => { + channels.forEach((channel, microphoneIndex) => { + const microphone = arrayDefinition.microphones[microphoneIndex]; + const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d); + const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt); + const spectrum = prepareDelaySpectrum(ambientSamples, 0); + addFrequencyDomainDelay({ + output: channel, + spectrum, + delaySamples: 0, + gain: dbToLinear(microphone.sensitivityDb ?? 0), + phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, + workspace: createDelayWorkspace(spectrum.fftSize), + }); + }); + }); + } + + let preNormalizationPeak = 0; + for (const channel of channels) { + for (const value of channel) { + preNormalizationPeak = Math.max(preNormalizationPeak, Math.abs(value)); + } + } + const appliedScale = + scene.mix?.normalization === "peak" && preNormalizationPeak > 0 + ? scene.mix.targetPeak / preNormalizationPeak + : 1; + let peak = 0; + let clippedSampleCount = 0; + for (const channel of channels) { + for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) { + channel[sampleIndex] *= appliedScale; + const magnitude = Math.abs(channel[sampleIndex]); + peak = Math.max(peak, magnitude); + if (magnitude > 1) clippedSampleCount += 1; + } + } + return { + channels, + sampleRate: scene.sampleRate, + peak, + preNormalizationPeak, + appliedScale, + clippedSampleCount, + estimatedBufferBytes, + }; +} diff --git a/packages/acoustics-core/signal.js b/packages/acoustics-core/signal.js new file mode 100644 index 0000000..ed7200b --- /dev/null +++ b/packages/acoustics-core/signal.js @@ -0,0 +1,53 @@ +import { dbToLinear, TAU } from "./math.js"; +import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js"; + +function sampleAsset(signal, assetBuffers, time) { + const asset = assetBuffers.get(signal.assetId); + if (!asset) throw new Error(`오디오 asset을 찾을 수 없습니다: ${signal.assetId}`); + const channel = asset.channels[signal.channel ?? 0]; + if (!channel) throw new Error(`${signal.assetId}의 channel ${signal.channel ?? 0}을 찾을 수 없습니다.`); + let sourceIndex = time * asset.sampleRate; + if (signal.loop && channel.length > 0) sourceIndex %= channel.length; + if (sourceIndex < 0 || sourceIndex >= channel.length) return 0; + const left = Math.floor(sourceIndex); + const fraction = sourceIndex - left; + const rightValue = channel[Math.min(left + 1, channel.length - 1)]; + return channel[left] + (rightValue - channel[left]) * fraction; +} + +export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0) { + const sampleCount = Math.ceil(scene.duration * scene.sampleRate); + const output = new Float32Array(sampleCount); + const startTime = entry.startTime ?? 0; + const duration = entry.duration ?? scene.duration; + const gain = dbToLinear(entry.gainDb ?? 0); + const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate)); + const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate)); + const signal = entry.signal; + const seed = (scene.seed + (signal.seedOffset ?? 0) + hashString(entry.id ?? "signal") + seedSalt) >>> 0; + + for (let sampleIndex = startSample; sampleIndex < endSample; sampleIndex += 1) { + const localIndex = sampleIndex - startSample; + const localTime = sampleIndex / scene.sampleRate - startTime; + let value; + if (signal.type === "tone") { + value = Math.sin(TAU * signal.frequency * localTime + ((signal.phaseDeg ?? 0) * Math.PI) / 180); + } else if (signal.type === "noise") { + value = signal.color === "pink" ? pinkNoiseAt(seed, localIndex) : whiteNoiseAt(seed, localIndex); + } else if (signal.type === "asset") { + value = sampleAsset(signal, assetBuffers, localTime); + } else { + throw new Error(`지원하지 않는 signal type: ${signal.type}`); + } + output[sampleIndex] = value * gain; + } + return output; +} + +export function sampleLinear(buffer, sampleIndex) { + if (sampleIndex < 0 || sampleIndex >= buffer.length) return 0; + const left = Math.floor(sampleIndex); + const fraction = sampleIndex - left; + const rightValue = buffer[Math.min(left + 1, buffer.length - 1)]; + return buffer[left] + (rightValue - buffer[left]) * fraction; +} diff --git a/packages/acoustics-core/stft.js b/packages/acoustics-core/stft.js new file mode 100644 index 0000000..75eab40 --- /dev/null +++ b/packages/acoustics-core/stft.js @@ -0,0 +1,96 @@ +import { fftInPlace } from "./fft.js"; + +function throwIfAborted(signal) { + if (signal?.aborted) { + const error = new Error("STFT 계산이 취소되었습니다."); + error.name = "AbortError"; + throw error; + } +} + +function yieldToEventLoop() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +export function hannWindow(size) { + if (!Number.isInteger(size) || size < 2) throw new Error("Hann window 크기는 2 이상이어야 합니다."); + const window = new Float64Array(size); + for (let index = 0; index < size; index += 1) { + window[index] = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (size - 1)); + } + return window; +} + +export function getFrameCount(sampleCount, fftSize, hopSize) { + if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) { + throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다."); + } + return Math.max(1, 1 + Math.ceil(Math.max(0, sampleCount - fftSize) / hopSize)); +} + +export async function extractStftBins(channels, options) { + const { fftSize, hopSize, bins, signal, onProgress, transformBatchSize = 32 } = options; + if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다."); + const sampleCount = channels[0].length; + if (channels.some((channel) => channel.length !== sampleCount)) throw new Error("마이크 채널 길이가 다릅니다."); + const uniqueBins = [...new Set(bins)].sort((a, b) => a - b); + for (const bin of uniqueBins) { + if (!Number.isInteger(bin) || bin < 0 || bin > fftSize / 2) throw new Error(`유효하지 않은 FFT bin: ${bin}`); + } + + const frameCount = getFrameCount(sampleCount, fftSize, hopSize); + const microphoneCount = channels.length; + if (!Number.isInteger(transformBatchSize) || transformBatchSize < 1) { + throw new Error("transformBatchSize는 1 이상의 정수여야 합니다."); + } + const spectra = new Map(); + for (const bin of uniqueBins) { + spectra.set(bin, { + real: new Float32Array(frameCount * microphoneCount), + imaginary: new Float32Array(frameCount * microphoneCount), + }); + } + + const window = hannWindow(fftSize); + const real = new Float64Array(fftSize); + const imaginary = new Float64Array(fftSize); + const totalTransforms = frameCount * microphoneCount; + let completedTransforms = 0; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const frameStart = frameIndex * hopSize; + for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) { + const channel = channels[microphoneIndex]; + real.fill(0); + imaginary.fill(0); + for (let offset = 0; offset < fftSize; offset += 1) { + real[offset] = (channel[frameStart + offset] ?? 0) * window[offset]; + } + fftInPlace(real, imaginary); + const spectrumIndex = frameIndex * microphoneCount + microphoneIndex; + for (const bin of uniqueBins) { + const spectrum = spectra.get(bin); + spectrum.real[spectrumIndex] = real[bin]; + spectrum.imaginary[spectrumIndex] = imaginary[bin]; + } + completedTransforms += 1; + if (completedTransforms % transformBatchSize === 0) { + onProgress?.({ completedTransforms, totalTransforms }); + await yieldToEventLoop(); + throwIfAborted(signal); + } + } + } + onProgress?.({ completedTransforms, totalTransforms }); + throwIfAborted(signal); + return { frameCount, microphoneCount, spectra }; +} + +export function binsForBand(band, sampleRate, fftSize) { + const binWidth = sampleRate / fftSize; + const firstBin = Math.max(0, Math.ceil(band.minHz / binWidth)); + const lastBin = Math.min(fftSize / 2, Math.floor(band.maxHz / binWidth)); + if (firstBin > lastBin) { + throw new Error(`${band.minHz}–${band.maxHz} Hz에 해당하는 FFT bin이 없습니다.`); + } + return Array.from({ length: lastBin - firstBin + 1 }, (_, index) => firstBin + index); +} diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js index bacf5dc..6e28724 100644 --- a/packages/contracts/validation.js +++ b/packages/contracts/validation.js @@ -332,14 +332,31 @@ function validateExperimentRelationships(value, context = {}) { }); } if (context.arrayDefinition && context.scene) { - const estimatedBytes = - context.arrayDefinition.microphones.length * Math.ceil(context.scene.duration * context.scene.sampleRate) * 4; + const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1; + const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate); + let maximumDelaySamples = 0; + for (const source of context.scene.sources) { + for (const microphone of context.arrayDefinition.microphones) { + const distance = Math.hypot( + source.position[0] - microphone.position[0], + source.position[1] - microphone.position[1], + source.position[2] - microphone.position[2], + ); + maximumDelaySamples = Math.max( + maximumDelaySamples, + (distance / context.scene.speedOfSound) * context.scene.sampleRate, + ); + } + } + let delayFftSize = 1; + while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) delayFftSize *= 2; + const estimatedBytes = retainedBufferCount * sampleCount * 4 + delayFftSize * 8 * 4; const maximumBytes = 512 * 1024 * 1024; if (estimatedBytes > maximumBytes) { addIssue( issues, "$", - `현재 비스트리밍 CPU 모델의 채널 buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`, + `현재 비스트리밍 CPU 모델의 signal buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`, ); } } diff --git a/tests/integration/cpu-baseline.test.js b/tests/integration/cpu-baseline.test.js new file mode 100644 index 0000000..74e1bc0 --- /dev/null +++ b/tests/integration/cpu-baseline.test.js @@ -0,0 +1,34 @@ +import { computeDasMap } from "/packages/acoustics-core/das.js"; +import { distance3 } from "/packages/acoustics-core/math.js"; +import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; + +async function loadJson(path) { + const response = await fetch(path); + assert(response.ok, `${path} load failed`); + return response.json(); +} + +test("192채널 · 50×50 CPU baseline이 ground truth를 찾는다", async () => { + const [arrayDefinition, scene, experiment] = await Promise.all([ + loadJson("/assets/array-geometries/rectangular-16x12.array.json"), + loadJson("/assets/sample-scenes/single-tone.scene.json"), + loadJson("/assets/sample-scenes/single-tone.experiment.json"), + ]); + const startedAt = performance.now(); + const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition }); + const result = await computeDasMap({ + channels: synthesis.channels, + sampleRate: synthesis.sampleRate, + arrayDefinition, + scene, + experiment, + }); + const elapsedMs = performance.now() - startedAt; + const localizationError = distance3(result.bands[0].peak.position, scene.sources[0].position); + + assertEqual(result.microphoneCount, 192, "microphone count"); + assertEqual(result.pointCount, 2500, "scan point count"); + assert(localizationError < 0.03, `localization error=${localizationError} m`); + assert(Number.isFinite(result.bands[0].peak.rawPower) && result.bands[0].peak.rawPower > 0, "finite peak power"); + document.documentElement.dataset.cpuBaselineMs = elapsedMs.toFixed(1); +}); diff --git a/tests/test-runner.js b/tests/test-runner.js index e2b21b5..64dc80f 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -10,6 +10,8 @@ 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/integration/cpu-baseline.test.js"); } catch (error) { registeredTests.push({ name: "테스트 모듈을 불러온다", diff --git a/tests/unit/acoustics.test.js b/tests/unit/acoustics.test.js new file mode 100644 index 0000000..932131a --- /dev/null +++ b/tests/unit/acoustics.test.js @@ -0,0 +1,304 @@ +import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js"; +import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js"; +import { + addFrequencyDomainDelay, + createDelayWorkspace, + prepareDelaySpectrum, +} from "/packages/acoustics-core/fractional-delay.js"; +import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js"; +import { whiteNoiseAt } from "/packages/acoustics-core/noise.js"; +import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; +import { getFrameCount } from "/packages/acoustics-core/stft.js"; + +function assertNear(actual, expected, tolerance, label) { + assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected} ± ${tolerance}, received ${actual}`); +} + +function rms(values, start = 0, end = values.length) { + let sum = 0; + for (let index = start; index < end; index += 1) sum += values[index] ** 2; + return Math.sqrt(sum / (end - start)); +} + +function createArray(columns, rows, width, height) { + const microphones = []; + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + microphones.push({ + id: `mic-${row}-${column}`, + position: [ + -width / 2 + (column * width) / (columns - 1), + -height / 2 + (row * height) / (rows - 1), + 0, + ], + }); + } + } + return { id: "test-array", aperture: { width, height }, microphones }; +} + +test("거리, dB와 구면 감쇠 기본식을 계산한다", () => { + assertNear(distance3([0, 0, 0], [1, 2, 2]), 3, 1e-12, "distance"); + assertNear(dbToLinear(-6.020599913), 0.5, 1e-9, "dB amplitude"); + assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation"); +}); + +test("index 기반 noise가 seed에 대해 결정적이다", () => { + const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index)); + const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index)); + assert(first.every((value, index) => value === second[index])); + assert(first.some((value, index) => value !== whiteNoiseAt(43, index))); +}); + +test("radix-2 FFT가 정확한 tone bin을 찾는다", () => { + const size = 128; + const bin = 16; + const samples = Float64Array.from({ length: size }, (_, index) => Math.sin((2 * Math.PI * bin * index) / size)); + const spectrum = fftReal(samples); + const magnitude = Math.hypot(spectrum.real[bin], spectrum.imaginary[bin]); + assertNear(magnitude, size / 2, 1e-8, "tone magnitude"); + assertNear(spectrum.real[bin], 0, 1e-8, "forward real sign"); + assertNear(spectrum.imaginary[bin], -size / 2, 1e-8, "forward imaginary sign"); + assert(Math.hypot(spectrum.real[bin - 1], spectrum.imaginary[bin - 1]) < 1e-8, "adjacent leakage"); + fftInPlace(spectrum.real, spectrum.imaginary, true); + samples.forEach((value, index) => assertNear(spectrum.real[index], value, 1e-9, `inverse sample ${index}`)); +}); + +test("FFT phase shift가 integer와 fractional delay를 적용한다", () => { + const impulse = new Float32Array(16); + impulse[2] = 1; + const impulseSpectrum = prepareDelaySpectrum(impulse, 3); + const impulseOutput = new Float32Array(16); + addFrequencyDomainDelay({ + output: impulseOutput, + spectrum: impulseSpectrum, + delaySamples: 3, + workspace: createDelayWorkspace(impulseSpectrum.fftSize), + }); + assertNear(impulseOutput[5], 1, 1e-6, "integer delayed impulse"); + + const size = 512; + const frequencyCyclesPerSample = 1 / 8; + const tone = Float32Array.from({ length: size }, (_, index) => Math.sin(2 * Math.PI * frequencyCyclesPerSample * index)); + const toneSpectrum = prepareDelaySpectrum(tone, 2.5); + const delayedTone = new Float32Array(size); + addFrequencyDomainDelay({ + output: delayedTone, + spectrum: toneSpectrum, + delaySamples: 2.5, + workspace: createDelayWorkspace(toneSpectrum.fftSize), + }); + let squaredError = 0; + let count = 0; + for (let index = 48; index < size - 48; index += 1) { + const expected = Math.sin(2 * Math.PI * frequencyCyclesPerSample * (index - 2.5)); + squaredError += (delayedTone[index] - expected) ** 2; + count += 1; + } + assert(Math.sqrt(squaredError / count) < 1e-3, "fractional delay RMS error"); +}); + +test("STFT frame count가 마지막 partial tail을 포함한다", () => { + assertEqual(getFrameCount(512, 512, 256), 1); + assertEqual(getFrameCount(513, 512, 256), 2); + assertEqual(getFrameCount(12000, 512, 256), 46); +}); + +test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () => { + const scene = { + sampleRate: 8000, + duration: 0.02, + speedOfSound: 400, + seed: 1, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [{ + id: "tone", name: "tone", position: [0, 0, 0.1], gainDb: -40, startTime: 0, duration: 0.02, + signal: { type: "tone", frequency: 500, phaseDeg: 0 }, + }], + ambient: [], + }; + const arrayDefinition = { + microphones: [ + { id: "near", position: [0, 0, 0.05] }, + { id: "far", position: [0, 0, 0] }, + ], + }; + const result = synthesizeMicrophoneSignals({ scene, arrayDefinition }); + const emittedSampleOne = Math.sin((2 * Math.PI * 500) / 8000) * dbToLinear(-40); + assertNear(result.channels[0][1], 0, 1e-7, "one-sample propagation delay"); + assertNear(result.channels[0][2], emittedSampleOne * 20, 1e-5, "near analytic delayed sample"); + assertNear(result.channels[1][3], emittedSampleOne * 10, 1e-5, "far analytic delayed sample"); + assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio"); +}); + +test("peak mix 정규화와 channel buffer 한도를 적용한다", () => { + const scene = { + sampleRate: 8000, + duration: 0.02, + speedOfSound: 343, + seed: 1, + mix: { normalization: "peak", targetPeak: 0.5 }, + sources: [{ + id: "tone", name: "tone", position: [0, 0, 1], gainDb: 6, startTime: 0, duration: 0.02, + signal: { type: "tone", frequency: 500, phaseDeg: 0 }, + }], + ambient: [], + }; + const arrayDefinition = { microphones: [{ id: "mic", position: [0, 0, 0] }] }; + const result = synthesizeMicrophoneSignals({ scene, arrayDefinition }); + assertNear(result.peak, 0.5, 1e-6, "normalized peak"); + assert(result.appliedScale < 1); + let error; + try { + synthesizeMicrophoneSignals({ scene, arrayDefinition, maximumBufferBytes: 1 }); + } catch (caught) { + error = caught; + } + assert(error instanceof Error && error.message.includes("한도"), "buffer limit error"); +}); + +test("ambient에도 microphone gain과 phase calibration 편차를 적용한다", () => { + const scene = { + sampleRate: 8000, + duration: 0.02, + speedOfSound: 343, + seed: 1, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [], + ambient: [{ id: "ambient", gainDb: -20, signal: { type: "tone", frequency: 500, phaseDeg: 0 } }], + }; + const arrayDefinition = { + microphones: [{ id: "calibrated", position: [0, 0, 0], sensitivityDb: 6, phaseOffsetDeg: 90 }], + }; + const result = synthesizeMicrophoneSignals({ scene, arrayDefinition }); + const expected = dbToLinear(-20) * dbToLinear(6); + assertNear(result.channels[0][80], expected, 4e-3, "ambient calibrated sample"); +}); + +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; + }); + const scene = { + id: "das-scene", + sampleRate: 8000, + duration: 0.128, + speedOfSound: 343, + seed: 7, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [{ + id: "source", name: "source", position: [0.1, 0.1, 1.5], gainDb: -12, startTime: 0, duration: 0.128, + signal: { type: "tone", frequency: 1000, phaseDeg: 0 }, + }], + ambient: [], + }; + const experiment = { + stft: { fftSize: 256, hopSize: 128, window: "hann" }, + scan: { z: 1.5, xMin: -0.4, xMax: 0.4, yMin: -0.4, yMax: 0.4, columns: 17, rows: 17 }, + frequencyBands: [{ id: "1k", minHz: 990, maxHz: 1010, color: "#65f2bf" }], + beamforming: { normalization: "peak" }, + }; + const synthesis = synthesizeMicrophoneSignals({ scene, arrayDefinition }); + const progressEvents = []; + const result = await computeDasMap({ + channels: synthesis.channels, + sampleRate: synthesis.sampleRate, + arrayDefinition, + scene, + experiment, + onProgress: (progress) => progressEvents.push(progress), + }); + const peakPosition = result.bands[0].peak.position; + assert(distance3(peakPosition, scene.sources[0].position) <= 0.071, `peak=${peakPosition.join(",")}`); + assert(result.bands[0].rawMap !== result.bands[0].displayMap, "raw/display buffers are separate"); + 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"); +}); + +test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async () => { + const arrayDefinition = { + microphones: [ + { id: "left", position: [-0.15, 0, 0] }, + { id: "middle", position: [-0.02, 0, 0] }, + { id: "right", position: [0.12, 0, 0] }, + ], + }; + const scan = { z: 1, xMin: -0.2, xMax: 0.2, yMin: 0, yMax: 0, columns: 2, rows: 1 }; + const target = [0.2, 0, 1]; + const sampleRate = 8000; + const fftSize = 256; + const bin = 32; + const frequency = (bin * sampleRate) / fftSize; + const speedOfSound = 343; + const spectrum = { real: new Float32Array(3), imaginary: new Float32Array(3) }; + arrayDefinition.microphones.forEach((microphone, index) => { + const phase = (2 * Math.PI * frequency * distance3(target, microphone.position)) / speedOfSound; + spectrum.real[index] = Math.cos(-phase); + spectrum.imaginary[index] = Math.sin(-phase); + }); + const map = await computeBinMap({ + bin, + spectrum, + frameCount: 1, + arrayDefinition, + pointCount: 2, + distances: createDistanceMatrix(arrayDefinition, scan), + sampleRate, + fftSize, + speedOfSound, + pointBatchSize: 64, + }); + assertNear(map[1], 1, 1e-6, "target coherent power"); + assert(map[1] > map[0] * 1.1, `wrong-sign alternative power=${map[0]}`); +}); + +test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async () => { + const arrayDefinition = createArray(2, 2, 0.2, 0.2); + const scene = { speedOfSound: 343 }; + const experiment = { + stft: { fftSize: 128, hopSize: 64, window: "hann" }, + scan: { z: 1, xMin: -0.2, xMax: 0.2, yMin: -0.2, yMax: 0.2, columns: 17, rows: 17 }, + frequencyBands: [{ id: "tone", minHz: 490, maxHz: 510, color: "#65f2bf" }], + beamforming: { normalization: "peak" }, + }; + const channels = arrayDefinition.microphones.map(() => new Float32Array(512)); + let memoryError; + try { + await computeDasMap({ channels, sampleRate: 8000, arrayDefinition, scene, experiment, maximumWorkingBytes: 1 }); + } catch (error) { + memoryError = error; + } + assert(memoryError instanceof Error && memoryError.message.includes("working set"), "working-set guard"); + + const controller = new AbortController(); + let stftStarted = false; + let abortScheduled = false; + const pending = computeDasMap({ + channels, + sampleRate: 8000, + arrayDefinition, + scene, + experiment, + signal: controller.signal, + pointBatchSize: 1, + stftTransformBatchSize: 1, + onProgress: (progress) => { + if (progress.stage === "stft" && progress.completedTransforms > 0 && !abortScheduled) { + stftStarted = true; + abortScheduled = true; + setTimeout(() => controller.abort(), 0); + } + }, + }); + let abortError; + try { + await pending; + } catch (error) { + abortError = error; + } + assert(stftStarted, "STFT started before abort"); + assert(abortError?.name === "AbortError", "async abort"); +});