feat: add physically explicit narrowband MUSIC
This commit is contained in:
@@ -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`를
|
||||
측정해 판정해야 합니다.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user