perf: stream propagation working sets
This commit is contained in:
@@ -176,12 +176,16 @@ scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전
|
||||
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
|
||||
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
|
||||
|
||||
시간창은 요청 출력 시작보다 앞선 신호를 현재 array·음향 환경의 최대 경로 지연만큼 pre-roll합니다.
|
||||
따라서 창이 시작되기 전에 발음이 끝난 source라도 direct·reflection·scattering·diffraction·diffuse
|
||||
경로가 창 안에 도착하면 제거하지 않습니다. `signalOffsets`는 원래 scene/source의 절대 sample
|
||||
offset과 출력 crop을 함께 보존합니다. `synthesizeMicrophoneSignals`는 pre-roll 버퍼에 모든 경로를
|
||||
적용한 뒤 요청 구간만 반환하므로 tone phase, seed 기반 noise index, audio asset 재생 위치와
|
||||
ambient가 전체 timeline과 같은 절대 시간축을 사용합니다.
|
||||
시간창은 요청 출력 시작보다 앞선 sampled noise·asset을 해당 source들의 최대 경로 지연만큼
|
||||
pre-roll합니다. analytic tone은 과거 sample buffer 없이 절대시간에서 직접 계산하므로 tone의 더 긴
|
||||
경로는 render buffer를 늘리지 않습니다. 그래도 창이 시작되기 전에 발음이 끝난 tone을
|
||||
direct·reflection·scattering·diffraction·diffuse 도착 구간으로 선별해 제거하지 않습니다.
|
||||
`signalOffsets`는 원래 scene/source의 절대 sample
|
||||
offset과 출력 crop을 함께 보존합니다. analytic tone은 pre-roll 길이의 마이크 channel을 만들지 않고
|
||||
요청 crop에 절대 sample 위상으로 바로 더합니다. sampled noise·asset은 지연에 필요한 pre-roll
|
||||
방사 buffer와 zero-padded FFT는 유지하되 IFFT 결과의 crop 구간만 crop 크기 channel에 더합니다.
|
||||
따라서 tone phase, seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline과 같은
|
||||
절대 시간축을 사용하면서 `마이크 수 × pre-roll` 출력 메모리와 tone sample-add를 만들지 않습니다.
|
||||
|
||||
이 보장은 현재 희소 경로 모델 안에서의 **전파 지연 tail**에 관한 것입니다. quasi-static 창은 pre-roll
|
||||
구간까지 포함해 source를 출력 창 중앙의 한 위치에 고정하며, retarded-time trajectory, 창 안의 연속
|
||||
@@ -190,6 +194,8 @@ sample, `source-position-fixed-at-window-midpoint` geometry와 `dopplerMode: not
|
||||
`sourceArrivals`는 source별 방사·도착 sample 경계와 출력에 겹친 path kind를,
|
||||
`arrivingSourceIds`는 적어도 한 실제 path의 half-open 도착 구간이 출력과 겹친 source를 기록합니다.
|
||||
이는 사후 ground-truth 평가에만 쓰이며 위치 추정 입력에는 전달하지 않습니다.
|
||||
loop하지 않는 asset의 방사 종료는 선언 duration과 decoded channel duration 중 빠른 쪽이며, PCM
|
||||
내용의 leading/trailing zero까지 분석해 truth를 줄이지는 않습니다.
|
||||
sampled asset/noise의 fractional delay와 microphone response는 기존 zero-padded block FFT 모델을
|
||||
그대로 사용하므로 임의 길이 analog microphone impulse response의 streaming state를 재현하는 모델은
|
||||
아닙니다.
|
||||
@@ -247,9 +253,32 @@ Scene의 `acoustics`를 생략하거나 `{ "model": "free-field" }`로 두면
|
||||
센서 self-noise, ADC quantization·clock jitter는 계산하지 않습니다. 그런 효과가 중요한
|
||||
검증에서는 실측 다채널 데이터를 아래 streaming API에 넣어 결과를 비교해야 합니다.
|
||||
|
||||
snapshot 합성은 전체 signal과 가장 늦은 sparse path를 포함하는 delay FFT working set을
|
||||
512 MiB로 제한합니다. 실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로
|
||||
전체 녹음을 메모리에 보관하지 않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
|
||||
합성기는 source별로 경로를 한 receiver 묶음씩 만들고, sampled source도 하나씩
|
||||
`render → spectrum → microphone 적용 → 해제`합니다. render 길이를 `N`, 출력 crop을 `C`,
|
||||
마이크 수를 `M`, sampled source `i`의 자체 최대 경로 지연을 포함한 FFT 길이를 `F_i`라 하면
|
||||
명시적으로 살아 있는 typed-array peak는 다음입니다.
|
||||
|
||||
```text
|
||||
4 M C + max_i(4 N + 32 F_i)
|
||||
```
|
||||
|
||||
`32 F_i`는 Float64 spectrum 두 개와 재사용 workspace 두 개입니다. sampled ambient는
|
||||
zero-delay `F_0 = nextPow2(N + 2)` 후보 하나로 같은 max에 들어가며, analytic tone이나 다른
|
||||
sampled source의 수를 transient byte에 더하지 않습니다. 이 논리적 typed-array working set을
|
||||
기본 512 MiB로 할당 전에 검사하고, Experiment validator도 동일 estimator와 실제
|
||||
reflection·scattering·diffraction·diffuse 지연을 사용합니다. 외부 decoded asset buffer와 JS
|
||||
engine의 객체 heap/RSS까지 512 MiB라고 주장하는 값은 아닙니다.
|
||||
|
||||
라이브 preflight가 만든 경로 count·source별 최대 지연 summary는 geometry, source/microphone id와
|
||||
좌표, acoustics/seed, 거리 설정 전체의 서명이 맞을 때만 합성 preflight에 재사용합니다. summary는
|
||||
불변이고 모듈 내부에서 인증되므로 복제·변조·다른 geometry의 cache는 자동 재계산합니다. 동일
|
||||
Scene·Array 객체에서 band/scan/backend만 바뀐 반복 validation은 이 exact summary를 재사용합니다. 실제
|
||||
전달함수 경로는 receiver별로 한 번 다시 만들며, sampled source의 IFFT는 아직 source와 microphone
|
||||
조합마다 한 번입니다. 다수 non-tone source의 transform batching은 수학 동치와 메모리 예산을 함께
|
||||
검증해야 하는 후속 최적화입니다.
|
||||
|
||||
실측·장시간 입력은 위 streaming localizer의 bounded ring buffer로 전체 녹음을 메모리에 보관하지
|
||||
않고 처리할 수 있습니다. CPU analysis plan도 기본 512 MiB
|
||||
hard cap을 할당 전에 검사합니다. DAS steering cache는 별도의 64 MiB 안에서 가능한 bin만 유지하고,
|
||||
GCC-PHAT는 거리·STFT·correlation workspace 전체를 plan 한도 안에서 검사합니다. Plan 없는 단발
|
||||
GCC 분석도 거리 행렬을 만들기 전에 spectrum·calibration·output을 포함한 working set을 preflight합니다.
|
||||
|
||||
@@ -11,6 +11,11 @@ const WALLS = [
|
||||
];
|
||||
|
||||
const GEOMETRY_EPSILON = 1e-9;
|
||||
export const DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS = 2_000_000;
|
||||
const trustedAcousticPathSummaries = new WeakSet();
|
||||
const acousticPathSummaryCache = new WeakMap();
|
||||
const acousticPathSummaryLru = new Map();
|
||||
const MAXIMUM_ACOUSTIC_PATH_SUMMARY_CACHE_ENTRIES = 8;
|
||||
|
||||
function insideBounds(position, bounds, epsilon = GEOMETRY_EPSILON) {
|
||||
return position.every(
|
||||
@@ -223,10 +228,152 @@ export function buildAcousticPaths(options) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function maximumAcousticDelaySeconds(options) {
|
||||
const { scene, arrayDefinition, referenceDistance, minimumDistance } = options;
|
||||
let maximum = 0;
|
||||
for (const source of scene.sources) {
|
||||
export function maximumAcousticPathsPerReceiver(scene) {
|
||||
const acoustics = scene?.acoustics;
|
||||
if (!acoustics || acoustics.model === "free-field") return 1;
|
||||
let count = 1 + (Array.isArray(acoustics.occluders) ? acoustics.occluders.length : 0);
|
||||
if (
|
||||
acoustics.reflections?.order === 1 &&
|
||||
acoustics.room
|
||||
) {
|
||||
count += 6;
|
||||
if (acoustics.reflections.scattering > 0) count += 6;
|
||||
}
|
||||
if (acoustics.diffuseReverb?.enabled) {
|
||||
count += acoustics.diffuseReverb.tapCount;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function acousticGeometrySignature(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
} = options;
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
speedOfSound: scene.speedOfSound,
|
||||
seed: scene.seed ?? 0,
|
||||
acoustics: scene.acoustics ?? null,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
sources: scene.sources.map((source) => ({
|
||||
id: source.id,
|
||||
position: source.position,
|
||||
})),
|
||||
microphones: arrayDefinition.microphones.map((microphone) => ({
|
||||
id: microphone.id,
|
||||
position: microphone.position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function freezePathCounts(counts) {
|
||||
return Object.freeze({ ...counts });
|
||||
}
|
||||
|
||||
function createTrustedAcousticPathSummary(options) {
|
||||
const {
|
||||
signature,
|
||||
sources,
|
||||
microphoneCount,
|
||||
} = options;
|
||||
const pathCounts = {};
|
||||
let pathCount = 0;
|
||||
let maximumDelaySeconds = 0;
|
||||
for (const source of sources) {
|
||||
pathCount += source.pathCount;
|
||||
maximumDelaySeconds = Math.max(maximumDelaySeconds, source.maximumDelaySeconds);
|
||||
for (const [kind, count] of Object.entries(source.pathCounts)) {
|
||||
pathCounts[kind] = (pathCounts[kind] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
const frozenSources = Object.freeze(sources.map((source) => Object.freeze({
|
||||
sourceId: source.sourceId,
|
||||
pathCount: source.pathCount,
|
||||
pathCounts: freezePathCounts(source.pathCounts),
|
||||
maximumDelaySeconds: source.maximumDelaySeconds,
|
||||
})));
|
||||
const summary = Object.freeze({
|
||||
kind: "micarrsim.acoustic-path-summary/1",
|
||||
signature,
|
||||
sourceCount: frozenSources.length,
|
||||
microphoneCount,
|
||||
sourceReceiverPairCount: frozenSources.length * microphoneCount,
|
||||
pathCount,
|
||||
pathCounts: freezePathCounts(pathCounts),
|
||||
maximumDelaySeconds,
|
||||
sources: frozenSources,
|
||||
});
|
||||
trustedAcousticPathSummaries.add(summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate sparse paths one source/receiver pair at a time and retain only
|
||||
* numeric count/delay metadata. The returned immutable summary is internally
|
||||
* branded so callers cannot forge a stale geometry cache.
|
||||
*/
|
||||
export function summarizeAcousticPaths(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
} = options;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumPathEvaluations) ||
|
||||
maximumPathEvaluations <= 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"maximumPathEvaluations must be a positive safe integer.",
|
||||
);
|
||||
}
|
||||
const maximumPerReceiver = maximumAcousticPathsPerReceiver(scene);
|
||||
const theoreticalPathEvaluations =
|
||||
scene.sources.length * arrayDefinition.microphones.length * maximumPerReceiver;
|
||||
if (
|
||||
!Number.isSafeInteger(theoreticalPathEvaluations) ||
|
||||
theoreticalPathEvaluations > maximumPathEvaluations
|
||||
) {
|
||||
throw new RangeError(
|
||||
`acoustic path evaluation upper bound ${theoreticalPathEvaluations} exceeds ` +
|
||||
`maximumPathEvaluations ${maximumPathEvaluations}.`,
|
||||
);
|
||||
}
|
||||
const signature = acousticGeometrySignature({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
});
|
||||
const lruSummary = acousticPathSummaryLru.get(signature);
|
||||
if (lruSummary) {
|
||||
acousticPathSummaryLru.delete(signature);
|
||||
acousticPathSummaryLru.set(signature, lruSummary);
|
||||
return lruSummary;
|
||||
}
|
||||
let arrayCache = acousticPathSummaryCache.get(scene);
|
||||
if (!arrayCache) {
|
||||
arrayCache = new WeakMap();
|
||||
acousticPathSummaryCache.set(scene, arrayCache);
|
||||
}
|
||||
let parameterCache = arrayCache.get(arrayDefinition);
|
||||
if (!parameterCache) {
|
||||
parameterCache = new Map();
|
||||
arrayCache.set(arrayDefinition, parameterCache);
|
||||
}
|
||||
const parameterKey = `${referenceDistance}\u0000${minimumDistance}`;
|
||||
const cachedSummary = parameterCache.get(parameterKey);
|
||||
if (cachedSummary?.signature === signature) return cachedSummary;
|
||||
|
||||
const sources = scene.sources.map((source) => {
|
||||
const pathCounts = {};
|
||||
let pathCount = 0;
|
||||
let maximumDelaySeconds = 0;
|
||||
for (const microphone of arrayDefinition.microphones) {
|
||||
const paths = buildAcousticPaths({
|
||||
scene,
|
||||
@@ -237,8 +384,122 @@ export function maximumAcousticDelaySeconds(options) {
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
});
|
||||
for (const path of paths) maximum = Math.max(maximum, path.delaySeconds);
|
||||
for (const path of paths) {
|
||||
if (!Number.isFinite(path.delaySeconds) || path.delaySeconds < 0) {
|
||||
throw new RangeError("acoustic path delay must be finite and non-negative.");
|
||||
}
|
||||
pathCount += 1;
|
||||
pathCounts[path.kind] = (pathCounts[path.kind] ?? 0) + 1;
|
||||
maximumDelaySeconds = Math.max(maximumDelaySeconds, path.delaySeconds);
|
||||
}
|
||||
}
|
||||
return {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -32,19 +32,36 @@ export function addFrequencyDomainPaths(options) {
|
||||
frequencyResponse,
|
||||
sampleRate,
|
||||
workspace,
|
||||
outputOffsetSamples = 0,
|
||||
pathDelaySampleRate,
|
||||
} = options;
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
throw new Error("frequency-domain path가 하나 이상 필요합니다.");
|
||||
}
|
||||
const delaySamplesForPath = (path) => {
|
||||
if (path.delaySamples !== undefined) return path.delaySamples;
|
||||
if (
|
||||
path.delaySeconds !== undefined &&
|
||||
pathDelaySampleRate > 0 &&
|
||||
Number.isFinite(pathDelaySampleRate)
|
||||
) {
|
||||
return path.delaySeconds * pathDelaySampleRate;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
for (const path of paths) {
|
||||
if (!(path.delaySamples >= 0) || !Number.isFinite(path.delaySamples)) {
|
||||
throw new Error("유효하지 않은 delay: " + path.delaySamples);
|
||||
const delaySamples = delaySamplesForPath(path);
|
||||
if (!(delaySamples >= 0) || !Number.isFinite(delaySamples)) {
|
||||
throw new Error("유효하지 않은 delay: " + delaySamples);
|
||||
}
|
||||
if (!Number.isFinite(path.gain)) throw new Error("path gain은 유한한 숫자여야 합니다.");
|
||||
}
|
||||
if (workspace.real.length !== spectrum.fftSize || workspace.imaginary.length !== spectrum.fftSize) {
|
||||
throw new Error("fractional-delay workspace 크기가 spectrum과 다릅니다.");
|
||||
}
|
||||
if (!Number.isSafeInteger(outputOffsetSamples) || outputOffsetSamples < 0) {
|
||||
throw new RangeError("outputOffsetSamples must be a non-negative safe integer.");
|
||||
}
|
||||
const hasFrequencyResponse = Array.isArray(frequencyResponse) && frequencyResponse.length > 0;
|
||||
if (hasFrequencyResponse && (!(sampleRate > 0) || !Number.isFinite(sampleRate))) {
|
||||
throw new Error("frequencyResponse 적용에는 유효한 sampleRate가 필요합니다.");
|
||||
@@ -58,7 +75,7 @@ export function addFrequencyDomainPaths(options) {
|
||||
const halfSize = size / 2;
|
||||
const phaseReanchorInterval = 1024;
|
||||
for (const path of paths) {
|
||||
const phaseStep = (-TAU * path.delaySamples) / size;
|
||||
const phaseStep = (-TAU * delaySamplesForPath(path)) / size;
|
||||
const stepReal = Math.cos(phaseStep);
|
||||
const stepImaginary = Math.sin(phaseStep);
|
||||
let phaseReal = 1;
|
||||
@@ -118,9 +135,12 @@ export function addFrequencyDomainPaths(options) {
|
||||
// bin back to the real axis preserves Hermitian symmetry for fractional delays.
|
||||
workspace.imaginary[size / 2] = 0;
|
||||
fftInPlace(workspace.real, workspace.imaginary, true);
|
||||
const outputLength = Math.min(output.length, workspace.real.length);
|
||||
const outputLength = Math.min(
|
||||
output.length,
|
||||
Math.max(0, workspace.real.length - outputOffsetSamples),
|
||||
);
|
||||
for (let sampleIndex = 0; sampleIndex < outputLength; sampleIndex += 1) {
|
||||
output[sampleIndex] += workspace.real[sampleIndex];
|
||||
output[sampleIndex] += workspace.real[outputOffsetSamples + sampleIndex];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
nextPowerOfTwo,
|
||||
prepareDelaySpectrum,
|
||||
} from "./fractional-delay.js";
|
||||
import { buildAcousticPaths } from "./environment.js";
|
||||
import { renderSignal } from "./signal.js";
|
||||
import {
|
||||
buildAcousticPaths,
|
||||
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
isAcousticPathSummaryCompatible,
|
||||
summarizeAcousticPaths,
|
||||
} from "./environment.js";
|
||||
import {
|
||||
effectiveSignalDurationSeconds,
|
||||
renderSignal,
|
||||
} from "./signal.js";
|
||||
import { describeFrequencyAliasing } from "./sampling.js";
|
||||
|
||||
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
|
||||
@@ -68,16 +76,28 @@ function addSampledAnalogTonePaths(options) {
|
||||
microphone,
|
||||
paths,
|
||||
timeMapping = {},
|
||||
outputSceneSampleOffset,
|
||||
outputRenderSampleOffset = 0,
|
||||
} = options;
|
||||
const signal = entry.signal;
|
||||
const frequencyHz = signal.frequency;
|
||||
const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate);
|
||||
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
|
||||
const outputSampleOffset = outputSceneSampleOffset ?? sceneSampleOffset;
|
||||
const signalStartTime = timeMapping.signalStartTime ?? entry.startTime ?? 0;
|
||||
const signalDuration = timeMapping.signalDuration ?? entry.duration ?? scene.duration;
|
||||
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
|
||||
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
|
||||
}
|
||||
if (!Number.isSafeInteger(outputSampleOffset) || outputSampleOffset < 0) {
|
||||
throw new RangeError("outputSceneSampleOffset must be a non-negative safe integer.");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(outputRenderSampleOffset) ||
|
||||
outputRenderSampleOffset < 0
|
||||
) {
|
||||
throw new RangeError("outputRenderSampleOffset must be a non-negative safe integer.");
|
||||
}
|
||||
if (!Number.isFinite(signalStartTime)) {
|
||||
throw new TypeError("signalStartTime must be finite.");
|
||||
}
|
||||
@@ -102,14 +122,14 @@ function addSampledAnalogTonePaths(options) {
|
||||
const firstSample = Math.max(
|
||||
0,
|
||||
ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) -
|
||||
sceneSampleOffset,
|
||||
outputSampleOffset,
|
||||
);
|
||||
const lastSample = Math.min(
|
||||
output.length,
|
||||
ceilSampleIndex(
|
||||
signalStartTime + signalDuration + path.delaySeconds,
|
||||
scene.sampleRate,
|
||||
) - sceneSampleOffset,
|
||||
) - outputSampleOffset,
|
||||
);
|
||||
if (firstSample >= lastSample) continue;
|
||||
const amplitude = baseGain * path.gain;
|
||||
@@ -132,7 +152,7 @@ function addSampledAnalogTonePaths(options) {
|
||||
const initialCarrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + firstSample,
|
||||
outputSampleOffset + firstSample,
|
||||
);
|
||||
let carrierCosine = initialCarrier.cosine;
|
||||
let carrierSine = initialCarrier.sine;
|
||||
@@ -157,7 +177,7 @@ function addSampledAnalogTonePaths(options) {
|
||||
const carrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + sampleIndex,
|
||||
outputSampleOffset + sampleIndex,
|
||||
);
|
||||
carrierCosine = carrier.cosine;
|
||||
carrierSine = carrier.sine;
|
||||
@@ -167,11 +187,13 @@ function addSampledAnalogTonePaths(options) {
|
||||
carrierSine * sineCoefficient + carrierCosine * cosineCoefficient;
|
||||
|
||||
const nextSample = sampleIndex + 1;
|
||||
if (nextSample % phaseReanchorInterval === 0) {
|
||||
if (
|
||||
(outputRenderSampleOffset + nextSample) % phaseReanchorInterval === 0
|
||||
) {
|
||||
const carrier = carrierAtSample(
|
||||
sampling.signedAdcFrequencyHz,
|
||||
scene.sampleRate,
|
||||
sceneSampleOffset + nextSample,
|
||||
outputSampleOffset + nextSample,
|
||||
);
|
||||
carrierCosine = carrier.cosine;
|
||||
carrierSine = carrier.sine;
|
||||
@@ -186,16 +208,9 @@ function addSampledAnalogTonePaths(options) {
|
||||
}
|
||||
}
|
||||
|
||||
export function synthesizeMicrophoneSignals(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers = new Map(),
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
maximumBufferBytes = 512 * 1024 * 1024,
|
||||
signalOffsets,
|
||||
} = options;
|
||||
export const DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
function resolveSynthesisDimensions(scene, signalOffsets) {
|
||||
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
|
||||
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
|
||||
const cropStartSample = signalOffsets?.cropStartSample ?? 0;
|
||||
@@ -212,12 +227,322 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
) {
|
||||
throw new RangeError("signal output crop must be a non-empty range inside the synthesis buffer.");
|
||||
}
|
||||
const cropEndSample = cropStartSample + cropSampleCount;
|
||||
const needsOutputCrop = cropStartSample !== 0 || cropSampleCount !== sampleCount;
|
||||
const sourcePathSets = scene.sources.map((definition) => ({
|
||||
const outputStartSample = sceneSampleOffset + cropStartSample;
|
||||
const outputEndSample = outputStartSample + cropSampleCount;
|
||||
if (
|
||||
!Number.isSafeInteger(outputStartSample) ||
|
||||
!Number.isSafeInteger(outputEndSample)
|
||||
) {
|
||||
throw new RangeError("signal output absolute sample range exceeds the safe integer range.");
|
||||
}
|
||||
return {
|
||||
sampleCount,
|
||||
sceneSampleOffset,
|
||||
cropStartSample,
|
||||
cropSampleCount,
|
||||
outputStartSample,
|
||||
};
|
||||
}
|
||||
|
||||
function sampledSignalTransient(sampleCount, maximumDelaySeconds, sampleRate) {
|
||||
const maximumDelaySamples = ceilSamplePosition(maximumDelaySeconds * sampleRate);
|
||||
const fftSize = nextPowerOfTwo(sampleCount + maximumDelaySamples + 2);
|
||||
const sampleBytes = sampleCount * Float32Array.BYTES_PER_ELEMENT;
|
||||
const spectrumAndWorkspaceBytes =
|
||||
fftSize * Float64Array.BYTES_PER_ELEMENT * 4;
|
||||
return {
|
||||
maximumDelaySamples,
|
||||
fftSize,
|
||||
sampleBytes,
|
||||
spectrumAndWorkspaceBytes,
|
||||
totalBytes: sampleBytes + spectrumAndWorkspaceBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the maximum simultaneously live typed-array storage used by the
|
||||
* source-streaming synthesizer. Paths are represented separately by bounded
|
||||
* short-lived JS objects and are not included in this typed working-set cap.
|
||||
*/
|
||||
export function estimateSynthesisWorkingSet(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
signalOffsets,
|
||||
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
} = options;
|
||||
if (
|
||||
!Number.isSafeInteger(maximumPathEvaluations) ||
|
||||
maximumPathEvaluations <= 0
|
||||
) {
|
||||
throw new RangeError(
|
||||
"maximumPathEvaluations must be a positive safe integer.",
|
||||
);
|
||||
}
|
||||
const dimensions = resolveSynthesisDimensions(scene, signalOffsets);
|
||||
const summaryOptions = {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
};
|
||||
const candidateSummary =
|
||||
options.acousticPathSummary ?? signalOffsets?.acousticPathSummary;
|
||||
const reusedPathSummary = isAcousticPathSummaryCompatible(
|
||||
candidateSummary,
|
||||
summaryOptions,
|
||||
);
|
||||
const acousticPathSummary = reusedPathSummary
|
||||
? candidateSummary
|
||||
: summarizeAcousticPaths({
|
||||
...summaryOptions,
|
||||
maximumPathEvaluations,
|
||||
});
|
||||
if (acousticPathSummary.pathCount > maximumPathEvaluations) {
|
||||
throw new RangeError(
|
||||
`acoustic path count ${acousticPathSummary.pathCount} exceeds ` +
|
||||
`maximumPathEvaluations ${maximumPathEvaluations}.`,
|
||||
);
|
||||
}
|
||||
const activePathMaximumDelaySamples = ceilSamplePosition(
|
||||
acousticPathSummary.maximumDelaySeconds * scene.sampleRate,
|
||||
);
|
||||
|
||||
const channelBytes =
|
||||
arrayDefinition.microphones.length *
|
||||
dimensions.cropSampleCount *
|
||||
Float32Array.BYTES_PER_ELEMENT;
|
||||
let maximumTransientBytes = 0;
|
||||
let maximumTransient = {
|
||||
scope: "none",
|
||||
id: null,
|
||||
maximumDelaySamples: 0,
|
||||
fftSize: 0,
|
||||
sampleBytes: 0,
|
||||
spectrumAndWorkspaceBytes: 0,
|
||||
totalBytes: 0,
|
||||
};
|
||||
const sampledSources = [];
|
||||
scene.sources.forEach((source, sourceIndex) => {
|
||||
if (source.signal.type === "tone") return;
|
||||
const transient = sampledSignalTransient(
|
||||
dimensions.sampleCount,
|
||||
acousticPathSummary.sources[sourceIndex].maximumDelaySeconds,
|
||||
scene.sampleRate,
|
||||
);
|
||||
const entry = { scope: "source", id: source.id, ...transient };
|
||||
sampledSources.push(entry);
|
||||
if (entry.totalBytes > maximumTransientBytes) {
|
||||
maximumTransientBytes = entry.totalBytes;
|
||||
maximumTransient = entry;
|
||||
}
|
||||
});
|
||||
const sampledAmbient = [];
|
||||
for (const ambient of scene.ambient ?? []) {
|
||||
if (ambient.signal.type === "tone") continue;
|
||||
const transient = sampledSignalTransient(
|
||||
dimensions.sampleCount,
|
||||
0,
|
||||
scene.sampleRate,
|
||||
);
|
||||
const entry = { scope: "ambient", id: ambient.id, ...transient };
|
||||
sampledAmbient.push(entry);
|
||||
if (entry.totalBytes > maximumTransientBytes) {
|
||||
maximumTransientBytes = entry.totalBytes;
|
||||
maximumTransient = entry;
|
||||
}
|
||||
}
|
||||
const estimatedBufferBytes = channelBytes + maximumTransientBytes;
|
||||
if (!Number.isSafeInteger(estimatedBufferBytes)) {
|
||||
throw new RangeError("estimated synthesis typed working set exceeds the safe integer range.");
|
||||
}
|
||||
return {
|
||||
...dimensions,
|
||||
estimatedBufferBytes,
|
||||
channelBytes,
|
||||
maximumTransientBytes,
|
||||
maximumTransient,
|
||||
sampledSources,
|
||||
sampledAmbient,
|
||||
acousticPathSummary,
|
||||
activePathMaximumDelaySamples,
|
||||
reusedPathSummary,
|
||||
model: "crop-channels-plus-one-sampled-signal-spectrum-and-workspace",
|
||||
};
|
||||
}
|
||||
|
||||
function assertWorkingSetWithinLimit(estimatedBytes, maximumBufferBytes) {
|
||||
if (
|
||||
!(maximumBufferBytes > 0) ||
|
||||
!Number.isFinite(maximumBufferBytes)
|
||||
) {
|
||||
throw new RangeError("maximumBufferBytes must be finite and positive.");
|
||||
}
|
||||
if (estimatedBytes > maximumBufferBytes) {
|
||||
throw new Error(
|
||||
`예상 signal buffer ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createSourceArrivalState(options) {
|
||||
const {
|
||||
definition,
|
||||
microphonePaths: arrayDefinition.microphones.map((microphone) =>
|
||||
buildAcousticPaths({
|
||||
scene,
|
||||
assetBuffers,
|
||||
timeMapping,
|
||||
} = options;
|
||||
const emissionStartTime =
|
||||
timeMapping.signalStartTime ?? definition.startTime ?? 0;
|
||||
const declaredDuration =
|
||||
timeMapping.signalDuration ?? definition.duration ?? scene.duration;
|
||||
const emissionDuration = effectiveSignalDurationSeconds(
|
||||
{ ...definition, duration: declaredDuration },
|
||||
scene,
|
||||
assetBuffers,
|
||||
);
|
||||
return {
|
||||
sourceId: definition.id,
|
||||
emissionStartTime,
|
||||
emissionDuration,
|
||||
earliestArrivalSample: Infinity,
|
||||
latestArrivalEndSample: -Infinity,
|
||||
overlapsOutput: false,
|
||||
overlappingPathKinds: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function observeSourceArrivalPaths(
|
||||
state,
|
||||
paths,
|
||||
sampleRate,
|
||||
outputStartSample,
|
||||
outputEndSample,
|
||||
) {
|
||||
for (const path of paths) {
|
||||
const arrivalStartSample = ceilSampleIndex(
|
||||
state.emissionStartTime + path.delaySeconds,
|
||||
sampleRate,
|
||||
);
|
||||
const arrivalEndSample = ceilSampleIndex(
|
||||
state.emissionStartTime + state.emissionDuration + path.delaySeconds,
|
||||
sampleRate,
|
||||
);
|
||||
state.earliestArrivalSample = Math.min(
|
||||
state.earliestArrivalSample,
|
||||
arrivalStartSample,
|
||||
);
|
||||
state.latestArrivalEndSample = Math.max(
|
||||
state.latestArrivalEndSample,
|
||||
arrivalEndSample,
|
||||
);
|
||||
if (
|
||||
arrivalStartSample < outputEndSample &&
|
||||
arrivalEndSample > outputStartSample
|
||||
) {
|
||||
state.overlapsOutput = true;
|
||||
state.overlappingPathKinds.add(path.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeSourceArrival(state, sampleRate) {
|
||||
return {
|
||||
sourceId: state.sourceId,
|
||||
emissionStartSample: ceilSampleIndex(state.emissionStartTime, sampleRate),
|
||||
emissionEndSample: ceilSampleIndex(
|
||||
state.emissionStartTime + state.emissionDuration,
|
||||
sampleRate,
|
||||
),
|
||||
earliestArrivalSample: Number.isFinite(state.earliestArrivalSample)
|
||||
? state.earliestArrivalSample
|
||||
: null,
|
||||
latestArrivalEndSample: Number.isFinite(state.latestArrivalEndSample)
|
||||
? state.latestArrivalEndSample
|
||||
: null,
|
||||
overlapsOutput: state.overlapsOutput,
|
||||
overlappingPathKinds: [...state.overlappingPathKinds].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function synthesizeMicrophoneSignals(options) {
|
||||
const {
|
||||
scene,
|
||||
arrayDefinition,
|
||||
assetBuffers = new Map(),
|
||||
referenceDistance = 1,
|
||||
minimumDistance = 0.01,
|
||||
maximumBufferBytes = DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||
maximumPathEvaluations = DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
signalOffsets,
|
||||
} = options;
|
||||
const workingSet = estimateSynthesisWorkingSet({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
signalOffsets,
|
||||
acousticPathSummary: options.acousticPathSummary,
|
||||
maximumPathEvaluations,
|
||||
});
|
||||
assertWorkingSetWithinLimit(
|
||||
workingSet.estimatedBufferBytes,
|
||||
maximumBufferBytes,
|
||||
);
|
||||
const {
|
||||
sampleCount,
|
||||
sceneSampleOffset,
|
||||
cropStartSample,
|
||||
cropSampleCount,
|
||||
outputStartSample,
|
||||
acousticPathSummary,
|
||||
} = workingSet;
|
||||
const outputEndSample = outputStartSample + cropSampleCount;
|
||||
const needsOutputCrop =
|
||||
cropStartSample !== 0 || cropSampleCount !== sampleCount;
|
||||
const synthesisChannels = arrayDefinition.microphones.map(
|
||||
() => new Float32Array(cropSampleCount),
|
||||
);
|
||||
const sourceArrivals = [];
|
||||
|
||||
scene.sources.forEach((definition, sourceIndex) => {
|
||||
const timeMapping = {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||
};
|
||||
const arrivalState = createSourceArrivalState({
|
||||
definition,
|
||||
scene,
|
||||
assetBuffers,
|
||||
timeMapping,
|
||||
});
|
||||
const sourceWorkingSet = workingSet.sampledSources.find(
|
||||
({ id }) => id === definition.id,
|
||||
);
|
||||
if (sourceWorkingSet) {
|
||||
assertWorkingSetWithinLimit(
|
||||
workingSet.channelBytes + sourceWorkingSet.totalBytes,
|
||||
maximumBufferBytes,
|
||||
);
|
||||
}
|
||||
const samples = definition.signal.type === "tone"
|
||||
? undefined
|
||||
: renderSignal(definition, scene, assetBuffers, 0, timeMapping);
|
||||
const spectrum = samples
|
||||
? prepareDelaySpectrum(
|
||||
samples,
|
||||
sourceWorkingSet.maximumDelaySamples,
|
||||
)
|
||||
: undefined;
|
||||
const workspace = spectrum
|
||||
? createDelayWorkspace(spectrum.fftSize)
|
||||
: undefined;
|
||||
|
||||
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
|
||||
const paths = buildAcousticPaths({
|
||||
scene,
|
||||
sourcePosition: definition.position,
|
||||
receiverPosition: microphone.position,
|
||||
@@ -225,136 +550,14 @@ export function synthesizeMicrophoneSignals(options) {
|
||||
receiverId: microphone.id,
|
||||
referenceDistance,
|
||||
minimumDistance,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const acousticPathCounts = {};
|
||||
let acousticPathCount = 0;
|
||||
let maximumDelaySamples = 0;
|
||||
for (const { microphonePaths } of sourcePathSets) {
|
||||
for (const paths of microphonePaths) {
|
||||
for (const path of paths) {
|
||||
acousticPathCount += 1;
|
||||
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
|
||||
maximumDelaySamples = Math.max(
|
||||
maximumDelaySamples,
|
||||
path.delaySeconds * scene.sampleRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sampledSourceCount = scene.sources
|
||||
.filter((source) => source.signal.type !== "tone")
|
||||
.length;
|
||||
const hasSampledAmbient = (scene.ambient ?? [])
|
||||
.some((ambient) => ambient.signal.type !== "tone");
|
||||
const retainedBufferCount =
|
||||
arrayDefinition.microphones.length +
|
||||
sampledSourceCount +
|
||||
(hasSampledAmbient ? 1 : 0);
|
||||
const delayFftSize = nextPowerOfTwo(
|
||||
sampleCount + ceilSamplePosition(maximumDelaySamples) + 2,
|
||||
);
|
||||
const delayWorkingBytes =
|
||||
sampledSourceCount > 0 || hasSampledAmbient
|
||||
? delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4
|
||||
: 0;
|
||||
const outputCropBytes = needsOutputCrop
|
||||
? arrayDefinition.microphones.length * cropSampleCount * Float32Array.BYTES_PER_ELEMENT
|
||||
: 0;
|
||||
const estimatedBufferBytes =
|
||||
retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT +
|
||||
delayWorkingBytes +
|
||||
outputCropBytes;
|
||||
if (estimatedBufferBytes > maximumBufferBytes) {
|
||||
throw new Error(
|
||||
`예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
const synthesisChannels = arrayDefinition.microphones.map(
|
||||
() => new Float32Array(sampleCount),
|
||||
);
|
||||
const renderedSources = sourcePathSets.map(({ definition, microphonePaths }, sourceIndex) => ({
|
||||
definition,
|
||||
microphonePaths,
|
||||
timeMapping: {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||
},
|
||||
...(definition.signal.type === "tone"
|
||||
? {}
|
||||
: {
|
||||
samples: renderSignal(definition, scene, assetBuffers, 0, {
|
||||
sceneSampleOffset,
|
||||
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
const outputStartSample = sceneSampleOffset + cropStartSample;
|
||||
const outputEndSample = outputStartSample + cropSampleCount;
|
||||
const sourceArrivals = renderedSources.map(({ definition, microphonePaths, timeMapping }) => {
|
||||
const emissionStartTime = timeMapping.signalStartTime ?? definition.startTime ?? 0;
|
||||
const emissionDuration = timeMapping.signalDuration ?? definition.duration ?? scene.duration;
|
||||
let earliestArrivalSample = Infinity;
|
||||
let latestArrivalEndSample = -Infinity;
|
||||
let overlapsOutput = false;
|
||||
const overlappingPathKinds = new Set();
|
||||
for (const paths of microphonePaths) {
|
||||
for (const path of paths) {
|
||||
const arrivalStartSample = ceilSampleIndex(
|
||||
emissionStartTime + path.delaySeconds,
|
||||
scene.sampleRate,
|
||||
);
|
||||
const arrivalEndSample = ceilSampleIndex(
|
||||
emissionStartTime + emissionDuration + path.delaySeconds,
|
||||
scene.sampleRate,
|
||||
);
|
||||
earliestArrivalSample = Math.min(earliestArrivalSample, arrivalStartSample);
|
||||
latestArrivalEndSample = Math.max(latestArrivalEndSample, arrivalEndSample);
|
||||
if (arrivalStartSample < outputEndSample && arrivalEndSample > outputStartSample) {
|
||||
overlapsOutput = true;
|
||||
overlappingPathKinds.add(path.kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
sourceId: definition.id,
|
||||
emissionStartSample: ceilSampleIndex(emissionStartTime, scene.sampleRate),
|
||||
emissionEndSample: ceilSampleIndex(
|
||||
emissionStartTime + emissionDuration,
|
||||
});
|
||||
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)
|
||||
|
||||
@@ -15,6 +15,31 @@ function sampleAsset(signal, assetBuffers, time) {
|
||||
return channel[left] + (rightValue - channel[left]) * fraction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the interval in which an entry can emit non-zero samples according to
|
||||
* its declared duration and, for a non-looping asset, the decoded channel
|
||||
* duration. Sample contents themselves are intentionally not inspected.
|
||||
*/
|
||||
export function effectiveSignalDurationSeconds(
|
||||
entry,
|
||||
scene,
|
||||
assetBuffers = new Map(),
|
||||
) {
|
||||
const declaredDuration = entry.duration ?? scene.duration;
|
||||
const signal = entry.signal;
|
||||
if (signal?.type !== "asset" || signal.loop) return declaredDuration;
|
||||
const asset = assetBuffers.get(signal.assetId);
|
||||
const channel = asset?.channels?.[signal.channel ?? 0];
|
||||
if (
|
||||
!channel ||
|
||||
!(asset.sampleRate > 0) ||
|
||||
!Number.isFinite(asset.sampleRate)
|
||||
) {
|
||||
return declaredDuration;
|
||||
}
|
||||
return Math.min(declaredDuration, channel.length / asset.sampleRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one signal entry into the scene-sized output buffer.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { maximumAcousticDelaySeconds } from "./environment.js";
|
||||
import {
|
||||
DEFAULT_MAXIMUM_ACOUSTIC_PATH_EVALUATIONS,
|
||||
selectAcousticPathSummary,
|
||||
summarizeAcousticPaths,
|
||||
} from "./environment.js";
|
||||
import { ceilSampleIndex } from "./math.js";
|
||||
import { effectiveSignalDurationSeconds } from "./signal.js";
|
||||
|
||||
function copyPosition(position) {
|
||||
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
|
||||
@@ -102,16 +107,20 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {})
|
||||
const alignedEndTime = endSample / sampleRate;
|
||||
const sampleTime = (startSample + endSample) / (2 * sampleRate);
|
||||
const sampledScene = sceneAtTime(scene, sampleTime);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user