feat(cw): телеграф — FPGA-кейер, pitch, сайдтон, передача текста

CW не работал ни на приём, ни на передачу: фильтр стоял симметрично вокруг
нуля (тона не было), понятия pitch не существовало, MOX в CWL отправлял в
эфир голос 2.9 кГц (в WDSP TXA_CWL — ветка SSB), байт CW-опций DUC всегда
был нулевым, поэтому кейер, сайдтон и break-in в прошивке молчали.

Pitch сделан ОДНИМ сдвигом в движке. Кромки фильтра везде остаются
относительно VFO (у CW — симметрично нулю), а гетеродин и полосу двигают
три метода WDSPEngine: CWLOOffset / PushShift / PushPassband. Прямых
вызовов SetRXAShiftFreq и RXASetPassband в проекте больше нет. Так таблица
фильтров не перестраивается при смене pitch (в отличие от Thetis), полоска
на спектре и маркер VFO верны без правок в UI, а слайсы получают свой
сдвиг по своему режиму.

Правила эфира:
- голосовой TXA в CW не запускается вообще (гард по режиму), MOX = только
  PTT; несущую даёт кейер, бит CWX или тон TUN;
- байт 5 собирается из настроек и перепосылается на каждой смене режима и
  TX-слайса — вне CW он обязан быть нулевым, иначе прошивка поднимет PTT на
  замыкание ключа посреди SSB;
- TUN в CW: тон = pitch и встречный сдвиг DUC, несущая встаёт ровно на VFO;
- приёмник на передаче в CW не глушится, иначе умолкает программный
  сайдтон и эфир между посылками.

Программная передача текста (CWMorse): поток с абсолютными дедлайнами
дёргает бит CWX в High Priority, элементы рисует прошивка. Касание
манипулятора или снятие MOX обрывают передачу. Память сообщений — окно
CW Messages и F1..F8 в главном окне. Оживлены CAT KS/KY/ZZKM/ZZKS/ZZKY.

Настройки — вкладка Transmit, подвкладка Hardware, перед блоком FM/CTCSS.

Программный сайдтон точен для передачи текста и прямого ключа; при иамбике
таймингом владеет FPGA, поэтому там верен только аппаратный тон.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:20:56 +03:00
co-authored by Claude Opus 5
parent 0ec120c2d5
commit 53329e82e8
12 changed files with 1421 additions and 46 deletions
+121
View File
@@ -28,6 +28,7 @@ const
CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS
CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES
CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090)
CW_MSG_COUNT = 8; // ячеек памяти телеграфных сообщений (F1..F8)
TXPROF_MAX = 16; // потолок именованных TX-профилей на устройство
QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100
// (split-LO транспондер; настраивается на
@@ -306,6 +307,39 @@ type
// (Orion MkII → 5.0, иначе выкл.), 0 = выкл.
end;
// ---- CW ------------------------------------------------------------------
// Телеграф в openHPSDR P2 делает ПРОШИВКА: точки/тире формирует кейер в FPGA,
// мы только отдаём ему параметры (DUC Specific байты 5..13, 17). Поэтому
// почти всё здесь — свойство устройства, а не «звука», и живёт отдельно от
// TTXSettings/TTXProfile (профиль голоса телеграф не переключает).
// Раскладка байта 5 — HPSDRProtocol.CW_*; эталон Thetis (console.cs/setup.cs)
// и pihpsdr (new_protocol.c:1353).
TCWSettings = record
Pitch: Integer; // Гц, тон приёма/сайдтона (Thetis default 600)
// Память сообщений для программной передачи (окно CW Messages, F1..F8).
Messages: array[0..CW_MSG_COUNT-1] of string[63];
FWKeyer: Boolean; // кейер в прошивке (иначе телеграф выключен)
KeyerMode: Integer; // 0=Straight, 1=Iambic A, 2=Iambic B
ReverseKeys: Boolean; // поменять точку и тире местами
StrictSpacing: Boolean; // строгие межзнаковые интервалы
Speed: Integer; // WPM, 5..60
Weight: Integer; // 33..66 (50 = точка:пауза 1:1)
BreakIn: Boolean; // прошивка сама поднимает PTT на нажатие ключа
HangTimeMS: Integer; // сколько держать TX после последнего элемента
RampMS: Integer; // форма фронта посылки (Thetis зашил 9 мс)
RFDelayMS: Integer; // задержка РЧ после PTT (byte 13, реле/усилитель)
SidetoneHW: Boolean; // тон в наушники САМОГО трансивера (байт 5/6)
SidetoneHWLevel: Integer; // 0..127
// Программный сайдтон — свой генератор в аудиотракте PC. Нужен тем, кто
// слушает через звуковую карту: аппаратный тон туда физически не попадает
// (он подмешивается в наушники трансивера, а не в IQ-поток).
// ★Точен там, где момент нажатия знает САМ PC: программная передача текста
// (CWX) и прямой ключ. При иамбике таймингом владеет FPGA, и PC видит лишь
// состояние лепестков манипулятора — там верен только аппаратный тон.
SidetoneSW: Boolean;
SidetoneSWLevel: Integer; // 0..100 % от полной шкалы аудио
end;
// ---- TX-профили (именованные снимки «как я звучу») ----------------------
// Профиль хранит ТОЛЬКО то, что определяет звук в эфире: маршрутизацию
// микрофонного входа, усиление, кромки TX-фильтра, динамическую обработку,
@@ -646,6 +680,10 @@ type
// в любом случае T заполняется (дефолтами или сохранёнными значениями).
function LoadTX(const MAC: array of Byte; out T: TTXSettings): Boolean;
procedure SaveTX(const MAC: array of Byte; const T: TTXSettings);
// CW settings per-device (JSON-секция "cw" под MAC).
class procedure DefaultCW(out C: TCWSettings);
function LoadCW(const MAC: array of Byte; out C: TCWSettings): Boolean;
procedure SaveCW(const MAC: array of Byte; const C: TCWSettings);
class function MacToStr(const MAC: array of Byte): string;
class procedure DefaultBand(BandIdx: Integer; out B: TBandSettings);
class procedure DefaultGlobal(out G: TGlobalSettings);
@@ -1733,6 +1771,89 @@ begin
T.PSOutlierSigma := JD(O,'ps_outlier_sigma', T.PSOutlierSigma);
end;
class procedure TSettingsManager.DefaultCW(out C: TCWSettings);
begin
FillChar(C, SizeOf(C), 0);
C.Pitch := 600; // Thetis cw_pitch default
C.FWKeyer := True; // тайминги в FPGA — джиттер PC не влияет
C.KeyerMode := 2; // Iambic B
C.ReverseKeys := False;
C.StrictSpacing := False;
C.Speed := 20;
C.Weight := 50;
C.BreakIn := True;
C.HangTimeMS := 300;
C.RampMS := 9; // Thetis SetCWEdgeLength(9)
C.RFDelayMS := 0;
C.SidetoneHW := True;
C.SidetoneHWLevel := 50;
C.SidetoneSW := False;
C.SidetoneSWLevel := 30;
// Заводские заготовки: типовой вызов, ответ и стандартные концовки.
C.Messages[0] := 'CQ CQ DE ';
C.Messages[1] := 'DE ';
C.Messages[2] := 'UR RST 599 599 ';
C.Messages[3] := 'TU 73 GL';
C.Messages[4] := '?';
C.Messages[5] := 'AGN';
C.Messages[6] := 'QRZ?';
C.Messages[7] := 'TEST';
end;
function TSettingsManager.LoadCW(const MAC: array of Byte;
out C: TCWSettings): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
DefaultCW(C);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('cw') = nil then Exit; // нет секции — остаются дефолты
O := EnsureObj(DevObj, 'cw');
C.Pitch := EnsureRange(JI(O,'pitch', C.Pitch), 200, 1200);
C.FWKeyer := JB(O,'fw_keyer', C.FWKeyer);
C.KeyerMode := EnsureRange(JI(O,'keyer_mode', C.KeyerMode), 0, 2);
C.ReverseKeys := JB(O,'reverse_keys', C.ReverseKeys);
C.StrictSpacing := JB(O,'strict_spacing',C.StrictSpacing);
C.Speed := EnsureRange(JI(O,'speed', C.Speed), 5, 60);
C.Weight := EnsureRange(JI(O,'weight', C.Weight), 33, 66);
C.BreakIn := JB(O,'break_in', C.BreakIn);
C.HangTimeMS := EnsureRange(JI(O,'hang_time_ms', C.HangTimeMS), 0, 2000);
C.RampMS := EnsureRange(JI(O,'ramp_ms', C.RampMS), 0, 20);
C.RFDelayMS := EnsureRange(JI(O,'rf_delay_ms', C.RFDelayMS), 0, 255);
C.SidetoneHW := JB(O,'sidetone_hw', C.SidetoneHW);
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);
for i := 0 to CW_MSG_COUNT - 1 do
C.Messages[i] := Copy(JS(O, 'msg_' + IntToStr(i), C.Messages[i]), 1, 63);
end;
procedure TSettingsManager.SaveCW(const MAC: array of Byte;
const C: TCWSettings);
var O: TJSONObject; i: Integer;
begin
O := EnsureObj(GetDevObj(MacToStr(MAC)), 'cw');
JW(O,'pitch', C.Pitch);
JW(O,'fw_keyer', C.FWKeyer);
JW(O,'keyer_mode', C.KeyerMode);
JW(O,'reverse_keys', C.ReverseKeys);
JW(O,'strict_spacing', C.StrictSpacing);
JW(O,'speed', C.Speed);
JW(O,'weight', C.Weight);
JW(O,'break_in', C.BreakIn);
JW(O,'hang_time_ms', C.HangTimeMS);
JW(O,'ramp_ms', C.RampMS);
JW(O,'rf_delay_ms', C.RFDelayMS);
JW(O,'sidetone_hw', C.SidetoneHW);
JW(O,'sidetone_hw_level', C.SidetoneHWLevel);
JW(O,'sidetone_sw', C.SidetoneSW);
JW(O,'sidetone_sw_level', C.SidetoneSWLevel);
for i := 0 to CW_MSG_COUNT - 1 do
JWS(O, 'msg_' + IntToStr(i), C.Messages[i]);
end;
procedure TSettingsManager.SaveTX(const MAC: array of Byte;
const T: TTXSettings);
var O: TJSONObject; i: Integer;