commkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commkit/__init__.py +74 -0
- commkit/_cuda/__init__.py +321 -0
- commkit/_cuda/compiler.py +88 -0
- commkit/_cuda/src/bps_min_d2.cu +104 -0
- commkit/_cuda/src/cs_block.cu +119 -0
- commkit/_cuda/src/selftest.cu +14 -0
- commkit/analysis/__init__.py +55 -0
- commkit/analysis/_common.py +236 -0
- commkit/analysis/allan.py +108 -0
- commkit/analysis/drift.py +213 -0
- commkit/analysis/interferometry.py +887 -0
- commkit/analysis/linewidth.py +480 -0
- commkit/analysis/trajectory.py +91 -0
- commkit/backend.py +507 -0
- commkit/coding/__init__.py +23 -0
- commkit/coding/base.py +17 -0
- commkit/coding/bch.py +6 -0
- commkit/coding/convolutional.py +7 -0
- commkit/coding/crc.py +7 -0
- commkit/coding/galois.py +8 -0
- commkit/coding/hamming.py +6 -0
- commkit/coding/interleaving.py +7 -0
- commkit/coding/ldpc.py +8 -0
- commkit/coding/polar.py +8 -0
- commkit/coding/ratematch.py +6 -0
- commkit/coding/reed_solomon.py +6 -0
- commkit/coding/turbo.py +8 -0
- commkit/core/__init__.py +32 -0
- commkit/core/frame.py +992 -0
- commkit/core/generation.py +581 -0
- commkit/core/signal.py +725 -0
- commkit/equalization/__init__.py +49 -0
- commkit/equalization/_block.py +1855 -0
- commkit/equalization/_common.py +606 -0
- commkit/equalization/_kernels_jax.py +1720 -0
- commkit/equalization/_kernels_numba.py +1704 -0
- commkit/equalization/blind.py +223 -0
- commkit/equalization/linear.py +365 -0
- commkit/equalization/polarization.py +790 -0
- commkit/equalization/result.py +191 -0
- commkit/equalization/sequential.py +2805 -0
- commkit/filtering.py +1120 -0
- commkit/frequency.py +1191 -0
- commkit/helpers.py +489 -0
- commkit/impairments/__init__.py +43 -0
- commkit/impairments/channel/__init__.py +20 -0
- commkit/impairments/channel/linear.py +310 -0
- commkit/impairments/channel/nonlinear.py +11 -0
- commkit/impairments/frontend.py +229 -0
- commkit/impairments/noise.py +105 -0
- commkit/impairments/source.py +219 -0
- commkit/io.py +308 -0
- commkit/logger.py +103 -0
- commkit/mapping/__init__.py +46 -0
- commkit/mapping/bits.py +240 -0
- commkit/mapping/constellation.py +153 -0
- commkit/mapping/gray.py +429 -0
- commkit/mapping/llr.py +253 -0
- commkit/mapping/shaping.py +218 -0
- commkit/metrics.py +949 -0
- commkit/multirate.py +476 -0
- commkit/plotting/__init__.py +78 -0
- commkit/plotting/analysis.py +627 -0
- commkit/plotting/constellation.py +483 -0
- commkit/plotting/equalizer.py +390 -0
- commkit/plotting/eye.py +388 -0
- commkit/plotting/spectral.py +575 -0
- commkit/plotting/sync.py +953 -0
- commkit/plotting/theme.py +203 -0
- commkit/plotting/waveform.py +200 -0
- commkit/py.typed +0 -0
- commkit/recovery/__init__.py +51 -0
- commkit/recovery/bps.py +337 -0
- commkit/recovery/corrections.py +751 -0
- commkit/recovery/pilots.py +803 -0
- commkit/recovery/pll.py +482 -0
- commkit/recovery/tikhonov.py +424 -0
- commkit/recovery/viterbi_viterbi.py +227 -0
- commkit/spectral.py +560 -0
- commkit/timing.py +841 -0
- commkit-1.0.0.dist-info/METADATA +145 -0
- commkit-1.0.0.dist-info/RECORD +84 -0
- commkit-1.0.0.dist-info/WHEEL +4 -0
- commkit-1.0.0.dist-info/licenses/LICENSE +21 -0
commkit/timing.py
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Timing synchronization utilities.
|
|
3
|
+
|
|
4
|
+
This module provides routines for time synchronization, including the
|
|
5
|
+
generation of optimal synchronization sequences (Barker, Zadoff-Chu),
|
|
6
|
+
robust integer timing offset estimation via cross-correlation, and
|
|
7
|
+
fractional timing offset estimation and correction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .backend import ArrayType, dispatch, is_cupy_available, to_device
|
|
16
|
+
from .core import Preamble
|
|
17
|
+
from .logger import logger
|
|
18
|
+
|
|
19
|
+
# Window length for DFT-upsampling in estimate_fractional_delay()
|
|
20
|
+
_DFT_WINDOW = 33
|
|
21
|
+
|
|
22
|
+
# Standard Barker codes
|
|
23
|
+
_BARKER_SEQUENCES = {
|
|
24
|
+
2: [1, -1],
|
|
25
|
+
3: [1, 1, -1],
|
|
26
|
+
4: [1, 1, -1, 1],
|
|
27
|
+
5: [1, 1, 1, -1, 1],
|
|
28
|
+
7: [1, 1, 1, -1, -1, 1, -1],
|
|
29
|
+
11: [1, 1, 1, -1, -1, -1, 1, -1, -1, 1, -1],
|
|
30
|
+
13: [1, 1, 1, 1, 1, -1, -1, 1, 1, -1, 1, -1, 1],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def barker_sequence(length: int) -> ArrayType:
|
|
35
|
+
"""
|
|
36
|
+
Generates a Barker sequence of the specified length.
|
|
37
|
+
|
|
38
|
+
Barker sequences are binary sequences (+1, -1) with optimal cyclic
|
|
39
|
+
auto-correlation properties, where the sidelobes are at most 1. They are
|
|
40
|
+
widely used for frame synchronization and pulse compression.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
length : {2, 3, 4, 5, 7, 11, 13}
|
|
45
|
+
Total length of the Barker sequence.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
array_like
|
|
50
|
+
BPSK symbols (+1.0, -1.0). Shape: (length,).
|
|
51
|
+
Backend depends on system availability (CuPy if available, else NumPy).
|
|
52
|
+
|
|
53
|
+
Raises
|
|
54
|
+
------
|
|
55
|
+
ValueError
|
|
56
|
+
If the requested length is not a valid Barker length.
|
|
57
|
+
|
|
58
|
+
Examples
|
|
59
|
+
--------
|
|
60
|
+
>>> barker_sequence(7)
|
|
61
|
+
array([ 1., 1., 1., -1., -1., 1., -1.], dtype=float32)
|
|
62
|
+
"""
|
|
63
|
+
if length not in _BARKER_SEQUENCES:
|
|
64
|
+
valid = sorted(_BARKER_SEQUENCES.keys())
|
|
65
|
+
raise ValueError(f"No Barker sequence of length {length}. Valid: {valid}")
|
|
66
|
+
|
|
67
|
+
seq = np.array(_BARKER_SEQUENCES[length], dtype="float32")
|
|
68
|
+
|
|
69
|
+
if is_cupy_available():
|
|
70
|
+
seq = to_device(seq, "gpu")
|
|
71
|
+
|
|
72
|
+
logger.debug("Generated Barker-%s sequence.", length)
|
|
73
|
+
return seq
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def zadoff_chu_sequence(length: int, root: int = 1) -> ArrayType:
|
|
77
|
+
r"""
|
|
78
|
+
Generates a Zadoff-Chu (ZC) synchronization sequence.
|
|
79
|
+
|
|
80
|
+
ZC sequences are Complex-valued, Constant Amplitude Zero
|
|
81
|
+
Auto-Correlation (CAZAC) sequences. They possess the unique property
|
|
82
|
+
that their periodic auto-correlation is zero at all non-zero lags,
|
|
83
|
+
and their DFT is also a ZC sequence. This makes them ideal for
|
|
84
|
+
timing and frequency synchronization in systems like LTE and 5G NR.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
length : int
|
|
89
|
+
The sequence length (N_ZC). For optimal cross-correlation
|
|
90
|
+
properties, this should be a prime number.
|
|
91
|
+
root : int, default 1
|
|
92
|
+
The root index (u). Must be relatively prime to `length`.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
array_like
|
|
97
|
+
Complex Zadoff-Chu symbols of unit magnitude.
|
|
98
|
+
Shape: (length,). Data type: `complex64`.
|
|
99
|
+
|
|
100
|
+
Notes
|
|
101
|
+
-----
|
|
102
|
+
- For odd lengths: x[n] = exp(-j * pi * u * n * (n + 1) / N_ZC)
|
|
103
|
+
- For even lengths: x[n] = exp(-j * pi * u * n^2 / N_ZC)
|
|
104
|
+
- ZC sequences have exceptionally low Peak-to-Average Power Ratio (PAPR).
|
|
105
|
+
"""
|
|
106
|
+
if length < 1:
|
|
107
|
+
raise ValueError("Length must be positive.")
|
|
108
|
+
if root < 1 or root >= length:
|
|
109
|
+
raise ValueError(f"Root must be in [1, {length - 1}].")
|
|
110
|
+
|
|
111
|
+
# Determine backend
|
|
112
|
+
if is_cupy_available():
|
|
113
|
+
import cupy as cp
|
|
114
|
+
|
|
115
|
+
xp = cp
|
|
116
|
+
else:
|
|
117
|
+
xp = np
|
|
118
|
+
|
|
119
|
+
# ZC formula: x[n] = exp(-j * pi * u * n * (n+1) / N)
|
|
120
|
+
n = xp.arange(length)
|
|
121
|
+
if length % 2 == 0:
|
|
122
|
+
# Even length: x[n] = exp(-j * pi * u * n^2 / N)
|
|
123
|
+
seq = xp.exp(-1j * xp.pi * root * n * n / length)
|
|
124
|
+
else:
|
|
125
|
+
# Odd length: x[n] = exp(-j * pi * u * n * (n+1) / N)
|
|
126
|
+
seq = xp.exp(-1j * xp.pi * root * n * (n + 1) / length)
|
|
127
|
+
|
|
128
|
+
seq = seq.astype(xp.complex64)
|
|
129
|
+
|
|
130
|
+
logger.debug("Generated ZC sequence: length=%s, root=%s.", length, root)
|
|
131
|
+
return seq
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def estimate_fractional_delay(
|
|
135
|
+
correlation: ArrayType,
|
|
136
|
+
peak_indices: ArrayType,
|
|
137
|
+
dft_upsample: int = 1,
|
|
138
|
+
method: str = "log-parabolic",
|
|
139
|
+
) -> ArrayType:
|
|
140
|
+
"""
|
|
141
|
+
Estimates sub-sample timing offset via parabolic interpolation.
|
|
142
|
+
|
|
143
|
+
Given a correlation array and the integer peak positions, fits a
|
|
144
|
+
parabola (or Gaussian) through the three points around each peak to
|
|
145
|
+
estimate the fractional offset with sub-sample precision.
|
|
146
|
+
|
|
147
|
+
When the input is **complex**, the peak is phase-rotated to the real
|
|
148
|
+
axis before fitting. This preserves the true peak shape (unlike
|
|
149
|
+
fitting to ``|R|``) and improves accuracy by 2-5x for typical
|
|
150
|
+
matched-filter outputs.
|
|
151
|
+
|
|
152
|
+
If ``dft_upsample > 1``, performs a "Zoom FFT" (DFT zero-padding)
|
|
153
|
+
around the peak to interpolate the correlation function onto a finer
|
|
154
|
+
grid before fitting. This significantly reduces bias error.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
correlation : array_like
|
|
159
|
+
Correlation values - complex or real magnitude.
|
|
160
|
+
Shape: ``(N,)`` or ``(C, N)``.
|
|
161
|
+
peak_indices : array_like
|
|
162
|
+
Integer peak positions. Shape: scalar or ``(C,)``.
|
|
163
|
+
dft_upsample : int, default 1
|
|
164
|
+
Upsampling factor for DFT-based interpolation.
|
|
165
|
+
Values > 1 perform zero-padded FFT interpolation on a window
|
|
166
|
+
around the peak (typically 33 samples).
|
|
167
|
+
method : {'parabolic', 'log-parabolic'}, default 'log-parabolic'
|
|
168
|
+
Fitting method:
|
|
169
|
+
- 'parabolic': Standard parabolic fit. Good for general peaks.
|
|
170
|
+
- 'log-parabolic': Fits a parabola to log(y), equivalent to a
|
|
171
|
+
Gaussian fit. Often more accurate for bandlimited pulses.
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
array_like
|
|
176
|
+
Fractional offset per channel, in [-0.5, 0.5). Shape: ``(C,)`` or scalar.
|
|
177
|
+
"""
|
|
178
|
+
correlation, xp, _ = dispatch(correlation)
|
|
179
|
+
peak_indices = xp.asarray(peak_indices)
|
|
180
|
+
scalar_input = peak_indices.ndim == 0
|
|
181
|
+
|
|
182
|
+
if correlation.ndim == 1:
|
|
183
|
+
correlation = correlation[None, :] # (1, N)
|
|
184
|
+
if peak_indices.ndim == 0:
|
|
185
|
+
peak_indices = peak_indices[None] # (1,)
|
|
186
|
+
|
|
187
|
+
N = correlation.shape[-1]
|
|
188
|
+
C = correlation.shape[0]
|
|
189
|
+
ch_idx = xp.arange(C)
|
|
190
|
+
|
|
191
|
+
k = peak_indices.astype(int)
|
|
192
|
+
mu = xp.zeros(C, dtype=correlation.real.dtype)
|
|
193
|
+
|
|
194
|
+
def _calculate_mu(r_prev, r_curr, r_next, xp, method):
|
|
195
|
+
if xp.iscomplexobj(r_curr):
|
|
196
|
+
phase = xp.exp(-1j * xp.angle(r_curr))
|
|
197
|
+
alpha = (r_prev * phase).real
|
|
198
|
+
beta = (r_curr * phase).real
|
|
199
|
+
gamma = (r_next * phase).real
|
|
200
|
+
else:
|
|
201
|
+
alpha = r_prev
|
|
202
|
+
beta = r_curr
|
|
203
|
+
gamma = r_next
|
|
204
|
+
|
|
205
|
+
if method == "log-parabolic":
|
|
206
|
+
eps = 1e-12
|
|
207
|
+
alpha = xp.maximum(alpha, eps)
|
|
208
|
+
beta = xp.maximum(beta, eps)
|
|
209
|
+
gamma = xp.maximum(gamma, eps)
|
|
210
|
+
alpha, beta, gamma = xp.log(alpha), xp.log(beta), xp.log(gamma)
|
|
211
|
+
|
|
212
|
+
denom = 2.0 * (alpha - 2.0 * beta + gamma)
|
|
213
|
+
safe_denom = xp.where(xp.abs(denom) > 1e-12, denom, xp.ones_like(denom))
|
|
214
|
+
mu_val = (alpha - gamma) / safe_denom
|
|
215
|
+
|
|
216
|
+
# Mask invalid fits
|
|
217
|
+
valid = xp.abs(denom) > 1e-12
|
|
218
|
+
mu_val = xp.where(valid, mu_val, xp.zeros_like(mu_val))
|
|
219
|
+
return xp.clip(mu_val, -0.5, 0.5)
|
|
220
|
+
|
|
221
|
+
half_W = _DFT_WINDOW // 2
|
|
222
|
+
interior_mask = (k >= half_W) & (k < N - half_W)
|
|
223
|
+
|
|
224
|
+
# -------------------------------------------------------------------------
|
|
225
|
+
# Path A: DFT Upsampling
|
|
226
|
+
# -------------------------------------------------------------------------
|
|
227
|
+
if dft_upsample > 1:
|
|
228
|
+
if xp.any(interior_mask):
|
|
229
|
+
valid_indices = xp.where(interior_mask)[0]
|
|
230
|
+
k_valid = k[interior_mask]
|
|
231
|
+
|
|
232
|
+
offsets = xp.arange(-half_W, half_W + 1)
|
|
233
|
+
gather_idx = k_valid[:, None] + offsets[None, :]
|
|
234
|
+
windows = correlation[valid_indices[:, None], gather_idx]
|
|
235
|
+
|
|
236
|
+
specs = xp.fft.fft(windows, axis=-1)
|
|
237
|
+
pos_limit = (_DFT_WINDOW + 1) // 2
|
|
238
|
+
neg_len = _DFT_WINDOW - pos_limit
|
|
239
|
+
target_len = _DFT_WINDOW * dft_upsample
|
|
240
|
+
padded_specs = xp.zeros((len(valid_indices), target_len), dtype=specs.dtype)
|
|
241
|
+
padded_specs[:, :pos_limit] = specs[:, :pos_limit]
|
|
242
|
+
padded_specs[:, -neg_len:] = specs[:, pos_limit:]
|
|
243
|
+
|
|
244
|
+
upsampled = xp.fft.ifft(padded_specs, axis=-1) * dft_upsample
|
|
245
|
+
up_mag = xp.abs(upsampled)
|
|
246
|
+
k_up = xp.argmax(up_mag, axis=-1)
|
|
247
|
+
k_up_safe = xp.clip(k_up, 1, target_len - 2)
|
|
248
|
+
|
|
249
|
+
row_idx = xp.arange(len(valid_indices))
|
|
250
|
+
r_prev = upsampled[row_idx, k_up_safe - 1]
|
|
251
|
+
r_curr = upsampled[row_idx, k_up_safe]
|
|
252
|
+
r_next = upsampled[row_idx, k_up_safe + 1]
|
|
253
|
+
|
|
254
|
+
mu_up = _calculate_mu(r_prev, r_curr, r_next, xp, method)
|
|
255
|
+
|
|
256
|
+
# Position relative to window start: k_up + mu_up
|
|
257
|
+
# Center of window is at index (half_W * dft_upsample)?
|
|
258
|
+
# No, standard ZoomFFT mapping.
|
|
259
|
+
# Time axis of upsampled is 0 to W*M-1.
|
|
260
|
+
# Original sample k corresponds to center of window.
|
|
261
|
+
# Window covers [k - half_W, k + half_W].
|
|
262
|
+
# Index 0 of upsampled corresponds to k - half_W.
|
|
263
|
+
# So offset from k is: -half_W + (k_up + mu_up) / M.
|
|
264
|
+
offset_samples = -half_W + (k_up_safe + mu_up) / dft_upsample
|
|
265
|
+
mu[interior_mask] = offset_samples
|
|
266
|
+
|
|
267
|
+
# -------------------------------------------------------------------------
|
|
268
|
+
# Path B: Standard (Primary when M=1, fallback for edge channels when M>1)
|
|
269
|
+
# -------------------------------------------------------------------------
|
|
270
|
+
# The trigger is determined purely by which channels still need a result:
|
|
271
|
+
# - M=1: all channels (Path A was skipped entirely)
|
|
272
|
+
# - M>1: only ~interior_mask channels (edges Path A cannot process)
|
|
273
|
+
# Do NOT use `mu == 0` as a proxy - zero is a valid fractional offset and
|
|
274
|
+
# would wrongly overwrite DFT results for on-centre correlation peaks.
|
|
275
|
+
if dft_upsample == 1:
|
|
276
|
+
calc_mask = xp.ones(C, dtype=bool)
|
|
277
|
+
else:
|
|
278
|
+
calc_mask = ~interior_mask # only edge channels need the fallback
|
|
279
|
+
|
|
280
|
+
if xp.any(calc_mask):
|
|
281
|
+
interior_all = (k >= 1) & (k < N - 1)
|
|
282
|
+
k_all_safe = xp.clip(k, 1, N - 2)
|
|
283
|
+
|
|
284
|
+
r_prev = correlation[ch_idx, k_all_safe - 1]
|
|
285
|
+
r_curr = correlation[ch_idx, k_all_safe]
|
|
286
|
+
r_next = correlation[ch_idx, k_all_safe + 1]
|
|
287
|
+
|
|
288
|
+
mu_std = _calculate_mu(r_prev, r_curr, r_next, xp, method)
|
|
289
|
+
mu_std = xp.where(interior_all, mu_std, xp.zeros_like(mu_std))
|
|
290
|
+
|
|
291
|
+
if dft_upsample == 1:
|
|
292
|
+
mu = mu_std
|
|
293
|
+
else:
|
|
294
|
+
# Merge: DFT result for interior channels, standard for edge channels
|
|
295
|
+
mu = xp.where(interior_mask, mu, mu_std)
|
|
296
|
+
|
|
297
|
+
if scalar_input:
|
|
298
|
+
return mu[0]
|
|
299
|
+
return mu
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def fft_fractional_delay(
|
|
303
|
+
samples: ArrayType,
|
|
304
|
+
delay: float | ArrayType,
|
|
305
|
+
) -> ArrayType:
|
|
306
|
+
"""
|
|
307
|
+
Applies fractional sample delay using FFT-based frequency-domain method.
|
|
308
|
+
|
|
309
|
+
This is the mathematically ideal method for bandlimited signals. It
|
|
310
|
+
applies the delay as a phase shift in the frequency domain, which is
|
|
311
|
+
equivalent to ideal sinc interpolation in the time domain. Unlike
|
|
312
|
+
polynomial interpolators (e.g., Farrow), this method perfectly preserves
|
|
313
|
+
signal power and has no bandwidth limitations.
|
|
314
|
+
|
|
315
|
+
Parameters
|
|
316
|
+
----------
|
|
317
|
+
samples : array_like
|
|
318
|
+
Input signal. Shape: (N,) or (C, N).
|
|
319
|
+
delay : float or array_like
|
|
320
|
+
Fractional delay in samples. Positive = delay (shift right).
|
|
321
|
+
Scalar applies the same delay to all channels.
|
|
322
|
+
Array of shape (C,) applies per-channel delays.
|
|
323
|
+
|
|
324
|
+
Returns
|
|
325
|
+
-------
|
|
326
|
+
array_like
|
|
327
|
+
Delayed signal with the same shape as input.
|
|
328
|
+
|
|
329
|
+
Notes
|
|
330
|
+
-----
|
|
331
|
+
Applies Y(f) = X(f) * exp(-j * 2*pi * f * delay / N) - equivalent to
|
|
332
|
+
ideal sinc interpolation with perfect power preservation.
|
|
333
|
+
"""
|
|
334
|
+
samples, xp, _ = dispatch(samples)
|
|
335
|
+
was_1d = samples.ndim == 1
|
|
336
|
+
if was_1d:
|
|
337
|
+
samples = samples[None, :] # (1, N)
|
|
338
|
+
|
|
339
|
+
C, N = samples.shape
|
|
340
|
+
|
|
341
|
+
# Convert delay to array
|
|
342
|
+
if isinstance(delay, (int, float)):
|
|
343
|
+
delay_arr = xp.full(C, delay, dtype=samples.real.dtype)
|
|
344
|
+
else:
|
|
345
|
+
delay_arr = xp.asarray(delay, dtype=samples.real.dtype)
|
|
346
|
+
if delay_arr.ndim == 0:
|
|
347
|
+
delay_arr = delay_arr[None]
|
|
348
|
+
|
|
349
|
+
# FFT
|
|
350
|
+
spec = xp.fft.fft(samples, axis=-1)
|
|
351
|
+
|
|
352
|
+
# Frequency axis: normalized frequencies in cycles/sample
|
|
353
|
+
freqs = xp.fft.fftfreq(N, d=1.0)
|
|
354
|
+
|
|
355
|
+
# Phase shift: exp(-j * 2 * pi * f * delay)
|
|
356
|
+
# Positive delay -> phase ramp that shifts signal to the right.
|
|
357
|
+
# Computed at float64 accuracy, then cast to match spec's dtype to prevent
|
|
358
|
+
# -2j * xp.pi (complex128) from promoting complex64 spectra.
|
|
359
|
+
phase_shift = xp.exp(-2j * xp.pi * freqs[None, :] * delay_arr[:, None])
|
|
360
|
+
phase_shift = phase_shift.astype(spec.dtype)
|
|
361
|
+
|
|
362
|
+
# Apply phase shift
|
|
363
|
+
spec_delayed = spec * phase_shift
|
|
364
|
+
|
|
365
|
+
# IFFT
|
|
366
|
+
result = xp.fft.ifft(spec_delayed, axis=-1)
|
|
367
|
+
|
|
368
|
+
# Dtype restoration: mirrors the impairments.py pattern.
|
|
369
|
+
if not xp.iscomplexobj(samples):
|
|
370
|
+
# Real input: fractional delay is a real-valued operation
|
|
371
|
+
result = result.real
|
|
372
|
+
elif result.dtype != samples.dtype:
|
|
373
|
+
# Complex input: ifft may return complex128 from complex64 input
|
|
374
|
+
result = result.astype(samples.dtype)
|
|
375
|
+
|
|
376
|
+
if was_1d:
|
|
377
|
+
return result[0]
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def estimate_timing(
|
|
382
|
+
samples: ArrayType,
|
|
383
|
+
reference: Union[ArrayType, "Preamble"] | None = None,
|
|
384
|
+
threshold: float = 3.0,
|
|
385
|
+
sps: int | None = None,
|
|
386
|
+
pulse_shape: str | None = None,
|
|
387
|
+
filter_params: dict | None = None,
|
|
388
|
+
search_range: tuple[int, int] | None = None,
|
|
389
|
+
dft_upsample: int = 1,
|
|
390
|
+
fractional_method: str = "log-parabolic",
|
|
391
|
+
debug_plot: bool = False,
|
|
392
|
+
) -> tuple[ArrayType, ArrayType]:
|
|
393
|
+
"""
|
|
394
|
+
Estimates integer and fractional timing offsets via cross-correlation.
|
|
395
|
+
|
|
396
|
+
Slides a cross-correlation between the received signal and a known reference
|
|
397
|
+
to find the integer-sample timing offset per channel, then estimates the
|
|
398
|
+
fractional offset via parabolic interpolation on the correlation peak.
|
|
399
|
+
|
|
400
|
+
For MIMO references with shape ``(C_tx, L)``, every template is correlated
|
|
401
|
+
against every RX channel; each channel takes the strongest peak across all
|
|
402
|
+
(template, lag) pairs. This preserves per-channel hardware skew and is
|
|
403
|
+
robust to polarization swap or mixing.
|
|
404
|
+
|
|
405
|
+
Parameters
|
|
406
|
+
----------
|
|
407
|
+
samples : array_like
|
|
408
|
+
Received signal samples. Shape: ``(N,)`` or ``(C, N)``.
|
|
409
|
+
reference : array_like or Preamble
|
|
410
|
+
Reference for correlation. A Preamble object is reconstructed via
|
|
411
|
+
``Preamble.to_signal()`` using ``sps`` and ``pulse_shape``; a raw
|
|
412
|
+
array (shape ``(L,)`` or ``(C_tx, L)``) is used directly.
|
|
413
|
+
threshold : float, default 3.0
|
|
414
|
+
Peak-to-average power ratio threshold for peak detection.
|
|
415
|
+
sps : int, optional
|
|
416
|
+
Samples per symbol. Required when ``reference`` is a Preamble.
|
|
417
|
+
pulse_shape : str, optional
|
|
418
|
+
Pulse shaping filter type used when ``reference`` is a Preamble.
|
|
419
|
+
Defaults to ``'rrc'``.
|
|
420
|
+
filter_params : dict, optional
|
|
421
|
+
Extra pulse shaper parameters when ``reference`` is a Preamble.
|
|
422
|
+
search_range : tuple of int, optional
|
|
423
|
+
``(start, end)`` sample range to restrict the search.
|
|
424
|
+
dft_upsample : int, default 1
|
|
425
|
+
DFT-based upsampling factor for high-precision fractional estimation.
|
|
426
|
+
fractional_method : {'parabolic', 'log-parabolic'}, default 'log-parabolic'
|
|
427
|
+
Fitting method for fractional delay estimation.
|
|
428
|
+
debug_plot : bool, default False
|
|
429
|
+
If ``True``, plots the correlation magnitude for debugging.
|
|
430
|
+
|
|
431
|
+
Returns
|
|
432
|
+
-------
|
|
433
|
+
integer_offsets : ArrayType
|
|
434
|
+
Integer sample offsets where the reference sequence begins,
|
|
435
|
+
per RX channel. Shape: ``(N_channels,)``. Each channel's
|
|
436
|
+
offset is estimated independently so hardware skew between
|
|
437
|
+
channels is preserved.
|
|
438
|
+
fractional_offsets : ArrayType
|
|
439
|
+
Sub-sample timing offsets in [-0.5, 0.5) per channel.
|
|
440
|
+
Shape: ``(N_channels,)``.
|
|
441
|
+
|
|
442
|
+
Raises
|
|
443
|
+
------
|
|
444
|
+
ValueError
|
|
445
|
+
If no correlation peak is found that satisfies the threshold
|
|
446
|
+
criteria, or if ``reference`` is not provided.
|
|
447
|
+
|
|
448
|
+
Notes
|
|
449
|
+
-----
|
|
450
|
+
The returned integer offset is the sample index of the first reference
|
|
451
|
+
sample. For oversampled signals, pass a shaped reference at the same sps
|
|
452
|
+
for best timing SNR.
|
|
453
|
+
"""
|
|
454
|
+
from .helpers import cross_correlate_fft
|
|
455
|
+
|
|
456
|
+
# 1. Resolve Inputs & Metadata
|
|
457
|
+
if filter_params is None:
|
|
458
|
+
filter_params = {}
|
|
459
|
+
|
|
460
|
+
if not hasattr(samples, "ndim"):
|
|
461
|
+
raise TypeError(
|
|
462
|
+
f"estimate_timing() expects an array of samples, got {type(samples).__name__}."
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
sig_array, xp, _ = dispatch(samples)
|
|
466
|
+
|
|
467
|
+
# 2. Build reference waveform
|
|
468
|
+
ref_waveform = None
|
|
469
|
+
|
|
470
|
+
if isinstance(reference, Preamble):
|
|
471
|
+
# Preamble object: reconstruct the shaped waveform at the target SPS.
|
|
472
|
+
if sps is None:
|
|
473
|
+
raise ValueError("SPS must be provided when using a Preamble object.")
|
|
474
|
+
|
|
475
|
+
ref_waveform = xp.asarray(
|
|
476
|
+
reference.to_signal(
|
|
477
|
+
sps=sps,
|
|
478
|
+
symbol_rate=1.0,
|
|
479
|
+
pulse_shape=pulse_shape or "rrc",
|
|
480
|
+
**filter_params,
|
|
481
|
+
).samples
|
|
482
|
+
)
|
|
483
|
+
# ensure 2-D (C_tx, L*sps) for the correlation engine
|
|
484
|
+
if ref_waveform.ndim == 1:
|
|
485
|
+
ref_waveform = ref_waveform[None, :]
|
|
486
|
+
|
|
487
|
+
elif reference is not None:
|
|
488
|
+
# Raw array: use directly as the correlation template (e.g. the known
|
|
489
|
+
# TX waveform or a slice of the payload itself).
|
|
490
|
+
ref_waveform = xp.asarray(reference)
|
|
491
|
+
else:
|
|
492
|
+
raise ValueError(
|
|
493
|
+
"A 'reference' sequence must be provided (Preamble object or raw array)."
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# 3. Correlation Strategy
|
|
497
|
+
# Signal: (C, N) or (N,)
|
|
498
|
+
# Reference: (C, L) or (L,) or (1, L)
|
|
499
|
+
|
|
500
|
+
# Ensure dimensions match for broadcast/multichannel correlation
|
|
501
|
+
if sig_array.ndim == 1:
|
|
502
|
+
sig_array = sig_array[None, :] # Treat as 1 channel
|
|
503
|
+
|
|
504
|
+
if ref_waveform.ndim == 1:
|
|
505
|
+
ref_waveform = ref_waveform[None, :] # Treat as 1 template
|
|
506
|
+
|
|
507
|
+
# Ensure reference is on the same device as the signal.
|
|
508
|
+
ref_waveform = to_device(ref_waveform, "cpu" if xp is np else "gpu")
|
|
509
|
+
|
|
510
|
+
num_sig_ch = sig_array.shape[0]
|
|
511
|
+
|
|
512
|
+
# Apply search range
|
|
513
|
+
offset = 0
|
|
514
|
+
if search_range is not None:
|
|
515
|
+
start, end = search_range
|
|
516
|
+
sig_processing = sig_array[:, start:end]
|
|
517
|
+
offset = start
|
|
518
|
+
else:
|
|
519
|
+
sig_processing = sig_array
|
|
520
|
+
|
|
521
|
+
# === Vectorized Correlation (FFT) via shared helper ===
|
|
522
|
+
L = ref_waveform.shape[-1]
|
|
523
|
+
C_tx = ref_waveform.shape[0]
|
|
524
|
+
|
|
525
|
+
if C_tx > 1:
|
|
526
|
+
# Correlate every TX template against every RX channel.
|
|
527
|
+
# corr_all[rx, tx, lag] - shape (C_rx, C_tx, N_lag)
|
|
528
|
+
corr_all = xp.stack(
|
|
529
|
+
[
|
|
530
|
+
cross_correlate_fft(
|
|
531
|
+
sig_processing, ref_waveform[t : t + 1], mode="positive_lags"
|
|
532
|
+
)
|
|
533
|
+
for t in range(C_tx)
|
|
534
|
+
],
|
|
535
|
+
axis=1,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# === Greedy per-channel peak finding ===
|
|
539
|
+
# Each RX channel takes its strongest peak across all (template, lag).
|
|
540
|
+
# The lag is an independent per-channel argmax, so hardware skew is
|
|
541
|
+
# preserved; the template choice only selects which peak height is read,
|
|
542
|
+
# not its position (all streams share the symbol clock). Robust to a
|
|
543
|
+
# polarization swap, mixing, or a missing/weak stream. Polarization
|
|
544
|
+
# identity is not resolved here (see recovery.resolve_channel_permutation).
|
|
545
|
+
corr_all_mag = xp.abs(corr_all) # (C_rx, C_tx, N_lag)
|
|
546
|
+
best_tx = xp.argmax(xp.max(corr_all_mag, axis=-1), axis=-1) # (C_rx,)
|
|
547
|
+
corr = xp.take_along_axis(corr_all, best_tx[:, None, None], axis=1)[
|
|
548
|
+
:, 0
|
|
549
|
+
] # (C_rx, N_lag) complex - on-device gather, no per-channel host sync
|
|
550
|
+
corr_incoherent = xp.abs(corr) # (C_rx, N_lag)
|
|
551
|
+
peak_indices = xp.argmax(corr_incoherent, axis=-1) # (C_rx,)
|
|
552
|
+
else:
|
|
553
|
+
corr = cross_correlate_fft(sig_processing, ref_waveform, mode="positive_lags")
|
|
554
|
+
|
|
555
|
+
# Magnitude
|
|
556
|
+
corr_mag = xp.abs(corr)
|
|
557
|
+
|
|
558
|
+
# === Per-Channel Analysis ===
|
|
559
|
+
# For SISO / broadcast single template: find peak from magnitude
|
|
560
|
+
if C_tx == 1:
|
|
561
|
+
peak_indices = xp.argmax(corr_mag, axis=-1) # Shape (C,)
|
|
562
|
+
|
|
563
|
+
# === Per-Channel Normalization ===
|
|
564
|
+
# Normalized correlation metric: use local signal energy around each
|
|
565
|
+
# detected peak (L-sample window) instead of global mean. This gives
|
|
566
|
+
# metric ≈ 1.0 for a perfectly matched reference regardless of the
|
|
567
|
+
# noise/data content elsewhere in the signal.
|
|
568
|
+
e_ref = xp.sum(xp.abs(ref_waveform) ** 2, axis=-1) # (C_tx,) or scalar
|
|
569
|
+
if e_ref.ndim == 0:
|
|
570
|
+
e_ref = xp.full(num_sig_ch, e_ref)
|
|
571
|
+
else:
|
|
572
|
+
e_ref = xp.full(num_sig_ch, xp.mean(e_ref))
|
|
573
|
+
|
|
574
|
+
# Local signal energy: sum |sig|² in the L-sample window at each peak.
|
|
575
|
+
# Batch the peak indices to host once instead of one sync per channel.
|
|
576
|
+
N_sig = sig_processing.shape[-1]
|
|
577
|
+
peaks_np = to_device(peak_indices, "cpu")
|
|
578
|
+
e_s = xp.empty(num_sig_ch, dtype=sig_processing.real.dtype)
|
|
579
|
+
for ch in range(num_sig_ch):
|
|
580
|
+
pk = int(peaks_np[ch])
|
|
581
|
+
end_idx = min(pk + L, N_sig)
|
|
582
|
+
e_s[ch] = xp.sum(xp.abs(sig_processing[ch, pk:end_idx]) ** 2)
|
|
583
|
+
|
|
584
|
+
norm_factors = xp.sqrt(e_ref * e_s)
|
|
585
|
+
norm_factors = xp.maximum(norm_factors, 1e-12)
|
|
586
|
+
|
|
587
|
+
# Calculate per-channel coherence (diagnostic mathematically absolute bound, [0, 1])
|
|
588
|
+
if C_tx > 1:
|
|
589
|
+
peak_vals = xp.max(
|
|
590
|
+
corr_incoherent, axis=-1
|
|
591
|
+
) # (C,) incoherent - matches peak detection
|
|
592
|
+
mean_vals = xp.mean(corr_incoherent, axis=-1)
|
|
593
|
+
else:
|
|
594
|
+
peak_vals = xp.max(corr_mag, axis=-1) # (C,)
|
|
595
|
+
mean_vals = xp.mean(corr_mag, axis=-1)
|
|
596
|
+
|
|
597
|
+
coherence = peak_vals / norm_factors
|
|
598
|
+
coherence = xp.clip(coherence, 0.0, 1.0)
|
|
599
|
+
|
|
600
|
+
# Primary timing metric: PAPR (Peak-to-Average magnitude)
|
|
601
|
+
# This evaluates visual prominence against the noise floor, ensuring
|
|
602
|
+
# extreme robustness to CFO phase-rotation and pulse-shape mismatch.
|
|
603
|
+
mean_vals = xp.maximum(mean_vals, 1e-12)
|
|
604
|
+
metrics = peak_vals / mean_vals
|
|
605
|
+
|
|
606
|
+
# Log coherence diagnostics - one batched D2H per array, not per channel
|
|
607
|
+
coherence_np = to_device(coherence, "cpu")
|
|
608
|
+
metrics_np = to_device(metrics, "cpu")
|
|
609
|
+
for ch in range(num_sig_ch):
|
|
610
|
+
c_val = float(coherence_np[ch])
|
|
611
|
+
p_val = float(metrics_np[ch])
|
|
612
|
+
if c_val < 0.5 and p_val >= threshold:
|
|
613
|
+
logger.warning(
|
|
614
|
+
"Channel %s: Peak phase coherence is very low (%.2f), but peak is visually prominent (PAPR=%.1f >= %s). This suggests strong Carrier Frequency Offset (CFO) or uncompensated dispersion destroying phase alignment over the sequence length.",
|
|
615
|
+
ch,
|
|
616
|
+
c_val,
|
|
617
|
+
p_val,
|
|
618
|
+
threshold,
|
|
619
|
+
)
|
|
620
|
+
else:
|
|
621
|
+
logger.debug(
|
|
622
|
+
"Channel %s: Peak prominence = %.1f, coherence = %.2f", ch, p_val, c_val
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# === Threshold Check === (reuses the host copy - no further syncs)
|
|
626
|
+
max_metric = float(metrics_np.max())
|
|
627
|
+
if max_metric < threshold:
|
|
628
|
+
raise ValueError(
|
|
629
|
+
f"No correlation peak above threshold {threshold} (max: {max_metric:.3f})"
|
|
630
|
+
)
|
|
631
|
+
for _ch in range(num_sig_ch):
|
|
632
|
+
_m = float(metrics_np[_ch])
|
|
633
|
+
if _m < threshold:
|
|
634
|
+
logger.warning(
|
|
635
|
+
"Channel %s: correlation metric %.3f is below threshold %s. Integer offset for this channel may be unreliable.",
|
|
636
|
+
_ch,
|
|
637
|
+
_m,
|
|
638
|
+
threshold,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
# === Skew Check (Robust) ===
|
|
642
|
+
if num_sig_ch > 1:
|
|
643
|
+
valid_mask = metrics > threshold
|
|
644
|
+
|
|
645
|
+
if xp.sum(valid_mask) > 1:
|
|
646
|
+
valid_peaks = peak_indices[valid_mask]
|
|
647
|
+
spread = int(xp.max(valid_peaks) - xp.min(valid_peaks))
|
|
648
|
+
|
|
649
|
+
if spread > 0:
|
|
650
|
+
logger.warning(
|
|
651
|
+
"Skew detected among valid channels! Valid Peaks: %s. Spread: %s samples.",
|
|
652
|
+
valid_peaks.tolist(),
|
|
653
|
+
spread,
|
|
654
|
+
)
|
|
655
|
+
else:
|
|
656
|
+
logger.info("Channels aligned (spread %s).", spread)
|
|
657
|
+
|
|
658
|
+
# Each channel's peak is found independently so hardware skew is preserved.
|
|
659
|
+
integer_offsets = xp.maximum(0, peak_indices + offset)
|
|
660
|
+
|
|
661
|
+
if debug_plot:
|
|
662
|
+
from . import plotting as _plotting
|
|
663
|
+
|
|
664
|
+
_plotting.plot_timing_correlation(
|
|
665
|
+
corr_mag=to_device(corr_mag, "cpu"),
|
|
666
|
+
peak_indices=to_device(peak_indices, "cpu"),
|
|
667
|
+
norm_factors=to_device(mean_vals, "cpu"),
|
|
668
|
+
threshold=threshold,
|
|
669
|
+
offset=offset,
|
|
670
|
+
show=True,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# === Fractional Timing (Parabolic Interpolation) ===
|
|
674
|
+
fractional_offsets = estimate_fractional_delay(
|
|
675
|
+
corr, peak_indices, dft_upsample=dft_upsample, method=fractional_method
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
if logger.isEnabledFor(logging.INFO):
|
|
679
|
+
# Three .tolist() host syncs, only for this summary line.
|
|
680
|
+
logger.info(
|
|
681
|
+
"Timing estimated. Integer: %s, Fractional: %s, Metrics: %s",
|
|
682
|
+
integer_offsets.tolist(),
|
|
683
|
+
fractional_offsets.tolist(),
|
|
684
|
+
metrics.tolist(),
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
return integer_offsets, fractional_offsets
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def correct_timing(
|
|
691
|
+
samples: ArrayType,
|
|
692
|
+
integer_offset: int | ArrayType,
|
|
693
|
+
fractional_offset: float | ArrayType = 0.0,
|
|
694
|
+
mode: str = "circular",
|
|
695
|
+
) -> ArrayType:
|
|
696
|
+
"""
|
|
697
|
+
Combined integer and fractional timing correction.
|
|
698
|
+
|
|
699
|
+
Applies an integer sample shift followed by fractional sample
|
|
700
|
+
interpolation using FFT-based frequency-domain delay.
|
|
701
|
+
|
|
702
|
+
Parameters
|
|
703
|
+
----------
|
|
704
|
+
samples : array_like
|
|
705
|
+
Input signal. Shape: (N,) or (C, N).
|
|
706
|
+
integer_offset : int or array_like
|
|
707
|
+
Integer sample offset(s) to correct. Positive values shift
|
|
708
|
+
the signal left (i.e., remove leading samples).
|
|
709
|
+
Scalar or shape (C,) for per-channel offsets.
|
|
710
|
+
fractional_offset : float or array_like, default 0.0
|
|
711
|
+
Fractional sample delay(s) in [-0.5, 0.5) to correct via
|
|
712
|
+
FFT-based interpolation. Scalar or shape (C,).
|
|
713
|
+
mode : {'circular', 'zero', 'slice'}, default 'circular'
|
|
714
|
+
How to handle boundary samples after the integer shift:
|
|
715
|
+
|
|
716
|
+
- ``'circular'``: Wrap-around (``xp.roll``). Output has the
|
|
717
|
+
same length as input. Correct for periodic signals or
|
|
718
|
+
unit tests; **not** suitable for burst frames.
|
|
719
|
+
- ``'zero'``: Shift left, fill trailing samples with zeros.
|
|
720
|
+
Same output shape as input. Correct for burst reception
|
|
721
|
+
where tail wrapping would corrupt the payload.
|
|
722
|
+
- ``'slice'``: Discard leading samples; no tail artifact.
|
|
723
|
+
For a scalar offset the output length is ``N - offset``.
|
|
724
|
+
For per-channel offsets the output length is
|
|
725
|
+
``N - max(offset)`` so all channels are aligned to the
|
|
726
|
+
same common overlap region - the correct approach for
|
|
727
|
+
offline MIMO timing-skew correction.
|
|
728
|
+
|
|
729
|
+
Returns
|
|
730
|
+
-------
|
|
731
|
+
array_like
|
|
732
|
+
Timing-corrected signal. Same shape as input for
|
|
733
|
+
``'circular'`` and ``'zero'``; shorter for ``'slice'``.
|
|
734
|
+
|
|
735
|
+
Notes
|
|
736
|
+
-----
|
|
737
|
+
The reference sequence is not stripped from the output; sample 0 is the
|
|
738
|
+
first reference sample. Fractional delay uses FFT-based phase shift
|
|
739
|
+
(ideal for bandlimited signals; no power loss). For ``mode='slice'``,
|
|
740
|
+
fractional delay is applied to the full buffer before slicing so the
|
|
741
|
+
wrap-around stays at the buffer ends.
|
|
742
|
+
"""
|
|
743
|
+
samples, xp, _ = dispatch(samples)
|
|
744
|
+
was_1d = samples.ndim == 1
|
|
745
|
+
if was_1d:
|
|
746
|
+
samples = samples[None, :]
|
|
747
|
+
|
|
748
|
+
num_ch = samples.shape[0]
|
|
749
|
+
N = samples.shape[-1]
|
|
750
|
+
|
|
751
|
+
# Pre-evaluate the fractional-correction flag so mode='slice' can apply the
|
|
752
|
+
# delay before the slice. Same logic that used to live just before the
|
|
753
|
+
# post-integer fractional call below - relocated, not changed.
|
|
754
|
+
if isinstance(fractional_offset, (int, float)):
|
|
755
|
+
apply_frac = abs(fractional_offset) > 1e-9
|
|
756
|
+
else:
|
|
757
|
+
fractional_offset = xp.asarray(fractional_offset)
|
|
758
|
+
apply_frac = bool(xp.any(xp.abs(fractional_offset) > 1e-9))
|
|
759
|
+
|
|
760
|
+
# mode='slice': apply fractional delay on the *full* pre-slice buffer.
|
|
761
|
+
# fft_fractional_delay treats its input as circular; applying it after the
|
|
762
|
+
# slice would wrap the slice's trailing edge back into its new sample 0 -
|
|
763
|
+
# exactly the frame boundary we just aligned to. Applying first puts the
|
|
764
|
+
# wrap at the physical buffer ends, the leading one of which is then
|
|
765
|
+
# discarded by the slice; only the tail of the slice carries any residual
|
|
766
|
+
# ~sinc-tail artefact, well away from the equalizer training start.
|
|
767
|
+
if mode == "slice" and apply_frac:
|
|
768
|
+
samples = fft_fractional_delay(samples, -fractional_offset)
|
|
769
|
+
|
|
770
|
+
# === Integer correction (integer shift) ===
|
|
771
|
+
integer_offset = xp.asarray(integer_offset)
|
|
772
|
+
|
|
773
|
+
if integer_offset.ndim == 0:
|
|
774
|
+
# --- Scalar: same shift for all channels ---
|
|
775
|
+
shift = int(integer_offset)
|
|
776
|
+
if mode == "circular":
|
|
777
|
+
samples = xp.roll(samples, -shift, axis=-1)
|
|
778
|
+
elif mode == "zero":
|
|
779
|
+
result = xp.zeros_like(samples)
|
|
780
|
+
if shift > 0:
|
|
781
|
+
result[..., : N - shift] = samples[..., shift:]
|
|
782
|
+
elif shift < 0:
|
|
783
|
+
result[..., -shift:] = samples[..., : N + shift]
|
|
784
|
+
samples = result
|
|
785
|
+
elif mode == "slice":
|
|
786
|
+
samples = samples[..., shift:]
|
|
787
|
+
else:
|
|
788
|
+
raise ValueError(
|
|
789
|
+
f"Unknown mode {mode!r}. Choose 'circular', 'zero', or 'slice'."
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
else:
|
|
793
|
+
# --- Per-channel: vectorized gather (avoids one GPU->CPU sync per channel) ---
|
|
794
|
+
integer_shift = integer_offset.astype(xp.int64) # (C,) on device
|
|
795
|
+
col_base = xp.arange(N, dtype=xp.int64)[None, :] # (1, N)
|
|
796
|
+
row_idx = xp.arange(num_ch)[:, None] # (C, 1)
|
|
797
|
+
|
|
798
|
+
if mode == "circular":
|
|
799
|
+
col_idx = (col_base + integer_shift[:, None]) % N # (C, N)
|
|
800
|
+
samples = samples[row_idx, col_idx]
|
|
801
|
+
|
|
802
|
+
elif mode == "zero":
|
|
803
|
+
col_raw = col_base + integer_shift[:, None] # (C, N)
|
|
804
|
+
gathered = samples[row_idx, xp.clip(col_raw, 0, N - 1)]
|
|
805
|
+
samples = xp.where(col_raw < N, gathered, xp.zeros_like(gathered))
|
|
806
|
+
|
|
807
|
+
elif mode == "slice":
|
|
808
|
+
# Align all channels to common overlap: N - max(offset) samples
|
|
809
|
+
max_shift = int(xp.max(integer_shift)) # one GPU sync
|
|
810
|
+
common_len = N - max_shift
|
|
811
|
+
col_idx_s = (
|
|
812
|
+
xp.arange(common_len, dtype=xp.int64)[None, :] + integer_shift[:, None]
|
|
813
|
+
) # (C, common_len)
|
|
814
|
+
samples = samples[row_idx, col_idx_s]
|
|
815
|
+
|
|
816
|
+
else:
|
|
817
|
+
raise ValueError(
|
|
818
|
+
f"Unknown mode {mode!r}. Choose 'circular', 'zero', or 'slice'."
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
# === Fractional correction (via FFT) ===
|
|
822
|
+
# For mode='slice' this was already applied above on the full pre-slice
|
|
823
|
+
# buffer; here we only handle 'circular' / 'zero', whose output length
|
|
824
|
+
# equals the input length so the wrap location is unchanged either way.
|
|
825
|
+
if apply_frac and mode != "slice":
|
|
826
|
+
samples = fft_fractional_delay(samples, -fractional_offset)
|
|
827
|
+
|
|
828
|
+
if mode == "slice":
|
|
829
|
+
logger.warning(
|
|
830
|
+
"correct_timing(mode='slice'): output is shorter than input (trimmed by %s samples). Signal length metadata (e.g. duration) will no longer match the original.",
|
|
831
|
+
int(xp.max(xp.asarray(integer_offset))),
|
|
832
|
+
)
|
|
833
|
+
logger.info(
|
|
834
|
+
"Timing corrected: integer=applied, fractional=%s, mode=%r.",
|
|
835
|
+
"applied" if apply_frac else "skipped",
|
|
836
|
+
mode,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
if was_1d:
|
|
840
|
+
return samples[0]
|
|
841
|
+
return samples
|