fix: expose physical aliasing and delayed arrivals
This commit is contained in:
@@ -133,6 +133,20 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
|
||||
frame.style.width = "1400px";
|
||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 5000, "input");
|
||||
assert(
|
||||
documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone") &&
|
||||
!documentObject.querySelector("#source-list").textContent.includes("ADC 3000 Hz alias"),
|
||||
"tone card starts as baseband at 48 kHz sample rate",
|
||||
);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 8000, "change");
|
||||
assert(
|
||||
documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone → ADC 3000 Hz alias"),
|
||||
"sample-rate change immediately rerenders the tone alias card",
|
||||
);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 48000, "change");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 1000, "input");
|
||||
|
||||
const previewSamples = new Float32Array(2000);
|
||||
for (let index = 0; index < previewSamples.length; index += 1) {
|
||||
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
|
||||
|
||||
@@ -171,6 +171,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
"Result JSON download",
|
||||
);
|
||||
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
|
||||
assertEqual(resultDocument.schemaVersion, "1.1.0", "exported sampling-aware Result version");
|
||||
assert(
|
||||
!documentObject.documentElement.dataset.unsavedWork.includes("Result"),
|
||||
"Result export clears Result dirty state",
|
||||
@@ -399,11 +400,24 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
scene.name = "Two-tone band preservation";
|
||||
const secondSource = structuredClone(scene.sources[0]);
|
||||
secondSource.id = "source-2";
|
||||
secondSource.name = "1.2 kHz tone";
|
||||
secondSource.name = "48 kHz DC-edge tone";
|
||||
secondSource.position = [-0.12, 0.09, 2];
|
||||
secondSource.signal.frequency = 1200;
|
||||
secondSource.signal.frequency = 48000;
|
||||
secondSource.signal.phaseDeg = 45;
|
||||
scene.sources.push(secondSource);
|
||||
const nyquistEdgeSource = structuredClone(scene.sources[0]);
|
||||
nyquistEdgeSource.id = "source-3";
|
||||
nyquistEdgeSource.name = "72 kHz Nyquist-edge tone";
|
||||
nyquistEdgeSource.position = [0.18, -0.12, 2];
|
||||
nyquistEdgeSource.signal.frequency = 72000;
|
||||
nyquistEdgeSource.signal.phaseDeg = 90;
|
||||
scene.sources[0].name = "47 kHz aliased tone";
|
||||
scene.sources[0].signal.frequency = 47000;
|
||||
scene.sources.push(secondSource, nyquistEdgeSource);
|
||||
scene.ambient.push({
|
||||
id: "ambient-1",
|
||||
gainDb: -30,
|
||||
signal: { type: "tone", frequency: 46800, phaseDeg: 20 },
|
||||
});
|
||||
|
||||
const openArrayDefinition = await loadJson(
|
||||
"/assets/array-geometries/rectangular-16x12.array.json",
|
||||
@@ -486,38 +500,62 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
);
|
||||
downloads.length = 0;
|
||||
|
||||
const contextInvalidExperiment = structuredClone(experiment);
|
||||
contextInvalidExperiment.id = "nyquist-invalid-after-rebind";
|
||||
contextInvalidExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2;
|
||||
const aliasedExperiment = structuredClone(experiment);
|
||||
aliasedExperiment.id = "above-nyquist-after-rebind";
|
||||
aliasedExperiment.sceneId = openScene.id;
|
||||
aliasedExperiment.arrayId = openArrayDefinition.id;
|
||||
aliasedExperiment.frequencyBands[0].minHz = openScene.sampleRate / 2 + 800;
|
||||
aliasedExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2 + 1_000;
|
||||
aliasedExperiment.frequencyBands[1].minHz = openScene.sampleRate / 2 - 1_000;
|
||||
aliasedExperiment.frequencyBands[1].maxHz = openScene.sampleRate / 2 - 800;
|
||||
assertEqual(
|
||||
(await validateExperiment(contextInvalidExperiment)).length,
|
||||
(await validateExperiment(aliasedExperiment, {
|
||||
scene: openScene,
|
||||
arrayDefinition: openArrayDefinition,
|
||||
})).length,
|
||||
0,
|
||||
"Nyquist fixture is valid without a Scene context",
|
||||
"above-Nyquist observation band remains valid with a Scene context",
|
||||
);
|
||||
const contextInvalidFile = new windowObject.File(
|
||||
[JSON.stringify(contextInvalidExperiment)],
|
||||
"nyquist-invalid-after-rebind.experiment.json",
|
||||
const aliasedFile = new windowObject.File(
|
||||
[JSON.stringify(aliasedExperiment)],
|
||||
"above-nyquist-after-rebind.experiment.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
dispatchFiles(
|
||||
windowObject,
|
||||
documentObject.querySelector("#experiment-input"),
|
||||
[contextInvalidFile],
|
||||
[aliasedFile],
|
||||
);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||
documentObject.documentElement.dataset.cameraValid === "true",
|
||||
"relationship-invalid Experiment rejection",
|
||||
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
documentObject.querySelector("#experiment-id").value === aliasedExperiment.id,
|
||||
"above-Nyquist Experiment import",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#experiment-id").value,
|
||||
experiment.id,
|
||||
"context relationship validation rejects the imported Experiment before assignment",
|
||||
aliasedExperiment.id,
|
||||
"above-Nyquist Experiment is assigned instead of rejected",
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(readBandEditors()),
|
||||
JSON.stringify(expectedBands),
|
||||
"failed relationship validation leaves the current bands unchanged",
|
||||
JSON.stringify(aliasedExperiment.frequencyBands),
|
||||
"above-Nyquist requested bands remain explicit in the editor",
|
||||
);
|
||||
const samplingWarning = documentObject.querySelector("#sampling-warning");
|
||||
assert(!samplingWarning.hidden, "above-Nyquist steering warning remains visible");
|
||||
assert(
|
||||
samplingWarning.textContent.includes("ADC FFT bin") &&
|
||||
samplingWarning.textContent.includes("원래 analog carrier를 사용하지 않습니다") &&
|
||||
samplingWarning.textContent.includes("같은 ADC bin 집합") &&
|
||||
samplingWarning.textContent.includes("order 1/inverted"),
|
||||
"warning states that localization does not use the original carrier",
|
||||
);
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||
documentObject.querySelector("#experiment-id").value === experiment.id,
|
||||
"restore original multi-band Experiment",
|
||||
);
|
||||
|
||||
let replacementConfirmCount = 0;
|
||||
@@ -546,7 +584,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||
documentObject.querySelectorAll("#source-summary .source-line").length === 2 &&
|
||||
documentObject.querySelectorAll("#source-summary .source-line").length === 3 &&
|
||||
documentObject.querySelectorAll("#band-list .band-editor").length === 2 &&
|
||||
documentObject.documentElement.dataset.cameraValid === "true",
|
||||
"source-added Scene reimport",
|
||||
@@ -560,6 +598,26 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"),
|
||||
"Camera status keeps the preserved band count visible",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#source-summary").textContent.includes(
|
||||
"48000 Hz tone → ADC 0 Hz alias (signed +0 Hz) · DC phase-degenerate edge",
|
||||
),
|
||||
"Camera summary keeps the aliased exact-DC phase-degenerate edge visible",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#source-summary").textContent.includes(
|
||||
"72000 Hz tone → ADC 24000 Hz alias (signed +24000 Hz) · Nyquist phase-degenerate edge",
|
||||
),
|
||||
"Camera summary keeps the aliased exact-Nyquist phase-degenerate edge visible",
|
||||
);
|
||||
const sceneAliasWarning = documentObject.querySelector("#sampling-warning");
|
||||
assert(!sceneAliasWarning.hidden, "aliased Scene tone keeps the sampling warning visible");
|
||||
assert(
|
||||
sceneAliasWarning.textContent.includes("선택 ADC bin으로 접힌 Scene tone") &&
|
||||
sceneAliasWarning.textContent.includes("tone-1k ← source source-1 47000→ADC 1000 Hz") &&
|
||||
sceneAliasWarning.textContent.includes("tone-1.2k ← ambient ambient-1 46800→ADC 1200 Hz"),
|
||||
"baseband bands disclose contributing source and ambient aliases",
|
||||
);
|
||||
|
||||
const arrayFile = new windowObject.File(
|
||||
[JSON.stringify(arrayDefinition)],
|
||||
@@ -583,6 +641,32 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
"ready status keeps the connected Experiment band count visible",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"aliased Scene tone snapshot setup",
|
||||
);
|
||||
documentObject.querySelector("#run-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.runState === "done",
|
||||
"aliased Scene tone snapshot",
|
||||
30000,
|
||||
);
|
||||
const metricText = documentObject.querySelector("#metric-grid").textContent;
|
||||
assert(
|
||||
metricText.includes("tone-1k · ADC steering") &&
|
||||
metricText.includes("source source-1 47000→ADC 1000 Hz"),
|
||||
"baseband source-alias steering metric is rendered",
|
||||
);
|
||||
assert(
|
||||
metricText.includes("tone-1.2k · ADC steering") &&
|
||||
metricText.includes("ambient ambient-1 46800→ADC 1200 Hz"),
|
||||
"baseband ambient-alias steering metric is rendered",
|
||||
);
|
||||
|
||||
documentObject.querySelector("#export-experiment-button").click();
|
||||
const experimentDownload = await waitFor(
|
||||
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
createDelayWorkspace,
|
||||
prepareDelaySpectrum,
|
||||
} from "/packages/acoustics-core/fractional-delay.js";
|
||||
import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
|
||||
import {
|
||||
ceilSampleIndex,
|
||||
ceilSamplePosition,
|
||||
dbToLinear,
|
||||
distance3,
|
||||
} from "/packages/acoustics-core/math.js";
|
||||
import {
|
||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||
LOCALIZATION_ALGORITHM_IDS,
|
||||
@@ -23,7 +28,12 @@ import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
||||
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
||||
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||
import { renderSignal } from "/packages/acoustics-core/signal.js";
|
||||
import { getFrameCount } from "/packages/acoustics-core/stft.js";
|
||||
import {
|
||||
describeFrequencyAliasing,
|
||||
foldFrequencyToNyquist,
|
||||
observeFrequencyBand,
|
||||
} from "/packages/acoustics-core/sampling.js";
|
||||
import { binsForBand, getFrameCount } from "/packages/acoustics-core/stft.js";
|
||||
import {
|
||||
createQuasiStaticWindows,
|
||||
sampleQuasiStaticWindow,
|
||||
@@ -241,6 +251,217 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
|
||||
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
|
||||
});
|
||||
|
||||
test("sample boundary ceil ignores only IEEE-754 integer roundoff", () => {
|
||||
assertEqual(ceilSampleIndex(0.1 + 0.2, 1_000), 300, "summed exact boundary");
|
||||
assertEqual(ceilSamplePosition(300 + 1e-10), 301, "physical sub-sample offset is not snapped");
|
||||
assertEqual(ceilSampleIndex(0.300_001, 1_000), 301, "physical time offset is ceiled");
|
||||
});
|
||||
|
||||
test("ideal ADC sampling reports signed aliases without hiding Nyquist folding", () => {
|
||||
const first = describeFrequencyAliasing(5_000, 8_000);
|
||||
assertEqual(first.adcFrequencyHz, 3_000, "first folded frequency");
|
||||
assertEqual(first.signedAdcFrequencyHz, -3_000, "first folded signed frequency");
|
||||
assert(first.aliased && first.spectralInversion, "first fold metadata");
|
||||
assertEqual(foldFrequencyToNyquist(13_000, 8_000), 3_000, "multiple fold");
|
||||
assertEqual(foldFrequencyToNyquist(16_000, 8_000), 0, "sample-rate multiple folds to DC");
|
||||
const dc = describeFrequencyAliasing(8_000, 8_000);
|
||||
assert(dc.dcEdge && dc.phaseDegenerate, "sample-rate multiple is a phase-degenerate DC fold");
|
||||
const edge = describeFrequencyAliasing(4_000, 8_000);
|
||||
assert(
|
||||
edge.nyquistEdge && edge.phaseDegenerate && !edge.aliased,
|
||||
"exact Nyquist is retained as a phase-degenerate edge",
|
||||
);
|
||||
});
|
||||
|
||||
test("above-Nyquist requested bands retain every physically observable ADC bin", () => {
|
||||
const observation = observeFrequencyBand(
|
||||
{ minHz: 4_900, maxHz: 5_100 },
|
||||
8_000,
|
||||
256,
|
||||
);
|
||||
assert(observation.aliased, "band is marked aliased");
|
||||
assertEqual(observation.bins[0], 93, "folded lower ADC bin");
|
||||
assertEqual(observation.bins.at(-1), 99, "folded upper ADC bin");
|
||||
assertEqual(
|
||||
JSON.stringify(binsForBand({ minHz: 4_900, maxHz: 5_100 }, 8_000, 256)),
|
||||
JSON.stringify(observation.bins),
|
||||
"STFT uses the explicit observation mapping",
|
||||
);
|
||||
|
||||
const wide = observeFrequencyBand({ minHz: 0, maxHz: 8_000 }, 8_000, 256);
|
||||
assertEqual(wide.bins.length, 129, "one sample-rate interval covers every real FFT bin");
|
||||
assert(wide.continuousManyToOne, "a full sample-rate interval folds continuously many-to-one");
|
||||
assert(wide.gridManyToOne, "multiple analog FFT-grid bins share ADC bins");
|
||||
assertEqual(wide.foldSegments.length, 2, "first and second Nyquist zones remain explicit");
|
||||
assertEqual(wide.foldSegments[0].nyquistZone, 1, "Nyquist zones are one-based");
|
||||
assertEqual(wide.foldSegments[1].nyquistZone, 2, "second Nyquist zone");
|
||||
assert(!wide.foldSegments[0].inverted, "first Nyquist zone is not inverted");
|
||||
assert(wide.foldSegments[1].inverted, "second Nyquist zone is inverted");
|
||||
for (const segment of wide.foldSegments) {
|
||||
assertNear(
|
||||
segment.signedAdcStartHz + segment.aliasOrder * wide.sampleRate,
|
||||
segment.analogMinHz,
|
||||
1e-12,
|
||||
`zone ${segment.nyquistZone} start invariant`,
|
||||
);
|
||||
assertNear(
|
||||
segment.signedAdcEndHz + segment.aliasOrder * wide.sampleRate,
|
||||
segment.analogMaxHz,
|
||||
1e-12,
|
||||
`zone ${segment.nyquistZone} end invariant`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseband = observeFrequencyBand({ minHz: 2_900, maxHz: 3_100 }, 8_000, 256);
|
||||
const equivalentAlias = observeFrequencyBand(
|
||||
{ minHz: 4_900, maxHz: 5_100 },
|
||||
8_000,
|
||||
256,
|
||||
);
|
||||
assertEqual(
|
||||
equivalentAlias.adcBinSetKey,
|
||||
baseband.adcBinSetKey,
|
||||
"alias-equivalent requested bands disclose the same ADC bin set",
|
||||
);
|
||||
|
||||
const narrowFold = observeFrequencyBand(
|
||||
{ minHz: 3_999, maxHz: 4_001 },
|
||||
8_000,
|
||||
256,
|
||||
);
|
||||
assert(
|
||||
narrowFold.continuousManyToOne,
|
||||
"a band straddling Nyquist is continuously many-to-one even on a fine FFT grid",
|
||||
);
|
||||
assert(!narrowFold.gridManyToOne, "the single selected bin is not grid many-to-one");
|
||||
assertEqual(narrowFold.bins.join(","), "128", "only the Nyquist bin center is selected");
|
||||
|
||||
let segmentLimitError;
|
||||
try {
|
||||
observeFrequencyBand({ minHz: 0, maxHz: 1_000_000 }, 1, 256);
|
||||
} catch (error) {
|
||||
segmentLimitError = error;
|
||||
}
|
||||
assert(
|
||||
segmentLimitError?.message.includes("explicit limit"),
|
||||
"portable helper rejects excessive fold-zone enumeration before looping",
|
||||
);
|
||||
});
|
||||
|
||||
test("real ADC samples and FFT expose temporal aliases without carrier recovery", () => {
|
||||
const sampleRate = 8_000;
|
||||
const fftSize = 256;
|
||||
const phaseDeg = 23;
|
||||
const scene = { sampleRate, duration: fftSize / sampleRate, seed: 1 };
|
||||
const entry = {
|
||||
id: "temporal-alias",
|
||||
startTime: 0,
|
||||
duration: scene.duration,
|
||||
gainDb: 0,
|
||||
signal: { type: "tone", frequency: 5_000, phaseDeg },
|
||||
};
|
||||
const first = renderSignal(entry, scene);
|
||||
const second = renderSignal(
|
||||
{ ...entry, signal: { ...entry.signal, frequency: 13_000 } },
|
||||
scene,
|
||||
);
|
||||
const phase = (phaseDeg * Math.PI) / 180;
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
const expected = Math.sin(
|
||||
(2 * Math.PI * -3_000 * index) / sampleRate + phase,
|
||||
);
|
||||
assertNear(first[index], expected, 2e-6, `5 kHz alias sample ${index}`);
|
||||
assertNear(second[index], expected, 4e-6, `13 kHz alias sample ${index}`);
|
||||
}
|
||||
const spectrum = fftReal(first);
|
||||
let peakBin = 0;
|
||||
let peakPower = -Infinity;
|
||||
for (let bin = 0; bin <= fftSize / 2; bin += 1) {
|
||||
const power = spectrum.real[bin] ** 2 + spectrum.imaginary[bin] ** 2;
|
||||
if (power > peakPower) {
|
||||
peakPower = power;
|
||||
peakBin = bin;
|
||||
}
|
||||
}
|
||||
assertEqual(peakBin, 96, "5 kHz analog tone appears at the 3 kHz ADC bin");
|
||||
});
|
||||
|
||||
test("undersampled microphone spacing preserves a physical grating-lobe ambiguity", async () => {
|
||||
const sampleRate = 8_000;
|
||||
const fftSize = 256;
|
||||
const frequencyHz = 1_000;
|
||||
const bin = 32;
|
||||
const speedOfSound = 343;
|
||||
const wavelength = speedOfSound / frequencyHz;
|
||||
const microphoneSpacing = 0.5;
|
||||
assert(
|
||||
microphoneSpacing > wavelength / 2,
|
||||
"fixture is spatially undersampled",
|
||||
);
|
||||
const arrayDefinition = {
|
||||
microphones: [
|
||||
{ id: "left", position: [-microphoneSpacing / 2, 0, 0] },
|
||||
{ id: "right", position: [microphoneSpacing / 2, 0, 0] },
|
||||
],
|
||||
};
|
||||
const targetX = -1;
|
||||
const z = 0.5;
|
||||
const pathDifference = (x) =>
|
||||
distance3([x, 0, z], arrayDefinition.microphones[1].position) -
|
||||
distance3([x, 0, z], arrayDefinition.microphones[0].position);
|
||||
const desiredDifference = pathDifference(targetX) - wavelength;
|
||||
let lowerX = targetX;
|
||||
let upperX = 0;
|
||||
for (let iteration = 0; iteration < 80; iteration += 1) {
|
||||
const middleX = (lowerX + upperX) / 2;
|
||||
if (pathDifference(middleX) > desiredDifference) lowerX = middleX;
|
||||
else upperX = middleX;
|
||||
}
|
||||
const ambiguousX = (lowerX + upperX) / 2;
|
||||
assertNear(
|
||||
pathDifference(targetX) - pathDifference(ambiguousX),
|
||||
wavelength,
|
||||
1e-12,
|
||||
"candidate path-difference offset is one wavelength",
|
||||
);
|
||||
|
||||
const angularWaveNumber =
|
||||
(2 * Math.PI * frequencyHz) / speedOfSound;
|
||||
const spectrum = {
|
||||
real: new Float32Array(2),
|
||||
imaginary: new Float32Array(2),
|
||||
};
|
||||
arrayDefinition.microphones.forEach((microphone, index) => {
|
||||
const phase = -angularWaveNumber * distance3([targetX, 0, z], microphone.position);
|
||||
spectrum.real[index] = Math.cos(phase);
|
||||
spectrum.imaginary[index] = Math.sin(phase);
|
||||
});
|
||||
const scan = {
|
||||
xMin: targetX,
|
||||
xMax: ambiguousX,
|
||||
yMin: 0,
|
||||
yMax: 0,
|
||||
z,
|
||||
columns: 2,
|
||||
rows: 1,
|
||||
};
|
||||
const map = await computeBinMap({
|
||||
bin,
|
||||
spectrum,
|
||||
frameCount: 1,
|
||||
arrayDefinition,
|
||||
pointCount: 2,
|
||||
distances: createDistanceMatrix(arrayDefinition, scan),
|
||||
sampleRate,
|
||||
fftSize,
|
||||
speedOfSound,
|
||||
compensateFrequencyResponse: false,
|
||||
algorithm: "das",
|
||||
});
|
||||
assertNear(map[0], map[1], 2e-6, "true point and grating lobe have equal DAS power");
|
||||
assert(map[0] > 0.999, "both ambiguous candidates remain coherent");
|
||||
});
|
||||
|
||||
test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => {
|
||||
const scene = { speedOfSound: 400, seed: 11 };
|
||||
const paths = buildAcousticPaths({
|
||||
@@ -474,6 +695,179 @@ test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () =>
|
||||
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
|
||||
});
|
||||
|
||||
test("above-Nyquist tone keeps analog propagation phase before ADC aliasing", () => {
|
||||
const sampleRate = 8_000;
|
||||
const analogFrequencyHz = 5_000;
|
||||
const speedOfSound = 343;
|
||||
const differentialDelay = 0.00005;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 0.01,
|
||||
speedOfSound,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: "aliased-tone",
|
||||
name: "aliased tone",
|
||||
position: [0, 0, 0],
|
||||
gainDb: 0,
|
||||
startTime: 0,
|
||||
duration: 0.01,
|
||||
signal: { type: "tone", frequency: analogFrequencyHz, phaseDeg: 0 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [
|
||||
{ id: "reference", position: [0, 0, 0] },
|
||||
{ id: "delayed", position: [differentialDelay * speedOfSound, 0, 0] },
|
||||
],
|
||||
};
|
||||
const synthesis = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
const sampleIndex = 20;
|
||||
const sampleTime = sampleIndex / sampleRate;
|
||||
assertNear(
|
||||
synthesis.channels[0][sampleIndex],
|
||||
Math.sin(2 * Math.PI * analogFrequencyHz * sampleTime),
|
||||
1e-6,
|
||||
"reference temporal alias sample",
|
||||
);
|
||||
assertNear(
|
||||
synthesis.channels[1][sampleIndex],
|
||||
Math.sin(2 * Math.PI * analogFrequencyHz * (sampleTime - differentialDelay)),
|
||||
1e-6,
|
||||
"delayed sensor uses original analog carrier phase",
|
||||
);
|
||||
const wrongAliasPhase = Math.sin(
|
||||
2 * Math.PI * 3_000 * (sampleTime - differentialDelay),
|
||||
);
|
||||
assert(
|
||||
Math.abs(synthesis.channels[1][sampleIndex] - wrongAliasPhase) > 0.1,
|
||||
"fractional spatial delay is not rewritten using the positive alias carrier",
|
||||
);
|
||||
assertEqual(
|
||||
synthesis.sampling.toneObservations[0].adcFrequencyHz,
|
||||
3_000,
|
||||
"synthesis exposes the recorded ADC alias",
|
||||
);
|
||||
});
|
||||
|
||||
test("above-Nyquist tone applies microphone response at the analog carrier", () => {
|
||||
const scene = {
|
||||
sampleRate: 8_000,
|
||||
duration: 0.032,
|
||||
speedOfSound: 343,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: "response-tone",
|
||||
name: "response tone",
|
||||
position: [0, 0, 0],
|
||||
gainDb: -20,
|
||||
startTime: 0,
|
||||
duration: 0.032,
|
||||
signal: { type: "tone", frequency: 5_000, phaseDeg: 20 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const baselineArray = {
|
||||
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
||||
};
|
||||
const responseArray = {
|
||||
microphones: [{
|
||||
id: "mic",
|
||||
position: [0, 0, 0],
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 3_000, gainDb: -6.020599913, phaseDeg: -30 },
|
||||
{ frequencyHz: 5_000, gainDb: 6.020599913, phaseDeg: 45 },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const baseline = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition: baselineArray,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
}).channels[0];
|
||||
const filtered = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition: responseArray,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
}).channels[0];
|
||||
assertNear(
|
||||
rms(filtered, 16) / rms(baseline, 16),
|
||||
2,
|
||||
2e-5,
|
||||
"5 kHz response gain is used instead of the 3 kHz ADC alias response",
|
||||
);
|
||||
});
|
||||
|
||||
test("analytic sampler preserves exact aliased DC and Nyquist phase degeneracy", () => {
|
||||
const renderTone = (frequency, phaseDeg) => {
|
||||
const scene = {
|
||||
sampleRate: 8_000,
|
||||
duration: 8 / 8_000,
|
||||
speedOfSound: 343,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: `edge-${frequency}-${phaseDeg}`,
|
||||
name: "edge tone",
|
||||
position: [0, 0, 0],
|
||||
gainDb: 0,
|
||||
startTime: 0,
|
||||
duration: 8 / 8_000,
|
||||
signal: { type: "tone", frequency, phaseDeg },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
return synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition: {
|
||||
microphones: [{ id: "reference", position: [0, 0, 0] }],
|
||||
},
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const vanishedDc = renderTone(8_000, 0);
|
||||
for (const [sampleIndex, value] of vanishedDc.channels[0].entries()) {
|
||||
assertNear(value, 0, 1e-10, `aliased DC phase-zero sample ${sampleIndex}`);
|
||||
}
|
||||
assert(
|
||||
vanishedDc.sampling.toneObservations[0].dcEdge &&
|
||||
vanishedDc.sampling.toneObservations[0].phaseDegenerate,
|
||||
"sample-rate carrier is explicitly a phase-degenerate DC alias",
|
||||
);
|
||||
|
||||
const constantDc = renderTone(8_000, 90);
|
||||
for (const [sampleIndex, value] of constantDc.channels[0].entries()) {
|
||||
assertNear(value, 1, 1e-6, `aliased DC quadrature sample ${sampleIndex}`);
|
||||
}
|
||||
|
||||
const nyquist = renderTone(12_000, 90);
|
||||
for (const [sampleIndex, value] of nyquist.channels[0].entries()) {
|
||||
assertNear(
|
||||
value,
|
||||
sampleIndex % 2 === 0 ? 1 : -1,
|
||||
1e-6,
|
||||
`aliased Nyquist alternating sample ${sampleIndex}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
nyquist.sampling.toneObservations[0].nyquistEdge &&
|
||||
nyquist.sampling.toneObservations[0].phaseDegenerate,
|
||||
"odd Nyquist-zone edge remains explicitly phase-degenerate",
|
||||
);
|
||||
});
|
||||
|
||||
test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => {
|
||||
const scene = {
|
||||
sampleRate: 8000,
|
||||
@@ -937,3 +1331,184 @@ test("absolute scene windows preserve tone, noise, asset, ambient, and synthesis
|
||||
assertNear(value, wholeSynthesis.channels[0][window.startSample + sampleIndex], 2e-5, `mic sample ${sampleIndex}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("quasi-static pre-roll preserves ended noise and asset arrivals before output crop", () => {
|
||||
const sampleRate = 1_000;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 1,
|
||||
speedOfSound: 100,
|
||||
seed: 73,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [
|
||||
{
|
||||
id: "ended-noise",
|
||||
position: [20, 0, 0],
|
||||
gainDb: -12,
|
||||
startTime: 0.1,
|
||||
duration: 0.2,
|
||||
signal: { type: "noise", color: "white", seedOffset: 11 },
|
||||
},
|
||||
{
|
||||
id: "ended-asset",
|
||||
position: [20, 0, 0],
|
||||
gainDb: -9,
|
||||
startTime: 0.1,
|
||||
duration: 0.2,
|
||||
signal: { type: "asset", assetId: "tail-clip", channel: 0, loop: false },
|
||||
},
|
||||
],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
||||
};
|
||||
const assetBuffers = new Map([["tail-clip", {
|
||||
sampleRate,
|
||||
channels: [Float32Array.from(
|
||||
{ length: 300 },
|
||||
(_, index) => 0.45 * Math.sin((2 * Math.PI * index) / 29) + (index % 17) / 80,
|
||||
)],
|
||||
}]]);
|
||||
const whole = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
const window = sampleQuasiStaticWindow(scene, 0.4, 0.5, {
|
||||
arrayDefinition,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertEqual(window.preRollSamples, 200, "direct-path pre-roll");
|
||||
assertEqual(
|
||||
window.scene.sources.map(({ id }) => id).join(","),
|
||||
"ended-noise,ended-asset",
|
||||
"sources that stopped before the visible window remain in the render interval",
|
||||
);
|
||||
const partial = synthesizeMicrophoneSignals({
|
||||
scene: window.scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
signalOffsets: window.signalOffsets,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertEqual(partial.channels[0].length, 100, "only the requested output window is returned");
|
||||
assertEqual(partial.windowing.preRollSamples, 200, "synthesis records applied pre-roll");
|
||||
assertEqual(
|
||||
partial.windowing.delayedTailPolicy,
|
||||
"propagation-preroll-then-output-crop",
|
||||
"tail policy metadata",
|
||||
);
|
||||
assertEqual(
|
||||
partial.windowing.arrivingSourceIds.join(","),
|
||||
"ended-noise,ended-asset",
|
||||
"arrival-overlap metadata includes both sampled sources",
|
||||
);
|
||||
for (const arrival of partial.windowing.sourceArrivals) {
|
||||
assert(arrival.emissionEndSample < partial.windowing.outputStartSample, `${arrival.sourceId} ended before output`);
|
||||
assert(arrival.overlapsOutput, `${arrival.sourceId} has a path arrival inside output`);
|
||||
assertEqual(arrival.earliestArrivalSample, 300, `${arrival.sourceId} earliest direct arrival`);
|
||||
assertEqual(arrival.latestArrivalEndSample, 500, `${arrival.sourceId} direct tail end`);
|
||||
}
|
||||
assert(rms(partial.channels[0]) > 1e-4, "the delayed sampled tail reaches the output window");
|
||||
partial.channels[0].forEach((value, sampleIndex) => {
|
||||
assertNear(
|
||||
value,
|
||||
whole.channels[0][window.startSample + sampleIndex],
|
||||
3e-5,
|
||||
`sampled tail ${sampleIndex}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("quasi-static pre-roll preserves reflection and diffuse tone tails", () => {
|
||||
const sampleRate = 2_000;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 1,
|
||||
speedOfSound: 100,
|
||||
seed: 91,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
acoustics: {
|
||||
model: "shoebox",
|
||||
room: { min: [0, 0, 0], max: [10, 10, 10] },
|
||||
reflections: { order: 1, wallGainDb: -5, scattering: 0.3 },
|
||||
diffuseReverb: { enabled: true, rt60Seconds: 0.4, gainDb: -8, tapCount: 4 },
|
||||
occluders: [],
|
||||
},
|
||||
sources: [{
|
||||
id: "ended-tone",
|
||||
position: [2, 2, 2],
|
||||
gainDb: -12,
|
||||
startTime: 0.05,
|
||||
duration: 0.1,
|
||||
signal: { type: "tone", frequency: 137, phaseDeg: 31 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [{ id: "mic", position: [4, 2, 2] }],
|
||||
};
|
||||
const synthesisOptions = {
|
||||
arrayDefinition,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
};
|
||||
const whole = synthesizeMicrophoneSignals({ scene, ...synthesisOptions });
|
||||
assert(whole.acousticPathCounts.reflection > 0, "fixture contains image-source reflections");
|
||||
assert(whole.acousticPathCounts.diffuse > 0, "fixture contains diffuse tail taps");
|
||||
|
||||
for (const [startTime, endTime, label] of [
|
||||
[0.22, 0.27, "reflection interval"],
|
||||
[0.42, 0.48, "late diffuse interval"],
|
||||
]) {
|
||||
const window = sampleQuasiStaticWindow(scene, startTime, endTime, synthesisOptions);
|
||||
assertEqual(window.scene.sources[0].id, "ended-tone", `${label} retains ended source`);
|
||||
const partial = synthesizeMicrophoneSignals({
|
||||
scene: window.scene,
|
||||
signalOffsets: window.signalOffsets,
|
||||
...synthesisOptions,
|
||||
});
|
||||
assertEqual(
|
||||
partial.windowing.geometryModel,
|
||||
"source-position-fixed-at-window-midpoint",
|
||||
`${label} geometry metadata`,
|
||||
);
|
||||
assertEqual(partial.windowing.dopplerMode, "not-modeled", `${label} Doppler metadata`);
|
||||
assertEqual(
|
||||
partial.windowing.arrivingSourceIds.join(","),
|
||||
"ended-tone",
|
||||
`${label} arrival truth source`,
|
||||
);
|
||||
assert(
|
||||
partial.windowing.sourceArrivals[0].emissionEndSample <
|
||||
partial.windowing.outputStartSample,
|
||||
`${label} source emission ended before output`,
|
||||
);
|
||||
if (label === "late diffuse interval") {
|
||||
assertEqual(
|
||||
partial.windowing.sourceArrivals[0].overlappingPathKinds.join(","),
|
||||
"diffuse",
|
||||
"late interval truth is justified by a diffuse arrival",
|
||||
);
|
||||
} else {
|
||||
assert(
|
||||
partial.windowing.sourceArrivals[0].overlappingPathKinds.includes("reflection"),
|
||||
"reflection interval truth includes an image-source arrival",
|
||||
);
|
||||
}
|
||||
assert(rms(partial.channels[0]) > 1e-6, `${label} contains propagated energy`);
|
||||
partial.channels[0].forEach((value, sampleIndex) => {
|
||||
assertNear(
|
||||
value,
|
||||
whole.channels[0][window.startSample + sampleIndex],
|
||||
2e-6,
|
||||
`${label} sample ${sampleIndex}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
enableSourceTrajectory,
|
||||
createSceneId,
|
||||
createSource,
|
||||
describeSignal,
|
||||
insertTrajectoryKeyframe,
|
||||
matchAssetDescriptor,
|
||||
prepareSceneForEditing,
|
||||
@@ -49,6 +50,29 @@ test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든
|
||||
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
|
||||
});
|
||||
|
||||
test("Builder tone summary exposes the ideal ADC alias above Nyquist", () => {
|
||||
assertEqual(
|
||||
describeSignal({ type: "tone", frequency: 5_000 }, 8_000),
|
||||
"5000 Hz tone → ADC 3000 Hz alias",
|
||||
"above-Nyquist source summary",
|
||||
);
|
||||
assertEqual(
|
||||
describeSignal({ type: "tone", frequency: 1_000 }, 8_000),
|
||||
"1000 Hz tone",
|
||||
"baseband source summary",
|
||||
);
|
||||
assertEqual(
|
||||
describeSignal({ type: "tone", frequency: 8_000 }, 8_000),
|
||||
"8000 Hz tone → ADC 0 Hz alias · DC phase-degenerate edge",
|
||||
"aliased exact-DC source summary",
|
||||
);
|
||||
assertEqual(
|
||||
describeSignal({ type: "tone", frequency: 12_000 }, 8_000),
|
||||
"12000 Hz tone → ADC 4000 Hz alias · Nyquist phase-degenerate edge",
|
||||
"aliased exact-Nyquist source summary",
|
||||
);
|
||||
});
|
||||
|
||||
test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
|
||||
const source = { position: [0.5, 0.1, 2] };
|
||||
const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
|
||||
|
||||
+194
-2
@@ -22,6 +22,7 @@ import {
|
||||
shiftSceneDistance,
|
||||
} from "/apps/acoustic-camera/model.js";
|
||||
import { validateResult } from "/packages/contracts/validation.js";
|
||||
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
@@ -56,6 +57,56 @@ test("Camera detects moving source trajectories", () => {
|
||||
}), true, "moving scene");
|
||||
});
|
||||
|
||||
test("Camera live truth uses synthesized path arrivals instead of emission midpoint", () => {
|
||||
const scene = {
|
||||
sampleRate: 8_000,
|
||||
duration: 1,
|
||||
sources: [
|
||||
{
|
||||
id: "ended-tone-tail",
|
||||
startTime: 0,
|
||||
duration: 0.1,
|
||||
signal: { type: "tone", frequency: 1_000 },
|
||||
},
|
||||
{
|
||||
id: "ended-noise-tail",
|
||||
startTime: 0,
|
||||
duration: 0.1,
|
||||
signal: { type: "noise", color: "white" },
|
||||
},
|
||||
{
|
||||
id: "wrong-band-tone",
|
||||
startTime: 0,
|
||||
duration: 0.1,
|
||||
signal: { type: "tone", frequency: 2_000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const band = { minHz: 900, maxHz: 1_100 };
|
||||
const fallback = sourcesForObservedBand(scene, band, 256, { time: 0.5 });
|
||||
assertEqual(fallback.length, 0, "legacy emission-time fallback sees no active source");
|
||||
assertEqual(
|
||||
sourcesForObservedBand(scene, band, 256, { time: 0.1 }).length,
|
||||
0,
|
||||
"emission-time fallback treats the exact end boundary as half-open",
|
||||
);
|
||||
|
||||
const arrived = sourcesForObservedBand(scene, band, 256, {
|
||||
time: 0.5,
|
||||
arrivingSourceIds: ["ended-tone-tail", "ended-noise-tail", "wrong-band-tone"],
|
||||
});
|
||||
assertEqual(
|
||||
arrived.map(({ id }) => id).join(","),
|
||||
"ended-tone-tail,ended-noise-tail",
|
||||
"path-arrival ids override time while band selection remains independent",
|
||||
);
|
||||
assertEqual(
|
||||
sourcesForObservedBand(scene, band, 256, { arrivingSourceIds: [] }).length,
|
||||
0,
|
||||
"an explicit empty arrival set does not fall back to emission activity",
|
||||
);
|
||||
});
|
||||
|
||||
test("Camera analysis plan covers a scene and respects its frame budget", () => {
|
||||
const scene = { duration: 2, sampleRate: 1000, sources: [] };
|
||||
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
|
||||
@@ -460,7 +511,12 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
};
|
||||
const scene = {
|
||||
id: "test-scene",
|
||||
sources: [{ id: "source-1", position: [0, 0, 2] }],
|
||||
sampleRate: 8_000,
|
||||
sources: [{
|
||||
id: "source-1",
|
||||
position: [0, 0, 2],
|
||||
signal: { type: "tone", frequency: 5_000, phaseDeg: 0 },
|
||||
}],
|
||||
};
|
||||
const experiment = {
|
||||
id: "test-experiment",
|
||||
@@ -468,7 +524,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
sceneId: scene.id,
|
||||
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
||||
beamforming: { algorithm: "das-phat", backend: "auto" },
|
||||
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
|
||||
stft: { fftSize: 256, hopSize: 128, window: "hann" },
|
||||
frequencyBands: [{
|
||||
id: "band-1",
|
||||
minHz: 4_900,
|
||||
maxHz: 5_100,
|
||||
color: "#65f2bf",
|
||||
}, {
|
||||
id: "band-2",
|
||||
minHz: 2_900,
|
||||
maxHz: 3_100,
|
||||
color: "#58c7ff",
|
||||
}],
|
||||
};
|
||||
const run = {
|
||||
algorithm: "das-phat",
|
||||
@@ -481,6 +548,23 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
acousticModel: "shoebox",
|
||||
acousticPathCount: 38,
|
||||
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
|
||||
sampling: {
|
||||
model: "ideal-real-sampler-no-antialias-filter",
|
||||
sampleRate: 8_000,
|
||||
nyquistHz: 4_000,
|
||||
toneObservations: [{
|
||||
scope: "source",
|
||||
id: "source-1",
|
||||
analogFrequencyHz: 5_000,
|
||||
adcFrequencyHz: 3_000,
|
||||
signedAdcFrequencyHz: -3_000,
|
||||
aliased: true,
|
||||
spectralInversion: true,
|
||||
dcEdge: false,
|
||||
nyquistEdge: false,
|
||||
phaseDegenerate: false,
|
||||
}],
|
||||
},
|
||||
},
|
||||
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
|
||||
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
||||
@@ -491,6 +575,13 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||
displayScale: "normalized",
|
||||
}, {
|
||||
id: "band-2",
|
||||
color: "#58c7ff",
|
||||
rawScale: "score",
|
||||
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||
displayScale: "normalized",
|
||||
}],
|
||||
};
|
||||
const evaluations = new Map([["band-1", {
|
||||
@@ -539,6 +630,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
maximumDetections: 128,
|
||||
},
|
||||
}]]);
|
||||
evaluations.set("band-2", evaluations.get("band-1"));
|
||||
|
||||
const result = createResultDocument({
|
||||
arrayDefinition,
|
||||
@@ -550,6 +642,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
createdAt: "2026-08-28T00:00:00.000Z",
|
||||
});
|
||||
|
||||
assertEqual(result.schemaVersion, "1.1.0", "sampling-aware Result contract version");
|
||||
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
|
||||
assertEqual(result.algorithm, "das-phat", "actual algorithm is exported");
|
||||
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
|
||||
@@ -570,6 +663,41 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported");
|
||||
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
|
||||
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
|
||||
assertEqual(
|
||||
result.signalStats.sampling.toneObservations[0].adcFrequencyHz,
|
||||
3_000,
|
||||
"ADC alias is exported",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].frequencyObservation.adcBins.join(","),
|
||||
"93,94,95,96,97,98,99",
|
||||
"requested analog band exports every folded ADC bin",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].frequencyObservation.equivalentBandIds.join(","),
|
||||
"band-2",
|
||||
"alias-equivalent band is disclosed",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[1].frequencyObservation.equivalentBandIds.join(","),
|
||||
"band-1",
|
||||
"equivalence is symmetric",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].frequencyObservation.localizationModel,
|
||||
"spherical-steering-at-positive-adc-bin-frequency",
|
||||
"actual DAS-family localization model is explicit",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].frequencyObservation.originalCarrierUsed,
|
||||
false,
|
||||
"result does not imply hidden original-carrier steering",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].evaluation.truthSelection,
|
||||
"tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources",
|
||||
"truth-selection policy is explicit",
|
||||
);
|
||||
|
||||
const history = createHistorySummary({
|
||||
arrayDefinition,
|
||||
@@ -584,6 +712,70 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
|
||||
const issues = await validateResult(result, { experiment, scene });
|
||||
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
||||
assertEqual(
|
||||
(await validateResult(result)).length,
|
||||
0,
|
||||
"generated Result is self-consistent without external context",
|
||||
);
|
||||
|
||||
const standaloneCorruption = structuredClone(result);
|
||||
standaloneCorruption.signalStats.sampling.nyquistHz += 1;
|
||||
standaloneCorruption.signalStats.sampling.toneObservations.push(
|
||||
structuredClone(standaloneCorruption.signalStats.sampling.toneObservations[0]),
|
||||
);
|
||||
standaloneCorruption.bands[0].frequencyObservation.adcBins.push(
|
||||
standaloneCorruption.bands[0].frequencyObservation.adcBins[0],
|
||||
);
|
||||
standaloneCorruption.bands[0].frequencyObservation.equivalentBandIds.push("band-2");
|
||||
standaloneCorruption.bands[0].frequencyObservation.localizationModel =
|
||||
"band-limited-gcc-phat-geometric-lag";
|
||||
const standaloneIssues = await validateResult(standaloneCorruption);
|
||||
assert(
|
||||
standaloneIssues.some(({ path }) => path === "$.signalStats.sampling.nyquistHz"),
|
||||
"standalone validator checks Nyquist self-consistency",
|
||||
);
|
||||
assert(
|
||||
standaloneIssues.some(({ message }) => message.includes("duplicate tone observation")),
|
||||
"standalone validator rejects duplicate tone provenance",
|
||||
);
|
||||
assert(
|
||||
standaloneIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
|
||||
"standalone validator recomputes unique ADC bins",
|
||||
);
|
||||
assert(
|
||||
standaloneIssues.some(({ path }) =>
|
||||
path.endsWith("frequencyObservation.equivalentBandIds")),
|
||||
"standalone validator recomputes equivalent band IDs",
|
||||
);
|
||||
assert(
|
||||
standaloneIssues.some(({ path }) =>
|
||||
path.endsWith("frequencyObservation.localizationModel")),
|
||||
"standalone validator links localization model to actual algorithm",
|
||||
);
|
||||
|
||||
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz += 1;
|
||||
let semanticIssues = await validateResult(result, { experiment, scene });
|
||||
assert(
|
||||
semanticIssues.some(({ path }) => path.endsWith("signedAdcFrequencyHz")),
|
||||
"validator recomputes the temporal alias",
|
||||
);
|
||||
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz -= 1;
|
||||
|
||||
result.bands[0].frequencyObservation.adcBins[0] += 1;
|
||||
semanticIssues = await validateResult(result, { experiment, scene });
|
||||
assert(
|
||||
semanticIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
|
||||
"validator recomputes folded ADC bins",
|
||||
);
|
||||
result.bands[0].frequencyObservation.adcBins[0] -= 1;
|
||||
|
||||
const missingBandResult = structuredClone(result);
|
||||
missingBandResult.bands.pop();
|
||||
semanticIssues = await validateResult(missingBandResult, { experiment, scene });
|
||||
assert(
|
||||
semanticIssues.some(({ message }) => message.includes("missing Experiment band result")),
|
||||
"validator rejects incomplete Result band sets",
|
||||
);
|
||||
|
||||
const wrongExperiment = { ...experiment, arrayId: "another-array" };
|
||||
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
validateResult,
|
||||
validateScene,
|
||||
} from "/packages/contracts/validation.js";
|
||||
import {
|
||||
describeFrequencyAliasing,
|
||||
observeFrequencyBand,
|
||||
} from "/packages/acoustics-core/sampling.js";
|
||||
|
||||
async function loadJson(path) {
|
||||
const response = await fetch(path);
|
||||
@@ -11,6 +15,75 @@ async function loadJson(path) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function resultFrequencyObservation(scene, experiment, band, equivalentBandIds = []) {
|
||||
const observation = observeFrequencyBand(
|
||||
band,
|
||||
scene.sampleRate,
|
||||
experiment.stft.fftSize,
|
||||
);
|
||||
return {
|
||||
model: observation.model,
|
||||
sampleRate: observation.sampleRate,
|
||||
nyquistHz: observation.nyquistHz,
|
||||
fftSize: observation.fftSize,
|
||||
binWidthHz: observation.binWidthHz,
|
||||
requestedMinHz: observation.requestedMinHz,
|
||||
requestedMaxHz: observation.requestedMaxHz,
|
||||
aliased: observation.aliased,
|
||||
crossesFirstNyquist: observation.crossesFirstNyquist,
|
||||
localizationInputModel: observation.localizationInputModel,
|
||||
selectionModel: observation.selectionModel,
|
||||
localizationModel:
|
||||
experiment.beamforming?.algorithm === "gcc-phat"
|
||||
? "band-limited-gcc-phat-geometric-lag"
|
||||
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||
originalCarrierUsed: observation.originalCarrierUsed,
|
||||
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
|
||||
foldBoundaryCount: observation.foldBoundaryCount,
|
||||
continuousManyToOne: observation.continuousManyToOne,
|
||||
gridManyToOne: observation.gridManyToOne,
|
||||
foldSegments: observation.foldSegments.map((segment) => ({ ...segment })),
|
||||
adcBins: [...observation.bins],
|
||||
adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })),
|
||||
adcBinSetKey: observation.adcBinSetKey,
|
||||
equivalentBandIds,
|
||||
};
|
||||
}
|
||||
|
||||
function resultSampling(scene) {
|
||||
const toneObservations = [];
|
||||
for (const [scope, entries] of [
|
||||
["source", scene.sources ?? []],
|
||||
["ambient", scene.ambient ?? []],
|
||||
]) {
|
||||
for (const entry of entries) {
|
||||
if (entry.signal?.type !== "tone") continue;
|
||||
const observation = describeFrequencyAliasing(
|
||||
entry.signal.frequency,
|
||||
scene.sampleRate,
|
||||
);
|
||||
toneObservations.push({
|
||||
scope,
|
||||
id: entry.id,
|
||||
analogFrequencyHz: observation.analogFrequencyHz,
|
||||
adcFrequencyHz: observation.adcFrequencyHz,
|
||||
signedAdcFrequencyHz: observation.signedAdcFrequencyHz,
|
||||
aliased: observation.aliased,
|
||||
spectralInversion: observation.spectralInversion,
|
||||
dcEdge: observation.dcEdge,
|
||||
nyquistEdge: observation.nyquistEdge,
|
||||
phaseDegenerate: observation.phaseDegenerate,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
model: "ideal-real-sampler-no-antialias-filter",
|
||||
sampleRate: scene.sampleRate,
|
||||
nyquistHz: scene.sampleRate / 2,
|
||||
toneObservations,
|
||||
};
|
||||
}
|
||||
|
||||
const RECTANGULAR_ARRAY_EXAMPLES = [
|
||||
{ id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 },
|
||||
{ id: "rectangular-4x3-450mm", microphoneCount: 12, columns: 4, rows: 3, width: 0.45, height: 0.45 },
|
||||
@@ -329,7 +402,7 @@ test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름
|
||||
);
|
||||
});
|
||||
|
||||
test("Nyquist를 넘는 tone을 거부한다", async () => {
|
||||
test("Nyquist를 넘는 tone을 ideal ADC alias 실험으로 허용한다", async () => {
|
||||
const scene = {
|
||||
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
||||
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 },
|
||||
@@ -338,7 +411,74 @@ test("Nyquist를 넘는 tone을 거부한다", async () => {
|
||||
signal: { type: "tone", frequency: 5000 },
|
||||
}],
|
||||
};
|
||||
assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency")));
|
||||
assertEqual((await validateScene(scene)).length, 0, "above-Nyquist tone is structurally valid");
|
||||
|
||||
scene.sources[0].signal.frequency = 1_000_001;
|
||||
assert(
|
||||
(await validateScene(scene)).some(({ path }) =>
|
||||
path === "$.sources[0].signal.frequency"),
|
||||
"tone above the explicit numerical model range is rejected, not silently rounded",
|
||||
);
|
||||
});
|
||||
|
||||
test("Experiment band는 Nyquist 위에서도 허용하되 FFT bin center가 하나는 있어야 한다", async () => {
|
||||
const [scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
experiment.frequencyBands[0].minHz = 24_900;
|
||||
experiment.frequencyBands[0].maxHz = 25_100;
|
||||
assertEqual(
|
||||
(await validateExperiment(experiment, { scene })).length,
|
||||
0,
|
||||
"above-Nyquist band with folded bin centers is valid",
|
||||
);
|
||||
|
||||
experiment.frequencyBands[0].minHz = 1_000.1;
|
||||
experiment.frequencyBands[0].maxHz = 1_000.2;
|
||||
let issues = await validateExperiment(experiment, { scene });
|
||||
assert(
|
||||
issues.some(({ path, message }) =>
|
||||
path === "$.frequencyBands[0]" && message.includes("bin center")),
|
||||
"a sub-bin band fails validation before runtime",
|
||||
);
|
||||
|
||||
experiment.frequencyBands[0].minHz = 999_900;
|
||||
experiment.frequencyBands[0].maxHz = 1_000_001;
|
||||
issues = await validateExperiment(experiment, { scene });
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.frequencyBands[0].maxHz"),
|
||||
"band above the explicit numerical model range is rejected",
|
||||
);
|
||||
|
||||
experiment.frequencyBands = {};
|
||||
issues = await validateExperiment(experiment, { scene });
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.frequencyBands"),
|
||||
"malformed frequencyBands returns validation issues instead of throwing",
|
||||
);
|
||||
});
|
||||
|
||||
test("analytic tone-only Scene은 존재하지 않는 delay FFT 메모리로 거부하지 않는다", 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"),
|
||||
]);
|
||||
arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 1);
|
||||
scene.sampleRate = 192_000;
|
||||
scene.duration = 30;
|
||||
scene.sources = Array.from({ length: 32 }, (_, index) => ({
|
||||
...structuredClone(scene.sources[0]),
|
||||
id: `tone-${index + 1}`,
|
||||
name: `Tone ${index + 1}`,
|
||||
duration: 30,
|
||||
}));
|
||||
const issues = await validateExperiment(experiment, { arrayDefinition, scene });
|
||||
assert(
|
||||
!issues.some(({ message }) => message.includes("512 MiB")),
|
||||
`analytic tones must not allocate sampled source/FFT buffers: ${JSON.stringify(issues)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("NaN 좌표를 거부한다", async () => {
|
||||
@@ -395,7 +535,7 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
const result = {
|
||||
schemaVersion: "1.0.0",
|
||||
schemaVersion: "1.1.0",
|
||||
kind: "micarrsim.result",
|
||||
experimentId: experiment.id,
|
||||
arrayId: experiment.arrayId,
|
||||
@@ -404,11 +544,26 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
||||
backend: "cpu",
|
||||
algorithm: "das",
|
||||
inputHash: "a".repeat(64),
|
||||
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
|
||||
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
|
||||
signalStats: {
|
||||
preNormalizationPeak: 0.5,
|
||||
appliedScale: 1,
|
||||
clippedSampleCount: 0,
|
||||
sampling: resultSampling(scene),
|
||||
},
|
||||
timingMs: {
|
||||
total: 10,
|
||||
signalGeneration: 3,
|
||||
localization: 7,
|
||||
beamforming: 7,
|
||||
},
|
||||
bands: [{
|
||||
id: "tone-1k",
|
||||
color: "#65f2bf",
|
||||
frequencyObservation: resultFrequencyObservation(
|
||||
scene,
|
||||
experiment,
|
||||
experiment.frequencyBands[0],
|
||||
),
|
||||
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
||||
peaks: [{
|
||||
sourceId: "source-1",
|
||||
@@ -437,6 +592,66 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
||||
};
|
||||
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
|
||||
|
||||
const mismatchedTimingResult = structuredClone(result);
|
||||
mismatchedTimingResult.timingMs.localization = 8;
|
||||
const mismatchedTimingIssues = await validateResult(
|
||||
mismatchedTimingResult,
|
||||
{ experiment, scene },
|
||||
);
|
||||
assert(
|
||||
mismatchedTimingIssues.some(
|
||||
({ path }) => path === "$.timingMs.localization",
|
||||
),
|
||||
"localization timing and its compatibility alias must agree",
|
||||
);
|
||||
|
||||
const missingLocalizationTiming = structuredClone(result);
|
||||
delete missingLocalizationTiming.timingMs.localization;
|
||||
const missingLocalizationIssues = await validateResult(
|
||||
missingLocalizationTiming,
|
||||
{ experiment, scene },
|
||||
);
|
||||
assert(
|
||||
missingLocalizationIssues.some(
|
||||
({ path }) => path === "$.timingMs.localization",
|
||||
),
|
||||
"Result 1.1 requires generic localization timing",
|
||||
);
|
||||
|
||||
const legacyResult = structuredClone(result);
|
||||
legacyResult.schemaVersion = "1.0.0";
|
||||
const legacyIssues = await validateResult(legacyResult, {
|
||||
experiment,
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
legacyIssues.some(({ path }) => path === "$.schemaVersion"),
|
||||
"sampling-aware Result validation must reject legacy 1.0 implicitly",
|
||||
);
|
||||
|
||||
const impossibleBackendResult = structuredClone(result);
|
||||
impossibleBackendResult.algorithm = "gcc-phat";
|
||||
impossibleBackendResult.backend = "webgpu";
|
||||
const impossibleBackendIssues = await validateResult(
|
||||
impossibleBackendResult,
|
||||
);
|
||||
assert(
|
||||
impossibleBackendIssues.some(({ path }) => path === "$.backend"),
|
||||
"standalone Result rejects the unsupported GCC-PHAT WebGPU combination",
|
||||
);
|
||||
|
||||
const malformedResult = structuredClone(result);
|
||||
delete malformedResult.bands[0].id;
|
||||
malformedResult.bands[0].processedMaps = {};
|
||||
const malformedIssues = await validateResult(malformedResult, {
|
||||
experiment,
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
malformedIssues.length > 0,
|
||||
"malformed Result fields return validation issues instead of throwing",
|
||||
);
|
||||
|
||||
experiment.beamforming.algorithm = "das-phat";
|
||||
result.algorithm = "das-phat";
|
||||
result.bands[0].rawMap.scale = "score";
|
||||
|
||||
@@ -112,6 +112,30 @@ test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map
|
||||
);
|
||||
});
|
||||
|
||||
test("portable above-Nyquist band observes the folded ADC bin without hidden carrier steering", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const basebandPlan = createLocalizationPlan(fixture.config);
|
||||
const aliasedPlan = createLocalizationPlan({
|
||||
...fixture.config,
|
||||
bands: [{ id: "aliased", minHz: 3_572, maxHz: 3_596, color: "#58c7ff" }],
|
||||
});
|
||||
const baseband = await localizeMultichannel({
|
||||
plan: basebandPlan,
|
||||
channels: fixture.channels,
|
||||
});
|
||||
const aliased = await localizeMultichannel({
|
||||
plan: aliasedPlan,
|
||||
channels: fixture.channels,
|
||||
});
|
||||
|
||||
assertEqual(aliasedPlan.bands[0].minHz, 3_572, "requested analog band is retained");
|
||||
assertFloat32Exact(
|
||||
aliased.bands[0].rawMap,
|
||||
baseband.bands[0].rawMap,
|
||||
"same ADC bin produces the same conventional DAS map",
|
||||
);
|
||||
});
|
||||
|
||||
test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => {
|
||||
const scan = {
|
||||
z: 1,
|
||||
|
||||
Reference in New Issue
Block a user