diff --git a/CATAdapter.pas b/CATAdapter.pas index 1882c42..7d5658f 100644 --- a/CATAdapter.pas +++ b/CATAdapter.pas @@ -38,6 +38,7 @@ type FSyncFreq: Double; FSyncInt: Integer; FSyncBool: Boolean; + FSyncStr: string; // ── Геттеры (CAT-поток, read-only) ── function GetVfoA: Double; @@ -86,6 +87,15 @@ type procedure SetSNB(V: Boolean); procedure SetANF(V: Boolean); procedure SetTransmitting(V: Boolean); + function GetCWSpeed: Integer; + procedure SetCWSpeed(V: Integer); + function GetCWKeyerMode: Integer; + procedure SetCWKeyerMode(V: Integer); + procedure SendCWText(const S: string); + function GetCWTextBusy: Boolean; + procedure SyncCWSpeed; + procedure SyncCWKeyerMode; + procedure SyncCWText; procedure DoBandUp; procedure DoBandDown; procedure DoTuneUp; @@ -217,6 +227,13 @@ begin Ctx.SetAGCTop := @SetAGCTop; Ctx.GetCTun := @GetCTun; Ctx.SetCTun := @SetCTun; + // Телеграф: скорость/режим кейера и программная передача текста (KY/ZZKY). + Ctx.GetCWSpeed := @GetCWSpeed; + Ctx.SetCWSpeed := @SetCWSpeed; + Ctx.GetCWKeyerMode := @GetCWKeyerMode; + Ctx.SetCWKeyerMode := @SetCWKeyerMode; + Ctx.SendCWText := @SendCWText; + Ctx.CWTextBusy := @GetCWTextBusy; Ctx.OnPanelVfoStep := @PanelVfoStep; Ctx.OnPanelEncoder := @PanelEncoder; Ctx.OnPanelButton := @PanelButton; @@ -418,6 +435,47 @@ begin FController.SetANF(FSyncBool); end; procedure TCATAdapter.SetTransmitting(V: Boolean); begin FSyncBool := V; FController.Invoke(@SyncMOX); end; +// ---- Телеграф (KS/KY/ZZKM/ZZKS/ZZKY) -------------------------------------- +// Правки настроек и запуск передачи маршалим в поток контроллера, как всё +// остальное в адаптере: SetCWSettings трогает движок и пишет конфиг. + +function TCATAdapter.GetCWSpeed: Integer; +begin Result := FController.CWSettings.Speed; end; + +procedure TCATAdapter.SetCWSpeed(V: Integer); +begin FSyncInt := V; FController.Invoke(@SyncCWSpeed); end; + +procedure TCATAdapter.SyncCWSpeed; +var C: TCWSettings; +begin + C := FController.CWSettings; + C.Speed := FSyncInt; + FController.SetCWSettings(C); +end; + +function TCATAdapter.GetCWKeyerMode: Integer; +begin Result := FController.CWSettings.KeyerMode; end; + +procedure TCATAdapter.SetCWKeyerMode(V: Integer); +begin FSyncInt := V; FController.Invoke(@SyncCWKeyerMode); end; + +procedure TCATAdapter.SyncCWKeyerMode; +var C: TCWSettings; +begin + C := FController.CWSettings; + C.KeyerMode := FSyncInt; + FController.SetCWSettings(C); +end; + +procedure TCATAdapter.SendCWText(const S: string); +begin FSyncStr := S; FController.Invoke(@SyncCWText); end; + +procedure TCATAdapter.SyncCWText; +begin FController.CWXSend(FSyncStr); end; + +function TCATAdapter.GetCWTextBusy: Boolean; +begin Result := FController.CWXBusy; end; + procedure TCATAdapter.SyncMOX; begin FController.SetMOX(FSyncBool); end; diff --git a/CATEngine.pas b/CATEngine.pas index 7993e69..b2f2a0b 100644 --- a/CATEngine.pas +++ b/CATEngine.pas @@ -112,6 +112,14 @@ type GetFilterHigh: TCATGetIntFunc; SetFilterLow: TCATSetIntProc; SetFilterHigh: TCATSetIntProc; + // Телеграф (KS/KY/ZZKS/ZZKY/ZZKM). Скорость — WPM 5..60; SendCWText ставит + // текст в очередь программной передачи, CWTextBusy = «очередь занята». + GetCWSpeed: TCATGetIntFunc; + SetCWSpeed: TCATSetIntProc; + GetCWKeyerMode: TCATGetIntFunc; // 0=прямой ключ, 1=iambic A, 2=iambic B + SetCWKeyerMode: TCATSetIntProc; + SendCWText: procedure(const S: string) of object; + CWTextBusy: TCATGetBoolFunc; // Andromeda front panel input (ZZZD/ZZZU/ZZZE/ZZZP/ZZZS) OnPanelVfoStep: TCATSetIntProc; // знаковое число шагов энкодера VFO OnPanelEncoder: TCATPanelEncoderProc; // номер энкодера (0-based) + знаковый шаг @@ -136,6 +144,7 @@ type function SafeGetVfoA: Double; function SafeGetVfoB: Double; function SafeGetMode: Integer; + function SafeGetCWSpeed: Integer; function SafeGetVolume: Integer; function SafeGetDriveLevel: Integer; function SafeGetFilterIdx: Integer; @@ -812,21 +821,43 @@ begin Result := freq + step + incr + rit + xit + '000' + tx + mode + '0' + '0' + split + '0000'; end; +function TCATEngine.SafeGetCWSpeed: Integer; +begin + if Assigned(FCtx.GetCWSpeed) then Result := FCtx.GetCWSpeed else Result := 20; +end; + function TCATEngine.CmdKS(const s: string): string; -// KS: CW keying speed (3 digits, WPM) — STUB: no CW keyer +// KS: CW keying speed (3 digits, WPM). +var v: Integer; begin if Length(s) = 3 then - Result := '' // accepted but ignored + begin + if not TryStrToInt(s, v) then Exit(CAT_ERROR); + if Assigned(FCtx.SetCWSpeed) then FCtx.SetCWSpeed(v); + Result := ''; + end else if Length(s) = 0 then - Result := '025' // default 25 WPM + Result := Format('%.3d', [SafeGetCWSpeed]) else Result := CAT_ERROR; end; function TCATEngine.CmdKY(const s: string): string; -// KY: send CW text — STUB: no CW keyer implemented +// KY: передать текст телеграфом. Kenwood-семантика: первый символ — пробел- +// заполнитель признака «не занято», далее сам текст; пустой аргумент = опрос +// занятости ('1' — очередь занята, логгер ждёт). +var Txt: string; begin - Result := '0'; // queue not full + if Length(s) = 0 then + begin + if Assigned(FCtx.CWTextBusy) and FCtx.CWTextBusy then Result := '1' + else Result := '0'; + Exit; + end; + Txt := s; + if (Length(Txt) > 0) and (Txt[1] = ' ') then Delete(Txt, 1, 1); + if Assigned(FCtx.SendCWText) then FCtx.SendCWText(Txt); + Result := ''; end; function TCATEngine.CmdMD(const s: string): string; @@ -1754,23 +1785,34 @@ begin end; function TCATEngine.ZZKM(const s: string): string; -// ZZKM: CW key mode — STUB: no CW keyer +// ZZKM: режим кейера (0=прямой ключ, 1=iambic A, 2=iambic B). +var v: Integer; begin - if Length(s) = 1 then Result := '' else if Length(s) = 0 then Result := '0' + if Length(s) = 1 then + begin + if not TryStrToInt(s, v) then Exit(CAT_ERROR); + if (v < 0) or (v > 2) then Exit(CAT_ERROR); + if Assigned(FCtx.SetCWKeyerMode) then FCtx.SetCWKeyerMode(v); + Result := ''; + end + else if Length(s) = 0 then + begin + if Assigned(FCtx.GetCWKeyerMode) then Result := IntToStr(FCtx.GetCWKeyerMode) + else Result := '0'; + end else Result := CAT_ERROR; end; function TCATEngine.ZZKS(const s: string): string; -// ZZKS: CW speed (WPM) — STUB: no CW keyer +// ZZKS: скорость кейера, WPM. begin - if Length(s) = 3 then Result := '' else if Length(s) = 0 then Result := '025' - else Result := CAT_ERROR; + Result := CmdKS(s); end; function TCATEngine.ZZKY(const s: string): string; -// ZZKY: CW text to send — STUB: no CW keyer implemented +// ZZKY: передать текст телеграфом (то же, что KY). begin - Result := '0'; // buffer not full + Result := CmdKY(s); end; function TCATEngine.ZZMA(const s: string): string; diff --git a/CWDecoder.pas b/CWDecoder.pas new file mode 100644 index 0000000..5478f61 --- /dev/null +++ b/CWDecoder.pas @@ -0,0 +1,462 @@ +unit CWDecoder; + +{$mode objfpc}{$H+} + +// --------------------------------------------------------------------------- +// Декодер телеграфа: RX-аудио → текст. +// +// ОТКУДА СИГНАЛ. Отвод берётся ДО громкости и мьюта (OnDemodAudioReady) — там +// нет программного сайдтона (он подмешивается позже) и там уже отработал узкий +// CW-фильтр приёмника. Лучшего предетектора не найти: полоса 250-500 Гц режет +// всё, кроме корреспондента, а тон стоит ровно на pitch, потому что сдвиг +// гетеродина в телеграфе делаем мы сами (WDSPEngine.CWLOOffset). +// +// ЦЕПОЧКА. Гёрцель гребёнкой бинов вокруг pitch (оператор редко попадает в +// ноль, поэтому берём максимум и заодно знаем расстройку) → огибающая → +// адаптивный порог с гистерезисом → длительности посылок и пауз → адаптивная +// ТОЧКА → дерево Морзе. +// +// ★Длительности живут в ШАГАХ анализа (то есть в сэмплах), а не в показаниях +// часов: разбор идёт пачками с таймера окна, и привязка ко времени вызова +// ломала бы тайминг на любой загрузке. Ровно то же правило, что в CWKeyer. +// +// ГРАНИЦА, которую надо знать. Машинную передачу (кейер, компьютер, маяк) такой +// декодер читает почти без ошибок; рукопашный «фист» с плавающим весом — плохо. +// Это свойство задачи, а не реализации: у fldigi и трансиверов с декодером на +// борту ровно та же картина. +// --------------------------------------------------------------------------- + +interface + +uses + Classes, SysUtils, SyncObjs, Math, CWMorse; + +const + CWD_RATE = 48000; // rate отвода аудио + CWD_N = 512; // окно Гёрцеля (10.7 мс — полоса ~100 Гц) + CWD_HOP = 256; // шаг анализа (5.33 мс), окна перекрываются вдвое + CWD_BINS = 5; // бинов в гребёнке (центр ± 2 шага) + CWD_BIN_HZ = 100; // шаг гребёнки, Гц + +type + TCWDecoder = class + private + FLock: TCriticalSection; + // ---- кольцо аудио (продюсер — DSP-поток, консьюмер — Process) ---------- + FRing: array of Single; + FHead: Integer; + FTail: Integer; + // ---- параметры --------------------------------------------------------- + FPitch: Integer; + FEnabled: Boolean; + // ---- гребёнка Гёрцеля -------------------------------------------------- + FCoef: array[0..CWD_BINS-1] of Double; + FWin: array[0..CWD_N-1] of Double; // окно Ханна + FBuf: array[0..CWD_N-1] of Double; // оконный кадр + FBestBin: Integer; + FBestPow: Double; // уровень лучшего бина (для гейта расстройки) + // ---- пороги ------------------------------------------------------------ + FNoise: Double; + FPeak: Double; + FMarkNow: Boolean; + FRunLen: Integer; // длина текущего состояния в шагах + FCandLen: Integer; // длина «дребезга» — кандидата на смену состояния + FCandMark: Boolean; + FWarmup: Integer; // шаги до первого решения (пороги должны устояться) + FDebounce: Integer; // сколько шагов подряд держать новое состояние + // ---- разбор ------------------------------------------------------------ + FDitMs: Double; // адаптивная длина точки + FPattern: string; // накопленный знак ('.' и '-') + FIdleSteps: Integer; // тишина после последнего знака (шаги) + FWordDone: Boolean; // межсловный пробел уже выдан + FOut: string; // очередь декодированного текста (под FLock) + FSignal: Boolean; + procedure BuildTables; + procedure StepEnvelope(Mag: Double); + procedure OnMark(Steps: Integer); + procedure OnSpace(Steps: Integer); + procedure FlushChar; + procedure Emit(const S: string); + function StepMs: Double; + public + constructor Create; + destructor Destroy; override; + + procedure SetPitch(Hz: Integer); + procedure SetSpeedHint(WPM: Integer); // старт адаптации (своя скорость) + procedure Reset; + property Enabled: Boolean read FEnabled write FEnabled; + + // DSP-поток: только копирование в кольцо. + procedure FeedAudio(const Left: array of Single; Count: Integer); + // Поток-хозяин (таймер): разбор накопленного. + procedure Process; + // Забрать декодированное (и очистить очередь). + function TakeText: string; + + function SpeedWPM: Integer; // оценка скорости корреспондента + function ToneOffsetHz: Integer; // расстройка: где нашёлся тон + function SignalPresent: Boolean; + end; + +// Обратная таблица: код ('.', '-') → знак. '' если такого кода нет. +function MorseDecode(const Code: string): string; + +implementation + +const + RING_BITS = 15; + RING_SIZE = 1 shl RING_BITS; // 32768 сэмплов ≈ 0.68 с + RING_MASK = RING_SIZE - 1; + DIT_MIN_MS = 20.0; // 60 WPM + DIT_MAX_MS = 240.0; // 5 WPM + SNR_GATE = 2.2; // во сколько раз пик должен быть выше шума + +var + GCodes: array[0..127] of string; // знак → код, строится один раз + GBuilt: Boolean = False; + +procedure BuildCodeTable; +var C: Char; +begin + if GBuilt then Exit; + for C := #32 to #126 do GCodes[Ord(C)] := MorseCode(C); + GBuilt := True; +end; + +function MorseDecode(const Code: string): string; +var i: Integer; +begin + Result := ''; + if Code = '' then Exit; + BuildCodeTable; + for i := 32 to 126 do + if GCodes[i] = Code then + begin + // Таблица кодирования отдаёт заглавные — их и печатаем. + Result := UpCase(Chr(i)); + Exit; + end; + // Служебные сочетания, которых нет в таблице передачи (принимают их часто). + if Code = '...-.-' then Result := '' + else if Code = '-.--.' then Result := '' + else if Code = '.-...' then Result := '' + else if Code = '........' then Result := ''; +end; + +{ ── TCWDecoder ────────────────────────────────────────────────────────────── } + +constructor TCWDecoder.Create; +begin + FLock := TCriticalSection.Create; + SetLength(FRing, RING_SIZE); + FPitch := 600; + FDitMs := 60.0; // 20 WPM — стартовая догадка, дальше адаптация + BuildTables; + Reset; +end; + +destructor TCWDecoder.Destroy; +begin + FLock.Free; + inherited Destroy; +end; + +function TCWDecoder.StepMs: Double; +begin + Result := 1000.0 * CWD_HOP / CWD_RATE; +end; + +procedure TCWDecoder.BuildTables; +var + i: Integer; + F: Double; +begin + for i := 0 to CWD_BINS - 1 do + begin + F := FPitch + (i - CWD_BINS div 2) * CWD_BIN_HZ; + if F < 100 then F := 100; + FCoef[i] := 2.0 * Cos(2.0 * Pi * F / CWD_RATE); + end; + for i := 0 to CWD_N - 1 do + FWin[i] := 0.5 - 0.5 * Cos(2.0 * Pi * i / (CWD_N - 1)); // Ханн +end; + +procedure TCWDecoder.SetPitch(Hz: Integer); +begin + Hz := EnsureRange(Hz, 200, 1200); + if Hz = FPitch then Exit; + FPitch := Hz; + BuildTables; +end; + +procedure TCWDecoder.SetSpeedHint(WPM: Integer); +begin + WPM := EnsureRange(WPM, 5, 60); + FDitMs := 1200.0 / WPM; +end; + +procedure TCWDecoder.Reset; +begin + FLock.Enter; + try + FHead := 0; FTail := 0; FOut := ''; + finally + FLock.Leave; + end; + FNoise := 0; FPeak := 0; + // Пол шума и пик стартуют с нуля — пока они не устоялись, порог стоит где + // попало, и первый же знак прочитался бы с лишним элементом. Полсекунды + // молчания в начале никому не мешают: приёмник работает задолго до сигнала. + FWarmup := Round(500.0 / StepMs); + FMarkNow := False; FRunLen := 0; FCandLen := 0; FCandMark := False; + FPattern := ''; FIdleSteps := 0; FWordDone := True; + FSignal := False; FBestBin := CWD_BINS div 2; +end; + +procedure TCWDecoder.FeedAudio(const Left: array of Single; Count: Integer); +// DSP-поток. Кольцо на 0.68 с: таймер разбора ходит в разы чаще, а если +// подвиснет — потеряем старое, но не сломаем состояние (это лучше, чем ждать). +var + i, H: Integer; +begin + if not FEnabled then Exit; + if Count > Length(Left) then Count := Length(Left); + FLock.Enter; + try + H := FHead; + for i := 0 to Count - 1 do + begin + FRing[H] := Left[i]; + H := (H + 1) and RING_MASK; + if H = FTail then FTail := (FTail + 1) and RING_MASK; // переполнение + end; + FHead := H; + finally + FLock.Leave; + end; +end; + +procedure TCWDecoder.Process; +// Поток-хозяин. Гёрцель по перекрывающимся окнам: на каждый шаг — одно решение +// «есть тон / нет тона». +var + Avail, i, k, T, BestK: Integer; + S0, S1, S2, W, P, Best: Double; +begin + if not FEnabled then Exit; + while True do + begin + FLock.Enter; + try + Avail := (FHead - FTail) and RING_MASK; + if Avail < CWD_N then Break; // finally отработает, кольцо не тронуто + T := FTail; + for i := 0 to CWD_N - 1 do + begin + FBuf[i] := FRing[T] * FWin[i]; + T := (T + 1) and RING_MASK; + end; + FTail := (FTail + CWD_HOP) and RING_MASK; + finally + FLock.Leave; + end; + + Best := 0; BestK := FBestBin; + for k := 0 to CWD_BINS - 1 do + begin + S1 := 0; S2 := 0; W := FCoef[k]; + for i := 0 to CWD_N - 1 do + begin + S0 := FBuf[i] + W * S1 - S2; + S2 := S1; + S1 := S0; + end; + P := S1 * S1 + S2 * S2 - W * S1 * S2; + if P > Best then begin Best := P; BestK := k; end; + end; + FBestPow := Sqrt(Max(0.0, Best)) / (CWD_N / 2); + // ★Расстройку запоминаем ТОЛЬКО на посылке: в паузе максимум выбирает шум, + // и индикатор в окне плясал бы от −200 до +200 Гц. Между посылками + // показываем последнее осмысленное значение. + // Только на ПОЛНОЙ амплитуде посылки: на спаде фронта максимум уже + // разыгрывают шум и соседи по полосе. + if FMarkNow and (FBestPow > FNoise + (FPeak - FNoise) * 0.7) then + FBestBin := BestK; + StepEnvelope(FBestPow); + end; +end; + +procedure TCWDecoder.StepEnvelope(Mag: Double); +// Порог плавает между полом шума и пиком сигнала: QSB, разные корреспонденты и +// разные уровни AGC иначе требовали бы ручной подстройки на каждую связь. +// Гистерезис (0.6/0.4) не даёт дребезжать на фронте посылки. +var + Span, ThrOn, ThrOff: Double; + IsMark: Boolean; +begin + // Пол шума: быстро вниз, очень медленно вверх (иначе он «догоняет» длинное + // тире и посылка обрывается посередине). + if Mag < FNoise then FNoise := FNoise + (Mag - FNoise) * 0.05 + else FNoise := FNoise + (Mag - FNoise) * 0.002; + if Mag > FPeak then FPeak := Mag + else FPeak := FNoise + (FPeak - FNoise) * 0.9995; + // ★Пик обязан быть не ниже пола: на старте (и в полной тишине) он ещё нулевой, + // размах уходил в минус, порог оказывался НИЖЕ шума — и первым же «знаком» + // декодер читал собственный шум, портя начало передачи. + if FPeak < FNoise then FPeak := FNoise; + + Span := FPeak - FNoise; + FSignal := Span > FNoise * (SNR_GATE - 1.0) + 1e-9; + if FWarmup > 0 then + begin + Dec(FWarmup); + Exit; // трекеры набирают уровень, решений не принимаем + end; + ThrOn := FNoise + Span * 0.6; + ThrOff := FNoise + Span * 0.4; + + if FMarkNow then IsMark := Mag > ThrOff + else IsMark := Mag > ThrOn; + // Пока сигнал не выделяется из шума, посылок не бывает по определению. + if not FSignal then IsMark := False; + + // Дребезг не считается сменой состояния: это либо импульсная помеха, либо + // провал на границе окна. Порог берём от текущей точки (четверть её длины, + // но не меньше двух шагов): фиксированный либо пропускал бы щелчки на 20 WPM, + // либо съедал бы настоящие посылки на 60. + FDebounce := Round(0.25 * FDitMs / StepMs); + if FDebounce < 2 then FDebounce := 2; + if IsMark <> FMarkNow then + begin + if FCandMark = IsMark then Inc(FCandLen) + else begin FCandMark := IsMark; FCandLen := 1; end; + if FCandLen >= FDebounce then + begin + // ★Длину меряем ЗА ВЫЧЕТОМ подтверждения: пока копился кандидат, сигнал + // уже сменился, и эти шаги принадлежат новому состоянию. Без поправки + // каждая посылка выходила длиннее на порог дребезга — на 20 WPM это + // давало оценку 17 WPM и смещало границу «точка/тире». + if FMarkNow then OnMark(FRunLen - (FDebounce - 1)) + else OnSpace(FRunLen - (FDebounce - 1)); + FMarkNow := IsMark; + FRunLen := FDebounce; + FCandLen := 0; + Exit; + end; + end + else + FCandLen := 0; + Inc(FRunLen); + + // Хвосты в тишине: знак пора закрыть, а слово — отбить пробелом. + if not FMarkNow then + begin + Inc(FIdleSteps); + if (FPattern <> '') and (FIdleSteps * StepMs > 2.5 * FDitMs) then FlushChar; + if (not FWordDone) and (FIdleSteps * StepMs > 6.0 * FDitMs) then + begin + Emit(' '); + FWordDone := True; + end; + end; +end; + +procedure TCWDecoder.OnMark(Steps: Integer); +// Посылка кончилась: точка это или тире — решает адаптивная длина точки, и она +// же по этой посылке уточняется. Порог 2 точки — классический. +var + Ms: Double; +begin + Ms := Steps * StepMs; + if Ms < 0.5 * DIT_MIN_MS then Exit; // мусор + if Ms < 2.0 * FDitMs then + begin + FPattern := FPattern + '.'; + // ★Вниз — сразу, вверх — плавно. Корреспондент может оказаться вдвое быстрее + // нашей подсказки, и тогда его тире мы читаем как точку; пока оценка ползёт + // вверх-вниз усреднением, теряется целое слово. Посылка заметно короче + // текущей точки — это и есть новая точка, спорить не о чем. + if Ms < 0.6 * FDitMs then FDitMs := Ms + else FDitMs := FDitMs + 0.3 * (Ms - FDitMs); + end + else + begin + FPattern := FPattern + '-'; + FDitMs := FDitMs + 0.15 * (Ms / 3.0 - FDitMs); + end; + FDitMs := EnsureRange(FDitMs, DIT_MIN_MS, DIT_MAX_MS); + FIdleSteps := 0; + FWordDone := False; +end; + +procedure TCWDecoder.OnSpace(Steps: Integer); +// Пауза кончилась (началась посылка): по её длине понимаем, был ли это разрыв +// внутри знака, между знаками или между словами. +var + Ms: Double; +begin + Ms := Steps * StepMs; + FIdleSteps := 0; + if Ms > 2.0 * FDitMs then FlushChar; + if Ms > 5.0 * FDitMs then + begin + if not FWordDone then + begin + Emit(' '); + FWordDone := True; + end; + end; +end; + +procedure TCWDecoder.FlushChar; +var + S: string; +begin + if FPattern = '' then Exit; + S := MorseDecode(FPattern); + if S = '' then S := '*'; // не опознали — метка, а не тишина + FPattern := ''; + Emit(S); +end; + +procedure TCWDecoder.Emit(const S: string); +begin + if S = '' then Exit; + FLock.Enter; + try + FOut := FOut + S; + if Length(FOut) > 4096 then FOut := Copy(FOut, Length(FOut) - 4095, 4096); + finally + FLock.Leave; + end; +end; + +function TCWDecoder.TakeText: string; +begin + FLock.Enter; + try + Result := FOut; + FOut := ''; + finally + FLock.Leave; + end; +end; + +function TCWDecoder.SpeedWPM: Integer; +begin + if FDitMs <= 0 then Exit(0); + Result := Round(1200.0 / FDitMs); +end; + +function TCWDecoder.ToneOffsetHz: Integer; +begin + Result := (FBestBin - CWD_BINS div 2) * CWD_BIN_HZ; +end; + +function TCWDecoder.SignalPresent: Boolean; +begin + Result := FSignal; +end; + +end. diff --git a/CWKeyer.pas b/CWKeyer.pas new file mode 100644 index 0000000..b37b502 --- /dev/null +++ b/CWKeyer.pas @@ -0,0 +1,1069 @@ +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; // касание манипулятора — обрывает текст + // Эхо передачи для окна-терминала: знаки, которые УЖЕ ушли в эфир, и хвост, + // который ещё стоит в очереди. Ведём под FLock, потому что читает их UI. + FSent: string; + FUnsent: string; + FTrimTail: Boolean; // Backspace съел знак из уже разобранного текста + // ---- рабочая копия (только поток генератора) --------------------------- + 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; + // Терминал: знаки, ушедшие в эфир с прошлого опроса (эхо своей передачи). + function TakeSentText: string; + // Терминал: что ещё стоит в очереди (набранное вперёд). + function PendingText: string; + // Стереть последний НЕ ушедший знак (Backspace в окне набора). + function Backspace: 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; + +function TCWLocalKeyer.TakeSentText: string; +begin + FLock.Enter; + try + Result := FSent; + FSent := ''; + finally + FLock.Leave; + end; +end; + +function TCWLocalKeyer.PendingText: string; +begin + FLock.Enter; + try + Result := FUnsent + FPending; + finally + FLock.Leave; + end; +end; + +function TCWLocalKeyer.Backspace: Boolean; +// Стираем с ХВОСТА очереди: то, что уже звучит в эфире, вернуть нельзя, а +// набранное вперёд — можно и нужно (иначе опечатка обязательно уедет). +begin + FLock.Enter; + try + Result := False; + if FPending <> '' then + begin + SetLength(FPending, Length(FPending) - 1); + Result := True; + end + else if FUnsent <> '' then + begin + // Хвост уже разобранного текста: помечаем к отсечению — генератор увидит + // укороченный FUnsent на следующем знаке (см. TakeTextElement). + SetLength(FUnsent, Length(FUnsent) - 1); + FTrimTail := True; + Result := True; + end; + 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; + FUnsent := ''; + FTrimTail := False; + finally + FLock.Leave; + end; +end; + +function TCWLocalKeyer.TakeTextElement(out IsDot: Boolean): Boolean; +// Очередной элемент передаваемого текста. Раскладка пауз та же, что у кейера +// прошивки: 1 точка между элементами знака, 3 между знаками, 7 между словами. +// Межсловная пауза копится в FLeadGap (отдаётся ПЕРЕД элементом), межзнаковая — +// в FTailGap (после). +var + Ch: Char; + Add: string; + Drop, Trim: Boolean; + KeepLen: Integer; +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 := ''; + Trim := FTrimTail; + FTrimTail := False; + KeepLen := FTextPos + Length(FUnsent); + finally + FLock.Leave; + end; + if Drop then + begin + ResetText; + Exit; + end; + // Backspace дотянулся до уже разобранного текста — отсекаем хвост. + if Trim and (Length(FText) > KeepLen) then SetLength(FText, KeepLen); + 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]; + // Эхо: знак пошёл в эфир — отдаём его окну-терминалу, а хвост очереди + // обновляем, чтобы строка набора показывала только ненабранное. + FLock.Enter; + try + FSent := FSent + Ch; + FUnsent := Copy(FText, FTextPos + 1, MaxInt); + if Length(FSent) > 4096 then FSent := Copy(FSent, Length(FSent) - 4095, 4096); + finally + FLock.Leave; + end; + 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/CWMessagesForm.pas b/CWMessagesForm.pas new file mode 100644 index 0000000..0485687 --- /dev/null +++ b/CWMessagesForm.pas @@ -0,0 +1,231 @@ +unit CWMessagesForm; + +{ TCWMessagesForm — память телеграфных сообщений (CW_MSG_COUNT ячеек). + + Зачем отдельным окном. Передача текста нужна ПОСРЕДИ связи, поэтому кнопки + «отправить» обязаны быть под рукой, а не в настройках. В блоке TX левой + панели свободных ячеек нет (MOX/TUN/PS/2TON + DUP/RX MUTE/профиль + DRV), + так что окно висит рядом — ровно как окно CWX у Thetis. Те же ячейки + привязаны к F1..F8 в главном окне. + + Правки текста пишутся в TCWSettings сразу (идиома EWSDR: кнопки «сохранить» + нет), через тот же OnChanged, что и вкладка настроек. } + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, Math, + Forms, Controls, Graphics, StdCtrls, ExtCtrls, + FlatButton, FlatEdit, AppTheme, Settings, DpiUtils; + +type + TCWSendEvent = procedure(const Text: string) of object; + TCWMsgsChanged = procedure(const C: TCWSettings) of object; + + TCWMessagesForm = class(TForm) + private + FCW: TCWSettings; + FTheme: TAppTheme; + FUpdating: Boolean; + FLbls: array[0..CW_MSG_COUNT-1] of TLabel; + FEdits: array[0..CW_MSG_COUNT-1] of TFlatEdit; + FSendBtns: array[0..CW_MSG_COUNT-1] of TFlatButton; + FBtnStop: TFlatButton; + FBtnClose: TFlatButton; + FHint: TLabel; + FOnSend: TCWSendEvent; + FOnAbort: TNotifyEvent; + FOnChanged: TCWMsgsChanged; + procedure BuildUI; + procedure EditChange(Sender: TObject); + procedure SendClick(Sender: TObject); + procedure StopClick(Sender: TObject); + procedure CloseClick(Sender: TObject); + procedure StyleBtn(B: TFlatButton); + public + constructor Create(AOwner: TComponent); override; + procedure LoadFrom(const C: TCWSettings); + procedure ApplyTheme(const T: TAppTheme); + // Отправить ячейку по индексу (F1..F8 из главного окна). + procedure SendSlot(Idx: Integer); + property OnSend: TCWSendEvent read FOnSend write FOnSend; + property OnAbort: TNotifyEvent read FOnAbort write FOnAbort; + property OnChanged: TCWMsgsChanged read FOnChanged write FOnChanged; + end; + +implementation + +const +{$IFDEF WINDOWS} + UI_FONT = 'Segoe UI'; +{$ELSE} + UI_FONT = 'Sans'; +{$ENDIF} + FORM_W = 470; + PAD = 14; + ROW_H = 26; + ROW_GAP = 6; + LBL_W = 30; + BTN_W = 66; + +constructor TCWMessagesForm.Create(AOwner: TComponent); +begin + inherited CreateNew(AOwner); + FTheme := DarkTheme; + FUpdating := False; + TSettingsManager.DefaultCW(FCW); + + Scaled := False; + Caption := 'CW Messages'; + BorderStyle := bsSingle; + if AOwner is TCustomForm then Position := poOwnerFormCenter + else Position := poScreenCenter; + Width := DpiScale(FORM_W); + // Ряды + строка кнопок + подсказка в ДВЕ строки под ними (в один ряд с + // кнопками она налезала на Close). + Height := DpiScale(2 * PAD + CW_MSG_COUNT * (ROW_H + ROW_GAP) + + ROW_H + 8 + 34); + BuildUI; + ApplyTheme(DarkTheme); +end; + +procedure TCWMessagesForm.BuildUI; +var + i, Y: Integer; +begin + Y := PAD; + for i := 0 to CW_MSG_COUNT - 1 do + begin + FLbls[i] := TLabel.Create(Self); + FLbls[i].Parent := Self; + FLbls[i].Caption := 'F' + IntToStr(i + 1); + FLbls[i].Font.Name := UI_FONT; + FLbls[i].Font.Size := 9; + FLbls[i].SetBounds(DpiScale(PAD), DpiScale(Y + 5), + DpiScale(LBL_W), DpiScale(18)); + + FEdits[i] := TFlatEdit.Create(Self); + FEdits[i].Parent := Self; + FEdits[i].Font.Name := UI_FONT; + FEdits[i].Font.Size := 9; + FEdits[i].MaxLength := 63; + FEdits[i].Tag := i; + FEdits[i].OnChange := @EditChange; + FEdits[i].SetBounds(DpiScale(PAD + LBL_W), DpiScale(Y), + DpiScale(FORM_W - 2 * PAD - LBL_W - BTN_W - 8), + DpiScale(ROW_H - 2)); + + FSendBtns[i] := TFlatButton.Create(Self); + FSendBtns[i].Parent := Self; + FSendBtns[i].Caption := 'Send'; + FSendBtns[i].Tag := i; + FSendBtns[i].OnClick := @SendClick; + FSendBtns[i].SetBounds(DpiScale(FORM_W - PAD - BTN_W), DpiScale(Y), + DpiScale(BTN_W), DpiScale(ROW_H - 2)); + Inc(Y, ROW_H + ROW_GAP); + end; + + FBtnStop := TFlatButton.Create(Self); + FBtnStop.Parent := Self; + FBtnStop.Caption := 'Stop'; + FBtnStop.OnClick := @StopClick; + FBtnStop.SetBounds(DpiScale(PAD), DpiScale(Y), DpiScale(BTN_W), DpiScale(ROW_H)); + + FBtnClose := TFlatButton.Create(Self); + FBtnClose.Parent := Self; + FBtnClose.Caption := 'Close'; + FBtnClose.OnClick := @CloseClick; + FBtnClose.SetBounds(DpiScale(FORM_W - PAD - BTN_W), DpiScale(Y), + DpiScale(BTN_W), DpiScale(ROW_H)); + + FHint := TLabel.Create(Self); + FHint.Parent := Self; + FHint.Font.Name := UI_FONT; + FHint.Font.Size := 8; + FHint.AutoSize := False; + FHint.WordWrap := True; + FHint.Caption := 'F1..F8 in the main window send the same slots. ' + + 'Touching the paddle or dropping MOX stops sending.'; + FHint.SetBounds(DpiScale(PAD), DpiScale(Y + ROW_H + 8), + DpiScale(FORM_W - 2 * PAD), DpiScale(34)); +end; + +procedure TCWMessagesForm.StyleBtn(B: TFlatButton); +begin + if B = nil then Exit; + B.ClrNorm := FTheme.BtnNorm; + B.ClrBorder := FTheme.BtnBorderNorm; + B.ClrHot := FTheme.BtnHot; + B.ClrActive := FTheme.BtnActive; + B.ClrText := FTheme.BtnText; + B.ClrTextAct := FTheme.BtnTextActive; + B.Font.Name := UI_FONT; + B.Font.Size := 9; + B.Invalidate; +end; + +procedure TCWMessagesForm.ApplyTheme(const T: TAppTheme); +var i: Integer; +begin + FTheme := T; + Color := T.BG; + for i := 0 to CW_MSG_COUNT - 1 do + begin + FLbls[i].Font.Color := T.TextDim; + FEdits[i].SetAppTheme(T); + StyleBtn(FSendBtns[i]); + end; + StyleBtn(FBtnStop); + StyleBtn(FBtnClose); + FHint.Font.Color := T.TextDim; + Invalidate; +end; + +procedure TCWMessagesForm.LoadFrom(const C: TCWSettings); +var i: Integer; +begin + FUpdating := True; + try + FCW := C; + for i := 0 to CW_MSG_COUNT - 1 do + FEdits[i].Text := C.Messages[i]; + finally + FUpdating := False; + end; +end; + +procedure TCWMessagesForm.EditChange(Sender: TObject); +var i: Integer; +begin + if FUpdating then Exit; + i := TFlatEdit(Sender).Tag; + if (i < 0) or (i >= CW_MSG_COUNT) then Exit; + FCW.Messages[i] := Copy(TFlatEdit(Sender).Text, 1, 63); + if Assigned(FOnChanged) then FOnChanged(FCW); +end; + +procedure TCWMessagesForm.SendSlot(Idx: Integer); +begin + if (Idx < 0) or (Idx >= CW_MSG_COUNT) then Exit; + if FCW.Messages[Idx] = '' then Exit; + if Assigned(FOnSend) then FOnSend(FCW.Messages[Idx]); +end; + +procedure TCWMessagesForm.SendClick(Sender: TObject); +begin + SendSlot(TFlatButton(Sender).Tag); +end; + +procedure TCWMessagesForm.StopClick(Sender: TObject); +begin + if Assigned(FOnAbort) then FOnAbort(Self); +end; + +procedure TCWMessagesForm.CloseClick(Sender: TObject); +begin + Hide; +end; + +end. diff --git a/CWMorse.pas b/CWMorse.pas new file mode 100644 index 0000000..dd6f027 --- /dev/null +++ b/CWMorse.pas @@ -0,0 +1,382 @@ +unit CWMorse; + +{$mode objfpc}{$H+} + +// --------------------------------------------------------------------------- +// Программная передача телеграфа: текст → точки/тире → ключ. +// +// Кто держит тайминги. У openHPSDR P2 есть ДВА пути в эфир: +// * манипулятор в разъёме трансивера — элементы формирует кейер в FPGA, PC +// только отдаёт ему скорость/вес (DUC Specific байт 5..10). Джиттера нет; +// * бит CWX в High Priority — это «ключ нажат», сырая линия. Прошивка по +// нему даёт несущую с той же формой фронта (CWRampPeriod), но КОГДА +// нажать и отпустить, решает PC. Именно этот путь используется для +// передачи текста (макросы, CAT KY) — так же устроен Thetis (cwx.cs). +// +// Отсюда устройство юнита: отдельный поток, который спит до дедлайнов и +// дёргает колбэк ключа. Дедлайны АБСОЛЮТНЫЕ (GetTickCount64), иначе ошибка +// каждого сна накапливалась бы и знак «плыл» бы по длине. +// --------------------------------------------------------------------------- + +interface + +uses + Classes, SysUtils, SyncObjs; + +type + // Вызывается из потока передачи на каждом фронте ключа. + TCWKeyEvent = procedure(Down: Boolean) of object; + + TCWSender = class(TThread) + private + FKeyEvent: TCWKeyEvent; + FLock: TCriticalSection; + FWake: TEvent; + FPending: string; // очередь текста (под FLock) + FAbortReq: Boolean; // «бросить и отпустить ключ» (под FLock) + FBusy: Boolean; // идёт передача (под FLock) + FWPM: Integer; // под FLock + FWeight: Integer; // 33..66, 50 = симметрично (под FLock) + // Эхо для окна-терминала: ушедшее в эфир и хвост очереди (под FLock). + FSent: string; + FUnsent: string; + FTrimTail: Boolean; // Backspace дотянулся до уже взятого текста + function TakeText: string; + function Aborted: Boolean; + procedure Key(Down: Boolean); + // Выдержать Ms миллисекунд, просыпаясь дробно ради отзывчивости на abort. + // Возвращает False, если передачу оборвали. + function Hold(Deadline: QWord): Boolean; + procedure SendChar(Ch: Char; var Deadline: QWord); + protected + procedure Execute; override; + public + constructor Create(AKeyEvent: TCWKeyEvent); + destructor Destroy; override; + procedure Enqueue(const S: string); + procedure AbortSending; + procedure SetSpeed(WPM, Weight: Integer); + function Busy: Boolean; + // Терминал: знаки, ушедшие в эфир с прошлого опроса; хвост очереди; стереть + // последний ненабранный знак. Смысл тот же, что у TCWLocalKeyer. + function TakeSentText: string; + function PendingText: string; + function Backspace: Boolean; + end; + +// Код знака: строка из '.' и '-'. Пустая — знака нет в таблице (пропускаем, +// иначе опечатка в макросе превратилась бы в мусор в эфире). +function MorseCode(Ch: Char): string; + +implementation + +const + // Точка при 1 WPM = 1200 мс (стандарт PARIS). + DIT_MS_AT_1WPM = 1200; + +function MorseCode(Ch: Char): string; +begin + case UpCase(Ch) of + 'A': Result := '.-'; 'B': Result := '-...'; 'C': Result := '-.-.'; + 'D': Result := '-..'; 'E': Result := '.'; 'F': Result := '..-.'; + 'G': Result := '--.'; 'H': Result := '....'; 'I': Result := '..'; + 'J': Result := '.---'; 'K': Result := '-.-'; 'L': Result := '.-..'; + 'M': Result := '--'; 'N': Result := '-.'; 'O': Result := '---'; + 'P': Result := '.--.'; 'Q': Result := '--.-'; 'R': Result := '.-.'; + 'S': Result := '...'; 'T': Result := '-'; 'U': Result := '..-'; + 'V': Result := '...-'; 'W': Result := '.--'; 'X': Result := '-..-'; + 'Y': Result := '-.--'; 'Z': Result := '--..'; + '0': Result := '-----'; '1': Result := '.----'; '2': Result := '..---'; + '3': Result := '...--'; '4': Result := '....-'; '5': Result := '.....'; + '6': Result := '-....'; '7': Result := '--...'; '8': Result := '---..'; + '9': Result := '----.'; + '.': Result := '.-.-.-'; ',': Result := '--..--'; '?': Result := '..--..'; + '/': Result := '-..-.'; '=': Result := '-...-'; '+': Result := '.-.-.'; + '-': Result := '-....-'; '(': Result := '-.--.'; ')': Result := '-.--.-'; + ':': Result := '---...'; '''':Result := '.----.'; '"': Result := '.-..-.'; + '@': Result := '.--.-.'; '!': Result := '-.-.--'; ';': Result := '-.-.-.'; + '$': Result := '...-..-'; + else + Result := ''; + end; +end; + +constructor TCWSender.Create(AKeyEvent: TCWKeyEvent); +begin + FKeyEvent := AKeyEvent; + FLock := TCriticalSection.Create; + FWake := TEvent.Create(nil, False, False, ''); + FWPM := 20; + FWeight := 50; + FreeOnTerminate := False; + inherited Create(False); +end; + +destructor TCWSender.Destroy; +begin + Terminate; + FWake.SetEvent; + WaitFor; + FWake.Free; + FLock.Free; + inherited Destroy; +end; + +procedure TCWSender.Enqueue(const S: string); +begin + if S = '' then Exit; + FLock.Enter; + try + FAbortReq := False; + FPending := FPending + S; + finally + FLock.Leave; + end; + FWake.SetEvent; +end; + +procedure TCWSender.AbortSending; +begin + FLock.Enter; + try + FPending := ''; + FAbortReq := True; + finally + FLock.Leave; + end; + FWake.SetEvent; +end; + +procedure TCWSender.SetSpeed(WPM, Weight: Integer); +begin + if WPM < 5 then WPM := 5; + if WPM > 60 then WPM := 60; + if Weight < 33 then Weight := 33; + if Weight > 66 then Weight := 66; + FLock.Enter; + try + FWPM := WPM; + FWeight := Weight; + finally + FLock.Leave; + end; +end; + +function TCWSender.Busy: Boolean; +begin + FLock.Enter; + try + Result := FBusy or (FPending <> ''); + finally + FLock.Leave; + end; +end; + +function TCWSender.TakeSentText: string; +begin + FLock.Enter; + try + Result := FSent; + FSent := ''; + finally + FLock.Leave; + end; +end; + +function TCWSender.PendingText: string; +begin + FLock.Enter; + try + Result := FUnsent + FPending; + finally + FLock.Leave; + end; +end; + +function TCWSender.Backspace: Boolean; +// Стираем с хвоста очереди: то, что уже звучит, вернуть нельзя. +begin + FLock.Enter; + try + Result := False; + if FPending <> '' then + begin + SetLength(FPending, Length(FPending) - 1); + Result := True; + end + else if FUnsent <> '' then + begin + SetLength(FUnsent, Length(FUnsent) - 1); + FTrimTail := True; + Result := True; + end; + finally + FLock.Leave; + end; +end; + +function TCWSender.TakeText: string; +begin + FLock.Enter; + try + Result := FPending; + FPending := ''; + finally + FLock.Leave; + end; +end; + +function TCWSender.Aborted: Boolean; +begin + FLock.Enter; + try + Result := FAbortReq; + finally + FLock.Leave; + end; +end; + +procedure TCWSender.Key(Down: Boolean); +begin + if Assigned(FKeyEvent) then FKeyEvent(Down); +end; + +function TCWSender.Hold(Deadline: QWord): Boolean; +var Now_: QWord; Slice: Integer; +begin + Result := True; + repeat + if Terminated or Aborted then Exit(False); + Now_ := GetTickCount64; + if Now_ >= Deadline then Exit(True); + // Дробим сон: обрыв передачи не должен ждать конца длинного тире. + Slice := Integer(Deadline - Now_); + if Slice > 5 then Slice := 5; + Sleep(Slice); + until False; +end; + +procedure TCWSender.SendChar(Ch: Char; var Deadline: QWord); +// Deadline — момент, к которому знак должен закончиться; ведём его вперёд, +// чтобы длительности не «уползали» на ошибке каждого Sleep. +var + Code: string; + i, Dit, Mark, Gap, WPM, Weight: Integer; +begin + FLock.Enter; + try + WPM := FWPM; + Weight := FWeight; + finally + FLock.Leave; + end; + Dit := DIT_MS_AT_1WPM div WPM; + // Вес: 50 = точка равна паузе. Выше — посылки длиннее за счёт пауз внутри + // знака (межзнаковые интервалы остаются стандартными, иначе «плывёт» темп). + Mark := (Dit * Weight) div 50; + Gap := (Dit * (100 - Weight)) div 50; + if Mark < 1 then Mark := 1; + if Gap < 1 then Gap := 1; + + if Ch = ' ' then + begin + // Межсловный интервал = 7 точек; 3 из них уже отданы после прошлого знака. + Inc(Deadline, 4 * Dit); + Hold(Deadline); + Exit; + end; + + Code := MorseCode(Ch); + if Code = '' then Exit; + for i := 1 to Length(Code) do + begin + Key(True); + if Code[i] = '-' then Inc(Deadline, 3 * Mark) else Inc(Deadline, Mark); + if not Hold(Deadline) then begin Key(False); Exit; end; + Key(False); + Inc(Deadline, Gap); // пауза между элементами знака + if not Hold(Deadline) then Exit; + end; + Inc(Deadline, 2 * Dit); // добор до 3 точек между знаками + Hold(Deadline); +end; + +procedure TCWSender.Execute; +var + Text: string; + i, KeepLen: Integer; + Trim: Boolean; + Deadline: QWord; +begin + while not Terminated do + begin + FWake.WaitFor(200); + if Terminated then Break; + Text := TakeText; + if Text = '' then Continue; + + FLock.Enter; + try + FBusy := True; + FAbortReq := False; + finally + FLock.Leave; + end; + try + Deadline := GetTickCount64; + i := 1; + while i <= Length(Text) do + begin + if Terminated or Aborted then Break; + // Backspace мог съесть хвост уже взятого текста — отсекаем. + FLock.Enter; + try + Trim := FTrimTail; + FTrimTail := False; + KeepLen := i - 1 + Length(FUnsent); + finally + FLock.Leave; + end; + if Trim then + begin + if KeepLen < i then Break; // стёрли всё впереди + if Length(Text) > KeepLen then SetLength(Text, KeepLen); + if i > Length(Text) then Break; + end; + // Эхо в окно-терминал: знак пошёл в эфир; хвост очереди — для строки + // набора (там должно остаться только НЕ переданное). + FLock.Enter; + try + FSent := FSent + Text[i]; + FUnsent := Copy(Text, i + 1, MaxInt); + if Length(FSent) > 4096 then FSent := Copy(FSent, Length(FSent) - 4095, 4096); + finally + FLock.Leave; + end; + SendChar(Text[i], Deadline); + // Дописанное в очередь по ходу передачи подхватываем без паузы — + // иначе поток допередал бы «старый» текст и уснул на 200 мс. + if i = Length(Text) then Text := Text + TakeText; + Inc(i); + end; + finally + Key(False); // ключ всегда отпущен, что бы ни случилось + FLock.Enter; + try + FUnsent := ''; + FTrimTail := False; + finally + FLock.Leave; + end; + FLock.Enter; + try + FBusy := False; + finally + FLock.Leave; + end; + end; + end; + Key(False); +end; + +end. diff --git a/CWTerminalForm.pas b/CWTerminalForm.pas new file mode 100644 index 0000000..095a57c --- /dev/null +++ b/CWTerminalForm.pas @@ -0,0 +1,678 @@ +unit CWTerminalForm; + +{ TCWTerminalForm — телеграфный терминал: лента связи + набор с клавиатуры. + + Почему одно окно, а не два. В работе читаешь и отвечаешь одновременно, поэтому + принятое декодером и своя передача идут ОДНОЙ лентой (своё — другим цветом), + а строка набора висит под ней. Так устроены fldigi и CW-окна контест-логгеров, + и по делу это единственная удобная раскладка. + + Набор идёт В ЭФИР ПО МЕРЕ ПЕЧАТИ, а не по Enter: строка набора показывает + ровно то, что ещё НЕ передано (очередь генератора), и знаки уходят из неё по + мере отправки. Backspace стирает с хвоста очереди — то, что уже звучит, + вернуть нельзя. + + Ленту рисуем сами (TPaintBox), а не TMemo: нужен цвет по фрагментам (приём / + своя передача) и постоянная дописка снизу без мигания. } + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, Math, + Forms, Controls, Graphics, StdCtrls, ExtCtrls, LCLType, + FlatButton, FlatEdit, FlatSpinEdit, AppTheme, Settings, DpiUtils; + +type + TCWTermSend = procedure(const Text: string) of object; + TCWTermFlag = procedure(Value: Boolean) of object; + TCWTermKey = procedure(Dot, Dash: Boolean) of object; + TCWTermSpeed = procedure(WPM: Integer) of object; + + // Фрагмент ленты: текст одного происхождения подряд. + TCWRun = record + Text: string; + IsTX: Boolean; + end; + + TCWTerminalForm = class(TForm) + private + FTheme: TAppTheme; + FRuns: array of TCWRun; + FLines: TStringList; // разложенная по ширине лента (текст) + FLineTX: TStringList; // '1'/'0' на каждый знак строки — цвет + FLog: TPaintBox; + FInput: TFlatEdit; + FBtnStop: TFlatButton; + FBtnClear: TFlatButton; + FBtnDec: TFlatButton; + FBtnKbd: TFlatButton; + FBtnClose: TFlatButton; + FEdWPM: TFlatSpinEdit; + FLblStatus: TLabel; + FMacroBtns: array[0..CW_MSG_COUNT-1] of TFlatButton; + FCW: TCWSettings; + FUpdating: Boolean; + FDecOn: Boolean; + FKbdOn: Boolean; + FKbdDot: Boolean; + FKbdDash: Boolean; + FCharW: Integer; + FLineH: Integer; + FOnSend: TCWTermSend; + FOnAbort: TNotifyEvent; + FOnBackspace:TNotifyEvent; + FOnDecoder: TCWTermFlag; + FOnKbdKey: TCWTermKey; + FOnSpeed: TCWTermSpeed; + FOnMacro: TNotifyEvent; // Sender.Tag = номер ячейки + procedure BuildUI; + procedure LogPaint(Sender: TObject); + procedure LogResize(Sender: TObject); + procedure RewrapAll; + procedure AppendToWrap(const S: string; IsTX: Boolean); + procedure StyleBtn(B: TFlatButton); + procedure StopClick(Sender: TObject); + procedure ClearClick(Sender: TObject); + procedure DecClick(Sender: TObject); + procedure KbdClick(Sender: TObject); + procedure CloseClick(Sender: TObject); + procedure MacroClick(Sender: TObject); + procedure WPMChange(Sender: TObject); + procedure FormUTF8Key(Sender: TObject; var UTF8Key: TUTF8Char); + procedure FormKeyDownEx(Sender: TObject; var Key: Word; Shift: TShiftState); + procedure FormKeyUpEx(Sender: TObject; var Key: Word; Shift: TShiftState); + procedure PushKbdKey; + procedure FocusInput; + procedure FormLostFocus(Sender: TObject); + public + constructor Create(AOwner: TComponent); override; + destructor Destroy; override; + procedure ApplyTheme(const T: TAppTheme); + procedure LoadFrom(const C: TCWSettings); + procedure DoShow; override; + // Прибавка ленты (из ServiceCWDecoder хозяина). + procedure AppendLog(const S: string; IsTX: Boolean); + // Строка набора = очередь передачи; статус — скорость/расстройка/сигнал. + procedure SetPending(const S: string); + procedure SetStatus(const S: string); + property OnSend: TCWTermSend read FOnSend write FOnSend; + property OnAbort: TNotifyEvent read FOnAbort write FOnAbort; + property OnBackspace: TNotifyEvent read FOnBackspace write FOnBackspace; + property OnDecoder: TCWTermFlag read FOnDecoder write FOnDecoder; + property OnKbdKey: TCWTermKey read FOnKbdKey write FOnKbdKey; + property OnSpeed: TCWTermSpeed read FOnSpeed write FOnSpeed; + property OnMacro: TNotifyEvent read FOnMacro write FOnMacro; + end; + +implementation + +const +{$IFDEF WINDOWS} + UI_FONT = 'Segoe UI'; + MONO_FONT = 'Consolas'; +{$ELSE} + UI_FONT = 'Sans'; + MONO_FONT = 'Monospace'; +{$ENDIF} + FORM_W = 700; + FORM_H = 460; + PAD = 10; + ROW_H = 26; + MAX_LINES = 500; + +constructor TCWTerminalForm.Create(AOwner: TComponent); +begin + inherited CreateNew(AOwner); + FTheme := DarkTheme; + FLines := TStringList.Create; + FLineTX := TStringList.Create; + TSettingsManager.DefaultCW(FCW); + Scaled := False; + Caption := 'CW Terminal'; + BorderStyle := bsSizeable; + if AOwner is TCustomForm then + begin + Position := poOwnerFormCenter; + // ★Держим окно ПОВЕРХ главного, пока его не закрыли: набор идёт вслепую по + // ленте, и если терминал ныряет под главное окно от любого клика по спектру, + // работать в нём невозможно. Через transient-родителя, а не fsStayOnTop: + // так окно поднимается над своим приложением, но не лезет поверх чужих + // (и это единственный способ, который корректно работает на Wayland). + PopupMode := pmExplicit; + PopupParent := TCustomForm(AOwner); + end + else + Position := poScreenCenter; + Width := DpiScale(FORM_W); + Height := DpiScale(FORM_H); + Constraints.MinWidth := DpiScale(520); + Constraints.MinHeight := DpiScale(300); + KeyPreview := True; + OnUTF8KeyPress := @FormUTF8Key; + OnKeyDown := @FormKeyDownEx; + OnKeyUp := @FormKeyUpEx; + // ★Отпускание клавиши приходит только сфокусированному окну. Ушёл фокус с + // зажатым Ctrl (alt-tab, клик по главному окну) — и несущая осталась бы в + // эфире навсегда. Снимаем ключ на любой потере фокуса и при скрытии окна. + OnDeactivate := @FormLostFocus; + OnHide := @FormLostFocus; + BuildUI; + ApplyTheme(DarkTheme); +end; + +destructor TCWTerminalForm.Destroy; +begin + FLines.Free; + FLineTX.Free; + inherited Destroy; +end; + +procedure TCWTerminalForm.BuildUI; +var + i, X: Integer; +begin + // ---- лента --------------------------------------------------------------- + FLog := TPaintBox.Create(Self); + FLog.Parent := Self; + FLog.Align := alClient; + FLog.BorderSpacing.Around := DpiScale(PAD); + FLog.BorderSpacing.Bottom := DpiScale(PAD + 3 * ROW_H + 3 * 6); + FLog.OnPaint := @LogPaint; + FLog.OnResize := @LogResize; + + // ---- строка набора ------------------------------------------------------- + FInput := TFlatEdit.Create(Self); + FInput.Parent := Self; + FInput.Anchors := [akLeft, akRight, akBottom]; + FInput.Font.Name := MONO_FONT; + FInput.Font.Size := 10; + // ★Обработчики ввода НЕ вешаем на поле: TFlatEdit вставляет знак сам в + // UTF8KeyPress и гасит клавишу, поэтому его OnKeyPress не вызывается вовсе — + // именно из-за этого набранное «исчезало», а в эфир не уходило. Весь ввод + // ловим на форме (KeyPreview), а поле остаётся показывать очередь передачи. + + // ---- нижние ряды --------------------------------------------------------- + FBtnStop := TFlatButton.Create(Self); + FBtnStop.Parent := Self; + FBtnStop.Caption := 'Stop'; + FBtnStop.OnClick := @StopClick; + FBtnStop.Anchors := [akLeft, akBottom]; + + FBtnClear := TFlatButton.Create(Self); + FBtnClear.Parent := Self; + FBtnClear.Caption := 'Clear'; + FBtnClear.OnClick := @ClearClick; + FBtnClear.Anchors := [akLeft, akBottom]; + + FBtnDec := TFlatButton.Create(Self); + FBtnDec.Parent := Self; + FBtnDec.Caption := 'Decoder'; + FBtnDec.OnClick := @DecClick; + FBtnDec.Anchors := [akLeft, akBottom]; + + FBtnKbd := TFlatButton.Create(Self); + FBtnKbd.Parent := Self; + FBtnKbd.Caption := 'Ctrl = key'; + FBtnKbd.OnClick := @KbdClick; + FBtnKbd.Anchors := [akLeft, akBottom]; + + FEdWPM := TFlatSpinEdit.Create(Self); + FEdWPM.Parent := Self; + FEdWPM.MinValue := 5; + FEdWPM.MaxValue := 60; + FEdWPM.Value := 20; + FEdWPM.OnChange := @WPMChange; + FEdWPM.Anchors := [akLeft, akBottom]; + + FLblStatus := TLabel.Create(Self); + FLblStatus.Parent := Self; + FLblStatus.Font.Name := UI_FONT; + FLblStatus.Font.Size := 8; + FLblStatus.Anchors := [akLeft, akRight, akBottom]; + FLblStatus.AutoSize := False; + + FBtnClose := TFlatButton.Create(Self); + FBtnClose.Parent := Self; + FBtnClose.Caption := 'Close'; + FBtnClose.OnClick := @CloseClick; + FBtnClose.Anchors := [akRight, akBottom]; + + X := PAD; + for i := 0 to CW_MSG_COUNT - 1 do + begin + FMacroBtns[i] := TFlatButton.Create(Self); + FMacroBtns[i].Parent := Self; + FMacroBtns[i].Caption := 'F' + IntToStr(i + 1); + FMacroBtns[i].Tag := i; + FMacroBtns[i].OnClick := @MacroClick; + FMacroBtns[i].Anchors := [akLeft, akBottom]; + Inc(X, 46); + end; + LogResize(nil); +end; + +procedure TCWTerminalForm.LogResize(Sender: TObject); +// Раскладка нижних рядов руками: якорей хватает по горизонтали, но ряды идут +// снизу вверх, и считать их проще одной формулой, чем городить панели. +var + i, Y, X, W: Integer; +begin + if FInput = nil then Exit; + W := ClientWidth; + Y := ClientHeight - DpiScale(PAD) - DpiScale(ROW_H); + // ряд 3 (низ): макросы + Close + X := DpiScale(PAD); + for i := 0 to CW_MSG_COUNT - 1 do + begin + FMacroBtns[i].SetBounds(X, Y, DpiScale(42), DpiScale(ROW_H)); + Inc(X, DpiScale(46)); + end; + FBtnClose.SetBounds(W - DpiScale(PAD + 70), Y, DpiScale(70), DpiScale(ROW_H)); + // ряд 2: кнопки + скорость + статус + Dec(Y, DpiScale(ROW_H + 6)); + X := DpiScale(PAD); + FBtnStop.SetBounds(X, Y, DpiScale(60), DpiScale(ROW_H)); Inc(X, DpiScale(64)); + FBtnClear.SetBounds(X, Y, DpiScale(60), DpiScale(ROW_H)); Inc(X, DpiScale(64)); + FBtnDec.SetBounds(X, Y, DpiScale(80), DpiScale(ROW_H)); Inc(X, DpiScale(84)); + FBtnKbd.SetBounds(X, Y, DpiScale(90), DpiScale(ROW_H)); Inc(X, DpiScale(94)); + FEdWPM.SetBounds(X, Y, DpiScale(70), DpiScale(ROW_H)); Inc(X, DpiScale(78)); + FLblStatus.SetBounds(X, Y + DpiScale(6), W - X - DpiScale(PAD), DpiScale(18)); + // ряд 1: строка набора + Dec(Y, DpiScale(ROW_H + 6)); + FInput.SetBounds(DpiScale(PAD), Y, W - DpiScale(2 * PAD), DpiScale(ROW_H)); + RewrapAll; + if FLog <> nil then FLog.Invalidate; +end; + +{ ── лента ─────────────────────────────────────────────────────────────────── } + +procedure TCWTerminalForm.AppendToWrap(const S: string; IsTX: Boolean); +// Дописываем знаки в последнюю строку, перенося по ширине окна. Цвет храним +// параллельной строкой флагов — так рисование не зависит от разбиения на +// фрагменты и переносы получаются посимвольно точными. +var + i, Cols: Integer; + Flag: Char; +begin + if S = '' then Exit; + if FCharW <= 0 then FCharW := 8; + Cols := Max(8, (FLog.Width - DpiScale(8)) div FCharW); + if FLines.Count = 0 then + begin + FLines.Add(''); + FLineTX.Add(''); + end; + if IsTX then Flag := '1' else Flag := '0'; + for i := 1 to Length(S) do + begin + if S[i] = #10 then + begin + FLines.Add(''); FLineTX.Add(''); + Continue; + end; + if Length(FLines[FLines.Count - 1]) >= Cols then + begin + FLines.Add(''); FLineTX.Add(''); + end; + FLines[FLines.Count - 1] := FLines[FLines.Count - 1] + S[i]; + FLineTX[FLineTX.Count - 1] := FLineTX[FLineTX.Count - 1] + Flag; + end; + while FLines.Count > MAX_LINES do + begin + FLines.Delete(0); + FLineTX.Delete(0); + end; +end; + +procedure TCWTerminalForm.RewrapAll; +var + i: Integer; +begin + if FLog = nil then Exit; + FLines.Clear; + FLineTX.Clear; + for i := 0 to High(FRuns) do + AppendToWrap(FRuns[i].Text, FRuns[i].IsTX); +end; + +procedure TCWTerminalForm.AppendLog(const S: string; IsTX: Boolean); +var + n: Integer; +begin + if S = '' then Exit; + n := Length(FRuns); + // Соседние фрагменты одного происхождения склеиваем — иначе массив распухнет + // на каждый принятый знак. + if (n > 0) and (FRuns[n-1].IsTX = IsTX) and (Length(FRuns[n-1].Text) < 4096) then + FRuns[n-1].Text := FRuns[n-1].Text + S + else + begin + SetLength(FRuns, n + 1); + FRuns[n].Text := S; + FRuns[n].IsTX := IsTX; + end; + // Держим историю ограниченной (окно не журнал связи). + if Length(FRuns) > 400 then + begin + Move(FRuns[100], FRuns[0], (Length(FRuns) - 100) * SizeOf(TCWRun)); + SetLength(FRuns, Length(FRuns) - 100); + RewrapAll; + end + else + AppendToWrap(S, IsTX); + FLog.Invalidate; +end; + +procedure TCWTerminalForm.LogPaint(Sender: TObject); +var + C: TCanvas; + i, j, Y, X, First, Rows: Integer; + L, F: string; +begin + C := FLog.Canvas; + C.Brush.Color := FTheme.Panel; + C.FillRect(0, 0, FLog.Width, FLog.Height); + C.Pen.Color := FTheme.Border; + C.Brush.Style := bsClear; + C.Rectangle(0, 0, FLog.Width, FLog.Height); + C.Font.Name := MONO_FONT; + C.Font.Size := 10; + if FLineH <= 0 then + begin + FLineH := C.TextHeight('Wg') + 2; + FCharW := C.TextWidth('W'); + if FCharW <= 0 then FCharW := 8; + end; + Rows := Max(1, (FLog.Height - DpiScale(8)) div FLineH); + First := Max(0, FLines.Count - Rows); // всегда показываем хвост ленты + Y := DpiScale(4); + for i := First to FLines.Count - 1 do + begin + L := FLines[i]; + F := FLineTX[i]; + X := DpiScale(4); + for j := 1 to Length(L) do + begin + // Своя передача — акцентом, приём — обычным текстом. Разделять их + // строками нельзя: в QSK они перемежаются посреди фразы. + if (j <= Length(F)) and (F[j] = '1') then C.Font.Color := FTheme.BtnTextActive + else C.Font.Color := FTheme.Text; + C.TextOut(X, Y, L[j]); + Inc(X, FCharW); + end; + Inc(Y, FLineH); + end; +end; + +{ ── управление ────────────────────────────────────────────────────────────── } + +procedure TCWTerminalForm.SetPending(const S: string); +begin + if FInput = nil then Exit; + if FInput.Text = S then Exit; + FUpdating := True; + try + FInput.Text := S; + FInput.CaretPos := MaxInt; // курсор всегда в конце очереди набора + finally + FUpdating := False; + end; +end; + +procedure TCWTerminalForm.SetStatus(const S: string); +begin + if FLblStatus <> nil then FLblStatus.Caption := S; +end; + +procedure TCWTerminalForm.LoadFrom(const C: TCWSettings); +var i: Integer; +begin + FCW := C; + FUpdating := True; + try + if FEdWPM <> nil then FEdWPM.Value := EnsureRange(C.Speed, 5, 60); + for i := 0 to CW_MSG_COUNT - 1 do + if FMacroBtns[i] <> nil then + begin + FMacroBtns[i].Hint := C.Messages[i]; + FMacroBtns[i].ShowHint := C.Messages[i] <> ''; + end; + FDecOn := C.Decoder; + FKbdOn := C.KbdKey; + finally + FUpdating := False; + end; + ApplyTheme(FTheme); // состояние кнопок-переключателей рисуется цветом +end; + +procedure TCWTerminalForm.DoShow; +begin + inherited DoShow; + // Набор — главное занятие в этом окне, курсор должен быть уже там. + if FInput <> nil then FInput.SetFocus; +end; + +procedure TCWTerminalForm.FormUTF8Key(Sender: TObject; var UTF8Key: TUTF8Char); +// ★Печать уходит в эфир СРАЗУ, посимвольно. Строка набора нами не редактируется: +// её содержимое — очередь генератора, и она перезаписывается через SetPending по +// мере передачи. Знак гасим, чтобы поле не вставило его ещё и от себя. +var Key: Char; +begin + if UTF8Key = '' then Exit; + if ActiveControl <> FInput then Exit; // печатаем только в строке набора + Key := UTF8Key[1]; + UTF8Key := ''; + if Key < #32 then Exit; + if Assigned(FOnSend) then FOnSend(UpCase(Key)); + // Показываем знак в очереди сразу, не дожидаясь таймера: иначе набор + // ощущается «залипающим». Через 100 мс SetPending всё равно перепишет строку + // настоящей очередью генератора. + FUpdating := True; + try + FInput.Text := FInput.Text + UpCase(Key); + FInput.CaretPos := MaxInt; + finally + FUpdating := False; + end; +end; + +procedure TCWTerminalForm.FocusInput; +// ★Клик по любой кнопке уводит фокус на неё, и печать после этого молча +// перестаёт уходить в эфир (ввод ловится, только пока фокус в строке набора). +// Поэтому после каждого действия возвращаем курсор туда. +begin + if (FInput <> nil) and FInput.CanFocus then FInput.SetFocus; +end; + +procedure TCWTerminalForm.PushKbdKey; +begin + if Assigned(FOnKbdKey) then FOnKbdKey(FKbdDot, FKbdDash); +end; + +procedure TCWTerminalForm.FormLostFocus(Sender: TObject); +begin + if FKbdDot or FKbdDash then + begin + FKbdDot := False; + FKbdDash := False; + PushKbdKey; + end; +end; + +procedure TCWTerminalForm.FormKeyDownEx(Sender: TObject; var Key: Word; + Shift: TShiftState); +begin + // F1..F8 — те же ячейки памяти, что в главном окне. + if (Key >= VK_F1) and (Key <= VK_F8) then + begin + if Assigned(FOnMacro) then + begin + FBtnStop.Tag := Key - VK_F1; // переиспользуем Tag как переносчик номера + MacroClick(FBtnStop); + end; + Key := 0; + Exit; + end; + // Правка очереди передачи — только пока фокус в строке набора. Поле не + // редактируем сами: его содержимое задаёт генератор (SetPending), поэтому + // клавиши правки гасим здесь, до контрола. + if ActiveControl = FInput then + case Key of + VK_BACK: + begin + if Assigned(FOnBackspace) then FOnBackspace(Self); + if FInput.Text <> '' then + begin + FUpdating := True; + try + FInput.Text := Copy(FInput.Text, 1, Length(FInput.Text) - 1); + FInput.CaretPos := MaxInt; + finally + FUpdating := False; + end; + end; + Key := 0; + Exit; + end; + VK_ESCAPE: + begin + if Assigned(FOnAbort) then FOnAbort(Self); + Key := 0; + Exit; + end; + // Стрелки/Delete/Enter внутри очереди смысла не имеют: эфир ушёл вперёд. + VK_RETURN, VK_LEFT, VK_RIGHT, VK_UP, VK_DOWN, VK_DELETE, VK_HOME, VK_END: + begin + Key := 0; + Exit; + end; + end; + + if not FKbdOn then Exit; + // Ctrl = манипулятор. Левый — точка, правый — тире: при иамбике это полноценные + // лепестки, при прямом ключе годится любой. + if Key = VK_LCONTROL then begin FKbdDot := True; PushKbdKey; Key := 0; end + else if Key = VK_RCONTROL then begin FKbdDash := True; PushKbdKey; Key := 0; end + else if Key = VK_CONTROL then begin FKbdDot := True; PushKbdKey; Key := 0; end; +end; + +procedure TCWTerminalForm.FormKeyUpEx(Sender: TObject; var Key: Word; + Shift: TShiftState); +begin + if not FKbdOn then Exit; + if Key = VK_LCONTROL then begin FKbdDot := False; PushKbdKey; Key := 0; end + else if Key = VK_RCONTROL then begin FKbdDash := False; PushKbdKey; Key := 0; end + else if Key = VK_CONTROL then + begin + FKbdDot := False; FKbdDash := False; PushKbdKey; Key := 0; + end; +end; + +procedure TCWTerminalForm.MacroClick(Sender: TObject); +begin + if Assigned(FOnMacro) then FOnMacro(Sender); + FocusInput; +end; + +procedure TCWTerminalForm.StopClick(Sender: TObject); +begin + if Assigned(FOnAbort) then FOnAbort(Self); + FocusInput; +end; + +procedure TCWTerminalForm.ClearClick(Sender: TObject); +begin + SetLength(FRuns, 0); + FLines.Clear; + FLineTX.Clear; + FLog.Invalidate; + FocusInput; +end; + +procedure TCWTerminalForm.DecClick(Sender: TObject); +begin + FDecOn := not FDecOn; + if Assigned(FOnDecoder) then FOnDecoder(FDecOn); + ApplyTheme(FTheme); + FocusInput; +end; + +procedure TCWTerminalForm.KbdClick(Sender: TObject); +begin + FKbdOn := not FKbdOn; + if not FKbdOn then + begin + FKbdDot := False; FKbdDash := False; + PushKbdKey; // отпустить, если выключили с зажатой + end; + FCW.KbdKey := FKbdOn; + ApplyTheme(FTheme); + FocusInput; +end; + +procedure TCWTerminalForm.CloseClick(Sender: TObject); +begin + Hide; +end; + +procedure TCWTerminalForm.WPMChange(Sender: TObject); +begin + if FUpdating then Exit; + if Assigned(FOnSpeed) then FOnSpeed(FEdWPM.Value); +end; + +procedure TCWTerminalForm.StyleBtn(B: TFlatButton); +begin + if B = nil then Exit; + B.ClrNorm := FTheme.BtnNorm; + B.ClrBorder := FTheme.BtnBorderNorm; + B.ClrHot := FTheme.BtnHot; + B.ClrActive := FTheme.BtnActive; + B.ClrText := FTheme.BtnText; + B.ClrTextAct := FTheme.BtnTextActive; + B.Font.Name := UI_FONT; + B.Font.Size := 9; + B.Invalidate; +end; + +procedure TCWTerminalForm.ApplyTheme(const T: TAppTheme); +var + i: Integer; +begin + FTheme := T; + Color := T.BG; + StyleBtn(FBtnStop); + StyleBtn(FBtnClear); + StyleBtn(FBtnDec); + StyleBtn(FBtnKbd); + StyleBtn(FBtnClose); + for i := 0 to CW_MSG_COUNT - 1 do StyleBtn(FMacroBtns[i]); + // Переключатели: включённое состояние — акцентной рамкой и текстом. + if FBtnDec <> nil then + begin + if FDecOn then + begin + FBtnDec.ClrBorder := T.BtnTextActive; + FBtnDec.ClrText := T.BtnTextActive; + end; + FBtnDec.Invalidate; + end; + if FBtnKbd <> nil then + begin + if FKbdOn then + begin + FBtnKbd.ClrBorder := T.BtnTextActive; + FBtnKbd.ClrText := T.BtnTextActive; + end; + FBtnKbd.Invalidate; + end; + if FInput <> nil then FInput.SetAppTheme(T); + if FEdWPM <> nil then FEdWPM.SetAppTheme(T); + if FLblStatus <> nil then FLblStatus.Font.Color := T.TextDim; + if FLog <> nil then FLog.Invalidate; + Invalidate; +end; + +end. diff --git a/FlatEdit.pas b/FlatEdit.pas index 12a2856..936ad67 100644 --- a/FlatEdit.pas +++ b/FlatEdit.pas @@ -71,6 +71,10 @@ type procedure SelectAll; property Text: string read FText write SetText; + // Позиция курсора (в знаках, не в байтах). Нужна тем, кто задаёт текст + // программно: SetText курсор НЕ двигает — он лишь клампится в диапазон, и + // при внешней подстановке текста оставался бы в начале строки. + property CaretPos: Integer read FCaretPos write SetCaretPos; property TextHint: string read FTextHint write SetTextHint; property PasswordChar: Char read FPasswordChar write SetPasswordChar; property MaxLength: Integer read FMaxLength write SetMaxLength; diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas index 6a7680a..ec5de6a 100644 --- a/HPSDRNetwork.pas +++ b/HPSDRNetwork.pas @@ -169,6 +169,8 @@ type FAlexEnabled: Boolean; FAlexConfig: TAlexSettings; // per-band antenna + routing config FStepAtten: Byte; // ADC0 step attenuator 0-31 dB + FCWXKey: Boolean; // бит CWX (HP байт 5): программный ключ + FCWKeyerArmed: Boolean; // кейер прошивки вооружён (см. байт 345) // ---- OC Control (Open Collector выходы Penny/Alex, byte 1401) ---- FOCConfig: TOCSettings; FTuning: Boolean; // TUN активен (для TX pin action) @@ -269,6 +271,8 @@ type Transmitting, PAEnabled, AlexEnabled: Boolean; Tuning: Boolean = False; TwoTone: Boolean = False); override; procedure SetStepAtten(dB: Byte); override; + procedure SetCWXKey(Down: Boolean); override; + procedure SetCWKeyerArmed(Armed: Boolean); override; procedure SetFreqCalPPM(P: Double); override; // Частота с поправкой опорника — для частотного слова DDC/DUC. function CalFreq(Hz: Double): Double; @@ -727,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; @@ -1696,7 +1702,13 @@ begin SendDDCSpecific(Pkt); FCachedDDCSpec := Pkt; FCachedDDCValid := True; - FResendCount := 0; + // ★СЧЁТЧИК ПЕРЕ-ОТПРАВКИ ЗДЕСЬ НЕ СБРАСЫВАЕМ. Окно повторов — сугубо + // СТАРТОВЫЙ костыль (порты 1025/1026 открываются только после Run=1), и + // живёт оно от Run=1. Сброс отсюда превращал его в рецидивирующий: любая + // пере-сборка на живой сессии — вход/выход PureSignal в TX (каждый MOX!), + // добавление пана, смена rate — заводила ЕЩЁ 10 пере-латчей DDC Specific в + // течение 5 секунд. А пере-латч конфига DDC на ходу трогает и ГЛАВНЫЙ + // приёмник: ровно этим уже был получен «мусорный» поток на доп. панах. end; procedure THPSDRNetwork.SetPanDDC(DDCIdx: Integer; Enabled: Boolean; @@ -1801,6 +1813,22 @@ begin FFreqCalPPM := P; end; +procedure THPSDRNetwork.SetCWXKey(Down: Boolean); +// Каждый фронт — немедленный HP-пакет: тайминги посылки держит PC, задержка +// доставки = один UDP-пакет (так же делает Thetis, cwx.cs setkey). +begin + if FCWXKey = Down then Exit; + FCWXKey := Down; + if FConnected then SendFullHP; +end; + +procedure THPSDRNetwork.SetCWKeyerArmed(Armed: Boolean); +begin + if FCWKeyerArmed = Armed then Exit; + FCWKeyerArmed := Armed; + if FConnected then SendFullHP; +end; + procedure THPSDRNetwork.SetStepAtten(dB: Byte); begin if dB > 31 then dB := 31; @@ -1840,6 +1868,9 @@ begin if FPTTActive then Buf[4] := Buf[4] or HP_PTT0; end; + // Byte 5 bit0: CWX — программный ключ телеграфа (несущую даёт прошивка). + if FCWXKey then Buf[5] := Buf[5] or $01; + // IsOrion2: платы с Alex BPF и TxRx Status bit18 (ORION_MK2=5, SATURN=10) IsOrion2 := FDevice.BoardType in [5, 10]; // ORION_MK2=5, SATURN=10 DDCBase := DDCBaseIndex; @@ -1885,8 +1916,11 @@ begin Buf[331] := (Ph shr 8) and $FF; Buf[332] := Ph and $FF; - // Drive level (byte 345) - if FIsTransmitting then + // Drive level (byte 345). Обычно уровень нужен только на передаче, но с + // вооружённым кейером телеграфа «передачи» в понимании софта не бывает: + // прошивка замыкает ключ сама, и в кадре, который она в этот момент видит, + // уровень уже обязан стоять — иначе посылка уходит с чужой мощностью. + if FIsTransmitting or FCWKeyerArmed then Buf[345] := FCurrentDrive; // ALEX0 filter bits (bytes 1432-1435) @@ -1957,7 +1991,11 @@ procedure THPSDRNetwork.SetRunAndFreq(Run: Boolean; DDC0FreqHz, DUCFreqHz: Doubl begin // При Run=1 аппаратура сбрасывает счётчик HP-последовательности. // Синхронизируем свой счётчик чтобы не получить SEQ ERROR на первом пакете. - if Run then FSeqHP := 0; + if Run then + begin + FSeqHP := 0; + FResendCount := 0; // окно повторов Specific-пакетов — только со старта + end; FRunning := Run; FCurrentRXFreq := DDC0FreqHz; FCurrentTXFreq := DUCFreqHz; 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 6f78e61..8bd1538 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -43,7 +43,7 @@ uses WinFirewall, BoardUtils, WisdomBuilder, UISync, FlatEdit, FMRepeater, - ChannelStore, ChannelsForm, BeaconScopeForm, DMRDecoder, + ChannelStore, ChannelsForm, CWMessagesForm, CWTerminalForm, BeaconScopeForm, DMRDecoder, PowerInhibit, DeviceStore, RadioBackend, PlutoBackend, @@ -367,6 +367,8 @@ type BtnTXProfile: TFlatButton; FTXProfileDropDown: TFlatDropDown; FChannelsForm: TObject; // TChannelsForm (cast при использовании) + FCWMsgForm: TObject; // TCWMessagesForm (память телеграфа) + FCWTermForm: TObject; // TCWTerminalForm (лента + набор с клавиатуры) FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON) // ---- Right panel ---- @@ -472,6 +474,8 @@ type procedure FreqDispBChanged(Sender: TObject; NewFreq: Int64); procedure BtnBandClick(Sender: TObject); procedure BtnModeClick(Sender: TObject); + procedure BtnModeMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); procedure BtnFilterClick(Sender: TObject); procedure BtnFilterMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); @@ -763,6 +767,19 @@ type procedure OnAnt936xChange(const A: TAD936xAnt); // разъёмы AD936x (Antenna) procedure OnVHFCalSettingsChange(const VHFCal: array of Double); procedure OnTXSettingsChange(const T: TTXSettings); + procedure OnCWSettingsChange(const C: TCWSettings); + procedure ShowCWMessages; + procedure ShowCWTerminal; + procedure ServiceCWTerminal; // таймер: разбор + обновление окна + procedure OnCWLogAppend(const S: string; IsTX: Boolean); + procedure OnCWTermBackspace(Sender: TObject); + procedure OnCWTermDecoder(Value: Boolean); + procedure OnCWTermKbdKey(Dot, Dash: Boolean); + procedure OnCWTermSpeed(WPM: Integer); + procedure OnCWTermMacro(Sender: TObject); + procedure OnCWMessageSend(const Text: string); + procedure OnCWMessageAbort(Sender: TObject); + procedure FormKeyDownCW(Sender: TObject; var Key: Word; Shift: TShiftState); procedure BtnAGCModeClick(Sender: TObject); procedure TrkAGCChange(Sender: TObject); procedure TrkRxGainChange(Sender: TObject); @@ -1065,7 +1082,12 @@ begin FController.FTXSpecGridStep := 10.0; FSettingsForm := nil; FChannelsForm := nil; + FCWMsgForm := nil; FBeaconScopeForm := nil; + // F1..F8 для памяти телеграфа. Обработчик молчит, пока окно памяти не + // открывали и пока режим не CW, поэтому прочим клавишам ничего не мешает. + KeyPreview := True; + OnKeyDown := FormKeyDownCW; FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0); // Инициализируем кэш диапазонов умолчаниями @@ -1465,7 +1487,9 @@ begin FSpecView.TXMode := FController.FTransmitting and not FController.FDisplayDuplex; // Красная полоса главного VFO — только когда передаём с него: при // TX со слайса краснеет полоса ТОГО слайса (DrawSliceFilterMarkers). - FSpecView.TXOverlay := FController.FTransmitting and + // ★RadioKeyed, а не FTransmitting: в телеграфе ключ замыкает прошивка, + // софт «не передаёт», и полоса оставалась бы приёмной всю связь. + FSpecView.TXOverlay := FController.RadioKeyed and (FController.FTxSliceId = 0); PushFilterEdgesToView; // на передаче полоса рисуется по TX-фильтру ApplySpecViewGridFromState; // сетка спектра под активный тракт @@ -1798,6 +1822,17 @@ begin end; end; + rfSliceFreq: + begin + // Слайс перестроен по своему CAT-порту в пределах захваченной полосы: + // окно DDC не двигалось (rfPanFreq не придёт), но цифры и полоса + // фильтра во флаге — его собственные поля, их надо перезалить. + PushFlagStateAllPans(FController.FSliceFreqId); + LayoutFlagsAllPans; + for i := 0 to MAX_PANS - 1 do + if FPans[i] <> nil then MarkPanDirty(FPans[i]); + end; + rfPanFreq: begin // Центр доп. пана переехал не из UI (CAT слайса перестроил его за @@ -2410,6 +2445,7 @@ begin LeftPanelButtonWidth(LEFT_W, 4, i mod 4), BTN_SM, BtnModeClick); B.Tag := i; + B.OnMouseDown := BtnModeMouseDown; // ПКМ по CWL/CWU → терминал BtnMode[i] := B; StyleButton(B, i = FController.FMode); end; @@ -3400,6 +3436,8 @@ begin T := CurrentAppTheme; if FDeviceDialog <> nil then FDeviceDialog.SetTheme(T); if FChannelsForm <> nil then TChannelsForm(FChannelsForm).ApplyTheme(T); + if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T); + if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T); if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T); FController.FSettings.SaveTheme(V); FController.FSettings.Save; @@ -3674,6 +3712,11 @@ begin FController.ServiceBeaconLock; // Pluto: опрос температуры/RSSI (троттлинг внутри; no-op для HPSDR). FController.ServicePlutoTelemetry; + // Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет — гнать индикацию + // и отпускать реле T/R после выдержки больше некому). + FController.ServiceCWKeyed; + // Телеграф: разбор принятого в текст + окно-терминал + лента под спектром. + ServiceCWTerminal; // PureSignal: state machine команд + auto-attenuate + опрос GetPSInfo // (no-op на бэкендах без PS; рендер кнопок/поповера через rfPureSignal). FController.PureSignalTick; @@ -3699,7 +3742,9 @@ begin FSpecView.SMeterMin := FSMeterMin; FSpecView.LastFwdW := FController.FLastFwdW; FSpecView.LastSWR := FController.FLastSWR; - FSpecView.Transmitting := FController.FTransmitting; + // Метр — по RadioKeyed, а не по FTransmitting: в телеграфе софт «не передаёт», + // ключ замыкает прошивка, и стрелка осталась бы на S-метре всю связь. + FSpecView.Transmitting := FController.RadioKeyed; if PbSMeterRight <> nil then PbSMeterRight.Invalidate; // PWR/SWR метки и бары обновляем здесь (10 Гц), а не в DoUpdateStatus — @@ -4585,7 +4630,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; @@ -4607,7 +4652,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; @@ -5053,7 +5098,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; @@ -5565,6 +5610,17 @@ begin end; end; +procedure TMainForm.BtnModeMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +// ПКМ по CWL/CWU открывает телеграфный терминал — по той же идиоме, что ПКМ по +// кнопке фильтра открывает редактор фильтров. ЛКМ не трогаем: переключение +// режима не должно тащить за собой окно. +begin + if Button <> mbRight then Exit; + if not ((Sender as TFlatButton).Tag in [MODE_CWL, MODE_CWU]) then Exit; + ShowCWTerminal; +end; + procedure TMainForm.BtnFilterMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); // ПКМ по кнопке фильтра открывает редактор на этом слоте (эталон Thetis: @@ -5831,6 +5887,7 @@ begin TSettingsForm(FSettingsForm).LoadTXSettings(FController.FTXSettings, IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType)); + TSettingsForm(FSettingsForm).LoadCWSettings(FController.CWSettings); PushTXProfilesToSettings; end; // Пилюли профиля на флагах: список общий, привязки могли съехать после @@ -8023,7 +8080,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); @@ -8058,7 +8115,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); @@ -8104,7 +8161,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; @@ -8122,6 +8179,158 @@ begin end; end; +procedure TMainForm.OnCWSettingsChange(const C: TCWSettings); +// Телеграф целиком живёт в контроллере: pitch → движок, кейер/сайдтон → в +// прошивку, персист — там же. Форме остаётся только передать запись. +begin + FController.SetCWSettings(C); + // Скорость кейера = ширина полосы телеграфа на экране. Движок вьюху сам не + // уведомляет — правило то же, что для профилей и кромок TX. + PushFilterEdgesToView; + if FController.FTransmitting then FSpectrumDirty := True; + // Окно памяти показывает те же ячейки — держим его в курсе правок из настроек. + if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).LoadFrom(FController.CWSettings); +end; + +procedure TMainForm.ShowCWMessages; +begin + if FCWMsgForm = nil then + begin + FCWMsgForm := TCWMessagesForm.Create(Self); + TCWMessagesForm(FCWMsgForm).OnSend := OnCWMessageSend; + TCWMessagesForm(FCWMsgForm).OnAbort := OnCWMessageAbort; + TCWMessagesForm(FCWMsgForm).OnChanged := OnCWSettingsChange; + end; + TCWMessagesForm(FCWMsgForm).LoadFrom(FController.CWSettings); + TCWMessagesForm(FCWMsgForm).ApplyTheme(CurrentAppTheme); + TCWMessagesForm(FCWMsgForm).Show; +end; + +procedure TMainForm.ShowCWTerminal; +begin + if FCWTermForm = nil then + begin + FCWTermForm := TCWTerminalForm.Create(Self); + TCWTerminalForm(FCWTermForm).OnSend := OnCWMessageSend; + TCWTerminalForm(FCWTermForm).OnAbort := OnCWMessageAbort; + TCWTerminalForm(FCWTermForm).OnBackspace := OnCWTermBackspace; + TCWTerminalForm(FCWTermForm).OnDecoder := OnCWTermDecoder; + TCWTerminalForm(FCWTermForm).OnKbdKey := OnCWTermKbdKey; + TCWTerminalForm(FCWTermForm).OnSpeed := OnCWTermSpeed; + TCWTerminalForm(FCWTermForm).OnMacro := OnCWTermMacro; + // Лента собирается в контроллере (там же эхо своей передачи) и приходит + // сюда прибавками — окно только рисует. + FController.OnCWLog := OnCWLogAppend; + end; + TCWTerminalForm(FCWTermForm).LoadFrom(FController.CWSettings); + TCWTerminalForm(FCWTermForm).ApplyTheme(CurrentAppTheme); + TCWTerminalForm(FCWTermForm).Show; +end; + +procedure TMainForm.OnCWLogAppend(const S: string; IsTX: Boolean); +begin + if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).AppendLog(S, IsTX); +end; + +procedure TMainForm.OnCWTermBackspace(Sender: TObject); +begin + FController.CWXBackspace; +end; + +procedure TMainForm.OnCWTermDecoder(Value: Boolean); +begin + FController.SetCWDecoder(Value); +end; + +procedure TMainForm.OnCWTermKbdKey(Dot, Dash: Boolean); +begin + FController.CWKeyboardKey(Dot, Dash); +end; + +procedure TMainForm.OnCWTermSpeed(WPM: Integer); +var C: TCWSettings; +begin + C := FController.CWSettings; + if C.Speed = WPM then Exit; + C.Speed := WPM; + FController.SetCWSettings(C); +end; + +procedure TMainForm.OnCWTermMacro(Sender: TObject); +var Slot: Integer; +begin + Slot := TComponent(Sender).Tag; + if (Slot < 0) or (Slot >= CW_MSG_COUNT) then Exit; + if FController.CWSettings.Messages[Slot] = '' then Exit; + FController.CWXSend(FController.CWSettings.Messages[Slot]); +end; + +procedure TMainForm.ServiceCWTerminal; +// Таймер: разбор принятого аудио + обновление окна и ленты под спектром. +// Разбор идёт ВСЕГДА, когда декодер включён, даже если окно закрыто: лента под +// спектром живёт сама по себе. +var + St: string; +begin + FController.ServiceCWDecoder; + if FCWTermForm <> nil then + begin + TCWTerminalForm(FCWTermForm).SetPending(FController.CWXPendingText); + if FController.CWDecoderOn then + begin + St := Format('RX %d WPM', [FController.CWDecSpeedWPM]); + if FController.CWDecToneOffsetHz <> 0 then + St := St + Format(', tone %+d Hz', [FController.CWDecToneOffsetHz]); + if not FController.CWDecSignal then St := St + ', no signal'; + end + else + St := 'decoder off'; + // Набор молча уходит в никуда, если телеграф не вооружён (не CW, запрет + // передачи на бэнде или в слоте трансвертера) — говорим об этом прямо. + if not FController.CWTXActive then St := St + ' | TX not armed'; + TCWTerminalForm(FCWTermForm).SetStatus(St); + end; + if FPans[0] <> nil then + if FPans[0].SetCWStrip(FController.CWSettings.DecoderStrip and FController.CWDecoderOn, + FController.CWLogTail(300)) then + ResizeSpectrumPanels; // строка появилась/пропала — переложить пан +end; + +procedure TMainForm.OnCWMessageSend(const Text: string); +begin + FController.CWXSend(Text); +end; + +procedure TMainForm.OnCWMessageAbort(Sender: TObject); +begin + FController.CWXAbort; +end; + +procedure TMainForm.FormKeyDownCW(Sender: TObject; var Key: Word; + Shift: TShiftState); +// F1..F8 — ячейки памяти телеграфа. Единственное условие — телеграф на +// передающем источнике; в прочих режимах клавиши свободны для всего другого. +// ★Текст берём ИЗ НАСТРОЕК, а не из окна памяти: окно создаётся лениво, и +// раньше клавиши молчали, пока его хоть раз не открыли. +var Slot: Integer; +begin + if Shift <> [] then Exit; + // F9 — окно телеграфного терминала (лента приёма + набор с клавиатуры). + if Key = VK_F9 then + begin + Key := 0; + ShowCWTerminal; + Exit; + end; + if (Key < VK_F1) or (Key > VK_F8) then Exit; + if not (FController.ActiveTXMode in [MODE_CWL, MODE_CWU]) then Exit; + Slot := Key - VK_F1; + if (Slot < 0) or (Slot >= CW_MSG_COUNT) then Exit; + Key := 0; + if FController.CWSettings.Messages[Slot] = '' then Exit; + FController.CWXSend(FController.CWSettings.Messages[Slot]); +end; + procedure TMainForm.OnAlexSettingsChange(const A: TAlexSettings); begin FController.FAlexSettings := A; @@ -9047,6 +9256,9 @@ begin SF.OnThemeChange := SetLightTheme; SF.OnFreqMhzDigitsChange := ApplyFreqMhzDigits; SF.OnTXChange := OnTXSettingsChange; + SF.OnCWChange := OnCWSettingsChange; + SF.OnCWMessagesOpen := ShowCWMessages; + SF.OnCWTerminalOpen := ShowCWTerminal; SF.OnTXProfileSelect := OnTXProfileSelectFromSettings; SF.OnTXProfileAdd := OnTXProfileAddFromSettings; SF.OnTXProfileRename := OnTXProfileRenameFromSettings; @@ -9063,6 +9275,10 @@ 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)); // Вкладка Antenna: у AD936x (Pluto/LibreSDR) вместо Alex-таблицы — таблица @@ -9159,6 +9375,7 @@ begin EnsureWDSPWisdom; end; + // =========================================================================== // CAT subsystem // =========================================================================== diff --git a/PanafallPanel.pas b/PanafallPanel.pas index 1580211..c93be98 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -115,6 +115,14 @@ type FBtnAddPan: TFlatButton; // «⊞» в ряду пан/зума (только пан 0) FHeaderVisible: Boolean; FShowZoomRow: Boolean; // False у панов N>0 (пан-зум — этап 3.3+) + // Бегущая лента расшифровки телеграфа под спектром (только пан 0). Своя + // строка, а не окно: смотреть в эфир и читать расшифровку удобнее в одном + // месте, а полноценный терминал открывается отдельно. + FCWStripOn: Boolean; + FCWStripText: string; + FPbCWStrip: TPaintBox; + FThemeBG: TColor; + FThemeText: TColor; FOnPanClose: TNotifyEvent; FOnAddPan: TNotifyEvent; FAddPanWanted: Boolean; // хозяин: показывать «⊞» в ряду пан/зума @@ -152,6 +160,7 @@ type Shift: TShiftState; X, Y: Integer); procedure PanZoomDblClick(Sender: TObject); procedure PositionPanZoomBar(X0, BottomY, RW, H: Integer; AVisible: Boolean); + procedure CWStripPaint(Sender: TObject); public // Обработчики мыши спектра/водопада и кликов зум-кнопок хозяина — // задать ДО вызова Build (Build подвешивает их на создаваемые контролы). @@ -245,6 +254,9 @@ type property BtnAddPan: TFlatButton read FBtnAddPan; // Ряд пан/зума: у панов N>0 скрыт (их зум — этап 3.3+). property ShowZoomRow: Boolean read FShowZoomRow write FShowZoomRow; + // Лента расшифровки телеграфа: включить/выключить и обновить текст. + // True — состояние видимости изменилось, хозяину нужно переразложить пан. + function SetCWStrip(AOn: Boolean; const AText: string): Boolean; // «⊞» в ряду пан/зума (пан 0): показать/спрятать решает хозяин. property AddPanEnabled: Boolean read FAddPanWanted write FAddPanWanted; // «▦/▤» тумблер грид-раскладки (пан 0, виден при >=2 доп. панах). @@ -432,6 +444,12 @@ begin FPbPanZoom.OnMouseUp := PanZoomMouseUp; FPbPanZoom.OnDblClick := PanZoomDblClick; FZoomBar.PaintBox := FPbPanZoom; + + // ---- Лента расшифровки телеграфа (пан 0, включается хозяином) ---- + FPbCWStrip := TPaintBox.Create(Self); + FPbCWStrip.Parent := AParent; + FPbCWStrip.OnPaint := CWStripPaint; + FPbCWStrip.Visible := False; FBtnZoomOut := MakeFlatBtn(AParent, '−', 0, 0, 10, 10, ZoomOutClick); FBtnZoomDef := MakeFlatBtn(AParent, '⌂', 0, 0, 10, 10, ZoomDefClick); FBtnZoomIn := MakeFlatBtn(AParent, '+', 0, 0, 10, 10, ZoomInClick); @@ -501,6 +519,7 @@ begin FSplitter.Parent := NewParent; FPbWaterfall.Parent := NewParent; FPbPanZoom.Parent := NewParent; + if FPbCWStrip <> nil then FPbCWStrip.Parent := NewParent; FBtnZoomOut.Parent := NewParent; FBtnZoomDef.Parent := NewParent; FBtnZoomIn.Parent := NewParent; @@ -668,6 +687,44 @@ begin end; end; +function TPanafallPanel.SetCWStrip(AOn: Boolean; const AText: string): Boolean; +// Хозяин зовёт с таймера. Раскладку трогаем только на смене видимости — текст +// меняется по несколько знаков в секунду, и дёргать Layout на каждый знак +// незачем. +begin + Result := AOn <> FCWStripOn; + FCWStripOn := AOn; + if AText <> FCWStripText then + begin + FCWStripText := AText; + if (FPbCWStrip <> nil) and FPbCWStrip.Visible then FPbCWStrip.Invalidate; + end; +end; + +procedure TPanafallPanel.CWStripPaint(Sender: TObject); +// Хвост расшифровки, вытянутый по ширине: сколько знаков влезло, столько и +// показываем (лента, а не журнал — история живёт в окне терминала). +var + C: TCanvas; + Cols, ChW: Integer; + S: string; +begin + if FPbCWStrip = nil then Exit; + C := FPbCWStrip.Canvas; + C.Brush.Color := FThemeBG; + C.FillRect(0, 0, FPbCWStrip.Width, FPbCWStrip.Height); + C.Font.Name := {$IFDEF WINDOWS}'Consolas'{$ELSE}'Monospace'{$ENDIF}; + C.Font.Size := 9; + C.Font.Color := FThemeText; + C.Brush.Style := bsClear; + ChW := C.TextWidth('W'); + if ChW <= 0 then ChW := 8; + Cols := Max(1, (FPbCWStrip.Width - DpiScale(8)) div ChW); + S := FCWStripText; + if Length(S) > Cols then S := Copy(S, Length(S) - Cols + 1, Cols); + C.TextOut(DpiScale(4), DpiScale(2), S); +end; + procedure TPanafallPanel.PositionPanZoomBar(X0, BottomY, RW, H: Integer; AVisible: Boolean); var BtnW, Gap, StripW, X, NBtns: Integer; WantAdd, WantGrid: Boolean; @@ -725,6 +782,8 @@ begin if FHeaderVisible then Inc(Result, HEADER_H); if (AShowSpectrum or AShowWaterfall) and FShowZoomRow then Inc(Result, PANZOOM_H); + if FCWStripOn and (AShowSpectrum or AShowWaterfall) then + Inc(Result, DpiScale(20)); if AShowSpectrum then Inc(Result, MIN_SH + RULER_H); if AShowWaterfall then begin @@ -746,6 +805,7 @@ var RULER_H: Integer; PANZOOM_H: Integer; HEADER_H: Integer; + CWSTRIP_H: Integer; ShowPZ: Boolean; EffRatio: Double; begin @@ -784,6 +844,17 @@ begin ShowPZ := (AShowSpectrum or AShowWaterfall) and FShowZoomRow; PositionPanZoomBar(X0, RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ); if ShowPZ then RH := RH - PANZOOM_H; + // Лента расшифровки телеграфа — ещё одной строкой над рядом пан/зума. + if FPbCWStrip <> nil then + begin + FPbCWStrip.Visible := FCWStripOn and (AShowSpectrum or AShowWaterfall); + if FPbCWStrip.Visible then + begin + CWSTRIP_H := DpiScale(20); + FPbCWStrip.SetBounds(X0, RH - CWSTRIP_H, RW, CWSTRIP_H); + RH := RH - CWSTRIP_H; + end; + end; TopOff := ATopOffset + FStackTop; if FHeaderVisible then Inc(TopOff, HEADER_H); @@ -1366,7 +1437,7 @@ begin O.SetSquelchState(S.FMSQOn, S.FMSQLevel); // TxSel: слайс — источник передачи; Tx: передача идёт именно с него. O.SetExtState(Round(S.Volume * 100), S.Muted, False, - FController.FTransmitting and (FController.FTxSliceId = SliceId), + FController.RadioKeyed and (FController.FTxSliceId = SliceId), FController.FTxSliceId = SliceId); // Auto TX (настройка CAT-порта слайса) — бейдж TX подписан AutoTX. O.SetAutoTxState(FController.SliceAutoTx(SliceId)); @@ -1417,7 +1488,7 @@ begin // передаём со слайса, ActiveTXFreqHz берёт его частоту, а не VFO B. FMainFlag.SetExtState(FController.ActiveVolume, FController.FMuted, FController.FSplitTxB and (FController.FTxSliceId = 0), - FController.FTransmitting and (FController.FTxSliceId = 0), + FController.RadioKeyed and (FController.FTxSliceId = 0), FController.FTxSliceId = 0); PushFlagTXProfiles(FMainFlag, 0); end; @@ -1533,6 +1604,10 @@ end; procedure TPanafallPanel.SetFlagsTheme(const T: TAppTheme); var i: Integer; begin + // Лента расшифровки рисуется своими руками — цвета держим здесь же. + FThemeBG := T.Panel; + FThemeText := T.Text; + if (FPbCWStrip <> nil) and FPbCWStrip.Visible then FPbCWStrip.Invalidate; if Assigned(FMainFlag) then FMainFlag.SetTheme(T); for i := 0 to High(FSliceFlags) do if Assigned(FSliceFlags[i]) then FSliceFlags[i].SetTheme(T); 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 1790b51..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; @@ -180,6 +185,15 @@ type procedure SendDDCSpecific(const Pkt: TDDCSpecificPacket); virtual; procedure SendDUCSpecific(const Pkt: TDUCSpecificPacket); virtual; procedure SendHighPriority(const Pkt: THighPriorityPacket); virtual; + // Бит CWX (High Priority байт 5 bit0): «ключ нажат» для программной + // передачи телеграфа — несущую по нему формирует прошивка. Пакет уходит + // немедленно, каждый фронт (тайминги держит PC). Pluto — no-op. + procedure SetCWXKey(Down: Boolean); virtual; + // Кейер в прошивке вооружён: железо может замкнуть ключ САМО, без нашего + // PTT. Пока флаг взведён, уровень мощности обязан лежать в каждом HP-кадре + // (обычно он шлётся только на передаче — а «передачи» с точки зрения софта + // тут не происходит вовсе). Pluto — no-op. + procedure SetCWKeyerArmed(Armed: Boolean); virtual; procedure SetStepAtten(dB: Byte); virtual; // RX-усиление (Pluto/AD936x). ModeIdx: 0=manual, 1=fast_attack, 2=slow_attack, // 3=hybrid. GainDb применяется только в manual. HPSDR — no-op. @@ -277,6 +291,14 @@ procedure TRadioBackend.SendHighPriority(const Pkt: THighPriorityPacket); begin end; +procedure TRadioBackend.SetCWXKey(Down: Boolean); +begin +end; + +procedure TRadioBackend.SetCWKeyerArmed(Armed: Boolean); +begin +end; + procedure TRadioBackend.SetStepAtten(dB: Byte); begin end; diff --git a/RadioController.pas b/RadioController.pas index 7ed9f52..26e2419 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -50,7 +50,8 @@ uses Classes, SysUtils, Math, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder, - Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; + Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer, + CWDecoder; // Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для // движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1). @@ -86,7 +87,8 @@ type rfBeaconLock, // QO-100 beacon lock: статус/коррекция rfDevice, // подключённое устройство сменилось rfDeviceList, // список discovered устройств обновился - rfPanFreq // центр доп. пана уехал (ретюн DDC извне) + rfPanFreq, // центр доп. пана уехал (ретюн DDC извне) + rfSliceFreq // слайс перестроен извне (CAT); Id — FSliceFreqId ); TRadioStateEvent = procedure(Sender: TObject; Field: TRadioField) of object; @@ -103,6 +105,11 @@ type // Сырьё спектра/водопада наружу (фронтенды рисуют). Вызывается из DSP-потока. TPixelDataEvent = procedure(const Pixels: array of Single; Count: Integer) of object; + // Лента телеграфа для окна-терминала: прибавка текста и чья она — принятая + // декодером или эхо собственной передачи (окно красит их по-разному). + // Зовётся из потока хозяина (ServiceCWDecoder), не из DSP. + TCWLogEvent = procedure(const S: string; IsTX: Boolean) of object; + // Снимок состояния для синхронизации нового фронтенда (Arduino/Web на connect). TRadioSnapshot = record VfoA, VfoB, CenterFreq, SpanHz: Double; @@ -360,6 +367,9 @@ type FTwoTone: Boolean; // 2TON активен FSplitTxB: Boolean; FTxSliceId: Integer; // 0 = главный VFO (A/B split); >0 = слайс-источник TX + // Id слайса, перестроенного извне (CAT): полезная нагрузка rfSliceFreq — + // Changed несёт только поле, а обработчику нужен конкретный флаг. + FSliceFreqId: Integer; // Auto TX слайса (индекс = СЛОТ слайса, не Id): PTT по CAT-порту слайса сам // назначает его TX-источником. Флаг живёт на слоте, а не на слайсе, потому // что настройка привязана к букве (B..G) и переживает удаление слайса. @@ -433,6 +443,52 @@ type // TTXProfile). Истина для эфира — всегда живые FTXSettings/FDrivePercent; // активный профиль ведётся за ними (StoreActiveTXProfile). FTXProfiles: TTXProfileList; + // Телеграф: параметры кейера/сайдтона/pitch (DUC Specific 5..13,17 + CW-тракт). + FCWSettings: TCWSettings; + // Программная передача текста (бит CWX). Поток создаётся лениво — у тех, + // кто телеграфом не пользуется, лишней нити не заводится. + FCWSender: TCWSender; + // Состояние ключа для ПРОГРАММНОГО сайдтона. Пишется потоком передачи и + // разбором HP-статуса (прямой ключ), читается DSP-потоком в MixSidetone — + // толерантная гонка на одном булеве, как FHPp*. + FCWKeyDown: Boolean; + // Прошлое состояние лепестков — обрыв программной передачи по фронту. + FCWPrevDot: Boolean; + FCWPrevDash: Boolean; + // Момент последнего замыкания ключа прошивкой — только для ИНДИКАЦИИ + // (см. RadioKeyed): без выдержки метр моргал бы RX/TX на каждой точке. + FCWKeyedAt: QWord; + // Предыдущее значение RadioKeyed — событие рендера шлём только на фронте. + FCWKeyedShown: Boolean; + // Последний отправленный байт 5 — чтобы не гнать DUC-пакет на каждую + // смену режима, а только когда телеграфная конфигурация реально поменялась. + FLastCWOptions: Byte; + FCWSTPhase: Double; // фаза генератора (только DSP-поток) + 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; + // ---- Декодер приёма (CWDecoder.pas) -------------------------------------- + // Создаётся лениво вместе с окном-терминалом. Кормится из OnDemodAudioReady + // (до громкости и мьюта, без сайдтона), разбирается на таймере хозяина. + FCWDec: TCWDecoder; + // Лента расшифровки: последние знаки приёма (для полосы под спектром) и + // счётчик, по которому потребители понимают, что появилось новое. + FCWLogText: string; + FCWLogSeq: Int64; + FOnCWLog: TCWLogEvent; FAlexSettings: TAlexSettings; FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only) // Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер. @@ -554,6 +610,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-слайса при кросс-банде). @@ -584,6 +645,34 @@ type procedure ApplyNoiseFilterButtonsToDSP; // NR/NB/SNB/ANF → WDSP procedure SendDUCSpecificFromSettings; // DUC Specific (mic/keyer/atten) в сеть procedure ApplyTXSettingsToDSP; // TX-цепь (фильтр/gain/EQ/comp/...) → WDSP + // ---- Телеграф ---------------------------------------------------------- + // Живой контекст «где мы сейчас»: в трансвертере правки запоминаются в его + // слоте, иначе в кэше диапазона. ★Развилка обязана стоять у КАЖДОГО поля, + // которое помнится по диапазону: пока её не было у режима/фильтра/AGC/CTUN, + // трансвертер травил ими тот HF-диапазон, на котором стоял до входа + // (FCurrentBand при входе в слот не меняется). + function XvtrSlotActive: Boolean; + function TXProhibited: Boolean; // DoNotTx-бэнд / RX-only XVTR + 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; + Count: Integer): Boolean; function PSOutlierSigmaEff: Double; // σ outlier-фильтра calcc (авто по плате) // ---- TX-профили -------------------------------------------------------- @@ -799,6 +888,9 @@ type // вне захваченной полосы пана — двигаем окно DDC. False = отказ (другой // диапазон либо окно не подвинуть, не оглушив главный приёмник). function TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean; + // Уведомление UI «частота слайса пришла извне»: перезалить флаг (цифры + + // кромки фильтра), позиция флага и так живая. + procedure SliceFreqChanged(Id: Integer); // ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ---- // Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр в ВИДИМЫХ @@ -917,6 +1009,47 @@ type procedure SetMOX(On_: Boolean); procedure SetTune(On_: Boolean); // TUN + // ---- Телеграф ---------------------------------------------------------- + // Настройки применяются целиком: pitch уезжает в движок (там он двигает + // гетеродин и полосу), остальное — в DUC Specific прошивке. + // «Железо сейчас в эфире» — ДЛЯ ИНДИКАЦИИ (S-метр, лампочки), не для тракта. + // В телеграфе FTransmitting не поднимается: передачей владеет кейер прошивки, + // и единственный признак эфира — HPS_PTT в статусе. + function RadioKeyed: Boolean; + // Фронты RadioKeyed → индикация. Зовётся из разбора HP-статуса (openHPSDR) и + // с UI-таймера: у Pluto статуса нет вовсе, а телеграф там локальный. + procedure ServiceCWKeyed; + function CWSettings: TCWSettings; + procedure SetCWSettings(const C: TCWSettings); + // Программная передача текста: PC сам выдерживает тайминги и дёргает бит + // CWX в High Priority — точки/тире рисует прошивка по этому биту (эталон + // Thetis cwx.cs). Занят ли передатчик — CWXBusy; ЛЮБОЕ касание + // манипулятора или снятие MOX обрывает передачу (CWXAbort). + procedure CWXSend(const Text: string); + procedure CWXAbort; + function CWXBusy: Boolean; + // ---- Окно-терминал ----------------------------------------------------- + // Стереть последний НЕ ушедший в эфир знак (Backspace при наборе). + function CWXBackspace: Boolean; + // Что ещё стоит в очереди передачи (строка набора показывает именно это). + function CWXPendingText: string; + // Ключ с клавиатуры: при иамбике — лепестки, при прямом ключе — уровень. + // На кейере прошивки лепестков нет (их читает FPGA со своего разъёма), + // поэтому там клавиатура работает через бит CWX, то есть прямым ключом. + procedure CWKeyboardKey(Dot, Dash: Boolean); + // Разбор принимаемого телеграфа: включение и обслуживание (таймер хозяина). + procedure SetCWDecoder(On_: Boolean); + function CWDecoderOn: Boolean; + procedure ServiceCWDecoder; + // Последние знаки ленты (для полосы под спектром) и счётчик изменений. + function CWLogTail(MaxChars: Integer): string; + property CWLogSeq: Int64 read FCWLogSeq; + // Оценка скорости корреспондента и расстройка тона (индикация в терминале). + function CWDecSpeedWPM: Integer; + function CWDecToneOffsetHz: Integer; + function CWDecSignal: Boolean; + property OnCWLog: TCWLogEvent read FOnCWLog write FOnCWLog; + // ---- PureSignal ---- // Доступность PS у активного бэкенда/платы (Caps.HasPureSignal). function PSAvailable: Boolean; @@ -1108,6 +1241,8 @@ begin DefaultFilterTable(FFilterTable); // до подключения — заводские фильтры ApplyFilterSlot(FFilter); FAGCMode := 1; FAGCTop := 90; + TSettingsManager.DefaultCW(FCWSettings); // до подключения — заводской телеграф + FLastCWOptions := $FF; // «ещё не слали» — первый пуш пройдёт FRxGainMode := 2; FRxGainDb := 40; // Pluto: slow_attack по умолчанию FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX) FVolume := 70; FTxMonVolume := 40; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0; @@ -1239,7 +1374,25 @@ 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; + FreeAndNil(FCWSender); + end; + if Assigned(FCWDec) then FCWDec.Enabled := False; // кольцо больше не кормим FreeEngines; + // Декодер — ПОСЛЕ движка: его кольцо наполняет DSP-поток, и освобождать + // объект, пока поток жив, нельзя. + if Assigned(FCWDec) then FreeAndNil(FCWDec); FreeAndNil(FDeviceStore); FreeAndNil(FSettings); inherited Destroy; @@ -1266,8 +1419,11 @@ procedure TRadioController.PushNetworkState; begin if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then begin - FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), - FDriveLevel, FTransmitting, True, True, FTuning, FTwoTone); + // Состояние передачи для ЖЕЛЕЗА — RadioKeyed, а не FTransmitting: в + // телеграфе ключ замыкает прошивка, и реле T/R с антенной и OC обязаны + // ехать за ней. PTT в кадре этим не поднимается (он живёт в SendPTT). + FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, + FDriveLevel, RadioKeyed, True, True, FTuning, FTwoTone); FNetwork.SendFullHP; end; end; @@ -1385,6 +1541,7 @@ var SupplyV, SupplyA, FwdW, RevW, SWRV, Rho, DetK: Double; Bi: Integer; Is6m, IsTx: Boolean; + KeyDot, KeyDash: Boolean; begin FwdPwr := (Status.FwdPwrAlex0Hi shl 8) or Status.FwdPwrAlex0Lo; RevPwr := (Status.RevPwrAlex0Hi shl 8) or Status.RevPwrAlex0Lo; @@ -1447,6 +1604,22 @@ begin FHPpPLL := (Status.StatusBits and HPS_PLL_LOCKED) <> 0; FHPpHWPTT := (Status.StatusBits and HPS_PTT) <> 0; FHPpOverload := Status.ADCOverload; + // Состояние ключа/манипулятора в разъёме трансивера (сетевой поток). + // Касание обрывает программную передачу — как в Thetis/pihpsdr («key hit» + // останавливает CAT CW), иначе оператор не может вклиниться. ★Обрываем по + // ФРОНТУ, а не по уровню: на плате без подключённого ключа входы могут стоять + // в единице, и проверка уровня убивала бы передачу на первом же HP-статусе. + KeyDot := (Status.StatusBits and HPS_DOT) <> 0; + KeyDash := (Status.StatusBits and HPS_DASH) <> 0; + if ((KeyDot and not FCWPrevDot) or (KeyDash and not FCWPrevDash)) + and CWXBusy then CWXAbort; + FCWPrevDot := KeyDot; + FCWPrevDash := KeyDash; + // Программный сайдтон от лепестков верен ТОЛЬКО у прямого ключа: при иамбике + // элементы считает FPGA, а PC видит лишь замыкание контакта. + if (FCWSettings.KeyerMode = 0) and (ActiveTXMode in [MODE_CWL, MODE_CWU]) + and (not CWXBusy) then + FCWKeyDown := KeyDot or KeyDash; Invoke(ApplyHPStatus); end; @@ -1495,6 +1668,34 @@ begin Changed(rfADCOverload); end; + // Вооружение кейера — под текущий контекст (бэнд/трансвертер/Alex могли + // поменяться мимо SetMode и SetTxSlice). Вызов идемпотентный, см. SyncCWKeyer. + SyncCWKeyer; + // ★Телеграф с кейером в прошивке: HPS_PTT здесь — ЭХО собственной манипуляции + // (при break-in железо само поднимает T/R на каждую посылку), а не действие + // оператора. Гонять по нему SetMOX нельзя: на каждой точке пересобирался бы + // передающий тракт, а программная передача обрывалась бы после ПЕРВОЙ посылки + // (снятие MOX гасит CWX). Флаг держим актуальным — им выбирается источник + // модуляции, — но передачей по нему не управляем. + if CWTXActive then + begin + // ★Ниже стоит Exit, поэтому падающий фронт HWPTT здесь обработать НЕКОМУ. + // Если в эфир нас увёл именно HWPTT, пока источником передачи был не + // телеграф (например слайс в голосовом режиме), а телеграф включился уже + // после — передача залипала бы НАВСЕГДА: FTransmitting остаётся взведённым, + // Alex стоит в TX, PTT не снимается, и приёмник всю дорогу видит свой же + // передатчик. Снимаем такую передачу здесь. + if FHWPTTStartedTX and (not FHPpHWPTT) then + begin + FHWPTTStartedTX := False; + SetMOX(False); + end; + FHWPTTActive := FHPpHWPTT; + if FHPpHWPTT then FCWKeyedAt := GetTickCount64; + ServiceCWKeyed; + Exit; + end; + ServiceCWKeyed; // ушли из телеграфа — вернуть индикацию в исходное // Hardware PTT (footswitch / mic PTT): фронт на rate HP Status — не пропустить // короткое нажатие. HWPTT не перекрывает ручной MOX; выключаем TX только если // его включил именно HWPTT. @@ -1533,6 +1734,19 @@ begin // the independent pre-volume tap is handled by OnDemodAudioReady. if FMode in [MODE_DMR, MODE_FMRAW] then Exit; + // Программный сайдтон подмешиваем ДО всех потребителей: его должны слышать + // и локальная звуковуха, и web-клиент, и динамик трансивера. + if MixSidetone(Left, Right, Count) then + begin + if FSendAudioToRadio then + FNetwork.SendSpeakerAudio(FCWSTBufL, FCWSTBufR, Count); + if Assigned(FOnAudioConsume) and FOnAudioConsume(FCWSTBufL, FCWSTBufR, Count) then + Exit; + if FLocalAudio then + FAudioOut.Write(FCWSTBufL, FCWSTBufR, Count); + Exit; + end; + if FSendAudioToRadio then FNetwork.SendSpeakerAudio(Left, Right, Count); if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, Count) then @@ -1561,6 +1775,14 @@ begin end else if Assigned(FDMRDec) and FDMRDec.Enabled then FDMRDec.SetEnabled(False); + // ★Телеграфный декодер кормим отсюда же: здесь аудио идёт ДО громкости и + // мьюта и БЕЗ программного сайдтона (он подмешивается позже, в OnAudioReady), + // зато уже после узкого CW-фильтра — лучшего предетектора не найти. + // На своей передаче молчим: в телеграфе приёмник намеренно жив, и собственный + // сигнал (особенно в полном дуплексе Pluto) забил бы ленту своей же работой. + if Assigned(FCWDec) and FCWDec.Enabled + and (FMode in [MODE_CWL, MODE_CWU]) and (not RadioKeyed) then + FCWDec.FeedAudio(Left, Count); end; procedure TRadioController.OnDMRAudioReady(const PCM: array of Single; @@ -1733,6 +1955,17 @@ begin Result := BandEdges(FCurrentBand, IsPluto, LoHz, HiHz); end; +procedure TRadioController.SliceFreqChanged(Id: Integer); +// Слайс перестроен НЕ из UI (CAT-порт слайса). Позицию флага UI держит сам +// (TickFlags → LayoutFlags читает живой TargetHz), а вот ЧАСТОТА и кромки +// фильтра во флаге живут в его собственных полях и обновляются только +// PushSliceFlagState. Без этого уведомления флаг уезжал на новую частоту, +// показывая старые цифры и старую полосу фильтра. +begin + FSliceFreqId := Id; + Changed(rfSliceFreq); +end; + function TRadioController.TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean; // Свобода внешней программы: она двигает слайс куда угодно ВНУТРИ включённого // диапазона (2 м на трансвертере: 144.174 при окне на 145.5 — можно), но сменить @@ -1756,6 +1989,7 @@ begin if SliceFitsCapture(TargetHz, PanId) then begin SetSliceTarget(Id, TargetHz); + SliceFreqChanged(Id); Exit(True); end; @@ -1767,6 +2001,7 @@ begin SetPanDDCFreq(PanId, NewCenter); SetSliceTarget(Id, TargetHz); Changed(rfPanFreq); // UI: шапка/зум-бар/флаги пана под новый центр + // (несёт и перезалив флагов — SliceFreqChanged лишний) Result := True; end else @@ -1780,6 +2015,7 @@ begin NewCenter := (TargetHz + ActiveVfoHz) / 2; SetCenter(NewCenter); // несёт Changed(rfCenterFreq) + сдвиги слайсов SetSliceTarget(Id, TargetHz); + SliceFreqChanged(Id); // rfCenterFreq флаги не перезаливает — нужен свой Result := True; end; end; @@ -2220,6 +2456,10 @@ begin FSlices[idx].TargetHz := TargetHz; if Assigned(FDSPEngine) then FDSPEngine.SetSliceShift(Id, TargetHz - PanCenterHz(FSlices[idx].PanId)); + // Слайс — источник передачи ⇒ он же задаёт частоту DUC. Раньше её дошлёт бы + // первый MOX, но в телеграфе ключ замыкает прошивка без всякого MOX: DUC + // обязан стоять правильно ВСЕГДА, а не только на передаче. + if FTxSliceId = Id then PushNetworkState; end; procedure TRadioController.SetSliceMode(Id, Mode: Integer); @@ -2255,6 +2495,7 @@ begin ActiveMicInput.Flush; end; end; + if FTxSliceId = Id then SyncCWKeyer; end; procedure TRadioController.SetSliceFilter(Id, Low, High: Integer); @@ -2909,17 +3150,46 @@ begin // Мультислайс-TX: если выбран слайс-источник — передаём на его частоте // (слайсы не несут repeater-конфиг, FM-сдвиг не применяем). if (FTxSliceId > 0) and GetSlice(FTxSliceId, S) then - Exit(S.TargetHz); - // TX-VFO задаётся ТОЛЬКО селектором FSplitTxB (UI: кнопки TX-A/TX-B), - // независимо от активного RX-VFO. Раньше при не-split TX ошибочно следовал - // за активным RX (TX-A горит, но передача шла на B при активном B). - if FSplitTxB then Result := FVfoB - else Result := FVfoA; - if FMode = MODE_FM then - case FFMRptDir of - RPT_MINUS: Result := Result - FFMRptOffsetHz; - RPT_PLUS: Result := Result + FFMRptOffsetHz; - end; + Result := S.TargetHz + else + begin + // TX-VFO задаётся ТОЛЬКО селектором FSplitTxB (UI: кнопки TX-A/TX-B), + // независимо от активного RX-VFO. Раньше при не-split TX ошибочно следовал + // за активным RX (TX-A горит, но передача шла на B при активном B). + if FSplitTxB then Result := FVfoB + else Result := FVfoA; + if FMode = MODE_FM then + case FFMRptDir of + RPT_MINUS: Result := Result - FFMRptOffsetHz; + RPT_PLUS: Result := Result + FFMRptOffsetHz; + end; + end; + // Телеграф + TUN: несущую даёт SSB-модулятор из тона pitch, поэтому DUC надо + // сдвинуть навстречу — иначе настроечная несущая уедет от VFO на pitch. При + // работе ключом (кейер/CWX) сдвигать нечего: РЧ формирует прошивка на DUC. + if FTuning and (ActiveTXMode in [MODE_CWL, MODE_CWU]) and FWDSPReady + and Assigned(FDSPEngine) then + 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; @@ -2975,6 +3245,7 @@ begin // без сброса первые ~200мс передачи ушли бы в эфир «из прошлого». if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then ActiveMicInput.Flush; + SyncCWKeyer; // кейер прошивки — под режим нового источника FDriveLevel := CalcDriveByte; // калибровка драйва банда TX-слайса ApplyAntPorts; // AD936x: TX-разъём — по диапазону TX-слайса PushNetworkState; // TX-частота/Alex через ActiveTXFreqHz/TxBandIdx @@ -3915,7 +4186,8 @@ begin begin // Частота за окном — сбрасываем CTUN и центрируемся на ней. FCTun := False; - FBandCache[FCurrentBand].CTun := False; + if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastCTun := False + else FBandCache[FCurrentBand].CTun := False; FCenterFreq := ActiveFreq; if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0); NeedPush := True; @@ -4022,7 +4294,8 @@ begin end; if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR); ApplyModeDefaults; // дефолтный фильтр/девиация под режим - FBandCache[FCurrentBand].Mode := FMode; + if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastMode := FMode + else FBandCache[FCurrentBand].Mode := FMode; // Профиль привязан к паре «флаг + модуляция»: если передаём с главного, // новый режим приносит свой профиль. До пуша в WDSP — ApplyTXChainSettings // внутри SetTXMode ниже перешьёт цепь уже с новыми значениями. @@ -4043,8 +4316,15 @@ begin ActiveMicInput.Flush; end; // Если TUN активен — пересчитываем знак частоты под новый режим - if FTuning then FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG); + if FTuning then + begin + if ActiveTXMode in [MODE_CWL, MODE_CWU] then + FDSPEngine.SetTXTone(True, FCWSettings.Pitch, TUN_TONE_MAG) + else + FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG); + end; end; + SyncCWKeyer; ApplyModeFilter; Changed(rfMode); end; @@ -4061,6 +4341,11 @@ end; procedure TRadioController.CacheFilterToBand; begin + if XvtrSlotActive then + begin + FXvtrSettings.Entries[FCurrentXvtr].LastFilterIdx := FFilter; + Exit; + end; FBandCache[FCurrentBand].FilterIdx := FFilter; FBandCache[FCurrentBand].FilterBW := FFilterBW; // легаси-поле, для старых конфигов end; @@ -4196,7 +4481,8 @@ begin if (M < 0) or (M > 4) then Exit; FAGCMode := M; ApplyAGCToEngine; - FBandCache[FCurrentBand].AGCMode := FAGCMode; + if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastAGCMode := FAGCMode + else FBandCache[FCurrentBand].AGCMode := FAGCMode; Changed(rfAGCMode); end; @@ -4206,7 +4492,8 @@ begin FAGCTop := DB; if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetAGCTop(FAGCTop); ApplyAGCToEngine; // ре-применяем режим с новым уровнем - FBandCache[FCurrentBand].AGCTop := FAGCTop; + if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastAGCTop := FAGCTop + else FBandCache[FCurrentBand].AGCTop := FAGCTop; Changed(rfAGCTop); end; @@ -4323,8 +4610,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); @@ -4829,7 +5121,8 @@ end; procedure TRadioController.SetCTun(On_: Boolean); begin FCTun := On_; - FBandCache[FCurrentBand].CTun := FCTun; + if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastCTun := FCTun + else FBandCache[FCurrentBand].CTun := FCTun; if not FCTun then begin // Выключили CTUN: центрируемся на АКТИВНОМ VFO, shift=0. (Включение CTUN не @@ -4941,21 +5234,135 @@ begin FDSPEngine.SetANF(FANF); end; +function TRadioController.XvtrSlotActive: Boolean; +begin + Result := (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT); +end; + +function TRadioController.TXProhibited: Boolean; +// Единая точка запретов передачи: диапазон с DoNotTx (Alex) и RX-only слот +// трансвертера. Раньше эта пара была скопирована в SetMOX/SetTune/SetTwoTone — +// то есть жила ТОЛЬКО на пути через софтовый MOX. С кейером в прошивке этого +// пути нет вовсе, и на RX-only трансвертере ключ спокойно выходил в эфир. +begin + Result := (FDevConnected and (FCurrentXvtr < 0) + and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)]) + or ((FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) + and FXvtrSettings.Entries[FCurrentXvtr].RXOnly); +end; + +function TRadioController.CWFWSource: Boolean; +// Манипуляцию рисует ПРОШИВКА (кейер в FPGA, openHPSDR P2). Путь по умолчанию: +// тайминги в железе, джиттер PC не влияет вовсе. +begin + 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). +// ★Вне телеграфа байт ОБЯЗАН быть нулевым: иначе прошивка сама поднимет PTT +// на замыкание ключа посреди SSB (Thetis, console.cs:27394). +begin + Result := 0; + // ★Байт обязан быть нулевым не только вне телеграфа, но и когда манипуляцию + // взял на себя локальный генератор: иначе прошивка ключевала бы ПАРАЛЛЕЛЬНО + // с ним (и поднимала бы 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 + 1: Result := Result or CW_IAMBIC; // Mode A + 2: Result := Result or CW_IAMBIC or CW_MODE_B; // Mode B + end; // 0 = прямой ключ + if FCWSettings.SidetoneHW and (FCWSettings.SidetoneHWLevel > 0) then + Result := Result or CW_SIDETONE; + if FCWSettings.StrictSpacing then Result := Result or CW_STRICT_SPACING; + if FCWSettings.BreakIn then Result := Result or CW_BREAK_IN; +end; + procedure TRadioController.SendDUCSpecificFromSettings; var DUCPkt: TDUCSpecificPacket; RateKsps: Word; + HangMs: Word; begin if not (FNetwork.Connected and FNetwork.Running) then Exit; FillChar(DUCPkt, SizeOf(DUCPkt), 0); // DUC0 rate в ksps берём из WDSP-движка (фиксированный TX rate, не RX). RateKsps := FDSPEngine.TXSampleRate div 1000; DUCPkt.NumDACs := 1; - DUCPkt.SidetoneLevel := 50; - DUCPkt.SidetoneFreqHi := Hi(600); - DUCPkt.SidetoneFreqLo := Lo(600); - DUCPkt.KeyerSpeed := 20; - DUCPkt.KeyerWeight := 50; + DUCPkt.CWOptions := CWOptionsByte; + // Уровень/частота аппаратного сайдтона — тон в наушниках САМОГО трансивера. + if FCWSettings.SidetoneHW then + DUCPkt.SidetoneLevel := Byte(EnsureRange(FCWSettings.SidetoneHWLevel, 0, 127)) + else + DUCPkt.SidetoneLevel := 0; + DUCPkt.SidetoneFreqHi := Hi(Word(EnsureRange(FCWSettings.Pitch, 200, 1200))); + DUCPkt.SidetoneFreqLo := Lo(Word(EnsureRange(FCWSettings.Pitch, 200, 1200))); + DUCPkt.KeyerSpeed := Byte(EnsureRange(FCWSettings.Speed, 1, 60)); + DUCPkt.KeyerWeight := Byte(EnsureRange(FCWSettings.Weight, 33, 66)); + // Hang — сколько прошивка держит передачу после последнего элемента (semi + // break-in). При выключенном break-in железу его знать незачем. + if FCWSettings.BreakIn then + HangMs := Word(EnsureRange(FCWSettings.HangTimeMS, 0, 2000)) + else + HangMs := 0; + DUCPkt.HangDelayHi := Hi(HangMs); + DUCPkt.HangDelayLo := Lo(HangMs); + DUCPkt.RFDelay := Byte(EnsureRange(FCWSettings.RFDelayMS, 0, 255)); + DUCPkt.CWRampPeriod := Byte(EnsureRange(FCWSettings.RampMS, 0, 20)); DUCPkt.DUC0RateHi := Hi(RateKsps); DUCPkt.DUC0RateLo := Lo(RateKsps); DUCPkt.DUC0Bits := 24; @@ -4969,6 +5376,7 @@ begin DUCPkt.StepAtten0 := Byte(EnsureRange(FTXSettings.AttOnTX, 0, 31)); DUCPkt.StepAtten1 := DUCPkt.StepAtten0; DUCPkt.StepAtten2 := DUCPkt.StepAtten0; + FLastCWOptions := DUCPkt.CWOptions; FNetwork.SendDUCSpecific(DUCPkt); end; @@ -5308,14 +5716,12 @@ begin // DMR support is receive-only. This guard also covers CAT, web and hardware // PTT. FM RAW is a flat external-modem transmit path. if On_ and (ActiveTXMode = MODE_DMR) then Exit; - // Safety: блокируем TX на бэндах с DoNotTx (Alex) и на RX-only трансвертере. - if On_ and FDevConnected and (FCurrentXvtr < 0) - and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then - Exit; - if On_ and (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) - and FXvtrSettings.Entries[FCurrentXvtr].RXOnly then - Exit; + // Safety: бэнды с DoNotTx (Alex) и RX-only трансвертер — см. TXProhibited. + if On_ and TXProhibited then Exit; WasTransmitting := FTransmitting; + // Снятие MOX руками обрывает программную передачу текста: оператор решил + // прекратить — ключ обязан отпуститься немедленно. + if (not On_) and CWXBusy then CWXAbort; FTransmitting := On_; // 2TON: прямое снятие MOX (кнопкой/CAT/web) гасит и двухтональник. if (not On_) and FTwoTone then @@ -5336,7 +5742,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 после переключения реле @@ -5386,13 +5792,27 @@ begin ActiveMicInput.Flush; // В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX, чтобы // TX leakage не накапливался в RXA pipeline / FFT-истории анализатора. - FDSPEngine.KeepRXDuringTX := FDisplayDuplex; + // ★Телеграф — исключение: приёмник должен жить, иначе с ним умолкает и + // программный сайдтон (он подмешивается в RX-аудио), а при break-in + // пропадал бы эфир между посылками. От утечки прикрывает ATT on TX. + FDSPEngine.KeepRXDuringTX := FDisplayDuplex or + (ActiveTXMode in [MODE_CWL, MODE_CWU]); // Мультислайс-TX: передающий тракт под режим слайса-источника (SetMode мог // оставить главный режим). Ставим перед стартом TX-потока. if FTransmitting then FDSPEngine.SetTXMode(ActiveTXMode); // PureSignal: mox-флаг calcc (гейтит сбор данных калибровки в pscc). FDSPEngine.PSSetMox(FTransmitting); - FDSPEngine.SetTXRun(FTransmitting); + // ★Телеграф: голосовой TXA не запускается ВООБЩЕ (эталон Thetis, + // console.cs:29570). В WDSP режим TXA_CWL — буквально ветка SSB, поэтому + // без этого гарда MOX в CW отправлял бы в эфир микрофон на 2.9 кГц. + // Несущую в телеграфе даёт прошивка: кейер, бит CWX или тон TUN. + // Гард по РЕЖИМУ, а не по CWTXActive: даже с выключенным кейером прошивки + // голос в телеграфном участке недопустим (тогда MOX = только PTT). + if FTransmitting and (ActiveTXMode in [MODE_CWL, MODE_CWU]) + and (not FTuning) and (not FTwoTone) then + FDSPEngine.SetTXRun(False) + else + FDSPEngine.SetTXRun(FTransmitting); if WasTransmitting and (not FTransmitting) and (not FDisplayDuplex) then begin FDSPEngine.FlushRX; @@ -5415,13 +5835,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; @@ -5431,16 +5854,21 @@ begin if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit; // Safety: те же блокировки, что и в SetMOX — TUN это TX через WDSP PostGen-тон, // он тоже должен молчать на бэндах с DoNotTx и на RX-only XVTR. - if On_ and FDevConnected and (FCurrentXvtr < 0) - and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then Exit; - if On_ and (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) - and FXvtrSettings.Entries[FCurrentXvtr].RXOnly then Exit; + if On_ and TXProhibited then Exit; if On_ and FTwoTone then SetTwoTone(False); // TUN поверх 2TON — гасим тона if On_ then begin // Тон включаем ДО запуска TX-аудио, чтобы первые сэмплы уже шли с тоном. + // В телеграфе тон = pitch: TX-частота уезжает на CWLOOffset (ActiveTXFreqHz), + // и несущая настройки встаёт РОВНО на VFO, а не на VFO±тон. Так же в Thetis + // (console.cs:31757, ветка cw_fw_keyer && chkTUN). if FWDSPReady and Assigned(FDSPEngine) then - FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG); + begin + if ActiveTXMode in [MODE_CWL, MODE_CWU] then + FDSPEngine.SetTXTone(True, FCWSettings.Pitch, TUN_TONE_MAG) + else + FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG); + end; FTuning := True; FDriveLevel := CalcDriveByte; // CalcDriveByte увидит FTuning=True (TUN-уровень) ApplyWDSPDrive(FTXSettings.TUNLevel); @@ -5456,7 +5884,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; @@ -5465,6 +5893,523 @@ begin Changed(rfTuning); end; +// =========================================================================== +// Телеграф +// =========================================================================== + +function TRadioController.RadioKeyed: Boolean; +// Выдержка после последней посылки: точка на 20 WPM длится 60 мс, а индикаторы +// перерисовываются 10 раз в секунду — без неё бейджи и метр мигали бы внутри +// одного знака. Держим 8 точек, но не меньше 500 мс: на медленной скорости +// межсловный интервал (7 точек) сам по себе длиннее полусекунды, и фиксированная +// выдержка гасила бы индикацию между словами. +var Hold: QWord; +begin + Result := FTransmitting; + if Result then Exit; + if not CWTXActive then Exit(False); + if FCWKeyedAt = 0 then Exit(False); + // Выдержка = hang прошивки + запас, но не меньше 8 точек: полурывок semi-QSK. + // По ней же переключается реле T/R, поэтому щёлкать оно обязано РАЗ на + // передачу, а не на каждую посылку. + Hold := 8 * (1200 div EnsureRange(FCWSettings.Speed, 5, 60)); + if Hold < QWord(FCWSettings.HangTimeMS) + 250 then + Hold := QWord(FCWSettings.HangTimeMS) + 250; + if Hold < 500 then Hold := 500; + 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; +end; + +procedure TRadioController.SetCWSettings(const C: TCWSettings); +// Одна дверь для любой правки телеграфа: pitch уезжает в движок (там он +// двигает гетеродин и полосу всех CW-каналов), кейер/сайдтон — в прошивку, +// скорость — в поток программной передачи. +begin + FCWSettings := C; + FCWSettings.Pitch := EnsureRange(FCWSettings.Pitch, 200, 1200); + FCWSettings.Speed := EnsureRange(FCWSettings.Speed, 5, 60); + FCWSettings.Weight := EnsureRange(FCWSettings.Weight, 33, 66); + FCWSettings.SidetoneHWLevel := EnsureRange(FCWSettings.SidetoneHWLevel, 0, 127); + FCWSettings.SidetoneSWLevel := EnsureRange(FCWSettings.SidetoneSWLevel, 0, 100); + if FWDSPReady and Assigned(FDSPEngine) then + begin + FDSPEngine.SetCWPitch(FCWSettings.Pitch); + FDSPEngine.SetCWSpeed(FCWSettings.Speed); // ширина полосы CW на экране + end; + if Assigned(FCWSender) then + FCWSender.SetSpeed(FCWSettings.Speed, FCWSettings.Weight); + SendDUCSpecificFromSettings; + // Локальный кейер: параметры + вооружение (галку «кейер в программе», как и + // запрет передачи, разбирает SyncCWKeyer — одна дверь на всё). + PushCWLocalConfig; + SyncCWKeyer; + // Декодер слушает на том же pitch и стартует адаптацию со своей скорости. + // Создаём лениво прямо здесь: настройки — единственная дверь, через которую + // он включается (галка в настройках, кнопка в терминале, загрузка профиля). + if FCWSettings.Decoder and (not Assigned(FCWDec)) then + begin + FCWDec := TCWDecoder.Create; + FCWDec.SetSpeedHint(FCWSettings.Speed); + end; + if Assigned(FCWDec) then + begin + FCWDec.SetPitch(FCWSettings.Pitch); + FCWDec.Enabled := FCWSettings.Decoder; + end; + // Тон TUN в телеграфе равен pitch (несущая обязана лечь на VFO, см. SetTune): + // если крутят pitch прямо на настройке — обновляем живьём. + if FTuning and FWDSPReady and Assigned(FDSPEngine) + and (ActiveTXMode in [MODE_CWL, MODE_CWU]) then + FDSPEngine.SetTXTone(True, FCWSettings.Pitch, TUN_TONE_MAG); + if FDevConnected and Assigned(FSettings) then + begin + FSettings.SaveCW(FDevMAC, FCWSettings); + FSettings.Save; + end; +end; + +procedure TRadioController.SyncCWKeyer; +// Байт 5 зависит от режима ИСТОЧНИКА передачи, поэтому каждая смена режима или +// TX-слайса обязана его перепослать: вне телеграфа он должен стать нулевым, +// иначе прошивка поднимет PTT на замыкание ключа посреди SSB. +var + WasArmed: Boolean; +begin + // Дёшево и идемпотентно: пара сравнений плюс ранний выход внутри + // SetCWKeyerArmed. Поэтому зовётся не только на смене режима/источника, но и + // из разбора HP-статуса — вооружение кейера обязано быть САМОВОССТАНАВЛИВАЮЩИМСЯ. + // Разоружить его может что угодно: заход в RX-only слот трансвертера, переход + // на бэнд с DoNotTx, правка Alex. Перечислять такие точки поимённо — гарантия + // однажды пропустить очередную (ровно так уже вышло с DUC-частотой и мощностью). + if (not (ActiveTXMode in [MODE_CWL, MODE_CWU])) + and Assigned(FCWSender) and FCWSender.Busy then CWXAbort; + // Вооружённый кейер = железо может выйти в эфир само ⇒ бэкенд обязан класть + // уровень мощности в КАЖДЫЙ 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-поток. +begin + FCWKeyDown := Down; + if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then + FNetwork.SetCWXKey(Down); +end; + +function TRadioController.CWXBusy: Boolean; +begin + Result := (Assigned(FCWSender) and FCWSender.Busy) + or (Assigned(FCWLocal) and FCWLocal.Busy); +end; + +procedure TRadioController.CWXSend(const Text: string); +// Передача текста. Break-in ВКЛ — PTT поднимает прошивка сама (как на ключ); +// выключен — оператор держит MOX руками, как при ручном T/R. +begin + if Text = '' then Exit; + // CWTXActive, а не просто режим: он же учитывает запрет передачи (RX-only + // слот трансвертера, бэнд с 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); + FCWSender.Enqueue(Text); +end; + +procedure TRadioController.CWXAbort; +begin + if Assigned(FCWSender) then FCWSender.AbortSending; + if Assigned(FCWLocal) then FCWLocal.AbortText; +end; + +function TRadioController.CWXBackspace: Boolean; +begin + if CWLocalSource then + Result := Assigned(FCWLocal) and FCWLocal.Backspace + else + Result := Assigned(FCWSender) and FCWSender.Backspace; +end; + +function TRadioController.CWXPendingText: string; +begin + if CWLocalSource then + begin + if Assigned(FCWLocal) then Result := FCWLocal.PendingText else Result := ''; + end + else + begin + if Assigned(FCWSender) then Result := FCWSender.PendingText else Result := ''; + end; +end; + +procedure TRadioController.CWKeyboardKey(Dot, Dash: Boolean); +// Манипулятор с клавиатуры. У локального генератора это те же лепестки, что с +// COM-порта — включая иамбик. У кейера прошивки лепестков нет: их читает FPGA +// со своего разъёма, а из программы доступен только бит CWX, то есть прямой +// ключ (это же ограничение у Thetis). +begin + if not CWTXActive then + begin + // Разоружены (не CW, запрет передачи) — ключ обязан быть отпущен, иначе + // несущая повиснет при выходе из режима с зажатой клавишей. + if Assigned(FCWLocal) then FCWLocal.Paddle(False, False); + CWXKeyEvent(False); + Exit; + end; + if CWLocalSource then + begin + EnsureCWLocal; + SyncCWKeyer; + FCWLocal.Paddle(Dot, Dash); + end + else + CWXKeyEvent(Dot or Dash); +end; + +procedure TRadioController.SetCWDecoder(On_: Boolean); +begin + FCWSettings.Decoder := On_; + if On_ then + begin + if not Assigned(FCWDec) then FCWDec := TCWDecoder.Create; + FCWDec.SetPitch(FCWSettings.Pitch); + FCWDec.SetSpeedHint(FCWSettings.Speed); // старт адаптации со своей скорости + FCWDec.Reset; + FCWDec.Enabled := True; + end + else if Assigned(FCWDec) then + FCWDec.Enabled := False; + if FDevConnected and Assigned(FSettings) then + begin + FSettings.SaveCW(FDevMAC, FCWSettings); + FSettings.Save; + end; +end; + +function TRadioController.CWDecoderOn: Boolean; +begin + Result := Assigned(FCWDec) and FCWDec.Enabled; +end; + +procedure TRadioController.ServiceCWDecoder; +// Поток хозяина (таймер GUI / цикл демона): разбор накопленного аудио и сборка +// ленты. Здесь же эхо собственной передачи — чтобы в окне была цельная лента +// связи, а не только принятое. +var + S: string; + + procedure Append(const Txt: string; IsTX: Boolean); + begin + if Txt = '' then Exit; + FCWLogText := FCWLogText + Txt; + if Length(FCWLogText) > 8192 then + FCWLogText := Copy(FCWLogText, Length(FCWLogText) - 8191, 8192); + Inc(FCWLogSeq, Length(Txt)); + if Assigned(FOnCWLog) then FOnCWLog(Txt, IsTX); + end; + +begin + // Эхо передачи забираем всегда: терминал показывает свою работу и при + // выключенном декодере. + if Assigned(FCWLocal) then Append(FCWLocal.TakeSentText, True); + if Assigned(FCWSender) then Append(FCWSender.TakeSentText, True); + if not Assigned(FCWDec) then Exit; + if not FCWDec.Enabled then Exit; + FCWDec.Process; + S := FCWDec.TakeText; + Append(S, False); +end; + +function TRadioController.CWLogTail(MaxChars: Integer): string; +begin + if MaxChars <= 0 then Exit(''); + if Length(FCWLogText) <= MaxChars then Result := FCWLogText + else Result := Copy(FCWLogText, Length(FCWLogText) - MaxChars + 1, MaxChars); +end; + +function TRadioController.CWDecSpeedWPM: Integer; +begin + if Assigned(FCWDec) then Result := FCWDec.SpeedWPM else Result := 0; +end; + +function TRadioController.CWDecToneOffsetHz: Integer; +begin + if Assigned(FCWDec) then Result := FCWDec.ToneOffsetHz else Result := 0; +end; + +function TRadioController.CWDecSignal: Boolean; +begin + Result := Assigned(FCWDec) and FCWDec.SignalPresent; +end; + +function TRadioController.MixSidetone(const Left, Right: array of Single; + Count: Integer): Boolean; +// DSP-поток. Генератор синуса на pitch с трапецеидальной огибающей — без неё +// фронт посылки даёт щелчок в наушниках громче самой посылки. Результат +// кладём в свои буферы (входные массивы const), вызывающий отдаёт их дальше. +const + RAMP_MS = 5; + AUDIO_SR = 48000; +var + 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 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); + SetLength(FCWSTBufR, Count); + end; + Amp := (FCWSettings.SidetoneSWLevel / 100.0) * 0.7; + Step := 2.0 * Pi * FCWSettings.Pitch / AUDIO_SR; + Rate := 1000.0 / (RAMP_MS * AUDIO_SR); + for i := 0 to Count - 1 do + begin + 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; + end + else + begin + FCWSTEnv := FCWSTEnv - Rate; + if FCWSTEnv < 0.0 then FCWSTEnv := 0.0; + end; + FCWSTPhase := FCWSTPhase + Step; + if FCWSTPhase > 2.0 * Pi then FCWSTPhase := FCWSTPhase - 2.0 * Pi; + S := Amp * FCWSTEnv * Sin(FCWSTPhase); + FCWSTBufL[i] := EnsureRange(Left[i] + S, -1.0, 1.0); + FCWSTBufR[i] := EnsureRange(Right[i] + S, -1.0, 1.0); + end; + Result := True; +end; + // =========================================================================== // PureSignal — оркестрация (порт state machine Thetis PSForm timer1/timer2) // =========================================================================== @@ -5752,10 +6697,7 @@ begin if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit; if On_ and FTuning then Exit; // не поверх TUN-тона // Safety: те же блокировки, что SetMOX/SetTune. - if On_ and FDevConnected and (FCurrentXvtr < 0) - and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then Exit; - if On_ and (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) - and FXvtrSettings.Entries[FCurrentXvtr].RXOnly then Exit; + if On_ and TXProhibited then Exit; if On_ then begin // Генератор ДО запуска TX-тракта — первые блоки уже с тонами. @@ -5940,8 +6882,17 @@ begin // Сначала отправляем Run=1 — эмулятор/железо создают ddc_specific_thread // (порт 1025) и rx_thread (порт 1035+) только после получения HP Run=1. // DDC Specific и DUC Specific отправляем ПОСЛЕ, иначе порты ещё не открыты. - FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, False, True, True, FTuning, FTwoTone); - FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq), XvtrTranslate(FCenterFreq), FDriveLevel); + // Уровень пересчитываем здесь же: с вооружённым кейером он уедет в железо + // сразу, до всякой передачи, а после конструктора FDriveLevel ещё нулевой. + FDriveLevel := CalcDriveByte; + FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, FDriveLevel, False, True, True, FTuning, FTwoTone); + // ★DUC — на TX-ЧАСТОТУ, а не на центр дисплея. Раньше здесь стоял центр, и до + // первой перестройки VFO (или до первого MOX, который частоту дошлёт сам) + // передатчик стоял не там, где маркер: при CTUN центр и VFO — разные частоты. + // С кейером в прошивке это стало слышно в эфире: железо может замкнуть ключ + // сразу после старта, никакого MOX перед этим не будет. + FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq), + TXTuneFreqHz, FDriveLevel); FRunning := True; FRXStartTime := GetTickCount64; FRXLastPktTime := 0; @@ -5964,7 +6915,15 @@ begin ApplyAntPorts; // QO-100 self-monitor: RX-аудио на TX (full-duplex). По умолчанию заглушено. ApplyRxMuteToEngine; + // Тон телеграфа в движок: на момент ConnectDevice канал WDSP мог быть ещё + // не открыт, а здесь он гарантированно жив. + if FWDSPReady and Assigned(FDSPEngine) then + begin + FDSPEngine.SetCWPitch(FCWSettings.Pitch); + FDSPEngine.SetCWSpeed(FCWSettings.Speed); + end; SendDUCSpecificFromSettings; + SyncCWKeyer; // вооружение кейера + уровень мощности в HP-кадре // Применяем TX-цепь (фильтр, mic gain, EQ, leveler, ALC, comp, ...). ApplyTXSettingsToDSP; ApplyModeFilter; @@ -5991,10 +6950,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.SetRunAndFreq(False, XvtrTranslate(FCenterFreq), XvtrTranslate(FCenterFreq), 0); + FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, 0, False, True, True, FTuning, FTwoTone); + FNetwork.SetRunAndFreq(False, XvtrTranslate(FCenterFreq), + TXTuneFreqHz, 0); FRunning := False; FTransmitting := False; + // ★Локальный кейер обязан разоружиться ЗДЕСЬ: таймер хозяина, который его + // пересинхронизирует, на остановленном радио уже не работает — иначе + // манипулятор продолжал бы гонять IQ в закрытую сессию. + SyncCWKeyer; // Стоп при активном TX/TUN: хвост IQ в DUC-очереди не должен пережить // остановку (иначе при следующем старте уйдёт в эфир старый тон/голос), // а TUN-состояние (PostGen-тон) — вернуться в выключенное. @@ -6175,6 +7139,24 @@ begin FSettings.LoadDevice(FDevMAC, FLoadedGlobal, FBandCache); // TX/Alex/XVTR per-device FSettings.LoadTX(FDevMAC, FTXSettings); + FSettings.LoadCW(FDevMAC, FCWSettings); + if FWDSPReady and Assigned(FDSPEngine) then + begin + FDSPEngine.SetCWPitch(FCWSettings.Pitch); + FDSPEngine.SetCWSpeed(FCWSettings.Speed); + end; + // Декодер приёма — по настройке этого устройства (создаётся лениво). + if FCWSettings.Decoder and (not Assigned(FCWDec)) then + begin + FCWDec := TCWDecoder.Create; + FCWDec.SetSpeedHint(FCWSettings.Speed); + end; + if Assigned(FCWDec) then + begin + FCWDec.SetPitch(FCWSettings.Pitch); + FCWDec.Reset; + FCWDec.Enabled := FCWSettings.Decoder; + end; FSettings.LoadAlex(FDevMAC, FAlexSettings); FSettings.LoadAnt936x(FDevMAC, FAnt936x); FNetwork.SetAlexConfig(FAlexSettings); diff --git a/Settings.pas b/Settings.pas index 9ffbd2d..d2af3f8 100644 --- a/Settings.pas +++ b/Settings.pas @@ -28,6 +28,7 @@ const CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090) + CW_MSG_COUNT = 8; // ячеек памяти телеграфных сообщений (F1..F8) TXPROF_MAX = 16; // потолок именованных TX-профилей на устройство QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100 // (split-LO транспондер; настраивается на @@ -306,6 +307,66 @@ type // (Orion MkII → 5.0, иначе выкл.), 0 = выкл. end; + // ---- CW ------------------------------------------------------------------ + // Телеграф в openHPSDR P2 делает ПРОШИВКА: точки/тире формирует кейер в FPGA, + // мы только отдаём ему параметры (DUC Specific байты 5..13, 17). Поэтому + // почти всё здесь — свойство устройства, а не «звука», и живёт отдельно от + // TTXSettings/TTXProfile (профиль голоса телеграф не переключает). + // Раскладка байта 5 — HPSDRProtocol.CW_*; эталон Thetis (console.cs/setup.cs) + // и pihpsdr (new_protocol.c:1353). + TCWSettings = record + Pitch: Integer; // Гц, тон приёма/сайдтона (Thetis default 600) + // Память сообщений для программной передачи (окно CW Messages, F1..F8). + Messages: array[0..CW_MSG_COUNT-1] of string[63]; + // Манипуляция разрешена вообще (False = телеграф без кейера: MOX + голосом + // ключа нет). ВМЕСТЕ с LocalKeyer образует одну развилку «кто рисует точки», + // в UI это один список: прошивка / программа / выкл. + FWKeyer: Boolean; + KeyerMode: Integer; // 0=Straight, 1=Iambic A, 2=Iambic B + ReverseKeys: Boolean; // поменять точку и тире местами + StrictSpacing: Boolean; // строгие межзнаковые интервалы + Speed: Integer; // WPM, 5..60 + Weight: Integer; // 33..66 (50 = точка:пауза 1:1) + BreakIn: Boolean; // прошивка сама поднимает PTT на нажатие ключа + HangTimeMS: Integer; // сколько держать TX после последнего элемента + RampMS: Integer; // форма фронта посылки (Thetis зашил 9 мс) + RFDelayMS: Integer; // задержка РЧ после PTT (byte 13, реле/усилитель) + SidetoneHW: Boolean; // тон в наушники САМОГО трансивера (байт 5/6) + SidetoneHWLevel: Integer; // 0..127 + // Программный сайдтон — свой генератор в аудиотракте PC. Нужен тем, кто + // слушает через звуковую карту: аппаратный тон туда физически не попадает + // (он подмешивается в наушники трансивера, а не в IQ-поток). + // ★Точен там, где момент нажатия знает САМ PC: программная передача текста + // (CWX) и прямой ключ. При иамбике таймингом владеет FPGA, и PC видит лишь + // состояние лепестков манипулятора — там верен только аппаратный тон. + SidetoneSW: Boolean; + SidetoneSWLevel: Integer; // 0..100 % от полной шкалы аудио + // ---- Локальный (программный) кейер -------------------------------------- + // Манипуляцию формирует САМ 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; + // ---- Окно-терминал: набор с клавиатуры + декодер приёма ---------------- + Decoder: Boolean; // разбирать принимаемый телеграф в текст + DecoderStrip: Boolean; // бегущая лента расшифровки под спектром + KbdKey: Boolean; // Ctrl в окне терминала работает манипулятором + end; + // ---- TX-профили (именованные снимки «как я звучу») ---------------------- // Профиль хранит ТОЛЬКО то, что определяет звук в эфире: маршрутизацию // микрофонного входа, усиление, кромки TX-фильтра, динамическую обработку, @@ -646,6 +707,10 @@ type // в любом случае T заполняется (дефолтами или сохранёнными значениями). function LoadTX(const MAC: array of Byte; out T: TTXSettings): Boolean; procedure SaveTX(const MAC: array of Byte; const T: TTXSettings); + // CW settings per-device (JSON-секция "cw" под MAC). + class procedure DefaultCW(out C: TCWSettings); + function LoadCW(const MAC: array of Byte; out C: TCWSettings): Boolean; + procedure SaveCW(const MAC: array of Byte; const C: TCWSettings); class function MacToStr(const MAC: array of Byte): string; class procedure DefaultBand(BandIdx: Integer; out B: TBandSettings); class procedure DefaultGlobal(out G: TGlobalSettings); @@ -1733,6 +1798,125 @@ begin T.PSOutlierSigma := JD(O,'ps_outlier_sigma', T.PSOutlierSigma); end; +class procedure TSettingsManager.DefaultCW(out C: TCWSettings); +begin + FillChar(C, SizeOf(C), 0); + C.Pitch := 600; // Thetis cw_pitch default + C.FWKeyer := True; // тайминги в FPGA — джиттер PC не влияет + C.KeyerMode := 2; // Iambic B + C.ReverseKeys := False; + C.StrictSpacing := False; + C.Speed := 20; + C.Weight := 50; + C.BreakIn := True; + C.HangTimeMS := 300; + C.RampMS := 9; // Thetis SetCWEdgeLength(9) + C.RFDelayMS := 0; + C.SidetoneHW := True; + C.SidetoneHWLevel := 50; + C.SidetoneSW := False; + C.SidetoneSWLevel := 30; + C.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.Decoder := True; // окно терминала без декодера бессмысленно + C.DecoderStrip := False; // лента под спектром — по желанию + C.KbdKey := False; // Ctrl-манипулятор включают осознанно + // Заводские заготовки: типовой вызов, ответ и стандартные концовки. + C.Messages[0] := 'CQ CQ DE '; + C.Messages[1] := 'DE '; + C.Messages[2] := 'UR RST 599 599 '; + C.Messages[3] := 'TU 73 GL'; + C.Messages[4] := '?'; + C.Messages[5] := 'AGN'; + C.Messages[6] := 'QRZ?'; + C.Messages[7] := 'TEST'; +end; + +function TSettingsManager.LoadCW(const MAC: array of Byte; + out C: TCWSettings): Boolean; +var MacStr: string; DevObj, O: TJSONObject; i: Integer; +begin + DefaultCW(C); + MacStr := MacToStr(MAC); + Result := FRoot.Find(MacStr) <> nil; + if not Result then Exit; + DevObj := GetDevObj(MacStr); + if DevObj.Find('cw') = nil then Exit; // нет секции — остаются дефолты + O := EnsureObj(DevObj, 'cw'); + C.Pitch := EnsureRange(JI(O,'pitch', C.Pitch), 200, 1200); + C.FWKeyer := JB(O,'fw_keyer', C.FWKeyer); + C.KeyerMode := EnsureRange(JI(O,'keyer_mode', C.KeyerMode), 0, 2); + C.ReverseKeys := JB(O,'reverse_keys', C.ReverseKeys); + C.StrictSpacing := JB(O,'strict_spacing',C.StrictSpacing); + C.Speed := EnsureRange(JI(O,'speed', C.Speed), 5, 60); + C.Weight := EnsureRange(JI(O,'weight', C.Weight), 33, 66); + C.BreakIn := JB(O,'break_in', C.BreakIn); + C.HangTimeMS := EnsureRange(JI(O,'hang_time_ms', C.HangTimeMS), 0, 2000); + C.RampMS := EnsureRange(JI(O,'ramp_ms', C.RampMS), 0, 20); + C.RFDelayMS := EnsureRange(JI(O,'rf_delay_ms', C.RFDelayMS), 0, 255); + C.SidetoneHW := JB(O,'sidetone_hw', C.SidetoneHW); + C.SidetoneHWLevel := EnsureRange(JI(O,'sidetone_hw_level', C.SidetoneHWLevel), 0, 127); + C.SidetoneSW := JB(O,'sidetone_sw', C.SidetoneSW); + C.SidetoneSWLevel := EnsureRange(JI(O,'sidetone_sw_level', C.SidetoneSWLevel), 0, 100); + 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); + C.Decoder := JB(O,'decoder', C.Decoder); + C.DecoderStrip := JB(O,'decoder_strip', C.DecoderStrip); + C.KbdKey := JB(O,'kbd_key', C.KbdKey); + for i := 0 to CW_MSG_COUNT - 1 do + C.Messages[i] := Copy(JS(O, 'msg_' + IntToStr(i), C.Messages[i]), 1, 63); +end; + +procedure TSettingsManager.SaveCW(const MAC: array of Byte; + const C: TCWSettings); +var O: TJSONObject; i: Integer; +begin + O := EnsureObj(GetDevObj(MacToStr(MAC)), 'cw'); + JW(O,'pitch', C.Pitch); + JW(O,'fw_keyer', C.FWKeyer); + JW(O,'keyer_mode', C.KeyerMode); + JW(O,'reverse_keys', C.ReverseKeys); + JW(O,'strict_spacing', C.StrictSpacing); + JW(O,'speed', C.Speed); + JW(O,'weight', C.Weight); + JW(O,'break_in', C.BreakIn); + JW(O,'hang_time_ms', C.HangTimeMS); + JW(O,'ramp_ms', C.RampMS); + JW(O,'rf_delay_ms', C.RFDelayMS); + JW(O,'sidetone_hw', C.SidetoneHW); + JW(O,'sidetone_hw_level', C.SidetoneHWLevel); + JW(O,'sidetone_sw', C.SidetoneSW); + JW(O,'sidetone_sw_level', C.SidetoneSWLevel); + 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); + JW(O,'decoder', C.Decoder); + JW(O,'decoder_strip', C.DecoderStrip); + JW(O,'kbd_key', C.KbdKey); + for i := 0 to CW_MSG_COUNT - 1 do + JWS(O, 'msg_' + IntToStr(i), C.Messages[i]); +end; + procedure TSettingsManager.SaveTX(const MAC: array of Byte; const T: TTXSettings); var O: TJSONObject; i: Integer; diff --git a/SettingsForm.pas b/SettingsForm.pas index 9a6886b..b7d7cbf 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -59,6 +59,9 @@ type TOnCalibrationChange = procedure(const C: TCalibration) of object; // TX-настройки целиком (любая правка → один callback с актуальной структурой) TOnTXSettingsChange = procedure(const T: TTXSettings) of object; + TOnCWSettingsChange = procedure(const C: TCWSettings) of object; + TOnCWMessagesOpen = procedure of object; + TOnCWTerminalOpen = procedure of object; // TX-профили: выбор/переименование/удаление по индексу, создание — по имени. TOnTXProfileIdx = procedure(Idx: Integer) of object; TOnTXProfileName = procedure(const AName: string) of object; @@ -285,6 +288,37 @@ type // CTCSS FChkTXCTCSSOn: TFlatCheckBox; FEdTXCTCSSFreq: TFlatFloatSpinEdit; + // CW (телеграф) — свойство устройства, профилем не переключается + FCW: TCWSettings; + // Кто рисует точки: прошивка / программа / никто. Одна развилка — один + // список (две галки на неё разъезжались во взаимоисключающих состояниях). + FCmbCWKeyerSrc: TFlatComboBox; + FEdCWPitch: TFlatSpinEdit; + FEdCWSpeed: TFlatSpinEdit; + FEdCWWeight: TFlatSpinEdit; + FCmbCWKeyerMode: TFlatComboBox; + FChkCWReverse: TFlatCheckBox; + FChkCWStrict: TFlatCheckBox; + FChkCWBreakIn: TFlatCheckBox; + FEdCWHang: TFlatSpinEdit; + FEdCWRamp: TFlatSpinEdit; + FEdCWRFDelay: TFlatSpinEdit; + FChkCWSidetoneHW: TFlatCheckBox; + FEdCWSidetoneHWLv: TFlatSpinEdit; + FChkCWSidetoneSW: TFlatCheckBox; + FEdCWSidetoneSWLv: TFlatSpinEdit; + // Локальный кейер: манипуляцию рисует сам EWSDR (обязателен на AD936x) + FEdCWOffset: TFlatSpinEdit; + FChkCWKeyPort: TFlatCheckBox; + FEdCWKeyPort: TFlatEdit; + FCmbCWDotLine: TFlatComboBox; + FCmbCWDashLine: TFlatComboBox; + FChkCWDecoder: TFlatCheckBox; + FChkCWDecStrip: TFlatCheckBox; + FChkCWKeyInvert: TFlatCheckBox; + FChkCWKeyDTR: TFlatCheckBox; + FChkCWKeyRTS: TFlatCheckBox; + FCWHasFWKeyer: Boolean; // у железа есть кейер в прошивке // TX Display (отдельный analyzer) FCmbTXFFT: TFlatComboBox; FCmbTXWindow: TFlatComboBox; @@ -446,6 +480,9 @@ type FOnWfAGCNFChange: TOnWfAGCNFChange; FOnFreqMhzDigitsChange: TOnFreqMhzDigitsChange; FOnTXChange: TOnTXSettingsChange; + FOnCWChange: TOnCWSettingsChange; + FOnCWMessagesOpen: TOnCWMessagesOpen; + FOnCWTerminalOpen: TOnCWTerminalOpen; FOnTXProfileSelect: TOnTXProfileIdx; FOnTXProfileAdd: TOnTXProfileName; FOnTXProfileRename: TOnTXProfileIdxName; @@ -500,6 +537,11 @@ type procedure CollectTXFromUI; procedure FireTXChange; procedure OnTXAnyChange(Sender: TObject); + procedure CollectCWFromUI; + procedure FireCWChange; + procedure OnCWAnyChange(Sender: TObject); + procedure BtnCWMessagesClick(Sender: TObject); + procedure BtnCWTerminalClick(Sender: TObject); procedure OnMicJackChange(Sender: TObject); procedure UpdateMicJackVisibility; // Подвкладки и полоса профиля на странице Transmit @@ -632,6 +674,9 @@ type const SerAndromeda: array of Boolean; 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; @@ -678,6 +723,9 @@ type property OnThemeChange: TOnThemeChange read FOnThemeChange write FOnThemeChange; property OnFreqMhzDigitsChange: TOnFreqMhzDigitsChange read FOnFreqMhzDigitsChange write FOnFreqMhzDigitsChange; property OnTXChange: TOnTXSettingsChange read FOnTXChange write FOnTXChange; + property OnCWChange: TOnCWSettingsChange read FOnCWChange write FOnCWChange; + property OnCWMessagesOpen: TOnCWMessagesOpen read FOnCWMessagesOpen write FOnCWMessagesOpen; + property OnCWTerminalOpen: TOnCWTerminalOpen read FOnCWTerminalOpen write FOnCWTerminalOpen; property OnTXProfileSelect: TOnTXProfileIdx read FOnTXProfileSelect write FOnTXProfileSelect; property OnTXProfileAdd: TOnTXProfileName read FOnTXProfileAdd write FOnTXProfileAdd; property OnTXProfileRename: TOnTXProfileIdxName read FOnTXProfileRename write FOnTXProfileRename; @@ -1795,6 +1843,27 @@ var Result := MakeCombo(AParent, AX, AY, AW, OnTXAnyChange); end; + // Телеграф едет отдельной записью (TCWSettings) и своим событием — иначе + // правка скорости кейера дёргала бы пересборку всей TX-цепи WDSP. + function MkSpinCW(AParent: TWinControl; AX, AY: Integer; + AMin, AMax, AVal: Integer): TFlatSpinEdit; + begin + Result := MkSpin(AParent, AX, AY, AMin, AMax, AVal); + Result.OnChange := OnCWAnyChange; + end; + + function MkChkCW(AParent: TWinControl; AX, AY, AW: Integer; + const ACap: string): TFlatCheckBox; + begin + Result := MkChk(AParent, AX, AY, AW, ACap); + Result.OnChange := OnCWAnyChange; + end; + + function MkCmbCW(AParent: TWinControl; AX, AY, AW: Integer): TFlatComboBox; + begin + Result := MakeCombo(AParent, AX, AY, AW, OnCWAnyChange); + end; + procedure TxNote(AParent: TWinControl; const AText: string; ATop, AHeight: Integer); var @@ -1991,7 +2060,88 @@ begin TxNote(Grp, 'Skirt steepness and path latency, not timbre — hence outside ' + 'the profile.', R1 + 3 * STEP - 2, 22); + // ---- CW: телеграфом в openHPSDR P2 управляет прошивка ------------------- Inc(Y, 190 + 12); + Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 502); + + 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, 100, 'Messages...', BtnCWMessagesClick); + MkBtn(Grp, PAD + 366, R1 - 6, 100, 'Terminal...', BtnCWTerminalClick); + MakeLbl(Grp, 'Pitch (Hz)', PAD + 480, R1 - 1, 90); + FEdCWPitch := MkSpinCW(Grp, PAD + 574, R1 - 6, 200, 1200, 600); + + MakeLbl(Grp, 'Keyer mode', PAD, R1 + STEP + 5, LW); + FCmbCWKeyerMode := MkCmbCW(Grp, CX, R1 + STEP, 200); + FCmbCWKeyerMode.Items.AddStrings(['Straight key','Iambic A','Iambic B']); + FCmbCWKeyerMode.ItemIndex := 2; + FChkCWReverse := MkChkCW(Grp, PAD + 400, R1 + STEP, 150, 'Reverse paddles'); + FChkCWStrict := MkChkCW(Grp, PAD + 560, R1 + STEP, 160, 'Strict spacing'); + + MakeLbl(Grp, 'Speed (WPM)', PAD, R1 + 2 * STEP + 5, LW); + FEdCWSpeed := MkSpinCW(Grp, CX, R1 + 2 * STEP, 5, 60, 20); + MakeLbl(Grp, 'Weight', PAD + 320, R1 + 2 * STEP + 5, 70); + FEdCWWeight := MkSpinCW(Grp, PAD + 400, R1 + 2 * STEP, 33, 66, 50); + MakeLbl(Grp, 'Edge (ms)', PAD + 520, R1 + 2 * STEP + 5, 80); + FEdCWRamp := MkSpinCW(Grp, PAD + 604, R1 + 2 * STEP, 0, 20, 9); + + FChkCWBreakIn := MkChkCW(Grp, PAD, R1 + 3 * STEP, 150, 'Break-in'); + MakeLbl(Grp, 'Hang (ms)', PAD + 320, R1 + 3 * STEP + 5, 80); + FEdCWHang := MkSpinCW(Grp, PAD + 400, R1 + 3 * STEP, 0, 2000, 300); + MakeLbl(Grp, 'RF delay', PAD + 520, R1 + 3 * STEP + 5, 80); + FEdCWRFDelay := MkSpinCW(Grp, PAD + 604, R1 + 3 * STEP, 0, 255, 0); + + FChkCWSidetoneHW := MkChkCW(Grp, PAD, R1 + 4 * STEP, 190, 'Sidetone in radio'); + MakeLbl(Grp, 'Level', PAD + 196, R1 + 4 * STEP + 5, 44); + FEdCWSidetoneHWLv := MkSpinCW(Grp, PAD + 244, R1 + 4 * STEP, 0, 127, 50); + FChkCWSidetoneSW := MkChkCW(Grp, PAD + 362, R1 + 4 * STEP, 200, 'Sidetone in PC audio'); + MakeLbl(Grp, 'Level', PAD + 568, R1 + 4 * STEP + 5, 44); + FEdCWSidetoneSWLv := MkSpinCW(Grp, PAD + 616, R1 + 4 * STEP, 0, 100, 30); + + // ---- Программный кейер: манипуляцию рисует сам 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); + FChkCWDecoder := MkChkCW(Grp, PAD, R1 + 6 * STEP, 160, 'Decode RX'); + FChkCWDecStrip := MkChkCW(Grp, PAD + 176, R1 + 6 * STEP, 210, 'Text under spectrum'); + + FChkCWKeyPort := MkChkCW(Grp, PAD, R1 + 7 * STEP, 250, 'Paddle on serial port'); + FEdCWKeyPort := TFlatEdit.Create(Self); + FEdCWKeyPort.Parent := Grp; + FEdCWKeyPort.SetBounds(DpiScale(PAD + 260), DpiScale(R1 + 7 * 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 + 7 * STEP + 5, 200); + + MakeLbl(Grp, 'Dot line', PAD, R1 + 8 * STEP + 5, 70); + FCmbCWDotLine := MkCmbCW(Grp, PAD + 74, R1 + 8 * STEP, 90); + FCmbCWDotLine.Items.AddStrings(['CTS','DSR','DCD','RI']); + FCmbCWDotLine.ItemIndex := 0; + MakeLbl(Grp, 'Dash line', PAD + 176, R1 + 8 * STEP + 5, 74); + FCmbCWDashLine := MkCmbCW(Grp, PAD + 254, R1 + 8 * STEP, 90); + FCmbCWDashLine.Items.AddStrings(['CTS','DSR','DCD','RI']); + FCmbCWDashLine.ItemIndex := 1; + FChkCWKeyInvert := MkChkCW(Grp, PAD + 360, R1 + 8 * STEP, 90, 'Invert'); + FChkCWKeyDTR := MkChkCW(Grp, PAD + 456, R1 + 8 * STEP, 100, 'DTR power'); + FChkCWKeyRTS := MkChkCW(Grp, PAD + 562, R1 + 8 * 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 + 9 * STEP - 6, 92); + + Inc(Y, 502 + 12); Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250); MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW); @@ -2179,6 +2329,137 @@ begin FireTXChange; end; +procedure TSettingsForm.CollectCWFromUI; +var Src: Integer; +begin + // Список «кто рисует точки» → пара флагов. На железе без кейера в прошивке + // первого пункта в списке нет вовсе, поэтому индексы сдвинуты на один. + 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; + if FChkCWStrict <> nil then FCW.StrictSpacing := FChkCWStrict.Checked; + if FEdCWSpeed <> nil then FCW.Speed := FEdCWSpeed.Value; + if FEdCWWeight <> nil then FCW.Weight := FEdCWWeight.Value; + if FChkCWBreakIn <> nil then FCW.BreakIn := FChkCWBreakIn.Checked; + if FEdCWHang <> nil then FCW.HangTimeMS := FEdCWHang.Value; + if FEdCWRamp <> nil then FCW.RampMS := FEdCWRamp.Value; + if FEdCWRFDelay <> nil then FCW.RFDelayMS := FEdCWRFDelay.Value; + if FChkCWSidetoneHW <> nil then FCW.SidetoneHW := FChkCWSidetoneHW.Checked; + 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 FChkCWDecoder <> nil then FCW.Decoder := FChkCWDecoder.Checked; + if FChkCWDecStrip <> nil then FCW.DecoderStrip := FChkCWDecStrip.Checked; + 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; +begin + if FLoading then Exit; + CollectCWFromUI; + if Assigned(FOnCWChange) then FOnCWChange(FCW); +end; + +procedure TSettingsForm.OnCWAnyChange(Sender: TObject); +begin + FireCWChange; +end; + +procedure TSettingsForm.BtnCWMessagesClick(Sender: TObject); +begin + if Assigned(FOnCWMessagesOpen) then FOnCWMessagesOpen; +end; + +procedure TSettingsForm.BtnCWTerminalClick(Sender: TObject); +begin + if Assigned(FOnCWTerminalOpen) then FOnCWTerminalOpen; +end; + +procedure TSettingsForm.LoadCWSettings(const C: TCWSettings); +var Src: Integer; +begin + FLoading := True; + try + FCW := C; + 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; + if FChkCWStrict <> nil then FChkCWStrict.Checked := C.StrictSpacing; + if FEdCWSpeed <> nil then FEdCWSpeed.Value := EnsureRange(C.Speed, 5, 60); + if FEdCWWeight <> nil then FEdCWWeight.Value := EnsureRange(C.Weight, 33, 66); + if FChkCWBreakIn <> nil then FChkCWBreakIn.Checked := C.BreakIn; + if FEdCWHang <> nil then FEdCWHang.Value := EnsureRange(C.HangTimeMS, 0, 2000); + if FEdCWRamp <> nil then FEdCWRamp.Value := EnsureRange(C.RampMS, 0, 20); + if FEdCWRFDelay <> nil then FEdCWRFDelay.Value := EnsureRange(C.RFDelayMS, 0, 255); + if FChkCWSidetoneHW <> nil then FChkCWSidetoneHW.Checked := C.SidetoneHW; + 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 FChkCWDecoder <> nil then FChkCWDecoder.Checked := C.Decoder; + if FChkCWDecStrip <> nil then FChkCWDecStrip.Checked := C.DecoderStrip; + 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; +end; + procedure TSettingsForm.UpdateMicJackVisibility; var LineIn: Boolean; begin diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 6607bc1..03d8751 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -472,6 +472,8 @@ type FLastSpectrumW: Integer; // последняя известная ширина спектра (пикс) FDisplayResLimit: Integer; // потолок точек анализатора (настройка display_pixels) FShiftHz: Double; // NCO shift для CTUN + FCWPitch: Integer; // Гц, тон телеграфа (см. CWLOOffset) + FCWSpeed: Integer; // WPM — только для занимаемой полосы на экране FNRMode: Integer; FNBMode: Integer; FSNBEnabled: Boolean; @@ -505,6 +507,14 @@ type // Порт Thetis UpdateTXLowHighFilterForMode: единственный конвертер знака, // через который обязан идти ЛЮБОЙ пуш TX-bandpass, иначе LSB уходит в USB. procedure TXSignedEdges(Mode: Integer; out Lo, Hi: Double); + // --- CW pitch ----------------------------------------------------------- + // Смещение гетеродина демодулятора относительно VFO в телеграфе: станция, + // стоящая ТОЧНО на частоте VFO, обязана звучать тоном CWPitch, а не нулём. + // CWL слушаем ниже (гетеродин выше на pitch), CWU — наоборот. Всё остальное + // в проекте (кромки фильтра, полоска на спектре, маркер VFO) остаётся в + // координатах «относительно VFO»: конвертер ровно один — эти три метода. + procedure PushShift(Chan: Integer; ShiftHz: Double; Mode: Integer); + procedure PushPassband(Chan, Lo, Hi, Mode, Rate: Integer); // Mode-dependent TX overlay. FM RAW uses the flat WFM modulator and // bypasses every speech processor without overwriting the saved TX setup. procedure ApplyTXModeSettings(Mode: Integer); @@ -641,6 +651,17 @@ type procedure SetSpectrumWidth(W: Integer); // обновляет FLastSpectrumW для корректных AGC линий procedure SetDisplayResLimit(V: Integer); // потолок точек анализатора (1024/2048/4096) procedure SetShift(ShiftHz: Double); // CTUN NCO сдвиг + // Тон телеграфа. Меняет положение гетеродина и полосы во ВСЕХ CW-каналах — + // применяется немедленно (главный + слайсы), персист делает контроллер. + procedure SetCWPitch(Hz: Integer); + function CWPitch: Integer; + // Скорость кейера нужна движку ровно для одного — ширины полосы телеграфа + // на экране (TXFilterEdgesHz). Тайминги делает прошивка, не мы. + procedure SetCWSpeed(WPM: Integer); + // Смещение гетеродина демодулятора относительно VFO в телеграфе (см. + // комментарий у PushShift). Публично: тем же сдвигом контроллер двигает + // DUC при настройке TUN, чтобы несущая легла ровно на VFO. + function CWLOOffset(Mode: Integer): Double; procedure SetNRMode(Mode: Integer); procedure SetNR(Enable: Boolean); procedure SetNBMode(Mode: Integer); @@ -1231,6 +1252,35 @@ begin end; end; +function TWDSPEngine.CWLOOffset(Mode: Integer): Double; +// Знак — как в Thetis (console.cs:31757 rx_freq += pitch для CWL) и pihpsdr +// (new_protocol.c:773 rxFrequency -= pitch для CWU). +begin + case Mode of + MODE_CWL: Result := FCWPitch; + MODE_CWU: Result := -FCWPitch; + else Result := 0.0; + end; +end; + +procedure TWDSPEngine.PushShift(Chan: Integer; ShiftHz: Double; Mode: Integer); +// ShiftHz приходит в координатах «VFO минус центр DDC»; в эфир гетеродин +// уезжает ещё на CWLOOffset, чтобы принимаемая посылка легла на pitch. +begin + SetRXAShiftFreq(Chan, ShiftHz + CWLOOffset(Mode)); +end; + +procedure TWDSPEngine.PushPassband(Chan, Lo, Hi, Mode, Rate: Integer); +// Кромки хранятся относительно VFO (у CW — симметрично нулю: «500 Гц вокруг +// корреспондента»). В baseband демодулятора они уезжают навстречу гетеродину, +// т.е. у CWU получается pitch±bw/2 — ровно таблица Thetis (console.cs:5338). +var Off: Integer; +begin + Off := Round(CWLOOffset(Mode)); + RXASetPassband(Chan, ClampToNyquist(Lo - Off, Rate), + ClampToNyquist(Hi - Off, Rate)); +end; + procedure TWDSPEngine.ApplyTXModeSettings(Mode: Integer); begin if not FInitialized then Exit; @@ -1323,6 +1373,8 @@ begin FFilterHigh := 2400; FAGCMode := agcMedium; FShiftHz := 0.0; + FCWPitch := 600; + FCWSpeed := 20; FAGCTop := -90.0; FVolume := 0.7; FSMeter := -130.0; @@ -1932,8 +1984,8 @@ begin SetRXABandpassWindow(RXA_CHAN, 1); SetRXAMode(RXA_CHAN, ModeToWDSP(FMode)); SetRXAShiftRun(RXA_CHAN, 1); - SetRXAShiftFreq(RXA_CHAN, FShiftHz); - RXASetPassband(RXA_CHAN, FFilterLow, FFilterHigh); + PushShift(RXA_CHAN, FShiftHz, FMode); + PushPassband(RXA_CHAN, FFilterLow, FFilterHigh, FMode, FRXDSPRate); SetAGC(FAGCMode, 50.0); SetRXAPanelGain1(RXA_CHAN, FVolume); SetRXAPanelSelect(RXA_CHAN, 3); @@ -2457,9 +2509,9 @@ begin SetRXAMode(Chan, ModeToWDSP(FSlices[Idx].Mode)); ApplyRXModeSettings(Chan, FSlices[Idx].Mode); SetRXAShiftRun(Chan, 1); - SetRXAShiftFreq(Chan, FSlices[Idx].ShiftHz); - RXASetPassband(Chan, ClampToNyquist(FSlices[Idx].FilterLo, DSPRate), - ClampToNyquist(FSlices[Idx].FilterHi, DSPRate)); + PushShift(Chan, FSlices[Idx].ShiftHz, FSlices[Idx].Mode); + PushPassband(Chan, FSlices[Idx].FilterLo, FSlices[Idx].FilterHi, + FSlices[Idx].Mode, DSPRate); ApplyAGCToChan(Chan, FSlices[Idx].AGCMode, 50.0); // NR/NB/SNB/ANF из сохранённого состояния слайса. ApplyNRToChan(Chan, FSlices[Idx].NRMode); @@ -2733,7 +2785,8 @@ begin try idx := FindSliceIdx(Id); if idx < 0 then Exit; FSlices[idx].ShiftHz := ShiftHz; - if FSlices[idx].Opened then SetRXAShiftFreq(FSlices[idx].Chan, ShiftHz); + if FSlices[idx].Opened then + PushShift(FSlices[idx].Chan, ShiftHz, FSlices[idx].Mode); finally FSliceLock.Leave; end; @@ -2768,6 +2821,14 @@ begin // шумодава в не-FM режимах нет); вернулись в FM — восстановить выбор. ApplySliceFMSQ(idx); end; + // Приход/уход телеграфа меняет CWLOOffset — гетеродин и полосу надо + // переложить под новый режим, даже если канал не пересоздавали. + if FSlices[idx].Opened then + begin + PushShift(FSlices[idx].Chan, FSlices[idx].ShiftHz, Mode); + PushPassband(FSlices[idx].Chan, FSlices[idx].FilterLo, FSlices[idx].FilterHi, + Mode, SliceDSPRateFor(Mode, SliceInRate(idx))); + end; finally FSliceLock.Leave; end; @@ -2784,8 +2845,7 @@ begin if FSlices[idx].Opened then begin Rate := SliceDSPRateFor(FSlices[idx].Mode, SliceInRate(idx)); - RXASetPassband(FSlices[idx].Chan, ClampToNyquist(Low, Rate), - ClampToNyquist(High, Rate)); + PushPassband(FSlices[idx].Chan, Low, High, FSlices[idx].Mode, Rate); end; finally FSliceLock.Leave; @@ -2965,6 +3025,9 @@ begin ApplyDSPRate(Mode); SetRXAMode(RXA_CHAN, ModeToWDSP(Mode)); ApplyRXModeSettings(RXA_CHAN, Mode); + // Вход/выход из телеграфа меняет CWLOOffset: гетеродин надо переложить сразу + // (кромки приедут из UI следом, но сдвиг оттуда не приходит). + PushShift(RXA_CHAN, FShiftHz, Mode); ApplyTXMode(FTXMode); // НЕ вызываем ApplyDefaultFilter — фильтр устанавливается явно из UI end; @@ -2978,9 +3041,7 @@ begin // до ±100 кГц, а dsp_rate зависит от SampleRate. Клампим только то, что // уходит в WDSP — запрошенные значения храним как есть, иначе понижение // SampleRate необратимо «съело» бы полосу. - // RXASetPassband — правильный unified API, пересчитывает фильтр целиком - RXASetPassband(RXA_CHAN, ClampToNyquist(Low, FRXDSPRate), - ClampToNyquist(High, FRXDSPRate)); + PushPassband(RXA_CHAN, Low, High, FMode, FRXDSPRate); end; procedure TWDSPEngine.SetAGC(Mode: TWDSPAGCMode; FixedGain: Double); @@ -2999,7 +3060,46 @@ procedure TWDSPEngine.SetShift(ShiftHz: Double); begin FShiftHz := ShiftHz; if not FInitialized then Exit; - SetRXAShiftFreq(RXA_CHAN, ShiftHz); + PushShift(RXA_CHAN, ShiftHz, FMode); +end; + +function TWDSPEngine.CWPitch: Integer; +begin + Result := FCWPitch; +end; + +procedure TWDSPEngine.SetCWPitch(Hz: Integer); +var i, Rate: Integer; +begin + if Hz = FCWPitch then Exit; + FCWPitch := Hz; + if not FInitialized then Exit; + if FMode in [MODE_CWL, MODE_CWU] then + begin + PushShift(RXA_CHAN, FShiftHz, FMode); + PushPassband(RXA_CHAN, FFilterLow, FFilterHigh, FMode, FRXDSPRate); + end; + FSliceLock.Enter; + try + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and FSlices[i].Opened and + (FSlices[i].Mode in [MODE_CWL, MODE_CWU]) then + begin + Rate := SliceDSPRateFor(FSlices[i].Mode, SliceInRate(i)); + PushShift(FSlices[i].Chan, FSlices[i].ShiftHz, FSlices[i].Mode); + PushPassband(FSlices[i].Chan, FSlices[i].FilterLo, FSlices[i].FilterHi, + FSlices[i].Mode, Rate); + end; + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetCWSpeed(WPM: Integer); +begin + if WPM < 5 then WPM := 5; + if WPM > 60 then WPM := 60; + FCWSpeed := WPM; end; procedure TWDSPEngine.SetAGCTop(TopDBm: Double); @@ -3733,6 +3833,17 @@ begin MODE_FM: Half := FTXFMDeviation + FTXFMHighCut; MODE_WFM: Half := WFM_DEVIATION + WFM_AF_HIGH; MODE_FMRAW: Half := FRawFMDeviation + RAW_AF_HIGH; + // ★Телеграф. TXSignedEdges отдаёт для CWL/CWU ГОЛОСОВУЮ боковую (bp0 обязан + // оставаться открытым: через него идёт тон pitch при настройке TUN), но в + // эфир уходит манипулируемая НЕСУЩАЯ на самом VFO. Рисовать её юбкой SSB + // нельзя — полоса выглядела как в SSB и уезжала вбок от несущей. + // Занимаемая полоса телеграфа: B = WPM/1.2 бод, BW ≈ 5·B при мягкой + // манипуляции (фронт CWRampPeriod). Симметрично несущей. + MODE_CWL, MODE_CWU: + begin + Half := 5.0 * (FCWSpeed / 1.2) / 2.0; + if Half < 25.0 then Half := 25.0; // не вырождаться в нулевую полоску + end; end; if Half > 0.0 then begin diff --git a/ewsdr.lpi b/ewsdr.lpi index 9b5246a..28fef8b 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -308,6 +308,26 @@ + + + + + + + + + + + + + + + + + + + + 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