feat: implement free-field CPU beamforming

This commit is contained in:
Codex
2026-08-28 09:48:34 +09:00
parent 691a4b03ce
commit 6a1197d60e
15 changed files with 1148 additions and 4 deletions
+4
View File
@@ -15,6 +15,10 @@ scene JSON에는 192채널 원시 sample을 넣지 않는다. 합성 tone/noise
재생성하고, 녹음 파일과 이미지는 manifest 기준의 안전한 상대 경로로 참조한다. 사용자 컴퓨터의 재생성하고, 녹음 파일과 이미지는 manifest 기준의 안전한 상대 경로로 참조한다. 사용자 컴퓨터의
절대 경로, URL, `..` 경로는 저장하지 않는다. 절대 경로, URL, `..` 경로는 저장하지 않는다.
array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에 적용되는 편차다. 합성기는
이를 신호에 주입하고, beamformer는 알려진 calibration 값의 역 gain/phase를 적용한다. 따라서
두 값이 0이 아닌 배열도 오류 없이 조용히 무시되지 않는다.
## 오디오와 map 정규화 ## 오디오와 map 정규화
`scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래 `scene.mix.normalization`은 마이크 신호 합성 전에 적용할 오디오 mix 정책이다. `none`은 원래
+22 -1
View File
@@ -1,3 +1,24 @@
# Acoustics Core # 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 검출까지 실행합니다.
+270
View File
@@ -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,
};
}
+63
View File
@@ -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 };
}
@@ -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;
}
}
+8
View File
@@ -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";
+62
View File
@@ -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 };
}
+34
View File
@@ -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;
}
+123
View File
@@ -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,
};
}
+53
View File
@@ -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;
}
+96
View File
@@ -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);
}
+20 -3
View File
@@ -332,14 +332,31 @@ function validateExperimentRelationships(value, context = {}) {
}); });
} }
if (context.arrayDefinition && context.scene) { if (context.arrayDefinition && context.scene) {
const estimatedBytes = const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
context.arrayDefinition.microphones.length * Math.ceil(context.scene.duration * context.scene.sampleRate) * 4; 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; const maximumBytes = 512 * 1024 * 1024;
if (estimatedBytes > maximumBytes) { if (estimatedBytes > maximumBytes) {
addIssue( addIssue(
issues, issues,
"$", "$",
`현재 비스트리밍 CPU 모델의 채널 buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`, `현재 비스트리밍 CPU 모델의 signal buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
); );
} }
} }
+34
View File
@@ -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);
});
+2
View File
@@ -10,6 +10,8 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
try { try {
await import("/tests/unit/contracts.test.js"); await import("/tests/unit/contracts.test.js");
await import("/tests/unit/acoustics.test.js");
await import("/tests/integration/cpu-baseline.test.js");
} catch (error) { } catch (error) {
registeredTests.push({ registeredTests.push({
name: "테스트 모듈을 불러온다", name: "테스트 모듈을 불러온다",
+304
View File
@@ -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");
});