Compare commits
14 Commits
85ad056a73
...
122b6cf7be
| Author | SHA1 | Date | |
|---|---|---|---|
| 122b6cf7be | |||
| 3714146df9 | |||
| 0f9b4f8942 | |||
| acf68f4931 | |||
| 33618be129 | |||
| 947237dc7c | |||
| 1be1dbf3a4 | |||
| d41d06d04b | |||
| a48791075e | |||
| fe0f70ab11 | |||
| e8dac792d9 | |||
| 9b9244be85 | |||
| d58f673402 | |||
| 7fe846fbf9 |
@@ -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
|
||||
|
||||
@@ -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`를
|
||||
측정해 판정해야 합니다.
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)를 참고하세요.
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -9,6 +9,11 @@
|
||||
<http://127.0.0.1:4173/apps/dataset-builder/>을 엽니다. `index.html`을 직접 열면 module과 Worker가
|
||||
동작하지 않습니다.
|
||||
|
||||
데스크톱에서 canvas와 오른쪽 설정 패널 사이의 선을 드래그하면 설정 폭을 바꿀 수 있습니다. 선에
|
||||
초점을 둔 뒤 방향키로도 조절할 수 있고, `Enter` 또는 더블클릭하면 기본 폭으로 돌아갑니다.
|
||||
폭은 이 브라우저에 저장되며, 1100px 이하에서는 설정 패널이 작업 영역 아래로 자동 배치됩니다.
|
||||
설정 안의 좌표·시간·경로 입력은 viewport가 아니라 실제 패널 폭에 맞춰 열 수를 바꿉니다.
|
||||
|
||||
## 기본 사용법
|
||||
|
||||
1. **배경 이미지**를 선택하거나 이미지 없이 grid를 사용합니다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 오차까지 모사되지는
|
||||
않는다.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 & 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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 재사용을 검사합니다.
|
||||
|
||||
+135
-27
@@ -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(
|
||||
export function createBinCalibration(options) {
|
||||
const {
|
||||
arrayDefinition,
|
||||
bin,
|
||||
sampleRate,
|
||||
fftSize,
|
||||
compensateFrequencyResponse,
|
||||
maximumCompensationDb,
|
||||
) {
|
||||
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,
|
||||
sampleRate: plan.sampleRate,
|
||||
fftSize: plan.experiment.stft.fftSize,
|
||||
compensateFrequencyResponse:
|
||||
plan.experiment.beamforming.compensateFrequencyResponse !== false,
|
||||
plan.experiment.beamforming.maxCompensationDb,
|
||||
);
|
||||
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];
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 + ".");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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여야 합니다.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 저장은
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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가 있을 때 수행합니다.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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)}`);
|
||||
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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한다", () => {
|
||||
|
||||
Reference in New Issue
Block a user