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
+51 -1
View File
@@ -318,7 +318,10 @@ type
Pitch: Integer; // Гц, тон приёма/сайдтона (Thetis default 600)
// Память сообщений для программной передачи (окно CW Messages, F1..F8).
Messages: array[0..CW_MSG_COUNT-1] of string[63];
FWKeyer: Boolean; // кейер в прошивке (иначе телеграф выключен)
// Манипуляция разрешена вообще (False = телеграф без кейера: MOX + голосом
// ключа нет). ВМЕСТЕ с LocalKeyer образует одну развилку «кто рисует точки»,
// в UI это один список: прошивка / программа / выкл.
FWKeyer: Boolean;
KeyerMode: Integer; // 0=Straight, 1=Iambic A, 2=Iambic B
ReverseKeys: Boolean; // поменять точку и тире местами
StrictSpacing: Boolean; // строгие межзнаковые интервалы
@@ -338,6 +341,26 @@ type
// состояние лепестков манипулятора — там верен только аппаратный тон.
SidetoneSW: Boolean;
SidetoneSWLevel: Integer; // 0..100 % от полной шкалы аудио
// ---- Локальный (программный) кейер --------------------------------------
// Манипуляцию формирует САМ EWSDR: генератор рисует несущую с огибающей
// прямо в поток IQ (CWKeyer.pas). Для Pluto/LibreSDR это единственный
// возможный путь — кейера в железе там нет, и выбор «прошивка» вырождается
// туда сам; для openHPSDR это опция, по умолчанию выключенная (тайминги в
// FPGA точнее любой программы).
LocalKeyer: Boolean;
// Сдвиг несущей манипуляции от гетеродина. 0 = авто: у openHPSDR 0 (DUC —
// цифровой, утекать нечему), у zero-IF AD936x — CW_AUTO_OFFSET_HZ, иначе
// утечка LO сидит РОВНО на рабочей частоте и слышна между посылками.
CarrierOffsetHz: Integer;
// Манипулятор на модемных линиях COM/USB-serial (DTR/RTS питают ключ,
// CTS/DSR/DCD/RI читаются как лепестки).
KeyPortEnabled: Boolean;
KeyPort: string[63];
KeyDotLine: Integer; // 0=CTS, 1=DSR, 2=DCD, 3=RI
KeyDashLine: Integer;
KeyInvert: Boolean;
KeyPowerDTR: Boolean;
KeyPowerRTS: Boolean;
end;
// ---- TX-профили (именованные снимки «как я звучу») ----------------------
@@ -1789,6 +1812,15 @@ begin
C.SidetoneHWLevel := 50;
C.SidetoneSW := False;
C.SidetoneSWLevel := 30;
C.LocalKeyer := False; // у openHPSDR тайминги в FPGA; Pluto включит сам
C.CarrierOffsetHz := 0; // 0 = авто (по типу бэкенда)
C.KeyPortEnabled := False;
C.KeyPort := '';
C.KeyDotLine := 0; // CTS
C.KeyDashLine := 1; // DSR
C.KeyInvert := False;
C.KeyPowerDTR := True; // типовая распайка: ключ питается от DTR/RTS
C.KeyPowerRTS := True;
// Заводские заготовки: типовой вызов, ответ и стандартные концовки.
C.Messages[0] := 'CQ CQ DE ';
C.Messages[1] := 'DE ';
@@ -1826,6 +1858,15 @@ begin
C.SidetoneHWLevel := EnsureRange(JI(O,'sidetone_hw_level', C.SidetoneHWLevel), 0, 127);
C.SidetoneSW := JB(O,'sidetone_sw', C.SidetoneSW);
C.SidetoneSWLevel := EnsureRange(JI(O,'sidetone_sw_level', C.SidetoneSWLevel), 0, 100);
C.LocalKeyer := JB(O,'local_keyer', C.LocalKeyer);
C.CarrierOffsetHz := EnsureRange(JI(O,'carrier_offset_hz', C.CarrierOffsetHz), -1, 48000);
C.KeyPortEnabled := JB(O,'key_port_on', C.KeyPortEnabled);
C.KeyPort := Copy(JS(O,'key_port', C.KeyPort), 1, 63);
C.KeyDotLine := EnsureRange(JI(O,'key_dot_line', C.KeyDotLine), 0, 3);
C.KeyDashLine := EnsureRange(JI(O,'key_dash_line', C.KeyDashLine), 0, 3);
C.KeyInvert := JB(O,'key_invert', C.KeyInvert);
C.KeyPowerDTR := JB(O,'key_power_dtr', C.KeyPowerDTR);
C.KeyPowerRTS := JB(O,'key_power_rts', C.KeyPowerRTS);
for i := 0 to CW_MSG_COUNT - 1 do
C.Messages[i] := Copy(JS(O, 'msg_' + IntToStr(i), C.Messages[i]), 1, 63);
end;
@@ -1850,6 +1891,15 @@ begin
JW(O,'sidetone_hw_level', C.SidetoneHWLevel);
JW(O,'sidetone_sw', C.SidetoneSW);
JW(O,'sidetone_sw_level', C.SidetoneSWLevel);
JW(O,'local_keyer', C.LocalKeyer);
JW(O,'carrier_offset_hz', C.CarrierOffsetHz);
JW(O,'key_port_on', C.KeyPortEnabled);
JWS(O,'key_port', C.KeyPort);
JW(O,'key_dot_line', C.KeyDotLine);
JW(O,'key_dash_line', C.KeyDashLine);
JW(O,'key_invert', C.KeyInvert);
JW(O,'key_power_dtr', C.KeyPowerDTR);
JW(O,'key_power_rts', C.KeyPowerRTS);
for i := 0 to CW_MSG_COUNT - 1 do
JWS(O, 'msg_' + IntToStr(i), C.Messages[i]);
end;