feat: Pluto TX polyphase FIR interpolator (anti-imaging)

Replaced linear interpolation upsampling (192k→device-rate) with a
polyphase FIR interpolator ×L. Prototype: windowed-sinc lowpass (Blackman,
~-58 dB sidelobes), TAPS*L long, cutoff TX_FIR_CUT=40k, normalized to unity
per-phase DC gain (loudness unchanged). Images at multiples of 192k are now
suppressed ~-58 dB instead of only sinc², cleaning up the uplink spectrum.

BuildTxResampler designs coefficients per FTxRatio in StartTX; FTxDlI/Q
delay line persists across SendDUCIQ calls, reset on StartTX/ClearDUCIQQueue.
Removed FTxPrevI/Q.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 20:25:52 +03:00
co-authored by Claude Opus 4.8
parent 071ffa476d
commit be684545c8
+77 -21
View File
@@ -67,10 +67,13 @@ type
FTxChQ: Piio_channel;
FTxThread: TThread;
FTxStreaming: Boolean;
FTxRatio: Integer; // FSampleRate div 192000 (целочисленный апсемпл)
FTxRatio: Integer; // FSampleRate div 192000 (целочисленный апсемпл ×L)
FTxBufPairs: Integer; // размер TX-буфера в IQ-парах (по sample-rate, см. TxBufPairsForRate)
FTxPrevI: LongInt; // последний 24-бит сэмпл (линейная интерполяция между вызовами)
FTxPrevQ: LongInt;
// Полифазный FIR-интерполятор: FTxCoef[p*TAPS + k] = h[p + k*L] (фаза p, отвод k).
// Линия задержки FTxDlI/Q хранит последние TAPS входных сэмплов между вызовами.
FTxCoef: array of Double;
FTxDlI: array of Double;
FTxDlQ: array of Double;
// FIFO int16-пар @ device-rate (кольцо): продюсер — SendDUCIQ, консьюмер — push-поток.
FTxFifo: array of SmallInt; // интерливнутые I,Q
FTxFifoCap: Integer; // ёмкость в SmallInt-элементах (пар*2)
@@ -83,6 +86,7 @@ type
procedure ApplyRxLO(Hz: Double);
procedure ApplyTxLO(Hz: Double);
procedure ApplySampleRate(Hz: Integer);
procedure BuildTxResampler; // (пере)строить полифазные коэффициенты под FTxRatio
procedure ApplyGain;
procedure ApplyTxAtten;
procedure StartRX;
@@ -146,6 +150,10 @@ const
// выше 1 Msps — сэмплы дропаются» (комментарий драйвера). Минимум — TX_BUF_MIN.
TX_BUF_MIN = 4096; // нижняя граница (~7 мс @ 576k)
TX_BUF_MAX = 65536; // верхняя граница (защита от гигантских буферов)
// Полифазный анти-имидж интерполятор 192k→device-rate (вместо линейной интерполяции,
// которая оставляла образы на кратных 192k с подавлением только sinc²).
TX_FIR_TAPS = 16; // отводов на фазу (групп-задержка TAPS/2 входных сэмплов)
TX_FIR_CUT = 40000; // частота среза анти-имидж ФНЧ, Гц (покрывает SSB/NFM/AM/data)
PLUTO_TX_OFF = -89.75; // макс. аттенюация AD9361 — TX практически выключен
type
@@ -341,7 +349,7 @@ begin
FRxBuf := nil; FRxChI := nil; FRxChQ := nil; FRxThread := nil;
FStreaming := False; FRxSeq := 0;
FTxBuf := nil; FTxChI := nil; FTxChQ := nil; FTxThread := nil;
FTxStreaming := False; FTxRatio := 8; FTxPrevI := 0; FTxPrevQ := 0;
FTxStreaming := False; FTxRatio := 8;
FTxAttDb := PLUTO_TX_OFF; // на старте TX выключен (макс. аттенюация)
// FIFO на ~4 TX-буфера запаса под джиттер продюсера/консьюмера. Окончательный
// размер выставляется в StartTX по фактическому device-rate (TxBufPairsForRate).
@@ -597,6 +605,44 @@ begin
if WasTxStreaming then StartTX; // пересоздаём TX с новым FTxRatio
end;
procedure TPlutoBackend.BuildTxResampler;
// Полифазный FIR-интерполятор ×L (L=FTxRatio): прототип — оконный sinc-ФНЧ длиной
// TAPS*L с частотой среза TX_FIR_CUT, нормированный так, что DC-усиление любой фазы
// ≈ 1 (полная сумма = L). Раскладка в фазы: coef[p*TAPS+k] = proto[p + k*L].
var
L, M, n, p, k: Integer;
fc, x, s, w, sum, scale: Double;
proto: array of Double;
begin
L := FTxRatio;
if L < 1 then L := 1;
M := TX_FIR_TAPS * L;
SetLength(proto, M);
fc := TX_FIR_CUT / FSampleRate; // нормированный срез (циклов/сэмпл на выходе)
if fc > 0.49 then fc := 0.49;
sum := 0;
for n := 0 to M - 1 do
begin
x := n - (M - 1) / 2.0;
if Abs(x) < 1e-9 then s := 2.0 * fc
else s := Sin(2.0 * Pi * fc * x) / (Pi * x);
// окно Блэкмана (~ −58 дБ боковые)
w := 0.42 - 0.5 * Cos(2.0 * Pi * n / (M - 1)) + 0.08 * Cos(4.0 * Pi * n / (M - 1));
proto[n] := s * w;
sum := sum + proto[n];
end;
if sum = 0 then sum := 1;
scale := L / sum; // DC-усиление на фазу ≈ 1
SetLength(FTxCoef, M);
for p := 0 to L - 1 do
for k := 0 to TX_FIR_TAPS - 1 do
FTxCoef[p * TX_FIR_TAPS + k] := proto[p + k * L] * scale;
// линия задержки на TAPS входных сэмплов (сброс)
SetLength(FTxDlI, TX_FIR_TAPS);
SetLength(FTxDlQ, TX_FIR_TAPS);
for k := 0 to TX_FIR_TAPS - 1 do begin FTxDlI[k] := 0; FTxDlQ[k] := 0; end;
end;
procedure TPlutoBackend.ApplyGain;
var Ch: Piio_channel;
begin
@@ -666,7 +712,7 @@ begin
// Апсемпл-ратио под текущий device-rate (рейты-пресеты кратны 192k → целое).
FTxRatio := FSampleRate div TX_SRC_RATE;
if FTxRatio < 1 then FTxRatio := 1;
FTxPrevI := 0; FTxPrevQ := 0;
BuildTxResampler; // полифазные коэффициенты + сброс линии задержки под FTxRatio
// Размер push-буфера под текущий rate + FIFO на ~4 таких буфера запаса.
FTxBufPairs := TxBufPairsForRate(FSampleRate);
EnterCriticalSection(FTxLock);
@@ -776,9 +822,10 @@ end;
procedure TPlutoBackend.SendDUCIQ(const IData, QData: array of Integer);
// IData/QData — 24-bit пары @ TX_SRC_RATE (192k) от WDSP через контроллер.
// Линейно апсемплим ×FTxRatio, конвертим 24→int16 (>>8, full-scale MSB-aligned),
// Q инвертируем (та же конвенция, что и на RX) и кладём в FIFO. Underrun в push-
// потоке добивается нулями, overflow (продюсер опередил) — отбрасываем старое.
// Полифазный FIR-интерполятор ×FTxRatio (анти-имидж ФНЧ, см. BuildTxResampler):
// на каждый входной сэмпл выдаём L выходных, конвертим 24→int16 (>>8, full-scale
// MSB-aligned), Q инвертируем (та же конвенция, что и на RX) и кладём в FIFO.
// Underrun в push-потоке добивается нулями, overflow — отбрасываем старое.
function Clamp16(V: Int64): SmallInt; inline;
begin
if V > 32767 then Result := 32767
@@ -786,23 +833,32 @@ procedure TPlutoBackend.SendDUCIQ(const IData, QData: array of Integer);
else Result := SmallInt(V);
end;
var
i, j, t: Integer;
ci, cq, oi, oq: Int64;
n: Integer;
i, p, k, t, L, taps, base, n: Integer;
ai, aq: Double;
begin
if (not FTxStreaming) or (FTxRatio < 1) then Exit;
if (not FTxStreaming) or (FTxRatio < 1) or (Length(FTxCoef) = 0) then Exit;
n := Length(IData);
if Length(QData) < n then n := Length(QData);
L := FTxRatio;
taps := TX_FIR_TAPS;
EnterCriticalSection(FTxLock);
try
for i := 0 to n - 1 do
begin
ci := IData[i]; cq := QData[i];
for j := 1 to FTxRatio do
// сдвигаем линию задержки (FTxDl[0] = новейший входной сэмпл)
for k := taps - 1 downto 1 do
begin FTxDlI[k] := FTxDlI[k - 1]; FTxDlQ[k] := FTxDlQ[k - 1]; end;
FTxDlI[0] := IData[i]; FTxDlQ[0] := QData[i];
// L выходных сэмплов — фазы p = 0..L-1, каждая = свёртка с подфильтром
for p := 0 to L - 1 do
begin
// линейная интерполяция prev→cur по FTxRatio точкам (последняя = cur)
oi := FTxPrevI + (ci - FTxPrevI) * j div FTxRatio;
oq := FTxPrevQ + (cq - FTxPrevQ) * j div FTxRatio;
ai := 0; aq := 0;
base := p * taps;
for k := 0 to taps - 1 do
begin
ai := ai + FTxCoef[base + k] * FTxDlI[k];
aq := aq + FTxCoef[base + k] * FTxDlQ[k];
end;
if FTxFifoCount + 2 > FTxFifoCap then
begin
// overflow: сбрасываем самые старые пары, держим хвост (минимум латентности)
@@ -810,12 +866,11 @@ begin
Dec(FTxFifoCount, 2);
end;
t := FTxFifoTail;
FTxFifo[t] := Clamp16(oi div 256); // 24→16 бит
FTxFifo[t + 1] := Clamp16(-(oq div 256)); // Q инверсия (как RX)
FTxFifo[t] := Clamp16(Round(ai / 256.0)); // 24→16 бит
FTxFifo[t + 1] := Clamp16(-Round(aq / 256.0)); // Q инверсия (как RX)
FTxFifoTail := (t + 2) mod FTxFifoCap;
Inc(FTxFifoCount, 2);
end;
FTxPrevI := ci; FTxPrevQ := cq;
end;
finally
LeaveCriticalSection(FTxLock);
@@ -823,10 +878,11 @@ begin
end;
procedure TPlutoBackend.ClearDUCIQQueue;
var k: Integer;
begin
EnterCriticalSection(FTxLock);
FTxFifoHead := 0; FTxFifoTail := 0; FTxFifoCount := 0;
FTxPrevI := 0; FTxPrevQ := 0;
for k := 0 to Length(FTxDlI) - 1 do begin FTxDlI[k] := 0; FTxDlQ[k] := 0; end;
LeaveCriticalSection(FTxLock);
end;