feat: add physically explicit narrowband MUSIC
This commit is contained in:
@@ -53,6 +53,9 @@ 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보다 오래 걸릴 수
|
||||||
있습니다.
|
있습니다.
|
||||||
|
|
||||||
@@ -114,7 +117,8 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
|
|
||||||
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
|
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
|
||||||
2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment는 어느
|
2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment는 어느
|
||||||
시점에 열어도 모든 분석 대역을 유지하고 현재 Scene·Array에 자동으로 연결됩니다.
|
시점에 열어도 모든 분석 대역과 원래의 `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`을 선택합니다.
|
||||||
@@ -123,9 +127,9 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다.
|
7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다.
|
||||||
8. 완료 후 **Result JSON**을 저장합니다.
|
8. 완료 후 **Result JSON**을 저장합니다.
|
||||||
|
|
||||||
Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사한 뒤 `sceneId`와 `arrayId`를
|
Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사하고 가져온 `sceneId`,
|
||||||
현재 열린 Scene·Array에 자동으로 연결합니다. 가져온 `frequencyBands`와 나머지 분석 설정은
|
`arrayId`, `frequencyBands`와 나머지 분석 설정을 그대로 보존합니다. 두 ID가 현재 입력과
|
||||||
보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행합니다. Nyquist 위 tone과
|
달라도 거부하지 않으며, Result의 ID와 hash에는 실제 실행에 사용한 Scene·Array를 기록합니다. Nyquist 위 tone과
|
||||||
band는 오류가 아니라 ideal ADC alias 실험으로 허용되며, 원 주파수와 실제 ADC FFT range가 UI와
|
band는 오류가 아니라 ideal ADC alias 실험으로 허용되며, 원 주파수와 실제 ADC FFT range가 UI와
|
||||||
Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제 오류는 그대로 거부됩니다. Scene을
|
Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제 오류는 그대로 거부됩니다. Scene을
|
||||||
다시 불러오면 이전 asset 연결은 해제됩니다.
|
다시 불러오면 이전 asset 연결은 해제됩니다.
|
||||||
@@ -133,7 +137,7 @@ Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제
|
|||||||
Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에
|
Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에
|
||||||
유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와
|
유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와
|
||||||
Scene 또는 Array를 선택하세요. 같은 Camera 페이지에서 Scene을 교체하면 scan 영역과
|
Scene 또는 Array를 선택하세요. 같은 Camera 페이지에서 Scene을 교체하면 scan 영역과
|
||||||
`sceneId`만 맞추며 기존 `frequencyBands`는 보존합니다.
|
plane을 새 Scene에 맞추되 Experiment의 provenance ID와 기존 `frequencyBands`는 보존합니다.
|
||||||
|
|
||||||
Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
|
Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
|
||||||
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
|
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
|
||||||
@@ -157,6 +161,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로 계속합니다.
|
||||||
@@ -164,9 +169,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, 대역 폭과 장치에 함께 좌우됩니다.
|
||||||
@@ -217,6 +234,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는
|
||||||
@@ -243,7 +275,9 @@ channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부
|
|||||||
Result JSON은 계약 버전 `1.1.0`의 `micarrsim.result` 문서입니다. Array·Scene·Experiment는
|
Result JSON은 계약 버전 `1.1.0`의 `micarrsim.result` 문서입니다. Array·Scene·Experiment는
|
||||||
`1.0.0`을 유지합니다. 주요 내용은 다음과 같습니다.
|
`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
|
- 원 tone→signed ADC alias, DC/Nyquist 퇴화와 anti-alias filter 부재를 담은 필수 sampling provenance
|
||||||
@@ -268,8 +302,9 @@ source는 임의 spectrum threshold를 만들지 않고 모든 band의 ground tr
|
|||||||
음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된
|
음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된
|
||||||
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
|
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
|
||||||
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC quantization·nonlinearity와
|
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC quantization·nonlinearity와
|
||||||
channel clock jitter·drift는 모델링하지 않습니다. 위치 추정기는 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·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
|
||||||
판단을 대신하지 않습니다.
|
판단을 대신하지 않습니다.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ 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에서도 준비량을 줄였습니다.
|
||||||
|
|
||||||
@@ -34,11 +37,12 @@ DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 g
|
|||||||
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를 불러와도
|
||||||
시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에
|
`arrayId` label을 덮어쓰지 않습니다. 별도 Experiment는 어느 시점에 불러와도 문서 자체를
|
||||||
자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도
|
먼저 검증하며 원래 `sceneId`/`arrayId`와 모든 분석 대역을 보존합니다. ID가 달라도 실행이
|
||||||
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist 위 tone·band는 ideal ADC
|
막히지 않고 현재 열린 Scene·Array가 실제 입력이 되며, Result에 그 실제 ID와 hash가 기록됩니다.
|
||||||
|
Nyquist 위 tone·band는 ideal ADC
|
||||||
alias로 실행되고, 잘못된 scan 범위 같은 실제 관계 오류는 그대로 거부됩니다.
|
alias로 실행되고, 잘못된 scan 범위 같은 실제 관계 오류는 그대로 거부됩니다.
|
||||||
|
|
||||||
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
|
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
|
||||||
@@ -53,7 +57,7 @@ Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라
|
|||||||
|
|
||||||
## 위치 추정 알고리즘과 Backend
|
## 위치 추정 알고리즘과 Backend
|
||||||
|
|
||||||
**분석 설정 → 위치 추정 알고리즘**에서 다음 세 구현을 선택합니다.
|
**분석 설정 → 위치 추정 알고리즘**에서 다음 네 구현을 선택합니다.
|
||||||
|
|
||||||
- **DAS**: 채널 진폭과 위상을 유지해 coherent sum power를 계산하는 기준선입니다. CPU와 WebGPU를
|
- **DAS**: 채널 진폭과 위상을 유지해 coherent sum power를 계산하는 기준선입니다. CPU와 WebGPU를
|
||||||
지원하고 `rawMap.scale`은 `power`입니다.
|
지원하고 `rawMap.scale`은 `power`입니다.
|
||||||
@@ -62,13 +66,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)**로
|
||||||
@@ -129,6 +140,8 @@ 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에 따라 실제 시간이 달라집니다.
|
||||||
@@ -138,6 +151,12 @@ band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
|||||||
DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
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를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
|
||||||
@@ -161,7 +180,8 @@ basin을 사용합니다. 선택한 표준 방식과 threshold는 Experiment와
|
|||||||
남습니다.
|
남습니다.
|
||||||
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 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` 문서로 저장합니다.
|
||||||
|
|
||||||
@@ -220,8 +240,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`를
|
||||||
측정해 판정해야 합니다.
|
측정해 판정해야 합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -40,12 +40,12 @@
|
|||||||
<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="Experiment를 먼저 열 수 있습니다. sceneId와 arrayId는 현재 열린 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="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/" target="_blank" rel="noopener" title="현재 Camera의 Experiment를 유지한 채 새 탭에서 Scene을 편집합니다. 저장 후 이 Camera 탭으로 돌아와 Scene JSON을 여세요.">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>
|
||||||
@@ -89,13 +89,18 @@
|
|||||||
</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>
|
<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>
|
||||||
|
<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>
|
||||||
<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>
|
<p class="sampling-warning" id="sampling-warning" hidden></p>
|
||||||
|
|
||||||
|
|||||||
+309
-64
@@ -53,6 +53,14 @@ const MOVING_EXAMPLE_URLS = {
|
|||||||
scene: "/assets/sample-scenes/moving-tone.scene.json",
|
scene: "/assets/sample-scenes/moving-tone.scene.json",
|
||||||
experiment: "/assets/sample-scenes/moving-tone.experiment.json",
|
experiment: "/assets/sample-scenes/moving-tone.experiment.json",
|
||||||
};
|
};
|
||||||
|
const MUSIC_EXAMPLE_URLS = {
|
||||||
|
arrayDefinition:
|
||||||
|
"/assets/array-geometries/rectangular-4x3-450mm.array.json",
|
||||||
|
scene:
|
||||||
|
"/assets/sample-scenes/music-single-tone.scene.json",
|
||||||
|
experiment:
|
||||||
|
"/assets/sample-scenes/music-single-tone.experiment.json",
|
||||||
|
};
|
||||||
const ARRAY_PRESET_URLS = new Map([
|
const ARRAY_PRESET_URLS = new Map([
|
||||||
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
|
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
|
||||||
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"],
|
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"],
|
||||||
@@ -91,6 +99,7 @@ const elements = {
|
|||||||
livePanel: document.querySelector(".live-panel"),
|
livePanel: document.querySelector(".live-panel"),
|
||||||
liveStatus: byId("live-status"),
|
liveStatus: byId("live-status"),
|
||||||
loadMovingExampleButton: byId("load-moving-example-button"),
|
loadMovingExampleButton: byId("load-moving-example-button"),
|
||||||
|
loadMusicExampleButton: byId("load-music-example-button"),
|
||||||
prepareLiveButton: byId("prepare-live-button"),
|
prepareLiveButton: byId("prepare-live-button"),
|
||||||
livePlayButton: byId("live-play-button"),
|
livePlayButton: byId("live-play-button"),
|
||||||
liveTime: byId("live-time"),
|
liveTime: byId("live-time"),
|
||||||
@@ -119,6 +128,10 @@ const elements = {
|
|||||||
frequencyResponseMode: byId("frequency-response-mode"),
|
frequencyResponseMode: byId("frequency-response-mode"),
|
||||||
algorithm: byId("algorithm"),
|
algorithm: byId("algorithm"),
|
||||||
backend: byId("backend"),
|
backend: byId("backend"),
|
||||||
|
musicOptions: byId("music-options"),
|
||||||
|
musicSourceCountMethod: byId("music-source-count-method"),
|
||||||
|
musicSourceCount: byId("music-source-count"),
|
||||||
|
musicOptionsNote: byId("music-options-note"),
|
||||||
gridPreset: byId("grid-preset"),
|
gridPreset: byId("grid-preset"),
|
||||||
scanColumns: byId("scan-columns"),
|
scanColumns: byId("scan-columns"),
|
||||||
scanRows: byId("scan-rows"),
|
scanRows: byId("scan-rows"),
|
||||||
@@ -217,6 +230,10 @@ function applyExperimentDefaults(experiment) {
|
|||||||
if (experiment.evaluation.detectionMethod === undefined) {
|
if (experiment.evaluation.detectionMethod === undefined) {
|
||||||
experiment.evaluation.detectionMethod = "local-max";
|
experiment.evaluation.detectionMethod = "local-max";
|
||||||
}
|
}
|
||||||
|
if (experiment.beamforming?.algorithm === "music") {
|
||||||
|
experiment.beamforming.musicSourceCountMethod ??= "fixed";
|
||||||
|
experiment.beamforming.musicSourceCount ??= 1;
|
||||||
|
}
|
||||||
return experiment;
|
return experiment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +297,7 @@ function renderLiveControls() {
|
|||||||
const playing = Boolean(ready && state.live.playing);
|
const playing = Boolean(ready && state.live.playing);
|
||||||
const locked = Boolean(state.active) || state.pendingInputOperations > 0;
|
const locked = Boolean(state.active) || state.pendingInputOperations > 0;
|
||||||
elements.loadMovingExampleButton.disabled = locked;
|
elements.loadMovingExampleButton.disabled = locked;
|
||||||
|
elements.loadMusicExampleButton.disabled = locked;
|
||||||
elements.prepareLiveButton.disabled = locked || !hasTrajectory || !state.validation.valid;
|
elements.prepareLiveButton.disabled = locked || !hasTrajectory || !state.validation.valid;
|
||||||
elements.livePlayButton.disabled = locked || !ready;
|
elements.livePlayButton.disabled = locked || !ready;
|
||||||
elements.liveTime.disabled = locked || !ready;
|
elements.liveTime.disabled = locked || !ready;
|
||||||
@@ -331,7 +349,8 @@ function setConfigurationLocked(locked) {
|
|||||||
setEditableControlsDisabled(locked);
|
setEditableControlsDisabled(locked);
|
||||||
elements.runButton.disabled = locked || !state.validation.valid;
|
elements.runButton.disabled = locked || !state.validation.valid;
|
||||||
elements.cancelButton.classList.toggle("is-hidden", !locked);
|
elements.cancelButton.classList.toggle("is-hidden", !locked);
|
||||||
elements.exportResultButton.disabled = locked || !state.lastRun;
|
elements.exportResultButton.disabled =
|
||||||
|
locked || !state.lastRun?.resultDocument;
|
||||||
renderLiveControls();
|
renderLiveControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +426,27 @@ function syncAlgorithmBackendControls() {
|
|||||||
elements.backend.title = supportsWebGpu
|
elements.backend.title = supportsWebGpu
|
||||||
? "지원되는 GPU를 우선 사용하고 필요하면 CPU로 전환합니다."
|
? "지원되는 GPU를 우선 사용하고 필요하면 CPU로 전환합니다."
|
||||||
: `${definition?.label ?? algorithm}는 Auto와 CPU만 지원합니다.`;
|
: `${definition?.label ?? algorithm}는 Auto와 CPU만 지원합니다.`;
|
||||||
|
const music = algorithm === "music";
|
||||||
|
const sourceCountMethod =
|
||||||
|
state.experiment?.beamforming?.musicSourceCountMethod ?? "fixed";
|
||||||
|
const maximumSourceCount = Math.max(
|
||||||
|
1,
|
||||||
|
(state.arrayDefinition?.microphones?.length ?? 2) - 1,
|
||||||
|
);
|
||||||
|
elements.musicOptions.hidden = !music;
|
||||||
|
elements.musicSourceCountMethod.value = sourceCountMethod;
|
||||||
|
elements.musicSourceCount.max = String(maximumSourceCount);
|
||||||
|
elements.musicSourceCount.value = String(
|
||||||
|
state.experiment?.beamforming?.musicSourceCount ?? 1,
|
||||||
|
);
|
||||||
|
elements.musicSourceCount.disabled = sourceCountMethod !== "fixed";
|
||||||
|
elements.musicOptionsNote.textContent = sourceCountMethod === "fixed"
|
||||||
|
? `각 band는 아날로그 FFT-grid 가설과 ADC bin을 각각 하나만 선택합니다. K는 1–${maximumSourceCount} 범위입니다. 같은 주파수·고정 위상의 coherent 음원은 standard MUSIC으로 분리되지 않으며 raw q는 확률이 아닙니다.`
|
||||||
|
: "MDL/AIC는 IID complex-Gaussian snapshot·spatially white noise·일정한 source count를 가정합니다. 비중첩 frame은 독립성의 증명이 아니며, 결과 K는 확률/신뢰도가 아닙니다.";
|
||||||
|
elements.showBeamSpread.disabled = music;
|
||||||
|
elements.showBeamSpread.title = music
|
||||||
|
? "MUSIC noise-projection에는 DAS/PHAT의 half-power beam spread를 적용하지 않습니다."
|
||||||
|
: "점선은 1× RMS, 실선 영역은 2× RMS 빔 응답 분포입니다. 확률 또는 신뢰구간은 아닙니다.";
|
||||||
document.documentElement.dataset.localizationAlgorithm = algorithm ?? "";
|
document.documentElement.dataset.localizationAlgorithm = algorithm ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,9 +704,9 @@ function renderLegend() {
|
|||||||
`${formatNumber(range.lastBinCenterHz, 0)} Hz`)
|
`${formatNumber(range.lastBinCenterHz, 0)} Hz`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
text.textContent = `${requested} → ADC FFT bin centers ${adcRanges}`;
|
text.textContent = `${requested} → ADC FFT bin centers ${adcRanges}`;
|
||||||
text.title =
|
text.title = state.experiment.beamforming.algorithm === "music"
|
||||||
"Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; " +
|
? "MUSIC uses the single analog FFT-grid hypothesis configured by this band, never Scene truth. Inverted Nyquist zones conjugate the full received manifold."
|
||||||
"the original alias order is not silently used for steering.";
|
: "Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; the original alias order is not silently used for steering.";
|
||||||
} else {
|
} else {
|
||||||
text.textContent = requested;
|
text.textContent = requested;
|
||||||
}
|
}
|
||||||
@@ -729,13 +769,17 @@ function renderLegend() {
|
|||||||
const localizationDescription =
|
const localizationDescription =
|
||||||
state.experiment.beamforming.algorithm === "gcc-phat"
|
state.experiment.beamforming.algorithm === "gcc-phat"
|
||||||
? "관측된 one-sided ADC FFT bin만 사용해 band-limited GCC-PHAT 기하 lag를 계산하며 "
|
? "관측된 one-sided ADC FFT bin만 사용해 band-limited GCC-PHAT 기하 lag를 계산하며 "
|
||||||
|
: state.experiment.beamforming.algorithm === "music"
|
||||||
|
? "관측 ADC bin의 covariance와 band가 명시한 단일 analog carrier manifold로 standard narrowband MUSIC q를 계산하며 "
|
||||||
: "관측된 양의 ADC FFT bin 주파수로 구면 steering하며 ";
|
: "관측된 양의 ADC FFT bin 주파수로 구면 steering하며 ";
|
||||||
|
const carrierDescription =
|
||||||
|
state.experiment.beamforming.algorithm === "music"
|
||||||
|
? "Scene truth는 사용하지 않습니다. 반전 Nyquist zone은 마이크 응답과 공간 위상을 모두 켤레 처리하며, 물리적 grating lobe·동률·모델 불일치는 그대로 유지합니다. 한 band의 복수 carrier 가설은 선택하거나 융합하지 않고 거부합니다. "
|
||||||
|
: "원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 그대로 나타나며 alias order는 미해결 상태입니다. "
|
||||||
elements.samplingWarning.textContent =
|
elements.samplingWarning.textContent =
|
||||||
"Ideal ADC alias · 위치 추정은 " + localizationDescription +
|
"Ideal ADC alias · 위치 추정은 " +
|
||||||
"원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 " +
|
localizationDescription +
|
||||||
"그대로 나타나며, 현재 알고리즘에서는 alias order가 미해결 상태로 남습니다. " +
|
carrierDescription +
|
||||||
"다채널 공간 위상에는 carrier-order 증거가 남을 수 있지만 이 localizer는 " +
|
|
||||||
"carrier hypothesis를 열거하지 않습니다. " +
|
|
||||||
details.join(" · ");
|
details.join(" · ");
|
||||||
elements.samplingWarning.hidden = false;
|
elements.samplingWarning.hidden = false;
|
||||||
}
|
}
|
||||||
@@ -752,8 +796,13 @@ function renderWorkload() {
|
|||||||
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
|
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
|
||||||
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
|
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
|
||||||
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
|
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
|
||||||
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
|
: workload.algorithm === "music"
|
||||||
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
|
? `${workload.frameCount} snapshots × ${workload.musicHypothesisCount} analog hypotheses / ${workload.selectedBinCount} ADC bins · ` +
|
||||||
|
`cov ${formatCount(workload.musicCovarianceComplexAccumulations)} accumulations + ` +
|
||||||
|
`Jacobi ≤ ${formatCount(workload.musicJacobiWorstCasePairUpdates)} / cap ${formatCount(workload.musicJacobiPairUpdateLimit)} pair updates (32 sweeps) + ` +
|
||||||
|
`projection ≤ ${formatCount(workload.musicProjectionComplexMacs)} complex MAC`
|
||||||
|
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
|
||||||
|
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
|
||||||
elements.workloadSummary.textContent =
|
elements.workloadSummary.textContent =
|
||||||
`${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` +
|
`${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` +
|
||||||
`${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` +
|
`${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` +
|
||||||
@@ -762,7 +811,14 @@ function renderWorkload() {
|
|||||||
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
|
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
|
||||||
const heavyCpu =
|
const heavyCpu =
|
||||||
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
|
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
|
||||||
workload.scanOperationCount > 5e8;
|
(
|
||||||
|
workload.scanOperationCount > 5e8 ||
|
||||||
|
(
|
||||||
|
workload.algorithm === "music" &&
|
||||||
|
workload.musicJacobiWorstCasePairUpdates >
|
||||||
|
150_000_000
|
||||||
|
)
|
||||||
|
);
|
||||||
const narrowGcc = workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8;
|
const narrowGcc = workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8;
|
||||||
elements.workloadSummary.classList.toggle(
|
elements.workloadSummary.classList.toggle(
|
||||||
"is-warning",
|
"is-warning",
|
||||||
@@ -792,7 +848,11 @@ function bandsForVisualization(lastRun) {
|
|||||||
function interpolateMap(left, right, progress) {
|
function interpolateMap(left, right, progress) {
|
||||||
if (left === right || !(progress > 0)) return left;
|
if (left === right || !(progress > 0)) return left;
|
||||||
if (!left || !right || left.length !== right.length) return progress < 0.5 ? left : right;
|
if (!left || !right || left.length !== right.length) return progress < 0.5 ? left : right;
|
||||||
const values = new Float32Array(left.length);
|
const MapArray =
|
||||||
|
left instanceof Float64Array || right instanceof Float64Array
|
||||||
|
? Float64Array
|
||||||
|
: Float32Array;
|
||||||
|
const values = new MapArray(left.length);
|
||||||
const inverse = 1 - progress;
|
const inverse = 1 - progress;
|
||||||
for (let index = 0; index < values.length; index += 1) {
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
values[index] = left[index] * inverse + right[index] * progress;
|
values[index] = left[index] * inverse + right[index] * progress;
|
||||||
@@ -818,7 +878,13 @@ function liveVisualizationState(live) {
|
|||||||
visible: state.visibleBandIds.has(leftBand.id),
|
visible: state.visibleBandIds.has(leftBand.id),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { playback, scene: playback.scene, bands, evaluations: nearestFrame.evaluations };
|
return {
|
||||||
|
playback,
|
||||||
|
scene: playback.scene,
|
||||||
|
bands,
|
||||||
|
evaluations: nearestFrame.evaluations,
|
||||||
|
run: nearestFrame.run,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSceneVisualization() {
|
function renderSceneVisualization() {
|
||||||
@@ -843,7 +909,12 @@ function renderSceneVisualization() {
|
|||||||
);
|
);
|
||||||
elements.canvasBadge.textContent =
|
elements.canvasBadge.textContent =
|
||||||
`LIVE · ${algorithmLabel} · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
|
`LIVE · ${algorithmLabel} · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` +
|
||||||
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}`;
|
`${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}` +
|
||||||
|
(
|
||||||
|
visualization.run.status === "unavailable"
|
||||||
|
? ` · 위치 없음 (${visualization.run.unavailable?.reason ?? "unavailable"})`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const displayed = state.lastRun;
|
const displayed = state.lastRun;
|
||||||
@@ -862,6 +933,9 @@ function renderSceneVisualization() {
|
|||||||
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
|
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
|
||||||
if (!displayed) {
|
if (!displayed) {
|
||||||
elements.canvasBadge.textContent = "결과 없음";
|
elements.canvasBadge.textContent = "결과 없음";
|
||||||
|
} else if (displayed.run.status === "unavailable") {
|
||||||
|
elements.canvasBadge.textContent =
|
||||||
|
`MUSIC · 위치 없음 · ${displayed.run.unavailable?.reason ?? "unavailable"}`;
|
||||||
} else if (displayed.stale) {
|
} else if (displayed.stale) {
|
||||||
elements.canvasBadge.textContent =
|
elements.canvasBadge.textContent =
|
||||||
`이전 설정 결과 · ${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
|
`이전 설정 결과 · ${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
|
||||||
@@ -890,7 +964,8 @@ function appendMetric(label, value, detail, color) {
|
|||||||
|
|
||||||
function renderResult() {
|
function renderResult() {
|
||||||
const displayed = state.lastRun;
|
const displayed = state.lastRun;
|
||||||
elements.exportResultButton.disabled = Boolean(state.active) || !displayed;
|
elements.exportResultButton.disabled =
|
||||||
|
Boolean(state.active) || !displayed?.resultDocument;
|
||||||
elements.metricGrid.replaceChildren();
|
elements.metricGrid.replaceChildren();
|
||||||
elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed));
|
elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed));
|
||||||
elements.metricGrid.classList.toggle("is-hidden", !displayed);
|
elements.metricGrid.classList.toggle("is-hidden", !displayed);
|
||||||
@@ -898,6 +973,26 @@ function renderResult() {
|
|||||||
elements.resultBadge.textContent = "아직 실행하지 않음";
|
elements.resultBadge.textContent = "아직 실행하지 않음";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (displayed.run.status === "unavailable") {
|
||||||
|
const algorithmLabel = localizationAlgorithmLabel(
|
||||||
|
displayed.run.algorithm ??
|
||||||
|
displayed.snapshot.experiment.beamforming.algorithm,
|
||||||
|
);
|
||||||
|
elements.resultBadge.textContent =
|
||||||
|
`${algorithmLabel} · 위치 없음`;
|
||||||
|
appendMetric(
|
||||||
|
"위치 추정",
|
||||||
|
"UNAVAILABLE",
|
||||||
|
displayed.run.unavailable?.reason ??
|
||||||
|
"spatially-unidentifiable",
|
||||||
|
);
|
||||||
|
appendMetric(
|
||||||
|
"처리 상태",
|
||||||
|
"정상 소비",
|
||||||
|
"임의 map·위치를 만들지 않았습니다.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const algorithm = displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm;
|
const algorithm = displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm;
|
||||||
const algorithmLabel = localizationAlgorithmLabel(algorithm);
|
const algorithmLabel = localizationAlgorithmLabel(algorithm);
|
||||||
elements.resultBadge.textContent = displayed.stale
|
elements.resultBadge.textContent = displayed.stale
|
||||||
@@ -968,8 +1063,12 @@ function renderResult() {
|
|||||||
!observation.gridManyToOne &&
|
!observation.gridManyToOne &&
|
||||||
toneContributions.length === 0
|
toneContributions.length === 0
|
||||||
) continue;
|
) continue;
|
||||||
|
const resultAlgorithm =
|
||||||
|
displayed.snapshot.experiment.beamforming.algorithm;
|
||||||
const localizationDetail =
|
const localizationDetail =
|
||||||
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
|
resultAlgorithm === "music"
|
||||||
|
? "협대역 MUSIC noise subspace · configured analog carrier · inversion-aware · Scene truth 미사용"
|
||||||
|
: resultAlgorithm === "gcc-phat"
|
||||||
? "band-limited GCC-PHAT · 원 carrier 미사용"
|
? "band-limited GCC-PHAT · 원 carrier 미사용"
|
||||||
: "양의 ADC bin steering · 원 carrier 미사용 · alias order 미해결";
|
: "양의 ADC bin steering · 원 carrier 미사용 · alias order 미해결";
|
||||||
const toneDetail = toneContributions.length > 0
|
const toneDetail = toneContributions.length > 0
|
||||||
@@ -977,9 +1076,11 @@ function renderResult() {
|
|||||||
: "";
|
: "";
|
||||||
appendMetric(
|
appendMetric(
|
||||||
`${band.id} · ADC steering`,
|
`${band.id} · ADC steering`,
|
||||||
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
|
resultAlgorithm === "gcc-phat"
|
||||||
? "ADC LAG"
|
? "ADC LAG"
|
||||||
: "OBSERVED BIN",
|
: resultAlgorithm === "music"
|
||||||
|
? "MIN q"
|
||||||
|
: "OBSERVED BIN",
|
||||||
localizationDetail + toneDetail,
|
localizationDetail + toneDetail,
|
||||||
band.color,
|
band.color,
|
||||||
);
|
);
|
||||||
@@ -995,7 +1096,9 @@ function renderResult() {
|
|||||||
appendMetric(
|
appendMetric(
|
||||||
"Peak 검출 임계값",
|
"Peak 검출 임계값",
|
||||||
`${formatNumber(detectionThreshold * 100, 0)}%`,
|
`${formatNumber(detectionThreshold * 100, 0)}%`,
|
||||||
"최강 map 값 기준 · 낮을수록 민감",
|
algorithm === "music"
|
||||||
|
? "표준 MUSIC P=1/q의 P/Pmax 기준 · raw q 불변 · floor 없음"
|
||||||
|
: "최강 map 값 기준 · 낮을수록 민감",
|
||||||
);
|
);
|
||||||
appendMetric(
|
appendMetric(
|
||||||
"Peak 묶기",
|
"Peak 묶기",
|
||||||
@@ -1015,32 +1118,45 @@ function renderResult() {
|
|||||||
`${evaluation.counts.truePositive}/${evaluation.counts.truth} source`,
|
`${evaluation.counts.truePositive}/${evaluation.counts.truth} source`,
|
||||||
band.color,
|
band.color,
|
||||||
);
|
);
|
||||||
appendMetric(
|
if (evaluation.diagnostics.objectiveDirection === "minimum") {
|
||||||
`${band.id} · Truth concentration`,
|
appendMetric(
|
||||||
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
|
`${band.id} · Noise projection q`,
|
||||||
"ground-truth 반경 내",
|
evaluation.globalPeak
|
||||||
band.color,
|
? formatNumber(evaluation.globalPeak.value, 6)
|
||||||
);
|
: "식별 불가",
|
||||||
appendMetric(
|
"작을수록 MUSIC 후보 · 확률/신뢰도 아님 · raw q",
|
||||||
`${band.id} · Peak/sidelobe`,
|
band.color,
|
||||||
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
|
);
|
||||||
"높을수록 분리 우수",
|
} else {
|
||||||
band.color,
|
appendMetric(
|
||||||
);
|
`${band.id} · Truth concentration`,
|
||||||
|
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
|
||||||
|
"ground-truth 반경 내",
|
||||||
|
band.color,
|
||||||
|
);
|
||||||
|
appendMetric(
|
||||||
|
`${band.id} · Peak/sidelobe`,
|
||||||
|
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
|
||||||
|
"높을수록 분리 우수",
|
||||||
|
band.color,
|
||||||
|
);
|
||||||
|
}
|
||||||
appendMetric(
|
appendMetric(
|
||||||
`${band.id} · Precision / Recall`,
|
`${band.id} · Precision / Recall`,
|
||||||
`${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`,
|
`${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`,
|
||||||
`${evaluation.counts.detected} peaks`,
|
`${evaluation.counts.detected} peaks`,
|
||||||
band.color,
|
band.color,
|
||||||
);
|
);
|
||||||
appendMetric(
|
if (evaluation.diagnostics.objectiveDirection === "maximum") {
|
||||||
`${band.id} · Main lobe`,
|
appendMetric(
|
||||||
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
|
`${band.id} · Main lobe`,
|
||||||
evaluation.diagnostics.mapScale === "score"
|
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
|
||||||
? "half-maximum score diameter"
|
evaluation.diagnostics.mapScale === "score"
|
||||||
: "half-power equivalent diameter",
|
? "half-maximum score diameter"
|
||||||
band.color,
|
: "half-power equivalent diameter",
|
||||||
);
|
band.color,
|
||||||
|
);
|
||||||
|
}
|
||||||
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
|
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
|
||||||
if (spread?.ellipse) {
|
if (spread?.ellipse) {
|
||||||
appendMetric(
|
appendMetric(
|
||||||
@@ -1109,6 +1225,15 @@ function validationWarnings() {
|
|||||||
if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8) {
|
if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 8) {
|
||||||
warnings.push("GCC-PHAT는 안정적인 광대역 상관을 위해 선택 FFT bin을 8개 이상 권장합니다.");
|
warnings.push("GCC-PHAT는 안정적인 광대역 상관을 위해 선택 FFT bin을 8개 이상 권장합니다.");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
workload.algorithm === "music" &&
|
||||||
|
workload.musicJacobiWorstCasePairUpdates >
|
||||||
|
150_000_000
|
||||||
|
) {
|
||||||
|
warnings.push(
|
||||||
|
`MUSIC Jacobi 상한이 큽니다 (${workload.musicJacobiWorstCasePairUpdates.toLocaleString()} / hard cap ${workload.musicJacobiPairUpdateLimit.toLocaleString()} pair updates). 결과를 단순화하지 않으므로 채널 수나 명시 carrier band 수를 줄이지 않으면 오래 걸릴 수 있습니다.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
|
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
|
||||||
if (
|
if (
|
||||||
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
|
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
|
||||||
@@ -1355,6 +1480,35 @@ async function runSimulation() {
|
|||||||
assetRuntime.transferListForAudio(assetBuffers),
|
assetRuntime.transferListForAudio(assetBuffers),
|
||||||
);
|
);
|
||||||
if (activity.canceled || state.active !== activity) throw createAbortError();
|
if (activity.canceled || state.active !== activity) throw createAbortError();
|
||||||
|
if (run.status === "unavailable") {
|
||||||
|
const inputHash = await inputHashPromise;
|
||||||
|
state.lastRun = {
|
||||||
|
snapshot,
|
||||||
|
run,
|
||||||
|
evaluations: new Map(),
|
||||||
|
exportedEvaluations: new Map(),
|
||||||
|
inputHash,
|
||||||
|
resultDocument: null,
|
||||||
|
stale: state.revision !== activity.revision,
|
||||||
|
imageElement: assetRuntime.imageElement,
|
||||||
|
visibleBandIds: new Set(state.visibleBandIds),
|
||||||
|
};
|
||||||
|
state.resultDirty = false;
|
||||||
|
document.documentElement.dataset.runState =
|
||||||
|
"unavailable";
|
||||||
|
document.documentElement.dataset.runBackend =
|
||||||
|
run.backend;
|
||||||
|
document.documentElement.dataset.runAlgorithm =
|
||||||
|
"music";
|
||||||
|
document.documentElement.dataset.resultBandCount =
|
||||||
|
"0";
|
||||||
|
setRunStatus(
|
||||||
|
`MUSIC 위치 없음 · ${run.unavailable?.reason ?? "unavailable"} · 임의 map을 만들지 않았습니다.`,
|
||||||
|
);
|
||||||
|
renderResult();
|
||||||
|
renderSceneVisualization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRunStatus("Peak 검출과 정량 지표를 계산하고 있습니다…");
|
setRunStatus("Peak 검출과 정량 지표를 계산하고 있습니다…");
|
||||||
const evaluations = new Map();
|
const evaluations = new Map();
|
||||||
const exportedEvaluations = new Map();
|
const exportedEvaluations = new Map();
|
||||||
@@ -1368,6 +1522,7 @@ async function runSimulation() {
|
|||||||
band.rawScale ??
|
band.rawScale ??
|
||||||
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
|
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
|
||||||
"power",
|
"power",
|
||||||
|
objectiveDirection: band.objectiveDirection,
|
||||||
scan: snapshot.experiment.scan,
|
scan: snapshot.experiment.scan,
|
||||||
sources: sourcesForObservedBand(
|
sources: sourcesForObservedBand(
|
||||||
snapshot.scene,
|
snapshot.scene,
|
||||||
@@ -1509,6 +1664,7 @@ function evaluateLiveFrames(snapshot, plan, frames) {
|
|||||||
frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm,
|
frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm,
|
||||||
)?.rawScale ??
|
)?.rawScale ??
|
||||||
"power",
|
"power",
|
||||||
|
objectiveDirection: band.objectiveDirection,
|
||||||
scan: snapshot.experiment.scan,
|
scan: snapshot.experiment.scan,
|
||||||
sources: sourcesForObservedBand(
|
sources: sourcesForObservedBand(
|
||||||
frameScene,
|
frameScene,
|
||||||
@@ -1625,9 +1781,17 @@ async function prepareLiveSimulation() {
|
|||||||
playbackStartedAt: null,
|
playbackStartedAt: null,
|
||||||
playbackStartedTime: 0,
|
playbackStartedTime: 0,
|
||||||
};
|
};
|
||||||
|
const unavailableFrameCount = timeline.frames.filter(
|
||||||
|
(frame) => frame.run.status === "unavailable",
|
||||||
|
).length;
|
||||||
setRunStatus(
|
setRunStatus(
|
||||||
`라이브 준비 완료 · ${timeline.frames.length} maps · ` +
|
`라이브 준비 완료 · ${timeline.frames.length} maps · ` +
|
||||||
`${formatNumber(timeline.timingMs.total, 0)} ms`,
|
`${formatNumber(timeline.timingMs.total, 0)} ms` +
|
||||||
|
(
|
||||||
|
unavailableFrameCount > 0
|
||||||
|
? ` · 위치 없음 ${unavailableFrameCount} frames`
|
||||||
|
: ""
|
||||||
|
),
|
||||||
);
|
);
|
||||||
renderLiveControls();
|
renderLiveControls();
|
||||||
renderSceneVisualization();
|
renderSceneVisualization();
|
||||||
@@ -1739,6 +1903,82 @@ async function loadMovingExample() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMusicExample() {
|
||||||
|
if (state.active) return;
|
||||||
|
const bandCount =
|
||||||
|
state.experiment?.frequencyBands?.length ?? 0;
|
||||||
|
if (
|
||||||
|
(bandCount > 1 || hasUnsavedWork()) &&
|
||||||
|
!window.confirm(
|
||||||
|
"MUSIC 빠른 예제는 현재 Scene·Array·Experiment 전체와 " +
|
||||||
|
String(bandCount) +
|
||||||
|
"개 분석 대역을 교체합니다. 계속할까요?",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setRunStatus(
|
||||||
|
"MUSIC 예제를 열지 않았습니다. 현재 입력을 유지합니다.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
beginInputOperation();
|
||||||
|
try {
|
||||||
|
setRunStatus(
|
||||||
|
"12채널 표준 MUSIC 예제를 불러오고 있습니다…",
|
||||||
|
);
|
||||||
|
const [arrayDefinition, scene, experiment] =
|
||||||
|
await Promise.all([
|
||||||
|
loadJson(MUSIC_EXAMPLE_URLS.arrayDefinition),
|
||||||
|
loadJson(MUSIC_EXAMPLE_URLS.scene),
|
||||||
|
loadJson(MUSIC_EXAMPLE_URLS.experiment),
|
||||||
|
]);
|
||||||
|
const [arrayIssues, sceneIssues] =
|
||||||
|
await Promise.all([
|
||||||
|
validateArrayDefinition(arrayDefinition),
|
||||||
|
validateScene(scene),
|
||||||
|
]);
|
||||||
|
const experimentIssues = await validateExperiment(
|
||||||
|
experiment,
|
||||||
|
{ arrayDefinition, scene },
|
||||||
|
);
|
||||||
|
const issues = [
|
||||||
|
...arrayIssues,
|
||||||
|
...sceneIssues,
|
||||||
|
...experimentIssues,
|
||||||
|
];
|
||||||
|
if (issues.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
issues.slice(0, 4).map(formatIssue).join(" · "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.arrayDefinition = arrayDefinition;
|
||||||
|
state.scene = scene;
|
||||||
|
state.experiment =
|
||||||
|
applyExperimentDefaults(experiment);
|
||||||
|
state.sceneDirty = false;
|
||||||
|
state.experimentDirty = false;
|
||||||
|
state.visibleBandIds = new Set(
|
||||||
|
experiment.frequencyBands.map((band) => band.id),
|
||||||
|
);
|
||||||
|
assetRuntime.reset();
|
||||||
|
markChanged({
|
||||||
|
syncControls: true,
|
||||||
|
renderBands: true,
|
||||||
|
dirty: "none",
|
||||||
|
});
|
||||||
|
renderAssets();
|
||||||
|
setRunStatus(
|
||||||
|
"12채널·1.125 kHz·Fixed K=1 MUSIC 예제를 열었습니다. Snapshot 실행을 누르세요.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setTransientError(
|
||||||
|
error,
|
||||||
|
"MUSIC 예제 불러오기 실패",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
endInputOperation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cancelSimulation() {
|
function cancelSimulation() {
|
||||||
const activity = state.active;
|
const activity = state.active;
|
||||||
if (!activity || activity.canceled) return;
|
if (!activity || activity.canceled) return;
|
||||||
@@ -1772,7 +2012,6 @@ async function loadArrayPreset(presetId) {
|
|||||||
if (generation !== state.importGenerations.array) return;
|
if (generation !== state.importGenerations.array) return;
|
||||||
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
|
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
|
||||||
state.arrayDefinition = value;
|
state.arrayDefinition = value;
|
||||||
state.experiment.arrayId = value.id;
|
|
||||||
markChanged({ syncControls: true });
|
markChanged({ syncControls: true });
|
||||||
setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`);
|
setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1792,25 +2031,17 @@ async function importDocument(kind, input) {
|
|||||||
try {
|
try {
|
||||||
const value = await readJsonFile(file);
|
const value = await readJsonFile(file);
|
||||||
if (generation !== state.importGenerations[kind]) return;
|
if (generation !== state.importGenerations[kind]) return;
|
||||||
let importedExperimentReferences = null;
|
|
||||||
let issues;
|
let issues;
|
||||||
if (kind === "array") {
|
if (kind === "array") {
|
||||||
issues = await validateArrayDefinition(value);
|
issues = await validateArrayDefinition(value);
|
||||||
} else if (kind === "scene") {
|
} else if (kind === "scene") {
|
||||||
issues = await validateScene(value);
|
issues = await validateScene(value);
|
||||||
} else {
|
} else {
|
||||||
// Validate the imported document independently before changing any
|
// arrayId/sceneId are retained as document provenance, not treated as
|
||||||
// references. Then bind it to the open workspace and run the relationship
|
// foreign keys. The currently open Array and Scene are the run inputs.
|
||||||
// checks as well, so import convenience does not weaken the contract.
|
|
||||||
issues = await validateExperiment(value);
|
issues = await validateExperiment(value);
|
||||||
if (generation !== state.importGenerations[kind]) return;
|
if (generation !== state.importGenerations[kind]) return;
|
||||||
if (issues.length === 0) {
|
if (issues.length === 0) {
|
||||||
importedExperimentReferences = {
|
|
||||||
sceneId: value.sceneId,
|
|
||||||
arrayId: value.arrayId,
|
|
||||||
};
|
|
||||||
value.sceneId = state.scene.id;
|
|
||||||
value.arrayId = state.arrayDefinition.id;
|
|
||||||
issues = await validateExperiment(value, {
|
issues = await validateExperiment(value, {
|
||||||
arrayDefinition: state.arrayDefinition,
|
arrayDefinition: state.arrayDefinition,
|
||||||
scene: state.scene,
|
scene: state.scene,
|
||||||
@@ -1821,10 +2052,8 @@ async function importDocument(kind, input) {
|
|||||||
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
|
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
|
||||||
if (kind === "array") {
|
if (kind === "array") {
|
||||||
state.arrayDefinition = value;
|
state.arrayDefinition = value;
|
||||||
state.experiment.arrayId = value.id;
|
|
||||||
} else if (kind === "scene") {
|
} else if (kind === "scene") {
|
||||||
state.scene = value;
|
state.scene = value;
|
||||||
state.experiment.sceneId = value.id;
|
|
||||||
alignExperimentToScene(state.experiment, value);
|
alignExperimentToScene(state.experiment, value);
|
||||||
state.sceneDirty = false;
|
state.sceneDirty = false;
|
||||||
assetRuntime.reset();
|
assetRuntime.reset();
|
||||||
@@ -1844,16 +2073,9 @@ async function importDocument(kind, input) {
|
|||||||
: kind === "experiment"
|
: kind === "experiment"
|
||||||
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개`
|
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개`
|
||||||
: "";
|
: "";
|
||||||
const referencesChanged = kind === "experiment" && (
|
setRunStatus(
|
||||||
importedExperimentReferences?.sceneId !== state.scene.id ||
|
`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}`,
|
||||||
importedExperimentReferences?.arrayId !== state.arrayDefinition.id
|
|
||||||
);
|
);
|
||||||
const referenceStatus = kind === "experiment"
|
|
||||||
? referencesChanged
|
|
||||||
? ` · 현재 Scene ${state.scene.id} · Array ${state.arrayDefinition.id}로 재연결`
|
|
||||||
: " · 현재 Scene · Array 연결 확인"
|
|
||||||
: "";
|
|
||||||
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}${referenceStatus}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTransientError(error, `${file.name} 불러오기 실패`);
|
setTransientError(error, `${file.name} 불러오기 실패`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1906,6 +2128,11 @@ function installDocumentInputListeners() {
|
|||||||
elements.algorithm.addEventListener("change", () => {
|
elements.algorithm.addEventListener("change", () => {
|
||||||
const algorithm = elements.algorithm.value;
|
const algorithm = elements.algorithm.value;
|
||||||
state.experiment.beamforming.algorithm = algorithm;
|
state.experiment.beamforming.algorithm = algorithm;
|
||||||
|
if (algorithm === "music") {
|
||||||
|
state.experiment.beamforming.musicSourceCountMethod ??= "fixed";
|
||||||
|
state.experiment.beamforming.musicSourceCount ??= 1;
|
||||||
|
state.experiment.beamforming.normalization = "none";
|
||||||
|
}
|
||||||
const backendReset =
|
const backendReset =
|
||||||
state.experiment.beamforming.backend === "webgpu" &&
|
state.experiment.beamforming.backend === "webgpu" &&
|
||||||
!localizationAlgorithmSupportsBackend(algorithm, "webgpu");
|
!localizationAlgorithmSupportsBackend(algorithm, "webgpu");
|
||||||
@@ -1915,6 +2142,20 @@ function installDocumentInputListeners() {
|
|||||||
setRunStatus(`${localizationAlgorithmLabel(algorithm)}는 CPU 전용이므로 Backend를 Auto (CPU)로 바꿨습니다.`);
|
setRunStatus(`${localizationAlgorithmLabel(algorithm)}는 CPU 전용이므로 Backend를 Auto (CPU)로 바꿨습니다.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
elements.musicSourceCountMethod.addEventListener("change", () => {
|
||||||
|
const method = elements.musicSourceCountMethod.value;
|
||||||
|
state.experiment.beamforming.musicSourceCountMethod = method;
|
||||||
|
if (method === "fixed") {
|
||||||
|
state.experiment.beamforming.musicSourceCount ??= 1;
|
||||||
|
}
|
||||||
|
markChanged({ syncControls: true });
|
||||||
|
});
|
||||||
|
elements.musicSourceCount.addEventListener("change", () => {
|
||||||
|
const value = readFiniteInput(elements.musicSourceCount);
|
||||||
|
if (value === null) return syncControls();
|
||||||
|
state.experiment.beamforming.musicSourceCount = Math.trunc(value);
|
||||||
|
markChanged({ syncControls: true });
|
||||||
|
});
|
||||||
elements.backend.addEventListener("change", () => {
|
elements.backend.addEventListener("change", () => {
|
||||||
if (
|
if (
|
||||||
elements.backend.value === "webgpu" &&
|
elements.backend.value === "webgpu" &&
|
||||||
@@ -2045,6 +2286,10 @@ function installActionListeners() {
|
|||||||
elements.runButton.addEventListener("click", runSimulation);
|
elements.runButton.addEventListener("click", runSimulation);
|
||||||
elements.cancelButton.addEventListener("click", cancelSimulation);
|
elements.cancelButton.addEventListener("click", cancelSimulation);
|
||||||
elements.loadMovingExampleButton.addEventListener("click", loadMovingExample);
|
elements.loadMovingExampleButton.addEventListener("click", loadMovingExample);
|
||||||
|
elements.loadMusicExampleButton.addEventListener(
|
||||||
|
"click",
|
||||||
|
loadMusicExample,
|
||||||
|
);
|
||||||
elements.prepareLiveButton.addEventListener("click", prepareLiveSimulation);
|
elements.prepareLiveButton.addEventListener("click", prepareLiveSimulation);
|
||||||
elements.livePlayButton.addEventListener("click", toggleLivePlayback);
|
elements.livePlayButton.addEventListener("click", toggleLivePlayback);
|
||||||
elements.liveTime.addEventListener("input", () => {
|
elements.liveTime.addEventListener("input", () => {
|
||||||
@@ -2125,7 +2370,7 @@ function installActionListeners() {
|
|||||||
setRunStatus("Experiment JSON을 저장했습니다.");
|
setRunStatus("Experiment JSON을 저장했습니다.");
|
||||||
});
|
});
|
||||||
elements.exportResultButton.addEventListener("click", () => {
|
elements.exportResultButton.addEventListener("click", () => {
|
||||||
if (!state.lastRun) return;
|
if (!state.lastRun?.resultDocument) return;
|
||||||
downloadJson(
|
downloadJson(
|
||||||
state.lastRun.resultDocument,
|
state.lastRun.resultDocument,
|
||||||
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
|
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
|
||||||
|
|||||||
+174
-15
@@ -1,5 +1,17 @@
|
|||||||
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
import {
|
||||||
import { observeFrequencyBand } from "/packages/acoustics-core/sampling.js";
|
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,
|
||||||
@@ -35,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 });
|
||||||
}
|
}
|
||||||
@@ -51,19 +70,39 @@ 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.stft.fftSize,
|
experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
||||||
experiment.stft.hopSize,
|
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.hopSize,
|
||||||
|
);
|
||||||
const bandBins = experiment.frequencyBands.map((band) =>
|
const bandBins = experiment.frequencyBands.map((band) =>
|
||||||
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
|
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
|
||||||
);
|
);
|
||||||
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;
|
||||||
@@ -72,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,
|
||||||
@@ -87,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,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 ??
|
||||||
@@ -291,6 +406,18 @@ function frequencyObservationForResult(scene, experiment, band, algorithm) {
|
|||||||
scene.sampleRate,
|
scene.sampleRate,
|
||||||
experiment.stft.fftSize,
|
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 {
|
return {
|
||||||
model: observation.model,
|
model: observation.model,
|
||||||
sampleRate: observation.sampleRate,
|
sampleRate: observation.sampleRate,
|
||||||
@@ -306,9 +433,28 @@ function frequencyObservationForResult(scene, experiment, band, algorithm) {
|
|||||||
localizationModel:
|
localizationModel:
|
||||||
algorithm === "gcc-phat"
|
algorithm === "gcc-phat"
|
||||||
? "band-limited-gcc-phat-geometric-lag"
|
? "band-limited-gcc-phat-geometric-lag"
|
||||||
|
: music
|
||||||
|
? "near-field-music-at-configured-analog-frequency"
|
||||||
: "spherical-steering-at-positive-adc-bin-frequency",
|
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||||
originalCarrierUsed: observation.originalCarrierUsed,
|
originalCarrierUsed: music,
|
||||||
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
|
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,
|
foldBoundaryCount: observation.foldBoundaryCount,
|
||||||
continuousManyToOne: observation.continuousManyToOne,
|
continuousManyToOne: observation.continuousManyToOne,
|
||||||
gridManyToOne: observation.gridManyToOne,
|
gridManyToOne: observation.gridManyToOne,
|
||||||
@@ -387,21 +533,31 @@ 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),
|
||||||
@@ -409,6 +565,9 @@ export function createResultDocument(options) {
|
|||||||
};
|
};
|
||||||
const frequencyObservation = frequencyObservations.get(band.id);
|
const frequencyObservation = frequencyObservations.get(band.id);
|
||||||
if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation;
|
if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation;
|
||||||
|
if (algorithm === "music" && band.diagnostics) {
|
||||||
|
bandResult.musicDiagnostics = sortForCanonicalJson(band.diagnostics);
|
||||||
|
}
|
||||||
if (evaluation.evaluation) {
|
if (evaluation.evaluation) {
|
||||||
bandResult.evaluation = {
|
bandResult.evaluation = {
|
||||||
...evaluation.evaluation,
|
...evaluation.evaluation,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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";
|
||||||
|
|
||||||
function post(type, jobId, payload = {}, transfer = []) {
|
function post(type, jobId, payload = {}, transfer = []) {
|
||||||
self.postMessage({ type, jobId, ...payload }, transfer);
|
self.postMessage({ type, jobId, ...payload }, transfer);
|
||||||
@@ -35,8 +38,47 @@ function signalStats(synthesis) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +96,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({
|
||||||
@@ -105,7 +149,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,
|
||||||
@@ -234,7 +278,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -451,7 +470,7 @@ 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 { 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); }
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
+61
-8
@@ -227,6 +227,54 @@ 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
|
## 샘플링, Nyquist와 alias
|
||||||
|
|
||||||
Scene의 `sampleRate`는 모든 마이크가 동시에 동작하는 이상적 real-valued ADC의 sampling rate다.
|
Scene의 `sampleRate`는 모든 마이크가 동시에 동작하는 이상적 real-valued ADC의 sampling rate다.
|
||||||
@@ -269,16 +317,19 @@ alias-equivalent band ID를 보존한다. 같은 `adcBinSetKey`를 가진 band
|
|||||||
`-Fs/2` endpoint는 두 관측이나 두 FFT bin이 아니라 같은 phase-degenerate Nyquist
|
`-Fs/2` endpoint는 두 관측이나 두 FFT bin이 아니라 같은 phase-degenerate Nyquist
|
||||||
sample을 양쪽 zone에서 표현한 것이다.
|
sample을 양쪽 zone에서 표현한 것이다.
|
||||||
|
|
||||||
같은 이유로 위치 추정기의 선택적 microphone-response 역보정도 관측 ADC bin에서 평가한다. Forward
|
같은 이유로 DAS·DAS-PHAT·GCC-PHAT의 선택적 microphone-response 역보정은 관측 ADC bin에서
|
||||||
tone 합성은 원 analog 주파수의 응답을 사용하므로, Nyquist 위 carrier를 알고 보정하는 별도
|
평가한다. Forward tone 합성은 원 analog 주파수의 응답을 사용하므로 이 세 알고리즘은 Nyquist 위
|
||||||
alias-aware 알고리즘처럼 동작하지 않는다. 이 차이 역시 감추지 않는 현재 알고리즘의 결과다.
|
carrier를 아는 alias-aware 알고리즘처럼 동작하지 않는다. MUSIC만 예외로, 사용자가 band에
|
||||||
|
명시한 단일 아날로그 FFT-grid 가설에서 response와 signed complex manifold를 계산한다. 이는
|
||||||
|
Scene truth를 읽거나 가능한 alias order를 자동 탐색하는 것이 아니다.
|
||||||
|
|
||||||
서로 다른 analog tone이 같은 ADC bin으로 접히면 **단일 channel의 시간 sample만으로는**
|
서로 다른 analog tone이 같은 ADC bin으로 접히면 **단일 channel의 시간 sample만으로는**
|
||||||
원 주파수를 구별할 수 없다. 그러나 이 합성기는 원 analog 파장의 센서 간 위상을 보존하므로,
|
원 주파수를 구별할 수 없다. 그러나 이 합성기는 원 analog 파장의 센서 간 위상을 보존하므로,
|
||||||
충분히 촘촘하고 보정된 다채널 array에는 carrier order를 판별할 공간 위상 증거가 남을 수 있다.
|
충분히 촘촘하고 보정된 다채널 array에는 carrier order를 판별할 공간 위상 증거가 남을 수 있다.
|
||||||
현재 DAS·DAS-PHAT·GCC-PHAT는 carrier hypothesis를 열거하는 알고리즘이 아니므로 이를 복원하지
|
현재 DAS·DAS-PHAT·GCC-PHAT는 carrier hypothesis를 열거하는 알고리즘이 아니므로 이를 복원하지
|
||||||
않고 Result에 `aliasOrderResolution: not-attempted`로 기록한다. 이 기능을 추가하려면 검증된
|
않고 Result에 `aliasOrderResolution: not-attempted`로 기록한다. MUSIC은 band 하나에 사용자가
|
||||||
array-processing 방법과 별도 alias-order 평가가 필요하며, 임의 규칙으로 peak를 고르지 않는다.
|
고른 가설 하나만 계산해 `configured-single-hypothesis`로 기록하며, 후보 중 승자를 고르지 않는다.
|
||||||
|
여러 order를 비교하려면 각각 독립 band로 명시해야 한다.
|
||||||
audio asset과 noise는 이미 sampling된 baseband 신호로 취급하므로 파일에 없는 Nyquist 위 analog
|
audio asset과 noise는 이미 sampling된 baseband 신호로 취급하므로 파일에 없는 Nyquist 위 analog
|
||||||
spectrum을 만들어 내지 않는다.
|
spectrum을 만들어 내지 않는다.
|
||||||
|
|
||||||
@@ -361,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도
|
||||||
|
확률분포나 신뢰구간으로 바꾸지 않는다.
|
||||||
|
|
||||||
## 정확도 범위와 제외 사항
|
## 정확도 범위와 제외 사항
|
||||||
|
|
||||||
|
|||||||
+38
-9
@@ -29,7 +29,7 @@ snap과 zoom은 편집기 상태이며 `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 +37,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 +59,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,7 +136,7 @@ sidelobe false positive도 늘 수 있다.
|
|||||||
|
|
||||||
## Result 평가 정보
|
## Result 평가 정보
|
||||||
|
|
||||||
Result 1.1은 최상위 필수 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를
|
Result 1.1은 최상위 필수 `algorithm`에 실제 실행한 `das`, `das-phat`, `gcc-phat` 또는 `music`을
|
||||||
기록하며 Experiment의 선택값과 같아야 한다. `backend`는 요청값 `auto`가 아니라 실제 사용한
|
기록하며 Experiment의 선택값과 같아야 한다. `backend`는 요청값 `auto`가 아니라 실제 사용한
|
||||||
`cpu` 또는 `webgpu`다.
|
`cpu` 또는 `webgpu`다.
|
||||||
|
|
||||||
@@ -180,8 +189,10 @@ threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic r
|
|||||||
경계의 `+Fs/2`와 `-Fs/2`는 같은 실수 sample이며, point provenance는 `+Fs/2`로
|
경계의 `+Fs/2`와 `-Fs/2`는 같은 실수 sample이며, point provenance는 `+Fs/2`로
|
||||||
canonicalize하고 fold segment endpoint는 zone 진행 방향에 따라 둘 중 하나를 기록한다.
|
canonicalize하고 fold segment endpoint는 zone 진행 방향에 따라 둘 중 하나를 기록한다.
|
||||||
`localizationInputModel`은 one-sided real FFT bin만 사용함을 밝히며, `localizationModel`은
|
`localizationInputModel`은 one-sided real FFT bin만 사용함을 밝히며, `localizationModel`은
|
||||||
DAS 계열의 구면 steering과 GCC-PHAT의 기하 lag를 구분한다. `originalCarrierUsed`는 항상
|
DAS 계열의 구면 steering, GCC-PHAT의 기하 lag와 MUSIC의 configured-analog-frequency
|
||||||
`false`다. Nyquist 위 band의 `aliasOrderResolution`은 현재 `not-attempted`다.
|
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 집합을 공유하므로 독립
|
`equivalentBandIds`가 있으면 그 band들은 같은 ADC bin 집합을 공유하므로 독립
|
||||||
관측으로 세면 안 된다.
|
관측으로 세면 안 된다.
|
||||||
|
|
||||||
@@ -206,8 +217,26 @@ Array·Scene·Experiment 계약 버전은 `1.0.0`, Result 계약 버전은 `1.1.
|
|||||||
`packages/contracts/schema-validator.js`로 저장소의
|
`packages/contracts/schema-validator.js`로 저장소의
|
||||||
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
|
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
|
||||||
|
|
||||||
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한 뒤 `sceneId`와
|
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한다. `sceneId`와
|
||||||
`arrayId`를 현재 열린 Scene·Array의 id로 연결하고, 그 context에서 관계 검증을 다시 수행한다.
|
`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도 아래 관계를
|
이 가져오기 편의 동작은 계약을 완화하지 않는다. 내보내기와 실행에 쓰는 Experiment도 아래 관계를
|
||||||
모두 만족해야 한다.
|
모두 만족해야 한다.
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ 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·연산 상한
|
||||||
- [ ] 라이선스 검토를 통과한 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 후처리
|
||||||
@@ -227,7 +228,10 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
|
|||||||
- 실제 AC와 simulation gap: gain·phase·주파수 응답, ideal real ADC sampling/alias와 희소 실내
|
- 실제 AC와 simulation gap: gain·phase·주파수 응답, ideal real ADC sampling/alias와 희소 실내
|
||||||
환경을 비교 조건으로 제공하되, 위치 공차·self-noise·ADC quantization/nonlinearity와
|
환경을 비교 조건으로 제공하되, 위치 공차·self-noise·ADC quantization/nonlinearity와
|
||||||
channel clock jitter/drift는 실측 adapter와 현장 검증으로 다룬다.
|
channel clock jitter/drift는 실측 adapter와 현장 검증으로 다룬다.
|
||||||
- post-processing이 기본 결과를 가림: 알고리즘별 raw power/score map과 후처리 map을 별도로 보존한다.
|
- 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를 보존한다.
|
||||||
|
|
||||||
## 10. 구현 결과와 다음 백로그
|
## 10. 구현 결과와 다음 백로그
|
||||||
|
|
||||||
@@ -237,6 +241,7 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
|
|||||||
- [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] 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 재연결 왕복 테스트
|
||||||
|
|||||||
+4
-3
@@ -110,8 +110,9 @@
|
|||||||
<li>
|
<li>
|
||||||
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
|
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
|
||||||
가져오는 것을 권장합니다. Experiment는 어느 시점에 열어도 분석 대역을 보존하고
|
가져오는 것을 권장합니다. Experiment는 어느 시점에 열어도 분석 대역을 보존하고
|
||||||
<code>sceneId</code>와 <code>arrayId</code>를 현재 열린 입력에 자동으로 연결합니다.
|
<code>sceneId</code>와 <code>arrayId</code> provenance label도 덮어쓰지 않습니다.
|
||||||
재연결 뒤 관계 검증도 다시 수행됩니다. Array Builder에서 저장한 파일이 있다면 Array
|
실행에는 현재 열린 Scene·Array를 사용하고 Result에 실제 입력 ID와 hash를 기록합니다.
|
||||||
|
Array Builder에서 저장한 파일이 있다면 Array
|
||||||
단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
|
단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -269,7 +270,7 @@
|
|||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Experiment를 가져왔는데 검증 오류가 남습니다.</summary>
|
<summary>Experiment를 가져왔는데 검증 오류가 남습니다.</summary>
|
||||||
<p>참조 ID는 현재 Scene·Array에 자동으로 연결됩니다. Nyquist 위 tone·band는 ideal ADC alias로 실행되며 원/관측 주파수가 함께 표시됩니다. 실행할 수 없다면 검증 카드에서 scan 범위, asset처럼 재연결 뒤 확인된 실제 입력 오류를 확인하세요.</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>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -174,7 +183,7 @@ const plan = createLocalizationPlan({
|
|||||||
- 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,
|
||||||
@@ -186,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 경계에서
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Tone과 band의 검증 범위는 0–1 MHz다. 이는 Nyquist cutoff가 아니
|
|||||||
|
|
||||||
## 위치 추정 알고리즘
|
## 위치 추정 알고리즘
|
||||||
|
|
||||||
`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 |
|
||||||
@@ -37,6 +37,7 @@ Tone과 band의 검증 범위는 0–1 MHz다. 이는 Nyquist cutoff가 아니
|
|||||||
| `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`을 사용합니다.
|
||||||
@@ -49,7 +50,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +76,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된 동기식 마이크 채널을 직접
|
||||||
@@ -83,8 +139,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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -58,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.");
|
||||||
@@ -85,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();
|
||||||
@@ -742,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.");
|
||||||
@@ -751,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,
|
||||||
@@ -789,19 +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 candidates = detectionCandidates(
|
const globalMeritPeak = localMeritPeaks[0] ?? null;
|
||||||
values,
|
const thresholdValue = (globalMeritPeak?.value ?? 0) * detectionThresholdRatio;
|
||||||
|
const meritCandidates = detectionCandidates(
|
||||||
|
merit.values,
|
||||||
scan,
|
scan,
|
||||||
localPeaks,
|
localMeritPeaks,
|
||||||
thresholdValue,
|
thresholdValue,
|
||||||
detectionMethod,
|
detectionMethod,
|
||||||
);
|
);
|
||||||
const detections = selectDetections(candidates, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
|
const selectedMeritDetections = selectDetections(
|
||||||
(detection) => ({
|
meritCandidates,
|
||||||
...detection,
|
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,
|
||||||
@@ -809,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,
|
||||||
@@ -833,6 +965,10 @@ export function evaluateLocalizationBand(options) {
|
|||||||
matchToleranceM,
|
matchToleranceM,
|
||||||
detectionThresholdRatio,
|
detectionThresholdRatio,
|
||||||
detectionThresholdValue: thresholdValue,
|
detectionThresholdValue: thresholdValue,
|
||||||
|
detectionThresholdDomain:
|
||||||
|
objectiveDirection === "minimum"
|
||||||
|
? "normalized-music-pseudospectrum"
|
||||||
|
: "raw-map-value",
|
||||||
detectionMethod,
|
detectionMethod,
|
||||||
minimumPeakDistanceM,
|
minimumPeakDistanceM,
|
||||||
sidelobeExclusionRadiusM,
|
sidelobeExclusionRadiusM,
|
||||||
@@ -840,23 +976,14 @@ export function evaluateLocalizationBand(options) {
|
|||||||
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,
|
||||||
detectionCandidateCount: candidates.length,
|
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,
|
||||||
@@ -870,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}`);
|
||||||
}
|
}
|
||||||
@@ -927,13 +1056,16 @@ 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",
|
detectionMethod: evaluation.parameters.detectionMethod ?? "local-max",
|
||||||
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
|
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
|
||||||
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
|
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ 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";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const ALGORITHM_DEFINITIONS = Object.freeze([
|
|||||||
definition("das", "DAS", "power", "none", ["cpu", "webgpu"]),
|
definition("das", "DAS", "power", "none", ["cpu", "webgpu"]),
|
||||||
definition("das-phat", "DAS-PHAT", "score", "phat", ["cpu", "webgpu"]),
|
definition("das-phat", "DAS-PHAT", "score", "phat", ["cpu", "webgpu"]),
|
||||||
definition("gcc-phat", "GCC-PHAT", "score", "phat", ["cpu"]),
|
definition("gcc-phat", "GCC-PHAT", "score", "phat", ["cpu"]),
|
||||||
|
definition("music", "MUSIC", "noise-projection", "subspace", ["cpu"]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALGORITHM_BY_ID = new Map(
|
const ALGORITHM_BY_ID = new Map(
|
||||||
@@ -43,6 +44,10 @@ export function isDasAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
|||||||
return id === "das" || id === "das-phat";
|
return id === "das" || id === "das-phat";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMusicAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||||
|
return id === "music";
|
||||||
|
}
|
||||||
|
|
||||||
export function localizationAlgorithmLabel(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
export function localizationAlgorithmLabel(id = DEFAULT_LOCALIZATION_ALGORITHM) {
|
||||||
return localizationAlgorithmDefinition(id)?.label ?? String(id);
|
return localizationAlgorithmDefinition(id)?.label ?? String(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
createGccPhatAnalysisPlan,
|
createGccPhatAnalysisPlan,
|
||||||
estimateGccPhatWorkload,
|
estimateGccPhatWorkload,
|
||||||
} from "./gcc-phat.js";
|
} from "./gcc-phat.js";
|
||||||
|
import {
|
||||||
|
computeMusicMap,
|
||||||
|
createMusicAnalysisPlan,
|
||||||
|
estimateMusicWorkload,
|
||||||
|
} from "./music.js";
|
||||||
import {
|
import {
|
||||||
isDasAlgorithm,
|
isDasAlgorithm,
|
||||||
requireLocalizationAlgorithmDefinition,
|
requireLocalizationAlgorithmDefinition,
|
||||||
@@ -27,6 +32,7 @@ export function estimateLocalizationWorkload(options) {
|
|||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(options);
|
if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(options);
|
||||||
if (algorithm.id === "gcc-phat") return estimateGccPhatWorkload(options);
|
if (algorithm.id === "gcc-phat") return estimateGccPhatWorkload(options);
|
||||||
|
if (algorithm.id === "music") return estimateMusicWorkload(options);
|
||||||
throw new RangeError("No workload estimator is registered for " + algorithm.id + ".");
|
throw new RangeError("No workload estimator is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ export function createLocalizationAnalysisPlan(options) {
|
|||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(options);
|
if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(options);
|
||||||
if (algorithm.id === "gcc-phat") return createGccPhatAnalysisPlan(options);
|
if (algorithm.id === "gcc-phat") return createGccPhatAnalysisPlan(options);
|
||||||
|
if (algorithm.id === "music") return createMusicAnalysisPlan(options);
|
||||||
throw new RangeError("No analysis-plan factory is registered for " + algorithm.id + ".");
|
throw new RangeError("No analysis-plan factory is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,5 +56,6 @@ export async function computeLocalizationMap(options) {
|
|||||||
const algorithm = requestedAlgorithm(options);
|
const algorithm = requestedAlgorithm(options);
|
||||||
if (isDasAlgorithm(algorithm.id)) return computeDasMap(options);
|
if (isDasAlgorithm(algorithm.id)) return computeDasMap(options);
|
||||||
if (algorithm.id === "gcc-phat") return computeGccPhatMap(options);
|
if (algorithm.id === "gcc-phat") return computeGccPhatMap(options);
|
||||||
|
if (algorithm.id === "music") return computeMusicMap(options);
|
||||||
throw new RangeError("No CPU localization implementation is registered for " + algorithm.id + ".");
|
throw new RangeError("No CPU localization implementation is registered for " + algorithm.id + ".");
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -224,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) {
|
||||||
@@ -248,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;
|
||||||
}
|
}
|
||||||
@@ -291,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 ??
|
||||||
@@ -307,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 =
|
||||||
@@ -326,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({
|
||||||
@@ -350,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 } : {}),
|
||||||
@@ -390,18 +454,49 @@ 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;
|
||||||
...plan.computeOptions,
|
try {
|
||||||
...(options.computeOptions ?? {}),
|
localization = await computeLocalizationMap({
|
||||||
channels: options.channels,
|
...plan.computeOptions,
|
||||||
sampleRate: plan.sampleRate,
|
...(options.computeOptions ?? {}),
|
||||||
arrayDefinition: plan.arrayDefinition,
|
channels: options.channels,
|
||||||
scene: plan.scene,
|
sampleRate: plan.sampleRate,
|
||||||
experiment: plan.experiment,
|
arrayDefinition: plan.arrayDefinition,
|
||||||
analysisPlan: plan.analysisPlan,
|
scene: plan.scene,
|
||||||
signal: options.signal,
|
experiment: plan.experiment,
|
||||||
onProgress: options.onProgress,
|
analysisPlan: plan.analysisPlan,
|
||||||
});
|
signal: options.signal,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -52,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,
|
||||||
@@ -61,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;
|
||||||
@@ -70,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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +140,7 @@ 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) {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||||
estimateSynthesisWorkingSet,
|
estimateSynthesisWorkingSet,
|
||||||
} from "../acoustics-core/propagation.js";
|
} from "../acoustics-core/propagation.js";
|
||||||
|
import { DEFAULT_MAX_COMPENSATION_DB } from "../acoustics-core/frequency-response.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
|
||||||
|
} from "../acoustics-core/music.js";
|
||||||
|
|
||||||
export const INPUT_SCHEMA_VERSION = "1.0.0";
|
export const INPUT_SCHEMA_VERSION = "1.0.0";
|
||||||
export const RESULT_SCHEMA_VERSION = "1.1.0";
|
export const RESULT_SCHEMA_VERSION = "1.1.0";
|
||||||
@@ -16,7 +20,7 @@ const MAX_SOURCES = 32;
|
|||||||
const MAX_GRID_SIDE = 512;
|
const MAX_GRID_SIDE = 512;
|
||||||
const MAX_FREQUENCY_RESPONSE_POINTS = 64;
|
const MAX_FREQUENCY_RESPONSE_POINTS = 64;
|
||||||
const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
|
const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
|
||||||
const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat"];
|
const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat", "music"];
|
||||||
|
|
||||||
/** @typedef {{ path: string, message: string }} ValidationIssue */
|
/** @typedef {{ path: string, message: string }} ValidationIssue */
|
||||||
|
|
||||||
@@ -416,13 +420,6 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
|
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
|
||||||
requireString(value.arrayId, "$.arrayId", issues);
|
requireString(value.arrayId, "$.arrayId", issues);
|
||||||
requireString(value.sceneId, "$.sceneId", issues);
|
requireString(value.sceneId, "$.sceneId", issues);
|
||||||
if (context.arrayDefinition && value.arrayId !== context.arrayDefinition.id) {
|
|
||||||
addIssue(issues, "$.arrayId", "불러온 배열 id와 다릅니다.");
|
|
||||||
}
|
|
||||||
if (context.scene && value.sceneId !== context.scene.id) {
|
|
||||||
addIssue(issues, "$.sceneId", "불러온 장면 id와 다릅니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requireRecord(value.scan, "$.scan", issues)) {
|
if (requireRecord(value.scan, "$.scan", issues)) {
|
||||||
requireFinite(value.scan.z, "$.scan.z", issues, { min: 0, exclusiveMin: true });
|
requireFinite(value.scan.z, "$.scan.z", issues, { min: 0, exclusiveMin: true });
|
||||||
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
|
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
|
||||||
@@ -482,7 +479,53 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
) {
|
) {
|
||||||
value.frequencyBands.forEach((band, index) => {
|
value.frequencyBands.forEach((band, index) => {
|
||||||
try {
|
try {
|
||||||
observeFrequencyBand(band, context.scene.sampleRate, value.stft.fftSize);
|
const observation = observeFrequencyBand(
|
||||||
|
band,
|
||||||
|
context.scene.sampleRate,
|
||||||
|
value.stft.fftSize,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
value.beamforming?.algorithm === "music" &&
|
||||||
|
observation.bins.length !== 1
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`$.frequencyBands[${index}]`,
|
||||||
|
"표준 narrowband MUSIC band는 관측 ADC FFT bin center를 정확히 하나만 선택해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value.beamforming?.algorithm === "music") {
|
||||||
|
const binWidthHz =
|
||||||
|
context.scene.sampleRate /
|
||||||
|
value.stft.fftSize;
|
||||||
|
const firstAnalogBin = Math.ceil(
|
||||||
|
band.minHz / binWidthHz,
|
||||||
|
);
|
||||||
|
const lastAnalogBin = Math.floor(
|
||||||
|
band.maxHz / binWidthHz,
|
||||||
|
);
|
||||||
|
if (firstAnalogBin !== lastAnalogBin) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`$.frequencyBands[${index}]`,
|
||||||
|
"Standard narrowband MUSIC band must contain exactly one configured analog FFT-grid hypothesis. Narrow the band or create separate bands; no alias-order selection or map fusion is applied.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.beamforming?.algorithm === "music" &&
|
||||||
|
observation.bins.length === 1 &&
|
||||||
|
(
|
||||||
|
observation.bins[0] === 0 ||
|
||||||
|
observation.bins[0] === value.stft.fftSize / 2
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`$.frequencyBands[${index}]`,
|
||||||
|
"DC와 Nyquist FFT bin은 실수 샘플의 quadrature 위상이 퇴화하므로 narrowband MUSIC에 사용할 수 없습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addIssue(
|
addIssue(
|
||||||
issues,
|
issues,
|
||||||
@@ -497,20 +540,20 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
addIssue(
|
addIssue(
|
||||||
issues,
|
issues,
|
||||||
"$.beamforming.algorithm",
|
"$.beamforming.algorithm",
|
||||||
"das, das-phat 또는 gcc-phat이어야 합니다.",
|
"das, das-phat, gcc-phat 또는 music이어야 합니다.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
|
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
|
||||||
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
|
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
value.beamforming.algorithm === "gcc-phat" &&
|
["gcc-phat", "music"].includes(value.beamforming.algorithm) &&
|
||||||
value.beamforming.backend === "webgpu"
|
value.beamforming.backend === "webgpu"
|
||||||
) {
|
) {
|
||||||
addIssue(
|
addIssue(
|
||||||
issues,
|
issues,
|
||||||
"$.beamforming.backend",
|
"$.beamforming.backend",
|
||||||
"gcc-phat는 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
|
"선택한 위치 추정 알고리즘은 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
|
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
|
||||||
@@ -536,6 +579,44 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
{ min: 0, max: 120 },
|
{ min: 0, max: 120 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (value.beamforming.algorithm === "music") {
|
||||||
|
const method = value.beamforming.musicSourceCountMethod ?? "fixed";
|
||||||
|
if (value.beamforming.normalization !== "none") {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.beamforming.normalization",
|
||||||
|
"MUSIC raw noise-projection은 정규화하거나 뒤집지 않으므로 normalization은 none이어야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!["fixed", "mdl", "aic"].includes(method)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.beamforming.musicSourceCountMethod",
|
||||||
|
"fixed, mdl 또는 aic여야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.beamforming.musicSourceCount !== undefined ||
|
||||||
|
method === "fixed"
|
||||||
|
) {
|
||||||
|
requireInteger(
|
||||||
|
value.beamforming.musicSourceCount,
|
||||||
|
"$.beamforming.musicSourceCount",
|
||||||
|
issues,
|
||||||
|
{ min: 1, max: MAX_MICROPHONES - 1 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(method === "mdl" || method === "aic") &&
|
||||||
|
value.stft?.hopSize !== value.stft?.fftSize
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.stft.hopSize",
|
||||||
|
"Wax–Kailath MDL/AIC의 IID snapshot 모델을 적용하려면 최소한 frame이 겹치지 않아야 하므로 hopSize가 fftSize와 같아야 합니다. 비중첩 자체가 통계적 독립성을 증명하지는 않습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
|
if (value.evaluation !== undefined && requireRecord(value.evaluation, "$.evaluation", issues)) {
|
||||||
const threshold = value.evaluation.detectionThresholdRatio;
|
const threshold = value.evaluation.detectionThresholdRatio;
|
||||||
@@ -558,6 +639,101 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
|
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (value.beamforming?.algorithm === "music") {
|
||||||
|
const microphoneCount = context.arrayDefinition.microphones.length;
|
||||||
|
const method = value.beamforming.musicSourceCountMethod ?? "fixed";
|
||||||
|
const distinctPositions = new Set(
|
||||||
|
context.arrayDefinition.microphones.map((microphone) =>
|
||||||
|
microphone.position?.join(",")),
|
||||||
|
);
|
||||||
|
if (microphoneCount < 2 || distinctPositions.size < 2) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.beamforming.algorithm",
|
||||||
|
"MUSIC에는 서로 다른 위치의 마이크가 두 개 이상 필요합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
method === "fixed" &&
|
||||||
|
Number.isInteger(value.beamforming.musicSourceCount) &&
|
||||||
|
value.beamforming.musicSourceCount >= microphoneCount
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.beamforming.musicSourceCount",
|
||||||
|
"고정 MUSIC source count는 마이크 수보다 작아야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Number.isInteger(value.stft?.fftSize) &&
|
||||||
|
Array.isArray(value.frequencyBands)
|
||||||
|
) {
|
||||||
|
const binWidthHz =
|
||||||
|
context.scene.sampleRate /
|
||||||
|
value.stft.fftSize;
|
||||||
|
const hypothesisKeys = new Set();
|
||||||
|
for (const band of value.frequencyBands) {
|
||||||
|
try {
|
||||||
|
const observation = observeFrequencyBand(
|
||||||
|
band,
|
||||||
|
context.scene.sampleRate,
|
||||||
|
value.stft.fftSize,
|
||||||
|
);
|
||||||
|
if (observation.bins.length === 1) {
|
||||||
|
hypothesisKeys.add(
|
||||||
|
`${Math.ceil(band.minHz / binWidthHz)}|${observation.bins[0]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The band-specific validation above reports this.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const worstCasePairUpdates =
|
||||||
|
hypothesisKeys.size *
|
||||||
|
32 *
|
||||||
|
(
|
||||||
|
microphoneCount *
|
||||||
|
(microphoneCount - 1) /
|
||||||
|
2
|
||||||
|
) *
|
||||||
|
(2 * microphoneCount - 2);
|
||||||
|
if (
|
||||||
|
worstCasePairUpdates >
|
||||||
|
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.beamforming.algorithm",
|
||||||
|
`MUSIC Jacobi worst-case work ${worstCasePairUpdates.toLocaleString()} pair updates exceeds the explicit CPU limit ${DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES.toLocaleString()}. Reduce microphones or configured hypotheses; no partial eigensystem is returned.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(method === "mdl" || method === "aic") &&
|
||||||
|
Number.isFinite(context.scene.duration) &&
|
||||||
|
Number.isFinite(context.scene.sampleRate) &&
|
||||||
|
Number.isInteger(value.stft?.fftSize) &&
|
||||||
|
Number.isInteger(value.stft?.hopSize)
|
||||||
|
) {
|
||||||
|
const sampleCount = Math.ceil(
|
||||||
|
context.scene.duration * context.scene.sampleRate,
|
||||||
|
);
|
||||||
|
const frameCount =
|
||||||
|
sampleCount < value.stft.fftSize
|
||||||
|
? 0
|
||||||
|
: 1 + Math.floor(
|
||||||
|
(sampleCount - value.stft.fftSize) /
|
||||||
|
value.stft.hopSize,
|
||||||
|
);
|
||||||
|
if (frameCount <= microphoneCount) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.beamforming.musicSourceCountMethod",
|
||||||
|
`Wax–Kailath ${method.toUpperCase()}의 IID snapshot 가정 아래에서도 microphone 수보다 많은 frame이 필요합니다 (현재 ${frameCount} frames / ${microphoneCount} microphones).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const workingSet = estimateSynthesisWorkingSet({
|
const workingSet = estimateSynthesisWorkingSet({
|
||||||
scene: context.scene,
|
scene: context.scene,
|
||||||
@@ -591,8 +767,6 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
if (!isRecord(value)) return issues;
|
if (!isRecord(value)) return issues;
|
||||||
if (context.experiment) {
|
if (context.experiment) {
|
||||||
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
|
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
|
||||||
if (value.arrayId !== context.experiment.arrayId) addIssue(issues, "$.arrayId", "실험의 배열 id와 다릅니다.");
|
|
||||||
if (value.sceneId !== context.experiment.sceneId) addIssue(issues, "$.sceneId", "실험의 장면 id와 다릅니다.");
|
|
||||||
if (
|
if (
|
||||||
value.algorithm !== undefined &&
|
value.algorithm !== undefined &&
|
||||||
value.algorithm !== context.experiment.beamforming?.algorithm
|
value.algorithm !== context.experiment.beamforming?.algorithm
|
||||||
@@ -600,14 +774,34 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
addIssue(issues, "$.algorithm", "실험의 위치 추정 알고리즘과 다릅니다.");
|
addIssue(issues, "$.algorithm", "실험의 위치 추정 알고리즘과 다릅니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
context.arrayDefinition &&
|
||||||
|
value.arrayId !== context.arrayDefinition.id
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.arrayId",
|
||||||
|
"Result arrayId must identify the array actually used for this run.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (context.scene && value.sceneId !== context.scene.id) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.sceneId",
|
||||||
|
"Result sceneId must identify the Scene actually used for this run.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
||||||
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
||||||
const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm;
|
const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm;
|
||||||
if (resultAlgorithm === "gcc-phat" && value.backend === "webgpu") {
|
if (
|
||||||
|
(resultAlgorithm === "gcc-phat" || resultAlgorithm === "music") &&
|
||||||
|
value.backend === "webgpu"
|
||||||
|
) {
|
||||||
addIssue(
|
addIssue(
|
||||||
issues,
|
issues,
|
||||||
"$.backend",
|
"$.backend",
|
||||||
"gcc-phat Result must use the CPU backend.",
|
"The selected Result algorithm must use the CPU backend.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const expectedRawScale =
|
const expectedRawScale =
|
||||||
@@ -615,13 +809,60 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
? "power"
|
? "power"
|
||||||
: resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat"
|
: resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat"
|
||||||
? "score"
|
? "score"
|
||||||
: undefined;
|
: resultAlgorithm === "music"
|
||||||
|
? "noise-projection"
|
||||||
|
: undefined;
|
||||||
const expectedBeamSpreadMethod =
|
const expectedBeamSpreadMethod =
|
||||||
expectedRawScale === "power"
|
expectedRawScale === "power"
|
||||||
? "connected-half-power-power-weighted-covariance"
|
? "connected-half-power-power-weighted-covariance"
|
||||||
: expectedRawScale === "score"
|
: expectedRawScale === "score"
|
||||||
? "connected-half-maximum-score-weighted-covariance"
|
? "connected-half-maximum-score-weighted-covariance"
|
||||||
: undefined;
|
: undefined;
|
||||||
|
if (resultAlgorithm === "music" && !isRecord(value.musicModel)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.musicModel",
|
||||||
|
"MUSIC Result에는 사용한 manifold·loading·fusion 모델 설명이 필요합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resultAlgorithm !== "music" && value.musicModel !== undefined) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.musicModel",
|
||||||
|
"musicModel은 MUSIC Result에만 사용할 수 있습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
resultAlgorithm === "music" &&
|
||||||
|
isRecord(value.musicModel) &&
|
||||||
|
isRecord(context.experiment?.beamforming)
|
||||||
|
) {
|
||||||
|
const beamforming = context.experiment.beamforming;
|
||||||
|
for (const [field, expected] of [
|
||||||
|
["requestedNormalization", beamforming.normalization],
|
||||||
|
["minimumDistance", beamforming.minimumDistance],
|
||||||
|
[
|
||||||
|
"microphoneCalibration.compensateFrequencyResponse",
|
||||||
|
beamforming.compensateFrequencyResponse ?? true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"microphoneCalibration.maxCompensationDb",
|
||||||
|
beamforming.maxCompensationDb ?? DEFAULT_MAX_COMPENSATION_DB,
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const actual = field.split(".").reduce(
|
||||||
|
(current, key) => current?.[key],
|
||||||
|
value.musicModel,
|
||||||
|
);
|
||||||
|
if (!samplingMetadataEqual(actual, expected)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`$.musicModel.${field}`,
|
||||||
|
`${field} does not match the Experiment MUSIC configuration.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
Number.isFinite(value.timingMs?.localization) &&
|
Number.isFinite(value.timingMs?.localization) &&
|
||||||
Number.isFinite(value.timingMs?.beamforming) &&
|
Number.isFinite(value.timingMs?.beamforming) &&
|
||||||
@@ -829,6 +1070,19 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
const standaloneObservation = standaloneObservations.get(bandIndex);
|
const standaloneObservation = standaloneObservations.get(bandIndex);
|
||||||
if (standaloneObservation && isRecord(band.frequencyObservation)) {
|
if (standaloneObservation && isRecord(band.frequencyObservation)) {
|
||||||
const observation = band.frequencyObservation;
|
const observation = band.frequencyObservation;
|
||||||
|
const musicAnalogBin = Math.ceil(
|
||||||
|
standaloneObservation.requestedMinHz /
|
||||||
|
standaloneObservation.binWidthHz,
|
||||||
|
);
|
||||||
|
const musicAnalogFrequencyHz =
|
||||||
|
musicAnalogBin *
|
||||||
|
standaloneObservation.binWidthHz;
|
||||||
|
const musicAlias = resultAlgorithm === "music"
|
||||||
|
? describeFrequencyAliasing(
|
||||||
|
musicAnalogFrequencyHz,
|
||||||
|
standaloneObservation.sampleRate,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const expectedEquivalentBandIds = (standaloneEquivalentGroups
|
const expectedEquivalentBandIds = (standaloneEquivalentGroups
|
||||||
.get(standaloneObservation.adcBinSetKey) ?? [])
|
.get(standaloneObservation.adcBinSetKey) ?? [])
|
||||||
.filter((candidateId) => candidateId !== band.id)
|
.filter((candidateId) => candidateId !== band.id)
|
||||||
@@ -848,10 +1102,31 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
localizationModel:
|
localizationModel:
|
||||||
resultAlgorithm === "gcc-phat"
|
resultAlgorithm === "gcc-phat"
|
||||||
? "band-limited-gcc-phat-geometric-lag"
|
? "band-limited-gcc-phat-geometric-lag"
|
||||||
|
: resultAlgorithm === "music"
|
||||||
|
? "near-field-music-at-configured-analog-frequency"
|
||||||
: "spherical-steering-at-positive-adc-bin-frequency",
|
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||||
originalCarrierUsed: standaloneObservation.originalCarrierUsed,
|
originalCarrierUsed:
|
||||||
|
resultAlgorithm === "music",
|
||||||
aliasOrderResolution:
|
aliasOrderResolution:
|
||||||
standaloneObservation.aliased ? "not-attempted" : "not-applicable",
|
resultAlgorithm === "music" &&
|
||||||
|
standaloneObservation.aliased
|
||||||
|
? "configured-single-hypothesis"
|
||||||
|
: standaloneObservation.aliased
|
||||||
|
? "not-attempted"
|
||||||
|
: "not-applicable",
|
||||||
|
...(resultAlgorithm === "music"
|
||||||
|
? {
|
||||||
|
configuredAnalogFrequencyHz:
|
||||||
|
musicAnalogFrequencyHz,
|
||||||
|
signedAdcFrequencyHz:
|
||||||
|
musicAlias.signedAdcFrequencyHz,
|
||||||
|
spectralInversion:
|
||||||
|
musicAlias.spectralInversion,
|
||||||
|
frequencyHypothesisCount: 1,
|
||||||
|
frequencyHypothesisSource:
|
||||||
|
"experiment-band-not-scene-truth",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
foldBoundaryCount: standaloneObservation.foldBoundaryCount,
|
foldBoundaryCount: standaloneObservation.foldBoundaryCount,
|
||||||
continuousManyToOne: standaloneObservation.continuousManyToOne,
|
continuousManyToOne: standaloneObservation.continuousManyToOne,
|
||||||
gridManyToOne: standaloneObservation.gridManyToOne,
|
gridManyToOne: standaloneObservation.gridManyToOne,
|
||||||
@@ -945,8 +1220,15 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
|
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (isRecord(band.rawMap) && !["power", "score"].includes(band.rawMap.scale)) {
|
if (
|
||||||
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power 또는 score scale이어야 합니다.");
|
isRecord(band.rawMap) &&
|
||||||
|
!["power", "score", "noise-projection"].includes(band.rawMap.scale)
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.rawMap.scale`,
|
||||||
|
"rawMap은 power, score 또는 noise-projection scale이어야 합니다.",
|
||||||
|
);
|
||||||
} else if (
|
} else if (
|
||||||
isRecord(band.rawMap) &&
|
isRecord(band.rawMap) &&
|
||||||
expectedRawScale &&
|
expectedRawScale &&
|
||||||
@@ -958,6 +1240,188 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
`${resultAlgorithm} 결과의 rawMap scale은 ${expectedRawScale}여야 합니다.`,
|
`${resultAlgorithm} 결과의 rawMap scale은 ${expectedRawScale}여야 합니다.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
isRecord(band.rawMap) &&
|
||||||
|
band.rawMap.scale === "noise-projection" &&
|
||||||
|
band.rawMap.objectiveDirection !== "minimum"
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.rawMap.objectiveDirection`,
|
||||||
|
"MUSIC noise-projection map의 optimum은 minimum이어야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isRecord(band.rawMap) &&
|
||||||
|
band.rawMap.scale !== "noise-projection" &&
|
||||||
|
band.rawMap.objectiveDirection !== undefined &&
|
||||||
|
band.rawMap.objectiveDirection !== "maximum"
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.rawMap.objectiveDirection`,
|
||||||
|
"power와 score map의 optimum은 maximum이어야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resultAlgorithm === "music") {
|
||||||
|
const diagnostics = band.musicDiagnostics;
|
||||||
|
if (!isRecord(diagnostics)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics`,
|
||||||
|
"MUSIC band에는 covariance/EVD/source-count 수치 진단이 필요합니다.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const frequencyHypothesis = diagnostics.frequencyHypothesis;
|
||||||
|
const frequencyObservation = band.frequencyObservation;
|
||||||
|
if (
|
||||||
|
isRecord(frequencyHypothesis) &&
|
||||||
|
isRecord(frequencyObservation)
|
||||||
|
) {
|
||||||
|
const adcBin =
|
||||||
|
Array.isArray(frequencyObservation.adcBins) &&
|
||||||
|
frequencyObservation.adcBins.length === 1
|
||||||
|
? frequencyObservation.adcBins[0]
|
||||||
|
: null;
|
||||||
|
const analogFrequencyHz =
|
||||||
|
frequencyObservation.configuredAnalogFrequencyHz;
|
||||||
|
const signedAdcFrequencyHz =
|
||||||
|
frequencyObservation.signedAdcFrequencyHz;
|
||||||
|
const sampleRate = frequencyObservation.sampleRate;
|
||||||
|
const expectedHypothesis = {
|
||||||
|
model:
|
||||||
|
"configured-single-analog-fft-grid-hypothesis",
|
||||||
|
hypothesisCount:
|
||||||
|
frequencyObservation.frequencyHypothesisCount,
|
||||||
|
analogFrequencyHz,
|
||||||
|
observedFrequencyHz:
|
||||||
|
Number.isInteger(adcBin) &&
|
||||||
|
Number.isFinite(frequencyObservation.binWidthHz)
|
||||||
|
? adcBin * frequencyObservation.binWidthHz
|
||||||
|
: null,
|
||||||
|
signedAdcFrequencyHz,
|
||||||
|
spectralInversion:
|
||||||
|
frequencyObservation.spectralInversion,
|
||||||
|
aliasOrder:
|
||||||
|
Number.isFinite(analogFrequencyHz) &&
|
||||||
|
Number.isFinite(signedAdcFrequencyHz) &&
|
||||||
|
Number.isFinite(sampleRate) &&
|
||||||
|
sampleRate > 0
|
||||||
|
? Math.round(
|
||||||
|
(analogFrequencyHz - signedAdcFrequencyHz) /
|
||||||
|
sampleRate,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
sharedObservationKey:
|
||||||
|
Number.isInteger(adcBin) &&
|
||||||
|
Number.isFinite(sampleRate) &&
|
||||||
|
Number.isInteger(frequencyObservation.fftSize)
|
||||||
|
? `fs:${sampleRate}|fft:${frequencyObservation.fftSize}|bin:${adcBin}`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
for (const [field, expected] of Object.entries(
|
||||||
|
expectedHypothesis,
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
expected !== null &&
|
||||||
|
!samplingMetadataEqual(
|
||||||
|
frequencyHypothesis[field],
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics.frequencyHypothesis.${field}`,
|
||||||
|
`${field} must match the configured analog frequency hypothesis.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const microphoneCount =
|
||||||
|
context.arrayDefinition?.microphones?.length ??
|
||||||
|
value.environment?.microphoneCount;
|
||||||
|
if (
|
||||||
|
Number.isInteger(microphoneCount) &&
|
||||||
|
(
|
||||||
|
diagnostics.eigenvalues?.length !== microphoneCount ||
|
||||||
|
diagnostics.auditedEigenvalues?.length !== microphoneCount
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics.eigenvalues`,
|
||||||
|
"MUSIC eigenvalue 수는 microphone 수와 같아야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sourceCount = diagnostics.sourceCount?.value;
|
||||||
|
const expectedSourceCountMethod =
|
||||||
|
context.experiment?.beamforming?.musicSourceCountMethod ??
|
||||||
|
"fixed";
|
||||||
|
if (
|
||||||
|
diagnostics.sourceCount?.method !== expectedSourceCountMethod ||
|
||||||
|
diagnostics.sourceCount?.criterion !==
|
||||||
|
(expectedSourceCountMethod === "fixed"
|
||||||
|
? null
|
||||||
|
: expectedSourceCountMethod)
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics.sourceCount.method`,
|
||||||
|
"source-count method/criterion은 Experiment 설정과 같아야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expectedSourceCountMethod !== "fixed" &&
|
||||||
|
!isRecord(
|
||||||
|
diagnostics.sourceCount?.assumptions?.statisticalDataModel,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics.sourceCount.assumptions.statisticalDataModel`,
|
||||||
|
"MDL/AIC 진단에는 white-noise/Gaussian/stationarity 모델 가정과 비확률 해석이 필요합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Number.isInteger(value.environment?.stftFrameCount) &&
|
||||||
|
diagnostics.snapshotCount !== value.environment.stftFrameCount
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics.snapshotCount`,
|
||||||
|
"MUSIC snapshotCount는 실행 STFT frame 수와 같아야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Number.isInteger(microphoneCount) &&
|
||||||
|
Number.isInteger(sourceCount) &&
|
||||||
|
sourceCount >= microphoneCount
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics.sourceCount.value`,
|
||||||
|
"MUSIC source count는 microphone 수보다 작아야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
context.experiment?.beamforming?.musicSourceCountMethod === "fixed" &&
|
||||||
|
Number.isInteger(context.experiment.beamforming.musicSourceCount) &&
|
||||||
|
sourceCount !== context.experiment.beamforming.musicSourceCount
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics.sourceCount.value`,
|
||||||
|
"고정 source count 진단은 Experiment K와 같아야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (band.musicDiagnostics !== undefined) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.musicDiagnostics`,
|
||||||
|
"musicDiagnostics는 MUSIC band에만 사용할 수 있습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (Array.isArray(band.peaks)) {
|
if (Array.isArray(band.peaks)) {
|
||||||
band.peaks.forEach((peak, peakIndex) => {
|
band.peaks.forEach((peak, peakIndex) => {
|
||||||
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
|
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
|
||||||
@@ -974,6 +1438,16 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
`${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`,
|
`${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
resultAlgorithm === "music" &&
|
||||||
|
isRecord(peak?.beamSpread)
|
||||||
|
) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
`${path}.peaks[${peakIndex}].beamSpread`,
|
||||||
|
"MUSIC noise-projection에는 DAS/PHAT half-power beamSpread를 적용할 수 없습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,13 +43,15 @@
|
|||||||
"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": {
|
||||||
|
|||||||
+303
-5
@@ -15,7 +15,7 @@
|
|||||||
"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": {
|
||||||
@@ -51,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" }
|
||||||
@@ -123,11 +124,27 @@
|
|||||||
"localizationModel": {
|
"localizationModel": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"spherical-steering-at-positive-adc-bin-frequency",
|
"spherical-steering-at-positive-adc-bin-frequency",
|
||||||
"band-limited-gcc-phat-geometric-lag"
|
"band-limited-gcc-phat-geometric-lag",
|
||||||
|
"near-field-music-at-configured-analog-frequency"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"originalCarrierUsed": { "const": false },
|
"originalCarrierUsed": { "type": "boolean" },
|
||||||
"aliasOrderResolution": { "enum": ["not-applicable", "not-attempted"] },
|
"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 },
|
"foldBoundaryCount": { "type": "integer", "minimum": 0, "maximum": 512 },
|
||||||
"continuousManyToOne": { "type": "boolean" },
|
"continuousManyToOne": { "type": "boolean" },
|
||||||
"gridManyToOne": { "type": "boolean" },
|
"gridManyToOne": { "type": "boolean" },
|
||||||
@@ -185,7 +202,8 @@
|
|||||||
"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" }
|
||||||
@@ -246,6 +264,281 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": [
|
||||||
@@ -259,6 +552,10 @@
|
|||||||
"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": {
|
"detectionMethod": {
|
||||||
"enum": ["local-max", "connected-components", "watershed"]
|
"enum": ["local-max", "connected-components", "watershed"]
|
||||||
},
|
},
|
||||||
@@ -289,6 +586,7 @@
|
|||||||
"displayMap": { "$ref": "#/$defs/map" },
|
"displayMap": { "$ref": "#/$defs/map" },
|
||||||
"frequencyObservation": { "$ref": "#/$defs/frequencyObservation" },
|
"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": {
|
||||||
|
|||||||
@@ -613,6 +613,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 +645,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;
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
|
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
|
||||||
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
|
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
|
||||||
assertEqual(documentObject.querySelector("#algorithm").value, "das", "default DAS algorithm");
|
assertEqual(documentObject.querySelector("#algorithm").value, "das", "default DAS algorithm");
|
||||||
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 3, "three runnable algorithms");
|
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 4, "four runnable algorithms");
|
||||||
assert(!documentObject.querySelector("#postprocess"), "disabled postprocess advertisement is absent");
|
assert(!documentObject.querySelector("#postprocess"), "disabled postprocess advertisement is absent");
|
||||||
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
|
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
|
||||||
assertEqual(
|
assertEqual(
|
||||||
@@ -325,6 +325,63 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
);
|
);
|
||||||
assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved");
|
assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved");
|
||||||
|
|
||||||
|
documentObject.querySelector("#load-music-example-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
documentObject.querySelector("#algorithm").value === "music" &&
|
||||||
|
documentObject.querySelector("#microphone-count").textContent === "12 ch" &&
|
||||||
|
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||||
|
!documentObject.querySelector("#run-button").disabled,
|
||||||
|
"quick MUSIC example load",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
documentObject.querySelector("#workload-summary").textContent.includes(
|
||||||
|
"1 analog hypotheses / 1 ADC bins",
|
||||||
|
) &&
|
||||||
|
documentObject.querySelector("#workload-summary").textContent.includes(
|
||||||
|
"cap",
|
||||||
|
),
|
||||||
|
"MUSIC workload exposes hypotheses and hard cap",
|
||||||
|
);
|
||||||
|
documentObject.querySelector("#run-button").click();
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
documentObject.documentElement.dataset.runState === "done" &&
|
||||||
|
documentObject.documentElement.dataset.runAlgorithm === "music",
|
||||||
|
"quick MUSIC example run",
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
documentObject.documentElement.dataset.resultBandCount,
|
||||||
|
"1",
|
||||||
|
"quick MUSIC example returns one map",
|
||||||
|
);
|
||||||
|
const downloadCountBeforeMusic =
|
||||||
|
downloads.length;
|
||||||
|
documentObject.querySelector("#export-result-button").click();
|
||||||
|
const musicDownload = await waitFor(
|
||||||
|
() =>
|
||||||
|
downloads.length > downloadCountBeforeMusic
|
||||||
|
? downloads.at(-1)
|
||||||
|
: null,
|
||||||
|
"MUSIC Result JSON download",
|
||||||
|
);
|
||||||
|
const musicResult = await fetch(musicDownload.url).then(
|
||||||
|
(response) => response.json(),
|
||||||
|
);
|
||||||
|
assertEqual(musicResult.algorithm, "music", "MUSIC example export");
|
||||||
|
assertEqual(
|
||||||
|
musicResult.musicModel.hypothesisSource,
|
||||||
|
"experiment-band-not-scene-truth",
|
||||||
|
"MUSIC export never claims Scene-truth carrier selection",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
musicResult.bands[0].frequencyObservation
|
||||||
|
.configuredAnalogFrequencyHz,
|
||||||
|
1125,
|
||||||
|
"configured analog carrier is exported",
|
||||||
|
);
|
||||||
|
|
||||||
frame.style.width = "390px";
|
frame.style.width = "390px";
|
||||||
await nextPaint(windowObject);
|
await nextPaint(windowObject);
|
||||||
assert(
|
assert(
|
||||||
@@ -448,7 +505,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||||
documentObject.documentElement.dataset.cameraValid === "true" &&
|
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||||
!documentObject.querySelector("#run-button").disabled,
|
!documentObject.querySelector("#run-button").disabled,
|
||||||
"Experiment import and automatic reference binding",
|
"Experiment import with independent provenance labels",
|
||||||
);
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
documentObject.querySelector("#experiment-id").value,
|
documentObject.querySelector("#experiment-id").value,
|
||||||
@@ -465,7 +522,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
!documentObject.querySelector("#validation-message").textContent.includes("sceneId") &&
|
!documentObject.querySelector("#validation-message").textContent.includes("sceneId") &&
|
||||||
!documentObject.querySelector("#validation-message").textContent.includes("arrayId") &&
|
!documentObject.querySelector("#validation-message").textContent.includes("arrayId") &&
|
||||||
!documentObject.querySelector("#run-status").textContent.includes("연결 대기"),
|
!documentObject.querySelector("#run-status").textContent.includes("연결 대기"),
|
||||||
"automatic binding does not leave a reference mismatch warning",
|
"provenance labels do not create a reference mismatch warning",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") &&
|
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") &&
|
||||||
@@ -474,29 +531,33 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
);
|
);
|
||||||
|
|
||||||
documentObject.querySelector("#export-experiment-button").click();
|
documentObject.querySelector("#export-experiment-button").click();
|
||||||
const reboundDownload = await waitFor(
|
const preservedDownload = await waitFor(
|
||||||
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
|
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
|
||||||
"automatically rebound Experiment export",
|
"provenance-preserving Experiment export",
|
||||||
);
|
);
|
||||||
const reboundExperiment = await fetch(reboundDownload.url).then((response) => response.json());
|
const preservedExperiment = await fetch(preservedDownload.url).then((response) => response.json());
|
||||||
assertEqual(reboundExperiment.sceneId, openScene.id, "import binds the open Scene ID");
|
|
||||||
assertEqual(
|
assertEqual(
|
||||||
reboundExperiment.arrayId,
|
preservedExperiment.sceneId,
|
||||||
openArrayDefinition.id,
|
experiment.sceneId,
|
||||||
"import binds the open Array ID",
|
"import preserves the Experiment Scene provenance label",
|
||||||
);
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
JSON.stringify(reboundExperiment.frequencyBands),
|
preservedExperiment.arrayId,
|
||||||
|
experiment.arrayId,
|
||||||
|
"import preserves the Experiment Array provenance label",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
JSON.stringify(preservedExperiment.frequencyBands),
|
||||||
JSON.stringify(expectedBands),
|
JSON.stringify(expectedBands),
|
||||||
"automatic reference binding preserves every imported band",
|
"provenance preservation keeps every imported band",
|
||||||
);
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
(await validateExperiment(reboundExperiment, {
|
(await validateExperiment(preservedExperiment, {
|
||||||
scene: openScene,
|
scene: openScene,
|
||||||
arrayDefinition: openArrayDefinition,
|
arrayDefinition: openArrayDefinition,
|
||||||
})).length,
|
})).length,
|
||||||
0,
|
0,
|
||||||
"rebound Experiment passes relationship validation",
|
"provenance labels may differ from the open Scene and Array",
|
||||||
);
|
);
|
||||||
downloads.length = 0;
|
downloads.length = 0;
|
||||||
|
|
||||||
@@ -678,8 +739,16 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
JSON.stringify(expectedBands),
|
JSON.stringify(expectedBands),
|
||||||
"Experiment export preserves every band after Scene reimport",
|
"Experiment export preserves every band after Scene reimport",
|
||||||
);
|
);
|
||||||
assertEqual(exportedExperiment.sceneId, scene.id, "Experiment rebinds only the Scene ID");
|
assertEqual(
|
||||||
assertEqual(exportedExperiment.arrayId, arrayDefinition.id, "Experiment rebinds the Array ID");
|
exportedExperiment.sceneId,
|
||||||
|
experiment.sceneId,
|
||||||
|
"Experiment keeps its Scene provenance label after Scene replacement",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
exportedExperiment.arrayId,
|
||||||
|
experiment.arrayId,
|
||||||
|
"Experiment keeps its Array provenance label after Array replacement",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
windowObject.confirm = originalConfirm;
|
windowObject.confirm = originalConfirm;
|
||||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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/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");
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ test("localization algorithm registry exposes stable ids and capabilities", () =
|
|||||||
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
||||||
assertEqual(
|
assertEqual(
|
||||||
LOCALIZATION_ALGORITHM_IDS.join(","),
|
LOCALIZATION_ALGORITHM_IDS.join(","),
|
||||||
"das,das-phat,gcc-phat",
|
"das,das-phat,gcc-phat,music",
|
||||||
"stable algorithm ids",
|
"stable algorithm ids",
|
||||||
);
|
);
|
||||||
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS map scale");
|
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS map scale");
|
||||||
@@ -70,8 +70,14 @@ test("localization algorithm registry exposes stable ids and capabilities", () =
|
|||||||
"cpu",
|
"cpu",
|
||||||
"GCC-PHAT backend capability",
|
"GCC-PHAT backend capability",
|
||||||
);
|
);
|
||||||
|
assertEqual(
|
||||||
|
localizationAlgorithmDefinition("music").rawScale,
|
||||||
|
"noise-projection",
|
||||||
|
"MUSIC map scale",
|
||||||
|
);
|
||||||
assert(isDasAlgorithm("das") && isDasAlgorithm("das-phat"), "DAS engine algorithms");
|
assert(isDasAlgorithm("das") && isDasAlgorithm("das-phat"), "DAS engine algorithms");
|
||||||
assert(!isDasAlgorithm("gcc-phat"), "GCC-PHAT uses a separate engine");
|
assert(!isDasAlgorithm("gcc-phat"), "GCC-PHAT uses a separate engine");
|
||||||
|
assert(!isDasAlgorithm("music"), "MUSIC uses a separate engine");
|
||||||
assertEqual(localizationAlgorithmLabel("das-phat"), "DAS-PHAT", "algorithm label");
|
assertEqual(localizationAlgorithmLabel("das-phat"), "DAS-PHAT", "algorithm label");
|
||||||
assertEqual(localizationAlgorithmDefinition("unknown"), undefined, "unknown lookup");
|
assertEqual(localizationAlgorithmDefinition("unknown"), undefined, "unknown lookup");
|
||||||
});
|
});
|
||||||
|
|||||||
+236
-6
@@ -22,6 +22,7 @@ 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";
|
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
|
||||||
|
|
||||||
function assertNear(actual, expected, tolerance, label) {
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
@@ -319,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", () => {
|
||||||
@@ -520,8 +564,8 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
};
|
};
|
||||||
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" },
|
||||||
stft: { fftSize: 256, hopSize: 128, window: "hann" },
|
stft: { fftSize: 256, hopSize: 128, window: "hann" },
|
||||||
@@ -643,6 +687,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
assertEqual(result.schemaVersion, "1.1.0", "sampling-aware Result contract version");
|
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");
|
||||||
@@ -710,7 +766,11 @@ 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(
|
assertEqual(
|
||||||
(await validateResult(result)).length,
|
(await validateResult(result)).length,
|
||||||
@@ -777,7 +837,177 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
"validator rejects incomplete Result band sets",
|
"validator rejects incomplete Result band sets",
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrongExperiment = { ...experiment, arrayId: "another-array" };
|
const wrongArrayResult = structuredClone(result);
|
||||||
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
|
wrongArrayResult.arrayId = "another-array";
|
||||||
assert(linkageIssues.some(({ path }) => path === "$.arrayId"), "validator catches snapshot linkage mismatch");
|
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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ function resultFrequencyObservation(scene, experiment, band, equivalentBandIds =
|
|||||||
localizationModel:
|
localizationModel:
|
||||||
experiment.beamforming?.algorithm === "gcc-phat"
|
experiment.beamforming?.algorithm === "gcc-phat"
|
||||||
? "band-limited-gcc-phat-geometric-lag"
|
? "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",
|
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||||
originalCarrierUsed: observation.originalCarrierUsed,
|
originalCarrierUsed: observation.originalCarrierUsed,
|
||||||
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
|
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
|
||||||
@@ -234,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,
|
||||||
@@ -252,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";
|
||||||
@@ -271,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"),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -426,3 +427,102 @@ test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서
|
|||||||
"display-only map scale rejected",
|
"display-only map scale rejected",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("MUSIC noise projection is minimized without rewriting raw q values", () => {
|
||||||
|
const scan = {
|
||||||
|
xMin: 0,
|
||||||
|
xMax: 4,
|
||||||
|
yMin: 0,
|
||||||
|
yMax: 4,
|
||||||
|
z: 1,
|
||||||
|
columns: 5,
|
||||||
|
rows: 5,
|
||||||
|
};
|
||||||
|
const values = new Float64Array(25);
|
||||||
|
values.fill(1);
|
||||||
|
values[6] = 0.1;
|
||||||
|
values[18] = 0.2;
|
||||||
|
values[3] = 0.6;
|
||||||
|
const evaluation = evaluateLocalizationBand({
|
||||||
|
rawMap: {
|
||||||
|
rows: 5,
|
||||||
|
columns: 5,
|
||||||
|
scale: "noise-projection",
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
values,
|
||||||
|
},
|
||||||
|
scan,
|
||||||
|
sources: [
|
||||||
|
{ id: "left", position: [1, 3, 1] },
|
||||||
|
{ id: "right", position: [3, 1, 1] },
|
||||||
|
],
|
||||||
|
detectionThresholdRatio: 0.5,
|
||||||
|
minimumPeakDistanceM: 0,
|
||||||
|
matchToleranceM: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEqual(evaluation.globalPeak.index, 6, "smallest q is the global optimum");
|
||||||
|
assertNear(evaluation.globalPeak.value, 0.1, 1e-12, "global peak retains raw q");
|
||||||
|
assertEqual(evaluation.detections.length, 2, "two separated minima are detected");
|
||||||
|
assertNear(evaluation.detections[1].value, 0.2, 1e-12, "second detection retains raw q");
|
||||||
|
assert(!("beamSpread" in evaluation.detections[0]), "power beam spread is omitted");
|
||||||
|
assertEqual(
|
||||||
|
Object.keys(evaluation.metrics).join(","),
|
||||||
|
"precision,recall",
|
||||||
|
"only objective-independent metrics are reported",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
evaluation.diagnostics.detectionTransform,
|
||||||
|
"normalized-reciprocal-noise-projection",
|
||||||
|
"the standard normalized MUSIC pseudospectrum is disclosed",
|
||||||
|
);
|
||||||
|
const exported = exportBandEvaluation(evaluation);
|
||||||
|
assertEqual(exported.evaluation.objectiveDirection, "minimum", "minimum objective is exported");
|
||||||
|
assertEqual(
|
||||||
|
exported.evaluation.detectionThresholdDomain,
|
||||||
|
"normalized-music-pseudospectrum",
|
||||||
|
"threshold domain is explicit",
|
||||||
|
);
|
||||||
|
assertNear(exported.peaks[0].value, 0.1, 1e-12, "exported peak remains raw q");
|
||||||
|
|
||||||
|
const withBadOutlier = Float64Array.from(values);
|
||||||
|
withBadOutlier[0] = 100;
|
||||||
|
const outlierEvaluation = evaluateLocalizationBand({
|
||||||
|
rawMap: {
|
||||||
|
rows: 5,
|
||||||
|
columns: 5,
|
||||||
|
scale: "noise-projection",
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
values: withBadOutlier,
|
||||||
|
},
|
||||||
|
scan,
|
||||||
|
sources: [],
|
||||||
|
detectionThresholdRatio: 0.5,
|
||||||
|
minimumPeakDistanceM: 0,
|
||||||
|
});
|
||||||
|
assertEqual(
|
||||||
|
outlierEvaluation.detections.map(({ index }) => index).join(","),
|
||||||
|
evaluation.detections.map(({ index }) => index).join(","),
|
||||||
|
"a worse scan outlier does not move the reciprocal threshold",
|
||||||
|
);
|
||||||
|
|
||||||
|
const flat = evaluateLocalizationBand({
|
||||||
|
rawMap: {
|
||||||
|
rows: 5,
|
||||||
|
columns: 5,
|
||||||
|
scale: "noise-projection",
|
||||||
|
objectiveDirection: "minimum",
|
||||||
|
values: new Float64Array(25).fill(0.5),
|
||||||
|
},
|
||||||
|
scan,
|
||||||
|
sources: [],
|
||||||
|
});
|
||||||
|
const flatDisplay = normalizedMusicPseudospectrum(
|
||||||
|
new Float64Array(25).fill(0.5),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
flatDisplay.values.every((value) => value === 1),
|
||||||
|
"flat q preserves the standard normalized reciprocal value",
|
||||||
|
);
|
||||||
|
assertEqual(flat.detections.length, 0, "flat q does not invent a location");
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
createLocalizationAnalysisPlan,
|
createLocalizationAnalysisPlan,
|
||||||
estimateLocalizationWorkload,
|
estimateLocalizationWorkload,
|
||||||
} from "/packages/acoustics-core/localization.js";
|
} from "/packages/acoustics-core/localization.js";
|
||||||
|
import {
|
||||||
|
createMusicBinCalibration,
|
||||||
|
isMusicUnavailableError,
|
||||||
|
} from "/packages/acoustics-core/music.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
|
||||||
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
|
||||||
@@ -58,9 +62,9 @@ function createFixture(algorithm = "das") {
|
|||||||
columns: 3,
|
columns: 3,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
};
|
};
|
||||||
const frequencyBands = [
|
const frequencyBands = algorithm === "music"
|
||||||
{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" },
|
? [{ id: "single-bin", minHz: 500, maxHz: 530, color: "#65f2bf" }]
|
||||||
];
|
: [{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" }];
|
||||||
const experiment = {
|
const experiment = {
|
||||||
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
stft: { fftSize: 64, hopSize: 32, window: "hann" },
|
||||||
scan,
|
scan,
|
||||||
@@ -68,8 +72,11 @@ function createFixture(algorithm = "das") {
|
|||||||
beamforming: {
|
beamforming: {
|
||||||
algorithm,
|
algorithm,
|
||||||
backend: "cpu",
|
backend: "cpu",
|
||||||
normalization: "peak",
|
normalization: algorithm === "music" ? "none" : "peak",
|
||||||
compensateFrequencyResponse: true,
|
compensateFrequencyResponse: true,
|
||||||
|
...(algorithm === "music"
|
||||||
|
? { musicSourceCountMethod: "fixed", musicSourceCount: 1 }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
@@ -83,12 +90,22 @@ function createFixture(algorithm = "das") {
|
|||||||
|
|
||||||
test("localization algorithm registry exposes explicit scale and backend semantics", () => {
|
test("localization algorithm registry exposes explicit scale and backend semantics", () => {
|
||||||
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
|
||||||
assertEqual(LOCALIZATION_ALGORITHM_IDS.join(","), "das,das-phat,gcc-phat", "registered ids");
|
assertEqual(
|
||||||
|
LOCALIZATION_ALGORITHM_IDS.join(","),
|
||||||
|
"das,das-phat,gcc-phat,music",
|
||||||
|
"registered ids",
|
||||||
|
);
|
||||||
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS power scale");
|
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS power scale");
|
||||||
assertEqual(localizationAlgorithmDefinition("das-phat").rawScale, "score", "DAS-PHAT score scale");
|
assertEqual(localizationAlgorithmDefinition("das-phat").rawScale, "score", "DAS-PHAT score scale");
|
||||||
assertEqual(localizationAlgorithmDefinition("gcc-phat").rawScale, "score", "GCC-PHAT score scale");
|
assertEqual(localizationAlgorithmDefinition("gcc-phat").rawScale, "score", "GCC-PHAT score scale");
|
||||||
|
assertEqual(
|
||||||
|
localizationAlgorithmDefinition("music").rawScale,
|
||||||
|
"noise-projection",
|
||||||
|
"MUSIC raw noise-projection scale",
|
||||||
|
);
|
||||||
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU support");
|
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU support");
|
||||||
assert(!localizationAlgorithmSupportsBackend("gcc-phat", "webgpu"), "GCC-PHAT CPU-only support");
|
assert(!localizationAlgorithmSupportsBackend("gcc-phat", "webgpu"), "GCC-PHAT CPU-only support");
|
||||||
|
assert(!localizationAlgorithmSupportsBackend("music", "webgpu"), "MUSIC CPU-only support");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generic CPU dispatcher runs all algorithms with explicit raw-map scales", async () => {
|
test("generic CPU dispatcher runs all algorithms with explicit raw-map scales", async () => {
|
||||||
@@ -156,6 +173,348 @@ test("backend dispatcher keeps GCC-PHAT on CPU and rejects an explicit GPU reque
|
|||||||
assert(legacyError instanceof RangeError, "legacy DAS entry rejects GCC");
|
assert(legacyError instanceof RangeError, "legacy DAS entry rejects GCC");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("backend dispatcher keeps MUSIC on CPU and rejects an explicit GPU request", async () => {
|
||||||
|
const fixture = createFixture("music");
|
||||||
|
fixture.experiment.beamforming.backend = "auto";
|
||||||
|
const calls = [];
|
||||||
|
const result = await computeLocalizationWithBackend(fixture, {
|
||||||
|
computeGpu: async () => {
|
||||||
|
calls.push("gpu");
|
||||||
|
throw new Error("MUSIC must not call WebGPU");
|
||||||
|
},
|
||||||
|
computeMusicCpu: async () => {
|
||||||
|
calls.push("music-cpu");
|
||||||
|
return { backend: "cpu", algorithm: "music", bands: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEqual(calls.join(","), "music-cpu", "MUSIC auto routing");
|
||||||
|
assertEqual(result.algorithm, "music", "MUSIC result");
|
||||||
|
|
||||||
|
fixture.experiment.beamforming.backend = "webgpu";
|
||||||
|
let backendError;
|
||||||
|
try {
|
||||||
|
await computeLocalizationWithBackend(fixture);
|
||||||
|
} catch (error) {
|
||||||
|
backendError = error;
|
||||||
|
}
|
||||||
|
assert(backendError instanceof TypeError, "explicit MUSIC WebGPU is rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fixed-K MUSIC reports silent rank loss instead of choosing a grid cell", async () => {
|
||||||
|
const fixture = createFixture("music");
|
||||||
|
fixture.channels = fixture.channels.map(
|
||||||
|
(channel) => new Float32Array(channel.length),
|
||||||
|
);
|
||||||
|
let unavailable;
|
||||||
|
try {
|
||||||
|
await computeLocalizationMap(fixture);
|
||||||
|
} catch (error) {
|
||||||
|
unavailable = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
isMusicUnavailableError(unavailable),
|
||||||
|
"silence is a typed MUSIC unavailable condition",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
unavailable.reason,
|
||||||
|
"signal-noise-partition-unidentifiable",
|
||||||
|
"silence has an explicit identifiability reason",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC uses an explicit above-Nyquist carrier hypothesis without Scene truth", async () => {
|
||||||
|
const baseband = createFixture("music");
|
||||||
|
const aliased = createFixture("music");
|
||||||
|
aliased.channels = baseband.channels;
|
||||||
|
aliased.experiment.frequencyBands = [
|
||||||
|
{ id: "aliased-single-bin", minHz: 4590, maxHz: 4620, color: "#65f2bf" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const basebandResult = await computeLocalizationMap(baseband);
|
||||||
|
const aliasedResult = await computeLocalizationMap(aliased);
|
||||||
|
assertEqual(aliasedResult.bands[0].bins.join(","), "8", "aliased hypothesis maps to bin 8");
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.bands[0].observedFrequencyHz,
|
||||||
|
512,
|
||||||
|
"covariance uses the observed ADC bin center",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.bands[0].analogFrequencyHz,
|
||||||
|
4608,
|
||||||
|
"band config supplies the analog FFT-grid carrier",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.bands[0].signedAdcFrequencyHz,
|
||||||
|
512,
|
||||||
|
"signed ADC frequency is retained",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.model.originalCarrierUsed,
|
||||||
|
true,
|
||||||
|
"configured analog carrier is used",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.model.hypothesisSource,
|
||||||
|
"experiment-band-not-scene-truth",
|
||||||
|
"Scene truth is never used to choose the carrier",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliasedResult.model.multibandFusion,
|
||||||
|
"none-each-band-is-separate-single-analog-hypothesis",
|
||||||
|
"no cross-band fusion is invented",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
JSON.stringify(Array.from(aliasedResult.bands[0].rawMap)) !==
|
||||||
|
JSON.stringify(Array.from(basebandResult.bands[0].rawMap)),
|
||||||
|
"same ADC data is tested against a distinct configured spatial wavelength",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("near-field MUSIC applies the configured analog carrier and inversion without hiding grating lobes", async () => {
|
||||||
|
const sampleRate = 8192;
|
||||||
|
const fftSize = 128;
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const basebandFrequency = 1024;
|
||||||
|
const invertedAnalogFrequency = sampleRate - basebandFrequency;
|
||||||
|
const source = [0.1, -0.1, 1];
|
||||||
|
const arrayDefinition = {
|
||||||
|
id: "physical-music-array",
|
||||||
|
microphones: [
|
||||||
|
{ id: "nw", position: [-0.12, 0.12, 0] },
|
||||||
|
{ id: "ne", position: [0.12, 0.12, 0] },
|
||||||
|
{ id: "sw", position: [-0.12, -0.12, 0] },
|
||||||
|
{ id: "se", position: [0.12, -0.12, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const channelsForAnalogFrequency = (frequency) =>
|
||||||
|
arrayDefinition.microphones.map((microphone) => {
|
||||||
|
const distance = Math.hypot(
|
||||||
|
source[0] - microphone.position[0],
|
||||||
|
source[1] - microphone.position[1],
|
||||||
|
source[2] - microphone.position[2],
|
||||||
|
);
|
||||||
|
return Float64Array.from(
|
||||||
|
{ length: 384 },
|
||||||
|
(_, sampleIndex) =>
|
||||||
|
Math.cos(
|
||||||
|
2 * Math.PI * frequency *
|
||||||
|
(sampleIndex / sampleRate - distance / speedOfSound),
|
||||||
|
) / distance,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const experimentForBand = (band) => ({
|
||||||
|
stft: { fftSize, hopSize: 64, window: "hann" },
|
||||||
|
scan: {
|
||||||
|
z: 1,
|
||||||
|
xMin: -0.2,
|
||||||
|
xMax: 0.2,
|
||||||
|
yMin: -0.2,
|
||||||
|
yMax: 0.2,
|
||||||
|
columns: 5,
|
||||||
|
rows: 5,
|
||||||
|
},
|
||||||
|
frequencyBands: [{ id: "tone", color: "#65f2bf", ...band }],
|
||||||
|
beamforming: {
|
||||||
|
algorithm: "music",
|
||||||
|
backend: "cpu",
|
||||||
|
minimumDistance: 0.01,
|
||||||
|
normalization: "none",
|
||||||
|
compensateFrequencyResponse: true,
|
||||||
|
musicSourceCountMethod: "fixed",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const common = {
|
||||||
|
sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene: { speedOfSound },
|
||||||
|
};
|
||||||
|
const baseband = await computeLocalizationMap({
|
||||||
|
...common,
|
||||||
|
channels: channelsForAnalogFrequency(basebandFrequency),
|
||||||
|
experiment: experimentForBand({ minHz: 1000, maxHz: 1050 }),
|
||||||
|
});
|
||||||
|
const truthIndex = 18;
|
||||||
|
assertEqual(baseband.bands[0].peak.index, truthIndex, "baseband physical truth minimizes q");
|
||||||
|
const truthQ = baseband.bands[0].rawMap[truthIndex];
|
||||||
|
assert(
|
||||||
|
baseband.bands[0].rawMap.every((value) => value >= truthQ),
|
||||||
|
"truth q is the global minimum",
|
||||||
|
);
|
||||||
|
|
||||||
|
const aliased = await computeLocalizationMap({
|
||||||
|
...common,
|
||||||
|
channels: channelsForAnalogFrequency(invertedAnalogFrequency),
|
||||||
|
experiment: experimentForBand({ minHz: 7140, maxHz: 7190 }),
|
||||||
|
});
|
||||||
|
assertEqual(aliased.bands[0].bins.join(","), "16", "analog carrier folds to observed bin 16");
|
||||||
|
assertEqual(
|
||||||
|
aliased.bands[0].observedFrequencyHz,
|
||||||
|
basebandFrequency,
|
||||||
|
"covariance still uses the observed ADC frequency",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliased.bands[0].analogFrequencyHz,
|
||||||
|
invertedAnalogFrequency,
|
||||||
|
"manifold uses the configured analog carrier",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
aliased.bands[0].signedAdcFrequencyHz,
|
||||||
|
-basebandFrequency,
|
||||||
|
"inverted signed ADC frequency is explicit",
|
||||||
|
);
|
||||||
|
assert(aliased.bands[0].spectralInversion, "full received manifold is conjugated");
|
||||||
|
const aliasedTruthQ = aliased.bands[0].rawMap[truthIndex];
|
||||||
|
const aliasedMinimumQ = Math.min(...aliased.bands[0].rawMap);
|
||||||
|
assert(
|
||||||
|
Math.abs(aliasedTruthQ - aliasedMinimumQ) <= 1e-10,
|
||||||
|
"physical truth is a q minimum while grating-lobe ties remain allowed",
|
||||||
|
);
|
||||||
|
assertEqual(aliased.model.originalCarrierUsed, true, "configured carrier is used");
|
||||||
|
assertEqual(
|
||||||
|
aliased.model.aliasOrderResolution,
|
||||||
|
"configured-single-hypothesis-no-selection",
|
||||||
|
"no data-driven alias-order choice is claimed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC conjugates analog microphone phase and preserves capped residual gain in inverted zones", () => {
|
||||||
|
const arrayDefinition = {
|
||||||
|
microphones: [{
|
||||||
|
id: "calibrated",
|
||||||
|
position: [0, 0, 0],
|
||||||
|
phaseOffsetDeg: 20,
|
||||||
|
frequencyResponse: [
|
||||||
|
{ frequencyHz: 3_000, gainDb: 0, phaseDeg: 0 },
|
||||||
|
{ frequencyHz: 5_000, gainDb: -30, phaseDeg: 40 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const corrected = createMusicBinCalibration({
|
||||||
|
arrayDefinition,
|
||||||
|
bin: 48,
|
||||||
|
sampleRate: 8_000,
|
||||||
|
fftSize: 128,
|
||||||
|
analogFrequencyHz: 5_000,
|
||||||
|
signedAdcFrequencyHz: -3_000,
|
||||||
|
spectralInversion: true,
|
||||||
|
compensateFrequencyResponse: true,
|
||||||
|
maxCompensationDb: 24,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
Math.abs(corrected.dataPhases[0] - Math.PI / 3) <= 1e-12,
|
||||||
|
"inverted positive-bin calibration conjugates 20° + 40°",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(
|
||||||
|
20 * Math.log10(corrected.manifoldGains[0]) + 6
|
||||||
|
) <= 1e-9,
|
||||||
|
"-30 dB response with 24 dB inverse cap leaves -6 dB",
|
||||||
|
);
|
||||||
|
assertEqual(corrected.manifoldPhases[0], 0, "compensated phase has no residual");
|
||||||
|
|
||||||
|
const raw = createMusicBinCalibration({
|
||||||
|
arrayDefinition,
|
||||||
|
bin: 48,
|
||||||
|
sampleRate: 8_000,
|
||||||
|
fftSize: 128,
|
||||||
|
analogFrequencyHz: 5_000,
|
||||||
|
signedAdcFrequencyHz: -3_000,
|
||||||
|
spectralInversion: true,
|
||||||
|
compensateFrequencyResponse: false,
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
Math.abs(raw.dataPhases[0] - 20 * Math.PI / 180) <= 1e-12,
|
||||||
|
"fixed phase offset correction follows inversion sign",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Math.abs(raw.manifoldPhases[0] + 40 * Math.PI / 180) <= 1e-12,
|
||||||
|
"uncompensated analog response phase remains conjugated in the manifold",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable MUSIC plan retains its physical and source-count configuration", () => {
|
||||||
|
const fixture = createFixture("music");
|
||||||
|
const plan = createLocalizationPlan({
|
||||||
|
sampleRate: fixture.sampleRate,
|
||||||
|
array: fixture.arrayDefinition,
|
||||||
|
scan: fixture.experiment.scan,
|
||||||
|
bands: fixture.experiment.frequencyBands,
|
||||||
|
speedOfSound: fixture.scene.speedOfSound,
|
||||||
|
stft: fixture.experiment.stft,
|
||||||
|
algorithm: "music",
|
||||||
|
minimumDistance: 0.025,
|
||||||
|
musicSourceCountMethod: "fixed",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
});
|
||||||
|
assertEqual(plan.normalization, "none", "portable MUSIC defaults to no map normalization");
|
||||||
|
assertEqual(plan.minimumDistance, 0.025, "minimum distance is retained");
|
||||||
|
assertEqual(plan.experiment.beamforming.musicSourceCount, 1, "fixed K reaches core experiment");
|
||||||
|
assertEqual(plan.musicPlan, plan.analysisPlan, "portable MUSIC plan has a dedicated alias");
|
||||||
|
assert(!("gccPhatPlan" in plan), "MUSIC is not mislabeled as a GCC plan");
|
||||||
|
assert(!("crossSpectrumBatchSize" in plan.computeOptions), "GCC-only batching is omitted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC rejects peak normalization at both direct and portable API boundaries", () => {
|
||||||
|
const direct = createFixture("music");
|
||||||
|
direct.experiment.beamforming.normalization = "peak";
|
||||||
|
let directError;
|
||||||
|
try {
|
||||||
|
createLocalizationAnalysisPlan(direct);
|
||||||
|
} catch (error) {
|
||||||
|
directError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
directError instanceof RangeError &&
|
||||||
|
directError.message.includes("normalization"),
|
||||||
|
"direct core rejects peak normalization",
|
||||||
|
);
|
||||||
|
|
||||||
|
let portableError;
|
||||||
|
try {
|
||||||
|
createLocalizationPlan({
|
||||||
|
sampleRate: direct.sampleRate,
|
||||||
|
array: direct.arrayDefinition,
|
||||||
|
scan: direct.experiment.scan,
|
||||||
|
bands: direct.experiment.frequencyBands,
|
||||||
|
speedOfSound: direct.scene.speedOfSound,
|
||||||
|
stft: direct.experiment.stft,
|
||||||
|
algorithm: "music",
|
||||||
|
normalization: "peak",
|
||||||
|
musicSourceCount: 1,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
portableError = error;
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
portableError instanceof RangeError &&
|
||||||
|
portableError.message.includes("normalization"),
|
||||||
|
"portable API rejects peak normalization",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC MDL/AIC workload excludes a zero-padded partial snapshot", () => {
|
||||||
|
const fixture = createFixture("music");
|
||||||
|
const completeSampleCount = 6 * fixture.experiment.stft.fftSize;
|
||||||
|
const partialSampleCount = completeSampleCount + 10;
|
||||||
|
fixture.channels = fixture.channels.map((channel) =>
|
||||||
|
Float64Array.from(
|
||||||
|
{ length: partialSampleCount },
|
||||||
|
(_, index) => channel[index % channel.length],
|
||||||
|
));
|
||||||
|
fixture.experiment.stft.hopSize = fixture.experiment.stft.fftSize;
|
||||||
|
fixture.experiment.beamforming.musicSourceCountMethod = "mdl";
|
||||||
|
delete fixture.experiment.beamforming.musicSourceCount;
|
||||||
|
|
||||||
|
const workload = estimateLocalizationWorkload(fixture);
|
||||||
|
assertEqual(workload.frameCount, 6, "only six complete non-overlapping frames");
|
||||||
|
assertEqual(
|
||||||
|
workload.sampleCount,
|
||||||
|
partialSampleCount,
|
||||||
|
"input length remains disclosed without treating its tail as a snapshot",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("portable and streaming API accept an algorithm choice without Scene synthesis", async () => {
|
test("portable and streaming API accept an algorithm choice without Scene synthesis", async () => {
|
||||||
const fixture = createFixture("gcc-phat");
|
const fixture = createFixture("gcc-phat");
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
import {
|
||||||
|
auditCovarianceEigenvalues,
|
||||||
|
computeComplexSampleCovariance,
|
||||||
|
selectWaxKailathSourceCount,
|
||||||
|
} from "/packages/acoustics-core/covariance.js";
|
||||||
|
import {
|
||||||
|
decomposeHermitian,
|
||||||
|
estimateHermitianEigenWorkload,
|
||||||
|
} from "/packages/acoustics-core/hermitian-eigen.js";
|
||||||
|
import {
|
||||||
|
computeMusicMap,
|
||||||
|
estimateMusicWorkload,
|
||||||
|
} from "/packages/acoustics-core/music.js";
|
||||||
|
|
||||||
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
|
assert(
|
||||||
|
Math.abs(actual - expected) <= tolerance,
|
||||||
|
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertArrayNear(actual, expected, tolerance, label) {
|
||||||
|
assertEqual(actual.length, expected.length, `${label} length`);
|
||||||
|
for (let index = 0; index < actual.length; index += 1) {
|
||||||
|
assertNear(actual[index], expected[index], tolerance, `${label}[${index}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dftUnitary(order) {
|
||||||
|
const real = new Float64Array(order * order);
|
||||||
|
const imaginary = new Float64Array(order * order);
|
||||||
|
const scale = 1 / Math.sqrt(order);
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
for (let column = 0; column < order; column += 1) {
|
||||||
|
const phase = (2 * Math.PI * row * column) / order;
|
||||||
|
const index = row * order + column;
|
||||||
|
real[index] = Math.cos(phase) * scale;
|
||||||
|
imaginary[index] = Math.sin(phase) * scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { real, imaginary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hermitianFromEigenpairs(vectors, eigenvalues) {
|
||||||
|
const order = eigenvalues.length;
|
||||||
|
const real = new Float64Array(order * order);
|
||||||
|
const imaginary = new Float64Array(order * order);
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
for (let column = row; column < order; column += 1) {
|
||||||
|
let valueReal = 0;
|
||||||
|
let valueImaginary = 0;
|
||||||
|
for (let eigenvector = 0; eigenvector < order; eigenvector += 1) {
|
||||||
|
const rowIndex = row * order + eigenvector;
|
||||||
|
const columnIndex = column * order + eigenvector;
|
||||||
|
const rowReal = vectors.real[rowIndex];
|
||||||
|
const rowImaginary = vectors.imaginary[rowIndex];
|
||||||
|
const columnReal = vectors.real[columnIndex];
|
||||||
|
const columnImaginary = vectors.imaginary[columnIndex];
|
||||||
|
valueReal += eigenvalues[eigenvector] *
|
||||||
|
(rowReal * columnReal + rowImaginary * columnImaginary);
|
||||||
|
valueImaginary += eigenvalues[eigenvector] *
|
||||||
|
(rowImaginary * columnReal - rowReal * columnImaginary);
|
||||||
|
}
|
||||||
|
const upper = row * order + column;
|
||||||
|
const lower = column * order + row;
|
||||||
|
real[upper] = valueReal;
|
||||||
|
imaginary[upper] = row === column ? 0 : valueImaginary;
|
||||||
|
real[lower] = valueReal;
|
||||||
|
imaginary[lower] = row === column ? 0 : -valueImaginary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { real, imaginary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projector(vectors, order, firstColumn, columnCount) {
|
||||||
|
const real = new Float64Array(order * order);
|
||||||
|
const imaginary = new Float64Array(order * order);
|
||||||
|
for (let row = 0; row < order; row += 1) {
|
||||||
|
for (let column = 0; column < order; column += 1) {
|
||||||
|
let valueReal = 0;
|
||||||
|
let valueImaginary = 0;
|
||||||
|
for (let vector = firstColumn; vector < firstColumn + columnCount; vector += 1) {
|
||||||
|
const rowIndex = row * order + vector;
|
||||||
|
const columnIndex = column * order + vector;
|
||||||
|
valueReal +=
|
||||||
|
vectors.real[rowIndex] * vectors.real[columnIndex] +
|
||||||
|
vectors.imaginary[rowIndex] * vectors.imaginary[columnIndex];
|
||||||
|
valueImaginary +=
|
||||||
|
vectors.imaginary[rowIndex] * vectors.real[columnIndex] -
|
||||||
|
vectors.real[rowIndex] * vectors.imaginary[columnIndex];
|
||||||
|
}
|
||||||
|
const index = row * order + column;
|
||||||
|
real[index] = valueReal;
|
||||||
|
imaginary[index] = valueImaginary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { real, imaginary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutomaticMusicFixture() {
|
||||||
|
const sampleRate = 3_200;
|
||||||
|
const fftSize = 32;
|
||||||
|
const snapshotCount = 64;
|
||||||
|
const frequencyHz = 500;
|
||||||
|
const speedOfSound = 343;
|
||||||
|
const source = [0.08, -0.04, 1];
|
||||||
|
const arrayDefinition = {
|
||||||
|
id: "automatic-music-audit-array",
|
||||||
|
microphones: [
|
||||||
|
{ id: "nw", position: [-0.1, 0.1, 0] },
|
||||||
|
{ id: "ne", position: [0.1, 0.1, 0] },
|
||||||
|
{ id: "sw", position: [-0.1, -0.1, 0] },
|
||||||
|
{ id: "se", position: [0.1, -0.1, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let state = 0x51f15e5d;
|
||||||
|
const random = () => {
|
||||||
|
state ^= state << 13;
|
||||||
|
state ^= state >>> 17;
|
||||||
|
state ^= state << 5;
|
||||||
|
return ((state >>> 0) / 0xffff_ffff) * 2 - 1;
|
||||||
|
};
|
||||||
|
const sourceRealBySnapshot = Float64Array.from(
|
||||||
|
{ length: snapshotCount },
|
||||||
|
random,
|
||||||
|
);
|
||||||
|
const sourceImaginaryBySnapshot = Float64Array.from(
|
||||||
|
{ length: snapshotCount },
|
||||||
|
random,
|
||||||
|
);
|
||||||
|
const channels = arrayDefinition.microphones.map((microphone) => {
|
||||||
|
const distance = Math.hypot(
|
||||||
|
source[0] - microphone.position[0],
|
||||||
|
source[1] - microphone.position[1],
|
||||||
|
source[2] - microphone.position[2],
|
||||||
|
);
|
||||||
|
const propagationPhase = -(2 * Math.PI * frequencyHz * distance) / speedOfSound;
|
||||||
|
const channel = new Float64Array(snapshotCount * fftSize);
|
||||||
|
for (let snapshot = 0; snapshot < snapshotCount; snapshot += 1) {
|
||||||
|
const sourceReal = sourceRealBySnapshot[snapshot];
|
||||||
|
const sourceImaginary = sourceImaginaryBySnapshot[snapshot];
|
||||||
|
for (let offset = 0; offset < fftSize; offset += 1) {
|
||||||
|
const phase = (2 * Math.PI * frequencyHz * offset) / sampleRate + propagationPhase;
|
||||||
|
channel[snapshot * fftSize + offset] =
|
||||||
|
4 * (sourceReal * Math.cos(phase) - sourceImaginary * Math.sin(phase)) /
|
||||||
|
distance +
|
||||||
|
0.08 * random();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
});
|
||||||
|
const experiment = {
|
||||||
|
stft: { fftSize, hopSize: fftSize, window: "hann" },
|
||||||
|
scan: {
|
||||||
|
z: source[2],
|
||||||
|
xMin: source[0],
|
||||||
|
xMax: source[0],
|
||||||
|
yMin: source[1],
|
||||||
|
yMax: source[1],
|
||||||
|
columns: 1,
|
||||||
|
rows: 1,
|
||||||
|
},
|
||||||
|
frequencyBands: [
|
||||||
|
{ id: "tone", minHz: 499, maxHz: 501, color: "#65f2bf" },
|
||||||
|
],
|
||||||
|
beamforming: {
|
||||||
|
algorithm: "music",
|
||||||
|
backend: "cpu",
|
||||||
|
normalization: "none",
|
||||||
|
compensateFrequencyResponse: true,
|
||||||
|
musicSourceCountMethod: "mdl",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
channels,
|
||||||
|
sampleRate,
|
||||||
|
arrayDefinition,
|
||||||
|
scene: { speedOfSound },
|
||||||
|
experiment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("complex Hermitian Jacobi resolves the [4, 1] two-by-two reference", () => {
|
||||||
|
const result = decomposeHermitian({
|
||||||
|
real: Float64Array.of(2, 1, 1, 3),
|
||||||
|
imaginary: Float64Array.of(0, 1, -1, 0),
|
||||||
|
});
|
||||||
|
assert(result.converged, "2x2 Jacobi convergence");
|
||||||
|
assertArrayNear(result.eigenvalues, [4, 1], 2e-14, "2x2 eigenvalues");
|
||||||
|
assertEqual(result.eigenvectors.layout, "row-major-columns", "vector layout");
|
||||||
|
assert(result.relativeResidual < 1e-14, "2x2 relative residual");
|
||||||
|
assert(result.orthogonalityError < 1e-14, "2x2 orthogonality");
|
||||||
|
assert(result.numericallyVerified, "2x2 numerical verification");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hermitian Jacobi diagonalizes a constructed DFT4 matrix", () => {
|
||||||
|
const vectors = dftUnitary(4);
|
||||||
|
const matrix = hermitianFromEigenpairs(vectors, [9, 4, 2, 1]);
|
||||||
|
const result = decomposeHermitian(matrix);
|
||||||
|
assert(result.converged, "DFT4 convergence");
|
||||||
|
assertArrayNear(result.eigenvalues, [9, 4, 2, 1], 2e-12, "DFT4 eigenvalues");
|
||||||
|
assert(result.relativeResidual < 2e-13, "DFT4 relative residual");
|
||||||
|
assert(result.relativeOffDiagonal < result.relativeTolerance, "DFT4 off-diagonal bound");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated Hermitian eigenspaces preserve their invariant projector", () => {
|
||||||
|
const expectedVectors = dftUnitary(4);
|
||||||
|
const matrix = hermitianFromEigenpairs(expectedVectors, [5, 5, 1, 1]);
|
||||||
|
const result = decomposeHermitian(matrix);
|
||||||
|
assert(result.converged, "repeated-eigenspace convergence");
|
||||||
|
assertArrayNear(result.eigenvalues, [5, 5, 1, 1], 2e-12, "repeated eigenvalues");
|
||||||
|
const expectedProjector = projector(expectedVectors, 4, 0, 2);
|
||||||
|
const actualProjector = projector(result.eigenvectors, 4, 0, 2);
|
||||||
|
assertArrayNear(actualProjector.real, expectedProjector.real, 2e-12, "projector real");
|
||||||
|
assertArrayNear(
|
||||||
|
actualProjector.imaginary,
|
||||||
|
expectedProjector.imaginary,
|
||||||
|
2e-12,
|
||||||
|
"projector imaginary",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hermitian eigenpairs are deterministic and phase-canonicalized", () => {
|
||||||
|
const matrix = hermitianFromEigenpairs(dftUnitary(4), [12, 7, 3, 0.5]);
|
||||||
|
const first = decomposeHermitian(matrix);
|
||||||
|
const second = decomposeHermitian(matrix);
|
||||||
|
assertArrayNear(second.eigenvalues, first.eigenvalues, 0, "deterministic eigenvalues");
|
||||||
|
assertArrayNear(second.eigenvectors.real, first.eigenvectors.real, 0, "deterministic real vectors");
|
||||||
|
assertArrayNear(
|
||||||
|
second.eigenvectors.imaginary,
|
||||||
|
first.eigenvectors.imaginary,
|
||||||
|
0,
|
||||||
|
"deterministic imaginary vectors",
|
||||||
|
);
|
||||||
|
for (let column = 0; column < first.order; column += 1) {
|
||||||
|
let maximumMagnitude = 0;
|
||||||
|
for (let row = 0; row < first.order; row += 1) {
|
||||||
|
const index = row * first.order + column;
|
||||||
|
const magnitude = Math.hypot(
|
||||||
|
first.eigenvectors.real[index],
|
||||||
|
first.eigenvectors.imaginary[index],
|
||||||
|
);
|
||||||
|
maximumMagnitude = Math.max(maximumMagnitude, magnitude);
|
||||||
|
}
|
||||||
|
let canonicalPivotFound = false;
|
||||||
|
for (let row = 0; row < first.order; row += 1) {
|
||||||
|
const index = row * first.order + column;
|
||||||
|
const magnitude = Math.hypot(
|
||||||
|
first.eigenvectors.real[index],
|
||||||
|
first.eigenvectors.imaginary[index],
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
magnitude >= maximumMagnitude * (1 - 16 * Number.EPSILON) &&
|
||||||
|
first.eigenvectors.real[index] > 0 &&
|
||||||
|
first.eigenvectors.imaginary[index] === 0
|
||||||
|
) {
|
||||||
|
canonicalPivotFound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(canonicalPivotFound, `column ${column} has a positive-real maximal pivot`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hermitian Jacobi reports non-convergence without returning it as success", () => {
|
||||||
|
const result = decomposeHermitian({
|
||||||
|
real: Float64Array.of(
|
||||||
|
4, 1, 0.5,
|
||||||
|
1, 3, 1.5,
|
||||||
|
0.5, 1.5, 2,
|
||||||
|
),
|
||||||
|
imaginary: Float64Array.of(
|
||||||
|
0, 0.5, -0.3,
|
||||||
|
-0.5, 0, 0.7,
|
||||||
|
0.3, -0.7, 0,
|
||||||
|
),
|
||||||
|
maximumSweeps: 1,
|
||||||
|
});
|
||||||
|
assert(!result.converged, "one-sweep result is explicitly unconverged");
|
||||||
|
assertEqual(result.sweeps, 1, "sweep limit is exact");
|
||||||
|
assert(!result.numericallyVerified, "partial eigenpairs are not verified");
|
||||||
|
assert(result.relativeResidual > result.relativeTolerance, "unconverged residual remains visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hermitian workload rejects M=1024 before reading or allocating matrix data", () => {
|
||||||
|
const order = 1024;
|
||||||
|
const workload = estimateHermitianEigenWorkload({ order });
|
||||||
|
assert(workload.allocationBytes > 16 * 1024 * 1024, "M=1024 exceeds default bytes");
|
||||||
|
let numericRead = false;
|
||||||
|
const fakeMatrix = new Proxy(
|
||||||
|
{ length: order * order },
|
||||||
|
{
|
||||||
|
get(target, property) {
|
||||||
|
if (typeof property === "string" && /^\d+$/.test(property)) numericRead = true;
|
||||||
|
return target[property];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
decomposeHermitian({ real: fakeMatrix, imaginary: fakeMatrix, order });
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
assert(error instanceof RangeError, "M=1024 is rejected by the workload cap");
|
||||||
|
assert(!numericRead, "workload cap fires before matrix values are read");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sample covariance uses compensated upper-triangle accumulation and exact conjugates", () => {
|
||||||
|
const cancellation = computeComplexSampleCovariance({
|
||||||
|
sensorCount: 2,
|
||||||
|
snapshotCount: 3,
|
||||||
|
layout: "snapshot-major",
|
||||||
|
real: Float64Array.of(1, 1e16, 1, 1, 1, -1e16),
|
||||||
|
imaginary: new Float64Array(6),
|
||||||
|
});
|
||||||
|
assertNear(cancellation.matrix.real[1], 1 / 3, 1e-16, "compensated cross covariance");
|
||||||
|
|
||||||
|
const complex = computeComplexSampleCovariance({
|
||||||
|
sensorCount: 2,
|
||||||
|
snapshotCount: 1,
|
||||||
|
real: Float64Array.of(1, 2),
|
||||||
|
imaginary: Float64Array.of(1, 3),
|
||||||
|
});
|
||||||
|
assertNear(complex.matrix.real[1], 5, 0, "upper real");
|
||||||
|
assertNear(complex.matrix.imaginary[1], -1, 0, "upper imaginary");
|
||||||
|
assertNear(complex.matrix.real[2], 5, 0, "lower real");
|
||||||
|
assertNear(complex.matrix.imaginary[2], 1, 0, "exact conjugate lower imaginary");
|
||||||
|
assertEqual(complex.matrix.imaginary[0], 0, "real diagonal 0");
|
||||||
|
assertEqual(complex.normalization, "1/T", "covariance normalization disclosed");
|
||||||
|
assertEqual(complex.centered, false, "covariance is not silently centered");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("T<M rank-two covariance remains rank-deficient and source count is unavailable", () => {
|
||||||
|
const sensorCount = 4;
|
||||||
|
const snapshotCount = 2;
|
||||||
|
const covariance = computeComplexSampleCovariance({
|
||||||
|
sensorCount,
|
||||||
|
snapshotCount,
|
||||||
|
real: Float64Array.of(1, 2, 3, 4, 0, 1, 2, -1),
|
||||||
|
imaginary: Float64Array.of(0, 0, 0, 0, 1, -1, 0.5, 2),
|
||||||
|
});
|
||||||
|
const decomposition = decomposeHermitian({
|
||||||
|
...covariance.matrix,
|
||||||
|
order: sensorCount,
|
||||||
|
});
|
||||||
|
assert(decomposition.converged, "rank-two covariance decomposition");
|
||||||
|
const audit = auditCovarianceEigenvalues({
|
||||||
|
eigenvalues: decomposition.eigenvalues,
|
||||||
|
sensorCount,
|
||||||
|
matrixScale: covariance.matrixScale,
|
||||||
|
covarianceRoundoffBound: covariance.covarianceRoundoffBound,
|
||||||
|
eigensolverResidualFrobeniusNorm: decomposition.residualFrobeniusNorm,
|
||||||
|
});
|
||||||
|
assertEqual(
|
||||||
|
audit.roundoffDerivation,
|
||||||
|
"covariance-accumulation-plus-eigensolver-residual",
|
||||||
|
"PSD roundoff derivation",
|
||||||
|
);
|
||||||
|
assertNear(audit.roundoffClampedEigenvalues[2], 0, audit.roundoffTolerance, "rank eigenvalue 2");
|
||||||
|
assertNear(audit.roundoffClampedEigenvalues[3], 0, audit.roundoffTolerance, "rank eigenvalue 3");
|
||||||
|
const selected = selectWaxKailathSourceCount({
|
||||||
|
eigenvalues: audit.roundoffClampedEigenvalues,
|
||||||
|
snapshotCount,
|
||||||
|
independentSnapshots: true,
|
||||||
|
criterion: "mdl",
|
||||||
|
roundoffTolerance: audit.roundoffTolerance,
|
||||||
|
});
|
||||||
|
assert(!selected.available, "T<M MDL unavailable");
|
||||||
|
assertEqual(
|
||||||
|
selected.reason,
|
||||||
|
"snapshot-count-not-greater-than-sensors",
|
||||||
|
"T<M reason",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wax-Kailath explicitly declines noiseless rank-deficient spectra", () => {
|
||||||
|
const result = selectWaxKailathSourceCount({
|
||||||
|
eigenvalues: Float64Array.of(5, 2, 0, 0),
|
||||||
|
snapshotCount: 100,
|
||||||
|
independentSnapshots: true,
|
||||||
|
criterion: "mdl",
|
||||||
|
roundoffTolerance: 1e-12,
|
||||||
|
});
|
||||||
|
assert(!result.available, "rank-deficient MDL unavailable");
|
||||||
|
assertEqual(
|
||||||
|
result.reason,
|
||||||
|
"noise-eigenvalue-not-strictly-above-roundoff",
|
||||||
|
"rank-deficient reason",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wax-Kailath AIC and MDL select q=1 for [10,1,1,1]", () => {
|
||||||
|
for (const criterion of ["aic", "mdl"]) {
|
||||||
|
const result = selectWaxKailathSourceCount({
|
||||||
|
eigenvalues: Float64Array.of(10, 1, 1, 1),
|
||||||
|
snapshotCount: 100,
|
||||||
|
independentSnapshots: true,
|
||||||
|
criterion,
|
||||||
|
roundoffTolerance: 1e-14,
|
||||||
|
});
|
||||||
|
assert(result.available, `${criterion} available`);
|
||||||
|
assertEqual(result.sourceCount, 1, `${criterion} source count`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wax-Kailath AIC and MDL select q=0 for an identity covariance", () => {
|
||||||
|
for (const criterion of ["aic", "mdl"]) {
|
||||||
|
const result = selectWaxKailathSourceCount({
|
||||||
|
eigenvalues: Float64Array.of(1, 1, 1, 1),
|
||||||
|
snapshotCount: 100,
|
||||||
|
independentSnapshots: true,
|
||||||
|
criterion,
|
||||||
|
roundoffTolerance: 1e-14,
|
||||||
|
});
|
||||||
|
assert(result.available, `${criterion} available`);
|
||||||
|
assertEqual(result.sourceCount, 0, `${criterion} identity source count`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PSD audit preserves tiny positives and rejects genuine negative eigenvalues", () => {
|
||||||
|
const tinyPositive = 1e-20;
|
||||||
|
const audit = auditCovarianceEigenvalues({
|
||||||
|
eigenvalues: Float64Array.of(10, 1, tinyPositive, -1e-14),
|
||||||
|
sensorCount: 4,
|
||||||
|
matrixScale: 10,
|
||||||
|
roundoffTolerance: 1e-12,
|
||||||
|
});
|
||||||
|
assertEqual(audit.rawEigenvalues[2], tinyPositive, "raw tiny positive");
|
||||||
|
assertEqual(
|
||||||
|
audit.roundoffClampedEigenvalues[2],
|
||||||
|
tinyPositive,
|
||||||
|
"tiny positive is not floored",
|
||||||
|
);
|
||||||
|
assertEqual(audit.roundoffClampedEigenvalues[3], 0, "roundoff negative clamps to zero");
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
auditCovarianceEigenvalues({
|
||||||
|
eigenvalues: Float64Array.of(10, 1, 0.1, -1e-3),
|
||||||
|
sensorCount: 4,
|
||||||
|
roundoffTolerance: 1e-12,
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
assert(error instanceof RangeError, "genuine negative eigenvalue is rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC workload includes PSD-audit and automatic source-count typed arrays", () => {
|
||||||
|
const automatic = createAutomaticMusicFixture();
|
||||||
|
const automaticWorkload = estimateMusicWorkload(automatic);
|
||||||
|
const microphoneCount = automatic.arrayDefinition.microphones.length;
|
||||||
|
const fixed = {
|
||||||
|
...automatic,
|
||||||
|
experiment: structuredClone(automatic.experiment),
|
||||||
|
};
|
||||||
|
fixed.experiment.beamforming.musicSourceCountMethod = "fixed";
|
||||||
|
fixed.experiment.beamforming.musicSourceCount = 1;
|
||||||
|
const fixedWorkload = estimateMusicWorkload(fixed);
|
||||||
|
|
||||||
|
assertEqual(
|
||||||
|
automaticWorkload.covarianceAuditBytes,
|
||||||
|
2 * microphoneCount * Float64Array.BYTES_PER_ELEMENT,
|
||||||
|
"raw and roundoff-clamped PSD audit arrays",
|
||||||
|
);
|
||||||
|
assertEqual(fixedWorkload.sourceCountSelectionBytes, 0, "fixed K has no score array");
|
||||||
|
assertEqual(
|
||||||
|
automaticWorkload.sourceCountSelectionBytes,
|
||||||
|
microphoneCount * Float64Array.BYTES_PER_ELEMENT,
|
||||||
|
"automatic K criterion score array",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
automaticWorkload.perBinNumericalBytes - fixedWorkload.perBinNumericalBytes,
|
||||||
|
automaticWorkload.sourceCountSelectionBytes,
|
||||||
|
"automatic score bytes reach per-bin numerical peak",
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
automaticWorkload.baseWorkingBytes - fixedWorkload.baseWorkingBytes,
|
||||||
|
automaticWorkload.sourceCountSelectionBytes,
|
||||||
|
"automatic score bytes reach total working peak",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MUSIC reports PSD derivation and labels MDL independence as an unverified model assumption", async () => {
|
||||||
|
const result = await computeMusicMap(createAutomaticMusicFixture());
|
||||||
|
const diagnostics = result.bands[0].diagnostics;
|
||||||
|
assertEqual(diagnostics.sourceCount.method, "mdl", "MDL selected model order");
|
||||||
|
assertEqual(diagnostics.sourceCount.value, 1, "one dominant source selected");
|
||||||
|
assertEqual(
|
||||||
|
diagnostics.covarianceAudit.roundoffDerivation,
|
||||||
|
"covariance-accumulation-plus-eigensolver-residual",
|
||||||
|
"PSD roundoff derivation is serialized from the audit API",
|
||||||
|
);
|
||||||
|
const independence =
|
||||||
|
diagnostics.sourceCount.assumptions.statisticalSnapshotIndependence;
|
||||||
|
assertEqual(independence.assumed, true, "statistical independence is an assumption");
|
||||||
|
assertEqual(
|
||||||
|
independence.evidence,
|
||||||
|
"non-overlapping-frames-only-not-statistical-proof",
|
||||||
|
"non-overlap is not mislabeled as statistical proof",
|
||||||
|
);
|
||||||
|
assertEqual(independence.modelSelectedByUser, true, "MDL model was selected by the user");
|
||||||
|
assertEqual(
|
||||||
|
diagnostics.sourceCount.assumptions.statisticalDataModel.verified,
|
||||||
|
false,
|
||||||
|
"Wax-Kailath data model is not claimed as verified",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -165,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user