3457 lines
131 KiB
JavaScript
3457 lines
131 KiB
JavaScript
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();
|