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
+36 -3
View File
@@ -8,6 +8,25 @@
- radix-2 FFT, Hann STFT와 선택 bin 추출
- scan grid 거리 cache 및 DAS·DAS-PHAT·GCC-PHAT 위치 추정
## Nyquist 초과 입력
`sampling.js`는 anti-alias filter 없는 이상적 real sampler의 signed alias와 band-to-FFT-bin
mapping을 제공한다. tone 전파는 먼저 원 analog 주파수에서 path delay와 microphone response를
적용하고 그 뒤 ADC 시각에서 sampling한다. 따라서 시간축에는 alias가 나타나지만 센서 간 위상은
원 파장의 물리를 유지한다.
Nyquist 위 band는 모든 등가 ADC FFT bin center로 접어 관찰한다. 연속 fold segment와 이산
bin-center membership은 별도 metadata다. DAS 계열은 양의 ADC bin 주파수로 steering하고
GCC-PHAT는 선택 bin의 lag correlation을 사용하며, 원 carrier를 몰래 추론하거나 peak를
고르지 않는다. 단일 channel 시간축에서 같은 sequence로 접혀도 다채널 공간 위상에는 원 파장의
증거가 남을 수 있다. 현재 localizer는 carrier hypothesis를 열거하지 않으므로 Result에
`aliasOrderResolution: not-attempted`로 남긴다. asset/noise 입력은 이미 sampling된 baseband로
취급한다.
Tone과 band의 검증 범위는 0–1 MHz다. 이는 Nyquist cutoff가 아니라 현재 IEEE-754 위상 계산,
마이크 응답과 희소 경로 모델의 명시적 정밀도 범위다. public sampling helper는 비정상적으로
작은 sample rate에서도 최대 513개 fold segment를 넘기 전에 거부한다.
## 위치 추정 알고리즘
`localization-algorithms.js`의 registry와 `localization.js`의 generic API가 다음 세 경로를
@@ -157,9 +176,23 @@ scan MAC을 줄여도 작은 grid에서는 forward STFT와 pair별 IFFT가 전
경로 범위 밖에서는 가장 가까운 끝점 위치를 유지합니다. `sampleQuasiStaticWindow`는 요청 경계를
sample 경계로 올림 정렬하고, 시간창 중앙의 위치에서 이동 음원을 정지한 것으로 근사합니다.
시간창은 활성 source만 잘라 짧은 Scene으로 만들지만 `signalOffsets`에 원래 scene/source의 절대 sample
offset을 보존합니다. 이를 `synthesizeMicrophoneSignals`에 전달하면 각 창을 따로 합성해도 tone phase,
seed 기반 noise index, audio asset 재생 위치와 ambient가 전체 timeline 합성과 연속됩니다.
시간창은 요청 출력 시작보다 앞선 신호를 현재 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과 같은 절대 시간축을 사용합니다.
이 보장은 현재 희소 경로 모델 안에서의 **전파 지연 tail**에 관한 것입니다. quasi-static 창은 pre-roll
구간까지 포함해 source를 출력 창 중앙의 한 위치에 고정하며, retarded-time trajectory, 창 안의 연속
이동과 Doppler는 계산하지 않습니다. 합성 결과의 `windowing` metadata는 적용한 pre-roll, 출력 절대
sample, `source-position-fixed-at-window-midpoint` geometry와 `dopplerMode: not-modeled`를 기록합니다.
`sourceArrivals`는 source별 방사·도착 sample 경계와 출력에 겹친 path kind를,
`arrivingSourceIds`는 적어도 한 실제 path의 half-open 도착 구간이 출력과 겹친 source를 기록합니다.
이는 사후 ground-truth 평가에만 쓰이며 위치 추정 입력에는 전달하지 않습니다.
sampled asset/noise의 fractional delay와 microphone response는 기존 zero-padded block FFT 모델을
그대로 사용하므로 임의 길이 analog microphone impulse response의 streaming state를 재현하는 모델은
아닙니다.
## Beam spread 의미
+29
View File
@@ -1,5 +1,34 @@
export const TAU = Math.PI * 2;
/**
* Ceil a sample position while keeping an analytically integral boundary on
* that integer. IEEE-754 roundoff from expressions such as (0.1 + 0.2) * 1000
* must not add a physically nonexistent sample of latency.
*/
export function ceilSamplePosition(samplePosition) {
if (!Number.isFinite(samplePosition)) {
throw new TypeError("sample position must be finite.");
}
const nearestInteger = Math.round(samplePosition);
const tolerance =
16 * Number.EPSILON * Math.max(1, Math.abs(samplePosition));
const result = Math.abs(samplePosition - nearestInteger) <= tolerance
? nearestInteger
: Math.ceil(samplePosition);
if (!Number.isSafeInteger(result)) {
throw new RangeError("sample position exceeds the safe integer range.");
}
return result;
}
export function ceilSampleIndex(timeSeconds, sampleRate) {
if (!Number.isFinite(timeSeconds)) throw new TypeError("time must be finite.");
if (!(sampleRate > 0) || !Number.isFinite(sampleRate)) {
throw new TypeError("sampleRate must be finite and positive.");
}
return ceilSamplePosition(timeSeconds * sampleRate);
}
export function distance3(a, b) {
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
+396 -56
View File
@@ -1,4 +1,11 @@
import { dbToLinear, distance3 } from "./math.js";
import { interpolateFrequencyResponse } from "./frequency-response.js";
import {
ceilSampleIndex,
ceilSamplePosition,
dbToLinear,
distance3,
TAU,
} from "./math.js";
import {
addFrequencyDomainDelay,
addFrequencyDomainPaths,
@@ -6,8 +13,9 @@ import {
nextPowerOfTwo,
prepareDelaySpectrum,
} from "./fractional-delay.js";
import { buildAcousticPaths, maximumAcousticDelaySeconds } from "./environment.js";
import { buildAcousticPaths } from "./environment.js";
import { renderSignal } from "./signal.js";
import { describeFrequencyAliasing } from "./sampling.js";
export function propagationDelaySeconds(sourcePosition, receiverPosition, speedOfSound) {
return distance3(sourcePosition, receiverPosition) / speedOfSound;
@@ -17,6 +25,167 @@ export function sphericalAttenuation(distance, referenceDistance = 1, minimumDis
return referenceDistance / Math.max(distance, minimumDistance);
}
function addToneEvent(
events,
sampleIndex,
sineDelta,
cosineDelta,
activePathDelta,
) {
const event = events.get(sampleIndex);
if (event) {
event.sine += sineDelta;
event.cosine += cosineDelta;
event.activePaths += activePathDelta;
} else {
events.set(sampleIndex, {
sine: sineDelta,
cosine: cosineDelta,
activePaths: activePathDelta,
});
}
}
function carrierAtSample(signedFrequencyHz, sampleRate, absoluteSampleIndex) {
const cycles = (signedFrequencyHz * absoluteSampleIndex) / sampleRate;
const fractionalCycles = cycles - Math.floor(cycles);
const phase = TAU * fractionalCycles;
return { cosine: Math.cos(phase), sine: Math.sin(phase) };
}
/**
* Apply propagation and microphone response to an analog tone before sampling.
*
* This order is essential above Nyquist: temporal samples fold to the ADC
* alias, while inter-microphone phase still comes from the original acoustic
* frequency and path delay.
*/
function addSampledAnalogTonePaths(options) {
const {
output,
entry,
scene,
microphone,
paths,
timeMapping = {},
} = options;
const signal = entry.signal;
const frequencyHz = signal.frequency;
const sampling = describeFrequencyAliasing(frequencyHz, scene.sampleRate);
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
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.isFinite(signalStartTime)) {
throw new TypeError("signalStartTime must be finite.");
}
if (!Number.isFinite(signalDuration) || signalDuration <= 0) {
throw new RangeError("signalDuration must be a positive finite number.");
}
const sourcePhase = ((signal.phaseDeg ?? 0) * Math.PI) / 180;
const response = interpolateFrequencyResponse(
microphone.frequencyResponse,
frequencyHz,
{},
);
const microphonePhase =
((microphone.phaseOffsetDeg ?? 0) * Math.PI) / 180 + response.phaseRadians;
const baseGain =
dbToLinear(entry.gainDb ?? 0) *
dbToLinear(microphone.sensitivityDb ?? 0) *
response.linearGain;
const events = new Map();
for (const path of paths) {
const firstSample = Math.max(
0,
ceilSampleIndex(signalStartTime + path.delaySeconds, scene.sampleRate) -
sceneSampleOffset,
);
const lastSample = Math.min(
output.length,
ceilSampleIndex(
signalStartTime + signalDuration + path.delaySeconds,
scene.sampleRate,
) - sceneSampleOffset,
);
if (firstSample >= lastSample) continue;
const amplitude = baseGain * path.gain;
const delayedCycles = (frequencyHz * (signalStartTime + path.delaySeconds)) % 1;
const phase = sourcePhase + microphonePhase - TAU * delayedCycles;
const sineCoefficient = amplitude * Math.cos(phase);
const cosineCoefficient = amplitude * Math.sin(phase);
addToneEvent(events, firstSample, sineCoefficient, cosineCoefficient, 1);
addToneEvent(events, lastSample, -sineCoefficient, -cosineCoefficient, -1);
}
if (events.size === 0) return;
const orderedEvents = [...events.entries()].sort((left, right) => left[0] - right[0]);
const phaseStep =
(TAU * sampling.signedAdcFrequencyHz) / scene.sampleRate;
const stepReal = Math.cos(phaseStep);
const stepImaginary = Math.sin(phaseStep);
const firstSample = orderedEvents[0][0];
const finalEventSample = orderedEvents.at(-1)[0];
const initialCarrier = carrierAtSample(
sampling.signedAdcFrequencyHz,
scene.sampleRate,
sceneSampleOffset + firstSample,
);
let carrierCosine = initialCarrier.cosine;
let carrierSine = initialCarrier.sine;
let sineCoefficient = 0;
let cosineCoefficient = 0;
let activePathCount = 0;
let eventIndex = 0;
const phaseReanchorInterval = 1024;
let sampleIndex = firstSample;
while (sampleIndex < output.length && sampleIndex < finalEventSample) {
while (
eventIndex < orderedEvents.length &&
orderedEvents[eventIndex][0] === sampleIndex
) {
sineCoefficient += orderedEvents[eventIndex][1].sine;
cosineCoefficient += orderedEvents[eventIndex][1].cosine;
activePathCount += orderedEvents[eventIndex][1].activePaths;
eventIndex += 1;
}
if (activePathCount === 0 && eventIndex < orderedEvents.length) {
sampleIndex = orderedEvents[eventIndex][0];
const carrier = carrierAtSample(
sampling.signedAdcFrequencyHz,
scene.sampleRate,
sceneSampleOffset + sampleIndex,
);
carrierCosine = carrier.cosine;
carrierSine = carrier.sine;
continue;
}
output[sampleIndex] +=
carrierSine * sineCoefficient + carrierCosine * cosineCoefficient;
const nextSample = sampleIndex + 1;
if (nextSample % phaseReanchorInterval === 0) {
const carrier = carrierAtSample(
sampling.signedAdcFrequencyHz,
scene.sampleRate,
sceneSampleOffset + nextSample,
);
carrierCosine = carrier.cosine;
carrierSine = carrier.sine;
} else {
const nextCosine =
carrierCosine * stepReal - carrierSine * stepImaginary;
carrierSine =
carrierSine * stepReal + carrierCosine * stepImaginary;
carrierCosine = nextCosine;
}
sampleIndex = nextSample;
}
}
export function synthesizeMicrophoneSignals(options) {
const {
scene,
@@ -27,39 +196,27 @@ export function synthesizeMicrophoneSignals(options) {
maximumBufferBytes = 512 * 1024 * 1024,
signalOffsets,
} = options;
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const retainedBufferCount = arrayDefinition.microphones.length + scene.sources.length + 1;
const maximumDelaySamples =
maximumAcousticDelaySeconds({
scene,
arrayDefinition,
referenceDistance,
minimumDistance,
}) * scene.sampleRate;
const delayFftSize = nextPowerOfTwo(sampleCount + Math.ceil(maximumDelaySamples) + 2);
const delayWorkingBytes = delayFftSize * Float64Array.BYTES_PER_ELEMENT * 4;
const estimatedBufferBytes =
retainedBufferCount * sampleCount * Float32Array.BYTES_PER_ELEMENT + delayWorkingBytes;
if (estimatedBufferBytes > maximumBufferBytes) {
throw new Error(
`예상 signal buffer ${(estimatedBufferBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
`현재 한도 ${(maximumBufferBytes / 1024 ** 2).toFixed(1)} MiB를 넘습니다. duration 또는 sample rate를 줄이세요.`,
);
}
const channels = arrayDefinition.microphones.map(() => new Float32Array(sampleCount));
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
const sceneSampleOffset = signalOffsets?.sceneSampleOffset ?? 0;
const renderedSources = scene.sources.map((source, sourceIndex) => ({
definition: source,
samples: renderSignal(source, scene, assetBuffers, 0, {
sceneSampleOffset,
...(signalOffsets?.sources?.[sourceIndex] ?? {}),
}),
}));
const acousticPathCounts = {};
let acousticPathCount = 0;
renderedSources.forEach(({ definition, samples }) => {
const microphonePaths = arrayDefinition.microphones.map((microphone) =>
const cropStartSample = signalOffsets?.cropStartSample ?? 0;
const cropSampleCount = signalOffsets?.cropSampleCount ?? sampleCount;
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
}
if (
!Number.isSafeInteger(cropStartSample) ||
cropStartSample < 0 ||
!Number.isSafeInteger(cropSampleCount) ||
cropSampleCount <= 0 ||
cropStartSample + cropSampleCount > sampleCount
) {
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) => ({
definition,
microphonePaths: arrayDefinition.microphones.map((microphone) =>
buildAcousticPaths({
scene,
sourcePosition: definition.position,
@@ -69,7 +226,117 @@ export function synthesizeMicrophoneSignals(options) {
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,
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) {
@@ -79,39 +346,64 @@ export function synthesizeMicrophoneSignals(options) {
);
}
}
const spectrum = prepareDelaySpectrum(samples, sourceMaximumDelaySamples);
const workspace = createDelayWorkspace(spectrum.fftSize);
const spectrum = samples
? prepareDelaySpectrum(samples, sourceMaximumDelaySamples)
: undefined;
const workspace = spectrum
? createDelayWorkspace(spectrum.fftSize)
: undefined;
arrayDefinition.microphones.forEach((microphone, microphoneIndex) => {
const paths = microphonePaths[microphoneIndex];
for (const path of paths) {
acousticPathCount += 1;
acousticPathCounts[path.kind] = (acousticPathCounts[path.kind] ?? 0) + 1;
}
const sensitivity = dbToLinear(microphone.sensitivityDb ?? 0);
addFrequencyDomainPaths({
output: channels[microphoneIndex],
spectrum,
paths: paths.map((path) => ({
delaySamples: path.delaySeconds * scene.sampleRate,
gain: path.gain,
})),
gain: sensitivity,
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
frequencyResponse: microphone.frequencyResponse,
sampleRate: scene.sampleRate,
workspace,
});
if (definition.signal.type === "tone") {
addSampledAnalogTonePaths({
output: synthesisChannels[microphoneIndex],
entry: definition,
scene,
microphone,
paths,
timeMapping,
});
} else {
addFrequencyDomainPaths({
output: synthesisChannels[microphoneIndex],
spectrum,
paths: paths.map((path) => ({
delaySamples: path.delaySeconds * scene.sampleRate,
gain: path.gain,
})),
gain: sensitivity,
phaseOffsetDeg: microphone.phaseOffsetDeg ?? 0,
frequencyResponse: microphone.frequencyResponse,
sampleRate: scene.sampleRate,
workspace,
});
}
});
});
if (Array.isArray(scene.ambient)) {
scene.ambient.forEach((ambient, ambientIndex) => {
channels.forEach((channel, microphoneIndex) => {
synthesisChannels.forEach((channel, microphoneIndex) => {
const microphone = arrayDefinition.microphones[microphoneIndex];
const seedSalt = Math.imul(ambientIndex + 1, 0x45d9f3b) ^ Math.imul(microphoneIndex + 1, 0x27d4eb2d);
const ambientSamples = renderSignal(ambient, scene, assetBuffers, seedSalt, {
const ambientTimeMapping = {
sceneSampleOffset,
...(signalOffsets?.ambient?.[ambientIndex] ?? {}),
};
if (ambient.signal.type === "tone") {
addSampledAnalogTonePaths({
output: channel,
entry: ambient,
scene,
microphone,
paths: [{ delaySeconds: 0, gain: 1 }],
timeMapping: ambientTimeMapping,
});
return;
}
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({
@@ -128,6 +420,10 @@ export function synthesizeMicrophoneSignals(options) {
});
}
const channels = needsOutputCrop
? synthesisChannels.map((channel) => channel.slice(cropStartSample, cropEndSample))
: synthesisChannels;
let preNormalizationPeak = 0;
for (const channel of channels) {
for (const value of channel) {
@@ -159,5 +455,49 @@ export function synthesizeMicrophoneSignals(options) {
acousticModel: scene.acoustics?.model ?? "free-field",
acousticPathCount,
acousticPathCounts,
windowing: {
model: signalOffsets?.windowingModel ?? "whole-scene",
renderStartSample: sceneSampleOffset,
outputStartSample,
outputSampleCount: cropSampleCount,
preRollSamples: cropStartSample,
activePathMaximumDelaySeconds: maximumDelaySamples / scene.sampleRate,
requestedMaximumPropagationDelaySeconds:
signalOffsets?.maximumPropagationDelaySeconds ?? maximumDelaySamples / scene.sampleRate,
geometryModel: Number.isFinite(signalOffsets?.sourcePositionSampleTime)
? "source-position-fixed-at-window-midpoint"
: "static-source-position",
sourcePositionSampleTime: signalOffsets?.sourcePositionSampleTime ?? null,
dopplerMode: "not-modeled",
delayedTailPolicy: needsOutputCrop
? "propagation-preroll-then-output-crop"
: "whole-render-buffer",
arrivalOverlapModel: "path-gated-half-open-sample-interval",
arrivingSourceIds: sourceArrivals
.filter(({ overlapsOutput }) => overlapsOutput)
.map(({ sourceId }) => sourceId),
sourceArrivals,
},
sampling: {
model: "ideal-real-sampler-no-antialias-filter",
sampleRate: scene.sampleRate,
nyquistHz: scene.sampleRate / 2,
toneObservations: [
...scene.sources
.filter((source) => source.signal.type === "tone")
.map((source) => ({
scope: "source",
id: source.id,
...describeFrequencyAliasing(source.signal.frequency, scene.sampleRate),
})),
...(scene.ambient ?? [])
.filter((ambient) => ambient.signal.type === "tone")
.map((ambient) => ({
scope: "ambient",
id: ambient.id,
...describeFrequencyAliasing(ambient.signal.frequency, scene.sampleRate),
})),
],
},
};
}
@@ -152,20 +152,18 @@ function copyScan(scan) {
return Object.freeze({ ...scan, columns, rows });
}
function copyBands(bands, sampleRate) {
function copyBands(bands) {
if (!Array.isArray(bands) || bands.length === 0) {
throw new TypeError("bands must be a non-empty array.");
}
const nyquist = sampleRate / 2;
return Object.freeze(bands.map((band, index) => {
if (
!Number.isFinite(band?.minHz) ||
!Number.isFinite(band?.maxHz) ||
band.minHz < 0 ||
band.maxHz <= band.minHz ||
band.maxHz >= nyquist
band.maxHz <= band.minHz
) {
throw new RangeError(`bands[${index}] must lie inside 0–${nyquist} Hz.`);
throw new RangeError(`bands[${index}] must satisfy 0 <= minHz < maxHz.`);
}
return Object.freeze({
...band,
@@ -285,7 +283,7 @@ export function createLocalizationPlan(config) {
const arrayDefinition = copyArrayDefinition(config.array ?? config.arrayDefinition);
const scan = copyScan(config.scan);
const peakDetection = copyPeakDetection(config.peakDetection, scan);
const frequencyBands = copyBands(config.bands ?? config.frequencyBands, sampleRate);
const frequencyBands = copyBands(config.bands ?? config.frequencyBands);
const fftSize = requirePowerOfTwo(config.stft?.fftSize ?? config.fftSize ?? 512, "fftSize");
const hopSize = requirePositiveInteger(
config.stft?.hopSize ?? config.stftHopSize ?? fftSize / 2,
+250
View File
@@ -0,0 +1,250 @@
export const MAX_PRECISE_ANALOG_FREQUENCY_HZ = 1_000_000;
export const MAX_EXPLICIT_FOLD_SEGMENTS = 513;
function requireSampleRate(sampleRate) {
if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
throw new RangeError("sampleRate must be a positive finite number.");
}
return sampleRate;
}
function requireFrequency(frequencyHz, label = "frequencyHz") {
if (
!Number.isFinite(frequencyHz) ||
frequencyHz < 0 ||
frequencyHz > MAX_PRECISE_ANALOG_FREQUENCY_HZ
) {
throw new RangeError(
`${label} must be between 0 and ${MAX_PRECISE_ANALOG_FREQUENCY_HZ} Hz.`,
);
}
return frequencyHz;
}
function positiveModulo(value, modulus) {
const remainder = value % modulus;
return remainder < 0 ? remainder + modulus : remainder;
}
function normalizedZero(value) {
return Object.is(value, -0) ? 0 : value;
}
/**
* Describe the frequency that an ideal real-valued sampler records.
*
* No anti-alias filter is implied. A negative signed ADC frequency means that
* the positive-frequency spectrum is mirrored when represented on 0..Nyquist.
*/
export function describeFrequencyAliasing(frequencyHz, sampleRate) {
requireFrequency(frequencyHz);
requireSampleRate(sampleRate);
const nyquistHz = sampleRate / 2;
const wrappedHz = positiveModulo(frequencyHz, sampleRate);
const signedAdcFrequencyHz =
wrappedHz <= nyquistHz ? wrappedHz : wrappedHz - sampleRate;
const adcFrequencyHz = Math.abs(signedAdcFrequencyHz);
const dcEdge = adcFrequencyHz === 0;
const nyquistEdge = adcFrequencyHz === nyquistHz;
return {
model: "ideal-real-sampler-no-antialias-filter",
sampleRate,
nyquistHz,
analogFrequencyHz: frequencyHz,
adcFrequencyHz: normalizedZero(adcFrequencyHz),
signedAdcFrequencyHz: normalizedZero(signedAdcFrequencyHz),
aliased: frequencyHz > nyquistHz,
spectralInversion: signedAdcFrequencyHz < 0,
dcEdge,
nyquistEdge,
phaseDegenerate: dcEdge || nyquistEdge,
};
}
export function foldFrequencyToNyquist(frequencyHz, sampleRate) {
return describeFrequencyAliasing(frequencyHz, sampleRate).adcFrequencyHz;
}
function containsResidue(first, last, residue, modulus) {
const firstResidue = positiveModulo(first, modulus);
const offset = positiveModulo(residue - firstResidue, modulus);
return first + offset <= last;
}
function contiguousBinRanges(bins, binWidthHz) {
const ranges = [];
let firstBin = bins[0];
let lastBin = firstBin;
for (let index = 1; index <= bins.length; index += 1) {
const bin = bins[index];
if (bin === lastBin + 1) {
lastBin = bin;
continue;
}
ranges.push({
firstBin,
lastBin,
firstBinCenterHz: firstBin * binWidthHz,
lastBinCenterHz: lastBin * binWidthHz,
});
firstBin = bin;
lastBin = bin;
}
return ranges;
}
function foldBandSegments(minHz, maxHz, sampleRate) {
const nyquistHz = sampleRate / 2;
const firstZoneIndex = Math.floor(minHz / nyquistHz);
const lastZoneIndex = Math.ceil(maxHz / nyquistHz) - 1;
const segmentCount = lastZoneIndex - firstZoneIndex + 1;
if (
!Number.isSafeInteger(firstZoneIndex) ||
!Number.isSafeInteger(lastZoneIndex) ||
!Number.isSafeInteger(segmentCount) ||
segmentCount < 1 ||
segmentCount > MAX_EXPLICIT_FOLD_SEGMENTS
) {
throw new RangeError(
`band crosses ${segmentCount} Nyquist zones; the explicit limit is ` +
`${MAX_EXPLICIT_FOLD_SEGMENTS}.`,
);
}
const segments = [];
let analogStartHz = minHz;
while (analogStartHz < maxHz) {
const zoneIndex = Math.floor(analogStartHz / nyquistHz);
const nyquistZone = zoneIndex + 1;
const nextBoundaryHz = nyquistZone * nyquistHz;
const analogEndHz = Math.min(maxHz, nextBoundaryHz);
if (!(analogEndHz > analogStartHz)) {
throw new RangeError("band is too large to retain Nyquist-zone precision.");
}
const inverted = nyquistZone % 2 === 0;
const signedAdcStartHz = inverted
? analogStartHz - nyquistZone * nyquistHz
: analogStartHz - (nyquistZone - 1) * nyquistHz;
const signedAdcEndHz = inverted
? analogEndHz - nyquistZone * nyquistHz
: analogEndHz - (nyquistZone - 1) * nyquistHz;
segments.push({
analogMinHz: analogStartHz,
analogMaxHz: analogEndHz,
signedAdcStartHz: normalizedZero(signedAdcStartHz),
signedAdcEndHz: normalizedZero(signedAdcEndHz),
inverted,
aliasOrder: Math.floor(nyquistZone / 2),
nyquistZone,
});
analogStartHz = analogEndHz;
}
return segments;
}
function hasContinuousFoldOverlap(segments) {
const adcRanges = segments.map((segment) => ({
minHz: Math.min(
Math.abs(segment.signedAdcStartHz),
Math.abs(segment.signedAdcEndHz),
),
maxHz: Math.max(
Math.abs(segment.signedAdcStartHz),
Math.abs(segment.signedAdcEndHz),
),
})).sort((left, right) => left.minHz - right.minHz || left.maxHz - right.maxHz);
let maximumEndHz = -Infinity;
for (const range of adcRanges) {
if (range.minHz < maximumEndHz) return true;
maximumEndHz = Math.max(maximumEndHz, range.maxHz);
}
return false;
}
function adcBinSetKey(sampleRate, fftSize, ranges) {
const rangeKey = ranges
.map(({ firstBin, lastBin }) =>
firstBin === lastBin ? String(firstBin) : `${firstBin}-${lastBin}`)
.join(",");
return `fs:${sampleRate}|fft:${fftSize}|bins:${rangeKey}`;
}
/**
* Map a requested pre-sampling frequency interval to the observable one-sided
* real FFT bins. Every matching bin is retained; this function does not choose
* an alias order or steer with the original carrier.
*/
export function observeFrequencyBand(band, sampleRate, fftSize) {
requireSampleRate(sampleRate);
if (!Number.isInteger(fftSize) || fftSize < 2 || fftSize % 2 !== 0) {
throw new RangeError("fftSize must be a positive even integer.");
}
if (!band || typeof band !== "object") {
throw new TypeError("band must be an object.");
}
requireFrequency(band.minHz, "band.minHz");
requireFrequency(band.maxHz, "band.maxHz");
if (band.minHz >= band.maxHz) {
throw new RangeError("band.minHz must be lower than band.maxHz.");
}
const binWidthHz = sampleRate / fftSize;
const firstAnalogBin = Math.ceil(band.minHz / binWidthHz);
const lastAnalogBin = Math.floor(band.maxHz / binWidthHz);
if (
!Number.isSafeInteger(firstAnalogBin) ||
!Number.isSafeInteger(lastAnalogBin)
) {
throw new RangeError("band frequency is too large for safe FFT-bin mapping.");
}
if (firstAnalogBin > lastAnalogBin) {
throw new Error(
`${band.minHz}–${band.maxHz} Hz contains no FFT-grid frequency at ${binWidthHz} Hz spacing.`,
);
}
const bins = [];
const halfSize = fftSize / 2;
for (let adcBin = 0; adcBin <= halfSize; adcBin += 1) {
const positiveResidue = adcBin;
const negativeResidue = adcBin === 0 || adcBin === halfSize
? adcBin
: fftSize - adcBin;
if (
containsResidue(firstAnalogBin, lastAnalogBin, positiveResidue, fftSize) ||
containsResidue(firstAnalogBin, lastAnalogBin, negativeResidue, fftSize)
) {
bins.push(adcBin);
}
}
if (bins.length === 0) {
throw new Error(`${band.minHz}–${band.maxHz} Hz maps to no observable FFT bin.`);
}
const nyquistHz = sampleRate / 2;
const adcBinCenterRanges = contiguousBinRanges(bins, binWidthHz);
const foldSegments = foldBandSegments(band.minHz, band.maxHz, sampleRate);
const analogBinCount = lastAnalogBin - firstAnalogBin + 1;
const continuousManyToOne = hasContinuousFoldOverlap(foldSegments);
const gridManyToOne = analogBinCount > bins.length;
return {
model: "ideal-real-sampler-no-antialias-filter",
sampleRate,
nyquistHz,
fftSize,
binWidthHz,
requestedMinHz: band.minHz,
requestedMaxHz: band.maxHz,
aliased: band.maxHz > nyquistHz,
crossesFirstNyquist: band.minHz < nyquistHz && band.maxHz > nyquistHz,
localizationInputModel: "observed-one-sided-real-fft-bins",
selectionModel: "fft-bin-center-membership",
originalCarrierUsed: false,
foldBoundaryCount: Math.max(0, foldSegments.length - 1),
continuousManyToOne,
gridManyToOne,
foldSegments,
bins,
adcBinCenterRanges,
adcBinSetKey: adcBinSetKey(sampleRate, fftSize, adcBinCenterRanges),
};
}
+9 -5
View File
@@ -1,4 +1,4 @@
import { dbToLinear, TAU } from "./math.js";
import { ceilSampleIndex, dbToLinear, TAU } from "./math.js";
import { hashString, pinkNoiseAt, whiteNoiseAt } from "./noise.js";
function sampleAsset(signal, assetBuffers, time) {
@@ -25,16 +25,20 @@ function sampleAsset(signal, assetBuffers, time) {
* Omitting it keeps the historical whole-scene behaviour exactly unchanged.
*/
export function renderSignal(entry, scene, assetBuffers = new Map(), seedSalt = 0, timeMapping = {}) {
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
const sampleCount = ceilSampleIndex(scene.duration, scene.sampleRate);
const output = new Float32Array(sampleCount);
const startTime = entry.startTime ?? 0;
const duration = entry.duration ?? scene.duration;
const gain = dbToLinear(entry.gainDb ?? 0);
const startSample = Math.max(0, Math.ceil(startTime * scene.sampleRate));
const endSample = Math.min(sampleCount, Math.ceil((startTime + duration) * scene.sampleRate));
const startSample = Math.max(0, ceilSampleIndex(startTime, scene.sampleRate));
const endSample = Math.min(
sampleCount,
ceilSampleIndex(startTime + duration, scene.sampleRate),
);
const sceneSampleOffset = timeMapping.sceneSampleOffset ?? 0;
const signalStartTime = timeMapping.signalStartTime ?? startTime;
const signalStartSample = timeMapping.signalStartSample ?? Math.ceil(startTime * scene.sampleRate);
const signalStartSample =
timeMapping.signalStartSample ?? ceilSampleIndex(startTime, scene.sampleRate);
if (!Number.isSafeInteger(sceneSampleOffset) || sceneSampleOffset < 0) {
throw new RangeError("sceneSampleOffset must be a non-negative safe integer.");
}
+2 -7
View File
@@ -1,5 +1,6 @@
import { fftInPlace } from "./fft.js";
import { cooperativeYield } from "./cooperative-yield.js";
import { observeFrequencyBand } from "./sampling.js";
function throwIfAborted(signal) {
if (signal?.aborted) {
@@ -119,11 +120,5 @@ export async function extractStftBins(channels, options) {
}
export function binsForBand(band, sampleRate, fftSize) {
const binWidth = sampleRate / fftSize;
const firstBin = Math.max(0, Math.ceil(band.minHz / binWidth));
const lastBin = Math.min(fftSize / 2, Math.floor(band.maxHz / binWidth));
if (firstBin > lastBin) {
throw new Error(`${band.minHz}–${band.maxHz} Hz에 해당하는 FFT bin이 없습니다.`);
}
return Array.from({ length: lastBin - firstBin + 1 }, (_, index) => firstBin + index);
return observeFrequencyBand(band, sampleRate, fftSize).bins;
}
+56 -14
View File
@@ -1,3 +1,6 @@
import { maximumAcousticDelaySeconds } from "./environment.js";
import { ceilSampleIndex } from "./math.js";
function copyPosition(position) {
if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) {
throw new TypeError("source position must be a finite [x, y, z] array.");
@@ -65,7 +68,7 @@ export function sceneAtTime(scene, time) {
* Approximate a moving scene as stationary during [startTime, endTime] by
* sampling every trajectory at the window midpoint.
*/
export function sampleQuasiStaticWindow(scene, startTime, endTime) {
export function sampleQuasiStaticWindow(scene, startTime, endTime, options = {}) {
requireFiniteTime(startTime, "startTime");
requireFiniteTime(endTime, "endTime");
if (startTime < 0 || endTime <= startTime) {
@@ -90,42 +93,77 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
// boundary. Adjacent windows therefore share an exact boundary without a
// duplicated or omitted sample, even when their requested times are not
// integer multiples of the sample period.
const startSample = Math.ceil(startTime * sampleRate);
const endSample = Math.ceil(endTime * sampleRate);
const startSample = ceilSampleIndex(startTime, sampleRate);
const endSample = ceilSampleIndex(endTime, sampleRate);
if (endSample <= startSample) {
throw new RangeError("window must contain at least one sample.");
}
const alignedStartTime = startSample / sampleRate;
const alignedEndTime = endSample / sampleRate;
const sampleTime = (startSample + endSample) / (2 * sampleRate);
const sampledScene = sceneAtTime(scene, sampleTime);
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;
}
if (
!Number.isFinite(maximumPropagationDelaySeconds) ||
maximumPropagationDelaySeconds < 0
) {
throw new RangeError("maximumPropagationDelaySeconds must be finite and non-negative.");
}
const preRollSamples = ceilSampleIndex(maximumPropagationDelaySeconds, sampleRate);
const renderStartSample = Math.max(0, startSample - preRollSamples);
const sources = [];
const sourceOffsets = [];
for (const source of scene.sources) {
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 signalStartSample = Math.ceil(signalStartTime * sampleRate);
const signalEndSample = Math.ceil((signalStartTime + signalDuration) * sampleRate);
const activeStartSample = Math.max(startSample, signalStartSample);
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;
sources.push({
...source,
position: sourcePositionAtTime(source, sampleTime),
startTime: (activeStartSample - startSample) / sampleRate,
position: sampledScene.sources[sourceIndex].position,
startTime: (activeStartSample - renderStartSample) / sampleRate,
duration: (activeEndSample - activeStartSample) / sampleRate,
});
sourceOffsets.push({ signalStartTime, signalStartSample });
sourceOffsets.push({
signalStartTime,
signalStartSample,
signalDuration,
});
}
const ambient = Array.isArray(scene.ambient) ? scene.ambient.slice() : scene.ambient;
const signalOffsets = {
sceneSampleOffset: startSample,
sceneSampleOffset: renderStartSample,
cropStartSample: startSample - renderStartSample,
cropSampleCount: endSample - startSample,
maximumPropagationDelaySeconds,
sourcePositionSampleTime: sampleTime,
windowingModel: "quasi-static-midpoint-propagation-preroll",
sources: sourceOffsets,
ambient: (scene.ambient ?? []).map(() => ({ signalStartTime: 0, signalStartSample: 0 })),
ambient: (scene.ambient ?? []).map(() => ({
signalStartTime: 0,
signalStartSample: 0,
signalDuration: scene.duration,
})),
};
const duration = (endSample - startSample) / sampleRate;
const renderDuration = (endSample - renderStartSample) / sampleRate;
return {
startTime: alignedStartTime,
endTime: alignedEndTime,
@@ -134,10 +172,14 @@ export function sampleQuasiStaticWindow(scene, startTime, endTime) {
endSample,
sampleCount: endSample - startSample,
duration,
renderStartSample,
renderDuration,
preRollSamples: startSample - renderStartSample,
maximumPropagationDelaySeconds,
signalOffsets,
scene: {
...scene,
duration,
duration: renderDuration,
sources,
...(Array.isArray(scene.ambient) ? { ambient } : {}),
},
@@ -170,7 +212,7 @@ export function createQuasiStaticWindows(scene, options = {}) {
const windows = [];
for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) {
const windowEnd = Math.min(windowStart + windowDuration, endTime);
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd));
windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd, options));
}
return windows;
}