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
+125 -1
View File
@@ -43,7 +43,7 @@ uses
WinFirewall,
BoardUtils, WisdomBuilder, UISync,
FlatEdit, FMRepeater,
ChannelStore, ChannelsForm, CWMessagesForm, BeaconScopeForm, DMRDecoder,
ChannelStore, ChannelsForm, CWMessagesForm, CWTerminalForm, BeaconScopeForm, DMRDecoder,
PowerInhibit,
DeviceStore,
RadioBackend, PlutoBackend,
@@ -368,6 +368,7 @@ type
FTXProfileDropDown: TFlatDropDown;
FChannelsForm: TObject; // TChannelsForm (cast при использовании)
FCWMsgForm: TObject; // TCWMessagesForm (память телеграфа)
FCWTermForm: TObject; // TCWTerminalForm (лента + набор с клавиатуры)
FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON)
// ---- Right panel ----
@@ -473,6 +474,8 @@ type
procedure FreqDispBChanged(Sender: TObject; NewFreq: Int64);
procedure BtnBandClick(Sender: TObject);
procedure BtnModeClick(Sender: TObject);
procedure BtnModeMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure BtnFilterClick(Sender: TObject);
procedure BtnFilterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
@@ -766,6 +769,14 @@ type
procedure OnTXSettingsChange(const T: TTXSettings);
procedure OnCWSettingsChange(const C: TCWSettings);
procedure ShowCWMessages;
procedure ShowCWTerminal;
procedure ServiceCWTerminal; // таймер: разбор + обновление окна
procedure OnCWLogAppend(const S: string; IsTX: Boolean);
procedure OnCWTermBackspace(Sender: TObject);
procedure OnCWTermDecoder(Value: Boolean);
procedure OnCWTermKbdKey(Dot, Dash: Boolean);
procedure OnCWTermSpeed(WPM: Integer);
procedure OnCWTermMacro(Sender: TObject);
procedure OnCWMessageSend(const Text: string);
procedure OnCWMessageAbort(Sender: TObject);
procedure FormKeyDownCW(Sender: TObject; var Key: Word; Shift: TShiftState);
@@ -2423,6 +2434,7 @@ begin
LeftPanelButtonWidth(LEFT_W, 4, i mod 4),
BTN_SM, BtnModeClick);
B.Tag := i;
B.OnMouseDown := BtnModeMouseDown; // ПКМ по CWL/CWU → терминал
BtnMode[i] := B;
StyleButton(B, i = FController.FMode);
end;
@@ -3414,6 +3426,7 @@ begin
if FDeviceDialog <> nil then FDeviceDialog.SetTheme(T);
if FChannelsForm <> nil then TChannelsForm(FChannelsForm).ApplyTheme(T);
if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T);
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T);
if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T);
FController.FSettings.SaveTheme(V);
FController.FSettings.Save;
@@ -3691,6 +3704,8 @@ begin
// Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет — гнать индикацию
// и отпускать реле T/R после выдержки больше некому).
FController.ServiceCWKeyed;
// Телеграф: разбор принятого в текст + окно-терминал + лента под спектром.
ServiceCWTerminal;
// PureSignal: state machine команд + auto-attenuate + опрос GetPSInfo
// (no-op на бэкендах без PS; рендер кнопок/поповера через rfPureSignal).
FController.PureSignalTick;
@@ -5584,6 +5599,17 @@ begin
end;
end;
procedure TMainForm.BtnModeMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
// ПКМ по CWL/CWU открывает телеграфный терминал — по той же идиоме, что ПКМ по
// кнопке фильтра открывает редактор фильтров. ЛКМ не трогаем: переключение
// режима не должно тащить за собой окно.
begin
if Button <> mbRight then Exit;
if not ((Sender as TFlatButton).Tag in [MODE_CWL, MODE_CWU]) then Exit;
ShowCWTerminal;
end;
procedure TMainForm.BtnFilterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
// ПКМ по кнопке фильтра открывает редактор на этом слоте (эталон Thetis:
@@ -8169,6 +8195,96 @@ begin
TCWMessagesForm(FCWMsgForm).Show;
end;
procedure TMainForm.ShowCWTerminal;
begin
if FCWTermForm = nil then
begin
FCWTermForm := TCWTerminalForm.Create(Self);
TCWTerminalForm(FCWTermForm).OnSend := OnCWMessageSend;
TCWTerminalForm(FCWTermForm).OnAbort := OnCWMessageAbort;
TCWTerminalForm(FCWTermForm).OnBackspace := OnCWTermBackspace;
TCWTerminalForm(FCWTermForm).OnDecoder := OnCWTermDecoder;
TCWTerminalForm(FCWTermForm).OnKbdKey := OnCWTermKbdKey;
TCWTerminalForm(FCWTermForm).OnSpeed := OnCWTermSpeed;
TCWTerminalForm(FCWTermForm).OnMacro := OnCWTermMacro;
// Лента собирается в контроллере (там же эхо своей передачи) и приходит
// сюда прибавками — окно только рисует.
FController.OnCWLog := OnCWLogAppend;
end;
TCWTerminalForm(FCWTermForm).LoadFrom(FController.CWSettings);
TCWTerminalForm(FCWTermForm).ApplyTheme(CurrentAppTheme);
TCWTerminalForm(FCWTermForm).Show;
end;
procedure TMainForm.OnCWLogAppend(const S: string; IsTX: Boolean);
begin
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).AppendLog(S, IsTX);
end;
procedure TMainForm.OnCWTermBackspace(Sender: TObject);
begin
FController.CWXBackspace;
end;
procedure TMainForm.OnCWTermDecoder(Value: Boolean);
begin
FController.SetCWDecoder(Value);
end;
procedure TMainForm.OnCWTermKbdKey(Dot, Dash: Boolean);
begin
FController.CWKeyboardKey(Dot, Dash);
end;
procedure TMainForm.OnCWTermSpeed(WPM: Integer);
var C: TCWSettings;
begin
C := FController.CWSettings;
if C.Speed = WPM then Exit;
C.Speed := WPM;
FController.SetCWSettings(C);
end;
procedure TMainForm.OnCWTermMacro(Sender: TObject);
var Slot: Integer;
begin
Slot := TComponent(Sender).Tag;
if (Slot < 0) or (Slot >= CW_MSG_COUNT) then Exit;
if FController.CWSettings.Messages[Slot] = '' then Exit;
FController.CWXSend(FController.CWSettings.Messages[Slot]);
end;
procedure TMainForm.ServiceCWTerminal;
// Таймер: разбор принятого аудио + обновление окна и ленты под спектром.
// Разбор идёт ВСЕГДА, когда декодер включён, даже если окно закрыто: лента под
// спектром живёт сама по себе.
var
St: string;
begin
FController.ServiceCWDecoder;
if FCWTermForm <> nil then
begin
TCWTerminalForm(FCWTermForm).SetPending(FController.CWXPendingText);
if FController.CWDecoderOn then
begin
St := Format('RX %d WPM', [FController.CWDecSpeedWPM]);
if FController.CWDecToneOffsetHz <> 0 then
St := St + Format(', tone %+d Hz', [FController.CWDecToneOffsetHz]);
if not FController.CWDecSignal then St := St + ', no signal';
end
else
St := 'decoder off';
// Набор молча уходит в никуда, если телеграф не вооружён (не CW, запрет
// передачи на бэнде или в слоте трансвертера) — говорим об этом прямо.
if not FController.CWTXActive then St := St + ' | TX not armed';
TCWTerminalForm(FCWTermForm).SetStatus(St);
end;
if FPans[0] <> nil then
if FPans[0].SetCWStrip(FController.CWSettings.DecoderStrip and FController.CWDecoderOn,
FController.CWLogTail(300)) then
ResizeSpectrumPanels; // строка появилась/пропала — переложить пан
end;
procedure TMainForm.OnCWMessageSend(const Text: string);
begin
FController.CWXSend(Text);
@@ -8188,6 +8304,13 @@ procedure TMainForm.FormKeyDownCW(Sender: TObject; var Key: Word;
var Slot: Integer;
begin
if Shift <> [] then Exit;
// F9 — окно телеграфного терминала (лента приёма + набор с клавиатуры).
if Key = VK_F9 then
begin
Key := 0;
ShowCWTerminal;
Exit;
end;
if (Key < VK_F1) or (Key > VK_F8) then Exit;
if not (FController.ActiveTXMode in [MODE_CWL, MODE_CWU]) then Exit;
Slot := Key - VK_F1;
@@ -9124,6 +9247,7 @@ begin
SF.OnTXChange := OnTXSettingsChange;
SF.OnCWChange := OnCWSettingsChange;
SF.OnCWMessagesOpen := ShowCWMessages;
SF.OnCWTerminalOpen := ShowCWTerminal;
SF.OnTXProfileSelect := OnTXProfileSelectFromSettings;
SF.OnTXProfileAdd := OnTXProfileAddFromSettings;
SF.OnTXProfileRename := OnTXProfileRenameFromSettings;