fix: expose physical aliasing and delayed arrivals
This commit is contained in:
@@ -125,8 +125,10 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
|||||||
|
|
||||||
Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사한 뒤 `sceneId`와 `arrayId`를
|
Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사한 뒤 `sceneId`와 `arrayId`를
|
||||||
현재 열린 Scene·Array에 자동으로 연결합니다. 가져온 `frequencyBands`와 나머지 분석 설정은
|
현재 열린 Scene·Array에 자동으로 연결합니다. 가져온 `frequencyBands`와 나머지 분석 설정은
|
||||||
보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행하므로 Nyquist·scan 등
|
보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행합니다. Nyquist 위 tone과
|
||||||
실제 오류는 그대로 거부됩니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다.
|
band는 오류가 아니라 ideal ADC alias 실험으로 허용되며, 원 주파수와 실제 ADC FFT range가 UI와
|
||||||
|
Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제 오류는 그대로 거부됩니다. Scene을
|
||||||
|
다시 불러오면 이전 asset 연결은 해제됩니다.
|
||||||
|
|
||||||
Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에
|
Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에
|
||||||
유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와
|
유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와
|
||||||
@@ -238,20 +240,24 @@ channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부
|
|||||||
|
|
||||||
## Result JSON
|
## 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`와 생성 시각
|
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `algorithm`·`backend`와 생성 시각
|
||||||
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
|
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
|
||||||
- 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats`
|
- 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats`
|
||||||
|
- 원 tone→signed ADC alias, DC/Nyquist 퇴화와 anti-alias filter 부재를 담은 필수 sampling provenance
|
||||||
- 전체·신호 생성·위치 추정 시간인 `timingMs.localization`과 기존 reader용 `beamforming` alias
|
- 전체·신호 생성·위치 추정 시간인 `timingMs.localization`과 기존 reader용 `beamforming` alias
|
||||||
- 주파수 대역별 power 또는 score `rawMap`, 표시용 `displayMap`, 검출 `peaks`
|
- 주파수 대역별 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 폭을 계산하기
|
- 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기
|
||||||
위한 평가 파라미터와 `metrics`
|
위한 평가 파라미터와 `metrics`
|
||||||
- peak별 half-power 공분산과 RMS ellipse를 담은 `beamSpread` (`calibrated: false`)
|
- peak별 half-power 공분산과 RMS ellipse를 담은 `beamSpread` (`calibrated: false`)
|
||||||
|
|
||||||
평가에서 tone source는 tone 주파수가 포함되는 band의 ground truth로만 사용합니다. Audio asset과
|
평가에서 tone source는 요청 analog interval 또는 접힌 주파수의 nearest ADC bin을 기준으로
|
||||||
noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 ground truth로 취급합니다. 이 가정은
|
사용합니다. 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을 제한된
|
음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된
|
||||||
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
|
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
|
||||||
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC와 channel clock은 모델링하지
|
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC quantization·nonlinearity와
|
||||||
않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC,
|
channel clock jitter·drift는 모델링하지 않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC,
|
||||||
DAMAS와 CLEAN-SC는 구현하지 않습니다.
|
DAMAS와 CLEAN-SC는 구현하지 않습니다.
|
||||||
따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
|
따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
|
||||||
판단을 대신하지 않습니다.
|
판단을 대신하지 않습니다.
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에
|
|||||||
해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 어느
|
해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 어느
|
||||||
시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에
|
시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에
|
||||||
자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도
|
자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도
|
||||||
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist·scan 같은 실제 관계 오류는
|
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist 위 tone·band는 ideal ADC
|
||||||
그대로 거부됩니다.
|
alias로 실행되고, 잘못된 scan 범위 같은 실제 관계 오류는 그대로 거부됩니다.
|
||||||
|
|
||||||
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
|
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
|
||||||
Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다.
|
Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다.
|
||||||
@@ -133,8 +133,9 @@ cache를 표시하고 알고리즘별 위치추정 비용을 다음처럼 분리
|
|||||||
이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop,
|
이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop,
|
||||||
band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
||||||
|
|
||||||
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist를 넘거나 겹치는 id 등 계약 오류는
|
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist 위 band도 허용되며 legend에 실제로
|
||||||
실행 전에 표시됩니다. DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
접힌 ADC FFT range가 표시됩니다. 중복 id나 잘못된 범위 같은 계약 오류는 실행 전에 표시됩니다.
|
||||||
|
DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
||||||
항목은 Camera UI에서 제거했습니다. Result의 `processedMaps` 계약만 향후 확장을 위해 남아 있습니다.
|
항목은 Camera UI에서 제거했습니다. Result의 `processedMaps` 계약만 향후 확장을 위해 남아 있습니다.
|
||||||
|
|
||||||
**Peak 검출 임계값 (%)**은 최강 local peak의 map 값에 대한 상대 임계값이며 Experiment JSON에
|
**Peak 검출 임계값 (%)**은 최강 local peak의 map 값에 대한 상대 임계값이며 Experiment JSON에
|
||||||
@@ -142,6 +143,11 @@ band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
|||||||
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
|
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
|
||||||
main lobe 폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 하나로 합쳐질 수 있습니다.
|
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를 선택할 수 있습니다.
|
- acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다.
|
||||||
@@ -181,12 +187,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
|||||||
|
|
||||||
## Result JSON
|
## Result JSON
|
||||||
|
|
||||||
**Result JSON**은 검증을 통과한 `micarrsim.result` 문서만 저장합니다.
|
**Result JSON**은 검증을 통과한 `micarrsim.result` 1.1 문서만 저장합니다.
|
||||||
|
|
||||||
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `algorithm`과 `backend`
|
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `algorithm`과 `backend`
|
||||||
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
|
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
|
||||||
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse
|
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse
|
||||||
path 전체·종류별 수
|
path 전체·종류별 수와 필수 ideal-ADC sampling provenance
|
||||||
- `timingMs`: 전체, signal generation과 `localization`; 기존 소비자 호환용 `beamforming`은 같은
|
- `timingMs`: 전체, signal generation과 `localization`; 기존 소비자 호환용 `beamforming`은 같은
|
||||||
위치추정 시간을 담는 alias
|
위치추정 시간을 담는 alias
|
||||||
- band별 `rawMap`: DAS는 선형 `power`, PHAT 계열은 무차원 `score`; 화면용 `displayMap`과 검출
|
- band별 `rawMap`: DAS는 선형 `power`, PHAT 계열은 무차원 `score`; 화면용 `displayMap`과 검출
|
||||||
@@ -194,9 +200,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
|||||||
- 각 peak의 비보정 half-power/half-maximum 공분산과 RMS ellipse인 `beamSpread`
|
- 각 peak의 비보정 half-power/half-maximum 공분산과 RMS ellipse인 `beamSpread`
|
||||||
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
|
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
|
||||||
`mainLobeWidthM`
|
`mainLobeWidthM`
|
||||||
|
- band별 연속 fold segment, FFT bin-center 선택, DAS/GCC 실제 localization model,
|
||||||
|
원 carrier 미사용 여부와 alias-equivalent band ID
|
||||||
|
|
||||||
Tone source는 그 주파수가 band 범위 안에 있을 때만 해당 band의 ground truth입니다. Audio asset과
|
Tone source는 요청 analog interval 또는 접힌 ADC 주파수의 nearest selected bin을 기준으로
|
||||||
noise source는 스펙트럼을 사전에 판별하지 않으므로 모든 band의 ground truth로 평가합니다. Source
|
해당 band의 ground truth가 됩니다. Audio asset과 noise source는 임의 spectrum detector를 만들지 않고 모든 band의
|
||||||
|
ground truth로 평가합니다. Source
|
||||||
z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와 위치 오차를 함께 해석하세요.
|
z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와 위치 오차를 함께 해석하세요.
|
||||||
|
|
||||||
## 해석 한계
|
## 해석 한계
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
<label class="field" title="원시 map은 바꾸지 않고, 임계값 위 영역을 peak로 묶는 표준 방식을 선택합니다."><span>Peak 묶기</span><select id="detection-method"><option value="local-max">Local maxima</option><option value="connected-components">8-connected components</option><option value="watershed">Marker watershed</option></select></label>
|
<label class="field" title="원시 map은 바꾸지 않고, 임계값 위 영역을 peak로 묶는 표준 방식을 선택합니다."><span>Peak 묶기</span><select id="detection-method"><option value="local-max">Local maxima</option><option value="connected-components">8-connected components</option><option value="watershed">Marker watershed</option></select></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
|
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
|
||||||
|
<p class="sampling-warning" id="sampling-warning" hidden></p>
|
||||||
|
|
||||||
<div class="band-config">
|
<div class="band-config">
|
||||||
<div class="compact-section-heading"><h3>주파수 대역</h3><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
|
<div class="compact-section-heading"><h3>주파수 대역</h3><button class="mini-button" id="add-band-button" type="button">+ 대역</button></div>
|
||||||
|
|||||||
+233
-22
@@ -5,6 +5,11 @@ import {
|
|||||||
localizationAlgorithmLabel,
|
localizationAlgorithmLabel,
|
||||||
localizationAlgorithmSupportsBackend,
|
localizationAlgorithmSupportsBackend,
|
||||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
} 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 { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
||||||
import { createSplitPane } from "/packages/ui/split-pane.js";
|
import { createSplitPane } from "/packages/ui/split-pane.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +19,7 @@ import {
|
|||||||
validateScene,
|
validateScene,
|
||||||
} from "/packages/contracts/validation.js";
|
} from "/packages/contracts/validation.js";
|
||||||
import { createCameraAssetRuntime } from "./asset-runtime.js";
|
import { createCameraAssetRuntime } from "./asset-runtime.js";
|
||||||
|
import { sourcesForObservedBand } from "./source-selection.js";
|
||||||
import {
|
import {
|
||||||
applyAcousticProfile,
|
applyAcousticProfile,
|
||||||
detectAcousticProfile,
|
detectAcousticProfile,
|
||||||
@@ -121,6 +127,7 @@ const elements = {
|
|||||||
scanYMin: byId("scan-y-min"),
|
scanYMin: byId("scan-y-min"),
|
||||||
scanYMax: byId("scan-y-max"),
|
scanYMax: byId("scan-y-max"),
|
||||||
workloadSummary: byId("workload-summary"),
|
workloadSummary: byId("workload-summary"),
|
||||||
|
samplingWarning: byId("sampling-warning"),
|
||||||
fftSize: byId("fft-size"),
|
fftSize: byId("fft-size"),
|
||||||
hopSize: byId("hop-size"),
|
hopSize: byId("hop-size"),
|
||||||
normalization: byId("normalization"),
|
normalization: byId("normalization"),
|
||||||
@@ -466,12 +473,64 @@ function renderAcousticSummary() {
|
|||||||
document.documentElement.dataset.acousticProfile = profile;
|
document.documentElement.dataset.acousticProfile = profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeSignal(signal) {
|
function describeSignal(signal, sampleRate) {
|
||||||
if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`;
|
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`;
|
if (signal.type === "noise") return `${signal.color} noise`;
|
||||||
return `asset · ${signal.assetId}`;
|
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() {
|
function renderDocumentSummary() {
|
||||||
const { arrayDefinition, scene, experiment } = state;
|
const { arrayDefinition, scene, experiment } = state;
|
||||||
if (!arrayDefinition || !scene || !experiment) return;
|
if (!arrayDefinition || !scene || !experiment) return;
|
||||||
@@ -494,7 +553,7 @@ function renderDocumentSummary() {
|
|||||||
name.title = source.name;
|
name.title = source.name;
|
||||||
const detail = document.createElement("span");
|
const detail = document.createElement("span");
|
||||||
detail.textContent =
|
detail.textContent =
|
||||||
`${describeSignal(source.signal)} · ` +
|
`${describeSignal(source.signal, scene.sampleRate)} · ` +
|
||||||
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` +
|
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` +
|
||||||
(source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : "");
|
(source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : "");
|
||||||
detail.title = detail.textContent;
|
detail.title = detail.textContent;
|
||||||
@@ -552,7 +611,9 @@ function renderBandEditors() {
|
|||||||
if (type !== "color") input.placeholder = label;
|
if (type !== "color") input.placeholder = label;
|
||||||
if (type === "number") {
|
if (type === "number") {
|
||||||
input.min = "0";
|
input.min = "0";
|
||||||
|
input.max = String(MAX_PRECISE_ANALOG_FREQUENCY_HZ);
|
||||||
input.step = "1";
|
input.step = "1";
|
||||||
|
input.title = `${label} · 0–1 MHz verified analog range`;
|
||||||
}
|
}
|
||||||
editor.append(input);
|
editor.append(input);
|
||||||
}
|
}
|
||||||
@@ -571,7 +632,11 @@ function renderBandEditors() {
|
|||||||
|
|
||||||
function renderLegend() {
|
function renderLegend() {
|
||||||
elements.bandLegend.replaceChildren();
|
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) {
|
for (const band of state.experiment.frequencyBands) {
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.className = "legend-toggle";
|
label.className = "legend-toggle";
|
||||||
@@ -583,10 +648,97 @@ function renderLegend() {
|
|||||||
const swatch = document.createElement("span");
|
const swatch = document.createElement("span");
|
||||||
swatch.className = "legend-swatch";
|
swatch.className = "legend-swatch";
|
||||||
const text = document.createElement("span");
|
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);
|
label.append(input, swatch, text);
|
||||||
elements.bandLegend.append(label);
|
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() {
|
function renderWorkload() {
|
||||||
@@ -773,6 +925,65 @@ function renderResult() {
|
|||||||
`${(displayed.run.signalStats.acousticPathCount ?? 0).toLocaleString("ko-KR")} paths 합성`,
|
`${(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(
|
appendMetric(
|
||||||
"위치 추정 시간",
|
"위치 추정 시간",
|
||||||
`${formatNumber(displayed.run.timingMs.localization ?? displayed.run.timingMs.beamforming, 0)} ms`,
|
`${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() {
|
function createAbortError() {
|
||||||
const error = new Error("시뮬레이션이 취소되었습니다.");
|
const error = new Error("시뮬레이션이 취소되었습니다.");
|
||||||
error.name = "AbortError";
|
error.name = "AbortError";
|
||||||
@@ -1170,7 +1369,11 @@ async function runSimulation() {
|
|||||||
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
|
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
|
||||||
"power",
|
"power",
|
||||||
scan: snapshot.experiment.scan,
|
scan: snapshot.experiment.scan,
|
||||||
sources: sourcesForBand(snapshot.scene, definition),
|
sources: sourcesForObservedBand(
|
||||||
|
snapshot.scene,
|
||||||
|
definition,
|
||||||
|
snapshot.experiment.stft.fftSize,
|
||||||
|
),
|
||||||
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
||||||
detectionThresholdRatio:
|
detectionThresholdRatio:
|
||||||
snapshot.experiment.evaluation?.detectionThresholdRatio ??
|
snapshot.experiment.evaluation?.detectionThresholdRatio ??
|
||||||
@@ -1289,6 +1492,7 @@ function reconcileLivePlan(plan, timeline) {
|
|||||||
function evaluateLiveFrames(snapshot, plan, frames) {
|
function evaluateLiveFrames(snapshot, plan, frames) {
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
const frameScene = scenePlaybackState(snapshot.scene, plan, frame.sampleTime).scene;
|
const frameScene = scenePlaybackState(snapshot.scene, plan, frame.sampleTime).scene;
|
||||||
|
const arrivingSourceIds = frame.run.signalStats.windowing?.arrivingSourceIds;
|
||||||
const evaluations = new Map();
|
const evaluations = new Map();
|
||||||
for (const band of frame.run.bands) {
|
for (const band of frame.run.bands) {
|
||||||
const definition = snapshot.experiment.frequencyBands.find(
|
const definition = snapshot.experiment.frequencyBands.find(
|
||||||
@@ -1306,7 +1510,15 @@ function evaluateLiveFrames(snapshot, plan, frames) {
|
|||||||
)?.rawScale ??
|
)?.rawScale ??
|
||||||
"power",
|
"power",
|
||||||
scan: snapshot.experiment.scan,
|
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,
|
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
||||||
detectionThresholdRatio:
|
detectionThresholdRatio:
|
||||||
snapshot.experiment.evaluation?.detectionThresholdRatio ??
|
snapshot.experiment.evaluation?.detectionThresholdRatio ??
|
||||||
@@ -1809,12 +2021,11 @@ function installBandListeners() {
|
|||||||
let suffix = state.experiment.frequencyBands.length + 1;
|
let suffix = state.experiment.frequencyBands.length + 1;
|
||||||
while (used.has(`band-${suffix}`)) suffix += 1;
|
while (used.has(`band-${suffix}`)) suffix += 1;
|
||||||
const previous = state.experiment.frequencyBands.at(-1);
|
const previous = state.experiment.frequencyBands.at(-1);
|
||||||
const nyquist = state.scene.sampleRate / 2;
|
const minHz = Math.max(0, previous?.maxHz ?? 900);
|
||||||
const minHz = Math.min(Math.max(0, previous?.maxHz ?? 900), nyquist - 101);
|
|
||||||
const band = {
|
const band = {
|
||||||
id: `band-${suffix}`,
|
id: `band-${suffix}`,
|
||||||
minHz,
|
minHz,
|
||||||
maxHz: Math.min(nyquist - 1, minHz + 200),
|
maxHz: minHz + 200,
|
||||||
color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
|
color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
|
||||||
};
|
};
|
||||||
state.experiment.frequencyBands.push(band);
|
state.experiment.frequencyBands.push(band);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
||||||
|
import { observeFrequencyBand } from "/packages/acoustics-core/sampling.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||||
localizationAlgorithmDefinition,
|
localizationAlgorithmDefinition,
|
||||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||||
|
import { RESULT_SCHEMA_VERSION } from "/packages/contracts/validation.js";
|
||||||
|
|
||||||
const RESULT_METRIC_KEYS = [
|
const RESULT_METRIC_KEYS = [
|
||||||
"peakToSidelobeDb",
|
"peakToSidelobeDb",
|
||||||
@@ -254,6 +256,70 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
|
|||||||
return environment;
|
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) {
|
export function createResultDocument(options) {
|
||||||
const {
|
const {
|
||||||
arrayDefinition,
|
arrayDefinition,
|
||||||
@@ -266,8 +332,31 @@ export function createResultDocument(options) {
|
|||||||
} = options;
|
} = options;
|
||||||
const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
||||||
const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power";
|
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 {
|
return {
|
||||||
schemaVersion: "1.0.0",
|
schemaVersion: RESULT_SCHEMA_VERSION,
|
||||||
kind: "micarrsim.result",
|
kind: "micarrsim.result",
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
arrayId: arrayDefinition.id,
|
arrayId: arrayDefinition.id,
|
||||||
@@ -289,6 +378,7 @@ export function createResultDocument(options) {
|
|||||||
...(run.signalStats.acousticPathCounts
|
...(run.signalStats.acousticPathCounts
|
||||||
? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } }
|
? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(sampling ? { sampling } : {}),
|
||||||
},
|
},
|
||||||
timingMs: {
|
timingMs: {
|
||||||
total: run.timingMs.total,
|
total: run.timingMs.total,
|
||||||
@@ -317,7 +407,15 @@ export function createResultDocument(options) {
|
|||||||
peaks: evaluation.peaks.map(peakForResult),
|
peaks: evaluation.peaks.map(peakForResult),
|
||||||
metrics: finiteMetricSubset(evaluation.metrics),
|
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;
|
return bandResult;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ function signalStats(synthesis) {
|
|||||||
acousticModel: synthesis.acousticModel,
|
acousticModel: synthesis.acousticModel,
|
||||||
acousticPathCount: synthesis.acousticPathCount,
|
acousticPathCount: synthesis.acousticPathCount,
|
||||||
acousticPathCounts: synthesis.acousticPathCounts,
|
acousticPathCounts: synthesis.acousticPathCounts,
|
||||||
|
windowing: synthesis.windowing,
|
||||||
|
sampling: synthesis.sampling,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,10 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
|||||||
scene,
|
scene,
|
||||||
plannedFrame.startTime,
|
plannedFrame.startTime,
|
||||||
plannedFrame.endTime,
|
plannedFrame.endTime,
|
||||||
|
{
|
||||||
|
arrayDefinition,
|
||||||
|
minimumDistance: experiment.beamforming.minimumDistance,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
post("progress", jobId, {
|
post("progress", jobId, {
|
||||||
stage: "timeline-signal",
|
stage: "timeline-signal",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -240,6 +240,16 @@
|
|||||||
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
font: 600 .6rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
}
|
}
|
||||||
.workload-summary.is-warning { color: var(--amber); }
|
.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 {
|
.acoustic-environment-summary {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
이미지 평면에 source ground truth를 배치하고 tone, noise, 로컬 audio asset과 ambient를 조합해
|
이미지 평면에 source ground truth를 배치하고 tone, noise, 로컬 audio asset과 ambient를 조합해
|
||||||
재현 가능한 `micarrsim.scene` JSON을 만드는 브라우저 앱입니다.
|
재현 가능한 `micarrsim.scene` JSON을 만드는 브라우저 앱입니다.
|
||||||
|
|
||||||
|
Tone 주파수는 Scene Nyquist 위도 입력할 수 있습니다. source 카드는 anti-alias filter 없는 이상적
|
||||||
|
ADC에서 관측될 alias 주파수를 함께 표시합니다. 이는 고주파를 복원하거나 감쇠시키는 기능이 아니며,
|
||||||
|
실제 장치의 analog anti-alias filter는 별도 모델 대상입니다.
|
||||||
|
|
||||||
## 열기
|
## 열기
|
||||||
|
|
||||||
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Dataset Builder**를 선택하거나
|
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Dataset Builder**를 선택하거나
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
|
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
|
||||||
<div id="tone-fields"><label class="field field--wide"><span>주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" step="1" /></label></div>
|
<div id="tone-fields"><label class="field field--wide" title="Nyquist를 넘어도 허용됩니다. 0–1 MHz는 현재 수치·마이크 모델의 검증 범위이며, 이상적 anti-alias filter 없는 ADC에서 접힌 주파수는 source 카드에 표시됩니다."><span>아날로그 tone 주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" max="1000000" step="1" /></label></div>
|
||||||
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
|
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
|
||||||
<div id="asset-fields">
|
<div id="asset-fields">
|
||||||
<label class="field field--wide"><span>Audio asset</span><select id="source-asset"></select></label>
|
<label class="field field--wide"><span>Audio asset</span><select id="source-asset"></select></label>
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ function renderSourceCards() {
|
|||||||
name.title = source.name;
|
name.title = source.name;
|
||||||
heading.append(dot, name);
|
heading.append(dot, name);
|
||||||
const signal = document.createElement("small");
|
const signal = document.createElement("small");
|
||||||
signal.textContent = describeSignal(source.signal);
|
signal.textContent = describeSignal(source.signal, state.scene.sampleRate);
|
||||||
const position = document.createElement("small");
|
const position = document.createElement("small");
|
||||||
const motion = Array.isArray(source.trajectory) ? ` · 이동 ${source.trajectory.length}점` : "";
|
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}`;
|
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);
|
if (asset.kind === "audio") state.assetErrors.delete(asset.id);
|
||||||
}
|
}
|
||||||
renderAssetList();
|
renderAssetList();
|
||||||
|
renderSourceCards();
|
||||||
}, "change");
|
}, "change");
|
||||||
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
|
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
|
||||||
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
|
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { describeFrequencyAliasing } from "/packages/acoustics-core/sampling.js";
|
||||||
|
|
||||||
export const DEFAULT_VIEW_PLANE = Object.freeze({
|
export const DEFAULT_VIEW_PLANE = Object.freeze({
|
||||||
xMin: -0.6,
|
xMin: -0.6,
|
||||||
xMax: 0.6,
|
xMax: 0.6,
|
||||||
@@ -192,8 +194,20 @@ export function changeSignalType(source, type, firstAudioAssetId = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeSignal(signal) {
|
export function describeSignal(signal, sampleRate) {
|
||||||
if (signal.type === "tone") return `${signal.frequency} Hz tone`;
|
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 === "noise") return `${signal.color} noise`;
|
||||||
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
|
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
|
||||||
return "알 수 없는 신호";
|
return "알 수 없는 신호";
|
||||||
|
|||||||
@@ -82,6 +82,38 @@ Scene JSON의 사용자 설정 예는 다음과 같다.
|
|||||||
하며 occluder id는 중복될 수 없다. 사용자 설정에서도 마이크 좌표가 room 안에 놓이도록 구성해야
|
하며 occluder id는 중복될 수 없다. 사용자 설정에서도 마이크 좌표가 room 안에 놓이도록 구성해야
|
||||||
1차 반사가 생성된다.
|
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 환경 preset과 비용
|
||||||
|
|
||||||
Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음
|
Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음
|
||||||
@@ -184,6 +216,66 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가
|
|||||||
해결하지는 않는다. 단일 reference 방식은 모든 마이크 쌍을 쓰는 GCC보다 계산량이 작지만 pair
|
해결하지는 않는다. 단일 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에서
|
각 마이크에는 2–64개의 측정점을 지정할 수 있다. `frequencyHz`는 0–1,000,000 Hz에서
|
||||||
@@ -212,6 +304,11 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가
|
|||||||
합성기는 알고리즘과 무관하게 sensitivity, 정적 phase offset과 frequency-response curve를 수신
|
합성기는 알고리즘과 무관하게 sensitivity, 정적 phase offset과 frequency-response curve를 수신
|
||||||
channel에 적용한다. 위치 추정에서의 역보정은 알고리즘별로 다르다.
|
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`를
|
- DAS는 sensitivity 역 gain과 정적 phase를 항상 보정한다. `compensateFrequencyResponse`를
|
||||||
생략하거나 `true`로 두면 response의 역 gain과 phase도 보정한다. 응답 notch의 무제한 gain
|
생략하거나 `true`로 두면 response의 역 gain과 phase도 보정한다. 응답 notch의 무제한 gain
|
||||||
boost는 noise까지 키울 수 있으므로 `maxCompensationDb`로 제한하며 기본값은 24 dB, 허용 범위는
|
boost는 noise까지 키울 수 있으므로 `maxCompensationDb`로 제한하며 기본값은 24 dB, 허용 범위는
|
||||||
|
|||||||
+38
-11
@@ -127,9 +127,8 @@ sidelobe false positive도 늘 수 있다.
|
|||||||
|
|
||||||
## Result 평가 정보
|
## Result 평가 정보
|
||||||
|
|
||||||
새 Result는 최상위 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 기록하며
|
Result 1.1은 최상위 필수 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를
|
||||||
Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result를 읽기 위해 schema상 선택적으로
|
기록하며 Experiment의 선택값과 같아야 한다. `backend`는 요청값 `auto`가 아니라 실제 사용한
|
||||||
남아 있지만 현재 export에는 항상 들어간다. `backend`는 요청값 `auto`가 아니라 실제 사용한
|
|
||||||
`cpu` 또는 `webgpu`다.
|
`cpu` 또는 `webgpu`다.
|
||||||
|
|
||||||
관계 검증은 `das → power`, `das-phat/gcc-phat → score`를 강제한다. Peak에 `beamSpread`가
|
관계 검증은 `das → power`, `das-phat/gcc-phat → score`를 강제한다. Peak에 `beamSpread`가
|
||||||
@@ -137,8 +136,8 @@ Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result
|
|||||||
구조상 유효하지만 의미가 모순된 외부 Result도 import 시 거절된다.
|
구조상 유효하지만 의미가 모순된 외부 Result도 import 시 거절된다.
|
||||||
|
|
||||||
`timingMs.localization`은 선택한 위치 추정 알고리즘의 wall time이다. 현재 export는 기존 DAS 전용
|
`timingMs.localization`은 선택한 위치 추정 알고리즘의 wall time이다. 현재 export는 기존 DAS 전용
|
||||||
소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.0.0` schema는 이전
|
소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.1.0` schema는
|
||||||
문서를 위해 `beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을
|
`beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을
|
||||||
함께 저장해야 한다. `timingMs.total`은 전체 시간이고 선택적 `signalGeneration`은 합성 시간이다.
|
함께 저장해야 한다. `timingMs.total`은 전체 시간이고 선택적 `signalGeneration`은 합성 시간이다.
|
||||||
|
|
||||||
각 band의 `peaks`는 위치와 해당 `rawMap.scale`의 값, 선택적 `sourceId`, `localizationErrorM`,
|
각 band의 `peaks`는 위치와 해당 `rawMap.scale`의 값, 선택적 `sourceId`, `localizationErrorM`,
|
||||||
@@ -168,14 +167,29 @@ threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic r
|
|||||||
버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z,
|
버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z,
|
||||||
가능한 경우 GPU adapter 및 fallback 정보를 기록한다.
|
가능한 경우 GPU adapter 및 fallback 정보를 기록한다.
|
||||||
|
|
||||||
`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 선택적으로
|
`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 필수 `sampling` provenance와 선택적
|
||||||
`acousticModel`, 실제 전체 `acousticPathCount`와 `acousticPathCounts`를
|
`acousticModel`, 실제 전체 `acousticPathCount`와 `acousticPathCounts`를
|
||||||
기록한다. 종류별 key는 `direct`, `reflection`, `scattering`,
|
기록한다. 종류별 key는 `direct`, `reflection`, `scattering`,
|
||||||
`diffraction`과 `diffuse`다.
|
`diffraction`과 `diffuse`다.
|
||||||
|
|
||||||
대역별 ground truth는 tone의 주파수가 band 안에 있을 때 그 tone source를 포함한다. noise와
|
각 band의 필수 `frequencyObservation`은 연속 analog fold와 이산 FFT 계산 선택을 구분한다.
|
||||||
asset source는 현재 모든 band의 truth로 간주한다. 즉 녹음 asset의 실제 spectrum energy를
|
`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
|
## 재현성 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`에서 다음 관계를 추가 검사한다.
|
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
|
||||||
|
|
||||||
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한 뒤 `sceneId`와
|
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한 뒤 `sceneId`와
|
||||||
@@ -196,7 +213,7 @@ Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검
|
|||||||
|
|
||||||
- 중복 id와 array/scene/experiment/result 참조
|
- 중복 id와 array/scene/experiment/result 참조
|
||||||
- asset 종류와 안전한 상대 경로
|
- asset 종류와 안전한 상대 경로
|
||||||
- Nyquist 및 시간·scan 범위
|
- ideal sampler의 Nyquist alias mapping 및 시간·scan 범위
|
||||||
- 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계
|
- 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계
|
||||||
- map shape와 source peak 매칭
|
- map shape와 source peak 매칭
|
||||||
- band별 evaluation 파라미터와 허용 metric
|
- band별 evaluation 파라미터와 허용 metric
|
||||||
@@ -204,6 +221,16 @@ Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검
|
|||||||
|
|
||||||
지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
|
지원하지 않는 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를 같은 규칙으로 검사한다.
|
실측 channel을 받는 portable API는 저장 JSON 계약과 별도로 입력 Array를 같은 규칙으로 검사한다.
|
||||||
CPU 분석 계획은 기본 512 MiB, 반복 steering cache는 64 MiB, streaming ring과 분석창은 기본
|
CPU 분석 계획은 기본 512 MiB, 반복 steering cache는 64 MiB, streaming ring과 분석창은 기본
|
||||||
256 MiB hard cap을 할당 전에 적용한다. 채널 순서와 streaming 상태 계약은
|
256 MiB hard cap을 할당 전에 적용한다. 채널 순서와 streaming 상태 계약은
|
||||||
|
|||||||
+10
-3
@@ -113,9 +113,15 @@ Scene·Array·Experiment·Assets 준비 상태와 실행 가능 여부를 가까
|
|||||||
일상적으로 바꾸는 backend, grid, 대역과 peak 감도는 설정 단계에 유지한다. 음속, seed, scan
|
일상적으로 바꾸는 backend, grid, 대역과 peak 감도는 설정 단계에 유지한다. 음속, seed, scan
|
||||||
범위, STFT, 음향 환경 preset과 마이크 응답 보상은 **고급 설정**에 묶는다. 장문의 개발 요구나
|
범위, 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. 평가 방법
|
## 6. 평가 방법
|
||||||
|
|
||||||
점 음원 위치를 image plane으로 투영해 ground-truth heatmap과 위치 목록을 함께 저장한다. 최소
|
점 음원 위치를 image plane으로 투영해 ground-truth heatmap과 위치 목록을 함께 저장한다. 최소
|
||||||
@@ -217,8 +223,9 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
|
|||||||
한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다.
|
한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다.
|
||||||
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
|
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
|
||||||
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
|
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
|
||||||
- 실제 AC와 simulation gap: gain·phase·주파수 응답과 희소 실내 환경을 비교 조건으로 제공하되,
|
- 실제 AC와 simulation gap: gain·phase·주파수 응답, ideal real ADC sampling/alias와 희소 실내
|
||||||
위치 공차·self-noise·ADC·clock은 실측 adapter와 현장 검증으로 다룬다.
|
환경을 비교 조건으로 제공하되, 위치 공차·self-noise·ADC quantization/nonlinearity와
|
||||||
|
channel clock jitter/drift는 실측 adapter와 현장 검증으로 다룬다.
|
||||||
- post-processing이 기본 결과를 가림: 알고리즘별 raw power/score map과 후처리 map을 별도로 보존한다.
|
- post-processing이 기본 결과를 가림: 알고리즘별 raw power/score map과 후처리 map을 별도로 보존한다.
|
||||||
|
|
||||||
## 10. 구현 결과와 다음 백로그
|
## 10. 구현 결과와 다음 백로그
|
||||||
|
|||||||
+1
-1
@@ -269,7 +269,7 @@
|
|||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Experiment를 가져왔는데 검증 오류가 남습니다.</summary>
|
<summary>Experiment를 가져왔는데 검증 오류가 남습니다.</summary>
|
||||||
<p>참조 ID는 현재 Scene·Array에 자동으로 연결됩니다. 실행할 수 없다면 검증 카드에서 Nyquist, scan 범위, asset처럼 재연결 뒤 확인된 실제 입력 오류를 확인하세요.</p>
|
<p>참조 ID는 현재 Scene·Array에 자동으로 연결됩니다. Nyquist 위 tone·band는 ideal ADC alias로 실행되며 원/관측 주파수가 함께 표시됩니다. 실행할 수 없다면 검증 카드에서 scan 범위, asset처럼 재연결 뒤 확인된 실제 입력 오류를 확인하세요.</p>
|
||||||
</details>
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Assets가 준비되지 않습니다.</summary>
|
<summary>Assets가 준비되지 않습니다.</summary>
|
||||||
|
|||||||
@@ -167,7 +167,10 @@ const plan = createLocalizationPlan({
|
|||||||
주요 검증 조건은 다음과 같다.
|
주요 검증 조건은 다음과 같다.
|
||||||
|
|
||||||
- `fftSize`는 2 이상의 2의 거듭제곱이고 `hopSize <= fftSize`
|
- `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`는 양의 정수
|
- scan의 `columns`와 `rows`는 양의 정수
|
||||||
- `speedOfSound`는 양수
|
- `speedOfSound`는 양수
|
||||||
- `normalization`은 `peak` 또는 `none`
|
- `normalization`은 `peak` 또는 `none`
|
||||||
|
|||||||
@@ -8,6 +8,25 @@
|
|||||||
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
||||||
- scan grid 거리 cache 및 DAS·DAS-PHAT·GCC-PHAT 위치 추정
|
- 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가 다음 세 경로를
|
`localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 세 경로를
|
||||||
@@ -157,9 +176,23 @@ scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전
|
|||||||
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
|
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
|
||||||
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
|
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
|
||||||
|
|
||||||
시간창은 활성 source만 잘라 짧은 Scene으로 만들지만 `signalOffsets`에 원래 scene/source의 절대 sample
|
시간창은 요청 출력 시작보다 앞선 신호를 현재 array·음향 환경의 최대 경로 지연만큼 pre-roll합니다.
|
||||||
offset을 보존합니다. 이를 `synthesizeMicrophoneSignals`에 전달하면 각 창을 따로 합성해도 tone phase,
|
따라서 창이 시작되기 전에 발음이 끝난 source라도 direct·reflection·scattering·diffraction·diffuse
|
||||||
seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline 합성과 연속됩니다.
|
경로가 창 안에 도착하면 제거하지 않습니다. `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 의미
|
## Beam spread 의미
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
export const TAU = Math.PI * 2;
|
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) {
|
export function distance3(a, b) {
|
||||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
addFrequencyDomainDelay,
|
addFrequencyDomainDelay,
|
||||||
addFrequencyDomainPaths,
|
addFrequencyDomainPaths,
|
||||||
@@ -6,8 +13,9 @@ import {
|
|||||||
nextPowerOfTwo,
|
nextPowerOfTwo,
|
||||||
prepareDelaySpectrum,
|
prepareDelaySpectrum,
|
||||||
} from "./fractional-delay.js";
|
} from "./fractional-delay.js";
|
||||||
import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js";
|
import { buildAcousticPaths } from "./environment.js";
|
||||||
import { renderSignal } from "./signal.js";
|
import { renderSignal } from "./signal.js";
|
||||||
|
import { describeFrequencyAliasing } from "./sampling.js";
|
||||||
|
|
||||||
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||||
return distance3(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);
|
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) {
|
export function synthesizeMicrophoneSignals(options) {
|
||||||
const {
|
const {
|
||||||
scene,
|
scene,
|
||||||
@@ -27,39 +196,27 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
maximumBufferBytes = 512 * 1024 * 1024,
|
maximumBufferBytes = 512 * 1024 * 1024,
|
||||||
signalOffsets,
|
signalOffsets,
|
||||||
} = options;
|
} = options;
|
||||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
const sampleCount = ceilSampleIndex(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 sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
|
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
|
||||||
const renderedSources = scene.sources.map((source, sourceIndex) => ({
|
const cropStartSample = signalOffsets?.cropStartSample ?? 0;
|
||||||
definition: source,
|
const cropSampleCount = signalOffsets?.cropSampleCount ?? sampleCount;
|
||||||
samples: renderSignal(source, scene, assetBuffers, 0, {
|
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||||
sceneSampleOffset,
|
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
}
|
||||||
}),
|
if (
|
||||||
}));
|
!Number.isSafeInteger(cropStartSample) ||
|
||||||
|
cropStartSample < 0 ||
|
||||||
const acousticPathCounts = {};
|
!Number.isSafeInteger(cropSampleCount) ||
|
||||||
let acousticPathCount = 0;
|
cropSampleCount <= 0 ||
|
||||||
renderedSources.forEach(({ definition, samples }) => {
|
cropStartSample + cropSampleCount > sampleCount
|
||||||
const microphonePaths = arrayDefinition.microphones.map((microphone) =>
|
) {
|
||||||
|
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({
|
buildAcousticPaths({
|
||||||
scene,
|
scene,
|
||||||
sourcePosition: definition.position,
|
sourcePosition: definition.position,
|
||||||
@@ -69,7 +226,117 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
referenceDistance,
|
referenceDistance,
|
||||||
minimumDistance,
|
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;
|
let sourceMaximumDelaySamples = 0;
|
||||||
for (const paths of microphonePaths) {
|
for (const paths of microphonePaths) {
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
@@ -79,17 +346,27 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const spectrum = prepareDelaySpectrum(samples, sourceMaximumDelaySamples);
|
const spectrum = samples
|
||||||
const workspace = createDelayWorkspace(spectrum.fftSize);
|
? prepareDelaySpectrum(samples, sourceMaximumDelaySamples)
|
||||||
|
: undefined;
|
||||||
|
const workspace = spectrum
|
||||||
|
? createDelayWorkspace(spectrum.fftSize)
|
||||||
|
: undefined;
|
||||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||||
const paths = microphonePaths[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);
|
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
||||||
|
if (definition.signal.type === "tone") {
|
||||||
|
addSampledAnalogTonePaths({
|
||||||
|
output: synthesisChannels[microphoneIndex],
|
||||||
|
entry: definition,
|
||||||
|
scene,
|
||||||
|
microphone,
|
||||||
|
paths,
|
||||||
|
timeMapping,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
addFrequencyDomainPaths({
|
addFrequencyDomainPaths({
|
||||||
output: channels[microphoneIndex],
|
output: synthesisChannels[microphoneIndex],
|
||||||
spectrum,
|
spectrum,
|
||||||
paths: paths.map((path) => ({
|
paths: paths.map((path) => ({
|
||||||
delaySamples: path.delaySeconds * scene.sampleRate,
|
delaySamples: path.delaySeconds * scene.sampleRate,
|
||||||
@@ -101,17 +378,32 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
sampleRate: scene.sampleRate,
|
sampleRate: scene.sampleRate,
|
||||||
workspace,
|
workspace,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(scene.ambient)) {
|
if (Array.isArray(scene.ambient)) {
|
||||||
scene.ambient.forEach((ambient, ambientIndex) => {
|
scene.ambient.forEach((ambient, ambientIndex) => {
|
||||||
channels.forEach((channel, microphoneIndex) => {
|
synthesisChannels.forEach((channel, microphoneIndex) => {
|
||||||
const microphone = arrayDefinition.microphones[microphoneIndex];
|
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||||
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
const ambientTimeMapping = {
|
||||||
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, {
|
|
||||||
sceneSampleOffset,
|
sceneSampleOffset,
|
||||||
...(signalOffsets?.ambient?.[ambientIndex] ?? {}),
|
...(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);
|
const spectrum = prepareDelaySpectrum(ambientSamples, 0);
|
||||||
addFrequencyDomainDelay({
|
addFrequencyDomainDelay({
|
||||||
@@ -128,6 +420,10 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const channels = needsOutputCrop
|
||||||
|
? synthesisChannels.map((channel) => channel.slice(cropStartSample, cropEndSample))
|
||||||
|
: synthesisChannels;
|
||||||
|
|
||||||
let preNormalizationPeak = 0;
|
let preNormalizationPeak = 0;
|
||||||
for (const channel of channels) {
|
for (const channel of channels) {
|
||||||
for (const value of channel) {
|
for (const value of channel) {
|
||||||
@@ -159,5 +455,49 @@ export function synthesizeMicrophoneSignals(options) {
|
|||||||
acousticModel: scene.acoustics?.model ?? "free-field",
|
acousticModel: scene.acoustics?.model ?? "free-field",
|
||||||
acousticPathCount,
|
acousticPathCount,
|
||||||
acousticPathCounts,
|
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),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,20 +152,18 @@ function copyScan(scan) {
|
|||||||
return Object.freeze({ ...scan, columns, rows });
|
return Object.freeze({ ...scan, columns, rows });
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyBands(bands, sampleRate) {
|
function copyBands(bands) {
|
||||||
if (!Array.isArray(bands) || bands.length === 0) {
|
if (!Array.isArray(bands) || bands.length === 0) {
|
||||||
throw new TypeError("bands must be a non-empty array.");
|
throw new TypeError("bands must be a non-empty array.");
|
||||||
}
|
}
|
||||||
const nyquist = sampleRate / 2;
|
|
||||||
return Object.freeze(bands.map((band, index) => {
|
return Object.freeze(bands.map((band, index) => {
|
||||||
if (
|
if (
|
||||||
!Number.isFinite(band?.minHz) ||
|
!Number.isFinite(band?.minHz) ||
|
||||||
!Number.isFinite(band?.maxHz) ||
|
!Number.isFinite(band?.maxHz) ||
|
||||||
band.minHz < 0 ||
|
band.minHz < 0 ||
|
||||||
band.maxHz <= band.minHz ||
|
band.maxHz <= band.minHz
|
||||||
band.maxHz >= nyquist
|
|
||||||
) {
|
) {
|
||||||
throw new RangeError(`bands[${index}] must lie inside 0–${nyquist} Hz.`);
|
throw new RangeError(`bands[${index}] must satisfy 0 <= minHz < maxHz.`);
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...band,
|
...band,
|
||||||
@@ -285,7 +283,7 @@ export function createLocalizationPlan(config) {
|
|||||||
const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition);
|
const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition);
|
||||||
const scan = copyScan(config.scan);
|
const scan = copyScan(config.scan);
|
||||||
const peakDetection = copyPeakDetection(config.peakDetection, 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 fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize");
|
||||||
const hopSize = requirePositiveInteger(
|
const hopSize = requirePositiveInteger(
|
||||||
config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2,
|
config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { dbToLinear, TAU } from "./math.js";
|
import { ceilSampleIndex, dbToLinear, TAU } from "./math.js";
|
||||||
import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js";
|
import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js";
|
||||||
|
|
||||||
function sampleAsset(signal, assetBuffers, time) {
|
function sampleAsset(signal, assetBuffers, time) {
|
||||||
@@ -25,16 +25,20 @@ function sampleAsset(signal, assetBuffers, time) {
|
|||||||
* Omitting it keeps the historical whole-scene behaviour exactly unchanged.
|
* Omitting it keeps the historical whole-scene behaviour exactly unchanged.
|
||||||
*/
|
*/
|
||||||
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0, timeMapping = {}) {
|
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 output = new Float32Array(sampleCount);
|
||||||
const startTime = entry.startTime ?? 0;
|
const startTime = entry.startTime ?? 0;
|
||||||
const duration = entry.duration ?? scene.duration;
|
const duration = entry.duration ?? scene.duration;
|
||||||
const gain = dbToLinear(entry.gainDb ?? 0);
|
const gain = dbToLinear(entry.gainDb ?? 0);
|
||||||
const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate));
|
const startSample = Math.max(0, ceilSampleIndex(startTime, scene.sampleRate));
|
||||||
const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate));
|
const endSample = Math.min(
|
||||||
|
sampleCount,
|
||||||
|
ceilSampleIndex(startTime + duration, scene.sampleRate),
|
||||||
|
);
|
||||||
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
||||||
const signalStartTime = timeMapping.signalStartTime ?? startTime;
|
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) {
|
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||||
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fftInPlace } from "./fft.js";
|
import { fftInPlace } from "./fft.js";
|
||||||
import { cooperativeYield } from "./cooperative-yield.js";
|
import { cooperativeYield } from "./cooperative-yield.js";
|
||||||
|
import { observeFrequencyBand } from "./sampling.js";
|
||||||
|
|
||||||
function throwIfAborted(signal) {
|
function throwIfAborted(signal) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
@@ -119,11 +120,5 @@ export async function extractStftBins(channels, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function binsForBand(band, sampleRate, fftSize) {
|
export function binsForBand(band, sampleRate, fftSize) {
|
||||||
const binWidth = sampleRate / fftSize;
|
return observeFrequencyBand(band, sampleRate, fftSize).bins;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { maximumAcousticDelaySeconds } from "./environment.js";
|
||||||
|
import { ceilSampleIndex } from "./math.js";
|
||||||
|
|
||||||
function copyPosition(position) {
|
function copyPosition(position) {
|
||||||
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
|
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
|
||||||
throw new TypeError("source position must be a finite [x, y, z] array.");
|
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
|
* Approximate a moving scene as stationary during [startTime, endTime] by
|
||||||
* sampling every trajectory at the window midpoint.
|
* sampling every trajectory at the window midpoint.
|
||||||
*/
|
*/
|
||||||
export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {}) {
|
||||||
requireFiniteTime(startTime, "startTime");
|
requireFiniteTime(startTime, "startTime");
|
||||||
requireFiniteTime(endTime, "endTime");
|
requireFiniteTime(endTime, "endTime");
|
||||||
if (startTime < 0 || endTime <= startTime) {
|
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
|
// boundary. Adjacent windows therefore share an exact boundary without a
|
||||||
// duplicated or omitted sample, even when their requested times are not
|
// duplicated or omitted sample, even when their requested times are not
|
||||||
// integer multiples of the sample period.
|
// integer multiples of the sample period.
|
||||||
const startSample = Math.ceil(startTime * sampleRate);
|
const startSample = ceilSampleIndex(startTime, sampleRate);
|
||||||
const endSample = Math.ceil(endTime * sampleRate);
|
const endSample = ceilSampleIndex(endTime, sampleRate);
|
||||||
if (endSample <= startSample) {
|
if (endSample <= startSample) {
|
||||||
throw new RangeError("window must contain at least one sample.");
|
throw new RangeError("window must contain at least one sample.");
|
||||||
}
|
}
|
||||||
const alignedStartTime = startSample / sampleRate;
|
const alignedStartTime = startSample / sampleRate;
|
||||||
const alignedEndTime = endSample / sampleRate;
|
const alignedEndTime = endSample / sampleRate;
|
||||||
const sampleTime = (startSample + endSample) / (2 * 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 sources = [];
|
||||||
const sourceOffsets = [];
|
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 signalStartTime = source.startTime ?? 0;
|
||||||
const signalDuration = source.duration ?? scene.duration;
|
const signalDuration = source.duration ?? scene.duration;
|
||||||
const signalStartSample = Math.ceil(signalStartTime * sampleRate);
|
const signalStartSample = ceilSampleIndex(signalStartTime, sampleRate);
|
||||||
const signalEndSample = Math.ceil((signalStartTime + signalDuration) * sampleRate);
|
const signalEndSample = ceilSampleIndex(signalStartTime + signalDuration, sampleRate);
|
||||||
const activeStartSample = Math.max(startSample, signalStartSample);
|
const activeStartSample = Math.max(renderStartSample, signalStartSample);
|
||||||
const activeEndSample = Math.min(endSample, signalEndSample);
|
const activeEndSample = Math.min(endSample, signalEndSample);
|
||||||
if (activeEndSample <= activeStartSample) continue;
|
if (activeEndSample <= activeStartSample) continue;
|
||||||
|
|
||||||
sources.push({
|
sources.push({
|
||||||
...source,
|
...source,
|
||||||
position: sourcePositionAtTime(source, sampleTime),
|
position: sampledScene.sources[sourceIndex].position,
|
||||||
startTime: (activeStartSample - startSample) / sampleRate,
|
startTime: (activeStartSample - renderStartSample) / sampleRate,
|
||||||
duration: (activeEndSample - activeStartSample) / 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 ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
|
||||||
const signalOffsets = {
|
const signalOffsets = {
|
||||||
sceneSampleOffset: startSample,
|
sceneSampleOffset: renderStartSample,
|
||||||
|
cropStartSample: startSample - renderStartSample,
|
||||||
|
cropSampleCount: endSample - startSample,
|
||||||
|
maximumPropagationDelaySeconds,
|
||||||
|
sourcePositionSampleTime: sampleTime,
|
||||||
|
windowingModel: "quasi-static-midpoint-propagation-preroll",
|
||||||
sources: sourceOffsets,
|
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 duration = (endSample - startSample) / sampleRate;
|
||||||
|
const renderDuration = (endSample - renderStartSample) / sampleRate;
|
||||||
return {
|
return {
|
||||||
startTime: alignedStartTime,
|
startTime: alignedStartTime,
|
||||||
endTime: alignedEndTime,
|
endTime: alignedEndTime,
|
||||||
@@ -134,10 +172,14 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
|||||||
endSample,
|
endSample,
|
||||||
sampleCount: endSample - startSample,
|
sampleCount: endSample - startSample,
|
||||||
duration,
|
duration,
|
||||||
|
renderStartSample,
|
||||||
|
renderDuration,
|
||||||
|
preRollSamples: startSample - renderStartSample,
|
||||||
|
maximumPropagationDelaySeconds,
|
||||||
signalOffsets,
|
signalOffsets,
|
||||||
scene: {
|
scene: {
|
||||||
...scene,
|
...scene,
|
||||||
duration,
|
duration: renderDuration,
|
||||||
sources,
|
sources,
|
||||||
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
||||||
},
|
},
|
||||||
@@ -170,7 +212,7 @@ export function createQuasiStaticWindows(scene, options = {}) {
|
|||||||
const windows = [];
|
const windows = [];
|
||||||
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
|
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
|
||||||
const windowEnd = Math.min(windowStart + windowDuration, endTime);
|
const windowEnd = Math.min(windowStart + windowDuration, endTime);
|
||||||
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd));
|
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd, options));
|
||||||
}
|
}
|
||||||
return windows;
|
return windows;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
# Contracts
|
# Contracts
|
||||||
|
|
||||||
네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제
|
네이티브 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`는 배열에서 장면 쪽입니다.
|
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
|
||||||
|
|
||||||
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
|
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { validateDocumentStructure } from "./schema-validator.js";
|
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_MICROPHONES = 1024;
|
||||||
const MAX_SOURCES = 32;
|
const MAX_SOURCES = 32;
|
||||||
const MAX_GRID_SIDE = 512;
|
const MAX_GRID_SIDE = 512;
|
||||||
@@ -18,6 +24,30 @@ function isFiniteNumber(value) {
|
|||||||
return typeof value === "number" && Number.isFinite(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) {
|
function addIssue(issues, path, message) {
|
||||||
issues.push({ path, message });
|
issues.push({ path, message });
|
||||||
}
|
}
|
||||||
@@ -92,8 +122,8 @@ function boundsContain(bounds, position) {
|
|||||||
|
|
||||||
function validateHeader(value, expectedKind, issues) {
|
function validateHeader(value, expectedKind, issues) {
|
||||||
if (!requireRecord(value, "$", issues)) return false;
|
if (!requireRecord(value, "$", issues)) return false;
|
||||||
if (value.schemaVersion !== SCHEMA_VERSION) {
|
if (value.schemaVersion !== INPUT_SCHEMA_VERSION) {
|
||||||
addIssue(issues, "$.schemaVersion", `지원 버전은 ${SCHEMA_VERSION}입니다.`);
|
addIssue(issues, "$.schemaVersion", `지원 버전은 ${INPUT_SCHEMA_VERSION}입니다.`);
|
||||||
}
|
}
|
||||||
if (value.kind !== expectedKind) addIssue(issues, "$.kind", `${expectedKind}이어야 합니다.`);
|
if (value.kind !== expectedKind) addIssue(issues, "$.kind", `${expectedKind}이어야 합니다.`);
|
||||||
requireString(value.id, "$.id", issues);
|
requireString(value.id, "$.id", issues);
|
||||||
@@ -194,14 +224,14 @@ function validateArrayRelationships(value) {
|
|||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSignal(signal, path, issues, assetsById, sampleRate) {
|
function validateSignal(signal, path, issues, assetsById) {
|
||||||
if (!requireRecord(signal, path, issues)) return;
|
if (!requireRecord(signal, path, issues)) return;
|
||||||
if (signal.type === "tone") {
|
if (signal.type === "tone") {
|
||||||
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
|
requireFinite(signal.frequency, `${path}.frequency`, issues, {
|
||||||
if (signal.frequency >= sampleRate / 2) {
|
min: 0,
|
||||||
addIssue(issues, `${path}.frequency`, `Nyquist 주파수 ${sampleRate / 2} Hz보다 낮아야 합니다.`);
|
max: MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||||||
}
|
exclusiveMin: true,
|
||||||
}
|
});
|
||||||
if (signal.phaseDeg !== undefined) {
|
if (signal.phaseDeg !== undefined) {
|
||||||
requireFinite(signal.phaseDeg, `${path}.phaseDeg`, issues, { min: -360, max: 360 });
|
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) {
|
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
|
||||||
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
|
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}`);
|
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 (!requireRecord(ambient, path, issues)) return;
|
||||||
if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id);
|
if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id);
|
||||||
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
|
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}`);
|
for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`);
|
||||||
}
|
}
|
||||||
@@ -412,14 +442,18 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
const path = `$.frequencyBands[${index}]`;
|
const path = `$.frequencyBands[${index}]`;
|
||||||
if (!requireRecord(band, path, issues)) return;
|
if (!requireRecord(band, path, issues)) return;
|
||||||
if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id);
|
if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id);
|
||||||
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 });
|
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, {
|
||||||
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
|
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) {
|
if (minValid && maxValid && band.minHz >= band.maxHz) {
|
||||||
addIssue(issues, path, "minHz는 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)) {
|
if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) {
|
||||||
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
|
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
|
||||||
}
|
}
|
||||||
@@ -436,6 +470,24 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
}
|
}
|
||||||
if (value.stft.window !== "hann") addIssue(issues, "$.stft.window", "hann이어야 합니다.");
|
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 (requireRecord(value.beamforming, "$.beamforming", issues)) {
|
||||||
if (!LOCALIZATION_ALGORITHMS.includes(value.beamforming.algorithm)) {
|
if (!LOCALIZATION_ALGORITHMS.includes(value.beamforming.algorithm)) {
|
||||||
addIssue(
|
addIssue(
|
||||||
@@ -502,7 +554,15 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
|
"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);
|
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
|
||||||
let maximumDelaySamples = 0;
|
let maximumDelaySamples = 0;
|
||||||
for (const source of context.scene.sources) {
|
for (const source of context.scene.sources) {
|
||||||
@@ -524,8 +584,15 @@ function validateExperimentRelationships(value, context = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let delayFftSize = 1;
|
let delayFftSize = 1;
|
||||||
while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) delayFftSize *= 2;
|
const hasSampledSignal = sampledSourceCount > 0 || hasSampledAmbient;
|
||||||
const estimatedBytes = retainedBufferCount * sampleCount * 4 + delayFftSize * 8 * 4;
|
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;
|
const maximumBytes = 512 * 1024 * 1024;
|
||||||
if (estimatedBytes > maximumBytes) {
|
if (estimatedBytes > maximumBytes) {
|
||||||
addIssue(
|
addIssue(
|
||||||
@@ -556,6 +623,13 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
||||||
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
||||||
const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm;
|
const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm;
|
||||||
|
if (resultAlgorithm === "gcc-phat" && value.backend === "webgpu") {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
"$.backend",
|
||||||
|
"gcc-phat Result must use the CPU backend.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const expectedRawScale =
|
const expectedRawScale =
|
||||||
resultAlgorithm === "das"
|
resultAlgorithm === "das"
|
||||||
? "power"
|
? "power"
|
||||||
@@ -568,6 +642,201 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
: expectedRawScale === "score"
|
: expectedRawScale === "score"
|
||||||
? "connected-half-maximum-score-weighted-covariance"
|
? "connected-half-maximum-score-weighted-covariance"
|
||||||
: undefined;
|
: 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 = [];
|
const bandIds = [];
|
||||||
if (Array.isArray(value.bands)) {
|
if (Array.isArray(value.bands)) {
|
||||||
value.bands.forEach((band, bandIndex) => {
|
value.bands.forEach((band, bandIndex) => {
|
||||||
@@ -577,7 +846,116 @@ function validateResultRelationships(value, context = {}) {
|
|||||||
bandIds.push(band.id);
|
bandIds.push(band.id);
|
||||||
if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 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) => {
|
maps.forEach((map, mapIndex) => {
|
||||||
if (!isRecord(map) || !Array.isArray(map.values)) return;
|
if (!isRecord(map) || !Array.isArray(map.values)) return;
|
||||||
if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) {
|
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}`);
|
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`);
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -9,11 +9,16 @@ JSON Schema Draft 2020-12 형식입니다.
|
|||||||
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
|
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
|
||||||
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
|
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
|
||||||
|
|
||||||
마이크 주파수 응답점은 배열 계약 자체이므로 특정 Scene의 Nyquist와 결합하지 않고 0~1 MHz의
|
마이크 주파수 응답점과 Scene tone·Experiment band는 특정 Scene의 Nyquist에서 자르지 않고
|
||||||
안전 범위에서 엄격한 오름차순을 요구합니다. 실제 분석에서는 선택한 Scene/STFT의 bin 주파수만
|
0~1 MHz의 명시적 수치/마이크 모델 검증 범위를 사용합니다. Tone 합성에서는 원 analog 주파수,
|
||||||
보간합니다. 중복 id, Nyquist, 시간 구간과 참조 무결성은
|
sampled asset 분석에서는 선택한 Scene/STFT의 baseband bin 주파수로 보간합니다. 중복 id,
|
||||||
|
sampling alias mapping,
|
||||||
|
시간 구간과 참조 무결성은
|
||||||
`packages/contracts/validation.js`에서 추가로 검증합니다.
|
`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차
|
Scene `acoustics`는 기본 `free-field` 또는 `shoebox`입니다. shoebox는 room bounds, 0/1차
|
||||||
반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다.
|
반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다.
|
||||||
room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에
|
room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"type": "object", "additionalProperties": false, "required": ["id", "minHz", "maxHz", "color"],
|
"type": "object", "additionalProperties": false, "required": ["id", "minHz", "maxHz", "color"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
|
"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}$" }
|
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-8
@@ -5,11 +5,11 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": [
|
"required": [
|
||||||
"schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "backend",
|
"schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "algorithm", "backend",
|
||||||
"inputHash", "signalStats", "timingMs", "bands"
|
"inputHash", "signalStats", "timingMs", "bands"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"schemaVersion": { "const": "1.0.0" },
|
"schemaVersion": { "const": "1.1.0" },
|
||||||
"kind": { "const": "micarrsim.result" },
|
"kind": { "const": "micarrsim.result" },
|
||||||
"experimentId": { "type": "string", "minLength": 1 },
|
"experimentId": { "type": "string", "minLength": 1 },
|
||||||
"arrayId": { "type": "string", "minLength": 1 },
|
"arrayId": { "type": "string", "minLength": 1 },
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||||
"signalStats": {
|
"signalStats": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount"],
|
"required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount", "sampling"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"preNormalizationPeak": { "type": "number", "minimum": 0 },
|
"preNormalizationPeak": { "type": "number", "minimum": 0 },
|
||||||
"appliedScale": { "type": "number", "minimum": 0 },
|
"appliedScale": { "type": "number", "minimum": 0 },
|
||||||
@@ -36,11 +36,13 @@
|
|||||||
"diffraction": { "type": "integer", "minimum": 0 },
|
"diffraction": { "type": "integer", "minimum": 0 },
|
||||||
"diffuse": { "type": "integer", "minimum": 0 }
|
"diffuse": { "type": "integer", "minimum": 0 }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"sampling": { "$ref": "#/$defs/sampling" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timingMs": {
|
"timingMs": {
|
||||||
"type": "object", "additionalProperties": false, "required": ["total", "beamforming"],
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["total", "localization", "beamforming"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"total": { "type": "number", "minimum": 0 },
|
"total": { "type": "number", "minimum": 0 },
|
||||||
"signalGeneration": { "type": "number", "minimum": 0 },
|
"signalGeneration": { "type": "number", "minimum": 0 },
|
||||||
@@ -60,6 +62,123 @@
|
|||||||
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
|
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
|
||||||
"items": false, "minItems": 3, "maxItems": 3
|
"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": {
|
"map": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["rows", "columns", "scale", "values"],
|
"required": ["rows", "columns", "scale", "values"],
|
||||||
@@ -132,7 +251,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio",
|
"version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio",
|
||||||
"detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM",
|
"detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM",
|
||||||
"halfPowerRatio", "dynamicRangeDb", "maximumDetections"
|
"halfPowerRatio", "dynamicRangeDb", "maximumDetections", "truthSelection"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"version": { "const": "1.0.0" },
|
"version": { "const": "1.0.0" },
|
||||||
@@ -147,7 +266,10 @@
|
|||||||
"sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
|
"sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
|
||||||
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
||||||
"dynamicRangeDb": { "type": "number", "minimum": 0 },
|
"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": {
|
"processedMap": {
|
||||||
@@ -159,12 +281,13 @@
|
|||||||
},
|
},
|
||||||
"bandResult": {
|
"bandResult": {
|
||||||
"type": "object", "additionalProperties": false,
|
"type": "object", "additionalProperties": false,
|
||||||
"required": ["id", "color", "rawMap", "peaks", "metrics"],
|
"required": ["id", "color", "rawMap", "frequencyObservation", "peaks", "metrics"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "minLength": 1 },
|
"id": { "type": "string", "minLength": 1 },
|
||||||
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
|
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
|
||||||
"rawMap": { "$ref": "#/$defs/map" },
|
"rawMap": { "$ref": "#/$defs/map" },
|
||||||
"displayMap": { "$ref": "#/$defs/map" },
|
"displayMap": { "$ref": "#/$defs/map" },
|
||||||
|
"frequencyObservation": { "$ref": "#/$defs/frequencyObservation" },
|
||||||
"evaluation": { "$ref": "#/$defs/evaluation" },
|
"evaluation": { "$ref": "#/$defs/evaluation" },
|
||||||
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
|
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
|
||||||
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
|
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
"signal": {
|
"signal": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{ "type": "object", "additionalProperties": false, "required": ["type", "frequency"], "properties": {
|
{ "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 }
|
"phaseDeg": { "type": "number", "minimum": -360, "maximum": 360 }
|
||||||
}},
|
}},
|
||||||
{ "type": "object", "additionalProperties": false, "required": ["type", "color"], "properties": {
|
{ "type": "object", "additionalProperties": false, "required": ["type", "color"], "properties": {
|
||||||
|
|||||||
@@ -133,6 +133,20 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
|
|||||||
frame.style.width = "1400px";
|
frame.style.width = "1400px";
|
||||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
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);
|
const previewSamples = new Float32Array(2000);
|
||||||
for (let index = 0; index < previewSamples.length; index += 1) {
|
for (let index = 0; index < previewSamples.length; index += 1) {
|
||||||
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
|
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
|||||||
"Result JSON download",
|
"Result JSON download",
|
||||||
);
|
);
|
||||||
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
|
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
|
||||||
|
assertEqual(resultDocument.schemaVersion, "1.1.0", "exported sampling-aware Result version");
|
||||||
assert(
|
assert(
|
||||||
!documentObject.documentElement.dataset.unsavedWork.includes("Result"),
|
!documentObject.documentElement.dataset.unsavedWork.includes("Result"),
|
||||||
"Result export clears Result dirty state",
|
"Result export clears Result dirty state",
|
||||||
@@ -399,11 +400,24 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
scene.name = "Two-tone band preservation";
|
scene.name = "Two-tone band preservation";
|
||||||
const secondSource = structuredClone(scene.sources[0]);
|
const secondSource = structuredClone(scene.sources[0]);
|
||||||
secondSource.id = "source-2";
|
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.position = [-0.12, 0.09, 2];
|
||||||
secondSource.signal.frequency = 1200;
|
secondSource.signal.frequency = 48000;
|
||||||
secondSource.signal.phaseDeg = 45;
|
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(
|
const openArrayDefinition = await loadJson(
|
||||||
"/assets/array-geometries/rectangular-16x12.array.json",
|
"/assets/array-geometries/rectangular-16x12.array.json",
|
||||||
@@ -486,38 +500,62 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
);
|
);
|
||||||
downloads.length = 0;
|
downloads.length = 0;
|
||||||
|
|
||||||
const contextInvalidExperiment = structuredClone(experiment);
|
const aliasedExperiment = structuredClone(experiment);
|
||||||
contextInvalidExperiment.id = "nyquist-invalid-after-rebind";
|
aliasedExperiment.id = "above-nyquist-after-rebind";
|
||||||
contextInvalidExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2;
|
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(
|
assertEqual(
|
||||||
(await validateExperiment(contextInvalidExperiment)).length,
|
(await validateExperiment(aliasedExperiment, {
|
||||||
|
scene: openScene,
|
||||||
|
arrayDefinition: openArrayDefinition,
|
||||||
|
})).length,
|
||||||
0,
|
0,
|
||||||
"Nyquist fixture is valid without a Scene context",
|
"above-Nyquist observation band remains valid with a Scene context",
|
||||||
);
|
);
|
||||||
const contextInvalidFile = new windowObject.File(
|
const aliasedFile = new windowObject.File(
|
||||||
[JSON.stringify(contextInvalidExperiment)],
|
[JSON.stringify(aliasedExperiment)],
|
||||||
"nyquist-invalid-after-rebind.experiment.json",
|
"above-nyquist-after-rebind.experiment.json",
|
||||||
{ type: "application/json" },
|
{ type: "application/json" },
|
||||||
);
|
);
|
||||||
dispatchFiles(
|
dispatchFiles(
|
||||||
windowObject,
|
windowObject,
|
||||||
documentObject.querySelector("#experiment-input"),
|
documentObject.querySelector("#experiment-input"),
|
||||||
[contextInvalidFile],
|
[aliasedFile],
|
||||||
);
|
);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||||
documentObject.documentElement.dataset.cameraValid === "true",
|
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||||
"relationship-invalid Experiment rejection",
|
documentObject.querySelector("#experiment-id").value === aliasedExperiment.id,
|
||||||
|
"above-Nyquist Experiment import",
|
||||||
);
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
documentObject.querySelector("#experiment-id").value,
|
documentObject.querySelector("#experiment-id").value,
|
||||||
experiment.id,
|
aliasedExperiment.id,
|
||||||
"context relationship validation rejects the imported Experiment before assignment",
|
"above-Nyquist Experiment is assigned instead of rejected",
|
||||||
);
|
);
|
||||||
assertEqual(
|
assertEqual(
|
||||||
JSON.stringify(readBandEditors()),
|
JSON.stringify(readBandEditors()),
|
||||||
JSON.stringify(expectedBands),
|
JSON.stringify(aliasedExperiment.frequencyBands),
|
||||||
"failed relationship validation leaves the current bands unchanged",
|
"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;
|
let replacementConfirmCount = 0;
|
||||||
@@ -546,7 +584,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]);
|
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
() => 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.querySelectorAll("#band-list .band-editor").length === 2 &&
|
||||||
documentObject.documentElement.dataset.cameraValid === "true",
|
documentObject.documentElement.dataset.cameraValid === "true",
|
||||||
"source-added Scene reimport",
|
"source-added Scene reimport",
|
||||||
@@ -560,6 +598,26 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"),
|
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"),
|
||||||
"Camera status keeps the preserved band count visible",
|
"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(
|
const arrayFile = new windowObject.File(
|
||||||
[JSON.stringify(arrayDefinition)],
|
[JSON.stringify(arrayDefinition)],
|
||||||
@@ -583,6 +641,32 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
|||||||
"ready status keeps the connected Experiment band count visible",
|
"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();
|
documentObject.querySelector("#export-experiment-button").click();
|
||||||
const experimentDownload = await waitFor(
|
const experimentDownload = await waitFor(
|
||||||
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
|
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
createDelayWorkspace,
|
createDelayWorkspace,
|
||||||
prepareDelaySpectrum,
|
prepareDelaySpectrum,
|
||||||
} from "/packages/acoustics-core/fractional-delay.js";
|
} 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 {
|
import {
|
||||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||||
LOCALIZATION_ALGORITHM_IDS,
|
LOCALIZATION_ALGORITHM_IDS,
|
||||||
@@ -23,7 +28,12 @@ import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
|||||||
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
||||||
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||||
import { renderSignal } from "/packages/acoustics-core/signal.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 {
|
import {
|
||||||
createQuasiStaticWindows,
|
createQuasiStaticWindows,
|
||||||
sampleQuasiStaticWindow,
|
sampleQuasiStaticWindow,
|
||||||
@@ -241,6 +251,217 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
|
|||||||
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
|
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을 보존한다", () => {
|
test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => {
|
||||||
const scene = { speedOfSound: 400, seed: 11 };
|
const scene = { speedOfSound: 400, seed: 11 };
|
||||||
const paths = buildAcousticPaths({
|
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");
|
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를 실제 채널에 적용한다", () => {
|
test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => {
|
||||||
const scene = {
|
const scene = {
|
||||||
sampleRate: 8000,
|
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}`);
|
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}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
enableSourceTrajectory,
|
enableSourceTrajectory,
|
||||||
createSceneId,
|
createSceneId,
|
||||||
createSource,
|
createSource,
|
||||||
|
describeSignal,
|
||||||
insertTrajectoryKeyframe,
|
insertTrajectoryKeyframe,
|
||||||
matchAssetDescriptor,
|
matchAssetDescriptor,
|
||||||
prepareSceneForEditing,
|
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");
|
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을 첫 점에 맞춘다", () => {
|
test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
|
||||||
const source = { position: [0.5, 0.1, 2] };
|
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 });
|
const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
|
||||||
|
|||||||
+194
-2
@@ -22,6 +22,7 @@ import {
|
|||||||
shiftSceneDistance,
|
shiftSceneDistance,
|
||||||
} from "/apps/acoustic-camera/model.js";
|
} from "/apps/acoustic-camera/model.js";
|
||||||
import { validateResult } from "/packages/contracts/validation.js";
|
import { validateResult } from "/packages/contracts/validation.js";
|
||||||
|
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
|
||||||
|
|
||||||
function assertNear(actual, expected, tolerance, label) {
|
function assertNear(actual, expected, tolerance, label) {
|
||||||
assert(
|
assert(
|
||||||
@@ -56,6 +57,56 @@ test("Camera detects moving source trajectories", () => {
|
|||||||
}), true, "moving scene");
|
}), 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", () => {
|
test("Camera analysis plan covers a scene and respects its frame budget", () => {
|
||||||
const scene = { duration: 2, sampleRate: 1000, sources: [] };
|
const scene = { duration: 2, sampleRate: 1000, sources: [] };
|
||||||
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
|
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 = {
|
const scene = {
|
||||||
id: "test-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 = {
|
const experiment = {
|
||||||
id: "test-experiment",
|
id: "test-experiment",
|
||||||
@@ -468,7 +524,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
sceneId: scene.id,
|
sceneId: scene.id,
|
||||||
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
||||||
beamforming: { algorithm: "das-phat", backend: "auto" },
|
beamforming: { algorithm: "das-phat", backend: "auto" },
|
||||||
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 = {
|
const run = {
|
||||||
algorithm: "das-phat",
|
algorithm: "das-phat",
|
||||||
@@ -481,6 +548,23 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
|||||||
acousticModel: "shoebox",
|
acousticModel: "shoebox",
|
||||||
acousticPathCount: 38,
|
acousticPathCount: 38,
|
||||||
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
|
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 },
|
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
|
||||||
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
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),
|
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||||
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||||
displayScale: "normalized",
|
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", {
|
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,
|
maximumDetections: 128,
|
||||||
},
|
},
|
||||||
}]]);
|
}]]);
|
||||||
|
evaluations.set("band-2", evaluations.get("band-1"));
|
||||||
|
|
||||||
const result = createResultDocument({
|
const result = createResultDocument({
|
||||||
arrayDefinition,
|
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",
|
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");
|
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
|
||||||
assertEqual(result.algorithm, "das-phat", "actual algorithm is exported");
|
assertEqual(result.algorithm, "das-phat", "actual algorithm is exported");
|
||||||
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
|
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
|
||||||
@@ -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.acousticModel, "shoebox", "acoustic model is exported");
|
||||||
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count 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.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({
|
const history = createHistorySummary({
|
||||||
arrayDefinition,
|
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 });
|
const issues = await validateResult(result, { experiment, scene });
|
||||||
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
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 wrongExperiment = { ...experiment, arrayId: "another-array" };
|
||||||
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
|
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
validateResult,
|
validateResult,
|
||||||
validateScene,
|
validateScene,
|
||||||
} from "/packages/contracts/validation.js";
|
} from "/packages/contracts/validation.js";
|
||||||
|
import {
|
||||||
|
describeFrequencyAliasing,
|
||||||
|
observeFrequencyBand,
|
||||||
|
} from "/packages/acoustics-core/sampling.js";
|
||||||
|
|
||||||
async function loadJson(path) {
|
async function loadJson(path) {
|
||||||
const response = await fetch(path);
|
const response = await fetch(path);
|
||||||
@@ -11,6 +15,75 @@ async function loadJson(path) {
|
|||||||
return response.json();
|
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 = [
|
const RECTANGULAR_ARRAY_EXAMPLES = [
|
||||||
{ id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 },
|
{ 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 },
|
{ 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 = {
|
const scene = {
|
||||||
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
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 },
|
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 },
|
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 () => {
|
test("NaN 좌표를 거부한다", async () => {
|
||||||
@@ -395,7 +535,7 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
|||||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||||
]);
|
]);
|
||||||
const result = {
|
const result = {
|
||||||
schemaVersion: "1.0.0",
|
schemaVersion: "1.1.0",
|
||||||
kind: "micarrsim.result",
|
kind: "micarrsim.result",
|
||||||
experimentId: experiment.id,
|
experimentId: experiment.id,
|
||||||
arrayId: experiment.arrayId,
|
arrayId: experiment.arrayId,
|
||||||
@@ -404,11 +544,26 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
|||||||
backend: "cpu",
|
backend: "cpu",
|
||||||
algorithm: "das",
|
algorithm: "das",
|
||||||
inputHash: "a".repeat(64),
|
inputHash: "a".repeat(64),
|
||||||
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
|
signalStats: {
|
||||||
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
|
preNormalizationPeak: 0.5,
|
||||||
|
appliedScale: 1,
|
||||||
|
clippedSampleCount: 0,
|
||||||
|
sampling: resultSampling(scene),
|
||||||
|
},
|
||||||
|
timingMs: {
|
||||||
|
total: 10,
|
||||||
|
signalGeneration: 3,
|
||||||
|
localization: 7,
|
||||||
|
beamforming: 7,
|
||||||
|
},
|
||||||
bands: [{
|
bands: [{
|
||||||
id: "tone-1k",
|
id: "tone-1k",
|
||||||
color: "#65f2bf",
|
color: "#65f2bf",
|
||||||
|
frequencyObservation: resultFrequencyObservation(
|
||||||
|
scene,
|
||||||
|
experiment,
|
||||||
|
experiment.frequencyBands[0],
|
||||||
|
),
|
||||||
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
||||||
peaks: [{
|
peaks: [{
|
||||||
sourceId: "source-1",
|
sourceId: "source-1",
|
||||||
@@ -437,6 +592,66 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
|||||||
};
|
};
|
||||||
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
|
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";
|
experiment.beamforming.algorithm = "das-phat";
|
||||||
result.algorithm = "das-phat";
|
result.algorithm = "das-phat";
|
||||||
result.bands[0].rawMap.scale = "score";
|
result.bands[0].rawMap.scale = "score";
|
||||||
|
|||||||
@@ -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의 두 진원 후보를 강도순으로 보존한다", () => {
|
test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => {
|
||||||
const scan = {
|
const scan = {
|
||||||
z: 1,
|
z: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user