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`를
|
||||
현재 열린 Scene·Array에 자동으로 연결합니다. 가져온 `frequencyBands`와 나머지 분석 설정은
|
||||
보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행하므로 Nyquist·scan 등
|
||||
실제 오류는 그대로 거부됩니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다.
|
||||
보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행합니다. Nyquist 위 tone과
|
||||
band는 오류가 아니라 ideal ADC alias 실험으로 허용되며, 원 주파수와 실제 ADC FFT range가 UI와
|
||||
Result에 함께 기록됩니다. 잘못된 범위·scan·asset 같은 실제 오류는 그대로 거부됩니다. Scene을
|
||||
다시 불러오면 이전 asset 연결은 해제됩니다.
|
||||
|
||||
Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에
|
||||
유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와
|
||||
@@ -238,20 +240,24 @@ channel/clock/transport adapter만 별도로 연결합니다. 실시간 여부
|
||||
|
||||
## Result JSON
|
||||
|
||||
Result JSON은 계약 버전 `1.0.0`의 `micarrsim.result` 문서입니다. 주요 내용은 다음과 같습니다.
|
||||
Result JSON은 계약 버전 `1.1.0`의 `micarrsim.result` 문서입니다. Array·Scene·Experiment는
|
||||
`1.0.0`을 유지합니다. 주요 내용은 다음과 같습니다.
|
||||
|
||||
- 실행한 `experimentId`, `arrayId`, `sceneId`, 실제 `algorithm`·`backend`와 생성 시각
|
||||
- Array·Scene·Experiment의 canonical SHA-256인 `inputHash`
|
||||
- 정규화 전 peak, 적용 scale, clipping 수와 음향 모델·희소 경로 수를 담은 `signalStats`
|
||||
- 원 tone→signed ADC alias, DC/Nyquist 퇴화와 anti-alias filter 부재를 담은 필수 sampling provenance
|
||||
- 전체·신호 생성·위치 추정 시간인 `timingMs.localization`과 기존 reader용 `beamforming` alias
|
||||
- 주파수 대역별 power 또는 score `rawMap`, 표시용 `displayMap`, 검출 `peaks`
|
||||
- band별 Nyquist zone fold, FFT bin-center 선택, 실제 localization model과 동일 ADC band 목록
|
||||
- 위치 오차, precision/recall, truth 반경 내 energy, peak-to-sidelobe, main-lobe 폭을 계산하기
|
||||
위한 평가 파라미터와 `metrics`
|
||||
- peak별 half-power 공분산과 RMS ellipse를 담은 `beamSpread` (`calibrated: false`)
|
||||
|
||||
평가에서 tone source는 tone 주파수가 포함되는 band의 ground truth로만 사용합니다. Audio asset과
|
||||
noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 ground truth로 취급합니다. 이 가정은
|
||||
결과를 실제 데이터와 해석할 때 고려해야 합니다.
|
||||
평가에서 tone source는 요청 analog interval 또는 접힌 주파수의 nearest ADC bin을 기준으로
|
||||
사용합니다. Audio asset과 noise
|
||||
source는 임의 spectrum threshold를 만들지 않고 모든 band의 ground truth로 취급합니다. 정확한
|
||||
정책은 Result `evaluation.truthSelection`에 기록됩니다.
|
||||
|
||||
## 개인정보와 모델의 한계
|
||||
|
||||
@@ -261,8 +267,8 @@ noise source는 스펙트럼을 미리 알 수 없으므로 모든 band의 groun
|
||||
|
||||
음향 모델은 직접 경로의 자유음장과 1차 반사·scattering·occluder 회절·RT60 tail을 제한된
|
||||
경로로 계산하는 shoebox 근사를 제공합니다. 파동 방정식, 고차 반사, 재질별 주파수 의존 흡음,
|
||||
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC와 channel clock은 모델링하지
|
||||
않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC,
|
||||
복잡한 mesh 회절, 바람, Doppler, 마이크 self-noise·비선형, ADC quantization·nonlinearity와
|
||||
channel clock jitter·drift는 모델링하지 않습니다. 위치 추정기는 DAS, DAS-PHAT와 CPU GCC-PHAT를 제공하지만 full-pair SRP-PHAT, MUSIC,
|
||||
DAMAS와 CLEAN-SC는 구현하지 않습니다.
|
||||
따라서 결과는 배열·grid·대역의 상대 비교와 소프트웨어 검증용이며, 현장 성능 인증이나 안전
|
||||
판단을 대신하지 않습니다.
|
||||
|
||||
@@ -38,8 +38,8 @@ Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에
|
||||
해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 어느
|
||||
시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에
|
||||
자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도
|
||||
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist·scan 같은 실제 관계 오류는
|
||||
그대로 거부됩니다.
|
||||
연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist 위 tone·band는 ideal ADC
|
||||
alias로 실행되고, 잘못된 scan 범위 같은 실제 관계 오류는 그대로 거부됩니다.
|
||||
|
||||
Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면
|
||||
Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다.
|
||||
@@ -133,8 +133,9 @@ cache를 표시하고 알고리즘별 위치추정 비용을 다음처럼 분리
|
||||
이는 구현 연산 횟수이지 실행 시간 예측값은 아닙니다. scene 길이, microphone 수, FFT/hop,
|
||||
band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
||||
|
||||
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist를 넘거나 겹치는 id 등 계약 오류는
|
||||
실행 전에 표시됩니다. DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
||||
각 band는 id, 최소/최대 Hz와 overlay 색을 가집니다. Nyquist 위 band도 허용되며 legend에 실제로
|
||||
접힌 ADC FFT range가 표시됩니다. 중복 id나 잘못된 범위 같은 계약 오류는 실행 전에 표시됩니다.
|
||||
DAMAS와 CLEAN-SC 후처리는 아직 구현하지 않았으며, 실행할 수 없는 비활성
|
||||
항목은 Camera UI에서 제거했습니다. Result의 `processedMaps` 계약만 향후 확장을 위해 남아 있습니다.
|
||||
|
||||
**Peak 검출 임계값 (%)**은 최강 local peak의 map 값에 대한 상대 임계값이며 Experiment JSON에
|
||||
@@ -142,6 +143,11 @@ band 수와 폭, grid 및 CPU/GPU에 따라 실제 시간이 달라집니다.
|
||||
살리는 대신 sidelobe를 음원으로 잘못 고를 가능성도 높입니다. 같은 대역의 두 음원이 알고리즘의
|
||||
main lobe 폭보다 가깝다면 grid만 키우거나 threshold를 낮춰도 하나로 합쳐질 수 있습니다.
|
||||
|
||||
**Peak 묶기**는 raw map을 바꾸지 않고 threshold 위 후보를 표현하는 방식을 고릅니다.
|
||||
`local-max`는 각 8-neighbor local maximum, `connected-components`는 threshold mask의
|
||||
8-connected component별 weighted centroid, `watershed`는 local maximum marker 기반 watershed
|
||||
basin을 사용합니다. 선택한 표준 방식과 threshold는 Experiment와 Result에 기록됩니다.
|
||||
|
||||
## 화면과 배열·알고리즘 비교
|
||||
|
||||
- acoustic overlay의 투명도와 표시할 band를 선택할 수 있습니다.
|
||||
@@ -181,12 +187,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
||||
|
||||
## Result JSON
|
||||
|
||||
**Result JSON**은 검증을 통과한 `micarrsim.result` 문서만 저장합니다.
|
||||
**Result JSON**은 검증을 통과한 `micarrsim.result` 1.1 문서만 저장합니다.
|
||||
|
||||
- `experimentId`, `arrayId`, `sceneId`, `createdAt`, 실제 `algorithm`과 `backend`
|
||||
- 실행 입력 전체의 canonical SHA-256인 `inputHash`
|
||||
- `signalStats`: 정규화 전 peak, 적용 scale, clipped sample 수, 실제 음향 모델과 sparse
|
||||
path 전체·종류별 수
|
||||
path 전체·종류별 수와 필수 ideal-ADC sampling provenance
|
||||
- `timingMs`: 전체, signal generation과 `localization`; 기존 소비자 호환용 `beamforming`은 같은
|
||||
위치추정 시간을 담는 alias
|
||||
- band별 `rawMap`: DAS는 선형 `power`, PHAT 계열은 무차원 `score`; 화면용 `displayMap`과 검출
|
||||
@@ -194,9 +200,12 @@ Scene 시간에 이어 붙입니다. 한 창 안에서는 중앙 시각의 위
|
||||
- 각 peak의 비보정 half-power/half-maximum 공분산과 RMS ellipse인 `beamSpread`
|
||||
- 평가 설정과 `peakToSidelobeDb`, `energyInsideTruthRatio`, `precision`, `recall`,
|
||||
`mainLobeWidthM`
|
||||
- band별 연속 fold segment, FFT bin-center 선택, DAS/GCC 실제 localization model,
|
||||
원 carrier 미사용 여부와 alias-equivalent band ID
|
||||
|
||||
Tone source는 그 주파수가 band 범위 안에 있을 때만 해당 band의 ground truth입니다. Audio asset과
|
||||
noise source는 스펙트럼을 사전에 판별하지 않으므로 모든 band의 ground truth로 평가합니다. Source
|
||||
Tone source는 요청 analog interval 또는 접힌 ADC 주파수의 nearest selected bin을 기준으로
|
||||
해당 band의 ground truth가 됩니다. Audio asset과 noise source는 임의 spectrum detector를 만들지 않고 모든 band의
|
||||
ground truth로 평가합니다. Source
|
||||
z가 scan z와 다르면 평면에 투영한 x/y를 사용하므로 UI 경고와 위치 오차를 함께 해석하세요.
|
||||
|
||||
## 해석 한계
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<p class="workload-summary" id="workload-summary">연산량 계산 중…</p>
|
||||
<p class="sampling-warning" id="sampling-warning" hidden></p>
|
||||
|
||||
<div class="band-config">
|
||||
<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,
|
||||
localizationAlgorithmSupportsBackend,
|
||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||
import {
|
||||
describeFrequencyAliasing,
|
||||
MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||||
observeFrequencyBand,
|
||||
} from "/packages/acoustics-core/sampling.js";
|
||||
import { downloadJson, readJsonFile } from "/packages/contracts/io.js";
|
||||
import { createSplitPane } from "/packages/ui/split-pane.js";
|
||||
import {
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
validateScene,
|
||||
} from "/packages/contracts/validation.js";
|
||||
import { createCameraAssetRuntime } from "./asset-runtime.js";
|
||||
import { sourcesForObservedBand } from "./source-selection.js";
|
||||
import {
|
||||
applyAcousticProfile,
|
||||
detectAcousticProfile,
|
||||
@@ -121,6 +127,7 @@ const elements = {
|
||||
scanYMin: byId("scan-y-min"),
|
||||
scanYMax: byId("scan-y-max"),
|
||||
workloadSummary: byId("workload-summary"),
|
||||
samplingWarning: byId("sampling-warning"),
|
||||
fftSize: byId("fft-size"),
|
||||
hopSize: byId("hop-size"),
|
||||
normalization: byId("normalization"),
|
||||
@@ -466,12 +473,64 @@ function renderAcousticSummary() {
|
||||
document.documentElement.dataset.acousticProfile = profile;
|
||||
}
|
||||
|
||||
function describeSignal(signal) {
|
||||
if (signal.type === "tone") return `${formatNumber(signal.frequency, 0)} Hz tone`;
|
||||
function describeSignal(signal, sampleRate) {
|
||||
if (signal.type === "tone") {
|
||||
const observation = describeFrequencyAliasing(signal.frequency, sampleRate);
|
||||
const base = `${formatNumber(signal.frequency, 0)} Hz tone`;
|
||||
const edge = observation.dcEdge
|
||||
? " · DC phase-degenerate edge"
|
||||
: observation.nyquistEdge
|
||||
? " · Nyquist phase-degenerate edge"
|
||||
: "";
|
||||
if (observation.aliased) {
|
||||
const direction = observation.spectralInversion ? "−" : "+";
|
||||
return (
|
||||
`${base} → ADC ${formatNumber(observation.adcFrequencyHz, 0)} Hz alias ` +
|
||||
`(signed ${direction}${formatNumber(observation.adcFrequencyHz, 0)} Hz)${edge}`
|
||||
);
|
||||
}
|
||||
return base + edge;
|
||||
}
|
||||
if (signal.type === "noise") return `${signal.color} noise`;
|
||||
return `asset · ${signal.assetId}`;
|
||||
}
|
||||
|
||||
function sceneToneObservations(scene) {
|
||||
const entries = [
|
||||
...(scene.sources ?? []).map((entry) => ({ scope: "source", entry })),
|
||||
...(scene.ambient ?? []).map((entry) => ({ scope: "ambient", entry })),
|
||||
];
|
||||
const observations = [];
|
||||
for (const { scope, entry } of entries) {
|
||||
if (entry.signal?.type !== "tone") continue;
|
||||
try {
|
||||
observations.push({
|
||||
scope,
|
||||
id: entry.id,
|
||||
...describeFrequencyAliasing(entry.signal.frequency, scene.sampleRate),
|
||||
});
|
||||
} catch {
|
||||
// Validation owns malformed inputs; rendering must remain available while editing.
|
||||
}
|
||||
}
|
||||
return observations;
|
||||
}
|
||||
|
||||
function aliasedToneContributions(toneObservations, bandObservation) {
|
||||
const selectedBins = new Set(bandObservation.bins);
|
||||
return toneObservations.filter((tone) =>
|
||||
tone.aliased &&
|
||||
selectedBins.has(Math.round(tone.adcFrequencyHz / bandObservation.binWidthHz)));
|
||||
}
|
||||
|
||||
function describeToneFold(tone) {
|
||||
return (
|
||||
`${tone.scope} ${tone.id} ` +
|
||||
`${formatNumber(tone.analogFrequencyHz, 0)}→ADC ` +
|
||||
`${formatNumber(tone.adcFrequencyHz, 0)} Hz`
|
||||
);
|
||||
}
|
||||
|
||||
function renderDocumentSummary() {
|
||||
const { arrayDefinition, scene, experiment } = state;
|
||||
if (!arrayDefinition || !scene || !experiment) return;
|
||||
@@ -494,7 +553,7 @@ function renderDocumentSummary() {
|
||||
name.title = source.name;
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent =
|
||||
`${describeSignal(source.signal)} · ` +
|
||||
`${describeSignal(source.signal, scene.sampleRate)} · ` +
|
||||
`(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` +
|
||||
(source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : "");
|
||||
detail.title = detail.textContent;
|
||||
@@ -552,7 +611,9 @@ function renderBandEditors() {
|
||||
if (type !== "color") input.placeholder = label;
|
||||
if (type === "number") {
|
||||
input.min = "0";
|
||||
input.max = String(MAX_PRECISE_ANALOG_FREQUENCY_HZ);
|
||||
input.step = "1";
|
||||
input.title = `${label} · 0–1 MHz verified analog range`;
|
||||
}
|
||||
editor.append(input);
|
||||
}
|
||||
@@ -571,7 +632,11 @@ function renderBandEditors() {
|
||||
|
||||
function renderLegend() {
|
||||
elements.bandLegend.replaceChildren();
|
||||
if (!state.experiment) return;
|
||||
elements.samplingWarning.hidden = true;
|
||||
elements.samplingWarning.textContent = "";
|
||||
if (!state.experiment || !state.scene) return;
|
||||
const observations = [];
|
||||
const toneObservations = sceneToneObservations(state.scene);
|
||||
for (const band of state.experiment.frequencyBands) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "legend-toggle";
|
||||
@@ -583,10 +648,97 @@ function renderLegend() {
|
||||
const swatch = document.createElement("span");
|
||||
swatch.className = "legend-swatch";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`;
|
||||
const requested =
|
||||
`${band.id} · ${formatNumber(band.minHz, 0)}–${formatNumber(band.maxHz, 0)} Hz`;
|
||||
try {
|
||||
const observation = observeFrequencyBand(
|
||||
band,
|
||||
state.scene.sampleRate,
|
||||
state.experiment.stft.fftSize,
|
||||
);
|
||||
observations.push({ bandId: band.id, observation });
|
||||
if (observation.aliased) {
|
||||
const adcRanges = observation.adcBinCenterRanges
|
||||
.map((range) =>
|
||||
`${formatNumber(range.firstBinCenterHz, 0)}–` +
|
||||
`${formatNumber(range.lastBinCenterHz, 0)} Hz`)
|
||||
.join(", ");
|
||||
text.textContent = `${requested} → ADC FFT bin centers ${adcRanges}`;
|
||||
text.title =
|
||||
"Ideal sampler, no anti-alias filter. Localization uses the observed ADC bins; " +
|
||||
"the original alias order is not silently used for steering.";
|
||||
} else {
|
||||
text.textContent = requested;
|
||||
}
|
||||
} catch (error) {
|
||||
text.textContent = requested;
|
||||
text.title = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
label.append(input, swatch, text);
|
||||
elements.bandLegend.append(label);
|
||||
}
|
||||
const toneContributions = observations.flatMap(({ bandId, observation }) =>
|
||||
aliasedToneContributions(toneObservations, observation)
|
||||
.map((tone) => ({ bandId, tone })));
|
||||
const aliased = observations.filter(
|
||||
({ observation }) =>
|
||||
observation.aliased ||
|
||||
observation.continuousManyToOne ||
|
||||
observation.gridManyToOne,
|
||||
);
|
||||
const groups = new Map();
|
||||
for (const { bandId, observation } of observations) {
|
||||
const group = groups.get(observation.adcBinSetKey) ?? [];
|
||||
group.push(bandId);
|
||||
groups.set(observation.adcBinSetKey, group);
|
||||
}
|
||||
const equivalentGroups = [...groups.values()].filter((group) => group.length > 1);
|
||||
if (aliased.length > 0 || equivalentGroups.length > 0 || toneContributions.length > 0) {
|
||||
const details = [];
|
||||
if (aliased.length > 0) {
|
||||
const describeFold = ({ bandId, observation }) => {
|
||||
const visibleSegments = observation.foldSegments
|
||||
.slice(0, 4)
|
||||
.map((segment) =>
|
||||
`Z${segment.nyquistZone}/order ${segment.aliasOrder}` +
|
||||
(segment.inverted ? "/inverted" : ""))
|
||||
.join(", ");
|
||||
const remainder = observation.foldSegments.length > 4
|
||||
? `, +${observation.foldSegments.length - 4} zones`
|
||||
: "";
|
||||
return `${bandId} [${visibleSegments}${remainder}]`;
|
||||
};
|
||||
details.push(
|
||||
`alias band: ${aliased.map(describeFold).join(" · ")}`,
|
||||
);
|
||||
}
|
||||
if (equivalentGroups.length > 0) {
|
||||
details.push(
|
||||
"같은 ADC bin 집합(독립 관측 아님): " +
|
||||
equivalentGroups.map((group) => group.join(" ↔ ")).join(" · "),
|
||||
);
|
||||
}
|
||||
if (toneContributions.length > 0) {
|
||||
details.push(
|
||||
"선택 ADC bin으로 접힌 Scene tone: " +
|
||||
toneContributions
|
||||
.map(({ bandId, tone }) => `${bandId} ← ${describeToneFold(tone)}`)
|
||||
.join(" · "),
|
||||
);
|
||||
}
|
||||
const localizationDescription =
|
||||
state.experiment.beamforming.algorithm === "gcc-phat"
|
||||
? "관측된 one-sided ADC FFT bin만 사용해 band-limited GCC-PHAT 기하 lag를 계산하며 "
|
||||
: "관측된 양의 ADC FFT bin 주파수로 구면 steering하며 ";
|
||||
elements.samplingWarning.textContent =
|
||||
"Ideal ADC alias · 위치 추정은 " + localizationDescription +
|
||||
"원래 analog carrier를 사용하지 않습니다. 공간 위상 불일치와 grating lobe는 " +
|
||||
"그대로 나타나며, 현재 알고리즘에서는 alias order가 미해결 상태로 남습니다. " +
|
||||
"다채널 공간 위상에는 carrier-order 증거가 남을 수 있지만 이 localizer는 " +
|
||||
"carrier hypothesis를 열거하지 않습니다. " +
|
||||
details.join(" · ");
|
||||
elements.samplingWarning.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWorkload() {
|
||||
@@ -773,6 +925,65 @@ function renderResult() {
|
||||
`${(displayed.run.signalStats.acousticPathCount ?? 0).toLocaleString("ko-KR")} paths 합성`,
|
||||
);
|
||||
}
|
||||
const aliasedTones = displayed.run.signalStats.sampling?.toneObservations
|
||||
?.filter((observation) => observation.aliased) ?? [];
|
||||
if (displayed.run.signalStats.sampling) {
|
||||
appendMetric(
|
||||
"ADC sampling",
|
||||
aliasedTones.length > 0 ? `${aliasedTones.length} aliased tone` : "Nyquist 내",
|
||||
aliasedTones.length > 0
|
||||
? aliasedTones
|
||||
.map((observation) =>
|
||||
`${formatNumber(observation.analogFrequencyHz, 0)}→` +
|
||||
`${formatNumber(observation.adcFrequencyHz, 0)} Hz`)
|
||||
.join(" · ")
|
||||
: "ideal real sampler · anti-alias filter 없음",
|
||||
);
|
||||
}
|
||||
const resultObservations = displayed.snapshot.experiment.frequencyBands
|
||||
.map((band) => {
|
||||
try {
|
||||
return {
|
||||
band,
|
||||
observation: observeFrequencyBand(
|
||||
band,
|
||||
displayed.snapshot.scene.sampleRate,
|
||||
displayed.snapshot.experiment.stft.fftSize,
|
||||
),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
const resultToneObservations = sceneToneObservations(displayed.snapshot.scene);
|
||||
for (const { band, observation } of resultObservations) {
|
||||
const toneContributions = aliasedToneContributions(
|
||||
resultToneObservations,
|
||||
observation,
|
||||
);
|
||||
if (
|
||||
!observation.aliased &&
|
||||
!observation.continuousManyToOne &&
|
||||
!observation.gridManyToOne &&
|
||||
toneContributions.length === 0
|
||||
) continue;
|
||||
const localizationDetail =
|
||||
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
|
||||
? "band-limited GCC-PHAT · 원 carrier 미사용"
|
||||
: "양의 ADC bin steering · 원 carrier 미사용 · alias order 미해결";
|
||||
const toneDetail = toneContributions.length > 0
|
||||
? " · aliased Scene tone " + toneContributions.map(describeToneFold).join(" · ")
|
||||
: "";
|
||||
appendMetric(
|
||||
`${band.id} · ADC steering`,
|
||||
displayed.snapshot.experiment.beamforming.algorithm === "gcc-phat"
|
||||
? "ADC LAG"
|
||||
: "OBSERVED BIN",
|
||||
localizationDetail + toneDetail,
|
||||
band.color,
|
||||
);
|
||||
}
|
||||
appendMetric(
|
||||
"위치 추정 시간",
|
||||
`${formatNumber(displayed.run.timingMs.localization ?? displayed.run.timingMs.beamforming, 0)} ms`,
|
||||
@@ -1009,18 +1220,6 @@ function scheduleValidation() {
|
||||
});
|
||||
}
|
||||
|
||||
function sourcesForBand(scene, band, time = null) {
|
||||
return scene.sources.filter((source) => {
|
||||
if (time !== null) {
|
||||
const startTime = source.startTime ?? 0;
|
||||
const endTime = startTime + (source.duration ?? scene.duration);
|
||||
if (time < startTime || time > endTime) return false;
|
||||
}
|
||||
if (source.signal.type !== "tone") return true;
|
||||
return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz;
|
||||
});
|
||||
}
|
||||
|
||||
function createAbortError() {
|
||||
const error = new Error("시뮬레이션이 취소되었습니다.");
|
||||
error.name = "AbortError";
|
||||
@@ -1170,7 +1369,11 @@ async function runSimulation() {
|
||||
localizationAlgorithmDefinition(run.algorithm ?? snapshot.experiment.beamforming.algorithm)?.rawScale ??
|
||||
"power",
|
||||
scan: snapshot.experiment.scan,
|
||||
sources: sourcesForBand(snapshot.scene, definition),
|
||||
sources: sourcesForObservedBand(
|
||||
snapshot.scene,
|
||||
definition,
|
||||
snapshot.experiment.stft.fftSize,
|
||||
),
|
||||
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
||||
detectionThresholdRatio:
|
||||
snapshot.experiment.evaluation?.detectionThresholdRatio ??
|
||||
@@ -1289,6 +1492,7 @@ function reconcileLivePlan(plan, timeline) {
|
||||
function evaluateLiveFrames(snapshot, plan, frames) {
|
||||
for (const frame of frames) {
|
||||
const frameScene = scenePlaybackState(snapshot.scene, plan, frame.sampleTime).scene;
|
||||
const arrivingSourceIds = frame.run.signalStats.windowing?.arrivingSourceIds;
|
||||
const evaluations = new Map();
|
||||
for (const band of frame.run.bands) {
|
||||
const definition = snapshot.experiment.frequencyBands.find(
|
||||
@@ -1306,7 +1510,15 @@ function evaluateLiveFrames(snapshot, plan, frames) {
|
||||
)?.rawScale ??
|
||||
"power",
|
||||
scan: snapshot.experiment.scan,
|
||||
sources: sourcesForBand(frameScene, definition, frame.sampleTime),
|
||||
sources: sourcesForObservedBand(
|
||||
frameScene,
|
||||
definition,
|
||||
snapshot.experiment.stft.fftSize,
|
||||
{
|
||||
time: Array.isArray(arrivingSourceIds) ? null : frame.sampleTime,
|
||||
arrivingSourceIds,
|
||||
},
|
||||
),
|
||||
dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb,
|
||||
detectionThresholdRatio:
|
||||
snapshot.experiment.evaluation?.detectionThresholdRatio ??
|
||||
@@ -1809,12 +2021,11 @@ function installBandListeners() {
|
||||
let suffix = state.experiment.frequencyBands.length + 1;
|
||||
while (used.has(`band-${suffix}`)) suffix += 1;
|
||||
const previous = state.experiment.frequencyBands.at(-1);
|
||||
const nyquist = state.scene.sampleRate / 2;
|
||||
const minHz = Math.min(Math.max(0, previous?.maxHz ?? 900), nyquist - 101);
|
||||
const minHz = Math.max(0, previous?.maxHz ?? 900);
|
||||
const band = {
|
||||
id: `band-${suffix}`,
|
||||
minHz,
|
||||
maxHz: Math.min(nyquist - 1, minHz + 200),
|
||||
maxHz: minHz + 200,
|
||||
color: BAND_COLORS[state.experiment.frequencyBands.length % BAND_COLORS.length],
|
||||
};
|
||||
state.experiment.frequencyBands.push(band);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js";
|
||||
import { observeFrequencyBand } from "/packages/acoustics-core/sampling.js";
|
||||
import {
|
||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||
localizationAlgorithmDefinition,
|
||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||
import { RESULT_SCHEMA_VERSION } from "/packages/contracts/validation.js";
|
||||
|
||||
const RESULT_METRIC_KEYS = [
|
||||
"peakToSidelobeDb",
|
||||
@@ -254,6 +256,70 @@ function environmentForResult({ arrayDefinition, experiment, run }) {
|
||||
return environment;
|
||||
}
|
||||
|
||||
function samplingForResult(sampling) {
|
||||
if (!sampling || typeof sampling !== "object") return null;
|
||||
return {
|
||||
model: sampling.model,
|
||||
sampleRate: sampling.sampleRate,
|
||||
nyquistHz: sampling.nyquistHz,
|
||||
toneObservations: (sampling.toneObservations ?? []).map((observation) => ({
|
||||
scope: observation.scope,
|
||||
id: observation.id,
|
||||
analogFrequencyHz: observation.analogFrequencyHz,
|
||||
adcFrequencyHz: observation.adcFrequencyHz,
|
||||
signedAdcFrequencyHz: observation.signedAdcFrequencyHz,
|
||||
aliased: observation.aliased,
|
||||
spectralInversion: observation.spectralInversion,
|
||||
dcEdge: observation.dcEdge,
|
||||
nyquistEdge: observation.nyquistEdge,
|
||||
phaseDegenerate: observation.phaseDegenerate,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function frequencyObservationForResult(scene, experiment, band, algorithm) {
|
||||
if (
|
||||
!Number.isFinite(scene?.sampleRate) ||
|
||||
!Number.isInteger(experiment?.stft?.fftSize) ||
|
||||
!Number.isFinite(band?.minHz) ||
|
||||
!Number.isFinite(band?.maxHz)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const observation = observeFrequencyBand(
|
||||
band,
|
||||
scene.sampleRate,
|
||||
experiment.stft.fftSize,
|
||||
);
|
||||
return {
|
||||
model: observation.model,
|
||||
sampleRate: observation.sampleRate,
|
||||
nyquistHz: observation.nyquistHz,
|
||||
fftSize: observation.fftSize,
|
||||
binWidthHz: observation.binWidthHz,
|
||||
requestedMinHz: observation.requestedMinHz,
|
||||
requestedMaxHz: observation.requestedMaxHz,
|
||||
aliased: observation.aliased,
|
||||
crossesFirstNyquist: observation.crossesFirstNyquist,
|
||||
localizationInputModel: observation.localizationInputModel,
|
||||
selectionModel: observation.selectionModel,
|
||||
localizationModel:
|
||||
algorithm === "gcc-phat"
|
||||
? "band-limited-gcc-phat-geometric-lag"
|
||||
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||
originalCarrierUsed: observation.originalCarrierUsed,
|
||||
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
|
||||
foldBoundaryCount: observation.foldBoundaryCount,
|
||||
continuousManyToOne: observation.continuousManyToOne,
|
||||
gridManyToOne: observation.gridManyToOne,
|
||||
foldSegments: observation.foldSegments.map((segment) => ({ ...segment })),
|
||||
adcBins: [...observation.bins],
|
||||
adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })),
|
||||
adcBinSetKey: observation.adcBinSetKey,
|
||||
equivalentBandIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createResultDocument(options) {
|
||||
const {
|
||||
arrayDefinition,
|
||||
@@ -266,8 +332,31 @@ export function createResultDocument(options) {
|
||||
} = options;
|
||||
const algorithm = run.algorithm ?? experiment.beamforming?.algorithm ?? DEFAULT_LOCALIZATION_ALGORITHM;
|
||||
const rawScale = localizationAlgorithmDefinition(algorithm)?.rawScale ?? "power";
|
||||
const sampling = samplingForResult(run.signalStats.sampling);
|
||||
const frequencyObservations = new Map();
|
||||
for (const definition of experiment.frequencyBands) {
|
||||
const observation = frequencyObservationForResult(
|
||||
scene,
|
||||
experiment,
|
||||
definition,
|
||||
algorithm,
|
||||
);
|
||||
if (observation) frequencyObservations.set(definition.id, observation);
|
||||
}
|
||||
const equivalentGroups = new Map();
|
||||
for (const [bandId, observation] of frequencyObservations) {
|
||||
const group = equivalentGroups.get(observation.adcBinSetKey) ?? [];
|
||||
group.push(bandId);
|
||||
equivalentGroups.set(observation.adcBinSetKey, group);
|
||||
}
|
||||
for (const [bandId, observation] of frequencyObservations) {
|
||||
observation.equivalentBandIds = equivalentGroups
|
||||
.get(observation.adcBinSetKey)
|
||||
.filter((candidateId) => candidateId !== bandId)
|
||||
.sort();
|
||||
}
|
||||
return {
|
||||
schemaVersion: "1.0.0",
|
||||
schemaVersion: RESULT_SCHEMA_VERSION,
|
||||
kind: "micarrsim.result",
|
||||
experimentId: experiment.id,
|
||||
arrayId: arrayDefinition.id,
|
||||
@@ -289,6 +378,7 @@ export function createResultDocument(options) {
|
||||
...(run.signalStats.acousticPathCounts
|
||||
? { acousticPathCounts: { ...run.signalStats.acousticPathCounts } }
|
||||
: {}),
|
||||
...(sampling ? { sampling } : {}),
|
||||
},
|
||||
timingMs: {
|
||||
total: run.timingMs.total,
|
||||
@@ -317,7 +407,15 @@ export function createResultDocument(options) {
|
||||
peaks: evaluation.peaks.map(peakForResult),
|
||||
metrics: finiteMetricSubset(evaluation.metrics),
|
||||
};
|
||||
if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation };
|
||||
const frequencyObservation = frequencyObservations.get(band.id);
|
||||
if (frequencyObservation) bandResult.frequencyObservation = frequencyObservation;
|
||||
if (evaluation.evaluation) {
|
||||
bandResult.evaluation = {
|
||||
...evaluation.evaluation,
|
||||
truthSelection:
|
||||
"tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources",
|
||||
};
|
||||
}
|
||||
return bandResult;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ function signalStats(synthesis) {
|
||||
acousticModel: synthesis.acousticModel,
|
||||
acousticPathCount: synthesis.acousticPathCount,
|
||||
acousticPathCounts: synthesis.acousticPathCounts,
|
||||
windowing: synthesis.windowing,
|
||||
sampling: synthesis.sampling,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +77,10 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
||||
scene,
|
||||
plannedFrame.startTime,
|
||||
plannedFrame.endTime,
|
||||
{
|
||||
arrayDefinition,
|
||||
minimumDistance: experiment.beamforming.minimumDistance,
|
||||
},
|
||||
);
|
||||
post("progress", jobId, {
|
||||
stage: "timeline-signal",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
.workload-summary.is-warning { color: var(--amber); }
|
||||
.sampling-warning {
|
||||
margin: 8px 0 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgb(255 197 110 / 28%);
|
||||
border-radius: 8px;
|
||||
color: var(--amber);
|
||||
background: rgb(255 197 110 / 7%);
|
||||
font-size: .64rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.acoustic-environment-summary {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
이미지 평면에 source ground truth를 배치하고 tone, noise, 로컬 audio asset과 ambient를 조합해
|
||||
재현 가능한 `micarrsim.scene` JSON을 만드는 브라우저 앱입니다.
|
||||
|
||||
Tone 주파수는 Scene Nyquist 위도 입력할 수 있습니다. source 카드는 anti-alias filter 없는 이상적
|
||||
ADC에서 관측될 alias 주파수를 함께 표시합니다. 이는 고주파를 복원하거나 감쇠시키는 기능이 아니며,
|
||||
실제 장치의 analog anti-alias filter는 별도 모델 대상입니다.
|
||||
|
||||
## 열기
|
||||
|
||||
저장소의 `start.cmd`를 실행한 뒤 시작 화면에서 **Dataset Builder**를 선택하거나
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
</div>
|
||||
</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>
|
||||
<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="asset-fields">
|
||||
<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;
|
||||
heading.append(dot, name);
|
||||
const signal = document.createElement("small");
|
||||
signal.textContent = describeSignal(source.signal);
|
||||
signal.textContent = describeSignal(source.signal, state.scene.sampleRate);
|
||||
const position = document.createElement("small");
|
||||
const motion = Array.isArray(source.trajectory) ? ` · 이동 ${source.trajectory.length}점` : "";
|
||||
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m${motion}`;
|
||||
@@ -683,6 +683,7 @@ function bindSceneControls() {
|
||||
if (asset.kind === "audio") state.assetErrors.delete(asset.id);
|
||||
}
|
||||
renderAssetList();
|
||||
renderSourceCards();
|
||||
}, "change");
|
||||
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
|
||||
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { describeFrequencyAliasing } from "/packages/acoustics-core/sampling.js";
|
||||
|
||||
export const DEFAULT_VIEW_PLANE = Object.freeze({
|
||||
xMin: -0.6,
|
||||
xMax: 0.6,
|
||||
@@ -192,8 +194,20 @@ export function changeSignalType(source, type, firstAudioAssetId = "") {
|
||||
}
|
||||
}
|
||||
|
||||
export function describeSignal(signal) {
|
||||
if (signal.type === "tone") return `${signal.frequency} Hz tone`;
|
||||
export function describeSignal(signal, sampleRate) {
|
||||
if (signal.type === "tone") {
|
||||
if (!Number.isFinite(sampleRate)) return `${signal.frequency} Hz tone`;
|
||||
const observation = describeFrequencyAliasing(signal.frequency, sampleRate);
|
||||
const alias = observation.aliased
|
||||
? ` → ADC ${observation.adcFrequencyHz} Hz alias`
|
||||
: "";
|
||||
const edge = observation.dcEdge
|
||||
? " · DC phase-degenerate edge"
|
||||
: observation.nyquistEdge
|
||||
? " · Nyquist phase-degenerate edge"
|
||||
: "";
|
||||
return `${signal.frequency} Hz tone${alias}${edge}`;
|
||||
}
|
||||
if (signal.type === "noise") return `${signal.color} noise`;
|
||||
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
|
||||
return "알 수 없는 신호";
|
||||
|
||||
@@ -82,6 +82,38 @@ Scene JSON의 사용자 설정 예는 다음과 같다.
|
||||
하며 occluder id는 중복될 수 없다. 사용자 설정에서도 마이크 좌표가 room 안에 놓이도록 구성해야
|
||||
1차 반사가 생성된다.
|
||||
|
||||
## 라이브 창의 전파 지연과 이동 근사
|
||||
|
||||
라이브 frame의 출력 sample 구간을 `[S, E)`, 현재 quasi-static geometry에서 모든
|
||||
source–microphone 희소 경로의 최대 지연을 `Dmax`라 하자. 합성기는 다음 순서로 frame을 만든다.
|
||||
|
||||
1. source trajectory를 출력 창 중앙 시각에서 한 번 샘플링한다.
|
||||
2. `R = max(0, S - ceil(Dmax × sampleRate))`부터 `E`까지의 tone, noise와 asset 방사 신호를
|
||||
원래 Scene 절대 sample index로 생성한다.
|
||||
3. 이 pre-roll buffer에 direct, reflection, scattering, diffraction과 diffuse 경로를 적용한다.
|
||||
4. 전파가 끝난 뒤 `[S, E)`만 crop하여 localizer에 넘긴다.
|
||||
|
||||
따라서 source가 `S` 전에 멈췄더라도 그 직접음·반사음·diffuse tail이 창 안에 도착하면 보존된다.
|
||||
analytic tone뿐 아니라 deterministic noise index와 asset 재생 위치도 원래 절대 시간축을 유지한다.
|
||||
`synthesis.windowing`과 live frame의 `signalStats.windowing`은 render/output sample, pre-roll,
|
||||
최대 지연, geometry와 Doppler 정책을 기록한다.
|
||||
source별 `sourceArrivals`는 각 midpoint path의 half-open 도착 sample 구간이 출력과 겹치는지와
|
||||
겹친 path kind를 기록한다. live ground-truth 평가는 emission midpoint가 아니라
|
||||
`arrivingSourceIds`를 사용하므로, 이미 멈춘 source의 tail을 false positive로 잘못 세지 않는다.
|
||||
이 ID 집합은 위치 추정이 끝난 뒤 평가에만 쓰며 raw channel, STFT, steering이나 peak 검출에는
|
||||
입력하지 않는다.
|
||||
|
||||
다만 이는 moving wave equation 해법이 아니다. pre-roll 방사 시각의 retarded source 위치를 다시
|
||||
구하지 않고 전체 render buffer에서 창 중앙 위치와 그 위치의 경로를 고정한다. 창 안의 연속 이동,
|
||||
Doppler, 시간에 따라 변하는 reflection geometry도 모델링하지 않는다. 움직임이 경로 지연에 비해
|
||||
크다면 frame 길이를 줄여 quasi-static 오차를 드러내고 수렴성을 확인해야 한다.
|
||||
|
||||
sampled asset/noise의 fractional delay와 microphone response는 zero-padded block FFT 전달함수다.
|
||||
pre-roll은 현재 희소 **전파 경로**의 최대 지연을 보존하지만, 측정된 주파수 응답에서 무한하거나
|
||||
임의 길이의 causal analog impulse response state를 복원하지 않는다. 또한 Scene이
|
||||
`mix.normalization: peak`이면 live 합성은 각 출력 crop의 peak로 scale을 계산하므로 frame 간
|
||||
절대 진폭 비교에는 `normalization: none`을 사용해야 한다.
|
||||
|
||||
## Camera 환경 preset과 비용
|
||||
|
||||
Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음
|
||||
@@ -184,6 +216,66 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가
|
||||
해결하지는 않는다. 단일 reference 방식은 모든 마이크 쌍을 쓰는 GCC보다 계산량이 작지만 pair
|
||||
중복성과 고장 마이크에 대한 강건성도 작다.
|
||||
|
||||
## 샘플링, Nyquist와 alias
|
||||
|
||||
Scene의 `sampleRate`는 모든 마이크가 동시에 동작하는 이상적 real-valued ADC의 sampling rate다.
|
||||
현재 모델에는 analog anti-alias filter가 없다. 따라서 tone 주파수는 Nyquist를 넘어도 허용되며,
|
||||
관측 주파수는 다음 signed baseband 주파수로 접힌다. 정확한 Nyquist tie는 `+Fs/2`로
|
||||
표준화하지만 `+Fs/2`와 `-Fs/2`는 같은 real sample sequence다.
|
||||
|
||||
```text
|
||||
f_wrapped = f_analog mod Fs, 0 <= f_wrapped < Fs
|
||||
f_signed = f_wrapped if f_wrapped <= Fs/2
|
||||
f_wrapped - Fs otherwise
|
||||
f_adc = |f_signed|
|
||||
```
|
||||
|
||||
`f_signed < 0`이면 one-sided positive spectrum이 반전된다. 정확히 DC나 Nyquist에 접힌 tone은
|
||||
quadrature 정보가 소실되는 퇴화점이다. Camera는 이를 오류로 숨기지 않고 source 요약, band legend와
|
||||
Result `signalStats.sampling`에 원 주파수·signed ADC 주파수·반전 여부와 DC/Nyquist
|
||||
위상 퇴화 여부를 기록한다.
|
||||
|
||||
공간 전파는 alias 주파수로 바꾸지 않는다. 합성기는 각 path의 원래 analog 주파수
|
||||
`2π f_analog τ`로 지연 위상과 해당 주파수의 microphone response를 적용한 뒤 ADC 시각에서
|
||||
sampling한다. 이 순서를 뒤집으면 fractional path delay의 센서 간 위상이 틀리므로, tone은
|
||||
mic/path별 analytic phasor와 시작·종료 event로 합성한다.
|
||||
|
||||
Experiment band도 Nyquist 위 값을 허용한다. 요청 주파수 구간의 모든 등가 one-sided real-FFT
|
||||
bin **center**를 접어 선택하며, 일부 alias 후보를 골라 버리지 않는다. 이 선택은 brick-wall
|
||||
대역 필터가 아니다. 유한 Hann window의 off-grid leakage는 인접 bin에도 남는다. 현재
|
||||
DAS·DAS-PHAT는 양의 ADC bin 주파수로 구면 steering하고, GCC-PHAT는 선택 bin으로 제한한
|
||||
기하 lag correlation을 수행한다. 어느 쪽도 원래 alias order를 이용해 결과가 좋아지도록
|
||||
보정하지 않는다.
|
||||
따라서 초과 주파수에서 peak가 틀어지거나 grating lobe·동률 peak가 나타나는 것이 정상적인 설계
|
||||
결과일 수 있다. Result의 band별 `frequencyObservation`은 1-based Nyquist zone별 연속 fold
|
||||
segment, 반전·alias order, 실제 ADC bin center, 연속/FFT-grid many-to-one 여부와
|
||||
alias-equivalent band ID를 보존한다. 같은 `adcBinSetKey`를 가진 band는 독립 관측이 아니다.
|
||||
|
||||
정확한 Nyquist 경계에서 `+Fs/2`와 `-Fs/2`는 실수 sampling sequence가 동일하다.
|
||||
단일 주파수 provenance는 이를 `+Fs/2`로 canonicalize하지만, `foldSegments`의
|
||||
`signedAdcStartHz`/`signedAdcEndHz`는 각 zone을 따라가는 방향을 보존하므로 반전 zone
|
||||
시작점이 `-Fs/2`로 나타날 수 있다. 경계를 가로지르는 두 segment의 `+Fs/2`와
|
||||
`-Fs/2` endpoint는 두 관측이나 두 FFT bin이 아니라 같은 phase-degenerate Nyquist
|
||||
sample을 양쪽 zone에서 표현한 것이다.
|
||||
|
||||
같은 이유로 위치 추정기의 선택적 microphone-response 역보정도 관측 ADC bin에서 평가한다. Forward
|
||||
tone 합성은 원 analog 주파수의 응답을 사용하므로, Nyquist 위 carrier를 알고 보정하는 별도
|
||||
alias-aware 알고리즘처럼 동작하지 않는다. 이 차이 역시 감추지 않는 현재 알고리즘의 결과다.
|
||||
|
||||
서로 다른 analog tone이 같은 ADC bin으로 접히면 **단일 channel의 시간 sample만으로는**
|
||||
원 주파수를 구별할 수 없다. 그러나 이 합성기는 원 analog 파장의 센서 간 위상을 보존하므로,
|
||||
충분히 촘촘하고 보정된 다채널 array에는 carrier order를 판별할 공간 위상 증거가 남을 수 있다.
|
||||
현재 DAS·DAS-PHAT·GCC-PHAT는 carrier hypothesis를 열거하는 알고리즘이 아니므로 이를 복원하지
|
||||
않고 Result에 `aliasOrderResolution: not-attempted`로 기록한다. 이 기능을 추가하려면 검증된
|
||||
array-processing 방법과 별도 alias-order 평가가 필요하며, 임의 규칙으로 peak를 고르지 않는다.
|
||||
audio asset과 noise는 이미 sampling된 baseband 신호로 취급하므로 파일에 없는 Nyquist 위 analog
|
||||
spectrum을 만들어 내지 않는다.
|
||||
|
||||
Tone과 band의 현재 검증 범위는 0–1 MHz다. 이는 Nyquist 제한이 아니라 JavaScript
|
||||
IEEE-754 위상 정밀도, 마이크 응답 계약과 현재 희소 경로 모델을 함께 검증한 수치 범위다.
|
||||
더 높은 carrier가 필요하면 단순히 상한을 제거하지 말고 phase reduction과 microphone model을
|
||||
그 범위에서 다시 검증해야 한다.
|
||||
|
||||
## 마이크 주파수 응답
|
||||
|
||||
각 마이크에는 2–64개의 측정점을 지정할 수 있다. `frequencyHz`는 0–1,000,000 Hz에서
|
||||
@@ -212,6 +304,11 @@ band는 주기적으로 같은 phase를 만드는 lag가 많아 여러 위치가
|
||||
합성기는 알고리즘과 무관하게 sensitivity, 정적 phase offset과 frequency-response curve를 수신
|
||||
channel에 적용한다. 위치 추정에서의 역보정은 알고리즘별로 다르다.
|
||||
|
||||
Tone에는 원 analog carrier에서 보간한 하나의 complex response를 정상상태 phasor로 적용한다.
|
||||
따라서 지속 sinusoid의 gain/phase 비교에는 맞지만, hard-gated 시작·종료에서 실제 analog
|
||||
마이크 필터가 만드는 ringing이나 frequency-dependent transient까지 계산하는 full LTI filter
|
||||
모델은 아니다. 이 근사를 ADC를 포함한 완전한 하드웨어 시뮬레이션으로 해석하지 않는다.
|
||||
|
||||
- DAS는 sensitivity 역 gain과 정적 phase를 항상 보정한다. `compensateFrequencyResponse`를
|
||||
생략하거나 `true`로 두면 response의 역 gain과 phase도 보정한다. 응답 notch의 무제한 gain
|
||||
boost는 noise까지 키울 수 있으므로 `maxCompensationDb`로 제한하며 기본값은 24 dB, 허용 범위는
|
||||
|
||||
+38
-11
@@ -127,9 +127,8 @@ sidelobe false positive도 늘 수 있다.
|
||||
|
||||
## Result 평가 정보
|
||||
|
||||
새 Result는 최상위 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를 기록하며
|
||||
Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result를 읽기 위해 schema상 선택적으로
|
||||
남아 있지만 현재 export에는 항상 들어간다. `backend`는 요청값 `auto`가 아니라 실제 사용한
|
||||
Result 1.1은 최상위 필수 `algorithm`에 실제 실행한 `das`, `das-phat` 또는 `gcc-phat`를
|
||||
기록하며 Experiment의 선택값과 같아야 한다. `backend`는 요청값 `auto`가 아니라 실제 사용한
|
||||
`cpu` 또는 `webgpu`다.
|
||||
|
||||
관계 검증은 `das → power`, `das-phat/gcc-phat → score`를 강제한다. Peak에 `beamSpread`가
|
||||
@@ -137,8 +136,8 @@ Experiment의 선택값과 같아야 한다. 이 필드는 이전 `1.0.0` Result
|
||||
구조상 유효하지만 의미가 모순된 외부 Result도 import 시 거절된다.
|
||||
|
||||
`timingMs.localization`은 선택한 위치 추정 알고리즘의 wall time이다. 현재 export는 기존 DAS 전용
|
||||
소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.0.0` schema는 이전
|
||||
문서를 위해 `beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을
|
||||
소비자를 위해 같은 값을 `timingMs.beamforming`에도 호환 alias로 기록한다. `1.1.0` schema는
|
||||
`beamforming`을 여전히 필수로 두므로, 새 writer는 `localization`과 `beamforming`을
|
||||
함께 저장해야 한다. `timingMs.total`은 전체 시간이고 선택적 `signalGeneration`은 합성 시간이다.
|
||||
|
||||
각 band의 `peaks`는 위치와 해당 `rawMap.scale`의 값, 선택적 `sourceId`, `localizationErrorM`,
|
||||
@@ -168,14 +167,29 @@ threshold와 최소 거리, sidelobe 제외 반경, half-power 비율, dynamic r
|
||||
버전과 함께 저장한다. `environment`에는 실제 backend, microphone/grid 수, scan bounds와 z,
|
||||
가능한 경우 GPU adapter 및 fallback 정보를 기록한다.
|
||||
|
||||
`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 선택적으로
|
||||
`signalStats`는 정규화 전 peak, scale과 clipping 수 외에 필수 `sampling` provenance와 선택적
|
||||
`acousticModel`, 실제 전체 `acousticPathCount`와 `acousticPathCounts`를
|
||||
기록한다. 종류별 key는 `direct`, `reflection`, `scattering`,
|
||||
`diffraction`과 `diffuse`다.
|
||||
|
||||
대역별 ground truth는 tone의 주파수가 band 안에 있을 때 그 tone source를 포함한다. noise와
|
||||
asset source는 현재 모든 band의 truth로 간주한다. 즉 녹음 asset의 실제 spectrum energy를
|
||||
대역별로 판정하는 계약은 아직 없으며, 이 가정은 실제 데이터 결과를 해석할 때 고려해야 한다.
|
||||
각 band의 필수 `frequencyObservation`은 연속 analog fold와 이산 FFT 계산 선택을 구분한다.
|
||||
`foldSegments`는 1-based Nyquist zone·반전·alias order를,
|
||||
`adcBins`/`adcBinCenterRanges`는
|
||||
`selectionModel: fft-bin-center-membership`으로 선택된 bin center를 뜻한다.
|
||||
요청 `minHz`와 `maxHz`에 정확히 놓인 FFT bin center도 선택한다. 정확한 Nyquist
|
||||
경계의 `+Fs/2`와 `-Fs/2`는 같은 실수 sample이며, point provenance는 `+Fs/2`로
|
||||
canonicalize하고 fold segment endpoint는 zone 진행 방향에 따라 둘 중 하나를 기록한다.
|
||||
`localizationInputModel`은 one-sided real FFT bin만 사용함을 밝히며, `localizationModel`은
|
||||
DAS 계열의 구면 steering과 GCC-PHAT의 기하 lag를 구분한다. `originalCarrierUsed`는 항상
|
||||
`false`다. Nyquist 위 band의 `aliasOrderResolution`은 현재 `not-attempted`다.
|
||||
`equivalentBandIds`가 있으면 그 band들은 같은 ADC bin 집합을 공유하므로 독립
|
||||
관측으로 세면 안 된다.
|
||||
|
||||
대역별 ground truth의 실제 정책은 evaluation의
|
||||
`truthSelection: tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources`에
|
||||
기록한다. Tone은 원 주파수가 요청 analog interval 안에 있거나, 접힌 주파수의 nearest ADC
|
||||
bin이 선택됐을 때 포함한다. noise와 asset source는 모든 band에 포함한다. 녹음 asset의
|
||||
spectrum을 임의 threshold로 분류하는 미검증 multiband 알고리즘은 사용하지 않는다.
|
||||
|
||||
## 재현성 hash
|
||||
|
||||
@@ -186,7 +200,10 @@ SHA-256을 계산해 scene의 해당 asset `sha256` 필드를 채운 상태로 c
|
||||
|
||||
## 버전과 검증
|
||||
|
||||
현재 계약 버전은 `1.0.0`이다. 브라우저는 `packages/contracts/schema-validator.js`로 저장소의
|
||||
Array·Scene·Experiment 계약 버전은 `1.0.0`, Result 계약 버전은 `1.1.0`이다. Result
|
||||
`1.1.0`은 sampling provenance와 band별 fold/localization metadata를 필수화하므로 이전
|
||||
`1.0.0` Result와 구조적으로 호환되지 않는다. 브라우저는
|
||||
`packages/contracts/schema-validator.js`로 저장소의
|
||||
실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다.
|
||||
|
||||
Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한 뒤 `sceneId`와
|
||||
@@ -196,7 +213,7 @@ Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검
|
||||
|
||||
- 중복 id와 array/scene/experiment/result 참조
|
||||
- asset 종류와 안전한 상대 경로
|
||||
- Nyquist 및 시간·scan 범위
|
||||
- ideal sampler의 Nyquist alias mapping 및 시간·scan 범위
|
||||
- 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계
|
||||
- map shape와 source peak 매칭
|
||||
- band별 evaluation 파라미터와 허용 metric
|
||||
@@ -204,6 +221,16 @@ Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검
|
||||
|
||||
지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
|
||||
|
||||
Scene tone과 Experiment band는 0–1 MHz 검증 범위 안에서 Nyquist를 넘어도 유효하다. 1 MHz는
|
||||
Nyquist 제한이 아니라 수치 위상 정밀도·마이크 응답·희소 음향 모델의 현재 검증 상한이다.
|
||||
이는 원 신호를 복원한다는 뜻이 아니라
|
||||
anti-alias filter가 없는 이상적 real ADC에서 접힌 bin을 관찰한다는 뜻이다. Result는
|
||||
`signalStats.sampling`과 band별 `frequencyObservation`에 원 주파수, signed ADC 주파수,
|
||||
spectral inversion, fold segment와 선택 bin center를 기록한다. 동일 ADC bin에 접힌 analog
|
||||
후보는 단일 channel의 시간 sample만으로 식별할 수 없다. 다채널 공간 위상에는 원 파장 정보가
|
||||
남을 수 있지만 현재 conventional localizer는 carrier hypothesis를 시도하지 않으며,
|
||||
validation이나 평가도 임의의 후보를 정답으로 선택하지 않는다.
|
||||
|
||||
실측 channel을 받는 portable API는 저장 JSON 계약과 별도로 입력 Array를 같은 규칙으로 검사한다.
|
||||
CPU 분석 계획은 기본 512 MiB, 반복 steering cache는 64 MiB, streaming ring과 분석창은 기본
|
||||
256 MiB hard cap을 할당 전에 적용한다. 채널 순서와 streaming 상태 계약은
|
||||
|
||||
+10
-3
@@ -113,9 +113,15 @@ Scene·Array·Experiment·Assets 준비 상태와 실행 가능 여부를 가까
|
||||
일상적으로 바꾸는 backend, grid, 대역과 peak 감도는 설정 단계에 유지한다. 음속, seed, scan
|
||||
범위, STFT, 음향 환경 preset과 마이크 응답 보상은 **고급 설정**에 묶는다. 장문의 개발 요구나
|
||||
미구현 기능 홍보는 작업 화면에서 제거하고, 물리 모델·데이터 계약·포팅 설명은 문서로 연결한다.
|
||||
잘못된 단위, Nyquist 초과 대역, room 포함 관계와 CPU/GPU·신호 working-set 한도는 실행 전에
|
||||
잘못된 단위, room 포함 관계와 CPU/GPU·신호 working-set 한도는 실행 전에
|
||||
검증한다.
|
||||
|
||||
Nyquist 초과 tone/band는 배열 설계 실패 양상을 관찰할 수 있도록 허용한다. tone은 analog path
|
||||
phase와 microphone response를 먼저 적용한 뒤 ideal ADC에서 sampling하고, band는 모든 등가 ADC
|
||||
bin center로 접는다. 원 carrier를 이용한 숨은 steering이나 결과 선택은 하지 않으며, 연속
|
||||
Nyquist-zone fold·이산 bin 선택·알고리즘별 localization model·동일 ADC band를 Result 1.1에
|
||||
기록한다. 0–1 MHz는 Nyquist cutoff가 아니라 현재 수치/마이크 모델의 검증 범위다.
|
||||
|
||||
## 6. 평가 방법
|
||||
|
||||
점 음원 위치를 image plane으로 투영해 ground-truth heatmap과 위치 목록을 함께 저장한다. 최소
|
||||
@@ -217,8 +223,9 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
|
||||
한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다.
|
||||
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
|
||||
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
|
||||
- 실제 AC와 simulation gap: gain·phase·주파수 응답과 희소 실내 환경을 비교 조건으로 제공하되,
|
||||
위치 공차·self-noise·ADC·clock은 실측 adapter와 현장 검증으로 다룬다.
|
||||
- 실제 AC와 simulation gap: gain·phase·주파수 응답, ideal real ADC sampling/alias와 희소 실내
|
||||
환경을 비교 조건으로 제공하되, 위치 공차·self-noise·ADC quantization/nonlinearity와
|
||||
channel clock jitter/drift는 실측 adapter와 현장 검증으로 다룬다.
|
||||
- post-processing이 기본 결과를 가림: 알고리즘별 raw power/score map과 후처리 map을 별도로 보존한다.
|
||||
|
||||
## 10. 구현 결과와 다음 백로그
|
||||
|
||||
+1
-1
@@ -269,7 +269,7 @@
|
||||
</details>
|
||||
<details>
|
||||
<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>
|
||||
<summary>Assets가 준비되지 않습니다.</summary>
|
||||
|
||||
@@ -167,7 +167,10 @@ const plan = createLocalizationPlan({
|
||||
주요 검증 조건은 다음과 같다.
|
||||
|
||||
- `fftSize`는 2 이상의 2의 거듭제곱이고 `hopSize <= fftSize`
|
||||
- band는 하나 이상이며 `0 <= minHz < maxHz < sampleRate / 2`
|
||||
- band는 하나 이상이며 `0 <= minHz < maxHz`. Nyquist 위 구간은 anti-alias filter 없는 ideal
|
||||
real ADC의 등가 baseband bin으로 접힌다. 단일 channel 시간 sample은 원 alias order를
|
||||
복원하지 못하지만 다채널 공간 위상에는 원 파장의 증거가 남을 수 있다. 현재 portable
|
||||
localizer는 carrier hypothesis를 열거하지 않는다.
|
||||
- scan의 `columns`와 `rows`는 양의 정수
|
||||
- `speedOfSound`는 양수
|
||||
- `normalization`은 `peak` 또는 `none`
|
||||
|
||||
@@ -8,6 +8,25 @@
|
||||
- radix-2 FFT, Hann STFT와 선택 bin 추출
|
||||
- scan grid 거리 cache 및 DAS·DAS-PHAT·GCC-PHAT 위치 추정
|
||||
|
||||
## Nyquist 초과 입력
|
||||
|
||||
`sampling.js`는 anti-alias filter 없는 이상적 real sampler의 signed alias와 band-to-FFT-bin
|
||||
mapping을 제공한다. tone 전파는 먼저 원 analog 주파수에서 path delay와 microphone response를
|
||||
적용하고 그 뒤 ADC 시각에서 sampling한다. 따라서 시간축에는 alias가 나타나지만 센서 간 위상은
|
||||
원 파장의 물리를 유지한다.
|
||||
|
||||
Nyquist 위 band는 모든 등가 ADC FFT bin center로 접어 관찰한다. 연속 fold segment와 이산
|
||||
bin-center membership은 별도 metadata다. DAS 계열은 양의 ADC bin 주파수로 steering하고
|
||||
GCC-PHAT는 선택 bin의 lag correlation을 사용하며, 원 carrier를 몰래 추론하거나 peak를
|
||||
고르지 않는다. 단일 channel 시간축에서 같은 sequence로 접혀도 다채널 공간 위상에는 원 파장의
|
||||
증거가 남을 수 있다. 현재 localizer는 carrier hypothesis를 열거하지 않으므로 Result에
|
||||
`aliasOrderResolution: not-attempted`로 남긴다. asset/noise 입력은 이미 sampling된 baseband로
|
||||
취급한다.
|
||||
|
||||
Tone과 band의 검증 범위는 0–1 MHz다. 이는 Nyquist cutoff가 아니라 현재 IEEE-754 위상 계산,
|
||||
마이크 응답과 희소 경로 모델의 명시적 정밀도 범위다. public sampling helper는 비정상적으로
|
||||
작은 sample rate에서도 최대 513개 fold segment를 넘기 전에 거부한다.
|
||||
|
||||
## 위치 추정 알고리즘
|
||||
|
||||
`localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 세 경로를
|
||||
@@ -157,9 +176,23 @@ scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전
|
||||
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
|
||||
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
|
||||
|
||||
시간창은 활성 source만 잘라 짧은 Scene으로 만들지만 `signalOffsets`에 원래 scene/source의 절대 sample
|
||||
offset을 보존합니다. 이를 `synthesizeMicrophoneSignals`에 전달하면 각 창을 따로 합성해도 tone phase,
|
||||
seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline 합성과 연속됩니다.
|
||||
시간창은 요청 출력 시작보다 앞선 신호를 현재 array·음향 환경의 최대 경로 지연만큼 pre-roll합니다.
|
||||
따라서 창이 시작되기 전에 발음이 끝난 source라도 direct·reflection·scattering·diffraction·diffuse
|
||||
경로가 창 안에 도착하면 제거하지 않습니다. `signalOffsets`는 원래 scene/source의 절대 sample
|
||||
offset과 출력 crop을 함께 보존합니다. `synthesizeMicrophoneSignals`는 pre-roll 버퍼에 모든 경로를
|
||||
적용한 뒤 요청 구간만 반환하므로 tone phase, seed 기반 noise index, audio asset 재생 위치와
|
||||
ambient가 전체 timeline과 같은 절대 시간축을 사용합니다.
|
||||
|
||||
이 보장은 현재 희소 경로 모델 안에서의 **전파 지연 tail**에 관한 것입니다. quasi-static 창은 pre-roll
|
||||
구간까지 포함해 source를 출력 창 중앙의 한 위치에 고정하며, retarded-time trajectory, 창 안의 연속
|
||||
이동과 Doppler는 계산하지 않습니다. 합성 결과의 `windowing` metadata는 적용한 pre-roll, 출력 절대
|
||||
sample, `source-position-fixed-at-window-midpoint` geometry와 `dopplerMode: not-modeled`를 기록합니다.
|
||||
`sourceArrivals`는 source별 방사·도착 sample 경계와 출력에 겹친 path kind를,
|
||||
`arrivingSourceIds`는 적어도 한 실제 path의 half-open 도착 구간이 출력과 겹친 source를 기록합니다.
|
||||
이는 사후 ground-truth 평가에만 쓰이며 위치 추정 입력에는 전달하지 않습니다.
|
||||
sampled asset/noise의 fractional delay와 microphone response는 기존 zero-padded block FFT 모델을
|
||||
그대로 사용하므로 임의 길이 analog microphone impulse response의 streaming state를 재현하는 모델은
|
||||
아닙니다.
|
||||
|
||||
## Beam spread 의미
|
||||
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
export const TAU = Math.PI * 2;
|
||||
|
||||
/**
|
||||
* Ceil a sample position while keeping an analytically integral boundary on
|
||||
* that integer. IEEE-754 roundoff from expressions such as (0.1 + 0.2) * 1000
|
||||
* must not add a physically nonexistent sample of latency.
|
||||
*/
|
||||
export function ceilSamplePosition(samplePosition) {
|
||||
if (!Number.isFinite(samplePosition)) {
|
||||
throw new TypeError("sample position must be finite.");
|
||||
}
|
||||
const nearestInteger = Math.round(samplePosition);
|
||||
const tolerance =
|
||||
16 * Number.EPSILON * Math.max(1, Math.abs(samplePosition));
|
||||
const result = Math.abs(samplePosition - nearestInteger) <= tolerance
|
||||
? nearestInteger
|
||||
: Math.ceil(samplePosition);
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new RangeError("sample position exceeds the safe integer range.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ceilSampleIndex(timeSeconds, sampleRate) {
|
||||
if (!Number.isFinite(timeSeconds)) throw new TypeError("time must be finite.");
|
||||
if (!(sampleRate > 0) || !Number.isFinite(sampleRate)) {
|
||||
throw new TypeError("sampleRate must be finite and positive.");
|
||||
}
|
||||
return ceilSamplePosition(timeSeconds * sampleRate);
|
||||
}
|
||||
|
||||
export function distance3(a, b) {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { dbToLinear, distance3 } from "./math.js";
|
||||
import { interpolateFrequencyResponse } from "./frequency-response.js";
|
||||
import {
|
||||
ceilSampleIndex,
|
||||
ceilSamplePosition,
|
||||
dbToLinear,
|
||||
distance3,
|
||||
TAU,
|
||||
} from "./math.js";
|
||||
import {
|
||||
addFrequencyDomainDelay,
|
||||
addFrequencyDomainPaths,
|
||||
@@ -6,8 +13,9 @@ import {
|
||||
nextPowerOfTwo,
|
||||
prepareDelaySpectrum,
|
||||
} from "./fractional-delay.js";
|
||||
import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js";
|
||||
import { buildAcousticPaths } from "./environment.js";
|
||||
import { renderSignal } from "./signal.js";
|
||||
import { describeFrequencyAliasing } from "./sampling.js";
|
||||
|
||||
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||
return distance3(sourcePosition, receiverPosition) / speedOfSound;
|
||||
@@ -17,6 +25,167 @@ export function sphericalAttenuation(distance, referenceDistance = 1, minimumDis
|
||||
return referenceDistance / Math.max(distance, minimumDistance);
|
||||
}
|
||||
|
||||
function addToneEvent(
|
||||
events,
|
||||
sampleIndex,
|
||||
sineDelta,
|
||||
cosineDelta,
|
||||
activePathDelta,
|
||||
) {
|
||||
const event = events.get(sampleIndex);
|
||||
if (event) {
|
||||
event.sine += sineDelta;
|
||||
event.cosine += cosineDelta;
|
||||
event.activePaths += activePathDelta;
|
||||
} else {
|
||||
events.set(sampleIndex, {
|
||||
sine: sineDelta,
|
||||
cosine: cosineDelta,
|
||||
activePaths: activePathDelta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function carrierAtSample(signedFrequencyHz, sampleRate, absoluteSampleIndex) {
|
||||
const cycles = (signedFrequencyHz * absoluteSampleIndex) / sampleRate;
|
||||
const fractionalCycles = cycles - Math.floor(cycles);
|
||||
const phase = TAU * fractionalCycles;
|
||||
return { cosine: Math.cos(phase), sine: Math.sin(phase) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply propagation and microphone response to an analog tone before sampling.
|
||||
*
|
||||
* This order is essential above Nyquist: temporal samples fold to the ADC
|
||||
* alias, while inter-microphone phase still comes from the original acoustic
|
||||
* frequency and path delay.
|
||||
*/
|
||||
function addSampledAnalogTonePaths(options) {
|
||||
const {
|
||||
output,
|
||||
entry,
|
||||
scene,
|
||||
microphone,
|
||||
paths,
|
||||
timeMapping = {},
|
||||
} = options;
|
||||
const signal = entry.signal;
|
||||
const frequencyHz = signal.frequency;
|
||||
const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate);
|
||||
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
||||
const signalStartTime = timeMapping.signalStartTime ?? entry.startTime ?? 0;
|
||||
const signalDuration = timeMapping.signalDuration ?? entry.duration ?? scene.duration;
|
||||
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||
}
|
||||
if (!Number.isFinite(signalStartTime)) {
|
||||
throw new TypeError("signalStartTime must be finite.");
|
||||
}
|
||||
if (!Number.isFinite(signalDuration) || signalDuration <= 0) {
|
||||
throw new RangeError("signalDuration must be a positive finite number.");
|
||||
}
|
||||
const sourcePhase = ((signal.phaseDeg ?? 0) * Math.PI) / 180;
|
||||
const response = interpolateFrequencyResponse(
|
||||
microphone.frequencyResponse,
|
||||
frequencyHz,
|
||||
{},
|
||||
);
|
||||
const microphonePhase =
|
||||
((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180 + response.phaseRadians;
|
||||
const baseGain =
|
||||
dbToLinear(entry.gainDb ?? 0) *
|
||||
dbToLinear(microphone.sensitivityDb ?? 0) *
|
||||
response.linearGain;
|
||||
const events = new Map();
|
||||
|
||||
for (const path of paths) {
|
||||
const firstSample = Math.max(
|
||||
0,
|
||||
ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) -
|
||||
sceneSampleOffset,
|
||||
);
|
||||
const lastSample = Math.min(
|
||||
output.length,
|
||||
ceilSampleIndex(
|
||||
signalStartTime + signalDuration + path.delaySeconds,
|
||||
scene.sampleRate,
|
||||
) - sceneSampleOffset,
|
||||
);
|
||||
if (firstSample >= lastSample) continue;
|
||||
const amplitude = baseGain * path.gain;
|
||||
const delayedCycles = (frequencyHz * (signalStartTime + path.delaySeconds)) % 1;
|
||||
const phase = sourcePhase + microphonePhase - TAU * delayedCycles;
|
||||
const sineCoefficient = amplitude * Math.cos(phase);
|
||||
const cosineCoefficient = amplitude * Math.sin(phase);
|
||||
addToneEvent(events, firstSample, sineCoefficient, cosineCoefficient, 1);
|
||||
addToneEvent(events, lastSample, -sineCoefficient, -cosineCoefficient, -1);
|
||||
}
|
||||
if (events.size === 0) return;
|
||||
|
||||
const orderedEvents = [...events.entries()].sort((left, right) => left[0] - right[0]);
|
||||
const phaseStep =
|
||||
(TAU * sampling.signedAdcFrequencyHz) / scene.sampleRate;
|
||||
const stepReal = Math.cos(phaseStep);
|
||||
const stepImaginary = Math.sin(phaseStep);
|
||||
const firstSample = orderedEvents[0][0];
|
||||
const finalEventSample = orderedEvents.at(-1)[0];
|
||||
const initialCarrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + firstSample,
|
||||
);
|
||||
let carrierCosine = initialCarrier.cosine;
|
||||
let carrierSine = initialCarrier.sine;
|
||||
let sineCoefficient = 0;
|
||||
let cosineCoefficient = 0;
|
||||
let activePathCount = 0;
|
||||
let eventIndex = 0;
|
||||
const phaseReanchorInterval = 1024;
|
||||
let sampleIndex = firstSample;
|
||||
while (sampleIndex < output.length && sampleIndex < finalEventSample) {
|
||||
while (
|
||||
eventIndex < orderedEvents.length &&
|
||||
orderedEvents[eventIndex][0] === sampleIndex
|
||||
) {
|
||||
sineCoefficient += orderedEvents[eventIndex][1].sine;
|
||||
cosineCoefficient += orderedEvents[eventIndex][1].cosine;
|
||||
activePathCount += orderedEvents[eventIndex][1].activePaths;
|
||||
eventIndex += 1;
|
||||
}
|
||||
if (activePathCount === 0 && eventIndex < orderedEvents.length) {
|
||||
sampleIndex = orderedEvents[eventIndex][0];
|
||||
const carrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + sampleIndex,
|
||||
);
|
||||
carrierCosine = carrier.cosine;
|
||||
carrierSine = carrier.sine;
|
||||
continue;
|
||||
}
|
||||
output[sampleIndex] +=
|
||||
carrierSine * sineCoefficient + carrierCosine * cosineCoefficient;
|
||||
|
||||
const nextSample = sampleIndex + 1;
|
||||
if (nextSample % phaseReanchorInterval === 0) {
|
||||
const carrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + nextSample,
|
||||
);
|
||||
carrierCosine = carrier.cosine;
|
||||
carrierSine = carrier.sine;
|
||||
} else {
|
||||
const nextCosine =
|
||||
carrierCosine * stepReal - carrierSine * stepImaginary;
|
||||
carrierSine =
|
||||
carrierSine * stepReal + carrierCosine * stepImaginary;
|
||||
carrierCosine = nextCosine;
|
||||
}
|
||||
sampleIndex = nextSample;
|
||||
}
|
||||
}
|
||||
|
||||
export function synthesizeMicrophoneSignals(options) {
|
||||
const {
|
||||
scene,
|
||||
@@ -27,39 +196,27 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
maximumBufferBytes = 512 * 1024 * 1024,
|
||||
signalOffsets,
|
||||
} = options;
|
||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
|
||||
const maximumDelaySamples =
|
||||
maximumAcousticDelaySeconds({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
}) * scene.sampleRate;
|
||||
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
|
||||
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
||||
const estimatedBufferBytes =
|
||||
retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + delayWorkingBytes;
|
||||
if (estimatedBufferBytes > maximumBufferBytes) {
|
||||
throw new Error(
|
||||
`예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount));
|
||||
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
|
||||
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
|
||||
const renderedSources = scene.sources.map((source, sourceIndex) => ({
|
||||
definition: source,
|
||||
samples: renderSignal(source, scene, assetBuffers, 0, {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const acousticPathCounts = {};
|
||||
let acousticPathCount = 0;
|
||||
renderedSources.forEach(({ definition, samples }) => {
|
||||
const microphonePaths = arrayDefinition.microphones.map((microphone) =>
|
||||
const cropStartSample = signalOffsets?.cropStartSample ?? 0;
|
||||
const cropSampleCount = signalOffsets?.cropSampleCount ?? sampleCount;
|
||||
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(cropStartSample) ||
|
||||
cropStartSample < 0 ||
|
||||
!Number.isSafeInteger(cropSampleCount) ||
|
||||
cropSampleCount <= 0 ||
|
||||
cropStartSample + cropSampleCount > sampleCount
|
||||
) {
|
||||
throw new RangeError("signal output crop must be a non-empty range inside the synthesis buffer.");
|
||||
}
|
||||
const cropEndSample = cropStartSample + cropSampleCount;
|
||||
const needsOutputCrop = cropStartSample !== 0 || cropSampleCount !== sampleCount;
|
||||
const sourcePathSets = scene.sources.map((definition) => ({
|
||||
definition,
|
||||
microphonePaths: arrayDefinition.microphones.map((microphone) =>
|
||||
buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: definition.position,
|
||||
@@ -69,7 +226,117 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const acousticPathCounts = {};
|
||||
let acousticPathCount = 0;
|
||||
let maximumDelaySamples = 0;
|
||||
for (const { microphonePaths } of sourcePathSets) {
|
||||
for (const paths of microphonePaths) {
|
||||
for (const path of paths) {
|
||||
acousticPathCount += 1;
|
||||
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
|
||||
maximumDelaySamples = Math.max(
|
||||
maximumDelaySamples,
|
||||
path.delaySeconds * scene.sampleRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sampledSourceCount = scene.sources
|
||||
.filter((source) => source.signal.type !== "tone")
|
||||
.length;
|
||||
const hasSampledAmbient = (scene.ambient ?? [])
|
||||
.some((ambient) => ambient.signal.type !== "tone");
|
||||
const retainedBufferCount =
|
||||
arrayDefinition.microphones.length +
|
||||
sampledSourceCount +
|
||||
(hasSampledAmbient ? 1 : 0);
|
||||
const delayFftSize = nextPowerOfTwo(
|
||||
sampleCount + ceilSamplePosition(maximumDelaySamples) + 2,
|
||||
);
|
||||
const delayWorkingBytes =
|
||||
sampledSourceCount > 0 || hasSampledAmbient
|
||||
? delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4
|
||||
: 0;
|
||||
const outputCropBytes = needsOutputCrop
|
||||
? arrayDefinition.microphones.length * cropSampleCount * Float32Array.BYTES_PER_ELEMENT
|
||||
: 0;
|
||||
const estimatedBufferBytes =
|
||||
retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT +
|
||||
delayWorkingBytes +
|
||||
outputCropBytes;
|
||||
if (estimatedBufferBytes > maximumBufferBytes) {
|
||||
throw new Error(
|
||||
`예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
const synthesisChannels = arrayDefinition.microphones.map(
|
||||
() => new Float32Array(sampleCount),
|
||||
);
|
||||
const renderedSources = sourcePathSets.map(({ definition, microphonePaths }, sourceIndex) => ({
|
||||
definition,
|
||||
microphonePaths,
|
||||
timeMapping: {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||
},
|
||||
...(definition.signal.type === "tone"
|
||||
? {}
|
||||
: {
|
||||
samples: renderSignal(definition, scene, assetBuffers, 0, {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
const outputStartSample = sceneSampleOffset + cropStartSample;
|
||||
const outputEndSample = outputStartSample + cropSampleCount;
|
||||
const sourceArrivals = renderedSources.map(({ definition, microphonePaths, timeMapping }) => {
|
||||
const emissionStartTime = timeMapping.signalStartTime ?? definition.startTime ?? 0;
|
||||
const emissionDuration = timeMapping.signalDuration ?? definition.duration ?? scene.duration;
|
||||
let earliestArrivalSample = Infinity;
|
||||
let latestArrivalEndSample = -Infinity;
|
||||
let overlapsOutput = false;
|
||||
const overlappingPathKinds = new Set();
|
||||
for (const paths of microphonePaths) {
|
||||
for (const path of paths) {
|
||||
const arrivalStartSample = ceilSampleIndex(
|
||||
emissionStartTime + path.delaySeconds,
|
||||
scene.sampleRate,
|
||||
);
|
||||
const arrivalEndSample = ceilSampleIndex(
|
||||
emissionStartTime + emissionDuration + path.delaySeconds,
|
||||
scene.sampleRate,
|
||||
);
|
||||
earliestArrivalSample = Math.min(earliestArrivalSample, arrivalStartSample);
|
||||
latestArrivalEndSample = Math.max(latestArrivalEndSample, arrivalEndSample);
|
||||
if (arrivalStartSample < outputEndSample && arrivalEndSample > outputStartSample) {
|
||||
overlapsOutput = true;
|
||||
overlappingPathKinds.add(path.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
sourceId: definition.id,
|
||||
emissionStartSample: ceilSampleIndex(emissionStartTime, scene.sampleRate),
|
||||
emissionEndSample: ceilSampleIndex(
|
||||
emissionStartTime + emissionDuration,
|
||||
scene.sampleRate,
|
||||
),
|
||||
earliestArrivalSample: Number.isFinite(earliestArrivalSample)
|
||||
? earliestArrivalSample
|
||||
: null,
|
||||
latestArrivalEndSample: Number.isFinite(latestArrivalEndSample)
|
||||
? latestArrivalEndSample
|
||||
: null,
|
||||
overlapsOutput,
|
||||
overlappingPathKinds: [...overlappingPathKinds].sort(),
|
||||
};
|
||||
});
|
||||
|
||||
renderedSources.forEach(({ definition, microphonePaths, samples, timeMapping }) => {
|
||||
let sourceMaximumDelaySamples = 0;
|
||||
for (const paths of microphonePaths) {
|
||||
for (const path of paths) {
|
||||
@@ -79,17 +346,27 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
);
|
||||
}
|
||||
}
|
||||
const spectrum = prepareDelaySpectrum(samples, sourceMaximumDelaySamples);
|
||||
const workspace = createDelayWorkspace(spectrum.fftSize);
|
||||
const spectrum = samples
|
||||
? prepareDelaySpectrum(samples, sourceMaximumDelaySamples)
|
||||
: undefined;
|
||||
const workspace = spectrum
|
||||
? createDelayWorkspace(spectrum.fftSize)
|
||||
: undefined;
|
||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||
const paths = microphonePaths[microphoneIndex];
|
||||
for (const path of paths) {
|
||||
acousticPathCount += 1;
|
||||
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
|
||||
}
|
||||
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
||||
if (definition.signal.type === "tone") {
|
||||
addSampledAnalogTonePaths({
|
||||
output: synthesisChannels[microphoneIndex],
|
||||
entry: definition,
|
||||
scene,
|
||||
microphone,
|
||||
paths,
|
||||
timeMapping,
|
||||
});
|
||||
} else {
|
||||
addFrequencyDomainPaths({
|
||||
output: channels[microphoneIndex],
|
||||
output: synthesisChannels[microphoneIndex],
|
||||
spectrum,
|
||||
paths: paths.map((path) => ({
|
||||
delaySamples: path.delaySeconds * scene.sampleRate,
|
||||
@@ -101,17 +378,32 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
sampleRate: scene.sampleRate,
|
||||
workspace,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (Array.isArray(scene.ambient)) {
|
||||
scene.ambient.forEach((ambient, ambientIndex) => {
|
||||
channels.forEach((channel, microphoneIndex) => {
|
||||
synthesisChannels.forEach((channel, microphoneIndex) => {
|
||||
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
||||
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, {
|
||||
const ambientTimeMapping = {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.ambient?.[ambientIndex] ?? {}),
|
||||
};
|
||||
if (ambient.signal.type === "tone") {
|
||||
addSampledAnalogTonePaths({
|
||||
output: channel,
|
||||
entry: ambient,
|
||||
scene,
|
||||
microphone,
|
||||
paths: [{ delaySeconds: 0, gain: 1 }],
|
||||
timeMapping: ambientTimeMapping,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
||||
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, {
|
||||
...ambientTimeMapping,
|
||||
});
|
||||
const spectrum = prepareDelaySpectrum(ambientSamples, 0);
|
||||
addFrequencyDomainDelay({
|
||||
@@ -128,6 +420,10 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
});
|
||||
}
|
||||
|
||||
const channels = needsOutputCrop
|
||||
? synthesisChannels.map((channel) => channel.slice(cropStartSample, cropEndSample))
|
||||
: synthesisChannels;
|
||||
|
||||
let preNormalizationPeak = 0;
|
||||
for (const channel of channels) {
|
||||
for (const value of channel) {
|
||||
@@ -159,5 +455,49 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
acousticModel: scene.acoustics?.model ?? "free-field",
|
||||
acousticPathCount,
|
||||
acousticPathCounts,
|
||||
windowing: {
|
||||
model: signalOffsets?.windowingModel ?? "whole-scene",
|
||||
renderStartSample: sceneSampleOffset,
|
||||
outputStartSample,
|
||||
outputSampleCount: cropSampleCount,
|
||||
preRollSamples: cropStartSample,
|
||||
activePathMaximumDelaySeconds: maximumDelaySamples / scene.sampleRate,
|
||||
requestedMaximumPropagationDelaySeconds:
|
||||
signalOffsets?.maximumPropagationDelaySeconds ?? maximumDelaySamples / scene.sampleRate,
|
||||
geometryModel: Number.isFinite(signalOffsets?.sourcePositionSampleTime)
|
||||
? "source-position-fixed-at-window-midpoint"
|
||||
: "static-source-position",
|
||||
sourcePositionSampleTime: signalOffsets?.sourcePositionSampleTime ?? null,
|
||||
dopplerMode: "not-modeled",
|
||||
delayedTailPolicy: needsOutputCrop
|
||||
? "propagation-preroll-then-output-crop"
|
||||
: "whole-render-buffer",
|
||||
arrivalOverlapModel: "path-gated-half-open-sample-interval",
|
||||
arrivingSourceIds: sourceArrivals
|
||||
.filter(({ overlapsOutput }) => overlapsOutput)
|
||||
.map(({ sourceId }) => sourceId),
|
||||
sourceArrivals,
|
||||
},
|
||||
sampling: {
|
||||
model: "ideal-real-sampler-no-antialias-filter",
|
||||
sampleRate: scene.sampleRate,
|
||||
nyquistHz: scene.sampleRate / 2,
|
||||
toneObservations: [
|
||||
...scene.sources
|
||||
.filter((source) => source.signal.type === "tone")
|
||||
.map((source) => ({
|
||||
scope: "source",
|
||||
id: source.id,
|
||||
...describeFrequencyAliasing(source.signal.frequency, scene.sampleRate),
|
||||
})),
|
||||
...(scene.ambient ?? [])
|
||||
.filter((ambient) => ambient.signal.type === "tone")
|
||||
.map((ambient) => ({
|
||||
scope: "ambient",
|
||||
id: ambient.id,
|
||||
...describeFrequencyAliasing(ambient.signal.frequency, scene.sampleRate),
|
||||
})),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,20 +152,18 @@ function copyScan(scan) {
|
||||
return Object.freeze({ ...scan, columns, rows });
|
||||
}
|
||||
|
||||
function copyBands(bands, sampleRate) {
|
||||
function copyBands(bands) {
|
||||
if (!Array.isArray(bands) || bands.length === 0) {
|
||||
throw new TypeError("bands must be a non-empty array.");
|
||||
}
|
||||
const nyquist = sampleRate / 2;
|
||||
return Object.freeze(bands.map((band, index) => {
|
||||
if (
|
||||
!Number.isFinite(band?.minHz) ||
|
||||
!Number.isFinite(band?.maxHz) ||
|
||||
band.minHz < 0 ||
|
||||
band.maxHz <= band.minHz ||
|
||||
band.maxHz >= nyquist
|
||||
band.maxHz <= band.minHz
|
||||
) {
|
||||
throw new RangeError(`bands[${index}] must lie inside 0–${nyquist} Hz.`);
|
||||
throw new RangeError(`bands[${index}] must satisfy 0 <= minHz < maxHz.`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...band,
|
||||
@@ -285,7 +283,7 @@ export function createLocalizationPlan(config) {
|
||||
const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition);
|
||||
const scan = copyScan(config.scan);
|
||||
const peakDetection = copyPeakDetection(config.peakDetection, scan);
|
||||
const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate);
|
||||
const frequencyBands = copyBands(config.bands ?? config.frequencyBands);
|
||||
const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize");
|
||||
const hopSize = requirePositiveInteger(
|
||||
config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2,
|
||||
|
||||
@@ -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";
|
||||
|
||||
function sampleAsset(signal, assetBuffers, time) {
|
||||
@@ -25,16 +25,20 @@ function sampleAsset(signal, assetBuffers, time) {
|
||||
* Omitting it keeps the historical whole-scene behaviour exactly unchanged.
|
||||
*/
|
||||
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0, timeMapping = {}) {
|
||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
|
||||
const output = new Float32Array(sampleCount);
|
||||
const startTime = entry.startTime ?? 0;
|
||||
const duration = entry.duration ?? scene.duration;
|
||||
const gain = dbToLinear(entry.gainDb ?? 0);
|
||||
const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate));
|
||||
const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate));
|
||||
const startSample = Math.max(0, ceilSampleIndex(startTime, scene.sampleRate));
|
||||
const endSample = Math.min(
|
||||
sampleCount,
|
||||
ceilSampleIndex(startTime + duration, scene.sampleRate),
|
||||
);
|
||||
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
||||
const signalStartTime = timeMapping.signalStartTime ?? startTime;
|
||||
const signalStartSample = timeMapping.signalStartSample ?? Math.ceil(startTime * scene.sampleRate);
|
||||
const signalStartSample =
|
||||
timeMapping.signalStartSample ?? ceilSampleIndex(startTime, scene.sampleRate);
|
||||
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fftInPlace } from "./fft.js";
|
||||
import { cooperativeYield } from "./cooperative-yield.js";
|
||||
import { observeFrequencyBand } from "./sampling.js";
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) {
|
||||
@@ -119,11 +120,5 @@ export async function extractStftBins(channels, options) {
|
||||
}
|
||||
|
||||
export function binsForBand(band, sampleRate, fftSize) {
|
||||
const binWidth = sampleRate / fftSize;
|
||||
const firstBin = Math.max(0, Math.ceil(band.minHz / binWidth));
|
||||
const lastBin = Math.min(fftSize / 2, Math.floor(band.maxHz / binWidth));
|
||||
if (firstBin > lastBin) {
|
||||
throw new Error(`${band.minHz}–${band.maxHz} Hz에 해당하는 FFT bin이 없습니다.`);
|
||||
}
|
||||
return Array.from({ length: lastBin - firstBin + 1 }, (_, index) => firstBin + index);
|
||||
return observeFrequencyBand(band, sampleRate, fftSize).bins;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { maximumAcousticDelaySeconds } from "./environment.js";
|
||||
import { ceilSampleIndex } from "./math.js";
|
||||
|
||||
function copyPosition(position) {
|
||||
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
|
||||
throw new TypeError("source position must be a finite [x, y, z] array.");
|
||||
@@ -65,7 +68,7 @@ export function sceneAtTime(scene, time) {
|
||||
* Approximate a moving scene as stationary during [startTime, endTime] by
|
||||
* sampling every trajectory at the window midpoint.
|
||||
*/
|
||||
export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
||||
export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {}) {
|
||||
requireFiniteTime(startTime, "startTime");
|
||||
requireFiniteTime(endTime, "endTime");
|
||||
if (startTime < 0 || endTime <= startTime) {
|
||||
@@ -90,42 +93,77 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
||||
// boundary. Adjacent windows therefore share an exact boundary without a
|
||||
// duplicated or omitted sample, even when their requested times are not
|
||||
// integer multiples of the sample period.
|
||||
const startSample = Math.ceil(startTime * sampleRate);
|
||||
const endSample = Math.ceil(endTime * sampleRate);
|
||||
const startSample = ceilSampleIndex(startTime, sampleRate);
|
||||
const endSample = ceilSampleIndex(endTime, sampleRate);
|
||||
if (endSample <= startSample) {
|
||||
throw new RangeError("window must contain at least one sample.");
|
||||
}
|
||||
const alignedStartTime = startSample / sampleRate;
|
||||
const alignedEndTime = endSample / sampleRate;
|
||||
const sampleTime = (startSample + endSample) / (2 * sampleRate);
|
||||
const sampledScene = sceneAtTime(scene, sampleTime);
|
||||
let maximumPropagationDelaySeconds = options.maximumPropagationDelaySeconds;
|
||||
if (maximumPropagationDelaySeconds === undefined) {
|
||||
maximumPropagationDelaySeconds = options.arrayDefinition
|
||||
? maximumAcousticDelaySeconds({
|
||||
scene: sampledScene,
|
||||
arrayDefinition: options.arrayDefinition,
|
||||
referenceDistance: options.referenceDistance ?? 1,
|
||||
minimumDistance: options.minimumDistance ?? 0.01,
|
||||
})
|
||||
: 0;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(maximumPropagationDelaySeconds) ||
|
||||
maximumPropagationDelaySeconds < 0
|
||||
) {
|
||||
throw new RangeError("maximumPropagationDelaySeconds must be finite and non-negative.");
|
||||
}
|
||||
const preRollSamples = ceilSampleIndex(maximumPropagationDelaySeconds, sampleRate);
|
||||
const renderStartSample = Math.max(0, startSample - preRollSamples);
|
||||
const sources = [];
|
||||
const sourceOffsets = [];
|
||||
|
||||
for (const source of scene.sources) {
|
||||
for (let sourceIndex = 0; sourceIndex < scene.sources.length; sourceIndex += 1) {
|
||||
const source = scene.sources[sourceIndex];
|
||||
const signalStartTime = source.startTime ?? 0;
|
||||
const signalDuration = source.duration ?? scene.duration;
|
||||
const signalStartSample = Math.ceil(signalStartTime * sampleRate);
|
||||
const signalEndSample = Math.ceil((signalStartTime + signalDuration) * sampleRate);
|
||||
const activeStartSample = Math.max(startSample, signalStartSample);
|
||||
const signalStartSample = ceilSampleIndex(signalStartTime, sampleRate);
|
||||
const signalEndSample = ceilSampleIndex(signalStartTime + signalDuration, sampleRate);
|
||||
const activeStartSample = Math.max(renderStartSample, signalStartSample);
|
||||
const activeEndSample = Math.min(endSample, signalEndSample);
|
||||
if (activeEndSample <= activeStartSample) continue;
|
||||
|
||||
sources.push({
|
||||
...source,
|
||||
position: sourcePositionAtTime(source, sampleTime),
|
||||
startTime: (activeStartSample - startSample) / sampleRate,
|
||||
position: sampledScene.sources[sourceIndex].position,
|
||||
startTime: (activeStartSample - renderStartSample) / sampleRate,
|
||||
duration: (activeEndSample - activeStartSample) / sampleRate,
|
||||
});
|
||||
sourceOffsets.push({ signalStartTime, signalStartSample });
|
||||
sourceOffsets.push({
|
||||
signalStartTime,
|
||||
signalStartSample,
|
||||
signalDuration,
|
||||
});
|
||||
}
|
||||
|
||||
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
|
||||
const signalOffsets = {
|
||||
sceneSampleOffset: startSample,
|
||||
sceneSampleOffset: renderStartSample,
|
||||
cropStartSample: startSample - renderStartSample,
|
||||
cropSampleCount: endSample - startSample,
|
||||
maximumPropagationDelaySeconds,
|
||||
sourcePositionSampleTime: sampleTime,
|
||||
windowingModel: "quasi-static-midpoint-propagation-preroll",
|
||||
sources: sourceOffsets,
|
||||
ambient: (scene.ambient ?? []).map(() => ({ signalStartTime: 0, signalStartSample: 0 })),
|
||||
ambient: (scene.ambient ?? []).map(() => ({
|
||||
signalStartTime: 0,
|
||||
signalStartSample: 0,
|
||||
signalDuration: scene.duration,
|
||||
})),
|
||||
};
|
||||
const duration = (endSample - startSample) / sampleRate;
|
||||
const renderDuration = (endSample - renderStartSample) / sampleRate;
|
||||
return {
|
||||
startTime: alignedStartTime,
|
||||
endTime: alignedEndTime,
|
||||
@@ -134,10 +172,14 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
|
||||
endSample,
|
||||
sampleCount: endSample - startSample,
|
||||
duration,
|
||||
renderStartSample,
|
||||
renderDuration,
|
||||
preRollSamples: startSample - renderStartSample,
|
||||
maximumPropagationDelaySeconds,
|
||||
signalOffsets,
|
||||
scene: {
|
||||
...scene,
|
||||
duration,
|
||||
duration: renderDuration,
|
||||
sources,
|
||||
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
||||
},
|
||||
@@ -170,7 +212,7 @@ export function createQuasiStaticWindows(scene, options = {}) {
|
||||
const windows = [];
|
||||
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
|
||||
const windowEnd = Math.min(windowStart + windowDuration, endTime);
|
||||
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd));
|
||||
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd, options));
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# Contracts
|
||||
|
||||
네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제
|
||||
JSON Schema를 실행하고, `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼
|
||||
JSON Schema를 실행하고, `validation.js`는 중복 id, 시간 구간, 참조 무결성처럼
|
||||
스키마만으로 표현하기 불편한 조건을 추가 검사합니다.
|
||||
|
||||
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
|
||||
Nyquist 초과 tone과 Experiment band는 거절하지 않습니다. acoustics core가 anti-alias filter 없는
|
||||
ideal ADC alias를 명시적으로 계산하고 Result에 원/관측 주파수와 선택 bin을 기록합니다.
|
||||
|
||||
Array·Scene·Experiment 계약 버전은
|
||||
`INPUT_SCHEMA_VERSION = "1.0.0"`, sampling provenance가 필수인 Result 계약은
|
||||
`RESULT_SCHEMA_VERSION = "1.1.0"`입니다. 두 버전은 `validation.js`에서 별도 상수로
|
||||
공개합니다. 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
|
||||
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
|
||||
|
||||
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { validateDocumentStructure } from "./schema-validator.js";
|
||||
import {
|
||||
describeFrequencyAliasing,
|
||||
MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||||
observeFrequencyBand,
|
||||
} from "../acoustics-core/sampling.js";
|
||||
|
||||
export const SCHEMA_VERSION = "1.0.0";
|
||||
export const INPUT_SCHEMA_VERSION = "1.0.0";
|
||||
export const RESULT_SCHEMA_VERSION = "1.1.0";
|
||||
const MAX_MICROPHONES = 1024;
|
||||
const MAX_SOURCES = 32;
|
||||
const MAX_GRID_SIDE = 512;
|
||||
@@ -18,6 +24,30 @@ function isFiniteNumber(value) {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function samplingMetadataEqual(actual, expected) {
|
||||
if (typeof actual === "number" && typeof expected === "number") {
|
||||
const tolerance =
|
||||
1e-9 + 1e-12 * Math.max(Math.abs(actual), Math.abs(expected));
|
||||
return Number.isFinite(actual) &&
|
||||
Number.isFinite(expected) &&
|
||||
Math.abs(actual - expected) <= tolerance;
|
||||
}
|
||||
if (Array.isArray(actual) || Array.isArray(expected)) {
|
||||
return Array.isArray(actual) &&
|
||||
Array.isArray(expected) &&
|
||||
actual.length === expected.length &&
|
||||
actual.every((value, index) =>
|
||||
samplingMetadataEqual(value, expected[index]));
|
||||
}
|
||||
if (isRecord(actual) || isRecord(expected)) {
|
||||
if (!isRecord(actual) || !isRecord(expected)) return false;
|
||||
const expectedKeys = Object.keys(expected);
|
||||
return expectedKeys.every((key) =>
|
||||
samplingMetadataEqual(actual[key], expected[key]));
|
||||
}
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
function addIssue(issues, path, message) {
|
||||
issues.push({ path, message });
|
||||
}
|
||||
@@ -92,8 +122,8 @@ function boundsContain(bounds, position) {
|
||||
|
||||
function validateHeader(value, expectedKind, issues) {
|
||||
if (!requireRecord(value, "$", issues)) return false;
|
||||
if (value.schemaVersion !== SCHEMA_VERSION) {
|
||||
addIssue(issues, "$.schemaVersion", `지원 버전은 ${SCHEMA_VERSION}입니다.`);
|
||||
if (value.schemaVersion !== INPUT_SCHEMA_VERSION) {
|
||||
addIssue(issues, "$.schemaVersion", `지원 버전은 ${INPUT_SCHEMA_VERSION}입니다.`);
|
||||
}
|
||||
if (value.kind !== expectedKind) addIssue(issues, "$.kind", `${expectedKind}이어야 합니다.`);
|
||||
requireString(value.id, "$.id", issues);
|
||||
@@ -194,14 +224,14 @@ function validateArrayRelationships(value) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateSignal(signal, path, issues, assetsById, sampleRate) {
|
||||
function validateSignal(signal, path, issues, assetsById) {
|
||||
if (!requireRecord(signal, path, issues)) return;
|
||||
if (signal.type === "tone") {
|
||||
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
|
||||
if (signal.frequency >= sampleRate / 2) {
|
||||
addIssue(issues, `${path}.frequency`, `Nyquist 주파수 ${sampleRate / 2} Hz보다 낮아야 합니다.`);
|
||||
}
|
||||
}
|
||||
requireFinite(signal.frequency, `${path}.frequency`, issues, {
|
||||
min: 0,
|
||||
max: MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||||
exclusiveMin: true,
|
||||
});
|
||||
if (signal.phaseDeg !== undefined) {
|
||||
requireFinite(signal.phaseDeg, `${path}.phaseDeg`, issues, { min: -360, max: 360 });
|
||||
}
|
||||
@@ -358,7 +388,7 @@ function validateSceneRelationships(value) {
|
||||
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
|
||||
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
|
||||
}
|
||||
validateSignal(source.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
|
||||
validateSignal(source.signal, `${path}.signal`, issues, assetsById);
|
||||
});
|
||||
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
|
||||
|
||||
@@ -369,7 +399,7 @@ function validateSceneRelationships(value) {
|
||||
if (!requireRecord(ambient, path, issues)) return;
|
||||
if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id);
|
||||
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
|
||||
validateSignal(ambient.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
|
||||
validateSignal(ambient.signal, `${path}.signal`, issues, assetsById);
|
||||
});
|
||||
for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`);
|
||||
}
|
||||
@@ -412,14 +442,18 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
const path = `$.frequencyBands[${index}]`;
|
||||
if (!requireRecord(band, path, issues)) return;
|
||||
if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id);
|
||||
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 });
|
||||
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
|
||||
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, {
|
||||
min: 0,
|
||||
max: MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||||
});
|
||||
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, {
|
||||
min: 0,
|
||||
max: MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||||
exclusiveMin: true,
|
||||
});
|
||||
if (minValid && maxValid && band.minHz >= band.maxHz) {
|
||||
addIssue(issues, path, "minHz는 maxHz보다 작아야 합니다.");
|
||||
}
|
||||
if (context.scene && maxValid && band.maxHz >= context.scene.sampleRate / 2) {
|
||||
addIssue(issues, `${path}.maxHz`, `Nyquist 주파수 ${context.scene.sampleRate / 2} Hz보다 낮아야 합니다.`);
|
||||
}
|
||||
if (typeof band.color !== "string" || !/^#[0-9a-f]{6}$/i.test(band.color)) {
|
||||
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
|
||||
}
|
||||
@@ -436,6 +470,24 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
}
|
||||
if (value.stft.window !== "hann") addIssue(issues, "$.stft.window", "hann이어야 합니다.");
|
||||
}
|
||||
if (
|
||||
context.scene &&
|
||||
Number.isFinite(context.scene.sampleRate) &&
|
||||
Number.isInteger(value.stft?.fftSize) &&
|
||||
Array.isArray(value.frequencyBands)
|
||||
) {
|
||||
value.frequencyBands.forEach((band, index) => {
|
||||
try {
|
||||
observeFrequencyBand(band, context.scene.sampleRate, value.stft.fftSize);
|
||||
} catch (error) {
|
||||
addIssue(
|
||||
issues,
|
||||
`$.frequencyBands[${index}]`,
|
||||
`선택한 FFT grid의 bin center가 없는 대역입니다: ${error.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (requireRecord(value.beamforming, "$.beamforming", issues)) {
|
||||
if (!LOCALIZATION_ALGORITHMS.includes(value.beamforming.algorithm)) {
|
||||
addIssue(
|
||||
@@ -502,7 +554,15 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
|
||||
);
|
||||
}
|
||||
const retainedBufferCount = context.arrayDefinition.microphones.length + context.scene.sources.length + 1;
|
||||
const sampledSourceCount = context.scene.sources
|
||||
.filter((source) => source.signal?.type !== "tone")
|
||||
.length;
|
||||
const hasSampledAmbient = (context.scene.ambient ?? [])
|
||||
.some((ambient) => ambient.signal?.type !== "tone");
|
||||
const retainedBufferCount =
|
||||
context.arrayDefinition.microphones.length +
|
||||
sampledSourceCount +
|
||||
(hasSampledAmbient ? 1 : 0);
|
||||
const sampleCount = Math.ceil(context.scene.duration * context.scene.sampleRate);
|
||||
let maximumDelaySamples = 0;
|
||||
for (const source of context.scene.sources) {
|
||||
@@ -524,8 +584,15 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
}
|
||||
}
|
||||
let delayFftSize = 1;
|
||||
while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) delayFftSize *= 2;
|
||||
const estimatedBytes = retainedBufferCount * sampleCount * 4 + delayFftSize * 8 * 4;
|
||||
const hasSampledSignal = sampledSourceCount > 0 || hasSampledAmbient;
|
||||
if (hasSampledSignal) {
|
||||
while (delayFftSize < sampleCount + Math.ceil(maximumDelaySamples) + 2) {
|
||||
delayFftSize *= 2;
|
||||
}
|
||||
}
|
||||
const delayWorkingBytes = hasSampledSignal ? delayFftSize * 8 * 4 : 0;
|
||||
const estimatedBytes =
|
||||
retainedBufferCount * sampleCount * 4 + delayWorkingBytes;
|
||||
const maximumBytes = 512 * 1024 * 1024;
|
||||
if (estimatedBytes > maximumBytes) {
|
||||
addIssue(
|
||||
@@ -556,6 +623,13 @@ function validateResultRelationships(value, context = {}) {
|
||||
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
||||
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
||||
const resultAlgorithm = value.algorithm ?? context.experiment?.beamforming?.algorithm;
|
||||
if (resultAlgorithm === "gcc-phat" && value.backend === "webgpu") {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.backend",
|
||||
"gcc-phat Result must use the CPU backend.",
|
||||
);
|
||||
}
|
||||
const expectedRawScale =
|
||||
resultAlgorithm === "das"
|
||||
? "power"
|
||||
@@ -568,6 +642,201 @@ function validateResultRelationships(value, context = {}) {
|
||||
: expectedRawScale === "score"
|
||||
? "connected-half-maximum-score-weighted-covariance"
|
||||
: undefined;
|
||||
if (
|
||||
Number.isFinite(value.timingMs?.localization) &&
|
||||
Number.isFinite(value.timingMs?.beamforming) &&
|
||||
!samplingMetadataEqual(
|
||||
value.timingMs.localization,
|
||||
value.timingMs.beamforming,
|
||||
)
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.timingMs.localization",
|
||||
"localization must equal the compatibility beamforming timing alias.",
|
||||
);
|
||||
}
|
||||
const expectedObservations = new Map();
|
||||
const expectedEquivalentGroups = new Map();
|
||||
if (
|
||||
context.scene &&
|
||||
context.experiment &&
|
||||
Number.isFinite(context.scene.sampleRate) &&
|
||||
Number.isInteger(context.experiment.stft?.fftSize)
|
||||
) {
|
||||
const definitions = Array.isArray(context.experiment.frequencyBands)
|
||||
? context.experiment.frequencyBands
|
||||
: [];
|
||||
for (const definition of definitions) {
|
||||
try {
|
||||
const observation = observeFrequencyBand(
|
||||
definition,
|
||||
context.scene.sampleRate,
|
||||
context.experiment.stft.fftSize,
|
||||
);
|
||||
expectedObservations.set(definition.id, observation);
|
||||
const group = expectedEquivalentGroups.get(observation.adcBinSetKey) ?? [];
|
||||
group.push(definition.id);
|
||||
expectedEquivalentGroups.set(observation.adcBinSetKey, group);
|
||||
} catch {
|
||||
// Experiment validation reports an unusable band separately.
|
||||
}
|
||||
}
|
||||
}
|
||||
const sampling = value.signalStats?.sampling;
|
||||
const contextSampleRate =
|
||||
Number.isFinite(context.scene?.sampleRate) && context.scene.sampleRate > 0
|
||||
? context.scene.sampleRate
|
||||
: null;
|
||||
const toneDefinitions = new Map();
|
||||
if (context.scene) {
|
||||
const sources = Array.isArray(context.scene.sources) ? context.scene.sources : [];
|
||||
const ambientEntries = Array.isArray(context.scene.ambient)
|
||||
? context.scene.ambient
|
||||
: [];
|
||||
for (const source of sources) {
|
||||
if (source.signal?.type === "tone") {
|
||||
toneDefinitions.set(`source:${source.id}`, source.signal.frequency);
|
||||
}
|
||||
}
|
||||
for (const ambient of ambientEntries) {
|
||||
if (ambient.signal?.type === "tone") {
|
||||
toneDefinitions.set(`ambient:${ambient.id}`, ambient.signal.frequency);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isRecord(sampling)) {
|
||||
const samplingRateValid =
|
||||
Number.isFinite(sampling.sampleRate) && sampling.sampleRate > 0;
|
||||
if (
|
||||
samplingRateValid &&
|
||||
!samplingMetadataEqual(sampling.nyquistHz, sampling.sampleRate / 2)
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.signalStats.sampling.nyquistHz",
|
||||
"sampling Nyquist must equal sampling sampleRate / 2.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
contextSampleRate !== null &&
|
||||
!samplingMetadataEqual(sampling.sampleRate, contextSampleRate)
|
||||
) {
|
||||
addIssue(issues, "$.signalStats.sampling.sampleRate", "sampling sampleRate must match Scene.");
|
||||
}
|
||||
if (
|
||||
contextSampleRate !== null &&
|
||||
!samplingMetadataEqual(sampling.nyquistHz, contextSampleRate / 2)
|
||||
) {
|
||||
addIssue(issues, "$.signalStats.sampling.nyquistHz", "sampling Nyquist must equal sampleRate / 2.");
|
||||
}
|
||||
const observedToneKeys = new Set();
|
||||
const toneObservations = Array.isArray(sampling.toneObservations)
|
||||
? sampling.toneObservations
|
||||
: [];
|
||||
for (const [observationIndex, observation] of toneObservations.entries()) {
|
||||
if (!isRecord(observation)) continue;
|
||||
const path = `$.signalStats.sampling.toneObservations[${observationIndex}]`;
|
||||
const observationKey = `${observation.scope}:${observation.id}`;
|
||||
if (observedToneKeys.has(observationKey)) {
|
||||
addIssue(issues, path, `duplicate tone observation: ${observationKey}`);
|
||||
}
|
||||
observedToneKeys.add(observationKey);
|
||||
if (samplingRateValid && Number.isFinite(observation.analogFrequencyHz)) {
|
||||
try {
|
||||
const expected = describeFrequencyAliasing(
|
||||
observation.analogFrequencyHz,
|
||||
sampling.sampleRate,
|
||||
);
|
||||
for (const key of [
|
||||
"analogFrequencyHz",
|
||||
"adcFrequencyHz",
|
||||
"signedAdcFrequencyHz",
|
||||
"aliased",
|
||||
"spectralInversion",
|
||||
"dcEdge",
|
||||
"nyquistEdge",
|
||||
"phaseDegenerate",
|
||||
]) {
|
||||
if (!samplingMetadataEqual(observation[key], expected[key])) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.${key}`,
|
||||
`${key} does not match ideal ADC sampling.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addIssue(issues, `${path}.analogFrequencyHz`, error.message);
|
||||
}
|
||||
}
|
||||
if (context.scene) {
|
||||
const analogFrequencyHz = toneDefinitions.get(observationKey);
|
||||
if (analogFrequencyHz === undefined) {
|
||||
addIssue(issues, path, "tone observation must reference a Scene tone.");
|
||||
} else if (
|
||||
!samplingMetadataEqual(observation.analogFrequencyHz, analogFrequencyHz)
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.analogFrequencyHz`,
|
||||
"analogFrequencyHz must match the Scene tone.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (context.scene) {
|
||||
for (const toneKey of toneDefinitions.keys()) {
|
||||
if (!observedToneKeys.has(toneKey)) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$.signalStats.sampling.toneObservations",
|
||||
`missing Scene tone observation: ${toneKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (contextSampleRate !== null) {
|
||||
addIssue(issues, "$.signalStats.sampling", "sampling metadata is required.");
|
||||
}
|
||||
const standaloneObservations = new Map();
|
||||
const standaloneEquivalentGroups = new Map();
|
||||
if (Array.isArray(value.bands)) {
|
||||
value.bands.forEach((band, bandIndex) => {
|
||||
const observation = band?.frequencyObservation;
|
||||
if (!isRecord(observation)) return;
|
||||
if (
|
||||
!Number.isFinite(observation.sampleRate) ||
|
||||
!Number.isInteger(observation.fftSize) ||
|
||||
!Number.isFinite(observation.requestedMinHz) ||
|
||||
!Number.isFinite(observation.requestedMaxHz)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const expected = observeFrequencyBand(
|
||||
{
|
||||
minHz: observation.requestedMinHz,
|
||||
maxHz: observation.requestedMaxHz,
|
||||
},
|
||||
observation.sampleRate,
|
||||
observation.fftSize,
|
||||
);
|
||||
standaloneObservations.set(bandIndex, expected);
|
||||
if (typeof band.id === "string") {
|
||||
const group = standaloneEquivalentGroups.get(expected.adcBinSetKey) ?? [];
|
||||
group.push(band.id);
|
||||
standaloneEquivalentGroups.set(expected.adcBinSetKey, group);
|
||||
}
|
||||
} catch (error) {
|
||||
addIssue(
|
||||
issues,
|
||||
`$.bands[${bandIndex}].frequencyObservation`,
|
||||
`invalid self-described frequency observation: ${error.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
const bandIds = [];
|
||||
if (Array.isArray(value.bands)) {
|
||||
value.bands.forEach((band, bandIndex) => {
|
||||
@@ -577,7 +846,116 @@ function validateResultRelationships(value, context = {}) {
|
||||
bandIds.push(band.id);
|
||||
if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다.");
|
||||
}
|
||||
const maps = [band.rawMap, band.displayMap, ...(band.processedMaps?.map((item) => item.map) ?? [])].filter(Boolean);
|
||||
const standaloneObservation = standaloneObservations.get(bandIndex);
|
||||
if (standaloneObservation && isRecord(band.frequencyObservation)) {
|
||||
const observation = band.frequencyObservation;
|
||||
const expectedEquivalentBandIds = (standaloneEquivalentGroups
|
||||
.get(standaloneObservation.adcBinSetKey) ?? [])
|
||||
.filter((candidateId) => candidateId !== band.id)
|
||||
.sort();
|
||||
const expectedFields = {
|
||||
model: standaloneObservation.model,
|
||||
sampleRate: standaloneObservation.sampleRate,
|
||||
nyquistHz: standaloneObservation.nyquistHz,
|
||||
fftSize: standaloneObservation.fftSize,
|
||||
binWidthHz: standaloneObservation.binWidthHz,
|
||||
requestedMinHz: standaloneObservation.requestedMinHz,
|
||||
requestedMaxHz: standaloneObservation.requestedMaxHz,
|
||||
aliased: standaloneObservation.aliased,
|
||||
crossesFirstNyquist: standaloneObservation.crossesFirstNyquist,
|
||||
localizationInputModel: standaloneObservation.localizationInputModel,
|
||||
selectionModel: standaloneObservation.selectionModel,
|
||||
localizationModel:
|
||||
resultAlgorithm === "gcc-phat"
|
||||
? "band-limited-gcc-phat-geometric-lag"
|
||||
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||
originalCarrierUsed: standaloneObservation.originalCarrierUsed,
|
||||
aliasOrderResolution:
|
||||
standaloneObservation.aliased ? "not-attempted" : "not-applicable",
|
||||
foldBoundaryCount: standaloneObservation.foldBoundaryCount,
|
||||
continuousManyToOne: standaloneObservation.continuousManyToOne,
|
||||
gridManyToOne: standaloneObservation.gridManyToOne,
|
||||
adcBinSetKey: standaloneObservation.adcBinSetKey,
|
||||
};
|
||||
for (const [key, expected] of Object.entries(expectedFields)) {
|
||||
if (!samplingMetadataEqual(observation[key], expected)) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.frequencyObservation.${key}`,
|
||||
`${key} is not self-consistent with the Result sampling inputs.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [key, expected] of [
|
||||
["adcBins", standaloneObservation.bins],
|
||||
["adcBinCenterRanges", standaloneObservation.adcBinCenterRanges],
|
||||
["foldSegments", standaloneObservation.foldSegments],
|
||||
["equivalentBandIds", expectedEquivalentBandIds],
|
||||
]) {
|
||||
if (!samplingMetadataEqual(observation[key], expected)) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.frequencyObservation.${key}`,
|
||||
`${key} does not match the deterministic ADC fold.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
isRecord(sampling) &&
|
||||
Number.isFinite(sampling.sampleRate) &&
|
||||
!samplingMetadataEqual(observation.sampleRate, sampling.sampleRate)
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.frequencyObservation.sampleRate`,
|
||||
"band sampleRate must match signalStats.sampling.sampleRate.",
|
||||
);
|
||||
}
|
||||
} else if (!isRecord(band.frequencyObservation)) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.frequencyObservation`,
|
||||
"frequency observation metadata is required for every Result band.",
|
||||
);
|
||||
}
|
||||
const contextObservation = expectedObservations.get(band.id);
|
||||
if (contextObservation && isRecord(band.frequencyObservation)) {
|
||||
const observation = band.frequencyObservation;
|
||||
for (const [key, expected] of Object.entries({
|
||||
sampleRate: contextObservation.sampleRate,
|
||||
fftSize: contextObservation.fftSize,
|
||||
requestedMinHz: contextObservation.requestedMinHz,
|
||||
requestedMaxHz: contextObservation.requestedMaxHz,
|
||||
})) {
|
||||
if (!samplingMetadataEqual(observation[key], expected)) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.frequencyObservation.${key}`,
|
||||
`${key} does not match Scene/Experiment.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const expectedEquivalentBandIds = expectedEquivalentGroups
|
||||
.get(contextObservation.adcBinSetKey)
|
||||
.filter((candidateId) => candidateId !== band.id)
|
||||
.sort();
|
||||
if (
|
||||
!samplingMetadataEqual(
|
||||
observation.equivalentBandIds,
|
||||
expectedEquivalentBandIds,
|
||||
)
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
`${path}.frequencyObservation.equivalentBandIds`,
|
||||
"equivalentBandIds does not match the Experiment band set.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const processedMaps = Array.isArray(band.processedMaps)
|
||||
? band.processedMaps.map((item) => item?.map)
|
||||
: [];
|
||||
const maps = [band.rawMap, band.displayMap, ...processedMaps].filter(Boolean);
|
||||
maps.forEach((map, mapIndex) => {
|
||||
if (!isRecord(map) || !Array.isArray(map.values)) return;
|
||||
if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) {
|
||||
@@ -620,6 +998,11 @@ function validateResultRelationships(value, context = {}) {
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const expectedBandId of expectedBandIds) {
|
||||
if (!bandIds.includes(expectedBandId)) {
|
||||
addIssue(issues, "$.bands", `missing Experiment band result: ${expectedBandId}`);
|
||||
}
|
||||
}
|
||||
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`);
|
||||
return issues;
|
||||
}
|
||||
|
||||
+8
-3
@@ -9,11 +9,16 @@ JSON Schema Draft 2020-12 형식입니다.
|
||||
- `experiment.schema.json`: scan plane/grid, 주파수 대역, STFT/DAS와 peak 검출 임계값
|
||||
- `result.schema.json`: raw/display/후처리 map, 복수 peak, 비확률 beam spread, timing과 평가 지표
|
||||
|
||||
마이크 주파수 응답점은 배열 계약 자체이므로 특정 Scene의 Nyquist와 결합하지 않고 0~1 MHz의
|
||||
안전 범위에서 엄격한 오름차순을 요구합니다. 실제 분석에서는 선택한 Scene/STFT의 bin 주파수만
|
||||
보간합니다. 중복 id, Nyquist, 시간 구간과 참조 무결성은
|
||||
마이크 주파수 응답점과 Scene tone·Experiment band는 특정 Scene의 Nyquist에서 자르지 않고
|
||||
0~1 MHz의 명시적 수치/마이크 모델 검증 범위를 사용합니다. Tone 합성에서는 원 analog 주파수,
|
||||
sampled asset 분석에서는 선택한 Scene/STFT의 baseband bin 주파수로 보간합니다. 중복 id,
|
||||
sampling alias mapping,
|
||||
시간 구간과 참조 무결성은
|
||||
`packages/contracts/validation.js`에서 추가로 검증합니다.
|
||||
|
||||
Array·Scene·Experiment는 1.0.0, sampling provenance와 band별 fold/localization metadata를
|
||||
필수화한 Result는 1.1.0입니다. Result 1.0을 묵시적으로 변환하지 않습니다.
|
||||
|
||||
Scene `acoustics`는 기본 `free-field` 또는 `shoebox`입니다. shoebox는 room bounds, 0/1차
|
||||
반사와 scattering 비율, bounded diffuse tail, 최대 8개의 AABB occluder를 저장합니다.
|
||||
room·occluder의 각 축은 `min < max`여야 하고 source/trajectory와 occluder는 room 안에
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"type": "object", "additionalProperties": false, "required": ["id", "minHz", "maxHz", "color"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" },
|
||||
"minHz": { "type": "number", "minimum": 0 }, "maxHz": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"minHz": { "type": "number", "minimum": 0, "maximum": 1000000 },
|
||||
"maxHz": { "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 },
|
||||
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }
|
||||
}
|
||||
}
|
||||
|
||||
+131
-8
@@ -5,11 +5,11 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "backend",
|
||||
"schemaVersion", "kind", "experimentId", "arrayId", "sceneId", "createdAt", "algorithm", "backend",
|
||||
"inputHash", "signalStats", "timingMs", "bands"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "1.0.0" },
|
||||
"schemaVersion": { "const": "1.1.0" },
|
||||
"kind": { "const": "micarrsim.result" },
|
||||
"experimentId": { "type": "string", "minLength": 1 },
|
||||
"arrayId": { "type": "string", "minLength": 1 },
|
||||
@@ -20,7 +20,7 @@
|
||||
"inputHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||
"signalStats": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount"],
|
||||
"required": ["preNormalizationPeak", "appliedScale", "clippedSampleCount", "sampling"],
|
||||
"properties": {
|
||||
"preNormalizationPeak": { "type": "number", "minimum": 0 },
|
||||
"appliedScale": { "type": "number", "minimum": 0 },
|
||||
@@ -36,11 +36,13 @@
|
||||
"diffraction": { "type": "integer", "minimum": 0 },
|
||||
"diffuse": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"sampling": { "$ref": "#/$defs/sampling" }
|
||||
}
|
||||
},
|
||||
"timingMs": {
|
||||
"type": "object", "additionalProperties": false, "required": ["total", "beamforming"],
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["total", "localization", "beamforming"],
|
||||
"properties": {
|
||||
"total": { "type": "number", "minimum": 0 },
|
||||
"signalGeneration": { "type": "number", "minimum": 0 },
|
||||
@@ -60,6 +62,123 @@
|
||||
"prefixItems": [{ "type": "number" }, { "type": "number" }, { "type": "number" }],
|
||||
"items": false, "minItems": 3, "maxItems": 3
|
||||
},
|
||||
"sampling": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["model", "sampleRate", "nyquistHz", "toneObservations"],
|
||||
"properties": {
|
||||
"model": { "const": "ideal-real-sampler-no-antialias-filter" },
|
||||
"sampleRate": { "type": "integer", "minimum": 8000, "maximum": 192000 },
|
||||
"nyquistHz": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"toneObservations": {
|
||||
"type": "array", "maxItems": 64,
|
||||
"items": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": [
|
||||
"scope", "id", "analogFrequencyHz", "adcFrequencyHz",
|
||||
"signedAdcFrequencyHz", "aliased", "spectralInversion", "dcEdge",
|
||||
"nyquistEdge", "phaseDegenerate"
|
||||
],
|
||||
"properties": {
|
||||
"scope": { "enum": ["source", "ambient"] },
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"analogFrequencyHz": {
|
||||
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
|
||||
},
|
||||
"adcFrequencyHz": { "type": "number", "minimum": 0 },
|
||||
"signedAdcFrequencyHz": { "type": "number" },
|
||||
"aliased": { "type": "boolean" },
|
||||
"spectralInversion": { "type": "boolean" },
|
||||
"dcEdge": { "type": "boolean" },
|
||||
"nyquistEdge": { "type": "boolean" },
|
||||
"phaseDegenerate": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"frequencyObservation": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": [
|
||||
"model", "sampleRate", "nyquistHz", "fftSize", "binWidthHz",
|
||||
"requestedMinHz", "requestedMaxHz", "aliased", "crossesFirstNyquist",
|
||||
"localizationInputModel", "selectionModel", "localizationModel", "originalCarrierUsed",
|
||||
"aliasOrderResolution", "foldBoundaryCount", "continuousManyToOne",
|
||||
"gridManyToOne", "foldSegments", "adcBins",
|
||||
"adcBinCenterRanges", "adcBinSetKey", "equivalentBandIds"
|
||||
],
|
||||
"properties": {
|
||||
"model": { "const": "ideal-real-sampler-no-antialias-filter" },
|
||||
"sampleRate": { "type": "integer", "minimum": 8000, "maximum": 192000 },
|
||||
"nyquistHz": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"fftSize": { "enum": [128, 256, 512, 1024, 2048, 4096] },
|
||||
"binWidthHz": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"requestedMinHz": { "type": "number", "minimum": 0, "maximum": 1000000 },
|
||||
"requestedMaxHz": {
|
||||
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
|
||||
},
|
||||
"aliased": { "type": "boolean" },
|
||||
"crossesFirstNyquist": { "type": "boolean" },
|
||||
"localizationInputModel": { "const": "observed-one-sided-real-fft-bins" },
|
||||
"selectionModel": { "const": "fft-bin-center-membership" },
|
||||
"localizationModel": {
|
||||
"enum": [
|
||||
"spherical-steering-at-positive-adc-bin-frequency",
|
||||
"band-limited-gcc-phat-geometric-lag"
|
||||
]
|
||||
},
|
||||
"originalCarrierUsed": { "const": false },
|
||||
"aliasOrderResolution": { "enum": ["not-applicable", "not-attempted"] },
|
||||
"foldBoundaryCount": { "type": "integer", "minimum": 0, "maximum": 512 },
|
||||
"continuousManyToOne": { "type": "boolean" },
|
||||
"gridManyToOne": { "type": "boolean" },
|
||||
"foldSegments": {
|
||||
"type": "array", "minItems": 1, "maxItems": 513,
|
||||
"items": { "$ref": "#/$defs/foldSegment" }
|
||||
},
|
||||
"adcBins": {
|
||||
"type": "array", "minItems": 1, "maxItems": 2049, "uniqueItems": true,
|
||||
"items": { "type": "integer", "minimum": 0, "maximum": 2048 }
|
||||
},
|
||||
"adcBinCenterRanges": {
|
||||
"type": "array", "minItems": 1, "maxItems": 2049,
|
||||
"items": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": [
|
||||
"firstBin", "lastBin", "firstBinCenterHz", "lastBinCenterHz"
|
||||
],
|
||||
"properties": {
|
||||
"firstBin": { "type": "integer", "minimum": 0, "maximum": 2048 },
|
||||
"lastBin": { "type": "integer", "minimum": 0, "maximum": 2048 },
|
||||
"firstBinCenterHz": { "type": "number", "minimum": 0 },
|
||||
"lastBinCenterHz": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"adcBinSetKey": { "type": "string", "minLength": 1, "maxLength": 16384 },
|
||||
"equivalentBandIds": {
|
||||
"type": "array", "maxItems": 7, "uniqueItems": true,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"foldSegment": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": [
|
||||
"analogMinHz", "analogMaxHz", "signedAdcStartHz", "signedAdcEndHz",
|
||||
"inverted", "aliasOrder", "nyquistZone"
|
||||
],
|
||||
"properties": {
|
||||
"analogMinHz": { "type": "number", "minimum": 0, "maximum": 1000000 },
|
||||
"analogMaxHz": {
|
||||
"type": "number", "exclusiveMinimum": 0, "maximum": 1000000
|
||||
},
|
||||
"signedAdcStartHz": { "type": "number" },
|
||||
"signedAdcEndHz": { "type": "number" },
|
||||
"inverted": { "type": "boolean" },
|
||||
"aliasOrder": { "type": "integer", "minimum": 0 },
|
||||
"nyquistZone": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"map": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["rows", "columns", "scale", "values"],
|
||||
@@ -132,7 +251,7 @@
|
||||
"required": [
|
||||
"version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio",
|
||||
"detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM",
|
||||
"halfPowerRatio", "dynamicRangeDb", "maximumDetections"
|
||||
"halfPowerRatio", "dynamicRangeDb", "maximumDetections", "truthSelection"
|
||||
],
|
||||
"properties": {
|
||||
"version": { "const": "1.0.0" },
|
||||
@@ -147,7 +266,10 @@
|
||||
"sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 },
|
||||
"halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
||||
"dynamicRangeDb": { "type": "number", "minimum": 0 },
|
||||
"maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 }
|
||||
"maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 },
|
||||
"truthSelection": {
|
||||
"const": "tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processedMap": {
|
||||
@@ -159,12 +281,13 @@
|
||||
},
|
||||
"bandResult": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["id", "color", "rawMap", "peaks", "metrics"],
|
||||
"required": ["id", "color", "rawMap", "frequencyObservation", "peaks", "metrics"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
|
||||
"rawMap": { "$ref": "#/$defs/map" },
|
||||
"displayMap": { "$ref": "#/$defs/map" },
|
||||
"frequencyObservation": { "$ref": "#/$defs/frequencyObservation" },
|
||||
"evaluation": { "$ref": "#/$defs/evaluation" },
|
||||
"processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } },
|
||||
"peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } },
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"signal": {
|
||||
"oneOf": [
|
||||
{ "type": "object", "additionalProperties": false, "required": ["type", "frequency"], "properties": {
|
||||
"type": { "const": "tone" }, "frequency": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"type": { "const": "tone" }, "frequency": { "type": "number", "exclusiveMinimum": 0, "maximum": 1000000 },
|
||||
"phaseDeg": { "type": "number", "minimum": -360, "maximum": 360 }
|
||||
}},
|
||||
{ "type": "object", "additionalProperties": false, "required": ["type", "color"], "properties": {
|
||||
|
||||
@@ -133,6 +133,20 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
|
||||
frame.style.width = "1400px";
|
||||
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 5000, "input");
|
||||
assert(
|
||||
documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone") &&
|
||||
!documentObject.querySelector("#source-list").textContent.includes("ADC 3000 Hz alias"),
|
||||
"tone card starts as baseband at 48 kHz sample rate",
|
||||
);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 8000, "change");
|
||||
assert(
|
||||
documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone → ADC 3000 Hz alias"),
|
||||
"sample-rate change immediately rerenders the tone alias card",
|
||||
);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 48000, "change");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 1000, "input");
|
||||
|
||||
const previewSamples = new Float32Array(2000);
|
||||
for (let index = 0; index < previewSamples.length; index += 1) {
|
||||
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
|
||||
|
||||
@@ -171,6 +171,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
"Result JSON download",
|
||||
);
|
||||
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
|
||||
assertEqual(resultDocument.schemaVersion, "1.1.0", "exported sampling-aware Result version");
|
||||
assert(
|
||||
!documentObject.documentElement.dataset.unsavedWork.includes("Result"),
|
||||
"Result export clears Result dirty state",
|
||||
@@ -399,11 +400,24 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
scene.name = "Two-tone band preservation";
|
||||
const secondSource = structuredClone(scene.sources[0]);
|
||||
secondSource.id = "source-2";
|
||||
secondSource.name = "1.2 kHz tone";
|
||||
secondSource.name = "48 kHz DC-edge tone";
|
||||
secondSource.position = [-0.12, 0.09, 2];
|
||||
secondSource.signal.frequency = 1200;
|
||||
secondSource.signal.frequency = 48000;
|
||||
secondSource.signal.phaseDeg = 45;
|
||||
scene.sources.push(secondSource);
|
||||
const nyquistEdgeSource = structuredClone(scene.sources[0]);
|
||||
nyquistEdgeSource.id = "source-3";
|
||||
nyquistEdgeSource.name = "72 kHz Nyquist-edge tone";
|
||||
nyquistEdgeSource.position = [0.18, -0.12, 2];
|
||||
nyquistEdgeSource.signal.frequency = 72000;
|
||||
nyquistEdgeSource.signal.phaseDeg = 90;
|
||||
scene.sources[0].name = "47 kHz aliased tone";
|
||||
scene.sources[0].signal.frequency = 47000;
|
||||
scene.sources.push(secondSource, nyquistEdgeSource);
|
||||
scene.ambient.push({
|
||||
id: "ambient-1",
|
||||
gainDb: -30,
|
||||
signal: { type: "tone", frequency: 46800, phaseDeg: 20 },
|
||||
});
|
||||
|
||||
const openArrayDefinition = await loadJson(
|
||||
"/assets/array-geometries/rectangular-16x12.array.json",
|
||||
@@ -486,38 +500,62 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
);
|
||||
downloads.length = 0;
|
||||
|
||||
const contextInvalidExperiment = structuredClone(experiment);
|
||||
contextInvalidExperiment.id = "nyquist-invalid-after-rebind";
|
||||
contextInvalidExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2;
|
||||
const aliasedExperiment = structuredClone(experiment);
|
||||
aliasedExperiment.id = "above-nyquist-after-rebind";
|
||||
aliasedExperiment.sceneId = openScene.id;
|
||||
aliasedExperiment.arrayId = openArrayDefinition.id;
|
||||
aliasedExperiment.frequencyBands[0].minHz = openScene.sampleRate / 2 + 800;
|
||||
aliasedExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2 + 1_000;
|
||||
aliasedExperiment.frequencyBands[1].minHz = openScene.sampleRate / 2 - 1_000;
|
||||
aliasedExperiment.frequencyBands[1].maxHz = openScene.sampleRate / 2 - 800;
|
||||
assertEqual(
|
||||
(await validateExperiment(contextInvalidExperiment)).length,
|
||||
(await validateExperiment(aliasedExperiment, {
|
||||
scene: openScene,
|
||||
arrayDefinition: openArrayDefinition,
|
||||
})).length,
|
||||
0,
|
||||
"Nyquist fixture is valid without a Scene context",
|
||||
"above-Nyquist observation band remains valid with a Scene context",
|
||||
);
|
||||
const contextInvalidFile = new windowObject.File(
|
||||
[JSON.stringify(contextInvalidExperiment)],
|
||||
"nyquist-invalid-after-rebind.experiment.json",
|
||||
const aliasedFile = new windowObject.File(
|
||||
[JSON.stringify(aliasedExperiment)],
|
||||
"above-nyquist-after-rebind.experiment.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
dispatchFiles(
|
||||
windowObject,
|
||||
documentObject.querySelector("#experiment-input"),
|
||||
[contextInvalidFile],
|
||||
[aliasedFile],
|
||||
);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||
documentObject.documentElement.dataset.cameraValid === "true",
|
||||
"relationship-invalid Experiment rejection",
|
||||
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
documentObject.querySelector("#experiment-id").value === aliasedExperiment.id,
|
||||
"above-Nyquist Experiment import",
|
||||
);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#experiment-id").value,
|
||||
experiment.id,
|
||||
"context relationship validation rejects the imported Experiment before assignment",
|
||||
aliasedExperiment.id,
|
||||
"above-Nyquist Experiment is assigned instead of rejected",
|
||||
);
|
||||
assertEqual(
|
||||
JSON.stringify(readBandEditors()),
|
||||
JSON.stringify(expectedBands),
|
||||
"failed relationship validation leaves the current bands unchanged",
|
||||
JSON.stringify(aliasedExperiment.frequencyBands),
|
||||
"above-Nyquist requested bands remain explicit in the editor",
|
||||
);
|
||||
const samplingWarning = documentObject.querySelector("#sampling-warning");
|
||||
assert(!samplingWarning.hidden, "above-Nyquist steering warning remains visible");
|
||||
assert(
|
||||
samplingWarning.textContent.includes("ADC FFT bin") &&
|
||||
samplingWarning.textContent.includes("원래 analog carrier를 사용하지 않습니다") &&
|
||||
samplingWarning.textContent.includes("같은 ADC bin 집합") &&
|
||||
samplingWarning.textContent.includes("order 1/inverted"),
|
||||
"warning states that localization does not use the original carrier",
|
||||
);
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||
documentObject.querySelector("#experiment-id").value === experiment.id,
|
||||
"restore original multi-band Experiment",
|
||||
);
|
||||
|
||||
let replacementConfirmCount = 0;
|
||||
@@ -546,7 +584,7 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]);
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||||
documentObject.querySelectorAll("#source-summary .source-line").length === 2 &&
|
||||
documentObject.querySelectorAll("#source-summary .source-line").length === 3 &&
|
||||
documentObject.querySelectorAll("#band-list .band-editor").length === 2 &&
|
||||
documentObject.documentElement.dataset.cameraValid === "true",
|
||||
"source-added Scene reimport",
|
||||
@@ -560,6 +598,26 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"),
|
||||
"Camera status keeps the preserved band count visible",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#source-summary").textContent.includes(
|
||||
"48000 Hz tone → ADC 0 Hz alias (signed +0 Hz) · DC phase-degenerate edge",
|
||||
),
|
||||
"Camera summary keeps the aliased exact-DC phase-degenerate edge visible",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#source-summary").textContent.includes(
|
||||
"72000 Hz tone → ADC 24000 Hz alias (signed +24000 Hz) · Nyquist phase-degenerate edge",
|
||||
),
|
||||
"Camera summary keeps the aliased exact-Nyquist phase-degenerate edge visible",
|
||||
);
|
||||
const sceneAliasWarning = documentObject.querySelector("#sampling-warning");
|
||||
assert(!sceneAliasWarning.hidden, "aliased Scene tone keeps the sampling warning visible");
|
||||
assert(
|
||||
sceneAliasWarning.textContent.includes("선택 ADC bin으로 접힌 Scene tone") &&
|
||||
sceneAliasWarning.textContent.includes("tone-1k ← source source-1 47000→ADC 1000 Hz") &&
|
||||
sceneAliasWarning.textContent.includes("tone-1.2k ← ambient ambient-1 46800→ADC 1200 Hz"),
|
||||
"baseband bands disclose contributing source and ambient aliases",
|
||||
);
|
||||
|
||||
const arrayFile = new windowObject.File(
|
||||
[JSON.stringify(arrayDefinition)],
|
||||
@@ -583,6 +641,32 @@ test("Acoustic Camera가 Experiment를 현재 입력에 연결하고 Scene·Arra
|
||||
"ready status keeps the connected Experiment band count visible",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||||
!documentObject.querySelector("#run-button").disabled,
|
||||
"aliased Scene tone snapshot setup",
|
||||
);
|
||||
documentObject.querySelector("#run-button").click();
|
||||
await waitFor(
|
||||
() => documentObject.documentElement.dataset.runState === "done",
|
||||
"aliased Scene tone snapshot",
|
||||
30000,
|
||||
);
|
||||
const metricText = documentObject.querySelector("#metric-grid").textContent;
|
||||
assert(
|
||||
metricText.includes("tone-1k · ADC steering") &&
|
||||
metricText.includes("source source-1 47000→ADC 1000 Hz"),
|
||||
"baseband source-alias steering metric is rendered",
|
||||
);
|
||||
assert(
|
||||
metricText.includes("tone-1.2k · ADC steering") &&
|
||||
metricText.includes("ambient ambient-1 46800→ADC 1200 Hz"),
|
||||
"baseband ambient-alias steering metric is rendered",
|
||||
);
|
||||
|
||||
documentObject.querySelector("#export-experiment-button").click();
|
||||
const experimentDownload = await waitFor(
|
||||
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
createDelayWorkspace,
|
||||
prepareDelaySpectrum,
|
||||
} from "/packages/acoustics-core/fractional-delay.js";
|
||||
import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
|
||||
import {
|
||||
ceilSampleIndex,
|
||||
ceilSamplePosition,
|
||||
dbToLinear,
|
||||
distance3,
|
||||
} from "/packages/acoustics-core/math.js";
|
||||
import {
|
||||
DEFAULT_LOCALIZATION_ALGORITHM,
|
||||
LOCALIZATION_ALGORITHM_IDS,
|
||||
@@ -23,7 +28,12 @@ import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
||||
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
||||
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||
import { renderSignal } from "/packages/acoustics-core/signal.js";
|
||||
import { getFrameCount } from "/packages/acoustics-core/stft.js";
|
||||
import {
|
||||
describeFrequencyAliasing,
|
||||
foldFrequencyToNyquist,
|
||||
observeFrequencyBand,
|
||||
} from "/packages/acoustics-core/sampling.js";
|
||||
import { binsForBand, getFrameCount } from "/packages/acoustics-core/stft.js";
|
||||
import {
|
||||
createQuasiStaticWindows,
|
||||
sampleQuasiStaticWindow,
|
||||
@@ -241,6 +251,217 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
|
||||
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
|
||||
});
|
||||
|
||||
test("sample boundary ceil ignores only IEEE-754 integer roundoff", () => {
|
||||
assertEqual(ceilSampleIndex(0.1 + 0.2, 1_000), 300, "summed exact boundary");
|
||||
assertEqual(ceilSamplePosition(300 + 1e-10), 301, "physical sub-sample offset is not snapped");
|
||||
assertEqual(ceilSampleIndex(0.300_001, 1_000), 301, "physical time offset is ceiled");
|
||||
});
|
||||
|
||||
test("ideal ADC sampling reports signed aliases without hiding Nyquist folding", () => {
|
||||
const first = describeFrequencyAliasing(5_000, 8_000);
|
||||
assertEqual(first.adcFrequencyHz, 3_000, "first folded frequency");
|
||||
assertEqual(first.signedAdcFrequencyHz, -3_000, "first folded signed frequency");
|
||||
assert(first.aliased && first.spectralInversion, "first fold metadata");
|
||||
assertEqual(foldFrequencyToNyquist(13_000, 8_000), 3_000, "multiple fold");
|
||||
assertEqual(foldFrequencyToNyquist(16_000, 8_000), 0, "sample-rate multiple folds to DC");
|
||||
const dc = describeFrequencyAliasing(8_000, 8_000);
|
||||
assert(dc.dcEdge && dc.phaseDegenerate, "sample-rate multiple is a phase-degenerate DC fold");
|
||||
const edge = describeFrequencyAliasing(4_000, 8_000);
|
||||
assert(
|
||||
edge.nyquistEdge && edge.phaseDegenerate && !edge.aliased,
|
||||
"exact Nyquist is retained as a phase-degenerate edge",
|
||||
);
|
||||
});
|
||||
|
||||
test("above-Nyquist requested bands retain every physically observable ADC bin", () => {
|
||||
const observation = observeFrequencyBand(
|
||||
{ minHz: 4_900, maxHz: 5_100 },
|
||||
8_000,
|
||||
256,
|
||||
);
|
||||
assert(observation.aliased, "band is marked aliased");
|
||||
assertEqual(observation.bins[0], 93, "folded lower ADC bin");
|
||||
assertEqual(observation.bins.at(-1), 99, "folded upper ADC bin");
|
||||
assertEqual(
|
||||
JSON.stringify(binsForBand({ minHz: 4_900, maxHz: 5_100 }, 8_000, 256)),
|
||||
JSON.stringify(observation.bins),
|
||||
"STFT uses the explicit observation mapping",
|
||||
);
|
||||
|
||||
const wide = observeFrequencyBand({ minHz: 0, maxHz: 8_000 }, 8_000, 256);
|
||||
assertEqual(wide.bins.length, 129, "one sample-rate interval covers every real FFT bin");
|
||||
assert(wide.continuousManyToOne, "a full sample-rate interval folds continuously many-to-one");
|
||||
assert(wide.gridManyToOne, "multiple analog FFT-grid bins share ADC bins");
|
||||
assertEqual(wide.foldSegments.length, 2, "first and second Nyquist zones remain explicit");
|
||||
assertEqual(wide.foldSegments[0].nyquistZone, 1, "Nyquist zones are one-based");
|
||||
assertEqual(wide.foldSegments[1].nyquistZone, 2, "second Nyquist zone");
|
||||
assert(!wide.foldSegments[0].inverted, "first Nyquist zone is not inverted");
|
||||
assert(wide.foldSegments[1].inverted, "second Nyquist zone is inverted");
|
||||
for (const segment of wide.foldSegments) {
|
||||
assertNear(
|
||||
segment.signedAdcStartHz + segment.aliasOrder * wide.sampleRate,
|
||||
segment.analogMinHz,
|
||||
1e-12,
|
||||
`zone ${segment.nyquistZone} start invariant`,
|
||||
);
|
||||
assertNear(
|
||||
segment.signedAdcEndHz + segment.aliasOrder * wide.sampleRate,
|
||||
segment.analogMaxHz,
|
||||
1e-12,
|
||||
`zone ${segment.nyquistZone} end invariant`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseband = observeFrequencyBand({ minHz: 2_900, maxHz: 3_100 }, 8_000, 256);
|
||||
const equivalentAlias = observeFrequencyBand(
|
||||
{ minHz: 4_900, maxHz: 5_100 },
|
||||
8_000,
|
||||
256,
|
||||
);
|
||||
assertEqual(
|
||||
equivalentAlias.adcBinSetKey,
|
||||
baseband.adcBinSetKey,
|
||||
"alias-equivalent requested bands disclose the same ADC bin set",
|
||||
);
|
||||
|
||||
const narrowFold = observeFrequencyBand(
|
||||
{ minHz: 3_999, maxHz: 4_001 },
|
||||
8_000,
|
||||
256,
|
||||
);
|
||||
assert(
|
||||
narrowFold.continuousManyToOne,
|
||||
"a band straddling Nyquist is continuously many-to-one even on a fine FFT grid",
|
||||
);
|
||||
assert(!narrowFold.gridManyToOne, "the single selected bin is not grid many-to-one");
|
||||
assertEqual(narrowFold.bins.join(","), "128", "only the Nyquist bin center is selected");
|
||||
|
||||
let segmentLimitError;
|
||||
try {
|
||||
observeFrequencyBand({ minHz: 0, maxHz: 1_000_000 }, 1, 256);
|
||||
} catch (error) {
|
||||
segmentLimitError = error;
|
||||
}
|
||||
assert(
|
||||
segmentLimitError?.message.includes("explicit limit"),
|
||||
"portable helper rejects excessive fold-zone enumeration before looping",
|
||||
);
|
||||
});
|
||||
|
||||
test("real ADC samples and FFT expose temporal aliases without carrier recovery", () => {
|
||||
const sampleRate = 8_000;
|
||||
const fftSize = 256;
|
||||
const phaseDeg = 23;
|
||||
const scene = { sampleRate, duration: fftSize / sampleRate, seed: 1 };
|
||||
const entry = {
|
||||
id: "temporal-alias",
|
||||
startTime: 0,
|
||||
duration: scene.duration,
|
||||
gainDb: 0,
|
||||
signal: { type: "tone", frequency: 5_000, phaseDeg },
|
||||
};
|
||||
const first = renderSignal(entry, scene);
|
||||
const second = renderSignal(
|
||||
{ ...entry, signal: { ...entry.signal, frequency: 13_000 } },
|
||||
scene,
|
||||
);
|
||||
const phase = (phaseDeg * Math.PI) / 180;
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
const expected = Math.sin(
|
||||
(2 * Math.PI * -3_000 * index) / sampleRate + phase,
|
||||
);
|
||||
assertNear(first[index], expected, 2e-6, `5 kHz alias sample ${index}`);
|
||||
assertNear(second[index], expected, 4e-6, `13 kHz alias sample ${index}`);
|
||||
}
|
||||
const spectrum = fftReal(first);
|
||||
let peakBin = 0;
|
||||
let peakPower = -Infinity;
|
||||
for (let bin = 0; bin <= fftSize / 2; bin += 1) {
|
||||
const power = spectrum.real[bin] ** 2 + spectrum.imaginary[bin] ** 2;
|
||||
if (power > peakPower) {
|
||||
peakPower = power;
|
||||
peakBin = bin;
|
||||
}
|
||||
}
|
||||
assertEqual(peakBin, 96, "5 kHz analog tone appears at the 3 kHz ADC bin");
|
||||
});
|
||||
|
||||
test("undersampled microphone spacing preserves a physical grating-lobe ambiguity", async () => {
|
||||
const sampleRate = 8_000;
|
||||
const fftSize = 256;
|
||||
const frequencyHz = 1_000;
|
||||
const bin = 32;
|
||||
const speedOfSound = 343;
|
||||
const wavelength = speedOfSound / frequencyHz;
|
||||
const microphoneSpacing = 0.5;
|
||||
assert(
|
||||
microphoneSpacing > wavelength / 2,
|
||||
"fixture is spatially undersampled",
|
||||
);
|
||||
const arrayDefinition = {
|
||||
microphones: [
|
||||
{ id: "left", position: [-microphoneSpacing / 2, 0, 0] },
|
||||
{ id: "right", position: [microphoneSpacing / 2, 0, 0] },
|
||||
],
|
||||
};
|
||||
const targetX = -1;
|
||||
const z = 0.5;
|
||||
const pathDifference = (x) =>
|
||||
distance3([x, 0, z], arrayDefinition.microphones[1].position) -
|
||||
distance3([x, 0, z], arrayDefinition.microphones[0].position);
|
||||
const desiredDifference = pathDifference(targetX) - wavelength;
|
||||
let lowerX = targetX;
|
||||
let upperX = 0;
|
||||
for (let iteration = 0; iteration < 80; iteration += 1) {
|
||||
const middleX = (lowerX + upperX) / 2;
|
||||
if (pathDifference(middleX) > desiredDifference) lowerX = middleX;
|
||||
else upperX = middleX;
|
||||
}
|
||||
const ambiguousX = (lowerX + upperX) / 2;
|
||||
assertNear(
|
||||
pathDifference(targetX) - pathDifference(ambiguousX),
|
||||
wavelength,
|
||||
1e-12,
|
||||
"candidate path-difference offset is one wavelength",
|
||||
);
|
||||
|
||||
const angularWaveNumber =
|
||||
(2 * Math.PI * frequencyHz) / speedOfSound;
|
||||
const spectrum = {
|
||||
real: new Float32Array(2),
|
||||
imaginary: new Float32Array(2),
|
||||
};
|
||||
arrayDefinition.microphones.forEach((microphone, index) => {
|
||||
const phase = -angularWaveNumber * distance3([targetX, 0, z], microphone.position);
|
||||
spectrum.real[index] = Math.cos(phase);
|
||||
spectrum.imaginary[index] = Math.sin(phase);
|
||||
});
|
||||
const scan = {
|
||||
xMin: targetX,
|
||||
xMax: ambiguousX,
|
||||
yMin: 0,
|
||||
yMax: 0,
|
||||
z,
|
||||
columns: 2,
|
||||
rows: 1,
|
||||
};
|
||||
const map = await computeBinMap({
|
||||
bin,
|
||||
spectrum,
|
||||
frameCount: 1,
|
||||
arrayDefinition,
|
||||
pointCount: 2,
|
||||
distances: createDistanceMatrix(arrayDefinition, scan),
|
||||
sampleRate,
|
||||
fftSize,
|
||||
speedOfSound,
|
||||
compensateFrequencyResponse: false,
|
||||
algorithm: "das",
|
||||
});
|
||||
assertNear(map[0], map[1], 2e-6, "true point and grating lobe have equal DAS power");
|
||||
assert(map[0] > 0.999, "both ambiguous candidates remain coherent");
|
||||
});
|
||||
|
||||
test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => {
|
||||
const scene = { speedOfSound: 400, seed: 11 };
|
||||
const paths = buildAcousticPaths({
|
||||
@@ -474,6 +695,179 @@ test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () =>
|
||||
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
|
||||
});
|
||||
|
||||
test("above-Nyquist tone keeps analog propagation phase before ADC aliasing", () => {
|
||||
const sampleRate = 8_000;
|
||||
const analogFrequencyHz = 5_000;
|
||||
const speedOfSound = 343;
|
||||
const differentialDelay = 0.00005;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 0.01,
|
||||
speedOfSound,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: "aliased-tone",
|
||||
name: "aliased tone",
|
||||
position: [0, 0, 0],
|
||||
gainDb: 0,
|
||||
startTime: 0,
|
||||
duration: 0.01,
|
||||
signal: { type: "tone", frequency: analogFrequencyHz, phaseDeg: 0 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [
|
||||
{ id: "reference", position: [0, 0, 0] },
|
||||
{ id: "delayed", position: [differentialDelay * speedOfSound, 0, 0] },
|
||||
],
|
||||
};
|
||||
const synthesis = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
const sampleIndex = 20;
|
||||
const sampleTime = sampleIndex / sampleRate;
|
||||
assertNear(
|
||||
synthesis.channels[0][sampleIndex],
|
||||
Math.sin(2 * Math.PI * analogFrequencyHz * sampleTime),
|
||||
1e-6,
|
||||
"reference temporal alias sample",
|
||||
);
|
||||
assertNear(
|
||||
synthesis.channels[1][sampleIndex],
|
||||
Math.sin(2 * Math.PI * analogFrequencyHz * (sampleTime - differentialDelay)),
|
||||
1e-6,
|
||||
"delayed sensor uses original analog carrier phase",
|
||||
);
|
||||
const wrongAliasPhase = Math.sin(
|
||||
2 * Math.PI * 3_000 * (sampleTime - differentialDelay),
|
||||
);
|
||||
assert(
|
||||
Math.abs(synthesis.channels[1][sampleIndex] - wrongAliasPhase) > 0.1,
|
||||
"fractional spatial delay is not rewritten using the positive alias carrier",
|
||||
);
|
||||
assertEqual(
|
||||
synthesis.sampling.toneObservations[0].adcFrequencyHz,
|
||||
3_000,
|
||||
"synthesis exposes the recorded ADC alias",
|
||||
);
|
||||
});
|
||||
|
||||
test("above-Nyquist tone applies microphone response at the analog carrier", () => {
|
||||
const scene = {
|
||||
sampleRate: 8_000,
|
||||
duration: 0.032,
|
||||
speedOfSound: 343,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: "response-tone",
|
||||
name: "response tone",
|
||||
position: [0, 0, 0],
|
||||
gainDb: -20,
|
||||
startTime: 0,
|
||||
duration: 0.032,
|
||||
signal: { type: "tone", frequency: 5_000, phaseDeg: 20 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const baselineArray = {
|
||||
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
||||
};
|
||||
const responseArray = {
|
||||
microphones: [{
|
||||
id: "mic",
|
||||
position: [0, 0, 0],
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 3_000, gainDb: -6.020599913, phaseDeg: -30 },
|
||||
{ frequencyHz: 5_000, gainDb: 6.020599913, phaseDeg: 45 },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const baseline = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition: baselineArray,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
}).channels[0];
|
||||
const filtered = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition: responseArray,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
}).channels[0];
|
||||
assertNear(
|
||||
rms(filtered, 16) / rms(baseline, 16),
|
||||
2,
|
||||
2e-5,
|
||||
"5 kHz response gain is used instead of the 3 kHz ADC alias response",
|
||||
);
|
||||
});
|
||||
|
||||
test("analytic sampler preserves exact aliased DC and Nyquist phase degeneracy", () => {
|
||||
const renderTone = (frequency, phaseDeg) => {
|
||||
const scene = {
|
||||
sampleRate: 8_000,
|
||||
duration: 8 / 8_000,
|
||||
speedOfSound: 343,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: `edge-${frequency}-${phaseDeg}`,
|
||||
name: "edge tone",
|
||||
position: [0, 0, 0],
|
||||
gainDb: 0,
|
||||
startTime: 0,
|
||||
duration: 8 / 8_000,
|
||||
signal: { type: "tone", frequency, phaseDeg },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
return synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition: {
|
||||
microphones: [{ id: "reference", position: [0, 0, 0] }],
|
||||
},
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const vanishedDc = renderTone(8_000, 0);
|
||||
for (const [sampleIndex, value] of vanishedDc.channels[0].entries()) {
|
||||
assertNear(value, 0, 1e-10, `aliased DC phase-zero sample ${sampleIndex}`);
|
||||
}
|
||||
assert(
|
||||
vanishedDc.sampling.toneObservations[0].dcEdge &&
|
||||
vanishedDc.sampling.toneObservations[0].phaseDegenerate,
|
||||
"sample-rate carrier is explicitly a phase-degenerate DC alias",
|
||||
);
|
||||
|
||||
const constantDc = renderTone(8_000, 90);
|
||||
for (const [sampleIndex, value] of constantDc.channels[0].entries()) {
|
||||
assertNear(value, 1, 1e-6, `aliased DC quadrature sample ${sampleIndex}`);
|
||||
}
|
||||
|
||||
const nyquist = renderTone(12_000, 90);
|
||||
for (const [sampleIndex, value] of nyquist.channels[0].entries()) {
|
||||
assertNear(
|
||||
value,
|
||||
sampleIndex % 2 === 0 ? 1 : -1,
|
||||
1e-6,
|
||||
`aliased Nyquist alternating sample ${sampleIndex}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
nyquist.sampling.toneObservations[0].nyquistEdge &&
|
||||
nyquist.sampling.toneObservations[0].phaseDegenerate,
|
||||
"odd Nyquist-zone edge remains explicitly phase-degenerate",
|
||||
);
|
||||
});
|
||||
|
||||
test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => {
|
||||
const scene = {
|
||||
sampleRate: 8000,
|
||||
@@ -937,3 +1331,184 @@ test("absolute scene windows preserve tone, noise, asset, ambient, and synthesis
|
||||
assertNear(value, wholeSynthesis.channels[0][window.startSample + sampleIndex], 2e-5, `mic sample ${sampleIndex}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("quasi-static pre-roll preserves ended noise and asset arrivals before output crop", () => {
|
||||
const sampleRate = 1_000;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 1,
|
||||
speedOfSound: 100,
|
||||
seed: 73,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [
|
||||
{
|
||||
id: "ended-noise",
|
||||
position: [20, 0, 0],
|
||||
gainDb: -12,
|
||||
startTime: 0.1,
|
||||
duration: 0.2,
|
||||
signal: { type: "noise", color: "white", seedOffset: 11 },
|
||||
},
|
||||
{
|
||||
id: "ended-asset",
|
||||
position: [20, 0, 0],
|
||||
gainDb: -9,
|
||||
startTime: 0.1,
|
||||
duration: 0.2,
|
||||
signal: { type: "asset", assetId: "tail-clip", channel: 0, loop: false },
|
||||
},
|
||||
],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
||||
};
|
||||
const assetBuffers = new Map([["tail-clip", {
|
||||
sampleRate,
|
||||
channels: [Float32Array.from(
|
||||
{ length: 300 },
|
||||
(_, index) => 0.45 * Math.sin((2 * Math.PI * index) / 29) + (index % 17) / 80,
|
||||
)],
|
||||
}]]);
|
||||
const whole = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
const window = sampleQuasiStaticWindow(scene, 0.4, 0.5, {
|
||||
arrayDefinition,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertEqual(window.preRollSamples, 200, "direct-path pre-roll");
|
||||
assertEqual(
|
||||
window.scene.sources.map(({ id }) => id).join(","),
|
||||
"ended-noise,ended-asset",
|
||||
"sources that stopped before the visible window remain in the render interval",
|
||||
);
|
||||
const partial = synthesizeMicrophoneSignals({
|
||||
scene: window.scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
signalOffsets: window.signalOffsets,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertEqual(partial.channels[0].length, 100, "only the requested output window is returned");
|
||||
assertEqual(partial.windowing.preRollSamples, 200, "synthesis records applied pre-roll");
|
||||
assertEqual(
|
||||
partial.windowing.delayedTailPolicy,
|
||||
"propagation-preroll-then-output-crop",
|
||||
"tail policy metadata",
|
||||
);
|
||||
assertEqual(
|
||||
partial.windowing.arrivingSourceIds.join(","),
|
||||
"ended-noise,ended-asset",
|
||||
"arrival-overlap metadata includes both sampled sources",
|
||||
);
|
||||
for (const arrival of partial.windowing.sourceArrivals) {
|
||||
assert(arrival.emissionEndSample < partial.windowing.outputStartSample, `${arrival.sourceId} ended before output`);
|
||||
assert(arrival.overlapsOutput, `${arrival.sourceId} has a path arrival inside output`);
|
||||
assertEqual(arrival.earliestArrivalSample, 300, `${arrival.sourceId} earliest direct arrival`);
|
||||
assertEqual(arrival.latestArrivalEndSample, 500, `${arrival.sourceId} direct tail end`);
|
||||
}
|
||||
assert(rms(partial.channels[0]) > 1e-4, "the delayed sampled tail reaches the output window");
|
||||
partial.channels[0].forEach((value, sampleIndex) => {
|
||||
assertNear(
|
||||
value,
|
||||
whole.channels[0][window.startSample + sampleIndex],
|
||||
3e-5,
|
||||
`sampled tail ${sampleIndex}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("quasi-static pre-roll preserves reflection and diffuse tone tails", () => {
|
||||
const sampleRate = 2_000;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 1,
|
||||
speedOfSound: 100,
|
||||
seed: 91,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
acoustics: {
|
||||
model: "shoebox",
|
||||
room: { min: [0, 0, 0], max: [10, 10, 10] },
|
||||
reflections: { order: 1, wallGainDb: -5, scattering: 0.3 },
|
||||
diffuseReverb: { enabled: true, rt60Seconds: 0.4, gainDb: -8, tapCount: 4 },
|
||||
occluders: [],
|
||||
},
|
||||
sources: [{
|
||||
id: "ended-tone",
|
||||
position: [2, 2, 2],
|
||||
gainDb: -12,
|
||||
startTime: 0.05,
|
||||
duration: 0.1,
|
||||
signal: { type: "tone", frequency: 137, phaseDeg: 31 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [{ id: "mic", position: [4, 2, 2] }],
|
||||
};
|
||||
const synthesisOptions = {
|
||||
arrayDefinition,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
};
|
||||
const whole = synthesizeMicrophoneSignals({ scene, ...synthesisOptions });
|
||||
assert(whole.acousticPathCounts.reflection > 0, "fixture contains image-source reflections");
|
||||
assert(whole.acousticPathCounts.diffuse > 0, "fixture contains diffuse tail taps");
|
||||
|
||||
for (const [startTime, endTime, label] of [
|
||||
[0.22, 0.27, "reflection interval"],
|
||||
[0.42, 0.48, "late diffuse interval"],
|
||||
]) {
|
||||
const window = sampleQuasiStaticWindow(scene, startTime, endTime, synthesisOptions);
|
||||
assertEqual(window.scene.sources[0].id, "ended-tone", `${label} retains ended source`);
|
||||
const partial = synthesizeMicrophoneSignals({
|
||||
scene: window.scene,
|
||||
signalOffsets: window.signalOffsets,
|
||||
...synthesisOptions,
|
||||
});
|
||||
assertEqual(
|
||||
partial.windowing.geometryModel,
|
||||
"source-position-fixed-at-window-midpoint",
|
||||
`${label} geometry metadata`,
|
||||
);
|
||||
assertEqual(partial.windowing.dopplerMode, "not-modeled", `${label} Doppler metadata`);
|
||||
assertEqual(
|
||||
partial.windowing.arrivingSourceIds.join(","),
|
||||
"ended-tone",
|
||||
`${label} arrival truth source`,
|
||||
);
|
||||
assert(
|
||||
partial.windowing.sourceArrivals[0].emissionEndSample <
|
||||
partial.windowing.outputStartSample,
|
||||
`${label} source emission ended before output`,
|
||||
);
|
||||
if (label === "late diffuse interval") {
|
||||
assertEqual(
|
||||
partial.windowing.sourceArrivals[0].overlappingPathKinds.join(","),
|
||||
"diffuse",
|
||||
"late interval truth is justified by a diffuse arrival",
|
||||
);
|
||||
} else {
|
||||
assert(
|
||||
partial.windowing.sourceArrivals[0].overlappingPathKinds.includes("reflection"),
|
||||
"reflection interval truth includes an image-source arrival",
|
||||
);
|
||||
}
|
||||
assert(rms(partial.channels[0]) > 1e-6, `${label} contains propagated energy`);
|
||||
partial.channels[0].forEach((value, sampleIndex) => {
|
||||
assertNear(
|
||||
value,
|
||||
whole.channels[0][window.startSample + sampleIndex],
|
||||
2e-6,
|
||||
`${label} sample ${sampleIndex}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
enableSourceTrajectory,
|
||||
createSceneId,
|
||||
createSource,
|
||||
describeSignal,
|
||||
insertTrajectoryKeyframe,
|
||||
matchAssetDescriptor,
|
||||
prepareSceneForEditing,
|
||||
@@ -49,6 +50,29 @@ test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든
|
||||
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
|
||||
});
|
||||
|
||||
test("Builder tone summary exposes the ideal ADC alias above Nyquist", () => {
|
||||
assertEqual(
|
||||
describeSignal({ type: "tone", frequency: 5_000 }, 8_000),
|
||||
"5000 Hz tone → ADC 3000 Hz alias",
|
||||
"above-Nyquist source summary",
|
||||
);
|
||||
assertEqual(
|
||||
describeSignal({ type: "tone", frequency: 1_000 }, 8_000),
|
||||
"1000 Hz tone",
|
||||
"baseband source summary",
|
||||
);
|
||||
assertEqual(
|
||||
describeSignal({ type: "tone", frequency: 8_000 }, 8_000),
|
||||
"8000 Hz tone → ADC 0 Hz alias · DC phase-degenerate edge",
|
||||
"aliased exact-DC source summary",
|
||||
);
|
||||
assertEqual(
|
||||
describeSignal({ type: "tone", frequency: 12_000 }, 8_000),
|
||||
"12000 Hz tone → ADC 4000 Hz alias · Nyquist phase-degenerate edge",
|
||||
"aliased exact-Nyquist source summary",
|
||||
);
|
||||
});
|
||||
|
||||
test("Builder가 이동 경로를 순서대로 편집하고 fallback position을 첫 점에 맞춘다", () => {
|
||||
const source = { position: [0.5, 0.1, 2] };
|
||||
const trajectory = enableSourceTrajectory(source, 2, { xMin: -0.6, xMax: 0.6, yMin: -0.45, yMax: 0.45 });
|
||||
|
||||
+194
-2
@@ -22,6 +22,7 @@ import {
|
||||
shiftSceneDistance,
|
||||
} from "/apps/acoustic-camera/model.js";
|
||||
import { validateResult } from "/packages/contracts/validation.js";
|
||||
import { sourcesForObservedBand } from "/apps/acoustic-camera/source-selection.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(
|
||||
@@ -56,6 +57,56 @@ test("Camera detects moving source trajectories", () => {
|
||||
}), true, "moving scene");
|
||||
});
|
||||
|
||||
test("Camera live truth uses synthesized path arrivals instead of emission midpoint", () => {
|
||||
const scene = {
|
||||
sampleRate: 8_000,
|
||||
duration: 1,
|
||||
sources: [
|
||||
{
|
||||
id: "ended-tone-tail",
|
||||
startTime: 0,
|
||||
duration: 0.1,
|
||||
signal: { type: "tone", frequency: 1_000 },
|
||||
},
|
||||
{
|
||||
id: "ended-noise-tail",
|
||||
startTime: 0,
|
||||
duration: 0.1,
|
||||
signal: { type: "noise", color: "white" },
|
||||
},
|
||||
{
|
||||
id: "wrong-band-tone",
|
||||
startTime: 0,
|
||||
duration: 0.1,
|
||||
signal: { type: "tone", frequency: 2_000 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const band = { minHz: 900, maxHz: 1_100 };
|
||||
const fallback = sourcesForObservedBand(scene, band, 256, { time: 0.5 });
|
||||
assertEqual(fallback.length, 0, "legacy emission-time fallback sees no active source");
|
||||
assertEqual(
|
||||
sourcesForObservedBand(scene, band, 256, { time: 0.1 }).length,
|
||||
0,
|
||||
"emission-time fallback treats the exact end boundary as half-open",
|
||||
);
|
||||
|
||||
const arrived = sourcesForObservedBand(scene, band, 256, {
|
||||
time: 0.5,
|
||||
arrivingSourceIds: ["ended-tone-tail", "ended-noise-tail", "wrong-band-tone"],
|
||||
});
|
||||
assertEqual(
|
||||
arrived.map(({ id }) => id).join(","),
|
||||
"ended-tone-tail,ended-noise-tail",
|
||||
"path-arrival ids override time while band selection remains independent",
|
||||
);
|
||||
assertEqual(
|
||||
sourcesForObservedBand(scene, band, 256, { arrivingSourceIds: [] }).length,
|
||||
0,
|
||||
"an explicit empty arrival set does not fall back to emission activity",
|
||||
);
|
||||
});
|
||||
|
||||
test("Camera analysis plan covers a scene and respects its frame budget", () => {
|
||||
const scene = { duration: 2, sampleRate: 1000, sources: [] };
|
||||
const options = { fftSize: 100, targetMapHz: 4, maxFrames: 3 };
|
||||
@@ -460,7 +511,12 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
};
|
||||
const scene = {
|
||||
id: "test-scene",
|
||||
sources: [{ id: "source-1", position: [0, 0, 2] }],
|
||||
sampleRate: 8_000,
|
||||
sources: [{
|
||||
id: "source-1",
|
||||
position: [0, 0, 2],
|
||||
signal: { type: "tone", frequency: 5_000, phaseDeg: 0 },
|
||||
}],
|
||||
};
|
||||
const experiment = {
|
||||
id: "test-experiment",
|
||||
@@ -468,7 +524,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
sceneId: scene.id,
|
||||
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
|
||||
beamforming: { algorithm: "das-phat", backend: "auto" },
|
||||
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
|
||||
stft: { fftSize: 256, hopSize: 128, window: "hann" },
|
||||
frequencyBands: [{
|
||||
id: "band-1",
|
||||
minHz: 4_900,
|
||||
maxHz: 5_100,
|
||||
color: "#65f2bf",
|
||||
}, {
|
||||
id: "band-2",
|
||||
minHz: 2_900,
|
||||
maxHz: 3_100,
|
||||
color: "#58c7ff",
|
||||
}],
|
||||
};
|
||||
const run = {
|
||||
algorithm: "das-phat",
|
||||
@@ -481,6 +548,23 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
acousticModel: "shoebox",
|
||||
acousticPathCount: 38,
|
||||
acousticPathCounts: { direct: 2, reflection: 12, scattering: 12, diffuse: 12 },
|
||||
sampling: {
|
||||
model: "ideal-real-sampler-no-antialias-filter",
|
||||
sampleRate: 8_000,
|
||||
nyquistHz: 4_000,
|
||||
toneObservations: [{
|
||||
scope: "source",
|
||||
id: "source-1",
|
||||
analogFrequencyHz: 5_000,
|
||||
adcFrequencyHz: 3_000,
|
||||
signedAdcFrequencyHz: -3_000,
|
||||
aliased: true,
|
||||
spectralInversion: true,
|
||||
dcEdge: false,
|
||||
nyquistEdge: false,
|
||||
phaseDegenerate: false,
|
||||
}],
|
||||
},
|
||||
},
|
||||
timingMs: { total: 12.5, signalGeneration: 2.5, beamforming: 10 },
|
||||
fallback: { code: "webgpu-unavailable", message: "CPU fallback" },
|
||||
@@ -491,6 +575,13 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||
displayScale: "normalized",
|
||||
}, {
|
||||
id: "band-2",
|
||||
color: "#58c7ff",
|
||||
rawScale: "score",
|
||||
rawMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||
displayMap: Float32Array.of(0.1, 0.2, 0.3, 1),
|
||||
displayScale: "normalized",
|
||||
}],
|
||||
};
|
||||
const evaluations = new Map([["band-1", {
|
||||
@@ -539,6 +630,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
maximumDetections: 128,
|
||||
},
|
||||
}]]);
|
||||
evaluations.set("band-2", evaluations.get("band-1"));
|
||||
|
||||
const result = createResultDocument({
|
||||
arrayDefinition,
|
||||
@@ -550,6 +642,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
createdAt: "2026-08-28T00:00:00.000Z",
|
||||
});
|
||||
|
||||
assertEqual(result.schemaVersion, "1.1.0", "sampling-aware Result contract version");
|
||||
assert(Array.isArray(result.bands[0].rawMap.values), "typed raw map becomes JSON array");
|
||||
assertEqual(result.algorithm, "das-phat", "actual algorithm is exported");
|
||||
assertEqual(result.timingMs.localization, 10, "generic localization timing is exported");
|
||||
@@ -570,6 +663,41 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
assertEqual(result.signalStats.acousticModel, "shoebox", "acoustic model is exported");
|
||||
assertEqual(result.signalStats.acousticPathCount, 38, "acoustic path count is exported");
|
||||
assertEqual(result.signalStats.acousticPathCounts.diffuse, 12, "path kinds are exported");
|
||||
assertEqual(
|
||||
result.signalStats.sampling.toneObservations[0].adcFrequencyHz,
|
||||
3_000,
|
||||
"ADC alias is exported",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].frequencyObservation.adcBins.join(","),
|
||||
"93,94,95,96,97,98,99",
|
||||
"requested analog band exports every folded ADC bin",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].frequencyObservation.equivalentBandIds.join(","),
|
||||
"band-2",
|
||||
"alias-equivalent band is disclosed",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[1].frequencyObservation.equivalentBandIds.join(","),
|
||||
"band-1",
|
||||
"equivalence is symmetric",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].frequencyObservation.localizationModel,
|
||||
"spherical-steering-at-positive-adc-bin-frequency",
|
||||
"actual DAS-family localization model is explicit",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].frequencyObservation.originalCarrierUsed,
|
||||
false,
|
||||
"result does not imply hidden original-carrier steering",
|
||||
);
|
||||
assertEqual(
|
||||
result.bands[0].evaluation.truthSelection,
|
||||
"tone-analog-interval-or-nearest-adc-bin-and-all-nontone-sources",
|
||||
"truth-selection policy is explicit",
|
||||
);
|
||||
|
||||
const history = createHistorySummary({
|
||||
arrayDefinition,
|
||||
@@ -584,6 +712,70 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
|
||||
|
||||
const issues = await validateResult(result, { experiment, scene });
|
||||
assertEqual(issues.length, 0, `generated result validation: ${JSON.stringify(issues)}`);
|
||||
assertEqual(
|
||||
(await validateResult(result)).length,
|
||||
0,
|
||||
"generated Result is self-consistent without external context",
|
||||
);
|
||||
|
||||
const standaloneCorruption = structuredClone(result);
|
||||
standaloneCorruption.signalStats.sampling.nyquistHz += 1;
|
||||
standaloneCorruption.signalStats.sampling.toneObservations.push(
|
||||
structuredClone(standaloneCorruption.signalStats.sampling.toneObservations[0]),
|
||||
);
|
||||
standaloneCorruption.bands[0].frequencyObservation.adcBins.push(
|
||||
standaloneCorruption.bands[0].frequencyObservation.adcBins[0],
|
||||
);
|
||||
standaloneCorruption.bands[0].frequencyObservation.equivalentBandIds.push("band-2");
|
||||
standaloneCorruption.bands[0].frequencyObservation.localizationModel =
|
||||
"band-limited-gcc-phat-geometric-lag";
|
||||
const standaloneIssues = await validateResult(standaloneCorruption);
|
||||
assert(
|
||||
standaloneIssues.some(({ path }) => path === "$.signalStats.sampling.nyquistHz"),
|
||||
"standalone validator checks Nyquist self-consistency",
|
||||
);
|
||||
assert(
|
||||
standaloneIssues.some(({ message }) => message.includes("duplicate tone observation")),
|
||||
"standalone validator rejects duplicate tone provenance",
|
||||
);
|
||||
assert(
|
||||
standaloneIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
|
||||
"standalone validator recomputes unique ADC bins",
|
||||
);
|
||||
assert(
|
||||
standaloneIssues.some(({ path }) =>
|
||||
path.endsWith("frequencyObservation.equivalentBandIds")),
|
||||
"standalone validator recomputes equivalent band IDs",
|
||||
);
|
||||
assert(
|
||||
standaloneIssues.some(({ path }) =>
|
||||
path.endsWith("frequencyObservation.localizationModel")),
|
||||
"standalone validator links localization model to actual algorithm",
|
||||
);
|
||||
|
||||
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz += 1;
|
||||
let semanticIssues = await validateResult(result, { experiment, scene });
|
||||
assert(
|
||||
semanticIssues.some(({ path }) => path.endsWith("signedAdcFrequencyHz")),
|
||||
"validator recomputes the temporal alias",
|
||||
);
|
||||
result.signalStats.sampling.toneObservations[0].signedAdcFrequencyHz -= 1;
|
||||
|
||||
result.bands[0].frequencyObservation.adcBins[0] += 1;
|
||||
semanticIssues = await validateResult(result, { experiment, scene });
|
||||
assert(
|
||||
semanticIssues.some(({ path }) => path.endsWith("frequencyObservation.adcBins")),
|
||||
"validator recomputes folded ADC bins",
|
||||
);
|
||||
result.bands[0].frequencyObservation.adcBins[0] -= 1;
|
||||
|
||||
const missingBandResult = structuredClone(result);
|
||||
missingBandResult.bands.pop();
|
||||
semanticIssues = await validateResult(missingBandResult, { experiment, scene });
|
||||
assert(
|
||||
semanticIssues.some(({ message }) => message.includes("missing Experiment band result")),
|
||||
"validator rejects incomplete Result band sets",
|
||||
);
|
||||
|
||||
const wrongExperiment = { ...experiment, arrayId: "another-array" };
|
||||
const linkageIssues = await validateResult(result, { experiment: wrongExperiment, scene });
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
validateResult,
|
||||
validateScene,
|
||||
} from "/packages/contracts/validation.js";
|
||||
import {
|
||||
describeFrequencyAliasing,
|
||||
observeFrequencyBand,
|
||||
} from "/packages/acoustics-core/sampling.js";
|
||||
|
||||
async function loadJson(path) {
|
||||
const response = await fetch(path);
|
||||
@@ -11,6 +15,75 @@ async function loadJson(path) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function resultFrequencyObservation(scene, experiment, band, equivalentBandIds = []) {
|
||||
const observation = observeFrequencyBand(
|
||||
band,
|
||||
scene.sampleRate,
|
||||
experiment.stft.fftSize,
|
||||
);
|
||||
return {
|
||||
model: observation.model,
|
||||
sampleRate: observation.sampleRate,
|
||||
nyquistHz: observation.nyquistHz,
|
||||
fftSize: observation.fftSize,
|
||||
binWidthHz: observation.binWidthHz,
|
||||
requestedMinHz: observation.requestedMinHz,
|
||||
requestedMaxHz: observation.requestedMaxHz,
|
||||
aliased: observation.aliased,
|
||||
crossesFirstNyquist: observation.crossesFirstNyquist,
|
||||
localizationInputModel: observation.localizationInputModel,
|
||||
selectionModel: observation.selectionModel,
|
||||
localizationModel:
|
||||
experiment.beamforming?.algorithm === "gcc-phat"
|
||||
? "band-limited-gcc-phat-geometric-lag"
|
||||
: "spherical-steering-at-positive-adc-bin-frequency",
|
||||
originalCarrierUsed: observation.originalCarrierUsed,
|
||||
aliasOrderResolution: observation.aliased ? "not-attempted" : "not-applicable",
|
||||
foldBoundaryCount: observation.foldBoundaryCount,
|
||||
continuousManyToOne: observation.continuousManyToOne,
|
||||
gridManyToOne: observation.gridManyToOne,
|
||||
foldSegments: observation.foldSegments.map((segment) => ({ ...segment })),
|
||||
adcBins: [...observation.bins],
|
||||
adcBinCenterRanges: observation.adcBinCenterRanges.map((range) => ({ ...range })),
|
||||
adcBinSetKey: observation.adcBinSetKey,
|
||||
equivalentBandIds,
|
||||
};
|
||||
}
|
||||
|
||||
function resultSampling(scene) {
|
||||
const toneObservations = [];
|
||||
for (const [scope, entries] of [
|
||||
["source", scene.sources ?? []],
|
||||
["ambient", scene.ambient ?? []],
|
||||
]) {
|
||||
for (const entry of entries) {
|
||||
if (entry.signal?.type !== "tone") continue;
|
||||
const observation = describeFrequencyAliasing(
|
||||
entry.signal.frequency,
|
||||
scene.sampleRate,
|
||||
);
|
||||
toneObservations.push({
|
||||
scope,
|
||||
id: entry.id,
|
||||
analogFrequencyHz: observation.analogFrequencyHz,
|
||||
adcFrequencyHz: observation.adcFrequencyHz,
|
||||
signedAdcFrequencyHz: observation.signedAdcFrequencyHz,
|
||||
aliased: observation.aliased,
|
||||
spectralInversion: observation.spectralInversion,
|
||||
dcEdge: observation.dcEdge,
|
||||
nyquistEdge: observation.nyquistEdge,
|
||||
phaseDegenerate: observation.phaseDegenerate,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
model: "ideal-real-sampler-no-antialias-filter",
|
||||
sampleRate: scene.sampleRate,
|
||||
nyquistHz: scene.sampleRate / 2,
|
||||
toneObservations,
|
||||
};
|
||||
}
|
||||
|
||||
const RECTANGULAR_ARRAY_EXAMPLES = [
|
||||
{ id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 },
|
||||
{ id: "rectangular-4x3-450mm", microphoneCount: 12, columns: 4, rows: 3, width: 0.45, height: 0.45 },
|
||||
@@ -329,7 +402,7 @@ test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름
|
||||
);
|
||||
});
|
||||
|
||||
test("Nyquist를 넘는 tone을 거부한다", async () => {
|
||||
test("Nyquist를 넘는 tone을 ideal ADC alias 실험으로 허용한다", async () => {
|
||||
const scene = {
|
||||
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
|
||||
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 },
|
||||
@@ -338,7 +411,74 @@ test("Nyquist를 넘는 tone을 거부한다", async () => {
|
||||
signal: { type: "tone", frequency: 5000 },
|
||||
}],
|
||||
};
|
||||
assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency")));
|
||||
assertEqual((await validateScene(scene)).length, 0, "above-Nyquist tone is structurally valid");
|
||||
|
||||
scene.sources[0].signal.frequency = 1_000_001;
|
||||
assert(
|
||||
(await validateScene(scene)).some(({ path }) =>
|
||||
path === "$.sources[0].signal.frequency"),
|
||||
"tone above the explicit numerical model range is rejected, not silently rounded",
|
||||
);
|
||||
});
|
||||
|
||||
test("Experiment band는 Nyquist 위에서도 허용하되 FFT bin center가 하나는 있어야 한다", async () => {
|
||||
const [scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
experiment.frequencyBands[0].minHz = 24_900;
|
||||
experiment.frequencyBands[0].maxHz = 25_100;
|
||||
assertEqual(
|
||||
(await validateExperiment(experiment, { scene })).length,
|
||||
0,
|
||||
"above-Nyquist band with folded bin centers is valid",
|
||||
);
|
||||
|
||||
experiment.frequencyBands[0].minHz = 1_000.1;
|
||||
experiment.frequencyBands[0].maxHz = 1_000.2;
|
||||
let issues = await validateExperiment(experiment, { scene });
|
||||
assert(
|
||||
issues.some(({ path, message }) =>
|
||||
path === "$.frequencyBands[0]" && message.includes("bin center")),
|
||||
"a sub-bin band fails validation before runtime",
|
||||
);
|
||||
|
||||
experiment.frequencyBands[0].minHz = 999_900;
|
||||
experiment.frequencyBands[0].maxHz = 1_000_001;
|
||||
issues = await validateExperiment(experiment, { scene });
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.frequencyBands[0].maxHz"),
|
||||
"band above the explicit numerical model range is rejected",
|
||||
);
|
||||
|
||||
experiment.frequencyBands = {};
|
||||
issues = await validateExperiment(experiment, { scene });
|
||||
assert(
|
||||
issues.some(({ path }) => path === "$.frequencyBands"),
|
||||
"malformed frequencyBands returns validation issues instead of throwing",
|
||||
);
|
||||
});
|
||||
|
||||
test("analytic tone-only Scene은 존재하지 않는 delay FFT 메모리로 거부하지 않는다", async () => {
|
||||
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 1);
|
||||
scene.sampleRate = 192_000;
|
||||
scene.duration = 30;
|
||||
scene.sources = Array.from({ length: 32 }, (_, index) => ({
|
||||
...structuredClone(scene.sources[0]),
|
||||
id: `tone-${index + 1}`,
|
||||
name: `Tone ${index + 1}`,
|
||||
duration: 30,
|
||||
}));
|
||||
const issues = await validateExperiment(experiment, { arrayDefinition, scene });
|
||||
assert(
|
||||
!issues.some(({ message }) => message.includes("512 MiB")),
|
||||
`analytic tones must not allocate sampled source/FFT buffers: ${JSON.stringify(issues)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("NaN 좌표를 거부한다", async () => {
|
||||
@@ -395,7 +535,7 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
const result = {
|
||||
schemaVersion: "1.0.0",
|
||||
schemaVersion: "1.1.0",
|
||||
kind: "micarrsim.result",
|
||||
experimentId: experiment.id,
|
||||
arrayId: experiment.arrayId,
|
||||
@@ -404,11 +544,26 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
||||
backend: "cpu",
|
||||
algorithm: "das",
|
||||
inputHash: "a".repeat(64),
|
||||
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
|
||||
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
|
||||
signalStats: {
|
||||
preNormalizationPeak: 0.5,
|
||||
appliedScale: 1,
|
||||
clippedSampleCount: 0,
|
||||
sampling: resultSampling(scene),
|
||||
},
|
||||
timingMs: {
|
||||
total: 10,
|
||||
signalGeneration: 3,
|
||||
localization: 7,
|
||||
beamforming: 7,
|
||||
},
|
||||
bands: [{
|
||||
id: "tone-1k",
|
||||
color: "#65f2bf",
|
||||
frequencyObservation: resultFrequencyObservation(
|
||||
scene,
|
||||
experiment,
|
||||
experiment.frequencyBands[0],
|
||||
),
|
||||
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
|
||||
peaks: [{
|
||||
sourceId: "source-1",
|
||||
@@ -437,6 +592,66 @@ test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", a
|
||||
};
|
||||
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
|
||||
|
||||
const mismatchedTimingResult = structuredClone(result);
|
||||
mismatchedTimingResult.timingMs.localization = 8;
|
||||
const mismatchedTimingIssues = await validateResult(
|
||||
mismatchedTimingResult,
|
||||
{ experiment, scene },
|
||||
);
|
||||
assert(
|
||||
mismatchedTimingIssues.some(
|
||||
({ path }) => path === "$.timingMs.localization",
|
||||
),
|
||||
"localization timing and its compatibility alias must agree",
|
||||
);
|
||||
|
||||
const missingLocalizationTiming = structuredClone(result);
|
||||
delete missingLocalizationTiming.timingMs.localization;
|
||||
const missingLocalizationIssues = await validateResult(
|
||||
missingLocalizationTiming,
|
||||
{ experiment, scene },
|
||||
);
|
||||
assert(
|
||||
missingLocalizationIssues.some(
|
||||
({ path }) => path === "$.timingMs.localization",
|
||||
),
|
||||
"Result 1.1 requires generic localization timing",
|
||||
);
|
||||
|
||||
const legacyResult = structuredClone(result);
|
||||
legacyResult.schemaVersion = "1.0.0";
|
||||
const legacyIssues = await validateResult(legacyResult, {
|
||||
experiment,
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
legacyIssues.some(({ path }) => path === "$.schemaVersion"),
|
||||
"sampling-aware Result validation must reject legacy 1.0 implicitly",
|
||||
);
|
||||
|
||||
const impossibleBackendResult = structuredClone(result);
|
||||
impossibleBackendResult.algorithm = "gcc-phat";
|
||||
impossibleBackendResult.backend = "webgpu";
|
||||
const impossibleBackendIssues = await validateResult(
|
||||
impossibleBackendResult,
|
||||
);
|
||||
assert(
|
||||
impossibleBackendIssues.some(({ path }) => path === "$.backend"),
|
||||
"standalone Result rejects the unsupported GCC-PHAT WebGPU combination",
|
||||
);
|
||||
|
||||
const malformedResult = structuredClone(result);
|
||||
delete malformedResult.bands[0].id;
|
||||
malformedResult.bands[0].processedMaps = {};
|
||||
const malformedIssues = await validateResult(malformedResult, {
|
||||
experiment,
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
malformedIssues.length > 0,
|
||||
"malformed Result fields return validation issues instead of throwing",
|
||||
);
|
||||
|
||||
experiment.beamforming.algorithm = "das-phat";
|
||||
result.algorithm = "das-phat";
|
||||
result.bands[0].rawMap.scale = "score";
|
||||
|
||||
@@ -112,6 +112,30 @@ test("portable localizer가 Scene 합성 없이 기존 CPU DAS와 bit-exact map
|
||||
);
|
||||
});
|
||||
|
||||
test("portable above-Nyquist band observes the folded ADC bin without hidden carrier steering", async () => {
|
||||
const fixture = createFixture(128);
|
||||
const basebandPlan = createLocalizationPlan(fixture.config);
|
||||
const aliasedPlan = createLocalizationPlan({
|
||||
...fixture.config,
|
||||
bands: [{ id: "aliased", minHz: 3_572, maxHz: 3_596, color: "#58c7ff" }],
|
||||
});
|
||||
const baseband = await localizeMultichannel({
|
||||
plan: basebandPlan,
|
||||
channels: fixture.channels,
|
||||
});
|
||||
const aliased = await localizeMultichannel({
|
||||
plan: aliasedPlan,
|
||||
channels: fixture.channels,
|
||||
});
|
||||
|
||||
assertEqual(aliasedPlan.bands[0].minHz, 3_572, "requested analog band is retained");
|
||||
assertFloat32Exact(
|
||||
aliased.bands[0].rawMap,
|
||||
baseband.bands[0].rawMap,
|
||||
"same ADC bin produces the same conventional DAS map",
|
||||
);
|
||||
});
|
||||
|
||||
test("portable raw-map peak detection이 같은 band의 두 진원 후보를 강도순으로 보존한다", () => {
|
||||
const scan = {
|
||||
z: 1,
|
||||
|
||||
Reference in New Issue
Block a user