perf: stream propagation working sets
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
describeFrequencyAliasing,
|
||||
observeFrequencyBand,
|
||||
} from "/packages/acoustics-core/sampling.js";
|
||||
import {
|
||||
DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||
estimateSynthesisWorkingSet,
|
||||
} from "/packages/acoustics-core/propagation.js";
|
||||
|
||||
async function loadJson(path) {
|
||||
const response = await fetch(path);
|
||||
@@ -481,6 +485,85 @@ test("analytic tone-only Scene은 존재하지 않는 delay FFT 메모리로 거
|
||||
);
|
||||
});
|
||||
|
||||
test("Experiment preflight는 runtime과 같은 source-streaming shoebox delay working set을 쓴다", async () => {
|
||||
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
arrayDefinition.microphones = arrayDefinition.microphones.slice(0, 16);
|
||||
scene.sampleRate = 192_000;
|
||||
scene.duration = 4_000_000 / scene.sampleRate;
|
||||
scene.sources = [{
|
||||
...structuredClone(scene.sources[0]),
|
||||
id: "long-sampled-source",
|
||||
name: "Long sampled source",
|
||||
position: [0, 0, 1],
|
||||
startTime: 0,
|
||||
duration: scene.duration,
|
||||
signal: { type: "noise", color: "white", seedOffset: 1 },
|
||||
}];
|
||||
scene.ambient = [];
|
||||
scene.acoustics = {
|
||||
model: "shoebox",
|
||||
room: { min: [-10, -10, -10], max: [10, 10, 10] },
|
||||
reflections: { order: 0, wallGainDb: -12, scattering: 0 },
|
||||
diffuseReverb: {
|
||||
enabled: true,
|
||||
rt60Seconds: 4,
|
||||
gainDb: -18,
|
||||
tapCount: 1,
|
||||
},
|
||||
occluders: [],
|
||||
};
|
||||
experiment.beamforming.minimumDistance = 0.01;
|
||||
|
||||
const workingSet = estimateSynthesisWorkingSet({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
minimumDistance: experiment.beamforming.minimumDistance,
|
||||
});
|
||||
assertEqual(workingSet.sampleCount, 4_000_000, "fixture sample count");
|
||||
assertEqual(
|
||||
workingSet.maximumTransient.fftSize,
|
||||
8_388_608,
|
||||
"diffuse delay crosses the FFT power-of-two boundary",
|
||||
);
|
||||
assert(
|
||||
workingSet.estimatedBufferBytes > DEFAULT_MAXIMUM_SYNTHESIS_BUFFER_BYTES,
|
||||
"shared runtime estimate exceeds 512 MiB",
|
||||
);
|
||||
const issues = await validateExperiment(experiment, {
|
||||
arrayDefinition,
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
issues.some(({ message }) =>
|
||||
message.includes("source-streaming") && message.includes("512 MiB")),
|
||||
`validator must reject the same shared estimate: ${JSON.stringify(issues)}`,
|
||||
);
|
||||
|
||||
scene.sources[0].signal = { type: "tone", frequency: 1_000, phaseDeg: 0 };
|
||||
const toneWorkingSet = estimateSynthesisWorkingSet({
|
||||
scene,
|
||||
arrayDefinition,
|
||||
minimumDistance: experiment.beamforming.minimumDistance,
|
||||
});
|
||||
assertEqual(
|
||||
toneWorkingSet.estimatedBufferBytes,
|
||||
toneWorkingSet.channelBytes,
|
||||
"analytic tone has no sampled FFT transient",
|
||||
);
|
||||
const toneIssues = await validateExperiment(experiment, {
|
||||
arrayDefinition,
|
||||
scene,
|
||||
});
|
||||
assert(
|
||||
!toneIssues.some(({ message }) => message.includes("512 MiB")),
|
||||
"validator does not charge analytic tone for a nonexistent delay FFT",
|
||||
);
|
||||
});
|
||||
|
||||
test("NaN 좌표를 거부한다", async () => {
|
||||
const arrayDefinition = {
|
||||
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m",
|
||||
|
||||
Reference in New Issue
Block a user