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
+994
View File
@@ -0,0 +1,994 @@
unit CWKeyer;
{$mode objfpc}{$H+}
// ---------------------------------------------------------------------------
// Локальный (программный) телеграфный генератор + вход манипулятора.
//
// ЗАЧЕМ. У openHPSDR P2 манипуляцию делает кейер в FPGA: PC отдаёт ему
// скорость/вес (DUC Specific байты 5..13), а точки/тире формирует прошивка —
// джиттера PC там нет вовсе, и это остаётся путём по умолчанию. У Pluto/
// LibreSDR такого кейера НЕТ: это голый AD936x, в эфир идёт ровно то, что мы
// сами положили в поток IQ. Значит телеграф там обязан формироваться здесь.
// Эталон — pihpsdr: src/iambic.c (автомат) + cw_shape_buffer в transmitter.c
// (огибающая прямо в TX-буфере, мимо WDSP).
//
// ПОЧЕМУ МИМО WDSP. Голосовой TXA в телеграфе не запускается вообще (в WDSP
// режим TXA_CWL — буквально ветка SSB), да и PostGen-тон не умеет огибающей.
// Поэтому генератор отдаёт готовые IQ-пары прямо в бэкенд: несущая = комплексная
// экспонента на OffsetHz с косинусной («приподнятый косинус») огибающей.
//
// ТАЙМИНГИ ЖИВУТ В СЭМПЛАХ, а не в миллисекундах. Точка на 40 WPM — 30 мс;
// отмеряй её Sleep-ами, и планировщик ОС растянул бы её на единицы мс, отчего
// знак «плывёт». В сэмплах длительность точна по определению — ошибиться может
// только ТЕМП ВЫДАЧИ блоков, а его сглаживает FIFO бэкенда (держим в нём запас
// CW_PREFILL_MS).
//
// СЕССИЯ. Между посылками несущей нет, но реле T/R, LO передатчика и аттенюатор
// дёргать на каждую точку нельзя. Поэтому генератор работает сессиями: первое
// замыкание поднимает передачу (OnSession(True)), дальше идут элементы, и через
// hang после последнего передача снимается. Внутри сессии манипуляция — чисто
// цифровая, в огибающей.
//
// САЙДТОН. Огибающая пишется в кольцо на аудио-rate; аудиотракт её вычитывает
// и умножает на свой синус (PullSidetone). Так тон точен ДЛЯ ЛЮБОГО источника —
// и для текста, и для иамбика, — в отличие от пути с кейером в прошивке, где
// PC знает лишь состояние лепестков.
// ---------------------------------------------------------------------------
interface
uses
Classes, SysUtils, SyncObjs, Math, Serial, CWMorse;
const
CW_SIDETONE_RATE = 48000; // rate огибающей сайдтона (аудиотракт PC)
CW_IQ_BLOCK = 240; // пар в блоке = ровно DUC IQ пакет openHPSDR
// Запас в FIFO бэкенда, он же латентность РЧ. Нижняя планка задана ЖЕЛЕЗОМ:
// TX-поток Pluto наполняет буфер целиком (16384 пары ≈ 28 мс на 576 ksps) и
// добивает нулями всё, чего в FIFO не хватило — то есть при меньшем запасе
// посылка получила бы дыры. Сайдтон этой латентности не наследует: его отставание
// отдельно ограничено CW_ST_MAX_LAG_MS.
CW_PREFILL_MS = 60;
CW_ST_MAX_LAG_MS = 20; // потолок отставания сайдтона от манипуляции
type
// Режим манипулятора. Прямой ключ = уровень на линии, иамбик = автомат по
// двум лепесткам (A — без памяти, B — с памятью задетого элемента).
TCWLocalMode = (clmStraight, clmIambicA, clmIambicB);
// Линии модемного разъёма, которыми читается манипулятор.
TCWKeyLine = (cklCTS, cklDSR, cklCD, cklRI);
// IQ-пары (interleaved I,Q) в конвенции WDSP: РЧ = гетеродин + f.
TCWIQEvent = procedure(const Buf: array of Double; Count: Integer) of object;
// Начало/конец сессии передачи: PTT, реле T/R, антенна, аттенюатор.
TCWSessionEvent = procedure(Active: Boolean) of object;
// «Ключ живой» — дёргается на каждом выданном блоке (индикация/выдержка).
TCWTickEvent = procedure of object;
TCWLocalConfig = record
Rate: Integer; // sample rate потока IQ (TX rate движка)
OffsetHz: Double; // сдвиг несущей от гетеродина (уход от утечки LO)
Amplitude: Double; // 0..1 — цифровой уровень несущей
WPM: Integer;
Weight: Integer; // 33..66 (50 = точка равна паузе)
RampMS: Integer; // форма фронта посылки
Mode: TCWLocalMode;
Reverse: Boolean; // поменять лепестки местами
Strict: Boolean; // строгие интервалы (см. RunSession)
BreakIn: Boolean; // сессия сама поднимает передачу
HangMS: Integer; // сколько держать передачу после последнего элемента
RFDelayMS: Integer; // пауза после подъёма передачи до первой посылки
Sidetone: Boolean; // писать огибающую в кольцо сайдтона
end;
{ Генератор. Один поток: решает, что передавать, и сам же рисует IQ. }
TCWLocalKeyer = class(TThread)
private
FLock: TCriticalSection;
FSTLock: TCriticalSection;
FWake: TEvent;
// ---- вход (под FLock) --------------------------------------------------
FCfg: TCWLocalConfig;
FArmed: Boolean;
FPending: string; // очередь текста
FAbortReq: Boolean;
FBusyText: Boolean;
FDotIn: Boolean; // лепесток «точка» (сырой, до Reverse)
FDashIn: Boolean;
FStraightIn:Boolean; // прямой ключ отдельным источником
FTouched: Boolean; // касание манипулятора — обрывает текст
// ---- рабочая копия (только поток генератора) ---------------------------
FRate: Integer;
FAmp: Double;
FDit: Int64; // длительности в сэмплах
FMark: Int64;
FGap: Int64;
FRamp: Int64;
FHang: Int64;
FRFDelay: Integer;
FMode: TCWLocalMode;
FReverse: Boolean;
FStrict: Boolean;
FSidetoneOn:Boolean;
FCosStep: Double; // поворот фазора на сэмпл
FSinStep: Double;
FPhI: Double; // текущий фазор
FPhQ: Double;
FPhCnt: Integer; // счётчик до перенормировки
FEnvPos: Int64; // позиция в рампе 0..FRamp
FBuf: array of Double;
FBufCnt: Integer;
FEmitted: Int64; // сэмплов с начала сессии (пейсинг)
FT0: QWord;
FSTAcc: Integer; // делитель rate → CW_SIDETONE_RATE
FSTRing: array of Single;
FSTHead: Integer;
FSTTail: Integer;
// ---- разбор текста (только поток) --------------------------------------
FText: string;
FTextPos: Integer;
FCode: string; // код текущего знака ('.' и '-')
FCodePos: Integer;
FLeadGap: Int64; // пауза ПЕРЕД следующим элементом (межсловная)
FTailGap: Int64; // добор паузы ПОСЛЕ элемента (межзнаковая)
// ---- иамбик (только поток) ---------------------------------------------
FLastDot: Boolean;
FDotMem: Boolean;
FDashMem: Boolean;
FSeenDot: Boolean; // лепесток задет во время элемента (режим B)
FSeenDash: Boolean;
// ---- события -----------------------------------------------------------
FOnIQ: TCWIQEvent;
FOnSession: TCWSessionEvent;
FOnTick: TCWTickEvent;
function Snapshot: Boolean; // конфиг → рабочие поля; False = не вооружён
function MsToSamples(Ms: Integer): Int64;
procedure Flush;
procedure Pace;
procedure Emit(KeyOn: Boolean; N: Int64; Watch: Boolean);
procedure PushSidetone(E: Double);
procedure ReadPaddles(out Dot, Dash: Boolean);
function WorkPending: Boolean;
function TakeTextElement(out IsDot: Boolean): Boolean;
function TakeIambicElement(out IsDot: Boolean): Boolean;
procedure ResetText;
procedure RunSession;
protected
procedure Execute; override;
public
constructor Create;
destructor Destroy; override;
procedure Configure(const C: TCWLocalConfig);
// Вооружение: снятие мгновенно обрывает передачу и отпускает ключ.
procedure SetArmed(Value: Boolean);
function Armed: Boolean;
procedure SendText(const S: string);
procedure AbortText;
function Busy: Boolean;
// Вход манипулятора (поток опроса порта / GUI). Состояния лепестков сырые,
// Reverse применяет сам генератор.
procedure Paddle(Dot, Dash: Boolean);
procedure StraightKey(Down: Boolean);
// Аудиотракт: вычитать до N отсчётов огибающей (0..1). Возвращает сколько
// реально отдано — остальное вызывающий доигрывает спадом.
function PullSidetone(var Buf: array of Single; N: Integer): Integer;
property OnIQ: TCWIQEvent read FOnIQ write FOnIQ;
property OnSession: TCWSessionEvent read FOnSession write FOnSession;
property OnTick: TCWTickEvent read FOnTick write FOnTick;
end;
TCWKeyPortConfig = record
Enabled: Boolean;
Port: string;
DotLine: TCWKeyLine;
DashLine: TCWKeyLine;
Invert: Boolean; // ключ замыкает линию в «0» (инверсный интерфейс)
PowerDTR: Boolean; // поднять DTR (питание/общий провод ключа)
PowerRTS: Boolean;
end;
TCWKeyPortEvent = procedure(Dot, Dash: Boolean) of object;
{ Опрос манипулятора на модемных линиях COM/USB-serial: DTR/RTS питают ключ,
CTS/DSR/DCD/RI читаются как лепестки. Так же делают Thetis (cwkeyer.cs),
hamlib и fldigi — это единственный вход ключа, доступный на любом железе. }
TCWKeyPort = class(TThread)
private
FLock: TCriticalSection;
FCfg: TCWKeyPortConfig;
FDirty: Boolean; // конфиг сменился — переоткрыть
FHandle: TSerialHandle;
FLastErr: string;
FOnKey: TCWKeyPortEvent;
FPrevDot: Boolean;
FPrevDash: Boolean;
function ReadLine(L: TCWKeyLine): Boolean;
procedure ClosePort;
function OpenPort(const C: TCWKeyPortConfig): Boolean;
procedure SetErr(const S: string);
procedure Release;
protected
procedure Execute; override;
public
constructor Create;
destructor Destroy; override;
procedure Configure(const C: TCWKeyPortConfig);
function LastError: string;
property OnKey: TCWKeyPortEvent read FOnKey write FOnKey;
end;
implementation
const
DIT_MS_AT_1WPM = 1200; // стандарт PARIS
ST_RING_MS = 250; // глубина кольца сайдтона
{ ── TCWLocalKeyer ─────────────────────────────────────────────────────────── }
constructor TCWLocalKeyer.Create;
begin
FLock := TCriticalSection.Create;
FSTLock := TCriticalSection.Create;
FWake := TEvent.Create(nil, False, False, '');
FCfg.Rate := 192000;
FCfg.Amplitude := 0.99;
FCfg.WPM := 20;
FCfg.Weight := 50;
FCfg.RampMS := 9;
FCfg.Mode := clmIambicB;
FCfg.BreakIn := True;
FCfg.HangMS := 300;
FRate := FCfg.Rate;
FCodePos := 1;
SetLength(FBuf, CW_IQ_BLOCK * 2);
SetLength(FSTRing, (CW_SIDETONE_RATE * ST_RING_MS) div 1000);
FreeOnTerminate := False;
inherited Create(False);
end;
destructor TCWLocalKeyer.Destroy;
begin
Terminate;
SetArmed(False);
FWake.SetEvent;
WaitFor;
FWake.Free;
FSTLock.Free;
FLock.Free;
inherited Destroy;
end;
procedure TCWLocalKeyer.Configure(const C: TCWLocalConfig);
begin
FLock.Enter;
try
FCfg := C;
if FCfg.Rate < 8000 then FCfg.Rate := 8000;
if FCfg.WPM < 5 then FCfg.WPM := 5;
if FCfg.WPM > 60 then FCfg.WPM := 60;
if FCfg.Weight < 33 then FCfg.Weight := 33;
if FCfg.Weight > 66 then FCfg.Weight := 66;
if FCfg.RampMS < 0 then FCfg.RampMS := 0;
if FCfg.RampMS > 20 then FCfg.RampMS := 20;
if FCfg.HangMS < 0 then FCfg.HangMS := 0;
if FCfg.RFDelayMS < 0 then FCfg.RFDelayMS := 0;
if FCfg.Amplitude < 0 then FCfg.Amplitude := 0;
if FCfg.Amplitude > 1 then FCfg.Amplitude := 1;
finally
FLock.Leave;
end;
end;
procedure TCWLocalKeyer.SetArmed(Value: Boolean);
begin
FLock.Enter;
try
if FArmed = Value then Exit;
FArmed := Value;
if not Value then
begin
// Разоружение — это «передавать нельзя» (ушли из CW, зашли в RX-only слот
// трансвертера, встали на бэнд с DoNotTx). Всё бросаем немедленно.
FPending := '';
FAbortReq := True;
FDotIn := False;
FDashIn := False;
FStraightIn := False;
end;
finally
FLock.Leave;
end;
FWake.SetEvent;
end;
function TCWLocalKeyer.Armed: Boolean;
begin
FLock.Enter;
try
Result := FArmed;
finally
FLock.Leave;
end;
end;
procedure TCWLocalKeyer.SendText(const S: string);
begin
if S = '' then Exit;
FLock.Enter;
try
if not FArmed then Exit;
FAbortReq := False;
FTouched := False;
FPending := FPending + S;
FBusyText := True;
finally
FLock.Leave;
end;
FWake.SetEvent;
end;
procedure TCWLocalKeyer.AbortText;
begin
FLock.Enter;
try
FPending := '';
FAbortReq := True;
finally
FLock.Leave;
end;
FWake.SetEvent;
end;
function TCWLocalKeyer.Busy: Boolean;
begin
FLock.Enter;
try
Result := FBusyText or (FPending <> '');
finally
FLock.Leave;
end;
end;
procedure TCWLocalKeyer.Paddle(Dot, Dash: Boolean);
begin
FLock.Enter;
try
if (Dot and not FDotIn) or (Dash and not FDashIn) then FTouched := True;
FDotIn := Dot;
FDashIn := Dash;
finally
FLock.Leave;
end;
if Dot or Dash then FWake.SetEvent;
end;
procedure TCWLocalKeyer.StraightKey(Down: Boolean);
begin
FLock.Enter;
try
if Down and not FStraightIn then FTouched := True;
FStraightIn := Down;
finally
FLock.Leave;
end;
if Down then FWake.SetEvent;
end;
function TCWLocalKeyer.PullSidetone(var Buf: array of Single; N: Integer): Integer;
var
Avail, MaxLag: Integer;
begin
Result := 0;
if N <= 0 then Exit;
FSTLock.Enter;
try
// Генератор идёт впереди эфира на CW_PREFILL_MS, и весь этот запас оседал бы
// в кольце — сайдтон отставал бы от руки на всю латентность РЧ. Лишнее
// пролистываем: в начале сессии это тишина (окно RF delay), а дальше темпы
// равны и листать уже нечего.
Avail := FSTHead - FSTTail;
if Avail < 0 then Inc(Avail, Length(FSTRing));
MaxLag := N + (CW_SIDETONE_RATE * CW_ST_MAX_LAG_MS) div 1000;
if Avail > MaxLag then
FSTTail := (FSTTail + (Avail - MaxLag)) mod Length(FSTRing);
while (Result < N) and (FSTTail <> FSTHead) do
begin
Buf[Result] := FSTRing[FSTTail];
FSTTail := (FSTTail + 1) mod Length(FSTRing);
Inc(Result);
end;
finally
FSTLock.Leave;
end;
end;
procedure TCWLocalKeyer.PushSidetone(E: Double);
var
NextH: Integer;
begin
FSTLock.Enter;
try
NextH := (FSTHead + 1) mod Length(FSTRing);
if NextH = FSTTail then
// Переполнение: аудиотракт молчит или отстал — держим ХВОСТ, иначе
// сайдтон уезжал бы по времени от манипуляции.
FSTTail := (FSTTail + 1) mod Length(FSTRing);
FSTRing[FSTHead] := E;
FSTHead := NextH;
finally
FSTLock.Leave;
end;
end;
function TCWLocalKeyer.MsToSamples(Ms: Integer): Int64;
begin
if Ms <= 0 then Exit(0);
Result := (Int64(FRate) * Ms) div 1000;
end;
function TCWLocalKeyer.Snapshot: Boolean;
// Копия конфига в рабочие поля. Зовётся на каждом элементе — скорость/вес можно
// крутить прямо во время передачи.
var
C: TCWLocalConfig;
Th: Double;
begin
FLock.Enter;
try
C := FCfg;
Result := FArmed;
finally
FLock.Leave;
end;
FRate := C.Rate;
FAmp := C.Amplitude;
FMode := C.Mode;
FReverse := C.Reverse;
FStrict := C.Strict;
FSidetoneOn := C.Sidetone;
FRFDelay := C.RFDelayMS;
FDit := (Int64(FRate) * DIT_MS_AT_1WPM) div (Int64(C.WPM) * 1000);
// Вес: 50 = точка равна паузе. Выше — посылки длиннее за счёт пауз ВНУТРИ
// знака; межзнаковые интервалы остаются стандартными (иначе плывёт темп).
FMark := (FDit * C.Weight) div 50;
FGap := (FDit * (100 - C.Weight)) div 50;
if FMark < 1 then FMark := 1;
if FGap < 1 then FGap := 1;
FRamp := (Int64(FRate) * C.RampMS) div 1000;
if FRamp * 2 > FMark then FRamp := FMark div 2; // рампа не длиннее посылки
if FRamp < 0 then FRamp := 0;
FHang := MsToSamples(C.HangMS);
Th := 2.0 * Pi * C.OffsetHz / FRate;
FCosStep := Cos(Th);
FSinStep := Sin(Th);
end;
procedure TCWLocalKeyer.Flush;
begin
if FBufCnt <= 0 then Exit;
if Assigned(FOnIQ) then FOnIQ(FBuf, FBufCnt);
FBufCnt := 0;
if Assigned(FOnTick) then FOnTick;
end;
procedure TCWLocalKeyer.Pace;
// Держим в FIFO бэкенда запас CW_PREFILL_MS: продюсер идёт по часам PC, а
// потребитель — по радиоклоку. Больше запас — больше латентность РЧ и сайдтона,
// меньше — риск подсоса нулей посреди посылки.
var
Target, Elapsed: Int64;
begin
Target := (FEmitted * 1000) div FRate - CW_PREFILL_MS;
if Target <= 0 then Exit;
repeat
if Terminated then Exit;
Elapsed := Int64(GetTickCount64 - FT0);
if Elapsed >= Target then Exit;
Sleep(1);
until False;
end;
procedure TCWLocalKeyer.Emit(KeyOn: Boolean; N: Int64; Watch: Boolean);
// Рисует N сэмплов при заданном состоянии ключа. Огибающая живёт МЕЖДУ
// вызовами (FEnvPos): посылка = Emit(True, mark) поднимает фронт в первые FRamp
// сэмплов, пауза = Emit(False, gap) роняет его в свои первые FRamp. Так точка
// длится ровно mark по уровню 50%, а щелчка нет ни на одном фронте.
var
i: Int64;
E, A, NI, NQ, Nrm: Double;
Dot, Dash: Boolean;
begin
i := 0;
while i < N do
begin
if Terminated then Exit;
if KeyOn then
begin
if FEnvPos < FRamp then Inc(FEnvPos) else FEnvPos := FRamp;
end
else if FEnvPos > 0 then
Dec(FEnvPos);
if FRamp > 0 then
E := 0.5 * (1.0 - Cos(Pi * FEnvPos / FRamp))
else if KeyOn then
E := 1.0
else
E := 0.0;
A := FAmp * E;
if (FSinStep <> 0.0) or (FCosStep <> 1.0) then
begin
// Поворот фазора вместо Sin/Cos на каждый сэмпл (192к вызовов в секунду).
NI := FPhI * FCosStep - FPhQ * FSinStep;
NQ := FPhI * FSinStep + FPhQ * FCosStep;
FPhI := NI; FPhQ := NQ;
Inc(FPhCnt);
if FPhCnt >= 1024 then
begin
FPhCnt := 0;
Nrm := Sqrt(FPhI * FPhI + FPhQ * FPhQ);
if Nrm > 0 then begin FPhI := FPhI / Nrm; FPhQ := FPhQ / Nrm; end
else begin FPhI := 1.0; FPhQ := 0.0; end;
end;
FBuf[FBufCnt * 2] := A * FPhI;
FBuf[FBufCnt * 2 + 1] := A * FPhQ;
end
else
begin
FBuf[FBufCnt * 2] := A; // нулевой сдвиг — несущая прямо на DUC
FBuf[FBufCnt * 2 + 1] := 0.0;
end;
Inc(FBufCnt);
if FSidetoneOn then
begin
Inc(FSTAcc, CW_SIDETONE_RATE);
if FSTAcc >= FRate then
begin
Dec(FSTAcc, FRate);
PushSidetone(E);
end;
end;
if FBufCnt >= CW_IQ_BLOCK then
begin
Flush;
Pace;
// Лепестки во время элемента — память режима B (см. RunSession).
if Watch then
begin
ReadPaddles(Dot, Dash);
if Dot then FSeenDot := True;
if Dash then FSeenDash := True;
end;
end;
Inc(i);
Inc(FEmitted);
end;
end;
procedure TCWLocalKeyer.ReadPaddles(out Dot, Dash: Boolean);
var
D, H: Boolean;
begin
FLock.Enter;
try
D := FDotIn;
H := FDashIn;
finally
FLock.Leave;
end;
if FReverse then begin Dot := H; Dash := D; end
else begin Dot := D; Dash := H; end;
end;
function TCWLocalKeyer.WorkPending: Boolean;
begin
FLock.Enter;
try
Result := FArmed and ((FPending <> '') or FDotIn or FDashIn or FStraightIn);
finally
FLock.Leave;
end;
end;
procedure TCWLocalKeyer.ResetText;
begin
FText := '';
FTextPos := 0;
FCode := '';
FCodePos := 1;
FLeadGap := 0;
FTailGap := 0;
FLock.Enter;
try
FBusyText := False;
finally
FLock.Leave;
end;
end;
function TCWLocalKeyer.TakeTextElement(out IsDot: Boolean): Boolean;
// Очередной элемент передаваемого текста. Раскладка пауз та же, что у кейера
// прошивки: 1 точка между элементами знака, 3 между знаками, 7 между словами.
// Межсловная пауза копится в FLeadGap (отдаётся ПЕРЕД элементом), межзнаковая —
// в FTailGap (после).
var
Ch: Char;
Add: string;
Drop: Boolean;
begin
Result := False;
IsDot := True;
FTailGap := 0;
FLock.Enter;
try
Drop := FAbortReq or FTouched;
if Drop then
begin
FPending := '';
FAbortReq := False;
FTouched := False;
end;
Add := FPending;
FPending := '';
finally
FLock.Leave;
end;
if Drop then
begin
ResetText;
Exit;
end;
if Add <> '' then FText := FText + Add;
// Ищем следующий элемент, пропуская пробелы и знаки не из таблицы.
while FCodePos > Length(FCode) do
begin
if FTextPos >= Length(FText) then
begin
ResetText;
Exit;
end;
Inc(FTextPos);
Ch := FText[FTextPos];
if Ch = ' ' then
begin
// 3 точки уже отданы после прошлого знака — добираем до 7.
Inc(FLeadGap, 4 * FDit);
Continue;
end;
FCode := MorseCode(Ch);
FCodePos := 1;
end;
IsDot := FCode[FCodePos] = '.';
Result := True;
Inc(FCodePos);
// Последний элемент знака — добираем межзнаковый интервал до 3 точек.
if FCodePos > Length(FCode) then FTailGap := 2 * FDit;
FLock.Enter;
try
FBusyText := True;
finally
FLock.Leave;
end;
end;
function TCWLocalKeyer.TakeIambicElement(out IsDot: Boolean): Boolean;
// Автомат иамбика (логика pihpsdr src/iambic.c в терминах элементов):
// * зажаты оба лепестка — элементы чередуются (иначе точка «съедала» бы тире);
// * режим B — лепесток, задетый ВО ВРЕМЯ элемента, запоминается и отыгрывается
// следующим; режим A памяти не имеет (в этом и вся разница);
// * strict spacing — лепестки во время межэлементной паузы не запоминаются:
// ритм задаёт кейер, а не рука.
var
Dot, Dash: Boolean;
begin
ReadPaddles(Dot, Dash);
Result := True;
if Dot and Dash then IsDot := not FLastDot
else if Dot then IsDot := True
else if Dash then IsDot := False
else if FDotMem then IsDot := True
else if FDashMem then IsDot := False
else Result := False;
if Result then
begin
FDotMem := False;
FDashMem := False;
FLastDot := IsDot;
end;
end;
procedure TCWLocalKeyer.RunSession;
// Одна сессия передачи: подъём T/R → элементы → hang → снятие T/R.
var
Idle: Int64;
Chunk: Int64;
IsDot: Boolean;
Level: Boolean;
Got: Boolean;
Dot, Dash: Boolean;
Br: Boolean;
begin
if not Snapshot then Exit;
FLock.Enter;
try
Br := FCfg.BreakIn;
finally
FLock.Leave;
end;
FEnvPos := 0; FEmitted := 0; FBufCnt := 0; FSTAcc := 0;
FPhI := 1.0; FPhQ := 0.0; FPhCnt := 0;
FDotMem := False; FDashMem := False; FSeenDot := False; FSeenDash := False;
FSTLock.Enter;
try
FSTHead := 0; FSTTail := 0; // старую огибающую в кольце не доигрываем
finally
FSTLock.Leave;
end;
if Br and Assigned(FOnSession) then FOnSession(True);
FT0 := GetTickCount64;
try
// Окно на реле T/R, LO передатчика и аттенюатор: без него первая точка
// ушла бы в ещё приёмную обвязку.
Emit(False, MsToSamples(FRFDelay), False);
Idle := 0;
while not Terminated do
begin
if not Snapshot then Break; // разоружили посреди передачи
FLock.Enter;
try
Level := FStraightIn;
finally
FLock.Leave;
end;
if FMode = clmStraight then
begin
ReadPaddles(Dot, Dash);
Level := Level or Dot or Dash; // прямой ключ на любом лепестке
end;
// Прямой ключ — следуем за уровнем: длительность задаёт рука, наше дело
// форма фронта. Квант 2 мс = задержка реакции на отпускание.
if Level then
begin
Emit(True, MsToSamples(2), False);
Idle := 0;
Continue;
end;
// Текст имеет приоритет над манипулятором; касание лепестка его обрывает
// (это ловит сам TakeTextElement по FTouched).
Got := TakeTextElement(IsDot);
if (not Got) and (FMode <> clmStraight) then Got := TakeIambicElement(IsDot);
if Got then
begin
if FLeadGap > 0 then
begin
Emit(False, FLeadGap, not FStrict);
FLeadGap := 0;
end;
FSeenDot := False; FSeenDash := False;
if IsDot then Emit(True, FMark, True)
else Emit(True, 3 * FMark, True);
Emit(False, FGap, not FStrict);
if FTailGap > 0 then
begin
Emit(False, FTailGap, not FStrict);
FTailGap := 0;
end;
if FMode = clmIambicB then
begin
if IsDot and FSeenDash then FDashMem := True;
if (not IsDot) and FSeenDot then FDotMem := True;
end;
Idle := 0;
Continue;
end;
// Передавать нечего: держим передачу hang, потом закрываем сессию.
Chunk := MsToSamples(4);
if Chunk < 1 then Chunk := 1;
Emit(False, Chunk, False);
Inc(Idle, Chunk);
if Idle >= FHang then Break;
end;
// Добить спад огибающей — сессия не должна обрываться щелчком.
if FEnvPos > 0 then Emit(False, FRamp + 1, False);
// ★Хвост тишины на всю глубину предзаполнения: конец сессии сбрасывает
// очередь IQ в бэкенде, и без него в мусор ушёл бы ЕЩЁ НЕ СЫГРАННЫЙ спад
// последней посылки (то есть щелчок в эфир). При hang > 0 хвост и так
// тишина, но полагаться на настройку тут нельзя.
Emit(False, MsToSamples(CW_PREFILL_MS + 5), False);
Flush;
finally
if Br and Assigned(FOnSession) then FOnSession(False);
end;
end;
procedure TCWLocalKeyer.Execute;
begin
while not Terminated do
begin
if not WorkPending then
begin
FWake.WaitFor(100);
Continue;
end;
RunSession;
end;
end;
{ ── TCWKeyPort ────────────────────────────────────────────────────────────── }
constructor TCWKeyPort.Create;
begin
FLock := TCriticalSection.Create;
FHandle := 0;
FreeOnTerminate := False;
inherited Create(False);
end;
destructor TCWKeyPort.Destroy;
begin
Terminate;
WaitFor;
ClosePort;
FLock.Free;
inherited Destroy;
end;
procedure TCWKeyPort.Configure(const C: TCWKeyPortConfig);
begin
FLock.Enter;
try
if (FCfg.Enabled = C.Enabled) and (FCfg.Port = C.Port)
and (FCfg.DotLine = C.DotLine) and (FCfg.DashLine = C.DashLine)
and (FCfg.Invert = C.Invert) and (FCfg.PowerDTR = C.PowerDTR)
and (FCfg.PowerRTS = C.PowerRTS) then Exit;
FCfg := C;
FDirty := True;
finally
FLock.Leave;
end;
end;
function TCWKeyPort.LastError: string;
begin
FLock.Enter;
try
Result := FLastErr;
finally
FLock.Leave;
end;
end;
procedure TCWKeyPort.SetErr(const S: string);
begin
FLock.Enter;
try
FLastErr := S;
finally
FLock.Leave;
end;
end;
procedure TCWKeyPort.Release;
// Отпустить ключ: порт закрылся/выключили — генератор не должен остаться с
// «зажатым» лепестком, иначе несущая повиснет в эфире.
begin
if (FPrevDot or FPrevDash) and Assigned(FOnKey) then FOnKey(False, False);
FPrevDot := False;
FPrevDash := False;
end;
procedure TCWKeyPort.ClosePort;
begin
if FHandle > 0 then
begin
SerSetDTR(FHandle, False);
SerSetRTS(FHandle, False);
SerClose(FHandle);
end;
FHandle := 0;
end;
function TCWKeyPort.OpenPort(const C: TCWKeyPortConfig): Boolean;
begin
Result := False;
if C.Port = '' then Exit;
FHandle := SerOpen(C.Port);
if FHandle <= 0 then
begin
FHandle := 0;
SetErr('cannot open ' + C.Port);
Exit;
end;
// Скорость/формат манипулятору безразличны (данные не идут), но порт должен
// быть настроен — иначе часть драйверов не отдаёт модемные линии.
SerSetParams(FHandle, 9600, 8, NoneParity, 1, []);
SerSetDTR(FHandle, C.PowerDTR);
SerSetRTS(FHandle, C.PowerRTS);
SetErr('');
Result := True;
end;
function TCWKeyPort.ReadLine(L: TCWKeyLine): Boolean;
begin
case L of
cklDSR: Result := SerGetDSR(FHandle);
cklCD: Result := SerGetCD(FHandle);
cklRI: Result := SerGetRI(FHandle);
else
Result := SerGetCTS(FHandle);
end;
end;
procedure TCWKeyPort.Execute;
var
C: TCWKeyPortConfig;
Dot, Dash: Boolean;
Retry: QWord;
NeedReopen: Boolean;
begin
Retry := 0;
while not Terminated do
begin
FLock.Enter;
try
C := FCfg;
NeedReopen := FDirty;
FDirty := False;
finally
FLock.Leave;
end;
if NeedReopen then
begin
Release;
ClosePort;
Retry := 0;
end;
if not C.Enabled then
begin
if FHandle > 0 then begin Release; ClosePort; end;
Sleep(100);
Continue;
end;
if FHandle <= 0 then
begin
// Переоткрытие раз в 2 с: адаптер могли воткнуть уже после старта.
if GetTickCount64 < Retry then begin Sleep(100); Continue; end;
Retry := GetTickCount64 + 2000;
if not OpenPort(C) then begin Sleep(100); Continue; end;
end;
Dot := ReadLine(C.DotLine);
Dash := ReadLine(C.DashLine);
if C.Invert then begin Dot := not Dot; Dash := not Dash; end;
if (Dot <> FPrevDot) or (Dash <> FPrevDash) then
begin
FPrevDot := Dot;
FPrevDash := Dash;
if Assigned(FOnKey) then FOnKey(Dot, Dash);
end;
Sleep(1);
end;
Release;
end;
end.
+2
View File
@@ -731,6 +731,8 @@ begin
Result.NumADCs := 1; Result.NumADCs := 1;
// PureSignal: нужны свободные DDC0/1 под feedback — платы с DDCBase=2. // PureSignal: нужны свободные DDC0/1 под feedback — платы с DDCBase=2.
Result.HasPureSignal := FDevice.BoardType in [3, 4, 5, 10]; Result.HasPureSignal := FDevice.BoardType in [3, 4, 5, 10];
// Кейер в прошивке есть у всех плат P2 (DUC Specific байты 5..13).
Result.HasCWKeyer := True;
end; end;
constructor THPSDRNetwork.Create; constructor THPSDRNetwork.Create;
+5
View File
@@ -79,6 +79,10 @@ var
out val: clonglong): cint; cdecl; out val: clonglong): cint; cdecl;
iio_device_attr_write_longlong: function(dev: Piio_device; attr: PAnsiChar; iio_device_attr_write_longlong: function(dev: Piio_device; attr: PAnsiChar;
val: clonglong): cint; cdecl; val: clonglong): cint; cdecl;
// 'calib_mode' — режим калибровок AD936x (строка): 'auto' = драйвер сам
// переигрывает TX quad (утечка LO + квадратура) на каждой смене TX LO.
iio_device_attr_write: function(dev: Piio_device; attr: PAnsiChar;
src: PAnsiChar): ptrint; cdecl;
// ---- Debug-атрибуты устройства (debugfs) ---- // ---- Debug-атрибуты устройства (debugfs) ----
// Нужны для выбора антенного разъёма у AD936x в режиме 1R1T: // Нужны для выбора антенного разъёма у AD936x в режиме 1R1T:
@@ -195,6 +199,7 @@ begin
iio_channel_attr_write_double := Sym('iio_channel_attr_write_double'); iio_channel_attr_write_double := Sym('iio_channel_attr_write_double');
iio_device_attr_read_longlong := Sym('iio_device_attr_read_longlong'); iio_device_attr_read_longlong := Sym('iio_device_attr_read_longlong');
iio_device_attr_write_longlong := Sym('iio_device_attr_write_longlong'); iio_device_attr_write_longlong := Sym('iio_device_attr_write_longlong');
iio_device_attr_write := Sym('iio_device_attr_write');
iio_device_debug_attr_read := Sym('iio_device_debug_attr_read'); iio_device_debug_attr_read := Sym('iio_device_debug_attr_read');
iio_device_debug_attr_write := Sym('iio_device_debug_attr_write'); iio_device_debug_attr_write := Sym('iio_device_debug_attr_write');
iio_device_debug_attr_write_longlong := Sym('iio_device_debug_attr_write_longlong'); iio_device_debug_attr_write_longlong := Sym('iio_device_debug_attr_write_longlong');
+12 -6
View File
@@ -3688,6 +3688,9 @@ begin
FController.ServiceBeaconLock; FController.ServiceBeaconLock;
// Pluto: опрос температуры/RSSI (троттлинг внутри; no-op для HPSDR). // Pluto: опрос температуры/RSSI (троттлинг внутри; no-op для HPSDR).
FController.ServicePlutoTelemetry; FController.ServicePlutoTelemetry;
// Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет — гнать индикацию
// и отпускать реле T/R после выдержки больше некому).
FController.ServiceCWKeyed;
// PureSignal: state machine команд + auto-attenuate + опрос GetPSInfo // PureSignal: state machine команд + auto-attenuate + опрос GetPSInfo
// (no-op на бэкендах без PS; рендер кнопок/поповера через rfPureSignal). // (no-op на бэкендах без PS; рендер кнопок/поповера через rfPureSignal).
FController.PureSignalTick; FController.PureSignalTick;
@@ -4601,7 +4604,7 @@ begin
if FController.FNetwork.Connected and FController.FNetwork.Running then if FController.FNetwork.Connected and FController.FNetwork.Running then
begin begin
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel,
FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone); FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
FController.FNetwork.SendFullHP; FController.FNetwork.SendFullHP;
end; end;
@@ -4623,7 +4626,7 @@ begin
if FController.FNetwork.Connected and FController.FNetwork.Running then if FController.FNetwork.Connected and FController.FNetwork.Running then
begin begin
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel,
FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone); FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
FController.FNetwork.SendFullHP; FController.FNetwork.SendFullHP;
end; end;
@@ -5069,7 +5072,7 @@ begin
if FController.FNetwork.Connected and FController.FNetwork.Running then if FController.FNetwork.Connected and FController.FNetwork.Running then
begin begin
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq),
XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True, FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True,
FController.FTuning, FController.FTwoTone); FController.FTuning, FController.FTwoTone);
FController.FNetwork.SendFullHP; FController.FNetwork.SendFullHP;
end; end;
@@ -8040,7 +8043,7 @@ begin
end; end;
FController.FDriveLevel := CalcDriveByte; FController.FDriveLevel := CalcDriveByte;
if FController.FNetwork.Connected and FController.FNetwork.Running then if FController.FNetwork.Connected and FController.FNetwork.Running then
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone); FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
end; end;
procedure TMainForm.OnPlutoTxMaxAttChange(MaxAttDb: Double); procedure TMainForm.OnPlutoTxMaxAttChange(MaxAttDb: Double);
@@ -8075,7 +8078,7 @@ begin
end; end;
FController.FDriveLevel := CalcDriveByte; FController.FDriveLevel := CalcDriveByte;
if FController.FNetwork.Connected and FController.FNetwork.Running then if FController.FNetwork.Connected and FController.FNetwork.Running then
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone); FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
end; end;
procedure TMainForm.OnTXSettingsChange(const T: TTXSettings); procedure TMainForm.OnTXSettingsChange(const T: TTXSettings);
@@ -8121,7 +8124,7 @@ begin
FController.FDSPEngine.SetDriveLevel(FController.FTXSettings.TUNLevel / 100.0); FController.FDSPEngine.SetDriveLevel(FController.FTXSettings.TUNLevel / 100.0);
if FController.FNetwork.Connected and FController.FNetwork.Running then if FController.FNetwork.Connected and FController.FNetwork.Running then
begin begin
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FController.FDriveLevel, FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel,
FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone); FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
FController.FNetwork.SendFullHP; FController.FNetwork.SendFullHP;
end; end;
@@ -9137,6 +9140,9 @@ begin
PushTXProfilesToSettings; PushTXProfilesToSettings;
SF.LoadTXSettings(FController.FTXSettings, SF.LoadTXSettings(FController.FTXSettings,
IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType)); IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
// Телеграф: есть ли кейер в железе. У AD936x его нет — там телеграф может
// быть только программным, и настройки прошивки в группе гасятся.
SF.SetCWBackend(FController.BackendCaps.HasCWKeyer);
SF.LoadCWSettings(FController.CWSettings); SF.LoadCWSettings(FController.CWSettings);
SF.LoadAlexSettings(FController.FAlexSettings, SF.LoadAlexSettings(FController.FAlexSettings,
IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType)); IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
+10
View File
@@ -458,6 +458,7 @@ begin
Result.HasWideband := False; Result.HasWideband := False;
Result.HasDitherRandom := False; Result.HasDitherRandom := False;
Result.HasHWMic := False; Result.HasHWMic := False;
Result.HasCWKeyer := False; // голый AD936x — телеграф формирует PC
Result.HasPLLStatus := False; Result.HasPLLStatus := False;
Result.HasHWGain := True; // manual gain / hw-AGC Result.HasHWGain := True; // manual gain / hw-AGC
Result.HasRFBandwidth := True; Result.HasRFBandwidth := True;
@@ -634,6 +635,15 @@ begin
if not (FRx2Avail or FRxMuxAvail) then FRxChanSel := 0; if not (FRx2Avail or FRxMuxAvail) then FRxChanSel := 0;
FConnected := True; FConnected := True;
// ★Калибровки — в автомат. AD936x нулит утечку гетеродина и квадратурный
// разбаланс передатчика калибровкой TX quad, и в режиме 'auto' драйвер сам
// прогоняет её на каждой смене TX LO. Найдено на живом Pluto: там стояло
// 'manual_tx_quad', то есть калибровка НЕ переигрывалась вовсе — утечка LO
// (в телеграфе она видна отдельной несущей в стороне от рабочей частоты)
// оставалась такой, какой её оставила прошлая программа. Состояние живёт в
// чипе между сессиями, поэтому выставляем явно.
if Assigned(iio_device_attr_write) then
iio_device_attr_write(FPhy, 'calib_mode', 'auto');
// Приводим железо в известное состояние: валидный rate + вход + усиление. // Приводим железо в известное состояние: валидный rate + вход + усиление.
if FSampleRate < PLUTO_MIN_SR then FSampleRate := PLUTO_DEF_SR; if FSampleRate < PLUTO_MIN_SR then FSampleRate := PLUTO_DEF_SR;
ApplySampleRate(FSampleRate); ApplySampleRate(FSampleRate);
+5
View File
@@ -60,6 +60,11 @@ type
// feedback (Angelia/Orion/Orion2/Saturn). Гейтит кнопки PS/2TON. // feedback (Angelia/Orion/Orion2/Saturn). Гейтит кнопки PS/2TON.
// Достоверно после Connect. // Достоверно после Connect.
HasPureSignal: Boolean; HasPureSignal: Boolean;
// Телеграфный кейер в железе: точки/тире формирует прошивка по параметрам из
// DUC Specific (openHPSDR P2), а манипулятор воткнут в сам трансивер. False
// (голый AD936x) означает, что телеграф обязан формировать локальный
// генератор — см. CWKeyer.pas.
HasCWKeyer: Boolean;
MinSampleRate: Integer; MinSampleRate: Integer;
RatePresets: TBackendRateArray; // пресеты для SampleRateOverlay RatePresets: TBackendRateArray; // пресеты для SampleRateOverlay
MinFreqHz: Double; MinFreqHz: Double;
+374 -46
View File
@@ -50,7 +50,7 @@ uses
Classes, SysUtils, Math, Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse; Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer;
// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для // Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1). // движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
@@ -457,6 +457,19 @@ type
FCWSTEnv: Double; // огибающая 0..1, сглаживает щелчки (DSP-поток) FCWSTEnv: Double; // огибающая 0..1, сглаживает щелчки (DSP-поток)
FCWSTBufL: array of Single; // буферы подмешивания (только DSP-поток) FCWSTBufL: array of Single; // буферы подмешивания (только DSP-поток)
FCWSTBufR: array of Single; 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; FAlexSettings: TAlexSettings;
FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only) FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only)
// Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер. // Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер.
@@ -578,6 +591,11 @@ type
// uplink-LO не подвержен дрейфу LNB). // uplink-LO не подвержен дрейфу LNB).
function XvtrTranslateTX(VisibleHz: Double): Double; function XvtrTranslateTX(VisibleHz: Double): Double;
function ActiveTXFreqHz: Double; function ActiveTXFreqHz: Double;
// TXTuneFreqHz — ЕДИНАЯ дверь «какую частоту передачи класть в железо»:
// ActiveTXFreqHz через трансвертер, минус сдвиг несущей локального кейера
// (CWCarrierOffsetHz). Прямых XvtrTranslateTX(ActiveTXFreqHz) в пушах
// состояния быть не должно — иначе телеграф уедет от VFO ровно на сдвиг.
function TXTuneFreqHz: Double;
// Режим передающего тракта: слайс-источник TX (FTxSliceId) либо главный FMode. // Режим передающего тракта: слайс-источник TX (FTxSliceId) либо главный FMode.
function ActiveTXMode: Integer; function ActiveTXMode: Integer;
// Диапазон для калибровки драйва/антенны TX (банд TX-слайса при кросс-банде). // Диапазон для калибровки драйва/антенны TX (банд TX-слайса при кросс-банде).
@@ -616,10 +634,22 @@ type
// (FCurrentBand при входе в слот не меняется). // (FCurrentBand при входе в слот не меняется).
function XvtrSlotActive: Boolean; function XvtrSlotActive: Boolean;
function TXProhibited: Boolean; // DoNotTx-бэнд / RX-only XVTR 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 function CWOptionsByte: Byte; // DUC Specific байт 5
procedure SyncCWKeyer; // байт 5 под текущий TX-режим procedure SyncCWKeyer; // байт 5 под текущий TX-режим
procedure CWXKeyEvent(Down: Boolean); // колбэк потока передачи 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-поток). Возвращает // Подмешать программный сайдтон в блок RX-аудио (DSP-поток). Возвращает
// False, если подмешивать нечего — тогда вызывающий отдаёт исходный блок. // False, если подмешивать нечего — тогда вызывающий отдаёт исходный блок.
function MixSidetone(const Left, Right: array of Single; function MixSidetone(const Left, Right: array of Single;
@@ -964,6 +994,9 @@ type
// В телеграфе FTransmitting не поднимается: передачей владеет кейер прошивки, // В телеграфе FTransmitting не поднимается: передачей владеет кейер прошивки,
// и единственный признак эфира — HPS_PTT в статусе. // и единственный признак эфира — HPS_PTT в статусе.
function RadioKeyed: Boolean; function RadioKeyed: Boolean;
// Фронты RadioKeyed → индикация. Зовётся из разбора HP-статуса (openHPSDR) и
// с UI-таймера: у Pluto статуса нет вовсе, а телеграф там локальный.
procedure ServiceCWKeyed;
function CWSettings: TCWSettings; function CWSettings: TCWSettings;
procedure SetCWSettings(const C: TCWSettings); procedure SetCWSettings(const C: TCWSettings);
// Программная передача текста: PC сам выдерживает тайминги и дёргает бит // Программная передача текста: PC сам выдерживает тайминги и дёргает бит
@@ -1298,7 +1331,15 @@ end;
destructor TRadioController.Destroy; destructor TRadioController.Destroy;
begin begin
// Поток передачи телеграфа гасим ПЕРВЫМ: его колбэк ключа лезет в бэкенд. // Потоки телеграфа гасим ПЕРВЫМИ: их колбэки лезут в бэкенд и в движок.
// Порядок — порт ключа → генератор: иначе лепесток успел бы разбудить уже
// разрушаемый генератор.
if Assigned(FCWKeyPort) then FreeAndNil(FCWKeyPort);
if Assigned(FCWLocal) then
begin
FCWLocal.SetArmed(False);
FreeAndNil(FCWLocal);
end;
if Assigned(FCWSender) then if Assigned(FCWSender) then
begin begin
FCWSender.AbortSending; FCWSender.AbortSending;
@@ -1334,7 +1375,7 @@ begin
// Состояние передачи для ЖЕЛЕЗА — RadioKeyed, а не FTransmitting: в // Состояние передачи для ЖЕЛЕЗА — RadioKeyed, а не FTransmitting: в
// телеграфе ключ замыкает прошивка, и реле T/R с антенной и OC обязаны // телеграфе ключ замыкает прошивка, и реле T/R с антенной и OC обязаны
// ехать за ней. PTT в кадре этим не поднимается (он живёт в SendPTT). // ехать за ней. PTT в кадре этим не поднимается (он живёт в SendPTT).
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, RadioKeyed, True, True, FTuning, FTwoTone); FDriveLevel, RadioKeyed, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP; FNetwork.SendFullHP;
end; end;
@@ -1604,28 +1645,10 @@ begin
end; end;
FHWPTTActive := FHPpHWPTT; FHWPTTActive := FHPpHWPTT;
if FHPpHWPTT then FCWKeyedAt := GetTickCount64; if FHPpHWPTT then FCWKeyedAt := GetTickCount64;
// UI про телеграфную передачу иначе не узнает вовсе: SetMOX здесь не ServiceCWKeyed;
// вызывается, а значит и rfTransmitting никто не шлёт. Фронт RadioKeyed
// (с выдержкой) — единственный сигнал «покрасить бейджи и полосы».
if FCWKeyedShown <> RadioKeyed then
begin
FCWKeyedShown := RadioKeyed;
// ★Железная обвязка обязана идти за фактическим ключом: реле T/R (Alex
// bit 27), TX-антенна, bypass/Ext1 и выходы OC (ими ключуется усилитель)
// считаются в бэкенде от состояния передачи. Без этого пути прошивка
// гнала бы несущую, пока Alex стоит в ПРИЁМНОЙ конфигурации.
PushNetworkState;
Changed(rfTransmitting);
end;
Exit; Exit;
end; end;
// Ушли из телеграфа/сняли вооружение — вернуть индикацию в исходное. ServiceCWKeyed; // ушли из телеграфа — вернуть индикацию в исходное
if FCWKeyedShown then
begin
FCWKeyedShown := False;
PushNetworkState;
Changed(rfTransmitting);
end;
// Hardware PTT (footswitch / mic PTT): фронт на rate HP Status — не пропустить // Hardware PTT (footswitch / mic PTT): фронт на rate HP Status — не пропустить
// короткое нажатие. HWPTT не перекрывает ручной MOX; выключаем TX только если // короткое нажатие. HWPTT не перекрывает ручной MOX; выключаем TX только если
// его включил именно HWPTT. // его включил именно HWPTT.
@@ -3080,6 +3103,26 @@ begin
Result := Result + FDSPEngine.CWLOOffset(ActiveTXMode); Result := Result + FDSPEngine.CWLOOffset(ActiveTXMode);
end; 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; function TRadioController.ActiveTXMode: Integer;
var S: TCtrlSlice; var S: TCtrlSlice;
begin begin
@@ -4498,8 +4541,13 @@ const
begin begin
if (FNetwork = nil) or not IsPluto then Exit; if (FNetwork = nil) or not IsPluto then Exit;
// На передаче (MOX/TUN) — мощность от drive; на приёме — макс. аттенюация (выкл). // На передаче (MOX/TUN) — мощность от drive; на приёме — макс. аттенюация (выкл).
if FTransmitting or FTuning then FNetwork.SetTxAtten(PlutoTxAttForDrive) // ★В телеграфе FTransmitting не поднимается вовсе (передачей владеет кейер),
else FNetwork.SetTxAtten(TX_OFF); // поэтому спрашиваем и сессию локального генератора — иначе манипуляция шла бы
// в закрытый аттенюатор, то есть в никуда.
if FTransmitting or FTuning or FCWLocalTX then
FNetwork.SetTxAtten(PlutoTxAttForDrive)
else
FNetwork.SetTxAtten(TX_OFF);
end; end;
procedure TRadioController.SetPlutoTxMaxAtt(Db: Double); procedure TRadioController.SetPlutoTxMaxAtt(Db: Double);
@@ -5134,15 +5182,64 @@ begin
and FXvtrSettings.Entries[FCurrentXvtr].RXOnly); and FXvtrSettings.Entries[FCurrentXvtr].RXOnly);
end; end;
function TRadioController.CWTXActive: Boolean; function TRadioController.CWFWSource: Boolean;
// «Телеграфом управляет железо»: режим ИСТОЧНИКА передачи — CW и кейер в // Манипуляцию рисует ПРОШИВКА (кейер в FPGA, openHPSDR P2). Путь по умолчанию:
// прошивке. В этом состоянии голосовой TXA не запускается вовсе (эталон // тайминги в железе, джиттер PC не влияет вовсе.
// Thetis, console.cs:29570), а точки/тире формирует FPGA.
begin 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); // где передавать нельзя — кейер разоружён and (not TXProhibited); // где передавать нельзя — кейер разоружён
end; 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; function TRadioController.CWOptionsByte: Byte;
// DUC Specific байт 5. Порт pihpsdr new_protocol.c:1353-1383 (там же // DUC Specific байт 5. Порт pihpsdr new_protocol.c:1353-1383 (там же
// объяснение, почему iambic-бит выставляется и для Mode B). // объяснение, почему iambic-бит выставляется и для Mode B).
@@ -5150,7 +5247,10 @@ function TRadioController.CWOptionsByte: Byte;
// на замыкание ключа посреди SSB (Thetis, console.cs:27394). // на замыкание ключа посреди SSB (Thetis, console.cs:27394).
begin begin
Result := 0; Result := 0;
if not CWTXActive then Exit; // ★Байт обязан быть нулевым не только вне телеграфа, но и когда манипуляцию
// взял на себя локальный генератор: иначе прошивка ключевала бы ПАРАЛЛЕЛЬНО
// с ним (и поднимала бы PTT на замыкание своего разъёма).
if not (CWTXActive and CWFWSource) then Exit;
Result := Result or CW_MODE; Result := Result or CW_MODE;
if FCWSettings.ReverseKeys then Result := Result or CW_REVERSE_KEYS; if FCWSettings.ReverseKeys then Result := Result or CW_REVERSE_KEYS;
case FCWSettings.KeyerMode of case FCWSettings.KeyerMode of
@@ -5573,7 +5673,7 @@ begin
if FTransmitting and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then if FTransmitting and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin begin
FNetwork.ClearDUCIQQueue; FNetwork.ClearDUCIQQueue;
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, True, True, True, FTuning, FTwoTone); FDriveLevel, True, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP; // 1й пакет: Alex bit27=1 (T/R relay), drive — без PTT FNetwork.SendFullHP; // 1й пакет: Alex bit27=1 (T/R relay), drive — без PTT
FNetwork.SendPTT(True); // 2й пакет: PTT=1 после переключения реле FNetwork.SendPTT(True); // 2й пакет: PTT=1 после переключения реле
@@ -5666,13 +5766,16 @@ begin
FNetwork.SendPTT(False); // 1й пакет: PTT=0 — сначала снимаем PTT FNetwork.SendPTT(False); // 1й пакет: PTT=0 — сначала снимаем PTT
// PureSignal: вернуть DDC0/1 в RX-конфигурацию (убрать feedback-DDC). // PureSignal: вернуть DDC0/1 в RX-конфигурацию (убрать feedback-DDC).
FNetwork.SetPureSignalTX(False); FNetwork.SetPureSignalTX(False);
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, False, True, True, FTuning, FTwoTone); FDriveLevel, False, True, True, FTuning, FTwoTone);
FNetwork.ClearDUCIQQueue; FNetwork.ClearDUCIQQueue;
FNetwork.SendFullHP; // 2й пакет: Alex bit27=0 (T/R relay RX) FNetwork.SendFullHP; // 2й пакет: Alex bit27=0 (T/R relay RX)
end; end;
ApplyPlutoTxAtten; // Pluto: TX-мощность по drive на передаче, OFF на приёме ApplyPlutoTxAtten; // Pluto: TX-мощность по drive на передаче, OFF на приёме
ApplyActiveVolume; // T/R: переключаем громкость RX ↔ TX-монитор (если monitoring) ApplyActiveVolume; // T/R: переключаем громкость RX ↔ TX-монитор (если monitoring)
// При выключенном break-in локальный кейер вооружён только вместе с MOX
// (оператор держит T/R руками) — вооружение обязано пойти за этой кнопкой.
SyncCWKeyer;
Changed(rfTransmitting); Changed(rfTransmitting);
end; end;
@@ -5712,7 +5815,7 @@ begin
ApplyWDSPDrive(FDrivePercent); ApplyWDSPDrive(FDrivePercent);
if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin begin
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, FTransmitting, True, True, FTuning, FTwoTone); FDriveLevel, FTransmitting, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP; FNetwork.SendFullHP;
end; end;
@@ -5747,6 +5850,30 @@ begin
Result := (GetTickCount64 - FCWKeyedAt) < Hold; Result := (GetTickCount64 - FCWKeyedAt) < Hold;
end; 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; function TRadioController.CWSettings: TCWSettings;
begin begin
Result := FCWSettings; Result := FCWSettings;
@@ -5771,7 +5898,10 @@ begin
if Assigned(FCWSender) then if Assigned(FCWSender) then
FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight); FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight);
SendDUCSpecificFromSettings; SendDUCSpecificFromSettings;
if Assigned(FNetwork) then FNetwork.SetCWKeyerArmed(CWTXActive); // Локальный кейер: параметры + вооружение (галку «кейер в программе», как и
// запрет передачи, разбирает SyncCWKeyer — одна дверь на всё).
PushCWLocalConfig;
SyncCWKeyer;
// Тон TUN в телеграфе равен pitch (несущая обязана лечь на VFO, см. SetTune): // Тон TUN в телеграфе равен pitch (несущая обязана лечь на VFO, см. SetTune):
// если крутят pitch прямо на настройке — обновляем живьём. // если крутят pitch прямо на настройке — обновляем живьём.
if FTuning and FWDSPReady and Assigned(FDSPEngine) if FTuning and FWDSPReady and Assigned(FDSPEngine)
@@ -5788,6 +5918,8 @@ procedure TRadioController.SyncCWKeyer;
// Байт 5 зависит от режима ИСТОЧНИКА передачи, поэтому каждая смена режима или // Байт 5 зависит от режима ИСТОЧНИКА передачи, поэтому каждая смена режима или
// TX-слайса обязана его перепослать: вне телеграфа он должен стать нулевым, // TX-слайса обязана его перепослать: вне телеграфа он должен стать нулевым,
// иначе прошивка поднимет PTT на замыкание ключа посреди SSB. // иначе прошивка поднимет PTT на замыкание ключа посреди SSB.
var
WasArmed: Boolean;
begin begin
// Дёшево и идемпотентно: пара сравнений плюс ранний выход внутри // Дёшево и идемпотентно: пара сравнений плюс ранний выход внутри
// SetCWKeyerArmed. Поэтому зовётся не только на смене режима/источника, но и // SetCWKeyerArmed. Поэтому зовётся не только на смене режима/источника, но и
@@ -5798,11 +5930,167 @@ begin
if (not (ActiveTXMode in [MODE_CWL, MODE_CWU])) if (not (ActiveTXMode in [MODE_CWL, MODE_CWU]))
and Assigned(FCWSender) and FCWSender.Busy then CWXAbort; and Assigned(FCWSender) and FCWSender.Busy then CWXAbort;
// Вооружённый кейер = железо может выйти в эфир само ⇒ бэкенд обязан класть // Вооружённый кейер = железо может выйти в эфир само ⇒ бэкенд обязан класть
// уровень мощности в КАЖДЫЙ HP-кадр, а не только на передаче. // уровень мощности в КАЖДЫЙ HP-кадр, а не только на передаче. Локальный
// генератор — та же история: FTransmitting в телеграфе не поднимается, а
// несущая идёт.
if Assigned(FNetwork) then FNetwork.SetCWKeyerArmed(CWTXActive); 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; if CWOptionsByte <> FLastCWOptions then SendDUCSpecificFromSettings;
end; 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); procedure TRadioController.CWXKeyEvent(Down: Boolean);
// Поток передачи, НЕ GUI. Обе операции потокобезопасны: SetCWXKey шлёт HP-пакет // Поток передачи, НЕ GUI. Обе операции потокобезопасны: SetCWXKey шлёт HP-пакет
// под своим локом бэкенда, FCWKeyDown читает DSP-поток. // под своим локом бэкенда, FCWKeyDown читает DSP-поток.
@@ -5814,7 +6102,8 @@ end;
function TRadioController.CWXBusy: Boolean; function TRadioController.CWXBusy: Boolean;
begin begin
Result := Assigned(FCWSender) and FCWSender.Busy; Result := (Assigned(FCWSender) and FCWSender.Busy)
or (Assigned(FCWLocal) and FCWLocal.Busy);
end; end;
procedure TRadioController.CWXSend(const Text: string); procedure TRadioController.CWXSend(const Text: string);
@@ -5826,6 +6115,15 @@ begin
// слот трансвертера, бэнд с DoNotTx) — там кейер разоружён, и дёргать бит // слот трансвертера, бэнд с DoNotTx) — там кейер разоружён, и дёргать бит
// CWX незачем. // CWX незачем.
if not CWTXActive then Exit; if not CWTXActive then Exit;
// Локальный генератор сам держит тайминги В СЭМПЛАХ — текст уходит ему как
// есть; поток TCWSender (бит CWX прошивки) при этом не нужен вовсе.
if CWLocalSource then
begin
EnsureCWLocal;
SyncCWKeyer; // вооружиться, если пришли сюда первым делом
FCWLocal.SendText(Text);
Exit;
end;
if not Assigned(FCWSender) then if not Assigned(FCWSender) then
FCWSender := TCWSender.Create(Self.CWXKeyEvent); FCWSender := TCWSender.Create(Self.CWXKeyEvent);
FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight); FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight);
@@ -5835,6 +6133,7 @@ end;
procedure TRadioController.CWXAbort; procedure TRadioController.CWXAbort;
begin begin
if Assigned(FCWSender) then FCWSender.AbortSending; if Assigned(FCWSender) then FCWSender.AbortSending;
if Assigned(FCWLocal) then FCWLocal.AbortText;
end; end;
function TRadioController.MixSidetone(const Left, Right: array of Single; function TRadioController.MixSidetone(const Left, Right: array of Single;
@@ -5846,15 +6145,29 @@ const
RAMP_MS = 5; RAMP_MS = 5;
AUDIO_SR = 48000; AUDIO_SR = 48000;
var var
i: Integer; i, Got: Integer;
Amp, Step, Rate, S: Double; Amp, Step, Rate, S: Double;
Local: Boolean;
begin begin
Result := False; Result := False;
if not FCWSettings.SidetoneSW then Exit; if not FCWSettings.SidetoneSW then Exit;
if FCWSettings.SidetoneSWLevel <= 0 then Exit; if FCWSettings.SidetoneSWLevel <= 0 then Exit;
// Ключ отпущен и огибающая уже сошлась в ноль — блок трогать незачем.
if (not FCWKeyDown) and (FCWSTEnv <= 0.0) then Exit;
if Count <= 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 if Length(FCWSTBufL) < Count then
begin begin
SetLength(FCWSTBufL, Count); SetLength(FCWSTBufL, Count);
@@ -5865,7 +6178,18 @@ begin
Rate := 1000.0 / (RAMP_MS * AUDIO_SR); Rate := 1000.0 / (RAMP_MS * AUDIO_SR);
for i := 0 to Count - 1 do for i := 0 to Count - 1 do
begin 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 begin
FCWSTEnv := FCWSTEnv + Rate; FCWSTEnv := FCWSTEnv + Rate;
if FCWSTEnv > 1.0 then FCWSTEnv := 1.0; if FCWSTEnv > 1.0 then FCWSTEnv := 1.0;
@@ -6359,14 +6683,14 @@ begin
// Уровень пересчитываем здесь же: с вооружённым кейером он уедет в железо // Уровень пересчитываем здесь же: с вооружённым кейером он уедет в железо
// сразу, до всякой передачи, а после конструктора FDriveLevel ещё нулевой. // сразу, до всякой передачи, а после конструктора FDriveLevel ещё нулевой.
FDriveLevel := CalcDriveByte; 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-ЧАСТОТУ, а не на центр дисплея. Раньше здесь стоял центр, и до // ★DUC — на TX-ЧАСТОТУ, а не на центр дисплея. Раньше здесь стоял центр, и до
// первой перестройки VFO (или до первого MOX, который частоту дошлёт сам) // первой перестройки VFO (или до первого MOX, который частоту дошлёт сам)
// передатчик стоял не там, где маркер: при CTUN центр и VFO — разные частоты. // передатчик стоял не там, где маркер: при CTUN центр и VFO — разные частоты.
// С кейером в прошивке это стало слышно в эфире: железо может замкнуть ключ // С кейером в прошивке это стало слышно в эфире: железо может замкнуть ключ
// сразу после старта, никакого MOX перед этим не будет. // сразу после старта, никакого MOX перед этим не будет.
FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq), FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq),
XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel); TXTuneFreqHz, FDriveLevel);
FRunning := True; FRunning := True;
FRXStartTime := GetTickCount64; FRXStartTime := GetTickCount64;
FRXLastPktTime := 0; FRXLastPktTime := 0;
@@ -6424,11 +6748,15 @@ begin
// (3.3 UI пересоздаст по персисту при следующем старте). // (3.3 UI пересоздаст по персисту при следующем старте).
for p := 1 to MAX_PANS - 1 do for p := 1 to MAX_PANS - 1 do
RemovePanDDC(p); 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), FNetwork.SetRunAndFreq(False, XvtrTranslate(FCenterFreq),
XvtrTranslateTX(ActiveTXFreqHz), 0); TXTuneFreqHz, 0);
FRunning := False; FRunning := False;
FTransmitting := False; FTransmitting := False;
// ★Локальный кейер обязан разоружиться ЗДЕСЬ: таймер хозяина, который его
// пересинхронизирует, на остановленном радио уже не работает — иначе
// манипулятор продолжал бы гонять IQ в закрытую сессию.
SyncCWKeyer;
// Стоп при активном TX/TUN: хвост IQ в DUC-очереди не должен пережить // Стоп при активном TX/TUN: хвост IQ в DUC-очереди не должен пережить
// остановку (иначе при следующем старте уйдёт в эфир старый тон/голос), // остановку (иначе при следующем старте уйдёт в эфир старый тон/голос),
// а TUN-состояние (PostGen-тон) — вернуться в выключенное. // а TUN-состояние (PostGen-тон) — вернуться в выключенное.
+51 -1
View File
@@ -318,7 +318,10 @@ type
Pitch: Integer; // Гц, тон приёма/сайдтона (Thetis default 600) Pitch: Integer; // Гц, тон приёма/сайдтона (Thetis default 600)
// Память сообщений для программной передачи (окно CW Messages, F1..F8). // Память сообщений для программной передачи (окно CW Messages, F1..F8).
Messages: array[0..CW_MSG_COUNT-1] of string[63]; 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 KeyerMode: Integer; // 0=Straight, 1=Iambic A, 2=Iambic B
ReverseKeys: Boolean; // поменять точку и тире местами ReverseKeys: Boolean; // поменять точку и тире местами
StrictSpacing: Boolean; // строгие межзнаковые интервалы StrictSpacing: Boolean; // строгие межзнаковые интервалы
@@ -338,6 +341,26 @@ type
// состояние лепестков манипулятора — там верен только аппаратный тон. // состояние лепестков манипулятора — там верен только аппаратный тон.
SidetoneSW: Boolean; SidetoneSW: Boolean;
SidetoneSWLevel: Integer; // 0..100 % от полной шкалы аудио 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; end;
// ---- TX-профили (именованные снимки «как я звучу») ---------------------- // ---- TX-профили (именованные снимки «как я звучу») ----------------------
@@ -1789,6 +1812,15 @@ begin
C.SidetoneHWLevel := 50; C.SidetoneHWLevel := 50;
C.SidetoneSW := False; C.SidetoneSW := False;
C.SidetoneSWLevel := 30; 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[0] := 'CQ CQ DE ';
C.Messages[1] := 'DE '; C.Messages[1] := 'DE ';
@@ -1826,6 +1858,15 @@ begin
C.SidetoneHWLevel := EnsureRange(JI(O,'sidetone_hw_level', C.SidetoneHWLevel), 0, 127); C.SidetoneHWLevel := EnsureRange(JI(O,'sidetone_hw_level', C.SidetoneHWLevel), 0, 127);
C.SidetoneSW := JB(O,'sidetone_sw', C.SidetoneSW); C.SidetoneSW := JB(O,'sidetone_sw', C.SidetoneSW);
C.SidetoneSWLevel := EnsureRange(JI(O,'sidetone_sw_level', C.SidetoneSWLevel), 0, 100); 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 for i := 0 to CW_MSG_COUNT - 1 do
C.Messages[i] := Copy(JS(O, 'msg_' + IntToStr(i), C.Messages[i]), 1, 63); C.Messages[i] := Copy(JS(O, 'msg_' + IntToStr(i), C.Messages[i]), 1, 63);
end; end;
@@ -1850,6 +1891,15 @@ begin
JW(O,'sidetone_hw_level', C.SidetoneHWLevel); JW(O,'sidetone_hw_level', C.SidetoneHWLevel);
JW(O,'sidetone_sw', C.SidetoneSW); JW(O,'sidetone_sw', C.SidetoneSW);
JW(O,'sidetone_sw_level', C.SidetoneSWLevel); 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 for i := 0 to CW_MSG_COUNT - 1 do
JWS(O, 'msg_' + IntToStr(i), C.Messages[i]); JWS(O, 'msg_' + IntToStr(i), C.Messages[i]);
end; end;
+122 -11
View File
@@ -289,7 +289,9 @@ type
FEdTXCTCSSFreq: TFlatFloatSpinEdit; FEdTXCTCSSFreq: TFlatFloatSpinEdit;
// CW (телеграф) — свойство устройства, профилем не переключается // CW (телеграф) — свойство устройства, профилем не переключается
FCW: TCWSettings; FCW: TCWSettings;
FChkCWFWKeyer: TFlatCheckBox; // Кто рисует точки: прошивка / программа / никто. Одна развилка — один
// список (две галки на неё разъезжались во взаимоисключающих состояниях).
FCmbCWKeyerSrc: TFlatComboBox;
FEdCWPitch: TFlatSpinEdit; FEdCWPitch: TFlatSpinEdit;
FEdCWSpeed: TFlatSpinEdit; FEdCWSpeed: TFlatSpinEdit;
FEdCWWeight: TFlatSpinEdit; FEdCWWeight: TFlatSpinEdit;
@@ -304,6 +306,16 @@ type
FEdCWSidetoneHWLv: TFlatSpinEdit; FEdCWSidetoneHWLv: TFlatSpinEdit;
FChkCWSidetoneSW: TFlatCheckBox; FChkCWSidetoneSW: TFlatCheckBox;
FEdCWSidetoneSWLv: TFlatSpinEdit; FEdCWSidetoneSWLv: TFlatSpinEdit;
// Локальный кейер: манипуляцию рисует сам EWSDR (обязателен на AD936x)
FEdCWOffset: TFlatSpinEdit;
FChkCWKeyPort: TFlatCheckBox;
FEdCWKeyPort: TFlatEdit;
FCmbCWDotLine: TFlatComboBox;
FCmbCWDashLine: TFlatComboBox;
FChkCWKeyInvert: TFlatCheckBox;
FChkCWKeyDTR: TFlatCheckBox;
FChkCWKeyRTS: TFlatCheckBox;
FCWHasFWKeyer: Boolean; // у железа есть кейер в прошивке
// TX Display (отдельный analyzer) // TX Display (отдельный analyzer)
FCmbTXFFT: TFlatComboBox; FCmbTXFFT: TFlatComboBox;
FCmbTXWindow: TFlatComboBox; FCmbTXWindow: TFlatComboBox;
@@ -658,6 +670,8 @@ type
TcpEnabled: Boolean; TcpPort: Integer); TcpEnabled: Boolean; TcpPort: Integer);
procedure LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0); procedure LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0);
procedure LoadCWSettings(const C: TCWSettings); procedure LoadCWSettings(const C: TCWSettings);
// Есть ли у активного железа кейер в прошивке (openHPSDR — да, AD936x — нет).
procedure SetCWBackend(HasFWKeyer: Boolean);
// Список TX-профилей + активный + drive активного (ползунок DRV живёт в // Список TX-профилей + активный + drive активного (ползунок DRV живёт в
// главном окне, здесь показываем его значение как часть профиля). // главном окне, здесь показываем его значение как часть профиля).
procedure LoadTXProfiles(const Names: array of string; procedure LoadTXProfiles(const Names: array of string;
@@ -2042,9 +2056,12 @@ begin
// ---- CW: телеграфом в openHPSDR P2 управляет прошивка ------------------- // ---- CW: телеграфом в openHPSDR P2 управляет прошивка -------------------
Inc(Y, 190 + 12); Inc(Y, 190 + 12);
Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 320); Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 460);
FChkCWFWKeyer := MkChkCW(Grp, PAD, R1 - 6, 250, 'Keyer in radio firmware'); MakeLbl(Grp, 'Keyer', PAD, R1 - 1, 50);
FCmbCWKeyerSrc := MkCmbCW(Grp, PAD + 54, R1 - 6, 194);
FCmbCWKeyerSrc.Items.AddStrings(['Radio firmware','Software (PC)','Off (MOX only)']);
FCmbCWKeyerSrc.ItemIndex := 0;
MkBtn(Grp, PAD + 260, R1 - 6, 110, 'Messages...', BtnCWMessagesClick); MkBtn(Grp, PAD + 260, R1 - 6, 110, 'Messages...', BtnCWMessagesClick);
MakeLbl(Grp, 'Pitch (Hz)', PAD + 400, R1 - 1, 90); MakeLbl(Grp, 'Pitch (Hz)', PAD + 400, R1 - 1, 90);
FEdCWPitch := MkSpinCW(Grp, PAD + 500, R1 - 6, 200, 1200, 600); FEdCWPitch := MkSpinCW(Grp, PAD + 500, R1 - 6, 200, 1200, 600);
@@ -2076,13 +2093,46 @@ begin
MakeLbl(Grp, 'Level', PAD + 568, R1 + 4 * STEP + 5, 44); MakeLbl(Grp, 'Level', PAD + 568, R1 + 4 * STEP + 5, 44);
FEdCWSidetoneSWLv := MkSpinCW(Grp, PAD + 616, R1 + 4 * STEP, 0, 100, 30); FEdCWSidetoneSWLv := MkSpinCW(Grp, PAD + 616, R1 + 4 * STEP, 0, 100, 30);
TxNote(Grp, 'Dot and dash timing is produced by the radio, so PC load never ' // ---- Программный кейер: манипуляцию рисует сам EWSDR --------------------
+ 'affects it. The voice transmit chain is not started in CW at all. ' MakeLbl(Grp, 'Carrier offset (Hz)', PAD, R1 + 5 * STEP + 5, LW);
+ 'The radio sidetone is heard in the radio headphones; the PC sidetone ' FEdCWOffset := MkSpinCW(Grp, CX, R1 + 5 * STEP, -1, 48000, 0);
+ 'is exact for keyboard sending and a straight key, but not for a paddle ' MakeLbl(Grp, '0 = auto, -1 = none (software keyer only)', PAD + 320, R1 + 5 * STEP + 5, 260);
+ '— with iambic the firmware owns the timing.', R1 + 5 * STEP - 6, 60);
Inc(Y, 320 + 12); FChkCWKeyPort := MkChkCW(Grp, PAD, R1 + 6 * STEP, 250, 'Paddle on serial port');
FEdCWKeyPort := TFlatEdit.Create(Self);
FEdCWKeyPort.Parent := Grp;
FEdCWKeyPort.SetBounds(DpiScale(PAD + 260), DpiScale(R1 + 6 * STEP),
DpiScale(220), DpiScale(BTN_H));
FEdCWKeyPort.Color := CLR_INPUT;
FEdCWKeyPort.Font.Color := CLR_INPUT_TEXT;
FEdCWKeyPort.Font.Size := 9;
FEdCWKeyPort.OnExit := OnCWAnyChange;
MakeLbl(Grp, '/dev/ttyUSB0, COM3', PAD + 492, R1 + 6 * STEP + 5, 200);
MakeLbl(Grp, 'Dot line', PAD, R1 + 7 * STEP + 5, 70);
FCmbCWDotLine := MkCmbCW(Grp, PAD + 74, R1 + 7 * STEP, 90);
FCmbCWDotLine.Items.AddStrings(['CTS','DSR','DCD','RI']);
FCmbCWDotLine.ItemIndex := 0;
MakeLbl(Grp, 'Dash line', PAD + 176, R1 + 7 * STEP + 5, 74);
FCmbCWDashLine := MkCmbCW(Grp, PAD + 254, R1 + 7 * STEP, 90);
FCmbCWDashLine.Items.AddStrings(['CTS','DSR','DCD','RI']);
FCmbCWDashLine.ItemIndex := 1;
FChkCWKeyInvert := MkChkCW(Grp, PAD + 360, R1 + 7 * STEP, 90, 'Invert');
FChkCWKeyDTR := MkChkCW(Grp, PAD + 456, R1 + 7 * STEP, 100, 'DTR power');
FChkCWKeyRTS := MkChkCW(Grp, PAD + 562, R1 + 7 * STEP, 100, 'RTS power');
TxNote(Grp, 'Firmware keyer: the radio produces dot and dash timing, so PC '
+ 'load never affects it — the default where the hardware has one, and the '
+ 'paddle plugs into the radio. Software keyer: EWSDR draws the keyed '
+ 'carrier into the IQ stream itself (the only option on Pluto/LibreSDR) '
+ 'and the paddle comes in over a serial port; element lengths are counted '
+ 'in samples, so only the paddle input jitters, never the rhythm. Carrier '
+ 'offset 0 means auto: zero on openHPSDR, 12 kHz on zero-IF hardware, '
+ 'where LO leakage would otherwise sit right on the working frequency '
+ 'between elements. The voice transmit chain is not started in CW at all.',
R1 + 8 * STEP - 6, 92);
Inc(Y, 460 + 12);
Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250); Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250);
MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW); MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW);
@@ -2271,8 +2321,21 @@ begin
end; end;
procedure TSettingsForm.CollectCWFromUI; procedure TSettingsForm.CollectCWFromUI;
var Src: Integer;
begin begin
if FChkCWFWKeyer <> nil then FCW.FWKeyer := FChkCWFWKeyer.Checked; // Список «кто рисует точки» → пара флагов. На железе без кейера в прошивке
// первого пункта в списке нет вовсе, поэтому индексы сдвинуты на один.
if FCmbCWKeyerSrc <> nil then
begin
Src := FCmbCWKeyerSrc.ItemIndex;
if Src < 0 then Src := 0;
if not FCWHasFWKeyer then Inc(Src);
case Src of
0: begin FCW.FWKeyer := True; FCW.LocalKeyer := False; end; // прошивка
1: begin FCW.FWKeyer := True; FCW.LocalKeyer := True; end; // программа
else begin FCW.FWKeyer := False; FCW.LocalKeyer := False; end; // выключен
end;
end;
if FEdCWPitch <> nil then FCW.Pitch := FEdCWPitch.Value; if FEdCWPitch <> nil then FCW.Pitch := FEdCWPitch.Value;
if FCmbCWKeyerMode <> nil then FCW.KeyerMode := EnsureRange(FCmbCWKeyerMode.ItemIndex, 0, 2); if FCmbCWKeyerMode <> nil then FCW.KeyerMode := EnsureRange(FCmbCWKeyerMode.ItemIndex, 0, 2);
if FChkCWReverse <> nil then FCW.ReverseKeys := FChkCWReverse.Checked; if FChkCWReverse <> nil then FCW.ReverseKeys := FChkCWReverse.Checked;
@@ -2287,6 +2350,36 @@ begin
if FEdCWSidetoneHWLv <> nil then FCW.SidetoneHWLevel := FEdCWSidetoneHWLv.Value; if FEdCWSidetoneHWLv <> nil then FCW.SidetoneHWLevel := FEdCWSidetoneHWLv.Value;
if FChkCWSidetoneSW <> nil then FCW.SidetoneSW := FChkCWSidetoneSW.Checked; if FChkCWSidetoneSW <> nil then FCW.SidetoneSW := FChkCWSidetoneSW.Checked;
if FEdCWSidetoneSWLv <> nil then FCW.SidetoneSWLevel := FEdCWSidetoneSWLv.Value; if FEdCWSidetoneSWLv <> nil then FCW.SidetoneSWLevel := FEdCWSidetoneSWLv.Value;
if FEdCWOffset <> nil then FCW.CarrierOffsetHz := FEdCWOffset.Value;
if FChkCWKeyPort <> nil then FCW.KeyPortEnabled := FChkCWKeyPort.Checked;
if FEdCWKeyPort <> nil then FCW.KeyPort := Copy(FEdCWKeyPort.Text, 1, 63);
if FCmbCWDotLine <> nil then FCW.KeyDotLine := EnsureRange(FCmbCWDotLine.ItemIndex, 0, 3);
if FCmbCWDashLine <> nil then FCW.KeyDashLine := EnsureRange(FCmbCWDashLine.ItemIndex, 0, 3);
if FChkCWKeyInvert <> nil then FCW.KeyInvert := FChkCWKeyInvert.Checked;
if FChkCWKeyDTR <> nil then FCW.KeyPowerDTR := FChkCWKeyDTR.Checked;
if FChkCWKeyRTS <> nil then FCW.KeyPowerRTS := FChkCWKeyRTS.Checked;
end;
procedure TSettingsForm.SetCWBackend(HasFWKeyer: Boolean);
// Гейт по железу: у AD936x кейера в прошивке нет вовсе, значит нет ни этого
// пункта в списке, ни аппаратного сайдтона (он живёт в наушниках самого
// трансивера) — телеграф там может быть только программным.
// ★Обязан отработать ДО LoadCWSettings: тот считает индекс списка по FCWHasFWKeyer.
begin
FCWHasFWKeyer := HasFWKeyer;
if FChkCWSidetoneHW <> nil then FChkCWSidetoneHW.Enabled := HasFWKeyer;
if FEdCWSidetoneHWLv <> nil then FEdCWSidetoneHWLv.Enabled := HasFWKeyer;
if FCmbCWKeyerSrc = nil then Exit;
FLoading := True; // правка Items дёргает OnChange — настройки трогать нельзя
try
FCmbCWKeyerSrc.Items.Clear;
if HasFWKeyer then FCmbCWKeyerSrc.Items.Add('Radio firmware');
FCmbCWKeyerSrc.Items.Add('Software (PC)');
FCmbCWKeyerSrc.Items.Add('Off (MOX only)');
FCmbCWKeyerSrc.ItemIndex := 0;
finally
FLoading := False;
end;
end; end;
procedure TSettingsForm.FireCWChange; procedure TSettingsForm.FireCWChange;
@@ -2307,11 +2400,21 @@ begin
end; end;
procedure TSettingsForm.LoadCWSettings(const C: TCWSettings); procedure TSettingsForm.LoadCWSettings(const C: TCWSettings);
var Src: Integer;
begin begin
FLoading := True; FLoading := True;
try try
FCW := C; FCW := C;
if FChkCWFWKeyer <> nil then FChkCWFWKeyer.Checked := C.FWKeyer; if FCmbCWKeyerSrc <> nil then
begin
// Пара флагов → пункт списка. «Прошивка» на железе без кейера означает
// программный кейер: другого пути там нет, и врать в списке незачем.
if (not C.FWKeyer) and (not C.LocalKeyer) then Src := 2
else if C.LocalKeyer or (not FCWHasFWKeyer) then Src := 1
else Src := 0;
if not FCWHasFWKeyer then Dec(Src);
FCmbCWKeyerSrc.ItemIndex := EnsureRange(Src, 0, FCmbCWKeyerSrc.Items.Count - 1);
end;
if FEdCWPitch <> nil then FEdCWPitch.Value := EnsureRange(C.Pitch, 200, 1200); if FEdCWPitch <> nil then FEdCWPitch.Value := EnsureRange(C.Pitch, 200, 1200);
if FCmbCWKeyerMode <> nil then FCmbCWKeyerMode.ItemIndex := EnsureRange(C.KeyerMode, 0, 2); if FCmbCWKeyerMode <> nil then FCmbCWKeyerMode.ItemIndex := EnsureRange(C.KeyerMode, 0, 2);
if FChkCWReverse <> nil then FChkCWReverse.Checked := C.ReverseKeys; if FChkCWReverse <> nil then FChkCWReverse.Checked := C.ReverseKeys;
@@ -2326,6 +2429,14 @@ begin
if FEdCWSidetoneHWLv <> nil then FEdCWSidetoneHWLv.Value := EnsureRange(C.SidetoneHWLevel, 0, 127); if FEdCWSidetoneHWLv <> nil then FEdCWSidetoneHWLv.Value := EnsureRange(C.SidetoneHWLevel, 0, 127);
if FChkCWSidetoneSW <> nil then FChkCWSidetoneSW.Checked := C.SidetoneSW; if FChkCWSidetoneSW <> nil then FChkCWSidetoneSW.Checked := C.SidetoneSW;
if FEdCWSidetoneSWLv <> nil then FEdCWSidetoneSWLv.Value := EnsureRange(C.SidetoneSWLevel, 0, 100); if FEdCWSidetoneSWLv <> nil then FEdCWSidetoneSWLv.Value := EnsureRange(C.SidetoneSWLevel, 0, 100);
if FEdCWOffset <> nil then FEdCWOffset.Value := EnsureRange(C.CarrierOffsetHz, -1, 48000);
if FChkCWKeyPort <> nil then FChkCWKeyPort.Checked := C.KeyPortEnabled;
if FEdCWKeyPort <> nil then FEdCWKeyPort.Text := C.KeyPort;
if FCmbCWDotLine <> nil then FCmbCWDotLine.ItemIndex := EnsureRange(C.KeyDotLine, 0, 3);
if FCmbCWDashLine <> nil then FCmbCWDashLine.ItemIndex := EnsureRange(C.KeyDashLine, 0, 3);
if FChkCWKeyInvert <> nil then FChkCWKeyInvert.Checked := C.KeyInvert;
if FChkCWKeyDTR <> nil then FChkCWKeyDTR.Checked := C.KeyPowerDTR;
if FChkCWKeyRTS <> nil then FChkCWKeyRTS.Checked := C.KeyPowerRTS;
finally finally
FLoading := False; FLoading := False;
end; end;
+4
View File
@@ -312,6 +312,10 @@
<Filename Value="CWMorse.pas"/> <Filename Value="CWMorse.pas"/>
<IsPartOfProject Value="True"/> <IsPartOfProject Value="True"/>
</Unit> </Unit>
<Unit>
<Filename Value="CWKeyer.pas"/>
<IsPartOfProject Value="True"/>
</Unit>
<Unit> <Unit>
<Filename Value="CWMessagesForm.pas"/> <Filename Value="CWMessagesForm.pas"/>
<IsPartOfProject Value="True"/> <IsPartOfProject Value="True"/>
+3
View File
@@ -467,6 +467,9 @@ begin
begin begin
if FCtrl.FRunning and FCtrl.FWDSPReady then if FCtrl.FRunning and FCtrl.FWDSPReady then
FCtrl.ServiceBeaconLock; FCtrl.ServiceBeaconLock;
// Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет вовсе, и
// отпустить реле T/R после выдержки больше некому).
FCtrl.ServiceCWKeyed;
lastBeacon := nowMs; lastBeacon := nowMs;
end; end;
if nowMs - lastPush >= PUSH_INTERVAL_MS then if nowMs - lastPush >= PUSH_INTERVAL_MS then