From de0053eae3fb1ab369f96f80e9fd86b9c3b5ceea Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Sat, 29 Aug 2026 10:21:33 +0900 Subject: [PATCH] fix: expose physical aliasing and delayed arrivals --- README.md | 22 +- apps/acoustic-camera/README.md | 25 +- apps/acoustic-camera/index.html | 1 + apps/acoustic-camera/main.js | 255 +++++++- apps/acoustic-camera/model.js | 102 ++- apps/acoustic-camera/simulation-worker.js | 6 + apps/acoustic-camera/source-selection.js | 41 ++ apps/acoustic-camera/styles.css | 10 + apps/dataset-builder/README.md | 4 + apps/dataset-builder/index.html | 2 +- apps/dataset-builder/main.js | 3 +- apps/dataset-builder/model.js | 18 +- docs/acoustic-model.md | 97 +++ docs/data-contracts.md | 49 +- docs/execution-plan.md | 13 +- docs/index.html | 2 +- docs/realtime-porting.md | 5 +- packages/acoustics-core/README.md | 39 +- packages/acoustics-core/math.js | 29 + packages/acoustics-core/propagation.js | 452 ++++++++++++-- packages/acoustics-core/realtime-localizer.js | 10 +- packages/acoustics-core/sampling.js | 250 ++++++++ packages/acoustics-core/signal.js | 14 +- packages/acoustics-core/stft.js | 9 +- packages/acoustics-core/trajectory.js | 70 ++- packages/contracts/README.md | 10 +- packages/contracts/validation.js | 423 ++++++++++++- schemas/README.md | 11 +- schemas/experiment.schema.json | 3 +- schemas/result.schema.json | 139 ++++- schemas/scene.schema.json | 2 +- tests/integration/builder-ui.test.js | 14 + tests/integration/camera-ui.test.js | 122 +++- tests/unit/acoustics.test.js | 579 +++++++++++++++++- tests/unit/builder.test.js | 24 + tests/unit/camera.test.js | 196 +++++- tests/unit/contracts.test.js | 225 ++++++- tests/unit/realtime-localizer.test.js | 24 + 38 files changed, 3086 insertions(+), 214 deletions(-) create mode 100644 apps/acoustic-camera/source-selection.js create mode 100644 packages/acoustics-core/sampling.js diff --git a/README.md b/README.md index 2067027..537a75e 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,10 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve. Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에 자동으로 연결합니다. 가져온 `frequencyBands`와 나머지 분석 설정은 -보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행하므로 Nyquist·scan 등 -실제 오류는 그대로 거부됩니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다. +보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행합니다. Nyquist 위 tone과 +band는 오류가 아니라 ideal ADC alias 실험으로 허용되며, 원 주파수와 실제 ADC FFT range가 UI와 +Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제 오류는 그대로 거부됩니다. Scene을 +다시 불러오면 이전 asset 연결은 해제됩니다. Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에 유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와 @@ -238,20 +240,24 @@ channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부 ## Result JSON -Result JSON은 계약 버전 `1.0.0`의 `micarrsim.result` 문서입니다. 주요 내용은 다음과 같습니다. +Result JSON은 계약 버전 `1.1.0`의 `micarrsim.result` 문서입니다. Array·Scene·Experiment는 +`1.0.0`을 유지합니다. 주요 내용은 다음과 같습니다. - 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `algorithm`·`backend`와 생성 시각 - Array·Scene·Experiment의 canonical SHA-256인 `inputHash` - 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats` +- 원 tone→signed ADC alias, DC/Nyquist 퇴화와 anti-alias filter 부재를 담은 필수 sampling provenance - 전체·신호 생성·위치 추정 시간인 `timingMs.localization`과 기존 reader용 `beamforming` alias - 주파수 대역별 power 또는 score `rawMap`, 표시용 `displayMap`, 검출 `peaks` +- band별 Nyquist zone fold, FFT bin-center 선택, 실제 localization model과 동일 ADC band 목록 - 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기 위한 평가 파라미터와 `metrics` - peak별 half-power 공분산과 RMS ellipse를 담은 `beamSpread` (`calibrated: false`) -평가에서 tone source는 tone 주파수가 포함되는 band의 ground truth로만 사용합니다. Audio asset과 -noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 ground truth로 취급합니다. 이 가정은 -결과를 실제 데이터와 해석할 때 고려해야 합니다. +평가에서 tone source는 요청 analog interval 또는 접힌 주파수의 nearest ADC bin을 기준으로 +사용합니다. Audio asset과 noise +source는 임의 spectrum threshold를 만들지 않고 모든 band의 ground truth로 취급합니다. 정확한 +정책은 Result `evaluation.truthSelection`에 기록됩니다. ## 개인정보와 모델의 한계 @@ -261,8 +267,8 @@ noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 groun 음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된 경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음, -복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC와 channel clock은 모델링하지 -않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC, +복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC quantization·nonlinearity와 +channel clock jitter·drift는 모델링하지 않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC, DAMAS와 CLEAN-SC는 구현하지 않습니다. 따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전 판단을 대신하지 않습니다. diff --git a/apps/acoustic-camera/README.md b/apps/acoustic-camera/README.md index f95b866..9c36ec1 100644 --- a/apps/acoustic-camera/README.md +++ b/apps/acoustic-camera/README.md @@ -38,8 +38,8 @@ Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에 해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 어느 시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에 자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도 -연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist·scan 같은 실제 관계 오류는 -그대로 거부됩니다. +연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist 위 tone·band는 ideal ADC +alias로 실행되고, 잘못된 scan 범위 같은 실제 관계 오류는 그대로 거부됩니다. Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면 Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다. @@ -133,8 +133,9 @@ cache를 표시하고 알고리즘별 위치추정 비용을 다음처럼 분리 이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop, band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다. -각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist를 넘거나 겹치는 id 등 계약 오류는 -실행 전에 표시됩니다. DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성 +각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist 위 band도 허용되며 legend에 실제로 +접힌 ADC FFT range가 표시됩니다. 중복 id나 잘못된 범위 같은 계약 오류는 실행 전에 표시됩니다. +DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성 항목은 Camera UI에서 제거했습니다. Result의 `processedMaps` 계약만 향후 확장을 위해 남아 있습니다. **Peak 검출 임계값 (%)**은 최강 local peak의 map 값에 대한 상대 임계값이며 Experiment JSON에 @@ -142,6 +143,11 @@ band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다. 살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의 main lobe 폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 하나로 합쳐질 수 있습니다. +**Peak 묶기**는 raw map을 바꾸지 않고 threshold 위 후보를 표현하는 방식을 고릅니다. +`local-max`는 각 8-neighbor local maximum, `connected-components`는 threshold mask의 +8-connected component별 weighted centroid, `watershed`는 local maximum marker 기반 watershed +basin을 사용합니다. 선택한 표준 방식과 threshold는 Experiment와 Result에 기록됩니다. + ## 화면과 배열·알고리즘 비교 - acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다. @@ -181,12 +187,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위 ## Result JSON -**Result JSON**은 검증을 통과한 `micarrsim.result` 문서만 저장합니다. +**Result JSON**은 검증을 통과한 `micarrsim.result` 1.1 문서만 저장합니다. - `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `algorithm`과 `backend` - 실행 입력 전체의 canonical SHA-256인 `inputHash` - `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse - path 전체·종류별 수 + path 전체·종류별 수와 필수 ideal-ADC sampling provenance - `timingMs`: 전체, signal generation과 `localization`; 기존 소비자 호환용 `beamforming`은 같은 위치추정 시간을 담는 alias - band별 `rawMap`: DAS는 선형 `power`, PHAT 계열은 무차원 `score`; 화면용 `displayMap`과 검출 @@ -194,9 +200,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위 - 각 peak의 비보정 half-power/half-maximum 공분산과 RMS ellipse인 `beamSpread` - 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`, `mainLobeWidthM` +- band별 연속 fold segment, FFT bin-center 선택, DAS/GCC 실제 localization model, + 원 carrier 미사용 여부와 alias-equivalent band ID -Tone source는 그 주파수가 band 범위 안에 있을 때만 해당 band의 ground truth입니다. Audio asset과 -noise source는 스펙트럼을 사전에 판별하지 않으므로 모든 band의 ground truth로 평가합니다. Source +Tone source는 요청 analog interval 또는 접힌 ADC 주파수의 nearest selected bin을 기준으로 +해당 band의 ground truth가 됩니다. Audio asset과 noise source는 임의 spectrum detector를 만들지 않고 모든 band의 +ground truth로 평가합니다. Source z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와 위치 오차를 함께 해석하세요. ## 해석 한계 diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index 9aeaede..768837e 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -97,6 +97,7 @@

연산량 계산 중…

+

주파수 대역

diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index 897cd51..10a6c51 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -5,6 +5,11 @@ import { localizationAlgorithmLabel, localizationAlgorithmSupportsBackend, } from "/packages/acoustics-core/localization-algorithms.js"; +import { + describeFrequencyAliasing, + MAX_PRECISE_ANALOG_FREQUENCY_HZ, + observeFrequencyBand, +} from "/packages/acoustics-core/sampling.js"; import { downloadJson, readJsonFile } from "/packages/contracts/io.js"; import { createSplitPane } from "/packages/ui/split-pane.js"; import { @@ -14,6 +19,7 @@ import { validateScene, } from "/packages/contracts/validation.js"; import { createCameraAssetRuntime } from "./asset-runtime.js"; +import { sourcesForObservedBand } from "./source-selection.js"; import { applyAcousticProfile, detectAcousticProfile, @@ -121,6 +127,7 @@ const elements = { scanYMin: byId("scan-y-min"), scanYMax: byId("scan-y-max"), workloadSummary: byId("workload-summary"), + samplingWarning: byId("sampling-warning"), fftSize: byId("fft-size"), hopSize: byId("hop-size"), normalization: byId("normalization"), @@ -466,12 +473,64 @@ function renderAcousticSummary() { document.documentElement.dataset.acousticProfile = profile; } -function describeSignal(signal) { - if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`; +function describeSignal(signal, sampleRate) { + if (signal.type === "tone") { + const observation = describeFrequencyAliasing(signal.frequency, sampleRate); + const base = `${formatNumber(signal.frequency, 0)} Hz tone`; + const edge = observation.dcEdge + ? " · DC phase-degenerate edge" + : observation.nyquistEdge + ? " · Nyquist phase-degenerate edge" + : ""; + if (observation.aliased) { + const direction = observation.spectralInversion ? "−" : "+"; + return ( + `${base} → ADC ${formatNumber(observation.adcFrequencyHz, 0)} Hz alias ` + + `(signed ${direction}${formatNumber(observation.adcFrequencyHz, 0)} Hz)${edge}` + ); + } + return base + edge; + } if (signal.type === "noise") return `${signal.color} noise`; return `asset · ${signal.assetId}`; } +function sceneToneObservations(scene) { + const entries = [ + ...(scene.sources ?? []).map((entry) => ({ scope: "source", entry })), + ...(scene.ambient ?? []).map((entry) => ({ scope: "ambient", entry })), + ]; + const observations = []; + for (const { scope, entry } of entries) { + if (entry.signal?.type !== "tone") continue; + try { + observations.push({ + scope, + id: entry.id, + ...describeFrequencyAliasing(entry.signal.frequency, scene.sampleRate), + }); + } catch { + // Validation owns malformed inputs; rendering must remain available while editing. + } + } + return observations; +} + +function aliasedToneContributions(toneObservations, bandObservation) { + const selectedBins = new Set(bandObservation.bins); + return toneObservations.filter((tone) => + tone.aliased && + selectedBins.has(Math.round(tone.adcFrequencyHz / bandObservation.binWidthHz))); +} + +function describeToneFold(tone) { + return ( + `${tone.scope} ${tone.id} ` + + `${formatNumber(tone.analogFrequencyHz, 0)}→ADC ` + + `${formatNumber(tone.adcFrequencyHz, 0)} Hz` + ); +} + function renderDocumentSummary() { const { arrayDefinition, scene, experiment } = state; if (!arrayDefinition || !scene || !experiment) return; @@ -494,7 +553,7 @@ function renderDocumentSummary() { name.title = source.name; const detail = document.createElement("span"); detail.textContent = - `${describeSignal(source.signal)} · ` + + `${describeSignal(source.signal, scene.sampleRate)} · ` + `(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` + (source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : ""); detail.title = detail.textContent; @@ -552,7 +611,9 @@ function renderBandEditors() { if (type !== "color") input.placeholder = label; if (type === "number") { input.min = "0"; + input.max = String(MAX_PRECISE_ANALOG_FREQUENCY_HZ); input.step = "1"; + input.title = `${label} · 0–1 MHz verified analog range`; } editor.append(input); } @@ -571,7 +632,11 @@ function renderBandEditors() { function renderLegend() { elements.bandLegend.replaceChildren(); - if (!state.experiment) return; + elements.samplingWarning.hidden = true; + elements.samplingWarning.textContent = ""; + if (!state.experiment || !state.scene) return; + const observations = []; + const toneObservations = sceneToneObservations(state.scene); for (const band of state.experiment.frequencyBands) { const label = document.createElement("label"); label.className = "legend-toggle"; @@ -583,10 +648,97 @@ function renderLegend() { const swatch = document.createElement("span"); swatch.className = "legend-swatch"; const text = document.createElement("span"); - text.textContent = `${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`; + const requested = + `${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`; + try { + const observation = observeFrequencyBand( + band, + state.scene.sampleRate, + state.experiment.stft.fftSize, + ); + observations.push({ bandId: band.id, observation }); + if (observation.aliased) { + const adcRanges = observation.adcBinCenterRanges + .map((range) => + `${formatNumber(range.firstBinCenterHz, 0)}–` + + `${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."; + } else { + text.textContent = requested; + } + } catch (error) { + text.textContent = requested; + text.title = error instanceof Error ? error.message : String(error); + } label.append(input, swatch, text); elements.bandLegend.append(label); } + const toneContributions = observations.flatMap(({ bandId, observation }) => + aliasedToneContributions(toneObservations, observation) + .map((tone) => ({ bandId, tone }))); + const aliased = observations.filter( + ({ observation }) => + observation.aliased || + observation.continuousManyToOne || + observation.gridManyToOne, + ); + const groups = new Map(); + for (const { bandId, observation } of observations) { + const group = groups.get(observation.adcBinSetKey) ?? []; + group.push(bandId); + groups.set(observation.adcBinSetKey, group); + } + const equivalentGroups = [...groups.values()].filter((group) => group.length > 1); + if (aliased.length > 0 || equivalentGroups.length > 0 || toneContributions.length > 0) { + const details = []; + if (aliased.length > 0) { + const describeFold = ({ bandId, observation }) => { + const visibleSegments = observation.foldSegments + .slice(0, 4) + .map((segment) => + `Z${segment.nyquistZone}/order ${segment.aliasOrder}` + + (segment.inverted ? "/inverted" : "")) + .join(", "); + const remainder = observation.foldSegments.length > 4 + ? `, +${observation.foldSegments.length - 4} zones` + : ""; + return `${bandId} [${visibleSegments}${remainder}]`; + }; + details.push( + `alias band: ${aliased.map(describeFold).join(" · ")}`, + ); + } + if (equivalentGroups.length > 0) { + details.push( + "같은 ADC bin 집합(독립 관측 아님): " + + equivalentGroups.map((group) => group.join(" ↔ ")).join(" · "), + ); + } + if (toneContributions.length > 0) { + details.push( + "선택 ADC bin으로 접힌 Scene tone: " + + toneContributions + .map(({ bandId, tone }) => `${bandId} ← ${describeToneFold(tone)}`) + .join(" · "), + ); + } + const localizationDescription = + state.experiment.beamforming.algorithm === "gcc-phat" + ? "관측된 one-sided ADC FFT bin만 사용해 band-limited GCC-PHAT 기하 lag를 계산하며 " + : "관측된 양의 ADC FFT bin 주파수로 구면 steering하며 "; + elements.samplingWarning.textContent = + "Ideal ADC alias · 위치 추정은 " + localizationDescription + + "원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 " + + "그대로 나타나며, 현재 알고리즘에서는 alias order가 미해결 상태로 남습니다. " + + "다채널 공간 위상에는 carrier-order 증거가 남을 수 있지만 이 localizer는 " + + "carrier hypothesis를 열거하지 않습니다. " + + details.join(" · "); + elements.samplingWarning.hidden = false; + } } function renderWorkload() { @@ -773,6 +925,65 @@ function renderResult() { `${(displayed.run.signalStats.acousticPathCount ?? 0).toLocaleString("ko-KR")} paths 합성`, ); } + const aliasedTones = displayed.run.signalStats.sampling?.toneObservations + ?.filter((observation) => observation.aliased) ?? []; + if (displayed.run.signalStats.sampling) { + appendMetric( + "ADC sampling", + aliasedTones.length > 0 ? `${aliasedTones.length} aliased tone` : "Nyquist 내", + aliasedTones.length > 0 + ? aliasedTones + .map((observation) => + `${formatNumber(observation.analogFrequencyHz, 0)}→` + + `${formatNumber(observation.adcFrequencyHz, 0)} Hz`) + .join(" · ") + : "ideal real sampler · anti-alias filter 없음", + ); + } + const resultObservations = displayed.snapshot.experiment.frequencyBands + .map((band) => { + try { + return { + band, + observation: observeFrequencyBand( + band, + displayed.snapshot.scene.sampleRate, + displayed.snapshot.experiment.stft.fftSize, + ), + }; + } catch { + return null; + } + }) + .filter(Boolean); + const resultToneObservations = sceneToneObservations(displayed.snapshot.scene); + for (const { band, observation } of resultObservations) { + const toneContributions = aliasedToneContributions( + resultToneObservations, + observation, + ); + if ( + !observation.aliased && + !observation.continuousManyToOne && + !observation.gridManyToOne && + toneContributions.length === 0 + ) continue; + const localizationDetail = + displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat" + ? "band-limited GCC-PHAT · 원 carrier 미사용" + : "양의 ADC bin steering · 원 carrier 미사용 · alias order 미해결"; + const toneDetail = toneContributions.length > 0 + ? " · aliased Scene tone " + toneContributions.map(describeToneFold).join(" · ") + : ""; + appendMetric( + `${band.id} · ADC steering`, + displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat" + ? "ADC LAG" + : "OBSERVED BIN", + localizationDetail + toneDetail, + band.color, + ); + } appendMetric( "위치 추정 시간", `${formatNumber(displayed.run.timingMs.localization ?? displayed.run.timingMs.beamforming, 0)} ms`, @@ -1009,18 +1220,6 @@ function scheduleValidation() { }); } -function sourcesForBand(scene, band, time = null) { - return scene.sources.filter((source) => { - if (time !== null) { - const startTime = source.startTime ?? 0; - const endTime = startTime + (source.duration ?? scene.duration); - if (time < startTime || time > endTime) return false; - } - if (source.signal.type !== "tone") return true; - return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz; - }); -} - function createAbortError() { const error = new Error("시뮬레이션이 취소되었습니다."); error.name = "AbortError"; @@ -1170,7 +1369,11 @@ async function runSimulation() { localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ?? "power", scan: snapshot.experiment.scan, - sources: sourcesForBand(snapshot.scene, definition), + sources: sourcesForObservedBand( + snapshot.scene, + definition, + snapshot.experiment.stft.fftSize, + ), dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, detectionThresholdRatio: snapshot.experiment.evaluation?.detectionThresholdRatio ?? @@ -1289,6 +1492,7 @@ function reconcileLivePlan(plan, timeline) { function evaluateLiveFrames(snapshot, plan, frames) { for (const frame of frames) { const frameScene = scenePlaybackState(snapshot.scene, plan, frame.sampleTime).scene; + const arrivingSourceIds = frame.run.signalStats.windowing?.arrivingSourceIds; const evaluations = new Map(); for (const band of frame.run.bands) { const definition = snapshot.experiment.frequencyBands.find( @@ -1306,7 +1510,15 @@ function evaluateLiveFrames(snapshot, plan, frames) { )?.rawScale ?? "power", scan: snapshot.experiment.scan, - sources: sourcesForBand(frameScene, definition, frame.sampleTime), + sources: sourcesForObservedBand( + frameScene, + definition, + snapshot.experiment.stft.fftSize, + { + time: Array.isArray(arrivingSourceIds) ? null : frame.sampleTime, + arrivingSourceIds, + }, + ), dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, detectionThresholdRatio: snapshot.experiment.evaluation?.detectionThresholdRatio ?? @@ -1809,12 +2021,11 @@ function installBandListeners() { let suffix = state.experiment.frequencyBands.length + 1; while (used.has(`band-${suffix}`)) suffix += 1; const previous = state.experiment.frequencyBands.at(-1); - const nyquist = state.scene.sampleRate / 2; - const minHz = Math.min(Math.max(0, previous?.maxHz ?? 900), nyquist - 101); + const minHz = Math.max(0, previous?.maxHz ?? 900); const band = { id: `band-${suffix}`, minHz, - maxHz: Math.min(nyquist - 1, minHz + 200), + maxHz: minHz + 200, color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length], }; state.experiment.frequencyBands.push(band); diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js index 2721ce1..22c5c6f 100644 --- a/apps/acoustic-camera/model.js +++ b/apps/acoustic-camera/model.js @@ -1,8 +1,10 @@ import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js"; +import { observeFrequencyBand } from "/packages/acoustics-core/sampling.js"; import { DEFAULT_LOCALIZATION_ALGORITHM, localizationAlgorithmDefinition, } from "/packages/acoustics-core/localization-algorithms.js"; +import { RESULT_SCHEMA_VERSION } from "/packages/contracts/validation.js"; const RESULT_METRIC_KEYS = [ "peakToSidelobeDb", @@ -254,6 +256,70 @@ function environmentForResult({ arrayDefinition, experiment, run }) { return environment; } +function samplingForResult(sampling) { + if (!sampling || typeof sampling !== "object") return null; + return { + model: sampling.model, + sampleRate: sampling.sampleRate, + nyquistHz: sampling.nyquistHz, + toneObservations: (sampling.toneObservations ?? []).map((observation) => ({ + scope: observation.scope, + id: observation.id, + analogFrequencyHz: observation.analogFrequencyHz, + adcFrequencyHz: observation.adcFrequencyHz, + signedAdcFrequencyHz: observation.signedAdcFrequencyHz, + aliased: observation.aliased, + spectralInversion: observation.spectralInversion, + dcEdge: observation.dcEdge, + nyquistEdge: observation.nyquistEdge, + phaseDegenerate: observation.phaseDegenerate, + })), + }; +} + +function frequencyObservationForResult(scene, experiment, band, algorithm) { + if ( + !Number.isFinite(scene?.sampleRate) || + !Number.isInteger(experiment?.stft?.fftSize) || + !Number.isFinite(band?.minHz) || + !Number.isFinite(band?.maxHz) + ) { + return null; + } + const observation = observeFrequencyBand( + band, + scene.sampleRate, + experiment.stft.fftSize, + ); + return { + model: observation.model, + sampleRate: observation.sampleRate, + nyquistHz: observation.nyquistHz, + fftSize: observation.fftSize, + binWidthHz: observation.binWidthHz, + requestedMinHz: observation.requestedMinHz, + requestedMaxHz: observation.requestedMaxHz, + aliased: observation.aliased, + crossesFirstNyquist: observation.crossesFirstNyquist, + localizationInputModel: observation.localizationInputModel, + selectionModel: observation.selectionModel, + localizationModel: + algorithm === "gcc-phat" + ? "band-limited-gcc-phat-geometric-lag" + : "spherical-steering-at-positive-adc-bin-frequency", + originalCarrierUsed: observation.originalCarrierUsed, + aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable", + foldBoundaryCount: observation.foldBoundaryCount, + continuousManyToOne: observation.continuousManyToOne, + gridManyToOne: observation.gridManyToOne, + foldSegments: observation.foldSegments.map((segment) => ({ ...segment })), + adcBins: [...observation.bins], + adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })), + adcBinSetKey: observation.adcBinSetKey, + equivalentBandIds: [], + }; +} + export function createResultDocument(options) { const { arrayDefinition, @@ -266,8 +332,31 @@ export function createResultDocument(options) { } = options; const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM; const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power"; + const sampling = samplingForResult(run.signalStats.sampling); + const frequencyObservations = new Map(); + for (const definition of experiment.frequencyBands) { + const observation = frequencyObservationForResult( + scene, + experiment, + definition, + algorithm, + ); + if (observation) frequencyObservations.set(definition.id, observation); + } + const equivalentGroups = new Map(); + for (const [bandId, observation] of frequencyObservations) { + const group = equivalentGroups.get(observation.adcBinSetKey) ?? []; + group.push(bandId); + equivalentGroups.set(observation.adcBinSetKey, group); + } + for (const [bandId, observation] of frequencyObservations) { + observation.equivalentBandIds = equivalentGroups + .get(observation.adcBinSetKey) + .filter((candidateId) => candidateId !== bandId) + .sort(); + } return { - schemaVersion: "1.0.0", + schemaVersion: RESULT_SCHEMA_VERSION, kind: "micarrsim.result", experimentId: experiment.id, arrayId: arrayDefinition.id, @@ -289,6 +378,7 @@ export function createResultDocument(options) { ...(run.signalStats.acousticPathCounts ? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } } : {}), + ...(sampling ? { sampling } : {}), }, timingMs: { total: run.timingMs.total, @@ -317,7 +407,15 @@ export function createResultDocument(options) { peaks: evaluation.peaks.map(peakForResult), metrics: finiteMetricSubset(evaluation.metrics), }; - if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation }; + const frequencyObservation = frequencyObservations.get(band.id); + if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation; + if (evaluation.evaluation) { + bandResult.evaluation = { + ...evaluation.evaluation, + truthSelection: + "tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources", + }; + } return bandResult; }), }; diff --git a/apps/acoustic-camera/simulation-worker.js b/apps/acoustic-camera/simulation-worker.js index 2ecda9a..cdd594b 100644 --- a/apps/acoustic-camera/simulation-worker.js +++ b/apps/acoustic-camera/simulation-worker.js @@ -28,6 +28,8 @@ function signalStats(synthesis) { acousticModel: synthesis.acousticModel, acousticPathCount: synthesis.acousticPathCount, acousticPathCounts: synthesis.acousticPathCounts, + windowing: synthesis.windowing, + sampling: synthesis.sampling, }; } @@ -75,6 +77,10 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf scene, plannedFrame.startTime, plannedFrame.endTime, + { + arrayDefinition, + minimumDistance: experiment.beamforming.minimumDistance, + }, ); post("progress", jobId, { stage: "timeline-signal", diff --git a/apps/acoustic-camera/source-selection.js b/apps/acoustic-camera/source-selection.js new file mode 100644 index 0000000..5655a7f --- /dev/null +++ b/apps/acoustic-camera/source-selection.js @@ -0,0 +1,41 @@ +import { + describeFrequencyAliasing, + observeFrequencyBand, +} from "/packages/acoustics-core/sampling.js"; + +/** + * Select evaluation truth sources for one observed band. + * + * Arrival ids come from synthesis path timing and take precedence over the + * legacy emission-time fallback. This helper is evaluation-only; localization + * never receives truth positions or ids. + */ +export function sourcesForObservedBand( + scene, + band, + fftSize, + options = {}, +) { + const observation = observeFrequencyBand(band, scene.sampleRate, fftSize); + const observedBins = new Set(observation.bins); + const arrivingSourceIdSet = Array.isArray(options.arrivingSourceIds) + ? new Set(options.arrivingSourceIds) + : null; + const time = options.time ?? null; + return scene.sources.filter((source) => { + if (arrivingSourceIdSet) { + if (!arrivingSourceIdSet.has(source.id)) return false; + } else if (time !== null) { + const startTime = source.startTime ?? 0; + const endTime = startTime + (source.duration ?? scene.duration); + if (time < startTime || time >= endTime) return false; + } + if (source.signal.type !== "tone") return true; + const analogInside = + source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz; + if (analogInside) return true; + const alias = describeFrequencyAliasing(source.signal.frequency, scene.sampleRate); + const nearestAdcBin = Math.round(alias.adcFrequencyHz / observation.binWidthHz); + return observedBins.has(nearestAdcBin); + }); +} diff --git a/apps/acoustic-camera/styles.css b/apps/acoustic-camera/styles.css index 2fb3d69..9f33fcd 100644 --- a/apps/acoustic-camera/styles.css +++ b/apps/acoustic-camera/styles.css @@ -240,6 +240,16 @@ font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; } .workload-summary.is-warning { color: var(--amber); } +.sampling-warning { + margin: 8px 0 0; + padding: 9px 10px; + border: 1px solid rgb(255 197 110 / 28%); + border-radius: 8px; + color: var(--amber); + background: rgb(255 197 110 / 7%); + font-size: .64rem; + line-height: 1.55; +} .acoustic-environment-summary { margin: 8px 0 0; color: var(--muted); diff --git a/apps/dataset-builder/README.md b/apps/dataset-builder/README.md index 6dfbc72..467a5aa 100644 --- a/apps/dataset-builder/README.md +++ b/apps/dataset-builder/README.md @@ -3,6 +3,10 @@ 이미지 평면에 source ground truth를 배치하고 tone, noise, 로컬 audio asset과 ambient를 조합해 재현 가능한 `micarrsim.scene` JSON을 만드는 브라우저 앱입니다. +Tone 주파수는 Scene Nyquist 위도 입력할 수 있습니다. source 카드는 anti-alias filter 없는 이상적 +ADC에서 관측될 alias 주파수를 함께 표시합니다. 이는 고주파를 복원하거나 감쇠시키는 기능이 아니며, +실제 장치의 analog anti-alias filter는 별도 모델 대상입니다. + ## 열기 저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Dataset Builder**를 선택하거나 diff --git a/apps/dataset-builder/index.html b/apps/dataset-builder/index.html index 205d436..3a0886c 100644 --- a/apps/dataset-builder/index.html +++ b/apps/dataset-builder/index.html @@ -150,7 +150,7 @@
-
+
diff --git a/apps/dataset-builder/main.js b/apps/dataset-builder/main.js index 7b54b74..1dfadf7 100644 --- a/apps/dataset-builder/main.js +++ b/apps/dataset-builder/main.js @@ -190,7 +190,7 @@ function renderSourceCards() { name.title = source.name; heading.append(dot, name); const signal = document.createElement("small"); - signal.textContent = describeSignal(source.signal); + signal.textContent = describeSignal(source.signal, state.scene.sampleRate); const position = document.createElement("small"); const motion = Array.isArray(source.trajectory) ? ` · 이동 ${source.trajectory.length}점` : ""; position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m${motion}`; @@ -683,6 +683,7 @@ function bindSceneControls() { if (asset.kind === "audio") state.assetErrors.delete(asset.id); } renderAssetList(); + renderSourceCards(); }, "change"); bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); }); bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); }); diff --git a/apps/dataset-builder/model.js b/apps/dataset-builder/model.js index f43e842..f5dff47 100644 --- a/apps/dataset-builder/model.js +++ b/apps/dataset-builder/model.js @@ -1,3 +1,5 @@ +import { describeFrequencyAliasing } from "/packages/acoustics-core/sampling.js"; + export const DEFAULT_VIEW_PLANE = Object.freeze({ xMin: -0.6, xMax: 0.6, @@ -192,8 +194,20 @@ export function changeSignalType(source, type, firstAudioAssetId = "") { } } -export function describeSignal(signal) { - if (signal.type === "tone") return `${signal.frequency} Hz tone`; +export function describeSignal(signal, sampleRate) { + if (signal.type === "tone") { + if (!Number.isFinite(sampleRate)) return `${signal.frequency} Hz tone`; + const observation = describeFrequencyAliasing(signal.frequency, sampleRate); + const alias = observation.aliased + ? ` → ADC ${observation.adcFrequencyHz} Hz alias` + : ""; + const edge = observation.dcEdge + ? " · DC phase-degenerate edge" + : observation.nyquistEdge + ? " · Nyquist phase-degenerate edge" + : ""; + return `${signal.frequency} Hz tone${alias}${edge}`; + } if (signal.type === "noise") return `${signal.color} noise`; if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`; return "알 수 없는 신호"; diff --git a/docs/acoustic-model.md b/docs/acoustic-model.md index c3ec9c1..8e2adad 100644 --- a/docs/acoustic-model.md +++ b/docs/acoustic-model.md @@ -82,6 +82,38 @@ Scene JSON의 사용자 설정 예는 다음과 같다. 하며 occluder id는 중복될 수 없다. 사용자 설정에서도 마이크 좌표가 room 안에 놓이도록 구성해야 1차 반사가 생성된다. +## 라이브 창의 전파 지연과 이동 근사 + +라이브 frame의 출력 sample 구간을 `[S, E)`, 현재 quasi-static geometry에서 모든 +source–microphone 희소 경로의 최대 지연을 `Dmax`라 하자. 합성기는 다음 순서로 frame을 만든다. + +1. source trajectory를 출력 창 중앙 시각에서 한 번 샘플링한다. +2. `R = max(0, S - ceil(Dmax × sampleRate))`부터 `E`까지의 tone, noise와 asset 방사 신호를 + 원래 Scene 절대 sample index로 생성한다. +3. 이 pre-roll buffer에 direct, reflection, scattering, diffraction과 diffuse 경로를 적용한다. +4. 전파가 끝난 뒤 `[S, E)`만 crop하여 localizer에 넘긴다. + +따라서 source가 `S` 전에 멈췄더라도 그 직접음·반사음·diffuse tail이 창 안에 도착하면 보존된다. +analytic tone뿐 아니라 deterministic noise index와 asset 재생 위치도 원래 절대 시간축을 유지한다. +`synthesis.windowing`과 live frame의 `signalStats.windowing`은 render/output sample, pre-roll, +최대 지연, geometry와 Doppler 정책을 기록한다. +source별 `sourceArrivals`는 각 midpoint path의 half-open 도착 sample 구간이 출력과 겹치는지와 +겹친 path kind를 기록한다. live ground-truth 평가는 emission midpoint가 아니라 +`arrivingSourceIds`를 사용하므로, 이미 멈춘 source의 tail을 false positive로 잘못 세지 않는다. +이 ID 집합은 위치 추정이 끝난 뒤 평가에만 쓰며 raw channel, STFT, steering이나 peak 검출에는 +입력하지 않는다. + +다만 이는 moving wave equation 해법이 아니다. pre-roll 방사 시각의 retarded source 위치를 다시 +구하지 않고 전체 render buffer에서 창 중앙 위치와 그 위치의 경로를 고정한다. 창 안의 연속 이동, +Doppler, 시간에 따라 변하는 reflection geometry도 모델링하지 않는다. 움직임이 경로 지연에 비해 +크다면 frame 길이를 줄여 quasi-static 오차를 드러내고 수렴성을 확인해야 한다. + +sampled asset/noise의 fractional delay와 microphone response는 zero-padded block FFT 전달함수다. +pre-roll은 현재 희소 **전파 경로**의 최대 지연을 보존하지만, 측정된 주파수 응답에서 무한하거나 +임의 길이의 causal analog impulse response state를 복원하지 않는다. 또한 Scene이 +`mix.normalization: peak`이면 live 합성은 각 출력 crop의 peak로 scale을 계산하므로 frame 간 +절대 진폭 비교에는 `normalization: none`을 사용해야 한다. + ## Camera 환경 preset과 비용 Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음 @@ -184,6 +216,66 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가 해결하지는 않는다. 단일 reference 방식은 모든 마이크 쌍을 쓰는 GCC보다 계산량이 작지만 pair 중복성과 고장 마이크에 대한 강건성도 작다. +## 샘플링, Nyquist와 alias + +Scene의 `sampleRate`는 모든 마이크가 동시에 동작하는 이상적 real-valued ADC의 sampling rate다. +현재 모델에는 analog anti-alias filter가 없다. 따라서 tone 주파수는 Nyquist를 넘어도 허용되며, +관측 주파수는 다음 signed baseband 주파수로 접힌다. 정확한 Nyquist tie는 `+Fs/2`로 +표준화하지만 `+Fs/2`와 `-Fs/2`는 같은 real sample sequence다. + +```text +f_wrapped = f_analog mod Fs, 0 <= f_wrapped < Fs +f_signed = f_wrapped if f_wrapped <= Fs/2 + f_wrapped - Fs otherwise +f_adc = |f_signed| +``` + +`f_signed < 0`이면 one-sided positive spectrum이 반전된다. 정확히 DC나 Nyquist에 접힌 tone은 +quadrature 정보가 소실되는 퇴화점이다. Camera는 이를 오류로 숨기지 않고 source 요약, band legend와 +Result `signalStats.sampling`에 원 주파수·signed ADC 주파수·반전 여부와 DC/Nyquist +위상 퇴화 여부를 기록한다. + +공간 전파는 alias 주파수로 바꾸지 않는다. 합성기는 각 path의 원래 analog 주파수 +`2π f_analog τ`로 지연 위상과 해당 주파수의 microphone response를 적용한 뒤 ADC 시각에서 +sampling한다. 이 순서를 뒤집으면 fractional path delay의 센서 간 위상이 틀리므로, tone은 +mic/path별 analytic phasor와 시작·종료 event로 합성한다. + +Experiment band도 Nyquist 위 값을 허용한다. 요청 주파수 구간의 모든 등가 one-sided real-FFT +bin **center**를 접어 선택하며, 일부 alias 후보를 골라 버리지 않는다. 이 선택은 brick-wall +대역 필터가 아니다. 유한 Hann window의 off-grid leakage는 인접 bin에도 남는다. 현재 +DAS·DAS-PHAT는 양의 ADC bin 주파수로 구면 steering하고, GCC-PHAT는 선택 bin으로 제한한 +기하 lag correlation을 수행한다. 어느 쪽도 원래 alias order를 이용해 결과가 좋아지도록 +보정하지 않는다. +따라서 초과 주파수에서 peak가 틀어지거나 grating lobe·동률 peak가 나타나는 것이 정상적인 설계 +결과일 수 있다. Result의 band별 `frequencyObservation`은 1-based Nyquist zone별 연속 fold +segment, 반전·alias order, 실제 ADC bin center, 연속/FFT-grid many-to-one 여부와 +alias-equivalent band ID를 보존한다. 같은 `adcBinSetKey`를 가진 band는 독립 관측이 아니다. + +정확한 Nyquist 경계에서 `+Fs/2`와 `-Fs/2`는 실수 sampling sequence가 동일하다. +단일 주파수 provenance는 이를 `+Fs/2`로 canonicalize하지만, `foldSegments`의 +`signedAdcStartHz`/`signedAdcEndHz`는 각 zone을 따라가는 방향을 보존하므로 반전 zone +시작점이 `-Fs/2`로 나타날 수 있다. 경계를 가로지르는 두 segment의 `+Fs/2`와 +`-Fs/2` endpoint는 두 관측이나 두 FFT bin이 아니라 같은 phase-degenerate Nyquist +sample을 양쪽 zone에서 표현한 것이다. + +같은 이유로 위치 추정기의 선택적 microphone-response 역보정도 관측 ADC bin에서 평가한다. Forward +tone 합성은 원 analog 주파수의 응답을 사용하므로, Nyquist 위 carrier를 알고 보정하는 별도 +alias-aware 알고리즘처럼 동작하지 않는다. 이 차이 역시 감추지 않는 현재 알고리즘의 결과다. + +서로 다른 analog tone이 같은 ADC bin으로 접히면 **단일 channel의 시간 sample만으로는** +원 주파수를 구별할 수 없다. 그러나 이 합성기는 원 analog 파장의 센서 간 위상을 보존하므로, +충분히 촘촘하고 보정된 다채널 array에는 carrier order를 판별할 공간 위상 증거가 남을 수 있다. +현재 DAS·DAS-PHAT·GCC-PHAT는 carrier hypothesis를 열거하는 알고리즘이 아니므로 이를 복원하지 +않고 Result에 `aliasOrderResolution: not-attempted`로 기록한다. 이 기능을 추가하려면 검증된 +array-processing 방법과 별도 alias-order 평가가 필요하며, 임의 규칙으로 peak를 고르지 않는다. +audio asset과 noise는 이미 sampling된 baseband 신호로 취급하므로 파일에 없는 Nyquist 위 analog +spectrum을 만들어 내지 않는다. + +Tone과 band의 현재 검증 범위는 0–1 MHz다. 이는 Nyquist 제한이 아니라 JavaScript +IEEE-754 위상 정밀도, 마이크 응답 계약과 현재 희소 경로 모델을 함께 검증한 수치 범위다. +더 높은 carrier가 필요하면 단순히 상한을 제거하지 말고 phase reduction과 microphone model을 +그 범위에서 다시 검증해야 한다. + ## 마이크 주파수 응답 각 마이크에는 2–64개의 측정점을 지정할 수 있다. `frequencyHz`는 0–1,000,000 Hz에서 @@ -212,6 +304,11 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가 합성기는 알고리즘과 무관하게 sensitivity, 정적 phase offset과 frequency-response curve를 수신 channel에 적용한다. 위치 추정에서의 역보정은 알고리즘별로 다르다. +Tone에는 원 analog carrier에서 보간한 하나의 complex response를 정상상태 phasor로 적용한다. +따라서 지속 sinusoid의 gain/phase 비교에는 맞지만, hard-gated 시작·종료에서 실제 analog +마이크 필터가 만드는 ringing이나 frequency-dependent transient까지 계산하는 full LTI filter +모델은 아니다. 이 근사를 ADC를 포함한 완전한 하드웨어 시뮬레이션으로 해석하지 않는다. + - DAS는 sensitivity 역 gain과 정적 phase를 항상 보정한다. `compensateFrequencyResponse`를 생략하거나 `true`로 두면 response의 역 gain과 phase도 보정한다. 응답 notch의 무제한 gain boost는 noise까지 키울 수 있으므로 `maxCompensationDb`로 제한하며 기본값은 24 dB, 허용 범위는 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index 88c0501..e4135a7 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -127,9 +127,8 @@ sidelobe false positive도 늘 수 있다. ## Result 평가 정보 -새 Result는 최상위 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 기록하며 -Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result를 읽기 위해 schema상 선택적으로 -남아 있지만 현재 export에는 항상 들어간다. `backend`는 요청값 `auto`가 아니라 실제 사용한 +Result 1.1은 최상위 필수 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 +기록하며 Experiment의 선택값과 같아야 한다. `backend`는 요청값 `auto`가 아니라 실제 사용한 `cpu` 또는 `webgpu`다. 관계 검증은 `das → power`, `das-phat/gcc-phat → score`를 강제한다. Peak에 `beamSpread`가 @@ -137,8 +136,8 @@ Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result 구조상 유효하지만 의미가 모순된 외부 Result도 import 시 거절된다. `timingMs.localization`은 선택한 위치 추정 알고리즘의 wall time이다. 현재 export는 기존 DAS 전용 -소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.0.0` schema는 이전 -문서를 위해 `beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을 +소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.1.0` schema는 +`beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을 함께 저장해야 한다. `timingMs.total`은 전체 시간이고 선택적 `signalGeneration`은 합성 시간이다. 각 band의 `peaks`는 위치와 해당 `rawMap.scale`의 값, 선택적 `sourceId`, `localizationErrorM`, @@ -168,14 +167,29 @@ threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic r 버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z, 가능한 경우 GPU adapter 및 fallback 정보를 기록한다. -`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 선택적으로 +`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 필수 `sampling` provenance와 선택적 `acousticModel`, 실제 전체 `acousticPathCount`와 `acousticPathCounts`를 기록한다. 종류별 key는 `direct`, `reflection`, `scattering`, `diffraction`과 `diffuse`다. -대역별 ground truth는 tone의 주파수가 band 안에 있을 때 그 tone source를 포함한다. noise와 -asset source는 현재 모든 band의 truth로 간주한다. 즉 녹음 asset의 실제 spectrum energy를 -대역별로 판정하는 계약은 아직 없으며, 이 가정은 실제 데이터 결과를 해석할 때 고려해야 한다. +각 band의 필수 `frequencyObservation`은 연속 analog fold와 이산 FFT 계산 선택을 구분한다. +`foldSegments`는 1-based Nyquist zone·반전·alias order를, +`adcBins`/`adcBinCenterRanges`는 +`selectionModel: fft-bin-center-membership`으로 선택된 bin center를 뜻한다. +요청 `minHz`와 `maxHz`에 정확히 놓인 FFT bin center도 선택한다. 정확한 Nyquist +경계의 `+Fs/2`와 `-Fs/2`는 같은 실수 sample이며, point provenance는 `+Fs/2`로 +canonicalize하고 fold segment endpoint는 zone 진행 방향에 따라 둘 중 하나를 기록한다. +`localizationInputModel`은 one-sided real FFT bin만 사용함을 밝히며, `localizationModel`은 +DAS 계열의 구면 steering과 GCC-PHAT의 기하 lag를 구분한다. `originalCarrierUsed`는 항상 +`false`다. Nyquist 위 band의 `aliasOrderResolution`은 현재 `not-attempted`다. +`equivalentBandIds`가 있으면 그 band들은 같은 ADC bin 집합을 공유하므로 독립 +관측으로 세면 안 된다. + +대역별 ground truth의 실제 정책은 evaluation의 +`truthSelection: tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources`에 +기록한다. Tone은 원 주파수가 요청 analog interval 안에 있거나, 접힌 주파수의 nearest ADC +bin이 선택됐을 때 포함한다. noise와 asset source는 모든 band에 포함한다. 녹음 asset의 +spectrum을 임의 threshold로 분류하는 미검증 multiband 알고리즘은 사용하지 않는다. ## 재현성 hash @@ -186,7 +200,10 @@ SHA-256을 계산해 scene의 해당 asset `sha256` 필드를 채운 상태로 c ## 버전과 검증 -현재 계약 버전은 `1.0.0`이다. 브라우저는 `packages/contracts/schema-validator.js`로 저장소의 +Array·Scene·Experiment 계약 버전은 `1.0.0`, Result 계약 버전은 `1.1.0`이다. Result +`1.1.0`은 sampling provenance와 band별 fold/localization metadata를 필수화하므로 이전 +`1.0.0` Result와 구조적으로 호환되지 않는다. 브라우저는 +`packages/contracts/schema-validator.js`로 저장소의 실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다. Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한 뒤 `sceneId`와 @@ -196,7 +213,7 @@ Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검 - 중복 id와 array/scene/experiment/result 참조 - asset 종류와 안전한 상대 경로 -- Nyquist 및 시간·scan 범위 +- ideal sampler의 Nyquist alias mapping 및 시간·scan 범위 - 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계 - map shape와 source peak 매칭 - band별 evaluation 파라미터와 허용 metric @@ -204,6 +221,16 @@ Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검 지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다. +Scene tone과 Experiment band는 0–1 MHz 검증 범위 안에서 Nyquist를 넘어도 유효하다. 1 MHz는 +Nyquist 제한이 아니라 수치 위상 정밀도·마이크 응답·희소 음향 모델의 현재 검증 상한이다. +이는 원 신호를 복원한다는 뜻이 아니라 +anti-alias filter가 없는 이상적 real ADC에서 접힌 bin을 관찰한다는 뜻이다. Result는 +`signalStats.sampling`과 band별 `frequencyObservation`에 원 주파수, signed ADC 주파수, +spectral inversion, fold segment와 선택 bin center를 기록한다. 동일 ADC bin에 접힌 analog +후보는 단일 channel의 시간 sample만으로 식별할 수 없다. 다채널 공간 위상에는 원 파장 정보가 +남을 수 있지만 현재 conventional localizer는 carrier hypothesis를 시도하지 않으며, +validation이나 평가도 임의의 후보를 정답으로 선택하지 않는다. + 실측 channel을 받는 portable API는 저장 JSON 계약과 별도로 입력 Array를 같은 규칙으로 검사한다. CPU 분석 계획은 기본 512 MiB, 반복 steering cache는 64 MiB, streaming ring과 분석창은 기본 256 MiB hard cap을 할당 전에 적용한다. 채널 순서와 streaming 상태 계약은 diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 19693b4..144716a 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -113,9 +113,15 @@ Scene·Array·Experiment·Assets 준비 상태와 실행 가능 여부를 가까 일상적으로 바꾸는 backend, grid, 대역과 peak 감도는 설정 단계에 유지한다. 음속, seed, scan 범위, STFT, 음향 환경 preset과 마이크 응답 보상은 **고급 설정**에 묶는다. 장문의 개발 요구나 미구현 기능 홍보는 작업 화면에서 제거하고, 물리 모델·데이터 계약·포팅 설명은 문서로 연결한다. -잘못된 단위, Nyquist 초과 대역, room 포함 관계와 CPU/GPU·신호 working-set 한도는 실행 전에 +잘못된 단위, room 포함 관계와 CPU/GPU·신호 working-set 한도는 실행 전에 검증한다. +Nyquist 초과 tone/band는 배열 설계 실패 양상을 관찰할 수 있도록 허용한다. tone은 analog path +phase와 microphone response를 먼저 적용한 뒤 ideal ADC에서 sampling하고, band는 모든 등가 ADC +bin center로 접는다. 원 carrier를 이용한 숨은 steering이나 결과 선택은 하지 않으며, 연속 +Nyquist-zone fold·이산 bin 선택·알고리즘별 localization model·동일 ADC band를 Result 1.1에 +기록한다. 0–1 MHz는 Nyquist cutoff가 아니라 현재 수치/마이크 모델의 검증 범위다. + ## 6. 평가 방법 점 음원 위치를 image plane으로 투영해 ground-truth heatmap과 위치 목록을 함께 저장한다. 최소 @@ -217,8 +223,9 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정 한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다. - 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로 쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다. -- 실제 AC와 simulation gap: gain·phase·주파수 응답과 희소 실내 환경을 비교 조건으로 제공하되, - 위치 공차·self-noise·ADC·clock은 실측 adapter와 현장 검증으로 다룬다. +- 실제 AC와 simulation gap: gain·phase·주파수 응답, ideal real ADC sampling/alias와 희소 실내 + 환경을 비교 조건으로 제공하되, 위치 공차·self-noise·ADC quantization/nonlinearity와 + channel clock jitter/drift는 실측 adapter와 현장 검증으로 다룬다. - post-processing이 기본 결과를 가림: 알고리즘별 raw power/score map과 후처리 map을 별도로 보존한다. ## 10. 구현 결과와 다음 백로그 diff --git a/docs/index.html b/docs/index.html index 49b1124..db55303 100644 --- a/docs/index.html +++ b/docs/index.html @@ -269,7 +269,7 @@
Experiment를 가져왔는데 검증 오류가 남습니다. -

참조 ID는 현재 Scene·Array에 자동으로 연결됩니다. 실행할 수 없다면 검증 카드에서 Nyquist, scan 범위, asset처럼 재연결 뒤 확인된 실제 입력 오류를 확인하세요.

+

참조 ID는 현재 Scene·Array에 자동으로 연결됩니다. Nyquist 위 tone·band는 ideal ADC alias로 실행되며 원/관측 주파수가 함께 표시됩니다. 실행할 수 없다면 검증 카드에서 scan 범위, asset처럼 재연결 뒤 확인된 실제 입력 오류를 확인하세요.

Assets가 준비되지 않습니다. diff --git a/docs/realtime-porting.md b/docs/realtime-porting.md index 9e1c37d..bc17d23 100644 --- a/docs/realtime-porting.md +++ b/docs/realtime-porting.md @@ -167,7 +167,10 @@ const plan = createLocalizationPlan({ 주요 검증 조건은 다음과 같다. - `fftSize`는 2 이상의 2의 거듭제곱이고 `hopSize <= fftSize` -- band는 하나 이상이며 `0 <= minHz < maxHz < sampleRate / 2` +- band는 하나 이상이며 `0 <= minHz < maxHz`. Nyquist 위 구간은 anti-alias filter 없는 ideal + real ADC의 등가 baseband bin으로 접힌다. 단일 channel 시간 sample은 원 alias order를 + 복원하지 못하지만 다채널 공간 위상에는 원 파장의 증거가 남을 수 있다. 현재 portable + localizer는 carrier hypothesis를 열거하지 않는다. - scan의 `columns`와 `rows`는 양의 정수 - `speedOfSound`는 양수 - `normalization`은 `peak` 또는 `none` diff --git a/packages/acoustics-core/README.md b/packages/acoustics-core/README.md index feb37d8..a6e08b1 100644 --- a/packages/acoustics-core/README.md +++ b/packages/acoustics-core/README.md @@ -8,6 +8,25 @@ - radix-2 FFT, Hann STFT와 선택 bin 추출 - scan grid 거리 cache 및 DAS·DAS-PHAT·GCC-PHAT 위치 추정 +## Nyquist 초과 입력 + +`sampling.js`는 anti-alias filter 없는 이상적 real sampler의 signed alias와 band-to-FFT-bin +mapping을 제공한다. tone 전파는 먼저 원 analog 주파수에서 path delay와 microphone response를 +적용하고 그 뒤 ADC 시각에서 sampling한다. 따라서 시간축에는 alias가 나타나지만 센서 간 위상은 +원 파장의 물리를 유지한다. + +Nyquist 위 band는 모든 등가 ADC FFT bin center로 접어 관찰한다. 연속 fold segment와 이산 +bin-center membership은 별도 metadata다. DAS 계열은 양의 ADC bin 주파수로 steering하고 +GCC-PHAT는 선택 bin의 lag correlation을 사용하며, 원 carrier를 몰래 추론하거나 peak를 +고르지 않는다. 단일 channel 시간축에서 같은 sequence로 접혀도 다채널 공간 위상에는 원 파장의 +증거가 남을 수 있다. 현재 localizer는 carrier hypothesis를 열거하지 않으므로 Result에 +`aliasOrderResolution: not-attempted`로 남긴다. asset/noise 입력은 이미 sampling된 baseband로 +취급한다. + +Tone과 band의 검증 범위는 0–1 MHz다. 이는 Nyquist cutoff가 아니라 현재 IEEE-754 위상 계산, +마이크 응답과 희소 경로 모델의 명시적 정밀도 범위다. public sampling helper는 비정상적으로 +작은 sample rate에서도 최대 513개 fold segment를 넘기 전에 거부한다. + ## 위치 추정 알고리즘 `localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 세 경로를 @@ -157,9 +176,23 @@ scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전 경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를 sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다. -시간창은 활성 source만 잘라 짧은 Scene으로 만들지만 `signalOffsets`에 원래 scene/source의 절대 sample -offset을 보존합니다. 이를 `synthesizeMicrophoneSignals`에 전달하면 각 창을 따로 합성해도 tone phase, -seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline 합성과 연속됩니다. +시간창은 요청 출력 시작보다 앞선 신호를 현재 array·음향 환경의 최대 경로 지연만큼 pre-roll합니다. +따라서 창이 시작되기 전에 발음이 끝난 source라도 direct·reflection·scattering·diffraction·diffuse +경로가 창 안에 도착하면 제거하지 않습니다. `signalOffsets`는 원래 scene/source의 절대 sample +offset과 출력 crop을 함께 보존합니다. `synthesizeMicrophoneSignals`는 pre-roll 버퍼에 모든 경로를 +적용한 뒤 요청 구간만 반환하므로 tone phase, seed 기반 noise index, audio asset 재생 위치와 +ambient가 전체 timeline과 같은 절대 시간축을 사용합니다. + +이 보장은 현재 희소 경로 모델 안에서의 **전파 지연 tail**에 관한 것입니다. quasi-static 창은 pre-roll +구간까지 포함해 source를 출력 창 중앙의 한 위치에 고정하며, retarded-time trajectory, 창 안의 연속 +이동과 Doppler는 계산하지 않습니다. 합성 결과의 `windowing` metadata는 적용한 pre-roll, 출력 절대 +sample, `source-position-fixed-at-window-midpoint` geometry와 `dopplerMode: not-modeled`를 기록합니다. +`sourceArrivals`는 source별 방사·도착 sample 경계와 출력에 겹친 path kind를, +`arrivingSourceIds`는 적어도 한 실제 path의 half-open 도착 구간이 출력과 겹친 source를 기록합니다. +이는 사후 ground-truth 평가에만 쓰이며 위치 추정 입력에는 전달하지 않습니다. +sampled asset/noise의 fractional delay와 microphone response는 기존 zero-padded block FFT 모델을 +그대로 사용하므로 임의 길이 analog microphone impulse response의 streaming state를 재현하는 모델은 +아닙니다. ## Beam spread 의미 diff --git a/packages/acoustics-core/math.js b/packages/acoustics-core/math.js index 6581216..9b61242 100644 --- a/packages/acoustics-core/math.js +++ b/packages/acoustics-core/math.js @@ -1,5 +1,34 @@ export const TAU = Math.PI * 2; +/** + * Ceil a sample position while keeping an analytically integral boundary on + * that integer. IEEE-754 roundoff from expressions such as (0.1 + 0.2) * 1000 + * must not add a physically nonexistent sample of latency. + */ +export function ceilSamplePosition(samplePosition) { + if (!Number.isFinite(samplePosition)) { + throw new TypeError("sample position must be finite."); + } + const nearestInteger = Math.round(samplePosition); + const tolerance = + 16 * Number.EPSILON * Math.max(1, Math.abs(samplePosition)); + const result = Math.abs(samplePosition - nearestInteger) <= tolerance + ? nearestInteger + : Math.ceil(samplePosition); + if (!Number.isSafeInteger(result)) { + throw new RangeError("sample position exceeds the safe integer range."); + } + return result; +} + +export function ceilSampleIndex(timeSeconds, sampleRate) { + if (!Number.isFinite(timeSeconds)) throw new TypeError("time must be finite."); + if (!(sampleRate > 0) || !Number.isFinite(sampleRate)) { + throw new TypeError("sampleRate must be finite and positive."); + } + return ceilSamplePosition(timeSeconds * sampleRate); +} + export function distance3(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); } diff --git a/packages/acoustics-core/propagation.js b/packages/acoustics-core/propagation.js index 8ad0b0f..b09777d 100644 --- a/packages/acoustics-core/propagation.js +++ b/packages/acoustics-core/propagation.js @@ -1,4 +1,11 @@ -import { dbToLinear, distance3 } from "./math.js"; +import { interpolateFrequencyResponse } from "./frequency-response.js"; +import { + ceilSampleIndex, + ceilSamplePosition, + dbToLinear, + distance3, + TAU, +} from "./math.js"; import { addFrequencyDomainDelay, addFrequencyDomainPaths, @@ -6,8 +13,9 @@ import { nextPowerOfTwo, prepareDelaySpectrum, } from "./fractional-delay.js"; -import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js"; +import { buildAcousticPaths } from "./environment.js"; import { renderSignal } from "./signal.js"; +import { describeFrequencyAliasing } from "./sampling.js"; export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) { return distance3(sourcePosition, receiverPosition) / speedOfSound; @@ -17,6 +25,167 @@ export function sphericalAttenuation(distance, referenceDistance = 1, minimumDis return referenceDistance / Math.max(distance, minimumDistance); } +function addToneEvent( + events, + sampleIndex, + sineDelta, + cosineDelta, + activePathDelta, +) { + const event = events.get(sampleIndex); + if (event) { + event.sine += sineDelta; + event.cosine += cosineDelta; + event.activePaths += activePathDelta; + } else { + events.set(sampleIndex, { + sine: sineDelta, + cosine: cosineDelta, + activePaths: activePathDelta, + }); + } +} + +function carrierAtSample(signedFrequencyHz, sampleRate, absoluteSampleIndex) { + const cycles = (signedFrequencyHz * absoluteSampleIndex) / sampleRate; + const fractionalCycles = cycles - Math.floor(cycles); + const phase = TAU * fractionalCycles; + return { cosine: Math.cos(phase), sine: Math.sin(phase) }; +} + +/** + * Apply propagation and microphone response to an analog tone before sampling. + * + * This order is essential above Nyquist: temporal samples fold to the ADC + * alias, while inter-microphone phase still comes from the original acoustic + * frequency and path delay. + */ +function addSampledAnalogTonePaths(options) { + const { + output, + entry, + scene, + microphone, + paths, + timeMapping = {}, + } = options; + const signal = entry.signal; + const frequencyHz = signal.frequency; + const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate); + const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0; + const signalStartTime = timeMapping.signalStartTime ?? entry.startTime ?? 0; + const signalDuration = timeMapping.signalDuration ?? entry.duration ?? scene.duration; + if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) { + throw new RangeError("sceneSampleOffset must be a non-negative safe integer."); + } + if (!Number.isFinite(signalStartTime)) { + throw new TypeError("signalStartTime must be finite."); + } + if (!Number.isFinite(signalDuration) || signalDuration <= 0) { + throw new RangeError("signalDuration must be a positive finite number."); + } + const sourcePhase = ((signal.phaseDeg ?? 0) * Math.PI) / 180; + const response = interpolateFrequencyResponse( + microphone.frequencyResponse, + frequencyHz, + {}, + ); + const microphonePhase = + ((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180 + response.phaseRadians; + const baseGain = + dbToLinear(entry.gainDb ?? 0) * + dbToLinear(microphone.sensitivityDb ?? 0) * + response.linearGain; + const events = new Map(); + + for (const path of paths) { + const firstSample = Math.max( + 0, + ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) - + sceneSampleOffset, + ); + const lastSample = Math.min( + output.length, + ceilSampleIndex( + signalStartTime + signalDuration + path.delaySeconds, + scene.sampleRate, + ) - sceneSampleOffset, + ); + if (firstSample >= lastSample) continue; + const amplitude = baseGain * path.gain; + const delayedCycles = (frequencyHz * (signalStartTime + path.delaySeconds)) % 1; + const phase = sourcePhase + microphonePhase - TAU * delayedCycles; + const sineCoefficient = amplitude * Math.cos(phase); + const cosineCoefficient = amplitude * Math.sin(phase); + addToneEvent(events, firstSample, sineCoefficient, cosineCoefficient, 1); + addToneEvent(events, lastSample, -sineCoefficient, -cosineCoefficient, -1); + } + if (events.size === 0) return; + + const orderedEvents = [...events.entries()].sort((left, right) => left[0] - right[0]); + const phaseStep = + (TAU * sampling.signedAdcFrequencyHz) / scene.sampleRate; + const stepReal = Math.cos(phaseStep); + const stepImaginary = Math.sin(phaseStep); + const firstSample = orderedEvents[0][0]; + const finalEventSample = orderedEvents.at(-1)[0]; + const initialCarrier = carrierAtSample( + sampling.signedAdcFrequencyHz, + scene.sampleRate, + sceneSampleOffset + firstSample, + ); + let carrierCosine = initialCarrier.cosine; + let carrierSine = initialCarrier.sine; + let sineCoefficient = 0; + let cosineCoefficient = 0; + let activePathCount = 0; + let eventIndex = 0; + const phaseReanchorInterval = 1024; + let sampleIndex = firstSample; + while (sampleIndex < output.length && sampleIndex < finalEventSample) { + while ( + eventIndex < orderedEvents.length && + orderedEvents[eventIndex][0] === sampleIndex + ) { + sineCoefficient += orderedEvents[eventIndex][1].sine; + cosineCoefficient += orderedEvents[eventIndex][1].cosine; + activePathCount += orderedEvents[eventIndex][1].activePaths; + eventIndex += 1; + } + if (activePathCount === 0 && eventIndex < orderedEvents.length) { + sampleIndex = orderedEvents[eventIndex][0]; + const carrier = carrierAtSample( + sampling.signedAdcFrequencyHz, + scene.sampleRate, + sceneSampleOffset + sampleIndex, + ); + carrierCosine = carrier.cosine; + carrierSine = carrier.sine; + continue; + } + output[sampleIndex] += + carrierSine * sineCoefficient + carrierCosine * cosineCoefficient; + + const nextSample = sampleIndex + 1; + if (nextSample % phaseReanchorInterval === 0) { + const carrier = carrierAtSample( + sampling.signedAdcFrequencyHz, + scene.sampleRate, + sceneSampleOffset + nextSample, + ); + carrierCosine = carrier.cosine; + carrierSine = carrier.sine; + } else { + const nextCosine = + carrierCosine * stepReal - carrierSine * stepImaginary; + carrierSine = + carrierSine * stepReal + carrierCosine * stepImaginary; + carrierCosine = nextCosine; + } + sampleIndex = nextSample; + } +} + export function synthesizeMicrophoneSignals(options) { const { scene, @@ -27,39 +196,27 @@ export function synthesizeMicrophoneSignals(options) { maximumBufferBytes = 512 * 1024 * 1024, signalOffsets, } = options; - const sampleCount = Math.ceil(scene.duration * scene.sampleRate); - const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1; - const maximumDelaySamples = - maximumAcousticDelaySeconds({ - scene, - arrayDefinition, - referenceDistance, - minimumDistance, - }) * scene.sampleRate; - const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2); - const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4; - const estimatedBufferBytes = - retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + delayWorkingBytes; - if (estimatedBufferBytes > maximumBufferBytes) { - throw new Error( - `예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` + - `현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`, - ); - } - const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount)); + const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate); const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0; - const renderedSources = scene.sources.map((source, sourceIndex) => ({ - definition: source, - samples: renderSignal(source, scene, assetBuffers, 0, { - sceneSampleOffset, - ...(signalOffsets?.sources?.[sourceIndex] ?? {}), - }), - })); - - const acousticPathCounts = {}; - let acousticPathCount = 0; - renderedSources.forEach(({ definition, samples }) => { - const microphonePaths = arrayDefinition.microphones.map((microphone) => + const cropStartSample = signalOffsets?.cropStartSample ?? 0; + const cropSampleCount = signalOffsets?.cropSampleCount ?? sampleCount; + if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) { + throw new RangeError("sceneSampleOffset must be a non-negative safe integer."); + } + if ( + !Number.isSafeInteger(cropStartSample) || + cropStartSample < 0 || + !Number.isSafeInteger(cropSampleCount) || + cropSampleCount <= 0 || + cropStartSample + cropSampleCount > sampleCount + ) { + throw new RangeError("signal output crop must be a non-empty range inside the synthesis buffer."); + } + const cropEndSample = cropStartSample + cropSampleCount; + const needsOutputCrop = cropStartSample !== 0 || cropSampleCount !== sampleCount; + const sourcePathSets = scene.sources.map((definition) => ({ + definition, + microphonePaths: arrayDefinition.microphones.map((microphone) => buildAcousticPaths({ scene, sourcePosition: definition.position, @@ -69,7 +226,117 @@ export function synthesizeMicrophoneSignals(options) { referenceDistance, minimumDistance, }), + ), + })); + const acousticPathCounts = {}; + let acousticPathCount = 0; + let maximumDelaySamples = 0; + for (const { microphonePaths } of sourcePathSets) { + for (const paths of microphonePaths) { + for (const path of paths) { + acousticPathCount += 1; + acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1; + maximumDelaySamples = Math.max( + maximumDelaySamples, + path.delaySeconds * scene.sampleRate, + ); + } + } + } + const sampledSourceCount = scene.sources + .filter((source) => source.signal.type !== "tone") + .length; + const hasSampledAmbient = (scene.ambient ?? []) + .some((ambient) => ambient.signal.type !== "tone"); + const retainedBufferCount = + arrayDefinition.microphones.length + + sampledSourceCount + + (hasSampledAmbient ? 1 : 0); + const delayFftSize = nextPowerOfTwo( + sampleCount + ceilSamplePosition(maximumDelaySamples) + 2, + ); + const delayWorkingBytes = + sampledSourceCount > 0 || hasSampledAmbient + ? delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4 + : 0; + const outputCropBytes = needsOutputCrop + ? arrayDefinition.microphones.length * cropSampleCount * Float32Array.BYTES_PER_ELEMENT + : 0; + const estimatedBufferBytes = + retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + + delayWorkingBytes + + outputCropBytes; + if (estimatedBufferBytes > maximumBufferBytes) { + throw new Error( + `예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` + + `현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`, ); + } + const synthesisChannels = arrayDefinition.microphones.map( + () => new Float32Array(sampleCount), + ); + const renderedSources = sourcePathSets.map(({ definition, microphonePaths }, sourceIndex) => ({ + definition, + microphonePaths, + timeMapping: { + sceneSampleOffset, + ...(signalOffsets?.sources?.[sourceIndex] ?? {}), + }, + ...(definition.signal.type === "tone" + ? {} + : { + samples: renderSignal(definition, scene, assetBuffers, 0, { + sceneSampleOffset, + ...(signalOffsets?.sources?.[sourceIndex] ?? {}), + }), + }), + })); + const outputStartSample = sceneSampleOffset + cropStartSample; + const outputEndSample = outputStartSample + cropSampleCount; + const sourceArrivals = renderedSources.map(({ definition, microphonePaths, timeMapping }) => { + const emissionStartTime = timeMapping.signalStartTime ?? definition.startTime ?? 0; + const emissionDuration = timeMapping.signalDuration ?? definition.duration ?? scene.duration; + let earliestArrivalSample = Infinity; + let latestArrivalEndSample = -Infinity; + let overlapsOutput = false; + const overlappingPathKinds = new Set(); + for (const paths of microphonePaths) { + for (const path of paths) { + const arrivalStartSample = ceilSampleIndex( + emissionStartTime + path.delaySeconds, + scene.sampleRate, + ); + const arrivalEndSample = ceilSampleIndex( + emissionStartTime + emissionDuration + path.delaySeconds, + scene.sampleRate, + ); + earliestArrivalSample = Math.min(earliestArrivalSample, arrivalStartSample); + latestArrivalEndSample = Math.max(latestArrivalEndSample, arrivalEndSample); + if (arrivalStartSample < outputEndSample && arrivalEndSample > outputStartSample) { + overlapsOutput = true; + overlappingPathKinds.add(path.kind); + } + } + } + return { + sourceId: definition.id, + emissionStartSample: ceilSampleIndex(emissionStartTime, scene.sampleRate), + emissionEndSample: ceilSampleIndex( + emissionStartTime + emissionDuration, + scene.sampleRate, + ), + earliestArrivalSample: Number.isFinite(earliestArrivalSample) + ? earliestArrivalSample + : null, + latestArrivalEndSample: Number.isFinite(latestArrivalEndSample) + ? latestArrivalEndSample + : null, + overlapsOutput, + overlappingPathKinds: [...overlappingPathKinds].sort(), + }; + }); + + renderedSources.forEach(({ definition, microphonePaths, samples, timeMapping }) => { let sourceMaximumDelaySamples = 0; for (const paths of microphonePaths) { for (const path of paths) { @@ -79,39 +346,64 @@ export function synthesizeMicrophoneSignals(options) { ); } } - const spectrum = prepareDelaySpectrum(samples, sourceMaximumDelaySamples); - const workspace = createDelayWorkspace(spectrum.fftSize); + const spectrum = samples + ? prepareDelaySpectrum(samples, sourceMaximumDelaySamples) + : undefined; + const workspace = spectrum + ? createDelayWorkspace(spectrum.fftSize) + : undefined; arrayDefinition.microphones.forEach((microphone, microphoneIndex) => { const paths = microphonePaths[microphoneIndex]; - for (const path of paths) { - acousticPathCount += 1; - acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1; - } const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0); - addFrequencyDomainPaths({ - output: channels[microphoneIndex], - spectrum, - paths: paths.map((path) => ({ - delaySamples: path.delaySeconds * scene.sampleRate, - gain: path.gain, - })), - gain: sensitivity, - phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, - frequencyResponse: microphone.frequencyResponse, - sampleRate: scene.sampleRate, - workspace, - }); + if (definition.signal.type === "tone") { + addSampledAnalogTonePaths({ + output: synthesisChannels[microphoneIndex], + entry: definition, + scene, + microphone, + paths, + timeMapping, + }); + } else { + addFrequencyDomainPaths({ + output: synthesisChannels[microphoneIndex], + spectrum, + paths: paths.map((path) => ({ + delaySamples: path.delaySeconds * scene.sampleRate, + gain: path.gain, + })), + gain: sensitivity, + phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, + frequencyResponse: microphone.frequencyResponse, + sampleRate: scene.sampleRate, + workspace, + }); + } }); }); if (Array.isArray(scene.ambient)) { scene.ambient.forEach((ambient, ambientIndex) => { - channels.forEach((channel, microphoneIndex) => { + synthesisChannels.forEach((channel, microphoneIndex) => { const microphone = arrayDefinition.microphones[microphoneIndex]; - const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d); - const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, { + const ambientTimeMapping = { sceneSampleOffset, ...(signalOffsets?.ambient?.[ambientIndex] ?? {}), + }; + if (ambient.signal.type === "tone") { + addSampledAnalogTonePaths({ + output: channel, + entry: ambient, + scene, + microphone, + paths: [{ delaySeconds: 0, gain: 1 }], + timeMapping: ambientTimeMapping, + }); + return; + } + const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d); + const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, { + ...ambientTimeMapping, }); const spectrum = prepareDelaySpectrum(ambientSamples, 0); addFrequencyDomainDelay({ @@ -128,6 +420,10 @@ export function synthesizeMicrophoneSignals(options) { }); } + const channels = needsOutputCrop + ? synthesisChannels.map((channel) => channel.slice(cropStartSample, cropEndSample)) + : synthesisChannels; + let preNormalizationPeak = 0; for (const channel of channels) { for (const value of channel) { @@ -159,5 +455,49 @@ export function synthesizeMicrophoneSignals(options) { acousticModel: scene.acoustics?.model ?? "free-field", acousticPathCount, acousticPathCounts, + windowing: { + model: signalOffsets?.windowingModel ?? "whole-scene", + renderStartSample: sceneSampleOffset, + outputStartSample, + outputSampleCount: cropSampleCount, + preRollSamples: cropStartSample, + activePathMaximumDelaySeconds: maximumDelaySamples / scene.sampleRate, + requestedMaximumPropagationDelaySeconds: + signalOffsets?.maximumPropagationDelaySeconds ?? maximumDelaySamples / scene.sampleRate, + geometryModel: Number.isFinite(signalOffsets?.sourcePositionSampleTime) + ? "source-position-fixed-at-window-midpoint" + : "static-source-position", + sourcePositionSampleTime: signalOffsets?.sourcePositionSampleTime ?? null, + dopplerMode: "not-modeled", + delayedTailPolicy: needsOutputCrop + ? "propagation-preroll-then-output-crop" + : "whole-render-buffer", + arrivalOverlapModel: "path-gated-half-open-sample-interval", + arrivingSourceIds: sourceArrivals + .filter(({ overlapsOutput }) => overlapsOutput) + .map(({ sourceId }) => sourceId), + sourceArrivals, + }, + sampling: { + model: "ideal-real-sampler-no-antialias-filter", + sampleRate: scene.sampleRate, + nyquistHz: scene.sampleRate / 2, + toneObservations: [ + ...scene.sources + .filter((source) => source.signal.type === "tone") + .map((source) => ({ + scope: "source", + id: source.id, + ...describeFrequencyAliasing(source.signal.frequency, scene.sampleRate), + })), + ...(scene.ambient ?? []) + .filter((ambient) => ambient.signal.type === "tone") + .map((ambient) => ({ + scope: "ambient", + id: ambient.id, + ...describeFrequencyAliasing(ambient.signal.frequency, scene.sampleRate), + })), + ], + }, }; } diff --git a/packages/acoustics-core/realtime-localizer.js b/packages/acoustics-core/realtime-localizer.js index af1eb86..fbca96c 100644 --- a/packages/acoustics-core/realtime-localizer.js +++ b/packages/acoustics-core/realtime-localizer.js @@ -152,20 +152,18 @@ function copyScan(scan) { return Object.freeze({ ...scan, columns, rows }); } -function copyBands(bands, sampleRate) { +function copyBands(bands) { if (!Array.isArray(bands) || bands.length === 0) { throw new TypeError("bands must be a non-empty array."); } - const nyquist = sampleRate / 2; return Object.freeze(bands.map((band, index) => { if ( !Number.isFinite(band?.minHz) || !Number.isFinite(band?.maxHz) || band.minHz < 0 || - band.maxHz <= band.minHz || - band.maxHz >= nyquist + band.maxHz <= band.minHz ) { - throw new RangeError(`bands[${index}] must lie inside 0–${nyquist} Hz.`); + throw new RangeError(`bands[${index}] must satisfy 0 <= minHz < maxHz.`); } return Object.freeze({ ...band, @@ -285,7 +283,7 @@ export function createLocalizationPlan(config) { const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition); const scan = copyScan(config.scan); const peakDetection = copyPeakDetection(config.peakDetection, scan); - const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate); + const frequencyBands = copyBands(config.bands ?? config.frequencyBands); const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize"); const hopSize = requirePositiveInteger( config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2, diff --git a/packages/acoustics-core/sampling.js b/packages/acoustics-core/sampling.js new file mode 100644 index 0000000..55bf640 --- /dev/null +++ b/packages/acoustics-core/sampling.js @@ -0,0 +1,250 @@ +export const MAX_PRECISE_ANALOG_FREQUENCY_HZ = 1_000_000; +export const MAX_EXPLICIT_FOLD_SEGMENTS = 513; + +function requireSampleRate(sampleRate) { + if (!Number.isFinite(sampleRate) || sampleRate <= 0) { + throw new RangeError("sampleRate must be a positive finite number."); + } + return sampleRate; +} + +function requireFrequency(frequencyHz, label = "frequencyHz") { + if ( + !Number.isFinite(frequencyHz) || + frequencyHz < 0 || + frequencyHz > MAX_PRECISE_ANALOG_FREQUENCY_HZ + ) { + throw new RangeError( + `${label} must be between 0 and ${MAX_PRECISE_ANALOG_FREQUENCY_HZ} Hz.`, + ); + } + return frequencyHz; +} + +function positiveModulo(value, modulus) { + const remainder = value % modulus; + return remainder < 0 ? remainder + modulus : remainder; +} + +function normalizedZero(value) { + return Object.is(value, -0) ? 0 : value; +} + +/** + * Describe the frequency that an ideal real-valued sampler records. + * + * No anti-alias filter is implied. A negative signed ADC frequency means that + * the positive-frequency spectrum is mirrored when represented on 0..Nyquist. + */ +export function describeFrequencyAliasing(frequencyHz, sampleRate) { + requireFrequency(frequencyHz); + requireSampleRate(sampleRate); + const nyquistHz = sampleRate / 2; + const wrappedHz = positiveModulo(frequencyHz, sampleRate); + const signedAdcFrequencyHz = + wrappedHz <= nyquistHz ? wrappedHz : wrappedHz - sampleRate; + const adcFrequencyHz = Math.abs(signedAdcFrequencyHz); + const dcEdge = adcFrequencyHz === 0; + const nyquistEdge = adcFrequencyHz === nyquistHz; + return { + model: "ideal-real-sampler-no-antialias-filter", + sampleRate, + nyquistHz, + analogFrequencyHz: frequencyHz, + adcFrequencyHz: normalizedZero(adcFrequencyHz), + signedAdcFrequencyHz: normalizedZero(signedAdcFrequencyHz), + aliased: frequencyHz > nyquistHz, + spectralInversion: signedAdcFrequencyHz < 0, + dcEdge, + nyquistEdge, + phaseDegenerate: dcEdge || nyquistEdge, + }; +} + +export function foldFrequencyToNyquist(frequencyHz, sampleRate) { + return describeFrequencyAliasing(frequencyHz, sampleRate).adcFrequencyHz; +} + +function containsResidue(first, last, residue, modulus) { + const firstResidue = positiveModulo(first, modulus); + const offset = positiveModulo(residue - firstResidue, modulus); + return first + offset <= last; +} + +function contiguousBinRanges(bins, binWidthHz) { + const ranges = []; + let firstBin = bins[0]; + let lastBin = firstBin; + for (let index = 1; index <= bins.length; index += 1) { + const bin = bins[index]; + if (bin === lastBin + 1) { + lastBin = bin; + continue; + } + ranges.push({ + firstBin, + lastBin, + firstBinCenterHz: firstBin * binWidthHz, + lastBinCenterHz: lastBin * binWidthHz, + }); + firstBin = bin; + lastBin = bin; + } + return ranges; +} + +function foldBandSegments(minHz, maxHz, sampleRate) { + const nyquistHz = sampleRate / 2; + const firstZoneIndex = Math.floor(minHz / nyquistHz); + const lastZoneIndex = Math.ceil(maxHz / nyquistHz) - 1; + const segmentCount = lastZoneIndex - firstZoneIndex + 1; + if ( + !Number.isSafeInteger(firstZoneIndex) || + !Number.isSafeInteger(lastZoneIndex) || + !Number.isSafeInteger(segmentCount) || + segmentCount < 1 || + segmentCount > MAX_EXPLICIT_FOLD_SEGMENTS + ) { + throw new RangeError( + `band crosses ${segmentCount} Nyquist zones; the explicit limit is ` + + `${MAX_EXPLICIT_FOLD_SEGMENTS}.`, + ); + } + const segments = []; + let analogStartHz = minHz; + while (analogStartHz < maxHz) { + const zoneIndex = Math.floor(analogStartHz / nyquistHz); + const nyquistZone = zoneIndex + 1; + const nextBoundaryHz = nyquistZone * nyquistHz; + const analogEndHz = Math.min(maxHz, nextBoundaryHz); + if (!(analogEndHz > analogStartHz)) { + throw new RangeError("band is too large to retain Nyquist-zone precision."); + } + const inverted = nyquistZone % 2 === 0; + const signedAdcStartHz = inverted + ? analogStartHz - nyquistZone * nyquistHz + : analogStartHz - (nyquistZone - 1) * nyquistHz; + const signedAdcEndHz = inverted + ? analogEndHz - nyquistZone * nyquistHz + : analogEndHz - (nyquistZone - 1) * nyquistHz; + segments.push({ + analogMinHz: analogStartHz, + analogMaxHz: analogEndHz, + signedAdcStartHz: normalizedZero(signedAdcStartHz), + signedAdcEndHz: normalizedZero(signedAdcEndHz), + inverted, + aliasOrder: Math.floor(nyquistZone / 2), + nyquistZone, + }); + analogStartHz = analogEndHz; + } + return segments; +} + +function hasContinuousFoldOverlap(segments) { + const adcRanges = segments.map((segment) => ({ + minHz: Math.min( + Math.abs(segment.signedAdcStartHz), + Math.abs(segment.signedAdcEndHz), + ), + maxHz: Math.max( + Math.abs(segment.signedAdcStartHz), + Math.abs(segment.signedAdcEndHz), + ), + })).sort((left, right) => left.minHz - right.minHz || left.maxHz - right.maxHz); + let maximumEndHz = -Infinity; + for (const range of adcRanges) { + if (range.minHz < maximumEndHz) return true; + maximumEndHz = Math.max(maximumEndHz, range.maxHz); + } + return false; +} + +function adcBinSetKey(sampleRate, fftSize, ranges) { + const rangeKey = ranges + .map(({ firstBin, lastBin }) => + firstBin === lastBin ? String(firstBin) : `${firstBin}-${lastBin}`) + .join(","); + return `fs:${sampleRate}|fft:${fftSize}|bins:${rangeKey}`; +} + +/** + * Map a requested pre-sampling frequency interval to the observable one-sided + * real FFT bins. Every matching bin is retained; this function does not choose + * an alias order or steer with the original carrier. + */ +export function observeFrequencyBand(band, sampleRate, fftSize) { + requireSampleRate(sampleRate); + if (!Number.isInteger(fftSize) || fftSize < 2 || fftSize % 2 !== 0) { + throw new RangeError("fftSize must be a positive even integer."); + } + if (!band || typeof band !== "object") { + throw new TypeError("band must be an object."); + } + requireFrequency(band.minHz, "band.minHz"); + requireFrequency(band.maxHz, "band.maxHz"); + if (band.minHz >= band.maxHz) { + throw new RangeError("band.minHz must be lower than band.maxHz."); + } + + const binWidthHz = sampleRate / fftSize; + const firstAnalogBin = Math.ceil(band.minHz / binWidthHz); + const lastAnalogBin = Math.floor(band.maxHz / binWidthHz); + if ( + !Number.isSafeInteger(firstAnalogBin) || + !Number.isSafeInteger(lastAnalogBin) + ) { + throw new RangeError("band frequency is too large for safe FFT-bin mapping."); + } + if (firstAnalogBin > lastAnalogBin) { + throw new Error( + `${band.minHz}–${band.maxHz} Hz contains no FFT-grid frequency at ${binWidthHz} Hz spacing.`, + ); + } + + const bins = []; + const halfSize = fftSize / 2; + for (let adcBin = 0; adcBin <= halfSize; adcBin += 1) { + const positiveResidue = adcBin; + const negativeResidue = adcBin === 0 || adcBin === halfSize + ? adcBin + : fftSize - adcBin; + if ( + containsResidue(firstAnalogBin, lastAnalogBin, positiveResidue, fftSize) || + containsResidue(firstAnalogBin, lastAnalogBin, negativeResidue, fftSize) + ) { + bins.push(adcBin); + } + } + if (bins.length === 0) { + throw new Error(`${band.minHz}–${band.maxHz} Hz maps to no observable FFT bin.`); + } + + const nyquistHz = sampleRate / 2; + const adcBinCenterRanges = contiguousBinRanges(bins, binWidthHz); + const foldSegments = foldBandSegments(band.minHz, band.maxHz, sampleRate); + const analogBinCount = lastAnalogBin - firstAnalogBin + 1; + const continuousManyToOne = hasContinuousFoldOverlap(foldSegments); + const gridManyToOne = analogBinCount > bins.length; + return { + model: "ideal-real-sampler-no-antialias-filter", + sampleRate, + nyquistHz, + fftSize, + binWidthHz, + requestedMinHz: band.minHz, + requestedMaxHz: band.maxHz, + aliased: band.maxHz > nyquistHz, + crossesFirstNyquist: band.minHz < nyquistHz && band.maxHz > nyquistHz, + localizationInputModel: "observed-one-sided-real-fft-bins", + selectionModel: "fft-bin-center-membership", + originalCarrierUsed: false, + foldBoundaryCount: Math.max(0, foldSegments.length - 1), + continuousManyToOne, + gridManyToOne, + foldSegments, + bins, + adcBinCenterRanges, + adcBinSetKey: adcBinSetKey(sampleRate, fftSize, adcBinCenterRanges), + }; +} diff --git a/packages/acoustics-core/signal.js b/packages/acoustics-core/signal.js index 19a5841..5ea1f64 100644 --- a/packages/acoustics-core/signal.js +++ b/packages/acoustics-core/signal.js @@ -1,4 +1,4 @@ -import { dbToLinear, TAU } from "./math.js"; +import { ceilSampleIndex, dbToLinear, TAU } from "./math.js"; import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js"; function sampleAsset(signal, assetBuffers, time) { @@ -25,16 +25,20 @@ function sampleAsset(signal, assetBuffers, time) { * Omitting it keeps the historical whole-scene behaviour exactly unchanged. */ export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0, timeMapping = {}) { - const sampleCount = Math.ceil(scene.duration * scene.sampleRate); + const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate); const output = new Float32Array(sampleCount); const startTime = entry.startTime ?? 0; const duration = entry.duration ?? scene.duration; const gain = dbToLinear(entry.gainDb ?? 0); - const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate)); - const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate)); + const startSample = Math.max(0, ceilSampleIndex(startTime, scene.sampleRate)); + const endSample = Math.min( + sampleCount, + ceilSampleIndex(startTime + duration, scene.sampleRate), + ); const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0; const signalStartTime = timeMapping.signalStartTime ?? startTime; - const signalStartSample = timeMapping.signalStartSample ?? Math.ceil(startTime * scene.sampleRate); + const signalStartSample = + timeMapping.signalStartSample ?? ceilSampleIndex(startTime, scene.sampleRate); if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) { throw new RangeError("sceneSampleOffset must be a non-negative safe integer."); } diff --git a/packages/acoustics-core/stft.js b/packages/acoustics-core/stft.js index ae09381..5948b4b 100644 --- a/packages/acoustics-core/stft.js +++ b/packages/acoustics-core/stft.js @@ -1,5 +1,6 @@ import { fftInPlace } from "./fft.js"; import { cooperativeYield } from "./cooperative-yield.js"; +import { observeFrequencyBand } from "./sampling.js"; function throwIfAborted(signal) { if (signal?.aborted) { @@ -119,11 +120,5 @@ export async function extractStftBins(channels, options) { } export function binsForBand(band, sampleRate, fftSize) { - const binWidth = sampleRate / fftSize; - const firstBin = Math.max(0, Math.ceil(band.minHz / binWidth)); - const lastBin = Math.min(fftSize / 2, Math.floor(band.maxHz / binWidth)); - if (firstBin > lastBin) { - throw new Error(`${band.minHz}–${band.maxHz} Hz에 해당하는 FFT bin이 없습니다.`); - } - return Array.from({ length: lastBin - firstBin + 1 }, (_, index) => firstBin + index); + return observeFrequencyBand(band, sampleRate, fftSize).bins; } diff --git a/packages/acoustics-core/trajectory.js b/packages/acoustics-core/trajectory.js index 48b4674..fb8481b 100644 --- a/packages/acoustics-core/trajectory.js +++ b/packages/acoustics-core/trajectory.js @@ -1,3 +1,6 @@ +import { maximumAcousticDelaySeconds } from "./environment.js"; +import { ceilSampleIndex } from "./math.js"; + function copyPosition(position) { if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) { throw new TypeError("source position must be a finite [x, y, z] array."); @@ -65,7 +68,7 @@ export function sceneAtTime(scene, time) { * Approximate a moving scene as stationary during [startTime, endTime] by * sampling every trajectory at the window midpoint. */ -export function sampleQuasiStaticWindow(scene, startTime, endTime) { +export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {}) { requireFiniteTime(startTime, "startTime"); requireFiniteTime(endTime, "endTime"); if (startTime < 0 || endTime <= startTime) { @@ -90,42 +93,77 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) { // boundary. Adjacent windows therefore share an exact boundary without a // duplicated or omitted sample, even when their requested times are not // integer multiples of the sample period. - const startSample = Math.ceil(startTime * sampleRate); - const endSample = Math.ceil(endTime * sampleRate); + const startSample = ceilSampleIndex(startTime, sampleRate); + const endSample = ceilSampleIndex(endTime, sampleRate); if (endSample <= startSample) { throw new RangeError("window must contain at least one sample."); } const alignedStartTime = startSample / sampleRate; const alignedEndTime = endSample / sampleRate; const sampleTime = (startSample + endSample) / (2 * sampleRate); + const sampledScene = sceneAtTime(scene, sampleTime); + let maximumPropagationDelaySeconds = options.maximumPropagationDelaySeconds; + if (maximumPropagationDelaySeconds === undefined) { + maximumPropagationDelaySeconds = options.arrayDefinition + ? maximumAcousticDelaySeconds({ + scene: sampledScene, + arrayDefinition: options.arrayDefinition, + referenceDistance: options.referenceDistance ?? 1, + minimumDistance: options.minimumDistance ?? 0.01, + }) + : 0; + } + if ( + !Number.isFinite(maximumPropagationDelaySeconds) || + maximumPropagationDelaySeconds < 0 + ) { + throw new RangeError("maximumPropagationDelaySeconds must be finite and non-negative."); + } + const preRollSamples = ceilSampleIndex(maximumPropagationDelaySeconds, sampleRate); + const renderStartSample = Math.max(0, startSample - preRollSamples); const sources = []; const sourceOffsets = []; - for (const source of scene.sources) { + for (let sourceIndex = 0; sourceIndex < scene.sources.length; sourceIndex += 1) { + const source = scene.sources[sourceIndex]; const signalStartTime = source.startTime ?? 0; const signalDuration = source.duration ?? scene.duration; - const signalStartSample = Math.ceil(signalStartTime * sampleRate); - const signalEndSample = Math.ceil((signalStartTime + signalDuration) * sampleRate); - const activeStartSample = Math.max(startSample, signalStartSample); + const signalStartSample = ceilSampleIndex(signalStartTime, sampleRate); + const signalEndSample = ceilSampleIndex(signalStartTime + signalDuration, sampleRate); + const activeStartSample = Math.max(renderStartSample, signalStartSample); const activeEndSample = Math.min(endSample, signalEndSample); if (activeEndSample <= activeStartSample) continue; sources.push({ ...source, - position: sourcePositionAtTime(source, sampleTime), - startTime: (activeStartSample - startSample) / sampleRate, + position: sampledScene.sources[sourceIndex].position, + startTime: (activeStartSample - renderStartSample) / sampleRate, duration: (activeEndSample - activeStartSample) / sampleRate, }); - sourceOffsets.push({ signalStartTime, signalStartSample }); + sourceOffsets.push({ + signalStartTime, + signalStartSample, + signalDuration, + }); } const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient; const signalOffsets = { - sceneSampleOffset: startSample, + sceneSampleOffset: renderStartSample, + cropStartSample: startSample - renderStartSample, + cropSampleCount: endSample - startSample, + maximumPropagationDelaySeconds, + sourcePositionSampleTime: sampleTime, + windowingModel: "quasi-static-midpoint-propagation-preroll", sources: sourceOffsets, - ambient: (scene.ambient ?? []).map(() => ({ signalStartTime: 0, signalStartSample: 0 })), + ambient: (scene.ambient ?? []).map(() => ({ + signalStartTime: 0, + signalStartSample: 0, + signalDuration: scene.duration, + })), }; const duration = (endSample - startSample) / sampleRate; + const renderDuration = (endSample - renderStartSample) / sampleRate; return { startTime: alignedStartTime, endTime: alignedEndTime, @@ -134,10 +172,14 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) { endSample, sampleCount: endSample - startSample, duration, + renderStartSample, + renderDuration, + preRollSamples: startSample - renderStartSample, + maximumPropagationDelaySeconds, signalOffsets, scene: { ...scene, - duration, + duration: renderDuration, sources, ...(Array.isArray(scene.ambient) ? { ambient } : {}), }, @@ -170,7 +212,7 @@ export function createQuasiStaticWindows(scene, options = {}) { const windows = []; for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) { const windowEnd = Math.min(windowStart + windowDuration, endTime); - windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd)); + windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd, options)); } return windows; } diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 82e75d6..6bad6a0 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -1,10 +1,16 @@ # Contracts 네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제 -JSON Schema를 실행하고, `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼 +JSON Schema를 실행하고, `validation.js`는 중복 id, 시간 구간, 참조 무결성처럼 스키마만으로 표현하기 불편한 조건을 추가 검사합니다. -계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다. +Nyquist 초과 tone과 Experiment band는 거절하지 않습니다. acoustics core가 anti-alias filter 없는 +ideal ADC alias를 명시적으로 계산하고 Result에 원/관측 주파수와 선택 bin을 기록합니다. + +Array·Scene·Experiment 계약 버전은 +`INPUT_SCHEMA_VERSION = "1.0.0"`, sampling provenance가 필수인 Result 계약은 +`RESULT_SCHEMA_VERSION = "1.1.0"`입니다. 두 버전은 `validation.js`에서 별도 상수로 +공개합니다. 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다. `+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다. 저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요. diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js index 5db8258..8e465b1 100644 --- a/packages/contracts/validation.js +++ b/packages/contracts/validation.js @@ -1,6 +1,12 @@ import { validateDocumentStructure } from "./schema-validator.js"; +import { + describeFrequencyAliasing, + MAX_PRECISE_ANALOG_FREQUENCY_HZ, + observeFrequencyBand, +} from "../acoustics-core/sampling.js"; -export const SCHEMA_VERSION = "1.0.0"; +export const INPUT_SCHEMA_VERSION = "1.0.0"; +export const RESULT_SCHEMA_VERSION = "1.1.0"; const MAX_MICROPHONES = 1024; const MAX_SOURCES = 32; const MAX_GRID_SIDE = 512; @@ -18,6 +24,30 @@ function isFiniteNumber(value) { return typeof value === "number" && Number.isFinite(value); } +function samplingMetadataEqual(actual, expected) { + if (typeof actual === "number" && typeof expected === "number") { + const tolerance = + 1e-9 + 1e-12 * Math.max(Math.abs(actual), Math.abs(expected)); + return Number.isFinite(actual) && + Number.isFinite(expected) && + Math.abs(actual - expected) <= tolerance; + } + if (Array.isArray(actual) || Array.isArray(expected)) { + return Array.isArray(actual) && + Array.isArray(expected) && + actual.length === expected.length && + actual.every((value, index) => + samplingMetadataEqual(value, expected[index])); + } + if (isRecord(actual) || isRecord(expected)) { + if (!isRecord(actual) || !isRecord(expected)) return false; + const expectedKeys = Object.keys(expected); + return expectedKeys.every((key) => + samplingMetadataEqual(actual[key], expected[key])); + } + return actual === expected; +} + function addIssue(issues, path, message) { issues.push({ path, message }); } @@ -92,8 +122,8 @@ function boundsContain(bounds, position) { function validateHeader(value, expectedKind, issues) { if (!requireRecord(value, "$", issues)) return false; - if (value.schemaVersion !== SCHEMA_VERSION) { - addIssue(issues, "$.schemaVersion", `지원 버전은 ${SCHEMA_VERSION}입니다.`); + if (value.schemaVersion !== INPUT_SCHEMA_VERSION) { + addIssue(issues, "$.schemaVersion", `지원 버전은 ${INPUT_SCHEMA_VERSION}입니다.`); } if (value.kind !== expectedKind) addIssue(issues, "$.kind", `${expectedKind}이어야 합니다.`); requireString(value.id, "$.id", issues); @@ -194,14 +224,14 @@ function validateArrayRelationships(value) { return issues; } -function validateSignal(signal, path, issues, assetsById, sampleRate) { +function validateSignal(signal, path, issues, assetsById) { if (!requireRecord(signal, path, issues)) return; if (signal.type === "tone") { - if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) { - if (signal.frequency >= sampleRate / 2) { - addIssue(issues, `${path}.frequency`, `Nyquist 주파수 ${sampleRate / 2} Hz보다 낮아야 합니다.`); - } - } + requireFinite(signal.frequency, `${path}.frequency`, issues, { + min: 0, + max: MAX_PRECISE_ANALOG_FREQUENCY_HZ, + exclusiveMin: true, + }); if (signal.phaseDeg !== undefined) { requireFinite(signal.phaseDeg, `${path}.phaseDeg`, issues, { min: -360, max: 360 }); } @@ -358,7 +388,7 @@ function validateSceneRelationships(value) { if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) { addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다."); } - validateSignal(source.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000); + validateSignal(source.signal, `${path}.signal`, issues, assetsById); }); for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`); @@ -369,7 +399,7 @@ function validateSceneRelationships(value) { if (!requireRecord(ambient, path, issues)) return; if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id); requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 }); - validateSignal(ambient.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000); + validateSignal(ambient.signal, `${path}.signal`, issues, assetsById); }); for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`); } @@ -412,14 +442,18 @@ function validateExperimentRelationships(value, context = {}) { const path = `$.frequencyBands[${index}]`; if (!requireRecord(band, path, issues)) return; if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id); - const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 }); - const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true }); + const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { + min: 0, + max: MAX_PRECISE_ANALOG_FREQUENCY_HZ, + }); + const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { + min: 0, + max: MAX_PRECISE_ANALOG_FREQUENCY_HZ, + exclusiveMin: true, + }); if (minValid && maxValid && band.minHz >= band.maxHz) { addIssue(issues, path, "minHz는 maxHz보다 작아야 합니다."); } - if (context.scene && maxValid && band.maxHz >= context.scene.sampleRate / 2) { - addIssue(issues, `${path}.maxHz`, `Nyquist 주파수 ${context.scene.sampleRate / 2} Hz보다 낮아야 합니다.`); - } if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) { addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다."); } @@ -436,6 +470,24 @@ function validateExperimentRelationships(value, context = {}) { } if (value.stft.window !== "hann") addIssue(issues, "$.stft.window", "hann이어야 합니다."); } + if ( + context.scene && + Number.isFinite(context.scene.sampleRate) && + Number.isInteger(value.stft?.fftSize) && + Array.isArray(value.frequencyBands) + ) { + value.frequencyBands.forEach((band, index) => { + try { + observeFrequencyBand(band, context.scene.sampleRate, value.stft.fftSize); + } catch (error) { + addIssue( + issues, + `$.frequencyBands[${index}]`, + `선택한 FFT grid의 bin center가 없는 대역입니다: ${error.message}`, + ); + } + }); + } if (requireRecord(value.beamforming, "$.beamforming", issues)) { if (!LOCALIZATION_ALGORITHMS.includes(value.beamforming.algorithm)) { addIssue( @@ -502,7 +554,15 @@ function validateExperimentRelationships(value, context = {}) { "gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.", ); } - const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1; + const sampledSourceCount = context.scene.sources + .filter((source) => source.signal?.type !== "tone") + .length; + const hasSampledAmbient = (context.scene.ambient ?? []) + .some((ambient) => ambient.signal?.type !== "tone"); + const retainedBufferCount = + context.arrayDefinition.microphones.length + + sampledSourceCount + + (hasSampledAmbient ? 1 : 0); const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate); let maximumDelaySamples = 0; for (const source of context.scene.sources) { @@ -524,8 +584,15 @@ function validateExperimentRelationships(value, context = {}) { } } let delayFftSize = 1; - while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) delayFftSize *= 2; - const estimatedBytes = retainedBufferCount * sampleCount * 4 + delayFftSize * 8 * 4; + const hasSampledSignal = sampledSourceCount > 0 || hasSampledAmbient; + if (hasSampledSignal) { + while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) { + delayFftSize *= 2; + } + } + const delayWorkingBytes = hasSampledSignal ? delayFftSize * 8 * 4 : 0; + const estimatedBytes = + retainedBufferCount * sampleCount * 4 + delayWorkingBytes; const maximumBytes = 512 * 1024 * 1024; if (estimatedBytes > maximumBytes) { addIssue( @@ -556,6 +623,13 @@ function validateResultRelationships(value, context = {}) { const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []); const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []); const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm; + if (resultAlgorithm === "gcc-phat" && value.backend === "webgpu") { + addIssue( + issues, + "$.backend", + "gcc-phat Result must use the CPU backend.", + ); + } const expectedRawScale = resultAlgorithm === "das" ? "power" @@ -568,6 +642,201 @@ function validateResultRelationships(value, context = {}) { : expectedRawScale === "score" ? "connected-half-maximum-score-weighted-covariance" : undefined; + if ( + Number.isFinite(value.timingMs?.localization) && + Number.isFinite(value.timingMs?.beamforming) && + !samplingMetadataEqual( + value.timingMs.localization, + value.timingMs.beamforming, + ) + ) { + addIssue( + issues, + "$.timingMs.localization", + "localization must equal the compatibility beamforming timing alias.", + ); + } + const expectedObservations = new Map(); + const expectedEquivalentGroups = new Map(); + if ( + context.scene && + context.experiment && + Number.isFinite(context.scene.sampleRate) && + Number.isInteger(context.experiment.stft?.fftSize) + ) { + const definitions = Array.isArray(context.experiment.frequencyBands) + ? context.experiment.frequencyBands + : []; + for (const definition of definitions) { + try { + const observation = observeFrequencyBand( + definition, + context.scene.sampleRate, + context.experiment.stft.fftSize, + ); + expectedObservations.set(definition.id, observation); + const group = expectedEquivalentGroups.get(observation.adcBinSetKey) ?? []; + group.push(definition.id); + expectedEquivalentGroups.set(observation.adcBinSetKey, group); + } catch { + // Experiment validation reports an unusable band separately. + } + } + } + const sampling = value.signalStats?.sampling; + const contextSampleRate = + Number.isFinite(context.scene?.sampleRate) && context.scene.sampleRate > 0 + ? context.scene.sampleRate + : null; + const toneDefinitions = new Map(); + if (context.scene) { + const sources = Array.isArray(context.scene.sources) ? context.scene.sources : []; + const ambientEntries = Array.isArray(context.scene.ambient) + ? context.scene.ambient + : []; + for (const source of sources) { + if (source.signal?.type === "tone") { + toneDefinitions.set(`source:${source.id}`, source.signal.frequency); + } + } + for (const ambient of ambientEntries) { + if (ambient.signal?.type === "tone") { + toneDefinitions.set(`ambient:${ambient.id}`, ambient.signal.frequency); + } + } + } + if (isRecord(sampling)) { + const samplingRateValid = + Number.isFinite(sampling.sampleRate) && sampling.sampleRate > 0; + if ( + samplingRateValid && + !samplingMetadataEqual(sampling.nyquistHz, sampling.sampleRate / 2) + ) { + addIssue( + issues, + "$.signalStats.sampling.nyquistHz", + "sampling Nyquist must equal sampling sampleRate / 2.", + ); + } + if ( + contextSampleRate !== null && + !samplingMetadataEqual(sampling.sampleRate, contextSampleRate) + ) { + addIssue(issues, "$.signalStats.sampling.sampleRate", "sampling sampleRate must match Scene."); + } + if ( + contextSampleRate !== null && + !samplingMetadataEqual(sampling.nyquistHz, contextSampleRate / 2) + ) { + addIssue(issues, "$.signalStats.sampling.nyquistHz", "sampling Nyquist must equal sampleRate / 2."); + } + const observedToneKeys = new Set(); + const toneObservations = Array.isArray(sampling.toneObservations) + ? sampling.toneObservations + : []; + for (const [observationIndex, observation] of toneObservations.entries()) { + if (!isRecord(observation)) continue; + const path = `$.signalStats.sampling.toneObservations[${observationIndex}]`; + const observationKey = `${observation.scope}:${observation.id}`; + if (observedToneKeys.has(observationKey)) { + addIssue(issues, path, `duplicate tone observation: ${observationKey}`); + } + observedToneKeys.add(observationKey); + if (samplingRateValid && Number.isFinite(observation.analogFrequencyHz)) { + try { + const expected = describeFrequencyAliasing( + observation.analogFrequencyHz, + sampling.sampleRate, + ); + for (const key of [ + "analogFrequencyHz", + "adcFrequencyHz", + "signedAdcFrequencyHz", + "aliased", + "spectralInversion", + "dcEdge", + "nyquistEdge", + "phaseDegenerate", + ]) { + if (!samplingMetadataEqual(observation[key], expected[key])) { + addIssue( + issues, + `${path}.${key}`, + `${key} does not match ideal ADC sampling.`, + ); + } + } + } catch (error) { + addIssue(issues, `${path}.analogFrequencyHz`, error.message); + } + } + if (context.scene) { + const analogFrequencyHz = toneDefinitions.get(observationKey); + if (analogFrequencyHz === undefined) { + addIssue(issues, path, "tone observation must reference a Scene tone."); + } else if ( + !samplingMetadataEqual(observation.analogFrequencyHz, analogFrequencyHz) + ) { + addIssue( + issues, + `${path}.analogFrequencyHz`, + "analogFrequencyHz must match the Scene tone.", + ); + } + } + } + if (context.scene) { + for (const toneKey of toneDefinitions.keys()) { + if (!observedToneKeys.has(toneKey)) { + addIssue( + issues, + "$.signalStats.sampling.toneObservations", + `missing Scene tone observation: ${toneKey}`, + ); + } + } + } + } else if (contextSampleRate !== null) { + addIssue(issues, "$.signalStats.sampling", "sampling metadata is required."); + } + const standaloneObservations = new Map(); + const standaloneEquivalentGroups = new Map(); + if (Array.isArray(value.bands)) { + value.bands.forEach((band, bandIndex) => { + const observation = band?.frequencyObservation; + if (!isRecord(observation)) return; + if ( + !Number.isFinite(observation.sampleRate) || + !Number.isInteger(observation.fftSize) || + !Number.isFinite(observation.requestedMinHz) || + !Number.isFinite(observation.requestedMaxHz) + ) { + return; + } + try { + const expected = observeFrequencyBand( + { + minHz: observation.requestedMinHz, + maxHz: observation.requestedMaxHz, + }, + observation.sampleRate, + observation.fftSize, + ); + standaloneObservations.set(bandIndex, expected); + if (typeof band.id === "string") { + const group = standaloneEquivalentGroups.get(expected.adcBinSetKey) ?? []; + group.push(band.id); + standaloneEquivalentGroups.set(expected.adcBinSetKey, group); + } + } catch (error) { + addIssue( + issues, + `$.bands[${bandIndex}].frequencyObservation`, + `invalid self-described frequency observation: ${error.message}`, + ); + } + }); + } const bandIds = []; if (Array.isArray(value.bands)) { value.bands.forEach((band, bandIndex) => { @@ -577,7 +846,116 @@ function validateResultRelationships(value, context = {}) { bandIds.push(band.id); if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다."); } - const maps = [band.rawMap, band.displayMap, ...(band.processedMaps?.map((item) => item.map) ?? [])].filter(Boolean); + const standaloneObservation = standaloneObservations.get(bandIndex); + if (standaloneObservation && isRecord(band.frequencyObservation)) { + const observation = band.frequencyObservation; + const expectedEquivalentBandIds = (standaloneEquivalentGroups + .get(standaloneObservation.adcBinSetKey) ?? []) + .filter((candidateId) => candidateId !== band.id) + .sort(); + const expectedFields = { + model: standaloneObservation.model, + sampleRate: standaloneObservation.sampleRate, + nyquistHz: standaloneObservation.nyquistHz, + fftSize: standaloneObservation.fftSize, + binWidthHz: standaloneObservation.binWidthHz, + requestedMinHz: standaloneObservation.requestedMinHz, + requestedMaxHz: standaloneObservation.requestedMaxHz, + aliased: standaloneObservation.aliased, + crossesFirstNyquist: standaloneObservation.crossesFirstNyquist, + localizationInputModel: standaloneObservation.localizationInputModel, + selectionModel: standaloneObservation.selectionModel, + localizationModel: + resultAlgorithm === "gcc-phat" + ? "band-limited-gcc-phat-geometric-lag" + : "spherical-steering-at-positive-adc-bin-frequency", + originalCarrierUsed: standaloneObservation.originalCarrierUsed, + aliasOrderResolution: + standaloneObservation.aliased ? "not-attempted" : "not-applicable", + foldBoundaryCount: standaloneObservation.foldBoundaryCount, + continuousManyToOne: standaloneObservation.continuousManyToOne, + gridManyToOne: standaloneObservation.gridManyToOne, + adcBinSetKey: standaloneObservation.adcBinSetKey, + }; + for (const [key, expected] of Object.entries(expectedFields)) { + if (!samplingMetadataEqual(observation[key], expected)) { + addIssue( + issues, + `${path}.frequencyObservation.${key}`, + `${key} is not self-consistent with the Result sampling inputs.`, + ); + } + } + for (const [key, expected] of [ + ["adcBins", standaloneObservation.bins], + ["adcBinCenterRanges", standaloneObservation.adcBinCenterRanges], + ["foldSegments", standaloneObservation.foldSegments], + ["equivalentBandIds", expectedEquivalentBandIds], + ]) { + if (!samplingMetadataEqual(observation[key], expected)) { + addIssue( + issues, + `${path}.frequencyObservation.${key}`, + `${key} does not match the deterministic ADC fold.`, + ); + } + } + if ( + isRecord(sampling) && + Number.isFinite(sampling.sampleRate) && + !samplingMetadataEqual(observation.sampleRate, sampling.sampleRate) + ) { + addIssue( + issues, + `${path}.frequencyObservation.sampleRate`, + "band sampleRate must match signalStats.sampling.sampleRate.", + ); + } + } else if (!isRecord(band.frequencyObservation)) { + addIssue( + issues, + `${path}.frequencyObservation`, + "frequency observation metadata is required for every Result band.", + ); + } + const contextObservation = expectedObservations.get(band.id); + if (contextObservation && isRecord(band.frequencyObservation)) { + const observation = band.frequencyObservation; + for (const [key, expected] of Object.entries({ + sampleRate: contextObservation.sampleRate, + fftSize: contextObservation.fftSize, + requestedMinHz: contextObservation.requestedMinHz, + requestedMaxHz: contextObservation.requestedMaxHz, + })) { + if (!samplingMetadataEqual(observation[key], expected)) { + addIssue( + issues, + `${path}.frequencyObservation.${key}`, + `${key} does not match Scene/Experiment.`, + ); + } + } + const expectedEquivalentBandIds = expectedEquivalentGroups + .get(contextObservation.adcBinSetKey) + .filter((candidateId) => candidateId !== band.id) + .sort(); + if ( + !samplingMetadataEqual( + observation.equivalentBandIds, + expectedEquivalentBandIds, + ) + ) { + addIssue( + issues, + `${path}.frequencyObservation.equivalentBandIds`, + "equivalentBandIds does not match the Experiment band set.", + ); + } + } + const processedMaps = Array.isArray(band.processedMaps) + ? band.processedMaps.map((item) => item?.map) + : []; + const maps = [band.rawMap, band.displayMap, ...processedMaps].filter(Boolean); maps.forEach((map, mapIndex) => { if (!isRecord(map) || !Array.isArray(map.values)) return; if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) { @@ -620,6 +998,11 @@ function validateResultRelationships(value, context = {}) { } }); } + for (const expectedBandId of expectedBandIds) { + if (!bandIds.includes(expectedBandId)) { + addIssue(issues, "$.bands", `missing Experiment band result: ${expectedBandId}`); + } + } for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`); return issues; } diff --git a/schemas/README.md b/schemas/README.md index c1ff2f1..14bbfcf 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -9,11 +9,16 @@ JSON Schema Draft 2020-12 형식입니다. - `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값 - `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표 -마이크 주파수 응답점은 배열 계약 자체이므로 특정 Scene의 Nyquist와 결합하지 않고 0~1 MHz의 -안전 범위에서 엄격한 오름차순을 요구합니다. 실제 분석에서는 선택한 Scene/STFT의 bin 주파수만 -보간합니다. 중복 id, Nyquist, 시간 구간과 참조 무결성은 +마이크 주파수 응답점과 Scene tone·Experiment band는 특정 Scene의 Nyquist에서 자르지 않고 +0~1 MHz의 명시적 수치/마이크 모델 검증 범위를 사용합니다. Tone 합성에서는 원 analog 주파수, +sampled asset 분석에서는 선택한 Scene/STFT의 baseband bin 주파수로 보간합니다. 중복 id, +sampling alias mapping, +시간 구간과 참조 무결성은 `packages/contracts/validation.js`에서 추가로 검증합니다. +Array·Scene·Experiment는 1.0.0, sampling provenance와 band별 fold/localization metadata를 +필수화한 Result는 1.1.0입니다. Result 1.0을 묵시적으로 변환하지 않습니다. + Scene `acoustics`는 기본 `free-field` 또는 `shoebox`입니다. shoebox는 room bounds, 0/1차 반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다. room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에 diff --git a/schemas/experiment.schema.json b/schemas/experiment.schema.json index 78e076b..1e5c083 100644 --- a/schemas/experiment.schema.json +++ b/schemas/experiment.schema.json @@ -26,7 +26,8 @@ "type": "object", "additionalProperties": false, "required": ["id", "minHz", "maxHz", "color"], "properties": { "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, - "minHz": { "type": "number", "minimum": 0 }, "maxHz": { "type": "number", "exclusiveMinimum": 0 }, + "minHz": { "type": "number", "minimum": 0, "maximum": 1000000 }, + "maxHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 }, "color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" } } } diff --git a/schemas/result.schema.json b/schemas/result.schema.json index 800ab84..f1b3508 100644 --- a/schemas/result.schema.json +++ b/schemas/result.schema.json @@ -5,11 +5,11 @@ "type": "object", "additionalProperties": false, "required": [ - "schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "backend", + "schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "algorithm", "backend", "inputHash", "signalStats", "timingMs", "bands" ], "properties": { - "schemaVersion": { "const": "1.0.0" }, + "schemaVersion": { "const": "1.1.0" }, "kind": { "const": "micarrsim.result" }, "experimentId": { "type": "string", "minLength": 1 }, "arrayId": { "type": "string", "minLength": 1 }, @@ -20,7 +20,7 @@ "inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "signalStats": { "type": "object", "additionalProperties": false, - "required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount"], + "required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount", "sampling"], "properties": { "preNormalizationPeak": { "type": "number", "minimum": 0 }, "appliedScale": { "type": "number", "minimum": 0 }, @@ -36,11 +36,13 @@ "diffraction": { "type": "integer", "minimum": 0 }, "diffuse": { "type": "integer", "minimum": 0 } } - } + }, + "sampling": { "$ref": "#/$defs/sampling" } } }, "timingMs": { - "type": "object", "additionalProperties": false, "required": ["total", "beamforming"], + "type": "object", "additionalProperties": false, + "required": ["total", "localization", "beamforming"], "properties": { "total": { "type": "number", "minimum": 0 }, "signalGeneration": { "type": "number", "minimum": 0 }, @@ -60,6 +62,123 @@ "prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }], "items": false, "minItems": 3, "maxItems": 3 }, + "sampling": { + "type": "object", "additionalProperties": false, + "required": ["model", "sampleRate", "nyquistHz", "toneObservations"], + "properties": { + "model": { "const": "ideal-real-sampler-no-antialias-filter" }, + "sampleRate": { "type": "integer", "minimum": 8000, "maximum": 192000 }, + "nyquistHz": { "type": "number", "exclusiveMinimum": 0 }, + "toneObservations": { + "type": "array", "maxItems": 64, + "items": { + "type": "object", "additionalProperties": false, + "required": [ + "scope", "id", "analogFrequencyHz", "adcFrequencyHz", + "signedAdcFrequencyHz", "aliased", "spectralInversion", "dcEdge", + "nyquistEdge", "phaseDegenerate" + ], + "properties": { + "scope": { "enum": ["source", "ambient"] }, + "id": { "type": "string", "minLength": 1 }, + "analogFrequencyHz": { + "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 + }, + "adcFrequencyHz": { "type": "number", "minimum": 0 }, + "signedAdcFrequencyHz": { "type": "number" }, + "aliased": { "type": "boolean" }, + "spectralInversion": { "type": "boolean" }, + "dcEdge": { "type": "boolean" }, + "nyquistEdge": { "type": "boolean" }, + "phaseDegenerate": { "type": "boolean" } + } + } + } + } + }, + "frequencyObservation": { + "type": "object", "additionalProperties": false, + "required": [ + "model", "sampleRate", "nyquistHz", "fftSize", "binWidthHz", + "requestedMinHz", "requestedMaxHz", "aliased", "crossesFirstNyquist", + "localizationInputModel", "selectionModel", "localizationModel", "originalCarrierUsed", + "aliasOrderResolution", "foldBoundaryCount", "continuousManyToOne", + "gridManyToOne", "foldSegments", "adcBins", + "adcBinCenterRanges", "adcBinSetKey", "equivalentBandIds" + ], + "properties": { + "model": { "const": "ideal-real-sampler-no-antialias-filter" }, + "sampleRate": { "type": "integer", "minimum": 8000, "maximum": 192000 }, + "nyquistHz": { "type": "number", "exclusiveMinimum": 0 }, + "fftSize": { "enum": [128, 256, 512, 1024, 2048, 4096] }, + "binWidthHz": { "type": "number", "exclusiveMinimum": 0 }, + "requestedMinHz": { "type": "number", "minimum": 0, "maximum": 1000000 }, + "requestedMaxHz": { + "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 + }, + "aliased": { "type": "boolean" }, + "crossesFirstNyquist": { "type": "boolean" }, + "localizationInputModel": { "const": "observed-one-sided-real-fft-bins" }, + "selectionModel": { "const": "fft-bin-center-membership" }, + "localizationModel": { + "enum": [ + "spherical-steering-at-positive-adc-bin-frequency", + "band-limited-gcc-phat-geometric-lag" + ] + }, + "originalCarrierUsed": { "const": false }, + "aliasOrderResolution": { "enum": ["not-applicable", "not-attempted"] }, + "foldBoundaryCount": { "type": "integer", "minimum": 0, "maximum": 512 }, + "continuousManyToOne": { "type": "boolean" }, + "gridManyToOne": { "type": "boolean" }, + "foldSegments": { + "type": "array", "minItems": 1, "maxItems": 513, + "items": { "$ref": "#/$defs/foldSegment" } + }, + "adcBins": { + "type": "array", "minItems": 1, "maxItems": 2049, "uniqueItems": true, + "items": { "type": "integer", "minimum": 0, "maximum": 2048 } + }, + "adcBinCenterRanges": { + "type": "array", "minItems": 1, "maxItems": 2049, + "items": { + "type": "object", "additionalProperties": false, + "required": [ + "firstBin", "lastBin", "firstBinCenterHz", "lastBinCenterHz" + ], + "properties": { + "firstBin": { "type": "integer", "minimum": 0, "maximum": 2048 }, + "lastBin": { "type": "integer", "minimum": 0, "maximum": 2048 }, + "firstBinCenterHz": { "type": "number", "minimum": 0 }, + "lastBinCenterHz": { "type": "number", "minimum": 0 } + } + } + }, + "adcBinSetKey": { "type": "string", "minLength": 1, "maxLength": 16384 }, + "equivalentBandIds": { + "type": "array", "maxItems": 7, "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "foldSegment": { + "type": "object", "additionalProperties": false, + "required": [ + "analogMinHz", "analogMaxHz", "signedAdcStartHz", "signedAdcEndHz", + "inverted", "aliasOrder", "nyquistZone" + ], + "properties": { + "analogMinHz": { "type": "number", "minimum": 0, "maximum": 1000000 }, + "analogMaxHz": { + "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 + }, + "signedAdcStartHz": { "type": "number" }, + "signedAdcEndHz": { "type": "number" }, + "inverted": { "type": "boolean" }, + "aliasOrder": { "type": "integer", "minimum": 0 }, + "nyquistZone": { "type": "integer", "minimum": 1 } + } + }, "map": { "type": "object", "additionalProperties": false, "required": ["rows", "columns", "scale", "values"], @@ -132,7 +251,7 @@ "required": [ "version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio", "detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM", - "halfPowerRatio", "dynamicRangeDb", "maximumDetections" + "halfPowerRatio", "dynamicRangeDb", "maximumDetections", "truthSelection" ], "properties": { "version": { "const": "1.0.0" }, @@ -147,7 +266,10 @@ "sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 }, "halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "dynamicRangeDb": { "type": "number", "minimum": 0 }, - "maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 } + "maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 }, + "truthSelection": { + "const": "tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources" + } } }, "processedMap": { @@ -159,12 +281,13 @@ }, "bandResult": { "type": "object", "additionalProperties": false, - "required": ["id", "color", "rawMap", "peaks", "metrics"], + "required": ["id", "color", "rawMap", "frequencyObservation", "peaks", "metrics"], "properties": { "id": { "type": "string", "minLength": 1 }, "color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, "rawMap": { "$ref": "#/$defs/map" }, "displayMap": { "$ref": "#/$defs/map" }, + "frequencyObservation": { "$ref": "#/$defs/frequencyObservation" }, "evaluation": { "$ref": "#/$defs/evaluation" }, "processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } }, "peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } }, diff --git a/schemas/scene.schema.json b/schemas/scene.schema.json index 60b65d2..a95534f 100644 --- a/schemas/scene.schema.json +++ b/schemas/scene.schema.json @@ -130,7 +130,7 @@ "signal": { "oneOf": [ { "type": "object", "additionalProperties": false, "required": ["type", "frequency"], "properties": { - "type": { "const": "tone" }, "frequency": { "type": "number", "exclusiveMinimum": 0 }, + "type": { "const": "tone" }, "frequency": { "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 }, "phaseDeg": { "type": "number", "minimum": -360, "maximum": 360 } }}, { "type": "object", "additionalProperties": false, "required": ["type", "color"], "properties": { diff --git a/tests/integration/builder-ui.test.js b/tests/integration/builder-ui.test.js index 6788b96..ab0e412 100644 --- a/tests/integration/builder-ui.test.js +++ b/tests/integration/builder-ui.test.js @@ -133,6 +133,20 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev frame.style.width = "1400px"; await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve))); + dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 5000, "input"); + assert( + documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone") && + !documentObject.querySelector("#source-list").textContent.includes("ADC 3000 Hz alias"), + "tone card starts as baseband at 48 kHz sample rate", + ); + dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 8000, "change"); + assert( + documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone → ADC 3000 Hz alias"), + "sample-rate change immediately rerenders the tone alias card", + ); + dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 48000, "change"); + dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 1000, "input"); + const previewSamples = new Float32Array(2000); for (let index = 0; index < previewSamples.length; index += 1) { previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12; diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js index faac356..4b82395 100644 --- a/tests/integration/camera-ui.test.js +++ b/tests/integration/camera-ui.test.js @@ -171,6 +171,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 "Result JSON download", ); const resultDocument = await fetch(resultDownload.url).then((response) => response.json()); + assertEqual(resultDocument.schemaVersion, "1.1.0", "exported sampling-aware Result version"); assert( !documentObject.documentElement.dataset.unsavedWork.includes("Result"), "Result export clears Result dirty state", @@ -399,11 +400,24 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra scene.name = "Two-tone band preservation"; const secondSource = structuredClone(scene.sources[0]); secondSource.id = "source-2"; - secondSource.name = "1.2 kHz tone"; + secondSource.name = "48 kHz DC-edge tone"; secondSource.position = [-0.12, 0.09, 2]; - secondSource.signal.frequency = 1200; + secondSource.signal.frequency = 48000; secondSource.signal.phaseDeg = 45; - scene.sources.push(secondSource); + const nyquistEdgeSource = structuredClone(scene.sources[0]); + nyquistEdgeSource.id = "source-3"; + nyquistEdgeSource.name = "72 kHz Nyquist-edge tone"; + nyquistEdgeSource.position = [0.18, -0.12, 2]; + nyquistEdgeSource.signal.frequency = 72000; + nyquistEdgeSource.signal.phaseDeg = 90; + scene.sources[0].name = "47 kHz aliased tone"; + scene.sources[0].signal.frequency = 47000; + scene.sources.push(secondSource, nyquistEdgeSource); + scene.ambient.push({ + id: "ambient-1", + gainDb: -30, + signal: { type: "tone", frequency: 46800, phaseDeg: 20 }, + }); const openArrayDefinition = await loadJson( "/assets/array-geometries/rectangular-16x12.array.json", @@ -486,38 +500,62 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra ); downloads.length = 0; - const contextInvalidExperiment = structuredClone(experiment); - contextInvalidExperiment.id = "nyquist-invalid-after-rebind"; - contextInvalidExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2; + const aliasedExperiment = structuredClone(experiment); + aliasedExperiment.id = "above-nyquist-after-rebind"; + aliasedExperiment.sceneId = openScene.id; + aliasedExperiment.arrayId = openArrayDefinition.id; + aliasedExperiment.frequencyBands[0].minHz = openScene.sampleRate / 2 + 800; + aliasedExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2 + 1_000; + aliasedExperiment.frequencyBands[1].minHz = openScene.sampleRate / 2 - 1_000; + aliasedExperiment.frequencyBands[1].maxHz = openScene.sampleRate / 2 - 800; assertEqual( - (await validateExperiment(contextInvalidExperiment)).length, + (await validateExperiment(aliasedExperiment, { + scene: openScene, + arrayDefinition: openArrayDefinition, + })).length, 0, - "Nyquist fixture is valid without a Scene context", + "above-Nyquist observation band remains valid with a Scene context", ); - const contextInvalidFile = new windowObject.File( - [JSON.stringify(contextInvalidExperiment)], - "nyquist-invalid-after-rebind.experiment.json", + const aliasedFile = new windowObject.File( + [JSON.stringify(aliasedExperiment)], + "above-nyquist-after-rebind.experiment.json", { type: "application/json" }, ); dispatchFiles( windowObject, documentObject.querySelector("#experiment-input"), - [contextInvalidFile], + [aliasedFile], ); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && - documentObject.documentElement.dataset.cameraValid === "true", - "relationship-invalid Experiment rejection", + documentObject.documentElement.dataset.cameraValid === "true" && + documentObject.querySelector("#experiment-id").value === aliasedExperiment.id, + "above-Nyquist Experiment import", ); assertEqual( documentObject.querySelector("#experiment-id").value, - experiment.id, - "context relationship validation rejects the imported Experiment before assignment", + aliasedExperiment.id, + "above-Nyquist Experiment is assigned instead of rejected", ); assertEqual( JSON.stringify(readBandEditors()), - JSON.stringify(expectedBands), - "failed relationship validation leaves the current bands unchanged", + JSON.stringify(aliasedExperiment.frequencyBands), + "above-Nyquist requested bands remain explicit in the editor", + ); + const samplingWarning = documentObject.querySelector("#sampling-warning"); + assert(!samplingWarning.hidden, "above-Nyquist steering warning remains visible"); + assert( + samplingWarning.textContent.includes("ADC FFT bin") && + samplingWarning.textContent.includes("원래 analog carrier를 사용하지 않습니다") && + samplingWarning.textContent.includes("같은 ADC bin 집합") && + samplingWarning.textContent.includes("order 1/inverted"), + "warning states that localization does not use the original carrier", + ); + dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]); + await waitFor( + () => documentObject.documentElement.dataset.inputBusy === "false" && + documentObject.querySelector("#experiment-id").value === experiment.id, + "restore original multi-band Experiment", ); let replacementConfirmCount = 0; @@ -546,7 +584,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && - documentObject.querySelectorAll("#source-summary .source-line").length === 2 && + documentObject.querySelectorAll("#source-summary .source-line").length === 3 && documentObject.querySelectorAll("#band-list .band-editor").length === 2 && documentObject.documentElement.dataset.cameraValid === "true", "source-added Scene reimport", @@ -560,6 +598,26 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"), "Camera status keeps the preserved band count visible", ); + assert( + documentObject.querySelector("#source-summary").textContent.includes( + "48000 Hz tone → ADC 0 Hz alias (signed +0 Hz) · DC phase-degenerate edge", + ), + "Camera summary keeps the aliased exact-DC phase-degenerate edge visible", + ); + assert( + documentObject.querySelector("#source-summary").textContent.includes( + "72000 Hz tone → ADC 24000 Hz alias (signed +24000 Hz) · Nyquist phase-degenerate edge", + ), + "Camera summary keeps the aliased exact-Nyquist phase-degenerate edge visible", + ); + const sceneAliasWarning = documentObject.querySelector("#sampling-warning"); + assert(!sceneAliasWarning.hidden, "aliased Scene tone keeps the sampling warning visible"); + assert( + sceneAliasWarning.textContent.includes("선택 ADC bin으로 접힌 Scene tone") && + sceneAliasWarning.textContent.includes("tone-1k ← source source-1 47000→ADC 1000 Hz") && + sceneAliasWarning.textContent.includes("tone-1.2k ← ambient ambient-1 46800→ADC 1200 Hz"), + "baseband bands disclose contributing source and ambient aliases", + ); const arrayFile = new windowObject.File( [JSON.stringify(arrayDefinition)], @@ -583,6 +641,32 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra "ready status keeps the connected Experiment band count visible", ); + dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8); + dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8); + dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu"); + await waitFor( + () => documentObject.documentElement.dataset.cameraValid === "true" && + !documentObject.querySelector("#run-button").disabled, + "aliased Scene tone snapshot setup", + ); + documentObject.querySelector("#run-button").click(); + await waitFor( + () => documentObject.documentElement.dataset.runState === "done", + "aliased Scene tone snapshot", + 30000, + ); + const metricText = documentObject.querySelector("#metric-grid").textContent; + assert( + metricText.includes("tone-1k · ADC steering") && + metricText.includes("source source-1 47000→ADC 1000 Hz"), + "baseband source-alias steering metric is rendered", + ); + assert( + metricText.includes("tone-1.2k · ADC steering") && + metricText.includes("ambient ambient-1 46800→ADC 1200 Hz"), + "baseband ambient-alias steering metric is rendered", + ); + documentObject.querySelector("#export-experiment-button").click(); const experimentDownload = await waitFor( () => downloads.find((download) => download.fileName.endsWith(".experiment.json")), diff --git a/tests/unit/acoustics.test.js b/tests/unit/acoustics.test.js index 84b64f0..028c299 100644 --- a/tests/unit/acoustics.test.js +++ b/tests/unit/acoustics.test.js @@ -11,7 +11,12 @@ import { createDelayWorkspace, prepareDelaySpectrum, } from "/packages/acoustics-core/fractional-delay.js"; -import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js"; +import { + ceilSampleIndex, + ceilSamplePosition, + dbToLinear, + distance3, +} from "/packages/acoustics-core/math.js"; import { DEFAULT_LOCALIZATION_ALGORITHM, LOCALIZATION_ALGORITHM_IDS, @@ -23,7 +28,12 @@ import { renderSceneMix } from "/packages/acoustics-core/mixdown.js"; import { whiteNoiseAt } from "/packages/acoustics-core/noise.js"; import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; import { renderSignal } from "/packages/acoustics-core/signal.js"; -import { getFrameCount } from "/packages/acoustics-core/stft.js"; +import { + describeFrequencyAliasing, + foldFrequencyToNyquist, + observeFrequencyBand, +} from "/packages/acoustics-core/sampling.js"; +import { binsForBand, getFrameCount } from "/packages/acoustics-core/stft.js"; import { createQuasiStaticWindows, sampleQuasiStaticWindow, @@ -241,6 +251,217 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => { assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation"); }); +test("sample boundary ceil ignores only IEEE-754 integer roundoff", () => { + assertEqual(ceilSampleIndex(0.1 + 0.2, 1_000), 300, "summed exact boundary"); + assertEqual(ceilSamplePosition(300 + 1e-10), 301, "physical sub-sample offset is not snapped"); + assertEqual(ceilSampleIndex(0.300_001, 1_000), 301, "physical time offset is ceiled"); +}); + +test("ideal ADC sampling reports signed aliases without hiding Nyquist folding", () => { + const first = describeFrequencyAliasing(5_000, 8_000); + assertEqual(first.adcFrequencyHz, 3_000, "first folded frequency"); + assertEqual(first.signedAdcFrequencyHz, -3_000, "first folded signed frequency"); + assert(first.aliased && first.spectralInversion, "first fold metadata"); + assertEqual(foldFrequencyToNyquist(13_000, 8_000), 3_000, "multiple fold"); + assertEqual(foldFrequencyToNyquist(16_000, 8_000), 0, "sample-rate multiple folds to DC"); + const dc = describeFrequencyAliasing(8_000, 8_000); + assert(dc.dcEdge && dc.phaseDegenerate, "sample-rate multiple is a phase-degenerate DC fold"); + const edge = describeFrequencyAliasing(4_000, 8_000); + assert( + edge.nyquistEdge && edge.phaseDegenerate && !edge.aliased, + "exact Nyquist is retained as a phase-degenerate edge", + ); +}); + +test("above-Nyquist requested bands retain every physically observable ADC bin", () => { + const observation = observeFrequencyBand( + { minHz: 4_900, maxHz: 5_100 }, + 8_000, + 256, + ); + assert(observation.aliased, "band is marked aliased"); + assertEqual(observation.bins[0], 93, "folded lower ADC bin"); + assertEqual(observation.bins.at(-1), 99, "folded upper ADC bin"); + assertEqual( + JSON.stringify(binsForBand({ minHz: 4_900, maxHz: 5_100 }, 8_000, 256)), + JSON.stringify(observation.bins), + "STFT uses the explicit observation mapping", + ); + + const wide = observeFrequencyBand({ minHz: 0, maxHz: 8_000 }, 8_000, 256); + assertEqual(wide.bins.length, 129, "one sample-rate interval covers every real FFT bin"); + assert(wide.continuousManyToOne, "a full sample-rate interval folds continuously many-to-one"); + assert(wide.gridManyToOne, "multiple analog FFT-grid bins share ADC bins"); + assertEqual(wide.foldSegments.length, 2, "first and second Nyquist zones remain explicit"); + assertEqual(wide.foldSegments[0].nyquistZone, 1, "Nyquist zones are one-based"); + assertEqual(wide.foldSegments[1].nyquistZone, 2, "second Nyquist zone"); + assert(!wide.foldSegments[0].inverted, "first Nyquist zone is not inverted"); + assert(wide.foldSegments[1].inverted, "second Nyquist zone is inverted"); + for (const segment of wide.foldSegments) { + assertNear( + segment.signedAdcStartHz + segment.aliasOrder * wide.sampleRate, + segment.analogMinHz, + 1e-12, + `zone ${segment.nyquistZone} start invariant`, + ); + assertNear( + segment.signedAdcEndHz + segment.aliasOrder * wide.sampleRate, + segment.analogMaxHz, + 1e-12, + `zone ${segment.nyquistZone} end invariant`, + ); + } + + const baseband = observeFrequencyBand({ minHz: 2_900, maxHz: 3_100 }, 8_000, 256); + const equivalentAlias = observeFrequencyBand( + { minHz: 4_900, maxHz: 5_100 }, + 8_000, + 256, + ); + assertEqual( + equivalentAlias.adcBinSetKey, + baseband.adcBinSetKey, + "alias-equivalent requested bands disclose the same ADC bin set", + ); + + const narrowFold = observeFrequencyBand( + { minHz: 3_999, maxHz: 4_001 }, + 8_000, + 256, + ); + assert( + narrowFold.continuousManyToOne, + "a band straddling Nyquist is continuously many-to-one even on a fine FFT grid", + ); + assert(!narrowFold.gridManyToOne, "the single selected bin is not grid many-to-one"); + assertEqual(narrowFold.bins.join(","), "128", "only the Nyquist bin center is selected"); + + let segmentLimitError; + try { + observeFrequencyBand({ minHz: 0, maxHz: 1_000_000 }, 1, 256); + } catch (error) { + segmentLimitError = error; + } + assert( + segmentLimitError?.message.includes("explicit limit"), + "portable helper rejects excessive fold-zone enumeration before looping", + ); +}); + +test("real ADC samples and FFT expose temporal aliases without carrier recovery", () => { + const sampleRate = 8_000; + const fftSize = 256; + const phaseDeg = 23; + const scene = { sampleRate, duration: fftSize / sampleRate, seed: 1 }; + const entry = { + id: "temporal-alias", + startTime: 0, + duration: scene.duration, + gainDb: 0, + signal: { type: "tone", frequency: 5_000, phaseDeg }, + }; + const first = renderSignal(entry, scene); + const second = renderSignal( + { ...entry, signal: { ...entry.signal, frequency: 13_000 } }, + scene, + ); + const phase = (phaseDeg * Math.PI) / 180; + for (let index = 0; index < 32; index += 1) { + const expected = Math.sin( + (2 * Math.PI * -3_000 * index) / sampleRate + phase, + ); + assertNear(first[index], expected, 2e-6, `5 kHz alias sample ${index}`); + assertNear(second[index], expected, 4e-6, `13 kHz alias sample ${index}`); + } + const spectrum = fftReal(first); + let peakBin = 0; + let peakPower = -Infinity; + for (let bin = 0; bin <= fftSize / 2; bin += 1) { + const power = spectrum.real[bin] ** 2 + spectrum.imaginary[bin] ** 2; + if (power > peakPower) { + peakPower = power; + peakBin = bin; + } + } + assertEqual(peakBin, 96, "5 kHz analog tone appears at the 3 kHz ADC bin"); +}); + +test("undersampled microphone spacing preserves a physical grating-lobe ambiguity", async () => { + const sampleRate = 8_000; + const fftSize = 256; + const frequencyHz = 1_000; + const bin = 32; + const speedOfSound = 343; + const wavelength = speedOfSound / frequencyHz; + const microphoneSpacing = 0.5; + assert( + microphoneSpacing > wavelength / 2, + "fixture is spatially undersampled", + ); + const arrayDefinition = { + microphones: [ + { id: "left", position: [-microphoneSpacing / 2, 0, 0] }, + { id: "right", position: [microphoneSpacing / 2, 0, 0] }, + ], + }; + const targetX = -1; + const z = 0.5; + const pathDifference = (x) => + distance3([x, 0, z], arrayDefinition.microphones[1].position) - + distance3([x, 0, z], arrayDefinition.microphones[0].position); + const desiredDifference = pathDifference(targetX) - wavelength; + let lowerX = targetX; + let upperX = 0; + for (let iteration = 0; iteration < 80; iteration += 1) { + const middleX = (lowerX + upperX) / 2; + if (pathDifference(middleX) > desiredDifference) lowerX = middleX; + else upperX = middleX; + } + const ambiguousX = (lowerX + upperX) / 2; + assertNear( + pathDifference(targetX) - pathDifference(ambiguousX), + wavelength, + 1e-12, + "candidate path-difference offset is one wavelength", + ); + + const angularWaveNumber = + (2 * Math.PI * frequencyHz) / speedOfSound; + const spectrum = { + real: new Float32Array(2), + imaginary: new Float32Array(2), + }; + arrayDefinition.microphones.forEach((microphone, index) => { + const phase = -angularWaveNumber * distance3([targetX, 0, z], microphone.position); + spectrum.real[index] = Math.cos(phase); + spectrum.imaginary[index] = Math.sin(phase); + }); + const scan = { + xMin: targetX, + xMax: ambiguousX, + yMin: 0, + yMax: 0, + z, + columns: 2, + rows: 1, + }; + const map = await computeBinMap({ + bin, + spectrum, + frameCount: 1, + arrayDefinition, + pointCount: 2, + distances: createDistanceMatrix(arrayDefinition, scan), + sampleRate, + fftSize, + speedOfSound, + compensateFrequencyResponse: false, + algorithm: "das", + }); + assertNear(map[0], map[1], 2e-6, "true point and grating lobe have equal DAS power"); + assert(map[0] > 0.999, "both ambiguous candidates remain coherent"); +}); + test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => { const scene = { speedOfSound: 400, seed: 11 }; const paths = buildAcousticPaths({ @@ -474,6 +695,179 @@ test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () => assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio"); }); +test("above-Nyquist tone keeps analog propagation phase before ADC aliasing", () => { + const sampleRate = 8_000; + const analogFrequencyHz = 5_000; + const speedOfSound = 343; + const differentialDelay = 0.00005; + const scene = { + sampleRate, + duration: 0.01, + speedOfSound, + seed: 1, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [{ + id: "aliased-tone", + name: "aliased tone", + position: [0, 0, 0], + gainDb: 0, + startTime: 0, + duration: 0.01, + signal: { type: "tone", frequency: analogFrequencyHz, phaseDeg: 0 }, + }], + ambient: [], + }; + const arrayDefinition = { + microphones: [ + { id: "reference", position: [0, 0, 0] }, + { id: "delayed", position: [differentialDelay * speedOfSound, 0, 0] }, + ], + }; + const synthesis = synthesizeMicrophoneSignals({ + scene, + arrayDefinition, + referenceDistance: 1, + minimumDistance: 1, + }); + const sampleIndex = 20; + const sampleTime = sampleIndex / sampleRate; + assertNear( + synthesis.channels[0][sampleIndex], + Math.sin(2 * Math.PI * analogFrequencyHz * sampleTime), + 1e-6, + "reference temporal alias sample", + ); + assertNear( + synthesis.channels[1][sampleIndex], + Math.sin(2 * Math.PI * analogFrequencyHz * (sampleTime - differentialDelay)), + 1e-6, + "delayed sensor uses original analog carrier phase", + ); + const wrongAliasPhase = Math.sin( + 2 * Math.PI * 3_000 * (sampleTime - differentialDelay), + ); + assert( + Math.abs(synthesis.channels[1][sampleIndex] - wrongAliasPhase) > 0.1, + "fractional spatial delay is not rewritten using the positive alias carrier", + ); + assertEqual( + synthesis.sampling.toneObservations[0].adcFrequencyHz, + 3_000, + "synthesis exposes the recorded ADC alias", + ); +}); + +test("above-Nyquist tone applies microphone response at the analog carrier", () => { + const scene = { + sampleRate: 8_000, + duration: 0.032, + speedOfSound: 343, + seed: 1, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [{ + id: "response-tone", + name: "response tone", + position: [0, 0, 0], + gainDb: -20, + startTime: 0, + duration: 0.032, + signal: { type: "tone", frequency: 5_000, phaseDeg: 20 }, + }], + ambient: [], + }; + const baselineArray = { + microphones: [{ id: "mic", position: [0, 0, 0] }], + }; + const responseArray = { + microphones: [{ + id: "mic", + position: [0, 0, 0], + frequencyResponse: [ + { frequencyHz: 3_000, gainDb: -6.020599913, phaseDeg: -30 }, + { frequencyHz: 5_000, gainDb: 6.020599913, phaseDeg: 45 }, + ], + }], + }; + const baseline = synthesizeMicrophoneSignals({ + scene, + arrayDefinition: baselineArray, + referenceDistance: 1, + minimumDistance: 1, + }).channels[0]; + const filtered = synthesizeMicrophoneSignals({ + scene, + arrayDefinition: responseArray, + referenceDistance: 1, + minimumDistance: 1, + }).channels[0]; + assertNear( + rms(filtered, 16) / rms(baseline, 16), + 2, + 2e-5, + "5 kHz response gain is used instead of the 3 kHz ADC alias response", + ); +}); + +test("analytic sampler preserves exact aliased DC and Nyquist phase degeneracy", () => { + const renderTone = (frequency, phaseDeg) => { + const scene = { + sampleRate: 8_000, + duration: 8 / 8_000, + speedOfSound: 343, + seed: 1, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [{ + id: `edge-${frequency}-${phaseDeg}`, + name: "edge tone", + position: [0, 0, 0], + gainDb: 0, + startTime: 0, + duration: 8 / 8_000, + signal: { type: "tone", frequency, phaseDeg }, + }], + ambient: [], + }; + return synthesizeMicrophoneSignals({ + scene, + arrayDefinition: { + microphones: [{ id: "reference", position: [0, 0, 0] }], + }, + referenceDistance: 1, + minimumDistance: 1, + }); + }; + + const vanishedDc = renderTone(8_000, 0); + for (const [sampleIndex, value] of vanishedDc.channels[0].entries()) { + assertNear(value, 0, 1e-10, `aliased DC phase-zero sample ${sampleIndex}`); + } + assert( + vanishedDc.sampling.toneObservations[0].dcEdge && + vanishedDc.sampling.toneObservations[0].phaseDegenerate, + "sample-rate carrier is explicitly a phase-degenerate DC alias", + ); + + const constantDc = renderTone(8_000, 90); + for (const [sampleIndex, value] of constantDc.channels[0].entries()) { + assertNear(value, 1, 1e-6, `aliased DC quadrature sample ${sampleIndex}`); + } + + const nyquist = renderTone(12_000, 90); + for (const [sampleIndex, value] of nyquist.channels[0].entries()) { + assertNear( + value, + sampleIndex % 2 === 0 ? 1 : -1, + 1e-6, + `aliased Nyquist alternating sample ${sampleIndex}`, + ); + } + assert( + nyquist.sampling.toneObservations[0].nyquistEdge && + nyquist.sampling.toneObservations[0].phaseDegenerate, + "odd Nyquist-zone edge remains explicitly phase-degenerate", + ); +}); + test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => { const scene = { sampleRate: 8000, @@ -937,3 +1331,184 @@ test("absolute scene windows preserve tone, noise, asset, ambient, and synthesis assertNear(value, wholeSynthesis.channels[0][window.startSample + sampleIndex], 2e-5, `mic sample ${sampleIndex}`); }); }); + +test("quasi-static pre-roll preserves ended noise and asset arrivals before output crop", () => { + const sampleRate = 1_000; + const scene = { + sampleRate, + duration: 1, + speedOfSound: 100, + seed: 73, + mix: { normalization: "none", targetPeak: 0.95 }, + sources: [ + { + id: "ended-noise", + position: [20, 0, 0], + gainDb: -12, + startTime: 0.1, + duration: 0.2, + signal: { type: "noise", color: "white", seedOffset: 11 }, + }, + { + id: "ended-asset", + position: [20, 0, 0], + gainDb: -9, + startTime: 0.1, + duration: 0.2, + signal: { type: "asset", assetId: "tail-clip", channel: 0, loop: false }, + }, + ], + ambient: [], + }; + const arrayDefinition = { + microphones: [{ id: "mic", position: [0, 0, 0] }], + }; + const assetBuffers = new Map([["tail-clip", { + sampleRate, + channels: [Float32Array.from( + { length: 300 }, + (_, index) => 0.45 * Math.sin((2 * Math.PI * index) / 29) + (index % 17) / 80, + )], + }]]); + const whole = synthesizeMicrophoneSignals({ + scene, + arrayDefinition, + assetBuffers, + referenceDistance: 1, + minimumDistance: 1, + }); + const window = sampleQuasiStaticWindow(scene, 0.4, 0.5, { + arrayDefinition, + referenceDistance: 1, + minimumDistance: 1, + }); + assertEqual(window.preRollSamples, 200, "direct-path pre-roll"); + assertEqual( + window.scene.sources.map(({ id }) => id).join(","), + "ended-noise,ended-asset", + "sources that stopped before the visible window remain in the render interval", + ); + const partial = synthesizeMicrophoneSignals({ + scene: window.scene, + arrayDefinition, + assetBuffers, + signalOffsets: window.signalOffsets, + referenceDistance: 1, + minimumDistance: 1, + }); + assertEqual(partial.channels[0].length, 100, "only the requested output window is returned"); + assertEqual(partial.windowing.preRollSamples, 200, "synthesis records applied pre-roll"); + assertEqual( + partial.windowing.delayedTailPolicy, + "propagation-preroll-then-output-crop", + "tail policy metadata", + ); + assertEqual( + partial.windowing.arrivingSourceIds.join(","), + "ended-noise,ended-asset", + "arrival-overlap metadata includes both sampled sources", + ); + for (const arrival of partial.windowing.sourceArrivals) { + assert(arrival.emissionEndSample < partial.windowing.outputStartSample, `${arrival.sourceId} ended before output`); + assert(arrival.overlapsOutput, `${arrival.sourceId} has a path arrival inside output`); + assertEqual(arrival.earliestArrivalSample, 300, `${arrival.sourceId} earliest direct arrival`); + assertEqual(arrival.latestArrivalEndSample, 500, `${arrival.sourceId} direct tail end`); + } + assert(rms(partial.channels[0]) > 1e-4, "the delayed sampled tail reaches the output window"); + partial.channels[0].forEach((value, sampleIndex) => { + assertNear( + value, + whole.channels[0][window.startSample + sampleIndex], + 3e-5, + `sampled tail ${sampleIndex}`, + ); + }); +}); + +test("quasi-static pre-roll preserves reflection and diffuse tone tails", () => { + const sampleRate = 2_000; + const scene = { + sampleRate, + duration: 1, + speedOfSound: 100, + seed: 91, + mix: { normalization: "none", targetPeak: 0.95 }, + acoustics: { + model: "shoebox", + room: { min: [0, 0, 0], max: [10, 10, 10] }, + reflections: { order: 1, wallGainDb: -5, scattering: 0.3 }, + diffuseReverb: { enabled: true, rt60Seconds: 0.4, gainDb: -8, tapCount: 4 }, + occluders: [], + }, + sources: [{ + id: "ended-tone", + position: [2, 2, 2], + gainDb: -12, + startTime: 0.05, + duration: 0.1, + signal: { type: "tone", frequency: 137, phaseDeg: 31 }, + }], + ambient: [], + }; + const arrayDefinition = { + microphones: [{ id: "mic", position: [4, 2, 2] }], + }; + const synthesisOptions = { + arrayDefinition, + referenceDistance: 1, + minimumDistance: 1, + }; + const whole = synthesizeMicrophoneSignals({ scene, ...synthesisOptions }); + assert(whole.acousticPathCounts.reflection > 0, "fixture contains image-source reflections"); + assert(whole.acousticPathCounts.diffuse > 0, "fixture contains diffuse tail taps"); + + for (const [startTime, endTime, label] of [ + [0.22, 0.27, "reflection interval"], + [0.42, 0.48, "late diffuse interval"], + ]) { + const window = sampleQuasiStaticWindow(scene, startTime, endTime, synthesisOptions); + assertEqual(window.scene.sources[0].id, "ended-tone", `${label} retains ended source`); + const partial = synthesizeMicrophoneSignals({ + scene: window.scene, + signalOffsets: window.signalOffsets, + ...synthesisOptions, + }); + assertEqual( + partial.windowing.geometryModel, + "source-position-fixed-at-window-midpoint", + `${label} geometry metadata`, + ); + assertEqual(partial.windowing.dopplerMode, "not-modeled", `${label} Doppler metadata`); + assertEqual( + partial.windowing.arrivingSourceIds.join(","), + "ended-tone", + `${label} arrival truth source`, + ); + assert( + partial.windowing.sourceArrivals[0].emissionEndSample < + partial.windowing.outputStartSample, + `${label} source emission ended before output`, + ); + if (label === "late diffuse interval") { + assertEqual( + partial.windowing.sourceArrivals[0].overlappingPathKinds.join(","), + "diffuse", + "late interval truth is justified by a diffuse arrival", + ); + } else { + assert( + partial.windowing.sourceArrivals[0].overlappingPathKinds.includes("reflection"), + "reflection interval truth includes an image-source arrival", + ); + } + assert(rms(partial.channels[0]) > 1e-6, `${label} contains propagated energy`); + partial.channels[0].forEach((value, sampleIndex) => { + assertNear( + value, + whole.channels[0][window.startSample + sampleIndex], + 2e-6, + `${label} sample ${sampleIndex}`, + ); + }); + } +}); diff --git a/tests/unit/builder.test.js b/tests/unit/builder.test.js index 394e3ad..aac263f 100644 --- a/tests/unit/builder.test.js +++ b/tests/unit/builder.test.js @@ -5,6 +5,7 @@ import { enableSourceTrajectory, createSceneId, createSource, + describeSignal, insertTrajectoryKeyframe, matchAssetDescriptor, prepareSceneForEditing, @@ -49,6 +50,29 @@ test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든 assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe"); }); +test("Builder tone summary exposes the ideal ADC alias above Nyquist", () => { + assertEqual( + describeSignal({ type: "tone", frequency: 5_000 }, 8_000), + "5000 Hz tone → ADC 3000 Hz alias", + "above-Nyquist source summary", + ); + assertEqual( + describeSignal({ type: "tone", frequency: 1_000 }, 8_000), + "1000 Hz tone", + "baseband source summary", + ); + assertEqual( + describeSignal({ type: "tone", frequency: 8_000 }, 8_000), + "8000 Hz tone → ADC 0 Hz alias · DC phase-degenerate edge", + "aliased exact-DC source summary", + ); + assertEqual( + describeSignal({ type: "tone", frequency: 12_000 }, 8_000), + "12000 Hz tone → ADC 4000 Hz alias · Nyquist phase-degenerate edge", + "aliased exact-Nyquist source summary", + ); +}); + test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => { const source = { position: [0.5, 0.1, 2] }; const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 }); diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js index 58fa9e9..62cb79e 100644 --- a/tests/unit/camera.test.js +++ b/tests/unit/camera.test.js @@ -22,6 +22,7 @@ import { shiftSceneDistance, } from "/apps/acoustic-camera/model.js"; import { validateResult } from "/packages/contracts/validation.js"; +import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js"; function assertNear(actual, expected, tolerance, label) { assert( @@ -56,6 +57,56 @@ test("Camera detects moving source trajectories", () => { }), true, "moving scene"); }); +test("Camera live truth uses synthesized path arrivals instead of emission midpoint", () => { + const scene = { + sampleRate: 8_000, + duration: 1, + sources: [ + { + id: "ended-tone-tail", + startTime: 0, + duration: 0.1, + signal: { type: "tone", frequency: 1_000 }, + }, + { + id: "ended-noise-tail", + startTime: 0, + duration: 0.1, + signal: { type: "noise", color: "white" }, + }, + { + id: "wrong-band-tone", + startTime: 0, + duration: 0.1, + signal: { type: "tone", frequency: 2_000 }, + }, + ], + }; + const band = { minHz: 900, maxHz: 1_100 }; + const fallback = sourcesForObservedBand(scene, band, 256, { time: 0.5 }); + assertEqual(fallback.length, 0, "legacy emission-time fallback sees no active source"); + assertEqual( + sourcesForObservedBand(scene, band, 256, { time: 0.1 }).length, + 0, + "emission-time fallback treats the exact end boundary as half-open", + ); + + const arrived = sourcesForObservedBand(scene, band, 256, { + time: 0.5, + arrivingSourceIds: ["ended-tone-tail", "ended-noise-tail", "wrong-band-tone"], + }); + assertEqual( + arrived.map(({ id }) => id).join(","), + "ended-tone-tail,ended-noise-tail", + "path-arrival ids override time while band selection remains independent", + ); + assertEqual( + sourcesForObservedBand(scene, band, 256, { arrivingSourceIds: [] }).length, + 0, + "an explicit empty arrival set does not fall back to emission activity", + ); +}); + test("Camera analysis plan covers a scene and respects its frame budget", () => { const scene = { duration: 2, sampleRate: 1000, sources: [] }; const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 }; @@ -460,7 +511,12 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( }; const scene = { id: "test-scene", - sources: [{ id: "source-1", position: [0, 0, 2] }], + sampleRate: 8_000, + sources: [{ + id: "source-1", + position: [0, 0, 2], + signal: { type: "tone", frequency: 5_000, phaseDeg: 0 }, + }], }; const experiment = { id: "test-experiment", @@ -468,7 +524,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( sceneId: scene.id, scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 }, beamforming: { algorithm: "das-phat", backend: "auto" }, - frequencyBands: [{ id: "band-1", color: "#65f2bf" }], + stft: { fftSize: 256, hopSize: 128, window: "hann" }, + frequencyBands: [{ + id: "band-1", + minHz: 4_900, + maxHz: 5_100, + color: "#65f2bf", + }, { + id: "band-2", + minHz: 2_900, + maxHz: 3_100, + color: "#58c7ff", + }], }; const run = { algorithm: "das-phat", @@ -481,6 +548,23 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( acousticModel: "shoebox", acousticPathCount: 38, acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 }, + sampling: { + model: "ideal-real-sampler-no-antialias-filter", + sampleRate: 8_000, + nyquistHz: 4_000, + toneObservations: [{ + scope: "source", + id: "source-1", + analogFrequencyHz: 5_000, + adcFrequencyHz: 3_000, + signedAdcFrequencyHz: -3_000, + aliased: true, + spectralInversion: true, + dcEdge: false, + nyquistEdge: false, + phaseDegenerate: false, + }], + }, }, timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 }, fallback: { code: "webgpu-unavailable", message: "CPU fallback" }, @@ -491,6 +575,13 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( rawMap: Float32Array.of(0.1, 0.2, 0.3, 1), displayMap: Float32Array.of(0.1, 0.2, 0.3, 1), displayScale: "normalized", + }, { + id: "band-2", + color: "#58c7ff", + rawScale: "score", + rawMap: Float32Array.of(0.1, 0.2, 0.3, 1), + displayMap: Float32Array.of(0.1, 0.2, 0.3, 1), + displayScale: "normalized", }], }; const evaluations = new Map([["band-1", { @@ -539,6 +630,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( maximumDetections: 128, }, }]]); + evaluations.set("band-2", evaluations.get("band-1")); const result = createResultDocument({ arrayDefinition, @@ -550,6 +642,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( createdAt: "2026-08-28T00:00:00.000Z", }); + assertEqual(result.schemaVersion, "1.1.0", "sampling-aware Result contract version"); assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array"); assertEqual(result.algorithm, "das-phat", "actual algorithm is exported"); assertEqual(result.timingMs.localization, 10, "generic localization timing is exported"); @@ -570,6 +663,41 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported"); assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported"); assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported"); + assertEqual( + result.signalStats.sampling.toneObservations[0].adcFrequencyHz, + 3_000, + "ADC alias is exported", + ); + assertEqual( + result.bands[0].frequencyObservation.adcBins.join(","), + "93,94,95,96,97,98,99", + "requested analog band exports every folded ADC bin", + ); + assertEqual( + result.bands[0].frequencyObservation.equivalentBandIds.join(","), + "band-2", + "alias-equivalent band is disclosed", + ); + assertEqual( + result.bands[1].frequencyObservation.equivalentBandIds.join(","), + "band-1", + "equivalence is symmetric", + ); + assertEqual( + result.bands[0].frequencyObservation.localizationModel, + "spherical-steering-at-positive-adc-bin-frequency", + "actual DAS-family localization model is explicit", + ); + assertEqual( + result.bands[0].frequencyObservation.originalCarrierUsed, + false, + "result does not imply hidden original-carrier steering", + ); + assertEqual( + result.bands[0].evaluation.truthSelection, + "tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources", + "truth-selection policy is explicit", + ); const history = createHistorySummary({ arrayDefinition, @@ -584,6 +712,70 @@ test("Camera result document is schema-safe and linked to its snapshot", async ( const issues = await validateResult(result, { experiment, scene }); assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`); + assertEqual( + (await validateResult(result)).length, + 0, + "generated Result is self-consistent without external context", + ); + + const standaloneCorruption = structuredClone(result); + standaloneCorruption.signalStats.sampling.nyquistHz += 1; + standaloneCorruption.signalStats.sampling.toneObservations.push( + structuredClone(standaloneCorruption.signalStats.sampling.toneObservations[0]), + ); + standaloneCorruption.bands[0].frequencyObservation.adcBins.push( + standaloneCorruption.bands[0].frequencyObservation.adcBins[0], + ); + standaloneCorruption.bands[0].frequencyObservation.equivalentBandIds.push("band-2"); + standaloneCorruption.bands[0].frequencyObservation.localizationModel = + "band-limited-gcc-phat-geometric-lag"; + const standaloneIssues = await validateResult(standaloneCorruption); + assert( + standaloneIssues.some(({ path }) => path === "$.signalStats.sampling.nyquistHz"), + "standalone validator checks Nyquist self-consistency", + ); + assert( + standaloneIssues.some(({ message }) => message.includes("duplicate tone observation")), + "standalone validator rejects duplicate tone provenance", + ); + assert( + standaloneIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")), + "standalone validator recomputes unique ADC bins", + ); + assert( + standaloneIssues.some(({ path }) => + path.endsWith("frequencyObservation.equivalentBandIds")), + "standalone validator recomputes equivalent band IDs", + ); + assert( + standaloneIssues.some(({ path }) => + path.endsWith("frequencyObservation.localizationModel")), + "standalone validator links localization model to actual algorithm", + ); + + result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz += 1; + let semanticIssues = await validateResult(result, { experiment, scene }); + assert( + semanticIssues.some(({ path }) => path.endsWith("signedAdcFrequencyHz")), + "validator recomputes the temporal alias", + ); + result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz -= 1; + + result.bands[0].frequencyObservation.adcBins[0] += 1; + semanticIssues = await validateResult(result, { experiment, scene }); + assert( + semanticIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")), + "validator recomputes folded ADC bins", + ); + result.bands[0].frequencyObservation.adcBins[0] -= 1; + + const missingBandResult = structuredClone(result); + missingBandResult.bands.pop(); + semanticIssues = await validateResult(missingBandResult, { experiment, scene }); + assert( + semanticIssues.some(({ message }) => message.includes("missing Experiment band result")), + "validator rejects incomplete Result band sets", + ); const wrongExperiment = { ...experiment, arrayId: "another-array" }; const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene }); diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js index 370c425..e0eb906 100644 --- a/tests/unit/contracts.test.js +++ b/tests/unit/contracts.test.js @@ -4,6 +4,10 @@ import { validateResult, validateScene, } from "/packages/contracts/validation.js"; +import { + describeFrequencyAliasing, + observeFrequencyBand, +} from "/packages/acoustics-core/sampling.js"; async function loadJson(path) { const response = await fetch(path); @@ -11,6 +15,75 @@ async function loadJson(path) { return response.json(); } +function resultFrequencyObservation(scene, experiment, band, equivalentBandIds = []) { + const observation = observeFrequencyBand( + band, + scene.sampleRate, + experiment.stft.fftSize, + ); + return { + model: observation.model, + sampleRate: observation.sampleRate, + nyquistHz: observation.nyquistHz, + fftSize: observation.fftSize, + binWidthHz: observation.binWidthHz, + requestedMinHz: observation.requestedMinHz, + requestedMaxHz: observation.requestedMaxHz, + aliased: observation.aliased, + crossesFirstNyquist: observation.crossesFirstNyquist, + localizationInputModel: observation.localizationInputModel, + selectionModel: observation.selectionModel, + localizationModel: + experiment.beamforming?.algorithm === "gcc-phat" + ? "band-limited-gcc-phat-geometric-lag" + : "spherical-steering-at-positive-adc-bin-frequency", + originalCarrierUsed: observation.originalCarrierUsed, + aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable", + foldBoundaryCount: observation.foldBoundaryCount, + continuousManyToOne: observation.continuousManyToOne, + gridManyToOne: observation.gridManyToOne, + foldSegments: observation.foldSegments.map((segment) => ({ ...segment })), + adcBins: [...observation.bins], + adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })), + adcBinSetKey: observation.adcBinSetKey, + equivalentBandIds, + }; +} + +function resultSampling(scene) { + const toneObservations = []; + for (const [scope, entries] of [ + ["source", scene.sources ?? []], + ["ambient", scene.ambient ?? []], + ]) { + for (const entry of entries) { + if (entry.signal?.type !== "tone") continue; + const observation = describeFrequencyAliasing( + entry.signal.frequency, + scene.sampleRate, + ); + toneObservations.push({ + scope, + id: entry.id, + analogFrequencyHz: observation.analogFrequencyHz, + adcFrequencyHz: observation.adcFrequencyHz, + signedAdcFrequencyHz: observation.signedAdcFrequencyHz, + aliased: observation.aliased, + spectralInversion: observation.spectralInversion, + dcEdge: observation.dcEdge, + nyquistEdge: observation.nyquistEdge, + phaseDegenerate: observation.phaseDegenerate, + }); + } + } + return { + model: "ideal-real-sampler-no-antialias-filter", + sampleRate: scene.sampleRate, + nyquistHz: scene.sampleRate / 2, + toneObservations, + }; +} + const RECTANGULAR_ARRAY_EXAMPLES = [ { id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 }, { id: "rectangular-4x3-450mm", microphoneCount: 12, columns: 4, rows: 3, width: 0.45, height: 0.45 }, @@ -329,7 +402,7 @@ test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름 ); }); -test("Nyquist를 넘는 tone을 거부한다", async () => { +test("Nyquist를 넘는 tone을 ideal ADC alias 실험으로 허용한다", async () => { const scene = { schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m", sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 }, @@ -338,7 +411,74 @@ test("Nyquist를 넘는 tone을 거부한다", async () => { signal: { type: "tone", frequency: 5000 }, }], }; - assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency"))); + assertEqual((await validateScene(scene)).length, 0, "above-Nyquist tone is structurally valid"); + + scene.sources[0].signal.frequency = 1_000_001; + assert( + (await validateScene(scene)).some(({ path }) => + path === "$.sources[0].signal.frequency"), + "tone above the explicit numerical model range is rejected, not silently rounded", + ); +}); + +test("Experiment band는 Nyquist 위에서도 허용하되 FFT bin center가 하나는 있어야 한다", async () => { + const [scene, experiment] = await Promise.all([ + loadJson("/assets/sample-scenes/single-tone.scene.json"), + loadJson("/assets/sample-scenes/single-tone.experiment.json"), + ]); + experiment.frequencyBands[0].minHz = 24_900; + experiment.frequencyBands[0].maxHz = 25_100; + assertEqual( + (await validateExperiment(experiment, { scene })).length, + 0, + "above-Nyquist band with folded bin centers is valid", + ); + + experiment.frequencyBands[0].minHz = 1_000.1; + experiment.frequencyBands[0].maxHz = 1_000.2; + let issues = await validateExperiment(experiment, { scene }); + assert( + issues.some(({ path, message }) => + path === "$.frequencyBands[0]" && message.includes("bin center")), + "a sub-bin band fails validation before runtime", + ); + + experiment.frequencyBands[0].minHz = 999_900; + experiment.frequencyBands[0].maxHz = 1_000_001; + issues = await validateExperiment(experiment, { scene }); + assert( + issues.some(({ path }) => path === "$.frequencyBands[0].maxHz"), + "band above the explicit numerical model range is rejected", + ); + + experiment.frequencyBands = {}; + issues = await validateExperiment(experiment, { scene }); + assert( + issues.some(({ path }) => path === "$.frequencyBands"), + "malformed frequencyBands returns validation issues instead of throwing", + ); +}); + +test("analytic tone-only Scene은 존재하지 않는 delay FFT 메모리로 거부하지 않는다", async () => { + const [arrayDefinition, scene, experiment] = await Promise.all([ + loadJson("/assets/array-geometries/rectangular-16x12.array.json"), + loadJson("/assets/sample-scenes/single-tone.scene.json"), + loadJson("/assets/sample-scenes/single-tone.experiment.json"), + ]); + arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 1); + scene.sampleRate = 192_000; + scene.duration = 30; + scene.sources = Array.from({ length: 32 }, (_, index) => ({ + ...structuredClone(scene.sources[0]), + id: `tone-${index + 1}`, + name: `Tone ${index + 1}`, + duration: 30, + })); + const issues = await validateExperiment(experiment, { arrayDefinition, scene }); + assert( + !issues.some(({ message }) => message.includes("512 MiB")), + `analytic tones must not allocate sampled source/FFT buffers: ${JSON.stringify(issues)}`, + ); }); test("NaN 좌표를 거부한다", async () => { @@ -395,7 +535,7 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a loadJson("/assets/sample-scenes/single-tone.experiment.json"), ]); const result = { - schemaVersion: "1.0.0", + schemaVersion: "1.1.0", kind: "micarrsim.result", experimentId: experiment.id, arrayId: experiment.arrayId, @@ -404,11 +544,26 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a backend: "cpu", algorithm: "das", inputHash: "a".repeat(64), - signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 }, - timingMs: { total: 10, signalGeneration: 3, beamforming: 7 }, + signalStats: { + preNormalizationPeak: 0.5, + appliedScale: 1, + clippedSampleCount: 0, + sampling: resultSampling(scene), + }, + timingMs: { + total: 10, + signalGeneration: 3, + localization: 7, + beamforming: 7, + }, bands: [{ id: "tone-1k", color: "#65f2bf", + frequencyObservation: resultFrequencyObservation( + scene, + experiment, + experiment.frequencyBands[0], + ), rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) }, peaks: [{ sourceId: "source-1", @@ -437,6 +592,66 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a }; assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues"); + const mismatchedTimingResult = structuredClone(result); + mismatchedTimingResult.timingMs.localization = 8; + const mismatchedTimingIssues = await validateResult( + mismatchedTimingResult, + { experiment, scene }, + ); + assert( + mismatchedTimingIssues.some( + ({ path }) => path === "$.timingMs.localization", + ), + "localization timing and its compatibility alias must agree", + ); + + const missingLocalizationTiming = structuredClone(result); + delete missingLocalizationTiming.timingMs.localization; + const missingLocalizationIssues = await validateResult( + missingLocalizationTiming, + { experiment, scene }, + ); + assert( + missingLocalizationIssues.some( + ({ path }) => path === "$.timingMs.localization", + ), + "Result 1.1 requires generic localization timing", + ); + + const legacyResult = structuredClone(result); + legacyResult.schemaVersion = "1.0.0"; + const legacyIssues = await validateResult(legacyResult, { + experiment, + scene, + }); + assert( + legacyIssues.some(({ path }) => path === "$.schemaVersion"), + "sampling-aware Result validation must reject legacy 1.0 implicitly", + ); + + const impossibleBackendResult = structuredClone(result); + impossibleBackendResult.algorithm = "gcc-phat"; + impossibleBackendResult.backend = "webgpu"; + const impossibleBackendIssues = await validateResult( + impossibleBackendResult, + ); + assert( + impossibleBackendIssues.some(({ path }) => path === "$.backend"), + "standalone Result rejects the unsupported GCC-PHAT WebGPU combination", + ); + + const malformedResult = structuredClone(result); + delete malformedResult.bands[0].id; + malformedResult.bands[0].processedMaps = {}; + const malformedIssues = await validateResult(malformedResult, { + experiment, + scene, + }); + assert( + malformedIssues.length > 0, + "malformed Result fields return validation issues instead of throwing", + ); + experiment.beamforming.algorithm = "das-phat"; result.algorithm = "das-phat"; result.bands[0].rawMap.scale = "score"; diff --git a/tests/unit/realtime-localizer.test.js b/tests/unit/realtime-localizer.test.js index 81cbb77..4292e43 100644 --- a/tests/unit/realtime-localizer.test.js +++ b/tests/unit/realtime-localizer.test.js @@ -112,6 +112,30 @@ test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map ); }); +test("portable above-Nyquist band observes the folded ADC bin without hidden carrier steering", async () => { + const fixture = createFixture(128); + const basebandPlan = createLocalizationPlan(fixture.config); + const aliasedPlan = createLocalizationPlan({ + ...fixture.config, + bands: [{ id: "aliased", minHz: 3_572, maxHz: 3_596, color: "#58c7ff" }], + }); + const baseband = await localizeMultichannel({ + plan: basebandPlan, + channels: fixture.channels, + }); + const aliased = await localizeMultichannel({ + plan: aliasedPlan, + channels: fixture.channels, + }); + + assertEqual(aliasedPlan.bands[0].minHz, 3_572, "requested analog band is retained"); + assertFloat32Exact( + aliased.bands[0].rawMap, + baseband.bands[0].rawMap, + "same ADC bin produces the same conventional DAS map", + ); +}); + test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => { const scan = { z: 1,