feat: add interactive microphone array builder

This commit is contained in:
2026-08-28 22:18:18 +09:00
parent acf68f4931
commit 0f9b4f8942
7 changed files with 3298 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="마이크 위치를 직접 편집하고 격자·선형·원형·나선·랜덤 배열을 생성하는 micarrsim Array Builder"
/>
<title>Array Builder · micarrsim</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<link rel="stylesheet" href="/apps/array-builder/styles.css" />
</head>
<body>
<div class="app-shell array-builder-shell">
<header class="app-header array-builder-header">
<a class="app-brand" href="/">
<strong>micarrsim</strong>
<span>Array Builder</span>
</a>
<nav class="array-workflow" aria-label="마이크 어레이 제작 순서">
<ol>
<li><span>1</span>크기·형상</li>
<li><span>2</span>생성·편집</li>
<li><span>3</span>검증</li>
<li><span>4</span>저장</li>
</ol>
</nav>
<div class="header-actions">
<label class="button button--ghost file-button">
JSON 열기
<input id="array-input" type="file" accept="application/json,.json" />
</label>
<a class="button button--ghost" href="/apps/acoustic-camera/">Camera 열기</a>
<button class="button button--primary" id="export-button" type="button">Array JSON 저장</button>
</div>
</header>
<main class="array-builder-layout" id="array-builder-layout" data-split-layout>
<aside
class="array-controls"
id="array-builder-controls"
data-split-panel
aria-label="마이크 어레이 설정"
>
<div class="runtime-line">
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
<span class="save-state" id="save-state" role="status">기본 배열</span>
</div>
<section class="validation-card" aria-live="polite" aria-atomic="true">
<span class="validation-dot" id="validation-dot" aria-hidden="true"></span>
<div>
<strong id="validation-title">검증 대기</strong>
<p id="validation-message">마이크 배열을 준비하고 있습니다.</p>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>01</span><h2>배열 정보</h2></div>
<label class="field field--wide">
<span>이름</span>
<input id="array-name" type="text" maxlength="120" autocomplete="off" />
</label>
<label class="field field--wide">
<span>Array ID</span>
<input
id="array-id"
type="text"
maxlength="120"
pattern="[a-z0-9][a-z0-9._-]*"
title="영문 소문자나 숫자로 시작하고 소문자, 숫자, 점, 밑줄, 하이픈만 사용할 수 있습니다."
autocomplete="off"
/>
</label>
<label class="field field--wide">
<span>설명 <small>선택 사항</small></span>
<textarea id="array-description" rows="2" maxlength="1000"></textarea>
</label>
</section>
<section class="control-section control-section--primary">
<div class="section-title"><span>02</span><h2>크기와 형상</h2></div>
<div class="field-grid">
<label class="field">
<span>너비 (m)</span>
<input id="aperture-width" type="number" min="0.001" max="20" step="0.01" value="0.45" inputmode="decimal" />
</label>
<label class="field">
<span>높이 (m)</span>
<input id="aperture-height" type="number" min="0.001" max="20" step="0.01" value="0.45" inputmode="decimal" />
</label>
</div>
<label class="field field--wide">
<span>생성 경계</span>
<select id="aperture-shape">
<option value="rectangle">사각형</option>
<option value="ellipse">타원</option>
</select>
</label>
<p class="section-note">크기를 바꿔도 기존 마이크 좌표는 자동으로 늘어나거나 줄어들지 않습니다.</p>
<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="fit-aperture-button" type="button">좌표에 크기 맞춤</button>
<button class="button button--ghost button-grid__wide" id="clamp-microphones-button" type="button">경계 밖 마이크를 안으로 이동</button>
</div>
</section>
<section class="control-section">
<div class="section-title"><span>03</span><h2>프리셋 생성</h2></div>
<label class="field field--wide">
<span>배치 방식</span>
<select id="preset-type">
<option value="grid">격자</option>
<option value="linear">선형</option>
<option value="ring">원형</option>
<option value="spiral">나선</option>
<option value="random">균일 랜덤</option>
</select>
</label>
<div class="preset-grid">
<label class="field" id="preset-count-field">
<span>마이크 수</span>
<input id="preset-count" type="number" min="1" max="1024" step="1" value="16" inputmode="numeric" />
</label>
<label class="field" id="preset-axis-field">
<span>선형 축</span>
<select id="preset-axis">
<option value="x">가로 (x)</option>
<option value="y">세로 (y)</option>
</select>
</label>
<label class="field" id="preset-columns-field">
<span>열</span>
<input id="preset-columns" type="number" min="1" max="1024" step="1" value="4" inputmode="numeric" />
</label>
<label class="field" id="preset-rows-field">
<span>행</span>
<input id="preset-rows" type="number" min="1" max="1024" step="1" value="3" inputmode="numeric" />
</label>
<label class="field" id="preset-seed-field">
<span>Random seed</span>
<input id="preset-seed" type="number" min="0" max="4294967295" step="1" value="1" inputmode="numeric" />
</label>
<label class="field" id="preset-minimum-spacing-field">
<span>최소 간격 (m)</span>
<input id="preset-minimum-spacing" type="number" min="0" max="20" step="0.001" value="0.015" inputmode="decimal" />
</label>
<label class="field preset-grid__wide" id="preset-edge-inset-field">
<span>경계 여백 (m)</span>
<input id="preset-edge-inset" type="number" min="0" max="10" step="0.001" value="0.01" inputmode="decimal" />
</label>
</div>
<p class="section-note" id="preset-hint">격자는 아래에서 위로, 각 행은 왼쪽에서 오른쪽 순서로 채널을 만듭니다.</p>
<button class="button button--primary button--full" id="generate-preset-button" type="button">현재 배열을 프리셋으로 교체</button>
</section>
<section class="control-section microphone-section">
<div class="section-title section-title--split">
<div><span>04</span><h2>마이크 편집</h2></div>
<button class="mini-button" id="add-microphone-button" type="button">+ 마이크</button>
</div>
<label class="field field--wide search-field">
<span>목록 검색</span>
<input id="microphone-search" type="search" placeholder="ID로 찾기" autocomplete="off" />
</label>
<div class="microphone-list" id="microphone-list" role="listbox" aria-label="마이크 채널 목록">
<p class="list-empty">마이크 목록을 준비하고 있습니다.</p>
</div>
<div class="selected-microphone-editor">
<p class="selection-empty" id="microphone-selection-empty">canvas나 목록에서 마이크를 선택하세요.</p>
<div id="microphone-fields">
<label class="field field--wide">
<span>마이크 ID</span>
<input id="microphone-id" type="text" maxlength="64" autocomplete="off" disabled />
</label>
<div class="field-grid field-grid--three">
<label class="field"><span>x (m)</span><input id="microphone-x" type="number" step="0.001" inputmode="decimal" disabled /></label>
<label class="field"><span>y (m)</span><input id="microphone-y" type="number" step="0.001" inputmode="decimal" disabled /></label>
<label class="field"><span>z (m)</span><input id="microphone-z" type="number" step="0.001" inputmode="decimal" disabled /></label>
</div>
<details class="inline-details">
<summary>보정값</summary>
<div class="field-grid calibration-fields">
<label class="field"><span>Sensitivity (dB)</span><input id="microphone-sensitivity" type="number" min="-120" max="120" step="0.1" inputmode="decimal" disabled /></label>
<label class="field"><span>Phase (deg)</span><input id="microphone-phase" type="number" min="-180" max="180" step="0.1" inputmode="decimal" disabled /></label>
</div>
<p class="section-note" id="frequency-response-summary">주파수 응답 없음</p>
</details>
<div class="button-grid reorder-actions">
<button class="button button--ghost" id="move-microphone-up-button" type="button" disabled>채널 앞으로</button>
<button class="button button--ghost" id="move-microphone-down-button" type="button" disabled>채널 뒤로</button>
<button class="button button--danger button-grid__wide" id="remove-microphone-button" type="button" disabled>선택 마이크 삭제</button>
</div>
</div>
</div>
</section>
</aside>
<div
class="split-handle"
id="array-builder-split-handle"
role="separator"
tabindex="0"
aria-label="배열 설정 패널 너비 조절"
aria-controls="array-builder-controls"
aria-orientation="vertical"
data-split-resizer
data-storage-key="micarrsim.array-builder.control-panel-width"
title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화"
></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>
</div>
<script type="module" src="/packages/ui/runtime-status.js"></script>
<script type="module" src="/apps/array-builder/main.js"></script>
</body>
</html>