feat: add physically explicit narrowband MUSIC

This commit is contained in:
2026-08-29 12:40:48 +09:00
parent 044be3cf92
commit 11fee76280
40 changed files with 6473 additions and 275 deletions
+44 -9
View File
@@ -53,6 +53,9 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
같은 입력에서 **위치 추정 알고리즘**만 DAS-PHAT으로 바꿔 다시 실행하면 아래 비교 기록에 두 결과가
나란히 남습니다. GCC-PHAT은 광대역 입력에서 비교하세요.
MUSIC을 바로 확인하려면 현재 장면 제목 옆 **MUSIC 빠른 예제**를 누른 뒤
**▶ Snapshot 실행**만 누르면 됩니다. 12채널·1.125 kHz 단일-bin·Fixed K=1 설정이 함께 열립니다.
실제 완료 시간은 브라우저와 장치에 따라 다릅니다. CPU fallback에서는 WebGPU보다 오래 걸릴 수
있습니다.
@@ -114,7 +117,8 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
1. 홈으로 돌아가 **Acoustic Camera**를 엽니다.
2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment는 어느
시점에 열어도 모든 분석 대역을 유지하고 현재 Scene·Array에 자동으로 연결됩니다.
시점에 열어도 모든 분석 대역과 원래의 `sceneId`/`arrayId` provenance label을 유지하며,
실행에는 현재 열린 Scene·Array가 사용됩니다.
3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다.
4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다.
5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다.
@@ -123,9 +127,9 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다.
8. 완료 후 **Result JSON**을 저장합니다.
Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사한 뒤 `sceneId`와 `arrayId`를
현재 열린 Scene·Array에 자동으로 연결합니다. 가져온 `frequencyBands`와 나머지 분석 설정은
보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행합니다. Nyquist 위 tone과
Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사하고 가져온 `sceneId`,
`arrayId`, `frequencyBands`와 나머지 분석 설정을 그대로 보존합니다. 두 ID가 현재 입력과
달라도 거부하지 않으며, Result의 ID와 hash에는 실제 실행에 사용한 Scene·Array를 기록합니다. Nyquist 위 tone과
band는 오류가 아니라 ideal ADC alias 실험으로 허용되며, 원 주파수와 실제 ADC FFT range가 UI와
Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제 오류는 그대로 거부됩니다. Scene을
다시 불러오면 이전 asset 연결은 해제됩니다.
@@ -133,7 +137,7 @@ Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제
Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에
유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와
Scene 또는 Array를 선택하세요. 같은 Camera 페이지에서 Scene을 교체하면 scan 영역과
`sceneId`만 맞추며 기존 `frequencyBands`는 보존합니다.
plane을 새 Scene에 맞추되 Experiment의 provenance ID와 기존 `frequencyBands`는 보존합니다.
Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
@@ -157,6 +161,7 @@ SHA-256 manifest를 저장합니다. 브라우저는 새 페이지나 세션에
| DAS | 상대 acoustic power | CPU, WebGPU | 가장 단순한 기준선. 채널 gain과 알려진 마이크 응답을 보정합니다. |
| DAS-PHAT | 무차원 score | CPU, WebGPU | 마이크별 진폭을 버리고 위상 정합을 비교해 gain·반사에 더 강건할 수 있습니다. |
| 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를 사용할 수 없거나 지원되는 장치
한계를 넘으면 CPU로 계속합니다.
@@ -164,9 +169,21 @@ SHA-256 manifest를 저장합니다. 브라우저는 새 페이지나 세션에
오류를 표시합니다.
- **CPU 강제**: 비교와 호환성 확인을 위해 선택한 알고리즘의 CPU 구현을 사용합니다.
GCC-PHAT은 현재 CPU 전용이므로 선택하면 Auto 또는 CPU만 사용할 수 있습니다. Camera는 각 band가
GCC-PHAT과 MUSIC은 현재 CPU 전용이므로 선택하면 Auto 또는 CPU만 사용할 수 있습니다. Camera는 각 band가
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로 보면
100×100은 4배, 200×200은 16배입니다. 처음에는 50×50을 사용하고 해상도가 필요할 때만 높이세요.
실행 시간은 microphone 수, scene 길이, FFT, 대역 폭과 장치에 함께 좌우됩니다.
@@ -217,6 +234,21 @@ scan 범위나 마이크 수 때문에 값이 단순히 커지는 현상은 막
main lobe보다 가깝거나 gain 차이가 큰 음원은 conventional DAS의 물리·알고리즘 한계 때문에
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는
@@ -243,7 +275,9 @@ channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부
Result JSON은 계약 버전 `1.1.0`의 `micarrsim.result` 문서입니다. Array·Scene·Experiment는
`1.0.0`을 유지합니다. 주요 내용은 다음과 같습니다.
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `algorithm`·`backend`와 생성 시각
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `algorithm`·`backend`와 생성 시각. 이 ID들은
provenance label이며 물리 호환성 판정값이 아닙니다. Camera는 가져온 Experiment를 현재
Scene·Array에 연결한 뒤 Result에 실제 실행 ID와 hash를 기록합니다.
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
- 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats`
- 원 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을 제한된
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC quantization·nonlinearity와
channel clock jitter·drift는 모델링하지 않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC,
DAMAS와 CLEAN-SC는 구현하지 않습니다.
channel clock jitter·drift는 모델링하지 않습니다. 위치 추정기는 DAS, DAS-PHAT, CPU GCC-PHAT와
CPU narrowband MUSIC을 제공하지만 full-pair SRP-PHAT, wideband MUSIC, DAMAS와 CLEAN-SC는
구현하지 않습니다.
따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
판단을 대신하지 않습니다.
+31 -10
View File
@@ -22,6 +22,9 @@ DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 g
3. 상단 작업 순서에서 입력·설정 상태를 확인한 뒤 Snapshot 카드의 **▶ Snapshot 실행**을 누릅니다.
4. 완료 후 overlay, 위치 오차와 timing을 확인하고 **Result JSON**을 저장합니다.
MUSIC은 현재 장면 제목 옆 **MUSIC 빠른 예제 → ▶ Snapshot 실행** 두 단계로 확인할 수 있습니다.
12채널 배열, 1.125 kHz 단일 FFT-grid 가설과 Fixed K=1 Experiment가 함께 열립니다.
이동 장면을 바로 보려면 Moving 카드에서 **이동 예제 열기 → Map 준비 → 재생** 순서로 누릅니다.
이동 예제는 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`
4. **Assets**: Scene이 참조하는 원본 이미지와 오디오
Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에 맞추고 기존 asset 연결을
해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 어느
시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에
자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist 위 tone·band는 ideal ADC
Scene을 불러오면 scan plane/bounds를 새 Scene에 맞추고 기존 asset 연결을 해제하지만
Experiment의 `sceneId` provenance label과 분석 대역은 보존합니다. Array를 불러와도
`arrayId` label을 덮어쓰지 않습니다. 별도 Experiment는 어느 시점에 불러와도 문서 자체를
먼저 검증하며 원래 `sceneId`/`arrayId`와 모든 분석 대역을 보존합니다. ID가 달라도 실행이
막히지 않고 현재 열린 Scene·Array가 실제 입력이 되며, Result에 그 실제 ID와 hash가 기록됩니다.
Nyquist 위 tone·band는 ideal ADC
alias로 실행되고, 잘못된 scan 범위 같은 실제 관계 오류는 그대로 거부됩니다.
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
@@ -53,7 +57,7 @@ Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라
## 위치 추정 알고리즘과 Backend
**분석 설정 → 위치 추정 알고리즘**에서 다음 세 구현을 선택합니다.
**분석 설정 → 위치 추정 알고리즘**에서 다음 네 구현을 선택합니다.
- **DAS**: 채널 진폭과 위상을 유지해 coherent sum power를 계산하는 기준선입니다. CPU와 WebGPU를
지원하고 `rawMap.scale`은 `power`입니다.
@@ -62,13 +66,20 @@ Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라
- **GCC-PHAT**: 기준 마이크와 나머지 마이크의 PHAT 상관에서 기하학적 TDOA를 평가합니다. 현재
CPU 전용이므로 Auto는 CPU를 선택하고 **WebGPU 강제**는 선택할 수 없습니다. 각 band가 8개
미만의 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로 비교하세요.
GCC-PHAT raw score는 pair·band coefficient·IFFT 크기를 정규화한 0–1 양의 상관 응답이라 scan
범위나 마이크 수에 따른 단순 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 부재나 지원되는 장치 한계가
있으면 CPU로 계속합니다. 라이브 준비에서는 첫 fallback 뒤 남은 frame이 같은 CPU analysis
plan을 재사용하므로 GPU 실패를 매 frame 반복하지 않습니다. GCC-PHAT에서는 **Auto (CPU)**로
@@ -129,6 +140,8 @@ cache를 표시하고 알고리즘별 위치추정 비용을 다음처럼 분리
- DAS/DAS-PHAT: scan point × microphone × STFT frame × 선택 bin의 complex MAC
- GCC-PHAT: 기준 마이크 pair별 two-pass cross-spectrum, pair × band inverse FFT와
scan point × pair × band correlation lookup
- MUSIC: hypothesis별 Float64 covariance, Hermitian Jacobi EVD와
scan point × microphone × noise-subspace projection
이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop,
band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
@@ -138,6 +151,12 @@ band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
항목은 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에
저장됩니다. 기본 25%에서 약한 두 번째 음원이 빠지면 10–20%로 낮추세요. 낮은 값은 더 약한 후보를
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
@@ -161,7 +180,8 @@ basin을 사용합니다. 선택한 표준 방식과 threshold는 Experiment와
남습니다.
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각
**JSON 저장** 버튼으로 보존할 수 있습니다.
**JSON 저장** 버튼으로 보존할 수 있습니다. Experiment의 `sceneId`/`arrayId`는 provenance
label이며, 현재 입력에 다시 연결한 뒤 실제 실행 ID와 hash가 Result에 기록됩니다.
- **전체 기록 JSON**은 세션에 남은 배열·알고리즘·backend 비교 요약을
`micarrsim.comparison-history` 문서로 저장합니다.
@@ -220,8 +240,9 @@ sensitivity/phase/frequency-response calibration은 적용하지만 결과는
실제 동기식 microphone `Float32Array`를 Scene 합성 없이 위치추정하려면
[실시간 위치추정 코어 포팅](../../docs/realtime-porting.md)의
`createLocalizationPlan`, `localizeMultichannel` 또는
`createStreamingLocalizer`를 사용하세요. portable config의 `algorithm`으로 같은 세 위치추정을
선택할 수 있습니다. 실시간성은 장치에서
`createStreamingLocalizer`를 사용하세요. portable config의 `algorithm`으로 같은 네 위치추정을
선택할 수 있습니다. MUSIC은 CPU 전용이며 fixed K 또는 엄격한 가정 아래 AIC/MDL을 사용합니다.
실시간성은 장치에서
`processingTimeMs <= deadlineMs`와 `deadlineMisses`·`droppedSamples`를
측정해 판정해야 합니다.
+8 -3
View File
@@ -40,12 +40,12 @@
<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="사용자 배열이 있을 때 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>
</div>
<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="asset-list" id="asset-list"></div>
</div>
@@ -89,13 +89,18 @@
</div>
<div class="quick-field-grid">
<label class="field field--wide"><span>위치 추정 알고리즘</span><select id="algorithm"><option value="das">DAS · 기준 power</option><option value="das-phat">DAS-PHAT · 위상 기반</option><option value="gcc-phat">GCC-PHAT · TDOA 기반 / CPU</option></select></label>
<label class="field 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>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" title="최강 peak 대비 검출 임계값입니다. 낮추면 약한 음원을 더 찾지만 sidelobe 오검출도 늘 수 있습니다."><span>Peak 임계값 (%)</span><input id="detection-threshold" type="number" min="1" max="100" step="1" /></label>
<label class="field" title="원시 map은 바꾸지 않고, 임계값 위 영역을 peak로 묶는 표준 방식을 선택합니다."><span>Peak 묶기</span><select id="detection-method"><option value="local-max">Local maxima</option><option value="connected-components">8-connected components</option><option value="watershed">Marker watershed</option></select></label>
</div>
<div class="algorithm-options" id="music-options" hidden>
<label class="field"><span>Source count 방식</span><select id="music-source-count-method"><option value="fixed">Fixed K</option><option value="mdl">Wax–Kailath MDL</option><option value="aic">Wax–Kailath AIC</option></select></label>
<label class="field"><span>Source count K</span><input id="music-source-count" type="number" min="1" max="1023" step="1" /></label>
<p id="music-options-note">각 band는 ADC FFT bin 하나만 선택합니다. Fixed K는 마이크 수보다 작아야 합니다.</p>
</div>
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
<p class="sampling-warning" id="sampling-warning" hidden></p>
+286 -41
View File
@@ -53,6 +53,14 @@ const MOVING_EXAMPLE_URLS = {
scene: "/assets/sample-scenes/moving-tone.scene.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([
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"],
@@ -91,6 +99,7 @@ const elements = {
livePanel: document.querySelector(".live-panel"),
liveStatus: byId("live-status"),
loadMovingExampleButton: byId("load-moving-example-button"),
loadMusicExampleButton: byId("load-music-example-button"),
prepareLiveButton: byId("prepare-live-button"),
livePlayButton: byId("live-play-button"),
liveTime: byId("live-time"),
@@ -119,6 +128,10 @@ const elements = {
frequencyResponseMode: byId("frequency-response-mode"),
algorithm: byId("algorithm"),
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"),
scanColumns: byId("scan-columns"),
scanRows: byId("scan-rows"),
@@ -217,6 +230,10 @@ function applyExperimentDefaults(experiment) {
if (experiment.evaluation.detectionMethod === undefined) {
experiment.evaluation.detectionMethod = "local-max";
}
if (experiment.beamforming?.algorithm === "music") {
experiment.beamforming.musicSourceCountMethod ??= "fixed";
experiment.beamforming.musicSourceCount ??= 1;
}
return experiment;
}
@@ -280,6 +297,7 @@ function renderLiveControls() {
const playing = Boolean(ready && state.live.playing);
const locked = Boolean(state.active) || state.pendingInputOperations > 0;
elements.loadMovingExampleButton.disabled = locked;
elements.loadMusicExampleButton.disabled = locked;
elements.prepareLiveButton.disabled = locked || !hasTrajectory || !state.validation.valid;
elements.livePlayButton.disabled = locked || !ready;
elements.liveTime.disabled = locked || !ready;
@@ -331,7 +349,8 @@ function setConfigurationLocked(locked) {
setEditableControlsDisabled(locked);
elements.runButton.disabled = locked || !state.validation.valid;
elements.cancelButton.classList.toggle("is-hidden", !locked);
elements.exportResultButton.disabled = locked || !state.lastRun;
elements.exportResultButton.disabled =
locked || !state.lastRun?.resultDocument;
renderLiveControls();
}
@@ -407,6 +426,27 @@ function syncAlgorithmBackendControls() {
elements.backend.title = supportsWebGpu
? "지원되는 GPU를 우선 사용하고 필요하면 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 ?? "";
}
@@ -664,9 +704,9 @@ function renderLegend() {
`${formatNumber(range.lastBinCenterHz, 0)} Hz`)
.join(", ");
text.textContent = `${requested} → ADC FFT bin centers ${adcRanges}`;
text.title =
"Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; " +
"the original alias order is not silently used for steering.";
text.title = state.experiment.beamforming.algorithm === "music"
? "MUSIC uses the single analog FFT-grid hypothesis configured by this band, never Scene truth. Inverted Nyquist zones conjugate the full received manifold."
: "Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; the original alias order is not silently used for steering.";
} else {
text.textContent = requested;
}
@@ -729,13 +769,17 @@ function renderLegend() {
const localizationDescription =
state.experiment.beamforming.algorithm === "gcc-phat"
? "관측된 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하며 ";
const carrierDescription =
state.experiment.beamforming.algorithm === "music"
? "Scene truth는 사용하지 않습니다. 반전 Nyquist zone은 마이크 응답과 공간 위상을 모두 켤레 처리하며, 물리적 grating lobe·동률·모델 불일치는 그대로 유지합니다. 한 band의 복수 carrier 가설은 선택하거나 융합하지 않고 거부합니다. "
: "원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 그대로 나타나며 alias order는 미해결 상태입니다. "
elements.samplingWarning.textContent =
"Ideal ADC alias · 위치 추정은 " + localizationDescription +
"원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 " +
"그대로 나타나며, 현재 알고리즘에서는 alias order가 미해결 상태로 남습니다. " +
"다채널 공간 위상에는 carrier-order 증거가 남을 수 있지만 이 localizer는 " +
"carrier hypothesis를 열거하지 않습니다. " +
"Ideal ADC alias · 위치 추정은 " +
localizationDescription +
carrierDescription +
details.join(" · ");
elements.samplingWarning.hidden = false;
}
@@ -752,6 +796,11 @@ function renderWorkload() {
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
: workload.algorithm === "music"
? `${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 =
@@ -762,7 +811,14 @@ function renderWorkload() {
const cpuOnly = !localizationAlgorithmSupportsBackend(workload.algorithm, "webgpu");
const heavyCpu =
(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;
elements.workloadSummary.classList.toggle(
"is-warning",
@@ -792,7 +848,11 @@ function bandsForVisualization(lastRun) {
function interpolateMap(left, right, progress) {
if (left === right || !(progress > 0)) return left;
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;
for (let index = 0; index < values.length; index += 1) {
values[index] = left[index] * inverse + right[index] * progress;
@@ -818,7 +878,13 @@ function liveVisualizationState(live) {
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() {
@@ -843,7 +909,12 @@ function renderSceneVisualization() {
);
elements.canvasBadge.textContent =
`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;
}
const displayed = state.lastRun;
@@ -862,6 +933,9 @@ function renderSceneVisualization() {
elements.canvasMessage.classList.toggle("is-hidden", Boolean(displayed || assetRuntime.imageElement));
if (!displayed) {
elements.canvasBadge.textContent = "결과 없음";
} else if (displayed.run.status === "unavailable") {
elements.canvasBadge.textContent =
`MUSIC · 위치 없음 · ${displayed.run.unavailable?.reason ?? "unavailable"}`;
} else if (displayed.stale) {
elements.canvasBadge.textContent =
`이전 설정 결과 · ${localizationAlgorithmLabel(displayed.run.algorithm ?? displayed.snapshot.experiment.beamforming.algorithm)} · ` +
@@ -890,7 +964,8 @@ function appendMetric(label, value, detail, color) {
function renderResult() {
const displayed = state.lastRun;
elements.exportResultButton.disabled = Boolean(state.active) || !displayed;
elements.exportResultButton.disabled =
Boolean(state.active) || !displayed?.resultDocument;
elements.metricGrid.replaceChildren();
elements.emptyResult.classList.toggle("is-hidden", Boolean(displayed));
elements.metricGrid.classList.toggle("is-hidden", !displayed);
@@ -898,6 +973,26 @@ function renderResult() {
elements.resultBadge.textContent = "아직 실행하지 않음";
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 algorithmLabel = localizationAlgorithmLabel(algorithm);
elements.resultBadge.textContent = displayed.stale
@@ -968,8 +1063,12 @@ function renderResult() {
!observation.gridManyToOne &&
toneContributions.length === 0
) continue;
const resultAlgorithm =
displayed.snapshot.experiment.beamforming.algorithm;
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 미사용"
: "양의 ADC bin steering · 원 carrier 미사용 · alias order 미해결";
const toneDetail = toneContributions.length > 0
@@ -977,8 +1076,10 @@ function renderResult() {
: "";
appendMetric(
`${band.id} · ADC steering`,
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
resultAlgorithm === "gcc-phat"
? "ADC LAG"
: resultAlgorithm === "music"
? "MIN q"
: "OBSERVED BIN",
localizationDetail + toneDetail,
band.color,
@@ -995,7 +1096,9 @@ function renderResult() {
appendMetric(
"Peak 검출 임계값",
`${formatNumber(detectionThreshold * 100, 0)}%`,
"최강 map 값 기준 · 낮을수록 민감",
algorithm === "music"
? "표준 MUSIC P=1/q의 P/Pmax 기준 · raw q 불변 · floor 없음"
: "최강 map 값 기준 · 낮을수록 민감",
);
appendMetric(
"Peak 묶기",
@@ -1015,6 +1118,16 @@ function renderResult() {
`${evaluation.counts.truePositive}/${evaluation.counts.truth} source`,
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "minimum") {
appendMetric(
`${band.id} · Noise projection q`,
evaluation.globalPeak
? formatNumber(evaluation.globalPeak.value, 6)
: "식별 불가",
"작을수록 MUSIC 후보 · 확률/신뢰도 아님 · raw q",
band.color,
);
} else {
appendMetric(
`${band.id} · Truth concentration`,
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
@@ -1027,12 +1140,14 @@ function renderResult() {
"높을수록 분리 우수",
band.color,
);
}
appendMetric(
`${band.id} · Precision / Recall`,
`${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`,
`${evaluation.counts.detected} peaks`,
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "maximum") {
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
@@ -1041,6 +1156,7 @@ function renderResult() {
: "half-power equivalent diameter",
band.color,
);
}
const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread;
if (spread?.ellipse) {
appendMetric(
@@ -1109,6 +1225,15 @@ function validationWarnings() {
if (workload.algorithm === "gcc-phat" && workload.minimumBandBinCount < 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");
if (
(state.experiment.beamforming.backend === "cpu" || cpuOnly) &&
@@ -1355,6 +1480,35 @@ async function runSimulation() {
assetRuntime.transferListForAudio(assetBuffers),
);
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 검출과 정량 지표를 계산하고 있습니다…");
const evaluations = new Map();
const exportedEvaluations = new Map();
@@ -1368,6 +1522,7 @@ async function runSimulation() {
band.rawScale ??
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
"power",
objectiveDirection: band.objectiveDirection,
scan: snapshot.experiment.scan,
sources: sourcesForObservedBand(
snapshot.scene,
@@ -1509,6 +1664,7 @@ function evaluateLiveFrames(snapshot, plan, frames) {
frame.run.algorithm ?? snapshot.experiment.beamforming.algorithm,
)?.rawScale ??
"power",
objectiveDirection: band.objectiveDirection,
scan: snapshot.experiment.scan,
sources: sourcesForObservedBand(
frameScene,
@@ -1625,9 +1781,17 @@ async function prepareLiveSimulation() {
playbackStartedAt: null,
playbackStartedTime: 0,
};
const unavailableFrameCount = timeline.frames.filter(
(frame) => frame.run.status === "unavailable",
).length;
setRunStatus(
`라이브 준비 완료 · ${timeline.frames.length} maps · ` +
`${formatNumber(timeline.timingMs.total, 0)} ms`,
`${formatNumber(timeline.timingMs.total, 0)} ms` +
(
unavailableFrameCount > 0
? ` · 위치 없음 ${unavailableFrameCount} frames`
: ""
),
);
renderLiveControls();
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() {
const activity = state.active;
if (!activity || activity.canceled) return;
@@ -1772,7 +2012,6 @@ async function loadArrayPreset(presetId) {
if (generation !== state.importGenerations.array) return;
if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · "));
state.arrayDefinition = value;
state.experiment.arrayId = value.id;
markChanged({ syncControls: true });
setRunStatus(`${value.name} 배열을 선택했습니다. 실행하면 같은 설정 기록에 추가됩니다.`);
} catch (error) {
@@ -1792,25 +2031,17 @@ async function importDocument(kind, input) {
try {
const value = await readJsonFile(file);
if (generation !== state.importGenerations[kind]) return;
let importedExperimentReferences = null;
let issues;
if (kind === "array") {
issues = await validateArrayDefinition(value);
} else if (kind === "scene") {
issues = await validateScene(value);
} else {
// Validate the imported document independently before changing any
// references. Then bind it to the open workspace and run the relationship
// checks as well, so import convenience does not weaken the contract.
// arrayId/sceneId are retained as document provenance, not treated as
// foreign keys. The currently open Array and Scene are the run inputs.
issues = await validateExperiment(value);
if (generation !== state.importGenerations[kind]) return;
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, {
arrayDefinition: state.arrayDefinition,
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 (kind === "array") {
state.arrayDefinition = value;
state.experiment.arrayId = value.id;
} else if (kind === "scene") {
state.scene = value;
state.experiment.sceneId = value.id;
alignExperimentToScene(state.experiment, value);
state.sceneDirty = false;
assetRuntime.reset();
@@ -1844,16 +2073,9 @@ async function importDocument(kind, input) {
: kind === "experiment"
? ` · 분석 대역 ${state.experiment.frequencyBands.length}개`
: "";
const referencesChanged = kind === "experiment" && (
importedExperimentReferences?.sceneId !== state.scene.id ||
importedExperimentReferences?.arrayId !== state.arrayDefinition.id
setRunStatus(
`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}`,
);
const referenceStatus = kind === "experiment"
? referencesChanged
? ` · 현재 Scene ${state.scene.id} · Array ${state.arrayDefinition.id}로 재연결`
: " · 현재 Scene · Array 연결 확인"
: "";
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}${referenceStatus}`);
} catch (error) {
setTransientError(error, `${file.name} 불러오기 실패`);
} finally {
@@ -1906,6 +2128,11 @@ function installDocumentInputListeners() {
elements.algorithm.addEventListener("change", () => {
const algorithm = elements.algorithm.value;
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 =
state.experiment.beamforming.backend === "webgpu" &&
!localizationAlgorithmSupportsBackend(algorithm, "webgpu");
@@ -1915,6 +2142,20 @@ function installDocumentInputListeners() {
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", () => {
if (
elements.backend.value === "webgpu" &&
@@ -2045,6 +2286,10 @@ function installActionListeners() {
elements.runButton.addEventListener("click", runSimulation);
elements.cancelButton.addEventListener("click", cancelSimulation);
elements.loadMovingExampleButton.addEventListener("click", loadMovingExample);
elements.loadMusicExampleButton.addEventListener(
"click",
loadMusicExample,
);
elements.prepareLiveButton.addEventListener("click", prepareLiveSimulation);
elements.livePlayButton.addEventListener("click", toggleLivePlayback);
elements.liveTime.addEventListener("input", () => {
@@ -2125,7 +2370,7 @@ function installActionListeners() {
setRunStatus("Experiment JSON을 저장했습니다.");
});
elements.exportResultButton.addEventListener("click", () => {
if (!state.lastRun) return;
if (!state.lastRun?.resultDocument) return;
downloadJson(
state.lastRun.resultDocument,
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
+171 -12
View File
@@ -1,5 +1,17 @@
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
import { observeFrequencyBand } from "/packages/acoustics-core/sampling.js";
import {
binsForBand,
getCompleteFrameCount,
getFrameCount,
} from "/packages/acoustics-core/stft.js";
import {
DEFAULT_MAXIMUM_MUSIC_COVARIANCE_COMPLEX_ACCUMULATIONS,
DEFAULT_MAXIMUM_MUSIC_JACOBI_PAIR_UPDATES,
DEFAULT_MAXIMUM_MUSIC_PROJECTION_COMPLEX_MACS,
} from "/packages/acoustics-core/music.js";
import {
describeFrequencyAliasing,
observeFrequencyBand,
} from "/packages/acoustics-core/sampling.js";
import {
DEFAULT_LOCALIZATION_ALGORITHM,
localizationAlgorithmDefinition,
@@ -35,9 +47,16 @@ export function createComparisonSignature(scene, sourceExperiment) {
if (!scene || !sourceExperiment) return "";
const experiment = structuredClone(sourceExperiment);
experiment.arrayId = "$array";
experiment.sceneId = "$scene";
if (experiment.beamforming) {
experiment.beamforming.algorithm = "$algorithm";
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 });
}
@@ -51,8 +70,20 @@ export async function hashSimulationInputs({ arrayDefinition, scene, experiment
}
export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
const frameCount = getFrameCount(
Math.ceil(scene.duration * scene.sampleRate),
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const algorithm =
experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
const musicSourceCountMethod =
experiment.beamforming?.musicSourceCountMethod ?? "fixed";
const frameCount =
algorithm === "music" && musicSourceCountMethod !== "fixed"
? getCompleteFrameCount(
sampleCount,
experiment.stft.fftSize,
experiment.stft.hopSize,
)
: getFrameCount(
sampleCount,
experiment.stft.fftSize,
experiment.stft.hopSize,
);
@@ -61,9 +92,17 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
);
const bandBinSelectionCount = bandBins.reduce((total, bins) => total + bins.length, 0);
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 microphoneCount = arrayDefinition.microphones.length;
const algorithm = experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
const algorithmDefinition = localizationAlgorithmDefinition(algorithm);
const referencePairCount = Math.max(0, microphoneCount - 1);
const stftTransformCount = frameCount * microphoneCount;
@@ -72,7 +111,50 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
2 * referencePairCount * frameCount * bandBinSelectionCount;
const gccScanScoreOps = pointCount * 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 {
algorithm,
algorithmLabel: algorithmDefinition?.label ?? algorithm,
@@ -87,12 +169,42 @@ export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
stftTransformCount,
referencePairCount,
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,
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,
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?.device) environment.gpuDevice = adapter.device;
if (run.gpu?.timingKind) environment.gpuTimingKind = run.gpu.timingKind;
if (Number.isInteger(run.frameCount) && run.frameCount >= 0) {
environment.stftFrameCount = run.frameCount;
}
const referenceMic =
run.referenceMic ??
run.referenceMicrophoneId ??
@@ -291,6 +406,18 @@ function frequencyObservationForResult(scene, experiment, band, algorithm) {
scene.sampleRate,
experiment.stft.fftSize,
);
const music = algorithm === "music";
const analogBin = Math.ceil(
band.minHz / observation.binWidthHz,
);
const configuredAnalogFrequencyHz =
analogBin * observation.binWidthHz;
const configuredAlias = music
? describeFrequencyAliasing(
configuredAnalogFrequencyHz,
scene.sampleRate,
)
: null;
return {
model: observation.model,
sampleRate: observation.sampleRate,
@@ -306,9 +433,28 @@ function frequencyObservationForResult(scene, experiment, band, algorithm) {
localizationModel:
algorithm === "gcc-phat"
? "band-limited-gcc-phat-geometric-lag"
: music
? "near-field-music-at-configured-analog-frequency"
: "spherical-steering-at-positive-adc-bin-frequency",
originalCarrierUsed: observation.originalCarrierUsed,
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
originalCarrierUsed: music,
aliasOrderResolution:
music && observation.aliased
? "configured-single-hypothesis"
: observation.aliased
? "not-attempted"
: "not-applicable",
...(music
? {
configuredAnalogFrequencyHz,
signedAdcFrequencyHz:
configuredAlias.signedAdcFrequencyHz,
spectralInversion:
configuredAlias.spectralInversion,
frequencyHypothesisCount: 1,
frequencyHypothesisSource:
"experiment-band-not-scene-truth",
}
: {}),
foldBoundaryCount: observation.foldBoundaryCount,
continuousManyToOne: observation.continuousManyToOne,
gridManyToOne: observation.gridManyToOne,
@@ -387,21 +533,31 @@ export function createResultDocument(options) {
beamforming: run.timingMs.beamforming ?? run.timingMs.localization,
},
environment: environmentForResult({ arrayDefinition, experiment, run }),
...(algorithm === "music" && run.model
? { musicModel: sortForCanonicalJson(run.model) }
: {}),
bands: run.bands.map((band) => {
const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} };
const bandRawScale = band.rawScale ?? rawScale;
const objectiveDirection =
band.objectiveDirection ??
(bandRawScale === "noise-projection" ? "minimum" : "maximum");
const bandResult = {
id: band.id,
color: band.color,
rawMap: {
rows: experiment.scan.rows,
columns: experiment.scan.columns,
scale: band.rawScale ?? rawScale,
scale: bandRawScale,
objectiveDirection,
values: Array.from(band.rawMap),
},
displayMap: {
rows: experiment.scan.rows,
columns: experiment.scan.columns,
scale: band.displayScale,
objectiveDirection:
band.displayObjectiveDirection ?? objectiveDirection,
values: Array.from(band.displayMap),
},
peaks: evaluation.peaks.map(peakForResult),
@@ -409,6 +565,9 @@ export function createResultDocument(options) {
};
const frequencyObservation = frequencyObservations.get(band.id);
if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation;
if (algorithm === "music" && band.diagnostics) {
bandResult.musicDiagnostics = sortForCanonicalJson(band.diagnostics);
}
if (evaluation.evaluation) {
bandResult.evaluation = {
...evaluation.evaluation,
+49 -5
View File
@@ -1,7 +1,10 @@
import { synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import { sampleQuasiStaticWindow } from "/packages/acoustics-core/trajectory.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 { isMusicUnavailableError } from "/packages/acoustics-core/music.js";
import { getCompleteFrameCount } from "/packages/acoustics-core/stft.js";
function post(type, jobId, payload = {}, transfer = []) {
self.postMessage({ type, jobId, ...payload }, transfer);
@@ -35,8 +38,47 @@ function signalStats(synthesis) {
}
function collectBandTransfers(run, transfer) {
for (const band of run.bands) {
transfer.push(band.rawMap.buffer, band.displayMap.buffer);
const knownBuffers = new Set(transfer);
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 algorithm = experiment.beamforming.algorithm ?? "das";
const requestedBackend = experiment.beamforming.backend ?? "auto";
const reuseCpuPlan = algorithm === "gcc-phat" || requestedBackend === "cpu";
const reuseCpuPlan =
!localizationAlgorithmSupportsBackend(algorithm, "webgpu") ||
requestedBackend === "cpu";
let localizationExperiment = experiment;
let analysisPlan = reuseCpuPlan
? createLocalizationAnalysisPlan({
@@ -105,7 +149,7 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
signalGeneration += frameSignalGeneration;
const localizationStartedAt = performance.now();
const localization = await computeLocalizationWithBackend({
const localization = await computeLocalizationOrUnavailable({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
@@ -234,7 +278,7 @@ self.addEventListener("message", async (event) => {
});
const localizationStartedAt = performance.now();
const localization = await computeLocalizationWithBackend({
const localization = await computeLocalizationOrUnavailable({
channels: synthesis.channels,
sampleRate: synthesis.sampleRate,
arrayDefinition,
+20 -1
View File
@@ -185,6 +185,7 @@
}
.compact-section-heading h3, .array-heading h3 { margin: 0; font-size: .75rem; }
.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; }
.builder-link { color: var(--cyan); font-size: .6rem; text-decoration: none; }
.source-summary, .asset-list { display: grid; gap: 6px; margin-top: 8px; }
@@ -230,6 +231,24 @@
.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 { 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; }
.workload-summary {
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; }
.run-status { max-width: 100%; }
.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; }
.band-editor__remove { min-height: 32px; }
.live-actions .button { flex: 1 1 calc(50% - 4px); }
+28 -1
View File
@@ -1,3 +1,5 @@
import { normalizedMusicPseudospectrum } from "/packages/acoustics-core/evaluation.js";
const DEFAULT_BACKGROUND = "#07110f";
const DEFAULT_DYNAMIC_RANGE_DB = 48;
const MAX_PIXEL_RATIO = 3;
@@ -269,11 +271,36 @@ function resolveBandMap(band, scan) {
const rows = map.rows ?? scan.rows;
const columns = map.columns ?? scan.columns;
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 {
values,
rows,
columns,
scale: map.scale ?? band.displayScale ?? "power",
scale,
sourceScale: scale,
objectiveDirection,
displayTransform: "identity",
};
}
+18 -1
View File
@@ -8,7 +8,24 @@
FFT 512 / hop 256, Auto backend를 쓰는 Experiment
- `moving-tone.scene.json`: 2초 동안 다섯 trajectory keyframe을 따라 이동하는 2 kHz tone Scene
- `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/>의
전체 테스트와 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
View File
@@ -227,6 +227,54 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가
해결하지는 않는다. 단일 reference 방식은 모든 마이크 쌍을 쓰는 GCC보다 계산량이 작지만 pair
중복성과 고장 마이크에 대한 강건성도 작다.
### Narrowband MUSIC (`music`)
현재 CPU MUSIC은 [Schmidt의 noise-subspace 방법](https://doi.org/10.1109/TAP.1986.1143830)을
근거리 구면파 direct-path manifold에 적용한다. 근거리 source localization의 관련 문헌은
[Huang–Barkat](https://doi.org/10.1109/8.86917)이다. 보정된 snapshot
`x(t)`로 mean subtraction 없는 sample covariance를 만든다.
```text
R = (1/T) Σ_t x(t) x(t)ᴴ
R v_j = λ_j v_j, λ_0 ≥ ... ≥ λ_(M-1)
q(p) = Σ_(j=K)^(M-1) |v_jᴴ a(p)|² / (a(p)ᴴ a(p))
```
`q`는 noise projection fraction이고 optimum은 minimum이다. UI/evaluation은 표준
pseudospectrum `P=1/q`를 scan 안에서 `P/Pmax`로 표시한다. `q_min>0`이면 이는
`q_min/q(p)`이고, `q_min=0`이면 정확한 zero 위치만 1로 표시한다. flat `q`는 모두 1로
표시하되 위치 peak는 만들지 않는다. `q`, `P`와 `P/Pmax`는 확률·posterior·confidence 또는
신뢰구간이 아니다.
Experiment band는 `Fs/N` grid에 놓인 아날로그 carrier 가설을 정확히 하나만 포함한다.
`f_a`를 그 center, signed ADC alias의 orientation을 `η = sign(f_s)`라 하면 공간 위상의
크기는 analog carrier `f_a`를 유지하고 `η`만 alias inversion 방향으로 사용한다.
```text
a_m(p) = H_m,residual(f_a) / max(r_m(p), d_min)
× exp[-j η 2π f_a (r_m(p)-r_0(p))/c + j φ_m,residual(f_a)]
```
`f_s<0`인 alias inversion에서는 positive-bin STFT coefficient, sensitivity/response 보정과
전체 complex manifold가 baseband 가설의 켤레 방향이 된다. microphone frequency response는
관측 bin이 아니라 사용자가 지정한 `f_a`에서 평가한다. 이 가설은 Experiment에서 오며 Scene
source frequency·position truth는 covariance, K 선택이나 scan에 사용하지 않는다. 같은 ADC
bin의 다른 alias order는 별도 band로 명시해야 하고 자동 선택하지 않는다. 정확한 DC와 Nyquist는
실수 sequence에 독립 quadrature가 없어 거부한다.
K는 사용자가 고정하거나 [Wax–Kailath AIC/MDL](https://doi.org/10.1109/TASSP.1985.1164557)로
선택한다. AIC/MDL은 비중첩 complete snapshot, `T>M`, roundoff보다 엄격히 큰 noise eigenvalue가
필요하고 IID complex Gaussian snapshot, spatially white noise, 창 동안 일정한 source 수를
가정한다. 비중첩 자체는 통계적 독립성의 증명이 아니며 criterion은 확률이나 confidence가 아니다.
가정/수치 조건이 깨지면 typed `MUSIC_UNAVAILABLE` 오류를 내고 fixed K로 fallback하지 않는다.
같은 주파수의 coherent source는 signal covariance rank를 잃을 수 있다. eigengap이 수치 audit
bound보다 작으면 subspace를 임의 분할하지 않는다. diagonal loading, eigenvalue floor, spatial
smoothing, band 간 association·평균·maximum fusion은 없다. 배열의 spatial alias/grating lobe,
동률과 flat objective도 결과를 좋게 보이도록 제거하지 않는다.
[Wax–Shan–Kailath broadband eigenstructure 방법](https://doi.org/10.1109/TASSP.1984.1164400)은
알려진 별도 알고리즘이지만 현재 구현하지 않았다.
## 샘플링, Nyquist와 alias
Scene의 `sampleRate`는 모든 마이크가 동시에 동작하는 이상적 real-valued ADC의 sampling rate다.
@@ -269,16 +317,19 @@ alias-equivalent band ID를 보존한다. 같은 `adcBinSetKey`를 가진 band
`-Fs/2` endpoint는 두 관측이나 두 FFT bin이 아니라 같은 phase-degenerate Nyquist
sample을 양쪽 zone에서 표현한 것이다.
같은 이유로 위치 추정기의 선택적 microphone-response 역보정도 관측 ADC bin에서 평가한다. Forward
tone 합성은 원 analog 주파수의 응답을 사용하므로, Nyquist 위 carrier를 알고 보정하는 별도
alias-aware 알고리즘처럼 동작하지 않는다. 이 차이 역시 감추지 않는 현재 알고리즘의 결과다.
같은 이유로 DAS·DAS-PHAT·GCC-PHAT의 선택적 microphone-response 역보정은 관측 ADC bin에서
평가한다. Forward tone 합성은 원 analog 주파수의 응답을 사용하므로 이 세 알고리즘은 Nyquist 위
carrier를 아는 alias-aware 알고리즘처럼 동작하지 않는다. MUSIC만 예외로, 사용자가 band에
명시한 단일 아날로그 FFT-grid 가설에서 response와 signed complex manifold를 계산한다. 이는
Scene truth를 읽거나 가능한 alias order를 자동 탐색하는 것이 아니다.
서로 다른 analog tone이 같은 ADC bin으로 접히면 **단일 channel의 시간 sample만으로는**
원 주파수를 구별할 수 없다. 그러나 이 합성기는 원 analog 파장의 센서 간 위상을 보존하므로,
충분히 촘촘하고 보정된 다채널 array에는 carrier order를 판별할 공간 위상 증거가 남을 수 있다.
현재 DAS·DAS-PHAT·GCC-PHAT는 carrier hypothesis를 열거하는 알고리즘이 아니므로 이를 복원하지
않고 Result에 `aliasOrderResolution: not-attempted`로 기록한다. 이 기능을 추가하려면 검증된
array-processing 방법과 별도 alias-order 평가가 필요하며, 임의 규칙으로 peak를 고르지 않는다.
않고 Result에 `aliasOrderResolution: not-attempted`로 기록한다. MUSIC은 band 하나에 사용자가
고른 가설 하나만 계산해 `configured-single-hypothesis`로 기록하며, 후보 중 승자를 고르지 않는다.
여러 order를 비교하려면 각각 독립 band로 명시해야 한다.
audio asset과 noise는 이미 sampling된 baseband 신호로 취급하므로 파일에 없는 Nyquist 위 analog
spectrum을 만들어 내지 않는다.
@@ -361,9 +412,11 @@ Result `signalStats`에는 다음 선택 필드가 들어간다.
같은 입력과 seed에서 환경 preset만 바꿨다면 이 값과 위치 오차·precision/recall·PSR을 함께
비교할 수 있다.
Result 최상위 `algorithm`은 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 기록한다. band의
`rawMap.scale`은 DAS에서 `power`, 두 PHAT 방식에서 `score`다. peak 정규화된 `displayMap`은
시각 비교용일 뿐 어느 scale도 확률분포나 신뢰구간으로 바꾸지 않는다.
Result 최상위 `algorithm`은 실제 실행한 `das`, `das-phat`, `gcc-phat` 또는 `music`을 기록한다.
band의 `rawMap.scale`은 DAS에서 `power`, 두 PHAT 방식에서 `score`, MUSIC에서
`noise-projection`이다. MUSIC Result는 `musicModel`과 band별 `musicDiagnostics`에 아날로그
가설, alias inversion, covariance/EVD audit와 K 선택 가정을 기록한다. 표시 변환은 어느 scale도
확률분포나 신뢰구간으로 바꾸지 않는다.
## 정확도 범위와 제외 사항
+38 -9
View File
@@ -29,7 +29,7 @@ snap과 zoom은 편집기 상태이며 `micarrsim.array` 계약에 포함되지
`aperture.width/height`와 최종 `microphones` 좌표·순서만 들어간다. 따라서 다른 도구는
형상 이름을 추론하지 않고 실제 좌표를 배열 geometry의 기준으로 사용해야 한다.
Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`다. backend와
Experiment의 필수 `beamforming.algorithm` enum은 `das`, `das-phat`, `gcc-phat`, `music`이다. backend와
raw map 계약은 다음과 같다.
| algorithm | `rawMap.scale` | 허용 backend |
@@ -37,11 +37,19 @@ raw map 계약은 다음과 같다.
| `das` | `power` | `cpu`, `webgpu`; `auto` 가능 |
| `das-phat` | `score` | `cpu`, `webgpu`; `auto` 가능 |
| `gcc-phat` | `score` | `cpu`; `auto`는 CPU 선택 |
| `music` | `noise-projection` | `cpu`; `auto`는 CPU 선택 |
`gcc-phat`에 명시적 `webgpu`를 조합하면 검증 오류이며, 마이크가 두 개 이상이어야 한다. 필드
그룹 이름 `beamforming`은 기존 계약과의 호환을 위해 유지하지만 세 알고리즘을 모두 선택하는 위치
`gcc-phat` 또는 `music`에 명시적 `webgpu`를 조합하면 검증 오류이며, 마이크가 두 개 이상이어야 한다. 필드
그룹 이름 `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가
항상 적용된다. DAS는 sensitivity와 정적 phase를 항상 역보정하고, 값이 `true`이면 선택 bin에서
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만
제한하는 0–120 dB 값이며 생략 시 24 dB다. 응답 peak를 줄이는 attenuation과 phase 보상은 이
boost 상한의 대상이 아니다. amplitude를 사용하지 않는 DAS-PHAT와 GCC-PHAT에는 이 값이 영향을
주지 않는다. 세 알고리즘의 계산식과 한계는
주지 않는다. MUSIC은 analog 가설에서 residual complex response를 일관되게 manifold에 넣는다.
네 알고리즘의 계산식과 한계는
[음향 환경과 마이크 응답 모델](acoustic-model.md)에 설명한다.
## 로컬 asset 재연결
@@ -127,7 +136,7 @@ sidelobe false positive도 늘 수 있다.
## Result 평가 정보
Result 1.1은 최상위 필수 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를
Result 1.1은 최상위 필수 `algorithm`에 실제 실행한 `das`, `das-phat`, `gcc-phat` 또는 `music`을
기록하며 Experiment의 선택값과 같아야 한다. `backend`는 요청값 `auto`가 아니라 실제 사용한
`cpu` 또는 `webgpu`다.
@@ -180,8 +189,10 @@ threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic r
경계의 `+Fs/2`와 `-Fs/2`는 같은 실수 sample이며, point provenance는 `+Fs/2`로
canonicalize하고 fold segment endpoint는 zone 진행 방향에 따라 둘 중 하나를 기록한다.
`localizationInputModel`은 one-sided real FFT bin만 사용함을 밝히며, `localizationModel`은
DAS 계열의 구면 steering과 GCC-PHAT의 기하 lag를 구분한다. `originalCarrierUsed`는 항상
`false`다. Nyquist 위 band의 `aliasOrderResolution`은 현재 `not-attempted`다.
DAS 계열의 구면 steering, GCC-PHAT의 기하 lag와 MUSIC의 configured-analog-frequency
near-field manifold를 구분한다. DAS/DAS-PHAT/GCC-PHAT의 `originalCarrierUsed`는 `false`이고
Nyquist 위 band의 `aliasOrderResolution`은 `not-attempted`다. MUSIC의
`originalCarrierUsed: true`는 Scene truth가 아니라 사용자가 지정한 analog 가설을 뜻한다.
`equivalentBandIds`가 있으면 그 band들은 같은 ADC bin 집합을 공유하므로 독립
관측으로 세면 안 된다.
@@ -206,8 +217,26 @@ Array·Scene·Experiment 계약 버전은 `1.0.0`, Result 계약 버전은 `1.1.
`packages/contracts/schema-validator.js`로 저장소의
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한 뒤 `sceneId`와
`arrayId`를 현재 열린 Scene·Array의 id로 연결하고, 그 context에서 관계 검증을 다시 수행한다.
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한다. `sceneId`와
`arrayId`는 provenance label이므로 현재 열린 Scene·Array ID와 비교해 거부하거나 값을
덮어쓰지 않는다. 실행에는 현재 열린 geometry와 sample data를 사용하고, Result의
`sceneId`/`arrayId`와 `inputHash`에는 실제 사용한 입력을 기록한다.
## MUSIC Result 의미
MUSIC의 `rawMap.scale`은 `noise-projection`이고
`q = ||E_n^H a||²/(a^H a)`의 최솟값이 optimum이다. 표시용 `P/Pmax = q_min/q`도 q와 마찬가지로
확률, likelihood, posterior 또는 신뢰구간이 아니다. `q_min = 0`이면 0인 cell만 1로 표시한다.
Result의 `musicModel`과 band별 `musicDiagnostics`는 source-count 방법·가정,
covariance/eigensolver 감사, analog/ADC alias와 CPU workload를 보존한다.
MUSIC의 `originalCarrierUsed: true`는 Scene truth가 아니라 Experiment에 사용자가 지정한 analog
FFT-grid 가설을 썼다는 뜻이다. analog carrier `f_a`와 관측 alias orientation `η`로
`exp[-j η 2π f_a (r_m-r_ref)/c]`를 만들며, inversion이면 calibration과 steering을 포함한 전체
complex manifold를 켤레화한다. Scene source carrier/위치 truth는 읽지 않는다. 정확한 DC/Nyquist
bin은 quadrature-degenerate라 거절한다. diagonal loading, epsilon/floor, spatial smoothing,
multibin averaging/association과 wideband fusion은 없다. grating lobe, 동률과 coherent-source
rank deficiency는 숨기지 않고 map 또는 typed unavailable로 나타낸다.
이 가져오기 편의 동작은 계약을 완화하지 않는다. 내보내기와 실행에 쓰는 Experiment도 아래 관계를
모두 만족해야 한다.
+6 -1
View File
@@ -206,6 +206,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
- [x] Scene 합성에서 분리한 block/streaming multichannel 위치추정 API
- [x] 반복 거리·calibration·steering cache, bounded ring과 메모리 hard cap
- [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
- [ ] 실제 장치의 channel/clock/sample 형식에 맞춘 capture·transport adapter와 현장 검증
- [ ] 필요 시 센서 self-noise·위치 공차와 DAMAS/CLEAN-SC 후처리
@@ -227,7 +228,10 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
- 실제 AC와 simulation gap: gain·phase·주파수 응답, ideal real ADC sampling/alias와 희소 실내
환경을 비교 조건으로 제공하되, 위치 공차·self-noise·ADC quantization/nonlinearity와
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. 구현 결과와 다음 백로그
@@ -237,6 +241,7 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
- [x] 좌표·거리·구면 감쇠·분수 지연·STFT 단위 테스트
- [x] CPU DAS golden implementation
- [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] scene canvas, array view, parameter controls와 Result export
- [x] Builder→Camera SHA-256 asset 재연결 왕복 테스트
+4 -3
View File
@@ -110,8 +110,9 @@
<li>
Camera에서 파일을 <strong>Scene → Array → Experiment → Assets</strong> 순서로
가져오는 것을 권장합니다. Experiment는 어느 시점에 열어도 분석 대역을 보존하고
<code>sceneId</code>와 <code>arrayId</code>를 현재 열린 입력에 자동으로 연결합니다.
재연결 뒤 관계 검증도 다시 수행됩니다. Array Builder에서 저장한 파일이 있다면 Array
<code>sceneId</code>와 <code>arrayId</code> provenance label도 덮어쓰지 않습니다.
실행에는 현재 열린 Scene·Array를 사용하고 Result에 실제 입력 ID와 hash를 기록합니다.
Array Builder에서 저장한 파일이 있다면 Array
단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
</li>
<li>
@@ -269,7 +270,7 @@
</details>
<details>
<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>
<summary>Assets가 준비되지 않습니다.</summary>
+34 -3
View File
@@ -83,6 +83,7 @@ Portable plan은 top-level `algorithm`으로 위치 추정기를 선택한다.
| `das` | frequency-domain delay-and-sum | `power` | CPU |
| `das-phat` | phase-only spectrum을 steering하는 DAS | `score` | CPU |
| `gcc-phat` | 한 reference mic에 대한 `M-1`개 GCC-PHAT 지연 scan | `score` | CPU only |
| `music` | 표준 좁은대역 Schmidt noise-subspace scan | `noise-projection` | CPU only |
같은 array·scan·band·STFT로 알고리즘을 비교할 때도 알고리즘마다 별도 plan을 만든다.
@@ -103,6 +104,14 @@ const gccPhatPlan = createLocalizationPlan({
algorithm: "gcc-phat",
phatFloorRatio: 1e-6,
});
const musicPlan = createLocalizationPlan({
...commonConfig,
algorithm: "music",
normalization: "none",
musicSourceCountMethod: "fixed",
musicSourceCount: 1,
});
```
`phatRelativeFloor`는 DAS-PHAT의 channel spectrum 정규화 floor이고, `phatFloorRatio`는
@@ -151,7 +160,7 @@ const plan = createLocalizationPlan({
{ id: "machine", minHz: 900, maxHz: 1100, color: "#65f2bf" },
],
stft: { fftSize: 512, hopSize: 256 },
algorithm: "das", // "das" | "das-phat" | "gcc-phat"
algorithm: "das", // "das" | "das-phat" | "gcc-phat" | "music"
speedOfSound: 343,
normalization: "peak",
compensateFrequencyResponse: true,
@@ -174,7 +183,7 @@ const plan = createLocalizationPlan({
- scan의 `columns`와 `rows`는 양의 정수
- `speedOfSound`는 양수
- `normalization`은 `peak` 또는 `none`
- `algorithm`은 `das`, `das-phat`, `gcc-phat` 중 하나
- `algorithm`은 `das`, `das-phat`, `gcc-phat`, `music` 중 하나
- `phatRelativeFloor`와 `phatFloorRatio`는 지정한 경우 0–1
- `maxCompensationDb`는 0–120 dB
- `peakDetection.thresholdRatio`는 0–1,
@@ -186,7 +195,29 @@ const plan = createLocalizationPlan({
STFT·correlation workspace를 유지한다. 같은 계획으로 두 분석을 동시에 실행하지 말고 worker마다
별도 계획을 만든다. Array·scan·band·STFT·알고리즘을 바꾸면 새 계획을 만들어야 한다.
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
batch는 128개다. GCC-PHAT cross-spectrum은 16,384 operation 단위로도 양보한다. 각 batch 경계에서
+60 -4
View File
@@ -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로 제공합니다.
| `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-phat` | 채널 spectrum을 phase-only로 만든 DAS | `score` | CPU, WebGPU |
| `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`이며, 낮은
수준의 generic API는 `experiment.beamforming.algorithm`을 사용합니다.
@@ -49,7 +50,7 @@ import {
const plan = createLocalizationPlan({
...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,
});
@@ -75,6 +76,61 @@ band에 선택된 bin이 8개 미만이면 코어는 계산을 허용하지만
GCC-PHAT score는 pair 수, band coefficient 수와 IFFT 크기로 정규화한 0–1 양의 상관 응답이라
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된 동기식 마이크 채널을 직접
@@ -83,8 +139,8 @@ scan 범위와 마이크 수에 의한 단순 재척도화는 피하지만, 여
각 chunk는 동일 길이의 `Float32Array` 목록이어야 합니다.
`StreamingLocalizer`는 알고리즘 중립 class 이름입니다. 기존 `StreamingDasLocalizer`와 같은 class의
export alias이므로 오래된 import도 그대로 동작하며, `createStreamingLocalizer()` factory도 세
알고리즘을 모두 지원합니다.
export alias이므로 오래된 import도 그대로 동작하며, `createStreamingLocalizer()` factory도 네
알고리즘을 모두 지원합니다. MUSIC plan은 `plan.musicPlan` alias로도 접근할 수 있습니다.
```js
import { createStreamingLocalizer } from "./packages/acoustics-core/realtime-localizer.js";
+480
View File
@@ -0,0 +1,480 @@
const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT;
export const DEFAULT_MAX_COVARIANCE_WORK_BYTES = 32 * 1024 * 1024;
export const DEFAULT_MAX_COVARIANCE_COMPLEX_ACCUMULATIONS = 250_000_000;
export const DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR = 64;
function assertPositiveInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new TypeError(`${label} must be a positive safe integer.`);
}
}
function checkedProduct(factors, label) {
let product = 1;
for (const factor of factors) {
if (factor !== 0 && product > Number.MAX_SAFE_INTEGER / factor) {
throw new RangeError(`${label} exceeds the safe integer range.`);
}
product *= factor;
}
return product;
}
function roundoffGamma(operationCount) {
const product = operationCount * (Number.EPSILON / 2);
if (!(product < 1)) {
throw new RangeError("roundoff bound is undefined for this workload.");
}
return product / (1 - product);
}
/** Estimate covariance output allocation and upper-triangle accumulation work. */
export function estimateComplexCovarianceWorkload({ sensorCount, snapshotCount }) {
assertPositiveInteger(sensorCount, "sensorCount");
assertPositiveInteger(snapshotCount, "snapshotCount");
const inputElementCount = checkedProduct(
[sensorCount, snapshotCount],
"covariance input element count",
);
const matrixElementCount = checkedProduct(
[sensorCount, sensorCount],
"covariance matrix element count",
);
const upperTriangleElementCount =
checkedProduct([sensorCount, sensorCount + 1], "covariance upper triangle") / 2;
const complexAccumulations = checkedProduct(
[upperTriangleElementCount, snapshotCount],
"covariance accumulation count",
);
const allocationBytes = checkedProduct(
[2, matrixElementCount, FLOAT64_BYTES],
"covariance allocation",
);
return Object.freeze({
sensorCount,
snapshotCount,
inputElementCount,
matrixElementCount,
upperTriangleElementCount,
complexAccumulations,
allocationBytes,
});
}
function compensatedAdd(sum, compensation, value) {
const next = sum + value;
const correction = Math.abs(sum) >= Math.abs(value)
? (sum - next) + value
: (value - next) + sum;
return [next, compensation + correction];
}
/**
* Compute R = (1/T) sum_t x[t] x[t]^H in Float64.
*
* `snapshot-major` stores x(t,m) at `t * sensorCount + m`; `sensor-major`
* stores it at `m * snapshotCount + t`. Only the upper triangle is
* accumulated, with Neumaier compensation, and the lower triangle is filled
* as its exact complex conjugate. No centering or diagonal loading is applied.
*/
export function computeComplexSampleCovariance({
real,
imaginary,
sensorCount,
snapshotCount,
layout = "snapshot-major",
maximumWorkBytes = DEFAULT_MAX_COVARIANCE_WORK_BYTES,
maximumComplexAccumulations = DEFAULT_MAX_COVARIANCE_COMPLEX_ACCUMULATIONS,
}) {
const workload = estimateComplexCovarianceWorkload({ sensorCount, snapshotCount });
if (layout !== "snapshot-major" && layout !== "sensor-major") {
throw new RangeError("layout must be 'snapshot-major' or 'sensor-major'.");
}
if (real == null || imaginary == null) {
throw new TypeError("real and imaginary snapshots are required.");
}
if (
real.length !== workload.inputElementCount ||
imaginary.length !== workload.inputElementCount
) {
throw new RangeError(
`snapshot components must each contain ${workload.inputElementCount} values.`,
);
}
if (!(maximumWorkBytes >= 0) || !Number.isFinite(maximumWorkBytes)) {
throw new TypeError("maximumWorkBytes must be finite and nonnegative.");
}
if (
!(maximumComplexAccumulations >= 0) ||
!Number.isFinite(maximumComplexAccumulations)
) {
throw new TypeError("maximumComplexAccumulations must be finite and nonnegative.");
}
if (workload.allocationBytes > maximumWorkBytes) {
throw new RangeError(
`covariance requires ${workload.allocationBytes} typed-array bytes; limit is ${maximumWorkBytes}.`,
);
}
if (workload.complexAccumulations > maximumComplexAccumulations) {
throw new RangeError(
`covariance requires ${workload.complexAccumulations} complex accumulations; limit is ${maximumComplexAccumulations}.`,
);
}
for (let index = 0; index < workload.inputElementCount; index += 1) {
if (!Number.isFinite(real[index]) || !Number.isFinite(imaginary[index])) {
throw new TypeError("snapshot components must be finite.");
}
}
const matrixReal = new Float64Array(workload.matrixElementCount);
const matrixImaginary = new Float64Array(workload.matrixElementCount);
const sampleIndex = layout === "snapshot-major"
? (sensor, snapshot) => snapshot * sensorCount + sensor
: (sensor, snapshot) => sensor * snapshotCount + snapshot;
let matrixScale = 0;
let traceSum = 0;
let traceCompensation = 0;
for (let row = 0; row < sensorCount; row += 1) {
for (let column = row; column < sensorCount; column += 1) {
let sumReal = 0;
let compensationReal = 0;
let sumImaginary = 0;
let compensationImaginary = 0;
for (let snapshot = 0; snapshot < snapshotCount; snapshot += 1) {
const rowIndex = sampleIndex(row, snapshot);
const columnIndex = sampleIndex(column, snapshot);
const rowReal = real[rowIndex];
const rowImaginary = imaginary[rowIndex];
const columnReal = real[columnIndex];
const columnImaginary = imaginary[columnIndex];
const productReal =
rowReal * columnReal + rowImaginary * columnImaginary;
const productImaginary =
rowImaginary * columnReal - rowReal * columnImaginary;
if (!Number.isFinite(productReal) || !Number.isFinite(productImaginary)) {
throw new RangeError("covariance product exceeds the finite Float64 range.");
}
[sumReal, compensationReal] = compensatedAdd(
sumReal,
compensationReal,
productReal,
);
[sumImaginary, compensationImaginary] = compensatedAdd(
sumImaginary,
compensationImaginary,
productImaginary,
);
}
const valueReal = (sumReal + compensationReal) / snapshotCount;
const valueImaginary = row === column
? 0
: (sumImaginary + compensationImaginary) / snapshotCount;
if (!Number.isFinite(valueReal) || !Number.isFinite(valueImaginary)) {
throw new RangeError("covariance value exceeds the finite Float64 range.");
}
const upper = row * sensorCount + column;
const lower = column * sensorCount + row;
matrixReal[upper] = valueReal;
matrixImaginary[upper] = valueImaginary;
matrixReal[lower] = valueReal;
matrixImaginary[lower] = row === column ? 0 : -valueImaginary;
matrixScale = Math.max(
matrixScale,
Math.abs(valueReal),
Math.abs(valueImaginary),
);
if (row === column) {
[traceSum, traceCompensation] = compensatedAdd(
traceSum,
traceCompensation,
valueReal,
);
}
}
}
const trace = traceSum + traceCompensation;
const covarianceRoundoffBound =
roundoffGamma(4 * snapshotCount + 32) * trace;
return {
sensorCount,
snapshotCount,
inputLayout: layout,
normalization: "1/T",
centered: false,
matrix: {
real: matrixReal,
imaginary: matrixImaginary,
layout: "row-major",
},
matrixScale,
trace,
covarianceRoundoffBound,
workload,
};
}
/**
* Audit covariance eigenvalues against a scale-aware backward-roundoff bound.
* Strictly positive values are never floored. Only negative values within the
* bound are represented as zero in `roundoffClampedEigenvalues`; raw values
* are always retained separately.
*/
export function auditCovarianceEigenvalues({
eigenvalues,
sensorCount = eigenvalues?.length,
matrixScale,
roundoffTolerance,
covarianceRoundoffBound,
eigensolverResidualFrobeniusNorm = 0,
roundoffFactor = DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR,
rejectIndefinite = true,
}) {
if (eigenvalues == null) throw new TypeError("eigenvalues are required.");
assertPositiveInteger(sensorCount, "sensorCount");
if (eigenvalues.length !== sensorCount) {
throw new RangeError("eigenvalue count must equal sensorCount.");
}
if (!(roundoffFactor > 0) || !Number.isFinite(roundoffFactor)) {
throw new TypeError("roundoffFactor must be finite and positive.");
}
if (matrixScale !== undefined && (!(matrixScale >= 0) || !Number.isFinite(matrixScale))) {
throw new TypeError("matrixScale must be finite and nonnegative.");
}
if (
covarianceRoundoffBound !== undefined &&
(!(covarianceRoundoffBound >= 0) || !Number.isFinite(covarianceRoundoffBound))
) {
throw new TypeError("covarianceRoundoffBound must be finite and nonnegative.");
}
if (
!(eigensolverResidualFrobeniusNorm >= 0) ||
!Number.isFinite(eigensolverResidualFrobeniusNorm)
) {
throw new TypeError(
"eigensolverResidualFrobeniusNorm must be finite and nonnegative.",
);
}
let eigenvalueScale = matrixScale ?? 0;
let minimumEigenvalue = Infinity;
for (const value of eigenvalues) {
if (!Number.isFinite(value)) throw new TypeError("eigenvalues must be finite.");
eigenvalueScale = Math.max(eigenvalueScale, Math.abs(value));
minimumEigenvalue = Math.min(minimumEigenvalue, value);
}
const scaleDerivedRoundoffTolerance = eigenvalueScale === 0
? 0
: roundoffFactor * Number.EPSILON * Math.max(1, sensorCount) * eigenvalueScale;
const effectiveRoundoffTolerance = roundoffTolerance ?? (
covarianceRoundoffBound === undefined
? scaleDerivedRoundoffTolerance
: covarianceRoundoffBound + eigensolverResidualFrobeniusNorm
);
if (
!(effectiveRoundoffTolerance >= 0) ||
!Number.isFinite(effectiveRoundoffTolerance)
) {
throw new TypeError("roundoffTolerance must be finite and nonnegative.");
}
const rawEigenvalues = Float64Array.from(eigenvalues);
const roundoffClampedEigenvalues = Float64Array.from(eigenvalues);
let clampedNegativeCount = 0;
let firstIndefiniteIndex = -1;
for (let index = 0; index < sensorCount; index += 1) {
const value = rawEigenvalues[index];
if (value < -effectiveRoundoffTolerance) {
if (firstIndefiniteIndex < 0) firstIndefiniteIndex = index;
} else if (value < 0 || Object.is(value, -0)) {
roundoffClampedEigenvalues[index] = 0;
clampedNegativeCount += 1;
}
}
const isPositiveSemidefinite = firstIndefiniteIndex < 0;
if (!isPositiveSemidefinite && rejectIndefinite) {
const error = new RangeError(
`covariance is genuinely indefinite: eigenvalue ${rawEigenvalues[firstIndefiniteIndex]} is below -${effectiveRoundoffTolerance}.`,
);
error.eigenvalueIndex = firstIndefiniteIndex;
error.eigenvalue = rawEigenvalues[firstIndefiniteIndex];
error.roundoffTolerance = effectiveRoundoffTolerance;
throw error;
}
return {
rawEigenvalues,
roundoffClampedEigenvalues,
roundoffTolerance: effectiveRoundoffTolerance,
roundoffFactor,
roundoffDerivation: roundoffTolerance !== undefined
? "explicit"
: covarianceRoundoffBound === undefined
? "eigenvalue-scale"
: "covariance-accumulation-plus-eigensolver-residual",
covarianceRoundoffBound: covarianceRoundoffBound ?? null,
eigensolverResidualFrobeniusNorm,
eigenvalueScale,
minimumEigenvalue,
clampedNegativeCount,
firstIndefiniteIndex,
isPositiveSemidefinite,
};
}
function unavailableSourceCount(criterion, reason, details = {}) {
return {
available: false,
criterion,
sourceCount: null,
score: null,
scores: null,
reason,
...details,
};
}
/**
* Select source count with the Wax-Kailath AIC or MDL criterion.
*
* This pure function deliberately declines an answer unless T > M, snapshots
* are declared independent, and every eigenvalue used by the candidate set is
* strictly above the supplied roundoff bound. It performs no loading, rank
* repair, or cross-frequency source-count fusion.
*/
export function selectWaxKailathSourceCount({
eigenvalues,
snapshotCount,
independentSnapshots,
criterion = "mdl",
roundoffTolerance,
}) {
if (criterion !== "aic" && criterion !== "mdl") {
throw new RangeError("criterion must be 'aic' or 'mdl'.");
}
if (eigenvalues == null || eigenvalues.length < 1) {
throw new TypeError("at least one eigenvalue is required.");
}
assertPositiveInteger(snapshotCount, "snapshotCount");
const sensorCount = eigenvalues.length;
assertPositiveInteger(sensorCount, "sensorCount");
let scale = 0;
for (const value of eigenvalues) {
if (!Number.isFinite(value)) throw new TypeError("eigenvalues must be finite.");
scale = Math.max(scale, Math.abs(value));
}
const effectiveRoundoffTolerance = roundoffTolerance ?? (
scale === 0
? 0
: DEFAULT_COVARIANCE_EIGENVALUE_ROUNDOFF_FACTOR *
Number.EPSILON * Math.max(1, sensorCount) * scale
);
if (
!(effectiveRoundoffTolerance >= 0) ||
!Number.isFinite(effectiveRoundoffTolerance)
) {
throw new TypeError("roundoffTolerance must be finite and nonnegative.");
}
if (independentSnapshots !== true) {
return unavailableSourceCount(criterion, "snapshots-not-independent", {
sensorCount,
snapshotCount,
roundoffTolerance: effectiveRoundoffTolerance,
});
}
if (snapshotCount <= sensorCount) {
return unavailableSourceCount(criterion, "snapshot-count-not-greater-than-sensors", {
sensorCount,
snapshotCount,
roundoffTolerance: effectiveRoundoffTolerance,
});
}
for (let index = 0; index < sensorCount; index += 1) {
const value = eigenvalues[index];
if (index > 0 && value > eigenvalues[index - 1] + effectiveRoundoffTolerance) {
throw new RangeError("eigenvalues must be sorted in descending order.");
}
if (!(value > effectiveRoundoffTolerance)) {
return unavailableSourceCount(
criterion,
"noise-eigenvalue-not-strictly-above-roundoff",
{
sensorCount,
snapshotCount,
roundoffTolerance: effectiveRoundoffTolerance,
failingEigenvalueIndex: index,
failingEigenvalue: value,
},
);
}
}
const scores = new Float64Array(sensorCount);
let selectedSourceCount = 0;
let selectedScore = Infinity;
let logRatioRoundoffClampCount = 0;
for (let sourceCount = 0; sourceCount < sensorCount; sourceCount += 1) {
const noiseCount = sensorCount - sourceCount;
let arithmeticSum = 0;
let arithmeticCompensation = 0;
let logarithmSum = 0;
let logarithmCompensation = 0;
for (let index = sourceCount; index < sensorCount; index += 1) {
[arithmeticSum, arithmeticCompensation] = compensatedAdd(
arithmeticSum,
arithmeticCompensation,
eigenvalues[index],
);
[logarithmSum, logarithmCompensation] = compensatedAdd(
logarithmSum,
logarithmCompensation,
Math.log(eigenvalues[index]),
);
}
const arithmeticMean =
(arithmeticSum + arithmeticCompensation) / noiseCount;
const logarithmicGeometricMean =
(logarithmSum + logarithmCompensation) / noiseCount;
let logarithmicRatio = Math.log(arithmeticMean) - logarithmicGeometricMean;
const ratioRoundoffTolerance =
64 * Number.EPSILON * Math.max(1, noiseCount) *
Math.max(1, Math.abs(Math.log(arithmeticMean)), Math.abs(logarithmicGeometricMean));
if (logarithmicRatio < 0 && logarithmicRatio >= -ratioRoundoffTolerance) {
logarithmicRatio = 0;
logRatioRoundoffClampCount += 1;
}
if (!(logarithmicRatio >= 0) || !Number.isFinite(logarithmicRatio)) {
throw new RangeError("Wax-Kailath arithmetic/geometric mean ratio is invalid.");
}
const likelihoodTerm = snapshotCount * noiseCount * logarithmicRatio;
const parameterCount = sourceCount * (2 * sensorCount - sourceCount);
const score = criterion === "aic"
? 2 * likelihoodTerm + 2 * parameterCount
: likelihoodTerm + 0.5 * parameterCount * Math.log(snapshotCount);
scores[sourceCount] = score;
if (score < selectedScore) {
selectedScore = score;
selectedSourceCount = sourceCount;
}
}
return {
available: true,
criterion,
sourceCount: selectedSourceCount,
score: selectedScore,
scores,
reason: null,
sensorCount,
snapshotCount,
roundoffTolerance: effectiveRoundoffTolerance,
logRatioRoundoffClampCount,
};
}
+169 -37
View File
@@ -58,9 +58,10 @@ function normalizeRawMap(rawMap, scan) {
if (
descriptor?.scale !== undefined &&
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) {
throw new RangeError("rawMap rows do not match scan.rows.");
@@ -85,6 +86,86 @@ function rawMapScale(rawMap) {
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) {
if (!Array.isArray(sources)) throw new TypeError("sources must be an array.");
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
* are projected onto scan.z, matching the image-plane ground-truth convention.
* Evaluate one nonnegative spatial map. Noise-projection minima are detected
* 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) {
if (!options || typeof options !== "object") throw new TypeError("evaluation options are required.");
@@ -751,9 +835,20 @@ export function evaluateLocalizationBand(options) {
validateScan(scan);
const values = normalizeRawMap(rawMap, scan);
const mapScale = options.mapScale ?? rawMapScale(rawMap);
if (mapScale !== "power" && mapScale !== "score") {
throw new RangeError("mapScale must be power or score.");
if (mapScale !== "power" && mapScale !== "score" && mapScale !== "noise-projection") {
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 truths = sources.map((source) => ({
sourceId: source.id,
@@ -789,19 +884,32 @@ export function evaluateLocalizationBand(options) {
throw new RangeError("maximumDetections must be an integer from 1 through 128.");
}
const localPeaks = findLocalPeaks(values, scan);
const globalPeak = localPeaks.length > 0 ? { ...localPeaks[0], position: [...localPeaks[0].position] } : null;
const thresholdValue = (globalPeak?.value ?? 0) * detectionThresholdRatio;
const candidates = detectionCandidates(
values,
const localMeritPeaks = merit.flat
? []
: findLocalPeaks(merit.values, scan);
const globalMeritPeak = localMeritPeaks[0] ?? null;
const thresholdValue = (globalMeritPeak?.value ?? 0) * detectionThresholdRatio;
const meritCandidates = detectionCandidates(
merit.values,
scan,
localPeaks,
localMeritPeaks,
thresholdValue,
detectionMethod,
);
const detections = selectDetections(candidates, thresholdValue, minimumPeakDistanceM, maximumDetections).map(
(detection) => ({
...detection,
const selectedMeritDetections = selectDetections(
meritCandidates,
thresholdValue,
minimumPeakDistanceM,
maximumDetections,
);
const localPeaks = localMeritPeaks.map((peak) =>
restoreObjectiveValue(peak, values));
const globalPeak = restoreObjectiveValue(globalMeritPeak, values);
const detections = selectedMeritDetections.map((detection) => {
const restored = restoreObjectiveValue(detection, values);
if (objectiveDirection === "minimum") return restored;
return {
...restored,
beamSpread: measureBeamSpreadFromValues(
values,
scan,
@@ -809,12 +917,36 @@ export function evaluateLocalizationBand(options) {
halfPowerRatio,
mapScale,
),
}),
);
};
});
const { sourceMatches, truePositiveCount } = matchTruths(truths, detections, matchToleranceM);
const precision = detections.length > 0 ? truePositiveCount / detections.length : truths.length === 0 ? 1 : 0;
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 {
globalPeak,
@@ -833,6 +965,10 @@ export function evaluateLocalizationBand(options) {
matchToleranceM,
detectionThresholdRatio,
detectionThresholdValue: thresholdValue,
detectionThresholdDomain:
objectiveDirection === "minimum"
? "normalized-music-pseudospectrum"
: "raw-map-value",
detectionMethod,
minimumPeakDistanceM,
sidelobeExclusionRadiusM,
@@ -840,23 +976,14 @@ export function evaluateLocalizationBand(options) {
dynamicRangeDb,
maximumDetections,
},
metrics: {
peakToSidelobeDb: peakToSidelobeDb(
values,
scan,
truths,
globalPeak,
sidelobeExclusionRadiusM,
dynamicRangeDb,
),
energyInsideTruthRatio: energyInsideRatio(values, scan, truths, truthRadiusM, globalPeak?.value ?? 0),
precision,
recall,
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
},
metrics,
diagnostics: {
mapScale,
detectionCandidateCount: candidates.length,
objectiveDirection,
detectionTransform: merit.transform,
...(merit.rawMinimum === null ? {} : { rawMinimum: merit.rawMinimum }),
flatObjectiveMap: merit.flat,
detectionCandidateCount: meritCandidates.length,
mainLobe,
mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0,
gridResolutionM: resolution,
@@ -870,12 +997,14 @@ export function evaluateDasBand(options) {
}
/** 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;
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 = {};
for (const metricName of metricNames) {
for (const metricName of selectedMetricNames) {
if (!RESULT_METRIC_KEY_SET.has(metricName)) {
throw new RangeError(`result.schema.json does not allow metric: ${metricName}`);
}
@@ -927,13 +1056,16 @@ export function exportBandEvaluation(evaluation, options = {}) {
});
return {
peaks,
metrics: selectResultMetrics(evaluation, options.metricNames ?? RESULT_METRIC_KEYS),
metrics: selectResultMetrics(evaluation, options.metricNames),
evaluation: {
version: "1.0.0",
truthRadiusM: evaluation.parameters.truthRadiusM,
matchToleranceM: evaluation.parameters.matchToleranceM,
detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio,
detectionThresholdValue: evaluation.parameters.detectionThresholdValue,
objectiveDirection: evaluation.diagnostics?.objectiveDirection ?? "maximum",
detectionThresholdDomain:
evaluation.parameters.detectionThresholdDomain ?? "raw-map-value",
detectionMethod: evaluation.parameters.detectionMethod ?? "local-max",
minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM,
sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM,
+555
View File
@@ -0,0 +1,555 @@
const FLOAT64_BYTES = Float64Array.BYTES_PER_ELEMENT;
export const DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS = 32;
export const DEFAULT_MAX_HERMITIAN_EIGEN_WORK_BYTES = 16 * 1024 * 1024;
export const DEFAULT_MAX_HERMITIAN_JACOBI_PAIR_UPDATES = 1_000_000_000;
function assertPositiveInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 1) {
throw new TypeError(`${label} must be a positive safe integer.`);
}
}
function assertMaximumSweeps(value) {
if (
!Number.isSafeInteger(value) ||
value < 0 ||
value > DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS
) {
throw new RangeError(
`maximumSweeps must be an integer from 0 through ${DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS}.`,
);
}
}
function checkedProduct(factors, label) {
let product = 1;
for (const factor of factors) {
if (factor !== 0 && product > Number.MAX_SAFE_INTEGER / factor) {
throw new RangeError(`${label} exceeds the safe integer range.`);
}
product *= factor;
}
return product;
}
function roundoffGamma(operationCount) {
const product = operationCount * (Number.EPSILON / 2);
if (!(product < 1)) {
throw new RangeError("roundoff bound is undefined for this workload.");
}
return product / (1 - product);
}
/**
* Estimate the bounded typed-array allocation and cyclic-Jacobi work before
* any decomposition workspace is allocated.
*/
export function estimateHermitianEigenWorkload({
order,
maximumSweeps = DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS,
}) {
assertPositiveInteger(order, "order");
assertMaximumSweeps(maximumSweeps);
const matrixElementCount = checkedProduct([order, order], "matrix element count");
const pairCount = checkedProduct([order, order - 1], "Jacobi pair count") / 2;
const worstCaseRotations = checkedProduct(
[pairCount, maximumSweeps],
"Jacobi rotation count",
);
// A rotation updates n-2 matrix row pairs and n eigenvector row pairs.
const pairUpdatesPerRotation = Math.max(0, 2 * order - 2);
const worstCasePairUpdates = checkedProduct(
[worstCaseRotations, pairUpdatesPerRotation],
"Jacobi pair-update count",
);
// Two split-complex matrices plus normalized and physical eigenvalue arrays.
const typedArrayElementCount = checkedProduct(
[4 * matrixElementCount + 2 * order, FLOAT64_BYTES],
"Hermitian eigensolver allocation",
) / FLOAT64_BYTES;
const allocationBytes = checkedProduct(
[typedArrayElementCount, FLOAT64_BYTES],
"Hermitian eigensolver allocation",
);
return Object.freeze({
order,
maximumSweeps,
matrixElementCount,
pairCount,
pairUpdatesPerRotation,
worstCaseRotations,
worstCasePairUpdates,
allocationBytes,
});
}
function inferOrder(real, imaginary, explicitOrder) {
if (real == null || imaginary == null) {
throw new TypeError("real and imaginary matrix components are required.");
}
const order = explicitOrder ?? Math.sqrt(real.length);
assertPositiveInteger(order, "order");
const expectedLength = checkedProduct([order, order], "matrix element count");
if (real.length !== expectedLength || imaginary.length !== expectedLength) {
throw new RangeError(
`Hermitian matrix components must each contain ${expectedLength} values.`,
);
}
return order;
}
function matrixFrobeniusNorm(real, imaginary, order) {
let sum = 0;
for (let index = 0; index < order * order; index += 1) {
sum += real[index] * real[index] + imaginary[index] * imaginary[index];
}
return Math.sqrt(sum);
}
function offDiagonalFrobeniusNorm(real, imaginary, order) {
let upperTriangleSum = 0;
for (let row = 0; row < order; row += 1) {
for (let column = row + 1; column < order; column += 1) {
const index = row * order + column;
upperTriangleSum +=
real[index] * real[index] + imaginary[index] * imaginary[index];
}
}
return Math.sqrt(2 * upperTriangleSum);
}
function rotatePair(matrixReal, matrixImaginary, vectorsReal, vectorsImaginary, order, p, q) {
const pq = p * order + q;
const offReal = matrixReal[pq];
const offImaginary = matrixImaginary[pq];
const magnitude = Math.hypot(offReal, offImaginary);
if (magnitude === 0) return false;
const diagonalP = matrixReal[p * order + p];
const diagonalQ = matrixReal[q * order + q];
const halfDifference = (diagonalQ - diagonalP) / 2;
const tangent = halfDifference === 0
? 1
: Math.sign(halfDifference) * magnitude /
(Math.abs(halfDifference) + Math.hypot(halfDifference, magnitude));
const cosine = 1 / Math.hypot(1, tangent);
const sine = tangent * cosine;
const phaseReal = offReal / magnitude;
const phaseImaginary = offImaginary / magnitude;
for (let row = 0; row < order; row += 1) {
if (row === p || row === q) continue;
const rowP = row * order + p;
const rowQ = row * order + q;
const pReal = matrixReal[rowP];
const pImaginary = matrixImaginary[rowP];
const qReal = matrixReal[rowQ];
const qImaginary = matrixImaginary[rowQ];
const conjugatePhaseTimesQReal =
phaseReal * qReal + phaseImaginary * qImaginary;
const conjugatePhaseTimesQImaginary =
phaseReal * qImaginary - phaseImaginary * qReal;
const phaseTimesPReal = phaseReal * pReal - phaseImaginary * pImaginary;
const phaseTimesPImaginary = phaseReal * pImaginary + phaseImaginary * pReal;
const nextPReal = cosine * pReal - sine * conjugatePhaseTimesQReal;
const nextPImaginary = cosine * pImaginary - sine * conjugatePhaseTimesQImaginary;
const nextQReal = sine * phaseTimesPReal + cosine * qReal;
const nextQImaginary = sine * phaseTimesPImaginary + cosine * qImaginary;
matrixReal[rowP] = nextPReal;
matrixImaginary[rowP] = nextPImaginary;
matrixReal[p * order + row] = nextPReal;
matrixImaginary[p * order + row] = -nextPImaginary;
matrixReal[rowQ] = nextQReal;
matrixImaginary[rowQ] = nextQImaginary;
matrixReal[q * order + row] = nextQReal;
matrixImaginary[q * order + row] = -nextQImaginary;
}
matrixReal[p * order + p] = diagonalP - tangent * magnitude;
matrixImaginary[p * order + p] = 0;
matrixReal[q * order + q] = diagonalQ + tangent * magnitude;
matrixImaginary[q * order + q] = 0;
matrixReal[pq] = 0;
matrixImaginary[pq] = 0;
matrixReal[q * order + p] = 0;
matrixImaginary[q * order + p] = 0;
for (let row = 0; row < order; row += 1) {
const rowP = row * order + p;
const rowQ = row * order + q;
const pReal = vectorsReal[rowP];
const pImaginary = vectorsImaginary[rowP];
const qReal = vectorsReal[rowQ];
const qImaginary = vectorsImaginary[rowQ];
const conjugatePhaseTimesQReal =
phaseReal * qReal + phaseImaginary * qImaginary;
const conjugatePhaseTimesQImaginary =
phaseReal * qImaginary - phaseImaginary * qReal;
const phaseTimesPReal = phaseReal * pReal - phaseImaginary * pImaginary;
const phaseTimesPImaginary = phaseReal * pImaginary + phaseImaginary * pReal;
vectorsReal[rowP] = cosine * pReal - sine * conjugatePhaseTimesQReal;
vectorsImaginary[rowP] = cosine * pImaginary - sine * conjugatePhaseTimesQImaginary;
vectorsReal[rowQ] = sine * phaseTimesPReal + cosine * qReal;
vectorsImaginary[rowQ] = sine * phaseTimesPImaginary + cosine * qImaginary;
}
return true;
}
function canonicalizeColumnPhases(real, imaginary, order) {
for (let column = 0; column < order; column += 1) {
let pivotRow = 0;
let pivotMagnitude = -1;
for (let row = 0; row < order; row += 1) {
const index = row * order + column;
const magnitude = Math.hypot(real[index], imaginary[index]);
if (magnitude > pivotMagnitude) {
pivotMagnitude = magnitude;
pivotRow = row;
}
}
const pivotIndex = pivotRow * order + column;
const pivotReal = real[pivotIndex];
const pivotImaginary = imaginary[pivotIndex];
const normalizedPivotMagnitude = Math.hypot(pivotReal, pivotImaginary);
if (!(normalizedPivotMagnitude > 0) || !Number.isFinite(normalizedPivotMagnitude)) {
throw new RangeError("Jacobi eigenvector phase canonicalization failed.");
}
const phaseReal = pivotReal / normalizedPivotMagnitude;
const phaseImaginary = -pivotImaginary / normalizedPivotMagnitude;
for (let row = 0; row < order; row += 1) {
const index = row * order + column;
const valueReal = real[index];
const valueImaginary = imaginary[index];
real[index] = valueReal * phaseReal - valueImaginary * phaseImaginary;
imaginary[index] = valueReal * phaseImaginary + valueImaginary * phaseReal;
}
real[pivotIndex] = normalizedPivotMagnitude;
imaginary[pivotIndex] = 0;
}
}
function projectedInputEntry(real, imaginary, order, row, column, scale) {
if (row === column) return [real[row * order + row] / scale, 0];
const forward = row * order + column;
const reverse = column * order + row;
return [
(real[forward] + real[reverse]) / (2 * scale),
(imaginary[forward] - imaginary[reverse]) / (2 * scale),
];
}
function residualFrobeniusNorm(
inputReal,
inputImaginary,
vectorsReal,
vectorsImaginary,
normalizedEigenvalues,
order,
scale,
) {
if (scale === 0) return 0;
let residualSquared = 0;
for (let column = 0; column < order; column += 1) {
const eigenvalue = normalizedEigenvalues[column];
for (let row = 0; row < order; row += 1) {
let productReal = 0;
let productImaginary = 0;
for (let inner = 0; inner < order; inner += 1) {
const [matrixReal, matrixImaginary] = projectedInputEntry(
inputReal,
inputImaginary,
order,
row,
inner,
scale,
);
const vectorIndex = inner * order + column;
const vectorReal = vectorsReal[vectorIndex];
const vectorImaginary = vectorsImaginary[vectorIndex];
productReal += matrixReal * vectorReal - matrixImaginary * vectorImaginary;
productImaginary += matrixReal * vectorImaginary + matrixImaginary * vectorReal;
}
const vectorIndex = row * order + column;
const differenceReal = productReal - eigenvalue * vectorsReal[vectorIndex];
const differenceImaginary =
productImaginary - eigenvalue * vectorsImaginary[vectorIndex];
residualSquared +=
differenceReal * differenceReal + differenceImaginary * differenceImaginary;
}
}
return Math.sqrt(residualSquared);
}
function orthogonalityError(real, imaginary, order) {
let errorSquared = 0;
for (let left = 0; left < order; left += 1) {
for (let right = 0; right < order; right += 1) {
let productReal = 0;
let productImaginary = 0;
for (let row = 0; row < order; row += 1) {
const leftIndex = row * order + left;
const rightIndex = row * order + right;
productReal +=
real[leftIndex] * real[rightIndex] +
imaginary[leftIndex] * imaginary[rightIndex];
productImaginary +=
real[leftIndex] * imaginary[rightIndex] -
imaginary[leftIndex] * real[rightIndex];
}
if (left === right) productReal -= 1;
errorSquared += productReal * productReal + productImaginary * productImaginary;
}
}
return Math.sqrt(errorSquared);
}
/**
* Deterministic full cyclic Jacobi decomposition of a complex Hermitian
* matrix. Split-complex matrices are row-major; eigenvectors are returned as
* row-major columns, so component `row` of eigenvector `column` is at
* `row * order + column`.
*
* The input is projected to its nearest Hermitian matrix only after its
* anti-Hermitian discrepancy has passed the reported roundoff-scale audit.
* No diagonal loading or eigenvalue flooring is performed.
*/
export function decomposeHermitian({
real,
imaginary,
order: explicitOrder,
maximumSweeps = DEFAULT_HERMITIAN_JACOBI_MAX_SWEEPS,
relativeTolerance,
maximumWorkBytes = DEFAULT_MAX_HERMITIAN_EIGEN_WORK_BYTES,
maximumPairUpdates = DEFAULT_MAX_HERMITIAN_JACOBI_PAIR_UPDATES,
}) {
const order = inferOrder(real, imaginary, explicitOrder);
const workload = estimateHermitianEigenWorkload({ order, maximumSweeps });
if (!(maximumWorkBytes >= 0) || !Number.isFinite(maximumWorkBytes)) {
throw new TypeError("maximumWorkBytes must be finite and nonnegative.");
}
if (!(maximumPairUpdates >= 0) || !Number.isFinite(maximumPairUpdates)) {
throw new TypeError("maximumPairUpdates must be finite and nonnegative.");
}
if (workload.allocationBytes > maximumWorkBytes) {
throw new RangeError(
`Hermitian eigensolver requires ${workload.allocationBytes} typed-array bytes; limit is ${maximumWorkBytes}.`,
);
}
if (workload.worstCasePairUpdates > maximumPairUpdates) {
throw new RangeError(
`Hermitian eigensolver worst-case work is ${workload.worstCasePairUpdates} pair updates; limit is ${maximumPairUpdates}.`,
);
}
let scale = 0;
for (let index = 0; index < workload.matrixElementCount; index += 1) {
const valueReal = real[index];
const valueImaginary = imaginary[index];
if (!Number.isFinite(valueReal) || !Number.isFinite(valueImaginary)) {
throw new TypeError("Hermitian matrix components must be finite.");
}
// Component scaling avoids overflow in hypot() for otherwise finite
// split-complex entries near Number.MAX_VALUE.
scale = Math.max(scale, Math.abs(valueReal), Math.abs(valueImaginary));
}
const inputHermitianTolerance =
scale === 0 ? 0 : 32 * Number.EPSILON * Math.max(1, order) * scale;
let inputHermitianError = 0;
for (let row = 0; row < order; row += 1) {
inputHermitianError = Math.max(
inputHermitianError,
Math.abs(imaginary[row * order + row]),
);
for (let column = row + 1; column < order; column += 1) {
const forward = row * order + column;
const reverse = column * order + row;
inputHermitianError = Math.max(
inputHermitianError,
Math.hypot(
real[forward] - real[reverse],
imaginary[forward] + imaginary[reverse],
),
);
}
}
if (inputHermitianError > inputHermitianTolerance) {
throw new RangeError(
`matrix is not Hermitian within roundoff: error ${inputHermitianError}, tolerance ${inputHermitianTolerance}.`,
);
}
const matrixReal = new Float64Array(workload.matrixElementCount);
const matrixImaginary = new Float64Array(workload.matrixElementCount);
const vectorsReal = new Float64Array(workload.matrixElementCount);
const vectorsImaginary = new Float64Array(workload.matrixElementCount);
const normalizedEigenvalues = new Float64Array(order);
const eigenvalues = new Float64Array(order);
if (scale > 0) {
for (let row = 0; row < order; row += 1) {
const diagonal = row * order + row;
matrixReal[diagonal] = real[diagonal] / scale;
for (let column = row + 1; column < order; column += 1) {
const forward = row * order + column;
const reverse = column * order + row;
const projectedReal = (real[forward] + real[reverse]) / (2 * scale);
const projectedImaginary =
(imaginary[forward] - imaginary[reverse]) / (2 * scale);
matrixReal[forward] = projectedReal;
matrixImaginary[forward] = projectedImaginary;
matrixReal[reverse] = projectedReal;
matrixImaginary[reverse] = -projectedImaginary;
}
}
}
for (let index = 0; index < order; index += 1) {
vectorsReal[index * order + index] = 1;
}
const matrixNormNormalized = matrixFrobeniusNorm(
matrixReal,
matrixImaginary,
order,
);
const effectiveRelativeTolerance = relativeTolerance ?? roundoffGamma(32 * order);
if (!(effectiveRelativeTolerance > 0) || !Number.isFinite(effectiveRelativeTolerance)) {
throw new TypeError("relativeTolerance must be finite and positive.");
}
const absoluteTolerance = effectiveRelativeTolerance * matrixNormNormalized;
let offDiagonalNormalized = offDiagonalFrobeniusNorm(
matrixReal,
matrixImaginary,
order,
);
let converged = offDiagonalNormalized <= absoluteTolerance;
let sweeps = 0;
let rotations = 0;
const pairTolerance = workload.pairCount === 0
? 0
: absoluteTolerance / Math.sqrt(2 * workload.pairCount);
while (!converged && sweeps < maximumSweeps) {
let sweepRotations = 0;
for (let p = 0; p < order; p += 1) {
for (let q = p + 1; q < order; q += 1) {
const pq = p * order + q;
if (Math.hypot(matrixReal[pq], matrixImaginary[pq]) <= pairTolerance) {
continue;
}
if (
rotatePair(
matrixReal,
matrixImaginary,
vectorsReal,
vectorsImaginary,
order,
p,
q,
)
) {
sweepRotations += 1;
}
}
}
rotations += sweepRotations;
sweeps += 1;
offDiagonalNormalized = offDiagonalFrobeniusNorm(
matrixReal,
matrixImaginary,
order,
);
converged = offDiagonalNormalized <= absoluteTolerance;
if (sweepRotations === 0) break;
}
const stableOrder = Array.from({ length: order }, (_, index) => ({
index,
value: matrixReal[index * order + index],
}));
stableOrder.sort((left, right) => {
if (left.value > right.value) return -1;
if (left.value < right.value) return 1;
return left.index - right.index;
});
for (let column = 0; column < order; column += 1) {
const sourceColumn = stableOrder[column].index;
const normalizedEigenvalue = stableOrder[column].value;
normalizedEigenvalues[column] = Object.is(normalizedEigenvalue, -0)
? 0
: normalizedEigenvalue;
const physicalEigenvalue = normalizedEigenvalues[column] * scale;
if (!Number.isFinite(physicalEigenvalue)) {
throw new RangeError("Hermitian eigenvalue exceeds the finite Float64 range.");
}
eigenvalues[column] = Object.is(physicalEigenvalue, -0) ? 0 : physicalEigenvalue;
for (let row = 0; row < order; row += 1) {
const destination = row * order + column;
const source = row * order + sourceColumn;
matrixReal[destination] = vectorsReal[source];
matrixImaginary[destination] = vectorsImaginary[source];
}
}
// A product of unitary Jacobi rotations already has unit-norm columns.
// Canonicalize phase only; do not hide orthogonality drift by renormalizing.
canonicalizeColumnPhases(matrixReal, matrixImaginary, order);
const residualNormalized = residualFrobeniusNorm(
real,
imaginary,
matrixReal,
matrixImaginary,
normalizedEigenvalues,
order,
scale,
);
const relativeResidual = matrixNormNormalized === 0
? 0
: residualNormalized / matrixNormNormalized;
const relativeOffDiagonal = matrixNormNormalized === 0
? 0
: offDiagonalNormalized / matrixNormNormalized;
const residualTolerance = roundoffGamma(128 * order);
const orthogonalityTolerance = residualTolerance * Math.sqrt(order);
const measuredOrthogonalityError = orthogonalityError(
matrixReal,
matrixImaginary,
order,
);
const numericallyVerified =
converged &&
relativeResidual <= residualTolerance &&
measuredOrthogonalityError <= orthogonalityTolerance;
return {
order,
eigenvalues,
eigenvectors: {
real: matrixReal,
imaginary: matrixImaginary,
layout: "row-major-columns",
},
converged,
sweeps,
rotations,
scale,
inputHermitianError,
inputHermitianTolerance,
relativeTolerance: effectiveRelativeTolerance,
offDiagonalFrobeniusNorm: offDiagonalNormalized * scale,
relativeOffDiagonal,
residualFrobeniusNorm: residualNormalized * scale,
relativeResidual,
residualTolerance,
orthogonalityError: measuredOrthogonalityError,
orthogonalityTolerance,
numericallyVerified,
workload,
};
}
+3
View File
@@ -5,11 +5,14 @@ export * from "./fft.js";
export * from "./frequency-response.js";
export * from "./fractional-delay.js";
export * from "./gcc-phat.js";
export * from "./hermitian-eigen.js";
export * from "./localization.js";
export * from "./localization-algorithms.js";
export * from "./math.js";
export * from "./mixdown.js";
export * from "./music.js";
export * from "./noise.js";
export * from "./covariance.js";
export * from "./propagation.js";
export * from "./realtime-localizer.js";
export * from "./signal.js";
@@ -15,6 +15,7 @@ const ALGORITHM_DEFINITIONS = Object.freeze([
definition("das", "DAS", "power", "none", ["cpu", "webgpu"]),
definition("das-phat", "DAS-PHAT", "score", "phat", ["cpu", "webgpu"]),
definition("gcc-phat", "GCC-PHAT", "score", "phat", ["cpu"]),
definition("music", "MUSIC", "noise-projection", "subspace", ["cpu"]),
]);
const ALGORITHM_BY_ID = new Map(
@@ -43,6 +44,10 @@ export function isDasAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
return id === "das" || id === "das-phat";
}
export function isMusicAlgorithm(id = DEFAULT_LOCALIZATION_ALGORITHM) {
return id === "music";
}
export function localizationAlgorithmLabel(id = DEFAULT_LOCALIZATION_ALGORITHM) {
return localizationAlgorithmDefinition(id)?.label ?? String(id);
}
+8
View File
@@ -8,6 +8,11 @@ import {
createGccPhatAnalysisPlan,
estimateGccPhatWorkload,
} from "./gcc-phat.js";
import {
computeMusicMap,
createMusicAnalysisPlan,
estimateMusicWorkload,
} from "./music.js";
import {
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
@@ -27,6 +32,7 @@ export function estimateLocalizationWorkload(options) {
const algorithm = requestedAlgorithm(options);
if (isDasAlgorithm(algorithm.id)) return estimateDasWorkload(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 + ".");
}
@@ -37,6 +43,7 @@ export function createLocalizationAnalysisPlan(options) {
const algorithm = requestedAlgorithm(options);
if (isDasAlgorithm(algorithm.id)) return createDasAnalysisPlan(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 + ".");
}
@@ -49,5 +56,6 @@ export async function computeLocalizationMap(options) {
const algorithm = requestedAlgorithm(options);
if (isDasAlgorithm(algorithm.id)) return computeDasMap(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 + ".");
}
File diff suppressed because it is too large Load Diff
+111 -16
View File
@@ -1,4 +1,7 @@
import { findLocalPeaks } from "./evaluation.js";
import {
findLocalPeaks,
normalizedMusicPseudospectrum,
} from "./evaluation.js";
import {
computeLocalizationMap,
createLocalizationAnalysisPlan,
@@ -17,6 +20,8 @@ import {
MAX_FREQUENCY_RESPONSE_POINTS,
requireMaxCompensationDb,
} 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 DEFAULT_STREAM_RESULTS_PER_CALL = 1;
@@ -224,11 +229,36 @@ function throwIfAborted(signal) {
export function detectBandPeaks(rawMap, scan, options = {}) {
const rawScale = options.rawScale ?? rawMap?.scale ?? "power";
if (rawScale !== "power" && rawScale !== "score") {
throw new RangeError('peak map rawScale must be "power" or "score".');
if (rawScale !== "power" && rawScale !== "score" && rawScale !== "noise-projection") {
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 localPeaks = findLocalPeaks(rawMap, scan);
const localPeaks = findLocalPeaks(detectionMap, scan);
const threshold = (localPeaks[0]?.value ?? 0) * peakDetection.thresholdRatio;
const detections = [];
for (const peak of localPeaks) {
@@ -248,11 +278,15 @@ export function detectBandPeaks(rawMap, scan, options = {}) {
rank: detections.length + 1,
index: peak.index,
position: [...peak.position],
rawValue: peak.value,
rawValue: rawValues[peak.index],
rawScale,
objectiveDirection,
detectionMerit: peak.value,
...(rawScale === "power"
? { rawPower: peak.value }
: { rawScore: peak.value }),
? { rawPower: rawValues[peak.index] }
: rawScale === "score"
? { rawScore: rawValues[peak.index] }
: { noiseProjection: rawValues[peak.index] }),
});
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.");
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") {
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 =
config.compensateFrequencyResponse ??
config.beamforming?.compensateFrequencyResponse ??
@@ -307,9 +352,20 @@ export function createLocalizationPlan(config) {
config.beamforming?.maxCompensationDb ??
DEFAULT_MAX_COMPENSATION_DB,
);
const algorithm = requireLocalizationAlgorithmDefinition(
config.algorithm ?? config.beamforming?.algorithm,
const minimumDistance = requirePositiveFinite(
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 =
config.phatRelativeFloor ?? config.beamforming?.phatRelativeFloor;
const phatFloorRatio =
@@ -326,6 +382,10 @@ export function createLocalizationPlan(config) {
normalization,
compensateFrequencyResponse,
maxCompensationDb,
minimumDistance,
...(algorithm.id === "music"
? { musicSourceCountMethod, musicSourceCount }
: {}),
}),
});
const analysisPlan = createLocalizationAnalysisPlan({
@@ -350,18 +410,22 @@ export function createLocalizationPlan(config) {
speedOfSound,
normalization,
maxCompensationDb,
minimumDistance,
scene,
experiment,
analysisPlan,
...(isDasAlgorithm(algorithm.id)
? { dasPlan: analysisPlan }
: { gccPhatPlan: analysisPlan }),
: algorithm.id === "gcc-phat"
? { gccPhatPlan: analysisPlan }
: { musicPlan: analysisPlan }),
computeOptions: Object.freeze({
pointBatchSize: isDasAlgorithm(algorithm.id)
? DEFAULT_REALTIME_POINT_BATCH_SIZE
: DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
pointBatchSize:
algorithm.id === "gcc-phat"
? DEFAULT_GCC_PHAT_POINT_BATCH_SIZE
: DEFAULT_REALTIME_POINT_BATCH_SIZE,
stftTransformBatchSize: DEFAULT_REALTIME_STFT_TRANSFORM_BATCH_SIZE,
...(!isDasAlgorithm(algorithm.id)
...(algorithm.id === "gcc-phat"
? { crossSpectrumBatchSize: DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE }
: {}),
...(phatRelativeFloor !== undefined ? { phatRelativeFloor } : {}),
@@ -390,7 +454,9 @@ export async function localizeMultichannel(options) {
if (sampleCount === 0) throw new RangeError("channels must contain at least one sample.");
throwIfAborted(options.signal);
const processingStartedAt = monotonicNow();
const localization = await computeLocalizationMap({
let localization;
try {
localization = await computeLocalizationMap({
...plan.computeOptions,
...(options.computeOptions ?? {}),
channels: options.channels,
@@ -402,6 +468,35 @@ export async function localizeMultichannel(options) {
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(
localization.bands,
plan.scan,
+28 -4
View File
@@ -52,6 +52,17 @@ export function getFrameCount(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) {
const {
fftSize,
@@ -61,6 +72,8 @@ export async function extractStftBins(channels, options) {
onProgress,
transformBatchSize = 32,
workspace: providedWorkspace,
spectrumPrecision = "float32",
includePartialFrame = true,
} = options;
if (!Array.isArray(channels) || channels.length === 0) throw new Error("마이크 채널이 필요합니다.");
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}`);
}
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;
if (!Number.isInteger(transformBatchSize) || 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();
for (const bin of uniqueBins) {
spectra.set(bin, {
real: new Float32Array(frameCount * microphoneCount),
imaginary: new Float32Array(frameCount * microphoneCount),
real: new SpectrumArray(frameCount * microphoneCount),
imaginary: new SpectrumArray(frameCount * microphoneCount),
});
}
@@ -116,7 +140,7 @@ export async function extractStftBins(channels, options) {
}
onProgress?.({ completedTransforms, totalTransforms });
throwIfAborted(signal);
return { frameCount, microphoneCount, spectra };
return { frameCount, microphoneCount, spectra, spectrumPrecision };
}
export function binsForBand(band, sampleRate, fftSize) {
+494 -20
View File
@@ -8,6 +8,10 @@ import {
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
estimateSynthesisWorkingSet,
} 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 RESULT_SCHEMA_VERSION = "1.1.0";
@@ -16,7 +20,7 @@ const MAX_SOURCES = 32;
const MAX_GRID_SIDE = 512;
const MAX_FREQUENCY_RESPONSE_POINTS = 64;
const MAX_FREQUENCY_RESPONSE_HZ = 1_000_000;
const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat"];
const LOCALIZATION_ALGORITHMS = ["das", "das-phat", "gcc-phat", "music"];
/** @typedef {{ path: string, message: string }} ValidationIssue */
@@ -416,13 +420,6 @@ function validateExperimentRelationships(value, context = {}) {
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
requireString(value.arrayId, "$.arrayId", 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)) {
requireFinite(value.scan.z, "$.scan.z", issues, { min: 0, exclusiveMin: true });
for (const key of ["xMin", "xMax", "yMin", "yMax"]) {
@@ -482,7 +479,53 @@ function validateExperimentRelationships(value, context = {}) {
) {
value.frequencyBands.forEach((band, index) => {
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) {
addIssue(
issues,
@@ -497,20 +540,20 @@ function validateExperimentRelationships(value, context = {}) {
addIssue(
issues,
"$.beamforming.algorithm",
"das, das-phat 또는 gcc-phat이어야 합니다.",
"das, das-phat, gcc-phat 또는 music이어야 합니다.",
);
}
if (!["auto", "cpu", "webgpu"].includes(value.beamforming.backend)) {
addIssue(issues, "$.beamforming.backend", "auto, cpu 또는 webgpu여야 합니다.");
}
if (
value.beamforming.algorithm === "gcc-phat" &&
["gcc-phat", "music"].includes(value.beamforming.algorithm) &&
value.beamforming.backend === "webgpu"
) {
addIssue(
issues,
"$.beamforming.backend",
"gcc-phat는 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
"선택한 위치 추정 알고리즘은 현재 CPU backend만 지원합니다. auto 또는 cpu를 선택하세요.",
);
}
requireFinite(value.beamforming.minimumDistance, "$.beamforming.minimumDistance", issues, {
@@ -536,6 +579,44 @@ function validateExperimentRelationships(value, context = {}) {
{ 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)) {
const threshold = value.evaluation.detectionThresholdRatio;
@@ -558,6 +639,101 @@ function validateExperimentRelationships(value, context = {}) {
"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 {
const workingSet = estimateSynthesisWorkingSet({
scene: context.scene,
@@ -591,8 +767,6 @@ function validateResultRelationships(value, context = {}) {
if (!isRecord(value)) return issues;
if (context.experiment) {
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
if (value.arrayId !== context.experiment.arrayId) addIssue(issues, "$.arrayId", "실험의 배열 id와 다릅니다.");
if (value.sceneId !== context.experiment.sceneId) addIssue(issues, "$.sceneId", "실험의 장면 id와 다릅니다.");
if (
value.algorithm !== undefined &&
value.algorithm !== context.experiment.beamforming?.algorithm
@@ -600,14 +774,34 @@ function validateResultRelationships(value, context = {}) {
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 expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
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(
issues,
"$.backend",
"gcc-phat Result must use the CPU backend.",
"The selected Result algorithm must use the CPU backend.",
);
}
const expectedRawScale =
@@ -615,6 +809,8 @@ function validateResultRelationships(value, context = {}) {
? "power"
: resultAlgorithm === "das-phat" || resultAlgorithm === "gcc-phat"
? "score"
: resultAlgorithm === "music"
? "noise-projection"
: undefined;
const expectedBeamSpreadMethod =
expectedRawScale === "power"
@@ -622,6 +818,51 @@ function validateResultRelationships(value, context = {}) {
: expectedRawScale === "score"
? "connected-half-maximum-score-weighted-covariance"
: 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 (
Number.isFinite(value.timingMs?.localization) &&
Number.isFinite(value.timingMs?.beamforming) &&
@@ -829,6 +1070,19 @@ function validateResultRelationships(value, context = {}) {
const standaloneObservation = standaloneObservations.get(bandIndex);
if (standaloneObservation && isRecord(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
.get(standaloneObservation.adcBinSetKey) ?? [])
.filter((candidateId) => candidateId !== band.id)
@@ -848,10 +1102,31 @@ function validateResultRelationships(value, context = {}) {
localizationModel:
resultAlgorithm === "gcc-phat"
? "band-limited-gcc-phat-geometric-lag"
: resultAlgorithm === "music"
? "near-field-music-at-configured-analog-frequency"
: "spherical-steering-at-positive-adc-bin-frequency",
originalCarrierUsed: standaloneObservation.originalCarrierUsed,
originalCarrierUsed:
resultAlgorithm === "music",
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,
continuousManyToOne: standaloneObservation.continuousManyToOne,
gridManyToOne: standaloneObservation.gridManyToOne,
@@ -945,8 +1220,15 @@ function validateResultRelationships(value, context = {}) {
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
}
});
if (isRecord(band.rawMap) && !["power", "score"].includes(band.rawMap.scale)) {
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power 또는 score scale이어야 합니다.");
if (
isRecord(band.rawMap) &&
!["power", "score", "noise-projection"].includes(band.rawMap.scale)
) {
addIssue(
issues,
`${path}.rawMap.scale`,
"rawMap은 power, score 또는 noise-projection scale이어야 합니다.",
);
} else if (
isRecord(band.rawMap) &&
expectedRawScale &&
@@ -958,6 +1240,188 @@ function validateResultRelationships(value, context = {}) {
`${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)) {
band.peaks.forEach((peak, peakIndex) => {
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
@@ -974,6 +1438,16 @@ function validateResultRelationships(value, context = {}) {
`${expectedRawScale} map과 맞는 beamSpread method여야 합니다.`,
);
}
if (
resultAlgorithm === "music" &&
isRecord(peak?.beamSpread)
) {
addIssue(
issues,
`${path}.peaks[${peakIndex}].beamSpread`,
"MUSIC noise-projection에는 DAS/PHAT half-power beamSpread를 적용할 수 없습니다.",
);
}
});
}
});
+9 -8
View File
@@ -1,5 +1,6 @@
import { computeDasMap } from "../acoustics-core/das.js";
import { computeGccPhatMap } from "../acoustics-core/gcc-phat.js";
import { computeLocalizationMap } from "../acoustics-core/localization.js";
import {
isDasAlgorithm,
requireLocalizationAlgorithmDefinition,
@@ -79,9 +80,10 @@ export async function computeDasWithBackend(options, dependencies = {}) {
}
/**
* Execute any registered localization algorithm. GCC-PHAT currently uses its
* portable CPU implementation for both `auto` and `cpu`; an explicit WebGPU
* request is rejected instead of silently changing the requested backend.
* Execute any registered localization algorithm. CPU-only algorithms use
* their portable implementation for both `auto` and `cpu`; an explicit
* WebGPU request is rejected instead of silently changing the requested
* backend.
*/
export async function computeLocalizationWithBackend(options, dependencies = {}) {
const algorithm = requireLocalizationAlgorithmDefinition(
@@ -90,7 +92,7 @@ export async function computeLocalizationWithBackend(options, dependencies = {})
if (isDasAlgorithm(algorithm.id)) {
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 + ".");
}
@@ -103,9 +105,8 @@ export async function computeLocalizationWithBackend(options, dependencies = {})
if (requestedBackend !== "auto" && requestedBackend !== "cpu") {
throw new TypeError(`unsupported localization backend: ${requestedBackend}`);
}
const computeCpu =
dependencies.computeGccCpu ??
dependencies.computeCpu ??
computeGccPhatMap;
const computeCpu = algorithm.id === "gcc-phat"
? dependencies.computeGccCpu ?? dependencies.computeCpu ?? computeGccPhatMap
: dependencies.computeMusicCpu ?? dependencies.computeCpu ?? computeLocalizationMap;
return computeCpu(options);
}
+4 -2
View File
@@ -43,13 +43,15 @@
"type": "object", "additionalProperties": false,
"required": ["algorithm", "backend", "minimumDistance", "normalization", "dynamicRangeDb"],
"properties": {
"algorithm": { "enum": ["das", "das-phat", "gcc-phat"] },
"algorithm": { "enum": ["das", "das-phat", "gcc-phat", "music"] },
"backend": { "enum": ["auto", "cpu", "webgpu"] },
"minimumDistance": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 },
"normalization": { "enum": ["peak", "none"] },
"dynamicRangeDb": { "type": "number", "minimum": 6, "maximum": 120 },
"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": {
+303 -5
View File
@@ -15,7 +15,7 @@
"arrayId": { "type": "string", "minLength": 1 },
"sceneId": { "type": "string", "minLength": 1 },
"createdAt": { "type": "string", "format": "date-time" },
"algorithm": { "enum": ["das", "das-phat", "gcc-phat"] },
"algorithm": { "enum": ["das", "das-phat", "gcc-phat", "music"] },
"backend": { "enum": ["cpu", "webgpu"] },
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
"signalStats": {
@@ -51,6 +51,7 @@
}
},
"environment": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
"musicModel": { "$ref": "#/$defs/musicModel" },
"bands": {
"type": "array", "minItems": 1, "maxItems": 8,
"items": { "$ref": "#/$defs/bandResult" }
@@ -123,11 +124,27 @@
"localizationModel": {
"enum": [
"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 },
"aliasOrderResolution": { "enum": ["not-applicable", "not-attempted"] },
"originalCarrierUsed": { "type": "boolean" },
"aliasOrderResolution": {
"enum": [
"not-applicable",
"not-attempted",
"configured-single-hypothesis"
]
},
"configuredAnalogFrequencyHz": {
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
},
"signedAdcFrequencyHz": { "type": "number" },
"spectralInversion": { "type": "boolean" },
"frequencyHypothesisCount": { "const": 1 },
"frequencyHypothesisSource": {
"const": "experiment-band-not-scene-truth"
},
"foldBoundaryCount": { "type": "integer", "minimum": 0, "maximum": 512 },
"continuousManyToOne": { "type": "boolean" },
"gridManyToOne": { "type": "boolean" },
@@ -185,7 +202,8 @@
"properties": {
"rows": { "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": {
"type": "array", "minItems": 1, "maxItems": 262144,
"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": {
"type": "object", "additionalProperties": false,
"required": [
@@ -259,6 +552,10 @@
"matchToleranceM": { "type": "number", "minimum": 0 },
"detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 },
"detectionThresholdValue": { "type": "number", "minimum": 0 },
"objectiveDirection": { "enum": ["maximum", "minimum"] },
"detectionThresholdDomain": {
"enum": ["raw-map-value", "normalized-music-pseudospectrum"]
},
"detectionMethod": {
"enum": ["local-max", "connected-components", "watershed"]
},
@@ -289,6 +586,7 @@
"displayMap": { "$ref": "#/$defs/map" },
"frequencyObservation": { "$ref": "#/$defs/frequencyObservation" },
"evaluation": { "$ref": "#/$defs/evaluation" },
"musicDiagnostics": { "$ref": "#/$defs/musicDiagnostics" },
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
"metrics": {
+9 -2
View File
@@ -613,6 +613,9 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
() => cameraDocument.documentElement.dataset.appReady === "true",
"Camera handoff initialization",
);
const defaultCameraExperiment = await cameraWindow
.fetch("/assets/sample-scenes/single-tone.experiment.json")
.then((response) => response.json());
const handoffFile = new cameraWindow.File(
[JSON.stringify(editedExport)],
"builder-handoff.array.json",
@@ -642,8 +645,12 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
);
assertEqual(
cameraExperiment.arrayId,
editedExport.id,
"Camera rebinds Experiment to the Builder Array ID",
defaultCameraExperiment.arrayId,
"Camera preserves the Experiment Array provenance label",
);
assert(
cameraExperiment.arrayId !== editedExport.id,
"opening a Builder Array does not rewrite Experiment provenance",
);
} finally {
cameraWindow.URL.createObjectURL = originalCameraCreate;
+85 -16
View File
@@ -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("#camera-help").open, "limitations help starts collapsed");
assertEqual(documentObject.querySelector("#algorithm").value, "das", "default DAS algorithm");
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 3, "three runnable algorithms");
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 4, "four runnable algorithms");
assert(!documentObject.querySelector("#postprocess"), "disabled postprocess advertisement is absent");
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
assertEqual(
@@ -325,6 +325,63 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
);
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";
await nextPaint(windowObject);
assert(
@@ -448,7 +505,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
() => documentObject.documentElement.dataset.inputBusy === "false" &&
documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"Experiment import and automatic reference binding",
"Experiment import with independent provenance labels",
);
assertEqual(
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("arrayId") &&
!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(
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") &&
@@ -474,29 +531,33 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
);
documentObject.querySelector("#export-experiment-button").click();
const reboundDownload = await waitFor(
const preservedDownload = await waitFor(
() => 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());
assertEqual(reboundExperiment.sceneId, openScene.id, "import binds the open Scene ID");
const preservedExperiment = await fetch(preservedDownload.url).then((response) => response.json());
assertEqual(
reboundExperiment.arrayId,
openArrayDefinition.id,
"import binds the open Array ID",
preservedExperiment.sceneId,
experiment.sceneId,
"import preserves the Experiment Scene provenance label",
);
assertEqual(
JSON.stringify(reboundExperiment.frequencyBands),
preservedExperiment.arrayId,
experiment.arrayId,
"import preserves the Experiment Array provenance label",
);
assertEqual(
JSON.stringify(preservedExperiment.frequencyBands),
JSON.stringify(expectedBands),
"automatic reference binding preserves every imported band",
"provenance preservation keeps every imported band",
);
assertEqual(
(await validateExperiment(reboundExperiment, {
(await validateExperiment(preservedExperiment, {
scene: openScene,
arrayDefinition: openArrayDefinition,
})).length,
0,
"rebound Experiment passes relationship validation",
"provenance labels may differ from the open Scene and Array",
);
downloads.length = 0;
@@ -678,8 +739,16 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
JSON.stringify(expectedBands),
"Experiment export preserves every band after Scene reimport",
);
assertEqual(exportedExperiment.sceneId, scene.id, "Experiment rebinds only the Scene ID");
assertEqual(exportedExperiment.arrayId, arrayDefinition.id, "Experiment rebinds the Array ID");
assertEqual(
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 {
windowObject.confirm = originalConfirm;
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
+1
View File
@@ -12,6 +12,7 @@ try {
await import("/tests/unit/contracts.test.js");
await import("/tests/unit/acoustics.test.js");
await import("/tests/unit/gcc-phat.test.js");
await import("/tests/unit/music-numerics.test.js");
await import("/tests/unit/localization.test.js");
await import("/tests/unit/realtime-localizer.test.js");
await import("/tests/unit/builder.test.js");
+7 -1
View File
@@ -56,7 +56,7 @@ test("localization algorithm registry exposes stable ids and capabilities", () =
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
assertEqual(
LOCALIZATION_ALGORITHM_IDS.join(","),
"das,das-phat,gcc-phat",
"das,das-phat,gcc-phat,music",
"stable algorithm ids",
);
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS map scale");
@@ -70,8 +70,14 @@ test("localization algorithm registry exposes stable ids and capabilities", () =
"cpu",
"GCC-PHAT backend capability",
);
assertEqual(
localizationAlgorithmDefinition("music").rawScale,
"noise-projection",
"MUSIC map scale",
);
assert(isDasAlgorithm("das") && isDasAlgorithm("das-phat"), "DAS engine algorithms");
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(localizationAlgorithmDefinition("unknown"), undefined, "unknown lookup");
});
+236 -6
View File
@@ -22,6 +22,7 @@ import {
shiftSceneDistance,
} from "/apps/acoustic-camera/model.js";
import { validateResult } from "/packages/contracts/validation.js";
import { computeLocalizationMap } from "/packages/acoustics-core/localization.js";
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
function assertNear(actual, expected, tolerance, label) {
@@ -319,6 +320,49 @@ test("Camera workload counts unique overlapping FFT bins", () => {
assertEqual(gccWorkload.scanOperationCount, 320, "GCC pair-band scan lookups");
assertEqual(gccWorkload.scanOperationKind, "reference-pair lookup", "GCC operation kind");
assertEqual(gccWorkload.complexMacCount, 0, "GCC is not mislabeled as DAS complex MAC");
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", () => {
@@ -520,8 +564,8 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
};
const experiment = {
id: "test-experiment",
arrayId: arrayDefinition.id,
sceneId: scene.id,
arrayId: "declared-array-provenance",
sceneId: "declared-scene-provenance",
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
beamforming: { algorithm: "das-phat", backend: "auto" },
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.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");
assertEqual(result.algorithm, "das-phat", "actual algorithm 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.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(
(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",
);
const wrongExperiment = { ...experiment, arrayId: "another-array" };
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
assert(linkageIssues.some(({ path }) => path === "$.arrayId"), "validator catches snapshot linkage mismatch");
const wrongArrayResult = structuredClone(result);
wrongArrayResult.arrayId = "another-array";
let linkageIssues = await validateResult(wrongArrayResult, {
experiment,
scene,
arrayDefinition,
});
assert(
linkageIssues.some(({ path }) => path === "$.arrayId"),
"validator catches an actual Array snapshot linkage mismatch",
);
const wrongSceneResult = structuredClone(result);
wrongSceneResult.sceneId = "another-scene";
linkageIssues = await validateResult(wrongSceneResult, {
experiment,
scene,
arrayDefinition,
});
assert(
linkageIssues.some(({ path }) => path === "$.sceneId"),
"validator catches an actual Scene snapshot linkage mismatch",
);
});
test("MUSIC Result preserves its physical model and numerical audit", async () => {
const sampleRate = 8192;
const fftSize = 128;
const sampleCount = 256;
const arrayDefinition = {
id: "music-result-array",
microphones: [
{ id: "m0", position: [-0.1, 0.1, 0] },
{ id: "m1", position: [0.1, 0.1, 0] },
{ id: "m2", position: [-0.1, -0.1, 0] },
{ id: "m3", position: [0.1, -0.1, 0] },
],
};
const channels = arrayDefinition.microphones.map((_, microphoneIndex) =>
Float64Array.from(
{ length: sampleCount },
(_, sampleIndex) =>
Math.cos(
2 * Math.PI * 16 * sampleIndex / fftSize +
microphoneIndex * 0.21,
),
));
const scene = {
id: "music-result-scene",
sampleRate,
duration: sampleCount / sampleRate,
speedOfSound: 343,
sources: [],
};
const experiment = {
id: "music-result-experiment",
arrayId: arrayDefinition.id,
sceneId: scene.id,
scan: {
rows: 2,
columns: 2,
xMin: -0.2,
xMax: 0.2,
yMin: -0.2,
yMax: 0.2,
z: 1,
},
frequencyBands: [
{ id: "single-bin", minHz: 1000, maxHz: 1050, color: "#65f2bf" },
],
stft: { fftSize, hopSize: 64, window: "hann" },
beamforming: {
algorithm: "music",
backend: "cpu",
minimumDistance: 0.01,
normalization: "none",
compensateFrequencyResponse: true,
musicSourceCountMethod: "fixed",
musicSourceCount: 1,
},
};
const localization = await computeLocalizationMap({
channels,
sampleRate,
arrayDefinition,
scene,
experiment,
});
const run = {
...localization,
signalStats: {
preNormalizationPeak: 1,
appliedScale: 1,
clippedSampleCount: 0,
sampling: {
model: "ideal-real-sampler-no-antialias-filter",
sampleRate,
nyquistHz: sampleRate / 2,
toneObservations: [],
},
},
timingMs: {
total: 1,
signalGeneration: 0,
localization: 1,
beamforming: 1,
},
};
const result = createResultDocument({
arrayDefinition,
scene,
experiment,
run,
evaluations: new Map([[
"single-bin",
{ peaks: [], metrics: { precision: 1, recall: 1 } },
]]),
inputHash: "b".repeat(64),
createdAt: "2026-08-29T00:00:00.000Z",
});
assertEqual(result.bands[0].rawMap.scale, "noise-projection", "raw q scale");
assertEqual(result.bands[0].rawMap.objectiveDirection, "minimum", "q is minimized");
assertEqual(result.musicModel.originalCarrierUsed, true, "configured carrier is used");
assertEqual(
result.musicModel.hypothesisSource,
"experiment-band-not-scene-truth",
"carrier comes from Experiment rather than Scene truth",
);
assertEqual(result.musicModel.diagonalLoading, "none", "no hidden diagonal loading");
assertEqual(result.musicModel.multibandFusion, "none-each-band-is-separate-single-analog-hypothesis", "no band fusion");
assertEqual(result.bands[0].musicDiagnostics.eigenvalues.length, 4, "all eigenvalues exported");
assert(result.bands[0].musicDiagnostics.eigensolver.numericallyVerified, "EVD audit exported");
assertEqual(result.bands[0].musicDiagnostics.sourceCount.value, 1, "fixed K exported");
const resultIssues = await validateResult(result, {
experiment,
scene,
arrayDefinition,
});
assertEqual(
resultIssues.length,
0,
`MUSIC Result passes schema and semantic validation: ${JSON.stringify(resultIssues)}`,
);
const corrupted = structuredClone(result);
corrupted.bands[0].musicDiagnostics.sourceCount.value = 4;
const issues = await validateResult(corrupted, {
experiment,
scene,
arrayDefinition,
});
assert(
issues.some(({ path }) =>
path.endsWith("musicDiagnostics.sourceCount.value")),
"source count cannot equal microphone count",
);
const corruptedHypothesis = structuredClone(result);
corruptedHypothesis.bands[0].musicDiagnostics.frequencyHypothesis
.analogFrequencyHz += sampleRate;
const hypothesisIssues = await validateResult(corruptedHypothesis, {
experiment,
scene,
arrayDefinition,
});
assert(
hypothesisIssues.some(({ path }) =>
path.endsWith(
"musicDiagnostics.frequencyHypothesis.analogFrequencyHz",
)),
"numerical diagnostics cannot claim a different analog carrier",
);
});
+40 -4
View File
@@ -40,6 +40,8 @@ function resultFrequencyObservation(scene, experiment, band, equivalentBandIds =
localizationModel:
experiment.beamforming?.algorithm === "gcc-phat"
? "band-limited-gcc-phat-geometric-lag"
: experiment.beamforming?.algorithm === "music"
? "near-field-music-at-positive-adc-bin-frequency"
: "spherical-steering-at-positive-adc-bin-frequency",
originalCarrierUsed: observation.originalCarrierUsed,
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
@@ -234,17 +236,26 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
});
test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => {
test("네 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => {
const [arrayDefinition, scene, baseline] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
for (const algorithm of ["das", "das-phat", "gcc-phat"]) {
for (const algorithm of ["das", "das-phat", "gcc-phat", "music"]) {
const experiment = structuredClone(baseline);
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(
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
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);
incompatible.beamforming.algorithm = "gcc-phat";
incompatible.beamforming.backend = "webgpu";
@@ -271,7 +307,7 @@ test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다
"GCC-PHAT requires two microphones",
);
incompatible.beamforming.algorithm = "music";
incompatible.beamforming.algorithm = "invented-localizer";
issues = await validateExperiment(incompatible, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.beamforming.algorithm"),
+100
View File
@@ -6,6 +6,7 @@ import {
findLocalPeaks,
measureBeamSpread,
measureMainLobe,
normalizedMusicPseudospectrum,
selectResultMetrics,
} from "/packages/acoustics-core/evaluation.js";
@@ -426,3 +427,102 @@ test("evaluation이 non-finite, negative, shape 오류를 입력 경계에서
"display-only map scale rejected",
);
});
test("MUSIC noise projection is minimized without rewriting raw q values", () => {
const scan = {
xMin: 0,
xMax: 4,
yMin: 0,
yMax: 4,
z: 1,
columns: 5,
rows: 5,
};
const values = new Float64Array(25);
values.fill(1);
values[6] = 0.1;
values[18] = 0.2;
values[3] = 0.6;
const evaluation = evaluateLocalizationBand({
rawMap: {
rows: 5,
columns: 5,
scale: "noise-projection",
objectiveDirection: "minimum",
values,
},
scan,
sources: [
{ id: "left", position: [1, 3, 1] },
{ id: "right", position: [3, 1, 1] },
],
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
matchToleranceM: 0,
});
assertEqual(evaluation.globalPeak.index, 6, "smallest q is the global optimum");
assertNear(evaluation.globalPeak.value, 0.1, 1e-12, "global peak retains raw q");
assertEqual(evaluation.detections.length, 2, "two separated minima are detected");
assertNear(evaluation.detections[1].value, 0.2, 1e-12, "second detection retains raw q");
assert(!("beamSpread" in evaluation.detections[0]), "power beam spread is omitted");
assertEqual(
Object.keys(evaluation.metrics).join(","),
"precision,recall",
"only objective-independent metrics are reported",
);
assertEqual(
evaluation.diagnostics.detectionTransform,
"normalized-reciprocal-noise-projection",
"the standard normalized MUSIC pseudospectrum is disclosed",
);
const exported = exportBandEvaluation(evaluation);
assertEqual(exported.evaluation.objectiveDirection, "minimum", "minimum objective is exported");
assertEqual(
exported.evaluation.detectionThresholdDomain,
"normalized-music-pseudospectrum",
"threshold domain is explicit",
);
assertNear(exported.peaks[0].value, 0.1, 1e-12, "exported peak remains raw q");
const withBadOutlier = Float64Array.from(values);
withBadOutlier[0] = 100;
const outlierEvaluation = evaluateLocalizationBand({
rawMap: {
rows: 5,
columns: 5,
scale: "noise-projection",
objectiveDirection: "minimum",
values: withBadOutlier,
},
scan,
sources: [],
detectionThresholdRatio: 0.5,
minimumPeakDistanceM: 0,
});
assertEqual(
outlierEvaluation.detections.map(({ index }) => index).join(","),
evaluation.detections.map(({ index }) => index).join(","),
"a worse scan outlier does not move the reciprocal threshold",
);
const flat = evaluateLocalizationBand({
rawMap: {
rows: 5,
columns: 5,
scale: "noise-projection",
objectiveDirection: "minimum",
values: new Float64Array(25).fill(0.5),
},
scan,
sources: [],
});
const flatDisplay = normalizedMusicPseudospectrum(
new Float64Array(25).fill(0.5),
);
assert(
flatDisplay.values.every((value) => value === 1),
"flat q preserves the standard normalized reciprocal value",
);
assertEqual(flat.detections.length, 0, "flat q does not invent a location");
});
+364 -5
View File
@@ -3,6 +3,10 @@ import {
createLocalizationAnalysisPlan,
estimateLocalizationWorkload,
} from "/packages/acoustics-core/localization.js";
import {
createMusicBinCalibration,
isMusicUnavailableError,
} from "/packages/acoustics-core/music.js";
import {
DEFAULT_GCC_PHAT_CROSS_SPECTRUM_BATCH_SIZE,
DEFAULT_GCC_PHAT_POINT_BATCH_SIZE,
@@ -58,9 +62,9 @@ function createFixture(algorithm = "das") {
columns: 3,
rows: 3,
};
const frequencyBands = [
{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" },
];
const frequencyBands = algorithm === "music"
? [{ id: "single-bin", minHz: 500, maxHz: 530, color: "#65f2bf" }]
: [{ id: "wide", minHz: 128, maxHz: 1800, color: "#65f2bf" }];
const experiment = {
stft: { fftSize: 64, hopSize: 32, window: "hann" },
scan,
@@ -68,8 +72,11 @@ function createFixture(algorithm = "das") {
beamforming: {
algorithm,
backend: "cpu",
normalization: "peak",
normalization: algorithm === "music" ? "none" : "peak",
compensateFrequencyResponse: true,
...(algorithm === "music"
? { musicSourceCountMethod: "fixed", musicSourceCount: 1 }
: {}),
},
};
return {
@@ -83,12 +90,22 @@ function createFixture(algorithm = "das") {
test("localization algorithm registry exposes explicit scale and backend semantics", () => {
assertEqual(DEFAULT_LOCALIZATION_ALGORITHM, "das", "default algorithm");
assertEqual(LOCALIZATION_ALGORITHM_IDS.join(","), "das,das-phat,gcc-phat", "registered ids");
assertEqual(
LOCALIZATION_ALGORITHM_IDS.join(","),
"das,das-phat,gcc-phat,music",
"registered ids",
);
assertEqual(localizationAlgorithmDefinition("das").rawScale, "power", "DAS power scale");
assertEqual(localizationAlgorithmDefinition("das-phat").rawScale, "score", "DAS-PHAT score scale");
assertEqual(localizationAlgorithmDefinition("gcc-phat").rawScale, "score", "GCC-PHAT score scale");
assertEqual(
localizationAlgorithmDefinition("music").rawScale,
"noise-projection",
"MUSIC raw noise-projection scale",
);
assert(localizationAlgorithmSupportsBackend("das-phat", "webgpu"), "DAS-PHAT WebGPU 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 () => {
@@ -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");
});
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 () => {
const fixture = createFixture("gcc-phat");
const config = {
+507
View File
@@ -0,0 +1,507 @@
import {
auditCovarianceEigenvalues,
computeComplexSampleCovariance,
selectWaxKailathSourceCount,
} from "/packages/acoustics-core/covariance.js";
import {
decomposeHermitian,
estimateHermitianEigenWorkload,
} from "/packages/acoustics-core/hermitian-eigen.js";
import {
computeMusicMap,
estimateMusicWorkload,
} from "/packages/acoustics-core/music.js";
function assertNear(actual, expected, tolerance, label) {
assert(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, received ${actual}`,
);
}
function assertArrayNear(actual, expected, tolerance, label) {
assertEqual(actual.length, expected.length, `${label} length`);
for (let index = 0; index < actual.length; index += 1) {
assertNear(actual[index], expected[index], tolerance, `${label}[${index}]`);
}
}
function dftUnitary(order) {
const real = new Float64Array(order * order);
const imaginary = new Float64Array(order * order);
const scale = 1 / Math.sqrt(order);
for (let row = 0; row < order; row += 1) {
for (let column = 0; column < order; column += 1) {
const phase = (2 * Math.PI * row * column) / order;
const index = row * order + column;
real[index] = Math.cos(phase) * scale;
imaginary[index] = Math.sin(phase) * scale;
}
}
return { real, imaginary };
}
function hermitianFromEigenpairs(vectors, eigenvalues) {
const order = eigenvalues.length;
const real = new Float64Array(order * order);
const imaginary = new Float64Array(order * order);
for (let row = 0; row < order; row += 1) {
for (let column = row; column < order; column += 1) {
let valueReal = 0;
let valueImaginary = 0;
for (let eigenvector = 0; eigenvector < order; eigenvector += 1) {
const rowIndex = row * order + eigenvector;
const columnIndex = column * order + eigenvector;
const rowReal = vectors.real[rowIndex];
const rowImaginary = vectors.imaginary[rowIndex];
const columnReal = vectors.real[columnIndex];
const columnImaginary = vectors.imaginary[columnIndex];
valueReal += eigenvalues[eigenvector] *
(rowReal * columnReal + rowImaginary * columnImaginary);
valueImaginary += eigenvalues[eigenvector] *
(rowImaginary * columnReal - rowReal * columnImaginary);
}
const upper = row * order + column;
const lower = column * order + row;
real[upper] = valueReal;
imaginary[upper] = row === column ? 0 : valueImaginary;
real[lower] = valueReal;
imaginary[lower] = row === column ? 0 : -valueImaginary;
}
}
return { real, imaginary };
}
function projector(vectors, order, firstColumn, columnCount) {
const real = new Float64Array(order * order);
const imaginary = new Float64Array(order * order);
for (let row = 0; row < order; row += 1) {
for (let column = 0; column < order; column += 1) {
let valueReal = 0;
let valueImaginary = 0;
for (let vector = firstColumn; vector < firstColumn + columnCount; vector += 1) {
const rowIndex = row * order + vector;
const columnIndex = column * order + vector;
valueReal +=
vectors.real[rowIndex] * vectors.real[columnIndex] +
vectors.imaginary[rowIndex] * vectors.imaginary[columnIndex];
valueImaginary +=
vectors.imaginary[rowIndex] * vectors.real[columnIndex] -
vectors.real[rowIndex] * vectors.imaginary[columnIndex];
}
const index = row * order + column;
real[index] = valueReal;
imaginary[index] = valueImaginary;
}
}
return { real, imaginary };
}
function createAutomaticMusicFixture() {
const sampleRate = 3_200;
const fftSize = 32;
const snapshotCount = 64;
const frequencyHz = 500;
const speedOfSound = 343;
const source = [0.08, -0.04, 1];
const arrayDefinition = {
id: "automatic-music-audit-array",
microphones: [
{ id: "nw", position: [-0.1, 0.1, 0] },
{ id: "ne", position: [0.1, 0.1, 0] },
{ id: "sw", position: [-0.1, -0.1, 0] },
{ id: "se", position: [0.1, -0.1, 0] },
],
};
let state = 0x51f15e5d;
const random = () => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return ((state >>> 0) / 0xffff_ffff) * 2 - 1;
};
const sourceRealBySnapshot = Float64Array.from(
{ length: snapshotCount },
random,
);
const sourceImaginaryBySnapshot = Float64Array.from(
{ length: snapshotCount },
random,
);
const channels = arrayDefinition.microphones.map((microphone) => {
const distance = Math.hypot(
source[0] - microphone.position[0],
source[1] - microphone.position[1],
source[2] - microphone.position[2],
);
const propagationPhase = -(2 * Math.PI * frequencyHz * distance) / speedOfSound;
const channel = new Float64Array(snapshotCount * fftSize);
for (let snapshot = 0; snapshot < snapshotCount; snapshot += 1) {
const sourceReal = sourceRealBySnapshot[snapshot];
const sourceImaginary = sourceImaginaryBySnapshot[snapshot];
for (let offset = 0; offset < fftSize; offset += 1) {
const phase = (2 * Math.PI * frequencyHz * offset) / sampleRate + propagationPhase;
channel[snapshot * fftSize + offset] =
4 * (sourceReal * Math.cos(phase) - sourceImaginary * Math.sin(phase)) /
distance +
0.08 * random();
}
}
return channel;
});
const experiment = {
stft: { fftSize, hopSize: fftSize, window: "hann" },
scan: {
z: source[2],
xMin: source[0],
xMax: source[0],
yMin: source[1],
yMax: source[1],
columns: 1,
rows: 1,
},
frequencyBands: [
{ id: "tone", minHz: 499, maxHz: 501, color: "#65f2bf" },
],
beamforming: {
algorithm: "music",
backend: "cpu",
normalization: "none",
compensateFrequencyResponse: true,
musicSourceCountMethod: "mdl",
},
};
return {
channels,
sampleRate,
arrayDefinition,
scene: { speedOfSound },
experiment,
};
}
test("complex Hermitian Jacobi resolves the [4, 1] two-by-two reference", () => {
const result = decomposeHermitian({
real: Float64Array.of(2, 1, 1, 3),
imaginary: Float64Array.of(0, 1, -1, 0),
});
assert(result.converged, "2x2 Jacobi convergence");
assertArrayNear(result.eigenvalues, [4, 1], 2e-14, "2x2 eigenvalues");
assertEqual(result.eigenvectors.layout, "row-major-columns", "vector layout");
assert(result.relativeResidual < 1e-14, "2x2 relative residual");
assert(result.orthogonalityError < 1e-14, "2x2 orthogonality");
assert(result.numericallyVerified, "2x2 numerical verification");
});
test("Hermitian Jacobi diagonalizes a constructed DFT4 matrix", () => {
const vectors = dftUnitary(4);
const matrix = hermitianFromEigenpairs(vectors, [9, 4, 2, 1]);
const result = decomposeHermitian(matrix);
assert(result.converged, "DFT4 convergence");
assertArrayNear(result.eigenvalues, [9, 4, 2, 1], 2e-12, "DFT4 eigenvalues");
assert(result.relativeResidual < 2e-13, "DFT4 relative residual");
assert(result.relativeOffDiagonal < result.relativeTolerance, "DFT4 off-diagonal bound");
});
test("repeated Hermitian eigenspaces preserve their invariant projector", () => {
const expectedVectors = dftUnitary(4);
const matrix = hermitianFromEigenpairs(expectedVectors, [5, 5, 1, 1]);
const result = decomposeHermitian(matrix);
assert(result.converged, "repeated-eigenspace convergence");
assertArrayNear(result.eigenvalues, [5, 5, 1, 1], 2e-12, "repeated eigenvalues");
const expectedProjector = projector(expectedVectors, 4, 0, 2);
const actualProjector = projector(result.eigenvectors, 4, 0, 2);
assertArrayNear(actualProjector.real, expectedProjector.real, 2e-12, "projector real");
assertArrayNear(
actualProjector.imaginary,
expectedProjector.imaginary,
2e-12,
"projector imaginary",
);
});
test("Hermitian eigenpairs are deterministic and phase-canonicalized", () => {
const matrix = hermitianFromEigenpairs(dftUnitary(4), [12, 7, 3, 0.5]);
const first = decomposeHermitian(matrix);
const second = decomposeHermitian(matrix);
assertArrayNear(second.eigenvalues, first.eigenvalues, 0, "deterministic eigenvalues");
assertArrayNear(second.eigenvectors.real, first.eigenvectors.real, 0, "deterministic real vectors");
assertArrayNear(
second.eigenvectors.imaginary,
first.eigenvectors.imaginary,
0,
"deterministic imaginary vectors",
);
for (let column = 0; column < first.order; column += 1) {
let maximumMagnitude = 0;
for (let row = 0; row < first.order; row += 1) {
const index = row * first.order + column;
const magnitude = Math.hypot(
first.eigenvectors.real[index],
first.eigenvectors.imaginary[index],
);
maximumMagnitude = Math.max(maximumMagnitude, magnitude);
}
let canonicalPivotFound = false;
for (let row = 0; row < first.order; row += 1) {
const index = row * first.order + column;
const magnitude = Math.hypot(
first.eigenvectors.real[index],
first.eigenvectors.imaginary[index],
);
if (
magnitude >= maximumMagnitude * (1 - 16 * Number.EPSILON) &&
first.eigenvectors.real[index] > 0 &&
first.eigenvectors.imaginary[index] === 0
) {
canonicalPivotFound = true;
}
}
assert(canonicalPivotFound, `column ${column} has a positive-real maximal pivot`);
}
});
test("Hermitian Jacobi reports non-convergence without returning it as success", () => {
const result = decomposeHermitian({
real: Float64Array.of(
4, 1, 0.5,
1, 3, 1.5,
0.5, 1.5, 2,
),
imaginary: Float64Array.of(
0, 0.5, -0.3,
-0.5, 0, 0.7,
0.3, -0.7, 0,
),
maximumSweeps: 1,
});
assert(!result.converged, "one-sweep result is explicitly unconverged");
assertEqual(result.sweeps, 1, "sweep limit is exact");
assert(!result.numericallyVerified, "partial eigenpairs are not verified");
assert(result.relativeResidual > result.relativeTolerance, "unconverged residual remains visible");
});
test("Hermitian workload rejects M=1024 before reading or allocating matrix data", () => {
const order = 1024;
const workload = estimateHermitianEigenWorkload({ order });
assert(workload.allocationBytes > 16 * 1024 * 1024, "M=1024 exceeds default bytes");
let numericRead = false;
const fakeMatrix = new Proxy(
{ length: order * order },
{
get(target, property) {
if (typeof property === "string" && /^\d+$/.test(property)) numericRead = true;
return target[property];
},
},
);
let error;
try {
decomposeHermitian({ real: fakeMatrix, imaginary: fakeMatrix, order });
} catch (caught) {
error = caught;
}
assert(error instanceof RangeError, "M=1024 is rejected by the workload cap");
assert(!numericRead, "workload cap fires before matrix values are read");
});
test("sample covariance uses compensated upper-triangle accumulation and exact conjugates", () => {
const cancellation = computeComplexSampleCovariance({
sensorCount: 2,
snapshotCount: 3,
layout: "snapshot-major",
real: Float64Array.of(1, 1e16, 1, 1, 1, -1e16),
imaginary: new Float64Array(6),
});
assertNear(cancellation.matrix.real[1], 1 / 3, 1e-16, "compensated cross covariance");
const complex = computeComplexSampleCovariance({
sensorCount: 2,
snapshotCount: 1,
real: Float64Array.of(1, 2),
imaginary: Float64Array.of(1, 3),
});
assertNear(complex.matrix.real[1], 5, 0, "upper real");
assertNear(complex.matrix.imaginary[1], -1, 0, "upper imaginary");
assertNear(complex.matrix.real[2], 5, 0, "lower real");
assertNear(complex.matrix.imaginary[2], 1, 0, "exact conjugate lower imaginary");
assertEqual(complex.matrix.imaginary[0], 0, "real diagonal 0");
assertEqual(complex.normalization, "1/T", "covariance normalization disclosed");
assertEqual(complex.centered, false, "covariance is not silently centered");
});
test("T<M rank-two covariance remains rank-deficient and source count is unavailable", () => {
const sensorCount = 4;
const snapshotCount = 2;
const covariance = computeComplexSampleCovariance({
sensorCount,
snapshotCount,
real: Float64Array.of(1, 2, 3, 4, 0, 1, 2, -1),
imaginary: Float64Array.of(0, 0, 0, 0, 1, -1, 0.5, 2),
});
const decomposition = decomposeHermitian({
...covariance.matrix,
order: sensorCount,
});
assert(decomposition.converged, "rank-two covariance decomposition");
const audit = auditCovarianceEigenvalues({
eigenvalues: decomposition.eigenvalues,
sensorCount,
matrixScale: covariance.matrixScale,
covarianceRoundoffBound: covariance.covarianceRoundoffBound,
eigensolverResidualFrobeniusNorm: decomposition.residualFrobeniusNorm,
});
assertEqual(
audit.roundoffDerivation,
"covariance-accumulation-plus-eigensolver-residual",
"PSD roundoff derivation",
);
assertNear(audit.roundoffClampedEigenvalues[2], 0, audit.roundoffTolerance, "rank eigenvalue 2");
assertNear(audit.roundoffClampedEigenvalues[3], 0, audit.roundoffTolerance, "rank eigenvalue 3");
const selected = selectWaxKailathSourceCount({
eigenvalues: audit.roundoffClampedEigenvalues,
snapshotCount,
independentSnapshots: true,
criterion: "mdl",
roundoffTolerance: audit.roundoffTolerance,
});
assert(!selected.available, "T<M MDL unavailable");
assertEqual(
selected.reason,
"snapshot-count-not-greater-than-sensors",
"T<M reason",
);
});
test("Wax-Kailath explicitly declines noiseless rank-deficient spectra", () => {
const result = selectWaxKailathSourceCount({
eigenvalues: Float64Array.of(5, 2, 0, 0),
snapshotCount: 100,
independentSnapshots: true,
criterion: "mdl",
roundoffTolerance: 1e-12,
});
assert(!result.available, "rank-deficient MDL unavailable");
assertEqual(
result.reason,
"noise-eigenvalue-not-strictly-above-roundoff",
"rank-deficient reason",
);
});
test("Wax-Kailath AIC and MDL select q=1 for [10,1,1,1]", () => {
for (const criterion of ["aic", "mdl"]) {
const result = selectWaxKailathSourceCount({
eigenvalues: Float64Array.of(10, 1, 1, 1),
snapshotCount: 100,
independentSnapshots: true,
criterion,
roundoffTolerance: 1e-14,
});
assert(result.available, `${criterion} available`);
assertEqual(result.sourceCount, 1, `${criterion} source count`);
}
});
test("Wax-Kailath AIC and MDL select q=0 for an identity covariance", () => {
for (const criterion of ["aic", "mdl"]) {
const result = selectWaxKailathSourceCount({
eigenvalues: Float64Array.of(1, 1, 1, 1),
snapshotCount: 100,
independentSnapshots: true,
criterion,
roundoffTolerance: 1e-14,
});
assert(result.available, `${criterion} available`);
assertEqual(result.sourceCount, 0, `${criterion} identity source count`);
}
});
test("PSD audit preserves tiny positives and rejects genuine negative eigenvalues", () => {
const tinyPositive = 1e-20;
const audit = auditCovarianceEigenvalues({
eigenvalues: Float64Array.of(10, 1, tinyPositive, -1e-14),
sensorCount: 4,
matrixScale: 10,
roundoffTolerance: 1e-12,
});
assertEqual(audit.rawEigenvalues[2], tinyPositive, "raw tiny positive");
assertEqual(
audit.roundoffClampedEigenvalues[2],
tinyPositive,
"tiny positive is not floored",
);
assertEqual(audit.roundoffClampedEigenvalues[3], 0, "roundoff negative clamps to zero");
let error;
try {
auditCovarianceEigenvalues({
eigenvalues: Float64Array.of(10, 1, 0.1, -1e-3),
sensorCount: 4,
roundoffTolerance: 1e-12,
});
} catch (caught) {
error = caught;
}
assert(error instanceof RangeError, "genuine negative eigenvalue is rejected");
});
test("MUSIC workload includes PSD-audit and automatic source-count typed arrays", () => {
const automatic = createAutomaticMusicFixture();
const automaticWorkload = estimateMusicWorkload(automatic);
const microphoneCount = automatic.arrayDefinition.microphones.length;
const fixed = {
...automatic,
experiment: structuredClone(automatic.experiment),
};
fixed.experiment.beamforming.musicSourceCountMethod = "fixed";
fixed.experiment.beamforming.musicSourceCount = 1;
const fixedWorkload = estimateMusicWorkload(fixed);
assertEqual(
automaticWorkload.covarianceAuditBytes,
2 * microphoneCount * Float64Array.BYTES_PER_ELEMENT,
"raw and roundoff-clamped PSD audit arrays",
);
assertEqual(fixedWorkload.sourceCountSelectionBytes, 0, "fixed K has no score array");
assertEqual(
automaticWorkload.sourceCountSelectionBytes,
microphoneCount * Float64Array.BYTES_PER_ELEMENT,
"automatic K criterion score array",
);
assertEqual(
automaticWorkload.perBinNumericalBytes - fixedWorkload.perBinNumericalBytes,
automaticWorkload.sourceCountSelectionBytes,
"automatic score bytes reach per-bin numerical peak",
);
assertEqual(
automaticWorkload.baseWorkingBytes - fixedWorkload.baseWorkingBytes,
automaticWorkload.sourceCountSelectionBytes,
"automatic score bytes reach total working peak",
);
});
test("MUSIC reports PSD derivation and labels MDL independence as an unverified model assumption", async () => {
const result = await computeMusicMap(createAutomaticMusicFixture());
const diagnostics = result.bands[0].diagnostics;
assertEqual(diagnostics.sourceCount.method, "mdl", "MDL selected model order");
assertEqual(diagnostics.sourceCount.value, 1, "one dominant source selected");
assertEqual(
diagnostics.covarianceAudit.roundoffDerivation,
"covariance-accumulation-plus-eigensolver-residual",
"PSD roundoff derivation is serialized from the audit API",
);
const independence =
diagnostics.sourceCount.assumptions.statisticalSnapshotIndependence;
assertEqual(independence.assumed, true, "statistical independence is an assumption");
assertEqual(
independence.evidence,
"non-overlapping-frames-only-not-statistical-proof",
"non-overlap is not mislabeled as statistical proof",
);
assertEqual(independence.modelSelectedByUser, true, "MDL model was selected by the user");
assertEqual(
diagnostics.sourceCount.assumptions.statisticalDataModel.verified,
false,
"Wax-Kailath data model is not claimed as verified",
);
});
+92
View File
@@ -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 () => {
const fixture = createFixture(128);
const plan = createLocalizationPlan(fixture.config);