feat(cw): телеграф — FPGA-кейер, pitch, сайдтон, передача текста

CW не работал ни на приём, ни на передачу: фильтр стоял симметрично вокруг
нуля (тона не было), понятия pitch не существовало, MOX в CWL отправлял в
эфир голос 2.9 кГц (в WDSP TXA_CWL — ветка SSB), байт CW-опций DUC всегда
был нулевым, поэтому кейер, сайдтон и break-in в прошивке молчали.

Pitch сделан ОДНИМ сдвигом в движке. Кромки фильтра везде остаются
относительно VFO (у CW — симметрично нулю), а гетеродин и полосу двигают
три метода WDSPEngine: CWLOOffset / PushShift / PushPassband. Прямых
вызовов SetRXAShiftFreq и RXASetPassband в проекте больше нет. Так таблица
фильтров не перестраивается при смене pitch (в отличие от Thetis), полоска
на спектре и маркер VFO верны без правок в UI, а слайсы получают свой
сдвиг по своему режиму.

Правила эфира:
- голосовой TXA в CW не запускается вообще (гард по режиму), MOX = только
  PTT; несущую даёт кейер, бит CWX или тон TUN;
- байт 5 собирается из настроек и перепосылается на каждой смене режима и
  TX-слайса — вне CW он обязан быть нулевым, иначе прошивка поднимет PTT на
  замыкание ключа посреди SSB;
- TUN в CW: тон = pitch и встречный сдвиг DUC, несущая встаёт ровно на VFO;
- приёмник на передаче в CW не глушится, иначе умолкает программный
  сайдтон и эфир между посылками.

Программная передача текста (CWMorse): поток с абсолютными дедлайнами
дёргает бит CWX в High Priority, элементы рисует прошивка. Касание
манипулятора или снятие MOX обрывают передачу. Память сообщений — окно
CW Messages и F1..F8 в главном окне. Оживлены CAT KS/KY/ZZKM/ZZKS/ZZKY.

Настройки — вкладка Transmit, подвкладка Hardware, перед блоком FM/CTCSS.

Программный сайдтон точен для передачи текста и прямого ключа; при иамбике
таймингом владеет FPGA, поэтому там верен только аппаратный тон.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:20:56 +03:00
co-authored by Claude Opus 5
parent 0ec120c2d5
commit 53329e82e8
12 changed files with 1421 additions and 46 deletions
+100 -12
View File
@@ -472,6 +472,7 @@ type
FLastSpectrumW: Integer; // последняя известная ширина спектра (пикс)
FDisplayResLimit: Integer; // потолок точек анализатора (настройка display_pixels)
FShiftHz: Double; // NCO shift для CTUN
FCWPitch: Integer; // Гц, тон телеграфа (см. CWLOOffset)
FNRMode: Integer;
FNBMode: Integer;
FSNBEnabled: Boolean;
@@ -505,6 +506,14 @@ type
// Порт Thetis UpdateTXLowHighFilterForMode: единственный конвертер знака,
// через который обязан идти ЛЮБОЙ пуш TX-bandpass, иначе LSB уходит в USB.
procedure TXSignedEdges(Mode: Integer; out Lo, Hi: Double);
// --- CW pitch -----------------------------------------------------------
// Смещение гетеродина демодулятора относительно VFO в телеграфе: станция,
// стоящая ТОЧНО на частоте VFO, обязана звучать тоном CWPitch, а не нулём.
// CWL слушаем ниже (гетеродин выше на pitch), CWU — наоборот. Всё остальное
// в проекте (кромки фильтра, полоска на спектре, маркер VFO) остаётся в
// координатах «относительно VFO»: конвертер ровно один — эти три метода.
procedure PushShift(Chan: Integer; ShiftHz: Double; Mode: Integer);
procedure PushPassband(Chan, Lo, Hi, Mode, Rate: Integer);
// Mode-dependent TX overlay. FM RAW uses the flat WFM modulator and
// bypasses every speech processor without overwriting the saved TX setup.
procedure ApplyTXModeSettings(Mode: Integer);
@@ -641,6 +650,14 @@ type
procedure SetSpectrumWidth(W: Integer); // обновляет FLastSpectrumW для корректных AGC линий
procedure SetDisplayResLimit(V: Integer); // потолок точек анализатора (1024/2048/4096)
procedure SetShift(ShiftHz: Double); // CTUN NCO сдвиг
// Тон телеграфа. Меняет положение гетеродина и полосы во ВСЕХ CW-каналах —
// применяется немедленно (главный + слайсы), персист делает контроллер.
procedure SetCWPitch(Hz: Integer);
function CWPitch: Integer;
// Смещение гетеродина демодулятора относительно VFO в телеграфе (см.
// комментарий у PushShift). Публично: тем же сдвигом контроллер двигает
// DUC при настройке TUN, чтобы несущая легла ровно на VFO.
function CWLOOffset(Mode: Integer): Double;
procedure SetNRMode(Mode: Integer);
procedure SetNR(Enable: Boolean);
procedure SetNBMode(Mode: Integer);
@@ -1231,6 +1248,35 @@ begin
end;
end;
function TWDSPEngine.CWLOOffset(Mode: Integer): Double;
// Знак — как в Thetis (console.cs:31757 rx_freq += pitch для CWL) и pihpsdr
// (new_protocol.c:773 rxFrequency -= pitch для CWU).
begin
case Mode of
MODE_CWL: Result := FCWPitch;
MODE_CWU: Result := -FCWPitch;
else Result := 0.0;
end;
end;
procedure TWDSPEngine.PushShift(Chan: Integer; ShiftHz: Double; Mode: Integer);
// ShiftHz приходит в координатах «VFO минус центр DDC»; в эфир гетеродин
// уезжает ещё на CWLOOffset, чтобы принимаемая посылка легла на pitch.
begin
SetRXAShiftFreq(Chan, ShiftHz + CWLOOffset(Mode));
end;
procedure TWDSPEngine.PushPassband(Chan, Lo, Hi, Mode, Rate: Integer);
// Кромки хранятся относительно VFO (у CW — симметрично нулю: «500 Гц вокруг
// корреспондента»). В baseband демодулятора они уезжают навстречу гетеродину,
// т.е. у CWU получается pitch±bw/2 — ровно таблица Thetis (console.cs:5338).
var Off: Integer;
begin
Off := Round(CWLOOffset(Mode));
RXASetPassband(Chan, ClampToNyquist(Lo - Off, Rate),
ClampToNyquist(Hi - Off, Rate));
end;
procedure TWDSPEngine.ApplyTXModeSettings(Mode: Integer);
begin
if not FInitialized then Exit;
@@ -1323,6 +1369,7 @@ begin
FFilterHigh := 2400;
FAGCMode := agcMedium;
FShiftHz := 0.0;
FCWPitch := 600;
FAGCTop := -90.0;
FVolume := 0.7;
FSMeter := -130.0;
@@ -1932,8 +1979,8 @@ begin
SetRXABandpassWindow(RXA_CHAN, 1);
SetRXAMode(RXA_CHAN, ModeToWDSP(FMode));
SetRXAShiftRun(RXA_CHAN, 1);
SetRXAShiftFreq(RXA_CHAN, FShiftHz);
RXASetPassband(RXA_CHAN, FFilterLow, FFilterHigh);
PushShift(RXA_CHAN, FShiftHz, FMode);
PushPassband(RXA_CHAN, FFilterLow, FFilterHigh, FMode, FRXDSPRate);
SetAGC(FAGCMode, 50.0);
SetRXAPanelGain1(RXA_CHAN, FVolume);
SetRXAPanelSelect(RXA_CHAN, 3);
@@ -2457,9 +2504,9 @@ begin
SetRXAMode(Chan, ModeToWDSP(FSlices[Idx].Mode));
ApplyRXModeSettings(Chan, FSlices[Idx].Mode);
SetRXAShiftRun(Chan, 1);
SetRXAShiftFreq(Chan, FSlices[Idx].ShiftHz);
RXASetPassband(Chan, ClampToNyquist(FSlices[Idx].FilterLo, DSPRate),
ClampToNyquist(FSlices[Idx].FilterHi, DSPRate));
PushShift(Chan, FSlices[Idx].ShiftHz, FSlices[Idx].Mode);
PushPassband(Chan, FSlices[Idx].FilterLo, FSlices[Idx].FilterHi,
FSlices[Idx].Mode, DSPRate);
ApplyAGCToChan(Chan, FSlices[Idx].AGCMode, 50.0);
// NR/NB/SNB/ANF из сохранённого состояния слайса.
ApplyNRToChan(Chan, FSlices[Idx].NRMode);
@@ -2733,7 +2780,8 @@ begin
try
idx := FindSliceIdx(Id); if idx < 0 then Exit;
FSlices[idx].ShiftHz := ShiftHz;
if FSlices[idx].Opened then SetRXAShiftFreq(FSlices[idx].Chan, ShiftHz);
if FSlices[idx].Opened then
PushShift(FSlices[idx].Chan, ShiftHz, FSlices[idx].Mode);
finally
FSliceLock.Leave;
end;
@@ -2768,6 +2816,14 @@ begin
// шумодава в не-FM режимах нет); вернулись в FM — восстановить выбор.
ApplySliceFMSQ(idx);
end;
// Приход/уход телеграфа меняет CWLOOffset — гетеродин и полосу надо
// переложить под новый режим, даже если канал не пересоздавали.
if FSlices[idx].Opened then
begin
PushShift(FSlices[idx].Chan, FSlices[idx].ShiftHz, Mode);
PushPassband(FSlices[idx].Chan, FSlices[idx].FilterLo, FSlices[idx].FilterHi,
Mode, SliceDSPRateFor(Mode, SliceInRate(idx)));
end;
finally
FSliceLock.Leave;
end;
@@ -2784,8 +2840,7 @@ begin
if FSlices[idx].Opened then
begin
Rate := SliceDSPRateFor(FSlices[idx].Mode, SliceInRate(idx));
RXASetPassband(FSlices[idx].Chan, ClampToNyquist(Low, Rate),
ClampToNyquist(High, Rate));
PushPassband(FSlices[idx].Chan, Low, High, FSlices[idx].Mode, Rate);
end;
finally
FSliceLock.Leave;
@@ -2965,6 +3020,9 @@ begin
ApplyDSPRate(Mode);
SetRXAMode(RXA_CHAN, ModeToWDSP(Mode));
ApplyRXModeSettings(RXA_CHAN, Mode);
// Вход/выход из телеграфа меняет CWLOOffset: гетеродин надо переложить сразу
// (кромки приедут из UI следом, но сдвиг оттуда не приходит).
PushShift(RXA_CHAN, FShiftHz, Mode);
ApplyTXMode(FTXMode);
// НЕ вызываем ApplyDefaultFilter — фильтр устанавливается явно из UI
end;
@@ -2978,9 +3036,7 @@ begin
// до ±100 кГц, а dsp_rate зависит от SampleRate. Клампим только то, что
// уходит в WDSP — запрошенные значения храним как есть, иначе понижение
// SampleRate необратимо «съело» бы полосу.
// RXASetPassband — правильный unified API, пересчитывает фильтр целиком
RXASetPassband(RXA_CHAN, ClampToNyquist(Low, FRXDSPRate),
ClampToNyquist(High, FRXDSPRate));
PushPassband(RXA_CHAN, Low, High, FMode, FRXDSPRate);
end;
procedure TWDSPEngine.SetAGC(Mode: TWDSPAGCMode; FixedGain: Double);
@@ -2999,7 +3055,39 @@ procedure TWDSPEngine.SetShift(ShiftHz: Double);
begin
FShiftHz := ShiftHz;
if not FInitialized then Exit;
SetRXAShiftFreq(RXA_CHAN, ShiftHz);
PushShift(RXA_CHAN, ShiftHz, FMode);
end;
function TWDSPEngine.CWPitch: Integer;
begin
Result := FCWPitch;
end;
procedure TWDSPEngine.SetCWPitch(Hz: Integer);
var i, Rate: Integer;
begin
if Hz = FCWPitch then Exit;
FCWPitch := Hz;
if not FInitialized then Exit;
if FMode in [MODE_CWL, MODE_CWU] then
begin
PushShift(RXA_CHAN, FShiftHz, FMode);
PushPassband(RXA_CHAN, FFilterLow, FFilterHigh, FMode, FRXDSPRate);
end;
FSliceLock.Enter;
try
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and FSlices[i].Opened and
(FSlices[i].Mode in [MODE_CWL, MODE_CWU]) then
begin
Rate := SliceDSPRateFor(FSlices[i].Mode, SliceInRate(i));
PushShift(FSlices[i].Chan, FSlices[i].ShiftHz, FSlices[i].Mode);
PushPassband(FSlices[i].Chan, FSlices[i].FilterLo, FSlices[i].FilterHi,
FSlices[i].Mode, Rate);
end;
finally
FSliceLock.Leave;
end;
end;
procedure TWDSPEngine.SetAGCTop(TopDBm: Double);