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

1925 lines
75 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 { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import {
validateArrayDefinition,
validateExperiment,
validateResult,
validateScene,
} from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.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 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"),
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"),
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"),
fftSize: byId("fft-size"),
hopSize: byId("hop-size"),
normalization: byId("normalization"),
dynamicRange: byId("dynamic-range"),
detectionThreshold: byId("detection-threshold"),
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 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;
}
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.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;
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만 지원합니다.`;
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.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) {
if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`;
if (signal.type === "noise") return `${signal.color} noise`;
return `asset · ${signal.assetId}`;
}
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;
const detail = document.createElement("span");
detail.textContent =
`${describeSignal(source.signal)} · ` +
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` +
(source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : "");
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;
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}`);
if (type === "number") {
input.min = "0";
input.step = "1";
}
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();
if (!state.experiment) return;
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");
text.textContent = `${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`;
label.append(input, swatch, text);
elements.bandLegend.append(label);
}
}
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.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;
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 values = new Float32Array(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 };
}
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()}`;
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.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;
elements.metricGrid.replaceChildren();
elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed));
elements.metricGrid.classList.toggle("is-hidden", !displayed);
if (!displayed) {
elements.resultBadge.textContent = "아직 실행하지 않음";
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 합성`,
);
}
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)}%`,
"최강 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,
);
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,
);
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개 이상 권장합니다.");
}
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} · 실행 준비 완료`,
);
}
}
}
renderLiveControls();
}
function scheduleValidation() {
if (validationQueued) return;
validationQueued = true;
queueMicrotask(async () => {
validationQueued = false;
await validateCurrent();
});
}
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";
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();
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",
scan: snapshot.experiment.scan,
sources: sourcesForBand(snapshot.scene, definition),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
});
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 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",
scan: snapshot.experiment.scan,
sources: sourcesForBand(frameScene, definition, frame.sampleTime),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
}),
);
}
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,
};
setRunStatus(
`라이브 준비 완료 · ${timeline.frames.length} maps · ` +
`${formatNumber(timeline.timingMs.total, 0)} ms`,
);
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;
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("이동 음원 예제를 열었습니다. ‘라이브 준비’를 누르세요.");
} catch (error) {
setTransientError(error, "이동 예제 불러오기 실패");
} 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;
state.experiment.arrayId = value.id;
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 {
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;
state.experiment.arrayId = value.id;
} else if (kind === "scene") {
state.scene = value;
state.experiment.sceneId = value.id;
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();
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`);
} 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;
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.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();
});
}
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 nyquist = state.scene.sampleRate / 2;
const minHz = Math.min(Math.max(0, previous?.maxHz ?? 900), nyquist - 101);
const band = {
id: `band-${suffix}`,
minHz,
maxHz: Math.min(nyquist - 1, 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.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) 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();
});
}
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();