feat: add realistic portable localization core
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user