fix: expose physical aliasing and delayed arrivals
This commit is contained in:
@@ -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 의미
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user