perf: stream propagation working sets
This commit is contained in:
@@ -28,6 +28,7 @@ function signalStats(synthesis) {
|
||||
acousticModel: synthesis.acousticModel,
|
||||
acousticPathCount: synthesis.acousticPathCount,
|
||||
acousticPathCounts: synthesis.acousticPathCounts,
|
||||
workingSet: synthesis.workingSet,
|
||||
windowing: synthesis.windowing,
|
||||
sampling: synthesis.sampling,
|
||||
};
|
||||
@@ -79,6 +80,7 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
|
||||
plannedFrame.endTime,
|
||||
{
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
minimumDistance: experiment.beamforming.minimumDistance,
|
||||
},
|
||||
);
|
||||
|
||||
+18
-7
@@ -84,24 +84,29 @@ Scene JSON의 사용자 설정 예는 다음과 같다.
|
||||
|
||||
## 라이브 창의 전파 지연과 이동 근사
|
||||
|
||||
라이브 frame의 출력 sample 구간을 `[S, E)`, 현재 quasi-static geometry에서 모든
|
||||
source–microphone 희소 경로의 최대 지연을 `Dmax`라 하자. 합성기는 다음 순서로 frame을 만든다.
|
||||
라이브 frame의 출력 sample 구간을 `[S, E)`, 현재 quasi-static geometry에서 sampled
|
||||
source–microphone 희소 경로의 최대 지연을 `Dsampled`라 하자. 전체 경로 최대 `Dall`은
|
||||
tone tail의 도착 가능성 판정과 metadata에 별도로 보존한다. 합성기는 다음 순서로 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에 넘긴다.
|
||||
2. `R = max(0, S - ceil(Dsampled × sampleRate))`를 sampled 방사 입력의 시작으로 정한다.
|
||||
3. analytic tone은 `[S, E)` 크기의 마이크 channel에 원래 Scene의 절대 sample 위상으로 직접
|
||||
경로를 더한다. tone에는 과거 sample buffer가 필요 없다.
|
||||
4. noise와 asset은 `[R, E)` 방사 buffer와 source별 최대 지연의 zero-padded FFT를 만들고,
|
||||
direct·reflection·scattering·diffraction·diffuse 전달함수를 적용한 IFFT의 `[S, E)` 부분만
|
||||
crop 크기 마이크 channel에 더한다.
|
||||
|
||||
따라서 source가 `S` 전에 멈췄더라도 그 직접음·반사음·diffuse tail이 창 안에 도착하면 보존된다.
|
||||
analytic tone뿐 아니라 deterministic noise index와 asset 재생 위치도 원래 절대 시간축을 유지한다.
|
||||
`synthesis.windowing`과 live frame의 `signalStats.windowing`은 render/output sample, pre-roll,
|
||||
최대 지연, geometry와 Doppler 정책을 기록한다.
|
||||
전체/실제 render 최대 지연, 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 검출에는
|
||||
입력하지 않는다.
|
||||
loop하지 않는 asset의 emission end는 선언 duration과 decoded channel duration 중 작은 값이다.
|
||||
leading/trailing zero sample 내용은 검사하지 않는다.
|
||||
|
||||
다만 이는 moving wave equation 해법이 아니다. pre-roll 방사 시각의 retarded source 위치를 다시
|
||||
구하지 않고 전체 render buffer에서 창 중앙 위치와 그 위치의 경로를 고정한다. 창 안의 연속 이동,
|
||||
@@ -114,6 +119,12 @@ pre-roll은 현재 희소 **전파 경로**의 최대 지연을 보존하지만,
|
||||
`mix.normalization: peak`이면 live 합성은 각 출력 crop의 peak로 scale을 계산하므로 frame 간
|
||||
절대 진폭 비교에는 `normalization: none`을 사용해야 한다.
|
||||
|
||||
마이크 출력은 처음부터 `[S,E)` 크기로만 할당하고 sampled source buffer·spectrum은 source
|
||||
하나씩 처리한다. source별 경로 최대 지연을 `D_i`라 하면 unrelated far tone의 global
|
||||
`Dmax`가 noise·asset FFT를 키우지 않는다. Experiment 검증과 runtime은 같은 estimator를 사용해
|
||||
shoebox의 모든 실제 sparse path 지연을 포함한다. 현재 sampled source는 source–microphone
|
||||
조합마다 IFFT 한 번을 수행하므로, source batching으로 IFFT 수를 줄이는 작업은 남아 있다.
|
||||
|
||||
## Camera 환경 preset과 비용
|
||||
|
||||
Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음
|
||||
|
||||
@@ -217,7 +217,9 @@ Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검
|
||||
- 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계
|
||||
- map shape와 source peak 매칭
|
||||
- band별 evaluation 파라미터와 허용 metric
|
||||
- Snapshot channel·최대 sparse delay FFT working set의 512 MiB 예산
|
||||
- crop 크기 microphone channel과 한 sampled source의 자체 최대 sparse-delay
|
||||
samples·spectrum·workspace를 합친 typed working set의 512 MiB 예산. validator와 runtime은
|
||||
reflection·scattering·diffraction·diffuse를 포함한 동일 estimator를 사용한다.
|
||||
|
||||
지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
|
||||
|
||||
|
||||
@@ -218,7 +218,8 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
|
||||
|
||||
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
|
||||
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다.
|
||||
- 브라우저 메모리: snapshot signal·최대 sparse delay FFT에 512 MiB 한도를 적용하고, 이동
|
||||
- 브라우저 메모리: crop microphone channel과 source별로 순차 처리하는 한 sampled
|
||||
signal·spectrum·workspace의 peak typed working set에 512 MiB 한도를 적용하고, 이동
|
||||
미리보기는 최대 48개·2,500 points의 짧은 시간창으로 제한한다. 장시간 입력은 256 MiB 기본
|
||||
한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다.
|
||||
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
|
||||
|
||||
@@ -176,12 +176,16 @@ scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전
|
||||
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
|
||||
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
|
||||
|
||||
시간창은 요청 출력 시작보다 앞선 신호를 현재 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과 같은 절대 시간축을 사용합니다.
|
||||
시간창은 요청 출력 시작보다 앞선 sampled noise·asset을 해당 source들의 최대 경로 지연만큼
|
||||
pre-roll합니다. analytic tone은 과거 sample buffer 없이 절대시간에서 직접 계산하므로 tone의 더 긴
|
||||
경로는 render buffer를 늘리지 않습니다. 그래도 창이 시작되기 전에 발음이 끝난 tone을
|
||||
direct·reflection·scattering·diffraction·diffuse 도착 구간으로 선별해 제거하지 않습니다.
|
||||
`signalOffsets`는 원래 scene/source의 절대 sample
|
||||
offset과 출력 crop을 함께 보존합니다. analytic tone은 pre-roll 길이의 마이크 channel을 만들지 않고
|
||||
요청 crop에 절대 sample 위상으로 바로 더합니다. sampled noise·asset은 지연에 필요한 pre-roll
|
||||
방사 buffer와 zero-padded FFT는 유지하되 IFFT 결과의 crop 구간만 crop 크기 channel에 더합니다.
|
||||
따라서 tone phase, seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline과 같은
|
||||
절대 시간축을 사용하면서 `마이크 수 × pre-roll` 출력 메모리와 tone sample-add를 만들지 않습니다.
|
||||
|
||||
이 보장은 현재 희소 경로 모델 안에서의 **전파 지연 tail**에 관한 것입니다. quasi-static 창은 pre-roll
|
||||
구간까지 포함해 source를 출력 창 중앙의 한 위치에 고정하며, retarded-time trajectory, 창 안의 연속
|
||||
@@ -190,6 +194,8 @@ sample, `source-position-fixed-at-window-midpoint` geometry와 `dopplerMode: not
|
||||
`sourceArrivals`는 source별 방사·도착 sample 경계와 출력에 겹친 path kind를,
|
||||
`arrivingSourceIds`는 적어도 한 실제 path의 half-open 도착 구간이 출력과 겹친 source를 기록합니다.
|
||||
이는 사후 ground-truth 평가에만 쓰이며 위치 추정 입력에는 전달하지 않습니다.
|
||||
loop하지 않는 asset의 방사 종료는 선언 duration과 decoded channel duration 중 빠른 쪽이며, PCM
|
||||
내용의 leading/trailing zero까지 분석해 truth를 줄이지는 않습니다.
|
||||
sampled asset/noise의 fractional delay와 microphone response는 기존 zero-padded block FFT 모델을
|
||||
그대로 사용하므로 임의 길이 analog microphone impulse response의 streaming state를 재현하는 모델은
|
||||
아닙니다.
|
||||
@@ -247,9 +253,32 @@ Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면
|
||||
센서 self-noise, ADC quantization·clock jitter는 계산하지 않습니다. 그런 효과가 중요한
|
||||
검증에서는 실측 다채널 데이터를 아래 streaming API에 넣어 결과를 비교해야 합니다.
|
||||
|
||||
snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을
|
||||
512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로
|
||||
전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
|
||||
합성기는 source별로 경로를 한 receiver 묶음씩 만들고, sampled source도 하나씩
|
||||
`render → spectrum → microphone 적용 → 해제`합니다. render 길이를 `N`, 출력 crop을 `C`,
|
||||
마이크 수를 `M`, sampled source `i`의 자체 최대 경로 지연을 포함한 FFT 길이를 `F_i`라 하면
|
||||
명시적으로 살아 있는 typed-array peak는 다음입니다.
|
||||
|
||||
```text
|
||||
4 M C + max_i(4 N + 32 F_i)
|
||||
```
|
||||
|
||||
`32 F_i`는 Float64 spectrum 두 개와 재사용 workspace 두 개입니다. sampled ambient는
|
||||
zero-delay `F_0 = nextPow2(N + 2)` 후보 하나로 같은 max에 들어가며, analytic tone이나 다른
|
||||
sampled source의 수를 transient byte에 더하지 않습니다. 이 논리적 typed-array working set을
|
||||
기본 512 MiB로 할당 전에 검사하고, Experiment validator도 동일 estimator와 실제
|
||||
reflection·scattering·diffraction·diffuse 지연을 사용합니다. 외부 decoded asset buffer와 JS
|
||||
engine의 객체 heap/RSS까지 512 MiB라고 주장하는 값은 아닙니다.
|
||||
|
||||
라이브 preflight가 만든 경로 count·source별 최대 지연 summary는 geometry, source/microphone id와
|
||||
좌표, acoustics/seed, 거리 설정 전체의 서명이 맞을 때만 합성 preflight에 재사용합니다. summary는
|
||||
불변이고 모듈 내부에서 인증되므로 복제·변조·다른 geometry의 cache는 자동 재계산합니다. 동일
|
||||
Scene·Array 객체에서 band/scan/backend만 바뀐 반복 validation은 이 exact summary를 재사용합니다. 실제
|
||||
전달함수 경로는 receiver별로 한 번 다시 만들며, sampled source의 IFFT는 아직 source와 microphone
|
||||
조합마다 한 번입니다. 다수 non-tone source의 transform batching은 수학 동치와 메모리 예산을 함께
|
||||
검증해야 하는 후속 최적화입니다.
|
||||
|
||||
실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로 전체 녹음을 메모리에 보관하지
|
||||
않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
|
||||
hard cap을 할당 전에 검사합니다. DAS steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지하고,
|
||||
GCC-PHAT는 거리·STFT·correlation workspace 전체를 plan 한도 안에서 검사합니다. Plan 없는 단발
|
||||
GCC 분석도 거리 행렬을 만들기 전에 spectrum·calibration·output을 포함한 working set을 preflight합니다.
|
||||
|
||||
@@ -11,6 +11,11 @@ const WALLS = [
|
||||
];
|
||||
|
||||
const GEOMETRY_EPSILON = 1e-9;
|
||||
export const DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS = 2_000_000;
|
||||
const trustedAcousticPathSummaries = new WeakSet();
|
||||
const acousticPathSummaryCache = new WeakMap();
|
||||
const acousticPathSummaryLru = new Map();
|
||||
const MAXIMUM_ACOUSTIC_PATH_SUMMARY_CACHE_ENTRIES = 8;
|
||||
|
||||
function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) {
|
||||
return position.every(
|
||||
@@ -223,10 +228,152 @@ export function buildAcousticPaths(options) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function maximumAcousticDelaySeconds(options) {
|
||||
const { scene, arrayDefinition, referenceDistance, minimumDistance } = options;
|
||||
let maximum = 0;
|
||||
for (const source of scene.sources) {
|
||||
export function maximumAcousticPathsPerReceiver(scene) {
|
||||
const acoustics = scene?.acoustics;
|
||||
if (!acoustics || acoustics.model === "free-field") return 1;
|
||||
let count = 1 + (Array.isArray(acoustics.occluders) ? acoustics.occluders.length : 0);
|
||||
if (
|
||||
acoustics.reflections?.order === 1 &&
|
||||
acoustics.room
|
||||
) {
|
||||
count += 6;
|
||||
if (acoustics.reflections.scattering > 0) count += 6;
|
||||
}
|
||||
if (acoustics.diffuseReverb?.enabled) {
|
||||
count += acoustics.diffuseReverb.tapCount;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function acousticGeometrySignature(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
} = options;
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
speedOfSound: scene.speedOfSound,
|
||||
seed: scene.seed ?? 0,
|
||||
acoustics: scene.acoustics ?? null,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
sources: scene.sources.map((source) => ({
|
||||
id: source.id,
|
||||
position: source.position,
|
||||
})),
|
||||
microphones: arrayDefinition.microphones.map((microphone) => ({
|
||||
id: microphone.id,
|
||||
position: microphone.position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function freezePathCounts(counts) {
|
||||
return Object.freeze({ ...counts });
|
||||
}
|
||||
|
||||
function createTrustedAcousticPathSummary(options) {
|
||||
const {
|
||||
signature,
|
||||
sources,
|
||||
microphoneCount,
|
||||
} = options;
|
||||
const pathCounts = {};
|
||||
let pathCount = 0;
|
||||
let maximumDelaySeconds = 0;
|
||||
for (const source of sources) {
|
||||
pathCount += source.pathCount;
|
||||
maximumDelaySeconds = Math.max(maximumDelaySeconds, source.maximumDelaySeconds);
|
||||
for (const [kind, count] of Object.entries(source.pathCounts)) {
|
||||
pathCounts[kind] = (pathCounts[kind] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
const frozenSources = Object.freeze(sources.map((source) => Object.freeze({
|
||||
sourceId: source.sourceId,
|
||||
pathCount: source.pathCount,
|
||||
pathCounts: freezePathCounts(source.pathCounts),
|
||||
maximumDelaySeconds: source.maximumDelaySeconds,
|
||||
})));
|
||||
const summary = Object.freeze({
|
||||
kind: "micarrsim.acoustic-path-summary/1",
|
||||
signature,
|
||||
sourceCount: frozenSources.length,
|
||||
microphoneCount,
|
||||
sourceReceiverPairCount: frozenSources.length * microphoneCount,
|
||||
pathCount,
|
||||
pathCounts: freezePathCounts(pathCounts),
|
||||
maximumDelaySeconds,
|
||||
sources: frozenSources,
|
||||
});
|
||||
trustedAcousticPathSummaries.add(summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate sparse paths one source/receiver pair at a time and retain only
|
||||
* numeric count/delay metadata. The returned immutable summary is internally
|
||||
* branded so callers cannot forge a stale geometry cache.
|
||||
*/
|
||||
export function summarizeAcousticPaths(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
} = options;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumPathEvaluations) ||
|
||||
maximumPathEvaluations <= 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"maximumPathEvaluations must be a positive safe integer.",
|
||||
);
|
||||
}
|
||||
const maximumPerReceiver = maximumAcousticPathsPerReceiver(scene);
|
||||
const theoreticalPathEvaluations =
|
||||
scene.sources.length * arrayDefinition.microphones.length * maximumPerReceiver;
|
||||
if (
|
||||
!Number.isSafeInteger(theoreticalPathEvaluations) ||
|
||||
theoreticalPathEvaluations > maximumPathEvaluations
|
||||
) {
|
||||
throw new RangeError(
|
||||
`acoustic path evaluation upper bound ${theoreticalPathEvaluations} exceeds ` +
|
||||
`maximumPathEvaluations ${maximumPathEvaluations}.`,
|
||||
);
|
||||
}
|
||||
const signature = acousticGeometrySignature({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
});
|
||||
const lruSummary = acousticPathSummaryLru.get(signature);
|
||||
if (lruSummary) {
|
||||
acousticPathSummaryLru.delete(signature);
|
||||
acousticPathSummaryLru.set(signature, lruSummary);
|
||||
return lruSummary;
|
||||
}
|
||||
let arrayCache = acousticPathSummaryCache.get(scene);
|
||||
if (!arrayCache) {
|
||||
arrayCache = new WeakMap();
|
||||
acousticPathSummaryCache.set(scene, arrayCache);
|
||||
}
|
||||
let parameterCache = arrayCache.get(arrayDefinition);
|
||||
if (!parameterCache) {
|
||||
parameterCache = new Map();
|
||||
arrayCache.set(arrayDefinition, parameterCache);
|
||||
}
|
||||
const parameterKey = `${referenceDistance}\u0000${minimumDistance}`;
|
||||
const cachedSummary = parameterCache.get(parameterKey);
|
||||
if (cachedSummary?.signature === signature) return cachedSummary;
|
||||
|
||||
const sources = scene.sources.map((source) => {
|
||||
const pathCounts = {};
|
||||
let pathCount = 0;
|
||||
let maximumDelaySeconds = 0;
|
||||
for (const microphone of arrayDefinition.microphones) {
|
||||
const paths = buildAcousticPaths({
|
||||
scene,
|
||||
@@ -237,8 +384,122 @@ export function maximumAcousticDelaySeconds(options) {
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
});
|
||||
for (const path of paths) maximum = Math.max(maximum, path.delaySeconds);
|
||||
for (const path of paths) {
|
||||
if (!Number.isFinite(path.delaySeconds) || path.delaySeconds < 0) {
|
||||
throw new RangeError("acoustic path delay must be finite and non-negative.");
|
||||
}
|
||||
pathCount += 1;
|
||||
pathCounts[path.kind] = (pathCounts[path.kind] ?? 0) + 1;
|
||||
maximumDelaySeconds = Math.max(maximumDelaySeconds, path.delaySeconds);
|
||||
}
|
||||
}
|
||||
return maximum;
|
||||
return {
|
||||
sourceId: source.id,
|
||||
pathCount,
|
||||
pathCounts,
|
||||
maximumDelaySeconds,
|
||||
};
|
||||
});
|
||||
const summary = createTrustedAcousticPathSummary({
|
||||
signature,
|
||||
sources,
|
||||
microphoneCount: arrayDefinition.microphones.length,
|
||||
});
|
||||
parameterCache.set(parameterKey, summary);
|
||||
acousticPathSummaryLru.set(signature, summary);
|
||||
while (
|
||||
acousticPathSummaryLru.size >
|
||||
MAXIMUM_ACOUSTIC_PATH_SUMMARY_CACHE_ENTRIES
|
||||
) {
|
||||
acousticPathSummaryLru.delete(acousticPathSummaryLru.keys().next().value);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function isAcousticPathSummaryCompatible(summary, options) {
|
||||
return (
|
||||
trustedAcousticPathSummaries.has(summary) &&
|
||||
summary.signature === acousticGeometrySignature(options) &&
|
||||
summary.sourceCount === options.scene.sources.length &&
|
||||
summary.microphoneCount === options.arrayDefinition.microphones.length
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retarget an authenticated full-scene summary to the source subset retained
|
||||
* by a quasi-static render window. Only immutable numeric metadata is shared.
|
||||
*/
|
||||
export function selectAcousticPathSummary(options) {
|
||||
const {
|
||||
summary,
|
||||
sourceIndexes,
|
||||
sourceScene,
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
} = options;
|
||||
if (!trustedAcousticPathSummaries.has(summary)) {
|
||||
throw new TypeError("summary must be created by summarizeAcousticPaths.");
|
||||
}
|
||||
if (
|
||||
!sourceScene ||
|
||||
!isAcousticPathSummaryCompatible(summary, {
|
||||
scene: sourceScene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
})
|
||||
) {
|
||||
throw new RangeError(
|
||||
"source acoustic path summary does not match its original geometry.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(sourceIndexes) ||
|
||||
sourceIndexes.length !== scene.sources.length
|
||||
) {
|
||||
throw new RangeError("sourceIndexes must correspond exactly to the selected scene sources.");
|
||||
}
|
||||
const sources = sourceIndexes.map((sourceIndex, selectedIndex) => {
|
||||
const sourceSummary = summary.sources[sourceIndex];
|
||||
if (
|
||||
!sourceSummary ||
|
||||
sourceSummary.sourceId !== scene.sources[selectedIndex].id
|
||||
) {
|
||||
throw new RangeError("selected acoustic path summary does not match scene source order.");
|
||||
}
|
||||
return sourceSummary;
|
||||
});
|
||||
const expectedSelectedSignature = acousticGeometrySignature({
|
||||
scene: {
|
||||
speedOfSound: sourceScene.speedOfSound,
|
||||
seed: sourceScene.seed,
|
||||
acoustics: sourceScene.acoustics,
|
||||
sources: sourceIndexes.map((sourceIndex) => sourceScene.sources[sourceIndex]),
|
||||
},
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
});
|
||||
const selectedSignature = acousticGeometrySignature({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
});
|
||||
if (selectedSignature !== expectedSelectedSignature) {
|
||||
throw new RangeError(
|
||||
"selected scene path geometry does not match the summarized source geometry.",
|
||||
);
|
||||
}
|
||||
return createTrustedAcousticPathSummary({
|
||||
signature: selectedSignature,
|
||||
sources,
|
||||
microphoneCount: arrayDefinition.microphones.length,
|
||||
});
|
||||
}
|
||||
|
||||
export function maximumAcousticDelaySeconds(options) {
|
||||
return summarizeAcousticPaths(options).maximumDelaySeconds;
|
||||
}
|
||||
|
||||
@@ -32,19 +32,36 @@ export function addFrequencyDomainPaths(options) {
|
||||
frequencyResponse,
|
||||
sampleRate,
|
||||
workspace,
|
||||
outputOffsetSamples = 0,
|
||||
pathDelaySampleRate,
|
||||
} = options;
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
throw new Error("frequency-domain path가 하나 이상 필요합니다.");
|
||||
}
|
||||
const delaySamplesForPath = (path) => {
|
||||
if (path.delaySamples !== undefined) return path.delaySamples;
|
||||
if (
|
||||
path.delaySeconds !== undefined &&
|
||||
pathDelaySampleRate > 0 &&
|
||||
Number.isFinite(pathDelaySampleRate)
|
||||
) {
|
||||
return path.delaySeconds * pathDelaySampleRate;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
for (const path of paths) {
|
||||
if (!(path.delaySamples >= 0) || !Number.isFinite(path.delaySamples)) {
|
||||
throw new Error("유효하지 않은 delay: " + path.delaySamples);
|
||||
const delaySamples = delaySamplesForPath(path);
|
||||
if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) {
|
||||
throw new Error("유효하지 않은 delay: " + delaySamples);
|
||||
}
|
||||
if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다.");
|
||||
}
|
||||
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
|
||||
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
|
||||
}
|
||||
if (!Number.isSafeInteger(outputOffsetSamples) || outputOffsetSamples < 0) {
|
||||
throw new RangeError("outputOffsetSamples must be a non-negative safe integer.");
|
||||
}
|
||||
const hasFrequencyResponse = Array.isArray(frequencyResponse) && frequencyResponse.length > 0;
|
||||
if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) {
|
||||
throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다.");
|
||||
@@ -58,7 +75,7 @@ export function addFrequencyDomainPaths(options) {
|
||||
const halfSize = size / 2;
|
||||
const phaseReanchorInterval = 1024;
|
||||
for (const path of paths) {
|
||||
const phaseStep = (-TAU * path.delaySamples) / size;
|
||||
const phaseStep = (-TAU * delaySamplesForPath(path)) / size;
|
||||
const stepReal = Math.cos(phaseStep);
|
||||
const stepImaginary = Math.sin(phaseStep);
|
||||
let phaseReal = 1;
|
||||
@@ -118,9 +135,12 @@ export function addFrequencyDomainPaths(options) {
|
||||
// bin back to the real axis preserves Hermitian symmetry for fractional delays.
|
||||
workspace.imaginary[size / 2] = 0;
|
||||
fftInPlace(workspace.real, workspace.imaginary, true);
|
||||
const outputLength = Math.min(output.length, workspace.real.length);
|
||||
const outputLength = Math.min(
|
||||
output.length,
|
||||
Math.max(0, workspace.real.length - outputOffsetSamples),
|
||||
);
|
||||
for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
|
||||
output[sampleIndex] += workspace.real[sampleIndex];
|
||||
output[sampleIndex] += workspace.real[outputOffsetSamples + sampleIndex];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
nextPowerOfTwo,
|
||||
prepareDelaySpectrum,
|
||||
} from "./fractional-delay.js";
|
||||
import { buildAcousticPaths } from "./environment.js";
|
||||
import { renderSignal } from "./signal.js";
|
||||
import {
|
||||
buildAcousticPaths,
|
||||
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
isAcousticPathSummaryCompatible,
|
||||
summarizeAcousticPaths,
|
||||
} from "./environment.js";
|
||||
import {
|
||||
effectiveSignalDurationSeconds,
|
||||
renderSignal,
|
||||
} from "./signal.js";
|
||||
import { describeFrequencyAliasing } from "./sampling.js";
|
||||
|
||||
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||
@@ -68,16 +76,28 @@ function addSampledAnalogTonePaths(options) {
|
||||
microphone,
|
||||
paths,
|
||||
timeMapping = {},
|
||||
outputSceneSampleOffset,
|
||||
outputRenderSampleOffset = 0,
|
||||
} = options;
|
||||
const signal = entry.signal;
|
||||
const frequencyHz = signal.frequency;
|
||||
const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate);
|
||||
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
||||
const outputSampleOffset = outputSceneSampleOffset ?? sceneSampleOffset;
|
||||
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.isSafeInteger(outputSampleOffset) || outputSampleOffset < 0) {
|
||||
throw new RangeError("outputSceneSampleOffset must be a non-negative safe integer.");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(outputRenderSampleOffset) ||
|
||||
outputRenderSampleOffset < 0
|
||||
) {
|
||||
throw new RangeError("outputRenderSampleOffset must be a non-negative safe integer.");
|
||||
}
|
||||
if (!Number.isFinite(signalStartTime)) {
|
||||
throw new TypeError("signalStartTime must be finite.");
|
||||
}
|
||||
@@ -102,14 +122,14 @@ function addSampledAnalogTonePaths(options) {
|
||||
const firstSample = Math.max(
|
||||
0,
|
||||
ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) -
|
||||
sceneSampleOffset,
|
||||
outputSampleOffset,
|
||||
);
|
||||
const lastSample = Math.min(
|
||||
output.length,
|
||||
ceilSampleIndex(
|
||||
signalStartTime + signalDuration + path.delaySeconds,
|
||||
scene.sampleRate,
|
||||
) - sceneSampleOffset,
|
||||
) - outputSampleOffset,
|
||||
);
|
||||
if (firstSample >= lastSample) continue;
|
||||
const amplitude = baseGain * path.gain;
|
||||
@@ -132,7 +152,7 @@ function addSampledAnalogTonePaths(options) {
|
||||
const initialCarrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + firstSample,
|
||||
outputSampleOffset + firstSample,
|
||||
);
|
||||
let carrierCosine = initialCarrier.cosine;
|
||||
let carrierSine = initialCarrier.sine;
|
||||
@@ -157,7 +177,7 @@ function addSampledAnalogTonePaths(options) {
|
||||
const carrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + sampleIndex,
|
||||
outputSampleOffset + sampleIndex,
|
||||
);
|
||||
carrierCosine = carrier.cosine;
|
||||
carrierSine = carrier.sine;
|
||||
@@ -167,11 +187,13 @@ function addSampledAnalogTonePaths(options) {
|
||||
carrierSine * sineCoefficient + carrierCosine * cosineCoefficient;
|
||||
|
||||
const nextSample = sampleIndex + 1;
|
||||
if (nextSample % phaseReanchorInterval === 0) {
|
||||
if (
|
||||
(outputRenderSampleOffset + nextSample) % phaseReanchorInterval === 0
|
||||
) {
|
||||
const carrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + nextSample,
|
||||
outputSampleOffset + nextSample,
|
||||
);
|
||||
carrierCosine = carrier.cosine;
|
||||
carrierSine = carrier.sine;
|
||||
@@ -186,16 +208,9 @@ function addSampledAnalogTonePaths(options) {
|
||||
}
|
||||
}
|
||||
|
||||
export function synthesizeMicrophoneSignals(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers = new Map(),
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
maximumBufferBytes = 512 * 1024 * 1024,
|
||||
signalOffsets,
|
||||
} = options;
|
||||
export const DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
function resolveSynthesisDimensions(scene, signalOffsets) {
|
||||
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
|
||||
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
|
||||
const cropStartSample = signalOffsets?.cropStartSample ?? 0;
|
||||
@@ -212,12 +227,322 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
) {
|
||||
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) => ({
|
||||
const outputStartSample = sceneSampleOffset + cropStartSample;
|
||||
const outputEndSample = outputStartSample + cropSampleCount;
|
||||
if (
|
||||
!Number.isSafeInteger(outputStartSample) ||
|
||||
!Number.isSafeInteger(outputEndSample)
|
||||
) {
|
||||
throw new RangeError("signal output absolute sample range exceeds the safe integer range.");
|
||||
}
|
||||
return {
|
||||
sampleCount,
|
||||
sceneSampleOffset,
|
||||
cropStartSample,
|
||||
cropSampleCount,
|
||||
outputStartSample,
|
||||
};
|
||||
}
|
||||
|
||||
function sampledSignalTransient(sampleCount, maximumDelaySeconds, sampleRate) {
|
||||
const maximumDelaySamples = ceilSamplePosition(maximumDelaySeconds * sampleRate);
|
||||
const fftSize = nextPowerOfTwo(sampleCount + maximumDelaySamples + 2);
|
||||
const sampleBytes = sampleCount * Float32Array.BYTES_PER_ELEMENT;
|
||||
const spectrumAndWorkspaceBytes =
|
||||
fftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
||||
return {
|
||||
maximumDelaySamples,
|
||||
fftSize,
|
||||
sampleBytes,
|
||||
spectrumAndWorkspaceBytes,
|
||||
totalBytes: sampleBytes + spectrumAndWorkspaceBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the maximum simultaneously live typed-array storage used by the
|
||||
* source-streaming synthesizer. Paths are represented separately by bounded
|
||||
* short-lived JS objects and are not included in this typed working-set cap.
|
||||
*/
|
||||
export function estimateSynthesisWorkingSet(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
signalOffsets,
|
||||
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
} = options;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumPathEvaluations) ||
|
||||
maximumPathEvaluations <= 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"maximumPathEvaluations must be a positive safe integer.",
|
||||
);
|
||||
}
|
||||
const dimensions = resolveSynthesisDimensions(scene, signalOffsets);
|
||||
const summaryOptions = {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
};
|
||||
const candidateSummary =
|
||||
options.acousticPathSummary ?? signalOffsets?.acousticPathSummary;
|
||||
const reusedPathSummary = isAcousticPathSummaryCompatible(
|
||||
candidateSummary,
|
||||
summaryOptions,
|
||||
);
|
||||
const acousticPathSummary = reusedPathSummary
|
||||
? candidateSummary
|
||||
: summarizeAcousticPaths({
|
||||
...summaryOptions,
|
||||
maximumPathEvaluations,
|
||||
});
|
||||
if (acousticPathSummary.pathCount > maximumPathEvaluations) {
|
||||
throw new RangeError(
|
||||
`acoustic path count ${acousticPathSummary.pathCount} exceeds ` +
|
||||
`maximumPathEvaluations ${maximumPathEvaluations}.`,
|
||||
);
|
||||
}
|
||||
const activePathMaximumDelaySamples = ceilSamplePosition(
|
||||
acousticPathSummary.maximumDelaySeconds * scene.sampleRate,
|
||||
);
|
||||
|
||||
const channelBytes =
|
||||
arrayDefinition.microphones.length *
|
||||
dimensions.cropSampleCount *
|
||||
Float32Array.BYTES_PER_ELEMENT;
|
||||
let maximumTransientBytes = 0;
|
||||
let maximumTransient = {
|
||||
scope: "none",
|
||||
id: null,
|
||||
maximumDelaySamples: 0,
|
||||
fftSize: 0,
|
||||
sampleBytes: 0,
|
||||
spectrumAndWorkspaceBytes: 0,
|
||||
totalBytes: 0,
|
||||
};
|
||||
const sampledSources = [];
|
||||
scene.sources.forEach((source, sourceIndex) => {
|
||||
if (source.signal.type === "tone") return;
|
||||
const transient = sampledSignalTransient(
|
||||
dimensions.sampleCount,
|
||||
acousticPathSummary.sources[sourceIndex].maximumDelaySeconds,
|
||||
scene.sampleRate,
|
||||
);
|
||||
const entry = { scope: "source", id: source.id, ...transient };
|
||||
sampledSources.push(entry);
|
||||
if (entry.totalBytes > maximumTransientBytes) {
|
||||
maximumTransientBytes = entry.totalBytes;
|
||||
maximumTransient = entry;
|
||||
}
|
||||
});
|
||||
const sampledAmbient = [];
|
||||
for (const ambient of scene.ambient ?? []) {
|
||||
if (ambient.signal.type === "tone") continue;
|
||||
const transient = sampledSignalTransient(
|
||||
dimensions.sampleCount,
|
||||
0,
|
||||
scene.sampleRate,
|
||||
);
|
||||
const entry = { scope: "ambient", id: ambient.id, ...transient };
|
||||
sampledAmbient.push(entry);
|
||||
if (entry.totalBytes > maximumTransientBytes) {
|
||||
maximumTransientBytes = entry.totalBytes;
|
||||
maximumTransient = entry;
|
||||
}
|
||||
}
|
||||
const estimatedBufferBytes = channelBytes + maximumTransientBytes;
|
||||
if (!Number.isSafeInteger(estimatedBufferBytes)) {
|
||||
throw new RangeError("estimated synthesis typed working set exceeds the safe integer range.");
|
||||
}
|
||||
return {
|
||||
...dimensions,
|
||||
estimatedBufferBytes,
|
||||
channelBytes,
|
||||
maximumTransientBytes,
|
||||
maximumTransient,
|
||||
sampledSources,
|
||||
sampledAmbient,
|
||||
acousticPathSummary,
|
||||
activePathMaximumDelaySamples,
|
||||
reusedPathSummary,
|
||||
model: "crop-channels-plus-one-sampled-signal-spectrum-and-workspace",
|
||||
};
|
||||
}
|
||||
|
||||
function assertWorkingSetWithinLimit(estimatedBytes, maximumBufferBytes) {
|
||||
if (
|
||||
!(maximumBufferBytes > 0) ||
|
||||
!Number.isFinite(maximumBufferBytes)
|
||||
) {
|
||||
throw new RangeError("maximumBufferBytes must be finite and positive.");
|
||||
}
|
||||
if (estimatedBytes > maximumBufferBytes) {
|
||||
throw new Error(
|
||||
`예상 signal buffer ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createSourceArrivalState(options) {
|
||||
const {
|
||||
definition,
|
||||
microphonePaths: arrayDefinition.microphones.map((microphone) =>
|
||||
buildAcousticPaths({
|
||||
scene,
|
||||
assetBuffers,
|
||||
timeMapping,
|
||||
} = options;
|
||||
const emissionStartTime =
|
||||
timeMapping.signalStartTime ?? definition.startTime ?? 0;
|
||||
const declaredDuration =
|
||||
timeMapping.signalDuration ?? definition.duration ?? scene.duration;
|
||||
const emissionDuration = effectiveSignalDurationSeconds(
|
||||
{ ...definition, duration: declaredDuration },
|
||||
scene,
|
||||
assetBuffers,
|
||||
);
|
||||
return {
|
||||
sourceId: definition.id,
|
||||
emissionStartTime,
|
||||
emissionDuration,
|
||||
earliestArrivalSample: Infinity,
|
||||
latestArrivalEndSample: -Infinity,
|
||||
overlapsOutput: false,
|
||||
overlappingPathKinds: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function observeSourceArrivalPaths(
|
||||
state,
|
||||
paths,
|
||||
sampleRate,
|
||||
outputStartSample,
|
||||
outputEndSample,
|
||||
) {
|
||||
for (const path of paths) {
|
||||
const arrivalStartSample = ceilSampleIndex(
|
||||
state.emissionStartTime + path.delaySeconds,
|
||||
sampleRate,
|
||||
);
|
||||
const arrivalEndSample = ceilSampleIndex(
|
||||
state.emissionStartTime + state.emissionDuration + path.delaySeconds,
|
||||
sampleRate,
|
||||
);
|
||||
state.earliestArrivalSample = Math.min(
|
||||
state.earliestArrivalSample,
|
||||
arrivalStartSample,
|
||||
);
|
||||
state.latestArrivalEndSample = Math.max(
|
||||
state.latestArrivalEndSample,
|
||||
arrivalEndSample,
|
||||
);
|
||||
if (
|
||||
arrivalStartSample < outputEndSample &&
|
||||
arrivalEndSample > outputStartSample
|
||||
) {
|
||||
state.overlapsOutput = true;
|
||||
state.overlappingPathKinds.add(path.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeSourceArrival(state, sampleRate) {
|
||||
return {
|
||||
sourceId: state.sourceId,
|
||||
emissionStartSample: ceilSampleIndex(state.emissionStartTime, sampleRate),
|
||||
emissionEndSample: ceilSampleIndex(
|
||||
state.emissionStartTime + state.emissionDuration,
|
||||
sampleRate,
|
||||
),
|
||||
earliestArrivalSample: Number.isFinite(state.earliestArrivalSample)
|
||||
? state.earliestArrivalSample
|
||||
: null,
|
||||
latestArrivalEndSample: Number.isFinite(state.latestArrivalEndSample)
|
||||
? state.latestArrivalEndSample
|
||||
: null,
|
||||
overlapsOutput: state.overlapsOutput,
|
||||
overlappingPathKinds: [...state.overlappingPathKinds].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function synthesizeMicrophoneSignals(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers = new Map(),
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
maximumBufferBytes = DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
signalOffsets,
|
||||
} = options;
|
||||
const workingSet = estimateSynthesisWorkingSet({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
signalOffsets,
|
||||
acousticPathSummary: options.acousticPathSummary,
|
||||
maximumPathEvaluations,
|
||||
});
|
||||
assertWorkingSetWithinLimit(
|
||||
workingSet.estimatedBufferBytes,
|
||||
maximumBufferBytes,
|
||||
);
|
||||
const {
|
||||
sampleCount,
|
||||
sceneSampleOffset,
|
||||
cropStartSample,
|
||||
cropSampleCount,
|
||||
outputStartSample,
|
||||
acousticPathSummary,
|
||||
} = workingSet;
|
||||
const outputEndSample = outputStartSample + cropSampleCount;
|
||||
const needsOutputCrop =
|
||||
cropStartSample !== 0 || cropSampleCount !== sampleCount;
|
||||
const synthesisChannels = arrayDefinition.microphones.map(
|
||||
() => new Float32Array(cropSampleCount),
|
||||
);
|
||||
const sourceArrivals = [];
|
||||
|
||||
scene.sources.forEach((definition, sourceIndex) => {
|
||||
const timeMapping = {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||
};
|
||||
const arrivalState = createSourceArrivalState({
|
||||
definition,
|
||||
scene,
|
||||
assetBuffers,
|
||||
timeMapping,
|
||||
});
|
||||
const sourceWorkingSet = workingSet.sampledSources.find(
|
||||
({ id }) => id === definition.id,
|
||||
);
|
||||
if (sourceWorkingSet) {
|
||||
assertWorkingSetWithinLimit(
|
||||
workingSet.channelBytes + sourceWorkingSet.totalBytes,
|
||||
maximumBufferBytes,
|
||||
);
|
||||
}
|
||||
const samples = definition.signal.type === "tone"
|
||||
? undefined
|
||||
: renderSignal(definition, scene, assetBuffers, 0, timeMapping);
|
||||
const spectrum = samples
|
||||
? prepareDelaySpectrum(
|
||||
samples,
|
||||
sourceWorkingSet.maximumDelaySamples,
|
||||
)
|
||||
: undefined;
|
||||
const workspace = spectrum
|
||||
? createDelayWorkspace(spectrum.fftSize)
|
||||
: undefined;
|
||||
|
||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||
const paths = buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: definition.position,
|
||||
receiverPosition: microphone.position,
|
||||
@@ -225,136 +550,14 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
receiverId: microphone.id,
|
||||
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) {
|
||||
sourceMaximumDelaySamples = Math.max(
|
||||
sourceMaximumDelaySamples,
|
||||
path.delaySeconds * scene.sampleRate,
|
||||
observeSourceArrivalPaths(
|
||||
arrivalState,
|
||||
paths,
|
||||
scene.sampleRate,
|
||||
outputStartSample,
|
||||
outputEndSample,
|
||||
);
|
||||
}
|
||||
}
|
||||
const spectrum = samples
|
||||
? prepareDelaySpectrum(samples, sourceMaximumDelaySamples)
|
||||
: undefined;
|
||||
const workspace = spectrum
|
||||
? createDelayWorkspace(spectrum.fftSize)
|
||||
: undefined;
|
||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||
const paths = microphonePaths[microphoneIndex];
|
||||
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
|
||||
if (definition.signal.type === "tone") {
|
||||
addSampledAnalogTonePaths({
|
||||
output: synthesisChannels[microphoneIndex],
|
||||
@@ -363,33 +566,47 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
microphone,
|
||||
paths,
|
||||
timeMapping,
|
||||
outputSceneSampleOffset: outputStartSample,
|
||||
outputRenderSampleOffset: cropStartSample,
|
||||
});
|
||||
} else {
|
||||
addFrequencyDomainPaths({
|
||||
output: synthesisChannels[microphoneIndex],
|
||||
spectrum,
|
||||
paths: paths.map((path) => ({
|
||||
delaySamples: path.delaySeconds * scene.sampleRate,
|
||||
gain: path.gain,
|
||||
})),
|
||||
gain: sensitivity,
|
||||
paths,
|
||||
pathDelaySampleRate: scene.sampleRate,
|
||||
gain: dbToLinear(microphone.sensitivityDb ?? 0),
|
||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||
frequencyResponse: microphone.frequencyResponse,
|
||||
sampleRate: scene.sampleRate,
|
||||
workspace,
|
||||
outputOffsetSamples: cropStartSample,
|
||||
});
|
||||
}
|
||||
});
|
||||
sourceArrivals.push(finalizeSourceArrival(arrivalState, scene.sampleRate));
|
||||
});
|
||||
|
||||
if (Array.isArray(scene.ambient)) {
|
||||
scene.ambient.forEach((ambient, ambientIndex) => {
|
||||
synthesisChannels.forEach((channel, microphoneIndex) => {
|
||||
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||
const ambientTimeMapping = {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.ambient?.[ambientIndex] ?? {}),
|
||||
};
|
||||
const ambientWorkingSet = workingSet.sampledAmbient.find(
|
||||
({ id }) => id === ambient.id,
|
||||
);
|
||||
if (ambientWorkingSet) {
|
||||
assertWorkingSetWithinLimit(
|
||||
workingSet.channelBytes + ambientWorkingSet.totalBytes,
|
||||
maximumBufferBytes,
|
||||
);
|
||||
}
|
||||
const ambientWorkspace = ambientWorkingSet
|
||||
? createDelayWorkspace(ambientWorkingSet.fftSize)
|
||||
: undefined;
|
||||
synthesisChannels.forEach((channel, microphoneIndex) => {
|
||||
const microphone = arrayDefinition.microphones[microphoneIndex];
|
||||
if (ambient.signal.type === "tone") {
|
||||
addSampledAnalogTonePaths({
|
||||
output: channel,
|
||||
@@ -398,13 +615,21 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
microphone,
|
||||
paths: [{ delaySeconds: 0, gain: 1 }],
|
||||
timeMapping: ambientTimeMapping,
|
||||
outputSceneSampleOffset: outputStartSample,
|
||||
outputRenderSampleOffset: cropStartSample,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
|
||||
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, {
|
||||
...ambientTimeMapping,
|
||||
});
|
||||
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({
|
||||
output: channel,
|
||||
@@ -414,15 +639,14 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
|
||||
frequencyResponse: microphone.frequencyResponse,
|
||||
sampleRate: scene.sampleRate,
|
||||
workspace: createDelayWorkspace(spectrum.fftSize),
|
||||
workspace: ambientWorkspace,
|
||||
outputOffsetSamples: cropStartSample,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const channels = needsOutputCrop
|
||||
? synthesisChannels.map((channel) => channel.slice(cropStartSample, cropEndSample))
|
||||
: synthesisChannels;
|
||||
const channels = synthesisChannels;
|
||||
|
||||
let preNormalizationPeak = 0;
|
||||
for (const channel of channels) {
|
||||
@@ -451,19 +675,40 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
preNormalizationPeak,
|
||||
appliedScale,
|
||||
clippedSampleCount,
|
||||
estimatedBufferBytes,
|
||||
estimatedBufferBytes: workingSet.estimatedBufferBytes,
|
||||
workingSet: {
|
||||
model: workingSet.model,
|
||||
channelBytes: workingSet.channelBytes,
|
||||
maximumTransientBytes: workingSet.maximumTransientBytes,
|
||||
maximumTransientScope: workingSet.maximumTransient.scope,
|
||||
maximumTransientId: workingSet.maximumTransient.id,
|
||||
pathSummary: workingSet.reusedPathSummary
|
||||
? "reused-authenticated-geometry-summary"
|
||||
: "computed-for-synthesis",
|
||||
summaryPathPairEvaluations: workingSet.reusedPathSummary
|
||||
? 0
|
||||
: acousticPathSummary.sourceReceiverPairCount,
|
||||
synthesisPathBuildPairEvaluations:
|
||||
acousticPathSummary.sourceReceiverPairCount,
|
||||
retainedPathObjects: "one-source-receiver-pair",
|
||||
remainingNonToneTransformModel: "one-ifft-per-source-and-microphone",
|
||||
},
|
||||
acousticModel: scene.acoustics?.model ?? "free-field",
|
||||
acousticPathCount,
|
||||
acousticPathCounts,
|
||||
acousticPathCount: acousticPathSummary.pathCount,
|
||||
acousticPathCounts: { ...acousticPathSummary.pathCounts },
|
||||
windowing: {
|
||||
model: signalOffsets?.windowingModel ?? "whole-scene",
|
||||
renderStartSample: sceneSampleOffset,
|
||||
outputStartSample,
|
||||
outputSampleCount: cropSampleCount,
|
||||
preRollSamples: cropStartSample,
|
||||
activePathMaximumDelaySeconds: maximumDelaySamples / scene.sampleRate,
|
||||
renderMaximumPropagationDelaySeconds:
|
||||
signalOffsets?.renderMaximumPropagationDelaySeconds ??
|
||||
acousticPathSummary.maximumDelaySeconds,
|
||||
activePathMaximumDelaySeconds: acousticPathSummary.maximumDelaySeconds,
|
||||
requestedMaximumPropagationDelaySeconds:
|
||||
signalOffsets?.maximumPropagationDelaySeconds ?? maximumDelaySamples / scene.sampleRate,
|
||||
signalOffsets?.maximumPropagationDelaySeconds ??
|
||||
acousticPathSummary.maximumDelaySeconds,
|
||||
geometryModel: Number.isFinite(signalOffsets?.sourcePositionSampleTime)
|
||||
? "source-position-fixed-at-window-midpoint"
|
||||
: "static-source-position",
|
||||
@@ -471,6 +716,8 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
dopplerMode: "not-modeled",
|
||||
delayedTailPolicy: needsOutputCrop
|
||||
? "propagation-preroll-then-output-crop"
|
||||
: signalOffsets?.windowingModel
|
||||
? "live-request-window-direct-output-with-analytic-path-gating"
|
||||
: "whole-render-buffer",
|
||||
arrivalOverlapModel: "path-gated-half-open-sample-interval",
|
||||
arrivingSourceIds: sourceArrivals
|
||||
|
||||
@@ -15,6 +15,31 @@ function sampleAsset(signal, assetBuffers, time) {
|
||||
return channel[left] + (rightValue - channel[left]) * fraction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the interval in which an entry can emit non-zero samples according to
|
||||
* its declared duration and, for a non-looping asset, the decoded channel
|
||||
* duration. Sample contents themselves are intentionally not inspected.
|
||||
*/
|
||||
export function effectiveSignalDurationSeconds(
|
||||
entry,
|
||||
scene,
|
||||
assetBuffers = new Map(),
|
||||
) {
|
||||
const declaredDuration = entry.duration ?? scene.duration;
|
||||
const signal = entry.signal;
|
||||
if (signal?.type !== "asset" || signal.loop) return declaredDuration;
|
||||
const asset = assetBuffers.get(signal.assetId);
|
||||
const channel = asset?.channels?.[signal.channel ?? 0];
|
||||
if (
|
||||
!channel ||
|
||||
!(asset.sampleRate > 0) ||
|
||||
!Number.isFinite(asset.sampleRate)
|
||||
) {
|
||||
return declaredDuration;
|
||||
}
|
||||
return Math.min(declaredDuration, channel.length / asset.sampleRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one signal entry into the scene-sized output buffer.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { maximumAcousticDelaySeconds } from "./environment.js";
|
||||
import {
|
||||
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
selectAcousticPathSummary,
|
||||
summarizeAcousticPaths,
|
||||
} from "./environment.js";
|
||||
import { ceilSampleIndex } from "./math.js";
|
||||
import { effectiveSignalDurationSeconds } from "./signal.js";
|
||||
|
||||
function copyPosition(position) {
|
||||
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
|
||||
@@ -102,16 +107,20 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
|
||||
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({
|
||||
const pathSummary = options.arrayDefinition
|
||||
? summarizeAcousticPaths({
|
||||
scene: sampledScene,
|
||||
arrayDefinition: options.arrayDefinition,
|
||||
referenceDistance: options.referenceDistance ?? 1,
|
||||
minimumDistance: options.minimumDistance ?? 0.01,
|
||||
maximumPathEvaluations:
|
||||
options.maximumPathEvaluations ??
|
||||
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
})
|
||||
: 0;
|
||||
: undefined;
|
||||
let maximumPropagationDelaySeconds = options.maximumPropagationDelaySeconds;
|
||||
if (maximumPropagationDelaySeconds === undefined) {
|
||||
maximumPropagationDelaySeconds = pathSummary?.maximumDelaySeconds ?? 0;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(maximumPropagationDelaySeconds) ||
|
||||
@@ -119,42 +128,110 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
|
||||
) {
|
||||
throw new RangeError("maximumPropagationDelaySeconds must be finite and non-negative.");
|
||||
}
|
||||
const preRollSamples = ceilSampleIndex(maximumPropagationDelaySeconds, sampleRate);
|
||||
const requestedPreRollSamples = ceilSampleIndex(
|
||||
maximumPropagationDelaySeconds,
|
||||
sampleRate,
|
||||
);
|
||||
const requiredPreRollSamples = pathSummary
|
||||
? ceilSampleIndex(pathSummary.maximumDelaySeconds, sampleRate)
|
||||
: 0;
|
||||
if (pathSummary && requestedPreRollSamples < requiredPreRollSamples) {
|
||||
throw new RangeError(
|
||||
"maximumPropagationDelaySeconds is shorter than an active acoustic path delay.",
|
||||
);
|
||||
}
|
||||
const renderMaximumPropagationDelaySeconds = pathSummary
|
||||
? pathSummary.sources.reduce(
|
||||
(maximum, sourceSummary, sourceIndex) =>
|
||||
sampledScene.sources[sourceIndex].signal.type === "tone"
|
||||
? maximum
|
||||
: Math.max(maximum, sourceSummary.maximumDelaySeconds),
|
||||
0,
|
||||
)
|
||||
: maximumPropagationDelaySeconds;
|
||||
const preRollSamples = ceilSampleIndex(
|
||||
renderMaximumPropagationDelaySeconds,
|
||||
sampleRate,
|
||||
);
|
||||
const renderStartSample = Math.max(0, startSample - preRollSamples);
|
||||
const renderDuration = (endSample - renderStartSample) / sampleRate;
|
||||
const sources = [];
|
||||
const sourceOffsets = [];
|
||||
const sourceIndexes = [];
|
||||
|
||||
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 signalDuration = effectiveSignalDurationSeconds(
|
||||
source,
|
||||
scene,
|
||||
options.assetBuffers,
|
||||
);
|
||||
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;
|
||||
const sourceMaximumDelaySeconds =
|
||||
pathSummary?.sources[sourceIndex]?.maximumDelaySeconds ??
|
||||
maximumPropagationDelaySeconds;
|
||||
const latestPossibleArrivalEndSample = ceilSampleIndex(
|
||||
signalStartTime + signalDuration + sourceMaximumDelaySeconds,
|
||||
sampleRate,
|
||||
);
|
||||
const analyticTailMayOverlap =
|
||||
source.signal.type === "tone" &&
|
||||
signalStartSample < endSample &&
|
||||
latestPossibleArrivalEndSample > startSample;
|
||||
if (activeEndSample <= activeStartSample && !analyticTailMayOverlap) continue;
|
||||
const hasEmissionInRender = activeEndSample > activeStartSample;
|
||||
|
||||
sources.push({
|
||||
...source,
|
||||
position: sampledScene.sources[sourceIndex].position,
|
||||
startTime: (activeStartSample - renderStartSample) / sampleRate,
|
||||
duration: (activeEndSample - activeStartSample) / sampleRate,
|
||||
startTime: hasEmissionInRender
|
||||
? (activeStartSample - renderStartSample) / sampleRate
|
||||
: 0,
|
||||
duration: hasEmissionInRender
|
||||
? (activeEndSample - activeStartSample) / sampleRate
|
||||
: renderDuration,
|
||||
});
|
||||
sourceOffsets.push({
|
||||
signalStartTime,
|
||||
signalStartSample,
|
||||
signalDuration,
|
||||
});
|
||||
sourceIndexes.push(sourceIndex);
|
||||
}
|
||||
|
||||
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
|
||||
const windowScene = {
|
||||
...scene,
|
||||
duration: renderDuration,
|
||||
sources,
|
||||
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
||||
};
|
||||
const selectedPathSummary = pathSummary
|
||||
? selectAcousticPathSummary({
|
||||
summary: pathSummary,
|
||||
sourceIndexes,
|
||||
sourceScene: sampledScene,
|
||||
scene: windowScene,
|
||||
arrayDefinition: options.arrayDefinition,
|
||||
referenceDistance: options.referenceDistance ?? 1,
|
||||
minimumDistance: options.minimumDistance ?? 0.01,
|
||||
})
|
||||
: undefined;
|
||||
const signalOffsets = {
|
||||
sceneSampleOffset: renderStartSample,
|
||||
cropStartSample: startSample - renderStartSample,
|
||||
cropSampleCount: endSample - startSample,
|
||||
maximumPropagationDelaySeconds,
|
||||
renderMaximumPropagationDelaySeconds,
|
||||
sourcePositionSampleTime: sampleTime,
|
||||
windowingModel: "quasi-static-midpoint-propagation-preroll",
|
||||
...(selectedPathSummary
|
||||
? { acousticPathSummary: selectedPathSummary }
|
||||
: {}),
|
||||
sources: sourceOffsets,
|
||||
ambient: (scene.ambient ?? []).map(() => ({
|
||||
signalStartTime: 0,
|
||||
@@ -163,7 +240,6 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
|
||||
})),
|
||||
};
|
||||
const duration = (endSample - startSample) / sampleRate;
|
||||
const renderDuration = (endSample - renderStartSample) / sampleRate;
|
||||
return {
|
||||
startTime: alignedStartTime,
|
||||
endTime: alignedEndTime,
|
||||
@@ -176,13 +252,9 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
|
||||
renderDuration,
|
||||
preRollSamples: startSample - renderStartSample,
|
||||
maximumPropagationDelaySeconds,
|
||||
renderMaximumPropagationDelaySeconds,
|
||||
signalOffsets,
|
||||
scene: {
|
||||
...scene,
|
||||
duration: renderDuration,
|
||||
sources,
|
||||
...(Array.isArray(scene.ambient) ? { ambient } : {}),
|
||||
},
|
||||
scene: windowScene,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
MAX_PRECISE_ANALOG_FREQUENCY_HZ,
|
||||
observeFrequencyBand,
|
||||
} from "../acoustics-core/sampling.js";
|
||||
import {
|
||||
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||
estimateSynthesisWorkingSet,
|
||||
} from "../acoustics-core/propagation.js";
|
||||
|
||||
export const INPUT_SCHEMA_VERSION = "1.0.0";
|
||||
export const RESULT_SCHEMA_VERSION = "1.1.0";
|
||||
@@ -554,51 +558,27 @@ function validateExperimentRelationships(value, context = {}) {
|
||||
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
|
||||
);
|
||||
}
|
||||
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) {
|
||||
const sourcePositions = Array.isArray(source.trajectory)
|
||||
? source.trajectory.map((keyframe) => keyframe.position)
|
||||
: [source.position];
|
||||
for (const sourcePosition of sourcePositions) {
|
||||
for (const microphone of context.arrayDefinition.microphones) {
|
||||
const distance = Math.hypot(
|
||||
sourcePosition[0] - microphone.position[0],
|
||||
sourcePosition[1] - microphone.position[1],
|
||||
sourcePosition[2] - microphone.position[2],
|
||||
);
|
||||
maximumDelaySamples = Math.max(
|
||||
maximumDelaySamples,
|
||||
(distance / context.scene.speedOfSound) * context.scene.sampleRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let delayFftSize = 1;
|
||||
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) {
|
||||
try {
|
||||
const workingSet = estimateSynthesisWorkingSet({
|
||||
scene: context.scene,
|
||||
arrayDefinition: context.arrayDefinition,
|
||||
minimumDistance: value.beamforming?.minimumDistance ?? 0.01,
|
||||
});
|
||||
if (
|
||||
workingSet.estimatedBufferBytes >
|
||||
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES
|
||||
) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$",
|
||||
`현재 비스트리밍 CPU 모델의 signal buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
|
||||
`source-streaming signal buffer 예상 크기 ${(workingSet.estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$",
|
||||
`signal synthesis preflight failed: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
|
||||
import {
|
||||
buildAcousticPaths,
|
||||
maximumAcousticDelaySeconds,
|
||||
selectAcousticPathSummary,
|
||||
summarizeAcousticPaths,
|
||||
} from "/packages/acoustics-core/environment.js";
|
||||
import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js";
|
||||
import {
|
||||
@@ -26,7 +28,11 @@ import {
|
||||
} from "/packages/acoustics-core/localization-algorithms.js";
|
||||
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 {
|
||||
estimateSynthesisWorkingSet,
|
||||
sphericalAttenuation,
|
||||
synthesizeMicrophoneSignals,
|
||||
} from "/packages/acoustics-core/propagation.js";
|
||||
import { renderSignal } from "/packages/acoustics-core/signal.js";
|
||||
import {
|
||||
describeFrequencyAliasing,
|
||||
@@ -1479,6 +1485,11 @@ test("quasi-static pre-roll preserves reflection and diffuse tone tails", () =>
|
||||
`${label} geometry metadata`,
|
||||
);
|
||||
assertEqual(partial.windowing.dopplerMode, "not-modeled", `${label} Doppler metadata`);
|
||||
assertEqual(
|
||||
partial.windowing.delayedTailPolicy,
|
||||
"live-request-window-direct-output-with-analytic-path-gating",
|
||||
`${label} direct live crop policy`,
|
||||
);
|
||||
assertEqual(
|
||||
partial.windowing.arrivingSourceIds.join(","),
|
||||
"ended-tone",
|
||||
@@ -1512,3 +1523,524 @@ test("quasi-static pre-roll preserves reflection and diffuse tone tails", () =>
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("interior output crop은 tone·sampled source·ambient의 whole-scene slice와 동치다", () => {
|
||||
const sampleRate = 1_024;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 1,
|
||||
speedOfSound: 343,
|
||||
seed: 417,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [
|
||||
{
|
||||
id: "aliased-tone",
|
||||
position: [0.5, -0.1, 0.3],
|
||||
gainDb: -18,
|
||||
startTime: 0.031,
|
||||
duration: 0.81,
|
||||
signal: { type: "tone", frequency: 700, phaseDeg: 23 },
|
||||
},
|
||||
{
|
||||
id: "sampled-noise",
|
||||
position: [-0.2, 0.4, 0.6],
|
||||
gainDb: -24,
|
||||
startTime: 0.017,
|
||||
duration: 0.9,
|
||||
signal: { type: "noise", color: "pink", seedOffset: 7 },
|
||||
},
|
||||
{
|
||||
id: "sampled-asset",
|
||||
position: [0.1, 0.2, 0.7],
|
||||
gainDb: -12,
|
||||
startTime: 0.05,
|
||||
duration: 0.8,
|
||||
signal: { type: "asset", assetId: "interior", channel: 0, loop: true },
|
||||
},
|
||||
],
|
||||
ambient: [{
|
||||
id: "sampled-ambient",
|
||||
gainDb: -30,
|
||||
signal: { type: "noise", color: "white", seedOffset: 13 },
|
||||
}],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [
|
||||
{ id: "left", position: [0, 0, 0] },
|
||||
{
|
||||
id: "right",
|
||||
position: [0.23, -0.11, 0],
|
||||
sensitivityDb: 1.5,
|
||||
phaseOffsetDeg: -17,
|
||||
frequencyResponse: [
|
||||
{ frequencyHz: 0, gainDb: -1, phaseDeg: 5 },
|
||||
{ frequencyHz: 512, gainDb: 2, phaseDeg: -11 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const assetBuffers = new Map([["interior", {
|
||||
sampleRate: 800,
|
||||
channels: [Float32Array.from(
|
||||
{ length: 173 },
|
||||
(_, index) => Math.sin((2 * Math.PI * index) / 31) * 0.6,
|
||||
)],
|
||||
}]]);
|
||||
const whole = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
});
|
||||
const cropStartSample = 173;
|
||||
const cropSampleCount = 211;
|
||||
const cropped = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
signalOffsets: {
|
||||
sceneSampleOffset: 0,
|
||||
cropStartSample,
|
||||
cropSampleCount,
|
||||
},
|
||||
});
|
||||
assertEqual(cropped.channels[0].length, cropSampleCount, "crop-sized microphone output");
|
||||
assertEqual(
|
||||
cropped.workingSet.channelBytes,
|
||||
arrayDefinition.microphones.length * cropSampleCount * 4,
|
||||
"only crop-sized channels are retained",
|
||||
);
|
||||
cropped.channels.forEach((channel, microphoneIndex) => {
|
||||
channel.forEach((value, sampleIndex) => {
|
||||
assertNear(
|
||||
value,
|
||||
whole.channels[microphoneIndex][cropStartSample + sampleIndex],
|
||||
4e-5,
|
||||
`interior crop mic ${microphoneIndex} sample ${sampleIndex}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("source-streaming working set은 source 수가 아니라 한 sampled source의 Dmax로 결정된다", () => {
|
||||
const sampleRate = 1_000;
|
||||
const sampledSources = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `noise-${index}`,
|
||||
position: [1, 0, 0],
|
||||
gainDb: -40,
|
||||
startTime: 0,
|
||||
duration: 0.062,
|
||||
signal: { type: "noise", color: "white", seedOffset: index },
|
||||
}));
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 0.062,
|
||||
speedOfSound: 1_000,
|
||||
seed: 9,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [
|
||||
{
|
||||
id: "far-analytic-tone",
|
||||
position: [100, 0, 0],
|
||||
gainDb: -60,
|
||||
startTime: 0,
|
||||
duration: 0.062,
|
||||
signal: { type: "tone", frequency: 137, phaseDeg: 0 },
|
||||
},
|
||||
...sampledSources,
|
||||
],
|
||||
ambient: [
|
||||
{ id: "ambient-a", gainDb: -60, signal: { type: "noise", color: "white" } },
|
||||
{ id: "ambient-b", gainDb: -60, signal: { type: "noise", color: "pink" } },
|
||||
],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [
|
||||
{ id: "mic-a", position: [0, 0, 0] },
|
||||
{ id: "mic-b", position: [0, 0, 0] },
|
||||
],
|
||||
};
|
||||
const signalOffsets = {
|
||||
sceneSampleOffset: 0,
|
||||
cropStartSample: 10,
|
||||
cropSampleCount: 7,
|
||||
};
|
||||
const estimate = estimateSynthesisWorkingSet({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
signalOffsets,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertEqual(estimate.channelBytes, 56, "two seven-sample Float32 channels");
|
||||
assertEqual(estimate.maximumTransient.maximumDelaySamples, 1, "sampled source Dmax");
|
||||
assertEqual(estimate.maximumTransient.fftSize, 128, "source-specific delay FFT size");
|
||||
assertEqual(estimate.maximumTransientBytes, 4_344, "one samples+spectrum+workspace");
|
||||
assertEqual(estimate.estimatedBufferBytes, 4_400, "exact peak typed working set");
|
||||
assertEqual(
|
||||
estimate.acousticPathSummary.maximumDelaySeconds,
|
||||
0.1,
|
||||
"far analytic tone remains in physical path metadata",
|
||||
);
|
||||
|
||||
const synthesis = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
signalOffsets,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
maximumBufferBytes: 4_400,
|
||||
});
|
||||
assertEqual(synthesis.estimatedBufferBytes, 4_400, "runtime uses the shared estimate");
|
||||
assertEqual(synthesis.channels[0].length, 7, "runtime cap succeeds at equality");
|
||||
let error;
|
||||
try {
|
||||
synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
signalOffsets,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
maximumBufferBytes: 4_399,
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
assert(error?.message.includes("한도"), "one byte below exact peak is rejected");
|
||||
});
|
||||
|
||||
test("far analytic tone은 live sampled source·ambient render pre-roll과 FFT를 키우지 않는다", () => {
|
||||
const sampleRate = 1_000;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 6,
|
||||
speedOfSound: 100,
|
||||
seed: 77,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [
|
||||
{
|
||||
id: "far-tone",
|
||||
position: [500, 0, 0],
|
||||
gainDb: -30,
|
||||
startTime: 0,
|
||||
duration: 6,
|
||||
signal: { type: "tone", frequency: 137, phaseDeg: 19 },
|
||||
},
|
||||
{
|
||||
id: "near-noise",
|
||||
position: [1, 0, 0],
|
||||
gainDb: -24,
|
||||
startTime: 0,
|
||||
duration: 6,
|
||||
signal: { type: "noise", color: "white", seedOffset: 3 },
|
||||
},
|
||||
],
|
||||
ambient: [{
|
||||
id: "ambient",
|
||||
gainDb: -36,
|
||||
signal: { type: "noise", color: "pink", seedOffset: 5 },
|
||||
}],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
||||
};
|
||||
const whole = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
const window = sampleQuasiStaticWindow(scene, 5.5, 5.6, {
|
||||
arrayDefinition,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertNear(window.maximumPropagationDelaySeconds, 5, 1e-12, "physical global Dmax");
|
||||
assertNear(
|
||||
window.renderMaximumPropagationDelaySeconds,
|
||||
0.01,
|
||||
1e-12,
|
||||
"sampled render Dmax excludes analytic tone",
|
||||
);
|
||||
assertEqual(window.preRollSamples, 10, "only sampled propagation needs pre-roll samples");
|
||||
assertEqual(window.scene.duration * sampleRate, 110, "render buffer is crop plus sampled Dmax");
|
||||
const partial = synthesizeMicrophoneSignals({
|
||||
scene: window.scene,
|
||||
arrayDefinition,
|
||||
signalOffsets: window.signalOffsets,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertEqual(partial.workingSet.maximumTransientId, "near-noise", "near sampled source sets peak");
|
||||
assertEqual(partial.workingSet.maximumTransientBytes, 110 * 4 + 128 * 32, "small source FFT");
|
||||
assertNear(
|
||||
partial.windowing.renderMaximumPropagationDelaySeconds,
|
||||
0.01,
|
||||
1e-12,
|
||||
"synthesis records actual sampled pre-roll Dmax",
|
||||
);
|
||||
partial.channels[0].forEach((value, sampleIndex) => {
|
||||
assertNear(
|
||||
value,
|
||||
whole.channels[0][window.startSample + sampleIndex],
|
||||
4e-5,
|
||||
`far-tone live crop sample ${sampleIndex}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("live path summary는 source subset에 맞춰 불변 재사용되고 stale geometry에는 재사용되지 않는다", () => {
|
||||
const scene = {
|
||||
sampleRate: 2_000,
|
||||
duration: 1,
|
||||
speedOfSound: 100,
|
||||
seed: 31,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: "moving-noise",
|
||||
position: [2, 0, 0],
|
||||
gainDb: -24,
|
||||
startTime: 0,
|
||||
duration: 1,
|
||||
signal: { type: "noise", color: "white" },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
||||
};
|
||||
const window = sampleQuasiStaticWindow(scene, 0.3, 0.4, {
|
||||
arrayDefinition,
|
||||
});
|
||||
const summary = window.signalOffsets.acousticPathSummary;
|
||||
assert(Object.isFrozen(summary), "path summary is immutable");
|
||||
assert(Object.isFrozen(summary.sources), "source summaries are immutable");
|
||||
const synthesis = synthesizeMicrophoneSignals({
|
||||
scene: window.scene,
|
||||
arrayDefinition,
|
||||
signalOffsets: window.signalOffsets,
|
||||
});
|
||||
assertEqual(
|
||||
synthesis.workingSet.pathSummary,
|
||||
"reused-authenticated-geometry-summary",
|
||||
"live synthesis reuses its authenticated summary",
|
||||
);
|
||||
assertEqual(
|
||||
synthesis.workingSet.summaryPathPairEvaluations,
|
||||
0,
|
||||
"synthesis does not repeat its preflight summary enumeration",
|
||||
);
|
||||
assertEqual(
|
||||
synthesis.workingSet.synthesisPathBuildPairEvaluations,
|
||||
1,
|
||||
"each selected source/receiver path set is built once for synthesis",
|
||||
);
|
||||
const movedArray = structuredClone(arrayDefinition);
|
||||
movedArray.microphones[0].position[0] = 0.5;
|
||||
const stale = estimateSynthesisWorkingSet({
|
||||
scene: window.scene,
|
||||
arrayDefinition: movedArray,
|
||||
signalOffsets: window.signalOffsets,
|
||||
});
|
||||
assert(!stale.reusedPathSummary, "changed receiver geometry invalidates the summary");
|
||||
|
||||
const originalSummary = summarizeAcousticPaths({ scene, arrayDefinition });
|
||||
assertEqual(
|
||||
summarizeAcousticPaths({ scene, arrayDefinition }),
|
||||
originalSummary,
|
||||
"unchanged geometry reuses the internally cached exact summary",
|
||||
);
|
||||
assertEqual(
|
||||
summarizeAcousticPaths({
|
||||
scene: structuredClone(scene),
|
||||
arrayDefinition: structuredClone(arrayDefinition),
|
||||
}),
|
||||
originalSummary,
|
||||
"bounded signature cache also reuses validated structured clones",
|
||||
);
|
||||
const movedScene = structuredClone(scene);
|
||||
movedScene.sources[0].position[0] = 8;
|
||||
let staleRetargetError;
|
||||
try {
|
||||
selectAcousticPathSummary({
|
||||
summary: originalSummary,
|
||||
sourceIndexes: [0],
|
||||
sourceScene: scene,
|
||||
scene: movedScene,
|
||||
arrayDefinition,
|
||||
});
|
||||
} catch (error) {
|
||||
staleRetargetError = error;
|
||||
}
|
||||
assert(
|
||||
staleRetargetError?.message.includes("selected scene path geometry"),
|
||||
"selector cannot rebrand stale numeric delays for new geometry",
|
||||
);
|
||||
|
||||
let insufficientDelayError;
|
||||
try {
|
||||
sampleQuasiStaticWindow(scene, 0.3, 0.4, {
|
||||
arrayDefinition,
|
||||
maximumPropagationDelaySeconds: 0.001,
|
||||
});
|
||||
} catch (error) {
|
||||
insufficientDelayError = error;
|
||||
}
|
||||
assert(
|
||||
insufficientDelayError?.message.includes("shorter than"),
|
||||
"undersized explicit pre-roll is rejected",
|
||||
);
|
||||
});
|
||||
|
||||
test("non-loop asset arrival truth는 decoded channel 종료 시각에서 끝난다", () => {
|
||||
const sampleRate = 1_000;
|
||||
const scene = {
|
||||
sampleRate,
|
||||
duration: 1,
|
||||
speedOfSound: 100,
|
||||
seed: 5,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: "short-asset",
|
||||
position: [10, 0, 0],
|
||||
gainDb: -12,
|
||||
startTime: 0,
|
||||
duration: 0.5,
|
||||
signal: { type: "asset", assetId: "short", channel: 0, loop: false },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [{ id: "mic", position: [0, 0, 0] }],
|
||||
};
|
||||
const assetBuffers = new Map([["short", {
|
||||
sampleRate,
|
||||
channels: [Float32Array.from(
|
||||
{ length: 50 },
|
||||
(_, index) => Math.sin((2 * Math.PI * index) / 11),
|
||||
)],
|
||||
}]]);
|
||||
const whole = synthesizeMicrophoneSignals({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
const arrival = whole.windowing.sourceArrivals[0];
|
||||
assertEqual(arrival.emissionEndSample, 50, "decoded channel limits emission");
|
||||
assertEqual(arrival.earliestArrivalSample, 100, "direct arrival start");
|
||||
assertEqual(arrival.latestArrivalEndSample, 150, "decoded tail arrival end");
|
||||
|
||||
const arrivingWindow = sampleQuasiStaticWindow(scene, 0.12, 0.14, {
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
const arriving = synthesizeMicrophoneSignals({
|
||||
scene: arrivingWindow.scene,
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
signalOffsets: arrivingWindow.signalOffsets,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertEqual(
|
||||
arriving.windowing.arrivingSourceIds.join(","),
|
||||
"short-asset",
|
||||
"decoded tail overlaps the early output",
|
||||
);
|
||||
const silentWindow = sampleQuasiStaticWindow(scene, 0.3, 0.4, {
|
||||
arrayDefinition,
|
||||
assetBuffers,
|
||||
referenceDistance: 1,
|
||||
minimumDistance: 1,
|
||||
});
|
||||
assertEqual(silentWindow.scene.sources.length, 0, "expired decoded asset is not retained");
|
||||
});
|
||||
|
||||
test("거대 acoustic delay와 path evaluation budget은 typed channel 할당 전에 거부된다", () => {
|
||||
const scene = {
|
||||
sampleRate: 8_000,
|
||||
duration: 0.01,
|
||||
speedOfSound: 343,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{
|
||||
id: "astronomical",
|
||||
position: [Number.MAX_VALUE / 4, 0, 0],
|
||||
gainDb: -30,
|
||||
startTime: 0,
|
||||
duration: 0.01,
|
||||
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
const arrayDefinition = {
|
||||
microphones: [
|
||||
{ id: "mic-a", position: [0, 0, 0] },
|
||||
{ id: "mic-b", position: [0, 0, 0] },
|
||||
],
|
||||
};
|
||||
let delayError;
|
||||
try {
|
||||
synthesizeMicrophoneSignals({ scene, arrayDefinition });
|
||||
} catch (error) {
|
||||
delayError = error;
|
||||
}
|
||||
assert(
|
||||
delayError?.message.includes("sample position"),
|
||||
"unsafe path delay fails during preflight",
|
||||
);
|
||||
|
||||
const boundedScene = structuredClone(scene);
|
||||
boundedScene.sources[0].position = [1, 0, 0];
|
||||
let budgetError;
|
||||
try {
|
||||
synthesizeMicrophoneSignals({
|
||||
scene: boundedScene,
|
||||
arrayDefinition,
|
||||
maximumPathEvaluations: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
budgetError = error;
|
||||
}
|
||||
assert(
|
||||
budgetError?.message.includes("maximumPathEvaluations"),
|
||||
"path evaluation cap fails before path objects accumulate",
|
||||
);
|
||||
|
||||
let invalidBudgetError;
|
||||
try {
|
||||
estimateSynthesisWorkingSet({
|
||||
scene: boundedScene,
|
||||
arrayDefinition,
|
||||
maximumPathEvaluations: 1.5,
|
||||
});
|
||||
} catch (error) {
|
||||
invalidBudgetError = error;
|
||||
}
|
||||
assert(
|
||||
invalidBudgetError?.message.includes("positive safe integer"),
|
||||
"path evaluation budget itself is validated",
|
||||
);
|
||||
|
||||
let absoluteRangeError;
|
||||
try {
|
||||
estimateSynthesisWorkingSet({
|
||||
scene: boundedScene,
|
||||
arrayDefinition,
|
||||
signalOffsets: {
|
||||
sceneSampleOffset: Number.MAX_SAFE_INTEGER,
|
||||
cropStartSample: 1,
|
||||
cropSampleCount: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
absoluteRangeError = error;
|
||||
}
|
||||
assert(
|
||||
absoluteRangeError?.message.includes("absolute sample range"),
|
||||
"absolute output sample addition is checked before synthesis",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
describeFrequencyAliasing,
|
||||
observeFrequencyBand,
|
||||
} from "/packages/acoustics-core/sampling.js";
|
||||
import {
|
||||
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||
estimateSynthesisWorkingSet,
|
||||
} from "/packages/acoustics-core/propagation.js";
|
||||
|
||||
async function loadJson(path) {
|
||||
const response = await fetch(path);
|
||||
@@ -481,6 +485,85 @@ test("analytic tone-only Scene은 존재하지 않는 delay FFT 메모리로 거
|
||||
);
|
||||
});
|
||||
|
||||
test("Experiment preflight는 runtime과 같은 source-streaming shoebox delay working set을 쓴다", 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, 16);
|
||||
scene.sampleRate = 192_000;
|
||||
scene.duration = 4_000_000 / scene.sampleRate;
|
||||
scene.sources = [{
|
||||
...structuredClone(scene.sources[0]),
|
||||
id: "long-sampled-source",
|
||||
name: "Long sampled source",
|
||||
position: [0, 0, 1],
|
||||
startTime: 0,
|
||||
duration: scene.duration,
|
||||
signal: { type: "noise", color: "white", seedOffset: 1 },
|
||||
}];
|
||||
scene.ambient = [];
|
||||
scene.acoustics = {
|
||||
model: "shoebox",
|
||||
room: { min: [-10, -10, -10], max: [10, 10, 10] },
|
||||
reflections: { order: 0, wallGainDb: -12, scattering: 0 },
|
||||
diffuseReverb: {
|
||||
enabled: true,
|
||||
rt60Seconds: 4,
|
||||
gainDb: -18,
|
||||
tapCount: 1,
|
||||
},
|
||||
occluders: [],
|
||||
};
|
||||
experiment.beamforming.minimumDistance = 0.01;
|
||||
|
||||
const workingSet = estimateSynthesisWorkingSet({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
minimumDistance: experiment.beamforming.minimumDistance,
|
||||
});
|
||||
assertEqual(workingSet.sampleCount, 4_000_000, "fixture sample count");
|
||||
assertEqual(
|
||||
workingSet.maximumTransient.fftSize,
|
||||
8_388_608,
|
||||
"diffuse delay crosses the FFT power-of-two boundary",
|
||||
);
|
||||
assert(
|
||||
workingSet.estimatedBufferBytes > DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||
"shared runtime estimate exceeds 512 MiB",
|
||||
);
|
||||
const issues = await validateExperiment(experiment, {
|
||||
arrayDefinition,
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
issues.some(({ message }) =>
|
||||
message.includes("source-streaming") && message.includes("512 MiB")),
|
||||
`validator must reject the same shared estimate: ${JSON.stringify(issues)}`,
|
||||
);
|
||||
|
||||
scene.sources[0].signal = { type: "tone", frequency: 1_000, phaseDeg: 0 };
|
||||
const toneWorkingSet = estimateSynthesisWorkingSet({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
minimumDistance: experiment.beamforming.minimumDistance,
|
||||
});
|
||||
assertEqual(
|
||||
toneWorkingSet.estimatedBufferBytes,
|
||||
toneWorkingSet.channelBytes,
|
||||
"analytic tone has no sampled FFT transient",
|
||||
);
|
||||
const toneIssues = await validateExperiment(experiment, {
|
||||
arrayDefinition,
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
!toneIssues.some(({ message }) => message.includes("512 MiB")),
|
||||
"validator does not charge analytic tone for a nonexistent delay FFT",
|
||||
);
|
||||
});
|
||||
|
||||
test("NaN 좌표를 거부한다", async () => {
|
||||
const arrayDefinition = {
|
||||
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m",
|
||||
|
||||
Reference in New Issue
Block a user