Files

251 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
};
}