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
+152
View File
@@ -59,6 +59,8 @@ type
TOnCalibrationChange = procedure(const C: TCalibration) of object;
// TX-настройки целиком (любая правка → один callback с актуальной структурой)
TOnTXSettingsChange = procedure(const T: TTXSettings) of object;
TOnCWSettingsChange = procedure(const C: TCWSettings) of object;
TOnCWMessagesOpen = procedure of object;
// TX-профили: выбор/переименование/удаление по индексу, создание — по имени.
TOnTXProfileIdx = procedure(Idx: Integer) of object;
TOnTXProfileName = procedure(const AName: string) of object;
@@ -285,6 +287,23 @@ type
// CTCSS
FChkTXCTCSSOn: TFlatCheckBox;
FEdTXCTCSSFreq: TFlatFloatSpinEdit;
// CW (телеграф) — свойство устройства, профилем не переключается
FCW: TCWSettings;
FChkCWFWKeyer: TFlatCheckBox;
FEdCWPitch: TFlatSpinEdit;
FEdCWSpeed: TFlatSpinEdit;
FEdCWWeight: TFlatSpinEdit;
FCmbCWKeyerMode: TFlatComboBox;
FChkCWReverse: TFlatCheckBox;
FChkCWStrict: TFlatCheckBox;
FChkCWBreakIn: TFlatCheckBox;
FEdCWHang: TFlatSpinEdit;
FEdCWRamp: TFlatSpinEdit;
FEdCWRFDelay: TFlatSpinEdit;
FChkCWSidetoneHW: TFlatCheckBox;
FEdCWSidetoneHWLv: TFlatSpinEdit;
FChkCWSidetoneSW: TFlatCheckBox;
FEdCWSidetoneSWLv: TFlatSpinEdit;
// TX Display (отдельный analyzer)
FCmbTXFFT: TFlatComboBox;
FCmbTXWindow: TFlatComboBox;
@@ -446,6 +465,8 @@ type
FOnWfAGCNFChange: TOnWfAGCNFChange;
FOnFreqMhzDigitsChange: TOnFreqMhzDigitsChange;
FOnTXChange: TOnTXSettingsChange;
FOnCWChange: TOnCWSettingsChange;
FOnCWMessagesOpen: TOnCWMessagesOpen;
FOnTXProfileSelect: TOnTXProfileIdx;
FOnTXProfileAdd: TOnTXProfileName;
FOnTXProfileRename: TOnTXProfileIdxName;
@@ -500,6 +521,10 @@ type
procedure CollectTXFromUI;
procedure FireTXChange;
procedure OnTXAnyChange(Sender: TObject);
procedure CollectCWFromUI;
procedure FireCWChange;
procedure OnCWAnyChange(Sender: TObject);
procedure BtnCWMessagesClick(Sender: TObject);
procedure OnMicJackChange(Sender: TObject);
procedure UpdateMicJackVisibility;
// Подвкладки и полоса профиля на странице Transmit
@@ -632,6 +657,7 @@ type
const SerAndromeda: array of Boolean;
TcpEnabled: Boolean; TcpPort: Integer);
procedure LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0);
procedure LoadCWSettings(const C: TCWSettings);
// Список TX-профилей + активный + drive активного (ползунок DRV живёт в
// главном окне, здесь показываем его значение как часть профиля).
procedure LoadTXProfiles(const Names: array of string;
@@ -678,6 +704,8 @@ type
property OnThemeChange: TOnThemeChange read FOnThemeChange write FOnThemeChange;
property OnFreqMhzDigitsChange: TOnFreqMhzDigitsChange read FOnFreqMhzDigitsChange write FOnFreqMhzDigitsChange;
property OnTXChange: TOnTXSettingsChange read FOnTXChange write FOnTXChange;
property OnCWChange: TOnCWSettingsChange read FOnCWChange write FOnCWChange;
property OnCWMessagesOpen: TOnCWMessagesOpen read FOnCWMessagesOpen write FOnCWMessagesOpen;
property OnTXProfileSelect: TOnTXProfileIdx read FOnTXProfileSelect write FOnTXProfileSelect;
property OnTXProfileAdd: TOnTXProfileName read FOnTXProfileAdd write FOnTXProfileAdd;
property OnTXProfileRename: TOnTXProfileIdxName read FOnTXProfileRename write FOnTXProfileRename;
@@ -1795,6 +1823,27 @@ var
Result := MakeCombo(AParent, AX, AY, AW, OnTXAnyChange);
end;
// Телеграф едет отдельной записью (TCWSettings) и своим событием — иначе
// правка скорости кейера дёргала бы пересборку всей TX-цепи WDSP.
function MkSpinCW(AParent: TWinControl; AX, AY: Integer;
AMin, AMax, AVal: Integer): TFlatSpinEdit;
begin
Result := MkSpin(AParent, AX, AY, AMin, AMax, AVal);
Result.OnChange := OnCWAnyChange;
end;
function MkChkCW(AParent: TWinControl; AX, AY, AW: Integer;
const ACap: string): TFlatCheckBox;
begin
Result := MkChk(AParent, AX, AY, AW, ACap);
Result.OnChange := OnCWAnyChange;
end;
function MkCmbCW(AParent: TWinControl; AX, AY, AW: Integer): TFlatComboBox;
begin
Result := MakeCombo(AParent, AX, AY, AW, OnCWAnyChange);
end;
procedure TxNote(AParent: TWinControl; const AText: string;
ATop, AHeight: Integer);
var
@@ -1991,7 +2040,49 @@ begin
TxNote(Grp, 'Skirt steepness and path latency, not timbre — hence outside '
+ 'the profile.', R1 + 3 * STEP - 2, 22);
// ---- CW: телеграфом в openHPSDR P2 управляет прошивка -------------------
Inc(Y, 190 + 12);
Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 320);
FChkCWFWKeyer := MkChkCW(Grp, PAD, R1 - 6, 250, 'Keyer in radio firmware');
MkBtn(Grp, PAD + 260, R1 - 6, 110, 'Messages...', BtnCWMessagesClick);
MakeLbl(Grp, 'Pitch (Hz)', PAD + 400, R1 - 1, 90);
FEdCWPitch := MkSpinCW(Grp, PAD + 500, R1 - 6, 200, 1200, 600);
MakeLbl(Grp, 'Keyer mode', PAD, R1 + STEP + 5, LW);
FCmbCWKeyerMode := MkCmbCW(Grp, CX, R1 + STEP, 200);
FCmbCWKeyerMode.Items.AddStrings(['Straight key','Iambic A','Iambic B']);
FCmbCWKeyerMode.ItemIndex := 2;
FChkCWReverse := MkChkCW(Grp, PAD + 400, R1 + STEP, 150, 'Reverse paddles');
FChkCWStrict := MkChkCW(Grp, PAD + 560, R1 + STEP, 160, 'Strict spacing');
MakeLbl(Grp, 'Speed (WPM)', PAD, R1 + 2 * STEP + 5, LW);
FEdCWSpeed := MkSpinCW(Grp, CX, R1 + 2 * STEP, 5, 60, 20);
MakeLbl(Grp, 'Weight', PAD + 320, R1 + 2 * STEP + 5, 70);
FEdCWWeight := MkSpinCW(Grp, PAD + 400, R1 + 2 * STEP, 33, 66, 50);
MakeLbl(Grp, 'Edge (ms)', PAD + 520, R1 + 2 * STEP + 5, 80);
FEdCWRamp := MkSpinCW(Grp, PAD + 604, R1 + 2 * STEP, 0, 20, 9);
FChkCWBreakIn := MkChkCW(Grp, PAD, R1 + 3 * STEP, 150, 'Break-in');
MakeLbl(Grp, 'Hang (ms)', PAD + 320, R1 + 3 * STEP + 5, 80);
FEdCWHang := MkSpinCW(Grp, PAD + 400, R1 + 3 * STEP, 0, 2000, 300);
MakeLbl(Grp, 'RF delay', PAD + 520, R1 + 3 * STEP + 5, 80);
FEdCWRFDelay := MkSpinCW(Grp, PAD + 604, R1 + 3 * STEP, 0, 255, 0);
FChkCWSidetoneHW := MkChkCW(Grp, PAD, R1 + 4 * STEP, 190, 'Sidetone in radio');
MakeLbl(Grp, 'Level', PAD + 196, R1 + 4 * STEP + 5, 44);
FEdCWSidetoneHWLv := MkSpinCW(Grp, PAD + 244, R1 + 4 * STEP, 0, 127, 50);
FChkCWSidetoneSW := MkChkCW(Grp, PAD + 362, R1 + 4 * STEP, 200, 'Sidetone in PC audio');
MakeLbl(Grp, 'Level', PAD + 568, R1 + 4 * STEP + 5, 44);
FEdCWSidetoneSWLv := MkSpinCW(Grp, PAD + 616, R1 + 4 * STEP, 0, 100, 30);
TxNote(Grp, 'Dot and dash timing is produced by the radio, so PC load never '
+ 'affects it. The voice transmit chain is not started in CW at all. '
+ 'The radio sidetone is heard in the radio headphones; the PC sidetone '
+ 'is exact for keyboard sending and a straight key, but not for a paddle '
+ '— with iambic the firmware owns the timing.', R1 + 5 * STEP - 6, 60);
Inc(Y, 320 + 12);
Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250);
MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW);
@@ -2179,6 +2270,67 @@ begin
FireTXChange;
end;
procedure TSettingsForm.CollectCWFromUI;
begin
if FChkCWFWKeyer <> nil then FCW.FWKeyer := FChkCWFWKeyer.Checked;
if FEdCWPitch <> nil then FCW.Pitch := FEdCWPitch.Value;
if FCmbCWKeyerMode <> nil then FCW.KeyerMode := EnsureRange(FCmbCWKeyerMode.ItemIndex, 0, 2);
if FChkCWReverse <> nil then FCW.ReverseKeys := FChkCWReverse.Checked;
if FChkCWStrict <> nil then FCW.StrictSpacing := FChkCWStrict.Checked;
if FEdCWSpeed <> nil then FCW.Speed := FEdCWSpeed.Value;
if FEdCWWeight <> nil then FCW.Weight := FEdCWWeight.Value;
if FChkCWBreakIn <> nil then FCW.BreakIn := FChkCWBreakIn.Checked;
if FEdCWHang <> nil then FCW.HangTimeMS := FEdCWHang.Value;
if FEdCWRamp <> nil then FCW.RampMS := FEdCWRamp.Value;
if FEdCWRFDelay <> nil then FCW.RFDelayMS := FEdCWRFDelay.Value;
if FChkCWSidetoneHW <> nil then FCW.SidetoneHW := FChkCWSidetoneHW.Checked;
if FEdCWSidetoneHWLv <> nil then FCW.SidetoneHWLevel := FEdCWSidetoneHWLv.Value;
if FChkCWSidetoneSW <> nil then FCW.SidetoneSW := FChkCWSidetoneSW.Checked;
if FEdCWSidetoneSWLv <> nil then FCW.SidetoneSWLevel := FEdCWSidetoneSWLv.Value;
end;
procedure TSettingsForm.FireCWChange;
begin
if FLoading then Exit;
CollectCWFromUI;
if Assigned(FOnCWChange) then FOnCWChange(FCW);
end;
procedure TSettingsForm.OnCWAnyChange(Sender: TObject);
begin
FireCWChange;
end;
procedure TSettingsForm.BtnCWMessagesClick(Sender: TObject);
begin
if Assigned(FOnCWMessagesOpen) then FOnCWMessagesOpen;
end;
procedure TSettingsForm.LoadCWSettings(const C: TCWSettings);
begin
FLoading := True;
try
FCW := C;
if FChkCWFWKeyer <> nil then FChkCWFWKeyer.Checked := C.FWKeyer;
if FEdCWPitch <> nil then FEdCWPitch.Value := EnsureRange(C.Pitch, 200, 1200);
if FCmbCWKeyerMode <> nil then FCmbCWKeyerMode.ItemIndex := EnsureRange(C.KeyerMode, 0, 2);
if FChkCWReverse <> nil then FChkCWReverse.Checked := C.ReverseKeys;
if FChkCWStrict <> nil then FChkCWStrict.Checked := C.StrictSpacing;
if FEdCWSpeed <> nil then FEdCWSpeed.Value := EnsureRange(C.Speed, 5, 60);
if FEdCWWeight <> nil then FEdCWWeight.Value := EnsureRange(C.Weight, 33, 66);
if FChkCWBreakIn <> nil then FChkCWBreakIn.Checked := C.BreakIn;
if FEdCWHang <> nil then FEdCWHang.Value := EnsureRange(C.HangTimeMS, 0, 2000);
if FEdCWRamp <> nil then FEdCWRamp.Value := EnsureRange(C.RampMS, 0, 20);
if FEdCWRFDelay <> nil then FEdCWRFDelay.Value := EnsureRange(C.RFDelayMS, 0, 255);
if FChkCWSidetoneHW <> nil then FChkCWSidetoneHW.Checked := C.SidetoneHW;
if FEdCWSidetoneHWLv <> nil then FEdCWSidetoneHWLv.Value := EnsureRange(C.SidetoneHWLevel, 0, 127);
if FChkCWSidetoneSW <> nil then FChkCWSidetoneSW.Checked := C.SidetoneSW;
if FEdCWSidetoneSWLv <> nil then FEdCWSidetoneSWLv.Value := EnsureRange(C.SidetoneSWLevel, 0, 100);
finally
FLoading := False;
end;
end;
procedure TSettingsForm.UpdateMicJackVisibility;
var LineIn: Boolean;
begin