fix: expose physical aliasing and delayed arrivals
This commit is contained in:
+233
-22
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user