perf: stream propagation working sets

This commit is contained in:
2026-08-29 10:56:51 +09:00
parent de0053eae3
commit 044be3cf92
13 changed files with 1532 additions and 267 deletions
@@ -28,6 +28,7 @@ function signalStats(synthesis) {
acousticModel: synthesis.acousticModel, acousticModel: synthesis.acousticModel,
acousticPathCount: synthesis.acousticPathCount, acousticPathCount: synthesis.acousticPathCount,
acousticPathCounts: synthesis.acousticPathCounts, acousticPathCounts: synthesis.acousticPathCounts,
workingSet: synthesis.workingSet,
windowing: synthesis.windowing, windowing: synthesis.windowing,
sampling: synthesis.sampling, sampling: synthesis.sampling,
}; };
@@ -79,6 +80,7 @@ async function runTimeline({ jobId, scene, arrayDefinition, experiment, assetBuf
plannedFrame.endTime, plannedFrame.endTime,
{ {
arrayDefinition, arrayDefinition,
assetBuffers,
minimumDistance: experiment.beamforming.minimumDistance, minimumDistance: experiment.beamforming.minimumDistance,
}, },
); );
+18 -7
View File
@@ -84,24 +84,29 @@ Scene JSON의 사용자 설정 예는 다음과 같다.
## 라이브 창의 전파 지연과 이동 근사 ## 라이브 창의 전파 지연과 이동 근사
라이브 frame의 출력 sample 구간을 `[S, E)`, 현재 quasi-static geometry에서 모든 라이브 frame의 출력 sample 구간을 `[S, E)`, 현재 quasi-static geometry에서 sampled
source–microphone 희소 경로의 최대 지연을 `Dmax`라 하자. 합성기는 다음 순서로 frame을 만든다. source–microphone 희소 경로의 최대 지연을 `Dsampled`라 하자. 전체 경로 최대 `Dall`은
tone tail의 도착 가능성 판정과 metadata에 별도로 보존한다. 합성기는 다음 순서로 frame을 만든다.
1. source trajectory를 출력 창 중앙 시각에서 한 번 샘플링한다. 1. source trajectory를 출력 창 중앙 시각에서 한 번 샘플링한다.
2. `R = max(0, S - ceil(Dmax × sampleRate))`부터 `E`까지의 tone, noise와 asset 방사 신호를 2. `R = max(0, S - ceil(Dsampled × sampleRate))`를 sampled 방사 입력의 시작으로 정한다.
원래 Scene 절대 sample index로 생성한다. 3. analytic tone은 `[S, E)` 크기의 마이크 channel에 원래 Scene의 절대 sample 위상으로 직접
3. 이 pre-roll buffer에 direct, reflection, scattering, diffraction과 diffuse 경로를 적용한다. 경로를 더한다. tone에는 과거 sample buffer가 필요 없다.
4. 전파가 끝난 뒤 `[S, E)`만 crop하여 localizer에 넘긴다. 4. noise와 asset은 `[R, E)` 방사 buffer와 source별 최대 지연의 zero-padded FFT를 만들고,
direct·reflection·scattering·diffraction·diffuse 전달함수를 적용한 IFFT의 `[S, E)` 부분만
crop 크기 마이크 channel에 더한다.
따라서 source가 `S` 전에 멈췄더라도 그 직접음·반사음·diffuse tail이 창 안에 도착하면 보존된다. 따라서 source가 `S` 전에 멈췄더라도 그 직접음·반사음·diffuse tail이 창 안에 도착하면 보존된다.
analytic tone뿐 아니라 deterministic noise index와 asset 재생 위치도 원래 절대 시간축을 유지한다. analytic tone뿐 아니라 deterministic noise index와 asset 재생 위치도 원래 절대 시간축을 유지한다.
`synthesis.windowing`과 live frame의 `signalStats.windowing`은 render/output sample, pre-roll, `synthesis.windowing`과 live frame의 `signalStats.windowing`은 render/output sample, pre-roll,
최대 지연, geometry와 Doppler 정책을 기록한다. 전체/실제 render 최대 지연, geometry와 Doppler 정책을 기록한다.
source별 `sourceArrivals`는 각 midpoint path의 half-open 도착 sample 구간이 출력과 겹치는지와 source별 `sourceArrivals`는 각 midpoint path의 half-open 도착 sample 구간이 출력과 겹치는지와
겹친 path kind를 기록한다. live ground-truth 평가는 emission midpoint가 아니라 겹친 path kind를 기록한다. live ground-truth 평가는 emission midpoint가 아니라
`arrivingSourceIds`를 사용하므로, 이미 멈춘 source의 tail을 false positive로 잘못 세지 않는다. `arrivingSourceIds`를 사용하므로, 이미 멈춘 source의 tail을 false positive로 잘못 세지 않는다.
이 ID 집합은 위치 추정이 끝난 뒤 평가에만 쓰며 raw channel, STFT, steering이나 peak 검출에는 이 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 위치를 다시 다만 이는 moving wave equation 해법이 아니다. pre-roll 방사 시각의 retarded source 위치를 다시
구하지 않고 전체 render buffer에서 창 중앙 위치와 그 위치의 경로를 고정한다. 창 안의 연속 이동, 구하지 않고 전체 render buffer에서 창 중앙 위치와 그 위치의 경로를 고정한다. 창 안의 연속 이동,
@@ -114,6 +119,12 @@ pre-roll은 현재 희소 **전파 경로**의 최대 지연을 보존하지만,
`mix.normalization: peak`이면 live 합성은 각 출력 crop의 peak로 scale을 계산하므로 frame 간 `mix.normalization: peak`이면 live 합성은 각 출력 crop의 peak로 scale을 계산하므로 frame 간
절대 진폭 비교에는 `normalization: none`을 사용해야 한다. 절대 진폭 비교에는 `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 환경 preset과 비용
Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음 Camera의 **고급 설정 → 음향 환경**은 현재 geometry 전체가 들어가도록 room을 자동 추론하고 다음
+3 -1
View File
@@ -217,7 +217,9 @@ Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검
- 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계 - 마이크 주파수 응답점의 범위·정렬과 shoebox room/occluder 포함 관계
- map shape와 source peak 매칭 - map shape와 source peak 매칭
- band별 evaluation 파라미터와 허용 metric - 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은 묵시적으로 변환하지 않고 오류로 알린다. 지원하지 않는 schemaVersion은 묵시적으로 변환하지 않고 오류로 알린다.
+2 -1
View File
@@ -218,7 +218,8 @@ hardware deadline과 정확도는 장치 adapter가 정해진 뒤 별도 측정
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다. - WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다. - 좌표·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 기본 미리보기는 최대 48개·2,500 points의 짧은 시간창으로 제한한다. 장시간 입력은 256 MiB 기본
한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다. 한도의 fixed ring streaming core에서 처리하고 overload의 `droppedSamples`를 감시한다.
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로 - 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
+38 -9
View File
@@ -176,12 +176,16 @@ scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를 경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다. sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
시간창은 요청 출력 시작보다 앞선 신호를 현재 array·음향 환경의 최대 경로 지연만큼 pre-roll합니다. 시간창은 요청 출력 시작보다 앞선 sampled noise·asset을 해당 source들의 최대 경로 지연만큼
따라서 창이 시작되기 전에 발음이 끝난 source라도 direct·reflection·scattering·diffraction·diffuse pre-roll합니다. analytic tone은 과거 sample buffer 없이 절대시간에서 직접 계산하므로 tone의 더 긴
경로가 창 안에 도착하면 제거하지 않습니다. `signalOffsets`는 원래 scene/source의 절대 sample 경로는 render buffer를 늘리지 않습니다. 그래도 창이 시작되기 전에 발음이 끝난 tone을
offset과 출력 crop을 함께 보존합니다. `synthesizeMicrophoneSignals`는 pre-roll 버퍼에 모든 경로를 direct·reflection·scattering·diffraction·diffuse 도착 구간으로 선별해 제거하지 않습니다.
적용한 뒤 요청 구간만 반환하므로 tone phase, seed 기반 noise index, audio asset 재생 위치와 `signalOffsets`는 원래 scene/source의 절대 sample
ambient가 전체 timeline과 같은 절대 시간축을 사용합니다. 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 이 보장은 현재 희소 경로 모델 안에서의 **전파 지연 tail**에 관한 것입니다. quasi-static 창은 pre-roll
구간까지 포함해 source를 출력 창 중앙의 한 위치에 고정하며, retarded-time trajectory, 창 안의 연속 구간까지 포함해 source를 출력 창 중앙의 한 위치에 고정하며, retarded-time trajectory, 창 안의 연속
@@ -190,6 +194,8 @@ sample, `source-position-fixed-at-window-midpoint` geometry와 `dopplerMode: not
`sourceArrivals`는 source별 방사·도착 sample 경계와 출력에 겹친 path kind를, `sourceArrivals`는 source별 방사·도착 sample 경계와 출력에 겹친 path kind를,
`arrivingSourceIds`는 적어도 한 실제 path의 half-open 도착 구간이 출력과 겹친 source를 기록합니다. `arrivingSourceIds`는 적어도 한 실제 path의 half-open 도착 구간이 출력과 겹친 source를 기록합니다.
이는 사후 ground-truth 평가에만 쓰이며 위치 추정 입력에는 전달하지 않습니다. 이는 사후 ground-truth 평가에만 쓰이며 위치 추정 입력에는 전달하지 않습니다.
loop하지 않는 asset의 방사 종료는 선언 duration과 decoded channel duration 중 빠른 쪽이며, PCM
내용의 leading/trailing zero까지 분석해 truth를 줄이지는 않습니다.
sampled asset/noise의 fractional delay와 microphone response는 기존 zero-padded block FFT 모델을 sampled asset/noise의 fractional delay와 microphone response는 기존 zero-padded block FFT 모델을
그대로 사용하므로 임의 길이 analog microphone impulse response의 streaming state를 재현하는 모델은 그대로 사용하므로 임의 길이 analog microphone impulse response의 streaming state를 재현하는 모델은
아닙니다. 아닙니다.
@@ -247,9 +253,32 @@ Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면
센서 self-noise, ADC quantization·clock jitter는 계산하지 않습니다. 그런 효과가 중요한 센서 self-noise, ADC quantization·clock jitter는 계산하지 않습니다. 그런 효과가 중요한
검증에서는 실측 다채널 데이터를 아래 streaming API에 넣어 결과를 비교해야 합니다. 검증에서는 실측 다채널 데이터를 아래 streaming API에 넣어 결과를 비교해야 합니다.
snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을 합성기는 source별로 경로를 한 receiver 묶음씩 만들고, sampled source도 하나씩
512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로 `render → spectrum → microphone 적용 → 해제`합니다. render 길이를 `N`, 출력 crop을 `C`,
전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB 마이크 수를 `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만 유지하고, hard cap을 할당 전에 검사합니다. DAS steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지하고,
GCC-PHAT는 거리·STFT·correlation workspace 전체를 plan 한도 안에서 검사합니다. Plan 없는 단발 GCC-PHAT는 거리·STFT·correlation workspace 전체를 plan 한도 안에서 검사합니다. Plan 없는 단발
GCC 분석도 거리 행렬을 만들기 전에 spectrum·calibration·output을 포함한 working set을 preflight합니다. GCC 분석도 거리 행렬을 만들기 전에 spectrum·calibration·output을 포함한 working set을 preflight합니다.
+267 -6
View File
@@ -11,6 +11,11 @@ const WALLS = [
]; ];
const GEOMETRY_EPSILON = 1e-9; 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) { function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) {
return position.every( return position.every(
@@ -223,10 +228,152 @@ export function buildAcousticPaths(options) {
return paths; return paths;
} }
export function maximumAcousticDelaySeconds(options) { export function maximumAcousticPathsPerReceiver(scene) {
const { scene, arrayDefinition, referenceDistance, minimumDistance } = options; const acoustics = scene?.acoustics;
let maximum = 0; if (!acoustics || acoustics.model === "free-field") return 1;
for (const source of scene.sources) { 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) { for (const microphone of arrayDefinition.microphones) {
const paths = buildAcousticPaths({ const paths = buildAcousticPaths({
scene, scene,
@@ -237,8 +384,122 @@ export function maximumAcousticDelaySeconds(options) {
referenceDistance, referenceDistance,
minimumDistance, 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;
} }
+25 -5
View File
@@ -32,19 +32,36 @@ export function addFrequencyDomainPaths(options) {
frequencyResponse, frequencyResponse,
sampleRate, sampleRate,
workspace, workspace,
outputOffsetSamples = 0,
pathDelaySampleRate,
} = options; } = options;
if (!Array.isArray(paths) || paths.length === 0) { if (!Array.isArray(paths) || paths.length === 0) {
throw new Error("frequency-domain path가 하나 이상 필요합니다."); 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) { for (const path of paths) {
if (!(path.delaySamples >= 0) || !Number.isFinite(path.delaySamples)) { const delaySamples = delaySamplesForPath(path);
throw new Error("유효하지 않은 delay: " + path.delaySamples); if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) {
throw new Error("유효하지 않은 delay: " + delaySamples);
} }
if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다."); if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다.");
} }
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) { if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다."); 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; const hasFrequencyResponse = Array.isArray(frequencyResponse) && frequencyResponse.length > 0;
if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) { if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) {
throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다."); throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다.");
@@ -58,7 +75,7 @@ export function addFrequencyDomainPaths(options) {
const halfSize = size / 2; const halfSize = size / 2;
const phaseReanchorInterval = 1024; const phaseReanchorInterval = 1024;
for (const path of paths) { for (const path of paths) {
const phaseStep = (-TAU * path.delaySamples) / size; const phaseStep = (-TAU * delaySamplesForPath(path)) / size;
const stepReal = Math.cos(phaseStep); const stepReal = Math.cos(phaseStep);
const stepImaginary = Math.sin(phaseStep); const stepImaginary = Math.sin(phaseStep);
let phaseReal = 1; let phaseReal = 1;
@@ -118,9 +135,12 @@ export function addFrequencyDomainPaths(options) {
// bin back to the real axis preserves Hermitian symmetry for fractional delays. // bin back to the real axis preserves Hermitian symmetry for fractional delays.
workspace.imaginary[size / 2] = 0; workspace.imaginary[size / 2] = 0;
fftInPlace(workspace.real, workspace.imaginary, true); 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) { for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
output[sampleIndex] += workspace.real[sampleIndex]; output[sampleIndex] += workspace.real[outputOffsetSamples + sampleIndex];
} }
} }
+418 -171
View File
@@ -13,8 +13,16 @@ import {
nextPowerOfTwo, nextPowerOfTwo,
prepareDelaySpectrum, prepareDelaySpectrum,
} from "./fractional-delay.js"; } from "./fractional-delay.js";
import { buildAcousticPaths } from "./environment.js"; import {
import { renderSignal } from "./signal.js"; buildAcousticPaths,
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
isAcousticPathSummaryCompatible,
summarizeAcousticPaths,
} from "./environment.js";
import {
effectiveSignalDurationSeconds,
renderSignal,
} from "./signal.js";
import { describeFrequencyAliasing } from "./sampling.js"; import { describeFrequencyAliasing } from "./sampling.js";
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) { export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
@@ -68,16 +76,28 @@ function addSampledAnalogTonePaths(options) {
microphone, microphone,
paths, paths,
timeMapping = {}, timeMapping = {},
outputSceneSampleOffset,
outputRenderSampleOffset = 0,
} = options; } = options;
const signal = entry.signal; const signal = entry.signal;
const frequencyHz = signal.frequency; const frequencyHz = signal.frequency;
const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate); const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate);
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0; const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
const outputSampleOffset = outputSceneSampleOffset ?? sceneSampleOffset;
const signalStartTime = timeMapping.signalStartTime ?? entry.startTime ?? 0; const signalStartTime = timeMapping.signalStartTime ?? entry.startTime ?? 0;
const signalDuration = timeMapping.signalDuration ?? entry.duration ?? scene.duration; const signalDuration = timeMapping.signalDuration ?? entry.duration ?? scene.duration;
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) { if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
throw new RangeError("sceneSampleOffset must be a non-negative safe integer."); throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
} }
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)) { if (!Number.isFinite(signalStartTime)) {
throw new TypeError("signalStartTime must be finite."); throw new TypeError("signalStartTime must be finite.");
} }
@@ -102,14 +122,14 @@ function addSampledAnalogTonePaths(options) {
const firstSample = Math.max( const firstSample = Math.max(
0, 0,
ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) - ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) -
sceneSampleOffset, outputSampleOffset,
); );
const lastSample = Math.min( const lastSample = Math.min(
output.length, output.length,
ceilSampleIndex( ceilSampleIndex(
signalStartTime + signalDuration + path.delaySeconds, signalStartTime + signalDuration + path.delaySeconds,
scene.sampleRate, scene.sampleRate,
) - sceneSampleOffset, ) - outputSampleOffset,
); );
if (firstSample >= lastSample) continue; if (firstSample >= lastSample) continue;
const amplitude = baseGain * path.gain; const amplitude = baseGain * path.gain;
@@ -132,7 +152,7 @@ function addSampledAnalogTonePaths(options) {
const initialCarrier = carrierAtSample( const initialCarrier = carrierAtSample(
sampling.signedAdcFrequencyHz, sampling.signedAdcFrequencyHz,
scene.sampleRate, scene.sampleRate,
sceneSampleOffset + firstSample, outputSampleOffset + firstSample,
); );
let carrierCosine = initialCarrier.cosine; let carrierCosine = initialCarrier.cosine;
let carrierSine = initialCarrier.sine; let carrierSine = initialCarrier.sine;
@@ -157,7 +177,7 @@ function addSampledAnalogTonePaths(options) {
const carrier = carrierAtSample( const carrier = carrierAtSample(
sampling.signedAdcFrequencyHz, sampling.signedAdcFrequencyHz,
scene.sampleRate, scene.sampleRate,
sceneSampleOffset + sampleIndex, outputSampleOffset + sampleIndex,
); );
carrierCosine = carrier.cosine; carrierCosine = carrier.cosine;
carrierSine = carrier.sine; carrierSine = carrier.sine;
@@ -167,11 +187,13 @@ function addSampledAnalogTonePaths(options) {
carrierSine * sineCoefficient + carrierCosine * cosineCoefficient; carrierSine * sineCoefficient + carrierCosine * cosineCoefficient;
const nextSample = sampleIndex + 1; const nextSample = sampleIndex + 1;
if (nextSample % phaseReanchorInterval === 0) { if (
(outputRenderSampleOffset + nextSample) % phaseReanchorInterval === 0
) {
const carrier = carrierAtSample( const carrier = carrierAtSample(
sampling.signedAdcFrequencyHz, sampling.signedAdcFrequencyHz,
scene.sampleRate, scene.sampleRate,
sceneSampleOffset + nextSample, outputSampleOffset + nextSample,
); );
carrierCosine = carrier.cosine; carrierCosine = carrier.cosine;
carrierSine = carrier.sine; carrierSine = carrier.sine;
@@ -186,16 +208,9 @@ function addSampledAnalogTonePaths(options) {
} }
} }
export function synthesizeMicrophoneSignals(options) { export const DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES = 512 * 1024 * 1024;
const {
scene, function resolveSynthesisDimensions(scene, signalOffsets) {
arrayDefinition,
assetBuffers = new Map(),
referenceDistance = 1,
minimumDistance = 0.01,
maximumBufferBytes = 512 * 1024 * 1024,
signalOffsets,
} = options;
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate); const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0; const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
const cropStartSample = signalOffsets?.cropStartSample ?? 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."); throw new RangeError("signal output crop must be a non-empty range inside the synthesis buffer.");
} }
const cropEndSample = cropStartSample + cropSampleCount; const outputStartSample = sceneSampleOffset + cropStartSample;
const needsOutputCrop = cropStartSample !== 0 || cropSampleCount !== sampleCount; const outputEndSample = outputStartSample + cropSampleCount;
const sourcePathSets = scene.sources.map((definition) => ({ 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, definition,
microphonePaths: arrayDefinition.microphones.map((microphone) => scene,
buildAcousticPaths({ 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, scene,
sourcePosition: definition.position, sourcePosition: definition.position,
receiverPosition: microphone.position, receiverPosition: microphone.position,
@@ -225,136 +550,14 @@ export function synthesizeMicrophoneSignals(options) {
receiverId: microphone.id, receiverId: microphone.id,
referenceDistance, referenceDistance,
minimumDistance, minimumDistance,
}),
),
}));
const acousticPathCounts = {};
let acousticPathCount = 0;
let maximumDelaySamples = 0;
for (const { microphonePaths } of sourcePathSets) {
for (const paths of microphonePaths) {
for (const path of paths) {
acousticPathCount += 1;
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
maximumDelaySamples = Math.max(
maximumDelaySamples,
path.delaySeconds * scene.sampleRate,
);
}
}
}
const sampledSourceCount = scene.sources
.filter((source) => source.signal.type !== "tone")
.length;
const hasSampledAmbient = (scene.ambient ?? [])
.some((ambient) => ambient.signal.type !== "tone");
const retainedBufferCount =
arrayDefinition.microphones.length +
sampledSourceCount +
(hasSampledAmbient ? 1 : 0);
const delayFftSize = nextPowerOfTwo(
sampleCount + ceilSamplePosition(maximumDelaySamples) + 2,
);
const delayWorkingBytes =
sampledSourceCount > 0 || hasSampledAmbient
? delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4
: 0;
const outputCropBytes = needsOutputCrop
? arrayDefinition.microphones.length * cropSampleCount * Float32Array.BYTES_PER_ELEMENT
: 0;
const estimatedBufferBytes =
retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT +
delayWorkingBytes +
outputCropBytes;
if (estimatedBufferBytes > maximumBufferBytes) {
throw new Error(
`예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
);
}
const synthesisChannels = arrayDefinition.microphones.map(
() => new Float32Array(sampleCount),
);
const renderedSources = sourcePathSets.map(({ definition, microphonePaths }, sourceIndex) => ({
definition,
microphonePaths,
timeMapping: {
sceneSampleOffset,
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
},
...(definition.signal.type === "tone"
? {}
: {
samples: renderSignal(definition, scene, assetBuffers, 0, {
sceneSampleOffset,
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
}),
}),
}));
const outputStartSample = sceneSampleOffset + cropStartSample;
const outputEndSample = outputStartSample + cropSampleCount;
const sourceArrivals = renderedSources.map(({ definition, microphonePaths, timeMapping }) => {
const emissionStartTime = timeMapping.signalStartTime ?? definition.startTime ?? 0;
const emissionDuration = timeMapping.signalDuration ?? definition.duration ?? scene.duration;
let earliestArrivalSample = Infinity;
let latestArrivalEndSample = -Infinity;
let overlapsOutput = false;
const overlappingPathKinds = new Set();
for (const paths of microphonePaths) {
for (const path of paths) {
const arrivalStartSample = ceilSampleIndex(
emissionStartTime + path.delaySeconds,
scene.sampleRate,
);
const arrivalEndSample = ceilSampleIndex(
emissionStartTime + emissionDuration + path.delaySeconds,
scene.sampleRate,
);
earliestArrivalSample = Math.min(earliestArrivalSample, arrivalStartSample);
latestArrivalEndSample = Math.max(latestArrivalEndSample, arrivalEndSample);
if (arrivalStartSample < outputEndSample && arrivalEndSample > outputStartSample) {
overlapsOutput = true;
overlappingPathKinds.add(path.kind);
}
}
}
return {
sourceId: definition.id,
emissionStartSample: ceilSampleIndex(emissionStartTime, scene.sampleRate),
emissionEndSample: ceilSampleIndex(
emissionStartTime + emissionDuration,
scene.sampleRate,
),
earliestArrivalSample: Number.isFinite(earliestArrivalSample)
? earliestArrivalSample
: null,
latestArrivalEndSample: Number.isFinite(latestArrivalEndSample)
? latestArrivalEndSample
: null,
overlapsOutput,
overlappingPathKinds: [...overlappingPathKinds].sort(),
};
}); });
observeSourceArrivalPaths(
renderedSources.forEach(({ definition, microphonePaths, samples, timeMapping }) => { arrivalState,
let sourceMaximumDelaySamples = 0; paths,
for (const paths of microphonePaths) { scene.sampleRate,
for (const path of paths) { outputStartSample,
sourceMaximumDelaySamples = Math.max( outputEndSample,
sourceMaximumDelaySamples,
path.delaySeconds * scene.sampleRate,
); );
}
}
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") { if (definition.signal.type === "tone") {
addSampledAnalogTonePaths({ addSampledAnalogTonePaths({
output: synthesisChannels[microphoneIndex], output: synthesisChannels[microphoneIndex],
@@ -363,33 +566,47 @@ export function synthesizeMicrophoneSignals(options) {
microphone, microphone,
paths, paths,
timeMapping, timeMapping,
outputSceneSampleOffset: outputStartSample,
outputRenderSampleOffset: cropStartSample,
}); });
} else { } else {
addFrequencyDomainPaths({ addFrequencyDomainPaths({
output: synthesisChannels[microphoneIndex], output: synthesisChannels[microphoneIndex],
spectrum, spectrum,
paths: paths.map((path) => ({ paths,
delaySamples: path.delaySeconds * scene.sampleRate, pathDelaySampleRate: scene.sampleRate,
gain: path.gain, gain: dbToLinear(microphone.sensitivityDb ?? 0),
})),
gain: sensitivity,
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
frequencyResponse: microphone.frequencyResponse, frequencyResponse: microphone.frequencyResponse,
sampleRate: scene.sampleRate, sampleRate: scene.sampleRate,
workspace, workspace,
outputOffsetSamples: cropStartSample,
}); });
} }
}); });
sourceArrivals.push(finalizeSourceArrival(arrivalState, scene.sampleRate));
}); });
if (Array.isArray(scene.ambient)) { if (Array.isArray(scene.ambient)) {
scene.ambient.forEach((ambient, ambientIndex) => { scene.ambient.forEach((ambient, ambientIndex) => {
synthesisChannels.forEach((channel, microphoneIndex) => {
const microphone = arrayDefinition.microphones[microphoneIndex];
const ambientTimeMapping = { const ambientTimeMapping = {
sceneSampleOffset, sceneSampleOffset,
...(signalOffsets?.ambient?.[ambientIndex] ?? {}), ...(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") { if (ambient.signal.type === "tone") {
addSampledAnalogTonePaths({ addSampledAnalogTonePaths({
output: channel, output: channel,
@@ -398,13 +615,21 @@ export function synthesizeMicrophoneSignals(options) {
microphone, microphone,
paths: [{ delaySeconds: 0, gain: 1 }], paths: [{ delaySeconds: 0, gain: 1 }],
timeMapping: ambientTimeMapping, timeMapping: ambientTimeMapping,
outputSceneSampleOffset: outputStartSample,
outputRenderSampleOffset: cropStartSample,
}); });
return; return;
} }
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d); const seedSalt =
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, { Math.imul(ambientIndex + 1, 0x45d9f3b) ^
...ambientTimeMapping, Math.imul(microphoneIndex + 1, 0x27d4eb2d);
}); const ambientSamples = renderSignal(
ambient,
scene,
assetBuffers,
seedSalt,
ambientTimeMapping,
);
const spectrum = prepareDelaySpectrum(ambientSamples, 0); const spectrum = prepareDelaySpectrum(ambientSamples, 0);
addFrequencyDomainDelay({ addFrequencyDomainDelay({
output: channel, output: channel,
@@ -414,15 +639,14 @@ export function synthesizeMicrophoneSignals(options) {
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0, phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
frequencyResponse: microphone.frequencyResponse, frequencyResponse: microphone.frequencyResponse,
sampleRate: scene.sampleRate, sampleRate: scene.sampleRate,
workspace: createDelayWorkspace(spectrum.fftSize), workspace: ambientWorkspace,
outputOffsetSamples: cropStartSample,
}); });
}); });
}); });
} }
const channels = needsOutputCrop const channels = synthesisChannels;
? synthesisChannels.map((channel) => channel.slice(cropStartSample, cropEndSample))
: synthesisChannels;
let preNormalizationPeak = 0; let preNormalizationPeak = 0;
for (const channel of channels) { for (const channel of channels) {
@@ -451,19 +675,40 @@ export function synthesizeMicrophoneSignals(options) {
preNormalizationPeak, preNormalizationPeak,
appliedScale, appliedScale,
clippedSampleCount, 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", acousticModel: scene.acoustics?.model ?? "free-field",
acousticPathCount, acousticPathCount: acousticPathSummary.pathCount,
acousticPathCounts, acousticPathCounts: { ...acousticPathSummary.pathCounts },
windowing: { windowing: {
model: signalOffsets?.windowingModel ?? "whole-scene", model: signalOffsets?.windowingModel ?? "whole-scene",
renderStartSample: sceneSampleOffset, renderStartSample: sceneSampleOffset,
outputStartSample, outputStartSample,
outputSampleCount: cropSampleCount, outputSampleCount: cropSampleCount,
preRollSamples: cropStartSample, preRollSamples: cropStartSample,
activePathMaximumDelaySeconds: maximumDelaySamples / scene.sampleRate, renderMaximumPropagationDelaySeconds:
signalOffsets?.renderMaximumPropagationDelaySeconds ??
acousticPathSummary.maximumDelaySeconds,
activePathMaximumDelaySeconds: acousticPathSummary.maximumDelaySeconds,
requestedMaximumPropagationDelaySeconds: requestedMaximumPropagationDelaySeconds:
signalOffsets?.maximumPropagationDelaySeconds ?? maximumDelaySamples / scene.sampleRate, signalOffsets?.maximumPropagationDelaySeconds ??
acousticPathSummary.maximumDelaySeconds,
geometryModel: Number.isFinite(signalOffsets?.sourcePositionSampleTime) geometryModel: Number.isFinite(signalOffsets?.sourcePositionSampleTime)
? "source-position-fixed-at-window-midpoint" ? "source-position-fixed-at-window-midpoint"
: "static-source-position", : "static-source-position",
@@ -471,6 +716,8 @@ export function synthesizeMicrophoneSignals(options) {
dopplerMode: "not-modeled", dopplerMode: "not-modeled",
delayedTailPolicy: needsOutputCrop delayedTailPolicy: needsOutputCrop
? "propagation-preroll-then-output-crop" ? "propagation-preroll-then-output-crop"
: signalOffsets?.windowingModel
? "live-request-window-direct-output-with-analytic-path-gating"
: "whole-render-buffer", : "whole-render-buffer",
arrivalOverlapModel: "path-gated-half-open-sample-interval", arrivalOverlapModel: "path-gated-half-open-sample-interval",
arrivingSourceIds: sourceArrivals arrivingSourceIds: sourceArrivals
+25
View File
@@ -15,6 +15,31 @@ function sampleAsset(signal, assetBuffers, time) {
return channel[left] + (rightValue - channel[left]) * fraction; 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. * Render one signal entry into the scene-sized output buffer.
* *
+90 -18
View File
@@ -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 { ceilSampleIndex } from "./math.js";
import { effectiveSignalDurationSeconds } from "./signal.js";
function copyPosition(position) { function copyPosition(position) {
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) { if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
@@ -102,16 +107,20 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
const alignedEndTime = endSample / sampleRate; const alignedEndTime = endSample / sampleRate;
const sampleTime = (startSample + endSample) / (2 * sampleRate); const sampleTime = (startSample + endSample) / (2 * sampleRate);
const sampledScene = sceneAtTime(scene, sampleTime); const sampledScene = sceneAtTime(scene, sampleTime);
let maximumPropagationDelaySeconds = options.maximumPropagationDelaySeconds; const pathSummary = options.arrayDefinition
if (maximumPropagationDelaySeconds === undefined) { ? summarizeAcousticPaths({
maximumPropagationDelaySeconds = options.arrayDefinition
? maximumAcousticDelaySeconds({
scene: sampledScene, scene: sampledScene,
arrayDefinition: options.arrayDefinition, arrayDefinition: options.arrayDefinition,
referenceDistance: options.referenceDistance ?? 1, referenceDistance: options.referenceDistance ?? 1,
minimumDistance: options.minimumDistance ?? 0.01, 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 ( if (
!Number.isFinite(maximumPropagationDelaySeconds) || !Number.isFinite(maximumPropagationDelaySeconds) ||
@@ -119,42 +128,110 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
) { ) {
throw new RangeError("maximumPropagationDelaySeconds must be finite and non-negative."); 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 renderStartSample = Math.max(0, startSample - preRollSamples);
const renderDuration = (endSample - renderStartSample) / sampleRate;
const sources = []; const sources = [];
const sourceOffsets = []; const sourceOffsets = [];
const sourceIndexes = [];
for (let sourceIndex = 0; sourceIndex < scene.sources.length; sourceIndex += 1) { for (let sourceIndex = 0; sourceIndex < scene.sources.length; sourceIndex += 1) {
const source = scene.sources[sourceIndex]; const source = scene.sources[sourceIndex];
const signalStartTime = source.startTime ?? 0; const signalStartTime = source.startTime ?? 0;
const signalDuration = source.duration ?? scene.duration; const signalDuration = effectiveSignalDurationSeconds(
source,
scene,
options.assetBuffers,
);
const signalStartSample = ceilSampleIndex(signalStartTime, sampleRate); const signalStartSample = ceilSampleIndex(signalStartTime, sampleRate);
const signalEndSample = ceilSampleIndex(signalStartTime + signalDuration, sampleRate); const signalEndSample = ceilSampleIndex(signalStartTime + signalDuration, sampleRate);
const activeStartSample = Math.max(renderStartSample, signalStartSample); const activeStartSample = Math.max(renderStartSample, signalStartSample);
const activeEndSample = Math.min(endSample, signalEndSample); 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({ sources.push({
...source, ...source,
position: sampledScene.sources[sourceIndex].position, position: sampledScene.sources[sourceIndex].position,
startTime: (activeStartSample - renderStartSample) / sampleRate, startTime: hasEmissionInRender
duration: (activeEndSample - activeStartSample) / sampleRate, ? (activeStartSample - renderStartSample) / sampleRate
: 0,
duration: hasEmissionInRender
? (activeEndSample - activeStartSample) / sampleRate
: renderDuration,
}); });
sourceOffsets.push({ sourceOffsets.push({
signalStartTime, signalStartTime,
signalStartSample, signalStartSample,
signalDuration, signalDuration,
}); });
sourceIndexes.push(sourceIndex);
} }
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient; 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 = { const signalOffsets = {
sceneSampleOffset: renderStartSample, sceneSampleOffset: renderStartSample,
cropStartSample: startSample - renderStartSample, cropStartSample: startSample - renderStartSample,
cropSampleCount: endSample - startSample, cropSampleCount: endSample - startSample,
maximumPropagationDelaySeconds, maximumPropagationDelaySeconds,
renderMaximumPropagationDelaySeconds,
sourcePositionSampleTime: sampleTime, sourcePositionSampleTime: sampleTime,
windowingModel: "quasi-static-midpoint-propagation-preroll", windowingModel: "quasi-static-midpoint-propagation-preroll",
...(selectedPathSummary
? { acousticPathSummary: selectedPathSummary }
: {}),
sources: sourceOffsets, sources: sourceOffsets,
ambient: (scene.ambient ?? []).map(() => ({ ambient: (scene.ambient ?? []).map(() => ({
signalStartTime: 0, signalStartTime: 0,
@@ -163,7 +240,6 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
})), })),
}; };
const duration = (endSample - startSample) / sampleRate; const duration = (endSample - startSample) / sampleRate;
const renderDuration = (endSample - renderStartSample) / sampleRate;
return { return {
startTime: alignedStartTime, startTime: alignedStartTime,
endTime: alignedEndTime, endTime: alignedEndTime,
@@ -176,13 +252,9 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
renderDuration, renderDuration,
preRollSamples: startSample - renderStartSample, preRollSamples: startSample - renderStartSample,
maximumPropagationDelaySeconds, maximumPropagationDelaySeconds,
renderMaximumPropagationDelaySeconds,
signalOffsets, signalOffsets,
scene: { scene: windowScene,
...scene,
duration: renderDuration,
sources,
...(Array.isArray(scene.ambient) ? { ambient } : {}),
},
}; };
} }
+22 -42
View File
@@ -4,6 +4,10 @@ import {
MAX_PRECISE_ANALOG_FREQUENCY_HZ, MAX_PRECISE_ANALOG_FREQUENCY_HZ,
observeFrequencyBand, observeFrequencyBand,
} from "../acoustics-core/sampling.js"; } 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 INPUT_SCHEMA_VERSION = "1.0.0";
export const RESULT_SCHEMA_VERSION = "1.1.0"; export const RESULT_SCHEMA_VERSION = "1.1.0";
@@ -554,51 +558,27 @@ function validateExperimentRelationships(value, context = {}) {
"gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.", "gcc-phat에는 서로 다른 두 개 이상의 마이크가 필요합니다.",
); );
} }
const sampledSourceCount = context.scene.sources try {
.filter((source) => source.signal?.type !== "tone") const workingSet = estimateSynthesisWorkingSet({
.length; scene: context.scene,
const hasSampledAmbient = (context.scene.ambient ?? []) arrayDefinition: context.arrayDefinition,
.some((ambient) => ambient.signal?.type !== "tone"); minimumDistance: value.beamforming?.minimumDistance ?? 0.01,
const retainedBufferCount = });
context.arrayDefinition.microphones.length + if (
sampledSourceCount + workingSet.estimatedBufferBytes >
(hasSampledAmbient ? 1 : 0); DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES
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) {
addIssue( addIssue(
issues, 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}`,
); );
} }
} }
+533 -1
View File
@@ -3,6 +3,8 @@ import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
import { import {
buildAcousticPaths, buildAcousticPaths,
maximumAcousticDelaySeconds, maximumAcousticDelaySeconds,
selectAcousticPathSummary,
summarizeAcousticPaths,
} from "/packages/acoustics-core/environment.js"; } from "/packages/acoustics-core/environment.js";
import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js"; import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js";
import { import {
@@ -26,7 +28,11 @@ import {
} from "/packages/acoustics-core/localization-algorithms.js"; } from "/packages/acoustics-core/localization-algorithms.js";
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js"; import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js"; import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js"; import {
estimateSynthesisWorkingSet,
sphericalAttenuation,
synthesizeMicrophoneSignals,
} from "/packages/acoustics-core/propagation.js";
import { renderSignal } from "/packages/acoustics-core/signal.js"; import { renderSignal } from "/packages/acoustics-core/signal.js";
import { import {
describeFrequencyAliasing, describeFrequencyAliasing,
@@ -1479,6 +1485,11 @@ test("quasi-static pre-roll preserves reflection and diffuse tone tails", () =>
`${label} geometry metadata`, `${label} geometry metadata`,
); );
assertEqual(partial.windowing.dopplerMode, "not-modeled", `${label} Doppler 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( assertEqual(
partial.windowing.arrivingSourceIds.join(","), partial.windowing.arrivingSourceIds.join(","),
"ended-tone", "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",
);
});
+83
View File
@@ -8,6 +8,10 @@ import {
describeFrequencyAliasing, describeFrequencyAliasing,
observeFrequencyBand, observeFrequencyBand,
} from "/packages/acoustics-core/sampling.js"; } from "/packages/acoustics-core/sampling.js";
import {
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
estimateSynthesisWorkingSet,
} from "/packages/acoustics-core/propagation.js";
async function loadJson(path) { async function loadJson(path) {
const response = await fetch(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 () => { test("NaN 좌표를 거부한다", async () => {
const arrayDefinition = { const arrayDefinition = {
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m", schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m",