Files
micarrsim/apps/acoustic-camera/main.js
T

2459 lines
95 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { evaluateLocalizationBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
import { DEFAULT_MAX_COMPENSATION_DB } from "/packages/acoustics-core/frequency-response.js";
import {
localizationAlgorithmDefinition,
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 {
validateArrayDefinition,
validateExperiment,
validateResult,
validateScene,
} from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.js";
import { sourcesForObservedBand } from "./source-selection.js";
import {
applyAcousticProfile,
detectAcousticProfile,
maximumSparsePathsPerReceiver,
} from "./acoustic-profiles.js";
import {
createAnalysisFramePlan,
playbackFrameAtTime,
sceneHasTrajectories,
scenePlaybackState,
} from "./timeline.js";
import {
alignExperimentToScene,
createComparisonSignature,
createHistorySummary,
createResultDocument,
estimateExperimentWorkload,
hashSimulationInputs,
safeFileStem,
shiftSceneDistance,
} from "./model.js";
import { createArrayVisualization, createSceneVisualization } from "./visualization.js";
const DEFAULT_DOCUMENT_URLS = {
arrayDefinition: "/assets/array-geometries/rectangular-16x12.array.json",
scene: "/assets/sample-scenes/single-tone.scene.json",
experiment: "/assets/sample-scenes/single-tone.experiment.json",
};
const MOVING_EXAMPLE_URLS = {
arrayDefinition: "/assets/array-geometries/rectangular-16x12.array.json",
scene: "/assets/sample-scenes/moving-tone.scene.json",
experiment: "/assets/sample-scenes/moving-tone.experiment.json",
};
const MUSIC_EXAMPLE_URLS = {
arrayDefinition:
"/assets/array-geometries/rectangular-4x3-450mm.array.json",
scene:
"/assets/sample-scenes/music-single-tone.scene.json",
experiment:
"/assets/sample-scenes/music-single-tone.experiment.json",
};
const ARRAY_PRESET_URLS = new Map([
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"],
["rectangular-2x2-450mm", "/assets/array-geometries/rectangular-2x2-450mm.array.json"],
["rectangular-4x3-450mm", "/assets/array-geometries/rectangular-4x3-450mm.array.json"],
["rectangular-8x6-450mm", "/assets/array-geometries/rectangular-8x6-450mm.array.json"],
["rectangular-24x16-450mm", "/assets/array-geometries/rectangular-24x16-450mm.array.json"],
["rectangular-16x12-compact", "/assets/array-geometries/rectangular-16x12-compact.array.json"],
["rectangular-16x12-large", "/assets/array-geometries/rectangular-16x12-large.array.json"],
]);
const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"];
const MAX_HISTORY_ITEMS = 24;
const MAX_LIVE_ANALYSIS_FRAMES = 48;
const MAX_LIVE_SCAN_POINTS = 2500;
const DEFAULT_DETECTION_THRESHOLD_RATIO = 0.25;
const byId = (id) => document.getElementById(id);
const elements = {
runStatus: byId("run-status"),
runButton: byId("run-button"),
cancelButton: byId("cancel-button"),
exportResultButton: byId("export-result-button"),
sceneInput: byId("scene-input"),
arrayInput: byId("array-input"),
experimentInput: byId("experiment-input"),
assetInput: byId("asset-input"),
workspaceTitle: byId("workspace-title"),
sceneMeta: byId("scene-meta"),
sceneCanvas: byId("scene-canvas"),
canvasMessage: byId("canvas-message"),
canvasBadge: byId("canvas-badge"),
bandLegend: byId("band-legend"),
overlayOpacity: byId("overlay-opacity"),
showBeamSpread: byId("show-beam-spread"),
showTruth: byId("show-truth"),
livePanel: document.querySelector(".live-panel"),
liveStatus: byId("live-status"),
loadMovingExampleButton: byId("load-moving-example-button"),
loadMusicExampleButton: byId("load-music-example-button"),
prepareLiveButton: byId("prepare-live-button"),
livePlayButton: byId("live-play-button"),
liveTime: byId("live-time"),
liveTimeLabel: byId("live-time-label"),
liveMapRate: byId("live-map-rate"),
liveSpeed: byId("live-speed"),
resultBadge: byId("result-badge"),
emptyResult: byId("empty-result"),
metricGrid: byId("metric-grid"),
clearHistoryButton: byId("clear-history-button"),
historyList: byId("history-list"),
arrayTitle: byId("array-title"),
microphoneCount: byId("microphone-count"),
arrayPreset: byId("array-preset"),
arrayCanvas: byId("array-canvas"),
arrayMeta: byId("array-meta"),
sourceSummary: byId("source-summary"),
assetList: byId("asset-list"),
experimentName: byId("experiment-name"),
experimentId: byId("experiment-id"),
sceneDistance: byId("scene-distance"),
speedOfSound: byId("speed-of-sound"),
sceneSeed: byId("scene-seed"),
acousticProfile: byId("acoustic-profile"),
acousticEnvironmentSummary: byId("acoustic-environment-summary"),
frequencyResponseMode: byId("frequency-response-mode"),
algorithm: byId("algorithm"),
backend: byId("backend"),
musicOptions: byId("music-options"),
musicSourceCountMethod: byId("music-source-count-method"),
musicSourceCount: byId("music-source-count"),
musicOptionsNote: byId("music-options-note"),
gridPreset: byId("grid-preset"),
scanColumns: byId("scan-columns"),
scanRows: byId("scan-rows"),
scanXMin: byId("scan-x-min"),
scanXMax: byId("scan-x-max"),
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"),
dynamicRange: byId("dynamic-range"),
detectionThreshold: byId("detection-threshold"),
detectionMethod: byId("detection-method"),
addBandButton: byId("add-band-button"),
bandList: byId("band-list"),
exportSceneButton: byId("export-scene-button"),
exportExperimentButton: byId("export-experiment-button"),
exportHistoryButton: byId("export-history-button"),
outputSaveState: byId("output-save-state"),
validationCard: document.querySelector(".validation-card"),
validationTitle: byId("validation-title"),
validationMessage: byId("validation-message"),
};
const layoutSplitPane = createSplitPane({
container: byId("camera-layout"),
separator: byId("camera-split-handle"),
panel: byId("camera-controls"),
panelSide: "start",
storageKey: "micarrsim.camera.control-panel-width",
defaultSize: 420,
minSize: 320,
maxSize: 720,
minMainSize: 560,
enabledMediaQuery: "(min-width: 981px)",
});
const state = {
arrayDefinition: null,
scene: null,
experiment: null,
revision: 0,
validationSequence: 0,
validation: { valid: false, issues: [] },
visibleBandIds: new Set(),
lastRun: null,
live: null,
liveError: null,
history: [],
active: null,
pendingInputOperations: 0,
importGenerations: { array: 0, scene: 0, experiment: 0 },
sceneDirty: false,
experimentDirty: false,
resultDirty: false,
};
const sceneVisualization = createSceneVisualization(elements.sceneCanvas);
const arrayVisualization = createArrayVisualization(elements.arrayCanvas);
const assetRuntime = createCameraAssetRuntime(() => {
renderAssets();
renderSceneVisualization();
scheduleValidation();
});
let validationQueued = false;
let jobSequence = 0;
function formatNumber(value, digits = 1) {
return Number.isFinite(value) ? value.toFixed(digits) : "—";
}
function formatCount(value) {
if (!Number.isFinite(value)) return "—";
return new Intl.NumberFormat("ko-KR", {
notation: value >= 1e6 ? "compact" : "standard",
maximumFractionDigits: 1,
}).format(value);
}
function formatIssue(issue) {
return `${issue.path}: ${issue.message}`;
}
function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) {
return createComparisonSignature(scene, sourceExperiment);
}
function applyExperimentDefaults(experiment) {
if (!experiment.evaluation) experiment.evaluation = {};
if (experiment.evaluation.detectionThresholdRatio === undefined) {
experiment.evaluation.detectionThresholdRatio = DEFAULT_DETECTION_THRESHOLD_RATIO;
}
if (experiment.evaluation.detectionMethod === undefined) {
experiment.evaluation.detectionMethod = "local-max";
}
if (experiment.beamforming?.algorithm === "music") {
experiment.beamforming.musicSourceCountMethod ??= "fixed";
experiment.beamforming.musicSourceCount ??= 1;
}
return experiment;
}
function setRunStatus(message) {
elements.runStatus.textContent = message;
}
function unsavedWorkLabels() {
const labels = [];
if (state.sceneDirty) labels.push("Scene");
if (state.experimentDirty) labels.push("Experiment");
if (state.resultDirty) labels.push("Result");
if (state.history.some((item) => !item.comparisonSaved)) labels.push("비교 기록");
return labels;
}
function syncUnsavedState() {
const labels = unsavedWorkLabels();
document.documentElement.dataset.unsavedWork = labels.join(",");
elements.outputSaveState.textContent = labels.length > 0
? `미저장 · ${labels.join(" · ")}`
: "저장하지 않은 변경 없음";
elements.outputSaveState.classList.toggle("is-dirty", labels.length > 0);
elements.exportHistoryButton.disabled = state.history.length === 0;
}
function hasUnsavedWork() {
return Boolean(state.active) || unsavedWorkLabels().length > 0;
}
function setTransientError(error, prefix = "오류") {
const message = error instanceof Error ? error.message : String(error);
setRunStatus(`${prefix} · ${message}`);
elements.validationCard.classList.remove("is-valid", "is-warning");
elements.validationCard.classList.add("is-invalid");
elements.validationTitle.textContent = prefix;
elements.validationMessage.textContent = message;
}
function stopLivePlayback(render = true) {
const live = state.live;
if (!live) return;
if (live.animationFrameId) cancelAnimationFrame(live.animationFrameId);
live.animationFrameId = 0;
live.playing = false;
live.playbackStartedAt = null;
if (render) renderLiveControls();
}
function resetLiveTimeline() {
stopLivePlayback(false);
state.live = null;
state.liveError = null;
renderLiveControls();
}
function renderLiveControls() {
const hasTrajectory = sceneHasTrajectories(state.scene);
const ready = Boolean(state.live?.frames?.length);
const preparing = state.active?.kind === "live";
const playing = Boolean(ready && state.live.playing);
const locked = Boolean(state.active) || state.pendingInputOperations > 0;
elements.loadMovingExampleButton.disabled = locked;
elements.loadMusicExampleButton.disabled = locked;
elements.prepareLiveButton.disabled = locked || !hasTrajectory || !state.validation.valid;
elements.livePlayButton.disabled = locked || !ready;
elements.liveTime.disabled = locked || !ready;
elements.liveMapRate.disabled = locked;
elements.liveSpeed.disabled = locked || !ready;
elements.livePlayButton.textContent = playing ? "❚❚ 일시정지" : "▶ 재생";
elements.livePanel.classList.toggle("is-ready", ready);
elements.livePanel.classList.toggle("is-playing", playing);
if (ready) {
const time = Math.min(state.live.plan.duration, Math.max(0, state.live.time));
elements.liveTime.max = String(state.live.plan.duration);
elements.liveTime.value = String(time);
elements.liveTimeLabel.textContent =
`${formatNumber(time, 2)} / ${formatNumber(state.live.plan.duration, 2)} s`;
elements.liveStatus.textContent =
`${state.live.frames.length}개 acoustic map · ${formatNumber(state.live.plan.effectiveMapHz, 1)} Hz · ` +
`${state.live.snapshot.experiment.scan.columns}×${state.live.snapshot.experiment.scan.rows}` +
`${state.live.reducedScan ? " preview" : ""} · ${state.live.backend.toUpperCase()}`;
} else {
elements.liveTime.max = String(state.scene?.duration ?? 0);
elements.liveTime.value = "0";
elements.liveTimeLabel.textContent = `0.00 / ${formatNumber(state.scene?.duration ?? 0, 2)} s`;
if (preparing) {
elements.liveStatus.textContent = "이동 음원의 시간창 acoustic map을 계산하고 있습니다…";
} else if (state.liveError) {
elements.liveStatus.textContent = `라이브 준비 실패 · ${state.liveError}`;
} else if (hasTrajectory) {
elements.liveStatus.textContent = "이동 경로를 찾았습니다. ‘라이브 준비’를 누르면 시간축 map을 계산합니다.";
} else {
elements.liveStatus.textContent = "trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.";
}
}
let liveState = "unavailable";
if (hasTrajectory) liveState = "idle";
if (state.liveError) liveState = "error";
if (ready) liveState = "ready";
if (playing) liveState = "playing";
if (preparing) liveState = "preparing";
document.documentElement.dataset.liveState = liveState;
document.documentElement.dataset.liveFrameCount = ready ? String(state.live.frames.length) : "0";
document.documentElement.dataset.livePlanAnalyses = ready
? String(state.live.frames.at(-1)?.run.analysisCache?.completedAnalyses ?? 0)
: "0";
document.documentElement.dataset.liveTime = ready ? String(state.live.time) : "0";
}
function setConfigurationLocked(locked) {
setEditableControlsDisabled(locked);
elements.runButton.disabled = locked || !state.validation.valid;
elements.cancelButton.classList.toggle("is-hidden", !locked);
elements.exportResultButton.disabled =
locked || !state.lastRun?.resultDocument;
renderLiveControls();
}
function setEditableControlsDisabled(disabled) {
document.querySelectorAll(
".control-panel input, .control-panel select, .control-panel button, .workspace-tools input",
).forEach((control) => {
control.disabled = disabled;
});
}
function beginInputOperation() {
state.pendingInputOperations += 1;
document.documentElement.dataset.inputBusy = "true";
elements.runButton.disabled = true;
document.querySelectorAll(".workspace-tools input").forEach((input) => {
input.disabled = true;
});
renderLiveControls();
}
function endInputOperation() {
state.pendingInputOperations = Math.max(0, state.pendingInputOperations - 1);
document.documentElement.dataset.inputBusy = String(state.pendingInputOperations > 0);
if (
state.pendingInputOperations === 0 &&
!state.active &&
document.documentElement.dataset.appReady === "true"
) {
document.querySelectorAll(".workspace-tools input").forEach((input) => {
input.disabled = false;
});
}
renderLiveControls();
scheduleValidation();
}
function readFiniteInput(element) {
return Number.isFinite(element.valueAsNumber) ? element.valueAsNumber : null;
}
function markChanged(options = {}) {
const dirty = options.dirty ?? "experiment";
if (dirty === "scene" || dirty === "both") state.sceneDirty = true;
if (dirty === "experiment" || dirty === "both") state.experimentDirty = true;
state.revision += 1;
resetLiveTimeline();
if (state.lastRun) state.lastRun.stale = true;
if (["error", "canceled"].includes(document.documentElement.dataset.runState)) {
document.documentElement.dataset.runState = "idle";
}
if (options.syncControls) syncControls();
if (options.renderBands) renderBandEditors();
renderDocumentSummary();
renderLegend();
renderWorkload();
renderSceneVisualization();
renderResult();
renderHistory();
syncUnsavedState();
scheduleValidation();
}
function syncAlgorithmBackendControls() {
const algorithm = state.experiment?.beamforming?.algorithm;
const definition = localizationAlgorithmDefinition(algorithm);
const webGpuOption = elements.backend.querySelector('option[value="webgpu"]');
const autoOption = elements.backend.querySelector('option[value="auto"]');
const supportsWebGpu = definition?.backends.includes("webgpu") ?? true;
webGpuOption.disabled = !supportsWebGpu;
webGpuOption.title = supportsWebGpu ? "" : `${definition?.label ?? algorithm}는 현재 CPU에서만 실행됩니다.`;
autoOption.textContent = supportsWebGpu ? "Auto (GPU→CPU)" : "Auto (CPU)";
elements.backend.title = supportsWebGpu
? "지원되는 GPU를 우선 사용하고 필요하면 CPU로 전환합니다."
: `${definition?.label ?? algorithm}는 Auto와 CPU만 지원합니다.`;
const music = algorithm === "music";
const sourceCountMethod =
state.experiment?.beamforming?.musicSourceCountMethod ?? "fixed";
const maximumSourceCount = Math.max(
1,
(state.arrayDefinition?.microphones?.length ?? 2) - 1,
);
elements.musicOptions.hidden = !music;
elements.musicSourceCountMethod.value = sourceCountMethod;
elements.musicSourceCount.max = String(maximumSourceCount);
elements.musicSourceCount.value = String(
state.experiment?.beamforming?.musicSourceCount ?? 1,
);
elements.musicSourceCount.disabled = sourceCountMethod !== "fixed";
elements.musicOptionsNote.textContent = sourceCountMethod === "fixed"
? `각 band는 아날로그 FFT-grid 가설과 ADC bin을 각각 하나만 선택합니다. K는 1–${maximumSourceCount} 범위입니다. 같은 주파수·고정 위상의 coherent 음원은 standard MUSIC으로 분리되지 않으며 raw q는 확률이 아닙니다.`
: "MDL/AIC는 IID complex-Gaussian snapshot·spatially white noise·일정한 source count를 가정합니다. 비중첩 frame은 독립성의 증명이 아니며, 결과 K는 확률/신뢰도가 아닙니다.";
elements.showBeamSpread.disabled = music;
elements.showBeamSpread.title = music
? "MUSIC noise-projection에는 DAS/PHAT의 half-power beam spread를 적용하지 않습니다."
: "점선은 1× RMS, 실선 영역은 2× RMS 빔 응답 분포입니다. 확률 또는 신뢰구간은 아닙니다.";
document.documentElement.dataset.localizationAlgorithm = algorithm ?? "";
}
function syncControls() {
const { scene, experiment } = state;
if (!scene || !experiment) return;
elements.experimentName.value = experiment.name;
elements.experimentId.value = experiment.id;
elements.sceneDistance.value = experiment.scan.z;
elements.speedOfSound.value = scene.speedOfSound;
elements.sceneSeed.value = scene.seed;
elements.acousticProfile.value = detectAcousticProfile(scene);
elements.frequencyResponseMode.value =
experiment.beamforming.compensateFrequencyResponse === false ? "raw" : "compensate";
elements.algorithm.value = experiment.beamforming.algorithm;
elements.backend.value = experiment.beamforming.backend;
syncAlgorithmBackendControls();
elements.scanColumns.value = experiment.scan.columns;
elements.scanRows.value = experiment.scan.rows;
elements.scanXMin.value = experiment.scan.xMin;
elements.scanXMax.value = experiment.scan.xMax;
elements.scanYMin.value = experiment.scan.yMin;
elements.scanYMax.value = experiment.scan.yMax;
elements.fftSize.value = experiment.stft.fftSize;
elements.hopSize.value = experiment.stft.hopSize;
elements.normalization.value = experiment.beamforming.normalization;
elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb;
const detectionThresholdRatio =
experiment.evaluation?.detectionThresholdRatio ?? DEFAULT_DETECTION_THRESHOLD_RATIO;
elements.detectionThreshold.value = String(Number((detectionThresholdRatio * 100).toFixed(4)));
elements.detectionMethod.value = experiment.evaluation?.detectionMethod ?? "local-max";
elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id)
? state.arrayDefinition.id
: "custom";
elements.gridPreset.value =
experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows)
? String(experiment.scan.rows)
: "custom";
}
function renderAcousticSummary() {
if (!state.scene || !state.arrayDefinition || !state.experiment) return;
const profile = detectAcousticProfile(state.scene);
const profileLabels = {
"free-field": "자유음장",
"treated-room": "흡음된 실내",
"typical-room": "일반 실내",
"reflective-room": "반사 강한 실내",
custom: "Scene 사용자 환경",
};
const responseCurveCount = state.arrayDefinition.microphones.filter(
(microphone) => microphone.frequencyResponse?.length >= 2,
).length;
const maxCompensationDb =
state.experiment.beamforming.maxCompensationDb ?? DEFAULT_MAX_COMPENSATION_DB;
const responseMode =
state.experiment.beamforming.compensateFrequencyResponse === false
? "응답 역보정 꺼짐"
: "알려진 응답 역보정 ≤ " + formatNumber(maxCompensationDb, 0) + " dB";
elements.acousticEnvironmentSummary.textContent =
profileLabels[profile] + " · 수신점당 최대 " +
maximumSparsePathsPerReceiver(state.scene) + "개 sparse path · 응답 curve " +
responseCurveCount + "/" + state.arrayDefinition.microphones.length + " · " + responseMode;
document.documentElement.dataset.acousticProfile = profile;
}
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;
renderAcousticSummary();
elements.workspaceTitle.textContent = scene.name;
elements.sceneMeta.textContent =
`${scene.sampleRate.toLocaleString("ko-KR")} Hz · ${formatNumber(scene.duration, 3)} s · ` +
`${scene.sources.length} source · scan z ${formatNumber(experiment.scan.z, 2)} m`;
elements.arrayTitle.textContent = arrayDefinition.name;
elements.microphoneCount.textContent = `${arrayDefinition.microphones.length} ch`;
elements.arrayMeta.textContent =
`${arrayDefinition.id} · ${formatNumber(arrayDefinition.aperture.width, 3)} × ` +
`${formatNumber(arrayDefinition.aperture.height, 3)} m`;
elements.sourceSummary.replaceChildren();
for (const source of scene.sources) {
const line = document.createElement("div");
line.className = "source-line";
const name = document.createElement("strong");
name.textContent = source.name;
name.title = source.name;
const detail = document.createElement("span");
detail.textContent =
`${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;
line.append(name, detail);
elements.sourceSummary.append(line);
}
arrayVisualization.render({ arrayDefinition });
}
function renderAssets() {
elements.assetList.replaceChildren();
if (!state.scene) return;
const statuses = assetRuntime.statuses(state.scene);
if (statuses.length === 0) {
const empty = document.createElement("p");
empty.className = "asset-empty";
empty.textContent = "이 장면은 외부 asset 없이 바로 실행됩니다.";
elements.assetList.append(empty);
return;
}
for (const status of statuses) {
const row = document.createElement("div");
row.className = `asset-status ${status.ready ? "is-ready" : status.error ? "is-error" : ""}`;
const label = document.createElement("strong");
label.textContent = status.descriptor.fileName;
label.title = status.descriptor.fileName;
const value = document.createElement("span");
value.textContent = status.ready ? "연결됨" : status.error ? "오류" : "파일 필요";
value.title = status.error ?? "";
row.append(label, value);
elements.assetList.append(row);
}
}
function renderBandEditors() {
elements.bandList.replaceChildren();
if (!state.experiment) return;
state.experiment.frequencyBands.forEach((band, index) => {
const editor = document.createElement("div");
editor.className = "band-editor";
const fields = [
["id", "text", band.id, "Band ID"],
["minHz", "number", band.minHz, "Min Hz"],
["maxHz", "number", band.maxHz, "Max Hz"],
["color", "color", band.color, "Color"],
];
for (const [key, type, value, label] of fields) {
const input = document.createElement("input");
input.type = type;
input.value = value;
input.dataset.bandIndex = String(index);
input.dataset.bandKey = key;
input.setAttribute("aria-label", `${band.id} ${label}`);
input.title = label;
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);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "band-editor__remove";
remove.dataset.removeBand = String(index);
remove.textContent = "×";
remove.title = "대역 삭제";
remove.disabled = state.experiment.frequencyBands.length === 1 || Boolean(state.active);
editor.append(remove);
elements.bandList.append(editor);
});
elements.addBandButton.disabled = state.experiment.frequencyBands.length >= 8 || Boolean(state.active);
}
function renderLegend() {
elements.bandLegend.replaceChildren();
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";
label.style.setProperty("--band-color", band.color);
const input = document.createElement("input");
input.type = "checkbox";
input.checked = state.visibleBandIds.has(band.id);
input.dataset.visibleBand = band.id;
const swatch = document.createElement("span");
swatch.className = "legend-swatch";
const text = document.createElement("span");
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 = state.experiment.beamforming.algorithm === "music"
? "MUSIC uses the single analog FFT-grid hypothesis configured by this band, never Scene truth. Inverted Nyquist zones conjugate the full received manifold."
: "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를 계산하며 "
: state.experiment.beamforming.algorithm === "music"
? "관측 ADC bin의 covariance와 band가 명시한 단일 analog carrier manifold로 standard narrowband MUSIC q를 계산하며 "
: "관측된 양의 ADC FFT bin 주파수로 구면 steering하며 ";
const carrierDescription =
state.experiment.beamforming.algorithm === "music"
? "Scene truth는 사용하지 않습니다. 반전 Nyquist zone은 마이크 응답과 공간 위상을 모두 켤레 처리하며, 물리적 grating lobe·동률·모델 불일치는 그대로 유지합니다. 한 band의 복수 carrier 가설은 선택하거나 융합하지 않고 거부합니다. "
: "원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 그대로 나타나며 alias order는 미해결 상태입니다. "
elements.samplingWarning.textContent =
"Ideal ADC alias · 위치 추정은 " +
localizationDescription +
carrierDescription +
details.join(" · ");
elements.samplingWarning.hidden = false;
}
}
function renderWorkload() {
if (!state.arrayDefinition || !state.scene || !state.experiment) return;
try {
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
const maximumPaths = maximumSparsePathsPerReceiver(state.scene);
const signalPathBudget =
workload.microphoneCount * state.scene.sources.length * maximumPaths;
const scanCost = workload.algorithm === "gcc-phat"
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
: workload.algorithm === "music"
? `${workload.frameCount} snapshots × ${workload.musicHypothesisCount} analog hypotheses / ${workload.selectedBinCount} ADC bins · ` +
`cov ${formatCount(workload.musicCovarianceComplexAccumulations)} accumulations + ` +
`Jacobi ≤ ${formatCount(workload.musicJacobiWorstCasePairUpdates)} / cap ${formatCount(workload.musicJacobiPairUpdateLimit)} pair updates (32 sweeps) + ` +
`projection ≤ ${formatCount(workload.musicProjectionComplexMacs)} complex MAC`
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
elements.workloadSummary.textContent =
`${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` +
`${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` +
`${scanCost} · signal ≤ ${formatCount(signalPathBudget)} paths · ` +
`distance cache ${formatNumber(workload.distanceMiB, 1)} MiB`;
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
const heavyCpu =
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
(
workload.scanOperationCount > 5e8 ||
(
workload.algorithm === "music" &&
workload.musicJacobiWorstCasePairUpdates >
150_000_000
)
);
const narrowGcc = workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8;
elements.workloadSummary.classList.toggle(
"is-warning",
heavyCpu || narrowGcc || workload.pointCount >= 40000 || signalPathBudget > 100000,
);
} catch (error) {
elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`;
elements.workloadSummary.classList.add("is-warning");
}
}
function bandsForVisualization(lastRun) {
if (!lastRun) return [];
const definitions = new Map(lastRun.snapshot.experiment.frequencyBands.map((band) => [band.id, band]));
const snapshotIds = [...definitions.keys()];
const currentIds = state.experiment.frequencyBands.map((band) => band.id);
const sameBandSet =
snapshotIds.length === currentIds.length && snapshotIds.every((id) => currentIds.includes(id));
const visibleBandIds = sameBandSet ? state.visibleBandIds : lastRun.visibleBandIds;
return lastRun.run.bands.map((band) => ({
...band,
color: band.color ?? definitions.get(band.id)?.color ?? "#65f2bf",
visible: visibleBandIds.has(band.id),
}));
}
function interpolateMap(left, right, progress) {
if (left === right || !(progress > 0)) return left;
if (!left || !right || left.length !== right.length) return progress < 0.5 ? left : right;
const MapArray =
left instanceof Float64Array || right instanceof Float64Array
? Float64Array
: Float32Array;
const values = new MapArray(left.length);
const inverse = 1 - progress;
for (let index = 0; index < values.length; index += 1) {
values[index] = left[index] * inverse + right[index] * progress;
}
return values;
}
function liveVisualizationState(live) {
const playback = scenePlaybackState(live.snapshot.scene, live.plan, live.time);
const leftFrame = live.frames[playback.leftFrameIndex];
const rightFrame = live.frames[playback.rightFrameIndex];
const nearestFrame = live.frames[playback.frameIndex];
const rightBands = new Map(rightFrame.run.bands.map((band) => [band.id, band]));
const definitions = new Map(
live.snapshot.experiment.frequencyBands.map((band) => [band.id, band]),
);
const bands = leftFrame.run.bands.map((leftBand) => {
const rightBand = rightBands.get(leftBand.id) ?? leftBand;
return {
...leftBand,
displayMap: interpolateMap(leftBand.displayMap, rightBand.displayMap, playback.progress),
color: leftBand.color ?? definitions.get(leftBand.id)?.color ?? "#65f2bf",
visible: state.visibleBandIds.has(leftBand.id),
};
});
return {
playback,
scene: playback.scene,
bands,
evaluations: nearestFrame.evaluations,
run: nearestFrame.run,
};
}
function renderSceneVisualization() {
if (!state.scene || !state.experiment) return;
if (state.live?.frames?.length) {
const live = state.live;
const visualization = liveVisualizationState(live);
sceneVisualization.render({
scene: visualization.scene,
experiment: live.snapshot.experiment,
image: live.imageElement,
bands: visualization.bands,
evaluations: visualization.evaluations,
dynamicRangeDb: live.snapshot.experiment.beamforming.dynamicRangeDb,
opacity: Number(elements.overlayOpacity.value),
showBeamSpread: elements.showBeamSpread.checked,
showTruth: elements.showTruth.checked,
});
elements.canvasMessage.classList.add("is-hidden");
const algorithmLabel = localizationAlgorithmLabel(
live.frames[0]?.run.algorithm ?? live.snapshot.experiment.beamforming.algorithm,
);
elements.canvasBadge.textContent =
`LIVE · ${algorithmLabel} · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}` +
(
visualization.run.status === "unavailable"
? ` · 위치 없음 (${visualization.run.unavailable?.reason ?? "unavailable"})`
: ""
);
return;
}
const displayed = state.lastRun;
const snapshot = displayed?.snapshot ?? { scene: state.scene, experiment: state.experiment };
sceneVisualization.render({
scene: snapshot.scene,
experiment: snapshot.experiment,
image: displayed ? displayed.imageElement : assetRuntime.imageElement,
bands: bandsForVisualization(displayed),
evaluations: displayed?.evaluations,
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
opacity: Number(elements.overlayOpacity.value),
showBeamSpread: elements.showBeamSpread.checked,
showTruth: elements.showTruth.checked,
});
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
if (!displayed) {
elements.canvasBadge.textContent = "결과 없음";
} else if (displayed.run.status === "unavailable") {
elements.canvasBadge.textContent =
`MUSIC · 위치 없음 · ${displayed.run.unavailable?.reason ?? "unavailable"}`;
} else if (displayed.stale) {
elements.canvasBadge.textContent =
`이전 설정 결과 · ${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
displayed.run.backend.toUpperCase();
} else {
elements.canvasBadge.textContent =
`${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
`${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` +
`${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`;
}
}
function appendMetric(label, value, detail, color) {
const card = document.createElement("article");
card.className = "metric-card";
if (color) card.style.borderTopColor = color;
const labelElement = document.createElement("span");
labelElement.textContent = label;
const valueElement = document.createElement("strong");
valueElement.textContent = value;
const detailElement = document.createElement("small");
detailElement.textContent = detail;
card.append(labelElement, valueElement, detailElement);
elements.metricGrid.append(card);
}
function renderResult() {
const displayed = state.lastRun;
elements.exportResultButton.disabled =
Boolean(state.active) || !displayed?.resultDocument;
elements.metricGrid.replaceChildren();
elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed));
elements.metricGrid.classList.toggle("is-hidden", !displayed);
if (!displayed) {
elements.resultBadge.textContent = "아직 실행하지 않음";
return;
}
if (displayed.run.status === "unavailable") {
const algorithmLabel = localizationAlgorithmLabel(
displayed.run.algorithm ??
displayed.snapshot.experiment.beamforming.algorithm,
);
elements.resultBadge.textContent =
`${algorithmLabel} · 위치 없음`;
appendMetric(
"위치 추정",
"UNAVAILABLE",
displayed.run.unavailable?.reason ??
"spatially-unidentifiable",
);
appendMetric(
"처리 상태",
"정상 소비",
"임의 map·위치를 만들지 않았습니다.",
);
return;
}
const algorithm = displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm;
const algorithmLabel = localizationAlgorithmLabel(algorithm);
elements.resultBadge.textContent = displayed.stale
? `이전 설정 · ${algorithmLabel} · ${displayed.run.backend.toUpperCase()}`
: `${algorithmLabel} · ${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`;
appendMetric(
"위치 추정",
algorithmLabel,
displayed.run.fallback
? `실제 ${displayed.run.backend.toUpperCase()} · WebGPU fallback`
: `실제 ${displayed.run.backend.toUpperCase()} backend`,
);
appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + localization");
appendMetric(
"신호 합성",
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
`${displayed.snapshot.arrayDefinition.microphones.length} channels`,
);
if (displayed.run.signalStats.acousticModel) {
const modelLabel =
displayed.run.signalStats.acousticModel === "shoebox" ? "SPARSE ROOM" : "FREE FIELD";
appendMetric(
"음향 모델",
modelLabel,
`${(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 resultAlgorithm =
displayed.snapshot.experiment.beamforming.algorithm;
const localizationDetail =
resultAlgorithm === "music"
? "협대역 MUSIC noise subspace · configured analog carrier · inversion-aware · Scene truth 미사용"
: resultAlgorithm === "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`,
resultAlgorithm === "gcc-phat"
? "ADC LAG"
: resultAlgorithm === "music"
? "MIN q"
: "OBSERVED BIN",
localizationDetail + toneDetail,
band.color,
);
}
appendMetric(
"위치 추정 시간",
`${formatNumber(displayed.run.timingMs.localization ?? displayed.run.timingMs.beamforming, 0)} ms`,
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`,
);
const detectionThreshold =
displayed.snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO;
appendMetric(
"Peak 검출 임계값",
`${formatNumber(detectionThreshold * 100, 0)}%`,
algorithm === "music"
? "표준 MUSIC P=1/q의 P/Pmax 기준 · raw q 불변 · floor 없음"
: "최강 map 값 기준 · 낮을수록 민감",
);
appendMetric(
"Peak 묶기",
displayed.snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
"raw map 불변 · 검출 후보만 군집화",
);
for (const band of displayed.snapshot.experiment.frequencyBands) {
const evaluation = displayed.evaluations.get(band.id);
if (!evaluation) continue;
const matchedErrors = evaluation.sourceMatches
.filter((match) => match.withinTolerance && Number.isFinite(match.localizationErrorM))
.map((match) => match.localizationErrorM);
const localization = matchedErrors.length ? Math.min(...matchedErrors) : null;
appendMetric(
`${band.id} · 위치 오차`,
localization === null ? "미검출" : `${formatNumber(localization * 100, 1)} cm`,
`${evaluation.counts.truePositive}/${evaluation.counts.truth} source`,
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "minimum") {
appendMetric(
`${band.id} · Noise projection q`,
evaluation.globalPeak
? formatNumber(evaluation.globalPeak.value, 6)
: "식별 불가",
"작을수록 MUSIC 후보 · 확률/신뢰도 아님 · raw q",
band.color,
);
} else {
appendMetric(
`${band.id} · Truth concentration`,
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
"ground-truth 반경 내",
band.color,
);
appendMetric(
`${band.id} · Peak/sidelobe`,
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
"높을수록 분리 우수",
band.color,
);
}
appendMetric(
`${band.id} · Precision / Recall`,
`${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`,
`${evaluation.counts.detected} peaks`,
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "maximum") {
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
evaluation.diagnostics.mapScale === "score"
? "half-maximum score diameter"
: "half-power equivalent diameter",
band.color,
);
}
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
if (spread?.ellipse) {
appendMetric(
`${band.id} · 빔 분포`,
`${formatNumber(spread.ellipse.majorRmsRadiusM * 100, 1)} × ` +
`${formatNumber(spread.ellipse.minorRmsRadiusM * 100, 1)} cm`,
`1× RMS · ${formatNumber(spread.ellipse.angleDeg, 0)}° · 확률 분포 아님`,
band.color,
);
}
}
}
function renderHistory() {
elements.historyList.replaceChildren();
elements.exportHistoryButton.disabled = state.history.length === 0;
const signature = comparisonSignature();
const items = state.history.filter((item) => item.comparisonSignature === signature);
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "history-empty";
empty.textContent = "현재 scene·설정의 비교 실행 기록이 없습니다.";
elements.historyList.append(empty);
return;
}
for (const item of items) {
const card = document.createElement("article");
card.className = "history-card";
const name = document.createElement("strong");
name.textContent = `${localizationAlgorithmLabel(item.algorithm)} · ${item.arrayName}`;
const fields = [
`${item.backend.toUpperCase()} · ${item.pointCount.toLocaleString("ko-KR")} pt`,
`위치 ${formatNumber(item.localizationMs, 0)} / 전체 ${formatNumber(item.totalMs, 0)} ms`,
item.localizationErrorM === null ? "오차 —" : `오차 ${formatNumber(item.localizationErrorM * 100, 1)} cm`,
item.peakToSidelobeDb === null ? "PSR —" : `PSR ${formatNumber(item.peakToSidelobeDb, 1)} dB`,
];
card.append(name);
for (const field of fields) {
const value = document.createElement("span");
value.textContent = field;
card.append(value);
}
elements.historyList.append(card);
}
}
function validationWarnings() {
const warnings = [];
if (!state.scene || !state.experiment || !state.arrayDefinition) return warnings;
const zMismatch = state.scene.sources.some((source) =>
[source.position, ...(source.trajectory ?? []).map(({ position }) => position)].some(
(position) => Math.abs(position[2] - state.experiment.scan.z) > 1e-6,
),
);
if (zMismatch) {
warnings.push("일부 source z가 scan z와 달라 ground truth에는 평면 투영값을 사용합니다.");
}
if (
state.scene.image?.plane &&
Math.abs(state.scene.image.plane.z - state.experiment.scan.z) > 1e-6
) {
warnings.push("배경 image plane z와 scan z가 다릅니다. 이미지는 x/y 좌표만 투영해 표시합니다.");
}
try {
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8) {
warnings.push("GCC-PHAT는 안정적인 광대역 상관을 위해 선택 FFT bin을 8개 이상 권장합니다.");
}
if (
workload.algorithm === "music" &&
workload.musicJacobiWorstCasePairUpdates >
150_000_000
) {
warnings.push(
`MUSIC Jacobi 상한이 큽니다 (${workload.musicJacobiWorstCasePairUpdates.toLocaleString()} / hard cap ${workload.musicJacobiPairUpdateLimit.toLocaleString()} pair updates). 결과를 단순화하지 않으므로 채널 수나 명시 carrier band 수를 줄이지 않으면 오래 걸릴 수 있습니다.`,
);
}
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
if (
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
workload.scanOperationCount > 5e8
) {
warnings.push(
cpuOnly
? "CPU 계산량이 큽니다. 더 작은 grid 또는 더 적은 band를 권장합니다."
: "CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다.",
);
} else if (workload.pointCount >= 40000) {
warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다.");
}
} catch {
// Contract validation reports the actionable error.
}
return warnings;
}
async function validateCurrent() {
const sequence = ++state.validationSequence;
if (!state.arrayDefinition || !state.scene || !state.experiment) {
const validation = {
valid: false,
issues: [{ path: "$", message: "문서를 불러오는 중입니다." }],
warnings: [],
};
if (sequence === state.validationSequence) applyValidation(validation);
return validation;
}
const revision = state.revision;
const snapshot = structuredClone({
arrayDefinition: state.arrayDefinition,
scene: state.scene,
experiment: state.experiment,
});
const [arrayIssues, sceneIssues, experimentIssues] = await Promise.all([
validateArrayDefinition(snapshot.arrayDefinition),
validateScene(snapshot.scene),
validateExperiment(snapshot.experiment, {
arrayDefinition: snapshot.arrayDefinition,
scene: snapshot.scene,
}),
]);
const issues = [
...arrayIssues.map((issue) => ({ ...issue, path: `array${issue.path}` })),
...sceneIssues.map((issue) => ({ ...issue, path: `scene${issue.path}` })),
...experimentIssues.map((issue) => ({ ...issue, path: `experiment${issue.path}` })),
];
const missingAssets = assetRuntime.missingAssetIds(snapshot.scene);
if (missingAssets.length > 0) {
issues.push({
path: "scene.assets",
message: `원본 파일을 연결하세요: ${missingAssets.join(", ")}`,
});
}
if (state.pendingInputOperations > 0) {
issues.push({ path: "$", message: "선택한 파일을 확인하고 있습니다. 잠시 기다리세요." });
}
const validation = {
valid: issues.length === 0,
issues,
warnings: validationWarnings(),
revision,
};
if (sequence === state.validationSequence) applyValidation(validation);
return validation;
}
function applyValidation(validation) {
state.validation = validation;
elements.validationCard.classList.remove("is-valid", "is-invalid", "is-warning");
if (!validation.valid) {
elements.validationCard.classList.add("is-invalid");
elements.validationTitle.textContent = `${validation.issues.length}개 항목 확인 필요`;
elements.validationMessage.textContent = validation.issues.slice(0, 2).map(formatIssue).join(" · ");
} else if (validation.warnings.length > 0) {
elements.validationCard.classList.add("is-warning");
elements.validationTitle.textContent = "실행 가능 · 주의";
elements.validationMessage.textContent = validation.warnings.join(" · ");
} else {
elements.validationCard.classList.add("is-valid");
elements.validationTitle.textContent = "실행 준비 완료";
elements.validationMessage.textContent = "계약 검증과 asset 연결 상태가 정상입니다.";
}
document.documentElement.dataset.cameraValid = String(validation.valid);
if (!state.active) {
elements.runButton.disabled = !validation.valid;
if (validation.valid) {
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ Snapshot 실행";
if (!state.lastRun && document.documentElement.dataset.runState === "idle") {
setRunStatus(
`${state.arrayDefinition.microphones.length} ch · ` +
`${state.experiment.scan.columns}×${state.experiment.scan.rows} · ` +
`분석 대역 ${state.experiment.frequencyBands.length}개 · 실행 준비 완료`,
);
}
}
}
renderLiveControls();
}
function scheduleValidation() {
if (validationQueued) return;
validationQueued = true;
queueMicrotask(async () => {
validationQueued = false;
await validateCurrent();
});
}
function createAbortError() {
const error = new Error("시뮬레이션이 취소되었습니다.");
error.name = "AbortError";
return error;
}
function progressMessage(message) {
if (message.message) return message.message;
const progress = message.progress;
if (progress?.totalBins) {
return `위치 추정 중 · ${progress.completedBins}/${progress.totalBins} bins`;
}
if (progress?.totalChunks) {
return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`;
}
return `계산 중 · ${message.stage ?? "worker"}`;
}
function runWorker(activity, payload, transfer, resultOptions = {}) {
const resultType = resultOptions.type ?? "result";
const resultKey = resultOptions.key ?? "run";
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" });
activity.worker = worker;
activity.reject = reject;
worker.addEventListener("message", (event) => {
const message = event.data;
if (message?.jobId !== activity.jobId || state.active !== activity || activity.finished) return;
if (message.type === "progress") {
document.documentElement.dataset.runStage = message.stage ?? "worker";
setRunStatus(progressMessage(message));
if (activity.kind === "live") elements.liveStatus.textContent = progressMessage(message);
} else if (message.type === "fallback") {
activity.fallback = { code: message.code, message: message.message };
setRunStatus(`WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`);
if (activity.kind === "live") {
elements.liveStatus.textContent = `WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`;
}
} else if (message.type === resultType) {
activity.finished = true;
resolve(message[resultKey]);
} else if (message.type === "error") {
activity.finished = true;
const error = new Error(message.error?.message ?? "Worker 실행에 실패했습니다.");
error.name = message.error?.name ?? "Error";
error.code = message.error?.code ?? "";
reject(error);
}
});
worker.addEventListener("error", (event) => {
if (activity.finished) return;
activity.finished = true;
reject(new Error(event.message || "Simulation Worker를 시작하지 못했습니다."));
});
worker.postMessage(payload, transfer);
});
}
async function runSimulation() {
if (state.active) return;
const validation = await validateCurrent();
if (!validation.valid) {
setTransientError(
new Error(validation.issues.slice(0, 2).map(formatIssue).join(" · ")),
"실행 전 검증 실패",
);
return;
}
if (validation.revision !== state.revision || state.pendingInputOperations > 0) {
setRunStatus("입력 파일 또는 설정이 갱신되었습니다. 검증 완료 후 다시 실행하세요.");
await validateCurrent();
return;
}
if (
state.resultDirty &&
!window.confirm("직전 Result를 아직 저장하지 않았습니다. 새 실행으로 교체할까요?")
) {
setRunStatus("직전 Result를 먼저 저장한 뒤 다시 실행할 수 있습니다.");
return;
}
if (state.resultDirty) {
state.resultDirty = false;
syncUnsavedState();
}
resetLiveTimeline();
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
experiment: structuredClone(state.experiment),
};
const activity = {
jobId: `camera-${Date.now()}-${++jobSequence}`,
kind: "snapshot",
revision: state.revision,
abortController: new AbortController(),
worker: null,
reject: null,
canceled: false,
finished: false,
};
state.active = activity;
document.documentElement.dataset.runState = "running";
document.documentElement.dataset.runStage = "assets";
setConfigurationLocked(true);
elements.runButton.textContent = "계산 중…";
setRunStatus("실행 입력을 고정하고 asset을 준비하고 있습니다…");
try {
const inputHashPromise = hashSimulationInputs(snapshot);
const assetBuffers = await assetRuntime.decodeAudioAssets(
snapshot.scene,
activity.abortController.signal,
({ completed, total, fileName }) => {
if (state.active !== activity) return;
setRunStatus(
total > 0
? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}`
: "Synthetic signal 준비 완료",
);
},
);
if (activity.canceled || state.active !== activity) throw createAbortError();
document.documentElement.dataset.runStage = "worker";
const run = await runWorker(
activity,
{
type: "run",
jobId: activity.jobId,
scene: snapshot.scene,
arrayDefinition: snapshot.arrayDefinition,
experiment: snapshot.experiment,
assetBuffers,
},
assetRuntime.transferListForAudio(assetBuffers),
);
if (activity.canceled || state.active !== activity) throw createAbortError();
if (run.status === "unavailable") {
const inputHash = await inputHashPromise;
state.lastRun = {
snapshot,
run,
evaluations: new Map(),
exportedEvaluations: new Map(),
inputHash,
resultDocument: null,
stale: state.revision !== activity.revision,
imageElement: assetRuntime.imageElement,
visibleBandIds: new Set(state.visibleBandIds),
};
state.resultDirty = false;
document.documentElement.dataset.runState =
"unavailable";
document.documentElement.dataset.runBackend =
run.backend;
document.documentElement.dataset.runAlgorithm =
"music";
document.documentElement.dataset.resultBandCount =
"0";
setRunStatus(
`MUSIC 위치 없음 · ${run.unavailable?.reason ?? "unavailable"} · 임의 map을 만들지 않았습니다.`,
);
renderResult();
renderSceneVisualization();
return;
}
setRunStatus("Peak 검출과 정량 지표를 계산하고 있습니다…");
const evaluations = new Map();
const exportedEvaluations = new Map();
for (const band of run.bands) {
const definition = snapshot.experiment.frequencyBands.find(
(candidate) => candidate.id === band.id,
);
const evaluation = evaluateLocalizationBand({
rawMap: band.rawMap,
mapScale:
band.rawScale ??
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
"power",
objectiveDirection: band.objectiveDirection,
scan: snapshot.experiment.scan,
sources: sourcesForObservedBand(
snapshot.scene,
definition,
snapshot.experiment.stft.fftSize,
),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
detectionMethod: snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
});
evaluations.set(band.id, evaluation);
exportedEvaluations.set(band.id, exportBandEvaluation(evaluation));
}
const inputHash = await inputHashPromise;
const resultDocument = createResultDocument({
...snapshot,
run,
evaluations: exportedEvaluations,
inputHash,
});
const resultIssues = await validateResult(resultDocument, {
experiment: snapshot.experiment,
scene: snapshot.scene,
});
if (resultIssues.length > 0) {
throw new Error(
`Result 계약 검증 실패 · ${resultIssues.slice(0, 3).map(formatIssue).join(" · ")}`,
);
}
const completedRun = {
snapshot,
run,
evaluations,
exportedEvaluations,
inputHash,
resultDocument,
stale: state.revision !== activity.revision,
imageElement: assetRuntime.imageElement,
visibleBandIds: new Set(state.visibleBandIds),
};
state.lastRun = completedRun;
const historyItem = createHistorySummary({
...snapshot,
run,
evaluations: exportedEvaluations,
inputHash,
});
historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment);
historyItem.comparisonSaved = false;
state.history.unshift(historyItem);
state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS);
state.resultDirty = true;
syncUnsavedState();
document.documentElement.dataset.runState = "done";
document.documentElement.dataset.runBackend = run.backend;
document.documentElement.dataset.runAlgorithm =
run.algorithm ?? snapshot.experiment.beamforming.algorithm;
document.documentElement.dataset.resultBandCount = String(run.bands.length);
setRunStatus(
`${localizationAlgorithmLabel(run.algorithm ?? snapshot.experiment.beamforming.algorithm)} · ` +
`${run.backend.toUpperCase()} 완료 · ${formatNumber(run.timingMs.total, 0)} ms · ` +
`hash ${inputHash.slice(0, 10)}`,
);
renderResult();
renderHistory();
renderSceneVisualization();
} catch (error) {
if (error?.name === "AbortError" || activity.canceled) {
document.documentElement.dataset.runState = "canceled";
setRunStatus("계산을 취소했습니다. 입력과 직전 결과는 그대로 유지됩니다.");
} else {
document.documentElement.dataset.runState = "error";
setTransientError(error, "실행 실패");
}
} finally {
activity.worker?.terminate();
if (state.active === activity) state.active = null;
setConfigurationLocked(false);
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ Snapshot 실행";
renderBandEditors();
await validateCurrent();
}
}
function createLiveExperiment(experiment) {
const preview = structuredClone(experiment);
const originalPoints = preview.scan.columns * preview.scan.rows;
if (originalPoints <= MAX_LIVE_SCAN_POINTS) {
return { experiment: preview, reduced: false, originalPoints };
}
const scale = Math.sqrt(MAX_LIVE_SCAN_POINTS / originalPoints);
preview.scan.columns = Math.max(8, Math.floor(preview.scan.columns * scale));
preview.scan.rows = Math.max(8, Math.floor(preview.scan.rows * scale));
while (preview.scan.columns * preview.scan.rows > MAX_LIVE_SCAN_POINTS) {
if (preview.scan.columns >= preview.scan.rows && preview.scan.columns > 8) {
preview.scan.columns -= 1;
} else if (preview.scan.rows > 8) {
preview.scan.rows -= 1;
} else {
break;
}
}
return { experiment: preview, reduced: true, originalPoints };
}
function reconcileLivePlan(plan, timeline) {
const frames = timeline.frames.map(({ run, ...frame }) => frame);
const frameInterval = frames.length > 1
? (frames.at(-1).sampleTime - frames[0].sampleTime) / (frames.length - 1)
: plan.duration;
return {
...plan,
frames,
frameCount: frames.length,
frameInterval,
effectiveMapHz: frameInterval > 0 ? 1 / frameInterval : 0,
};
}
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(
(candidate) => candidate.id === band.id,
);
if (!definition) continue;
evaluations.set(
band.id,
evaluateLocalizationBand({
rawMap: band.rawMap,
mapScale:
band.rawScale ??
localizationAlgorithmDefinition(
frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm,
)?.rawScale ??
"power",
objectiveDirection: band.objectiveDirection,
scan: snapshot.experiment.scan,
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 ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
detectionMethod: snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
}),
);
}
frame.evaluations = evaluations;
}
}
async function prepareLiveSimulation() {
if (state.active) return;
const validation = await validateCurrent();
if (!validation.valid) {
state.liveError = validation.issues.slice(0, 2).map(formatIssue).join(" · ");
renderLiveControls();
return;
}
if (!sceneHasTrajectories(state.scene)) {
state.liveError = "Scene에 2개 이상의 keyframe을 가진 trajectory가 없습니다.";
renderLiveControls();
return;
}
if (validation.revision !== state.revision || state.pendingInputOperations > 0) {
state.liveError = "입력 갱신이 끝난 뒤 다시 시도하세요.";
renderLiveControls();
await validateCurrent();
return;
}
resetLiveTimeline();
const preview = createLiveExperiment(state.experiment);
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
experiment: preview.experiment,
};
const plan = createAnalysisFramePlan(snapshot.scene, {
fftSize: snapshot.experiment.stft.fftSize,
targetMapHz: Number(elements.liveMapRate.value),
maxFrames: MAX_LIVE_ANALYSIS_FRAMES,
});
const activity = {
jobId: `camera-live-${Date.now()}-${++jobSequence}`,
kind: "live",
revision: state.revision,
abortController: new AbortController(),
worker: null,
reject: null,
canceled: false,
finished: false,
};
state.active = activity;
state.liveError = null;
document.documentElement.dataset.runStage = "live-assets";
setConfigurationLocked(true);
elements.prepareLiveButton.textContent = "준비 중…";
setRunStatus(`라이브 입력 고정 · ${plan.frameCount}개 map을 준비합니다…`);
try {
const assetBuffers = await assetRuntime.decodeAudioAssets(
snapshot.scene,
activity.abortController.signal,
({ completed, total, fileName }) => {
if (state.active !== activity) return;
elements.liveStatus.textContent = total > 0
? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}`
: "Synthetic signal 준비 완료";
},
);
if (activity.canceled || state.active !== activity) throw createAbortError();
document.documentElement.dataset.runStage = "live-worker";
const timeline = await runWorker(
activity,
{
type: "run-timeline",
jobId: activity.jobId,
scene: snapshot.scene,
arrayDefinition: snapshot.arrayDefinition,
experiment: snapshot.experiment,
framePlan: plan,
assetBuffers,
},
assetRuntime.transferListForAudio(assetBuffers),
{ type: "timeline-result", key: "timeline" },
);
if (activity.canceled || state.active !== activity) throw createAbortError();
elements.liveStatus.textContent = "프레임별 peak와 빔 분포를 계산하고 있습니다…";
const resolvedPlan = reconcileLivePlan(plan, timeline);
evaluateLiveFrames(snapshot, resolvedPlan, timeline.frames);
state.live = {
snapshot,
plan: resolvedPlan,
frames: timeline.frames,
backend: timeline.backend,
timingMs: timeline.timingMs,
reducedScan: preview.reduced,
originalPoints: preview.originalPoints,
imageElement: assetRuntime.imageElement,
time: 0,
playing: false,
animationFrameId: 0,
playbackStartedAt: null,
playbackStartedTime: 0,
};
const unavailableFrameCount = timeline.frames.filter(
(frame) => frame.run.status === "unavailable",
).length;
setRunStatus(
`라이브 준비 완료 · ${timeline.frames.length} maps · ` +
`${formatNumber(timeline.timingMs.total, 0)} ms` +
(
unavailableFrameCount > 0
? ` · 위치 없음 ${unavailableFrameCount} frames`
: ""
),
);
renderLiveControls();
renderSceneVisualization();
} catch (error) {
if (error?.name === "AbortError" || activity.canceled) {
setRunStatus("라이브 map 준비를 취소했습니다.");
} else {
state.liveError = error instanceof Error ? error.message : String(error);
setRunStatus(`라이브 준비 실패 · ${state.liveError}`);
}
} finally {
activity.worker?.terminate();
if (state.active === activity) state.active = null;
elements.prepareLiveButton.textContent = "라이브 준비";
setConfigurationLocked(false);
renderBandEditors();
await validateCurrent();
renderLiveControls();
}
}
function setLiveTime(value) {
const live = state.live;
if (!live) return;
live.time = Math.min(live.plan.duration, Math.max(0, Number(value) || 0));
const playback = playbackFrameAtTime(live.plan, live.time);
document.documentElement.dataset.liveFrame = String(playback.framePosition);
renderLiveControls();
renderSceneVisualization();
}
function livePlaybackTick(timestamp) {
const live = state.live;
if (!live?.playing) return;
if (live.playbackStartedAt === null) live.playbackStartedAt = timestamp;
const speed = Number(elements.liveSpeed.value) || 1;
const elapsed = ((timestamp - live.playbackStartedAt) / 1000) * speed;
const nextTime = live.playbackStartedTime + elapsed;
if (nextTime >= live.plan.duration) {
live.time = live.plan.duration;
stopLivePlayback(false);
renderLiveControls();
renderSceneVisualization();
return;
}
setLiveTime(nextTime);
live.animationFrameId = requestAnimationFrame(livePlaybackTick);
}
function toggleLivePlayback() {
const live = state.live;
if (!live) return;
if (live.playing) {
stopLivePlayback();
return;
}
if (live.time >= live.plan.duration) live.time = 0;
live.playing = true;
live.playbackStartedAt = null;
live.playbackStartedTime = live.time;
live.animationFrameId = requestAnimationFrame(livePlaybackTick);
renderLiveControls();
renderSceneVisualization();
}
async function loadMovingExample() {
if (state.active) return;
const bandCount = state.experiment?.frequencyBands?.length ?? 0;
if (
(bandCount > 1 || hasUnsavedWork()) &&
!window.confirm(
"이동 예제는 현재 Scene·Array·Experiment 전체와 " +
String(bandCount) +
"개 분석 대역을 교체합니다. 계속할까요?",
)
) {
setRunStatus("이동 예제를 열지 않았습니다. 현재 입력과 분석 대역을 유지합니다.");
return;
}
beginInputOperation();
try {
setRunStatus("이동 음원 예제를 불러오고 있습니다…");
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson(MOVING_EXAMPLE_URLS.arrayDefinition),
loadJson(MOVING_EXAMPLE_URLS.scene),
loadJson(MOVING_EXAMPLE_URLS.experiment),
]);
const [arrayIssues, sceneIssues] = await Promise.all([
validateArrayDefinition(arrayDefinition),
validateScene(scene),
]);
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
const issues = [...arrayIssues, ...sceneIssues, ...experimentIssues];
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = arrayDefinition;
state.scene = scene;
state.experiment = applyExperimentDefaults(experiment);
state.sceneDirty = false;
state.experimentDirty = false;
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
assetRuntime.reset();
markChanged({ syncControls: true, renderBands: true, dirty: "none" });
renderAssets();
setRunStatus("Scene·Array·Experiment를 이동 음원 예제 전체로 교체했습니다. ‘Map 준비’를 누르세요.");
} catch (error) {
setTransientError(error, "이동 예제 불러오기 실패");
} finally {
endInputOperation();
}
}
async function loadMusicExample() {
if (state.active) return;
const bandCount =
state.experiment?.frequencyBands?.length ?? 0;
if (
(bandCount > 1 || hasUnsavedWork()) &&
!window.confirm(
"MUSIC 빠른 예제는 현재 Scene·Array·Experiment 전체와 " +
String(bandCount) +
"개 분석 대역을 교체합니다. 계속할까요?",
)
) {
setRunStatus(
"MUSIC 예제를 열지 않았습니다. 현재 입력을 유지합니다.",
);
return;
}
beginInputOperation();
try {
setRunStatus(
"12채널 표준 MUSIC 예제를 불러오고 있습니다…",
);
const [arrayDefinition, scene, experiment] =
await Promise.all([
loadJson(MUSIC_EXAMPLE_URLS.arrayDefinition),
loadJson(MUSIC_EXAMPLE_URLS.scene),
loadJson(MUSIC_EXAMPLE_URLS.experiment),
]);
const [arrayIssues, sceneIssues] =
await Promise.all([
validateArrayDefinition(arrayDefinition),
validateScene(scene),
]);
const experimentIssues = await validateExperiment(
experiment,
{ arrayDefinition, scene },
);
const issues = [
...arrayIssues,
...sceneIssues,
...experimentIssues,
];
if (issues.length > 0) {
throw new Error(
issues.slice(0, 4).map(formatIssue).join(" · "),
);
}
state.arrayDefinition = arrayDefinition;
state.scene = scene;
state.experiment =
applyExperimentDefaults(experiment);
state.sceneDirty = false;
state.experimentDirty = false;
state.visibleBandIds = new Set(
experiment.frequencyBands.map((band) => band.id),
);
assetRuntime.reset();
markChanged({
syncControls: true,
renderBands: true,
dirty: "none",
});
renderAssets();
setRunStatus(
"12채널·1.125 kHz·Fixed K=1 MUSIC 예제를 열었습니다. Snapshot 실행을 누르세요.",
);
} catch (error) {
setTransientError(
error,
"MUSIC 예제 불러오기 실패",
);
} finally {
endInputOperation();
}
}
function cancelSimulation() {
const activity = state.active;
if (!activity || activity.canceled) return;
activity.canceled = true;
activity.abortController.abort(createAbortError());
activity.worker?.terminate();
activity.reject?.(createAbortError());
setRunStatus("계산을 취소하는 중…");
}
async function loadJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} 응답 ${response.status}`);
return response.json();
}
async function loadArrayPreset(presetId) {
const url = ARRAY_PRESET_URLS.get(presetId);
if (!url) {
elements.arrayInput.click();
syncControls();
return;
}
const generation = ++state.importGenerations.array;
beginInputOperation();
try {
setRunStatus(`${presetId} 비교 배열을 불러오고 있습니다…`);
const value = await loadJson(url);
if (generation !== state.importGenerations.array) return;
const issues = await validateArrayDefinition(value);
if (generation !== state.importGenerations.array) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = value;
markChanged({ syncControls: true });
setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`);
} catch (error) {
syncControls();
setTransientError(error, "비교 배열 불러오기 실패");
} finally {
endInputOperation();
}
}
async function importDocument(kind, input) {
const file = input.files?.[0];
input.value = "";
if (!file) return;
const generation = ++state.importGenerations[kind];
beginInputOperation();
try {
const value = await readJsonFile(file);
if (generation !== state.importGenerations[kind]) return;
let issues;
if (kind === "array") {
issues = await validateArrayDefinition(value);
} else if (kind === "scene") {
issues = await validateScene(value);
} else {
// arrayId/sceneId are retained as document provenance, not treated as
// foreign keys. The currently open Array and Scene are the run inputs.
issues = await validateExperiment(value);
if (generation !== state.importGenerations[kind]) return;
if (issues.length === 0) {
issues = await validateExperiment(value, {
arrayDefinition: state.arrayDefinition,
scene: state.scene,
});
}
}
if (generation !== state.importGenerations[kind]) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
if (kind === "array") {
state.arrayDefinition = value;
} else if (kind === "scene") {
state.scene = value;
alignExperimentToScene(state.experiment, value);
state.sceneDirty = false;
assetRuntime.reset();
} else {
state.experiment = applyExperimentDefaults(value);
state.experimentDirty = false;
}
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
markChanged({
syncControls: true,
renderBands: true,
dirty: kind === "experiment" ? "none" : "experiment",
});
renderAssets();
const bandStatus = kind === "scene"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개 유지`
: kind === "experiment"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개`
: "";
setRunStatus(
`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}`,
);
} catch (error) {
setTransientError(error, `${file.name} 불러오기 실패`);
} finally {
endInputOperation();
}
}
function installDocumentInputListeners() {
elements.experimentName.addEventListener("input", () => {
state.experiment.name = elements.experimentName.value;
markChanged();
});
elements.experimentId.addEventListener("input", () => {
state.experiment.id = elements.experimentId.value;
markChanged();
});
elements.sceneDistance.addEventListener("change", () => {
const value = readFiniteInput(elements.sceneDistance);
if (value === null) return syncControls();
shiftSceneDistance(state.scene, state.experiment, value);
markChanged({ dirty: "both" });
});
elements.speedOfSound.addEventListener("change", () => {
const value = readFiniteInput(elements.speedOfSound);
if (value === null) return syncControls();
state.scene.speedOfSound = value;
markChanged({ dirty: "scene" });
});
elements.sceneSeed.addEventListener("change", () => {
const value = readFiniteInput(elements.sceneSeed);
if (value === null) return syncControls();
state.scene.seed = value;
markChanged({ dirty: "scene" });
});
elements.acousticProfile.addEventListener("change", () => {
if (elements.acousticProfile.value === "custom") return syncControls();
applyAcousticProfile(
state.scene,
state.arrayDefinition,
state.experiment,
elements.acousticProfile.value,
);
markChanged({ dirty: "scene", syncControls: true });
});
elements.frequencyResponseMode.addEventListener("change", () => {
state.experiment.beamforming.compensateFrequencyResponse =
elements.frequencyResponseMode.value === "compensate";
markChanged();
});
elements.algorithm.addEventListener("change", () => {
const algorithm = elements.algorithm.value;
state.experiment.beamforming.algorithm = algorithm;
if (algorithm === "music") {
state.experiment.beamforming.musicSourceCountMethod ??= "fixed";
state.experiment.beamforming.musicSourceCount ??= 1;
state.experiment.beamforming.normalization = "none";
}
const backendReset =
state.experiment.beamforming.backend === "webgpu" &&
!localizationAlgorithmSupportsBackend(algorithm, "webgpu");
if (backendReset) state.experiment.beamforming.backend = "auto";
markChanged({ syncControls: true });
if (backendReset) {
setRunStatus(`${localizationAlgorithmLabel(algorithm)}는 CPU 전용이므로 Backend를 Auto (CPU)로 바꿨습니다.`);
}
});
elements.musicSourceCountMethod.addEventListener("change", () => {
const method = elements.musicSourceCountMethod.value;
state.experiment.beamforming.musicSourceCountMethod = method;
if (method === "fixed") {
state.experiment.beamforming.musicSourceCount ??= 1;
}
markChanged({ syncControls: true });
});
elements.musicSourceCount.addEventListener("change", () => {
const value = readFiniteInput(elements.musicSourceCount);
if (value === null) return syncControls();
state.experiment.beamforming.musicSourceCount = Math.trunc(value);
markChanged({ syncControls: true });
});
elements.backend.addEventListener("change", () => {
if (
elements.backend.value === "webgpu" &&
!localizationAlgorithmSupportsBackend(state.experiment.beamforming.algorithm, "webgpu")
) {
elements.backend.value = state.experiment.beamforming.backend;
setRunStatus(
`${localizationAlgorithmLabel(state.experiment.beamforming.algorithm)}는 Auto 또는 CPU를 사용하세요.`,
);
return;
}
state.experiment.beamforming.backend = elements.backend.value;
markChanged();
});
elements.gridPreset.addEventListener("change", () => {
if (elements.gridPreset.value === "custom") return;
const side = Number(elements.gridPreset.value);
state.experiment.scan.columns = side;
state.experiment.scan.rows = side;
markChanged({ syncControls: true });
});
const scanBindings = [
[elements.scanColumns, "columns", true],
[elements.scanRows, "rows", true],
[elements.scanXMin, "xMin", false],
[elements.scanXMax, "xMax", false],
[elements.scanYMin, "yMin", false],
[elements.scanYMax, "yMax", false],
];
for (const [element, key, integer] of scanBindings) {
element.addEventListener("change", () => {
const value = readFiniteInput(element);
if (value === null) return syncControls();
state.experiment.scan[key] = integer ? Math.trunc(value) : value;
markChanged({ syncControls: true });
});
}
elements.fftSize.addEventListener("change", () => {
state.experiment.stft.fftSize = Number(elements.fftSize.value);
if (state.experiment.stft.hopSize > state.experiment.stft.fftSize) {
state.experiment.stft.hopSize = state.experiment.stft.fftSize / 2;
}
markChanged({ syncControls: true });
});
elements.hopSize.addEventListener("change", () => {
const value = readFiniteInput(elements.hopSize);
if (value === null) return syncControls();
state.experiment.stft.hopSize = Math.trunc(value);
markChanged({ syncControls: true });
});
elements.normalization.addEventListener("change", () => {
state.experiment.beamforming.normalization = elements.normalization.value;
markChanged();
});
elements.dynamicRange.addEventListener("change", () => {
const value = readFiniteInput(elements.dynamicRange);
if (value === null) return syncControls();
state.experiment.beamforming.dynamicRangeDb = value;
markChanged();
});
elements.detectionThreshold.addEventListener("change", () => {
const value = readFiniteInput(elements.detectionThreshold);
if (value === null) return syncControls();
state.experiment.evaluation ??= {};
state.experiment.evaluation.detectionThresholdRatio = value / 100;
markChanged();
});
elements.detectionMethod.addEventListener("change", () => {
state.experiment.evaluation ??= {};
state.experiment.evaluation.detectionMethod = elements.detectionMethod.value;
markChanged();
});
}
function installBandListeners() {
elements.bandList.addEventListener("change", (event) => {
const input = event.target.closest("input[data-band-index]");
if (!input) return;
const band = state.experiment.frequencyBands[Number(input.dataset.bandIndex)];
if (!band) return;
const key = input.dataset.bandKey;
if (key === "id") {
const previousId = band.id;
band.id = input.value;
state.visibleBandIds.delete(previousId);
state.visibleBandIds.add(band.id);
} else if (key === "color") {
band.color = input.value;
} else if (Number.isFinite(input.valueAsNumber)) {
band[key] = input.valueAsNumber;
}
markChanged();
});
elements.bandList.addEventListener("click", (event) => {
const button = event.target.closest("button[data-remove-band]");
if (!button || state.experiment.frequencyBands.length === 1) return;
const [removed] = state.experiment.frequencyBands.splice(Number(button.dataset.removeBand), 1);
state.visibleBandIds.delete(removed.id);
markChanged({ renderBands: true });
});
elements.addBandButton.addEventListener("click", () => {
if (state.experiment.frequencyBands.length >= 8) return;
const used = new Set(state.experiment.frequencyBands.map((band) => band.id));
let suffix = state.experiment.frequencyBands.length + 1;
while (used.has(`band-${suffix}`)) suffix += 1;
const previous = state.experiment.frequencyBands.at(-1);
const minHz = Math.max(0, previous?.maxHz ?? 900);
const band = {
id: `band-${suffix}`,
minHz,
maxHz: minHz + 200,
color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
};
state.experiment.frequencyBands.push(band);
state.visibleBandIds.add(band.id);
markChanged({ renderBands: true });
});
elements.bandLegend.addEventListener("change", (event) => {
const input = event.target.closest("input[data-visible-band]");
if (!input) return;
if (input.checked) state.visibleBandIds.add(input.dataset.visibleBand);
else state.visibleBandIds.delete(input.dataset.visibleBand);
renderSceneVisualization();
});
}
function installActionListeners() {
elements.runButton.addEventListener("click", runSimulation);
elements.cancelButton.addEventListener("click", cancelSimulation);
elements.loadMovingExampleButton.addEventListener("click", loadMovingExample);
elements.loadMusicExampleButton.addEventListener(
"click",
loadMusicExample,
);
elements.prepareLiveButton.addEventListener("click", prepareLiveSimulation);
elements.livePlayButton.addEventListener("click", toggleLivePlayback);
elements.liveTime.addEventListener("input", () => {
stopLivePlayback(false);
setLiveTime(elements.liveTime.valueAsNumber);
});
elements.liveMapRate.addEventListener("change", () => {
resetLiveTimeline();
renderSceneVisualization();
});
elements.liveSpeed.addEventListener("change", () => {
if (!state.live?.playing) return;
state.live.playbackStartedAt = performance.now();
state.live.playbackStartedTime = state.live.time;
});
elements.sceneInput.addEventListener("change", () => importDocument("scene", elements.sceneInput));
elements.arrayInput.addEventListener("change", () => importDocument("array", elements.arrayInput));
elements.experimentInput.addEventListener(
"change",
() => importDocument("experiment", elements.experimentInput),
);
elements.arrayPreset.addEventListener("change", () => loadArrayPreset(elements.arrayPreset.value));
elements.assetInput.addEventListener("change", async () => {
const files = [...(elements.assetInput.files ?? [])];
elements.assetInput.value = "";
if (files.length === 0) return;
beginInputOperation();
try {
setRunStatus(`${files.length}개 asset의 SHA-256을 확인하고 있습니다…`);
const report = await assetRuntime.bindFiles(files, state.scene);
if (report.rejected.length > 0) {
setTransientError(
new Error(report.rejected.map((item) => `${item.fileName}: ${item.message}`).join(" · ")),
"Asset 연결 실패",
);
} else {
setRunStatus(`${report.bound.length}개 asset descriptor를 연결했습니다.`);
}
} catch (error) {
setTransientError(error, "Asset 연결 실패");
} finally {
renderAssets();
endInputOperation();
}
});
elements.overlayOpacity.addEventListener("input", renderSceneVisualization);
elements.showBeamSpread.addEventListener("change", renderSceneVisualization);
elements.showTruth.addEventListener("change", renderSceneVisualization);
elements.exportSceneButton.addEventListener("click", async () => {
const issues = await validateScene(state.scene);
if (issues.length > 0) {
setTransientError(
new Error(issues.slice(0, 4).map(formatIssue).join(" · ")),
"Scene 저장 실패",
);
return;
}
downloadJson(state.scene, `${safeFileStem(state.scene.id)}.scene.json`);
state.sceneDirty = false;
syncUnsavedState();
setRunStatus("Scene JSON을 저장했습니다.");
});
elements.exportExperimentButton.addEventListener("click", async () => {
const issues = await validateExperiment(state.experiment, {
arrayDefinition: state.arrayDefinition,
scene: state.scene,
});
if (issues.length > 0) {
setTransientError(
new Error(issues.slice(0, 4).map(formatIssue).join(" · ")),
"Experiment 저장 실패",
);
return;
}
downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`);
state.experimentDirty = false;
syncUnsavedState();
setRunStatus("Experiment JSON을 저장했습니다.");
});
elements.exportResultButton.addEventListener("click", () => {
if (!state.lastRun?.resultDocument) return;
downloadJson(
state.lastRun.resultDocument,
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
);
state.resultDirty = false;
syncUnsavedState();
setRunStatus("검증된 Result JSON을 저장했습니다.");
});
elements.exportHistoryButton.addEventListener("click", () => {
if (state.history.length === 0) return;
const items = state.history.map(({ comparisonSignature, comparisonSaved, ...item }) => item);
downloadJson(
{
schemaVersion: "1.0.0",
kind: "micarrsim.comparison-history",
createdAt: new Date().toISOString(),
items,
},
"micarrsim.comparison-history.json",
);
for (const item of state.history) item.comparisonSaved = true;
syncUnsavedState();
setRunStatus("전체 비교 기록 JSON을 저장했습니다.");
});
elements.clearHistoryButton.addEventListener("click", () => {
const signature = comparisonSignature();
state.history = state.history.filter((item) => item.comparisonSignature !== signature);
renderHistory();
syncUnsavedState();
});
window.addEventListener("beforeunload", (event) => {
if (window.top !== window || !hasUnsavedWork()) return;
event.preventDefault();
event.returnValue = "";
});
window.addEventListener("unload", () => {
stopLivePlayback(false);
cancelSimulation();
sceneVisualization.destroy();
arrayVisualization.destroy();
assetRuntime.dispose();
layoutSplitPane.destroy();
});
}
async function initialize() {
installDocumentInputListeners();
installBandListeners();
installActionListeners();
setEditableControlsDisabled(true);
document.documentElement.dataset.runState = "idle";
renderLiveControls();
try {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson(DEFAULT_DOCUMENT_URLS.arrayDefinition),
loadJson(DEFAULT_DOCUMENT_URLS.scene),
loadJson(DEFAULT_DOCUMENT_URLS.experiment),
]);
state.arrayDefinition = arrayDefinition;
state.scene = scene;
state.experiment = applyExperimentDefaults(experiment);
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
syncControls();
renderBandEditors();
renderDocumentSummary();
renderAssets();
renderLegend();
renderWorkload();
renderResult();
renderHistory();
renderSceneVisualization();
renderLiveControls();
syncUnsavedState();
await validateCurrent();
setEditableControlsDisabled(false);
renderBandEditors();
elements.runButton.disabled = !state.validation.valid;
document.documentElement.dataset.appReady = "true";
} catch (error) {
document.documentElement.dataset.appReady = "error";
setTransientError(error, "기본 fixture 초기화 실패");
}
}
initialize();