fix: expose physical aliasing and delayed arrivals
This commit is contained in:
@@ -38,8 +38,8 @@ Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에
|
||||
해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 어느
|
||||
시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에
|
||||
자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도
|
||||
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist·scan 같은 실제 관계 오류는
|
||||
그대로 거부됩니다.
|
||||
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist 위 tone·band는 ideal ADC
|
||||
alias로 실행되고, 잘못된 scan 범위 같은 실제 관계 오류는 그대로 거부됩니다.
|
||||
|
||||
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
|
||||
Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다.
|
||||
@@ -133,8 +133,9 @@ cache를 표시하고 알고리즘별 위치추정 비용을 다음처럼 분리
|
||||
이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop,
|
||||
band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
||||
|
||||
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist를 넘거나 겹치는 id 등 계약 오류는
|
||||
실행 전에 표시됩니다. DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
||||
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist 위 band도 허용되며 legend에 실제로
|
||||
접힌 ADC FFT range가 표시됩니다. 중복 id나 잘못된 범위 같은 계약 오류는 실행 전에 표시됩니다.
|
||||
DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
||||
항목은 Camera UI에서 제거했습니다. Result의 `processedMaps` 계약만 향후 확장을 위해 남아 있습니다.
|
||||
|
||||
**Peak 검출 임계값 (%)**은 최강 local peak의 map 값에 대한 상대 임계값이며 Experiment JSON에
|
||||
@@ -142,6 +143,11 @@ band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
||||
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
|
||||
main lobe 폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 하나로 합쳐질 수 있습니다.
|
||||
|
||||
**Peak 묶기**는 raw map을 바꾸지 않고 threshold 위 후보를 표현하는 방식을 고릅니다.
|
||||
`local-max`는 각 8-neighbor local maximum, `connected-components`는 threshold mask의
|
||||
8-connected component별 weighted centroid, `watershed`는 local maximum marker 기반 watershed
|
||||
basin을 사용합니다. 선택한 표준 방식과 threshold는 Experiment와 Result에 기록됩니다.
|
||||
|
||||
## 화면과 배열·알고리즘 비교
|
||||
|
||||
- acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다.
|
||||
@@ -181,12 +187,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
||||
|
||||
## Result JSON
|
||||
|
||||
**Result JSON**은 검증을 통과한 `micarrsim.result` 문서만 저장합니다.
|
||||
**Result JSON**은 검증을 통과한 `micarrsim.result` 1.1 문서만 저장합니다.
|
||||
|
||||
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `algorithm`과 `backend`
|
||||
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
|
||||
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse
|
||||
path 전체·종류별 수
|
||||
path 전체·종류별 수와 필수 ideal-ADC sampling provenance
|
||||
- `timingMs`: 전체, signal generation과 `localization`; 기존 소비자 호환용 `beamforming`은 같은
|
||||
위치추정 시간을 담는 alias
|
||||
- band별 `rawMap`: DAS는 선형 `power`, PHAT 계열은 무차원 `score`; 화면용 `displayMap`과 검출
|
||||
@@ -194,9 +200,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
||||
- 각 peak의 비보정 half-power/half-maximum 공분산과 RMS ellipse인 `beamSpread`
|
||||
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
|
||||
`mainLobeWidthM`
|
||||
- band별 연속 fold segment, FFT bin-center 선택, DAS/GCC 실제 localization model,
|
||||
원 carrier 미사용 여부와 alias-equivalent band ID
|
||||
|
||||
Tone source는 그 주파수가 band 범위 안에 있을 때만 해당 band의 ground truth입니다. Audio asset과
|
||||
noise source는 스펙트럼을 사전에 판별하지 않으므로 모든 band의 ground truth로 평가합니다. Source
|
||||
Tone source는 요청 analog interval 또는 접힌 ADC 주파수의 nearest selected bin을 기준으로
|
||||
해당 band의 ground truth가 됩니다. Audio asset과 noise source는 임의 spectrum detector를 만들지 않고 모든 band의
|
||||
ground truth로 평가합니다. Source
|
||||
z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와 위치 오차를 함께 해석하세요.
|
||||
|
||||
## 해석 한계
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
<label class="field" title="원시 map은 바꾸지 않고, 임계값 위 영역을 peak로 묶는 표준 방식을 선택합니다."><span>Peak 묶기</span><select id="detection-method"><option value="local-max">Local maxima</option><option value="connected-components">8-connected components</option><option value="watershed">Marker watershed</option></select></label>
|
||||
</div>
|
||||
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
|
||||
<p class="sampling-warning" id="sampling-warning" hidden></p>
|
||||
|
||||
<div class="band-config">
|
||||
<div class="compact-section-heading"><h3>주파수 대역</h3><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
|
||||
|
||||
+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);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
||||
import { observeFrequencyBand } from "/packages/acoustics-core/sampling.js";
|
||||
import {
|
||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||
localizationAlgorithmDefinition,
|
||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||
import { RESULT_SCHEMA_VERSION } from "/packages/contracts/validation.js";
|
||||
|
||||
const RESULT_METRIC_KEYS = [
|
||||
"peakToSidelobeDb",
|
||||
@@ -254,6 +256,70 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
|
||||
return environment;
|
||||
}
|
||||
|
||||
function samplingForResult(sampling) {
|
||||
if (!sampling || typeof sampling !== "object") return null;
|
||||
return {
|
||||
model: sampling.model,
|
||||
sampleRate: sampling.sampleRate,
|
||||
nyquistHz: sampling.nyquistHz,
|
||||
toneObservations: (sampling.toneObservations ?? []).map((observation) => ({
|
||||
scope: observation.scope,
|
||||
id: observation.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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function frequencyObservationForResult(scene, experiment, band, algorithm) {
|
||||
if (
|
||||
!Number.isFinite(scene?.sampleRate) ||
|
||||
!Number.isInteger(experiment?.stft?.fftSize) ||
|
||||
!Number.isFinite(band?.minHz) ||
|
||||
!Number.isFinite(band?.maxHz)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
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:
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createResultDocument(options) {
|
||||
const {
|
||||
arrayDefinition,
|
||||
@@ -266,8 +332,31 @@ export function createResultDocument(options) {
|
||||
} = options;
|
||||
const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
||||
const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power";
|
||||
const sampling = samplingForResult(run.signalStats.sampling);
|
||||
const frequencyObservations = new Map();
|
||||
for (const definition of experiment.frequencyBands) {
|
||||
const observation = frequencyObservationForResult(
|
||||
scene,
|
||||
experiment,
|
||||
definition,
|
||||
algorithm,
|
||||
);
|
||||
if (observation) frequencyObservations.set(definition.id, observation);
|
||||
}
|
||||
const equivalentGroups = new Map();
|
||||
for (const [bandId, observation] of frequencyObservations) {
|
||||
const group = equivalentGroups.get(observation.adcBinSetKey) ?? [];
|
||||
group.push(bandId);
|
||||
equivalentGroups.set(observation.adcBinSetKey, group);
|
||||
}
|
||||
for (const [bandId, observation] of frequencyObservations) {
|
||||
observation.equivalentBandIds = equivalentGroups
|
||||
.get(observation.adcBinSetKey)
|
||||
.filter((candidateId) => candidateId !== bandId)
|
||||
.sort();
|
||||
}
|
||||
return {
|
||||
schemaVersion: "1.0.0",
|
||||
schemaVersion: RESULT_SCHEMA_VERSION,
|
||||
kind: "micarrsim.result",
|
||||
experimentId: experiment.id,
|
||||
arrayId: arrayDefinition.id,
|
||||
@@ -289,6 +378,7 @@ export function createResultDocument(options) {
|
||||
...(run.signalStats.acousticPathCounts
|
||||
? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } }
|
||||
: {}),
|
||||
...(sampling ? { sampling } : {}),
|
||||
},
|
||||
timingMs: {
|
||||
total: run.timingMs.total,
|
||||
@@ -317,7 +407,15 @@ export function createResultDocument(options) {
|
||||
peaks: evaluation.peaks.map(peakForResult),
|
||||
metrics: finiteMetricSubset(evaluation.metrics),
|
||||
};
|
||||
if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation };
|
||||
const frequencyObservation = frequencyObservations.get(band.id);
|
||||
if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation;
|
||||
if (evaluation.evaluation) {
|
||||
bandResult.evaluation = {
|
||||
...evaluation.evaluation,
|
||||
truthSelection:
|
||||
"tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources",
|
||||
};
|
||||
}
|
||||
return bandResult;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ function signalStats(synthesis) {
|
||||
acousticModel: synthesis.acousticModel,
|
||||
acousticPathCount: synthesis.acousticPathCount,
|
||||
acousticPathCounts: synthesis.acousticPathCounts,
|
||||
windowing: synthesis.windowing,
|
||||
sampling: synthesis.sampling,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +77,10 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
||||
scene,
|
||||
plannedFrame.startTime,
|
||||
plannedFrame.endTime,
|
||||
{
|
||||
arrayDefinition,
|
||||
minimumDistance: experiment.beamforming.minimumDistance,
|
||||
},
|
||||
);
|
||||
post("progress", jobId, {
|
||||
stage: "timeline-signal",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
describeFrequencyAliasing,
|
||||
observeFrequencyBand,
|
||||
} from "/packages/acoustics-core/sampling.js";
|
||||
|
||||
/**
|
||||
* Select evaluation truth sources for one observed band.
|
||||
*
|
||||
* Arrival ids come from synthesis path timing and take precedence over the
|
||||
* legacy emission-time fallback. This helper is evaluation-only; localization
|
||||
* never receives truth positions or ids.
|
||||
*/
|
||||
export function sourcesForObservedBand(
|
||||
scene,
|
||||
band,
|
||||
fftSize,
|
||||
options = {},
|
||||
) {
|
||||
const observation = observeFrequencyBand(band, scene.sampleRate, fftSize);
|
||||
const observedBins = new Set(observation.bins);
|
||||
const arrivingSourceIdSet = Array.isArray(options.arrivingSourceIds)
|
||||
? new Set(options.arrivingSourceIds)
|
||||
: null;
|
||||
const time = options.time ?? null;
|
||||
return scene.sources.filter((source) => {
|
||||
if (arrivingSourceIdSet) {
|
||||
if (!arrivingSourceIdSet.has(source.id)) return false;
|
||||
} else 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;
|
||||
const analogInside =
|
||||
source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz;
|
||||
if (analogInside) return true;
|
||||
const alias = describeFrequencyAliasing(source.signal.frequency, scene.sampleRate);
|
||||
const nearestAdcBin = Math.round(alias.adcFrequencyHz / observation.binWidthHz);
|
||||
return observedBins.has(nearestAdcBin);
|
||||
});
|
||||
}
|
||||
@@ -240,6 +240,16 @@
|
||||
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
.workload-summary.is-warning { color: var(--amber); }
|
||||
.sampling-warning {
|
||||
margin: 8px 0 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgb(255 197 110 / 28%);
|
||||
border-radius: 8px;
|
||||
color: var(--amber);
|
||||
background: rgb(255 197 110 / 7%);
|
||||
font-size: .64rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.acoustic-environment-summary {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
이미지 평면에 source ground truth를 배치하고 tone, noise, 로컬 audio asset과 ambient를 조합해
|
||||
재현 가능한 `micarrsim.scene` JSON을 만드는 브라우저 앱입니다.
|
||||
|
||||
Tone 주파수는 Scene Nyquist 위도 입력할 수 있습니다. source 카드는 anti-alias filter 없는 이상적
|
||||
ADC에서 관측될 alias 주파수를 함께 표시합니다. 이는 고주파를 복원하거나 감쇠시키는 기능이 아니며,
|
||||
실제 장치의 analog anti-alias filter는 별도 모델 대상입니다.
|
||||
|
||||
## 열기
|
||||
|
||||
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Dataset Builder**를 선택하거나
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
|
||||
<div id="tone-fields"><label class="field field--wide"><span>주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" step="1" /></label></div>
|
||||
<div id="tone-fields"><label class="field field--wide" title="Nyquist를 넘어도 허용됩니다. 0–1 MHz는 현재 수치·마이크 모델의 검증 범위이며, 이상적 anti-alias filter 없는 ADC에서 접힌 주파수는 source 카드에 표시됩니다."><span>아날로그 tone 주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" max="1000000" step="1" /></label></div>
|
||||
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
|
||||
<div id="asset-fields">
|
||||
<label class="field field--wide"><span>Audio asset</span><select id="source-asset"></select></label>
|
||||
|
||||
@@ -190,7 +190,7 @@ function renderSourceCards() {
|
||||
name.title = source.name;
|
||||
heading.append(dot, name);
|
||||
const signal = document.createElement("small");
|
||||
signal.textContent = describeSignal(source.signal);
|
||||
signal.textContent = describeSignal(source.signal, state.scene.sampleRate);
|
||||
const position = document.createElement("small");
|
||||
const motion = Array.isArray(source.trajectory) ? ` · 이동 ${source.trajectory.length}점` : "";
|
||||
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m${motion}`;
|
||||
@@ -683,6 +683,7 @@ function bindSceneControls() {
|
||||
if (asset.kind === "audio") state.assetErrors.delete(asset.id);
|
||||
}
|
||||
renderAssetList();
|
||||
renderSourceCards();
|
||||
}, "change");
|
||||
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
|
||||
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { describeFrequencyAliasing } from "/packages/acoustics-core/sampling.js";
|
||||
|
||||
export const DEFAULT_VIEW_PLANE = Object.freeze({
|
||||
xMin: -0.6,
|
||||
xMax: 0.6,
|
||||
@@ -192,8 +194,20 @@ export function changeSignalType(source, type, firstAudioAssetId = "") {
|
||||
}
|
||||
}
|
||||
|
||||
export function describeSignal(signal) {
|
||||
if (signal.type === "tone") return `${signal.frequency} Hz tone`;
|
||||
export function describeSignal(signal, sampleRate) {
|
||||
if (signal.type === "tone") {
|
||||
if (!Number.isFinite(sampleRate)) return `${signal.frequency} Hz tone`;
|
||||
const observation = describeFrequencyAliasing(signal.frequency, sampleRate);
|
||||
const alias = observation.aliased
|
||||
? ` → ADC ${observation.adcFrequencyHz} Hz alias`
|
||||
: "";
|
||||
const edge = observation.dcEdge
|
||||
? " · DC phase-degenerate edge"
|
||||
: observation.nyquistEdge
|
||||
? " · Nyquist phase-degenerate edge"
|
||||
: "";
|
||||
return `${signal.frequency} Hz tone${alias}${edge}`;
|
||||
}
|
||||
if (signal.type === "noise") return `${signal.color} noise`;
|
||||
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
|
||||
return "알 수 없는 신호";
|
||||
|
||||
Reference in New Issue
Block a user