feat: add realistic portable localization core

This commit is contained in:
Codex
2026-08-28 18:19:31 +09:00
parent bc63ada1cd
commit d002a2bd70
20 changed files with 2345 additions and 70 deletions
+231
View File
@@ -1,7 +1,13 @@
import { computeBinMap, computeDasMap, createDistanceMatrix } from "/packages/acoustics-core/das.js";
import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
import {
buildAcousticPaths,
maximumAcousticDelaySeconds,
} from "/packages/acoustics-core/environment.js";
import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js";
import {
addFrequencyDomainDelay,
addFrequencyDomainPaths,
createDelayWorkspace,
prepareDelaySpectrum,
} from "/packages/acoustics-core/fractional-delay.js";
@@ -119,6 +125,100 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
});
test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => {
const scene = { speedOfSound: 400, seed: 11 };
const paths = buildAcousticPaths({
scene,
sourcePosition: [0, 0, 2],
receiverPosition: [0, 0, 0],
sourceId: "source",
receiverId: "mic",
});
assertEqual(paths.length, 1, "direct path only");
assertEqual(paths[0].kind, "direct", "path kind");
assertNear(paths[0].delaySeconds, 0.005, 1e-12, "direct delay");
assertNear(paths[0].gain, 0.5, 1e-12, "direct spherical gain");
});
test("shoebox가 1차 반사와 산란을 고정된 sparse path로 만든다", () => {
const scene = {
speedOfSound: 343,
seed: 23,
sources: [{ id: "source", position: [0, 0, 1] }],
acoustics: {
model: "shoebox",
room: { min: [-2, -2, -1], max: [2, 2, 3] },
reflections: { order: 1, wallGainDb: -6, scattering: 0.25 },
diffuseReverb: { enabled: true, rt60Seconds: 0.2, gainDb: -18, tapCount: 4 },
},
};
const options = {
scene,
sourcePosition: scene.sources[0].position,
receiverPosition: [0, 0, 0],
sourceId: "source",
receiverId: "mic",
};
const first = buildAcousticPaths(options);
const second = buildAcousticPaths(options);
assertEqual(first.length, 17, "direct + six reflection + six scatter + four diffuse");
assertEqual(first.filter(({ kind }) => kind === "reflection").length, 6, "first-order reflection count");
assertEqual(first.filter(({ kind }) => kind === "scattering").length, 6, "scattering count");
assertEqual(first.filter(({ kind }) => kind === "diffuse").length, 4, "diffuse count");
assertEqual(JSON.stringify(first), JSON.stringify(second), "seeded path set is deterministic");
const maximum = maximumAcousticDelaySeconds({
scene,
arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] },
});
assertNear(maximum, Math.max(...first.map(({ delaySeconds }) => delaySeconds)), 1e-12, "maximum delay");
});
test("차폐물은 직접음을 감쇠하고 더 긴 edge diffraction 경로를 추가한다", () => {
const scene = {
speedOfSound: 400,
seed: 1,
acoustics: {
model: "shoebox",
room: { min: [-2, -2, -2], max: [2, 2, 2] },
occluders: [{
id: "panel",
min: [-0.1, -0.5, -0.5],
max: [0.1, 0.5, 0.5],
transmissionDb: -20,
diffractionGainDb: -12,
}],
},
};
const paths = buildAcousticPaths({
scene,
sourcePosition: [-1, 0, 0],
receiverPosition: [1, 0, 0],
});
assertEqual(paths.length, 2, "direct and diffraction paths");
const direct = paths.find(({ kind }) => kind === "direct");
const diffraction = paths.find(({ kind }) => kind === "diffraction");
assertNear(direct.gain, 0.05, 1e-12, "-20 dB direct transmission");
assert(diffraction.distance > direct.distance, "edge detour is longer than line of sight");
assert(diffraction.delaySeconds > direct.delaySeconds, "diffraction arrives later");
});
test("마이크 주파수 응답을 dB와 wrapped phase로 보간하고 끝점 밖에서 고정한다", () => {
const curve = [
{ frequencyHz: 100, gainDb: -6, phaseDeg: 170 },
{ frequencyHz: 300, gainDb: 6, phaseDeg: -170 },
];
const below = interpolateFrequencyResponse(curve, 20);
const middle = interpolateFrequencyResponse(curve, 200);
const above = interpolateFrequencyResponse(curve, 10_000);
assertEqual(below.gainDb, -6, "lower endpoint gain");
assertEqual(below.phaseDeg, 170, "lower endpoint phase");
assertNear(middle.gainDb, 0, 1e-12, "interpolated dB");
assertNear(Math.abs(middle.phaseDeg), 180, 1e-12, "phase follows shortest wrapped path");
assertNear(middle.linearGain, 1, 1e-12, "interpolated linear gain");
assertEqual(above.gainDb, 6, "upper endpoint gain");
assertEqual(above.phaseDeg, -170, "upper endpoint phase");
});
test("index 기반 noise가 seed에 대해 결정적이다", () => {
const first = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
const second = Array.from({ length: 16 }, (_, index) => whiteNoiseAt(42, index));
@@ -174,6 +274,24 @@ test("FFT phase shift가 integer와 fractional delay를 적용한다", () => {
assert(Math.sqrt(squaredError / count) < 1e-3, "fractional delay RMS error");
});
test("여러 음향 경로를 하나의 frequency-domain transfer로 합성한다", () => {
const impulse = new Float32Array(16);
impulse[2] = 1;
const spectrum = prepareDelaySpectrum(impulse, 5);
const output = new Float32Array(16);
addFrequencyDomainPaths({
output,
spectrum,
paths: [
{ delaySamples: 3, gain: 1 },
{ delaySamples: 5, gain: 0.5 },
],
workspace: createDelayWorkspace(spectrum.fftSize),
});
assertNear(output[5], 1, 1e-6, "direct impulse");
assertNear(output[7], 0.5, 1e-6, "reflected impulse");
});
test("STFT frame count가 마지막 partial tail을 포함한다", () => {
assertEqual(getFrameCount(512, 512, 256), 1);
assertEqual(getFrameCount(513, 512, 256), 2);
@@ -207,6 +325,35 @@ test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () =>
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
});
test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => {
const scene = {
sampleRate: 8000,
duration: 0.02,
speedOfSound: 343,
seed: 7,
mix: { normalization: "none", targetPeak: 0.95 },
acoustics: {
model: "shoebox",
room: { min: [-2, -2, -1], max: [2, 2, 3] },
reflections: { order: 1, wallGainDb: -12, scattering: 0 },
},
sources: [{
id: "tone", name: "tone", position: [0, 0, 1], gainDb: -30,
startTime: 0, duration: 0.02,
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
}],
ambient: [],
};
const result = synthesizeMicrophoneSignals({
scene,
arrayDefinition: { microphones: [{ id: "mic", position: [0, 0, 0] }] },
});
assertEqual(result.acousticModel, "shoebox", "reported acoustic model");
assertEqual(result.acousticPathCount, 7, "one direct and six image-source paths");
assertEqual(result.acousticPathCounts.reflection, 6, "reflection path count");
assert(result.preNormalizationPeak > 0, "multipath channel contains signal");
});
test("peak mix 정규화와 channel buffer 한도를 적용한다", () => {
const scene = {
sampleRate: 8000,
@@ -251,11 +398,65 @@ test("ambient에도 microphone gain과 phase calibration 편차를 적용한다"
assertNear(result.channels[0][80], expected, 4e-3, "ambient calibrated sample");
});
test("합성 microphone channel에 주파수별 응답 gain을 적용한다", () => {
const scene = {
sampleRate: 8000,
duration: 0.256,
speedOfSound: 343,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [
{
id: "lower", name: "lower", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256,
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
},
{
id: "upper", name: "upper", position: [0, 0, 0], gainDb: -30, startTime: 0, duration: 0.256,
signal: { type: "tone", frequency: 1000, phaseDeg: 0 },
},
],
ambient: [],
};
const baselineArray = { microphones: [{ id: "mic", position: [0, 0, 0] }] };
const responseArray = {
microphones: [{
id: "mic",
position: [0, 0, 0],
frequencyResponse: [
{ frequencyHz: 500, gainDb: -6.020599913 },
{ frequencyHz: 1000, gainDb: 6.020599913 },
],
}],
};
const baseline = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: baselineArray }).channels[0]);
const filtered = fftReal(synthesizeMicrophoneSignals({ scene, arrayDefinition: responseArray }).channels[0]);
const magnitudeAt = (spectrum, frequency) => {
const bin = Math.round((frequency * spectrum.real.length) / scene.sampleRate);
return Math.hypot(spectrum.real[bin], spectrum.imaginary[bin]);
};
assertNear(
magnitudeAt(filtered, 500) / magnitudeAt(baseline, 500),
0.5,
0.015,
"500 Hz channel response",
);
assertNear(
magnitudeAt(filtered, 1000) / magnitudeAt(baseline, 1000),
2,
0.03,
"1 kHz channel response",
);
});
test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾는다", async () => {
const arrayDefinition = createArray(4, 4, 0.3, 0.3);
arrayDefinition.microphones.forEach((microphone, index) => {
microphone.sensitivityDb = (index % 3) * 2 - 2;
microphone.phaseOffsetDeg = (index % 5) * 8 - 16;
microphone.frequencyResponse = [
{ frequencyHz: 0, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 },
{ frequencyHz: 4000, gainDb: (index % 4) * 6 - 9, phaseDeg: (index % 8) * 30 - 105 },
];
});
const scene = {
id: "das-scene",
@@ -292,6 +493,21 @@ test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾
assertNear(Math.max(...result.bands[0].rawMap), result.bands[0].peak.rawPower, 1e-4, "raw map peak");
assertNear(Math.max(...result.bands[0].displayMap), 1, 1e-6, "normalized display peak");
assert(progressEvents.some(({ stage }) => stage === "beamforming"), "beamforming progress");
const uncompensated = await computeDasMap({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment: {
...experiment,
beamforming: { ...experiment.beamforming, compensateFrequencyResponse: false },
},
});
assert(
result.bands[0].peak.rawPower > uncompensated.bands[0].peak.rawPower * 1.5,
"known response compensation improves coherent peak power",
);
});
test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async () => {
@@ -329,6 +545,21 @@ test("독립 복소 spectrum fixture에서 DAS steering 부호가 맞다", async
});
assertNear(map[1], 1, 1e-6, "target coherent power");
assert(map[1] > map[0] * 1.1, `wrong-sign alternative power=${map[0]}`);
const legacyMap = await computeBinMap({
bin,
spectrum,
frameCount: 1,
arrayDefinition,
pointCount: 2,
distances: createDistanceMatrix(arrayDefinition, scan),
sampleRate,
fftSize,
speedOfSound,
pointBatchSize: 64,
compensateFrequencyResponse: false,
});
assertEqual(legacyMap[0], map[0], "response-free map remains unchanged when compensation is disabled");
assertEqual(legacyMap[1], map[1], "response-free target power remains unchanged");
});
test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async () => {