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
+75
View File
@@ -115,6 +115,14 @@ type
FBtnAddPan: TFlatButton; // «⊞» в ряду пан/зума (только пан 0)
FHeaderVisible: Boolean;
FShowZoomRow: Boolean; // False у панов N>0 (пан-зум — этап 3.3+)
// Бегущая лента расшифровки телеграфа под спектром (только пан 0). Своя
// строка, а не окно: смотреть в эфир и читать расшифровку удобнее в одном
// месте, а полноценный терминал открывается отдельно.
FCWStripOn: Boolean;
FCWStripText: string;
FPbCWStrip: TPaintBox;
FThemeBG: TColor;
FThemeText: TColor;
FOnPanClose: TNotifyEvent;
FOnAddPan: TNotifyEvent;
FAddPanWanted: Boolean; // хозяин: показывать «⊞» в ряду пан/зума
@@ -152,6 +160,7 @@ type
Shift: TShiftState; X, Y: Integer);
procedure PanZoomDblClick(Sender: TObject);
procedure PositionPanZoomBar(X0, BottomY, RW, H: Integer; AVisible: Boolean);
procedure CWStripPaint(Sender: TObject);
public
// Обработчики мыши спектра/водопада и кликов зум-кнопок хозяина —
// задать ДО вызова Build (Build подвешивает их на создаваемые контролы).
@@ -245,6 +254,9 @@ type
property BtnAddPan: TFlatButton read FBtnAddPan;
// Ряд пан/зума: у панов N>0 скрыт (их зум — этап 3.3+).
property ShowZoomRow: Boolean read FShowZoomRow write FShowZoomRow;
// Лента расшифровки телеграфа: включить/выключить и обновить текст.
// True — состояние видимости изменилось, хозяину нужно переразложить пан.
function SetCWStrip(AOn: Boolean; const AText: string): Boolean;
// «⊞» в ряду пан/зума (пан 0): показать/спрятать решает хозяин.
property AddPanEnabled: Boolean read FAddPanWanted write FAddPanWanted;
// «▦/▤» тумблер грид-раскладки (пан 0, виден при >=2 доп. панах).
@@ -432,6 +444,12 @@ begin
FPbPanZoom.OnMouseUp := PanZoomMouseUp;
FPbPanZoom.OnDblClick := PanZoomDblClick;
FZoomBar.PaintBox := FPbPanZoom;
// ---- Лента расшифровки телеграфа (пан 0, включается хозяином) ----
FPbCWStrip := TPaintBox.Create(Self);
FPbCWStrip.Parent := AParent;
FPbCWStrip.OnPaint := CWStripPaint;
FPbCWStrip.Visible := False;
FBtnZoomOut := MakeFlatBtn(AParent, '', 0, 0, 10, 10, ZoomOutClick);
FBtnZoomDef := MakeFlatBtn(AParent, '⌂', 0, 0, 10, 10, ZoomDefClick);
FBtnZoomIn := MakeFlatBtn(AParent, '+', 0, 0, 10, 10, ZoomInClick);
@@ -501,6 +519,7 @@ begin
FSplitter.Parent := NewParent;
FPbWaterfall.Parent := NewParent;
FPbPanZoom.Parent := NewParent;
if FPbCWStrip <> nil then FPbCWStrip.Parent := NewParent;
FBtnZoomOut.Parent := NewParent;
FBtnZoomDef.Parent := NewParent;
FBtnZoomIn.Parent := NewParent;
@@ -668,6 +687,44 @@ begin
end;
end;
function TPanafallPanel.SetCWStrip(AOn: Boolean; const AText: string): Boolean;
// Хозяин зовёт с таймера. Раскладку трогаем только на смене видимости — текст
// меняется по несколько знаков в секунду, и дёргать Layout на каждый знак
// незачем.
begin
Result := AOn <> FCWStripOn;
FCWStripOn := AOn;
if AText <> FCWStripText then
begin
FCWStripText := AText;
if (FPbCWStrip <> nil) and FPbCWStrip.Visible then FPbCWStrip.Invalidate;
end;
end;
procedure TPanafallPanel.CWStripPaint(Sender: TObject);
// Хвост расшифровки, вытянутый по ширине: сколько знаков влезло, столько и
// показываем (лента, а не журнал — история живёт в окне терминала).
var
C: TCanvas;
Cols, ChW: Integer;
S: string;
begin
if FPbCWStrip = nil then Exit;
C := FPbCWStrip.Canvas;
C.Brush.Color := FThemeBG;
C.FillRect(0, 0, FPbCWStrip.Width, FPbCWStrip.Height);
C.Font.Name := {$IFDEF WINDOWS}'Consolas'{$ELSE}'Monospace'{$ENDIF};
C.Font.Size := 9;
C.Font.Color := FThemeText;
C.Brush.Style := bsClear;
ChW := C.TextWidth('W');
if ChW <= 0 then ChW := 8;
Cols := Max(1, (FPbCWStrip.Width - DpiScale(8)) div ChW);
S := FCWStripText;
if Length(S) > Cols then S := Copy(S, Length(S) - Cols + 1, Cols);
C.TextOut(DpiScale(4), DpiScale(2), S);
end;
procedure TPanafallPanel.PositionPanZoomBar(X0, BottomY, RW, H: Integer;
AVisible: Boolean);
var BtnW, Gap, StripW, X, NBtns: Integer; WantAdd, WantGrid: Boolean;
@@ -725,6 +782,8 @@ begin
if FHeaderVisible then Inc(Result, HEADER_H);
if (AShowSpectrum or AShowWaterfall) and FShowZoomRow then
Inc(Result, PANZOOM_H);
if FCWStripOn and (AShowSpectrum or AShowWaterfall) then
Inc(Result, DpiScale(20));
if AShowSpectrum then Inc(Result, MIN_SH + RULER_H);
if AShowWaterfall then
begin
@@ -746,6 +805,7 @@ var
RULER_H: Integer;
PANZOOM_H: Integer;
HEADER_H: Integer;
CWSTRIP_H: Integer;
ShowPZ: Boolean;
EffRatio: Double;
begin
@@ -784,6 +844,17 @@ begin
ShowPZ := (AShowSpectrum or AShowWaterfall) and FShowZoomRow;
PositionPanZoomBar(X0, RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ);
if ShowPZ then RH := RH - PANZOOM_H;
// Лента расшифровки телеграфа — ещё одной строкой над рядом пан/зума.
if FPbCWStrip <> nil then
begin
FPbCWStrip.Visible := FCWStripOn and (AShowSpectrum or AShowWaterfall);
if FPbCWStrip.Visible then
begin
CWSTRIP_H := DpiScale(20);
FPbCWStrip.SetBounds(X0, RH - CWSTRIP_H, RW, CWSTRIP_H);
RH := RH - CWSTRIP_H;
end;
end;
TopOff := ATopOffset + FStackTop;
if FHeaderVisible then Inc(TopOff, HEADER_H);
@@ -1533,6 +1604,10 @@ end;
procedure TPanafallPanel.SetFlagsTheme(const T: TAppTheme);
var i: Integer;
begin
// Лента расшифровки рисуется своими руками — цвета держим здесь же.
FThemeBG := T.Panel;
FThemeText := T.Text;
if (FPbCWStrip <> nil) and FPbCWStrip.Visible then FPbCWStrip.Invalidate;
if Assigned(FMainFlag) then FMainFlag.SetTheme(T);
for i := 0 to High(FSliceFlags) do
if Assigned(FSliceFlags[i]) then FSliceFlags[i].SetTheme(T);