feat: implement free-field CPU beamforming
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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: "테스트 모듈을 불러온다",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user