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
+194 -1
View File
@@ -50,7 +50,8 @@ uses
Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer;
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer,
CWDecoder;
// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
@@ -103,6 +104,11 @@ type
// Сырьё спектра/водопада наружу (фронтенды рисуют). Вызывается из DSP-потока.
TPixelDataEvent = procedure(const Pixels: array of Single; Count: Integer) of object;
// Лента телеграфа для окна-терминала: прибавка текста и чья она — принятая
// декодером или эхо собственной передачи (окно красит их по-разному).
// Зовётся из потока хозяина (ServiceCWDecoder), не из DSP.
TCWLogEvent = procedure(const S: string; IsTX: Boolean) of object;
// Снимок состояния для синхронизации нового фронтенда (Arduino/Web на connect).
TRadioSnapshot = record
VfoA, VfoB, CenterFreq, SpanHz: Double;
@@ -470,6 +476,15 @@ type
// Локальный кейер вооружён — считается в SyncCWKeyer, читается пейсингом
// частоты DUC (CWCarrierOffsetHz) и аттенюатором Pluto.
FCWLocalArmed: Boolean;
// ---- Декодер приёма (CWDecoder.pas) --------------------------------------
// Создаётся лениво вместе с окном-терминалом. Кормится из OnDemodAudioReady
// (до громкости и мьюта, без сайдтона), разбирается на таймере хозяина.
FCWDec: TCWDecoder;
// Лента расшифровки: последние знаки приёма (для полосы под спектром) и
// счётчик, по которому потребители понимают, что появилось новое.
FCWLogText: string;
FCWLogSeq: Int64;
FOnCWLog: TCWLogEvent;
FAlexSettings: TAlexSettings;
FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only)
// Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер.
@@ -1006,6 +1021,27 @@ type
procedure CWXSend(const Text: string);
procedure CWXAbort;
function CWXBusy: Boolean;
// ---- Окно-терминал -----------------------------------------------------
// Стереть последний НЕ ушедший в эфир знак (Backspace при наборе).
function CWXBackspace: Boolean;
// Что ещё стоит в очереди передачи (строка набора показывает именно это).
function CWXPendingText: string;
// Ключ с клавиатуры: при иамбике — лепестки, при прямом ключе — уровень.
// На кейере прошивки лепестков нет (их читает FPGA со своего разъёма),
// поэтому там клавиатура работает через бит CWX, то есть прямым ключом.
procedure CWKeyboardKey(Dot, Dash: Boolean);
// Разбор принимаемого телеграфа: включение и обслуживание (таймер хозяина).
procedure SetCWDecoder(On_: Boolean);
function CWDecoderOn: Boolean;
procedure ServiceCWDecoder;
// Последние знаки ленты (для полосы под спектром) и счётчик изменений.
function CWLogTail(MaxChars: Integer): string;
property CWLogSeq: Int64 read FCWLogSeq;
// Оценка скорости корреспондента и расстройка тона (индикация в терминале).
function CWDecSpeedWPM: Integer;
function CWDecToneOffsetHz: Integer;
function CWDecSignal: Boolean;
property OnCWLog: TCWLogEvent read FOnCWLog write FOnCWLog;
// ---- PureSignal ----
// Доступность PS у активного бэкенда/платы (Caps.HasPureSignal).
@@ -1345,7 +1381,11 @@ begin
FCWSender.AbortSending;
FreeAndNil(FCWSender);
end;
if Assigned(FCWDec) then FCWDec.Enabled := False; // кольцо больше не кормим
FreeEngines;
// Декодер — ПОСЛЕ движка: его кольцо наполняет DSP-поток, и освобождать
// объект, пока поток жив, нельзя.
if Assigned(FCWDec) then FreeAndNil(FCWDec);
FreeAndNil(FDeviceStore);
FreeAndNil(FSettings);
inherited Destroy;
@@ -1728,6 +1768,14 @@ begin
end
else if Assigned(FDMRDec) and FDMRDec.Enabled then
FDMRDec.SetEnabled(False);
// ★Телеграфный декодер кормим отсюда же: здесь аудио идёт ДО громкости и
// мьюта и БЕЗ программного сайдтона (он подмешивается позже, в OnAudioReady),
// зато уже после узкого CW-фильтра — лучшего предетектора не найти.
// На своей передаче молчим: в телеграфе приёмник намеренно жив, и собственный
// сигнал (особенно в полном дуплексе Pluto) забил бы ленту своей же работой.
if Assigned(FCWDec) and FCWDec.Enabled
and (FMode in [MODE_CWL, MODE_CWU]) and (not RadioKeyed) then
FCWDec.FeedAudio(Left, Count);
end;
procedure TRadioController.OnDMRAudioReady(const PCM: array of Single;
@@ -5902,6 +5950,19 @@ begin
// запрет передачи, разбирает SyncCWKeyer — одна дверь на всё).
PushCWLocalConfig;
SyncCWKeyer;
// Декодер слушает на том же pitch и стартует адаптацию со своей скорости.
// Создаём лениво прямо здесь: настройки — единственная дверь, через которую
// он включается (галка в настройках, кнопка в терминале, загрузка профиля).
if FCWSettings.Decoder and (not Assigned(FCWDec)) then
begin
FCWDec := TCWDecoder.Create;
FCWDec.SetSpeedHint(FCWSettings.Speed);
end;
if Assigned(FCWDec) then
begin
FCWDec.SetPitch(FCWSettings.Pitch);
FCWDec.Enabled := FCWSettings.Decoder;
end;
// Тон TUN в телеграфе равен pitch (несущая обязана лечь на VFO, см. SetTune):
// если крутят pitch прямо на настройке — обновляем живьём.
if FTuning and FWDSPReady and Assigned(FDSPEngine)
@@ -6136,6 +6197,126 @@ begin
if Assigned(FCWLocal) then FCWLocal.AbortText;
end;
function TRadioController.CWXBackspace: Boolean;
begin
if CWLocalSource then
Result := Assigned(FCWLocal) and FCWLocal.Backspace
else
Result := Assigned(FCWSender) and FCWSender.Backspace;
end;
function TRadioController.CWXPendingText: string;
begin
if CWLocalSource then
begin
if Assigned(FCWLocal) then Result := FCWLocal.PendingText else Result := '';
end
else
begin
if Assigned(FCWSender) then Result := FCWSender.PendingText else Result := '';
end;
end;
procedure TRadioController.CWKeyboardKey(Dot, Dash: Boolean);
// Манипулятор с клавиатуры. У локального генератора это те же лепестки, что с
// COM-порта — включая иамбик. У кейера прошивки лепестков нет: их читает FPGA
// со своего разъёма, а из программы доступен только бит CWX, то есть прямой
// ключ (это же ограничение у Thetis).
begin
if not CWTXActive then
begin
// Разоружены (не CW, запрет передачи) — ключ обязан быть отпущен, иначе
// несущая повиснет при выходе из режима с зажатой клавишей.
if Assigned(FCWLocal) then FCWLocal.Paddle(False, False);
CWXKeyEvent(False);
Exit;
end;
if CWLocalSource then
begin
EnsureCWLocal;
SyncCWKeyer;
FCWLocal.Paddle(Dot, Dash);
end
else
CWXKeyEvent(Dot or Dash);
end;
procedure TRadioController.SetCWDecoder(On_: Boolean);
begin
FCWSettings.Decoder := On_;
if On_ then
begin
if not Assigned(FCWDec) then FCWDec := TCWDecoder.Create;
FCWDec.SetPitch(FCWSettings.Pitch);
FCWDec.SetSpeedHint(FCWSettings.Speed); // старт адаптации со своей скорости
FCWDec.Reset;
FCWDec.Enabled := True;
end
else if Assigned(FCWDec) then
FCWDec.Enabled := False;
if FDevConnected and Assigned(FSettings) then
begin
FSettings.SaveCW(FDevMAC, FCWSettings);
FSettings.Save;
end;
end;
function TRadioController.CWDecoderOn: Boolean;
begin
Result := Assigned(FCWDec) and FCWDec.Enabled;
end;
procedure TRadioController.ServiceCWDecoder;
// Поток хозяина (таймер GUI / цикл демона): разбор накопленного аудио и сборка
// ленты. Здесь же эхо собственной передачи — чтобы в окне была цельная лента
// связи, а не только принятое.
var
S: string;
procedure Append(const Txt: string; IsTX: Boolean);
begin
if Txt = '' then Exit;
FCWLogText := FCWLogText + Txt;
if Length(FCWLogText) > 8192 then
FCWLogText := Copy(FCWLogText, Length(FCWLogText) - 8191, 8192);
Inc(FCWLogSeq, Length(Txt));
if Assigned(FOnCWLog) then FOnCWLog(Txt, IsTX);
end;
begin
// Эхо передачи забираем всегда: терминал показывает свою работу и при
// выключенном декодере.
if Assigned(FCWLocal) then Append(FCWLocal.TakeSentText, True);
if Assigned(FCWSender) then Append(FCWSender.TakeSentText, True);
if not Assigned(FCWDec) then Exit;
if not FCWDec.Enabled then Exit;
FCWDec.Process;
S := FCWDec.TakeText;
Append(S, False);
end;
function TRadioController.CWLogTail(MaxChars: Integer): string;
begin
if MaxChars <= 0 then Exit('');
if Length(FCWLogText) <= MaxChars then Result := FCWLogText
else Result := Copy(FCWLogText, Length(FCWLogText) - MaxChars + 1, MaxChars);
end;
function TRadioController.CWDecSpeedWPM: Integer;
begin
if Assigned(FCWDec) then Result := FCWDec.SpeedWPM else Result := 0;
end;
function TRadioController.CWDecToneOffsetHz: Integer;
begin
if Assigned(FCWDec) then Result := FCWDec.ToneOffsetHz else Result := 0;
end;
function TRadioController.CWDecSignal: Boolean;
begin
Result := Assigned(FCWDec) and FCWDec.SignalPresent;
end;
function TRadioController.MixSidetone(const Left, Right: array of Single;
Count: Integer): Boolean;
// DSP-поток. Генератор синуса на pitch с трапецеидальной огибающей — без неё
@@ -6943,6 +7124,18 @@ begin
FDSPEngine.SetCWPitch(FCWSettings.Pitch);
FDSPEngine.SetCWSpeed(FCWSettings.Speed);
end;
// Декодер приёма — по настройке этого устройства (создаётся лениво).
if FCWSettings.Decoder and (not Assigned(FCWDec)) then
begin
FCWDec := TCWDecoder.Create;
FCWDec.SetSpeedHint(FCWSettings.Speed);
end;
if Assigned(FCWDec) then
begin
FCWDec.SetPitch(FCWSettings.Pitch);
FCWDec.Reset;
FCWDec.Enabled := FCWSettings.Decoder;
end;
FSettings.LoadAlex(FDevMAC, FAlexSettings);
FSettings.LoadAnt936x(FDevMAC, FAnt936x);
FNetwork.SetAlexConfig(FAlexSettings);