Source code for adctoolbox.timeinterleave.fractional_delay
"""Fractional-sample delay primitives.
Two equivalent interfaces for applying a sub-sample time shift to a uniformly
sampled signal — used by TI-ADC skew calibration but independently useful
anywhere a fractional delay is needed.
- :func:`fractional_delay_fft` — frequency-domain phase rotation
- :func:`fractional_delay_farrow` — time-domain Lagrange FIR (causal, streaming-friendly)
Both obey the same sign convention: ``delay_sec > 0`` shifts the signal **later**
in time (``y(t) = x(t - delay_sec)``); ``delay_sec < 0`` advances it.
"""
from __future__ import annotations
import numpy as np
[docs]
def fractional_delay_fft(x: np.ndarray, delay_sec: float, fs: float) -> np.ndarray:
"""
Apply a fractional-sample delay via DFT phase rotation.
Output ``y[n]`` approximates ``x((n / fs) - delay_sec)`` using the sinc
interpolation implicit in a length-``N`` DFT. Exact for signals that are
periodic over their length and strictly bandlimited to ``[0, fs/2)``.
Parameters
----------
x : array_like, 1-D real
delay_sec : float
Delay in seconds; positive = later in time, negative = earlier.
fs : float
Sample rate (Hz).
Returns
-------
y : ndarray, same length as ``x``.
Notes
-----
Because the DFT assumes periodic extension, non-periodic signals will wrap
around when ``|delay_sec|`` approaches the full record length. For clean
results near the edges, zero-pad ``x`` before calling and trim afterwards.
"""
x = np.asarray(x, dtype=float)
if x.ndim != 1:
raise ValueError(f"expected 1-D input, got shape {x.shape}")
N = x.size
X = np.fft.rfft(x)
f = np.fft.rfftfreq(N, d=1.0 / fs)
X = X * np.exp(-1j * 2 * np.pi * f * delay_sec)
if N % 2 == 0:
# Nyquist bin must stay real to preserve real-valued irfft output
X[-1] = X[-1].real
return np.fft.irfft(X, n=N)
[docs]
def fractional_delay_farrow(
x: np.ndarray,
delay_sec: float,
fs: float,
n_taps: int = 7,
) -> np.ndarray:
"""
Apply a fractional-sample delay via a Lagrange FIR interpolator.
Causal / streaming alternative to :func:`fractional_delay_fft`. The
``n_taps``-tap centered filter trades accuracy against boundary transient
length (``n_taps // 2`` samples on each end are unreliable, because the
`same`-mode convolution zero-pads the input edges).
Parameters
----------
x : array_like, 1-D real
delay_sec : float
fs : float
n_taps : int, default 7
Must be a positive odd integer. 5–9 is typical; higher = flatter
passband but longer transient.
Returns
-------
y : ndarray, same length as ``x``.
Notes
-----
The delay is split into an integer part (applied by zero-padded shift) and
a fractional remainder in ``(-0.5, 0.5]`` handled by the centered Lagrange
filter. When ``delay_sec == 0`` the filter is an impulse at the center tap.
"""
if n_taps % 2 == 0 or n_taps < 1:
raise ValueError(f"n_taps must be a positive odd integer, got {n_taps}")
x = np.asarray(x, dtype=float)
if x.ndim != 1:
raise ValueError(f"expected 1-D input, got shape {x.shape}")
D = delay_sec * fs
D_int = int(np.round(D))
d_frac = D - D_int
y = _integer_shift_zero_pad(x, D_int)
# Centered Lagrange taps: evaluation point p = P + d_frac within [0, n_taps-1].
# d_frac == 0 yields a delta at k = P (identity).
P = n_taps // 2
p_eval = P + d_frac
h = np.ones(n_taps)
for i in range(n_taps):
for j in range(n_taps):
if j != i:
h[i] *= (p_eval - j) / (i - j)
return np.convolve(y, h, mode="same")
def _integer_shift_zero_pad(x: np.ndarray, D: int) -> np.ndarray:
"""Shift ``x`` by ``D`` samples with zero-padded boundaries (no wrap-around)."""
if D == 0:
return x.copy()
y = np.zeros_like(x)
if D > 0:
y[D:] = x[:-D]
else:
y[:D] = x[-D:]
return y