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
+374 -46
View File
@@ -50,7 +50,7 @@ uses
Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse;
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer;
// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
@@ -457,6 +457,19 @@ type
FCWSTEnv: Double; // огибающая 0..1, сглаживает щелчки (DSP-поток)
FCWSTBufL: array of Single; // буферы подмешивания (только DSP-поток)
FCWSTBufR: array of Single;
FCWSTEnvBuf: array of Single; // огибающая от локального кейера (DSP-поток)
// ---- Локальный кейер (CWKeyer.pas) --------------------------------------
// Генератор несущей с огибающей прямо в поток IQ. Обязателен там, где кейера
// в железе нет (Pluto/LibreSDR), и опционален у openHPSDR. Оба объекта
// создаются лениво — кто телеграфом не пользуется, лишних нитей не получает.
FCWLocal: TCWLocalKeyer;
FCWKeyPort: TCWKeyPort;
// Сессия локального кейера подняла передачу сама (break-in): по этому флагу
// её снимаем и не путаем с ручным MOX.
FCWLocalTX: Boolean;
// Локальный кейер вооружён — считается в SyncCWKeyer, читается пейсингом
// частоты DUC (CWCarrierOffsetHz) и аттенюатором Pluto.
FCWLocalArmed: Boolean;
FAlexSettings: TAlexSettings;
FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only)
// Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер.
@@ -578,6 +591,11 @@ type
// uplink-LO не подвержен дрейфу LNB).
function XvtrTranslateTX(VisibleHz: Double): Double;
function ActiveTXFreqHz: Double;
// TXTuneFreqHz — ЕДИНАЯ дверь «какую частоту передачи класть в железо»:
// ActiveTXFreqHz через трансвертер, минус сдвиг несущей локального кейера
// (CWCarrierOffsetHz). Прямых XvtrTranslateTX(ActiveTXFreqHz) в пушах
// состояния быть не должно — иначе телеграф уедет от VFO ровно на сдвиг.
function TXTuneFreqHz: Double;
// Режим передающего тракта: слайс-источник TX (FTxSliceId) либо главный FMode.
function ActiveTXMode: Integer;
// Диапазон для калибровки драйва/антенны TX (банд TX-слайса при кросс-банде).
@@ -616,10 +634,22 @@ type
// (FCurrentBand при входе в слот не меняется).
function XvtrSlotActive: Boolean;
function TXProhibited: Boolean; // DoNotTx-бэнд / RX-only XVTR
function CWTXActive: Boolean; // передачей владеет FPGA-кейер
function CWTXActive: Boolean; // манипуляцией владеет не MOX
function CWLocalSource: Boolean; // манипуляцию рисует PC
function CWFWSource: Boolean; // манипуляцию рисует прошивка
function CWOptionsByte: Byte; // DUC Specific байт 5
procedure SyncCWKeyer; // байт 5 под текущий TX-режим
procedure CWXKeyEvent(Down: Boolean); // колбэк потока передачи
// Сдвиг несущей манипуляции от гетеродина (уход от утечки LO у zero-IF).
// На него же уезжает DUC/LO, чтобы РЧ встала ровно на VFO — см. TXTuneFreqHz.
function CWCarrierOffsetHz: Double;
// ---- Локальный кейер ----------------------------------------------------
procedure EnsureCWLocal; // ленивое создание генератора
procedure PushCWLocalConfig; // настройки → генератор/порт
procedure CWLocalIQ(const Buf: array of Double; Count: Integer);
procedure CWLocalSession(Active: Boolean); // T/R под сессию манипуляции
procedure CWLocalTick; // «ключ живой» (индикация)
procedure CWKeyPortEvent(Dot, Dash: Boolean); // лепестки с COM-порта
// Подмешать программный сайдтон в блок RX-аудио (DSP-поток). Возвращает
// False, если подмешивать нечего — тогда вызывающий отдаёт исходный блок.
function MixSidetone(const Left, Right: array of Single;
@@ -964,6 +994,9 @@ type
// В телеграфе FTransmitting не поднимается: передачей владеет кейер прошивки,
// и единственный признак эфира — HPS_PTT в статусе.
function RadioKeyed: Boolean;
// Фронты RadioKeyed → индикация. Зовётся из разбора HP-статуса (openHPSDR) и
// с UI-таймера: у Pluto статуса нет вовсе, а телеграф там локальный.
procedure ServiceCWKeyed;
function CWSettings: TCWSettings;
procedure SetCWSettings(const C: TCWSettings);
// Программная передача текста: PC сам выдерживает тайминги и дёргает бит
@@ -1298,7 +1331,15 @@ end;
destructor TRadioController.Destroy;
begin
// Поток передачи телеграфа гасим ПЕРВЫМ: его колбэк ключа лезет в бэкенд.
// Потоки телеграфа гасим ПЕРВЫМИ: их колбэки лезут в бэкенд и в движок.
// Порядок — порт ключа → генератор: иначе лепесток успел бы разбудить уже
// разрушаемый генератор.
if Assigned(FCWKeyPort) then FreeAndNil(FCWKeyPort);
if Assigned(FCWLocal) then
begin
FCWLocal.SetArmed(False);
FreeAndNil(FCWLocal);
end;
if Assigned(FCWSender) then
begin
FCWSender.AbortSending;
@@ -1334,7 +1375,7 @@ begin
// Состояние передачи для ЖЕЛЕЗА — RadioKeyed, а не FTransmitting: в
// телеграфе ключ замыкает прошивка, и реле T/R с антенной и OC обязаны
// ехать за ней. PTT в кадре этим не поднимается (он живёт в SendPTT).
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, RadioKeyed, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP;
end;
@@ -1604,28 +1645,10 @@ begin
end;
FHWPTTActive := FHPpHWPTT;
if FHPpHWPTT then FCWKeyedAt := GetTickCount64;
// UI про телеграфную передачу иначе не узнает вовсе: SetMOX здесь не
// вызывается, а значит и rfTransmitting никто не шлёт. Фронт RadioKeyed
// (с выдержкой) — единственный сигнал «покрасить бейджи и полосы».
if FCWKeyedShown <> RadioKeyed then
begin
FCWKeyedShown := RadioKeyed;
// ★Железная обвязка обязана идти за фактическим ключом: реле T/R (Alex
// bit 27), TX-антенна, bypass/Ext1 и выходы OC (ими ключуется усилитель)
// считаются в бэкенде от состояния передачи. Без этого пути прошивка
// гнала бы несущую, пока Alex стоит в ПРИЁМНОЙ конфигурации.
PushNetworkState;
Changed(rfTransmitting);
end;
ServiceCWKeyed;
Exit;
end;
// Ушли из телеграфа/сняли вооружение — вернуть индикацию в исходное.
if FCWKeyedShown then
begin
FCWKeyedShown := False;
PushNetworkState;
Changed(rfTransmitting);
end;
ServiceCWKeyed; // ушли из телеграфа — вернуть индикацию в исходное
// Hardware PTT (footswitch / mic PTT): фронт на rate HP Status — не пропустить
// короткое нажатие. HWPTT не перекрывает ручной MOX; выключаем TX только если
// его включил именно HWPTT.
@@ -3080,6 +3103,26 @@ begin
Result := Result + FDSPEngine.CWLOOffset(ActiveTXMode);
end;
function TRadioController.TXTuneFreqHz: Double;
// Единая дверь «какую частоту передачи класть в железо». Кроме трансвертера
// учитывает сдвиг несущей локального кейера: манипуляция идёт не на нуле
// бейсбенда, а на CWCarrierOffsetHz, значит гетеродин обязан уехать НАВСТРЕЧУ,
// иначе телеграф уйдёт в эфир мимо VFO ровно на этот сдвиг.
// ★Сдвиг применяется, пока кейер ВООРУЖЁН, а не только на передаче: железо
// может замкнуть ключ в любой момент (тот же урок, что был с частотой DUC).
//
// ★★ЗНАК — ЗАМЕРЕН НА ЭФИРЕ (Pluto, 2026-08-10), а не выведен: положительная
// частота бейсбенда в нашей конвенции выходит в эфир НИЖЕ гетеродина
// (РЧ = LO + CW_TX_BB_SIGN·f). Первая версия считала наоборот, и несущая вставала
// на 2×сдвига НИЖЕ маркера. Отсюда гетеродин уезжает ВВЕРХ на величину сдвига.
// Если когда-нибудь понадобится ненулевой сдвиг у openHPSDR — проверить там
// отдельно: тракт другой (цифровой DUC вместо zero-IF), и знак может отличаться.
const
CW_TX_BB_SIGN = -1;
begin
Result := XvtrTranslateTX(ActiveTXFreqHz) - CW_TX_BB_SIGN * CWCarrierOffsetHz;
end;
function TRadioController.ActiveTXMode: Integer;
var S: TCtrlSlice;
begin
@@ -4498,8 +4541,13 @@ const
begin
if (FNetwork = nil) or not IsPluto then Exit;
// На передаче (MOX/TUN) — мощность от drive; на приёме — макс. аттенюация (выкл).
if FTransmitting or FTuning then FNetwork.SetTxAtten(PlutoTxAttForDrive)
else FNetwork.SetTxAtten(TX_OFF);
// ★В телеграфе FTransmitting не поднимается вовсе (передачей владеет кейер),
// поэтому спрашиваем и сессию локального генератора — иначе манипуляция шла бы
// в закрытый аттенюатор, то есть в никуда.
if FTransmitting or FTuning or FCWLocalTX then
FNetwork.SetTxAtten(PlutoTxAttForDrive)
else
FNetwork.SetTxAtten(TX_OFF);
end;
procedure TRadioController.SetPlutoTxMaxAtt(Db: Double);
@@ -5134,15 +5182,64 @@ begin
and FXvtrSettings.Entries[FCurrentXvtr].RXOnly);
end;
function TRadioController.CWTXActive: Boolean;
// «Телеграфом управляет железо»: режим ИСТОЧНИКА передачи — CW и кейер в
// прошивке. В этом состоянии голосовой TXA не запускается вовсе (эталон
// Thetis, console.cs:29570), а точки/тире формирует FPGA.
function TRadioController.CWFWSource: Boolean;
// Манипуляцию рисует ПРОШИВКА (кейер в FPGA, openHPSDR P2). Путь по умолчанию:
// тайминги в железе, джиттер PC не влияет вовсе.
begin
Result := (ActiveTXMode in [MODE_CWL, MODE_CWU]) and FCWSettings.FWKeyer
Result := BackendCaps.HasCWKeyer and FCWSettings.FWKeyer
and (not FCWSettings.LocalKeyer);
end;
function TRadioController.CWLocalSource: Boolean;
// Манипуляцию рисует САМ EWSDR (CWKeyer.pas). Выбирается списком в настройках, а
// на железе без кейера в прошивке (Pluto/LibreSDR — голый AD936x) выбор
// «прошивка» вырождается сюда: другого пути там просто нет.
// ★Оба источника False = кейер выключен намеренно (третий пункт списка): тогда
// телеграф идёт только через MOX, и ключевать некому.
begin
Result := FCWSettings.LocalKeyer
or ((not BackendCaps.HasCWKeyer) and FCWSettings.FWKeyer);
end;
function TRadioController.CWTXActive: Boolean;
// «Передачей в телеграфе владеет не MOX»: режим ИСТОЧНИКА передачи — CW и
// манипуляция кому-то поручена (прошивке или локальному генератору). В этом
// состоянии голосовой TXA не запускается вовсе (эталон Thetis, console.cs:29570),
// а несущую даёт кейер / бит CWX / тон TUN.
begin
Result := (ActiveTXMode in [MODE_CWL, MODE_CWU])
and (CWFWSource or CWLocalSource)
and (not TXProhibited); // где передавать нельзя — кейер разоружён
end;
function TRadioController.CWCarrierOffsetHz: Double;
// Сдвиг несущей манипуляции от гетеродина — и в генераторе, и в частоте DUC/LO.
// ★У zero-IF (AD936x) ноль бейсбенда = частота гетеродина, и ТАМ ЖЕ сидит его
// утечка. Держи несущую на нуле — между посылками в эфир пойдёт слабый backwave
// РОВНО на рабочей частоте (самое неприятное, что бывает в телеграфе). Поэтому
// у Pluto манипуляция идёт на сдвиге, а LO уезжает навстречу (знак — в
// TXTuneFreqHz, он замерен на эфире). У openHPSDR DUC цифровой, утекать
// нечему — сдвиг 0.
const
CW_AUTO_OFFSET_HZ = 12000;
begin
Result := 0;
if not FCWLocalArmed then Exit;
// TUN/2TON идут через TXA (несущую даёт SSB-модулятор из тона), их частота
// уже посчитана в ActiveTXFreqHz — сдвигать нечего.
if FTuning or FTwoTone then Exit;
// −1 = «без сдвига» ЯВНО (несущая прямо на гетеродине). Нужно не только для
// отладки: сдвиг разносит TX LO и RX LO на единицы-десятки килогерц, а два
// fractional-N синтезатора в одном кристалле на близких частотах тянут друг
// друга и дают симметричные боковые вокруг несущей. Что хуже — утечка LO на
// рабочей частоте или эти боковые, решает оператор на своём железе.
if FCWSettings.CarrierOffsetHz < 0 then Exit;
if FCWSettings.CarrierOffsetHz > 0 then
Result := FCWSettings.CarrierOffsetHz
else if not BackendCaps.HasCWKeyer then
Result := CW_AUTO_OFFSET_HZ;
end;
function TRadioController.CWOptionsByte: Byte;
// DUC Specific байт 5. Порт pihpsdr new_protocol.c:1353-1383 (там же
// объяснение, почему iambic-бит выставляется и для Mode B).
@@ -5150,7 +5247,10 @@ function TRadioController.CWOptionsByte: Byte;
// на замыкание ключа посреди SSB (Thetis, console.cs:27394).
begin
Result := 0;
if not CWTXActive then Exit;
// ★Байт обязан быть нулевым не только вне телеграфа, но и когда манипуляцию
// взял на себя локальный генератор: иначе прошивка ключевала бы ПАРАЛЛЕЛЬНО
// с ним (и поднимала бы PTT на замыкание своего разъёма).
if not (CWTXActive and CWFWSource) then Exit;
Result := Result or CW_MODE;
if FCWSettings.ReverseKeys then Result := Result or CW_REVERSE_KEYS;
case FCWSettings.KeyerMode of
@@ -5573,7 +5673,7 @@ begin
if FTransmitting and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
FNetwork.ClearDUCIQQueue;
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, True, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP; // 1й пакет: Alex bit27=1 (T/R relay), drive — без PTT
FNetwork.SendPTT(True); // 2й пакет: PTT=1 после переключения реле
@@ -5666,13 +5766,16 @@ begin
FNetwork.SendPTT(False); // 1й пакет: PTT=0 — сначала снимаем PTT
// PureSignal: вернуть DDC0/1 в RX-конфигурацию (убрать feedback-DDC).
FNetwork.SetPureSignalTX(False);
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, False, True, True, FTuning, FTwoTone);
FNetwork.ClearDUCIQQueue;
FNetwork.SendFullHP; // 2й пакет: Alex bit27=0 (T/R relay RX)
end;
ApplyPlutoTxAtten; // Pluto: TX-мощность по drive на передаче, OFF на приёме
ApplyActiveVolume; // T/R: переключаем громкость RX ↔ TX-монитор (если monitoring)
// При выключенном break-in локальный кейер вооружён только вместе с MOX
// (оператор держит T/R руками) — вооружение обязано пойти за этой кнопкой.
SyncCWKeyer;
Changed(rfTransmitting);
end;
@@ -5712,7 +5815,7 @@ begin
ApplyWDSPDrive(FDrivePercent);
if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, FTransmitting, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP;
end;
@@ -5747,6 +5850,30 @@ begin
Result := (GetTickCount64 - FCWKeyedAt) < Hold;
end;
procedure TRadioController.ServiceCWKeyed;
// UI про телеграфную передачу иначе не узнает вовсе: SetMOX в телеграфе не
// вызывается, а значит и rfTransmitting никто не шлёт. Фронт RadioKeyed (с
// выдержкой) — единственный сигнал «покрасить бейджи и полосы».
// Зовётся из разбора HP-статуса (openHPSDR, быстро) и с UI-таймера — у Pluto
// статуса нет вообще, а телеграф там локальный.
begin
// Вооружение кейера обязано быть САМОВОССТАНАВЛИВАЮЩИМСЯ: у openHPSDR это
// делает разбор HP-статуса, а у Pluto статуса нет — значит здесь. Разоружить
// может что угодно (бэнд, слот трансвертера, выход из TUN), перечислять такие
// точки поимённо — гарантия однажды пропустить очередную.
SyncCWKeyer;
if FCWKeyedShown = RadioKeyed then Exit;
FCWKeyedShown := RadioKeyed;
// ★Железная обвязка обязана идти за фактическим ключом: реле T/R (Alex
// bit 27), TX-антенна, bypass/Ext1 и выходы OC (ими ключуется усилитель)
// считаются в бэкенде от состояния передачи. Без этого пути прошивка гнала бы
// несущую, пока Alex стоит в ПРИЁМНОЙ конфигурации. У локального кейера то же
// делает сессия (CWLocalSession) — синхронно, до первой посылки; здесь остаётся
// хвост выдержки, когда реле пора отпустить.
PushNetworkState;
Changed(rfTransmitting);
end;
function TRadioController.CWSettings: TCWSettings;
begin
Result := FCWSettings;
@@ -5771,7 +5898,10 @@ begin
if Assigned(FCWSender) then
FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight);
SendDUCSpecificFromSettings;
if Assigned(FNetwork) then FNetwork.SetCWKeyerArmed(CWTXActive);
// Локальный кейер: параметры + вооружение (галку «кейер в программе», как и
// запрет передачи, разбирает SyncCWKeyer — одна дверь на всё).
PushCWLocalConfig;
SyncCWKeyer;
// Тон TUN в телеграфе равен pitch (несущая обязана лечь на VFO, см. SetTune):
// если крутят pitch прямо на настройке — обновляем живьём.
if FTuning and FWDSPReady and Assigned(FDSPEngine)
@@ -5788,6 +5918,8 @@ procedure TRadioController.SyncCWKeyer;
// Байт 5 зависит от режима ИСТОЧНИКА передачи, поэтому каждая смена режима или
// TX-слайса обязана его перепослать: вне телеграфа он должен стать нулевым,
// иначе прошивка поднимет PTT на замыкание ключа посреди SSB.
var
WasArmed: Boolean;
begin
// Дёшево и идемпотентно: пара сравнений плюс ранний выход внутри
// SetCWKeyerArmed. Поэтому зовётся не только на смене режима/источника, но и
@@ -5798,11 +5930,167 @@ begin
if (not (ActiveTXMode in [MODE_CWL, MODE_CWU]))
and Assigned(FCWSender) and FCWSender.Busy then CWXAbort;
// Вооружённый кейер = железо может выйти в эфир само ⇒ бэкенд обязан класть
// уровень мощности в КАЖДЫЙ HP-кадр, а не только на передаче.
// уровень мощности в КАЖДЫЙ HP-кадр, а не только на передаче. Локальный
// генератор — та же история: FTransmitting в телеграфе не поднимается, а
// несущая идёт.
if Assigned(FNetwork) then FNetwork.SetCWKeyerArmed(CWTXActive);
// ---- Локальный кейер ----------------------------------------------------
// Вооружение самовосстанавливающееся, как и у прошивки: SyncCWKeyer зовётся
// из разбора статуса и со всех смен режима/источника/бэнда.
WasArmed := FCWLocalArmed;
// Без break-in передачей владеет оператор: ключ имеет право звучать только
// когда MOX уже поднят — иначе несущая ушла бы в приёмную обвязку.
// ★TUN и 2TON исключены намеренно: там несущую даёт TXA, и в DUC-очередь уже
// пишет поток WDSP. Двух продюсеров одной очереди быть не должно.
FCWLocalArmed := CWTXActive and CWLocalSource
and FDevConnected and FRunning
and (FCWSettings.BreakIn or FTransmitting)
and (not FTuning) and (not FTwoTone);
if FCWLocalArmed and (not Assigned(FCWLocal)) then EnsureCWLocal;
if Assigned(FCWLocal) then
begin
if FCWLocalArmed then PushCWLocalConfig;
FCWLocal.SetArmed(FCWLocalArmed);
end;
if Assigned(FCWKeyPort) then
if not FCWLocalArmed then CWKeyPortEvent(False, False);
// Частота DUC/LO зависит от вооружения (сдвиг несущей) — на фронте
// перекладываем железо, иначе манипуляция уехала бы от VFO на сдвиг.
if WasArmed <> FCWLocalArmed then PushNetworkState;
if CWOptionsByte <> FLastCWOptions then SendDUCSpecificFromSettings;
end;
procedure TRadioController.EnsureCWLocal;
// Ленивое создание: у тех, кто телеграфом не пользуется, лишних нитей нет.
begin
if not Assigned(FCWLocal) then
begin
FCWLocal := TCWLocalKeyer.Create;
FCWLocal.OnIQ := CWLocalIQ;
FCWLocal.OnSession := CWLocalSession;
FCWLocal.OnTick := CWLocalTick;
end;
if not Assigned(FCWKeyPort) then
begin
FCWKeyPort := TCWKeyPort.Create;
FCWKeyPort.OnKey := CWKeyPortEvent;
end;
PushCWLocalConfig;
end;
procedure TRadioController.PushCWLocalConfig;
// Настройки → генератор и порт манипулятора. Дёшево и идемпотентно: зовётся из
// SyncCWKeyer (то есть с каждой смены режима/бэнда) и из SetCWSettings.
var
C: TCWLocalConfig;
P: TCWKeyPortConfig;
begin
if not Assigned(FCWLocal) then Exit;
FillChar(C, SizeOf(C), 0);
if FWDSPReady and Assigned(FDSPEngine) then C.Rate := FDSPEngine.TXSampleRate
else C.Rate := TX_SAMPLE_RATE;
C.OffsetHz := CWCarrierOffsetHz;
// Уровень несущей. У Pluto мощность задаёт аттенюатор, цифровой уровень
// держим на максимуме; у openHPSDR цифровой drive работает вместе с
// байтом 345 — так же, как WDSP-драйв голосового тракта (ApplyWDSPDrive).
if IsPluto then C.Amplitude := 0.99
else C.Amplitude := 0.99 * EnsureRange(FDrivePercent, 0, 100) / 100.0;
C.WPM := FCWSettings.Speed;
C.Weight := FCWSettings.Weight;
C.RampMS := FCWSettings.RampMS;
case FCWSettings.KeyerMode of
1: C.Mode := clmIambicA;
2: C.Mode := clmIambicB;
else C.Mode := clmStraight;
end;
C.Reverse := FCWSettings.ReverseKeys;
C.Strict := FCWSettings.StrictSpacing;
C.BreakIn := FCWSettings.BreakIn;
C.HangMS := FCWSettings.HangTimeMS;
// Реле T/R, LO передатчика (Pluto гасит его на приёме) и аттенюатор успевают
// встать за это окно. Ноль у прошивки означает «железо знает само», у нас
// ждать некому — держим минимум.
C.RFDelayMS := Max(FCWSettings.RFDelayMS, 10);
C.Sidetone := FCWSettings.SidetoneSW and (FCWSettings.SidetoneSWLevel > 0);
FCWLocal.Configure(C);
if Assigned(FCWKeyPort) then
begin
FillChar(P, SizeOf(P), 0);
P.Enabled := FCWSettings.KeyPortEnabled and (FCWSettings.KeyPort <> '');
P.Port := FCWSettings.KeyPort;
P.DotLine := TCWKeyLine(EnsureRange(FCWSettings.KeyDotLine, 0, 3));
P.DashLine := TCWKeyLine(EnsureRange(FCWSettings.KeyDashLine, 0, 3));
P.Invert := FCWSettings.KeyInvert;
P.PowerDTR := FCWSettings.KeyPowerDTR;
P.PowerRTS := FCWSettings.KeyPowerRTS;
FCWKeyPort.Configure(P);
end;
end;
procedure TRadioController.CWLocalIQ(const Buf: array of Double; Count: Integer);
// Поток генератора. Тот же маршрут, что у WDSP TXA (OnTXIQ): пары копятся в
// DUC-пакеты и уходят в бэкенд. Голосовой тракт в телеграфе стоит, так что
// продюсер тут ровно один.
begin
OnTXIQ(Buf, Count);
end;
procedure TRadioController.CWLocalSession(Active: Boolean);
// Поток генератора. Сессия = одна передача (не одна посылка!): поднимаем PTT,
// реле T/R с антенной и OC, аттенюатор Pluto — и держим до конца hang.
// ★FTransmitting НЕ трогаем: в телеграфе передающий тракт WDSP не запускается,
// а вся индикация идёт через RadioKeyed (см. ApplyHPStatus/ServiceCWKeyed).
begin
if Active then FCWKeyedAt := GetTickCount64;
FCWLocalTX := Active;
if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
if Active then
begin
PushNetworkState; // RadioKeyed=True: Alex bit27, TX-антенна, OC
FNetwork.SendPTT(True); // у Pluto здесь же поднимается LO передатчика
end
else
begin
// ★Если оператор ДЕРЖИТ MOX (ручной T/R поверх break-in), снимать передачу
// не наше дело — иначе конец посылки выбрасывал бы его из эфира.
if not FTransmitting then
begin
FNetwork.SendPTT(False);
FNetwork.ClearDUCIQQueue;
end;
FCWKeyedAt := GetTickCount64; // выдержка RadioKeyed идёт от КОНЦА сессии
PushNetworkState;
end;
end;
ApplyPlutoTxAtten;
// ★Индикацию отсюда НЕ дёргаем: Invoke у GUI — это TThread.Synchronize, то
// есть блокировка потока генератора до кадра UI. Мало того что она вклинилась
// бы в путь к эфиру, так ещё и давала бы классический клинч на выходе из
// программы (UI ждёт WaitFor генератора, генератор — UI). Фронты снимет
// ServiceCWKeyed с таймера хозяина: для бейджа задержка в кадр незаметна,
// а выдержка RadioKeyed всё равно не меньше 500 мс.
end;
procedure TRadioController.CWLocalTick;
// Поток генератора, на каждом блоке (1.25 мс): держим «железо в эфире» живым,
// иначе выдержка RadioKeyed истекла бы посреди длинного сообщения.
begin
FCWKeyedAt := GetTickCount64;
end;
procedure TRadioController.CWKeyPortEvent(Dot, Dash: Boolean);
// Поток опроса порта. Касание манипулятора обрывает программную передачу —
// оператор всегда может вклиниться (то же правило, что и у кейера прошивки).
begin
if not Assigned(FCWLocal) then Exit;
if (Dot or Dash) and FCWLocal.Busy then FCWLocal.AbortText;
FCWLocal.Paddle(Dot, Dash);
end;
procedure TRadioController.CWXKeyEvent(Down: Boolean);
// Поток передачи, НЕ GUI. Обе операции потокобезопасны: SetCWXKey шлёт HP-пакет
// под своим локом бэкенда, FCWKeyDown читает DSP-поток.
@@ -5814,7 +6102,8 @@ end;
function TRadioController.CWXBusy: Boolean;
begin
Result := Assigned(FCWSender) and FCWSender.Busy;
Result := (Assigned(FCWSender) and FCWSender.Busy)
or (Assigned(FCWLocal) and FCWLocal.Busy);
end;
procedure TRadioController.CWXSend(const Text: string);
@@ -5826,6 +6115,15 @@ begin
// слот трансвертера, бэнд с DoNotTx) — там кейер разоружён, и дёргать бит
// CWX незачем.
if not CWTXActive then Exit;
// Локальный генератор сам держит тайминги В СЭМПЛАХ — текст уходит ему как
// есть; поток TCWSender (бит CWX прошивки) при этом не нужен вовсе.
if CWLocalSource then
begin
EnsureCWLocal;
SyncCWKeyer; // вооружиться, если пришли сюда первым делом
FCWLocal.SendText(Text);
Exit;
end;
if not Assigned(FCWSender) then
FCWSender := TCWSender.Create(Self.CWXKeyEvent);
FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight);
@@ -5835,6 +6133,7 @@ end;
procedure TRadioController.CWXAbort;
begin
if Assigned(FCWSender) then FCWSender.AbortSending;
if Assigned(FCWLocal) then FCWLocal.AbortText;
end;
function TRadioController.MixSidetone(const Left, Right: array of Single;
@@ -5846,15 +6145,29 @@ const
RAMP_MS = 5;
AUDIO_SR = 48000;
var
i: Integer;
i, Got: Integer;
Amp, Step, Rate, S: Double;
Local: Boolean;
begin
Result := False;
if not FCWSettings.SidetoneSW then Exit;
if FCWSettings.SidetoneSWLevel <= 0 then Exit;
// Ключ отпущен и огибающая уже сошлась в ноль — блок трогать незачем.
if (not FCWKeyDown) and (FCWSTEnv <= 0.0) then Exit;
if Count <= 0 then Exit;
// ★Локальный кейер даёт ГОТОВУЮ огибающую — ту же самую, что ушла в эфир, с
// точностью до сэмпла. Это снимает старую границу «программный сайдтон верен
// только для текста и прямого ключа»: при иамбике в прошивке PC видел лишь
// состояние лепестков, а здесь тайминг его собственный.
Got := 0;
Local := FCWLocalArmed and Assigned(FCWLocal);
if Local then
begin
if Length(FCWSTEnvBuf) < Count then SetLength(FCWSTEnvBuf, Count);
Got := FCWLocal.PullSidetone(FCWSTEnvBuf, Count);
// Кольцо пусто и хвост уже сошёлся в ноль — блок трогать незачем.
if (Got <= 0) and (FCWSTEnv <= 0.0) then Exit;
end
else if (not FCWKeyDown) and (FCWSTEnv <= 0.0) then
Exit;
if Length(FCWSTBufL) < Count then
begin
SetLength(FCWSTBufL, Count);
@@ -5865,7 +6178,18 @@ begin
Rate := 1000.0 / (RAMP_MS * AUDIO_SR);
for i := 0 to Count - 1 do
begin
if FCWKeyDown then
if Local then
begin
// Дальше конца кольца доигрываем спадом: генератор либо отстал на
// блок, либо сессия кончилась.
if i < Got then FCWSTEnv := FCWSTEnvBuf[i]
else if FCWSTEnv > 0.0 then
begin
FCWSTEnv := FCWSTEnv - Rate;
if FCWSTEnv < 0.0 then FCWSTEnv := 0.0;
end;
end
else if FCWKeyDown then
begin
FCWSTEnv := FCWSTEnv + Rate;
if FCWSTEnv > 1.0 then FCWSTEnv := 1.0;
@@ -6359,14 +6683,14 @@ begin
// Уровень пересчитываем здесь же: с вооружённым кейером он уедет в железо
// сразу, до всякой передачи, а после конструктора FDriveLevel ещё нулевой.
FDriveLevel := CalcDriveByte;
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, False, True, True, FTuning, FTwoTone);
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, FDriveLevel, False, True, True, FTuning, FTwoTone);
// ★DUC — на TX-ЧАСТОТУ, а не на центр дисплея. Раньше здесь стоял центр, и до
// первой перестройки VFO (или до первого MOX, который частоту дошлёт сам)
// передатчик стоял не там, где маркер: при CTUN центр и VFO — разные частоты.
// С кейером в прошивке это стало слышно в эфире: железо может замкнуть ключ
// сразу после старта, никакого MOX перед этим не будет.
FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq),
XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel);
TXTuneFreqHz, FDriveLevel);
FRunning := True;
FRXStartTime := GetTickCount64;
FRXLastPktTime := 0;
@@ -6424,11 +6748,15 @@ begin
// (3.3 UI пересоздаст по персисту при следующем старте).
for p := 1 to MAX_PANS - 1 do
RemovePanDDC(p);
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), 0, False, True, True, FTuning, FTwoTone);
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, 0, False, True, True, FTuning, FTwoTone);
FNetwork.SetRunAndFreq(False, XvtrTranslate(FCenterFreq),
XvtrTranslateTX(ActiveTXFreqHz), 0);
TXTuneFreqHz, 0);
FRunning := False;
FTransmitting := False;
// ★Локальный кейер обязан разоружиться ЗДЕСЬ: таймер хозяина, который его
// пересинхронизирует, на остановленном радио уже не работает — иначе
// манипулятор продолжал бы гонять IQ в закрытую сессию.
SyncCWKeyer;
// Стоп при активном TX/TUN: хвост IQ в DUC-очереди не должен пережить
// остановку (иначе при следующем старте уйдёт в эфир старый тон/голос),
// а TUN-состояние (PostGen-тон) — вернуться в выключенное.