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
+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>
+309 -64
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,8 +796,13 @@ function renderWorkload() {
? `${workload.referencePairCount} ref pairs · 약 ${formatCount(workload.scanOperationCount)} scan lookups + ` +
`${formatCount(workload.gccCrossSpectrumComplexOps)} cross-spectrum ops + ` +
`${formatCount(workload.inverseFftTransformCount)} inverse FFTs`
: `${workload.frameCount} frames × ${workload.selectedBinCount} bins · ` +
`약 ${formatCount(workload.scanOperationCount)} complex MAC`;
: 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 =
`${workload.algorithmLabel} · ${workload.pointCount.toLocaleString("ko-KR")} points × ` +
`${workload.microphoneCount} ch · STFT ${formatCount(workload.stftTransformCount)} transforms · ` +
@@ -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,9 +1076,11 @@ function renderResult() {
: "";
appendMetric(
`${band.id} · ADC steering`,
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
resultAlgorithm === "gcc-phat"
? "ADC LAG"
: "OBSERVED BIN",
: 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,32 +1118,45 @@ function renderResult() {
`${evaluation.counts.truePositive}/${evaluation.counts.truth} source`,
band.color,
);
appendMetric(
`${band.id} · Truth concentration`,
`${formatNumber(evaluation.metrics.energyInsideTruthRatio * 100, 1)}%`,
"ground-truth 반경 내",
band.color,
);
appendMetric(
`${band.id} · Peak/sidelobe`,
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
"높을수록 분리 우수",
band.color,
);
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)}%`,
"ground-truth 반경 내",
band.color,
);
appendMetric(
`${band.id} · Peak/sidelobe`,
`${formatNumber(evaluation.metrics.peakToSidelobeDb, 1)} dB`,
"높을수록 분리 우수",
band.color,
);
}
appendMetric(
`${band.id} · Precision / Recall`,
`${formatNumber(evaluation.metrics.precision, 2)} / ${formatNumber(evaluation.metrics.recall, 2)}`,
`${evaluation.counts.detected} peaks`,
band.color,
);
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
evaluation.diagnostics.mapScale === "score"
? "half-maximum score diameter"
: "half-power equivalent diameter",
band.color,
);
if (evaluation.diagnostics.objectiveDirection === "maximum") {
appendMetric(
`${band.id} · Main lobe`,
`${formatNumber(evaluation.diagnostics.mainLobeWidthM * 100, 1)} cm`,
evaluation.diagnostics.mapScale === "score"
? "half-maximum score diameter"
: "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`,
+174 -15
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,19 +70,39 @@ export async function hashSimulationInputs({ arrayDefinition, scene, experiment
}
export function estimateExperimentWorkload(arrayDefinition, scene, experiment) {
const frameCount = getFrameCount(
Math.ceil(scene.duration * scene.sampleRate),
experiment.stft.fftSize,
experiment.stft.hopSize,
);
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,
);
const bandBins = experiment.frequencyBands.map((band) =>
binsForBand(band, scene.sampleRate, experiment.stft.fftSize),
);
const bandBinSelectionCount = bandBins.reduce((total, bins) => total + bins.length, 0);
const selectedBinCount = new Set(bandBins.flat()).size;
const 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",
};
}