Compare commits

..

14 Commits

75 changed files with 18659 additions and 511 deletions
+96 -16
View File
@@ -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 포팅
+97 -19
View File
@@ -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`를
측정해 판정해야 합니다. 측정해 판정해야 합니다.
+12
View File
@@ -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,
+55 -6
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+268 -11
View File
@@ -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;
}), }),
}; };
+284 -6
View File
@@ -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,
+41
View File
@@ -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);
});
}
+231 -2
View File
@@ -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;
} }
+28 -1
View File
@@ -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",
}; };
} }
+36 -7
View File
@@ -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**으로 바뀌고 채널 수·좌표가 의도한
배열인지 직접 확인하세요.
## 검증과 제한 ## 검증과 제한
+326
View File
@@ -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 };
}
+117 -1
View File
@@ -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>
+249
View File
@@ -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();
}); });
+235
View File
@@ -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;
+4
View File
@@ -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**를 선택하거나
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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); });
+16 -2
View File
@@ -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 "알 수 없는 신호";
+18 -1
View File
@@ -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
View File
@@ -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도
확률분포나 신뢰구간으로 바꾸지 않는다.
## 정확도 범위와 제외 사항 ## 정확도 범위와 제외 사항
+157
View File
@@ -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만 반환하지 않고
신호 합성 전에 실패한다.
+130
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &amp; compute</p> <p class="eyebrow">Localization &amp; 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>
+38 -4
View File
@@ -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 경계에서
+213 -11
View File
@@ -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합니다.
+345
View File
@@ -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),
};
}
+625
View File
@@ -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,
},
};
}
+622
View File
@@ -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" });
}
+480
View File
@@ -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,
};
}
+267 -6
View File
@@ -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;
} }
+389 -33
View File
@@ -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,
+25 -5
View File
@@ -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];
} }
} }
+555
View File
@@ -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,
};
}
+7
View File
@@ -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
View File
@@ -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 + ".");
} }
+29
View File
@@ -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
+650 -63
View File
@@ -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),
})),
],
},
}; };
} }
+115 -22
View File
@@ -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,
+250
View File
@@ -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),
};
}
+34 -5
View File
@@ -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.");
} }
+477
View File
@@ -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",
},
};
}
+30 -11
View File
@@ -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);
} }
+136 -22
View File
@@ -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;
} }
+8 -2
View File
@@ -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
+9 -8
View File
@@ -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
View File
@@ -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 안에
+10 -4
View File
@@ -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
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+6 -2
View File
@@ -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 흐름
+127 -3
View File
@@ -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",
+14
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치
File diff suppressed because it is too large Load Diff
+179
View File
@@ -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");
});
+415
View File
@@ -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");
});
+24
View File
@@ -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
View File
@@ -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",
);
}); });
+465
View File
@@ -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}`);
}
});
+343 -9
View File
@@ -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";
+147
View File
@@ -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");
});
+364 -5
View File
@@ -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 = {
+507
View File
@@ -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",
);
});
+116
View File
@@ -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);
+386
View File
@@ -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");
});