diff --git a/CWKeyer.pas b/CWKeyer.pas
new file mode 100644
index 0000000..9e8f285
--- /dev/null
+++ b/CWKeyer.pas
@@ -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.
diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas
index 19b8424..ec5de6a 100644
--- a/HPSDRNetwork.pas
+++ b/HPSDRNetwork.pas
@@ -731,6 +731,8 @@ begin
Result.NumADCs := 1;
// PureSignal: нужны свободные DDC0/1 под feedback — платы с DDCBase=2.
Result.HasPureSignal := FDevice.BoardType in [3, 4, 5, 10];
+ // Кейер в прошивке есть у всех плат P2 (DUC Specific байты 5..13).
+ Result.HasCWKeyer := True;
end;
constructor THPSDRNetwork.Create;
diff --git a/IIOBindings.pas b/IIOBindings.pas
index adb02c0..f26aa75 100644
--- a/IIOBindings.pas
+++ b/IIOBindings.pas
@@ -79,6 +79,10 @@ var
out val: clonglong): cint; cdecl;
iio_device_attr_write_longlong: function(dev: Piio_device; attr: PAnsiChar;
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) ----
// Нужны для выбора антенного разъёма у AD936x в режиме 1R1T:
@@ -195,6 +199,7 @@ begin
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_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_write := Sym('iio_device_debug_attr_write');
iio_device_debug_attr_write_longlong := Sym('iio_device_debug_attr_write_longlong');
diff --git a/MainForm.pas b/MainForm.pas
index 67f6a28..9cc119e 100644
--- a/MainForm.pas
+++ b/MainForm.pas
@@ -3688,6 +3688,9 @@ begin
FController.ServiceBeaconLock;
// Pluto: опрос температуры/RSSI (троттлинг внутри; no-op для HPSDR).
FController.ServicePlutoTelemetry;
+ // Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет — гнать индикацию
+ // и отпускать реле T/R после выдержки больше некому).
+ FController.ServiceCWKeyed;
// PureSignal: state machine команд + auto-attenuate + опрос GetPSInfo
// (no-op на бэкендах без PS; рендер кнопок/поповера через rfPureSignal).
FController.PureSignalTick;
@@ -4601,7 +4604,7 @@ begin
if FController.FNetwork.Connected and FController.FNetwork.Running then
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.FNetwork.SendFullHP;
end;
@@ -4623,7 +4626,7 @@ begin
if FController.FNetwork.Connected and FController.FNetwork.Running then
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.FNetwork.SendFullHP;
end;
@@ -5069,7 +5072,7 @@ begin
if FController.FNetwork.Connected and FController.FNetwork.Running then
begin
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.FNetwork.SendFullHP;
end;
@@ -8040,7 +8043,7 @@ begin
end;
FController.FDriveLevel := CalcDriveByte;
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;
procedure TMainForm.OnPlutoTxMaxAttChange(MaxAttDb: Double);
@@ -8075,7 +8078,7 @@ begin
end;
FController.FDriveLevel := CalcDriveByte;
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;
procedure TMainForm.OnTXSettingsChange(const T: TTXSettings);
@@ -8121,7 +8124,7 @@ begin
FController.FDSPEngine.SetDriveLevel(FController.FTXSettings.TUNLevel / 100.0);
if FController.FNetwork.Connected and FController.FNetwork.Running then
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.FNetwork.SendFullHP;
end;
@@ -9137,6 +9140,9 @@ begin
PushTXProfilesToSettings;
SF.LoadTXSettings(FController.FTXSettings,
IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
+ // Телеграф: есть ли кейер в железе. У AD936x его нет — там телеграф может
+ // быть только программным, и настройки прошивки в группе гасятся.
+ SF.SetCWBackend(FController.BackendCaps.HasCWKeyer);
SF.LoadCWSettings(FController.CWSettings);
SF.LoadAlexSettings(FController.FAlexSettings,
IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
diff --git a/PlutoBackend.pas b/PlutoBackend.pas
index e0ffe01..a00241f 100644
--- a/PlutoBackend.pas
+++ b/PlutoBackend.pas
@@ -458,6 +458,7 @@ begin
Result.HasWideband := False;
Result.HasDitherRandom := False;
Result.HasHWMic := False;
+ Result.HasCWKeyer := False; // голый AD936x — телеграф формирует PC
Result.HasPLLStatus := False;
Result.HasHWGain := True; // manual gain / hw-AGC
Result.HasRFBandwidth := True;
@@ -634,6 +635,15 @@ begin
if not (FRx2Avail or FRxMuxAvail) then FRxChanSel := 0;
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 + вход + усиление.
if FSampleRate < PLUTO_MIN_SR then FSampleRate := PLUTO_DEF_SR;
ApplySampleRate(FSampleRate);
diff --git a/RadioBackend.pas b/RadioBackend.pas
index dd39b69..c8003b9 100644
--- a/RadioBackend.pas
+++ b/RadioBackend.pas
@@ -60,6 +60,11 @@ type
// feedback (Angelia/Orion/Orion2/Saturn). Гейтит кнопки PS/2TON.
// Достоверно после Connect.
HasPureSignal: Boolean;
+ // Телеграфный кейер в железе: точки/тире формирует прошивка по параметрам из
+ // DUC Specific (openHPSDR P2), а манипулятор воткнут в сам трансивер. False
+ // (голый AD936x) означает, что телеграф обязан формировать локальный
+ // генератор — см. CWKeyer.pas.
+ HasCWKeyer: Boolean;
MinSampleRate: Integer;
RatePresets: TBackendRateArray; // пресеты для SampleRateOverlay
MinFreqHz: Double;
diff --git a/RadioController.pas b/RadioController.pas
index 15cae6d..5091c9d 100644
--- a/RadioController.pas
+++ b/RadioController.pas
@@ -50,7 +50,7 @@ uses
Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
- Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse;
+ Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer;
// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
@@ -457,6 +457,19 @@ type
FCWSTEnv: Double; // огибающая 0..1, сглаживает щелчки (DSP-поток)
FCWSTBufL: array of Single; // буферы подмешивания (только DSP-поток)
FCWSTBufR: array of Single;
+ FCWSTEnvBuf: array of Single; // огибающая от локального кейера (DSP-поток)
+ // ---- Локальный кейер (CWKeyer.pas) --------------------------------------
+ // Генератор несущей с огибающей прямо в поток IQ. Обязателен там, где кейера
+ // в железе нет (Pluto/LibreSDR), и опционален у openHPSDR. Оба объекта
+ // создаются лениво — кто телеграфом не пользуется, лишних нитей не получает.
+ FCWLocal: TCWLocalKeyer;
+ FCWKeyPort: TCWKeyPort;
+ // Сессия локального кейера подняла передачу сама (break-in): по этому флагу
+ // её снимаем и не путаем с ручным MOX.
+ FCWLocalTX: Boolean;
+ // Локальный кейер вооружён — считается в SyncCWKeyer, читается пейсингом
+ // частоты DUC (CWCarrierOffsetHz) и аттенюатором Pluto.
+ FCWLocalArmed: Boolean;
FAlexSettings: TAlexSettings;
FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only)
// Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер.
@@ -578,6 +591,11 @@ type
// uplink-LO не подвержен дрейфу LNB).
function XvtrTranslateTX(VisibleHz: Double): Double;
function ActiveTXFreqHz: Double;
+ // TXTuneFreqHz — ЕДИНАЯ дверь «какую частоту передачи класть в железо»:
+ // ActiveTXFreqHz через трансвертер, минус сдвиг несущей локального кейера
+ // (CWCarrierOffsetHz). Прямых XvtrTranslateTX(ActiveTXFreqHz) в пушах
+ // состояния быть не должно — иначе телеграф уедет от VFO ровно на сдвиг.
+ function TXTuneFreqHz: Double;
// Режим передающего тракта: слайс-источник TX (FTxSliceId) либо главный FMode.
function ActiveTXMode: Integer;
// Диапазон для калибровки драйва/антенны TX (банд TX-слайса при кросс-банде).
@@ -616,10 +634,22 @@ type
// (FCurrentBand при входе в слот не меняется).
function XvtrSlotActive: Boolean;
function TXProhibited: Boolean; // DoNotTx-бэнд / RX-only XVTR
- function CWTXActive: Boolean; // передачей владеет FPGA-кейер
+ function CWTXActive: Boolean; // манипуляцией владеет не MOX
+ function CWLocalSource: Boolean; // манипуляцию рисует PC
+ function CWFWSource: Boolean; // манипуляцию рисует прошивка
function CWOptionsByte: Byte; // DUC Specific байт 5
procedure SyncCWKeyer; // байт 5 под текущий TX-режим
procedure CWXKeyEvent(Down: Boolean); // колбэк потока передачи
+ // Сдвиг несущей манипуляции от гетеродина (уход от утечки LO у zero-IF).
+ // На него же уезжает DUC/LO, чтобы РЧ встала ровно на VFO — см. TXTuneFreqHz.
+ function CWCarrierOffsetHz: Double;
+ // ---- Локальный кейер ----------------------------------------------------
+ procedure EnsureCWLocal; // ленивое создание генератора
+ procedure PushCWLocalConfig; // настройки → генератор/порт
+ procedure CWLocalIQ(const Buf: array of Double; Count: Integer);
+ procedure CWLocalSession(Active: Boolean); // T/R под сессию манипуляции
+ procedure CWLocalTick; // «ключ живой» (индикация)
+ procedure CWKeyPortEvent(Dot, Dash: Boolean); // лепестки с COM-порта
// Подмешать программный сайдтон в блок RX-аудио (DSP-поток). Возвращает
// False, если подмешивать нечего — тогда вызывающий отдаёт исходный блок.
function MixSidetone(const Left, Right: array of Single;
@@ -964,6 +994,9 @@ type
// В телеграфе FTransmitting не поднимается: передачей владеет кейер прошивки,
// и единственный признак эфира — HPS_PTT в статусе.
function RadioKeyed: Boolean;
+ // Фронты RadioKeyed → индикация. Зовётся из разбора HP-статуса (openHPSDR) и
+ // с UI-таймера: у Pluto статуса нет вовсе, а телеграф там локальный.
+ procedure ServiceCWKeyed;
function CWSettings: TCWSettings;
procedure SetCWSettings(const C: TCWSettings);
// Программная передача текста: PC сам выдерживает тайминги и дёргает бит
@@ -1298,7 +1331,15 @@ end;
destructor TRadioController.Destroy;
begin
- // Поток передачи телеграфа гасим ПЕРВЫМ: его колбэк ключа лезет в бэкенд.
+ // Потоки телеграфа гасим ПЕРВЫМИ: их колбэки лезут в бэкенд и в движок.
+ // Порядок — порт ключа → генератор: иначе лепесток успел бы разбудить уже
+ // разрушаемый генератор.
+ if Assigned(FCWKeyPort) then FreeAndNil(FCWKeyPort);
+ if Assigned(FCWLocal) then
+ begin
+ FCWLocal.SetArmed(False);
+ FreeAndNil(FCWLocal);
+ end;
if Assigned(FCWSender) then
begin
FCWSender.AbortSending;
@@ -1334,7 +1375,7 @@ begin
// Состояние передачи для ЖЕЛЕЗА — RadioKeyed, а не FTransmitting: в
// телеграфе ключ замыкает прошивка, и реле T/R с антенной и OC обязаны
// ехать за ней. PTT в кадре этим не поднимается (он живёт в SendPTT).
- FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
+ FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, RadioKeyed, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP;
end;
@@ -1604,28 +1645,10 @@ begin
end;
FHWPTTActive := FHPpHWPTT;
if FHPpHWPTT then FCWKeyedAt := GetTickCount64;
- // UI про телеграфную передачу иначе не узнает вовсе: SetMOX здесь не
- // вызывается, а значит и rfTransmitting никто не шлёт. Фронт RadioKeyed
- // (с выдержкой) — единственный сигнал «покрасить бейджи и полосы».
- if FCWKeyedShown <> RadioKeyed then
- begin
- FCWKeyedShown := RadioKeyed;
- // ★Железная обвязка обязана идти за фактическим ключом: реле T/R (Alex
- // bit 27), TX-антенна, bypass/Ext1 и выходы OC (ими ключуется усилитель)
- // считаются в бэкенде от состояния передачи. Без этого пути прошивка
- // гнала бы несущую, пока Alex стоит в ПРИЁМНОЙ конфигурации.
- PushNetworkState;
- Changed(rfTransmitting);
- end;
+ ServiceCWKeyed;
Exit;
end;
- // Ушли из телеграфа/сняли вооружение — вернуть индикацию в исходное.
- if FCWKeyedShown then
- begin
- FCWKeyedShown := False;
- PushNetworkState;
- Changed(rfTransmitting);
- end;
+ ServiceCWKeyed; // ушли из телеграфа — вернуть индикацию в исходное
// Hardware PTT (footswitch / mic PTT): фронт на rate HP Status — не пропустить
// короткое нажатие. HWPTT не перекрывает ручной MOX; выключаем TX только если
// его включил именно HWPTT.
@@ -3080,6 +3103,26 @@ begin
Result := Result + FDSPEngine.CWLOOffset(ActiveTXMode);
end;
+function TRadioController.TXTuneFreqHz: Double;
+// Единая дверь «какую частоту передачи класть в железо». Кроме трансвертера
+// учитывает сдвиг несущей локального кейера: манипуляция идёт не на нуле
+// бейсбенда, а на CWCarrierOffsetHz, значит гетеродин обязан уехать НАВСТРЕЧУ,
+// иначе телеграф уйдёт в эфир мимо VFO ровно на этот сдвиг.
+// ★Сдвиг применяется, пока кейер ВООРУЖЁН, а не только на передаче: железо
+// может замкнуть ключ в любой момент (тот же урок, что был с частотой DUC).
+//
+// ★★ЗНАК — ЗАМЕРЕН НА ЭФИРЕ (Pluto, 2026-08-10), а не выведен: положительная
+// частота бейсбенда в нашей конвенции выходит в эфир НИЖЕ гетеродина
+// (РЧ = LO + CW_TX_BB_SIGN·f). Первая версия считала наоборот, и несущая вставала
+// на 2×сдвига НИЖЕ маркера. Отсюда гетеродин уезжает ВВЕРХ на величину сдвига.
+// Если когда-нибудь понадобится ненулевой сдвиг у openHPSDR — проверить там
+// отдельно: тракт другой (цифровой DUC вместо zero-IF), и знак может отличаться.
+const
+ CW_TX_BB_SIGN = -1;
+begin
+ Result := XvtrTranslateTX(ActiveTXFreqHz) - CW_TX_BB_SIGN * CWCarrierOffsetHz;
+end;
+
function TRadioController.ActiveTXMode: Integer;
var S: TCtrlSlice;
begin
@@ -4498,8 +4541,13 @@ const
begin
if (FNetwork = nil) or not IsPluto then Exit;
// На передаче (MOX/TUN) — мощность от drive; на приёме — макс. аттенюация (выкл).
- if FTransmitting or FTuning then FNetwork.SetTxAtten(PlutoTxAttForDrive)
- else FNetwork.SetTxAtten(TX_OFF);
+ // ★В телеграфе FTransmitting не поднимается вовсе (передачей владеет кейер),
+ // поэтому спрашиваем и сессию локального генератора — иначе манипуляция шла бы
+ // в закрытый аттенюатор, то есть в никуда.
+ if FTransmitting or FTuning or FCWLocalTX then
+ FNetwork.SetTxAtten(PlutoTxAttForDrive)
+ else
+ FNetwork.SetTxAtten(TX_OFF);
end;
procedure TRadioController.SetPlutoTxMaxAtt(Db: Double);
@@ -5134,15 +5182,64 @@ begin
and FXvtrSettings.Entries[FCurrentXvtr].RXOnly);
end;
-function TRadioController.CWTXActive: Boolean;
-// «Телеграфом управляет железо»: режим ИСТОЧНИКА передачи — CW и кейер в
-// прошивке. В этом состоянии голосовой TXA не запускается вовсе (эталон
-// Thetis, console.cs:29570), а точки/тире формирует FPGA.
+function TRadioController.CWFWSource: Boolean;
+// Манипуляцию рисует ПРОШИВКА (кейер в FPGA, openHPSDR P2). Путь по умолчанию:
+// тайминги в железе, джиттер PC не влияет вовсе.
begin
- Result := (ActiveTXMode in [MODE_CWL, MODE_CWU]) and FCWSettings.FWKeyer
+ Result := BackendCaps.HasCWKeyer and FCWSettings.FWKeyer
+ and (not FCWSettings.LocalKeyer);
+end;
+
+function TRadioController.CWLocalSource: Boolean;
+// Манипуляцию рисует САМ EWSDR (CWKeyer.pas). Выбирается списком в настройках, а
+// на железе без кейера в прошивке (Pluto/LibreSDR — голый AD936x) выбор
+// «прошивка» вырождается сюда: другого пути там просто нет.
+// ★Оба источника False = кейер выключен намеренно (третий пункт списка): тогда
+// телеграф идёт только через MOX, и ключевать некому.
+begin
+ Result := FCWSettings.LocalKeyer
+ or ((not BackendCaps.HasCWKeyer) and FCWSettings.FWKeyer);
+end;
+
+function TRadioController.CWTXActive: Boolean;
+// «Передачей в телеграфе владеет не MOX»: режим ИСТОЧНИКА передачи — CW и
+// манипуляция кому-то поручена (прошивке или локальному генератору). В этом
+// состоянии голосовой TXA не запускается вовсе (эталон Thetis, console.cs:29570),
+// а несущую даёт кейер / бит CWX / тон TUN.
+begin
+ Result := (ActiveTXMode in [MODE_CWL, MODE_CWU])
+ and (CWFWSource or CWLocalSource)
and (not TXProhibited); // где передавать нельзя — кейер разоружён
end;
+function TRadioController.CWCarrierOffsetHz: Double;
+// Сдвиг несущей манипуляции от гетеродина — и в генераторе, и в частоте DUC/LO.
+// ★У zero-IF (AD936x) ноль бейсбенда = частота гетеродина, и ТАМ ЖЕ сидит его
+// утечка. Держи несущую на нуле — между посылками в эфир пойдёт слабый backwave
+// РОВНО на рабочей частоте (самое неприятное, что бывает в телеграфе). Поэтому
+// у Pluto манипуляция идёт на сдвиге, а LO уезжает навстречу (знак — в
+// TXTuneFreqHz, он замерен на эфире). У openHPSDR DUC цифровой, утекать
+// нечему — сдвиг 0.
+const
+ CW_AUTO_OFFSET_HZ = 12000;
+begin
+ Result := 0;
+ if not FCWLocalArmed then Exit;
+ // TUN/2TON идут через TXA (несущую даёт SSB-модулятор из тона), их частота
+ // уже посчитана в ActiveTXFreqHz — сдвигать нечего.
+ if FTuning or FTwoTone then Exit;
+ // −1 = «без сдвига» ЯВНО (несущая прямо на гетеродине). Нужно не только для
+ // отладки: сдвиг разносит TX LO и RX LO на единицы-десятки килогерц, а два
+ // fractional-N синтезатора в одном кристалле на близких частотах тянут друг
+ // друга и дают симметричные боковые вокруг несущей. Что хуже — утечка LO на
+ // рабочей частоте или эти боковые, решает оператор на своём железе.
+ if FCWSettings.CarrierOffsetHz < 0 then Exit;
+ if FCWSettings.CarrierOffsetHz > 0 then
+ Result := FCWSettings.CarrierOffsetHz
+ else if not BackendCaps.HasCWKeyer then
+ Result := CW_AUTO_OFFSET_HZ;
+end;
+
function TRadioController.CWOptionsByte: Byte;
// DUC Specific байт 5. Порт pihpsdr new_protocol.c:1353-1383 (там же
// объяснение, почему iambic-бит выставляется и для Mode B).
@@ -5150,7 +5247,10 @@ function TRadioController.CWOptionsByte: Byte;
// на замыкание ключа посреди SSB (Thetis, console.cs:27394).
begin
Result := 0;
- if not CWTXActive then Exit;
+ // ★Байт обязан быть нулевым не только вне телеграфа, но и когда манипуляцию
+ // взял на себя локальный генератор: иначе прошивка ключевала бы ПАРАЛЛЕЛЬНО
+ // с ним (и поднимала бы PTT на замыкание своего разъёма).
+ if not (CWTXActive and CWFWSource) then Exit;
Result := Result or CW_MODE;
if FCWSettings.ReverseKeys then Result := Result or CW_REVERSE_KEYS;
case FCWSettings.KeyerMode of
@@ -5573,7 +5673,7 @@ begin
if FTransmitting and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
FNetwork.ClearDUCIQQueue;
- FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
+ FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, True, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP; // 1й пакет: Alex bit27=1 (T/R relay), drive — без PTT
FNetwork.SendPTT(True); // 2й пакет: PTT=1 после переключения реле
@@ -5666,13 +5766,16 @@ begin
FNetwork.SendPTT(False); // 1й пакет: PTT=0 — сначала снимаем PTT
// PureSignal: вернуть DDC0/1 в RX-конфигурацию (убрать feedback-DDC).
FNetwork.SetPureSignalTX(False);
- FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
+ FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, False, True, True, FTuning, FTwoTone);
FNetwork.ClearDUCIQQueue;
FNetwork.SendFullHP; // 2й пакет: Alex bit27=0 (T/R relay RX)
end;
ApplyPlutoTxAtten; // Pluto: TX-мощность по drive на передаче, OFF на приёме
ApplyActiveVolume; // T/R: переключаем громкость RX ↔ TX-монитор (если monitoring)
+ // При выключенном break-in локальный кейер вооружён только вместе с MOX
+ // (оператор держит T/R руками) — вооружение обязано пойти за этой кнопкой.
+ SyncCWKeyer;
Changed(rfTransmitting);
end;
@@ -5712,7 +5815,7 @@ begin
ApplyWDSPDrive(FDrivePercent);
if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
- FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz),
+ FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, FTransmitting, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP;
end;
@@ -5747,6 +5850,30 @@ begin
Result := (GetTickCount64 - FCWKeyedAt) < Hold;
end;
+procedure TRadioController.ServiceCWKeyed;
+// UI про телеграфную передачу иначе не узнает вовсе: SetMOX в телеграфе не
+// вызывается, а значит и rfTransmitting никто не шлёт. Фронт RadioKeyed (с
+// выдержкой) — единственный сигнал «покрасить бейджи и полосы».
+// Зовётся из разбора HP-статуса (openHPSDR, быстро) и с UI-таймера — у Pluto
+// статуса нет вообще, а телеграф там локальный.
+begin
+ // Вооружение кейера обязано быть САМОВОССТАНАВЛИВАЮЩИМСЯ: у openHPSDR это
+ // делает разбор HP-статуса, а у Pluto статуса нет — значит здесь. Разоружить
+ // может что угодно (бэнд, слот трансвертера, выход из TUN), перечислять такие
+ // точки поимённо — гарантия однажды пропустить очередную.
+ SyncCWKeyer;
+ if FCWKeyedShown = RadioKeyed then Exit;
+ FCWKeyedShown := RadioKeyed;
+ // ★Железная обвязка обязана идти за фактическим ключом: реле T/R (Alex
+ // bit 27), TX-антенна, bypass/Ext1 и выходы OC (ими ключуется усилитель)
+ // считаются в бэкенде от состояния передачи. Без этого пути прошивка гнала бы
+ // несущую, пока Alex стоит в ПРИЁМНОЙ конфигурации. У локального кейера то же
+ // делает сессия (CWLocalSession) — синхронно, до первой посылки; здесь остаётся
+ // хвост выдержки, когда реле пора отпустить.
+ PushNetworkState;
+ Changed(rfTransmitting);
+end;
+
function TRadioController.CWSettings: TCWSettings;
begin
Result := FCWSettings;
@@ -5771,7 +5898,10 @@ begin
if Assigned(FCWSender) then
FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight);
SendDUCSpecificFromSettings;
- if Assigned(FNetwork) then FNetwork.SetCWKeyerArmed(CWTXActive);
+ // Локальный кейер: параметры + вооружение (галку «кейер в программе», как и
+ // запрет передачи, разбирает SyncCWKeyer — одна дверь на всё).
+ PushCWLocalConfig;
+ SyncCWKeyer;
// Тон TUN в телеграфе равен pitch (несущая обязана лечь на VFO, см. SetTune):
// если крутят pitch прямо на настройке — обновляем живьём.
if FTuning and FWDSPReady and Assigned(FDSPEngine)
@@ -5788,6 +5918,8 @@ procedure TRadioController.SyncCWKeyer;
// Байт 5 зависит от режима ИСТОЧНИКА передачи, поэтому каждая смена режима или
// TX-слайса обязана его перепослать: вне телеграфа он должен стать нулевым,
// иначе прошивка поднимет PTT на замыкание ключа посреди SSB.
+var
+ WasArmed: Boolean;
begin
// Дёшево и идемпотентно: пара сравнений плюс ранний выход внутри
// SetCWKeyerArmed. Поэтому зовётся не только на смене режима/источника, но и
@@ -5798,11 +5930,167 @@ begin
if (not (ActiveTXMode in [MODE_CWL, MODE_CWU]))
and Assigned(FCWSender) and FCWSender.Busy then CWXAbort;
// Вооружённый кейер = железо может выйти в эфир само ⇒ бэкенд обязан класть
- // уровень мощности в КАЖДЫЙ HP-кадр, а не только на передаче.
+ // уровень мощности в КАЖДЫЙ HP-кадр, а не только на передаче. Локальный
+ // генератор — та же история: FTransmitting в телеграфе не поднимается, а
+ // несущая идёт.
if Assigned(FNetwork) then FNetwork.SetCWKeyerArmed(CWTXActive);
+
+ // ---- Локальный кейер ----------------------------------------------------
+ // Вооружение самовосстанавливающееся, как и у прошивки: SyncCWKeyer зовётся
+ // из разбора статуса и со всех смен режима/источника/бэнда.
+ WasArmed := FCWLocalArmed;
+ // Без break-in передачей владеет оператор: ключ имеет право звучать только
+ // когда MOX уже поднят — иначе несущая ушла бы в приёмную обвязку.
+ // ★TUN и 2TON исключены намеренно: там несущую даёт TXA, и в DUC-очередь уже
+ // пишет поток WDSP. Двух продюсеров одной очереди быть не должно.
+ FCWLocalArmed := CWTXActive and CWLocalSource
+ and FDevConnected and FRunning
+ and (FCWSettings.BreakIn or FTransmitting)
+ and (not FTuning) and (not FTwoTone);
+ if FCWLocalArmed and (not Assigned(FCWLocal)) then EnsureCWLocal;
+ if Assigned(FCWLocal) then
+ begin
+ if FCWLocalArmed then PushCWLocalConfig;
+ FCWLocal.SetArmed(FCWLocalArmed);
+ end;
+ if Assigned(FCWKeyPort) then
+ if not FCWLocalArmed then CWKeyPortEvent(False, False);
+ // Частота DUC/LO зависит от вооружения (сдвиг несущей) — на фронте
+ // перекладываем железо, иначе манипуляция уехала бы от VFO на сдвиг.
+ if WasArmed <> FCWLocalArmed then PushNetworkState;
+
if CWOptionsByte <> FLastCWOptions then SendDUCSpecificFromSettings;
end;
+procedure TRadioController.EnsureCWLocal;
+// Ленивое создание: у тех, кто телеграфом не пользуется, лишних нитей нет.
+begin
+ if not Assigned(FCWLocal) then
+ begin
+ FCWLocal := TCWLocalKeyer.Create;
+ FCWLocal.OnIQ := CWLocalIQ;
+ FCWLocal.OnSession := CWLocalSession;
+ FCWLocal.OnTick := CWLocalTick;
+ end;
+ if not Assigned(FCWKeyPort) then
+ begin
+ FCWKeyPort := TCWKeyPort.Create;
+ FCWKeyPort.OnKey := CWKeyPortEvent;
+ end;
+ PushCWLocalConfig;
+end;
+
+procedure TRadioController.PushCWLocalConfig;
+// Настройки → генератор и порт манипулятора. Дёшево и идемпотентно: зовётся из
+// SyncCWKeyer (то есть с каждой смены режима/бэнда) и из SetCWSettings.
+var
+ C: TCWLocalConfig;
+ P: TCWKeyPortConfig;
+begin
+ if not Assigned(FCWLocal) then Exit;
+ FillChar(C, SizeOf(C), 0);
+ if FWDSPReady and Assigned(FDSPEngine) then C.Rate := FDSPEngine.TXSampleRate
+ else C.Rate := TX_SAMPLE_RATE;
+ C.OffsetHz := CWCarrierOffsetHz;
+ // Уровень несущей. У Pluto мощность задаёт аттенюатор, цифровой уровень
+ // держим на максимуме; у openHPSDR цифровой drive работает вместе с
+ // байтом 345 — так же, как WDSP-драйв голосового тракта (ApplyWDSPDrive).
+ if IsPluto then C.Amplitude := 0.99
+ else C.Amplitude := 0.99 * EnsureRange(FDrivePercent, 0, 100) / 100.0;
+ C.WPM := FCWSettings.Speed;
+ C.Weight := FCWSettings.Weight;
+ C.RampMS := FCWSettings.RampMS;
+ case FCWSettings.KeyerMode of
+ 1: C.Mode := clmIambicA;
+ 2: C.Mode := clmIambicB;
+ else C.Mode := clmStraight;
+ end;
+ C.Reverse := FCWSettings.ReverseKeys;
+ C.Strict := FCWSettings.StrictSpacing;
+ C.BreakIn := FCWSettings.BreakIn;
+ C.HangMS := FCWSettings.HangTimeMS;
+ // Реле T/R, LO передатчика (Pluto гасит его на приёме) и аттенюатор успевают
+ // встать за это окно. Ноль у прошивки означает «железо знает само», у нас
+ // ждать некому — держим минимум.
+ C.RFDelayMS := Max(FCWSettings.RFDelayMS, 10);
+ C.Sidetone := FCWSettings.SidetoneSW and (FCWSettings.SidetoneSWLevel > 0);
+ FCWLocal.Configure(C);
+
+ if Assigned(FCWKeyPort) then
+ begin
+ FillChar(P, SizeOf(P), 0);
+ P.Enabled := FCWSettings.KeyPortEnabled and (FCWSettings.KeyPort <> '');
+ P.Port := FCWSettings.KeyPort;
+ P.DotLine := TCWKeyLine(EnsureRange(FCWSettings.KeyDotLine, 0, 3));
+ P.DashLine := TCWKeyLine(EnsureRange(FCWSettings.KeyDashLine, 0, 3));
+ P.Invert := FCWSettings.KeyInvert;
+ P.PowerDTR := FCWSettings.KeyPowerDTR;
+ P.PowerRTS := FCWSettings.KeyPowerRTS;
+ FCWKeyPort.Configure(P);
+ end;
+end;
+
+procedure TRadioController.CWLocalIQ(const Buf: array of Double; Count: Integer);
+// Поток генератора. Тот же маршрут, что у WDSP TXA (OnTXIQ): пары копятся в
+// DUC-пакеты и уходят в бэкенд. Голосовой тракт в телеграфе стоит, так что
+// продюсер тут ровно один.
+begin
+ OnTXIQ(Buf, Count);
+end;
+
+procedure TRadioController.CWLocalSession(Active: Boolean);
+// Поток генератора. Сессия = одна передача (не одна посылка!): поднимаем PTT,
+// реле T/R с антенной и OC, аттенюатор Pluto — и держим до конца hang.
+// ★FTransmitting НЕ трогаем: в телеграфе передающий тракт WDSP не запускается,
+// а вся индикация идёт через RadioKeyed (см. ApplyHPStatus/ServiceCWKeyed).
+begin
+ if Active then FCWKeyedAt := GetTickCount64;
+ FCWLocalTX := Active;
+ if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
+ begin
+ if Active then
+ begin
+ PushNetworkState; // RadioKeyed=True: Alex bit27, TX-антенна, OC
+ FNetwork.SendPTT(True); // у Pluto здесь же поднимается LO передатчика
+ end
+ else
+ begin
+ // ★Если оператор ДЕРЖИТ MOX (ручной T/R поверх break-in), снимать передачу
+ // не наше дело — иначе конец посылки выбрасывал бы его из эфира.
+ if not FTransmitting then
+ begin
+ FNetwork.SendPTT(False);
+ FNetwork.ClearDUCIQQueue;
+ end;
+ FCWKeyedAt := GetTickCount64; // выдержка RadioKeyed идёт от КОНЦА сессии
+ PushNetworkState;
+ end;
+ end;
+ ApplyPlutoTxAtten;
+ // ★Индикацию отсюда НЕ дёргаем: Invoke у GUI — это TThread.Synchronize, то
+ // есть блокировка потока генератора до кадра UI. Мало того что она вклинилась
+ // бы в путь к эфиру, так ещё и давала бы классический клинч на выходе из
+ // программы (UI ждёт WaitFor генератора, генератор — UI). Фронты снимет
+ // ServiceCWKeyed с таймера хозяина: для бейджа задержка в кадр незаметна,
+ // а выдержка RadioKeyed всё равно не меньше 500 мс.
+end;
+
+procedure TRadioController.CWLocalTick;
+// Поток генератора, на каждом блоке (1.25 мс): держим «железо в эфире» живым,
+// иначе выдержка RadioKeyed истекла бы посреди длинного сообщения.
+begin
+ FCWKeyedAt := GetTickCount64;
+end;
+
+procedure TRadioController.CWKeyPortEvent(Dot, Dash: Boolean);
+// Поток опроса порта. Касание манипулятора обрывает программную передачу —
+// оператор всегда может вклиниться (то же правило, что и у кейера прошивки).
+begin
+ if not Assigned(FCWLocal) then Exit;
+ if (Dot or Dash) and FCWLocal.Busy then FCWLocal.AbortText;
+ FCWLocal.Paddle(Dot, Dash);
+end;
+
procedure TRadioController.CWXKeyEvent(Down: Boolean);
// Поток передачи, НЕ GUI. Обе операции потокобезопасны: SetCWXKey шлёт HP-пакет
// под своим локом бэкенда, FCWKeyDown читает DSP-поток.
@@ -5814,7 +6102,8 @@ end;
function TRadioController.CWXBusy: Boolean;
begin
- Result := Assigned(FCWSender) and FCWSender.Busy;
+ Result := (Assigned(FCWSender) and FCWSender.Busy)
+ or (Assigned(FCWLocal) and FCWLocal.Busy);
end;
procedure TRadioController.CWXSend(const Text: string);
@@ -5826,6 +6115,15 @@ begin
// слот трансвертера, бэнд с DoNotTx) — там кейер разоружён, и дёргать бит
// CWX незачем.
if not CWTXActive then Exit;
+ // Локальный генератор сам держит тайминги В СЭМПЛАХ — текст уходит ему как
+ // есть; поток TCWSender (бит CWX прошивки) при этом не нужен вовсе.
+ if CWLocalSource then
+ begin
+ EnsureCWLocal;
+ SyncCWKeyer; // вооружиться, если пришли сюда первым делом
+ FCWLocal.SendText(Text);
+ Exit;
+ end;
if not Assigned(FCWSender) then
FCWSender := TCWSender.Create(Self.CWXKeyEvent);
FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight);
@@ -5835,6 +6133,7 @@ end;
procedure TRadioController.CWXAbort;
begin
if Assigned(FCWSender) then FCWSender.AbortSending;
+ if Assigned(FCWLocal) then FCWLocal.AbortText;
end;
function TRadioController.MixSidetone(const Left, Right: array of Single;
@@ -5846,15 +6145,29 @@ const
RAMP_MS = 5;
AUDIO_SR = 48000;
var
- i: Integer;
+ i, Got: Integer;
Amp, Step, Rate, S: Double;
+ Local: Boolean;
begin
Result := False;
if not FCWSettings.SidetoneSW then Exit;
if FCWSettings.SidetoneSWLevel <= 0 then Exit;
- // Ключ отпущен и огибающая уже сошлась в ноль — блок трогать незачем.
- if (not FCWKeyDown) and (FCWSTEnv <= 0.0) then Exit;
if Count <= 0 then Exit;
+ // ★Локальный кейер даёт ГОТОВУЮ огибающую — ту же самую, что ушла в эфир, с
+ // точностью до сэмпла. Это снимает старую границу «программный сайдтон верен
+ // только для текста и прямого ключа»: при иамбике в прошивке PC видел лишь
+ // состояние лепестков, а здесь тайминг его собственный.
+ Got := 0;
+ Local := FCWLocalArmed and Assigned(FCWLocal);
+ if Local then
+ begin
+ if Length(FCWSTEnvBuf) < Count then SetLength(FCWSTEnvBuf, Count);
+ Got := FCWLocal.PullSidetone(FCWSTEnvBuf, Count);
+ // Кольцо пусто и хвост уже сошёлся в ноль — блок трогать незачем.
+ if (Got <= 0) and (FCWSTEnv <= 0.0) then Exit;
+ end
+ else if (not FCWKeyDown) and (FCWSTEnv <= 0.0) then
+ Exit;
if Length(FCWSTBufL) < Count then
begin
SetLength(FCWSTBufL, Count);
@@ -5865,7 +6178,18 @@ begin
Rate := 1000.0 / (RAMP_MS * AUDIO_SR);
for i := 0 to Count - 1 do
begin
- if FCWKeyDown then
+ if Local then
+ begin
+ // Дальше конца кольца доигрываем спадом: генератор либо отстал на
+ // блок, либо сессия кончилась.
+ if i < Got then FCWSTEnv := FCWSTEnvBuf[i]
+ else if FCWSTEnv > 0.0 then
+ begin
+ FCWSTEnv := FCWSTEnv - Rate;
+ if FCWSTEnv < 0.0 then FCWSTEnv := 0.0;
+ end;
+ end
+ else if FCWKeyDown then
begin
FCWSTEnv := FCWSTEnv + Rate;
if FCWSTEnv > 1.0 then FCWSTEnv := 1.0;
@@ -6359,14 +6683,14 @@ begin
// Уровень пересчитываем здесь же: с вооружённым кейером он уедет в железо
// сразу, до всякой передачи, а после конструктора FDriveLevel ещё нулевой.
FDriveLevel := CalcDriveByte;
- FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, False, True, True, FTuning, FTwoTone);
+ FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, FDriveLevel, False, True, True, FTuning, FTwoTone);
// ★DUC — на TX-ЧАСТОТУ, а не на центр дисплея. Раньше здесь стоял центр, и до
// первой перестройки VFO (или до первого MOX, который частоту дошлёт сам)
// передатчик стоял не там, где маркер: при CTUN центр и VFO — разные частоты.
// С кейером в прошивке это стало слышно в эфире: железо может замкнуть ключ
// сразу после старта, никакого MOX перед этим не будет.
FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq),
- XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel);
+ TXTuneFreqHz, FDriveLevel);
FRunning := True;
FRXStartTime := GetTickCount64;
FRXLastPktTime := 0;
@@ -6424,11 +6748,15 @@ begin
// (3.3 UI пересоздаст по персисту при следующем старте).
for p := 1 to MAX_PANS - 1 do
RemovePanDDC(p);
- FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), 0, False, True, True, FTuning, FTwoTone);
+ FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, 0, False, True, True, FTuning, FTwoTone);
FNetwork.SetRunAndFreq(False, XvtrTranslate(FCenterFreq),
- XvtrTranslateTX(ActiveTXFreqHz), 0);
+ TXTuneFreqHz, 0);
FRunning := False;
FTransmitting := False;
+ // ★Локальный кейер обязан разоружиться ЗДЕСЬ: таймер хозяина, который его
+ // пересинхронизирует, на остановленном радио уже не работает — иначе
+ // манипулятор продолжал бы гонять IQ в закрытую сессию.
+ SyncCWKeyer;
// Стоп при активном TX/TUN: хвост IQ в DUC-очереди не должен пережить
// остановку (иначе при следующем старте уйдёт в эфир старый тон/голос),
// а TUN-состояние (PostGen-тон) — вернуться в выключенное.
diff --git a/Settings.pas b/Settings.pas
index 336305d..0249bd7 100644
--- a/Settings.pas
+++ b/Settings.pas
@@ -318,7 +318,10 @@ type
Pitch: Integer; // Гц, тон приёма/сайдтона (Thetis default 600)
// Память сообщений для программной передачи (окно CW Messages, F1..F8).
Messages: array[0..CW_MSG_COUNT-1] of string[63];
- FWKeyer: Boolean; // кейер в прошивке (иначе телеграф выключен)
+ // Манипуляция разрешена вообще (False = телеграф без кейера: MOX + голосом
+ // ключа нет). ВМЕСТЕ с LocalKeyer образует одну развилку «кто рисует точки»,
+ // в UI это один список: прошивка / программа / выкл.
+ FWKeyer: Boolean;
KeyerMode: Integer; // 0=Straight, 1=Iambic A, 2=Iambic B
ReverseKeys: Boolean; // поменять точку и тире местами
StrictSpacing: Boolean; // строгие межзнаковые интервалы
@@ -338,6 +341,26 @@ type
// состояние лепестков манипулятора — там верен только аппаратный тон.
SidetoneSW: Boolean;
SidetoneSWLevel: Integer; // 0..100 % от полной шкалы аудио
+ // ---- Локальный (программный) кейер --------------------------------------
+ // Манипуляцию формирует САМ EWSDR: генератор рисует несущую с огибающей
+ // прямо в поток IQ (CWKeyer.pas). Для Pluto/LibreSDR это единственный
+ // возможный путь — кейера в железе там нет, и выбор «прошивка» вырождается
+ // туда сам; для openHPSDR это опция, по умолчанию выключенная (тайминги в
+ // FPGA точнее любой программы).
+ LocalKeyer: Boolean;
+ // Сдвиг несущей манипуляции от гетеродина. 0 = авто: у openHPSDR 0 (DUC —
+ // цифровой, утекать нечему), у zero-IF AD936x — CW_AUTO_OFFSET_HZ, иначе
+ // утечка LO сидит РОВНО на рабочей частоте и слышна между посылками.
+ CarrierOffsetHz: Integer;
+ // Манипулятор на модемных линиях COM/USB-serial (DTR/RTS питают ключ,
+ // CTS/DSR/DCD/RI читаются как лепестки).
+ KeyPortEnabled: Boolean;
+ KeyPort: string[63];
+ KeyDotLine: Integer; // 0=CTS, 1=DSR, 2=DCD, 3=RI
+ KeyDashLine: Integer;
+ KeyInvert: Boolean;
+ KeyPowerDTR: Boolean;
+ KeyPowerRTS: Boolean;
end;
// ---- TX-профили (именованные снимки «как я звучу») ----------------------
@@ -1789,6 +1812,15 @@ begin
C.SidetoneHWLevel := 50;
C.SidetoneSW := False;
C.SidetoneSWLevel := 30;
+ C.LocalKeyer := False; // у openHPSDR тайминги в FPGA; Pluto включит сам
+ C.CarrierOffsetHz := 0; // 0 = авто (по типу бэкенда)
+ C.KeyPortEnabled := False;
+ C.KeyPort := '';
+ C.KeyDotLine := 0; // CTS
+ C.KeyDashLine := 1; // DSR
+ C.KeyInvert := False;
+ C.KeyPowerDTR := True; // типовая распайка: ключ питается от DTR/RTS
+ C.KeyPowerRTS := True;
// Заводские заготовки: типовой вызов, ответ и стандартные концовки.
C.Messages[0] := 'CQ CQ DE ';
C.Messages[1] := 'DE ';
@@ -1826,6 +1858,15 @@ begin
C.SidetoneHWLevel := EnsureRange(JI(O,'sidetone_hw_level', C.SidetoneHWLevel), 0, 127);
C.SidetoneSW := JB(O,'sidetone_sw', C.SidetoneSW);
C.SidetoneSWLevel := EnsureRange(JI(O,'sidetone_sw_level', C.SidetoneSWLevel), 0, 100);
+ C.LocalKeyer := JB(O,'local_keyer', C.LocalKeyer);
+ C.CarrierOffsetHz := EnsureRange(JI(O,'carrier_offset_hz', C.CarrierOffsetHz), -1, 48000);
+ C.KeyPortEnabled := JB(O,'key_port_on', C.KeyPortEnabled);
+ C.KeyPort := Copy(JS(O,'key_port', C.KeyPort), 1, 63);
+ C.KeyDotLine := EnsureRange(JI(O,'key_dot_line', C.KeyDotLine), 0, 3);
+ C.KeyDashLine := EnsureRange(JI(O,'key_dash_line', C.KeyDashLine), 0, 3);
+ C.KeyInvert := JB(O,'key_invert', C.KeyInvert);
+ C.KeyPowerDTR := JB(O,'key_power_dtr', C.KeyPowerDTR);
+ C.KeyPowerRTS := JB(O,'key_power_rts', C.KeyPowerRTS);
for i := 0 to CW_MSG_COUNT - 1 do
C.Messages[i] := Copy(JS(O, 'msg_' + IntToStr(i), C.Messages[i]), 1, 63);
end;
@@ -1850,6 +1891,15 @@ begin
JW(O,'sidetone_hw_level', C.SidetoneHWLevel);
JW(O,'sidetone_sw', C.SidetoneSW);
JW(O,'sidetone_sw_level', C.SidetoneSWLevel);
+ JW(O,'local_keyer', C.LocalKeyer);
+ JW(O,'carrier_offset_hz', C.CarrierOffsetHz);
+ JW(O,'key_port_on', C.KeyPortEnabled);
+ JWS(O,'key_port', C.KeyPort);
+ JW(O,'key_dot_line', C.KeyDotLine);
+ JW(O,'key_dash_line', C.KeyDashLine);
+ JW(O,'key_invert', C.KeyInvert);
+ JW(O,'key_power_dtr', C.KeyPowerDTR);
+ JW(O,'key_power_rts', C.KeyPowerRTS);
for i := 0 to CW_MSG_COUNT - 1 do
JWS(O, 'msg_' + IntToStr(i), C.Messages[i]);
end;
diff --git a/SettingsForm.pas b/SettingsForm.pas
index 64a17a8..50b2962 100644
--- a/SettingsForm.pas
+++ b/SettingsForm.pas
@@ -289,7 +289,9 @@ type
FEdTXCTCSSFreq: TFlatFloatSpinEdit;
// CW (телеграф) — свойство устройства, профилем не переключается
FCW: TCWSettings;
- FChkCWFWKeyer: TFlatCheckBox;
+ // Кто рисует точки: прошивка / программа / никто. Одна развилка — один
+ // список (две галки на неё разъезжались во взаимоисключающих состояниях).
+ FCmbCWKeyerSrc: TFlatComboBox;
FEdCWPitch: TFlatSpinEdit;
FEdCWSpeed: TFlatSpinEdit;
FEdCWWeight: TFlatSpinEdit;
@@ -304,6 +306,16 @@ type
FEdCWSidetoneHWLv: TFlatSpinEdit;
FChkCWSidetoneSW: TFlatCheckBox;
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)
FCmbTXFFT: TFlatComboBox;
FCmbTXWindow: TFlatComboBox;
@@ -658,6 +670,8 @@ type
TcpEnabled: Boolean; TcpPort: Integer);
procedure LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0);
procedure LoadCWSettings(const C: TCWSettings);
+ // Есть ли у активного железа кейер в прошивке (openHPSDR — да, AD936x — нет).
+ procedure SetCWBackend(HasFWKeyer: Boolean);
// Список TX-профилей + активный + drive активного (ползунок DRV живёт в
// главном окне, здесь показываем его значение как часть профиля).
procedure LoadTXProfiles(const Names: array of string;
@@ -2042,9 +2056,12 @@ begin
// ---- CW: телеграфом в openHPSDR P2 управляет прошивка -------------------
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);
MakeLbl(Grp, 'Pitch (Hz)', PAD + 400, R1 - 1, 90);
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);
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 '
- + 'affects it. The voice transmit chain is not started in CW at all. '
- + 'The radio sidetone is heard in the radio headphones; the PC sidetone '
- + 'is exact for keyboard sending and a straight key, but not for a paddle '
- + '— with iambic the firmware owns the timing.', R1 + 5 * STEP - 6, 60);
+ // ---- Программный кейер: манипуляцию рисует сам EWSDR --------------------
+ MakeLbl(Grp, 'Carrier offset (Hz)', PAD, R1 + 5 * STEP + 5, LW);
+ FEdCWOffset := MkSpinCW(Grp, CX, R1 + 5 * STEP, -1, 48000, 0);
+ MakeLbl(Grp, '0 = auto, -1 = none (software keyer only)', PAD + 320, R1 + 5 * STEP + 5, 260);
- 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);
MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW);
@@ -2271,8 +2321,21 @@ begin
end;
procedure TSettingsForm.CollectCWFromUI;
+var Src: Integer;
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 FCmbCWKeyerMode <> nil then FCW.KeyerMode := EnsureRange(FCmbCWKeyerMode.ItemIndex, 0, 2);
if FChkCWReverse <> nil then FCW.ReverseKeys := FChkCWReverse.Checked;
@@ -2287,6 +2350,36 @@ begin
if FEdCWSidetoneHWLv <> nil then FCW.SidetoneHWLevel := FEdCWSidetoneHWLv.Value;
if FChkCWSidetoneSW <> nil then FCW.SidetoneSW := FChkCWSidetoneSW.Checked;
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;
procedure TSettingsForm.FireCWChange;
@@ -2307,11 +2400,21 @@ begin
end;
procedure TSettingsForm.LoadCWSettings(const C: TCWSettings);
+var Src: Integer;
begin
FLoading := True;
try
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 FCmbCWKeyerMode <> nil then FCmbCWKeyerMode.ItemIndex := EnsureRange(C.KeyerMode, 0, 2);
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 FChkCWSidetoneSW <> nil then FChkCWSidetoneSW.Checked := C.SidetoneSW;
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
FLoading := False;
end;
diff --git a/ewsdr.lpi b/ewsdr.lpi
index a80c357..66045c3 100644
--- a/ewsdr.lpi
+++ b/ewsdr.lpi
@@ -312,6 +312,10 @@
+
+
+
+
diff --git a/ewsdrd.lpr b/ewsdrd.lpr
index 3ffb099..f04e51c 100644
--- a/ewsdrd.lpr
+++ b/ewsdrd.lpr
@@ -467,6 +467,9 @@ begin
begin
if FCtrl.FRunning and FCtrl.FWDSPReady then
FCtrl.ServiceBeaconLock;
+ // Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет вовсе, и
+ // отпустить реле T/R после выдержки больше некому).
+ FCtrl.ServiceCWKeyed;
lastBeacon := nowMs;
end;
if nowMs - lastPush >= PUSH_INTERVAL_MS then