feat(cw): терминал телеграфа — набор с клавиатуры и декодер приёма

Окно (ПКМ по CWL/CWU, F9, кнопка в настройках CW): одна лента, где принятое
декодером и СВОЯ передача идут вперемешку — своё акцентным цветом. Разделять их
нельзя: в QSK они перемежаются посреди фразы. Под лентой строка набора.

Печать уходит в эфир ПОСИМВОЛЬНО, а не по Enter: строка показывает ровно то,
что ещё не передано (очередь генератора), знаки уходят из неё по мере отправки,
Backspace стирает с хвоста очереди — то, что уже звучит, вернуть нельзя.
Ctrl работает манипулятором (левый точка, правый тире; у кейера прошивки
лепестков нет — там это прямой ключ через бит CWX), по умолчанию выключено,
чтобы Ctrl+C не уводил в эфир. Отпускание ключа ловится и на потере фокуса:
иначе уход из окна с зажатым Ctrl оставил бы несущую в эфире навсегда.

CWDecoder.pas: аудио → текст. Отвод берётся до громкости и мьюта — там нет
программного сайдтона, зато уже отработал узкий CW-фильтр, лучшего предетектора
не найти. Гёрцель гребёнкой из пяти бинов вокруг pitch (заодно показывает
расстройку) → огибающая → адаптивный порог с гистерезисом → длительности →
адаптивная точка → обратная таблица Морзе. Длительности живут в шагах анализа,
а не в показаниях часов: разбор идёт пачками с таймера, и привязка ко времени
вызова ломала бы тайминг на любой загрузке.

Вылезло на тестах и учтено: пик обязан клампиться не ниже пола шума (иначе на
старте порог уходит НИЖЕ шума и первым «знаком» читается собственный шум);
порог дребезга берётся от текущей точки, фиксированный либо пропускает щелчки
на медленной передаче, либо ест посылки на быстрой; длина посылки меряется за
вычетом подтверждения дребезга, иначе скорость занижалась на 15%; расстройка
запоминается только на полной амплитуде посылки, иначе индикатор пляшет.
Граница честная: при вдвое неверной подсказке скорости теряется первое слово —
пока не услышана настоящая точка, длина элемента неизвестна.

Лента расшифровки продублирована строкой под спектром (пан 0), эхо передачи
и очередь набора появились у обоих отправителей — и у локального генератора,
и у кейера прошивки.

★TFlatEdit получил публичный CaretPos: он вставляет знак сам в UTF8KeyPress и
гасит клавишу, поэтому OnKeyPress контрола не вызывается вовсе — из-за этого
набранное «исчезало», а в эфир не уходило. Ввод перенесён на уровень формы.

Проверено оффлайн: 12/20/40 WPM, расстройка, шум, слабый сигнал, цифры и знаки;
плюс сквозной прогон против шести станций CW-стенда в hpsdrsim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 21:28:48 +03:00
co-authored by Claude Opus 5
parent 25fbf10212
commit b02b68aa6a
11 changed files with 1755 additions and 20 deletions
+34 -16
View File
@@ -61,6 +61,7 @@ type
TOnTXSettingsChange = procedure(const T: TTXSettings) of object;
TOnCWSettingsChange = procedure(const C: TCWSettings) of object;
TOnCWMessagesOpen = procedure of object;
TOnCWTerminalOpen = procedure of object;
// TX-профили: выбор/переименование/удаление по индексу, создание — по имени.
TOnTXProfileIdx = procedure(Idx: Integer) of object;
TOnTXProfileName = procedure(const AName: string) of object;
@@ -312,6 +313,8 @@ type
FEdCWKeyPort: TFlatEdit;
FCmbCWDotLine: TFlatComboBox;
FCmbCWDashLine: TFlatComboBox;
FChkCWDecoder: TFlatCheckBox;
FChkCWDecStrip: TFlatCheckBox;
FChkCWKeyInvert: TFlatCheckBox;
FChkCWKeyDTR: TFlatCheckBox;
FChkCWKeyRTS: TFlatCheckBox;
@@ -479,6 +482,7 @@ type
FOnTXChange: TOnTXSettingsChange;
FOnCWChange: TOnCWSettingsChange;
FOnCWMessagesOpen: TOnCWMessagesOpen;
FOnCWTerminalOpen: TOnCWTerminalOpen;
FOnTXProfileSelect: TOnTXProfileIdx;
FOnTXProfileAdd: TOnTXProfileName;
FOnTXProfileRename: TOnTXProfileIdxName;
@@ -537,6 +541,7 @@ type
procedure FireCWChange;
procedure OnCWAnyChange(Sender: TObject);
procedure BtnCWMessagesClick(Sender: TObject);
procedure BtnCWTerminalClick(Sender: TObject);
procedure OnMicJackChange(Sender: TObject);
procedure UpdateMicJackVisibility;
// Подвкладки и полоса профиля на странице Transmit
@@ -720,6 +725,7 @@ type
property OnTXChange: TOnTXSettingsChange read FOnTXChange write FOnTXChange;
property OnCWChange: TOnCWSettingsChange read FOnCWChange write FOnCWChange;
property OnCWMessagesOpen: TOnCWMessagesOpen read FOnCWMessagesOpen write FOnCWMessagesOpen;
property OnCWTerminalOpen: TOnCWTerminalOpen read FOnCWTerminalOpen write FOnCWTerminalOpen;
property OnTXProfileSelect: TOnTXProfileIdx read FOnTXProfileSelect write FOnTXProfileSelect;
property OnTXProfileAdd: TOnTXProfileName read FOnTXProfileAdd write FOnTXProfileAdd;
property OnTXProfileRename: TOnTXProfileIdxName read FOnTXProfileRename write FOnTXProfileRename;
@@ -2056,15 +2062,16 @@ begin
// ---- CW: телеграфом в openHPSDR P2 управляет прошивка -------------------
Inc(Y, 190 + 12);
Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 460);
Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 502);
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);
MkBtn(Grp, PAD + 260, R1 - 6, 100, 'Messages...', BtnCWMessagesClick);
MkBtn(Grp, PAD + 366, R1 - 6, 100, 'Terminal...', BtnCWTerminalClick);
MakeLbl(Grp, 'Pitch (Hz)', PAD + 480, R1 - 1, 90);
FEdCWPitch := MkSpinCW(Grp, PAD + 574, R1 - 6, 200, 1200, 600);
MakeLbl(Grp, 'Keyer mode', PAD, R1 + STEP + 5, LW);
FCmbCWKeyerMode := MkCmbCW(Grp, CX, R1 + STEP, 200);
@@ -2097,29 +2104,31 @@ begin
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);
FChkCWDecoder := MkChkCW(Grp, PAD, R1 + 6 * STEP, 160, 'Decode RX');
FChkCWDecStrip := MkChkCW(Grp, PAD + 176, R1 + 6 * STEP, 210, 'Text under spectrum');
FChkCWKeyPort := MkChkCW(Grp, PAD, R1 + 6 * STEP, 250, 'Paddle on serial port');
FChkCWKeyPort := MkChkCW(Grp, PAD, R1 + 7 * STEP, 250, 'Paddle on serial port');
FEdCWKeyPort := TFlatEdit.Create(Self);
FEdCWKeyPort.Parent := Grp;
FEdCWKeyPort.SetBounds(DpiScale(PAD + 260), DpiScale(R1 + 6 * STEP),
FEdCWKeyPort.SetBounds(DpiScale(PAD + 260), DpiScale(R1 + 7 * 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, '/dev/ttyUSB0, COM3', PAD + 492, R1 + 7 * STEP + 5, 200);
MakeLbl(Grp, 'Dot line', PAD, R1 + 7 * STEP + 5, 70);
FCmbCWDotLine := MkCmbCW(Grp, PAD + 74, R1 + 7 * STEP, 90);
MakeLbl(Grp, 'Dot line', PAD, R1 + 8 * STEP + 5, 70);
FCmbCWDotLine := MkCmbCW(Grp, PAD + 74, R1 + 8 * 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);
MakeLbl(Grp, 'Dash line', PAD + 176, R1 + 8 * STEP + 5, 74);
FCmbCWDashLine := MkCmbCW(Grp, PAD + 254, R1 + 8 * 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');
FChkCWKeyInvert := MkChkCW(Grp, PAD + 360, R1 + 8 * STEP, 90, 'Invert');
FChkCWKeyDTR := MkChkCW(Grp, PAD + 456, R1 + 8 * STEP, 100, 'DTR power');
FChkCWKeyRTS := MkChkCW(Grp, PAD + 562, R1 + 8 * 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 '
@@ -2130,9 +2139,9 @@ begin
+ '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);
R1 + 9 * STEP - 6, 92);
Inc(Y, 460 + 12);
Inc(Y, 502 + 12);
Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250);
MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW);
@@ -2355,6 +2364,8 @@ begin
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 FChkCWDecoder <> nil then FCW.Decoder := FChkCWDecoder.Checked;
if FChkCWDecStrip <> nil then FCW.DecoderStrip := FChkCWDecStrip.Checked;
if FChkCWKeyInvert <> nil then FCW.KeyInvert := FChkCWKeyInvert.Checked;
if FChkCWKeyDTR <> nil then FCW.KeyPowerDTR := FChkCWKeyDTR.Checked;
if FChkCWKeyRTS <> nil then FCW.KeyPowerRTS := FChkCWKeyRTS.Checked;
@@ -2399,6 +2410,11 @@ begin
if Assigned(FOnCWMessagesOpen) then FOnCWMessagesOpen;
end;
procedure TSettingsForm.BtnCWTerminalClick(Sender: TObject);
begin
if Assigned(FOnCWTerminalOpen) then FOnCWTerminalOpen;
end;
procedure TSettingsForm.LoadCWSettings(const C: TCWSettings);
var Src: Integer;
begin
@@ -2434,6 +2450,8 @@ begin
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 FChkCWDecoder <> nil then FChkCWDecoder.Checked := C.Decoder;
if FChkCWDecStrip <> nil then FChkCWDecStrip.Checked := C.DecoderStrip;
if FChkCWKeyInvert <> nil then FChkCWKeyInvert.Checked := C.KeyInvert;
if FChkCWKeyDTR <> nil then FChkCWKeyDTR.Checked := C.KeyPowerDTR;
if FChkCWKeyRTS <> nil then FChkCWKeyRTS.Checked := C.KeyPowerRTS;