fix: preserve analysis bands across input workflows

This commit is contained in:
2026-08-29 08:05:15 +09:00
parent 122b6cf7be
commit 62971e07ec
10 changed files with 282 additions and 24 deletions
+14 -4
View File
@@ -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을 계산합니다. 2. Moving 카드의 **Map 준비**를 눌러 2 Hz acoustic map을 계산합니다.
3. **재생**을 누르거나 시간 slider를 움직여 음원과 heatmap의 이동을 확인합니다. 3. **재생**을 누르거나 시간 slider를 움직여 음원과 heatmap의 이동을 확인합니다.
이 기능은 trajectory가 있는 합성 Scene의 준정적 시간창 재생입니다. 실제 192채널 마이크 장치에서 이 기능은 trajectory가 있는 합성 Scene의 준정적 시간창 재생입니다. 실제 192채널 마이크 장치에서
들어오는 스트림을 캡처하는 기능은 아닙니다. 들어오는 스트림을 캡처하는 기능은 아닙니다.
이 버튼은 Scene뿐 아니라 Array와 Experiment, 분석 대역까지 예제 전체로 교체합니다. 여러 대역이나
저장하지 않은 설정이 있으면 교체 전에 확인합니다.
## 내 장면으로 전체 흐름 실행하기 ## 내 장면으로 전체 흐름 실행하기
@@ -111,7 +113,8 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
### 3. Acoustic Camera에서 실행하기 ### 3. Acoustic Camera에서 실행하기
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다. 1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
2. 다음 순서로 입력합니다: **Scene → Array → Experiment → Assets**. 2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment를 먼저
열어도 모든 분석 대역이 유지됩니다.
3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다. 3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다.
4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다. 4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다.
5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다. 5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다.
@@ -120,8 +123,15 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다. 7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다.
8. 완료 후 **Result JSON**을 저장합니다. 8. 완료 후 **Result JSON**을 저장합니다.
Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수 Experiment를 먼저 불러오면 문서와 모든 `frequencyBands`를 즉시 편집할 수 있습니다. 현재
있습니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다. 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으로 저장할 수 Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다. 있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
+5 -5
View File
@@ -34,24 +34,24 @@
<section class="workflow-card input-card" id="input-step" data-workflow-section="1"> <section class="workflow-card input-card" id="input-step" data-workflow-section="1">
<div class="step-heading"> <div class="step-heading">
<span class="step-number">1</span> <span class="step-number">1</span>
<div><h2>입력 준비</h2><p>기본 예제를 쓰거나 파일을 순서대로 교체하세요.</p></div> <div><h2>입력 준비</h2><p>기본 예제를 쓰거나 파일을 교체하세요. Experiment부터 열어도 대역은 유지됩니다.</p></div>
</div> </div>
<div class="workspace-tools input-file-grid" aria-label="입력 파일"> <div class="workspace-tools input-file-grid" aria-label="입력 파일">
<label class="file-button file-choice" title="Scene JSON을 선택하세요."><span class="file-sequence">1</span><span><strong>Scene</strong><small>장면·음원</small></span><input id="scene-input" type="file" accept="application/json,.json" /></label> <label class="file-button file-choice" title="Scene JSON을 선택하세요."><span class="file-sequence">1</span><span><strong>Scene</strong><small>장면·음원</small></span><input id="scene-input" type="file" accept="application/json,.json" /></label>
<label class="file-button file-choice" title="사용자 배열이 있을 때 Array JSON을 선택하세요."><span class="file-sequence">2</span><span><strong>Array</strong><small>마이크 배열</small></span><input id="array-input" type="file" accept="application/json,.json" /></label> <label class="file-button file-choice" title="사용자 배열이 있을 때 Array JSON을 선택하세요."><span class="file-sequence">2</span><span><strong>Array</strong><small>마이크 배열</small></span><input id="array-input" type="file" accept="application/json,.json" /></label>
<label class="file-button file-choice" title="현재 Scene과 Array를 참조하는 Experiment JSON을 선택하세요."><span class="file-sequence">3</span><span><strong>Experiment</strong><small>분석 설정</small></span><input id="experiment-input" type="file" accept="application/json,.json" /></label> <label class="file-button file-choice" title="Experiment를 먼저 열 수 있습니다. 참조 ID가 다른 동안에는 실행만 비활성화됩니다."><span class="file-sequence">3</span><span><strong>Experiment</strong><small>분석 설정 · 선로드 가능</small></span><input id="experiment-input" type="file" accept="application/json,.json" /></label>
<label class="file-button file-choice" title="Scene에 사용한 원본 파일을 다시 선택하세요."><span class="file-sequence">4</span><span><strong>Assets</strong><small>오디오·이미지</small></span><input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label> <label class="file-button file-choice" title="Scene에 사용한 원본 파일을 다시 선택하세요."><span class="file-sequence">4</span><span><strong>Assets</strong><small>오디오·이미지</small></span><input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
</div> </div>
<div class="input-summary"> <div class="input-summary">
<div class="compact-section-heading"><h3>현재 장면</h3><a class="builder-link" href="/apps/dataset-builder/">Builder에서 편집 →</a></div> <div class="compact-section-heading"><h3>현재 장면</h3><a class="builder-link" href="/apps/dataset-builder/" target="_blank" rel="noopener" title="현재 Camera의 Experiment를 유지한 채 새 탭에서 Scene을 편집합니다. 저장 후 이 Camera 탭으로 돌아와 Scene JSON을 여세요.">Builder 새 탭 ↗</a></div>
<div class="source-summary" id="source-summary"></div> <div class="source-summary" id="source-summary"></div>
<div class="asset-list" id="asset-list"></div> <div class="asset-list" id="asset-list"></div>
</div> </div>
<div class="array-card"> <div class="array-card">
<div class="array-heading"><div><span class="compact-label">MIC ARRAY</span><h3 id="array-title">배열</h3><a class="builder-link" href="/apps/array-builder/">Array Builder에서 만들기 →</a></div><strong id="microphone-count">— ch</strong></div> <div class="array-heading"><div><span class="compact-label">MIC ARRAY</span><h3 id="array-title">배열</h3><a class="builder-link" href="/apps/array-builder/" target="_blank" rel="noopener" title="현재 Camera의 Experiment를 유지한 채 새 탭에서 배열을 편집합니다. 저장 후 이 Camera 탭으로 돌아와 Array JSON을 여세요.">Array Builder 새 탭 ↗</a></div><strong id="microphone-count">— ch</strong></div>
<label class="field"> <label class="field">
<span>배열 선택</span> <span>배열 선택</span>
<select id="array-preset"> <select id="array-preset">
@@ -200,7 +200,7 @@
</div> </div>
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p> <p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
<div class="live-actions"> <div class="live-actions">
<button class="button button--ghost" id="load-moving-example-button" type="button">이동 예제 열기</button> <button class="button button--ghost" id="load-moving-example-button" type="button" title="Scene·Array·Experiment와 분석 대역을 이동 예제 전체로 교체합니다.">이동 예제 전체 열기</button>
<button class="button button--primary" id="prepare-live-button" type="button" disabled>Map 준비</button> <button class="button button--primary" id="prepare-live-button" type="button" disabled>Map 준비</button>
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button> <button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
</div> </div>
+34 -7
View File
@@ -981,7 +981,8 @@ function applyValidation(validation) {
if (!state.lastRun && document.documentElement.dataset.runState === "idle") { if (!state.lastRun && document.documentElement.dataset.runState === "idle") {
setRunStatus( setRunStatus(
`${state.arrayDefinition.microphones.length} ch · ` + `${state.arrayDefinition.microphones.length} ch · ` +
`${state.experiment.scan.columns}×${state.experiment.scan.rows} · 실행 준비 완료`, `${state.experiment.scan.columns}×${state.experiment.scan.rows} · ` +
`분석 대역 ${state.experiment.frequencyBands.length}개 · 실행 준비 완료`,
); );
} }
} }
@@ -1470,6 +1471,18 @@ function toggleLivePlayback() {
async function loadMovingExample() { async function loadMovingExample() {
if (state.active) return; 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(); beginInputOperation();
try { try {
setRunStatus("이동 음원 예제를 불러오고 있습니다…"); setRunStatus("이동 음원 예제를 불러오고 있습니다…");
@@ -1494,7 +1507,7 @@ async function loadMovingExample() {
assetRuntime.reset(); assetRuntime.reset();
markChanged({ syncControls: true, renderBands: true, dirty: "none" }); markChanged({ syncControls: true, renderBands: true, dirty: "none" });
renderAssets(); renderAssets();
setRunStatus("이동 음원 예제를 열었습니다. ‘라이브 준비’를 누르세요."); setRunStatus("Scene·Array·Experiment를 이동 음원 예제 전체로 교체했습니다. ‘Map 준비’를 누르세요.");
} catch (error) { } catch (error) {
setTransientError(error, "이동 예제 불러오기 실패"); setTransientError(error, "이동 예제 불러오기 실패");
} finally { } finally {
@@ -1561,10 +1574,10 @@ async function importDocument(kind, input) {
} else if (kind === "scene") { } else if (kind === "scene") {
issues = await validateScene(value); issues = await validateScene(value);
} else { } else {
issues = await validateExperiment(value, { // An Experiment is a useful editable document even before its referenced
arrayDefinition: state.arrayDefinition, // Scene or Array has been loaded. Validate its own contract here, then let
scene: state.scene, // validateCurrent() keep Run disabled until the document IDs line up.
}); issues = await validateExperiment(value);
} }
if (generation !== state.importGenerations[kind]) return; if (generation !== state.importGenerations[kind]) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · ")); if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
@@ -1588,7 +1601,21 @@ async function importDocument(kind, input) {
dirty: kind === "experiment" ? "none" : "experiment", dirty: kind === "experiment" ? "none" : "experiment",
}); });
renderAssets(); renderAssets();
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`); const bandStatus = kind === "scene"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개 유지`
: kind === "experiment"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개`
: "";
const pendingReferences = kind === "experiment"
? [
value.sceneId !== state.scene?.id ? `Scene ${value.sceneId}` : null,
value.arrayId !== state.arrayDefinition?.id ? `Array ${value.arrayId}` : null,
].filter(Boolean)
: [];
const referenceStatus = pendingReferences.length > 0
? ` · ${pendingReferences.join(" · ")} 연결 대기`
: "";
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}${referenceStatus}`);
} catch (error) { } catch (error) {
setTransientError(error, `${file.name} 불러오기 실패`); setTransientError(error, `${file.name} 불러오기 실패`);
} finally { } finally {
+1 -1
View File
@@ -34,7 +34,7 @@
JSON 열기 JSON 열기
<input id="array-input" type="file" accept="application/json,.json" /> <input id="array-input" type="file" accept="application/json,.json" />
</label> </label>
<a class="button button--ghost" href="/apps/acoustic-camera/">Camera 열기</a> <a class="button button--ghost" href="/apps/acoustic-camera/" title="새 Camera 세션을 엽니다. 기존 Camera에서 편집을 시작했다면 원래 Camera 탭으로 돌아가 Array JSON을 선택하세요.">새 Camera 열기</a>
<button class="button button--primary" id="export-button" type="button">Array JSON 저장</button> <button class="button button--primary" id="export-button" type="button">Array JSON 저장</button>
</div> </div>
</header> </header>
+1 -1
View File
@@ -30,7 +30,7 @@
<summary class="button button--ghost" aria-label="추가 작업">더보기</summary> <summary class="button button--ghost" aria-label="추가 작업">더보기</summary>
<div class="header-more__menu"> <div class="header-more__menu">
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button> <button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
<a class="button button--ghost" href="/apps/acoustic-camera/">Acoustic Camera 열기 →</a> <a class="button button--ghost" href="/apps/acoustic-camera/" title="새 Camera 세션을 엽니다. 기존 Camera에서 편집을 시작했다면 원래 Camera 탭으로 돌아가 Scene JSON을 선택하세요.">새 Acoustic Camera 열기 →</a>
</div> </div>
</details> </details>
</div> </div>
+6 -4
View File
@@ -109,8 +109,10 @@
<li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li> <li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li>
<li> <li>
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로 Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
가져옵니다. Array Builder에서 저장한 파일이 있다면 Array 단계에서 선택하고, 가져오는 것을 권장합니다. Experiment를 먼저 열어도 분석 대역은 보존되며, 참조
내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다. Scene·Array가 연결될 때까지 실행만 비활성화됩니다. Array Builder에서 저장한 파일이
있다면 Array 단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로
둡니다.
</li> </li>
<li> <li>
Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다. Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다.
@@ -266,8 +268,8 @@
<p><code>start.cmd -Port 4174</code>처럼 다른 포트를 지정하고 <code>http://127.0.0.1:4174/</code>을 여세요.</p> <p><code>start.cmd -Port 4174</code>처럼 다른 포트를 지정하고 <code>http://127.0.0.1:4174/</code>을 여세요.</p>
</details> </details>
<details> <details>
<summary>Experiment를 가져올 수 없습니다.</summary> <summary>Experiment를 가져온 뒤 실행할 수 없습니다.</summary>
<p>Scene과 Array를 먼저 가져오세요. Experiment의 <code>sceneId</code>와 <code>arrayId</code>가 현재 문서와 같아야 합니다.</p> <p>Experiment부터 가져와도 <code>frequencyBands</code>는 모두 표시됩니다. 검증 카드에 연결 대기가 보이면 참조하는 Scene과 Array를 이어서 가져와 <code>sceneId</code>와 <code>arrayId</code>를 맞추세요.</p>
</details> </details>
<details> <details>
<summary>Assets가 준비되지 않습니다.</summary> <summary>Assets가 준비되지 않습니다.</summary>
+2 -1
View File
@@ -25,7 +25,8 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장
- `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo, - `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo,
중복 좌표 차단, 표준 Array JSON 저장·재가져오기와 390px layout 중복 좌표 차단, 표준 Array JSON 저장·재가져오기와 390px layout
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와 - `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, 재생 시간 증가와 일시정지 Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨 - `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
Result JSON을 저장하는 end-to-end 흐름 Result JSON을 저장하는 end-to-end 흐름
+207
View File
@@ -15,6 +15,13 @@ function dispatchValue(windowObject, element, value, eventName = "change") {
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true })); 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) { async function loadJson(path) {
const response = await fetch(path); const response = await fetch(path);
assert(response.ok, `${path} load failed`); 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 () => { test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source example", async () => {
const frame = document.createElement("iframe"); const frame = document.createElement("iframe");
frame.src = "/apps/acoustic-camera/?integration-test=1"; frame.src = "/apps/acoustic-camera/?integration-test=1";
+2 -1
View File
@@ -34,7 +34,8 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
1/2/5 grid와 keyboard nudge 1/2/5 grid와 keyboard nudge
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적 - `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
beam-spread covariance ellipse 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, - `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
장치 한계와 typed CPU fallback 정책 장치 한계와 typed CPU fallback 정책
- `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치 - `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치
+10
View File
@@ -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", () => { test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
const experiment = { const experiment = {
scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 }, 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 = { const imageScene = {
sources: [{ id: "one", position: [0.2, -0.1, 4.5] }], 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 } }, 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(alignExperimentToScene(experiment, movingScene), "source-bounds", "trajectory fallback");
assertEqual(experiment.scan.z, 5, "trajectory depths contribute to median scan depth"); 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.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.xMax, 4.9, 1e-12, "trajectory x maximum with padding");
assertNear(experiment.scan.yMin, -4.2, 1e-12, "trajectory y minimum with padding"); assertNear(experiment.scan.yMin, -4.2, 1e-12, "trajectory y minimum with padding");