feat: protect unsaved camera work

This commit is contained in:
Codex
2026-08-28 13:09:55 +09:00
parent 513de2b51f
commit b03eccde50
10 changed files with 192 additions and 17 deletions
+3
View File
@@ -79,6 +79,9 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수 Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수
있습니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다. 있습니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다.
Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
## Scene JSON과 로컬 assets ## Scene JSON과 로컬 assets
Scene JSON에는 원본 이미지나 오디오 byte가 들어가지 않습니다. 대신 asset id, 종류, 파일명과 Scene JSON에는 원본 이미지나 오디오 byte가 들어가지 않습니다. 대신 asset id, 종류, 파일명과
+8
View File
@@ -65,6 +65,14 @@ WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 *
- rectangular와 Fermat spiral preset을 같은 Scene/Experiment에서 연속 실행하면 **배열 비교 기록**에 - rectangular와 Fermat spiral preset을 같은 Scene/Experiment에서 연속 실행하면 **배열 비교 기록**에
backend, timing과 평가 요약이 남습니다. backend, timing과 평가 요약이 남습니다.
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다. - 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각
**JSON 저장** 버튼으로 보존할 수 있습니다.
- **전체 기록 JSON**은 세션에 남은 모든 배열 비교 요약을
`micarrsim.comparison-history` 문서로 저장합니다.
미저장 Scene·Experiment·Result·비교 기록은 우측 저장 상태에 표시됩니다. 직전 Result를 저장하지
않고 새 실행을 시작하면 교체 여부를 확인하며, 미저장 항목이나 실행 중 계산이 있는 상태에서
페이지를 닫으면 브라우저가 한 번 더 확인합니다.
## Result JSON ## Result JSON
+8 -2
View File
@@ -61,7 +61,7 @@
</section> </section>
<section class="history-section"> <section class="history-section">
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div> <div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div></div>
<p class="section-copy">비교 배열 또는 Array JSON을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 나란히 비교합니다.</p> <p class="section-copy">비교 배열 또는 Array JSON을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 나란히 비교합니다.</p>
<div class="history-list" id="history-list"></div> <div class="history-list" id="history-list"></div>
</section> </section>
@@ -124,7 +124,13 @@
<div class="band-editor-list" id="band-list"></div> <div class="band-editor-list" id="band-list"></div>
</section> </section>
<section class="output-card"><button class="button button--ghost button--full" id="export-experiment-button" type="button">Experiment JSON 저장</button></section> <section class="output-card">
<div class="output-actions">
<button class="button button--ghost" id="export-scene-button" type="button">Scene JSON 저장</button>
<button class="button button--ghost" id="export-experiment-button" type="button">Experiment JSON 저장</button>
</div>
<p class="output-save-state" id="output-save-state">저장하지 않은 변경 없음</p>
</section>
<section class="validation-card" aria-live="polite"> <section class="validation-card" aria-live="polite">
<div class="validation-dot" id="validation-dot"></div> <div class="validation-dot" id="validation-dot"></div>
+100 -5
View File
@@ -82,7 +82,10 @@ const elements = {
postprocess: byId("postprocess"), postprocess: byId("postprocess"),
addBandButton: byId("add-band-button"), addBandButton: byId("add-band-button"),
bandList: byId("band-list"), bandList: byId("band-list"),
exportSceneButton: byId("export-scene-button"),
exportExperimentButton: byId("export-experiment-button"), exportExperimentButton: byId("export-experiment-button"),
exportHistoryButton: byId("export-history-button"),
outputSaveState: byId("output-save-state"),
validationCard: document.querySelector(".validation-card"), validationCard: document.querySelector(".validation-card"),
validationTitle: byId("validation-title"), validationTitle: byId("validation-title"),
validationMessage: byId("validation-message"), validationMessage: byId("validation-message"),
@@ -101,6 +104,9 @@ const state = {
active: null, active: null,
pendingInputOperations: 0, pendingInputOperations: 0,
importGenerations: { array: 0, scene: 0, experiment: 0 }, importGenerations: { array: 0, scene: 0, experiment: 0 },
sceneDirty: false,
experimentDirty: false,
resultDirty: false,
}; };
const sceneVisualization = createSceneVisualization(elements.sceneCanvas); const sceneVisualization = createSceneVisualization(elements.sceneCanvas);
@@ -141,6 +147,29 @@ function setRunStatus(message) {
elements.runStatus.textContent = message; elements.runStatus.textContent = message;
} }
function unsavedWorkLabels() {
const labels = [];
if (state.sceneDirty) labels.push("Scene");
if (state.experimentDirty) labels.push("Experiment");
if (state.resultDirty) labels.push("Result");
if (state.history.some((item) => !item.comparisonSaved)) labels.push("비교 기록");
return labels;
}
function syncUnsavedState() {
const labels = unsavedWorkLabels();
document.documentElement.dataset.unsavedWork = labels.join(",");
elements.outputSaveState.textContent = labels.length > 0
? `미저장 · ${labels.join(" · ")}`
: "저장하지 않은 변경 없음";
elements.outputSaveState.classList.toggle("is-dirty", labels.length > 0);
elements.exportHistoryButton.disabled = state.history.length === 0;
}
function hasUnsavedWork() {
return Boolean(state.active) || unsavedWorkLabels().length > 0;
}
function setTransientError(error, prefix = "오류") { function setTransientError(error, prefix = "오류") {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
setRunStatus(`${prefix} · ${message}`); setRunStatus(`${prefix} · ${message}`);
@@ -194,6 +223,9 @@ function readFiniteInput(element) {
} }
function markChanged(options = {}) { 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; state.revision += 1;
if (state.lastRun) state.lastRun.stale = true; if (state.lastRun) state.lastRun.stale = true;
if (["error", "canceled"].includes(document.documentElement.dataset.runState)) { if (["error", "canceled"].includes(document.documentElement.dataset.runState)) {
@@ -207,6 +239,7 @@ function markChanged(options = {}) {
renderSceneVisualization(); renderSceneVisualization();
renderResult(); renderResult();
renderHistory(); renderHistory();
syncUnsavedState();
scheduleValidation(); scheduleValidation();
} }
@@ -496,6 +529,7 @@ function renderResult() {
function renderHistory() { function renderHistory() {
elements.historyList.replaceChildren(); elements.historyList.replaceChildren();
elements.exportHistoryButton.disabled = state.history.length === 0;
const signature = comparisonSignature(); const signature = comparisonSignature();
const items = state.history.filter((item) => item.comparisonSignature === signature); const items = state.history.filter((item) => item.comparisonSignature === signature);
if (items.length === 0) { if (items.length === 0) {
@@ -718,6 +752,17 @@ async function runSimulation() {
await validateCurrent(); await validateCurrent();
return; return;
} }
if (
state.resultDirty &&
!window.confirm("직전 Result를 아직 저장하지 않았습니다. 새 실행으로 교체할까요?")
) {
setRunStatus("직전 Result를 먼저 저장한 뒤 다시 실행할 수 있습니다.");
return;
}
if (state.resultDirty) {
state.resultDirty = false;
syncUnsavedState();
}
const snapshot = { const snapshot = {
arrayDefinition: structuredClone(state.arrayDefinition), arrayDefinition: structuredClone(state.arrayDefinition),
scene: structuredClone(state.scene), scene: structuredClone(state.scene),
@@ -818,8 +863,11 @@ async function runSimulation() {
inputHash, inputHash,
}); });
historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment); historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment);
historyItem.comparisonSaved = false;
state.history.unshift(historyItem); state.history.unshift(historyItem);
state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS); state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS);
state.resultDirty = true;
syncUnsavedState();
document.documentElement.dataset.runState = "done"; document.documentElement.dataset.runState = "done";
document.documentElement.dataset.runBackend = run.backend; document.documentElement.dataset.runBackend = run.backend;
document.documentElement.dataset.resultBandCount = String(run.bands.length); document.documentElement.dataset.resultBandCount = String(run.bands.length);
@@ -921,12 +969,18 @@ async function importDocument(kind, input) {
state.scene = value; state.scene = value;
state.experiment.sceneId = value.id; state.experiment.sceneId = value.id;
alignExperimentToScene(state.experiment, value); alignExperimentToScene(state.experiment, value);
state.sceneDirty = false;
assetRuntime.reset(); assetRuntime.reset();
} else { } else {
state.experiment = value; state.experiment = value;
state.experimentDirty = false;
} }
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id)); state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
markChanged({ syncControls: true, renderBands: true }); markChanged({
syncControls: true,
renderBands: true,
dirty: kind === "experiment" ? "none" : "experiment",
});
renderAssets(); renderAssets();
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`); setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`);
} catch (error) { } catch (error) {
@@ -949,19 +1003,19 @@ function installDocumentInputListeners() {
const value = readFiniteInput(elements.sceneDistance); const value = readFiniteInput(elements.sceneDistance);
if (value === null) return syncControls(); if (value === null) return syncControls();
shiftSceneDistance(state.scene, state.experiment, value); shiftSceneDistance(state.scene, state.experiment, value);
markChanged(); markChanged({ dirty: "both" });
}); });
elements.speedOfSound.addEventListener("change", () => { elements.speedOfSound.addEventListener("change", () => {
const value = readFiniteInput(elements.speedOfSound); const value = readFiniteInput(elements.speedOfSound);
if (value === null) return syncControls(); if (value === null) return syncControls();
state.scene.speedOfSound = value; state.scene.speedOfSound = value;
markChanged(); markChanged({ dirty: "scene" });
}); });
elements.sceneSeed.addEventListener("change", () => { elements.sceneSeed.addEventListener("change", () => {
const value = readFiniteInput(elements.sceneSeed); const value = readFiniteInput(elements.sceneSeed);
if (value === null) return syncControls(); if (value === null) return syncControls();
state.scene.seed = value; state.scene.seed = value;
markChanged(); markChanged({ dirty: "scene" });
}); });
elements.backend.addEventListener("change", () => { elements.backend.addEventListener("change", () => {
state.experiment.beamforming.backend = elements.backend.value; state.experiment.beamforming.backend = elements.backend.value;
@@ -1103,6 +1157,20 @@ function installActionListeners() {
}); });
elements.overlayOpacity.addEventListener("input", renderSceneVisualization); elements.overlayOpacity.addEventListener("input", renderSceneVisualization);
elements.showTruth.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 () => { elements.exportExperimentButton.addEventListener("click", async () => {
const issues = await validateExperiment(state.experiment, { const issues = await validateExperiment(state.experiment, {
arrayDefinition: state.arrayDefinition, arrayDefinition: state.arrayDefinition,
@@ -1116,6 +1184,8 @@ function installActionListeners() {
return; return;
} }
downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`); downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`);
state.experimentDirty = false;
syncUnsavedState();
setRunStatus("Experiment JSON을 저장했습니다."); setRunStatus("Experiment JSON을 저장했습니다.");
}); });
elements.exportResultButton.addEventListener("click", () => { elements.exportResultButton.addEventListener("click", () => {
@@ -1124,14 +1194,38 @@ function installActionListeners() {
state.lastRun.resultDocument, state.lastRun.resultDocument,
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`, `${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
); );
state.resultDirty = false;
syncUnsavedState();
setRunStatus("검증된 Result JSON을 저장했습니다."); 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", () => { elements.clearHistoryButton.addEventListener("click", () => {
const signature = comparisonSignature(); const signature = comparisonSignature();
state.history = state.history.filter((item) => item.comparisonSignature !== signature); state.history = state.history.filter((item) => item.comparisonSignature !== signature);
renderHistory(); renderHistory();
syncUnsavedState();
}); });
window.addEventListener("beforeunload", () => { window.addEventListener("beforeunload", (event) => {
if (window.top !== window || !hasUnsavedWork()) return;
event.preventDefault();
event.returnValue = "";
});
window.addEventListener("unload", () => {
cancelSimulation(); cancelSimulation();
sceneVisualization.destroy(); sceneVisualization.destroy();
arrayVisualization.destroy(); arrayVisualization.destroy();
@@ -1164,6 +1258,7 @@ async function initialize() {
renderResult(); renderResult();
renderHistory(); renderHistory();
renderSceneVisualization(); renderSceneVisualization();
syncUnsavedState();
await validateCurrent(); await validateCurrent();
setEditableControlsDisabled(false); setEditableControlsDisabled(false);
renderBandEditors(); renderBandEditors();
+1
View File
@@ -224,6 +224,7 @@ export function createHistorySummary({ arrayDefinition, scene, experiment, run,
const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0]; const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0];
return { return {
id: `${Date.now()}-${inputHash.slice(0, 8)}`, id: `${Date.now()}-${inputHash.slice(0, 8)}`,
createdAt: new Date().toISOString(),
arrayId: arrayDefinition.id, arrayId: arrayDefinition.id,
arrayName: arrayDefinition.name, arrayName: arrayDefinition.name,
sceneId: scene.id, sceneId: scene.id,
+5 -1
View File
@@ -46,6 +46,7 @@
.metric-card small { display: block; margin-top: 5px; color: var(--muted); font-size: .62rem; } .metric-card small { display: block; margin-top: 5px; color: var(--muted); font-size: .62rem; }
.text-button, .mini-button { padding: 5px 7px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: .68rem; } .text-button, .mini-button { padding: 5px 7px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: .68rem; }
.history-list { display: grid; gap: 8px; margin-top: 14px; } .history-list { display: grid; gap: 8px; margin-top: 14px; }
.history-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
.history-empty { padding: 12px; color: var(--muted); font-size: .72rem; text-align: center; } .history-empty { padding: 12px; color: var(--muted); font-size: .72rem; text-align: center; }
.history-card { display: grid; grid-template-columns: minmax(150px, 1.5fr) repeat(4, minmax(85px, .75fr)); gap: 10px; align-items: center; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; } .history-card { display: grid; grid-template-columns: minmax(150px, 1.5fr) repeat(4, minmax(85px, .75fr)); gap: 10px; align-items: center; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; }
.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -78,6 +79,9 @@
.asset-status.is-error span { color: var(--danger); } .asset-status.is-error span { color: var(--danger); }
.asset-empty { color: var(--muted); font-size: .68rem; } .asset-empty { color: var(--muted); font-size: .68rem; }
.builder-link { display: block; margin-top: 11px; color: var(--cyan); font-size: .68rem; text-decoration: none; } .builder-link { display: block; margin-top: 11px; color: var(--cyan); font-size: .68rem; text-decoration: none; }
.output-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.output-save-state { margin: 9px 0 0; color: var(--muted); font-size: .65rem; text-align: center; }
.output-save-state.is-dirty { color: var(--amber); }
.field { display: grid; min-width: 0; gap: 6px; } .field { display: grid; min-width: 0; gap: 6px; }
.field > span { color: var(--muted); font-size: .67rem; font-weight: 650; } .field > span { color: var(--muted); font-size: .67rem; font-weight: 650; }
.field input, .field select, .band-editor input { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; } .field input, .field select, .band-editor input { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
@@ -121,7 +125,7 @@
.history-card strong { grid-column: 1 / -1; } .history-card strong { grid-column: 1 / -1; }
} }
@media (max-width: 430px) { @media (max-width: 430px) {
.field-grid, .band-editor { grid-template-columns: 1fr; } .field-grid, .band-editor, .output-actions { grid-template-columns: 1fr; }
.band-editor__remove { min-height: 34px; } .band-editor__remove { min-height: 34px; }
.metric-grid { grid-template-columns: 1fr 1fr; } .metric-grid { grid-template-columns: 1fr 1fr; }
} }
+2
View File
@@ -10,6 +10,8 @@ micarrsim의 저장 형식은 JSON Schema Draft 2020-12와 추가 관계 검증
- `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest - `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest
- `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 DAS 설정 - `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
- `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보 - `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보
- `micarrsim.comparison-history`: 세션의 배열별 Result 요약을 모은 편의 export. core schema
검증 대상은 아니며 각 원본 Result JSON을 대신하지 않는다.
scene JSON에는 192채널 원시 sample이나 원본 asset byte를 넣지 않는다. 합성 tone/noise는 seed가 scene JSON에는 192채널 원시 sample이나 원본 asset byte를 넣지 않는다. 합성 tone/noise는 seed가
포함된 recipe로 재생성하고, 녹음 파일과 이미지는 `id`, `kind`, `fileName`, `mimeType`, 포함된 recipe로 재생성하고, 녹음 파일과 이미지는 `id`, `kind`, `fileName`, `mimeType`,
+9 -6
View File
@@ -80,8 +80,9 @@ DAS는 STFT 후 각 scan point와 마이크 사이 거리로 steering phase를
## 5. UI 계획 ## 5. UI 계획
`acoustic-camera`는 폭 600px 이상인 좌측 scene canvas에 기기 이미지, ground-truth hint, `acoustic-camera`는 폭 600px 이상인 좌측 scene canvas에 기기 이미지, ground-truth hint,
주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 확대·회전 가능한 평면도로 주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 좌표와 함께 정적 평면도로
표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다. 표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다. 확대·회전 상호작용은
향후 필요성 검증 뒤 추가한다.
필수 입력은 음원 수와 위치, AC 거리, 다중 주파수 구간과 색, ambient 파일, scan grid 크기, 필수 입력은 음원 수와 위치, AC 거리, 다중 주파수 구간과 색, ambient 파일, scan grid 크기,
후처리 선택이다. 여기에 sample rate, 음속/온도, STFT window·hop, scan 영역, dynamic range, 후처리 선택이다. 여기에 sample rate, 음속/온도, STFT window·hop, scan 영역, dynamic range,
@@ -114,7 +115,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
### 단계 0 — 기반과 계약 · 완료 ### 단계 0 — 기반과 계약 · 완료
- workspace, formatter, linter, unit test, WebGPU feature detection 구성 - 무설치 ES module workspace, browser test runner, WebGPU feature detection 구성
- 네 가지 JSON Schema와 최소 예제 작성 - 네 가지 JSON Schema와 최소 예제 작성
- 좌표계·단위·오디오 normalization 결정 기록 - 좌표계·단위·오디오 normalization 결정 기록
- synthetic single-tone scene과 192채널 기준 배열 작성 - synthetic single-tone scene과 192채널 기준 배열 작성
@@ -133,7 +134,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- 이미지와 음원 파일 불러오기, 위치·시간·gain 편집, ambient mix와 미리 듣기 - 이미지와 음원 파일 불러오기, 위치·시간·gain 편집, ambient mix와 미리 듣기
- deterministic seed, clipping 경고, Scene JSON과 mono Preview WAV 내보내기 - deterministic seed, clipping 경고, Scene JSON과 mono Preview WAV 내보내기
- 데이터셋 adapter 인터페이스와 synthetic adapter 구현 - synthetic tone/noise fixture와 로컬 asset manifest 경계 구현
완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다. 완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다.
@@ -166,8 +167,10 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다. - WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다. - 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다.
- 브라우저 메모리: 전체 시간×192 신호를 항상 보관하지 않고 chunk/STFT frame 단위로 처리한다. - 브라우저 메모리: 현재 전체 192채널 signal buffer에 512 MiB 한도를 적용한다. 장시간 입력은
- 데이터셋 부적합/라이선스: adapter 경계를 유지하고 synthetic fixture를 기본 CI 입력으로 쓴다. 향후 chunk/STFT streaming backend로 확장한다.
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
- 실제 AC와 simulation gap: 감도, 위상, 위치 오차를 이후 calibration/noise model로 주입 가능하게 한다. - 실제 AC와 simulation gap: 감도, 위상, 위치 오차를 이후 calibration/noise model로 주입 가능하게 한다.
- post-processing이 기본 결과를 가림: raw DAS map과 후처리 map을 항상 별도로 보존한다. - post-processing이 기본 결과를 가림: raw DAS map과 후처리 map을 항상 별도로 보존한다.
+10 -3
View File
@@ -44,9 +44,10 @@
<li>완료되면 <strong>Result JSON</strong>을 눌러 다운로드 폴더에 저장합니다.</li> <li>완료되면 <strong>Result JSON</strong>을 눌러 다운로드 폴더에 저장합니다.</li>
</ol> </ol>
<aside class="callout"> <aside class="callout">
브라우저가 자동으로 열리지 않으면 브라우저가 자동으로 열리지 않으면 서버 콘솔에 표시된 주소(기본
<a href="http://127.0.0.1:4173/">http://127.0.0.1:4173/</a>을 직접 여세요. <code>http://127.0.0.1:4173/</code>)를 입력하거나
종료할 때는 서버 콘솔에서 <kbd>Ctrl</kbd>+<kbd>C</kbd>를 누릅니다. <a href="/">현재 서버의 micarrsim 홈</a>을 여세요. 종료할 때는 서버 콘솔에서
<kbd>Ctrl</kbd>+<kbd>C</kbd>를 누릅니다.
</aside> </aside>
</div> </div>
</section> </section>
@@ -123,12 +124,18 @@
<div><dt>evaluation</dt><dd>검출 threshold, 반경, tolerance 등 지표 계산 조건</dd></div> <div><dt>evaluation</dt><dd>검출 threshold, 반경, tolerance 등 지표 계산 조건</dd></div>
<div><dt>timing / environment</dt><dd>backend, grid·scan 범위, 장치 정보와 구간별 시간</dd></div> <div><dt>timing / environment</dt><dd>backend, grid·scan 범위, 장치 정보와 구간별 시간</dd></div>
<div><dt>inputHash</dt><dd>Array·Scene·Experiment 입력을 묶은 SHA-256 재현성 식별자</dd></div> <div><dt>inputHash</dt><dd>Array·Scene·Experiment 입력을 묶은 SHA-256 재현성 식별자</dd></div>
<div><dt>comparison-history</dt><dd>세션의 배열별 시간·위치 오차·PSR 요약을 한 번에 저장</dd></div>
</dl> </dl>
<aside class="callout"> <aside class="callout">
평가는 tone 음원을 해당 주파수 대역이 tone 주파수를 포함할 때만 정답으로 사용합니다. 평가는 tone 음원을 해당 주파수 대역이 tone 주파수를 포함할 때만 정답으로 사용합니다.
noise와 asset 음원은 현재 모든 대역의 정답으로 간주합니다. 녹음 파일의 실제 대역별 noise와 asset 음원은 현재 모든 대역의 정답으로 간주합니다. 녹음 파일의 실제 대역별
에너지를 자동 판정하는 기능은 아직 없습니다. 에너지를 자동 판정하는 기능은 아직 없습니다.
</aside> </aside>
<p class="guide-copy">
Camera에서 바꾼 Scene과 Experiment는 각각 저장할 수 있습니다. 완료 Result와 배열 비교
기록도 별도 JSON으로 저장하세요. 저장하지 않은 항목이나 실행 중인 계산이 있으면
페이지를 닫을 때 브라우저가 확인합니다.
</p>
</div> </div>
</section> </section>
+46
View File
@@ -44,6 +44,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
const objectUrls = []; const objectUrls = [];
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click; const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL); const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
const originalConfirm = windowObject.confirm.bind(windowObject);
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() { windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
downloads.push({ fileName: this.download, url: this.href }); downloads.push({ fileName: this.download, url: this.href });
}; };
@@ -73,6 +74,10 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
"8×8 CPU 입력 검증", "8×8 CPU 입력 검증",
); );
assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset"); assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset");
assert(
documentObject.documentElement.dataset.unsavedWork.includes("Experiment"),
"edited Experiment is marked unsaved",
);
documentObject.querySelector("#run-button").click(); documentObject.querySelector("#run-button").click();
await waitFor( await waitFor(
@@ -85,6 +90,20 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered"); assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered");
assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible"); assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible");
assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered"); assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered");
assert(documentObject.documentElement.dataset.unsavedWork.includes("Result"), "completed Result is unsaved");
assert(
documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
"comparison summary is unsaved",
);
let replacementConfirmCount = 0;
windowObject.confirm = () => {
replacementConfirmCount += 1;
return false;
};
documentObject.querySelector("#run-button").click();
await waitFor(() => replacementConfirmCount === 1, "unsaved Result replacement confirmation");
assertEqual(documentObject.documentElement.dataset.runState, "done", "declined replacement keeps result");
windowObject.confirm = originalConfirm;
await nextPaint(windowObject); await nextPaint(windowObject);
assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas"); assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas");
@@ -94,6 +113,10 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
"Result JSON download", "Result JSON download",
); );
const resultDocument = await fetch(resultDownload.url).then((response) => response.json()); const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
assert(
!documentObject.documentElement.dataset.unsavedWork.includes("Result"),
"Result export clears Result dirty state",
);
const [scene, experiment] = await Promise.all([ const [scene, experiment] = await Promise.all([
loadJson("/assets/sample-scenes/single-tone.scene.json"), loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"), loadJson("/assets/sample-scenes/single-tone.experiment.json"),
@@ -156,7 +179,30 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
const historyText = documentObject.querySelector("#history-list").textContent; const historyText = documentObject.querySelector("#history-list").textContent;
assert(historyText.includes("rectangular baseline"), "baseline history retained"); assert(historyText.includes("rectangular baseline"), "baseline history retained");
assert(historyText.includes("Fermat spiral"), "comparison array history added"); assert(historyText.includes("Fermat spiral"), "comparison array history added");
documentObject.querySelector("#export-history-button").click();
const historyDownload = await waitFor(
() => downloads.find((download) => download.fileName.endsWith(".comparison-history.json")),
"comparison history download",
);
const historyDocument = await fetch(historyDownload.url).then((response) => response.json());
assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind");
assertEqual(historyDocument.items.length, 2, "both comparison summaries exported");
assert(
!documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
"history export clears comparison dirty state",
);
documentObject.querySelector("#export-result-button").click();
documentObject.querySelector("#export-experiment-button").click();
await waitFor(
() => !documentObject.documentElement.dataset.unsavedWork.includes("Result") &&
!documentObject.documentElement.dataset.unsavedWork.includes("Experiment"),
"final Camera documents saved",
);
assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved");
} finally { } finally {
windowObject.confirm = originalConfirm;
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
windowObject.URL.revokeObjectURL = originalRevoke; windowObject.URL.revokeObjectURL = originalRevoke;
for (const url of objectUrls) originalRevoke(url); for (const url of objectUrls) originalRevoke(url);