feat(cw): программный кейер — телеграф на Pluto/LibreSDR

У openHPSDR точки и тире формирует кейер в FPGA, и это остаётся путём по
умолчанию: тайминги в железе, джиттер PC не влияет вовсе. У AD936x такого
кейера нет вовсе, поэтому телеграфа на Pluto не было в принципе — голосовой
TXA в CW не запускается, а несущую давать некому.

CWKeyer.pas: TCWLocalKeyer рисует манипулированную несущую прямо в поток IQ,
мимо WDSP (эталон pihpsdr cw_shape_buffer: в WDSP режим TXA_CWL — ветка SSB,
да и PostGen не умеет огибающей). Длительности живут В СЭМПЛАХ, а не в
миллисекундах: планировщик ОС влияет только на темп выдачи блоков, а его
сглаживает FIFO бэкенда. Умеет текст, иамбик A/B и прямой ключ; TCWKeyPort
читает манипулятор с модемных линий COM/USB-serial (DTR/RTS питают ключ,
CTS/DSR/DCD/RI — лепестки). Оффлайн-прогон: PARIS занимает ровно 43 точки,
все посылки и паузы 1/3/7 dit с точностью до сэмпла.

Сессия = одна передача, а не одна посылка: PTT, реле T/R, антенна, OC и
аттенюатор Pluto поднимаются один раз и держатся до конца hang — внутри
манипуляция чисто цифровая. Предзаполнение FIFO задано железом: TX-поток
Pluto наполняет буфер целиком (16384 пары ≈ 28 мс на 576k) и добивает
нулями, чего не хватило. Сайдтон эту латентность не наследует — кольцо
огибающей листается, если отставание больше 20 мс.

Сдвиг несущей: у zero-IF ноль бейсбенда совпадает с частотой гетеродина, и
там же его утечка — манипуляция на нуле дала бы backwave ровно на рабочей
частоте. Поэтому несущая идёт на сдвиге, а гетеродин уезжает навстречу через
новую единую дверь TXTuneFreqHz (все пуши TX-частоты переведены на неё).
Знак (CW_TX_BB_SIGN) замерен на эфире, а не выведен.

Развилка источника в UI — один список Keyer: firmware / software (PC) / off;
хранится прежней парой флагов, и на железе без кейера выбор «прошивка»
вырождается в программный, поэтому Pluto работает из коробки. Новое поле
caps HasCWKeyer (раньше это неявно жило в HasHWMic).

Попутно: у AD9361 calib_mode стоял manual_tx_quad, то есть драйвер не
переигрывал калибровку TX quad (она нулит утечку гетеродина) на сменах
TX LO — выставляем auto при подключении.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 17:38:54 +03:00
co-authored by Claude Opus 5
parent 827902f3e2
commit 25fbf10212
11 changed files with 1582 additions and 64 deletions
+122 -11
View File
@@ -289,7 +289,9 @@ type
FEdTXCTCSSFreq: TFlatFloatSpinEdit;
// CW (телеграф) — свойство устройства, профилем не переключается
FCW: TCWSettings;
FChkCWFWKeyer: TFlatCheckBox;
// Кто рисует точки: прошивка / программа / никто. Одна развилка — один
// список (две галки на неё разъезжались во взаимоисключающих состояниях).
FCmbCWKeyerSrc: TFlatComboBox;
FEdCWPitch: TFlatSpinEdit;
FEdCWSpeed: TFlatSpinEdit;
FEdCWWeight: TFlatSpinEdit;
@@ -304,6 +306,16 @@ type
FEdCWSidetoneHWLv: TFlatSpinEdit;
FChkCWSidetoneSW: TFlatCheckBox;
FEdCWSidetoneSWLv: TFlatSpinEdit;
// Локальный кейер: манипуляцию рисует сам EWSDR (обязателен на AD936x)
FEdCWOffset: TFlatSpinEdit;
FChkCWKeyPort: TFlatCheckBox;
FEdCWKeyPort: TFlatEdit;
FCmbCWDotLine: TFlatComboBox;
FCmbCWDashLine: TFlatComboBox;
FChkCWKeyInvert: TFlatCheckBox;
FChkCWKeyDTR: TFlatCheckBox;
FChkCWKeyRTS: TFlatCheckBox;
FCWHasFWKeyer: Boolean; // у железа есть кейер в прошивке
// TX Display (отдельный analyzer)
FCmbTXFFT: TFlatComboBox;
FCmbTXWindow: TFlatComboBox;
@@ -658,6 +670,8 @@ type
TcpEnabled: Boolean; TcpPort: Integer);
procedure LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0);
procedure LoadCWSettings(const C: TCWSettings);
// Есть ли у активного железа кейер в прошивке (openHPSDR — да, AD936x — нет).
procedure SetCWBackend(HasFWKeyer: Boolean);
// Список TX-профилей + активный + drive активного (ползунок DRV живёт в
// главном окне, здесь показываем его значение как часть профиля).
procedure LoadTXProfiles(const Names: array of string;
@@ -2042,9 +2056,12 @@ begin
// ---- CW: телеграфом в openHPSDR P2 управляет прошивка -------------------
Inc(Y, 190 + 12);
Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 320);
Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 460);
FChkCWFWKeyer := MkChkCW(Grp, PAD, R1 - 6, 250, 'Keyer in radio firmware');
MakeLbl(Grp, 'Keyer', PAD, R1 - 1, 50);
FCmbCWKeyerSrc := MkCmbCW(Grp, PAD + 54, R1 - 6, 194);
FCmbCWKeyerSrc.Items.AddStrings(['Radio firmware','Software (PC)','Off (MOX only)']);
FCmbCWKeyerSrc.ItemIndex := 0;
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);
@@ -2076,13 +2093,46 @@ begin
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);
// ---- Программный кейер: манипуляцию рисует сам EWSDR --------------------
MakeLbl(Grp, 'Carrier offset (Hz)', PAD, R1 + 5 * STEP + 5, LW);
FEdCWOffset := MkSpinCW(Grp, CX, R1 + 5 * STEP, -1, 48000, 0);
MakeLbl(Grp, '0 = auto, -1 = none (software keyer only)', PAD + 320, R1 + 5 * STEP + 5, 260);
Inc(Y, 320 + 12);
FChkCWKeyPort := MkChkCW(Grp, PAD, R1 + 6 * STEP, 250, 'Paddle on serial port');
FEdCWKeyPort := TFlatEdit.Create(Self);
FEdCWKeyPort.Parent := Grp;
FEdCWKeyPort.SetBounds(DpiScale(PAD + 260), DpiScale(R1 + 6 * STEP),
DpiScale(220), DpiScale(BTN_H));
FEdCWKeyPort.Color := CLR_INPUT;
FEdCWKeyPort.Font.Color := CLR_INPUT_TEXT;
FEdCWKeyPort.Font.Size := 9;
FEdCWKeyPort.OnExit := OnCWAnyChange;
MakeLbl(Grp, '/dev/ttyUSB0, COM3', PAD + 492, R1 + 6 * STEP + 5, 200);
MakeLbl(Grp, 'Dot line', PAD, R1 + 7 * STEP + 5, 70);
FCmbCWDotLine := MkCmbCW(Grp, PAD + 74, R1 + 7 * STEP, 90);
FCmbCWDotLine.Items.AddStrings(['CTS','DSR','DCD','RI']);
FCmbCWDotLine.ItemIndex := 0;
MakeLbl(Grp, 'Dash line', PAD + 176, R1 + 7 * STEP + 5, 74);
FCmbCWDashLine := MkCmbCW(Grp, PAD + 254, R1 + 7 * STEP, 90);
FCmbCWDashLine.Items.AddStrings(['CTS','DSR','DCD','RI']);
FCmbCWDashLine.ItemIndex := 1;
FChkCWKeyInvert := MkChkCW(Grp, PAD + 360, R1 + 7 * STEP, 90, 'Invert');
FChkCWKeyDTR := MkChkCW(Grp, PAD + 456, R1 + 7 * STEP, 100, 'DTR power');
FChkCWKeyRTS := MkChkCW(Grp, PAD + 562, R1 + 7 * STEP, 100, 'RTS power');
TxNote(Grp, 'Firmware keyer: the radio produces dot and dash timing, so PC '
+ 'load never affects it — the default where the hardware has one, and the '
+ 'paddle plugs into the radio. Software keyer: EWSDR draws the keyed '
+ 'carrier into the IQ stream itself (the only option on Pluto/LibreSDR) '
+ 'and the paddle comes in over a serial port; element lengths are counted '
+ 'in samples, so only the paddle input jitters, never the rhythm. Carrier '
+ 'offset 0 means auto: zero on openHPSDR, 12 kHz on zero-IF hardware, '
+ 'where LO leakage would otherwise sit right on the working frequency '
+ 'between elements. The voice transmit chain is not started in CW at all.',
R1 + 8 * STEP - 6, 92);
Inc(Y, 460 + 12);
Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250);
MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW);
@@ -2271,8 +2321,21 @@ begin
end;
procedure TSettingsForm.CollectCWFromUI;
var Src: Integer;
begin
if FChkCWFWKeyer <> nil then FCW.FWKeyer := FChkCWFWKeyer.Checked;
// Список «кто рисует точки» → пара флагов. На железе без кейера в прошивке
// первого пункта в списке нет вовсе, поэтому индексы сдвинуты на один.
if FCmbCWKeyerSrc <> nil then
begin
Src := FCmbCWKeyerSrc.ItemIndex;
if Src < 0 then Src := 0;
if not FCWHasFWKeyer then Inc(Src);
case Src of
0: begin FCW.FWKeyer := True; FCW.LocalKeyer := False; end; // прошивка
1: begin FCW.FWKeyer := True; FCW.LocalKeyer := True; end; // программа
else begin FCW.FWKeyer := False; FCW.LocalKeyer := False; end; // выключен
end;
end;
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;
@@ -2287,6 +2350,36 @@ begin
if FEdCWSidetoneHWLv <> nil then FCW.SidetoneHWLevel := FEdCWSidetoneHWLv.Value;
if FChkCWSidetoneSW <> nil then FCW.SidetoneSW := FChkCWSidetoneSW.Checked;
if FEdCWSidetoneSWLv <> nil then FCW.SidetoneSWLevel := FEdCWSidetoneSWLv.Value;
if FEdCWOffset <> nil then FCW.CarrierOffsetHz := FEdCWOffset.Value;
if FChkCWKeyPort <> nil then FCW.KeyPortEnabled := FChkCWKeyPort.Checked;
if FEdCWKeyPort <> nil then FCW.KeyPort := Copy(FEdCWKeyPort.Text, 1, 63);
if FCmbCWDotLine <> nil then FCW.KeyDotLine := EnsureRange(FCmbCWDotLine.ItemIndex, 0, 3);
if FCmbCWDashLine <> nil then FCW.KeyDashLine := EnsureRange(FCmbCWDashLine.ItemIndex, 0, 3);
if FChkCWKeyInvert <> nil then FCW.KeyInvert := FChkCWKeyInvert.Checked;
if FChkCWKeyDTR <> nil then FCW.KeyPowerDTR := FChkCWKeyDTR.Checked;
if FChkCWKeyRTS <> nil then FCW.KeyPowerRTS := FChkCWKeyRTS.Checked;
end;
procedure TSettingsForm.SetCWBackend(HasFWKeyer: Boolean);
// Гейт по железу: у AD936x кейера в прошивке нет вовсе, значит нет ни этого
// пункта в списке, ни аппаратного сайдтона (он живёт в наушниках самого
// трансивера) — телеграф там может быть только программным.
// ★Обязан отработать ДО LoadCWSettings: тот считает индекс списка по FCWHasFWKeyer.
begin
FCWHasFWKeyer := HasFWKeyer;
if FChkCWSidetoneHW <> nil then FChkCWSidetoneHW.Enabled := HasFWKeyer;
if FEdCWSidetoneHWLv <> nil then FEdCWSidetoneHWLv.Enabled := HasFWKeyer;
if FCmbCWKeyerSrc = nil then Exit;
FLoading := True; // правка Items дёргает OnChange — настройки трогать нельзя
try
FCmbCWKeyerSrc.Items.Clear;
if HasFWKeyer then FCmbCWKeyerSrc.Items.Add('Radio firmware');
FCmbCWKeyerSrc.Items.Add('Software (PC)');
FCmbCWKeyerSrc.Items.Add('Off (MOX only)');
FCmbCWKeyerSrc.ItemIndex := 0;
finally
FLoading := False;
end;
end;
procedure TSettingsForm.FireCWChange;
@@ -2307,11 +2400,21 @@ begin
end;
procedure TSettingsForm.LoadCWSettings(const C: TCWSettings);
var Src: Integer;
begin
FLoading := True;
try
FCW := C;
if FChkCWFWKeyer <> nil then FChkCWFWKeyer.Checked := C.FWKeyer;
if FCmbCWKeyerSrc <> nil then
begin
// Пара флагов → пункт списка. «Прошивка» на железе без кейера означает
// программный кейер: другого пути там нет, и врать в списке незачем.
if (not C.FWKeyer) and (not C.LocalKeyer) then Src := 2
else if C.LocalKeyer or (not FCWHasFWKeyer) then Src := 1
else Src := 0;
if not FCWHasFWKeyer then Dec(Src);
FCmbCWKeyerSrc.ItemIndex := EnsureRange(Src, 0, FCmbCWKeyerSrc.Items.Count - 1);
end;
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;
@@ -2326,6 +2429,14 @@ begin
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);
if FEdCWOffset <> nil then FEdCWOffset.Value := EnsureRange(C.CarrierOffsetHz, -1, 48000);
if FChkCWKeyPort <> nil then FChkCWKeyPort.Checked := C.KeyPortEnabled;
if FEdCWKeyPort <> nil then FEdCWKeyPort.Text := C.KeyPort;
if FCmbCWDotLine <> nil then FCmbCWDotLine.ItemIndex := EnsureRange(C.KeyDotLine, 0, 3);
if FCmbCWDashLine <> nil then FCmbCWDashLine.ItemIndex := EnsureRange(C.KeyDashLine, 0, 3);
if FChkCWKeyInvert <> nil then FChkCWKeyInvert.Checked := C.KeyInvert;
if FChkCWKeyDTR <> nil then FChkCWKeyDTR.Checked := C.KeyPowerDTR;
if FChkCWKeyRTS <> nil then FChkCWKeyRTS.Checked := C.KeyPowerRTS;
finally
FLoading := False;
end;