1925 lines
75 KiB
JavaScript
1925 lines
75 KiB
JavaScript
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();
|