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
+38 -9
View File
@@ -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합니다.
+267 -6
View File
@@ -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;
}
+25 -5
View File
@@ -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];
}
}
+422 -175
View File
@@ -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)
+25
View File
@@ -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.
*
+93 -21
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 { 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,
};
}
+21 -41
View File
@@ -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}`,
);
}
}