feat: add realistic portable localization core
This commit is contained in:
@@ -20,6 +20,8 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
|
||||
- `contracts.test.js`: Array·Scene·Experiment·Result schema, 관계 검증, 안전한 asset 경로와 이동 fixture
|
||||
- `acoustics.test.js`: seed 재현성, 자유음장 전파, trajectory 보간, sample 정렬 시간창과
|
||||
tone/noise/asset 절대 시간 연속성, calibration, STFT와 CPU DAS
|
||||
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
|
||||
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
|
||||
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
||||
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
||||
beam-spread covariance ellipse
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -61,6 +61,43 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
|
||||
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
||||
});
|
||||
|
||||
test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => {
|
||||
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
|
||||
scene.acoustics = {
|
||||
model: "shoebox",
|
||||
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||
reflections: { order: 1, wallGainDb: -8, scattering: 0.2 },
|
||||
diffuseReverb: { enabled: true, rt60Seconds: 0.35, gainDb: -20, tapCount: 12 },
|
||||
occluders: [{
|
||||
id: "screen",
|
||||
min: [-0.2, -0.2, 0.8],
|
||||
max: [0.2, 0.2, 1],
|
||||
transmissionDb: -18,
|
||||
diffractionGainDb: -12,
|
||||
}],
|
||||
};
|
||||
assertEqual((await validateScene(scene)).length, 0, "valid shoebox scene");
|
||||
|
||||
scene.acoustics.room.max[0] = scene.acoustics.room.min[0];
|
||||
let issues = await validateScene(scene);
|
||||
assert(issues.some(({ path }) => path === "$.acoustics.room"), "unordered room bounds");
|
||||
|
||||
scene.acoustics.room.max[0] = 2;
|
||||
scene.acoustics.occluders[0].max[2] = 4;
|
||||
issues = await validateScene(scene);
|
||||
assert(
|
||||
issues.some(({ path, message }) => path.includes("occluders[0]") && message.includes("room")),
|
||||
"occluder outside room",
|
||||
);
|
||||
|
||||
scene.acoustics = {
|
||||
model: "free-field",
|
||||
room: { min: [-2, -2, -1], max: [2, 2, 3] },
|
||||
};
|
||||
issues = await validateScene(scene);
|
||||
assert(issues.some(({ path }) => path === "$.acoustics"), "unused free-field settings");
|
||||
});
|
||||
|
||||
test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => {
|
||||
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||
@@ -125,6 +162,32 @@ test("중복 마이크 id를 거부한다", async () => {
|
||||
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
|
||||
});
|
||||
|
||||
test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름차순이어야 한다", async () => {
|
||||
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
||||
arrayDefinition.microphones[0].frequencyResponse = [
|
||||
{ frequencyHz: 20, gainDb: -3 },
|
||||
{ frequencyHz: 1000, gainDb: 1.5, phaseDeg: 12 },
|
||||
{ frequencyHz: 20_000, gainDb: -2 },
|
||||
];
|
||||
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "valid response curve");
|
||||
|
||||
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1000;
|
||||
let issues = await validateArrayDefinition(arrayDefinition);
|
||||
assert(
|
||||
issues.some(({ path, message }) =>
|
||||
path === "$.microphones[0].frequencyResponse[2].frequencyHz" &&
|
||||
message.includes("엄격히 증가")),
|
||||
"duplicate frequency is rejected with sorting guidance",
|
||||
);
|
||||
|
||||
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1_000_001;
|
||||
issues = await validateArrayDefinition(arrayDefinition);
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.microphones[0].frequencyResponse[2].frequencyHz"),
|
||||
"globally bounded frequency is enforced without a Scene Nyquist dependency",
|
||||
);
|
||||
});
|
||||
|
||||
test("Nyquist를 넘는 tone을 거부한다", async () => {
|
||||
const scene = {
|
||||
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
||||
@@ -156,11 +219,13 @@ test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한
|
||||
scene.unexpected = true;
|
||||
delete experiment.beamforming.normalization;
|
||||
experiment.beamforming.dynamicRangeDb = 4;
|
||||
experiment.beamforming.compensateFrequencyResponse = "false";
|
||||
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
|
||||
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
|
||||
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
|
||||
assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse")));
|
||||
});
|
||||
|
||||
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { computeDasMap } from "/packages/acoustics-core/das.js";
|
||||
import {
|
||||
createLocalizationPlan,
|
||||
createStreamingLocalizer,
|
||||
localizeMultichannel,
|
||||
} from "/packages/acoustics-core/realtime-localizer.js";
|
||||
import { distance3 } from "/packages/acoustics-core/math.js";
|
||||
|
||||
function createFixture(sampleCount = 320) {
|
||||
const sampleRate = 4096;
|
||||
const speedOfSound = 343;
|
||||
const frequency = 512;
|
||||
const target = [0.1, -0.1, 1];
|
||||
const array = {
|
||||
id: "portable-array",
|
||||
microphones: [
|
||||
{
|
||||
id: "nw",
|
||||
position: [-0.12, 0.12, 0],
|
||||
sensitivityDb: 1.5,
|
||||
phaseOffsetDeg: -4,
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 400, gainDb: -1, phaseDeg: -3 },
|
||||
{ frequencyHz: 600, gainDb: 2, phaseDeg: 5 },
|
||||
],
|
||||
},
|
||||
{ id: "ne", position: [0.12, 0.12, 0], sensitivityDb: -0.5, phaseOffsetDeg: 2 },
|
||||
{ id: "sw", position: [-0.12, -0.12, 0] },
|
||||
{ id: "se", position: [0.12, -0.12, 0] },
|
||||
],
|
||||
};
|
||||
const channels = array.microphones.map((microphone) => {
|
||||
const delay = distance3(target, microphone.position) / speedOfSound;
|
||||
return Float32Array.from(
|
||||
{ length: sampleCount },
|
||||
(_, sampleIndex) =>
|
||||
Math.sin(2 * Math.PI * frequency * (sampleIndex / sampleRate - delay)),
|
||||
);
|
||||
});
|
||||
const scan = {
|
||||
z: 1,
|
||||
xMin: -0.2,
|
||||
xMax: 0.2,
|
||||
yMin: -0.1,
|
||||
yMax: 0.1,
|
||||
columns: 5,
|
||||
rows: 3,
|
||||
};
|
||||
const bands = [{ id: "tone", minHz: 500, maxHz: 524, color: "#65f2bf" }];
|
||||
const config = {
|
||||
sampleRate,
|
||||
array,
|
||||
scan,
|
||||
bands,
|
||||
speedOfSound,
|
||||
stft: { fftSize: 64, hopSize: 32 },
|
||||
normalization: "peak",
|
||||
maximumSteeringCacheBytes: 1024 * 1024,
|
||||
computeOptions: {
|
||||
pointBatchSize: 1024,
|
||||
stftTransformBatchSize: 1024,
|
||||
},
|
||||
};
|
||||
return { sampleRate, speedOfSound, target, array, channels, scan, bands, config };
|
||||
}
|
||||
|
||||
function assertFloat32Exact(actual, expected, label) {
|
||||
assertEqual(actual.length, expected.length, `${label} length`);
|
||||
for (let index = 0; index < actual.length; index += 1) {
|
||||
assertEqual(actual[index], expected[index], `${label}[${index}]`);
|
||||
}
|
||||
}
|
||||
|
||||
function sliceChannels(channels, start, end) {
|
||||
return channels.map((channel) => channel.slice(start, end));
|
||||
}
|
||||
|
||||
test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map을 만든다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const experiment = {
|
||||
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
||||
scan: fixture.scan,
|
||||
frequencyBands: fixture.bands,
|
||||
beamforming: { normalization: "peak", compensateFrequencyResponse: true },
|
||||
};
|
||||
const baseline = await computeDasMap({
|
||||
channels: fixture.channels,
|
||||
sampleRate: fixture.sampleRate,
|
||||
arrayDefinition: fixture.array,
|
||||
scene: { speedOfSound: fixture.speedOfSound },
|
||||
experiment,
|
||||
pointBatchSize: 1024,
|
||||
stftTransformBatchSize: 1024,
|
||||
});
|
||||
const plan = createLocalizationPlan(fixture.config);
|
||||
const portable = await localizeMultichannel({
|
||||
plan,
|
||||
channels: fixture.channels,
|
||||
});
|
||||
|
||||
assertFloat32Exact(portable.bands[0].rawMap, baseline.bands[0].rawMap, "raw map");
|
||||
assertFloat32Exact(portable.bands[0].displayMap, baseline.bands[0].displayMap, "display map");
|
||||
assertEqual(portable.bands[0].peak.index, baseline.bands[0].peak.index, "peak index");
|
||||
assertEqual(portable.locations[0].bandId, "tone", "portable location band");
|
||||
assertEqual(portable.sampleCount, 128, "sample count");
|
||||
assertEqual(portable.duration, 128 / fixture.sampleRate, "block duration");
|
||||
});
|
||||
|
||||
test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 실제로 제거한다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const plan = createLocalizationPlan(fixture.config);
|
||||
const first = await localizeMultichannel({ plan, channels: fixture.channels });
|
||||
const second = await localizeMultichannel({ plan, channels: fixture.channels });
|
||||
const cache = second.analysisCache;
|
||||
const pointMicrophonePairs = fixture.scan.columns * fixture.scan.rows * fixture.array.microphones.length;
|
||||
|
||||
assertEqual(cache.analysisAttempts, 2, "analysis attempts");
|
||||
assertEqual(cache.completedAnalyses, 2, "completed analyses");
|
||||
assertEqual(cache.distanceMatrixBuilds, 1, "one retained distance matrix");
|
||||
assertEqual(cache.distanceEvaluations, pointMicrophonePairs, "distance evaluations performed");
|
||||
assertEqual(
|
||||
cache.avoidedDistanceEvaluations,
|
||||
pointMicrophonePairs,
|
||||
"second distance pass avoided",
|
||||
);
|
||||
assert(cache.steeringCacheEnabled, "bounded steering cache enabled");
|
||||
assertEqual(cache.steeringMatrixBuilds, 1, "one steering matrix build");
|
||||
assertEqual(cache.steeringCacheHits, 1, "second steering pass is a cache hit");
|
||||
assertEqual(
|
||||
cache.avoidedSteeringPointMicrophonePairs,
|
||||
pointMicrophonePairs,
|
||||
"second trigonometric steering pass avoided",
|
||||
);
|
||||
assertEqual(cache.avoidedBinMapAllocations, 1, "second temporary bin map allocation avoided");
|
||||
assert(cache.retainedBytes > 0, "retained cache bytes are reported");
|
||||
assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "cached repeat raw map");
|
||||
});
|
||||
|
||||
test("streaming localizer 결과가 임의 chunk 경계와 무관하다", async () => {
|
||||
const fixture = createFixture(320);
|
||||
const streamConfig = {
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 512,
|
||||
maximumResultsPerCall: 4,
|
||||
};
|
||||
const whole = createStreamingLocalizer(streamConfig);
|
||||
whole.append(fixture.channels);
|
||||
const wholeResults = await whole.analyzeAvailable({ maximumResults: 4 });
|
||||
|
||||
const chunked = createStreamingLocalizer(streamConfig);
|
||||
const chunkSizes = [17, 83, 1, 91, 128];
|
||||
const chunkedResults = [];
|
||||
let offset = 0;
|
||||
for (const chunkSize of chunkSizes) {
|
||||
const end = offset + chunkSize;
|
||||
chunkedResults.push(...await chunked.push(sliceChannels(fixture.channels, offset, end)));
|
||||
offset = end;
|
||||
}
|
||||
|
||||
assertEqual(wholeResults.length, 4, "whole block window count");
|
||||
assertEqual(chunkedResults.length, wholeResults.length, "chunked window count");
|
||||
wholeResults.forEach((expected, index) => {
|
||||
const actual = chunkedResults[index];
|
||||
assertEqual(actual.startSample, index * 64, `chunked start sample ${index}`);
|
||||
assertEqual(actual.endSample, index * 64 + 128, `chunked end sample ${index}`);
|
||||
assertEqual(actual.sequence, index, `chunked sequence ${index}`);
|
||||
assertFloat32Exact(actual.bands[0].rawMap, expected.bands[0].rawMap, `chunked map ${index}`);
|
||||
});
|
||||
assertEqual(chunked.getState().droppedSamples, 0, "no samples dropped under capacity");
|
||||
});
|
||||
|
||||
test("streaming ring buffer와 호출별 결과 수가 설정된 상한을 지킨다", async () => {
|
||||
const fixture = createFixture(400);
|
||||
let planLimitError;
|
||||
try {
|
||||
createLocalizationPlan({ ...fixture.config, maximumPlanBytes: 64 });
|
||||
} catch (error) {
|
||||
planLimitError = error;
|
||||
}
|
||||
assert(
|
||||
planLimitError instanceof RangeError &&
|
||||
planLimitError.message.includes("maximumPlanBytes"),
|
||||
"analysis plan allocation hard limit",
|
||||
);
|
||||
const invalidResponseConfig = structuredClone(fixture.config);
|
||||
invalidResponseConfig.array.microphones[0].frequencyResponse = [
|
||||
{ frequencyHz: 1000, gainDb: 0 },
|
||||
{ frequencyHz: 1000, gainDb: -3 },
|
||||
];
|
||||
let responseError;
|
||||
try {
|
||||
createLocalizationPlan(invalidResponseConfig);
|
||||
} catch (error) {
|
||||
responseError = error;
|
||||
}
|
||||
assert(
|
||||
responseError instanceof RangeError && responseError.message.includes("strictly increasing"),
|
||||
"portable microphone response validation",
|
||||
);
|
||||
let inputLimitError;
|
||||
try {
|
||||
createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
maximumBufferedSamples: 128,
|
||||
maximumInputBufferBytes: 1024,
|
||||
});
|
||||
} catch (error) {
|
||||
inputLimitError = error;
|
||||
}
|
||||
assert(
|
||||
inputLimitError instanceof RangeError &&
|
||||
inputLimitError.message.includes("maximumInputBufferBytes"),
|
||||
"input allocation hard limit",
|
||||
);
|
||||
const bounded = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 128,
|
||||
maximumResultsPerCall: 1,
|
||||
});
|
||||
const appended = bounded.append(fixture.channels);
|
||||
assertEqual(appended.bufferedSamples, 128, "ring capacity");
|
||||
assertEqual(appended.droppedSamples, 272, "oldest samples dropped");
|
||||
assertEqual(appended.bufferStartSample, 272, "retained absolute sample origin");
|
||||
assertEqual(
|
||||
appended.retainedInputBytes,
|
||||
fixture.array.microphones.length * (128 + 128) * Float32Array.BYTES_PER_ELEMENT,
|
||||
"fixed ring and analysis workspace bytes",
|
||||
);
|
||||
const boundedResults = await bounded.analyzeAvailable();
|
||||
assertEqual(boundedResults.length, 1, "one bounded result");
|
||||
assertEqual(boundedResults[0].startSample, 272, "latest retained window analyzed");
|
||||
|
||||
const resultBound = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 512,
|
||||
maximumResultsPerCall: 2,
|
||||
});
|
||||
resultBound.append(fixture.channels);
|
||||
const first = await resultBound.analyzeAvailable();
|
||||
const second = await resultBound.analyzeAvailable();
|
||||
assertEqual(first.length, 2, "first result batch bound");
|
||||
assertEqual(second.length, 2, "second result batch bound");
|
||||
assertEqual(resultBound.getState().availableAnalysisCount, 1, "remaining work stays buffered");
|
||||
});
|
||||
|
||||
test("streaming 분석 취소가 AbortError를 전달하고 계획 lock을 해제한다", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const stream = createStreamingLocalizer({
|
||||
...fixture.config,
|
||||
analysisWindowSamples: 128,
|
||||
analysisHopSamples: 64,
|
||||
maximumBufferedSamples: 256,
|
||||
maximumResultsPerCall: 1,
|
||||
});
|
||||
stream.append(fixture.channels);
|
||||
const controller = new AbortController();
|
||||
let cancellationRequested = false;
|
||||
let caught;
|
||||
try {
|
||||
await stream.analyzeAvailable({
|
||||
signal: controller.signal,
|
||||
computeOptions: { stftTransformBatchSize: 1 },
|
||||
onProgress: ({ stage, completedTransforms }) => {
|
||||
if (stage === "stft" && completedTransforms > 0 && !cancellationRequested) {
|
||||
cancellationRequested = true;
|
||||
controller.abort();
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
assert(cancellationRequested, "cancellation requested during STFT");
|
||||
assertEqual(caught?.name, "AbortError", "AbortError propagated");
|
||||
assertEqual(stream.getState().busy, false, "stream lock released");
|
||||
assertEqual(stream.plan.dasPlan.busy, false, "DAS plan lock released");
|
||||
assertEqual(stream.getState().failedAnalyses, 1, "failed analysis counted");
|
||||
});
|
||||
@@ -81,6 +81,36 @@ test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () =>
|
||||
expected.forEach((value, index) => assertEqual(packed[index], value, `packed STFT value ${index}`));
|
||||
});
|
||||
|
||||
test("WebGPU STFT packing이 microphone 주파수 응답을 bin별로 역보정한다", () => {
|
||||
const stft = {
|
||||
frameCount: 1,
|
||||
microphoneCount: 1,
|
||||
spectra: new Map([[8, { real: Float32Array.of(0), imaginary: Float32Array.of(2) }]]),
|
||||
};
|
||||
const microphones = [{
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: 6.020599913, phaseDeg: 90 },
|
||||
{ frequencyHz: 4000, gainDb: 6.020599913, phaseDeg: 90 },
|
||||
],
|
||||
}];
|
||||
const compensated = packStftBatch(stft, [8], {
|
||||
microphones,
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
});
|
||||
assertNear(compensated[0], 1, 1e-6, "inverse response real");
|
||||
assertNear(compensated[1], 0, 1e-6, "inverse response imaginary");
|
||||
|
||||
const uncompensated = packStftBatch(stft, [8], {
|
||||
microphones,
|
||||
sampleRate: 8000,
|
||||
fftSize: 256,
|
||||
compensateFrequencyResponse: false,
|
||||
});
|
||||
assertEqual(uncompensated[0], 0, "disabled compensation real");
|
||||
assertEqual(uncompensated[1], 2, "disabled compensation imaginary");
|
||||
});
|
||||
|
||||
test("WebGPU batch size가 storage, dispatch, memory 한도를 모두 지킨다", () => {
|
||||
const workload = {
|
||||
selectedBins: [4, 5, 6, 7, 8],
|
||||
|
||||
Reference in New Issue
Block a user