Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6804eca6ed | |||
| b68cd87e3c | |||
| 041e0c3665 | |||
| 974d33a137 | |||
| 9cc34df59b | |||
| 764cf28cf8 | |||
| 51e346a477 | |||
| 3ebc0f84e0 | |||
| 11fee76280 | |||
| 044be3cf92 | |||
| de0053eae3 | |||
| e048ccbadc | |||
| 447f77dfa7 | |||
| 62971e07ec |
@@ -1,7 +1,7 @@
|
|||||||
# micarrsim
|
# micarrsim
|
||||||
|
|
||||||
192채널 MEMS 마이크 배열의 배치와 자유음장·저비용 실내 근사에서 DAS·DAS-PHAT·GCC-PHAT
|
192채널 MEMS 마이크 배열의 배치와 자유음장·저비용 실내 근사에서 DAS·DAS-PHAT·GCC-PHAT·MUSIC
|
||||||
위치 추정을 비교하는 로컬 브라우저
|
위치 추정을 비교하고, 표준 CFAR와 spectral flux로 ADC 대역의 원시 활동 후보를 확인하는 로컬 브라우저
|
||||||
시뮬레이터입니다. Array Builder에서 마이크 배치를 설계하고, Dataset Builder에서 녹음 파일·합성
|
시뮬레이터입니다. Array Builder에서 마이크 배치를 설계하고, Dataset Builder에서 녹음 파일·합성
|
||||||
신호와 이동 경로를 포함한 재현 가능한 음향 장면을 만든 뒤, Acoustic Camera에서 CPU 또는
|
신호와 이동 경로를 포함한 재현 가능한 음향 장면을 만든 뒤, Acoustic Camera에서 CPU 또는
|
||||||
WebGPU로 배열 응답, acoustic map과 위치 검출 지표를 계산하거나 이동 장면을 시간축으로 재생합니다.
|
WebGPU로 배열 응답, acoustic map과 위치 검출 지표를 계산하거나 이동 장면을 시간축으로 재생합니다.
|
||||||
@@ -53,9 +53,33 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
같은 입력에서 **위치 추정 알고리즘**만 DAS-PHAT으로 바꿔 다시 실행하면 아래 비교 기록에 두 결과가
|
같은 입력에서 **위치 추정 알고리즘**만 DAS-PHAT으로 바꿔 다시 실행하면 아래 비교 기록에 두 결과가
|
||||||
나란히 남습니다. GCC-PHAT은 광대역 입력에서 비교하세요.
|
나란히 남습니다. GCC-PHAT은 광대역 입력에서 비교하세요.
|
||||||
|
|
||||||
|
MUSIC을 바로 확인하려면 현재 장면 제목 옆 **MUSIC 빠른 예제**를 누른 뒤
|
||||||
|
**▶ Snapshot 실행**만 누르면 됩니다. 12채널·1.125 kHz 단일-bin·Fixed K=1 설정이 함께 열립니다.
|
||||||
|
|
||||||
실제 완료 시간은 브라우저와 장치에 따라 다릅니다. CPU fallback에서는 WebGPU보다 오래 걸릴 수
|
실제 완료 시간은 브라우저와 장치에 따라 다릅니다. CPU fallback에서는 WebGPU보다 오래 걸릴 수
|
||||||
있습니다.
|
있습니다.
|
||||||
|
|
||||||
|
### 분석 주파수를 모를 때
|
||||||
|
|
||||||
|
1. Acoustic Camera의 분석 대역 아래에서 **전 대역 활동 검사**를 펼칩니다.
|
||||||
|
2. 물리 마이크 한 채널과 **OS-CFAR**, **CA-CFAR** 또는
|
||||||
|
**Dixon spectral flux**를 고릅니다.
|
||||||
|
3. **전 대역 검사**를 누르고 frame slider에서 원시 ADC-bin 통계 또는 onset novelty를 봅니다.
|
||||||
|
4. CFAR candidate의 **아날로그 center 가설**을 직접 입력합니다. 기본값은 관측 ADC
|
||||||
|
center일 뿐 원 주파수 정답이 아닙니다.
|
||||||
|
5. **이 가설을 대역으로 추가**한 뒤 위치 추정 알고리즘을 골라 Snapshot을 실행합니다.
|
||||||
|
6. 감사 가능한 모든 frame/bin 값은 **Activity JSON**으로 저장합니다.
|
||||||
|
|
||||||
|
예를 들어 48 kHz ADC의 1,125 Hz 관측에는 1,125 Hz뿐 아니라 46,875 Hz,
|
||||||
|
49,125 Hz 같은 서로 다른 아날로그 가설이 대응합니다. Nyquist 초과 값도 막지 않지만,
|
||||||
|
Camera가 Scene truth로 alias order를 고르지는 않습니다. 각 가설을 별도 band로 넣어 배열의
|
||||||
|
spatial alias·grating lobe·실패 양상을 그대로 비교하세요.
|
||||||
|
|
||||||
|
CFAR의 Pfa는 IID exponential 배경 모델의 **tested CUT 하나당 명목값**이며 위치 확률이나
|
||||||
|
실측 confidence가 아닙니다. Spectral flux는 지속 신호 검출이 아니라 onset novelty입니다.
|
||||||
|
세 검출 경로와 위치 map을 자동 합의·평균·maximum fusion하지 않습니다. 수식, 미검사 edge와
|
||||||
|
causal latency는 [전 대역 활동 검출 모델](docs/activity-detection.md)에 설명되어 있습니다.
|
||||||
|
|
||||||
### 화면 폭 조절
|
### 화면 폭 조절
|
||||||
|
|
||||||
데스크톱에서는 Array Builder와 Acoustic Camera의 왼쪽 설정 패널, Dataset Builder의 오른쪽 설정
|
데스크톱에서는 Array Builder와 Acoustic Camera의 왼쪽 설정 패널, Dataset Builder의 오른쪽 설정
|
||||||
@@ -68,12 +92,14 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
|
|
||||||
### 이동 음원 바로 보기
|
### 이동 음원 바로 보기
|
||||||
|
|
||||||
1. Acoustic Camera에서 **이동 예제 열기**를 누릅니다.
|
1. Acoustic Camera에서 **이동 예제 전체 열기**를 누릅니다.
|
||||||
2. Moving 카드의 **Map 준비**를 눌러 2 Hz acoustic map을 계산합니다.
|
2. Moving 카드의 **Map 준비**를 눌러 2 Hz acoustic map을 계산합니다.
|
||||||
3. **재생**을 누르거나 시간 slider를 움직여 음원과 heatmap의 이동을 확인합니다.
|
3. **재생**을 누르거나 시간 slider를 움직여 음원과 heatmap의 이동을 확인합니다.
|
||||||
|
|
||||||
이 기능은 trajectory가 있는 합성 Scene의 준정적 시간창 재생입니다. 실제 192채널 마이크 장치에서
|
이 기능은 trajectory가 있는 합성 Scene의 준정적 시간창 재생입니다. 실제 192채널 마이크 장치에서
|
||||||
들어오는 스트림을 캡처하는 기능은 아닙니다.
|
들어오는 스트림을 캡처하는 기능은 아닙니다.
|
||||||
|
이 버튼은 Scene뿐 아니라 Array와 Experiment, 분석 대역까지 예제 전체로 교체합니다. 여러 대역이나
|
||||||
|
저장하지 않은 설정이 있으면 교체 전에 확인합니다.
|
||||||
|
|
||||||
## 내 장면으로 전체 흐름 실행하기
|
## 내 장면으로 전체 흐름 실행하기
|
||||||
|
|
||||||
@@ -85,7 +111,9 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
seed로 같은 좌표를 재현하며, 최소 간격과 경계 여백도 지정할 수 있습니다.
|
seed로 같은 좌표를 재현하며, 최소 간격과 경계 여백도 지정할 수 있습니다.
|
||||||
4. **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로 수정합니다.
|
4. **추가** 도구로 canvas에 마이크를 놓고, 선택한 점을 드래그하거나 x/y/z 입력으로 수정합니다.
|
||||||
목록에서는 삭제와 채널 순서 변경도 할 수 있습니다.
|
목록에서는 삭제와 채널 순서 변경도 할 수 있습니다.
|
||||||
5. 상태가 **Array JSON 저장 가능**이면 **Array JSON 저장**을 누릅니다.
|
5. canvas 아래 **수신 빔 패턴**에서 아날로그 주파수·360° 절단면·조향각을 바꿔 polar 응답과
|
||||||
|
조건부 −3.0103 dB 폭, sampled peak 동률을 확인합니다.
|
||||||
|
6. 상태가 **Array JSON 저장 가능**이면 **Array JSON 저장**을 누릅니다.
|
||||||
|
|
||||||
불러온 Array의 `sensitivityDb`, `phaseOffsetDeg`, `frequencyResponse` 보정 정보는 마이크 위치·ID나
|
불러온 Array의 `sensitivityDb`, `phaseOffsetDeg`, `frequencyResponse` 보정 정보는 마이크 위치·ID나
|
||||||
채널 순서를 편집해도 보존됩니다. 프리셋 생성은 마이크 목록 전체를 새로 만드는 작업이므로 기존
|
채널 순서를 편집해도 보존됩니다. 프리셋 생성은 마이크 목록 전체를 새로 만드는 작업이므로 기존
|
||||||
@@ -93,6 +121,7 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
|
|
||||||
생성 경계 형상, 프리셋 종류, random seed와 최소 간격은 편집을 돕는 설정이며 표준 Array JSON에
|
생성 경계 형상, 프리셋 종류, random seed와 최소 간격은 편집을 돕는 설정이며 표준 Array JSON에
|
||||||
저장되지 않습니다. 저장되는 것은 aperture 너비·높이와 최종 마이크 좌표·순서 및 채널별 보정값입니다.
|
저장되지 않습니다. 저장되는 것은 aperture 너비·높이와 최종 마이크 좌표·순서 및 채널별 보정값입니다.
|
||||||
|
빔 패턴 주파수·절단면·조향각도 preview 설정이므로 Array JSON과 undo/dirty 상태에 들어가지 않습니다.
|
||||||
자세한 사용법은 [`apps/array-builder/README.md`](apps/array-builder/README.md)를 참고하세요.
|
자세한 사용법은 [`apps/array-builder/README.md`](apps/array-builder/README.md)를 참고하세요.
|
||||||
|
|
||||||
### 2. Dataset Builder에서 Scene 만들기
|
### 2. Dataset Builder에서 Scene 만들기
|
||||||
@@ -111,7 +140,9 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
### 3. Acoustic Camera에서 실행하기
|
### 3. Acoustic Camera에서 실행하기
|
||||||
|
|
||||||
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
|
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
|
||||||
2. 다음 순서로 입력합니다: **Scene → Array → Experiment → Assets**.
|
2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment는 어느
|
||||||
|
시점에 열어도 모든 분석 대역과 원래의 `sceneId`/`arrayId` provenance label을 유지하며,
|
||||||
|
실행에는 현재 열린 Scene·Array가 사용됩니다.
|
||||||
3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다.
|
3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다.
|
||||||
4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다.
|
4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다.
|
||||||
5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다.
|
5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다.
|
||||||
@@ -120,8 +151,17 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다.
|
7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다.
|
||||||
8. 완료 후 **Result JSON**을 저장합니다.
|
8. 완료 후 **Result JSON**을 저장합니다.
|
||||||
|
|
||||||
Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수
|
Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사하고 가져온 `sceneId`,
|
||||||
있습니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다.
|
`arrayId`, `frequencyBands`와 나머지 분석 설정을 그대로 보존합니다. 두 ID가 현재 입력과
|
||||||
|
달라도 거부하지 않으며, Result의 ID와 hash에는 실제 실행에 사용한 Scene·Array를 기록합니다. Nyquist 위 tone과
|
||||||
|
band는 오류가 아니라 ideal ADC alias 실험으로 허용되며, 원 주파수와 실제 ADC FFT range가 UI와
|
||||||
|
Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제 오류는 그대로 거부됩니다. Scene을
|
||||||
|
다시 불러오면 이전 asset 연결은 해제됩니다.
|
||||||
|
|
||||||
|
Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에
|
||||||
|
유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와
|
||||||
|
Scene 또는 Array를 선택하세요. 같은 Camera 페이지에서 Scene을 교체하면 scan 영역과
|
||||||
|
plane을 새 Scene에 맞추되 Experiment의 provenance ID와 기존 `frequencyBands`는 보존합니다.
|
||||||
|
|
||||||
Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
|
Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
|
||||||
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
|
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
|
||||||
@@ -145,6 +185,7 @@ SHA-256 manifest를 저장합니다. 브라우저는 새 페이지나 세션에
|
|||||||
| DAS | 상대 acoustic power | CPU, WebGPU | 가장 단순한 기준선. 채널 gain과 알려진 마이크 응답을 보정합니다. |
|
| DAS | 상대 acoustic power | CPU, WebGPU | 가장 단순한 기준선. 채널 gain과 알려진 마이크 응답을 보정합니다. |
|
||||||
| DAS-PHAT | 무차원 score | CPU, WebGPU | 마이크별 진폭을 버리고 위상 정합을 비교해 gain·반사에 더 강건할 수 있습니다. |
|
| DAS-PHAT | 무차원 score | CPU, WebGPU | 마이크별 진폭을 버리고 위상 정합을 비교해 gain·반사에 더 강건할 수 있습니다. |
|
||||||
| GCC-PHAT | 무차원 score | CPU | 한 reference와 나머지 마이크의 TDOA 상관을 사용합니다. 광대역이 필요하며 협대역에서는 주기적 모호성이 큽니다. |
|
| GCC-PHAT | 무차원 score | CPU | 한 reference와 나머지 마이크의 TDOA 상관을 사용합니다. 광대역이 필요하며 협대역에서는 주기적 모호성이 큽니다. |
|
||||||
|
| MUSIC | noise-subspace projection `q` | CPU | [Schmidt narrowband MUSIC](https://doi.org/10.1109/TAP.1986.1143830)의 근거리 구면파 구현입니다. band마다 아날로그 FFT-grid carrier 가설 하나와 source 수를 지정해야 합니다. |
|
||||||
|
|
||||||
- **Auto (GPU→CPU)**: 권장값입니다. WebGPU adapter나 device를 사용할 수 없거나 지원되는 장치
|
- **Auto (GPU→CPU)**: 권장값입니다. WebGPU adapter나 device를 사용할 수 없거나 지원되는 장치
|
||||||
한계를 넘으면 CPU로 계속합니다.
|
한계를 넘으면 CPU로 계속합니다.
|
||||||
@@ -152,9 +193,21 @@ SHA-256 manifest를 저장합니다. 브라우저는 새 페이지나 세션에
|
|||||||
오류를 표시합니다.
|
오류를 표시합니다.
|
||||||
- **CPU 강제**: 비교와 호환성 확인을 위해 선택한 알고리즘의 CPU 구현을 사용합니다.
|
- **CPU 강제**: 비교와 호환성 확인을 위해 선택한 알고리즘의 CPU 구현을 사용합니다.
|
||||||
|
|
||||||
GCC-PHAT은 현재 CPU 전용이므로 선택하면 Auto 또는 CPU만 사용할 수 있습니다. Camera는 각 band가
|
GCC-PHAT과 MUSIC은 현재 CPU 전용이므로 선택하면 Auto 또는 CPU만 사용할 수 있습니다. Camera는 각 band가
|
||||||
8개 미만의 FFT bin을 포함하면 협대역 경고를 표시합니다.
|
8개 미만의 FFT bin을 포함하면 협대역 경고를 표시합니다.
|
||||||
|
|
||||||
|
MUSIC의 원시 값은
|
||||||
|
`q(r)=Σ|E_nᴴa(r)|²/(a(r)ᴴa(r))`이며 작은 값이 더 좋은 위치 가설입니다. Camera가 표시·peak
|
||||||
|
검출에 쓰는 `P/Pmax`는 `P=1/q`를 scan 안에서 다시 0–1로 나타낸 값일 뿐, `q`와 `P/Pmax`
|
||||||
|
모두 위치 확률·posterior·confidence 또는 신뢰구간이 아닙니다. `q=0`인 이상적 pole, 동일
|
||||||
|
최솟값 tie와 완전히 평평한 map은 큰 유한값으로 조작하지 않고 별도로 처리합니다.
|
||||||
|
|
||||||
|
`musicSourceCountMethod`은 사용자가 지정한 `fixed` K 또는
|
||||||
|
[Wax–Kailath AIC/MDL](https://doi.org/10.1109/TASSP.1985.1164557)입니다. AIC/MDL은
|
||||||
|
비중첩 frame, `T>M`, 양의 noise eigenvalue 외에도 IID complex Gaussian snapshot,
|
||||||
|
spatially white noise와 창 동안 일정한 source 수를 **가정**합니다. 비중첩은 통계적 독립성의
|
||||||
|
증명이 아니며 criterion score 역시 확률이나 confidence가 아닙니다.
|
||||||
|
|
||||||
WebGPU 경로도 신호 합성과 STFT 일부는 CPU에서 수행합니다. grid의 point 수는 50×50을 1로 보면
|
WebGPU 경로도 신호 합성과 STFT 일부는 CPU에서 수행합니다. grid의 point 수는 50×50을 1로 보면
|
||||||
100×100은 4배, 200×200은 16배입니다. 처음에는 50×50을 사용하고 해상도가 필요할 때만 높이세요.
|
100×100은 4배, 200×200은 16배입니다. 처음에는 50×50을 사용하고 해상도가 필요할 때만 높이세요.
|
||||||
실행 시간은 microphone 수, scene 길이, FFT, 대역 폭과 장치에 함께 좌우됩니다.
|
실행 시간은 microphone 수, scene 길이, FFT, 대역 폭과 장치에 함께 좌우됩니다.
|
||||||
@@ -205,6 +258,21 @@ scan 범위나 마이크 수 때문에 값이 단순히 커지는 현상은 막
|
|||||||
main lobe보다 가깝거나 gain 차이가 큰 음원은 conventional DAS의 물리·알고리즘 한계 때문에
|
main lobe보다 가깝거나 gain 차이가 큰 음원은 conventional DAS의 물리·알고리즘 한계 때문에
|
||||||
threshold만으로 분리되지 않을 수 있습니다.
|
threshold만으로 분리되지 않을 수 있습니다.
|
||||||
|
|
||||||
|
MUSIC band는 FFT spacing에 맞는 사용자가 지정한 아날로그 carrier 가설을 정확히 하나만 포함해야
|
||||||
|
합니다. Nyquist 위 가설도 허용되며 signed ADC alias가 음수이면 positive-bin coefficient,
|
||||||
|
마이크 calibration과 전체 complex 근거리 manifold를 켤레 방향으로 사용합니다. 공간 위상의
|
||||||
|
크기는 접힌 ADC 주파수가 아니라 analog carrier `f_a`이고 alias 부호는 orientation만 정합니다. 이는 Scene의
|
||||||
|
실제 tone을 조회해 정답 alias order를 고르는 기능이 아닙니다. 가설은 Experiment band에서만
|
||||||
|
오고 Scene truth는 위치추정에 사용되지 않습니다. 같은 ADC bin의 다른 alias order는 별도 band로
|
||||||
|
직접 만들어 비교해야 하며 자동 선택·평균·maximum fusion은 없습니다. DC와 정확한 Nyquist bin은
|
||||||
|
실수 sampling에서 quadrature가 퇴화하므로 거부됩니다.
|
||||||
|
|
||||||
|
표준 narrowband MUSIC은 coherent한 같은-frequency source에서 covariance rank가 줄어드는 현상을
|
||||||
|
고치지 않습니다. 현재 구현에는 diagonal loading, spatial smoothing, 임의 wideband/multiband
|
||||||
|
fusion이 없고, [Wax–Shan–Kailath broadband eigenstructure 방법](https://doi.org/10.1109/TASSP.1984.1164400)도
|
||||||
|
구현하지 않았습니다. grating lobe와 동일 최솟값은 배열 설계 결과로 그대로 남고, signal/noise
|
||||||
|
subspace가 수치적으로 식별되지 않으면 `MUSIC_UNAVAILABLE`로 실패합니다.
|
||||||
|
|
||||||
## 이동 음원 라이브의 동작
|
## 이동 음원 라이브의 동작
|
||||||
|
|
||||||
Builder의 trajectory는 Scene 시작 기준 절대 시간의 keyframe 사이를 직선 보간합니다. Camera는
|
Builder의 trajectory는 Scene 시작 기준 절대 시간의 keyframe 사이를 직선 보간합니다. Camera는
|
||||||
@@ -228,20 +296,26 @@ channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부
|
|||||||
|
|
||||||
## Result JSON
|
## Result JSON
|
||||||
|
|
||||||
Result JSON은 계약 버전 `1.0.0`의 `micarrsim.result` 문서입니다. 주요 내용은 다음과 같습니다.
|
Result JSON은 계약 버전 `1.1.0`의 `micarrsim.result` 문서입니다. Array·Scene·Experiment는
|
||||||
|
`1.0.0`을 유지합니다. 주요 내용은 다음과 같습니다.
|
||||||
|
|
||||||
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `algorithm`·`backend`와 생성 시각
|
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `algorithm`·`backend`와 생성 시각. 이 ID들은
|
||||||
|
provenance label이며 물리 호환성 판정값이 아닙니다. Camera는 가져온 Experiment를 현재
|
||||||
|
Scene·Array에 연결한 뒤 Result에 실제 실행 ID와 hash를 기록합니다.
|
||||||
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
|
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
|
||||||
- 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats`
|
- 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats`
|
||||||
|
- 원 tone→signed ADC alias, DC/Nyquist 퇴화와 anti-alias filter 부재를 담은 필수 sampling provenance
|
||||||
- 전체·신호 생성·위치 추정 시간인 `timingMs.localization`과 기존 reader용 `beamforming` alias
|
- 전체·신호 생성·위치 추정 시간인 `timingMs.localization`과 기존 reader용 `beamforming` alias
|
||||||
- 주파수 대역별 power 또는 score `rawMap`, 표시용 `displayMap`, 검출 `peaks`
|
- 주파수 대역별 power 또는 score `rawMap`, 표시용 `displayMap`, 검출 `peaks`
|
||||||
|
- band별 Nyquist zone fold, FFT bin-center 선택, 실제 localization model과 동일 ADC band 목록
|
||||||
- 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기
|
- 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기
|
||||||
위한 평가 파라미터와 `metrics`
|
위한 평가 파라미터와 `metrics`
|
||||||
- peak별 half-power 공분산과 RMS ellipse를 담은 `beamSpread` (`calibrated: false`)
|
- peak별 half-power 공분산과 RMS ellipse를 담은 `beamSpread` (`calibrated: false`)
|
||||||
|
|
||||||
평가에서 tone source는 tone 주파수가 포함되는 band의 ground truth로만 사용합니다. Audio asset과
|
평가에서 tone source는 요청 analog interval 또는 접힌 주파수의 nearest ADC bin을 기준으로
|
||||||
noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 ground truth로 취급합니다. 이 가정은
|
사용합니다. Audio asset과 noise
|
||||||
결과를 실제 데이터와 해석할 때 고려해야 합니다.
|
source는 임의 spectrum threshold를 만들지 않고 모든 band의 ground truth로 취급합니다. 정확한
|
||||||
|
정책은 Result `evaluation.truthSelection`에 기록됩니다.
|
||||||
|
|
||||||
## 개인정보와 모델의 한계
|
## 개인정보와 모델의 한계
|
||||||
|
|
||||||
@@ -251,9 +325,10 @@ noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 groun
|
|||||||
|
|
||||||
음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된
|
음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된
|
||||||
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
|
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
|
||||||
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC와 channel clock은 모델링하지
|
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC quantization·nonlinearity와
|
||||||
않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC,
|
channel clock jitter·drift는 모델링하지 않습니다. 위치 추정기는 DAS, DAS-PHAT, CPU GCC-PHAT와
|
||||||
DAMAS와 CLEAN-SC는 구현하지 않습니다.
|
CPU narrowband MUSIC을 제공하지만 full-pair SRP-PHAT, wideband MUSIC, DAMAS와 CLEAN-SC는
|
||||||
|
구현하지 않습니다.
|
||||||
따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
|
따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
|
||||||
판단을 대신하지 않습니다.
|
판단을 대신하지 않습니다.
|
||||||
|
|
||||||
@@ -268,6 +343,9 @@ Camera의 **배열 선택**에서 8개 fixture를 바로 불러와 같은 Scene/
|
|||||||
의도된 예제 특성입니다.
|
의도된 예제 특성입니다.
|
||||||
새 배치를 직접 만들려면 [Array Builder](apps/array-builder/README.md)를 사용하세요. 저장소 fixture의
|
새 배치를 직접 만들려면 [Array Builder](apps/array-builder/README.md)를 사용하세요. 저장소 fixture의
|
||||||
상세한 생성법은 [`assets/array-geometries/README.md`](assets/array-geometries/README.md)를 참고하세요.
|
상세한 생성법은 [`assets/array-geometries/README.md`](assets/array-geometries/README.md)를 참고하세요.
|
||||||
|
Array Builder는 현재 배치의 geometry-only conventional receive array factor를 XZ·YZ·XY 360°
|
||||||
|
polar 단면으로 즉시 표시합니다. 이 계산은 ADC sample rate 없이 원 아날로그 파장을 사용하므로
|
||||||
|
Nyquist 초과 조건도 실행하며, 각도 해상도가 부족하면 raw 선을 남긴 채 HPBW·peak 해석을 보류합니다.
|
||||||
|
|
||||||
## 테스트와 benchmark
|
## 테스트와 benchmark
|
||||||
|
|
||||||
@@ -289,6 +367,8 @@ test.cmd
|
|||||||
|
|
||||||
## 저장 형식과 개발 문서
|
## 저장 형식과 개발 문서
|
||||||
|
|
||||||
|
- [전 대역 활동 검출 모델](docs/activity-detection.md): CA/OS-CFAR, spectral flux와 alias 가설 흐름
|
||||||
|
- [수신 빔 패턴 모델](docs/beam-pattern.md): array factor 수식, polar 단면, HPBW와 해상도 기준
|
||||||
- [`docs/data-contracts.md`](docs/data-contracts.md): Array·Scene·Experiment·Result 계약
|
- [`docs/data-contracts.md`](docs/data-contracts.md): Array·Scene·Experiment·Result 계약
|
||||||
- [`docs/acoustic-model.md`](docs/acoustic-model.md): 마이크 응답과 자유음장·shoebox 희소 근사
|
- [`docs/acoustic-model.md`](docs/acoustic-model.md): 마이크 응답과 자유음장·shoebox 희소 근사
|
||||||
- [`docs/realtime-porting.md`](docs/realtime-porting.md): 실측 다채널 위치추정 API 포팅
|
- [`docs/realtime-porting.md`](docs/realtime-porting.md): 실측 다채널 위치추정 API 포팅
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Acoustic Camera
|
# Acoustic Camera
|
||||||
|
|
||||||
Scene과 마이크 배열로 자유음장 또는 저비용 shoebox microphone signal을 합성하고, STFT와
|
Scene과 마이크 배열로 자유음장 또는 저비용 shoebox microphone signal을 합성하고, 표준
|
||||||
DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 ground-truth 평가 지표를
|
CA/OS-CFAR·Dixon spectral flux로 ADC 대역 후보를 보거나 STFT와
|
||||||
만드는 브라우저 앱입니다. 정지 장면은 Snapshot, trajectory 장면은 Moving 흐름으로 분리되어
|
DAS·DAS-PHAT·GCC-PHAT·MUSIC 위치추정으로 주파수 대역별 acoustic map과 ground-truth 평가
|
||||||
있습니다.
|
지표를 만드는 브라우저 앱입니다. 정지 장면은 Snapshot, trajectory 장면은 Moving 흐름으로
|
||||||
|
분리되어 있습니다.
|
||||||
|
|
||||||
## 열기와 Snapshot
|
## 열기와 Snapshot
|
||||||
|
|
||||||
@@ -22,21 +23,28 @@ DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 g
|
|||||||
3. 상단 작업 순서에서 입력·설정 상태를 확인한 뒤 Snapshot 카드의 **▶ Snapshot 실행**을 누릅니다.
|
3. 상단 작업 순서에서 입력·설정 상태를 확인한 뒤 Snapshot 카드의 **▶ Snapshot 실행**을 누릅니다.
|
||||||
4. 완료 후 overlay, 위치 오차와 timing을 확인하고 **Result JSON**을 저장합니다.
|
4. 완료 후 overlay, 위치 오차와 timing을 확인하고 **Result JSON**을 저장합니다.
|
||||||
|
|
||||||
|
MUSIC은 현재 장면 제목 옆 **MUSIC 빠른 예제 → ▶ Snapshot 실행** 두 단계로 확인할 수 있습니다.
|
||||||
|
12채널 배열, 1.125 kHz 단일 FFT-grid 가설과 Fixed K=1 Experiment가 함께 열립니다.
|
||||||
|
|
||||||
이동 장면을 바로 보려면 Moving 카드에서 **이동 예제 열기 → Map 준비 → 재생** 순서로 누릅니다.
|
이동 장면을 바로 보려면 Moving 카드에서 **이동 예제 열기 → Map 준비 → 재생** 순서로 누릅니다.
|
||||||
이동 예제는 24×18 grid와 2 Hz map rate를 사용해 CPU fallback에서도 준비량을 줄였습니다.
|
이동 예제는 24×18 grid와 2 Hz map rate를 사용해 CPU fallback에서도 준비량을 줄였습니다.
|
||||||
|
|
||||||
## 사용자 입력 불러오기
|
## 사용자 입력 불러오기
|
||||||
|
|
||||||
문서는 다음 순서로 불러오는 것이 안전합니다.
|
문서는 다음 순서로 불러오면 asset 연결까지 한 번에 확인하기 쉽습니다.
|
||||||
|
|
||||||
1. **Scene**: `.scene.json`
|
1. **Scene**: `.scene.json`
|
||||||
2. **Array**: 선택 사항인 `.array.json`; 또는 **비교 배열** preset 사용
|
2. **Array**: 선택 사항인 `.array.json`; 또는 **비교 배열** preset 사용
|
||||||
3. **Experiment**: 선택 사항인 `.experiment.json`
|
3. **Experiment**: 선택 사항인 `.experiment.json`
|
||||||
4. **Assets**: Scene이 참조하는 원본 이미지와 오디오
|
4. **Assets**: Scene이 참조하는 원본 이미지와 오디오
|
||||||
|
|
||||||
Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에 맞추고 기존 asset 연결을
|
Scene을 불러오면 scan plane/bounds를 새 Scene에 맞추고 기존 asset 연결을 해제하지만
|
||||||
해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 현재
|
Experiment의 `sceneId` provenance label과 분석 대역은 보존합니다. Array를 불러와도
|
||||||
Scene과 Array를 참조해야 하므로 마지막에 불러오세요.
|
`arrayId` label을 덮어쓰지 않습니다. 별도 Experiment는 어느 시점에 불러와도 문서 자체를
|
||||||
|
먼저 검증하며 원래 `sceneId`/`arrayId`와 모든 분석 대역을 보존합니다. ID가 달라도 실행이
|
||||||
|
막히지 않고 현재 열린 Scene·Array가 실제 입력이 되며, Result에 그 실제 ID와 hash가 기록됩니다.
|
||||||
|
Nyquist 위 tone·band는 ideal ADC
|
||||||
|
alias로 실행되고, 잘못된 scan 범위 같은 실제 관계 오류는 그대로 거부됩니다.
|
||||||
|
|
||||||
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
|
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
|
||||||
Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다.
|
Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다.
|
||||||
@@ -48,9 +56,53 @@ Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라
|
|||||||
|
|
||||||
파일은 외부로 업로드되지 않고 브라우저 메모리에서만 처리됩니다.
|
파일은 외부로 업로드되지 않고 브라우저 메모리에서만 처리됩니다.
|
||||||
|
|
||||||
|
## 전 대역 활동 후보 찾기
|
||||||
|
|
||||||
|
분석 주파수를 미리 모를 때는 분석 대역 아래의 **전 대역 활동 검사**를 펼칩니다.
|
||||||
|
이 흐름은 위치추정 map과 별도이며 다음 순서로 사용합니다.
|
||||||
|
|
||||||
|
1. 실제로 관찰할 물리 마이크 한 채널을 고릅니다.
|
||||||
|
2. 좁은 spectral outlier에는 OS-CFAR 또는 CA-CFAR, 갑작스러운 onset에는
|
||||||
|
Dixon spectral flux를 고릅니다.
|
||||||
|
3. **전 대역 검사**를 누르고 frame별 원시 결과를 확인합니다.
|
||||||
|
4. CFAR가 표시한 ADC bin에 대응한다고 가정할 아날로그 center를 직접 입력합니다.
|
||||||
|
5. **이 가설을 대역으로 추가**한 뒤 DAS·PHAT·GCC-PHAT·MUSIC으로 배열 위치응답을 비교합니다.
|
||||||
|
6. 모든 complete frame과 모든 tested/untested bin은 **Activity JSON**으로 저장합니다.
|
||||||
|
|
||||||
|
Activity worker는 Scene과 선택한 마이크의 위치·응답으로 물리 수신 신호를 합성하지만,
|
||||||
|
음원 위치·주파수·개수 정답은 detector 함수에 전달하지 않습니다. 선택하지 않은 마이크는
|
||||||
|
합성하거나 평균·투표하지 않습니다. 위치추정 band, algorithm, backend 또는 배경 image 연결이
|
||||||
|
잘못돼 Snapshot을 실행할 수 없어도, Scene·선택 마이크·STFT·합성 최소 거리와 필요한 audio
|
||||||
|
asset이 유효하면 Activity는 실행할 수 있습니다.
|
||||||
|
|
||||||
|
CFAR는 Hann STFT의 각 complete frame을 독립 검사합니다. DC, Nyquist와 좌우 full
|
||||||
|
reference window가 없는 edge bin은 reference 수를 줄이거나 wrap하지 않고 untested로 남깁니다.
|
||||||
|
Pfa는 IID exponential square-law 배경을 가정한 tested CUT 하나당 명목 오경보율이며
|
||||||
|
위치 확률, confidence, frame 전체 오경보율 또는 실측 보정값이 아닙니다. 화면에는 CUT,
|
||||||
|
reference, threshold와 ratio를 그대로 표시합니다.
|
||||||
|
|
||||||
|
Dixon spectral flux는 Hamming STFT의 연속 frame 사이에서 증가한 linear magnitude의
|
||||||
|
full-DFT L1 합입니다. 지속 activity가 아니라 onset novelty입니다. Böck–Krebs–Schedl
|
||||||
|
causal peak picker는 기본으로 꺼져 있고, 켤 때도 delta는 확률이 아닌 사용자 보정값입니다.
|
||||||
|
Frame start는 설명용 시각이며 **available** 시각이 실제 causal 판정 가능 시각입니다.
|
||||||
|
|
||||||
|
Scene의 peak normalization을 선택했다면 선택한 한 채널의 전체 render peak가 detector 전에
|
||||||
|
적용됩니다. 이 경우 detector 자체는 입력 이후 causal이어도 spectral-flux 절대값과 delta
|
||||||
|
event를 포함한 end-to-end 전처리는 미래 sample에 의존하며, UI와 JSON에 이를 표시합니다.
|
||||||
|
물리 scale을 그대로 비교하려면 Scene normalization을 none으로 두세요.
|
||||||
|
|
||||||
|
검출기가 관찰하는 주파수는 ADC의 0…Fs/2뿐입니다. 예를 들어 Fs=48 kHz에서 ADC 1,125 Hz로
|
||||||
|
보인 성분은 1,125 Hz, 46,875 Hz, 49,125 Hz 등 여러 아날로그 carrier와 양립합니다.
|
||||||
|
Nyquist 초과 가설도 0–1 MHz 검증 범위에서 허용하지만 alias order와 spectral inversion은
|
||||||
|
자동 선택하지 않습니다. 각 가설을 FFT-grid center의 별도 band로 추가합니다. CFAR와 flux,
|
||||||
|
서로 다른 band 또는 위치 map을 합의·평균·maximum으로 자동 결합하지 않습니다.
|
||||||
|
|
||||||
|
수식, 통계 가정, 문헌과 raw output 계약은
|
||||||
|
[전 대역 활동 검출 모델](../../docs/activity-detection.md)을 참고하세요.
|
||||||
|
|
||||||
## 위치 추정 알고리즘과 Backend
|
## 위치 추정 알고리즘과 Backend
|
||||||
|
|
||||||
**분석 설정 → 위치 추정 알고리즘**에서 다음 세 구현을 선택합니다.
|
**분석 설정 → 위치 추정 알고리즘**에서 다음 네 구현을 선택합니다.
|
||||||
|
|
||||||
- **DAS**: 채널 진폭과 위상을 유지해 coherent sum power를 계산하는 기준선입니다. CPU와 WebGPU를
|
- **DAS**: 채널 진폭과 위상을 유지해 coherent sum power를 계산하는 기준선입니다. CPU와 WebGPU를
|
||||||
지원하고 `rawMap.scale`은 `power`입니다.
|
지원하고 `rawMap.scale`은 `power`입니다.
|
||||||
@@ -59,13 +111,20 @@ Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라
|
|||||||
- **GCC-PHAT**: 기준 마이크와 나머지 마이크의 PHAT 상관에서 기하학적 TDOA를 평가합니다. 현재
|
- **GCC-PHAT**: 기준 마이크와 나머지 마이크의 PHAT 상관에서 기하학적 TDOA를 평가합니다. 현재
|
||||||
CPU 전용이므로 Auto는 CPU를 선택하고 **WebGPU 강제**는 선택할 수 없습니다. 각 band가 8개
|
CPU 전용이므로 Auto는 CPU를 선택하고 **WebGPU 강제**는 선택할 수 없습니다. 각 band가 8개
|
||||||
미만의 FFT bin을 포함하면 협대역 상관이 불안정할 수 있다는 경고가 표시됩니다.
|
미만의 FFT bin을 포함하면 협대역 상관이 불안정할 수 있다는 경고가 표시됩니다.
|
||||||
|
- **MUSIC**: CPU에서 표준 Schmidt narrowband MUSIC의 근거리 구면파 direct-path manifold를
|
||||||
|
계산합니다. Fixed K 또는 Wax–Kailath AIC/MDL을 선택하며, band마다 아날로그 FFT-grid 가설
|
||||||
|
하나만 허용합니다.
|
||||||
|
|
||||||
PHAT score는 위치 확률이나 음향 power가 아닙니다. 세 알고리즘 모두 같은 scan grid에 응답 map을
|
PHAT score는 위치 확률이나 음향 power가 아닙니다. 네 알고리즘 모두 같은 scan grid에 응답 map을
|
||||||
만들지만, 진폭 보존 여부와 실패 양상이 다르므로 같은 Scene·Array·band로 비교하세요.
|
만들지만, 진폭 보존 여부와 실패 양상이 다르므로 같은 Scene·Array·band로 비교하세요.
|
||||||
GCC-PHAT raw score는 pair·band coefficient·IFFT 크기를 정규화한 0–1 양의 상관 응답이라 scan
|
GCC-PHAT raw score는 pair·band coefficient·IFFT 크기를 정규화한 0–1 양의 상관 응답이라 scan
|
||||||
범위나 마이크 수에 따른 단순 scale 변화는 피하지만, 확률이나 다른 알고리즘과 공통인 confidence는
|
범위나 마이크 수에 따른 단순 scale 변화는 피하지만, 확률이나 다른 알고리즘과 공통인 confidence는
|
||||||
아닙니다.
|
아닙니다.
|
||||||
|
|
||||||
|
MUSIC raw `q`는 noise-subspace projection이고 작은 값이 좋은 가설입니다. 화면 heatmap과 peak
|
||||||
|
threshold는 reciprocal `P=1/q`의 scan 내 표시값 `P/Pmax`를 사용합니다. 둘 다 확률·confidence
|
||||||
|
또는 신뢰구간이 아닙니다. flat `q`는 위치를 임의 선택하지 않고 식별 불가로 표시합니다.
|
||||||
|
|
||||||
- **Auto (GPU→CPU)**: 권장. WebGPU를 먼저 시도하고 adapter/device 부재나 지원되는 장치 한계가
|
- **Auto (GPU→CPU)**: 권장. WebGPU를 먼저 시도하고 adapter/device 부재나 지원되는 장치 한계가
|
||||||
있으면 CPU로 계속합니다. 라이브 준비에서는 첫 fallback 뒤 남은 frame이 같은 CPU analysis
|
있으면 CPU로 계속합니다. 라이브 준비에서는 첫 fallback 뒤 남은 frame이 같은 CPU analysis
|
||||||
plan을 재사용하므로 GPU 실패를 매 frame 반복하지 않습니다. GCC-PHAT에서는 **Auto (CPU)**로
|
plan을 재사용하므로 GPU 실패를 매 frame 반복하지 않습니다. GCC-PHAT에서는 **Auto (CPU)**로
|
||||||
@@ -126,19 +185,33 @@ cache를 표시하고 알고리즘별 위치추정 비용을 다음처럼 분리
|
|||||||
- DAS/DAS-PHAT: scan point × microphone × STFT frame × 선택 bin의 complex MAC
|
- DAS/DAS-PHAT: scan point × microphone × STFT frame × 선택 bin의 complex MAC
|
||||||
- GCC-PHAT: 기준 마이크 pair별 two-pass cross-spectrum, pair × band inverse FFT와
|
- GCC-PHAT: 기준 마이크 pair별 two-pass cross-spectrum, pair × band inverse FFT와
|
||||||
scan point × pair × band correlation lookup
|
scan point × pair × band correlation lookup
|
||||||
|
- MUSIC: hypothesis별 Float64 covariance, Hermitian Jacobi EVD와
|
||||||
|
scan point × microphone × noise-subspace projection
|
||||||
|
|
||||||
이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop,
|
이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop,
|
||||||
band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
||||||
|
|
||||||
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist를 넘거나 겹치는 id 등 계약 오류는
|
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist 위 band도 허용되며 legend에 실제로
|
||||||
실행 전에 표시됩니다. DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
접힌 ADC FFT range가 표시됩니다. 중복 id나 잘못된 범위 같은 계약 오류는 실행 전에 표시됩니다.
|
||||||
|
DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
||||||
항목은 Camera UI에서 제거했습니다. Result의 `processedMaps` 계약만 향후 확장을 위해 남아 있습니다.
|
항목은 Camera UI에서 제거했습니다. Result의 `processedMaps` 계약만 향후 확장을 위해 남아 있습니다.
|
||||||
|
|
||||||
|
MUSIC에서는 band가 사용자가 지정한 아날로그 FFT-grid center 하나만 포함해야 합니다. 같은 ADC
|
||||||
|
bin으로 접히는 서로 다른 Nyquist-zone 가설은 별도 band로 만드세요. spectral inversion이면
|
||||||
|
analog carrier 크기의 공간 위상을 유지하면서 positive-bin coefficient와 calibration/manifold
|
||||||
|
전체를 켤레 방향으로 계산합니다. signed ADC alias는 orientation만 정합니다. Scene의 tone
|
||||||
|
truth로 alias order를 자동 선택하지 않으며 DC/Nyquist edge는 거부합니다.
|
||||||
|
|
||||||
**Peak 검출 임계값 (%)**은 최강 local peak의 map 값에 대한 상대 임계값이며 Experiment JSON에
|
**Peak 검출 임계값 (%)**은 최강 local peak의 map 값에 대한 상대 임계값이며 Experiment JSON에
|
||||||
저장됩니다. 기본 25%에서 약한 두 번째 음원이 빠지면 10–20%로 낮추세요. 낮은 값은 더 약한 후보를
|
저장됩니다. 기본 25%에서 약한 두 번째 음원이 빠지면 10–20%로 낮추세요. 낮은 값은 더 약한 후보를
|
||||||
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
|
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
|
||||||
main lobe 폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 하나로 합쳐질 수 있습니다.
|
main lobe 폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 하나로 합쳐질 수 있습니다.
|
||||||
|
|
||||||
|
**Peak 묶기**는 raw map을 바꾸지 않고 threshold 위 후보를 표현하는 방식을 고릅니다.
|
||||||
|
`local-max`는 각 8-neighbor local maximum, `connected-components`는 threshold mask의
|
||||||
|
8-connected component별 weighted centroid, `watershed`는 local maximum marker 기반 watershed
|
||||||
|
basin을 사용합니다. 선택한 표준 방식과 threshold는 Experiment와 Result에 기록됩니다.
|
||||||
|
|
||||||
## 화면과 배열·알고리즘 비교
|
## 화면과 배열·알고리즘 비교
|
||||||
|
|
||||||
- acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다.
|
- acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다.
|
||||||
@@ -152,7 +225,8 @@ main lobe 폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 하
|
|||||||
남습니다.
|
남습니다.
|
||||||
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
|
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
|
||||||
- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각
|
- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각
|
||||||
**JSON 저장** 버튼으로 보존할 수 있습니다.
|
**JSON 저장** 버튼으로 보존할 수 있습니다. Experiment의 `sceneId`/`arrayId`는 provenance
|
||||||
|
label이며, 현재 입력에 다시 연결한 뒤 실제 실행 ID와 hash가 Result에 기록됩니다.
|
||||||
- **전체 기록 JSON**은 세션에 남은 배열·알고리즘·backend 비교 요약을
|
- **전체 기록 JSON**은 세션에 남은 배열·알고리즘·backend 비교 요약을
|
||||||
`micarrsim.comparison-history` 문서로 저장합니다.
|
`micarrsim.comparison-history` 문서로 저장합니다.
|
||||||
|
|
||||||
@@ -178,12 +252,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
|||||||
|
|
||||||
## Result JSON
|
## Result JSON
|
||||||
|
|
||||||
**Result JSON**은 검증을 통과한 `micarrsim.result` 문서만 저장합니다.
|
**Result JSON**은 검증을 통과한 `micarrsim.result` 1.1 문서만 저장합니다.
|
||||||
|
|
||||||
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `algorithm`과 `backend`
|
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `algorithm`과 `backend`
|
||||||
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
|
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
|
||||||
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse
|
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse
|
||||||
path 전체·종류별 수
|
path 전체·종류별 수와 필수 ideal-ADC sampling provenance
|
||||||
- `timingMs`: 전체, signal generation과 `localization`; 기존 소비자 호환용 `beamforming`은 같은
|
- `timingMs`: 전체, signal generation과 `localization`; 기존 소비자 호환용 `beamforming`은 같은
|
||||||
위치추정 시간을 담는 alias
|
위치추정 시간을 담는 alias
|
||||||
- band별 `rawMap`: DAS는 선형 `power`, PHAT 계열은 무차원 `score`; 화면용 `displayMap`과 검출
|
- band별 `rawMap`: DAS는 선형 `power`, PHAT 계열은 무차원 `score`; 화면용 `displayMap`과 검출
|
||||||
@@ -191,9 +265,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
|||||||
- 각 peak의 비보정 half-power/half-maximum 공분산과 RMS ellipse인 `beamSpread`
|
- 각 peak의 비보정 half-power/half-maximum 공분산과 RMS ellipse인 `beamSpread`
|
||||||
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
|
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
|
||||||
`mainLobeWidthM`
|
`mainLobeWidthM`
|
||||||
|
- band별 연속 fold segment, FFT bin-center 선택, DAS/GCC 실제 localization model,
|
||||||
|
원 carrier 미사용 여부와 alias-equivalent band ID
|
||||||
|
|
||||||
Tone source는 그 주파수가 band 범위 안에 있을 때만 해당 band의 ground truth입니다. Audio asset과
|
Tone source는 요청 analog interval 또는 접힌 ADC 주파수의 nearest selected bin을 기준으로
|
||||||
noise source는 스펙트럼을 사전에 판별하지 않으므로 모든 band의 ground truth로 평가합니다. Source
|
해당 band의 ground truth가 됩니다. Audio asset과 noise source는 임의 spectrum detector를 만들지 않고 모든 band의
|
||||||
|
ground truth로 평가합니다. Source
|
||||||
z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와 위치 오차를 함께 해석하세요.
|
z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와 위치 오차를 함께 해석하세요.
|
||||||
|
|
||||||
## 해석 한계
|
## 해석 한계
|
||||||
@@ -208,8 +285,9 @@ sensitivity/phase/frequency-response calibration은 적용하지만 결과는
|
|||||||
실제 동기식 microphone `Float32Array`를 Scene 합성 없이 위치추정하려면
|
실제 동기식 microphone `Float32Array`를 Scene 합성 없이 위치추정하려면
|
||||||
[실시간 위치추정 코어 포팅](../../docs/realtime-porting.md)의
|
[실시간 위치추정 코어 포팅](../../docs/realtime-porting.md)의
|
||||||
`createLocalizationPlan`, `localizeMultichannel` 또는
|
`createLocalizationPlan`, `localizeMultichannel` 또는
|
||||||
`createStreamingLocalizer`를 사용하세요. portable config의 `algorithm`으로 같은 세 위치추정을
|
`createStreamingLocalizer`를 사용하세요. portable config의 `algorithm`으로 같은 네 위치추정을
|
||||||
선택할 수 있습니다. 실시간성은 장치에서
|
선택할 수 있습니다. MUSIC은 CPU 전용이며 fixed K 또는 엄격한 가정 아래 AIC/MDL을 사용합니다.
|
||||||
|
실시간성은 장치에서
|
||||||
`processingTimeMs <= deadlineMs`와 `deadlineMisses`·`droppedSamples`를
|
`processingTimeMs <= deadlineMs`와 `deadlineMisses`·`droppedSamples`를
|
||||||
측정해 판정해야 합니다.
|
측정해 판정해야 합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,17 @@ export function createCameraAssetRuntime(onChange = () => {}) {
|
|||||||
return statuses(scene).filter((status) => !status.ready).map(({ descriptor }) => descriptor.id);
|
return statuses(scene).filter((status) => !status.ready).map(({ descriptor }) => descriptor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function missingAudioAssetIds(scene) {
|
||||||
|
const audioIds = audioAssetIds(scene);
|
||||||
|
return statuses(scene)
|
||||||
|
.filter(
|
||||||
|
(status) =>
|
||||||
|
audioIds.has(status.descriptor.id) &&
|
||||||
|
!status.ready,
|
||||||
|
)
|
||||||
|
.map(({ descriptor }) => descriptor.id);
|
||||||
|
}
|
||||||
|
|
||||||
async function decodeAudioAssets(scene, signal, onProgress = () => {}) {
|
async function decodeAudioAssets(scene, signal, onProgress = () => {}) {
|
||||||
const ids = [...audioAssetIds(scene)];
|
const ids = [...audioAssetIds(scene)];
|
||||||
const entries = [...scene.sources, ...(scene.ambient ?? [])];
|
const entries = [...scene.sources, ...(scene.ambient ?? [])];
|
||||||
@@ -248,6 +259,7 @@ export function createCameraAssetRuntime(onChange = () => {}) {
|
|||||||
return imageElement;
|
return imageElement;
|
||||||
},
|
},
|
||||||
missingAssetIds,
|
missingAssetIds,
|
||||||
|
missingAudioAssetIds,
|
||||||
reset,
|
reset,
|
||||||
statuses,
|
statuses,
|
||||||
transferListForAudio,
|
transferListForAudio,
|
||||||
|
|||||||
@@ -34,24 +34,24 @@
|
|||||||
<section class="workflow-card input-card" id="input-step" data-workflow-section="1">
|
<section class="workflow-card input-card" id="input-step" data-workflow-section="1">
|
||||||
<div class="step-heading">
|
<div class="step-heading">
|
||||||
<span class="step-number">1</span>
|
<span class="step-number">1</span>
|
||||||
<div><h2>입력 준비</h2><p>기본 예제를 쓰거나 파일을 순서대로 교체하세요.</p></div>
|
<div><h2>입력 준비</h2><p>기본 예제를 쓰거나 파일을 교체하세요. Experiment부터 열어도 대역은 유지됩니다.</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="workspace-tools input-file-grid" aria-label="입력 파일">
|
<div class="workspace-tools input-file-grid" aria-label="입력 파일">
|
||||||
<label class="file-button file-choice" title="Scene JSON을 선택하세요."><span class="file-sequence">1</span><span><strong>Scene</strong><small>장면·음원</small></span><input id="scene-input" type="file" accept="application/json,.json" /></label>
|
<label class="file-button file-choice" title="Scene JSON을 선택하세요."><span class="file-sequence">1</span><span><strong>Scene</strong><small>장면·음원</small></span><input id="scene-input" type="file" accept="application/json,.json" /></label>
|
||||||
<label class="file-button file-choice" title="사용자 배열이 있을 때 Array JSON을 선택하세요."><span class="file-sequence">2</span><span><strong>Array</strong><small>마이크 배열</small></span><input id="array-input" type="file" accept="application/json,.json" /></label>
|
<label class="file-button file-choice" title="사용자 배열이 있을 때 Array JSON을 선택하세요."><span class="file-sequence">2</span><span><strong>Array</strong><small>마이크 배열</small></span><input id="array-input" type="file" accept="application/json,.json" /></label>
|
||||||
<label class="file-button file-choice" title="현재 Scene과 Array를 참조하는 Experiment JSON을 선택하세요."><span class="file-sequence">3</span><span><strong>Experiment</strong><small>분석 설정</small></span><input id="experiment-input" type="file" accept="application/json,.json" /></label>
|
<label class="file-button file-choice" title="Experiment를 먼저 열 수 있습니다. sceneId와 arrayId provenance label은 보존되고 현재 열린 Scene·Array가 실행 입력이 됩니다."><span class="file-sequence">3</span><span><strong>Experiment</strong><small>분석 설정 · ID 보존</small></span><input id="experiment-input" type="file" accept="application/json,.json" /></label>
|
||||||
<label class="file-button file-choice" title="Scene에 사용한 원본 파일을 다시 선택하세요."><span class="file-sequence">4</span><span><strong>Assets</strong><small>오디오·이미지</small></span><input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
|
<label class="file-button file-choice" title="Scene에 사용한 원본 파일을 다시 선택하세요."><span class="file-sequence">4</span><span><strong>Assets</strong><small>오디오·이미지</small></span><input id="asset-input" type="file" accept="audio/*,image/*,.wav,.flac,.mp3,.ogg,.m4a,.png,.jpg,.jpeg,.webp,.svg" multiple /></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-summary">
|
<div class="input-summary">
|
||||||
<div class="compact-section-heading"><h3>현재 장면</h3><a class="builder-link" href="/apps/dataset-builder/">Builder에서 편집 →</a></div>
|
<div class="compact-section-heading"><h3>현재 장면</h3><div class="example-actions"><button class="mini-button" id="load-music-example-button" type="button" title="12채널·단일 FFT-grid carrier·Fixed K=1인 빠른 표준 MUSIC 예제를 엽니다.">MUSIC 빠른 예제</button><a class="builder-link" href="/apps/dataset-builder/" target="_blank" rel="noopener" title="현재 Camera의 Experiment를 유지한 채 새 탭에서 Scene을 편집합니다. 저장 후 이 Camera 탭으로 돌아와 Scene JSON을 여세요.">Builder 새 탭 ↗</a></div></div>
|
||||||
<div class="source-summary" id="source-summary"></div>
|
<div class="source-summary" id="source-summary"></div>
|
||||||
<div class="asset-list" id="asset-list"></div>
|
<div class="asset-list" id="asset-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="array-card">
|
<div class="array-card">
|
||||||
<div class="array-heading"><div><span class="compact-label">MIC ARRAY</span><h3 id="array-title">배열</h3><a class="builder-link" href="/apps/array-builder/">Array Builder에서 만들기 →</a></div><strong id="microphone-count">— ch</strong></div>
|
<div class="array-heading"><div><span class="compact-label">MIC ARRAY</span><h3 id="array-title">배열</h3><a class="builder-link" href="/apps/array-builder/" target="_blank" rel="noopener" title="현재 Camera의 Experiment를 유지한 채 새 탭에서 배열을 편집합니다. 저장 후 이 Camera 탭으로 돌아와 Array JSON을 여세요.">Array Builder 새 탭 ↗</a></div><strong id="microphone-count">— ch</strong></div>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>배열 선택</span>
|
<span>배열 선택</span>
|
||||||
<select id="array-preset">
|
<select id="array-preset">
|
||||||
@@ -89,19 +89,68 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="quick-field-grid">
|
<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 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><option value="music">MUSIC · noise subspace / 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>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>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>
|
<label class="field"><span>Scene 거리 z (m)</span><input id="scene-distance" type="number" min="0.01" step="0.1" /></label>
|
||||||
<label class="field" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
|
<label class="field" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
|
||||||
|
<label class="field" title="원시 map은 바꾸지 않고, 임계값 위 영역을 peak로 묶는 표준 방식을 선택합니다."><span>Peak 묶기</span><select id="detection-method"><option value="local-max">Local maxima</option><option value="connected-components">8-connected components</option><option value="watershed">Marker watershed</option></select></label>
|
||||||
|
</div>
|
||||||
|
<div class="algorithm-options" id="music-options" hidden>
|
||||||
|
<label class="field"><span>Source count 방식</span><select id="music-source-count-method"><option value="fixed">Fixed K</option><option value="mdl">Wax–Kailath MDL</option><option value="aic">Wax–Kailath AIC</option></select></label>
|
||||||
|
<label class="field"><span>Source count K</span><input id="music-source-count" type="number" min="1" max="1023" step="1" /></label>
|
||||||
|
<p id="music-options-note">각 band는 ADC FFT bin 하나만 선택합니다. Fixed K는 마이크 수보다 작아야 합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
|
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
|
||||||
|
<p class="sampling-warning" id="sampling-warning" hidden></p>
|
||||||
|
|
||||||
<div class="band-config">
|
<div class="band-config">
|
||||||
<div class="compact-section-heading"><h3>주파수 대역</h3><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
|
<div class="compact-section-heading"><h3>주파수 대역</h3><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
|
||||||
<div class="band-editor-list" id="band-list"></div>
|
<div class="band-editor-list" id="band-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details class="activity-monitor utility-details" id="activity-monitor">
|
||||||
|
<summary><span>전 대역 활동 검사</span><small>위치추정과 분리 · ADC spectrum</small></summary>
|
||||||
|
<div class="activity-monitor__body">
|
||||||
|
<p class="activity-monitor__copy">Scene은 물리 마이크 신호 합성에만 사용하며 음원 위치·주파수·개수 정답은 검출기에 전달하지 않습니다. 위치추정과 독립적으로 ADC의 0…Fs/2만 관측하므로 analog alias order는 사용자가 지정해야 합니다.</p>
|
||||||
|
<div class="activity-field-grid">
|
||||||
|
<label class="field"><span>검출 알고리즘</span><select id="activity-algorithm"><option value="os-cfar">OS-CFAR</option><option value="ca-cfar">CA-CFAR</option><option value="dixon-spectral-flux">Dixon spectral flux</option></select></label>
|
||||||
|
<label class="field"><span>기준 마이크</span><select id="activity-microphone" disabled><option value="">마이크를 불러오세요</option></select></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-option-grid" id="activity-cfar-options">
|
||||||
|
<label class="field"><span>Nominal Pfa / tested CUT</span><input id="activity-pfa" type="number" min="0.000000001" max="0.999999999" step="0.001" value="0.001" /></label>
|
||||||
|
<label class="field"><span>Training cells / side</span><input id="activity-training" type="number" min="1" step="1" value="8" /></label>
|
||||||
|
<label class="field"><span>Guard cells / side</span><input id="activity-guard" type="number" min="0" step="1" value="2" /></label>
|
||||||
|
<label class="field"><span>OS rank k</span><input id="activity-rank" type="number" min="1" step="1" value="12" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-option-grid" id="activity-flux-options" hidden>
|
||||||
|
<label class="activity-checkbox field--wide"><input id="activity-peak-picker-enabled" type="checkbox" /><span>Böck–Krebs–Schedl causal peak picker 사용</span></label>
|
||||||
|
<label class="field"><span>Threshold δ</span><input id="activity-delta" type="number" min="0" step="0.01" placeholder="사용자 보정값" /></label>
|
||||||
|
<label class="field"><span>Pre-max frames</span><input id="activity-pre-max" type="number" min="0" step="1" value="3" /></label>
|
||||||
|
<label class="field"><span>Pre-mean frames</span><input id="activity-pre-mean" type="number" min="0" step="1" value="8" /></label>
|
||||||
|
<label class="field"><span>Refractory frames</span><input id="activity-refractory" type="number" min="0" step="1" value="3" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="activity-monitor__note activity-monitor__note--cfar">OS/CA의 Pfa는 IID exponential 배경 모델에서 tested CUT 하나당 정한 nominal false-alarm 값입니다. 위치 확률·신뢰도·실측 보정값이 아닙니다.</p>
|
||||||
|
<p class="activity-monitor__note activity-monitor__note--flux">Spectral flux는 onset novelty이며 지속 activity가 아닙니다. δ는 사용자가 데이터에 맞춰 정하는 보정값입니다.</p>
|
||||||
|
|
||||||
|
<div class="activity-actions">
|
||||||
|
<button class="button button--primary" id="activity-run-button" type="button">전 대역 검사</button>
|
||||||
|
<button class="button button--ghost" id="activity-export-button" type="button" disabled>Activity JSON</button>
|
||||||
|
</div>
|
||||||
|
<p class="activity-status" id="activity-status" role="status" aria-live="polite">검사할 입력과 기준 마이크를 준비하세요.</p>
|
||||||
|
<label class="activity-frame-control">
|
||||||
|
<span id="activity-frame-label">Frame 0 / 0</span>
|
||||||
|
<input id="activity-frame" type="range" min="0" max="0" step="1" value="0" disabled />
|
||||||
|
</label>
|
||||||
|
<div class="activity-result-list" id="activity-result-list" role="list" aria-label="전 대역 활동 검사 결과" aria-live="polite">
|
||||||
|
<p class="activity-result-empty">검사 후 선택 frame의 원시 검출 통계와 candidate band를 표시합니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="advanced-settings" id="advanced-settings">
|
<details class="advanced-settings" id="advanced-settings">
|
||||||
<summary><span>고급 설정</span><small>음향 · 범위 · STFT · 식별자</small></summary>
|
<summary><span>고급 설정</span><small>음향 · 범위 · STFT · 식별자</small></summary>
|
||||||
<div class="advanced-settings__body">
|
<div class="advanced-settings__body">
|
||||||
@@ -200,7 +249,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
|
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
|
||||||
<div class="live-actions">
|
<div class="live-actions">
|
||||||
<button class="button button--ghost" id="load-moving-example-button" type="button">이동 예제 열기</button>
|
<button class="button button--ghost" id="load-moving-example-button" type="button" title="Scene·Array·Experiment와 분석 대역을 이동 예제 전체로 교체합니다.">이동 예제 전체 열기</button>
|
||||||
<button class="button button--primary" id="prepare-live-button" type="button" disabled>Map 준비</button>
|
<button class="button button--primary" id="prepare-live-button" type="button" disabled>Map 준비</button>
|
||||||
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
|
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1551
-39
File diff suppressed because it is too large
Load Diff
+268
-11
@@ -1,8 +1,22 @@
|
|||||||
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
import {
|
||||||
|
binsForBand,
|
||||||
|
getCompleteFrameCount,
|
||||||
|
getFrameCount,
|
||||||
|
} from "/packages/acoustics-core/stft.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS,
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS,
|
||||||
|
} from "/packages/acoustics-core/music.js";
|
||||||
|
import {
|
||||||
|
describeFrequencyAliasing,
|
||||||
|
observeFrequencyBand,
|
||||||
|
} from "/packages/acoustics-core/sampling.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||||
localizationAlgorithmDefinition,
|
localizationAlgorithmDefinition,
|
||||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||||
|
import { RESULT_SCHEMA_VERSION } from "/packages/contracts/validation.js";
|
||||||
|
|
||||||
const RESULT_METRIC_KEYS = [
|
const RESULT_METRIC_KEYS = [
|
||||||
"peakToSidelobeDb",
|
"peakToSidelobeDb",
|
||||||
@@ -33,9 +47,16 @@ export function createComparisonSignature(scene, sourceExperiment) {
|
|||||||
if (!scene || !sourceExperiment) return "";
|
if (!scene || !sourceExperiment) return "";
|
||||||
const experiment = structuredClone(sourceExperiment);
|
const experiment = structuredClone(sourceExperiment);
|
||||||
experiment.arrayId = "$array";
|
experiment.arrayId = "$array";
|
||||||
|
experiment.sceneId = "$scene";
|
||||||
if (experiment.beamforming) {
|
if (experiment.beamforming) {
|
||||||
experiment.beamforming.algorithm = "$algorithm";
|
experiment.beamforming.algorithm = "$algorithm";
|
||||||
experiment.beamforming.backend = "$backend";
|
experiment.beamforming.backend = "$backend";
|
||||||
|
experiment.beamforming.normalization =
|
||||||
|
"$algorithm-specific-normalization";
|
||||||
|
delete experiment.beamforming.musicSourceCountMethod;
|
||||||
|
delete experiment.beamforming.musicSourceCount;
|
||||||
|
delete experiment.beamforming.phatRelativeFloor;
|
||||||
|
delete experiment.beamforming.phatFloorRatio;
|
||||||
}
|
}
|
||||||
return canonicalStringify({ scene, experiment });
|
return canonicalStringify({ scene, experiment });
|
||||||
}
|
}
|
||||||
@@ -49,8 +70,20 @@ export async function hashSimulationInputs({ arrayDefinition, scene, experiment
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
|
export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
|
||||||
const frameCount = getFrameCount(
|
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||||
Math.ceil(scene.duration * scene.sampleRate),
|
const algorithm =
|
||||||
|
experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
||||||
|
const musicSourceCountMethod =
|
||||||
|
experiment.beamforming?.musicSourceCountMethod ?? "fixed";
|
||||||
|
const frameCount =
|
||||||
|
algorithm === "music" && musicSourceCountMethod !== "fixed"
|
||||||
|
? getCompleteFrameCount(
|
||||||
|
sampleCount,
|
||||||
|
experiment.stft.fftSize,
|
||||||
|
experiment.stft.hopSize,
|
||||||
|
)
|
||||||
|
: getFrameCount(
|
||||||
|
sampleCount,
|
||||||
experiment.stft.fftSize,
|
experiment.stft.fftSize,
|
||||||
experiment.stft.hopSize,
|
experiment.stft.hopSize,
|
||||||
);
|
);
|
||||||
@@ -59,9 +92,17 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
|
|||||||
);
|
);
|
||||||
const bandBinSelectionCount = bandBins.reduce((total, bins) => total + bins.length, 0);
|
const bandBinSelectionCount = bandBins.reduce((total, bins) => total + bins.length, 0);
|
||||||
const selectedBinCount = new Set(bandBins.flat()).size;
|
const selectedBinCount = new Set(bandBins.flat()).size;
|
||||||
|
const binWidthHz =
|
||||||
|
scene.sampleRate / experiment.stft.fftSize;
|
||||||
|
const musicHypothesisCount = new Set(
|
||||||
|
experiment.frequencyBands.map((band, bandIndex) => {
|
||||||
|
const analogBin = Math.ceil(band.minHz / binWidthHz);
|
||||||
|
const adcBin = bandBins[bandIndex]?.[0];
|
||||||
|
return `${analogBin}|${adcBin}`;
|
||||||
|
}),
|
||||||
|
).size;
|
||||||
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
const pointCount = experiment.scan.columns * experiment.scan.rows;
|
||||||
const microphoneCount = arrayDefinition.microphones.length;
|
const microphoneCount = arrayDefinition.microphones.length;
|
||||||
const algorithm = experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
|
||||||
const algorithmDefinition = localizationAlgorithmDefinition(algorithm);
|
const algorithmDefinition = localizationAlgorithmDefinition(algorithm);
|
||||||
const referencePairCount = Math.max(0, microphoneCount - 1);
|
const referencePairCount = Math.max(0, microphoneCount - 1);
|
||||||
const stftTransformCount = frameCount * microphoneCount;
|
const stftTransformCount = frameCount * microphoneCount;
|
||||||
@@ -70,7 +111,50 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
|
|||||||
2 * referencePairCount * frameCount * bandBinSelectionCount;
|
2 * referencePairCount * frameCount * bandBinSelectionCount;
|
||||||
const gccScanScoreOps = pointCount * referencePairCount * bandBins.length;
|
const gccScanScoreOps = pointCount * referencePairCount * bandBins.length;
|
||||||
const inverseFftTransformCount = referencePairCount * bandBins.length;
|
const inverseFftTransformCount = referencePairCount * bandBins.length;
|
||||||
const scanOperationCount = algorithm === "gcc-phat" ? gccScanScoreOps : dasComplexMacCount;
|
const musicSourceCount = experiment.beamforming?.musicSourceCount ?? 1;
|
||||||
|
if (
|
||||||
|
algorithm === "music" &&
|
||||||
|
musicSourceCountMethod === "fixed" &&
|
||||||
|
(
|
||||||
|
!Number.isInteger(musicSourceCount) ||
|
||||||
|
musicSourceCount < 1 ||
|
||||||
|
musicSourceCount >= microphoneCount
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`MUSIC source count K must be inside 1..${microphoneCount - 1}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const musicNoiseSubspaceDimension =
|
||||||
|
musicSourceCountMethod === "fixed"
|
||||||
|
? microphoneCount - musicSourceCount
|
||||||
|
: microphoneCount;
|
||||||
|
const musicCovarianceComplexAccumulations =
|
||||||
|
musicHypothesisCount *
|
||||||
|
frameCount *
|
||||||
|
microphoneCount *
|
||||||
|
(microphoneCount + 1) /
|
||||||
|
2;
|
||||||
|
const musicJacobiWorstCasePairUpdates =
|
||||||
|
musicHypothesisCount *
|
||||||
|
32 *
|
||||||
|
(microphoneCount * (microphoneCount - 1) / 2) *
|
||||||
|
(2 * microphoneCount - 2);
|
||||||
|
const musicProjectionComplexMacs =
|
||||||
|
musicHypothesisCount *
|
||||||
|
pointCount *
|
||||||
|
microphoneCount *
|
||||||
|
musicNoiseSubspaceDimension;
|
||||||
|
const musicTotalOperationCount =
|
||||||
|
musicCovarianceComplexAccumulations +
|
||||||
|
musicJacobiWorstCasePairUpdates +
|
||||||
|
musicProjectionComplexMacs;
|
||||||
|
const scanOperationCount =
|
||||||
|
algorithm === "gcc-phat"
|
||||||
|
? gccScanScoreOps
|
||||||
|
: algorithm === "music"
|
||||||
|
? musicTotalOperationCount
|
||||||
|
: dasComplexMacCount;
|
||||||
return {
|
return {
|
||||||
algorithm,
|
algorithm,
|
||||||
algorithmLabel: algorithmDefinition?.label ?? algorithm,
|
algorithmLabel: algorithmDefinition?.label ?? algorithm,
|
||||||
@@ -85,12 +169,42 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
|
|||||||
stftTransformCount,
|
stftTransformCount,
|
||||||
referencePairCount,
|
referencePairCount,
|
||||||
scanOperationCount,
|
scanOperationCount,
|
||||||
scanOperationKind: algorithm === "gcc-phat" ? "reference-pair lookup" : "complex MAC",
|
scanOperationKind:
|
||||||
|
algorithm === "gcc-phat"
|
||||||
|
? "reference-pair lookup"
|
||||||
|
: algorithm === "music"
|
||||||
|
? "covariance + Jacobi upper bound + projection"
|
||||||
|
: "complex MAC",
|
||||||
gccCrossSpectrumComplexOps: algorithm === "gcc-phat" ? gccCrossSpectrumComplexOps : 0,
|
gccCrossSpectrumComplexOps: algorithm === "gcc-phat" ? gccCrossSpectrumComplexOps : 0,
|
||||||
inverseFftTransformCount: algorithm === "gcc-phat" ? inverseFftTransformCount : 0,
|
inverseFftTransformCount: algorithm === "gcc-phat" ? inverseFftTransformCount : 0,
|
||||||
complexMacCount: algorithm === "gcc-phat" ? 0 : dasComplexMacCount,
|
complexMacCount:
|
||||||
|
algorithm === "gcc-phat" || algorithm === "music"
|
||||||
|
? 0
|
||||||
|
: dasComplexMacCount,
|
||||||
|
musicSourceCountMethod,
|
||||||
|
musicSourceCount,
|
||||||
|
musicHypothesisCount,
|
||||||
|
musicNoiseSubspaceDimension,
|
||||||
|
musicCovarianceComplexAccumulations:
|
||||||
|
algorithm === "music" ? musicCovarianceComplexAccumulations : 0,
|
||||||
|
musicJacobiWorstCasePairUpdates:
|
||||||
|
algorithm === "music" ? musicJacobiWorstCasePairUpdates : 0,
|
||||||
|
musicProjectionComplexMacs:
|
||||||
|
algorithm === "music" ? musicProjectionComplexMacs : 0,
|
||||||
|
musicJacobiPairUpdateLimit:
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
|
||||||
|
musicCovarianceAccumulationLimit:
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS,
|
||||||
|
musicProjectionMacLimit:
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS,
|
||||||
|
musicJacobiLimitExceeded:
|
||||||
|
algorithm === "music" &&
|
||||||
|
musicJacobiWorstCasePairUpdates >
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
|
||||||
scalingVs50: pointCount / 2500,
|
scalingVs50: pointCount / 2500,
|
||||||
distanceMiB: (pointCount * microphoneCount * 4) / 1024 ** 2,
|
distanceMiB:
|
||||||
|
(pointCount * microphoneCount * (algorithm === "music" ? 8 : 4)) /
|
||||||
|
1024 ** 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +357,9 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
|
|||||||
if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture;
|
if (adapter?.architecture) environment.gpuArchitecture = adapter.architecture;
|
||||||
if (adapter?.device) environment.gpuDevice = adapter.device;
|
if (adapter?.device) environment.gpuDevice = adapter.device;
|
||||||
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
|
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
|
||||||
|
if (Number.isInteger(run.frameCount) && run.frameCount >= 0) {
|
||||||
|
environment.stftFrameCount = run.frameCount;
|
||||||
|
}
|
||||||
const referenceMic =
|
const referenceMic =
|
||||||
run.referenceMic ??
|
run.referenceMic ??
|
||||||
run.referenceMicrophoneId ??
|
run.referenceMicrophoneId ??
|
||||||
@@ -254,6 +371,101 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
|
|||||||
return environment;
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function samplingForResult(sampling) {
|
||||||
|
if (!sampling || typeof sampling !== "object") return null;
|
||||||
|
return {
|
||||||
|
model: sampling.model,
|
||||||
|
sampleRate: sampling.sampleRate,
|
||||||
|
nyquistHz: sampling.nyquistHz,
|
||||||
|
toneObservations: (sampling.toneObservations ?? []).map((observation) => ({
|
||||||
|
scope: observation.scope,
|
||||||
|
id: observation.id,
|
||||||
|
analogFrequencyHz: observation.analogFrequencyHz,
|
||||||
|
adcFrequencyHz: observation.adcFrequencyHz,
|
||||||
|
signedAdcFrequencyHz: observation.signedAdcFrequencyHz,
|
||||||
|
aliased: observation.aliased,
|
||||||
|
spectralInversion: observation.spectralInversion,
|
||||||
|
dcEdge: observation.dcEdge,
|
||||||
|
nyquistEdge: observation.nyquistEdge,
|
||||||
|
phaseDegenerate: observation.phaseDegenerate,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function frequencyObservationForResult(scene, experiment, band, algorithm) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(scene?.sampleRate) ||
|
||||||
|
!Number.isInteger(experiment?.stft?.fftSize) ||
|
||||||
|
!Number.isFinite(band?.minHz) ||
|
||||||
|
!Number.isFinite(band?.maxHz)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const observation = observeFrequencyBand(
|
||||||
|
band,
|
||||||
|
scene.sampleRate,
|
||||||
|
experiment.stft.fftSize,
|
||||||
|
);
|
||||||
|
const music = algorithm === "music";
|
||||||
|
const analogBin = Math.ceil(
|
||||||
|
band.minHz / observation.binWidthHz,
|
||||||
|
);
|
||||||
|
const configuredAnalogFrequencyHz =
|
||||||
|
analogBin * observation.binWidthHz;
|
||||||
|
const configuredAlias = music
|
||||||
|
? describeFrequencyAliasing(
|
||||||
|
configuredAnalogFrequencyHz,
|
||||||
|
scene.sampleRate,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
model: observation.model,
|
||||||
|
sampleRate: observation.sampleRate,
|
||||||
|
nyquistHz: observation.nyquistHz,
|
||||||
|
fftSize: observation.fftSize,
|
||||||
|
binWidthHz: observation.binWidthHz,
|
||||||
|
requestedMinHz: observation.requestedMinHz,
|
||||||
|
requestedMaxHz: observation.requestedMaxHz,
|
||||||
|
aliased: observation.aliased,
|
||||||
|
crossesFirstNyquist: observation.crossesFirstNyquist,
|
||||||
|
localizationInputModel: observation.localizationInputModel,
|
||||||
|
selectionModel: observation.selectionModel,
|
||||||
|
localizationModel:
|
||||||
|
algorithm === "gcc-phat"
|
||||||
|
? "band-limited-gcc-phat-geometric-lag"
|
||||||
|
: music
|
||||||
|
? "near-field-music-at-configured-analog-frequency"
|
||||||
|
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||||
|
originalCarrierUsed: music,
|
||||||
|
aliasOrderResolution:
|
||||||
|
music && observation.aliased
|
||||||
|
? "configured-single-hypothesis"
|
||||||
|
: observation.aliased
|
||||||
|
? "not-attempted"
|
||||||
|
: "not-applicable",
|
||||||
|
...(music
|
||||||
|
? {
|
||||||
|
configuredAnalogFrequencyHz,
|
||||||
|
signedAdcFrequencyHz:
|
||||||
|
configuredAlias.signedAdcFrequencyHz,
|
||||||
|
spectralInversion:
|
||||||
|
configuredAlias.spectralInversion,
|
||||||
|
frequencyHypothesisCount: 1,
|
||||||
|
frequencyHypothesisSource:
|
||||||
|
"experiment-band-not-scene-truth",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
foldBoundaryCount: observation.foldBoundaryCount,
|
||||||
|
continuousManyToOne: observation.continuousManyToOne,
|
||||||
|
gridManyToOne: observation.gridManyToOne,
|
||||||
|
foldSegments: observation.foldSegments.map((segment) => ({ ...segment })),
|
||||||
|
adcBins: [...observation.bins],
|
||||||
|
adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })),
|
||||||
|
adcBinSetKey: observation.adcBinSetKey,
|
||||||
|
equivalentBandIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createResultDocument(options) {
|
export function createResultDocument(options) {
|
||||||
const {
|
const {
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
@@ -266,8 +478,31 @@ export function createResultDocument(options) {
|
|||||||
} = options;
|
} = options;
|
||||||
const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
||||||
const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power";
|
const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power";
|
||||||
|
const sampling = samplingForResult(run.signalStats.sampling);
|
||||||
|
const frequencyObservations = new Map();
|
||||||
|
for (const definition of experiment.frequencyBands) {
|
||||||
|
const observation = frequencyObservationForResult(
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
definition,
|
||||||
|
algorithm,
|
||||||
|
);
|
||||||
|
if (observation) frequencyObservations.set(definition.id, observation);
|
||||||
|
}
|
||||||
|
const equivalentGroups = new Map();
|
||||||
|
for (const [bandId, observation] of frequencyObservations) {
|
||||||
|
const group = equivalentGroups.get(observation.adcBinSetKey) ?? [];
|
||||||
|
group.push(bandId);
|
||||||
|
equivalentGroups.set(observation.adcBinSetKey, group);
|
||||||
|
}
|
||||||
|
for (const [bandId, observation] of frequencyObservations) {
|
||||||
|
observation.equivalentBandIds = equivalentGroups
|
||||||
|
.get(observation.adcBinSetKey)
|
||||||
|
.filter((candidateId) => candidateId !== bandId)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
schemaVersion: "1.0.0",
|
schemaVersion: RESULT_SCHEMA_VERSION,
|
||||||
kind: "micarrsim.result",
|
kind: "micarrsim.result",
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
arrayId: arrayDefinition.id,
|
arrayId: arrayDefinition.id,
|
||||||
@@ -289,6 +524,7 @@ export function createResultDocument(options) {
|
|||||||
...(run.signalStats.acousticPathCounts
|
...(run.signalStats.acousticPathCounts
|
||||||
? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } }
|
? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(sampling ? { sampling } : {}),
|
||||||
},
|
},
|
||||||
timingMs: {
|
timingMs: {
|
||||||
total: run.timingMs.total,
|
total: run.timingMs.total,
|
||||||
@@ -297,27 +533,48 @@ export function createResultDocument(options) {
|
|||||||
beamforming: run.timingMs.beamforming ?? run.timingMs.localization,
|
beamforming: run.timingMs.beamforming ?? run.timingMs.localization,
|
||||||
},
|
},
|
||||||
environment: environmentForResult({ arrayDefinition, experiment, run }),
|
environment: environmentForResult({ arrayDefinition, experiment, run }),
|
||||||
|
...(algorithm === "music" && run.model
|
||||||
|
? { musicModel: sortForCanonicalJson(run.model) }
|
||||||
|
: {}),
|
||||||
bands: run.bands.map((band) => {
|
bands: run.bands.map((band) => {
|
||||||
const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} };
|
const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} };
|
||||||
|
const bandRawScale = band.rawScale ?? rawScale;
|
||||||
|
const objectiveDirection =
|
||||||
|
band.objectiveDirection ??
|
||||||
|
(bandRawScale === "noise-projection" ? "minimum" : "maximum");
|
||||||
const bandResult = {
|
const bandResult = {
|
||||||
id: band.id,
|
id: band.id,
|
||||||
color: band.color,
|
color: band.color,
|
||||||
rawMap: {
|
rawMap: {
|
||||||
rows: experiment.scan.rows,
|
rows: experiment.scan.rows,
|
||||||
columns: experiment.scan.columns,
|
columns: experiment.scan.columns,
|
||||||
scale: band.rawScale ?? rawScale,
|
scale: bandRawScale,
|
||||||
|
objectiveDirection,
|
||||||
values: Array.from(band.rawMap),
|
values: Array.from(band.rawMap),
|
||||||
},
|
},
|
||||||
displayMap: {
|
displayMap: {
|
||||||
rows: experiment.scan.rows,
|
rows: experiment.scan.rows,
|
||||||
columns: experiment.scan.columns,
|
columns: experiment.scan.columns,
|
||||||
scale: band.displayScale,
|
scale: band.displayScale,
|
||||||
|
objectiveDirection:
|
||||||
|
band.displayObjectiveDirection ?? objectiveDirection,
|
||||||
values: Array.from(band.displayMap),
|
values: Array.from(band.displayMap),
|
||||||
},
|
},
|
||||||
peaks: evaluation.peaks.map(peakForResult),
|
peaks: evaluation.peaks.map(peakForResult),
|
||||||
metrics: finiteMetricSubset(evaluation.metrics),
|
metrics: finiteMetricSubset(evaluation.metrics),
|
||||||
};
|
};
|
||||||
if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation };
|
const frequencyObservation = frequencyObservations.get(band.id);
|
||||||
|
if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation;
|
||||||
|
if (algorithm === "music" && band.diagnostics) {
|
||||||
|
bandResult.musicDiagnostics = sortForCanonicalJson(band.diagnostics);
|
||||||
|
}
|
||||||
|
if (evaluation.evaluation) {
|
||||||
|
bandResult.evaluation = {
|
||||||
|
...evaluation.evaluation,
|
||||||
|
truthSelection:
|
||||||
|
"tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources",
|
||||||
|
};
|
||||||
|
}
|
||||||
return bandResult;
|
return bandResult;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||||
import { sampleQuasiStaticWindow } from "/packages/acoustics-core/trajectory.js";
|
import { sampleQuasiStaticWindow } from "/packages/acoustics-core/trajectory.js";
|
||||||
import { createLocalizationAnalysisPlan } from "/packages/acoustics-core/localization.js";
|
import { createLocalizationAnalysisPlan } from "/packages/acoustics-core/localization.js";
|
||||||
|
import { localizationAlgorithmSupportsBackend } from "/packages/acoustics-core/localization-algorithms.js";
|
||||||
import { computeLocalizationWithBackend } from "/packages/webgpu-kernels/compute.js";
|
import { computeLocalizationWithBackend } from "/packages/webgpu-kernels/compute.js";
|
||||||
|
import { isMusicUnavailableError } from "/packages/acoustics-core/music.js";
|
||||||
|
import { getCompleteFrameCount } from "/packages/acoustics-core/stft.js";
|
||||||
|
import { analyzeCfarActivity } from "/packages/acoustics-core/activity-monitor.js";
|
||||||
|
import {
|
||||||
|
computeDixonSpectralFlux,
|
||||||
|
pickCausalSpectralFluxPeaks,
|
||||||
|
} from "/packages/acoustics-core/spectral-flux.js";
|
||||||
|
|
||||||
function post(type, jobId, payload = {}, transfer = []) {
|
function post(type, jobId, payload = {}, transfer = []) {
|
||||||
self.postMessage({ type, jobId, ...payload }, transfer);
|
self.postMessage({ type, jobId, ...payload }, transfer);
|
||||||
@@ -28,12 +36,267 @@ function signalStats(synthesis) {
|
|||||||
acousticModel: synthesis.acousticModel,
|
acousticModel: synthesis.acousticModel,
|
||||||
acousticPathCount: synthesis.acousticPathCount,
|
acousticPathCount: synthesis.acousticPathCount,
|
||||||
acousticPathCounts: synthesis.acousticPathCounts,
|
acousticPathCounts: synthesis.acousticPathCounts,
|
||||||
|
workingSet: synthesis.workingSet,
|
||||||
|
windowing: synthesis.windowing,
|
||||||
|
sampling: synthesis.sampling,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectBandTransfers(run, transfer) {
|
function collectBandTransfers(run, transfer) {
|
||||||
for (const band of run.bands) {
|
const knownBuffers = new Set(transfer);
|
||||||
transfer.push(band.rawMap.buffer, band.displayMap.buffer);
|
for (const band of run.bands ?? []) {
|
||||||
|
for (const map of [band.rawMap, band.displayMap]) {
|
||||||
|
const buffer = map?.buffer;
|
||||||
|
if (buffer instanceof ArrayBuffer && !knownBuffers.has(buffer)) {
|
||||||
|
knownBuffers.add(buffer);
|
||||||
|
transfer.push(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTypedArrayTransfers(value, transfer, seen = new Set(), buffers = new Set()) {
|
||||||
|
if (value === null || typeof value !== "object" || seen.has(value)) return;
|
||||||
|
seen.add(value);
|
||||||
|
if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
|
||||||
|
if (!buffers.has(value.buffer)) {
|
||||||
|
buffers.add(value.buffer);
|
||||||
|
transfer.push(value.buffer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) collectTypedArrayTransfers(item, transfer, seen, buffers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of Object.values(value)) {
|
||||||
|
collectTypedArrayTransfers(item, transfer, seen, buffers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activitySignalStats(synthesis, scene) {
|
||||||
|
const channel = synthesis.channels[0];
|
||||||
|
let selectedChannelPeak = 0;
|
||||||
|
for (const sample of channel) {
|
||||||
|
selectedChannelPeak = Math.max(selectedChannelPeak, Math.abs(sample));
|
||||||
|
}
|
||||||
|
const workingSetWithoutSourceIdentity = {
|
||||||
|
model: synthesis.workingSet.model,
|
||||||
|
channelBytes: synthesis.workingSet.channelBytes,
|
||||||
|
maximumTransientBytes: synthesis.workingSet.maximumTransientBytes,
|
||||||
|
maximumTransientScope: synthesis.workingSet.maximumTransientScope,
|
||||||
|
pathSummary: synthesis.workingSet.pathSummary,
|
||||||
|
summaryPathPairEvaluations:
|
||||||
|
synthesis.workingSet.summaryPathPairEvaluations,
|
||||||
|
synthesisPathBuildPairEvaluations:
|
||||||
|
synthesis.workingSet.synthesisPathBuildPairEvaluations,
|
||||||
|
retainedPathObjects: synthesis.workingSet.retainedPathObjects,
|
||||||
|
remainingNonToneTransformModel:
|
||||||
|
synthesis.workingSet.remainingNonToneTransformModel,
|
||||||
|
};
|
||||||
|
const windowingWithoutSourceTruth = {
|
||||||
|
model: synthesis.windowing.model,
|
||||||
|
renderStartSample: synthesis.windowing.renderStartSample,
|
||||||
|
outputStartSample: synthesis.windowing.outputStartSample,
|
||||||
|
outputSampleCount: synthesis.windowing.outputSampleCount,
|
||||||
|
preRollSamples: synthesis.windowing.preRollSamples,
|
||||||
|
renderMaximumPropagationDelaySeconds:
|
||||||
|
synthesis.windowing.renderMaximumPropagationDelaySeconds,
|
||||||
|
activePathMaximumDelaySeconds:
|
||||||
|
synthesis.windowing.activePathMaximumDelaySeconds,
|
||||||
|
requestedMaximumPropagationDelaySeconds:
|
||||||
|
synthesis.windowing.requestedMaximumPropagationDelaySeconds,
|
||||||
|
geometryModel: synthesis.windowing.geometryModel,
|
||||||
|
sourcePositionSampleTime: synthesis.windowing.sourcePositionSampleTime,
|
||||||
|
dopplerMode: synthesis.windowing.dopplerMode,
|
||||||
|
delayedTailPolicy: synthesis.windowing.delayedTailPolicy,
|
||||||
|
arrivalOverlapModel: synthesis.windowing.arrivalOverlapModel,
|
||||||
|
};
|
||||||
|
const peakNormalization = scene.mix?.normalization === "peak";
|
||||||
|
return {
|
||||||
|
preNormalizationPeak: synthesis.preNormalizationPeak,
|
||||||
|
appliedScale: synthesis.appliedScale,
|
||||||
|
clippedSampleCount: synthesis.clippedSampleCount,
|
||||||
|
selectedChannelPeak,
|
||||||
|
acousticModel: synthesis.acousticModel,
|
||||||
|
acousticPathCount: synthesis.acousticPathCount,
|
||||||
|
acousticPathCounts: synthesis.acousticPathCounts,
|
||||||
|
workingSet: workingSetWithoutSourceIdentity,
|
||||||
|
windowing: windowingWithoutSourceTruth,
|
||||||
|
preprocessing: {
|
||||||
|
configuredNormalization: scene.mix?.normalization ?? "none",
|
||||||
|
normalizationScope: peakNormalization
|
||||||
|
? "selected-physical-microphone-whole-render"
|
||||||
|
: "none",
|
||||||
|
futureDependentNormalization: peakNormalization,
|
||||||
|
detectorCausalAfterPreprocessing: true,
|
||||||
|
endToEndCausal: !peakNormalization,
|
||||||
|
},
|
||||||
|
samplingModel: "ideal-real-sampler-no-antialias-filter",
|
||||||
|
sampleRate: synthesis.sampleRate,
|
||||||
|
nyquistHz: synthesis.sampleRate / 2,
|
||||||
|
explicitSceneSourceIdentitiesAndArrivalsIncluded: false,
|
||||||
|
sourceDerivedAggregatePhysicalAuditIncluded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runActivity({
|
||||||
|
jobId,
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
experiment,
|
||||||
|
assetBuffers,
|
||||||
|
activityConfig,
|
||||||
|
}) {
|
||||||
|
if (!activityConfig || typeof activityConfig !== "object") {
|
||||||
|
throw new TypeError("activityConfig must be an object.");
|
||||||
|
}
|
||||||
|
const channelIndex = activityConfig.channelIndex;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(channelIndex) ||
|
||||||
|
channelIndex < 0 ||
|
||||||
|
channelIndex >= arrayDefinition.microphones.length
|
||||||
|
) {
|
||||||
|
throw new RangeError("activityConfig.channelIndex is outside the microphone array.");
|
||||||
|
}
|
||||||
|
const microphone = arrayDefinition.microphones[channelIndex];
|
||||||
|
const totalStartedAt = performance.now();
|
||||||
|
post("progress", jobId, {
|
||||||
|
stage: "activity-signal",
|
||||||
|
message: "전 대역 감시용 마이크 수신 신호를 합성하고 있습니다…",
|
||||||
|
});
|
||||||
|
const signalStartedAt = performance.now();
|
||||||
|
const selectedMicrophoneArray = {
|
||||||
|
...arrayDefinition,
|
||||||
|
microphones: [microphone],
|
||||||
|
};
|
||||||
|
const synthesis = synthesizeMicrophoneSignals({
|
||||||
|
scene,
|
||||||
|
arrayDefinition: selectedMicrophoneArray,
|
||||||
|
assetBuffers,
|
||||||
|
minimumDistance: experiment.beamforming.minimumDistance,
|
||||||
|
});
|
||||||
|
const signalGeneration = performance.now() - signalStartedAt;
|
||||||
|
const samples = synthesis.channels[0];
|
||||||
|
const detectorStartedAt = performance.now();
|
||||||
|
let analysis;
|
||||||
|
let peakPicking = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
activityConfig.algorithm === "ca-cfar" ||
|
||||||
|
activityConfig.algorithm === "os-cfar"
|
||||||
|
) {
|
||||||
|
analysis = await analyzeCfarActivity({
|
||||||
|
samples,
|
||||||
|
sampleRate: synthesis.sampleRate,
|
||||||
|
fftSize: experiment.stft.fftSize,
|
||||||
|
hopSize: experiment.stft.hopSize,
|
||||||
|
window: experiment.stft.window,
|
||||||
|
channelId: microphone.id,
|
||||||
|
detector: activityConfig.algorithm,
|
||||||
|
trainingCellsPerSide: activityConfig.trainingCellsPerSide,
|
||||||
|
guardCellsPerSide: activityConfig.guardCellsPerSide,
|
||||||
|
nominalFalseAlarmProbability:
|
||||||
|
activityConfig.nominalFalseAlarmProbability,
|
||||||
|
...(activityConfig.algorithm === "os-cfar"
|
||||||
|
? { rank: activityConfig.rank }
|
||||||
|
: {}),
|
||||||
|
onProgress: (progress) => post("progress", jobId, {
|
||||||
|
stage: progress.stage,
|
||||||
|
progress,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (activityConfig.algorithm === "dixon-spectral-flux") {
|
||||||
|
post("progress", jobId, {
|
||||||
|
stage: "activity-spectral-flux",
|
||||||
|
message: "Dixon spectral flux 원시 onset novelty를 계산하고 있습니다…",
|
||||||
|
});
|
||||||
|
analysis = computeDixonSpectralFlux({
|
||||||
|
samples,
|
||||||
|
sampleRate: synthesis.sampleRate,
|
||||||
|
fftSize: experiment.stft.fftSize,
|
||||||
|
hopSize: experiment.stft.hopSize,
|
||||||
|
channelId: microphone.id,
|
||||||
|
maximumOutputBytes: activityConfig.maximumOutputBytes,
|
||||||
|
});
|
||||||
|
if (activityConfig.peakPicker) {
|
||||||
|
peakPicking = pickCausalSpectralFluxPeaks(
|
||||||
|
analysis.rawOdf,
|
||||||
|
activityConfig.peakPicker,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new RangeError(
|
||||||
|
'activityConfig.algorithm must be "ca-cfar", "os-cfar", or "dixon-spectral-flux".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectorTime = performance.now() - detectorStartedAt;
|
||||||
|
const report = {
|
||||||
|
kind: "micarrsim.activity-worker-report/1",
|
||||||
|
algorithm: activityConfig.algorithm,
|
||||||
|
channel: {
|
||||||
|
index: channelIndex,
|
||||||
|
id: microphone.id,
|
||||||
|
position: [...microphone.position],
|
||||||
|
selection: "user-selected-single-physical-microphone",
|
||||||
|
microphoneFusion: "none",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
sampleRate: synthesis.sampleRate,
|
||||||
|
sampleCount: samples.length,
|
||||||
|
fftSize: experiment.stft.fftSize,
|
||||||
|
hopSize: experiment.stft.hopSize,
|
||||||
|
experimentWindow: experiment.stft.window,
|
||||||
|
adcRangeHz: [0, synthesis.sampleRate / 2],
|
||||||
|
analogAliasOrderResolved: false,
|
||||||
|
sceneSourceTruthUsedByPhysicalSimulator: true,
|
||||||
|
sceneSourceTruthPassedToDetector: false,
|
||||||
|
synthesizedMicrophoneCount: 1,
|
||||||
|
},
|
||||||
|
signalStats: activitySignalStats(synthesis, scene),
|
||||||
|
analysis,
|
||||||
|
...(peakPicking ? { peakPicking } : {}),
|
||||||
|
timingMs: {
|
||||||
|
total: performance.now() - totalStartedAt,
|
||||||
|
signalGeneration,
|
||||||
|
detector: detectorTime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const transfer = [];
|
||||||
|
collectTypedArrayTransfers(report, transfer);
|
||||||
|
post("activity-result", jobId, { activityReport: report }, transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeLocalizationOrUnavailable(options) {
|
||||||
|
try {
|
||||||
|
return await computeLocalizationWithBackend(options);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMusicUnavailableError(error)) throw error;
|
||||||
|
return {
|
||||||
|
algorithm: "music",
|
||||||
|
backend: "cpu",
|
||||||
|
status: "unavailable",
|
||||||
|
unavailable: {
|
||||||
|
code: error.code,
|
||||||
|
reason: error.reason,
|
||||||
|
message: error.message,
|
||||||
|
details: { ...error.details },
|
||||||
|
},
|
||||||
|
frameCount: getCompleteFrameCount(
|
||||||
|
options.channels[0]?.length ?? 0,
|
||||||
|
options.experiment.stft.fftSize,
|
||||||
|
options.experiment.stft.hopSize,
|
||||||
|
),
|
||||||
|
microphoneCount:
|
||||||
|
options.arrayDefinition.microphones.length,
|
||||||
|
pointCount:
|
||||||
|
options.experiment.scan.rows *
|
||||||
|
options.experiment.scan.columns,
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
rawScale: "noise-projection",
|
||||||
|
bands: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +314,9 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
const localizationScene = { speedOfSound: scene.speedOfSound };
|
const localizationScene = { speedOfSound: scene.speedOfSound };
|
||||||
const algorithm = experiment.beamforming.algorithm ?? "das";
|
const algorithm = experiment.beamforming.algorithm ?? "das";
|
||||||
const requestedBackend = experiment.beamforming.backend ?? "auto";
|
const requestedBackend = experiment.beamforming.backend ?? "auto";
|
||||||
const reuseCpuPlan = algorithm === "gcc-phat" || requestedBackend === "cpu";
|
const reuseCpuPlan =
|
||||||
|
!localizationAlgorithmSupportsBackend(algorithm, "webgpu") ||
|
||||||
|
requestedBackend === "cpu";
|
||||||
let localizationExperiment = experiment;
|
let localizationExperiment = experiment;
|
||||||
let analysisPlan = reuseCpuPlan
|
let analysisPlan = reuseCpuPlan
|
||||||
? createLocalizationAnalysisPlan({
|
? createLocalizationAnalysisPlan({
|
||||||
@@ -75,6 +340,11 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
scene,
|
scene,
|
||||||
plannedFrame.startTime,
|
plannedFrame.startTime,
|
||||||
plannedFrame.endTime,
|
plannedFrame.endTime,
|
||||||
|
{
|
||||||
|
arrayDefinition,
|
||||||
|
assetBuffers,
|
||||||
|
minimumDistance: experiment.beamforming.minimumDistance,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
post("progress", jobId, {
|
post("progress", jobId, {
|
||||||
stage: "timeline-signal",
|
stage: "timeline-signal",
|
||||||
@@ -97,7 +367,7 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
signalGeneration += frameSignalGeneration;
|
signalGeneration += frameSignalGeneration;
|
||||||
|
|
||||||
const localizationStartedAt = performance.now();
|
const localizationStartedAt = performance.now();
|
||||||
const localization = await computeLocalizationWithBackend({
|
const localization = await computeLocalizationOrUnavailable({
|
||||||
channels: synthesis.channels,
|
channels: synthesis.channels,
|
||||||
sampleRate: synthesis.sampleRate,
|
sampleRate: synthesis.sampleRate,
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
@@ -203,10 +473,18 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.addEventListener("message", async (event) => {
|
self.addEventListener("message", async (event) => {
|
||||||
if (event.data?.type !== "run" && event.data?.type !== "run-timeline") return;
|
if (
|
||||||
|
event.data?.type !== "run" &&
|
||||||
|
event.data?.type !== "run-timeline" &&
|
||||||
|
event.data?.type !== "detect-activity"
|
||||||
|
) return;
|
||||||
const { jobId, scene, arrayDefinition, experiment, assetBuffers } = event.data;
|
const { jobId, scene, arrayDefinition, experiment, assetBuffers } = event.data;
|
||||||
const totalStartedAt = performance.now();
|
const totalStartedAt = performance.now();
|
||||||
try {
|
try {
|
||||||
|
if (event.data.type === "detect-activity") {
|
||||||
|
await runActivity(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.data.type === "run-timeline") {
|
if (event.data.type === "run-timeline") {
|
||||||
await runTimeline(event.data);
|
await runTimeline(event.data);
|
||||||
return;
|
return;
|
||||||
@@ -226,7 +504,7 @@ self.addEventListener("message", async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const localizationStartedAt = performance.now();
|
const localizationStartedAt = performance.now();
|
||||||
const localization = await computeLocalizationWithBackend({
|
const localization = await computeLocalizationOrUnavailable({
|
||||||
channels: synthesis.channels,
|
channels: synthesis.channels,
|
||||||
sampleRate: synthesis.sampleRate,
|
sampleRate: synthesis.sampleRate,
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
describeFrequencyAliasing,
|
||||||
|
observeFrequencyBand,
|
||||||
|
} from "/packages/acoustics-core/sampling.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select evaluation truth sources for one observed band.
|
||||||
|
*
|
||||||
|
* Arrival ids come from synthesis path timing and take precedence over the
|
||||||
|
* legacy emission-time fallback. This helper is evaluation-only; localization
|
||||||
|
* never receives truth positions or ids.
|
||||||
|
*/
|
||||||
|
export function sourcesForObservedBand(
|
||||||
|
scene,
|
||||||
|
band,
|
||||||
|
fftSize,
|
||||||
|
options = {},
|
||||||
|
) {
|
||||||
|
const observation = observeFrequencyBand(band, scene.sampleRate, fftSize);
|
||||||
|
const observedBins = new Set(observation.bins);
|
||||||
|
const arrivingSourceIdSet = Array.isArray(options.arrivingSourceIds)
|
||||||
|
? new Set(options.arrivingSourceIds)
|
||||||
|
: null;
|
||||||
|
const time = options.time ?? null;
|
||||||
|
return scene.sources.filter((source) => {
|
||||||
|
if (arrivingSourceIdSet) {
|
||||||
|
if (!arrivingSourceIdSet.has(source.id)) return false;
|
||||||
|
} else if (time !== null) {
|
||||||
|
const startTime = source.startTime ?? 0;
|
||||||
|
const endTime = startTime + (source.duration ?? scene.duration);
|
||||||
|
if (time < startTime || time >= endTime) return false;
|
||||||
|
}
|
||||||
|
if (source.signal.type !== "tone") return true;
|
||||||
|
const analogInside =
|
||||||
|
source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz;
|
||||||
|
if (analogInside) return true;
|
||||||
|
const alias = describeFrequencyAliasing(source.signal.frequency, scene.sampleRate);
|
||||||
|
const nearestAdcBin = Math.round(alias.adcFrequencyHz / observation.binWidthHz);
|
||||||
|
return observedBins.has(nearestAdcBin);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -185,6 +185,7 @@
|
|||||||
}
|
}
|
||||||
.compact-section-heading h3, .array-heading h3 { margin: 0; font-size: .75rem; }
|
.compact-section-heading h3, .array-heading h3 { margin: 0; font-size: .75rem; }
|
||||||
.compact-section-heading > span { color: var(--muted); font-size: .61rem; }
|
.compact-section-heading > span { color: var(--muted); font-size: .61rem; }
|
||||||
|
.example-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
.compact-label, .mode-kicker { color: var(--mint); font: 750 .56rem ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .1em; }
|
.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; }
|
.builder-link { color: var(--cyan); font-size: .6rem; text-decoration: none; }
|
||||||
.source-summary, .asset-list { display: grid; gap: 6px; margin-top: 8px; }
|
.source-summary, .asset-list { display: grid; gap: 6px; margin-top: 8px; }
|
||||||
@@ -230,6 +231,24 @@
|
|||||||
.array-card > .field { margin-top: 9px; }
|
.array-card > .field { margin-top: 9px; }
|
||||||
.quick-field-grid, .advanced-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
.quick-field-grid, .advanced-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||||
.quick-field-grid { margin-top: 12px; }
|
.quick-field-grid { margin-top: 12px; }
|
||||||
|
.algorithm-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(101, 242, 191, .04);
|
||||||
|
}
|
||||||
|
.algorithm-options[hidden] { display: none; }
|
||||||
|
.algorithm-options p {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
.field--wide { grid-column: 1 / -1; }
|
.field--wide { grid-column: 1 / -1; }
|
||||||
.workload-summary {
|
.workload-summary {
|
||||||
margin: 9px 0 0;
|
margin: 9px 0 0;
|
||||||
@@ -240,6 +259,16 @@
|
|||||||
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
}
|
}
|
||||||
.workload-summary.is-warning { color: var(--amber); }
|
.workload-summary.is-warning { color: var(--amber); }
|
||||||
|
.sampling-warning {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid rgb(255 197 110 / 28%);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--amber);
|
||||||
|
background: rgb(255 197 110 / 7%);
|
||||||
|
font-size: .64rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
.acoustic-environment-summary {
|
.acoustic-environment-summary {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -271,6 +300,193 @@
|
|||||||
.band-editor input[type="color"] { padding: 3px; }
|
.band-editor input[type="color"] { padding: 3px; }
|
||||||
.band-editor__remove { border: 0; border-radius: 7px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; }
|
.band-editor__remove { border: 0; border-radius: 7px; color: var(--danger); background: rgb(255 123 115 / 8%); cursor: pointer; }
|
||||||
|
|
||||||
|
.activity-monitor__body {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.activity-monitor__copy,
|
||||||
|
.activity-monitor__note,
|
||||||
|
.activity-status,
|
||||||
|
.activity-result-empty {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: .6rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.activity-monitor__copy { color: var(--muted); }
|
||||||
|
.activity-field-grid,
|
||||||
|
.activity-option-grid,
|
||||||
|
.activity-actions {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.activity-option-grid {
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #0b1714;
|
||||||
|
}
|
||||||
|
.activity-option-grid[hidden] { display: none; }
|
||||||
|
.activity-checkbox {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .61rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.activity-checkbox input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--mint);
|
||||||
|
}
|
||||||
|
.activity-monitor__note {
|
||||||
|
padding: 8px 9px;
|
||||||
|
border-left: 2px solid rgb(88 199 232 / 45%);
|
||||||
|
border-radius: 0 7px 7px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgb(88 199 232 / 4%);
|
||||||
|
}
|
||||||
|
.activity-actions { margin-top: 1px; }
|
||||||
|
.activity-status {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #0b1714;
|
||||||
|
}
|
||||||
|
.activity-frame-control {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .59rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.activity-frame-control input { width: 100%; min-width: 0; accent-color: var(--cyan); }
|
||||||
|
.activity-result-list {
|
||||||
|
display: grid;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 140px;
|
||||||
|
max-height: min(52vh, 520px);
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #07110f;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.activity-result-empty { color: var(--muted); text-align: center; }
|
||||||
|
.activity-result-item {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0b1714;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.activity-message,
|
||||||
|
.activity-caveat,
|
||||||
|
.activity-frame-summary,
|
||||||
|
.activity-empty,
|
||||||
|
.activity-event-selected {
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: .6rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.activity-message,
|
||||||
|
.activity-frame-summary {
|
||||||
|
color: var(--text);
|
||||||
|
background: #0b1714;
|
||||||
|
}
|
||||||
|
.activity-message.is-stale {
|
||||||
|
color: var(--amber);
|
||||||
|
border: 1px solid rgb(255 197 110 / 24%);
|
||||||
|
}
|
||||||
|
.activity-caveat {
|
||||||
|
color: var(--muted);
|
||||||
|
border-left: 2px solid rgb(88 199 255 / 42%);
|
||||||
|
background: rgb(88 199 255 / 5%);
|
||||||
|
}
|
||||||
|
.activity-empty { color: var(--muted); text-align: center; }
|
||||||
|
.activity-event-selected {
|
||||||
|
color: var(--mint);
|
||||||
|
border: 1px solid rgb(101 242 191 / 24%);
|
||||||
|
background: rgb(101 242 191 / 5%);
|
||||||
|
}
|
||||||
|
.activity-result-row {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0b1714;
|
||||||
|
}
|
||||||
|
.activity-result-row > strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: .64rem;
|
||||||
|
}
|
||||||
|
.activity-result-row > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 550 .57rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.activity-hypothesis {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.activity-hypothesis > span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .56rem;
|
||||||
|
}
|
||||||
|
.activity-hypothesis input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #07110f;
|
||||||
|
font-size: .62rem;
|
||||||
|
}
|
||||||
|
.activity-event-button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #0b1714;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: left;
|
||||||
|
font: 550 .59rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.activity-event-button:hover { border-color: var(--cyan); }
|
||||||
|
|
||||||
details > summary { list-style: none; }
|
details > summary { list-style: none; }
|
||||||
details > summary::-webkit-details-marker { display: none; }
|
details > summary::-webkit-details-marker { display: none; }
|
||||||
.inline-details, .advanced-settings, .utility-details, .mode-help {
|
.inline-details, .advanced-settings, .utility-details, .mode-help {
|
||||||
@@ -441,22 +657,35 @@ html[data-run-state="running"] #snapshot-mode { border-color: var(--mint); box-s
|
|||||||
.header-status { grid-column: 1 / -1; }
|
.header-status { grid-column: 1 / -1; }
|
||||||
.run-status { max-width: 100%; }
|
.run-status { max-width: 100%; }
|
||||||
.workflow-strip { top: 88px; }
|
.workflow-strip { top: 88px; }
|
||||||
.input-file-grid, .quick-field-grid, .advanced-field-grid, .output-actions, .band-editor, .live-controls { grid-template-columns: 1fr; }
|
.input-file-grid, .quick-field-grid, .algorithm-options, .advanced-field-grid, .output-actions, .band-editor, .live-controls, .activity-field-grid, .activity-option-grid, .activity-actions { grid-template-columns: 1fr; }
|
||||||
.field--wide, .live-scrubber { grid-column: auto; }
|
.field--wide, .live-scrubber { grid-column: auto; }
|
||||||
.band-editor__remove { min-height: 32px; }
|
.band-editor__remove { min-height: 32px; }
|
||||||
.live-actions .button { flex: 1 1 calc(50% - 4px); }
|
.live-actions .button { flex: 1 1 calc(50% - 4px); }
|
||||||
.metric-grid { grid-template-columns: 1fr 1fr; }
|
.metric-grid { grid-template-columns: 1fr 1fr; }
|
||||||
.overlay-toolbar { justify-content: flex-start; }
|
.overlay-toolbar { justify-content: flex-start; }
|
||||||
|
.activity-hypothesis { grid-template-columns: 1fr; }
|
||||||
|
.activity-hypothesis > span { grid-column: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@container camera-controls (max-width: 350px) {
|
@container camera-controls (max-width: 350px) {
|
||||||
.input-file-grid,
|
.input-file-grid,
|
||||||
.quick-field-grid,
|
.quick-field-grid,
|
||||||
.advanced-field-grid,
|
.advanced-field-grid,
|
||||||
.output-actions {
|
.output-actions,
|
||||||
|
.activity-field-grid,
|
||||||
|
.activity-option-grid,
|
||||||
|
.activity-actions {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-hypothesis {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-hypothesis > span {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.field--wide {
|
.field--wide {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizedMusicPseudospectrum } from "/packages/acoustics-core/evaluation.js";
|
||||||
|
|
||||||
const DEFAULT_BACKGROUND = "#07110f";
|
const DEFAULT_BACKGROUND = "#07110f";
|
||||||
const DEFAULT_DYNAMIC_RANGE_DB = 48;
|
const DEFAULT_DYNAMIC_RANGE_DB = 48;
|
||||||
const MAX_PIXEL_RATIO = 3;
|
const MAX_PIXEL_RATIO = 3;
|
||||||
@@ -269,11 +271,36 @@ function resolveBandMap(band, scan) {
|
|||||||
const rows = map.rows ?? scan.rows;
|
const rows = map.rows ?? scan.rows;
|
||||||
const columns = map.columns ?? scan.columns;
|
const columns = map.columns ?? scan.columns;
|
||||||
if (rows !== scan.rows || columns !== scan.columns || values.length !== rows * columns) return null;
|
if (rows !== scan.rows || columns !== scan.columns || values.length !== rows * columns) return null;
|
||||||
|
const scale = map.scale ?? band.displayScale ?? band.rawScale ?? "power";
|
||||||
|
const objectiveDirection =
|
||||||
|
map.objectiveDirection ??
|
||||||
|
band.objectiveDirection ??
|
||||||
|
(scale === "noise-projection" ? "minimum" : "maximum");
|
||||||
|
if (objectiveDirection === "minimum") {
|
||||||
|
let pseudospectrum;
|
||||||
|
try {
|
||||||
|
pseudospectrum = normalizedMusicPseudospectrum(values);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
values: pseudospectrum.values,
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
|
scale: "score",
|
||||||
|
sourceScale: scale,
|
||||||
|
objectiveDirection,
|
||||||
|
displayTransform: pseudospectrum.transform,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
values,
|
values,
|
||||||
rows,
|
rows,
|
||||||
columns,
|
columns,
|
||||||
scale: map.scale ?? band.displayScale ?? "power",
|
scale,
|
||||||
|
sourceScale: scale,
|
||||||
|
objectiveDirection,
|
||||||
|
displayTransform: "identity",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Array Builder는 `micarrsim.array` JSON을 브라우저에서 만드는 마이크 배열 편집기입니다. aperture
|
Array Builder는 `micarrsim.array` JSON을 브라우저에서 만드는 마이크 배열 편집기입니다. aperture
|
||||||
크기와 생성 경계를 정한 뒤 프리셋으로 시작하거나 마이크를 직접 추가·이동해 Acoustic Camera의
|
크기와 생성 경계를 정한 뒤 프리셋으로 시작하거나 마이크를 직접 추가·이동해 Acoustic Camera의
|
||||||
배열 입력으로 사용할 수 있습니다.
|
배열 입력으로 사용할 수 있습니다. 같은 화면에서 현재 좌표의 narrowband far-field 수신 빔 패턴도
|
||||||
|
확인할 수 있습니다.
|
||||||
|
|
||||||
## 열기
|
## 열기
|
||||||
|
|
||||||
@@ -15,8 +16,9 @@ Array Builder는 `micarrsim.array` JSON을 브라우저에서 만드는 마이
|
|||||||
1. **크기와 형상**에서 aperture 너비·높이와 생성 경계를 정합니다.
|
1. **크기와 형상**에서 aperture 너비·높이와 생성 경계를 정합니다.
|
||||||
2. 프리셋을 고르고 **현재 배열을 프리셋으로 교체**를 누릅니다.
|
2. 프리셋을 고르고 **현재 배열을 프리셋으로 교체**를 누릅니다.
|
||||||
3. canvas와 마이크 목록에서 좌표와 채널 순서를 다듬습니다.
|
3. canvas와 마이크 목록에서 좌표와 채널 순서를 다듬습니다.
|
||||||
4. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다.
|
4. canvas 아래 **수신 빔 패턴**에서 주파수·절단면·조향각에 따른 main lobe와 동률 응답을 확인합니다.
|
||||||
5. Acoustic Camera를 열어 **Array** 입력에서 저장한 `.array.json`을 선택합니다.
|
5. 상태가 **Array JSON 저장 가능**인지 확인하고 **Array JSON 저장**을 누릅니다.
|
||||||
|
6. Acoustic Camera를 열어 **Array** 입력에서 저장한 `.array.json`을 선택합니다.
|
||||||
|
|
||||||
## 크기와 생성 경계
|
## 크기와 생성 경계
|
||||||
|
|
||||||
@@ -65,6 +67,32 @@ Array JSON을 저장하세요.
|
|||||||
`JSON 열기`로 다른 배열을 불러온 직후에도 한 번 실행 취소하면 불러오기 전 문서를 복구할 수
|
`JSON 열기`로 다른 배열을 불러온 직후에도 한 번 실행 취소하면 불러오기 전 문서를 복구할 수
|
||||||
있습니다. 다시 실행하면 불러온 배열로 돌아갑니다.
|
있습니다. 다시 실행하면 불러온 배열로 돌아갑니다.
|
||||||
|
|
||||||
|
## 수신 빔 패턴
|
||||||
|
|
||||||
|
배열 canvas 아래의 분석 카드는 ideal calibrated omnidirectional point microphone에 conventional
|
||||||
|
delay-and-sum을 적용한 geometry-only receive array factor를 표시합니다.
|
||||||
|
|
||||||
|
- **아날로그 주파수:** ADC bin이나 alias 주파수가 아닌 실제 음향 주파수입니다. 1 MHz까지
|
||||||
|
입력할 수 있으며 sample rate가 없으므로 Nyquist를 넘어도 원래 파장을 사용합니다.
|
||||||
|
- **절단면:** XZ는 0°=+Z·90°=+X, YZ는 0°=+Z·90°=+Y, XY는 0°=+X·90°=+Y입니다.
|
||||||
|
- **조향각:** 선택한 360° great-circle 단면 안에서 conventional beamformer가 보는 방향입니다.
|
||||||
|
- **각도 샘플:** 자동은 투영 aperture와 파장에 맞춰 최소 720개에서 늘립니다. 고정 sample은
|
||||||
|
계산량 비교나 일부러 coarse한 설계 조건을 확인할 때 사용합니다.
|
||||||
|
- **표시 하한:** polar dB radius에만 적용됩니다. raw complex·power와 HPBW는 바뀌지 않습니다.
|
||||||
|
|
||||||
|
마이크를 드래그하는 동안 최대 720-angle preview를 갱신하고 놓으면 선택한 해상도로 다시 계산합니다.
|
||||||
|
π/8 인접 pair phase-step 기준을 넘으면 raw polar 선은 유지하되 HPBW와 peak 해석을 **해상도
|
||||||
|
부족**으로 보류합니다. 기준을 만족한다는 것은 공개된 sampling 정책을 통과했다는 뜻이며 모든 연속
|
||||||
|
lobe 검출을 보장하지 않습니다.
|
||||||
|
|
||||||
|
−3.0103 dB 폭은 조향 lobe의 sampled bracket을 찾은 경우에만 표시합니다. sampled local maximum은
|
||||||
|
모두 나열하고 0 dB 동률을 임의로 하나 고르지 않지만, 이를 grating lobe나 sidelobe로 자동 분류하지
|
||||||
|
않습니다. plot은 3D 전체가 아니라 선택한 단면이고 near-field, 방·하우징·element directivity와
|
||||||
|
마이크 frequency response는 포함하지 않습니다.
|
||||||
|
|
||||||
|
수식, raw 결과 필드, workload 상한과 상태 의미는
|
||||||
|
[`../../docs/beam-pattern.md`](../../docs/beam-pattern.md)에 정리되어 있습니다.
|
||||||
|
|
||||||
## 보정값 보존
|
## 보정값 보존
|
||||||
|
|
||||||
선택한 채널의 `sensitivityDb`와 `phaseOffsetDeg`는 보정값 펼침 영역에서 편집할 수 있습니다. 기존
|
선택한 채널의 `sensitivityDb`와 `phaseOffsetDeg`는 보정값 펼침 영역에서 편집할 수 있습니다. 기존
|
||||||
@@ -84,8 +112,8 @@ frequency response도 함께 제거됩니다.
|
|||||||
- 채널별 선택적 sensitivity, phase와 frequency-response curve
|
- 채널별 선택적 sensitivity, phase와 frequency-response curve
|
||||||
|
|
||||||
생성 경계의 사각형/타원 선택, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백, snap과
|
생성 경계의 사각형/타원 선택, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백, snap과
|
||||||
zoom은 편집기 설정입니다. 표준 Array JSON에는 저장되지 않으며 Camera는 최종 aperture와 실제
|
zoom, 빔 패턴 주파수·절단면·조향각·표본 수는 편집기 설정입니다. 표준 Array JSON에는 저장되지
|
||||||
마이크 좌표·순서를 사용합니다.
|
않으며 Camera는 최종 aperture와 실제 마이크 좌표·순서를 사용합니다.
|
||||||
|
|
||||||
## Camera로 전달하기
|
## Camera로 전달하기
|
||||||
|
|
||||||
@@ -94,8 +122,9 @@ zoom은 편집기 설정입니다. 표준 Array JSON에는 저장되지 않으
|
|||||||
3. 입력 준비의 **Array**에서 저장한 파일을 선택합니다.
|
3. 입력 준비의 **Array**에서 저장한 파일을 선택합니다.
|
||||||
4. 채널 수와 aperture 요약을 확인한 뒤 Scene과 Experiment를 준비해 실행합니다.
|
4. 채널 수와 aperture 요약을 확인한 뒤 Scene과 Experiment를 준비해 실행합니다.
|
||||||
|
|
||||||
Experiment를 별도 파일로 가져올 때는 그 문서의 `arrayId`가 방금 불러온 Array ID와 같아야 합니다.
|
Experiment의 `arrayId`는 provenance label이라 불러온 Array의 `id`와 달라도 실행을 막거나
|
||||||
Camera에서 배열 선택이 **불러온 Array JSON**으로 바뀌고 입력 검증이 통과했는지 확인하세요.
|
경고하지 않습니다. Camera에서 배열 선택이 **불러온 Array JSON**으로 바뀌고 채널 수·좌표가 의도한
|
||||||
|
배열인지 직접 확인하세요.
|
||||||
|
|
||||||
## 검증과 제한
|
## 검증과 제한
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||||
|
const VIEWBOX_SIZE = 640;
|
||||||
|
const CENTER = VIEWBOX_SIZE / 2;
|
||||||
|
const PLOT_RADIUS = 244;
|
||||||
|
|
||||||
|
let viewSequence = 0;
|
||||||
|
|
||||||
|
function setAttributes(element, attributes) {
|
||||||
|
for (const [name, value] of Object.entries(attributes)) {
|
||||||
|
element.setAttribute(name, String(value));
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericSequence(value) {
|
||||||
|
return Array.isArray(value) || ArrayBuffer.isView(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDb(value) {
|
||||||
|
const rounded = Math.round(value * 10) / 10;
|
||||||
|
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCut(cut) {
|
||||||
|
const normalized = String(cut ?? "").trim().toUpperCase();
|
||||||
|
if (normalized !== "XY" && normalized !== "XZ" && normalized !== "YZ") {
|
||||||
|
throw new TypeError("cut은 XY, XZ 또는 YZ여야 합니다.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAxisLabels(cut) {
|
||||||
|
if (cut === "XY") {
|
||||||
|
return { right: "+X", top: "+Y", left: "−X", bottom: "−Y" };
|
||||||
|
}
|
||||||
|
if (cut === "XZ") {
|
||||||
|
return { right: "+Z", top: "+X", left: "−Z", bottom: "−X" };
|
||||||
|
}
|
||||||
|
return { right: "+Z", top: "+Y", left: "−Z", bottom: "−Y" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function polarPoint(angleDeg, radius) {
|
||||||
|
const radians = (angleDeg * Math.PI) / 180;
|
||||||
|
return {
|
||||||
|
x: CENTER + radius * Math.cos(radians),
|
||||||
|
y: CENTER - radius * Math.sin(radians),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePattern(pattern) {
|
||||||
|
if (!pattern || typeof pattern !== "object") {
|
||||||
|
throw new TypeError("빔 패턴 결과가 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { displayDb, anglesDeg } = pattern;
|
||||||
|
if (!isNumericSequence(displayDb) || !isNumericSequence(anglesDeg)) {
|
||||||
|
throw new TypeError("displayDb와 anglesDeg는 수치 배열이어야 합니다.");
|
||||||
|
}
|
||||||
|
if (displayDb.length === 0 || displayDb.length !== anglesDeg.length) {
|
||||||
|
throw new RangeError("displayDb와 anglesDeg는 같은 길이의 비어 있지 않은 배열이어야 합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbFloor = Number(pattern.dbFloor);
|
||||||
|
const steeringAngleDeg = Number(pattern.steeringAngleDeg);
|
||||||
|
if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
|
||||||
|
throw new RangeError("dbFloor는 0보다 작은 유한한 값이어야 합니다.");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(steeringAngleDeg)) {
|
||||||
|
throw new RangeError("steeringAngleDeg는 유한한 값이어야 합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < displayDb.length; index += 1) {
|
||||||
|
if (!Number.isFinite(Number(displayDb[index])) || !Number.isFinite(Number(anglesDeg[index]))) {
|
||||||
|
throw new RangeError("빔 패턴 표본에는 유한한 수치만 사용할 수 있습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayDb,
|
||||||
|
anglesDeg,
|
||||||
|
dbFloor,
|
||||||
|
steeringAngleDeg,
|
||||||
|
cut: normalizeCut(pattern.cut),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core 수신 빔 패턴 결과를 log-dB polar SVG로 표시한다.
|
||||||
|
* 입력 표본은 재정렬, 평활화 또는 peak 정규화하지 않는다.
|
||||||
|
*/
|
||||||
|
export function createReceiveBeamPatternView(svg) {
|
||||||
|
if (
|
||||||
|
!svg ||
|
||||||
|
String(svg.localName).toLowerCase() !== "svg" ||
|
||||||
|
typeof svg.replaceChildren !== "function" ||
|
||||||
|
!svg.ownerDocument
|
||||||
|
) {
|
||||||
|
throw new TypeError("createReceiveBeamPatternView에는 SVG 요소가 필요합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = svg.ownerDocument;
|
||||||
|
const idPrefix = `receive-beam-pattern-${++viewSequence}`;
|
||||||
|
const titleId = `${idPrefix}-title`;
|
||||||
|
const descriptionId = `${idPrefix}-description`;
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`);
|
||||||
|
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||||
|
svg.setAttribute("role", "img");
|
||||||
|
svg.setAttribute("aria-labelledby", `${titleId} ${descriptionId}`);
|
||||||
|
|
||||||
|
function createElement(name, attributes = {}) {
|
||||||
|
return setAttributes(document.createElementNS(SVG_NAMESPACE, name), attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccessibilityNodes(titleText, descriptionText) {
|
||||||
|
const title = createElement("title", { id: titleId });
|
||||||
|
title.textContent = titleText;
|
||||||
|
const description = createElement("desc", { id: descriptionId });
|
||||||
|
description.textContent = descriptionText;
|
||||||
|
return [title, description];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createText(text, attributes = {}) {
|
||||||
|
const node = createElement("text", attributes);
|
||||||
|
node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(message) {
|
||||||
|
if (destroyed) return;
|
||||||
|
|
||||||
|
const errorMessage = String(message || "빔 패턴을 표시할 수 없습니다.");
|
||||||
|
const [title, description] = createAccessibilityNodes(
|
||||||
|
"수신 빔 패턴을 표시할 수 없음",
|
||||||
|
errorMessage,
|
||||||
|
);
|
||||||
|
const errorText = createText(errorMessage, {
|
||||||
|
x: CENTER,
|
||||||
|
y: CENTER,
|
||||||
|
class: "beam-pattern-error",
|
||||||
|
"text-anchor": "middle",
|
||||||
|
"dominant-baseline": "middle",
|
||||||
|
fill: "currentColor",
|
||||||
|
});
|
||||||
|
svg.replaceChildren(title, description, errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(pattern) {
|
||||||
|
if (destroyed) return;
|
||||||
|
|
||||||
|
let validated;
|
||||||
|
try {
|
||||||
|
validated = validatePattern(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
renderError(error instanceof Error ? error.message : String(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { displayDb, anglesDeg, dbFloor, steeringAngleDeg, cut } = validated;
|
||||||
|
const [title, description] = createAccessibilityNodes(
|
||||||
|
`${cut} 수신 빔 패턴`,
|
||||||
|
`${cut} 평면, ${displayDb.length}개 원시 각도 표본, 표시 하한 ${formatDb(dbFloor)}, 조향각 ${steeringAngleDeg}도. 0도는 오른쪽, 양의 90도는 위쪽입니다.`,
|
||||||
|
);
|
||||||
|
const plot = createElement("g", { class: "beam-pattern-plot" });
|
||||||
|
const grid = createElement("g", {
|
||||||
|
class: "beam-pattern-grid",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
"stroke-opacity": "0.24",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let index = 1; index <= 4; index += 1) {
|
||||||
|
const fraction = index / 5;
|
||||||
|
const radius = PLOT_RADIUS * fraction;
|
||||||
|
const level = dbFloor + (0 - dbFloor) * fraction;
|
||||||
|
grid.append(
|
||||||
|
createElement("circle", {
|
||||||
|
cx: CENTER,
|
||||||
|
cy: CENTER,
|
||||||
|
r: radius,
|
||||||
|
class: "beam-pattern-ring",
|
||||||
|
"vector-effect": "non-scaling-stroke",
|
||||||
|
}),
|
||||||
|
createText(formatDb(level), {
|
||||||
|
x: CENTER + 6,
|
||||||
|
y: CENTER - radius + 16,
|
||||||
|
class: "beam-pattern-ring-label",
|
||||||
|
fill: "currentColor",
|
||||||
|
"fill-opacity": "0.68",
|
||||||
|
stroke: "none",
|
||||||
|
"font-size": "13",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.append(
|
||||||
|
createElement("circle", {
|
||||||
|
cx: CENTER,
|
||||||
|
cy: CENTER,
|
||||||
|
r: PLOT_RADIUS,
|
||||||
|
class: "beam-pattern-ring beam-pattern-ring-outer",
|
||||||
|
"vector-effect": "non-scaling-stroke",
|
||||||
|
}),
|
||||||
|
createElement("line", {
|
||||||
|
x1: CENTER - PLOT_RADIUS,
|
||||||
|
y1: CENTER,
|
||||||
|
x2: CENTER + PLOT_RADIUS,
|
||||||
|
y2: CENTER,
|
||||||
|
class: "beam-pattern-axis",
|
||||||
|
"vector-effect": "non-scaling-stroke",
|
||||||
|
}),
|
||||||
|
createElement("line", {
|
||||||
|
x1: CENTER,
|
||||||
|
y1: CENTER - PLOT_RADIUS,
|
||||||
|
x2: CENTER,
|
||||||
|
y2: CENTER + PLOT_RADIUS,
|
||||||
|
class: "beam-pattern-axis",
|
||||||
|
"vector-effect": "non-scaling-stroke",
|
||||||
|
}),
|
||||||
|
createText("0 dB", {
|
||||||
|
x: CENTER + 6,
|
||||||
|
y: CENTER - PLOT_RADIUS + 16,
|
||||||
|
class: "beam-pattern-ring-label beam-pattern-ring-label-outer",
|
||||||
|
fill: "currentColor",
|
||||||
|
"fill-opacity": "0.8",
|
||||||
|
stroke: "none",
|
||||||
|
"font-size": "13",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
plot.append(grid);
|
||||||
|
|
||||||
|
const labels = getAxisLabels(cut);
|
||||||
|
const axisLabels = createElement("g", {
|
||||||
|
class: "beam-pattern-axis-labels",
|
||||||
|
fill: "currentColor",
|
||||||
|
"font-size": "17",
|
||||||
|
"font-weight": "600",
|
||||||
|
});
|
||||||
|
axisLabels.append(
|
||||||
|
createText(labels.right, {
|
||||||
|
x: CENTER + PLOT_RADIUS + 20,
|
||||||
|
y: CENTER,
|
||||||
|
"text-anchor": "start",
|
||||||
|
"dominant-baseline": "middle",
|
||||||
|
}),
|
||||||
|
createText(labels.top, {
|
||||||
|
x: CENTER,
|
||||||
|
y: CENTER - PLOT_RADIUS - 20,
|
||||||
|
"text-anchor": "middle",
|
||||||
|
"dominant-baseline": "auto",
|
||||||
|
}),
|
||||||
|
createText(labels.left, {
|
||||||
|
x: CENTER - PLOT_RADIUS - 20,
|
||||||
|
y: CENTER,
|
||||||
|
"text-anchor": "end",
|
||||||
|
"dominant-baseline": "middle",
|
||||||
|
}),
|
||||||
|
createText(labels.bottom, {
|
||||||
|
x: CENTER,
|
||||||
|
y: CENTER + PLOT_RADIUS + 28,
|
||||||
|
"text-anchor": "middle",
|
||||||
|
"dominant-baseline": "hanging",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
plot.append(axisLabels);
|
||||||
|
|
||||||
|
const steeringEnd = polarPoint(steeringAngleDeg, PLOT_RADIUS);
|
||||||
|
const steering = createElement("g", { class: "beam-pattern-steering" });
|
||||||
|
steering.append(
|
||||||
|
createElement("line", {
|
||||||
|
x1: CENTER,
|
||||||
|
y1: CENTER,
|
||||||
|
x2: steeringEnd.x,
|
||||||
|
y2: steeringEnd.y,
|
||||||
|
class: "beam-pattern-steering-ray",
|
||||||
|
stroke: "currentColor",
|
||||||
|
"stroke-opacity": "0.72",
|
||||||
|
"stroke-dasharray": "8 6",
|
||||||
|
"vector-effect": "non-scaling-stroke",
|
||||||
|
}),
|
||||||
|
createElement("circle", {
|
||||||
|
cx: steeringEnd.x,
|
||||||
|
cy: steeringEnd.y,
|
||||||
|
r: 5,
|
||||||
|
class: "beam-pattern-steering-point",
|
||||||
|
fill: "currentColor",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
plot.append(steering);
|
||||||
|
|
||||||
|
const pathCommands = [];
|
||||||
|
for (let index = 0; index < displayDb.length; index += 1) {
|
||||||
|
const radius = PLOT_RADIUS * ((Number(displayDb[index]) - dbFloor) / (0 - dbFloor));
|
||||||
|
const point = polarPoint(Number(anglesDeg[index]), radius);
|
||||||
|
pathCommands.push(`${index === 0 ? "M" : "L"}${point.x} ${point.y}`);
|
||||||
|
}
|
||||||
|
pathCommands.push("Z");
|
||||||
|
|
||||||
|
plot.append(
|
||||||
|
createElement("path", {
|
||||||
|
d: pathCommands.join(" "),
|
||||||
|
class: "beam-pattern-response",
|
||||||
|
fill: "currentColor",
|
||||||
|
"fill-opacity": "0.12",
|
||||||
|
stroke: "currentColor",
|
||||||
|
"stroke-width": "2.25",
|
||||||
|
"stroke-linejoin": "round",
|
||||||
|
"vector-effect": "non-scaling-stroke",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
svg.replaceChildren(title, description, plot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
svg.replaceChildren();
|
||||||
|
if (svg.getAttribute("aria-labelledby") === `${titleId} ${descriptionId}`) {
|
||||||
|
svg.removeAttribute("aria-labelledby");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { render, renderError, destroy };
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
JSON 열기
|
JSON 열기
|
||||||
<input id="array-input" type="file" accept="application/json,.json" />
|
<input id="array-input" type="file" accept="application/json,.json" />
|
||||||
</label>
|
</label>
|
||||||
<a class="button button--ghost" href="/apps/acoustic-camera/">Camera 열기</a>
|
<a class="button button--ghost" href="/apps/acoustic-camera/" title="새 Camera 세션을 엽니다. 기존 Camera에서 편집을 시작했다면 원래 Camera 탭으로 돌아가 Array JSON을 선택하세요.">새 Camera 열기</a>
|
||||||
<button class="button button--primary" id="export-button" type="button">Array JSON 저장</button>
|
<button class="button button--primary" id="export-button" type="button">Array JSON 저장</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -90,6 +90,122 @@
|
|||||||
<article class="metric-card"><span>보정 채널</span><strong id="calibration-count">0</strong><small>sensitivity / phase / response</small></article>
|
<article class="metric-card"><span>보정 채널</span><strong id="calibration-count">0</strong><small>sensitivity / phase / response</small></article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<details class="beam-pattern-card" id="beam-pattern-panel" open>
|
||||||
|
<summary class="beam-pattern-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">배열 분석</p>
|
||||||
|
<h2>수신 빔 패턴</h2>
|
||||||
|
</div>
|
||||||
|
<span id="beam-pattern-summary">1 kHz · XZ · +Z 조향</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="beam-pattern-body">
|
||||||
|
<div class="beam-pattern-controls">
|
||||||
|
<p class="beam-pattern-model">
|
||||||
|
Narrowband far-field conventional delay-and-sum
|
||||||
|
<span>geometry-only</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="beam-pattern-fields">
|
||||||
|
<label class="field">
|
||||||
|
<span>아날로그 주파수 (Hz)</span>
|
||||||
|
<input
|
||||||
|
id="beam-pattern-frequency"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="1000000"
|
||||||
|
step="100"
|
||||||
|
value="1000"
|
||||||
|
inputmode="decimal"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>음속 (m/s)</span>
|
||||||
|
<input
|
||||||
|
id="beam-pattern-speed-of-sound"
|
||||||
|
type="number"
|
||||||
|
min="250"
|
||||||
|
max="400"
|
||||||
|
step="0.1"
|
||||||
|
value="343"
|
||||||
|
inputmode="decimal"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>360° 절단면</span>
|
||||||
|
<select id="beam-pattern-cut">
|
||||||
|
<option value="xz">XZ · 0° +Z / 90° +X</option>
|
||||||
|
<option value="yz">YZ · 0° +Z / 90° +Y</option>
|
||||||
|
<option value="xy">XY · 0° +X / 90° +Y</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>조향각 (deg)</span>
|
||||||
|
<input
|
||||||
|
id="beam-pattern-steering-angle"
|
||||||
|
type="number"
|
||||||
|
min="-360"
|
||||||
|
max="360"
|
||||||
|
step="0.5"
|
||||||
|
value="0"
|
||||||
|
inputmode="decimal"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>각도 샘플</span>
|
||||||
|
<select id="beam-pattern-samples">
|
||||||
|
<option value="auto">자동 · 위상 간격 기준</option>
|
||||||
|
<option value="360">360 · 1°</option>
|
||||||
|
<option value="720">720 · 0.5°</option>
|
||||||
|
<option value="1440">1,440 · 0.25°</option>
|
||||||
|
<option value="2880">2,880 · 0.125°</option>
|
||||||
|
<option value="5760">5,760 · 0.0625°</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>표시 하한 (dB)</span>
|
||||||
|
<select id="beam-pattern-floor">
|
||||||
|
<option value="-30">−30 dB</option>
|
||||||
|
<option value="-40">−40 dB</option>
|
||||||
|
<option value="-60" selected>−60 dB</option>
|
||||||
|
<option value="-80">−80 dB</option>
|
||||||
|
<option value="-100">−100 dB</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="beam-pattern-metrics" aria-label="빔 패턴 수치">
|
||||||
|
<article><span>파장</span><strong id="beam-pattern-wavelength">—</strong></article>
|
||||||
|
<article><span>각도 해상도</span><strong id="beam-pattern-resolution">—</strong></article>
|
||||||
|
<article><span>조향 응답</span><strong id="beam-pattern-steering-response">—</strong></article>
|
||||||
|
<article><span>−3.0103 dB 폭</span><strong id="beam-pattern-hpbw">—</strong></article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="beam-pattern-status" id="beam-pattern-status" role="status" aria-live="polite">
|
||||||
|
현재 배열의 수신 array factor를 계산하고 있습니다.
|
||||||
|
</p>
|
||||||
|
<p class="beam-pattern-note" id="beam-pattern-model-note">
|
||||||
|
이상적으로 보정된 무지향성 점 마이크의 자유장 평면파 단면입니다. 3D 전체 패턴,
|
||||||
|
근거리, 방·하우징, 마이크 주파수 응답은 포함하지 않습니다. ADC가 없으므로
|
||||||
|
Nyquist 초과 주파수도 접지 않고 원래 파장으로 계산합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure class="beam-pattern-figure">
|
||||||
|
<svg
|
||||||
|
id="beam-pattern-plot"
|
||||||
|
viewBox="0 0 520 520"
|
||||||
|
role="img"
|
||||||
|
aria-label="수신 빔 패턴 polar plot"
|
||||||
|
aria-describedby="beam-pattern-status beam-pattern-model-note beam-pattern-peaks"
|
||||||
|
></svg>
|
||||||
|
<figcaption id="beam-pattern-peaks">
|
||||||
|
국소 최대를 계산하고 있습니다.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="shortcut-help">
|
<details class="shortcut-help">
|
||||||
<summary>빠른 편집 키</summary>
|
<summary>빠른 편집 키</summary>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
||||||
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
import { validateArrayDefinition } from "/packages/contracts/validation.js";
|
||||||
|
import { computeReceiveBeamPattern } from "/packages/acoustics-core/beam-pattern.js";
|
||||||
import { createSplitPane } from "/packages/ui/split-pane.js";
|
import { createSplitPane } from "/packages/ui/split-pane.js";
|
||||||
import { createArrayCanvas } from "./canvas-view.js";
|
import { createArrayCanvas } from "./canvas-view.js";
|
||||||
|
import { createReceiveBeamPatternView } from "./beam-pattern-view.js";
|
||||||
import {
|
import {
|
||||||
addMicrophone,
|
addMicrophone,
|
||||||
arrayGeometryIssues,
|
arrayGeometryIssues,
|
||||||
@@ -38,6 +40,13 @@ const requiredIds = [
|
|||||||
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
|
"snap-enabled", "snap-step", "microphone-list", "microphone-id",
|
||||||
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
|
"microphone-x", "microphone-y", "microphone-z", "add-microphone-button",
|
||||||
"remove-microphone-button", "validation-title", "validation-message",
|
"remove-microphone-button", "validation-title", "validation-message",
|
||||||
|
"beam-pattern-panel", "beam-pattern-frequency",
|
||||||
|
"beam-pattern-speed-of-sound", "beam-pattern-cut",
|
||||||
|
"beam-pattern-steering-angle", "beam-pattern-samples",
|
||||||
|
"beam-pattern-floor", "beam-pattern-plot", "beam-pattern-status",
|
||||||
|
"beam-pattern-summary", "beam-pattern-wavelength",
|
||||||
|
"beam-pattern-resolution", "beam-pattern-steering-response",
|
||||||
|
"beam-pattern-hpbw", "beam-pattern-peaks",
|
||||||
];
|
];
|
||||||
const elements = Object.fromEntries(requiredIds.map((id) => [id, byId(id)]));
|
const elements = Object.fromEntries(requiredIds.map((id) => [id, byId(id)]));
|
||||||
|
|
||||||
@@ -53,6 +62,7 @@ const optionalIds = [
|
|||||||
];
|
];
|
||||||
for (const id of optionalIds) elements[id] = maybeById(id);
|
for (const id of optionalIds) elements[id] = maybeById(id);
|
||||||
elements.validationCard = elements["validation-title"].closest(".validation-card");
|
elements.validationCard = elements["validation-title"].closest(".validation-card");
|
||||||
|
const beamPatternView = createReceiveBeamPatternView(elements["beam-pattern-plot"]);
|
||||||
|
|
||||||
const splitPane = createSplitPane({
|
const splitPane = createSplitPane({
|
||||||
container: elements["array-builder-layout"],
|
container: elements["array-builder-layout"],
|
||||||
@@ -82,6 +92,9 @@ const state = {
|
|||||||
dragSnapshot: null,
|
dragSnapshot: null,
|
||||||
importRevision: 0,
|
importRevision: 0,
|
||||||
baselineSignature: "",
|
baselineSignature: "",
|
||||||
|
beamPatternRenderFrame: 0,
|
||||||
|
beamPatternDirty: true,
|
||||||
|
beamPatternPreviewPending: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
const MAX_HISTORY = 50;
|
||||||
@@ -442,6 +455,7 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
|
|||||||
state.selectedMicrophoneId = id;
|
state.selectedMicrophoneId = id;
|
||||||
syncSelectedEditor();
|
syncSelectedEditor();
|
||||||
canvasView.render(canvasState());
|
canvasView.render(canvasState());
|
||||||
|
scheduleBeamPatternRender({ preview: true });
|
||||||
},
|
},
|
||||||
onDragEnd(id, position) {
|
onDragEnd(id, position) {
|
||||||
if (!state.dragSnapshot) return;
|
if (!state.dragSnapshot) return;
|
||||||
@@ -481,6 +495,226 @@ const canvasView = createArrayCanvas(elements["array-canvas"], {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const beamPatternInputIds = [
|
||||||
|
"beam-pattern-frequency",
|
||||||
|
"beam-pattern-speed-of-sound",
|
||||||
|
"beam-pattern-cut",
|
||||||
|
"beam-pattern-steering-angle",
|
||||||
|
"beam-pattern-samples",
|
||||||
|
"beam-pattern-floor",
|
||||||
|
];
|
||||||
|
|
||||||
|
function beamPatternNumber(id, label, options) {
|
||||||
|
const element = elements[id];
|
||||||
|
try {
|
||||||
|
const value = finiteInput(element, label, options);
|
||||||
|
element.removeAttribute("aria-invalid");
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
element.setAttribute("aria-invalid", "true");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBeamPatternOptions(preview) {
|
||||||
|
for (const id of beamPatternInputIds) elements[id].removeAttribute("aria-invalid");
|
||||||
|
const frequencyHz = beamPatternNumber(
|
||||||
|
"beam-pattern-frequency",
|
||||||
|
"아날로그 주파수",
|
||||||
|
{ exclusiveMinimum: 0, maximum: 1_000_000 },
|
||||||
|
);
|
||||||
|
const speedOfSound = beamPatternNumber(
|
||||||
|
"beam-pattern-speed-of-sound",
|
||||||
|
"음속",
|
||||||
|
{ minimum: 250, maximum: 400 },
|
||||||
|
);
|
||||||
|
const steeringAngleDeg = beamPatternNumber(
|
||||||
|
"beam-pattern-steering-angle",
|
||||||
|
"조향각",
|
||||||
|
{ minimum: -360, maximum: 360 },
|
||||||
|
);
|
||||||
|
const cut = elements["beam-pattern-cut"].value;
|
||||||
|
if (!["xy", "xz", "yz"].includes(cut)) {
|
||||||
|
elements["beam-pattern-cut"].setAttribute("aria-invalid", "true");
|
||||||
|
throw new Error("절단면은 XY, XZ 또는 YZ여야 합니다.");
|
||||||
|
}
|
||||||
|
const sampleValue = elements["beam-pattern-samples"].value;
|
||||||
|
const configuredSampleCount = sampleValue === "auto"
|
||||||
|
? "auto"
|
||||||
|
: Number(sampleValue);
|
||||||
|
if (
|
||||||
|
configuredSampleCount !== "auto" &&
|
||||||
|
(!Number.isInteger(configuredSampleCount) || configuredSampleCount < 36)
|
||||||
|
) {
|
||||||
|
elements["beam-pattern-samples"].setAttribute("aria-invalid", "true");
|
||||||
|
throw new Error("각도 샘플 설정이 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
const dbFloor = Number(elements["beam-pattern-floor"].value);
|
||||||
|
if (!Number.isFinite(dbFloor) || dbFloor >= 0) {
|
||||||
|
elements["beam-pattern-floor"].setAttribute("aria-invalid", "true");
|
||||||
|
throw new Error("표시 dB 하한이 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
array: state.arrayDefinition,
|
||||||
|
frequencyHz,
|
||||||
|
speedOfSound,
|
||||||
|
cut,
|
||||||
|
steeringAngleDeg,
|
||||||
|
sampleCount: preview
|
||||||
|
? Math.min(
|
||||||
|
configuredSampleCount === "auto" ? 720 : configuredSampleCount,
|
||||||
|
720,
|
||||||
|
)
|
||||||
|
: configuredSampleCount,
|
||||||
|
dbFloor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBeamFrequency(frequencyHz) {
|
||||||
|
if (frequencyHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(3) + " MHz";
|
||||||
|
if (frequencyHz >= 1_000) return (frequencyHz / 1_000).toFixed(3) + " kHz";
|
||||||
|
return frequencyHz.toFixed(1) + " Hz";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBeamWavelength(wavelengthM) {
|
||||||
|
if (wavelengthM >= 1) return wavelengthM.toFixed(4) + " m";
|
||||||
|
return (wavelengthM * 1000).toFixed(wavelengthM >= 0.01 ? 2 : 3) + " mm";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBeamDecibels(value) {
|
||||||
|
if (!Number.isFinite(value)) return "−∞ dB";
|
||||||
|
const normalized = Math.abs(value) < 0.0005 ? 0 : value;
|
||||||
|
return normalized.toFixed(3) + " dB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBeamHpbw(halfPower) {
|
||||||
|
if (halfPower.status === "sampled-bracketed-bisection") {
|
||||||
|
return halfPower.widthDeg.toFixed(2) + "° · sampled bracket";
|
||||||
|
}
|
||||||
|
if (halfPower.status === "full-circle-above-half-power") {
|
||||||
|
return "정의 불가 · 전 방향 ≥ −3.0103 dB";
|
||||||
|
}
|
||||||
|
if (halfPower.status === "no-sampled-half-power-crossing") {
|
||||||
|
return "미확정 · sampled crossing 없음";
|
||||||
|
}
|
||||||
|
return "미확정 · 각도 해상도 부족";
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeBeamPatternPeaks(pattern) {
|
||||||
|
const summary = pattern.metrics.sampledLocalMaxima;
|
||||||
|
if (summary.status === "resolution-unresolved") {
|
||||||
|
return "각도 해상도 기준이 충족되지 않아 peak를 분류하지 않습니다. Polar 선은 raw 표본만 표시합니다.";
|
||||||
|
}
|
||||||
|
if (summary.status === "flat-on-grid") {
|
||||||
|
return "이 절단면의 모든 표본이 수치 허용오차 안에서 같습니다. 고유 peak를 선택하지 않습니다.";
|
||||||
|
}
|
||||||
|
const displayed = summary.peaks.slice(0, 8).map((peak) =>
|
||||||
|
peak.angleDeg.toFixed(2) + "° " + formatBeamDecibels(peak.responseDb));
|
||||||
|
const remaining = Math.max(0, summary.peaks.length - displayed.length);
|
||||||
|
const prefix = summary.status === "tied-global-maxima-on-grid"
|
||||||
|
? String(summary.globalMaximumRegions.length) + "개 sampled 전역 최대 동률 · "
|
||||||
|
: "sampled 전역 최대 1개 · ";
|
||||||
|
return prefix + displayed.join(" · ") +
|
||||||
|
(remaining ? " · 외 " + String(remaining) + "개" : "") +
|
||||||
|
" · 자동 sidelobe/grating-lobe 분류는 하지 않습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBeamPatternStatus(message, severity = "success") {
|
||||||
|
elements["beam-pattern-status"].textContent = message;
|
||||||
|
elements["beam-pattern-status"].dataset.severity = severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBeamPattern(preview = false) {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
try {
|
||||||
|
const options = readBeamPatternOptions(preview);
|
||||||
|
const pattern = computeReceiveBeamPattern(options);
|
||||||
|
beamPatternView.render(pattern);
|
||||||
|
state.beamPatternDirty = false;
|
||||||
|
document.documentElement.dataset.beamPatternReady = "true";
|
||||||
|
document.documentElement.dataset.beamPatternPreview = String(preview);
|
||||||
|
elements["beam-pattern-plot"].dataset.sampleCount = String(pattern.sampleCount);
|
||||||
|
elements["beam-pattern-plot"].dataset.cut = pattern.cut;
|
||||||
|
elements["beam-pattern-plot"].dataset.frequencyHz = String(pattern.frequencyHz);
|
||||||
|
elements["beam-pattern-summary"].textContent =
|
||||||
|
formatBeamFrequency(pattern.frequencyHz) + " · " +
|
||||||
|
pattern.cut.toUpperCase() + " · " +
|
||||||
|
pattern.steeringAngleDeg.toFixed(1) + "° 조향";
|
||||||
|
elements["beam-pattern-wavelength"].textContent =
|
||||||
|
formatBeamWavelength(pattern.wavelengthM);
|
||||||
|
elements["beam-pattern-resolution"].textContent =
|
||||||
|
pattern.sampleCount.toLocaleString("ko-KR") + " · " +
|
||||||
|
pattern.angularResolutionDeg.toFixed(4) + "°";
|
||||||
|
elements["beam-pattern-steering-response"].textContent =
|
||||||
|
formatBeamDecibels(pattern.metrics.steeringDb);
|
||||||
|
elements["beam-pattern-hpbw"].textContent = preview
|
||||||
|
? "드래그 종료 후 계산"
|
||||||
|
: formatBeamHpbw(pattern.metrics.halfPowerBeamwidth);
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
elements["beam-pattern-peaks"].textContent =
|
||||||
|
describeBeamPatternPeaks(pattern);
|
||||||
|
const elapsedMs = performance.now() - startedAt;
|
||||||
|
document.documentElement.dataset.beamPatternComputeMs =
|
||||||
|
elapsedMs.toFixed(3);
|
||||||
|
if (!pattern.angularSampling.metricResolutionSatisfied) {
|
||||||
|
setBeamPatternStatus(
|
||||||
|
"Raw polar 표본은 표시했습니다. 최대 인접 위상 변화 " +
|
||||||
|
pattern.angularSampling.maximumAdjacentPhaseStepRadBound.toFixed(3) +
|
||||||
|
" rad가 π/8 기준을 넘으므로 HPBW와 peak 해석은 보류합니다." +
|
||||||
|
(pattern.angularSampling.autoCapped ? " 자동 샘플은 안전 상한에서 제한됐습니다." : "") +
|
||||||
|
" · " + elapsedMs.toFixed(1) + " ms",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
pattern.metrics.halfPowerBeamwidth.status ===
|
||||||
|
"no-sampled-half-power-crossing"
|
||||||
|
) {
|
||||||
|
setBeamPatternStatus(
|
||||||
|
"π/8 인접 위상 간격 기준은 충족했지만 sampled half-power crossing이 없습니다. " +
|
||||||
|
"전 방향 HPBW라고 단정하지 않습니다. · " + elapsedMs.toFixed(1) + " ms",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setBeamPatternStatus(
|
||||||
|
"π/8 인접 위상 간격 기준 충족 · " +
|
||||||
|
pattern.sampleCount.toLocaleString("ko-KR") + " angles · " +
|
||||||
|
pattern.evaluationCount.toLocaleString("ko-KR") +
|
||||||
|
" phasor evaluations · " + elapsedMs.toFixed(1) + " ms",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state.beamPatternDirty = false;
|
||||||
|
document.documentElement.dataset.beamPatternReady = "error";
|
||||||
|
document.documentElement.dataset.beamPatternPreview = "false";
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
beamPatternView.renderError(message);
|
||||||
|
setBeamPatternStatus(message, "error");
|
||||||
|
elements["beam-pattern-hpbw"].textContent = "—";
|
||||||
|
elements["beam-pattern-peaks"].textContent =
|
||||||
|
"빔 패턴 설정을 수정하면 다시 계산합니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBeamPatternRender(options = {}) {
|
||||||
|
const preview = options.preview === true;
|
||||||
|
state.beamPatternDirty = true;
|
||||||
|
if (!elements["beam-pattern-panel"].open) return;
|
||||||
|
if (state.beamPatternRenderFrame) {
|
||||||
|
state.beamPatternPreviewPending = preview;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.beamPatternPreviewPending = preview;
|
||||||
|
state.beamPatternRenderFrame = requestAnimationFrame(() => {
|
||||||
|
const pendingPreview = state.beamPatternPreviewPending;
|
||||||
|
state.beamPatternRenderFrame = 0;
|
||||||
|
state.beamPatternPreviewPending = false;
|
||||||
|
renderBeamPattern(pendingPreview);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderDocumentEditor() {
|
function renderDocumentEditor() {
|
||||||
elements["array-id"].value = state.arrayDefinition.id;
|
elements["array-id"].value = state.arrayDefinition.id;
|
||||||
elements["array-name"].value = state.arrayDefinition.name;
|
elements["array-name"].value = state.arrayDefinition.name;
|
||||||
@@ -738,6 +972,7 @@ function renderAll(message) {
|
|||||||
syncHistoryButtons();
|
syncHistoryButtons();
|
||||||
setTool(state.tool);
|
setTool(state.tool);
|
||||||
canvasView.render(canvasState());
|
canvasView.render(canvasState());
|
||||||
|
scheduleBeamPatternRender();
|
||||||
announce(message);
|
announce(message);
|
||||||
scheduleValidation();
|
scheduleValidation();
|
||||||
}
|
}
|
||||||
@@ -866,6 +1101,16 @@ elements["snap-enabled"].addEventListener("change", () => {
|
|||||||
canvasView.render(canvasState());
|
canvasView.render(canvasState());
|
||||||
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
|
announce(state.snapEnabled ? "Snap 켜짐" : "Snap 꺼짐");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const id of beamPatternInputIds) {
|
||||||
|
const eventName = elements[id] instanceof HTMLInputElement ? "input" : "change";
|
||||||
|
elements[id].addEventListener(eventName, () => scheduleBeamPatternRender());
|
||||||
|
}
|
||||||
|
elements["beam-pattern-panel"].addEventListener("toggle", () => {
|
||||||
|
if (elements["beam-pattern-panel"].open && state.beamPatternDirty) {
|
||||||
|
scheduleBeamPatternRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
elements["snap-step"].addEventListener("change", () => safely(() => {
|
elements["snap-step"].addEventListener("change", () => safely(() => {
|
||||||
const millimeters = finiteInput(elements["snap-step"], "Snap 간격", {
|
const millimeters = finiteInput(elements["snap-step"], "Snap 간격", {
|
||||||
exclusiveMinimum: 0,
|
exclusiveMinimum: 0,
|
||||||
@@ -1066,6 +1311,10 @@ window.addEventListener("beforeunload", (event) => {
|
|||||||
event.returnValue = "";
|
event.returnValue = "";
|
||||||
});
|
});
|
||||||
window.addEventListener("unload", () => {
|
window.addEventListener("unload", () => {
|
||||||
|
if (state.beamPatternRenderFrame) {
|
||||||
|
cancelAnimationFrame(state.beamPatternRenderFrame);
|
||||||
|
}
|
||||||
|
beamPatternView.destroy();
|
||||||
splitPane.destroy();
|
splitPane.destroy();
|
||||||
canvasView.destroy();
|
canvasView.destroy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -571,6 +571,220 @@ details > summary::-webkit-details-marker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inline-details > summary,
|
.inline-details > summary,
|
||||||
|
.beam-pattern-card {
|
||||||
|
container: beam-pattern / inline-size;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgb(255 255 255 / 2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-heading {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-heading::after {
|
||||||
|
color: var(--mint);
|
||||||
|
content: "+";
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-card[open] > .beam-pattern-heading::after {
|
||||||
|
content: "−";
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-heading > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-heading .eyebrow {
|
||||||
|
margin: 0 0 3px;
|
||||||
|
font-size: 0.61rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-heading > span {
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 650 0.65rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-body {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(270px, 0.82fr) minmax(340px, 1.18fr);
|
||||||
|
gap: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-controls,
|
||||||
|
.beam-pattern-figure {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-model {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #cfe2db;
|
||||||
|
font: 680 0.64rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-model span {
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid rgb(88 199 232 / 28%);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--cyan);
|
||||||
|
background: rgb(88 199 232 / 7%);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-metrics article {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #0b1714;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-metrics span,
|
||||||
|
.beam-pattern-metrics strong {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-metrics span {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.57rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-metrics strong {
|
||||||
|
font: 720 0.68rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-status {
|
||||||
|
margin: 11px 0 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid rgb(101 242 191 / 22%);
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #cfe2db;
|
||||||
|
background: rgb(101 242 191 / 4%);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-status[data-severity="warning"] {
|
||||||
|
border-color: rgb(255 197 110 / 28%);
|
||||||
|
color: #ffe0a8;
|
||||||
|
background: rgb(255 197 110 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-status[data-severity="error"] {
|
||||||
|
border-color: rgb(255 123 115 / 34%);
|
||||||
|
color: #ffc1bd;
|
||||||
|
background: rgb(255 123 115 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-note {
|
||||||
|
margin: 9px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.59rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-figure {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#beam-pattern-plot {
|
||||||
|
display: block;
|
||||||
|
width: min(100%, 580px);
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: visible;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--mint);
|
||||||
|
background: #07110f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-figure figcaption {
|
||||||
|
width: min(100%, 580px);
|
||||||
|
min-height: 2.8em;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.61rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-ring,
|
||||||
|
.beam-pattern-axis {
|
||||||
|
stroke: rgb(145 170 162 / 28%);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-ring-label {
|
||||||
|
fill: #76948a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-axis-labels {
|
||||||
|
color: #b9cec7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-steering {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-response {
|
||||||
|
color: var(--mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-error {
|
||||||
|
color: #ffc1bd;
|
||||||
|
font: 650 13px system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
.shortcut-help > summary {
|
.shortcut-help > summary {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 10px 32px 10px 11px;
|
padding: 10px 32px 10px 11px;
|
||||||
@@ -873,6 +1087,27 @@ body[data-array-tool="add"] #array-canvas,
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container beam-pattern (max-width: 760px) {
|
||||||
|
.beam-pattern-body {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container beam-pattern (max-width: 460px) {
|
||||||
|
.beam-pattern-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-heading > span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beam-pattern-fields,
|
||||||
|
.beam-pattern-metrics {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.array-workflow li {
|
.array-workflow li {
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
이미지 평면에 source ground truth를 배치하고 tone, noise, 로컬 audio asset과 ambient를 조합해
|
이미지 평면에 source ground truth를 배치하고 tone, noise, 로컬 audio asset과 ambient를 조합해
|
||||||
재현 가능한 `micarrsim.scene` JSON을 만드는 브라우저 앱입니다.
|
재현 가능한 `micarrsim.scene` JSON을 만드는 브라우저 앱입니다.
|
||||||
|
|
||||||
|
Tone 주파수는 Scene Nyquist 위도 입력할 수 있습니다. source 카드는 anti-alias filter 없는 이상적
|
||||||
|
ADC에서 관측될 alias 주파수를 함께 표시합니다. 이는 고주파를 복원하거나 감쇠시키는 기능이 아니며,
|
||||||
|
실제 장치의 analog anti-alias filter는 별도 모델 대상입니다.
|
||||||
|
|
||||||
## 열기
|
## 열기
|
||||||
|
|
||||||
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Dataset Builder**를 선택하거나
|
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Dataset Builder**를 선택하거나
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<summary class="button button--ghost" aria-label="추가 작업">더보기</summary>
|
<summary class="button button--ghost" aria-label="추가 작업">더보기</summary>
|
||||||
<div class="header-more__menu">
|
<div class="header-more__menu">
|
||||||
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
|
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
|
||||||
<a class="button button--ghost" href="/apps/acoustic-camera/">Acoustic Camera 열기 →</a>
|
<a class="button button--ghost" href="/apps/acoustic-camera/" title="새 Camera 세션을 엽니다. 기존 Camera에서 편집을 시작했다면 원래 Camera 탭으로 돌아가 Scene JSON을 선택하세요.">새 Acoustic Camera 열기 →</a>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
|
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
|
||||||
<div id="tone-fields"><label class="field field--wide"><span>주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" step="1" /></label></div>
|
<div id="tone-fields"><label class="field field--wide" title="Nyquist를 넘어도 허용됩니다. 0–1 MHz는 현재 수치·마이크 모델의 검증 범위이며, 이상적 anti-alias filter 없는 ADC에서 접힌 주파수는 source 카드에 표시됩니다."><span>아날로그 tone 주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" max="1000000" step="1" /></label></div>
|
||||||
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
|
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
|
||||||
<div id="asset-fields">
|
<div id="asset-fields">
|
||||||
<label class="field field--wide"><span>Audio asset</span><select id="source-asset"></select></label>
|
<label class="field field--wide"><span>Audio asset</span><select id="source-asset"></select></label>
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ function renderSourceCards() {
|
|||||||
name.title = source.name;
|
name.title = source.name;
|
||||||
heading.append(dot, name);
|
heading.append(dot, name);
|
||||||
const signal = document.createElement("small");
|
const signal = document.createElement("small");
|
||||||
signal.textContent = describeSignal(source.signal);
|
signal.textContent = describeSignal(source.signal, state.scene.sampleRate);
|
||||||
const position = document.createElement("small");
|
const position = document.createElement("small");
|
||||||
const motion = Array.isArray(source.trajectory) ? ` · 이동 ${source.trajectory.length}점` : "";
|
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}`;
|
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m${motion}`;
|
||||||
@@ -683,6 +683,7 @@ function bindSceneControls() {
|
|||||||
if (asset.kind === "audio") state.assetErrors.delete(asset.id);
|
if (asset.kind === "audio") state.assetErrors.delete(asset.id);
|
||||||
}
|
}
|
||||||
renderAssetList();
|
renderAssetList();
|
||||||
|
renderSourceCards();
|
||||||
}, "change");
|
}, "change");
|
||||||
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
|
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
|
||||||
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
|
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { describeFrequencyAliasing } from "/packages/acoustics-core/sampling.js";
|
||||||
|
|
||||||
export const DEFAULT_VIEW_PLANE = Object.freeze({
|
export const DEFAULT_VIEW_PLANE = Object.freeze({
|
||||||
xMin: -0.6,
|
xMin: -0.6,
|
||||||
xMax: 0.6,
|
xMax: 0.6,
|
||||||
@@ -192,8 +194,20 @@ export function changeSignalType(source, type, firstAudioAssetId = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeSignal(signal) {
|
export function describeSignal(signal, sampleRate) {
|
||||||
if (signal.type === "tone") return `${signal.frequency} Hz tone`;
|
if (signal.type === "tone") {
|
||||||
|
if (!Number.isFinite(sampleRate)) return `${signal.frequency} Hz tone`;
|
||||||
|
const observation = describeFrequencyAliasing(signal.frequency, sampleRate);
|
||||||
|
const alias = observation.aliased
|
||||||
|
? ` → ADC ${observation.adcFrequencyHz} Hz alias`
|
||||||
|
: "";
|
||||||
|
const edge = observation.dcEdge
|
||||||
|
? " · DC phase-degenerate edge"
|
||||||
|
: observation.nyquistEdge
|
||||||
|
? " · Nyquist phase-degenerate edge"
|
||||||
|
: "";
|
||||||
|
return `${signal.frequency} Hz tone${alias}${edge}`;
|
||||||
|
}
|
||||||
if (signal.type === "noise") return `${signal.color} noise`;
|
if (signal.type === "noise") return `${signal.color} noise`;
|
||||||
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
|
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
|
||||||
return "알 수 없는 신호";
|
return "알 수 없는 신호";
|
||||||
|
|||||||
@@ -8,7 +8,24 @@
|
|||||||
FFT 512 / hop 256, Auto backend를 쓰는 Experiment
|
FFT 512 / hop 256, Auto backend를 쓰는 Experiment
|
||||||
- `moving-tone.scene.json`: 2초 동안 다섯 trajectory keyframe을 따라 이동하는 2 kHz tone Scene
|
- `moving-tone.scene.json`: 2초 동안 다섯 trajectory keyframe을 따라 이동하는 2 kHz tone Scene
|
||||||
- `moving-tone.experiment.json`: 라이브 준비 시간을 줄인 24×18 scan, FFT 256 / hop 128 Experiment
|
- `moving-tone.experiment.json`: 라이브 준비 시간을 줄인 24×18 scan, FFT 256 / hop 128 Experiment
|
||||||
|
- `music-single-tone.scene.json`: 48 kHz, 0.25 s, 1.125 kHz tone 하나를 z=2 m에 둔 MUSIC Scene
|
||||||
|
- `music-single-tone.experiment.json`: 12채널 배열, 25×25 scan, FFT 512의 1.125 kHz 단일-bin
|
||||||
|
analog 가설과 fixed K=1을 쓰는 CPU MUSIC Experiment
|
||||||
|
|
||||||
두 문서는 계약 버전 `1.0.0`이며 기본 Camera 실행, CPU golden test, CPU/WebGPU 비교와 benchmark가
|
각 문서는 계약 버전 `1.0.0`이며 기본 Camera 실행, CPU golden test, CPU/WebGPU 비교와 benchmark가
|
||||||
같은 조건을 공유하도록 고정되어 있습니다. fixture를 바꿀 때는 <http://127.0.0.1:4173/tests/>의
|
같은 조건을 공유하도록 고정되어 있습니다. fixture를 바꿀 때는 <http://127.0.0.1:4173/tests/>의
|
||||||
전체 테스트와 benchmark 입력 의미를 함께 확인하세요.
|
전체 테스트와 benchmark 입력 의미를 함께 확인하세요.
|
||||||
|
|
||||||
|
## MUSIC 빠른 확인
|
||||||
|
|
||||||
|
Camera에서 **MUSIC 빠른 예제**를 누르면 위 MUSIC Scene·Experiment와
|
||||||
|
`rectangular-4x3-450mm` 배열을 함께 엽니다. 1.125 kHz는 48 kHz/FFT 512에서 정확히 12번째
|
||||||
|
FFT-grid center이고, band는 이 아날로그 가설 하나만 포함합니다. 이 일치 조건은 fixture에
|
||||||
|
명시되어 있으며 localizer가 Scene carrier truth를 조회해 bin이나 위치를 고르는 것이 아닙니다.
|
||||||
|
|
||||||
|
Nyquist 초과 analog 가설도 배열 설계 비교를 위해 허용합니다. 이때 analog carrier 크기의 공간
|
||||||
|
위상은 유지하고 alias inversion 방향이면 calibration·steering 전체 complex manifold를
|
||||||
|
켤레화합니다. 정확한 DC/Nyquist edge는 거절합니다. q와 표시용 P/Pmax는 확률이나 신뢰구간이
|
||||||
|
아니며 grating lobe·동률·coherent rank deficiency는 그대로 나타납니다. Scene/Array ID는
|
||||||
|
provenance label이므로 값이 달라도 현재 열린 geometry/sample을 사용하고 Result에 실제 입력
|
||||||
|
ID와 hash를 기록합니다.
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0.0",
|
||||||
|
"kind": "micarrsim.experiment",
|
||||||
|
"id": "music-single-tone-25x25",
|
||||||
|
"name": "Standard MUSIC · 1.125 kHz · 25×25",
|
||||||
|
"arrayId": "rectangular-4x3-450mm",
|
||||||
|
"sceneId": "music-single-tone",
|
||||||
|
"scan": {
|
||||||
|
"z": 2,
|
||||||
|
"xMin": -0.6,
|
||||||
|
"xMax": 0.6,
|
||||||
|
"yMin": -0.45,
|
||||||
|
"yMax": 0.45,
|
||||||
|
"columns": 25,
|
||||||
|
"rows": 25
|
||||||
|
},
|
||||||
|
"frequencyBands": [
|
||||||
|
{
|
||||||
|
"id": "tone-1125",
|
||||||
|
"minHz": 1100,
|
||||||
|
"maxHz": 1150,
|
||||||
|
"color": "#65f2bf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stft": {
|
||||||
|
"fftSize": 512,
|
||||||
|
"hopSize": 256,
|
||||||
|
"window": "hann"
|
||||||
|
},
|
||||||
|
"evaluation": {
|
||||||
|
"detectionThresholdRatio": 0.25
|
||||||
|
},
|
||||||
|
"beamforming": {
|
||||||
|
"algorithm": "music",
|
||||||
|
"backend": "cpu",
|
||||||
|
"minimumDistance": 0.01,
|
||||||
|
"normalization": "none",
|
||||||
|
"dynamicRangeDb": 30,
|
||||||
|
"compensateFrequencyResponse": true,
|
||||||
|
"musicSourceCountMethod": "fixed",
|
||||||
|
"musicSourceCount": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "1.0.0",
|
||||||
|
"kind": "micarrsim.scene",
|
||||||
|
"id": "music-single-tone",
|
||||||
|
"name": "1.125 kHz MUSIC single source",
|
||||||
|
"description": "Small deterministic free-field fixture for standard narrowband MUSIC.",
|
||||||
|
"units": "m",
|
||||||
|
"sampleRate": 48000,
|
||||||
|
"duration": 0.25,
|
||||||
|
"speedOfSound": 343,
|
||||||
|
"seed": 20260829,
|
||||||
|
"mix": { "normalization": "none", "targetPeak": 0.95 },
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"id": "music-source-1",
|
||||||
|
"name": "1.125 kHz tone",
|
||||||
|
"position": [0.08, 0.04, 2],
|
||||||
|
"gainDb": -12,
|
||||||
|
"startTime": 0,
|
||||||
|
"duration": 0.25,
|
||||||
|
"signal": {
|
||||||
|
"type": "tone",
|
||||||
|
"frequency": 1125,
|
||||||
|
"phaseDeg": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ambient": []
|
||||||
|
}
|
||||||
+164
-3
@@ -82,6 +82,49 @@ Scene JSON의 사용자 설정 예는 다음과 같다.
|
|||||||
하며 occluder id는 중복될 수 없다. 사용자 설정에서도 마이크 좌표가 room 안에 놓이도록 구성해야
|
하며 occluder id는 중복될 수 없다. 사용자 설정에서도 마이크 좌표가 room 안에 놓이도록 구성해야
|
||||||
1차 반사가 생성된다.
|
1차 반사가 생성된다.
|
||||||
|
|
||||||
|
## 라이브 창의 전파 지연과 이동 근사
|
||||||
|
|
||||||
|
라이브 frame의 출력 sample 구간을 `[S, E)`, 현재 quasi-static geometry에서 sampled
|
||||||
|
source–microphone 희소 경로의 최대 지연을 `Dsampled`라 하자. 전체 경로 최대 `Dall`은
|
||||||
|
tone tail의 도착 가능성 판정과 metadata에 별도로 보존한다. 합성기는 다음 순서로 frame을 만든다.
|
||||||
|
|
||||||
|
1. source trajectory를 출력 창 중앙 시각에서 한 번 샘플링한다.
|
||||||
|
2. `R = max(0, S - ceil(Dsampled × sampleRate))`를 sampled 방사 입력의 시작으로 정한다.
|
||||||
|
3. analytic tone은 `[S, E)` 크기의 마이크 channel에 원래 Scene의 절대 sample 위상으로 직접
|
||||||
|
경로를 더한다. tone에는 과거 sample buffer가 필요 없다.
|
||||||
|
4. noise와 asset은 `[R, E)` 방사 buffer와 source별 최대 지연의 zero-padded FFT를 만들고,
|
||||||
|
direct·reflection·scattering·diffraction·diffuse 전달함수를 적용한 IFFT의 `[S, E)` 부분만
|
||||||
|
crop 크기 마이크 channel에 더한다.
|
||||||
|
|
||||||
|
따라서 source가 `S` 전에 멈췄더라도 그 직접음·반사음·diffuse tail이 창 안에 도착하면 보존된다.
|
||||||
|
analytic tone뿐 아니라 deterministic noise index와 asset 재생 위치도 원래 절대 시간축을 유지한다.
|
||||||
|
`synthesis.windowing`과 live frame의 `signalStats.windowing`은 render/output sample, pre-roll,
|
||||||
|
전체/실제 render 최대 지연, geometry와 Doppler 정책을 기록한다.
|
||||||
|
source별 `sourceArrivals`는 각 midpoint path의 half-open 도착 sample 구간이 출력과 겹치는지와
|
||||||
|
겹친 path kind를 기록한다. live ground-truth 평가는 emission midpoint가 아니라
|
||||||
|
`arrivingSourceIds`를 사용하므로, 이미 멈춘 source의 tail을 false positive로 잘못 세지 않는다.
|
||||||
|
이 ID 집합은 위치 추정이 끝난 뒤 평가에만 쓰며 raw channel, STFT, steering이나 peak 검출에는
|
||||||
|
입력하지 않는다.
|
||||||
|
loop하지 않는 asset의 emission end는 선언 duration과 decoded channel duration 중 작은 값이다.
|
||||||
|
leading/trailing zero sample 내용은 검사하지 않는다.
|
||||||
|
|
||||||
|
다만 이는 moving wave equation 해법이 아니다. pre-roll 방사 시각의 retarded source 위치를 다시
|
||||||
|
구하지 않고 전체 render buffer에서 창 중앙 위치와 그 위치의 경로를 고정한다. 창 안의 연속 이동,
|
||||||
|
Doppler, 시간에 따라 변하는 reflection geometry도 모델링하지 않는다. 움직임이 경로 지연에 비해
|
||||||
|
크다면 frame 길이를 줄여 quasi-static 오차를 드러내고 수렴성을 확인해야 한다.
|
||||||
|
|
||||||
|
sampled asset/noise의 fractional delay와 microphone response는 zero-padded block FFT 전달함수다.
|
||||||
|
pre-roll은 현재 희소 **전파 경로**의 최대 지연을 보존하지만, 측정된 주파수 응답에서 무한하거나
|
||||||
|
임의 길이의 causal analog impulse response state를 복원하지 않는다. 또한 Scene이
|
||||||
|
`mix.normalization: peak`이면 live 합성은 각 출력 crop의 peak로 scale을 계산하므로 frame 간
|
||||||
|
절대 진폭 비교에는 `normalization: none`을 사용해야 한다.
|
||||||
|
|
||||||
|
마이크 출력은 처음부터 `[S,E)` 크기로만 할당하고 sampled source buffer·spectrum은 source
|
||||||
|
하나씩 처리한다. source별 경로 최대 지연을 `D_i`라 하면 unrelated far tone의 global
|
||||||
|
`Dmax`가 noise·asset FFT를 키우지 않는다. Experiment 검증과 runtime은 같은 estimator를 사용해
|
||||||
|
shoebox의 모든 실제 sparse path 지연을 포함한다. 현재 sampled source는 source–microphone
|
||||||
|
조합마다 IFFT 한 번을 수행하므로, source batching으로 IFFT 수를 줄이는 작업은 남아 있다.
|
||||||
|
|
||||||
## Camera 환경 preset과 비용
|
## Camera 환경 preset과 비용
|
||||||
|
|
||||||
Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음
|
Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음
|
||||||
@@ -184,6 +227,117 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가
|
|||||||
해결하지는 않는다. 단일 reference 방식은 모든 마이크 쌍을 쓰는 GCC보다 계산량이 작지만 pair
|
해결하지는 않는다. 단일 reference 방식은 모든 마이크 쌍을 쓰는 GCC보다 계산량이 작지만 pair
|
||||||
중복성과 고장 마이크에 대한 강건성도 작다.
|
중복성과 고장 마이크에 대한 강건성도 작다.
|
||||||
|
|
||||||
|
### Narrowband MUSIC (`music`)
|
||||||
|
|
||||||
|
현재 CPU MUSIC은 [Schmidt의 noise-subspace 방법](https://doi.org/10.1109/TAP.1986.1143830)을
|
||||||
|
근거리 구면파 direct-path manifold에 적용한다. 근거리 source localization의 관련 문헌은
|
||||||
|
[Huang–Barkat](https://doi.org/10.1109/8.86917)이다. 보정된 snapshot
|
||||||
|
`x(t)`로 mean subtraction 없는 sample covariance를 만든다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
R = (1/T) Σ_t x(t) x(t)ᴴ
|
||||||
|
R v_j = λ_j v_j, λ_0 ≥ ... ≥ λ_(M-1)
|
||||||
|
q(p) = Σ_(j=K)^(M-1) |v_jᴴ a(p)|² / (a(p)ᴴ a(p))
|
||||||
|
```
|
||||||
|
|
||||||
|
`q`는 noise projection fraction이고 optimum은 minimum이다. UI/evaluation은 표준
|
||||||
|
pseudospectrum `P=1/q`를 scan 안에서 `P/Pmax`로 표시한다. `q_min>0`이면 이는
|
||||||
|
`q_min/q(p)`이고, `q_min=0`이면 정확한 zero 위치만 1로 표시한다. flat `q`는 모두 1로
|
||||||
|
표시하되 위치 peak는 만들지 않는다. `q`, `P`와 `P/Pmax`는 확률·posterior·confidence 또는
|
||||||
|
신뢰구간이 아니다.
|
||||||
|
|
||||||
|
Experiment band는 `Fs/N` grid에 놓인 아날로그 carrier 가설을 정확히 하나만 포함한다.
|
||||||
|
`f_a`를 그 center, signed ADC alias의 orientation을 `η = sign(f_s)`라 하면 공간 위상의
|
||||||
|
크기는 analog carrier `f_a`를 유지하고 `η`만 alias inversion 방향으로 사용한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
a_m(p) = H_m,residual(f_a) / max(r_m(p), d_min)
|
||||||
|
× exp[-j η 2π f_a (r_m(p)-r_0(p))/c + j φ_m,residual(f_a)]
|
||||||
|
```
|
||||||
|
|
||||||
|
`f_s<0`인 alias inversion에서는 positive-bin STFT coefficient, sensitivity/response 보정과
|
||||||
|
전체 complex manifold가 baseband 가설의 켤레 방향이 된다. microphone frequency response는
|
||||||
|
관측 bin이 아니라 사용자가 지정한 `f_a`에서 평가한다. 이 가설은 Experiment에서 오며 Scene
|
||||||
|
source frequency·position truth는 covariance, K 선택이나 scan에 사용하지 않는다. 같은 ADC
|
||||||
|
bin의 다른 alias order는 별도 band로 명시해야 하고 자동 선택하지 않는다. 정확한 DC와 Nyquist는
|
||||||
|
실수 sequence에 독립 quadrature가 없어 거부한다.
|
||||||
|
|
||||||
|
K는 사용자가 고정하거나 [Wax–Kailath AIC/MDL](https://doi.org/10.1109/TASSP.1985.1164557)로
|
||||||
|
선택한다. AIC/MDL은 비중첩 complete snapshot, `T>M`, roundoff보다 엄격히 큰 noise eigenvalue가
|
||||||
|
필요하고 IID complex Gaussian snapshot, spatially white noise, 창 동안 일정한 source 수를
|
||||||
|
가정한다. 비중첩 자체는 통계적 독립성의 증명이 아니며 criterion은 확률이나 confidence가 아니다.
|
||||||
|
가정/수치 조건이 깨지면 typed `MUSIC_UNAVAILABLE` 오류를 내고 fixed K로 fallback하지 않는다.
|
||||||
|
|
||||||
|
같은 주파수의 coherent source는 signal covariance rank를 잃을 수 있다. eigengap이 수치 audit
|
||||||
|
bound보다 작으면 subspace를 임의 분할하지 않는다. diagonal loading, eigenvalue floor, spatial
|
||||||
|
smoothing, band 간 association·평균·maximum fusion은 없다. 배열의 spatial alias/grating lobe,
|
||||||
|
동률과 flat objective도 결과를 좋게 보이도록 제거하지 않는다.
|
||||||
|
[Wax–Shan–Kailath broadband eigenstructure 방법](https://doi.org/10.1109/TASSP.1984.1164400)은
|
||||||
|
알려진 별도 알고리즘이지만 현재 구현하지 않았다.
|
||||||
|
|
||||||
|
## 샘플링, Nyquist와 alias
|
||||||
|
|
||||||
|
Scene의 `sampleRate`는 모든 마이크가 동시에 동작하는 이상적 real-valued ADC의 sampling rate다.
|
||||||
|
현재 모델에는 analog anti-alias filter가 없다. 따라서 tone 주파수는 Nyquist를 넘어도 허용되며,
|
||||||
|
관측 주파수는 다음 signed baseband 주파수로 접힌다. 정확한 Nyquist tie는 `+Fs/2`로
|
||||||
|
표준화하지만 `+Fs/2`와 `-Fs/2`는 같은 real sample sequence다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
f_wrapped = f_analog mod Fs, 0 <= f_wrapped < Fs
|
||||||
|
f_signed = f_wrapped if f_wrapped <= Fs/2
|
||||||
|
f_wrapped - Fs otherwise
|
||||||
|
f_adc = |f_signed|
|
||||||
|
```
|
||||||
|
|
||||||
|
`f_signed < 0`이면 one-sided positive spectrum이 반전된다. 정확히 DC나 Nyquist에 접힌 tone은
|
||||||
|
quadrature 정보가 소실되는 퇴화점이다. Camera는 이를 오류로 숨기지 않고 source 요약, band legend와
|
||||||
|
Result `signalStats.sampling`에 원 주파수·signed ADC 주파수·반전 여부와 DC/Nyquist
|
||||||
|
위상 퇴화 여부를 기록한다.
|
||||||
|
|
||||||
|
공간 전파는 alias 주파수로 바꾸지 않는다. 합성기는 각 path의 원래 analog 주파수
|
||||||
|
`2π f_analog τ`로 지연 위상과 해당 주파수의 microphone response를 적용한 뒤 ADC 시각에서
|
||||||
|
sampling한다. 이 순서를 뒤집으면 fractional path delay의 센서 간 위상이 틀리므로, tone은
|
||||||
|
mic/path별 analytic phasor와 시작·종료 event로 합성한다.
|
||||||
|
|
||||||
|
Experiment band도 Nyquist 위 값을 허용한다. 요청 주파수 구간의 모든 등가 one-sided real-FFT
|
||||||
|
bin **center**를 접어 선택하며, 일부 alias 후보를 골라 버리지 않는다. 이 선택은 brick-wall
|
||||||
|
대역 필터가 아니다. 유한 Hann window의 off-grid leakage는 인접 bin에도 남는다. 현재
|
||||||
|
DAS·DAS-PHAT는 양의 ADC bin 주파수로 구면 steering하고, GCC-PHAT는 선택 bin으로 제한한
|
||||||
|
기하 lag correlation을 수행한다. 어느 쪽도 원래 alias order를 이용해 결과가 좋아지도록
|
||||||
|
보정하지 않는다.
|
||||||
|
따라서 초과 주파수에서 peak가 틀어지거나 grating lobe·동률 peak가 나타나는 것이 정상적인 설계
|
||||||
|
결과일 수 있다. Result의 band별 `frequencyObservation`은 1-based Nyquist zone별 연속 fold
|
||||||
|
segment, 반전·alias order, 실제 ADC bin center, 연속/FFT-grid many-to-one 여부와
|
||||||
|
alias-equivalent band ID를 보존한다. 같은 `adcBinSetKey`를 가진 band는 독립 관측이 아니다.
|
||||||
|
|
||||||
|
정확한 Nyquist 경계에서 `+Fs/2`와 `-Fs/2`는 실수 sampling sequence가 동일하다.
|
||||||
|
단일 주파수 provenance는 이를 `+Fs/2`로 canonicalize하지만, `foldSegments`의
|
||||||
|
`signedAdcStartHz`/`signedAdcEndHz`는 각 zone을 따라가는 방향을 보존하므로 반전 zone
|
||||||
|
시작점이 `-Fs/2`로 나타날 수 있다. 경계를 가로지르는 두 segment의 `+Fs/2`와
|
||||||
|
`-Fs/2` endpoint는 두 관측이나 두 FFT bin이 아니라 같은 phase-degenerate Nyquist
|
||||||
|
sample을 양쪽 zone에서 표현한 것이다.
|
||||||
|
|
||||||
|
같은 이유로 DAS·DAS-PHAT·GCC-PHAT의 선택적 microphone-response 역보정은 관측 ADC bin에서
|
||||||
|
평가한다. Forward tone 합성은 원 analog 주파수의 응답을 사용하므로 이 세 알고리즘은 Nyquist 위
|
||||||
|
carrier를 아는 alias-aware 알고리즘처럼 동작하지 않는다. MUSIC만 예외로, 사용자가 band에
|
||||||
|
명시한 단일 아날로그 FFT-grid 가설에서 response와 signed complex manifold를 계산한다. 이는
|
||||||
|
Scene truth를 읽거나 가능한 alias order를 자동 탐색하는 것이 아니다.
|
||||||
|
|
||||||
|
서로 다른 analog tone이 같은 ADC bin으로 접히면 **단일 channel의 시간 sample만으로는**
|
||||||
|
원 주파수를 구별할 수 없다. 그러나 이 합성기는 원 analog 파장의 센서 간 위상을 보존하므로,
|
||||||
|
충분히 촘촘하고 보정된 다채널 array에는 carrier order를 판별할 공간 위상 증거가 남을 수 있다.
|
||||||
|
현재 DAS·DAS-PHAT·GCC-PHAT는 carrier hypothesis를 열거하는 알고리즘이 아니므로 이를 복원하지
|
||||||
|
않고 Result에 `aliasOrderResolution: not-attempted`로 기록한다. MUSIC은 band 하나에 사용자가
|
||||||
|
고른 가설 하나만 계산해 `configured-single-hypothesis`로 기록하며, 후보 중 승자를 고르지 않는다.
|
||||||
|
여러 order를 비교하려면 각각 독립 band로 명시해야 한다.
|
||||||
|
audio asset과 noise는 이미 sampling된 baseband 신호로 취급하므로 파일에 없는 Nyquist 위 analog
|
||||||
|
spectrum을 만들어 내지 않는다.
|
||||||
|
|
||||||
|
Tone과 band의 현재 검증 범위는 0–1 MHz다. 이는 Nyquist 제한이 아니라 JavaScript
|
||||||
|
IEEE-754 위상 정밀도, 마이크 응답 계약과 현재 희소 경로 모델을 함께 검증한 수치 범위다.
|
||||||
|
더 높은 carrier가 필요하면 단순히 상한을 제거하지 말고 phase reduction과 microphone model을
|
||||||
|
그 범위에서 다시 검증해야 한다.
|
||||||
|
|
||||||
## 마이크 주파수 응답
|
## 마이크 주파수 응답
|
||||||
|
|
||||||
각 마이크에는 2–64개의 측정점을 지정할 수 있다. `frequencyHz`는 0–1,000,000 Hz에서
|
각 마이크에는 2–64개의 측정점을 지정할 수 있다. `frequencyHz`는 0–1,000,000 Hz에서
|
||||||
@@ -212,6 +366,11 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가
|
|||||||
합성기는 알고리즘과 무관하게 sensitivity, 정적 phase offset과 frequency-response curve를 수신
|
합성기는 알고리즘과 무관하게 sensitivity, 정적 phase offset과 frequency-response curve를 수신
|
||||||
channel에 적용한다. 위치 추정에서의 역보정은 알고리즘별로 다르다.
|
channel에 적용한다. 위치 추정에서의 역보정은 알고리즘별로 다르다.
|
||||||
|
|
||||||
|
Tone에는 원 analog carrier에서 보간한 하나의 complex response를 정상상태 phasor로 적용한다.
|
||||||
|
따라서 지속 sinusoid의 gain/phase 비교에는 맞지만, hard-gated 시작·종료에서 실제 analog
|
||||||
|
마이크 필터가 만드는 ringing이나 frequency-dependent transient까지 계산하는 full LTI filter
|
||||||
|
모델은 아니다. 이 근사를 ADC를 포함한 완전한 하드웨어 시뮬레이션으로 해석하지 않는다.
|
||||||
|
|
||||||
- DAS는 sensitivity 역 gain과 정적 phase를 항상 보정한다. `compensateFrequencyResponse`를
|
- DAS는 sensitivity 역 gain과 정적 phase를 항상 보정한다. `compensateFrequencyResponse`를
|
||||||
생략하거나 `true`로 두면 response의 역 gain과 phase도 보정한다. 응답 notch의 무제한 gain
|
생략하거나 `true`로 두면 response의 역 gain과 phase도 보정한다. 응답 notch의 무제한 gain
|
||||||
boost는 noise까지 키울 수 있으므로 `maxCompensationDb`로 제한하며 기본값은 24 dB, 허용 범위는
|
boost는 noise까지 키울 수 있으므로 `maxCompensationDb`로 제한하며 기본값은 24 dB, 허용 범위는
|
||||||
@@ -253,9 +412,11 @@ Result `signalStats`에는 다음 선택 필드가 들어간다.
|
|||||||
같은 입력과 seed에서 환경 preset만 바꿨다면 이 값과 위치 오차·precision/recall·PSR을 함께
|
같은 입력과 seed에서 환경 preset만 바꿨다면 이 값과 위치 오차·precision/recall·PSR을 함께
|
||||||
비교할 수 있다.
|
비교할 수 있다.
|
||||||
|
|
||||||
Result 최상위 `algorithm`은 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 기록한다. band의
|
Result 최상위 `algorithm`은 실제 실행한 `das`, `das-phat`, `gcc-phat` 또는 `music`을 기록한다.
|
||||||
`rawMap.scale`은 DAS에서 `power`, 두 PHAT 방식에서 `score`다. peak 정규화된 `displayMap`은
|
band의 `rawMap.scale`은 DAS에서 `power`, 두 PHAT 방식에서 `score`, MUSIC에서
|
||||||
시각 비교용일 뿐 어느 scale도 확률분포나 신뢰구간으로 바꾸지 않는다.
|
`noise-projection`이다. MUSIC Result는 `musicModel`과 band별 `musicDiagnostics`에 아날로그
|
||||||
|
가설, alias inversion, covariance/EVD audit와 K 선택 가정을 기록한다. 표시 변환은 어느 scale도
|
||||||
|
확률분포나 신뢰구간으로 바꾸지 않는다.
|
||||||
|
|
||||||
## 정확도 범위와 제외 사항
|
## 정확도 범위와 제외 사항
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# 전 대역 활동 검출 모델
|
||||||
|
|
||||||
|
이 문서는 위치 추정 전에 ADC 대역에서 관측되는 신호 변화를 찾는 보조 분석의
|
||||||
|
의미와 한계를 정의한다. 검출기는 Scene의 음원 주파수·위치·개수를 입력으로 받지
|
||||||
|
않으며, 검출 결과를 위치 정답이나 분석 대역으로 자동 변환하지 않는다.
|
||||||
|
|
||||||
|
## Camera에서 가장 짧은 사용 순서
|
||||||
|
|
||||||
|
1. Acoustic Camera의 분석 대역 아래 **전 대역 활동 검사**를 펼친다.
|
||||||
|
2. 한 물리 마이크와 OS-CFAR, CA-CFAR 또는 Dixon spectral flux를 고른다.
|
||||||
|
3. **전 대역 검사**를 실행하고 frame의 raw 통계와 실제 판정 가능 시각
|
||||||
|
<code>available</code>을 확인한다.
|
||||||
|
4. CFAR candidate가 있으면 관측 ADC center와 같은 bin으로 접히는 아날로그
|
||||||
|
FFT-grid center 가설을 직접 입력한다.
|
||||||
|
5. **이 가설을 대역으로 추가**하고 원하는 위치추정기를 별도로 실행한다.
|
||||||
|
6. 모든 complete frame/bin 감사값은 **Activity JSON**으로 저장한다.
|
||||||
|
|
||||||
|
Activity 실행에는 Scene·선택 마이크·STFT·신호 합성용 최소 거리와 참조하는 audio
|
||||||
|
asset만 필요하다. 위치추정 band, MUSIC source-count, backend, scan 또는 배경 image
|
||||||
|
연결 오류는 이 검출을 막지 않는다. 반대로 Scene, 선택한 마이크 응답이나 audio
|
||||||
|
asset이 잘못되면 물리 입력 자체를 만들 수 없으므로 실행하지 않는다.
|
||||||
|
|
||||||
|
예를 들어 (F_s=48,mathrm{kHz})에서 관측한 ADC 1,125 Hz에는 1,125 Hz,
|
||||||
|
46,875 Hz와 49,125 Hz 등의 아날로그 가설이 대응한다. 기본 입력값 1,125 Hz는
|
||||||
|
가장 낮은 가설을 편의상 보여줄 뿐 정답 판정이 아니다. Nyquist 초과 가설도
|
||||||
|
0–1 MHz 검증 범위 안에서 별도 band로 추가할 수 있으며, 서로 다른 alias order와
|
||||||
|
spectral inversion 가설을 합치지 않는다.
|
||||||
|
|
||||||
|
## 관측 경계
|
||||||
|
|
||||||
|
- 입력은 사용자가 고른 **물리 마이크 채널 하나**의 시간 샘플이다. 여러 채널의
|
||||||
|
waveform 평균, power 평균, 최대값, 투표는 배열 형상과 TDOA에 따라 다른 결과를
|
||||||
|
만들기 때문에 기본 알고리즘에 포함하지 않는다.
|
||||||
|
- 실수 ADC의 관측 범위는 \(0\ldots F_s/2\)이다. anti-alias filter가 없는 현재
|
||||||
|
시뮬레이션에서는 \(F_s/2\)를 넘는 아날로그 성분도 샘플링되지만, 검출기가 보는
|
||||||
|
것은 접힌 ADC bin이다.
|
||||||
|
- 검출된 ADC bin만으로 아날로그 alias order, spectral inversion 전의 원 주파수,
|
||||||
|
음원 수 또는 위치를 식별할 수 없다. 배열 설계 비교에서는 같은 ADC bin에
|
||||||
|
대응하는 아날로그 주파수 가설을 별도 Experiment band로 명시해 비교한다.
|
||||||
|
- 정확한 DC와 Nyquist bin은 실수 성분 하나뿐이어서 내부 complex FFT bin과
|
||||||
|
통계 모델이 다르다. CFAR의 CUT와 reference cell에서 제외한다.
|
||||||
|
|
||||||
|
## 시뮬레이터와 detector의 경계
|
||||||
|
|
||||||
|
Camera는 Scene의 source와 음향 경로로 선택한 물리 마이크 **하나**의 수신 신호를
|
||||||
|
먼저 합성한다. 이는 시험 입력을 만드는 forward simulator이므로 source 위치·주파수와
|
||||||
|
개수를 사용한다. 그 다음 detector 함수에는 이 한 채널 sample, sample rate, STFT와
|
||||||
|
검출 설정만 전달하며 source identity·위치·주파수·개수 정답은 전달하지 않는다.
|
||||||
|
Activity JSON은 이 경계를
|
||||||
|
<code>sceneSourceTruthUsedByPhysicalSimulator: true</code>와
|
||||||
|
<code>sceneSourceTruthPassedToDetector: false</code>로 구분한다.
|
||||||
|
|
||||||
|
Report는 source ID와 source별 도착 목록을 내보내지 않지만, 적용한 음향 모델,
|
||||||
|
종류별 path 수와 최대 지연 같은 집계 physical audit는 남긴다. 이 값은 source에서
|
||||||
|
파생된 정보이므로
|
||||||
|
<code>sourceDerivedAggregatePhysicalAuditIncluded: true</code>라고 명시한다.
|
||||||
|
Detector 판정 입력과 사후 감사 metadata를 같은 의미로 부르지 않는다.
|
||||||
|
|
||||||
|
Scene mix가 peak normalization이면 선택한 한 채널의 **전체 render peak**로 먼저
|
||||||
|
scale한다. 선택하지 않은 마이크에는 의존하지 않지만 미래 sample에는 의존한다.
|
||||||
|
따라서 detector는 주어진 sample 이후 causal이어도 raw flux와 절대 delta event를
|
||||||
|
포함한 end-to-end 전처리는 causal이 아니다. 이 경우 UI와 JSON의
|
||||||
|
<code>futureDependentNormalization</code>이 true가 된다. 물리 amplitude scale을
|
||||||
|
비교하려면 Scene normalization을 none으로 둔다.
|
||||||
|
|
||||||
|
## CA-CFAR와 OS-CFAR
|
||||||
|
|
||||||
|
CFAR는 한 STFT frame 안의 좁은 spectral outlier를 찾는다. frame 또는 마이크
|
||||||
|
사이의 평균을 내지 않고 각 frame을 독립적으로 검사한다. 좌우 training cell을
|
||||||
|
각각 \(R\)개, guard cell을 각각 \(G\)개 쓰며 \(N=2R\)이다.
|
||||||
|
|
||||||
|
CA-CFAR는 training power의 평균 \(Z\)를 사용한다.
|
||||||
|
|
||||||
|
\[
|
||||||
|
Y_b > \alpha Z,\qquad
|
||||||
|
\alpha=N\left(P_{fa}^{-1/N}-1\right)
|
||||||
|
\]
|
||||||
|
|
||||||
|
OS-CFAR는 training power를 오름차순 정렬한 뒤 명시한 1-based rank \(k\)의
|
||||||
|
\(Y_{(k)}\)를 사용한다.
|
||||||
|
|
||||||
|
\[
|
||||||
|
Y_b > \alpha Y_{(k)},\qquad
|
||||||
|
P_{fa}=\prod_{j=0}^{k-1}\frac{N-j}{N-j+\alpha}
|
||||||
|
\]
|
||||||
|
|
||||||
|
OS-CFAR multiplier는 이 단조식을 수치적으로 풀어 구한다. 다른 \(N,k\)의 값을
|
||||||
|
재사용하지 않는다. 두 방식은 독립 결과이며 합의, winner 선택 또는 위치 map
|
||||||
|
융합을 하지 않는다.
|
||||||
|
|
||||||
|
여기서 \(P_{fa}\)는 CUT와 모든 training cell의 power가 독립·동일 평균의 지수
|
||||||
|
분포라는 square-law complex-Gaussian 모델 아래에서만 성립하는 **tested CUT
|
||||||
|
하나당 명목 오경보율**이다. posterior probability, confidence, frame 전체의
|
||||||
|
오경보율 또는 실측 보정값이 아니다. Hann window의 인접 bin 상관, 마이크 주파수
|
||||||
|
응답, colored noise, 잔향, noise-floor 경계와 off-bin leakage는 이 전제를
|
||||||
|
깨뜨린다. 결과에는 원 power, reference statistic, threshold, ratio와
|
||||||
|
`model-derived-not-empirically-calibrated` 상태를 그대로 남긴다.
|
||||||
|
|
||||||
|
full symmetric reference window를 만들 수 없는 edge bin은 reference 수를
|
||||||
|
줄이거나 wrap하지 않고 `untested`로 남긴다. 검출된 모든 bin을 반환하며 top-K,
|
||||||
|
NMS, gap merge, persistence 또는 임의 대역 padding을 적용하지 않는다.
|
||||||
|
|
||||||
|
CA-CFAR는 [Finn–Johnson의 adaptive threshold
|
||||||
|
detector](https://www.rsp-italy.it/Electronics/Magazines/RCA%20Review/_contents/RCA%20Review%201968-09.pdf)를
|
||||||
|
기준선으로 삼고, OS-CFAR는 다중 target/clutter 환경을 다룬
|
||||||
|
[Rohling의 order-statistic CFAR](https://doi.org/10.1109/TAES.1983.309350)를
|
||||||
|
따른다. 비균질 배경에서의 한계는
|
||||||
|
[Gandhi–Kassam](https://doi.org/10.1109/7.7185), window leakage의 해석은
|
||||||
|
[Harris](https://doi.org/10.1109/PROC.1978.10837)를 참고한다.
|
||||||
|
|
||||||
|
## Spectral flux onset
|
||||||
|
|
||||||
|
갑자기 시작되는 broadband 또는 tonal 변화를 보는 별도 지표는
|
||||||
|
[Dixon의 linear-magnitude spectral
|
||||||
|
flux](https://www.dafx.de/paper-archive/2006/papers/p_133.pdf)를 사용한다.
|
||||||
|
Hamming-window STFT의 연속 frame에 대해
|
||||||
|
|
||||||
|
\[
|
||||||
|
d[n,k]=\max(0,|X[n,k]|-|X[n-1,k]|),\qquad
|
||||||
|
SF[n]=\sum_k d[n,k]
|
||||||
|
\]
|
||||||
|
|
||||||
|
를 계산한다. per-bin 양의 차이와 합계 원값을 모두 보존하며 log compression,
|
||||||
|
전역 정규화, SuperFlux 일부 또는 마이크 융합을 섞지 않는다.
|
||||||
|
|
||||||
|
실시간 peak 선택은
|
||||||
|
[Böck·Krebs·Schedl의 causal peak
|
||||||
|
picker](https://www.cp.jku.at/research/papers/Boeck_etal_ISMIR_2012.pdf)를
|
||||||
|
별도 단계로 쓸 수 있다. 현재 frame이 과거 \(w_1\) frame의 local maximum이고,
|
||||||
|
과거 \(w_3\) frame 평균보다 사용자가 지정한 \(\delta\)만큼 크며, 마지막
|
||||||
|
검출에서 \(w_5\) frame보다 멀 때 onset으로 표시한다. \(\delta\)는 확률이 아닌
|
||||||
|
운용 임계값이고 환경 데이터로 보정해야 한다. 논문에서 음악 데이터로 최적화한
|
||||||
|
값을 보편 default나 물리 상수로 취급하지 않는다.
|
||||||
|
|
||||||
|
Spectral flux는 **onset novelty**이지 지속 activity detector가 아니다. 정상
|
||||||
|
상태의 tone은 첫 상승 뒤 flux가 거의 0이 되며, slow attack은 여러 frame에
|
||||||
|
분산되어 threshold를 넘지 못할 수 있다. vibrato, tremolo와 확률적 noise
|
||||||
|
변화는 false trigger를 만들 수 있다. STFT frame을 모두 받은 뒤에야 판단할 수
|
||||||
|
있으므로 실제 availability latency는 최소 \(N/F_s\), 갱신 간격은 \(H/F_s\)다.
|
||||||
|
|
||||||
|
## 배열 설계에서의 사용 순서
|
||||||
|
|
||||||
|
1. 물리 마이크 한 개와 검출 알고리즘·파라미터를 명시한다.
|
||||||
|
2. raw ADC-bin 검출 또는 raw onset curve와 모든 설정을 비교한다.
|
||||||
|
3. 관심 ADC bin을 선택하되, 이것을 주파수 정답이나 bandwidth 추정으로 부르지
|
||||||
|
않는다.
|
||||||
|
4. baseband 또는 명시한 초과-Nyquist 아날로그 가설을 Experiment band로
|
||||||
|
추가한다.
|
||||||
|
5. 같은 Scene·Array·가설에서 DAS, PHAT, GCC-PHAT, MUSIC의 raw map과 실패
|
||||||
|
양상을 비교한다. 서로 다른 band의 map은 알려진 별도 wideband 방법을
|
||||||
|
구현하기 전까지 자동 결합하지 않는다.
|
||||||
|
|
||||||
|
Activity의 독립 preflight는 합성과 detector output 상한을 먼저 계산한다. CFAR는
|
||||||
|
complete frame, frame×bin decision과 FFT butterfly hard cap을, spectral flux는
|
||||||
|
모든 frame×full-DFT-bin 차이를 보존하는 output byte 상한을 실제 함수와 같은
|
||||||
|
estimator로 검사한다. 상한을 넘으면 일부 frame이나 top-K bin만 반환하지 않고
|
||||||
|
신호 합성 전에 실패한다.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 마이크 어레이 수신 빔 패턴
|
||||||
|
|
||||||
|
Array Builder의 **수신 빔 패턴**은 현재 마이크 좌표에 conventional delay-and-sum을 적용했을 때의
|
||||||
|
narrowband far-field array factor를 360° polar 단면으로 보여줍니다. 마이크는 송신원이 아니므로
|
||||||
|
엄밀히는 방사 패턴이 아니라 **수신 응답**입니다.
|
||||||
|
|
||||||
|
## 가장 짧은 사용법
|
||||||
|
|
||||||
|
1. Array Builder에서 마이크 배치를 만들거나 Array JSON을 엽니다.
|
||||||
|
2. 배열 canvas 아래의 **수신 빔 패턴**을 펼칩니다.
|
||||||
|
3. 실제 음향파의 **아날로그 주파수**와 음속을 입력합니다.
|
||||||
|
4. XZ, YZ 또는 XY 360° 절단면과 조향각을 고릅니다.
|
||||||
|
5. 기본 **자동 · 위상 간격 기준**을 사용하고, 계산량 비교가 필요할 때만 고정 표본 수를 고릅니다.
|
||||||
|
6. polar 선, 조향 응답, 조건부 −3.0103 dB 폭과 sampled local maxima를 함께 읽습니다.
|
||||||
|
|
||||||
|
마이크를 드래그하는 동안에는 최대 720개 각도의 가벼운 preview를 표시합니다. 마우스나 pointer를
|
||||||
|
놓으면 선택한 표본 설정으로 다시 계산합니다. 빔 패턴 설정은 배열 좌표가 아니므로 undo history,
|
||||||
|
저장되지 않은 변경 상태와 Array JSON에는 들어가지 않습니다.
|
||||||
|
|
||||||
|
## 물리식과 좌표
|
||||||
|
|
||||||
|
마이크 위치 <code>r_m</code>, 배열 중심 <code>r_c</code>, array→source 방향 단위벡터
|
||||||
|
<code>u</code>, 조향 방향 <code>u0</code>, 아날로그 주파수 <code>f</code>, 음속
|
||||||
|
<code>c</code>에 대해 표시하는 complex receive array factor는 다음입니다.
|
||||||
|
|
||||||
|
<code>B(u) = (1/M) Σ_m exp[j (2πf/c) (u-u0)·(r_m-r_c)]</code>
|
||||||
|
|
||||||
|
<code>1/M</code>은 sampled maximum에 맞춘 사후 정규화가 아니라 고정 conventional
|
||||||
|
delay-and-sum weight입니다. 따라서 조향 방향은 이상적 모델에서 1이고, 같은 0 dB 응답이 여러
|
||||||
|
방향에 있으면 모두 그대로 남습니다. Array Builder는 <code>|B|²</code>와
|
||||||
|
<code>10 log10(|B|²)</code>를 계산하며, polar 표시 하한만 별도 clamp합니다. 표시 하한을 바꿔도
|
||||||
|
raw complex, power, peak 또는 HPBW 계산은 바뀌지 않습니다.
|
||||||
|
|
||||||
|
절단면 각도는 오른손 Array 좌표계에서 다음과 같습니다.
|
||||||
|
|
||||||
|
| 절단면 | 0° | +90° | 의미 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| XZ | +Z | +X | 기본. XY 평면 배열의 broadside와 x축 단면 |
|
||||||
|
| YZ | +Z | +Y | XY 평면 배열의 broadside와 y축 단면 |
|
||||||
|
| XY | +X | +Y | 배열 평면 안의 edge 방향 단면 |
|
||||||
|
|
||||||
|
polar plot은 0°를 오른쪽, +90°를 위쪽에 그립니다. 각 plot은 구면 전체가 아니라 하나의
|
||||||
|
great-circle 단면입니다. 임의 3D 배열을 세 단면만으로 완전히 평가할 수는 없습니다.
|
||||||
|
|
||||||
|
## 모델에 들어가는 것과 빠지는 것
|
||||||
|
|
||||||
|
현재 array factor는 다음 조건을 명시적으로 고정합니다.
|
||||||
|
|
||||||
|
- analog monochromatic narrowband 가설
|
||||||
|
- 자유장의 far-field plane wave
|
||||||
|
- 위치만 가진, 이상적으로 보정된 무지향성 point microphone
|
||||||
|
- uniform conventional delay-and-sum weight
|
||||||
|
- meter 좌표와 사용자가 입력한 음속
|
||||||
|
|
||||||
|
따라서 방 반사·회절·산란, near-field 거리와 구면 감쇠, baffle·housing·상호결합, 마이크의 각도별
|
||||||
|
element directivity는 포함하지 않습니다. Array JSON의 <code>sensitivityDb</code>,
|
||||||
|
<code>phaseOffsetDeg</code>와 <code>frequencyResponse</code>도 이 geometry-only plot에는
|
||||||
|
섞지 않습니다. 이 보정들은 합성 및 위치추정 pipeline에서 별도로 적용됩니다. 주파수 응답은
|
||||||
|
주파수별 channel transfer이고, 그 자체가 각도별 element pattern은 아닙니다.
|
||||||
|
|
||||||
|
## Nyquist와 공간 alias
|
||||||
|
|
||||||
|
이 계산에는 ADC sample rate 입력이 없습니다. 파수는 항상 원래 아날로그 주파수로
|
||||||
|
<code>k=2πf/c</code>를 계산하며, 48 kHz ADC를 가정했을 때 Nyquist를 넘는 49,125 Hz 같은 값도
|
||||||
|
1,125 Hz로 접지 않습니다. 이는 배열 설계에서 실제 파장과 grating/ambiguity를 관찰하기 위한
|
||||||
|
의도된 동작입니다.
|
||||||
|
|
||||||
|
ADC의 temporal alias, anti-alias filter와 signed alias orientation은 sampling 단계의 문제입니다.
|
||||||
|
마이크 간격·파장·조향이 만드는 spatial alias와는 별개입니다. 이 plot은 sampled local maximum을
|
||||||
|
보여줄 수 있지만, 그것을 자동으로 grating lobe나 sidelobe로 분류하지 않습니다. planar 배열의
|
||||||
|
앞·뒤 0 dB 동률처럼 서로 다른 원인도 같은 단면 응답으로 나타날 수 있기 때문입니다.
|
||||||
|
|
||||||
|
## HPBW와 각도 해상도
|
||||||
|
|
||||||
|
HPBW는 조향 방향을 포함하는 연결 lobe에서 raw power가 정확히 0.5, 즉 −3.010299956… dB가 되는
|
||||||
|
양쪽 crossing을 찾았을 때만 표시합니다. 표본 두 점이 crossing을 bracket하면 raw analytic
|
||||||
|
evaluator로 각 경계를 48회 이분합니다.
|
||||||
|
|
||||||
|
고주파·큰 aperture에서는 고정 각도 grid가 매우 좁은 lobe나 dip을 건너뛸 수 있습니다. 이를 숨기지
|
||||||
|
않기 위해 투영된 최대 microphone baseline <code>D</code>, 각도 간격
|
||||||
|
<code>Δ=2π/N</code>에 대한 인접 방향 pair phase 변화 상한
|
||||||
|
<code>2 k D sin(Δ/2)</code>를 계산합니다. 현재 metric 해석 기준은 이 상한이
|
||||||
|
<code>π/8</code> 이하인 경우입니다.
|
||||||
|
|
||||||
|
이 기준은 공개된 수치 해상도 정책이지, 모든 연속 lobe와 half-power excursion을 검출한다는
|
||||||
|
수학적 증명이 아닙니다. 기준을 넘으면 raw polar 표본은 그대로 표시하되 HPBW와 peak 해석을
|
||||||
|
<code>resolution-unresolved</code>로 보류합니다. 기준을 만족해도 half-power crossing을 표본에서
|
||||||
|
찾지 못한 비상수 패턴은 360° HPBW라고 만들지 않고
|
||||||
|
<code>no-sampled-half-power-crossing</code>으로 남깁니다. 단일 또는 절단면 투영 좌표가 모두 같은
|
||||||
|
배열만 analytic constant로 판정하고 폭을 <code>null</code>로 반환합니다.
|
||||||
|
|
||||||
|
자동 표본은 최소 720개에서 시작해 이 phase-step 기준에 맞게 늘어나며 최대 14,400개와 전체 phasor
|
||||||
|
평가 상한을 지킵니다. 상한에서도 기준을 만족하지 못하면 계산을 예쁘게 보이도록 평활화하거나
|
||||||
|
peak를 제거하지 않고 미확정 상태를 표시합니다.
|
||||||
|
|
||||||
|
## portable API
|
||||||
|
|
||||||
|
<code>beam-pattern.js</code>는 DOM, Scene, ADC와 외부 package에 의존하지 않는 ES module입니다.
|
||||||
|
|
||||||
|
~~~js
|
||||||
|
import { computeReceiveBeamPattern } from "./packages/acoustics-core/beam-pattern.js";
|
||||||
|
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: 49_125,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: "auto",
|
||||||
|
dbFloor: -60,
|
||||||
|
});
|
||||||
|
|
||||||
|
drawPolar(pattern.anglesDeg, pattern.displayDb);
|
||||||
|
|
||||||
|
if (!pattern.angularSampling.metricResolutionSatisfied) {
|
||||||
|
showUnresolved(pattern.angularSampling.maximumAdjacentPhaseStepRadBound);
|
||||||
|
}
|
||||||
|
~~~
|
||||||
|
|
||||||
|
결과는 normalized raw <code>responseReal</code>, <code>responseImaginary</code>,
|
||||||
|
<code>responsePower</code>, <code>responseLinear</code>, unclamped <code>responseDb</code>와
|
||||||
|
표시용 <code>displayDb</code>를 각각 보존합니다. workload에는 baseline pair 비교, sampled phasor,
|
||||||
|
HPBW용 최대·실제 평가 수가 따로 기록됩니다. 1,024채널, 0–1 MHz, 각도·phasor·baseline 상한은
|
||||||
|
할당과 큰 반복 전에 검사합니다.
|
||||||
|
|
||||||
|
far-field steering vector와 beamformer response를 구현한 독립 참고 구현은
|
||||||
|
[Pyroomacoustics 공식 beamforming source](https://github.com/LCAV/pyroomacoustics/blob/master/pyroomacoustics/beamforming.py)에서
|
||||||
|
확인할 수 있습니다. array factor와 element pattern의 구분은
|
||||||
|
[MathWorks 공식 설명](https://www.mathworks.com/help/phased/ug/element-and-array-radiation-patterns-and-responses.html)을
|
||||||
|
함께 참고하세요.
|
||||||
+113
-20
@@ -1,8 +1,9 @@
|
|||||||
# 데이터 계약
|
# 데이터 계약
|
||||||
|
|
||||||
micarrsim의 저장 형식은 JSON Schema Draft 2020-12와 추가 관계 검증으로 정의한다. 모든 길이는 m,
|
micarrsim의 핵심 Array·Scene·Experiment·Result 형식은 JSON Schema Draft 2020-12와 추가 관계
|
||||||
시간은 s, 주파수는 Hz다. 배열 중심이 원점이고 `+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서
|
검증으로 정의한다. Activity와 비교 기록 같은 편의 감사 export는 이 문서에서 별도 범위를
|
||||||
장면을 향하는 우수 좌표계를 사용한다.
|
명시한다. 모든 길이는 m, 시간은 s, 주파수는 Hz다. 배열 중심이 원점이고 `+x`는 오른쪽,
|
||||||
|
`+y`는 위쪽, `+z`는 배열에서 장면을 향하는 우수 좌표계를 사용한다.
|
||||||
|
|
||||||
## 파일 종류
|
## 파일 종류
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ micarrsim의 저장 형식은 JSON Schema Draft 2020-12와 추가 관계 검증
|
|||||||
- `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest
|
- `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest
|
||||||
- `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 위치 추정 설정
|
- `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 위치 추정 설정
|
||||||
- `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보
|
- `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보
|
||||||
|
- `micarrsim.activity-report`: 단일 물리 마이크의 raw CFAR 또는 spectral-flux 감사 export.
|
||||||
|
core schema 검증 대상은 아니며 위치추정 Result를 대신하지 않는다.
|
||||||
- `micarrsim.comparison-history`: 세션의 배열별 Result 요약을 모은 편의 export. core schema
|
- `micarrsim.comparison-history`: 세션의 배열별 Result 요약을 모은 편의 export. core schema
|
||||||
검증 대상은 아니며 각 원본 Result JSON을 대신하지 않는다.
|
검증 대상은 아니며 각 원본 Result JSON을 대신하지 않는다.
|
||||||
|
|
||||||
@@ -25,11 +28,12 @@ array의 `sensitivityDb`와 `phaseOffsetDeg`는 각 마이크 수신 channel에
|
|||||||
측정 범위 밖에서는 가까운 끝점을 사용한다.
|
측정 범위 밖에서는 가까운 끝점을 사용한다.
|
||||||
|
|
||||||
Array Builder의 사각형/타원 생성 경계, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백,
|
Array Builder의 사각형/타원 생성 경계, 프리셋 종류, 행·열, random seed·최소 간격·경계 여백,
|
||||||
snap과 zoom은 편집기 상태이며 `micarrsim.array` 계약에 포함되지 않는다. 저장 파일에는
|
snap·zoom과 수신 빔 패턴의 주파수·음속·절단면·조향각·angular sample·display floor는 편집기
|
||||||
|
preview 상태이며 `micarrsim.array` 계약에 포함되지 않는다. 저장 파일에는
|
||||||
`aperture.width/height`와 최종 `microphones` 좌표·순서만 들어간다. 따라서 다른 도구는
|
`aperture.width/height`와 최종 `microphones` 좌표·순서만 들어간다. 따라서 다른 도구는
|
||||||
형상 이름을 추론하지 않고 실제 좌표를 배열 geometry의 기준으로 사용해야 한다.
|
형상 이름을 추론하지 않고 실제 좌표를 배열 geometry의 기준으로 사용해야 한다.
|
||||||
|
|
||||||
Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`다. backend와
|
Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`, `music`이다. backend와
|
||||||
raw map 계약은 다음과 같다.
|
raw map 계약은 다음과 같다.
|
||||||
|
|
||||||
| algorithm | `rawMap.scale` | 허용 backend |
|
| algorithm | `rawMap.scale` | 허용 backend |
|
||||||
@@ -37,11 +41,19 @@ raw map 계약은 다음과 같다.
|
|||||||
| `das` | `power` | `cpu`, `webgpu`; `auto` 가능 |
|
| `das` | `power` | `cpu`, `webgpu`; `auto` 가능 |
|
||||||
| `das-phat` | `score` | `cpu`, `webgpu`; `auto` 가능 |
|
| `das-phat` | `score` | `cpu`, `webgpu`; `auto` 가능 |
|
||||||
| `gcc-phat` | `score` | `cpu`; `auto`는 CPU 선택 |
|
| `gcc-phat` | `score` | `cpu`; `auto`는 CPU 선택 |
|
||||||
|
| `music` | `noise-projection` | `cpu`; `auto`는 CPU 선택 |
|
||||||
|
|
||||||
`gcc-phat`에 명시적 `webgpu`를 조합하면 검증 오류이며, 마이크가 두 개 이상이어야 한다. 필드
|
`gcc-phat` 또는 `music`에 명시적 `webgpu`를 조합하면 검증 오류이며, 마이크가 두 개 이상이어야 한다. 필드
|
||||||
그룹 이름 `beamforming`은 기존 계약과의 호환을 위해 유지하지만 세 알고리즘을 모두 선택하는 위치
|
그룹 이름 `beamforming`은 기존 계약과의 호환을 위해 유지하지만 네 알고리즘을 모두 선택하는 위치
|
||||||
추정 설정이다.
|
추정 설정이다.
|
||||||
|
|
||||||
|
MUSIC은 `normalization: "none"`만 허용한다. band마다 analog FFT-grid 가설을 정확히 하나
|
||||||
|
선택하고, 접힌 양의 ADC bin 하나에서 snapshot covariance를 만든다. Fixed K는 `1..M-1`이다.
|
||||||
|
Wax–Kailath `mdl`/`aic`는 complete non-overlapping frame, `T > M`, 엄격히 양수인 noise
|
||||||
|
eigenvalue와 IID complex Gaussian snapshot·spatially white noise·고정 source 수를 가정한다.
|
||||||
|
non-overlap은 독립성의 증명이 아니다. 조건 불충족은 fixed fallback 없이 typed
|
||||||
|
`MUSIC_UNAVAILABLE`이다.
|
||||||
|
|
||||||
선택적 `beamforming.compensateFrequencyResponse`는 기본 `true`다. 합성 channel에는 curve가
|
선택적 `beamforming.compensateFrequencyResponse`는 기본 `true`다. 합성 channel에는 curve가
|
||||||
항상 적용된다. DAS는 sensitivity와 정적 phase를 항상 역보정하고, 값이 `true`이면 선택 bin에서
|
항상 적용된다. DAS는 sensitivity와 정적 phase를 항상 역보정하고, 값이 `true`이면 선택 bin에서
|
||||||
curve의 gain과 phase도 역보정한다. DAS-PHAT와 GCC-PHAT는 입력과 calibration의 amplitude gain을
|
curve의 gain과 phase도 역보정한다. DAS-PHAT와 GCC-PHAT는 입력과 calibration의 amplitude gain을
|
||||||
@@ -51,7 +63,8 @@ curve의 gain과 phase도 역보정한다. DAS-PHAT와 GCC-PHAT는 입력과 cal
|
|||||||
선택적 `beamforming.maxCompensationDb`는 DAS가 응답 notch를 역보정할 때 양의 gain boost만
|
선택적 `beamforming.maxCompensationDb`는 DAS가 응답 notch를 역보정할 때 양의 gain boost만
|
||||||
제한하는 0–120 dB 값이며 생략 시 24 dB다. 응답 peak를 줄이는 attenuation과 phase 보상은 이
|
제한하는 0–120 dB 값이며 생략 시 24 dB다. 응답 peak를 줄이는 attenuation과 phase 보상은 이
|
||||||
boost 상한의 대상이 아니다. amplitude를 사용하지 않는 DAS-PHAT와 GCC-PHAT에는 이 값이 영향을
|
boost 상한의 대상이 아니다. amplitude를 사용하지 않는 DAS-PHAT와 GCC-PHAT에는 이 값이 영향을
|
||||||
주지 않는다. 세 알고리즘의 계산식과 한계는
|
주지 않는다. MUSIC은 analog 가설에서 residual complex response를 일관되게 manifold에 넣는다.
|
||||||
|
네 알고리즘의 계산식과 한계는
|
||||||
[음향 환경과 마이크 응답 모델](acoustic-model.md)에 설명한다.
|
[음향 환경과 마이크 응답 모델](acoustic-model.md)에 설명한다.
|
||||||
|
|
||||||
## 로컬 asset 재연결
|
## 로컬 asset 재연결
|
||||||
@@ -127,9 +140,8 @@ sidelobe false positive도 늘 수 있다.
|
|||||||
|
|
||||||
## Result 평가 정보
|
## Result 평가 정보
|
||||||
|
|
||||||
새 Result는 최상위 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 기록하며
|
Result 1.1은 최상위 필수 `algorithm`에 실제 실행한 `das`, `das-phat`, `gcc-phat` 또는 `music`을
|
||||||
Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result를 읽기 위해 schema상 선택적으로
|
기록하며 Experiment의 선택값과 같아야 한다. `backend`는 요청값 `auto`가 아니라 실제 사용한
|
||||||
남아 있지만 현재 export에는 항상 들어간다. `backend`는 요청값 `auto`가 아니라 실제 사용한
|
|
||||||
`cpu` 또는 `webgpu`다.
|
`cpu` 또는 `webgpu`다.
|
||||||
|
|
||||||
관계 검증은 `das → power`, `das-phat/gcc-phat → score`를 강제한다. Peak에 `beamSpread`가
|
관계 검증은 `das → power`, `das-phat/gcc-phat → score`를 강제한다. Peak에 `beamSpread`가
|
||||||
@@ -137,8 +149,8 @@ Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result
|
|||||||
구조상 유효하지만 의미가 모순된 외부 Result도 import 시 거절된다.
|
구조상 유효하지만 의미가 모순된 외부 Result도 import 시 거절된다.
|
||||||
|
|
||||||
`timingMs.localization`은 선택한 위치 추정 알고리즘의 wall time이다. 현재 export는 기존 DAS 전용
|
`timingMs.localization`은 선택한 위치 추정 알고리즘의 wall time이다. 현재 export는 기존 DAS 전용
|
||||||
소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.0.0` schema는 이전
|
소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.1.0` schema는
|
||||||
문서를 위해 `beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을
|
`beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을
|
||||||
함께 저장해야 한다. `timingMs.total`은 전체 시간이고 선택적 `signalGeneration`은 합성 시간이다.
|
함께 저장해야 한다. `timingMs.total`은 전체 시간이고 선택적 `signalGeneration`은 합성 시간이다.
|
||||||
|
|
||||||
각 band의 `peaks`는 위치와 해당 `rawMap.scale`의 값, 선택적 `sourceId`, `localizationErrorM`,
|
각 band의 `peaks`는 위치와 해당 `rawMap.scale`의 값, 선택적 `sourceId`, `localizationErrorM`,
|
||||||
@@ -168,14 +180,57 @@ threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic r
|
|||||||
버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z,
|
버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z,
|
||||||
가능한 경우 GPU adapter 및 fallback 정보를 기록한다.
|
가능한 경우 GPU adapter 및 fallback 정보를 기록한다.
|
||||||
|
|
||||||
`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 선택적으로
|
`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 필수 `sampling` provenance와 선택적
|
||||||
`acousticModel`, 실제 전체 `acousticPathCount`와 `acousticPathCounts`를
|
`acousticModel`, 실제 전체 `acousticPathCount`와 `acousticPathCounts`를
|
||||||
기록한다. 종류별 key는 `direct`, `reflection`, `scattering`,
|
기록한다. 종류별 key는 `direct`, `reflection`, `scattering`,
|
||||||
`diffraction`과 `diffuse`다.
|
`diffraction`과 `diffuse`다.
|
||||||
|
|
||||||
대역별 ground truth는 tone의 주파수가 band 안에 있을 때 그 tone source를 포함한다. noise와
|
각 band의 필수 `frequencyObservation`은 연속 analog fold와 이산 FFT 계산 선택을 구분한다.
|
||||||
asset source는 현재 모든 band의 truth로 간주한다. 즉 녹음 asset의 실제 spectrum energy를
|
`foldSegments`는 1-based Nyquist zone·반전·alias order를,
|
||||||
대역별로 판정하는 계약은 아직 없으며, 이 가정은 실제 데이터 결과를 해석할 때 고려해야 한다.
|
`adcBins`/`adcBinCenterRanges`는
|
||||||
|
`selectionModel: fft-bin-center-membership`으로 선택된 bin center를 뜻한다.
|
||||||
|
요청 `minHz`와 `maxHz`에 정확히 놓인 FFT bin center도 선택한다. 정확한 Nyquist
|
||||||
|
경계의 `+Fs/2`와 `-Fs/2`는 같은 실수 sample이며, point provenance는 `+Fs/2`로
|
||||||
|
canonicalize하고 fold segment endpoint는 zone 진행 방향에 따라 둘 중 하나를 기록한다.
|
||||||
|
`localizationInputModel`은 one-sided real FFT bin만 사용함을 밝히며, `localizationModel`은
|
||||||
|
DAS 계열의 구면 steering, GCC-PHAT의 기하 lag와 MUSIC의 configured-analog-frequency
|
||||||
|
near-field manifold를 구분한다. DAS/DAS-PHAT/GCC-PHAT의 `originalCarrierUsed`는 `false`이고
|
||||||
|
Nyquist 위 band의 `aliasOrderResolution`은 `not-attempted`다. MUSIC의
|
||||||
|
`originalCarrierUsed: true`는 Scene truth가 아니라 사용자가 지정한 analog 가설을 뜻한다.
|
||||||
|
`equivalentBandIds`가 있으면 그 band들은 같은 ADC bin 집합을 공유하므로 독립
|
||||||
|
관측으로 세면 안 된다.
|
||||||
|
|
||||||
|
대역별 ground truth의 실제 정책은 evaluation의
|
||||||
|
`truthSelection: tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources`에
|
||||||
|
기록한다. Tone은 원 주파수가 요청 analog interval 안에 있거나, 접힌 주파수의 nearest ADC
|
||||||
|
bin이 선택됐을 때 포함한다. noise와 asset source는 모든 band에 포함한다. 녹음 asset의
|
||||||
|
spectrum을 임의 threshold로 분류하는 미검증 multiband 알고리즘은 사용하지 않는다.
|
||||||
|
|
||||||
|
## Activity JSON
|
||||||
|
|
||||||
|
Acoustic Camera의 Activity export는 <code>schemaVersion: "1.0.0"</code>,
|
||||||
|
<code>kind: "micarrsim.activity-report"</code>인 편의 감사 문서다. 최상위에는 실제 실행
|
||||||
|
Array·Scene·Experiment ID, 생성 시각과 64자리 SHA-256 <code>inputHash</code>가 있고,
|
||||||
|
<code>report</code>에는 다음 값이 있다.
|
||||||
|
|
||||||
|
- 사용자가 선택한 원래 microphone index·id·position과 microphone fusion 없음
|
||||||
|
- sample rate/count, FFT/hop, ADC 0…Fs/2와 alias order 미결정 상태
|
||||||
|
- simulator가 Scene truth로 물리 sample을 만들었다는 사실과 detector에는 truth를 전달하지
|
||||||
|
않았다는 분리 표기
|
||||||
|
- 선택 마이크 한 채널의 normalization·clipping·음향 경로 집계와 causal 전처리 여부
|
||||||
|
- CFAR의 모든 complete frame×모든 ADC bin 또는 spectral flux의 raw ODF와 모든
|
||||||
|
frame×full-DFT-bin 양의 차이
|
||||||
|
- 알고리즘 설정·가정·보정 상태, workload와 signal-generation/detector timing
|
||||||
|
|
||||||
|
Source identity와 source별 arrival 목록은 포함하지 않지만 path 수·최대 지연 같은 source-derived
|
||||||
|
집계 physical audit는 포함하며 이를 명시한다. CFAR의 <code>untested</code> bin과 zero-threshold
|
||||||
|
ratio 상태도 삭제하지 않는다. Typed array는 JSON 숫자 배열로 변환하고 비유한 수가 생기면
|
||||||
|
문자열 <code>Infinity</code>, <code>-Infinity</code> 또는 <code>NaN</code>으로 명시한다.
|
||||||
|
|
||||||
|
Activity <code>inputHash</code>는 검출에 실제로 사용한 선택 마이크, Scene의 음향 입력
|
||||||
|
(배경 image와 비audio asset 제외), STFT, 합성 최소 거리와 detector 설정의 canonical
|
||||||
|
UTF-8 SHA-256이다. 분석 band·scan·위치추정 algorithm/backend는 detector 입력이 아니므로
|
||||||
|
hash에 넣지 않는다. Array·Scene·Experiment ID는 provenance label로 별도 보존한다.
|
||||||
|
|
||||||
## 재현성 hash
|
## 재현성 hash
|
||||||
|
|
||||||
@@ -186,19 +241,57 @@ SHA-256을 계산해 scene의 해당 asset `sha256` 필드를 채운 상태로 c
|
|||||||
|
|
||||||
## 버전과 검증
|
## 버전과 검증
|
||||||
|
|
||||||
현재 계약 버전은 `1.0.0`이다. 브라우저는 `packages/contracts/schema-validator.js`로 저장소의
|
Array·Scene·Experiment 계약 버전은 `1.0.0`, Result 계약 버전은 `1.1.0`이다. Result
|
||||||
|
`1.1.0`은 sampling provenance와 band별 fold/localization metadata를 필수화하므로 이전
|
||||||
|
`1.0.0` Result와 구조적으로 호환되지 않는다. 브라우저는
|
||||||
|
`packages/contracts/schema-validator.js`로 저장소의
|
||||||
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
|
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
|
||||||
|
|
||||||
|
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한다. `sceneId`와
|
||||||
|
`arrayId`는 provenance label이므로 현재 열린 Scene·Array ID와 비교해 거부하거나 값을
|
||||||
|
덮어쓰지 않는다. 실행에는 현재 열린 geometry와 sample data를 사용하고, Result의
|
||||||
|
`sceneId`/`arrayId`와 `inputHash`에는 실제 사용한 입력을 기록한다.
|
||||||
|
|
||||||
|
## MUSIC Result 의미
|
||||||
|
|
||||||
|
MUSIC의 `rawMap.scale`은 `noise-projection`이고
|
||||||
|
`q = ||E_n^H a||²/(a^H a)`의 최솟값이 optimum이다. 표시용 `P/Pmax = q_min/q`도 q와 마찬가지로
|
||||||
|
확률, likelihood, posterior 또는 신뢰구간이 아니다. `q_min = 0`이면 0인 cell만 1로 표시한다.
|
||||||
|
Result의 `musicModel`과 band별 `musicDiagnostics`는 source-count 방법·가정,
|
||||||
|
covariance/eigensolver 감사, analog/ADC alias와 CPU workload를 보존한다.
|
||||||
|
|
||||||
|
MUSIC의 `originalCarrierUsed: true`는 Scene truth가 아니라 Experiment에 사용자가 지정한 analog
|
||||||
|
FFT-grid 가설을 썼다는 뜻이다. analog carrier `f_a`와 관측 alias orientation `η`로
|
||||||
|
`exp[-j η 2π f_a (r_m-r_ref)/c]`를 만들며, inversion이면 calibration과 steering을 포함한 전체
|
||||||
|
complex manifold를 켤레화한다. Scene source carrier/위치 truth는 읽지 않는다. 정확한 DC/Nyquist
|
||||||
|
bin은 quadrature-degenerate라 거절한다. diagonal loading, epsilon/floor, spatial smoothing,
|
||||||
|
multibin averaging/association과 wideband fusion은 없다. grating lobe, 동률과 coherent-source
|
||||||
|
rank deficiency는 숨기지 않고 map 또는 typed unavailable로 나타낸다.
|
||||||
|
이 가져오기 편의 동작은 계약을 완화하지 않는다. 내보내기와 실행에 쓰는 Experiment도 아래 관계를
|
||||||
|
모두 만족해야 한다.
|
||||||
|
|
||||||
- 중복 id와 array/scene/experiment/result 참조
|
- 중복 id와 array/scene/experiment/result 참조
|
||||||
- asset 종류와 안전한 상대 경로
|
- asset 종류와 안전한 상대 경로
|
||||||
- Nyquist 및 시간·scan 범위
|
- ideal sampler의 Nyquist alias mapping 및 시간·scan 범위
|
||||||
- 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계
|
- 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계
|
||||||
- map shape와 source peak 매칭
|
- map shape와 source peak 매칭
|
||||||
- band별 evaluation 파라미터와 허용 metric
|
- band별 evaluation 파라미터와 허용 metric
|
||||||
- Snapshot channel·최대 sparse delay FFT working set의 512 MiB 예산
|
- crop 크기 microphone channel과 한 sampled source의 자체 최대 sparse-delay
|
||||||
|
samples·spectrum·workspace를 합친 typed working set의 512 MiB 예산. validator와 runtime은
|
||||||
|
reflection·scattering·diffraction·diffuse를 포함한 동일 estimator를 사용한다.
|
||||||
|
|
||||||
지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
|
지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
|
||||||
|
|
||||||
|
Scene tone과 Experiment band는 0–1 MHz 검증 범위 안에서 Nyquist를 넘어도 유효하다. 1 MHz는
|
||||||
|
Nyquist 제한이 아니라 수치 위상 정밀도·마이크 응답·희소 음향 모델의 현재 검증 상한이다.
|
||||||
|
이는 원 신호를 복원한다는 뜻이 아니라
|
||||||
|
anti-alias filter가 없는 이상적 real ADC에서 접힌 bin을 관찰한다는 뜻이다. Result는
|
||||||
|
`signalStats.sampling`과 band별 `frequencyObservation`에 원 주파수, signed ADC 주파수,
|
||||||
|
spectral inversion, fold segment와 선택 bin center를 기록한다. 동일 ADC bin에 접힌 analog
|
||||||
|
후보는 단일 channel의 시간 sample만으로 식별할 수 없다. 다채널 공간 위상에는 원 파장 정보가
|
||||||
|
남을 수 있지만 현재 conventional localizer는 carrier hypothesis를 시도하지 않으며,
|
||||||
|
validation이나 평가도 임의의 후보를 정답으로 선택하지 않는다.
|
||||||
|
|
||||||
실측 channel을 받는 portable API는 저장 JSON 계약과 별도로 입력 Array를 같은 규칙으로 검사한다.
|
실측 channel을 받는 portable API는 저장 JSON 계약과 별도로 입력 Array를 같은 규칙으로 검사한다.
|
||||||
CPU 분석 계획은 기본 512 MiB, 반복 steering cache는 64 MiB, streaming ring과 분석창은 기본
|
CPU 분석 계획은 기본 512 MiB, 반복 steering cache는 64 MiB, streaming ring과 분석창은 기본
|
||||||
256 MiB hard cap을 할당 전에 적용한다. 채널 순서와 streaming 상태 계약은
|
256 MiB hard cap을 할당 전에 적용한다. 채널 순서와 streaming 상태 계약은
|
||||||
|
|||||||
+32
-8
@@ -4,7 +4,8 @@
|
|||||||
>
|
>
|
||||||
> 단계 0–4와 단계 5의 저비용 음향 환경·마이크 응답·다중 알고리즘 portable streaming core까지 완료했다.
|
> 단계 0–4와 단계 5의 저비용 음향 환경·마이크 응답·다중 알고리즘 portable streaming core까지 완료했다.
|
||||||
> 설치 없는 브라우저 앱, CPU/WebGPU DAS·DAS-PHAT와 CPU GCC-PHAT, Builder→Camera asset 재연결, 임의 오디오·이동
|
> 설치 없는 브라우저 앱, CPU/WebGPU DAS·DAS-PHAT와 CPU GCC-PHAT, Builder→Camera asset 재연결, 임의 오디오·이동
|
||||||
> trajectory, 빔 분포·복수 peak 감도, 라이브 시간축 재생, 배열 비교와 Result 내보내기를
|
> trajectory, 표준 단일채널 CA/OS-CFAR·Dixon spectral flux, 빔 분포·복수 peak 감도,
|
||||||
|
> 라이브 시간축 재생, 배열 비교와 Result 내보내기를
|
||||||
> 전체 자동 테스트로 검증했다. 실제 장치 adapter와 현장 검증은 장치 계약이 필요한 후속 범위다.
|
> 전체 자동 테스트로 검증했다. 실제 장치 adapter와 현장 검증은 장치 계약이 필요한 후속 범위다.
|
||||||
|
|
||||||
## 1. 목표와 성공 기준
|
## 1. 목표와 성공 기준
|
||||||
@@ -40,13 +41,15 @@ CPU 기준 구현과 GPU 결과의 수치 오차가 합의된 허용 범위 안
|
|||||||
세 앱의 책임은 다음과 같이 분리한다.
|
세 앱의 책임은 다음과 같이 분리한다.
|
||||||
|
|
||||||
- `array-builder`: aperture 크기와 사각/타원 생성 경계, 격자·선형·둘레·Fermat·seed random
|
- `array-builder`: aperture 크기와 사각/타원 생성 경계, 격자·선형·둘레·Fermat·seed random
|
||||||
프리셋, 마이크 직접 편집·채널 순서·보정 보존과 표준 Array JSON 내보내기
|
프리셋, 마이크 직접 편집·채널 순서·보정 보존, receive array-factor polar 분석과 표준 Array
|
||||||
|
JSON 내보내기
|
||||||
- `dataset-builder`: 음원 배치, gain/시간 구간/반복 설정, ambient mix, 이미지상의 위치 지정,
|
- `dataset-builder`: 음원 배치, gain/시간 구간/반복 설정, ambient mix, 이미지상의 위치 지정,
|
||||||
seed 고정, 합성 오디오와 ground truth manifest 내보내기
|
seed 고정, 합성 오디오와 ground truth manifest 내보내기
|
||||||
- `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성,
|
- `acoustic-camera`: manifest와 배열 JSON 읽기, 마이크 신호 전파 합성, steering vector 생성,
|
||||||
DAS 실행, overlay·수치 평가·실험 결과 내보내기
|
DAS 실행, overlay·수치 평가·실험 결과 내보내기
|
||||||
- `acoustics-core`: 앱들이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답,
|
- `acoustics-core`: 앱들이 공유하는 좌표계, 단위, 구면 감쇠·희소 multipath·마이크 응답,
|
||||||
STFT, 정답 rasterization, 지표, CPU reference와 실측 channel용 streaming localizer
|
STFT, far-field receive array factor, 정답 rasterization, 지표, CPU reference와 실측
|
||||||
|
channel용 streaming localizer
|
||||||
- `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark
|
- `webgpu-kernels`: steering/DAS 커널, buffer layout, 장치 한도 검사와 benchmark
|
||||||
|
|
||||||
## 3. 데이터 계약
|
## 3. 데이터 계약
|
||||||
@@ -113,9 +116,15 @@ Scene·Array·Experiment·Assets 준비 상태와 실행 가능 여부를 가까
|
|||||||
일상적으로 바꾸는 backend, grid, 대역과 peak 감도는 설정 단계에 유지한다. 음속, seed, scan
|
일상적으로 바꾸는 backend, grid, 대역과 peak 감도는 설정 단계에 유지한다. 음속, seed, scan
|
||||||
범위, STFT, 음향 환경 preset과 마이크 응답 보상은 **고급 설정**에 묶는다. 장문의 개발 요구나
|
범위, STFT, 음향 환경 preset과 마이크 응답 보상은 **고급 설정**에 묶는다. 장문의 개발 요구나
|
||||||
미구현 기능 홍보는 작업 화면에서 제거하고, 물리 모델·데이터 계약·포팅 설명은 문서로 연결한다.
|
미구현 기능 홍보는 작업 화면에서 제거하고, 물리 모델·데이터 계약·포팅 설명은 문서로 연결한다.
|
||||||
잘못된 단위, Nyquist 초과 대역, room 포함 관계와 CPU/GPU·신호 working-set 한도는 실행 전에
|
잘못된 단위, room 포함 관계와 CPU/GPU·신호 working-set 한도는 실행 전에
|
||||||
검증한다.
|
검증한다.
|
||||||
|
|
||||||
|
Nyquist 초과 tone/band는 배열 설계 실패 양상을 관찰할 수 있도록 허용한다. tone은 analog path
|
||||||
|
phase와 microphone response를 먼저 적용한 뒤 ideal ADC에서 sampling하고, band는 모든 등가 ADC
|
||||||
|
bin center로 접는다. 원 carrier를 이용한 숨은 steering이나 결과 선택은 하지 않으며, 연속
|
||||||
|
Nyquist-zone fold·이산 bin 선택·알고리즘별 localization model·동일 ADC band를 Result 1.1에
|
||||||
|
기록한다. 0–1 MHz는 Nyquist cutoff가 아니라 현재 수치/마이크 모델의 검증 범위다.
|
||||||
|
|
||||||
## 6. 평가 방법
|
## 6. 평가 방법
|
||||||
|
|
||||||
점 음원 위치를 image plane으로 투영해 ground-truth heatmap과 위치 목록을 함께 저장한다. 최소
|
점 음원 위치를 image plane으로 투영해 ground-truth heatmap과 위치 목록을 함께 저장한다. 최소
|
||||||
@@ -172,6 +181,8 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
|||||||
- canvas 추가·선택·drag, 정밀 좌표·ID·채널 순서 편집과 undo/redo
|
- canvas 추가·선택·drag, 정밀 좌표·ID·채널 순서 편집과 undo/redo
|
||||||
- 가져온 sensitivity·phase·frequency response 보존과 표준 Array JSON 왕복
|
- 가져온 sensitivity·phase·frequency response 보존과 표준 Array JSON 왕복
|
||||||
- 좌표 중복 오류, 경계 초과 경고, clamp·중앙 정렬·aperture 맞춤
|
- 좌표 중복 오류, 경계 초과 경고, clamp·중앙 정렬·aperture 맞춤
|
||||||
|
- analog frequency·XZ/YZ/XY 조향의 360° conventional receive array-factor polar plot
|
||||||
|
- raw complex/power 보존, 조건부 HPBW·sampled peak tie와 phase-step 해상도 미확정 상태
|
||||||
|
|
||||||
완료 조건: 만든 Array가 계약 검증을 통과하고 Acoustic Camera에서 그대로 불러와진다.
|
완료 조건: 만든 Array가 계약 검증을 통과하고 Acoustic Camera에서 그대로 불러와진다.
|
||||||
|
|
||||||
@@ -200,6 +211,8 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
|||||||
- [x] Scene 합성에서 분리한 block/streaming multichannel 위치추정 API
|
- [x] Scene 합성에서 분리한 block/streaming multichannel 위치추정 API
|
||||||
- [x] 반복 거리·calibration·steering cache, bounded ring과 메모리 hard cap
|
- [x] 반복 거리·calibration·steering cache, bounded ring과 메모리 hard cap
|
||||||
- [x] DAS·DAS-PHAT·reference-pair GCC-PHAT와 알고리즘별 power/score 계약
|
- [x] DAS·DAS-PHAT·reference-pair GCC-PHAT와 알고리즘별 power/score 계약
|
||||||
|
- [x] CPU 좁은대역 Schmidt MUSIC, fixed K와 Wax–Kailath AIC/MDL, typed unavailable·연산 상한
|
||||||
|
- [x] 단일 물리 마이크 CA/OS-CFAR·Dixon spectral flux와 raw frame/bin export
|
||||||
- [ ] 라이선스 검토를 통과한 DCASE/MIMII adapter와 batch workflow
|
- [ ] 라이선스 검토를 통과한 DCASE/MIMII adapter와 batch workflow
|
||||||
- [ ] 실제 장치의 channel/clock/sample 형식에 맞춘 capture·transport adapter와 현장 검증
|
- [ ] 실제 장치의 channel/clock/sample 형식에 맞춘 capture·transport adapter와 현장 검증
|
||||||
- [ ] 필요 시 센서 self-noise·위치 공차와 DAMAS/CLEAN-SC 후처리
|
- [ ] 필요 시 센서 self-noise·위치 공차와 DAMAS/CLEAN-SC 후처리
|
||||||
@@ -212,23 +225,34 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
|
|||||||
|
|
||||||
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
|
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
|
||||||
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다.
|
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다.
|
||||||
- 브라우저 메모리: snapshot signal·최대 sparse delay FFT에 512 MiB 한도를 적용하고, 이동
|
- 브라우저 메모리: crop microphone channel과 source별로 순차 처리하는 한 sampled
|
||||||
|
signal·spectrum·workspace의 peak typed working set에 512 MiB 한도를 적용하고, 이동
|
||||||
미리보기는 최대 48개·2,500 points의 짧은 시간창으로 제한한다. 장시간 입력은 256 MiB 기본
|
미리보기는 최대 48개·2,500 points의 짧은 시간창으로 제한한다. 장시간 입력은 256 MiB 기본
|
||||||
한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다.
|
한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다.
|
||||||
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
|
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
|
||||||
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
|
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
|
||||||
- 실제 AC와 simulation gap: gain·phase·주파수 응답과 희소 실내 환경을 비교 조건으로 제공하되,
|
- 실제 AC와 simulation gap: gain·phase·주파수 응답, ideal real ADC sampling/alias와 희소 실내
|
||||||
위치 공차·self-noise·ADC·clock은 실측 adapter와 현장 검증으로 다룬다.
|
환경을 비교 조건으로 제공하되, 위치 공차·self-noise·ADC quantization/nonlinearity와
|
||||||
- post-processing이 기본 결과를 가림: 알고리즘별 raw power/score map과 후처리 map을 별도로 보존한다.
|
channel clock jitter/drift는 실측 adapter와 현장 검증으로 다룬다.
|
||||||
|
- post-processing이 기본 결과를 가림: 알고리즘별 raw power/score/noise-projection map과 후처리 map을 별도로 보존한다.
|
||||||
|
- MUSIC 모호성을 숨김: band당 사용자 지정 analog FFT-grid 가설 하나와 direct-path near-field
|
||||||
|
manifold만 쓰며 Scene truth, diagonal loading, smoothing, multibin/wideband fusion 없이
|
||||||
|
grating lobe·동률·coherent rank deficiency를 보존한다.
|
||||||
|
- 자동 주파수 검출의 과장: detector에는 Scene truth를 전달하지 않고 ADC bin만 반환한다.
|
||||||
|
Nyquist 초과 analog carrier는 사용자가 별도 FFT-grid 가설로 지정하며, CFAR·flux·band·위치
|
||||||
|
map을 임의 consensus나 maximum으로 결합하지 않는다.
|
||||||
|
|
||||||
## 10. 구현 결과와 다음 백로그
|
## 10. 구현 결과와 다음 백로그
|
||||||
|
|
||||||
- [x] 설치 없는 ES module workspace와 세 앱 shell
|
- [x] 설치 없는 ES module workspace와 세 앱 shell
|
||||||
- [x] Array Builder 프리셋·직접 편집·검증·Camera 전달
|
- [x] Array Builder 프리셋·직접 편집·검증·Camera 전달
|
||||||
|
- [x] Array Builder receive array-factor polar 단면·HPBW·Nyquist 초과 설계 조건
|
||||||
- [x] array/scene/experiment/result schema와 synthetic fixture
|
- [x] array/scene/experiment/result schema와 synthetic fixture
|
||||||
- [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트
|
- [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트
|
||||||
- [x] CPU DAS golden implementation
|
- [x] CPU DAS golden implementation
|
||||||
- [x] CPU/WebGPU DAS-PHAT와 CPU reference-pair GCC-PHAT 구현·비교 UI
|
- [x] CPU/WebGPU DAS-PHAT와 CPU reference-pair GCC-PHAT 구현·비교 UI
|
||||||
|
- [x] CPU 좁은대역 MUSIC과 fixed/Wax–Kailath source count, q 최소 목적함수·typed unavailable
|
||||||
|
- [x] 표준 CA/OS-CFAR와 Dixon spectral flux의 독립 전 대역 검사·Activity JSON·alias 가설 UI
|
||||||
- [x] WGSL kernel, CPU/GPU 비교와 50²/100²/200² benchmark
|
- [x] WGSL kernel, CPU/GPU 비교와 50²/100²/200² benchmark
|
||||||
- [x] scene canvas, array view, parameter controls와 Result export
|
- [x] scene canvas, array view, parameter controls와 Result export
|
||||||
- [x] Builder→Camera SHA-256 asset 재연결 왕복 테스트
|
- [x] Builder→Camera SHA-256 asset 재연결 왕복 테스트
|
||||||
|
|||||||
+45
-11
@@ -17,6 +17,7 @@
|
|||||||
<nav aria-label="문서 목차">
|
<nav aria-label="문서 목차">
|
||||||
<a href="#quick-start">빠른 시작</a>
|
<a href="#quick-start">빠른 시작</a>
|
||||||
<a href="#moving-live">이동 라이브</a>
|
<a href="#moving-live">이동 라이브</a>
|
||||||
|
<a href="#activity">전 대역 검사</a>
|
||||||
<a href="#array-builder">배열 만들기</a>
|
<a href="#array-builder">배열 만들기</a>
|
||||||
<a href="#full-workflow">내 데이터</a>
|
<a href="#full-workflow">내 데이터</a>
|
||||||
<a href="#acoustic-model">환경·실측</a>
|
<a href="#acoustic-model">환경·실측</a>
|
||||||
@@ -71,8 +72,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="guide-section" id="array-builder">
|
<section class="guide-section" id="activity">
|
||||||
<div class="section-number">03</div>
|
<div class="section-number">03</div>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Unknown-frequency activity</p>
|
||||||
|
<h2>분석 주파수를 모를 때</h2>
|
||||||
|
<ol class="guide-steps">
|
||||||
|
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>의 분석 대역 아래 <strong>전 대역 활동 검사</strong>를 펼칩니다.</li>
|
||||||
|
<li>한 물리 마이크와 OS-CFAR·CA-CFAR 또는 Dixon spectral flux를 고르고 <strong>전 대역 검사</strong>를 누릅니다.</li>
|
||||||
|
<li>CFAR candidate의 ADC bin과 raw CUT/reference/threshold를 확인합니다. Spectral flux는 onset novelty만 봅니다.</li>
|
||||||
|
<li>아날로그 center 가설을 직접 입력해 별도 band로 추가한 뒤 Snapshot 위치추정을 실행합니다.</li>
|
||||||
|
<li>모든 complete frame/bin은 <strong>Activity JSON</strong>으로 저장합니다.</li>
|
||||||
|
</ol>
|
||||||
|
<aside class="callout callout--important">
|
||||||
|
Nyquist 초과 가설도 허용하지만 Scene truth로 원 주파수를 고르지 않습니다.
|
||||||
|
CFAR Pfa는 tested CUT 하나당 명목값이고 spectral flux는 확률이 아닙니다.
|
||||||
|
검출기·band·위치 map의 자동 fusion도 하지 않습니다.
|
||||||
|
<a href="/docs/activity-detection.md">수식과 물리적 한계</a>를 함께 확인하세요.
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-section" id="array-builder">
|
||||||
|
<div class="section-number">04</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Microphone geometry</p>
|
<p class="eyebrow">Microphone geometry</p>
|
||||||
<h2>내 마이크 배열 만들기</h2>
|
<h2>내 마이크 배열 만들기</h2>
|
||||||
@@ -80,6 +102,7 @@
|
|||||||
<li><a href="/apps/array-builder/">Array Builder</a>에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.</li>
|
<li><a href="/apps/array-builder/">Array Builder</a>에서 aperture 너비·높이와 사각형 또는 타원 생성 경계를 정합니다.</li>
|
||||||
<li>격자·선형·원형·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.</li>
|
<li>격자·선형·원형·Fermat 나선·균일 랜덤 중 하나를 고릅니다. 랜덤은 seed와 최소 간격으로 재현성과 채널 간 거리를 제어합니다.</li>
|
||||||
<li>canvas의 <strong>추가</strong> 도구로 마이크를 놓고, 점을 드래그하거나 x/y/z 필드로 좌표를 수정합니다.</li>
|
<li>canvas의 <strong>추가</strong> 도구로 마이크를 놓고, 점을 드래그하거나 x/y/z 필드로 좌표를 수정합니다.</li>
|
||||||
|
<li>canvas 아래 <strong>수신 빔 패턴</strong>에서 아날로그 주파수·XZ/YZ/XY 절단면·조향각에 따른 polar 응답을 확인합니다.</li>
|
||||||
<li>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li>
|
<li>목록에서 마이크를 삭제하거나 채널 순서를 바꾸고, 상태가 <strong>Array JSON 저장 가능</strong>이면 저장합니다.</li>
|
||||||
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>의 <strong>Array</strong> 입력에서 저장한 파일을 선택합니다.</li>
|
<li><a href="/apps/acoustic-camera/">Acoustic Camera</a>의 <strong>Array</strong> 입력에서 저장한 파일을 선택합니다.</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -91,11 +114,17 @@
|
|||||||
생성 경계 형상, 프리셋 종류, seed와 최소 간격은 편집기 설정이라 표준 Array JSON에
|
생성 경계 형상, 프리셋 종류, seed와 최소 간격은 편집기 설정이라 표준 Array JSON에
|
||||||
저장되지 않습니다. Camera가 사용하는 것은 저장된 aperture와 최종 마이크 좌표·순서입니다.
|
저장되지 않습니다. Camera가 사용하는 것은 저장된 aperture와 최종 마이크 좌표·순서입니다.
|
||||||
</aside>
|
</aside>
|
||||||
|
<aside class="callout">
|
||||||
|
빔 패턴은 이상적으로 보정된 무지향성 point microphone의 narrowband far-field
|
||||||
|
geometry-only 수신 단면입니다. Nyquist 초과 주파수도 원 파장으로 계산하며, angular
|
||||||
|
phase-step 기준이 부족하면 raw polar 선만 남기고 HPBW·peak 해석을 보류합니다.
|
||||||
|
<a href="/docs/beam-pattern.md">수식과 제한 보기</a>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="guide-section" id="full-workflow">
|
<section class="guide-section" id="full-workflow">
|
||||||
<div class="section-number">04</div>
|
<div class="section-number">05</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">5분 전체 흐름</p>
|
<p class="eyebrow">5분 전체 흐름</p>
|
||||||
<h2>내 장면을 만들고 Camera에서 실행하기</h2>
|
<h2>내 장면을 만들고 Camera에서 실행하기</h2>
|
||||||
@@ -109,8 +138,11 @@
|
|||||||
<li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li>
|
<li><strong>Scene 저장 후 Camera 열기</strong>를 누릅니다.</li>
|
||||||
<li>
|
<li>
|
||||||
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
|
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
|
||||||
가져옵니다. Array Builder에서 저장한 파일이 있다면 Array 단계에서 선택하고,
|
가져오는 것을 권장합니다. Experiment는 어느 시점에 열어도 분석 대역을 보존하고
|
||||||
내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
|
<code>sceneId</code>와 <code>arrayId</code> provenance label도 덮어쓰지 않습니다.
|
||||||
|
실행에는 현재 열린 Scene·Array를 사용하고 Result에 실제 입력 ID와 hash를 기록합니다.
|
||||||
|
Array Builder에서 저장한 파일이 있다면 Array
|
||||||
|
단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다.
|
Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다.
|
||||||
@@ -142,7 +174,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="guide-section">
|
<section class="guide-section">
|
||||||
<div class="section-number">05</div>
|
<div class="section-number">06</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Localization & compute</p>
|
<p class="eyebrow">Localization & compute</p>
|
||||||
<h2>알고리즘, 속도와 격자 선택</h2>
|
<h2>알고리즘, 속도와 격자 선택</h2>
|
||||||
@@ -191,7 +223,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="guide-section" id="acoustic-model">
|
<section class="guide-section" id="acoustic-model">
|
||||||
<div class="section-number">06</div>
|
<div class="section-number">07</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Acoustics & measured input</p>
|
<p class="eyebrow">Acoustics & measured input</p>
|
||||||
<h2>환경과 마이크 응답 선택</h2>
|
<h2>환경과 마이크 응답 선택</h2>
|
||||||
@@ -213,7 +245,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="guide-section" id="results">
|
<section class="guide-section" id="results">
|
||||||
<div class="section-number">07</div>
|
<div class="section-number">08</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Result JSON</p>
|
<p class="eyebrow">Result JSON</p>
|
||||||
<h2>무엇이 저장되나요?</h2>
|
<h2>무엇이 저장되나요?</h2>
|
||||||
@@ -253,7 +285,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="guide-section" id="troubleshooting">
|
<section class="guide-section" id="troubleshooting">
|
||||||
<div class="section-number">08</div>
|
<div class="section-number">09</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Troubleshooting</p>
|
<p class="eyebrow">Troubleshooting</p>
|
||||||
<h2>막혔을 때 확인할 것</h2>
|
<h2>막혔을 때 확인할 것</h2>
|
||||||
@@ -266,8 +298,8 @@
|
|||||||
<p><code>start.cmd -Port 4174</code>처럼 다른 포트를 지정하고 <code>http://127.0.0.1:4174/</code>을 여세요.</p>
|
<p><code>start.cmd -Port 4174</code>처럼 다른 포트를 지정하고 <code>http://127.0.0.1:4174/</code>을 여세요.</p>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Experiment를 가져올 수 없습니다.</summary>
|
<summary>Experiment를 가져왔는데 검증 오류가 남습니다.</summary>
|
||||||
<p>Scene과 Array를 먼저 가져오세요. Experiment의 <code>sceneId</code>와 <code>arrayId</code>가 현재 문서와 같아야 합니다.</p>
|
<p>참조 ID는 provenance label이라 현재 Scene·Array와 달라도 보존되며 실행을 막지 않습니다. Nyquist 위 tone·band는 ideal ADC alias로 실행되고 원/관측 주파수가 함께 표시됩니다. 실행할 수 없다면 검증 카드에서 scan 범위나 asset처럼 실제 입력 오류를 확인하세요.</p>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Assets가 준비되지 않습니다.</summary>
|
<summary>Assets가 준비되지 않습니다.</summary>
|
||||||
@@ -293,7 +325,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="guide-section">
|
<section class="guide-section">
|
||||||
<div class="section-number">09</div>
|
<div class="section-number">10</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Scope & privacy</p>
|
<p class="eyebrow">Scope & privacy</p>
|
||||||
<h2>현재 모델의 범위</h2>
|
<h2>현재 모델의 범위</h2>
|
||||||
@@ -317,6 +349,8 @@
|
|||||||
<a href="/apps/array-builder/README.md"><strong>Array Builder 설명서</strong><span>마이크 배치·프리셋·JSON</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/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="/apps/acoustic-camera/README.md"><strong>Camera 설명서</strong><span>backend·결과·비교</span></a>
|
||||||
|
<a href="/docs/activity-detection.md"><strong>전 대역 활동 검출</strong><span>CFAR·spectral flux·alias 가설</span></a>
|
||||||
|
<a href="/docs/beam-pattern.md"><strong>수신 빔 패턴</strong><span>array factor·HPBW·해상도</span></a>
|
||||||
<a href="/docs/acoustic-model.md"><strong>음향 모델</strong><span>마이크 응답·반사·산란·회절</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>
|
<a href="/docs/realtime-porting.md"><strong>실측 포팅</strong><span>block·streaming 위치추정 API</span></a>
|
||||||
<a href="/docs/data-contracts.md"><strong>데이터 계약</strong><span>JSON Schema와 재현성</span></a>
|
<a href="/docs/data-contracts.md"><strong>데이터 계약</strong><span>JSON Schema와 재현성</span></a>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ Portable plan은 top-level `algorithm`으로 위치 추정기를 선택한다.
|
|||||||
| `das` | frequency-domain delay-and-sum | `power` | CPU |
|
| `das` | frequency-domain delay-and-sum | `power` | CPU |
|
||||||
| `das-phat` | phase-only spectrum을 steering하는 DAS | `score` | CPU |
|
| `das-phat` | phase-only spectrum을 steering하는 DAS | `score` | CPU |
|
||||||
| `gcc-phat` | 한 reference mic에 대한 `M-1`개 GCC-PHAT 지연 scan | `score` | CPU only |
|
| `gcc-phat` | 한 reference mic에 대한 `M-1`개 GCC-PHAT 지연 scan | `score` | CPU only |
|
||||||
|
| `music` | 표준 좁은대역 Schmidt noise-subspace scan | `noise-projection` | CPU only |
|
||||||
|
|
||||||
같은 array·scan·band·STFT로 알고리즘을 비교할 때도 알고리즘마다 별도 plan을 만든다.
|
같은 array·scan·band·STFT로 알고리즘을 비교할 때도 알고리즘마다 별도 plan을 만든다.
|
||||||
|
|
||||||
@@ -103,6 +104,14 @@ const gccPhatPlan = createLocalizationPlan({
|
|||||||
algorithm: "gcc-phat",
|
algorithm: "gcc-phat",
|
||||||
phatFloorRatio: 1e-6,
|
phatFloorRatio: 1e-6,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const musicPlan = createLocalizationPlan({
|
||||||
|
...commonConfig,
|
||||||
|
algorithm: "music",
|
||||||
|
normalization: "none",
|
||||||
|
musicSourceCountMethod: "fixed",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
`phatRelativeFloor`는 DAS-PHAT의 channel spectrum 정규화 floor이고, `phatFloorRatio`는
|
`phatRelativeFloor`는 DAS-PHAT의 channel spectrum 정규화 floor이고, `phatFloorRatio`는
|
||||||
@@ -151,7 +160,7 @@ const plan = createLocalizationPlan({
|
|||||||
{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" },
|
{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" },
|
||||||
],
|
],
|
||||||
stft: { fftSize: 512, hopSize: 256 },
|
stft: { fftSize: 512, hopSize: 256 },
|
||||||
algorithm: "das", // "das" | "das-phat" | "gcc-phat"
|
algorithm: "das", // "das" | "das-phat" | "gcc-phat" | "music"
|
||||||
speedOfSound: 343,
|
speedOfSound: 343,
|
||||||
normalization: "peak",
|
normalization: "peak",
|
||||||
compensateFrequencyResponse: true,
|
compensateFrequencyResponse: true,
|
||||||
@@ -167,11 +176,14 @@ const plan = createLocalizationPlan({
|
|||||||
주요 검증 조건은 다음과 같다.
|
주요 검증 조건은 다음과 같다.
|
||||||
|
|
||||||
- `fftSize`는 2 이상의 2의 거듭제곱이고 `hopSize <= fftSize`
|
- `fftSize`는 2 이상의 2의 거듭제곱이고 `hopSize <= fftSize`
|
||||||
- band는 하나 이상이며 `0 <= minHz < maxHz < sampleRate / 2`
|
- band는 하나 이상이며 `0 <= minHz < maxHz`. Nyquist 위 구간은 anti-alias filter 없는 ideal
|
||||||
|
real ADC의 등가 baseband bin으로 접힌다. 단일 channel 시간 sample은 원 alias order를
|
||||||
|
복원하지 못하지만 다채널 공간 위상에는 원 파장의 증거가 남을 수 있다. 현재 portable
|
||||||
|
localizer는 carrier hypothesis를 열거하지 않는다.
|
||||||
- scan의 `columns`와 `rows`는 양의 정수
|
- scan의 `columns`와 `rows`는 양의 정수
|
||||||
- `speedOfSound`는 양수
|
- `speedOfSound`는 양수
|
||||||
- `normalization`은 `peak` 또는 `none`
|
- `normalization`은 `peak` 또는 `none`
|
||||||
- `algorithm`은 `das`, `das-phat`, `gcc-phat` 중 하나
|
- `algorithm`은 `das`, `das-phat`, `gcc-phat`, `music` 중 하나
|
||||||
- `phatRelativeFloor`와 `phatFloorRatio`는 지정한 경우 0–1
|
- `phatRelativeFloor`와 `phatFloorRatio`는 지정한 경우 0–1
|
||||||
- `maxCompensationDb`는 0–120 dB
|
- `maxCompensationDb`는 0–120 dB
|
||||||
- `peakDetection.thresholdRatio`는 0–1,
|
- `peakDetection.thresholdRatio`는 0–1,
|
||||||
@@ -183,7 +195,29 @@ const plan = createLocalizationPlan({
|
|||||||
STFT·correlation workspace를 유지한다. 같은 계획으로 두 분석을 동시에 실행하지 말고 worker마다
|
STFT·correlation workspace를 유지한다. 같은 계획으로 두 분석을 동시에 실행하지 말고 worker마다
|
||||||
별도 계획을 만든다. Array·scan·band·STFT·알고리즘을 바꾸면 새 계획을 만들어야 한다.
|
별도 계획을 만든다. Array·scan·band·STFT·알고리즘을 바꾸면 새 계획을 만들어야 한다.
|
||||||
Portable plan의 generic cache는 `plan.analysisPlan`이며 기존 호출자를 위해 DAS 계열에는
|
Portable plan의 generic cache는 `plan.analysisPlan`이며 기존 호출자를 위해 DAS 계열에는
|
||||||
`plan.dasPlan`, GCC-PHAT에는 `plan.gccPhatPlan` alias도 제공한다.
|
`plan.dasPlan`, GCC-PHAT에는 `plan.gccPhatPlan`, MUSIC에는 `plan.musicPlan` alias도 제공한다.
|
||||||
|
|
||||||
|
### MUSIC 포팅 경계
|
||||||
|
|
||||||
|
MUSIC raw `q = ||E_n^H a||²/(a^H a)`는 작은 값이 좋은 noise projection이다. 표시용
|
||||||
|
`P/Pmax = q_min/q`도 확률·신뢰도·Gaussian 신뢰구간이 아니며, raw q 보존을 위해
|
||||||
|
`normalization: "none"`만 허용한다.
|
||||||
|
|
||||||
|
band마다 사용자가 지정한 analog FFT-grid 가설 하나와 접힌 positive ADC bin 하나만 사용한다.
|
||||||
|
Scene carrier/위치 truth는 읽지 않는다. analog carrier `f_a`의 공간 위상을 alias orientation
|
||||||
|
`η`로 `exp[-j η 2π f_a Δr/c]`에 적용하며, inversion이면 calibration과 steering을 포함한
|
||||||
|
전체 complex manifold를 켤레화한다. 정확한 DC/Nyquist는 quadrature-degenerate라 거절한다.
|
||||||
|
|
||||||
|
Fixed K는 `1..M-1`이다. Wax–Kailath MDL/AIC는 complete non-overlapping frame, `T > M`,
|
||||||
|
strict-positive noise eigenvalue, IID complex Gaussian snapshot·spatially white noise·고정 K를
|
||||||
|
가정한다. non-overlap은 독립성의 증명이 아니다. 조건 불충족과 식별 불가능한 partition은
|
||||||
|
`MusicUnavailableError` (`code: "MUSIC_UNAVAILABLE"`)이며 fixed fallback은 없다.
|
||||||
|
diagonal loading, floor, spatial smoothing, multibin/wideband fusion도 없다. grating lobe,
|
||||||
|
동률과 coherent rank deficiency를 결과가 좋아 보이도록 제거하지 않는다.
|
||||||
|
|
||||||
|
계획은 allocation 전에 typed working bytes, FFT butterfly, covariance complex accumulation,
|
||||||
|
Jacobi pair update와 projection MAC 상한을 검사한다. 이 CPU cap은 메모리·응답성 보호 장치이지
|
||||||
|
realtime 보장이 아니므로 `processingTimeMs`와 streaming deadline 통계를 대상 장치에서 측정한다.
|
||||||
|
|
||||||
portable 계획의 기본 scan point batch는 DAS 계열 256개, GCC-PHAT 4,096개이고 STFT transform
|
portable 계획의 기본 scan point batch는 DAS 계열 256개, GCC-PHAT 4,096개이고 STFT transform
|
||||||
batch는 128개다. GCC-PHAT cross-spectrum은 16,384 operation 단위로도 양보한다. 각 batch 경계에서
|
batch는 128개다. GCC-PHAT cross-spectrum은 16,384 operation 단위로도 양보한다. 각 batch 경계에서
|
||||||
|
|||||||
@@ -6,11 +6,114 @@
|
|||||||
- 자유음장 직접음과 선택적 저비용 shoebox multipath
|
- 자유음장 직접음과 선택적 저비용 shoebox multipath
|
||||||
- zero-padded FFT 전달함수 기반 fractional sample delay와 마이크 주파수 응답
|
- zero-padded FFT 전달함수 기반 fractional sample delay와 마이크 주파수 응답
|
||||||
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
||||||
- scan grid 거리 cache 및 DAS·DAS-PHAT·GCC-PHAT 위치 추정
|
- scan grid 거리 cache 및 DAS·DAS-PHAT·GCC-PHAT·MUSIC 위치 추정
|
||||||
|
|
||||||
|
## 단일 채널 활동 검출 API
|
||||||
|
|
||||||
|
전 대역 후보 검출은 위치추정기와 분리된 무의존성 ES module입니다.
|
||||||
|
<code>analyzeCfarActivity()</code>는 한 채널의 모든 complete Hann frame에 CA-CFAR 또는
|
||||||
|
OS-CFAR를 적용하고, <code>computeDixonSpectralFlux()</code>는 Dixon 2006의 Hamming,
|
||||||
|
linear-magnitude, half-wave, full-DFT L1 onset novelty를 계산합니다.
|
||||||
|
|
||||||
|
~~~js
|
||||||
|
import {
|
||||||
|
analyzeCfarActivity,
|
||||||
|
computeDixonSpectralFlux,
|
||||||
|
pickCausalSpectralFluxPeaks,
|
||||||
|
} from "./packages/acoustics-core/index.js";
|
||||||
|
|
||||||
|
const cfar = await analyzeCfarActivity({
|
||||||
|
samples: onePhysicalAdcChannel,
|
||||||
|
sampleRate: 48_000,
|
||||||
|
fftSize: 512,
|
||||||
|
hopSize: 256,
|
||||||
|
window: "hann",
|
||||||
|
channelId: "mic-001",
|
||||||
|
detector: "os-cfar",
|
||||||
|
trainingCellsPerSide: 8,
|
||||||
|
guardCellsPerSide: 2,
|
||||||
|
rank: 12,
|
||||||
|
nominalFalseAlarmProbability: 0.001,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flux = computeDixonSpectralFlux({
|
||||||
|
samples: onePhysicalAdcChannel,
|
||||||
|
sampleRate: 48_000,
|
||||||
|
fftSize: 512,
|
||||||
|
hopSize: 256,
|
||||||
|
channelId: "mic-001",
|
||||||
|
});
|
||||||
|
~~~
|
||||||
|
|
||||||
|
두 함수는 여러 마이크, frame 또는 detector를 합치지 않으며 위치·음원 수·아날로그 alias order를
|
||||||
|
추론하지 않습니다. CFAR 결과는 DC와 Nyquist를 포함한 모든 ADC bin record를 보존하고,
|
||||||
|
full symmetric reference가 없는 edge를 <code>untested</code>로 남깁니다. Pfa는 IID
|
||||||
|
exponential 모델의 tested CUT 하나당 명목값입니다. Spectral flux는 모든 frame×full-DFT-bin
|
||||||
|
양의 차이와 raw ODF를 보존하며 peak picker는 별도 명시 호출입니다.
|
||||||
|
|
||||||
|
<code>estimateCfarActivityWorkload()</code>와
|
||||||
|
<code>estimateDixonSpectralFluxWorkload()</code>로 할당 전에 frame/bin/FFT 연산과 raw output
|
||||||
|
byte를 검사할 수 있습니다. 기본 상한을 넘으면 일부 frame, top-K bin 또는 축소 결과를
|
||||||
|
만들지 않고 실패합니다. 상세 수식과 문헌은
|
||||||
|
[전 대역 활동 검출 모델](../../docs/activity-detection.md)에 있습니다.
|
||||||
|
|
||||||
|
## Nyquist 초과 입력
|
||||||
|
|
||||||
|
`sampling.js`는 anti-alias filter 없는 이상적 real sampler의 signed alias와 band-to-FFT-bin
|
||||||
|
mapping을 제공한다. tone 전파는 먼저 원 analog 주파수에서 path delay와 microphone response를
|
||||||
|
적용하고 그 뒤 ADC 시각에서 sampling한다. 따라서 시간축에는 alias가 나타나지만 센서 간 위상은
|
||||||
|
원 파장의 물리를 유지한다.
|
||||||
|
|
||||||
|
Nyquist 위 band는 모든 등가 ADC FFT bin center로 접어 관찰한다. 연속 fold segment와 이산
|
||||||
|
bin-center membership은 별도 metadata다. DAS 계열은 양의 ADC bin 주파수로 steering하고
|
||||||
|
GCC-PHAT는 선택 bin의 lag correlation을 사용하며, 원 carrier를 몰래 추론하거나 peak를
|
||||||
|
고르지 않는다. 단일 channel 시간축에서 같은 sequence로 접혀도 다채널 공간 위상에는 원 파장의
|
||||||
|
증거가 남을 수 있다. 현재 localizer는 carrier hypothesis를 열거하지 않으므로 Result에
|
||||||
|
`aliasOrderResolution: not-attempted`로 남긴다. asset/noise 입력은 이미 sampling된 baseband로
|
||||||
|
취급한다.
|
||||||
|
|
||||||
|
Tone과 band의 검증 범위는 0–1 MHz다. 이는 Nyquist cutoff가 아니라 현재 IEEE-754 위상 계산,
|
||||||
|
마이크 응답과 희소 경로 모델의 명시적 정밀도 범위다. public sampling helper는 비정상적으로
|
||||||
|
작은 sample rate에서도 최대 513개 fold segment를 넘기 전에 거부한다.
|
||||||
|
|
||||||
|
## 수신 빔 패턴 API
|
||||||
|
|
||||||
|
`beam-pattern.js`의 `computeReceiveBeamPattern()`은 Scene과 ADC 없이 microphone geometry의
|
||||||
|
narrowband far-field conventional delay-and-sum receive array factor를 계산합니다.
|
||||||
|
|
||||||
|
~~~js
|
||||||
|
import { computeReceiveBeamPattern } from "./packages/acoustics-core/beam-pattern.js";
|
||||||
|
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: 49_125,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: "auto",
|
||||||
|
dbFloor: -60,
|
||||||
|
});
|
||||||
|
~~~
|
||||||
|
|
||||||
|
파수는 `2π frequencyHz / speedOfSound`이며 `frequencyHz`를 temporal ADC alias로 접지 않습니다.
|
||||||
|
결과는 `1/M` weight의 raw normalized complex·power·dB와 별도의 display-floor buffer를 보존합니다.
|
||||||
|
이상적으로 보정된 무지향성 point microphone의 geometry-only 응답이므로
|
||||||
|
`sensitivityDb`, `phaseOffsetDeg`, `frequencyResponse`, element directivity, 방과 near-field는
|
||||||
|
포함하지 않습니다.
|
||||||
|
|
||||||
|
XZ는 0°=+Z/90°=+X, YZ는 0°=+Z/90°=+Y, XY는 0°=+X/90°=+Y인 great-circle 단면입니다.
|
||||||
|
투영 maximum baseline `D`와 angle 수 `N`에서
|
||||||
|
`2 k D sin(π/N) ≤ π/8` phase-step criterion을 검사합니다. 이는 모든 lobe 검출 보장이 아니라
|
||||||
|
metric sampling 정책입니다. 미충족 시 raw samples만 반환하고 HPBW와 sampled peak 해석을
|
||||||
|
`resolution-unresolved`로 남깁니다. HPBW도 sampled half-power bracket이 있을 때만 raw analytic
|
||||||
|
response를 48회 이분하며, 비상수 all-above grid를 360° 폭으로 만들지 않습니다.
|
||||||
|
|
||||||
|
마이크 수·baseline pair·angular sample·phasor evaluation은 큰 반복과 할당 전에 상한을 검사합니다.
|
||||||
|
portable 결과 계약과 상태별 의미는 [수신 빔 패턴 문서](../../docs/beam-pattern.md)를 참고하세요.
|
||||||
|
|
||||||
## 위치 추정 알고리즘
|
## 위치 추정 알고리즘
|
||||||
|
|
||||||
`localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 세 경로를
|
`localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 네 경로를
|
||||||
공통 interface로 제공합니다.
|
공통 interface로 제공합니다.
|
||||||
|
|
||||||
| `algorithm` | 계산 | raw scale | backend |
|
| `algorithm` | 계산 | raw scale | backend |
|
||||||
@@ -18,6 +121,7 @@
|
|||||||
| `das` | conventional frequency-domain delay-and-sum | `power` | CPU, WebGPU |
|
| `das` | conventional frequency-domain delay-and-sum | `power` | CPU, WebGPU |
|
||||||
| `das-phat` | 채널 spectrum을 phase-only로 만든 DAS | `score` | CPU, WebGPU |
|
| `das-phat` | 채널 spectrum을 phase-only로 만든 DAS | `score` | CPU, WebGPU |
|
||||||
| `gcc-phat` | 하나의 reference mic와 나머지 mic의 GCC-PHAT 지연 scan | `score` | CPU only |
|
| `gcc-phat` | 하나의 reference mic와 나머지 mic의 GCC-PHAT 지연 scan | `score` | CPU only |
|
||||||
|
| `music` | Schmidt narrowband MUSIC의 근거리 spherical noise-subspace projection | `noise-projection` | CPU only |
|
||||||
|
|
||||||
Portable API에서는 top-level `algorithm`으로 선택합니다. 생략하면 기존과 같은 `das`이며, 낮은
|
Portable API에서는 top-level `algorithm`으로 선택합니다. 생략하면 기존과 같은 `das`이며, 낮은
|
||||||
수준의 generic API는 `experiment.beamforming.algorithm`을 사용합니다.
|
수준의 generic API는 `experiment.beamforming.algorithm`을 사용합니다.
|
||||||
@@ -30,7 +134,7 @@ import {
|
|||||||
|
|
||||||
const plan = createLocalizationPlan({
|
const plan = createLocalizationPlan({
|
||||||
...commonConfig, // sampleRate, array, scan, bands, stft
|
...commonConfig, // sampleRate, array, scan, bands, stft
|
||||||
algorithm: "gcc-phat", // "das" | "das-phat" | "gcc-phat"
|
algorithm: "gcc-phat", // "das" | "das-phat" | "gcc-phat" | "music"
|
||||||
phatFloorRatio: 1e-6,
|
phatFloorRatio: 1e-6,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,6 +160,61 @@ band에 선택된 bin이 8개 미만이면 코어는 계산을 허용하지만
|
|||||||
GCC-PHAT score는 pair 수, band coefficient 수와 IFFT 크기로 정규화한 0–1 양의 상관 응답이라
|
GCC-PHAT score는 pair 수, band coefficient 수와 IFFT 크기로 정규화한 0–1 양의 상관 응답이라
|
||||||
scan 범위와 마이크 수에 의한 단순 재척도화는 피하지만, 여전히 확률로 보정된 confidence는 아닙니다.
|
scan 범위와 마이크 수에 의한 단순 재척도화는 피하지만, 여전히 확률로 보정된 confidence는 아닙니다.
|
||||||
|
|
||||||
|
### Narrowband MUSIC
|
||||||
|
|
||||||
|
`music`은 [Schmidt MUSIC](https://doi.org/10.1109/TAP.1986.1143830)을 sample covariance와
|
||||||
|
근거리 구면파 direct-path manifold에 적용합니다. 근거리 배열 처리의 문헌 배경은
|
||||||
|
[Huang–Barkat](https://doi.org/10.1109/8.86917)을 참고하세요. raw map은 reciprocal을 취하지
|
||||||
|
않은 Float64 noise projection
|
||||||
|
|
||||||
|
`q(r)=Σ_(j=K)^(M-1)|v_jᴴa(r)|²/(a(r)ᴴa(r))`
|
||||||
|
|
||||||
|
이고 objective는 minimum입니다. 표준 pseudospectrum `P=1/q`와 표시용 `P/Pmax`는 위치
|
||||||
|
확률·likelihood·confidence·신뢰구간이 아닙니다. raw `q`는 peak normalization이나 epsilon
|
||||||
|
floor로 바꾸지 않습니다.
|
||||||
|
|
||||||
|
각 Experiment band는 FFT spacing에 놓인 **아날로그 carrier 가설 하나**만 포함해야 합니다.
|
||||||
|
관측 ADC bin이 같아도 alias order가 다른 아날로그 가설은 별도 band입니다. Nyquist 위 값은
|
||||||
|
허용하며 signed alias가 음수이면 positive-bin STFT, mic calibration과 전체 complex manifold를
|
||||||
|
켤레 방향으로 사용합니다. 공간 위상은 `exp[-j sign(f_signed) 2π f_a Δr/c]`로 analog carrier
|
||||||
|
크기를 유지하고 signed ADC alias는 orientation만 정합니다. 이 carrier는 Experiment에서 온 가설이고 Scene source truth는
|
||||||
|
localizer에 전달되지 않습니다. 정확한 DC/Nyquist bin은 quadrature-degenerate라 거부합니다.
|
||||||
|
|
||||||
|
source 수는 `musicSourceCountMethod: "fixed"`와 `musicSourceCount: K` 또는
|
||||||
|
[Wax–Kailath](https://doi.org/10.1109/TASSP.1985.1164557) `"aic"`/`"mdl"`로 정합니다.
|
||||||
|
AIC/MDL은 `hopSize === fftSize`, 완전한 frame만 사용, `T>M`, roundoff보다 큰 양의 noise
|
||||||
|
eigenvalue가 필요합니다. IID complex Gaussian snapshot, spatially white noise와 일정한 source
|
||||||
|
수를 가정하지만 실제 데이터에서 이를 검증하지 않으며 score는 posterior가 아닙니다. 조건이
|
||||||
|
깨지거나 signal/noise eigengap이 수치 오차보다 작으면 `MusicUnavailableError`
|
||||||
|
(`code: "MUSIC_UNAVAILABLE"`)를 반환합니다.
|
||||||
|
|
||||||
|
동일 주파수의 coherent source는 covariance rank를 잃을 수 있습니다. grating lobe, 동률과 flat
|
||||||
|
objective를 고르기 좋게 깨지 않으며, diagonal loading, eigenvalue floor, spatial smoothing,
|
||||||
|
band 간 association/fusion을 적용하지 않습니다. [Wax–Shan–Kailath broadband 방법](https://doi.org/10.1109/TASSP.1984.1164400)은
|
||||||
|
문헌상 별도 알고리즘이며 현재 구현하지 않았습니다.
|
||||||
|
|
||||||
|
빠른 portable 예:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const plan = createLocalizationPlan({
|
||||||
|
sampleRate: 48_000,
|
||||||
|
array,
|
||||||
|
scan,
|
||||||
|
bands: [{ id: "1k", minHz: 950, maxHz: 1_050, color: "#65f2bf" }],
|
||||||
|
stft: { fftSize: 512, hopSize: 256 },
|
||||||
|
algorithm: "music",
|
||||||
|
normalization: "none",
|
||||||
|
musicSourceCountMethod: "fixed",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
});
|
||||||
|
const result = await localizeMultichannel({ plan, channels });
|
||||||
|
```
|
||||||
|
|
||||||
|
band는 정확히 한 FFT-grid center를 포함해야 하므로 `minHz/maxHz`는 사용 중인 `Fs/N`에서
|
||||||
|
하나의 center만 감싸야 합니다. CPU plan/working-set, covariance accumulation, projection MAC,
|
||||||
|
Jacobi pair-update와 FFT butterfly 상한은 할당·계산 전에 검사합니다. 기본 상한을 넘으면 부분
|
||||||
|
eigensystem이나 축소 map을 반환하지 않습니다.
|
||||||
|
|
||||||
## 실측 다채널 스트림 위치 추정
|
## 실측 다채널 스트림 위치 추정
|
||||||
|
|
||||||
`realtime-localizer.js`는 Scene·음원 합성 없이 이미 sampling된 동기식 마이크 채널을 직접
|
`realtime-localizer.js`는 Scene·음원 합성 없이 이미 sampling된 동기식 마이크 채널을 직접
|
||||||
@@ -64,8 +223,8 @@ scan 범위와 마이크 수에 의한 단순 재척도화는 피하지만, 여
|
|||||||
각 chunk는 동일 길이의 `Float32Array` 목록이어야 합니다.
|
각 chunk는 동일 길이의 `Float32Array` 목록이어야 합니다.
|
||||||
|
|
||||||
`StreamingLocalizer`는 알고리즘 중립 class 이름입니다. 기존 `StreamingDasLocalizer`와 같은 class의
|
`StreamingLocalizer`는 알고리즘 중립 class 이름입니다. 기존 `StreamingDasLocalizer`와 같은 class의
|
||||||
export alias이므로 오래된 import도 그대로 동작하며, `createStreamingLocalizer()` factory도 세
|
export alias이므로 오래된 import도 그대로 동작하며, `createStreamingLocalizer()` factory도 네
|
||||||
알고리즘을 모두 지원합니다.
|
알고리즘을 모두 지원합니다. MUSIC plan은 `plan.musicPlan` alias로도 접근할 수 있습니다.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { createStreamingLocalizer } from "./packages/acoustics-core/realtime-localizer.js";
|
import { createStreamingLocalizer } from "./packages/acoustics-core/realtime-localizer.js";
|
||||||
@@ -157,9 +316,29 @@ scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전
|
|||||||
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
|
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
|
||||||
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
|
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
|
||||||
|
|
||||||
시간창은 활성 source만 잘라 짧은 Scene으로 만들지만 `signalOffsets`에 원래 scene/source의 절대 sample
|
시간창은 요청 출력 시작보다 앞선 sampled noise·asset을 해당 source들의 최대 경로 지연만큼
|
||||||
offset을 보존합니다. 이를 `synthesizeMicrophoneSignals`에 전달하면 각 창을 따로 합성해도 tone phase,
|
pre-roll합니다. analytic tone은 과거 sample buffer 없이 절대시간에서 직접 계산하므로 tone의 더 긴
|
||||||
seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline 합성과 연속됩니다.
|
경로는 render buffer를 늘리지 않습니다. 그래도 창이 시작되기 전에 발음이 끝난 tone을
|
||||||
|
direct·reflection·scattering·diffraction·diffuse 도착 구간으로 선별해 제거하지 않습니다.
|
||||||
|
`signalOffsets`는 원래 scene/source의 절대 sample
|
||||||
|
offset과 출력 crop을 함께 보존합니다. analytic tone은 pre-roll 길이의 마이크 channel을 만들지 않고
|
||||||
|
요청 crop에 절대 sample 위상으로 바로 더합니다. sampled noise·asset은 지연에 필요한 pre-roll
|
||||||
|
방사 buffer와 zero-padded FFT는 유지하되 IFFT 결과의 crop 구간만 crop 크기 channel에 더합니다.
|
||||||
|
따라서 tone phase, seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline과 같은
|
||||||
|
절대 시간축을 사용하면서 `마이크 수 × pre-roll` 출력 메모리와 tone sample-add를 만들지 않습니다.
|
||||||
|
|
||||||
|
이 보장은 현재 희소 경로 모델 안에서의 **전파 지연 tail**에 관한 것입니다. quasi-static 창은 pre-roll
|
||||||
|
구간까지 포함해 source를 출력 창 중앙의 한 위치에 고정하며, retarded-time trajectory, 창 안의 연속
|
||||||
|
이동과 Doppler는 계산하지 않습니다. 합성 결과의 `windowing` metadata는 적용한 pre-roll, 출력 절대
|
||||||
|
sample, `source-position-fixed-at-window-midpoint` geometry와 `dopplerMode: not-modeled`를 기록합니다.
|
||||||
|
`sourceArrivals`는 source별 방사·도착 sample 경계와 출력에 겹친 path kind를,
|
||||||
|
`arrivingSourceIds`는 적어도 한 실제 path의 half-open 도착 구간이 출력과 겹친 source를 기록합니다.
|
||||||
|
이는 사후 ground-truth 평가에만 쓰이며 위치 추정 입력에는 전달하지 않습니다.
|
||||||
|
loop하지 않는 asset의 방사 종료는 선언 duration과 decoded channel duration 중 빠른 쪽이며, PCM
|
||||||
|
내용의 leading/trailing zero까지 분석해 truth를 줄이지는 않습니다.
|
||||||
|
sampled asset/noise의 fractional delay와 microphone response는 기존 zero-padded block FFT 모델을
|
||||||
|
그대로 사용하므로 임의 길이 analog microphone impulse response의 streaming state를 재현하는 모델은
|
||||||
|
아닙니다.
|
||||||
|
|
||||||
## Beam spread 의미
|
## Beam spread 의미
|
||||||
|
|
||||||
@@ -214,9 +393,32 @@ Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면
|
|||||||
센서 self-noise, ADC quantization·clock jitter는 계산하지 않습니다. 그런 효과가 중요한
|
센서 self-noise, ADC quantization·clock jitter는 계산하지 않습니다. 그런 효과가 중요한
|
||||||
검증에서는 실측 다채널 데이터를 아래 streaming API에 넣어 결과를 비교해야 합니다.
|
검증에서는 실측 다채널 데이터를 아래 streaming API에 넣어 결과를 비교해야 합니다.
|
||||||
|
|
||||||
snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을
|
합성기는 source별로 경로를 한 receiver 묶음씩 만들고, sampled source도 하나씩
|
||||||
512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로
|
`render → spectrum → microphone 적용 → 해제`합니다. render 길이를 `N`, 출력 crop을 `C`,
|
||||||
전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
|
마이크 수를 `M`, sampled source `i`의 자체 최대 경로 지연을 포함한 FFT 길이를 `F_i`라 하면
|
||||||
|
명시적으로 살아 있는 typed-array peak는 다음입니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
4 M C + max_i(4 N + 32 F_i)
|
||||||
|
```
|
||||||
|
|
||||||
|
`32 F_i`는 Float64 spectrum 두 개와 재사용 workspace 두 개입니다. sampled ambient는
|
||||||
|
zero-delay `F_0 = nextPow2(N + 2)` 후보 하나로 같은 max에 들어가며, analytic tone이나 다른
|
||||||
|
sampled source의 수를 transient byte에 더하지 않습니다. 이 논리적 typed-array working set을
|
||||||
|
기본 512 MiB로 할당 전에 검사하고, Experiment validator도 동일 estimator와 실제
|
||||||
|
reflection·scattering·diffraction·diffuse 지연을 사용합니다. 외부 decoded asset buffer와 JS
|
||||||
|
engine의 객체 heap/RSS까지 512 MiB라고 주장하는 값은 아닙니다.
|
||||||
|
|
||||||
|
라이브 preflight가 만든 경로 count·source별 최대 지연 summary는 geometry, source/microphone id와
|
||||||
|
좌표, acoustics/seed, 거리 설정 전체의 서명이 맞을 때만 합성 preflight에 재사용합니다. summary는
|
||||||
|
불변이고 모듈 내부에서 인증되므로 복제·변조·다른 geometry의 cache는 자동 재계산합니다. 동일
|
||||||
|
Scene·Array 객체에서 band/scan/backend만 바뀐 반복 validation은 이 exact summary를 재사용합니다. 실제
|
||||||
|
전달함수 경로는 receiver별로 한 번 다시 만들며, sampled source의 IFFT는 아직 source와 microphone
|
||||||
|
조합마다 한 번입니다. 다수 non-tone source의 transform batching은 수학 동치와 메모리 예산을 함께
|
||||||
|
검증해야 하는 후속 최적화입니다.
|
||||||
|
|
||||||
|
실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로 전체 녹음을 메모리에 보관하지
|
||||||
|
않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
|
||||||
hard cap을 할당 전에 검사합니다. DAS steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지하고,
|
hard cap을 할당 전에 검사합니다. DAS steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지하고,
|
||||||
GCC-PHAT는 거리·STFT·correlation workspace 전체를 plan 한도 안에서 검사합니다. Plan 없는 단발
|
GCC-PHAT는 거리·STFT·correlation workspace 전체를 plan 한도 안에서 검사합니다. Plan 없는 단발
|
||||||
GCC 분석도 거리 행렬을 만들기 전에 spectrum·calibration·output을 포함한 working set을 preflight합니다.
|
GCC 분석도 거리 행렬을 만들기 전에 spectrum·calibration·output을 포함한 working set을 preflight합니다.
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { detectCfarFrame } from "./cfar.js";
|
||||||
|
import { cooperativeYield } from "./cooperative-yield.js";
|
||||||
|
import { fftInPlace } from "./fft.js";
|
||||||
|
import { getCompleteFrameCount, hannWindow } from "./stft.js";
|
||||||
|
|
||||||
|
export const DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT = 4_096;
|
||||||
|
export const DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS = 2_000_000;
|
||||||
|
export const DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES = 100_000_000;
|
||||||
|
|
||||||
|
function requirePositiveSafeInteger(value, label) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1) {
|
||||||
|
throw new RangeError(label + " must be a positive safe integer.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireNonNegativeSafeInteger(value, label) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw new RangeError(label + " must be a non-negative safe integer.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePositiveFinite(value, label) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
throw new RangeError(label + " must be a positive finite number.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSingleChannel(samples) {
|
||||||
|
if (
|
||||||
|
(!Array.isArray(samples) && !ArrayBuffer.isView(samples)) ||
|
||||||
|
samples instanceof DataView
|
||||||
|
) {
|
||||||
|
throw new TypeError(
|
||||||
|
"samples must be one numeric channel; channel collections and DataView are not accepted.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let index = 0; index < samples.length; index += 1) {
|
||||||
|
if (typeof samples[index] !== "number" || !Number.isFinite(samples[index])) {
|
||||||
|
throw new TypeError("samples[" + index + "] must be finite.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedProduct(values, label) {
|
||||||
|
let product = 1;
|
||||||
|
for (const value of values) {
|
||||||
|
product *= value;
|
||||||
|
if (!Number.isSafeInteger(product)) {
|
||||||
|
throw new RangeError(label + " exceeds the safe integer range.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal) {
|
||||||
|
if (!signal?.aborted) return;
|
||||||
|
const error = new Error("Full-band activity analysis was canceled.");
|
||||||
|
error.name = "AbortError";
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateCfarActivityWorkload({
|
||||||
|
sampleCount,
|
||||||
|
fftSize,
|
||||||
|
hopSize,
|
||||||
|
}) {
|
||||||
|
requireNonNegativeSafeInteger(sampleCount, "sampleCount");
|
||||||
|
requirePositiveSafeInteger(fftSize, "fftSize");
|
||||||
|
requirePositiveSafeInteger(hopSize, "hopSize");
|
||||||
|
if (fftSize < 4 || (fftSize & (fftSize - 1)) !== 0) {
|
||||||
|
throw new RangeError("fftSize must be a power of two greater than or equal to four.");
|
||||||
|
}
|
||||||
|
if (hopSize > fftSize) {
|
||||||
|
throw new RangeError("hopSize must not exceed fftSize.");
|
||||||
|
}
|
||||||
|
const frameCount = getCompleteFrameCount(sampleCount, fftSize, hopSize);
|
||||||
|
const binCount = fftSize / 2 + 1;
|
||||||
|
const binDecisionCount = checkedProduct(
|
||||||
|
[frameCount, binCount],
|
||||||
|
"CFAR bin decision count",
|
||||||
|
);
|
||||||
|
const fftButterfliesPerFrame =
|
||||||
|
(fftSize / 2) * Math.log2(fftSize);
|
||||||
|
const fftButterflyCount = checkedProduct(
|
||||||
|
[frameCount, fftButterfliesPerFrame],
|
||||||
|
"CFAR FFT butterfly count",
|
||||||
|
);
|
||||||
|
const workingBytes =
|
||||||
|
(3 * fftSize + binCount) * Float64Array.BYTES_PER_ELEMENT;
|
||||||
|
return Object.freeze({
|
||||||
|
frameCount,
|
||||||
|
binCount,
|
||||||
|
binDecisionCount,
|
||||||
|
fftButterfliesPerFrame,
|
||||||
|
fftButterflyCount,
|
||||||
|
workingBytes,
|
||||||
|
outputRecordModel: "one-bin-object-per-frame-without-truncation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLimit(value, fallback, label) {
|
||||||
|
return requirePositiveSafeInteger(value ?? fallback, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactFrameResult(frameResult, fftSize, hopSize) {
|
||||||
|
return Object.freeze({
|
||||||
|
frameIndex: frameResult.frame.frameIndex,
|
||||||
|
frameStartSample: frameResult.frame.frameStartSample,
|
||||||
|
frameCenterSample:
|
||||||
|
frameResult.frame.frameStartSample + (fftSize - 1) / 2,
|
||||||
|
frameAvailableAfterSample:
|
||||||
|
frameResult.frame.frameStartSample + fftSize,
|
||||||
|
updateHopSamples: hopSize,
|
||||||
|
testedBinCount: frameResult.parameters.testedBinCount,
|
||||||
|
untestedBinCount: frameResult.parameters.untestedBinCount,
|
||||||
|
detectedBinCount: frameResult.parameters.detectedBinCount,
|
||||||
|
bins: frameResult.bins,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedDetectorModel(frameResult, fftSize, hopSize, frameCount) {
|
||||||
|
const {
|
||||||
|
testedBinCount,
|
||||||
|
untestedBinCount,
|
||||||
|
detectedBinCount,
|
||||||
|
...sharedParameters
|
||||||
|
} = frameResult.parameters;
|
||||||
|
void testedBinCount;
|
||||||
|
void untestedBinCount;
|
||||||
|
void detectedBinCount;
|
||||||
|
return Object.freeze({
|
||||||
|
detector: frameResult.detector,
|
||||||
|
spectrum: Object.freeze({
|
||||||
|
...frameResult.spectrum,
|
||||||
|
statistic:
|
||||||
|
"symmetric-hann-windowed-dft-squared-magnitude-per-one-sided-bin",
|
||||||
|
powerNormalization: "none",
|
||||||
|
}),
|
||||||
|
parameters: Object.freeze(sharedParameters),
|
||||||
|
calibration: frameResult.calibration,
|
||||||
|
processing: frameResult.processing,
|
||||||
|
timeAnalysis: Object.freeze({
|
||||||
|
frameCount,
|
||||||
|
fftSize,
|
||||||
|
hopSize,
|
||||||
|
completeFramesOnly: true,
|
||||||
|
partialFramePadding: false,
|
||||||
|
temporalAveraging: false,
|
||||||
|
temporalDecisionFusion: false,
|
||||||
|
overlappingFrames:
|
||||||
|
hopSize < fftSize,
|
||||||
|
frameIndependenceClaimed: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run standard CA-CFAR or OS-CFAR independently on every complete STFT frame
|
||||||
|
* from one explicitly selected physical channel.
|
||||||
|
*
|
||||||
|
* Every tested and untested ADC bin is retained. This function does not merge
|
||||||
|
* neighboring bins, select peaks, infer analog alias order, combine frames,
|
||||||
|
* combine microphones, or feed a localization plan.
|
||||||
|
*/
|
||||||
|
export async function analyzeCfarActivity(options) {
|
||||||
|
if (!options || typeof options !== "object") {
|
||||||
|
throw new TypeError("activity options must be an object.");
|
||||||
|
}
|
||||||
|
const samples = requireSingleChannel(options.samples);
|
||||||
|
const sampleRate = requirePositiveFinite(options.sampleRate, "sampleRate");
|
||||||
|
const fftSize = requirePositiveSafeInteger(options.fftSize, "fftSize");
|
||||||
|
const hopSize = requirePositiveSafeInteger(options.hopSize, "hopSize");
|
||||||
|
if (typeof options.channelId !== "string" || options.channelId.length === 0) {
|
||||||
|
throw new TypeError("channelId must be a non-empty string.");
|
||||||
|
}
|
||||||
|
if (options.window !== "hann") {
|
||||||
|
throw new RangeError('window must be "hann".');
|
||||||
|
}
|
||||||
|
const transformBatchSize = requirePositiveSafeInteger(
|
||||||
|
options.transformBatchSize ?? 16,
|
||||||
|
"transformBatchSize",
|
||||||
|
);
|
||||||
|
const maximumFrameCount = requireLimit(
|
||||||
|
options.maximumFrameCount,
|
||||||
|
DEFAULT_MAXIMUM_ACTIVITY_FRAME_COUNT,
|
||||||
|
"maximumFrameCount",
|
||||||
|
);
|
||||||
|
const maximumBinDecisionCount = requireLimit(
|
||||||
|
options.maximumBinDecisionCount,
|
||||||
|
DEFAULT_MAXIMUM_ACTIVITY_BIN_DECISIONS,
|
||||||
|
"maximumBinDecisionCount",
|
||||||
|
);
|
||||||
|
const maximumFftButterflies = requireLimit(
|
||||||
|
options.maximumFftButterflies,
|
||||||
|
DEFAULT_MAXIMUM_ACTIVITY_FFT_BUTTERFLIES,
|
||||||
|
"maximumFftButterflies",
|
||||||
|
);
|
||||||
|
const workload = estimateCfarActivityWorkload({
|
||||||
|
sampleCount: samples.length,
|
||||||
|
fftSize,
|
||||||
|
hopSize,
|
||||||
|
});
|
||||||
|
if (workload.frameCount === 0) {
|
||||||
|
throw new RangeError(
|
||||||
|
"samples must contain at least one complete activity-analysis frame.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (workload.frameCount > maximumFrameCount) {
|
||||||
|
throw new RangeError(
|
||||||
|
"activity frame count " + workload.frameCount +
|
||||||
|
" exceeds maximumFrameCount " + maximumFrameCount + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (workload.binDecisionCount > maximumBinDecisionCount) {
|
||||||
|
throw new RangeError(
|
||||||
|
"activity bin decision count " + workload.binDecisionCount +
|
||||||
|
" exceeds maximumBinDecisionCount " + maximumBinDecisionCount + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (workload.fftButterflyCount > maximumFftButterflies) {
|
||||||
|
throw new RangeError(
|
||||||
|
"activity FFT butterfly count " + workload.fftButterflyCount +
|
||||||
|
" exceeds maximumFftButterflies " + maximumFftButterflies + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = hannWindow(fftSize);
|
||||||
|
const real = new Float64Array(fftSize);
|
||||||
|
const imaginary = new Float64Array(fftSize);
|
||||||
|
const powerSpectrum = new Float64Array(workload.binCount);
|
||||||
|
const frames = new Array(workload.frameCount);
|
||||||
|
let detectedBinCount = 0;
|
||||||
|
let detectorModel = null;
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
|
for (let frameIndex = 0; frameIndex < workload.frameCount; frameIndex += 1) {
|
||||||
|
const frameStartSample = frameIndex * hopSize;
|
||||||
|
imaginary.fill(0);
|
||||||
|
for (let offset = 0; offset < fftSize; offset += 1) {
|
||||||
|
real[offset] = samples[frameStartSample + offset] * window[offset];
|
||||||
|
}
|
||||||
|
fftInPlace(real, imaginary);
|
||||||
|
for (let bin = 0; bin < workload.binCount; bin += 1) {
|
||||||
|
const power =
|
||||||
|
real[bin] * real[bin] + imaginary[bin] * imaginary[bin];
|
||||||
|
if (!Number.isFinite(power)) {
|
||||||
|
throw new RangeError(
|
||||||
|
"STFT power became non-finite at frame " + frameIndex +
|
||||||
|
", bin " + bin + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
powerSpectrum[bin] = power;
|
||||||
|
}
|
||||||
|
const frameResult = detectCfarFrame({
|
||||||
|
detector: options.detector,
|
||||||
|
powerSpectrum,
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
window: "hann",
|
||||||
|
frameComplete: true,
|
||||||
|
channelId: options.channelId,
|
||||||
|
frameIndex,
|
||||||
|
frameStartSample,
|
||||||
|
temporalAverageCount: 1,
|
||||||
|
microphoneAverageCount: 1,
|
||||||
|
trainingCellsPerSide: options.trainingCellsPerSide,
|
||||||
|
guardCellsPerSide: options.guardCellsPerSide,
|
||||||
|
nominalFalseAlarmProbability:
|
||||||
|
options.nominalFalseAlarmProbability,
|
||||||
|
...(options.detector === "os-cfar"
|
||||||
|
? { rank: options.rank }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
detectorModel ??= sharedDetectorModel(
|
||||||
|
frameResult,
|
||||||
|
fftSize,
|
||||||
|
hopSize,
|
||||||
|
workload.frameCount,
|
||||||
|
);
|
||||||
|
frames[frameIndex] = compactFrameResult(
|
||||||
|
frameResult,
|
||||||
|
fftSize,
|
||||||
|
hopSize,
|
||||||
|
);
|
||||||
|
detectedBinCount += frameResult.parameters.detectedBinCount;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(frameIndex + 1) % transformBatchSize === 0 &&
|
||||||
|
frameIndex + 1 < workload.frameCount
|
||||||
|
) {
|
||||||
|
options.onProgress?.({
|
||||||
|
stage: "activity-cfar",
|
||||||
|
completedFrames: frameIndex + 1,
|
||||||
|
totalFrames: workload.frameCount,
|
||||||
|
});
|
||||||
|
await cooperativeYield();
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.onProgress?.({
|
||||||
|
stage: "activity-cfar",
|
||||||
|
completedFrames: workload.frameCount,
|
||||||
|
totalFrames: workload.frameCount,
|
||||||
|
});
|
||||||
|
throwIfAborted(options.signal);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "micarrsim.cfar-activity-analysis/1",
|
||||||
|
channel: Object.freeze({
|
||||||
|
mode: "one-explicit-physical-channel",
|
||||||
|
channelId: options.channelId,
|
||||||
|
microphoneFusion: "none",
|
||||||
|
}),
|
||||||
|
input: Object.freeze({
|
||||||
|
sampleRate,
|
||||||
|
sampleCount: samples.length,
|
||||||
|
adcObservableMinimumHz: 0,
|
||||||
|
adcObservableMaximumHz: sampleRate / 2,
|
||||||
|
analogFrequencyOrAliasOrderInferred: false,
|
||||||
|
sceneTruthUsed: false,
|
||||||
|
}),
|
||||||
|
detectorModel,
|
||||||
|
workload: Object.freeze({
|
||||||
|
...workload,
|
||||||
|
limits: Object.freeze({
|
||||||
|
maximumFrameCount,
|
||||||
|
maximumBinDecisionCount,
|
||||||
|
maximumFftButterflies,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
summary: Object.freeze({
|
||||||
|
frameCount: workload.frameCount,
|
||||||
|
binRecordCount: workload.binDecisionCount,
|
||||||
|
detectedBinCount,
|
||||||
|
selectionApplied: false,
|
||||||
|
clusteringApplied: false,
|
||||||
|
localizationApplied: false,
|
||||||
|
}),
|
||||||
|
frames: Object.freeze(frames),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
import { TAU } from "./math.js";
|
||||||
|
|
||||||
|
export const RECEIVE_BEAM_PATTERN_CUTS = Object.freeze(["xy", "xz", "yz"]);
|
||||||
|
export const DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES = 720;
|
||||||
|
export const DEFAULT_RECEIVE_BEAM_PATTERN_DB_FLOOR = -60;
|
||||||
|
export const MAXIMUM_RECEIVE_BEAM_PATTERN_EVALUATIONS = 16_000_000;
|
||||||
|
export const MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES = 14_400;
|
||||||
|
export const MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES = 1_024;
|
||||||
|
export const MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ = 1_000_000;
|
||||||
|
export const MAXIMUM_RECEIVE_BEAM_PATTERN_BASELINE_COMPARISONS = 600_000;
|
||||||
|
export const RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD = Math.PI / 8;
|
||||||
|
|
||||||
|
const HALF_POWER = 0.5;
|
||||||
|
const RADIANS_PER_DEGREE = Math.PI / 180;
|
||||||
|
const HPBW_BISECTION_ITERATIONS = 48;
|
||||||
|
|
||||||
|
function requireFinite(value, label) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) throw new TypeError(label + " must be finite.");
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePositive(value, label) {
|
||||||
|
const number = requireFinite(value, label);
|
||||||
|
if (!(number > 0)) throw new RangeError(label + " must be greater than zero.");
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCut(cut) {
|
||||||
|
if (!RECEIVE_BEAM_PATTERN_CUTS.includes(cut)) {
|
||||||
|
throw new RangeError(
|
||||||
|
"cut must be one of " + RECEIVE_BEAM_PATTERN_CUTS.join(", ") + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireMicrophones(array) {
|
||||||
|
const microphones = array?.microphones;
|
||||||
|
if (!Array.isArray(microphones) || microphones.length === 0) {
|
||||||
|
throw new TypeError("array.microphones must contain at least one microphone.");
|
||||||
|
}
|
||||||
|
if (microphones.length > MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES) {
|
||||||
|
throw new RangeError(
|
||||||
|
"array.microphones exceeds the receive beam-pattern limit of " +
|
||||||
|
MAXIMUM_RECEIVE_BEAM_PATTERN_MICROPHONES + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return microphones.map((microphone, microphoneIndex) => {
|
||||||
|
const position = microphone?.position;
|
||||||
|
if (!Array.isArray(position) || position.length !== 3) {
|
||||||
|
throw new TypeError(
|
||||||
|
"array.microphones[" + microphoneIndex + "].position must contain x, y, z.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return position.map((coordinate, axis) =>
|
||||||
|
requireFinite(
|
||||||
|
coordinate,
|
||||||
|
"array.microphones[" + microphoneIndex + "].position[" + axis + "]",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBeamPatternAngle(angleDeg) {
|
||||||
|
const angle = requireFinite(angleDeg, "angleDeg");
|
||||||
|
const normalized = ((angle % 360) + 360) % 360;
|
||||||
|
return Object.is(normalized, -0) ? 0 : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a polar angle on an orthogonal great-circle cut to an array-to-source
|
||||||
|
* unit vector. Zero degrees points along the first named axis and +90 degrees
|
||||||
|
* points along the second: XY=(+x,+y), XZ=(+z,+x), YZ=(+z,+y).
|
||||||
|
*/
|
||||||
|
export function receiveBeamPatternDirection(cut, angleDeg) {
|
||||||
|
const plane = requireCut(cut);
|
||||||
|
const angleRadians = requireFinite(angleDeg, "angleDeg") * RADIANS_PER_DEGREE;
|
||||||
|
const cosine = Math.cos(angleRadians);
|
||||||
|
const sine = Math.sin(angleRadians);
|
||||||
|
if (plane === "xy") return [cosine, sine, 0];
|
||||||
|
if (plane === "xz") return [sine, 0, cosine];
|
||||||
|
return [0, sine, cosine];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cutCoordinates(position, cut) {
|
||||||
|
if (cut === "xy") return [position[0], position[1]];
|
||||||
|
if (cut === "xz") return [position[2], position[0]];
|
||||||
|
return [position[2], position[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function bisectHalfPowerBoundary(
|
||||||
|
insideOffsetDeg,
|
||||||
|
outsideOffsetDeg,
|
||||||
|
evaluatePowerAtOffset,
|
||||||
|
) {
|
||||||
|
let inside = insideOffsetDeg;
|
||||||
|
let outside = outsideOffsetDeg;
|
||||||
|
for (let iteration = 0; iteration < HPBW_BISECTION_ITERATIONS; iteration += 1) {
|
||||||
|
const middle = (inside + outside) / 2;
|
||||||
|
if (evaluatePowerAtOffset(middle) >= HALF_POWER) inside = middle;
|
||||||
|
else outside = middle;
|
||||||
|
}
|
||||||
|
return (inside + outside) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function halfPowerBeamwidth(
|
||||||
|
responsePower,
|
||||||
|
steeringIndex,
|
||||||
|
stepDeg,
|
||||||
|
steeringAngleDeg,
|
||||||
|
evaluatePowerAtOffset,
|
||||||
|
analyticallyConstant,
|
||||||
|
) {
|
||||||
|
const sampleCount = responsePower.length;
|
||||||
|
let allAboveHalfPower = true;
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
if (responsePower[index] < HALF_POWER) allAboveHalfPower = false;
|
||||||
|
}
|
||||||
|
if (allAboveHalfPower) {
|
||||||
|
return {
|
||||||
|
status: analyticallyConstant
|
||||||
|
? "full-circle-above-half-power"
|
||||||
|
: "no-sampled-half-power-crossing",
|
||||||
|
widthDeg: null,
|
||||||
|
startAngleDeg: null,
|
||||||
|
endAngleDeg: null,
|
||||||
|
fullCircleAboveHalfPower: analyticallyConstant ? true : null,
|
||||||
|
bracketSource: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftSteps = 0;
|
||||||
|
while (leftSteps < sampleCount - 1) {
|
||||||
|
const nextIndex = (steeringIndex - leftSteps - 1 + sampleCount) % sampleCount;
|
||||||
|
if (responsePower[nextIndex] < HALF_POWER) break;
|
||||||
|
leftSteps += 1;
|
||||||
|
}
|
||||||
|
let rightSteps = 0;
|
||||||
|
while (rightSteps < sampleCount - leftSteps - 1) {
|
||||||
|
const nextIndex = (steeringIndex + rightSteps + 1) % sampleCount;
|
||||||
|
if (responsePower[nextIndex] < HALF_POWER) break;
|
||||||
|
rightSteps += 1;
|
||||||
|
}
|
||||||
|
if (leftSteps + rightSteps + 1 >= sampleCount) {
|
||||||
|
return {
|
||||||
|
status: analyticallyConstant
|
||||||
|
? "full-circle-above-half-power"
|
||||||
|
: "no-sampled-half-power-crossing",
|
||||||
|
widthDeg: null,
|
||||||
|
startAngleDeg: null,
|
||||||
|
endAngleDeg: null,
|
||||||
|
fullCircleAboveHalfPower: analyticallyConstant ? true : null,
|
||||||
|
bracketSource: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftBoundaryOffset = bisectHalfPowerBoundary(
|
||||||
|
-leftSteps * stepDeg,
|
||||||
|
-(leftSteps + 1) * stepDeg,
|
||||||
|
evaluatePowerAtOffset,
|
||||||
|
);
|
||||||
|
const rightBoundaryOffset = bisectHalfPowerBoundary(
|
||||||
|
rightSteps * stepDeg,
|
||||||
|
(rightSteps + 1) * stepDeg,
|
||||||
|
evaluatePowerAtOffset,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: "sampled-bracketed-bisection",
|
||||||
|
widthDeg: rightBoundaryOffset - leftBoundaryOffset,
|
||||||
|
startAngleDeg: normalizeBeamPatternAngle(steeringAngleDeg + leftBoundaryOffset),
|
||||||
|
endAngleDeg: normalizeBeamPatternAngle(steeringAngleDeg + rightBoundaryOffset),
|
||||||
|
fullCircleAboveHalfPower: false,
|
||||||
|
bracketSource: "adjacent-angular-samples",
|
||||||
|
bisectionIterations: HPBW_BISECTION_ITERATIONS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampledLocalMaxima(responsePower, anglesDeg, offsetsDeg, microphoneCount) {
|
||||||
|
const sampleCount = responsePower.length;
|
||||||
|
let minimum = Infinity;
|
||||||
|
let maximum = -Infinity;
|
||||||
|
for (const value of responsePower) {
|
||||||
|
minimum = Math.min(minimum, value);
|
||||||
|
maximum = Math.max(maximum, value);
|
||||||
|
}
|
||||||
|
const comparisonTolerance =
|
||||||
|
Math.max(1, maximum) * 256 * Number.EPSILON * microphoneCount;
|
||||||
|
if (maximum - minimum <= comparisonTolerance) {
|
||||||
|
return {
|
||||||
|
status: "flat-on-grid",
|
||||||
|
comparisonTolerancePower: comparisonTolerance,
|
||||||
|
peaks: [],
|
||||||
|
globalMaximumRegions: [],
|
||||||
|
selected: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let largestBoundaryIndex = 0;
|
||||||
|
let largestBoundaryDifference = -Infinity;
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
const previousIndex = (index - 1 + sampleCount) % sampleCount;
|
||||||
|
const difference = Math.abs(responsePower[index] - responsePower[previousIndex]);
|
||||||
|
if (difference > largestBoundaryDifference) {
|
||||||
|
largestBoundaryDifference = difference;
|
||||||
|
largestBoundaryIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runs = [];
|
||||||
|
let run = null;
|
||||||
|
for (let step = 0; step < sampleCount; step += 1) {
|
||||||
|
const index = (largestBoundaryIndex + step) % sampleCount;
|
||||||
|
const power = responsePower[index];
|
||||||
|
if (!run || Math.abs(power - run.anchorPower) > comparisonTolerance) {
|
||||||
|
run = {
|
||||||
|
anchorPower: power,
|
||||||
|
indices: [],
|
||||||
|
maximumPower: power,
|
||||||
|
representativeIndex: index,
|
||||||
|
};
|
||||||
|
runs.push(run);
|
||||||
|
}
|
||||||
|
run.indices.push(index);
|
||||||
|
if (power > run.maximumPower) {
|
||||||
|
run.maximumPower = power;
|
||||||
|
run.representativeIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const peakRegions = [];
|
||||||
|
for (let runIndex = 0; runIndex < runs.length; runIndex += 1) {
|
||||||
|
const current = runs[runIndex];
|
||||||
|
const previous = runs[(runIndex - 1 + runs.length) % runs.length];
|
||||||
|
const next = runs[(runIndex + 1) % runs.length];
|
||||||
|
if (
|
||||||
|
current.maximumPower <= previous.maximumPower + comparisonTolerance ||
|
||||||
|
current.maximumPower <= next.maximumPower + comparisonTolerance
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const representativeIndex = current.representativeIndex;
|
||||||
|
const firstIndex = current.indices[0];
|
||||||
|
const lastIndex = current.indices.at(-1);
|
||||||
|
peakRegions.push({
|
||||||
|
sampleIndices: [...current.indices],
|
||||||
|
startAngleDeg: anglesDeg[firstIndex],
|
||||||
|
endAngleDeg: anglesDeg[lastIndex],
|
||||||
|
representativeIndex,
|
||||||
|
angleDeg: anglesDeg[representativeIndex],
|
||||||
|
offsetDeg: offsetsDeg[representativeIndex],
|
||||||
|
responsePower: current.maximumPower,
|
||||||
|
responseLinear: Math.sqrt(current.maximumPower),
|
||||||
|
responseDb: current.maximumPower > 0
|
||||||
|
? 10 * Math.log10(current.maximumPower)
|
||||||
|
: -Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
peakRegions.sort((left, right) =>
|
||||||
|
right.responsePower - left.responsePower ||
|
||||||
|
Math.abs(left.offsetDeg) - Math.abs(right.offsetDeg) ||
|
||||||
|
left.angleDeg - right.angleDeg);
|
||||||
|
const globalMaximumRegions = peakRegions.filter(
|
||||||
|
(peak) => maximum - peak.responsePower <= comparisonTolerance,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: globalMaximumRegions.length === 1
|
||||||
|
? "unique-global-maximum-on-grid"
|
||||||
|
: "tied-global-maxima-on-grid",
|
||||||
|
comparisonTolerancePower: comparisonTolerance,
|
||||||
|
peaks: peakRegions,
|
||||||
|
globalMaximumRegions,
|
||||||
|
selected: globalMaximumRegions.length === 1 ? globalMaximumRegions[0] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maximumProjectedBaseline(coordinates) {
|
||||||
|
let maximum = 0;
|
||||||
|
for (let left = 0; left < coordinates.length; left += 1) {
|
||||||
|
for (let right = left + 1; right < coordinates.length; right += 1) {
|
||||||
|
maximum = Math.max(
|
||||||
|
maximum,
|
||||||
|
Math.hypot(
|
||||||
|
coordinates[left][0] - coordinates[right][0],
|
||||||
|
coordinates[left][1] - coordinates[right][1],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recommendedSampleCount(waveNumber, projectedApertureM) {
|
||||||
|
if (projectedApertureM === 0) return 36;
|
||||||
|
const ratio = RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD /
|
||||||
|
(2 * waveNumber * projectedApertureM);
|
||||||
|
if (ratio >= 1) return 36;
|
||||||
|
const required = Math.ceil(Math.PI / Math.asin(ratio));
|
||||||
|
if (!Number.isSafeInteger(required)) return Number.MAX_SAFE_INTEGER;
|
||||||
|
return Math.ceil(required / 4) * 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the ideal, calibrated, omnidirectional receive array factor for a
|
||||||
|
* narrowband far-field plane wave and conventional delay-and-sum steering.
|
||||||
|
*
|
||||||
|
* This function intentionally has no sample-rate input. frequencyHz is the
|
||||||
|
* analog acoustic hypothesis used for k=2πf/c, including frequencies above a
|
||||||
|
* later ADC Nyquist limit. The result is a great-circle slice, not a full 3-D
|
||||||
|
* directivity surface and not a probability distribution.
|
||||||
|
*/
|
||||||
|
export function computeReceiveBeamPattern(options) {
|
||||||
|
const array = options?.array;
|
||||||
|
const positions = requireMicrophones(array);
|
||||||
|
const frequencyHz = requirePositive(options?.frequencyHz, "frequencyHz");
|
||||||
|
if (frequencyHz > MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ) {
|
||||||
|
throw new RangeError(
|
||||||
|
"frequencyHz must not exceed " +
|
||||||
|
MAXIMUM_RECEIVE_BEAM_PATTERN_FREQUENCY_HZ + " Hz.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const speedOfSound = requirePositive(options?.speedOfSound ?? 343, "speedOfSound");
|
||||||
|
const cut = requireCut(options?.cut ?? "xz");
|
||||||
|
const steeringAngleDeg = normalizeBeamPatternAngle(options?.steeringAngleDeg ?? 0);
|
||||||
|
const requestedSampleCount =
|
||||||
|
options?.sampleCount ?? DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES;
|
||||||
|
if (
|
||||||
|
requestedSampleCount !== "auto" &&
|
||||||
|
(!Number.isInteger(requestedSampleCount) ||
|
||||||
|
requestedSampleCount < 36 ||
|
||||||
|
requestedSampleCount > MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES)
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"sampleCount must be 'auto' or an integer inside 36.." +
|
||||||
|
MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dbFloor = requireFinite(
|
||||||
|
options?.dbFloor ?? DEFAULT_RECEIVE_BEAM_PATTERN_DB_FLOOR,
|
||||||
|
"dbFloor",
|
||||||
|
);
|
||||||
|
if (dbFloor < -300 || dbFloor > -1) {
|
||||||
|
throw new RangeError("dbFloor must lie inside -300..-1 dB.");
|
||||||
|
}
|
||||||
|
const maximumEvaluations = options?.maximumEvaluations ??
|
||||||
|
MAXIMUM_RECEIVE_BEAM_PATTERN_EVALUATIONS;
|
||||||
|
if (!Number.isSafeInteger(maximumEvaluations) || maximumEvaluations < 1) {
|
||||||
|
throw new RangeError("maximumEvaluations must be a positive safe integer.");
|
||||||
|
}
|
||||||
|
const maximumBaselineComparisons = options?.maximumBaselineComparisons ??
|
||||||
|
MAXIMUM_RECEIVE_BEAM_PATTERN_BASELINE_COMPARISONS;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(maximumBaselineComparisons) ||
|
||||||
|
maximumBaselineComparisons < 0
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"maximumBaselineComparisons must be a non-negative safe integer.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const microphoneCount = positions.length;
|
||||||
|
const projectedBaselineComparisons =
|
||||||
|
microphoneCount * (microphoneCount - 1) / 2;
|
||||||
|
if (projectedBaselineComparisons > maximumBaselineComparisons) {
|
||||||
|
throw new RangeError(
|
||||||
|
"receive beam pattern requires " + projectedBaselineComparisons +
|
||||||
|
" projected-baseline comparisons; limit is " +
|
||||||
|
maximumBaselineComparisons + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const preliminarySampleCount =
|
||||||
|
requestedSampleCount === "auto" ? 36 : requestedSampleCount;
|
||||||
|
const preliminaryMaximumEvaluationCount =
|
||||||
|
microphoneCount * preliminarySampleCount;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(preliminaryMaximumEvaluationCount) ||
|
||||||
|
preliminaryMaximumEvaluationCount > maximumEvaluations
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"receive beam pattern requires at most " +
|
||||||
|
preliminaryMaximumEvaluationCount +
|
||||||
|
" microphone-angle evaluations before geometry audit; limit is " +
|
||||||
|
maximumEvaluations + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const centroid = [0, 0, 0];
|
||||||
|
for (const position of positions) {
|
||||||
|
centroid[0] += position[0] / microphoneCount;
|
||||||
|
centroid[1] += position[1] / microphoneCount;
|
||||||
|
centroid[2] += position[2] / microphoneCount;
|
||||||
|
}
|
||||||
|
const centeredCutCoordinates = positions.map((position) =>
|
||||||
|
cutCoordinates([
|
||||||
|
position[0] - centroid[0],
|
||||||
|
position[1] - centroid[1],
|
||||||
|
position[2] - centroid[2],
|
||||||
|
], cut));
|
||||||
|
const projectedApertureM = maximumProjectedBaseline(centeredCutCoordinates);
|
||||||
|
const waveNumber = TAU * frequencyHz / speedOfSound;
|
||||||
|
const maximumPhaseMagnitudeRadBound =
|
||||||
|
2 * waveNumber * projectedApertureM;
|
||||||
|
if (
|
||||||
|
!Number.isFinite(waveNumber) ||
|
||||||
|
!Number.isFinite(maximumPhaseMagnitudeRadBound)
|
||||||
|
) {
|
||||||
|
throw new RangeError("frequency, speed of sound, and array coordinates overflow phase.");
|
||||||
|
}
|
||||||
|
const recommendedMetricSampleCount =
|
||||||
|
recommendedSampleCount(waveNumber, projectedApertureM);
|
||||||
|
const maximumMetricDirectionEvaluations = projectedApertureM > 0
|
||||||
|
? HPBW_BISECTION_ITERATIONS * 2
|
||||||
|
: 0;
|
||||||
|
const maximumSamplesByWork = Math.floor(
|
||||||
|
maximumEvaluations / microphoneCount - maximumMetricDirectionEvaluations,
|
||||||
|
);
|
||||||
|
const allowedSampleCount = Math.min(
|
||||||
|
MAXIMUM_RECEIVE_BEAM_PATTERN_SAMPLES,
|
||||||
|
maximumSamplesByWork,
|
||||||
|
);
|
||||||
|
if (allowedSampleCount < 36) {
|
||||||
|
throw new RangeError(
|
||||||
|
"maximumEvaluations cannot cover the minimum 36-angle receive pattern.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sampleCount = requestedSampleCount === "auto"
|
||||||
|
? Math.min(
|
||||||
|
allowedSampleCount,
|
||||||
|
Math.max(DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES, recommendedMetricSampleCount),
|
||||||
|
)
|
||||||
|
: requestedSampleCount;
|
||||||
|
const maximumMetricEvaluationCount =
|
||||||
|
microphoneCount * maximumMetricDirectionEvaluations;
|
||||||
|
const sampleEvaluationCount = microphoneCount * sampleCount;
|
||||||
|
const evaluationCount = sampleEvaluationCount + maximumMetricEvaluationCount;
|
||||||
|
if (!Number.isSafeInteger(evaluationCount) || evaluationCount > maximumEvaluations) {
|
||||||
|
throw new RangeError(
|
||||||
|
"receive beam pattern requires at most " + evaluationCount +
|
||||||
|
" microphone-angle evaluations; limit is " + maximumEvaluations + ".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepDeg = 360 / sampleCount;
|
||||||
|
const stepRadians = TAU / sampleCount;
|
||||||
|
const maximumAdjacentPhaseStepRadBound =
|
||||||
|
2 * waveNumber * projectedApertureM * Math.sin(stepRadians / 2);
|
||||||
|
const metricResolutionSatisfied =
|
||||||
|
maximumAdjacentPhaseStepRadBound <=
|
||||||
|
RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD *
|
||||||
|
(1 + 64 * Number.EPSILON);
|
||||||
|
const steeringIndex = Math.floor(sampleCount / 2);
|
||||||
|
const anglesDeg = new Float64Array(sampleCount);
|
||||||
|
const offsetsDeg = new Float64Array(sampleCount);
|
||||||
|
const directionCosines = new Float64Array(sampleCount);
|
||||||
|
const directionSines = new Float64Array(sampleCount);
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
const offsetDeg = (index - steeringIndex) * stepDeg;
|
||||||
|
const angleDeg = normalizeBeamPatternAngle(steeringAngleDeg + offsetDeg);
|
||||||
|
const radians = angleDeg * RADIANS_PER_DEGREE;
|
||||||
|
offsetsDeg[index] = offsetDeg;
|
||||||
|
anglesDeg[index] = angleDeg;
|
||||||
|
directionCosines[index] = Math.cos(radians);
|
||||||
|
directionSines[index] = Math.sin(radians);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steeringRadians = steeringAngleDeg * RADIANS_PER_DEGREE;
|
||||||
|
const steeringCosine = Math.cos(steeringRadians);
|
||||||
|
const steeringSine = Math.sin(steeringRadians);
|
||||||
|
const summedReal = new Float64Array(sampleCount);
|
||||||
|
const summedImaginary = new Float64Array(sampleCount);
|
||||||
|
for (const [firstCoordinate, secondCoordinate] of centeredCutCoordinates) {
|
||||||
|
const steeringProjection =
|
||||||
|
firstCoordinate * steeringCosine + secondCoordinate * steeringSine;
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
const arrivalProjection =
|
||||||
|
firstCoordinate * directionCosines[index] +
|
||||||
|
secondCoordinate * directionSines[index];
|
||||||
|
const phase = waveNumber * (arrivalProjection - steeringProjection);
|
||||||
|
summedReal[index] += Math.cos(phase);
|
||||||
|
summedImaginary[index] += Math.sin(phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseReal = new Float64Array(sampleCount);
|
||||||
|
const responseImaginary = new Float64Array(sampleCount);
|
||||||
|
const responsePower = new Float64Array(sampleCount);
|
||||||
|
const responseLinear = new Float64Array(sampleCount);
|
||||||
|
const responseDb = new Float64Array(sampleCount);
|
||||||
|
const displayDb = new Float64Array(sampleCount);
|
||||||
|
let maximumLinear = 0;
|
||||||
|
let maximumPower = 0;
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
const real = summedReal[index] / microphoneCount;
|
||||||
|
const imaginary = summedImaginary[index] / microphoneCount;
|
||||||
|
const power = real * real + imaginary * imaginary;
|
||||||
|
if (!Number.isFinite(real) || !Number.isFinite(imaginary) || !Number.isFinite(power)) {
|
||||||
|
throw new RangeError("receive beam-pattern phasor accumulation became non-finite.");
|
||||||
|
}
|
||||||
|
const magnitude = Math.sqrt(power);
|
||||||
|
const decibels = power > 0 ? 10 * Math.log10(power) : -Infinity;
|
||||||
|
responseReal[index] = real;
|
||||||
|
responseImaginary[index] = imaginary;
|
||||||
|
responsePower[index] = power;
|
||||||
|
responseLinear[index] = magnitude;
|
||||||
|
responseDb[index] = decibels;
|
||||||
|
displayDb[index] = Math.max(dbFloor, decibels);
|
||||||
|
maximumLinear = Math.max(maximumLinear, magnitude);
|
||||||
|
maximumPower = Math.max(maximumPower, power);
|
||||||
|
}
|
||||||
|
|
||||||
|
let metricDirectionEvaluationCount = 0;
|
||||||
|
const evaluatePowerAtOffset = (offsetDeg) => {
|
||||||
|
metricDirectionEvaluationCount += 1;
|
||||||
|
const angleRadians =
|
||||||
|
(steeringAngleDeg + offsetDeg) * RADIANS_PER_DEGREE;
|
||||||
|
const directionCosine = Math.cos(angleRadians);
|
||||||
|
const directionSine = Math.sin(angleRadians);
|
||||||
|
let real = 0;
|
||||||
|
let imaginary = 0;
|
||||||
|
for (const [firstCoordinate, secondCoordinate] of centeredCutCoordinates) {
|
||||||
|
const steeringProjection =
|
||||||
|
firstCoordinate * steeringCosine + secondCoordinate * steeringSine;
|
||||||
|
const arrivalProjection =
|
||||||
|
firstCoordinate * directionCosine + secondCoordinate * directionSine;
|
||||||
|
const phase = waveNumber * (arrivalProjection - steeringProjection);
|
||||||
|
real += Math.cos(phase);
|
||||||
|
imaginary += Math.sin(phase);
|
||||||
|
}
|
||||||
|
const power = (real * real + imaginary * imaginary) /
|
||||||
|
(microphoneCount * microphoneCount);
|
||||||
|
if (!Number.isFinite(power)) {
|
||||||
|
throw new RangeError("receive beam-pattern metric evaluation became non-finite.");
|
||||||
|
}
|
||||||
|
return power;
|
||||||
|
};
|
||||||
|
const halfPower = metricResolutionSatisfied
|
||||||
|
? halfPowerBeamwidth(
|
||||||
|
responsePower,
|
||||||
|
steeringIndex,
|
||||||
|
stepDeg,
|
||||||
|
steeringAngleDeg,
|
||||||
|
evaluatePowerAtOffset,
|
||||||
|
projectedApertureM === 0,
|
||||||
|
)
|
||||||
|
: {
|
||||||
|
status: "resolution-unresolved",
|
||||||
|
widthDeg: null,
|
||||||
|
startAngleDeg: null,
|
||||||
|
endAngleDeg: null,
|
||||||
|
fullCircleAboveHalfPower: null,
|
||||||
|
bracketSource: null,
|
||||||
|
};
|
||||||
|
const peaks = metricResolutionSatisfied
|
||||||
|
? sampledLocalMaxima(responsePower, anglesDeg, offsetsDeg, microphoneCount)
|
||||||
|
: {
|
||||||
|
status: "resolution-unresolved",
|
||||||
|
comparisonTolerancePower: null,
|
||||||
|
peaks: [],
|
||||||
|
globalMaximumRegions: [],
|
||||||
|
selected: null,
|
||||||
|
};
|
||||||
|
const steeringLinear = responseLinear[steeringIndex];
|
||||||
|
return {
|
||||||
|
model: "far-field-narrowband-conventional-delay-and-sum-receive-array-factor",
|
||||||
|
assumptions: Object.freeze({
|
||||||
|
wavefront: "far-field-plane-wave",
|
||||||
|
bandwidth: "narrowband",
|
||||||
|
elements: "ideal-calibrated-omnidirectional-point-microphones",
|
||||||
|
environment: "free-field",
|
||||||
|
coordinates: "meters",
|
||||||
|
}),
|
||||||
|
cut,
|
||||||
|
frequencyHz,
|
||||||
|
speedOfSound,
|
||||||
|
wavelengthM: speedOfSound / frequencyHz,
|
||||||
|
waveNumberRadPerM: waveNumber,
|
||||||
|
steeringAngleDeg,
|
||||||
|
steeringDirection: receiveBeamPatternDirection(cut, steeringAngleDeg),
|
||||||
|
sampleCount,
|
||||||
|
angularResolutionDeg: stepDeg,
|
||||||
|
microphoneCount,
|
||||||
|
evaluationCount: sampleEvaluationCount +
|
||||||
|
metricDirectionEvaluationCount * microphoneCount,
|
||||||
|
workload: {
|
||||||
|
projectedBaselineComparisons:
|
||||||
|
projectedBaselineComparisons,
|
||||||
|
maximumBaselineComparisons,
|
||||||
|
sampleEvaluationCount,
|
||||||
|
maximumMetricEvaluationCount,
|
||||||
|
maximumEvaluationCount: evaluationCount,
|
||||||
|
},
|
||||||
|
angularSampling: {
|
||||||
|
mode: requestedSampleCount === "auto" ? "auto" : "fixed",
|
||||||
|
criterion: "maximum-adjacent-pair-phase-step",
|
||||||
|
projectedApertureM,
|
||||||
|
maximumPhaseMagnitudeRadBound,
|
||||||
|
maximumAdjacentPhaseStepRadBound,
|
||||||
|
metricPhaseStepLimitRad:
|
||||||
|
RECEIVE_BEAM_PATTERN_METRIC_PHASE_STEP_LIMIT_RAD,
|
||||||
|
metricResolutionSatisfied,
|
||||||
|
recommendedSampleCount: recommendedMetricSampleCount,
|
||||||
|
autoCapped:
|
||||||
|
requestedSampleCount === "auto" &&
|
||||||
|
sampleCount < Math.max(
|
||||||
|
DEFAULT_RECEIVE_BEAM_PATTERN_SAMPLES,
|
||||||
|
recommendedMetricSampleCount,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
dbFloor,
|
||||||
|
anglesDeg,
|
||||||
|
offsetsDeg,
|
||||||
|
responseReal,
|
||||||
|
responseImaginary,
|
||||||
|
responsePower,
|
||||||
|
responseLinear,
|
||||||
|
responseDb,
|
||||||
|
displayDb,
|
||||||
|
metrics: {
|
||||||
|
maximumLinear,
|
||||||
|
maximumPower,
|
||||||
|
maximumDb: maximumPower > 0 ? 10 * Math.log10(maximumPower) : -Infinity,
|
||||||
|
steeringLinear,
|
||||||
|
steeringDb: steeringLinear > 0 ? 20 * Math.log10(steeringLinear) : -Infinity,
|
||||||
|
halfPowerBeamwidth: halfPower,
|
||||||
|
sampledLocalMaxima: peaks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
const CFAR_FRAME_RESULT_KIND = "micarrsim.cfar-frame-result/1";
|
||||||
|
|
||||||
|
export const DEFAULT_OS_CFAR_MAXIMUM_BISECTION_ITERATIONS = 256;
|
||||||
|
|
||||||
|
function requirePositiveSafeInteger(value, label) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1) {
|
||||||
|
throw new TypeError(`${label} must be a positive safe integer.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireNonNegativeSafeInteger(value, label) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw new TypeError(`${label} must be a non-negative safe integer.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePositiveFinite(value, label) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
throw new TypeError(`${label} must be a positive finite number.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireProbability(value, label) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0 || value >= 1) {
|
||||||
|
throw new RangeError(`${label} must be finite and strictly inside 0..1.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireTrainingCellCount(trainingCellCount) {
|
||||||
|
return requirePositiveSafeInteger(trainingCellCount, "trainingCellCount");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOsRank(rank, trainingCellCount) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(rank) ||
|
||||||
|
rank < 1 ||
|
||||||
|
rank > trainingCellCount
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`rank must be a 1-based ascending order-statistic index inside 1..${trainingCellCount}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CA-CFAR multiplier for an arithmetic-mean reference statistic.
|
||||||
|
*
|
||||||
|
* Under independent, identically distributed exponential CUT/reference
|
||||||
|
* powers, Pfa = (1 + alpha / N)^(-N). The `expm1` form preserves accuracy
|
||||||
|
* when the requested probability is close to one or N is large.
|
||||||
|
*/
|
||||||
|
export function computeCaCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
}) {
|
||||||
|
const count = requireTrainingCellCount(trainingCellCount);
|
||||||
|
const probability = requireProbability(
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
"nominalFalseAlarmProbability",
|
||||||
|
);
|
||||||
|
const multiplier = count * Math.expm1(-Math.log(probability) / count);
|
||||||
|
if (!Number.isFinite(multiplier) || multiplier <= 0) {
|
||||||
|
throw new RangeError(
|
||||||
|
"CA-CFAR threshold multiplier exceeds the finite positive Float64 range.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log false-alarm probability for OS-CFAR with an ascending 1-based rank.
|
||||||
|
*
|
||||||
|
* log(Pfa) = sum_(j=0)^(k-1) -log(1 + alpha / (N - j)).
|
||||||
|
* The log product avoids factorial/gamma overflow and is also used by the
|
||||||
|
* bracketed multiplier solver below.
|
||||||
|
*/
|
||||||
|
export function computeOsCfarLogFalseAlarmProbability({
|
||||||
|
trainingCellCount,
|
||||||
|
rank,
|
||||||
|
thresholdMultiplier,
|
||||||
|
}) {
|
||||||
|
const count = requireTrainingCellCount(trainingCellCount);
|
||||||
|
const selectedRank = requireOsRank(rank, count);
|
||||||
|
if (!Number.isFinite(thresholdMultiplier) || thresholdMultiplier < 0) {
|
||||||
|
throw new RangeError(
|
||||||
|
"thresholdMultiplier must be finite and non-negative.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let compensation = 0;
|
||||||
|
for (let offset = 0; offset < selectedRank; offset += 1) {
|
||||||
|
const term = -Math.log1p(thresholdMultiplier / (count - offset));
|
||||||
|
const next = sum + term;
|
||||||
|
const correction = Math.abs(sum) >= Math.abs(term)
|
||||||
|
? (sum - next) + term
|
||||||
|
: (term - next) + sum;
|
||||||
|
sum = next;
|
||||||
|
compensation += correction;
|
||||||
|
}
|
||||||
|
return sum + compensation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function solveOsCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
rank,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
maximumIterations,
|
||||||
|
}) {
|
||||||
|
const count = requireTrainingCellCount(trainingCellCount);
|
||||||
|
const selectedRank = requireOsRank(rank, count);
|
||||||
|
const probability = requireProbability(
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
"nominalFalseAlarmProbability",
|
||||||
|
);
|
||||||
|
const iterationLimit = requirePositiveSafeInteger(
|
||||||
|
maximumIterations,
|
||||||
|
"maximumIterations",
|
||||||
|
);
|
||||||
|
const targetLogProbability = Math.log(probability);
|
||||||
|
const evaluate = (thresholdMultiplier) =>
|
||||||
|
computeOsCfarLogFalseAlarmProbability({
|
||||||
|
trainingCellCount: count,
|
||||||
|
rank: selectedRank,
|
||||||
|
thresholdMultiplier,
|
||||||
|
});
|
||||||
|
|
||||||
|
let lowerMultiplier = 0;
|
||||||
|
let upperMultiplier = 1;
|
||||||
|
let upperLogProbability = evaluate(upperMultiplier);
|
||||||
|
let bracketDoublings = 0;
|
||||||
|
while (upperLogProbability > targetLogProbability) {
|
||||||
|
if (upperMultiplier >= Number.MAX_VALUE / 2) {
|
||||||
|
upperMultiplier = Number.MAX_VALUE;
|
||||||
|
upperLogProbability = evaluate(upperMultiplier);
|
||||||
|
if (upperLogProbability > targetLogProbability) {
|
||||||
|
throw new RangeError(
|
||||||
|
"OS-CFAR threshold multiplier exceeds the finite Float64 range.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
upperMultiplier *= 2;
|
||||||
|
upperLogProbability = evaluate(upperMultiplier);
|
||||||
|
bracketDoublings += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logarithmTolerance =
|
||||||
|
64 * Number.EPSILON *
|
||||||
|
Math.max(1, selectedRank, Math.abs(targetLogProbability));
|
||||||
|
let iterations = 0;
|
||||||
|
let multiplier = upperMultiplier;
|
||||||
|
let logarithmResidual = upperLogProbability - targetLogProbability;
|
||||||
|
let converged = Math.abs(logarithmResidual) <= logarithmTolerance;
|
||||||
|
|
||||||
|
while (!converged && iterations < iterationLimit) {
|
||||||
|
const midpoint =
|
||||||
|
lowerMultiplier + (upperMultiplier - lowerMultiplier) / 2;
|
||||||
|
if (midpoint === lowerMultiplier || midpoint === upperMultiplier) {
|
||||||
|
multiplier = Math.abs(logarithmResidual) <= logarithmTolerance
|
||||||
|
? upperMultiplier
|
||||||
|
: midpoint;
|
||||||
|
const midpointLogProbability = evaluate(multiplier);
|
||||||
|
logarithmResidual = midpointLogProbability - targetLogProbability;
|
||||||
|
converged =
|
||||||
|
Math.abs(logarithmResidual) <= logarithmTolerance ||
|
||||||
|
midpoint === lowerMultiplier ||
|
||||||
|
midpoint === upperMultiplier;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const midpointLogProbability = evaluate(midpoint);
|
||||||
|
logarithmResidual = midpointLogProbability - targetLogProbability;
|
||||||
|
multiplier = midpoint;
|
||||||
|
iterations += 1;
|
||||||
|
if (Math.abs(logarithmResidual) <= logarithmTolerance) {
|
||||||
|
converged = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (midpointLogProbability > targetLogProbability) {
|
||||||
|
lowerMultiplier = midpoint;
|
||||||
|
} else {
|
||||||
|
upperMultiplier = midpoint;
|
||||||
|
upperLogProbability = midpointLogProbability;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!converged) {
|
||||||
|
throw new RangeError(
|
||||||
|
`OS-CFAR threshold multiplier did not converge within ${iterationLimit} bisection iterations.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(multiplier) || multiplier <= 0) {
|
||||||
|
throw new RangeError(
|
||||||
|
"OS-CFAR threshold multiplier exceeds the finite positive Float64 range.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
multiplier,
|
||||||
|
iterations,
|
||||||
|
bracketDoublings,
|
||||||
|
targetLogProbability,
|
||||||
|
logarithmResidual,
|
||||||
|
logarithmTolerance,
|
||||||
|
converged: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solve the standard exponential-background OS-CFAR multiplier by bisection. */
|
||||||
|
export function computeOsCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
rank,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
maximumIterations = DEFAULT_OS_CFAR_MAXIMUM_BISECTION_ITERATIONS,
|
||||||
|
}) {
|
||||||
|
return solveOsCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
rank,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
maximumIterations,
|
||||||
|
}).multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePowerSpectrum(powerSpectrum, fftSize) {
|
||||||
|
if (powerSpectrum == null || !Number.isSafeInteger(powerSpectrum.length)) {
|
||||||
|
throw new TypeError("powerSpectrum must be a finite numeric array-like value.");
|
||||||
|
}
|
||||||
|
const expectedBinCount = fftSize / 2 + 1;
|
||||||
|
if (powerSpectrum.length !== expectedBinCount) {
|
||||||
|
throw new RangeError(
|
||||||
|
`powerSpectrum must contain exactly ${expectedBinCount} one-sided bins for fftSize ${fftSize}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let bin = 0; bin < expectedBinCount; bin += 1) {
|
||||||
|
const power = powerSpectrum[bin];
|
||||||
|
if (typeof power !== "number" || !Number.isFinite(power)) {
|
||||||
|
throw new TypeError(`powerSpectrum[${bin}] must be finite.`);
|
||||||
|
}
|
||||||
|
if (power < 0) {
|
||||||
|
throw new RangeError(`powerSpectrum[${bin}] must be non-negative.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expectedBinCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaledArithmeticMean(values) {
|
||||||
|
let scale = 0;
|
||||||
|
for (const value of values) scale = Math.max(scale, value);
|
||||||
|
if (scale === 0) return 0;
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let compensation = 0;
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = value / scale;
|
||||||
|
const next = sum + normalized;
|
||||||
|
const correction = Math.abs(sum) >= Math.abs(normalized)
|
||||||
|
? (sum - next) + normalized
|
||||||
|
: (normalized - next) + sum;
|
||||||
|
sum = next;
|
||||||
|
compensation += correction;
|
||||||
|
}
|
||||||
|
return ((sum + compensation) / values.length) * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeArray(values) {
|
||||||
|
return Object.freeze(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFrequencyMetadata(bin, binWidthHz, nyquistHz) {
|
||||||
|
return {
|
||||||
|
adcBin: bin,
|
||||||
|
adcCenterFrequencyHz: bin * binWidthHz,
|
||||||
|
adcFftCellHz: Object.freeze({
|
||||||
|
minimumHz: Math.max(0, (bin - 0.5) * binWidthHz),
|
||||||
|
maximumHz: Math.min(nyquistHz, (bin + 0.5) * binWidthHz),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUntestedBin({
|
||||||
|
bin,
|
||||||
|
cutPower,
|
||||||
|
binWidthHz,
|
||||||
|
nyquistHz,
|
||||||
|
reason,
|
||||||
|
}) {
|
||||||
|
return Object.freeze({
|
||||||
|
...createFrequencyMetadata(bin, binWidthHz, nyquistHz),
|
||||||
|
status: "untested",
|
||||||
|
reason,
|
||||||
|
cutPower,
|
||||||
|
referencePower: null,
|
||||||
|
thresholdPower: null,
|
||||||
|
thresholdRatio: null,
|
||||||
|
thresholdRatioStatus: "unavailable-untested-bin",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDetectorConfiguration(options, nyquistBin) {
|
||||||
|
const detector = options.detector;
|
||||||
|
if (detector !== "ca-cfar" && detector !== "os-cfar") {
|
||||||
|
throw new RangeError('detector must be "ca-cfar" or "os-cfar".');
|
||||||
|
}
|
||||||
|
const trainingCellsPerSide = requirePositiveSafeInteger(
|
||||||
|
options.trainingCellsPerSide,
|
||||||
|
"trainingCellsPerSide",
|
||||||
|
);
|
||||||
|
const guardCellsPerSide = requireNonNegativeSafeInteger(
|
||||||
|
options.guardCellsPerSide,
|
||||||
|
"guardCellsPerSide",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
trainingCellsPerSide >
|
||||||
|
Number.MAX_SAFE_INTEGER - guardCellsPerSide
|
||||||
|
) {
|
||||||
|
throw new RangeError("CFAR reference radius exceeds the safe integer range.");
|
||||||
|
}
|
||||||
|
const referenceRadius = trainingCellsPerSide + guardCellsPerSide;
|
||||||
|
if (referenceRadius > (nyquistBin - 2) / 2) {
|
||||||
|
throw new RangeError(
|
||||||
|
"CFAR training and guard cells leave no interior bin with a full symmetric reference window.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const trainingCellCount = 2 * trainingCellsPerSide;
|
||||||
|
if (!Number.isSafeInteger(trainingCellCount)) {
|
||||||
|
throw new RangeError("trainingCellCount exceeds the safe integer range.");
|
||||||
|
}
|
||||||
|
const nominalFalseAlarmProbability = requireProbability(
|
||||||
|
options.nominalFalseAlarmProbability,
|
||||||
|
"nominalFalseAlarmProbability",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detector === "ca-cfar") {
|
||||||
|
if (options.rank !== undefined) {
|
||||||
|
throw new RangeError("rank is only valid for OS-CFAR.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detector,
|
||||||
|
trainingCellsPerSide,
|
||||||
|
guardCellsPerSide,
|
||||||
|
referenceRadius,
|
||||||
|
trainingCellCount,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
rank: null,
|
||||||
|
thresholdMultiplier: computeCaCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
}),
|
||||||
|
thresholdMultiplierDerivation:
|
||||||
|
"N*expm1(-log(nominalPfa)/N)-for-arithmetic-mean-reference",
|
||||||
|
osSolver: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = requireOsRank(options.rank, trainingCellCount);
|
||||||
|
const osSolver = solveOsCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
rank,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
maximumIterations:
|
||||||
|
options.maximumOsBisectionIterations ??
|
||||||
|
DEFAULT_OS_CFAR_MAXIMUM_BISECTION_ITERATIONS,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
detector,
|
||||||
|
trainingCellsPerSide,
|
||||||
|
guardCellsPerSide,
|
||||||
|
referenceRadius,
|
||||||
|
trainingCellCount,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
rank,
|
||||||
|
thresholdMultiplier: osSolver.multiplier,
|
||||||
|
thresholdMultiplierDerivation:
|
||||||
|
"log-product-bracketed-bisection-for-ascending-order-statistic",
|
||||||
|
osSolver,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect narrowband activity in one complete, single-channel, one-sided power
|
||||||
|
* spectrum. No temporal/microphone averaging, bin grouping, peak selection,
|
||||||
|
* persistence, clustering, or detector fusion is performed.
|
||||||
|
*/
|
||||||
|
export function detectCfarFrame(options) {
|
||||||
|
if (!options || typeof options !== "object") {
|
||||||
|
throw new TypeError("CFAR options must be an object.");
|
||||||
|
}
|
||||||
|
const sampleRate = requirePositiveFinite(options.sampleRate, "sampleRate");
|
||||||
|
const fftSize = requirePositiveSafeInteger(options.fftSize, "fftSize");
|
||||||
|
if (fftSize < 4 || fftSize % 2 !== 0) {
|
||||||
|
throw new RangeError("fftSize must be an even integer greater than or equal to four.");
|
||||||
|
}
|
||||||
|
if (options.window !== "hann") {
|
||||||
|
throw new RangeError('window must be "hann".');
|
||||||
|
}
|
||||||
|
if (options.frameComplete !== true) {
|
||||||
|
throw new RangeError("frameComplete must be true; partial frames are not tested.");
|
||||||
|
}
|
||||||
|
if (typeof options.channelId !== "string" || options.channelId.length === 0) {
|
||||||
|
throw new TypeError("channelId must be a non-empty string.");
|
||||||
|
}
|
||||||
|
const frameIndex = requireNonNegativeSafeInteger(
|
||||||
|
options.frameIndex,
|
||||||
|
"frameIndex",
|
||||||
|
);
|
||||||
|
const frameStartSample = requireNonNegativeSafeInteger(
|
||||||
|
options.frameStartSample,
|
||||||
|
"frameStartSample",
|
||||||
|
);
|
||||||
|
const temporalAverageCount = options.temporalAverageCount ?? 1;
|
||||||
|
const microphoneAverageCount = options.microphoneAverageCount ?? 1;
|
||||||
|
if (temporalAverageCount !== 1) {
|
||||||
|
throw new RangeError(
|
||||||
|
"temporalAverageCount must equal one; the exponential CFAR multiplier is not valid after temporal averaging.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (microphoneAverageCount !== 1) {
|
||||||
|
throw new RangeError(
|
||||||
|
"microphoneAverageCount must equal one; microphone averaging or fusion is not performed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binCount = requirePowerSpectrum(options.powerSpectrum, fftSize);
|
||||||
|
const nyquistBin = fftSize / 2;
|
||||||
|
const configuration = resolveDetectorConfiguration(options, nyquistBin);
|
||||||
|
const {
|
||||||
|
detector,
|
||||||
|
trainingCellsPerSide,
|
||||||
|
guardCellsPerSide,
|
||||||
|
referenceRadius,
|
||||||
|
trainingCellCount,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
rank,
|
||||||
|
thresholdMultiplier,
|
||||||
|
thresholdMultiplierDerivation,
|
||||||
|
osSolver,
|
||||||
|
} = configuration;
|
||||||
|
const nyquistHz = sampleRate / 2;
|
||||||
|
const binWidthHz = sampleRate / fftSize;
|
||||||
|
const firstTestedBin = referenceRadius + 1;
|
||||||
|
const lastTestedBin = nyquistBin - referenceRadius - 1;
|
||||||
|
const referencePowers = new Float64Array(trainingCellCount);
|
||||||
|
const bins = new Array(binCount);
|
||||||
|
let testedBinCount = 0;
|
||||||
|
let detectedBinCount = 0;
|
||||||
|
|
||||||
|
for (let bin = 0; bin < binCount; bin += 1) {
|
||||||
|
const cutPower = options.powerSpectrum[bin];
|
||||||
|
let untestedReason = null;
|
||||||
|
if (bin === 0) {
|
||||||
|
untestedReason = "real-only-dc-bin";
|
||||||
|
} else if (bin === nyquistBin) {
|
||||||
|
untestedReason = "real-only-nyquist-bin";
|
||||||
|
} else if (bin < firstTestedBin || bin > lastTestedBin) {
|
||||||
|
untestedReason = "insufficient-full-symmetric-reference-window";
|
||||||
|
}
|
||||||
|
if (untestedReason !== null) {
|
||||||
|
bins[bin] = createUntestedBin({
|
||||||
|
bin,
|
||||||
|
cutPower,
|
||||||
|
binWidthHz,
|
||||||
|
nyquistHz,
|
||||||
|
reason: untestedReason,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let referenceIndex = 0;
|
||||||
|
const leftStart = bin - guardCellsPerSide - trainingCellsPerSide;
|
||||||
|
const leftEnd = bin - guardCellsPerSide;
|
||||||
|
for (let referenceBin = leftStart; referenceBin < leftEnd; referenceBin += 1) {
|
||||||
|
referencePowers[referenceIndex] = options.powerSpectrum[referenceBin];
|
||||||
|
referenceIndex += 1;
|
||||||
|
}
|
||||||
|
const rightStart = bin + guardCellsPerSide + 1;
|
||||||
|
const rightEnd = rightStart + trainingCellsPerSide;
|
||||||
|
for (let referenceBin = rightStart; referenceBin < rightEnd; referenceBin += 1) {
|
||||||
|
referencePowers[referenceIndex] = options.powerSpectrum[referenceBin];
|
||||||
|
referenceIndex += 1;
|
||||||
|
}
|
||||||
|
if (referenceIndex !== trainingCellCount) {
|
||||||
|
throw new Error("CFAR reference-window construction violated fixed N=2R.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let referencePower;
|
||||||
|
if (detector === "ca-cfar") {
|
||||||
|
referencePower = scaledArithmeticMean(referencePowers);
|
||||||
|
} else {
|
||||||
|
referencePowers.sort();
|
||||||
|
referencePower = referencePowers[rank - 1];
|
||||||
|
}
|
||||||
|
const thresholdPower = thresholdMultiplier * referencePower;
|
||||||
|
if (!Number.isFinite(thresholdPower)) {
|
||||||
|
throw new RangeError(
|
||||||
|
`CFAR threshold exceeds the finite Float64 range at bin ${bin}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const detected = cutPower > thresholdPower;
|
||||||
|
let thresholdRatio;
|
||||||
|
let thresholdRatioStatus;
|
||||||
|
if (thresholdPower === 0) {
|
||||||
|
if (cutPower === 0) {
|
||||||
|
thresholdRatio = null;
|
||||||
|
thresholdRatioStatus = "indeterminate-zero-over-zero";
|
||||||
|
} else {
|
||||||
|
thresholdRatio = null;
|
||||||
|
thresholdRatioStatus = "positive-infinity-zero-threshold";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
thresholdRatio = cutPower / thresholdPower;
|
||||||
|
thresholdRatioStatus = "finite";
|
||||||
|
}
|
||||||
|
bins[bin] = Object.freeze({
|
||||||
|
...createFrequencyMetadata(bin, binWidthHz, nyquistHz),
|
||||||
|
status: detected ? "detected" : "not-detected",
|
||||||
|
reason: null,
|
||||||
|
cutPower,
|
||||||
|
referencePower,
|
||||||
|
thresholdPower,
|
||||||
|
thresholdRatio,
|
||||||
|
thresholdRatioStatus,
|
||||||
|
});
|
||||||
|
testedBinCount += 1;
|
||||||
|
if (detected) detectedBinCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: CFAR_FRAME_RESULT_KIND,
|
||||||
|
detector,
|
||||||
|
frame: Object.freeze({
|
||||||
|
channelId: options.channelId,
|
||||||
|
frameIndex,
|
||||||
|
frameStartSample,
|
||||||
|
complete: true,
|
||||||
|
temporalAverageCount: 1,
|
||||||
|
microphoneAverageCount: 1,
|
||||||
|
}),
|
||||||
|
spectrum: Object.freeze({
|
||||||
|
domain: "one-sided-real-adc-power-spectrum",
|
||||||
|
statistic: "caller-supplied-non-negative-power-per-bin",
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
binCount,
|
||||||
|
binWidthHz,
|
||||||
|
nyquistHz,
|
||||||
|
window: "hann",
|
||||||
|
windowDefinition: "symmetric-hann-N-minus-one",
|
||||||
|
analogFrequencyOrAliasOrderInferred: false,
|
||||||
|
}),
|
||||||
|
parameters: Object.freeze({
|
||||||
|
trainingCellsPerSide,
|
||||||
|
guardCellsPerSide,
|
||||||
|
trainingCellCount,
|
||||||
|
referenceStatistic: detector === "ca-cfar"
|
||||||
|
? "arithmetic-mean-power"
|
||||||
|
: "ascending-order-statistic-power",
|
||||||
|
rank,
|
||||||
|
rankConvention: detector === "os-cfar" ? "1-based-ascending" : null,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
nominalPfaPerTestedCutUnderIidExponentialModel:
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
thresholdMultiplier,
|
||||||
|
thresholdMultiplierDerivation,
|
||||||
|
thresholdComparison: "cut-power-strictly-greater-than-threshold-power",
|
||||||
|
firstTestedBin: firstTestedBin <= lastTestedBin
|
||||||
|
? firstTestedBin
|
||||||
|
: null,
|
||||||
|
lastTestedBin: firstTestedBin <= lastTestedBin
|
||||||
|
? lastTestedBin
|
||||||
|
: null,
|
||||||
|
testedBinCount,
|
||||||
|
untestedBinCount: binCount - testedBinCount,
|
||||||
|
detectedBinCount,
|
||||||
|
osSolver,
|
||||||
|
}),
|
||||||
|
calibration: Object.freeze({
|
||||||
|
status: "nominal-model-only-not-empirically-calibrated",
|
||||||
|
falseAlarmScope: "one-tested-bin-in-one-frame",
|
||||||
|
model: "independent-identically-distributed-exponential-power-with-common-mean",
|
||||||
|
calibratedFalseAlarmProbability: null,
|
||||||
|
globalFrameFalseAlarmProbability: null,
|
||||||
|
assumptions: freezeArray([
|
||||||
|
"cut-and-training-powers-are-independent",
|
||||||
|
"cut-and-training-powers-have-one-common-local-mean",
|
||||||
|
"powers-are-exponential-square-law-complex-gaussian-statistics",
|
||||||
|
]),
|
||||||
|
notCalibratedReasons: freezeArray([
|
||||||
|
"hann-window-bin-independence-is-not-guaranteed",
|
||||||
|
"colored-acoustic-noise-and-microphone-response-are-not-verified-homogeneous",
|
||||||
|
"overlapping-sliding-reference-windows-share-cells",
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
processing: Object.freeze({
|
||||||
|
completeFramesOnly: true,
|
||||||
|
temporalAveraging: false,
|
||||||
|
microphoneAveraging: false,
|
||||||
|
peakSelection: false,
|
||||||
|
topKSelection: false,
|
||||||
|
clustering: false,
|
||||||
|
contiguousBinMerging: false,
|
||||||
|
temporalPersistence: false,
|
||||||
|
detectorFusion: false,
|
||||||
|
microphoneFusion: false,
|
||||||
|
bandFusion: false,
|
||||||
|
}),
|
||||||
|
bins: Object.freeze(bins),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectCaCfarFrame(options) {
|
||||||
|
return detectCfarFrame({ ...options, detector: "ca-cfar" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectOsCfarFrame(options) {
|
||||||
|
return detectCfarFrame({ ...options, detector: "os-cfar" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT;
|
||||||
|
|
||||||
|
export const DEFAULT_MAX_COVARIANCE_WORK_BYTES = 32 * 1024 * 1024;
|
||||||
|
export const DEFAULT_MAX_COVARIANCE_COMPLEX_ACCUMULATIONS = 250_000_000;
|
||||||
|
export const DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR = 64;
|
||||||
|
|
||||||
|
function assertPositiveInteger(value, label) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1) {
|
||||||
|
throw new TypeError(`${label} must be a positive safe integer.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedProduct(factors, label) {
|
||||||
|
let product = 1;
|
||||||
|
for (const factor of factors) {
|
||||||
|
if (factor !== 0 && product > Number.MAX_SAFE_INTEGER / factor) {
|
||||||
|
throw new RangeError(`${label} exceeds the safe integer range.`);
|
||||||
|
}
|
||||||
|
product *= factor;
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundoffGamma(operationCount) {
|
||||||
|
const product = operationCount * (Number.EPSILON / 2);
|
||||||
|
if (!(product < 1)) {
|
||||||
|
throw new RangeError("roundoff bound is undefined for this workload.");
|
||||||
|
}
|
||||||
|
return product / (1 - product);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimate covariance output allocation and upper-triangle accumulation work. */
|
||||||
|
export function estimateComplexCovarianceWorkload({ sensorCount, snapshotCount }) {
|
||||||
|
assertPositiveInteger(sensorCount, "sensorCount");
|
||||||
|
assertPositiveInteger(snapshotCount, "snapshotCount");
|
||||||
|
const inputElementCount = checkedProduct(
|
||||||
|
[sensorCount, snapshotCount],
|
||||||
|
"covariance input element count",
|
||||||
|
);
|
||||||
|
const matrixElementCount = checkedProduct(
|
||||||
|
[sensorCount, sensorCount],
|
||||||
|
"covariance matrix element count",
|
||||||
|
);
|
||||||
|
const upperTriangleElementCount =
|
||||||
|
checkedProduct([sensorCount, sensorCount + 1], "covariance upper triangle") / 2;
|
||||||
|
const complexAccumulations = checkedProduct(
|
||||||
|
[upperTriangleElementCount, snapshotCount],
|
||||||
|
"covariance accumulation count",
|
||||||
|
);
|
||||||
|
const allocationBytes = checkedProduct(
|
||||||
|
[2, matrixElementCount, FLOAT64_BYTES],
|
||||||
|
"covariance allocation",
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
inputElementCount,
|
||||||
|
matrixElementCount,
|
||||||
|
upperTriangleElementCount,
|
||||||
|
complexAccumulations,
|
||||||
|
allocationBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compensatedAdd(sum, compensation, value) {
|
||||||
|
const next = sum + value;
|
||||||
|
const correction = Math.abs(sum) >= Math.abs(value)
|
||||||
|
? (sum - next) + value
|
||||||
|
: (value - next) + sum;
|
||||||
|
return [next, compensation + correction];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute R = (1/T) sum_t x[t] x[t]^H in Float64.
|
||||||
|
*
|
||||||
|
* `snapshot-major` stores x(t,m) at `t * sensorCount + m`; `sensor-major`
|
||||||
|
* stores it at `m * snapshotCount + t`. Only the upper triangle is
|
||||||
|
* accumulated, with Neumaier compensation, and the lower triangle is filled
|
||||||
|
* as its exact complex conjugate. No centering or diagonal loading is applied.
|
||||||
|
*/
|
||||||
|
export function computeComplexSampleCovariance({
|
||||||
|
real,
|
||||||
|
imaginary,
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
layout = "snapshot-major",
|
||||||
|
maximumWorkBytes = DEFAULT_MAX_COVARIANCE_WORK_BYTES,
|
||||||
|
maximumComplexAccumulations = DEFAULT_MAX_COVARIANCE_COMPLEX_ACCUMULATIONS,
|
||||||
|
}) {
|
||||||
|
const workload = estimateComplexCovarianceWorkload({ sensorCount, snapshotCount });
|
||||||
|
if (layout !== "snapshot-major" && layout !== "sensor-major") {
|
||||||
|
throw new RangeError("layout must be 'snapshot-major' or 'sensor-major'.");
|
||||||
|
}
|
||||||
|
if (real == null || imaginary == null) {
|
||||||
|
throw new TypeError("real and imaginary snapshots are required.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
real.length !== workload.inputElementCount ||
|
||||||
|
imaginary.length !== workload.inputElementCount
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`snapshot components must each contain ${workload.inputElementCount} values.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(maximumWorkBytes >= 0) || !Number.isFinite(maximumWorkBytes)) {
|
||||||
|
throw new TypeError("maximumWorkBytes must be finite and nonnegative.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(maximumComplexAccumulations >= 0) ||
|
||||||
|
!Number.isFinite(maximumComplexAccumulations)
|
||||||
|
) {
|
||||||
|
throw new TypeError("maximumComplexAccumulations must be finite and nonnegative.");
|
||||||
|
}
|
||||||
|
if (workload.allocationBytes > maximumWorkBytes) {
|
||||||
|
throw new RangeError(
|
||||||
|
`covariance requires ${workload.allocationBytes} typed-array bytes; limit is ${maximumWorkBytes}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (workload.complexAccumulations > maximumComplexAccumulations) {
|
||||||
|
throw new RangeError(
|
||||||
|
`covariance requires ${workload.complexAccumulations} complex accumulations; limit is ${maximumComplexAccumulations}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < workload.inputElementCount; index += 1) {
|
||||||
|
if (!Number.isFinite(real[index]) || !Number.isFinite(imaginary[index])) {
|
||||||
|
throw new TypeError("snapshot components must be finite.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matrixReal = new Float64Array(workload.matrixElementCount);
|
||||||
|
const matrixImaginary = new Float64Array(workload.matrixElementCount);
|
||||||
|
const sampleIndex = layout === "snapshot-major"
|
||||||
|
? (sensor, snapshot) => snapshot * sensorCount + sensor
|
||||||
|
: (sensor, snapshot) => sensor * snapshotCount + snapshot;
|
||||||
|
let matrixScale = 0;
|
||||||
|
let traceSum = 0;
|
||||||
|
let traceCompensation = 0;
|
||||||
|
|
||||||
|
for (let row = 0; row < sensorCount; row += 1) {
|
||||||
|
for (let column = row; column < sensorCount; column += 1) {
|
||||||
|
let sumReal = 0;
|
||||||
|
let compensationReal = 0;
|
||||||
|
let sumImaginary = 0;
|
||||||
|
let compensationImaginary = 0;
|
||||||
|
for (let snapshot = 0; snapshot < snapshotCount; snapshot += 1) {
|
||||||
|
const rowIndex = sampleIndex(row, snapshot);
|
||||||
|
const columnIndex = sampleIndex(column, snapshot);
|
||||||
|
const rowReal = real[rowIndex];
|
||||||
|
const rowImaginary = imaginary[rowIndex];
|
||||||
|
const columnReal = real[columnIndex];
|
||||||
|
const columnImaginary = imaginary[columnIndex];
|
||||||
|
const productReal =
|
||||||
|
rowReal * columnReal + rowImaginary * columnImaginary;
|
||||||
|
const productImaginary =
|
||||||
|
rowImaginary * columnReal - rowReal * columnImaginary;
|
||||||
|
if (!Number.isFinite(productReal) || !Number.isFinite(productImaginary)) {
|
||||||
|
throw new RangeError("covariance product exceeds the finite Float64 range.");
|
||||||
|
}
|
||||||
|
[sumReal, compensationReal] = compensatedAdd(
|
||||||
|
sumReal,
|
||||||
|
compensationReal,
|
||||||
|
productReal,
|
||||||
|
);
|
||||||
|
[sumImaginary, compensationImaginary] = compensatedAdd(
|
||||||
|
sumImaginary,
|
||||||
|
compensationImaginary,
|
||||||
|
productImaginary,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const valueReal = (sumReal + compensationReal) / snapshotCount;
|
||||||
|
const valueImaginary = row === column
|
||||||
|
? 0
|
||||||
|
: (sumImaginary + compensationImaginary) / snapshotCount;
|
||||||
|
if (!Number.isFinite(valueReal) || !Number.isFinite(valueImaginary)) {
|
||||||
|
throw new RangeError("covariance value exceeds the finite Float64 range.");
|
||||||
|
}
|
||||||
|
const upper = row * sensorCount + column;
|
||||||
|
const lower = column * sensorCount + row;
|
||||||
|
matrixReal[upper] = valueReal;
|
||||||
|
matrixImaginary[upper] = valueImaginary;
|
||||||
|
matrixReal[lower] = valueReal;
|
||||||
|
matrixImaginary[lower] = row === column ? 0 : -valueImaginary;
|
||||||
|
matrixScale = Math.max(
|
||||||
|
matrixScale,
|
||||||
|
Math.abs(valueReal),
|
||||||
|
Math.abs(valueImaginary),
|
||||||
|
);
|
||||||
|
if (row === column) {
|
||||||
|
[traceSum, traceCompensation] = compensatedAdd(
|
||||||
|
traceSum,
|
||||||
|
traceCompensation,
|
||||||
|
valueReal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trace = traceSum + traceCompensation;
|
||||||
|
const covarianceRoundoffBound =
|
||||||
|
roundoffGamma(4 * snapshotCount + 32) * trace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
inputLayout: layout,
|
||||||
|
normalization: "1/T",
|
||||||
|
centered: false,
|
||||||
|
matrix: {
|
||||||
|
real: matrixReal,
|
||||||
|
imaginary: matrixImaginary,
|
||||||
|
layout: "row-major",
|
||||||
|
},
|
||||||
|
matrixScale,
|
||||||
|
trace,
|
||||||
|
covarianceRoundoffBound,
|
||||||
|
workload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit covariance eigenvalues against a scale-aware backward-roundoff bound.
|
||||||
|
* Strictly positive values are never floored. Only negative values within the
|
||||||
|
* bound are represented as zero in `roundoffClampedEigenvalues`; raw values
|
||||||
|
* are always retained separately.
|
||||||
|
*/
|
||||||
|
export function auditCovarianceEigenvalues({
|
||||||
|
eigenvalues,
|
||||||
|
sensorCount = eigenvalues?.length,
|
||||||
|
matrixScale,
|
||||||
|
roundoffTolerance,
|
||||||
|
covarianceRoundoffBound,
|
||||||
|
eigensolverResidualFrobeniusNorm = 0,
|
||||||
|
roundoffFactor = DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR,
|
||||||
|
rejectIndefinite = true,
|
||||||
|
}) {
|
||||||
|
if (eigenvalues == null) throw new TypeError("eigenvalues are required.");
|
||||||
|
assertPositiveInteger(sensorCount, "sensorCount");
|
||||||
|
if (eigenvalues.length !== sensorCount) {
|
||||||
|
throw new RangeError("eigenvalue count must equal sensorCount.");
|
||||||
|
}
|
||||||
|
if (!(roundoffFactor > 0) || !Number.isFinite(roundoffFactor)) {
|
||||||
|
throw new TypeError("roundoffFactor must be finite and positive.");
|
||||||
|
}
|
||||||
|
if (matrixScale !== undefined && (!(matrixScale >= 0) || !Number.isFinite(matrixScale))) {
|
||||||
|
throw new TypeError("matrixScale must be finite and nonnegative.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
covarianceRoundoffBound !== undefined &&
|
||||||
|
(!(covarianceRoundoffBound >= 0) || !Number.isFinite(covarianceRoundoffBound))
|
||||||
|
) {
|
||||||
|
throw new TypeError("covarianceRoundoffBound must be finite and nonnegative.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!(eigensolverResidualFrobeniusNorm >= 0) ||
|
||||||
|
!Number.isFinite(eigensolverResidualFrobeniusNorm)
|
||||||
|
) {
|
||||||
|
throw new TypeError(
|
||||||
|
"eigensolverResidualFrobeniusNorm must be finite and nonnegative.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let eigenvalueScale = matrixScale ?? 0;
|
||||||
|
let minimumEigenvalue = Infinity;
|
||||||
|
for (const value of eigenvalues) {
|
||||||
|
if (!Number.isFinite(value)) throw new TypeError("eigenvalues must be finite.");
|
||||||
|
eigenvalueScale = Math.max(eigenvalueScale, Math.abs(value));
|
||||||
|
minimumEigenvalue = Math.min(minimumEigenvalue, value);
|
||||||
|
}
|
||||||
|
const scaleDerivedRoundoffTolerance = eigenvalueScale === 0
|
||||||
|
? 0
|
||||||
|
: roundoffFactor * Number.EPSILON * Math.max(1, sensorCount) * eigenvalueScale;
|
||||||
|
const effectiveRoundoffTolerance = roundoffTolerance ?? (
|
||||||
|
covarianceRoundoffBound === undefined
|
||||||
|
? scaleDerivedRoundoffTolerance
|
||||||
|
: covarianceRoundoffBound + eigensolverResidualFrobeniusNorm
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!(effectiveRoundoffTolerance >= 0) ||
|
||||||
|
!Number.isFinite(effectiveRoundoffTolerance)
|
||||||
|
) {
|
||||||
|
throw new TypeError("roundoffTolerance must be finite and nonnegative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawEigenvalues = Float64Array.from(eigenvalues);
|
||||||
|
const roundoffClampedEigenvalues = Float64Array.from(eigenvalues);
|
||||||
|
let clampedNegativeCount = 0;
|
||||||
|
let firstIndefiniteIndex = -1;
|
||||||
|
for (let index = 0; index < sensorCount; index += 1) {
|
||||||
|
const value = rawEigenvalues[index];
|
||||||
|
if (value < -effectiveRoundoffTolerance) {
|
||||||
|
if (firstIndefiniteIndex < 0) firstIndefiniteIndex = index;
|
||||||
|
} else if (value < 0 || Object.is(value, -0)) {
|
||||||
|
roundoffClampedEigenvalues[index] = 0;
|
||||||
|
clampedNegativeCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isPositiveSemidefinite = firstIndefiniteIndex < 0;
|
||||||
|
if (!isPositiveSemidefinite && rejectIndefinite) {
|
||||||
|
const error = new RangeError(
|
||||||
|
`covariance is genuinely indefinite: eigenvalue ${rawEigenvalues[firstIndefiniteIndex]} is below -${effectiveRoundoffTolerance}.`,
|
||||||
|
);
|
||||||
|
error.eigenvalueIndex = firstIndefiniteIndex;
|
||||||
|
error.eigenvalue = rawEigenvalues[firstIndefiniteIndex];
|
||||||
|
error.roundoffTolerance = effectiveRoundoffTolerance;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawEigenvalues,
|
||||||
|
roundoffClampedEigenvalues,
|
||||||
|
roundoffTolerance: effectiveRoundoffTolerance,
|
||||||
|
roundoffFactor,
|
||||||
|
roundoffDerivation: roundoffTolerance !== undefined
|
||||||
|
? "explicit"
|
||||||
|
: covarianceRoundoffBound === undefined
|
||||||
|
? "eigenvalue-scale"
|
||||||
|
: "covariance-accumulation-plus-eigensolver-residual",
|
||||||
|
covarianceRoundoffBound: covarianceRoundoffBound ?? null,
|
||||||
|
eigensolverResidualFrobeniusNorm,
|
||||||
|
eigenvalueScale,
|
||||||
|
minimumEigenvalue,
|
||||||
|
clampedNegativeCount,
|
||||||
|
firstIndefiniteIndex,
|
||||||
|
isPositiveSemidefinite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailableSourceCount(criterion, reason, details = {}) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
criterion,
|
||||||
|
sourceCount: null,
|
||||||
|
score: null,
|
||||||
|
scores: null,
|
||||||
|
reason,
|
||||||
|
...details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select source count with the Wax-Kailath AIC or MDL criterion.
|
||||||
|
*
|
||||||
|
* This pure function deliberately declines an answer unless T > M, snapshots
|
||||||
|
* are declared independent, and every eigenvalue used by the candidate set is
|
||||||
|
* strictly above the supplied roundoff bound. It performs no loading, rank
|
||||||
|
* repair, or cross-frequency source-count fusion.
|
||||||
|
*/
|
||||||
|
export function selectWaxKailathSourceCount({
|
||||||
|
eigenvalues,
|
||||||
|
snapshotCount,
|
||||||
|
independentSnapshots,
|
||||||
|
criterion = "mdl",
|
||||||
|
roundoffTolerance,
|
||||||
|
}) {
|
||||||
|
if (criterion !== "aic" && criterion !== "mdl") {
|
||||||
|
throw new RangeError("criterion must be 'aic' or 'mdl'.");
|
||||||
|
}
|
||||||
|
if (eigenvalues == null || eigenvalues.length < 1) {
|
||||||
|
throw new TypeError("at least one eigenvalue is required.");
|
||||||
|
}
|
||||||
|
assertPositiveInteger(snapshotCount, "snapshotCount");
|
||||||
|
const sensorCount = eigenvalues.length;
|
||||||
|
assertPositiveInteger(sensorCount, "sensorCount");
|
||||||
|
let scale = 0;
|
||||||
|
for (const value of eigenvalues) {
|
||||||
|
if (!Number.isFinite(value)) throw new TypeError("eigenvalues must be finite.");
|
||||||
|
scale = Math.max(scale, Math.abs(value));
|
||||||
|
}
|
||||||
|
const effectiveRoundoffTolerance = roundoffTolerance ?? (
|
||||||
|
scale === 0
|
||||||
|
? 0
|
||||||
|
: DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR *
|
||||||
|
Number.EPSILON * Math.max(1, sensorCount) * scale
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!(effectiveRoundoffTolerance >= 0) ||
|
||||||
|
!Number.isFinite(effectiveRoundoffTolerance)
|
||||||
|
) {
|
||||||
|
throw new TypeError("roundoffTolerance must be finite and nonnegative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (independentSnapshots !== true) {
|
||||||
|
return unavailableSourceCount(criterion, "snapshots-not-independent", {
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
roundoffTolerance: effectiveRoundoffTolerance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (snapshotCount <= sensorCount) {
|
||||||
|
return unavailableSourceCount(criterion, "snapshot-count-not-greater-than-sensors", {
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
roundoffTolerance: effectiveRoundoffTolerance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < sensorCount; index += 1) {
|
||||||
|
const value = eigenvalues[index];
|
||||||
|
if (index > 0 && value > eigenvalues[index - 1] + effectiveRoundoffTolerance) {
|
||||||
|
throw new RangeError("eigenvalues must be sorted in descending order.");
|
||||||
|
}
|
||||||
|
if (!(value > effectiveRoundoffTolerance)) {
|
||||||
|
return unavailableSourceCount(
|
||||||
|
criterion,
|
||||||
|
"noise-eigenvalue-not-strictly-above-roundoff",
|
||||||
|
{
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
roundoffTolerance: effectiveRoundoffTolerance,
|
||||||
|
failingEigenvalueIndex: index,
|
||||||
|
failingEigenvalue: value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores = new Float64Array(sensorCount);
|
||||||
|
let selectedSourceCount = 0;
|
||||||
|
let selectedScore = Infinity;
|
||||||
|
let logRatioRoundoffClampCount = 0;
|
||||||
|
for (let sourceCount = 0; sourceCount < sensorCount; sourceCount += 1) {
|
||||||
|
const noiseCount = sensorCount - sourceCount;
|
||||||
|
let arithmeticSum = 0;
|
||||||
|
let arithmeticCompensation = 0;
|
||||||
|
let logarithmSum = 0;
|
||||||
|
let logarithmCompensation = 0;
|
||||||
|
for (let index = sourceCount; index < sensorCount; index += 1) {
|
||||||
|
[arithmeticSum, arithmeticCompensation] = compensatedAdd(
|
||||||
|
arithmeticSum,
|
||||||
|
arithmeticCompensation,
|
||||||
|
eigenvalues[index],
|
||||||
|
);
|
||||||
|
[logarithmSum, logarithmCompensation] = compensatedAdd(
|
||||||
|
logarithmSum,
|
||||||
|
logarithmCompensation,
|
||||||
|
Math.log(eigenvalues[index]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const arithmeticMean =
|
||||||
|
(arithmeticSum + arithmeticCompensation) / noiseCount;
|
||||||
|
const logarithmicGeometricMean =
|
||||||
|
(logarithmSum + logarithmCompensation) / noiseCount;
|
||||||
|
let logarithmicRatio = Math.log(arithmeticMean) - logarithmicGeometricMean;
|
||||||
|
const ratioRoundoffTolerance =
|
||||||
|
64 * Number.EPSILON * Math.max(1, noiseCount) *
|
||||||
|
Math.max(1, Math.abs(Math.log(arithmeticMean)), Math.abs(logarithmicGeometricMean));
|
||||||
|
if (logarithmicRatio < 0 && logarithmicRatio >= -ratioRoundoffTolerance) {
|
||||||
|
logarithmicRatio = 0;
|
||||||
|
logRatioRoundoffClampCount += 1;
|
||||||
|
}
|
||||||
|
if (!(logarithmicRatio >= 0) || !Number.isFinite(logarithmicRatio)) {
|
||||||
|
throw new RangeError("Wax-Kailath arithmetic/geometric mean ratio is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const likelihoodTerm = snapshotCount * noiseCount * logarithmicRatio;
|
||||||
|
const parameterCount = sourceCount * (2 * sensorCount - sourceCount);
|
||||||
|
const score = criterion === "aic"
|
||||||
|
? 2 * likelihoodTerm + 2 * parameterCount
|
||||||
|
: likelihoodTerm + 0.5 * parameterCount * Math.log(snapshotCount);
|
||||||
|
scores[sourceCount] = score;
|
||||||
|
if (score < selectedScore) {
|
||||||
|
selectedScore = score;
|
||||||
|
selectedSourceCount = sourceCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
criterion,
|
||||||
|
sourceCount: selectedSourceCount,
|
||||||
|
score: selectedScore,
|
||||||
|
scores,
|
||||||
|
reason: null,
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
roundoffTolerance: effectiveRoundoffTolerance,
|
||||||
|
logRatioRoundoffClampCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ const WALLS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const GEOMETRY_EPSILON = 1e-9;
|
const GEOMETRY_EPSILON = 1e-9;
|
||||||
|
export const DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS = 2_000_000;
|
||||||
|
const trustedAcousticPathSummaries = new WeakSet();
|
||||||
|
const acousticPathSummaryCache = new WeakMap();
|
||||||
|
const acousticPathSummaryLru = new Map();
|
||||||
|
const MAXIMUM_ACOUSTIC_PATH_SUMMARY_CACHE_ENTRIES = 8;
|
||||||
|
|
||||||
function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) {
|
function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) {
|
||||||
return position.every(
|
return position.every(
|
||||||
@@ -223,10 +228,152 @@ export function buildAcousticPaths(options) {
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maximumAcousticDelaySeconds(options) {
|
export function maximumAcousticPathsPerReceiver(scene) {
|
||||||
const { scene, arrayDefinition, referenceDistance, minimumDistance } = options;
|
const acoustics = scene?.acoustics;
|
||||||
let maximum = 0;
|
if (!acoustics || acoustics.model === "free-field") return 1;
|
||||||
for (const source of scene.sources) {
|
let count = 1 + (Array.isArray(acoustics.occluders) ? acoustics.occluders.length : 0);
|
||||||
|
if (
|
||||||
|
acoustics.reflections?.order === 1 &&
|
||||||
|
acoustics.room
|
||||||
|
) {
|
||||||
|
count += 6;
|
||||||
|
if (acoustics.reflections.scattering > 0) count += 6;
|
||||||
|
}
|
||||||
|
if (acoustics.diffuseReverb?.enabled) {
|
||||||
|
count += acoustics.diffuseReverb.tapCount;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acousticGeometrySignature(options) {
|
||||||
|
const {
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance = 1,
|
||||||
|
minimumDistance = 0.01,
|
||||||
|
} = options;
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
speedOfSound: scene.speedOfSound,
|
||||||
|
seed: scene.seed ?? 0,
|
||||||
|
acoustics: scene.acoustics ?? null,
|
||||||
|
referenceDistance,
|
||||||
|
minimumDistance,
|
||||||
|
sources: scene.sources.map((source) => ({
|
||||||
|
id: source.id,
|
||||||
|
position: source.position,
|
||||||
|
})),
|
||||||
|
microphones: arrayDefinition.microphones.map((microphone) => ({
|
||||||
|
id: microphone.id,
|
||||||
|
position: microphone.position,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezePathCounts(counts) {
|
||||||
|
return Object.freeze({ ...counts });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrustedAcousticPathSummary(options) {
|
||||||
|
const {
|
||||||
|
signature,
|
||||||
|
sources,
|
||||||
|
microphoneCount,
|
||||||
|
} = options;
|
||||||
|
const pathCounts = {};
|
||||||
|
let pathCount = 0;
|
||||||
|
let maximumDelaySeconds = 0;
|
||||||
|
for (const source of sources) {
|
||||||
|
pathCount += source.pathCount;
|
||||||
|
maximumDelaySeconds = Math.max(maximumDelaySeconds, source.maximumDelaySeconds);
|
||||||
|
for (const [kind, count] of Object.entries(source.pathCounts)) {
|
||||||
|
pathCounts[kind] = (pathCounts[kind] ?? 0) + count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const frozenSources = Object.freeze(sources.map((source) => Object.freeze({
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
pathCount: source.pathCount,
|
||||||
|
pathCounts: freezePathCounts(source.pathCounts),
|
||||||
|
maximumDelaySeconds: source.maximumDelaySeconds,
|
||||||
|
})));
|
||||||
|
const summary = Object.freeze({
|
||||||
|
kind: "micarrsim.acoustic-path-summary/1",
|
||||||
|
signature,
|
||||||
|
sourceCount: frozenSources.length,
|
||||||
|
microphoneCount,
|
||||||
|
sourceReceiverPairCount: frozenSources.length * microphoneCount,
|
||||||
|
pathCount,
|
||||||
|
pathCounts: freezePathCounts(pathCounts),
|
||||||
|
maximumDelaySeconds,
|
||||||
|
sources: frozenSources,
|
||||||
|
});
|
||||||
|
trustedAcousticPathSummaries.add(summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate sparse paths one source/receiver pair at a time and retain only
|
||||||
|
* numeric count/delay metadata. The returned immutable summary is internally
|
||||||
|
* branded so callers cannot forge a stale geometry cache.
|
||||||
|
*/
|
||||||
|
export function summarizeAcousticPaths(options) {
|
||||||
|
const {
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance = 1,
|
||||||
|
minimumDistance = 0.01,
|
||||||
|
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||||
|
} = options;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(maximumPathEvaluations) ||
|
||||||
|
maximumPathEvaluations <= 0
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"maximumPathEvaluations must be a positive safe integer.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const maximumPerReceiver = maximumAcousticPathsPerReceiver(scene);
|
||||||
|
const theoreticalPathEvaluations =
|
||||||
|
scene.sources.length * arrayDefinition.microphones.length * maximumPerReceiver;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(theoreticalPathEvaluations) ||
|
||||||
|
theoreticalPathEvaluations > maximumPathEvaluations
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`acoustic path evaluation upper bound ${theoreticalPathEvaluations} exceeds ` +
|
||||||
|
`maximumPathEvaluations ${maximumPathEvaluations}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const signature = acousticGeometrySignature({
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance,
|
||||||
|
minimumDistance,
|
||||||
|
});
|
||||||
|
const lruSummary = acousticPathSummaryLru.get(signature);
|
||||||
|
if (lruSummary) {
|
||||||
|
acousticPathSummaryLru.delete(signature);
|
||||||
|
acousticPathSummaryLru.set(signature, lruSummary);
|
||||||
|
return lruSummary;
|
||||||
|
}
|
||||||
|
let arrayCache = acousticPathSummaryCache.get(scene);
|
||||||
|
if (!arrayCache) {
|
||||||
|
arrayCache = new WeakMap();
|
||||||
|
acousticPathSummaryCache.set(scene, arrayCache);
|
||||||
|
}
|
||||||
|
let parameterCache = arrayCache.get(arrayDefinition);
|
||||||
|
if (!parameterCache) {
|
||||||
|
parameterCache = new Map();
|
||||||
|
arrayCache.set(arrayDefinition, parameterCache);
|
||||||
|
}
|
||||||
|
const parameterKey = `${referenceDistance}\u0000${minimumDistance}`;
|
||||||
|
const cachedSummary = parameterCache.get(parameterKey);
|
||||||
|
if (cachedSummary?.signature === signature) return cachedSummary;
|
||||||
|
|
||||||
|
const sources = scene.sources.map((source) => {
|
||||||
|
const pathCounts = {};
|
||||||
|
let pathCount = 0;
|
||||||
|
let maximumDelaySeconds = 0;
|
||||||
for (const microphone of arrayDefinition.microphones) {
|
for (const microphone of arrayDefinition.microphones) {
|
||||||
const paths = buildAcousticPaths({
|
const paths = buildAcousticPaths({
|
||||||
scene,
|
scene,
|
||||||
@@ -237,8 +384,122 @@ export function maximumAcousticDelaySeconds(options) {
|
|||||||
referenceDistance,
|
referenceDistance,
|
||||||
minimumDistance,
|
minimumDistance,
|
||||||
});
|
});
|
||||||
for (const path of paths) maximum = Math.max(maximum, path.delaySeconds);
|
for (const path of paths) {
|
||||||
|
if (!Number.isFinite(path.delaySeconds) || path.delaySeconds < 0) {
|
||||||
|
throw new RangeError("acoustic path delay must be finite and non-negative.");
|
||||||
|
}
|
||||||
|
pathCount += 1;
|
||||||
|
pathCounts[path.kind] = (pathCounts[path.kind] ?? 0) + 1;
|
||||||
|
maximumDelaySeconds = Math.max(maximumDelaySeconds, path.delaySeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return maximum;
|
return {
|
||||||
|
sourceId: source.id,
|
||||||
|
pathCount,
|
||||||
|
pathCounts,
|
||||||
|
maximumDelaySeconds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const summary = createTrustedAcousticPathSummary({
|
||||||
|
signature,
|
||||||
|
sources,
|
||||||
|
microphoneCount: arrayDefinition.microphones.length,
|
||||||
|
});
|
||||||
|
parameterCache.set(parameterKey, summary);
|
||||||
|
acousticPathSummaryLru.set(signature, summary);
|
||||||
|
while (
|
||||||
|
acousticPathSummaryLru.size >
|
||||||
|
MAXIMUM_ACOUSTIC_PATH_SUMMARY_CACHE_ENTRIES
|
||||||
|
) {
|
||||||
|
acousticPathSummaryLru.delete(acousticPathSummaryLru.keys().next().value);
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAcousticPathSummaryCompatible(summary, options) {
|
||||||
|
return (
|
||||||
|
trustedAcousticPathSummaries.has(summary) &&
|
||||||
|
summary.signature === acousticGeometrySignature(options) &&
|
||||||
|
summary.sourceCount === options.scene.sources.length &&
|
||||||
|
summary.microphoneCount === options.arrayDefinition.microphones.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retarget an authenticated full-scene summary to the source subset retained
|
||||||
|
* by a quasi-static render window. Only immutable numeric metadata is shared.
|
||||||
|
*/
|
||||||
|
export function selectAcousticPathSummary(options) {
|
||||||
|
const {
|
||||||
|
summary,
|
||||||
|
sourceIndexes,
|
||||||
|
sourceScene,
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance = 1,
|
||||||
|
minimumDistance = 0.01,
|
||||||
|
} = options;
|
||||||
|
if (!trustedAcousticPathSummaries.has(summary)) {
|
||||||
|
throw new TypeError("summary must be created by summarizeAcousticPaths.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!sourceScene ||
|
||||||
|
!isAcousticPathSummaryCompatible(summary, {
|
||||||
|
scene: sourceScene,
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance,
|
||||||
|
minimumDistance,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"source acoustic path summary does not match its original geometry.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(sourceIndexes) ||
|
||||||
|
sourceIndexes.length !== scene.sources.length
|
||||||
|
) {
|
||||||
|
throw new RangeError("sourceIndexes must correspond exactly to the selected scene sources.");
|
||||||
|
}
|
||||||
|
const sources = sourceIndexes.map((sourceIndex, selectedIndex) => {
|
||||||
|
const sourceSummary = summary.sources[sourceIndex];
|
||||||
|
if (
|
||||||
|
!sourceSummary ||
|
||||||
|
sourceSummary.sourceId !== scene.sources[selectedIndex].id
|
||||||
|
) {
|
||||||
|
throw new RangeError("selected acoustic path summary does not match scene source order.");
|
||||||
|
}
|
||||||
|
return sourceSummary;
|
||||||
|
});
|
||||||
|
const expectedSelectedSignature = acousticGeometrySignature({
|
||||||
|
scene: {
|
||||||
|
speedOfSound: sourceScene.speedOfSound,
|
||||||
|
seed: sourceScene.seed,
|
||||||
|
acoustics: sourceScene.acoustics,
|
||||||
|
sources: sourceIndexes.map((sourceIndex) => sourceScene.sources[sourceIndex]),
|
||||||
|
},
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance,
|
||||||
|
minimumDistance,
|
||||||
|
});
|
||||||
|
const selectedSignature = acousticGeometrySignature({
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance,
|
||||||
|
minimumDistance,
|
||||||
|
});
|
||||||
|
if (selectedSignature !== expectedSelectedSignature) {
|
||||||
|
throw new RangeError(
|
||||||
|
"selected scene path geometry does not match the summarized source geometry.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return createTrustedAcousticPathSummary({
|
||||||
|
signature: selectedSignature,
|
||||||
|
sources,
|
||||||
|
microphoneCount: arrayDefinition.microphones.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maximumAcousticDelaySeconds(options) {
|
||||||
|
return summarizeAcousticPaths(options).maximumDelaySeconds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export const RESULT_METRIC_KEYS = Object.freeze([
|
|||||||
"recall",
|
"recall",
|
||||||
"mainLobeWidthM",
|
"mainLobeWidthM",
|
||||||
]);
|
]);
|
||||||
|
export const PEAK_DETECTION_METHODS = Object.freeze([
|
||||||
|
"local-max",
|
||||||
|
"connected-components",
|
||||||
|
"watershed",
|
||||||
|
]);
|
||||||
|
|
||||||
const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS);
|
const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS);
|
||||||
const DEFAULT_MAXIMUM_DETECTIONS = 128;
|
const DEFAULT_MAXIMUM_DETECTIONS = 128;
|
||||||
@@ -53,9 +58,10 @@ function normalizeRawMap(rawMap, scan) {
|
|||||||
if (
|
if (
|
||||||
descriptor?.scale !== undefined &&
|
descriptor?.scale !== undefined &&
|
||||||
descriptor.scale !== "power" &&
|
descriptor.scale !== "power" &&
|
||||||
descriptor.scale !== "score"
|
descriptor.scale !== "score" &&
|
||||||
|
descriptor.scale !== "noise-projection"
|
||||||
) {
|
) {
|
||||||
throw new RangeError("rawMap scale must be power or score.");
|
throw new RangeError("rawMap scale must be power, score, or noise-projection.");
|
||||||
}
|
}
|
||||||
if (descriptor?.rows !== undefined && descriptor.rows !== scan.rows) {
|
if (descriptor?.rows !== undefined && descriptor.rows !== scan.rows) {
|
||||||
throw new RangeError("rawMap rows do not match scan.rows.");
|
throw new RangeError("rawMap rows do not match scan.rows.");
|
||||||
@@ -80,6 +86,86 @@ function rawMapScale(rawMap) {
|
|||||||
return "power";
|
return "power";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rawMapObjectiveDirection(rawMap, mapScale) {
|
||||||
|
const descriptor =
|
||||||
|
rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap)
|
||||||
|
? rawMap
|
||||||
|
: null;
|
||||||
|
const direction =
|
||||||
|
descriptor?.objectiveDirection ??
|
||||||
|
(mapScale === "noise-projection" ? "minimum" : "maximum");
|
||||||
|
if (direction !== "maximum" && direction !== "minimum") {
|
||||||
|
throw new RangeError('rawMap objectiveDirection must be "maximum" or "minimum".');
|
||||||
|
}
|
||||||
|
if (mapScale === "noise-projection" && direction !== "minimum") {
|
||||||
|
throw new RangeError("noise-projection maps must use objectiveDirection minimum.");
|
||||||
|
}
|
||||||
|
if (mapScale !== "noise-projection" && direction !== "maximum") {
|
||||||
|
throw new RangeError("power and score maps must use objectiveDirection maximum.");
|
||||||
|
}
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizedMusicPseudospectrum(values) {
|
||||||
|
if (!Array.isArray(values) && !ArrayBuffer.isView(values)) {
|
||||||
|
throw new TypeError("noise projection values must be an array or typed array.");
|
||||||
|
}
|
||||||
|
if (values.length === 0) {
|
||||||
|
throw new RangeError("noise projection values must not be empty.");
|
||||||
|
}
|
||||||
|
let rawMinimum = Number.POSITIVE_INFINITY;
|
||||||
|
let rawMaximum = Number.NEGATIVE_INFINITY;
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
const value = requireNonNegative(
|
||||||
|
`noiseProjection[${index}]`,
|
||||||
|
values[index],
|
||||||
|
);
|
||||||
|
rawMinimum = Math.min(rawMinimum, value);
|
||||||
|
rawMaximum = Math.max(rawMaximum, value);
|
||||||
|
}
|
||||||
|
const pseudospectrum = new Float64Array(values.length);
|
||||||
|
if (rawMinimum === rawMaximum) {
|
||||||
|
pseudospectrum.fill(1);
|
||||||
|
return {
|
||||||
|
values: pseudospectrum,
|
||||||
|
transform: "normalized-reciprocal-noise-projection",
|
||||||
|
rawMinimum,
|
||||||
|
flat: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (rawMinimum === 0) {
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
pseudospectrum[index] = values[index] === 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
pseudospectrum[index] = rawMinimum / values[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
values: pseudospectrum,
|
||||||
|
transform: "normalized-reciprocal-noise-projection",
|
||||||
|
rawMinimum,
|
||||||
|
flat: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectiveMerit(values, direction) {
|
||||||
|
if (direction === "maximum") {
|
||||||
|
return { values, transform: "identity", rawMinimum: null, flat: false };
|
||||||
|
}
|
||||||
|
return normalizedMusicPseudospectrum(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreObjectiveValue(candidate, rawValues) {
|
||||||
|
if (!candidate) return null;
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
value: rawValues[candidate.index],
|
||||||
|
meritValue: candidate.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSources(sources) {
|
function normalizeSources(sources) {
|
||||||
if (!Array.isArray(sources)) throw new TypeError("sources must be an array.");
|
if (!Array.isArray(sources)) throw new TypeError("sources must be an array.");
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
@@ -114,6 +200,214 @@ function comparePeaks(a, b) {
|
|||||||
return b.value - a.value || a.index - b.index;
|
return b.value - a.value || a.index - b.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireDetectionMethod(value = "local-max") {
|
||||||
|
if (!PEAK_DETECTION_METHODS.includes(value)) {
|
||||||
|
throw new RangeError(
|
||||||
|
"detectionMethod must be local-max, connected-components, or watershed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forEachNeighbor(index, scan, callback) {
|
||||||
|
const row = Math.floor(index / scan.columns);
|
||||||
|
const column = index % scan.columns;
|
||||||
|
for (
|
||||||
|
let neighborRow = Math.max(0, row - 1);
|
||||||
|
neighborRow <= Math.min(scan.rows - 1, row + 1);
|
||||||
|
neighborRow += 1
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let neighborColumn = Math.max(0, column - 1);
|
||||||
|
neighborColumn <= Math.min(scan.columns - 1, column + 1);
|
||||||
|
neighborColumn += 1
|
||||||
|
) {
|
||||||
|
if (neighborRow === row && neighborColumn === column) continue;
|
||||||
|
callback(neighborRow * scan.columns + neighborColumn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function centroidCandidate(values, scan, indices, peakIndex, extra = {}) {
|
||||||
|
let totalWeight = 0;
|
||||||
|
let weightedX = 0;
|
||||||
|
let weightedY = 0;
|
||||||
|
for (const index of indices) {
|
||||||
|
const weight = values[index];
|
||||||
|
const position = scanIndexToPosition(index, scan);
|
||||||
|
totalWeight += weight;
|
||||||
|
weightedX += position[0] * weight;
|
||||||
|
weightedY += position[1] * weight;
|
||||||
|
}
|
||||||
|
const fallback = scanIndexToPosition(peakIndex, scan);
|
||||||
|
return {
|
||||||
|
index: peakIndex,
|
||||||
|
position: totalWeight > 0
|
||||||
|
? [weightedX / totalWeight, weightedY / totalWeight, scan.z]
|
||||||
|
: fallback,
|
||||||
|
value: values[peakIndex],
|
||||||
|
clusterPixelCount: indices.length,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findConnectedComponentCandidates(values, scan, thresholdValue) {
|
||||||
|
const visited = new Uint8Array(values.length);
|
||||||
|
const queue = new Int32Array(values.length);
|
||||||
|
const candidates = [];
|
||||||
|
const active = (index) => values[index] > 0 && values[index] >= thresholdValue;
|
||||||
|
for (let startIndex = 0; startIndex < values.length; startIndex += 1) {
|
||||||
|
if (visited[startIndex] || !active(startIndex)) continue;
|
||||||
|
let queueStart = 0;
|
||||||
|
let queueEnd = 1;
|
||||||
|
let peakIndex = startIndex;
|
||||||
|
queue[0] = startIndex;
|
||||||
|
visited[startIndex] = 1;
|
||||||
|
while (queueStart < queueEnd) {
|
||||||
|
const index = queue[queueStart];
|
||||||
|
queueStart += 1;
|
||||||
|
if (
|
||||||
|
values[index] > values[peakIndex] ||
|
||||||
|
(values[index] === values[peakIndex] && index < peakIndex)
|
||||||
|
) {
|
||||||
|
peakIndex = index;
|
||||||
|
}
|
||||||
|
forEachNeighbor(index, scan, (neighborIndex) => {
|
||||||
|
if (visited[neighborIndex] || !active(neighborIndex)) return;
|
||||||
|
visited[neighborIndex] = 1;
|
||||||
|
queue[queueEnd] = neighborIndex;
|
||||||
|
queueEnd += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
candidates.push(
|
||||||
|
centroidCandidate(
|
||||||
|
values,
|
||||||
|
scan,
|
||||||
|
queue.subarray(0, queueEnd),
|
||||||
|
peakIndex,
|
||||||
|
{ clusterMethod: "connected-components" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
candidates.sort(comparePeaks);
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function watershedEntryHigher(left, right) {
|
||||||
|
return (
|
||||||
|
left.priority > right.priority ||
|
||||||
|
(
|
||||||
|
left.priority === right.priority &&
|
||||||
|
(
|
||||||
|
left.seedOrder < right.seedOrder ||
|
||||||
|
(left.seedOrder === right.seedOrder && left.index < right.index)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushWatershedEntry(heap, entry) {
|
||||||
|
heap.push(entry);
|
||||||
|
let index = heap.length - 1;
|
||||||
|
while (index > 0) {
|
||||||
|
const parent = Math.floor((index - 1) / 2);
|
||||||
|
if (!watershedEntryHigher(heap[index], heap[parent])) break;
|
||||||
|
[heap[index], heap[parent]] = [heap[parent], heap[index]];
|
||||||
|
index = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function popWatershedEntry(heap) {
|
||||||
|
const first = heap[0];
|
||||||
|
const last = heap.pop();
|
||||||
|
if (heap.length === 0) return first;
|
||||||
|
heap[0] = last;
|
||||||
|
let index = 0;
|
||||||
|
while (true) {
|
||||||
|
const left = index * 2 + 1;
|
||||||
|
const right = left + 1;
|
||||||
|
let best = index;
|
||||||
|
if (left < heap.length && watershedEntryHigher(heap[left], heap[best])) best = left;
|
||||||
|
if (right < heap.length && watershedEntryHigher(heap[right], heap[best])) best = right;
|
||||||
|
if (best === index) break;
|
||||||
|
[heap[index], heap[best]] = [heap[best], heap[index]];
|
||||||
|
index = best;
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findWatershedCandidates(values, scan, thresholdValue, localPeaks) {
|
||||||
|
const seeds = localPeaks.filter((peak) => peak.value >= thresholdValue);
|
||||||
|
if (seeds.length === 0) return [];
|
||||||
|
const active = (index) => values[index] > 0 && values[index] >= thresholdValue;
|
||||||
|
const assignments = new Int32Array(values.length);
|
||||||
|
assignments.fill(-1);
|
||||||
|
const priorities = new Float64Array(values.length);
|
||||||
|
priorities.fill(Number.NEGATIVE_INFINITY);
|
||||||
|
const heap = [];
|
||||||
|
seeds.forEach((seed, seedOrder) => {
|
||||||
|
assignments[seed.index] = seedOrder;
|
||||||
|
priorities[seed.index] = seed.value;
|
||||||
|
pushWatershedEntry(heap, {
|
||||||
|
index: seed.index,
|
||||||
|
priority: seed.value,
|
||||||
|
seedOrder,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
while (heap.length > 0) {
|
||||||
|
const current = popWatershedEntry(heap);
|
||||||
|
if (
|
||||||
|
priorities[current.index] !== current.priority ||
|
||||||
|
assignments[current.index] !== current.seedOrder
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
forEachNeighbor(current.index, scan, (neighborIndex) => {
|
||||||
|
if (!active(neighborIndex)) return;
|
||||||
|
const nextPriority = Math.min(current.priority, values[neighborIndex]);
|
||||||
|
const previousPriority = priorities[neighborIndex];
|
||||||
|
const previousSeed = assignments[neighborIndex];
|
||||||
|
if (
|
||||||
|
nextPriority < previousPriority ||
|
||||||
|
(nextPriority === previousPriority && previousSeed >= 0 && previousSeed <= current.seedOrder)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
priorities[neighborIndex] = nextPriority;
|
||||||
|
assignments[neighborIndex] = current.seedOrder;
|
||||||
|
pushWatershedEntry(heap, {
|
||||||
|
index: neighborIndex,
|
||||||
|
priority: nextPriority,
|
||||||
|
seedOrder: current.seedOrder,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const basinIndices = seeds.map(() => []);
|
||||||
|
assignments.forEach((seedOrder, index) => {
|
||||||
|
if (seedOrder >= 0) basinIndices[seedOrder].push(index);
|
||||||
|
});
|
||||||
|
const candidates = seeds.map((seed, seedOrder) =>
|
||||||
|
centroidCandidate(
|
||||||
|
values,
|
||||||
|
scan,
|
||||||
|
basinIndices[seedOrder],
|
||||||
|
seed.index,
|
||||||
|
{ clusterMethod: "watershed", basin: seedOrder + 1 },
|
||||||
|
));
|
||||||
|
candidates.sort(comparePeaks);
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectionCandidates(values, scan, localPeaks, thresholdValue, detectionMethod) {
|
||||||
|
if (detectionMethod === "local-max") return localPeaks;
|
||||||
|
if (detectionMethod === "connected-components") {
|
||||||
|
return findConnectedComponentCandidates(values, scan, thresholdValue);
|
||||||
|
}
|
||||||
|
return findWatershedCandidates(values, scan, thresholdValue, localPeaks);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return positive local maxima with each equal-valued 8-connected plateau
|
* Return positive local maxima with each equal-valued 8-connected plateau
|
||||||
* represented by its lowest row-major index. A positive flat map falls back
|
* represented by its lowest row-major index. A positive flat map falls back
|
||||||
@@ -529,8 +823,11 @@ export function measureBeamSpread(rawMap, scan, peakIndex, halfPowerRatio = 0.5)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate one nonnegative spatial power or score map. Source z coordinates
|
* Evaluate one nonnegative spatial map. Noise-projection minima are detected
|
||||||
* are projected onto scan.z, matching the image-plane ground-truth convention.
|
* through the standard MUSIC reciprocal pseudospectrum normalized as
|
||||||
|
* P/Pmax=qmin/q, while exported peak values remain the unmodified q values.
|
||||||
|
* Source z coordinates are projected onto scan.z, matching the image-plane
|
||||||
|
* ground-truth convention.
|
||||||
*/
|
*/
|
||||||
export function evaluateLocalizationBand(options) {
|
export function evaluateLocalizationBand(options) {
|
||||||
if (!options || typeof options !== "object") throw new TypeError("evaluation options are required.");
|
if (!options || typeof options !== "object") throw new TypeError("evaluation options are required.");
|
||||||
@@ -538,9 +835,20 @@ export function evaluateLocalizationBand(options) {
|
|||||||
validateScan(scan);
|
validateScan(scan);
|
||||||
const values = normalizeRawMap(rawMap, scan);
|
const values = normalizeRawMap(rawMap, scan);
|
||||||
const mapScale = options.mapScale ?? rawMapScale(rawMap);
|
const mapScale = options.mapScale ?? rawMapScale(rawMap);
|
||||||
if (mapScale !== "power" && mapScale !== "score") {
|
if (mapScale !== "power" && mapScale !== "score" && mapScale !== "noise-projection") {
|
||||||
throw new RangeError("mapScale must be power or score.");
|
throw new RangeError("mapScale must be power, score, or noise-projection.");
|
||||||
}
|
}
|
||||||
|
const objectiveDirection =
|
||||||
|
options.objectiveDirection ?? rawMapObjectiveDirection(rawMap, mapScale);
|
||||||
|
if (
|
||||||
|
(mapScale === "noise-projection" && objectiveDirection !== "minimum") ||
|
||||||
|
(mapScale !== "noise-projection" && objectiveDirection !== "maximum")
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"noise-projection uses a minimum objective; power and score use a maximum objective.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const merit = objectiveMerit(values, objectiveDirection);
|
||||||
const sources = normalizeSources(options.sources ?? options.scene?.sources ?? []);
|
const sources = normalizeSources(options.sources ?? options.scene?.sources ?? []);
|
||||||
const truths = sources.map((source) => ({
|
const truths = sources.map((source) => ({
|
||||||
sourceId: source.id,
|
sourceId: source.id,
|
||||||
@@ -557,6 +865,7 @@ export function evaluateLocalizationBand(options) {
|
|||||||
"detectionThresholdRatio",
|
"detectionThresholdRatio",
|
||||||
options.detectionThresholdRatio ?? 0.5,
|
options.detectionThresholdRatio ?? 0.5,
|
||||||
);
|
);
|
||||||
|
const detectionMethod = requireDetectionMethod(options.detectionMethod ?? "local-max");
|
||||||
const minimumPeakDistanceM = requireNonNegative(
|
const minimumPeakDistanceM = requireNonNegative(
|
||||||
"minimumPeakDistanceM",
|
"minimumPeakDistanceM",
|
||||||
options.minimumPeakDistanceM ?? resolution.diagonal,
|
options.minimumPeakDistanceM ?? resolution.diagonal,
|
||||||
@@ -575,12 +884,32 @@ export function evaluateLocalizationBand(options) {
|
|||||||
throw new RangeError("maximumDetections must be an integer from 1 through 128.");
|
throw new RangeError("maximumDetections must be an integer from 1 through 128.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const localPeaks = findLocalPeaks(values, scan);
|
const localMeritPeaks = merit.flat
|
||||||
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
|
? []
|
||||||
const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio;
|
: findLocalPeaks(merit.values, scan);
|
||||||
const detections = selectDetections(localPeaks, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
|
const globalMeritPeak = localMeritPeaks[0] ?? null;
|
||||||
(detection) => ({
|
const thresholdValue = (globalMeritPeak?.value ?? 0) * detectionThresholdRatio;
|
||||||
...detection,
|
const meritCandidates = detectionCandidates(
|
||||||
|
merit.values,
|
||||||
|
scan,
|
||||||
|
localMeritPeaks,
|
||||||
|
thresholdValue,
|
||||||
|
detectionMethod,
|
||||||
|
);
|
||||||
|
const selectedMeritDetections = selectDetections(
|
||||||
|
meritCandidates,
|
||||||
|
thresholdValue,
|
||||||
|
minimumPeakDistanceM,
|
||||||
|
maximumDetections,
|
||||||
|
);
|
||||||
|
const localPeaks = localMeritPeaks.map((peak) =>
|
||||||
|
restoreObjectiveValue(peak, values));
|
||||||
|
const globalPeak = restoreObjectiveValue(globalMeritPeak, values);
|
||||||
|
const detections = selectedMeritDetections.map((detection) => {
|
||||||
|
const restored = restoreObjectiveValue(detection, values);
|
||||||
|
if (objectiveDirection === "minimum") return restored;
|
||||||
|
return {
|
||||||
|
...restored,
|
||||||
beamSpread: measureBeamSpreadFromValues(
|
beamSpread: measureBeamSpreadFromValues(
|
||||||
values,
|
values,
|
||||||
scan,
|
scan,
|
||||||
@@ -588,12 +917,36 @@ export function evaluateLocalizationBand(options) {
|
|||||||
halfPowerRatio,
|
halfPowerRatio,
|
||||||
mapScale,
|
mapScale,
|
||||||
),
|
),
|
||||||
}),
|
};
|
||||||
);
|
});
|
||||||
const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
|
const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
|
||||||
const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0;
|
const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0;
|
||||||
const recall = truths.length > 0 ? truePositiveCount / truths.length : 1;
|
const recall = truths.length > 0 ? truePositiveCount / truths.length : 1;
|
||||||
const mainLobe = globalPeak ? measureMainLobe(values, scan, globalPeak.index, halfPowerRatio) : null;
|
const mainLobe =
|
||||||
|
objectiveDirection === "maximum" && globalPeak
|
||||||
|
? measureMainLobe(values, scan, globalPeak.index, halfPowerRatio)
|
||||||
|
: null;
|
||||||
|
const metrics = { precision, recall };
|
||||||
|
if (objectiveDirection === "maximum") {
|
||||||
|
Object.assign(metrics, {
|
||||||
|
peakToSidelobeDb: peakToSidelobeDb(
|
||||||
|
values,
|
||||||
|
scan,
|
||||||
|
truths,
|
||||||
|
globalPeak,
|
||||||
|
sidelobeExclusionRadiusM,
|
||||||
|
dynamicRangeDb,
|
||||||
|
),
|
||||||
|
energyInsideTruthRatio: energyInsideRatio(
|
||||||
|
values,
|
||||||
|
scan,
|
||||||
|
truths,
|
||||||
|
truthRadiusM,
|
||||||
|
globalPeak?.value ?? 0,
|
||||||
|
),
|
||||||
|
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
globalPeak,
|
globalPeak,
|
||||||
@@ -612,28 +965,25 @@ export function evaluateLocalizationBand(options) {
|
|||||||
matchToleranceM,
|
matchToleranceM,
|
||||||
detectionThresholdRatio,
|
detectionThresholdRatio,
|
||||||
detectionThresholdValue: thresholdValue,
|
detectionThresholdValue: thresholdValue,
|
||||||
|
detectionThresholdDomain:
|
||||||
|
objectiveDirection === "minimum"
|
||||||
|
? "normalized-music-pseudospectrum"
|
||||||
|
: "raw-map-value",
|
||||||
|
detectionMethod,
|
||||||
minimumPeakDistanceM,
|
minimumPeakDistanceM,
|
||||||
sidelobeExclusionRadiusM,
|
sidelobeExclusionRadiusM,
|
||||||
halfPowerRatio,
|
halfPowerRatio,
|
||||||
dynamicRangeDb,
|
dynamicRangeDb,
|
||||||
maximumDetections,
|
maximumDetections,
|
||||||
},
|
},
|
||||||
metrics: {
|
metrics,
|
||||||
peakToSidelobeDb: peakToSidelobeDb(
|
|
||||||
values,
|
|
||||||
scan,
|
|
||||||
truths,
|
|
||||||
globalPeak,
|
|
||||||
sidelobeExclusionRadiusM,
|
|
||||||
dynamicRangeDb,
|
|
||||||
),
|
|
||||||
energyInsideTruthRatio: energyInsideRatio(values, scan, truths, truthRadiusM, globalPeak?.value ?? 0),
|
|
||||||
precision,
|
|
||||||
recall,
|
|
||||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
|
||||||
},
|
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
mapScale,
|
mapScale,
|
||||||
|
objectiveDirection,
|
||||||
|
detectionTransform: merit.transform,
|
||||||
|
...(merit.rawMinimum === null ? {} : { rawMinimum: merit.rawMinimum }),
|
||||||
|
flatObjectiveMap: merit.flat,
|
||||||
|
detectionCandidateCount: meritCandidates.length,
|
||||||
mainLobe,
|
mainLobe,
|
||||||
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
|
||||||
gridResolutionM: resolution,
|
gridResolutionM: resolution,
|
||||||
@@ -647,12 +997,14 @@ export function evaluateDasBand(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Select only metric names accepted by result.schema.json. */
|
/** Select only metric names accepted by result.schema.json. */
|
||||||
export function selectResultMetrics(evaluationOrMetrics, metricNames = RESULT_METRIC_KEYS) {
|
export function selectResultMetrics(evaluationOrMetrics, metricNames) {
|
||||||
const metrics = evaluationOrMetrics?.metrics ?? evaluationOrMetrics;
|
const metrics = evaluationOrMetrics?.metrics ?? evaluationOrMetrics;
|
||||||
if (!metrics || typeof metrics !== "object") throw new TypeError("evaluation metrics are required.");
|
if (!metrics || typeof metrics !== "object") throw new TypeError("evaluation metrics are required.");
|
||||||
if (!Array.isArray(metricNames)) throw new TypeError("metricNames must be an array.");
|
const selectedMetricNames =
|
||||||
|
metricNames ?? RESULT_METRIC_KEYS.filter((metricName) => metricName in metrics);
|
||||||
|
if (!Array.isArray(selectedMetricNames)) throw new TypeError("metricNames must be an array.");
|
||||||
const selected = {};
|
const selected = {};
|
||||||
for (const metricName of metricNames) {
|
for (const metricName of selectedMetricNames) {
|
||||||
if (!RESULT_METRIC_KEY_SET.has(metricName)) {
|
if (!RESULT_METRIC_KEY_SET.has(metricName)) {
|
||||||
throw new RangeError(`result.schema.json does not allow metric: ${metricName}`);
|
throw new RangeError(`result.schema.json does not allow metric: ${metricName}`);
|
||||||
}
|
}
|
||||||
@@ -704,13 +1056,17 @@ export function exportBandEvaluation(evaluation, options = {}) {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
peaks,
|
peaks,
|
||||||
metrics: selectResultMetrics(evaluation, options.metricNames ?? RESULT_METRIC_KEYS),
|
metrics: selectResultMetrics(evaluation, options.metricNames),
|
||||||
evaluation: {
|
evaluation: {
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
truthRadiusM: evaluation.parameters.truthRadiusM,
|
truthRadiusM: evaluation.parameters.truthRadiusM,
|
||||||
matchToleranceM: evaluation.parameters.matchToleranceM,
|
matchToleranceM: evaluation.parameters.matchToleranceM,
|
||||||
detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
|
detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
|
||||||
detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
|
detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
|
||||||
|
objectiveDirection: evaluation.diagnostics?.objectiveDirection ?? "maximum",
|
||||||
|
detectionThresholdDomain:
|
||||||
|
evaluation.parameters.detectionThresholdDomain ?? "raw-map-value",
|
||||||
|
detectionMethod: evaluation.parameters.detectionMethod ?? "local-max",
|
||||||
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
|
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
|
||||||
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
|
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
|
||||||
halfPowerRatio: evaluation.parameters.halfPowerRatio,
|
halfPowerRatio: evaluation.parameters.halfPowerRatio,
|
||||||
|
|||||||
@@ -32,19 +32,36 @@ export function addFrequencyDomainPaths(options) {
|
|||||||
frequencyResponse,
|
frequencyResponse,
|
||||||
sampleRate,
|
sampleRate,
|
||||||
workspace,
|
workspace,
|
||||||
|
outputOffsetSamples = 0,
|
||||||
|
pathDelaySampleRate,
|
||||||
} = options;
|
} = options;
|
||||||
if (!Array.isArray(paths) || paths.length === 0) {
|
if (!Array.isArray(paths) || paths.length === 0) {
|
||||||
throw new Error("frequency-domain path가 하나 이상 필요합니다.");
|
throw new Error("frequency-domain path가 하나 이상 필요합니다.");
|
||||||
}
|
}
|
||||||
|
const delaySamplesForPath = (path) => {
|
||||||
|
if (path.delaySamples !== undefined) return path.delaySamples;
|
||||||
|
if (
|
||||||
|
path.delaySeconds !== undefined &&
|
||||||
|
pathDelaySampleRate > 0 &&
|
||||||
|
Number.isFinite(pathDelaySampleRate)
|
||||||
|
) {
|
||||||
|
return path.delaySeconds * pathDelaySampleRate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
if (!(path.delaySamples >= 0) || !Number.isFinite(path.delaySamples)) {
|
const delaySamples = delaySamplesForPath(path);
|
||||||
throw new Error("유효하지 않은 delay: " + path.delaySamples);
|
if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) {
|
||||||
|
throw new Error("유효하지 않은 delay: " + delaySamples);
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다.");
|
if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다.");
|
||||||
}
|
}
|
||||||
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
|
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
|
||||||
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
|
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
|
||||||
}
|
}
|
||||||
|
if (!Number.isSafeInteger(outputOffsetSamples) || outputOffsetSamples < 0) {
|
||||||
|
throw new RangeError("outputOffsetSamples must be a non-negative safe integer.");
|
||||||
|
}
|
||||||
const hasFrequencyResponse = Array.isArray(frequencyResponse) && frequencyResponse.length > 0;
|
const hasFrequencyResponse = Array.isArray(frequencyResponse) && frequencyResponse.length > 0;
|
||||||
if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) {
|
if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) {
|
||||||
throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다.");
|
throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다.");
|
||||||
@@ -58,7 +75,7 @@ export function addFrequencyDomainPaths(options) {
|
|||||||
const halfSize = size / 2;
|
const halfSize = size / 2;
|
||||||
const phaseReanchorInterval = 1024;
|
const phaseReanchorInterval = 1024;
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
const phaseStep = (-TAU * path.delaySamples) / size;
|
const phaseStep = (-TAU * delaySamplesForPath(path)) / size;
|
||||||
const stepReal = Math.cos(phaseStep);
|
const stepReal = Math.cos(phaseStep);
|
||||||
const stepImaginary = Math.sin(phaseStep);
|
const stepImaginary = Math.sin(phaseStep);
|
||||||
let phaseReal = 1;
|
let phaseReal = 1;
|
||||||
@@ -118,9 +135,12 @@ export function addFrequencyDomainPaths(options) {
|
|||||||
// bin back to the real axis preserves Hermitian symmetry for fractional delays.
|
// bin back to the real axis preserves Hermitian symmetry for fractional delays.
|
||||||
workspace.imaginary[size / 2] = 0;
|
workspace.imaginary[size / 2] = 0;
|
||||||
fftInPlace(workspace.real, workspace.imaginary, true);
|
fftInPlace(workspace.real, workspace.imaginary, true);
|
||||||
const outputLength = Math.min(output.length, workspace.real.length);
|
const outputLength = Math.min(
|
||||||
|
output.length,
|
||||||
|
Math.max(0, workspace.real.length - outputOffsetSamples),
|
||||||
|
);
|
||||||
for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
|
for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
|
||||||
output[sampleIndex] += workspace.real[sampleIndex];
|
output[sampleIndex] += workspace.real[outputOffsetSamples + sampleIndex];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT;
|
||||||
|
|
||||||
|
export const DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS = 32;
|
||||||
|
export const DEFAULT_MAX_HERMITIAN_EIGEN_WORK_BYTES = 16 * 1024 * 1024;
|
||||||
|
export const DEFAULT_MAX_HERMITIAN_JACOBI_PAIR_UPDATES = 1_000_000_000;
|
||||||
|
|
||||||
|
function assertPositiveInteger(value, label) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 1) {
|
||||||
|
throw new TypeError(`${label} must be a positive safe integer.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMaximumSweeps(value) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < 0 ||
|
||||||
|
value > DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`maximumSweeps must be an integer from 0 through ${DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedProduct(factors, label) {
|
||||||
|
let product = 1;
|
||||||
|
for (const factor of factors) {
|
||||||
|
if (factor !== 0 && product > Number.MAX_SAFE_INTEGER / factor) {
|
||||||
|
throw new RangeError(`${label} exceeds the safe integer range.`);
|
||||||
|
}
|
||||||
|
product *= factor;
|
||||||
|
}
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundoffGamma(operationCount) {
|
||||||
|
const product = operationCount * (Number.EPSILON / 2);
|
||||||
|
if (!(product < 1)) {
|
||||||
|
throw new RangeError("roundoff bound is undefined for this workload.");
|
||||||
|
}
|
||||||
|
return product / (1 - product);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the bounded typed-array allocation and cyclic-Jacobi work before
|
||||||
|
* any decomposition workspace is allocated.
|
||||||
|
*/
|
||||||
|
export function estimateHermitianEigenWorkload({
|
||||||
|
order,
|
||||||
|
maximumSweeps = DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS,
|
||||||
|
}) {
|
||||||
|
assertPositiveInteger(order, "order");
|
||||||
|
assertMaximumSweeps(maximumSweeps);
|
||||||
|
|
||||||
|
const matrixElementCount = checkedProduct([order, order], "matrix element count");
|
||||||
|
const pairCount = checkedProduct([order, order - 1], "Jacobi pair count") / 2;
|
||||||
|
const worstCaseRotations = checkedProduct(
|
||||||
|
[pairCount, maximumSweeps],
|
||||||
|
"Jacobi rotation count",
|
||||||
|
);
|
||||||
|
// A rotation updates n-2 matrix row pairs and n eigenvector row pairs.
|
||||||
|
const pairUpdatesPerRotation = Math.max(0, 2 * order - 2);
|
||||||
|
const worstCasePairUpdates = checkedProduct(
|
||||||
|
[worstCaseRotations, pairUpdatesPerRotation],
|
||||||
|
"Jacobi pair-update count",
|
||||||
|
);
|
||||||
|
// Two split-complex matrices plus normalized and physical eigenvalue arrays.
|
||||||
|
const typedArrayElementCount = checkedProduct(
|
||||||
|
[4 * matrixElementCount + 2 * order, FLOAT64_BYTES],
|
||||||
|
"Hermitian eigensolver allocation",
|
||||||
|
) / FLOAT64_BYTES;
|
||||||
|
const allocationBytes = checkedProduct(
|
||||||
|
[typedArrayElementCount, FLOAT64_BYTES],
|
||||||
|
"Hermitian eigensolver allocation",
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
order,
|
||||||
|
maximumSweeps,
|
||||||
|
matrixElementCount,
|
||||||
|
pairCount,
|
||||||
|
pairUpdatesPerRotation,
|
||||||
|
worstCaseRotations,
|
||||||
|
worstCasePairUpdates,
|
||||||
|
allocationBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferOrder(real, imaginary, explicitOrder) {
|
||||||
|
if (real == null || imaginary == null) {
|
||||||
|
throw new TypeError("real and imaginary matrix components are required.");
|
||||||
|
}
|
||||||
|
const order = explicitOrder ?? Math.sqrt(real.length);
|
||||||
|
assertPositiveInteger(order, "order");
|
||||||
|
const expectedLength = checkedProduct([order, order], "matrix element count");
|
||||||
|
if (real.length !== expectedLength || imaginary.length !== expectedLength) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Hermitian matrix components must each contain ${expectedLength} values.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matrixFrobeniusNorm(real, imaginary, order) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let index = 0; index < order * order; index += 1) {
|
||||||
|
sum += real[index] * real[index] + imaginary[index] * imaginary[index];
|
||||||
|
}
|
||||||
|
return Math.sqrt(sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function offDiagonalFrobeniusNorm(real, imaginary, order) {
|
||||||
|
let upperTriangleSum = 0;
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
for (let column = row + 1; column < order; column += 1) {
|
||||||
|
const index = row * order + column;
|
||||||
|
upperTriangleSum +=
|
||||||
|
real[index] * real[index] + imaginary[index] * imaginary[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.sqrt(2 * upperTriangleSum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotatePair(matrixReal, matrixImaginary, vectorsReal, vectorsImaginary, order, p, q) {
|
||||||
|
const pq = p * order + q;
|
||||||
|
const offReal = matrixReal[pq];
|
||||||
|
const offImaginary = matrixImaginary[pq];
|
||||||
|
const magnitude = Math.hypot(offReal, offImaginary);
|
||||||
|
if (magnitude === 0) return false;
|
||||||
|
|
||||||
|
const diagonalP = matrixReal[p * order + p];
|
||||||
|
const diagonalQ = matrixReal[q * order + q];
|
||||||
|
const halfDifference = (diagonalQ - diagonalP) / 2;
|
||||||
|
const tangent = halfDifference === 0
|
||||||
|
? 1
|
||||||
|
: Math.sign(halfDifference) * magnitude /
|
||||||
|
(Math.abs(halfDifference) + Math.hypot(halfDifference, magnitude));
|
||||||
|
const cosine = 1 / Math.hypot(1, tangent);
|
||||||
|
const sine = tangent * cosine;
|
||||||
|
const phaseReal = offReal / magnitude;
|
||||||
|
const phaseImaginary = offImaginary / magnitude;
|
||||||
|
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
if (row === p || row === q) continue;
|
||||||
|
const rowP = row * order + p;
|
||||||
|
const rowQ = row * order + q;
|
||||||
|
const pReal = matrixReal[rowP];
|
||||||
|
const pImaginary = matrixImaginary[rowP];
|
||||||
|
const qReal = matrixReal[rowQ];
|
||||||
|
const qImaginary = matrixImaginary[rowQ];
|
||||||
|
|
||||||
|
const conjugatePhaseTimesQReal =
|
||||||
|
phaseReal * qReal + phaseImaginary * qImaginary;
|
||||||
|
const conjugatePhaseTimesQImaginary =
|
||||||
|
phaseReal * qImaginary - phaseImaginary * qReal;
|
||||||
|
const phaseTimesPReal = phaseReal * pReal - phaseImaginary * pImaginary;
|
||||||
|
const phaseTimesPImaginary = phaseReal * pImaginary + phaseImaginary * pReal;
|
||||||
|
|
||||||
|
const nextPReal = cosine * pReal - sine * conjugatePhaseTimesQReal;
|
||||||
|
const nextPImaginary = cosine * pImaginary - sine * conjugatePhaseTimesQImaginary;
|
||||||
|
const nextQReal = sine * phaseTimesPReal + cosine * qReal;
|
||||||
|
const nextQImaginary = sine * phaseTimesPImaginary + cosine * qImaginary;
|
||||||
|
|
||||||
|
matrixReal[rowP] = nextPReal;
|
||||||
|
matrixImaginary[rowP] = nextPImaginary;
|
||||||
|
matrixReal[p * order + row] = nextPReal;
|
||||||
|
matrixImaginary[p * order + row] = -nextPImaginary;
|
||||||
|
matrixReal[rowQ] = nextQReal;
|
||||||
|
matrixImaginary[rowQ] = nextQImaginary;
|
||||||
|
matrixReal[q * order + row] = nextQReal;
|
||||||
|
matrixImaginary[q * order + row] = -nextQImaginary;
|
||||||
|
}
|
||||||
|
|
||||||
|
matrixReal[p * order + p] = diagonalP - tangent * magnitude;
|
||||||
|
matrixImaginary[p * order + p] = 0;
|
||||||
|
matrixReal[q * order + q] = diagonalQ + tangent * magnitude;
|
||||||
|
matrixImaginary[q * order + q] = 0;
|
||||||
|
matrixReal[pq] = 0;
|
||||||
|
matrixImaginary[pq] = 0;
|
||||||
|
matrixReal[q * order + p] = 0;
|
||||||
|
matrixImaginary[q * order + p] = 0;
|
||||||
|
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
const rowP = row * order + p;
|
||||||
|
const rowQ = row * order + q;
|
||||||
|
const pReal = vectorsReal[rowP];
|
||||||
|
const pImaginary = vectorsImaginary[rowP];
|
||||||
|
const qReal = vectorsReal[rowQ];
|
||||||
|
const qImaginary = vectorsImaginary[rowQ];
|
||||||
|
const conjugatePhaseTimesQReal =
|
||||||
|
phaseReal * qReal + phaseImaginary * qImaginary;
|
||||||
|
const conjugatePhaseTimesQImaginary =
|
||||||
|
phaseReal * qImaginary - phaseImaginary * qReal;
|
||||||
|
const phaseTimesPReal = phaseReal * pReal - phaseImaginary * pImaginary;
|
||||||
|
const phaseTimesPImaginary = phaseReal * pImaginary + phaseImaginary * pReal;
|
||||||
|
vectorsReal[rowP] = cosine * pReal - sine * conjugatePhaseTimesQReal;
|
||||||
|
vectorsImaginary[rowP] = cosine * pImaginary - sine * conjugatePhaseTimesQImaginary;
|
||||||
|
vectorsReal[rowQ] = sine * phaseTimesPReal + cosine * qReal;
|
||||||
|
vectorsImaginary[rowQ] = sine * phaseTimesPImaginary + cosine * qImaginary;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeColumnPhases(real, imaginary, order) {
|
||||||
|
for (let column = 0; column < order; column += 1) {
|
||||||
|
let pivotRow = 0;
|
||||||
|
let pivotMagnitude = -1;
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
const index = row * order + column;
|
||||||
|
const magnitude = Math.hypot(real[index], imaginary[index]);
|
||||||
|
if (magnitude > pivotMagnitude) {
|
||||||
|
pivotMagnitude = magnitude;
|
||||||
|
pivotRow = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pivotIndex = pivotRow * order + column;
|
||||||
|
const pivotReal = real[pivotIndex];
|
||||||
|
const pivotImaginary = imaginary[pivotIndex];
|
||||||
|
const normalizedPivotMagnitude = Math.hypot(pivotReal, pivotImaginary);
|
||||||
|
if (!(normalizedPivotMagnitude > 0) || !Number.isFinite(normalizedPivotMagnitude)) {
|
||||||
|
throw new RangeError("Jacobi eigenvector phase canonicalization failed.");
|
||||||
|
}
|
||||||
|
const phaseReal = pivotReal / normalizedPivotMagnitude;
|
||||||
|
const phaseImaginary = -pivotImaginary / normalizedPivotMagnitude;
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
const index = row * order + column;
|
||||||
|
const valueReal = real[index];
|
||||||
|
const valueImaginary = imaginary[index];
|
||||||
|
real[index] = valueReal * phaseReal - valueImaginary * phaseImaginary;
|
||||||
|
imaginary[index] = valueReal * phaseImaginary + valueImaginary * phaseReal;
|
||||||
|
}
|
||||||
|
real[pivotIndex] = normalizedPivotMagnitude;
|
||||||
|
imaginary[pivotIndex] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedInputEntry(real, imaginary, order, row, column, scale) {
|
||||||
|
if (row === column) return [real[row * order + row] / scale, 0];
|
||||||
|
const forward = row * order + column;
|
||||||
|
const reverse = column * order + row;
|
||||||
|
return [
|
||||||
|
(real[forward] + real[reverse]) / (2 * scale),
|
||||||
|
(imaginary[forward] - imaginary[reverse]) / (2 * scale),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function residualFrobeniusNorm(
|
||||||
|
inputReal,
|
||||||
|
inputImaginary,
|
||||||
|
vectorsReal,
|
||||||
|
vectorsImaginary,
|
||||||
|
normalizedEigenvalues,
|
||||||
|
order,
|
||||||
|
scale,
|
||||||
|
) {
|
||||||
|
if (scale === 0) return 0;
|
||||||
|
let residualSquared = 0;
|
||||||
|
for (let column = 0; column < order; column += 1) {
|
||||||
|
const eigenvalue = normalizedEigenvalues[column];
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
let productReal = 0;
|
||||||
|
let productImaginary = 0;
|
||||||
|
for (let inner = 0; inner < order; inner += 1) {
|
||||||
|
const [matrixReal, matrixImaginary] = projectedInputEntry(
|
||||||
|
inputReal,
|
||||||
|
inputImaginary,
|
||||||
|
order,
|
||||||
|
row,
|
||||||
|
inner,
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
const vectorIndex = inner * order + column;
|
||||||
|
const vectorReal = vectorsReal[vectorIndex];
|
||||||
|
const vectorImaginary = vectorsImaginary[vectorIndex];
|
||||||
|
productReal += matrixReal * vectorReal - matrixImaginary * vectorImaginary;
|
||||||
|
productImaginary += matrixReal * vectorImaginary + matrixImaginary * vectorReal;
|
||||||
|
}
|
||||||
|
const vectorIndex = row * order + column;
|
||||||
|
const differenceReal = productReal - eigenvalue * vectorsReal[vectorIndex];
|
||||||
|
const differenceImaginary =
|
||||||
|
productImaginary - eigenvalue * vectorsImaginary[vectorIndex];
|
||||||
|
residualSquared +=
|
||||||
|
differenceReal * differenceReal + differenceImaginary * differenceImaginary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.sqrt(residualSquared);
|
||||||
|
}
|
||||||
|
|
||||||
|
function orthogonalityError(real, imaginary, order) {
|
||||||
|
let errorSquared = 0;
|
||||||
|
for (let left = 0; left < order; left += 1) {
|
||||||
|
for (let right = 0; right < order; right += 1) {
|
||||||
|
let productReal = 0;
|
||||||
|
let productImaginary = 0;
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
const leftIndex = row * order + left;
|
||||||
|
const rightIndex = row * order + right;
|
||||||
|
productReal +=
|
||||||
|
real[leftIndex] * real[rightIndex] +
|
||||||
|
imaginary[leftIndex] * imaginary[rightIndex];
|
||||||
|
productImaginary +=
|
||||||
|
real[leftIndex] * imaginary[rightIndex] -
|
||||||
|
imaginary[leftIndex] * real[rightIndex];
|
||||||
|
}
|
||||||
|
if (left === right) productReal -= 1;
|
||||||
|
errorSquared += productReal * productReal + productImaginary * productImaginary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.sqrt(errorSquared);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic full cyclic Jacobi decomposition of a complex Hermitian
|
||||||
|
* matrix. Split-complex matrices are row-major; eigenvectors are returned as
|
||||||
|
* row-major columns, so component `row` of eigenvector `column` is at
|
||||||
|
* `row * order + column`.
|
||||||
|
*
|
||||||
|
* The input is projected to its nearest Hermitian matrix only after its
|
||||||
|
* anti-Hermitian discrepancy has passed the reported roundoff-scale audit.
|
||||||
|
* No diagonal loading or eigenvalue flooring is performed.
|
||||||
|
*/
|
||||||
|
export function decomposeHermitian({
|
||||||
|
real,
|
||||||
|
imaginary,
|
||||||
|
order: explicitOrder,
|
||||||
|
maximumSweeps = DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS,
|
||||||
|
relativeTolerance,
|
||||||
|
maximumWorkBytes = DEFAULT_MAX_HERMITIAN_EIGEN_WORK_BYTES,
|
||||||
|
maximumPairUpdates = DEFAULT_MAX_HERMITIAN_JACOBI_PAIR_UPDATES,
|
||||||
|
}) {
|
||||||
|
const order = inferOrder(real, imaginary, explicitOrder);
|
||||||
|
const workload = estimateHermitianEigenWorkload({ order, maximumSweeps });
|
||||||
|
if (!(maximumWorkBytes >= 0) || !Number.isFinite(maximumWorkBytes)) {
|
||||||
|
throw new TypeError("maximumWorkBytes must be finite and nonnegative.");
|
||||||
|
}
|
||||||
|
if (!(maximumPairUpdates >= 0) || !Number.isFinite(maximumPairUpdates)) {
|
||||||
|
throw new TypeError("maximumPairUpdates must be finite and nonnegative.");
|
||||||
|
}
|
||||||
|
if (workload.allocationBytes > maximumWorkBytes) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Hermitian eigensolver requires ${workload.allocationBytes} typed-array bytes; limit is ${maximumWorkBytes}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (workload.worstCasePairUpdates > maximumPairUpdates) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Hermitian eigensolver worst-case work is ${workload.worstCasePairUpdates} pair updates; limit is ${maximumPairUpdates}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = 0;
|
||||||
|
for (let index = 0; index < workload.matrixElementCount; index += 1) {
|
||||||
|
const valueReal = real[index];
|
||||||
|
const valueImaginary = imaginary[index];
|
||||||
|
if (!Number.isFinite(valueReal) || !Number.isFinite(valueImaginary)) {
|
||||||
|
throw new TypeError("Hermitian matrix components must be finite.");
|
||||||
|
}
|
||||||
|
// Component scaling avoids overflow in hypot() for otherwise finite
|
||||||
|
// split-complex entries near Number.MAX_VALUE.
|
||||||
|
scale = Math.max(scale, Math.abs(valueReal), Math.abs(valueImaginary));
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputHermitianTolerance =
|
||||||
|
scale === 0 ? 0 : 32 * Number.EPSILON * Math.max(1, order) * scale;
|
||||||
|
let inputHermitianError = 0;
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
inputHermitianError = Math.max(
|
||||||
|
inputHermitianError,
|
||||||
|
Math.abs(imaginary[row * order + row]),
|
||||||
|
);
|
||||||
|
for (let column = row + 1; column < order; column += 1) {
|
||||||
|
const forward = row * order + column;
|
||||||
|
const reverse = column * order + row;
|
||||||
|
inputHermitianError = Math.max(
|
||||||
|
inputHermitianError,
|
||||||
|
Math.hypot(
|
||||||
|
real[forward] - real[reverse],
|
||||||
|
imaginary[forward] + imaginary[reverse],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inputHermitianError > inputHermitianTolerance) {
|
||||||
|
throw new RangeError(
|
||||||
|
`matrix is not Hermitian within roundoff: error ${inputHermitianError}, tolerance ${inputHermitianTolerance}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matrixReal = new Float64Array(workload.matrixElementCount);
|
||||||
|
const matrixImaginary = new Float64Array(workload.matrixElementCount);
|
||||||
|
const vectorsReal = new Float64Array(workload.matrixElementCount);
|
||||||
|
const vectorsImaginary = new Float64Array(workload.matrixElementCount);
|
||||||
|
const normalizedEigenvalues = new Float64Array(order);
|
||||||
|
const eigenvalues = new Float64Array(order);
|
||||||
|
|
||||||
|
if (scale > 0) {
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
const diagonal = row * order + row;
|
||||||
|
matrixReal[diagonal] = real[diagonal] / scale;
|
||||||
|
for (let column = row + 1; column < order; column += 1) {
|
||||||
|
const forward = row * order + column;
|
||||||
|
const reverse = column * order + row;
|
||||||
|
const projectedReal = (real[forward] + real[reverse]) / (2 * scale);
|
||||||
|
const projectedImaginary =
|
||||||
|
(imaginary[forward] - imaginary[reverse]) / (2 * scale);
|
||||||
|
matrixReal[forward] = projectedReal;
|
||||||
|
matrixImaginary[forward] = projectedImaginary;
|
||||||
|
matrixReal[reverse] = projectedReal;
|
||||||
|
matrixImaginary[reverse] = -projectedImaginary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let index = 0; index < order; index += 1) {
|
||||||
|
vectorsReal[index * order + index] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matrixNormNormalized = matrixFrobeniusNorm(
|
||||||
|
matrixReal,
|
||||||
|
matrixImaginary,
|
||||||
|
order,
|
||||||
|
);
|
||||||
|
const effectiveRelativeTolerance = relativeTolerance ?? roundoffGamma(32 * order);
|
||||||
|
if (!(effectiveRelativeTolerance > 0) || !Number.isFinite(effectiveRelativeTolerance)) {
|
||||||
|
throw new TypeError("relativeTolerance must be finite and positive.");
|
||||||
|
}
|
||||||
|
const absoluteTolerance = effectiveRelativeTolerance * matrixNormNormalized;
|
||||||
|
let offDiagonalNormalized = offDiagonalFrobeniusNorm(
|
||||||
|
matrixReal,
|
||||||
|
matrixImaginary,
|
||||||
|
order,
|
||||||
|
);
|
||||||
|
let converged = offDiagonalNormalized <= absoluteTolerance;
|
||||||
|
let sweeps = 0;
|
||||||
|
let rotations = 0;
|
||||||
|
const pairTolerance = workload.pairCount === 0
|
||||||
|
? 0
|
||||||
|
: absoluteTolerance / Math.sqrt(2 * workload.pairCount);
|
||||||
|
|
||||||
|
while (!converged && sweeps < maximumSweeps) {
|
||||||
|
let sweepRotations = 0;
|
||||||
|
for (let p = 0; p < order; p += 1) {
|
||||||
|
for (let q = p + 1; q < order; q += 1) {
|
||||||
|
const pq = p * order + q;
|
||||||
|
if (Math.hypot(matrixReal[pq], matrixImaginary[pq]) <= pairTolerance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rotatePair(
|
||||||
|
matrixReal,
|
||||||
|
matrixImaginary,
|
||||||
|
vectorsReal,
|
||||||
|
vectorsImaginary,
|
||||||
|
order,
|
||||||
|
p,
|
||||||
|
q,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
sweepRotations += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rotations += sweepRotations;
|
||||||
|
sweeps += 1;
|
||||||
|
offDiagonalNormalized = offDiagonalFrobeniusNorm(
|
||||||
|
matrixReal,
|
||||||
|
matrixImaginary,
|
||||||
|
order,
|
||||||
|
);
|
||||||
|
converged = offDiagonalNormalized <= absoluteTolerance;
|
||||||
|
if (sweepRotations === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stableOrder = Array.from({ length: order }, (_, index) => ({
|
||||||
|
index,
|
||||||
|
value: matrixReal[index * order + index],
|
||||||
|
}));
|
||||||
|
stableOrder.sort((left, right) => {
|
||||||
|
if (left.value > right.value) return -1;
|
||||||
|
if (left.value < right.value) return 1;
|
||||||
|
return left.index - right.index;
|
||||||
|
});
|
||||||
|
for (let column = 0; column < order; column += 1) {
|
||||||
|
const sourceColumn = stableOrder[column].index;
|
||||||
|
const normalizedEigenvalue = stableOrder[column].value;
|
||||||
|
normalizedEigenvalues[column] = Object.is(normalizedEigenvalue, -0)
|
||||||
|
? 0
|
||||||
|
: normalizedEigenvalue;
|
||||||
|
const physicalEigenvalue = normalizedEigenvalues[column] * scale;
|
||||||
|
if (!Number.isFinite(physicalEigenvalue)) {
|
||||||
|
throw new RangeError("Hermitian eigenvalue exceeds the finite Float64 range.");
|
||||||
|
}
|
||||||
|
eigenvalues[column] = Object.is(physicalEigenvalue, -0) ? 0 : physicalEigenvalue;
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
const destination = row * order + column;
|
||||||
|
const source = row * order + sourceColumn;
|
||||||
|
matrixReal[destination] = vectorsReal[source];
|
||||||
|
matrixImaginary[destination] = vectorsImaginary[source];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A product of unitary Jacobi rotations already has unit-norm columns.
|
||||||
|
// Canonicalize phase only; do not hide orthogonality drift by renormalizing.
|
||||||
|
canonicalizeColumnPhases(matrixReal, matrixImaginary, order);
|
||||||
|
|
||||||
|
const residualNormalized = residualFrobeniusNorm(
|
||||||
|
real,
|
||||||
|
imaginary,
|
||||||
|
matrixReal,
|
||||||
|
matrixImaginary,
|
||||||
|
normalizedEigenvalues,
|
||||||
|
order,
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
const relativeResidual = matrixNormNormalized === 0
|
||||||
|
? 0
|
||||||
|
: residualNormalized / matrixNormNormalized;
|
||||||
|
const relativeOffDiagonal = matrixNormNormalized === 0
|
||||||
|
? 0
|
||||||
|
: offDiagonalNormalized / matrixNormNormalized;
|
||||||
|
const residualTolerance = roundoffGamma(128 * order);
|
||||||
|
const orthogonalityTolerance = residualTolerance * Math.sqrt(order);
|
||||||
|
const measuredOrthogonalityError = orthogonalityError(
|
||||||
|
matrixReal,
|
||||||
|
matrixImaginary,
|
||||||
|
order,
|
||||||
|
);
|
||||||
|
const numericallyVerified =
|
||||||
|
converged &&
|
||||||
|
relativeResidual <= residualTolerance &&
|
||||||
|
measuredOrthogonalityError <= orthogonalityTolerance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
order,
|
||||||
|
eigenvalues,
|
||||||
|
eigenvectors: {
|
||||||
|
real: matrixReal,
|
||||||
|
imaginary: matrixImaginary,
|
||||||
|
layout: "row-major-columns",
|
||||||
|
},
|
||||||
|
converged,
|
||||||
|
sweeps,
|
||||||
|
rotations,
|
||||||
|
scale,
|
||||||
|
inputHermitianError,
|
||||||
|
inputHermitianTolerance,
|
||||||
|
relativeTolerance: effectiveRelativeTolerance,
|
||||||
|
offDiagonalFrobeniusNorm: offDiagonalNormalized * scale,
|
||||||
|
relativeOffDiagonal,
|
||||||
|
residualFrobeniusNorm: residualNormalized * scale,
|
||||||
|
relativeResidual,
|
||||||
|
residualTolerance,
|
||||||
|
orthogonalityError: measuredOrthogonalityError,
|
||||||
|
orthogonalityTolerance,
|
||||||
|
numericallyVerified,
|
||||||
|
workload,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export * from "./activity-monitor.js";
|
||||||
|
export * from "./beam-pattern.js";
|
||||||
|
export * from "./cfar.js";
|
||||||
export * from "./das.js";
|
export * from "./das.js";
|
||||||
export * from "./evaluation.js";
|
export * from "./evaluation.js";
|
||||||
export * from "./environment.js";
|
export * from "./environment.js";
|
||||||
@@ -5,14 +8,18 @@ export * from "./fft.js";
|
|||||||
export * from "./frequency-response.js";
|
export * from "./frequency-response.js";
|
||||||
export * from "./fractional-delay.js";
|
export * from "./fractional-delay.js";
|
||||||
export * from "./gcc-phat.js";
|
export * from "./gcc-phat.js";
|
||||||
|
export * from "./hermitian-eigen.js";
|
||||||
export * from "./localization.js";
|
export * from "./localization.js";
|
||||||
export * from "./localization-algorithms.js";
|
export * from "./localization-algorithms.js";
|
||||||
export * from "./math.js";
|
export * from "./math.js";
|
||||||
export * from "./mixdown.js";
|
export * from "./mixdown.js";
|
||||||
|
export * from "./music.js";
|
||||||
export * from "./noise.js";
|
export * from "./noise.js";
|
||||||
|
export * from "./covariance.js";
|
||||||
export * from "./propagation.js";
|
export * from "./propagation.js";
|
||||||
export * from "./realtime-localizer.js";
|
export * from "./realtime-localizer.js";
|
||||||
export * from "./signal.js";
|
export * from "./signal.js";
|
||||||
|
export * from "./spectral-flux.js";
|
||||||
export * from "./stft.js";
|
export * from "./stft.js";
|
||||||
export * from "./trajectory.js";
|
export * from "./trajectory.js";
|
||||||
export * from "./wav.js";
|
export * from "./wav.js";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const ALGORITHM_DEFINITIONS = Object.freeze([
|
|||||||
definition("das", "DAS", "power", "none", ["cpu", "webgpu"]),
|
definition("das", "DAS", "power", "none", ["cpu", "webgpu"]),
|
||||||
definition("das-phat", "DAS-PHAT", "score", "phat", ["cpu", "webgpu"]),
|
definition("das-phat", "DAS-PHAT", "score", "phat", ["cpu", "webgpu"]),
|
||||||
definition("gcc-phat", "GCC-PHAT", "score", "phat", ["cpu"]),
|
definition("gcc-phat", "GCC-PHAT", "score", "phat", ["cpu"]),
|
||||||
|
definition("music", "MUSIC", "noise-projection", "subspace", ["cpu"]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALGORITHM_BY_ID = new Map(
|
const ALGORITHM_BY_ID = new Map(
|
||||||
@@ -43,6 +44,10 @@ export function isDasAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
|||||||
return id === "das" || id === "das-phat";
|
return id === "das" || id === "das-phat";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMusicAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||||
|
return id === "music";
|
||||||
|
}
|
||||||
|
|
||||||
export function localizationAlgorithmLabel(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
export function localizationAlgorithmLabel(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||||
return localizationAlgorithmDefinition(id)?.label ?? String(id);
|
return localizationAlgorithmDefinition(id)?.label ?? String(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
createGccPhatAnalysisPlan,
|
createGccPhatAnalysisPlan,
|
||||||
estimateGccPhatWorkload,
|
estimateGccPhatWorkload,
|
||||||
} from "./gcc-phat.js";
|
} from "./gcc-phat.js";
|
||||||
|
import {
|
||||||
|
computeMusicMap,
|
||||||
|
createMusicAnalysisPlan,
|
||||||
|
estimateMusicWorkload,
|
||||||
|
} from "./music.js";
|
||||||
import {
|
import {
|
||||||
isDasAlgorithm,
|
isDasAlgorithm,
|
||||||
requireLocalizationAlgorithmDefinition,
|
requireLocalizationAlgorithmDefinition,
|
||||||
@@ -27,6 +32,7 @@ export function estimateLocalizationWorkload(options) {
|
|||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(options);
|
if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(options);
|
||||||
if (algorithm.id === "gcc-phat") return estimateGccPhatWorkload(options);
|
if (algorithm.id === "gcc-phat") return estimateGccPhatWorkload(options);
|
||||||
|
if (algorithm.id === "music") return estimateMusicWorkload(options);
|
||||||
throw new RangeError("No workload estimator is registered for " + algorithm.id + ".");
|
throw new RangeError("No workload estimator is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ export function createLocalizationAnalysisPlan(options) {
|
|||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(options);
|
if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(options);
|
||||||
if (algorithm.id === "gcc-phat") return createGccPhatAnalysisPlan(options);
|
if (algorithm.id === "gcc-phat") return createGccPhatAnalysisPlan(options);
|
||||||
|
if (algorithm.id === "music") return createMusicAnalysisPlan(options);
|
||||||
throw new RangeError("No analysis-plan factory is registered for " + algorithm.id + ".");
|
throw new RangeError("No analysis-plan factory is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,5 +56,6 @@ export async function computeLocalizationMap(options) {
|
|||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
if (isDasAlgorithm(algorithm.id)) return computeDasMap(options);
|
if (isDasAlgorithm(algorithm.id)) return computeDasMap(options);
|
||||||
if (algorithm.id === "gcc-phat") return computeGccPhatMap(options);
|
if (algorithm.id === "gcc-phat") return computeGccPhatMap(options);
|
||||||
|
if (algorithm.id === "music") return computeMusicMap(options);
|
||||||
throw new RangeError("No CPU localization implementation is registered for " + algorithm.id + ".");
|
throw new RangeError("No CPU localization implementation is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
export const TAU = Math.PI * 2;
|
export const TAU = Math.PI * 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceil a sample position while keeping an analytically integral boundary on
|
||||||
|
* that integer. IEEE-754 roundoff from expressions such as (0.1 + 0.2) * 1000
|
||||||
|
* must not add a physically nonexistent sample of latency.
|
||||||
|
*/
|
||||||
|
export function ceilSamplePosition(samplePosition) {
|
||||||
|
if (!Number.isFinite(samplePosition)) {
|
||||||
|
throw new TypeError("sample position must be finite.");
|
||||||
|
}
|
||||||
|
const nearestInteger = Math.round(samplePosition);
|
||||||
|
const tolerance =
|
||||||
|
16 * Number.EPSILON * Math.max(1, Math.abs(samplePosition));
|
||||||
|
const result = Math.abs(samplePosition - nearestInteger) <= tolerance
|
||||||
|
? nearestInteger
|
||||||
|
: Math.ceil(samplePosition);
|
||||||
|
if (!Number.isSafeInteger(result)) {
|
||||||
|
throw new RangeError("sample position exceeds the safe integer range.");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ceilSampleIndex(timeSeconds, sampleRate) {
|
||||||
|
if (!Number.isFinite(timeSeconds)) throw new TypeError("time must be finite.");
|
||||||
|
if (!(sampleRate > 0) || !Number.isFinite(sampleRate)) {
|
||||||
|
throw new TypeError("sampleRate must be finite and positive.");
|
||||||
|
}
|
||||||
|
return ceilSamplePosition(timeSeconds * sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
export function distance3(a, b) {
|
export function distance3(a, b) {
|
||||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,11 @@
|
|||||||
import { dbToLinear, distance3 } from "./math.js";
|
import { interpolateFrequencyResponse } from "./frequency-response.js";
|
||||||
|
import {
|
||||||
|
ceilSampleIndex,
|
||||||
|
ceilSamplePosition,
|
||||||
|
dbToLinear,
|
||||||
|
distance3,
|
||||||
|
TAU,
|
||||||
|
} from "./math.js";
|
||||||
import {
|
import {
|
||||||
addFrequencyDomainDelay,
|
addFrequencyDomainDelay,
|
||||||
addFrequencyDomainPaths,
|
addFrequencyDomainPaths,
|
||||||
@@ -6,8 +13,17 @@ import {
|
|||||||
nextPowerOfTwo,
|
nextPowerOfTwo,
|
||||||
prepareDelaySpectrum,
|
prepareDelaySpectrum,
|
||||||
} from "./fractional-delay.js";
|
} from "./fractional-delay.js";
|
||||||
import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js";
|
import {
|
||||||
import { renderSignal } from "./signal.js";
|
buildAcousticPaths,
|
||||||
|
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||||
|
isAcousticPathSummaryCompatible,
|
||||||
|
summarizeAcousticPaths,
|
||||||
|
} from "./environment.js";
|
||||||
|
import {
|
||||||
|
effectiveSignalDurationSeconds,
|
||||||
|
renderSignal,
|
||||||
|
} from "./signal.js";
|
||||||
|
import { describeFrequencyAliasing } from "./sampling.js";
|
||||||
|
|
||||||
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||||
return distance3(sourcePosition, receiverPosition) / speedOfSound;
|
return distance3(sourcePosition, receiverPosition) / speedOfSound;
|
||||||
@@ -17,6 +33,441 @@ export function sphericalAttenuation(distance, referenceDistance = 1, minimumDis
|
|||||||
return referenceDistance / Math.max(distance, minimumDistance);
|
return referenceDistance / Math.max(distance, minimumDistance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addToneEvent(
|
||||||
|
events,
|
||||||
|
sampleIndex,
|
||||||
|
sineDelta,
|
||||||
|
cosineDelta,
|
||||||
|
activePathDelta,
|
||||||
|
) {
|
||||||
|
const event = events.get(sampleIndex);
|
||||||
|
if (event) {
|
||||||
|
event.sine += sineDelta;
|
||||||
|
event.cosine += cosineDelta;
|
||||||
|
event.activePaths += activePathDelta;
|
||||||
|
} else {
|
||||||
|
events.set(sampleIndex, {
|
||||||
|
sine: sineDelta,
|
||||||
|
cosine: cosineDelta,
|
||||||
|
activePaths: activePathDelta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function carrierAtSample(signedFrequencyHz, sampleRate, absoluteSampleIndex) {
|
||||||
|
const cycles = (signedFrequencyHz * absoluteSampleIndex) / sampleRate;
|
||||||
|
const fractionalCycles = cycles - Math.floor(cycles);
|
||||||
|
const phase = TAU * fractionalCycles;
|
||||||
|
return { cosine: Math.cos(phase), sine: Math.sin(phase) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply propagation and microphone response to an analog tone before sampling.
|
||||||
|
*
|
||||||
|
* This order is essential above Nyquist: temporal samples fold to the ADC
|
||||||
|
* alias, while inter-microphone phase still comes from the original acoustic
|
||||||
|
* frequency and path delay.
|
||||||
|
*/
|
||||||
|
function addSampledAnalogTonePaths(options) {
|
||||||
|
const {
|
||||||
|
output,
|
||||||
|
entry,
|
||||||
|
scene,
|
||||||
|
microphone,
|
||||||
|
paths,
|
||||||
|
timeMapping = {},
|
||||||
|
outputSceneSampleOffset,
|
||||||
|
outputRenderSampleOffset = 0,
|
||||||
|
} = options;
|
||||||
|
const signal = entry.signal;
|
||||||
|
const frequencyHz = signal.frequency;
|
||||||
|
const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate);
|
||||||
|
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
||||||
|
const outputSampleOffset = outputSceneSampleOffset ?? sceneSampleOffset;
|
||||||
|
const signalStartTime = timeMapping.signalStartTime ?? entry.startTime ?? 0;
|
||||||
|
const signalDuration = timeMapping.signalDuration ?? entry.duration ?? scene.duration;
|
||||||
|
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||||
|
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(outputSampleOffset) || outputSampleOffset < 0) {
|
||||||
|
throw new RangeError("outputSceneSampleOffset must be a non-negative safe integer.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(outputRenderSampleOffset) ||
|
||||||
|
outputRenderSampleOffset < 0
|
||||||
|
) {
|
||||||
|
throw new RangeError("outputRenderSampleOffset must be a non-negative safe integer.");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(signalStartTime)) {
|
||||||
|
throw new TypeError("signalStartTime must be finite.");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(signalDuration) || signalDuration <= 0) {
|
||||||
|
throw new RangeError("signalDuration must be a positive finite number.");
|
||||||
|
}
|
||||||
|
const sourcePhase = ((signal.phaseDeg ?? 0) * Math.PI) / 180;
|
||||||
|
const response = interpolateFrequencyResponse(
|
||||||
|
microphone.frequencyResponse,
|
||||||
|
frequencyHz,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const microphonePhase =
|
||||||
|
((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180 + response.phaseRadians;
|
||||||
|
const baseGain =
|
||||||
|
dbToLinear(entry.gainDb ?? 0) *
|
||||||
|
dbToLinear(microphone.sensitivityDb ?? 0) *
|
||||||
|
response.linearGain;
|
||||||
|
const events = new Map();
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const firstSample = Math.max(
|
||||||
|
0,
|
||||||
|
ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) -
|
||||||
|
outputSampleOffset,
|
||||||
|
);
|
||||||
|
const lastSample = Math.min(
|
||||||
|
output.length,
|
||||||
|
ceilSampleIndex(
|
||||||
|
signalStartTime + signalDuration + path.delaySeconds,
|
||||||
|
scene.sampleRate,
|
||||||
|
) - outputSampleOffset,
|
||||||
|
);
|
||||||
|
if (firstSample >= lastSample) continue;
|
||||||
|
const amplitude = baseGain * path.gain;
|
||||||
|
const delayedCycles = (frequencyHz * (signalStartTime + path.delaySeconds)) % 1;
|
||||||
|
const phase = sourcePhase + microphonePhase - TAU * delayedCycles;
|
||||||
|
const sineCoefficient = amplitude * Math.cos(phase);
|
||||||
|
const cosineCoefficient = amplitude * Math.sin(phase);
|
||||||
|
addToneEvent(events, firstSample, sineCoefficient, cosineCoefficient, 1);
|
||||||
|
addToneEvent(events, lastSample, -sineCoefficient, -cosineCoefficient, -1);
|
||||||
|
}
|
||||||
|
if (events.size === 0) return;
|
||||||
|
|
||||||
|
const orderedEvents = [...events.entries()].sort((left, right) => left[0] - right[0]);
|
||||||
|
const phaseStep =
|
||||||
|
(TAU * sampling.signedAdcFrequencyHz) / scene.sampleRate;
|
||||||
|
const stepReal = Math.cos(phaseStep);
|
||||||
|
const stepImaginary = Math.sin(phaseStep);
|
||||||
|
const firstSample = orderedEvents[0][0];
|
||||||
|
const finalEventSample = orderedEvents.at(-1)[0];
|
||||||
|
const initialCarrier = carrierAtSample(
|
||||||
|
sampling.signedAdcFrequencyHz,
|
||||||
|
scene.sampleRate,
|
||||||
|
outputSampleOffset + firstSample,
|
||||||
|
);
|
||||||
|
let carrierCosine = initialCarrier.cosine;
|
||||||
|
let carrierSine = initialCarrier.sine;
|
||||||
|
let sineCoefficient = 0;
|
||||||
|
let cosineCoefficient = 0;
|
||||||
|
let activePathCount = 0;
|
||||||
|
let eventIndex = 0;
|
||||||
|
const phaseReanchorInterval = 1024;
|
||||||
|
let sampleIndex = firstSample;
|
||||||
|
while (sampleIndex < output.length && sampleIndex < finalEventSample) {
|
||||||
|
while (
|
||||||
|
eventIndex < orderedEvents.length &&
|
||||||
|
orderedEvents[eventIndex][0] === sampleIndex
|
||||||
|
) {
|
||||||
|
sineCoefficient += orderedEvents[eventIndex][1].sine;
|
||||||
|
cosineCoefficient += orderedEvents[eventIndex][1].cosine;
|
||||||
|
activePathCount += orderedEvents[eventIndex][1].activePaths;
|
||||||
|
eventIndex += 1;
|
||||||
|
}
|
||||||
|
if (activePathCount === 0 && eventIndex < orderedEvents.length) {
|
||||||
|
sampleIndex = orderedEvents[eventIndex][0];
|
||||||
|
const carrier = carrierAtSample(
|
||||||
|
sampling.signedAdcFrequencyHz,
|
||||||
|
scene.sampleRate,
|
||||||
|
outputSampleOffset + sampleIndex,
|
||||||
|
);
|
||||||
|
carrierCosine = carrier.cosine;
|
||||||
|
carrierSine = carrier.sine;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output[sampleIndex] +=
|
||||||
|
carrierSine * sineCoefficient + carrierCosine * cosineCoefficient;
|
||||||
|
|
||||||
|
const nextSample = sampleIndex + 1;
|
||||||
|
if (
|
||||||
|
(outputRenderSampleOffset + nextSample) % phaseReanchorInterval === 0
|
||||||
|
) {
|
||||||
|
const carrier = carrierAtSample(
|
||||||
|
sampling.signedAdcFrequencyHz,
|
||||||
|
scene.sampleRate,
|
||||||
|
outputSampleOffset + nextSample,
|
||||||
|
);
|
||||||
|
carrierCosine = carrier.cosine;
|
||||||
|
carrierSine = carrier.sine;
|
||||||
|
} else {
|
||||||
|
const nextCosine =
|
||||||
|
carrierCosine * stepReal - carrierSine * stepImaginary;
|
||||||
|
carrierSine =
|
||||||
|
carrierSine * stepReal + carrierCosine * stepImaginary;
|
||||||
|
carrierCosine = nextCosine;
|
||||||
|
}
|
||||||
|
sampleIndex = nextSample;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
function resolveSynthesisDimensions(scene, signalOffsets) {
|
||||||
|
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
|
||||||
|
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
|
||||||
|
const cropStartSample = signalOffsets?.cropStartSample ?? 0;
|
||||||
|
const cropSampleCount = signalOffsets?.cropSampleCount ?? sampleCount;
|
||||||
|
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||||
|
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(cropStartSample) ||
|
||||||
|
cropStartSample < 0 ||
|
||||||
|
!Number.isSafeInteger(cropSampleCount) ||
|
||||||
|
cropSampleCount <= 0 ||
|
||||||
|
cropStartSample + cropSampleCount > sampleCount
|
||||||
|
) {
|
||||||
|
throw new RangeError("signal output crop must be a non-empty range inside the synthesis buffer.");
|
||||||
|
}
|
||||||
|
const outputStartSample = sceneSampleOffset + cropStartSample;
|
||||||
|
const outputEndSample = outputStartSample + cropSampleCount;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(outputStartSample) ||
|
||||||
|
!Number.isSafeInteger(outputEndSample)
|
||||||
|
) {
|
||||||
|
throw new RangeError("signal output absolute sample range exceeds the safe integer range.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sampleCount,
|
||||||
|
sceneSampleOffset,
|
||||||
|
cropStartSample,
|
||||||
|
cropSampleCount,
|
||||||
|
outputStartSample,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampledSignalTransient(sampleCount, maximumDelaySeconds, sampleRate) {
|
||||||
|
const maximumDelaySamples = ceilSamplePosition(maximumDelaySeconds * sampleRate);
|
||||||
|
const fftSize = nextPowerOfTwo(sampleCount + maximumDelaySamples + 2);
|
||||||
|
const sampleBytes = sampleCount * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
const spectrumAndWorkspaceBytes =
|
||||||
|
fftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
||||||
|
return {
|
||||||
|
maximumDelaySamples,
|
||||||
|
fftSize,
|
||||||
|
sampleBytes,
|
||||||
|
spectrumAndWorkspaceBytes,
|
||||||
|
totalBytes: sampleBytes + spectrumAndWorkspaceBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate the maximum simultaneously live typed-array storage used by the
|
||||||
|
* source-streaming synthesizer. Paths are represented separately by bounded
|
||||||
|
* short-lived JS objects and are not included in this typed working-set cap.
|
||||||
|
*/
|
||||||
|
export function estimateSynthesisWorkingSet(options) {
|
||||||
|
const {
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance = 1,
|
||||||
|
minimumDistance = 0.01,
|
||||||
|
signalOffsets,
|
||||||
|
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||||
|
} = options;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(maximumPathEvaluations) ||
|
||||||
|
maximumPathEvaluations <= 0
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"maximumPathEvaluations must be a positive safe integer.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dimensions = resolveSynthesisDimensions(scene, signalOffsets);
|
||||||
|
const summaryOptions = {
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
referenceDistance,
|
||||||
|
minimumDistance,
|
||||||
|
};
|
||||||
|
const candidateSummary =
|
||||||
|
options.acousticPathSummary ?? signalOffsets?.acousticPathSummary;
|
||||||
|
const reusedPathSummary = isAcousticPathSummaryCompatible(
|
||||||
|
candidateSummary,
|
||||||
|
summaryOptions,
|
||||||
|
);
|
||||||
|
const acousticPathSummary = reusedPathSummary
|
||||||
|
? candidateSummary
|
||||||
|
: summarizeAcousticPaths({
|
||||||
|
...summaryOptions,
|
||||||
|
maximumPathEvaluations,
|
||||||
|
});
|
||||||
|
if (acousticPathSummary.pathCount > maximumPathEvaluations) {
|
||||||
|
throw new RangeError(
|
||||||
|
`acoustic path count ${acousticPathSummary.pathCount} exceeds ` +
|
||||||
|
`maximumPathEvaluations ${maximumPathEvaluations}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const activePathMaximumDelaySamples = ceilSamplePosition(
|
||||||
|
acousticPathSummary.maximumDelaySeconds * scene.sampleRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelBytes =
|
||||||
|
arrayDefinition.microphones.length *
|
||||||
|
dimensions.cropSampleCount *
|
||||||
|
Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
let maximumTransientBytes = 0;
|
||||||
|
let maximumTransient = {
|
||||||
|
scope: "none",
|
||||||
|
id: null,
|
||||||
|
maximumDelaySamples: 0,
|
||||||
|
fftSize: 0,
|
||||||
|
sampleBytes: 0,
|
||||||
|
spectrumAndWorkspaceBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
};
|
||||||
|
const sampledSources = [];
|
||||||
|
scene.sources.forEach((source, sourceIndex) => {
|
||||||
|
if (source.signal.type === "tone") return;
|
||||||
|
const transient = sampledSignalTransient(
|
||||||
|
dimensions.sampleCount,
|
||||||
|
acousticPathSummary.sources[sourceIndex].maximumDelaySeconds,
|
||||||
|
scene.sampleRate,
|
||||||
|
);
|
||||||
|
const entry = { scope: "source", id: source.id, ...transient };
|
||||||
|
sampledSources.push(entry);
|
||||||
|
if (entry.totalBytes > maximumTransientBytes) {
|
||||||
|
maximumTransientBytes = entry.totalBytes;
|
||||||
|
maximumTransient = entry;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sampledAmbient = [];
|
||||||
|
for (const ambient of scene.ambient ?? []) {
|
||||||
|
if (ambient.signal.type === "tone") continue;
|
||||||
|
const transient = sampledSignalTransient(
|
||||||
|
dimensions.sampleCount,
|
||||||
|
0,
|
||||||
|
scene.sampleRate,
|
||||||
|
);
|
||||||
|
const entry = { scope: "ambient", id: ambient.id, ...transient };
|
||||||
|
sampledAmbient.push(entry);
|
||||||
|
if (entry.totalBytes > maximumTransientBytes) {
|
||||||
|
maximumTransientBytes = entry.totalBytes;
|
||||||
|
maximumTransient = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const estimatedBufferBytes = channelBytes + maximumTransientBytes;
|
||||||
|
if (!Number.isSafeInteger(estimatedBufferBytes)) {
|
||||||
|
throw new RangeError("estimated synthesis typed working set exceeds the safe integer range.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...dimensions,
|
||||||
|
estimatedBufferBytes,
|
||||||
|
channelBytes,
|
||||||
|
maximumTransientBytes,
|
||||||
|
maximumTransient,
|
||||||
|
sampledSources,
|
||||||
|
sampledAmbient,
|
||||||
|
acousticPathSummary,
|
||||||
|
activePathMaximumDelaySamples,
|
||||||
|
reusedPathSummary,
|
||||||
|
model: "crop-channels-plus-one-sampled-signal-spectrum-and-workspace",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWorkingSetWithinLimit(estimatedBytes, maximumBufferBytes) {
|
||||||
|
if (
|
||||||
|
!(maximumBufferBytes > 0) ||
|
||||||
|
!Number.isFinite(maximumBufferBytes)
|
||||||
|
) {
|
||||||
|
throw new RangeError("maximumBufferBytes must be finite and positive.");
|
||||||
|
}
|
||||||
|
if (estimatedBytes > maximumBufferBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`예상 signal buffer ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||||
|
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSourceArrivalState(options) {
|
||||||
|
const {
|
||||||
|
definition,
|
||||||
|
scene,
|
||||||
|
assetBuffers,
|
||||||
|
timeMapping,
|
||||||
|
} = options;
|
||||||
|
const emissionStartTime =
|
||||||
|
timeMapping.signalStartTime ?? definition.startTime ?? 0;
|
||||||
|
const declaredDuration =
|
||||||
|
timeMapping.signalDuration ?? definition.duration ?? scene.duration;
|
||||||
|
const emissionDuration = effectiveSignalDurationSeconds(
|
||||||
|
{ ...definition, duration: declaredDuration },
|
||||||
|
scene,
|
||||||
|
assetBuffers,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
sourceId: definition.id,
|
||||||
|
emissionStartTime,
|
||||||
|
emissionDuration,
|
||||||
|
earliestArrivalSample: Infinity,
|
||||||
|
latestArrivalEndSample: -Infinity,
|
||||||
|
overlapsOutput: false,
|
||||||
|
overlappingPathKinds: new Set(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeSourceArrivalPaths(
|
||||||
|
state,
|
||||||
|
paths,
|
||||||
|
sampleRate,
|
||||||
|
outputStartSample,
|
||||||
|
outputEndSample,
|
||||||
|
) {
|
||||||
|
for (const path of paths) {
|
||||||
|
const arrivalStartSample = ceilSampleIndex(
|
||||||
|
state.emissionStartTime + path.delaySeconds,
|
||||||
|
sampleRate,
|
||||||
|
);
|
||||||
|
const arrivalEndSample = ceilSampleIndex(
|
||||||
|
state.emissionStartTime + state.emissionDuration + path.delaySeconds,
|
||||||
|
sampleRate,
|
||||||
|
);
|
||||||
|
state.earliestArrivalSample = Math.min(
|
||||||
|
state.earliestArrivalSample,
|
||||||
|
arrivalStartSample,
|
||||||
|
);
|
||||||
|
state.latestArrivalEndSample = Math.max(
|
||||||
|
state.latestArrivalEndSample,
|
||||||
|
arrivalEndSample,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
arrivalStartSample < outputEndSample &&
|
||||||
|
arrivalEndSample > outputStartSample
|
||||||
|
) {
|
||||||
|
state.overlapsOutput = true;
|
||||||
|
state.overlappingPathKinds.add(path.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeSourceArrival(state, sampleRate) {
|
||||||
|
return {
|
||||||
|
sourceId: state.sourceId,
|
||||||
|
emissionStartSample: ceilSampleIndex(state.emissionStartTime, sampleRate),
|
||||||
|
emissionEndSample: ceilSampleIndex(
|
||||||
|
state.emissionStartTime + state.emissionDuration,
|
||||||
|
sampleRate,
|
||||||
|
),
|
||||||
|
earliestArrivalSample: Number.isFinite(state.earliestArrivalSample)
|
||||||
|
? state.earliestArrivalSample
|
||||||
|
: null,
|
||||||
|
latestArrivalEndSample: Number.isFinite(state.latestArrivalEndSample)
|
||||||
|
? state.latestArrivalEndSample
|
||||||
|
: null,
|
||||||
|
overlapsOutput: state.overlapsOutput,
|
||||||
|
overlappingPathKinds: [...state.overlappingPathKinds].sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function synthesizeMicrophoneSignals(options) {
|
export function synthesizeMicrophoneSignals(options) {
|
||||||
const {
|
const {
|
||||||
scene,
|
scene,
|
||||||
@@ -24,43 +475,74 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
assetBuffers = new Map(),
|
assetBuffers = new Map(),
|
||||||
referenceDistance = 1,
|
referenceDistance = 1,
|
||||||
minimumDistance = 0.01,
|
minimumDistance = 0.01,
|
||||||
maximumBufferBytes = 512 * 1024 * 1024,
|
maximumBufferBytes = DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||||
|
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||||
signalOffsets,
|
signalOffsets,
|
||||||
} = options;
|
} = options;
|
||||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
const workingSet = estimateSynthesisWorkingSet({
|
||||||
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
|
|
||||||
const maximumDelaySamples =
|
|
||||||
maximumAcousticDelaySeconds({
|
|
||||||
scene,
|
scene,
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
referenceDistance,
|
referenceDistance,
|
||||||
minimumDistance,
|
minimumDistance,
|
||||||
}) * scene.sampleRate;
|
signalOffsets,
|
||||||
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
|
acousticPathSummary: options.acousticPathSummary,
|
||||||
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
maximumPathEvaluations,
|
||||||
const estimatedBufferBytes =
|
});
|
||||||
retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + delayWorkingBytes;
|
assertWorkingSetWithinLimit(
|
||||||
if (estimatedBufferBytes > maximumBufferBytes) {
|
workingSet.estimatedBufferBytes,
|
||||||
throw new Error(
|
maximumBufferBytes,
|
||||||
`예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
|
||||||
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
|
||||||
);
|
);
|
||||||
}
|
const {
|
||||||
const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount));
|
sampleCount,
|
||||||
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
|
sceneSampleOffset,
|
||||||
const renderedSources = scene.sources.map((source, sourceIndex) => ({
|
cropStartSample,
|
||||||
definition: source,
|
cropSampleCount,
|
||||||
samples: renderSignal(source, scene, assetBuffers, 0, {
|
outputStartSample,
|
||||||
|
acousticPathSummary,
|
||||||
|
} = workingSet;
|
||||||
|
const outputEndSample = outputStartSample + cropSampleCount;
|
||||||
|
const needsOutputCrop =
|
||||||
|
cropStartSample !== 0 || cropSampleCount !== sampleCount;
|
||||||
|
const synthesisChannels = arrayDefinition.microphones.map(
|
||||||
|
() => new Float32Array(cropSampleCount),
|
||||||
|
);
|
||||||
|
const sourceArrivals = [];
|
||||||
|
|
||||||
|
scene.sources.forEach((definition, sourceIndex) => {
|
||||||
|
const timeMapping = {
|
||||||
sceneSampleOffset,
|
sceneSampleOffset,
|
||||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||||
}),
|
};
|
||||||
}));
|
const arrivalState = createSourceArrivalState({
|
||||||
|
definition,
|
||||||
|
scene,
|
||||||
|
assetBuffers,
|
||||||
|
timeMapping,
|
||||||
|
});
|
||||||
|
const sourceWorkingSet = workingSet.sampledSources.find(
|
||||||
|
({ id }) => id === definition.id,
|
||||||
|
);
|
||||||
|
if (sourceWorkingSet) {
|
||||||
|
assertWorkingSetWithinLimit(
|
||||||
|
workingSet.channelBytes + sourceWorkingSet.totalBytes,
|
||||||
|
maximumBufferBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const samples = definition.signal.type === "tone"
|
||||||
|
? undefined
|
||||||
|
: renderSignal(definition, scene, assetBuffers, 0, timeMapping);
|
||||||
|
const spectrum = samples
|
||||||
|
? prepareDelaySpectrum(
|
||||||
|
samples,
|
||||||
|
sourceWorkingSet.maximumDelaySamples,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const workspace = spectrum
|
||||||
|
? createDelayWorkspace(spectrum.fftSize)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const acousticPathCounts = {};
|
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||||
let acousticPathCount = 0;
|
const paths = buildAcousticPaths({
|
||||||
renderedSources.forEach(({ definition, samples }) => {
|
|
||||||
const microphonePaths = arrayDefinition.microphones.map((microphone) =>
|
|
||||||
buildAcousticPaths({
|
|
||||||
scene,
|
scene,
|
||||||
sourcePosition: definition.position,
|
sourcePosition: definition.position,
|
||||||
receiverPosition: microphone.position,
|
receiverPosition: microphone.position,
|
||||||
@@ -68,51 +550,86 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
receiverId: microphone.id,
|
receiverId: microphone.id,
|
||||||
referenceDistance,
|
referenceDistance,
|
||||||
minimumDistance,
|
minimumDistance,
|
||||||
}),
|
});
|
||||||
|
observeSourceArrivalPaths(
|
||||||
|
arrivalState,
|
||||||
|
paths,
|
||||||
|
scene.sampleRate,
|
||||||
|
outputStartSample,
|
||||||
|
outputEndSample,
|
||||||
);
|
);
|
||||||
let sourceMaximumDelaySamples = 0;
|
if (definition.signal.type === "tone") {
|
||||||
for (const paths of microphonePaths) {
|
addSampledAnalogTonePaths({
|
||||||
for (const path of paths) {
|
output: synthesisChannels[microphoneIndex],
|
||||||
sourceMaximumDelaySamples = Math.max(
|
entry: definition,
|
||||||
sourceMaximumDelaySamples,
|
scene,
|
||||||
path.delaySeconds * scene.sampleRate,
|
microphone,
|
||||||
);
|
paths,
|
||||||
}
|
timeMapping,
|
||||||
}
|
outputSceneSampleOffset: outputStartSample,
|
||||||
const spectrum = prepareDelaySpectrum(samples, sourceMaximumDelaySamples);
|
outputRenderSampleOffset: cropStartSample,
|
||||||
const workspace = createDelayWorkspace(spectrum.fftSize);
|
});
|
||||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
} else {
|
||||||
const paths = microphonePaths[microphoneIndex];
|
|
||||||
for (const path of paths) {
|
|
||||||
acousticPathCount += 1;
|
|
||||||
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
|
||||||
addFrequencyDomainPaths({
|
addFrequencyDomainPaths({
|
||||||
output: channels[microphoneIndex],
|
output: synthesisChannels[microphoneIndex],
|
||||||
spectrum,
|
spectrum,
|
||||||
paths: paths.map((path) => ({
|
paths,
|
||||||
delaySamples: path.delaySeconds * scene.sampleRate,
|
pathDelaySampleRate: scene.sampleRate,
|
||||||
gain: path.gain,
|
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
||||||
})),
|
|
||||||
gain: sensitivity,
|
|
||||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||||
frequencyResponse: microphone.frequencyResponse,
|
frequencyResponse: microphone.frequencyResponse,
|
||||||
sampleRate: scene.sampleRate,
|
sampleRate: scene.sampleRate,
|
||||||
workspace,
|
workspace,
|
||||||
|
outputOffsetSamples: cropStartSample,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
sourceArrivals.push(finalizeSourceArrival(arrivalState, scene.sampleRate));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(scene.ambient)) {
|
if (Array.isArray(scene.ambient)) {
|
||||||
scene.ambient.forEach((ambient, ambientIndex) => {
|
scene.ambient.forEach((ambient, ambientIndex) => {
|
||||||
channels.forEach((channel, microphoneIndex) => {
|
const ambientTimeMapping = {
|
||||||
const microphone = arrayDefinition.microphones[microphoneIndex];
|
|
||||||
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
|
||||||
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, {
|
|
||||||
sceneSampleOffset,
|
sceneSampleOffset,
|
||||||
...(signalOffsets?.ambient?.[ambientIndex] ?? {}),
|
...(signalOffsets?.ambient?.[ambientIndex] ?? {}),
|
||||||
|
};
|
||||||
|
const ambientWorkingSet = workingSet.sampledAmbient.find(
|
||||||
|
({ id }) => id === ambient.id,
|
||||||
|
);
|
||||||
|
if (ambientWorkingSet) {
|
||||||
|
assertWorkingSetWithinLimit(
|
||||||
|
workingSet.channelBytes + ambientWorkingSet.totalBytes,
|
||||||
|
maximumBufferBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ambientWorkspace = ambientWorkingSet
|
||||||
|
? createDelayWorkspace(ambientWorkingSet.fftSize)
|
||||||
|
: undefined;
|
||||||
|
synthesisChannels.forEach((channel, microphoneIndex) => {
|
||||||
|
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||||
|
if (ambient.signal.type === "tone") {
|
||||||
|
addSampledAnalogTonePaths({
|
||||||
|
output: channel,
|
||||||
|
entry: ambient,
|
||||||
|
scene,
|
||||||
|
microphone,
|
||||||
|
paths: [{ delaySeconds: 0, gain: 1 }],
|
||||||
|
timeMapping: ambientTimeMapping,
|
||||||
|
outputSceneSampleOffset: outputStartSample,
|
||||||
|
outputRenderSampleOffset: cropStartSample,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seedSalt =
|
||||||
|
Math.imul(ambientIndex + 1, 0x45d9f3b) ^
|
||||||
|
Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
||||||
|
const ambientSamples = renderSignal(
|
||||||
|
ambient,
|
||||||
|
scene,
|
||||||
|
assetBuffers,
|
||||||
|
seedSalt,
|
||||||
|
ambientTimeMapping,
|
||||||
|
);
|
||||||
const spectrum = prepareDelaySpectrum(ambientSamples, 0);
|
const spectrum = prepareDelaySpectrum(ambientSamples, 0);
|
||||||
addFrequencyDomainDelay({
|
addFrequencyDomainDelay({
|
||||||
output: channel,
|
output: channel,
|
||||||
@@ -122,12 +639,15 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||||
frequencyResponse: microphone.frequencyResponse,
|
frequencyResponse: microphone.frequencyResponse,
|
||||||
sampleRate: scene.sampleRate,
|
sampleRate: scene.sampleRate,
|
||||||
workspace: createDelayWorkspace(spectrum.fftSize),
|
workspace: ambientWorkspace,
|
||||||
|
outputOffsetSamples: cropStartSample,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const channels = synthesisChannels;
|
||||||
|
|
||||||
let preNormalizationPeak = 0;
|
let preNormalizationPeak = 0;
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
for (const value of channel) {
|
for (const value of channel) {
|
||||||
@@ -155,9 +675,76 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
preNormalizationPeak,
|
preNormalizationPeak,
|
||||||
appliedScale,
|
appliedScale,
|
||||||
clippedSampleCount,
|
clippedSampleCount,
|
||||||
estimatedBufferBytes,
|
estimatedBufferBytes: workingSet.estimatedBufferBytes,
|
||||||
|
workingSet: {
|
||||||
|
model: workingSet.model,
|
||||||
|
channelBytes: workingSet.channelBytes,
|
||||||
|
maximumTransientBytes: workingSet.maximumTransientBytes,
|
||||||
|
maximumTransientScope: workingSet.maximumTransient.scope,
|
||||||
|
maximumTransientId: workingSet.maximumTransient.id,
|
||||||
|
pathSummary: workingSet.reusedPathSummary
|
||||||
|
? "reused-authenticated-geometry-summary"
|
||||||
|
: "computed-for-synthesis",
|
||||||
|
summaryPathPairEvaluations: workingSet.reusedPathSummary
|
||||||
|
? 0
|
||||||
|
: acousticPathSummary.sourceReceiverPairCount,
|
||||||
|
synthesisPathBuildPairEvaluations:
|
||||||
|
acousticPathSummary.sourceReceiverPairCount,
|
||||||
|
retainedPathObjects: "one-source-receiver-pair",
|
||||||
|
remainingNonToneTransformModel: "one-ifft-per-source-and-microphone",
|
||||||
|
},
|
||||||
acousticModel: scene.acoustics?.model ?? "free-field",
|
acousticModel: scene.acoustics?.model ?? "free-field",
|
||||||
acousticPathCount,
|
acousticPathCount: acousticPathSummary.pathCount,
|
||||||
acousticPathCounts,
|
acousticPathCounts: { ...acousticPathSummary.pathCounts },
|
||||||
|
windowing: {
|
||||||
|
model: signalOffsets?.windowingModel ?? "whole-scene",
|
||||||
|
renderStartSample: sceneSampleOffset,
|
||||||
|
outputStartSample,
|
||||||
|
outputSampleCount: cropSampleCount,
|
||||||
|
preRollSamples: cropStartSample,
|
||||||
|
renderMaximumPropagationDelaySeconds:
|
||||||
|
signalOffsets?.renderMaximumPropagationDelaySeconds ??
|
||||||
|
acousticPathSummary.maximumDelaySeconds,
|
||||||
|
activePathMaximumDelaySeconds: acousticPathSummary.maximumDelaySeconds,
|
||||||
|
requestedMaximumPropagationDelaySeconds:
|
||||||
|
signalOffsets?.maximumPropagationDelaySeconds ??
|
||||||
|
acousticPathSummary.maximumDelaySeconds,
|
||||||
|
geometryModel: Number.isFinite(signalOffsets?.sourcePositionSampleTime)
|
||||||
|
? "source-position-fixed-at-window-midpoint"
|
||||||
|
: "static-source-position",
|
||||||
|
sourcePositionSampleTime: signalOffsets?.sourcePositionSampleTime ?? null,
|
||||||
|
dopplerMode: "not-modeled",
|
||||||
|
delayedTailPolicy: needsOutputCrop
|
||||||
|
? "propagation-preroll-then-output-crop"
|
||||||
|
: signalOffsets?.windowingModel
|
||||||
|
? "live-request-window-direct-output-with-analytic-path-gating"
|
||||||
|
: "whole-render-buffer",
|
||||||
|
arrivalOverlapModel: "path-gated-half-open-sample-interval",
|
||||||
|
arrivingSourceIds: sourceArrivals
|
||||||
|
.filter(({ overlapsOutput }) => overlapsOutput)
|
||||||
|
.map(({ sourceId }) => sourceId),
|
||||||
|
sourceArrivals,
|
||||||
|
},
|
||||||
|
sampling: {
|
||||||
|
model: "ideal-real-sampler-no-antialias-filter",
|
||||||
|
sampleRate: scene.sampleRate,
|
||||||
|
nyquistHz: scene.sampleRate / 2,
|
||||||
|
toneObservations: [
|
||||||
|
...scene.sources
|
||||||
|
.filter((source) => source.signal.type === "tone")
|
||||||
|
.map((source) => ({
|
||||||
|
scope: "source",
|
||||||
|
id: source.id,
|
||||||
|
...describeFrequencyAliasing(source.signal.frequency, scene.sampleRate),
|
||||||
|
})),
|
||||||
|
...(scene.ambient ?? [])
|
||||||
|
.filter((ambient) => ambient.signal.type === "tone")
|
||||||
|
.map((ambient) => ({
|
||||||
|
scope: "ambient",
|
||||||
|
id: ambient.id,
|
||||||
|
...describeFrequencyAliasing(ambient.signal.frequency, scene.sampleRate),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { findLocalPeaks } from "./evaluation.js";
|
import {
|
||||||
|
findLocalPeaks,
|
||||||
|
normalizedMusicPseudospectrum,
|
||||||
|
} from "./evaluation.js";
|
||||||
import {
|
import {
|
||||||
computeLocalizationMap,
|
computeLocalizationMap,
|
||||||
createLocalizationAnalysisPlan,
|
createLocalizationAnalysisPlan,
|
||||||
@@ -17,6 +20,8 @@ import {
|
|||||||
MAX_FREQUENCY_RESPONSE_POINTS,
|
MAX_FREQUENCY_RESPONSE_POINTS,
|
||||||
requireMaxCompensationDb,
|
requireMaxCompensationDb,
|
||||||
} from "./frequency-response.js";
|
} from "./frequency-response.js";
|
||||||
|
import { isMusicUnavailableError } from "./music.js";
|
||||||
|
import { getCompleteFrameCount } from "./stft.js";
|
||||||
|
|
||||||
export const REALTIME_LOCALIZATION_PLAN_KIND = "micarrsim.realtime-localization-plan/1";
|
export const REALTIME_LOCALIZATION_PLAN_KIND = "micarrsim.realtime-localization-plan/1";
|
||||||
export const DEFAULT_STREAM_RESULTS_PER_CALL = 1;
|
export const DEFAULT_STREAM_RESULTS_PER_CALL = 1;
|
||||||
@@ -152,20 +157,18 @@ function copyScan(scan) {
|
|||||||
return Object.freeze({ ...scan, columns, rows });
|
return Object.freeze({ ...scan, columns, rows });
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyBands(bands, sampleRate) {
|
function copyBands(bands) {
|
||||||
if (!Array.isArray(bands) || bands.length === 0) {
|
if (!Array.isArray(bands) || bands.length === 0) {
|
||||||
throw new TypeError("bands must be a non-empty array.");
|
throw new TypeError("bands must be a non-empty array.");
|
||||||
}
|
}
|
||||||
const nyquist = sampleRate / 2;
|
|
||||||
return Object.freeze(bands.map((band, index) => {
|
return Object.freeze(bands.map((band, index) => {
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(band?.minHz) ||
|
!Number.isFinite(band?.minHz) ||
|
||||||
!Number.isFinite(band?.maxHz) ||
|
!Number.isFinite(band?.maxHz) ||
|
||||||
band.minHz < 0 ||
|
band.minHz < 0 ||
|
||||||
band.maxHz <= band.minHz ||
|
band.maxHz <= band.minHz
|
||||||
band.maxHz >= nyquist
|
|
||||||
) {
|
) {
|
||||||
throw new RangeError(`bands[${index}] must lie inside 0–${nyquist} Hz.`);
|
throw new RangeError(`bands[${index}] must satisfy 0 <= minHz < maxHz.`);
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...band,
|
...band,
|
||||||
@@ -226,11 +229,36 @@ function throwIfAborted(signal) {
|
|||||||
|
|
||||||
export function detectBandPeaks(rawMap, scan, options = {}) {
|
export function detectBandPeaks(rawMap, scan, options = {}) {
|
||||||
const rawScale = options.rawScale ?? rawMap?.scale ?? "power";
|
const rawScale = options.rawScale ?? rawMap?.scale ?? "power";
|
||||||
if (rawScale !== "power" && rawScale !== "score") {
|
if (rawScale !== "power" && rawScale !== "score" && rawScale !== "noise-projection") {
|
||||||
throw new RangeError('peak map rawScale must be "power" or "score".');
|
throw new RangeError(
|
||||||
|
'peak map rawScale must be "power", "score", or "noise-projection".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const objectiveDirection =
|
||||||
|
options.objectiveDirection ??
|
||||||
|
rawMap?.objectiveDirection ??
|
||||||
|
(rawScale === "noise-projection" ? "minimum" : "maximum");
|
||||||
|
if (
|
||||||
|
(rawScale === "noise-projection" && objectiveDirection !== "minimum") ||
|
||||||
|
(rawScale !== "noise-projection" && objectiveDirection !== "maximum")
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
"noise-projection uses a minimum objective; power and score use a maximum objective.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rawValues =
|
||||||
|
rawMap && !Array.isArray(rawMap) && !ArrayBuffer.isView(rawMap)
|
||||||
|
? rawMap.values
|
||||||
|
: rawMap;
|
||||||
|
let detectionMap = rawMap;
|
||||||
|
if (objectiveDirection === "minimum") {
|
||||||
|
const pseudospectrum =
|
||||||
|
normalizedMusicPseudospectrum(rawValues);
|
||||||
|
if (pseudospectrum.flat) return [];
|
||||||
|
detectionMap = pseudospectrum.values;
|
||||||
}
|
}
|
||||||
const peakDetection = copyPeakDetection(options, scan);
|
const peakDetection = copyPeakDetection(options, scan);
|
||||||
const localPeaks = findLocalPeaks(rawMap, scan);
|
const localPeaks = findLocalPeaks(detectionMap, scan);
|
||||||
const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
|
const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
|
||||||
const detections = [];
|
const detections = [];
|
||||||
for (const peak of localPeaks) {
|
for (const peak of localPeaks) {
|
||||||
@@ -250,11 +278,15 @@ export function detectBandPeaks(rawMap, scan, options = {}) {
|
|||||||
rank: detections.length + 1,
|
rank: detections.length + 1,
|
||||||
index: peak.index,
|
index: peak.index,
|
||||||
position: [...peak.position],
|
position: [...peak.position],
|
||||||
rawValue: peak.value,
|
rawValue: rawValues[peak.index],
|
||||||
rawScale,
|
rawScale,
|
||||||
|
objectiveDirection,
|
||||||
|
detectionMerit: peak.value,
|
||||||
...(rawScale === "power"
|
...(rawScale === "power"
|
||||||
? { rawPower: peak.value }
|
? { rawPower: rawValues[peak.index] }
|
||||||
: { rawScore: peak.value }),
|
: rawScale === "score"
|
||||||
|
? { rawScore: rawValues[peak.index] }
|
||||||
|
: { noiseProjection: rawValues[peak.index] }),
|
||||||
});
|
});
|
||||||
if (detections.length === peakDetection.maximumDetections) break;
|
if (detections.length === peakDetection.maximumDetections) break;
|
||||||
}
|
}
|
||||||
@@ -285,7 +317,7 @@ export function createLocalizationPlan(config) {
|
|||||||
const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition);
|
const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition);
|
||||||
const scan = copyScan(config.scan);
|
const scan = copyScan(config.scan);
|
||||||
const peakDetection = copyPeakDetection(config.peakDetection, scan);
|
const peakDetection = copyPeakDetection(config.peakDetection, scan);
|
||||||
const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate);
|
const frequencyBands = copyBands(config.bands ?? config.frequencyBands);
|
||||||
const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize");
|
const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize");
|
||||||
const hopSize = requirePositiveInteger(
|
const hopSize = requirePositiveInteger(
|
||||||
config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2,
|
config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2,
|
||||||
@@ -293,10 +325,21 @@ export function createLocalizationPlan(config) {
|
|||||||
);
|
);
|
||||||
if (hopSize > fftSize) throw new RangeError("stftHopSize must not exceed fftSize.");
|
if (hopSize > fftSize) throw new RangeError("stftHopSize must not exceed fftSize.");
|
||||||
const speedOfSound = requirePositiveFinite(config.speedOfSound ?? 343, "speedOfSound");
|
const speedOfSound = requirePositiveFinite(config.speedOfSound ?? 343, "speedOfSound");
|
||||||
const normalization = config.normalization ?? config.beamforming?.normalization ?? "peak";
|
const algorithm = requireLocalizationAlgorithmDefinition(
|
||||||
|
config.algorithm ?? config.beamforming?.algorithm,
|
||||||
|
);
|
||||||
|
const normalization =
|
||||||
|
config.normalization ??
|
||||||
|
config.beamforming?.normalization ??
|
||||||
|
(algorithm.id === "music" ? "none" : "peak");
|
||||||
if (normalization !== "peak" && normalization !== "none") {
|
if (normalization !== "peak" && normalization !== "none") {
|
||||||
throw new RangeError('normalization must be "peak" or "none".');
|
throw new RangeError('normalization must be "peak" or "none".');
|
||||||
}
|
}
|
||||||
|
if (algorithm.id === "music" && normalization !== "none") {
|
||||||
|
throw new RangeError(
|
||||||
|
'MUSIC normalization must be "none"; raw noise projection q is not peak-normalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const compensateFrequencyResponse =
|
const compensateFrequencyResponse =
|
||||||
config.compensateFrequencyResponse ??
|
config.compensateFrequencyResponse ??
|
||||||
config.beamforming?.compensateFrequencyResponse ??
|
config.beamforming?.compensateFrequencyResponse ??
|
||||||
@@ -309,9 +352,20 @@ export function createLocalizationPlan(config) {
|
|||||||
config.beamforming?.maxCompensationDb ??
|
config.beamforming?.maxCompensationDb ??
|
||||||
DEFAULT_MAX_COMPENSATION_DB,
|
DEFAULT_MAX_COMPENSATION_DB,
|
||||||
);
|
);
|
||||||
const algorithm = requireLocalizationAlgorithmDefinition(
|
const minimumDistance = requirePositiveFinite(
|
||||||
config.algorithm ?? config.beamforming?.algorithm,
|
config.minimumDistance ??
|
||||||
|
config.beamforming?.minimumDistance ??
|
||||||
|
0.01,
|
||||||
|
"minimumDistance",
|
||||||
);
|
);
|
||||||
|
const musicSourceCountMethod =
|
||||||
|
config.musicSourceCountMethod ??
|
||||||
|
config.beamforming?.musicSourceCountMethod ??
|
||||||
|
"fixed";
|
||||||
|
const musicSourceCount =
|
||||||
|
config.musicSourceCount ??
|
||||||
|
config.beamforming?.musicSourceCount ??
|
||||||
|
1;
|
||||||
const phatRelativeFloor =
|
const phatRelativeFloor =
|
||||||
config.phatRelativeFloor ?? config.beamforming?.phatRelativeFloor;
|
config.phatRelativeFloor ?? config.beamforming?.phatRelativeFloor;
|
||||||
const phatFloorRatio =
|
const phatFloorRatio =
|
||||||
@@ -328,6 +382,10 @@ export function createLocalizationPlan(config) {
|
|||||||
normalization,
|
normalization,
|
||||||
compensateFrequencyResponse,
|
compensateFrequencyResponse,
|
||||||
maxCompensationDb,
|
maxCompensationDb,
|
||||||
|
minimumDistance,
|
||||||
|
...(algorithm.id === "music"
|
||||||
|
? { musicSourceCountMethod, musicSourceCount }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const analysisPlan = createLocalizationAnalysisPlan({
|
const analysisPlan = createLocalizationAnalysisPlan({
|
||||||
@@ -352,18 +410,22 @@ export function createLocalizationPlan(config) {
|
|||||||
speedOfSound,
|
speedOfSound,
|
||||||
normalization,
|
normalization,
|
||||||
maxCompensationDb,
|
maxCompensationDb,
|
||||||
|
minimumDistance,
|
||||||
scene,
|
scene,
|
||||||
experiment,
|
experiment,
|
||||||
analysisPlan,
|
analysisPlan,
|
||||||
...(isDasAlgorithm(algorithm.id)
|
...(isDasAlgorithm(algorithm.id)
|
||||||
? { dasPlan: analysisPlan }
|
? { dasPlan: analysisPlan }
|
||||||
: { gccPhatPlan: analysisPlan }),
|
: algorithm.id === "gcc-phat"
|
||||||
|
? { gccPhatPlan: analysisPlan }
|
||||||
|
: { musicPlan: analysisPlan }),
|
||||||
computeOptions: Object.freeze({
|
computeOptions: Object.freeze({
|
||||||
pointBatchSize: isDasAlgorithm(algorithm.id)
|
pointBatchSize:
|
||||||
? DEFAULT_REALTIME_POINT_BATCH_SIZE
|
algorithm.id === "gcc-phat"
|
||||||
: DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
? DEFAULT_GCC_PHAT_POINT_BATCH_SIZE
|
||||||
|
: DEFAULT_REALTIME_POINT_BATCH_SIZE,
|
||||||
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
|
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
|
||||||
...(!isDasAlgorithm(algorithm.id)
|
...(algorithm.id === "gcc-phat"
|
||||||
? { crossSpectrumBatchSize: DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE }
|
? { crossSpectrumBatchSize: DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE }
|
||||||
: {}),
|
: {}),
|
||||||
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
|
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
|
||||||
@@ -392,7 +454,9 @@ export async function localizeMultichannel(options) {
|
|||||||
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
|
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
|
||||||
throwIfAborted(options.signal);
|
throwIfAborted(options.signal);
|
||||||
const processingStartedAt = monotonicNow();
|
const processingStartedAt = monotonicNow();
|
||||||
const localization = await computeLocalizationMap({
|
let localization;
|
||||||
|
try {
|
||||||
|
localization = await computeLocalizationMap({
|
||||||
...plan.computeOptions,
|
...plan.computeOptions,
|
||||||
...(options.computeOptions ?? {}),
|
...(options.computeOptions ?? {}),
|
||||||
channels: options.channels,
|
channels: options.channels,
|
||||||
@@ -404,6 +468,35 @@ export async function localizeMultichannel(options) {
|
|||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
onProgress: options.onProgress,
|
onProgress: options.onProgress,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
plan.algorithm !== "music" ||
|
||||||
|
!isMusicUnavailableError(error)
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
localization = {
|
||||||
|
algorithm: "music",
|
||||||
|
backend: "cpu",
|
||||||
|
status: "unavailable",
|
||||||
|
unavailable: {
|
||||||
|
code: error.code,
|
||||||
|
reason: error.reason,
|
||||||
|
message: error.message,
|
||||||
|
details: { ...error.details },
|
||||||
|
},
|
||||||
|
frameCount: getCompleteFrameCount(
|
||||||
|
sampleCount,
|
||||||
|
plan.stft.fftSize,
|
||||||
|
plan.stft.hopSize,
|
||||||
|
),
|
||||||
|
microphoneCount: plan.channelCount,
|
||||||
|
pointCount: plan.scan.rows * plan.scan.columns,
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
rawScale: "noise-projection",
|
||||||
|
bands: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
const locations = locationsFromBands(
|
const locations = locationsFromBands(
|
||||||
localization.bands,
|
localization.bands,
|
||||||
plan.scan,
|
plan.scan,
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
export const MAX_PRECISE_ANALOG_FREQUENCY_HZ = 1_000_000;
|
||||||
|
export const MAX_EXPLICIT_FOLD_SEGMENTS = 513;
|
||||||
|
|
||||||
|
function requireSampleRate(sampleRate) {
|
||||||
|
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
|
||||||
|
throw new RangeError("sampleRate must be a positive finite number.");
|
||||||
|
}
|
||||||
|
return sampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFrequency(frequencyHz, label = "frequencyHz") {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(frequencyHz) ||
|
||||||
|
frequencyHz < 0 ||
|
||||||
|
frequencyHz > MAX_PRECISE_ANALOG_FREQUENCY_HZ
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`${label} must be between 0 and ${MAX_PRECISE_ANALOG_FREQUENCY_HZ} Hz.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return frequencyHz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveModulo(value, modulus) {
|
||||||
|
const remainder = value % modulus;
|
||||||
|
return remainder < 0 ? remainder + modulus : remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedZero(value) {
|
||||||
|
return Object.is(value, -0) ? 0 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describe the frequency that an ideal real-valued sampler records.
|
||||||
|
*
|
||||||
|
* No anti-alias filter is implied. A negative signed ADC frequency means that
|
||||||
|
* the positive-frequency spectrum is mirrored when represented on 0..Nyquist.
|
||||||
|
*/
|
||||||
|
export function describeFrequencyAliasing(frequencyHz, sampleRate) {
|
||||||
|
requireFrequency(frequencyHz);
|
||||||
|
requireSampleRate(sampleRate);
|
||||||
|
const nyquistHz = sampleRate / 2;
|
||||||
|
const wrappedHz = positiveModulo(frequencyHz, sampleRate);
|
||||||
|
const signedAdcFrequencyHz =
|
||||||
|
wrappedHz <= nyquistHz ? wrappedHz : wrappedHz - sampleRate;
|
||||||
|
const adcFrequencyHz = Math.abs(signedAdcFrequencyHz);
|
||||||
|
const dcEdge = adcFrequencyHz === 0;
|
||||||
|
const nyquistEdge = adcFrequencyHz === nyquistHz;
|
||||||
|
return {
|
||||||
|
model: "ideal-real-sampler-no-antialias-filter",
|
||||||
|
sampleRate,
|
||||||
|
nyquistHz,
|
||||||
|
analogFrequencyHz: frequencyHz,
|
||||||
|
adcFrequencyHz: normalizedZero(adcFrequencyHz),
|
||||||
|
signedAdcFrequencyHz: normalizedZero(signedAdcFrequencyHz),
|
||||||
|
aliased: frequencyHz > nyquistHz,
|
||||||
|
spectralInversion: signedAdcFrequencyHz < 0,
|
||||||
|
dcEdge,
|
||||||
|
nyquistEdge,
|
||||||
|
phaseDegenerate: dcEdge || nyquistEdge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function foldFrequencyToNyquist(frequencyHz, sampleRate) {
|
||||||
|
return describeFrequencyAliasing(frequencyHz, sampleRate).adcFrequencyHz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsResidue(first, last, residue, modulus) {
|
||||||
|
const firstResidue = positiveModulo(first, modulus);
|
||||||
|
const offset = positiveModulo(residue - firstResidue, modulus);
|
||||||
|
return first + offset <= last;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contiguousBinRanges(bins, binWidthHz) {
|
||||||
|
const ranges = [];
|
||||||
|
let firstBin = bins[0];
|
||||||
|
let lastBin = firstBin;
|
||||||
|
for (let index = 1; index <= bins.length; index += 1) {
|
||||||
|
const bin = bins[index];
|
||||||
|
if (bin === lastBin + 1) {
|
||||||
|
lastBin = bin;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ranges.push({
|
||||||
|
firstBin,
|
||||||
|
lastBin,
|
||||||
|
firstBinCenterHz: firstBin * binWidthHz,
|
||||||
|
lastBinCenterHz: lastBin * binWidthHz,
|
||||||
|
});
|
||||||
|
firstBin = bin;
|
||||||
|
lastBin = bin;
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function foldBandSegments(minHz, maxHz, sampleRate) {
|
||||||
|
const nyquistHz = sampleRate / 2;
|
||||||
|
const firstZoneIndex = Math.floor(minHz / nyquistHz);
|
||||||
|
const lastZoneIndex = Math.ceil(maxHz / nyquistHz) - 1;
|
||||||
|
const segmentCount = lastZoneIndex - firstZoneIndex + 1;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(firstZoneIndex) ||
|
||||||
|
!Number.isSafeInteger(lastZoneIndex) ||
|
||||||
|
!Number.isSafeInteger(segmentCount) ||
|
||||||
|
segmentCount < 1 ||
|
||||||
|
segmentCount > MAX_EXPLICIT_FOLD_SEGMENTS
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`band crosses ${segmentCount} Nyquist zones; the explicit limit is ` +
|
||||||
|
`${MAX_EXPLICIT_FOLD_SEGMENTS}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const segments = [];
|
||||||
|
let analogStartHz = minHz;
|
||||||
|
while (analogStartHz < maxHz) {
|
||||||
|
const zoneIndex = Math.floor(analogStartHz / nyquistHz);
|
||||||
|
const nyquistZone = zoneIndex + 1;
|
||||||
|
const nextBoundaryHz = nyquistZone * nyquistHz;
|
||||||
|
const analogEndHz = Math.min(maxHz, nextBoundaryHz);
|
||||||
|
if (!(analogEndHz > analogStartHz)) {
|
||||||
|
throw new RangeError("band is too large to retain Nyquist-zone precision.");
|
||||||
|
}
|
||||||
|
const inverted = nyquistZone % 2 === 0;
|
||||||
|
const signedAdcStartHz = inverted
|
||||||
|
? analogStartHz - nyquistZone * nyquistHz
|
||||||
|
: analogStartHz - (nyquistZone - 1) * nyquistHz;
|
||||||
|
const signedAdcEndHz = inverted
|
||||||
|
? analogEndHz - nyquistZone * nyquistHz
|
||||||
|
: analogEndHz - (nyquistZone - 1) * nyquistHz;
|
||||||
|
segments.push({
|
||||||
|
analogMinHz: analogStartHz,
|
||||||
|
analogMaxHz: analogEndHz,
|
||||||
|
signedAdcStartHz: normalizedZero(signedAdcStartHz),
|
||||||
|
signedAdcEndHz: normalizedZero(signedAdcEndHz),
|
||||||
|
inverted,
|
||||||
|
aliasOrder: Math.floor(nyquistZone / 2),
|
||||||
|
nyquistZone,
|
||||||
|
});
|
||||||
|
analogStartHz = analogEndHz;
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasContinuousFoldOverlap(segments) {
|
||||||
|
const adcRanges = segments.map((segment) => ({
|
||||||
|
minHz: Math.min(
|
||||||
|
Math.abs(segment.signedAdcStartHz),
|
||||||
|
Math.abs(segment.signedAdcEndHz),
|
||||||
|
),
|
||||||
|
maxHz: Math.max(
|
||||||
|
Math.abs(segment.signedAdcStartHz),
|
||||||
|
Math.abs(segment.signedAdcEndHz),
|
||||||
|
),
|
||||||
|
})).sort((left, right) => left.minHz - right.minHz || left.maxHz - right.maxHz);
|
||||||
|
let maximumEndHz = -Infinity;
|
||||||
|
for (const range of adcRanges) {
|
||||||
|
if (range.minHz < maximumEndHz) return true;
|
||||||
|
maximumEndHz = Math.max(maximumEndHz, range.maxHz);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adcBinSetKey(sampleRate, fftSize, ranges) {
|
||||||
|
const rangeKey = ranges
|
||||||
|
.map(({ firstBin, lastBin }) =>
|
||||||
|
firstBin === lastBin ? String(firstBin) : `${firstBin}-${lastBin}`)
|
||||||
|
.join(",");
|
||||||
|
return `fs:${sampleRate}|fft:${fftSize}|bins:${rangeKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a requested pre-sampling frequency interval to the observable one-sided
|
||||||
|
* real FFT bins. Every matching bin is retained; this function does not choose
|
||||||
|
* an alias order or steer with the original carrier.
|
||||||
|
*/
|
||||||
|
export function observeFrequencyBand(band, sampleRate, fftSize) {
|
||||||
|
requireSampleRate(sampleRate);
|
||||||
|
if (!Number.isInteger(fftSize) || fftSize < 2 || fftSize % 2 !== 0) {
|
||||||
|
throw new RangeError("fftSize must be a positive even integer.");
|
||||||
|
}
|
||||||
|
if (!band || typeof band !== "object") {
|
||||||
|
throw new TypeError("band must be an object.");
|
||||||
|
}
|
||||||
|
requireFrequency(band.minHz, "band.minHz");
|
||||||
|
requireFrequency(band.maxHz, "band.maxHz");
|
||||||
|
if (band.minHz >= band.maxHz) {
|
||||||
|
throw new RangeError("band.minHz must be lower than band.maxHz.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const binWidthHz = sampleRate / fftSize;
|
||||||
|
const firstAnalogBin = Math.ceil(band.minHz / binWidthHz);
|
||||||
|
const lastAnalogBin = Math.floor(band.maxHz / binWidthHz);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(firstAnalogBin) ||
|
||||||
|
!Number.isSafeInteger(lastAnalogBin)
|
||||||
|
) {
|
||||||
|
throw new RangeError("band frequency is too large for safe FFT-bin mapping.");
|
||||||
|
}
|
||||||
|
if (firstAnalogBin > lastAnalogBin) {
|
||||||
|
throw new Error(
|
||||||
|
`${band.minHz}–${band.maxHz} Hz contains no FFT-grid frequency at ${binWidthHz} Hz spacing.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bins = [];
|
||||||
|
const halfSize = fftSize / 2;
|
||||||
|
for (let adcBin = 0; adcBin <= halfSize; adcBin += 1) {
|
||||||
|
const positiveResidue = adcBin;
|
||||||
|
const negativeResidue = adcBin === 0 || adcBin === halfSize
|
||||||
|
? adcBin
|
||||||
|
: fftSize - adcBin;
|
||||||
|
if (
|
||||||
|
containsResidue(firstAnalogBin, lastAnalogBin, positiveResidue, fftSize) ||
|
||||||
|
containsResidue(firstAnalogBin, lastAnalogBin, negativeResidue, fftSize)
|
||||||
|
) {
|
||||||
|
bins.push(adcBin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bins.length === 0) {
|
||||||
|
throw new Error(`${band.minHz}–${band.maxHz} Hz maps to no observable FFT bin.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nyquistHz = sampleRate / 2;
|
||||||
|
const adcBinCenterRanges = contiguousBinRanges(bins, binWidthHz);
|
||||||
|
const foldSegments = foldBandSegments(band.minHz, band.maxHz, sampleRate);
|
||||||
|
const analogBinCount = lastAnalogBin - firstAnalogBin + 1;
|
||||||
|
const continuousManyToOne = hasContinuousFoldOverlap(foldSegments);
|
||||||
|
const gridManyToOne = analogBinCount > bins.length;
|
||||||
|
return {
|
||||||
|
model: "ideal-real-sampler-no-antialias-filter",
|
||||||
|
sampleRate,
|
||||||
|
nyquistHz,
|
||||||
|
fftSize,
|
||||||
|
binWidthHz,
|
||||||
|
requestedMinHz: band.minHz,
|
||||||
|
requestedMaxHz: band.maxHz,
|
||||||
|
aliased: band.maxHz > nyquistHz,
|
||||||
|
crossesFirstNyquist: band.minHz < nyquistHz && band.maxHz > nyquistHz,
|
||||||
|
localizationInputModel: "observed-one-sided-real-fft-bins",
|
||||||
|
selectionModel: "fft-bin-center-membership",
|
||||||
|
originalCarrierUsed: false,
|
||||||
|
foldBoundaryCount: Math.max(0, foldSegments.length - 1),
|
||||||
|
continuousManyToOne,
|
||||||
|
gridManyToOne,
|
||||||
|
foldSegments,
|
||||||
|
bins,
|
||||||
|
adcBinCenterRanges,
|
||||||
|
adcBinSetKey: adcBinSetKey(sampleRate, fftSize, adcBinCenterRanges),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { dbToLinear, TAU } from "./math.js";
|
import { ceilSampleIndex, dbToLinear, TAU } from "./math.js";
|
||||||
import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js";
|
import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js";
|
||||||
|
|
||||||
function sampleAsset(signal, assetBuffers, time) {
|
function sampleAsset(signal, assetBuffers, time) {
|
||||||
@@ -15,6 +15,31 @@ function sampleAsset(signal, assetBuffers, time) {
|
|||||||
return channel[left] + (rightValue - channel[left]) * fraction;
|
return channel[left] + (rightValue - channel[left]) * fraction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the interval in which an entry can emit non-zero samples according to
|
||||||
|
* its declared duration and, for a non-looping asset, the decoded channel
|
||||||
|
* duration. Sample contents themselves are intentionally not inspected.
|
||||||
|
*/
|
||||||
|
export function effectiveSignalDurationSeconds(
|
||||||
|
entry,
|
||||||
|
scene,
|
||||||
|
assetBuffers = new Map(),
|
||||||
|
) {
|
||||||
|
const declaredDuration = entry.duration ?? scene.duration;
|
||||||
|
const signal = entry.signal;
|
||||||
|
if (signal?.type !== "asset" || signal.loop) return declaredDuration;
|
||||||
|
const asset = assetBuffers.get(signal.assetId);
|
||||||
|
const channel = asset?.channels?.[signal.channel ?? 0];
|
||||||
|
if (
|
||||||
|
!channel ||
|
||||||
|
!(asset.sampleRate > 0) ||
|
||||||
|
!Number.isFinite(asset.sampleRate)
|
||||||
|
) {
|
||||||
|
return declaredDuration;
|
||||||
|
}
|
||||||
|
return Math.min(declaredDuration, channel.length / asset.sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render one signal entry into the scene-sized output buffer.
|
* Render one signal entry into the scene-sized output buffer.
|
||||||
*
|
*
|
||||||
@@ -25,16 +50,20 @@ function sampleAsset(signal, assetBuffers, time) {
|
|||||||
* Omitting it keeps the historical whole-scene behaviour exactly unchanged.
|
* Omitting it keeps the historical whole-scene behaviour exactly unchanged.
|
||||||
*/
|
*/
|
||||||
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0, timeMapping = {}) {
|
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0, timeMapping = {}) {
|
||||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
|
||||||
const output = new Float32Array(sampleCount);
|
const output = new Float32Array(sampleCount);
|
||||||
const startTime = entry.startTime ?? 0;
|
const startTime = entry.startTime ?? 0;
|
||||||
const duration = entry.duration ?? scene.duration;
|
const duration = entry.duration ?? scene.duration;
|
||||||
const gain = dbToLinear(entry.gainDb ?? 0);
|
const gain = dbToLinear(entry.gainDb ?? 0);
|
||||||
const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate));
|
const startSample = Math.max(0, ceilSampleIndex(startTime, scene.sampleRate));
|
||||||
const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate));
|
const endSample = Math.min(
|
||||||
|
sampleCount,
|
||||||
|
ceilSampleIndex(startTime + duration, scene.sampleRate),
|
||||||
|
);
|
||||||
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
||||||
const signalStartTime = timeMapping.signalStartTime ?? startTime;
|
const signalStartTime = timeMapping.signalStartTime ?? startTime;
|
||||||
const signalStartSample = timeMapping.signalStartSample ?? Math.ceil(startTime * scene.sampleRate);
|
const signalStartSample =
|
||||||
|
timeMapping.signalStartSample ?? ceilSampleIndex(startTime, scene.sampleRate);
|
||||||
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||||
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { fftInPlace } from "./fft.js";
|
||||||
|
|
||||||
|
const DIXON_2006_URL =
|
||||||
|
"https://www.dafx.de/paper-archive/2006/papers/p_133.pdf";
|
||||||
|
const BOECK_KREBS_SCHEDL_2012_URL =
|
||||||
|
"https://www.cp.jku.at/research/papers/Boeck_etal_ISMIR_2012.pdf";
|
||||||
|
export const DEFAULT_MAXIMUM_SPECTRAL_FLUX_OUTPUT_BYTES =
|
||||||
|
64 * 1024 * 1024;
|
||||||
|
|
||||||
|
function requireConfigurationObject(value, label) {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new TypeError(`${label} must be an object.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOwn(configuration, key) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(configuration, key)) {
|
||||||
|
throw new TypeError(`${key} must be provided explicitly.`);
|
||||||
|
}
|
||||||
|
return configuration[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePositiveFinite(value, label) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
throw new RangeError(`${label} must be finite and greater than zero.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireNonNegativeFinite(value, label) {
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
throw new RangeError(`${label} must be finite and non-negative.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireNonNegativeSafeInteger(value, label) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw new RangeError(`${label} must be a non-negative safe integer.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePowerOfTwo(value, label) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < 2 ||
|
||||||
|
value > 0x20000000 ||
|
||||||
|
!Number.isInteger(Math.log2(value))
|
||||||
|
) {
|
||||||
|
throw new RangeError(
|
||||||
|
`${label} must be a power-of-two integer from 2 through 536870912.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSingleChannelSamples(samples) {
|
||||||
|
const isTypedArray = ArrayBuffer.isView(samples) && !(samples instanceof DataView);
|
||||||
|
if (!Array.isArray(samples) && !isTypedArray) {
|
||||||
|
throw new TypeError("samples must be one numeric channel, not a channel collection.");
|
||||||
|
}
|
||||||
|
for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex += 1) {
|
||||||
|
if (typeof samples[sampleIndex] !== "number" || !Number.isFinite(samples[sampleIndex])) {
|
||||||
|
throw new TypeError(`samples[${sampleIndex}] must be finite.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function symmetricHammingWindow(size) {
|
||||||
|
const window = new Float64Array(size);
|
||||||
|
for (let index = 0; index < size; index += 1) {
|
||||||
|
window[index] = 0.54 - 0.46 * Math.cos((2 * Math.PI * index) / (size - 1));
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedElementCount(frameCount, binCount) {
|
||||||
|
const elementCount = frameCount * binCount;
|
||||||
|
if (!Number.isSafeInteger(elementCount)) {
|
||||||
|
throw new RangeError("spectral-flux output element count exceeds safe integer range.");
|
||||||
|
}
|
||||||
|
return elementCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedByteCount(elementCount, bytesPerElement, label) {
|
||||||
|
const byteCount = elementCount * bytesPerElement;
|
||||||
|
if (!Number.isSafeInteger(byteCount)) {
|
||||||
|
throw new RangeError(`${label} byte count exceeds safe integer range.`);
|
||||||
|
}
|
||||||
|
return byteCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateDixonSpectralFluxWorkload(options) {
|
||||||
|
const configuration = requireConfigurationObject(options, "options");
|
||||||
|
const sampleCount = requireNonNegativeSafeInteger(
|
||||||
|
requireOwn(configuration, "sampleCount"),
|
||||||
|
"sampleCount",
|
||||||
|
);
|
||||||
|
const fftSize = requirePowerOfTwo(
|
||||||
|
requireOwn(configuration, "fftSize"),
|
||||||
|
"fftSize",
|
||||||
|
);
|
||||||
|
const hopSize = requireNonNegativeSafeInteger(
|
||||||
|
requireOwn(configuration, "hopSize"),
|
||||||
|
"hopSize",
|
||||||
|
);
|
||||||
|
if (hopSize < 1 || hopSize > fftSize) {
|
||||||
|
throw new RangeError("hopSize must be between 1 and fftSize, inclusive.");
|
||||||
|
}
|
||||||
|
const maximumOutputBytes =
|
||||||
|
configuration.maximumOutputBytes ??
|
||||||
|
DEFAULT_MAXIMUM_SPECTRAL_FLUX_OUTPUT_BYTES;
|
||||||
|
if (!Number.isSafeInteger(maximumOutputBytes) || maximumOutputBytes <= 0) {
|
||||||
|
throw new RangeError("maximumOutputBytes must be a positive safe integer.");
|
||||||
|
}
|
||||||
|
const frameCount = sampleCount < fftSize
|
||||||
|
? 0
|
||||||
|
: 1 + Math.floor((sampleCount - fftSize) / hopSize);
|
||||||
|
const binCount = fftSize;
|
||||||
|
const positiveDifferenceElements =
|
||||||
|
checkedElementCount(frameCount, binCount);
|
||||||
|
const positiveDifferenceBytes = checkedByteCount(
|
||||||
|
positiveDifferenceElements,
|
||||||
|
Float64Array.BYTES_PER_ELEMENT,
|
||||||
|
"positiveDifferences",
|
||||||
|
);
|
||||||
|
const rawOdfBytes = checkedByteCount(
|
||||||
|
frameCount,
|
||||||
|
Float64Array.BYTES_PER_ELEMENT,
|
||||||
|
"rawOdf",
|
||||||
|
);
|
||||||
|
const frameTimingBytes = checkedByteCount(
|
||||||
|
frameCount * 3,
|
||||||
|
Float64Array.BYTES_PER_ELEMENT,
|
||||||
|
"frame timing",
|
||||||
|
);
|
||||||
|
const outputBytes =
|
||||||
|
positiveDifferenceBytes + rawOdfBytes + frameTimingBytes;
|
||||||
|
if (!Number.isSafeInteger(outputBytes)) {
|
||||||
|
throw new RangeError(
|
||||||
|
"spectral-flux output byte count exceeds safe integer range.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (outputBytes > maximumOutputBytes) {
|
||||||
|
throw new RangeError(
|
||||||
|
`spectral flux requires ${outputBytes.toLocaleString()} output bytes; ` +
|
||||||
|
`maximumOutputBytes is ${maximumOutputBytes.toLocaleString()}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const workspaceElements = frameCount === 0 ? 0 : fftSize * 4;
|
||||||
|
const workspaceBytes = checkedByteCount(
|
||||||
|
workspaceElements,
|
||||||
|
Float64Array.BYTES_PER_ELEMENT,
|
||||||
|
"spectral-flux workspace",
|
||||||
|
);
|
||||||
|
const estimatedPeakTypedArrayBytes = outputBytes + workspaceBytes;
|
||||||
|
if (!Number.isSafeInteger(estimatedPeakTypedArrayBytes)) {
|
||||||
|
throw new RangeError(
|
||||||
|
"spectral-flux peak typed-array byte count exceeds safe integer range.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
sampleCount,
|
||||||
|
frameCount,
|
||||||
|
binCount,
|
||||||
|
positiveDifferenceElements,
|
||||||
|
positiveDifferenceBytes,
|
||||||
|
rawOdfBytes,
|
||||||
|
frameTimingBytes,
|
||||||
|
outputBytes,
|
||||||
|
maximumOutputBytes,
|
||||||
|
workspaceBytes,
|
||||||
|
estimatedPeakTypedArrayBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Simon Dixon's 2006 magnitude spectral-flux onset detection function.
|
||||||
|
*
|
||||||
|
* This function accepts exactly one numeric channel. It uses a symmetric
|
||||||
|
* Hamming window, complete non-padded frames, linear DFT magnitudes, positive
|
||||||
|
* (half-wave-rectified) frame differences, and an L1 sum over the full DFT.
|
||||||
|
* No per-frame or global normalization, microphone fusion, filterbank,
|
||||||
|
* maximum filter, location inference, or band inference is performed.
|
||||||
|
*/
|
||||||
|
export function computeDixonSpectralFlux(options) {
|
||||||
|
const configuration = requireConfigurationObject(options, "options");
|
||||||
|
const samples = requireSingleChannelSamples(requireOwn(configuration, "samples"));
|
||||||
|
const sampleRate = requirePositiveFinite(
|
||||||
|
requireOwn(configuration, "sampleRate"),
|
||||||
|
"sampleRate",
|
||||||
|
);
|
||||||
|
const fftSize = requirePowerOfTwo(
|
||||||
|
requireOwn(configuration, "fftSize"),
|
||||||
|
"fftSize",
|
||||||
|
);
|
||||||
|
const hopSize = requireNonNegativeSafeInteger(
|
||||||
|
requireOwn(configuration, "hopSize"),
|
||||||
|
"hopSize",
|
||||||
|
);
|
||||||
|
if (hopSize < 1 || hopSize > fftSize) {
|
||||||
|
throw new RangeError("hopSize must be between 1 and fftSize, inclusive.");
|
||||||
|
}
|
||||||
|
const channelId = requireOwn(configuration, "channelId");
|
||||||
|
if (typeof channelId !== "string" || channelId.length === 0) {
|
||||||
|
throw new TypeError("channelId must be a non-empty string.");
|
||||||
|
}
|
||||||
|
const workload = estimateDixonSpectralFluxWorkload({
|
||||||
|
sampleCount: samples.length,
|
||||||
|
fftSize,
|
||||||
|
hopSize,
|
||||||
|
maximumOutputBytes: configuration.maximumOutputBytes,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
frameCount,
|
||||||
|
binCount,
|
||||||
|
positiveDifferenceElements,
|
||||||
|
positiveDifferenceBytes,
|
||||||
|
rawOdfBytes,
|
||||||
|
frameTimingBytes,
|
||||||
|
outputBytes,
|
||||||
|
maximumOutputBytes,
|
||||||
|
workspaceBytes,
|
||||||
|
estimatedPeakTypedArrayBytes,
|
||||||
|
} = workload;
|
||||||
|
|
||||||
|
const positiveDifferences = new Float64Array(positiveDifferenceElements);
|
||||||
|
const rawOdf = new Float64Array(frameCount);
|
||||||
|
const frameStartSamples = new Float64Array(frameCount);
|
||||||
|
const frameCenterSamples = new Float64Array(frameCount);
|
||||||
|
const frameAvailableAfterSamples = new Float64Array(frameCount);
|
||||||
|
const workspaceLength = frameCount === 0 ? 0 : fftSize;
|
||||||
|
const window = symmetricHammingWindow(workspaceLength);
|
||||||
|
const real = new Float64Array(workspaceLength);
|
||||||
|
const imaginary = new Float64Array(workspaceLength);
|
||||||
|
const previousMagnitudes = new Float64Array(workspaceLength);
|
||||||
|
|
||||||
|
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||||
|
const frameStart = frameIndex * hopSize;
|
||||||
|
frameStartSamples[frameIndex] = frameStart;
|
||||||
|
frameCenterSamples[frameIndex] = frameStart + (fftSize - 1) / 2;
|
||||||
|
frameAvailableAfterSamples[frameIndex] = frameStart + fftSize;
|
||||||
|
imaginary.fill(0);
|
||||||
|
for (let offset = 0; offset < fftSize; offset += 1) {
|
||||||
|
real[offset] = samples[frameStart + offset] * window[offset];
|
||||||
|
}
|
||||||
|
fftInPlace(real, imaginary);
|
||||||
|
|
||||||
|
let frameFlux = 0;
|
||||||
|
const differenceOffset = frameIndex * binCount;
|
||||||
|
for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
|
||||||
|
const magnitude = Math.hypot(real[binIndex], imaginary[binIndex]);
|
||||||
|
if (!Number.isFinite(magnitude)) {
|
||||||
|
throw new RangeError(
|
||||||
|
`STFT magnitude became non-finite at frame ${frameIndex}, bin ${binIndex}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const difference = frameIndex === 0
|
||||||
|
? 0
|
||||||
|
: Math.max(0, magnitude - previousMagnitudes[binIndex]);
|
||||||
|
positiveDifferences[differenceOffset + binIndex] = difference;
|
||||||
|
frameFlux += difference;
|
||||||
|
previousMagnitudes[binIndex] = magnitude;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(frameFlux)) {
|
||||||
|
throw new RangeError(`spectral flux became non-finite at frame ${frameIndex}.`);
|
||||||
|
}
|
||||||
|
rawOdf[frameIndex] = frameFlux;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawOdf,
|
||||||
|
positiveDifferences,
|
||||||
|
positiveDifferenceLayout: {
|
||||||
|
order: "frame-major",
|
||||||
|
frameStride: binCount,
|
||||||
|
binCount,
|
||||||
|
firstFrameHasNoPredecessor: true,
|
||||||
|
},
|
||||||
|
frameCount,
|
||||||
|
binCount,
|
||||||
|
sampleRate,
|
||||||
|
sampleCount: samples.length,
|
||||||
|
fftSize,
|
||||||
|
hopSize,
|
||||||
|
frameStartSamples,
|
||||||
|
frameCenterSamples,
|
||||||
|
frameAvailableAfterSamples,
|
||||||
|
workload: {
|
||||||
|
completeFrameCount: frameCount,
|
||||||
|
transformCount: frameCount,
|
||||||
|
binCount,
|
||||||
|
comparedBinCount: Math.max(0, frameCount - 1) * binCount,
|
||||||
|
positiveDifferenceElements,
|
||||||
|
positiveDifferenceBytes,
|
||||||
|
rawOdfBytes,
|
||||||
|
frameTimingBytes,
|
||||||
|
outputBytes,
|
||||||
|
maximumOutputBytes,
|
||||||
|
workspaceBytes,
|
||||||
|
estimatedPeakTypedArrayBytes,
|
||||||
|
},
|
||||||
|
latency: {
|
||||||
|
completeFramesOnly: true,
|
||||||
|
analysisWindowSamples: fftSize,
|
||||||
|
analysisWindowSeconds: fftSize / sampleRate,
|
||||||
|
updateHopSamples: hopSize,
|
||||||
|
updateHopSeconds: hopSize / sampleRate,
|
||||||
|
firstSpectrumAvailableAfterSamples: frameCount > 0 ? fftSize : null,
|
||||||
|
firstDifferenceAvailableAfterSamples: frameCount > 1 ? fftSize + hopSize : null,
|
||||||
|
timestampConvention:
|
||||||
|
"frameStartSamples/frameCenterSamples are descriptive; frameAvailableAfterSamples is causal availability",
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
algorithm: "dixon-2006-linear-magnitude-half-wave-l1-spectral-flux",
|
||||||
|
reference: {
|
||||||
|
title: "Onset Detection Revisited",
|
||||||
|
author: "Simon Dixon",
|
||||||
|
year: 2006,
|
||||||
|
url: DIXON_2006_URL,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
channelMode: "one-explicit-channel",
|
||||||
|
channelId,
|
||||||
|
microphoneFusion: "none",
|
||||||
|
},
|
||||||
|
stft: {
|
||||||
|
window: "symmetric-hamming",
|
||||||
|
windowFormula: "0.54 - 0.46*cos(2*pi*n/(N-1))",
|
||||||
|
framePolicy: "complete-only-no-padding",
|
||||||
|
spectrumBins: "full-dft-bin-order-including-the-real-signal-negative-frequency-mirror",
|
||||||
|
},
|
||||||
|
magnitudeScale: "linear",
|
||||||
|
difference: "positive-only-half-wave-rectified",
|
||||||
|
frequencyAggregation: "unweighted-l1-sum-over-full-dft",
|
||||||
|
spectrumNormalization: "none",
|
||||||
|
globalNormalization: "none",
|
||||||
|
},
|
||||||
|
measurementSemantics: {
|
||||||
|
kind: "raw-onset-novelty",
|
||||||
|
sustainedActivityMeasure: false,
|
||||||
|
initialFrameValue: "zero-because-no-preceding-spectrum-exists",
|
||||||
|
},
|
||||||
|
limitations: {
|
||||||
|
steadySound:
|
||||||
|
"After the initial spectral rise, an exactly steady spectrum produces zero flux; finite precision and noise can leave small positive values.",
|
||||||
|
excludedInferences: [
|
||||||
|
"event probability",
|
||||||
|
"sustained activity duration",
|
||||||
|
"source count",
|
||||||
|
"source location",
|
||||||
|
"automatically selected frequency band",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the causal online peak-picking conditions evaluated by Böck, Krebs,
|
||||||
|
* and Schedl (2012). All operating parameters are required from the caller.
|
||||||
|
* The local-maximum and adaptive-mean windows contain only the current and
|
||||||
|
* past frames; no future frames or global normalization are used.
|
||||||
|
*/
|
||||||
|
export function pickCausalSpectralFluxPeaks(rawOdfInput, options) {
|
||||||
|
const rawOdfSource = requireSingleChannelSamples(rawOdfInput);
|
||||||
|
const configuration = requireConfigurationObject(options, "options");
|
||||||
|
const delta = requireNonNegativeFinite(requireOwn(configuration, "delta"), "delta");
|
||||||
|
const preMaxFrames = requireNonNegativeSafeInteger(
|
||||||
|
requireOwn(configuration, "preMaxFrames"),
|
||||||
|
"preMaxFrames",
|
||||||
|
);
|
||||||
|
const preMeanFrames = requireNonNegativeSafeInteger(
|
||||||
|
requireOwn(configuration, "preMeanFrames"),
|
||||||
|
"preMeanFrames",
|
||||||
|
);
|
||||||
|
const refractoryFrames = requireNonNegativeSafeInteger(
|
||||||
|
requireOwn(configuration, "refractoryFrames"),
|
||||||
|
"refractoryFrames",
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawOdf = Float64Array.from(rawOdfSource);
|
||||||
|
for (let frameIndex = 0; frameIndex < rawOdf.length; frameIndex += 1) {
|
||||||
|
if (rawOdf[frameIndex] < 0) {
|
||||||
|
throw new RangeError(`rawOdfInput[${frameIndex}] must be non-negative.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const adaptiveThresholds = new Float64Array(rawOdf.length);
|
||||||
|
const peakMask = new Uint8Array(rawOdf.length);
|
||||||
|
const peakFrameIndices = [];
|
||||||
|
const peaks = [];
|
||||||
|
let lastPeakFrame = null;
|
||||||
|
let runningMeanWindowSum = 0;
|
||||||
|
|
||||||
|
for (let frameIndex = 0; frameIndex < rawOdf.length; frameIndex += 1) {
|
||||||
|
const current = rawOdf[frameIndex];
|
||||||
|
runningMeanWindowSum += current;
|
||||||
|
const expiredMeanIndex = frameIndex - preMeanFrames - 1;
|
||||||
|
if (expiredMeanIndex >= 0) runningMeanWindowSum -= rawOdf[expiredMeanIndex];
|
||||||
|
const meanWindowStart = Math.max(0, frameIndex - preMeanFrames);
|
||||||
|
const meanWindowLength = frameIndex - meanWindowStart + 1;
|
||||||
|
const localMean = runningMeanWindowSum / meanWindowLength;
|
||||||
|
const adaptiveThreshold = localMean + delta;
|
||||||
|
if (!Number.isFinite(adaptiveThreshold)) {
|
||||||
|
throw new RangeError(`adaptive threshold became non-finite at frame ${frameIndex}.`);
|
||||||
|
}
|
||||||
|
adaptiveThresholds[frameIndex] = adaptiveThreshold;
|
||||||
|
|
||||||
|
const maxWindowStart = Math.max(0, frameIndex - preMaxFrames);
|
||||||
|
let isPastLocalMaximum = true;
|
||||||
|
for (
|
||||||
|
let comparisonIndex = maxWindowStart;
|
||||||
|
comparisonIndex < frameIndex;
|
||||||
|
comparisonIndex += 1
|
||||||
|
) {
|
||||||
|
if (rawOdf[comparisonIndex] > current) {
|
||||||
|
isPastLocalMaximum = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clearsAdaptiveThreshold = current >= adaptiveThreshold;
|
||||||
|
const clearsRefractory =
|
||||||
|
lastPeakFrame === null || frameIndex - lastPeakFrame > refractoryFrames;
|
||||||
|
if (isPastLocalMaximum && clearsAdaptiveThreshold && clearsRefractory) {
|
||||||
|
peakMask[frameIndex] = 1;
|
||||||
|
peakFrameIndices.push(frameIndex);
|
||||||
|
peaks.push({
|
||||||
|
frameIndex,
|
||||||
|
rawOdf: current,
|
||||||
|
adaptiveThreshold,
|
||||||
|
currentAndPastLocalMean: localMean,
|
||||||
|
});
|
||||||
|
lastPeakFrame = frameIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawOdf,
|
||||||
|
adaptiveThresholds,
|
||||||
|
peakMask,
|
||||||
|
peakFrameIndices: Uint32Array.from(peakFrameIndices),
|
||||||
|
peaks,
|
||||||
|
configuration: {
|
||||||
|
delta,
|
||||||
|
preMaxFrames,
|
||||||
|
preMeanFrames,
|
||||||
|
refractoryFrames,
|
||||||
|
postMaxFrames: 0,
|
||||||
|
postMeanFrames: 0,
|
||||||
|
},
|
||||||
|
latency: {
|
||||||
|
causal: true,
|
||||||
|
futureContextFrames: 0,
|
||||||
|
addedLookaheadFrames: 0,
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
algorithm: "boeck-krebs-schedl-2012-causal-online-peak-picker",
|
||||||
|
reference: {
|
||||||
|
title: "Evaluating the Online Capabilities of Onset Detection Methods",
|
||||||
|
authors: ["Sebastian Böck", "Florian Krebs", "Markus Schedl"],
|
||||||
|
year: 2012,
|
||||||
|
url: BOECK_KREBS_SCHEDL_2012_URL,
|
||||||
|
},
|
||||||
|
localMaximum: "current-equals-maximum-of-current-and-available-past-window",
|
||||||
|
adaptiveThreshold: "mean-of-current-and-available-past-window-plus-delta",
|
||||||
|
refractoryCondition: "currentFrame-minus-lastPeakFrame-strictly-greater-than-refractoryFrames",
|
||||||
|
edgePolicy: "truncate-windows-to-available-past",
|
||||||
|
globalNormalization: "none",
|
||||||
|
},
|
||||||
|
measurementSemantics: {
|
||||||
|
kind: "thresholded-onset-events-from-raw-novelty",
|
||||||
|
deltaMeaning: "caller-selected-detection-operating-offset",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fftInPlace } from "./fft.js";
|
import { fftInPlace } from "./fft.js";
|
||||||
import { cooperativeYield } from "./cooperative-yield.js";
|
import { cooperativeYield } from "./cooperative-yield.js";
|
||||||
|
import { observeFrequencyBand } from "./sampling.js";
|
||||||
|
|
||||||
function throwIfAborted(signal) {
|
function throwIfAborted(signal) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
@@ -51,6 +52,17 @@ export function getFrameCount(sampleCount, fftSize, hopSize) {
|
|||||||
return Math.max(1, 1 + Math.ceil(Math.max(0, sampleCount - fftSize) / hopSize));
|
return Math.max(1, 1 + Math.ceil(Math.max(0, sampleCount - fftSize) / hopSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCompleteFrameCount(sampleCount, fftSize, hopSize) {
|
||||||
|
if (!Number.isInteger(hopSize) || hopSize < 1 || hopSize > fftSize) {
|
||||||
|
throw new Error("hopSize는 1 이상 fftSize 이하여야 합니다.");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(sampleCount) || sampleCount < 0) {
|
||||||
|
throw new RangeError("sampleCount must be a non-negative integer.");
|
||||||
|
}
|
||||||
|
if (sampleCount < fftSize) return 0;
|
||||||
|
return 1 + Math.floor((sampleCount - fftSize) / hopSize);
|
||||||
|
}
|
||||||
|
|
||||||
export async function extractStftBins(channels, options) {
|
export async function extractStftBins(channels, options) {
|
||||||
const {
|
const {
|
||||||
fftSize,
|
fftSize,
|
||||||
@@ -60,6 +72,8 @@ export async function extractStftBins(channels, options) {
|
|||||||
onProgress,
|
onProgress,
|
||||||
transformBatchSize = 32,
|
transformBatchSize = 32,
|
||||||
workspace: providedWorkspace,
|
workspace: providedWorkspace,
|
||||||
|
spectrumPrecision = "float32",
|
||||||
|
includePartialFrame = true,
|
||||||
} = options;
|
} = options;
|
||||||
if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다.");
|
if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다.");
|
||||||
const sampleCount = channels[0].length;
|
const sampleCount = channels[0].length;
|
||||||
@@ -69,16 +83,27 @@ export async function extractStftBins(channels, options) {
|
|||||||
if (!Number.isInteger(bin) || bin < 0 || bin > fftSize / 2) throw new Error(`유효하지 않은 FFT bin: ${bin}`);
|
if (!Number.isInteger(bin) || bin < 0 || bin > fftSize / 2) throw new Error(`유효하지 않은 FFT bin: ${bin}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameCount = getFrameCount(sampleCount, fftSize, hopSize);
|
if (typeof includePartialFrame !== "boolean") {
|
||||||
|
throw new TypeError("includePartialFrame must be boolean.");
|
||||||
|
}
|
||||||
|
const frameCount = includePartialFrame
|
||||||
|
? getFrameCount(sampleCount, fftSize, hopSize)
|
||||||
|
: getCompleteFrameCount(sampleCount, fftSize, hopSize);
|
||||||
const microphoneCount = channels.length;
|
const microphoneCount = channels.length;
|
||||||
if (!Number.isInteger(transformBatchSize) || transformBatchSize < 1) {
|
if (!Number.isInteger(transformBatchSize) || transformBatchSize < 1) {
|
||||||
throw new Error("transformBatchSize는 1 이상의 정수여야 합니다.");
|
throw new Error("transformBatchSize는 1 이상의 정수여야 합니다.");
|
||||||
}
|
}
|
||||||
|
if (spectrumPrecision !== "float32" && spectrumPrecision !== "float64") {
|
||||||
|
throw new RangeError('spectrumPrecision must be "float32" or "float64".');
|
||||||
|
}
|
||||||
|
const SpectrumArray = spectrumPrecision === "float64"
|
||||||
|
? Float64Array
|
||||||
|
: Float32Array;
|
||||||
const spectra = new Map();
|
const spectra = new Map();
|
||||||
for (const bin of uniqueBins) {
|
for (const bin of uniqueBins) {
|
||||||
spectra.set(bin, {
|
spectra.set(bin, {
|
||||||
real: new Float32Array(frameCount * microphoneCount),
|
real: new SpectrumArray(frameCount * microphoneCount),
|
||||||
imaginary: new Float32Array(frameCount * microphoneCount),
|
imaginary: new SpectrumArray(frameCount * microphoneCount),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,15 +140,9 @@ export async function extractStftBins(channels, options) {
|
|||||||
}
|
}
|
||||||
onProgress?.({ completedTransforms, totalTransforms });
|
onProgress?.({ completedTransforms, totalTransforms });
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
return { frameCount, microphoneCount, spectra };
|
return { frameCount, microphoneCount, spectra, spectrumPrecision };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function binsForBand(band, sampleRate, fftSize) {
|
export function binsForBand(band, sampleRate, fftSize) {
|
||||||
const binWidth = sampleRate / fftSize;
|
return observeFrequencyBand(band, sampleRate, fftSize).bins;
|
||||||
const firstBin = Math.max(0, Math.ceil(band.minHz / binWidth));
|
|
||||||
const lastBin = Math.min(fftSize / 2, Math.floor(band.maxHz / binWidth));
|
|
||||||
if (firstBin > lastBin) {
|
|
||||||
throw new Error(`${band.minHz}–${band.maxHz} Hz에 해당하는 FFT bin이 없습니다.`);
|
|
||||||
}
|
|
||||||
return Array.from({ length: lastBin - firstBin + 1 }, (_, index) => firstBin + index);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||||
|
selectAcousticPathSummary,
|
||||||
|
summarizeAcousticPaths,
|
||||||
|
} from "./environment.js";
|
||||||
|
import { ceilSampleIndex } from "./math.js";
|
||||||
|
import { effectiveSignalDurationSeconds } from "./signal.js";
|
||||||
|
|
||||||
function copyPosition(position) {
|
function copyPosition(position) {
|
||||||
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
|
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
|
||||||
throw new TypeError("source position must be a finite [x, y, z] array.");
|
throw new TypeError("source position must be a finite [x, y, z] array.");
|
||||||
@@ -65,7 +73,7 @@ export function sceneAtTime(scene, time) {
|
|||||||
* Approximate a moving scene as stationary during [startTime, endTime] by
|
* Approximate a moving scene as stationary during [startTime, endTime] by
|
||||||
* sampling every trajectory at the window midpoint.
|
* sampling every trajectory at the window midpoint.
|
||||||
*/
|
*/
|
||||||
export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {}) {
|
||||||
requireFiniteTime(startTime, "startTime");
|
requireFiniteTime(startTime, "startTime");
|
||||||
requireFiniteTime(endTime, "endTime");
|
requireFiniteTime(endTime, "endTime");
|
||||||
if (startTime < 0 || endTime <= startTime) {
|
if (startTime < 0 || endTime <= startTime) {
|
||||||
@@ -90,40 +98,146 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
|||||||
// boundary. Adjacent windows therefore share an exact boundary without a
|
// boundary. Adjacent windows therefore share an exact boundary without a
|
||||||
// duplicated or omitted sample, even when their requested times are not
|
// duplicated or omitted sample, even when their requested times are not
|
||||||
// integer multiples of the sample period.
|
// integer multiples of the sample period.
|
||||||
const startSample = Math.ceil(startTime * sampleRate);
|
const startSample = ceilSampleIndex(startTime, sampleRate);
|
||||||
const endSample = Math.ceil(endTime * sampleRate);
|
const endSample = ceilSampleIndex(endTime, sampleRate);
|
||||||
if (endSample <= startSample) {
|
if (endSample <= startSample) {
|
||||||
throw new RangeError("window must contain at least one sample.");
|
throw new RangeError("window must contain at least one sample.");
|
||||||
}
|
}
|
||||||
const alignedStartTime = startSample / sampleRate;
|
const alignedStartTime = startSample / sampleRate;
|
||||||
const alignedEndTime = endSample / sampleRate;
|
const alignedEndTime = endSample / sampleRate;
|
||||||
const sampleTime = (startSample + endSample) / (2 * sampleRate);
|
const sampleTime = (startSample + endSample) / (2 * sampleRate);
|
||||||
|
const sampledScene = sceneAtTime(scene, sampleTime);
|
||||||
|
const pathSummary = options.arrayDefinition
|
||||||
|
? summarizeAcousticPaths({
|
||||||
|
scene: sampledScene,
|
||||||
|
arrayDefinition: options.arrayDefinition,
|
||||||
|
referenceDistance: options.referenceDistance ?? 1,
|
||||||
|
minimumDistance: options.minimumDistance ?? 0.01,
|
||||||
|
maximumPathEvaluations:
|
||||||
|
options.maximumPathEvaluations ??
|
||||||
|
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
let maximumPropagationDelaySeconds = options.maximumPropagationDelaySeconds;
|
||||||
|
if (maximumPropagationDelaySeconds === undefined) {
|
||||||
|
maximumPropagationDelaySeconds = pathSummary?.maximumDelaySeconds ?? 0;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isFinite(maximumPropagationDelaySeconds) ||
|
||||||
|
maximumPropagationDelaySeconds < 0
|
||||||
|
) {
|
||||||
|
throw new RangeError("maximumPropagationDelaySeconds must be finite and non-negative.");
|
||||||
|
}
|
||||||
|
const requestedPreRollSamples = ceilSampleIndex(
|
||||||
|
maximumPropagationDelaySeconds,
|
||||||
|
sampleRate,
|
||||||
|
);
|
||||||
|
const requiredPreRollSamples = pathSummary
|
||||||
|
? ceilSampleIndex(pathSummary.maximumDelaySeconds, sampleRate)
|
||||||
|
: 0;
|
||||||
|
if (pathSummary && requestedPreRollSamples < requiredPreRollSamples) {
|
||||||
|
throw new RangeError(
|
||||||
|
"maximumPropagationDelaySeconds is shorter than an active acoustic path delay.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const renderMaximumPropagationDelaySeconds = pathSummary
|
||||||
|
? pathSummary.sources.reduce(
|
||||||
|
(maximum, sourceSummary, sourceIndex) =>
|
||||||
|
sampledScene.sources[sourceIndex].signal.type === "tone"
|
||||||
|
? maximum
|
||||||
|
: Math.max(maximum, sourceSummary.maximumDelaySeconds),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: maximumPropagationDelaySeconds;
|
||||||
|
const preRollSamples = ceilSampleIndex(
|
||||||
|
renderMaximumPropagationDelaySeconds,
|
||||||
|
sampleRate,
|
||||||
|
);
|
||||||
|
const renderStartSample = Math.max(0, startSample - preRollSamples);
|
||||||
|
const renderDuration = (endSample - renderStartSample) / sampleRate;
|
||||||
const sources = [];
|
const sources = [];
|
||||||
const sourceOffsets = [];
|
const sourceOffsets = [];
|
||||||
|
const sourceIndexes = [];
|
||||||
|
|
||||||
for (const source of scene.sources) {
|
for (let sourceIndex = 0; sourceIndex < scene.sources.length; sourceIndex += 1) {
|
||||||
|
const source = scene.sources[sourceIndex];
|
||||||
const signalStartTime = source.startTime ?? 0;
|
const signalStartTime = source.startTime ?? 0;
|
||||||
const signalDuration = source.duration ?? scene.duration;
|
const signalDuration = effectiveSignalDurationSeconds(
|
||||||
const signalStartSample = Math.ceil(signalStartTime * sampleRate);
|
source,
|
||||||
const signalEndSample = Math.ceil((signalStartTime + signalDuration) * sampleRate);
|
scene,
|
||||||
const activeStartSample = Math.max(startSample, signalStartSample);
|
options.assetBuffers,
|
||||||
|
);
|
||||||
|
const signalStartSample = ceilSampleIndex(signalStartTime, sampleRate);
|
||||||
|
const signalEndSample = ceilSampleIndex(signalStartTime + signalDuration, sampleRate);
|
||||||
|
const activeStartSample = Math.max(renderStartSample, signalStartSample);
|
||||||
const activeEndSample = Math.min(endSample, signalEndSample);
|
const activeEndSample = Math.min(endSample, signalEndSample);
|
||||||
if (activeEndSample <= activeStartSample) continue;
|
const sourceMaximumDelaySeconds =
|
||||||
|
pathSummary?.sources[sourceIndex]?.maximumDelaySeconds ??
|
||||||
|
maximumPropagationDelaySeconds;
|
||||||
|
const latestPossibleArrivalEndSample = ceilSampleIndex(
|
||||||
|
signalStartTime + signalDuration + sourceMaximumDelaySeconds,
|
||||||
|
sampleRate,
|
||||||
|
);
|
||||||
|
const analyticTailMayOverlap =
|
||||||
|
source.signal.type === "tone" &&
|
||||||
|
signalStartSample < endSample &&
|
||||||
|
latestPossibleArrivalEndSample > startSample;
|
||||||
|
if (activeEndSample <= activeStartSample && !analyticTailMayOverlap) continue;
|
||||||
|
const hasEmissionInRender = activeEndSample > activeStartSample;
|
||||||
|
|
||||||
sources.push({
|
sources.push({
|
||||||
...source,
|
...source,
|
||||||
position: sourcePositionAtTime(source, sampleTime),
|
position: sampledScene.sources[sourceIndex].position,
|
||||||
startTime: (activeStartSample - startSample) / sampleRate,
|
startTime: hasEmissionInRender
|
||||||
duration: (activeEndSample - activeStartSample) / sampleRate,
|
? (activeStartSample - renderStartSample) / sampleRate
|
||||||
|
: 0,
|
||||||
|
duration: hasEmissionInRender
|
||||||
|
? (activeEndSample - activeStartSample) / sampleRate
|
||||||
|
: renderDuration,
|
||||||
});
|
});
|
||||||
sourceOffsets.push({ signalStartTime, signalStartSample });
|
sourceOffsets.push({
|
||||||
|
signalStartTime,
|
||||||
|
signalStartSample,
|
||||||
|
signalDuration,
|
||||||
|
});
|
||||||
|
sourceIndexes.push(sourceIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
|
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
|
||||||
|
const windowScene = {
|
||||||
|
...scene,
|
||||||
|
duration: renderDuration,
|
||||||
|
sources,
|
||||||
|
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
||||||
|
};
|
||||||
|
const selectedPathSummary = pathSummary
|
||||||
|
? selectAcousticPathSummary({
|
||||||
|
summary: pathSummary,
|
||||||
|
sourceIndexes,
|
||||||
|
sourceScene: sampledScene,
|
||||||
|
scene: windowScene,
|
||||||
|
arrayDefinition: options.arrayDefinition,
|
||||||
|
referenceDistance: options.referenceDistance ?? 1,
|
||||||
|
minimumDistance: options.minimumDistance ?? 0.01,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
const signalOffsets = {
|
const signalOffsets = {
|
||||||
sceneSampleOffset: startSample,
|
sceneSampleOffset: renderStartSample,
|
||||||
|
cropStartSample: startSample - renderStartSample,
|
||||||
|
cropSampleCount: endSample - startSample,
|
||||||
|
maximumPropagationDelaySeconds,
|
||||||
|
renderMaximumPropagationDelaySeconds,
|
||||||
|
sourcePositionSampleTime: sampleTime,
|
||||||
|
windowingModel: "quasi-static-midpoint-propagation-preroll",
|
||||||
|
...(selectedPathSummary
|
||||||
|
? { acousticPathSummary: selectedPathSummary }
|
||||||
|
: {}),
|
||||||
sources: sourceOffsets,
|
sources: sourceOffsets,
|
||||||
ambient: (scene.ambient ?? []).map(() => ({ signalStartTime: 0, signalStartSample: 0 })),
|
ambient: (scene.ambient ?? []).map(() => ({
|
||||||
|
signalStartTime: 0,
|
||||||
|
signalStartSample: 0,
|
||||||
|
signalDuration: scene.duration,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
const duration = (endSample - startSample) / sampleRate;
|
const duration = (endSample - startSample) / sampleRate;
|
||||||
return {
|
return {
|
||||||
@@ -134,13 +248,13 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
|||||||
endSample,
|
endSample,
|
||||||
sampleCount: endSample - startSample,
|
sampleCount: endSample - startSample,
|
||||||
duration,
|
duration,
|
||||||
|
renderStartSample,
|
||||||
|
renderDuration,
|
||||||
|
preRollSamples: startSample - renderStartSample,
|
||||||
|
maximumPropagationDelaySeconds,
|
||||||
|
renderMaximumPropagationDelaySeconds,
|
||||||
signalOffsets,
|
signalOffsets,
|
||||||
scene: {
|
scene: windowScene,
|
||||||
...scene,
|
|
||||||
duration,
|
|
||||||
sources,
|
|
||||||
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +284,7 @@ export function createQuasiStaticWindows(scene, options = {}) {
|
|||||||
const windows = [];
|
const windows = [];
|
||||||
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
|
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
|
||||||
const windowEnd = Math.min(windowStart + windowDuration, endTime);
|
const windowEnd = Math.min(windowStart + windowDuration, endTime);
|
||||||
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd));
|
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd, options));
|
||||||
}
|
}
|
||||||
return windows;
|
return windows;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
# Contracts
|
# Contracts
|
||||||
|
|
||||||
네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제
|
네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제
|
||||||
JSON Schema를 실행하고, `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼
|
JSON Schema를 실행하고, `validation.js`는 중복 id, 시간 구간, 참조 무결성처럼
|
||||||
스키마만으로 표현하기 불편한 조건을 추가 검사합니다.
|
스키마만으로 표현하기 불편한 조건을 추가 검사합니다.
|
||||||
|
|
||||||
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
|
Nyquist 초과 tone과 Experiment band는 거절하지 않습니다. acoustics core가 anti-alias filter 없는
|
||||||
|
ideal ADC alias를 명시적으로 계산하고 Result에 원/관측 주파수와 선택 bin을 기록합니다.
|
||||||
|
|
||||||
|
Array·Scene·Experiment 계약 버전은
|
||||||
|
`INPUT_SCHEMA_VERSION = "1.0.0"`, sampling provenance가 필수인 Result 계약은
|
||||||
|
`RESULT_SCHEMA_VERSION = "1.1.0"`입니다. 두 버전은 `validation.js`에서 별도 상수로
|
||||||
|
공개합니다. 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
|
||||||
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
|
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
|
||||||
|
|
||||||
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
|
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { computeDasMap } from "../acoustics-core/das.js";
|
import { computeDasMap } from "../acoustics-core/das.js";
|
||||||
import { computeGccPhatMap } from "../acoustics-core/gcc-phat.js";
|
import { computeGccPhatMap } from "../acoustics-core/gcc-phat.js";
|
||||||
|
import { computeLocalizationMap } from "../acoustics-core/localization.js";
|
||||||
import {
|
import {
|
||||||
isDasAlgorithm,
|
isDasAlgorithm,
|
||||||
requireLocalizationAlgorithmDefinition,
|
requireLocalizationAlgorithmDefinition,
|
||||||
@@ -79,9 +80,10 @@ export async function computeDasWithBackend(options, dependencies = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute any registered localization algorithm. GCC-PHAT currently uses its
|
* Execute any registered localization algorithm. CPU-only algorithms use
|
||||||
* portable CPU implementation for both `auto` and `cpu`; an explicit WebGPU
|
* their portable implementation for both `auto` and `cpu`; an explicit
|
||||||
* request is rejected instead of silently changing the requested backend.
|
* WebGPU request is rejected instead of silently changing the requested
|
||||||
|
* backend.
|
||||||
*/
|
*/
|
||||||
export async function computeLocalizationWithBackend(options, dependencies = {}) {
|
export async function computeLocalizationWithBackend(options, dependencies = {}) {
|
||||||
const algorithm = requireLocalizationAlgorithmDefinition(
|
const algorithm = requireLocalizationAlgorithmDefinition(
|
||||||
@@ -90,7 +92,7 @@ export async function computeLocalizationWithBackend(options, dependencies = {})
|
|||||||
if (isDasAlgorithm(algorithm.id)) {
|
if (isDasAlgorithm(algorithm.id)) {
|
||||||
return computeDasBackend(options, dependencies);
|
return computeDasBackend(options, dependencies);
|
||||||
}
|
}
|
||||||
if (algorithm.id !== "gcc-phat") {
|
if (algorithm.id !== "gcc-phat" && algorithm.id !== "music") {
|
||||||
throw new RangeError("No backend dispatcher is registered for " + algorithm.id + ".");
|
throw new RangeError("No backend dispatcher is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,9 +105,8 @@ export async function computeLocalizationWithBackend(options, dependencies = {})
|
|||||||
if (requestedBackend !== "auto" && requestedBackend !== "cpu") {
|
if (requestedBackend !== "auto" && requestedBackend !== "cpu") {
|
||||||
throw new TypeError(`unsupported localization backend: ${requestedBackend}`);
|
throw new TypeError(`unsupported localization backend: ${requestedBackend}`);
|
||||||
}
|
}
|
||||||
const computeCpu =
|
const computeCpu = algorithm.id === "gcc-phat"
|
||||||
dependencies.computeGccCpu ??
|
? dependencies.computeGccCpu ?? dependencies.computeCpu ?? computeGccPhatMap
|
||||||
dependencies.computeCpu ??
|
: dependencies.computeMusicCpu ?? dependencies.computeCpu ?? computeLocalizationMap;
|
||||||
computeGccPhatMap;
|
|
||||||
return computeCpu(options);
|
return computeCpu(options);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -9,11 +9,16 @@ JSON Schema Draft 2020-12 형식입니다.
|
|||||||
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
|
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
|
||||||
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
|
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
|
||||||
|
|
||||||
마이크 주파수 응답점은 배열 계약 자체이므로 특정 Scene의 Nyquist와 결합하지 않고 0~1 MHz의
|
마이크 주파수 응답점과 Scene tone·Experiment band는 특정 Scene의 Nyquist에서 자르지 않고
|
||||||
안전 범위에서 엄격한 오름차순을 요구합니다. 실제 분석에서는 선택한 Scene/STFT의 bin 주파수만
|
0~1 MHz의 명시적 수치/마이크 모델 검증 범위를 사용합니다. Tone 합성에서는 원 analog 주파수,
|
||||||
보간합니다. 중복 id, Nyquist, 시간 구간과 참조 무결성은
|
sampled asset 분석에서는 선택한 Scene/STFT의 baseband bin 주파수로 보간합니다. 중복 id,
|
||||||
|
sampling alias mapping,
|
||||||
|
시간 구간과 참조 무결성은
|
||||||
`packages/contracts/validation.js`에서 추가로 검증합니다.
|
`packages/contracts/validation.js`에서 추가로 검증합니다.
|
||||||
|
|
||||||
|
Array·Scene·Experiment는 1.0.0, sampling provenance와 band별 fold/localization metadata를
|
||||||
|
필수화한 Result는 1.1.0입니다. Result 1.0을 묵시적으로 변환하지 않습니다.
|
||||||
|
|
||||||
Scene `acoustics`는 기본 `free-field` 또는 `shoebox`입니다. shoebox는 room bounds, 0/1차
|
Scene `acoustics`는 기본 `free-field` 또는 `shoebox`입니다. shoebox는 room bounds, 0/1차
|
||||||
반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다.
|
반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다.
|
||||||
room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에
|
room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"type": "object", "additionalProperties": false, "required": ["id", "minHz", "maxHz", "color"],
|
"type": "object", "additionalProperties": false, "required": ["id", "minHz", "maxHz", "color"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
|
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
|
||||||
"minHz": { "type": "number", "minimum": 0 }, "maxHz": { "type": "number", "exclusiveMinimum": 0 },
|
"minHz": { "type": "number", "minimum": 0, "maximum": 1000000 },
|
||||||
|
"maxHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 },
|
||||||
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }
|
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,20 +43,25 @@
|
|||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["algorithm", "backend", "minimumDistance", "normalization", "dynamicRangeDb"],
|
"required": ["algorithm", "backend", "minimumDistance", "normalization", "dynamicRangeDb"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"algorithm": { "enum": ["das", "das-phat", "gcc-phat"] },
|
"algorithm": { "enum": ["das", "das-phat", "gcc-phat", "music"] },
|
||||||
"backend": { "enum": ["auto", "cpu", "webgpu"] },
|
"backend": { "enum": ["auto", "cpu", "webgpu"] },
|
||||||
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
|
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
|
||||||
"normalization": { "enum": ["peak", "none"] },
|
"normalization": { "enum": ["peak", "none"] },
|
||||||
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 },
|
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 },
|
||||||
"compensateFrequencyResponse": { "type": "boolean" },
|
"compensateFrequencyResponse": { "type": "boolean" },
|
||||||
"maxCompensationDb": { "type": "number", "minimum": 0, "maximum": 120 }
|
"maxCompensationDb": { "type": "number", "minimum": 0, "maximum": 120 },
|
||||||
|
"musicSourceCountMethod": { "enum": ["fixed", "mdl", "aic"] },
|
||||||
|
"musicSourceCount": { "type": "integer", "minimum": 1, "maximum": 1023 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"evaluation": {
|
"evaluation": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["detectionThresholdRatio"],
|
"required": ["detectionThresholdRatio"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
"detectionThresholdRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
||||||
|
"detectionMethod": {
|
||||||
|
"enum": ["local-max", "connected-components", "watershed"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+434
-10
@@ -5,22 +5,22 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": [
|
"required": [
|
||||||
"schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "backend",
|
"schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "algorithm", "backend",
|
||||||
"inputHash", "signalStats", "timingMs", "bands"
|
"inputHash", "signalStats", "timingMs", "bands"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"schemaVersion": { "const": "1.0.0" },
|
"schemaVersion": { "const": "1.1.0" },
|
||||||
"kind": { "const": "micarrsim.result" },
|
"kind": { "const": "micarrsim.result" },
|
||||||
"experimentId": { "type": "string", "minLength": 1 },
|
"experimentId": { "type": "string", "minLength": 1 },
|
||||||
"arrayId": { "type": "string", "minLength": 1 },
|
"arrayId": { "type": "string", "minLength": 1 },
|
||||||
"sceneId": { "type": "string", "minLength": 1 },
|
"sceneId": { "type": "string", "minLength": 1 },
|
||||||
"createdAt": { "type": "string", "format": "date-time" },
|
"createdAt": { "type": "string", "format": "date-time" },
|
||||||
"algorithm": { "enum": ["das", "das-phat", "gcc-phat"] },
|
"algorithm": { "enum": ["das", "das-phat", "gcc-phat", "music"] },
|
||||||
"backend": { "enum": ["cpu", "webgpu"] },
|
"backend": { "enum": ["cpu", "webgpu"] },
|
||||||
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||||
"signalStats": {
|
"signalStats": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount"],
|
"required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount", "sampling"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"preNormalizationPeak": { "type": "number", "minimum": 0 },
|
"preNormalizationPeak": { "type": "number", "minimum": 0 },
|
||||||
"appliedScale": { "type": "number", "minimum": 0 },
|
"appliedScale": { "type": "number", "minimum": 0 },
|
||||||
@@ -36,11 +36,13 @@
|
|||||||
"diffraction": { "type": "integer", "minimum": 0 },
|
"diffraction": { "type": "integer", "minimum": 0 },
|
||||||
"diffuse": { "type": "integer", "minimum": 0 }
|
"diffuse": { "type": "integer", "minimum": 0 }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"sampling": { "$ref": "#/$defs/sampling" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timingMs": {
|
"timingMs": {
|
||||||
"type": "object", "additionalProperties": false, "required": ["total", "beamforming"],
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["total", "localization", "beamforming"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"total": { "type": "number", "minimum": 0 },
|
"total": { "type": "number", "minimum": 0 },
|
||||||
"signalGeneration": { "type": "number", "minimum": 0 },
|
"signalGeneration": { "type": "number", "minimum": 0 },
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"environment": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
|
"environment": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
|
||||||
|
"musicModel": { "$ref": "#/$defs/musicModel" },
|
||||||
"bands": {
|
"bands": {
|
||||||
"type": "array", "minItems": 1, "maxItems": 8,
|
"type": "array", "minItems": 1, "maxItems": 8,
|
||||||
"items": { "$ref": "#/$defs/bandResult" }
|
"items": { "$ref": "#/$defs/bandResult" }
|
||||||
@@ -60,13 +63,147 @@
|
|||||||
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
|
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
|
||||||
"items": false, "minItems": 3, "maxItems": 3
|
"items": false, "minItems": 3, "maxItems": 3
|
||||||
},
|
},
|
||||||
|
"sampling": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["model", "sampleRate", "nyquistHz", "toneObservations"],
|
||||||
|
"properties": {
|
||||||
|
"model": { "const": "ideal-real-sampler-no-antialias-filter" },
|
||||||
|
"sampleRate": { "type": "integer", "minimum": 8000, "maximum": 192000 },
|
||||||
|
"nyquistHz": { "type": "number", "exclusiveMinimum": 0 },
|
||||||
|
"toneObservations": {
|
||||||
|
"type": "array", "maxItems": 64,
|
||||||
|
"items": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"scope", "id", "analogFrequencyHz", "adcFrequencyHz",
|
||||||
|
"signedAdcFrequencyHz", "aliased", "spectralInversion", "dcEdge",
|
||||||
|
"nyquistEdge", "phaseDegenerate"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"scope": { "enum": ["source", "ambient"] },
|
||||||
|
"id": { "type": "string", "minLength": 1 },
|
||||||
|
"analogFrequencyHz": {
|
||||||
|
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
|
||||||
|
},
|
||||||
|
"adcFrequencyHz": { "type": "number", "minimum": 0 },
|
||||||
|
"signedAdcFrequencyHz": { "type": "number" },
|
||||||
|
"aliased": { "type": "boolean" },
|
||||||
|
"spectralInversion": { "type": "boolean" },
|
||||||
|
"dcEdge": { "type": "boolean" },
|
||||||
|
"nyquistEdge": { "type": "boolean" },
|
||||||
|
"phaseDegenerate": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frequencyObservation": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"model", "sampleRate", "nyquistHz", "fftSize", "binWidthHz",
|
||||||
|
"requestedMinHz", "requestedMaxHz", "aliased", "crossesFirstNyquist",
|
||||||
|
"localizationInputModel", "selectionModel", "localizationModel", "originalCarrierUsed",
|
||||||
|
"aliasOrderResolution", "foldBoundaryCount", "continuousManyToOne",
|
||||||
|
"gridManyToOne", "foldSegments", "adcBins",
|
||||||
|
"adcBinCenterRanges", "adcBinSetKey", "equivalentBandIds"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"model": { "const": "ideal-real-sampler-no-antialias-filter" },
|
||||||
|
"sampleRate": { "type": "integer", "minimum": 8000, "maximum": 192000 },
|
||||||
|
"nyquistHz": { "type": "number", "exclusiveMinimum": 0 },
|
||||||
|
"fftSize": { "enum": [128, 256, 512, 1024, 2048, 4096] },
|
||||||
|
"binWidthHz": { "type": "number", "exclusiveMinimum": 0 },
|
||||||
|
"requestedMinHz": { "type": "number", "minimum": 0, "maximum": 1000000 },
|
||||||
|
"requestedMaxHz": {
|
||||||
|
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
|
||||||
|
},
|
||||||
|
"aliased": { "type": "boolean" },
|
||||||
|
"crossesFirstNyquist": { "type": "boolean" },
|
||||||
|
"localizationInputModel": { "const": "observed-one-sided-real-fft-bins" },
|
||||||
|
"selectionModel": { "const": "fft-bin-center-membership" },
|
||||||
|
"localizationModel": {
|
||||||
|
"enum": [
|
||||||
|
"spherical-steering-at-positive-adc-bin-frequency",
|
||||||
|
"band-limited-gcc-phat-geometric-lag",
|
||||||
|
"near-field-music-at-configured-analog-frequency"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"originalCarrierUsed": { "type": "boolean" },
|
||||||
|
"aliasOrderResolution": {
|
||||||
|
"enum": [
|
||||||
|
"not-applicable",
|
||||||
|
"not-attempted",
|
||||||
|
"configured-single-hypothesis"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configuredAnalogFrequencyHz": {
|
||||||
|
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
|
||||||
|
},
|
||||||
|
"signedAdcFrequencyHz": { "type": "number" },
|
||||||
|
"spectralInversion": { "type": "boolean" },
|
||||||
|
"frequencyHypothesisCount": { "const": 1 },
|
||||||
|
"frequencyHypothesisSource": {
|
||||||
|
"const": "experiment-band-not-scene-truth"
|
||||||
|
},
|
||||||
|
"foldBoundaryCount": { "type": "integer", "minimum": 0, "maximum": 512 },
|
||||||
|
"continuousManyToOne": { "type": "boolean" },
|
||||||
|
"gridManyToOne": { "type": "boolean" },
|
||||||
|
"foldSegments": {
|
||||||
|
"type": "array", "minItems": 1, "maxItems": 513,
|
||||||
|
"items": { "$ref": "#/$defs/foldSegment" }
|
||||||
|
},
|
||||||
|
"adcBins": {
|
||||||
|
"type": "array", "minItems": 1, "maxItems": 2049, "uniqueItems": true,
|
||||||
|
"items": { "type": "integer", "minimum": 0, "maximum": 2048 }
|
||||||
|
},
|
||||||
|
"adcBinCenterRanges": {
|
||||||
|
"type": "array", "minItems": 1, "maxItems": 2049,
|
||||||
|
"items": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"firstBin", "lastBin", "firstBinCenterHz", "lastBinCenterHz"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"firstBin": { "type": "integer", "minimum": 0, "maximum": 2048 },
|
||||||
|
"lastBin": { "type": "integer", "minimum": 0, "maximum": 2048 },
|
||||||
|
"firstBinCenterHz": { "type": "number", "minimum": 0 },
|
||||||
|
"lastBinCenterHz": { "type": "number", "minimum": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"adcBinSetKey": { "type": "string", "minLength": 1, "maxLength": 16384 },
|
||||||
|
"equivalentBandIds": {
|
||||||
|
"type": "array", "maxItems": 7, "uniqueItems": true,
|
||||||
|
"items": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foldSegment": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"analogMinHz", "analogMaxHz", "signedAdcStartHz", "signedAdcEndHz",
|
||||||
|
"inverted", "aliasOrder", "nyquistZone"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"analogMinHz": { "type": "number", "minimum": 0, "maximum": 1000000 },
|
||||||
|
"analogMaxHz": {
|
||||||
|
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
|
||||||
|
},
|
||||||
|
"signedAdcStartHz": { "type": "number" },
|
||||||
|
"signedAdcEndHz": { "type": "number" },
|
||||||
|
"inverted": { "type": "boolean" },
|
||||||
|
"aliasOrder": { "type": "integer", "minimum": 0 },
|
||||||
|
"nyquistZone": { "type": "integer", "minimum": 1 }
|
||||||
|
}
|
||||||
|
},
|
||||||
"map": {
|
"map": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["rows", "columns", "scale", "values"],
|
"required": ["rows", "columns", "scale", "values"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"rows": { "type": "integer", "minimum": 1, "maximum": 512 },
|
"rows": { "type": "integer", "minimum": 1, "maximum": 512 },
|
||||||
"columns": { "type": "integer", "minimum": 1, "maximum": 512 },
|
"columns": { "type": "integer", "minimum": 1, "maximum": 512 },
|
||||||
"scale": { "enum": ["power", "score", "normalized", "db"] },
|
"scale": { "enum": ["power", "score", "noise-projection", "normalized", "db"] },
|
||||||
|
"objectiveDirection": { "enum": ["maximum", "minimum"] },
|
||||||
"values": {
|
"values": {
|
||||||
"type": "array", "minItems": 1, "maxItems": 262144,
|
"type": "array", "minItems": 1, "maxItems": 262144,
|
||||||
"items": { "type": "number" }
|
"items": { "type": "number" }
|
||||||
@@ -127,12 +264,287 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"musicModel": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"publicationFamily", "covariance", "manifold", "propagationPaths",
|
||||||
|
"localizationFrequency", "hypothesisSource",
|
||||||
|
"originalCarrierUsed", "aliasOrderResolution",
|
||||||
|
"multibandFusion",
|
||||||
|
"spatialSmoothing", "diagonalLoading", "eigenvalueFloor",
|
||||||
|
"normalizationApplied", "requestedNormalization", "distanceAmplitude",
|
||||||
|
"minimumDistance", "microphoneCalibration"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"publicationFamily": { "const": "Schmidt narrowband MUSIC" },
|
||||||
|
"covariance": { "const": "sample-xxh-no-mean" },
|
||||||
|
"manifold": {
|
||||||
|
"const": "near-field-spherical-direct-path-configured-analog-hypothesis"
|
||||||
|
},
|
||||||
|
"propagationPaths": { "const": "direct-only" },
|
||||||
|
"localizationFrequency": {
|
||||||
|
"const": "configured-single-analog-fft-grid-hypothesis-with-adc-inversion"
|
||||||
|
},
|
||||||
|
"hypothesisSource": {
|
||||||
|
"const": "experiment-band-not-scene-truth"
|
||||||
|
},
|
||||||
|
"originalCarrierUsed": { "const": true },
|
||||||
|
"aliasOrderResolution": {
|
||||||
|
"const": "configured-single-hypothesis-no-selection"
|
||||||
|
},
|
||||||
|
"multibandFusion": {
|
||||||
|
"const": "none-each-band-is-separate-single-analog-hypothesis"
|
||||||
|
},
|
||||||
|
"spatialSmoothing": { "const": "none" },
|
||||||
|
"diagonalLoading": { "const": "none" },
|
||||||
|
"eigenvalueFloor": { "const": "none" },
|
||||||
|
"normalizationApplied": {
|
||||||
|
"const": "none-raw-noise-projection-preserved"
|
||||||
|
},
|
||||||
|
"requestedNormalization": { "const": "none" },
|
||||||
|
"distanceAmplitude": {
|
||||||
|
"const": "inverse-distance-with-explicit-minimum-distance"
|
||||||
|
},
|
||||||
|
"minimumDistance": { "type": "number", "exclusiveMinimum": 0 },
|
||||||
|
"microphoneCalibration": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"sensitivity", "phaseOffset", "frequencyResponse",
|
||||||
|
"frequencyResponseEvaluation", "spectralInversion",
|
||||||
|
"compensateFrequencyResponse",
|
||||||
|
"maxCompensationDb"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"sensitivity": { "const": "exact-inverse" },
|
||||||
|
"phaseOffset": { "const": "exact-inverse" },
|
||||||
|
"frequencyResponse": {
|
||||||
|
"enum": [
|
||||||
|
"analog-hypothesis-inverse-phase-capped-inverse-gain-residual-in-manifold",
|
||||||
|
"uncompensated-retained-in-manifold"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"frequencyResponseEvaluation": {
|
||||||
|
"const": "configured-analog-fft-grid-hypothesis"
|
||||||
|
},
|
||||||
|
"spectralInversion": {
|
||||||
|
"const": "positive-bin-full-complex-conjugation"
|
||||||
|
},
|
||||||
|
"compensateFrequencyResponse": { "type": "boolean" },
|
||||||
|
"maxCompensationDb": { "type": "number", "minimum": 0, "maximum": 120 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"musicDiagnostics": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"frequencyHypothesis", "snapshotCount", "covariance",
|
||||||
|
"eigenvalues", "auditedEigenvalues",
|
||||||
|
"covarianceAudit", "eigensolver", "partition", "sourceCount",
|
||||||
|
"projectionComplexMacs"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"frequencyHypothesis": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"model", "hypothesisCount", "analogFrequencyHz",
|
||||||
|
"observedFrequencyHz", "signedAdcFrequencyHz",
|
||||||
|
"spectralInversion", "aliasOrder",
|
||||||
|
"sharedObservationKey"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"model": {
|
||||||
|
"const": "configured-single-analog-fft-grid-hypothesis"
|
||||||
|
},
|
||||||
|
"hypothesisCount": { "const": 1 },
|
||||||
|
"analogFrequencyHz": {
|
||||||
|
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
|
||||||
|
},
|
||||||
|
"observedFrequencyHz": {
|
||||||
|
"type": "number", "exclusiveMinimum": 0
|
||||||
|
},
|
||||||
|
"signedAdcFrequencyHz": { "type": "number" },
|
||||||
|
"spectralInversion": { "type": "boolean" },
|
||||||
|
"aliasOrder": { "type": "integer", "minimum": 0 },
|
||||||
|
"sharedObservationKey": {
|
||||||
|
"type": "string", "minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snapshotCount": { "type": "integer", "minimum": 1 },
|
||||||
|
"covariance": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"model", "normalization", "centered", "inputLayout", "matrixScale",
|
||||||
|
"trace", "covarianceRoundoffBound"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"model": { "const": "sample-xxh-no-mean" },
|
||||||
|
"normalization": { "const": "1/T" },
|
||||||
|
"centered": { "const": false },
|
||||||
|
"inputLayout": { "const": "snapshot-major" },
|
||||||
|
"matrixScale": { "type": "number", "minimum": 0 },
|
||||||
|
"trace": { "type": "number", "minimum": 0 },
|
||||||
|
"covarianceRoundoffBound": { "type": "number", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eigenvalues": {
|
||||||
|
"type": "array", "minItems": 2, "maxItems": 1024,
|
||||||
|
"items": { "type": "number" }
|
||||||
|
},
|
||||||
|
"auditedEigenvalues": {
|
||||||
|
"type": "array", "minItems": 2, "maxItems": 1024,
|
||||||
|
"items": { "type": "number", "minimum": 0 }
|
||||||
|
},
|
||||||
|
"covarianceAudit": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"roundoffTolerance", "roundoffFactor", "eigenvalueScale",
|
||||||
|
"minimumEigenvalue", "isPositiveSemidefinite",
|
||||||
|
"clampedNegativeCount", "roundoffDerivation",
|
||||||
|
"covarianceRoundoffBound", "eigensolverResidualFrobeniusNorm"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"roundoffTolerance": { "type": "number", "minimum": 0 },
|
||||||
|
"roundoffFactor": { "type": "number", "exclusiveMinimum": 0 },
|
||||||
|
"eigenvalueScale": { "type": "number", "minimum": 0 },
|
||||||
|
"minimumEigenvalue": { "type": "number" },
|
||||||
|
"isPositiveSemidefinite": { "const": true },
|
||||||
|
"clampedNegativeCount": { "type": "integer", "minimum": 0 },
|
||||||
|
"roundoffDerivation": {
|
||||||
|
"enum": [
|
||||||
|
"explicit", "eigenvalue-scale",
|
||||||
|
"covariance-accumulation-plus-eigensolver-residual"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"covarianceRoundoffBound": {
|
||||||
|
"type": ["number", "null"], "minimum": 0
|
||||||
|
},
|
||||||
|
"eigensolverResidualFrobeniusNorm": {
|
||||||
|
"type": "number", "minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eigensolver": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"solver", "converged", "sweeps", "rotations", "scale",
|
||||||
|
"inputHermitianError", "inputHermitianTolerance",
|
||||||
|
"relativeTolerance", "offDiagonalFrobeniusNorm",
|
||||||
|
"relativeOffDiagonal", "residualFrobeniusNorm",
|
||||||
|
"relativeResidual", "residualTolerance", "orthogonalityError",
|
||||||
|
"orthogonalityTolerance", "numericallyVerified"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"solver": { "const": "full-cyclic-complex-hermitian-jacobi" },
|
||||||
|
"converged": { "const": true },
|
||||||
|
"sweeps": { "type": "integer", "minimum": 0, "maximum": 32 },
|
||||||
|
"rotations": { "type": "integer", "minimum": 0 },
|
||||||
|
"scale": { "type": "number", "minimum": 0 },
|
||||||
|
"inputHermitianError": { "type": "number", "minimum": 0 },
|
||||||
|
"inputHermitianTolerance": { "type": "number", "minimum": 0 },
|
||||||
|
"relativeTolerance": { "type": "number", "minimum": 0 },
|
||||||
|
"offDiagonalFrobeniusNorm": { "type": "number", "minimum": 0 },
|
||||||
|
"relativeOffDiagonal": { "type": "number", "minimum": 0 },
|
||||||
|
"residualFrobeniusNorm": { "type": "number", "minimum": 0 },
|
||||||
|
"relativeResidual": { "type": "number", "minimum": 0 },
|
||||||
|
"residualTolerance": { "type": "number", "minimum": 0 },
|
||||||
|
"orthogonalityError": { "type": "number", "minimum": 0 },
|
||||||
|
"orthogonalityTolerance": { "type": "number", "minimum": 0 },
|
||||||
|
"numericallyVerified": { "const": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"partition": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"signalEdge", "noiseEdge", "eigenvalueGap", "partitionTolerance"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"signalEdge": { "type": "number", "minimum": 0 },
|
||||||
|
"noiseEdge": { "type": "number", "minimum": 0 },
|
||||||
|
"eigenvalueGap": { "type": "number", "exclusiveMinimum": 0 },
|
||||||
|
"partitionTolerance": { "type": "number", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceCount": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"method", "status", "value", "criterion", "score", "scores",
|
||||||
|
"assumptions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"method": { "enum": ["fixed", "mdl", "aic"] },
|
||||||
|
"status": { "const": "available" },
|
||||||
|
"value": { "type": "integer", "minimum": 1, "maximum": 1023 },
|
||||||
|
"criterion": { "type": ["string", "null"], "enum": ["mdl", "aic", null] },
|
||||||
|
"score": { "type": ["number", "null"] },
|
||||||
|
"scores": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "type": "null" },
|
||||||
|
{
|
||||||
|
"type": "array", "minItems": 1, "maxItems": 1024,
|
||||||
|
"items": { "type": "number" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"assumptions": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["userSpecified", "independentSnapshotsRequired"],
|
||||||
|
"properties": {
|
||||||
|
"userSpecified": { "type": "boolean" },
|
||||||
|
"independentSnapshotsRequired": { "type": "boolean" },
|
||||||
|
"nonOverlappingSnapshots": { "type": "boolean" },
|
||||||
|
"partialFrames": { "const": "excluded" },
|
||||||
|
"statisticalSnapshotIndependence": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["assumed", "evidence", "modelSelectedByUser"],
|
||||||
|
"properties": {
|
||||||
|
"assumed": { "const": true },
|
||||||
|
"evidence": {
|
||||||
|
"const": "non-overlapping-frames-only-not-statistical-proof"
|
||||||
|
},
|
||||||
|
"modelSelectedByUser": { "const": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"statisticalDataModel": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"assumed", "verified", "assumptions", "interpretation"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"assumed": { "const": true },
|
||||||
|
"verified": { "const": false },
|
||||||
|
"assumptions": {
|
||||||
|
"type": "array", "minItems": 3, "maxItems": 3,
|
||||||
|
"prefixItems": [
|
||||||
|
{ "const": "iid-complex-gaussian-snapshots" },
|
||||||
|
{ "const": "spatially-white-noise" },
|
||||||
|
{ "const": "stationary-source-count-over-snapshots" }
|
||||||
|
],
|
||||||
|
"items": false
|
||||||
|
},
|
||||||
|
"interpretation": {
|
||||||
|
"const": "wax-kailath-model-order-criterion-not-probability-or-confidence"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snapshotCount": { "type": "integer", "minimum": 1 },
|
||||||
|
"sensorCount": { "type": "integer", "minimum": 2, "maximum": 1024 },
|
||||||
|
"roundoffTolerance": { "type": "number", "minimum": 0 },
|
||||||
|
"logRatioRoundoffClampCount": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectionComplexMacs": { "type": "integer", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
"evaluation": {
|
"evaluation": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": [
|
"required": [
|
||||||
"version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio",
|
"version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio",
|
||||||
"detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM",
|
"detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM",
|
||||||
"halfPowerRatio", "dynamicRangeDb", "maximumDetections"
|
"halfPowerRatio", "dynamicRangeDb", "maximumDetections", "truthSelection"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"version": { "const": "1.0.0" },
|
"version": { "const": "1.0.0" },
|
||||||
@@ -140,11 +552,21 @@
|
|||||||
"matchToleranceM": { "type": "number", "minimum": 0 },
|
"matchToleranceM": { "type": "number", "minimum": 0 },
|
||||||
"detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 },
|
"detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||||
"detectionThresholdValue": { "type": "number", "minimum": 0 },
|
"detectionThresholdValue": { "type": "number", "minimum": 0 },
|
||||||
|
"objectiveDirection": { "enum": ["maximum", "minimum"] },
|
||||||
|
"detectionThresholdDomain": {
|
||||||
|
"enum": ["raw-map-value", "normalized-music-pseudospectrum"]
|
||||||
|
},
|
||||||
|
"detectionMethod": {
|
||||||
|
"enum": ["local-max", "connected-components", "watershed"]
|
||||||
|
},
|
||||||
"minimumPeakDistanceM": { "type": "number", "minimum": 0 },
|
"minimumPeakDistanceM": { "type": "number", "minimum": 0 },
|
||||||
"sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
|
"sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
|
||||||
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
||||||
"dynamicRangeDb": { "type": "number", "minimum": 0 },
|
"dynamicRangeDb": { "type": "number", "minimum": 0 },
|
||||||
"maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 }
|
"maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 },
|
||||||
|
"truthSelection": {
|
||||||
|
"const": "tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"processedMap": {
|
"processedMap": {
|
||||||
@@ -156,13 +578,15 @@
|
|||||||
},
|
},
|
||||||
"bandResult": {
|
"bandResult": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["id", "color", "rawMap", "peaks", "metrics"],
|
"required": ["id", "color", "rawMap", "frequencyObservation", "peaks", "metrics"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "minLength": 1 },
|
"id": { "type": "string", "minLength": 1 },
|
||||||
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
|
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
|
||||||
"rawMap": { "$ref": "#/$defs/map" },
|
"rawMap": { "$ref": "#/$defs/map" },
|
||||||
"displayMap": { "$ref": "#/$defs/map" },
|
"displayMap": { "$ref": "#/$defs/map" },
|
||||||
|
"frequencyObservation": { "$ref": "#/$defs/frequencyObservation" },
|
||||||
"evaluation": { "$ref": "#/$defs/evaluation" },
|
"evaluation": { "$ref": "#/$defs/evaluation" },
|
||||||
|
"musicDiagnostics": { "$ref": "#/$defs/musicDiagnostics" },
|
||||||
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
|
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
|
||||||
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
|
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
|
||||||
"metrics": {
|
"metrics": {
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
"signal": {
|
"signal": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{ "type": "object", "additionalProperties": false, "required": ["type", "frequency"], "properties": {
|
{ "type": "object", "additionalProperties": false, "required": ["type", "frequency"], "properties": {
|
||||||
"type": { "const": "tone" }, "frequency": { "type": "number", "exclusiveMinimum": 0 },
|
"type": { "const": "tone" }, "frequency": { "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 },
|
||||||
"phaseDeg": { "type": "number", "minimum": -360, "maximum": 360 }
|
"phaseDeg": { "type": "number", "minimum": -360, "maximum": 360 }
|
||||||
}},
|
}},
|
||||||
{ "type": "object", "additionalProperties": false, "required": ["type", "color"], "properties": {
|
{ "type": "object", "additionalProperties": false, "required": ["type", "color"], "properties": {
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장
|
|||||||
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
|
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
|
||||||
trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
|
trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
|
||||||
- `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo,
|
- `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo,
|
||||||
중복 좌표 차단, 표준 Array JSON 저장·재가져오기와 390px layout
|
중복 좌표 차단, 표준 Array JSON 저장·재가져오기, receive polar plot·고주파 auto sampling,
|
||||||
|
coarse-grid 미확정·drag 갱신·preview/export 독립성과 390px layout
|
||||||
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와
|
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와
|
||||||
DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약; 이동 예제 로드, 8×8 CPU·2 Hz live map의
|
DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약, Builder 새 탭과 Experiment 선로드·Scene
|
||||||
|
재import의 다중 band 보존; 독립 OS-CFAR·raw spectral-flux Activity, 모든 frame/bin JSON,
|
||||||
|
단일 마이크·truth 경계·미연결 배경 image 독립성, 49.125 kHz 명시 alias 가설;
|
||||||
|
이동 예제 전체 교체 확인, 8×8 CPU·2 Hz live map의
|
||||||
Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지
|
Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지
|
||||||
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
|
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
|
||||||
Result JSON을 저장하는 end-to-end 흐름
|
Result JSON을 저장하는 end-to-end 흐름
|
||||||
|
|||||||
@@ -98,6 +98,95 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
() => documentObject.querySelector("#array-canvas").width > 0,
|
() => documentObject.querySelector("#array-canvas").width > 0,
|
||||||
"Array canvas 렌더",
|
"Array canvas 렌더",
|
||||||
);
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.beamPatternReady === "true",
|
||||||
|
"기본 수신 빔 패턴 렌더",
|
||||||
|
);
|
||||||
|
const beamPlot = documentObject.querySelector("#beam-pattern-plot");
|
||||||
|
const initialBeamPath = beamPlot.querySelector(".beam-pattern-response")
|
||||||
|
?.getAttribute("d");
|
||||||
|
assert(initialBeamPath?.startsWith("M"), "polar response path is rendered");
|
||||||
|
assertEqual(
|
||||||
|
beamPlot.querySelectorAll(".beam-pattern-ring").length,
|
||||||
|
5,
|
||||||
|
"polar plot has four inner dB rings and the 0 dB ring",
|
||||||
|
);
|
||||||
|
assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
|
||||||
|
assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
|
||||||
|
assert(
|
||||||
|
Number(beamPlot.dataset.sampleCount) >= 720,
|
||||||
|
"auto angular sampling uses at least 720 directions",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#beam-pattern-model-note").textContent.includes(
|
||||||
|
"Nyquist 초과",
|
||||||
|
),
|
||||||
|
"UI explains that the geometry-only pattern does not fold analog frequency",
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialSaveState = documentObject.querySelector("#save-state").textContent;
|
||||||
|
dispatchValue(
|
||||||
|
windowObject,
|
||||||
|
documentObject.querySelector("#beam-pattern-frequency"),
|
||||||
|
49_125,
|
||||||
|
"input",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => beamPlot.dataset.frequencyHz === "49125" &&
|
||||||
|
Number(beamPlot.dataset.sampleCount) > 720,
|
||||||
|
"above-Nyquist-style analog pattern with auto angular sampling",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"),
|
||||||
|
"high analog frequency uses its short physical wavelength",
|
||||||
|
);
|
||||||
|
dispatchValue(
|
||||||
|
windowObject,
|
||||||
|
documentObject.querySelector("#beam-pattern-samples"),
|
||||||
|
360,
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => beamPlot.dataset.sampleCount === "360" &&
|
||||||
|
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
|
||||||
|
"해상도 부족",
|
||||||
|
),
|
||||||
|
"coarse angular grid exposes unresolved metrics",
|
||||||
|
);
|
||||||
|
dispatchValue(
|
||||||
|
windowObject,
|
||||||
|
documentObject.querySelector("#beam-pattern-frequency"),
|
||||||
|
"",
|
||||||
|
"input",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.beamPatternReady === "error",
|
||||||
|
"invalid beam preview setting",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!documentObject.querySelector("#export-button").disabled,
|
||||||
|
"invalid preview never blocks valid Array export",
|
||||||
|
);
|
||||||
|
dispatchValue(
|
||||||
|
windowObject,
|
||||||
|
documentObject.querySelector("#beam-pattern-samples"),
|
||||||
|
"auto",
|
||||||
|
);
|
||||||
|
dispatchValue(
|
||||||
|
windowObject,
|
||||||
|
documentObject.querySelector("#beam-pattern-frequency"),
|
||||||
|
1_000,
|
||||||
|
"input",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.beamPatternReady === "true" &&
|
||||||
|
beamPlot.dataset.frequencyHz === "1000",
|
||||||
|
"beam preview recovery",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
documentObject.querySelector("#save-state").textContent,
|
||||||
|
initialSaveState,
|
||||||
|
"beam preview settings are outside Array dirty state",
|
||||||
|
);
|
||||||
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
|
assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels");
|
||||||
assertEqual(
|
assertEqual(
|
||||||
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
|
documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId,
|
||||||
@@ -143,7 +232,12 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
dispatchDragPointer("pointerdown", 0, 0, 1);
|
dispatchDragPointer("pointerdown", 0, 0, 1);
|
||||||
dispatchDragPointer("pointermove", 36, -24, 1);
|
dispatchDragPointer("pointermove", 36, -24, 1);
|
||||||
dispatchDragPointer("pointerup", 36, -24, 0);
|
dispatchDragPointer("pointerup", 36, -24, 0);
|
||||||
await nextPaint(windowObject);
|
await waitFor(
|
||||||
|
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
|
||||||
|
beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !==
|
||||||
|
initialBeamPath,
|
||||||
|
"beam pattern follows edited microphone geometry",
|
||||||
|
);
|
||||||
const draggedPosition = selectedCoordinates(documentObject);
|
const draggedPosition = selectedCoordinates(documentObject);
|
||||||
assert(
|
assert(
|
||||||
draggedPosition[0] !== initialFirstPosition[0] &&
|
draggedPosition[0] !== initialFirstPosition[0] &&
|
||||||
@@ -444,6 +538,7 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
assert(!Object.hasOwn(exported, "shape"), "editor shape is not exported");
|
assert(!Object.hasOwn(exported, "shape"), "editor shape is not exported");
|
||||||
assert(!Object.hasOwn(exported, "preset"), "editor preset is not exported");
|
assert(!Object.hasOwn(exported, "preset"), "editor preset is not exported");
|
||||||
assert(!Object.hasOwn(exported, "seed"), "editor seed is not exported");
|
assert(!Object.hasOwn(exported, "seed"), "editor seed is not exported");
|
||||||
|
assert(!Object.hasOwn(exported, "beamPattern"), "beam preview is not exported");
|
||||||
const calibrated = exported.microphones.find(
|
const calibrated = exported.microphones.find(
|
||||||
(microphone) => microphone.id === "reference-channel",
|
(microphone) => microphone.id === "reference-channel",
|
||||||
);
|
);
|
||||||
@@ -613,6 +708,9 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
() => cameraDocument.documentElement.dataset.appReady === "true",
|
() => cameraDocument.documentElement.dataset.appReady === "true",
|
||||||
"Camera handoff initialization",
|
"Camera handoff initialization",
|
||||||
);
|
);
|
||||||
|
const defaultCameraExperiment = await cameraWindow
|
||||||
|
.fetch("/assets/sample-scenes/single-tone.experiment.json")
|
||||||
|
.then((response) => response.json());
|
||||||
const handoffFile = new cameraWindow.File(
|
const handoffFile = new cameraWindow.File(
|
||||||
[JSON.stringify(editedExport)],
|
[JSON.stringify(editedExport)],
|
||||||
"builder-handoff.array.json",
|
"builder-handoff.array.json",
|
||||||
@@ -642,8 +740,12 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
);
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
cameraExperiment.arrayId,
|
cameraExperiment.arrayId,
|
||||||
editedExport.id,
|
defaultCameraExperiment.arrayId,
|
||||||
"Camera rebinds Experiment to the Builder Array ID",
|
"Camera preserves the Experiment Array provenance label",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
cameraExperiment.arrayId !== editedExport.id,
|
||||||
|
"opening a Builder Array does not rewrite Experiment provenance",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
cameraWindow.URL.createObjectURL = originalCameraCreate;
|
cameraWindow.URL.createObjectURL = originalCameraCreate;
|
||||||
@@ -687,6 +789,28 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
|||||||
documentObject.documentElement.clientWidth + 1,
|
documentObject.documentElement.clientWidth + 1,
|
||||||
"Array Builder has no horizontal overflow at 390px",
|
"Array Builder has no horizontal overflow at 390px",
|
||||||
);
|
);
|
||||||
|
const compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
|
||||||
|
.getBoundingClientRect();
|
||||||
|
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
|
||||||
|
.getBoundingClientRect();
|
||||||
|
assert(
|
||||||
|
compactBeamPlotRect.left >= compactBeamCardRect.left - 1 &&
|
||||||
|
compactBeamPlotRect.right <= compactBeamCardRect.right + 1,
|
||||||
|
"compact polar plot stays inside its analysis card",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2,
|
||||||
|
"compact polar plot remains square",
|
||||||
|
);
|
||||||
|
const compactBeamFields = [
|
||||||
|
...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
|
||||||
|
].map((field) => field.getBoundingClientRect());
|
||||||
|
assert(
|
||||||
|
compactBeamFields.every(
|
||||||
|
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
|
||||||
|
),
|
||||||
|
"compact beam controls stack without clipping",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350,
|
||||||
"Array canvas remains usable at 390px",
|
"Array canvas remains usable at 390px",
|
||||||
|
|||||||
@@ -133,6 +133,20 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
|
|||||||
frame.style.width = "1400px";
|
frame.style.width = "1400px";
|
||||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
||||||
|
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 5000, "input");
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone") &&
|
||||||
|
!documentObject.querySelector("#source-list").textContent.includes("ADC 3000 Hz alias"),
|
||||||
|
"tone card starts as baseband at 48 kHz sample rate",
|
||||||
|
);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 8000, "change");
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone → ADC 3000 Hz alias"),
|
||||||
|
"sample-rate change immediately rerenders the tone alias card",
|
||||||
|
);
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 48000, "change");
|
||||||
|
dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 1000, "input");
|
||||||
|
|
||||||
const previewSamples = new Float32Array(2000);
|
const previewSamples = new Float32Array(2000);
|
||||||
for (let index = 0; index < previewSamples.length; index += 1) {
|
for (let index = 0; index < previewSamples.length; index += 1) {
|
||||||
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
|
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,12 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
|||||||
try {
|
try {
|
||||||
await import("/tests/unit/contracts.test.js");
|
await import("/tests/unit/contracts.test.js");
|
||||||
await import("/tests/unit/acoustics.test.js");
|
await import("/tests/unit/acoustics.test.js");
|
||||||
|
await import("/tests/unit/beam-pattern.test.js");
|
||||||
|
await import("/tests/unit/cfar.test.js");
|
||||||
|
await import("/tests/unit/activity-monitor.test.js");
|
||||||
|
await import("/tests/unit/spectral-flux.test.js");
|
||||||
await import("/tests/unit/gcc-phat.test.js");
|
await import("/tests/unit/gcc-phat.test.js");
|
||||||
|
await import("/tests/unit/music-numerics.test.js");
|
||||||
await import("/tests/unit/localization.test.js");
|
await import("/tests/unit/localization.test.js");
|
||||||
await import("/tests/unit/realtime-localizer.test.js");
|
await import("/tests/unit/realtime-localizer.test.js");
|
||||||
await import("/tests/unit/builder.test.js");
|
await import("/tests/unit/builder.test.js");
|
||||||
|
|||||||
+13
-3
@@ -25,6 +25,12 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
|
|||||||
선할당 제한, 0–1 score bound·scan 범위 불변성과 geometry/STFT/IFFT workspace 재사용
|
선할당 제한, 0–1 score bound·scan 범위 불변성과 geometry/STFT/IFFT workspace 재사용
|
||||||
- `localization.test.js`: 알고리즘 registry와 generic CPU/backend dispatcher, power/score
|
- `localization.test.js`: 알고리즘 registry와 generic CPU/backend dispatcher, power/score
|
||||||
의미, portable GCC-PHAT block·streaming 경로
|
의미, portable GCC-PHAT block·streaming 경로
|
||||||
|
- `cfar.test.js`: CA/OS multiplier, nominal per-CUT Pfa, full symmetric reference,
|
||||||
|
모든 tested/untested bin과 zero-threshold JSON 상태
|
||||||
|
- `activity-monitor.test.js`: 단일 채널 complete-frame CFAR, raw frame×bin 보존,
|
||||||
|
deterministic workload·hard cap과 취소
|
||||||
|
- `spectral-flux.test.js`: Dixon Hamming/full-DFT raw novelty, causal peak picker,
|
||||||
|
latency, exact output preflight와 memory cap
|
||||||
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
|
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
|
||||||
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
|
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
|
||||||
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
||||||
@@ -32,9 +38,13 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
|
|||||||
최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단
|
최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단
|
||||||
- `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test,
|
- `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test,
|
||||||
1/2/5 grid와 keyboard nudge
|
1/2/5 grid와 keyboard nudge
|
||||||
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
- `beam-pattern.test.js`: conventional receive array-factor 부호·raw complex/power, XY/XZ/YZ
|
||||||
beam-spread covariance ellipse
|
좌표, analog Nyquist 초과 파장, front/back 동률, 조건부 HPBW, angular phase-step 해상도와
|
||||||
- `camera.test.js`: canonical input hash, workload 추정, 이동 timeline 계획·재생 보간, Result schema 검증
|
exact workload cap
|
||||||
|
- `evaluation.test.js`: local maximum·8-connected component·marker watershed peak 묶기,
|
||||||
|
검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적 beam-spread covariance ellipse
|
||||||
|
- `camera.test.js`: canonical input hash, workload 추정, Scene 정렬의 분석 band 보존,
|
||||||
|
이동 timeline 계획·재생 보간, Result schema 검증
|
||||||
- `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
|
- `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
|
||||||
장치 한계와 typed CPU fallback 정책
|
장치 한계와 typed CPU fallback 정책
|
||||||
- `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치
|
- `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치
|
||||||
|
|||||||
+1117
-4
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
|||||||
|
import {
|
||||||
|
analyzeCfarActivity,
|
||||||
|
estimateCfarActivityWorkload,
|
||||||
|
} from "/packages/acoustics-core/activity-monitor.js";
|
||||||
|
|
||||||
|
function sinusoid(sampleCount, sampleRate, frequencyHz, amplitude = 1) {
|
||||||
|
return Float64Array.from(
|
||||||
|
{ length: sampleCount },
|
||||||
|
(_, index) =>
|
||||||
|
amplitude * Math.sin((2 * Math.PI * frequencyHz * index) / sampleRate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectorOptions(overrides = {}) {
|
||||||
|
return {
|
||||||
|
samples: sinusoid(128, 6_400, 1_200),
|
||||||
|
sampleRate: 6_400,
|
||||||
|
fftSize: 64,
|
||||||
|
hopSize: 64,
|
||||||
|
window: "hann",
|
||||||
|
channelId: "mic-reference",
|
||||||
|
detector: "os-cfar",
|
||||||
|
trainingCellsPerSide: 4,
|
||||||
|
guardCellsPerSide: 1,
|
||||||
|
rank: 6,
|
||||||
|
nominalFalseAlarmProbability: 0.01,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("CFAR activity workload counts complete frames and every one-sided bin", () => {
|
||||||
|
const workload = estimateCfarActivityWorkload({
|
||||||
|
sampleCount: 160,
|
||||||
|
fftSize: 64,
|
||||||
|
hopSize: 32,
|
||||||
|
});
|
||||||
|
assertEqual(workload.frameCount, 4, "complete frame count");
|
||||||
|
assertEqual(workload.binCount, 33, "one-sided bin count");
|
||||||
|
assertEqual(workload.binDecisionCount, 132, "all frame/bin records");
|
||||||
|
assertEqual(workload.fftButterflyCount, 768, "radix-2 butterfly count");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CFAR activity monitor preserves raw bins without time, microphone, or band fusion", async () => {
|
||||||
|
const result = await analyzeCfarActivity(detectorOptions());
|
||||||
|
assertEqual(result.kind, "micarrsim.cfar-activity-analysis/1", "result kind");
|
||||||
|
assertEqual(result.channel.channelId, "mic-reference", "explicit channel");
|
||||||
|
assertEqual(result.channel.microphoneFusion, "none", "no microphone fusion");
|
||||||
|
assertEqual(result.frames.length, 2, "every complete frame retained");
|
||||||
|
assertEqual(result.frames[0].bins.length, 33, "every one-sided bin retained");
|
||||||
|
assertEqual(result.frames[0].bins[0].status, "untested", "DC remains untested");
|
||||||
|
assertEqual(
|
||||||
|
result.frames[0].bins[32].status,
|
||||||
|
"untested",
|
||||||
|
"Nyquist remains untested",
|
||||||
|
);
|
||||||
|
assertEqual(result.summary.selectionApplied, false, "no selection");
|
||||||
|
assertEqual(result.summary.clusteringApplied, false, "no clustering");
|
||||||
|
assertEqual(result.summary.localizationApplied, false, "no localization");
|
||||||
|
assertEqual(
|
||||||
|
result.detectorModel.processing.bandFusion,
|
||||||
|
false,
|
||||||
|
"no band fusion",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.frames.some((frame) => frame.bins[12].status === "detected"),
|
||||||
|
"the exact 1.2 kHz ADC bin is reported as detected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("above-Nyquist analog tone is reported only at its observable ADC alias", async () => {
|
||||||
|
const sampleRate = 6_400;
|
||||||
|
const analogFrequencyHz = 7_600;
|
||||||
|
const result = await analyzeCfarActivity(detectorOptions({
|
||||||
|
samples: sinusoid(128, sampleRate, analogFrequencyHz),
|
||||||
|
}));
|
||||||
|
assert(
|
||||||
|
result.frames.some((frame) => frame.bins[12].status === "detected"),
|
||||||
|
"7.6 kHz samples expose the 1.2 kHz ADC bin",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.input.analogFrequencyOrAliasOrderInferred,
|
||||||
|
false,
|
||||||
|
"monitor does not infer analog alias order",
|
||||||
|
);
|
||||||
|
assertEqual(result.input.sceneTruthUsed, false, "Scene truth is not an input");
|
||||||
|
assertEqual(
|
||||||
|
Object.prototype.hasOwnProperty.call(result.input, "analogFrequencyHz"),
|
||||||
|
false,
|
||||||
|
"the result does not invent an analog carrier field",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
Object.prototype.hasOwnProperty.call(result.frames[0].bins[12], "analogFrequencyHz"),
|
||||||
|
false,
|
||||||
|
"the detected ADC bin contains no hidden analog carrier",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CA and OS activity analyses stay separate algorithms", async () => {
|
||||||
|
const ca = await analyzeCfarActivity(detectorOptions({
|
||||||
|
detector: "ca-cfar",
|
||||||
|
rank: undefined,
|
||||||
|
}));
|
||||||
|
const os = await analyzeCfarActivity(detectorOptions());
|
||||||
|
assertEqual(ca.detectorModel.detector, "ca-cfar", "CA identity");
|
||||||
|
assertEqual(os.detectorModel.detector, "os-cfar", "OS identity");
|
||||||
|
assertEqual(
|
||||||
|
ca.detectorModel.processing.detectorFusion,
|
||||||
|
false,
|
||||||
|
"CA has no detector fusion",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
os.detectorModel.processing.detectorFusion,
|
||||||
|
false,
|
||||||
|
"OS has no detector fusion",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CFAR activity monitor is deterministic and preserves raw powers", async () => {
|
||||||
|
const first = await analyzeCfarActivity(detectorOptions());
|
||||||
|
const second = await analyzeCfarActivity(detectorOptions());
|
||||||
|
for (let frameIndex = 0; frameIndex < first.frames.length; frameIndex += 1) {
|
||||||
|
for (let bin = 0; bin < first.frames[frameIndex].bins.length; bin += 1) {
|
||||||
|
const left = first.frames[frameIndex].bins[bin];
|
||||||
|
const right = second.frames[frameIndex].bins[bin];
|
||||||
|
assertEqual(left.status, right.status, "deterministic status");
|
||||||
|
assertEqual(left.cutPower, right.cutPower, "deterministic raw CUT power");
|
||||||
|
assertEqual(
|
||||||
|
left.referencePower,
|
||||||
|
right.referencePower,
|
||||||
|
"deterministic reference power",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
left.thresholdPower,
|
||||||
|
right.thresholdPower,
|
||||||
|
"deterministic threshold",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CFAR activity monitor rejects implicit channel fusion and hard-cap overflow", async () => {
|
||||||
|
let channelError = null;
|
||||||
|
try {
|
||||||
|
await analyzeCfarActivity(detectorOptions({
|
||||||
|
samples: [
|
||||||
|
sinusoid(64, 6_400, 1_200),
|
||||||
|
sinusoid(64, 6_400, 1_200),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
channelError = error;
|
||||||
|
}
|
||||||
|
assert(channelError instanceof Error, "nested channel input is rejected");
|
||||||
|
|
||||||
|
let capError = null;
|
||||||
|
try {
|
||||||
|
await analyzeCfarActivity(detectorOptions({
|
||||||
|
maximumBinDecisionCount: 65,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
capError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
capError?.message.includes("maximumBinDecisionCount"),
|
||||||
|
"bin decision cap rejects work instead of truncating results",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CFAR activity monitor honors an already-aborted signal", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
let abortError = null;
|
||||||
|
try {
|
||||||
|
await analyzeCfarActivity(detectorOptions({ signal: controller.signal }));
|
||||||
|
} catch (error) {
|
||||||
|
abortError = error;
|
||||||
|
}
|
||||||
|
assertEqual(abortError?.name, "AbortError", "typed abort");
|
||||||
|
});
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import {
|
||||||
|
computeReceiveBeamPattern,
|
||||||
|
receiveBeamPatternDirection,
|
||||||
|
} from "/packages/acoustics-core/beam-pattern.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(
|
||||||
|
Math.abs(actual - expected) <= tolerance,
|
||||||
|
label + ": expected " + expected + " ± " + tolerance + ", received " + actual,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayFromPositions(positions) {
|
||||||
|
return {
|
||||||
|
microphones: positions.map((position, index) => ({
|
||||||
|
id: "mic-" + String(index + 1),
|
||||||
|
position,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleAtAngle(pattern, angleDeg) {
|
||||||
|
let bestIndex = 0;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
for (let index = 0; index < pattern.anglesDeg.length; index += 1) {
|
||||||
|
const delta = Math.abs(pattern.anglesDeg[index] - angleDeg);
|
||||||
|
const circularDistance = Math.min(delta, 360 - delta);
|
||||||
|
if (circularDistance < bestDistance) {
|
||||||
|
bestDistance = circularDistance;
|
||||||
|
bestIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(bestDistance < 1e-10, "requested angle is present in sampled cut");
|
||||||
|
return pattern.responseLinear[bestIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
test("receive beam-pattern cuts use the documented right-handed axes", () => {
|
||||||
|
const cases = [
|
||||||
|
["xy", 0, [1, 0, 0]],
|
||||||
|
["xy", 90, [0, 1, 0]],
|
||||||
|
["xz", 0, [0, 0, 1]],
|
||||||
|
["xz", 90, [1, 0, 0]],
|
||||||
|
["yz", 0, [0, 0, 1]],
|
||||||
|
["yz", 90, [0, 1, 0]],
|
||||||
|
];
|
||||||
|
for (const [cut, angleDeg, expected] of cases) {
|
||||||
|
const actual = receiveBeamPatternDirection(cut, angleDeg);
|
||||||
|
actual.forEach((value, axis) =>
|
||||||
|
assertNear(value, expected[axis], 1e-12, cut + " direction axis " + axis));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one ideal point microphone is omnidirectional on every sampled cut", () => {
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[12.5, -7, 3]]),
|
||||||
|
frequencyHz: 49_125,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "yz",
|
||||||
|
steeringAngleDeg: -35,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
for (const response of pattern.responseLinear) {
|
||||||
|
assertNear(response, 1, 1e-12, "single-microphone response");
|
||||||
|
}
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.status,
|
||||||
|
"full-circle-above-half-power",
|
||||||
|
"flat cut status",
|
||||||
|
);
|
||||||
|
assertEqual(pattern.metrics.halfPowerBeamwidth.widthDeg, null, "flat cut has no HPBW");
|
||||||
|
assert(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.fullCircleAboveHalfPower,
|
||||||
|
"flat cut records the missing crossing",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.sampledLocalMaxima.peaks.length,
|
||||||
|
0,
|
||||||
|
"flat cut has no unique peaks",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("half-wavelength two-microphone broadside cut has nulls, 60-degree HPBW, and front-back tie", () => {
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const frequencyHz = 1_000;
|
||||||
|
const spacing = speedOfSound / frequencyHz / 2;
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-spacing / 2, 0, 0], [spacing / 2, 0, 0]]),
|
||||||
|
frequencyHz,
|
||||||
|
speedOfSound,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 1_440,
|
||||||
|
});
|
||||||
|
assertNear(sampleAtAngle(pattern, 90), 0, 1e-12, "endfire null at +x");
|
||||||
|
assertNear(sampleAtAngle(pattern, 270), 0, 1e-12, "endfire null at -x");
|
||||||
|
assertNear(sampleAtAngle(pattern, 0), 1, 1e-12, "steered broadside response");
|
||||||
|
assertNear(sampleAtAngle(pattern, 180), 1, 1e-12, "planar front-back response");
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.status,
|
||||||
|
"sampled-bracketed-bisection",
|
||||||
|
"HPBW status",
|
||||||
|
);
|
||||||
|
assertNear(pattern.metrics.halfPowerBeamwidth.widthDeg, 60, 1e-10, "power HPBW");
|
||||||
|
const zeroDbAngles = pattern.metrics.sampledLocalMaxima.peaks
|
||||||
|
.filter((peak) => Math.abs(peak.responseDb) < 1e-10)
|
||||||
|
.map((peak) => peak.angleDeg)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
|
assertEqual(zeroDbAngles.join(","), "0,180", "front-back maxima remain tied");
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.sampledLocalMaxima.status,
|
||||||
|
"tied-global-maxima-on-grid",
|
||||||
|
"global tie is explicit",
|
||||||
|
);
|
||||||
|
assertEqual(pattern.metrics.sampledLocalMaxima.selected, null, "tie has no selected peak");
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.sampledLocalMaxima.globalMaximumRegions.length,
|
||||||
|
2,
|
||||||
|
"both tied regions are preserved",
|
||||||
|
);
|
||||||
|
assertNear(pattern.responseReal[Math.floor(pattern.sampleCount / 2)], 1, 1e-12, "raw real");
|
||||||
|
assertNear(pattern.responseImaginary[Math.floor(pattern.sampleCount / 2)], 0, 1e-12, "raw imaginary");
|
||||||
|
assertNear(pattern.responsePower[Math.floor(pattern.sampleCount / 2)], 1, 1e-12, "raw power");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asymmetric array preserves the receive steering sign in raw complex response", () => {
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const frequencyHz = 1_000;
|
||||||
|
const quarterWavelength = speedOfSound / frequencyHz / 4;
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([
|
||||||
|
[0, 0, 0],
|
||||||
|
[quarterWavelength, 0, 0],
|
||||||
|
[3 * quarterWavelength, 0, 0],
|
||||||
|
]),
|
||||||
|
frequencyHz,
|
||||||
|
speedOfSound,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
const lookIndex = [...pattern.anglesDeg].findIndex((angle) => angle === 90);
|
||||||
|
assert(lookIndex >= 0, "+x look direction is sampled");
|
||||||
|
assertNear(pattern.responseReal[lookIndex], -1 / 6, 1e-12, "signed real response");
|
||||||
|
assertNear(
|
||||||
|
pattern.responseImaginary[lookIndex],
|
||||||
|
-Math.sqrt(3) / 6,
|
||||||
|
1e-12,
|
||||||
|
"signed imaginary response",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("display dB floor never changes raw array factor or physical metrics", () => {
|
||||||
|
const array = arrayFromPositions([[-0.08575, 0, 0], [0.08575, 0, 0]]);
|
||||||
|
const shared = {
|
||||||
|
array,
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 720,
|
||||||
|
};
|
||||||
|
const shallow = computeReceiveBeamPattern({ ...shared, dbFloor: -30 });
|
||||||
|
const deep = computeReceiveBeamPattern({ ...shared, dbFloor: -100 });
|
||||||
|
for (let index = 0; index < shallow.sampleCount; index += 1) {
|
||||||
|
assertNear(
|
||||||
|
shallow.responsePower[index],
|
||||||
|
deep.responsePower[index],
|
||||||
|
0,
|
||||||
|
"raw power is floor-independent at " + index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNear(
|
||||||
|
shallow.metrics.halfPowerBeamwidth.widthDeg,
|
||||||
|
deep.metrics.halfPowerBeamwidth.widthDeg,
|
||||||
|
0,
|
||||||
|
"HPBW is floor-independent",
|
||||||
|
);
|
||||||
|
const nullIndex = [...shallow.anglesDeg].findIndex((angle) => angle === 90);
|
||||||
|
assertEqual(shallow.displayDb[nullIndex], -30, "shallow display floor");
|
||||||
|
assertEqual(deep.displayDb[nullIndex], -100, "deep display floor");
|
||||||
|
assert(
|
||||||
|
shallow.responseDb[nullIndex] < -300,
|
||||||
|
"raw null remains below both display floors",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("receive array factor is invariant to translating every microphone", () => {
|
||||||
|
const basePositions = [[-0.12, 0.03, 0], [0.04, -0.08, 0.02], [0.15, 0.07, -0.01]];
|
||||||
|
const translation = [1_000, -2_000, 500];
|
||||||
|
const translatedPositions = basePositions.map((position) =>
|
||||||
|
position.map((coordinate, axis) => coordinate + translation[axis]));
|
||||||
|
const options = {
|
||||||
|
frequencyHz: 7_500,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xy",
|
||||||
|
steeringAngleDeg: 23,
|
||||||
|
sampleCount: 720,
|
||||||
|
};
|
||||||
|
const base = computeReceiveBeamPattern({ ...options, array: arrayFromPositions(basePositions) });
|
||||||
|
const translated = computeReceiveBeamPattern({
|
||||||
|
...options,
|
||||||
|
array: arrayFromPositions(translatedPositions),
|
||||||
|
});
|
||||||
|
for (let index = 0; index < base.sampleCount; index += 1) {
|
||||||
|
assertNear(
|
||||||
|
translated.responseLinear[index],
|
||||||
|
base.responseLinear[index],
|
||||||
|
1e-11,
|
||||||
|
"translation-invariant sample " + index,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("beam pattern uses analog frequency directly without Nyquist folding", () => {
|
||||||
|
const highFrequencyHz = 49_125;
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const halfHighWavelength = speedOfSound / (2 * highFrequencyHz);
|
||||||
|
const array = arrayFromPositions([[0, 0, 0], [halfHighWavelength, 0, 0]]);
|
||||||
|
const highAnalog = computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: highFrequencyHz,
|
||||||
|
speedOfSound,
|
||||||
|
cut: "xy",
|
||||||
|
steeringAngleDeg: 90,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
const foldedTemporalAlias = computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: 1_125,
|
||||||
|
speedOfSound,
|
||||||
|
cut: "xy",
|
||||||
|
steeringAngleDeg: 90,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
assertEqual(highAnalog.frequencyHz, highFrequencyHz, "analog hypothesis is preserved");
|
||||||
|
assertNear(
|
||||||
|
highAnalog.wavelengthM,
|
||||||
|
speedOfSound / highFrequencyHz,
|
||||||
|
1e-15,
|
||||||
|
"analog wavelength",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(sampleAtAngle(highAnalog, 0) - sampleAtAngle(foldedTemporalAlias, 0)) > 0.1,
|
||||||
|
"spatial pattern is not computed from the folded ADC alias",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coarse high-frequency cuts preserve raw samples but withhold unresolved metrics", () => {
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-0.5, 0, 0], [0.5, 0, 0]]),
|
||||||
|
frequencyHz: 100_000,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 360,
|
||||||
|
});
|
||||||
|
assert(!pattern.angularSampling.metricResolutionSatisfied, "phase-step audit fails");
|
||||||
|
assert(
|
||||||
|
pattern.angularSampling.maximumAdjacentPhaseStepRadBound >
|
||||||
|
pattern.angularSampling.metricPhaseStepLimitRad,
|
||||||
|
"result exposes the physical phase-step bound",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.status,
|
||||||
|
"resolution-unresolved",
|
||||||
|
"HPBW is withheld",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.sampledLocalMaxima.status,
|
||||||
|
"resolution-unresolved",
|
||||||
|
"peak interpretation is withheld",
|
||||||
|
);
|
||||||
|
assertEqual(pattern.responsePower.length, 360, "raw sampled power remains available");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-constant cuts without sampled half-power crossings do not claim 360-degree HPBW", () => {
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-0.0005, 0, 0], [0.0005, 0, 0]]),
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: 720,
|
||||||
|
});
|
||||||
|
assert(pattern.angularSampling.projectedApertureM > 0, "array is not constant on cut");
|
||||||
|
assert(
|
||||||
|
Math.min(...pattern.responsePower) > 0.5,
|
||||||
|
"sampled cut stays above half power",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.status,
|
||||||
|
"no-sampled-half-power-crossing",
|
||||||
|
"missing crossing remains unresolved",
|
||||||
|
);
|
||||||
|
assertEqual(pattern.metrics.halfPowerBeamwidth.widthDeg, null, "no fabricated width");
|
||||||
|
assertEqual(
|
||||||
|
pattern.metrics.halfPowerBeamwidth.fullCircleAboveHalfPower,
|
||||||
|
null,
|
||||||
|
"full-circle claim is withheld",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auto angular sampling increases resolution and reports when the safety cap is insufficient", () => {
|
||||||
|
const moderate = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-0.225, 0, 0], [0.225, 0, 0]]),
|
||||||
|
frequencyHz: 49_125,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: "auto",
|
||||||
|
});
|
||||||
|
assert(moderate.sampleCount > 720, "auto mode increases angular samples");
|
||||||
|
assert(moderate.angularSampling.metricResolutionSatisfied, "moderate case becomes resolved");
|
||||||
|
assert(!moderate.angularSampling.autoCapped, "moderate case fits the cap");
|
||||||
|
|
||||||
|
const extreme = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[-10, 0, 0], [10, 0, 0]]),
|
||||||
|
frequencyHz: 1_000_000,
|
||||||
|
speedOfSound: 343,
|
||||||
|
cut: "xz",
|
||||||
|
steeringAngleDeg: 0,
|
||||||
|
sampleCount: "auto",
|
||||||
|
});
|
||||||
|
assertEqual(extreme.sampleCount, 14_400, "auto mode obeys the angular cap");
|
||||||
|
assert(extreme.angularSampling.autoCapped, "cap is explicit");
|
||||||
|
assert(!extreme.angularSampling.metricResolutionSatisfied, "extreme metric remains unresolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("beam-pattern preflight rejects invalid input and excessive work", () => {
|
||||||
|
const array = arrayFromPositions([[0, 0, 0], [0.1, 0, 0]]);
|
||||||
|
let frequencyError = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({ array, frequencyHz: 0 });
|
||||||
|
} catch (error) {
|
||||||
|
frequencyError = error;
|
||||||
|
}
|
||||||
|
assert(frequencyError instanceof RangeError, "zero analog frequency is rejected");
|
||||||
|
|
||||||
|
let workError = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
sampleCount: 360,
|
||||||
|
maximumEvaluations: 911,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
workError = error;
|
||||||
|
}
|
||||||
|
assert(workError instanceof RangeError, "work cap is checked before evaluation");
|
||||||
|
assert(workError.message.includes("912"), "error reports maximum evaluation count");
|
||||||
|
|
||||||
|
let microphoneLimitError = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions(
|
||||||
|
Array.from({ length: 1_025 }, (_, index) => [index / 1000, 0, 0]),
|
||||||
|
),
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
microphoneLimitError = error;
|
||||||
|
}
|
||||||
|
assert(microphoneLimitError instanceof RangeError, "microphone cap precedes pairwise audit");
|
||||||
|
assert(
|
||||||
|
microphoneLimitError.message.includes("1024"),
|
||||||
|
"microphone cap is explicit",
|
||||||
|
);
|
||||||
|
|
||||||
|
let baselineError = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({
|
||||||
|
array,
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
maximumBaselineComparisons: 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
baselineError = error;
|
||||||
|
}
|
||||||
|
assert(baselineError instanceof RangeError, "baseline pair cap is checked");
|
||||||
|
assert(baselineError.message.includes("1 projected-baseline"), "pair work is explicit");
|
||||||
|
|
||||||
|
let frequencyLimitError = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({ array, frequencyHz: 1_000_001 });
|
||||||
|
} catch (error) {
|
||||||
|
frequencyLimitError = error;
|
||||||
|
}
|
||||||
|
assert(frequencyLimitError instanceof RangeError, "extreme analog frequency is bounded");
|
||||||
|
assert(frequencyLimitError.message.includes("1000000"), "frequency cap is explicit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("co-located pattern accepts an exact sample-only evaluation cap", () => {
|
||||||
|
const pattern = computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[0, 0, 0]]),
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
sampleCount: 360,
|
||||||
|
maximumEvaluations: 360,
|
||||||
|
});
|
||||||
|
assertEqual(pattern.evaluationCount, 360, "exact one-microphone sample work");
|
||||||
|
assertEqual(pattern.workload.maximumMetricEvaluationCount, 0, "constant cut reserves no HPBW work");
|
||||||
|
|
||||||
|
let error = null;
|
||||||
|
try {
|
||||||
|
computeReceiveBeamPattern({
|
||||||
|
array: arrayFromPositions([[0, 0, 0]]),
|
||||||
|
frequencyHz: 1_000,
|
||||||
|
sampleCount: 360,
|
||||||
|
maximumEvaluations: 359,
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
assert(error instanceof RangeError, "strict cap rejects one fewer evaluation");
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
enableSourceTrajectory,
|
enableSourceTrajectory,
|
||||||
createSceneId,
|
createSceneId,
|
||||||
createSource,
|
createSource,
|
||||||
|
describeSignal,
|
||||||
insertTrajectoryKeyframe,
|
insertTrajectoryKeyframe,
|
||||||
matchAssetDescriptor,
|
matchAssetDescriptor,
|
||||||
prepareSceneForEditing,
|
prepareSceneForEditing,
|
||||||
@@ -49,6 +50,29 @@ test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든
|
|||||||
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
|
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Builder tone summary exposes the ideal ADC alias above Nyquist", () => {
|
||||||
|
assertEqual(
|
||||||
|
describeSignal({ type: "tone", frequency: 5_000 }, 8_000),
|
||||||
|
"5000 Hz tone → ADC 3000 Hz alias",
|
||||||
|
"above-Nyquist source summary",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
describeSignal({ type: "tone", frequency: 1_000 }, 8_000),
|
||||||
|
"1000 Hz tone",
|
||||||
|
"baseband source summary",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
describeSignal({ type: "tone", frequency: 8_000 }, 8_000),
|
||||||
|
"8000 Hz tone → ADC 0 Hz alias · DC phase-degenerate edge",
|
||||||
|
"aliased exact-DC source summary",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
describeSignal({ type: "tone", frequency: 12_000 }, 8_000),
|
||||||
|
"12000 Hz tone → ADC 4000 Hz alias · Nyquist phase-degenerate edge",
|
||||||
|
"aliased exact-Nyquist source summary",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
|
test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
|
||||||
const source = { position: [0.5, 0.1, 2] };
|
const source = { position: [0.5, 0.1, 2] };
|
||||||
const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
|
const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
|
||||||
|
|||||||
+440
-8
@@ -22,6 +22,8 @@ import {
|
|||||||
shiftSceneDistance,
|
shiftSceneDistance,
|
||||||
} from "/apps/acoustic-camera/model.js";
|
} from "/apps/acoustic-camera/model.js";
|
||||||
import { validateResult } from "/packages/contracts/validation.js";
|
import { validateResult } from "/packages/contracts/validation.js";
|
||||||
|
import { computeLocalizationMap } from "/packages/acoustics-core/localization.js";
|
||||||
|
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
|
||||||
|
|
||||||
function assertNear(actual, expected, tolerance, label) {
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
assert(
|
assert(
|
||||||
@@ -56,6 +58,56 @@ test("Camera detects moving source trajectories", () => {
|
|||||||
}), true, "moving scene");
|
}), true, "moving scene");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Camera live truth uses synthesized path arrivals instead of emission midpoint", () => {
|
||||||
|
const scene = {
|
||||||
|
sampleRate: 8_000,
|
||||||
|
duration: 1,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "ended-tone-tail",
|
||||||
|
startTime: 0,
|
||||||
|
duration: 0.1,
|
||||||
|
signal: { type: "tone", frequency: 1_000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ended-noise-tail",
|
||||||
|
startTime: 0,
|
||||||
|
duration: 0.1,
|
||||||
|
signal: { type: "noise", color: "white" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wrong-band-tone",
|
||||||
|
startTime: 0,
|
||||||
|
duration: 0.1,
|
||||||
|
signal: { type: "tone", frequency: 2_000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const band = { minHz: 900, maxHz: 1_100 };
|
||||||
|
const fallback = sourcesForObservedBand(scene, band, 256, { time: 0.5 });
|
||||||
|
assertEqual(fallback.length, 0, "legacy emission-time fallback sees no active source");
|
||||||
|
assertEqual(
|
||||||
|
sourcesForObservedBand(scene, band, 256, { time: 0.1 }).length,
|
||||||
|
0,
|
||||||
|
"emission-time fallback treats the exact end boundary as half-open",
|
||||||
|
);
|
||||||
|
|
||||||
|
const arrived = sourcesForObservedBand(scene, band, 256, {
|
||||||
|
time: 0.5,
|
||||||
|
arrivingSourceIds: ["ended-tone-tail", "ended-noise-tail", "wrong-band-tone"],
|
||||||
|
});
|
||||||
|
assertEqual(
|
||||||
|
arrived.map(({ id }) => id).join(","),
|
||||||
|
"ended-tone-tail,ended-noise-tail",
|
||||||
|
"path-arrival ids override time while band selection remains independent",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
sourcesForObservedBand(scene, band, 256, { arrivingSourceIds: [] }).length,
|
||||||
|
0,
|
||||||
|
"an explicit empty arrival set does not fall back to emission activity",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("Camera analysis plan covers a scene and respects its frame budget", () => {
|
test("Camera analysis plan covers a scene and respects its frame budget", () => {
|
||||||
const scene = { duration: 2, sampleRate: 1000, sources: [] };
|
const scene = { duration: 2, sampleRate: 1000, sources: [] };
|
||||||
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
|
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
|
||||||
@@ -268,6 +320,49 @@ test("Camera workload counts unique overlapping FFT bins", () => {
|
|||||||
assertEqual(gccWorkload.scanOperationCount, 320, "GCC pair-band scan lookups");
|
assertEqual(gccWorkload.scanOperationCount, 320, "GCC pair-band scan lookups");
|
||||||
assertEqual(gccWorkload.scanOperationKind, "reference-pair lookup", "GCC operation kind");
|
assertEqual(gccWorkload.scanOperationKind, "reference-pair lookup", "GCC operation kind");
|
||||||
assertEqual(gccWorkload.complexMacCount, 0, "GCC is not mislabeled as DAS complex MAC");
|
assertEqual(gccWorkload.complexMacCount, 0, "GCC is not mislabeled as DAS complex MAC");
|
||||||
|
|
||||||
|
const musicExperiment = structuredClone(experiment);
|
||||||
|
musicExperiment.frequencyBands = [
|
||||||
|
{ id: "single-bin", minHz: 120, maxHz: 130 },
|
||||||
|
];
|
||||||
|
musicExperiment.beamforming = {
|
||||||
|
algorithm: "music",
|
||||||
|
musicSourceCountMethod: "fixed",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
};
|
||||||
|
const musicWorkload = estimateExperimentWorkload(
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
musicExperiment,
|
||||||
|
);
|
||||||
|
assertEqual(musicWorkload.algorithmLabel, "MUSIC", "MUSIC algorithm label");
|
||||||
|
assertEqual(
|
||||||
|
musicWorkload.musicCovarianceComplexAccumulations,
|
||||||
|
372,
|
||||||
|
"MUSIC covariance accumulation count",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
musicWorkload.musicJacobiWorstCasePairUpdates,
|
||||||
|
384,
|
||||||
|
"MUSIC 32-sweep Jacobi upper bound",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
musicWorkload.musicProjectionComplexMacs,
|
||||||
|
480,
|
||||||
|
"MUSIC fixed-K projection count",
|
||||||
|
);
|
||||||
|
assertEqual(musicWorkload.scanOperationCount, 1236, "MUSIC disclosed total work");
|
||||||
|
assertEqual(
|
||||||
|
musicWorkload.scanOperationKind,
|
||||||
|
"covariance + Jacobi upper bound + projection",
|
||||||
|
"MUSIC work is not mislabeled as DAS",
|
||||||
|
);
|
||||||
|
assertNear(
|
||||||
|
musicWorkload.distanceMiB,
|
||||||
|
(80 * 3 * 8) / 1024 ** 2,
|
||||||
|
1e-15,
|
||||||
|
"MUSIC Float64 distance table size",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Camera comparison signature groups array, algorithm, and backend variants only", () => {
|
test("Camera comparison signature groups array, algorithm, and backend variants only", () => {
|
||||||
@@ -382,7 +477,12 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => {
|
|||||||
test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
|
test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
|
||||||
const experiment = {
|
const experiment = {
|
||||||
scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 },
|
scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 },
|
||||||
|
frequencyBands: [
|
||||||
|
{ id: "low", minHz: 600, maxHz: 900, color: "#65f2bf" },
|
||||||
|
{ id: "high", minHz: 1100, maxHz: 1500, color: "#58c7ff" },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
const originalBands = structuredClone(experiment.frequencyBands);
|
||||||
const imageScene = {
|
const imageScene = {
|
||||||
sources: [{ id: "one", position: [0.2, -0.1, 4.5] }],
|
sources: [{ id: "one", position: [0.2, -0.1, 4.5] }],
|
||||||
image: { plane: { z: 4.5, xMin: -2, xMax: 3, yMin: -1.5, yMax: 2.5 } },
|
image: { plane: { z: 4.5, xMin: -2, xMax: 3, yMin: -1.5, yMax: 2.5 } },
|
||||||
@@ -433,6 +533,11 @@ test("Camera aligns an experiment to an imported scene without changing grid res
|
|||||||
};
|
};
|
||||||
assertEqual(alignExperimentToScene(experiment, movingScene), "source-bounds", "trajectory fallback");
|
assertEqual(alignExperimentToScene(experiment, movingScene), "source-bounds", "trajectory fallback");
|
||||||
assertEqual(experiment.scan.z, 5, "trajectory depths contribute to median scan depth");
|
assertEqual(experiment.scan.z, 5, "trajectory depths contribute to median scan depth");
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify(experiment.frequencyBands),
|
||||||
|
JSON.stringify(originalBands),
|
||||||
|
"scene alignment preserves every analysis band",
|
||||||
|
);
|
||||||
assertNear(experiment.scan.xMin, -2.9, 1e-12, "trajectory x minimum with padding");
|
assertNear(experiment.scan.xMin, -2.9, 1e-12, "trajectory x minimum with padding");
|
||||||
assertNear(experiment.scan.xMax, 4.9, 1e-12, "trajectory x maximum with padding");
|
assertNear(experiment.scan.xMax, 4.9, 1e-12, "trajectory x maximum with padding");
|
||||||
assertNear(experiment.scan.yMin, -4.2, 1e-12, "trajectory y minimum with padding");
|
assertNear(experiment.scan.yMin, -4.2, 1e-12, "trajectory y minimum with padding");
|
||||||
@@ -450,15 +555,31 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
};
|
};
|
||||||
const scene = {
|
const scene = {
|
||||||
id: "test-scene",
|
id: "test-scene",
|
||||||
sources: [{ id: "source-1", position: [0, 0, 2] }],
|
sampleRate: 8_000,
|
||||||
|
sources: [{
|
||||||
|
id: "source-1",
|
||||||
|
position: [0, 0, 2],
|
||||||
|
signal: { type: "tone", frequency: 5_000, phaseDeg: 0 },
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
const experiment = {
|
const experiment = {
|
||||||
id: "test-experiment",
|
id: "test-experiment",
|
||||||
arrayId: arrayDefinition.id,
|
arrayId: "declared-array-provenance",
|
||||||
sceneId: scene.id,
|
sceneId: "declared-scene-provenance",
|
||||||
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
||||||
beamforming: { algorithm: "das-phat", backend: "auto" },
|
beamforming: { algorithm: "das-phat", backend: "auto" },
|
||||||
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
|
stft: { fftSize: 256, hopSize: 128, window: "hann" },
|
||||||
|
frequencyBands: [{
|
||||||
|
id: "band-1",
|
||||||
|
minHz: 4_900,
|
||||||
|
maxHz: 5_100,
|
||||||
|
color: "#65f2bf",
|
||||||
|
}, {
|
||||||
|
id: "band-2",
|
||||||
|
minHz: 2_900,
|
||||||
|
maxHz: 3_100,
|
||||||
|
color: "#58c7ff",
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
const run = {
|
const run = {
|
||||||
algorithm: "das-phat",
|
algorithm: "das-phat",
|
||||||
@@ -471,6 +592,23 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
acousticModel: "shoebox",
|
acousticModel: "shoebox",
|
||||||
acousticPathCount: 38,
|
acousticPathCount: 38,
|
||||||
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
|
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
|
||||||
|
sampling: {
|
||||||
|
model: "ideal-real-sampler-no-antialias-filter",
|
||||||
|
sampleRate: 8_000,
|
||||||
|
nyquistHz: 4_000,
|
||||||
|
toneObservations: [{
|
||||||
|
scope: "source",
|
||||||
|
id: "source-1",
|
||||||
|
analogFrequencyHz: 5_000,
|
||||||
|
adcFrequencyHz: 3_000,
|
||||||
|
signedAdcFrequencyHz: -3_000,
|
||||||
|
aliased: true,
|
||||||
|
spectralInversion: true,
|
||||||
|
dcEdge: false,
|
||||||
|
nyquistEdge: false,
|
||||||
|
phaseDegenerate: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
|
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
|
||||||
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
||||||
@@ -481,6 +619,13 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||||
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||||
displayScale: "normalized",
|
displayScale: "normalized",
|
||||||
|
}, {
|
||||||
|
id: "band-2",
|
||||||
|
color: "#58c7ff",
|
||||||
|
rawScale: "score",
|
||||||
|
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||||
|
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||||
|
displayScale: "normalized",
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
const evaluations = new Map([["band-1", {
|
const evaluations = new Map([["band-1", {
|
||||||
@@ -529,6 +674,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
maximumDetections: 128,
|
maximumDetections: 128,
|
||||||
},
|
},
|
||||||
}]]);
|
}]]);
|
||||||
|
evaluations.set("band-2", evaluations.get("band-1"));
|
||||||
|
|
||||||
const result = createResultDocument({
|
const result = createResultDocument({
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
@@ -540,6 +686,19 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
createdAt: "2026-08-28T00:00:00.000Z",
|
createdAt: "2026-08-28T00:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assertEqual(result.schemaVersion, "1.1.0", "sampling-aware Result contract version");
|
||||||
|
assertEqual(result.arrayId, arrayDefinition.id, "Result records the Array actually used");
|
||||||
|
assertEqual(result.sceneId, scene.id, "Result records the Scene actually used");
|
||||||
|
assertEqual(
|
||||||
|
experiment.arrayId,
|
||||||
|
"declared-array-provenance",
|
||||||
|
"Result creation preserves the Experiment Array provenance label",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
experiment.sceneId,
|
||||||
|
"declared-scene-provenance",
|
||||||
|
"Result creation preserves the Experiment Scene provenance label",
|
||||||
|
);
|
||||||
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
|
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.algorithm, "das-phat", "actual algorithm is exported");
|
||||||
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
|
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
|
||||||
@@ -560,6 +719,41 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported");
|
assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported");
|
||||||
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
|
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
|
||||||
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
|
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
|
||||||
|
assertEqual(
|
||||||
|
result.signalStats.sampling.toneObservations[0].adcFrequencyHz,
|
||||||
|
3_000,
|
||||||
|
"ADC alias is exported",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.bands[0].frequencyObservation.adcBins.join(","),
|
||||||
|
"93,94,95,96,97,98,99",
|
||||||
|
"requested analog band exports every folded ADC bin",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.bands[0].frequencyObservation.equivalentBandIds.join(","),
|
||||||
|
"band-2",
|
||||||
|
"alias-equivalent band is disclosed",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.bands[1].frequencyObservation.equivalentBandIds.join(","),
|
||||||
|
"band-1",
|
||||||
|
"equivalence is symmetric",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.bands[0].frequencyObservation.localizationModel,
|
||||||
|
"spherical-steering-at-positive-adc-bin-frequency",
|
||||||
|
"actual DAS-family localization model is explicit",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.bands[0].frequencyObservation.originalCarrierUsed,
|
||||||
|
false,
|
||||||
|
"result does not imply hidden original-carrier steering",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.bands[0].evaluation.truthSelection,
|
||||||
|
"tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources",
|
||||||
|
"truth-selection policy is explicit",
|
||||||
|
);
|
||||||
|
|
||||||
const history = createHistorySummary({
|
const history = createHistorySummary({
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
@@ -572,10 +766,248 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
assertEqual(history.algorithm, "das-phat", "history records the actual algorithm");
|
assertEqual(history.algorithm, "das-phat", "history records the actual algorithm");
|
||||||
assertEqual(history.localizationMs, 10, "history records localization time");
|
assertEqual(history.localizationMs, 10, "history records localization time");
|
||||||
|
|
||||||
const issues = await validateResult(result, { experiment, scene });
|
const issues = await validateResult(result, {
|
||||||
|
experiment,
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
});
|
||||||
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
||||||
|
assertEqual(
|
||||||
|
(await validateResult(result)).length,
|
||||||
|
0,
|
||||||
|
"generated Result is self-consistent without external context",
|
||||||
|
);
|
||||||
|
|
||||||
const wrongExperiment = { ...experiment, arrayId: "another-array" };
|
const standaloneCorruption = structuredClone(result);
|
||||||
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
|
standaloneCorruption.signalStats.sampling.nyquistHz += 1;
|
||||||
assert(linkageIssues.some(({ path }) => path === "$.arrayId"), "validator catches snapshot linkage mismatch");
|
standaloneCorruption.signalStats.sampling.toneObservations.push(
|
||||||
|
structuredClone(standaloneCorruption.signalStats.sampling.toneObservations[0]),
|
||||||
|
);
|
||||||
|
standaloneCorruption.bands[0].frequencyObservation.adcBins.push(
|
||||||
|
standaloneCorruption.bands[0].frequencyObservation.adcBins[0],
|
||||||
|
);
|
||||||
|
standaloneCorruption.bands[0].frequencyObservation.equivalentBandIds.push("band-2");
|
||||||
|
standaloneCorruption.bands[0].frequencyObservation.localizationModel =
|
||||||
|
"band-limited-gcc-phat-geometric-lag";
|
||||||
|
const standaloneIssues = await validateResult(standaloneCorruption);
|
||||||
|
assert(
|
||||||
|
standaloneIssues.some(({ path }) => path === "$.signalStats.sampling.nyquistHz"),
|
||||||
|
"standalone validator checks Nyquist self-consistency",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
standaloneIssues.some(({ message }) => message.includes("duplicate tone observation")),
|
||||||
|
"standalone validator rejects duplicate tone provenance",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
standaloneIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
|
||||||
|
"standalone validator recomputes unique ADC bins",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
standaloneIssues.some(({ path }) =>
|
||||||
|
path.endsWith("frequencyObservation.equivalentBandIds")),
|
||||||
|
"standalone validator recomputes equivalent band IDs",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
standaloneIssues.some(({ path }) =>
|
||||||
|
path.endsWith("frequencyObservation.localizationModel")),
|
||||||
|
"standalone validator links localization model to actual algorithm",
|
||||||
|
);
|
||||||
|
|
||||||
|
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz += 1;
|
||||||
|
let semanticIssues = await validateResult(result, { experiment, scene });
|
||||||
|
assert(
|
||||||
|
semanticIssues.some(({ path }) => path.endsWith("signedAdcFrequencyHz")),
|
||||||
|
"validator recomputes the temporal alias",
|
||||||
|
);
|
||||||
|
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz -= 1;
|
||||||
|
|
||||||
|
result.bands[0].frequencyObservation.adcBins[0] += 1;
|
||||||
|
semanticIssues = await validateResult(result, { experiment, scene });
|
||||||
|
assert(
|
||||||
|
semanticIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
|
||||||
|
"validator recomputes folded ADC bins",
|
||||||
|
);
|
||||||
|
result.bands[0].frequencyObservation.adcBins[0] -= 1;
|
||||||
|
|
||||||
|
const missingBandResult = structuredClone(result);
|
||||||
|
missingBandResult.bands.pop();
|
||||||
|
semanticIssues = await validateResult(missingBandResult, { experiment, scene });
|
||||||
|
assert(
|
||||||
|
semanticIssues.some(({ message }) => message.includes("missing Experiment band result")),
|
||||||
|
"validator rejects incomplete Result band sets",
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrongArrayResult = structuredClone(result);
|
||||||
|
wrongArrayResult.arrayId = "another-array";
|
||||||
|
let linkageIssues = await validateResult(wrongArrayResult, {
|
||||||
|
experiment,
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
linkageIssues.some(({ path }) => path === "$.arrayId"),
|
||||||
|
"validator catches an actual Array snapshot linkage mismatch",
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrongSceneResult = structuredClone(result);
|
||||||
|
wrongSceneResult.sceneId = "another-scene";
|
||||||
|
linkageIssues = await validateResult(wrongSceneResult, {
|
||||||
|
experiment,
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
linkageIssues.some(({ path }) => path === "$.sceneId"),
|
||||||
|
"validator catches an actual Scene snapshot linkage mismatch",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC Result preserves its physical model and numerical audit", async () => {
|
||||||
|
const sampleRate = 8192;
|
||||||
|
const fftSize = 128;
|
||||||
|
const sampleCount = 256;
|
||||||
|
const arrayDefinition = {
|
||||||
|
id: "music-result-array",
|
||||||
|
microphones: [
|
||||||
|
{ id: "m0", position: [-0.1, 0.1, 0] },
|
||||||
|
{ id: "m1", position: [0.1, 0.1, 0] },
|
||||||
|
{ id: "m2", position: [-0.1, -0.1, 0] },
|
||||||
|
{ id: "m3", position: [0.1, -0.1, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const channels = arrayDefinition.microphones.map((_, microphoneIndex) =>
|
||||||
|
Float64Array.from(
|
||||||
|
{ length: sampleCount },
|
||||||
|
(_, sampleIndex) =>
|
||||||
|
Math.cos(
|
||||||
|
2 * Math.PI * 16 * sampleIndex / fftSize +
|
||||||
|
microphoneIndex * 0.21,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
const scene = {
|
||||||
|
id: "music-result-scene",
|
||||||
|
sampleRate,
|
||||||
|
duration: sampleCount / sampleRate,
|
||||||
|
speedOfSound: 343,
|
||||||
|
sources: [],
|
||||||
|
};
|
||||||
|
const experiment = {
|
||||||
|
id: "music-result-experiment",
|
||||||
|
arrayId: arrayDefinition.id,
|
||||||
|
sceneId: scene.id,
|
||||||
|
scan: {
|
||||||
|
rows: 2,
|
||||||
|
columns: 2,
|
||||||
|
xMin: -0.2,
|
||||||
|
xMax: 0.2,
|
||||||
|
yMin: -0.2,
|
||||||
|
yMax: 0.2,
|
||||||
|
z: 1,
|
||||||
|
},
|
||||||
|
frequencyBands: [
|
||||||
|
{ id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" },
|
||||||
|
],
|
||||||
|
stft: { fftSize, hopSize: 64, window: "hann" },
|
||||||
|
beamforming: {
|
||||||
|
algorithm: "music",
|
||||||
|
backend: "cpu",
|
||||||
|
minimumDistance: 0.01,
|
||||||
|
normalization: "none",
|
||||||
|
compensateFrequencyResponse: true,
|
||||||
|
musicSourceCountMethod: "fixed",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const localization = await computeLocalizationMap({
|
||||||
|
channels,
|
||||||
|
sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
});
|
||||||
|
const run = {
|
||||||
|
...localization,
|
||||||
|
signalStats: {
|
||||||
|
preNormalizationPeak: 1,
|
||||||
|
appliedScale: 1,
|
||||||
|
clippedSampleCount: 0,
|
||||||
|
sampling: {
|
||||||
|
model: "ideal-real-sampler-no-antialias-filter",
|
||||||
|
sampleRate,
|
||||||
|
nyquistHz: sampleRate / 2,
|
||||||
|
toneObservations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timingMs: {
|
||||||
|
total: 1,
|
||||||
|
signalGeneration: 0,
|
||||||
|
localization: 1,
|
||||||
|
beamforming: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = createResultDocument({
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
run,
|
||||||
|
evaluations: new Map([[
|
||||||
|
"single-bin",
|
||||||
|
{ peaks: [], metrics: { precision: 1, recall: 1 } },
|
||||||
|
]]),
|
||||||
|
inputHash: "b".repeat(64),
|
||||||
|
createdAt: "2026-08-29T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEqual(result.bands[0].rawMap.scale, "noise-projection", "raw q scale");
|
||||||
|
assertEqual(result.bands[0].rawMap.objectiveDirection, "minimum", "q is minimized");
|
||||||
|
assertEqual(result.musicModel.originalCarrierUsed, true, "configured carrier is used");
|
||||||
|
assertEqual(
|
||||||
|
result.musicModel.hypothesisSource,
|
||||||
|
"experiment-band-not-scene-truth",
|
||||||
|
"carrier comes from Experiment rather than Scene truth",
|
||||||
|
);
|
||||||
|
assertEqual(result.musicModel.diagonalLoading, "none", "no hidden diagonal loading");
|
||||||
|
assertEqual(result.musicModel.multibandFusion, "none-each-band-is-separate-single-analog-hypothesis", "no band fusion");
|
||||||
|
assertEqual(result.bands[0].musicDiagnostics.eigenvalues.length, 4, "all eigenvalues exported");
|
||||||
|
assert(result.bands[0].musicDiagnostics.eigensolver.numericallyVerified, "EVD audit exported");
|
||||||
|
assertEqual(result.bands[0].musicDiagnostics.sourceCount.value, 1, "fixed K exported");
|
||||||
|
const resultIssues = await validateResult(result, {
|
||||||
|
experiment,
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
});
|
||||||
|
assertEqual(
|
||||||
|
resultIssues.length,
|
||||||
|
0,
|
||||||
|
`MUSIC Result passes schema and semantic validation: ${JSON.stringify(resultIssues)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const corrupted = structuredClone(result);
|
||||||
|
corrupted.bands[0].musicDiagnostics.sourceCount.value = 4;
|
||||||
|
const issues = await validateResult(corrupted, {
|
||||||
|
experiment,
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
issues.some(({ path }) =>
|
||||||
|
path.endsWith("musicDiagnostics.sourceCount.value")),
|
||||||
|
"source count cannot equal microphone count",
|
||||||
|
);
|
||||||
|
|
||||||
|
const corruptedHypothesis = structuredClone(result);
|
||||||
|
corruptedHypothesis.bands[0].musicDiagnostics.frequencyHypothesis
|
||||||
|
.analogFrequencyHz += sampleRate;
|
||||||
|
const hypothesisIssues = await validateResult(corruptedHypothesis, {
|
||||||
|
experiment,
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
hypothesisIssues.some(({ path }) =>
|
||||||
|
path.endsWith(
|
||||||
|
"musicDiagnostics.frequencyHypothesis.analogFrequencyHz",
|
||||||
|
)),
|
||||||
|
"numerical diagnostics cannot claim a different analog carrier",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import {
|
||||||
|
computeCaCfarThresholdMultiplier,
|
||||||
|
computeOsCfarLogFalseAlarmProbability,
|
||||||
|
computeOsCfarThresholdMultiplier,
|
||||||
|
detectCaCfarFrame,
|
||||||
|
detectCfarFrame,
|
||||||
|
detectOsCfarFrame,
|
||||||
|
} from "/packages/acoustics-core/cfar.js";
|
||||||
|
import { fftInPlace } from "/packages/acoustics-core/fft.js";
|
||||||
|
import { hannWindow } from "/packages/acoustics-core/stft.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(
|
||||||
|
Math.abs(actual - expected) <= tolerance,
|
||||||
|
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRelativeNear(actual, expected, relativeTolerance, label) {
|
||||||
|
const scale = Math.max(Number.MIN_VALUE, Math.abs(expected));
|
||||||
|
assertNear(actual, expected, relativeTolerance * scale, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureError(callback) {
|
||||||
|
try {
|
||||||
|
callback();
|
||||||
|
} catch (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertError(callback, ErrorType, messageFragment, label) {
|
||||||
|
const error = captureError(callback);
|
||||||
|
assert(error instanceof ErrorType, `${label}: expected ${ErrorType.name}`);
|
||||||
|
assert(
|
||||||
|
error.message.includes(messageFragment),
|
||||||
|
`${label}: expected message containing ${messageFragment}, received ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPowerFixture(fftSize, fill = 1) {
|
||||||
|
return new Float64Array(fftSize / 2 + 1).fill(fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBaseOptions(overrides = {}) {
|
||||||
|
const fftSize = overrides.fftSize ?? 64;
|
||||||
|
return {
|
||||||
|
powerSpectrum: overrides.powerSpectrum ?? createPowerFixture(fftSize),
|
||||||
|
detector: "ca-cfar",
|
||||||
|
sampleRate: 8192,
|
||||||
|
fftSize,
|
||||||
|
window: "hann",
|
||||||
|
frameComplete: true,
|
||||||
|
channelId: "mic-0",
|
||||||
|
frameIndex: 0,
|
||||||
|
frameStartSample: 0,
|
||||||
|
temporalAverageCount: 1,
|
||||||
|
microphoneAverageCount: 1,
|
||||||
|
trainingCellsPerSide: 4,
|
||||||
|
guardCellsPerSide: 2,
|
||||||
|
nominalFalseAlarmProbability: 1e-3,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHannTonePower({
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
frequencyHz,
|
||||||
|
amplitude = 1,
|
||||||
|
}) {
|
||||||
|
const window = hannWindow(fftSize);
|
||||||
|
const real = new Float64Array(fftSize);
|
||||||
|
const imaginary = new Float64Array(fftSize);
|
||||||
|
for (let sample = 0; sample < fftSize; sample += 1) {
|
||||||
|
real[sample] =
|
||||||
|
amplitude *
|
||||||
|
Math.sin((2 * Math.PI * frequencyHz * sample) / sampleRate) *
|
||||||
|
window[sample];
|
||||||
|
}
|
||||||
|
fftInPlace(real, imaginary);
|
||||||
|
return Float64Array.from(
|
||||||
|
{ length: fftSize / 2 + 1 },
|
||||||
|
(_, bin) => real[bin] * real[bin] + imaginary[bin] * imaginary[bin],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("CA-CFAR mean-reference multiplier recovers the requested nominal per-CUT Pfa", () => {
|
||||||
|
const trainingCellCount = 16;
|
||||||
|
const nominalFalseAlarmProbability = 1e-3;
|
||||||
|
const multiplier = computeCaCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
});
|
||||||
|
const recoveredProbability =
|
||||||
|
(1 + multiplier / trainingCellCount) ** -trainingCellCount;
|
||||||
|
assertRelativeNear(
|
||||||
|
recoveredProbability,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
2e-14,
|
||||||
|
"CA nominal false-alarm probability",
|
||||||
|
);
|
||||||
|
|
||||||
|
const nearOneProbability = 1 - 1e-12;
|
||||||
|
const nearOneMultiplier = computeCaCfarThresholdMultiplier({
|
||||||
|
trainingCellCount: 1024,
|
||||||
|
nominalFalseAlarmProbability: nearOneProbability,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
nearOneMultiplier > 0 && Number.isFinite(nearOneMultiplier),
|
||||||
|
"expm1 form preserves a finite positive multiplier near Pfa=1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OS-CFAR log-product bisection recovers Pfa and the analytic rank-one case", () => {
|
||||||
|
const trainingCellCount = 16;
|
||||||
|
const rank = 12;
|
||||||
|
const nominalFalseAlarmProbability = 1e-4;
|
||||||
|
const multiplier = computeOsCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
rank,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
});
|
||||||
|
const recoveredLogProbability = computeOsCfarLogFalseAlarmProbability({
|
||||||
|
trainingCellCount,
|
||||||
|
rank,
|
||||||
|
thresholdMultiplier: multiplier,
|
||||||
|
});
|
||||||
|
assertNear(
|
||||||
|
recoveredLogProbability,
|
||||||
|
Math.log(nominalFalseAlarmProbability),
|
||||||
|
2e-12,
|
||||||
|
"OS log false-alarm probability",
|
||||||
|
);
|
||||||
|
|
||||||
|
const rankOneProbability = 0.25;
|
||||||
|
const rankOneMultiplier = computeOsCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
rank: 1,
|
||||||
|
nominalFalseAlarmProbability: rankOneProbability,
|
||||||
|
});
|
||||||
|
assertNear(
|
||||||
|
rankOneMultiplier,
|
||||||
|
trainingCellCount * (1 / rankOneProbability - 1),
|
||||||
|
1e-11,
|
||||||
|
"rank-one OS multiplier",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => computeOsCfarThresholdMultiplier({
|
||||||
|
trainingCellCount,
|
||||||
|
rank,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
maximumIterations: 1,
|
||||||
|
}),
|
||||||
|
RangeError,
|
||||||
|
"did not converge",
|
||||||
|
"explicit OS bisection iteration cap",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CA-CFAR preserves every Hann-frame bin and detects an exact-bin tone", () => {
|
||||||
|
const sampleRate = 8192;
|
||||||
|
const fftSize = 256;
|
||||||
|
const toneBin = 43;
|
||||||
|
const powerSpectrum = createHannTonePower({
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
frequencyHz: toneBin * sampleRate / fftSize,
|
||||||
|
});
|
||||||
|
const result = detectCaCfarFrame(createBaseOptions({
|
||||||
|
powerSpectrum,
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
trainingCellsPerSide: 12,
|
||||||
|
guardCellsPerSide: 3,
|
||||||
|
}));
|
||||||
|
const tone = result.bins[toneBin];
|
||||||
|
|
||||||
|
assertEqual(result.bins.length, fftSize / 2 + 1, "all one-sided bins retained");
|
||||||
|
assertEqual(tone.status, "detected", "exact-bin Hann tone detected");
|
||||||
|
assertEqual(tone.cutPower, powerSpectrum[toneBin], "raw CUT power retained");
|
||||||
|
assert(tone.referencePower >= 0, "raw reference statistic retained");
|
||||||
|
assert(tone.thresholdPower >= 0, "raw threshold retained");
|
||||||
|
assert(tone.thresholdRatio > 1, "raw threshold ratio exceeds one");
|
||||||
|
assertEqual(
|
||||||
|
result.parameters.referenceStatistic,
|
||||||
|
"arithmetic-mean-power",
|
||||||
|
"CA mean normalization is explicit",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.calibration.calibratedFalseAlarmProbability,
|
||||||
|
null,
|
||||||
|
"nominal Pfa is not mislabeled as empirical calibration",
|
||||||
|
);
|
||||||
|
assertEqual(result.processing.clustering, false, "no bin clustering");
|
||||||
|
assertEqual(result.processing.temporalPersistence, false, "no persistence");
|
||||||
|
assertEqual(result.processing.detectorFusion, false, "no CA/OS fusion");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full symmetric references exclude DC, Nyquist, and every insufficient edge bin", () => {
|
||||||
|
const fftSize = 32;
|
||||||
|
const result = detectCaCfarFrame(createBaseOptions({
|
||||||
|
fftSize,
|
||||||
|
powerSpectrum: createPowerFixture(fftSize),
|
||||||
|
trainingCellsPerSide: 2,
|
||||||
|
guardCellsPerSide: 1,
|
||||||
|
nominalFalseAlarmProbability: 0.1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
assertEqual(result.bins[0].status, "untested", "DC status");
|
||||||
|
assertEqual(result.bins[0].reason, "real-only-dc-bin", "DC reason");
|
||||||
|
assertEqual(result.bins[16].status, "untested", "Nyquist status");
|
||||||
|
assertEqual(
|
||||||
|
result.bins[16].reason,
|
||||||
|
"real-only-nyquist-bin",
|
||||||
|
"Nyquist reason",
|
||||||
|
);
|
||||||
|
for (const bin of [1, 2, 3, 13, 14, 15]) {
|
||||||
|
assertEqual(result.bins[bin].status, "untested", `edge bin ${bin}`);
|
||||||
|
assertEqual(
|
||||||
|
result.bins[bin].reason,
|
||||||
|
"insufficient-full-symmetric-reference-window",
|
||||||
|
`edge reason ${bin}`,
|
||||||
|
);
|
||||||
|
assertEqual(result.bins[bin].cutPower, 1, `edge CUT power ${bin}`);
|
||||||
|
}
|
||||||
|
for (let bin = 4; bin <= 12; bin += 1) {
|
||||||
|
assertEqual(result.bins[bin].status, "not-detected", `tested bin ${bin}`);
|
||||||
|
assertEqual(result.bins[bin].referencePower, 1, `fixed-N mean ${bin}`);
|
||||||
|
}
|
||||||
|
assertEqual(result.parameters.trainingCellCount, 4, "N=2R");
|
||||||
|
assertEqual(result.parameters.firstTestedBin, 4, "first fully supported bin");
|
||||||
|
assertEqual(result.parameters.lastTestedBin, 12, "last fully supported bin");
|
||||||
|
assertEqual(result.parameters.testedBinCount, 9, "tested bin count");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CA uses a strict threshold comparison and does not promote a tie", () => {
|
||||||
|
const fftSize = 8;
|
||||||
|
const nominalFalseAlarmProbability = (1 + 1 / 2) ** -2;
|
||||||
|
const result = detectCaCfarFrame(createBaseOptions({
|
||||||
|
fftSize,
|
||||||
|
powerSpectrum: createPowerFixture(fftSize),
|
||||||
|
trainingCellsPerSide: 1,
|
||||||
|
guardCellsPerSide: 0,
|
||||||
|
nominalFalseAlarmProbability,
|
||||||
|
}));
|
||||||
|
assertNear(result.parameters.thresholdMultiplier, 1, 1e-15, "unit alpha");
|
||||||
|
assertEqual(result.bins[2].cutPower, 1, "tie CUT");
|
||||||
|
assertNear(result.bins[2].thresholdPower, 1, 1e-15, "tie threshold");
|
||||||
|
assertEqual(result.bins[2].status, "not-detected", "strict greater-than rule");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero thresholds preserve infinite-ratio meaning without non-JSON Infinity", () => {
|
||||||
|
const fftSize = 16;
|
||||||
|
const powerSpectrum = createPowerFixture(fftSize, 0);
|
||||||
|
powerSpectrum[4] = 1;
|
||||||
|
const result = detectCaCfarFrame(createBaseOptions({
|
||||||
|
fftSize,
|
||||||
|
powerSpectrum,
|
||||||
|
trainingCellsPerSide: 1,
|
||||||
|
guardCellsPerSide: 0,
|
||||||
|
nominalFalseAlarmProbability: 0.1,
|
||||||
|
}));
|
||||||
|
const detected = result.bins[4];
|
||||||
|
assertEqual(detected.status, "detected", "positive CUT exceeds zero threshold");
|
||||||
|
assertEqual(detected.thresholdPower, 0, "zero threshold retained");
|
||||||
|
assertEqual(detected.thresholdRatio, null, "non-finite ratio is JSON-safe null");
|
||||||
|
assertEqual(
|
||||||
|
detected.thresholdRatioStatus,
|
||||||
|
"positive-infinity-zero-threshold",
|
||||||
|
"positive infinite limit remains explicit",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
JSON.stringify(result).includes('"positive-infinity-zero-threshold"'),
|
||||||
|
"JSON serialization preserves the ratio meaning",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OS-CFAR remains a separate detector and resists one high training contaminant", () => {
|
||||||
|
const fftSize = 128;
|
||||||
|
const cutBin = 32;
|
||||||
|
const powerSpectrum = createPowerFixture(fftSize);
|
||||||
|
powerSpectrum[cutBin] = 100;
|
||||||
|
powerSpectrum[23] = 1000;
|
||||||
|
const common = createBaseOptions({
|
||||||
|
fftSize,
|
||||||
|
powerSpectrum,
|
||||||
|
trainingCellsPerSide: 8,
|
||||||
|
guardCellsPerSide: 1,
|
||||||
|
nominalFalseAlarmProbability: 0.1,
|
||||||
|
});
|
||||||
|
const ca = detectCaCfarFrame(common);
|
||||||
|
const os = detectOsCfarFrame({ ...common, rank: 12 });
|
||||||
|
|
||||||
|
assertEqual(ca.bins[cutBin].status, "not-detected", "CA masking exposed");
|
||||||
|
assertEqual(os.bins[cutBin].status, "detected", "OS order statistic result");
|
||||||
|
assertEqual(os.parameters.rank, 12, "explicit OS rank");
|
||||||
|
assertEqual(os.parameters.rankConvention, "1-based-ascending", "rank convention");
|
||||||
|
assertEqual(os.parameters.referencePower, undefined, "no invented fused reference");
|
||||||
|
assertEqual(os.processing.detectorFusion, false, "detectors remain separate");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a sampled above-Nyquist tone returns only its observed ADC bin", () => {
|
||||||
|
const sampleRate = 8192;
|
||||||
|
const fftSize = 256;
|
||||||
|
const analogFrequencyHz = 6144;
|
||||||
|
const expectedAdcBin = 64;
|
||||||
|
const expectedAdcFrequencyHz = 2048;
|
||||||
|
const powerSpectrum = createHannTonePower({
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
frequencyHz: analogFrequencyHz,
|
||||||
|
});
|
||||||
|
const result = detectOsCfarFrame(createBaseOptions({
|
||||||
|
powerSpectrum,
|
||||||
|
sampleRate,
|
||||||
|
fftSize,
|
||||||
|
trainingCellsPerSide: 12,
|
||||||
|
guardCellsPerSide: 3,
|
||||||
|
rank: 18,
|
||||||
|
}));
|
||||||
|
const observed = result.bins[expectedAdcBin];
|
||||||
|
|
||||||
|
assertEqual(observed.status, "detected", "sampled alias detected");
|
||||||
|
assertNear(
|
||||||
|
observed.adcCenterFrequencyHz,
|
||||||
|
expectedAdcFrequencyHz,
|
||||||
|
0,
|
||||||
|
"observed ADC center frequency",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.spectrum.analogFrequencyOrAliasOrderInferred,
|
||||||
|
false,
|
||||||
|
"no analog alias-order inference",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
Object.prototype.hasOwnProperty.call(observed, "analogFrequencyHz"),
|
||||||
|
false,
|
||||||
|
"bin result contains no invented analog frequency",
|
||||||
|
);
|
||||||
|
assertEqual(result.bands, undefined, "no bins-to-band fusion");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CFAR rejects nonfinite power, invalid configuration, partial frames, and averaging", () => {
|
||||||
|
const nonfinite = createPowerFixture(64);
|
||||||
|
nonfinite[10] = Number.NaN;
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({ powerSpectrum: nonfinite })),
|
||||||
|
TypeError,
|
||||||
|
"powerSpectrum[10] must be finite",
|
||||||
|
"NaN power",
|
||||||
|
);
|
||||||
|
|
||||||
|
const negative = createPowerFixture(64);
|
||||||
|
negative[10] = -1;
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({ powerSpectrum: negative })),
|
||||||
|
RangeError,
|
||||||
|
"must be non-negative",
|
||||||
|
"negative power",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({
|
||||||
|
powerSpectrum: new Float64Array(32),
|
||||||
|
})),
|
||||||
|
RangeError,
|
||||||
|
"exactly 33",
|
||||||
|
"wrong one-sided shape",
|
||||||
|
);
|
||||||
|
for (const probability of [0, 1, Number.NaN]) {
|
||||||
|
assertError(
|
||||||
|
() => computeCaCfarThresholdMultiplier({
|
||||||
|
trainingCellCount: 8,
|
||||||
|
nominalFalseAlarmProbability: probability,
|
||||||
|
}),
|
||||||
|
RangeError,
|
||||||
|
"strictly inside 0..1",
|
||||||
|
`invalid Pfa ${probability}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertError(
|
||||||
|
() => detectOsCfarFrame(createBaseOptions({ rank: 0 })),
|
||||||
|
RangeError,
|
||||||
|
"1-based ascending",
|
||||||
|
"zero OS rank",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectOsCfarFrame(createBaseOptions({ rank: 9 })),
|
||||||
|
RangeError,
|
||||||
|
"inside 1..8",
|
||||||
|
"OS rank above N",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({ trainingCellsPerSide: 0 })),
|
||||||
|
TypeError,
|
||||||
|
"trainingCellsPerSide must be a positive safe integer",
|
||||||
|
"zero training cells",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({ guardCellsPerSide: -1 })),
|
||||||
|
TypeError,
|
||||||
|
"guardCellsPerSide must be a non-negative safe integer",
|
||||||
|
"negative guard cells",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({
|
||||||
|
trainingCellsPerSide: 16,
|
||||||
|
guardCellsPerSide: 0,
|
||||||
|
})),
|
||||||
|
RangeError,
|
||||||
|
"leave no interior bin",
|
||||||
|
"reference window larger than the usable spectrum",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectCfarFrame(createBaseOptions({ window: "rectangular" })),
|
||||||
|
RangeError,
|
||||||
|
'window must be "hann"',
|
||||||
|
"non-Hann window",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({ frameComplete: false })),
|
||||||
|
RangeError,
|
||||||
|
"partial frames are not tested",
|
||||||
|
"partial frame",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({ temporalAverageCount: 2 })),
|
||||||
|
RangeError,
|
||||||
|
"temporalAverageCount must equal one",
|
||||||
|
"temporal averaging",
|
||||||
|
);
|
||||||
|
assertError(
|
||||||
|
() => detectCaCfarFrame(createBaseOptions({ microphoneAverageCount: 2 })),
|
||||||
|
RangeError,
|
||||||
|
"microphoneAverageCount must equal one",
|
||||||
|
"microphone averaging",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OS-CFAR is deterministic and sorting never mutates the caller spectrum", () => {
|
||||||
|
const fftSize = 128;
|
||||||
|
const powerSpectrum = Float64Array.from(
|
||||||
|
{ length: fftSize / 2 + 1 },
|
||||||
|
(_, bin) => 1 + ((bin * 17) % 23) / 11,
|
||||||
|
);
|
||||||
|
powerSpectrum[28] = 40;
|
||||||
|
powerSpectrum[41] = 75;
|
||||||
|
const before = Float64Array.from(powerSpectrum);
|
||||||
|
const options = createBaseOptions({
|
||||||
|
fftSize,
|
||||||
|
powerSpectrum,
|
||||||
|
trainingCellsPerSide: 8,
|
||||||
|
guardCellsPerSide: 2,
|
||||||
|
rank: 12,
|
||||||
|
});
|
||||||
|
const first = detectOsCfarFrame(options);
|
||||||
|
const second = detectOsCfarFrame(options);
|
||||||
|
|
||||||
|
assertEqual(JSON.stringify(second), JSON.stringify(first), "repeatable result");
|
||||||
|
assertEqual(powerSpectrum.length, before.length, "input length retained");
|
||||||
|
for (let bin = 0; bin < before.length; bin += 1) {
|
||||||
|
assertEqual(powerSpectrum[bin], before[bin], `input power ${bin}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,6 +4,14 @@ import {
|
|||||||
validateResult,
|
validateResult,
|
||||||
validateScene,
|
validateScene,
|
||||||
} from "/packages/contracts/validation.js";
|
} from "/packages/contracts/validation.js";
|
||||||
|
import {
|
||||||
|
describeFrequencyAliasing,
|
||||||
|
observeFrequencyBand,
|
||||||
|
} from "/packages/acoustics-core/sampling.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||||
|
estimateSynthesisWorkingSet,
|
||||||
|
} from "/packages/acoustics-core/propagation.js";
|
||||||
|
|
||||||
async function loadJson(path) {
|
async function loadJson(path) {
|
||||||
const response = await fetch(path);
|
const response = await fetch(path);
|
||||||
@@ -11,6 +19,77 @@ async function loadJson(path) {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resultFrequencyObservation(scene, experiment, band, equivalentBandIds = []) {
|
||||||
|
const observation = observeFrequencyBand(
|
||||||
|
band,
|
||||||
|
scene.sampleRate,
|
||||||
|
experiment.stft.fftSize,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
model: observation.model,
|
||||||
|
sampleRate: observation.sampleRate,
|
||||||
|
nyquistHz: observation.nyquistHz,
|
||||||
|
fftSize: observation.fftSize,
|
||||||
|
binWidthHz: observation.binWidthHz,
|
||||||
|
requestedMinHz: observation.requestedMinHz,
|
||||||
|
requestedMaxHz: observation.requestedMaxHz,
|
||||||
|
aliased: observation.aliased,
|
||||||
|
crossesFirstNyquist: observation.crossesFirstNyquist,
|
||||||
|
localizationInputModel: observation.localizationInputModel,
|
||||||
|
selectionModel: observation.selectionModel,
|
||||||
|
localizationModel:
|
||||||
|
experiment.beamforming?.algorithm === "gcc-phat"
|
||||||
|
? "band-limited-gcc-phat-geometric-lag"
|
||||||
|
: experiment.beamforming?.algorithm === "music"
|
||||||
|
? "near-field-music-at-positive-adc-bin-frequency"
|
||||||
|
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||||
|
originalCarrierUsed: observation.originalCarrierUsed,
|
||||||
|
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
|
||||||
|
foldBoundaryCount: observation.foldBoundaryCount,
|
||||||
|
continuousManyToOne: observation.continuousManyToOne,
|
||||||
|
gridManyToOne: observation.gridManyToOne,
|
||||||
|
foldSegments: observation.foldSegments.map((segment) => ({ ...segment })),
|
||||||
|
adcBins: [...observation.bins],
|
||||||
|
adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })),
|
||||||
|
adcBinSetKey: observation.adcBinSetKey,
|
||||||
|
equivalentBandIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultSampling(scene) {
|
||||||
|
const toneObservations = [];
|
||||||
|
for (const [scope, entries] of [
|
||||||
|
["source", scene.sources ?? []],
|
||||||
|
["ambient", scene.ambient ?? []],
|
||||||
|
]) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.signal?.type !== "tone") continue;
|
||||||
|
const observation = describeFrequencyAliasing(
|
||||||
|
entry.signal.frequency,
|
||||||
|
scene.sampleRate,
|
||||||
|
);
|
||||||
|
toneObservations.push({
|
||||||
|
scope,
|
||||||
|
id: entry.id,
|
||||||
|
analogFrequencyHz: observation.analogFrequencyHz,
|
||||||
|
adcFrequencyHz: observation.adcFrequencyHz,
|
||||||
|
signedAdcFrequencyHz: observation.signedAdcFrequencyHz,
|
||||||
|
aliased: observation.aliased,
|
||||||
|
spectralInversion: observation.spectralInversion,
|
||||||
|
dcEdge: observation.dcEdge,
|
||||||
|
nyquistEdge: observation.nyquistEdge,
|
||||||
|
phaseDegenerate: observation.phaseDegenerate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
model: "ideal-real-sampler-no-antialias-filter",
|
||||||
|
sampleRate: scene.sampleRate,
|
||||||
|
nyquistHz: scene.sampleRate / 2,
|
||||||
|
toneObservations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const RECTANGULAR_ARRAY_EXAMPLES = [
|
const RECTANGULAR_ARRAY_EXAMPLES = [
|
||||||
{ id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 },
|
{ 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-4x3-450mm", microphoneCount: 12, columns: 4, rows: 3, width: 0.45, height: 0.45 },
|
||||||
@@ -157,17 +236,26 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
|
|||||||
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => {
|
test("네 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => {
|
||||||
const [arrayDefinition, scene, baseline] = await Promise.all([
|
const [arrayDefinition, scene, baseline] = await Promise.all([
|
||||||
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const algorithm of ["das", "das-phat", "gcc-phat"]) {
|
for (const algorithm of ["das", "das-phat", "gcc-phat", "music"]) {
|
||||||
const experiment = structuredClone(baseline);
|
const experiment = structuredClone(baseline);
|
||||||
experiment.beamforming.algorithm = algorithm;
|
experiment.beamforming.algorithm = algorithm;
|
||||||
experiment.beamforming.backend = algorithm === "gcc-phat" ? "cpu" : "auto";
|
experiment.beamforming.backend =
|
||||||
|
algorithm === "gcc-phat" || algorithm === "music" ? "cpu" : "auto";
|
||||||
|
if (algorithm === "music") {
|
||||||
|
experiment.frequencyBands = [
|
||||||
|
{ id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" },
|
||||||
|
];
|
||||||
|
experiment.beamforming.normalization = "none";
|
||||||
|
experiment.beamforming.musicSourceCountMethod = "fixed";
|
||||||
|
experiment.beamforming.musicSourceCount = 1;
|
||||||
|
}
|
||||||
assertEqual(
|
assertEqual(
|
||||||
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
|
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
|
||||||
0,
|
0,
|
||||||
@@ -175,6 +263,31 @@ test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoWithStaleK = structuredClone(baseline);
|
||||||
|
autoWithStaleK.frequencyBands = [
|
||||||
|
{ id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" },
|
||||||
|
];
|
||||||
|
autoWithStaleK.stft.hopSize = autoWithStaleK.stft.fftSize;
|
||||||
|
Object.assign(autoWithStaleK.beamforming, {
|
||||||
|
algorithm: "music",
|
||||||
|
backend: "cpu",
|
||||||
|
normalization: "none",
|
||||||
|
musicSourceCountMethod: "mdl",
|
||||||
|
musicSourceCount: 8,
|
||||||
|
});
|
||||||
|
const smallArray = {
|
||||||
|
...arrayDefinition,
|
||||||
|
microphones: arrayDefinition.microphones.slice(0, 4),
|
||||||
|
};
|
||||||
|
assertEqual(
|
||||||
|
(await validateExperiment(autoWithStaleK, {
|
||||||
|
arrayDefinition: smallArray,
|
||||||
|
scene,
|
||||||
|
})).length,
|
||||||
|
0,
|
||||||
|
"MDL ignores disabled stale fixed K after an array-size change",
|
||||||
|
);
|
||||||
|
|
||||||
const incompatible = structuredClone(baseline);
|
const incompatible = structuredClone(baseline);
|
||||||
incompatible.beamforming.algorithm = "gcc-phat";
|
incompatible.beamforming.algorithm = "gcc-phat";
|
||||||
incompatible.beamforming.backend = "webgpu";
|
incompatible.beamforming.backend = "webgpu";
|
||||||
@@ -194,7 +307,7 @@ test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다
|
|||||||
"GCC-PHAT requires two microphones",
|
"GCC-PHAT requires two microphones",
|
||||||
);
|
);
|
||||||
|
|
||||||
incompatible.beamforming.algorithm = "music";
|
incompatible.beamforming.algorithm = "invented-localizer";
|
||||||
issues = await validateExperiment(incompatible, { arrayDefinition, scene });
|
issues = await validateExperiment(incompatible, { arrayDefinition, scene });
|
||||||
assert(
|
assert(
|
||||||
issues.some(({ path }) => path === "$.beamforming.algorithm"),
|
issues.some(({ path }) => path === "$.beamforming.algorithm"),
|
||||||
@@ -329,7 +442,7 @@ test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Nyquist를 넘는 tone을 거부한다", async () => {
|
test("Nyquist를 넘는 tone을 ideal ADC alias 실험으로 허용한다", async () => {
|
||||||
const scene = {
|
const scene = {
|
||||||
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
||||||
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 },
|
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 },
|
||||||
@@ -338,7 +451,153 @@ test("Nyquist를 넘는 tone을 거부한다", async () => {
|
|||||||
signal: { type: "tone", frequency: 5000 },
|
signal: { type: "tone", frequency: 5000 },
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency")));
|
assertEqual((await validateScene(scene)).length, 0, "above-Nyquist tone is structurally valid");
|
||||||
|
|
||||||
|
scene.sources[0].signal.frequency = 1_000_001;
|
||||||
|
assert(
|
||||||
|
(await validateScene(scene)).some(({ path }) =>
|
||||||
|
path === "$.sources[0].signal.frequency"),
|
||||||
|
"tone above the explicit numerical model range is rejected, not silently rounded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Experiment band는 Nyquist 위에서도 허용하되 FFT bin center가 하나는 있어야 한다", async () => {
|
||||||
|
const [scene, experiment] = await Promise.all([
|
||||||
|
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||||
|
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||||
|
]);
|
||||||
|
experiment.frequencyBands[0].minHz = 24_900;
|
||||||
|
experiment.frequencyBands[0].maxHz = 25_100;
|
||||||
|
assertEqual(
|
||||||
|
(await validateExperiment(experiment, { scene })).length,
|
||||||
|
0,
|
||||||
|
"above-Nyquist band with folded bin centers is valid",
|
||||||
|
);
|
||||||
|
|
||||||
|
experiment.frequencyBands[0].minHz = 1_000.1;
|
||||||
|
experiment.frequencyBands[0].maxHz = 1_000.2;
|
||||||
|
let issues = await validateExperiment(experiment, { scene });
|
||||||
|
assert(
|
||||||
|
issues.some(({ path, message }) =>
|
||||||
|
path === "$.frequencyBands[0]" && message.includes("bin center")),
|
||||||
|
"a sub-bin band fails validation before runtime",
|
||||||
|
);
|
||||||
|
|
||||||
|
experiment.frequencyBands[0].minHz = 999_900;
|
||||||
|
experiment.frequencyBands[0].maxHz = 1_000_001;
|
||||||
|
issues = await validateExperiment(experiment, { scene });
|
||||||
|
assert(
|
||||||
|
issues.some(({ path }) => path === "$.frequencyBands[0].maxHz"),
|
||||||
|
"band above the explicit numerical model range is rejected",
|
||||||
|
);
|
||||||
|
|
||||||
|
experiment.frequencyBands = {};
|
||||||
|
issues = await validateExperiment(experiment, { scene });
|
||||||
|
assert(
|
||||||
|
issues.some(({ path }) => path === "$.frequencyBands"),
|
||||||
|
"malformed frequencyBands returns validation issues instead of throwing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("analytic tone-only Scene은 존재하지 않는 delay FFT 메모리로 거부하지 않는다", async () => {
|
||||||
|
const [arrayDefinition, scene, experiment] = 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"),
|
||||||
|
]);
|
||||||
|
arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 1);
|
||||||
|
scene.sampleRate = 192_000;
|
||||||
|
scene.duration = 30;
|
||||||
|
scene.sources = Array.from({ length: 32 }, (_, index) => ({
|
||||||
|
...structuredClone(scene.sources[0]),
|
||||||
|
id: `tone-${index + 1}`,
|
||||||
|
name: `Tone ${index + 1}`,
|
||||||
|
duration: 30,
|
||||||
|
}));
|
||||||
|
const issues = await validateExperiment(experiment, { arrayDefinition, scene });
|
||||||
|
assert(
|
||||||
|
!issues.some(({ message }) => message.includes("512 MiB")),
|
||||||
|
`analytic tones must not allocate sampled source/FFT buffers: ${JSON.stringify(issues)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Experiment preflight는 runtime과 같은 source-streaming shoebox delay working set을 쓴다", async () => {
|
||||||
|
const [arrayDefinition, scene, experiment] = 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"),
|
||||||
|
]);
|
||||||
|
arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 16);
|
||||||
|
scene.sampleRate = 192_000;
|
||||||
|
scene.duration = 4_000_000 / scene.sampleRate;
|
||||||
|
scene.sources = [{
|
||||||
|
...structuredClone(scene.sources[0]),
|
||||||
|
id: "long-sampled-source",
|
||||||
|
name: "Long sampled source",
|
||||||
|
position: [0, 0, 1],
|
||||||
|
startTime: 0,
|
||||||
|
duration: scene.duration,
|
||||||
|
signal: { type: "noise", color: "white", seedOffset: 1 },
|
||||||
|
}];
|
||||||
|
scene.ambient = [];
|
||||||
|
scene.acoustics = {
|
||||||
|
model: "shoebox",
|
||||||
|
room: { min: [-10, -10, -10], max: [10, 10, 10] },
|
||||||
|
reflections: { order: 0, wallGainDb: -12, scattering: 0 },
|
||||||
|
diffuseReverb: {
|
||||||
|
enabled: true,
|
||||||
|
rt60Seconds: 4,
|
||||||
|
gainDb: -18,
|
||||||
|
tapCount: 1,
|
||||||
|
},
|
||||||
|
occluders: [],
|
||||||
|
};
|
||||||
|
experiment.beamforming.minimumDistance = 0.01;
|
||||||
|
|
||||||
|
const workingSet = estimateSynthesisWorkingSet({
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
minimumDistance: experiment.beamforming.minimumDistance,
|
||||||
|
});
|
||||||
|
assertEqual(workingSet.sampleCount, 4_000_000, "fixture sample count");
|
||||||
|
assertEqual(
|
||||||
|
workingSet.maximumTransient.fftSize,
|
||||||
|
8_388_608,
|
||||||
|
"diffuse delay crosses the FFT power-of-two boundary",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
workingSet.estimatedBufferBytes > DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||||
|
"shared runtime estimate exceeds 512 MiB",
|
||||||
|
);
|
||||||
|
const issues = await validateExperiment(experiment, {
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
issues.some(({ message }) =>
|
||||||
|
message.includes("source-streaming") && message.includes("512 MiB")),
|
||||||
|
`validator must reject the same shared estimate: ${JSON.stringify(issues)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.sources[0].signal = { type: "tone", frequency: 1_000, phaseDeg: 0 };
|
||||||
|
const toneWorkingSet = estimateSynthesisWorkingSet({
|
||||||
|
scene,
|
||||||
|
arrayDefinition,
|
||||||
|
minimumDistance: experiment.beamforming.minimumDistance,
|
||||||
|
});
|
||||||
|
assertEqual(
|
||||||
|
toneWorkingSet.estimatedBufferBytes,
|
||||||
|
toneWorkingSet.channelBytes,
|
||||||
|
"analytic tone has no sampled FFT transient",
|
||||||
|
);
|
||||||
|
const toneIssues = await validateExperiment(experiment, {
|
||||||
|
arrayDefinition,
|
||||||
|
scene,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
!toneIssues.some(({ message }) => message.includes("512 MiB")),
|
||||||
|
"validator does not charge analytic tone for a nonexistent delay FFT",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("NaN 좌표를 거부한다", async () => {
|
test("NaN 좌표를 거부한다", async () => {
|
||||||
@@ -395,7 +654,7 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
|||||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||||
]);
|
]);
|
||||||
const result = {
|
const result = {
|
||||||
schemaVersion: "1.0.0",
|
schemaVersion: "1.1.0",
|
||||||
kind: "micarrsim.result",
|
kind: "micarrsim.result",
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
arrayId: experiment.arrayId,
|
arrayId: experiment.arrayId,
|
||||||
@@ -404,11 +663,26 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
|||||||
backend: "cpu",
|
backend: "cpu",
|
||||||
algorithm: "das",
|
algorithm: "das",
|
||||||
inputHash: "a".repeat(64),
|
inputHash: "a".repeat(64),
|
||||||
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
|
signalStats: {
|
||||||
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
|
preNormalizationPeak: 0.5,
|
||||||
|
appliedScale: 1,
|
||||||
|
clippedSampleCount: 0,
|
||||||
|
sampling: resultSampling(scene),
|
||||||
|
},
|
||||||
|
timingMs: {
|
||||||
|
total: 10,
|
||||||
|
signalGeneration: 3,
|
||||||
|
localization: 7,
|
||||||
|
beamforming: 7,
|
||||||
|
},
|
||||||
bands: [{
|
bands: [{
|
||||||
id: "tone-1k",
|
id: "tone-1k",
|
||||||
color: "#65f2bf",
|
color: "#65f2bf",
|
||||||
|
frequencyObservation: resultFrequencyObservation(
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
experiment.frequencyBands[0],
|
||||||
|
),
|
||||||
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
||||||
peaks: [{
|
peaks: [{
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
@@ -437,6 +711,66 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
|||||||
};
|
};
|
||||||
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
|
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
|
||||||
|
|
||||||
|
const mismatchedTimingResult = structuredClone(result);
|
||||||
|
mismatchedTimingResult.timingMs.localization = 8;
|
||||||
|
const mismatchedTimingIssues = await validateResult(
|
||||||
|
mismatchedTimingResult,
|
||||||
|
{ experiment, scene },
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
mismatchedTimingIssues.some(
|
||||||
|
({ path }) => path === "$.timingMs.localization",
|
||||||
|
),
|
||||||
|
"localization timing and its compatibility alias must agree",
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingLocalizationTiming = structuredClone(result);
|
||||||
|
delete missingLocalizationTiming.timingMs.localization;
|
||||||
|
const missingLocalizationIssues = await validateResult(
|
||||||
|
missingLocalizationTiming,
|
||||||
|
{ experiment, scene },
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
missingLocalizationIssues.some(
|
||||||
|
({ path }) => path === "$.timingMs.localization",
|
||||||
|
),
|
||||||
|
"Result 1.1 requires generic localization timing",
|
||||||
|
);
|
||||||
|
|
||||||
|
const legacyResult = structuredClone(result);
|
||||||
|
legacyResult.schemaVersion = "1.0.0";
|
||||||
|
const legacyIssues = await validateResult(legacyResult, {
|
||||||
|
experiment,
|
||||||
|
scene,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
legacyIssues.some(({ path }) => path === "$.schemaVersion"),
|
||||||
|
"sampling-aware Result validation must reject legacy 1.0 implicitly",
|
||||||
|
);
|
||||||
|
|
||||||
|
const impossibleBackendResult = structuredClone(result);
|
||||||
|
impossibleBackendResult.algorithm = "gcc-phat";
|
||||||
|
impossibleBackendResult.backend = "webgpu";
|
||||||
|
const impossibleBackendIssues = await validateResult(
|
||||||
|
impossibleBackendResult,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
impossibleBackendIssues.some(({ path }) => path === "$.backend"),
|
||||||
|
"standalone Result rejects the unsupported GCC-PHAT WebGPU combination",
|
||||||
|
);
|
||||||
|
|
||||||
|
const malformedResult = structuredClone(result);
|
||||||
|
delete malformedResult.bands[0].id;
|
||||||
|
malformedResult.bands[0].processedMaps = {};
|
||||||
|
const malformedIssues = await validateResult(malformedResult, {
|
||||||
|
experiment,
|
||||||
|
scene,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
malformedIssues.length > 0,
|
||||||
|
"malformed Result fields return validation issues instead of throwing",
|
||||||
|
);
|
||||||
|
|
||||||
experiment.beamforming.algorithm = "das-phat";
|
experiment.beamforming.algorithm = "das-phat";
|
||||||
result.algorithm = "das-phat";
|
result.algorithm = "das-phat";
|
||||||
result.bands[0].rawMap.scale = "score";
|
result.bands[0].rawMap.scale = "score";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
findLocalPeaks,
|
findLocalPeaks,
|
||||||
measureBeamSpread,
|
measureBeamSpread,
|
||||||
measureMainLobe,
|
measureMainLobe,
|
||||||
|
normalizedMusicPseudospectrum,
|
||||||
selectResultMetrics,
|
selectResultMetrics,
|
||||||
} from "/packages/acoustics-core/evaluation.js";
|
} from "/packages/acoustics-core/evaluation.js";
|
||||||
|
|
||||||
@@ -113,6 +114,52 @@ test("peak 검출 감도를 낮추면 약한 두 번째 음원을 선택한다",
|
|||||||
assertEqual(recommended.parameters.detectionThresholdRatio, 0.25, "selected ratio is recorded");
|
assertEqual(recommended.parameters.detectionThresholdRatio, 0.25, "selected ratio is recorded");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("connected component와 watershed가 연결된 복수 lobe를 서로 다르게 군집화한다", () => {
|
||||||
|
const scan = { xMin: 0, xMax: 6, yMin: 0, yMax: 2, z: 1, columns: 7, rows: 3 };
|
||||||
|
const rawMap = Float32Array.of(
|
||||||
|
0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 10, 8, 6, 8, 9, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0,
|
||||||
|
);
|
||||||
|
const common = {
|
||||||
|
rawMap,
|
||||||
|
scan,
|
||||||
|
sources: [],
|
||||||
|
detectionThresholdRatio: 0.5,
|
||||||
|
minimumPeakDistanceM: 0,
|
||||||
|
};
|
||||||
|
const local = evaluateDasBand({ ...common, detectionMethod: "local-max" });
|
||||||
|
const connected = evaluateDasBand({ ...common, detectionMethod: "connected-components" });
|
||||||
|
const watershed = evaluateDasBand({ ...common, detectionMethod: "watershed" });
|
||||||
|
|
||||||
|
assertEqual(local.detections.length, 2, "local maxima keeps both summits");
|
||||||
|
assertEqual(connected.detections.length, 1, "connected lobe becomes one component");
|
||||||
|
assertNear(connected.detections[0].position[0], 121 / 41, 1e-12, "component weighted centroid x");
|
||||||
|
assertNear(connected.detections[0].position[1], 1, 1e-12, "component weighted centroid y");
|
||||||
|
assertEqual(connected.detections[0].clusterPixelCount, 5, "component pixel count");
|
||||||
|
assertEqual(watershed.detections.length, 2, "watershed splits the connected lobe at its saddle");
|
||||||
|
assertNear(watershed.detections[0].position[0], 44 / 24, 1e-12, "strong basin centroid");
|
||||||
|
assertNear(watershed.detections[1].position[0], 77 / 17, 1e-12, "weak basin centroid");
|
||||||
|
assertEqual(connected.parameters.detectionMethod, "connected-components", "component method recorded");
|
||||||
|
assertEqual(watershed.parameters.detectionMethod, "watershed", "watershed method recorded");
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify(evaluateDasBand({ ...common, detectionMethod: "watershed" })),
|
||||||
|
JSON.stringify(watershed),
|
||||||
|
"watershed assignment is deterministic",
|
||||||
|
);
|
||||||
|
|
||||||
|
const suppressed = evaluateDasBand({
|
||||||
|
...common,
|
||||||
|
detectionMethod: "watershed",
|
||||||
|
minimumPeakDistanceM: 10,
|
||||||
|
});
|
||||||
|
assertEqual(suppressed.detections.length, 1, "common minimum-distance suppression still applies");
|
||||||
|
assert(
|
||||||
|
captureError(() => evaluateDasBand({ ...common, detectionMethod: "unknown" })) instanceof RangeError,
|
||||||
|
"unsupported detection method is rejected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
|
test("zero map 평가가 빈 peak와 유한한 metric을 반환한다", () => {
|
||||||
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
|
const scan = { xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 1, columns: 2, rows: 2 };
|
||||||
const evaluation = evaluateDasBand({
|
const evaluation = evaluateDasBand({
|
||||||
@@ -338,6 +385,7 @@ test("result exporter가 schema 허용 metric만 선택하고 내부 필드를
|
|||||||
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
|
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
|
||||||
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
|
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
|
||||||
assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");
|
assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");
|
||||||
|
assertEqual(exported.evaluation.detectionMethod, "local-max", "detection method exported");
|
||||||
assertEqual(exported.evaluation.matchToleranceM, 0, "match tolerance exported");
|
assertEqual(exported.evaluation.matchToleranceM, 0, "match tolerance exported");
|
||||||
assertEqual(
|
assertEqual(
|
||||||
Object.keys(selectResultMetrics(evaluation)).join(","),
|
Object.keys(selectResultMetrics(evaluation)).join(","),
|
||||||
@@ -379,3 +427,102 @@ test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서
|
|||||||
"display-only map scale rejected",
|
"display-only map scale rejected",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("MUSIC noise projection is minimized without rewriting raw q values", () => {
|
||||||
|
const scan = {
|
||||||
|
xMin: 0,
|
||||||
|
xMax: 4,
|
||||||
|
yMin: 0,
|
||||||
|
yMax: 4,
|
||||||
|
z: 1,
|
||||||
|
columns: 5,
|
||||||
|
rows: 5,
|
||||||
|
};
|
||||||
|
const values = new Float64Array(25);
|
||||||
|
values.fill(1);
|
||||||
|
values[6] = 0.1;
|
||||||
|
values[18] = 0.2;
|
||||||
|
values[3] = 0.6;
|
||||||
|
const evaluation = evaluateLocalizationBand({
|
||||||
|
rawMap: {
|
||||||
|
rows: 5,
|
||||||
|
columns: 5,
|
||||||
|
scale: "noise-projection",
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
values,
|
||||||
|
},
|
||||||
|
scan,
|
||||||
|
sources: [
|
||||||
|
{ id: "left", position: [1, 3, 1] },
|
||||||
|
{ id: "right", position: [3, 1, 1] },
|
||||||
|
],
|
||||||
|
detectionThresholdRatio: 0.5,
|
||||||
|
minimumPeakDistanceM: 0,
|
||||||
|
matchToleranceM: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEqual(evaluation.globalPeak.index, 6, "smallest q is the global optimum");
|
||||||
|
assertNear(evaluation.globalPeak.value, 0.1, 1e-12, "global peak retains raw q");
|
||||||
|
assertEqual(evaluation.detections.length, 2, "two separated minima are detected");
|
||||||
|
assertNear(evaluation.detections[1].value, 0.2, 1e-12, "second detection retains raw q");
|
||||||
|
assert(!("beamSpread" in evaluation.detections[0]), "power beam spread is omitted");
|
||||||
|
assertEqual(
|
||||||
|
Object.keys(evaluation.metrics).join(","),
|
||||||
|
"precision,recall",
|
||||||
|
"only objective-independent metrics are reported",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
evaluation.diagnostics.detectionTransform,
|
||||||
|
"normalized-reciprocal-noise-projection",
|
||||||
|
"the standard normalized MUSIC pseudospectrum is disclosed",
|
||||||
|
);
|
||||||
|
const exported = exportBandEvaluation(evaluation);
|
||||||
|
assertEqual(exported.evaluation.objectiveDirection, "minimum", "minimum objective is exported");
|
||||||
|
assertEqual(
|
||||||
|
exported.evaluation.detectionThresholdDomain,
|
||||||
|
"normalized-music-pseudospectrum",
|
||||||
|
"threshold domain is explicit",
|
||||||
|
);
|
||||||
|
assertNear(exported.peaks[0].value, 0.1, 1e-12, "exported peak remains raw q");
|
||||||
|
|
||||||
|
const withBadOutlier = Float64Array.from(values);
|
||||||
|
withBadOutlier[0] = 100;
|
||||||
|
const outlierEvaluation = evaluateLocalizationBand({
|
||||||
|
rawMap: {
|
||||||
|
rows: 5,
|
||||||
|
columns: 5,
|
||||||
|
scale: "noise-projection",
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
values: withBadOutlier,
|
||||||
|
},
|
||||||
|
scan,
|
||||||
|
sources: [],
|
||||||
|
detectionThresholdRatio: 0.5,
|
||||||
|
minimumPeakDistanceM: 0,
|
||||||
|
});
|
||||||
|
assertEqual(
|
||||||
|
outlierEvaluation.detections.map(({ index }) => index).join(","),
|
||||||
|
evaluation.detections.map(({ index }) => index).join(","),
|
||||||
|
"a worse scan outlier does not move the reciprocal threshold",
|
||||||
|
);
|
||||||
|
|
||||||
|
const flat = evaluateLocalizationBand({
|
||||||
|
rawMap: {
|
||||||
|
rows: 5,
|
||||||
|
columns: 5,
|
||||||
|
scale: "noise-projection",
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
values: new Float64Array(25).fill(0.5),
|
||||||
|
},
|
||||||
|
scan,
|
||||||
|
sources: [],
|
||||||
|
});
|
||||||
|
const flatDisplay = normalizedMusicPseudospectrum(
|
||||||
|
new Float64Array(25).fill(0.5),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
flatDisplay.values.every((value) => value === 1),
|
||||||
|
"flat q preserves the standard normalized reciprocal value",
|
||||||
|
);
|
||||||
|
assertEqual(flat.detections.length, 0, "flat q does not invent a location");
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
createLocalizationAnalysisPlan,
|
createLocalizationAnalysisPlan,
|
||||||
estimateLocalizationWorkload,
|
estimateLocalizationWorkload,
|
||||||
} from "/packages/acoustics-core/localization.js";
|
} from "/packages/acoustics-core/localization.js";
|
||||||
|
import {
|
||||||
|
createMusicBinCalibration,
|
||||||
|
isMusicUnavailableError,
|
||||||
|
} from "/packages/acoustics-core/music.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
||||||
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||||
@@ -58,9 +62,9 @@ function createFixture(algorithm = "das") {
|
|||||||
columns: 3,
|
columns: 3,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
};
|
};
|
||||||
const frequencyBands = [
|
const frequencyBands = algorithm === "music"
|
||||||
{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" },
|
? [{ id: "single-bin", minHz: 500, maxHz: 530, color: "#65f2bf" }]
|
||||||
];
|
: [{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" }];
|
||||||
const experiment = {
|
const experiment = {
|
||||||
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
||||||
scan,
|
scan,
|
||||||
@@ -68,8 +72,11 @@ function createFixture(algorithm = "das") {
|
|||||||
beamforming: {
|
beamforming: {
|
||||||
algorithm,
|
algorithm,
|
||||||
backend: "cpu",
|
backend: "cpu",
|
||||||
normalization: "peak",
|
normalization: algorithm === "music" ? "none" : "peak",
|
||||||
compensateFrequencyResponse: true,
|
compensateFrequencyResponse: true,
|
||||||
|
...(algorithm === "music"
|
||||||
|
? { musicSourceCountMethod: "fixed", musicSourceCount: 1 }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
@@ -83,12 +90,22 @@ function createFixture(algorithm = "das") {
|
|||||||
|
|
||||||
test("localization algorithm registry exposes explicit scale and backend semantics", () => {
|
test("localization algorithm registry exposes explicit scale and backend semantics", () => {
|
||||||
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
||||||
assertEqual(LOCALIZATION_ALGORITHM_IDS.join(","), "das,das-phat,gcc-phat", "registered ids");
|
assertEqual(
|
||||||
|
LOCALIZATION_ALGORITHM_IDS.join(","),
|
||||||
|
"das,das-phat,gcc-phat,music",
|
||||||
|
"registered ids",
|
||||||
|
);
|
||||||
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS power scale");
|
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS power scale");
|
||||||
assertEqual(localizationAlgorithmDefinition("das-phat").rawScale, "score", "DAS-PHAT score scale");
|
assertEqual(localizationAlgorithmDefinition("das-phat").rawScale, "score", "DAS-PHAT score scale");
|
||||||
assertEqual(localizationAlgorithmDefinition("gcc-phat").rawScale, "score", "GCC-PHAT score scale");
|
assertEqual(localizationAlgorithmDefinition("gcc-phat").rawScale, "score", "GCC-PHAT score scale");
|
||||||
|
assertEqual(
|
||||||
|
localizationAlgorithmDefinition("music").rawScale,
|
||||||
|
"noise-projection",
|
||||||
|
"MUSIC raw noise-projection scale",
|
||||||
|
);
|
||||||
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU support");
|
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU support");
|
||||||
assert(!localizationAlgorithmSupportsBackend("gcc-phat", "webgpu"), "GCC-PHAT CPU-only support");
|
assert(!localizationAlgorithmSupportsBackend("gcc-phat", "webgpu"), "GCC-PHAT CPU-only support");
|
||||||
|
assert(!localizationAlgorithmSupportsBackend("music", "webgpu"), "MUSIC CPU-only support");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generic CPU dispatcher runs all algorithms with explicit raw-map scales", async () => {
|
test("generic CPU dispatcher runs all algorithms with explicit raw-map scales", async () => {
|
||||||
@@ -156,6 +173,348 @@ test("backend dispatcher keeps GCC-PHAT on CPU and rejects an explicit GPU reque
|
|||||||
assert(legacyError instanceof RangeError, "legacy DAS entry rejects GCC");
|
assert(legacyError instanceof RangeError, "legacy DAS entry rejects GCC");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("backend dispatcher keeps MUSIC on CPU and rejects an explicit GPU request", async () => {
|
||||||
|
const fixture = createFixture("music");
|
||||||
|
fixture.experiment.beamforming.backend = "auto";
|
||||||
|
const calls = [];
|
||||||
|
const result = await computeLocalizationWithBackend(fixture, {
|
||||||
|
computeGpu: async () => {
|
||||||
|
calls.push("gpu");
|
||||||
|
throw new Error("MUSIC must not call WebGPU");
|
||||||
|
},
|
||||||
|
computeMusicCpu: async () => {
|
||||||
|
calls.push("music-cpu");
|
||||||
|
return { backend: "cpu", algorithm: "music", bands: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEqual(calls.join(","), "music-cpu", "MUSIC auto routing");
|
||||||
|
assertEqual(result.algorithm, "music", "MUSIC result");
|
||||||
|
|
||||||
|
fixture.experiment.beamforming.backend = "webgpu";
|
||||||
|
let backendError;
|
||||||
|
try {
|
||||||
|
await computeLocalizationWithBackend(fixture);
|
||||||
|
} catch (error) {
|
||||||
|
backendError = error;
|
||||||
|
}
|
||||||
|
assert(backendError instanceof TypeError, "explicit MUSIC WebGPU is rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fixed-K MUSIC reports silent rank loss instead of choosing a grid cell", async () => {
|
||||||
|
const fixture = createFixture("music");
|
||||||
|
fixture.channels = fixture.channels.map(
|
||||||
|
(channel) => new Float32Array(channel.length),
|
||||||
|
);
|
||||||
|
let unavailable;
|
||||||
|
try {
|
||||||
|
await computeLocalizationMap(fixture);
|
||||||
|
} catch (error) {
|
||||||
|
unavailable = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
isMusicUnavailableError(unavailable),
|
||||||
|
"silence is a typed MUSIC unavailable condition",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
unavailable.reason,
|
||||||
|
"signal-noise-partition-unidentifiable",
|
||||||
|
"silence has an explicit identifiability reason",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC uses an explicit above-Nyquist carrier hypothesis without Scene truth", async () => {
|
||||||
|
const baseband = createFixture("music");
|
||||||
|
const aliased = createFixture("music");
|
||||||
|
aliased.channels = baseband.channels;
|
||||||
|
aliased.experiment.frequencyBands = [
|
||||||
|
{ id: "aliased-single-bin", minHz: 4590, maxHz: 4620, color: "#65f2bf" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const basebandResult = await computeLocalizationMap(baseband);
|
||||||
|
const aliasedResult = await computeLocalizationMap(aliased);
|
||||||
|
assertEqual(aliasedResult.bands[0].bins.join(","), "8", "aliased hypothesis maps to bin 8");
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.bands[0].observedFrequencyHz,
|
||||||
|
512,
|
||||||
|
"covariance uses the observed ADC bin center",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.bands[0].analogFrequencyHz,
|
||||||
|
4608,
|
||||||
|
"band config supplies the analog FFT-grid carrier",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.bands[0].signedAdcFrequencyHz,
|
||||||
|
512,
|
||||||
|
"signed ADC frequency is retained",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.model.originalCarrierUsed,
|
||||||
|
true,
|
||||||
|
"configured analog carrier is used",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.model.hypothesisSource,
|
||||||
|
"experiment-band-not-scene-truth",
|
||||||
|
"Scene truth is never used to choose the carrier",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.model.multibandFusion,
|
||||||
|
"none-each-band-is-separate-single-analog-hypothesis",
|
||||||
|
"no cross-band fusion is invented",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
JSON.stringify(Array.from(aliasedResult.bands[0].rawMap)) !==
|
||||||
|
JSON.stringify(Array.from(basebandResult.bands[0].rawMap)),
|
||||||
|
"same ADC data is tested against a distinct configured spatial wavelength",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("near-field MUSIC applies the configured analog carrier and inversion without hiding grating lobes", async () => {
|
||||||
|
const sampleRate = 8192;
|
||||||
|
const fftSize = 128;
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const basebandFrequency = 1024;
|
||||||
|
const invertedAnalogFrequency = sampleRate - basebandFrequency;
|
||||||
|
const source = [0.1, -0.1, 1];
|
||||||
|
const arrayDefinition = {
|
||||||
|
id: "physical-music-array",
|
||||||
|
microphones: [
|
||||||
|
{ id: "nw", position: [-0.12, 0.12, 0] },
|
||||||
|
{ id: "ne", position: [0.12, 0.12, 0] },
|
||||||
|
{ id: "sw", position: [-0.12, -0.12, 0] },
|
||||||
|
{ id: "se", position: [0.12, -0.12, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const channelsForAnalogFrequency = (frequency) =>
|
||||||
|
arrayDefinition.microphones.map((microphone) => {
|
||||||
|
const distance = Math.hypot(
|
||||||
|
source[0] - microphone.position[0],
|
||||||
|
source[1] - microphone.position[1],
|
||||||
|
source[2] - microphone.position[2],
|
||||||
|
);
|
||||||
|
return Float64Array.from(
|
||||||
|
{ length: 384 },
|
||||||
|
(_, sampleIndex) =>
|
||||||
|
Math.cos(
|
||||||
|
2 * Math.PI * frequency *
|
||||||
|
(sampleIndex / sampleRate - distance / speedOfSound),
|
||||||
|
) / distance,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const experimentForBand = (band) => ({
|
||||||
|
stft: { fftSize, hopSize: 64, window: "hann" },
|
||||||
|
scan: {
|
||||||
|
z: 1,
|
||||||
|
xMin: -0.2,
|
||||||
|
xMax: 0.2,
|
||||||
|
yMin: -0.2,
|
||||||
|
yMax: 0.2,
|
||||||
|
columns: 5,
|
||||||
|
rows: 5,
|
||||||
|
},
|
||||||
|
frequencyBands: [{ id: "tone", color: "#65f2bf", ...band }],
|
||||||
|
beamforming: {
|
||||||
|
algorithm: "music",
|
||||||
|
backend: "cpu",
|
||||||
|
minimumDistance: 0.01,
|
||||||
|
normalization: "none",
|
||||||
|
compensateFrequencyResponse: true,
|
||||||
|
musicSourceCountMethod: "fixed",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const common = {
|
||||||
|
sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene: { speedOfSound },
|
||||||
|
};
|
||||||
|
const baseband = await computeLocalizationMap({
|
||||||
|
...common,
|
||||||
|
channels: channelsForAnalogFrequency(basebandFrequency),
|
||||||
|
experiment: experimentForBand({ minHz: 1000, maxHz: 1050 }),
|
||||||
|
});
|
||||||
|
const truthIndex = 18;
|
||||||
|
assertEqual(baseband.bands[0].peak.index, truthIndex, "baseband physical truth minimizes q");
|
||||||
|
const truthQ = baseband.bands[0].rawMap[truthIndex];
|
||||||
|
assert(
|
||||||
|
baseband.bands[0].rawMap.every((value) => value >= truthQ),
|
||||||
|
"truth q is the global minimum",
|
||||||
|
);
|
||||||
|
|
||||||
|
const aliased = await computeLocalizationMap({
|
||||||
|
...common,
|
||||||
|
channels: channelsForAnalogFrequency(invertedAnalogFrequency),
|
||||||
|
experiment: experimentForBand({ minHz: 7140, maxHz: 7190 }),
|
||||||
|
});
|
||||||
|
assertEqual(aliased.bands[0].bins.join(","), "16", "analog carrier folds to observed bin 16");
|
||||||
|
assertEqual(
|
||||||
|
aliased.bands[0].observedFrequencyHz,
|
||||||
|
basebandFrequency,
|
||||||
|
"covariance still uses the observed ADC frequency",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliased.bands[0].analogFrequencyHz,
|
||||||
|
invertedAnalogFrequency,
|
||||||
|
"manifold uses the configured analog carrier",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliased.bands[0].signedAdcFrequencyHz,
|
||||||
|
-basebandFrequency,
|
||||||
|
"inverted signed ADC frequency is explicit",
|
||||||
|
);
|
||||||
|
assert(aliased.bands[0].spectralInversion, "full received manifold is conjugated");
|
||||||
|
const aliasedTruthQ = aliased.bands[0].rawMap[truthIndex];
|
||||||
|
const aliasedMinimumQ = Math.min(...aliased.bands[0].rawMap);
|
||||||
|
assert(
|
||||||
|
Math.abs(aliasedTruthQ - aliasedMinimumQ) <= 1e-10,
|
||||||
|
"physical truth is a q minimum while grating-lobe ties remain allowed",
|
||||||
|
);
|
||||||
|
assertEqual(aliased.model.originalCarrierUsed, true, "configured carrier is used");
|
||||||
|
assertEqual(
|
||||||
|
aliased.model.aliasOrderResolution,
|
||||||
|
"configured-single-hypothesis-no-selection",
|
||||||
|
"no data-driven alias-order choice is claimed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC conjugates analog microphone phase and preserves capped residual gain in inverted zones", () => {
|
||||||
|
const arrayDefinition = {
|
||||||
|
microphones: [{
|
||||||
|
id: "calibrated",
|
||||||
|
position: [0, 0, 0],
|
||||||
|
phaseOffsetDeg: 20,
|
||||||
|
frequencyResponse: [
|
||||||
|
{ frequencyHz: 3_000, gainDb: 0, phaseDeg: 0 },
|
||||||
|
{ frequencyHz: 5_000, gainDb: -30, phaseDeg: 40 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const corrected = createMusicBinCalibration({
|
||||||
|
arrayDefinition,
|
||||||
|
bin: 48,
|
||||||
|
sampleRate: 8_000,
|
||||||
|
fftSize: 128,
|
||||||
|
analogFrequencyHz: 5_000,
|
||||||
|
signedAdcFrequencyHz: -3_000,
|
||||||
|
spectralInversion: true,
|
||||||
|
compensateFrequencyResponse: true,
|
||||||
|
maxCompensationDb: 24,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
Math.abs(corrected.dataPhases[0] - Math.PI / 3) <= 1e-12,
|
||||||
|
"inverted positive-bin calibration conjugates 20° + 40°",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(
|
||||||
|
20 * Math.log10(corrected.manifoldGains[0]) + 6
|
||||||
|
) <= 1e-9,
|
||||||
|
"-30 dB response with 24 dB inverse cap leaves -6 dB",
|
||||||
|
);
|
||||||
|
assertEqual(corrected.manifoldPhases[0], 0, "compensated phase has no residual");
|
||||||
|
|
||||||
|
const raw = createMusicBinCalibration({
|
||||||
|
arrayDefinition,
|
||||||
|
bin: 48,
|
||||||
|
sampleRate: 8_000,
|
||||||
|
fftSize: 128,
|
||||||
|
analogFrequencyHz: 5_000,
|
||||||
|
signedAdcFrequencyHz: -3_000,
|
||||||
|
spectralInversion: true,
|
||||||
|
compensateFrequencyResponse: false,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
Math.abs(raw.dataPhases[0] - 20 * Math.PI / 180) <= 1e-12,
|
||||||
|
"fixed phase offset correction follows inversion sign",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(raw.manifoldPhases[0] + 40 * Math.PI / 180) <= 1e-12,
|
||||||
|
"uncompensated analog response phase remains conjugated in the manifold",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable MUSIC plan retains its physical and source-count configuration", () => {
|
||||||
|
const fixture = createFixture("music");
|
||||||
|
const plan = createLocalizationPlan({
|
||||||
|
sampleRate: fixture.sampleRate,
|
||||||
|
array: fixture.arrayDefinition,
|
||||||
|
scan: fixture.experiment.scan,
|
||||||
|
bands: fixture.experiment.frequencyBands,
|
||||||
|
speedOfSound: fixture.scene.speedOfSound,
|
||||||
|
stft: fixture.experiment.stft,
|
||||||
|
algorithm: "music",
|
||||||
|
minimumDistance: 0.025,
|
||||||
|
musicSourceCountMethod: "fixed",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
});
|
||||||
|
assertEqual(plan.normalization, "none", "portable MUSIC defaults to no map normalization");
|
||||||
|
assertEqual(plan.minimumDistance, 0.025, "minimum distance is retained");
|
||||||
|
assertEqual(plan.experiment.beamforming.musicSourceCount, 1, "fixed K reaches core experiment");
|
||||||
|
assertEqual(plan.musicPlan, plan.analysisPlan, "portable MUSIC plan has a dedicated alias");
|
||||||
|
assert(!("gccPhatPlan" in plan), "MUSIC is not mislabeled as a GCC plan");
|
||||||
|
assert(!("crossSpectrumBatchSize" in plan.computeOptions), "GCC-only batching is omitted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC rejects peak normalization at both direct and portable API boundaries", () => {
|
||||||
|
const direct = createFixture("music");
|
||||||
|
direct.experiment.beamforming.normalization = "peak";
|
||||||
|
let directError;
|
||||||
|
try {
|
||||||
|
createLocalizationAnalysisPlan(direct);
|
||||||
|
} catch (error) {
|
||||||
|
directError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
directError instanceof RangeError &&
|
||||||
|
directError.message.includes("normalization"),
|
||||||
|
"direct core rejects peak normalization",
|
||||||
|
);
|
||||||
|
|
||||||
|
let portableError;
|
||||||
|
try {
|
||||||
|
createLocalizationPlan({
|
||||||
|
sampleRate: direct.sampleRate,
|
||||||
|
array: direct.arrayDefinition,
|
||||||
|
scan: direct.experiment.scan,
|
||||||
|
bands: direct.experiment.frequencyBands,
|
||||||
|
speedOfSound: direct.scene.speedOfSound,
|
||||||
|
stft: direct.experiment.stft,
|
||||||
|
algorithm: "music",
|
||||||
|
normalization: "peak",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
portableError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
portableError instanceof RangeError &&
|
||||||
|
portableError.message.includes("normalization"),
|
||||||
|
"portable API rejects peak normalization",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC MDL/AIC workload excludes a zero-padded partial snapshot", () => {
|
||||||
|
const fixture = createFixture("music");
|
||||||
|
const completeSampleCount = 6 * fixture.experiment.stft.fftSize;
|
||||||
|
const partialSampleCount = completeSampleCount + 10;
|
||||||
|
fixture.channels = fixture.channels.map((channel) =>
|
||||||
|
Float64Array.from(
|
||||||
|
{ length: partialSampleCount },
|
||||||
|
(_, index) => channel[index % channel.length],
|
||||||
|
));
|
||||||
|
fixture.experiment.stft.hopSize = fixture.experiment.stft.fftSize;
|
||||||
|
fixture.experiment.beamforming.musicSourceCountMethod = "mdl";
|
||||||
|
delete fixture.experiment.beamforming.musicSourceCount;
|
||||||
|
|
||||||
|
const workload = estimateLocalizationWorkload(fixture);
|
||||||
|
assertEqual(workload.frameCount, 6, "only six complete non-overlapping frames");
|
||||||
|
assertEqual(
|
||||||
|
workload.sampleCount,
|
||||||
|
partialSampleCount,
|
||||||
|
"input length remains disclosed without treating its tail as a snapshot",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("portable and streaming API accept an algorithm choice without Scene synthesis", async () => {
|
test("portable and streaming API accept an algorithm choice without Scene synthesis", async () => {
|
||||||
const fixture = createFixture("gcc-phat");
|
const fixture = createFixture("gcc-phat");
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import {
|
||||||
|
auditCovarianceEigenvalues,
|
||||||
|
computeComplexSampleCovariance,
|
||||||
|
selectWaxKailathSourceCount,
|
||||||
|
} from "/packages/acoustics-core/covariance.js";
|
||||||
|
import {
|
||||||
|
decomposeHermitian,
|
||||||
|
estimateHermitianEigenWorkload,
|
||||||
|
} from "/packages/acoustics-core/hermitian-eigen.js";
|
||||||
|
import {
|
||||||
|
computeMusicMap,
|
||||||
|
estimateMusicWorkload,
|
||||||
|
} from "/packages/acoustics-core/music.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(
|
||||||
|
Math.abs(actual - expected) <= tolerance,
|
||||||
|
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertArrayNear(actual, expected, tolerance, label) {
|
||||||
|
assertEqual(actual.length, expected.length, `${label} length`);
|
||||||
|
for (let index = 0; index < actual.length; index += 1) {
|
||||||
|
assertNear(actual[index], expected[index], tolerance, `${label}[${index}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dftUnitary(order) {
|
||||||
|
const real = new Float64Array(order * order);
|
||||||
|
const imaginary = new Float64Array(order * order);
|
||||||
|
const scale = 1 / Math.sqrt(order);
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
for (let column = 0; column < order; column += 1) {
|
||||||
|
const phase = (2 * Math.PI * row * column) / order;
|
||||||
|
const index = row * order + column;
|
||||||
|
real[index] = Math.cos(phase) * scale;
|
||||||
|
imaginary[index] = Math.sin(phase) * scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { real, imaginary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hermitianFromEigenpairs(vectors, eigenvalues) {
|
||||||
|
const order = eigenvalues.length;
|
||||||
|
const real = new Float64Array(order * order);
|
||||||
|
const imaginary = new Float64Array(order * order);
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
for (let column = row; column < order; column += 1) {
|
||||||
|
let valueReal = 0;
|
||||||
|
let valueImaginary = 0;
|
||||||
|
for (let eigenvector = 0; eigenvector < order; eigenvector += 1) {
|
||||||
|
const rowIndex = row * order + eigenvector;
|
||||||
|
const columnIndex = column * order + eigenvector;
|
||||||
|
const rowReal = vectors.real[rowIndex];
|
||||||
|
const rowImaginary = vectors.imaginary[rowIndex];
|
||||||
|
const columnReal = vectors.real[columnIndex];
|
||||||
|
const columnImaginary = vectors.imaginary[columnIndex];
|
||||||
|
valueReal += eigenvalues[eigenvector] *
|
||||||
|
(rowReal * columnReal + rowImaginary * columnImaginary);
|
||||||
|
valueImaginary += eigenvalues[eigenvector] *
|
||||||
|
(rowImaginary * columnReal - rowReal * columnImaginary);
|
||||||
|
}
|
||||||
|
const upper = row * order + column;
|
||||||
|
const lower = column * order + row;
|
||||||
|
real[upper] = valueReal;
|
||||||
|
imaginary[upper] = row === column ? 0 : valueImaginary;
|
||||||
|
real[lower] = valueReal;
|
||||||
|
imaginary[lower] = row === column ? 0 : -valueImaginary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { real, imaginary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projector(vectors, order, firstColumn, columnCount) {
|
||||||
|
const real = new Float64Array(order * order);
|
||||||
|
const imaginary = new Float64Array(order * order);
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
for (let column = 0; column < order; column += 1) {
|
||||||
|
let valueReal = 0;
|
||||||
|
let valueImaginary = 0;
|
||||||
|
for (let vector = firstColumn; vector < firstColumn + columnCount; vector += 1) {
|
||||||
|
const rowIndex = row * order + vector;
|
||||||
|
const columnIndex = column * order + vector;
|
||||||
|
valueReal +=
|
||||||
|
vectors.real[rowIndex] * vectors.real[columnIndex] +
|
||||||
|
vectors.imaginary[rowIndex] * vectors.imaginary[columnIndex];
|
||||||
|
valueImaginary +=
|
||||||
|
vectors.imaginary[rowIndex] * vectors.real[columnIndex] -
|
||||||
|
vectors.real[rowIndex] * vectors.imaginary[columnIndex];
|
||||||
|
}
|
||||||
|
const index = row * order + column;
|
||||||
|
real[index] = valueReal;
|
||||||
|
imaginary[index] = valueImaginary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { real, imaginary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutomaticMusicFixture() {
|
||||||
|
const sampleRate = 3_200;
|
||||||
|
const fftSize = 32;
|
||||||
|
const snapshotCount = 64;
|
||||||
|
const frequencyHz = 500;
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const source = [0.08, -0.04, 1];
|
||||||
|
const arrayDefinition = {
|
||||||
|
id: "automatic-music-audit-array",
|
||||||
|
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 = 0x51f15e5d;
|
||||||
|
const random = () => {
|
||||||
|
state ^= state << 13;
|
||||||
|
state ^= state >>> 17;
|
||||||
|
state ^= state << 5;
|
||||||
|
return ((state >>> 0) / 0xffff_ffff) * 2 - 1;
|
||||||
|
};
|
||||||
|
const sourceRealBySnapshot = Float64Array.from(
|
||||||
|
{ length: snapshotCount },
|
||||||
|
random,
|
||||||
|
);
|
||||||
|
const sourceImaginaryBySnapshot = Float64Array.from(
|
||||||
|
{ length: snapshotCount },
|
||||||
|
random,
|
||||||
|
);
|
||||||
|
const channels = arrayDefinition.microphones.map((microphone) => {
|
||||||
|
const distance = Math.hypot(
|
||||||
|
source[0] - microphone.position[0],
|
||||||
|
source[1] - microphone.position[1],
|
||||||
|
source[2] - microphone.position[2],
|
||||||
|
);
|
||||||
|
const propagationPhase = -(2 * Math.PI * frequencyHz * distance) / speedOfSound;
|
||||||
|
const channel = new Float64Array(snapshotCount * fftSize);
|
||||||
|
for (let snapshot = 0; snapshot < snapshotCount; snapshot += 1) {
|
||||||
|
const sourceReal = sourceRealBySnapshot[snapshot];
|
||||||
|
const sourceImaginary = sourceImaginaryBySnapshot[snapshot];
|
||||||
|
for (let offset = 0; offset < fftSize; offset += 1) {
|
||||||
|
const phase = (2 * Math.PI * frequencyHz * offset) / sampleRate + propagationPhase;
|
||||||
|
channel[snapshot * fftSize + offset] =
|
||||||
|
4 * (sourceReal * Math.cos(phase) - sourceImaginary * Math.sin(phase)) /
|
||||||
|
distance +
|
||||||
|
0.08 * random();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
});
|
||||||
|
const experiment = {
|
||||||
|
stft: { fftSize, hopSize: fftSize, window: "hann" },
|
||||||
|
scan: {
|
||||||
|
z: source[2],
|
||||||
|
xMin: source[0],
|
||||||
|
xMax: source[0],
|
||||||
|
yMin: source[1],
|
||||||
|
yMax: source[1],
|
||||||
|
columns: 1,
|
||||||
|
rows: 1,
|
||||||
|
},
|
||||||
|
frequencyBands: [
|
||||||
|
{ id: "tone", minHz: 499, maxHz: 501, color: "#65f2bf" },
|
||||||
|
],
|
||||||
|
beamforming: {
|
||||||
|
algorithm: "music",
|
||||||
|
backend: "cpu",
|
||||||
|
normalization: "none",
|
||||||
|
compensateFrequencyResponse: true,
|
||||||
|
musicSourceCountMethod: "mdl",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
channels,
|
||||||
|
sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene: { speedOfSound },
|
||||||
|
experiment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("complex Hermitian Jacobi resolves the [4, 1] two-by-two reference", () => {
|
||||||
|
const result = decomposeHermitian({
|
||||||
|
real: Float64Array.of(2, 1, 1, 3),
|
||||||
|
imaginary: Float64Array.of(0, 1, -1, 0),
|
||||||
|
});
|
||||||
|
assert(result.converged, "2x2 Jacobi convergence");
|
||||||
|
assertArrayNear(result.eigenvalues, [4, 1], 2e-14, "2x2 eigenvalues");
|
||||||
|
assertEqual(result.eigenvectors.layout, "row-major-columns", "vector layout");
|
||||||
|
assert(result.relativeResidual < 1e-14, "2x2 relative residual");
|
||||||
|
assert(result.orthogonalityError < 1e-14, "2x2 orthogonality");
|
||||||
|
assert(result.numericallyVerified, "2x2 numerical verification");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hermitian Jacobi diagonalizes a constructed DFT4 matrix", () => {
|
||||||
|
const vectors = dftUnitary(4);
|
||||||
|
const matrix = hermitianFromEigenpairs(vectors, [9, 4, 2, 1]);
|
||||||
|
const result = decomposeHermitian(matrix);
|
||||||
|
assert(result.converged, "DFT4 convergence");
|
||||||
|
assertArrayNear(result.eigenvalues, [9, 4, 2, 1], 2e-12, "DFT4 eigenvalues");
|
||||||
|
assert(result.relativeResidual < 2e-13, "DFT4 relative residual");
|
||||||
|
assert(result.relativeOffDiagonal < result.relativeTolerance, "DFT4 off-diagonal bound");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated Hermitian eigenspaces preserve their invariant projector", () => {
|
||||||
|
const expectedVectors = dftUnitary(4);
|
||||||
|
const matrix = hermitianFromEigenpairs(expectedVectors, [5, 5, 1, 1]);
|
||||||
|
const result = decomposeHermitian(matrix);
|
||||||
|
assert(result.converged, "repeated-eigenspace convergence");
|
||||||
|
assertArrayNear(result.eigenvalues, [5, 5, 1, 1], 2e-12, "repeated eigenvalues");
|
||||||
|
const expectedProjector = projector(expectedVectors, 4, 0, 2);
|
||||||
|
const actualProjector = projector(result.eigenvectors, 4, 0, 2);
|
||||||
|
assertArrayNear(actualProjector.real, expectedProjector.real, 2e-12, "projector real");
|
||||||
|
assertArrayNear(
|
||||||
|
actualProjector.imaginary,
|
||||||
|
expectedProjector.imaginary,
|
||||||
|
2e-12,
|
||||||
|
"projector imaginary",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hermitian eigenpairs are deterministic and phase-canonicalized", () => {
|
||||||
|
const matrix = hermitianFromEigenpairs(dftUnitary(4), [12, 7, 3, 0.5]);
|
||||||
|
const first = decomposeHermitian(matrix);
|
||||||
|
const second = decomposeHermitian(matrix);
|
||||||
|
assertArrayNear(second.eigenvalues, first.eigenvalues, 0, "deterministic eigenvalues");
|
||||||
|
assertArrayNear(second.eigenvectors.real, first.eigenvectors.real, 0, "deterministic real vectors");
|
||||||
|
assertArrayNear(
|
||||||
|
second.eigenvectors.imaginary,
|
||||||
|
first.eigenvectors.imaginary,
|
||||||
|
0,
|
||||||
|
"deterministic imaginary vectors",
|
||||||
|
);
|
||||||
|
for (let column = 0; column < first.order; column += 1) {
|
||||||
|
let maximumMagnitude = 0;
|
||||||
|
for (let row = 0; row < first.order; row += 1) {
|
||||||
|
const index = row * first.order + column;
|
||||||
|
const magnitude = Math.hypot(
|
||||||
|
first.eigenvectors.real[index],
|
||||||
|
first.eigenvectors.imaginary[index],
|
||||||
|
);
|
||||||
|
maximumMagnitude = Math.max(maximumMagnitude, magnitude);
|
||||||
|
}
|
||||||
|
let canonicalPivotFound = false;
|
||||||
|
for (let row = 0; row < first.order; row += 1) {
|
||||||
|
const index = row * first.order + column;
|
||||||
|
const magnitude = Math.hypot(
|
||||||
|
first.eigenvectors.real[index],
|
||||||
|
first.eigenvectors.imaginary[index],
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
magnitude >= maximumMagnitude * (1 - 16 * Number.EPSILON) &&
|
||||||
|
first.eigenvectors.real[index] > 0 &&
|
||||||
|
first.eigenvectors.imaginary[index] === 0
|
||||||
|
) {
|
||||||
|
canonicalPivotFound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(canonicalPivotFound, `column ${column} has a positive-real maximal pivot`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hermitian Jacobi reports non-convergence without returning it as success", () => {
|
||||||
|
const result = decomposeHermitian({
|
||||||
|
real: Float64Array.of(
|
||||||
|
4, 1, 0.5,
|
||||||
|
1, 3, 1.5,
|
||||||
|
0.5, 1.5, 2,
|
||||||
|
),
|
||||||
|
imaginary: Float64Array.of(
|
||||||
|
0, 0.5, -0.3,
|
||||||
|
-0.5, 0, 0.7,
|
||||||
|
0.3, -0.7, 0,
|
||||||
|
),
|
||||||
|
maximumSweeps: 1,
|
||||||
|
});
|
||||||
|
assert(!result.converged, "one-sweep result is explicitly unconverged");
|
||||||
|
assertEqual(result.sweeps, 1, "sweep limit is exact");
|
||||||
|
assert(!result.numericallyVerified, "partial eigenpairs are not verified");
|
||||||
|
assert(result.relativeResidual > result.relativeTolerance, "unconverged residual remains visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hermitian workload rejects M=1024 before reading or allocating matrix data", () => {
|
||||||
|
const order = 1024;
|
||||||
|
const workload = estimateHermitianEigenWorkload({ order });
|
||||||
|
assert(workload.allocationBytes > 16 * 1024 * 1024, "M=1024 exceeds default bytes");
|
||||||
|
let numericRead = false;
|
||||||
|
const fakeMatrix = new Proxy(
|
||||||
|
{ length: order * order },
|
||||||
|
{
|
||||||
|
get(target, property) {
|
||||||
|
if (typeof property === "string" && /^\d+$/.test(property)) numericRead = true;
|
||||||
|
return target[property];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
decomposeHermitian({ real: fakeMatrix, imaginary: fakeMatrix, order });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
assert(error instanceof RangeError, "M=1024 is rejected by the workload cap");
|
||||||
|
assert(!numericRead, "workload cap fires before matrix values are read");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sample covariance uses compensated upper-triangle accumulation and exact conjugates", () => {
|
||||||
|
const cancellation = computeComplexSampleCovariance({
|
||||||
|
sensorCount: 2,
|
||||||
|
snapshotCount: 3,
|
||||||
|
layout: "snapshot-major",
|
||||||
|
real: Float64Array.of(1, 1e16, 1, 1, 1, -1e16),
|
||||||
|
imaginary: new Float64Array(6),
|
||||||
|
});
|
||||||
|
assertNear(cancellation.matrix.real[1], 1 / 3, 1e-16, "compensated cross covariance");
|
||||||
|
|
||||||
|
const complex = computeComplexSampleCovariance({
|
||||||
|
sensorCount: 2,
|
||||||
|
snapshotCount: 1,
|
||||||
|
real: Float64Array.of(1, 2),
|
||||||
|
imaginary: Float64Array.of(1, 3),
|
||||||
|
});
|
||||||
|
assertNear(complex.matrix.real[1], 5, 0, "upper real");
|
||||||
|
assertNear(complex.matrix.imaginary[1], -1, 0, "upper imaginary");
|
||||||
|
assertNear(complex.matrix.real[2], 5, 0, "lower real");
|
||||||
|
assertNear(complex.matrix.imaginary[2], 1, 0, "exact conjugate lower imaginary");
|
||||||
|
assertEqual(complex.matrix.imaginary[0], 0, "real diagonal 0");
|
||||||
|
assertEqual(complex.normalization, "1/T", "covariance normalization disclosed");
|
||||||
|
assertEqual(complex.centered, false, "covariance is not silently centered");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T<M rank-two covariance remains rank-deficient and source count is unavailable", () => {
|
||||||
|
const sensorCount = 4;
|
||||||
|
const snapshotCount = 2;
|
||||||
|
const covariance = computeComplexSampleCovariance({
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
real: Float64Array.of(1, 2, 3, 4, 0, 1, 2, -1),
|
||||||
|
imaginary: Float64Array.of(0, 0, 0, 0, 1, -1, 0.5, 2),
|
||||||
|
});
|
||||||
|
const decomposition = decomposeHermitian({
|
||||||
|
...covariance.matrix,
|
||||||
|
order: sensorCount,
|
||||||
|
});
|
||||||
|
assert(decomposition.converged, "rank-two covariance decomposition");
|
||||||
|
const audit = auditCovarianceEigenvalues({
|
||||||
|
eigenvalues: decomposition.eigenvalues,
|
||||||
|
sensorCount,
|
||||||
|
matrixScale: covariance.matrixScale,
|
||||||
|
covarianceRoundoffBound: covariance.covarianceRoundoffBound,
|
||||||
|
eigensolverResidualFrobeniusNorm: decomposition.residualFrobeniusNorm,
|
||||||
|
});
|
||||||
|
assertEqual(
|
||||||
|
audit.roundoffDerivation,
|
||||||
|
"covariance-accumulation-plus-eigensolver-residual",
|
||||||
|
"PSD roundoff derivation",
|
||||||
|
);
|
||||||
|
assertNear(audit.roundoffClampedEigenvalues[2], 0, audit.roundoffTolerance, "rank eigenvalue 2");
|
||||||
|
assertNear(audit.roundoffClampedEigenvalues[3], 0, audit.roundoffTolerance, "rank eigenvalue 3");
|
||||||
|
const selected = selectWaxKailathSourceCount({
|
||||||
|
eigenvalues: audit.roundoffClampedEigenvalues,
|
||||||
|
snapshotCount,
|
||||||
|
independentSnapshots: true,
|
||||||
|
criterion: "mdl",
|
||||||
|
roundoffTolerance: audit.roundoffTolerance,
|
||||||
|
});
|
||||||
|
assert(!selected.available, "T<M MDL unavailable");
|
||||||
|
assertEqual(
|
||||||
|
selected.reason,
|
||||||
|
"snapshot-count-not-greater-than-sensors",
|
||||||
|
"T<M reason",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wax-Kailath explicitly declines noiseless rank-deficient spectra", () => {
|
||||||
|
const result = selectWaxKailathSourceCount({
|
||||||
|
eigenvalues: Float64Array.of(5, 2, 0, 0),
|
||||||
|
snapshotCount: 100,
|
||||||
|
independentSnapshots: true,
|
||||||
|
criterion: "mdl",
|
||||||
|
roundoffTolerance: 1e-12,
|
||||||
|
});
|
||||||
|
assert(!result.available, "rank-deficient MDL unavailable");
|
||||||
|
assertEqual(
|
||||||
|
result.reason,
|
||||||
|
"noise-eigenvalue-not-strictly-above-roundoff",
|
||||||
|
"rank-deficient reason",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wax-Kailath AIC and MDL select q=1 for [10,1,1,1]", () => {
|
||||||
|
for (const criterion of ["aic", "mdl"]) {
|
||||||
|
const result = selectWaxKailathSourceCount({
|
||||||
|
eigenvalues: Float64Array.of(10, 1, 1, 1),
|
||||||
|
snapshotCount: 100,
|
||||||
|
independentSnapshots: true,
|
||||||
|
criterion,
|
||||||
|
roundoffTolerance: 1e-14,
|
||||||
|
});
|
||||||
|
assert(result.available, `${criterion} available`);
|
||||||
|
assertEqual(result.sourceCount, 1, `${criterion} source count`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wax-Kailath AIC and MDL select q=0 for an identity covariance", () => {
|
||||||
|
for (const criterion of ["aic", "mdl"]) {
|
||||||
|
const result = selectWaxKailathSourceCount({
|
||||||
|
eigenvalues: Float64Array.of(1, 1, 1, 1),
|
||||||
|
snapshotCount: 100,
|
||||||
|
independentSnapshots: true,
|
||||||
|
criterion,
|
||||||
|
roundoffTolerance: 1e-14,
|
||||||
|
});
|
||||||
|
assert(result.available, `${criterion} available`);
|
||||||
|
assertEqual(result.sourceCount, 0, `${criterion} identity source count`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PSD audit preserves tiny positives and rejects genuine negative eigenvalues", () => {
|
||||||
|
const tinyPositive = 1e-20;
|
||||||
|
const audit = auditCovarianceEigenvalues({
|
||||||
|
eigenvalues: Float64Array.of(10, 1, tinyPositive, -1e-14),
|
||||||
|
sensorCount: 4,
|
||||||
|
matrixScale: 10,
|
||||||
|
roundoffTolerance: 1e-12,
|
||||||
|
});
|
||||||
|
assertEqual(audit.rawEigenvalues[2], tinyPositive, "raw tiny positive");
|
||||||
|
assertEqual(
|
||||||
|
audit.roundoffClampedEigenvalues[2],
|
||||||
|
tinyPositive,
|
||||||
|
"tiny positive is not floored",
|
||||||
|
);
|
||||||
|
assertEqual(audit.roundoffClampedEigenvalues[3], 0, "roundoff negative clamps to zero");
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
auditCovarianceEigenvalues({
|
||||||
|
eigenvalues: Float64Array.of(10, 1, 0.1, -1e-3),
|
||||||
|
sensorCount: 4,
|
||||||
|
roundoffTolerance: 1e-12,
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
assert(error instanceof RangeError, "genuine negative eigenvalue is rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC workload includes PSD-audit and automatic source-count typed arrays", () => {
|
||||||
|
const automatic = createAutomaticMusicFixture();
|
||||||
|
const automaticWorkload = estimateMusicWorkload(automatic);
|
||||||
|
const microphoneCount = automatic.arrayDefinition.microphones.length;
|
||||||
|
const fixed = {
|
||||||
|
...automatic,
|
||||||
|
experiment: structuredClone(automatic.experiment),
|
||||||
|
};
|
||||||
|
fixed.experiment.beamforming.musicSourceCountMethod = "fixed";
|
||||||
|
fixed.experiment.beamforming.musicSourceCount = 1;
|
||||||
|
const fixedWorkload = estimateMusicWorkload(fixed);
|
||||||
|
|
||||||
|
assertEqual(
|
||||||
|
automaticWorkload.covarianceAuditBytes,
|
||||||
|
2 * microphoneCount * Float64Array.BYTES_PER_ELEMENT,
|
||||||
|
"raw and roundoff-clamped PSD audit arrays",
|
||||||
|
);
|
||||||
|
assertEqual(fixedWorkload.sourceCountSelectionBytes, 0, "fixed K has no score array");
|
||||||
|
assertEqual(
|
||||||
|
automaticWorkload.sourceCountSelectionBytes,
|
||||||
|
microphoneCount * Float64Array.BYTES_PER_ELEMENT,
|
||||||
|
"automatic K criterion score array",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
automaticWorkload.perBinNumericalBytes - fixedWorkload.perBinNumericalBytes,
|
||||||
|
automaticWorkload.sourceCountSelectionBytes,
|
||||||
|
"automatic score bytes reach per-bin numerical peak",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
automaticWorkload.baseWorkingBytes - fixedWorkload.baseWorkingBytes,
|
||||||
|
automaticWorkload.sourceCountSelectionBytes,
|
||||||
|
"automatic score bytes reach total working peak",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC reports PSD derivation and labels MDL independence as an unverified model assumption", async () => {
|
||||||
|
const result = await computeMusicMap(createAutomaticMusicFixture());
|
||||||
|
const diagnostics = result.bands[0].diagnostics;
|
||||||
|
assertEqual(diagnostics.sourceCount.method, "mdl", "MDL selected model order");
|
||||||
|
assertEqual(diagnostics.sourceCount.value, 1, "one dominant source selected");
|
||||||
|
assertEqual(
|
||||||
|
diagnostics.covarianceAudit.roundoffDerivation,
|
||||||
|
"covariance-accumulation-plus-eigensolver-residual",
|
||||||
|
"PSD roundoff derivation is serialized from the audit API",
|
||||||
|
);
|
||||||
|
const independence =
|
||||||
|
diagnostics.sourceCount.assumptions.statisticalSnapshotIndependence;
|
||||||
|
assertEqual(independence.assumed, true, "statistical independence is an assumption");
|
||||||
|
assertEqual(
|
||||||
|
independence.evidence,
|
||||||
|
"non-overlapping-frames-only-not-statistical-proof",
|
||||||
|
"non-overlap is not mislabeled as statistical proof",
|
||||||
|
);
|
||||||
|
assertEqual(independence.modelSelectedByUser, true, "MDL model was selected by the user");
|
||||||
|
assertEqual(
|
||||||
|
diagnostics.sourceCount.assumptions.statisticalDataModel.verified,
|
||||||
|
false,
|
||||||
|
"Wax-Kailath data model is not claimed as verified",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -112,6 +112,30 @@ test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("portable above-Nyquist band observes the folded ADC bin without hidden carrier steering", async () => {
|
||||||
|
const fixture = createFixture(128);
|
||||||
|
const basebandPlan = createLocalizationPlan(fixture.config);
|
||||||
|
const aliasedPlan = createLocalizationPlan({
|
||||||
|
...fixture.config,
|
||||||
|
bands: [{ id: "aliased", minHz: 3_572, maxHz: 3_596, color: "#58c7ff" }],
|
||||||
|
});
|
||||||
|
const baseband = await localizeMultichannel({
|
||||||
|
plan: basebandPlan,
|
||||||
|
channels: fixture.channels,
|
||||||
|
});
|
||||||
|
const aliased = await localizeMultichannel({
|
||||||
|
plan: aliasedPlan,
|
||||||
|
channels: fixture.channels,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEqual(aliasedPlan.bands[0].minHz, 3_572, "requested analog band is retained");
|
||||||
|
assertFloat32Exact(
|
||||||
|
aliased.bands[0].rawMap,
|
||||||
|
baseband.bands[0].rawMap,
|
||||||
|
"same ADC bin produces the same conventional DAS map",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => {
|
test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => {
|
||||||
const scan = {
|
const scan = {
|
||||||
z: 1,
|
z: 1,
|
||||||
@@ -141,6 +165,98 @@ test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("portable peak detection treats MUSIC noise projection as a minimum", () => {
|
||||||
|
const scan = {
|
||||||
|
z: 1,
|
||||||
|
xMin: 0,
|
||||||
|
xMax: 4,
|
||||||
|
yMin: 0,
|
||||||
|
yMax: 4,
|
||||||
|
columns: 5,
|
||||||
|
rows: 5,
|
||||||
|
};
|
||||||
|
const q = new Float64Array(25);
|
||||||
|
q.fill(1);
|
||||||
|
q[6] = 0.1;
|
||||||
|
q[18] = 0.2;
|
||||||
|
q[3] = 0.6;
|
||||||
|
|
||||||
|
const peaks = detectBandPeaks(q, scan, {
|
||||||
|
rawScale: "noise-projection",
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
});
|
||||||
|
assertEqual(peaks.length, 2, "two q minima exceed the merit threshold");
|
||||||
|
assertEqual(peaks[0].index, 6, "smallest q ranks first");
|
||||||
|
assert(
|
||||||
|
Math.abs(peaks[0].noiseProjection - 0.1) <= 1e-12,
|
||||||
|
"raw q is retained",
|
||||||
|
);
|
||||||
|
assertEqual(peaks[0].objectiveDirection, "minimum", "minimum objective is explicit");
|
||||||
|
assertEqual(
|
||||||
|
detectBandPeaks(
|
||||||
|
new Float64Array(25).fill(0.5),
|
||||||
|
scan,
|
||||||
|
{
|
||||||
|
rawScale: "noise-projection",
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
},
|
||||||
|
).length,
|
||||||
|
0,
|
||||||
|
"flat q does not choose the first grid cell",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable and streaming MUSIC consume unavailable auto-K windows without stalling", async () => {
|
||||||
|
const fixture = createFixture(256);
|
||||||
|
const musicConfig = {
|
||||||
|
...fixture.config,
|
||||||
|
algorithm: "music",
|
||||||
|
normalization: "none",
|
||||||
|
stft: { fftSize: 64, hopSize: 64 },
|
||||||
|
musicSourceCountMethod: "mdl",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
};
|
||||||
|
const plan = createLocalizationPlan(musicConfig);
|
||||||
|
const block = await localizeMultichannel({
|
||||||
|
plan,
|
||||||
|
channels: sliceChannels(fixture.channels, 0, 128),
|
||||||
|
});
|
||||||
|
assertEqual(block.status, "unavailable", "short auto-K block is typed unavailable");
|
||||||
|
assertEqual(
|
||||||
|
block.unavailable.reason,
|
||||||
|
"insufficient-snapshots-for-source-count-selection",
|
||||||
|
"unavailable reason is explicit",
|
||||||
|
);
|
||||||
|
assertEqual(block.frameCount, 2, "unavailable result preserves analyzed frame count");
|
||||||
|
assertEqual(block.bands.length, 0, "no arbitrary MUSIC map");
|
||||||
|
assertEqual(block.locations.length, 0, "no arbitrary location");
|
||||||
|
|
||||||
|
const stream = createStreamingLocalizer({
|
||||||
|
...musicConfig,
|
||||||
|
analysisWindowSamples: 128,
|
||||||
|
analysisHopSamples: 128,
|
||||||
|
maximumBufferedSamples: 256,
|
||||||
|
maximumResultsPerCall: 2,
|
||||||
|
});
|
||||||
|
stream.append(fixture.channels);
|
||||||
|
const results = await stream.analyzeAvailable({
|
||||||
|
maximumResults: 2,
|
||||||
|
});
|
||||||
|
assertEqual(results.length, 2, "both unavailable windows are emitted");
|
||||||
|
assert(
|
||||||
|
results.every(
|
||||||
|
(result) =>
|
||||||
|
result.status === "unavailable" &&
|
||||||
|
result.locations.length === 0,
|
||||||
|
),
|
||||||
|
"each window remains explicitly location-free",
|
||||||
|
);
|
||||||
|
const streamState = stream.getState();
|
||||||
|
assertEqual(streamState.completedAnalyses, 2, "unavailable windows commit");
|
||||||
|
assertEqual(streamState.failedAnalyses, 0, "unavailable is not a runtime failure");
|
||||||
|
assertEqual(streamState.bufferedSamples, 0, "stream advances past both windows");
|
||||||
|
});
|
||||||
|
|
||||||
test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 실제로 제거한다", async () => {
|
test("CPU 분석 계획이 반복 geometry·steering 연산과 bin map 할당을 실제로 제거한다", async () => {
|
||||||
const fixture = createFixture(128);
|
const fixture = createFixture(128);
|
||||||
const plan = createLocalizationPlan(fixture.config);
|
const plan = createLocalizationPlan(fixture.config);
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import {
|
||||||
|
computeDixonSpectralFlux,
|
||||||
|
estimateDixonSpectralFluxWorkload,
|
||||||
|
pickCausalSpectralFluxPeaks,
|
||||||
|
} from "/packages/acoustics-core/spectral-flux.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(
|
||||||
|
Math.abs(actual - expected) <= tolerance,
|
||||||
|
`${label}: expected ${expected} +/- ${tolerance}, received ${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertArrayNear(actual, expected, tolerance, label) {
|
||||||
|
assertEqual(actual.length, expected.length, `${label} length`);
|
||||||
|
for (let index = 0; index < expected.length; index += 1) {
|
||||||
|
assertNear(actual[index], expected[index], tolerance, `${label}[${index}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertArrayExact(actual, expected, label) {
|
||||||
|
assertEqual(actual.length, expected.length, `${label} length`);
|
||||||
|
for (let index = 0; index < expected.length; index += 1) {
|
||||||
|
assertEqual(actual[index], expected[index], `${label}[${index}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThrows(callback, ErrorType, expectedText, label) {
|
||||||
|
let caught = null;
|
||||||
|
try {
|
||||||
|
callback();
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
assert(caught instanceof ErrorType, `${label}: expected ${ErrorType.name}`);
|
||||||
|
assert(
|
||||||
|
caught.message.includes(expectedText),
|
||||||
|
`${label}: expected message containing "${expectedText}", received "${caught.message}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fluxConfiguration(samples, overrides = {}) {
|
||||||
|
return {
|
||||||
|
samples,
|
||||||
|
sampleRate: 4,
|
||||||
|
fftSize: 4,
|
||||||
|
hopSize: 4,
|
||||||
|
channelId: "adc-channel-0",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Dixon flux는 Hamming linear magnitude의 양의 차이를 full DFT에서 그대로 합산한다", () => {
|
||||||
|
const result = computeDixonSpectralFlux(fluxConfiguration(
|
||||||
|
Float64Array.of(0, 0, 0, 0, 1, 1, 1, 1),
|
||||||
|
{ maximumOutputBytes: 128 },
|
||||||
|
));
|
||||||
|
const sideMagnitude = Math.hypot(-0.69, -0.69);
|
||||||
|
const expectedSecondFrame = [1.7, sideMagnitude, 0, sideMagnitude];
|
||||||
|
|
||||||
|
assertEqual(result.frameCount, 2, "frame count");
|
||||||
|
assertEqual(result.binCount, 4, "full DFT bin count");
|
||||||
|
assert(result.rawOdf instanceof Float64Array, "raw ODF type");
|
||||||
|
assert(result.positiveDifferences instanceof Float64Array, "raw per-bin type");
|
||||||
|
assertArrayExact(result.positiveDifferences.subarray(0, 4), [0, 0, 0, 0], "first frame");
|
||||||
|
assertArrayNear(
|
||||||
|
result.positiveDifferences.subarray(4, 8),
|
||||||
|
expectedSecondFrame,
|
||||||
|
1e-12,
|
||||||
|
"second-frame positive differences",
|
||||||
|
);
|
||||||
|
assertNear(
|
||||||
|
result.rawOdf[1],
|
||||||
|
expectedSecondFrame.reduce((sum, value) => sum + value, 0),
|
||||||
|
1e-12,
|
||||||
|
"raw L1 ODF",
|
||||||
|
);
|
||||||
|
assertArrayExact(result.frameStartSamples, [0, 4], "frame starts");
|
||||||
|
assertArrayExact(result.frameCenterSamples, [1.5, 5.5], "frame centers");
|
||||||
|
assertArrayExact(result.frameAvailableAfterSamples, [4, 8], "causal availability");
|
||||||
|
assertEqual(result.latency.firstDifferenceAvailableAfterSamples, 8, "first difference latency");
|
||||||
|
assertEqual(result.provenance.input.channelId, "adc-channel-0", "explicit channel provenance");
|
||||||
|
assertEqual(result.provenance.input.microphoneFusion, "none", "no microphone fusion");
|
||||||
|
assertEqual(result.provenance.globalNormalization, "none", "no global normalization");
|
||||||
|
assertEqual(result.provenance.magnitudeScale, "linear", "linear magnitude");
|
||||||
|
assertEqual(
|
||||||
|
result.provenance.stft.spectrumBins,
|
||||||
|
"full-dft-bin-order-including-the-real-signal-negative-frequency-mirror",
|
||||||
|
"full DFT aggregation disclosure",
|
||||||
|
);
|
||||||
|
assertEqual(result.workload.positiveDifferenceBytes, 64, "raw bin bytes");
|
||||||
|
assertEqual(result.workload.outputBytes, 128, "total returned typed-array bytes");
|
||||||
|
assertEqual(result.workload.maximumOutputBytes, 128, "caller output limit");
|
||||||
|
const preflight = estimateDixonSpectralFluxWorkload({
|
||||||
|
sampleCount: 8,
|
||||||
|
fftSize: 4,
|
||||||
|
hopSize: 4,
|
||||||
|
maximumOutputBytes: 128,
|
||||||
|
});
|
||||||
|
assertEqual(preflight.frameCount, result.frameCount, "preflight frame count");
|
||||||
|
assertEqual(preflight.outputBytes, result.workload.outputBytes, "preflight output bytes");
|
||||||
|
assertEqual(
|
||||||
|
preflight.estimatedPeakTypedArrayBytes,
|
||||||
|
result.workload.estimatedPeakTypedArrayBytes,
|
||||||
|
"preflight peak typed-array bytes",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Dixon flux는 silence와 incomplete tail을 패딩 없이 처리한다", () => {
|
||||||
|
const samples = new Float64Array(9);
|
||||||
|
samples[8] = 1_000_000;
|
||||||
|
const result = computeDixonSpectralFlux(fluxConfiguration(samples));
|
||||||
|
|
||||||
|
assertEqual(result.frameCount, 2, "only two complete frames");
|
||||||
|
assertArrayExact(result.rawOdf, [0, 0], "silent raw ODF");
|
||||||
|
assertArrayExact(
|
||||||
|
result.positiveDifferences,
|
||||||
|
new Float64Array(8),
|
||||||
|
"silent per-bin differences",
|
||||||
|
);
|
||||||
|
assertEqual(result.latency.completeFramesOnly, true, "complete frame policy");
|
||||||
|
assertEqual(
|
||||||
|
result.provenance.stft.framePolicy,
|
||||||
|
"complete-only-no-padding",
|
||||||
|
"no padding provenance",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooShort = computeDixonSpectralFlux(fluxConfiguration(
|
||||||
|
Float64Array.of(1, 2, 3),
|
||||||
|
{ maximumOutputBytes: 1 },
|
||||||
|
));
|
||||||
|
assertEqual(tooShort.frameCount, 0, "short input has no frame");
|
||||||
|
assertEqual(tooShort.workload.outputBytes, 0, "short input allocates no output data");
|
||||||
|
assertEqual(tooShort.workload.workspaceBytes, 0, "short input allocates no FFT workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("정상상태 tone은 최초 spectral rise 뒤 raw flux가 0에 가까워지는 한계를 보존한다", () => {
|
||||||
|
const tone = Float64Array.from(
|
||||||
|
{ length: 8 },
|
||||||
|
(_, index) => Math.cos((2 * Math.PI * index) / 8),
|
||||||
|
);
|
||||||
|
const samples = new Float64Array(24);
|
||||||
|
samples.set(tone, 8);
|
||||||
|
samples.set(tone, 16);
|
||||||
|
const result = computeDixonSpectralFlux({
|
||||||
|
samples,
|
||||||
|
sampleRate: 8,
|
||||||
|
fftSize: 8,
|
||||||
|
hopSize: 8,
|
||||||
|
channelId: "tone-channel",
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEqual(result.frameCount, 3, "three complete frames");
|
||||||
|
assertEqual(result.rawOdf[0], 0, "no predecessor for first frame");
|
||||||
|
assert(result.rawOdf[1] > 0, "tone rise is nonzero");
|
||||||
|
assertNear(result.rawOdf[2], 0, 1e-12, "unchanged tone flux");
|
||||||
|
assertArrayNear(
|
||||||
|
result.positiveDifferences.subarray(16, 24),
|
||||||
|
new Float64Array(8),
|
||||||
|
1e-12,
|
||||||
|
"unchanged tone per-bin differences",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
result.measurementSemantics.sustainedActivityMeasure,
|
||||||
|
false,
|
||||||
|
"not a sustained activity measure",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
result.limitations.steadySound.includes("steady spectrum produces zero flux"),
|
||||||
|
"steady-sound limitation is structural metadata",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Boeck causal peak picker는 과거 max/mean, delta와 strict refractory만 사용한다", () => {
|
||||||
|
const source = Float64Array.of(0, 1, 0, 10, 0);
|
||||||
|
const configuration = {
|
||||||
|
delta: 0.4,
|
||||||
|
preMaxFrames: 1,
|
||||||
|
preMeanFrames: 2,
|
||||||
|
refractoryFrames: 1,
|
||||||
|
};
|
||||||
|
const result = pickCausalSpectralFluxPeaks(source, configuration);
|
||||||
|
|
||||||
|
assertArrayExact(result.rawOdf, [0, 1, 0, 10, 0], "preserved raw ODF");
|
||||||
|
assertArrayNear(
|
||||||
|
result.adaptiveThresholds,
|
||||||
|
[0.4, 0.9, 1 / 3 + 0.4, 11 / 3 + 0.4, 10 / 3 + 0.4],
|
||||||
|
1e-12,
|
||||||
|
"causal thresholds",
|
||||||
|
);
|
||||||
|
assertArrayExact(result.peakFrameIndices, [1, 3], "causal peaks");
|
||||||
|
assertArrayExact(result.peakMask, [0, 1, 0, 1, 0], "peak mask");
|
||||||
|
assertEqual(result.configuration.postMaxFrames, 0, "no future maximum frames");
|
||||||
|
assertEqual(result.configuration.postMeanFrames, 0, "no future mean frames");
|
||||||
|
assertEqual(result.latency.futureContextFrames, 0, "no future context");
|
||||||
|
assertEqual(result.provenance.globalNormalization, "none", "no peak-picker normalization");
|
||||||
|
assertEqual(
|
||||||
|
result.measurementSemantics.kind,
|
||||||
|
"thresholded-onset-events-from-raw-novelty",
|
||||||
|
"event semantics",
|
||||||
|
);
|
||||||
|
|
||||||
|
const prefix = pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), configuration);
|
||||||
|
assertArrayExact(prefix.peakFrameIndices, [1], "prefix peak");
|
||||||
|
assertEqual(prefix.adaptiveThresholds[1], result.adaptiveThresholds[1], "future invariance");
|
||||||
|
|
||||||
|
const refractory = pickCausalSpectralFluxPeaks(
|
||||||
|
Float64Array.of(0, 1, 0, 2),
|
||||||
|
{
|
||||||
|
delta: 0.4,
|
||||||
|
preMaxFrames: 1,
|
||||||
|
preMeanFrames: 1,
|
||||||
|
refractoryFrames: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assertArrayExact(refractory.peakFrameIndices, [1], "strict refractory boundary");
|
||||||
|
|
||||||
|
source[1] = 99;
|
||||||
|
assertEqual(result.rawOdf[1], 1, "returned raw ODF is a preserved copy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spectral flux와 causal picker는 non-finite 및 음수 novelty를 거부한다", () => {
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration(Float64Array.of(0, Number.NaN, 0, 0))),
|
||||||
|
TypeError,
|
||||||
|
"samples[1] must be finite",
|
||||||
|
"NaN sample",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration(
|
||||||
|
Float64Array.of(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE),
|
||||||
|
)),
|
||||||
|
RangeError,
|
||||||
|
"STFT magnitude became non-finite",
|
||||||
|
"overflowing transform",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => pickCausalSpectralFluxPeaks(
|
||||||
|
Float64Array.of(0, Number.POSITIVE_INFINITY),
|
||||||
|
{ delta: 1, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
|
||||||
|
),
|
||||||
|
TypeError,
|
||||||
|
"samples[1] must be finite",
|
||||||
|
"infinite ODF",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => pickCausalSpectralFluxPeaks(
|
||||||
|
Float64Array.of(0, -1),
|
||||||
|
{ delta: 1, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
|
||||||
|
),
|
||||||
|
RangeError,
|
||||||
|
"rawOdfInput[1] must be non-negative",
|
||||||
|
"negative ODF",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => pickCausalSpectralFluxPeaks(
|
||||||
|
Float64Array.of(0, 1),
|
||||||
|
{ delta: Number.NaN, preMaxFrames: 1, preMeanFrames: 1, refractoryFrames: 1 },
|
||||||
|
),
|
||||||
|
RangeError,
|
||||||
|
"delta must be finite",
|
||||||
|
"non-finite delta",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spectral flux 설정은 single channel, FFT, provenance와 출력 상한을 명시적으로 검증한다", () => {
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux({
|
||||||
|
samples: Float64Array.of(0, 0, 0, 0),
|
||||||
|
sampleRate: 4,
|
||||||
|
fftSize: 4,
|
||||||
|
hopSize: 4,
|
||||||
|
}),
|
||||||
|
TypeError,
|
||||||
|
"channelId must be provided explicitly",
|
||||||
|
"missing channel id",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration(Float64Array.of(0, 0, 0, 0), {
|
||||||
|
channelId: "",
|
||||||
|
})),
|
||||||
|
TypeError,
|
||||||
|
"channelId must be a non-empty string",
|
||||||
|
"empty channel id",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration([[0], [0], [0], [0]])),
|
||||||
|
TypeError,
|
||||||
|
"samples[0] must be finite",
|
||||||
|
"channel collection",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), { fftSize: 3 })),
|
||||||
|
RangeError,
|
||||||
|
"power-of-two integer",
|
||||||
|
"non-radix FFT",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), {
|
||||||
|
fftSize: 2 ** 32,
|
||||||
|
})),
|
||||||
|
RangeError,
|
||||||
|
"through 536870912",
|
||||||
|
"FFT exceeds radix implementation limit",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), { hopSize: 0 })),
|
||||||
|
RangeError,
|
||||||
|
"hopSize must be between 1 and fftSize",
|
||||||
|
"zero hop",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(8), {
|
||||||
|
maximumOutputBytes: 127,
|
||||||
|
})),
|
||||||
|
RangeError,
|
||||||
|
"maximumOutputBytes is 127",
|
||||||
|
"pre-allocation output cap",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => computeDixonSpectralFlux(fluxConfiguration(new Float64Array(4), {
|
||||||
|
maximumOutputBytes: 1.5,
|
||||||
|
})),
|
||||||
|
RangeError,
|
||||||
|
"maximumOutputBytes must be a positive safe integer",
|
||||||
|
"invalid output cap",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), {
|
||||||
|
delta: 0.1,
|
||||||
|
preMaxFrames: 1,
|
||||||
|
preMeanFrames: 1,
|
||||||
|
}),
|
||||||
|
TypeError,
|
||||||
|
"refractoryFrames must be provided explicitly",
|
||||||
|
"missing peak-picker parameter",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => pickCausalSpectralFluxPeaks(Float64Array.of(0, 1), {
|
||||||
|
delta: 0.1,
|
||||||
|
preMaxFrames: -1,
|
||||||
|
preMeanFrames: 1,
|
||||||
|
refractoryFrames: 1,
|
||||||
|
}),
|
||||||
|
RangeError,
|
||||||
|
"preMaxFrames must be a non-negative safe integer",
|
||||||
|
"negative peak-picker window",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("spectral flux와 causal peak picker는 같은 입력에서 결정적이다", () => {
|
||||||
|
const samples = Float64Array.of(
|
||||||
|
0, 0.25, -0.5, 0.75,
|
||||||
|
0.5, -0.25, 0.125, -0.625,
|
||||||
|
-0.5, 0.25, 0.75, -0.125,
|
||||||
|
);
|
||||||
|
const untouched = Float64Array.from(samples);
|
||||||
|
const options = {
|
||||||
|
samples,
|
||||||
|
sampleRate: 8,
|
||||||
|
fftSize: 4,
|
||||||
|
hopSize: 2,
|
||||||
|
channelId: "determinism-channel",
|
||||||
|
};
|
||||||
|
const first = computeDixonSpectralFlux(options);
|
||||||
|
const second = computeDixonSpectralFlux(options);
|
||||||
|
|
||||||
|
assertArrayExact(samples, untouched, "input samples remain unchanged");
|
||||||
|
assertArrayExact(first.rawOdf, second.rawOdf, "deterministic raw ODF");
|
||||||
|
assertArrayExact(
|
||||||
|
first.positiveDifferences,
|
||||||
|
second.positiveDifferences,
|
||||||
|
"deterministic per-bin differences",
|
||||||
|
);
|
||||||
|
assertEqual(JSON.stringify(first), JSON.stringify(second), "deterministic metadata");
|
||||||
|
|
||||||
|
const pickerOptions = {
|
||||||
|
delta: 0.01,
|
||||||
|
preMaxFrames: 2,
|
||||||
|
preMeanFrames: 3,
|
||||||
|
refractoryFrames: 1,
|
||||||
|
};
|
||||||
|
const firstPeaks = pickCausalSpectralFluxPeaks(first.rawOdf, pickerOptions);
|
||||||
|
const secondPeaks = pickCausalSpectralFluxPeaks(first.rawOdf, pickerOptions);
|
||||||
|
assertEqual(JSON.stringify(firstPeaks), JSON.stringify(secondPeaks), "deterministic peaks");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user