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();