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