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

3457 lines
131 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 { ceilSampleIndex } from "/packages/acoustics-core/math.js";
import {
DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS,
DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES,
DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT,
estimateCfarActivityWorkload,
} from "/packages/acoustics-core/activity-monitor.js";
import {
estimateDixonSpectralFluxWorkload,
} from "/packages/acoustics-core/spectral-flux.js";
import { DEFAULT_MAX_COMPENSATION_DB } from "/packages/acoustics-core/frequency-response.js";
import {
localizationAlgorithmDefinition,
localizationAlgorithmLabel,
localizationAlgorithmSupportsBackend,
} from "/packages/acoustics-core/localization-algorithms.js";
import {
describeFrequencyAliasing,
MAX_PRECISE_ANALOG_FREQUENCY_HZ,
observeFrequencyBand,
} from "/packages/acoustics-core/sampling.js";
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import {
validateArrayDefinition,
validateExperiment,
validateResult,
validateScene,
} from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.js";
import { sourcesForObservedBand } from "./source-selection.js";
import {
applyAcousticProfile,
detectAcousticProfile,
maximumSparsePathsPerReceiver,
} from "./acoustic-profiles.js";
import {
createAnalysisFramePlan,
playbackFrameAtTime,
sceneHasTrajectories,
scenePlaybackState,
} from "./timeline.js";
import {
alignExperimentToScene,
canonicalStringify,
createComparisonSignature,
createHistorySummary,
createResultDocument,
estimateExperimentWorkload,
hashSimulationInputs,
safeFileStem,
shiftSceneDistance,
} from "./model.js";
import { createArrayVisualization, createSceneVisualization } from "./visualization.js";
const DEFAULT_DOCUMENT_URLS = {
arrayDefinition: "/assets/array-geometries/rectangular-16x12.array.json",
scene: "/assets/sample-scenes/single-tone.scene.json",
experiment: "/assets/sample-scenes/single-tone.experiment.json",
};
const MOVING_EXAMPLE_URLS = {
arrayDefinition: "/assets/array-geometries/rectangular-16x12.array.json",
scene: "/assets/sample-scenes/moving-tone.scene.json",
experiment: "/assets/sample-scenes/moving-tone.experiment.json",
};
const MUSIC_EXAMPLE_URLS = {
arrayDefinition:
"/assets/array-geometries/rectangular-4x3-450mm.array.json",
scene:
"/assets/sample-scenes/music-single-tone.scene.json",
experiment:
"/assets/sample-scenes/music-single-tone.experiment.json",
};
const ARRAY_PRESET_URLS = new Map([
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"],
["rectangular-2x2-450mm", "/assets/array-geometries/rectangular-2x2-450mm.array.json"],
["rectangular-4x3-450mm", "/assets/array-geometries/rectangular-4x3-450mm.array.json"],
["rectangular-8x6-450mm", "/assets/array-geometries/rectangular-8x6-450mm.array.json"],
["rectangular-24x16-450mm", "/assets/array-geometries/rectangular-24x16-450mm.array.json"],
["rectangular-16x12-compact", "/assets/array-geometries/rectangular-16x12-compact.array.json"],
["rectangular-16x12-large", "/assets/array-geometries/rectangular-16x12-large.array.json"],
]);
const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"];
const MAX_HISTORY_ITEMS = 24;
const MAX_LIVE_ANALYSIS_FRAMES = 48;
const MAX_LIVE_SCAN_POINTS = 2500;
const DEFAULT_DETECTION_THRESHOLD_RATIO = 0.25;
const byId = (id) => document.getElementById(id);
const elements = {
runStatus: byId("run-status"),
runButton: byId("run-button"),
cancelButton: byId("cancel-button"),
exportResultButton: byId("export-result-button"),
sceneInput: byId("scene-input"),
arrayInput: byId("array-input"),
experimentInput: byId("experiment-input"),
assetInput: byId("asset-input"),
workspaceTitle: byId("workspace-title"),
sceneMeta: byId("scene-meta"),
sceneCanvas: byId("scene-canvas"),
canvasMessage: byId("canvas-message"),
canvasBadge: byId("canvas-badge"),
bandLegend: byId("band-legend"),
overlayOpacity: byId("overlay-opacity"),
showBeamSpread: byId("show-beam-spread"),
showTruth: byId("show-truth"),
livePanel: document.querySelector(".live-panel"),
liveStatus: byId("live-status"),
loadMovingExampleButton: byId("load-moving-example-button"),
loadMusicExampleButton: byId("load-music-example-button"),
prepareLiveButton: byId("prepare-live-button"),
livePlayButton: byId("live-play-button"),
liveTime: byId("live-time"),
liveTimeLabel: byId("live-time-label"),
liveMapRate: byId("live-map-rate"),
liveSpeed: byId("live-speed"),
resultBadge: byId("result-badge"),
emptyResult: byId("empty-result"),
metricGrid: byId("metric-grid"),
clearHistoryButton: byId("clear-history-button"),
historyList: byId("history-list"),
arrayTitle: byId("array-title"),
microphoneCount: byId("microphone-count"),
arrayPreset: byId("array-preset"),
arrayCanvas: byId("array-canvas"),
arrayMeta: byId("array-meta"),
sourceSummary: byId("source-summary"),
assetList: byId("asset-list"),
experimentName: byId("experiment-name"),
experimentId: byId("experiment-id"),
sceneDistance: byId("scene-distance"),
speedOfSound: byId("speed-of-sound"),
sceneSeed: byId("scene-seed"),
acousticProfile: byId("acoustic-profile"),
acousticEnvironmentSummary: byId("acoustic-environment-summary"),
frequencyResponseMode: byId("frequency-response-mode"),
algorithm: byId("algorithm"),
backend: byId("backend"),
musicOptions: byId("music-options"),
musicSourceCountMethod: byId("music-source-count-method"),
musicSourceCount: byId("music-source-count"),
musicOptionsNote: byId("music-options-note"),
gridPreset: byId("grid-preset"),
scanColumns: byId("scan-columns"),
scanRows: byId("scan-rows"),
scanXMin: byId("scan-x-min"),
scanXMax: byId("scan-x-max"),
scanYMin: byId("scan-y-min"),
scanYMax: byId("scan-y-max"),
workloadSummary: byId("workload-summary"),
samplingWarning: byId("sampling-warning"),
fftSize: byId("fft-size"),
hopSize: byId("hop-size"),
normalization: byId("normalization"),
dynamicRange: byId("dynamic-range"),
detectionThreshold: byId("detection-threshold"),
detectionMethod: byId("detection-method"),
addBandButton: byId("add-band-button"),
bandList: byId("band-list"),
exportSceneButton: byId("export-scene-button"),
exportExperimentButton: byId("export-experiment-button"),
exportHistoryButton: byId("export-history-button"),
outputSaveState: byId("output-save-state"),
validationCard: document.querySelector(".validation-card"),
validationTitle: byId("validation-title"),
validationMessage: byId("validation-message"),
activityMonitor: byId("activity-monitor"),
activityAlgorithm: byId("activity-algorithm"),
activityMicrophone: byId("activity-microphone"),
activityCfarOptions: byId("activity-cfar-options"),
activityPfa: byId("activity-pfa"),
activityTraining: byId("activity-training"),
activityGuard: byId("activity-guard"),
activityRank: byId("activity-rank"),
activityFluxOptions: byId("activity-flux-options"),
activityPeakPickerEnabled: byId("activity-peak-picker-enabled"),
activityDelta: byId("activity-delta"),
activityPreMax: byId("activity-pre-max"),
activityPreMean: byId("activity-pre-mean"),
activityRefractory: byId("activity-refractory"),
activityRunButton: byId("activity-run-button"),
activityExportButton: byId("activity-export-button"),
activityStatus: byId("activity-status"),
activityFrame: byId("activity-frame"),
activityFrameLabel: byId("activity-frame-label"),
activityResultList: byId("activity-result-list"),
};
const layoutSplitPane = createSplitPane({
container: byId("camera-layout"),
separator: byId("camera-split-handle"),
panel: byId("camera-controls"),
panelSide: "start",
storageKey: "micarrsim.camera.control-panel-width",
defaultSize: 420,
minSize: 320,
maxSize: 720,
minMainSize: 560,
enabledMediaQuery: "(min-width: 981px)",
});
const state = {
arrayDefinition: null,
scene: null,
experiment: null,
revision: 0,
validationSequence: 0,
validation: { valid: false, issues: [] },
activityValidation: { 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,
activityRun: null,
activityDirty: 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 requireActivityNumber(element, label, {
integer = false,
min = -Infinity,
max = Infinity,
exclusiveMin = false,
} = {}) {
const value = element.valueAsNumber;
if (
!Number.isFinite(value) ||
(integer && !Number.isInteger(value)) ||
(exclusiveMin ? value <= min : value < min) ||
value > max
) {
const range = exclusiveMin
? min + "보다 크고 " + max + " 이하여야 합니다"
: min + " 이상 " + max + " 이하여야 합니다";
throw new RangeError(
label + "은 " + (integer ? "정수이며 " : "") + range + ".",
);
}
return value;
}
function readActivityConfiguration() {
const algorithm = elements.activityAlgorithm.value;
const channelIndex = Number(elements.activityMicrophone.value);
if (
!Number.isInteger(channelIndex) ||
channelIndex < 0 ||
channelIndex >= (state.arrayDefinition?.microphones?.length ?? 0)
) {
throw new RangeError("활동 검출에 사용할 물리 마이크를 선택하세요.");
}
if (algorithm === "ca-cfar" || algorithm === "os-cfar") {
if (state.experiment?.stft?.window !== "hann") {
throw new RangeError("CFAR 입력 STFT window는 hann이어야 합니다.");
}
const trainingCellsPerSide = requireActivityNumber(
elements.activityTraining,
"좌우 training cell 수",
{ integer: true, min: 1, max: 2047 },
);
const guardCellsPerSide = requireActivityNumber(
elements.activityGuard,
"좌우 guard cell 수",
{ integer: true, min: 0, max: 2047 },
);
const fftSize = state.experiment.stft.fftSize;
const nyquistBin = fftSize / 2;
if (
trainingCellsPerSide + guardCellsPerSide >
(nyquistBin - 2) / 2
) {
throw new RangeError(
"Training+guard 반경이 너무 커서 DC와 Nyquist 사이에 full symmetric CFAR reference window를 만들 수 없습니다.",
);
}
const sceneSampleCount = ceilSampleIndex(
state.scene.duration,
state.scene.sampleRate,
);
if (sceneSampleCount < fftSize) {
throw new RangeError(
"CFAR에는 padding하지 않은 complete frame 하나 이상이 필요합니다. Scene duration 또는 FFT size를 조정하세요.",
);
}
const workload = estimateCfarActivityWorkload({
sampleCount: sceneSampleCount,
fftSize,
hopSize: state.experiment.stft.hopSize,
});
if (workload.frameCount > DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT) {
throw new RangeError(
"CFAR complete frame 수 " + workload.frameCount +
"개가 hard cap " +
DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT + "개를 넘습니다.",
);
}
if (
workload.binDecisionCount >
DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS
) {
throw new RangeError(
"CFAR frame×bin 결정 수 " + workload.binDecisionCount +
"개가 hard cap " +
DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS + "개를 넘습니다.",
);
}
if (
workload.fftButterflyCount >
DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES
) {
throw new RangeError(
"CFAR FFT butterfly 수 " + workload.fftButterflyCount +
"개가 hard cap " +
DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES + "개를 넘습니다.",
);
}
const nominalFalseAlarmProbability = requireActivityNumber(
elements.activityPfa,
"명목 Pfa",
{ min: 0, max: 1, exclusiveMin: true },
);
if (nominalFalseAlarmProbability >= 1) {
throw new RangeError("명목 Pfa는 0보다 크고 1보다 작아야 합니다.");
}
const configuration = {
algorithm,
channelIndex,
trainingCellsPerSide,
guardCellsPerSide,
nominalFalseAlarmProbability,
};
if (algorithm === "os-cfar") {
configuration.rank = requireActivityNumber(
elements.activityRank,
"OS rank",
{
integer: true,
min: 1,
max: 2 * trainingCellsPerSide,
},
);
}
return configuration;
}
if (algorithm !== "dixon-spectral-flux") {
throw new RangeError("알 수 없는 활동 검출 알고리즘입니다.");
}
const configuration = {
algorithm,
channelIndex,
};
estimateDixonSpectralFluxWorkload({
sampleCount: ceilSampleIndex(
state.scene.duration,
state.scene.sampleRate,
),
fftSize: state.experiment.stft.fftSize,
hopSize: state.experiment.stft.hopSize,
});
if (elements.activityPeakPickerEnabled.checked) {
configuration.peakPicker = {
delta: requireActivityNumber(elements.activityDelta, "Spectral flux δ", {
min: 0,
max: Number.MAX_VALUE,
}),
preMaxFrames: requireActivityNumber(
elements.activityPreMax,
"과거 local-max frame 수",
{ integer: true, min: 0, max: 4095 },
),
preMeanFrames: requireActivityNumber(
elements.activityPreMean,
"과거 mean frame 수",
{ integer: true, min: 0, max: 4095 },
),
refractoryFrames: requireActivityNumber(
elements.activityRefractory,
"refractory frame 수",
{ integer: true, min: 0, max: 4095 },
),
};
}
return configuration;
}
function activityInputSignature(snapshot, activityConfig) {
const selectedMicrophone =
snapshot.arrayDefinition.microphones[activityConfig.channelIndex];
const audioAssetIds = new Set();
for (const entry of [
...snapshot.scene.sources,
...(snapshot.scene.ambient ?? []),
]) {
if (entry.signal?.type === "asset") {
audioAssetIds.add(entry.signal.assetId);
}
}
const {
image: _backgroundImage,
assets: sceneAssets = [],
...acousticScene
} = snapshot.scene;
return canonicalStringify({
selectedPhysicalMicrophone: {
arrayUnits: snapshot.arrayDefinition.units,
coordinateSystem: snapshot.arrayDefinition.coordinateSystem,
index: activityConfig.channelIndex,
microphone: selectedMicrophone,
},
scene: {
...acousticScene,
assets: sceneAssets.filter((asset) => audioAssetIds.has(asset.id)),
},
analysis: {
stft: snapshot.experiment.stft,
minimumDistance: snapshot.experiment.beamforming.minimumDistance,
activityConfig,
},
});
}
async function sha256Text(value) {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function currentActivitySignature() {
if (!state.arrayDefinition || !state.scene || !state.experiment) return "";
try {
return activityInputSignature(
{
arrayDefinition: state.arrayDefinition,
scene: state.scene,
experiment: state.experiment,
},
readActivityConfiguration(),
);
} catch {
return "";
}
}
function activityRunIsStale() {
return Boolean(
state.activityRun &&
state.activityRun.inputSignature !== currentActivitySignature()
);
}
function plainActivityValue(value) {
if (typeof value === "number" && !Number.isFinite(value)) {
if (value === Number.POSITIVE_INFINITY) return "Infinity";
if (value === Number.NEGATIVE_INFINITY) return "-Infinity";
return "NaN";
}
if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
return Array.from(value, plainActivityValue);
}
if (Array.isArray(value)) return value.map(plainActivityValue);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
plainActivityValue(item),
]),
);
}
return value;
}
function createActivityReportDocument(activityRun) {
const { snapshot, workerReport, inputHash } = activityRun;
return {
schemaVersion: "1.0.0",
kind: "micarrsim.activity-report",
createdAt: activityRun.createdAt,
arrayId: snapshot.arrayDefinition.id,
sceneId: snapshot.scene.id,
experimentId: snapshot.experiment.id,
inputHash,
provenanceOnlyIds: true,
detectorSceneTruthInput: false,
report: plainActivityValue(workerReport),
};
}
function formatIssue(issue) {
return `${issue.path}: ${issue.message}`;
}
function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) {
return createComparisonSignature(scene, sourceExperiment);
}
function applyExperimentDefaults(experiment) {
if (!experiment.evaluation) experiment.evaluation = {};
if (experiment.evaluation.detectionThresholdRatio === undefined) {
experiment.evaluation.detectionThresholdRatio = DEFAULT_DETECTION_THRESHOLD_RATIO;
}
if (experiment.evaluation.detectionMethod === undefined) {
experiment.evaluation.detectionMethod = "local-max";
}
if (experiment.beamforming?.algorithm === "music") {
experiment.beamforming.musicSourceCountMethod ??= "fixed";
experiment.beamforming.musicSourceCount ??= 1;
}
return experiment;
}
function setRunStatus(message) {
elements.runStatus.textContent = message;
}
function unsavedWorkLabels() {
const labels = [];
if (state.sceneDirty) labels.push("Scene");
if (state.experimentDirty) labels.push("Experiment");
if (state.resultDirty) labels.push("Result");
if (state.activityDirty) labels.push("Activity report");
if (state.history.some((item) => !item.comparisonSaved)) labels.push("비교 기록");
return labels;
}
function syncUnsavedState() {
const labels = unsavedWorkLabels();
document.documentElement.dataset.unsavedWork = labels.join(",");
elements.outputSaveState.textContent = labels.length > 0
? `미저장 · ${labels.join(" · ")}`
: "저장하지 않은 변경 없음";
elements.outputSaveState.classList.toggle("is-dirty", labels.length > 0);
elements.exportHistoryButton.disabled = state.history.length === 0;
}
function hasUnsavedWork() {
return Boolean(state.active) || unsavedWorkLabels().length > 0;
}
function setTransientError(error, prefix = "오류") {
const message = error instanceof Error ? error.message : String(error);
setRunStatus(`${prefix} · ${message}`);
elements.validationCard.classList.remove("is-valid", "is-warning");
elements.validationCard.classList.add("is-invalid");
elements.validationTitle.textContent = prefix;
elements.validationMessage.textContent = message;
}
function stopLivePlayback(render = true) {
const live = state.live;
if (!live) return;
if (live.animationFrameId) cancelAnimationFrame(live.animationFrameId);
live.animationFrameId = 0;
live.playing = false;
live.playbackStartedAt = null;
if (render) renderLiveControls();
}
function resetLiveTimeline() {
stopLivePlayback(false);
state.live = null;
state.liveError = null;
renderLiveControls();
}
function renderLiveControls() {
const hasTrajectory = sceneHasTrajectories(state.scene);
const ready = Boolean(state.live?.frames?.length);
const preparing = state.active?.kind === "live";
const playing = Boolean(ready && state.live.playing);
const locked = Boolean(state.active) || state.pendingInputOperations > 0;
elements.loadMovingExampleButton.disabled = locked;
elements.loadMusicExampleButton.disabled = locked;
elements.prepareLiveButton.disabled = locked || !hasTrajectory || !state.validation.valid;
elements.livePlayButton.disabled = locked || !ready;
elements.liveTime.disabled = locked || !ready;
elements.liveMapRate.disabled = locked;
elements.liveSpeed.disabled = locked || !ready;
elements.livePlayButton.textContent = playing ? "❚❚ 일시정지" : "▶ 재생";
elements.livePanel.classList.toggle("is-ready", ready);
elements.livePanel.classList.toggle("is-playing", playing);
if (ready) {
const time = Math.min(state.live.plan.duration, Math.max(0, state.live.time));
elements.liveTime.max = String(state.live.plan.duration);
elements.liveTime.value = String(time);
elements.liveTimeLabel.textContent =
`${formatNumber(time, 2)} / ${formatNumber(state.live.plan.duration, 2)} s`;
elements.liveStatus.textContent =
`${state.live.frames.length}개 acoustic map · ${formatNumber(state.live.plan.effectiveMapHz, 1)} Hz · ` +
`${state.live.snapshot.experiment.scan.columns}×${state.live.snapshot.experiment.scan.rows}` +
`${state.live.reducedScan ? " preview" : ""} · ${state.live.backend.toUpperCase()}`;
} else {
elements.liveTime.max = String(state.scene?.duration ?? 0);
elements.liveTime.value = "0";
elements.liveTimeLabel.textContent = `0.00 / ${formatNumber(state.scene?.duration ?? 0, 2)} s`;
if (preparing) {
elements.liveStatus.textContent = "이동 음원의 시간창 acoustic map을 계산하고 있습니다…";
} else if (state.liveError) {
elements.liveStatus.textContent = `라이브 준비 실패 · ${state.liveError}`;
} else if (hasTrajectory) {
elements.liveStatus.textContent = "이동 경로를 찾았습니다. ‘라이브 준비’를 누르면 시간축 map을 계산합니다.";
} else {
elements.liveStatus.textContent = "trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.";
}
}
let liveState = "unavailable";
if (hasTrajectory) liveState = "idle";
if (state.liveError) liveState = "error";
if (ready) liveState = "ready";
if (playing) liveState = "playing";
if (preparing) liveState = "preparing";
document.documentElement.dataset.liveState = liveState;
document.documentElement.dataset.liveFrameCount = ready ? String(state.live.frames.length) : "0";
document.documentElement.dataset.livePlanAnalyses = ready
? String(state.live.frames.at(-1)?.run.analysisCache?.completedAnalyses ?? 0)
: "0";
document.documentElement.dataset.liveTime = ready ? String(state.live.time) : "0";
}
function setConfigurationLocked(locked) {
setEditableControlsDisabled(locked);
elements.runButton.disabled = locked || !state.validation.valid;
elements.cancelButton.classList.toggle("is-hidden", !locked);
elements.exportResultButton.disabled =
locked || !state.lastRun?.resultDocument;
renderLiveControls();
renderActivityControls();
}
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();
renderActivityControls();
}
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();
renderActivityControls();
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();
renderActivityResult();
syncUnsavedState();
scheduleValidation();
}
function syncAlgorithmBackendControls() {
const algorithm = state.experiment?.beamforming?.algorithm;
const definition = localizationAlgorithmDefinition(algorithm);
const webGpuOption = elements.backend.querySelector('option[value="webgpu"]');
const autoOption = elements.backend.querySelector('option[value="auto"]');
const supportsWebGpu = definition?.backends.includes("webgpu") ?? true;
webGpuOption.disabled = !supportsWebGpu;
webGpuOption.title = supportsWebGpu ? "" : `${definition?.label ?? algorithm}는 현재 CPU에서만 실행됩니다.`;
autoOption.textContent = supportsWebGpu ? "Auto (GPU→CPU)" : "Auto (CPU)";
elements.backend.title = supportsWebGpu
? "지원되는 GPU를 우선 사용하고 필요하면 CPU로 전환합니다."
: `${definition?.label ?? algorithm}는 Auto와 CPU만 지원합니다.`;
const music = algorithm === "music";
const sourceCountMethod =
state.experiment?.beamforming?.musicSourceCountMethod ?? "fixed";
const maximumSourceCount = Math.max(
1,
(state.arrayDefinition?.microphones?.length ?? 2) - 1,
);
elements.musicOptions.hidden = !music;
elements.musicSourceCountMethod.value = sourceCountMethod;
elements.musicSourceCount.max = String(maximumSourceCount);
elements.musicSourceCount.value = String(
state.experiment?.beamforming?.musicSourceCount ?? 1,
);
elements.musicSourceCount.disabled = sourceCountMethod !== "fixed";
elements.musicOptionsNote.textContent = sourceCountMethod === "fixed"
? `각 band는 아날로그 FFT-grid 가설과 ADC bin을 각각 하나만 선택합니다. K는 1–${maximumSourceCount} 범위입니다. 같은 주파수·고정 위상의 coherent 음원은 standard MUSIC으로 분리되지 않으며 raw q는 확률이 아닙니다.`
: "MDL/AIC는 IID complex-Gaussian snapshot·spatially white noise·일정한 source count를 가정합니다. 비중첩 frame은 독립성의 증명이 아니며, 결과 K는 확률/신뢰도가 아닙니다.";
elements.showBeamSpread.disabled = music;
elements.showBeamSpread.title = music
? "MUSIC noise-projection에는 DAS/PHAT의 half-power beam spread를 적용하지 않습니다."
: "점선은 1× RMS, 실선 영역은 2× RMS 빔 응답 분포입니다. 확률 또는 신뢰구간은 아닙니다.";
document.documentElement.dataset.localizationAlgorithm = algorithm ?? "";
}
function syncControls() {
const { scene, experiment } = state;
if (!scene || !experiment) return;
elements.experimentName.value = experiment.name;
elements.experimentId.value = experiment.id;
elements.sceneDistance.value = experiment.scan.z;
elements.speedOfSound.value = scene.speedOfSound;
elements.sceneSeed.value = scene.seed;
elements.acousticProfile.value = detectAcousticProfile(scene);
elements.frequencyResponseMode.value =
experiment.beamforming.compensateFrequencyResponse === false ? "raw" : "compensate";
elements.algorithm.value = experiment.beamforming.algorithm;
elements.backend.value = experiment.beamforming.backend;
syncAlgorithmBackendControls();
elements.scanColumns.value = experiment.scan.columns;
elements.scanRows.value = experiment.scan.rows;
elements.scanXMin.value = experiment.scan.xMin;
elements.scanXMax.value = experiment.scan.xMax;
elements.scanYMin.value = experiment.scan.yMin;
elements.scanYMax.value = experiment.scan.yMax;
elements.fftSize.value = experiment.stft.fftSize;
elements.hopSize.value = experiment.stft.hopSize;
elements.normalization.value = experiment.beamforming.normalization;
elements.dynamicRange.value = experiment.beamforming.dynamicRangeDb;
const detectionThresholdRatio =
experiment.evaluation?.detectionThresholdRatio ?? DEFAULT_DETECTION_THRESHOLD_RATIO;
elements.detectionThreshold.value = String(Number((detectionThresholdRatio * 100).toFixed(4)));
elements.detectionMethod.value = experiment.evaluation?.detectionMethod ?? "local-max";
elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id)
? state.arrayDefinition.id
: "custom";
elements.gridPreset.value =
experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows)
? String(experiment.scan.rows)
: "custom";
renderActivityResult();
}
function renderAcousticSummary() {
if (!state.scene || !state.arrayDefinition || !state.experiment) return;
const profile = detectAcousticProfile(state.scene);
const profileLabels = {
"free-field": "자유음장",
"treated-room": "흡음된 실내",
"typical-room": "일반 실내",
"reflective-room": "반사 강한 실내",
custom: "Scene 사용자 환경",
};
const responseCurveCount = state.arrayDefinition.microphones.filter(
(microphone) => microphone.frequencyResponse?.length >= 2,
).length;
const maxCompensationDb =
state.experiment.beamforming.maxCompensationDb ?? DEFAULT_MAX_COMPENSATION_DB;
const responseMode =
state.experiment.beamforming.compensateFrequencyResponse === false
? "응답 역보정 꺼짐"
: "알려진 응답 역보정 ≤ " + formatNumber(maxCompensationDb, 0) + " dB";
elements.acousticEnvironmentSummary.textContent =
profileLabels[profile] + " · 수신점당 최대 " +
maximumSparsePathsPerReceiver(state.scene) + "개 sparse path · 응답 curve " +
responseCurveCount + "/" + state.arrayDefinition.microphones.length + " · " + responseMode;
document.documentElement.dataset.acousticProfile = profile;
}
function describeSignal(signal, sampleRate) {
if (signal.type === "tone") {
const observation = describeFrequencyAliasing(signal.frequency, sampleRate);
const base = `${formatNumber(signal.frequency, 0)} Hz tone`;
const edge = observation.dcEdge
? " · DC phase-degenerate edge"
: observation.nyquistEdge
? " · Nyquist phase-degenerate edge"
: "";
if (observation.aliased) {
const direction = observation.spectralInversion ? "−" : "+";
return (
`${base} → ADC ${formatNumber(observation.adcFrequencyHz, 0)} Hz alias ` +
`(signed ${direction}${formatNumber(observation.adcFrequencyHz, 0)} Hz)${edge}`
);
}
return base + edge;
}
if (signal.type === "noise") return `${signal.color} noise`;
return `asset · ${signal.assetId}`;
}
function sceneToneObservations(scene) {
const entries = [
...(scene.sources ?? []).map((entry) => ({ scope: "source", entry })),
...(scene.ambient ?? []).map((entry) => ({ scope: "ambient", entry })),
];
const observations = [];
for (const { scope, entry } of entries) {
if (entry.signal?.type !== "tone") continue;
try {
observations.push({
scope,
id: entry.id,
...describeFrequencyAliasing(entry.signal.frequency, scene.sampleRate),
});
} catch {
// Validation owns malformed inputs; rendering must remain available while editing.
}
}
return observations;
}
function aliasedToneContributions(toneObservations, bandObservation) {
const selectedBins = new Set(bandObservation.bins);
return toneObservations.filter((tone) =>
tone.aliased &&
selectedBins.has(Math.round(tone.adcFrequencyHz / bandObservation.binWidthHz)));
}
function describeToneFold(tone) {
return (
`${tone.scope} ${tone.id} ` +
`${formatNumber(tone.analogFrequencyHz, 0)}→ADC ` +
`${formatNumber(tone.adcFrequencyHz, 0)} Hz`
);
}
function renderDocumentSummary() {
const { arrayDefinition, scene, experiment } = state;
if (!arrayDefinition || !scene || !experiment) return;
renderAcousticSummary();
elements.workspaceTitle.textContent = scene.name;
elements.sceneMeta.textContent =
`${scene.sampleRate.toLocaleString("ko-KR")} Hz · ${formatNumber(scene.duration, 3)} s · ` +
`${scene.sources.length} source · scan z ${formatNumber(experiment.scan.z, 2)} m`;
elements.arrayTitle.textContent = arrayDefinition.name;
elements.microphoneCount.textContent = `${arrayDefinition.microphones.length} ch`;
elements.arrayMeta.textContent =
`${arrayDefinition.id} · ${formatNumber(arrayDefinition.aperture.width, 3)} × ` +
`${formatNumber(arrayDefinition.aperture.height, 3)} m`;
elements.sourceSummary.replaceChildren();
for (const source of scene.sources) {
const line = document.createElement("div");
line.className = "source-line";
const name = document.createElement("strong");
name.textContent = source.name;
name.title = source.name;
const detail = document.createElement("span");
detail.textContent =
`${describeSignal(source.signal, scene.sampleRate)} · ` +
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` +
(source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : "");
detail.title = detail.textContent;
line.append(name, detail);
elements.sourceSummary.append(line);
}
arrayVisualization.render({ arrayDefinition });
}
function renderAssets() {
elements.assetList.replaceChildren();
if (!state.scene) return;
const statuses = assetRuntime.statuses(state.scene);
if (statuses.length === 0) {
const empty = document.createElement("p");
empty.className = "asset-empty";
empty.textContent = "이 장면은 외부 asset 없이 바로 실행됩니다.";
elements.assetList.append(empty);
return;
}
for (const status of statuses) {
const row = document.createElement("div");
row.className = `asset-status ${status.ready ? "is-ready" : status.error ? "is-error" : ""}`;
const label = document.createElement("strong");
label.textContent = status.descriptor.fileName;
label.title = status.descriptor.fileName;
const value = document.createElement("span");
value.textContent = status.ready ? "연결됨" : status.error ? "오류" : "파일 필요";
value.title = status.error ?? "";
row.append(label, value);
elements.assetList.append(row);
}
}
function renderBandEditors() {
elements.bandList.replaceChildren();
if (!state.experiment) return;
state.experiment.frequencyBands.forEach((band, index) => {
const editor = document.createElement("div");
editor.className = "band-editor";
const fields = [
["id", "text", band.id, "Band ID"],
["minHz", "number", band.minHz, "Min Hz"],
["maxHz", "number", band.maxHz, "Max Hz"],
["color", "color", band.color, "Color"],
];
for (const [key, type, value, label] of fields) {
const input = document.createElement("input");
input.type = type;
input.value = value;
input.dataset.bandIndex = String(index);
input.dataset.bandKey = key;
input.setAttribute("aria-label", `${band.id} ${label}`);
input.title = label;
if (type !== "color") input.placeholder = label;
if (type === "number") {
input.min = "0";
input.max = String(MAX_PRECISE_ANALOG_FREQUENCY_HZ);
input.step = "1";
input.title = `${label} · 0–1 MHz verified analog range`;
}
editor.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "band-editor__remove";
remove.dataset.removeBand = String(index);
remove.textContent = "×";
remove.title = "대역 삭제";
remove.disabled = state.experiment.frequencyBands.length === 1 || Boolean(state.active);
editor.append(remove);
elements.bandList.append(editor);
});
elements.addBandButton.disabled = state.experiment.frequencyBands.length >= 8 || Boolean(state.active);
}
function syncActivityMicrophones() {
const microphones = state.arrayDefinition?.microphones ?? [];
const previous = elements.activityMicrophone.value;
const optionKey = canonicalStringify(
microphones.map(({ id, position }) => ({ id, position })),
);
if (elements.activityMicrophone.dataset.optionKey !== optionKey) {
elements.activityMicrophone.replaceChildren();
microphones.forEach((microphone, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent =
(index + 1) + " · " + microphone.id +
" · [" + microphone.position.map((value) => formatNumber(value, 3)).join(", ") + "] m";
elements.activityMicrophone.append(option);
});
elements.activityMicrophone.dataset.optionKey = optionKey;
elements.activityMicrophone.value =
microphones[Number(previous)] ? previous : "0";
}
}
function renderActivityControls() {
syncActivityMicrophones();
const algorithm = elements.activityAlgorithm.value;
const cfar = algorithm === "ca-cfar" || algorithm === "os-cfar";
const os = algorithm === "os-cfar";
const flux = algorithm === "dixon-spectral-flux";
const peakPicker = flux && elements.activityPeakPickerEnabled.checked;
const locked = Boolean(state.active) || state.pendingInputOperations > 0;
elements.activityCfarOptions.hidden = !cfar;
elements.activityFluxOptions.hidden = !flux;
const cfarNote = elements.activityMonitor.querySelector(
".activity-monitor__note--cfar",
);
const fluxNote = elements.activityMonitor.querySelector(
".activity-monitor__note--flux",
);
if (cfarNote) cfarNote.hidden = !cfar;
if (fluxNote) fluxNote.hidden = !flux;
elements.activityRank.disabled = locked || !os;
elements.activityDelta.disabled = locked || !peakPicker;
elements.activityPreMax.disabled = locked || !peakPicker;
elements.activityPreMean.disabled = locked || !peakPicker;
elements.activityRefractory.disabled = locked || !peakPicker;
elements.activityRunButton.disabled =
locked || !state.activityValidation.valid || !state.arrayDefinition;
elements.activityExportButton.disabled =
locked || !state.activityRun;
elements.activityFrame.disabled =
locked || !state.activityRun;
const training = Number(elements.activityTraining.value);
if (Number.isInteger(training) && training > 0) {
elements.activityRank.max = String(2 * training);
}
}
function appendActivityMessage(text, className = "activity-message") {
const message = document.createElement("p");
message.className = className;
message.textContent = text;
elements.activityResultList.append(message);
return message;
}
function formatActivityStatistic(value) {
if (value === null || value === undefined) return "—";
if (!Number.isFinite(value)) return String(value);
if (value === 0) return "0";
if (Math.abs(value) >= 1e4 || Math.abs(value) < 1e-3) {
return value.toExponential(4);
}
return value.toPrecision(6);
}
function setActivityFrameBounds(frameCount) {
const maximum = Math.max(0, frameCount - 1);
elements.activityFrame.max = String(maximum);
let frameIndex = Number(elements.activityFrame.value);
if (!Number.isInteger(frameIndex) || frameIndex < 0 || frameIndex > maximum) {
frameIndex = 0;
elements.activityFrame.value = "0";
}
return frameIndex;
}
function renderCfarActivityResult(workerReport) {
const analysis = workerReport.analysis;
const frameIndex = setActivityFrameBounds(analysis.frames.length);
const frame = analysis.frames[frameIndex];
const model = analysis.detectorModel;
const stale = activityRunIsStale();
elements.activityFrameLabel.textContent =
"frame " + (frameIndex + 1) + "/" + analysis.frames.length +
" · start " +
formatNumber(frame.frameStartSample / workerReport.input.sampleRate, 4) +
" s · available " +
formatNumber(
frame.frameAvailableAfterSample / workerReport.input.sampleRate,
4,
) +
" s";
appendActivityMessage(
(workerReport.algorithm === "os-cfar" ? "OS-CFAR" : "CA-CFAR") +
" · " + workerReport.channel.id +
" · complete frame " + analysis.summary.frameCount + "개" +
" · detected bin 결정 " + analysis.summary.detectedBinCount + "개" +
(stale ? " · 현재 입력/검출 설정과 다름" : ""),
stale ? "activity-message is-stale" : "activity-message",
);
appendActivityMessage(
"Pfa " + formatActivityStatistic(
model.parameters.nominalPfaPerTestedCutUnderIidExponentialModel,
) +
"는 IID exponential 모델의 tested CUT 하나당 명목값입니다. " +
"실측 확률·confidence·frame 전체 오경보율이 아닙니다.",
"activity-caveat",
);
appendActivityMessage(
"선택 frame · tested " + frame.testedBinCount +
" · untested " + frame.untestedBinCount +
" · detected " + frame.detectedBinCount +
". 아래에는 이 frame의 모든 detected bin을 병합 없이 표시합니다.",
"activity-frame-summary",
);
const testedBins = frame.bins.filter((bin) => bin.status !== "untested");
appendActivityMessage(
testedBins.length > 0
? "CFAR tested 범위 " +
formatNumber(testedBins[0].adcCenterFrequencyHz, 2) + "–" +
formatNumber(testedBins.at(-1).adcCenterFrequencyHz, 2) +
" Hz. DC·Nyquist와 좌우 full reference window가 부족한 edge bin은 줄이거나 wrap하지 않고 untested입니다."
: "이 설정에서는 full symmetric reference window를 만들 수 없어 CFAR tested bin이 없습니다. DC·Nyquist를 포함한 모든 bin은 raw 상태로만 보존됩니다.",
"activity-caveat",
);
const detected = frame.bins.filter((bin) => bin.status === "detected");
if (detected.length === 0) {
appendActivityMessage(
"이 frame에는 threshold를 엄격히 초과한 ADC bin이 없습니다. raw/untested 항목은 Activity JSON에 모두 남아 있습니다.",
"activity-empty",
);
return;
}
for (const bin of detected) {
const row = document.createElement("div");
row.className = "activity-result-row";
const heading = document.createElement("strong");
heading.textContent =
"ADC bin " + bin.adcBin + " · " +
formatNumber(bin.adcCenterFrequencyHz, 2) + " Hz";
const raw = document.createElement("span");
raw.textContent =
"CUT " + formatActivityStatistic(bin.cutPower) +
" · ref " + formatActivityStatistic(bin.referencePower) +
" · threshold " + formatActivityStatistic(bin.thresholdPower) +
" · ratio " + (
bin.thresholdRatio === null
? bin.thresholdRatioStatus
: formatActivityStatistic(bin.thresholdRatio)
);
const hypothesis = document.createElement("label");
hypothesis.className = "activity-hypothesis";
const label = document.createElement("span");
label.textContent = "아날로그 center 가설 (Hz)";
const input = document.createElement("input");
input.type = "number";
input.min = "0";
input.max = String(MAX_PRECISE_ANALOG_FREQUENCY_HZ);
input.step = String(model.spectrum.binWidthHz);
input.value = String(bin.adcCenterFrequencyHz);
input.dataset.activityAnalogHypothesis = "true";
input.dataset.activityFrameIndex = String(frameIndex);
input.dataset.activityBin = String(bin.adcBin);
const add = document.createElement("button");
add.type = "button";
add.className = "mini-button";
add.dataset.addActivityBand = "true";
add.dataset.activityFrameIndex = String(frameIndex);
add.dataset.activityBin = String(bin.adcBin);
add.textContent = "이 가설을 대역으로 추가";
add.disabled = stale;
add.title = stale
? "현재 입력 또는 검출 설정과 다른 결과입니다. 다시 검사한 뒤 추가하세요."
: "이 ADC 관측에 대응하는 명시적 analog carrier 가설을 추가합니다.";
hypothesis.append(label, input, add);
row.append(heading, raw, hypothesis);
elements.activityResultList.append(row);
}
}
function renderFluxActivityResult(workerReport) {
const analysis = workerReport.analysis;
if (analysis.frameCount === 0) {
setActivityFrameBounds(0);
elements.activityFrameLabel.textContent = "complete frame 0개";
appendActivityMessage(
"현재 sample 길이가 FFT size보다 짧아 Dixon spectral flux를 계산할 완전한 frame이 없습니다. padding한 값을 만들지 않았습니다.",
"activity-empty",
);
return;
}
const frameIndex = setActivityFrameBounds(analysis.frameCount);
const stale = activityRunIsStale();
const frameTime = analysis.frameStartSamples[frameIndex] / analysis.sampleRate;
elements.activityFrameLabel.textContent =
"frame " + (frameIndex + 1) + "/" + analysis.frameCount +
" · start " + formatNumber(frameTime, 4) +
" s · available " +
formatNumber(
analysis.frameAvailableAfterSamples[frameIndex] / analysis.sampleRate,
4,
) +
" s";
appendActivityMessage(
"Dixon spectral flux · " + workerReport.channel.id +
" · raw onset novelty frame " + analysis.frameCount + "개" +
(stale ? " · 현재 입력/검출 설정과 다름" : ""),
stale ? "activity-message is-stale" : "activity-message",
);
appendActivityMessage(
"선택 frame raw SF = " + formatActivityStatistic(analysis.rawOdf[frameIndex]) +
" · availability " +
formatNumber(
analysis.frameAvailableAfterSamples[frameIndex] / analysis.sampleRate,
4,
) +
" s. 이 값은 probability·confidence·지속 activity가 아닙니다.",
"activity-frame-summary",
);
if (!workerReport.peakPicking) {
appendActivityMessage(
"Peak picker를 적용하지 않아 임의 event threshold를 만들지 않았습니다. raw ODF와 모든 per-bin 양의 차이는 Activity JSON에 보존됩니다.",
"activity-caveat",
);
return;
}
const picking = workerReport.peakPicking;
appendActivityMessage(
"Böck–Krebs–Schedl causal picker · δ " +
formatActivityStatistic(picking.configuration.delta) +
" · event " + picking.peaks.length +
"개. δ는 사용자가 보정한 operating offset입니다.",
"activity-caveat",
);
const selectedIsPeak = Boolean(picking.peakMask[frameIndex]);
appendActivityMessage(
"선택 frame threshold " +
formatActivityStatistic(picking.adaptiveThresholds[frameIndex]) +
" · " + (selectedIsPeak ? "onset event" : "event 아님"),
selectedIsPeak ? "activity-event-selected" : "activity-frame-summary",
);
if (picking.peaks.length === 0) {
appendActivityMessage("설정한 causal threshold를 통과한 event가 없습니다.", "activity-empty");
return;
}
for (const peak of picking.peaks) {
const button = document.createElement("button");
button.type = "button";
button.className = "activity-event-button";
button.dataset.activityViewFrame = String(peak.frameIndex);
button.textContent =
"start " + formatNumber(
analysis.frameStartSamples[peak.frameIndex] / analysis.sampleRate,
4,
) +
" s · available " +
formatNumber(
analysis.frameAvailableAfterSamples[peak.frameIndex] /
analysis.sampleRate,
4,
) +
" s · SF " + formatActivityStatistic(peak.rawOdf) +
" · threshold " + formatActivityStatistic(peak.adaptiveThreshold);
elements.activityResultList.append(button);
}
}
function renderActivityResult() {
elements.activityResultList.replaceChildren();
const activityRun = state.activityRun;
if (!activityRun) {
elements.activityFrame.max = "0";
elements.activityFrame.value = "0";
elements.activityFrameLabel.textContent = "검출 결과 없음";
elements.activityStatus.textContent =
"위치추정과 별도로 ADC 0–Fs/2 spectrum을 만듭니다. Scene의 음원 정답은 검출기에 전달하지 않습니다.";
document.documentElement.dataset.activityState = "idle";
document.documentElement.dataset.activityDetectedCount = "0";
renderActivityControls();
return;
}
const workerReport = activityRun.workerReport;
if (
workerReport.algorithm === "ca-cfar" ||
workerReport.algorithm === "os-cfar"
) {
renderCfarActivityResult(workerReport);
document.documentElement.dataset.activityDetectedCount =
String(workerReport.analysis.summary.detectedBinCount);
} else {
renderFluxActivityResult(workerReport);
document.documentElement.dataset.activityDetectedCount =
String(workerReport.peakPicking?.peaks?.length ?? 0);
}
if (workerReport.signalStats.preprocessing?.futureDependentNormalization) {
appendActivityMessage(
"Scene의 peak normalization은 선택한 물리 마이크 한 채널의 전체 render peak로 먼저 scale합니다. detector는 그 뒤에는 causal이지만 raw spectral flux와 δ event를 포함한 end-to-end 전처리는 미래 sample에 의존합니다.",
"activity-caveat",
);
}
const stale = activityRunIsStale();
elements.activityStatus.textContent =
(stale ? "과거 설정 결과 · " : "") +
workerReport.algorithm + " · " +
formatNumber(workerReport.timingMs.total, 0) + " ms · hash " +
activityRun.inputHash.slice(0, 10);
document.documentElement.dataset.activityState = stale ? "stale" : "done";
document.documentElement.dataset.activityAlgorithm = workerReport.algorithm;
renderActivityControls();
}
function addActivityBand(button) {
const activityRun = state.activityRun;
const workerReport = activityRun?.workerReport;
if (
!workerReport ||
(workerReport.algorithm !== "ca-cfar" &&
workerReport.algorithm !== "os-cfar")
) return;
if (activityRunIsStale()) {
setTransientError(
new Error("현재 입력 또는 검출 설정과 다른 Activity 결과입니다. 다시 검사한 뒤 대역을 추가하세요."),
"활동 후보 추가 실패",
);
return;
}
if (state.experiment.frequencyBands.length >= 8) {
setTransientError(
new RangeError("Experiment band는 최대 8개입니다. 기존 band를 정리한 뒤 추가하세요."),
"활동 후보 추가 실패",
);
return;
}
const frameIndex = Number(button.dataset.activityFrameIndex);
const adcBin = Number(button.dataset.activityBin);
const input = elements.activityResultList.querySelector(
'input[data-activity-analog-hypothesis="true"]' +
'[data-activity-frame-index="' + frameIndex + '"]' +
'[data-activity-bin="' + adcBin + '"]',
);
const analogCenterHz = input?.valueAsNumber;
const sampleRate = workerReport.input.sampleRate;
const fftSize = workerReport.input.fftSize;
const binWidthHz = sampleRate / fftSize;
if (
!Number.isFinite(analogCenterHz) ||
analogCenterHz <= 0 ||
analogCenterHz > MAX_PRECISE_ANALOG_FREQUENCY_HZ
) {
setTransientError(
new RangeError("아날로그 center 가설은 0보다 크고 1 MHz 이하여야 합니다."),
"활동 후보 추가 실패",
);
return;
}
const analogGridBin = Math.round(analogCenterHz / binWidthHz);
const gridErrorHz = Math.abs(analogCenterHz - analogGridBin * binWidthHz);
const observation = describeFrequencyAliasing(analogCenterHz, sampleRate);
const observedBin = Math.round(observation.adcFrequencyHz / binWidthHz);
if (
gridErrorHz > 1e-8 * Math.max(1, binWidthHz) ||
observedBin !== adcBin
) {
setTransientError(
new RangeError(
"가설은 FFT grid center에 놓이고 선택한 ADC bin으로 접혀야 합니다. " +
"bin 간격은 " + formatNumber(binWidthHz, 6) + " Hz입니다.",
),
"활동 후보 추가 실패",
);
return;
}
const halfCellHz = binWidthHz / 2;
const minHz = analogCenterHz - halfCellHz;
const maxHz = analogCenterHz + halfCellHz;
if (minHz < 0 || maxHz > MAX_PRECISE_ANALOG_FREQUENCY_HZ) {
setTransientError(
new RangeError("이 FFT cell은 검증된 0–1 MHz 아날로그 band 범위를 벗어납니다."),
"활동 후보 추가 실패",
);
return;
}
const usedIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
let suffix = 1;
while (usedIds.has("activity-" + suffix)) suffix += 1;
const band = {
id: "activity-" + suffix,
minHz,
maxHz,
color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
};
state.experiment.frequencyBands.push(band);
state.visibleBandIds.add(band.id);
markChanged({ renderBands: true });
setRunStatus(
band.id + " 추가 · analog " + formatNumber(analogCenterHz, 3) +
" Hz 가설 → ADC bin " + adcBin +
". 다른 alias order는 별도 가설로 추가하세요.",
);
renderActivityResult();
}
function renderLegend() {
elements.bandLegend.replaceChildren();
elements.samplingWarning.hidden = true;
elements.samplingWarning.textContent = "";
if (!state.experiment || !state.scene) return;
const observations = [];
const toneObservations = sceneToneObservations(state.scene);
for (const band of state.experiment.frequencyBands) {
const label = document.createElement("label");
label.className = "legend-toggle";
label.style.setProperty("--band-color", band.color);
const input = document.createElement("input");
input.type = "checkbox";
input.checked = state.visibleBandIds.has(band.id);
input.dataset.visibleBand = band.id;
const swatch = document.createElement("span");
swatch.className = "legend-swatch";
const text = document.createElement("span");
const requested =
`${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`;
try {
const observation = observeFrequencyBand(
band,
state.scene.sampleRate,
state.experiment.stft.fftSize,
);
observations.push({ bandId: band.id, observation });
if (observation.aliased) {
const adcRanges = observation.adcBinCenterRanges
.map((range) =>
`${formatNumber(range.firstBinCenterHz, 0)}–` +
`${formatNumber(range.lastBinCenterHz, 0)} Hz`)
.join(", ");
text.textContent = `${requested} → ADC FFT bin centers ${adcRanges}`;
text.title = state.experiment.beamforming.algorithm === "music"
? "MUSIC uses the single analog FFT-grid hypothesis configured by this band, never Scene truth. Inverted Nyquist zones conjugate the full received manifold."
: "Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; the original alias order is not silently used for steering.";
} else {
text.textContent = requested;
}
} catch (error) {
text.textContent = requested;
text.title = error instanceof Error ? error.message : String(error);
}
label.append(input, swatch, text);
elements.bandLegend.append(label);
}
const toneContributions = observations.flatMap(({ bandId, observation }) =>
aliasedToneContributions(toneObservations, observation)
.map((tone) => ({ bandId, tone })));
const aliased = observations.filter(
({ observation }) =>
observation.aliased ||
observation.continuousManyToOne ||
observation.gridManyToOne,
);
const groups = new Map();
for (const { bandId, observation } of observations) {
const group = groups.get(observation.adcBinSetKey) ?? [];
group.push(bandId);
groups.set(observation.adcBinSetKey, group);
}
const equivalentGroups = [...groups.values()].filter((group) => group.length > 1);
if (aliased.length > 0 || equivalentGroups.length > 0 || toneContributions.length > 0) {
const details = [];
if (aliased.length > 0) {
const describeFold = ({ bandId, observation }) => {
const visibleSegments = observation.foldSegments
.slice(0, 4)
.map((segment) =>
`Z${segment.nyquistZone}/order ${segment.aliasOrder}` +
(segment.inverted ? "/inverted" : ""))
.join(", ");
const remainder = observation.foldSegments.length > 4
? `, +${observation.foldSegments.length - 4} zones`
: "";
return `${bandId} [${visibleSegments}${remainder}]`;
};
details.push(
`alias band: ${aliased.map(describeFold).join(" · ")}`,
);
}
if (equivalentGroups.length > 0) {
details.push(
"같은 ADC bin 집합(독립 관측 아님): " +
equivalentGroups.map((group) => group.join(" ↔ ")).join(" · "),
);
}
if (toneContributions.length > 0) {
details.push(
"선택 ADC bin으로 접힌 Scene tone: " +
toneContributions
.map(({ bandId, tone }) => `${bandId} ← ${describeToneFold(tone)}`)
.join(" · "),
);
}
const localizationDescription =
state.experiment.beamforming.algorithm === "gcc-phat"
? "관측된 one-sided ADC FFT bin만 사용해 band-limited GCC-PHAT 기하 lag를 계산하며 "
: state.experiment.beamforming.algorithm === "music"
? "관측 ADC bin의 covariance와 band가 명시한 단일 analog carrier manifold로 standard narrowband MUSIC q를 계산하며 "
: "관측된 양의 ADC FFT bin 주파수로 구면 steering하며 ";
const carrierDescription =
state.experiment.beamforming.algorithm === "music"
? "Scene truth는 사용하지 않습니다. 반전 Nyquist zone은 마이크 응답과 공간 위상을 모두 켤레 처리하며, 물리적 grating lobe·동률·모델 불일치는 그대로 유지합니다. 한 band의 복수 carrier 가설은 선택하거나 융합하지 않고 거부합니다. "
: "원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 그대로 나타나며 alias order는 미해결 상태입니다. "
elements.samplingWarning.textContent =
"Ideal ADC alias · 위치 추정은 " +
localizationDescription +
carrierDescription +
details.join(" · ");
elements.samplingWarning.hidden = false;
}
}
function renderWorkload() {
if (!state.arrayDefinition || !state.scene || !state.experiment) return;
try {
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
const maximumPaths = maximumSparsePathsPerReceiver(state.scene);
const signalPathBudget =
workload.microphoneCount * state.scene.sources.length * maximumPaths;
const scanCost = workload.algorithm === "gcc-phat"
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
: workload.algorithm === "music"
? `${workload.frameCount} snapshots × ${workload.musicHypothesisCount} analog hypotheses / ${workload.selectedBinCount} ADC bins · ` +
`cov ${formatCount(workload.musicCovarianceComplexAccumulations)} accumulations + ` +
`Jacobi ≤ ${formatCount(workload.musicJacobiWorstCasePairUpdates)} / cap ${formatCount(workload.musicJacobiPairUpdateLimit)} pair updates (32 sweeps) + ` +
`projection ≤ ${formatCount(workload.musicProjectionComplexMacs)} complex MAC`
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
elements.workloadSummary.textContent =
`${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` +
`${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` +
`${scanCost} · signal ≤ ${formatCount(signalPathBudget)} paths · ` +
`distance cache ${formatNumber(workload.distanceMiB, 1)} MiB`;
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
const heavyCpu =
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
(
workload.scanOperationCount > 5e8 ||
(
workload.algorithm === "music" &&
workload.musicJacobiWorstCasePairUpdates >
150_000_000
)
);
const narrowGcc = workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8;
elements.workloadSummary.classList.toggle(
"is-warning",
heavyCpu || narrowGcc || workload.pointCount >= 40000 || signalPathBudget > 100000,
);
} catch (error) {
elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`;
elements.workloadSummary.classList.add("is-warning");
}
}
function bandsForVisualization(lastRun) {
if (!lastRun) return [];
const definitions = new Map(lastRun.snapshot.experiment.frequencyBands.map((band) => [band.id, band]));
const snapshotIds = [...definitions.keys()];
const currentIds = state.experiment.frequencyBands.map((band) => band.id);
const sameBandSet =
snapshotIds.length === currentIds.length && snapshotIds.every((id) => currentIds.includes(id));
const visibleBandIds = sameBandSet ? state.visibleBandIds : lastRun.visibleBandIds;
return lastRun.run.bands.map((band) => ({
...band,
color: band.color ?? definitions.get(band.id)?.color ?? "#65f2bf",
visible: visibleBandIds.has(band.id),
}));
}
function interpolateMap(left, right, progress) {
if (left === right || !(progress > 0)) return left;
if (!left || !right || left.length !== right.length) return progress < 0.5 ? left : right;
const MapArray =
left instanceof Float64Array || right instanceof Float64Array
? Float64Array
: Float32Array;
const values = new MapArray(left.length);
const inverse = 1 - progress;
for (let index = 0; index < values.length; index += 1) {
values[index] = left[index] * inverse + right[index] * progress;
}
return values;
}
function liveVisualizationState(live) {
const playback = scenePlaybackState(live.snapshot.scene, live.plan, live.time);
const leftFrame = live.frames[playback.leftFrameIndex];
const rightFrame = live.frames[playback.rightFrameIndex];
const nearestFrame = live.frames[playback.frameIndex];
const rightBands = new Map(rightFrame.run.bands.map((band) => [band.id, band]));
const definitions = new Map(
live.snapshot.experiment.frequencyBands.map((band) => [band.id, band]),
);
const bands = leftFrame.run.bands.map((leftBand) => {
const rightBand = rightBands.get(leftBand.id) ?? leftBand;
return {
...leftBand,
displayMap: interpolateMap(leftBand.displayMap, rightBand.displayMap, playback.progress),
color: leftBand.color ?? definitions.get(leftBand.id)?.color ?? "#65f2bf",
visible: state.visibleBandIds.has(leftBand.id),
};
});
return {
playback,
scene: playback.scene,
bands,
evaluations: nearestFrame.evaluations,
run: nearestFrame.run,
};
}
function renderSceneVisualization() {
if (!state.scene || !state.experiment) return;
if (state.live?.frames?.length) {
const live = state.live;
const visualization = liveVisualizationState(live);
sceneVisualization.render({
scene: visualization.scene,
experiment: live.snapshot.experiment,
image: live.imageElement,
bands: visualization.bands,
evaluations: visualization.evaluations,
dynamicRangeDb: live.snapshot.experiment.beamforming.dynamicRangeDb,
opacity: Number(elements.overlayOpacity.value),
showBeamSpread: elements.showBeamSpread.checked,
showTruth: elements.showTruth.checked,
});
elements.canvasMessage.classList.add("is-hidden");
const algorithmLabel = localizationAlgorithmLabel(
live.frames[0]?.run.algorithm ?? live.snapshot.experiment.beamforming.algorithm,
);
elements.canvasBadge.textContent =
`LIVE · ${algorithmLabel} · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}` +
(
visualization.run.status === "unavailable"
? ` · 위치 없음 (${visualization.run.unavailable?.reason ?? "unavailable"})`
: ""
);
return;
}
const displayed = state.lastRun;
const snapshot = displayed?.snapshot ?? { scene: state.scene, experiment: state.experiment };
sceneVisualization.render({
scene: snapshot.scene,
experiment: snapshot.experiment,
image: displayed ? displayed.imageElement : assetRuntime.imageElement,
bands: bandsForVisualization(displayed),
evaluations: displayed?.evaluations,
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
opacity: Number(elements.overlayOpacity.value),
showBeamSpread: elements.showBeamSpread.checked,
showTruth: elements.showTruth.checked,
});
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
if (!displayed) {
elements.canvasBadge.textContent = "결과 없음";
} else if (displayed.run.status === "unavailable") {
elements.canvasBadge.textContent =
`MUSIC · 위치 없음 · ${displayed.run.unavailable?.reason ?? "unavailable"}`;
} else if (displayed.stale) {
elements.canvasBadge.textContent =
`이전 설정 결과 · ${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
displayed.run.backend.toUpperCase();
} else {
elements.canvasBadge.textContent =
`${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
`${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` +
`${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`;
}
}
function appendMetric(label, value, detail, color) {
const card = document.createElement("article");
card.className = "metric-card";
if (color) card.style.borderTopColor = color;
const labelElement = document.createElement("span");
labelElement.textContent = label;
const valueElement = document.createElement("strong");
valueElement.textContent = value;
const detailElement = document.createElement("small");
detailElement.textContent = detail;
card.append(labelElement, valueElement, detailElement);
elements.metricGrid.append(card);
}
function renderResult() {
const displayed = state.lastRun;
elements.exportResultButton.disabled =
Boolean(state.active) || !displayed?.resultDocument;
elements.metricGrid.replaceChildren();
elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed));
elements.metricGrid.classList.toggle("is-hidden", !displayed);
if (!displayed) {
elements.resultBadge.textContent = "아직 실행하지 않음";
return;
}
if (displayed.run.status === "unavailable") {
const algorithmLabel = localizationAlgorithmLabel(
displayed.run.algorithm ??
displayed.snapshot.experiment.beamforming.algorithm,
);
elements.resultBadge.textContent =
`${algorithmLabel} · 위치 없음`;
appendMetric(
"위치 추정",
"UNAVAILABLE",
displayed.run.unavailable?.reason ??
"spatially-unidentifiable",
);
appendMetric(
"처리 상태",
"정상 소비",
"임의 map·위치를 만들지 않았습니다.",
);
return;
}
const algorithm = displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm;
const algorithmLabel = localizationAlgorithmLabel(algorithm);
elements.resultBadge.textContent = displayed.stale
? `이전 설정 · ${algorithmLabel} · ${displayed.run.backend.toUpperCase()}`
: `${algorithmLabel} · ${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`;
appendMetric(
"위치 추정",
algorithmLabel,
displayed.run.fallback
? `실제 ${displayed.run.backend.toUpperCase()} · WebGPU fallback`
: `실제 ${displayed.run.backend.toUpperCase()} backend`,
);
appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + localization");
appendMetric(
"신호 합성",
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
`${displayed.snapshot.arrayDefinition.microphones.length} channels`,
);
if (displayed.run.signalStats.acousticModel) {
const modelLabel =
displayed.run.signalStats.acousticModel === "shoebox" ? "SPARSE ROOM" : "FREE FIELD";
appendMetric(
"음향 모델",
modelLabel,
`${(displayed.run.signalStats.acousticPathCount ?? 0).toLocaleString("ko-KR")} paths 합성`,
);
}
const aliasedTones = displayed.run.signalStats.sampling?.toneObservations
?.filter((observation) => observation.aliased) ?? [];
if (displayed.run.signalStats.sampling) {
appendMetric(
"ADC sampling",
aliasedTones.length > 0 ? `${aliasedTones.length} aliased tone` : "Nyquist 내",
aliasedTones.length > 0
? aliasedTones
.map((observation) =>
`${formatNumber(observation.analogFrequencyHz, 0)}→` +
`${formatNumber(observation.adcFrequencyHz, 0)} Hz`)
.join(" · ")
: "ideal real sampler · anti-alias filter 없음",
);
}
const resultObservations = displayed.snapshot.experiment.frequencyBands
.map((band) => {
try {
return {
band,
observation: observeFrequencyBand(
band,
displayed.snapshot.scene.sampleRate,
displayed.snapshot.experiment.stft.fftSize,
),
};
} catch {
return null;
}
})
.filter(Boolean);
const resultToneObservations = sceneToneObservations(displayed.snapshot.scene);
for (const { band, observation } of resultObservations) {
const toneContributions = aliasedToneContributions(
resultToneObservations,
observation,
);
if (
!observation.aliased &&
!observation.continuousManyToOne &&
!observation.gridManyToOne &&
toneContributions.length === 0
) continue;
const resultAlgorithm =
displayed.snapshot.experiment.beamforming.algorithm;
const localizationDetail =
resultAlgorithm === "music"
? "협대역 MUSIC noise subspace · configured analog carrier · inversion-aware · Scene truth 미사용"
: resultAlgorithm === "gcc-phat"
? "band-limited GCC-PHAT · 원 carrier 미사용"
: "양의 ADC bin steering · 원 carrier 미사용 · alias order 미해결";
const toneDetail = toneContributions.length > 0
? " · aliased Scene tone " + toneContributions.map(describeToneFold).join(" · ")
: "";
appendMetric(
`${band.id} · ADC steering`,
resultAlgorithm === "gcc-phat"
? "ADC LAG"
: resultAlgorithm === "music"
? "MIN q"
: "OBSERVED BIN",
localizationDetail + toneDetail,
band.color,
);
}
appendMetric(
"위치 추정 시간",
`${formatNumber(displayed.run.timingMs.localization ?? displayed.run.timingMs.beamforming, 0)} ms`,
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`,
);
const detectionThreshold =
displayed.snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO;
appendMetric(
"Peak 검출 임계값",
`${formatNumber(detectionThreshold * 100, 0)}%`,
algorithm === "music"
? "표준 MUSIC P=1/q의 P/Pmax 기준 · raw q 불변 · floor 없음"
: "최강 map 값 기준 · 낮을수록 민감",
);
appendMetric(
"Peak 묶기",
displayed.snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
"raw map 불변 · 검출 후보만 군집화",
);
for (const band of displayed.snapshot.experiment.frequencyBands) {
const evaluation = displayed.evaluations.get(band.id);
if (!evaluation) continue;
const matchedErrors = evaluation.sourceMatches
.filter((match) => match.withinTolerance && Number.isFinite(match.localizationErrorM))
.map((match) => match.localizationErrorM);
const localization = matchedErrors.length ? Math.min(...matchedErrors) : null;
appendMetric(
`${band.id} · 위치 오차`,
localization === null ? "미검출" : `${formatNumber(localization * 100, 1)} cm`,
`${evaluation.counts.truePositive}/${evaluation.counts.truth} source`,
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "minimum") {
appendMetric(
`${band.id} · Noise projection q`,
evaluation.globalPeak
? formatNumber(evaluation.globalPeak.value, 6)
: "식별 불가",
"작을수록 MUSIC 후보 · 확률/신뢰도 아님 · raw q",
band.color,
);
} else {
appendMetric(
`${band.id} · Truth concentration`,
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
"ground-truth 반경 내",
band.color,
);
appendMetric(
`${band.id} · Peak/sidelobe`,
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
"높을수록 분리 우수",
band.color,
);
}
appendMetric(
`${band.id} · Precision / Recall`,
`${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`,
`${evaluation.counts.detected} peaks`,
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "maximum") {
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
evaluation.diagnostics.mapScale === "score"
? "half-maximum score diameter"
: "half-power equivalent diameter",
band.color,
);
}
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
if (spread?.ellipse) {
appendMetric(
`${band.id} · 빔 분포`,
`${formatNumber(spread.ellipse.majorRmsRadiusM * 100, 1)} × ` +
`${formatNumber(spread.ellipse.minorRmsRadiusM * 100, 1)} cm`,
`1× RMS · ${formatNumber(spread.ellipse.angleDeg, 0)}° · 확률 분포 아님`,
band.color,
);
}
}
}
function renderHistory() {
elements.historyList.replaceChildren();
elements.exportHistoryButton.disabled = state.history.length === 0;
const signature = comparisonSignature();
const items = state.history.filter((item) => item.comparisonSignature === signature);
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "history-empty";
empty.textContent = "현재 scene·설정의 비교 실행 기록이 없습니다.";
elements.historyList.append(empty);
return;
}
for (const item of items) {
const card = document.createElement("article");
card.className = "history-card";
const name = document.createElement("strong");
name.textContent = `${localizationAlgorithmLabel(item.algorithm)} · ${item.arrayName}`;
const fields = [
`${item.backend.toUpperCase()} · ${item.pointCount.toLocaleString("ko-KR")} pt`,
`위치 ${formatNumber(item.localizationMs, 0)} / 전체 ${formatNumber(item.totalMs, 0)} ms`,
item.localizationErrorM === null ? "오차 —" : `오차 ${formatNumber(item.localizationErrorM * 100, 1)} cm`,
item.peakToSidelobeDb === null ? "PSR —" : `PSR ${formatNumber(item.peakToSidelobeDb, 1)} dB`,
];
card.append(name);
for (const field of fields) {
const value = document.createElement("span");
value.textContent = field;
card.append(value);
}
elements.historyList.append(card);
}
}
function validationWarnings() {
const warnings = [];
if (!state.scene || !state.experiment || !state.arrayDefinition) return warnings;
const zMismatch = state.scene.sources.some((source) =>
[source.position, ...(source.trajectory ?? []).map(({ position }) => position)].some(
(position) => Math.abs(position[2] - state.experiment.scan.z) > 1e-6,
),
);
if (zMismatch) {
warnings.push("일부 source z가 scan z와 달라 ground truth에는 평면 투영값을 사용합니다.");
}
if (
state.scene.image?.plane &&
Math.abs(state.scene.image.plane.z - state.experiment.scan.z) > 1e-6
) {
warnings.push("배경 image plane z와 scan z가 다릅니다. 이미지는 x/y 좌표만 투영해 표시합니다.");
}
try {
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8) {
warnings.push("GCC-PHAT는 안정적인 광대역 상관을 위해 선택 FFT bin을 8개 이상 권장합니다.");
}
if (
workload.algorithm === "music" &&
workload.musicJacobiWorstCasePairUpdates >
150_000_000
) {
warnings.push(
`MUSIC Jacobi 상한이 큽니다 (${workload.musicJacobiWorstCasePairUpdates.toLocaleString()} / hard cap ${workload.musicJacobiPairUpdateLimit.toLocaleString()} pair updates). 결과를 단순화하지 않으므로 채널 수나 명시 carrier band 수를 줄이지 않으면 오래 걸릴 수 있습니다.`,
);
}
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
if (
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
workload.scanOperationCount > 5e8
) {
warnings.push(
cpuOnly
? "CPU 계산량이 큽니다. 더 작은 grid 또는 더 적은 band를 권장합니다."
: "CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다.",
);
} else if (workload.pointCount >= 40000) {
warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다.");
}
} catch {
// Contract validation reports the actionable error.
}
return warnings;
}
async function validateCurrent() {
const sequence = ++state.validationSequence;
if (!state.arrayDefinition || !state.scene || !state.experiment) {
const validation = {
valid: false,
issues: [{ path: "$", message: "문서를 불러오는 중입니다." }],
warnings: [],
};
if (sequence === state.validationSequence) {
state.activityValidation = {
valid: false,
issues: validation.issues,
};
applyValidation(validation);
}
validation.activity = state.activityValidation;
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 activityExperimentIssues = [];
const supportedActivityFftSizes = [128, 256, 512, 1024, 2048, 4096];
if (!snapshot.experiment.stft || typeof snapshot.experiment.stft !== "object") {
activityExperimentIssues.push({
path: "experiment.stft",
message: "Activity 분석용 STFT 설정이 필요합니다.",
});
} else {
const { fftSize, hopSize } = snapshot.experiment.stft;
if (!supportedActivityFftSizes.includes(fftSize)) {
activityExperimentIssues.push({
path: "experiment.stft.fftSize",
message: "Activity 분석은 128–4096의 지원되는 2의 거듭제곱 FFT size가 필요합니다.",
});
}
if (
!Number.isInteger(hopSize) ||
hopSize < 1 ||
!Number.isInteger(fftSize) ||
hopSize > fftSize
) {
activityExperimentIssues.push({
path: "experiment.stft.hopSize",
message: "Activity hop size는 1 이상 FFT size 이하의 정수여야 합니다.",
});
}
}
const minimumDistance = snapshot.experiment.beamforming?.minimumDistance;
if (
!Number.isFinite(minimumDistance) ||
minimumDistance <= 0 ||
minimumDistance > 10
) {
activityExperimentIssues.push({
path: "experiment.beamforming.minimumDistance",
message: "물리 신호 합성용 최소 거리는 0보다 크고 10 m 이하여야 합니다.",
});
}
const activityIssues = [
...arrayIssues.map((issue) => ({ ...issue, path: `array${issue.path}` })),
...sceneIssues.map((issue) => ({ ...issue, path: `scene${issue.path}` })),
...activityExperimentIssues,
];
const missingAudioAssets =
assetRuntime.missingAudioAssetIds(snapshot.scene);
if (missingAudioAssets.length > 0) {
activityIssues.push({
path: "scene.assets",
message: `오디오 원본 파일을 연결하세요: ${missingAudioAssets.join(", ")}`,
});
}
if (state.pendingInputOperations > 0) {
activityIssues.push({
path: "$",
message: "선택한 파일을 확인하고 있습니다. 잠시 기다리세요.",
});
}
const activityValidation = {
valid: activityIssues.length === 0,
issues: activityIssues,
revision,
};
const validation = {
valid: issues.length === 0,
issues,
warnings: validationWarnings(),
revision,
};
if (sequence === state.validationSequence) {
state.activityValidation = activityValidation;
applyValidation(validation);
}
validation.activity = activityValidation;
return validation;
}
function applyValidation(validation) {
state.validation = validation;
elements.validationCard.classList.remove("is-valid", "is-invalid", "is-warning");
if (!validation.valid) {
elements.validationCard.classList.add("is-invalid");
elements.validationTitle.textContent = `${validation.issues.length}개 항목 확인 필요`;
elements.validationMessage.textContent = validation.issues.slice(0, 2).map(formatIssue).join(" · ");
} else if (validation.warnings.length > 0) {
elements.validationCard.classList.add("is-warning");
elements.validationTitle.textContent = "실행 가능 · 주의";
elements.validationMessage.textContent = validation.warnings.join(" · ");
} else {
elements.validationCard.classList.add("is-valid");
elements.validationTitle.textContent = "실행 준비 완료";
elements.validationMessage.textContent = "계약 검증과 asset 연결 상태가 정상입니다.";
}
document.documentElement.dataset.cameraValid = String(validation.valid);
if (!state.active) {
elements.runButton.disabled = !validation.valid;
if (validation.valid) {
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ Snapshot 실행";
if (!state.lastRun && document.documentElement.dataset.runState === "idle") {
setRunStatus(
`${state.arrayDefinition.microphones.length} ch · ` +
`${state.experiment.scan.columns}×${state.experiment.scan.rows} · ` +
`분석 대역 ${state.experiment.frequencyBands.length}개 · 실행 준비 완료`,
);
}
}
}
renderLiveControls();
renderActivityControls();
}
function scheduleValidation() {
if (validationQueued) return;
validationQueued = true;
queueMicrotask(async () => {
validationQueued = false;
await validateCurrent();
});
}
function createAbortError() {
const error = new Error("시뮬레이션이 취소되었습니다.");
error.name = "AbortError";
return error;
}
function progressMessage(message) {
if (message.message) return message.message;
const progress = message.progress;
if (progress?.totalBins) {
return `위치 추정 중 · ${progress.completedBins}/${progress.totalBins} bins`;
}
if (progress?.totalChunks) {
return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`;
}
return `계산 중 · ${message.stage ?? "worker"}`;
}
function runWorker(activity, payload, transfer, resultOptions = {}) {
const resultType = resultOptions.type ?? "result";
const resultKey = resultOptions.key ?? "run";
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" });
activity.worker = worker;
activity.reject = reject;
worker.addEventListener("message", (event) => {
const message = event.data;
if (message?.jobId !== activity.jobId || state.active !== activity || activity.finished) return;
if (message.type === "progress") {
document.documentElement.dataset.runStage = message.stage ?? "worker";
setRunStatus(progressMessage(message));
if (activity.kind === "live") elements.liveStatus.textContent = progressMessage(message);
if (activity.kind === "activity") {
elements.activityStatus.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 runActivityDetection() {
if (state.active) return;
let activityConfig;
try {
activityConfig = readActivityConfiguration();
} catch (error) {
setTransientError(error, "활동 검출 설정 오류");
elements.activityMonitor.open = true;
return;
}
const validation = await validateCurrent();
if (state.active) return;
try {
activityConfig = readActivityConfiguration();
} catch (error) {
setTransientError(error, "활동 검출 설정 오류");
elements.activityMonitor.open = true;
return;
}
if (!validation.activity?.valid) {
setTransientError(
new Error(
(validation.activity?.issues ?? [])
.slice(0, 2)
.map(formatIssue)
.join(" · "),
),
"활동 검출 전 입력 검증 실패",
);
return;
}
if (
validation.activity.revision !== state.revision ||
state.pendingInputOperations > 0
) {
setRunStatus("입력 파일 또는 설정 검증이 끝난 뒤 전 대역 검사를 다시 실행하세요.");
await validateCurrent();
return;
}
if (
state.activityDirty &&
!window.confirm("직전 Activity report를 아직 저장하지 않았습니다. 새 검사로 교체할까요?")
) {
setRunStatus("직전 Activity JSON을 저장한 뒤 새 검사를 실행할 수 있습니다.");
return;
}
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
experiment: structuredClone(state.experiment),
};
const inputSignature = activityInputSignature(snapshot, activityConfig);
const activity = {
jobId: "activity-" + Date.now() + "-" + ++jobSequence,
kind: "activity",
revision: state.revision,
abortController: new AbortController(),
worker: null,
reject: null,
canceled: false,
finished: false,
};
state.active = activity;
document.documentElement.dataset.activityState = "running";
document.documentElement.dataset.runStage = "activity-assets";
setConfigurationLocked(true);
elements.activityMonitor.open = true;
elements.activityStatus.textContent =
"입력을 고정하고 선택한 물리 마이크의 전 대역 검사를 준비하고 있습니다…";
setRunStatus("전 대역 활동 검사를 준비하고 있습니다…");
try {
const inputHashPromise = sha256Text(inputSignature);
const assetBuffers = await assetRuntime.decodeAudioAssets(
snapshot.scene,
activity.abortController.signal,
({ completed, total, fileName }) => {
if (state.active !== activity) return;
const message = total > 0
? "활동 검사 audio decode · " + completed + "/" + total +
(fileName ? " · " + fileName : "")
: "활동 검사 synthetic signal 준비 완료";
setRunStatus(message);
elements.activityStatus.textContent = message;
},
);
if (activity.canceled || state.active !== activity) throw createAbortError();
document.documentElement.dataset.runStage = "activity-worker";
const workerReport = await runWorker(
activity,
{
type: "detect-activity",
jobId: activity.jobId,
scene: snapshot.scene,
arrayDefinition: snapshot.arrayDefinition,
experiment: snapshot.experiment,
assetBuffers,
activityConfig,
},
assetRuntime.transferListForAudio(assetBuffers),
{ type: "activity-result", key: "activityReport" },
);
if (activity.canceled || state.active !== activity) throw createAbortError();
const inputHash = await inputHashPromise;
state.activityRun = {
snapshot,
activityConfig,
inputSignature,
inputHash,
workerReport,
createdAt: new Date().toISOString(),
};
state.activityDirty = true;
elements.activityFrame.value = "0";
document.documentElement.dataset.activityState = "done";
syncUnsavedState();
renderActivityResult();
setRunStatus(
"전 대역 검사 완료 · " + workerReport.algorithm +
" · " + formatNumber(workerReport.timingMs.total, 0) +
" ms · hash " + inputHash.slice(0, 10),
);
} catch (error) {
if (error?.name === "AbortError" || activity.canceled) {
document.documentElement.dataset.activityState = "canceled";
elements.activityStatus.textContent =
"전 대역 검사를 취소했습니다. 직전 Activity report는 유지됩니다.";
setRunStatus("전 대역 검사를 취소했습니다.");
} else {
document.documentElement.dataset.activityState = "error";
elements.activityStatus.textContent =
"전 대역 검사 실패 · " +
(error instanceof Error ? error.message : String(error));
setTransientError(error, "전 대역 검사 실패");
}
} finally {
activity.worker?.terminate();
if (state.active === activity) state.active = null;
setConfigurationLocked(false);
renderBandEditors();
renderActivityControls();
await validateCurrent();
}
}
async function runSimulation() {
if (state.active) return;
const validation = await validateCurrent();
if (state.active) return;
if (!validation.valid) {
setTransientError(
new Error(validation.issues.slice(0, 2).map(formatIssue).join(" · ")),
"실행 전 검증 실패",
);
return;
}
if (validation.revision !== state.revision || state.pendingInputOperations > 0) {
setRunStatus("입력 파일 또는 설정이 갱신되었습니다. 검증 완료 후 다시 실행하세요.");
await validateCurrent();
return;
}
if (
state.resultDirty &&
!window.confirm("직전 Result를 아직 저장하지 않았습니다. 새 실행으로 교체할까요?")
) {
setRunStatus("직전 Result를 먼저 저장한 뒤 다시 실행할 수 있습니다.");
return;
}
if (state.resultDirty) {
state.resultDirty = false;
syncUnsavedState();
}
resetLiveTimeline();
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
experiment: structuredClone(state.experiment),
};
const activity = {
jobId: `camera-${Date.now()}-${++jobSequence}`,
kind: "snapshot",
revision: state.revision,
abortController: new AbortController(),
worker: null,
reject: null,
canceled: false,
finished: false,
};
state.active = activity;
document.documentElement.dataset.runState = "running";
document.documentElement.dataset.runStage = "assets";
setConfigurationLocked(true);
elements.runButton.textContent = "계산 중…";
setRunStatus("실행 입력을 고정하고 asset을 준비하고 있습니다…");
try {
const inputHashPromise = hashSimulationInputs(snapshot);
const assetBuffers = await assetRuntime.decodeAudioAssets(
snapshot.scene,
activity.abortController.signal,
({ completed, total, fileName }) => {
if (state.active !== activity) return;
setRunStatus(
total > 0
? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}`
: "Synthetic signal 준비 완료",
);
},
);
if (activity.canceled || state.active !== activity) throw createAbortError();
document.documentElement.dataset.runStage = "worker";
const run = await runWorker(
activity,
{
type: "run",
jobId: activity.jobId,
scene: snapshot.scene,
arrayDefinition: snapshot.arrayDefinition,
experiment: snapshot.experiment,
assetBuffers,
},
assetRuntime.transferListForAudio(assetBuffers),
);
if (activity.canceled || state.active !== activity) throw createAbortError();
if (run.status === "unavailable") {
const inputHash = await inputHashPromise;
state.lastRun = {
snapshot,
run,
evaluations: new Map(),
exportedEvaluations: new Map(),
inputHash,
resultDocument: null,
stale: state.revision !== activity.revision,
imageElement: assetRuntime.imageElement,
visibleBandIds: new Set(state.visibleBandIds),
};
state.resultDirty = false;
document.documentElement.dataset.runState =
"unavailable";
document.documentElement.dataset.runBackend =
run.backend;
document.documentElement.dataset.runAlgorithm =
"music";
document.documentElement.dataset.resultBandCount =
"0";
setRunStatus(
`MUSIC 위치 없음 · ${run.unavailable?.reason ?? "unavailable"} · 임의 map을 만들지 않았습니다.`,
);
renderResult();
renderSceneVisualization();
return;
}
setRunStatus("Peak 검출과 정량 지표를 계산하고 있습니다…");
const evaluations = new Map();
const exportedEvaluations = new Map();
for (const band of run.bands) {
const definition = snapshot.experiment.frequencyBands.find(
(candidate) => candidate.id === band.id,
);
const evaluation = evaluateLocalizationBand({
rawMap: band.rawMap,
mapScale:
band.rawScale ??
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
"power",
objectiveDirection: band.objectiveDirection,
scan: snapshot.experiment.scan,
sources: sourcesForObservedBand(
snapshot.scene,
definition,
snapshot.experiment.stft.fftSize,
),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
detectionMethod: snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
});
evaluations.set(band.id, evaluation);
exportedEvaluations.set(band.id, exportBandEvaluation(evaluation));
}
const inputHash = await inputHashPromise;
const resultDocument = createResultDocument({
...snapshot,
run,
evaluations: exportedEvaluations,
inputHash,
});
const resultIssues = await validateResult(resultDocument, {
experiment: snapshot.experiment,
scene: snapshot.scene,
});
if (resultIssues.length > 0) {
throw new Error(
`Result 계약 검증 실패 · ${resultIssues.slice(0, 3).map(formatIssue).join(" · ")}`,
);
}
const completedRun = {
snapshot,
run,
evaluations,
exportedEvaluations,
inputHash,
resultDocument,
stale: state.revision !== activity.revision,
imageElement: assetRuntime.imageElement,
visibleBandIds: new Set(state.visibleBandIds),
};
state.lastRun = completedRun;
const historyItem = createHistorySummary({
...snapshot,
run,
evaluations: exportedEvaluations,
inputHash,
});
historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment);
historyItem.comparisonSaved = false;
state.history.unshift(historyItem);
state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS);
state.resultDirty = true;
syncUnsavedState();
document.documentElement.dataset.runState = "done";
document.documentElement.dataset.runBackend = run.backend;
document.documentElement.dataset.runAlgorithm =
run.algorithm ?? snapshot.experiment.beamforming.algorithm;
document.documentElement.dataset.resultBandCount = String(run.bands.length);
setRunStatus(
`${localizationAlgorithmLabel(run.algorithm ?? snapshot.experiment.beamforming.algorithm)} · ` +
`${run.backend.toUpperCase()} 완료 · ${formatNumber(run.timingMs.total, 0)} ms · ` +
`hash ${inputHash.slice(0, 10)}`,
);
renderResult();
renderHistory();
renderSceneVisualization();
} catch (error) {
if (error?.name === "AbortError" || activity.canceled) {
document.documentElement.dataset.runState = "canceled";
setRunStatus("계산을 취소했습니다. 입력과 직전 결과는 그대로 유지됩니다.");
} else {
document.documentElement.dataset.runState = "error";
setTransientError(error, "실행 실패");
}
} finally {
activity.worker?.terminate();
if (state.active === activity) state.active = null;
setConfigurationLocked(false);
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ Snapshot 실행";
renderBandEditors();
await validateCurrent();
}
}
function createLiveExperiment(experiment) {
const preview = structuredClone(experiment);
const originalPoints = preview.scan.columns * preview.scan.rows;
if (originalPoints <= MAX_LIVE_SCAN_POINTS) {
return { experiment: preview, reduced: false, originalPoints };
}
const scale = Math.sqrt(MAX_LIVE_SCAN_POINTS / originalPoints);
preview.scan.columns = Math.max(8, Math.floor(preview.scan.columns * scale));
preview.scan.rows = Math.max(8, Math.floor(preview.scan.rows * scale));
while (preview.scan.columns * preview.scan.rows > MAX_LIVE_SCAN_POINTS) {
if (preview.scan.columns >= preview.scan.rows && preview.scan.columns > 8) {
preview.scan.columns -= 1;
} else if (preview.scan.rows > 8) {
preview.scan.rows -= 1;
} else {
break;
}
}
return { experiment: preview, reduced: true, originalPoints };
}
function reconcileLivePlan(plan, timeline) {
const frames = timeline.frames.map(({ run, ...frame }) => frame);
const frameInterval = frames.length > 1
? (frames.at(-1).sampleTime - frames[0].sampleTime) / (frames.length - 1)
: plan.duration;
return {
...plan,
frames,
frameCount: frames.length,
frameInterval,
effectiveMapHz: frameInterval > 0 ? 1 / frameInterval : 0,
};
}
function evaluateLiveFrames(snapshot, plan, frames) {
for (const frame of frames) {
const frameScene = scenePlaybackState(snapshot.scene, plan, frame.sampleTime).scene;
const arrivingSourceIds = frame.run.signalStats.windowing?.arrivingSourceIds;
const evaluations = new Map();
for (const band of frame.run.bands) {
const definition = snapshot.experiment.frequencyBands.find(
(candidate) => candidate.id === band.id,
);
if (!definition) continue;
evaluations.set(
band.id,
evaluateLocalizationBand({
rawMap: band.rawMap,
mapScale:
band.rawScale ??
localizationAlgorithmDefinition(
frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm,
)?.rawScale ??
"power",
objectiveDirection: band.objectiveDirection,
scan: snapshot.experiment.scan,
sources: sourcesForObservedBand(
frameScene,
definition,
snapshot.experiment.stft.fftSize,
{
time: Array.isArray(arrivingSourceIds) ? null : frame.sampleTime,
arrivingSourceIds,
},
),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
detectionThresholdRatio:
snapshot.experiment.evaluation?.detectionThresholdRatio ??
DEFAULT_DETECTION_THRESHOLD_RATIO,
detectionMethod: snapshot.experiment.evaluation?.detectionMethod ?? "local-max",
}),
);
}
frame.evaluations = evaluations;
}
}
async function prepareLiveSimulation() {
if (state.active) return;
const validation = await validateCurrent();
if (state.active) return;
if (!validation.valid) {
state.liveError = validation.issues.slice(0, 2).map(formatIssue).join(" · ");
renderLiveControls();
return;
}
if (!sceneHasTrajectories(state.scene)) {
state.liveError = "Scene에 2개 이상의 keyframe을 가진 trajectory가 없습니다.";
renderLiveControls();
return;
}
if (validation.revision !== state.revision || state.pendingInputOperations > 0) {
state.liveError = "입력 갱신이 끝난 뒤 다시 시도하세요.";
renderLiveControls();
await validateCurrent();
return;
}
resetLiveTimeline();
const preview = createLiveExperiment(state.experiment);
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
experiment: preview.experiment,
};
const plan = createAnalysisFramePlan(snapshot.scene, {
fftSize: snapshot.experiment.stft.fftSize,
targetMapHz: Number(elements.liveMapRate.value),
maxFrames: MAX_LIVE_ANALYSIS_FRAMES,
});
const activity = {
jobId: `camera-live-${Date.now()}-${++jobSequence}`,
kind: "live",
revision: state.revision,
abortController: new AbortController(),
worker: null,
reject: null,
canceled: false,
finished: false,
};
state.active = activity;
state.liveError = null;
document.documentElement.dataset.runStage = "live-assets";
setConfigurationLocked(true);
elements.prepareLiveButton.textContent = "준비 중…";
setRunStatus(`라이브 입력 고정 · ${plan.frameCount}개 map을 준비합니다…`);
try {
const assetBuffers = await assetRuntime.decodeAudioAssets(
snapshot.scene,
activity.abortController.signal,
({ completed, total, fileName }) => {
if (state.active !== activity) return;
elements.liveStatus.textContent = total > 0
? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}`
: "Synthetic signal 준비 완료";
},
);
if (activity.canceled || state.active !== activity) throw createAbortError();
document.documentElement.dataset.runStage = "live-worker";
const timeline = await runWorker(
activity,
{
type: "run-timeline",
jobId: activity.jobId,
scene: snapshot.scene,
arrayDefinition: snapshot.arrayDefinition,
experiment: snapshot.experiment,
framePlan: plan,
assetBuffers,
},
assetRuntime.transferListForAudio(assetBuffers),
{ type: "timeline-result", key: "timeline" },
);
if (activity.canceled || state.active !== activity) throw createAbortError();
elements.liveStatus.textContent = "프레임별 peak와 빔 분포를 계산하고 있습니다…";
const resolvedPlan = reconcileLivePlan(plan, timeline);
evaluateLiveFrames(snapshot, resolvedPlan, timeline.frames);
state.live = {
snapshot,
plan: resolvedPlan,
frames: timeline.frames,
backend: timeline.backend,
timingMs: timeline.timingMs,
reducedScan: preview.reduced,
originalPoints: preview.originalPoints,
imageElement: assetRuntime.imageElement,
time: 0,
playing: false,
animationFrameId: 0,
playbackStartedAt: null,
playbackStartedTime: 0,
};
const unavailableFrameCount = timeline.frames.filter(
(frame) => frame.run.status === "unavailable",
).length;
setRunStatus(
`라이브 준비 완료 · ${timeline.frames.length} maps · ` +
`${formatNumber(timeline.timingMs.total, 0)} ms` +
(
unavailableFrameCount > 0
? ` · 위치 없음 ${unavailableFrameCount} frames`
: ""
),
);
renderLiveControls();
renderSceneVisualization();
} catch (error) {
if (error?.name === "AbortError" || activity.canceled) {
setRunStatus("라이브 map 준비를 취소했습니다.");
} else {
state.liveError = error instanceof Error ? error.message : String(error);
setRunStatus(`라이브 준비 실패 · ${state.liveError}`);
}
} finally {
activity.worker?.terminate();
if (state.active === activity) state.active = null;
elements.prepareLiveButton.textContent = "라이브 준비";
setConfigurationLocked(false);
renderBandEditors();
await validateCurrent();
renderLiveControls();
}
}
function setLiveTime(value) {
const live = state.live;
if (!live) return;
live.time = Math.min(live.plan.duration, Math.max(0, Number(value) || 0));
const playback = playbackFrameAtTime(live.plan, live.time);
document.documentElement.dataset.liveFrame = String(playback.framePosition);
renderLiveControls();
renderSceneVisualization();
}
function livePlaybackTick(timestamp) {
const live = state.live;
if (!live?.playing) return;
if (live.playbackStartedAt === null) live.playbackStartedAt = timestamp;
const speed = Number(elements.liveSpeed.value) || 1;
const elapsed = ((timestamp - live.playbackStartedAt) / 1000) * speed;
const nextTime = live.playbackStartedTime + elapsed;
if (nextTime >= live.plan.duration) {
live.time = live.plan.duration;
stopLivePlayback(false);
renderLiveControls();
renderSceneVisualization();
return;
}
setLiveTime(nextTime);
live.animationFrameId = requestAnimationFrame(livePlaybackTick);
}
function toggleLivePlayback() {
const live = state.live;
if (!live) return;
if (live.playing) {
stopLivePlayback();
return;
}
if (live.time >= live.plan.duration) live.time = 0;
live.playing = true;
live.playbackStartedAt = null;
live.playbackStartedTime = live.time;
live.animationFrameId = requestAnimationFrame(livePlaybackTick);
renderLiveControls();
renderSceneVisualization();
}
async function loadMovingExample() {
if (state.active) return;
const bandCount = state.experiment?.frequencyBands?.length ?? 0;
if (
(bandCount > 1 || hasUnsavedWork()) &&
!window.confirm(
"이동 예제는 현재 Scene·Array·Experiment 전체와 " +
String(bandCount) +
"개 분석 대역을 교체합니다. 계속할까요?",
)
) {
setRunStatus("이동 예제를 열지 않았습니다. 현재 입력과 분석 대역을 유지합니다.");
return;
}
beginInputOperation();
try {
setRunStatus("이동 음원 예제를 불러오고 있습니다…");
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson(MOVING_EXAMPLE_URLS.arrayDefinition),
loadJson(MOVING_EXAMPLE_URLS.scene),
loadJson(MOVING_EXAMPLE_URLS.experiment),
]);
const [arrayIssues, sceneIssues] = await Promise.all([
validateArrayDefinition(arrayDefinition),
validateScene(scene),
]);
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
const issues = [...arrayIssues, ...sceneIssues, ...experimentIssues];
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = arrayDefinition;
state.scene = scene;
state.experiment = applyExperimentDefaults(experiment);
state.sceneDirty = false;
state.experimentDirty = false;
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
assetRuntime.reset();
markChanged({ syncControls: true, renderBands: true, dirty: "none" });
renderAssets();
setRunStatus("Scene·Array·Experiment를 이동 음원 예제 전체로 교체했습니다. ‘Map 준비’를 누르세요.");
} catch (error) {
setTransientError(error, "이동 예제 불러오기 실패");
} finally {
endInputOperation();
}
}
async function loadMusicExample() {
if (state.active) return;
const bandCount =
state.experiment?.frequencyBands?.length ?? 0;
if (
(bandCount > 1 || hasUnsavedWork()) &&
!window.confirm(
"MUSIC 빠른 예제는 현재 Scene·Array·Experiment 전체와 " +
String(bandCount) +
"개 분석 대역을 교체합니다. 계속할까요?",
)
) {
setRunStatus(
"MUSIC 예제를 열지 않았습니다. 현재 입력을 유지합니다.",
);
return;
}
beginInputOperation();
try {
setRunStatus(
"12채널 표준 MUSIC 예제를 불러오고 있습니다…",
);
const [arrayDefinition, scene, experiment] =
await Promise.all([
loadJson(MUSIC_EXAMPLE_URLS.arrayDefinition),
loadJson(MUSIC_EXAMPLE_URLS.scene),
loadJson(MUSIC_EXAMPLE_URLS.experiment),
]);
const [arrayIssues, sceneIssues] =
await Promise.all([
validateArrayDefinition(arrayDefinition),
validateScene(scene),
]);
const experimentIssues = await validateExperiment(
experiment,
{ arrayDefinition, scene },
);
const issues = [
...arrayIssues,
...sceneIssues,
...experimentIssues,
];
if (issues.length > 0) {
throw new Error(
issues.slice(0, 4).map(formatIssue).join(" · "),
);
}
state.arrayDefinition = arrayDefinition;
state.scene = scene;
state.experiment =
applyExperimentDefaults(experiment);
state.sceneDirty = false;
state.experimentDirty = false;
state.visibleBandIds = new Set(
experiment.frequencyBands.map((band) => band.id),
);
assetRuntime.reset();
markChanged({
syncControls: true,
renderBands: true,
dirty: "none",
});
renderAssets();
setRunStatus(
"12채널·1.125 kHz·Fixed K=1 MUSIC 예제를 열었습니다. Snapshot 실행을 누르세요.",
);
} catch (error) {
setTransientError(
error,
"MUSIC 예제 불러오기 실패",
);
} finally {
endInputOperation();
}
}
function cancelSimulation() {
const activity = state.active;
if (!activity || activity.canceled) return;
activity.canceled = true;
activity.abortController.abort(createAbortError());
activity.worker?.terminate();
activity.reject?.(createAbortError());
setRunStatus("계산을 취소하는 중…");
}
async function loadJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} 응답 ${response.status}`);
return response.json();
}
async function loadArrayPreset(presetId) {
const url = ARRAY_PRESET_URLS.get(presetId);
if (!url) {
elements.arrayInput.click();
syncControls();
return;
}
const generation = ++state.importGenerations.array;
beginInputOperation();
try {
setRunStatus(`${presetId} 비교 배열을 불러오고 있습니다…`);
const value = await loadJson(url);
if (generation !== state.importGenerations.array) return;
const issues = await validateArrayDefinition(value);
if (generation !== state.importGenerations.array) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = value;
markChanged({ syncControls: true });
setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`);
} catch (error) {
syncControls();
setTransientError(error, "비교 배열 불러오기 실패");
} finally {
endInputOperation();
}
}
async function importDocument(kind, input) {
const file = input.files?.[0];
input.value = "";
if (!file) return;
const generation = ++state.importGenerations[kind];
beginInputOperation();
try {
const value = await readJsonFile(file);
if (generation !== state.importGenerations[kind]) return;
let issues;
if (kind === "array") {
issues = await validateArrayDefinition(value);
} else if (kind === "scene") {
issues = await validateScene(value);
} else {
// arrayId/sceneId are retained as document provenance, not treated as
// foreign keys. The currently open Array and Scene are the run inputs.
issues = await validateExperiment(value);
if (generation !== state.importGenerations[kind]) return;
if (issues.length === 0) {
issues = await validateExperiment(value, {
arrayDefinition: state.arrayDefinition,
scene: state.scene,
});
}
}
if (generation !== state.importGenerations[kind]) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
if (kind === "array") {
state.arrayDefinition = value;
} else if (kind === "scene") {
state.scene = value;
alignExperimentToScene(state.experiment, value);
state.sceneDirty = false;
assetRuntime.reset();
} else {
state.experiment = applyExperimentDefaults(value);
state.experimentDirty = false;
}
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
markChanged({
syncControls: true,
renderBands: true,
dirty: kind === "experiment" ? "none" : "experiment",
});
renderAssets();
const bandStatus = kind === "scene"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개 유지`
: kind === "experiment"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개`
: "";
setRunStatus(
`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}`,
);
} catch (error) {
setTransientError(error, `${file.name} 불러오기 실패`);
} finally {
endInputOperation();
}
}
function installDocumentInputListeners() {
elements.experimentName.addEventListener("input", () => {
state.experiment.name = elements.experimentName.value;
markChanged();
});
elements.experimentId.addEventListener("input", () => {
state.experiment.id = elements.experimentId.value;
markChanged();
});
elements.sceneDistance.addEventListener("change", () => {
const value = readFiniteInput(elements.sceneDistance);
if (value === null) return syncControls();
shiftSceneDistance(state.scene, state.experiment, value);
markChanged({ dirty: "both" });
});
elements.speedOfSound.addEventListener("change", () => {
const value = readFiniteInput(elements.speedOfSound);
if (value === null) return syncControls();
state.scene.speedOfSound = value;
markChanged({ dirty: "scene" });
});
elements.sceneSeed.addEventListener("change", () => {
const value = readFiniteInput(elements.sceneSeed);
if (value === null) return syncControls();
state.scene.seed = value;
markChanged({ dirty: "scene" });
});
elements.acousticProfile.addEventListener("change", () => {
if (elements.acousticProfile.value === "custom") return syncControls();
applyAcousticProfile(
state.scene,
state.arrayDefinition,
state.experiment,
elements.acousticProfile.value,
);
markChanged({ dirty: "scene", syncControls: true });
});
elements.frequencyResponseMode.addEventListener("change", () => {
state.experiment.beamforming.compensateFrequencyResponse =
elements.frequencyResponseMode.value === "compensate";
markChanged();
});
elements.algorithm.addEventListener("change", () => {
const algorithm = elements.algorithm.value;
state.experiment.beamforming.algorithm = algorithm;
if (algorithm === "music") {
state.experiment.beamforming.musicSourceCountMethod ??= "fixed";
state.experiment.beamforming.musicSourceCount ??= 1;
state.experiment.beamforming.normalization = "none";
}
const backendReset =
state.experiment.beamforming.backend === "webgpu" &&
!localizationAlgorithmSupportsBackend(algorithm, "webgpu");
if (backendReset) state.experiment.beamforming.backend = "auto";
markChanged({ syncControls: true });
if (backendReset) {
setRunStatus(`${localizationAlgorithmLabel(algorithm)}는 CPU 전용이므로 Backend를 Auto (CPU)로 바꿨습니다.`);
}
});
elements.musicSourceCountMethod.addEventListener("change", () => {
const method = elements.musicSourceCountMethod.value;
state.experiment.beamforming.musicSourceCountMethod = method;
if (method === "fixed") {
state.experiment.beamforming.musicSourceCount ??= 1;
}
markChanged({ syncControls: true });
});
elements.musicSourceCount.addEventListener("change", () => {
const value = readFiniteInput(elements.musicSourceCount);
if (value === null) return syncControls();
state.experiment.beamforming.musicSourceCount = Math.trunc(value);
markChanged({ syncControls: true });
});
elements.backend.addEventListener("change", () => {
if (
elements.backend.value === "webgpu" &&
!localizationAlgorithmSupportsBackend(state.experiment.beamforming.algorithm, "webgpu")
) {
elements.backend.value = state.experiment.beamforming.backend;
setRunStatus(
`${localizationAlgorithmLabel(state.experiment.beamforming.algorithm)}는 Auto 또는 CPU를 사용하세요.`,
);
return;
}
state.experiment.beamforming.backend = elements.backend.value;
markChanged();
});
elements.gridPreset.addEventListener("change", () => {
if (elements.gridPreset.value === "custom") return;
const side = Number(elements.gridPreset.value);
state.experiment.scan.columns = side;
state.experiment.scan.rows = side;
markChanged({ syncControls: true });
});
const scanBindings = [
[elements.scanColumns, "columns", true],
[elements.scanRows, "rows", true],
[elements.scanXMin, "xMin", false],
[elements.scanXMax, "xMax", false],
[elements.scanYMin, "yMin", false],
[elements.scanYMax, "yMax", false],
];
for (const [element, key, integer] of scanBindings) {
element.addEventListener("change", () => {
const value = readFiniteInput(element);
if (value === null) return syncControls();
state.experiment.scan[key] = integer ? Math.trunc(value) : value;
markChanged({ syncControls: true });
});
}
elements.fftSize.addEventListener("change", () => {
state.experiment.stft.fftSize = Number(elements.fftSize.value);
if (state.experiment.stft.hopSize > state.experiment.stft.fftSize) {
state.experiment.stft.hopSize = state.experiment.stft.fftSize / 2;
}
markChanged({ syncControls: true });
});
elements.hopSize.addEventListener("change", () => {
const value = readFiniteInput(elements.hopSize);
if (value === null) return syncControls();
state.experiment.stft.hopSize = Math.trunc(value);
markChanged({ syncControls: true });
});
elements.normalization.addEventListener("change", () => {
state.experiment.beamforming.normalization = elements.normalization.value;
markChanged();
});
elements.dynamicRange.addEventListener("change", () => {
const value = readFiniteInput(elements.dynamicRange);
if (value === null) return syncControls();
state.experiment.beamforming.dynamicRangeDb = value;
markChanged();
});
elements.detectionThreshold.addEventListener("change", () => {
const value = readFiniteInput(elements.detectionThreshold);
if (value === null) return syncControls();
state.experiment.evaluation ??= {};
state.experiment.evaluation.detectionThresholdRatio = value / 100;
markChanged();
});
elements.detectionMethod.addEventListener("change", () => {
state.experiment.evaluation ??= {};
state.experiment.evaluation.detectionMethod = elements.detectionMethod.value;
markChanged();
});
}
function installBandListeners() {
elements.bandList.addEventListener("change", (event) => {
const input = event.target.closest("input[data-band-index]");
if (!input) return;
const band = state.experiment.frequencyBands[Number(input.dataset.bandIndex)];
if (!band) return;
const key = input.dataset.bandKey;
if (key === "id") {
const previousId = band.id;
band.id = input.value;
state.visibleBandIds.delete(previousId);
state.visibleBandIds.add(band.id);
} else if (key === "color") {
band.color = input.value;
} else if (Number.isFinite(input.valueAsNumber)) {
band[key] = input.valueAsNumber;
}
markChanged();
});
elements.bandList.addEventListener("click", (event) => {
const button = event.target.closest("button[data-remove-band]");
if (!button || state.experiment.frequencyBands.length === 1) return;
const [removed] = state.experiment.frequencyBands.splice(Number(button.dataset.removeBand), 1);
state.visibleBandIds.delete(removed.id);
markChanged({ renderBands: true });
});
elements.addBandButton.addEventListener("click", () => {
if (state.experiment.frequencyBands.length >= 8) return;
const used = new Set(state.experiment.frequencyBands.map((band) => band.id));
let suffix = state.experiment.frequencyBands.length + 1;
while (used.has(`band-${suffix}`)) suffix += 1;
const previous = state.experiment.frequencyBands.at(-1);
const minHz = Math.max(0, previous?.maxHz ?? 900);
const band = {
id: `band-${suffix}`,
minHz,
maxHz: minHz + 200,
color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
};
state.experiment.frequencyBands.push(band);
state.visibleBandIds.add(band.id);
markChanged({ renderBands: true });
});
elements.bandLegend.addEventListener("change", (event) => {
const input = event.target.closest("input[data-visible-band]");
if (!input) return;
if (input.checked) state.visibleBandIds.add(input.dataset.visibleBand);
else state.visibleBandIds.delete(input.dataset.visibleBand);
renderSceneVisualization();
});
}
function installActivityListeners() {
elements.activityRunButton.addEventListener("click", runActivityDetection);
elements.activityExportButton.addEventListener("click", () => {
if (!state.activityRun) return;
const documentValue = createActivityReportDocument(state.activityRun);
downloadJson(
documentValue,
safeFileStem(state.activityRun.snapshot.scene.id) + ".activity.json",
);
state.activityDirty = false;
syncUnsavedState();
setRunStatus("모든 raw frame/bin 통계를 포함한 Activity JSON을 저장했습니다.");
});
elements.activityFrame.addEventListener("input", renderActivityResult);
elements.activityResultList.addEventListener("click", (event) => {
const addButton = event.target.closest("button[data-add-activity-band]");
if (addButton) {
addActivityBand(addButton);
return;
}
const frameButton = event.target.closest("button[data-activity-view-frame]");
if (frameButton) {
elements.activityFrame.value = frameButton.dataset.activityViewFrame;
renderActivityResult();
}
});
const configurationControls = [
elements.activityAlgorithm,
elements.activityMicrophone,
elements.activityPfa,
elements.activityTraining,
elements.activityGuard,
elements.activityRank,
elements.activityPeakPickerEnabled,
elements.activityDelta,
elements.activityPreMax,
elements.activityPreMean,
elements.activityRefractory,
];
for (const control of configurationControls) {
control.addEventListener("change", () => {
renderActivityControls();
renderActivityResult();
});
}
}
function installActionListeners() {
elements.runButton.addEventListener("click", runSimulation);
elements.cancelButton.addEventListener("click", cancelSimulation);
elements.loadMovingExampleButton.addEventListener("click", loadMovingExample);
elements.loadMusicExampleButton.addEventListener(
"click",
loadMusicExample,
);
elements.prepareLiveButton.addEventListener("click", prepareLiveSimulation);
elements.livePlayButton.addEventListener("click", toggleLivePlayback);
elements.liveTime.addEventListener("input", () => {
stopLivePlayback(false);
setLiveTime(elements.liveTime.valueAsNumber);
});
elements.liveMapRate.addEventListener("change", () => {
resetLiveTimeline();
renderSceneVisualization();
});
elements.liveSpeed.addEventListener("change", () => {
if (!state.live?.playing) return;
state.live.playbackStartedAt = performance.now();
state.live.playbackStartedTime = state.live.time;
});
elements.sceneInput.addEventListener("change", () => importDocument("scene", elements.sceneInput));
elements.arrayInput.addEventListener("change", () => importDocument("array", elements.arrayInput));
elements.experimentInput.addEventListener(
"change",
() => importDocument("experiment", elements.experimentInput),
);
elements.arrayPreset.addEventListener("change", () => loadArrayPreset(elements.arrayPreset.value));
elements.assetInput.addEventListener("change", async () => {
const files = [...(elements.assetInput.files ?? [])];
elements.assetInput.value = "";
if (files.length === 0) return;
beginInputOperation();
try {
setRunStatus(`${files.length}개 asset의 SHA-256을 확인하고 있습니다…`);
const report = await assetRuntime.bindFiles(files, state.scene);
if (report.rejected.length > 0) {
setTransientError(
new Error(report.rejected.map((item) => `${item.fileName}: ${item.message}`).join(" · ")),
"Asset 연결 실패",
);
} else {
setRunStatus(`${report.bound.length}개 asset descriptor를 연결했습니다.`);
}
} catch (error) {
setTransientError(error, "Asset 연결 실패");
} finally {
renderAssets();
endInputOperation();
}
});
elements.overlayOpacity.addEventListener("input", renderSceneVisualization);
elements.showBeamSpread.addEventListener("change", renderSceneVisualization);
elements.showTruth.addEventListener("change", renderSceneVisualization);
elements.exportSceneButton.addEventListener("click", async () => {
const issues = await validateScene(state.scene);
if (issues.length > 0) {
setTransientError(
new Error(issues.slice(0, 4).map(formatIssue).join(" · ")),
"Scene 저장 실패",
);
return;
}
downloadJson(state.scene, `${safeFileStem(state.scene.id)}.scene.json`);
state.sceneDirty = false;
syncUnsavedState();
setRunStatus("Scene JSON을 저장했습니다.");
});
elements.exportExperimentButton.addEventListener("click", async () => {
const issues = await validateExperiment(state.experiment, {
arrayDefinition: state.arrayDefinition,
scene: state.scene,
});
if (issues.length > 0) {
setTransientError(
new Error(issues.slice(0, 4).map(formatIssue).join(" · ")),
"Experiment 저장 실패",
);
return;
}
downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`);
state.experimentDirty = false;
syncUnsavedState();
setRunStatus("Experiment JSON을 저장했습니다.");
});
elements.exportResultButton.addEventListener("click", () => {
if (!state.lastRun?.resultDocument) return;
downloadJson(
state.lastRun.resultDocument,
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
);
state.resultDirty = false;
syncUnsavedState();
setRunStatus("검증된 Result JSON을 저장했습니다.");
});
elements.exportHistoryButton.addEventListener("click", () => {
if (state.history.length === 0) return;
const items = state.history.map(({ comparisonSignature, comparisonSaved, ...item }) => item);
downloadJson(
{
schemaVersion: "1.0.0",
kind: "micarrsim.comparison-history",
createdAt: new Date().toISOString(),
items,
},
"micarrsim.comparison-history.json",
);
for (const item of state.history) item.comparisonSaved = true;
syncUnsavedState();
setRunStatus("전체 비교 기록 JSON을 저장했습니다.");
});
elements.clearHistoryButton.addEventListener("click", () => {
const signature = comparisonSignature();
state.history = state.history.filter((item) => item.comparisonSignature !== signature);
renderHistory();
syncUnsavedState();
});
window.addEventListener("beforeunload", (event) => {
if (window.top !== window || !hasUnsavedWork()) return;
event.preventDefault();
event.returnValue = "";
});
window.addEventListener("unload", () => {
stopLivePlayback(false);
cancelSimulation();
sceneVisualization.destroy();
arrayVisualization.destroy();
assetRuntime.dispose();
layoutSplitPane.destroy();
});
}
async function initialize() {
installDocumentInputListeners();
installBandListeners();
installActivityListeners();
installActionListeners();
setEditableControlsDisabled(true);
document.documentElement.dataset.runState = "idle";
renderLiveControls();
renderActivityResult();
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);
renderActivityControls();
renderBandEditors();
elements.runButton.disabled = !state.validation.valid;
document.documentElement.dataset.appReady = "true";
} catch (error) {
document.documentElement.dataset.appReady = "error";
setTransientError(error, "기본 fixture 초기화 실패");
}
}
initialize();