fix: harden array builder editing workflows

This commit is contained in:
2026-08-28 23:10:41 +09:00
parent 3714146df9
commit 122b6cf7be
14 changed files with 1148 additions and 165 deletions
+5
View File
@@ -59,6 +59,11 @@ Array JSON을 저장하세요.
- **실행 취소:** `Ctrl+Z`, 다시 실행은 `Ctrl+Shift+Z`입니다. - **실행 취소:** `Ctrl+Z`, 다시 실행은 `Ctrl+Shift+Z`입니다.
확대·축소와 화면 맞춤은 보기만 바꾸며 저장 좌표에는 영향을 주지 않습니다. 확대·축소와 화면 맞춤은 보기만 바꾸며 저장 좌표에는 영향을 주지 않습니다.
터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로
스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로
스크롤하려는 동작으로 채널이 생기지 않습니다.
`JSON 열기`로 다른 배열을 불러온 직후에도 한 번 실행 취소하면 불러오기 전 문서를 복구할 수
있습니다. 다시 실행하면 불러온 배열로 돌아갑니다.
## 보정값 보존 ## 보정값 보존
+102 -4
View File
@@ -5,6 +5,7 @@ const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3; const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25; const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16; const MAX_ZOOM = 16;
const TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 }); const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]); export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
@@ -97,6 +98,26 @@ export function fitArrayViewport(width, height, arrayDefinition, options = {}) {
const centerY = (contentYMin + contentYMax) / 2; const centerY = (contentYMin + contentYMax) / 2;
const visibleWidth = plot.width / scale; const visibleWidth = plot.width / scale;
const visibleHeight = plot.height / scale; const visibleHeight = plot.height / scale;
const fittedValues = [
scale,
1 / scale,
centerX,
centerY,
visibleWidth,
visibleHeight,
centerX - visibleWidth / 2,
centerX + visibleWidth / 2,
centerY - visibleHeight / 2,
centerY + visibleHeight / 2,
];
if (fittedValues.some((value) => !Number.isFinite(value))) {
return fitArrayViewport(
canvasWidth,
canvasHeight,
{ aperture, microphones: [] },
options,
);
}
return { return {
width: canvasWidth, width: canvasWidth,
@@ -168,6 +189,7 @@ export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
const microphone = microphones[index]; const microphone = microphones[index];
if (!validPosition(microphone?.position)) continue; if (!validPosition(microphone?.position)) continue;
const pixel = arrayPositionToCanvas(microphone.position, viewport); const pixel = arrayPositionToCanvas(microphone.position, viewport);
if (!Number.isFinite(pixel.x) || !Number.isFinite(pixel.y)) continue;
const distancePx = Math.hypot(pixel.x - point.x, pixel.y - point.y); const distancePx = Math.hypot(pixel.x - point.x, pixel.y - point.y);
if (distancePx <= maximumDistance && (!best || distancePx < best.distancePx)) { if (distancePx <= maximumDistance && (!best || distancePx < best.distancePx)) {
best = { id: microphone.id, index, microphone, point: pixel, distancePx }; best = { id: microphone.id, index, microphone, point: pixel, distancePx };
@@ -359,13 +381,14 @@ function drawMicrophones(context, model, viewport, drag) {
const position = drag?.id === microphone.id ? drag.last : microphone.position; const position = drag?.id === microphone.id ? drag.last : microphone.position;
const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position }; const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position };
const point = arrayPositionToCanvas(position, viewport); const point = arrayPositionToCanvas(position, viewport);
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
const isSelected = microphone.id === selectedId; const isSelected = microphone.id === selectedId;
const inside = positionInsideAperture(position, arrayDefinition.aperture, model.apertureShape); const inside = positionInsideAperture(position, arrayDefinition.aperture, model.apertureShape);
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius; const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)"; context.fillStyle = inside ? "rgba(101,242,191,.96)" : "rgba(255,184,92,.98)";
context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)"; context.shadowColor = inside ? "rgba(101,242,191,.7)" : "rgba(255,184,92,.7)";
context.shadowBlur = isSelected ? 16 : 6; context.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
context.beginPath(); context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2); context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill(); context.fill();
@@ -449,10 +472,9 @@ export function createArrayCanvas(canvas, callbacks = {}) {
let zoom = 1; let zoom = 1;
let tool = "select"; let tool = "select";
let drag = null; let drag = null;
let pendingAdd = null;
if (canvas.tabIndex < 0) canvas.tabIndex = 0; if (canvas.tabIndex < 0) canvas.tabIndex = 0;
if (canvas.style) canvas.style.touchAction = "none";
function updateCanvasAffordance() { function updateCanvasAffordance() {
if (canvas.dataset) { if (canvas.dataset) {
canvas.dataset.tool = tool; canvas.dataset.tool = tool;
@@ -492,6 +514,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
function setTool(nextTool) { function setTool(nextTool) {
const resolved = normalizedTool(nextTool); const resolved = normalizedTool(nextTool);
if (resolved === tool) return tool; if (resolved === tool) return tool;
if (resolved !== "add") pendingAdd = null;
tool = resolved; tool = resolved;
updateCanvasAffordance(); updateCanvasAffordance();
callbacks.onToolChange?.(tool); callbacks.onToolChange?.(tool);
@@ -550,15 +573,49 @@ export function createArrayCanvas(canvas, callbacks = {}) {
return eventToCanvasPoint(event, canvas.getBoundingClientRect()); return eventToCanvasPoint(event, canvas.getBoundingClientRect());
} }
function clientPoint(event) {
return {
x: finiteNumber(event?.clientX, 0),
y: finiteNumber(event?.clientY, 0),
};
}
function exceedsTouchThreshold(start, event) {
const current = clientPoint(event);
return Math.hypot(current.x - start.x, current.y - start.y) >
TOUCH_GESTURE_THRESHOLD_PX;
}
function onTouchStart(event) {
if (destroyed || tool !== "select" || !model?.arrayDefinition) return;
const touch = event.changedTouches?.[0] ?? event.touches?.[0];
const currentViewport = ensureViewport();
if (!touch || !currentViewport) return;
const hit = hitTestMicrophone(
pointerPoint(touch),
model.arrayDefinition.microphones,
currentViewport,
);
if (hit) event.preventDefault();
}
function onPointerDown(event) { function onPointerDown(event) {
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return; if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
if (drag || pendingAdd) return;
focus(); focus();
if (tool === "add") { if (tool === "add") {
const position = constrainedEventPosition(event, 0); const position = constrainedEventPosition(event, 0);
if (position) { if (position) {
if (event.pointerType === "touch") {
pendingAdd = {
pointerId: event.pointerId,
startClient: clientPoint(event),
};
} else {
event.preventDefault(); event.preventDefault();
callbacks.onAdd?.(position); callbacks.onAdd?.(position);
} }
}
return; return;
} }
@@ -577,15 +634,31 @@ export function createArrayCanvas(canvas, callbacks = {}) {
id: hit.id, id: hit.id,
start: [...hit.microphone.position], start: [...hit.microphone.position],
last: [...hit.microphone.position], last: [...hit.microphone.position],
startClient: clientPoint(event),
pointerType: event.pointerType,
pending: event.pointerType === "touch",
}; };
try {
canvas.setPointerCapture?.(event.pointerId); canvas.setPointerCapture?.(event.pointerId);
callbacks.onDragStart?.(hit.id); } catch {
// Synthetic pointers and interrupted input do not always own capture.
}
if (!drag.pending) callbacks.onDragStart?.(hit.id);
updateCanvasAffordance(); updateCanvasAffordance();
schedule(); schedule();
} }
function onPointerMove(event) { function onPointerMove(event) {
if (pendingAdd?.pointerId === event.pointerId) {
if (exceedsTouchThreshold(pendingAdd.startClient, event)) pendingAdd = null;
return;
}
if (!drag || event.pointerId !== drag.pointerId) return; if (!drag || event.pointerId !== drag.pointerId) return;
if (drag.pending) {
if (!exceedsTouchThreshold(drag.startClient, event)) return;
drag.pending = false;
callbacks.onDragStart?.(drag.id);
}
const microphone = microphoneById(drag.id); const microphone = microphoneById(drag.id);
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]); const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
if (!position) return; if (!position) return;
@@ -599,17 +672,37 @@ export function createArrayCanvas(canvas, callbacks = {}) {
if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return; if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return;
const completed = drag; const completed = drag;
drag = null; drag = null;
if (!completed.pending) {
const position = cancel ? completed.start : completed.last; const position = cancel ? completed.start : completed.last;
if (cancel) callbacks.onDrag?.(completed.id, [...position]); if (cancel) callbacks.onDrag?.(completed.id, [...position]);
callbacks.onDragEnd?.(completed.id, [...position]); callbacks.onDragEnd?.(completed.id, [...position]);
}
const pointerId = event?.pointerId ?? completed.pointerId; const pointerId = event?.pointerId ?? completed.pointerId;
try {
if (canvas.hasPointerCapture?.(pointerId)) { if (canvas.hasPointerCapture?.(pointerId)) {
canvas.releasePointerCapture?.(pointerId); canvas.releasePointerCapture?.(pointerId);
} }
} catch {
// The browser may have released capture before pointercancel/lostcapture.
}
updateCanvasAffordance(); updateCanvasAffordance();
schedule(); schedule();
} }
function finishPendingAdd(event, cancel = false) {
if (!pendingAdd || event?.pointerId !== pendingAdd.pointerId) return false;
const completed = pendingAdd;
pendingAdd = null;
if (!cancel && tool === "add" && !exceedsTouchThreshold(completed.startClient, event)) {
const position = constrainedEventPosition(event, 0);
if (position) {
event.preventDefault();
callbacks.onAdd?.(position);
}
}
return true;
}
function onKeyDown(event) { function onKeyDown(event) {
if (event.altKey || event.ctrlKey || event.metaKey) return; if (event.altKey || event.ctrlKey || event.metaKey) return;
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key; const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
@@ -649,10 +742,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
} }
function onPointerUp(event) { function onPointerUp(event) {
if (finishPendingAdd(event, false)) return;
finishDrag(event, false); finishDrag(event, false);
} }
function onPointerCancel(event) { function onPointerCancel(event) {
if (finishPendingAdd(event, true)) return;
finishDrag(event, true); finishDrag(event, true);
} }
@@ -665,6 +760,7 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.addEventListener("pointerup", onPointerUp); canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerCancel); canvas.addEventListener("pointercancel", onPointerCancel);
canvas.addEventListener("lostpointercapture", onLostPointerCapture); canvas.addEventListener("lostpointercapture", onLostPointerCapture);
canvas.addEventListener("touchstart", onTouchStart, { passive: false });
canvas.addEventListener("keydown", onKeyDown); canvas.addEventListener("keydown", onKeyDown);
let resizeObserver = null; let resizeObserver = null;
@@ -698,10 +794,12 @@ export function createArrayCanvas(canvas, callbacks = {}) {
canvas.removeEventListener("pointerup", onPointerUp); canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointercancel", onPointerCancel); canvas.removeEventListener("pointercancel", onPointerCancel);
canvas.removeEventListener("lostpointercapture", onLostPointerCapture); canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
canvas.removeEventListener("touchstart", onTouchStart);
canvas.removeEventListener("keydown", onKeyDown); canvas.removeEventListener("keydown", onKeyDown);
model = null; model = null;
viewport = null; viewport = null;
drag = null; drag = null;
pendingAdd = null;
}, },
}; };
} }
+68 -62
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="/apps/array-builder/styles.css" /> <link rel="stylesheet" href="/apps/array-builder/styles.css" />
</head> </head>
<body> <body>
<a class="skip-link" href="#array-canvas">배열 편집 화면으로 건너뛰기</a>
<div class="app-shell array-builder-shell"> <div class="app-shell array-builder-shell">
<header class="app-header array-builder-header"> <header class="app-header array-builder-header">
<a class="app-brand" href="/"> <a class="app-brand" href="/">
@@ -39,6 +40,65 @@
</header> </header>
<main class="array-builder-layout" id="array-builder-layout" data-split-layout> <main class="array-builder-layout" id="array-builder-layout" data-split-layout>
<section class="array-workspace" id="array-builder-workspace" aria-label="마이크 배열 편집 화면">
<div class="workspace-heading">
<div>
<p class="eyebrow">마이크 배치</p>
<h1>Array 평면 편집</h1>
</div>
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
</div>
<div class="workspace-toolbar" role="toolbar" aria-label="배열 편집 도구">
<div class="toolbar-group" aria-label="편집 도구">
<button class="tool-button is-active" id="select-tool-button" type="button" aria-pressed="true" aria-keyshortcuts="V">선택 <kbd>V</kbd></button>
<button class="tool-button" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
</div>
<div class="toolbar-group" aria-label="실행 취소와 다시 실행">
<button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button>
<button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
</div>
<div class="toolbar-group zoom-tools" aria-label="화면 확대와 맞춤">
<button class="icon-button" id="zoom-out-button" type="button" aria-label="축소" title="축소">−</button>
<button class="tool-button" id="fit-view-button" type="button">화면 맞춤</button>
<button class="icon-button" id="zoom-in-button" type="button" aria-label="확대" title="확대">+</button>
</div>
<div class="snap-controls">
<label class="check-field"><input id="snap-enabled" type="checkbox" checked /><span>격자 스냅</span></label>
<label class="compact-field"><span>간격</span><input id="snap-step" type="number" min="0.1" max="1000" step="0.1" value="5" inputmode="decimal" /><small>mm</small></label>
</div>
</div>
<div class="array-canvas-card">
<canvas
id="array-canvas"
tabindex="0"
aria-label="마이크 어레이 평면 편집 canvas"
aria-describedby="canvas-instructions"
></canvas>
<div class="canvas-instructions" id="canvas-instructions">
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
</div>
<div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div>
</div>
<section class="array-statistics" aria-label="배열 요약">
<article class="metric-card"><span>채널</span><strong id="microphone-count">12</strong><small>microphones</small></article>
<article class="metric-card"><span>최소 간격</span><strong id="minimum-spacing">—</strong><small>nearest pair</small></article>
<article class="metric-card"><span>무게중심</span><strong id="array-centroid">—</strong><small>x, y, z</small></article>
<article class="metric-card"><span>보정 채널</span><strong id="calibration-count">0</strong><small>sensitivity / phase / response</small></article>
</section>
<details class="shortcut-help">
<summary>빠른 편집 키</summary>
<div>
<p><kbd>V</kbd> 선택 · <kbd>A</kbd> 추가 · <kbd>Delete</kbd> 선택 삭제</p>
<p><kbd>← ↑ ↓ →</kbd> 위치 미세 조정 · <kbd>Shift</kbd>를 함께 누르면 10배 이동</p>
</div>
</details>
</section>
<aside <aside
class="array-controls" class="array-controls"
id="array-builder-controls" id="array-builder-controls"
@@ -58,7 +118,7 @@
</div> </div>
</section> </section>
<section class="control-section"> <section class="control-section array-metadata-section">
<div class="section-title"><span>01</span><h2>배열 정보</h2></div> <div class="section-title"><span>01</span><h2>배열 정보</h2></div>
<label class="field field--wide"> <label class="field field--wide">
<span>이름</span> <span>이름</span>
@@ -81,7 +141,7 @@
</label> </label>
</section> </section>
<section class="control-section control-section--primary"> <section class="control-section control-section--primary aperture-section">
<div class="section-title"><span>02</span><h2>크기와 형상</h2></div> <div class="section-title"><span>02</span><h2>크기와 형상</h2></div>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
@@ -100,7 +160,7 @@
<option value="ellipse">타원</option> <option value="ellipse">타원</option>
</select> </select>
</label> </label>
<p class="section-note">크기를 바꿔도 기존 마이크 좌표는 자동으로 늘어나거나 줄어들지 않습니다.</p> <p class="section-note">크기를 바꿔도 기존 좌표는 자동으로 늘어나거나 줄어들지 않습니다. 생성 경계 형상은 편집용이며 JSON에는 최종 좌표와 너비·높이만 저장됩니다.</p>
<div class="button-grid button-grid--two"> <div class="button-grid button-grid--two">
<button class="button button--ghost" id="center-microphones-button" type="button">좌표 중앙 정렬</button> <button class="button button--ghost" id="center-microphones-button" type="button">좌표 중앙 정렬</button>
<button class="button button--ghost" id="fit-aperture-button" type="button">좌표에 크기 맞춤</button> <button class="button button--ghost" id="fit-aperture-button" type="button">좌표에 크기 맞춤</button>
@@ -108,7 +168,7 @@
</div> </div>
</section> </section>
<section class="control-section"> <section class="control-section preset-section">
<div class="section-title"><span>03</span><h2>프리셋 생성</h2></div> <div class="section-title"><span>03</span><h2>프리셋 생성</h2></div>
<label class="field field--wide"> <label class="field field--wide">
<span>배치 방식</span> <span>배치 방식</span>
@@ -201,6 +261,10 @@
</div> </div>
</div> </div>
</section> </section>
<div class="mobile-save-actions">
<button class="button button--primary button--full" id="mobile-export-button" type="button">Array JSON 저장</button>
</div>
</aside> </aside>
<div <div
@@ -216,64 +280,6 @@
title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화" title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화"
></div> ></div>
<section class="array-workspace" id="array-builder-workspace" aria-label="마이크 배열 편집 화면">
<div class="workspace-heading">
<div>
<p class="eyebrow">마이크 배치</p>
<h1>Array 평면 편집</h1>
</div>
<p class="workspace-summary" id="workspace-summary">0.45 × 0.45 m · 12 channels</p>
</div>
<div class="workspace-toolbar" role="toolbar" aria-label="배열 편집 도구">
<div class="toolbar-group" aria-label="편집 도구">
<button class="tool-button is-active" id="select-tool-button" type="button" aria-pressed="true" aria-keyshortcuts="V">선택 <kbd>V</kbd></button>
<button class="tool-button" id="add-tool-button" type="button" aria-pressed="false" aria-keyshortcuts="A">추가 <kbd>A</kbd></button>
</div>
<div class="toolbar-group" aria-label="실행 취소와 다시 실행">
<button class="tool-button" id="undo-button" type="button" aria-keyshortcuts="Control+Z" disabled>실행 취소</button>
<button class="tool-button" id="redo-button" type="button" aria-keyshortcuts="Control+Shift+Z" disabled>다시 실행</button>
</div>
<div class="toolbar-group zoom-tools" aria-label="화면 확대와 맞춤">
<button class="icon-button" id="zoom-out-button" type="button" aria-label="축소" title="축소">−</button>
<button class="tool-button" id="fit-view-button" type="button">화면 맞춤</button>
<button class="icon-button" id="zoom-in-button" type="button" aria-label="확대" title="확대">+</button>
</div>
<div class="snap-controls">
<label class="check-field"><input id="snap-enabled" type="checkbox" checked /><span>격자 스냅</span></label>
<label class="compact-field"><span>간격</span><input id="snap-step" type="number" min="0.1" max="1000" step="0.1" value="5" inputmode="decimal" /><small>mm</small></label>
</div>
</div>
<div class="array-canvas-card">
<canvas
id="array-canvas"
tabindex="0"
aria-label="마이크 어레이 평면 편집 canvas"
aria-describedby="canvas-instructions"
></canvas>
<div class="canvas-instructions" id="canvas-instructions">
<strong>마이크를 클릭하거나 드래그해 위치를 편집하세요.</strong>
<span>추가 도구에서는 빈 공간을 클릭해 새 채널을 만듭니다.</span>
</div>
<div class="selection-status" id="selection-status" role="status" aria-live="polite">선택된 마이크 없음</div>
</div>
<section class="array-statistics" aria-label="배열 요약">
<article class="metric-card"><span>채널</span><strong id="microphone-count">12</strong><small>microphones</small></article>
<article class="metric-card"><span>최소 간격</span><strong id="minimum-spacing">—</strong><small>nearest pair</small></article>
<article class="metric-card"><span>무게중심</span><strong id="array-centroid">—</strong><small>x, y, z</small></article>
<article class="metric-card"><span>보정 채널</span><strong id="calibration-count">0</strong><small>sensitivity / phase / response</small></article>
</section>
<details class="shortcut-help">
<summary>빠른 편집 키</summary>
<div>
<p><kbd>V</kbd> 선택 · <kbd>A</kbd> 추가 · <kbd>Delete</kbd> 선택 삭제</p>
<p><kbd>← ↑ ↓ →</kbd> 위치 미세 조정 · <kbd>Shift</kbd>를 함께 누르면 10배 이동</p>
</div>
</details>
</section>
</main> </main>
</div> </div>
+245 -44
View File
@@ -49,7 +49,7 @@ const optionalIds = [
"move-microphone-down-button", "microphone-sensitivity", "microphone-phase", "move-microphone-down-button", "microphone-sensitivity", "microphone-phase",
"frequency-response-summary", "preset-axis", "preset-edge-inset", "frequency-response-summary", "preset-axis", "preset-edge-inset",
"microphone-search", "microphone-selection-empty", "microphone-fields", "microphone-search", "microphone-selection-empty", "microphone-fields",
"workspace-summary", "workspace-summary", "canvas-instructions", "mobile-export-button",
]; ];
for (const id of optionalIds) elements[id] = maybeById(id); for (const id of optionalIds) elements[id] = maybeById(id);
elements.validationCard = elements["validation-title"].closest(".validation-card"); elements.validationCard = elements["validation-title"].closest(".validation-card");
@@ -80,14 +80,20 @@ const state = {
history: [], history: [],
future: [], future: [],
dragSnapshot: null, dragSnapshot: null,
importRevision: 0,
baselineSignature: "",
}; };
const MAX_HISTORY = 50; const MAX_HISTORY = 50;
const MAX_HISTORY_BYTES = 24 * 1024 * 1024;
const frequencyResponseFingerprintCache = new WeakMap();
const BUILTIN_ARRAY_IDS = new Set([ const BUILTIN_ARRAY_IDS = new Set([
"rectangular-2x2", "rectangular-4x3", "rectangular-8x6", "rectangular-2x2-450mm", "rectangular-4x3-450mm",
"rectangular-16x12", "rectangular-24x16", "rectangular-16x12-compact", "rectangular-8x6-450mm", "rectangular-16x12",
"rectangular-24x16-450mm", "rectangular-16x12-compact",
"rectangular-16x12-large", "fermat-spiral-192", "rectangular-16x12-large", "fermat-spiral-192",
]); ]);
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
function finiteInput(element, label, options = {}) { function finiteInput(element, label, options = {}) {
const value = element.valueAsNumber; const value = element.valueAsNumber;
@@ -121,35 +127,152 @@ function selectedMicrophone() {
) ?? null; ) ?? null;
} }
function cloneArrayForHistory(arrayDefinition) {
return {
...arrayDefinition,
coordinateSystem: { ...arrayDefinition.coordinateSystem },
aperture: { ...arrayDefinition.aperture },
microphones: arrayDefinition.microphones.map((microphone) => ({
...microphone,
position: [...microphone.position],
...(microphone.frequencyResponse === undefined
? {}
: { frequencyResponse: microphone.frequencyResponse }),
})),
};
}
function captureSnapshot() { function captureSnapshot() {
return { return {
arrayDefinition: structuredClone(state.arrayDefinition), arrayDefinition: cloneArrayForHistory(state.arrayDefinition),
apertureShape: state.apertureShape, apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId, selectedMicrophoneId: state.selectedMicrophoneId,
}; };
} }
function snapshotSignature(snapshot) { function editableSnapshotSignature(snapshot) {
return JSON.stringify(snapshot); const arrayDefinition = snapshot.arrayDefinition;
return JSON.stringify({
document: editableDocumentView(arrayDefinition),
apertureShape: snapshot.apertureShape,
selectedMicrophoneId: snapshot.selectedMicrophoneId,
});
}
function fingerprintFrequencyResponse(response) {
if (!Array.isArray(response)) return null;
const cached = frequencyResponseFingerprintCache.get(response);
if (cached) return cached;
const canonical = JSON.stringify(response.map((point) => [
point.frequencyHz,
point.gainDb,
point.phaseDeg ?? null,
]));
let first = 0x811c9dc5;
let second = 0x9e3779b9;
for (let index = 0; index < canonical.length; index += 1) {
const code = canonical.charCodeAt(index);
first = Math.imul(first ^ code, 0x01000193) >>> 0;
second = Math.imul(second ^ code, 0x85ebca6b) >>> 0;
}
const fingerprint =
String(canonical.length) + ":" +
first.toString(16).padStart(8, "0") + ":" +
second.toString(16).padStart(8, "0");
frequencyResponseFingerprintCache.set(response, fingerprint);
return fingerprint;
}
function editableDocumentView(arrayDefinition) {
return {
schemaVersion: arrayDefinition.schemaVersion,
kind: arrayDefinition.kind,
id: arrayDefinition.id,
name: arrayDefinition.name,
description: arrayDefinition.description,
units: arrayDefinition.units,
coordinateSystem: arrayDefinition.coordinateSystem,
aperture: arrayDefinition.aperture,
microphones: arrayDefinition.microphones.map((microphone) => [
microphone.id,
...microphone.position,
microphone.sensitivityDb ?? null,
microphone.phaseOffsetDeg ?? null,
fingerprintFrequencyResponse(microphone.frequencyResponse),
]),
};
}
function editableDocumentSignature(arrayDefinition) {
return JSON.stringify(editableDocumentView(arrayDefinition));
}
function currentSnapshotView() {
return {
arrayDefinition: state.arrayDefinition,
apertureShape: state.apertureShape,
selectedMicrophoneId: state.selectedMicrophoneId,
};
}
function estimatedSnapshotBytes(snapshot) {
const responsePointCount = snapshot.arrayDefinition.microphones.reduce(
(count, microphone) => count + (microphone.frequencyResponse?.length ?? 0),
0,
);
return editableSnapshotSignature(snapshot).length * 4 + responsePointCount * 160;
}
function historyEntry(snapshot, label) {
return { snapshot, label, bytes: estimatedSnapshotBytes(snapshot) };
}
function trimHistoryMemory() {
while (state.history.length > MAX_HISTORY) state.history.shift();
let totalBytes = [...state.history, ...state.future].reduce(
(sum, entry) => sum + (entry.bytes ?? estimatedSnapshotBytes(entry.snapshot)),
0,
);
while (totalBytes > MAX_HISTORY_BYTES && state.history.length > 1) {
totalBytes -= state.history.shift().bytes;
}
while (totalBytes > MAX_HISTORY_BYTES && state.future.length > 1) {
totalBytes -= state.future.shift().bytes;
}
while (
totalBytes > MAX_HISTORY_BYTES &&
state.history.length + state.future.length > 1
) {
const removed = state.history.length
? state.history.shift()
: state.future.shift();
totalBytes -= removed.bytes;
}
}
function refreshDirtyState() {
state.dirty = editableDocumentSignature(state.arrayDefinition) !== state.baselineSignature;
} }
function restoreSnapshot(snapshot, message) { function restoreSnapshot(snapshot, message) {
state.arrayDefinition = structuredClone(snapshot.arrayDefinition); state.arrayDefinition = cloneArrayForHistory(snapshot.arrayDefinition);
state.apertureShape = snapshot.apertureShape; state.apertureShape = snapshot.apertureShape;
state.selectedMicrophoneId = snapshot.selectedMicrophoneId; state.selectedMicrophoneId = snapshot.selectedMicrophoneId;
if (!selectedMicrophone()) { if (!selectedMicrophone()) {
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null; state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
} }
state.dirty = true; refreshDirtyState();
renderAll(message); renderAll(message);
} }
function recordSnapshot(before, label) { function recordSnapshot(before, label) {
if (snapshotSignature(before) === snapshotSignature(captureSnapshot())) return false; if (editableSnapshotSignature(before) === editableSnapshotSignature(currentSnapshotView())) {
state.history.push({ snapshot: before, label }); return false;
if (state.history.length > MAX_HISTORY) state.history.shift(); }
state.history.push(historyEntry(before, label));
state.future.length = 0; state.future.length = 0;
state.dirty = true; trimHistoryMemory();
refreshDirtyState();
return true; return true;
} }
@@ -165,7 +288,11 @@ function showActionError(error) {
if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함"; if (elements["save-state"]) elements["save-state"].textContent = "적용하지 못함";
elements["validation-title"].textContent = "작업을 적용하지 못했습니다"; elements["validation-title"].textContent = "작업을 적용하지 못했습니다";
elements["validation-message"].textContent = message; elements["validation-message"].textContent = message;
if (elements.validationCard) elements.validationCard.dataset.severity = "error"; if (elements.validationCard) {
elements.validationCard.dataset.severity = "error";
elements.validationCard.classList.add("is-invalid");
elements.validationCard.classList.remove("is-valid", "is-warning");
}
elements["array-builder-workspace"].dataset.actionError = message; elements["array-builder-workspace"].dataset.actionError = message;
} }
@@ -174,6 +301,9 @@ function safely(callback) {
delete elements["array-builder-workspace"].dataset.actionError; delete elements["array-builder-workspace"].dataset.actionError;
callback(); callback();
} catch (error) { } catch (error) {
renderDocumentEditor();
syncSelectedEditor();
setNumber(elements["snap-step"], state.snapStepM * 1000);
showActionError(error); showActionError(error);
} }
} }
@@ -183,9 +313,14 @@ function announce(message) {
elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨"; elements["save-state"].textContent = state.dirty ? message || "저장하지 않은 변경" : message || "저장됨";
elements["save-state"].classList.toggle("is-dirty", state.dirty); elements["save-state"].classList.toggle("is-dirty", state.dirty);
} }
if (elements["selection-status"] && message) { }
elements["selection-status"].textContent = message;
} function announceSelection(message) {
if (elements["selection-status"]) elements["selection-status"].textContent = message;
}
function dismissCanvasInstructions() {
elements["canvas-instructions"]?.classList.add("is-hidden");
} }
function setTool(tool, options = {}) { function setTool(tool, options = {}) {
@@ -196,6 +331,10 @@ function setTool(tool, options = {}) {
elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add")); elements["add-tool-button"].setAttribute("aria-pressed", String(state.tool === "add"));
elements["array-canvas"].dataset.tool = state.tool; elements["array-canvas"].dataset.tool = state.tool;
canvasView.render(canvasState()); canvasView.render(canvasState());
if (options.userInitiated) {
dismissCanvasInstructions();
announceSelection(state.tool === "add" ? "추가 도구 · 빈 곳을 클릭하세요." : "선택 도구");
}
if (options.focus) canvasView.focus(); if (options.focus) canvasView.focus();
} }
@@ -258,15 +397,29 @@ function selectMicrophone(id, options = {}) {
renderMicrophoneList(); renderMicrophoneList();
syncSelectedEditor(); syncSelectedEditor();
canvasView.render(canvasState()); canvasView.render(canvasState());
if (state.selectedMicrophoneId) announce(state.selectedMicrophoneId + " 선택"); const selectedButton = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")]
.find((button) => button.dataset.microphoneId === state.selectedMicrophoneId);
if (options.focusList) selectedButton?.focus();
if (options.scrollList !== false) {
selectedButton?.scrollIntoView?.({ block: "nearest" });
}
announceSelection(
state.selectedMicrophoneId
? state.selectedMicrophoneId + " 선택"
: "선택된 마이크 없음",
);
if (options.focusCanvas) canvasView.focus(); if (options.focusCanvas) canvasView.focus();
} }
const canvasView = createArrayCanvas(elements["array-canvas"], { const canvasView = createArrayCanvas(elements["array-canvas"], {
onSelect(id) { onSelect(id) {
selectMicrophone(id); dismissCanvasInstructions();
selectMicrophone(id, {
scrollList: window.matchMedia("(min-width: 761px)").matches,
});
}, },
onAdd(position) { onAdd(position) {
dismissCanvasInstructions();
safely(() => mutate("마이크 추가됨", () => { safely(() => mutate("마이크 추가됨", () => {
const microphone = addMicrophone( const microphone = addMicrophone(
state.arrayDefinition, state.arrayDefinition,
@@ -276,6 +429,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
})); }));
}, },
onDragStart(id) { onDragStart(id) {
dismissCanvasInstructions();
if (state.dragSnapshot) return; if (state.dragSnapshot) return;
state.selectedMicrophoneId = id; state.selectedMicrophoneId = id;
state.dragSnapshot = captureSnapshot(); state.dragSnapshot = captureSnapshot();
@@ -320,7 +474,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
})); }));
}, },
onToolChange(tool) { onToolChange(tool) {
setTool(tool); setTool(tool, { userInitiated: true });
}, },
onFit() { onFit() {
canvasView.fit(); canvasView.fit();
@@ -372,11 +526,14 @@ function renderMicrophoneList() {
const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? ""; const query = elements["microphone-search"]?.value.trim().toLowerCase() ?? "";
list.replaceChildren(); list.replaceChildren();
state.arrayDefinition.microphones.forEach((microphone, index) => { state.arrayDefinition.microphones.forEach((microphone, index) => {
if (query && !microphone.id.toLowerCase().includes(query)) return; const matchesQuery = !query || microphone.id.toLowerCase().includes(query);
if (!matchesQuery && microphone.id !== state.selectedMicrophoneId) return;
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "microphone-row"; button.className = "microphone-row";
button.dataset.microphoneId = microphone.id; button.dataset.microphoneId = microphone.id;
button.dataset.searchPinned = String(!matchesQuery);
button.tabIndex = microphone.id === state.selectedMicrophoneId ? 0 : -1;
button.setAttribute("role", "option"); button.setAttribute("role", "option");
button.setAttribute( button.setAttribute(
"aria-selected", "aria-selected",
@@ -412,9 +569,16 @@ function renderMicrophoneList() {
identity.append(calibration); identity.append(calibration);
} }
button.append(channel, identity); button.append(channel, identity);
button.addEventListener("click", () => selectMicrophone(microphone.id)); button.addEventListener("click", () => {
dismissCanvasInstructions();
selectMicrophone(microphone.id, { focusList: true });
});
list.append(button); list.append(button);
}); });
if (!list.querySelector('[tabindex="0"]')) {
const firstButton = list.querySelector("[data-microphone-id]");
if (firstButton) firstButton.tabIndex = 0;
}
if (!list.childElementCount) { if (!list.childElementCount) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "microphone-list__empty"; empty.className = "microphone-list__empty";
@@ -521,6 +685,9 @@ async function validateCurrent() {
state.valid = errors.length === 0; state.valid = errors.length === 0;
document.documentElement.dataset.arrayValid = String(state.valid); document.documentElement.dataset.arrayValid = String(state.valid);
elements["export-button"].disabled = !state.valid; elements["export-button"].disabled = !state.valid;
if (elements["mobile-export-button"]) {
elements["mobile-export-button"].disabled = !state.valid;
}
elements.validationCard?.setAttribute( elements.validationCard?.setAttribute(
"data-severity", "data-severity",
errors.length ? "error" : warnings.length ? "warning" : "success", errors.length ? "error" : warnings.length ? "warning" : "success",
@@ -603,24 +770,31 @@ function applySelectedPosition() {
} }
function presetOptions() { function presetOptions() {
return { const type = elements["preset-type"].value;
type: elements["preset-type"].value, const options = {
type,
aperture: state.arrayDefinition.aperture, aperture: state.arrayDefinition.aperture,
shape: state.apertureShape, shape: state.apertureShape,
count: integerInput(elements["preset-count"], "마이크 수", 1, 1024),
columns: integerInput(elements["preset-columns"], "Columns", 1, 1024),
rows: integerInput(elements["preset-rows"], "Rows", 1, 1024),
seed: integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295),
minimumSpacingM: finiteInput(
elements["preset-minimum-spacing"],
"최소 간격",
{ minimum: 0 },
),
edgeInsetM: elements["preset-edge-inset"] edgeInsetM: elements["preset-edge-inset"]
? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 }) ? finiteInput(elements["preset-edge-inset"], "경계 여백", { minimum: 0 })
: 0, : 0,
axis: elements["preset-axis"]?.value ?? "x",
}; };
if (type === "grid") {
options.columns = integerInput(elements["preset-columns"], "Columns", 1, 1024);
options.rows = integerInput(elements["preset-rows"], "Rows", 1, 1024);
} else {
options.count = integerInput(elements["preset-count"], "마이크 수", 1, 1024);
}
if (type === "linear") options.axis = elements["preset-axis"]?.value ?? "x";
if (type === "random") {
options.seed = integerInput(elements["preset-seed"], "Seed", -2147483648, 4294967295);
options.minimumSpacingM = finiteInput(
elements["preset-minimum-spacing"],
"최소 간격",
{ minimum: 0 },
);
}
return options;
} }
elements["array-id"].addEventListener("change", () => safely(() => { elements["array-id"].addEventListener("change", () => safely(() => {
@@ -680,8 +854,10 @@ elements["generate-preset-button"].addEventListener("click", () => safely(() =>
); );
})); }));
elements["select-tool-button"].addEventListener("click", () => setTool("select", { focus: true })); elements["select-tool-button"].addEventListener("click", () =>
elements["add-tool-button"].addEventListener("click", () => setTool("add", { focus: true })); setTool("select", { focus: true, userInitiated: true }));
elements["add-tool-button"].addEventListener("click", () =>
setTool("add", { focus: true, userInitiated: true }));
elements["fit-view-button"].addEventListener("click", () => canvasView.fit()); elements["fit-view-button"].addEventListener("click", () => canvasView.fit());
elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25)); elements["zoom-in-button"].addEventListener("click", () => canvasView.zoomBy(1.25));
elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8)); elements["zoom-out-button"].addEventListener("click", () => canvasView.zoomBy(0.8));
@@ -701,6 +877,7 @@ elements["snap-step"].addEventListener("change", () => safely(() => {
})); }));
elements["add-microphone-button"].addEventListener("click", () => safely(() => { elements["add-microphone-button"].addEventListener("click", () => safely(() => {
dismissCanvasInstructions();
mutate("마이크 추가됨", () => { mutate("마이크 추가됨", () => {
const microphone = addMicrophone(state.arrayDefinition, availableManualPosition()); const microphone = addMicrophone(state.arrayDefinition, availableManualPosition());
state.selectedMicrophoneId = microphone.id; state.selectedMicrophoneId = microphone.id;
@@ -763,6 +940,19 @@ elements["move-microphone-down-button"]?.addEventListener("click", () => safely(
mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1)); mutate("채널 순서 변경됨", () => moveMicrophone(state.arrayDefinition, microphone.id, 1));
})); }));
elements["microphone-search"]?.addEventListener("input", renderMicrophoneList); elements["microphone-search"]?.addEventListener("input", renderMicrophoneList);
elements["microphone-list"].addEventListener("keydown", (event) => {
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
const buttons = [...elements["microphone-list"].querySelectorAll("[data-microphone-id]")];
if (!buttons.length) return;
const focusedIndex = Math.max(0, buttons.indexOf(document.activeElement));
let targetIndex = focusedIndex;
if (event.key === "ArrowUp") targetIndex = Math.max(0, focusedIndex - 1);
else if (event.key === "ArrowDown") targetIndex = Math.min(buttons.length - 1, focusedIndex + 1);
else if (event.key === "Home") targetIndex = 0;
else targetIndex = buttons.length - 1;
event.preventDefault();
selectMicrophone(buttons[targetIndex].dataset.microphoneId, { focusList: true });
});
elements["center-microphones-button"]?.addEventListener("click", () => safely(() => { elements["center-microphones-button"]?.addEventListener("click", () => safely(() => {
mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition)); mutate("마이크 중심 정렬됨", () => centerMicrophones(state.arrayDefinition));
@@ -789,14 +979,16 @@ elements["clamp-microphones-button"]?.addEventListener("click", () => safely(()
function undo() { function undo() {
const entry = state.history.pop(); const entry = state.history.pop();
if (!entry) return; if (!entry) return;
state.future.push({ snapshot: captureSnapshot(), label: entry.label }); state.future.push(historyEntry(captureSnapshot(), entry.label));
trimHistoryMemory();
restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label); restoreSnapshot(entry.snapshot, "실행 취소 · " + entry.label);
} }
function redo() { function redo() {
const entry = state.future.pop(); const entry = state.future.pop();
if (!entry) return; if (!entry) return;
state.history.push({ snapshot: captureSnapshot(), label: entry.label }); state.history.push(historyEntry(captureSnapshot(), entry.label));
trimHistoryMemory();
restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label); restoreSnapshot(entry.snapshot, "다시 실행 · " + entry.label);
} }
@@ -821,31 +1013,36 @@ window.addEventListener("keydown", (event) => {
elements["array-input"].addEventListener("change", async () => { elements["array-input"].addEventListener("change", async () => {
const file = elements["array-input"].files?.[0]; const file = elements["array-input"].files?.[0];
if (!file) return; if (!file) return;
const importRevision = ++state.importRevision;
try { try {
const value = await readJsonFile(file); const value = await readJsonFile(file);
const issues = await validateArrayDefinition(value); const issues = await validateArrayDefinition(value);
if (importRevision !== state.importRevision) return;
if (issues.length) { if (issues.length) {
const details = issues.slice(0, 4).map( const details = issues.slice(0, 4).map(
(issue) => issue.path + ": " + issue.message, (issue) => issue.path + ": " + issue.message,
).join(" · "); ).join(" · ");
throw new Error("Array JSON 계약을 만족하지 않습니다. " + details); throw new Error("Array JSON 계약을 만족하지 않습니다. " + details);
} }
const previousSnapshot = captureSnapshot();
state.arrayDefinition = prepareArrayForEditing(value); state.arrayDefinition = prepareArrayForEditing(value);
state.apertureShape = inferApertureShape(value); state.apertureShape = inferApertureShape(value);
state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null; state.selectedMicrophoneId = state.arrayDefinition.microphones[0]?.id ?? null;
state.history.length = 0; state.history = [historyEntry(previousSnapshot, "Array 불러오기")];
state.future.length = 0; state.future.length = 0;
trimHistoryMemory();
state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
state.dirty = false; state.dirty = false;
canvasView.fit(); canvasView.fit();
renderAll(file.name + " 불러옴"); renderAll(file.name + " 불러옴 · 실행 취소 가능");
} catch (error) { } catch (error) {
showActionError(error); if (importRevision === state.importRevision) showActionError(error);
} finally { } finally {
elements["array-input"].value = ""; if (importRevision === state.importRevision) elements["array-input"].value = "";
} }
}); });
elements["export-button"].addEventListener("click", async () => { async function exportArray() {
await validateCurrent(); await validateCurrent();
if (!state.valid) { if (!state.valid) {
showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요.")); showActionError(new Error("오류를 해결한 뒤 Array JSON을 저장하세요."));
@@ -855,9 +1052,13 @@ elements["export-button"].addEventListener("click", async () => {
state.arrayDefinition, state.arrayDefinition,
safeArrayFileStem(state.arrayDefinition) + ".array.json", safeArrayFileStem(state.arrayDefinition) + ".array.json",
); );
state.dirty = false; state.baselineSignature = editableDocumentSignature(state.arrayDefinition);
refreshDirtyState();
announce("Array JSON 저장됨"); announce("Array JSON 저장됨");
}); }
elements["export-button"].addEventListener("click", exportArray);
elements["mobile-export-button"]?.addEventListener("click", exportArray);
window.addEventListener("beforeunload", (event) => { window.addEventListener("beforeunload", (event) => {
if (!state.dirty) return; if (!state.dirty) return;
+71 -29
View File
@@ -63,7 +63,10 @@ function microphoneFromPosition(position, index) {
} }
function pointKey(position) { function pointKey(position) {
return position.map((value) => rounded(value).toFixed(COORDINATE_DIGITS)).join(","); return position.map((value) => {
const number = finiteNumber(value, "좌표");
return Object.is(number, -0) ? "0" : String(number);
}).join(",");
} }
function linearValue(index, count, minimum, maximum) { function linearValue(index, count, minimum, maximum) {
@@ -218,16 +221,43 @@ function randomPositions(options, bounds, shape) {
const random = createSeededRandom(options.seed ?? 1); const random = createSeededRandom(options.seed ?? 1);
const positions = []; const positions = [];
const keys = new Set(); const keys = new Set();
const maximumAttempts = Math.max(2000, count * 500); const cellSize = Math.max(minimumSpacing, 10 ** -COORDINATE_DIGITS);
const spatialCells = minimumSpacingSquared > 0 ? new Map() : null;
const cellCoordinates = (position) => [
Math.floor(position[0] / cellSize),
Math.floor(position[1] / cellSize),
];
const cellKey = (x, y) => String(x) + "," + String(y);
const violatesMinimumSpacing = (candidate) => {
if (!spatialCells) return false;
const [cellX, cellY] = cellCoordinates(candidate);
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
const neighbors = spatialCells.get(cellKey(cellX + offsetX, cellY + offsetY));
if (neighbors?.some(
(position) => squaredDistance(position, candidate) < minimumSpacingSquared,
)) return true;
}
}
return false;
};
const indexPosition = (position) => {
if (!spatialCells) return;
const [cellX, cellY] = cellCoordinates(position);
const key = cellKey(cellX, cellY);
const cell = spatialCells.get(key);
if (cell) cell.push(position);
else spatialCells.set(key, [position]);
};
const maximumAttempts = Math.max(2000, count * 150);
for (let attempt = 0; attempt < maximumAttempts && positions.length < count; attempt += 1) { for (let attempt = 0; attempt < maximumAttempts && positions.length < count; attempt += 1) {
const candidate = normalizedPosition(randomCandidate(random, bounds, shape)); const candidate = normalizedPosition(randomCandidate(random, bounds, shape));
const key = pointKey(candidate); const key = pointKey(candidate);
if (keys.has(key)) continue; if (keys.has(key)) continue;
if (positions.some((position) => squaredDistance(position, candidate) < minimumSpacingSquared)) { if (violatesMinimumSpacing(candidate)) continue;
continue;
}
keys.add(key); keys.add(key);
positions.push(candidate); positions.push(candidate);
indexPosition(candidate);
} }
if (positions.length !== count) { if (positions.length !== count) {
throw new RangeError( throw new RangeError(
@@ -302,35 +332,36 @@ export function prepareArrayForEditing(value) {
} }
export function inferApertureShape(arrayDefinition) { export function inferApertureShape(arrayDefinition) {
const description = String(arrayDefinition?.description ?? ""); const shapeWords = [
if (/circle|circular|ellipse|elliptic|ring|spiral/i.test(description)) return "ellipse"; arrayDefinition?.id,
const aperture = arrayDefinition?.aperture; arrayDefinition?.name,
const microphones = arrayDefinition?.microphones ?? []; arrayDefinition?.description,
if (!aperture || microphones.length < 3) return "rectangle"; ].filter(Boolean).join(" ");
let maximumNormalizedRadius = 0; return /(^|[^a-z])(circle|circular|ellipse|elliptic|ring|spiral)([^a-z]|$)/i.test(shapeWords)
for (const microphone of microphones) { ? "ellipse"
const position = microphone?.position; : "rectangle";
if (!Array.isArray(position)) return "rectangle";
const radius = Math.hypot(
position[0] / (aperture.width / 2),
position[1] / (aperture.height / 2),
);
if (radius > 1.000000001) return "rectangle";
maximumNormalizedRadius = Math.max(maximumNormalizedRadius, radius);
}
return maximumNormalizedRadius >= 0.72 ? "ellipse" : "rectangle";
} }
export function nextMicrophoneId(arrayDefinition) { export function nextMicrophoneId(arrayDefinition) {
const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id)); const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id));
let maximumSuffix = 0; let maximumSuffix = 0n;
for (const id of used) { for (const id of used) {
const match = /^mic-(\d+)$/.exec(id); const match = /^mic-(\d+)$/.exec(id);
if (match) maximumSuffix = Math.max(maximumSuffix, Number(match[1])); if (!match) continue;
const suffix = BigInt(match[1]);
if (suffix > maximumSuffix) maximumSuffix = suffix;
} }
let candidate = maximumSuffix + 1; let candidate = maximumSuffix + 1n;
while (used.has(microphoneId(candidate))) candidate += 1; for (let attempt = 0; attempt <= used.size; attempt += 1) {
return microphoneId(candidate); const id = "mic-" + candidate.toString().padStart(3, "0");
if (id.length <= 64 && !used.has(id)) return id;
candidate += 1n;
}
for (let suffix = 1; suffix <= MAX_MICROPHONES + 1; suffix += 1) {
const id = microphoneId(suffix);
if (!used.has(id)) return id;
}
throw new Error("사용 가능한 마이크 ID를 찾을 수 없습니다.");
} }
export function addMicrophone(arrayDefinition, position, properties = {}) { export function addMicrophone(arrayDefinition, position, properties = {}) {
@@ -514,8 +545,19 @@ export function arrayStatistics(arrayDefinition) {
if (microphones.length === 0) { if (microphones.length === 0) {
return { count: 0, centroid: [0, 0, 0], minimumSpacing: null, calibrationCount: 0 }; return { count: 0, centroid: [0, 0, 0], minimumSpacing: null, calibrationCount: 0 };
} }
const centroid = [0, 1, 2].map((axis) => const centroid = [0, 1, 2].map((axis) => {
microphones.reduce((sum, microphone) => sum + microphone.position[axis], 0) / microphones.length); const scale = microphones.reduce(
(maximum, microphone) => Math.max(maximum, Math.abs(microphone.position[axis])),
0,
);
if (scale === 0) return 0;
const normalizedSum = microphones.reduce(
(sum, microphone) => sum + microphone.position[axis] / scale,
0,
);
const normalizedMean = Math.max(-1, Math.min(1, normalizedSum / microphones.length));
return normalizedMean * scale;
});
let minimumSpacingSquared = Infinity; let minimumSpacingSquared = Infinity;
for (let left = 0; left < microphones.length; left += 1) { for (let left = 0; left < microphones.length; left += 1) {
for (let right = left + 1; right < microphones.length; right += 1) { for (let right = left + 1; right < microphones.length; right += 1) {
+69 -10
View File
@@ -3,6 +3,25 @@
overflow-x: clip; overflow-x: clip;
} }
.skip-link {
position: fixed;
z-index: 100;
top: 8px;
left: 8px;
padding: 9px 12px;
border-radius: 9px;
color: #06130f;
background: var(--mint);
font-size: 0.72rem;
font-weight: 750;
text-decoration: none;
transform: translateY(-150%);
}
.skip-link:focus {
transform: translateY(0);
}
.array-builder-header { .array-builder-header {
position: sticky; position: sticky;
z-index: 30; z-index: 30;
@@ -116,6 +135,11 @@
clip-path: inset(50%); clip-path: inset(50%);
} }
.file-button:focus-within {
border-color: var(--mint);
outline: 2px solid rgb(101 242 191 / 16%);
}
.array-workflow { .array-workflow {
min-width: 0; min-width: 0;
margin-left: auto; margin-left: auto;
@@ -169,6 +193,8 @@
} }
.array-controls { .array-controls {
grid-column: 1;
grid-row: 1;
position: sticky; position: sticky;
top: 59px; top: 59px;
min-width: 0; min-width: 0;
@@ -185,6 +211,8 @@
} }
.array-builder-layout > .split-handle { .array-builder-layout > .split-handle {
grid-column: 2;
grid-row: 1;
position: sticky; position: sticky;
top: 59px; top: 59px;
height: calc(100vh - 59px); height: calc(100vh - 59px);
@@ -577,7 +605,13 @@ details > summary::-webkit-details-marker {
padding: 0 10px 10px; padding: 0 10px 10px;
} }
.mobile-save-actions {
display: none;
}
.array-workspace { .array-workspace {
grid-column: 3;
grid-row: 1;
min-width: 0; min-width: 0;
padding: 18px clamp(18px, 2.5vw, 38px) 38px; padding: 18px clamp(18px, 2.5vw, 38px) 38px;
} }
@@ -857,41 +891,60 @@ body[data-array-tool="add"] #array-canvas,
@media (max-width: 1100px) { @media (max-width: 1100px) {
.array-builder-layout { .array-builder-layout {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(340px, 38%) minmax(0, 1fr);
} }
.array-builder-layout > .split-handle { .array-builder-layout > .split-handle {
display: none; display: none;
} }
.array-controls {
grid-column: 1;
}
.array-workspace {
grid-column: 2;
}
.array-controls, .array-controls,
.array-workspace { .array-workspace {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
.array-controls {
position: static;
overflow: visible;
max-height: none;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.microphone-list { .microphone-list {
max-height: 360px; max-height: 360px;
} }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.array-builder-layout {
grid-template-columns: minmax(0, 1fr);
}
.array-builder-header { .array-builder-header {
position: static; position: static;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
} }
.array-workspace {
grid-column: 1;
grid-row: 1;
}
.array-controls {
grid-column: 1;
grid-row: 2;
position: static;
overflow: visible;
max-height: none;
border-right: 0;
border-top: 1px solid var(--line);
border-bottom: 0;
}
.array-workflow { .array-workflow {
order: 3;
width: 100%; width: 100%;
margin: 0; margin: 0;
} }
@@ -935,11 +988,17 @@ body[data-array-tool="add"] #array-canvas,
#array-canvas { #array-canvas {
height: 500px; height: 500px;
touch-action: pan-y;
} }
.shortcut-help > div { .shortcut-help > div {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.mobile-save-actions {
display: block;
padding: 2px 0 16px;
}
} }
@media (max-width: 520px) { @media (max-width: 520px) {
+5
View File
@@ -24,6 +24,11 @@ array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에
주파수는 0–1,000,000 Hz에서 중복 없이 증가해야 하며, dB와 wrapped phase를 구간 선형 보간하고 주파수는 0–1,000,000 Hz에서 중복 없이 증가해야 하며, dB와 wrapped phase를 구간 선형 보간하고
측정 범위 밖에서는 가까운 끝점을 사용한다. 측정 범위 밖에서는 가까운 끝점을 사용한다.
Array Builder의 사각형/타원 생성 경계, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백,
snap과 zoom은 편집기 상태이며 `micarrsim.array` 계약에 포함되지 않는다. 저장 파일에는
`aperture.width/height`와 최종 `microphones` 좌표·순서만 들어간다. 따라서 다른 도구는
형상 이름을 추론하지 않고 실제 좌표를 배열 geometry의 기준으로 사용해야 한다.
Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`다. backend와 Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`다. backend와
raw map 계약은 다음과 같다. raw map 계약은 다음과 같다.
+17 -5
View File
@@ -37,13 +37,15 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
때만 동일 스키마를 쓰는 별도 CLI를 추가한다. 이 방식은 초기 복잡도를 줄이면서 확장 경로를 때만 동일 스키마를 쓰는 별도 CLI를 추가한다. 이 방식은 초기 복잡도를 줄이면서 확장 경로를
남긴다. 남긴다.
두 앱의 책임은 다음과 같이 분리한다. 세 앱의 책임은 다음과 같이 분리한다.
- `array-builder`: aperture 크기와 사각/타원 생성 경계, 격자·선형·둘레·Fermat·seed random
프리셋, 마이크 직접 편집·채널 순서·보정 보존과 표준 Array JSON 내보내기
- `dataset-builder`: 음원 배치, gain/시간 구간/반복 설정, ambient mix, 이미지상의 위치 지정, - `dataset-builder`: 음원 배치, gain/시간 구간/반복 설정, ambient mix, 이미지상의 위치 지정,
seed 고정, 합성 오디오와 ground truth manifest 내보내기 seed 고정, 합성 오디오와 ground truth manifest 내보내기
- `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성, - `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성,
DAS 실행, overlay·수치 평가·실험 결과 내보내기 DAS 실행, overlay·수치 평가·실험 결과 내보내기
- `acoustics-core`: 두 앱이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답, - `acoustics-core`: 앱들이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답,
STFT, 정답 rasterization, 지표, CPU reference와 실측 channel용 streaming localizer STFT, 정답 rasterization, 지표, CPU reference와 실측 channel용 streaming localizer
- `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark - `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark
@@ -52,7 +54,7 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
JSON Schema를 먼저 고정하고 모든 길이는 m, 시간은 s, 주파수는 Hz, 좌표는 우수 좌표계로 JSON Schema를 먼저 고정하고 모든 길이는 m, 시간은 s, 주파수는 Hz, 좌표는 우수 좌표계로
명시한다. 명시한다.
- `array.schema.json`: schema version, 배열 폭·높이, 192개 마이크의 id와 `[x,y,z]`, 선택적 - `array.schema.json`: schema version, 배열 폭·높이, 1–1,024개 마이크의 id와 `[x,y,z]`, 선택적
감도·위상·주파수 응답 보정값 감도·위상·주파수 응답 보정값
- `scene.schema.json`: 배경 이미지 크기와 실제 평면 변환, 음원별 위치·오디오 참조·시작/종료· - `scene.schema.json`: 배경 이미지 크기와 실제 평면 변환, 음원별 위치·오디오 참조·시작/종료·
gain, ambient 참조, sample rate, seed, 선택적 자유음장·shoebox 환경 gain, ambient 참조, sample rate, seed, 선택적 자유음장·shoebox 환경
@@ -145,7 +147,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- 좌표계·단위·오디오 normalization 결정 기록 - 좌표계·단위·오디오 normalization 결정 기록
- synthetic single-tone scene과 192채널 기준 배열 작성 - synthetic single-tone scene과 192채널 기준 배열 작성
완료 조건: 예제 JSON이 schema validation을 통과하고 두 앱이 동일 manifest를 읽는다. 완료 조건: 예제 JSON이 schema validation을 통과하고 앱들이 동일 계약을 읽는다.
### 단계 1 — CPU 기준 모델 · 완료 ### 단계 1 — CPU 기준 모델 · 완료
@@ -164,6 +166,15 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다. 완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다.
### 단계 2A — Array Builder · 완료
- aperture 크기와 사각/타원 생성 경계, 5종 결정론적 프리셋 구현
- canvas 추가·선택·drag, 정밀 좌표·ID·채널 순서 편집과 undo/redo
- 가져온 sensitivity·phase·frequency response 보존과 표준 Array JSON 왕복
- 좌표 중복 오류, 경계 초과 경고, clamp·중앙 정렬·aperture 맞춤
완료 조건: 만든 Array가 계약 검증을 통과하고 Acoustic Camera에서 그대로 불러와진다.
### 단계 3 — WebGPU DAS · 완료 ### 단계 3 — WebGPU DAS · 완료
- CPU와 동일한 buffer layout 및 WGSL kernel 구현 - CPU와 동일한 buffer layout 및 WGSL kernel 구현
@@ -212,7 +223,8 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
## 10. 구현 결과와 다음 백로그 ## 10. 구현 결과와 다음 백로그
- [x] 설치 없는 ES module workspace와 두 앱 shell - [x] 설치 없는 ES module workspace와 세 앱 shell
- [x] Array Builder 프리셋·직접 편집·검증·Camera 전달
- [x] array/scene/experiment/result schema와 synthetic fixture - [x] array/scene/experiment/result schema와 synthetic fixture
- [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트 - [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트
- [x] CPU DAS golden implementation - [x] CPU DAS golden implementation
+4
View File
@@ -22,11 +22,15 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장
- `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers - `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집, - `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존 trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
- `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo,
중복 좌표 차단, 표준 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·협대역 제약; 이동 예제 로드, 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 흐름
- `split-layout-ui.test.js`: 세 앱 설정 패널의 drag·keyboard 폭 조절, 저장·reset과
좁은 화면 적층
WebGPU adapter가 없는 환경에서는 장치가 필요한 비교 항목을 건너뛰거나 Auto fallback을 검증하므로 WebGPU adapter가 없는 환경에서는 장치가 필요한 비교 항목을 건너뛰거나 Auto fallback을 검증하므로
CPU-only PASS도 가능합니다. 실제 장치의 scaling과 benchmark JSON 저장은 CPU-only PASS도 가능합니다. 실제 장치의 scaling과 benchmark JSON 저장은
+421 -1
View File
@@ -1,4 +1,9 @@
import { validateArrayDefinition } from "/packages/contracts/validation.js"; import { validateArrayDefinition } from "/packages/contracts/validation.js";
import {
arrayPositionToCanvas,
fitArrayViewport,
} from "/apps/array-builder/canvas-view.js";
import { createArrayDefinition } from "/apps/array-builder/model.js";
function assertNear(actual, expected, tolerance, label) { function assertNear(actual, expected, tolerance, label) {
assert( assert(
@@ -103,16 +108,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500, documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500,
"canvas dominates the desktop workspace", "canvas dominates the desktop workspace",
); );
assert(
!documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"),
"canvas guidance starts visible",
);
const canvas = documentObject.querySelector("#array-canvas");
const defaultArray = createArrayDefinition();
const initialCanvasRect = canvas.getBoundingClientRect();
const initialViewport = fitArrayViewport(
initialCanvasRect.width,
initialCanvasRect.height,
defaultArray,
);
const firstMicrophonePixel = arrayPositionToCanvas(
defaultArray.microphones[0].position,
initialViewport,
);
const initialFirstPosition = selectedCoordinates(documentObject);
const dragPointerId = 21;
const dispatchDragPointer = (type, offsetX, offsetY, buttons) => {
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
bubbles: true,
cancelable: true,
pointerId: dragPointerId,
pointerType: "mouse",
isPrimary: true,
button: type === "pointermove" ? -1 : 0,
buttons,
clientX: initialCanvasRect.left + firstMicrophonePixel.x + offsetX,
clientY: initialCanvasRect.top + firstMicrophonePixel.y + offsetY,
}));
};
dispatchDragPointer("pointerdown", 0, 0, 1);
dispatchDragPointer("pointermove", 36, -24, 1);
dispatchDragPointer("pointerup", 36, -24, 0);
await nextPaint(windowObject);
const draggedPosition = selectedCoordinates(documentObject);
assert(
draggedPosition[0] !== initialFirstPosition[0] &&
draggedPosition[1] !== initialFirstPosition[1],
"pointer drag changes both array coordinates",
);
assert(
documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"),
"first canvas edit dismisses the guidance overlay",
);
documentObject.querySelector("#undo-button").click();
assertEqual(
selectedCoordinates(documentObject).join(","),
initialFirstPosition.join(","),
"one undo restores the whole drag",
);
dispatchDragPointer("pointerdown", 0, 0, 1);
dispatchDragPointer("pointermove", 28, -18, 1);
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "Escape",
}));
await nextPaint(windowObject);
assertEqual(
selectedCoordinates(documentObject).join(","),
initialFirstPosition.join(","),
"Escape cancels an in-progress drag",
);
const initialApertureWidth = documentObject.querySelector("#aperture-width").value;
dispatchValue(windowObject, documentObject.querySelector("#aperture-width"), 0);
assertEqual(
documentObject.querySelector("#aperture-width").value,
initialApertureWidth,
"rejected aperture input is restored to the active value",
);
assert(
documentObject.querySelector(".validation-card").classList.contains("is-invalid"),
"rejected action has an error visual state",
);
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), "");
dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3); dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3);
dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2); dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2);
documentObject.querySelector("#generate-preset-button").click(); documentObject.querySelector("#generate-preset-button").click();
await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation"); await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation");
assertEqual(
documentObject.querySelector("#preset-count").value,
"",
"hidden count does not block grid generation",
);
assertEqual( assertEqual(
microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","), microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","),
"mic-001,mic-002,mic-003,mic-004,mic-005,mic-006", "mic-001,mic-002,mic-003,mic-004,mic-005,mic-006",
"grid keeps row-major physical channel IDs", "grid keeps row-major physical channel IDs",
); );
assertEqual(
microphoneButtons(documentObject).filter((button) => button.tabIndex === 0).length,
1,
"microphone list has one roving Tab stop",
);
microphoneButtons(documentObject)[0].focus();
microphoneButtons(documentObject)[0].dispatchEvent(new windowObject.KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "ArrowDown",
}));
assertEqual(
documentObject.activeElement.dataset.microphoneId,
"mic-002",
"ArrowDown selects and focuses the next channel",
);
dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse"); dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse");
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring"); dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring");
@@ -158,9 +263,14 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
"reference-channel", "reference-channel",
"manual ID edit", "manual ID edit",
); );
documentObject.querySelector("#move-microphone-up-button").click();
assertEqual(
microphoneButtons(documentObject).at(-2).dataset.microphoneId,
"reference-channel",
"channel forward button changes physical order",
);
const beforeNudge = selectedCoordinates(documentObject); const beforeNudge = selectedCoordinates(documentObject);
const canvas = documentObject.querySelector("#array-canvas");
canvas.focus(); canvas.focus();
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", { canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
bubbles: true, bubbles: true,
@@ -172,6 +282,80 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step"); assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step");
assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y"); assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y");
const touchCanvasRect = canvas.getBoundingClientRect();
const touchViewport = fitArrayViewport(
touchCanvasRect.width,
touchCanvasRect.height,
{
aperture: {
width: Number(documentObject.querySelector("#aperture-width").value),
height: Number(documentObject.querySelector("#aperture-height").value),
},
microphones: [{ id: "selected", position: afterNudge }],
},
);
const selectedTouchPixel = arrayPositionToCanvas(afterNudge, touchViewport);
const dispatchTouchDragPointer = (type, pointerId, offsetX, offsetY, buttons) =>
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
bubbles: true,
cancelable: true,
pointerId,
pointerType: "touch",
isPrimary: true,
button: type === "pointermove" ? -1 : 0,
buttons,
clientX: touchCanvasRect.left + selectedTouchPixel.x + offsetX,
clientY: touchCanvasRect.top + selectedTouchPixel.y + offsetY,
}));
dispatchTouchDragPointer("pointerdown", 22, 0, 0, 1);
const microphoneTouchStart = new windowObject.Event("touchstart", {
bubbles: true,
cancelable: true,
});
Object.defineProperty(microphoneTouchStart, "changedTouches", {
value: [{
clientX: touchCanvasRect.left + selectedTouchPixel.x,
clientY: touchCanvasRect.top + selectedTouchPixel.y,
}],
});
canvas.dispatchEvent(microphoneTouchStart);
assert(microphoneTouchStart.defaultPrevented, "touch on a microphone reserves the drag gesture");
dispatchTouchDragPointer("pointermove", 22, 3, 2, 1);
dispatchTouchDragPointer("pointerup", 22, 3, 2, 0);
await nextPaint(windowObject);
assertEqual(
selectedCoordinates(documentObject).join(","),
afterNudge.join(","),
"touch jitter below the gesture threshold does not move a microphone",
);
const blankTouchStart = new windowObject.Event("touchstart", {
bubbles: true,
cancelable: true,
});
Object.defineProperty(blankTouchStart, "changedTouches", {
value: [{ clientX: touchCanvasRect.left + 3, clientY: touchCanvasRect.top + 3 }],
});
canvas.dispatchEvent(blankTouchStart);
assert(!blankTouchStart.defaultPrevented, "blank canvas touch remains available to page scroll");
dispatchTouchDragPointer("pointerdown", 23, 0, 0, 1);
dispatchTouchDragPointer("pointermove", 23, 24, -18, 1);
dispatchTouchDragPointer("pointerup", 23, 24, -18, 0);
await nextPaint(windowObject);
const touchDraggedPosition = selectedCoordinates(documentObject);
assert(
touchDraggedPosition[0] !== afterNudge[0] && touchDraggedPosition[1] !== afterNudge[1],
"touch movement beyond the threshold drags both coordinates",
);
documentObject.querySelector("#undo-button").click();
assertEqual(
selectedCoordinates(documentObject).join(","),
afterNudge.join(","),
"one undo restores the whole touch drag",
);
documentObject.querySelector("#add-tool-button").click(); documentObject.querySelector("#add-tool-button").click();
const canvasRect = canvas.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect();
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", { canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
@@ -190,6 +374,44 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.querySelector("#remove-microphone-button").click(); documentObject.querySelector("#remove-microphone-button").click();
await waitFor(() => microphoneCount(documentObject) === 13, "manual delete"); await waitFor(() => microphoneCount(documentObject) === 13, "manual delete");
documentObject.querySelector("#add-tool-button").click();
const dispatchTouchAddPointer = (type, pointerId, offsetX, offsetY, buttons) =>
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
bubbles: true,
cancelable: true,
pointerId,
pointerType: "touch",
isPrimary: true,
button: type === "pointermove" ? -1 : 0,
buttons,
clientX: canvasRect.left + canvasRect.width * 0.7 + offsetX,
clientY: canvasRect.top + canvasRect.height * 0.35 + offsetY,
}));
assert(
dispatchTouchAddPointer("pointerdown", 32, 0, 0, 1),
"touch add pointerdown remains available to page scroll",
);
assertEqual(microphoneCount(documentObject), 13, "touch add waits for pointerup");
assert(
dispatchTouchAddPointer("pointermove", 32, 0, 12, 1),
"touch add scroll movement is not canceled",
);
dispatchTouchAddPointer("pointerup", 32, 0, 12, 0);
await nextPaint(windowObject);
assertEqual(microphoneCount(documentObject), 13, "touch scroll intent does not add a microphone");
dispatchTouchAddPointer("pointerdown", 33, 0, 0, 1);
assertEqual(microphoneCount(documentObject), 13, "touch tap still waits while pressed");
dispatchTouchAddPointer("pointermove", 33, 3, 2, 1);
assert(
!dispatchTouchAddPointer("pointerup", 33, 3, 2, 0),
"completed touch add tap prevents its synthetic click",
);
await waitFor(() => microphoneCount(documentObject) === 14, "touch canvas microphone add");
documentObject.querySelector("#select-tool-button").click();
documentObject.querySelector("#remove-microphone-button").click();
await waitFor(() => microphoneCount(documentObject) === 13, "touch-added microphone delete");
const firstButton = microphoneButtons(documentObject)[0]; const firstButton = microphoneButtons(documentObject)[0];
const secondButton = microphoneButtons(documentObject)[1]; const secondButton = microphoneButtons(documentObject)[1];
firstButton.click(); firstButton.click();
@@ -228,6 +450,20 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
assert(calibrated, "edited channel is exported"); assert(calibrated, "edited channel is exported");
assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved"); assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved");
assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved"); assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved");
assertEqual(
exported.microphones.indexOf(calibrated),
exported.microphones.length - 2,
"export keeps the edited physical channel order",
);
calibrated.frequencyResponse = [
{ frequencyHz: 100, gainDb: -2, phaseDeg: -4 },
{ frequencyHz: 1000, gainDb: 0.5, phaseDeg: 3 },
];
assertEqual(
(await validateArrayDefinition(exported)).length,
0,
"response-enhanced round trip fixture contract",
);
dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name"); dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name");
const exportedFile = new windowObject.File( const exportedFile = new windowObject.File(
@@ -240,6 +476,27 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
() => documentObject.querySelector("#array-name").value === exported.name, () => documentObject.querySelector("#array-name").value === exported.name,
"Array round trip import", "Array round trip import",
); );
assert(!documentObject.querySelector("#undo-button").disabled, "valid import is undoable");
documentObject.querySelector("#undo-button").click();
assertEqual(
documentObject.querySelector("#array-name").value,
"Temporary name",
"import undo restores the previous document",
);
assert(
documentObject.querySelector("#save-state").classList.contains("is-dirty"),
"document before imported baseline is dirty",
);
documentObject.querySelector("#redo-button").click();
assertEqual(
documentObject.querySelector("#array-name").value,
exported.name,
"import redo restores the loaded document",
);
assert(
!documentObject.querySelector("#save-state").classList.contains("is-dirty"),
"loaded baseline is clean after redo",
);
microphoneButtons(documentObject).find( microphoneButtons(documentObject).find(
(button) => button.dataset.microphoneId === "reference-channel", (button) => button.dataset.microphoneId === "reference-channel",
).click(); ).click();
@@ -248,6 +505,62 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
-2.5, -2.5,
"import keeps sensitivity", "import keeps sensitivity",
); );
assert(
documentObject.querySelector("#frequency-response-summary").textContent.includes("2개"),
"import exposes preserved frequency-response points",
);
const importedReferenceX = Number(documentObject.querySelector("#microphone-x").value);
dispatchValue(
windowObject,
documentObject.querySelector("#microphone-x"),
importedReferenceX + 0.001,
);
documentObject.querySelector("#export-button").click();
await waitFor(
() => downloads.filter((item) => item.fileName).length === 2,
"edited response Array export",
);
const editedExport = JSON.parse(
await downloads.filter((item) => item.fileName).at(-1).blob.text(),
);
assertEqual(
JSON.stringify(
editedExport.microphones.find(
(microphone) => microphone.id === "reference-channel",
).frequencyResponse,
),
JSON.stringify(calibrated.frequencyResponse),
"position edit preserves every frequency-response value",
);
const responseVariant = structuredClone(editedExport);
responseVariant.microphones.find(
(microphone) => microphone.id === "reference-channel",
).frequencyResponse[0].gainDb -= 1;
const responseVariantFile = new windowObject.File(
[JSON.stringify(responseVariant)],
"response-variant.array.json",
{ type: "application/json" },
);
dispatchFiles(
windowObject,
documentObject.querySelector("#array-input"),
[responseVariantFile],
);
await waitFor(
() => documentObject.querySelector("#save-state").textContent.includes("불러옴"),
"same-length response variant import",
);
documentObject.querySelector("#undo-button").click();
assert(
documentObject.querySelector("#save-state").classList.contains("is-dirty"),
"undo detects changed response values even when the point count is unchanged",
);
documentObject.querySelector("#redo-button").click();
assert(
!documentObject.querySelector("#save-state").classList.contains("is-dirty"),
"response variant baseline is clean after redo",
);
const countBeforeInvalidImport = microphoneCount(documentObject); const countBeforeInvalidImport = microphoneCount(documentObject);
const invalidFile = new windowObject.File( const invalidFile = new windowObject.File(
@@ -266,12 +579,109 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
"invalid import preserves current array", "invalid import preserves current array",
); );
const cameraFrame = document.createElement("iframe");
cameraFrame.src = "/apps/acoustic-camera/?integration-test=1&array-builder-handoff=1";
cameraFrame.style.cssText =
"position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
document.body.append(cameraFrame);
await new Promise((resolve, reject) => {
cameraFrame.addEventListener("load", resolve, { once: true });
cameraFrame.addEventListener(
"error",
() => reject(new Error("Camera handoff iframe load failed")),
{ once: true },
);
});
const cameraWindow = cameraFrame.contentWindow;
const cameraDocument = cameraFrame.contentDocument;
const cameraDownloads = [];
const originalCameraCreate = cameraWindow.URL.createObjectURL.bind(cameraWindow.URL);
const originalCameraRevoke = cameraWindow.URL.revokeObjectURL.bind(cameraWindow.URL);
const originalCameraClick = cameraWindow.HTMLAnchorElement.prototype.click;
cameraWindow.URL.createObjectURL = (blob) => {
const url = "blob:array-camera-test-" + String(cameraDownloads.length + 1);
cameraDownloads.push({ blob, url, fileName: null });
return url;
};
cameraWindow.URL.revokeObjectURL = () => {};
cameraWindow.HTMLAnchorElement.prototype.click = function captureCameraDownload() {
const download = cameraDownloads.find((item) => item.url === this.href);
if (download) download.fileName = this.download;
};
try {
await waitFor(
() => cameraDocument.documentElement.dataset.appReady === "true",
"Camera handoff initialization",
);
const handoffFile = new cameraWindow.File(
[JSON.stringify(editedExport)],
"builder-handoff.array.json",
{ type: "application/json" },
);
dispatchFiles(cameraWindow, cameraDocument.querySelector("#array-input"), [handoffFile]);
await waitFor(
() => cameraDocument.querySelector("#array-preset").value === "custom" &&
cameraDocument.querySelector("#microphone-count").textContent.includes(
String(editedExport.microphones.length),
) &&
cameraDocument.documentElement.dataset.cameraValid === "true",
"Camera custom Array acceptance",
);
assertEqual(
cameraDocument.querySelector("#array-title").textContent,
editedExport.name,
"Camera shows the Builder array name",
);
cameraDocument.querySelector("#export-experiment-button").click();
await waitFor(
() => cameraDownloads.some((item) => item.fileName),
"Camera Experiment export after handoff",
);
const cameraExperiment = JSON.parse(
await cameraDownloads.find((item) => item.fileName).blob.text(),
);
assertEqual(
cameraExperiment.arrayId,
editedExport.id,
"Camera rebinds Experiment to the Builder Array ID",
);
} finally {
cameraWindow.URL.createObjectURL = originalCameraCreate;
cameraWindow.URL.revokeObjectURL = originalCameraRevoke;
cameraWindow.HTMLAnchorElement.prototype.click = originalCameraClick;
cameraFrame.remove();
}
frame.style.width = "390px"; frame.style.width = "390px";
await waitFor( await waitFor(
() => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false", () => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false",
"compact Array Builder layout", "compact Array Builder layout",
); );
await nextPaint(windowObject); await nextPaint(windowObject);
const compactWorkspace = documentObject.querySelector("#array-builder-workspace");
const compactControls = documentObject.querySelector("#array-builder-controls");
const compactWorkspaceRect = compactWorkspace.getBoundingClientRect();
const compactControlsRect = compactControls.getBoundingClientRect();
assert(
Boolean(
compactWorkspace.compareDocumentPosition(compactControls) &
windowObject.Node.DOCUMENT_POSITION_FOLLOWING,
),
"compact canvas workspace also precedes settings in DOM order",
);
assert(
compactWorkspaceRect.bottom <= compactControlsRect.top + 1,
"compact canvas workspace appears before the long settings form",
);
assert(
documentObject.querySelector(".array-metadata-section").getBoundingClientRect().top <
documentObject.querySelector(".aperture-section").getBoundingClientRect().top &&
documentObject.querySelector(".aperture-section").getBoundingClientRect().top <
documentObject.querySelector(".preset-section").getBoundingClientRect().top &&
documentObject.querySelector(".preset-section").getBoundingClientRect().top <
documentObject.querySelector(".microphone-section").getBoundingClientRect().top,
"compact controls keep the numbered source and visual order",
);
assert( assert(
documentObject.documentElement.scrollWidth <= documentObject.documentElement.scrollWidth <=
documentObject.documentElement.clientWidth + 1, documentObject.documentElement.clientWidth + 1,
@@ -281,6 +691,16 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350, documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
"Array canvas remains usable at 390px", "Array canvas remains usable at 390px",
); );
assertEqual(
windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction,
"pan-y",
"mobile canvas permits vertical page scrolling",
);
assert(
windowObject.getComputedStyle(documentObject.querySelector("#mobile-export-button")).display
!== "none",
"compact settings end with a visible save action",
);
} finally { } finally {
windowObject.URL.createObjectURL = originalCreate; windowObject.URL.createObjectURL = originalCreate;
windowObject.URL.revokeObjectURL = originalRevoke; windowObject.URL.revokeObjectURL = originalRevoke;
+7 -1
View File
@@ -51,6 +51,7 @@ const LAYOUT_CASES = [
defaultGridTracks: 2, defaultGridTracks: 2,
homePanelWidth: 360, homePanelWidth: 360,
homeGridTracks: 1, homeGridTracks: 1,
compactWorkspaceFirst: true,
}, },
]; ];
@@ -444,7 +445,12 @@ async function exerciseSplitLayout(definition) {
const panelRect = mounted.panel.getBoundingClientRect(); const panelRect = mounted.panel.getBoundingClientRect();
const workspaceRect = mounted.workspace.getBoundingClientRect(); const workspaceRect = mounted.workspace.getBoundingClientRect();
if (definition.panelSide === "start") { if (definition.compactWorkspaceFirst) {
assert(
panelRect.top >= workspaceRect.bottom - 1,
definition.label + " compact workspace precedes the long settings panel",
);
} else if (definition.panelSide === "start") {
assert( assert(
workspaceRect.top >= panelRect.bottom - 1, workspaceRect.top >= panelRect.bottom - 1,
definition.label + " compact workspace follows the panel vertically", definition.label + " compact workspace follows the panel vertically",
+5
View File
@@ -28,11 +28,16 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성, - `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감 bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증 - `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
- `array-builder.test.js`: Array 문서 모델, 격자·선형·둘레·Fermat·seed random 프리셋,
최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단
- `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test,
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 추정, 이동 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`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치
WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는 WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는
integration test에서 adapter가 있을 때 수행합니다. integration test에서 adapter가 있을 때 수행합니다.
+41
View File
@@ -86,6 +86,47 @@ test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채
assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss"); assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss");
}); });
test("Array canvas hit test는 viewport 변환에서 overflow된 마이크를 건너뛴다", () => {
const viewport = {
plot: { x: 0, y: 0 },
xMin: 0,
yMax: 100,
scale: 2,
};
const microphones = [
{ id: "overflow", position: [Number.MAX_VALUE, 0, 0] },
{ id: "normal", position: [5, 5, 0] },
];
assertEqual(
arrayPositionToCanvas(microphones[0].position, viewport).x,
Infinity,
"fixture overflows after viewport conversion",
);
assertEqual(
hitTestMicrophone({ x: 10, y: 190 }, microphones, viewport, 2)?.id,
"normal",
"finite microphone remains hittable",
);
});
test("Array canvas fit은 극단적인 유한 좌표에서도 aperture fallback viewport를 유지한다", () => {
const arrayDefinition = fixture();
arrayDefinition.microphones[0].position = [-Number.MAX_VALUE, 0, 0];
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, 0, 0];
const viewport = fitArrayViewport(800, 420, arrayDefinition);
for (const [name, value] of Object.entries({
scale: viewport.scale,
metersPerPixel: viewport.metersPerPixel,
xMin: viewport.xMin,
xMax: viewport.xMax,
yMin: viewport.yMin,
yMax: viewport.yMax,
})) {
assert(Number.isFinite(value), name + " remains finite");
}
assert(viewport.xMin < 0 && viewport.xMax > 0, "fallback keeps the aperture around the origin");
});
test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => { test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => {
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)]; const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
for (const step of candidates) { for (const step of candidates) {
+80 -1
View File
@@ -2,6 +2,7 @@ import {
MAX_MICROPHONES, MAX_MICROPHONES,
addMicrophone, addMicrophone,
arrayGeometryIssues, arrayGeometryIssues,
arrayStatistics,
centerMicrophones, centerMicrophones,
clampPositionToAperture, clampPositionToAperture,
createArrayDefinition, createArrayDefinition,
@@ -9,6 +10,7 @@ import {
generatePresetMicrophones, generatePresetMicrophones,
inferApertureShape, inferApertureShape,
moveMicrophone, moveMicrophone,
nextMicrophoneId,
positionInsideAperture, positionInsideAperture,
removeMicrophone, removeMicrophone,
replaceMicrophonesWithPreset, replaceMicrophonesWithPreset,
@@ -110,6 +112,45 @@ test("seed random preset은 재현되고 최소 간격과 형상 경계를 지
} }
}); });
test("1,024채널 random preset은 공간 index로 고유 좌표를 빠르게 생성한다", () => {
const startedAt = performance.now();
const microphones = generatePresetMicrophones({
type: "random",
aperture: { width: 0.45, height: 0.45 },
shape: "rectangle",
count: 1024,
seed: 20260828,
minimumSpacingM: 0.002,
});
const elapsedMs = performance.now() - startedAt;
assertEqual(microphones.length, 1024, "maximum random channel count");
assertEqual(
new Set(microphones.map((microphone) => microphone.position.join(","))).size,
1024,
"maximum random positions are unique",
);
assert(elapsedMs < 3000, "maximum random generation stays interactive: " + elapsedMs + " ms");
const failedAt = performance.now();
assertThrows(
() => generatePresetMicrophones({
type: "random",
aperture: { width: 0.45, height: 0.45 },
shape: "rectangle",
count: 1024,
seed: 7,
minimumSpacingM: 0.03,
}),
"배치할 수 없습니다",
"dense maximum random failure",
);
const failedElapsedMs = performance.now() - failedAt;
assert(
failedElapsedMs < 3000,
"impossible maximum random layout fails within a bound: " + failedElapsedMs + " ms",
);
});
test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => { test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => {
const arrayDefinition = createArrayDefinition(); const arrayDefinition = createArrayDefinition();
const before = JSON.stringify(arrayDefinition.microphones); const before = JSON.stringify(arrayDefinition.microphones);
@@ -185,7 +226,7 @@ test("좌표 clamp, 중심 정렬과 aperture 맞춤이 명시적으로 동작
assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height"); assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height");
}); });
test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한다", () => { test("문서의 명시적 형상 단서만으로 타원 생성 경계를 추론한다", () => {
const spiral = createArrayDefinition({ const spiral = createArrayDefinition({
description: "Fermat spiral on circular aperture", description: "Fermat spiral on circular aperture",
presetType: "spiral", presetType: "spiral",
@@ -194,4 +235,42 @@ test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한
assertEqual(inferApertureShape(spiral), "ellipse", "description inference"); assertEqual(inferApertureShape(spiral), "ellipse", "description inference");
const grid = createArrayDefinition(); const grid = createArrayDefinition();
assertEqual(inferApertureShape(grid), "rectangle", "grid inference"); assertEqual(inferApertureShape(grid), "rectangle", "grid inference");
grid.description = "Spring-mounted rectangular array";
assertEqual(inferApertureShape(grid), "rectangle", "word boundary avoids spring false positive");
});
test("매우 큰 유한 좌표에서도 배열 중심 통계가 유한하게 유지된다", () => {
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
arrayDefinition.microphones[0].position = [Number.MAX_VALUE, Number.MAX_VALUE, 0];
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, -Number.MAX_VALUE, 0];
const statistics = arrayStatistics(arrayDefinition);
assert(Number.isFinite(statistics.centroid[0]), "large x centroid remains finite");
assert(Number.isFinite(statistics.centroid[1]), "large y centroid remains finite");
assertEqual(statistics.centroid[0], Number.MAX_VALUE, "large x centroid");
assertEqual(statistics.centroid[1], 0, "balanced y centroid");
});
test("큰 숫자 suffix ID도 마이크 추가를 멈추지 않고 정확히 증가시킨다", () => {
const arrayDefinition = createArrayDefinition({ columns: 1, rows: 1 });
arrayDefinition.microphones[0].id = "mic-9007199254740992";
assertEqual(
nextMicrophoneId(arrayDefinition),
"mic-9007199254740993",
"BigInt suffix increment",
);
assertEqual(
addMicrophone(arrayDefinition, [0.01, 0, 0]).id,
"mic-9007199254740993",
"manual add uses the safe ID",
);
});
test("고정밀 import 좌표는 실제 값이 다르면 중복으로 오인하지 않는다", () => {
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
arrayDefinition.microphones[0].position = [0.0000000001, 0, 0];
arrayDefinition.microphones[1].position = [0.0000000002, 0, 0];
assert(
!arrayGeometryIssues(arrayDefinition).some((issue) => issue.severity === "error"),
"distinct high-precision positions",
);
}); });