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
+533 -1
View File
@@ -3,6 +3,8 @@ import { fftInPlace, fftReal } from "/packages/acoustics-core/fft.js";
import {
buildAcousticPaths,
maximumAcousticDelaySeconds,
selectAcousticPathSummary,
summarizeAcousticPaths,
} from "/packages/acoustics-core/environment.js";
import { interpolateFrequencyResponse } from "/packages/acoustics-core/frequency-response.js";
import {
@@ -26,7 +28,11 @@ import {
} from "/packages/acoustics-core/localization-algorithms.js";
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
import {
estimateSynthesisWorkingSet,
sphericalAttenuation,
synthesizeMicrophoneSignals,
} from "/packages/acoustics-core/propagation.js";
import { renderSignal } from "/packages/acoustics-core/signal.js";
import {
describeFrequencyAliasing,
@@ -1479,6 +1485,11 @@ test("quasi-static pre-roll preserves reflection and diffuse tone tails", () =>
`${label} geometry metadata`,
);
assertEqual(partial.windowing.dopplerMode, "not-modeled", `${label} Doppler metadata`);
assertEqual(
partial.windowing.delayedTailPolicy,
"live-request-window-direct-output-with-analytic-path-gating",
`${label} direct live crop policy`,
);
assertEqual(
partial.windowing.arrivingSourceIds.join(","),
"ended-tone",
@@ -1512,3 +1523,524 @@ test("quasi-static pre-roll preserves reflection and diffuse tone tails", () =>
});
}
});
test("interior output crop은 tone·sampled source·ambient의 whole-scene slice와 동치다", () => {
const sampleRate = 1_024;
const scene = {
sampleRate,
duration: 1,
speedOfSound: 343,
seed: 417,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [
{
id: "aliased-tone",
position: [0.5, -0.1, 0.3],
gainDb: -18,
startTime: 0.031,
duration: 0.81,
signal: { type: "tone", frequency: 700, phaseDeg: 23 },
},
{
id: "sampled-noise",
position: [-0.2, 0.4, 0.6],
gainDb: -24,
startTime: 0.017,
duration: 0.9,
signal: { type: "noise", color: "pink", seedOffset: 7 },
},
{
id: "sampled-asset",
position: [0.1, 0.2, 0.7],
gainDb: -12,
startTime: 0.05,
duration: 0.8,
signal: { type: "asset", assetId: "interior", channel: 0, loop: true },
},
],
ambient: [{
id: "sampled-ambient",
gainDb: -30,
signal: { type: "noise", color: "white", seedOffset: 13 },
}],
};
const arrayDefinition = {
microphones: [
{ id: "left", position: [0, 0, 0] },
{
id: "right",
position: [0.23, -0.11, 0],
sensitivityDb: 1.5,
phaseOffsetDeg: -17,
frequencyResponse: [
{ frequencyHz: 0, gainDb: -1, phaseDeg: 5 },
{ frequencyHz: 512, gainDb: 2, phaseDeg: -11 },
],
},
],
};
const assetBuffers = new Map([["interior", {
sampleRate: 800,
channels: [Float32Array.from(
{ length: 173 },
(_, index) => Math.sin((2 * Math.PI * index) / 31) * 0.6,
)],
}]]);
const whole = synthesizeMicrophoneSignals({
scene,
arrayDefinition,
assetBuffers,
});
const cropStartSample = 173;
const cropSampleCount = 211;
const cropped = synthesizeMicrophoneSignals({
scene,
arrayDefinition,
assetBuffers,
signalOffsets: {
sceneSampleOffset: 0,
cropStartSample,
cropSampleCount,
},
});
assertEqual(cropped.channels[0].length, cropSampleCount, "crop-sized microphone output");
assertEqual(
cropped.workingSet.channelBytes,
arrayDefinition.microphones.length * cropSampleCount * 4,
"only crop-sized channels are retained",
);
cropped.channels.forEach((channel, microphoneIndex) => {
channel.forEach((value, sampleIndex) => {
assertNear(
value,
whole.channels[microphoneIndex][cropStartSample + sampleIndex],
4e-5,
`interior crop mic ${microphoneIndex} sample ${sampleIndex}`,
);
});
});
});
test("source-streaming working set은 source 수가 아니라 한 sampled source의 Dmax로 결정된다", () => {
const sampleRate = 1_000;
const sampledSources = Array.from({ length: 20 }, (_, index) => ({
id: `noise-${index}`,
position: [1, 0, 0],
gainDb: -40,
startTime: 0,
duration: 0.062,
signal: { type: "noise", color: "white", seedOffset: index },
}));
const scene = {
sampleRate,
duration: 0.062,
speedOfSound: 1_000,
seed: 9,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [
{
id: "far-analytic-tone",
position: [100, 0, 0],
gainDb: -60,
startTime: 0,
duration: 0.062,
signal: { type: "tone", frequency: 137, phaseDeg: 0 },
},
...sampledSources,
],
ambient: [
{ id: "ambient-a", gainDb: -60, signal: { type: "noise", color: "white" } },
{ id: "ambient-b", gainDb: -60, signal: { type: "noise", color: "pink" } },
],
};
const arrayDefinition = {
microphones: [
{ id: "mic-a", position: [0, 0, 0] },
{ id: "mic-b", position: [0, 0, 0] },
],
};
const signalOffsets = {
sceneSampleOffset: 0,
cropStartSample: 10,
cropSampleCount: 7,
};
const estimate = estimateSynthesisWorkingSet({
scene,
arrayDefinition,
signalOffsets,
referenceDistance: 1,
minimumDistance: 1,
});
assertEqual(estimate.channelBytes, 56, "two seven-sample Float32 channels");
assertEqual(estimate.maximumTransient.maximumDelaySamples, 1, "sampled source Dmax");
assertEqual(estimate.maximumTransient.fftSize, 128, "source-specific delay FFT size");
assertEqual(estimate.maximumTransientBytes, 4_344, "one samples+spectrum+workspace");
assertEqual(estimate.estimatedBufferBytes, 4_400, "exact peak typed working set");
assertEqual(
estimate.acousticPathSummary.maximumDelaySeconds,
0.1,
"far analytic tone remains in physical path metadata",
);
const synthesis = synthesizeMicrophoneSignals({
scene,
arrayDefinition,
signalOffsets,
referenceDistance: 1,
minimumDistance: 1,
maximumBufferBytes: 4_400,
});
assertEqual(synthesis.estimatedBufferBytes, 4_400, "runtime uses the shared estimate");
assertEqual(synthesis.channels[0].length, 7, "runtime cap succeeds at equality");
let error;
try {
synthesizeMicrophoneSignals({
scene,
arrayDefinition,
signalOffsets,
referenceDistance: 1,
minimumDistance: 1,
maximumBufferBytes: 4_399,
});
} catch (caught) {
error = caught;
}
assert(error?.message.includes("한도"), "one byte below exact peak is rejected");
});
test("far analytic tone은 live sampled source·ambient render pre-roll과 FFT를 키우지 않는다", () => {
const sampleRate = 1_000;
const scene = {
sampleRate,
duration: 6,
speedOfSound: 100,
seed: 77,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [
{
id: "far-tone",
position: [500, 0, 0],
gainDb: -30,
startTime: 0,
duration: 6,
signal: { type: "tone", frequency: 137, phaseDeg: 19 },
},
{
id: "near-noise",
position: [1, 0, 0],
gainDb: -24,
startTime: 0,
duration: 6,
signal: { type: "noise", color: "white", seedOffset: 3 },
},
],
ambient: [{
id: "ambient",
gainDb: -36,
signal: { type: "noise", color: "pink", seedOffset: 5 },
}],
};
const arrayDefinition = {
microphones: [{ id: "mic", position: [0, 0, 0] }],
};
const whole = synthesizeMicrophoneSignals({
scene,
arrayDefinition,
referenceDistance: 1,
minimumDistance: 1,
});
const window = sampleQuasiStaticWindow(scene, 5.5, 5.6, {
arrayDefinition,
referenceDistance: 1,
minimumDistance: 1,
});
assertNear(window.maximumPropagationDelaySeconds, 5, 1e-12, "physical global Dmax");
assertNear(
window.renderMaximumPropagationDelaySeconds,
0.01,
1e-12,
"sampled render Dmax excludes analytic tone",
);
assertEqual(window.preRollSamples, 10, "only sampled propagation needs pre-roll samples");
assertEqual(window.scene.duration * sampleRate, 110, "render buffer is crop plus sampled Dmax");
const partial = synthesizeMicrophoneSignals({
scene: window.scene,
arrayDefinition,
signalOffsets: window.signalOffsets,
referenceDistance: 1,
minimumDistance: 1,
});
assertEqual(partial.workingSet.maximumTransientId, "near-noise", "near sampled source sets peak");
assertEqual(partial.workingSet.maximumTransientBytes, 110 * 4 + 128 * 32, "small source FFT");
assertNear(
partial.windowing.renderMaximumPropagationDelaySeconds,
0.01,
1e-12,
"synthesis records actual sampled pre-roll Dmax",
);
partial.channels[0].forEach((value, sampleIndex) => {
assertNear(
value,
whole.channels[0][window.startSample + sampleIndex],
4e-5,
`far-tone live crop sample ${sampleIndex}`,
);
});
});
test("live path summary는 source subset에 맞춰 불변 재사용되고 stale geometry에는 재사용되지 않는다", () => {
const scene = {
sampleRate: 2_000,
duration: 1,
speedOfSound: 100,
seed: 31,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "moving-noise",
position: [2, 0, 0],
gainDb: -24,
startTime: 0,
duration: 1,
signal: { type: "noise", color: "white" },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [{ id: "mic", position: [0, 0, 0] }],
};
const window = sampleQuasiStaticWindow(scene, 0.3, 0.4, {
arrayDefinition,
});
const summary = window.signalOffsets.acousticPathSummary;
assert(Object.isFrozen(summary), "path summary is immutable");
assert(Object.isFrozen(summary.sources), "source summaries are immutable");
const synthesis = synthesizeMicrophoneSignals({
scene: window.scene,
arrayDefinition,
signalOffsets: window.signalOffsets,
});
assertEqual(
synthesis.workingSet.pathSummary,
"reused-authenticated-geometry-summary",
"live synthesis reuses its authenticated summary",
);
assertEqual(
synthesis.workingSet.summaryPathPairEvaluations,
0,
"synthesis does not repeat its preflight summary enumeration",
);
assertEqual(
synthesis.workingSet.synthesisPathBuildPairEvaluations,
1,
"each selected source/receiver path set is built once for synthesis",
);
const movedArray = structuredClone(arrayDefinition);
movedArray.microphones[0].position[0] = 0.5;
const stale = estimateSynthesisWorkingSet({
scene: window.scene,
arrayDefinition: movedArray,
signalOffsets: window.signalOffsets,
});
assert(!stale.reusedPathSummary, "changed receiver geometry invalidates the summary");
const originalSummary = summarizeAcousticPaths({ scene, arrayDefinition });
assertEqual(
summarizeAcousticPaths({ scene, arrayDefinition }),
originalSummary,
"unchanged geometry reuses the internally cached exact summary",
);
assertEqual(
summarizeAcousticPaths({
scene: structuredClone(scene),
arrayDefinition: structuredClone(arrayDefinition),
}),
originalSummary,
"bounded signature cache also reuses validated structured clones",
);
const movedScene = structuredClone(scene);
movedScene.sources[0].position[0] = 8;
let staleRetargetError;
try {
selectAcousticPathSummary({
summary: originalSummary,
sourceIndexes: [0],
sourceScene: scene,
scene: movedScene,
arrayDefinition,
});
} catch (error) {
staleRetargetError = error;
}
assert(
staleRetargetError?.message.includes("selected scene path geometry"),
"selector cannot rebrand stale numeric delays for new geometry",
);
let insufficientDelayError;
try {
sampleQuasiStaticWindow(scene, 0.3, 0.4, {
arrayDefinition,
maximumPropagationDelaySeconds: 0.001,
});
} catch (error) {
insufficientDelayError = error;
}
assert(
insufficientDelayError?.message.includes("shorter than"),
"undersized explicit pre-roll is rejected",
);
});
test("non-loop asset arrival truth는 decoded channel 종료 시각에서 끝난다", () => {
const sampleRate = 1_000;
const scene = {
sampleRate,
duration: 1,
speedOfSound: 100,
seed: 5,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "short-asset",
position: [10, 0, 0],
gainDb: -12,
startTime: 0,
duration: 0.5,
signal: { type: "asset", assetId: "short", channel: 0, loop: false },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [{ id: "mic", position: [0, 0, 0] }],
};
const assetBuffers = new Map([["short", {
sampleRate,
channels: [Float32Array.from(
{ length: 50 },
(_, index) => Math.sin((2 * Math.PI * index) / 11),
)],
}]]);
const whole = synthesizeMicrophoneSignals({
scene,
arrayDefinition,
assetBuffers,
referenceDistance: 1,
minimumDistance: 1,
});
const arrival = whole.windowing.sourceArrivals[0];
assertEqual(arrival.emissionEndSample, 50, "decoded channel limits emission");
assertEqual(arrival.earliestArrivalSample, 100, "direct arrival start");
assertEqual(arrival.latestArrivalEndSample, 150, "decoded tail arrival end");
const arrivingWindow = sampleQuasiStaticWindow(scene, 0.12, 0.14, {
arrayDefinition,
assetBuffers,
referenceDistance: 1,
minimumDistance: 1,
});
const arriving = synthesizeMicrophoneSignals({
scene: arrivingWindow.scene,
arrayDefinition,
assetBuffers,
signalOffsets: arrivingWindow.signalOffsets,
referenceDistance: 1,
minimumDistance: 1,
});
assertEqual(
arriving.windowing.arrivingSourceIds.join(","),
"short-asset",
"decoded tail overlaps the early output",
);
const silentWindow = sampleQuasiStaticWindow(scene, 0.3, 0.4, {
arrayDefinition,
assetBuffers,
referenceDistance: 1,
minimumDistance: 1,
});
assertEqual(silentWindow.scene.sources.length, 0, "expired decoded asset is not retained");
});
test("거대 acoustic delay와 path evaluation budget은 typed channel 할당 전에 거부된다", () => {
const scene = {
sampleRate: 8_000,
duration: 0.01,
speedOfSound: 343,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "astronomical",
position: [Number.MAX_VALUE / 4, 0, 0],
gainDb: -30,
startTime: 0,
duration: 0.01,
signal: { type: "tone", frequency: 500, phaseDeg: 0 },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [
{ id: "mic-a", position: [0, 0, 0] },
{ id: "mic-b", position: [0, 0, 0] },
],
};
let delayError;
try {
synthesizeMicrophoneSignals({ scene, arrayDefinition });
} catch (error) {
delayError = error;
}
assert(
delayError?.message.includes("sample position"),
"unsafe path delay fails during preflight",
);
const boundedScene = structuredClone(scene);
boundedScene.sources[0].position = [1, 0, 0];
let budgetError;
try {
synthesizeMicrophoneSignals({
scene: boundedScene,
arrayDefinition,
maximumPathEvaluations: 1,
});
} catch (error) {
budgetError = error;
}
assert(
budgetError?.message.includes("maximumPathEvaluations"),
"path evaluation cap fails before path objects accumulate",
);
let invalidBudgetError;
try {
estimateSynthesisWorkingSet({
scene: boundedScene,
arrayDefinition,
maximumPathEvaluations: 1.5,
});
} catch (error) {
invalidBudgetError = error;
}
assert(
invalidBudgetError?.message.includes("positive safe integer"),
"path evaluation budget itself is validated",
);
let absoluteRangeError;
try {
estimateSynthesisWorkingSet({
scene: boundedScene,
arrayDefinition,
signalOffsets: {
sceneSampleOffset: Number.MAX_SAFE_INTEGER,
cropStartSample: 1,
cropSampleCount: 1,
},
});
} catch (error) {
absoluteRangeError = error;
}
assert(
absoluteRangeError?.message.includes("absolute sample range"),
"absolute output sample addition is checked before synthesis",
);
});