diff --git a/apps/acoustic-camera/simulation-worker.js b/apps/acoustic-camera/simulation-worker.js index cdd594b..e1509c7 100644 --- a/apps/acoustic-camera/simulation-worker.js +++ b/apps/acoustic-camera/simulation-worker.js @@ -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, }, ); diff --git a/docs/acoustic-model.md b/docs/acoustic-model.md index 8e2adad..e8cbe96 100644 --- a/docs/acoustic-model.md +++ b/docs/acoustic-model.md @@ -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을 자동 추론하고 다음 diff --git a/docs/data-contracts.md b/docs/data-contracts.md index e4135a7..d8a8272 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -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은 묵시적으로 변환하지 않고 오류로 알린다. diff --git a/docs/execution-plan.md b/docs/execution-plan.md index 144716a..5090d02 100644 --- a/docs/execution-plan.md +++ b/docs/execution-plan.md @@ -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 입력으로 diff --git a/packages/acoustics-core/README.md b/packages/acoustics-core/README.md index a6e08b1..f94757a 100644 --- a/packages/acoustics-core/README.md +++ b/packages/acoustics-core/README.md @@ -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합니다. diff --git a/packages/acoustics-core/environment.js b/packages/acoustics-core/environment.js index db8fbad..23eef61 100644 --- a/packages/acoustics-core/environment.js +++ b/packages/acoustics-core/environment.js @@ -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 { + 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 maximum; + 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; } diff --git a/packages/acoustics-core/fractional-delay.js b/packages/acoustics-core/fractional-delay.js index 93724d8..8450c14 100644 --- a/packages/acoustics-core/fractional-delay.js +++ b/packages/acoustics-core/fractional-delay.js @@ -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]; } } diff --git a/packages/acoustics-core/propagation.js b/packages/acoustics-core/propagation.js index b09777d..41088b4 100644 --- a/packages/acoustics-core/propagation.js +++ b/packages/acoustics-core/propagation.js @@ -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, + }); + observeSourceArrivalPaths( + arrivalState, + paths, 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, - ); - } - } - 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); + outputStartSample, + outputEndSample, + ); 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) => { + 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]; - const ambientTimeMapping = { - sceneSampleOffset, - ...(signalOffsets?.ambient?.[ambientIndex] ?? {}), - }; 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,7 +716,9 @@ export function synthesizeMicrophoneSignals(options) { dopplerMode: "not-modeled", delayedTailPolicy: needsOutputCrop ? "propagation-preroll-then-output-crop" - : "whole-render-buffer", + : signalOffsets?.windowingModel + ? "live-request-window-direct-output-with-analytic-path-gating" + : "whole-render-buffer", arrivalOverlapModel: "path-gated-half-open-sample-interval", arrivingSourceIds: sourceArrivals .filter(({ overlapsOutput }) => overlapsOutput) diff --git a/packages/acoustics-core/signal.js b/packages/acoustics-core/signal.js index 5ea1f64..a5dbaf8 100644 --- a/packages/acoustics-core/signal.js +++ b/packages/acoustics-core/signal.js @@ -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. * diff --git a/packages/acoustics-core/trajectory.js b/packages/acoustics-core/trajectory.js index fb8481b..3b1f5a9 100644 --- a/packages/acoustics-core/trajectory.js +++ b/packages/acoustics-core/trajectory.js @@ -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); + 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, + }) + : undefined; let maximumPropagationDelaySeconds = options.maximumPropagationDelaySeconds; if (maximumPropagationDelaySeconds === undefined) { - maximumPropagationDelaySeconds = options.arrayDefinition - ? maximumAcousticDelaySeconds({ - scene: sampledScene, - arrayDefinition: options.arrayDefinition, - referenceDistance: options.referenceDistance ?? 1, - minimumDistance: options.minimumDistance ?? 0.01, - }) - : 0; + 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, }; } diff --git a/packages/contracts/validation.js b/packages/contracts/validation.js index 8e465b1..517de17 100644 --- a/packages/contracts/validation.js +++ b/packages/contracts/validation.js @@ -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, - ); - } + 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, + "$", + `source-streaming signal buffer 예상 크기 ${(workingSet.estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`, + ); } - } - 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) { + } catch (error) { addIssue( issues, "$", - `현재 비스트리밍 CPU 모델의 signal buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`, + `signal synthesis preflight failed: ${error.message}`, ); } } diff --git a/tests/unit/acoustics.test.js b/tests/unit/acoustics.test.js index 028c299..879a8d9 100644 --- a/tests/unit/acoustics.test.js +++ b/tests/unit/acoustics.test.js @@ -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", + ); +}); diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js index e0eb906..2bfd9de 100644 --- a/tests/unit/contracts.test.js +++ b/tests/unit/contracts.test.js @@ -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",