From 62971e07ec48ae1b57e1c5c5298eb287a09c9830 Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Sat, 29 Aug 2026 08:05:15 +0900 Subject: [PATCH] fix: preserve analysis bands across input workflows --- README.md | 18 ++- apps/acoustic-camera/index.html | 10 +- apps/acoustic-camera/main.js | 41 +++++- apps/array-builder/index.html | 2 +- apps/dataset-builder/index.html | 2 +- docs/index.html | 10 +- tests/integration/README.md | 3 +- tests/integration/camera-ui.test.js | 207 ++++++++++++++++++++++++++++ tests/unit/README.md | 3 +- tests/unit/camera.test.js | 10 ++ 10 files changed, 282 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2433879..5a3dcbc 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,14 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve. ### 이동 음원 바로 보기 -1. Acoustic Camera에서 **이동 예제 열기**를 누릅니다. +1. Acoustic Camera에서 **이동 예제 전체 열기**를 누릅니다. 2. Moving 카드의 **Map 준비**를 눌러 2 Hz acoustic map을 계산합니다. 3. **재생**을 누르거나 시간 slider를 움직여 음원과 heatmap의 이동을 확인합니다. 이 기능은 trajectory가 있는 합성 Scene의 준정적 시간창 재생입니다. 실제 192채널 마이크 장치에서 들어오는 스트림을 캡처하는 기능은 아닙니다. +이 버튼은 Scene뿐 아니라 Array와 Experiment, 분석 대역까지 예제 전체로 교체합니다. 여러 대역이나 +저장하지 않은 설정이 있으면 교체 전에 확인합니다. ## 내 장면으로 전체 흐름 실행하기 @@ -111,7 +113,8 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve. ### 3. Acoustic Camera에서 실행하기 1. 홈으로 돌아가 **Acoustic Camera**를 엽니다. -2. 다음 순서로 입력합니다: **Scene → Array → Experiment → Assets**. +2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment를 먼저 + 열어도 모든 분석 대역이 유지됩니다. 3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다. 4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다. 5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다. @@ -120,8 +123,15 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve. 7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다. 8. 완료 후 **Result JSON**을 저장합니다. -Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수 -있습니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다. +Experiment를 먼저 불러오면 문서와 모든 `frequencyBands`를 즉시 편집할 수 있습니다. 현재 +Scene·Array와 `sceneId`·`arrayId`가 다른 동안에는 검증 카드에 연결 대기를 표시하고 실행만 +비활성화합니다. 참조하는 Scene과 Array를 이어서 불러오면 실행할 수 있습니다. Scene을 다시 +불러오면 이전 asset 연결은 해제됩니다. + +Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에 +유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와 +Scene 또는 Array를 선택하세요. 같은 Camera 페이지에서 Scene을 교체하면 scan 영역과 +`sceneId`만 맞추며 기존 `frequencyBands`는 보존합니다. Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수 있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다. diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index 05bd867..dcac2eb 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -34,24 +34,24 @@
1 -

입력 준비

기본 예제를 쓰거나 파일을 순서대로 교체하세요.

+

입력 준비

기본 예제를 쓰거나 파일을 교체하세요. Experiment부터 열어도 대역은 유지됩니다.

- +
- +

현재 장면

Builder 새 탭 ↗
- +
MIC ARRAY

배열

Array Builder 새 탭 ↗
— ch
- Camera 열기 + 새 Camera 열기
diff --git a/apps/dataset-builder/index.html b/apps/dataset-builder/index.html index d7bb781..205d436 100644 --- a/apps/dataset-builder/index.html +++ b/apps/dataset-builder/index.html @@ -30,7 +30,7 @@ 더보기 diff --git a/docs/index.html b/docs/index.html index 60f92d8..f97b8e6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -109,8 +109,10 @@
  • Scene 저장 후 Camera 열기를 누릅니다.
  • Camera에서 파일을 Scene → Array → Experiment → Assets 순서로 - 가져옵니다. Array Builder에서 저장한 파일이 있다면 Array 단계에서 선택하고, - 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다. + 가져오는 것을 권장합니다. Experiment를 먼저 열어도 분석 대역은 보존되며, 참조 + Scene·Array가 연결될 때까지 실행만 비활성화됩니다. Array Builder에서 저장한 파일이 + 있다면 Array 단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 + 둡니다.
  • Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다. @@ -266,8 +268,8 @@

    start.cmd -Port 4174처럼 다른 포트를 지정하고 http://127.0.0.1:4174/을 여세요.

    - Experiment를 가져올 수 없습니다. -

    Scene과 Array를 먼저 가져오세요. Experiment의 sceneId와 arrayId가 현재 문서와 같아야 합니다.

    + Experiment를 가져온 뒤 실행할 수 없습니다. +

    Experiment부터 가져와도 frequencyBands는 모두 표시됩니다. 검증 카드에 연결 대기가 보이면 참조하는 Scene과 Array를 이어서 가져와 sceneId와 arrayId를 맞추세요.

    Assets가 준비되지 않습니다. diff --git a/tests/integration/README.md b/tests/integration/README.md index 4cbf133..2673d60 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -25,7 +25,8 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장 - `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo, 중복 좌표 차단, 표준 Array JSON 저장·재가져오기와 390px layout - `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와 - DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약; 이동 예제 로드, 8×8 CPU·2 Hz live map의 + DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약, Builder 새 탭과 Experiment 선로드·Scene + 재import의 다중 band 보존; 이동 예제 전체 교체 확인, 8×8 CPU·2 Hz live map의 Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지 - `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨 Result JSON을 저장하는 end-to-end 흐름 diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js index 95f8360..0478ea0 100644 --- a/tests/integration/camera-ui.test.js +++ b/tests/integration/camera-ui.test.js @@ -15,6 +15,13 @@ function dispatchValue(windowObject, element, value, eventName = "change") { element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true })); } +function dispatchFiles(windowObject, input, files) { + const transfer = new windowObject.DataTransfer(); + for (const file of files) transfer.items.add(file); + input.files = transfer.files; + input.dispatchEvent(new windowObject.Event("change", { bubbles: true })); +} + async function loadJson(path) { const response = await fetch(path); assert(response.ok, `${path} load failed`); @@ -337,6 +344,206 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 } }); +test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 다중 분석 대역을 보존한다", async () => { + const frame = document.createElement("iframe"); + frame.src = "/apps/acoustic-camera/?integration-test=1&band-preservation=1"; + frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0"; + document.body.append(frame); + await new Promise((resolve, reject) => { + frame.addEventListener("load", resolve, { once: true }); + frame.addEventListener( + "error", + () => reject(new Error("Acoustic Camera band-preservation iframe load failed")), + { once: true }, + ); + }); + + const windowObject = frame.contentWindow; + const documentObject = frame.contentDocument; + const downloads = []; + const objectUrls = []; + const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click; + const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL); + const originalConfirm = windowObject.confirm.bind(windowObject); + windowObject.HTMLAnchorElement.prototype.click = function captureDownload() { + if (this.download) downloads.push({ fileName: this.download, url: this.href }); + }; + windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url); + + const readBandEditors = () => + [...documentObject.querySelectorAll("#band-list .band-editor")].map((editor) => + Object.fromEntries( + [...editor.querySelectorAll("input[data-band-key]")].map((input) => [ + input.dataset.bandKey, + input.type === "number" ? Number(input.value) : input.value, + ]), + )); + + try { + await waitFor( + () => documentObject.documentElement.dataset.appReady === "true" && + documentObject.documentElement.dataset.cameraValid === "true", + "band-preservation Camera initialization", + ); + + const builderLinks = [...documentObject.querySelectorAll("a.builder-link")]; + assertEqual(builderLinks.length, 2, "Scene and Array Builder links"); + for (const link of builderLinks) { + assertEqual(link.target, "_blank", "Builder opens without unloading Camera"); + assert(link.relList.contains("noopener"), "Builder tab cannot replace Camera through opener"); + } + + const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); + scene.id = "two-tone-band-preservation"; + scene.name = "Two-tone band preservation"; + const secondSource = structuredClone(scene.sources[0]); + secondSource.id = "source-2"; + secondSource.name = "1.2 kHz tone"; + secondSource.position = [-0.12, 0.09, 2]; + secondSource.signal.frequency = 1200; + secondSource.signal.phaseDeg = 45; + scene.sources.push(secondSource); + + const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json"); + arrayDefinition.id = "custom-array-band-preservation"; + arrayDefinition.name = "Custom array band preservation"; + + const experiment = await loadJson("/assets/sample-scenes/single-tone.experiment.json"); + experiment.id = "two-band-import-first"; + experiment.name = "Two-band import first"; + experiment.sceneId = scene.id; + experiment.arrayId = arrayDefinition.id; + experiment.frequencyBands.push({ + id: "tone-1.2k", + minHz: 1100, + maxHz: 1300, + color: "#58c7ff", + }); + const expectedBands = structuredClone(experiment.frequencyBands); + const experimentFile = new windowObject.File( + [JSON.stringify(experiment)], + "two-band-import-first.experiment.json", + { type: "application/json" }, + ); + dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]); + await waitFor( + () => documentObject.documentElement.dataset.inputBusy === "false", + "clean-state Experiment-first import", + ); + assertEqual( + documentObject.querySelector("#experiment-id").value, + experiment.id, + "Experiment loads before its referenced Scene", + ); + assertEqual(readBandEditors().length, 2, "Experiment-first import renders every analysis band"); + assertEqual( + JSON.stringify(readBandEditors()), + JSON.stringify(expectedBands), + "Experiment-first import preserves band IDs, ranges, and colors", + ); + await waitFor( + () => documentObject.documentElement.dataset.cameraValid === "false" && + documentObject.querySelector("#validation-message").textContent.includes("sceneId") && + documentObject.querySelector("#validation-message").textContent.includes("arrayId"), + "pending Scene and Array linkage validation", + ); + assert( + documentObject.querySelector("#validation-message").textContent.includes("다릅니다"), + "Experiment-first import explains the pending document ID mismatch", + ); + assert( + documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") && + documentObject.querySelector("#run-status").textContent.includes(scene.id) && + documentObject.querySelector("#run-status").textContent.includes(arrayDefinition.id) && + documentObject.querySelector("#run-status").textContent.includes("연결 대기"), + "Experiment-first status reports every band and pending reference", + ); + + let replacementConfirmCount = 0; + windowObject.confirm = () => { + replacementConfirmCount += 1; + return false; + }; + documentObject.querySelector("#load-moving-example-button").click(); + assertEqual(replacementConfirmCount, 1, "full moving example asks before replacing bands"); + assertEqual( + JSON.stringify(readBandEditors()), + JSON.stringify(expectedBands), + "declining the full example keeps every analysis band", + ); + assert( + documentObject.querySelector("#run-status").textContent.includes("유지"), + "declined replacement explains that inputs were kept", + ); + windowObject.confirm = originalConfirm; + + const sceneFile = new windowObject.File( + [JSON.stringify(scene)], + "two-tone-band-preservation.scene.json", + { type: "application/json" }, + ); + dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]); + await waitFor( + () => documentObject.documentElement.dataset.inputBusy === "false" && + documentObject.querySelectorAll("#source-summary .source-line").length === 2 && + documentObject.querySelectorAll("#band-list .band-editor").length === 2 && + documentObject.documentElement.dataset.cameraValid === "false", + "source-added Scene reimport", + ); + assertEqual( + JSON.stringify(readBandEditors()), + JSON.stringify(expectedBands), + "Scene reimport preserves band IDs, ranges, and colors", + ); + assert( + documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"), + "Camera status keeps the preserved band count visible", + ); + + const arrayFile = new windowObject.File( + [JSON.stringify(arrayDefinition)], + "custom-array-band-preservation.array.json", + { type: "application/json" }, + ); + dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [arrayFile]); + await waitFor( + () => documentObject.documentElement.dataset.inputBusy === "false" && + documentObject.querySelectorAll("#band-list .band-editor").length === 2 && + documentObject.documentElement.dataset.cameraValid === "true", + "referenced Array reimport", + ); + assertEqual( + JSON.stringify(readBandEditors()), + JSON.stringify(expectedBands), + "Array reimport also preserves every analysis band", + ); + assert( + documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"), + "ready status keeps the connected Experiment band count visible", + ); + + documentObject.querySelector("#export-experiment-button").click(); + const experimentDownload = await waitFor( + () => downloads.find((download) => download.fileName.endsWith(".experiment.json")), + "preserved Experiment export", + ); + const exportedExperiment = await fetch(experimentDownload.url).then((response) => response.json()); + assertEqual( + JSON.stringify(exportedExperiment.frequencyBands), + JSON.stringify(expectedBands), + "Experiment export preserves every band after Scene reimport", + ); + assertEqual(exportedExperiment.sceneId, scene.id, "Experiment rebinds only the Scene ID"); + assertEqual(exportedExperiment.arrayId, arrayDefinition.id, "Experiment rebinds the Array ID"); + } finally { + windowObject.confirm = originalConfirm; + windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; + windowObject.URL.revokeObjectURL = originalRevoke; + for (const url of objectUrls) originalRevoke(url); + frame.remove(); + } +}); + test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source example", async () => { const frame = document.createElement("iframe"); frame.src = "/apps/acoustic-camera/?integration-test=1"; diff --git a/tests/unit/README.md b/tests/unit/README.md index 7dc8311..3024569 100644 --- a/tests/unit/README.md +++ b/tests/unit/README.md @@ -34,7 +34,8 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩 1/2/5 grid와 keyboard nudge - `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적 beam-spread covariance ellipse -- `camera.test.js`: canonical input hash, workload 추정, 이동 timeline 계획·재생 보간, Result schema 검증 +- `camera.test.js`: canonical input hash, workload 추정, Scene 정렬의 분석 band 보존, + 이동 timeline 계획·재생 보간, Result schema 검증 - `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching, 장치 한계와 typed CPU fallback 정책 - `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치 diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js index 6ef1122..58fa9e9 100644 --- a/tests/unit/camera.test.js +++ b/tests/unit/camera.test.js @@ -382,7 +382,12 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => { test("Camera aligns an experiment to an imported scene without changing grid resolution", () => { const experiment = { scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 }, + frequencyBands: [ + { id: "low", minHz: 600, maxHz: 900, color: "#65f2bf" }, + { id: "high", minHz: 1100, maxHz: 1500, color: "#58c7ff" }, + ], }; + const originalBands = structuredClone(experiment.frequencyBands); const imageScene = { sources: [{ id: "one", position: [0.2, -0.1, 4.5] }], image: { plane: { z: 4.5, xMin: -2, xMax: 3, yMin: -1.5, yMax: 2.5 } }, @@ -433,6 +438,11 @@ test("Camera aligns an experiment to an imported scene without changing grid res }; assertEqual(alignExperimentToScene(experiment, movingScene), "source-bounds", "trajectory fallback"); assertEqual(experiment.scan.z, 5, "trajectory depths contribute to median scan depth"); + assertEqual( + JSON.stringify(experiment.frequencyBands), + JSON.stringify(originalBands), + "scene alignment preserves every analysis band", + ); assertNear(experiment.scan.xMin, -2.9, 1e-12, "trajectory x minimum with padding"); assertNear(experiment.scan.xMax, 4.9, 1e-12, "trajectory x maximum with padding"); assertNear(experiment.scan.yMin, -4.2, 1e-12, "trajectory y minimum with padding");