Compare commits

...

14 Commits

66 changed files with 16961 additions and 460 deletions
+90 -27
View File
@@ -1,9 +1,10 @@
# micarrsim
192채널 MEMS 마이크 배열의 배치와 자유음장·저비용 실내 근사에서의 DAS 음향 영상을 비교하는 로컬 브라우저
시뮬레이터입니다. Dataset Builder에서 녹음 파일·합성 신호와 이동 경로를 포함한 재현 가능한
음향 장면을 만들고 Acoustic Camera에서 CPU 또는 WebGPU로 배열 응답, acoustic map과 위치 검출
지표를 계산하거나 이동 장면을 시간축으로 재생합니다.
192채널 MEMS 마이크 배열의 배치와 자유음장·저비용 실내 근사에서 DAS·DAS-PHAT·GCC-PHAT
위치 추정을 비교하는 로컬 브라우저
시뮬레이터입니다. Array Builder에서 마이크 배치를 설계하고, Dataset Builder에서 녹음 파일·합성
신호와 이동 경로를 포함한 재현 가능한 음향 장면을 만든 뒤, Acoustic Camera에서 CPU 또는
WebGPU로 배열 응답, acoustic map과 위치 검출 지표를 계산하거나 이동 장면을 시간축으로 재생합니다.
별도 프로그램이나 패키지를 설치하지 않습니다. 모든 앱과 계산은 이 저장소와 브라우저 안에서
실행됩니다.
@@ -46,12 +47,25 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
1. 시작 화면에서 **Acoustic Camera**를 엽니다.
2. 기본 192채널 rectangular 배열, single-tone scene과 50×50 experiment가 준비될 때까지
기다립니다.
3. Snapshot 카드의 **▶ Snapshot 실행**을 누릅니다.
3. 처음에는 **DAS · 기준 power**를 두고 Snapshot 카드의 **▶ Snapshot 실행**을 누릅니다.
4. 계산이 끝나면 overlay와 평가 지표를 확인하고 **Result JSON**을 누릅니다.
같은 입력에서 **위치 추정 알고리즘**만 DAS-PHAT으로 바꿔 다시 실행하면 아래 비교 기록에 두 결과가
나란히 남습니다. GCC-PHAT은 광대역 입력에서 비교하세요.
실제 완료 시간은 브라우저와 장치에 따라 다릅니다. CPU fallback에서는 WebGPU보다 오래 걸릴 수
있습니다.
### 화면 폭 조절
데스크톱에서는 Array Builder와 Acoustic Camera의 왼쪽 설정 패널, Dataset Builder의 오른쪽 설정
패널 경계를 드래그해 넓힐 수 있습니다. 경계선에 키보드 초점을 두면 `←`/`→`로 16px씩,
`Shift`와 함께 누르면 64px씩 조절됩니다. `Home`/`End`는 최소·최대
폭, `Enter` 또는 더블클릭은 기본 폭으로 되돌립니다.
조절한 폭은 앱별로 현재 브라우저에 저장됩니다. 화면이 좁아지면 저장된 값과 관계없이 패널과 작업
영역이 자동으로 위아래로 쌓이고, 다시 데스크톱 폭으로 돌아오면 저장된 폭이 복원됩니다.
### 이동 음원 바로 보기
1. Acoustic Camera에서 **이동 예제 열기**를 누릅니다.
@@ -63,7 +77,25 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
## 내 장면으로 전체 흐름 실행하기
### 1. Dataset Builder에서 Scene 만들기
### 1. Array Builder에서 마이크 배열 만들기 (선택)
1. 시작 화면에서 **Array Builder**를 엽니다.
2. aperture 너비·높이와 프리셋을 배치할 **생성 경계**(사각형 또는 타원)를 정합니다.
3. **격자**, **선형**, **원형**, **Fermat 나선**, **균일 랜덤** 중 하나로 배치합니다. 랜덤은
seed로 같은 좌표를 재현하며, 최소 간격과 경계 여백도 지정할 수 있습니다.
4. **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로 수정합니다.
목록에서는 삭제와 채널 순서 변경도 할 수 있습니다.
5. 상태가 **Array JSON 저장 가능**이면 **Array JSON 저장**을 누릅니다.
불러온 Array의 `sensitivityDb`, `phaseOffsetDeg`, `frequencyResponse` 보정 정보는 마이크 위치·ID나
채널 순서를 편집해도 보존됩니다. 프리셋 생성은 마이크 목록 전체를 새로 만드는 작업이므로 기존
채널별 보정값도 교체됩니다.
생성 경계 형상, 프리셋 종류, random seed와 최소 간격은 편집을 돕는 설정이며 표준 Array JSON에
저장되지 않습니다. 저장되는 것은 aperture 너비·높이와 최종 마이크 좌표·순서 및 채널별 보정값입니다.
자세한 사용법은 [`apps/array-builder/README.md`](apps/array-builder/README.md)를 참고하세요.
### 2. Dataset Builder에서 Scene 만들기
1. 시작 화면에서 **Dataset Builder**를 엽니다.
2. 선택적으로 배경 이미지와 오디오를 추가합니다.
@@ -76,16 +108,16 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
**Preview WAV 저장**은 source와 ambient를 공간 정보 없이 하나의 mono channel로 합친 청취
확인용 파일입니다. 192채널 마이크 신호나 beamforming 결과가 아닙니다.
### 2. Acoustic Camera에서 실행하기
### 3. Acoustic Camera에서 실행하기
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
2. 다음 순서로 입력합니다: **Scene → Array → Experiment → Assets**.
3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다.
4. 기본 배열을 사용하지 않을 때만 **Array**에서 `.array.json`을 선택합니다.
4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다.
5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다.
6. Scene에 이미지나 audio asset이 있다면 **Assets**에서 Builder에 사용했던 원본 파일을 다시
선택합니다. 여러 파일을 한 번에 선택할 수 있습니다.
7. 입력 검증이 통과하면 backend와 scan grid를 정하고 실행합니다.
7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다.
8. 완료 후 **Result JSON**을 저장합니다.
Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수
@@ -106,18 +138,38 @@ SHA-256 manifest를 저장합니다. 브라우저는 새 페이지나 세션에
- 실제 decode되어 메모리에 유지되는 오디오 합계는 최대 192 MiB입니다.
- Scene JSON에는 원본 파일명이 기록될 수 있으므로 파일을 외부에 공유하기 전에 확인하세요.
## Backend와 scan grid
## 알고리즘, Backend와 계산량
| 알고리즘 | 원시 map | backend | 용도와 주의점 |
| --- | --- | --- | --- |
| DAS | 상대 acoustic power | CPU, WebGPU | 가장 단순한 기준선. 채널 gain과 알려진 마이크 응답을 보정합니다. |
| DAS-PHAT | 무차원 score | CPU, WebGPU | 마이크별 진폭을 버리고 위상 정합을 비교해 gain·반사에 더 강건할 수 있습니다. |
| GCC-PHAT | 무차원 score | CPU | 한 reference와 나머지 마이크의 TDOA 상관을 사용합니다. 광대역이 필요하며 협대역에서는 주기적 모호성이 큽니다. |
- **Auto (GPU→CPU)**: 권장값입니다. WebGPU adapter나 device를 사용할 수 없거나 지원되는 장치
한계를 넘으면 CPU로 계속합니다.
- **WebGPU 강제**: GPU 경로만 사용합니다. 장치를 열 수 없거나 한계를 넘으면 fallback하지 않고
오류를 표시합니다.
- **CPU 강제**: 비교와 호환성 확인을 위해 CPU DAS만 사용합니다.
- **CPU 강제**: 비교와 호환성 확인을 위해 선택한 알고리즘의 CPU 구현을 사용합니다.
GCC-PHAT은 현재 CPU 전용이므로 선택하면 Auto 또는 CPU만 사용할 수 있습니다. Camera는 각 band가
8개 미만의 FFT bin을 포함하면 협대역 경고를 표시합니다.
WebGPU 경로도 신호 합성과 STFT 일부는 CPU에서 수행합니다. grid의 point 수는 50×50을 1로 보면
100×100은 4배, 200×200은 16배입니다. 처음에는 50×50을 사용하고 해상도가 필요할 때만 높이세요.
실행 시간은 microphone 수, scene 길이, FFT, 대역 폭과 장치에 함께 좌우됩니다.
DAS 계열의 scan 주항은 `P × M × T × F`입니다. 192채널·50×50·46 frames·2 bins의 기존
fixture는 44,160,000 complex MAC입니다. GCC-PHAT은 같은 조건에서 forward STFT를 그대로
수행한 뒤 191 reference pairs에 대해 35,144회의 2-pass cross-spectrum 연산, 191회의 inverse
FFT와 477,500회의 point/pair lookup을 수행합니다. 따라서 큰 grid·광대역에서는 scan 비용을
줄이지만 작은 grid에서는 공통 STFT 때문에 전체 시간이 같은 비율로 줄지는 않습니다.
저장된 Intel Gen9 측정에서 DAS 50×50의 GPU wall은 79.2 ms였지만 CPU STFT는 1,669.8 ms,
signal 합성은 별도 1,200.5 ms였습니다. 즉 현재 기본 fixture의 병목은 단순히 grid scan 하나가
아닙니다. Camera의 workload 줄은 STFT, DAS MAC 또는 GCC cross-spectrum·inverse FFT·lookup,
signal path와 distance cache를 따로 표시합니다.
## 음향 환경과 마이크 응답
Camera의 **고급 설정**에서 자유음장, 흡음된 실내, 일반 실내와 반사 강한 실내를 선택할 수
@@ -137,13 +189,18 @@ Camera의 **고급 설정**에서 자유음장, 흡음된 실내, 일반 실내
## 위치 추정, 빔 분포와 복수 음원
DAS 출력은 각 scan point의 상대 acoustic power map이며 위치 확률분포가 아닙니다. Camera는 map의
local peak를 위치 후보로 표시합니다. **빔 분포**를 켜면 각 peak의 연결된 half-power lobe에 대해
power 가중 공분산을 계산해 1× RMS 점선과 2× RMS 영역을 그립니다. Gaussian의 등고선처럼 보일 수
있지만 통계적으로 보정된 Gaussian, 신뢰구간 또는 발생 확률은 아닙니다.
DAS 출력은 각 scan point의 상대 acoustic power, PHAT 계열 출력은 무차원 score이며 어느 쪽도
위치 확률분포가 아닙니다. Camera는 map의 local peak를 위치 후보로 표시합니다. **빔 분포**를 켜면
각 peak의 연결된 half-maximum lobe에 대해 map 값 가중 공분산을 계산해 1× RMS 점선과 2× RMS
영역을 그립니다. Gaussian의 등고선처럼 보일 수 있지만 통계적으로 보정된 Gaussian, 신뢰구간 또는
발생 확률은 아닙니다.
GCC-PHAT raw score는 pair 수·band coefficient 수·IFFT 크기로 정규화한 0–1 양의 상관 응답입니다.
scan 범위나 마이크 수 때문에 값이 단순히 커지는 현상은 막지만, 0.8을 “위치 확률 80%”로 읽을 수는
없고 다른 알고리즘의 score와 절대값을 비교해서도 안 됩니다.
복수 음원은 각각 별도 local peak가 남을 만큼 공간적으로 분리되고 선택 대역에 에너지가 있어야
검출됩니다. **Peak 검출 임계값 (%)**은 최강 peak 대비 후보의 최소 power입니다. 기본 25%에서 약한
검출됩니다. **Peak 검출 임계값 (%)**은 최강 peak 대비 후보의 최소 map 값입니다. 기본 25%에서 약한
두 번째 음원이 빠지면 10–20%로 낮출 수 있지만 sidelobe 오검출도 늘어납니다. 서로 같은 대역에서
main lobe보다 가깝거나 gain 차이가 큰 음원은 conventional DAS의 물리·알고리즘 한계 때문에
threshold만으로 분리되지 않을 수 있습니다.
@@ -161,9 +218,9 @@ index와 audio asset 재생 위치가 이어지도록 신호를 합성합니다.
- 실제 장치: 물리 192채널 capture/stream gateway는 하드웨어 형식이 정해진 뒤 별도 adapter가 필요
Scene 합성 없이 동기식 `Float32Array` 마이크 채널을 바로 처리하는
`createLocalizationPlan`, `localizeMultichannel`과 `StreamingDasLocalizer`가
`createLocalizationPlan`, `localizeMultichannel`과 `StreamingLocalizer`가
`packages/acoustics-core`에 분리되어 있습니다. 고정 ring buffer, oldest-first drop,
반복 geometry·steering cache와 `AbortSignal` 취소를 제공하며 실제 capture 장치에는
반복 geometry·workspace cache와 `AbortSignal` 취소를 제공하며 실제 capture 장치에는
channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부는 각 결과의
`processingTimeMs <= deadlineMs`와 누적 `deadlineMisses`·`droppedSamples`로
해당 장치에서 확인합니다. 복사할 파일과 운용 계약은
@@ -173,11 +230,11 @@ channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부
Result JSON은 계약 버전 `1.0.0`의 `micarrsim.result` 문서입니다. 주요 내용은 다음과 같습니다.
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `backend`와 생성 시각
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `algorithm`·`backend`와 생성 시각
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
- 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats`
- 전체·신호 생성·beamforming 시간인 `timingMs`
- 주파수 대역별 선형 power `rawMap`, 표시용 `displayMap`, 검출 `peaks`
- 전체·신호 생성·위치 추정 시간인 `timingMs.localization`과 기존 reader용 `beamforming` alias
- 주파수 대역별 power 또는 score `rawMap`, 표시용 `displayMap`, 검출 `peaks`
- 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기
위한 평가 파라미터와 `metrics`
- peak별 half-power 공분산과 RMS ellipse를 담은 `beamSpread` (`calibrated: false`)
@@ -195,16 +252,21 @@ noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 groun
음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC와 channel clock은 모델링하지
않습니다. Beamformer는 conventional DAS이며 UI의 DAMAS와 CLEAN-SC는 아직 사용할 수 없습니다.
않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC,
DAMAS와 CLEAN-SC는 구현하지 않습니다.
따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
판단을 대신하지 않습니다.
## 배열 예제
- `assets/array-geometries/rectangular-16x12.array.json`: 0.45 m 정사각 aperture의 16×12 기준 배열
- `assets/array-geometries/fermat-spiral-192.array.json`: 0.45 m 원형 aperture의 192채널 Fermat spiral
- **채널 수 비교:** 같은 0.45 m aperture에서 4·12·48·192·384채널 rectangular 배열
- **물리 크기 비교:** 같은 192채널 16×12 topology에서 0.12·0.45·1.20 m 배열
- **배치 비교:** 같은 192채널 규모의 rectangular baseline과 0.45 m Fermat spiral
Camera의 **비교 배열** 선택으로 두 fixture를 같은 scene과 experiment에서 연속 실행할 수 있습니다.
Camera의 **배열 선택**에서 8개 fixture를 바로 불러와 같은 Scene/Experiment로 연속 실행할 수 있습니다.
저채널·대형 배열의 grating lobe나 compact 배열의 넓은 main lobe는 배치 trade-off를 관찰하기 위한
의도된 예제 특성입니다.
새 배치를 직접 만들려면 [Array Builder](apps/array-builder/README.md)를 사용하세요. 저장소 fixture의
상세한 생성법은 [`assets/array-geometries/README.md`](assets/array-geometries/README.md)를 참고하세요.
## 테스트와 benchmark
@@ -230,6 +292,7 @@ test.cmd
- [`docs/data-contracts.md`](docs/data-contracts.md): Array·Scene·Experiment·Result 계약
- [`docs/acoustic-model.md`](docs/acoustic-model.md): 마이크 응답과 자유음장·shoebox 희소 근사
- [`docs/realtime-porting.md`](docs/realtime-porting.md): 실측 다채널 위치추정 API 포팅
- [`apps/array-builder/README.md`](apps/array-builder/README.md): 배열 생성·직접 편집과 Camera 전달
- [`schemas/README.md`](schemas/README.md): JSON Schema 구성
- [`docs/execution-plan.md`](docs/execution-plan.md): 구현 단계와 기술 결정
- [`goal.md`](goal.md): 프로젝트 목표
@@ -240,8 +303,8 @@ test.cmd
## 디렉터리 구조
```text
apps/ Dataset Builder와 Acoustic Camera UI
assets/ 192채널 배열과 synthetic Scene/Experiment fixture
apps/ Array Builder, Dataset Builder와 Acoustic Camera UI
assets/ 마이크 배열과 synthetic Scene/Experiment fixture
benchmarks/ 현재 브라우저에서 실행하는 WebGPU scaling 측정
docs/ 사용자 안내, 데이터 계약, 실행 계획과 기준 실측
packages/ 음향 수치 모델, 계약 검증, UI, WebGPU kernel
+67 -29
View File
@@ -1,14 +1,20 @@
# Acoustic Camera
Scene과 마이크 배열로 자유음장 또는 저비용 shoebox microphone signal을 합성하고, STFT와
conventional DAS를 통해 주파수 대역별 acoustic map과 ground-truth 평가 지표를 만드는 브라우저
앱입니다. 정지 장면은 Snapshot, trajectory 장면은 Moving 흐름으로 분리되어 있습니다.
DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 ground-truth 평가 지표를
만드는 브라우저 앱입니다. 정지 장면은 Snapshot, trajectory 장면은 Moving 흐름으로 분리되어
있습니다.
## 열기와 Snapshot
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Acoustic Camera**를 선택하거나
<http://127.0.0.1:4173/apps/acoustic-camera/>을 엽니다. `index.html`을 직접 열지 마세요.
데스크톱에서는 왼쪽 설정 패널과 결과 작업 영역 사이의 선을 드래그해 폭을 조절할 수 있습니다.
방향키도 지원하며 `Enter` 또는 더블클릭으로 기본 폭을 복원합니다. 앱별 폭은 브라우저에
저장되고, 980px 이하에서는 설정과 결과가 위아래로 자동 배치됩니다. 패널을 최소 폭으로 줄이면
파일·분석 설정과 주파수 대역 입력도 실제 패널 폭에 맞춰 한 열 또는 두 행으로 재배치됩니다.
가장 빠른 확인 방법은 다음과 같습니다.
1. 기본 fixture가 준비될 때까지 기다립니다.
@@ -42,12 +48,31 @@ Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라
파일은 외부로 업로드되지 않고 브라우저 메모리에서만 처리됩니다.
## Backend 선택
## 위치 추정 알고리즘과 Backend
**분석 설정 → 위치 추정 알고리즘**에서 다음 세 구현을 선택합니다.
- **DAS**: 채널 진폭과 위상을 유지해 coherent sum power를 계산하는 기준선입니다. CPU와 WebGPU를
지원하고 `rawMap.scale`은 `power`입니다.
- **DAS-PHAT**: 각 STFT 성분의 크기를 정규화하고 위상을 합산합니다. 채널 gain 차이에 덜
민감한 비교용이며 CPU와 WebGPU를 지원합니다. 결과는 무차원 `score`입니다.
- **GCC-PHAT**: 기준 마이크와 나머지 마이크의 PHAT 상관에서 기하학적 TDOA를 평가합니다. 현재
CPU 전용이므로 Auto는 CPU를 선택하고 **WebGPU 강제**는 선택할 수 없습니다. 각 band가 8개
미만의 FFT bin을 포함하면 협대역 상관이 불안정할 수 있다는 경고가 표시됩니다.
PHAT score는 위치 확률이나 음향 power가 아닙니다. 세 알고리즘 모두 같은 scan grid에 응답 map을
만들지만, 진폭 보존 여부와 실패 양상이 다르므로 같은 Scene·Array·band로 비교하세요.
GCC-PHAT raw score는 pair·band coefficient·IFFT 크기를 정규화한 0–1 양의 상관 응답이라 scan
범위나 마이크 수에 따른 단순 scale 변화는 피하지만, 확률이나 다른 알고리즘과 공통인 confidence는
아닙니다.
- **Auto (GPU→CPU)**: 권장. WebGPU를 먼저 시도하고 adapter/device 부재나 지원되는 장치 한계가
있으면 CPU로 계속합니다.
- **WebGPU 강제**: GPU만 사용하며 운용 오류가 나도 CPU로 fallback하지 않습니다.
- **CPU 강제**: CPU 수치 기준이나 WebGPU가 없는 환경에서 사용합니다.
있으면 CPU로 계속합니다. 라이브 준비에서는 첫 fallback 뒤 남은 frame이 같은 CPU analysis
plan을 재사용하므로 GPU 실패를 매 frame 반복하지 않습니다. GCC-PHAT에서는 **Auto (CPU)**로
표시됩니다.
- **WebGPU 강제**: DAS와 DAS-PHAT를 GPU에서만 실행하며 운용 오류가 나도 CPU로 fallback하지
않습니다.
- **CPU 강제**: 선택한 알고리즘의 CPU 수치 기준이나 WebGPU가 없는 환경에서 사용합니다.
WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 **계산 취소**를 누르면 Worker를
종료하며, 이미 완료된 이전 결과가 있으면 그대로 남습니다.
@@ -82,9 +107,10 @@ Array의 각 microphone에는 선택적 `frequencyResponse`를 넣을 수 있습
```
curve는 합성 microphone channel에 항상 적용됩니다. **마이크 주파수 응답 처리**의 기본
**측정 curve 적용 + 위치추정에서 역보정**은 CPU와 WebGPU DAS에서 알려진 gain/phase를
역보정합니다. **측정 curve 적용 + 보정하지 않음**은 합성 응답은 유지하고 역보정만 꺼서 영향을
비교합니다. curve가 없는 마이크는 기존 결과를 유지합니다. 응답 notch를 과도하게 증폭하지
**측정 curve 적용 + 위치추정에서 역보정**은 선택한 CPU 위치추정과 DAS/DAS-PHAT WebGPU
경로에서 알려진 gain/phase를 역보정합니다. **측정 curve 적용 + 보정하지 않음**은 합성 응답은
유지하고 역보정만 꺼서 영향을 비교합니다. curve가 없는 마이크는 기존 결과를 유지합니다. 응답 notch를
과도하게 증폭하지
않도록 역보정의 양의 gain boost는 기본 24 dB로 제한하며, 사용자 Experiment JSON의
`beamforming.maxCompensationDb`에서 0–120 dB로 지정할 수 있습니다.
@@ -94,31 +120,40 @@ curve는 합성 microphone channel에 항상 적용됩니다. **마이크 주파
## Grid, STFT와 대역
50×50은 2,500 points입니다. 이를 1로 보면 100×100은 4배, 200×200은 16배 points를 계산합니다.
처음에는 50×50을 권장합니다. 실행량은 microphone 수, scene 길이, FFT/hop과 선택 band의 bin
수에도 비례하므로 실제 시간 배율은 장치마다 다릅니다.
처음에는 50×50을 권장합니다. 연산량 줄에는 공통 STFT transform 수와 signal path, distance
cache를 표시하고 알고리즘별 위치추정 비용을 다음처럼 분리합니다.
- DAS/DAS-PHAT: scan point × microphone × STFT frame × 선택 bin의 complex MAC
- GCC-PHAT: 기준 마이크 pair별 two-pass cross-spectrum, pair × band inverse FFT와
scan point × pair × band correlation lookup
이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop,
band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist를 넘거나 겹치는 id 등 계약 오류는
실행 전에 표시됩니다. 현재 후처리는 raw DAS만 지원하며 DAMAS와 CLEAN-SC 항목은 비활성화되어
있습니다.
실행 전에 표시됩니다. DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
항목은 Camera UI에서 제거했습니다. Result의 `processedMaps` 계약만 향후 확장을 위해 남아 있습니다.
**Peak 검출 임계값 (%)**은 최강 local peak의 power에 대한 상대 임계값이며 Experiment JSON에
**Peak 검출 임계값 (%)**은 최강 local peak의 map 값에 대한 상대 임계값이며 Experiment JSON에
저장됩니다. 기본 25%에서 약한 두 번째 음원이 빠지면 10–20%로 낮추세요. 낮은 값은 더 약한 후보를
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 main lobe
폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 conventional DAS map에서 하나로 합쳐질 수
있습니다.
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
main lobe 폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 하나로 합쳐질 수 있습니다.
## 화면과 배열 비교
## 화면과 배열·알고리즘 비교
- acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다.
- **Ground truth**를 켜면 source 위치와 검출 peak를 비교할 수 있습니다.
- **빔 분포**는 peak 주변 half-power lobe의 power 가중 공분산을 1× RMS 점선과 2× RMS 영역으로
표시합니다. 위치 확률, Gaussian 신뢰구간 또는 측정 불확실성 보정값은 아닙니다.
- rectangular와 Fermat spiral preset을 같은 Scene/Experiment에서 연속 실행하면 **배열 비교 기록**에
backend, timing과 평가 요약이 남습니다.
- **빔 분포**는 DAS power의 half-power 또는 PHAT score의 half-maximum 연결 성분을 해당 map
값으로 가중한 공분산으로 계산해 1× RMS 점선과 2× RMS 영역으로 표시합니다. 위치 확률,
Gaussian 신뢰구간 또는 측정 불확실성 보정값은 아닙니다.
- **배열 선택**에는 채널 수(4·12·48·192·384), aperture 크기(0.12·0.45·1.20 m), rectangular와
Fermat spiral 배치를 분리 비교하는 8개 preset이 있습니다. 같은 scene·scan·STFT에서 연속
실행하면 **비교 기록**에 배열, 알고리즘, 실제 backend, 위치추정/전체 timing과 평가 요약이 함께
남습니다.
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각
**JSON 저장** 버튼으로 보존할 수 있습니다.
- **전체 기록 JSON**은 세션에 남은 모든 배열 비교 요약을
- **전체 기록 JSON**은 세션에 남은 배열·알고리즘·backend 비교 요약을
`micarrsim.comparison-history` 문서로 저장합니다.
미저장 Scene·Experiment·Result·비교 기록은 우측 저장 상태에 표시됩니다. 직전 Result를 저장하지
@@ -130,7 +165,7 @@ curve는 합성 microphone channel에 항상 적용됩니다. **마이크 주파
Scene source에 두 개 이상의 `trajectory` keyframe이 있으면 **Map 준비**가 활성화됩니다.
Builder에서는 **시간에 따라 음원 이동**과 경로 미리보기 slider로 이 데이터를 만들 수 있습니다.
1. Map rate와 backend를 선택합니다. 처음에는 2 Hz와 Auto를 권장합니다.
1. 알고리즘, Map rate와 backend를 선택합니다. 처음에는 DAS, 2 Hz와 Auto를 권장합니다.
2. **Map 준비**를 누릅니다. 현재 입력 snapshot을 최대 48개, map당 최대 2,500 points의 짧은
분석창으로 계산합니다.
3. **재생** 또는 시간 slider로 이동합니다. Heatmap은 이웃한 acoustic map 사이를 보간하고
@@ -145,13 +180,15 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
**Result JSON**은 검증을 통과한 `micarrsim.result` 문서만 저장합니다.
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `backend`
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `algorithm`과 `backend`
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse
path 전체·종류별 수
- `timingMs`: 전체, signal generation과 beamforming 시간
- band별 `rawMap` 선형 power, `displayMap`, 검출 `peaks`
- 각 peak의 비보정 half-power 공분산과 RMS ellipse인 `beamSpread`
- `timingMs`: 전체, signal generation과 `localization`; 기존 소비자 호환용 `beamforming`은 같은
위치추정 시간을 담는 alias
- band별 `rawMap`: DAS는 선형 `power`, PHAT 계열은 무차원 `score`; 화면용 `displayMap`과 검출
`peaks`
- 각 peak의 비보정 half-power/half-maximum 공분산과 RMS ellipse인 `beamSpread`
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
`mainLobeWidthM`
@@ -171,7 +208,8 @@ sensitivity/phase/frequency-response calibration은 적용하지만 결과는
실제 동기식 microphone `Float32Array`를 Scene 합성 없이 위치추정하려면
[실시간 위치추정 코어 포팅](../../docs/realtime-porting.md)의
`createLocalizationPlan`, `localizeMultichannel` 또는
`StreamingDasLocalizer`를 사용하세요. 실시간성은 장치에서
`createStreamingLocalizer`를 사용하세요. portable config의 `algorithm`으로 같은 세 위치추정을
선택할 수 있습니다. 실시간성은 장치에서
`processingTimeMs <= deadlineMs`와 `deadlineMisses`·`droppedSamples`를
측정해 판정해야 합니다.
+49 -15
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="192채널 WebGPU acoustic camera simulator" />
<meta name="description" content="다중 채널 CPU·WebGPU acoustic camera simulator" />
<title>Acoustic Camera · micarrsim</title>
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
<link rel="stylesheet" href="/apps/acoustic-camera/styles.css" />
@@ -29,8 +29,8 @@
<a href="#inspect-step" data-workflow-step="4"><span>4</span><strong>확인</strong><small>Map · 지표</small></a>
</nav>
<main class="camera-layout">
<aside class="control-panel" aria-label="입력 및 시뮬레이션 설정">
<main class="camera-layout" id="camera-layout" data-split-layout>
<aside class="control-panel" id="camera-controls" data-split-panel aria-label="입력 및 시뮬레이션 설정">
<section class="workflow-card input-card" id="input-step" data-workflow-section="1">
<div class="step-heading">
<span class="step-number">1</span>
@@ -51,8 +51,29 @@
</div>
<div class="array-card">
<div class="array-heading"><div><span class="compact-label">MIC ARRAY</span><h3 id="array-title">배열</h3></div><strong id="microphone-count">— ch</strong></div>
<label class="field"><span>배열 선택</span><select id="array-preset"><option value="rectangular-16x12">16×12 rectangular · 기준</option><option value="fermat-spiral-192">Fermat spiral · 비교</option><option value="custom">불러온 Array JSON</option></select></label>
<div class="array-heading"><div><span class="compact-label">MIC ARRAY</span><h3 id="array-title">배열</h3><a class="builder-link" href="/apps/array-builder/">Array Builder에서 만들기 →</a></div><strong id="microphone-count">— ch</strong></div>
<label class="field">
<span>배열 선택</span>
<select id="array-preset">
<optgroup label="기준 배열">
<option value="rectangular-16x12">16×12 rectangular · 192 ch / 0.45 m</option>
<option value="fermat-spiral-192">Fermat spiral · 192 ch / Ø 0.45 m</option>
</optgroup>
<optgroup label="채널 수 비교 · 0.45 m">
<option value="rectangular-2x2-450mm">2×2 sparse · 4 ch</option>
<option value="rectangular-4x3-450mm">4×3 sparse · 12 ch</option>
<option value="rectangular-8x6-450mm">8×6 · 48 ch</option>
<option value="rectangular-24x16-450mm">24×16 dense · 384 ch</option>
</optgroup>
<optgroup label="물리 크기 비교 · 192 ch">
<option value="rectangular-16x12-compact">16×12 compact · 0.12 m</option>
<option value="rectangular-16x12-large">16×12 large · 1.20 m</option>
</optgroup>
<optgroup label="사용자 배열">
<option value="custom">불러온 Array JSON</option>
</optgroup>
</select>
</label>
<details class="inline-details" id="array-details">
<summary>배열 미리보기와 좌표 정보</summary>
<canvas id="array-canvas" aria-label="마이크 배열 평면도"></canvas>
@@ -68,6 +89,7 @@
</div>
<div class="quick-field-grid">
<label class="field field--wide"><span>위치 추정 알고리즘</span><select id="algorithm"><option value="das">DAS · 기준 power</option><option value="das-phat">DAS-PHAT · 위상 기반</option><option value="gcc-phat">GCC-PHAT · TDOA 기반 / CPU</option></select></label>
<label class="field"><span>Backend</span><select id="backend"><option value="auto">Auto (GPU→CPU)</option><option value="webgpu">WebGPU 강제</option><option value="cpu">CPU 강제</option></select></label>
<label class="field"><span>Grid preset</span><select id="grid-preset"><option value="50">50×50 · 기준</option><option value="100">100×100 · 4× points</option><option value="200">200×200 · 16× points</option><option value="custom">직접 입력</option></select></label>
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
@@ -113,7 +135,6 @@
<label class="field"><span>Hop size</span><input id="hop-size" type="number" min="1" max="4096" step="1" /></label>
<label class="field"><span>Map 정규화</span><select id="normalization"><option value="peak">Peak</option><option value="none">없음</option></select></label>
<label class="field"><span>Dynamic range (dB)</span><input id="dynamic-range" type="number" min="6" max="120" step="1" /></label>
<label class="field field--wide"><span>후처리</span><select id="postprocess"><option value="none">없음 · raw DAS</option><option disabled>DAMAS · 후속 milestone</option><option disabled>CLEAN-SC · 후속 milestone</option></select></label>
</div>
</div>
</div>
@@ -135,7 +156,20 @@
</section>
</aside>
<section class="scene-workspace" aria-label="Acoustic image workspace">
<div
class="split-handle"
id="camera-split-handle"
role="separator"
tabindex="0"
aria-label="분석 설정 패널 너비 조절"
aria-controls="camera-controls"
aria-orientation="vertical"
data-split-resizer
data-storage-key="micarrsim.camera.control-panel-width"
title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화"
></div>
<section class="scene-workspace" id="camera-workspace" aria-label="Acoustic image workspace">
<div class="workspace-heading">
<div><span class="compact-label">CURRENT SCENE</span><h1 id="workspace-title">음향 장면</h1></div>
<p class="workspace-meta" id="scene-meta">장면을 불러오고 있습니다.</p>
@@ -184,7 +218,7 @@
<div class="inspect-heading">
<div class="step-heading step-heading--horizontal">
<span class="step-number">4</span>
<div><h2 id="inspect-title">Map과 결과 확인</h2><p>Canvas가 실제 음향 에너지와 추정 위치를 표시합니다.</p></div>
<div><h2 id="inspect-title">Map과 결과 확인</h2><p>Canvas가 위치추정 응답과 추정 위치를 표시합니다.</p></div>
</div>
<span class="result-chip" id="result-badge">아직 실행하지 않음</span>
</div>
@@ -198,30 +232,30 @@
<div class="overlay-toolbar">
<div class="band-legend" id="band-legend" aria-label="표시 주파수 대역"></div>
<label class="range-field"><span>Overlay</span><input id="overlay-opacity" type="range" min="0" max="1" step="0.05" value="0.72" /></label>
<label class="check-field" title="점선은 1× RMS, 실선 영역은 2× RMS 빔 분포입니다. 확률 또는 신뢰구간은 아닙니다."><input id="show-beam-spread" type="checkbox" checked /><span>빔 분포</span></label>
<label class="check-field" title="점선은 1× RMS, 실선 영역은 2× RMS 빔 응답 분포입니다. 확률 또는 신뢰구간은 아닙니다."><input id="show-beam-spread" type="checkbox" checked /><span>빔 분포</span></label>
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
</div>
<section class="result-section" aria-live="polite">
<div class="compact-section-heading"><h3>측정 요약</h3><span>peak · 오차 · beam</span></div>
<div class="empty-result" id="empty-result">실행 후 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
<div class="empty-result" id="empty-result">실행 후 위치 오차, truth concentration, sidelobe와 timing을 표시합니다.</div>
<div class="metric-grid is-hidden" id="metric-grid"></div>
</section>
<details class="history-section utility-details" id="comparison-history">
<summary><span>배열 비교 기록</span><small>같은 설정의 재실행 결과</small></summary>
<summary><span>비교 기록</span><small>배열 · 알고리즘 재실행 결과</small></summary>
<div class="history-body">
<div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
<p class="section-copy">배열을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 비교합니다.</p>
<p class="section-copy">같은 scene·scan·STFT에서 배열이나 알고리즘을 바꾼 결과를 비교합니다.</p>
<div class="history-list" id="history-list"></div>
</div>
</details>
<details class="help-panel utility-details" id="camera-help">
<summary><span>표시 해석과 한계</span><small>DAS · beam spread · live</small></summary>
<summary><span>표시 해석과 한계</span><small>localization · beam spread · live</small></summary>
<div class="help-grid">
<p><strong>Heatmap</strong>DAS 공간 power이며 위치 확률분포가 아닙니다.</p>
<p><strong>빔 분포</strong>Half-power covariance 폭이며 신뢰구간이 아닙니다.</p>
<p><strong>Heatmap</strong>DAS는 power, PHAT 계열은 무차원 score이며 위치 확률분포가 아닙니다.</p>
<p><strong>빔 분포</strong>Half-power 또는 half-maximum 응답 폭이며 신뢰구간이 아닙니다.</p>
<p><strong>이동 재생</strong>준정적 frame 시뮬레이션이며 실제 장치 스트림이 아닙니다.</p>
</div>
</details>
+150 -34
View File
@@ -1,6 +1,12 @@
import { evaluateDasBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
import { evaluateLocalizationBand, exportBandEvaluation } from "/packages/acoustics-core/evaluation.js";
import { DEFAULT_MAX_COMPENSATION_DB } from "/packages/acoustics-core/frequency-response.js";
import {
localizationAlgorithmDefinition,
localizationAlgorithmLabel,
localizationAlgorithmSupportsBackend,
} from "/packages/acoustics-core/localization-algorithms.js";
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import {
validateArrayDefinition,
validateExperiment,
@@ -21,7 +27,7 @@ import {
} from "./timeline.js";
import {
alignExperimentToScene,
canonicalStringify,
createComparisonSignature,
createHistorySummary,
createResultDocument,
estimateExperimentWorkload,
@@ -44,6 +50,12 @@ const MOVING_EXAMPLE_URLS = {
const ARRAY_PRESET_URLS = new Map([
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"],
["rectangular-2x2-450mm", "/assets/array-geometries/rectangular-2x2-450mm.array.json"],
["rectangular-4x3-450mm", "/assets/array-geometries/rectangular-4x3-450mm.array.json"],
["rectangular-8x6-450mm", "/assets/array-geometries/rectangular-8x6-450mm.array.json"],
["rectangular-24x16-450mm", "/assets/array-geometries/rectangular-24x16-450mm.array.json"],
["rectangular-16x12-compact", "/assets/array-geometries/rectangular-16x12-compact.array.json"],
["rectangular-16x12-large", "/assets/array-geometries/rectangular-16x12-large.array.json"],
]);
const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"];
const MAX_HISTORY_ITEMS = 24;
@@ -99,6 +111,7 @@ const elements = {
acousticProfile: byId("acoustic-profile"),
acousticEnvironmentSummary: byId("acoustic-environment-summary"),
frequencyResponseMode: byId("frequency-response-mode"),
algorithm: byId("algorithm"),
backend: byId("backend"),
gridPreset: byId("grid-preset"),
scanColumns: byId("scan-columns"),
@@ -113,7 +126,6 @@ const elements = {
normalization: byId("normalization"),
dynamicRange: byId("dynamic-range"),
detectionThreshold: byId("detection-threshold"),
postprocess: byId("postprocess"),
addBandButton: byId("add-band-button"),
bandList: byId("band-list"),
exportSceneButton: byId("export-scene-button"),
@@ -125,6 +137,19 @@ const elements = {
validationMessage: byId("validation-message"),
};
const layoutSplitPane = createSplitPane({
container: byId("camera-layout"),
separator: byId("camera-split-handle"),
panel: byId("camera-controls"),
panelSide: "start",
storageKey: "micarrsim.camera.control-panel-width",
defaultSize: 420,
minSize: 320,
maxSize: 720,
minMainSize: 560,
enabledMediaQuery: "(min-width: 981px)",
});
const state = {
arrayDefinition: null,
scene: null,
@@ -173,10 +198,7 @@ function formatIssue(issue) {
}
function comparisonSignature(scene = state.scene, sourceExperiment = state.experiment) {
if (!scene || !sourceExperiment) return "";
const experiment = structuredClone(sourceExperiment);
experiment.arrayId = "$array";
return canonicalStringify({ scene, experiment });
return createComparisonSignature(scene, sourceExperiment);
}
function applyExperimentDefaults(experiment) {
@@ -288,6 +310,9 @@ function renderLiveControls() {
if (preparing) liveState = "preparing";
document.documentElement.dataset.liveState = liveState;
document.documentElement.dataset.liveFrameCount = ready ? String(state.live.frames.length) : "0";
document.documentElement.dataset.livePlanAnalyses = ready
? String(state.live.frames.at(-1)?.run.analysisCache?.completedAnalyses ?? 0)
: "0";
document.documentElement.dataset.liveTime = ready ? String(state.live.time) : "0";
}
@@ -359,6 +384,21 @@ function markChanged(options = {}) {
scheduleValidation();
}
function syncAlgorithmBackendControls() {
const algorithm = state.experiment?.beamforming?.algorithm;
const definition = localizationAlgorithmDefinition(algorithm);
const webGpuOption = elements.backend.querySelector('option[value="webgpu"]');
const autoOption = elements.backend.querySelector('option[value="auto"]');
const supportsWebGpu = definition?.backends.includes("webgpu") ?? true;
webGpuOption.disabled = !supportsWebGpu;
webGpuOption.title = supportsWebGpu ? "" : `${definition?.label ?? algorithm}는 현재 CPU에서만 실행됩니다.`;
autoOption.textContent = supportsWebGpu ? "Auto (GPU→CPU)" : "Auto (CPU)";
elements.backend.title = supportsWebGpu
? "지원되는 GPU를 우선 사용하고 필요하면 CPU로 전환합니다."
: `${definition?.label ?? algorithm}는 Auto와 CPU만 지원합니다.`;
document.documentElement.dataset.localizationAlgorithm = algorithm ?? "";
}
function syncControls() {
const { scene, experiment } = state;
if (!scene || !experiment) return;
@@ -370,7 +410,9 @@ function syncControls() {
elements.acousticProfile.value = detectAcousticProfile(scene);
elements.frequencyResponseMode.value =
experiment.beamforming.compensateFrequencyResponse === false ? "raw" : "compensate";
elements.algorithm.value = experiment.beamforming.algorithm;
elements.backend.value = experiment.beamforming.backend;
syncAlgorithmBackendControls();
elements.scanColumns.value = experiment.scan.columns;
elements.scanRows.value = experiment.scan.rows;
elements.scanXMin.value = experiment.scan.xMin;
@@ -444,11 +486,13 @@ function renderDocumentSummary() {
line.className = "source-line";
const name = document.createElement("strong");
name.textContent = source.name;
name.title = source.name;
const detail = document.createElement("span");
detail.textContent =
`${describeSignal(source.signal)} · ` +
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` +
(source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : "");
detail.title = detail.textContent;
line.append(name, detail);
elements.sourceSummary.append(line);
}
@@ -471,6 +515,7 @@ function renderAssets() {
row.className = `asset-status ${status.ready ? "is-ready" : status.error ? "is-error" : ""}`;
const label = document.createElement("strong");
label.textContent = status.descriptor.fileName;
label.title = status.descriptor.fileName;
const value = document.createElement("span");
value.textContent = status.ready ? "연결됨" : status.error ? "오류" : "파일 필요";
value.title = status.error ?? "";
@@ -498,6 +543,8 @@ function renderBandEditors() {
input.dataset.bandIndex = String(index);
input.dataset.bandKey = key;
input.setAttribute("aria-label", `${band.id} ${label}`);
input.title = label;
if (type !== "color") input.placeholder = label;
if (type === "number") {
input.min = "0";
input.step = "1";
@@ -544,15 +591,25 @@ function renderWorkload() {
const maximumPaths = maximumSparsePathsPerReceiver(state.scene);
const signalPathBudget =
workload.microphoneCount * state.scene.sources.length * maximumPaths;
const scanCost = workload.algorithm === "gcc-phat"
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
elements.workloadSummary.textContent =
`${workload.pointCount.toLocaleString("ko-KR")} points × ${workload.microphoneCount} ch · ` +
`${workload.frameCount} frames · ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.complexMacCount)} complex MAC · ` +
`signal ≤ ${formatCount(signalPathBudget)} paths · steering ${formatNumber(workload.distanceMiB, 1)} MiB`;
const heavyCpu = state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8;
`${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` +
`${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` +
`${scanCost} · signal ≤ ${formatCount(signalPathBudget)} paths · ` +
`distance cache ${formatNumber(workload.distanceMiB, 1)} MiB`;
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
const heavyCpu =
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
workload.scanOperationCount > 5e8;
const narrowGcc = workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8;
elements.workloadSummary.classList.toggle(
"is-warning",
heavyCpu || workload.pointCount >= 40000 || signalPathBudget > 100000,
heavyCpu || narrowGcc || workload.pointCount >= 40000 || signalPathBudget > 100000,
);
} catch (error) {
elements.workloadSummary.textContent = `연산량을 계산할 수 없습니다 · ${error.message}`;
@@ -624,8 +681,11 @@ function renderSceneVisualization() {
showTruth: elements.showTruth.checked,
});
elements.canvasMessage.classList.add("is-hidden");
const algorithmLabel = localizationAlgorithmLabel(
live.frames[0]?.run.algorithm ?? live.snapshot.experiment.beamforming.algorithm,
);
elements.canvasBadge.textContent =
`LIVE · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
`LIVE · ${algorithmLabel} · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}`;
return;
}
@@ -646,9 +706,12 @@ function renderSceneVisualization() {
if (!displayed) {
elements.canvasBadge.textContent = "결과 없음";
} else if (displayed.stale) {
elements.canvasBadge.textContent = `이전 설정 결과 · ${displayed.run.backend.toUpperCase()}`;
elements.canvasBadge.textContent =
`이전 설정 결과 · ${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
displayed.run.backend.toUpperCase();
} else {
elements.canvasBadge.textContent =
`${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
`${displayed.run.backend.toUpperCase()} · ${displayed.snapshot.experiment.scan.columns}×` +
`${displayed.snapshot.experiment.scan.rows} · ${formatNumber(displayed.run.timingMs.total, 0)} ms`;
}
@@ -678,15 +741,19 @@ function renderResult() {
elements.resultBadge.textContent = "아직 실행하지 않음";
return;
}
const algorithm = displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm;
const algorithmLabel = localizationAlgorithmLabel(algorithm);
elements.resultBadge.textContent = displayed.stale
? `이전 설정 · ${displayed.run.backend.toUpperCase()}`
: `${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`;
? `이전 설정 · ${algorithmLabel} · ${displayed.run.backend.toUpperCase()}`
: `${algorithmLabel} · ${displayed.run.backend.toUpperCase()} · ${displayed.inputHash.slice(0, 10)}`;
appendMetric(
"Backend",
displayed.run.backend.toUpperCase(),
displayed.run.fallback ? "WebGPU → CPU fallback" : "요청한 계산 경로",
"위치 추정",
algorithmLabel,
displayed.run.fallback
? `실제 ${displayed.run.backend.toUpperCase()} · WebGPU fallback`
: `실제 ${displayed.run.backend.toUpperCase()} backend`,
);
appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + DAS");
appendMetric("전체 시간", `${formatNumber(displayed.run.timingMs.total, 0)} ms`, "signal generation + localization");
appendMetric(
"신호 합성",
`${formatNumber(displayed.run.timingMs.signalGeneration, 0)} ms`,
@@ -702,8 +769,8 @@ function renderResult() {
);
}
appendMetric(
"Beamforming",
`${formatNumber(displayed.run.timingMs.beamforming, 0)} ms`,
"위치 추정 시간",
`${formatNumber(displayed.run.timingMs.localization ?? displayed.run.timingMs.beamforming, 0)} ms`,
`${displayed.snapshot.experiment.scan.columns}×${displayed.snapshot.experiment.scan.rows} grid`,
);
const detectionThreshold =
@@ -712,7 +779,7 @@ function renderResult() {
appendMetric(
"Peak 검출 임계값",
`${formatNumber(detectionThreshold * 100, 0)}%`,
"최강 peak power 기준 · 낮을수록 민감",
"최강 map 값 기준 · 낮을수록 민감",
);
for (const band of displayed.snapshot.experiment.frequencyBands) {
const evaluation = displayed.evaluations.get(band.id);
@@ -728,7 +795,7 @@ function renderResult() {
band.color,
);
appendMetric(
`${band.id} · Truth energy`,
`${band.id} · Truth concentration`,
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
"ground-truth 반경 내",
band.color,
@@ -748,7 +815,9 @@ function renderResult() {
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
"half-power equivalent diameter",
evaluation.diagnostics.mapScale === "score"
? "half-maximum score diameter"
: "half-power equivalent diameter",
band.color,
);
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
@@ -780,10 +849,10 @@ function renderHistory() {
const card = document.createElement("article");
card.className = "history-card";
const name = document.createElement("strong");
name.textContent = item.arrayName;
name.textContent = `${localizationAlgorithmLabel(item.algorithm)} · ${item.arrayName}`;
const fields = [
`${item.backend.toUpperCase()} · ${item.pointCount.toLocaleString("ko-KR")} pt`,
`${formatNumber(item.totalMs, 0)} ms`,
`위치 ${formatNumber(item.localizationMs, 0)} / 전체 ${formatNumber(item.totalMs, 0)} ms`,
item.localizationErrorM === null ? "오차 —" : `오차 ${formatNumber(item.localizationErrorM * 100, 1)} cm`,
item.peakToSidelobeDb === null ? "PSR —" : `PSR ${formatNumber(item.peakToSidelobeDb, 1)} dB`,
];
@@ -816,8 +885,19 @@ function validationWarnings() {
}
try {
const workload = estimateExperimentWorkload(state.arrayDefinition, state.scene, state.experiment);
if (state.experiment.beamforming.backend === "cpu" && workload.complexMacCount > 5e8) {
warnings.push("CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다.");
if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8) {
warnings.push("GCC-PHAT는 안정적인 광대역 상관을 위해 선택 FFT bin을 8개 이상 권장합니다.");
}
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
if (
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
workload.scanOperationCount > 5e8
) {
warnings.push(
cpuOnly
? "CPU 계산량이 큽니다. 더 작은 grid 또는 더 적은 band를 권장합니다."
: "CPU 계산량이 큽니다. Auto/WebGPU 또는 더 작은 grid를 권장합니다.",
);
} else if (workload.pointCount >= 40000) {
warnings.push("큰 grid입니다. 실행 중에는 취소 버튼으로 즉시 중단할 수 있습니다.");
}
@@ -940,7 +1020,7 @@ function progressMessage(message) {
if (message.message) return message.message;
const progress = message.progress;
if (progress?.totalBins) {
return `DAS 계산 중 · ${progress.completedBins}/${progress.totalBins} bins`;
return `위치 추정 중 · ${progress.completedBins}/${progress.totalBins} bins`;
}
if (progress?.totalChunks) {
return `WebGPU 계산 중 · ${progress.completedChunks}/${progress.totalChunks} chunks`;
@@ -1072,8 +1152,12 @@ async function runSimulation() {
const definition = snapshot.experiment.frequencyBands.find(
(candidate) => candidate.id === band.id,
);
const evaluation = evaluateDasBand({
const evaluation = evaluateLocalizationBand({
rawMap: band.rawMap,
mapScale:
band.rawScale ??
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
"power",
scan: snapshot.experiment.scan,
sources: sourcesForBand(snapshot.scene, definition),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
@@ -1126,8 +1210,11 @@ async function runSimulation() {
syncUnsavedState();
document.documentElement.dataset.runState = "done";
document.documentElement.dataset.runBackend = run.backend;
document.documentElement.dataset.runAlgorithm =
run.algorithm ?? snapshot.experiment.beamforming.algorithm;
document.documentElement.dataset.resultBandCount = String(run.bands.length);
setRunStatus(
`${localizationAlgorithmLabel(run.algorithm ?? snapshot.experiment.beamforming.algorithm)} · ` +
`${run.backend.toUpperCase()} 완료 · ${formatNumber(run.timingMs.total, 0)} ms · ` +
`hash ${inputHash.slice(0, 10)}`,
);
@@ -1198,8 +1285,14 @@ function evaluateLiveFrames(snapshot, plan, frames) {
if (!definition) continue;
evaluations.set(
band.id,
evaluateDasBand({
evaluateLocalizationBand({
rawMap: band.rawMap,
mapScale:
band.rawScale ??
localizationAlgorithmDefinition(
frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm,
)?.rawScale ??
"power",
scan: snapshot.experiment.scan,
sources: sourcesForBand(frameScene, definition, frame.sampleTime),
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
@@ -1545,7 +1638,29 @@ function installDocumentInputListeners() {
elements.frequencyResponseMode.value === "compensate";
markChanged();
});
elements.algorithm.addEventListener("change", () => {
const algorithm = elements.algorithm.value;
state.experiment.beamforming.algorithm = algorithm;
const backendReset =
state.experiment.beamforming.backend === "webgpu" &&
!localizationAlgorithmSupportsBackend(algorithm, "webgpu");
if (backendReset) state.experiment.beamforming.backend = "auto";
markChanged({ syncControls: true });
if (backendReset) {
setRunStatus(`${localizationAlgorithmLabel(algorithm)}는 CPU 전용이므로 Backend를 Auto (CPU)로 바꿨습니다.`);
}
});
elements.backend.addEventListener("change", () => {
if (
elements.backend.value === "webgpu" &&
!localizationAlgorithmSupportsBackend(state.experiment.beamforming.algorithm, "webgpu")
) {
elements.backend.value = state.experiment.beamforming.backend;
setRunStatus(
`${localizationAlgorithmLabel(state.experiment.beamforming.algorithm)}는 Auto 또는 CPU를 사용하세요.`,
);
return;
}
state.experiment.beamforming.backend = elements.backend.value;
markChanged();
});
@@ -1764,7 +1879,7 @@ function installActionListeners() {
);
for (const item of state.history) item.comparisonSaved = true;
syncUnsavedState();
setRunStatus("전체 배열 비교 기록 JSON을 저장했습니다.");
setRunStatus("전체 비교 기록 JSON을 저장했습니다.");
});
elements.clearHistoryButton.addEventListener("click", () => {
const signature = comparisonSignature();
@@ -1783,6 +1898,7 @@ function installActionListeners() {
sceneVisualization.destroy();
arrayVisualization.destroy();
assetRuntime.dispose();
layoutSplitPane.destroy();
});
}
+54 -4
View File
@@ -1,4 +1,8 @@
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
import {
DEFAULT_LOCALIZATION_ALGORITHM,
localizationAlgorithmDefinition,
} from "/packages/acoustics-core/localization-algorithms.js";
const RESULT_METRIC_KEYS = [
"peakToSidelobeDb",
@@ -25,6 +29,17 @@ export function canonicalStringify(value) {
return JSON.stringify(sortForCanonicalJson(value));
}
export function createComparisonSignature(scene, sourceExperiment) {
if (!scene || !sourceExperiment) return "";
const experiment = structuredClone(sourceExperiment);
experiment.arrayId = "$array";
if (experiment.beamforming) {
experiment.beamforming.algorithm = "$algorithm";
experiment.beamforming.backend = "$backend";
}
return canonicalStringify({ scene, experiment });
}
export async function hashSimulationInputs({ arrayDefinition, scene, experiment }) {
const canonical = canonicalStringify({ array: arrayDefinition, experiment, scene });
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
@@ -42,17 +57,38 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
const bandBins = experiment.frequencyBands.map((band) =>
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
);
const bandBinSelectionCount = bandBins.reduce((total, bins) => total + bins.length, 0);
const selectedBinCount = new Set(bandBins.flat()).size;
const pointCount = experiment.scan.columns * experiment.scan.rows;
const microphoneCount = arrayDefinition.microphones.length;
const complexMacCount = pointCount * microphoneCount * frameCount * selectedBinCount;
const algorithm = experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
const algorithmDefinition = localizationAlgorithmDefinition(algorithm);
const referencePairCount = Math.max(0, microphoneCount - 1);
const stftTransformCount = frameCount * microphoneCount;
const dasComplexMacCount = pointCount * microphoneCount * frameCount * selectedBinCount;
const gccCrossSpectrumComplexOps =
2 * referencePairCount * frameCount * bandBinSelectionCount;
const gccScanScoreOps = pointCount * referencePairCount * bandBins.length;
const inverseFftTransformCount = referencePairCount * bandBins.length;
const scanOperationCount = algorithm === "gcc-phat" ? gccScanScoreOps : dasComplexMacCount;
return {
algorithm,
algorithmLabel: algorithmDefinition?.label ?? algorithm,
rawScale: algorithmDefinition?.rawScale ?? "power",
pointCount,
microphoneCount,
frameCount,
selectedBinCount,
bandBinSelectionCount,
minimumBandBinCount: Math.min(...bandBins.map((bins) => bins.length)),
bandBins,
complexMacCount,
stftTransformCount,
referencePairCount,
scanOperationCount,
scanOperationKind: algorithm === "gcc-phat" ? "reference-pair lookup" : "complex MAC",
gccCrossSpectrumComplexOps: algorithm === "gcc-phat" ? gccCrossSpectrumComplexOps : 0,
inverseFftTransformCount: algorithm === "gcc-phat" ? inverseFftTransformCount : 0,
complexMacCount: algorithm === "gcc-phat" ? 0 : dasComplexMacCount,
scalingVs50: pointCount / 2500,
distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2,
};
@@ -207,6 +243,14 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture;
if (adapter?.device) environment.gpuDevice = adapter.device;
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
const referenceMic =
run.referenceMic ??
run.referenceMicrophoneId ??
run.workload?.referenceMic ??
run.workload?.referenceMicrophoneId;
if (typeof referenceMic === "string" || Number.isFinite(referenceMic)) {
environment.referenceMic = referenceMic;
}
return environment;
}
@@ -220,6 +264,8 @@ export function createResultDocument(options) {
inputHash,
createdAt = new Date().toISOString(),
} = options;
const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power";
return {
schemaVersion: "1.0.0",
kind: "micarrsim.result",
@@ -227,6 +273,7 @@ export function createResultDocument(options) {
arrayId: arrayDefinition.id,
sceneId: scene.id,
createdAt,
algorithm,
backend: run.backend,
inputHash,
signalStats: {
@@ -246,7 +293,8 @@ export function createResultDocument(options) {
timingMs: {
total: run.timingMs.total,
signalGeneration: run.timingMs.signalGeneration,
beamforming: run.timingMs.beamforming,
localization: run.timingMs.localization ?? run.timingMs.beamforming,
beamforming: run.timingMs.beamforming ?? run.timingMs.localization,
},
environment: environmentForResult({ arrayDefinition, experiment, run }),
bands: run.bands.map((band) => {
@@ -257,7 +305,7 @@ export function createResultDocument(options) {
rawMap: {
rows: experiment.scan.rows,
columns: experiment.scan.columns,
scale: "power",
scale: band.rawScale ?? rawScale,
values: Array.from(band.rawMap),
},
displayMap: {
@@ -286,9 +334,11 @@ export function createHistorySummary({ arrayDefinition, scene, experiment, run,
arrayName: arrayDefinition.name,
sceneId: scene.id,
experimentId: experiment.id,
algorithm: run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM,
backend: run.backend,
pointCount: experiment.scan.rows * experiment.scan.columns,
totalMs: run.timingMs.total,
localizationMs: run.timingMs.localization ?? run.timingMs.beamforming,
localizationErrorM: firstMatchedPeak?.localizationErrorM ?? null,
peakToSidelobeDb: evaluation?.metrics?.peakToSidelobeDb ?? null,
inputHash,
+60 -18
View File
@@ -1,6 +1,7 @@
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { sampleQuasiStaticWindow } from "/packages/acoustics-core/trajectory.js";
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
import { createLocalizationAnalysisPlan } from "/packages/acoustics-core/localization.js";
import { computeLocalizationWithBackend } from "/packages/webgpu-kernels/compute.js";
function post(type, jobId, payload = {}, transfer = []) {
self.postMessage({ type, jobId, ...payload }, transfer);
@@ -46,7 +47,20 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
const frames = [];
const transfer = [];
let signalGeneration = 0;
let beamforming = 0;
let localizationTime = 0;
const localizationScene = { speedOfSound: scene.speedOfSound };
const algorithm = experiment.beamforming.algorithm ?? "das";
const requestedBackend = experiment.beamforming.backend ?? "auto";
const reuseCpuPlan = algorithm === "gcc-phat" || requestedBackend === "cpu";
let localizationExperiment = experiment;
let analysisPlan = reuseCpuPlan
? createLocalizationAnalysisPlan({
sampleRate: scene.sampleRate,
arrayDefinition,
scene: localizationScene,
experiment: localizationExperiment,
})
: undefined;
post("progress", jobId, {
stage: "timeline-start",
@@ -82,13 +96,14 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
const frameSignalGeneration = performance.now() - signalStartedAt;
signalGeneration += frameSignalGeneration;
const beamformingStartedAt = performance.now();
const das = await computeDasWithBackend({
const localizationStartedAt = performance.now();
const localization = await computeLocalizationWithBackend({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene: synthesisWindow.scene,
experiment,
scene: localizationScene,
experiment: localizationExperiment,
...(analysisPlan ? { analysisPlan } : {}),
onFallback: ({ error }) =>
post("fallback", jobId, {
code: error.code,
@@ -105,9 +120,32 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
progress,
}),
});
const frameBeamforming = performance.now() - beamformingStartedAt;
beamforming += frameBeamforming;
collectBandTransfers(das, transfer);
const frameLocalization = performance.now() - localizationStartedAt;
localizationTime += frameLocalization;
collectBandTransfers(localization, transfer);
if (
!analysisPlan &&
localization.backend === "cpu" &&
localization.fallback?.from === "webgpu"
) {
// Auto has established that WebGPU is unavailable for this worker. Keep
// the first frame's honest fallback metadata, then avoid retrying the GPU
// and rebuilding CPU geometry/workspaces on every remaining live frame.
localizationExperiment = {
...experiment,
beamforming: {
...experiment.beamforming,
backend: "cpu",
},
};
analysisPlan = createLocalizationAnalysisPlan({
sampleRate: scene.sampleRate,
arrayDefinition,
scene: localizationScene,
experiment: localizationExperiment,
});
}
frames.push({
index: plannedFrame.index ?? frameIndex,
@@ -116,12 +154,13 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
sampleTime: synthesisWindow.sampleTime,
duration: synthesisWindow.duration,
run: {
...das,
...localization,
signalStats: signalStats(synthesis),
timingMs: {
total: performance.now() - frameStartedAt,
signalGeneration: frameSignalGeneration,
beamforming: frameBeamforming,
localization: frameLocalization,
beamforming: frameLocalization,
},
},
});
@@ -145,6 +184,7 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
{
timeline: {
backend: backends.size === 1 ? frames[0].run.backend : "mixed",
algorithm: experiment.beamforming.algorithm ?? "das",
requestedBackend: experiment.beamforming.backend ?? "auto",
duration: scene.duration,
frameCount: frames.length,
@@ -153,7 +193,8 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
timingMs: {
total: performance.now() - totalStartedAt,
signalGeneration,
beamforming,
localization: localizationTime,
beamforming: localizationTime,
},
},
},
@@ -184,8 +225,8 @@ self.addEventListener("message", async (event) => {
message: `마이크 신호 합성 완료 · ${signalGeneration.toFixed(0)} ms`,
});
const beamformingStartedAt = performance.now();
const das = await computeDasWithBackend({
const localizationStartedAt = performance.now();
const localization = await computeLocalizationWithBackend({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
@@ -198,20 +239,21 @@ self.addEventListener("message", async (event) => {
}),
onProgress: (progress) => post("progress", jobId, { stage: progress.stage, progress }),
});
const beamforming = performance.now() - beamformingStartedAt;
const localizationTime = performance.now() - localizationStartedAt;
const transfer = [];
collectBandTransfers(das, transfer);
collectBandTransfers(localization, transfer);
post(
"result",
jobId,
{
run: {
...das,
...localization,
signalStats: signalStats(synthesis),
timingMs: {
total: performance.now() - totalStartedAt,
signalGeneration,
beamforming,
localization: localizationTime,
beamforming: localizationTime,
},
},
},
+85 -12
View File
@@ -100,8 +100,10 @@
.workflow-strip small { overflow: hidden; color: var(--muted); font-size: .58rem; text-overflow: ellipsis; white-space: nowrap; }
.camera-layout {
--control-panel-width: 420px;
display: grid;
grid-template-columns: minmax(320px, 360px) minmax(620px, 1fr);
grid-template-columns: var(--control-panel-width) 12px minmax(0, 1fr);
width: min(1880px, 100%);
min-height: calc(100vh - 103px);
margin: 0 auto;
@@ -109,6 +111,7 @@
.control-panel {
position: sticky;
top: 103px;
min-width: 0;
align-self: start;
overflow-y: auto;
max-height: calc(100vh - 103px);
@@ -116,8 +119,16 @@
border-right: 1px solid var(--line);
background: rgb(12 24 21 / 88%);
backdrop-filter: blur(18px);
container-name: camera-controls;
container-type: inline-size;
scrollbar-width: thin;
}
.camera-layout > .split-handle {
position: sticky;
top: 103px;
height: calc(100vh - 103px);
align-self: start;
}
.control-panel .workflow-card {
margin: 0;
padding: 14px;
@@ -160,7 +171,8 @@
}
.file-choice input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
.file-sequence { color: var(--mint); font: 700 .58rem ui-monospace, SFMono-Regular, Consolas, monospace; }
.file-choice strong, .file-choice small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-choice > span:last-of-type { min-width: 0; }
.file-choice strong, .file-choice small { display: block; min-width: 0; overflow-wrap: anywhere; }
.file-choice strong { font-size: .68rem; }
.file-choice small { margin-top: 2px; color: var(--muted); font-size: .56rem; }
@@ -176,10 +188,13 @@
.compact-label, .mode-kicker { color: var(--mint); font: 750 .56rem ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .1em; }
.builder-link { color: var(--cyan); font-size: .6rem; text-decoration: none; }
.source-summary, .asset-list { display: grid; gap: 6px; margin-top: 8px; }
.source-line { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: .63rem; }
.source-line strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
.source-line { display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); gap: 8px; color: var(--muted); font-size: .63rem; }
.source-line strong, .source-line span { min-width: 0; overflow-wrap: anywhere; }
.source-line strong { color: var(--text); }
.source-line span { text-align: right; }
.asset-status {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
justify-content: space-between;
gap: 8px;
@@ -189,7 +204,7 @@
color: var(--muted);
font-size: .61rem;
}
.asset-status strong { overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
.asset-status strong { min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.asset-status.is-ready span { color: var(--mint); }
.asset-status.is-error span { color: var(--danger); }
.asset-empty { color: var(--muted); font-size: .62rem; }
@@ -199,6 +214,7 @@
.field > span { color: var(--muted); font-size: .61rem; font-weight: 650; }
.field input, .field select, .band-editor input, .compact-select select {
width: 100%;
min-width: 0;
min-height: 34px;
padding: 7px 8px;
border: 1px solid var(--line);
@@ -244,7 +260,7 @@
.band-editor-list { display: grid; gap: 7px; margin-top: 8px; }
.band-editor {
display: grid;
grid-template-columns: minmax(66px, 1fr) minmax(55px, .72fr) minmax(55px, .72fr) 30px;
grid-template-columns: minmax(72px, 1fr) repeat(2, minmax(58px, .72fr)) 38px 30px;
gap: 5px;
padding: 7px;
border: 1px solid var(--line);
@@ -301,9 +317,10 @@ details[open] > summary::after { content: "−"; }
border-radius: 12px;
background: #0b1714;
}
.validation-card > :last-child { min-width: 0; }
.validation-dot { width: 9px; height: 9px; flex: 0 0 auto; margin-top: 3px; border-radius: 50%; background: var(--muted); box-shadow: 0 0 0 4px rgb(145 170 162 / 8%); }
.validation-card strong { font-size: .7rem; }
.validation-card p { margin: 3px 0 0; color: var(--muted); font-size: .62rem; line-height: 1.45; }
.validation-card strong { font-size: .7rem; overflow-wrap: anywhere; }
.validation-card p { margin: 3px 0 0; color: var(--muted); font-size: .62rem; line-height: 1.45; overflow-wrap: anywhere; }
.validation-card.is-valid .validation-dot { background: var(--mint); box-shadow: 0 0 0 4px rgb(101 242 191 / 10%); }
.validation-card.is-invalid .validation-dot { background: var(--danger); box-shadow: 0 0 0 4px rgb(255 123 115 / 10%); }
.validation-card.is-warning .validation-dot { background: var(--amber); box-shadow: 0 0 0 4px rgb(255 197 110 / 10%); }
@@ -359,7 +376,7 @@ html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-s
.metric-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); gap: 8px; margin-top: 9px; }
.metric-card { min-width: 0; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: #0b1714; }
.metric-card span { display: block; margin-bottom: 5px; color: var(--muted); font-size: .6rem; }
.metric-card strong { display: block; overflow: hidden; font-size: 1rem; letter-spacing: -.03em; text-overflow: ellipsis; white-space: nowrap; }
.metric-card strong { display: block; min-width: 0; font-size: 1rem; letter-spacing: -.03em; overflow-wrap: anywhere; }
.metric-card small { display: block; margin-top: 4px; color: var(--muted); font-size: .57rem; }
.history-section, .help-panel { margin-top: 9px; border-radius: 11px; }
.history-section > summary, .help-panel > summary { display: flex; justify-content: space-between; gap: 12px; padding: 10px 34px 10px 11px; }
@@ -369,7 +386,7 @@ html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-s
.history-list { display: grid; gap: 7px; margin-top: 9px; }
.history-empty { padding: 10px; color: var(--muted); font-size: .65rem; text-align: center; }
.history-card { display: grid; grid-template-columns: minmax(140px, 1.5fr) repeat(4, minmax(72px, .72fr)); gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: #0b1714; }
.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-card strong, .history-card span { min-width: 0; overflow-wrap: anywhere; }
.history-card strong { font-size: .69rem; }
.history-card span { color: var(--muted); font-size: .6rem; }
.help-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
@@ -379,7 +396,6 @@ html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-s
.is-hidden { display: none !important; }
@media (max-width: 1240px) {
.camera-layout { grid-template-columns: minmax(300px, 330px) minmax(560px, 1fr); }
.mode-grid { grid-template-columns: 1fr; }
.mode-card--snapshot { display: grid; grid-template-columns: 1fr auto; gap: 0 12px; }
.mode-card--snapshot > p { grid-column: 1; }
@@ -388,6 +404,7 @@ html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-s
@media (max-width: 980px) {
.camera-layout { grid-template-columns: minmax(0, 1fr); }
.camera-layout > .split-handle { display: none; }
.control-panel, .scene-workspace { width: 100%; min-width: 0; }
.control-panel { position: static; overflow: visible; max-height: none; border-right: 0; border-bottom: 1px solid var(--line); }
.scene-workspace { padding-top: 14px; }
@@ -431,3 +448,59 @@ html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-s
.metric-grid { grid-template-columns: 1fr 1fr; }
.overlay-toolbar { justify-content: flex-start; }
}
@container camera-controls (max-width: 350px) {
.input-file-grid,
.quick-field-grid,
.advanced-field-grid,
.output-actions {
grid-template-columns: minmax(0, 1fr);
}
.field--wide {
grid-column: auto;
}
.source-line {
grid-template-columns: minmax(0, 1fr);
}
.source-line span {
text-align: left;
}
.advanced-settings > summary {
align-items: flex-start;
flex-direction: column;
}
.band-editor {
grid-template-columns: repeat(2, minmax(0, 1fr)) 38px 32px;
grid-template-rows: repeat(2, minmax(34px, auto));
}
.band-editor input:nth-child(1) {
grid-column: 1 / 3;
grid-row: 1;
}
.band-editor input:nth-child(2) {
grid-column: 1;
grid-row: 2;
}
.band-editor input:nth-child(3) {
grid-column: 2;
grid-row: 2;
}
.band-editor input:nth-child(4) {
grid-column: 3;
grid-row: 1 / 3;
}
.band-editor__remove {
grid-column: 4;
grid-row: 1 / 3;
}
}
+112
View File
@@ -0,0 +1,112 @@
# Array Builder
Array Builder는 `micarrsim.array` JSON을 브라우저에서 만드는 마이크 배열 편집기입니다. aperture
크기와 생성 경계를 정한 뒤 프리셋으로 시작하거나 마이크를 직접 추가·이동해 Acoustic Camera의
배열 입력으로 사용할 수 있습니다.
## 열기
저장소 루트에서 `start.cmd`를 실행하고 시작 화면의 **Array Builder**를 선택합니다. 서버가 이미
실행 중이면 <http://127.0.0.1:4173/apps/array-builder/>를 직접 열 수 있습니다. HTML module을
사용하므로 파일을 탐색기에서 직접 열지 마세요.
## 가장 짧은 사용 순서
1. **크기와 형상**에서 aperture 너비·높이와 생성 경계를 정합니다.
2. 프리셋을 고르고 **현재 배열을 프리셋으로 교체**를 누릅니다.
3. canvas와 마이크 목록에서 좌표와 채널 순서를 다듬습니다.
4. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다.
5. Acoustic Camera를 열어 **Array** 입력에서 저장한 `.array.json`을 선택합니다.
## 크기와 생성 경계
- **너비·높이:** Array JSON의 `aperture.width`와 `aperture.height`이며 단위는 m입니다.
- **사각형:** 너비와 높이의 직사각형 안에 preset을 배치합니다.
- **타원:** 같은 너비와 높이를 지름으로 하는 타원 안에 preset을 배치합니다.
- **좌표 중앙 정렬:** 현재 마이크의 무게중심을 원점으로 이동합니다.
- **좌표에 크기 맞춤:** 현재 좌표가 들어가도록 aperture를 조절합니다.
- **경계 밖 마이크를 안으로 이동:** 선택한 생성 경계 안으로 좌표를 제한합니다.
aperture 크기를 입력하는 것만으로 기존 좌표가 자동 확대·축소되지는 않습니다. 크기를 줄인 뒤
경계 밖 좌표가 생겼다면 검증 메시지를 보고 크기를 다시 맞추거나 clamp 작업을 사용하세요.
## 프리셋
| 방식 | 설정 | 배치 |
| --- | --- | --- |
| 격자 | 행·열, 경계 여백 | 아래 행부터 위로, 각 행은 왼쪽에서 오른쪽으로 생성 |
| 선형 | 마이크 수, x/y 축 | 선택한 중심축을 따라 일정 간격으로 생성 |
| 원형 | 마이크 수 | 사각 경계는 사각 둘레, 타원 경계는 타원 둘레에 균일 배치 |
| 나선 | 마이크 수 | golden angle을 사용하는 결정론적 Fermat 나선 |
| 균일 랜덤 | 마이크 수, seed, 최소 간격 | 경계 내부에 seed로 재현되는 좌표 생성 |
**경계 여백**은 preset 좌표를 aperture 가장자리에서 안쪽으로 띄웁니다. 랜덤 배열은 요청한 최소
간격을 만족하는 배치를 제한된 횟수 안에 찾지 못하면 현재 배열을 바꾸지 않고 오류를 표시합니다.
같은 크기·형상·수·seed·간격을 사용하면 같은 랜덤 좌표가 생성됩니다.
프리셋 생성은 현재 마이크 목록 전체를 교체합니다. 필요한 기존 좌표나 채널 보정이 있다면 먼저
Array JSON을 저장하세요.
## 직접 편집
- **선택 도구 (`V`):** 마이크 점을 선택하고 드래그해 x/y를 바꿉니다.
- **추가 도구 (`A`):** 빈 canvas를 클릭해 새 마이크를 만듭니다.
- **좌표 필드:** 선택한 마이크의 ID와 x/y/z를 숫자로 정확히 입력합니다.
- **삭제:** 목록이나 canvas에서 선택한 뒤 **선택 마이크 삭제** 또는 `Delete`를 사용합니다.
- **순서:** **채널 앞으로/뒤로**로 `microphones` 배열의 채널 순서를 바꿉니다.
- **미세 이동:** 방향키로 이동하고 `Shift`를 함께 누르면 10배 거리로 이동합니다.
- **스냅:** 격자 스냅 간격은 화면에서 mm 단위로 입력합니다.
- **실행 취소:** `Ctrl+Z`, 다시 실행은 `Ctrl+Shift+Z`입니다.
확대·축소와 화면 맞춤은 보기만 바꾸며 저장 좌표에는 영향을 주지 않습니다.
터치 화면에서는 마이크 점에서 시작한 제스처만 위치 드래그로 처리하고, 빈 canvas의 세로
스와이프는 페이지 스크롤로 남겨 둡니다. 추가 도구도 짧은 탭을 놓을 때만 마이크를 만들므로
스크롤하려는 동작으로 채널이 생기지 않습니다.
`JSON 열기`로 다른 배열을 불러온 직후에도 한 번 실행 취소하면 불러오기 전 문서를 복구할 수
있습니다. 다시 실행하면 불러온 배열로 돌아갑니다.
## 보정값 보존
선택한 채널의 `sensitivityDb`와 `phaseOffsetDeg`는 보정값 펼침 영역에서 편집할 수 있습니다. 기존
Array JSON을 불러왔을 때 마이크에 `frequencyResponse`가 있으면 응답점 수를 표시하며, 위치·ID,
채널 순서나 배열 메타데이터를 편집해도 그 curve를 그대로 보존합니다.
프리셋으로 마이크 목록을 교체하면 새 채널이 만들어지므로 이전 채널의 sensitivity, phase와
frequency response도 함께 제거됩니다.
## Array JSON에 저장되는 것
저장 파일은 계약 버전 `1.0.0`의 `micarrsim.array` 문서입니다.
- Array ID, 이름과 선택적 설명
- m 단위 aperture 너비·높이
- 오른손 좌표계와 순서가 있는 마이크 ID·`[x, y, z]`
- 채널별 선택적 sensitivity, phase와 frequency-response curve
생성 경계의 사각형/타원 선택, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백, snap과
zoom은 편집기 설정입니다. 표준 Array JSON에는 저장되지 않으며 Camera는 최종 aperture와 실제
마이크 좌표·순서를 사용합니다.
## Camera로 전달하기
1. Array Builder에서 **Array JSON 저장**을 누릅니다.
2. 헤더의 **Camera 열기** 또는 홈의 Acoustic Camera를 엽니다.
3. 입력 준비의 **Array**에서 저장한 파일을 선택합니다.
4. 채널 수와 aperture 요약을 확인한 뒤 Scene과 Experiment를 준비해 실행합니다.
Experiment를 별도 파일로 가져올 때는 그 문서의 `arrayId`가 방금 불러온 Array ID와 같아야 합니다.
Camera에서 배열 선택이 **불러온 Array JSON**으로 바뀌고 입력 검증이 통과했는지 확인하세요.
## 검증과 제한
- 마이크는 1개 이상 1,024개 이하입니다.
- Array ID는 영문 소문자나 숫자로 시작하며 소문자·숫자·점·밑줄·하이픈만 허용합니다.
- 마이크 ID는 비어 있을 수 없고 배열 안에서 고유해야 합니다.
- 좌표는 유한한 수여야 합니다. x/y가 현재 생성 경계와 aperture를 벗어나면 저장 가능한 경고로
표시되므로, 의도한 배치인지 확인하거나 clamp 작업을 사용하세요.
- 마이크 두 개가 같은 x/y/z 좌표를 공유할 수 없습니다.
- 저장되지 않은 변경이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
JSON 구조 자체는 [`../../schemas/array.schema.json`](../../schemas/array.schema.json), 공통 계약은
[`../../docs/data-contracts.md`](../../docs/data-contracts.md), 배열 예제는
[`../../assets/array-geometries/README.md`](../../assets/array-geometries/README.md)를 참고하세요.
+805
View File
@@ -0,0 +1,805 @@
import { clampPositionToAperture, positionInsideAperture } from "./model.js";
const BACKGROUND = "#07110f";
const PLOT_BACKGROUND = "#0b1714";
const MAX_PIXEL_RATIO = 3;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 16;
const TOUCH_GESTURE_THRESHOLD_PX = 6;
const DEFAULT_MARGINS = Object.freeze({ left: 54, right: 22, top: 30, bottom: 46 });
export const ARRAY_CANVAS_TOOLS = Object.freeze(["select", "add"]);
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function finiteNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function validPosition(position) {
return Array.isArray(position) &&
Number.isFinite(Number(position[0])) &&
Number.isFinite(Number(position[1]));
}
function apertureDimensions(arrayDefinition) {
return {
width: Math.max(0.000001, finiteNumber(arrayDefinition?.aperture?.width, 0.1)),
height: Math.max(0.000001, finiteNumber(arrayDefinition?.aperture?.height, 0.1)),
};
}
function resolvedMargins(width, height, requested = {}) {
const margins = { ...DEFAULT_MARGINS, ...requested };
const horizontalLimit = Math.max(0, width - 1);
const verticalLimit = Math.max(0, height - 1);
const horizontalScale = Math.min(1, horizontalLimit / Math.max(1, margins.left + margins.right));
const verticalScale = Math.min(1, verticalLimit / Math.max(1, margins.top + margins.bottom));
return {
left: margins.left * horizontalScale,
right: margins.right * horizontalScale,
top: margins.top * verticalScale,
bottom: margins.bottom * verticalScale,
};
}
/** Return a 1/2/5 × 10^n grid step near the requested line density. */
export function niceGridStep(span, targetLines = 8) {
const safeSpan = Math.max(1e-12, Math.abs(finiteNumber(span, 1)));
const safeTarget = Math.max(1, finiteNumber(targetLines, 8));
const raw = safeSpan / safeTarget;
const magnitude = 10 ** Math.floor(Math.log10(raw));
const normalized = raw / magnitude;
const multiplier = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
return multiplier * magnitude;
}
/**
* Fit an array into a CSS-pixel viewport while keeping x/y at exactly the same scale.
* The aperture and any microphones outside it are both included by the fitted view.
*/
export function fitArrayViewport(width, height, arrayDefinition, options = {}) {
const canvasWidth = Math.max(1, finiteNumber(width, 1));
const canvasHeight = Math.max(1, finiteNumber(height, 1));
const margins = resolvedMargins(canvasWidth, canvasHeight, options.margins);
const plot = {
x: margins.left,
y: margins.top,
width: Math.max(1, canvasWidth - margins.left - margins.right),
height: Math.max(1, canvasHeight - margins.top - margins.bottom),
};
const aperture = apertureDimensions(arrayDefinition);
let contentXMin = -aperture.width / 2;
let contentXMax = aperture.width / 2;
let contentYMin = -aperture.height / 2;
let contentYMax = aperture.height / 2;
for (const microphone of arrayDefinition?.microphones ?? []) {
if (!validPosition(microphone?.position)) continue;
contentXMin = Math.min(contentXMin, Number(microphone.position[0]));
contentXMax = Math.max(contentXMax, Number(microphone.position[0]));
contentYMin = Math.min(contentYMin, Number(microphone.position[1]));
contentYMax = Math.max(contentYMax, Number(microphone.position[1]));
}
const paddingRatio = clamp(finiteNumber(options.paddingRatio, 0.12), 0, 1);
const contentWidth = Math.max(0.000001, contentXMax - contentXMin);
const contentHeight = Math.max(0.000001, contentYMax - contentYMin);
const paddedWidth = contentWidth * (1 + paddingRatio * 2);
const paddedHeight = contentHeight * (1 + paddingRatio * 2);
const zoom = clamp(finiteNumber(options.zoom, 1), MIN_ZOOM, MAX_ZOOM);
const scale = Math.max(
Number.MIN_VALUE,
Math.min(plot.width / paddedWidth, plot.height / paddedHeight) * zoom,
);
const centerX = (contentXMin + contentXMax) / 2;
const centerY = (contentYMin + contentYMax) / 2;
const visibleWidth = plot.width / 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 {
width: canvasWidth,
height: canvasHeight,
plot,
scale,
metersPerPixel: 1 / scale,
zoom,
centerX,
centerY,
xMin: centerX - visibleWidth / 2,
xMax: centerX + visibleWidth / 2,
yMin: centerY - visibleHeight / 2,
yMax: centerY + visibleHeight / 2,
};
}
/** Convert an array [x,y,z] position to CSS pixels. */
export function arrayPositionToCanvas(position, viewport) {
return {
x: viewport.plot.x + (Number(position[0]) - viewport.xMin) * viewport.scale,
y: viewport.plot.y + (viewport.yMax - Number(position[1])) * viewport.scale,
};
}
/** Convert CSS pixels to an array [x,y,z] position. `clamp` limits input to the plot. */
export function canvasPointToArray(point, viewport, options = {}) {
const plot = viewport.plot;
const pixelX = options.clamp
? clamp(finiteNumber(point?.x), plot.x, plot.x + plot.width)
: finiteNumber(point?.x);
const pixelY = options.clamp
? clamp(finiteNumber(point?.y), plot.y, plot.y + plot.height)
: finiteNumber(point?.y);
return [
viewport.xMin + (pixelX - plot.x) / viewport.scale,
viewport.yMax - (pixelY - plot.y) / viewport.scale,
finiteNumber(options.z, 0),
];
}
/** Convert a PointerEvent-like object into canvas-local CSS pixels without touching the DOM. */
export function eventToCanvasPoint(event, boundingRect, options = {}) {
const width = Math.max(0, finiteNumber(boundingRect?.width));
const height = Math.max(0, finiteNumber(boundingRect?.height));
let x = finiteNumber(event?.clientX) - finiteNumber(boundingRect?.left);
let y = finiteNumber(event?.clientY) - finiteNumber(boundingRect?.top);
if (options.clamp) {
x = clamp(x, 0, width);
y = clamp(y, 0, height);
}
return { x, y };
}
/** Convert a PointerEvent-like object to array coordinates; optionally clamp to the visible plot. */
export function eventToArrayPosition(event, boundingRect, viewport, options = {}) {
return canvasPointToArray(
eventToCanvasPoint(event, boundingRect, { clamp: Boolean(options.clampToCanvas) }),
viewport,
{ clamp: Boolean(options.clamp), z: options.z },
);
}
/** Find the closest microphone within a CSS-pixel radius. */
export function hitTestMicrophone(point, microphones, viewport, radiusPx = 12) {
const maximumDistance = Math.max(0, finiteNumber(radiusPx, 12));
let best = null;
for (let index = 0; index < (microphones?.length ?? 0); index += 1) {
const microphone = microphones[index];
if (!validPosition(microphone?.position)) continue;
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);
if (distancePx <= maximumDistance && (!best || distancePx < best.distancePx)) {
best = { id: microphone.id, index, microphone, point: pixel, distancePx };
}
}
return best;
}
/** Pure keyboard-to-array delta mapping used by the canvas controller. */
export function keyboardNudgeForKey(key, stepM = 0.001, shiftKey = false) {
const requestedStep = Math.abs(finiteNumber(stepM, 0.001));
const step = (requestedStep > 0 ? requestedStep : 0.001) * (shiftKey ? 10 : 1);
if (key === "ArrowLeft") return { dx: -step, dy: 0, dz: 0 };
if (key === "ArrowRight") return { dx: step, dy: 0, dz: 0 };
if (key === "ArrowUp") return { dx: 0, dy: step, dz: 0 };
if (key === "ArrowDown") return { dx: 0, dy: -step, dz: 0 };
return null;
}
function resolveCanvasSize(canvas, view) {
const rect = canvas.getBoundingClientRect?.();
const width = Math.max(1, rect?.width || canvas.clientWidth || 1);
const height = Math.max(1, rect?.height || canvas.clientHeight || 1);
const pixelRatio = clamp(finiteNumber(view?.devicePixelRatio, 1), 1, MAX_PIXEL_RATIO);
const backingWidth = Math.max(1, Math.round(width * pixelRatio));
const backingHeight = Math.max(1, Math.round(height * pixelRatio));
if (canvas.width !== backingWidth || canvas.height !== backingHeight) {
canvas.width = backingWidth;
canvas.height = backingHeight;
}
return { width, height, pixelRatio };
}
function prepareContext(canvas, context, view) {
const size = resolveCanvasSize(canvas, view);
context.setTransform(size.pixelRatio, 0, 0, size.pixelRatio, 0, 0);
context.globalAlpha = 1;
context.globalCompositeOperation = "source-over";
context.imageSmoothingEnabled = true;
context.clearRect(0, 0, size.width, size.height);
return size;
}
function drawEmpty(context, width, height) {
context.fillStyle = BACKGROUND;
context.fillRect(0, 0, width, height);
context.fillStyle = "rgba(205,225,218,.66)";
context.font = "600 13px Inter, system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("마이크 어레이를 준비하고 있습니다.", width / 2, height / 2);
}
function formatMeters(value, step) {
const digits = clamp(Math.max(0, -Math.floor(Math.log10(step))) + 1, 0, 6);
const normalized = Math.abs(value) < step * 1e-7 ? 0 : value;
return normalized.toFixed(digits);
}
function gridValues(minimum, maximum, step) {
const first = Math.ceil((minimum - step * 1e-8) / step) * step;
const count = Math.min(200, Math.max(0, Math.floor((maximum - first) / step + 1 + 1e-8)));
return Array.from({ length: count }, (_, index) => first + index * step);
}
function drawGrid(context, viewport) {
const { plot } = viewport;
context.save();
context.beginPath();
context.rect(plot.x, plot.y, plot.width, plot.height);
context.clip();
context.fillStyle = PLOT_BACKGROUND;
context.fillRect(plot.x, plot.y, plot.width, plot.height);
const step = niceGridStep(Math.max(viewport.xMax - viewport.xMin, viewport.yMax - viewport.yMin));
context.lineWidth = 1;
for (const x of gridValues(viewport.xMin, viewport.xMax, step)) {
const pixel = arrayPositionToCanvas([x, 0, 0], viewport).x;
context.strokeStyle = Math.abs(x) < step * 1e-7
? "rgba(101,242,191,.34)"
: "rgba(166,196,186,.11)";
context.beginPath();
context.moveTo(pixel, plot.y);
context.lineTo(pixel, plot.y + plot.height);
context.stroke();
}
for (const y of gridValues(viewport.yMin, viewport.yMax, step)) {
const pixel = arrayPositionToCanvas([0, y, 0], viewport).y;
context.strokeStyle = Math.abs(y) < step * 1e-7
? "rgba(101,242,191,.34)"
: "rgba(166,196,186,.11)";
context.beginPath();
context.moveTo(plot.x, pixel);
context.lineTo(plot.x + plot.width, pixel);
context.stroke();
}
context.restore();
context.fillStyle = "rgba(205,225,218,.62)";
context.font = "600 10px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textBaseline = "top";
context.textAlign = "center";
for (const x of gridValues(viewport.xMin, viewport.xMax, step)) {
const pixel = arrayPositionToCanvas([x, 0, 0], viewport).x;
if (pixel < plot.x + 14 || pixel > plot.x + plot.width - 14) continue;
context.fillText(formatMeters(x, step), pixel, plot.y + plot.height + 8);
}
context.textAlign = "right";
context.textBaseline = "middle";
for (const y of gridValues(viewport.yMin, viewport.yMax, step)) {
const pixel = arrayPositionToCanvas([0, y, 0], viewport).y;
if (pixel < plot.y + 8 || pixel > plot.y + plot.height - 8) continue;
context.fillText(formatMeters(y, step), plot.x - 8, pixel);
}
context.textAlign = "right";
context.textBaseline = "alphabetic";
context.fillText("x → m", plot.x + plot.width, plot.y + plot.height + 31);
context.save();
context.translate(Math.max(12, plot.x - 37), plot.y);
context.rotate(-Math.PI / 2);
context.textAlign = "right";
context.fillText("y ↑ m", 0, 0);
context.restore();
context.textAlign = "left";
}
function traceAperture(context, arrayDefinition, apertureShape, viewport) {
const aperture = apertureDimensions(arrayDefinition);
const center = arrayPositionToCanvas([0, 0, 0], viewport);
const radiusX = aperture.width * viewport.scale / 2;
const radiusY = aperture.height * viewport.scale / 2;
context.beginPath();
if (apertureShape === "ellipse") {
context.ellipse(center.x, center.y, radiusX, radiusY, 0, 0, Math.PI * 2);
} else {
context.rect(center.x - radiusX, center.y - radiusY, radiusX * 2, radiusY * 2);
}
}
function drawAperture(context, arrayDefinition, apertureShape, viewport) {
context.save();
context.beginPath();
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.clip();
traceAperture(context, arrayDefinition, apertureShape, viewport);
context.fillStyle = "rgba(101,242,191,.035)";
context.fill();
context.setLineDash([7, 5]);
context.lineWidth = 1.5;
context.strokeStyle = "rgba(174,229,210,.54)";
context.stroke();
context.setLineDash([]);
context.restore();
}
function drawSelectedLabel(context, microphone, point, viewport) {
const microphoneId = microphone.id.length > 24
? microphone.id.slice(0, 23) + "…"
: microphone.id;
const label = microphoneId + " [" +
microphone.position.map((value) => Number(value).toFixed(4)).join(", ") + "] m";
context.font = "700 11px ui-monospace, SFMono-Regular, Consolas, monospace";
const width = context.measureText(label).width + 16;
const x = clamp(point.x + 15, viewport.plot.x + 5, viewport.plot.x + viewport.plot.width - width - 5);
const y = clamp(point.y - 31, viewport.plot.y + 5, viewport.plot.y + viewport.plot.height - 28);
context.fillStyle = "rgba(4,13,10,.94)";
context.fillRect(x, y, width, 25);
context.strokeStyle = "rgba(101,242,191,.5)";
context.strokeRect(x + 0.5, y + 0.5, width - 1, 24);
context.fillStyle = "#eef8f4";
context.textAlign = "left";
context.textBaseline = "middle";
context.fillText(label, x + 8, y + 12.5);
}
function drawMicrophones(context, model, viewport, drag) {
const arrayDefinition = model.arrayDefinition;
const microphones = arrayDefinition.microphones ?? [];
const selectedId = drag?.id ?? model.selectedMicrophoneId;
const baseRadius = clamp(8 - Math.log2(Math.max(1, microphones.length)) * 0.55, 3, 7);
let selected = null;
context.save();
context.beginPath();
context.rect(viewport.plot.x, viewport.plot.y, viewport.plot.width, viewport.plot.height);
context.clip();
microphones.forEach((microphone, index) => {
if (!validPosition(microphone?.position)) return;
const position = drag?.id === microphone.id ? drag.last : microphone.position;
const drawMicrophone = position === microphone.position ? microphone : { ...microphone, position };
const point = arrayPositionToCanvas(position, viewport);
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
const isSelected = microphone.id === selectedId;
const inside = positionInsideAperture(position, arrayDefinition.aperture, model.apertureShape);
const radius = isSelected ? Math.max(7, baseRadius + 2) : baseRadius;
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.shadowBlur = isSelected ? 16 : microphones.length > 256 ? 0 : 6;
context.beginPath();
context.arc(point.x, point.y, radius, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
context.strokeStyle = "rgba(239,255,249,.7)";
context.lineWidth = 1;
context.stroke();
if (microphones.length <= 24 && !isSelected) {
context.fillStyle = "rgba(239,249,245,.76)";
context.font = "700 9px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textAlign = "center";
context.textBaseline = "bottom";
context.fillText(String(index + 1), point.x, point.y - radius - 3);
}
if (isSelected) selected = { microphone: drawMicrophone, point, radius };
});
if (selected) {
context.strokeStyle = "rgba(255,255,255,.94)";
context.lineWidth = 2;
context.beginPath();
context.arc(selected.point.x, selected.point.y, selected.radius + 6, 0, Math.PI * 2);
context.stroke();
}
context.restore();
if (selected) drawSelectedLabel(context, selected.microphone, selected.point, viewport);
}
function drawArray(context, size, model, zoom, drag) {
context.fillStyle = BACKGROUND;
context.fillRect(0, 0, size.width, size.height);
if (!model?.arrayDefinition?.aperture || !Array.isArray(model.arrayDefinition.microphones)) {
drawEmpty(context, size.width, size.height);
return null;
}
const viewport = fitArrayViewport(size.width, size.height, model.arrayDefinition, { zoom });
drawGrid(context, viewport);
drawAperture(context, model.arrayDefinition, model.apertureShape, viewport);
drawMicrophones(context, model, viewport, drag);
context.strokeStyle = "rgba(174,211,199,.28)";
context.lineWidth = 1;
context.strokeRect(
viewport.plot.x + 0.5,
viewport.plot.y + 0.5,
Math.max(0, viewport.plot.width - 1),
Math.max(0, viewport.plot.height - 1),
);
context.fillStyle = "rgba(205,225,218,.7)";
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
context.textAlign = "right";
context.textBaseline = "top";
context.fillText(Math.round(zoom * 100) + "%", viewport.plot.x + viewport.plot.width, 8);
context.textAlign = "left";
return viewport;
}
function normalizedTool(tool) {
return ARRAY_CANVAS_TOOLS.includes(tool) ? tool : "select";
}
/**
* Create the editable microphone-array canvas.
*
* Callbacks: onSelect(id), onAdd(position), onDragStart(id), onDrag(id, position),
* onDragEnd(id, position), onDelete(), onNudge({dx,dy,dz}), onToolChange(tool), onFit().
*/
export function createArrayCanvas(canvas, callbacks = {}) {
if (!canvas) throw new TypeError("array canvas가 필요합니다.");
const context = canvas.getContext?.("2d", { alpha: false });
if (!context) throw new Error("2D canvas context를 만들 수 없습니다.");
const view = canvas.ownerDocument?.defaultView ?? globalThis;
const requestFrame = view.requestAnimationFrame?.bind(view) ?? ((callback) => view.setTimeout(callback, 16));
const cancelFrame = view.cancelAnimationFrame?.bind(view) ?? view.clearTimeout?.bind(view);
let model = null;
let viewport = null;
let frameId = 0;
let destroyed = false;
let zoom = 1;
let tool = "select";
let drag = null;
let pendingAdd = null;
if (canvas.tabIndex < 0) canvas.tabIndex = 0;
function updateCanvasAffordance() {
if (canvas.dataset) {
canvas.dataset.tool = tool;
canvas.dataset.zoom = String(zoom);
}
if (canvas.style) canvas.style.cursor = tool === "add" ? "crosshair" : drag ? "grabbing" : "default";
}
function drawCurrent() {
frameId = 0;
if (destroyed) return;
const size = prepareContext(canvas, context, view);
viewport = drawArray(context, size, model, zoom, drag);
updateCanvasAffordance();
}
function schedule() {
if (!destroyed && !frameId) frameId = requestFrame(drawCurrent);
}
function ensureViewport() {
if (viewport) return viewport;
if (!model?.arrayDefinition) return null;
const rect = canvas.getBoundingClientRect();
viewport = fitArrayViewport(rect.width, rect.height, model.arrayDefinition, { zoom });
return viewport;
}
function render(nextModel) {
if (destroyed) return;
model = nextModel;
tool = normalizedTool(nextModel?.tool ?? tool);
viewport = null;
schedule();
}
function setTool(nextTool) {
const resolved = normalizedTool(nextTool);
if (resolved === tool) return tool;
if (resolved !== "add") pendingAdd = null;
tool = resolved;
updateCanvasAffordance();
callbacks.onToolChange?.(tool);
schedule();
return tool;
}
function fit() {
zoom = 1;
viewport = null;
schedule();
return zoom;
}
function zoomBy(factor) {
const multiplier = Number(factor);
if (!Number.isFinite(multiplier) || multiplier <= 0) {
throw new RangeError("zoom factor는 0보다 큰 유한한 숫자여야 합니다.");
}
zoom = clamp(zoom * multiplier, MIN_ZOOM, MAX_ZOOM);
viewport = null;
schedule();
return zoom;
}
function focus() {
try {
canvas.focus({ preventScroll: true });
} catch {
canvas.focus?.();
}
}
function microphoneById(id) {
return model?.arrayDefinition?.microphones?.find((microphone) => microphone.id === id) ?? null;
}
function constrainedEventPosition(event, z = 0) {
const currentViewport = ensureViewport();
if (!currentViewport || !model?.arrayDefinition?.aperture) return null;
const rect = canvas.getBoundingClientRect();
const raw = eventToArrayPosition(event, rect, currentViewport, { clamp: true, z });
const step = finiteNumber(model.snapStepM, 0);
if (step > 0) {
raw[0] = Math.round(raw[0] / step) * step;
raw[1] = Math.round(raw[1] / step) * step;
}
return clampPositionToAperture(
raw,
model.arrayDefinition.aperture,
model.apertureShape,
);
}
function pointerPoint(event) {
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) {
if (destroyed || event.button !== 0 || !model?.arrayDefinition) return;
if (drag || pendingAdd) return;
focus();
if (tool === "add") {
const position = constrainedEventPosition(event, 0);
if (position) {
if (event.pointerType === "touch") {
pendingAdd = {
pointerId: event.pointerId,
startClient: clientPoint(event),
};
} else {
event.preventDefault();
callbacks.onAdd?.(position);
}
}
return;
}
const currentViewport = ensureViewport();
if (!currentViewport) return;
const hit = hitTestMicrophone(
pointerPoint(event),
model.arrayDefinition.microphones,
currentViewport,
);
callbacks.onSelect?.(hit?.id ?? null);
if (!hit) return;
event.preventDefault();
drag = {
pointerId: event.pointerId,
id: hit.id,
start: [...hit.microphone.position],
last: [...hit.microphone.position],
startClient: clientPoint(event),
pointerType: event.pointerType,
pending: event.pointerType === "touch",
};
try {
canvas.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic pointers and interrupted input do not always own capture.
}
if (!drag.pending) callbacks.onDragStart?.(hit.id);
updateCanvasAffordance();
schedule();
}
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.pending) {
if (!exceedsTouchThreshold(drag.startClient, event)) return;
drag.pending = false;
callbacks.onDragStart?.(drag.id);
}
const microphone = microphoneById(drag.id);
const position = constrainedEventPosition(event, microphone?.position?.[2] ?? drag.start[2]);
if (!position) return;
event.preventDefault();
drag.last = position;
callbacks.onDrag?.(drag.id, [...position]);
schedule();
}
function finishDrag(event, cancel = false) {
if (!drag || (event?.pointerId !== undefined && event.pointerId !== drag.pointerId)) return;
const completed = drag;
drag = null;
if (!completed.pending) {
const position = cancel ? completed.start : completed.last;
if (cancel) callbacks.onDrag?.(completed.id, [...position]);
callbacks.onDragEnd?.(completed.id, [...position]);
}
const pointerId = event?.pointerId ?? completed.pointerId;
try {
if (canvas.hasPointerCapture?.(pointerId)) {
canvas.releasePointerCapture?.(pointerId);
}
} catch {
// The browser may have released capture before pointercancel/lostcapture.
}
updateCanvasAffordance();
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) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const lowerKey = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (lowerKey === "a") {
event.preventDefault();
setTool("add");
return;
}
if (lowerKey === "v") {
event.preventDefault();
setTool("select");
return;
}
if (lowerKey === "f") {
event.preventDefault();
fit();
callbacks.onFit?.();
return;
}
if (event.key === "Escape") {
event.preventDefault();
if (drag) finishDrag(undefined, true);
setTool("select");
return;
}
if ((event.key === "Delete" || event.key === "Backspace") && model?.selectedMicrophoneId) {
event.preventDefault();
callbacks.onDelete?.();
return;
}
if (!model?.selectedMicrophoneId) return;
const defaultStep = finiteNumber(model.snapStepM, 0) > 0 ? Number(model.snapStepM) : 0.001;
const delta = keyboardNudgeForKey(event.key, defaultStep, event.shiftKey);
if (!delta) return;
event.preventDefault();
callbacks.onNudge?.(delta);
}
function onPointerUp(event) {
if (finishPendingAdd(event, false)) return;
finishDrag(event, false);
}
function onPointerCancel(event) {
if (finishPendingAdd(event, true)) return;
finishDrag(event, true);
}
function onLostPointerCapture(event) {
if (drag?.pointerId === event.pointerId) finishDrag(event, false);
}
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerCancel);
canvas.addEventListener("lostpointercapture", onLostPointerCapture);
canvas.addEventListener("touchstart", onTouchStart, { passive: false });
canvas.addEventListener("keydown", onKeyDown);
let resizeObserver = null;
const onWindowResize = () => {
viewport = null;
schedule();
};
if (typeof view.ResizeObserver === "function") {
resizeObserver = new view.ResizeObserver(onWindowResize);
resizeObserver.observe(canvas);
} else {
view.addEventListener?.("resize", onWindowResize);
}
updateCanvasAffordance();
return {
render,
setTool,
fit,
zoomBy,
focus,
destroy() {
if (destroyed) return;
destroyed = true;
if (frameId) cancelFrame?.(frameId);
frameId = 0;
resizeObserver?.disconnect();
view.removeEventListener?.("resize", onWindowResize);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointercancel", onPointerCancel);
canvas.removeEventListener("lostpointercapture", onLostPointerCapture);
canvas.removeEventListener("touchstart", onTouchStart);
canvas.removeEventListener("keydown", onKeyDown);
model = null;
viewport = null;
drag = null;
pendingAdd = null;
},
};
}
+289
View File
@@ -0,0 +1,289 @@
<!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>
<a class="skip-link" href="#array-canvas">배열 편집 화면으로 건너뛰기</a>
<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>
<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
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 array-metadata-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 aperture-section">
<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">크기를 바꿔도 기존 좌표는 자동으로 늘어나거나 줄어들지 않습니다. 생성 경계 형상은 편집용이며 JSON에는 최종 좌표와 너비·높이만 저장됩니다.</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 preset-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>
<div class="mobile-save-actions">
<button class="button button--primary button--full" id="mobile-export-button" type="button">Array JSON 저장</button>
</div>
</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>
</main>
</div>
<script type="module" src="/packages/ui/runtime-status.js"></script>
<script type="module" src="/apps/array-builder/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+580
View File
@@ -0,0 +1,580 @@
export const MAX_MICROPHONES = 1024;
export const APERTURE_SHAPES = Object.freeze(["rectangle", "ellipse"]);
export const PRESET_TYPES = Object.freeze(["grid", "linear", "ring", "spiral", "random"]);
const COORDINATE_SYSTEM = Object.freeze({
handedness: "right",
xAxis: "right",
yAxis: "up",
zAxis: "toward-scene",
});
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const COORDINATE_DIGITS = 9;
function finiteNumber(value, label) {
const number = Number(value);
if (!Number.isFinite(number)) throw new TypeError(label + "은(는) 유한한 숫자여야 합니다.");
return number;
}
function positiveDimension(value, label) {
const number = finiteNumber(value, label);
if (number <= 0 || number > 20) throw new RangeError(label + "은(는) 0보다 크고 20 m 이하여야 합니다.");
return number;
}
function boundedInteger(value, label, minimum, maximum) {
const number = Number(value);
if (!Number.isInteger(number) || number < minimum || number > maximum) {
throw new RangeError(label + "은(는) " + minimum + "–" + maximum + " 정수여야 합니다.");
}
return number;
}
function normalizedShape(shape) {
return APERTURE_SHAPES.includes(shape) ? shape : "rectangle";
}
function rounded(value) {
const number = Number(finiteNumber(value, "좌표").toFixed(COORDINATE_DIGITS));
return Object.is(number, -0) ? 0 : number;
}
function normalizedPosition(position) {
if (!Array.isArray(position) || position.length !== 3) {
throw new TypeError("마이크 위치는 [x, y, z] 숫자 3개여야 합니다.");
}
return position.map(rounded);
}
function normalizedAperture(aperture) {
return {
width: positiveDimension(aperture?.width, "aperture width"),
height: positiveDimension(aperture?.height, "aperture height"),
};
}
function microphoneId(index) {
return "mic-" + String(index).padStart(3, "0");
}
function microphoneFromPosition(position, index) {
return { id: microphoneId(index + 1), position: normalizedPosition(position) };
}
function pointKey(position) {
return position.map((value) => {
const number = finiteNumber(value, "좌표");
return Object.is(number, -0) ? "0" : String(number);
}).join(",");
}
function linearValue(index, count, minimum, maximum) {
if (count === 1) return (minimum + maximum) / 2;
return minimum + (index / (count - 1)) * (maximum - minimum);
}
function presetBounds(aperture, edgeInsetM) {
const dimensions = normalizedAperture(aperture);
const inset = finiteNumber(edgeInsetM ?? 0, "경계 여백");
if (inset < 0 || inset * 2 >= Math.min(dimensions.width, dimensions.height)) {
throw new RangeError("경계 여백은 0 이상이고 짧은 aperture 변의 절반보다 작아야 합니다.");
}
return {
width: dimensions.width,
height: dimensions.height,
halfWidth: dimensions.width / 2 - inset,
halfHeight: dimensions.height / 2 - inset,
};
}
function gridPositions(options, bounds, shape) {
const columns = boundedInteger(options.columns ?? 4, "Columns", 1, MAX_MICROPHONES);
const rows = boundedInteger(options.rows ?? 3, "Rows", 1, MAX_MICROPHONES);
if (columns * rows > MAX_MICROPHONES) {
throw new RangeError("격자 마이크는 최대 " + MAX_MICROPHONES + "개까지 생성할 수 있습니다.");
}
const positions = [];
for (let row = 0; row < rows; row += 1) {
if (shape === "ellipse") {
const normalizedY = rows === 1 ? 0 : -1 + (2 * (row + 0.5)) / rows;
const y = normalizedY * bounds.halfHeight;
const rowHalfWidth = bounds.halfWidth * Math.sqrt(Math.max(0, 1 - normalizedY * normalizedY));
for (let column = 0; column < columns; column += 1) {
const normalizedX = columns === 1 ? 0 : -1 + (2 * (column + 0.5)) / columns;
positions.push([normalizedX * rowHalfWidth, y, 0]);
}
} else {
const y = linearValue(row, rows, -bounds.halfHeight, bounds.halfHeight);
for (let column = 0; column < columns; column += 1) {
positions.push([
linearValue(column, columns, -bounds.halfWidth, bounds.halfWidth),
y,
0,
]);
}
}
}
return positions;
}
function linearPositions(options, bounds, shape) {
const count = boundedInteger(options.count ?? 8, "마이크 수", 1, MAX_MICROPHONES);
const axis = ["x", "y", "diagonal"].includes(options.axis) ? options.axis : "x";
return Array.from({ length: count }, (_, index) => {
const ratio = count === 1 ? 0.5 : index / (count - 1);
if (axis === "y") return [0, -bounds.halfHeight + ratio * bounds.halfHeight * 2, 0];
if (axis === "diagonal") {
const extent = shape === "ellipse" ? Math.SQRT1_2 : 1;
return [
(-bounds.halfWidth + ratio * bounds.halfWidth * 2) * extent,
(-bounds.halfHeight + ratio * bounds.halfHeight * 2) * extent,
0,
];
}
return [-bounds.halfWidth + ratio * bounds.halfWidth * 2, 0, 0];
});
}
function rectanglePerimeterPosition(ratio, halfWidth, halfHeight) {
const width = halfWidth * 2;
const height = halfHeight * 2;
const perimeter = 2 * (width + height);
let distance = ratio * perimeter;
if (distance <= width) return [-halfWidth + distance, -halfHeight, 0];
distance -= width;
if (distance <= height) return [halfWidth, -halfHeight + distance, 0];
distance -= height;
if (distance <= width) return [halfWidth - distance, halfHeight, 0];
distance -= width;
return [-halfWidth, halfHeight - distance, 0];
}
function ringPositions(options, bounds, shape) {
const count = boundedInteger(options.count ?? 16, "마이크 수", 1, MAX_MICROPHONES);
if (count === 1) return [[0, 0, 0]];
return Array.from({ length: count }, (_, index) => {
const ratio = index / count;
if (shape === "rectangle") {
return rectanglePerimeterPosition(ratio, bounds.halfWidth, bounds.halfHeight);
}
const angle = ratio * Math.PI * 2;
return [bounds.halfWidth * Math.cos(angle), bounds.halfHeight * Math.sin(angle), 0];
});
}
function spiralPositions(options, bounds) {
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
return Array.from({ length: count }, (_, index) => {
const radius = Math.sqrt((index + 0.5) / count);
const angle = index * GOLDEN_ANGLE;
return [
bounds.halfWidth * radius * Math.cos(angle),
bounds.halfHeight * radius * Math.sin(angle),
0,
];
});
}
function createSeededRandom(seedValue) {
let seed = Number(seedValue);
if (!Number.isFinite(seed)) seed = 1;
let state = Math.trunc(seed) >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
function randomCandidate(random, bounds, shape) {
if (shape === "ellipse") {
const radius = Math.sqrt(random());
const angle = random() * Math.PI * 2;
return [
bounds.halfWidth * radius * Math.cos(angle),
bounds.halfHeight * radius * Math.sin(angle),
0,
];
}
return [
(random() * 2 - 1) * bounds.halfWidth,
(random() * 2 - 1) * bounds.halfHeight,
0,
];
}
function squaredDistance(left, right) {
const dx = left[0] - right[0];
const dy = left[1] - right[1];
const dz = left[2] - right[2];
return dx * dx + dy * dy + dz * dz;
}
function randomPositions(options, bounds, shape) {
const count = boundedInteger(options.count ?? 32, "마이크 수", 1, MAX_MICROPHONES);
const minimumSpacing = finiteNumber(options.minimumSpacingM ?? 0, "최소 간격");
if (minimumSpacing < 0) throw new RangeError("최소 간격은 0 이상이어야 합니다.");
const minimumSpacingSquared = minimumSpacing * minimumSpacing;
const random = createSeededRandom(options.seed ?? 1);
const positions = [];
const keys = new Set();
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) {
const candidate = normalizedPosition(randomCandidate(random, bounds, shape));
const key = pointKey(candidate);
if (keys.has(key)) continue;
if (violatesMinimumSpacing(candidate)) continue;
keys.add(key);
positions.push(candidate);
indexPosition(candidate);
}
if (positions.length !== count) {
throw new RangeError(
"지정한 aperture와 최소 간격으로 " + count +
"개를 배치할 수 없습니다. 마이크 수나 최소 간격을 줄이세요.",
);
}
return positions;
}
export function sanitizeArrayId(value, fallback = "custom-array") {
const sanitized = String(value ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "");
return sanitized || fallback;
}
export function safeArrayFileStem(arrayDefinition) {
return sanitizeArrayId(arrayDefinition?.id || arrayDefinition?.name, "microphone-array");
}
export function generatePresetMicrophones(options = {}) {
const shape = normalizedShape(options.shape);
const bounds = presetBounds(options.aperture, options.edgeInsetM);
let positions;
if (options.type === "linear") positions = linearPositions(options, bounds, shape);
else if (options.type === "ring") positions = ringPositions(options, bounds, shape);
else if (options.type === "spiral") positions = spiralPositions(options, bounds);
else if (options.type === "random") positions = randomPositions(options, bounds, shape);
else positions = gridPositions(options, bounds, shape);
return positions.map(microphoneFromPosition);
}
export function createArrayDefinition(options = {}) {
const aperture = normalizedAperture({
width: options.width ?? 0.45,
height: options.height ?? 0.45,
});
const arrayDefinition = {
schemaVersion: "1.0.0",
kind: "micarrsim.array",
id: sanitizeArrayId(options.id ?? "custom-array"),
name: String(options.name ?? "Custom 4x3 array"),
description: String(options.description ?? "Array Builder에서 생성한 사용자 마이크 배열"),
units: "m",
coordinateSystem: { ...COORDINATE_SYSTEM },
aperture,
microphones: [],
};
arrayDefinition.microphones = generatePresetMicrophones({
type: options.presetType ?? "grid",
aperture,
shape: options.shape ?? "rectangle",
columns: options.columns ?? 4,
rows: options.rows ?? 3,
count: options.count,
seed: options.seed,
minimumSpacingM: options.minimumSpacingM,
edgeInsetM: options.edgeInsetM,
axis: options.axis,
});
return arrayDefinition;
}
export function prepareArrayForEditing(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError("Array 문서 객체가 필요합니다.");
}
return structuredClone(value);
}
export function inferApertureShape(arrayDefinition) {
const shapeWords = [
arrayDefinition?.id,
arrayDefinition?.name,
arrayDefinition?.description,
].filter(Boolean).join(" ");
return /(^|[^a-z])(circle|circular|ellipse|elliptic|ring|spiral)([^a-z]|$)/i.test(shapeWords)
? "ellipse"
: "rectangle";
}
export function nextMicrophoneId(arrayDefinition) {
const used = new Set((arrayDefinition?.microphones ?? []).map((microphone) => microphone.id));
let maximumSuffix = 0n;
for (const id of used) {
const match = /^mic-(\d+)$/.exec(id);
if (!match) continue;
const suffix = BigInt(match[1]);
if (suffix > maximumSuffix) maximumSuffix = suffix;
}
let candidate = maximumSuffix + 1n;
for (let attempt = 0; attempt <= used.size; attempt += 1) {
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 = {}) {
if (arrayDefinition.microphones.length >= MAX_MICROPHONES) {
throw new RangeError("마이크는 최대 " + MAX_MICROPHONES + "개까지 추가할 수 있습니다.");
}
const microphone = {
id: properties.id || nextMicrophoneId(arrayDefinition),
position: normalizedPosition(position),
};
for (const key of ["sensitivityDb", "phaseOffsetDeg", "frequencyResponse"]) {
if (properties[key] !== undefined) microphone[key] = structuredClone(properties[key]);
}
arrayDefinition.microphones.push(microphone);
return microphone;
}
export function updateMicrophone(arrayDefinition, microphoneIdValue, changes) {
const microphone = arrayDefinition.microphones.find((item) => item.id === microphoneIdValue);
if (!microphone) throw new Error("마이크를 찾을 수 없습니다: " + microphoneIdValue);
if (changes.id !== undefined && changes.id !== microphone.id) {
const nextId = String(changes.id).trim();
if (!nextId) throw new Error("마이크 ID는 비어 있을 수 없습니다.");
if (arrayDefinition.microphones.some((item) => item !== microphone && item.id === nextId)) {
throw new Error("이미 사용 중인 마이크 ID입니다: " + nextId);
}
microphone.id = nextId;
}
if (changes.position !== undefined) microphone.position = normalizedPosition(changes.position);
for (const key of ["sensitivityDb", "phaseOffsetDeg"]) {
if (!Object.hasOwn(changes, key)) continue;
if (changes[key] === null || changes[key] === "") delete microphone[key];
else microphone[key] = finiteNumber(changes[key], key);
}
return microphone;
}
export function removeMicrophone(arrayDefinition, microphoneIdValue) {
if (arrayDefinition.microphones.length <= 1) return false;
const index = arrayDefinition.microphones.findIndex((item) => item.id === microphoneIdValue);
if (index < 0) return false;
arrayDefinition.microphones.splice(index, 1);
return true;
}
export function moveMicrophone(arrayDefinition, microphoneIdValue, delta) {
const index = arrayDefinition.microphones.findIndex((item) => item.id === microphoneIdValue);
if (index < 0) return false;
const target = Math.max(0, Math.min(arrayDefinition.microphones.length - 1, index + delta));
if (target === index) return false;
const [microphone] = arrayDefinition.microphones.splice(index, 1);
arrayDefinition.microphones.splice(target, 0, microphone);
return true;
}
export function replaceMicrophonesWithPreset(arrayDefinition, options) {
const microphones = generatePresetMicrophones({
...options,
aperture: options?.aperture ?? arrayDefinition.aperture,
});
arrayDefinition.microphones = microphones;
return microphones;
}
export function positionInsideAperture(position, aperture, shape = "rectangle", tolerance = 1e-8) {
const dimensions = normalizedAperture(aperture);
const normalized = normalizedPosition(position);
const halfWidth = dimensions.width / 2;
const halfHeight = dimensions.height / 2;
if (normalizedShape(shape) === "ellipse") {
const radiusSquared =
(normalized[0] * normalized[0]) / (halfWidth * halfWidth) +
(normalized[1] * normalized[1]) / (halfHeight * halfHeight);
return radiusSquared <= 1 + tolerance;
}
return Math.abs(normalized[0]) <= halfWidth + tolerance &&
Math.abs(normalized[1]) <= halfHeight + tolerance;
}
export function clampPositionToAperture(position, aperture, shape = "rectangle") {
const dimensions = normalizedAperture(aperture);
const normalized = normalizedPosition(position);
const halfWidth = dimensions.width / 2;
const halfHeight = dimensions.height / 2;
if (normalizedShape(shape) === "ellipse") {
const radius = Math.hypot(normalized[0] / halfWidth, normalized[1] / halfHeight);
if (radius > 1) {
normalized[0] /= radius;
normalized[1] /= radius;
}
} else {
normalized[0] = Math.max(-halfWidth, Math.min(halfWidth, normalized[0]));
normalized[1] = Math.max(-halfHeight, Math.min(halfHeight, normalized[1]));
}
return normalized.map(rounded);
}
export function snapPosition(position, stepM, aperture, shape = "rectangle") {
const step = finiteNumber(stepM, "snap 간격");
const normalized = normalizedPosition(position);
if (step > 0) {
normalized[0] = Math.round(normalized[0] / step) * step;
normalized[1] = Math.round(normalized[1] / step) * step;
normalized[2] = Math.round(normalized[2] / step) * step;
}
return aperture
? clampPositionToAperture(normalized, aperture, shape)
: normalized.map(rounded);
}
export function centerMicrophones(arrayDefinition) {
const microphones = arrayDefinition.microphones;
if (microphones.length === 0) return [0, 0, 0];
const centroid = [0, 1, 2].map((axis) =>
microphones.reduce((sum, microphone) => sum + microphone.position[axis], 0) / microphones.length);
for (const microphone of microphones) {
microphone.position = microphone.position.map((value, axis) => rounded(value - centroid[axis]));
}
return centroid;
}
export function fitApertureToMicrophones(arrayDefinition, options = {}) {
const microphones = arrayDefinition.microphones;
if (microphones.length === 0) return arrayDefinition.aperture;
const padding = Math.max(0, finiteNumber(options.paddingM ?? 0.005, "aperture 여백"));
const minimumSize = Math.max(0.000001, finiteNumber(options.minimumSizeM ?? 0.001, "최소 aperture"));
const maximumX = Math.max(...microphones.map((microphone) => Math.abs(microphone.position[0])));
const maximumY = Math.max(...microphones.map((microphone) => Math.abs(microphone.position[1])));
let width = Math.max(minimumSize, maximumX * 2);
let height = Math.max(minimumSize, maximumY * 2);
if (normalizedShape(options.shape) === "ellipse") {
let maximumRadius = 0;
for (const microphone of microphones) {
maximumRadius = Math.max(
maximumRadius,
Math.hypot(
(2 * microphone.position[0]) / width,
(2 * microphone.position[1]) / height,
),
);
}
if (maximumRadius > 1) {
width *= maximumRadius;
height *= maximumRadius;
}
}
arrayDefinition.aperture.width = rounded(Math.min(20, width + padding * 2));
arrayDefinition.aperture.height = rounded(Math.min(20, height + padding * 2));
return arrayDefinition.aperture;
}
export function arrayGeometryIssues(arrayDefinition, options = {}) {
const issues = [];
const shape = normalizedShape(options.shape);
const positions = new Map();
for (let index = 0; index < arrayDefinition.microphones.length; index += 1) {
const microphone = arrayDefinition.microphones[index];
const key = pointKey(microphone.position);
if (positions.has(key)) {
issues.push({
severity: "error",
path: "$.microphones[" + index + "].position",
message: microphone.id + "와 " + positions.get(key) + "의 위치가 중복됩니다.",
});
} else {
positions.set(key, microphone.id);
}
if (!positionInsideAperture(microphone.position, arrayDefinition.aperture, shape)) {
issues.push({
severity: "warning",
path: "$.microphones[" + index + "].position",
message: microphone.id + "가 " + shape + " 생성 경계 밖에 있습니다.",
});
}
}
return issues;
}
export function arrayStatistics(arrayDefinition) {
const microphones = arrayDefinition.microphones;
if (microphones.length === 0) {
return { count: 0, centroid: [0, 0, 0], minimumSpacing: null, calibrationCount: 0 };
}
const centroid = [0, 1, 2].map((axis) => {
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;
for (let left = 0; left < microphones.length; left += 1) {
for (let right = left + 1; right < microphones.length; right += 1) {
minimumSpacingSquared = Math.min(
minimumSpacingSquared,
squaredDistance(microphones[left].position, microphones[right].position),
);
}
}
const calibrationCount = microphones.filter((microphone) =>
microphone.sensitivityDb !== undefined ||
microphone.phaseOffsetDeg !== undefined ||
microphone.frequencyResponse !== undefined).length;
return {
count: microphones.length,
centroid: centroid.map(rounded),
minimumSpacing: Number.isFinite(minimumSpacingSquared) ? Math.sqrt(minimumSpacingSquared) : null,
calibrationCount,
};
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -9,6 +9,11 @@
<http://127.0.0.1:4173/apps/dataset-builder/>을 엽니다. `index.html`을 직접 열면 module과 Worker가
동작하지 않습니다.
데스크톱에서 canvas와 오른쪽 설정 패널 사이의 선을 드래그하면 설정 폭을 바꿀 수 있습니다. 선에
초점을 둔 뒤 방향키로도 조절할 수 있고, `Enter` 또는 더블클릭하면 기본 폭으로 돌아갑니다.
폭은 이 브라우저에 저장되며, 1100px 이하에서는 설정 패널이 작업 영역 아래로 자동 배치됩니다.
설정 안의 좌표·시간·경로 입력은 viewport가 아니라 실제 패널 폭에 맞춰 열 수를 바꿉니다.
## 기본 사용법
1. **배경 이미지**를 선택하거나 이미지 없이 grid를 사용합니다.
+16 -3
View File
@@ -36,8 +36,8 @@
</div>
</header>
<main class="builder-layout">
<section class="scene-workspace" aria-label="장면 편집 화면">
<main class="builder-layout" id="builder-layout" data-split-layout>
<section class="scene-workspace" id="builder-workspace" aria-label="장면 편집 화면">
<div class="workspace-heading">
<div>
<p class="eyebrow">01 · 입력과 배치</p>
@@ -97,7 +97,20 @@
</section>
</section>
<aside class="control-panel" aria-label="장면 파라미터">
<div
class="split-handle"
id="builder-split-handle"
role="separator"
tabindex="0"
aria-label="장면 설정 패널 너비 조절"
aria-controls="builder-controls"
aria-orientation="vertical"
data-split-resizer
data-storage-key="micarrsim.builder.control-panel-width"
title="드래그하거나 방향키로 너비 조절 · Enter/더블클릭으로 초기화"
></div>
<aside class="control-panel" id="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">기본 장면</span>
+18 -1
View File
@@ -2,6 +2,7 @@ import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
import { downloadBlob, downloadJson, readJsonFile, sha256File } from "/packages/contracts/io.js";
import { validateScene } from "/packages/contracts/validation.js";
import { createSplitPane } from "/packages/ui/split-pane.js";
import { createAudioRuntime } from "./audio-runtime.js";
import { createSceneCanvas } from "./canvas-view.js";
import {
@@ -51,6 +52,19 @@ const elements = Object.fromEntries(
);
elements.validationCard = document.querySelector(".validation-card");
const layoutSplitPane = createSplitPane({
container: byId("builder-layout"),
separator: byId("builder-split-handle"),
panel: byId("builder-controls"),
panelSide: "end",
storageKey: "micarrsim.builder.control-panel-width",
defaultSize: 430,
minSize: 360,
maxSize: 720,
minMainSize: 560,
enabledMediaQuery: "(min-width: 1101px)",
});
const state = {
scene: null,
selectedSourceId: null,
@@ -173,12 +187,14 @@ function renderSourceCards() {
dot.className = "source-card__dot";
const name = document.createElement("strong");
name.textContent = source.name;
name.title = source.name;
heading.append(dot, name);
const signal = document.createElement("small");
signal.textContent = describeSignal(source.signal);
const position = document.createElement("small");
const motion = Array.isArray(source.trajectory) ? ` · 이동 ${source.trajectory.length}점` : "";
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m${motion}`;
button.title = [source.name, signal.textContent, position.textContent].join("\n");
button.append(heading, signal, position);
button.addEventListener("click", () => {
state.selectedSourceId = source.id;
@@ -369,7 +385,6 @@ function renderAssetList() {
const label = document.createElement("span");
label.className = "asset-chip__label";
label.textContent = `${asset.kind} · ${asset.fileName}`;
label.title = `${asset.id}${asset.sha256 ? ` · ${asset.sha256}` : ""}`;
const availability = document.createElement("span");
availability.className = "asset-chip__state";
availability.textContent = state.assetErrors.has(asset.id)
@@ -384,6 +399,7 @@ function renderAssetList() {
remove.disabled = referenced;
remove.title = referenced ? "먼저 이 asset을 사용하는 image/source/ambient를 변경하세요." : `${asset.fileName} 제거`;
remove.addEventListener("click", () => removeAsset(asset.id));
label.title = [asset.fileName, asset.id, asset.sha256].filter(Boolean).join(" · ");
chip.append(label, availability, remove);
list.append(chip);
}
@@ -1048,6 +1064,7 @@ window.addEventListener("beforeunload", (event) => {
window.addEventListener("unload", () => {
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
audioRuntime.dispose();
layoutSplitPane.destroy();
});
initialize().catch((error) => {
+93 -11
View File
@@ -79,8 +79,10 @@
}
.builder-layout {
--control-panel-width: 430px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(330px, 400px);
grid-template-columns: minmax(0, 1fr) 12px var(--control-panel-width);
min-height: calc(100vh - 65px);
}
@@ -230,6 +232,7 @@
.source-card__heading {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
@@ -244,14 +247,15 @@
}
.source-card strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
overflow-wrap: anywhere;
}
.source-card small {
min-width: 0;
color: var(--muted);
font: 600 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
overflow-wrap: anywhere;
}
.asset-shelf {
@@ -282,16 +286,15 @@
max-width: 100%;
padding: 8px 9px 8px 11px;
border: 1px solid var(--line);
border-radius: 999px;
border-radius: 13px;
background: var(--surface);
}
.asset-chip__label {
overflow: hidden;
max-width: 210px;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
max-width: min(320px, 50vw);
font-size: 0.74rem;
overflow-wrap: anywhere;
}
.asset-chip__state {
@@ -320,6 +323,7 @@
.control-panel {
position: sticky;
top: 65px;
min-width: 0;
align-self: start;
overflow-y: auto;
max-height: calc(100vh - 65px);
@@ -327,6 +331,15 @@
border-left: 1px solid var(--line);
background: rgb(12 24 21 / 86%);
backdrop-filter: blur(18px);
container-name: builder-controls;
container-type: inline-size;
}
.builder-layout > .split-handle {
position: sticky;
top: 59px;
height: calc(100vh - 59px);
align-self: start;
}
.runtime-line {
@@ -335,8 +348,10 @@
}
.save-state {
min-width: 0;
color: var(--muted);
font-size: 0.7rem;
overflow-wrap: anywhere;
}
.control-section,
@@ -538,6 +553,10 @@
gap: 11px;
}
.validation-card > :last-child {
min-width: 0;
}
.validation-dot {
width: 10px;
height: 10px;
@@ -550,6 +569,7 @@
.validation-card strong {
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.validation-card p {
@@ -557,6 +577,7 @@
color: var(--muted);
font-size: 0.72rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.validation-card.is-valid .validation-dot {
@@ -571,7 +592,11 @@
@media (max-width: 1100px) {
.builder-layout {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
.builder-layout > .split-handle {
display: none;
}
.control-panel {
@@ -729,7 +754,6 @@
}
.builder-layout {
grid-template-columns: minmax(0, 1fr) minmax(320px, 370px);
min-height: calc(100vh - 59px);
}
@@ -871,8 +895,19 @@
}
@media (max-width: 1100px) {
.builder-layout {
grid-template-columns: minmax(0, 1fr);
}
.builder-layout > .split-handle {
display: none;
}
.control-panel {
position: static;
max-height: none;
border-top: 1px solid var(--line);
border-left: 0;
}
}
@@ -1003,3 +1038,50 @@
grid-column: 4;
}
}
@container builder-controls (max-width: 440px) {
.field-grid--three {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.trajectory-row {
grid-template-columns: 34px repeat(2, minmax(0, 1fr)) 30px;
grid-template-rows: repeat(2, auto);
}
.trajectory-row__select,
.trajectory-row__remove {
grid-row: 1 / 3;
align-self: stretch;
}
.trajectory-row .field:nth-of-type(1) {
grid-column: 2;
grid-row: 1;
}
.trajectory-row .field:nth-of-type(2) {
grid-column: 3;
grid-row: 1;
}
.trajectory-row .field:nth-of-type(3) {
grid-column: 2;
grid-row: 2;
}
.trajectory-row .field:nth-of-type(4) {
grid-column: 3;
grid-row: 2;
}
.trajectory-row__remove {
grid-column: 4;
}
}
@container builder-controls (max-width: 340px) {
.field-grid--three {
grid-template-columns: minmax(0, 1fr);
}
}
+30 -24
View File
@@ -1,40 +1,46 @@
# Array Geometries
Camera에서 즉시 사용하고 테스트에서 재현할 수 있는 계약 버전 `1.0.0`의 192채널 Array JSON
fixture입니다. 좌표 단위는 m이고 모든 microphone은 배열 평면 `z = 0`에 있습니다. `+x`는 오른쪽,
`+y`는 위, `+z`는 scene 방향입니다.
Acoustic Camera에서 즉시 선택하고 같은 Scene/Experiment로 비교할 수 있는 Array JSON 예제입니다.
모든 파일은 계약 버전 `1.0.0`을 따르며 좌표 단위는 m입니다. microphone은 배열 평면
`z = 0`에 있고, `+x`는 오른쪽, `+y`는 위, `+z`는 scene 방향입니다.
## 포함된 배열
## 비교용 preset
### `rectangular-16x12.array.json`
| 비교 축 | 파일 | 채널 | aperture | x × y pitch |
| --- | --- | ---: | ---: | ---: |
| 채널 수 · 최소 | `rectangular-2x2-450mm.array.json` | 4 | 0.45 × 0.45 m | 450 × 450 mm |
| 채널 수 · 적음 | `rectangular-4x3-450mm.array.json` | 12 | 0.45 × 0.45 m | 150 × 225 mm |
| 채널 수 · 중간 | `rectangular-8x6-450mm.array.json` | 48 | 0.45 × 0.45 m | 64.286 × 90 mm |
| 채널 수·크기 기준 | `rectangular-16x12.array.json` | 192 | 0.45 × 0.45 m | 30 × 40.909 mm |
| 채널 수 · 많음 | `rectangular-24x16-450mm.array.json` | 384 | 0.45 × 0.45 m | 19.565 × 30 mm |
| 물리 크기 · 소형 | `rectangular-16x12-compact.array.json` | 192 | 0.12 × 0.12 m | 8 × 10.909 mm |
| 물리 크기 · 대형 | `rectangular-16x12-large.array.json` | 192 | 1.20 × 1.20 m | 80 × 109.091 mm |
| 배치 패턴 | `fermat-spiral-192.array.json` | 192 | Ø 0.45 m | 비균일 |
- 16 columns × 12 rows = 192 microphones
- 폭과 높이가 각각 0.45 m인 정사각 aperture
- x/y 양 끝을 포함해 균일한 간격으로 배치
- 기본 single-tone experiment와 CPU/WebGPU 기준 fixture가 참조하는 baseline
Camera의 **배열 선택**은 비교 목적에 맞춰 preset을 그룹으로 나눕니다.
### `fermat-spiral-192.array.json`
- 채널 수 효과는 aperture가 모두 0.45 m인 4·12·48·192·384채널 rectangular 배열끼리 비교합니다.
- 물리 크기 효과는 topology와 채널 수가 같은 192채널 compact·baseline·large 배열끼리 비교합니다.
- 배치 패턴 효과는 192채널 0.45 m rectangular baseline과 Fermat spiral을 비교합니다.
- 192 microphones
- 반지름 0.225 m, 지름 0.45 m인 원형 aperture
- golden angle과 면적 균등 반지름으로 만든 Fermat spiral
- rectangular baseline과 sidelobe·집중도·위치 오차를 비교하기 위한 preset
Acoustic Camera의 **비교 배열**에서 두 배열을 선택해 같은 Scene/Experiment 결과를 세션 기록에
나란히 추가할 수 있습니다.
이 preset들은 정확도를 보장하는 제품 사양이 아니라 trade-off를 관찰하기 위한 입력입니다. 채널 간격이
넓은 저채널·대형 배열에서 고주파 spatial aliasing과 grating lobe가 나타나거나, compact 배열에서
main lobe가 넓어지는 것은 예상 가능한 결과입니다. 384채널 배열은 같은 grid에서 계산량과 메모리 사용량도
가장 큽니다. 빠른 동작 확인은 4채널이나 12채널부터 시작하세요.
## fixture 다시 생성하기
저장소 최상위에서 다음 명령을 실행하면 두 JSON을 결정론적으로 다시 생성합니다.
저장소 최상위에서 다음 명령을 실행하면 위 8개 JSON을 결정론적으로 다시 생성합니다.
```powershell
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\generate-array-fixture.ps1
```
이 스크립트는 기존 `rectangular-16x12.array.json`과 `fermat-spiral-192.array.json`을 덮어씁니다.
`ExecutionPolicy Bypass`는 이 PowerShell 프로세스에만 적용됩니다. 생성 뒤
<http://127.0.0.1:4173/tests/>에서 schema와 192채널 배치 검증을 실행하세요.
`ExecutionPolicy Bypass`는 해당 PowerShell 프로세스에만 적용됩니다. 생성 뒤
`test.cmd`로 schema, 채널 수, 좌표 중복, 평면성, grid 행·열 수와 aperture 경계를 검증할 수
있습니다.
사용자 배열은 [`../../schemas/array.schema.json`](../../schemas/array.schema.json) 구조를 따르고
고유한 `id`, aperture와 microphone별 `[x, y, z]`를 가져야 합니다. 선택적인 `sensitivityDb`와
`phaseOffsetDeg`는 합성 channel 편차와 beamforming 역 calibration에 사용됩니다.
사용자 배열은 [array schema](../../schemas/array.schema.json) 구조를 따르고 고유한 `id`,
aperture와 microphone별 `[x, y, z]`를 가져야 합니다. 선택적인 `sensitivityDb`와
`phaseOffsetDeg`는 합성 channel 편차와 beamforming 역 calibration에 사용됩니다. geometry
효과와 calibration 효과를 분리하기 위해 기본 preset에는 이 값을 넣지 않았습니다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.array",
"id": "rectangular-2x2-450mm",
"name": "2x2 sparse rectangular",
"description": "4-channel uniform rectangular layout on the 0.45 m reference aperture",
"units": "m",
"coordinateSystem": {
"handedness": "right",
"xAxis": "right",
"yAxis": "up",
"zAxis": "toward-scene"
},
"aperture": {
"width": 0.45,
"height": 0.45
},
"microphones": [
{
"id": "mic-001",
"position": [
-0.225,
-0.225,
0
]
},
{
"id": "mic-002",
"position": [
0.225,
-0.225,
0
]
},
{
"id": "mic-003",
"position": [
-0.225,
0.225,
0
]
},
{
"id": "mic-004",
"position": [
0.225,
0.225,
0
]
}
]
}
@@ -0,0 +1,116 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.array",
"id": "rectangular-4x3-450mm",
"name": "4x3 sparse rectangular",
"description": "12-channel uniform rectangular layout on the 0.45 m reference aperture",
"units": "m",
"coordinateSystem": {
"handedness": "right",
"xAxis": "right",
"yAxis": "up",
"zAxis": "toward-scene"
},
"aperture": {
"width": 0.45,
"height": 0.45
},
"microphones": [
{
"id": "mic-001",
"position": [
-0.225,
-0.225,
0
]
},
{
"id": "mic-002",
"position": [
-0.075,
-0.225,
0
]
},
{
"id": "mic-003",
"position": [
0.075,
-0.225,
0
]
},
{
"id": "mic-004",
"position": [
0.225,
-0.225,
0
]
},
{
"id": "mic-005",
"position": [
-0.225,
0,
0
]
},
{
"id": "mic-006",
"position": [
-0.075,
0,
0
]
},
{
"id": "mic-007",
"position": [
0.075,
0,
0
]
},
{
"id": "mic-008",
"position": [
0.225,
0,
0
]
},
{
"id": "mic-009",
"position": [
-0.225,
0.225,
0
]
},
{
"id": "mic-010",
"position": [
-0.075,
0.225,
0
]
},
{
"id": "mic-011",
"position": [
0.075,
0.225,
0
]
},
{
"id": "mic-012",
"position": [
0.225,
0.225,
0
]
}
]
}
@@ -0,0 +1,404 @@
{
"schemaVersion": "1.0.0",
"kind": "micarrsim.array",
"id": "rectangular-8x6-450mm",
"name": "8x6 rectangular",
"description": "48-channel uniform rectangular layout on the 0.45 m reference aperture",
"units": "m",
"coordinateSystem": {
"handedness": "right",
"xAxis": "right",
"yAxis": "up",
"zAxis": "toward-scene"
},
"aperture": {
"width": 0.45,
"height": 0.45
},
"microphones": [
{
"id": "mic-001",
"position": [
-0.225,
-0.225,
0
]
},
{
"id": "mic-002",
"position": [
-0.160714,
-0.225,
0
]
},
{
"id": "mic-003",
"position": [
-0.096429,
-0.225,
0
]
},
{
"id": "mic-004",
"position": [
-0.032143,
-0.225,
0
]
},
{
"id": "mic-005",
"position": [
0.032143,
-0.225,
0
]
},
{
"id": "mic-006",
"position": [
0.096429,
-0.225,
0
]
},
{
"id": "mic-007",
"position": [
0.160714,
-0.225,
0
]
},
{
"id": "mic-008",
"position": [
0.225,
-0.225,
0
]
},
{
"id": "mic-009",
"position": [
-0.225,
-0.135,
0
]
},
{
"id": "mic-010",
"position": [
-0.160714,
-0.135,
0
]
},
{
"id": "mic-011",
"position": [
-0.096429,
-0.135,
0
]
},
{
"id": "mic-012",
"position": [
-0.032143,
-0.135,
0
]
},
{
"id": "mic-013",
"position": [
0.032143,
-0.135,
0
]
},
{
"id": "mic-014",
"position": [
0.096429,
-0.135,
0
]
},
{
"id": "mic-015",
"position": [
0.160714,
-0.135,
0
]
},
{
"id": "mic-016",
"position": [
0.225,
-0.135,
0
]
},
{
"id": "mic-017",
"position": [
-0.225,
-0.045,
0
]
},
{
"id": "mic-018",
"position": [
-0.160714,
-0.045,
0
]
},
{
"id": "mic-019",
"position": [
-0.096429,
-0.045,
0
]
},
{
"id": "mic-020",
"position": [
-0.032143,
-0.045,
0
]
},
{
"id": "mic-021",
"position": [
0.032143,
-0.045,
0
]
},
{
"id": "mic-022",
"position": [
0.096429,
-0.045,
0
]
},
{
"id": "mic-023",
"position": [
0.160714,
-0.045,
0
]
},
{
"id": "mic-024",
"position": [
0.225,
-0.045,
0
]
},
{
"id": "mic-025",
"position": [
-0.225,
0.045,
0
]
},
{
"id": "mic-026",
"position": [
-0.160714,
0.045,
0
]
},
{
"id": "mic-027",
"position": [
-0.096429,
0.045,
0
]
},
{
"id": "mic-028",
"position": [
-0.032143,
0.045,
0
]
},
{
"id": "mic-029",
"position": [
0.032143,
0.045,
0
]
},
{
"id": "mic-030",
"position": [
0.096429,
0.045,
0
]
},
{
"id": "mic-031",
"position": [
0.160714,
0.045,
0
]
},
{
"id": "mic-032",
"position": [
0.225,
0.045,
0
]
},
{
"id": "mic-033",
"position": [
-0.225,
0.135,
0
]
},
{
"id": "mic-034",
"position": [
-0.160714,
0.135,
0
]
},
{
"id": "mic-035",
"position": [
-0.096429,
0.135,
0
]
},
{
"id": "mic-036",
"position": [
-0.032143,
0.135,
0
]
},
{
"id": "mic-037",
"position": [
0.032143,
0.135,
0
]
},
{
"id": "mic-038",
"position": [
0.096429,
0.135,
0
]
},
{
"id": "mic-039",
"position": [
0.160714,
0.135,
0
]
},
{
"id": "mic-040",
"position": [
0.225,
0.135,
0
]
},
{
"id": "mic-041",
"position": [
-0.225,
0.225,
0
]
},
{
"id": "mic-042",
"position": [
-0.160714,
0.225,
0
]
},
{
"id": "mic-043",
"position": [
-0.096429,
0.225,
0
]
},
{
"id": "mic-044",
"position": [
-0.032143,
0.225,
0
]
},
{
"id": "mic-045",
"position": [
0.032143,
0.225,
0
]
},
{
"id": "mic-046",
"position": [
0.096429,
0.225,
0
]
},
{
"id": "mic-047",
"position": [
0.160714,
0.225,
0
]
},
{
"id": "mic-048",
"position": [
0.225,
0.225,
0
]
}
]
}
+110 -14
View File
@@ -11,8 +11,9 @@ Snapshot 한 번의 처리 순서는 다음과 같다.
1. tone, noise 또는 audio asset으로 음원 신호를 만든다.
2. 음원과 각 마이크 사이의 직접음 또는 희소 multipath 전달함수를 만든다.
3. 마이크별 `sensitivityDb`, `phaseOffsetDeg`와 `frequencyResponse`를 수신 신호에 적용한다.
4. 합성된 동기식 다채널 신호를 STFT하고 conventional DAS map을 계산한다.
5. 설정에 따라 알려진 마이크 응답을 DAS에서 역보정하고 peak와 평가 지표를 구한다.
4. 합성된 동기식 다채널 신호를 STFT하고 `beamforming.algorithm`으로 선택한 위치 추정
알고리즘의 scan map을 계산한다.
5. 알고리즘에 맞게 알려진 마이크 응답을 보정하고 map의 peak와 평가 지표를 구한다.
Scene의 `acoustics`를 생략하거나 `model: "free-field"`로 지정하면 기존 직접음 결과를
유지한다. `model: "shoebox"`는 직육면체 방 안에서 경로 수가 제한된 근사를 추가한다.
@@ -97,9 +98,91 @@ preset을 적용한다.
각 source–microphone 직접 경로를 실제로 막을 때 하나씩 추가된다. 대략적인 합성 경로 예산은
`음원 수 × 마이크 수 × 수신점당 최대 경로`다. Camera는 실행 전에 이 예산을 표시한다.
반사·tail을 늘리면 beamforming의 `O(scan points × microphones × selected bins × STFT frames)`
뿐 아니라 신호 합성의 전달함수 누산도 늘어난다. 처음에는 50×50 grid와 자유음장으로 기준선을
만들고, 같은 입력에서 preset을 하나씩 바꾸어 비교하는 것이 좋다.
반사·tail을 늘리면 신호 합성의 전달함수 누산도 늘어난다. DAS 계열의 scan 누산은 대략
`O(scan points × microphones × selected bins × STFT frames)`이고, GCC-PHAT는 pair별 상호
상관을 만든 뒤 `O(scan points × (microphones - 1) × bands)` lookup을 수행한다. 어느 방식이든
STFT와 거리 cache 비용은 별도로 필요하다. 처음에는 50×50 grid와 자유음장으로 기준선을 만들고,
같은 입력에서 환경과 알고리즘을 하나씩 바꾸어 비교하는 것이 좋다.
## 위치 추정 알고리즘
마이크 `m`, STFT frame `t`, 주파수 `f`의 복소 spectrum을 `X_m(t, f)`, scan point `p`와
마이크 사이 거리를 `r_m(p)`, 파수를 `k = 2πf/c`라고 하자. `φ_m(f)`는 알려진 마이크의 정적
phase offset과, 주파수 응답 보정을 켠 경우 response phase를 합한 값이다. 모든 map은 scan point
마다 하나의 비음수 상대값을 만들지만, 그 값은 위치의 확률이나 posterior가 아니다.
### DAS (`das`)
Conventional frequency-domain delay-and-sum은 각 후보점에서 구면파 전파 위상을 되돌려 채널을
coherent sum한다.
```text
B_DAS(p,t,f) = (1/M) Σ_m A_m⁻¹(f) X_m(t,f) exp(j[k r_m(p) - φ_m(f)])
P_DAS(p) = mean_(t,f) |B_DAS(p,t,f)|²
```
`A_m⁻¹(f)`에는 sensitivity의 역 gain과, 설정한 경우 frequency-response gain의 제한된 역보정이
들어간다. 구현은 frame별 제곱 크기를 먼저 평균하고 선택 band의 bin들을 다시 평균한다. 따라서
Result의 `rawMap.scale`은 `power`지만, 절대 SPL로 calibration한 power가 아니라 같은 입력·설정
안에서 비교하는 선형 상대 power다. CPU와 WebGPU를 지원한다.
### DAS-PHAT (`das-phat`)
DAS-PHAT는 steering 전에 각 frame·bin·마이크 spectrum을 위상만 남기도록 정규화한다.
```text
U_m(t,f) = X_m(t,f) / |X_m(t,f)|
S_DAS-PHAT(p) = mean_(t,f) |(1/M) Σ_m U_m(t,f) exp(j[k r_m(p) - φ_m(f)])|²
```
한 frame·bin에서 최강 마이크 크기의 기본 `10⁻⁶`배인 상대 floor 이하의 값은 `0`으로 두어
무음이나 극소값의 불안정한 위상을 제외한다. 입력과 calibration의 amplitude gain은 coherent sum에 사용하지 않고,
알려진 phase만 보정한다. 그 결과 source spectrum이나 채널 gain 차이에 덜 끌릴 수 있지만 유용한
amplitude 정보도 버리며, 약한 bin의 위상과 multipath에 민감할 수 있다. `rawMap.scale`은 무차원
`score`이고 CPU와 WebGPU를 지원한다.
### GCC-PHAT (`gcc-phat`)
현재 GCC-PHAT는 배열 중심에서 가장 먼 마이크 하나를 결정적인 reference로 고르고, reference와
나머지 `M - 1`개 마이크의 pair만 사용한다. 각 band와 pair에 대해 다음 PHAT cross spectrum을
STFT frame에 걸쳐 평균한다.
```text
C_mr(t,f) = X_m(t,f) conj(X_r(t,f))
G_mr(f) = mean_t [C_mr(t,f) / max(|C_mr(t,f)|, floor)]
× exp(j[φ_r(f) - φ_m(f)])
```
기본 `floor`는 해당 band·pair의 모든 선택 bin과 frame에서 얻은 최대 cross-spectrum 크기의
`10⁻⁶`배다. 이 floor는 amplitude를 score 가중치로 쓰기 위한 것이 아니라 극소 cross spectrum의
불안정한 위상 정규화를 제한한다.
선택 band 밖의 bin은 `0`으로 두고 켤레 대칭 spectrum을 IFFT하여 원형 상호상관
`R_mr,b(ℓ)`을 만든다. scan point마다 far-field 각도 근사가 아니라 정확한 거리 차이로 near-field
예측 lag를 계산한다.
```text
ℓ_mr(p) = [r_m(p) - r_r(p)] sampleRate / c
Q_b(p) = Σ_(m≠r) R_mr,b(ℓ_mr(p))
K_b = I(0 ∈ b) + I(N/2 ∈ b) + 2 × #(나머지 positive bins)
S_b(p) = clamp[N Q_b(p) / ((M-1) K_b), 0, 1]
```
분수 sample lag는 IFFT 원형 상호상관의 이웃 두 sample을 선형 보간한다. Hermitian coefficient
수 `K_b`, IFFT의 `1/N`, reference pair 수로 나눈 뒤 음의 상관은 0으로 두어 score를 0–1로
제한한다. 따라서 같은 위치 가설의 raw GCC score는 scan 범위나 마이크 수 자체에 의해 재척도화되지
않는다. 이 값도 확률이나 calibration된 confidence는 아니다. `rawMap.scale`은 `score`다.
PHAT가 amplitude를 제거하므로 sensitivity와 frequency-response gain은 역보정하지 않고 알려진
phase만 보정한다. 현재 CPU만 지원하고 두 개 이상의 마이크가 필요하다.
GCC-PHAT는 broadband 신호에서 상호상관 peak가 뚜렷할수록 유리하다. 단일 tone이나 지나치게 좁은
band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가 비슷한 score를 얻을 수 있고,
반사음이나 여러 동시 음원은 pair 상관 peak를 섞는다. IFFT 길이가 `N = fftSize`이면 표현 가능한
비중첩 원형 lag는 절대값 `N/2` sample까지다. 현재 구현은 scan 전체의 최대 기하학적 lag가 이를
넘으면 실행을 거절하므로 FFT를 키우거나 array aperture 또는 scan 범위를 줄여야 한다. 이 검사는
원형 lag alias를 막지만 좁은 대역의 주기적 모호성, 제한된 bandwidth, scan grid 양자화까지
해결하지는 않는다. 단일 reference 방식은 모든 마이크 쌍을 쓰는 GCC보다 계산량이 작지만 pair
중복성과 고장 마이크에 대한 강건성도 작다.
## 마이크 주파수 응답
@@ -126,11 +209,17 @@ preset을 적용한다.
보간하고 측정 범위 밖에서는 가장 가까운 끝점 값을 유지한다. 합성기는 이 curve를 실제 마이크
채널에 적용한다.
Experiment에서 `beamforming.compensateFrequencyResponse`를 생략하거나 `true`로 두면
CPU와 WebGPU DAS가 선택 FFT bin에서 알려진 curve의 역 gain/phase를 적용한다. 응답 notch의
무제한 역보정은 noise까지 크게 증폭할 수 있으므로 양의 보상 boost는
`beamforming.maxCompensationDb`로 제한하며 기본값은 24 dB다. 허용 범위는 0–120 dB이고
응답 peak를 줄이는 attenuation과 phase 보상은 이 boost 상한에 의해 잘리지 않는다.
합성기는 알고리즘과 무관하게 sensitivity, 정적 phase offset과 frequency-response curve를 수신
channel에 적용한다. 위치 추정에서의 역보정은 알고리즘별로 다르다.
- DAS는 sensitivity 역 gain과 정적 phase를 항상 보정한다. `compensateFrequencyResponse`를
생략하거나 `true`로 두면 response의 역 gain과 phase도 보정한다. 응답 notch의 무제한 gain
boost는 noise까지 키울 수 있으므로 `maxCompensationDb`로 제한하며 기본값은 24 dB, 허용 범위는
0–120 dB다. 응답 peak를 줄이는 attenuation과 phase 보상에는 이 boost 상한을 적용하지 않는다.
- DAS-PHAT와 GCC-PHAT는 amplitude를 정규화하므로 sensitivity와 response gain의 역보정을
사용하지 않는다. 정적 phase는 항상 보정하고, `compensateFrequencyResponse`가 `true`이면 알려진
response phase도 보정한다. 따라서 `maxCompensationDb`는 두 PHAT 알고리즘의 score에는 영향을
주지 않는다.
```json
{
@@ -147,8 +236,10 @@ CPU와 WebGPU DAS가 선택 FFT bin에서 알려진 curve의 역 gain/phase를
```
Camera의 **마이크 주파수 응답 처리**에서 **측정 curve 적용 + 보정하지 않음**을 고르면 합성에는
curve가 남고 DAS 역보정만 꺼진다. 같은 배열에서 보정 유무의 robustness를 비교할 때 사용한다.
curve가 없는 마이크는 0 dB, 0°로 취급한다.
curve가 남고 위치 추정의 response gain/phase 역보정만 꺼진다. DAS-PHAT와 GCC-PHAT는 여전히
amplitude를 버리지만, curve가 신호 대 잡음과 relative floor 통과 여부에 준 영향까지 되돌리지는
못한다. 같은 배열에서 알려진 response 보정 유무의 robustness를 비교할 때 사용한다. curve가 없는
마이크는 0 dB, 0°로 취급한다.
## 결과에서 확인할 값
@@ -162,6 +253,10 @@ Result `signalStats`에는 다음 선택 필드가 들어간다.
같은 입력과 seed에서 환경 preset만 바꿨다면 이 값과 위치 오차·precision/recall·PSR을 함께
비교할 수 있다.
Result 최상위 `algorithm`은 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 기록한다. band의
`rawMap.scale`은 DAS에서 `power`, 두 PHAT 방식에서 `score`다. peak 정규화된 `displayMap`은
시각 비교용일 뿐 어느 scale도 확률분포나 신뢰구간으로 바꾸지 않는다.
## 정확도 범위와 제외 사항
이 구현은 FDTD/FEM/BEM이나 room impulse response 측정치를 대신하지 않는다. 다음 항목은
@@ -175,8 +270,9 @@ Result `signalStats`에는 다음 선택 필드가 들어간다.
- preamp, anti-alias filter, ADC quantization, channel clock jitter·drift
- 실제 장착 구조와 마이크 위치 공차
따라서 preset의 RT60이나 wall gain은 특정 방을 인증하는 값이 아니라 배열과 DAS가 multipath에
얼마나 민감한지 비교하는 조건이다. 실제 장치 성능을 평가할 때는 같은 Array/scan/band 설정으로
따라서 preset의 RT60이나 wall gain은 특정 방을 인증하는 값이 아니라 배열과 위치 추정 알고리즘이
multipath에 얼마나 민감한지 비교하는 조건이다. 실제 장치 성능을 평가할 때는 같은
Array/scan/band 설정으로
동기식 실측 채널을 [실시간 위치추정 코어](realtime-porting.md)에 입력하고 합성 결과와 나란히
비교해야 한다. 주파수 응답 curve만으로 noise floor, distortion, ADC와 clock 오차까지 모사되지는
않는다.
+14 -1
View File
@@ -1,4 +1,4 @@
# WebGPU 기준 실측
# WebGPU DAS 기준 실측
2026-08-28에 Chrome 151 headless, Intel Gen9 adapter에서 synthetic 1 kHz fixture를 측정했다. 입력은
48 kHz · 0.25 s, 192 microphones, FFT 512 / hop 256, 900–1100 Hz band이며 16×16 warm-up 뒤 각
@@ -10,6 +10,19 @@
| 100×100 | 176,640,000 | 251.4 ms | 2,102.0 ms | 36.91 MiB | 3.17× |
| 200×200 | 706,560,000 | 891.9 ms | 4,206.7 ms | 80.47 MiB | 11.26× |
이 표는 conventional DAS의 저장된 기준 측정이며 DAS-PHAT이나 GCC-PHAT의 실측값이 아니다.
50×50에서 CPU STFT median은 1,669.8 ms로 `end-to-end` 1,818.1 ms의 약 91.8%였고,
실제 GPU queue 구간은 79.2 ms였다. 별도로 한 번 수행한 192채널 signal 합성은 1,200.5 ms이며
위 `end-to-end` 열에 포함되지 않는다. 따라서 이 fixture에서는 scan MAC만 줄인 비율을 전체
Camera 실행 시간 향상으로 해석하면 안 된다.
같은 192채널·50×50·46 frames·2 bins·한 band를 식으로만 비교하면 DAS scan은
44,160,000 complex MAC이다. 현재 single-reference GCC-PHAT은 공통 forward STFT 뒤
35,144회의 2-pass cross-spectrum 연산, 191회의 inverse FFT와 477,500회의 point/pair
lookup을 수행한다. 이는 구현 연산량 비교이지 GCC 실측 benchmark가 아니며, 2 bins는 GCC-PHAT에
너무 좁어 주기적 지연 모호성이 크다. 정확도·시간 비교에는 band마다 8 bins 이상의 광대역 fixture와
같은 입력·장치·warm-up 조건을 사용해야 한다.
`GPU wall`은 host packing/`writeBuffer()` 호출 직후부터 queue에 들어간 transfer 완료, steering pass,
DAS pass와 readback까지 기다린 시간이다. `end-to-end`에는 이 host 작업과 장치/pipeline 초기화,
distance 생성, CPU STFT가 포함된다. 200×200은 64 MiB steering buffer 목표를 지키면서 두 FFT bin을
+78 -19
View File
@@ -8,7 +8,7 @@ micarrsim의 저장 형식은 JSON Schema Draft 2020-12와 추가 관계 검증
- `micarrsim.array`: aperture와 마이크별 `[x, y, z]` 좌표
- `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest
- `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
- `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 위치 추정 설정
- `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보
- `micarrsim.comparison-history`: 세션의 배열별 Result 요약을 모은 편의 export. core schema
검증 대상은 아니며 각 원본 Result JSON을 대신하지 않는다.
@@ -18,17 +18,41 @@ scene JSON에는 192채널 원시 sample이나 원본 asset byte를 넣지 않
`sha256` manifest로 참조한다. 사용자 컴퓨터의 절대 경로, URL, `..` 경로는 저장하지 않는다.
array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에 적용되는 편차다. 합성기는
이를 신호에 주입하고, beamformer는 알려진 calibration 값의 역 gain/phase를 적용한다. 선택적
이를 신호에 주입하고, 위치 추정기는 선택한 알고리즘의 규칙에 따라 알려진 calibration을 보정한다.
선택적
`frequencyResponse`는 2–64개의 `{ frequencyHz, gainDb, phaseDeg? }` 측정점이다.
주파수는 0–1,000,000 Hz에서 중복 없이 증가해야 하며, dB와 wrapped phase를 구간 선형 보간하고
측정 범위 밖에서는 가까운 끝점을 사용한다.
Experiment의 선택적 `beamforming.compensateFrequencyResponse`는 기본 `true`다. 합성
channel에는 curve가 항상 적용되며, `true`이면 CPU/WebGPU DAS가 선택 bin에서 curve의
역 gain/phase도 적용한다. `false`이면 sensitivity/phase calibration은 유지하고 curve
역보정만 끈다. 선택적 `beamforming.maxCompensationDb`는 응답 notch의 역보정에서 양의
gain boost만 제한하는 0–120 dB 값이며 생략 시 24 dB다. 응답 peak를 줄이는 attenuation과
phase 보상은 이 boost 상한을 그대로 적용하지 않는다.
Array Builder의 사각형/타원 생성 경계, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백,
snap과 zoom은 편집기 상태이며 `micarrsim.array` 계약에 포함되지 않는다. 저장 파일에는
`aperture.width/height`와 최종 `microphones` 좌표·순서만 들어간다. 따라서 다른 도구는
형상 이름을 추론하지 않고 실제 좌표를 배열 geometry의 기준으로 사용해야 한다.
Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`다. backend와
raw map 계약은 다음과 같다.
| algorithm | `rawMap.scale` | 허용 backend |
| --- | --- | --- |
| `das` | `power` | `cpu`, `webgpu`; `auto` 가능 |
| `das-phat` | `score` | `cpu`, `webgpu`; `auto` 가능 |
| `gcc-phat` | `score` | `cpu`; `auto`는 CPU 선택 |
`gcc-phat`에 명시적 `webgpu`를 조합하면 검증 오류이며, 마이크가 두 개 이상이어야 한다. 필드
그룹 이름 `beamforming`은 기존 계약과의 호환을 위해 유지하지만 세 알고리즘을 모두 선택하는 위치
추정 설정이다.
선택적 `beamforming.compensateFrequencyResponse`는 기본 `true`다. 합성 channel에는 curve가
항상 적용된다. DAS는 sensitivity와 정적 phase를 항상 역보정하고, 값이 `true`이면 선택 bin에서
curve의 gain과 phase도 역보정한다. DAS-PHAT와 GCC-PHAT는 입력과 calibration의 amplitude gain을
사용하지 않지만 정적 phase를 보정하며, 값이 `true`이면 알려진 curve phase도 보정한다. `false`는
모든 알고리즘에서 curve 보정만 끄고 정적 phase 보정은 유지한다.
선택적 `beamforming.maxCompensationDb`는 DAS가 응답 notch를 역보정할 때 양의 gain boost만
제한하는 0–120 dB 값이며 생략 시 24 dB다. 응답 peak를 줄이는 attenuation과 phase 보상은 이
boost 상한의 대상이 아니다. amplitude를 사용하지 않는 DAS-PHAT와 GCC-PHAT에는 이 값이 영향을
주지 않는다. 세 알고리즘의 계산식과 한계는
[음향 환경과 마이크 응답 모델](acoustic-model.md)에 설명한다.
## 로컬 asset 재연결
@@ -80,24 +104,55 @@ room 안의 min/max, 고유 id, -120–0 dB의 `transmissionDb`와
gain을 보존하고, `peak`는 합성 전 peak를 `targetPeak`에 맞춘다. result의 `signalStats`에는
정규화 전 peak, 실제 scale, scale 적용 후에도 범위를 넘은 sample 수를 기록한다.
이는 acoustic map 표시 정규화와 별개다. result의 `rawMap`은 반드시 선형 power이고,
`displayMap`은 peak-normalized 값 또는 dB 값일 수 있다. 후처리 결과는 `processedMaps`에 넣어
raw DAS map을 덮어쓰지 않는다.
이는 acoustic map 표시 정규화와 별개다. result의 `rawMap.scale`은 알고리즘의 원래 비음수 map을
뜻하는 `power` 또는 `score`다. DAS의 `power`는 절대 SPL로 calibration한 값이 아니며,
DAS-PHAT와 GCC-PHAT의 `score`도 확률, likelihood 또는 confidence가 아니다. 합이 1이 되도록
정규화되지 않으며 서로 다른 scale이나 알고리즘의 절대값을 직접 비교해서는 안 된다.
GCC-PHAT raw score는 pair 수·band의 Hermitian coefficient 수·IFFT 크기로 나눈 0–1 양의
상관 응답이므로 scan 범위를 바꿔도 같은 위치 가설의 척도가 유지된다. 그래도 확률 calibration은
아니며 DAS-PHAT score와 절대값을 비교하는 용도가 아니다.
`displayMap`은 원래 scale, peak-normalized `normalized` 또는 `db`일 수 있다. peak 정규화는
최댓값을 1로 만드는 표시 변환일 뿐 확률 정규화가 아니다. `processedMaps`는 `damas`, `clean-sc`
또는 `custom` 후처리 결과를 별도로 교환하기 위한 예약 슬롯이다. 현재 위치 추정 코어는 DAMAS나
CLEAN-SC를 실행하거나 이 필드를 자동 생성하지 않으며, raw map을 후처리 map으로 덮어쓰지 않는다.
map은 row-major 배열이다. 첫 행은 `yMax`, 마지막 행은 `yMin`, 각 행은 `xMin`에서 `xMax`
방향이다. `values.length`는 반드시 `rows × columns`와 같아야 한다.
Experiment의 선택적 `evaluation.detectionThresholdRatio`는 최강 local peak power에 대한 검출
임계 비율이며 `0 < ratio <= 1`이다. 생략된 이전 문서를 Camera에서 열면 기본값 `0.25`를 적용하며,
Experiment JSON을 다시 저장하면 이 값이 명시된다. 값을 낮추면 약한 peak 후보를 포함하지만
Experiment의 선택적 `evaluation.detectionThresholdRatio`는 최강 local peak의 같은-scale raw
값에 대한 검출 임계 비율이며 `0 < ratio <= 1`이다. 생략된 이전 문서를 Camera에서 열면 기본값
`0.25`를 적용하며, Experiment JSON을 다시 저장하면 이 값이 명시된다. 값을 낮추면 약한 peak 후보를 포함하지만
sidelobe false positive도 늘 수 있다.
## Result 평가 정보
각 band의 `peaks`는 위치와 power 값, 선택적 `sourceId`, `localizationErrorM`,
`localizationErrorPixels`, `beamSpread`를 저장한다. `beamSpread`는 선택 peak를 포함하는 연결된
half-power 성분의 power 가중 centroid·공분산과 major/minor RMS 반경·각도다. `calibrated`는 현재
항상 `false`이며 확률분포, Gaussian 신뢰구간 또는 통계적 위치 불확실성으로 해석하지 않는다.
새 Result는 최상위 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 기록하며
Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result를 읽기 위해 schema상 선택적으로
남아 있지만 현재 export에는 항상 들어간다. `backend`는 요청값 `auto`가 아니라 실제 사용한
`cpu` 또는 `webgpu`다.
관계 검증은 `das → power`, `das-phat/gcc-phat → score`를 강제한다. Peak에 `beamSpread`가
있으면 power map은 power-weighted method, score map은 score-weighted method여야 한다. 따라서
구조상 유효하지만 의미가 모순된 외부 Result도 import 시 거절된다.
`timingMs.localization`은 선택한 위치 추정 알고리즘의 wall time이다. 현재 export는 기존 DAS 전용
소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.0.0` schema는 이전
문서를 위해 `beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을
함께 저장해야 한다. `timingMs.total`은 전체 시간이고 선택적 `signalGeneration`은 합성 시간이다.
각 band의 `peaks`는 위치와 해당 `rawMap.scale`의 값, 선택적 `sourceId`, `localizationErrorM`,
`localizationErrorPixels`, `beamSpread`를 저장한다. `beamSpread`는 선택 peak를 포함하는 8-connected
성분의 가중 centroid·공분산과 major/minor RMS 반경·각도다.
- `power` map은 `connected-half-power-power-weighted-covariance`를 사용해 peak의
`halfPowerRatio` 이상인 연결 성분을 power로 가중한다.
- `score` map은 `connected-half-maximum-score-weighted-covariance`를 사용해 peak의 같은 비율
이상인 연결 성분을 score로 가중한다. 호환 필드명 `halfPowerRatio`는 이 경우 실제로
half-maximum score 비율을 뜻한다.
두 방식 모두 `calibrated`는 현재 항상 `false`다. covariance ellipse는 map의 공간적 퍼짐을
결정적으로 요약할 뿐 확률분포, Gaussian 신뢰구간 또는 통계적 위치 불확실성이 아니다.
`metrics`에는 다음 유한값만 기록한다.
- `peakToSidelobeDb`
@@ -105,6 +160,9 @@ half-power 성분의 power 가중 centroid·공분산과 major/minor RMS 반경
- `precision`, `recall`
- `mainLobeWidthM`
호환 필드명 `energyInsideTruthRatio`는 `power` map에서는 truth 주변 power 비율, `score` map에서는
truth 주변 score 비율이다. 후자의 값은 물리적 energy 비율이나 위치 확률이 아니다.
`evaluation`은 지표를 다시 해석할 수 있도록 truth 반경, source 매칭 tolerance, peak 검출
threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic range, 최대 검출 수를
버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z,
@@ -146,7 +204,8 @@ CPU 분석 계획은 기본 512 MiB, 반복 steering cache는 64 MiB, streaming
256 MiB hard cap을 할당 전에 적용한다. 채널 순서와 streaming 상태 계약은
[실측 다채널 위치추정 코어 포팅](realtime-porting.md)을 참고한다.
portable block 결과의 `processingTimeMs`는 DAS와 local-peak 검출 wall time이다. Streaming
portable block 결과의 `processingTimeMs`는 선택한 위치 추정 알고리즘과 local-peak 검출 wall
time이다. Streaming
결과는 `deadlineMs`와 `deadlineMet`을 추가하고 상태에는 최근·최대 처리 시간,
`deadlineMisses`와 `droppedSamples`를 누적한다. 이 runtime telemetry는
`micarrsim.result` 저장 스키마의 필수 필드가 아니다.
+41 -23
View File
@@ -2,8 +2,8 @@
> 구현 상태 · 2026-08-28
>
> 단계 0–4와 단계 5의 저비용 음향 환경·마이크 응답·portable streaming core까지 완료했다.
> 설치 없는 브라우저 앱, CPU/WebGPU DAS, Builder→Camera asset 재연결, 임의 오디오·이동
> 단계 0–4와 단계 5의 저비용 음향 환경·마이크 응답·다중 알고리즘 portable streaming core까지 완료했다.
> 설치 없는 브라우저 앱, CPU/WebGPU DAS·DAS-PHAT와 CPU GCC-PHAT, Builder→Camera asset 재연결, 임의 오디오·이동
> trajectory, 빔 분포·복수 peak 감도, 라이브 시간축 재생, 배열 비교와 Result 내보내기를
> 전체 자동 테스트로 검증했다. 실제 장치 adapter와 현장 검증은 장치 계약이 필요한 후속 범위다.
@@ -15,7 +15,7 @@
1. 기계음 데이터와 ambient audio를 조합해 장면 및 ground truth를 생성한다.
2. 배열 JSON과 장면 정보를 사용해 각 마이크에 도달하는 신호를 합성한다.
3. WGSL로 conventional delay-and-sum(DAS) 빔포밍을 수행한다.
3. DAS, DAS-PHAT 또는 GCC-PHAT로 위치 map을 계산하고 같은 입력에서 비교한다.
4. 주파수 구간별 acoustic image를 ground truth 위에 표시하고 정량 평가한다.
5. 배열, 거리, 음원, 주파수, grid 해상도를 바꾼 실험을 저장하고 비교한다.
@@ -37,13 +37,15 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
때만 동일 스키마를 쓰는 별도 CLI를 추가한다. 이 방식은 초기 복잡도를 줄이면서 확장 경로를
남긴다.
두 앱의 책임은 다음과 같이 분리한다.
세 앱의 책임은 다음과 같이 분리한다.
- `array-builder`: aperture 크기와 사각/타원 생성 경계, 격자·선형·둘레·Fermat·seed random
프리셋, 마이크 직접 편집·채널 순서·보정 보존과 표준 Array JSON 내보내기
- `dataset-builder`: 음원 배치, gain/시간 구간/반복 설정, ambient mix, 이미지상의 위치 지정,
seed 고정, 합성 오디오와 ground truth manifest 내보내기
- `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성,
DAS 실행, overlay·수치 평가·실험 결과 내보내기
- `acoustics-core`: 두 앱이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답,
- `acoustics-core`: 앱들이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답,
STFT, 정답 rasterization, 지표, CPU reference와 실측 channel용 streaming localizer
- `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark
@@ -52,7 +54,7 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
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`: 배경 이미지 크기와 실제 평면 변환, 음원별 위치·오디오 참조·시작/종료·
gain, ambient 참조, sample rate, seed, 선택적 자유음장·shoebox 환경
@@ -79,17 +81,21 @@ tap 수가 제한된 RT60 tail만 추가한다. 경로를 source–microphone
DAS는 STFT 후 각 scan point와 마이크 사이 거리로 steering phase를 만들고, 선택된 주파수 bin의
정렬 합 power를 계산한다. 여러 주파수 구간은 각각 독립 map과 색을 갖는다. CPU reference를 먼저
작성한 뒤 같은 buffer layout으로 WGSL 결과를 대조한다. DAMAS, CLEAN-SC는 인터페이스만 마련하고
초기 완료 범위에서는 제외한다.
작성한 뒤 같은 buffer layout으로 WGSL 결과를 대조한다. DAS-PHAT은 각 frame/bin에서 마이크
spectrum의 진폭을 제거하되 알려진 응답 phase를 보정한다. GCC-PHAT은 한 reference와 나머지
마이크의 band-limited PHAT 상관을 IFFT하고 scan point의 near-field 예상 지연에서 보간한다.
DAMAS, CLEAN-SC는 초기 완료 범위에서 제외한다.
연산량의 주항은 대략 `O(P × M × F × T)`다. `M=192`, `P=50²=2,500`일 때 STFT
frame·주파수 bin 하나당
약 48만 개의 복소 steering 누산이 필요하다. 100×100은 50×50보다 4배, 200×200은 16배이므로
장치별 실제 GPU timing을 UI에 표시하고 목표 frame budget에 맞춰 해상도를 제한한다. 정확한 성능과
메모리는 대표 GPU에서 benchmark하여 기록하며 이론치만으로 약속하지 않는다.
`P` points, `M` microphones, `T__ frames, `F` selected bins에서 DAS 계열 scan은
`O(P × M × T × F)`다. GCC-PHAT은 공통 forward STFT 외에 2-pass cross spectrum
`2(M-1)TΣF_b`, `(M-1)B` inverse FFT와 `P(M-1)B` lookup을 수행한다.
192채널·50×50·46 frames·2 bins의 DAS는 44,160,000 complex MAC이고 같은 한 band
GCC scan은 477,500 lookup이다. 다만 작은 grid에서는 공통 STFT가 지배할 수 있으므로 이 비율을
end-to-end 속도 향상으로 해석하지 않는다. 100×100은 50×50보다 4배, 200×200은 16배 points이며
장치별 실제 timing과 메모리를 기록하고 이론치만으로 실시간성을 약속하지 않는다.
실측·반복 분석용 CPU 계획은 거리 행렬, bin calibration, 임시 map과 허용되는 steering matrix를
재사용한다. 192채널·50×50·2 bins를 10회 분석하는 기준에서 거리 계산은 4,800,000회에서
실측·반복 분석용 CPU 계획은 거리 행렬과 workspace를 재사용하고, DAS 계열은 bin calibration과
허용되는 steering matrix도 재사용한다. 192채널·50×50·2 bins를 10회 DAS 분석하는 기준에서 거리 계산은 4,800,000회에서
480,000회, steering pair는 9,600,000회에서 960,000회로 줄어든다. snapshot working set과 계획은
512 MiB, steering cache는 64 MiB, streaming input은 256 MiB 기본 hard cap을 할당 전에 적용한다.
Streaming ring은 backlog에서 가장 오래된 sample을 버리고 호출당 결과 수를 제한한다.
@@ -141,7 +147,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- 좌표계·단위·오디오 normalization 결정 기록
- synthetic single-tone scene과 192채널 기준 배열 작성
완료 조건: 예제 JSON이 schema validation을 통과하고 두 앱이 동일 manifest를 읽는다.
완료 조건: 예제 JSON이 schema validation을 통과하고 앱들이 동일 계약을 읽는다.
### 단계 1 — CPU 기준 모델 · 완료
@@ -160,6 +166,15 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
완료 조건: 만든 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 · 완료
- CPU와 동일한 buffer layout 및 WGSL kernel 구현
@@ -176,20 +191,21 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- peak별 half-power 공분산/RMS 빔 분포와 재현 가능한 검출 임계값
- sample-aligned 준정적 시간창 분석, bounded map 계획과 이동 장면 재생
완료 조건: 사용자가 코드 수정 없이 배열 JSON 두 개를 같은 장면에서 비교할 수 있다.
완료 조건: 사용자가 코드 수정 없이 배열 JSON과 위치 추정 알고리즘을 같은 장면에서 비교할 수 있다.
### 단계 5 — 현실성·포팅과 실제 데이터 · 부분 완료
- [x] 마이크별 gain·phase·frequency-response 합성 및 선택적 DAS 역보정
- [x] 마이크별 gain·phase·frequency-response 합성과 알고리즘별 amplitude/phase 보정
- [x] 자유음장을 보존하는 선택형 shoebox 1차 반사·scattering·회절·RT60 tail
- [x] Scene 합성에서 분리한 block/streaming multichannel 위치추정 API
- [x] 반복 거리·calibration·steering cache, bounded ring과 메모리 hard cap
- [x] DAS·DAS-PHAT·reference-pair GCC-PHAT와 알고리즘별 power/score 계약
- [ ] 라이선스 검토를 통과한 DCASE/MIMII adapter와 batch workflow
- [ ] 실제 장치의 channel/clock/sample 형식에 맞춘 capture·transport adapter와 현장 검증
- [ ] 필요 시 센서 self-noise·위치 공차와 DAMAS/CLEAN-SC 후처리
현재 완료 조건은 기존 자유음장 결과를 유지한 채 propagation model과 응답 보상을 선택해
재실행하고, 같은 CPU DAS를 실측 `Float32Array` channel에 직접 적용하는 것이다. 실제
재실행하고, 선택한 CPU 위치 추정기를 실측 `Float32Array` channel에 직접 적용하는 것이다. 실제
hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정한다.
## 9. 주요 위험과 대응
@@ -203,14 +219,16 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
- 실제 AC와 simulation gap: gain·phase·주파수 응답과 희소 실내 환경을 비교 조건으로 제공하되,
위치 공차·self-noise·ADC·clock은 실측 adapter와 현장 검증으로 다룬다.
- post-processing이 기본 결과를 가림: raw DAS map과 후처리 map을 항상 별도로 보존한다.
- post-processing이 기본 결과를 가림: 알고리즘별 raw power/score map과 후처리 map을 별도로 보존한다.
## 10. 구현 결과와 다음 백로그
- [x] 설치 없는 ES module workspace와 두 앱 shell
- [x] 설치 없는 ES module workspace와 세 앱 shell
- [x] Array Builder 프리셋·직접 편집·검증·Camera 전달
- [x] array/scene/experiment/result schema와 synthetic fixture
- [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트
- [x] CPU DAS golden implementation
- [x] CPU/WebGPU DAS-PHAT와 CPU reference-pair GCC-PHAT 구현·비교 UI
- [x] WGSL kernel, CPU/GPU 비교와 50²/100²/200² benchmark
- [x] scene canvas, array view, parameter controls와 Result export
- [x] Builder→Camera SHA-256 asset 재연결 왕복 테스트
@@ -220,7 +238,7 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
- [x] 복수 음원용 Experiment peak 검출 감도
- [x] 마이크 주파수 응답 합성·CPU/WebGPU 역보정 비교
- [x] 자유음장·shoebox 희소 반사/산란/회절/RT60 환경 선택
- [x] portable block/streaming localizer와 반복 DAS cache·메모리 상한
- [x] portable block/streaming localizer와 알고리즘별 반복 plan·메모리 상한
- [x] Camera 입력→설정→실행→확인의 Snapshot/Moving UI 재구성
- [x] `test.cmd` one-command headless browser 검증
- [ ] DCASE/MIMII 라이선스 검토와 adapter
@@ -229,5 +247,5 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
- [ ] DAMAS/CLEAN-SC 후처리
정확성 test를 먼저 통과시킨 뒤 UI 또는 최적화를 진행한다는 원칙은 유지한다. 추가 현실성 모델은
자유음장 DAS 결과를 바꾸지 않는 선택형 확장으로, 장치 연결은 portable localizer 앞의 adapter로
자유음장 DAS 기준 결과를 바꾸지 않는 선택형 확장으로, 장치 연결은 portable localizer 앞의 adapter로
추가한다.
+101 -33
View File
@@ -17,6 +17,7 @@
<nav aria-label="문서 목차">
<a href="#quick-start">빠른 시작</a>
<a href="#moving-live">이동 라이브</a>
<a href="#array-builder">배열 만들기</a>
<a href="#full-workflow">내 데이터</a>
<a href="#acoustic-model">환경·실측</a>
<a href="#results">결과 해석</a>
@@ -28,7 +29,7 @@
<section class="guide-hero">
<h1>micarrsim 사용 설명서</h1>
<p>
<code>start.cmd</code>로 열고, 장면을 실행하고, 결과를 저장하는 순서만 먼저 확인하세요.
<code>start.cmd</code>로 열고, 배열과 장면을 준비하고, 결과를 저장하는 순서만 먼저 확인하세요.
입력과 계산은 로컬 브라우저에서 처리됩니다.
</p>
</section>
@@ -70,8 +71,31 @@
</div>
</section>
<section class="guide-section" id="full-workflow">
<section class="guide-section" id="array-builder">
<div class="section-number">03</div>
<div>
<p class="eyebrow">Microphone geometry</p>
<h2>내 마이크 배열 만들기</h2>
<ol class="guide-steps">
<li><a href="/apps/array-builder/">Array Builder</a>에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.</li>
<li>격자·선형·원형·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.</li>
<li>canvas의 <strong>추가</strong> 도구로 마이크를 놓고, 점을 드래그하거나 x/y/z 필드로 좌표를 수정합니다.</li>
<li>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li>
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>의 <strong>Array</strong> 입력에서 저장한 파일을 선택합니다.</li>
</ol>
<aside class="callout">
가져온 채널의 sensitivity, phase와 frequency-response 보정은 위치·순서 편집 중 보존됩니다.
다만 프리셋으로 마이크 목록을 교체하면 기존 채널별 보정도 교체됩니다.
</aside>
<aside class="callout callout--important">
생성 경계 형상, 프리셋 종류, seed와 최소 간격은 편집기 설정이라 표준 Array JSON에
저장되지 않습니다. Camera가 사용하는 것은 저장된 aperture와 최종 마이크 좌표·순서입니다.
</aside>
</div>
</section>
<section class="guide-section" id="full-workflow">
<div class="section-number">04</div>
<div>
<p class="eyebrow">5분 전체 흐름</p>
<h2>내 장면을 만들고 Camera에서 실행하기</h2>
@@ -85,7 +109,8 @@
<li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li>
<li>
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
가져옵니다. 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
가져옵니다. Array Builder에서 저장한 파일이 있다면 Array 단계에서 선택하고,
내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
</li>
<li>
Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다.
@@ -94,6 +119,13 @@
<li>Snapshot 또는 Moving을 선택해 <strong>Auto (GPU→CPU)</strong>로 실행한 뒤 Result JSON을 저장합니다.</li>
</ol>
<aside class="callout">
데스크톱에서는 설정과 작업 영역 사이의 선을 드래그하거나 방향키로 패널 폭을 조절할
수 있습니다. <kbd>Enter</kbd> 또는 더블클릭하면 기본 폭으로 돌아갑니다. 폭은 각 Builder와
Camera별로 이 브라우저에 저장되며, 좁은 화면에서는 두 영역이 자동으로 위아래로
배치됩니다.
</aside>
<aside class="callout callout--important" id="asset-reconnect">
<strong>Scene JSON에는 원본 파일이 들어 있지 않습니다.</strong>
파일명·종류·SHA-256 manifest만 저장됩니다. 브라우저는 새 페이지에서 로컬 파일
@@ -110,45 +142,68 @@
</section>
<section class="guide-section">
<div class="section-number">04</div>
<div class="section-number">05</div>
<div>
<p class="eyebrow">Compute backend</p>
<h2>속도와 격자 선택</h2>
<p class="eyebrow">Localization &amp; compute</p>
<h2>알고리즘, 속도와 격자 선택</h2>
<p class="guide-copy">
Camera의 <strong>위치 추정 알고리즘</strong>에서 실행할 방법을 고릅니다. 처음에는
DAS로 전체 흐름을 확인한 뒤 같은 scene·scan·STFT에서 PHAT 계열을 실행해 비교하세요.
</p>
<div class="guide-table-wrap">
<table>
<thead><tr><th>알고리즘</th><th>raw map</th><th>Backend</th><th>특징</th></tr></thead>
<tbody>
<tr><td>DAS</td><td>선형 power</td><td>CPU · WebGPU</td><td>진폭과 위상을 유지하는 기준선</td></tr>
<tr><td>DAS-PHAT</td><td>무차원 score</td><td>CPU · WebGPU</td><td>STFT 크기를 정규화한 위상 기반 합산</td></tr>
<tr><td>GCC-PHAT</td><td>무차원 score</td><td>CPU</td><td>기준 마이크 pair의 TDOA 상관</td></tr>
</tbody>
</table>
</div>
<div class="guide-table-wrap">
<table>
<thead><tr><th>선택</th><th>동작</th><th>권장 상황</th></tr></thead>
<tbody>
<tr><td>Auto (GPU→CPU)</td><td>WebGPU를 먼저 시도하고 불가능하면 CPU 사용</td><td>기본 권장</td></tr>
<tr><td>WebGPU 강제</td><td>GPU 장치가 없거나 실패하면 fallback 없이 오류</td><td>GPU 비교·측정</td></tr>
<tr><td>CPU 강제</td><td>동일한 conventional DAS를 CPU에서 계산</td><td>호환성 확인·기준 비교</td></tr>
<tr><td>WebGPU 강제</td><td>DAS/DAS-PHAT를 GPU에서만 실행; 실패하면 fallback 없이 오류</td><td>GPU 비교·측정</td></tr>
<tr><td>CPU 강제</td><td>선택한 알고리즘을 CPU에서 계산</td><td>호환성 확인·기준 비교</td></tr>
</tbody>
</table>
</div>
<p class="guide-copy">
먼저 50×50을 권장합니다. 100×100은 4배, 200×200은 16배의 scan point를 계산합니다.
실행 중에는 취소할 수 있으며, 취소하거나 실패해도 이전 완료 결과는 유지됩니다.
GCC-PHAT는 CPU 전용이므로 Auto가 자동으로 CPU를 사용하고 WebGPU 강제는 비활성화됩니다.
각 band가 8개 미만의 FFT bin을 포함하면 협대역 상관 경고가 나오므로 band 폭이나 FFT
설정을 확인하세요.
</p>
<p class="guide-copy">
복수 음원에서 약한 peak가 빠지면 <strong>Peak 검출 임계값</strong>을 기본 25%에서
10–20%로 낮춰 보세요. 약한 후보와 함께 sidelobe 오검출도 늘 수 있으며, main lobe보다
가까운 같은 대역 음원은 conventional DAS에서 하나로 합쳐질 수 있습니다.
먼저 50×50을 권장합니다. 100×100은 4배, 200×200은 16배의 scan point를 계산합니다.
연산량 줄은 공통 STFT transform과 함께 DAS/DAS-PHAT의 complex MAC, 또는 GCC-PHAT의
two-pass cross-spectrum·pair×band inverse FFT·scan correlation lookup을 분리해
표시합니다. 이는 실행 시간 예측값이 아니며 장치와 입력에 따라 달라집니다. 실행 중에는
취소할 수 있고, 취소하거나 실패해도 이전 완료 결과는 유지됩니다.
</p>
<p class="guide-copy">
복수 음원에서 약한 peak가 빠지면 <strong>Peak 검출 임계값</strong>을 최강 map 값의
기본 25%에서 10–20%로 낮춰 보세요. 약한 후보와 함께 sidelobe 오검출도 늘 수 있으며,
main lobe보다 가까운 같은 대역 음원은 어느 알고리즘에서도 하나로 합쳐질 수 있습니다.
</p>
</div>
</section>
<section class="guide-section" id="acoustic-model">
<div class="section-number">05</div>
<div class="section-number">06</div>
<div>
<p class="eyebrow">Acoustics & measured input</p>
<h2>환경과 마이크 응답 선택</h2>
<p class="guide-copy">
고급 설정에서 자유음장 또는 흡음·일반·반사 강한 실내 preset을 고릅니다. 실내 모델은
1차 반사, scattering, occluder 회절과 제한된 RT60 tail을 희소 경로로 근사합니다.
Array의 마이크 응답 curve는 수신 신호에 적용하며 DAS 역보정 유무를 비교할 수 있습니다.
Array의 마이크 응답 curve는 수신 신호에 적용하며 위치추정 역보정 유무를 비교할 수 있습니다.
값과 한계는 <a href="/docs/acoustic-model.md">음향 모델 설명</a>에서 확인하세요.
</p>
<p class="guide-copy">
실제 동기식 마이크 채널은 Scene 합성 없이 portable CPU DAS에 넣을 수 있습니다.
실제 동기식 마이크 채널은 Scene 합성 없이 portable CPU 위치추정 코어에 넣고
DAS·DAS-PHAT·GCC-PHAT를 선택할 수 있습니다.
장치별 channel·clock·transport adapter는 별도로 필요하며 실시간 deadline은 해당
장치에서 <code>processingTimeMs ≤ deadlineMs</code>와 miss·drop 누계로 확인해야
합니다. <a href="/docs/realtime-porting.md">포팅 설명서</a>에 입력 형식, ring buffer,
@@ -158,24 +213,31 @@
</section>
<section class="guide-section" id="results">
<div class="section-number">06</div>
<div class="section-number">07</div>
<div>
<p class="eyebrow">Result JSON</p>
<h2>무엇이 저장되나요?</h2>
<dl class="result-list">
<div><dt>rawMap / displayMap</dt><dd>선형 power 원본과 화면 표시용 acoustic map</dd></div>
<div><dt>algorithm / backend</dt><dd>실제로 실행한 위치추정과 CPU·WebGPU 경로</dd></div>
<div><dt>rawMap / displayMap</dt><dd>DAS 선형 power 또는 PHAT 무차원 score와 화면 표시용 map</dd></div>
<div><dt>peaks</dt><dd>검출 위치, source 매칭, m·pixel 위치 오차</dd></div>
<div><dt>beamSpread</dt><dd>half-power lobe의 power 가중 공분산과 1× RMS ellipse</dd></div>
<div><dt>metrics</dt><dd>energy 비율, precision/recall, sidelobe, main-lobe 폭</dd></div>
<div><dt>beamSpread</dt><dd>half-power 또는 half-maximum 응답의 map 값 가중 공분산과 1× RMS ellipse</dd></div>
<div><dt>metrics</dt><dd>truth concentration, precision/recall, sidelobe, main-lobe 폭</dd></div>
<div><dt>evaluation</dt><dd>검출 threshold, 반경, tolerance 등 지표 계산 조건</dd></div>
<div><dt>signalStats</dt><dd>normalization·clipping과 실제 음향 모델·종류별 sparse path 수</dd></div>
<div><dt>timing / environment</dt><dd>backend, grid·scan 범위, 장치 정보와 구간별 시간</dd></div>
<div><dt>timing / environment</dt><dd>signal generation·localization 시간, backend, grid·scan과 장치 정보</dd></div>
<div><dt>inputHash</dt><dd>Array·Scene·Experiment 입력을 묶은 SHA-256 재현성 식별자</dd></div>
<div><dt>comparison-history</dt><dd>세션의 배열별 시간·위치 오차·PSR 요약을 한 번에 저장</dd></div>
<div><dt>comparison-history</dt><dd>배열·알고리즘·실제 backend별 시간·위치 오차·PSR 요약</dd></div>
</dl>
<aside class="callout">
화면의 <strong>빔 분포</strong> 1×/2× RMS contour는 acoustic power lobe의 모양입니다.
위치 확률, Gaussian 신뢰구간 또는 보정된 측정 불확실성이 아닙니다.
DAS map은 acoustic power 응답이고 PHAT map은 무차원 위치추정 score입니다. score와
화면의 <strong>빔 분포</strong> 1×/2× RMS contour는 위치 확률, Gaussian 신뢰구간 또는
보정된 측정 불확실성이 아닙니다. GCC-PHAT의 0–1 raw score도 pair·band·IFFT scale을
제거한 양의 상관 응답이지 “확률 80%” 같은 의미가 아닙니다.
</aside>
<aside class="callout">
<code>timingMs.localization</code>은 위치추정 시간을 기록합니다. 기존 Result 소비자와의
호환을 위해 <code>timingMs.beamforming</code>에도 같은 값이 alias로 남습니다.
</aside>
<aside class="callout">
평가는 tone 음원을 해당 주파수 대역이 tone 주파수를 포함할 때만 정답으로 사용합니다.
@@ -183,7 +245,7 @@
에너지를 자동 판정하는 기능은 아직 없습니다.
</aside>
<p class="guide-copy">
Camera에서 바꾼 Scene과 Experiment는 각각 저장할 수 있습니다. 완료 Result와 배열 비교
Camera에서 바꾼 Scene과 Experiment는 각각 저장할 수 있습니다. 완료 Result와 비교
기록도 별도 JSON으로 저장하세요. 저장하지 않은 항목이나 실행 중인 계산이 있으면
페이지를 닫을 때 브라우저가 확인합니다.
</p>
@@ -191,7 +253,7 @@
</section>
<section class="guide-section" id="troubleshooting">
<div class="section-number">07</div>
<div class="section-number">08</div>
<div>
<p class="eyebrow">Troubleshooting</p>
<h2>막혔을 때 확인할 것</h2>
@@ -213,7 +275,11 @@
</details>
<details>
<summary>WebGPU가 없거나 실행이 너무 느립니다.</summary>
<p>Backend를 Auto 또는 CPU로 두고 50×50 grid부터 실행하세요. 최신 Chrome/Edge와 그래픽 드라이버도 확인하세요.</p>
<p>Backend를 Auto 또는 CPU로 두고 50×50 grid부터 실행하세요. GCC-PHAT는 원래 CPU 전용입니다. DAS/DAS-PHAT WebGPU를 쓴다면 최신 Chrome/Edge와 그래픽 드라이버도 확인하세요.</p>
</details>
<details>
<summary>GCC-PHAT에 8 bins 경고가 나옵니다.</summary>
<p>각 band가 포함하는 FFT bin이 너무 적으면 PHAT 상관이 협대역 주기성에 민감해집니다. 필요한 신호 대역 안에서 band를 넓히거나 FFT size를 조정하세요.</p>
</details>
<details>
<summary>두 번째 음원을 찾지 못합니다.</summary>
@@ -227,16 +293,17 @@
</section>
<section class="guide-section">
<div class="section-number">08</div>
<div class="section-number">09</div>
<div>
<p class="eyebrow">Scope & privacy</p>
<h2>현재 모델의 범위</h2>
<p class="guide-copy">
micarrsim 1.0은 점 음원의 자유음장과 계산량을 제한한 shoebox 희소 경로, 마이크
주파수 응답과 conventional DAS를 제공합니다. 파동 방정식·고차 반사·재질별 흡음,
복잡한 mesh, sensor self-noise·비선형, ADC·clock, DAMAS/CLEAN-SC와 자동 장치
capture adapter는 포함하지 않습니다. 결과는 장비의 절대 성능 보증이 아니라 같은
조건에서 배열과 알고리즘을 비교하는 재현 가능한 기준입니다.
주파수 응답과 DAS·DAS-PHAT·GCC-PHAT를 제공합니다. 파동 방정식·고차 반사·재질별
흡음, 복잡한 mesh, sensor self-noise·비선형, ADC·clock, DAMAS/CLEAN-SC 후처리와
자동 장치 capture adapter는 포함하지 않습니다. 실행할 수 없는 DAMAS/CLEAN-SC
placeholder는 Camera UI에서 제거했습니다. 결과는 장비의 절대 성능 보증이 아니라
같은 조건에서 배열과 알고리즘을 비교하는 재현 가능한 기준입니다.
</p>
<p class="guide-copy">
원본 asset은 브라우저 메모리에서만 읽고 서버나 외부 네트워크로 보내지 않습니다.
@@ -247,7 +314,8 @@
<section class="reference-grid" aria-label="상세 문서">
<a href="/README.md"><strong>전체 README</strong><span>설치·실행·구조·제약</span></a>
<a href="/apps/dataset-builder/README.md"><strong>Builder 설명서</strong><span>source·ambient·Preview WAV</span></a>
<a href="/apps/array-builder/README.md"><strong>Array Builder 설명서</strong><span>마이크 배치·프리셋·JSON</span></a>
<a href="/apps/dataset-builder/README.md"><strong>Dataset Builder 설명서</strong><span>source·ambient·Preview WAV</span></a>
<a href="/apps/acoustic-camera/README.md"><strong>Camera 설명서</strong><span>backend·결과·비교</span></a>
<a href="/docs/acoustic-model.md"><strong>음향 모델</strong><span>마이크 응답·반사·산란·회절</span></a>
<a href="/docs/realtime-porting.md"><strong>실측 포팅</strong><span>block·streaming 위치추정 API</span></a>
+175 -35
View File
@@ -1,7 +1,7 @@
# 실측 다채널 위치추정 코어 포팅
`packages/acoustics-core/realtime-localizer.js`는 Scene 생성이나 음향 합성 없이 이미 sampling된
동기식 마이크 채널을 conventional CPU DAS에 직접 넣는다. 브라우저 DOM과 외부 package에
동기식 마이크 채널을 CPU DAS·DAS-PHAT·GCC-PHAT 위치 추정에 직접 넣는다. 브라우저 DOM과 외부 package에
의존하지 않는 ES module이므로, 실제 장치 adapter 뒤에 붙이거나 다른 프로젝트로 분리할 수 있다.
이 API는 ADC와 transport를 제어하지 않는다. 입력 channel을 획득·정렬·동기화한 다음 위치 map과
@@ -9,13 +9,16 @@
## 최소 복사 단위
직접 경로로 `realtime-localizer.js`를 import한다면 다음 여덟 파일과 상대 경로를 함께
직접 경로로 `realtime-localizer.js`를 import한다면 다음 열한 파일과 상대 경로를 함께
복사하면 된다.
```text
acoustics-core/
realtime-localizer.js
localization.js
localization-algorithms.js
das.js
gcc-phat.js
stft.js
fft.js
math.js
@@ -24,8 +27,10 @@ acoustics-core/
evaluation.js
```
`index.js`는 전체 core의 편의 export이므로 최소 포팅에는 필요하지 않다. 현재 portable API는
CPU 기준 구현이며 Camera의 WebGPU worker나 Scene·propagation 모듈을 요구하지 않는다.
`index.js`는 전체 core의 편의 export이므로 최소 포팅에는 필요하지 않다. Portable API와
`localization.js`의 analysis plan은 CPU 기준 구현이며 Camera의 WebGPU worker나 Scene·propagation
모듈을 요구하지 않는다. Camera backend 통합은 DAS와 DAS-PHAT에 WebGPU를 제공하지만 GCC-PHAT는
CPU only다. GCC-PHAT에 명시적 WebGPU를 요청하면 오류가 나며 `auto`는 CPU를 선택한다.
## 입력 계약
@@ -68,6 +73,58 @@ const array = {
`beamforming.maxCompensationDb`로 제한하며 기본 24 dB, 허용 범위 0–120 dB다.
응답 peak를 줄이는 attenuation과 phase 보상은 이 boost 상한에 의해 잘리지 않는다.
## 알고리즘 선택과 결과 scale
Portable plan은 top-level `algorithm`으로 위치 추정기를 선택한다. 이 값을 생략하면 기존 동작과
결과를 유지하는 `das`다. 기존 구성 형태인 `beamforming.algorithm`도 허용한다.
| `algorithm` | 핵심 계산 | `rawScale` | portable backend |
| --- | --- | --- | --- |
| `das` | frequency-domain delay-and-sum | `power` | CPU |
| `das-phat` | phase-only spectrum을 steering하는 DAS | `score` | CPU |
| `gcc-phat` | 한 reference mic에 대한 `M-1`개 GCC-PHAT 지연 scan | `score` | CPU only |
같은 array·scan·band·STFT로 알고리즘을 비교할 때도 알고리즘마다 별도 plan을 만든다.
```js
const dasPlan = createLocalizationPlan({
...commonConfig,
algorithm: "das",
});
const dasPhatPlan = createLocalizationPlan({
...commonConfig,
algorithm: "das-phat",
phatRelativeFloor: 1e-6,
});
const gccPhatPlan = createLocalizationPlan({
...commonConfig,
algorithm: "gcc-phat",
phatFloorRatio: 1e-6,
});
```
`phatRelativeFloor`는 DAS-PHAT의 channel spectrum 정규화 floor이고, `phatFloorRatio`는
GCC-PHAT pair cross spectrum 정규화 floor다. 둘 다 해당 묶음의 최대 magnitude에 대한 0~1
비율이며 기본값은 `1e-6`이다. GCC-PHAT는 `phatRelativeFloor`도 호환 alias로 받지만 새 코드에서는
`phatFloorRatio`를 쓰는 편이 의미가 명확하다. 값을 키우면 낮은 magnitude 성분의 기여가 줄어들지만
너무 크게 두면 유효한 broadband 정보도 약해질 수 있으므로 현장 데이터로 조정한다.
각 band는 `rawScale`을 반환한다. `rawValue`는 scale에 관계없이 읽을 수 있는 canonical peak
값이다. `rawPower`는 `rawScale === "power"`, `rawScore`는 `rawScale === "score"`인 peak에만
사용하는 편의 alias이며 두 필드가 항상 함께 있다고 가정하면 안 된다. `score`는 음향 power,
확률 또는 신뢰도가 아니라 동일 알고리즘·설정 안에서 상대 peak를 비교하는 비보정 값이다.
`power`도 capture amplitude와 calibration이 절대 단위로 교정되지 않았다면 W 또는 Pa²가 아니다.
GCC-PHAT의 raw score는 pair 수·band coefficient 수·IFFT 크기로 정규화한 0–1 양의 상관 응답이라
scan 범위와 microphone 수 자체에 따른 척도 변화는 피하지만, 확률로 보정된 confidence는 아니다.
GCC-PHAT에는 여러 FFT bin에 에너지가 있는 broadband 입력을 권장한다. Band당 8 bin 미만도 hard
error는 아니지만 단일 tone이나 좁은 band에서는 반복 correlation peak 때문에 위치가 모호할 수 있다.
Plan 생성 시 reference pair와 scan point에서 가능한 최대 절대 기하 지연을 계산하며, 이 값이
`fftSize / 2` sample을 넘으면 circular alias를 막기 위해 거절한다. 더 큰 `fftSize`를 쓰거나
배열·scan 기하를 줄여야 하며, 결과의 `maximumLagSamples`로 실제 상한을 기록할 수 있다.
## 공통 계획 만들기
Array, scan grid, band와 STFT 설정이 바뀌지 않는 동안 계획을 한 번 만들고 재사용한다.
@@ -94,6 +151,7 @@ const plan = createLocalizationPlan({
{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" },
],
stft: { fftSize: 512, hopSize: 256 },
algorithm: "das", // "das" | "das-phat" | "gcc-phat"
speedOfSound: 343,
normalization: "peak",
compensateFrequencyResponse: true,
@@ -113,19 +171,63 @@ const plan = createLocalizationPlan({
- scan의 `columns`와 `rows`는 양의 정수
- `speedOfSound`는 양수
- `normalization`은 `peak` 또는 `none`
- `algorithm`은 `das`, `das-phat`, `gcc-phat` 중 하나
- `phatRelativeFloor`와 `phatFloorRatio`는 지정한 경우 0–1
- `maxCompensationDb`는 0–120 dB
- `peakDetection.thresholdRatio`는 0–1,
`minimumPeakDistanceM`는 0 이상,
`maximumDetections`는 1–128의 정수
계획은 거리 행렬, bin calibration, 임시 map과 허용되는 경우 steering matrix를 유지하는
stateful 객체다. 같은 계획으로 두 분석을 동시에 실행하지 말고, worker마다 별도 계획을 만든다.
Array·scan·band·STFT를 바꾸면 새 계획을 만들어야 한다.
계획은 알고리즘별 거리 행렬과 workspace를 유지하는 stateful 객체다. DAS 계열은 bin calibration,
임시 map과 허용되는 경우 steering matrix를 유지하고, GCC-PHAT는 reference mic 선택,
STFT·correlation workspace를 유지한다. 같은 계획으로 두 분석을 동시에 실행하지 말고 worker마다
별도 계획을 만든다. Array·scan·band·STFT·알고리즘을 바꾸면 새 계획을 만들어야 한다.
Portable plan의 generic cache는 `plan.analysisPlan`이며 기존 호출자를 위해 DAS 계열에는
`plan.dasPlan`, GCC-PHAT에는 `plan.gccPhatPlan` alias도 제공한다.
portable 계획의 기본 compute batch는 scan point 256개와 STFT transform 128개 단위로 event
loop에 양보한다. `computeOptions.pointBatchSize`와
`computeOptions.stftTransformBatchSize`로 조정할 수 있지만, 큰 batch는 throughput과
취소·UI 응답성의 trade-off가 있으므로 장치에서 함께 측정한다.
portable 계획의 기본 scan point batch는 DAS 계열 256개, GCC-PHAT 4,096개이고 STFT transform
batch는 128개다. GCC-PHAT cross-spectrum은 16,384 operation 단위로도 양보한다. 각 batch 경계에서
event loop에 기회를 주며 `computeOptions.pointBatchSize`,
`computeOptions.stftTransformBatchSize`, `computeOptions.crossSpectrumBatchSize`로 조정할
수 있다. 큰 batch는 throughput과 취소·UI 응답성의 trade-off가 있으므로 장치에서 함께 측정한다.
낮은 수준에서 직접 사용할 때는 `localization.js`의 generic plan과 compute 함수를 짝지어 쓴다.
Plan compatibility는 값이 같은 새 객체가 아니라 생성할 때 넘긴 `arrayDefinition`, `scene`,
`experiment`의 정확히 같은 객체를 요구한다.
```js
import {
computeLocalizationMap,
createLocalizationAnalysisPlan,
} from "./acoustics-core/localization.js";
const experiment = {
stft: { fftSize: 512, hopSize: 256, window: "hann" },
scan,
frequencyBands: bands,
beamforming: { algorithm: "das-phat", normalization: "peak" },
};
const scene = { speedOfSound: 343 };
const analysisPlan = createLocalizationAnalysisPlan({
sampleRate: 48_000,
arrayDefinition: array,
scene,
experiment,
});
for (const channels of capturedBlocks) {
const result = await computeLocalizationMap({
channels,
sampleRate: 48_000,
arrayDefinition: array,
scene,
experiment,
analysisPlan,
phatRelativeFloor: 1e-6,
});
consume(result);
}
```
## 한 블록 분석
@@ -144,22 +246,28 @@ const result = await localizeMultichannel({
});
for (const location of result.locations) {
console.log(location.bandId, location.position, location.rawPower);
console.log(
location.bandId,
location.position,
location.rawScale,
location.rawValue,
);
}
```
`result.locations`는 입력 band별 raw map의 local peak를 flat array로 담는다. 각 band
안에서는 power가 강한 순서이며 항목에는 `bandId`, band 내 `rank`, scan
`index`, `position`과 `rawPower`가 있다. 기본 threshold는 해당 band 최강
peak의 50%, 최소 peak 간격은 scan cell 대각선, 최대 검출 수는 band당 8개다. 여러 대역을
사용한다면 `bandId`로 묶어 해석한다.
안에서는 `rawValue`가 큰 순서이며 항목에는 `bandId`, band 내 `rank`, scan `index`,
`position`, `rawValue`, `rawScale`이 있다. DAS의 기존 `rawPower` 필드는 호환 alias로 유지된다.
Score 결과에서는 `rawValue`를 공통 필드로 읽고, `rawScore`가 포함된 peak 객체에서는 같은 값의
score alias로 사용할 수 있다. 기본 threshold는 해당 band 최강 peak의 50%, 최소 peak 간격은
scan cell 대각선, 최대 검출 수는 band당 8개다. 여러 대역을 사용한다면 `bandId`로 묶어 해석한다.
`peakDetection`은 후처리 위치 목록만 바꾸며 `result.bands[n].rawMap`과
`displayMap`은 그대로 보존한다. 같은 대역의 두 음원이 main lobe보다 가까우면 local
peak가 하나로 합쳐질 수 있고, threshold를 낮추면 sidelobe 후보도 늘 수 있다.
`sampleCount`와 `duration`도 결과에 함께 반환된다.
Block 결과의 `processingTimeMs`는 DAS 계산과 local-peak 검출에 걸린 monotonic wall
Block 결과의 `processingTimeMs`는 선택한 위치 추정 계산과 local-peak 검출에 걸린 monotonic wall
time이다. capture, adapter 변환과 결과 rendering 시간은 포함하지 않으므로 end-to-end latency는
호출자가 별도로 측정한다.
@@ -168,13 +276,14 @@ time이다. capture, adapter 변환과 결과 rendering 시간은 포함하지
## 연속 chunk 처리
`StreamingDasLocalizer` 또는 같은 factory인 `createStreamingLocalizer`는 고정 용량
ring buffer와 분석 workspace를 갖는다.
`StreamingLocalizer`는 알고리즘 중립 이름이며 기존 `StreamingDasLocalizer` class의 export alias다.
오래된 이름은 호환을 위해 유지된다. 두 이름과 `createStreamingLocalizer` factory는 모두 선택한
`algorithm`에 맞는 고정 용량 ring buffer와 분석 workspace를 만든다.
```js
import { createStreamingLocalizer } from "./acoustics-core/realtime-localizer.js";
import { StreamingLocalizer } from "./acoustics-core/realtime-localizer.js";
const localizer = createStreamingLocalizer({
const localizer = new StreamingLocalizer({
sampleRate: 48_000,
array,
scan: {
@@ -190,6 +299,8 @@ const localizer = createStreamingLocalizer({
{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" },
],
stft: { fftSize: 512, hopSize: 256 },
algorithm: "das-phat",
phatRelativeFloor: 1e-6,
analysisWindowSamples: 4_800,
analysisHopSamples: 2_400,
maximumBufferedSamples: 19_200,
@@ -224,6 +335,11 @@ async function analysisLoop(signal) {
`deadlineMs = analysisHopSamples / sampleRate × 1000`과
`deadlineMet = processingTimeMs <= deadlineMs`를 반환한다.
`deadlineMet`는 완료된 한 창의 위치 추정 시간이 hop 예산 안이었는지를 나타내는 측정값이지,
해당 구성이 실시간이라는 사전 보장이 아니다. JIT warm-up, GC, 다른 thread의 부하를 포함해 목표
장치에서 연속 실행을 측정해야 한다. 이 값은 capture·transport·rendering을 포함하지 않으므로 전체
시스템 deadline은 adapter 경계에서 따로 계측한다.
## 지연과 backpressure 조정
서로 다른 hop이 두 층에 있다.
@@ -268,18 +384,42 @@ async function analysisLoop(signal) {
입력 retained byte는
`채널 수 × (maximumBufferedSamples + analysisWindowSamples) × 4`이며 할당 전에 검사한다.
계획은 거리 행렬, bin calibration, Hann window, Float64 FFT workspace와 임시 map을 재사용한다.
기본 retained data가 `maximumPlanBytes`를 넘으면 계획 생성을 거절한다. 전체 steering
matrix가 64 MiB 또는 계획 전체 한도를 넘으면 선택 bin 앞쪽부터 예산에 들어가는 만큼만 cache하고
나머지는 고정 microphone workspace에서 필요할 때 계산한다.
모든 계획은 거리 행렬과 STFT workspace를 재사용한다. DAS 계열은 bin calibration, Hann window,
임시 map과 steering cache를, GCC-PHAT는 선택 bin metadata, reference mic와 correlation workspace를
재사용한다. 기본 retained data가 `maximumPlanBytes`를 넘으면 할당 전에 계획 생성을 거절한다.
Plan 없는 GCC 단발 분석도 거리 행렬 할당 전에 전체 working set을 `maximumWorkingBytes`와 비교한다.
DAS의 전체 steering matrix가 64 MiB 또는 계획 전체 한도를 넘으면 선택 bin 앞쪽부터 예산에 들어가는
만큼만 cache하고 나머지는 고정 microphone workspace에서 필요할 때 계산한다.
192채널, 50×50 scan, 선택 bin 2개를 같은 계획으로 10회 분석하는 기준에서 구현상 거리 계산은
기존 DAS 호환 경로에서 192채널, 50×50 scan, 선택 bin 2개를 같은 계획으로 10회 분석하면 거리 계산은
4,800,000회에서 480,000회, steering point–microphone pair는 9,600,000회에서 960,000회로
줄어든다. 17,280,000회의 `sin/cos` 호출을 피하고 약 16.5 MiB를 유지한다. 자동 테스트는
부분 cache와 cache 없음의 raw/display `Float32` map 모든 원소가 bit-exact임을 확인한다.
각 결과의 `analysisCache`에는 cache된 bin capacity와 실제 cached/uncached 사용,
거리·steering pair, STFT transform·FFT butterfly·DAS complex MAC, workspace allocation·reuse
수가 들어가므로 설정별 최적화 효과를 계측할 수 있다.
각 결과의 `analysisCache`에는 공통 거리·STFT 통계와 알고리즘별 작업량이 들어간다. DAS 계열은
cached/uncached steering pair, FFT butterfly와 complex MAC을, GCC-PHAT는 cross-spectrum
operation, pair별 IFFT, scan lookup과 correlation workspace reuse를 기록한다.
연산량을 비교할 때 다음 기호를 사용한다.
- `P`: scan point 수
- `M`: microphone 수
- `T`: 한 분석창의 STFT frame 수
- `F`: 선택한 고유 FFT bin 수
- `F_b`: band `b`의 bin 수, `B`: band 수
- `N`: `fftSize`
DAS와 DAS-PHAT의 지배적인 steering 누산은 대략 `P × M × T × F` complex MAC이다. PHAT
정규화가 추가되지만 scan 차수는 같다. GCC-PHAT는 모든 mic의 forward STFT를 한 번 수행한 뒤
reference pair `M-1`개에 대해 다음 작업을 수행한다.
- magnitude 최대값 pass와 PHAT 누산 pass:
`2 × (M-1) × T × ΣF_b` cross-spectrum operation
- 각 pair·band의 길이 `N` correlation IFFT: `(M-1) × B`회
- 각 scan point의 fractional lag lookup: `P × (M-1) × B`회
따라서 GCC-PHAT의 scan lookup은 DAS의 `P × M × T × F`보다 작을 수 있지만 공짜는 아니다.
Forward STFT는 그대로 필요하고, 작은 grid·큰 `N`·많은 band에서는 pair별 IFFT나 2-pass cross
spectrum이 지배할 수 있다. `workload`와 실제 `processingTimeMs`를 함께 비교한다.
실시간 목표를 맞추는 조정 순서는 보통 다음과 같다.
@@ -293,15 +433,15 @@ matrix가 64 MiB 또는 계획 전체 한도를 넘으면 선택 bin 앞쪽부
7. capture와 분석을 별도 Worker/thread에 두고 결과만 UI 또는 제어 loop로 전달한다.
캐시 최적화와 위 수치는 반복 계산 감소의 근거이지 192채널·50×50 또는 다른 구성이 실시간이라는
보장이 아니다. 마이크 수, scan point, 선택 bin, STFT frame에 따른 주연산은 여전히 대략
`O(P × M × F × T)`다. `deadlineMisses` 또는 `droppedSamples`가 누적되면
grid·band bin·FFT/분석창 또는 update rate를 줄여야 한다. deadline은 위치추정 처리량 판정이며
capture와 출력까지 포함하는 시스템 deadline은 adapter에서 별도로 검증한다.
보장이 아니다. `deadlineMisses` 또는 `droppedSamples`가 누적되면 grid·band bin·FFT/분석창 또는
update rate를 줄여야 한다. 평균 한 번만 재지 말고 목표 장치의 실제 입력으로 warm-up 뒤 충분히 긴
구간의 최대값과 상위 percentile을 측정한다. Capture와 출력까지 포함하는 시스템 deadline은
adapter에서 별도로 검증한다.
## 취소와 오류 복구
`localizeMultichannel`과 `analyzeAvailable`에 `AbortSignal`을 전달하면 STFT와
DAS batch 경계에서 `AbortError`를 발생시키고 계획 lock을 해제한다.
알고리즘별 scan/correlation batch 경계에서 `AbortError`를 발생시키고 계획 lock을 해제한다.
Streaming 분석이 취소되거나 실패하면 `failedAnalyses`가 증가하지만 해당 hop을
소비하지 않는다. 실패한 창은 별도 analysis workspace에 보존되며, 다음
@@ -329,4 +469,4 @@ hop을 commit하고 sequence를 증가시킨다.
코어 뒤에서는 `locations`를 제어에 바로 쓰기 전에 현장 데이터로 검출 임계값, false peak,
latency와 위치 오차를 검증해야 한다. 현재 `locations`는 통계적 신뢰구간이 아니라 각 band
DAS map에서 threshold와 거리 억제를 통과한 grid local peak 후보들이다.
위치 map에서 threshold와 거리 억제를 통과한 grid local peak 후보들이다.
+15 -3
View File
@@ -24,7 +24,7 @@
<section class="landing-intro" aria-labelledby="landing-title">
<p class="eyebrow">Microphone array workbench</p>
<h1 id="landing-title">무엇을 하시겠어요?</h1>
<p>배열 위치추정을 바로 실행하거나, 평가에 사용할 음원 장면을 먼저 만드세요.</p>
<p>배열을 설계하거나 음원 장면을 만든 뒤, 위치추정 결과를 바로 비교하세요.</p>
</section>
<section class="launcher-grid" aria-label="작업 선택">
@@ -47,19 +47,31 @@
</div>
<span class="launcher-card__action">새 Scene 만들기 →</span>
</a>
<a class="launcher-card" href="/apps/array-builder/">
<span class="launcher-card__number" aria-hidden="true">03</span>
<div>
<p class="launcher-card__kicker">마이크 배치 설계</p>
<h2>Array Builder</h2>
<p>크기와 생성 경계를 정하고, 프리셋 또는 직접 편집으로 마이크 배열을 만듭니다.</p>
</div>
<span class="launcher-card__action">새 Array 만들기 →</span>
</a>
</section>
<details class="workflow-disclosure">
<summary>내 파일로 분석하는 순서</summary>
<ol class="workflow-list">
<li><strong>Array 제작 (선택)</strong><span>크기와 마이크 좌표를 설계해 JSON 저장</span></li>
<li><strong>Scene 제작</strong><span>Builder에서 음원과 위치 지정</span></li>
<li><strong>파일 보관</strong><span>Scene JSON과 원본 파일을 함께 보관</span></li>
<li><strong>Camera 입력</strong><span>Scene, 배열, 원본 파일 선택</span></li>
<li><strong>결과 저장</strong><span>위치 map과 지표를 Result JSON으로 저장</span></li>
</ol>
<p class="workflow-note">
Scene JSON에는 원본 파일이 포함되지 않습니다. 다시 열 때 같은 파일을 선택하면 SHA-256으로
확인해 연결합니다. <a href="/docs/#asset-reconnect">자세히 보기</a>
Array Builder가 저장한 JSON은 Camera의 Array 입력에서 바로 선택할 수 있습니다. Scene JSON에는
원본 파일이 포함되지 않으므로 다시 열 때 같은 파일을 SHA-256으로 확인해 연결합니다.
<a href="/docs/#asset-reconnect">자세히 보기</a>
</p>
</details>
</main>
+100 -23
View File
@@ -6,7 +6,55 @@
- 자유음장 직접음과 선택적 저비용 shoebox multipath
- zero-padded FFT 전달함수 기반 fractional sample delay와 마이크 주파수 응답
- radix-2 FFT, Hann STFT와 선택 bin 추출
- scan grid 거리 cache 및 conventional frequency-domain DAS
- scan grid 거리 cache 및 DAS·DAS-PHAT·GCC-PHAT 위치 추정
## 위치 추정 알고리즘
`localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 세 경로를
공통 interface로 제공합니다.
| `algorithm` | 계산 | raw scale | backend |
| --- | --- | --- | --- |
| `das` | conventional frequency-domain delay-and-sum | `power` | CPU, WebGPU |
| `das-phat` | 채널 spectrum을 phase-only로 만든 DAS | `score` | CPU, WebGPU |
| `gcc-phat` | 하나의 reference mic와 나머지 mic의 GCC-PHAT 지연 scan | `score` | CPU only |
Portable API에서는 top-level `algorithm`으로 선택합니다. 생략하면 기존과 같은 `das`이며, 낮은
수준의 generic API는 `experiment.beamforming.algorithm`을 사용합니다.
```js
import {
createLocalizationPlan,
localizeMultichannel,
} from "./packages/acoustics-core/realtime-localizer.js";
const plan = createLocalizationPlan({
...commonConfig, // sampleRate, array, scan, bands, stft
algorithm: "gcc-phat", // "das" | "das-phat" | "gcc-phat"
phatFloorRatio: 1e-6,
});
// 반복 분석에서도 같은 plan을 재사용합니다.
const result = await localizeMultichannel({ plan, channels });
```
`das-phat`의 `phatRelativeFloor`와 `gcc-phat`의 `phatFloorRatio`는 모두 0~1의 상대
크기 floor이며 기본값은 `1e-6`입니다. GCC-PHAT는 `phatRelativeFloor`도 호환 alias로
받지만 새 코드에서는 알고리즘별 이름을 권장합니다. PHAT 경로는 mic gain을 위치 score에
사용하지 않고 알려진 mic·frequency-response phase만 보정합니다.
GCC-PHAT는 단일 tone보다 여러 FFT bin에 에너지가 있는 broadband 입력을 권장합니다. 하나의
band에 선택된 bin이 8개 미만이면 코어는 계산을 허용하지만 주기적 모호성과 ghost peak를 직접
검증해야 합니다. 또한 reference pair의 scan 전체 최대 절대 기하 지연이 `fftSize / 2` sample을
넘으면 circular correlation alias를 피하기 위해 plan 생성을 거절합니다. 이때는 `fftSize`를
늘리거나 배열·scan 기하를 조정합니다.
각 band의 `rawScale`은 `power` 또는 `score`입니다. `rawValue`가 scale에 무관한 표준 peak
값이며, `rawPower`는 `power`, `rawScore`는 `score`일 때만 쓰는 편의 alias입니다. `score`는
확률·신뢰도·SPL이 아니고 동일 알고리즘·설정 안에서 상대 peak를 비교하는 비교정 값입니다.
`power`도 입력과 calibration이 절대 단위로 교정되지 않았다면 물리적 W 또는 Pa²로 해석하지 않습니다.
GCC-PHAT score는 pair 수, band coefficient 수와 IFFT 크기로 정규화한 0–1 양의 상관 응답이라
scan 범위와 마이크 수에 의한 단순 재척도화는 피하지만, 여전히 확률로 보정된 confidence는 아닙니다.
## 실측 다채널 스트림 위치 추정
@@ -15,6 +63,10 @@
디렉터리를 그대로 복사해 사용할 수 있습니다. 채널 순서는 `array.microphones` 순서와 같아야 하고
각 chunk는 동일 길이의 `Float32Array` 목록이어야 합니다.
`StreamingLocalizer`는 알고리즘 중립 class 이름입니다. 기존 `StreamingDasLocalizer`와 같은 class의
export alias이므로 오래된 import도 그대로 동작하며, `createStreamingLocalizer()` factory도 세
알고리즘을 모두 지원합니다.
```js
import { createStreamingLocalizer } from "./packages/acoustics-core/realtime-localizer.js";
@@ -24,6 +76,7 @@ const localizer = createStreamingLocalizer({
scan: { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 50, rows: 50 },
bands: [{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" }],
stft: { fftSize: 512, hopSize: 256 },
algorithm: "das", // 호환 기본값. "das-phat" 또는 "gcc-phat"도 선택할 수 있습니다.
maxCompensationDb: 24,
peakDetection: { thresholdRatio: 0.5, maximumDetections: 8 },
analysisWindowSamples: 4_800,
@@ -38,15 +91,17 @@ localizer.append(channels);
// Analysis loop: 호출마다 설정된 상한 이하의 map만 반환합니다.
const results = await localizer.analyzeAvailable({ signal: abortController.signal });
for (const result of results) {
const { position, rawPower } = result.locations[0];
drawMarker(position, rawPower, result.centerTime);
const { position, rawValue, rawScale } = result.locations[0];
drawMarker(position, rawValue, rawScale, result.centerTime);
if (!result.deadlineMet) reportOverload(result.processingTimeMs, result.deadlineMs);
}
```
`locations`는 band 순서, 같은 band 안에서는 raw power 내림차순의 평평한 목록입니다. 각 항목은
기존 `{ bandId, index, position, rawPower }`에 `rank`를 더하며 같은 band의 여러 local peak를
포함할 수 있습니다. `peakDetection.thresholdRatio` 기본값은 최강 peak의 0.5배,
`locations`는 band 순서, 같은 band 안에서는 `rawValue` 내림차순의 평평한 목록입니다. 각 항목은
`{ bandId, rank, index, position, rawValue, rawScale }`이며 같은 band의 여러 local peak를 포함할
수 있습니다. 기존 DAS 결과의 `rawPower` 별칭은 그대로 유지됩니다. PHAT score는 `rawValue`와
`rawScale`을 기준으로 읽고, score peak 객체에 포함된 경우 `rawScore`를 같은 값의 별칭으로 씁니다.
`peakDetection.thresholdRatio` 기본값은 최강 peak의 0.5배,
`minimumPeakDistanceM` 기본값은 scan cell 대각선, `maximumDetections` 기본값은 8입니다.
기존처럼 `locations[0]`만 읽으면 최강 후보를 그대로 얻습니다.
@@ -55,10 +110,13 @@ Streaming 계산이 중단되거나 실패하면 해당 분석창과 sequence를
`onResult` callback의 예외는 `failedAnalyses`에 포함하지 않습니다. 계산 중 capture overflow가 나면
ring의 오래된 sample은 평소와 같이 drop telemetry에 기록됩니다.
한 블록 결과의 `processingTimeMs`는 DAS와 local-peak 검출에 걸린 실제 시간을 기록합니다. Streaming
한 블록 결과의 `processingTimeMs`는 선택한 위치 추정 알고리즘과 local-peak 검출에 걸린 실제 시간을
기록합니다. Streaming
결과는 `deadlineMs = analysisHopSamples / sampleRate × 1000`과 `deadlineMet`을 더하며,
`getState()`는 최근/최대 처리 시간과 `deadlineMisses` 누계를 제공합니다. `deadlineMisses` 또는
`droppedSamples`가 증가하면 grid·대역 bin·분석창·update rate를 장치 성능에 맞게 줄여야 합니다.
이 deadline 비교는 실시간 성능 보장이 아니라 완료된 창의 측정값입니다. 목표 장치에서 warm-up을 거친
지속 부하를 직접 측정하고, capture·adapter·출력을 포함한 end-to-end deadline은 별도로 검증해야 합니다.
깊은 응답 notch의 역필터가 sensor noise까지 폭증시키지 않도록 inverse response의 양의 boost는 기본
24 dB로 제한합니다. Experiment의 `beamforming.maxCompensationDb` 또는 portable plan의 top-level
@@ -71,16 +129,27 @@ cache가 나중에 `maximumPlanBytes`를 넘지 않습니다.
buffer byte 수를 돌려주므로 capture adapter가 지연을 감시할 수 있습니다. 실제 장치의 clock,
channel framing과 transport는 하드웨어별 adapter 책임이며 이 모듈은 그 다음 단계부터 시작합니다.
반복 CPU 분석 계획은 거리 행렬, bin calibration, Hann window, Float64 FFT workspace, 임시 bin map과
예산 내의 Float64 steering matrix를 재사용합니다. 예를 들어 192채널·50×50·2 bins를 10회 분석하면
반복 CPU 분석 계획은 공통 거리 행렬과 STFT workspace를 재사용합니다. DAS 계열은 bin calibration,
Hann window, 임시 bin map과 예산 내의 Float64 steering matrix를, GCC-PHAT는 reference 선택과
correlation workspace를 추가로 유지합니다. 같은 plan을 동시에 두 분석에 쓰지 말고 설정이나 알고리즘이
바뀌면 새 plan을 만듭니다. 예를 들어 DAS로 192채널·50×50·2 bins를 10회 분석하면
거리 계산은 4,800,000회에서 480,000회로, steering point/microphone pair는 9,600,000회에서
960,000회로 줄어듭니다(sin/cos 호출 17,280,000회 회피). 이 경우 약 16.5 MiB를 유지합니다.
기본 64 MiB보다 전체 wide-band steering matrix가 크더라도 cache 전체를 끄지 않습니다. 선택 bin
앞쪽부터 예산에 들어가는 만큼만 Float64로 유지하고 나머지는 고정 microphone workspace에서 계산합니다.
`analysisCache`는 cached bin capacity, uncached 사용 횟수·pair 수, STFT transform·FFT butterfly·DAS
complex MAC 수와 실제 workspace allocation/reuse 수를 제공합니다. 자동 테스트는 부분 cache와
cache 없음 사이 raw/display Float32 map의 전 요소 bit-exact 일치를 고정합니다.
`analysisCache`는 알고리즘별 실제 작업을 제공합니다. DAS 계열은 cached bin capacity,
uncached steering pair, STFT transform·FFT butterfly·complex MAC을, GCC-PHAT는 STFT/IFFT,
2-pass cross-spectrum operation, scan lookup과 correlation workspace reuse를 기록합니다.
`P`를 scan point 수, `M`을 mic 수, `T`를 STFT frame 수, `F`를 선택한 고유 bin 수,
`B`를 band 수, `N`을 `fftSize`라 하면 DAS의 주 scan 연산은 대략
`P × M × T × F` complex MAC입니다. GCC-PHAT는 forward STFT에 더해 reference pair `M-1`개마다
band bin을 두 번 순회하는 cross spectrum, `(M-1) × B`회의 길이 `N` IFFT,
`P × (M-1) × B`회의 fractional correlation lookup을 수행합니다. band별 bin 수를 `F_b`라 하면
cross-spectrum 항목 수는 구현의 두 pass를 포함해 `2 × (M-1) × T × ΣF_b`입니다. 따라서 GCC가
scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전체 시간을 지배할 수 있으므로
두 알고리즘 모두 실제 목표 장치에서 측정해야 합니다.
## 이동 음원과 분석 시간창
@@ -94,25 +163,30 @@ seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline
## Beam spread 의미
Peak의 `beamSpread`는 half-power 연결 성분을 power 가중한 공간 covariance(`covarianceM2`, m²)와 그
RMS 주축 ellipse로 요약한 빔 폭입니다. 가우시안 확률 모델, 위치 확률 또는 신뢰구간으로 보정된 값이
아니며 `calibrated: false`를 유지합니다.
Peak의 `beamSpread`는 raw map의 peak 주변 연결 성분을 가중한 공간 covariance(`covarianceM2`, m²)와
그 RMS 주축 ellipse로 요약한 폭입니다. `power` map은 half-power/power-weighted,
`score` map은 half-maximum/score-weighted 규칙을 사용합니다. 어느 쪽도 가우시안 확률 모델,
위치 확률 또는 신뢰구간으로 보정된 값이 아니며 `calibrated: false`를 유지합니다.
FFT는 `X[k] = Σ x[n] exp(-j2πkn/N)` 규약을 사용합니다. 마이크 신호의 전파 phase
`exp(-j2πfr/c)`를 정렬하기 위해 DAS steering은 `exp(+j2πfr/c)`를 곱합니다. 단일 tone의
ground-truth 위치에서 peak가 생기는 회귀 테스트로 이 부호를 고정합니다.
마이크 `sensitivityDb`와 `phaseOffsetDeg`는 수신 channel에 편차로 적용하고, DAS에서는 알려진
calibration 값의 역 gain/phase로 보정합니다. raw power map과 표시용 normalized map은 서로 다른
buffer로 보존합니다. bin spectrum은 제한된 batch로 처리합니다. cooperative yield는 지원 환경에서
마이크 `sensitivityDb`와 `phaseOffsetDeg`는 수신 channel에 편차로 적용합니다. Conventional DAS는
알려진 calibration의 역 gain/phase를 모두 보정하고, DAS-PHAT와 GCC-PHAT는 amplitude gain을 무시한
채 알려진 phase만 보정합니다. raw `power`/`score` map과 표시용 normalized map은 서로 다른 buffer로
보존합니다. bin spectrum은 제한된 batch로 처리합니다. cooperative yield는 지원 환경에서
`scheduler.yield()`, 그 외에는 `MessageChannel`을 사용하고 timer task에도 주기적으로 기회를 주므로
진행 callback과 `AbortSignal` 취소를 유지하면서 연속 `setTimeout(0)` clamp를 피합니다. portable
plan의 기본 batch는 point 256개·STFT transform 128개이며 `computeOptions`로 조정할 수 있습니다.
plan의 기본 point batch는 DAS 계열 256개, GCC-PHAT 4,096개이고 STFT transform batch는
128개입니다. GCC-PHAT의 두 cross-spectrum pass도 16,384 operation마다 양보합니다.
`pointBatchSize`, `stftTransformBatchSize`, `crossSpectrumBatchSize`를
`computeOptions`로 조정할 수 있습니다.
마이크별 `frequencyResponse`는 2~64개의 `{ frequencyHz, gainDb, phaseDeg? }` 점으로 정의합니다.
`interpolateFrequencyResponse`는 dB와 wrapped phase를 구간 선형 보간하고 측정 범위 밖에서는 가장
가까운 끝점을 사용합니다. 합성 시 기존 sensitivity/phase 편차에 이 응답을 추가하며, CPU DAS는
선택 FFT bin의 역응답을 calibration/steering cache에 포함합니다. WebGPU 경로는 GPU 전송 직전의
가까운 끝점을 사용합니다. 합성 시 기존 sensitivity/phase 편차에 이 응답을 추가하며, CPU 위치 추정은
선택 FFT bin의 알고리즘별 역응답을 calibration에 포함합니다. WebGPU DAS 계열 경로는 GPU 전송 직전의
packed STFT에 같은 역응답을 적용합니다. `beamforming.compensateFrequencyResponse: false`는
sensitivity/phase 보정은 유지한 채 곡선 보정만 꺼 robustness 비교를 재현합니다.
@@ -143,6 +217,9 @@ Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면
snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을
512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로
전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
hard cap을 할당 전에 검사하며 steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지합니다.
hard cap을 할당 전에 검사합니다. DAS steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지하고,
GCC-PHAT는 거리·STFT·correlation workspace 전체를 plan 한도 안에서 검사합니다. Plan 없는 단발
GCC 분석도 거리 행렬을 만들기 전에 spectrum·calibration·output을 포함한 working set을 preflight합니다.
192채널·50×50 fixture는 브라우저 통합 테스트에서 실제 합성부터 DAS peak 검출까지 실행합니다.
192채널·50×50 fixture는 기존 DAS 호환 경로를 실제 합성부터 peak 검출까지 고정하며, 별도의 결정적
회귀 fixture가 DAS-PHAT와 GCC-PHAT의 score scale·lag 부호·plan 재사용을 검사합니다.
+142 -34
View File
@@ -5,6 +5,11 @@ import {
requireMaxCompensationDb,
} from "./frequency-response.js";
import { cooperativeYield } from "./cooperative-yield.js";
import {
DEFAULT_PHAT_RELATIVE_FLOOR,
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
} from "./localization-algorithms.js";
import { binsForBand, createStftWorkspace, extractStftBins, getFrameCount } from "./stft.js";
function throwIfAborted(signal) {
@@ -48,20 +53,24 @@ function createBandIndexLookup(bandBins) {
return lookup;
}
function createBinCalibration(
arrayDefinition,
bin,
sampleRate,
fftSize,
compensateFrequencyResponse,
maximumCompensationDb,
) {
export function createBinCalibration(options) {
const {
arrayDefinition,
bin,
sampleRate,
fftSize,
compensateFrequencyResponse = true,
maxCompensationDb: maximumCompensationDb,
includeAmplitude = true,
} = options;
const microphoneCount = arrayDefinition.microphones.length;
const gains = new Float64Array(microphoneCount);
const phases = new Float64Array(microphoneCount);
const frequencyHz = (bin * sampleRate) / fftSize;
const response = {};
const maxCompensationDb = requireMaxCompensationDb(maximumCompensationDb);
const maxCompensationDb = includeAmplitude
? requireMaxCompensationDb(maximumCompensationDb)
: 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const microphone = arrayDefinition.microphones[microphoneIndex];
let responseGainDb = 0;
@@ -71,23 +80,72 @@ function createBinCalibration(
responseGainDb = response.gainDb;
responsePhaseDeg = response.phaseDeg;
}
const responseCompensationDb = compensateFrequencyResponse
const responseCompensationDb = includeAmplitude && compensateFrequencyResponse
? inverseResponseGainDb(responseGainDb, maxCompensationDb)
: 0;
gains[microphoneIndex] = dbToLinear(
-(microphone.sensitivityDb ?? 0) + responseCompensationDb,
);
gains[microphoneIndex] = includeAmplitude
? dbToLinear(-(microphone.sensitivityDb ?? 0) + responseCompensationDb)
: 1;
phases[microphoneIndex] =
((microphone.phaseOffsetDeg ?? 0) + responsePhaseDeg) * Math.PI / 180;
}
return { gains, phases };
}
function requirePhatRelativeFloor(value) {
if (!Number.isFinite(value) || value < 0 || value > 1) {
throw new RangeError("phatRelativeFloor must lie inside 0..1.");
}
return value;
}
export function phaseNormalizeSpectrum(
spectrum,
frameCount,
microphoneCount,
relativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
) {
const expectedLength = frameCount * microphoneCount;
if (
!spectrum ||
spectrum.real?.length !== expectedLength ||
spectrum.imaginary?.length !== expectedLength
) {
throw new RangeError("spectrum length must match frameCount and microphoneCount.");
}
requirePhatRelativeFloor(relativeFloor);
const real = new Float32Array(expectedLength);
const imaginary = new Float32Array(expectedLength);
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameOffset = frameIndex * microphoneCount;
let maximumMagnitude = 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const spectrumIndex = frameOffset + microphoneIndex;
maximumMagnitude = Math.max(
maximumMagnitude,
Math.hypot(spectrum.real[spectrumIndex], spectrum.imaginary[spectrumIndex]),
);
}
const magnitudeFloor = maximumMagnitude * relativeFloor;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const spectrumIndex = frameOffset + microphoneIndex;
const inputReal = spectrum.real[spectrumIndex];
const inputImaginary = spectrum.imaginary[spectrumIndex];
const magnitude = Math.hypot(inputReal, inputImaginary);
if (!(magnitude > magnitudeFloor)) continue;
real[spectrumIndex] = inputReal / magnitude;
imaginary[spectrumIndex] = inputImaginary / magnitude;
}
}
return { real, imaginary };
}
function fillSteeringVector({
angularWaveNumber,
distances,
distanceOffset,
calibration,
applyCalibrationGains = true,
outputReal,
outputImaginary,
outputOffset,
@@ -96,10 +154,9 @@ function fillSteeringVector({
const phase =
angularWaveNumber * distances[distanceOffset + microphoneIndex] -
calibration.phases[microphoneIndex];
outputReal[outputOffset + microphoneIndex] =
Math.cos(phase) * calibration.gains[microphoneIndex];
outputImaginary[outputOffset + microphoneIndex] =
Math.sin(phase) * calibration.gains[microphoneIndex];
const gain = applyCalibrationGains ? calibration.gains[microphoneIndex] : 1;
outputReal[outputOffset + microphoneIndex] = Math.cos(phase) * gain;
outputImaginary[outputOffset + microphoneIndex] = Math.sin(phase) * gain;
}
}
@@ -113,6 +170,7 @@ async function createSteeringMatrix(options) {
fftSize,
speedOfSound,
calibration,
applyCalibrationGains = true,
signal,
pointBatchSize,
} = options;
@@ -127,6 +185,7 @@ async function createSteeringMatrix(options) {
distances,
distanceOffset: offset,
calibration,
applyCalibrationGains,
outputReal: real,
outputImaginary: imaginary,
outputOffset: offset,
@@ -158,21 +217,32 @@ export async function computeBinMap(options) {
steeringImaginary: providedSteeringImaginary,
signal,
pointBatchSize = 64,
algorithm: algorithmId = "das",
phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
} = options;
const algorithm = requireLocalizationAlgorithmDefinition(algorithmId);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`computeBinMap does not implement ${algorithm.id}.`);
}
const phaseOnly = algorithm.spectralWeighting === "phat";
const microphoneCount = arrayDefinition.microphones.length;
const output = providedOutput ?? new Float32Array(pointCount);
const steeringReal = providedSteeringReal ?? new Float64Array(microphoneCount);
const steeringImaginary = providedSteeringImaginary ?? new Float64Array(microphoneCount);
const angularWaveNumber = (TAU * bin * sampleRate) / (fftSize * speedOfSound);
const microphoneScale = 1 / microphoneCount;
const calibration = providedCalibration ?? createBinCalibration(
const calibration = providedCalibration ?? createBinCalibration({
arrayDefinition,
bin,
sampleRate,
fftSize,
compensateFrequencyResponse,
maxCompensationDb,
);
includeAmplitude: !phaseOnly,
});
const inputSpectrum = phaseOnly
? phaseNormalizeSpectrum(spectrum, frameCount, microphoneCount, phatRelativeFloor)
: spectrum;
const phaseRealValues = steeringMatrix?.real ?? steeringReal;
const phaseImaginaryValues = steeringMatrix?.imaginary ?? steeringImaginary;
if (output.length !== pointCount) throw new RangeError("output length must match pointCount.");
@@ -208,6 +278,7 @@ export async function computeBinMap(options) {
outputReal: steeringReal,
outputImaginary: steeringImaginary,
outputOffset: 0,
applyCalibrationGains: !phaseOnly,
});
}
@@ -218,8 +289,8 @@ export async function computeBinMap(options) {
let sumImaginary = 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const spectrumIndex = spectrumOffset + microphoneIndex;
const inputReal = spectrum.real[spectrumIndex];
const inputImaginary = spectrum.imaginary[spectrumIndex];
const inputReal = inputSpectrum.real[spectrumIndex];
const inputImaginary = inputSpectrum.imaginary[spectrumIndex];
const phaseReal = phaseRealValues[steeringOffset + microphoneIndex];
const phaseImaginary = phaseImaginaryValues[steeringOffset + microphoneIndex];
sumReal += inputReal * phaseReal - inputImaginary * phaseImaginary;
@@ -265,6 +336,12 @@ export function createDasAnalysisPlan(options) {
if (!Number.isSafeInteger(maximumPlanBytes) || maximumPlanBytes <= 0) {
throw new RangeError("maximumPlanBytes must be a positive safe integer.");
}
const algorithm = requireLocalizationAlgorithmDefinition(
experiment.beamforming?.algorithm ?? "das",
);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`createDasAnalysisPlan does not implement ${algorithm.id}.`);
}
const { fftSize } = experiment.stft;
const bandBins = experiment.frequencyBands.map((band) => binsForBand(band, sampleRate, fftSize));
@@ -319,6 +396,7 @@ export function createDasAnalysisPlan(options) {
arrayDefinition,
scene,
experiment,
algorithm,
microphoneCount,
pointCount,
bandBins,
@@ -394,14 +472,16 @@ function planCalibration(plan, bin) {
plan.statistics.calibrationCacheHits += 1;
return calibration;
}
calibration = createBinCalibration(
plan.arrayDefinition,
calibration = createBinCalibration({
arrayDefinition: plan.arrayDefinition,
bin,
plan.sampleRate,
plan.experiment.stft.fftSize,
plan.experiment.beamforming.compensateFrequencyResponse !== false,
plan.experiment.beamforming.maxCompensationDb,
);
sampleRate: plan.sampleRate,
fftSize: plan.experiment.stft.fftSize,
compensateFrequencyResponse:
plan.experiment.beamforming.compensateFrequencyResponse !== false,
maxCompensationDb: plan.experiment.beamforming.maxCompensationDb,
includeAmplitude: plan.algorithm.spectralWeighting !== "phat",
});
plan.calibrations.set(bin, calibration);
plan.statistics.calibrationBuilds += 1;
plan.statistics.retainedBytes += calibration.gains.byteLength + calibration.phases.byteLength;
@@ -431,6 +511,7 @@ async function planSteeringMatrix(plan, bin, calibration, signal, pointBatchSize
fftSize: plan.experiment.stft.fftSize,
speedOfSound: plan.scene.speedOfSound,
calibration,
applyCalibrationGains: plan.algorithm.spectralWeighting !== "phat",
signal,
pointBatchSize,
});
@@ -520,15 +601,27 @@ export function estimateDasWorkload(options) {
};
}
export function finalizeDasBands(rawMaps, workload, experiment) {
export function finalizeDasBands(
rawMaps,
workload,
experiment,
rawScale = "power",
options = {},
) {
if (rawScale !== "power" && rawScale !== "score") {
throw new RangeError('rawScale must be "power" or "score".');
}
const averageBandBins = options.averageBandBins !== false;
return experiment.frequencyBands.map((band, bandIndex) => {
const rawMap = rawMaps[bandIndex];
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
rawMap[pointIndex] /= workload.bandBins[bandIndex].length;
if (averageBandBins) {
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
rawMap[pointIndex] /= workload.bandBins[bandIndex].length;
}
}
const rawPeak = findPeak(rawMap);
const displayMap = Float32Array.from(rawMap);
let displayScale = "power";
let displayScale = rawScale;
if (experiment.beamforming.normalization === "peak" && rawPeak.value > 0) {
displayScale = "normalized";
for (let pointIndex = 0; pointIndex < workload.pointCount; pointIndex += 1) {
@@ -540,12 +633,17 @@ export function finalizeDasBands(rawMaps, workload, experiment) {
color: band.color,
bins: workload.bandBins[bandIndex],
rawMap,
rawScale,
displayMap,
displayScale,
peak: {
index: rawPeak.index,
position: scanIndexToPosition(rawPeak.index, experiment.scan),
rawPower: rawPeak.value,
rawValue: rawPeak.value,
rawScale,
...(rawScale === "power"
? { rawPower: rawPeak.value }
: { rawScore: rawPeak.value }),
},
};
});
@@ -566,7 +664,14 @@ async function computeDasMapInternal(options, analysisPlan) {
maximumSpectrumBatchBins = Number.MAX_SAFE_INTEGER,
pointBatchSize = 64,
stftTransformBatchSize = 32,
phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
} = options;
const algorithm = analysisPlan?.algorithm ?? requireLocalizationAlgorithmDefinition(
experiment.beamforming?.algorithm ?? "das",
);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`computeDasMap does not implement ${algorithm.id}.`);
}
if (channels.length !== arrayDefinition.microphones.length) {
throw new Error(`채널 수 ${channels.length}와 마이크 수 ${arrayDefinition.microphones.length}가 다릅니다.`);
}
@@ -685,6 +790,8 @@ async function computeDasMapInternal(options, analysisPlan) {
steeringImaginary: analysisPlan?.steeringImaginary,
signal,
pointBatchSize,
algorithm: algorithm.id,
phatRelativeFloor,
});
if (analysisPlan) {
analysisPlan.statistics.beamformingComplexMacs +=
@@ -701,9 +808,10 @@ async function computeDasMapInternal(options, analysisPlan) {
}
}
const bands = finalizeDasBands(rawMaps, workload, experiment);
const bands = finalizeDasBands(rawMaps, workload, experiment, algorithm.rawScale);
return {
algorithm: algorithm.id,
backend: "cpu",
frameCount: workload.frameCount,
microphoneCount: workload.microphoneCount,
+44 -9
View File
@@ -50,8 +50,12 @@ function normalizeRawMap(rawMap, scan) {
if (!Array.isArray(values) && !ArrayBuffer.isView(values)) {
throw new TypeError("rawMap must be an Array, a typed array, or a map descriptor with values.");
}
if (descriptor?.scale !== undefined && descriptor.scale !== "power") {
throw new RangeError("rawMap scale must be power.");
if (
descriptor?.scale !== undefined &&
descriptor.scale !== "power" &&
descriptor.scale !== "score"
) {
throw new RangeError("rawMap scale must be power or score.");
}
if (descriptor?.rows !== undefined && descriptor.rows !== scan.rows) {
throw new RangeError("rawMap rows do not match scan.rows.");
@@ -69,6 +73,13 @@ function normalizeRawMap(rawMap, scan) {
return values;
}
function rawMapScale(rawMap) {
if (rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap)) {
return rawMap.scale ?? "power";
}
return "power";
}
function normalizeSources(sources) {
if (!Array.isArray(sources)) throw new TypeError("sources must be an array.");
const ids = new Set();
@@ -420,12 +431,14 @@ function collectHalfPowerIndices(values, scan, peakIndex, halfPowerRatio) {
return { indices: queue.subarray(0, queueEnd), thresholdValue };
}
function measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio) {
function measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio, mapScale = "power") {
const component = collectHalfPowerIndices(values, scan, peakIndex, halfPowerRatio);
const peakPosition = scanIndexToPosition(peakIndex, scan);
const base = {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
method: mapScale === "score"
? "connected-half-maximum-score-weighted-covariance"
: "connected-half-power-power-weighted-covariance",
halfPowerRatio,
thresholdValue: component.thresholdValue,
pixelCount: component.indices.length,
@@ -506,18 +519,28 @@ export function measureBeamSpread(rawMap, scan, peakIndex, halfPowerRatio = 0.5)
throw new RangeError("peakIndex is outside rawMap.");
}
requireRatio("halfPowerRatio", halfPowerRatio, { positive: true });
return measureBeamSpreadFromValues(values, scan, peakIndex, halfPowerRatio);
return measureBeamSpreadFromValues(
values,
scan,
peakIndex,
halfPowerRatio,
rawMapScale(rawMap),
);
}
/**
* Evaluate one linear-power DAS band. Source z coordinates are projected onto
* scan.z, matching the image-plane ground-truth convention.
* Evaluate one nonnegative spatial power or score map. Source z coordinates
* are projected onto scan.z, matching the image-plane ground-truth convention.
*/
export function evaluateDasBand(options) {
export function evaluateLocalizationBand(options) {
if (!options || typeof options !== "object") throw new TypeError("evaluation options are required.");
const { rawMap, scan } = options;
validateScan(scan);
const values = normalizeRawMap(rawMap, scan);
const mapScale = options.mapScale ?? rawMapScale(rawMap);
if (mapScale !== "power" && mapScale !== "score") {
throw new RangeError("mapScale must be power or score.");
}
const sources = normalizeSources(options.sources ?? options.scene?.sources ?? []);
const truths = sources.map((source) => ({
sourceId: source.id,
@@ -558,7 +581,13 @@ export function evaluateDasBand(options) {
const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
(detection) => ({
...detection,
beamSpread: measureBeamSpreadFromValues(values, scan, detection.index, halfPowerRatio),
beamSpread: measureBeamSpreadFromValues(
values,
scan,
detection.index,
halfPowerRatio,
mapScale,
),
}),
);
const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
@@ -604,6 +633,7 @@ export function evaluateDasBand(options) {
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
},
diagnostics: {
mapScale,
mainLobe,
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
gridResolutionM: resolution,
@@ -611,6 +641,11 @@ export function evaluateDasBand(options) {
};
}
/** Backwards-compatible name for callers that evaluate conventional DAS maps. */
export function evaluateDasBand(options) {
return evaluateLocalizationBand(options);
}
/** Select only metric names accepted by result.schema.json. */
export function selectResultMetrics(evaluationOrMetrics, metricNames = RESULT_METRIC_KEYS) {
const metrics = evaluationOrMetrics?.metrics ?? evaluationOrMetrics;
+892
View File
@@ -0,0 +1,892 @@
import {
createBinCalibration,
createDistanceMatrix,
finalizeDasBands,
} from "./das.js";
import { cooperativeYield } from "./cooperative-yield.js";
import { fftInPlace } from "./fft.js";
import {
binsForBand,
createStftWorkspace,
extractStftBins,
getFrameCount,
} from "./stft.js";
export const GCC_PHAT_ANALYSIS_PLAN_KIND = "micarrsim.gcc-phat-analysis-plan/1";
export const DEFAULT_GCC_PHAT_FLOOR_RATIO = 1e-6;
export const DEFAULT_GCC_PHAT_POINT_BATCH_SIZE = 4096;
export const DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE = 16384;
export const DEFAULT_MAXIMUM_GCC_PHAT_PLAN_BYTES = 512 * 1024 * 1024;
export const DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES = 512 * 1024 * 1024;
function createAbortError(signal) {
if (signal?.reason instanceof Error && signal.reason.name === "AbortError") {
return signal.reason;
}
const error = new Error("GCC-PHAT localization was canceled.");
error.name = "AbortError";
return error;
}
function throwIfAborted(signal) {
if (signal?.aborted) throw createAbortError(signal);
}
function requirePositiveFinite(value, label) {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(label + " must be a positive finite number.");
}
return value;
}
function requirePositiveInteger(value, label) {
if (!Number.isInteger(value) || value < 1) {
throw new RangeError(label + " must be a positive integer.");
}
return value;
}
function validateArrayDefinition(arrayDefinition) {
if (!Array.isArray(arrayDefinition?.microphones) || arrayDefinition.microphones.length < 2) {
throw new RangeError("GCC-PHAT requires at least two microphones.");
}
arrayDefinition.microphones.forEach((microphone, index) => {
if (
!Array.isArray(microphone?.position) ||
microphone.position.length !== 3 ||
!microphone.position.every(Number.isFinite)
) {
throw new TypeError(
"arrayDefinition.microphones[" + index + "].position must be a finite [x, y, z] array.",
);
}
});
return arrayDefinition;
}
function validateConfiguration(options) {
const sampleRate = requirePositiveFinite(options.sampleRate, "sampleRate");
const arrayDefinition = validateArrayDefinition(options.arrayDefinition);
const speedOfSound = requirePositiveFinite(options.scene?.speedOfSound, "scene.speedOfSound");
const experiment = options.experiment;
if (!experiment?.stft || !experiment?.scan || !Array.isArray(experiment.frequencyBands)) {
throw new TypeError("experiment must contain stft, scan, and frequencyBands.");
}
const fftSize = requirePositiveInteger(experiment.stft.fftSize, "experiment.stft.fftSize");
if ((fftSize & (fftSize - 1)) !== 0) {
throw new RangeError("experiment.stft.fftSize must be a power of two.");
}
const hopSize = requirePositiveInteger(experiment.stft.hopSize, "experiment.stft.hopSize");
if (hopSize > fftSize) {
throw new RangeError("experiment.stft.hopSize must not exceed fftSize.");
}
const pointCount =
requirePositiveInteger(experiment.scan.columns, "experiment.scan.columns") *
requirePositiveInteger(experiment.scan.rows, "experiment.scan.rows");
if (!Number.isSafeInteger(pointCount)) {
throw new RangeError("scan point count exceeds the safe integer range.");
}
if (experiment.frequencyBands.length === 0) {
throw new RangeError("experiment.frequencyBands must not be empty.");
}
return { sampleRate, arrayDefinition, speedOfSound, experiment, fftSize, hopSize, pointCount };
}
function validateChannels(channels, microphoneCount) {
if (!Array.isArray(channels) || channels.length !== microphoneCount) {
throw new Error(
"channel count " + (channels?.length ?? 0) +
" does not match microphone count " + microphoneCount + ".",
);
}
if (!channels.every((channel) => channel instanceof Float32Array)) {
throw new TypeError("every channel must be a Float32Array.");
}
const sampleCount = channels[0].length;
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
if (channels.some((channel) => channel.length !== sampleCount)) {
throw new Error("all channels must have the same sample count.");
}
return sampleCount;
}
function referenceTieBreak(left, right) {
if (left.id < right.id) return -1;
if (left.id > right.id) return 1;
return left.index - right.index;
}
/**
* Select a stable reference without depending on microphone input order.
*
* A peripheral microphone generally provides a longer TDOA baseline. Exact
* geometric ties are resolved by microphone id and finally by input index.
*/
export function selectGccPhatReference(arrayDefinition) {
validateArrayDefinition(arrayDefinition);
const centroid = [0, 0, 0];
for (const microphone of arrayDefinition.microphones) {
for (let axis = 0; axis < 3; axis += 1) centroid[axis] += microphone.position[axis];
}
for (let axis = 0; axis < 3; axis += 1) {
centroid[axis] /= arrayDefinition.microphones.length;
}
let selected;
arrayDefinition.microphones.forEach((microphone, index) => {
const descriptor = {
index,
id: String(microphone.id ?? ""),
microphone,
distanceSquared:
(microphone.position[0] - centroid[0]) ** 2 +
(microphone.position[1] - centroid[1]) ** 2 +
(microphone.position[2] - centroid[2]) ** 2,
};
if (!selected) {
selected = descriptor;
return;
}
if (
descriptor.distanceSquared > selected.distanceSquared ||
(
descriptor.distanceSquared === selected.distanceSquared &&
referenceTieBreak(descriptor, selected) < 0
)
) {
selected = descriptor;
}
});
return Object.freeze({
index: selected.index,
id: selected.id,
microphone: selected.microphone,
});
}
function createBandGeometry(configuration) {
const bandBins = configuration.experiment.frequencyBands.map((band) =>
binsForBand(band, configuration.sampleRate, configuration.fftSize)
);
const selectedBins = [...new Set(bandBins.flat())].sort((left, right) => left - right);
return { bandBins, selectedBins };
}
function maximumGeometricLagSamples({
distances,
pointCount,
microphoneCount,
referenceIndex,
sampleRate,
speedOfSound,
}) {
let maximumLagSamples = 0;
for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
const distanceOffset = pointIndex * microphoneCount;
const referenceDistance = distances[distanceOffset + referenceIndex];
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
if (microphoneIndex === referenceIndex) continue;
const lagSamples =
Math.abs(distances[distanceOffset + microphoneIndex] - referenceDistance) *
sampleRate / speedOfSound;
maximumLagSamples = Math.max(maximumLagSamples, lagSamples);
}
}
return maximumLagSamples;
}
function assertUnaliasedLag(maximumLagSamples, fftSize) {
const maximumUnaliasedLag = fftSize / 2;
if (maximumLagSamples > maximumUnaliasedLag) {
throw new RangeError(
"GCC-PHAT maximum absolute geometric lag " + maximumLagSamples.toFixed(3) +
" samples exceeds fftSize/2 (" + maximumUnaliasedLag +
"); increase fftSize or reduce the array aperture or scan range.",
);
}
}
function createGeometry(configuration, distances) {
const microphoneCount = configuration.arrayDefinition.microphones.length;
const reference = selectGccPhatReference(configuration.arrayDefinition);
const pairMicrophoneIndexes = [];
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
if (microphoneIndex !== reference.index) pairMicrophoneIndexes.push(microphoneIndex);
}
const resolvedDistances =
distances ?? createDistanceMatrix(configuration.arrayDefinition, configuration.experiment.scan);
const maximumLagSamples = maximumGeometricLagSamples({
distances: resolvedDistances,
pointCount: configuration.pointCount,
microphoneCount,
referenceIndex: reference.index,
sampleRate: configuration.sampleRate,
speedOfSound: configuration.speedOfSound,
});
assertUnaliasedLag(maximumLagSamples, configuration.fftSize);
return {
microphoneCount,
reference,
pairMicrophoneIndexes,
pairCount: pairMicrophoneIndexes.length,
distances: resolvedDistances,
maximumLagSamples,
...createBandGeometry(configuration),
};
}
function requirePhatFloorRatio(value) {
const ratio = value ?? DEFAULT_GCC_PHAT_FLOOR_RATIO;
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) {
throw new RangeError("phatFloorRatio must be a finite number from 0 through 1.");
}
return ratio;
}
function requireMaximumWorkingBytes(value) {
const maximumWorkingBytes = value ?? DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES;
if (!Number.isSafeInteger(maximumWorkingBytes) || maximumWorkingBytes <= 0) {
throw new RangeError("maximumWorkingBytes must be a positive safe integer.");
}
return maximumWorkingBytes;
}
/**
* Reject an oversized no-plan analysis before createDistanceMatrix allocates
* its P x M table. This mirrors estimateWithGeometry's retained/spectrum/
* calibration/output accounting without requiring geometry to exist first.
*/
function preflightUnplannedWorkingSet(options, configuration) {
const microphoneCount = configuration.arrayDefinition.microphones.length;
const sampleCount = validateChannels(options.channels, microphoneCount);
const { bandBins, selectedBins } = createBandGeometry(configuration);
const frameCount = getFrameCount(
sampleCount,
configuration.fftSize,
configuration.hopSize,
);
const bandCount = bandBins.length;
const distanceBytes =
configuration.pointCount * microphoneCount * Float32Array.BYTES_PER_ELEMENT;
const stftWorkspaceBytes =
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
const correlationWorkspaceBytes =
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 2;
const spectrumBytes =
selectedBins.length * frameCount * microphoneCount *
Float32Array.BYTES_PER_ELEMENT * 2;
const calibrationPhaseBytes =
selectedBins.length * microphoneCount * Float64Array.BYTES_PER_ELEMENT;
const outputBytes =
bandCount * configuration.pointCount * Float32Array.BYTES_PER_ELEMENT * 2;
const estimatedWorkingBytes =
distanceBytes + stftWorkspaceBytes + correlationWorkspaceBytes +
spectrumBytes + calibrationPhaseBytes + outputBytes;
if (!Number.isSafeInteger(estimatedWorkingBytes)) {
throw new RangeError("GCC-PHAT estimated working set exceeds the safe integer range.");
}
const maximumWorkingBytes = requireMaximumWorkingBytes(options.maximumWorkingBytes);
if (estimatedWorkingBytes > maximumWorkingBytes) {
throw new RangeError(
"GCC-PHAT estimated working set " +
(estimatedWorkingBytes / 1024 ** 2).toFixed(1) +
" MiB exceeds maximumWorkingBytes " +
(maximumWorkingBytes / 1024 ** 2).toFixed(1) + " MiB before geometry allocation.",
);
}
return { estimatedWorkingBytes, maximumWorkingBytes };
}
function estimateWithGeometry(options, configuration, geometry) {
const sampleCount = validateChannels(options.channels, geometry.microphoneCount);
const frameCount = getFrameCount(
sampleCount,
configuration.fftSize,
configuration.hopSize,
);
const pairCount = geometry.pairCount;
const bandCount = geometry.bandBins.length;
const bandBinCount = geometry.bandBins.reduce((sum, bins) => sum + bins.length, 0);
const selectedBinCount = geometry.selectedBins.length;
const stftTransformCount = frameCount * geometry.microphoneCount;
const inverseFftCount = pairCount * bandCount;
const fftButterfliesPerTransform =
(configuration.fftSize / 2) * Math.log2(configuration.fftSize);
const stftButterflyCount = stftTransformCount * fftButterfliesPerTransform;
const inverseFftButterflyCount = inverseFftCount * fftButterfliesPerTransform;
const crossSpectrumSampleCount = pairCount * frameCount * bandBinCount;
// The relative-floor pass finds the maximum magnitude, then the PHAT pass
// recomputes and accumulates each complex cross-spectrum sample.
const crossSpectrumComplexOpCount = crossSpectrumSampleCount * 2;
const scanLookupCount = configuration.pointCount * pairCount * bandCount;
const dasEquivalentComplexMacCount =
configuration.pointCount * geometry.microphoneCount * frameCount * selectedBinCount;
const distanceBytes =
configuration.pointCount * geometry.microphoneCount * Float32Array.BYTES_PER_ELEMENT;
const stftWorkspaceBytes =
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
const correlationWorkspaceBytes =
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 2;
const spectrumBytes =
selectedBinCount * frameCount * geometry.microphoneCount *
Float32Array.BYTES_PER_ELEMENT * 2;
const calibrationPhaseBytes =
selectedBinCount * geometry.microphoneCount * Float64Array.BYTES_PER_ELEMENT;
const outputBytes =
bandCount * configuration.pointCount * Float32Array.BYTES_PER_ELEMENT * 2;
const retainedPlanBytes =
options.analysisPlan?.retainedBytes ??
distanceBytes + stftWorkspaceBytes + correlationWorkspaceBytes;
const estimatedWorkingBytes =
retainedPlanBytes + spectrumBytes + calibrationPhaseBytes + outputBytes;
return {
bandBins: geometry.bandBins,
selectedBins: geometry.selectedBins,
selectedBinCount,
bandBinCount,
frameCount,
microphoneCount: geometry.microphoneCount,
pointCount: configuration.pointCount,
bandCount,
pairCount,
referenceMicrophoneId: geometry.reference.id,
referenceMicrophoneIndex: geometry.reference.index,
maximumLagSamples: geometry.maximumLagSamples,
stftTransformCount,
inverseFftCount,
stftButterflyCount,
inverseFftButterflyCount,
crossSpectrumSampleCount,
crossSpectrumComplexOpCount,
crossSpectrumCount: crossSpectrumComplexOpCount,
complexMacCount: crossSpectrumComplexOpCount,
scanLookupCount,
operationCount: crossSpectrumComplexOpCount + scanLookupCount,
dasEquivalentComplexMacCount,
distanceBytes,
stftWorkspaceBytes,
correlationWorkspaceBytes,
spectrumBytes,
calibrationPhaseBytes,
outputBytes,
retainedPlanBytes,
estimatedWorkingBytes,
};
}
/**
* Estimate CPU work for one reference-pair GCC-PHAT analysis.
*/
export function estimateGccPhatWorkload(options) {
if (!options || typeof options !== "object") {
throw new TypeError("options must be an object.");
}
const configuration = validateConfiguration(options);
let geometry;
if (options.analysisPlan) {
assertCompatibleAnalysisPlan(options.analysisPlan, options);
geometry = options.analysisPlan;
} else {
preflightUnplannedWorkingSet(options, configuration);
geometry = createGeometry(configuration);
}
return estimateWithGeometry(options, configuration, geometry);
}
/**
* Retain geometry and FFT workspaces for repeated analyses with one immutable
* array/scan/band configuration. Plans are stateful and cannot run concurrently.
*/
export function createGccPhatAnalysisPlan(options) {
if (!options || typeof options !== "object") {
throw new TypeError("options must be an object.");
}
const configuration = validateConfiguration(options);
const maximumPlanBytes =
options.maximumPlanBytes ?? DEFAULT_MAXIMUM_GCC_PHAT_PLAN_BYTES;
if (!Number.isSafeInteger(maximumPlanBytes) || maximumPlanBytes <= 0) {
throw new RangeError("maximumPlanBytes must be a positive safe integer.");
}
const microphoneCount = configuration.arrayDefinition.microphones.length;
const pointMicrophonePairs = configuration.pointCount * microphoneCount;
const distanceBytes = pointMicrophonePairs * Float32Array.BYTES_PER_ELEMENT;
const stftWorkspaceBytes =
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 3;
const correlationWorkspaceBytes =
configuration.fftSize * Float64Array.BYTES_PER_ELEMENT * 2;
const retainedBytes = distanceBytes + stftWorkspaceBytes + correlationWorkspaceBytes;
if (
!Number.isSafeInteger(pointMicrophonePairs) ||
!Number.isSafeInteger(retainedBytes) ||
retainedBytes > maximumPlanBytes
) {
throw new RangeError(
"GCC-PHAT analysis plan requires " + retainedBytes.toLocaleString() +
" bytes, above maximumPlanBytes " + maximumPlanBytes.toLocaleString() + ".",
);
}
const distances = createDistanceMatrix(
configuration.arrayDefinition,
configuration.experiment.scan,
);
const geometry = createGeometry(configuration, distances);
const plan = {
kind: GCC_PHAT_ANALYSIS_PLAN_KIND,
sampleRate: configuration.sampleRate,
arrayDefinition: configuration.arrayDefinition,
scene: options.scene,
experiment: configuration.experiment,
fftSize: configuration.fftSize,
hopSize: configuration.hopSize,
pointCount: configuration.pointCount,
maximumPlanBytes,
retainedBytes,
distanceBytes,
stftWorkspaceBytes,
correlationWorkspaceBytes,
stftWorkspace: createStftWorkspace(configuration.fftSize),
correlationReal: new Float64Array(configuration.fftSize),
correlationImaginary: new Float64Array(configuration.fftSize),
busy: false,
statistics: {
analysisAttempts: 0,
completedAnalyses: 0,
distanceMatrixBuilds: 1,
distanceEvaluations: pointMicrophonePairs,
avoidedDistanceEvaluations: 0,
stftWorkspaceAllocations: 1,
stftWorkspaceUses: 0,
avoidedStftWorkspaceAllocations: 0,
correlationWorkspaceAllocations: 1,
correlationWorkspaceUses: 0,
avoidedCorrelationWorkspaceAllocations: 0,
stftTransforms: 0,
inverseFftTransforms: 0,
crossSpectrumOperations: 0,
scanLookups: 0,
},
...geometry,
};
return plan;
}
function assertCompatibleAnalysisPlan(plan, options) {
if (plan?.kind !== GCC_PHAT_ANALYSIS_PLAN_KIND) {
throw new TypeError(
"analysisPlan must be created by createGccPhatAnalysisPlan().",
);
}
if (
plan.sampleRate !== options.sampleRate ||
plan.arrayDefinition !== options.arrayDefinition ||
plan.scene !== options.scene ||
plan.experiment !== options.experiment
) {
throw new Error(
"analysisPlan must be used with the exact configuration objects that created it.",
);
}
}
function analysisCacheSummary(plan) {
const statistics = plan.statistics;
return {
analysisAttempts: statistics.analysisAttempts,
completedAnalyses: statistics.completedAnalyses,
distanceMatrixBuilds: statistics.distanceMatrixBuilds,
distanceEvaluations: statistics.distanceEvaluations,
avoidedDistanceEvaluations: statistics.avoidedDistanceEvaluations,
stftWorkspaceAllocations: statistics.stftWorkspaceAllocations,
stftWorkspaceUses: statistics.stftWorkspaceUses,
avoidedStftWorkspaceAllocations: statistics.avoidedStftWorkspaceAllocations,
correlationWorkspaceAllocations: statistics.correlationWorkspaceAllocations,
correlationWorkspaceUses: statistics.correlationWorkspaceUses,
avoidedCorrelationWorkspaceAllocations:
statistics.avoidedCorrelationWorkspaceAllocations,
stftTransforms: statistics.stftTransforms,
inverseFftTransforms: statistics.inverseFftTransforms,
crossSpectrumOperations: statistics.crossSpectrumOperations,
scanLookups: statistics.scanLookups,
retainedBytes: plan.retainedBytes,
maximumPlanBytes: plan.maximumPlanBytes,
};
}
function circularLinearSample(values, lagSamples) {
const length = values.length;
const wrapped = ((lagSamples % length) + length) % length;
const firstIndex = Math.floor(wrapped);
const fraction = wrapped - firstIndex;
const secondIndex = (firstIndex + 1) % length;
return values[firstIndex] * (1 - fraction) + values[secondIndex] * fraction;
}
async function fillBandPairCorrelation({
correlationReal,
correlationImaginary,
bins,
stft,
microphoneIndex,
referenceIndex,
phasesByBin,
phatFloorRatio,
signal,
crossSpectrumBatchSize,
}) {
correlationReal.fill(0);
correlationImaginary.fill(0);
const microphoneCount = stft.microphoneCount;
let maximumMagnitude = 0;
let operationsUntilYield = crossSpectrumBatchSize;
for (const bin of bins) {
const spectrum = stft.spectra.get(bin);
for (let frameIndex = 0; frameIndex < stft.frameCount; frameIndex += 1) {
const offset = frameIndex * microphoneCount;
const microphoneReal = spectrum.real[offset + microphoneIndex];
const microphoneImaginary = spectrum.imaginary[offset + microphoneIndex];
const referenceReal = spectrum.real[offset + referenceIndex];
const referenceImaginary = spectrum.imaginary[offset + referenceIndex];
const crossReal =
microphoneReal * referenceReal + microphoneImaginary * referenceImaginary;
const crossImaginary =
microphoneImaginary * referenceReal - microphoneReal * referenceImaginary;
maximumMagnitude = Math.max(
maximumMagnitude,
Math.hypot(crossReal, crossImaginary),
);
operationsUntilYield -= 1;
if (operationsUntilYield === 0) {
await cooperativeYield();
throwIfAborted(signal);
operationsUntilYield = crossSpectrumBatchSize;
}
}
}
const magnitudeFloor = maximumMagnitude * phatFloorRatio;
if (!(maximumMagnitude > 0)) {
fftInPlace(correlationReal, correlationImaginary, true);
return;
}
for (const bin of bins) {
const spectrum = stft.spectra.get(bin);
const phases = phasesByBin.get(bin);
const correctionPhase = phases[referenceIndex] - phases[microphoneIndex];
const correctionReal = Math.cos(correctionPhase);
const correctionImaginary = Math.sin(correctionPhase);
let accumulatedReal = 0;
let accumulatedImaginary = 0;
for (let frameIndex = 0; frameIndex < stft.frameCount; frameIndex += 1) {
const offset = frameIndex * microphoneCount;
const microphoneReal = spectrum.real[offset + microphoneIndex];
const microphoneImaginary = spectrum.imaginary[offset + microphoneIndex];
const referenceReal = spectrum.real[offset + referenceIndex];
const referenceImaginary = spectrum.imaginary[offset + referenceIndex];
const crossReal =
microphoneReal * referenceReal + microphoneImaginary * referenceImaginary;
const crossImaginary =
microphoneImaginary * referenceReal - microphoneReal * referenceImaginary;
const magnitude = Math.hypot(crossReal, crossImaginary);
const denominator = Math.max(magnitude, magnitudeFloor);
if (!(denominator > 0)) continue;
const normalizedReal = crossReal / denominator;
const normalizedImaginary = crossImaginary / denominator;
accumulatedReal +=
normalizedReal * correctionReal - normalizedImaginary * correctionImaginary;
accumulatedImaginary +=
normalizedReal * correctionImaginary + normalizedImaginary * correctionReal;
operationsUntilYield -= 1;
if (operationsUntilYield === 0) {
await cooperativeYield();
throwIfAborted(signal);
operationsUntilYield = crossSpectrumBatchSize;
}
}
const averagedReal = accumulatedReal / stft.frameCount;
const averagedImaginary = accumulatedImaginary / stft.frameCount;
correlationReal[bin] = averagedReal;
correlationImaginary[bin] =
bin === 0 || bin === correlationReal.length / 2 ? 0 : averagedImaginary;
if (bin > 0 && bin < correlationReal.length / 2) {
const negativeBin = correlationReal.length - bin;
correlationReal[negativeBin] = averagedReal;
correlationImaginary[negativeBin] = -averagedImaginary;
}
}
fftInPlace(correlationReal, correlationImaginary, true);
}
async function normalizePositiveScores(
rawMaps,
bandBins,
fftSize,
pairCount,
pointBatchSize,
signal,
) {
for (let bandIndex = 0; bandIndex < rawMaps.length; bandIndex += 1) {
const rawMap = rawMaps[bandIndex];
const coefficientCount = bandBins[bandIndex].reduce(
(count, bin) => count + (bin === 0 || bin === fftSize / 2 ? 1 : 2),
0,
);
const normalization = fftSize / (pairCount * coefficientCount);
for (let pointIndex = 0; pointIndex < rawMap.length; pointIndex += 1) {
// PHAT coefficients have magnitude <= 1. After Hermitian completion and
// inverse-FFT scaling, coefficientCount / fftSize is therefore a strict
// per-pair bound. Pair averaging makes the positive score stable across
// microphone count, band width, and scan extent without implying a
// probability. Negative correlation is evidence against the hypothesis.
rawMap[pointIndex] = Math.min(
1,
Math.max(0, rawMap[pointIndex] * normalization),
);
if (
(pointIndex + 1) % pointBatchSize === 0 &&
pointIndex + 1 < rawMap.length
) {
await cooperativeYield();
throwIfAborted(signal);
}
}
}
}
async function computeGccPhatMapInternal(options, configuration, geometry, plan) {
const {
channels,
signal,
onProgress,
maximumWorkingBytes = DEFAULT_MAXIMUM_GCC_PHAT_WORKING_BYTES,
maximumFftButterflies = 1_000_000_000,
pointBatchSize = DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
crossSpectrumBatchSize = DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
stftTransformBatchSize = 128,
} = options;
requirePositiveInteger(pointBatchSize, "pointBatchSize");
requirePositiveInteger(crossSpectrumBatchSize, "crossSpectrumBatchSize");
requirePositiveInteger(stftTransformBatchSize, "stftTransformBatchSize");
requireMaximumWorkingBytes(maximumWorkingBytes);
const phatFloorRatio = requirePhatFloorRatio(
options.phatFloorRatio ??
options.phatRelativeFloor ??
configuration.experiment.beamforming?.phatFloorRatio,
);
const workload = estimateWithGeometry(options, configuration, geometry);
if (workload.estimatedWorkingBytes > maximumWorkingBytes) {
throw new RangeError(
"GCC-PHAT estimated working set " +
(workload.estimatedWorkingBytes / 1024 ** 2).toFixed(1) +
" MiB exceeds maximumWorkingBytes " +
(maximumWorkingBytes / 1024 ** 2).toFixed(1) + " MiB.",
);
}
const totalFftButterflies =
workload.stftButterflyCount + workload.inverseFftButterflyCount;
if (totalFftButterflies > maximumFftButterflies) {
throw new RangeError(
"GCC-PHAT estimated FFT butterfly count " +
Math.ceil(totalFftButterflies).toLocaleString() +
" exceeds maximumFftButterflies " +
maximumFftButterflies.toLocaleString() + ".",
);
}
throwIfAborted(signal);
if (plan) {
plan.statistics.stftWorkspaceUses += 1;
plan.statistics.avoidedStftWorkspaceAllocations = Math.max(
0,
plan.statistics.stftWorkspaceUses - 1,
);
plan.statistics.correlationWorkspaceUses += 1;
plan.statistics.avoidedCorrelationWorkspaceAllocations = Math.max(
0,
plan.statistics.correlationWorkspaceUses - 1,
);
}
const stft = await extractStftBins(channels, {
fftSize: configuration.fftSize,
hopSize: configuration.hopSize,
bins: geometry.selectedBins,
workspace: plan?.stftWorkspace,
transformBatchSize: stftTransformBatchSize,
signal,
onProgress: ({ completedTransforms, totalTransforms }) =>
onProgress?.({
stage: "stft",
algorithm: "gcc-phat",
completedTransforms,
totalTransforms,
}),
});
if (plan) {
plan.statistics.stftTransforms += workload.stftTransformCount;
}
await cooperativeYield();
throwIfAborted(signal);
const phasesByBin = new Map();
for (const bin of geometry.selectedBins) {
const calibration = createBinCalibration({
arrayDefinition: configuration.arrayDefinition,
bin,
sampleRate: configuration.sampleRate,
fftSize: configuration.fftSize,
compensateFrequencyResponse:
configuration.experiment.beamforming?.compensateFrequencyResponse !== false,
maxCompensationDb:
configuration.experiment.beamforming?.maxCompensationDb,
includeAmplitude: false,
});
phasesByBin.set(bin, calibration.phases);
}
const correlationReal =
plan?.correlationReal ?? new Float64Array(configuration.fftSize);
const correlationImaginary =
plan?.correlationImaginary ?? new Float64Array(configuration.fftSize);
const rawMaps = geometry.bandBins.map(() => new Float32Array(configuration.pointCount));
const totalPairs = geometry.pairCount * geometry.bandBins.length;
let completedPairs = 0;
for (let bandIndex = 0; bandIndex < geometry.bandBins.length; bandIndex += 1) {
const bins = geometry.bandBins[bandIndex];
const rawMap = rawMaps[bandIndex];
for (const microphoneIndex of geometry.pairMicrophoneIndexes) {
throwIfAborted(signal);
await fillBandPairCorrelation({
correlationReal,
correlationImaginary,
bins,
stft,
microphoneIndex,
referenceIndex: geometry.reference.index,
phasesByBin,
phatFloorRatio,
signal,
crossSpectrumBatchSize,
});
if (plan) {
plan.statistics.inverseFftTransforms += 1;
plan.statistics.crossSpectrumOperations +=
bins.length * stft.frameCount * 2;
}
for (let pointIndex = 0; pointIndex < configuration.pointCount; pointIndex += 1) {
const distanceOffset = pointIndex * geometry.microphoneCount;
const predictedLagSamples =
(
geometry.distances[distanceOffset + microphoneIndex] -
geometry.distances[distanceOffset + geometry.reference.index]
) *
configuration.sampleRate / configuration.speedOfSound;
rawMap[pointIndex] += circularLinearSample(
correlationReal,
predictedLagSamples,
);
if (
(pointIndex + 1) % pointBatchSize === 0 &&
pointIndex + 1 < configuration.pointCount
) {
await cooperativeYield();
throwIfAborted(signal);
}
}
if (plan) plan.statistics.scanLookups += configuration.pointCount;
completedPairs += 1;
onProgress?.({
stage: "localization",
algorithm: "gcc-phat",
completedPairs,
totalPairs,
});
if (completedPairs < totalPairs) await cooperativeYield();
}
}
throwIfAborted(signal);
await normalizePositiveScores(
rawMaps,
geometry.bandBins,
configuration.fftSize,
geometry.pairCount,
pointBatchSize,
signal,
);
const bands = finalizeDasBands(
rawMaps,
workload,
configuration.experiment,
"score",
{ averageBandBins: false },
);
return {
backend: "cpu",
algorithm: "gcc-phat",
frameCount: workload.frameCount,
microphoneCount: workload.microphoneCount,
pointCount: workload.pointCount,
referenceMicrophoneId: geometry.reference.id,
referenceMicrophoneIndex: geometry.reference.index,
pairCount: geometry.pairCount,
phatFloorRatio,
maximumLagSamples: geometry.maximumLagSamples,
workload,
bands,
...(plan ? { analysisCache: analysisCacheSummary(plan) } : {}),
};
}
/**
* Build band-limited reference-pair GCC-PHAT maps on the CPU.
*
* For each pair and band, corrected PHAT cross spectra are averaged over STFT
* frames, transformed once to circular correlation, and sampled at the
* near-field geometric delay of each scan point.
*/
export async function computeGccPhatMap(options) {
if (!options || typeof options !== "object") {
throw new TypeError("options must be an object.");
}
const configuration = validateConfiguration(options);
const plan = options.analysisPlan;
if (!plan) {
preflightUnplannedWorkingSet(options, configuration);
const geometry = createGeometry(configuration);
return computeGccPhatMapInternal(options, configuration, geometry, undefined);
}
assertCompatibleAnalysisPlan(plan, options);
if (plan.busy) {
throw new Error("analysisPlan is already in use by another GCC-PHAT analysis.");
}
plan.busy = true;
plan.statistics.analysisAttempts += 1;
if (plan.statistics.analysisAttempts > 1) {
plan.statistics.avoidedDistanceEvaluations +=
plan.pointCount * plan.microphoneCount;
}
try {
const result = await computeGccPhatMapInternal(
options,
configuration,
plan,
plan,
);
plan.statistics.completedAnalyses += 1;
result.analysisCache = analysisCacheSummary(plan);
return result;
} finally {
plan.busy = false;
}
}
+3
View File
@@ -4,6 +4,9 @@ export * from "./environment.js";
export * from "./fft.js";
export * from "./frequency-response.js";
export * from "./fractional-delay.js";
export * from "./gcc-phat.js";
export * from "./localization.js";
export * from "./localization-algorithms.js";
export * from "./math.js";
export * from "./mixdown.js";
export * from "./noise.js";
@@ -0,0 +1,52 @@
export const DEFAULT_LOCALIZATION_ALGORITHM = "das";
export const DEFAULT_PHAT_RELATIVE_FLOOR = 1e-6;
function definition(id, label, rawScale, spectralWeighting, backends) {
return Object.freeze({
id,
label,
rawScale,
spectralWeighting,
backends: Object.freeze([...backends]),
});
}
const ALGORITHM_DEFINITIONS = Object.freeze([
definition("das", "DAS", "power", "none", ["cpu", "webgpu"]),
definition("das-phat", "DAS-PHAT", "score", "phat", ["cpu", "webgpu"]),
definition("gcc-phat", "GCC-PHAT", "score", "phat", ["cpu"]),
]);
const ALGORITHM_BY_ID = new Map(
ALGORITHM_DEFINITIONS.map((algorithm) => [algorithm.id, algorithm]),
);
export const LOCALIZATION_ALGORITHM_IDS = Object.freeze(
ALGORITHM_DEFINITIONS.map((algorithm) => algorithm.id),
);
export function localizationAlgorithmDefinition(id = DEFAULT_LOCALIZATION_ALGORITHM) {
return ALGORITHM_BY_ID.get(id);
}
export function requireLocalizationAlgorithmDefinition(
id = DEFAULT_LOCALIZATION_ALGORITHM,
) {
const algorithm = localizationAlgorithmDefinition(id);
if (!algorithm) {
throw new RangeError(`unsupported localization algorithm: ${id}`);
}
return algorithm;
}
export function isDasAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
return id === "das" || id === "das-phat";
}
export function localizationAlgorithmLabel(id = DEFAULT_LOCALIZATION_ALGORITHM) {
return localizationAlgorithmDefinition(id)?.label ?? String(id);
}
export function localizationAlgorithmSupportsBackend(id, backend) {
return localizationAlgorithmDefinition(id)?.backends.includes(backend) ?? false;
}
+53
View File
@@ -0,0 +1,53 @@
import {
computeDasMap,
createDasAnalysisPlan,
estimateDasWorkload,
} from "./das.js";
import {
computeGccPhatMap,
createGccPhatAnalysisPlan,
estimateGccPhatWorkload,
} from "./gcc-phat.js";
import {
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
} from "./localization-algorithms.js";
function requestedAlgorithm(options) {
return requireLocalizationAlgorithmDefinition(
options?.experiment?.beamforming?.algorithm,
);
}
/**
* Estimate implementation-level work for whichever localization algorithm the
* experiment requests.
*/
export function estimateLocalizationWorkload(options) {
const algorithm = requestedAlgorithm(options);
if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(options);
if (algorithm.id === "gcc-phat") return estimateGccPhatWorkload(options);
throw new RangeError("No workload estimator is registered for " + algorithm.id + ".");
}
/**
* Compile immutable geometry/workspace state for repeated CPU analyses.
*/
export function createLocalizationAnalysisPlan(options) {
const algorithm = requestedAlgorithm(options);
if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(options);
if (algorithm.id === "gcc-phat") return createGccPhatAnalysisPlan(options);
throw new RangeError("No analysis-plan factory is registered for " + algorithm.id + ".");
}
/**
* Compute a CPU localization map using the algorithm selected by the
* experiment. The returned `algorithm` and each band's `rawScale` make the
* result semantics explicit.
*/
export async function computeLocalizationMap(options) {
const algorithm = requestedAlgorithm(options);
if (isDasAlgorithm(algorithm.id)) return computeDasMap(options);
if (algorithm.id === "gcc-phat") return computeGccPhatMap(options);
throw new RangeError("No CPU localization implementation is registered for " + algorithm.id + ".");
}
+60 -10
View File
@@ -1,5 +1,16 @@
import { computeDasMap, createDasAnalysisPlan } from "./das.js";
import { findLocalPeaks } from "./evaluation.js";
import {
computeLocalizationMap,
createLocalizationAnalysisPlan,
} from "./localization.js";
import {
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
} from "./gcc-phat.js";
import {
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
} from "./localization-algorithms.js";
import {
DEFAULT_MAX_COMPENSATION_DB,
MAX_FREQUENCY_RESPONSE_HZ,
@@ -214,6 +225,10 @@ function throwIfAborted(signal) {
}
export function detectBandPeaks(rawMap, scan, options = {}) {
const rawScale = options.rawScale ?? rawMap?.scale ?? "power";
if (rawScale !== "power" && rawScale !== "score") {
throw new RangeError('peak map rawScale must be "power" or "score".');
}
const peakDetection = copyPeakDetection(options, scan);
const localPeaks = findLocalPeaks(rawMap, scan);
const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
@@ -235,7 +250,11 @@ export function detectBandPeaks(rawMap, scan, options = {}) {
rank: detections.length + 1,
index: peak.index,
position: [...peak.position],
rawPower: peak.value,
rawValue: peak.value,
rawScale,
...(rawScale === "power"
? { rawPower: peak.value }
: { rawScore: peak.value }),
});
if (detections.length === peakDetection.maximumDetections) break;
}
@@ -244,7 +263,10 @@ export function detectBandPeaks(rawMap, scan, options = {}) {
function locationsFromBands(bands, scan, peakDetection) {
return bands.flatMap((band) =>
detectBandPeaks(band.rawMap, scan, peakDetection).map((peak) => ({
detectBandPeaks(band.rawMap, scan, {
...peakDetection,
rawScale: band.rawScale,
}).map((peak) => ({
bandId: band.id,
...peak,
})),
@@ -287,6 +309,13 @@ export function createLocalizationPlan(config) {
config.beamforming?.maxCompensationDb ??
DEFAULT_MAX_COMPENSATION_DB,
);
const algorithm = requireLocalizationAlgorithmDefinition(
config.algorithm ?? config.beamforming?.algorithm,
);
const phatRelativeFloor =
config.phatRelativeFloor ?? config.beamforming?.phatRelativeFloor;
const phatFloorRatio =
config.phatFloorRatio ?? config.beamforming?.phatFloorRatio;
const scene = Object.freeze({ speedOfSound });
const experiment = Object.freeze({
@@ -294,12 +323,14 @@ export function createLocalizationPlan(config) {
scan,
frequencyBands,
beamforming: Object.freeze({
algorithm: algorithm.id,
backend: "cpu",
normalization,
compensateFrequencyResponse,
maxCompensationDb,
}),
});
const dasPlan = createDasAnalysisPlan({
const analysisPlan = createLocalizationAnalysisPlan({
sampleRate,
arrayDefinition,
scene,
@@ -310,6 +341,7 @@ export function createLocalizationPlan(config) {
return Object.freeze({
kind: REALTIME_LOCALIZATION_PLAN_KIND,
algorithm: algorithm.id,
sampleRate,
channelCount: arrayDefinition.microphones.length,
arrayDefinition,
@@ -322,10 +354,20 @@ export function createLocalizationPlan(config) {
maxCompensationDb,
scene,
experiment,
dasPlan,
analysisPlan,
...(isDasAlgorithm(algorithm.id)
? { dasPlan: analysisPlan }
: { gccPhatPlan: analysisPlan }),
computeOptions: Object.freeze({
pointBatchSize: DEFAULT_REALTIME_POINT_BATCH_SIZE,
pointBatchSize: isDasAlgorithm(algorithm.id)
? DEFAULT_REALTIME_POINT_BATCH_SIZE
: DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
...(!isDasAlgorithm(algorithm.id)
? { crossSpectrumBatchSize: DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE }
: {}),
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
...(phatFloorRatio !== undefined ? { phatFloorRatio } : {}),
...(config.computeOptions ?? {}),
}),
});
@@ -350,7 +392,7 @@ export async function localizeMultichannel(options) {
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
throwIfAborted(options.signal);
const processingStartedAt = monotonicNow();
const das = await computeDasMap({
const localization = await computeLocalizationMap({
...plan.computeOptions,
...(options.computeOptions ?? {}),
channels: options.channels,
@@ -358,14 +400,18 @@ export async function localizeMultichannel(options) {
arrayDefinition: plan.arrayDefinition,
scene: plan.scene,
experiment: plan.experiment,
analysisPlan: plan.dasPlan,
analysisPlan: plan.analysisPlan,
signal: options.signal,
onProgress: options.onProgress,
});
const locations = locationsFromBands(das.bands, plan.scan, plan.peakDetection);
const locations = locationsFromBands(
localization.bands,
plan.scan,
plan.peakDetection,
);
const processingTimeMs = Math.max(0, monotonicNow() - processingStartedAt);
return {
...das,
...localization,
sampleRate: plan.sampleRate,
sampleCount,
duration: sampleCount / plan.sampleRate,
@@ -488,6 +534,7 @@ export class StreamingDasLocalizer {
getState() {
return {
sampleRate: this.plan.sampleRate,
algorithm: this.plan.algorithm,
channelCount: this.plan.channelCount,
analysisWindowSamples: this.analysisWindowSamples,
analysisHopSamples: this.analysisHopSamples,
@@ -675,6 +722,9 @@ export class StreamingDasLocalizer {
}
}
/** Algorithm-neutral alias; the older class name remains for compatibility. */
export { StreamingDasLocalizer as StreamingLocalizer };
export function createStreamingLocalizer(config) {
return new StreamingDasLocalizer(config);
}
+70 -3
View File
@@ -6,6 +6,7 @@ const MAX_SOURCES = 32;
const MAX_GRID_SIDE = 512;
const MAX_FREQUENCY_RESPONSE_POINTS = 64;
const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat"];
/** @typedef {{ path: string, message: string }} ValidationIssue */
@@ -436,10 +437,26 @@ function validateExperimentRelationships(value, context = {}) {
if (value.stft.window !== "hann") addIssue(issues, "$.stft.window", "hann이어야 합니다.");
}
if (requireRecord(value.beamforming, "$.beamforming", issues)) {
if (value.beamforming.algorithm !== "das") addIssue(issues, "$.beamforming.algorithm", "das이어야 합니다.");
if (!LOCALIZATION_ALGORITHMS.includes(value.beamforming.algorithm)) {
addIssue(
issues,
"$.beamforming.algorithm",
"das, das-phat 또는 gcc-phat이어야 합니다.",
);
}
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
}
if (
value.beamforming.algorithm === "gcc-phat" &&
value.beamforming.backend === "webgpu"
) {
addIssue(
issues,
"$.beamforming.backend",
"gcc-phat는 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
);
}
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
min: 0,
max: 10,
@@ -475,6 +492,16 @@ function validateExperimentRelationships(value, context = {}) {
}
}
if (context.arrayDefinition && context.scene) {
if (
value.beamforming?.algorithm === "gcc-phat" &&
context.arrayDefinition.microphones.length < 2
) {
addIssue(
issues,
"$.beamforming.algorithm",
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
);
}
const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
let maximumDelaySamples = 0;
@@ -519,9 +546,28 @@ function validateResultRelationships(value, context = {}) {
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
if (value.arrayId !== context.experiment.arrayId) addIssue(issues, "$.arrayId", "실험의 배열 id와 다릅니다.");
if (value.sceneId !== context.experiment.sceneId) addIssue(issues, "$.sceneId", "실험의 장면 id와 다릅니다.");
if (
value.algorithm !== undefined &&
value.algorithm !== context.experiment.beamforming?.algorithm
) {
addIssue(issues, "$.algorithm", "실험의 위치 추정 알고리즘과 다릅니다.");
}
}
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm;
const expectedRawScale =
resultAlgorithm === "das"
? "power"
: resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat"
? "score"
: undefined;
const expectedBeamSpreadMethod =
expectedRawScale === "power"
? "connected-half-power-power-weighted-covariance"
: expectedRawScale === "score"
? "connected-half-maximum-score-weighted-covariance"
: undefined;
const bandIds = [];
if (Array.isArray(value.bands)) {
value.bands.forEach((band, bandIndex) => {
@@ -541,14 +587,35 @@ function validateResultRelationships(value, context = {}) {
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
}
});
if (isRecord(band.rawMap) && band.rawMap.scale !== "power") {
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power scale이어야 합니다.");
if (isRecord(band.rawMap) && !["power", "score"].includes(band.rawMap.scale)) {
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power 또는 score scale이어야 합니다.");
} else if (
isRecord(band.rawMap) &&
expectedRawScale &&
band.rawMap.scale !== expectedRawScale
) {
addIssue(
issues,
`${path}.rawMap.scale`,
`${resultAlgorithm} 결과의 rawMap scale은 ${expectedRawScale}여야 합니다.`,
);
}
if (Array.isArray(band.peaks)) {
band.peaks.forEach((peak, peakIndex) => {
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다.");
}
if (
expectedBeamSpreadMethod &&
isRecord(peak?.beamSpread) &&
peak.beamSpread.method !== expectedBeamSpreadMethod
) {
addIssue(
issues,
`${path}.peaks[${peakIndex}].beamSpread.method`,
`${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`,
);
}
});
}
});
+51
View File
@@ -343,6 +343,55 @@ p {
padding: 28px;
}
.split-handle {
position: relative;
z-index: 12;
width: 12px;
min-width: 12px;
align-self: stretch;
border: 0;
cursor: col-resize;
touch-action: none;
}
.split-handle::before {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
background: var(--line);
content: "";
transform: translateX(-50%);
transition:
width 140ms ease,
background 140ms ease,
box-shadow 140ms ease;
}
.split-handle:hover::before,
.split-handle:focus-visible::before,
[data-split-pane-dragging="true"] > .split-handle::before {
width: 4px;
background: var(--mint);
box-shadow: 0 0 18px rgb(101 242 191 / 34%);
}
.split-handle:focus-visible {
outline: 2px solid var(--mint);
outline-offset: -2px;
}
.split-handle[aria-disabled="true"] {
cursor: default;
}
body.is-resizing-pane,
body.is-resizing-pane * {
cursor: col-resize !important;
user-select: none !important;
}
.empty-state {
width: min(680px, 100%);
padding: clamp(28px, 6vw, 56px);
@@ -465,6 +514,7 @@ p {
}
.landing-shell .launcher-grid {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-top: 28px;
}
@@ -533,6 +583,7 @@ p {
}
.workflow-disclosure .workflow-list {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
margin: 16px 0 12px;
}
+345
View File
@@ -0,0 +1,345 @@
const DEFAULT_KEYBOARD_STEP = 16;
function finiteNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function splitPaneRange({
containerSize,
separatorSize = 0,
minSize,
maxSize,
minMainSize = 0,
}) {
const minimum = Math.max(0, finiteNumber(minSize, 0));
const maximum = Math.max(minimum, finiteNumber(maxSize, minimum));
const available = Number.isFinite(containerSize)
? Math.max(0, containerSize - Math.max(0, separatorSize) - Math.max(0, minMainSize))
: maximum;
const effectiveMaximum = Math.min(maximum, available);
return {
min: Math.min(minimum, effectiveMaximum),
max: effectiveMaximum,
};
}
export function clampSplitPaneSize(requested, metrics) {
const range = splitPaneRange(metrics);
const fallback = range.min;
const value = finiteNumber(requested, fallback);
return Math.min(range.max, Math.max(range.min, value));
}
export function splitPaneSizeFromPointer({
pointerX,
containerLeft,
containerRight,
separatorSize = 0,
panelSide,
}) {
const halfSeparator = Math.max(0, separatorSize) / 2;
if (panelSide === "start") {
return pointerX - containerLeft - halfSeparator;
}
if (panelSide === "end") {
return containerRight - pointerX - halfSeparator;
}
throw new TypeError('panelSide는 "start" 또는 "end"여야 합니다.');
}
function requireElement(value, name) {
if (!(value instanceof Element)) {
throw new TypeError(name + " 요소가 필요합니다.");
}
return value;
}
export function createSplitPane(options) {
const container = requireElement(options?.container, "container");
const separator = requireElement(options?.separator, "separator");
const panel = requireElement(options?.panel, "panel");
const panelSide = options.panelSide;
if (panelSide !== "start" && panelSide !== "end") {
throw new TypeError('panelSide는 "start" 또는 "end"여야 합니다.');
}
const ownerDocument = container.ownerDocument;
const view = ownerDocument.defaultView;
if (!view) throw new Error("분할 패널 window를 찾을 수 없습니다.");
const cssVariable = options.cssVariable ?? "--control-panel-width";
const storageKey = String(options.storageKey ?? "");
const defaultSize = Math.max(0, finiteNumber(options.defaultSize, 400));
const minSize = Math.max(0, finiteNumber(options.minSize, 320));
const maxSize = Math.max(minSize, finiteNumber(options.maxSize, 720));
const minMainSize = Math.max(0, finiteNumber(options.minMainSize, 520));
const keyboardStep = Math.max(1, finiteNumber(options.keyboardStep, DEFAULT_KEYBOARD_STEP));
const enabledMedia = options.enabledMediaQuery
? view.matchMedia(options.enabledMediaQuery)
: null;
let storage = null;
try {
storage = Object.hasOwn(options, "storage") ? options.storage : view.localStorage;
} catch {
storage = null;
}
let requestedSize = defaultSize;
if (storage && storageKey) {
try {
const stored = Number(storage.getItem(storageKey));
if (Number.isFinite(stored) && stored > 0) requestedSize = stored;
} catch {
// UI preference storage must never prevent the app from starting.
}
}
let currentSize = defaultSize;
let activePointerId = null;
let pendingPointerSize = null;
let pointerFrame = 0;
let resizeFrame = 0;
let destroyed = false;
separator.dataset.storageKey = storageKey;
const isEnabled = () => enabledMedia?.matches ?? true;
function measurements() {
const containerRect = container.getBoundingClientRect();
const separatorRect = separator.getBoundingClientRect();
const containerSize = containerRect.width || container.clientWidth;
return {
containerSize: containerSize > 0 ? containerSize : Number.POSITIVE_INFINITY,
separatorSize: separatorRect.width || separator.offsetWidth || 0,
minSize,
maxSize,
minMainSize,
};
}
function updateAccessibility(size, metrics = measurements()) {
const range = splitPaneRange(metrics);
const rounded = Math.round(size);
separator.setAttribute("aria-valuemin", String(Math.round(range.min)));
separator.setAttribute("aria-valuemax", String(Math.round(range.max)));
separator.setAttribute("aria-valuenow", String(rounded));
separator.setAttribute("aria-valuetext", "설정 패널 " + rounded + "px");
}
function applySize(value, { updateRequested = false, emit = true } = {}) {
if (destroyed) return currentSize;
const metrics = measurements();
const nextSize = clampSplitPaneSize(value, metrics);
const changed = Math.abs(nextSize - currentSize) >= 0.5;
currentSize = nextSize;
if (updateRequested) requestedSize = nextSize;
container.style.setProperty(cssVariable, Math.round(nextSize) + "px");
container.dataset.splitPaneSize = String(Math.round(nextSize));
updateAccessibility(nextSize, metrics);
if (emit && changed) {
container.dispatchEvent(
new view.CustomEvent("splitpanechange", {
detail: { panelSide, size: nextSize },
}),
);
}
return nextSize;
}
function persist() {
if (!storage || !storageKey) return;
try {
storage.setItem(storageKey, String(Math.round(currentSize)));
} catch {
// Private browsing and quota failures should only disable persistence.
}
}
function flushPointerSize() {
if (pointerFrame) {
view.cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
}
if (pendingPointerSize === null) return;
const next = pendingPointerSize;
pendingPointerSize = null;
applySize(next, { updateRequested: true });
}
function schedulePointerSize(value) {
pendingPointerSize = value;
if (pointerFrame) return;
pointerFrame = view.requestAnimationFrame(() => {
pointerFrame = 0;
if (pendingPointerSize === null) return;
const next = pendingPointerSize;
pendingPointerSize = null;
applySize(next, { updateRequested: true });
});
}
function stopDragging({ save = true } = {}) {
if (activePointerId === null) return;
flushPointerSize();
try {
if (separator.hasPointerCapture?.(activePointerId)) {
separator.releasePointerCapture(activePointerId);
}
} catch {
// Synthetic events and interrupted pointers might not own capture.
}
activePointerId = null;
container.dataset.splitPaneDragging = "false";
ownerDocument.body.classList.remove("is-resizing-pane");
if (save) persist();
}
function onPointerDown(event) {
if (!isEnabled() || event.button !== 0 || activePointerId !== null) return;
activePointerId = event.pointerId;
container.dataset.splitPaneDragging = "true";
ownerDocument.body.classList.add("is-resizing-pane");
try {
separator.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic PointerEvents do not always create an active pointer.
}
event.preventDefault();
}
function onPointerMove(event) {
if (event.pointerId !== activePointerId) return;
const containerRect = container.getBoundingClientRect();
const separatorRect = separator.getBoundingClientRect();
schedulePointerSize(
splitPaneSizeFromPointer({
pointerX: event.clientX,
containerLeft: containerRect.left,
containerRight: containerRect.right,
separatorSize: separatorRect.width,
panelSide,
}),
);
event.preventDefault();
}
function onPointerEnd(event) {
if (event.pointerId !== activePointerId) return;
stopDragging();
}
function onKeyDown(event) {
if (!isEnabled()) return;
const multiplier = event.shiftKey ? 4 : 1;
const step = keyboardStep * multiplier;
let nextSize = null;
if (event.key === "ArrowLeft") {
nextSize = currentSize + (panelSide === "end" ? step : -step);
} else if (event.key === "ArrowRight") {
nextSize = currentSize + (panelSide === "start" ? step : -step);
} else if (event.key === "Home") {
nextSize = minSize;
} else if (event.key === "End") {
nextSize = maxSize;
} else if (event.key === "Enter") {
reset();
event.preventDefault();
return;
}
if (nextSize === null) return;
applySize(nextSize, { updateRequested: true });
persist();
event.preventDefault();
}
function reset() {
requestedSize = defaultSize;
applySize(defaultSize, { updateRequested: true });
if (storage && storageKey) {
try {
storage.removeItem(storageKey);
} catch {
// Ignore inaccessible preference storage.
}
}
}
function syncEnabledState() {
const enabled = isEnabled();
container.dataset.splitPaneActive = String(enabled);
separator.setAttribute("aria-disabled", String(!enabled));
if (!enabled) {
stopDragging({ save: false });
return;
}
applySize(requestedSize, { emit: false });
}
function scheduleContainerSync() {
if (!isEnabled() || resizeFrame || destroyed) return;
resizeFrame = view.requestAnimationFrame(() => {
resizeFrame = 0;
applySize(requestedSize, { emit: false });
});
}
function onDoubleClick(event) {
if (!isEnabled()) return;
reset();
event.preventDefault();
}
separator.addEventListener("pointerdown", onPointerDown);
separator.addEventListener("keydown", onKeyDown);
separator.addEventListener("dblclick", onDoubleClick);
view.addEventListener("pointermove", onPointerMove);
view.addEventListener("pointerup", onPointerEnd);
view.addEventListener("pointercancel", onPointerEnd);
view.addEventListener("blur", stopDragging);
view.addEventListener("resize", scheduleContainerSync);
const resizeObserver = typeof view.ResizeObserver === "function"
? new view.ResizeObserver(scheduleContainerSync)
: null;
resizeObserver?.observe(container);
const onMediaChange = () => syncEnabledState();
if (enabledMedia?.addEventListener) enabledMedia.addEventListener("change", onMediaChange);
else enabledMedia?.addListener?.(onMediaChange);
syncEnabledState();
return {
get size() {
return currentSize;
},
setSize(value, { save = true } = {}) {
requestedSize = finiteNumber(value, currentSize);
const applied = applySize(requestedSize, { updateRequested: true });
if (save) persist();
return applied;
},
reset,
destroy() {
if (destroyed) return;
stopDragging({ save: false });
destroyed = true;
separator.removeEventListener("pointerdown", onPointerDown);
separator.removeEventListener("keydown", onKeyDown);
separator.removeEventListener("dblclick", onDoubleClick);
view.removeEventListener("pointermove", onPointerMove);
view.removeEventListener("pointerup", onPointerEnd);
view.removeEventListener("pointercancel", onPointerEnd);
view.removeEventListener("blur", stopDragging);
view.removeEventListener("resize", scheduleContainerSync);
resizeObserver?.disconnect();
if (enabledMedia?.removeEventListener) enabledMedia.removeEventListener("change", onMediaChange);
else enabledMedia?.removeListener?.(onMediaChange);
if (pointerFrame) view.cancelAnimationFrame(pointerFrame);
if (resizeFrame) view.cancelAnimationFrame(resizeFrame);
},
};
}
+54 -1
View File
@@ -1,4 +1,9 @@
import { computeDasMap } from "../acoustics-core/das.js";
import { computeGccPhatMap } from "../acoustics-core/gcc-phat.js";
import {
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
} from "../acoustics-core/localization-algorithms.js";
import { computeDasWebGpu } from "./das.js";
import { isRecoverableWebGpuError } from "./device.js";
@@ -17,7 +22,7 @@ function throwIfAborted(signal) {
* Execute the requested DAS backend. `auto` falls back only for typed WebGPU
* operational errors; invalid inputs and AbortError remain visible to callers.
*/
export async function computeDasWithBackend(options, dependencies = {}) {
async function computeDasBackend(options, dependencies = {}) {
const computeGpu = dependencies.computeGpu ?? computeDasWebGpu;
const computeCpu = dependencies.computeCpu ?? computeDasMap;
const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto";
@@ -56,3 +61,51 @@ export async function computeDasWithBackend(options, dependencies = {}) {
}
}
}
/**
* Execute a DAS-family algorithm with the requested CPU/WebGPU policy.
* Conventional DAS callers can keep using this backwards-compatible entry.
*/
export async function computeDasWithBackend(options, dependencies = {}) {
const algorithm = requireLocalizationAlgorithmDefinition(
options?.experiment?.beamforming?.algorithm,
);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(
`computeDasWithBackend does not implement ${algorithm.id}.`,
);
}
return computeDasBackend(options, dependencies);
}
/**
* Execute any registered localization algorithm. GCC-PHAT currently uses its
* portable CPU implementation for both `auto` and `cpu`; an explicit WebGPU
* request is rejected instead of silently changing the requested backend.
*/
export async function computeLocalizationWithBackend(options, dependencies = {}) {
const algorithm = requireLocalizationAlgorithmDefinition(
options?.experiment?.beamforming?.algorithm,
);
if (isDasAlgorithm(algorithm.id)) {
return computeDasBackend(options, dependencies);
}
if (algorithm.id !== "gcc-phat") {
throw new RangeError("No backend dispatcher is registered for " + algorithm.id + ".");
}
const requestedBackend = options?.experiment?.beamforming?.backend ?? "auto";
if (requestedBackend === "webgpu") {
throw new TypeError(
`${algorithm.label} currently supports only the CPU backend; choose auto or cpu.`,
);
}
if (requestedBackend !== "auto" && requestedBackend !== "cpu") {
throw new TypeError(`unsupported localization backend: ${requestedBackend}`);
}
const computeCpu =
dependencies.computeGccCpu ??
dependencies.computeCpu ??
computeGccPhatMap;
return computeCpu(options);
}
+80 -5
View File
@@ -9,6 +9,11 @@ import {
requireMaxCompensationDb,
} from "../acoustics-core/frequency-response.js";
import { dbToLinear } from "../acoustics-core/math.js";
import {
DEFAULT_PHAT_RELATIVE_FLOOR,
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
} from "../acoustics-core/localization-algorithms.js";
import { extractStftBins } from "../acoustics-core/stft.js";
import {
DAS_PARAMS_BYTE_LENGTH,
@@ -72,11 +77,60 @@ function minimumBufferSize(byteLength) {
return Math.max(4, Math.ceil(byteLength / 4) * 4);
}
function requirePhatRelativeFloor(value) {
if (!Number.isFinite(value) || value < 0 || value > 1) {
throw new RangeError("phatRelativeFloor must lie inside 0..1.");
}
return value;
}
function phaseNormalizePackedStft(
packed,
binCount,
frameCount,
microphoneCount,
relativeFloor,
) {
requirePhatRelativeFloor(relativeFloor);
const valuesPerBin = frameCount * microphoneCount;
for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
const binOffset = binIndex * valuesPerBin * 2;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameOffset = binOffset + frameIndex * microphoneCount * 2;
let maximumMagnitude = 0;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const valueOffset = frameOffset + microphoneIndex * 2;
maximumMagnitude = Math.max(
maximumMagnitude,
Math.hypot(packed[valueOffset], packed[valueOffset + 1]),
);
}
const magnitudeFloor = maximumMagnitude * relativeFloor;
for (let microphoneIndex = 0; microphoneIndex < microphoneCount; microphoneIndex += 1) {
const valueOffset = frameOffset + microphoneIndex * 2;
const magnitude = Math.hypot(packed[valueOffset], packed[valueOffset + 1]);
if (!(magnitude > magnitudeFloor)) {
packed[valueOffset] = 0;
packed[valueOffset + 1] = 0;
continue;
}
packed[valueOffset] /= magnitude;
packed[valueOffset + 1] /= magnitude;
}
}
}
}
export function packStftBatch(stft, bins, options = {}) {
if (!stft || !Number.isInteger(stft.frameCount) || !Number.isInteger(stft.microphoneCount)) {
throw new TypeError("유효한 STFT 결과가 필요합니다.");
}
if (!Array.isArray(bins) || bins.length === 0) throw new TypeError("하나 이상의 FFT bin이 필요합니다.");
const algorithm = requireLocalizationAlgorithmDefinition(options.algorithm ?? "das");
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`packStftBatch does not implement ${algorithm.id}.`);
}
const phaseOnly = algorithm.spectralWeighting === "phat";
const valuesPerBin = stft.frameCount * stft.microphoneCount;
const packed = new Float32Array(bins.length * valuesPerBin * 2);
const microphones = options.microphones;
@@ -105,9 +159,9 @@ export function packStftBatch(stft, bins, options = {}) {
microphones.forEach((microphone, microphoneIndex) => {
if (!Array.isArray(microphone.frequencyResponse)) return;
interpolateFrequencyResponse(microphone.frequencyResponse, frequencyHz, response);
const inverseGain = dbToLinear(
inverseResponseGainDb(response.gainDb, maxCompensationDb),
);
const inverseGain = phaseOnly
? 1
: dbToLinear(inverseResponseGainDb(response.gainDb, maxCompensationDb));
const responseOffset = (binIndex * stft.microphoneCount + microphoneIndex) * 2;
inverseResponses[responseOffset] = inverseGain * Math.cos(response.phaseRadians);
inverseResponses[responseOffset + 1] = -inverseGain * Math.sin(response.phaseRadians);
@@ -138,6 +192,15 @@ export function packStftBatch(stft, bins, options = {}) {
inputReal * inverseImaginary + inputImaginary * inverseReal;
}
});
if (phaseOnly) {
phaseNormalizePackedStft(
packed,
bins.length,
stft.frameCount,
stft.microphoneCount,
options.phatRelativeFloor ?? DEFAULT_PHAT_RELATIVE_FLOOR,
);
}
return packed;
}
@@ -445,9 +508,16 @@ export async function computeDasWebGpu(options) {
maximumGpuComplexMacs = 20_000_000_000,
maximumFftButterflies = 1_000_000_000,
stftTransformBatchSize = 32,
phatRelativeFloor = DEFAULT_PHAT_RELATIVE_FLOOR,
gpu,
powerPreference,
} = options;
const algorithm = requireLocalizationAlgorithmDefinition(
experiment.beamforming?.algorithm ?? "das",
);
if (!isDasAlgorithm(algorithm.id)) {
throw new RangeError(`computeDasWebGpu does not implement ${algorithm.id}.`);
}
if (!Array.isArray(channels) || channels.length !== arrayDefinition?.microphones?.length) {
throw new Error(`채널 수 ${channels?.length ?? 0}와 마이크 수 ${arrayDefinition?.microphones?.length ?? 0}가 다릅니다.`);
}
@@ -501,7 +571,9 @@ export async function computeDasWebGpu(options) {
label: "DAS beamforming pipeline",
}),
}));
const calibration = packMicrophoneCalibration(arrayDefinition.microphones);
const calibration = packMicrophoneCalibration(arrayDefinition.microphones, {
includeAmplitude: algorithm.spectralWeighting !== "phat",
});
const calibrationBuffer = await withWebGpuErrorScopes(context.device, async () =>
createBuffer(context.device, {
label: "DAS microphone calibration",
@@ -543,12 +615,14 @@ export async function computeDasWebGpu(options) {
}),
});
const packedSpectrum = packStftBatch(stft, batchBins, {
algorithm: algorithm.id,
microphones: arrayDefinition.microphones,
sampleRate,
fftSize: experiment.stft.fftSize,
compensateFrequencyResponse:
experiment.beamforming.compensateFrequencyResponse !== false,
maxCompensationDb: experiment.beamforming.maxCompensationDb,
phatRelativeFloor,
});
stftMs += now() - stftStartedAt;
const binIndices = Uint32Array.from(batchBins);
@@ -688,9 +762,10 @@ export async function computeDasWebGpu(options) {
completedBins += batchBins.length;
}
const bands = finalizeDasBands(rawMaps, workload, experiment);
const bands = finalizeDasBands(rawMaps, workload, experiment, algorithm.rawScale);
const totalMs = now() - totalStartedAt;
return {
algorithm: algorithm.id,
backend: "webgpu",
frameCount: workload.frameCount,
microphoneCount: workload.microphoneCount,
+8 -4
View File
@@ -12,7 +12,7 @@
* Flat buffer layouts:
* spectrum: [bin][frame][microphone][real, imaginary]
* distances: [point][microphone]
* calibration: [microphone][inverse sensitivity gain, phase offset radians]
* calibration: [microphone][inverse sensitivity gain or unity, phase offset radians]
* steering: [bin][point][microphone][real, imaginary]
* output: [bin][point]
*/
@@ -197,9 +197,13 @@ export function packDasCalibration(calibration) {
return packed;
}
/** Convert array-definition microphone metadata to the shader calibration layout. */
export function packMicrophoneCalibration(microphones) {
/**
* Convert array-definition microphone metadata to the shader calibration layout.
* Phase-only algorithms set includeAmplitude=false while retaining phase offsets.
*/
export function packMicrophoneCalibration(microphones, options = {}) {
if (!Array.isArray(microphones)) throw new TypeError("microphones must be an array.");
const includeAmplitude = options.includeAmplitude !== false;
const packed = new Float32Array(microphones.length * 2);
for (let index = 0; index < microphones.length; index += 1) {
const microphone = microphones[index];
@@ -213,7 +217,7 @@ export function packMicrophoneCalibration(microphones) {
);
packed[index * 2] = requireFiniteF32(
`microphones[${index}] inverse sensitivity gain`,
10 ** (-sensitivityDb / 20),
includeAmplitude ? 10 ** (-sensitivityDb / 20) : 1,
);
packed[index * 2 + 1] = requireFiniteF32(
`microphones[${index}] phase offset`,
+2 -1
View File
@@ -42,7 +42,8 @@
"type": "object", "additionalProperties": false,
"required": ["algorithm", "backend", "minimumDistance", "normalization", "dynamicRangeDb"],
"properties": {
"algorithm": { "const": "das" }, "backend": { "enum": ["auto", "cpu", "webgpu"] },
"algorithm": { "enum": ["das", "das-phat", "gcc-phat"] },
"backend": { "enum": ["auto", "cpu", "webgpu"] },
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
"normalization": { "enum": ["peak", "none"] },
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 },
+9 -2
View File
@@ -15,6 +15,7 @@
"arrayId": { "type": "string", "minLength": 1 },
"sceneId": { "type": "string", "minLength": 1 },
"createdAt": { "type": "string", "format": "date-time" },
"algorithm": { "enum": ["das", "das-phat", "gcc-phat"] },
"backend": { "enum": ["cpu", "webgpu"] },
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"signalStats": {
@@ -43,6 +44,7 @@
"properties": {
"total": { "type": "number", "minimum": 0 },
"signalGeneration": { "type": "number", "minimum": 0 },
"localization": { "type": "number", "minimum": 0 },
"beamforming": { "type": "number", "minimum": 0 }
}
},
@@ -64,7 +66,7 @@
"properties": {
"rows": { "type": "integer", "minimum": 1, "maximum": 512 },
"columns": { "type": "integer", "minimum": 1, "maximum": 512 },
"scale": { "enum": ["power", "normalized", "db"] },
"scale": { "enum": ["power", "score", "normalized", "db"] },
"values": {
"type": "array", "minItems": 1, "maxItems": 262144,
"items": { "type": "number" }
@@ -91,7 +93,12 @@
],
"properties": {
"calibrated": { "const": false },
"method": { "const": "connected-half-power-power-weighted-covariance" },
"method": {
"enum": [
"connected-half-power-power-weighted-covariance",
"connected-half-maximum-score-weighted-covariance"
]
},
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
"thresholdValue": { "type": "number", "minimum": 0 },
"pixelCount": { "type": "integer", "minimum": 0, "maximum": 262144 },
+120 -45
View File
@@ -1,46 +1,132 @@
$ErrorActionPreference = 'Stop'
$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$outputPath = Join-Path $projectRoot 'assets/array-geometries/rectangular-16x12.array.json'
$microphones = [System.Collections.Generic.List[object]]::new()
$columns = 16
$rows = 12
$width = 0.45
$height = 0.45
$utf8WithoutBom = [System.Text.UTF8Encoding]::new($false)
for ($row = 0; $row -lt $rows; $row++) {
for ($column = 0; $column -lt $columns; $column++) {
$index = $row * $columns + $column
$x = -$width / 2 + $column * $width / ($columns - 1)
$y = -$height / 2 + $row * $height / ($rows - 1)
$microphones.Add([ordered]@{
id = 'mic-{0:D3}' -f ($index + 1)
position = @([Math]::Round($x, 6), [Math]::Round($y, 6), 0)
})
}
}
$arrayDefinition = [ordered]@{
schemaVersion = '1.0.0'
kind = 'micarrsim.array'
id = 'rectangular-16x12'
name = '16x12 rectangular baseline'
description = '192-channel uniform rectangular layout on a 0.45 m square aperture'
units = 'm'
coordinateSystem = [ordered]@{
function New-CoordinateSystem {
return [ordered]@{
handedness = 'right'
xAxis = 'right'
yAxis = 'up'
zAxis = 'toward-scene'
}
aperture = [ordered]@{ width = $width; height = $height }
microphones = $microphones
}
$json = $arrayDefinition | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText($outputPath, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
Write-Host "Generated $($microphones.Count) microphones: $outputPath"
function New-RectangularArray {
param(
[Parameter(Mandatory = $true)][string]$Id,
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$Description,
[Parameter(Mandatory = $true)][int]$Columns,
[Parameter(Mandatory = $true)][int]$Rows,
[Parameter(Mandatory = $true)][double]$Width,
[Parameter(Mandatory = $true)][double]$Height
)
$microphones = [System.Collections.Generic.List[object]]::new()
for ($row = 0; $row -lt $Rows; $row++) {
for ($column = 0; $column -lt $Columns; $column++) {
$index = $row * $Columns + $column
$x = if ($Columns -eq 1) { 0 } else { -$Width / 2 + $column * $Width / ($Columns - 1) }
$y = if ($Rows -eq 1) { 0 } else { -$Height / 2 + $row * $Height / ($Rows - 1) }
$microphones.Add([ordered]@{
id = 'mic-{0:D3}' -f ($index + 1)
position = @([Math]::Round($x, 6), [Math]::Round($y, 6), 0)
})
}
}
return [ordered]@{
schemaVersion = '1.0.0'
kind = 'micarrsim.array'
id = $Id
name = $Name
description = $Description
units = 'm'
coordinateSystem = New-CoordinateSystem
aperture = [ordered]@{ width = $Width; height = $Height }
microphones = $microphones
}
}
function Write-ArrayFixture {
param([Parameter(Mandatory = $true)]$Definition)
$outputPath = Join-Path $projectRoot "assets/array-geometries/$($Definition.id).array.json"
$json = $Definition | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText($outputPath, $json + [Environment]::NewLine, $utf8WithoutBom)
Write-Host "Generated $($Definition.microphones.Count) microphones: $outputPath"
}
$rectangularFixtures = @(
@{
Id = 'rectangular-2x2-450mm'
Name = '2x2 sparse rectangular'
Description = '4-channel uniform rectangular layout on the 0.45 m reference aperture'
Columns = 2
Rows = 2
Width = 0.45
Height = 0.45
},
@{
Id = 'rectangular-4x3-450mm'
Name = '4x3 sparse rectangular'
Description = '12-channel uniform rectangular layout on the 0.45 m reference aperture'
Columns = 4
Rows = 3
Width = 0.45
Height = 0.45
},
@{
Id = 'rectangular-8x6-450mm'
Name = '8x6 rectangular'
Description = '48-channel uniform rectangular layout on the 0.45 m reference aperture'
Columns = 8
Rows = 6
Width = 0.45
Height = 0.45
},
@{
Id = 'rectangular-16x12'
Name = '16x12 rectangular baseline'
Description = '192-channel uniform rectangular layout on a 0.45 m square aperture'
Columns = 16
Rows = 12
Width = 0.45
Height = 0.45
},
@{
Id = 'rectangular-24x16-450mm'
Name = '24x16 dense rectangular'
Description = '384-channel uniform rectangular layout on the 0.45 m reference aperture'
Columns = 24
Rows = 16
Width = 0.45
Height = 0.45
},
@{
Id = 'rectangular-16x12-compact'
Name = '16x12 compact rectangular'
Description = '192-channel uniform rectangular layout on a 0.12 m square aperture'
Columns = 16
Rows = 12
Width = 0.12
Height = 0.12
},
@{
Id = 'rectangular-16x12-large'
Name = '16x12 large rectangular'
Description = '192-channel uniform rectangular layout on a 1.20 m square aperture for spatial-aliasing comparison'
Columns = 16
Rows = 12
Width = 1.2
Height = 1.2
}
)
foreach ($fixture in $rectangularFixtures) {
Write-ArrayFixture (New-RectangularArray @fixture)
}
$spiralOutputPath = Join-Path $projectRoot 'assets/array-geometries/fermat-spiral-192.array.json'
$spiralMicrophones = [System.Collections.Generic.List[object]]::new()
$microphoneCount = 192
$radius = 0.225
@@ -64,20 +150,9 @@ $spiralDefinition = [ordered]@{
name = '192-channel Fermat spiral'
description = 'Equal-area Fermat spiral on a 0.45 m circular aperture for comparison with the rectangular baseline'
units = 'm'
coordinateSystem = [ordered]@{
handedness = 'right'
xAxis = 'right'
yAxis = 'up'
zAxis = 'toward-scene'
}
coordinateSystem = New-CoordinateSystem
aperture = [ordered]@{ width = 0.45; height = 0.45 }
microphones = $spiralMicrophones
}
$spiralJson = $spiralDefinition | ConvertTo-Json -Depth 8
[System.IO.File]::WriteAllText(
$spiralOutputPath,
$spiralJson + [Environment]::NewLine,
[System.Text.UTF8Encoding]::new($false)
)
Write-Host "Generated $($spiralMicrophones.Count) microphones: $spiralOutputPath"
Write-ArrayFixture $spiralDefinition
+8 -3
View File
@@ -17,15 +17,20 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장
## 범위
- `cpu-baseline.test.js`: 192채널·50×50 synthetic fixture의 CPU DAS와 위치 기준
- `webgpu.test.js`: 실제 adapter가 있으면 CPU/GPU map 오차, peak와 강제 bin/point batching;
- `webgpu.test.js`: 실제 adapter가 있으면 DAS·DAS-PHAT CPU/GPU map 오차, peak와 강제 bin/point batching;
adapter가 없으면 Auto CPU fallback
- `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat 비교;
이동 예제 로드, 8×8 CPU·2 Hz live map 준비, 복수 frame, scrub, 재생 시간 증가와 일시정지
- `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo,
중복 좌표 차단, 표준 Array JSON 저장·재가져오기와 390px layout
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와
DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약; 이동 예제 로드, 8×8 CPU·2 Hz live map의
Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
Result JSON을 저장하는 end-to-end 흐름
- `split-layout-ui.test.js`: 세 앱 설정 패널의 drag·keyboard 폭 조절, 저장·reset과
좁은 화면 적층
WebGPU adapter가 없는 환경에서는 장치가 필요한 비교 항목을 건너뛰거나 Auto fallback을 검증하므로
CPU-only PASS도 가능합니다. 실제 장치의 scaling과 benchmark JSON 저장은
+710
View File
@@ -0,0 +1,710 @@
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) {
assert(
Math.abs(actual - expected) <= tolerance,
label + ": expected " + expected + ", received " + actual,
);
}
async function waitFor(predicate, label, timeoutMs = 20000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const value = predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(label + " 대기 시간이 초과되었습니다.");
}
async function nextPaint(windowObject) {
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
windowObject.requestAnimationFrame(resolve)));
}
function dispatchValue(windowObject, element, value, eventName = "change") {
element.value = String(value);
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
function dispatchFiles(windowObject, input, files) {
const transfer = new windowObject.DataTransfer();
for (const file of files) transfer.items.add(file);
input.files = transfer.files;
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
}
function microphoneButtons(documentObject) {
return [...documentObject.querySelectorAll("#microphone-list [data-microphone-id]")];
}
function microphoneCount(documentObject) {
return microphoneButtons(documentObject).length;
}
function selectedCoordinates(documentObject) {
return ["microphone-x", "microphone-y", "microphone-z"].map(
(id) => Number(documentObject.getElementById(id).value),
);
}
test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 완주한다", async () => {
const frame = document.createElement("iframe");
frame.src = "/apps/array-builder/?integration-test=1";
frame.style.cssText =
"position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
document.body.append(frame);
await new Promise((resolve, reject) => {
frame.addEventListener("load", resolve, { once: true });
frame.addEventListener(
"error",
() => reject(new Error("Array Builder iframe load failed")),
{ once: true },
);
});
const windowObject = frame.contentWindow;
const documentObject = frame.contentDocument;
const downloads = [];
const originalCreate = windowObject.URL.createObjectURL.bind(windowObject.URL);
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
windowObject.URL.createObjectURL = (blob) => {
const url = "blob:array-builder-test-" + String(downloads.length + 1);
downloads.push({ blob, url, fileName: null });
return url;
};
windowObject.URL.revokeObjectURL = () => {};
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
const download = downloads.find((item) => item.url === this.href);
if (download) download.fileName = this.download;
};
try {
await waitFor(
() => documentObject.documentElement.dataset.appReady === "true",
"Array Builder 초기화",
);
await waitFor(
() => documentObject.documentElement.dataset.arrayValid === "true",
"기본 Array 검증",
);
await waitFor(
() => documentObject.querySelector("#array-canvas").width > 0,
"Array canvas 렌더",
);
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
assertEqual(
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
"mic-001",
"physical channel order starts at mic-001",
);
assert(
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500,
"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-rows"), 2);
documentObject.querySelector("#generate-preset-button").click();
await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation");
assertEqual(
documentObject.querySelector("#preset-count").value,
"",
"hidden count does not block grid generation",
);
assertEqual(
microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","),
"mic-001,mic-002,mic-003,mic-004,mic-005,mic-006",
"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("#preset-type"), "ring");
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 8);
documentObject.querySelector("#generate-preset-button").click();
await waitFor(() => microphoneCount(documentObject) === 8, "ellipse ring generation");
await waitFor(
() => documentObject.documentElement.dataset.arrayValid === "true",
"ellipse ring validation",
);
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "random");
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 12);
dispatchValue(windowObject, documentObject.querySelector("#preset-seed"), 4242);
dispatchValue(windowObject, documentObject.querySelector("#preset-minimum-spacing"), 0.01);
documentObject.querySelector("#generate-preset-button").click();
await waitFor(() => microphoneCount(documentObject) === 12, "seed random generation");
const firstRandom = documentObject.querySelector("#microphone-list").textContent;
documentObject.querySelector("#generate-preset-button").click();
const repeatedRandom = documentObject.querySelector("#microphone-list").textContent;
assertEqual(repeatedRandom, firstRandom, "same seed repeats the visible layout");
documentObject.querySelector("#undo-button").click();
assertEqual(microphoneCount(documentObject), 8, "preset replacement is undoable");
documentObject.querySelector("#redo-button").click();
assertEqual(microphoneCount(documentObject), 12, "preset replacement is redoable");
documentObject.querySelector("#add-microphone-button").click();
await waitFor(() => microphoneCount(documentObject) === 13, "manual microphone add");
assertEqual(
documentObject.querySelector("#microphone-id").value,
"mic-013",
"manual add selects a stable next ID",
);
dispatchValue(windowObject, documentObject.querySelector("#microphone-id"), "reference-channel");
dispatchValue(windowObject, documentObject.querySelector("#microphone-x"), 0.01);
dispatchValue(windowObject, documentObject.querySelector("#microphone-y"), -0.015);
dispatchValue(windowObject, documentObject.querySelector("#microphone-z"), 0.002);
dispatchValue(windowObject, documentObject.querySelector("#microphone-sensitivity"), -2.5);
dispatchValue(windowObject, documentObject.querySelector("#microphone-phase"), 3.5);
assertEqual(
documentObject.querySelector("#microphone-id").value,
"reference-channel",
"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);
canvas.focus();
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key: "ArrowRight",
}));
await nextPaint(windowObject);
const afterNudge = selectedCoordinates(documentObject);
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");
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();
const canvasRect = canvas.getBoundingClientRect();
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerId: 31,
pointerType: "mouse",
isPrimary: true,
button: 0,
buttons: 1,
clientX: canvasRect.left + canvasRect.width * 0.7,
clientY: canvasRect.top + canvasRect.height * 0.35,
}));
await waitFor(() => microphoneCount(documentObject) === 14, "canvas microphone add");
documentObject.querySelector("#select-tool-button").click();
documentObject.querySelector("#remove-microphone-button").click();
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 secondButton = microphoneButtons(documentObject)[1];
firstButton.click();
const firstPosition = selectedCoordinates(documentObject);
secondButton.click();
dispatchValue(windowObject, documentObject.querySelector("#microphone-x"), firstPosition[0]);
dispatchValue(windowObject, documentObject.querySelector("#microphone-y"), firstPosition[1]);
dispatchValue(windowObject, documentObject.querySelector("#microphone-z"), firstPosition[2]);
await waitFor(
() => documentObject.documentElement.dataset.arrayValid === "false",
"duplicate position rejection",
);
assert(documentObject.querySelector("#export-button").disabled, "invalid duplicate disables export");
documentObject.querySelector("#undo-button").click();
await waitFor(
() => documentObject.documentElement.dataset.arrayValid === "true",
"duplicate undo recovery",
);
documentObject.querySelector("#export-button").click();
await waitFor(() => downloads.some((item) => item.fileName), "Array JSON export");
const exportedDownload = downloads.find((item) => item.fileName);
assert(
exportedDownload.fileName.endsWith(".array.json"),
"export uses .array.json filename",
);
const exported = JSON.parse(await exportedDownload.blob.text());
assertEqual(exported.kind, "micarrsim.array", "standard array kind");
assertEqual((await validateArrayDefinition(exported)).length, 0, "exported Array contract");
assert(!Object.hasOwn(exported, "shape"), "editor shape is not exported");
assert(!Object.hasOwn(exported, "preset"), "editor preset is not exported");
assert(!Object.hasOwn(exported, "seed"), "editor seed is not exported");
const calibrated = exported.microphones.find(
(microphone) => microphone.id === "reference-channel",
);
assert(calibrated, "edited channel is exported");
assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity 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");
const exportedFile = new windowObject.File(
[JSON.stringify(exported)],
"round-trip.array.json",
{ type: "application/json" },
);
dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [exportedFile]);
await waitFor(
() => documentObject.querySelector("#array-name").value === exported.name,
"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(
(button) => button.dataset.microphoneId === "reference-channel",
).click();
assertEqual(
Number(documentObject.querySelector("#microphone-sensitivity").value),
-2.5,
"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 invalidFile = new windowObject.File(
[JSON.stringify({ kind: "not-an-array" })],
"invalid.json",
{ type: "application/json" },
);
dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [invalidFile]);
await waitFor(
() => documentObject.querySelector("#validation-title").textContent.includes("적용하지 못"),
"invalid import feedback",
);
assertEqual(
microphoneCount(documentObject),
countBeforeInvalidImport,
"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";
await waitFor(
() => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false",
"compact Array Builder layout",
);
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(
documentObject.documentElement.scrollWidth <=
documentObject.documentElement.clientWidth + 1,
"Array Builder has no horizontal overflow at 390px",
);
assert(
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
"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 {
windowObject.URL.createObjectURL = originalCreate;
windowObject.URL.revokeObjectURL = originalRevoke;
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
frame.remove();
}
});
+6 -3
View File
@@ -36,7 +36,7 @@ async function digestHex(blob) {
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
}
test("시작 화면과 Dataset Builder가 작업 우선순위 및 390px 레이아웃을 유지한다", async () => {
test("시작 화면이 세 앱 진입점과 390px 레이아웃을 유지한다", async () => {
const landingFrame = document.createElement("iframe");
landingFrame.src = "/";
landingFrame.style.cssText = "position:fixed;left:-20000px;top:0;width:390px;height:900px;border:0";
@@ -49,9 +49,12 @@ test("시작 화면과 Dataset Builder가 작업 우선순위 및 390px 레이
try {
const landingDocument = landingFrame.contentDocument;
const launchers = [...landingDocument.querySelectorAll(".launcher-card")];
assertEqual(launchers.length, 2, "landing exposes only two primary tasks");
assertEqual(launchers.length, 3, "landing exposes three primary tasks");
assert(launchers[0].textContent.includes("Acoustic Camera"), "analysis is the first landing task");
assert(launchers[1].textContent.includes("Dataset Builder"), "scene authoring is the second landing task");
const launcherPaths = new Set(launchers.map((launcher) => launcher.getAttribute("href")));
assert(launcherPaths.has("/apps/dataset-builder/"), "landing links to Dataset Builder");
assert(launcherPaths.has("/apps/array-builder/"), "landing links to Array Builder");
assert(launcherPaths.has("/apps/acoustic-camera/"), "landing links to Acoustic Camera");
assert(!landingDocument.querySelector(".workflow-disclosure").open, "file workflow starts collapsed");
assert(!landingDocument.querySelector('a[href="/docs/execution-plan.md"]'), "developer plan is absent from landing");
assert(
+78 -3
View File
@@ -76,6 +76,9 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed");
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
assertEqual(documentObject.querySelector("#algorithm").value, "das", "default DAS algorithm");
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 3, "three runnable algorithms");
assert(!documentObject.querySelector("#postprocess"), "disabled postprocess advertisement is absent");
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
assertEqual(
documentObject.querySelector("#frequency-response-mode").value,
@@ -90,6 +93,13 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
documentObject.querySelector("#acoustic-environment-summary").textContent.includes("≤ 24 dB"),
"safe inverse-response boost limit is visible",
);
assert(documentObject.querySelector("#workload-summary").textContent.includes("DAS"), "algorithm workload label");
assert(documentObject.querySelector("#workload-summary").textContent.includes("STFT"), "STFT transform cost");
assert(
documentObject.querySelector("#workload-summary").textContent.includes("distance cache") &&
!documentObject.querySelector("#workload-summary").textContent.includes("steering"),
"distance cache is labeled accurately",
);
const canvas = documentObject.querySelector("#scene-canvas");
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
@@ -125,6 +135,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
30000,
);
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
assertEqual(documentObject.documentElement.dataset.runAlgorithm, "das", "actual baseline algorithm");
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 10, "result metrics rendered");
assert(documentObject.querySelector("#metric-grid").textContent.includes("음향 모델"), "acoustic model metric rendered");
@@ -169,6 +180,8 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" · ")}`);
assertEqual(resultDocument.backend, "cpu", "exported backend");
assertEqual(resultDocument.algorithm, "das", "exported baseline algorithm");
assertEqual(resultDocument.bands[0].rawMap.scale, "power", "DAS exports a power map");
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
@@ -196,6 +209,52 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
!documentObject.querySelector("#run-button").disabled,
"original comparison configuration restore",
);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "webgpu");
dispatchValue(windowObject, documentObject.querySelector("#algorithm"), "gcc-phat");
await waitFor(
() => documentObject.querySelector("#algorithm").value === "gcc-phat" &&
documentObject.querySelector("#backend").value === "auto" &&
documentObject.querySelector('#backend option[value="webgpu"]').disabled &&
documentObject.querySelector("#validation-message").textContent.includes("8개 이상"),
"GCC-PHAT CPU capability and narrow-band warning",
);
assert(documentObject.querySelector("#workload-summary").textContent.includes("ref pairs"), "GCC pair cost");
dispatchValue(windowObject, documentObject.querySelector("#algorithm"), "das-phat");
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
documentObject.querySelector("#algorithm").value === "das-phat" &&
!documentObject.querySelector('#backend option[value="webgpu"]').disabled &&
!documentObject.querySelector("#run-button").disabled,
"DAS-PHAT comparison configuration",
);
const comparisonPresets = [
["rectangular-2x2-450mm", "4 ch", "0.450 × 0.450 m"],
["rectangular-4x3-450mm", "12 ch", "0.450 × 0.450 m"],
["rectangular-8x6-450mm", "48 ch", "0.450 × 0.450 m"],
["rectangular-24x16-450mm", "384 ch", "0.450 × 0.450 m"],
["rectangular-16x12-compact", "192 ch", "0.120 × 0.120 m"],
["rectangular-16x12-large", "192 ch", "1.200 × 1.200 m"],
];
assertEqual(
documentObject.querySelectorAll("#array-preset option").length,
9,
"all built-in and custom array choices are visible",
);
for (const [presetId, microphoneCount, aperture] of comparisonPresets) {
dispatchValue(windowObject, documentObject.querySelector("#array-preset"), presetId);
await waitFor(
() => documentObject.querySelector("#array-preset").value === presetId &&
documentObject.querySelector("#microphone-count").textContent === microphoneCount &&
documentObject.querySelector("#array-meta").textContent.includes(aperture) &&
documentObject.documentElement.dataset.cameraValid === "true",
`${presetId} preset`,
);
}
dispatchValue(
windowObject,
documentObject.querySelector("#array-preset"),
@@ -220,10 +279,15 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
30000,
);
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
assertEqual(documentObject.documentElement.dataset.runAlgorithm, "das-phat", "actual comparison algorithm");
assert(documentObject.querySelector("#result-badge").textContent.includes("DAS-PHAT"), "algorithm result badge");
assert(documentObject.querySelector("#canvas-badge").textContent.includes("DAS-PHAT"), "algorithm canvas badge");
assert(documentObject.querySelector("#metric-grid").textContent.includes("Truth concentration"), "generic concentration metric");
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
const historyText = documentObject.querySelector("#history-list").textContent;
assert(historyText.includes("rectangular baseline"), "baseline history retained");
assert(historyText.includes("Fermat spiral"), "comparison array history added");
assert(historyText.includes("DAS-PHAT") && historyText.includes("DAS"), "both algorithms are compared");
documentObject.querySelector("#export-history-button").click();
const historyDownload = await waitFor(
@@ -233,6 +297,12 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
const historyDocument = await fetch(historyDownload.url).then((response) => response.json());
assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind");
assertEqual(historyDocument.items.length, 2, "both comparison summaries exported");
assertEqual(
historyDocument.items.map((item) => item.algorithm).sort().join(","),
"das,das-phat",
"both comparison algorithms exported",
);
assert(historyDocument.items.every((item) => Number.isFinite(item.localizationMs)), "localization timing exported");
assert(
!documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
"history export clears comparison dirty state",
@@ -299,17 +369,17 @@ test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source exam
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
dispatchValue(windowObject, documentObject.querySelector("#backend"), "auto");
dispatchValue(windowObject, documentObject.querySelector("#live-map-rate"), 2);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
documentObject.documentElement.dataset.liveState === "idle" &&
documentObject.querySelector("#scan-columns").value === "8" &&
documentObject.querySelector("#scan-rows").value === "8" &&
documentObject.querySelector("#backend").value === "cpu" &&
documentObject.querySelector("#backend").value === "auto" &&
documentObject.querySelector("#live-map-rate").value === "2" &&
!documentObject.querySelector("#prepare-live-button").disabled,
"8x8 CPU 2 Hz live configuration",
"8x8 Auto 2 Hz live configuration",
);
documentObject.querySelector("#prepare-live-button").click();
@@ -320,6 +390,11 @@ test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source exam
);
const frameCount = Number(documentObject.documentElement.dataset.liveFrameCount);
assert(frameCount > 1, `multiple live frames: ${frameCount}`);
assertEqual(
Number(documentObject.documentElement.dataset.livePlanAnalyses),
frameCount - 1,
"Auto fallback retries GPU once then reuses one CPU localization plan",
);
assert(documentObject.querySelector("#live-status").textContent.includes("CPU"), "live backend is CPU");
dispatchValue(windowObject, documentObject.querySelector("#live-time"), 0.6, "input");
+499
View File
@@ -0,0 +1,499 @@
const LAYOUT_CASES = [
{
label: "Dataset Builder",
path: "/apps/dataset-builder/",
panelSide: "end",
storageKey: "micarrsim.builder.control-panel-width",
defaultPanelWidth: 430,
layoutId: "builder-layout",
panelId: "builder-controls",
workspaceId: "builder-workspace",
handleId: "builder-split-handle",
canvasSelector: ".canvas-card",
representativeSelectors: ["#scene-name", "#add-source-button"],
adaptiveGridSelector: "#source-editor-section .field-grid--three",
defaultGridTracks: 2,
homePanelWidth: 360,
homeGridTracks: 1,
endPanelWidth: 720,
endGridTracks: 3,
},
{
label: "Acoustic Camera",
path: "/apps/acoustic-camera/",
panelSide: "start",
storageKey: "micarrsim.camera.control-panel-width",
defaultPanelWidth: 420,
layoutId: "camera-layout",
panelId: "camera-controls",
workspaceId: "camera-workspace",
handleId: "camera-split-handle",
canvasSelector: ".scene-canvas-card",
representativeSelectors: ["#algorithm", "#run-button"],
adaptiveGridSelector: ".quick-field-grid",
defaultGridTracks: 2,
homePanelWidth: 320,
homeGridTracks: 1,
},
{
label: "Array Builder",
path: "/apps/array-builder/",
panelSide: "start",
storageKey: "micarrsim.array-builder.control-panel-width",
defaultPanelWidth: 420,
layoutId: "array-builder-layout",
panelId: "array-builder-controls",
workspaceId: "array-builder-workspace",
handleId: "array-builder-split-handle",
canvasSelector: ".array-canvas-card",
representativeSelectors: ["#array-name", "#generate-preset-button"],
adaptiveGridSelector: ".preset-grid",
defaultGridTracks: 2,
homePanelWidth: 360,
homeGridTracks: 1,
compactWorkspaceFirst: true,
},
];
let mountSequence = 0;
async function waitFor(predicate, label, timeoutMs = 15000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const value = predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(label + " timed out.");
}
async function nextPaint(windowObject) {
await new Promise((resolve) => {
windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve));
});
}
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
label + ": expected " + expected + " ± " + tolerance + ", received " + actual,
);
}
function topLevelCssTokens(value) {
const tokens = [];
let token = "";
let parenthesisDepth = 0;
let bracketDepth = 0;
for (const character of value.trim()) {
if (/\s/.test(character) && parenthesisDepth === 0 && bracketDepth === 0) {
if (token) {
tokens.push(token);
token = "";
}
continue;
}
token += character;
if (character === "(") parenthesisDepth += 1;
else if (character === ")") parenthesisDepth = Math.max(0, parenthesisDepth - 1);
else if (character === "[") bracketDepth += 1;
else if (character === "]") bracketDepth = Math.max(0, bracketDepth - 1);
}
if (token) tokens.push(token);
return tokens;
}
function gridTrackCount(template) {
if (!template || template === "none") return 0;
return topLevelCssTokens(template).reduce((count, token) => {
if (token.startsWith("[") && token.endsWith("]")) return count;
const repeat = token.match(/^repeat\(\s*(\d+)\s*,([\s\S]+)\)$/i);
if (repeat) return count + Number(repeat[1]) * gridTrackCount(repeat[2]);
return count + 1;
}, 0);
}
function computedGridTrackCount(windowObject, element) {
return gridTrackCount(windowObject.getComputedStyle(element).gridTemplateColumns);
}
async function mountLayoutApp(definition, width = 1400) {
const frame = document.createElement("iframe");
mountSequence += 1;
frame.src = definition.path + "?integration-test=1&split-layout-test=" + mountSequence;
frame.style.cssText =
"position:fixed;left:-20000px;top:0;width:" + width + "px;height:1000px;border:0";
document.body.append(frame);
await new Promise((resolve, reject) => {
frame.addEventListener("load", resolve, { once: true });
frame.addEventListener(
"error",
() => reject(new Error(definition.label + " iframe load failed")),
{ once: true },
);
});
await waitFor(
() => frame.contentDocument.documentElement.dataset.appReady === "true",
definition.label + " initialization",
);
await waitFor(
() => frame.contentDocument.getElementById(definition.layoutId)?.dataset.splitPaneActive === "true",
definition.label + " desktop split pane",
);
await nextPaint(frame.contentWindow);
const documentObject = frame.contentDocument;
return {
frame,
windowObject: frame.contentWindow,
documentObject,
layout: documentObject.getElementById(definition.layoutId),
panel: documentObject.getElementById(definition.panelId),
workspace: documentObject.getElementById(definition.workspaceId),
handle: documentObject.getElementById(definition.handleId),
};
}
function panelWidth(mounted) {
return mounted.panel.getBoundingClientRect().width;
}
function handleCenterX(mounted) {
const rect = mounted.handle.getBoundingClientRect();
return rect.left + rect.width / 2;
}
async function dragPanelWider(mounted, definition, amount = 120) {
const { windowObject, handle } = mounted;
const handleRect = handle.getBoundingClientRect();
const startX = handleRect.left + handleRect.width / 2;
const clientY = handleRect.top + Math.min(20, handleRect.height / 2);
const endX = startX + (definition.panelSide === "start" ? amount : -amount);
const pointerId = 17;
handle.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerId,
pointerType: "mouse",
isPrimary: true,
button: 0,
buttons: 1,
clientX: startX,
clientY,
}));
windowObject.dispatchEvent(new windowObject.PointerEvent("pointermove", {
bubbles: true,
cancelable: true,
pointerId,
pointerType: "mouse",
isPrimary: true,
button: -1,
buttons: 1,
clientX: endX,
clientY,
}));
windowObject.dispatchEvent(new windowObject.PointerEvent("pointerup", {
bubbles: true,
cancelable: true,
pointerId,
pointerType: "mouse",
isPrimary: true,
button: 0,
buttons: 0,
clientX: endX,
clientY,
}));
await nextPaint(windowObject);
}
async function pressKey(mounted, key) {
mounted.handle.dispatchEvent(new mounted.windowObject.KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
key,
}));
await nextPaint(mounted.windowObject);
}
async function doubleClickReset(mounted) {
mounted.handle.dispatchEvent(new mounted.windowObject.MouseEvent("dblclick", {
bubbles: true,
cancelable: true,
button: 0,
}));
await nextPaint(mounted.windowObject);
}
function assertFitsViewport(documentObject, element, label) {
const viewportWidth = documentObject.documentElement.clientWidth;
const rect = element.getBoundingClientRect();
assert(rect.width > 0, label + " has a usable width");
assert(rect.left >= -1, label + " extends past the viewport left edge: " + rect.left);
assert(
rect.right <= viewportWidth + 1,
label + " extends past the viewport right edge: " + rect.right + " > " + viewportWidth,
);
}
async function exerciseSplitLayout(definition) {
let mounted = null;
localStorage.removeItem(definition.storageKey);
try {
mounted = await mountLayoutApp(definition);
const { documentObject, layout, panel, workspace, handle } = mounted;
assertEqual(handle.getAttribute("role"), "separator", definition.label + " separator role");
assertEqual(handle.tabIndex, 0, definition.label + " separator keyboard focus");
assertEqual(
handle.getAttribute("aria-orientation"),
"vertical",
definition.label + " separator orientation",
);
assertEqual(handle.getAttribute("aria-controls"), panel.id, definition.label + " controlled panel");
assertEqual(handle.getAttribute("aria-disabled"), "false", definition.label + " desktop separator enabled");
assertEqual(handle.dataset.storageKey, definition.storageKey, definition.label + " storage key contract");
assertEqual(layout.dataset.splitPaneActive, "true", definition.label + " desktop split state");
const initialPanelWidth = panelWidth(mounted);
const initialWorkspaceWidth = workspace.getBoundingClientRect().width;
assertNear(
initialPanelWidth,
definition.defaultPanelWidth,
2,
definition.label + " default panel width",
);
const adaptiveGrid = documentObject.querySelector(definition.adaptiveGridSelector);
assert(adaptiveGrid, definition.label + " adaptive grid exists");
assertEqual(
computedGridTrackCount(mounted.windowObject, adaptiveGrid),
definition.defaultGridTracks,
definition.label + " default panel grid tracks",
);
if (definition.endPanelWidth) {
await pressKey(mounted, "End");
assertNear(
panelWidth(mounted),
definition.endPanelWidth,
2,
definition.label + " End panel width",
);
assertEqual(
computedGridTrackCount(mounted.windowObject, adaptiveGrid),
definition.endGridTracks,
definition.label + " wide panel grid tracks",
);
}
await pressKey(mounted, "Home");
assertNear(
panelWidth(mounted),
definition.homePanelWidth,
2,
definition.label + " Home panel width",
);
assertEqual(
computedGridTrackCount(mounted.windowObject, adaptiveGrid),
definition.homeGridTracks,
definition.label + " narrow panel grid tracks",
);
await doubleClickReset(mounted);
assertNear(
panelWidth(mounted),
definition.defaultPanelWidth,
2,
definition.label + " initial reset panel width",
);
assertEqual(
computedGridTrackCount(mounted.windowObject, adaptiveGrid),
definition.defaultGridTracks,
definition.label + " reset panel grid tracks",
);
assertEqual(
localStorage.getItem(definition.storageKey),
null,
definition.label + " initial reset removes its preference",
);
await dragPanelWider(mounted, definition);
const draggedPanelWidth = panelWidth(mounted);
const draggedWorkspaceWidth = workspace.getBoundingClientRect().width;
assertNear(
draggedPanelWidth,
initialPanelWidth + 120,
2,
definition.label + " pointer drag widens the panel",
);
assertNear(
draggedWorkspaceWidth,
initialWorkspaceWidth - 120,
2,
definition.label + " pointer drag gives panel space from the workspace",
);
assertEqual(layout.dataset.splitPaneDragging, "false", definition.label + " drag state is released");
assert(
!documentObject.body.classList.contains("is-resizing-pane"),
definition.label + " drag body state is released",
);
assertNear(
Number(handle.getAttribute("aria-valuenow")),
draggedPanelWidth,
1,
definition.label + " pointer drag updates aria-valuenow",
);
assertNear(
Number(localStorage.getItem(definition.storageKey)),
draggedPanelWidth,
1,
definition.label + " pointer drag persists its width",
);
handle.focus();
assertEqual(documentObject.activeElement, handle, definition.label + " separator receives focus");
const beforeArrowRightX = handleCenterX(mounted);
const beforeArrowRightPanelWidth = panelWidth(mounted);
await pressKey(mounted, "ArrowRight");
assertNear(
handleCenterX(mounted),
beforeArrowRightX + 16,
2,
definition.label + " ArrowRight moves divider right",
);
assertNear(
panelWidth(mounted),
beforeArrowRightPanelWidth + (definition.panelSide === "start" ? 16 : -16),
2,
definition.label + " ArrowRight changes the physical-side panel",
);
const afterArrowRightX = handleCenterX(mounted);
await pressKey(mounted, "ArrowLeft");
assertNear(
handleCenterX(mounted),
afterArrowRightX - 16,
2,
definition.label + " ArrowLeft moves divider left",
);
assertNear(
panelWidth(mounted),
draggedPanelWidth,
2,
definition.label + " opposite arrow restores width",
);
assertNear(
Number(localStorage.getItem(definition.storageKey)),
draggedPanelWidth,
1,
definition.label + " keyboard width is persisted",
);
const persistedPanelWidth = Number(localStorage.getItem(definition.storageKey));
mounted.frame.remove();
mounted = await mountLayoutApp(definition);
assertNear(
panelWidth(mounted),
persistedPanelWidth,
2,
definition.label + " restores the persisted panel width",
);
assertNear(
Number(mounted.handle.getAttribute("aria-valuenow")),
persistedPanelWidth,
1,
definition.label + " restored width is exposed accessibly",
);
await doubleClickReset(mounted);
assertNear(
panelWidth(mounted),
definition.defaultPanelWidth,
2,
definition.label + " double click restores the default width",
);
const restoredAdaptiveGrid = mounted.documentObject.querySelector(definition.adaptiveGridSelector);
assertEqual(
computedGridTrackCount(mounted.windowObject, restoredAdaptiveGrid),
definition.defaultGridTracks,
definition.label + " double click restores the default grid tracks",
);
assertEqual(
localStorage.getItem(definition.storageKey),
null,
definition.label + " reset removes its preference",
);
mounted.frame.style.width = "390px";
await waitFor(
() => mounted.layout.dataset.splitPaneActive === "false"
&& mounted.handle.getAttribute("aria-disabled") === "true",
definition.label + " compact split pane disable",
);
await nextPaint(mounted.windowObject);
const compactDocument = mounted.documentObject;
const viewportWidth = compactDocument.documentElement.clientWidth;
assertEqual(mounted.windowObject.innerWidth, 390, definition.label + " compact iframe width");
assert(
compactDocument.documentElement.scrollWidth <= viewportWidth + 1,
definition.label + " compact layout has no horizontal document overflow",
);
assertEqual(
mounted.windowObject.getComputedStyle(mounted.handle).display,
"none",
definition.label + " compact separator is hidden",
);
assertEqual(mounted.handle.offsetParent, null, definition.label + " compact separator leaves layout flow");
const panelRect = mounted.panel.getBoundingClientRect();
const workspaceRect = mounted.workspace.getBoundingClientRect();
if (definition.compactWorkspaceFirst) {
assert(
panelRect.top >= workspaceRect.bottom - 1,
definition.label + " compact workspace precedes the long settings panel",
);
} else if (definition.panelSide === "start") {
assert(
workspaceRect.top >= panelRect.bottom - 1,
definition.label + " compact workspace follows the panel vertically",
);
} else {
assert(
panelRect.top >= workspaceRect.bottom - 1,
definition.label + " compact panel follows the workspace vertically",
);
}
const canvasCard = compactDocument.querySelector(definition.canvasSelector);
assert(canvasCard, definition.label + " compact canvas card exists");
assertFitsViewport(compactDocument, mounted.layout, definition.label + " layout");
assertFitsViewport(compactDocument, mounted.panel, definition.label + " panel");
assertFitsViewport(compactDocument, mounted.workspace, definition.label + " workspace");
assertFitsViewport(compactDocument, canvasCard, definition.label + " canvas card");
for (const selector of definition.representativeSelectors) {
const element = compactDocument.querySelector(selector);
assert(element, definition.label + " compact representative exists: " + selector);
assertFitsViewport(compactDocument, element, definition.label + " " + selector);
}
if (definition.label === "Dataset Builder") {
assert(
workspaceRect.width >= viewportWidth - 2,
"Dataset Builder workspace keeps the compact viewport width: " + workspaceRect.width,
);
assert(
canvasCard.getBoundingClientRect().width >= 320,
"Dataset Builder canvas remains usable after stacking: "
+ canvasCard.getBoundingClientRect().width,
);
}
} finally {
mounted?.frame.remove();
localStorage.removeItem(definition.storageKey);
}
}
for (const definition of LAYOUT_CASES) {
test(
definition.label + " split layout supports resize, persistence, reset, and compact stacking",
async () => exerciseSplitLayout(definition),
);
}
+32
View File
@@ -118,6 +118,38 @@ test("WebGPU DAS가 CPU 수치 기준과 일치하거나 auto CPU fallback한다
document.documentElement.dataset.webgpuRmsError = error.rms.toExponential(3);
});
test("WebGPU DAS-PHAT가 CPU score map과 일치하거나 auto CPU fallback한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
const fixture = createFixture(adapter ? "webgpu" : "auto");
fixture.experiment.beamforming.algorithm = "das-phat";
fixture.experiment.frequencyBands = [
{ id: "phase-1k", minHz: 900, maxHz: 1_100, color: "#65f2bf" },
];
if (!adapter) {
const result = await computeDasWithBackend(fixture);
assertEqual(result.backend, "cpu", "DAS-PHAT auto fallback backend");
assertEqual(result.algorithm, "das-phat", "DAS-PHAT fallback algorithm");
assertEqual(result.bands[0].rawScale, "score", "DAS-PHAT fallback scale");
return;
}
const cpuResult = await computeDasMap(fixture);
const gpuResult = await computeDasWebGpu(fixture);
const cpuBand = cpuResult.bands[0];
const gpuBand = gpuResult.bands[0];
const error = normalizedMapError(cpuBand.rawMap, gpuBand.rawMap);
assertEqual(gpuResult.algorithm, "das-phat", "actual WebGPU DAS-PHAT algorithm");
assertEqual(gpuBand.rawScale, "score", "actual WebGPU DAS-PHAT scale");
assert(!("rawPower" in gpuBand.peak), "DAS-PHAT GPU peak is not mislabeled as power");
assert(error.rms <= 0.005, `DAS-PHAT CPU/GPU normalized RMS error=${error.rms}`);
assert(
error.maximumAbsoluteError <= 0.02,
`DAS-PHAT CPU/GPU normalized max error=${error.maximumAbsoluteError}`,
);
});
test("192채널 · 50×50 WebGPU map이 CPU baseline 허용 오차를 만족한다", async () => {
const adapter = await globalThis.navigator?.gpu?.requestAdapter();
if (!adapter) {
+7
View File
@@ -11,14 +11,21 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
try {
await import("/tests/unit/contracts.test.js");
await import("/tests/unit/acoustics.test.js");
await import("/tests/unit/gcc-phat.test.js");
await import("/tests/unit/localization.test.js");
await import("/tests/unit/realtime-localizer.test.js");
await import("/tests/unit/builder.test.js");
await import("/tests/unit/evaluation.test.js");
await import("/tests/unit/camera.test.js");
await import("/tests/unit/webgpu.test.js");
await import("/tests/unit/split-pane.test.js");
await import("/tests/unit/array-builder.test.js");
await import("/tests/unit/array-builder-canvas.test.js");
await import("/tests/integration/cpu-baseline.test.js");
await import("/tests/integration/webgpu.test.js");
await import("/tests/integration/server.test.js");
await import("/tests/integration/array-builder-ui.test.js");
await import("/tests/integration/split-layout-ui.test.js");
await import("/tests/integration/builder-ui.test.js");
await import("/tests/integration/camera-ui.test.js");
await import("/tests/integration/workflow-ui.test.js");
+14 -3
View File
@@ -17,16 +17,27 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
## 범위
- `contracts.test.js`: Array·Scene·Experiment·Result schema, 관계 검증, 안전한 asset 경로와 이동 fixture
- `contracts.test.js`: Array·Scene·Experiment·Result schema, algorithm·scale·beam-spread 관계 검증,
안전한 asset 경로와 이동 fixture
- `acoustics.test.js`: seed 재현성, 자유음장 전파, trajectory 보간, sample 정렬 시간창과
tone/noise/asset 절대 시간 연속성, calibration, STFT와 CPU DAS
tone/noise/asset 절대 시간 연속성, calibration, STFT, CPU DAS와 DAS-PHAT
- `gcc-phat.test.js`: reference 선택, near-field lag 부호, 광대역 source peak, alias·메모리
선할당 제한, 0–1 score bound·scan 범위 불변성과 geometry/STFT/IFFT workspace 재사용
- `localization.test.js`: 알고리즘 registry와 generic CPU/backend dispatcher, power/score
의미, portable GCC-PHAT block·streaming 경로
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
- `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 및 비확률적
beam-spread covariance ellipse
- `camera.test.js`: canonical input hash, workload 추정, 이동 timeline 계획·재생 보간, Result schema 검증
- `webgpu.test.js`: WGSL buffer packing, bin/point batching, 장치 한계와 typed CPU fallback 정책
- `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
장치 한계와 typed CPU fallback 정책
- `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치
WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는
integration test에서 adapter가 있을 때 수행합니다.
+135
View File
@@ -12,6 +12,13 @@ import {
prepareDelaySpectrum,
} from "/packages/acoustics-core/fractional-delay.js";
import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
import {
DEFAULT_LOCALIZATION_ALGORITHM,
LOCALIZATION_ALGORITHM_IDS,
isDasAlgorithm,
localizationAlgorithmDefinition,
localizationAlgorithmLabel,
} from "/packages/acoustics-core/localization-algorithms.js";
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
@@ -29,6 +36,115 @@ function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected} ± ${tolerance}, received ${actual}`);
}
test("localization algorithm registry exposes stable ids and capabilities", () => {
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
assertEqual(
LOCALIZATION_ALGORITHM_IDS.join(","),
"das,das-phat,gcc-phat",
"stable algorithm ids",
);
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS map scale");
assertEqual(
localizationAlgorithmDefinition("das-phat").spectralWeighting,
"phat",
"DAS-PHAT weighting",
);
assertEqual(
localizationAlgorithmDefinition("gcc-phat").backends.join(","),
"cpu",
"GCC-PHAT backend capability",
);
assert(isDasAlgorithm("das") && isDasAlgorithm("das-phat"), "DAS engine algorithms");
assert(!isDasAlgorithm("gcc-phat"), "GCC-PHAT uses a separate engine");
assertEqual(localizationAlgorithmLabel("das-phat"), "DAS-PHAT", "algorithm label");
assertEqual(localizationAlgorithmDefinition("unknown"), undefined, "unknown lookup");
});
test("CPU DAS-PHAT is invariant to spectrum and calibration amplitude gains", async () => {
const sampleRate = 8_000;
const fftSize = 256;
const bin = 32;
const frequency = (bin * sampleRate) / fftSize;
const speedOfSound = 343;
const target = [0.2, 0, 1];
const positions = [[-0.15, 0, 0], [-0.02, 0, 0], [0.12, 0, 0]];
const phaseOffsets = [30, -45, 75];
const responsePhases = [10, 20, -35];
const createDefinition = (withAmplitudeMetadata) => ({
microphones: positions.map((position, index) => ({
id: `mic-${index}`,
position,
sensitivityDb: withAmplitudeMetadata ? [-30, 24, 60][index] : 0,
phaseOffsetDeg: phaseOffsets[index],
frequencyResponse: [
{
frequencyHz: 0,
gainDb: withAmplitudeMetadata ? [-60, 40, 100][index] : 0,
phaseDeg: responsePhases[index],
},
{
frequencyHz: 4_000,
gainDb: withAmplitudeMetadata ? [-60, 40, 100][index] : 0,
phaseDeg: responsePhases[index],
},
],
})),
});
const createSpectrum = (magnitudes) => {
const real = new Float32Array(positions.length);
const imaginary = new Float32Array(positions.length);
positions.forEach((position, index) => {
const propagationPhase =
(2 * Math.PI * frequency * distance3(target, position)) / speedOfSound;
const calibrationPhase =
((phaseOffsets[index] + responsePhases[index]) * Math.PI) / 180;
const observedPhase = -propagationPhase + calibrationPhase;
real[index] = Math.cos(observedPhase) * magnitudes[index];
imaginary[index] = Math.sin(observedPhase) * magnitudes[index];
});
return { real, imaginary };
};
const scan = {
z: 1,
xMin: -0.2,
xMax: 0.2,
yMin: 0,
yMax: 0,
columns: 2,
rows: 1,
};
const baselineArray = createDefinition(false);
const gainedArray = createDefinition(true);
const common = {
bin,
frameCount: 1,
pointCount: 2,
sampleRate,
fftSize,
speedOfSound,
algorithm: "das-phat",
pointBatchSize: 64,
};
const baseline = await computeBinMap({
...common,
spectrum: createSpectrum([1, 1, 1]),
arrayDefinition: baselineArray,
distances: createDistanceMatrix(baselineArray, scan),
});
const gained = await computeBinMap({
...common,
spectrum: createSpectrum([0.125, 8, 32]),
arrayDefinition: gainedArray,
distances: createDistanceMatrix(gainedArray, scan),
});
baseline.forEach((value, index) => {
assertNear(gained[index], value, 2e-6, `gain-invariant map value ${index}`);
});
assertNear(baseline[1], 1, 2e-6, "phase-compensated target score");
assert(baseline[1] > baseline[0] * 1.1, "phase compensation preserves target peak");
});
function rms(values, start = 0, end = values.length) {
let sum = 0;
for (let index = start; index < end; index += 1) sum += values[index] ** 2;
@@ -521,12 +637,31 @@ test("CPU DAS peak가 calibration 후 단일 tone의 실제 grid 위치를 찾
onProgress: (progress) => progressEvents.push(progress),
});
const peakPosition = result.bands[0].peak.position;
assertEqual(result.algorithm, "das", "default result algorithm");
assertEqual(result.bands[0].rawScale, "power", "DAS raw map scale");
assert(distance3(peakPosition, scene.sources[0].position) <= 0.071, `peak=${peakPosition.join(",")}`);
assert(result.bands[0].rawMap !== result.bands[0].displayMap, "raw/display buffers are separate");
assertNear(Math.max(...result.bands[0].rawMap), result.bands[0].peak.rawPower, 1e-4, "raw map peak");
assertNear(Math.max(...result.bands[0].displayMap), 1, 1e-6, "normalized display peak");
assert(progressEvents.some(({ stage }) => stage === "beamforming"), "beamforming progress");
const phaseOnly = await computeDasMap({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
scene,
experiment: {
...experiment,
beamforming: { ...experiment.beamforming, algorithm: "das-phat" },
},
});
assertEqual(phaseOnly.algorithm, "das-phat", "phase-only result algorithm");
assertEqual(phaseOnly.bands[0].rawScale, "score", "DAS-PHAT raw map scale");
assert(
distance3(phaseOnly.bands[0].peak.position, scene.sources[0].position) <= 0.071,
`DAS-PHAT peak=${phaseOnly.bands[0].peak.position.join(",")}`,
);
const uncompensated = await computeDasMap({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
+141
View File
@@ -0,0 +1,141 @@
import {
arrayPositionToCanvas,
canvasPointToArray,
eventToArrayPosition,
eventToCanvasPoint,
fitArrayViewport,
hitTestMicrophone,
keyboardNudgeForKey,
niceGridStep,
} from "/apps/array-builder/canvas-view.js";
function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
}
function fixture() {
return {
aperture: { width: 0.4, height: 0.2 },
microphones: [
{ id: "mic-001", position: [-0.1, -0.05, 0] },
{ id: "mic-002", position: [0.1, 0.05, 0.01] },
],
};
}
test("Array canvas fit은 x/y에 동일 축척을 사용하고 좌표를 왕복한다", () => {
const viewport = fitArrayViewport(800, 420, fixture());
const point = [0.075, -0.025, 0.012];
const pixel = arrayPositionToCanvas(point, viewport);
const roundTrip = canvasPointToArray(pixel, viewport, { z: point[2] });
assertNear(roundTrip[0], point[0], 1e-12, "x round trip");
assertNear(roundTrip[1], point[1], 1e-12, "y round trip");
assertNear(roundTrip[2], point[2], 1e-12, "z preserved");
const oneCentimeterX = arrayPositionToCanvas([0.01, 0, 0], viewport).x -
arrayPositionToCanvas([0, 0, 0], viewport).x;
const oneCentimeterY = arrayPositionToCanvas([0, 0.01, 0], viewport).y -
arrayPositionToCanvas([0, 0, 0], viewport).y;
assertNear(Math.abs(oneCentimeterX), Math.abs(oneCentimeterY), 1e-12, "equal x/y scale");
});
test("Array canvas 좌표는 화면 위쪽을 양의 y로 해석한다", () => {
const viewport = fitArrayViewport(500, 500, fixture());
const upper = canvasPointToArray(
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y },
viewport,
);
const lower = canvasPointToArray(
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y + viewport.plot.height },
viewport,
);
assert(upper[1] > lower[1], "canvas top maps to larger array y");
assertNear(upper[1], viewport.yMax, 1e-12, "top is yMax");
assertNear(lower[1], viewport.yMin, 1e-12, "bottom is yMin");
});
test("PointerEvent-like 좌표 변환은 DOM 없이 canvas와 plot 경계에 clamp할 수 있다", () => {
const viewport = fitArrayViewport(640, 360, fixture());
const rect = { left: 100, top: 50, width: 640, height: 360 };
const canvasPoint = eventToCanvasPoint({ clientX: 50, clientY: 500 }, rect, { clamp: true });
assertEqual(canvasPoint.x, 0, "client x clamps to canvas left");
assertEqual(canvasPoint.y, 360, "client y clamps to canvas bottom");
const position = eventToArrayPosition(
{ clientX: -500, clientY: 900 },
rect,
viewport,
{ clamp: true, clampToCanvas: true, z: 0.02 },
);
assertNear(position[0], viewport.xMin, 1e-12, "event x clamps to plot xMin");
assertNear(position[1], viewport.yMin, 1e-12, "event y clamps to plot yMin");
assertEqual(position[2], 0.02, "event conversion carries z");
});
test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채널 index를 돌려준다", () => {
const arrayDefinition = fixture();
const viewport = fitArrayViewport(800, 420, arrayDefinition);
const micPixel = arrayPositionToCanvas(arrayDefinition.microphones[1].position, viewport);
const hit = hitTestMicrophone(
{ x: micPixel.x + 3, y: micPixel.y - 2 },
arrayDefinition.microphones,
viewport,
8,
);
assertEqual(hit.id, "mic-002", "closest microphone id");
assertEqual(hit.index, 1, "physical channel index");
assertNear(hit.distancePx, Math.hypot(3, 2), 1e-12, "pixel distance");
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 규칙을 따른다", () => {
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
for (const step of candidates) {
const magnitude = 10 ** Math.floor(Math.log10(step));
const normalized = Math.round(step / magnitude);
assert([1, 2, 5, 10].includes(normalized), "grid step uses 1/2/5 decade");
}
assertEqual(keyboardNudgeForKey("ArrowLeft", 0.002).dx, -0.002, "left nudge");
assertEqual(keyboardNudgeForKey("ArrowUp", 0.002).dy, 0.002, "up is positive y");
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
});
+276
View File
@@ -0,0 +1,276 @@
import {
MAX_MICROPHONES,
addMicrophone,
arrayGeometryIssues,
arrayStatistics,
centerMicrophones,
clampPositionToAperture,
createArrayDefinition,
fitApertureToMicrophones,
generatePresetMicrophones,
inferApertureShape,
moveMicrophone,
nextMicrophoneId,
positionInsideAperture,
removeMicrophone,
replaceMicrophonesWithPreset,
updateMicrophone,
} from "/apps/array-builder/model.js";
import { validateArrayDefinition } from "/packages/contracts/validation.js";
function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
}
function assertThrows(callback, text, label) {
let error;
try {
callback();
} catch (caught) {
error = caught;
}
assert(error instanceof Error, label + " should throw");
assert(error.message.includes(text), label + ": " + error.message);
}
test("Array Builder 기본 4x3 문서가 표준 Array 계약과 row-major 좌표를 만족한다", async () => {
const arrayDefinition = createArrayDefinition();
assertEqual(arrayDefinition.microphones.length, 12, "default microphone count");
assertEqual(arrayDefinition.microphones[0].id, "mic-001", "first channel id");
assertEqual(arrayDefinition.microphones[11].id, "mic-012", "last channel id");
assertEqual(arrayDefinition.microphones[0].position.join(","), "-0.225,-0.225,0", "bottom-left first");
assertEqual(arrayDefinition.microphones[3].position.join(","), "0.225,-0.225,0", "row advances left to right");
assertEqual(arrayDefinition.microphones[11].position.join(","), "0.225,0.225,0", "top-right last");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "default array schema");
});
test("격자·선형·링·Fermat preset이 개수와 생성 경계를 지킨다", () => {
const aperture = { width: 0.4, height: 0.2 };
const grid = generatePresetMicrophones({ type: "grid", aperture, columns: 3, rows: 2 });
assertEqual(grid.length, 6, "grid count");
assertEqual(grid[0].position.join(","), "-0.2,-0.1,0", "grid first");
assertEqual(grid[5].position.join(","), "0.2,0.1,0", "grid last");
for (const type of ["linear", "ring", "spiral"]) {
const microphones = generatePresetMicrophones({
type,
aperture,
shape: "ellipse",
count: 17,
axis: "diagonal",
});
assertEqual(microphones.length, 17, type + " count");
assert(
microphones.every((microphone) => positionInsideAperture(microphone.position, aperture, "ellipse")),
type + " ellipse bounds",
);
}
});
test("타원 격자는 요청한 rows x columns 수를 유지하며 경계 안에 배치된다", () => {
const aperture = { width: 0.45, height: 0.3 };
const microphones = generatePresetMicrophones({
type: "grid",
aperture,
shape: "ellipse",
columns: 7,
rows: 5,
});
assertEqual(microphones.length, 35, "ellipse grid count");
assert(
microphones.every((microphone) => positionInsideAperture(microphone.position, aperture, "ellipse")),
"ellipse-adapted grid bounds",
);
});
test("seed random preset은 재현되고 최소 간격과 형상 경계를 지킨다", () => {
const options = {
type: "random",
aperture: { width: 0.5, height: 0.35 },
shape: "ellipse",
count: 24,
seed: 12345,
minimumSpacingM: 0.01,
};
const first = generatePresetMicrophones(options);
const repeated = generatePresetMicrophones(options);
const different = generatePresetMicrophones({ ...options, seed: 54321 });
assertEqual(JSON.stringify(first), JSON.stringify(repeated), "same seed layout");
assert(JSON.stringify(first) !== JSON.stringify(different), "different seed layout");
assert(
first.every((microphone) => positionInsideAperture(microphone.position, options.aperture, "ellipse")),
"random ellipse bounds",
);
for (let left = 0; left < first.length; left += 1) {
for (let right = left + 1; right < first.length; right += 1) {
const distance = Math.hypot(
first[left].position[0] - first[right].position[0],
first[left].position[1] - first[right].position[1],
);
assert(distance >= 0.01 - 1e-9, "random minimum spacing");
}
}
});
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 조건과 최대 채널 초과는 기존 배열을 보존한다", () => {
const arrayDefinition = createArrayDefinition();
const before = JSON.stringify(arrayDefinition.microphones);
assertThrows(
() => replaceMicrophonesWithPreset(arrayDefinition, {
type: "random",
aperture: arrayDefinition.aperture,
count: 100,
minimumSpacingM: 0.4,
seed: 1,
}),
"배치할 수 없습니다",
"impossible random spacing",
);
assertEqual(JSON.stringify(arrayDefinition.microphones), before, "failed generation is atomic");
assertThrows(
() => generatePresetMicrophones({
type: "linear",
aperture: arrayDefinition.aperture,
count: MAX_MICROPHONES + 1,
}),
"1–1024",
"microphone maximum",
);
});
test("수동 add/update/reorder/remove가 ID와 calibration을 보존한다", () => {
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
arrayDefinition.microphones[0].sensitivityDb = -1.5;
arrayDefinition.microphones[0].phaseOffsetDeg = 8;
arrayDefinition.microphones[0].frequencyResponse = [
{ frequencyHz: 100, gainDb: -2 },
{ frequencyHz: 1000, gainDb: 0.5, phaseDeg: 2 },
];
updateMicrophone(arrayDefinition, "mic-001", { position: [-0.1, 0.02, 0.003] });
assertEqual(arrayDefinition.microphones[0].sensitivityDb, -1.5, "sensitivity preserved");
assertEqual(arrayDefinition.microphones[0].frequencyResponse.length, 2, "response preserved");
const added = addMicrophone(arrayDefinition, [0, 0, 0]);
assertEqual(added.id, "mic-003", "stable next id");
assert(moveMicrophone(arrayDefinition, added.id, -1), "channel reordered");
assertEqual(arrayDefinition.microphones[1].id, "mic-003", "new channel order");
assert(removeMicrophone(arrayDefinition, "mic-002"), "channel removed");
assertEqual(addMicrophone(arrayDefinition, [0.05, 0, 0]).id, "mic-004", "deleted suffix is not reused");
while (arrayDefinition.microphones.length > 1) {
removeMicrophone(arrayDefinition, arrayDefinition.microphones.at(-1).id);
}
assertEqual(removeMicrophone(arrayDefinition, arrayDefinition.microphones[0].id), false, "last microphone retained");
});
test("Builder geometry 진단은 중복을 오류, 생성 경계 초과를 경고로 구분한다", () => {
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
arrayDefinition.microphones[0].position[0] = 0.4;
arrayDefinition.microphones[1].position = [...arrayDefinition.microphones[0].position];
const issues = arrayGeometryIssues(arrayDefinition, { shape: "ellipse" });
assert(issues.some((issue) => issue.severity === "error" && issue.message.includes("중복")), "duplicate error");
assert(issues.some((issue) => issue.severity === "warning" && issue.message.includes("경계 밖")), "bounds warning");
});
test("좌표 clamp, 중심 정렬과 aperture 맞춤이 명시적으로 동작한다", () => {
const aperture = { width: 0.4, height: 0.2 };
const clamped = clampPositionToAperture([0.4, 0.2, 0.03], aperture, "ellipse");
assert(positionInsideAperture(clamped, aperture, "ellipse"), "ellipse clamp");
assertNear(clamped[2], 0.03, 1e-12, "z remains editable");
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1, width: 0.2, height: 0.1 });
arrayDefinition.microphones[0].position = [0.2, 0.1, 0.02];
arrayDefinition.microphones[1].position = [0.4, 0.1, 0.04];
centerMicrophones(arrayDefinition);
assertEqual(arrayDefinition.microphones[0].position.join(","), "-0.1,0,-0.01", "center first");
assertEqual(arrayDefinition.microphones[1].position.join(","), "0.1,0,0.01", "center second");
fitApertureToMicrophones(arrayDefinition, { shape: "rectangle", paddingM: 0.01 });
assertNear(arrayDefinition.aperture.width, 0.22, 1e-12, "fitted width");
assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height");
});
test("문서의 명시적 형상 단서만으로 타원 생성 경계를 추론한다", () => {
const spiral = createArrayDefinition({
description: "Fermat spiral on circular aperture",
presetType: "spiral",
count: 32,
});
assertEqual(inferApertureShape(spiral), "ellipse", "description inference");
const grid = createArrayDefinition();
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",
);
});
+62 -2
View File
@@ -14,6 +14,8 @@ import {
import {
alignExperimentToScene,
canonicalStringify,
createComparisonSignature,
createHistorySummary,
createResultDocument,
estimateExperimentWorkload,
hashSimulationInputs,
@@ -247,9 +249,49 @@ test("Camera workload counts unique overlapping FFT bins", () => {
assertEqual(workload.frameCount, 62, "STFT frame count");
assertEqual(JSON.stringify(workload.bandBins), JSON.stringify([[4, 5, 6], [6, 7, 8]]), "bins per band");
assertEqual(workload.selectedBinCount, 5, "overlapping bin is counted once");
assertEqual(workload.stftTransformCount, 186, "STFT transform count");
assertEqual(workload.scanOperationCount, 74_400, "DAS scan operation count");
assertEqual(workload.scanOperationKind, "complex MAC", "DAS operation kind");
assertEqual(workload.complexMacCount, 74_400, "estimated complex MAC count");
assertNear(workload.scalingVs50, 80 / 2500, 1e-15, "50x50 scaling");
assertNear(workload.distanceMiB, (80 * 3 * 4) / 1024 ** 2, 1e-15, "distance table size");
const gccExperiment = structuredClone(experiment);
gccExperiment.beamforming = { algorithm: "gcc-phat" };
const gccWorkload = estimateExperimentWorkload(arrayDefinition, scene, gccExperiment);
assertEqual(gccWorkload.algorithmLabel, "GCC-PHAT", "GCC algorithm label");
assertEqual(gccWorkload.referencePairCount, 2, "GCC reference pairs");
assertEqual(gccWorkload.bandBinSelectionCount, 6, "GCC counts overlapping band bins per band");
assertEqual(gccWorkload.gccCrossSpectrumComplexOps, 1_488, "GCC two-pass cross-spectrum ops");
assertEqual(gccWorkload.inverseFftTransformCount, 4, "GCC inverse FFT count");
assertEqual(gccWorkload.minimumBandBinCount, 3, "GCC narrowest band bin count");
assertEqual(gccWorkload.scanOperationCount, 320, "GCC pair-band scan lookups");
assertEqual(gccWorkload.scanOperationKind, "reference-pair lookup", "GCC operation kind");
assertEqual(gccWorkload.complexMacCount, 0, "GCC is not mislabeled as DAS complex MAC");
});
test("Camera comparison signature groups array, algorithm, and backend variants only", () => {
const scene = { id: "scene", seed: 7, sources: [] };
const baseline = {
id: "experiment",
arrayId: "array-a",
scan: { rows: 8, columns: 8 },
beamforming: { algorithm: "das", backend: "cpu", normalization: "peak" },
};
const variant = structuredClone(baseline);
variant.arrayId = "array-b";
variant.beamforming.algorithm = "das-phat";
variant.beamforming.backend = "webgpu";
assertEqual(
createComparisonSignature(scene, variant),
createComparisonSignature(scene, baseline),
"comparison axes share one history group",
);
variant.scan.columns = 9;
assert(
createComparisonSignature(scene, variant) !== createComparisonSignature(scene, baseline),
"scan changes remain separate",
);
});
test("Camera acoustic presets bound current geometry and expose predictable sparse cost", () => {
@@ -415,11 +457,13 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
arrayId: arrayDefinition.id,
sceneId: scene.id,
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
beamforming: { backend: "auto" },
beamforming: { algorithm: "das-phat", backend: "auto" },
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
};
const run = {
algorithm: "das-phat",
backend: "cpu",
referenceMic: "mic-1",
signalStats: {
preNormalizationPeak: 0.75,
appliedScale: 1,
@@ -433,6 +477,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
bands: [{
id: "band-1",
color: "#65f2bf",
rawScale: "score",
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
displayScale: "normalized",
@@ -448,7 +493,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
localizationErrorPixels: 0.1,
beamSpread: {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
method: "connected-half-maximum-score-weighted-covariance",
halfPowerRatio: 0.5,
thresholdValue: 0.5,
pixelCount: 3,
@@ -496,6 +541,10 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
});
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
assertEqual(result.algorithm, "das-phat", "actual algorithm is exported");
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
assertEqual(result.bands[0].rawMap.scale, "score", "algorithm raw score scale is exported");
assertEqual(result.environment.referenceMic, "mic-1", "reference microphone metadata is exported");
assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted");
assertEqual(
Object.keys(result.bands[0].metrics).join(","),
@@ -512,6 +561,17 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
const history = createHistorySummary({
arrayDefinition,
scene,
experiment,
run,
evaluations,
inputHash: "a".repeat(64),
});
assertEqual(history.algorithm, "das-phat", "history records the actual algorithm");
assertEqual(history.localizationMs, 10, "history records localization time");
const issues = await validateResult(result, { experiment, scene });
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
+202 -2
View File
@@ -11,6 +11,16 @@ async function loadJson(path) {
return response.json();
}
const RECTANGULAR_ARRAY_EXAMPLES = [
{ id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 },
{ id: "rectangular-4x3-450mm", microphoneCount: 12, columns: 4, rows: 3, width: 0.45, height: 0.45 },
{ id: "rectangular-8x6-450mm", microphoneCount: 48, columns: 8, rows: 6, width: 0.45, height: 0.45 },
{ id: "rectangular-16x12", microphoneCount: 192, columns: 16, rows: 12, width: 0.45, height: 0.45 },
{ id: "rectangular-24x16-450mm", microphoneCount: 384, columns: 24, rows: 16, width: 0.45, height: 0.45 },
{ id: "rectangular-16x12-compact", microphoneCount: 192, columns: 16, rows: 12, width: 0.12, height: 0.12 },
{ id: "rectangular-16x12-large", microphoneCount: 192, columns: 16, rows: 12, width: 1.2, height: 1.2 },
];
test("moving-tone sample Scene and Experiment satisfy their linked contracts", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
@@ -36,6 +46,92 @@ test("192채널 기준 배열이 계약을 통과한다", async () => {
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues");
});
test("rectangular array examples cover low-to-high channel counts and compact-to-wide apertures", async () => {
const arrays = await Promise.all(RECTANGULAR_ARRAY_EXAMPLES.map(({ id }) =>
loadJson(`/assets/array-geometries/${id}.array.json`)));
for (let index = 0; index < arrays.length; index += 1) {
const arrayDefinition = arrays[index];
const expected = RECTANGULAR_ARRAY_EXAMPLES[index];
assertEqual(arrayDefinition.id, expected.id, `${expected.id} fixture id`);
assertEqual(arrayDefinition.microphones.length, expected.microphoneCount, `${expected.id} channel count`);
assertEqual(arrayDefinition.aperture.width, expected.width, `${expected.id} aperture width`);
assertEqual(arrayDefinition.aperture.height, expected.height, `${expected.id} aperture height`);
assertEqual(
(await validateArrayDefinition(arrayDefinition)).length,
0,
`${expected.id} contract issues`,
);
assertEqual(
new Set(arrayDefinition.microphones.map(({ id }) => id)).size,
expected.microphoneCount,
`${expected.id} microphone ids are unique`,
);
assertEqual(
new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size,
expected.microphoneCount,
`${expected.id} positions are unique`,
);
const xCoordinates = arrayDefinition.microphones.map(({ position }) => position[0]);
const yCoordinates = arrayDefinition.microphones.map(({ position }) => position[1]);
assert(
arrayDefinition.microphones.every(({ position }) => position[2] === 0),
`${expected.id} microphones stay on the z=0 plane`,
);
assertEqual(new Set(xCoordinates).size, expected.columns, `${expected.id} column count`);
assertEqual(new Set(yCoordinates).size, expected.rows, `${expected.id} row count`);
const uniqueX = [...new Set(xCoordinates)].sort((left, right) => left - right);
const uniqueY = [...new Set(yCoordinates)].sort((left, right) => left - right);
const expectedXPitch = expected.width / (expected.columns - 1);
const expectedYPitch = expected.height / (expected.rows - 1);
assert(
uniqueX.slice(1).every((value, position) =>
Math.abs(value - uniqueX[position] - expectedXPitch) < 1.1e-6),
`${expected.id} uniform x pitch`,
);
assert(
uniqueY.slice(1).every((value, position) =>
Math.abs(value - uniqueY[position] - expectedYPitch) < 1.1e-6),
`${expected.id} uniform y pitch`,
);
assert(Math.abs(Math.min(...xCoordinates) + expected.width / 2) < 1e-6, `${expected.id} x minimum`);
assert(Math.abs(Math.max(...xCoordinates) - expected.width / 2) < 1e-6, `${expected.id} x maximum`);
assert(Math.abs(Math.min(...yCoordinates) + expected.height / 2) < 1e-6, `${expected.id} y minimum`);
assert(Math.abs(Math.max(...yCoordinates) - expected.height / 2) < 1e-6, `${expected.id} y maximum`);
}
const channelSeries = arrays.slice(0, 5);
assert(
channelSeries.every(({ aperture }) => aperture.width === 0.45 && aperture.height === 0.45),
"channel-count series keeps the 0.45 m aperture fixed",
);
assertEqual(
channelSeries.map(({ microphones }) => microphones.length).join(","),
"4,12,48,192,384",
"channel-count series",
);
const apertureSeries = [
arrays.find(({ id }) => id === "rectangular-16x12-compact"),
arrays.find(({ id }) => id === "rectangular-16x12"),
arrays.find(({ id }) => id === "rectangular-16x12-large"),
];
assert(
apertureSeries.every(({ microphones }) => microphones.length === 192),
"aperture series keeps 192 channels fixed",
);
assertEqual(
apertureSeries.map(({ aperture }) => aperture.width).join(","),
"0.12,0.45,1.2",
"compact-to-large aperture series",
);
assertEqual(
Math.max(...arrays.map(({ microphones }) => microphones.length)),
384,
"high-count example",
);
});
test("192채널 Fermat spiral 비교 배열이 계약과 원형 aperture를 만족한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/fermat-spiral-192.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "spiral microphone count");
@@ -61,6 +157,51 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
});
test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => {
const [arrayDefinition, scene, baseline] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
for (const algorithm of ["das", "das-phat", "gcc-phat"]) {
const experiment = structuredClone(baseline);
experiment.beamforming.algorithm = algorithm;
experiment.beamforming.backend = algorithm === "gcc-phat" ? "cpu" : "auto";
assertEqual(
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
0,
`${algorithm} experiment issues`,
);
}
const incompatible = structuredClone(baseline);
incompatible.beamforming.algorithm = "gcc-phat";
incompatible.beamforming.backend = "webgpu";
let issues = await validateExperiment(incompatible, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.beamforming.backend"),
"GCC-PHAT rejects the WebGPU backend",
);
incompatible.beamforming.backend = "cpu";
issues = await validateExperiment(incompatible, {
arrayDefinition: { ...arrayDefinition, microphones: arrayDefinition.microphones.slice(0, 1) },
scene,
});
assert(
issues.some(({ path }) => path === "$.beamforming.algorithm"),
"GCC-PHAT requires two microphones",
);
incompatible.beamforming.algorithm = "music";
issues = await validateExperiment(incompatible, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.beamforming.algorithm"),
"unsupported algorithms are rejected",
);
});
test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.acoustics = {
@@ -261,6 +402,7 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
sceneId: experiment.sceneId,
createdAt: "2026-08-28T00:00:00.000Z",
backend: "cpu",
algorithm: "das",
inputHash: "a".repeat(64),
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
@@ -268,15 +410,73 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
id: "tone-1k",
color: "#65f2bf",
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
peaks: [{ sourceId: "source-1", position: [0.08, 0.04, 2], value: 1, localizationErrorM: 0 }],
peaks: [{
sourceId: "source-1",
position: [0.08, 0.04, 2],
value: 1,
localizationErrorM: 0,
beamSpread: {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
halfPowerRatio: 0.5,
thresholdValue: 0.5,
pixelCount: 1,
centroid: [0.08, 0.04, 2],
covarianceM2: { xx: 0, xy: 0, yy: 0 },
ellipse: {
axisScale: "rms",
angleReference: "counterclockwise-from-positive-x",
majorRmsRadiusM: 0,
minorRmsRadiusM: 0,
angleDeg: 0,
},
},
}],
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
}],
};
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
experiment.beamforming.algorithm = "das-phat";
result.algorithm = "das-phat";
result.bands[0].rawMap.scale = "score";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-maximum-score-weighted-covariance";
assertEqual(
(await validateResult(result, { experiment, scene })).length,
0,
"phase-weighted score result issues",
);
result.bands[0].rawMap.scale = "power";
let issues = await validateResult(result, { experiment, scene });
assert(
issues.some(({ path }) => path.endsWith("rawMap.scale")),
"algorithm and raw map scale must agree",
);
result.bands[0].rawMap.scale = "score";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-power-power-weighted-covariance";
issues = await validateResult(result, { experiment, scene });
assert(
issues.some(({ path }) => path.endsWith("beamSpread.method")),
"score map and beam spread method must agree",
);
result.bands[0].peaks[0].beamSpread.method =
"connected-half-maximum-score-weighted-covariance";
result.algorithm = "das";
issues = await validateResult(result, { experiment, scene });
assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment");
experiment.beamforming.algorithm = "das";
result.bands[0].rawMap.scale = "power";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-power-power-weighted-covariance";
result.bands[0].rawMap.values.pop();
result.bands[0].rawMap.scale = "normalized";
result.bands[0].peaks[0].sourceId = "missing-source";
const issues = await validateResult(result, { experiment, scene });
issues = await validateResult(result, { experiment, scene });
assert(issues.some(({ message }) => message.includes("rows × columns")));
assert(issues.some(({ path }) => path.endsWith("rawMap.scale")));
assert(issues.some(({ path }) => path.endsWith("sourceId")));
+40 -1
View File
@@ -1,6 +1,7 @@
import {
RESULT_METRIC_KEYS,
evaluateDasBand,
evaluateLocalizationBand,
exportBandEvaluation,
findLocalPeaks,
measureBeamSpread,
@@ -213,6 +214,44 @@ test("beam spread가 회전된 비등방성 half-power lobe의 covariance ellips
);
});
test("generic localization 평가는 score map의 척도와 beam spread 방식을 보존한다", () => {
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2, columns: 3, rows: 3 };
const rawMap = {
rows: 3,
columns: 3,
scale: "score",
values: Float32Array.of(
0, 1, 0,
1, 4, 1,
0, 1, 0,
),
};
const evaluation = evaluateLocalizationBand({
rawMap,
scan,
sources: [{ id: "center", position: [0, 0, 2] }],
matchToleranceM: 0,
truthRadiusM: 0,
minimumPeakDistanceM: 0,
});
assertEqual(evaluation.diagnostics.mapScale, "score", "score scale diagnostic");
assertEqual(
evaluation.detections[0].beamSpread.method,
"connected-half-maximum-score-weighted-covariance",
"score beam-spread method",
);
assertEqual(evaluation.sourceMatches[0].localizationErrorM, 0, "score peak localization");
const legacy = evaluateDasBand({
rawMap: rawMap.values,
scan,
sources: [],
minimumPeakDistanceM: 0,
});
assertEqual(legacy.diagnostics.mapScale, "power", "legacy DAS defaults to power");
});
test("beam spread가 1-row, 1-column, single-pixel lobe에서 유한하고 결정적이다", () => {
const powers = Float32Array.of(5, 8, 10, 8, 5);
const rowSpread = measureBeamSpread(
@@ -337,6 +376,6 @@ test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서
scan,
sources: [],
})) instanceof RangeError,
"non-power map rejected",
"display-only map scale rejected",
);
});
+368
View File
@@ -0,0 +1,368 @@
import {
computeGccPhatMap,
createGccPhatAnalysisPlan,
estimateGccPhatWorkload,
selectGccPhatReference,
} from "/packages/acoustics-core/gcc-phat.js";
import {
addFrequencyDomainDelay,
createDelayWorkspace,
prepareDelaySpectrum,
} from "/packages/acoustics-core/fractional-delay.js";
import { distance3 } from "/packages/acoustics-core/math.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
label + ": expected " + expected + " ± " + tolerance + ", received " + actual,
);
}
function deterministicBroadbandSamples(length) {
let state = 0x6d2b79f5;
return Float32Array.from({ length }, (_, index) => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
const noise = ((state >>> 0) / 0xffff_ffff) * 2 - 1;
const taper = Math.min(1, index / 64, (length - 1 - index) / 64);
return noise * Math.max(0, taper);
});
}
function createBroadbandFixture() {
const sampleRate = 4096;
const speedOfSound = 343;
const target = [0.3, 0.2, 0.6];
const arrayDefinition = {
id: "gcc-array",
microphones: [
{
id: "a-ref",
position: [-0.45, -0.35, 0],
sensitivityDb: 9,
phaseOffsetDeg: 31,
frequencyResponse: [
{ frequencyHz: 0, gainDb: -8, phaseDeg: 19 },
{ frequencyHz: sampleRate / 2, gainDb: -8, phaseDeg: 19 },
],
},
{
id: "b-ne",
position: [0.45, 0.35, 0],
sensitivityDb: -7,
phaseOffsetDeg: -47,
frequencyResponse: [
{ frequencyHz: 0, gainDb: 6, phaseDeg: -23 },
{ frequencyHz: sampleRate / 2, gainDb: 6, phaseDeg: -23 },
],
},
{
id: "c-nw",
position: [-0.45, 0.35, 0],
sensitivityDb: 4,
phaseOffsetDeg: 73,
},
{
id: "d-se",
position: [0.45, -0.35, 0],
sensitivityDb: -3,
phaseOffsetDeg: -11,
},
],
};
const scene = { speedOfSound };
const experiment = {
stft: { fftSize: 256, hopSize: 128, window: "hann" },
scan: {
z: target[2],
xMin: -0.4,
xMax: 0.4,
yMin: -0.4,
yMax: 0.4,
columns: 9,
rows: 9,
},
frequencyBands: [
{ id: "broadband", minHz: 128, maxHz: 1800, color: "#65f2bf" },
],
beamforming: {
algorithm: "gcc-phat",
backend: "cpu",
normalization: "peak",
compensateFrequencyResponse: true,
maxCompensationDb: 24,
},
};
const emitted = deterministicBroadbandSamples(2048);
const distances = arrayDefinition.microphones.map((microphone) =>
distance3(target, microphone.position)
);
const maximumDelaySamples = Math.max(...distances) * sampleRate / speedOfSound;
const spectrum = prepareDelaySpectrum(emitted, maximumDelaySamples);
const workspace = createDelayWorkspace(spectrum.fftSize);
const channels = arrayDefinition.microphones.map((microphone, index) => {
const channel = new Float32Array(emitted.length);
addFrequencyDomainDelay({
output: channel,
spectrum,
delaySamples: distances[index] * sampleRate / speedOfSound,
gain: 1 / distances[index],
phaseOffsetDeg: microphone.phaseOffsetDeg,
frequencyResponse: microphone.frequencyResponse,
sampleRate,
workspace,
});
return channel;
});
return {
sampleRate,
speedOfSound,
target,
arrayDefinition,
scene,
experiment,
channels,
};
}
function scanIndexFor(scan, x, y) {
const column = Math.round((x - scan.xMin) / (scan.xMax - scan.xMin) * (scan.columns - 1));
const row = Math.round((scan.yMax - y) / (scan.yMax - scan.yMin) * (scan.rows - 1));
return row * scan.columns + column;
}
function assertFloat32Exact(actual, expected, label) {
assertEqual(actual.length, expected.length, label + " length");
for (let index = 0; index < actual.length; index += 1) {
assertEqual(actual[index], expected[index], label + "[" + index + "]");
}
}
test("GCC-PHAT reference selection uses the farthest centroid radius and id tie-break", () => {
const arrayDefinition = {
microphones: [
{ id: "z-left", position: [-1, 0, 0] },
{ id: "middle", position: [0, 0, 0] },
{ id: "a-right", position: [1, 0, 0] },
],
};
const selected = selectGccPhatReference(arrayDefinition);
assertEqual(selected.id, "a-right", "lexicographically first id wins a radius tie");
assertEqual(selected.index, 2, "selected microphone index");
assertEqual(selected.microphone, arrayDefinition.microphones[2], "selected microphone identity");
});
test("GCC-PHAT workload reports reference-pair scan reduction and rejects aliased lag", () => {
const fixture = createBroadbandFixture();
const workload = estimateGccPhatWorkload(fixture);
assertEqual(workload.pairCount, fixture.arrayDefinition.microphones.length - 1, "reference pair count");
assertEqual(
workload.scanLookupCount,
workload.pointCount * workload.pairCount * workload.bandCount,
"one correlation lookup per point, pair, and band",
);
assert(
workload.scanLookupCount < workload.dasEquivalentComplexMacCount,
"reference-pair lookup count is below equivalent DAS complex MAC count",
);
assert(
workload.maximumLagSamples > 0 &&
workload.maximumLagSamples <= fixture.experiment.stft.fftSize / 2,
"maximum geometric lag is exposed and unaliased",
);
let preflightError;
try {
estimateGccPhatWorkload({ ...fixture, maximumWorkingBytes: 1 });
} catch (error) {
preflightError = error;
}
assert(
preflightError instanceof RangeError &&
preflightError.message.includes("before geometry allocation"),
"unplanned workload is rejected before allocating its distance matrix",
);
let memoryError;
try {
createGccPhatAnalysisPlan({ ...fixture, maximumPlanBytes: 1 });
} catch (error) {
memoryError = error;
}
assert(
memoryError instanceof RangeError && memoryError.message.includes("maximumPlanBytes"),
"plan hard memory bound",
);
const aliasedArray = {
microphones: [
{ id: "far", position: [-1, 0, 0] },
{ id: "near", position: [1, 0, 0] },
],
};
let lagError;
try {
createGccPhatAnalysisPlan({
sampleRate: 48000,
arrayDefinition: aliasedArray,
scene: { speedOfSound: 343 },
experiment: {
stft: { fftSize: 128, hopSize: 64, window: "hann" },
scan: {
z: 0.1,
xMin: 0.9,
xMax: 0.9,
yMin: 0,
yMax: 0,
columns: 1,
rows: 1,
},
frequencyBands: [{ id: "wide", minHz: 500, maxHz: 5000, color: "#65f2bf" }],
beamforming: { algorithm: "gcc-phat", backend: "cpu", normalization: "peak" },
},
});
} catch (error) {
lagError = error;
}
assert(
lagError instanceof RangeError &&
lagError.message.includes("maximum absolute geometric lag") &&
lagError.message.includes("fftSize/2"),
"circular-lag alias guard",
);
});
test("GCC-PHAT corrected lag sign finds a deterministic broadband source and emits score maps", async () => {
const fixture = createBroadbandFixture();
const result = await computeGccPhatMap({
...fixture,
phatFloorRatio: 1e-5,
pointBatchSize: 1024,
stftTransformBatchSize: 1024,
});
const band = result.bands[0];
const peakPosition = band.peak.position;
assertEqual(result.backend, "cpu", "CPU backend");
assertEqual(result.algorithm, "gcc-phat", "algorithm id");
assertEqual(result.referenceMicrophoneId, "a-ref", "deterministic reference metadata");
assertEqual(result.pairCount, 3, "pair metadata");
assertEqual(result.phatFloorRatio, 1e-5, "PHAT floor metadata");
assertNear(result.maximumLagSamples, result.workload.maximumLagSamples, 0, "lag metadata");
assertEqual(band.rawScale, "score", "raw map scale");
assert(
band.rawMap.every((value) => Number.isFinite(value) && value >= 0 && value <= 1),
"pair- and bandwidth-normalized score map is finite and bounded",
);
assert(
distance3(peakPosition, fixture.target) <= 0.15,
"broadband GCC-PHAT peak is within one grid diagonal: " + peakPosition.join(","),
);
const targetIndex = scanIndexFor(
fixture.experiment.scan,
fixture.target[0],
fixture.target[1],
);
const wrongSignIndex = scanIndexFor(
fixture.experiment.scan,
-fixture.target[0],
fixture.target[1],
);
assert(
band.rawMap[targetIndex] > band.rawMap[wrongSignIndex],
"X_m * conj(X_ref) correlation uses the physical arrival-lag sign",
);
assertNear(Math.max(...band.displayMap), 1, 1e-6, "normalized score display");
const expandedFixture = createBroadbandFixture();
expandedFixture.experiment.scan = {
...expandedFixture.experiment.scan,
xMin: -0.8,
xMax: 0.8,
yMin: -0.8,
yMax: 0.8,
columns: 17,
rows: 17,
};
const expanded = await computeGccPhatMap({
...expandedFixture,
phatFloorRatio: 1e-5,
pointBatchSize: 1024,
stftTransformBatchSize: 1024,
});
const expandedTargetIndex = scanIndexFor(
expandedFixture.experiment.scan,
expandedFixture.target[0],
expandedFixture.target[1],
);
assertNear(
expanded.bands[0].rawMap[expandedTargetIndex],
band.rawMap[targetIndex],
1e-6,
"raw score at a fixed hypothesis is independent of scan extent",
);
});
test("GCC-PHAT compute rejects an oversized unplanned working set before geometry", async () => {
const fixture = createBroadbandFixture();
let error;
try {
await computeGccPhatMap({ ...fixture, maximumWorkingBytes: 1 });
} catch (candidate) {
error = candidate;
}
assert(
error instanceof RangeError && error.message.includes("before geometry allocation"),
"compute preflight error",
);
});
test("GCC-PHAT analysis plan reuses geometry and FFT workspaces and releases its busy lock", async () => {
const fixture = createBroadbandFixture();
const plan = createGccPhatAnalysisPlan(fixture);
const retainedDistances = plan.distances;
const retainedStftWorkspace = plan.stftWorkspace;
const retainedCorrelationReal = plan.correlationReal;
const computeOptions = {
...fixture,
analysisPlan: plan,
pointBatchSize: 1024,
stftTransformBatchSize: 1024,
};
const first = await computeGccPhatMap(computeOptions);
const second = await computeGccPhatMap(computeOptions);
assertEqual(plan.distances, retainedDistances, "distance matrix identity is retained");
assertEqual(plan.stftWorkspace, retainedStftWorkspace, "STFT workspace identity is retained");
assertEqual(plan.correlationReal, retainedCorrelationReal, "correlation workspace identity is retained");
assertFloat32Exact(second.bands[0].rawMap, first.bands[0].rawMap, "reused plan raw map");
assertEqual(second.analysisCache.analysisAttempts, 2, "analysis attempts");
assertEqual(second.analysisCache.completedAnalyses, 2, "completed analyses");
assertEqual(second.analysisCache.distanceMatrixBuilds, 1, "distance matrix built once");
assertEqual(
second.analysisCache.avoidedDistanceEvaluations,
plan.pointCount * plan.microphoneCount,
"second geometry pass avoided",
);
assertEqual(second.analysisCache.stftWorkspaceUses, 2, "STFT workspace reused");
assertEqual(second.analysisCache.correlationWorkspaceUses, 2, "correlation workspace reused");
assertEqual(
second.analysisCache.inverseFftTransforms,
plan.pairCount * plan.bandBins.length * 2,
"one inverse FFT per pair and band per analysis",
);
const controller = new AbortController();
controller.abort();
let abortError;
try {
await computeGccPhatMap({ ...computeOptions, signal: controller.signal });
} catch (error) {
abortError = error;
}
assertEqual(abortError?.name, "AbortError", "AbortError propagated");
assertEqual(plan.busy, false, "plan busy lock released after cancellation");
assertEqual(plan.statistics.completedAnalyses, 2, "canceled analysis is not completed");
});
+215
View File
@@ -0,0 +1,215 @@
import {
computeLocalizationMap,
createLocalizationAnalysisPlan,
estimateLocalizationWorkload,
} from "/packages/acoustics-core/localization.js";
import {
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
} from "/packages/acoustics-core/gcc-phat.js";
import {
DEFAULT_LOCALIZATION_ALGORITHM,
LOCALIZATION_ALGORITHM_IDS,
localizationAlgorithmDefinition,
localizationAlgorithmSupportsBackend,
} from "/packages/acoustics-core/localization-algorithms.js";
import {
StreamingDasLocalizer,
StreamingLocalizer,
createLocalizationPlan,
detectBandPeaks,
localizeMultichannel,
} from "/packages/acoustics-core/realtime-localizer.js";
import {
computeDasWithBackend,
computeLocalizationWithBackend,
} from "/packages/webgpu-kernels/compute.js";
function createFixture(algorithm = "das") {
const sampleRate = 4096;
const arrayDefinition = {
id: "algorithm-fixture",
microphones: [
{ id: "nw", position: [-0.1, 0.1, 0] },
{ id: "ne", position: [0.1, 0.1, 0] },
{ id: "sw", position: [-0.1, -0.1, 0] },
{ id: "se", position: [0.1, -0.1, 0] },
],
};
let state = 0x9e3779b9;
const emitted = Float32Array.from({ length: 128 }, () => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return ((state >>> 0) / 0xffff_ffff) * 2 - 1;
});
const channels = arrayDefinition.microphones.map((_, microphoneIndex) =>
Float32Array.from(
{ length: emitted.length },
(_, sampleIndex) => emitted[Math.max(0, sampleIndex - microphoneIndex)],
)
);
const scan = {
z: 1,
xMin: -0.2,
xMax: 0.2,
yMin: -0.2,
yMax: 0.2,
columns: 3,
rows: 3,
};
const frequencyBands = [
{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" },
];
const experiment = {
stft: { fftSize: 64, hopSize: 32, window: "hann" },
scan,
frequencyBands,
beamforming: {
algorithm,
backend: "cpu",
normalization: "peak",
compensateFrequencyResponse: true,
},
};
return {
channels,
sampleRate,
arrayDefinition,
scene: { speedOfSound: 343 },
experiment,
};
}
test("localization algorithm registry exposes explicit scale and backend semantics", () => {
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
assertEqual(LOCALIZATION_ALGORITHM_IDS.join(","), "das,das-phat,gcc-phat", "registered ids");
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS power scale");
assertEqual(localizationAlgorithmDefinition("das-phat").rawScale, "score", "DAS-PHAT score scale");
assertEqual(localizationAlgorithmDefinition("gcc-phat").rawScale, "score", "GCC-PHAT score scale");
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU support");
assert(!localizationAlgorithmSupportsBackend("gcc-phat", "webgpu"), "GCC-PHAT CPU-only support");
});
test("generic CPU dispatcher runs all algorithms with explicit raw-map scales", async () => {
for (const algorithm of LOCALIZATION_ALGORITHM_IDS) {
const fixture = createFixture(algorithm);
const analysisPlan = createLocalizationAnalysisPlan(fixture);
const workload = estimateLocalizationWorkload({ ...fixture, analysisPlan });
const result = await computeLocalizationMap({
...fixture,
analysisPlan,
pointBatchSize: 1024,
stftTransformBatchSize: 1024,
});
const definition = localizationAlgorithmDefinition(algorithm);
assertEqual(result.algorithm, algorithm, `${algorithm} result id`);
assertEqual(result.backend, "cpu", `${algorithm} backend`);
assertEqual(result.bands[0].rawScale, definition.rawScale, `${algorithm} raw scale`);
assertEqual(result.bands[0].rawMap.length, 9, `${algorithm} map shape`);
assertEqual(result.bands[0].peak.rawScale, definition.rawScale, `${algorithm} peak scale`);
assertEqual(
"rawPower" in result.bands[0].peak,
definition.rawScale === "power",
`${algorithm} peak power field`,
);
assert(
result.bands[0].rawMap.every((value) => Number.isFinite(value) && value >= 0),
`${algorithm} finite nonnegative map`,
);
assert(workload.pointCount === 9 && workload.microphoneCount === 4, `${algorithm} workload`);
}
});
test("backend dispatcher keeps GCC-PHAT on CPU and rejects an explicit GPU request", async () => {
const fixture = createFixture("gcc-phat");
const calls = [];
const result = await computeLocalizationWithBackend(fixture, {
computeGpu: async () => {
calls.push("gpu");
throw new Error("GCC-PHAT must not call WebGPU");
},
computeGccCpu: async () => {
calls.push("gcc-cpu");
return { backend: "cpu", algorithm: "gcc-phat", bands: [] };
},
});
assertEqual(calls.join(","), "gcc-cpu", "GCC auto routing");
assertEqual(result.algorithm, "gcc-phat", "GCC result");
fixture.experiment.beamforming.backend = "webgpu";
let backendError;
try {
await computeLocalizationWithBackend(fixture);
} catch (error) {
backendError = error;
}
assert(backendError instanceof TypeError, "explicit GCC WebGPU is rejected");
let legacyError;
try {
await computeDasWithBackend(fixture);
} catch (error) {
legacyError = error;
}
assert(legacyError instanceof RangeError, "legacy DAS entry rejects GCC");
});
test("portable and streaming API accept an algorithm choice without Scene synthesis", async () => {
const fixture = createFixture("gcc-phat");
const config = {
sampleRate: fixture.sampleRate,
array: fixture.arrayDefinition,
scan: fixture.experiment.scan,
bands: fixture.experiment.frequencyBands,
speedOfSound: fixture.scene.speedOfSound,
stft: fixture.experiment.stft,
algorithm: "gcc-phat",
phatFloorRatio: 1e-5,
normalization: "peak",
computeOptions: { pointBatchSize: 1024, stftTransformBatchSize: 1024 },
};
const plan = createLocalizationPlan(config);
const defaultBatchPlan = createLocalizationPlan({ ...config, computeOptions: undefined });
const localized = await localizeMultichannel({ plan, channels: fixture.channels });
assertEqual(plan.algorithm, "gcc-phat", "portable plan algorithm");
assertEqual(plan.computeOptions.phatFloorRatio, 1e-5, "portable PHAT floor forwarding");
assertEqual(plan.analysisPlan, plan.gccPhatPlan, "portable GCC plan alias");
assertEqual(plan.computeOptions.pointBatchSize, 1024, "explicit portable point batch override");
assertEqual(
defaultBatchPlan.computeOptions.pointBatchSize,
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
"GCC lookup-oriented default batch",
);
assertEqual(
defaultBatchPlan.computeOptions.crossSpectrumBatchSize,
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
"GCC cross-spectrum cancellation batch",
);
assertEqual(localized.algorithm, "gcc-phat", "portable result algorithm");
assertEqual(localized.phatFloorRatio, 1e-5, "portable PHAT floor reaches GCC core");
assertEqual(localized.bands[0].rawScale, "score", "portable score map");
assertEqual(StreamingLocalizer, StreamingDasLocalizer, "streaming compatibility alias");
const stream = new StreamingLocalizer({
...config,
analysisWindowSamples: 128,
analysisHopSamples: 128,
maximumBufferedSamples: 256,
});
const streamed = await stream.push(fixture.channels);
assertEqual(streamed.length, 1, "one GCC streaming result");
assertEqual(streamed[0].algorithm, "gcc-phat", "streaming result algorithm");
assertEqual(stream.getState().algorithm, "gcc-phat", "streaming state algorithm");
assertEqual(stream.plan.gccPhatPlan.busy, false, "streaming GCC plan lock released");
const scorePeaks = detectBandPeaks(
Float32Array.of(0, 2, 0, 1),
{ z: 1, xMin: 0, xMax: 1, yMin: 0, yMax: 1, columns: 2, rows: 2 },
{ rawScale: "score", minimumPeakDistanceM: 0 },
);
assertEqual(scorePeaks[0].rawScale, "score", "portable peak scale");
assertEqual(scorePeaks[0].rawScore, scorePeaks[0].rawValue, "portable score alias");
assert(!("rawPower" in scorePeaks[0]), "score is not mislabeled as acoustic power");
});
+41
View File
@@ -0,0 +1,41 @@
import {
clampSplitPaneSize,
splitPaneSizeFromPointer,
} from "/packages/ui/split-pane.js";
test("분할 패널 폭이 설정 범위와 작업 영역 최소 폭을 지킨다", () => {
const metrics = {
containerSize: 1400,
separatorSize: 12,
minSize: 360,
maxSize: 720,
minMainSize: 560,
};
assertEqual(clampSplitPaneSize(620, metrics), 620, "requested width");
assertEqual(clampSplitPaneSize(900, metrics), 720, "hard maximum");
assertEqual(clampSplitPaneSize(200, metrics), 360, "configured minimum");
assertEqual(
clampSplitPaneSize(720, { ...metrics, containerSize: 981 }),
409,
"workspace-aware maximum",
);
});
test("분리선 pointer 좌표가 시작·끝 패널 폭으로 변환된다", () => {
const common = {
pointerX: 506,
containerLeft: 100,
containerRight: 1500,
separatorSize: 12,
};
assertEqual(
splitPaneSizeFromPointer({ ...common, panelSide: "start" }),
400,
"start panel width",
);
assertEqual(
splitPaneSizeFromPointer({ ...common, panelSide: "end" }),
988,
"end panel width",
);
});
+53
View File
@@ -12,6 +12,48 @@ import {
} from "/packages/webgpu-kernels/das.js";
import { computeDasWithBackend } from "/packages/webgpu-kernels/compute.js";
test("WebGPU DAS-PHAT packing phase-normalizes above a relative floor", () => {
const stft = {
frameCount: 1,
microphoneCount: 3,
spectra: new Map([[
8,
{
real: Float32Array.of(3, 0, 1e-8),
imaginary: Float32Array.of(4, 2, 0),
},
]]),
};
const microphones = [
{
frequencyResponse: [
{ frequencyHz: 0, gainDb: 60, phaseDeg: 90 },
{ frequencyHz: 4_000, gainDb: 60, phaseDeg: 90 },
],
},
{
frequencyResponse: [
{ frequencyHz: 0, gainDb: -60, phaseDeg: 0 },
{ frequencyHz: 4_000, gainDb: -60, phaseDeg: 0 },
],
},
{},
];
const packed = packStftBatch(stft, [8], {
algorithm: "das-phat",
microphones,
sampleRate: 8_000,
fftSize: 256,
});
assertNear(packed[0], 0.8, 1e-6, "response-phase corrected real");
assertNear(packed[1], -0.6, 1e-6, "response-phase corrected imaginary");
assertNear(packed[2], 0, 1e-7, "second microphone real");
assertNear(packed[3], 1, 1e-7, "response gain is ignored");
assertEqual(packed[4], 0, "below-floor real is suppressed");
assertEqual(packed[5], 0, "below-floor imaginary is suppressed");
});
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
@@ -53,6 +95,17 @@ test("WebGPU DAS uniform과 microphone calibration을 WGSL layout으로 packing
assertNear(calibration[3], Math.PI / 2, 1e-6, "+90 degree phase");
assertNear(calibration[4], 2, 1e-6, "-6 dB inverse gain");
assertNear(calibration[5], -Math.PI / 4, 1e-6, "-45 degree phase");
const phaseOnlyCalibration = packMicrophoneCalibration([
{ sensitivityDb: 0, phaseOffsetDeg: 0 },
{ sensitivityDb: 6.020599913, phaseOffsetDeg: 90 },
{ sensitivityDb: -6.020599913, phaseOffsetDeg: -45 },
], { includeAmplitude: false });
assertNear(phaseOnlyCalibration[0], 1, 1e-7, "phase-only unity gain 0");
assertNear(phaseOnlyCalibration[2], 1, 1e-7, "phase-only unity gain 1");
assertNear(phaseOnlyCalibration[4], 1, 1e-7, "phase-only unity gain 2");
assertNear(phaseOnlyCalibration[3], Math.PI / 2, 1e-6, "phase-only preserves +90 phase");
assertNear(phaseOnlyCalibration[5], -Math.PI / 4, 1e-6, "phase-only preserves -45 phase");
});
test("STFT batch를 bin-frame-microphone-complex 순서로 packing한다", () => {