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