fix: expose physical aliasing and delayed arrivals

This commit is contained in:
2026-08-29 10:21:33 +09:00
parent e048ccbadc
commit de0053eae3
38 changed files with 3086 additions and 214 deletions
+233 -22
View File
@@ -5,6 +5,11 @@ import {
localizationAlgorithmLabel,
localizationAlgorithmSupportsBackend,
} from "/packages/acoustics-core/localization-algorithms.js";
import {
describeFrequencyAliasing,
MAX_PRECISE_ANALOG_FREQUENCY_HZ,
observeFrequencyBand,
} from "/packages/acoustics-core/sampling.js";
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import {
@@ -14,6 +19,7 @@ import {
validateScene,
} from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.js";
import { sourcesForObservedBand } from "./source-selection.js";
import {
applyAcousticProfile,
detectAcousticProfile,
@@ -121,6 +127,7 @@ const elements = {
scanYMin: byId("scan-y-min"),
scanYMax: byId("scan-y-max"),
workloadSummary: byId("workload-summary"),
samplingWarning: byId("sampling-warning"),
fftSize: byId("fft-size"),
hopSize: byId("hop-size"),
normalization: byId("normalization"),
@@ -466,12 +473,64 @@ function renderAcousticSummary() {
document.documentElement.dataset.acousticProfile = profile;
}
function describeSignal(signal) {
if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`;
function describeSignal(signal, sampleRate) {
if (signal.type === "tone") {
const observation = describeFrequencyAliasing(signal.frequency, sampleRate);
const base = `${formatNumber(signal.frequency, 0)} Hz tone`;
const edge = observation.dcEdge
? " · DC phase-degenerate edge"
: observation.nyquistEdge
? " · Nyquist phase-degenerate edge"
: "";
if (observation.aliased) {
const direction = observation.spectralInversion ? "−" : "+";
return (
`${base} → ADC ${formatNumber(observation.adcFrequencyHz, 0)} Hz alias ` +
`(signed ${direction}${formatNumber(observation.adcFrequencyHz, 0)} Hz)${edge}`
);
}
return base + edge;
}
if (signal.type === "noise") return `${signal.color} noise`;
return `asset · ${signal.assetId}`;
}
function sceneToneObservations(scene) {
const entries = [
...(scene.sources ?? []).map((entry) => ({ scope: "source", entry })),
...(scene.ambient ?? []).map((entry) => ({ scope: "ambient", entry })),
];
const observations = [];
for (const { scope, entry } of entries) {
if (entry.signal?.type !== "tone") continue;
try {
observations.push({
scope,
id: entry.id,
...describeFrequencyAliasing(entry.signal.frequency, scene.sampleRate),
});
} catch {
// Validation owns malformed inputs; rendering must remain available while editing.
}
}
return observations;
}
function aliasedToneContributions(toneObservations, bandObservation) {
const selectedBins = new Set(bandObservation.bins);
return toneObservations.filter((tone) =>
tone.aliased &&
selectedBins.has(Math.round(tone.adcFrequencyHz / bandObservation.binWidthHz)));
}
function describeToneFold(tone) {
return (
`${tone.scope} ${tone.id} ` +
`${formatNumber(tone.analogFrequencyHz, 0)}→ADC ` +
`${formatNumber(tone.adcFrequencyHz, 0)} Hz`
);
}
function renderDocumentSummary() {
const { arrayDefinition, scene, experiment } = state;
if (!arrayDefinition || !scene || !experiment) return;
@@ -494,7 +553,7 @@ function renderDocumentSummary() {
name.title = source.name;
const detail = document.createElement("span");
detail.textContent =
`${describeSignal(source.signal)} · ` +
`${describeSignal(source.signal, scene.sampleRate)} · ` +
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` +
(source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : "");
detail.title = detail.textContent;
@@ -552,7 +611,9 @@ function renderBandEditors() {
if (type !== "color") input.placeholder = label;
if (type === "number") {
input.min = "0";
input.max = String(MAX_PRECISE_ANALOG_FREQUENCY_HZ);
input.step = "1";
input.title = `${label} · 0–1 MHz verified analog range`;
}
editor.append(input);
}
@@ -571,7 +632,11 @@ function renderBandEditors() {
function renderLegend() {
elements.bandLegend.replaceChildren();
if (!state.experiment) return;
elements.samplingWarning.hidden = true;
elements.samplingWarning.textContent = "";
if (!state.experiment || !state.scene) return;
const observations = [];
const toneObservations = sceneToneObservations(state.scene);
for (const band of state.experiment.frequencyBands) {
const label = document.createElement("label");
label.className = "legend-toggle";
@@ -583,10 +648,97 @@ function renderLegend() {
const swatch = document.createElement("span");
swatch.className = "legend-swatch";
const text = document.createElement("span");
text.textContent = `${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`;
const requested =
`${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`;
try {
const observation = observeFrequencyBand(
band,
state.scene.sampleRate,
state.experiment.stft.fftSize,
);
observations.push({ bandId: band.id, observation });
if (observation.aliased) {
const adcRanges = observation.adcBinCenterRanges
.map((range) =>
`${formatNumber(range.firstBinCenterHz, 0)}–` +
`${formatNumber(range.lastBinCenterHz, 0)} Hz`)
.join(", ");
text.textContent = `${requested} → ADC FFT bin centers ${adcRanges}`;
text.title =
"Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; " +
"the original alias order is not silently used for steering.";
} else {
text.textContent = requested;
}
} catch (error) {
text.textContent = requested;
text.title = error instanceof Error ? error.message : String(error);
}
label.append(input, swatch, text);
elements.bandLegend.append(label);
}
const toneContributions = observations.flatMap(({ bandId, observation }) =>
aliasedToneContributions(toneObservations, observation)
.map((tone) => ({ bandId, tone })));
const aliased = observations.filter(
({ observation }) =>
observation.aliased ||
observation.continuousManyToOne ||
observation.gridManyToOne,
);
const groups = new Map();
for (const { bandId, observation } of observations) {
const group = groups.get(observation.adcBinSetKey) ?? [];
group.push(bandId);
groups.set(observation.adcBinSetKey, group);
}
const equivalentGroups = [...groups.values()].filter((group) => group.length > 1);
if (aliased.length > 0 || equivalentGroups.length > 0 || toneContributions.length > 0) {
const details = [];
if (aliased.length > 0) {
const describeFold = ({ bandId, observation }) => {
const visibleSegments = observation.foldSegments
.slice(0, 4)
.map((segment) =>
`Z${segment.nyquistZone}/order ${segment.aliasOrder}` +
(segment.inverted ? "/inverted" : ""))
.join(", ");
const remainder = observation.foldSegments.length > 4
? `, +${observation.foldSegments.length - 4} zones`
: "";
return `${bandId} [${visibleSegments}${remainder}]`;
};
details.push(
`alias band: ${aliased.map(describeFold).join(" · ")}`,
);
}
if (equivalentGroups.length > 0) {
details.push(
"같은 ADC bin 집합(독립 관측 아님): " +
equivalentGroups.map((group) => group.join(" ↔ ")).join(" · "),
);
}
if (toneContributions.length > 0) {
details.push(
"선택 ADC bin으로 접힌 Scene tone: " +
toneContributions
.map(({ bandId, tone }) => `${bandId} ← ${describeToneFold(tone)}`)
.join(" · "),
);
}
const localizationDescription =
state.experiment.beamforming.algorithm === "gcc-phat"
? "관측된 one-sided ADC FFT bin만 사용해 band-limited GCC-PHAT 기하 lag를 계산하며 "
: "관측된 양의 ADC FFT bin 주파수로 구면 steering하며 ";
elements.samplingWarning.textContent =
"Ideal ADC alias · 위치 추정은 " + localizationDescription +
"원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 " +
"그대로 나타나며, 현재 알고리즘에서는 alias order가 미해결 상태로 남습니다. " +
"다채널 공간 위상에는 carrier-order 증거가 남을 수 있지만 이 localizer는 " +
"carrier hypothesis를 열거하지 않습니다. " +
details.join(" · ");
elements.samplingWarning.hidden = false;
}
}
function renderWorkload() {
@@ -773,6 +925,65 @@ function renderResult() {
`${(displayed.run.signalStats.acousticPathCount ?? 0).toLocaleString("ko-KR")} paths 합성`,
);
}
const aliasedTones = displayed.run.signalStats.sampling?.toneObservations
?.filter((observation) => observation.aliased) ?? [];
if (displayed.run.signalStats.sampling) {
appendMetric(
"ADC sampling",
aliasedTones.length > 0 ? `${aliasedTones.length} aliased tone` : "Nyquist 내",
aliasedTones.length > 0
? aliasedTones
.map((observation) =>
`${formatNumber(observation.analogFrequencyHz, 0)}→` +
`${formatNumber(observation.adcFrequencyHz, 0)} Hz`)
.join(" · ")
: "ideal real sampler · anti-alias filter 없음",
);
}
const resultObservations = displayed.snapshot.experiment.frequencyBands
.map((band) => {
try {
return {
band,
observation: observeFrequencyBand(
band,
displayed.snapshot.scene.sampleRate,
displayed.snapshot.experiment.stft.fftSize,
),
};
} catch {
return null;
}
})
.filter(Boolean);
const resultToneObservations = sceneToneObservations(displayed.snapshot.scene);
for (const { band, observation } of resultObservations) {
const toneContributions = aliasedToneContributions(
resultToneObservations,
observation,
);
if (
!observation.aliased &&
!observation.continuousManyToOne &&
!observation.gridManyToOne &&
toneContributions.length === 0
) continue;
const localizationDetail =
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
? "band-limited GCC-PHAT · 원 carrier 미사용"
: "양의 ADC bin steering · 원 carrier 미사용 · alias order 미해결";
const toneDetail = toneContributions.length > 0
? " · aliased Scene tone " + toneContributions.map(describeToneFold).join(" · ")
: "";
appendMetric(
`${band.id} · ADC steering`,
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
? "ADC LAG"
: "OBSERVED BIN",
localizationDetail + toneDetail,
band.color,
);
}
appendMetric(
"위치 추정 시간",
`${formatNumber(displayed.run.timingMs.localization ?? displayed.run.timingMs.beamforming, 0)} ms`,
@@ -1009,18 +1220,6 @@ function scheduleValidation() {
});
}
function sourcesForBand(scene, band, time = null) {
return scene.sources.filter((source) => {
if (time !== null) {
const startTime = source.startTime ?? 0;
const endTime = startTime + (source.duration ?? scene.duration);
if (time < startTime || time > endTime) return false;
}
if (source.signal.type !== "tone") return true;
return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz;
});
}
function createAbortError() {
const error = new Error("시뮬레이션이 취소되었습니다.");
error.name = "AbortError";
@@ -1170,7 +1369,11 @@ async function runSimulation() {
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
"power",
scan: snapshot.experiment.scan,
sources: sourcesForBand(snapshot.scene, definition),
sources: sourcesForObservedBand(
snapshot.scene,
definition,
snapshot.experiment.stft.fftSize,
),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
@@ -1289,6 +1492,7 @@ function reconcileLivePlan(plan, timeline) {
function evaluateLiveFrames(snapshot, plan, frames) {
for (const frame of frames) {
const frameScene = scenePlaybackState(snapshot.scene, plan, frame.sampleTime).scene;
const arrivingSourceIds = frame.run.signalStats.windowing?.arrivingSourceIds;
const evaluations = new Map();
for (const band of frame.run.bands) {
const definition = snapshot.experiment.frequencyBands.find(
@@ -1306,7 +1510,15 @@ function evaluateLiveFrames(snapshot, plan, frames) {
)?.rawScale ??
"power",
scan: snapshot.experiment.scan,
sources: sourcesForBand(frameScene, definition, frame.sampleTime),
sources: sourcesForObservedBand(
frameScene,
definition,
snapshot.experiment.stft.fftSize,
{
time: Array.isArray(arrivingSourceIds) ? null : frame.sampleTime,
arrivingSourceIds,
},
),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
@@ -1809,12 +2021,11 @@ function installBandListeners() {
let suffix = state.experiment.frequencyBands.length + 1;
while (used.has(`band-${suffix}`)) suffix += 1;
const previous = state.experiment.frequencyBands.at(-1);
const nyquist = state.scene.sampleRate / 2;
const minHz = Math.min(Math.max(0, previous?.maxHz ?? 900), nyquist - 101);
const minHz = Math.max(0, previous?.maxHz ?? 900);
const band = {
id: `band-${suffix}`,
minHz,
maxHz: Math.min(nyquist - 1, minHz + 200),
maxHz: minHz + 200,
color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
};
state.experiment.frequencyBands.push(band);