fix: expose physical aliasing and delayed arrivals

This commit is contained in:
2026-08-29 10:21:33 +09:00
parent e048ccbadc
commit de0053eae3
38 changed files with 3086 additions and 214 deletions
+577 -2
View File
@@ -11,7 +11,12 @@ import {
createDelayWorkspace,
prepareDelaySpectrum,
} from "/packages/acoustics-core/fractional-delay.js";
import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
import {
ceilSampleIndex,
ceilSamplePosition,
dbToLinear,
distance3,
} from "/packages/acoustics-core/math.js";
import {
DEFAULT_LOCALIZATION_ALGORITHM,
LOCALIZATION_ALGORITHM_IDS,
@@ -23,7 +28,12 @@ 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 { renderSignal } from "/packages/acoustics-core/signal.js";
import { getFrameCount } from "/packages/acoustics-core/stft.js";
import {
describeFrequencyAliasing,
foldFrequencyToNyquist,
observeFrequencyBand,
} from "/packages/acoustics-core/sampling.js";
import { binsForBand, getFrameCount } from "/packages/acoustics-core/stft.js";
import {
createQuasiStaticWindows,
sampleQuasiStaticWindow,
@@ -241,6 +251,217 @@ test("거리, dB와 구면 감쇠 기본식을 계산한다", () => {
assertNear(sphericalAttenuation(2), 0.5, 1e-12, "1/r attenuation");
});
test("sample boundary ceil ignores only IEEE-754 integer roundoff", () => {
assertEqual(ceilSampleIndex(0.1 + 0.2, 1_000), 300, "summed exact boundary");
assertEqual(ceilSamplePosition(300 + 1e-10), 301, "physical sub-sample offset is not snapped");
assertEqual(ceilSampleIndex(0.300_001, 1_000), 301, "physical time offset is ceiled");
});
test("ideal ADC sampling reports signed aliases without hiding Nyquist folding", () => {
const first = describeFrequencyAliasing(5_000, 8_000);
assertEqual(first.adcFrequencyHz, 3_000, "first folded frequency");
assertEqual(first.signedAdcFrequencyHz, -3_000, "first folded signed frequency");
assert(first.aliased && first.spectralInversion, "first fold metadata");
assertEqual(foldFrequencyToNyquist(13_000, 8_000), 3_000, "multiple fold");
assertEqual(foldFrequencyToNyquist(16_000, 8_000), 0, "sample-rate multiple folds to DC");
const dc = describeFrequencyAliasing(8_000, 8_000);
assert(dc.dcEdge && dc.phaseDegenerate, "sample-rate multiple is a phase-degenerate DC fold");
const edge = describeFrequencyAliasing(4_000, 8_000);
assert(
edge.nyquistEdge && edge.phaseDegenerate && !edge.aliased,
"exact Nyquist is retained as a phase-degenerate edge",
);
});
test("above-Nyquist requested bands retain every physically observable ADC bin", () => {
const observation = observeFrequencyBand(
{ minHz: 4_900, maxHz: 5_100 },
8_000,
256,
);
assert(observation.aliased, "band is marked aliased");
assertEqual(observation.bins[0], 93, "folded lower ADC bin");
assertEqual(observation.bins.at(-1), 99, "folded upper ADC bin");
assertEqual(
JSON.stringify(binsForBand({ minHz: 4_900, maxHz: 5_100 }, 8_000, 256)),
JSON.stringify(observation.bins),
"STFT uses the explicit observation mapping",
);
const wide = observeFrequencyBand({ minHz: 0, maxHz: 8_000 }, 8_000, 256);
assertEqual(wide.bins.length, 129, "one sample-rate interval covers every real FFT bin");
assert(wide.continuousManyToOne, "a full sample-rate interval folds continuously many-to-one");
assert(wide.gridManyToOne, "multiple analog FFT-grid bins share ADC bins");
assertEqual(wide.foldSegments.length, 2, "first and second Nyquist zones remain explicit");
assertEqual(wide.foldSegments[0].nyquistZone, 1, "Nyquist zones are one-based");
assertEqual(wide.foldSegments[1].nyquistZone, 2, "second Nyquist zone");
assert(!wide.foldSegments[0].inverted, "first Nyquist zone is not inverted");
assert(wide.foldSegments[1].inverted, "second Nyquist zone is inverted");
for (const segment of wide.foldSegments) {
assertNear(
segment.signedAdcStartHz + segment.aliasOrder * wide.sampleRate,
segment.analogMinHz,
1e-12,
`zone ${segment.nyquistZone} start invariant`,
);
assertNear(
segment.signedAdcEndHz + segment.aliasOrder * wide.sampleRate,
segment.analogMaxHz,
1e-12,
`zone ${segment.nyquistZone} end invariant`,
);
}
const baseband = observeFrequencyBand({ minHz: 2_900, maxHz: 3_100 }, 8_000, 256);
const equivalentAlias = observeFrequencyBand(
{ minHz: 4_900, maxHz: 5_100 },
8_000,
256,
);
assertEqual(
equivalentAlias.adcBinSetKey,
baseband.adcBinSetKey,
"alias-equivalent requested bands disclose the same ADC bin set",
);
const narrowFold = observeFrequencyBand(
{ minHz: 3_999, maxHz: 4_001 },
8_000,
256,
);
assert(
narrowFold.continuousManyToOne,
"a band straddling Nyquist is continuously many-to-one even on a fine FFT grid",
);
assert(!narrowFold.gridManyToOne, "the single selected bin is not grid many-to-one");
assertEqual(narrowFold.bins.join(","), "128", "only the Nyquist bin center is selected");
let segmentLimitError;
try {
observeFrequencyBand({ minHz: 0, maxHz: 1_000_000 }, 1, 256);
} catch (error) {
segmentLimitError = error;
}
assert(
segmentLimitError?.message.includes("explicit limit"),
"portable helper rejects excessive fold-zone enumeration before looping",
);
});
test("real ADC samples and FFT expose temporal aliases without carrier recovery", () => {
const sampleRate = 8_000;
const fftSize = 256;
const phaseDeg = 23;
const scene = { sampleRate, duration: fftSize / sampleRate, seed: 1 };
const entry = {
id: "temporal-alias",
startTime: 0,
duration: scene.duration,
gainDb: 0,
signal: { type: "tone", frequency: 5_000, phaseDeg },
};
const first = renderSignal(entry, scene);
const second = renderSignal(
{ ...entry, signal: { ...entry.signal, frequency: 13_000 } },
scene,
);
const phase = (phaseDeg * Math.PI) / 180;
for (let index = 0; index < 32; index += 1) {
const expected = Math.sin(
(2 * Math.PI * -3_000 * index) / sampleRate + phase,
);
assertNear(first[index], expected, 2e-6, `5 kHz alias sample ${index}`);
assertNear(second[index], expected, 4e-6, `13 kHz alias sample ${index}`);
}
const spectrum = fftReal(first);
let peakBin = 0;
let peakPower = -Infinity;
for (let bin = 0; bin <= fftSize / 2; bin += 1) {
const power = spectrum.real[bin] ** 2 + spectrum.imaginary[bin] ** 2;
if (power > peakPower) {
peakPower = power;
peakBin = bin;
}
}
assertEqual(peakBin, 96, "5 kHz analog tone appears at the 3 kHz ADC bin");
});
test("undersampled microphone spacing preserves a physical grating-lobe ambiguity", async () => {
const sampleRate = 8_000;
const fftSize = 256;
const frequencyHz = 1_000;
const bin = 32;
const speedOfSound = 343;
const wavelength = speedOfSound / frequencyHz;
const microphoneSpacing = 0.5;
assert(
microphoneSpacing > wavelength / 2,
"fixture is spatially undersampled",
);
const arrayDefinition = {
microphones: [
{ id: "left", position: [-microphoneSpacing / 2, 0, 0] },
{ id: "right", position: [microphoneSpacing / 2, 0, 0] },
],
};
const targetX = -1;
const z = 0.5;
const pathDifference = (x) =>
distance3([x, 0, z], arrayDefinition.microphones[1].position) -
distance3([x, 0, z], arrayDefinition.microphones[0].position);
const desiredDifference = pathDifference(targetX) - wavelength;
let lowerX = targetX;
let upperX = 0;
for (let iteration = 0; iteration < 80; iteration += 1) {
const middleX = (lowerX + upperX) / 2;
if (pathDifference(middleX) > desiredDifference) lowerX = middleX;
else upperX = middleX;
}
const ambiguousX = (lowerX + upperX) / 2;
assertNear(
pathDifference(targetX) - pathDifference(ambiguousX),
wavelength,
1e-12,
"candidate path-difference offset is one wavelength",
);
const angularWaveNumber =
(2 * Math.PI * frequencyHz) / speedOfSound;
const spectrum = {
real: new Float32Array(2),
imaginary: new Float32Array(2),
};
arrayDefinition.microphones.forEach((microphone, index) => {
const phase = -angularWaveNumber * distance3([targetX, 0, z], microphone.position);
spectrum.real[index] = Math.cos(phase);
spectrum.imaginary[index] = Math.sin(phase);
});
const scan = {
xMin: targetX,
xMax: ambiguousX,
yMin: 0,
yMax: 0,
z,
columns: 2,
rows: 1,
};
const map = await computeBinMap({
bin,
spectrum,
frameCount: 1,
arrayDefinition,
pointCount: 2,
distances: createDistanceMatrix(arrayDefinition, scan),
sampleRate,
fftSize,
speedOfSound,
compensateFrequencyResponse: false,
algorithm: "das",
});
assertNear(map[0], map[1], 2e-6, "true point and grating lobe have equal DAS power");
assert(map[0] > 0.999, "both ambiguous candidates remain coherent");
});
test("자유음장 경로 계약은 기존 직접음 delay와 1/r gain을 보존한다", () => {
const scene = { speedOfSound: 400, seed: 11 };
const paths = buildAcousticPaths({
@@ -474,6 +695,179 @@ test("구면 전파가 거리별 지연과 1/r 진폭비를 적용한다", () =>
assertNear(rms(result.channels[0], 16, 150) / rms(result.channels[1], 16, 150), 2, 0.02, "distance amplitude ratio");
});
test("above-Nyquist tone keeps analog propagation phase before ADC aliasing", () => {
const sampleRate = 8_000;
const analogFrequencyHz = 5_000;
const speedOfSound = 343;
const differentialDelay = 0.00005;
const scene = {
sampleRate,
duration: 0.01,
speedOfSound,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "aliased-tone",
name: "aliased tone",
position: [0, 0, 0],
gainDb: 0,
startTime: 0,
duration: 0.01,
signal: { type: "tone", frequency: analogFrequencyHz, phaseDeg: 0 },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [
{ id: "reference", position: [0, 0, 0] },
{ id: "delayed", position: [differentialDelay * speedOfSound, 0, 0] },
],
};
const synthesis = synthesizeMicrophoneSignals({
scene,
arrayDefinition,
referenceDistance: 1,
minimumDistance: 1,
});
const sampleIndex = 20;
const sampleTime = sampleIndex / sampleRate;
assertNear(
synthesis.channels[0][sampleIndex],
Math.sin(2 * Math.PI * analogFrequencyHz * sampleTime),
1e-6,
"reference temporal alias sample",
);
assertNear(
synthesis.channels[1][sampleIndex],
Math.sin(2 * Math.PI * analogFrequencyHz * (sampleTime - differentialDelay)),
1e-6,
"delayed sensor uses original analog carrier phase",
);
const wrongAliasPhase = Math.sin(
2 * Math.PI * 3_000 * (sampleTime - differentialDelay),
);
assert(
Math.abs(synthesis.channels[1][sampleIndex] - wrongAliasPhase) > 0.1,
"fractional spatial delay is not rewritten using the positive alias carrier",
);
assertEqual(
synthesis.sampling.toneObservations[0].adcFrequencyHz,
3_000,
"synthesis exposes the recorded ADC alias",
);
});
test("above-Nyquist tone applies microphone response at the analog carrier", () => {
const scene = {
sampleRate: 8_000,
duration: 0.032,
speedOfSound: 343,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "response-tone",
name: "response tone",
position: [0, 0, 0],
gainDb: -20,
startTime: 0,
duration: 0.032,
signal: { type: "tone", frequency: 5_000, phaseDeg: 20 },
}],
ambient: [],
};
const baselineArray = {
microphones: [{ id: "mic", position: [0, 0, 0] }],
};
const responseArray = {
microphones: [{
id: "mic",
position: [0, 0, 0],
frequencyResponse: [
{ frequencyHz: 3_000, gainDb: -6.020599913, phaseDeg: -30 },
{ frequencyHz: 5_000, gainDb: 6.020599913, phaseDeg: 45 },
],
}],
};
const baseline = synthesizeMicrophoneSignals({
scene,
arrayDefinition: baselineArray,
referenceDistance: 1,
minimumDistance: 1,
}).channels[0];
const filtered = synthesizeMicrophoneSignals({
scene,
arrayDefinition: responseArray,
referenceDistance: 1,
minimumDistance: 1,
}).channels[0];
assertNear(
rms(filtered, 16) / rms(baseline, 16),
2,
2e-5,
"5 kHz response gain is used instead of the 3 kHz ADC alias response",
);
});
test("analytic sampler preserves exact aliased DC and Nyquist phase degeneracy", () => {
const renderTone = (frequency, phaseDeg) => {
const scene = {
sampleRate: 8_000,
duration: 8 / 8_000,
speedOfSound: 343,
seed: 1,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: `edge-${frequency}-${phaseDeg}`,
name: "edge tone",
position: [0, 0, 0],
gainDb: 0,
startTime: 0,
duration: 8 / 8_000,
signal: { type: "tone", frequency, phaseDeg },
}],
ambient: [],
};
return synthesizeMicrophoneSignals({
scene,
arrayDefinition: {
microphones: [{ id: "reference", position: [0, 0, 0] }],
},
referenceDistance: 1,
minimumDistance: 1,
});
};
const vanishedDc = renderTone(8_000, 0);
for (const [sampleIndex, value] of vanishedDc.channels[0].entries()) {
assertNear(value, 0, 1e-10, `aliased DC phase-zero sample ${sampleIndex}`);
}
assert(
vanishedDc.sampling.toneObservations[0].dcEdge &&
vanishedDc.sampling.toneObservations[0].phaseDegenerate,
"sample-rate carrier is explicitly a phase-degenerate DC alias",
);
const constantDc = renderTone(8_000, 90);
for (const [sampleIndex, value] of constantDc.channels[0].entries()) {
assertNear(value, 1, 1e-6, `aliased DC quadrature sample ${sampleIndex}`);
}
const nyquist = renderTone(12_000, 90);
for (const [sampleIndex, value] of nyquist.channels[0].entries()) {
assertNear(
value,
sampleIndex % 2 === 0 ? 1 : -1,
1e-6,
`aliased Nyquist alternating sample ${sampleIndex}`,
);
}
assert(
nyquist.sampling.toneObservations[0].nyquistEdge &&
nyquist.sampling.toneObservations[0].phaseDegenerate,
"odd Nyquist-zone edge remains explicitly phase-degenerate",
);
});
test("microphone synthesis가 shoebox sparse path를 실제 채널에 적용한다", () => {
const scene = {
sampleRate: 8000,
@@ -937,3 +1331,184 @@ test("absolute scene windows preserve tone, noise, asset, ambient, and synthesis
assertNear(value, wholeSynthesis.channels[0][window.startSample + sampleIndex], 2e-5, `mic sample ${sampleIndex}`);
});
});
test("quasi-static pre-roll preserves ended noise and asset arrivals before output crop", () => {
const sampleRate = 1_000;
const scene = {
sampleRate,
duration: 1,
speedOfSound: 100,
seed: 73,
mix: { normalization: "none", targetPeak: 0.95 },
sources: [
{
id: "ended-noise",
position: [20, 0, 0],
gainDb: -12,
startTime: 0.1,
duration: 0.2,
signal: { type: "noise", color: "white", seedOffset: 11 },
},
{
id: "ended-asset",
position: [20, 0, 0],
gainDb: -9,
startTime: 0.1,
duration: 0.2,
signal: { type: "asset", assetId: "tail-clip", channel: 0, loop: false },
},
],
ambient: [],
};
const arrayDefinition = {
microphones: [{ id: "mic", position: [0, 0, 0] }],
};
const assetBuffers = new Map([["tail-clip", {
sampleRate,
channels: [Float32Array.from(
{ length: 300 },
(_, index) => 0.45 * Math.sin((2 * Math.PI * index) / 29) + (index % 17) / 80,
)],
}]]);
const whole = synthesizeMicrophoneSignals({
scene,
arrayDefinition,
assetBuffers,
referenceDistance: 1,
minimumDistance: 1,
});
const window = sampleQuasiStaticWindow(scene, 0.4, 0.5, {
arrayDefinition,
referenceDistance: 1,
minimumDistance: 1,
});
assertEqual(window.preRollSamples, 200, "direct-path pre-roll");
assertEqual(
window.scene.sources.map(({ id }) => id).join(","),
"ended-noise,ended-asset",
"sources that stopped before the visible window remain in the render interval",
);
const partial = synthesizeMicrophoneSignals({
scene: window.scene,
arrayDefinition,
assetBuffers,
signalOffsets: window.signalOffsets,
referenceDistance: 1,
minimumDistance: 1,
});
assertEqual(partial.channels[0].length, 100, "only the requested output window is returned");
assertEqual(partial.windowing.preRollSamples, 200, "synthesis records applied pre-roll");
assertEqual(
partial.windowing.delayedTailPolicy,
"propagation-preroll-then-output-crop",
"tail policy metadata",
);
assertEqual(
partial.windowing.arrivingSourceIds.join(","),
"ended-noise,ended-asset",
"arrival-overlap metadata includes both sampled sources",
);
for (const arrival of partial.windowing.sourceArrivals) {
assert(arrival.emissionEndSample < partial.windowing.outputStartSample, `${arrival.sourceId} ended before output`);
assert(arrival.overlapsOutput, `${arrival.sourceId} has a path arrival inside output`);
assertEqual(arrival.earliestArrivalSample, 300, `${arrival.sourceId} earliest direct arrival`);
assertEqual(arrival.latestArrivalEndSample, 500, `${arrival.sourceId} direct tail end`);
}
assert(rms(partial.channels[0]) > 1e-4, "the delayed sampled tail reaches the output window");
partial.channels[0].forEach((value, sampleIndex) => {
assertNear(
value,
whole.channels[0][window.startSample + sampleIndex],
3e-5,
`sampled tail ${sampleIndex}`,
);
});
});
test("quasi-static pre-roll preserves reflection and diffuse tone tails", () => {
const sampleRate = 2_000;
const scene = {
sampleRate,
duration: 1,
speedOfSound: 100,
seed: 91,
mix: { normalization: "none", targetPeak: 0.95 },
acoustics: {
model: "shoebox",
room: { min: [0, 0, 0], max: [10, 10, 10] },
reflections: { order: 1, wallGainDb: -5, scattering: 0.3 },
diffuseReverb: { enabled: true, rt60Seconds: 0.4, gainDb: -8, tapCount: 4 },
occluders: [],
},
sources: [{
id: "ended-tone",
position: [2, 2, 2],
gainDb: -12,
startTime: 0.05,
duration: 0.1,
signal: { type: "tone", frequency: 137, phaseDeg: 31 },
}],
ambient: [],
};
const arrayDefinition = {
microphones: [{ id: "mic", position: [4, 2, 2] }],
};
const synthesisOptions = {
arrayDefinition,
referenceDistance: 1,
minimumDistance: 1,
};
const whole = synthesizeMicrophoneSignals({ scene, ...synthesisOptions });
assert(whole.acousticPathCounts.reflection > 0, "fixture contains image-source reflections");
assert(whole.acousticPathCounts.diffuse > 0, "fixture contains diffuse tail taps");
for (const [startTime, endTime, label] of [
[0.22, 0.27, "reflection interval"],
[0.42, 0.48, "late diffuse interval"],
]) {
const window = sampleQuasiStaticWindow(scene, startTime, endTime, synthesisOptions);
assertEqual(window.scene.sources[0].id, "ended-tone", `${label} retains ended source`);
const partial = synthesizeMicrophoneSignals({
scene: window.scene,
signalOffsets: window.signalOffsets,
...synthesisOptions,
});
assertEqual(
partial.windowing.geometryModel,
"source-position-fixed-at-window-midpoint",
`${label} geometry metadata`,
);
assertEqual(partial.windowing.dopplerMode, "not-modeled", `${label} Doppler metadata`);
assertEqual(
partial.windowing.arrivingSourceIds.join(","),
"ended-tone",
`${label} arrival truth source`,
);
assert(
partial.windowing.sourceArrivals[0].emissionEndSample <
partial.windowing.outputStartSample,
`${label} source emission ended before output`,
);
if (label === "late diffuse interval") {
assertEqual(
partial.windowing.sourceArrivals[0].overlappingPathKinds.join(","),
"diffuse",
"late interval truth is justified by a diffuse arrival",
);
} else {
assert(
partial.windowing.sourceArrivals[0].overlappingPathKinds.includes("reflection"),
"reflection interval truth includes an image-source arrival",
);
}
assert(rms(partial.channels[0]) > 1e-6, `${label} contains propagated energy`);
partial.channels[0].forEach((value, sampleIndex) => {
assertNear(
value,
whole.channels[0][window.startSample + sampleIndex],
2e-6,
`${label} sample ${sampleIndex}`,
);
});
}
});