Files
micarrsim/apps/acoustic-camera/main.js
T
2026-08-28 12:34:11 +09:00

1179 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { evaluateDasBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import {
validateArrayDefinition,
validateExperiment,
validateResult,
validateScene,
} from "/packages/contracts/validation.js";
import { createCameraAssetRuntime } from "./asset-runtime.js";
import {
alignExperimentToScene,
canonicalStringify,
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 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"],
]);
const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"];
const MAX_HISTORY_ITEMS = 24;
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"),
showTruth: byId("show-truth"),
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"),
backend: byId("backend"),
gridPreset: byId("grid-preset"),
scanColumns: byId("scan-columns"),
scanRows: byId("scan-rows"),
scanXMin: byId("scan-x-min"),
scanXMax: byId("scan-x-max"),
scanYMin: byId("scan-y-min"),
scanYMax: byId("scan-y-max"),
workloadSummary: byId("workload-summary"),
fftSize: byId("fft-size"),
hopSize: byId("hop-size"),
normalization: byId("normalization"),
dynamicRange: byId("dynamic-range"),
postprocess: byId("postprocess"),
addBandButton: byId("add-band-button"),
bandList: byId("band-list"),
exportExperimentButton: byId("export-experiment-button"),
validationCard: document.querySelector(".validation-card"),
validationTitle: byId("validation-title"),
validationMessage: byId("validation-message"),
};
const state = {
arrayDefinition: null,
scene: null,
experiment: null,
revision: 0,
validationSequence: 0,
validation: { valid: false, issues: [] },
visibleBandIds: new Set(),
lastRun: null,
history: [],
active: null,
pendingInputOperations: 0,
importGenerations: { array: 0, scene: 0, experiment: 0 },
};
const sceneVisualization = createSceneVisualization(elements.sceneCanvas);
const arrayVisualization = createArrayVisualization(elements.arrayCanvas);
const assetRuntime = createCameraAssetRuntime(() => {
renderAssets();
renderSceneVisualization();
scheduleValidation();
});
let validationQueued = false;
let jobSequence = 0;
function formatNumber(value, digits = 1) {
return Number.isFinite(value) ? value.toFixed(digits) : "—";
}
function formatCount(value) {
if (!Number.isFinite(value)) return "—";
return new Intl.NumberFormat("ko-KR", {
notation: value >= 1e6 ? "compact" : "standard",
maximumFractionDigits: 1,
}).format(value);
}
function formatIssue(issue) {
return `${issue.path}: ${issue.message}`;
}
function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) {
if (!scene || !sourceExperiment) return "";
const experiment = structuredClone(sourceExperiment);
experiment.arrayId = "$array";
return canonicalStringify({ scene, experiment });
}
function setRunStatus(message) {
elements.runStatus.textContent = message;
}
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 setConfigurationLocked(locked) {
setEditableControlsDisabled(locked);
elements.runButton.disabled = locked || !state.validation.valid;
elements.cancelButton.classList.toggle("is-hidden", !locked);
elements.exportResultButton.disabled = locked || !state.lastRun;
}
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;
});
}
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;
});
}
scheduleValidation();
}
function readFiniteInput(element) {
return Number.isFinite(element.valueAsNumber) ? element.valueAsNumber : null;
}
function markChanged(options = {}) {
state.revision += 1;
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();
scheduleValidation();
}
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.backend.value = experiment.beamforming.backend;
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;
elements.arrayPreset.value = ARRAY_PRESET_URLS.has(state.arrayDefinition.id)
? state.arrayDefinition.id
: "custom";
elements.gridPreset.value =
experiment.scan.columns === experiment.scan.rows && [50, 100, 200].includes(experiment.scan.rows)
? String(experiment.scan.rows)
: "custom";
}
function describeSignal(signal) {
if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`;
if (signal.type === "noise") return `${signal.color} noise`;
return `asset · ${signal.assetId}`;
}
function renderDocumentSummary() {
const { arrayDefinition, scene, experiment } = state;
if (!arrayDefinition || !scene || !experiment) return;
elements.workspaceTitle.textContent = scene.name;
elements.sceneMeta.textContent =
`${scene.sampleRate.toLocaleString("ko-KR")} Hz · ${formatNumber(scene.duration, 3)} s · ` +
`${scene.sources.length} source · scan z ${formatNumber(experiment.scan.z, 2)} m`;
elements.arrayTitle.textContent = arrayDefinition.name;
elements.microphoneCount.textContent = `${arrayDefinition.microphones.length} ch`;
elements.arrayMeta.textContent =
`${arrayDefinition.id} · ${formatNumber(arrayDefinition.aperture.width, 3)} × ` +
`${formatNumber(arrayDefinition.aperture.height, 3)} m`;
elements.sourceSummary.replaceChildren();
for (const source of scene.sources) {
const line = document.createElement("div");
line.className = "source-line";
const name = document.createElement("strong");
name.textContent = source.name;
const detail = document.createElement("span");
detail.textContent =
`${describeSignal(source.signal)} · ` +
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m`;
line.append(name, detail);
elements.sourceSummary.append(line);
}
arrayVisualization.render({ arrayDefinition });
}
function renderAssets() {
elements.assetList.replaceChildren();
if (!state.scene) return;
const statuses = assetRuntime.statuses(state.scene);
if (statuses.length === 0) {
const empty = document.createElement("p");
empty.className = "asset-empty";
empty.textContent = "이 장면은 외부 asset 없이 바로 실행됩니다.";
elements.assetList.append(empty);
return;
}
for (const status of statuses) {
const row = document.createElement("div");
row.className = `asset-status ${status.ready ? "is-ready" : status.error ? "is-error" : ""}`;
const label = document.createElement("strong");
label.textContent = status.descriptor.fileName;
const value = document.createElement("span");
value.textContent = status.ready ? "연결됨" : status.error ? "오류" : "파일 필요";
value.title = status.error ?? "";
row.append(label, value);
elements.assetList.append(row);
}
}
function renderBandEditors() {
elements.bandList.replaceChildren();
if (!state.experiment) return;
state.experiment.frequencyBands.forEach((band, index) => {
const editor = document.createElement("div");
editor.className = "band-editor";
const fields = [
["id", "text", band.id, "Band ID"],
["minHz", "number", band.minHz, "Min Hz"],
["maxHz", "number", band.maxHz, "Max Hz"],
["color", "color", band.color, "Color"],
];
for (const [key, type, value, label] of fields) {
const input = document.createElement("input");
input.type = type;
input.value = value;
input.dataset.bandIndex = String(index);
input.dataset.bandKey = key;
input.setAttribute("aria-label", `${band.id} ${label}`);
if (type === "number") {
input.min = "0";
input.step = "1";
}
editor.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "band-editor__remove";
remove.dataset.removeBand = String(index);
remove.textContent = "×";
remove.title = "대역 삭제";
remove.disabled = state.experiment.frequencyBands.length === 1 || Boolean(state.active);
editor.append(remove);
elements.bandList.append(editor);
});
elements.addBandButton.disabled = state.experiment.frequencyBands.length >= 8 || Boolean(state.active);
}
function renderLegend() {
elements.bandLegend.replaceChildren();
if (!state.experiment) return;
for (const band of state.experiment.frequencyBands) {
const label = document.createElement("label");
label.className = "legend-toggle";
label.style.setProperty("--band-color", band.color);
const input = document.createElement("input");
input.type = "checkbox";
input.checked = state.visibleBandIds.has(band.id);
input.dataset.visibleBand = band.id;
const swatch = document.createElement("span");
swatch.className = "legend-swatch";
const text = document.createElement("span");
text.textContent = `${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`;
label.append(input, swatch, text);
elements.bandLegend.append(label);
}
}
function renderWorkload() {
if (!state.arrayDefinition || !state.scene || !state.experiment) return;
try {
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
elements.workloadSummary.textContent =
`${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` +
`${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.complexMacCount)} complex MAC · steering ${formatNumber(workload.distanceMiB, 1)} MiB`;
const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8;
elements.workloadSummary.classList.toggle("is-warning", heavyCpu || workload.pointCount >= 40000);
} 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 renderSceneVisualization() {
if (!state.scene || !state.experiment) 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),
showTruth: elements.showTruth.checked,
});
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
if (!displayed) {
elements.canvasBadge.textContent = "결과 없음";
} else if (displayed.stale) {
elements.canvasBadge.textContent = `이전 설정 결과 · ${displayed.run.backend.toUpperCase()}`;
} else {
elements.canvasBadge.textContent =
`${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` +
`${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`;
}
}
function appendMetric(label, value, detail, color) {
const card = document.createElement("article");
card.className = "metric-card";
if (color) card.style.borderTopColor = color;
const labelElement = document.createElement("span");
labelElement.textContent = label;
const valueElement = document.createElement("strong");
valueElement.textContent = value;
const detailElement = document.createElement("small");
detailElement.textContent = detail;
card.append(labelElement, valueElement, detailElement);
elements.metricGrid.append(card);
}
function renderResult() {
const displayed = state.lastRun;
elements.exportResultButton.disabled = Boolean(state.active) || !displayed;
elements.metricGrid.replaceChildren();
elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed));
elements.metricGrid.classList.toggle("is-hidden", !displayed);
if (!displayed) {
elements.resultBadge.textContent = "아직 실행하지 않음";
return;
}
elements.resultBadge.textContent = displayed.stale
? `이전 설정 · ${displayed.run.backend.toUpperCase()}`
: `${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`;
appendMetric(
"Backend",
displayed.run.backend.toUpperCase(),
displayed.run.fallback ? "WebGPU → CPU fallback" : "요청한 계산 경로",
);
appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + DAS");
appendMetric(
"신호 합성",
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
`${displayed.snapshot.arrayDefinition.microphones.length} channels`,
);
appendMetric(
"Beamforming",
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`,
);
for (const band of displayed.snapshot.experiment.frequencyBands) {
const evaluation = displayed.evaluations.get(band.id);
if (!evaluation) continue;
const matchedErrors = evaluation.sourceMatches
.filter((match) => match.withinTolerance && Number.isFinite(match.localizationErrorM))
.map((match) => match.localizationErrorM);
const localization = matchedErrors.length ? Math.min(...matchedErrors) : null;
appendMetric(
`${band.id} · 위치 오차`,
localization === null ? "미검출" : `${formatNumber(localization * 100, 1)} cm`,
`${evaluation.counts.truePositive}/${evaluation.counts.truth} source`,
band.color,
);
appendMetric(
`${band.id} · Truth energy`,
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
"ground-truth 반경 내",
band.color,
);
appendMetric(
`${band.id} · Peak/sidelobe`,
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
"높을수록 분리 우수",
band.color,
);
appendMetric(
`${band.id} · Precision / Recall`,
`${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`,
`${evaluation.counts.detected} peaks`,
band.color,
);
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
"half-power equivalent diameter",
band.color,
);
}
}
function renderHistory() {
elements.historyList.replaceChildren();
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 = item.arrayName;
const fields = [
`${item.backend.toUpperCase()} · ${item.pointCount.toLocaleString("ko-KR")} pt`,
`${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) => Math.abs(source.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 (state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8) {
warnings.push("CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다.");
} else if (workload.pointCount >= 40000) {
warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다.");
}
} catch {
// Contract validation reports the actionable error.
}
return warnings;
}
async function validateCurrent() {
const sequence = ++state.validationSequence;
if (!state.arrayDefinition || !state.scene || !state.experiment) {
const validation = {
valid: false,
issues: [{ path: "$", message: "문서를 불러오는 중입니다." }],
warnings: [],
};
if (sequence === state.validationSequence) applyValidation(validation);
return validation;
}
const revision = state.revision;
const snapshot = structuredClone({
arrayDefinition: state.arrayDefinition,
scene: state.scene,
experiment: state.experiment,
});
const [arrayIssues, sceneIssues, experimentIssues] = await Promise.all([
validateArrayDefinition(snapshot.arrayDefinition),
validateScene(snapshot.scene),
validateExperiment(snapshot.experiment, {
arrayDefinition: snapshot.arrayDefinition,
scene: snapshot.scene,
}),
]);
const issues = [
...arrayIssues.map((issue) => ({ ...issue, path: `array${issue.path}` })),
...sceneIssues.map((issue) => ({ ...issue, path: `scene${issue.path}` })),
...experimentIssues.map((issue) => ({ ...issue, path: `experiment${issue.path}` })),
];
const missingAssets = assetRuntime.missingAssetIds(snapshot.scene);
if (missingAssets.length > 0) {
issues.push({
path: "scene.assets",
message: `원본 파일을 연결하세요: ${missingAssets.join(", ")}`,
});
}
if (state.pendingInputOperations > 0) {
issues.push({ path: "$", message: "선택한 파일을 확인하고 있습니다. 잠시 기다리세요." });
}
const validation = {
valid: issues.length === 0,
issues,
warnings: validationWarnings(),
revision,
};
if (sequence === state.validationSequence) applyValidation(validation);
return validation;
}
function applyValidation(validation) {
state.validation = validation;
elements.validationCard.classList.remove("is-valid", "is-invalid", "is-warning");
if (!validation.valid) {
elements.validationCard.classList.add("is-invalid");
elements.validationTitle.textContent = `${validation.issues.length}개 항목 확인 필요`;
elements.validationMessage.textContent = validation.issues.slice(0, 2).map(formatIssue).join(" · ");
} else if (validation.warnings.length > 0) {
elements.validationCard.classList.add("is-warning");
elements.validationTitle.textContent = "실행 가능 · 주의";
elements.validationMessage.textContent = validation.warnings.join(" · ");
} else {
elements.validationCard.classList.add("is-valid");
elements.validationTitle.textContent = "실행 준비 완료";
elements.validationMessage.textContent = "계약 검증과 asset 연결 상태가 정상입니다.";
}
document.documentElement.dataset.cameraValid = String(validation.valid);
if (!state.active) {
elements.runButton.disabled = !validation.valid;
if (validation.valid) {
elements.runButton.textContent = state.lastRun ? "▶ 다시 실행" : "▶ 기본 예제 실행";
if (!state.lastRun && document.documentElement.dataset.runState === "idle") {
setRunStatus(
`${state.arrayDefinition.microphones.length} ch · ` +
`${state.experiment.scan.columns}×${state.experiment.scan.rows} · 실행 준비 완료`,
);
}
}
}
}
function scheduleValidation() {
if (validationQueued) return;
validationQueued = true;
queueMicrotask(async () => {
validationQueued = false;
await validateCurrent();
});
}
function sourcesForBand(scene, band) {
return scene.sources.filter((source) => {
if (source.signal.type !== "tone") return true;
return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz;
});
}
function createAbortError() {
const error = new Error("시뮬레이션이 취소되었습니다.");
error.name = "AbortError";
return error;
}
function progressMessage(message) {
if (message.message) return message.message;
const progress = message.progress;
if (progress?.totalBins) {
return `DAS 계산 중 · ${progress.completedBins}/${progress.totalBins} bins`;
}
if (progress?.totalChunks) {
return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`;
}
return `계산 중 · ${message.stage ?? "worker"}`;
}
function runWorker(activity, payload, transfer) {
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));
} else if (message.type === "fallback") {
activity.fallback = { code: message.code, message: message.message };
setRunStatus(`WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`);
} else if (message.type === "result") {
activity.finished = true;
resolve(message.run);
} else if (message.type === "error") {
activity.finished = true;
const error = new Error(message.error?.message ?? "Worker 실행에 실패했습니다.");
error.name = message.error?.name ?? "Error";
error.code = message.error?.code ?? "";
reject(error);
}
});
worker.addEventListener("error", (event) => {
if (activity.finished) return;
activity.finished = true;
reject(new Error(event.message || "Simulation Worker를 시작하지 못했습니다."));
});
worker.postMessage(payload, transfer);
});
}
async function runSimulation() {
if (state.active) return;
const validation = await validateCurrent();
if (!validation.valid) {
setTransientError(
new Error(validation.issues.slice(0, 2).map(formatIssue).join(" · ")),
"실행 전 검증 실패",
);
return;
}
if (validation.revision !== state.revision || state.pendingInputOperations > 0) {
setRunStatus("입력 파일 또는 설정이 갱신되었습니다. 검증 완료 후 다시 실행하세요.");
await validateCurrent();
return;
}
const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene),
experiment: structuredClone(state.experiment),
};
const activity = {
jobId: `camera-${Date.now()}-${++jobSequence}`,
revision: state.revision,
abortController: new AbortController(),
worker: null,
reject: null,
canceled: false,
finished: false,
};
state.active = activity;
document.documentElement.dataset.runState = "running";
document.documentElement.dataset.runStage = "assets";
setConfigurationLocked(true);
elements.runButton.textContent = "계산 중…";
setRunStatus("실행 입력을 고정하고 asset을 준비하고 있습니다…");
try {
const inputHashPromise = hashSimulationInputs(snapshot);
const assetBuffers = await assetRuntime.decodeAudioAssets(
snapshot.scene,
activity.abortController.signal,
({ completed, total, fileName }) => {
if (state.active !== activity) return;
setRunStatus(
total > 0
? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}`
: "Synthetic signal 준비 완료",
);
},
);
if (activity.canceled || state.active !== activity) throw createAbortError();
document.documentElement.dataset.runStage = "worker";
const run = await runWorker(
activity,
{
type: "run",
jobId: activity.jobId,
scene: snapshot.scene,
arrayDefinition: snapshot.arrayDefinition,
experiment: snapshot.experiment,
assetBuffers,
},
assetRuntime.transferListForAudio(assetBuffers),
);
if (activity.canceled || state.active !== activity) throw createAbortError();
setRunStatus("Peak 검출과 정량 지표를 계산하고 있습니다…");
const evaluations = new Map();
const exportedEvaluations = new Map();
for (const band of run.bands) {
const definition = snapshot.experiment.frequencyBands.find(
(candidate) => candidate.id === band.id,
);
const evaluation = evaluateDasBand({
rawMap: band.rawMap,
scan: snapshot.experiment.scan,
sources: sourcesForBand(snapshot.scene, definition),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
});
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);
state.history.unshift(historyItem);
state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS);
document.documentElement.dataset.runState = "done";
document.documentElement.dataset.runBackend = run.backend;
document.documentElement.dataset.resultBandCount = String(run.bands.length);
setRunStatus(
`${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 ? "▶ 다시 실행" : "▶ 기본 예제 실행";
renderBandEditors();
await validateCurrent();
}
}
function cancelSimulation() {
const activity = state.active;
if (!activity || activity.canceled) return;
activity.canceled = true;
activity.abortController.abort(createAbortError());
activity.worker?.terminate();
activity.reject?.(createAbortError());
setRunStatus("계산을 취소하는 중…");
}
async function loadJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} 응답 ${response.status}`);
return response.json();
}
async function loadArrayPreset(presetId) {
const url = ARRAY_PRESET_URLS.get(presetId);
if (!url) {
elements.arrayInput.click();
syncControls();
return;
}
const generation = ++state.importGenerations.array;
beginInputOperation();
try {
setRunStatus(`${presetId} 비교 배열을 불러오고 있습니다…`);
const value = await loadJson(url);
if (generation !== state.importGenerations.array) return;
const issues = await validateArrayDefinition(value);
if (generation !== state.importGenerations.array) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = value;
state.experiment.arrayId = value.id;
markChanged({ syncControls: true });
setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`);
} catch (error) {
syncControls();
setTransientError(error, "비교 배열 불러오기 실패");
} finally {
endInputOperation();
}
}
async function importDocument(kind, input) {
const file = input.files?.[0];
input.value = "";
if (!file) return;
const generation = ++state.importGenerations[kind];
beginInputOperation();
try {
const value = await readJsonFile(file);
if (generation !== state.importGenerations[kind]) return;
let issues;
if (kind === "array") {
issues = await validateArrayDefinition(value);
} else if (kind === "scene") {
issues = await validateScene(value);
} else {
issues = await validateExperiment(value, {
arrayDefinition: state.arrayDefinition,
scene: state.scene,
});
}
if (generation !== state.importGenerations[kind]) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
if (kind === "array") {
state.arrayDefinition = value;
state.experiment.arrayId = value.id;
} else if (kind === "scene") {
state.scene = value;
state.experiment.sceneId = value.id;
alignExperimentToScene(state.experiment, value);
assetRuntime.reset();
} else {
state.experiment = value;
}
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
markChanged({ syncControls: true, renderBands: true });
renderAssets();
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`);
} catch (error) {
setTransientError(error, `${file.name} 불러오기 실패`);
} finally {
endInputOperation();
}
}
function installDocumentInputListeners() {
elements.experimentName.addEventListener("input", () => {
state.experiment.name = elements.experimentName.value;
markChanged();
});
elements.experimentId.addEventListener("input", () => {
state.experiment.id = elements.experimentId.value;
markChanged();
});
elements.sceneDistance.addEventListener("change", () => {
const value = readFiniteInput(elements.sceneDistance);
if (value === null) return syncControls();
shiftSceneDistance(state.scene, state.experiment, value);
markChanged();
});
elements.speedOfSound.addEventListener("change", () => {
const value = readFiniteInput(elements.speedOfSound);
if (value === null) return syncControls();
state.scene.speedOfSound = value;
markChanged();
});
elements.sceneSeed.addEventListener("change", () => {
const value = readFiniteInput(elements.sceneSeed);
if (value === null) return syncControls();
state.scene.seed = value;
markChanged();
});
elements.backend.addEventListener("change", () => {
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();
});
}
function installBandListeners() {
elements.bandList.addEventListener("change", (event) => {
const input = event.target.closest("input[data-band-index]");
if (!input) return;
const band = state.experiment.frequencyBands[Number(input.dataset.bandIndex)];
if (!band) return;
const key = input.dataset.bandKey;
if (key === "id") {
const previousId = band.id;
band.id = input.value;
state.visibleBandIds.delete(previousId);
state.visibleBandIds.add(band.id);
} else if (key === "color") {
band.color = input.value;
} else if (Number.isFinite(input.valueAsNumber)) {
band[key] = input.valueAsNumber;
}
markChanged();
});
elements.bandList.addEventListener("click", (event) => {
const button = event.target.closest("button[data-remove-band]");
if (!button || state.experiment.frequencyBands.length === 1) return;
const [removed] = state.experiment.frequencyBands.splice(Number(button.dataset.removeBand), 1);
state.visibleBandIds.delete(removed.id);
markChanged({ renderBands: true });
});
elements.addBandButton.addEventListener("click", () => {
if (state.experiment.frequencyBands.length >= 8) return;
const used = new Set(state.experiment.frequencyBands.map((band) => band.id));
let suffix = state.experiment.frequencyBands.length + 1;
while (used.has(`band-${suffix}`)) suffix += 1;
const previous = state.experiment.frequencyBands.at(-1);
const nyquist = state.scene.sampleRate / 2;
const minHz = Math.min(Math.max(0, previous?.maxHz ?? 900), nyquist - 101);
const band = {
id: `band-${suffix}`,
minHz,
maxHz: Math.min(nyquist - 1, minHz + 200),
color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
};
state.experiment.frequencyBands.push(band);
state.visibleBandIds.add(band.id);
markChanged({ renderBands: true });
});
elements.bandLegend.addEventListener("change", (event) => {
const input = event.target.closest("input[data-visible-band]");
if (!input) return;
if (input.checked) state.visibleBandIds.add(input.dataset.visibleBand);
else state.visibleBandIds.delete(input.dataset.visibleBand);
renderSceneVisualization();
});
}
function installActionListeners() {
elements.runButton.addEventListener("click", runSimulation);
elements.cancelButton.addEventListener("click", cancelSimulation);
elements.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.showTruth.addEventListener("change", renderSceneVisualization);
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`);
setRunStatus("Experiment JSON을 저장했습니다.");
});
elements.exportResultButton.addEventListener("click", () => {
if (!state.lastRun) return;
downloadJson(
state.lastRun.resultDocument,
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
);
setRunStatus("검증된 Result JSON을 저장했습니다.");
});
elements.clearHistoryButton.addEventListener("click", () => {
const signature = comparisonSignature();
state.history = state.history.filter((item) => item.comparisonSignature !== signature);
renderHistory();
});
window.addEventListener("beforeunload", () => {
cancelSimulation();
sceneVisualization.destroy();
arrayVisualization.destroy();
assetRuntime.dispose();
});
}
async function initialize() {
installDocumentInputListeners();
installBandListeners();
installActionListeners();
setEditableControlsDisabled(true);
document.documentElement.dataset.runState = "idle";
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 = experiment;
state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id));
syncControls();
renderBandEditors();
renderDocumentSummary();
renderAssets();
renderLegend();
renderWorkload();
renderResult();
renderHistory();
renderSceneVisualization();
await validateCurrent();
setEditableControlsDisabled(false);
renderBandEditors();
elements.runButton.disabled = !state.validation.valid;
document.documentElement.dataset.appReady = "true";
} catch (error) {
document.documentElement.dataset.appReady = "error";
setTransientError(error, "기본 fixture 초기화 실패");
}
}
initialize();