Files
ewsdr/RadioController.pas
T
ew8bakandClaude Opus 5 84b90b23c0 feat(tci): KEYER — чужой ключ очередью элементов, а не сетевыми фронтами
Последняя невыполненная команда протокола. Ключ к ней в том, что arg3 —
длительность интервала, который ТОЛЬКО ЧТО кончился, а не начинающегося.
Документ задаёт это алгоритмом: первое нажатие keyer:0,true,0, отпускание
keyer:0,false,142 («посылка длилась 142 мс»), следующее нажатие
keyer:0,true,58 («пауза длилась 58 мс»). Отсюда перевод: тип элемента — это
состояние ключа ДО фронта, то есть обратное пришедшему; arg3 = 0 играть нечего.

Почему не дёргать ключ по приходу пакета: приход говорит, что интервал
кончился, а не сколько он длился, и манипуляция «по приходу» — это сетевой
джиттер прямо в эфир, тот самый «пьяный матрос», ради которого третий аргумент
в протоколе и появился. Новый TCWElemPlayer (CWMorse.pas) держит очередь
элементов и играет их подряд по абсолютным дедлайнам: сумма длительностей
равна времени у клиента, значит отставание постоянно (сеть + один элемент) и
не накапливается. Очередь опустела — ключ отпускается и следующая пачка
начинается с чистого дедлайна: элементы чередуются, искажения нет, зато
оборвавшийся клиент не оставляет в эфире несущую.

Контроллер: CWKeyerElement(Mark, Ms) ставит элемент в очередь, CWElemKey
раздаёт фронты — чужая манипуляция это прямой ключ с точными длительностями,
поэтому у Pluto она идёт во вход прямого ключа локального генератора (он сам
поднимает сессию, рисует огибающую и сайдтон), а у openHPSDR в бит CWX
прошивки (тем же путём идёт передача текста). Гейт CWTXActive, как у CWXSend;
обрыв общий с текстом — касание манипулятора, снятие MOX и уход из телеграфа
гасят чужую манипуляцию тем же CWXAbort. Передачу KEYER не поднимает: при
break-in PTT даёт прошивка (или сессия генератора), без него оператор держит
MOX сам.

Адаптер: номер передатчика разбирается как у TRX (bad receiver / receiver is
not running), захват §3.5 общий с TRX — передатчик один, и ключ держит тот же,
кто держит эфир. Паузы обрезаются TCI_KEYER_GAP_MAX_MS = 1 с (пауза целиком
прибавляется к отставанию от клиента, а дольше секунды — это «оператор
задумался», и честнее догнать реальное время), посылки — 5 с.

Стенд test/tci: 238 проверок (было 219). Новая часть C2 меряет ДЛИТЕЛЬНОСТИ по
фронтам ключа (посылка 150 / пауза 60 / посылка 150, допуск 30 мс), проверяет
отпускание на пустой очереди, старт следующей пачки без «догона» дедлайна,
обрыв и нулевую длительность; в части D — разбор аргументов команды и сквозная
проверка, что keyer:0,true,<мс> ключ не замыкает (это пауза), а
keyer:0,false,<мс> замыкает. Негативный контроль на инверсию перевода.
★Локальный генератор вооружается только при живом устройстве, поэтому сквозная
проверка подставляет FDevConnected/FRunning на время.

doc/TCI.md: §2.6 описывает команду целиком, §3.1 и §4 переписаны под то, что из
пары KEYER/TX_FOOTSWITCH остался только второй; попутно убран устаревший абзац
§2.3 про «потоки — этап 2».

На железе с настоящим ключом по сети ещё не гонялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:46:37 +03:00

8262 lines
406 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit RadioController;
{
EWSDR — Radio Controller (headless ядро)
Назначение
----------
TRadioController — «мозг» трансивера БЕЗ привязки к UI/LCL-формам. Владеет
состоянием радио и движками (сеть HPSDR, WDSP, аудио, настройки, каналы) и
предоставляет единый интерфейс управления для ЛЮБОГО фронтенда: GUI, Web, CAT,
аппаратная панель Arduino.
• Команды (SetMode, SetVfoA, TuneActiveBy, StartStop, …) — единая точка входа.
Абсолютные (Set*) удобны UI/Web; относительные (*By/Up/Down) — энкодерам.
• GetSnapshot → TRadioSnapshot — весь стейт одной записью (синхронизация
нового фронтенда при подключении: LED/дисплеи панели, веб-клиент).
• OnStateChanged(Field) — гранулярное уведомление об изменении поля; все
фронтенды остаются синхронны, кто бы ни инициировал изменение.
Маршалинг потоков
-----------------
Команды из сетевых/serial/web-потоков идут через OnInvoke (GUI = обёртка над
TThread.Synchronize; демон = своя очередь/прямой вызов), чтобы вызовы движка
шли из одного потока без зависимости от GUI-цикла LCL.
СТАТУС МИГРАЦИИ (Фаза 1)
------------------------
Состояние и движки вынесены сюда как ПУБЛИЧНЫЕ ПОЛЯ с теми же именами, что были
в TMainForm (FVfoA, FNetwork, FDSPEngine, …), чтобы проводка MainForm свелась к
механической замене FXxx -> FController.FXxx без изменения поведения. Логика
операций (ApplyModeFilter, BtnStartStopClick, ApplyVfoA, …) пока остаётся в
MainForm; команды контроллера обновляют поле + шлют событие и будут наполнены
логикой на следующих фазах (помечено TODO-wiring). Поля станут приватными,
когда MainForm перестанет писать в них напрямую.
ВЛАДЕНИЕ ДВИЖКАМИ (Фаза 1): объекты создаёт и освобождает MainForm (в
FormCreate/FormDestroy), сохраняя в поля контроллера. Перенос создания в
Create/Destroy контроллера — отдельная фаза (нужен для режима демона).
FPC 3.2+ / режим Delphi. Без inline var и анонимных процедур.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, Math, SyncObjs,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer,
CWDecoder;
// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
// Допустимые rate пан-DDC — полная сетка железа Caps.RatePresets (кратные
// 48 кГц — требование движка), см. PanRateAllowed. Дефолт/фолбэк — 384 кГц.
type
// QO-100 beacon lock — статус контура (для UI/маркеров).
TBeaconState = (bsIdle, // лок выключен
bsSearch, // включён, маяк ещё не подтверждён (низкий prominence)
bsLock); // маяк уверенно виден и сопровождается
// Поле, которое изменилось — для гранулярных уведомлений наружу.
TRadioField = (
rfVfoA, rfVfoB, rfActiveVfo, rfVfoLock,
rfMode, rfFilter, rfFilterBW,
rfAGCMode, rfAGCTop,
rfRxGain, // Pluto: hw-gain (auto/manual dB)
rfVolume, rfMute, rfDrive, rfAtten,
rfBand, rfXvtr, rfBandRestore, // rfBandRestore: явная смена диапазона (сброс Wf-avg в UI)
rfCenterFreq, rfSpan, rfSampleRate, rfZoom,
rfCTun, rfDuplex, rfRxMuteOnTx, // rfRxMuteOnTx: QO-100 self-monitor toggle
rfRunning, rfConnected, rfTransmitting, rfTuning,
rfPureSignal, rfTwoTone, // PureSignal вкл/статус; 2TON вкл/выкл
rfNR, rfNB, rfSNB, rfANF,
rfWfAGC, rfWfNF, rfDither, rfRandom,
rfFMDeviation, rfFMCTCSS, rfFMCTCSSTone, rfFMSQ, rfFMSQLevel,
rfFMStep, rfFMStepIdx, rfFMRpt,
rfChannel,
rfTXProfile, // сменился активный TX-профиль
rfSMeter, rfPower, rfSWR, rfSupply, rfPLL, // телеметрия
rfADCOverload, // ADC overload (рендер индикатора)
rfBeaconLock, // QO-100 beacon lock: статус/коррекция
rfDevice, // подключённое устройство сменилось
rfDeviceList, // список discovered устройств обновился
rfPanFreq, // центр доп. пана уехал (ретюн DDC извне)
rfSliceFreq, // слайс перестроен извне (CAT); Id — FSliceFreqId
rfSliceState, // у слайса сменились мода/фильтр/АРУ/DSP/громкость; Id — FSliceFreqId
rfCWSettings, // правка телеграфа (скорость, задержка, pitch…)
rfMonVolume // громкость self-monitor'а (TX), отдельно от rfVolume
);
TRadioStateEvent = procedure(Sender: TObject; Field: TRadioField) of object;
// Маршалинг произвольного метода в поток контроллера (GUI: TThread.Synchronize).
TUIInvokeEvent = procedure(M: TThreadMethod) of object;
// Перехват RX-аудио потребителем (web-адаптер): вернуть True, если аудио
// отправлено клиенту и НЕ должно идти в локальный AudioOut. Вызывается из
// DSP-потока (как сам OnAudioReady) — реализация должна быть thread-safe.
TAudioConsumeEvent = function(const Left, Right: array of Single;
Count: Integer): Boolean of object;
// Тап RX-аудио: «послушать, не забирая», в отличие от OnAudioConsume. Нужен
// потокам TCI (§3.4): их может быть несколько, и ни один не имеет права
// отбирать звук у локальной звуковухи или у web-клиента.
// rakDemod — ДО громкости и мьюта, без сайдтона (аудиопоток приёмника);
// rakLineOut — то, что реально уходит на выход (поток линейного выхода).
// PanId: 0 = главный тракт, 1.. = доп. пан. SliceId — слайс-источник
// (0 у главного тракта): по нему потребитель отличает канал A от канала B.
// Вызывается из DSP-потока: обработчик обязан быть быстрым и не ждать.
TRadioAudioKind = (rakDemod, rakLineOut);
TRadioAudioTapEvent = procedure(Kind: TRadioAudioKind; PanId, SliceId: Integer;
const Left, Right: array of Single;
Count: Integer) of object;
// Сырьё спектра/водопада наружу (фронтенды рисуют). Вызывается из 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;
SampleRate: Integer;
ActiveVfo: Integer;
Mode, Filter, FilterBW: Integer;
AGCMode, AGCTop: Integer;
RxGainMode: Integer;
RxGainDb: Integer;
Volume, Drive, Atten: Integer;
Band, Xvtr: Integer;
Muted, CTun, Duplex: Boolean;
Running, Connected, Transmitting, Tuning: Boolean;
NRMode, NBMode: Integer;
SNB, ANF: Boolean;
WfAGC, WfNF: Boolean;
FMStepOn: Boolean;
FMStepIdx: Integer;
FMCTCSSOn: Boolean;
FMCTCSSToneIdx: Integer;
FMSQOn: Boolean;
FMSQLevel: Integer;
FMRptDir: Integer;
SMeter, FwdW, SWR, SupplyV, SupplyA: Double;
PLLLock: Boolean;
end;
TRadioController = class;
// Event adapter: every DMR slice owns a decoder, while its asynchronous
// audio callback still has to retain the originating logical slice id.
TDMRSliceDecoder = class
private
FOwner: TRadioController;
FSliceId: Integer;
FDecoder: TDMRDecoder;
procedure AudioReady(const PCM: array of Single; Count: Integer);
public
constructor Create(AOwner: TRadioController; ASliceId: Integer);
destructor Destroy; override;
procedure FeedAudio(const Left, Right: array of Single; Count: Integer);
procedure GetStatus(out S: TDMRStatus);
end;
// Слайс уровня контроллера: логическая обёртка над WDSP-слайс-каналом
// (в TWDSPEngine) + собственный аудио-выход со своей звуковухой.
TCtrlSlice = record
Active: Boolean;
Id: Integer; // = id слайса в TWDSPEngine
Letter: Char; // буква флага ('A'..'F'), пока для UI
PanId: Integer; // пан слайса: 0 = главный, 1.. = доп. DDC-пан
TargetHz: Double; // абсолютная частота слайса
Mode: Integer;
FilterLo: Integer;
FilterHi: Integer;
AGC: TWDSPAGCMode;
Volume: Double; // 0..1
Muted: Boolean;
RxMuteOnTx: Boolean; // True (умолч.) = динамик слайса молчит на передаче
FMSQOn: Boolean; // FM-squelch включён (слайс в режиме FM)
FMSQLevel: Integer; // порог squelch 0..100
// Зеркало DSP-состояния слайса (движок его не отдаёт обратно): нужно UI
// флага и CAT-геттерам. Пишется только через SetSliceDSP.
NRMode: Integer; // 0=выкл, 1=NR, 2=NR2 (как у главного)
NBMode: Integer; // 0=выкл, 1=NB, 2=NB2
SNB: Boolean;
ANF: Boolean;
DeviceIndex: Integer; // PortAudio-индекс вывода (-1 = default)
DevName: string;
Audio: TAudioOutput; // владеет; свой поток вывода
// Вход слайса (mic/виртуальный кабель) — источник модуляции, когда именно
// этот слайс выбран TX-источником. -1/'' = брать общий вход (FAudioIn).
InDeviceIndex: Integer; // PortAudio-индекс ввода (-1 = общий)
InDevName: string;
AudioIn: TAudioInput; // НЕ владеет: ссылка на поток из mic-пула
// TX-профиль слайса ПО РЕЖИМУ: индекс в FTXProfiles или -1 = «не
// переключать». Применяется, когда слайс становится TX-источником
// (SetTxSlice) и когда он меняет режим, будучи источником.
TXProfByMode: TTXProfModeTable;
DMR: TDMRSliceDecoder; // владеет; nil для не-DMR слайса
end;
// POD-снимок слайса для фронтендов, которые читают модель из своих потоков
// (TCI-сервер: команда исполняется в потоке клиента). Только скаляры: копия
// TCtrlSlice тащит за собой managed-поля (DevName/InDevName) и объекты, а
// копирование строки из чужого потока, пока UI-поток её же переписывает,
// портит счётчик ссылок — это уже не рассинхрон, а порча кучи.
TSliceView = record
Id: Integer;
PanId: Integer;
TargetHz: Double;
Mode: Integer;
FilterLo: Integer;
FilterHi: Integer;
AGC: TWDSPAGCMode;
Volume: Double;
Muted: Boolean;
FMSQOn: Boolean;
FMSQLevel: Integer;
NRMode: Integer;
NBMode: Integer;
SNB: Boolean;
ANF: Boolean;
end;
{ TRadioController }
TRadioController = class
private
FOnInvoke: TUIInvokeEvent;
FOnStateChanged: TRadioStateEvent;
FStateListeners: array of TRadioStateEvent; // доп. наблюдатели (CAT/Andromeda), помимо FOnStateChanged (MainForm)
FOnAfterTune: TThreadMethod; // пост-тюн хук: канал-оркестрация после SetVfoA
FOnBeforeStop: TThreadMethod; // хук перед гашением панов в StopRunning (персист панов)
FOnAudioConsume: TAudioConsumeEvent; // web-перехват RX-аудио (если активен клиент)
FLocalAudio: Boolean; // локальный звук (динамик/мик ПК): GUI=True, демон=False
FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView)
FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер)
// Тапы RX-аудио (TCI-потоки). Список короткий и меняется редко, но пишет
// его поток контроллера, а читает DSP — отсюда лок: снять тап на лету
// (клиент ушёл) иначе означало бы вызов метода освобождённого объекта.
FAudioTaps: array of TRadioAudioTapEvent;
FAudioTapLock: TCriticalSection;
FAudioTapCount: LongInt; // быстрый гейт: без тапов не берём и лок
FIQTap: TIQTapEvent; // тап IQ (TCI): помним ради пересоздания движка
procedure Changed(Field: TRadioField);
procedure PushAudioTapsActive;
procedure FireAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
// --- Мультислайсы ---
function FindSliceIndex(Id: Integer): Integer; // -1 если нет
procedure OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
procedure OnSliceDemodAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
procedure OnSliceDMRAudioReady(SliceId: Integer;
const PCM: array of Single; Count: Integer);
function SliceMutedByTx(Idx: Integer): Boolean; // слайс молчит на передаче?
// Пул входных потоков: захват/освобождение ссылки на устройство ввода.
function AcquireMicInput(DevIndex: Integer): TAudioInput;
procedure ReleaseMicInput(var AI: TAudioInput);
procedure SetSliceInDeviceIdx(Idx, DevIndex: Integer; const DevName: string);
procedure FreeMicPool; // финальная зачистка (FreeEngines)
procedure RefreshSliceShifts; // пересчёт ShiftHz слайсов пана 0 при смене центра
// --- Панадаптеры на аппаратных DDC (этап 3.1/3.2) ---
procedure ResetPanDDCMaps; // все пан-слоты свободны, DDC→Pan пуст
procedure PanZoomStepTo(PanId: Integer; NewZ: Double); // зум с якорем на центре окна
function PanCenterHz(PanId: Integer): Double; // центр пана (0 = FCenterFreq)
procedure ApplyAGCToEngine; // применяет FAGCMode к WDSP (единый маппинг)
procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode
procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state)
// Единственная точка, где выбранный слот превращается в состояние тракта
// (FFilter/кромки/производная BW/FM-девиация). Тракт не трогает — только state.
procedure ApplyFilterSlot(Idx: Integer);
function ClampFilterIdx(Mode, Idx: Integer): Integer;
procedure CacheFilterToBand; // фильтр текущего банда в band-cache
function ActiveVfoHz: Double; // частота активного VFO (A или B)
function AnchoredPan(AZoom: Double): Double; // пан, центрирующий окно на маркере VFO
procedure ApplyTuneCore(VfoHz: Double); // CTUN shift/center/scroll для актив. VFO
public
// ============================================================
// ПУБЛИЧНЫЕ ПОЛЯ (миграционные — имена совпадают с TMainForm).
// Внешний чистый доступ для адаптеров — через команды / GetSnapshot /
// OnStateChanged. Прямая запись извне — временная мера на время Фазы 1.
// ============================================================
// ---- Последний кадр спектра/водопада (пиксели от WDSP) ----
// Снимок для исходящего web-зеркала (PushState читает напрямую). Пишется из
// DSP-потока в OnSpectrumReady/OnWaterfallReady; читается из GUI-таймера —
// та же толерантная гонка, что и раньше (косметика пикселей).
FSpectrumBuf: array[0..SPECTRUM_PIXELS-1] of Single;
FWaterfallBuf: array[0..SPECTRUM_PIXELS-1] of Single;
FSpectrumBufCount: Integer;
FWaterfallBufCount: Integer;
// Потолок точек кадра для web (настройка web.spec_pixels, 1024/2048/4096).
// Кадр анализатора шире — децимируем до него; уже — уходит как есть.
FWebSpecPixels: Integer;
// Водопад копится max-hold'ом до тех пор, пока PushState не заберёт строку
// (анализатор даёт до 60 кадров/с, web шлёт 20 строк/с). Сбрасывает флаг
// тот, кто забрал строку, — тогда следующий кадр начинает новую.
FWfAccumFresh: Boolean;
// ---- Движки (владение: см. шапку юнита) ----
// FNetwork — абстрактный бэкенд железа (TRadioBackend): конкретный класс
// (THPSDRNetwork / TPlutoBackend позже) выбирается при подключении.
FNetwork: TRadioBackend;
FDSPEngine: TWDSPEngine;
FAudioOut: TAudioOutput;
FAudioIn: TAudioInput;
// ---- Мультислайсы (софт-fan-out) ----
// Слайс = доп. приёмник (свой WDSP-канал в движке) + своя звуковуха.
// TargetHz абсолютна; ShiftHz в движок = TargetHz - FCenterFreq.
FSlices: array[0..MAX_SLICES-1] of TCtrlSlice;
FNextSliceId: Integer; // монотонный генератор id слайсов
// ---- Пул входных потоков (mic) ----
// Каждому УНИКАЛЬНОМУ устройству ввода — один открытый TAudioInput на всё
// приложение; слайсы держат на него ссылку по refcount. Потоки живут, пока
// на них кто-то ссылается: переключение TX-источника = смена указателя,
// без Close/Open (иначе на старте передачи терялись бы первые сэмплы).
// Читатель у кольца всегда один — TX-источник в данный момент только один.
FMicPool: array of record
DevIdx: Integer;
Refs: Integer;
AI: TAudioInput;
end;
FSettings: TSettingsManager;
FChannelStore: TChannelStore;
FDeviceStore: TDeviceStore; // общий список устройств (saved+discovered)
FWDSPReady: Boolean;
// ---- Частоты / режим / фильтр / AGC ----
FVfoA, FVfoB: Double;
FActiveVfo: Integer;
FMode: Integer;
FFilter: Integer; // слот 0..FILT_SLOTS-1 (10/11 = VAR1/VAR2)
// Знаковые кромки текущего фильтра — ИСТИНА. FFilterBW производная
// (FilterBWFromEdges) и оставлена ради прежних читателей (web, зум,
// band-cache, CAT); писать в неё напрямую нельзя.
FFilterLo: Integer;
FFilterHi: Integer;
FFilterBW: Integer;
FFilterTable: TFilterTable; // редактируемые фильтры по режиму
FAGCMode: Integer;
FAGCTop: Integer;
FRxGainMode: Integer; // Pluto hw-gain: 0=manual,1=fast,2=slow,3=hybrid
FRxGainDb: Integer; // Pluto manual gain (дБ, 0..73)
FCTun: Boolean;
FCenterFreq: Double;
FSpanHz: Double;
FSampleRate: Integer;
FZoomFactor: Double; // зум спектра/водопада 0.05..1.0 (1.0 = без зума)
FPanSlider: Double; // положение окна зума 0..1 (0.5 = центр)
FCurrentBand: Integer;
FCurrentXvtr: Integer;
// ---- QO-100 beacon lock (декодер маяка 10489.750 = эталон частоты) ----
// Один концепт: лок = декодер + трим LOError. Декодер точно ведёт несущую,
// его измеренный дрейф стекаем в LOError → весь даунлинк стабилен.
FBeaconLockOn: Boolean; // лок (=декодер) включён пользователем
FBeaconLockHz: Double; // коррекция, накопленная за ТЕКУЩУЮ сессию лока
// (только индикация на кнопке); сама поправка
// живёт в LOError активного трансвертера
FBeaconRefHz: Double; // опорная частота маяка (Hz, downlink), деф. 10489.750e6
FBeaconTrackHz: Double; // измеренная АБСОЛЮТНАЯ display-частота маяка
// (наведение + остаток декодера) — для оранж. маркера
FBeaconSNRdB: Double; // SNR из последнего снимка декодера (индикация лока)
FBeaconBal: Double; // EMA доли меньшинства знаков I (BPSK-ность)
FBeaconLocked: Boolean; // декодер солидно залочен (гистерезис по SNR)
FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка ретюна LO)
FBeaconSaveCooldown: Integer; // троттлинг записи LOError на диск (тиков)
// ---- декодер маяка (BPSK 400 бод, AO-40) — фронтенд+констелляция+бюллетень ----
FBeaconDec: TBeaconDecoder; // владеет; тап IQ в WDSPEngine
FBeaconDecHz: Double; // АБСОЛЮТНАЯ display-частота наведения декодера
// (узкий фильтр, ставится кликом); 0 = ещё не наведён
FBeaconDecInvert: Boolean; // зеркальный baseband (QO-100/LNB) — деф. True
// Лупа наведения (окно Beacon) — состояние окна, переживающее перезапуск.
// Центр хранится СДВИГОМ от FBeaconRefHz: так он не зависит ни от правки
// опорной частоты, ни от накопленной калибровки LOError.
FBcnZoomSpanHz: Double; // ширина окна лупы, Гц
FBcnZoomOffsetHz: Double; // центр = FBeaconRefHz + этот сдвиг
FBcnZoomFollow: Boolean; // держать наведение декодера в окне
FBcnZoomAuto: Boolean; // автопоиск маяка в окне лупы
// ---- DMR 4FSK receive decoder ----
FDMRDec: TDMRDecoder; // 48 kHz FM discriminator -> DMR worker
// ---- Аудио / TX ----
FVolume: Integer; // громкость RX
FTxMonVolume: Integer; // громкость self-monitor RX-аудио на TX (RX MUTE off, DUP)
FMuted: Boolean;
FDrivePercent: Integer; // драйв 0..100 % (UI-уровень, источник истины)
FDriveLevel: Byte; // вычисляемый hardware-байт (CalcDriveByte)
FLastVfoScrolled: Boolean; // последний SetVfoA прокрутил центр (для водопада)
FAtten: Integer;
FPAMaxPower: Double;
FPlutoTxMaxAttDb: Double; // Pluto: TX-аттенюация на drive=100% (потолок, дБ<0)
FRunning: Boolean;
FTransmitting: Boolean;
FTuning: Boolean;
// ---- PureSignal ----
FPSEnabled: Boolean; // авто-калибровка PS включена (кнопка PS)
FPSSingleCal: Boolean; // запрошена одиночная калибровка
FPSResetReq: Boolean; // запрошен сброс коррекции (OFF)
FPSCmdState: Integer; // eCMDState Thetis (см. PureSignalTick)
FPSAAState: Integer; // auto-attenuate state (0=monitor,1=set,2=restore)
FPSAASaveAuto: Integer; // сохранённый automode на время смены атт.
FPSAASaveSingle: Integer;
FPSAADeltaDB: Integer;
FPSInfo: array[0..15] of Integer; // кэш GetPSInfo (UI-поток)
FPSInfoPrev: array[0..15] of Integer;
FPSMaxTX: Double; // кэш GetPSMaxTX
// Feedback-тракт вооружён (сеть DDC0/1 + pscc в движке). Пишет UI-поток
// (PureSignalTick/PSSetFeedbackPath), читает сетевой (OnDDCIQ).
FPSFeedbackOn: Boolean;
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) и переживает удаление слайса.
FSliceAutoTx: array[0..MAX_SLICES-1] of Boolean;
// Аудио-устройства слота (буквы B..G): '' = общий выход приложения / общий
// вход. Это НЕ шаблон, а привязка слота: применяется к живому слайсу сразу
// и достаётся новому слайсу, который встанет на этот слот.
FSliceOutDev: array[0..MAX_SLICES-1] of string;
FSliceInDev: array[0..MAX_SLICES-1] of string;
FVfoLock: Boolean; // блокировка перестройки VFO (Andromeda Lock)
FDisplayDuplex: Boolean;
// QO-100 full-duplex self-monitor: True — RX-аудио глушится на TX (по умолч.),
// False — слышим свой downlink со спутника при передаче. Кнопка RX MUTE.
FRxMuteOnTx: Boolean;
FHWPTTActive: Boolean;
FHWPTTStartedTX: Boolean;
FWebClientActive: Boolean; // выставляет web-слой (connect/disconnect клиента)
FWebMicActive: Boolean; // True если текущий TX идёт через txmsWeb
FTCIMicRequested: Boolean; // TCI-клиент попросил TRX:…,tci
FTCIMicActive: Boolean; // True если текущий TX модулируется из TCI
FSendAudioToRadio: Boolean;
FAudioOutDevName: string;
FAudioInDevName: string;
FDevConnected: Boolean;
FDiscoverNoneFound: Boolean; // discovery завершился без устройств (для рендера)
// ---- DSP-фильтры шума / водопад ----
FNRMode, FNBMode: Integer; // источник истины (раньше Btn*.Tag)
FSNB, FANF: Boolean; // источник истины (раньше Btn*.Tag)
FWfAGCEnabled: Boolean;
FWfNFEnabled: Boolean;
FDitherEnabled: Boolean;
FRandomEnabled: Boolean;
// ---- Display state (persist в TGlobalSettings; рендерится FSpecView/Wideband) ----
FShowSpectrum: Boolean;
FShowWaterfall: Boolean;
FShowWideband: Boolean;
FWidebandFill: Boolean;
FSpectrumFill: Boolean;
FWfManualHigh: Double;
FWfManualLow: Double;
FWfAGCOffset: Double;
FWfPalette: Integer; // 0=Classic, 1=Inferno, 2=Turbo
FWfGamma: Double; // контраст палитры водопада
FDisplayPixels: Integer; // потолок точек анализатора
FWfSpeedDiv: Integer; // делитель скорости водопада (1/2/4/8, max-hold)
FSpecMSAA: Integer; // MSAA GL-спектра (1=off, 2, 4, 8)
FSpecRefLevel: Double;
FSpecRange: Double;
FSpecGridStep: Double;
FTXSpecRefLevel: Double; // TX-сетка (Thetis-style): применяется при FTransmitting
FTXSpecRange: Double;
FTXSpecGridStep: Double;
// ---- FM ----
FFMDeviation: Double;
FRawFMDeviation: Double;
FFMCTCSSOn: Boolean;
FFMCTCSSToneIdx: Integer;
FFMSQOn: Boolean;
FFMSQLevel: Integer;
FFMStepOn: Boolean;
FFMStepIdx: Integer;
FFMRptDir: Integer;
FFMRptOffsetHz: Double;
FFMRptAutoActive: Boolean;
FFMCTCSSAutoActive: Boolean;
// ---- Конфиги / устройство (per-MAC) ----
FTXSettings: TTXSettings;
// Именованные TX-профили («звуковая» часть FTXSettings + drive, см.
// TTXProfile). Истина для эфира — всегда живые FTXSettings/FDrivePercent;
// активный профиль ведётся за ними (StoreActiveTXProfile).
FTXProfiles: TTXProfileList;
// Телеграф: параметры кейера/сайдтона/pitch (DUC Specific 5..13,17 + CW-тракт).
FCWSettings: TCWSettings;
// Программная передача текста (бит CWX). Поток создаётся лениво — у тех,
// кто телеграфом не пользуется, лишней нити не заводится.
FCWSender: TCWSender;
// Чужая манипуляция по сети (TCI KEYER): очередь готовых интервалов.
// Тоже лениво — заводится на первом элементе.
FCWElem: TCWElemPlayer;
// Состояние ключа для ПРОГРАММНОГО сайдтона. Пишется потоком передачи и
// разбором 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) на диапазон/трансвертер.
FAnt936x: TAD936xAnt;
FXvtrSettings: TXvtrSettings;
FBandCache: array[0..CFG_BAND_COUNT-1] of TBandSettings;
FPABandCal: array[0..CFG_BAND_COUNT-1] of Double;
FVHFBandCal: array[0..CFG_XVTR_COUNT-1] of Double;
// Калибровки (детектор мощности/КСВ, напряжение/ток, S-метр) — вкладка
// Calibration. Читается сетевым потоком в OnHPStatus (double-поля, torn
// read безопасен как у FPABandCal); менять через SetCalibration.
FCal: TCalibration;
FDevMAC: array[0..5] of Byte;
// Глобальные настройки устройства, загруженные ConnectDevice. Хранятся для
// UI-рендера полей, которых нет в контроллере (DisplayFPS/FreqMhzDigits/CAT).
FLoadedGlobal: TGlobalSettings;
// ---- Телеметрия (наполняется в Фазе 3/4 вместе с сетевыми колбэками) ----
FLastSMeter, FLastFwdW, FLastSWR, FLastSupplyV, FLastSupplyA: Double;
FLastPLLLock: Boolean;
// Pluto-телеметрия (нет HP-status): температура трансивера °C / Zynq SoC °C /
// RSSI приёмника дБ. Опрашиваются ServicePlutoTelemetry с UI-таймера;
// TELEMETRY_NONE = нет данных.
FLastPlutoTempC, FLastPlutoSocTempC, FLastPlutoRSSIdB: Double;
// Фактическое усиление RX-тракта AD936x (дБ) — в режимах AGC его крутит
// железо. Снимается тем же опросом; UI показывает его в блоке RF AGC.
FLastPlutoGainDb: Double;
FPlutoTeleTick: QWord; // GetTickCount64 последнего опроса (троттлинг)
FPlutoTempBad: Integer; // подряд отброшенных выбросов температуры (анти-залипание)
// ---- RX/seq телеметрия data-path (пишется из сетевого колбэка OnDDCIQ) ----
FRXPacketCount: LongWord; // счётчик принятых IQ пакетов
FRXStartTime: QWord; // GetTickCount64 момента START
FRXLastPktTime: QWord; // GetTickCount64 последнего принятого пакета
FActiveDDC: Integer; // DDC index для текущей платы (0 или 2)
// ---- Панадаптеры на аппаратных DDC (этап 3.1) ----
// PanId 1..MAX_PANS-1 → hw DDC index (-1 = пан не создан); пан 0 = главный
// тракт (FActiveDDC). FDDCPanMap — обратный маппинг DDC→PanId для роутинга
// IQ в OnDDCIQ (пишется под FSliceLock-дисциплиной: только UI-поток).
FPanDDCIdx: array[1..MAX_PANS-1] of Integer;
FPanFreqHz: array[1..MAX_PANS-1] of Double;
FPanRateKHz: array[1..MAX_PANS-1] of Word;
FPanADC: array[1..MAX_PANS-1] of Byte; // ADC-источник пана (3.4)
FPanZoomF: array[1..MAX_PANS-1] of Double; // зум пана 0..1 (0 = весь span)
FPanPanSl: array[1..MAX_PANS-1] of Double; // положение окна пана 0..1
FMainADCSrc: Byte; // ADC главного DDC (пан 0)
FDDCPanMap: array[0..MAX_DDCS-1] of Integer;
FLastDDCSeq: LongWord; // последний seq (из сетевого потока)
FLastDDCIndex: Integer; // последний DDC index
FDDCLastSeq: array[0..6] of LongWord;
FDDCSeqValid: array[0..6] of Boolean;
FSeqErrorCount: LongWord;
FLastSeqErrorDDC: Integer;
FLastSeqErrorDelta: Int64;
FSeqOkStreak: Integer;
FADCOverloadActive: Boolean; // ADC overload (рендерится по rfADCOverload)
// ---- TX DUC-очередь (накопление IQ от WDSP в пакеты по 240, OnTXIQ) ----
FDUCPendingI: array[0..239] of Integer;
FDUCPendingQ: array[0..239] of Integer;
FDUCPendingCount: Integer;
// Pending-значения декода HP-статуса (сетевой поток → ApplyHPStatus через
// Invoke; Synchronize блокирующий и последовательный, гонок нет).
FHPpFwdW, FHPpSWR, FHPpSupplyV, FHPpSupplyA: Double;
FHPpPLL, FHPpHWPTT: Boolean;
FHPpOverload: Byte;
// Pending discovered-устройство (сетевой поток discovery → ApplyDeviceFound
// через Invoke; Synchronize блокирующий — устройства приходят по одному).
FPendingFoundDev: THPSDRDevice;
FPendingFoundEntry: string;
// URI для probe сетевых Pluto при discovery (см. SetPlutoProbeURIs).
FPlutoProbeURIs: array of string;
constructor Create;
destructor Destroy; override;
// Lifecycle движков: создаёт FNetwork/FDSPEngine/FAudioOut/FAudioIn с
// заданным sample rate (вызывающий уже определил его из настроек). Колбэки
// движков навешивает вызывающий (GUI: методы MainForm; демон: свои). Парный
// FreeEngines закрывает и освобождает их; вызывается из Destroy.
procedure CreateEngines(ASampleRate: Integer);
procedure FreeEngines;
// Внутренние колбэки движка (аудио слайсов, отвод демодулятора, калибровка
// дисплея, тапы TCI). Зовёт CreateEngines — и ОБЯЗАН звать всякий, кто
// пересоздал FDSPEngine: без этого новый движок остаётся без отвода
// демодулятора (декодеры CW/DMR/FM RAW и RX_AUDIO по TCI) и без тапа IQ.
procedure AttachEngineCallbacks;
// ---- Колбэки движков (вызываются из рабочих потоков сети/DSP) ----
// Чистая обработка данных без UI; навешиваются на FNetwork/FDSPEngine
// вызывающим (GUI или демон) после CreateEngines.
procedure OnAudioReady(const Left, Right: array of Single; Count: Integer);
procedure OnDemodAudioReady(const Left, Right: array of Single; Count: Integer);
procedure OnDMRAudioReady(const PCM: array of Single; Count: Integer);
// Общая выборка калибровочной таблицы «диапазон/трансвертер» по частоте.
function CalLookup(VisibleHz: Double;
const BandTbl: array of Double;
const XvtrTbl: array of Double): Double;
procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer);
procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer);
procedure OnMicPacket(const Data: TMicDataPacket);
function PullSoundCardMic(MaxN: Integer): Integer;
procedure OnDDCIQ(DDCIndex: Integer; const Data: TDDCIQPacket);
procedure OnHPStatus(const Status: THighPriorityStatus);
procedure ApplyHPStatus; // маршалится из OnHPStatus (сглаживание/overload/HWPTT)
procedure OnTXIQ(const Buf: array of Double; Count: Integer);
procedure OnDeviceFound(const Dev: THPSDRDevice); // сетевой поток discovery
procedure ApplyDeviceFound; // маршалится из OnDeviceFound (store + rfDeviceList)
procedure DiscoverFinishedNone; // маршалится из discover-потока: устройств нет
// Маршалинг команды в поток контроллера (GUI: TThread.Synchronize).
procedure Invoke(M: TThreadMethod);
// Снимок всего состояния (для синхронизации нового фронтенда).
function GetSnapshot: TRadioSnapshot;
// Частотные хелперы — чистые вычисления над состоянием, общие для всех
// фронтендов и сетевого слоя (Vfo/Mode/Band/CTun/TX/FM-Rpt).
// XvtrTranslate — переводит видимую частоту в частоту IF-тракта с учётом
// активного трансвертора (LOOffset/LOError).
// ActiveTXFreqHz — текущая TX-частота (split/active VFO + FM-репитер сдвиг).
function XvtrTranslate(VisibleHz: Double): Double;
// XvtrTranslateTX — то же для ПЕРЕДАЧИ. Обычный трансвертер: = XvtrTranslate.
// QO-100 (FullDuplexTx): отдельный TX-LO (visible-TxLOOffset, без LOError —
// 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-слайса при кросс-банде).
function TxBandIdx: Integer;
// Выбор слайса-источника передачи (0 = главный VFO). Эксклюзивно.
procedure SetTxSlice(SliceId: Integer);
property TxSliceId: Integer read FTxSliceId;
// Складывает коррекцию маяка dLock в LOError активного трансвертера (живая
// калибровка LNB), ретюнит радио и троттлингом сохраняет на диск.
procedure ApplyBeaconLOError(dLock: Double);
// Flush LOError на диск при выключении лока (если троттлинг не успел).
procedure PersistBeaconCalibration;
// Применяет текущие FMode/FFilterBW/FM-параметры к DSP (RX/TX фильтр,
// FM-девиация, squelch). Вызывается командами и UI-путями смены режима.
procedure ApplyModeFilter;
// Вычисляет hardware drive-байт (Thetis-формула) из текущего состояния
// (band/xvtr cal, TUN/drive%, PA max). Чистая функция над состоянием.
function CalcDriveByte: Byte;
// Источник микрофона по умолчанию для MOX-кнопки/Web-TX: звуковая карта
// если настроена, иначе HW mic-стрим трансивера. HW PTT → txmsRadio в SetMOX.
function DefaultMicSource: TTXMicSource;
// ---- TX/DSP-цепь (чистые над состоянием: читают F*/FTXSettings, пишут в движок) ----
function BuildMicLineSelectByte: Byte; // DUC Specific byte 50 (mic/line биты)
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); // колбэк потока передачи
procedure CWElemKey(Down: Boolean); // колбэк проигрывателя KEYER
// Сдвиг несущей манипуляции от гетеродина (уход от утечки 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-профили --------------------------------------------------------
// Профиль — именованный снимок «звуковой» части TX-цепи (состав в
// TTXProfile). Отдельной кнопки «сохранить» нет: правки ручками пишутся в
// активный профиль сразу (StoreActiveTXProfile), как и всё остальное в
// EWSDR. Переключение применяет снимок в эфир немедленно.
function TXProfileCount: Integer;
function TXProfileName(Idx: Integer): string;
function ActiveTXProfile: Integer;
// Единая дверь для правки TX-настроек снаружи (вкладка Transmit, CAT, web):
// применяет запись к движку/железу, дописывает активный профиль и персистит.
// Notify=False — вызывающий сам отрисует изменения (так делает MainForm,
// иначе rfTXProfile перезагрузил бы форму настроек прямо под руками).
procedure SetTXSettings(const T: TTXSettings; Notify: Boolean = True);
// Auto=True — переключение сделала автоматика привязок; ручной выбор
// (дропдаун, список настроек) дополнительно ЗАПОМИНАЕТСЯ за текущей парой
// «источник + модуляция» (BindTXProfileToCurrentMode).
procedure SelectTXProfile(Idx: Integer; Auto: Boolean = False);
procedure StoreActiveTXProfile(Persist: Boolean = False);
function AddTXProfile(const AName: string): Integer; // форк текущего звука
procedure RenameTXProfile(Idx: Integer; const AName: string);
procedure DeleteTXProfile(Idx: Integer);
procedure ResetTXProfilesToFactory; // заводской набор заново
// Профиль на TX-источник, ключ — режим этого флага (-1 = не переключать,
// звучит активный). Таблица у каждого флага своя: главный в FTXProfiles,
// слайсы в своих записях.
function FlagMode(SliceId: Integer): Integer;
procedure BindTXProfileToCurrentMode(Idx: Integer); // ручной выбор → в таблицу
function SliceTXProfile(SliceId: Integer): Integer;
procedure SetSliceTXProfile(SliceId, ProfIdx: Integer);
function SliceTXProfileTable(SliceId: Integer): TTXProfModeTable;
procedure SetSliceTXProfileTable(SliceId: Integer; const T: TTXProfModeTable);
procedure RemapTXProfileBindings(Deleted: Integer); // после удаления/замены списка
procedure ApplyBoundTXProfile; // профиль текущего TX-источника → в эфир
procedure SaveTXProfilesToConfig;
// ---- Управление диапазонами (состояние + DSP; рендер через события) ----
// MakeBandSettings — снимок текущего состояния в запись диапазона.
// SaveCurrentBand — сохраняет текущее состояние в кэш+конфиг (или в XVTR-слот).
// RestoreBand — загружает состояние диапазона, применяет к DSP, шлёт события
// рендера. Финальную перестройку VFO A делает вызывающий (UI: ApplyVfoA).
function MakeBandSettings: TBandSettings;
procedure SaveCurrentBand;
procedure RestoreBand(BandIdx: Integer);
// Глобальные настройки устройства (persist по MAC). BuildGlobalSettings
// собирает радио-поля из своего состояния, а НЕ-радио поля (тема/FPS/digits/
// CAT) переносит сквозь из FLoadedGlobal — контроллер их не трактует, только
// round-trip в файл. SaveGlobalSettings пишет блоб (гейт по FDevConnected).
function BuildGlobalSettings: TGlobalSettings;
procedure SaveGlobalSettings;
// Применяет DSP-настройки диапазона (mode/filter/AGC/CTUN/FM) + шлёт события
// рендера. НЕ трогает частоты VFO/центр — это переиспользуется как RestoreBand
// (с загрузкой VFO), так и SetActiveVfo (bandstack: смена бэнда без сброса VFO).
procedure ApplyBandDSP(const B: TBandSettings);
// Полоса фильтра для (режим, индекс фильтра) — выбор per-mode таблицы.
function FilterBWFor(M, Idx: Integer): Integer;
// Обратное отображение: ближайший индекс пресета к полосе BW (для CAT/UI,
// где полоса задана в Гц, а протокол оперирует индексом).
function FilterIdxFor(M, BW: Integer): Integer;
// ---- XVTR (трансвертер) ----
// ApplyXvtrToNetwork — XVTR enable bit + DisablePA + RX ant в сеть.
// ActivateXvtr — вход в слот: state+DSP (через ApplyBandDSP) + VFO/центр + сеть;
// рендер через rfXvtr/rfBand(XVTR-aware)/rfActiveVfo. UI: wideband+web.
// DeactivateXvtr — сохраняет слот (SaveCurrentBand) и выходит; HF-возврат
// (RestoreBand + финальная перестройка VFO) делает UI-обёртка.
procedure ApplyXvtrToNetwork;
procedure ActivateXvtr(Idx: Integer);
procedure DeactivateXvtr;
// Полный HP-кадр в радио (RX/TX freq через трансвертор + drive + MOX).
// Вызывается командами смены частоты/центра и UI после правки drive.
procedure PushNetworkState;
// ====================================================================
// КОМАНДЫ — единый API для GUI / Web / CAT / Arduino.
// Фаза 1: обновляют поле + шлют событие; логика движка — TODO-wiring.
// ====================================================================
// Частоты / VFO
procedure SetVfoA(Hz: Double);
procedure StoreVfoA(Hz: Double); // сохранить частоту A без перестройки (когда активен B)
procedure SetVfoB(Hz: Double);
procedure SetActiveVfo(Idx: Integer);
procedure TuneActiveBy(DeltaHz: Double); // энкодер VFO
procedure SetCenter(Hz: Double);
procedure VfoSwap;
procedure VfoACopyB;
procedure VfoBCopyA;
// Режим / фильтр / AGC
procedure SetMode(M: Integer);
procedure SetFilterIdx(Idx: Integer);
procedure SetFilterBW(BWHz: Integer);
procedure SetRawFMDeviation(Hz: Integer);
procedure SetAGCMode(M: Integer);
procedure SetAGCTop(DB: Integer);
procedure AGCTopBy(Delta: Integer);
// RX hw-gain (Pluto): режим (0=manual,1=fast,2=slow,3=hybrid) / ручной дБ.
// На HPSDR — no-op в бэкенде.
procedure SetRxGainMode(M: Integer);
procedure SetRxGain(Db: Integer);
procedure RxGainBy(Delta: Integer);
procedure ApplyRxGainToBackend; // FRxGain* → FNetwork.SetRxGain
// Громкость / mute / drive / аттенюатор
procedure SetVolume(V: Integer);
procedure VolumeBy(Delta: Integer);
// Громкость self-monitor'а на передаче отдельно от RX-громкости: слайдер
// правит её только когда MonitoringTX, а CAT (ZZTM) — в любой момент.
{ Адресная громкость ПРИЁМА: правит FVolume независимо от того, идёт ли
передача. Слайдер (SetVolume) правит активную — на self-monitor это
громкость монитора; внешнему клиенту (TCI VOLUME) такой контекст не нужен. }
procedure SetRxVolume(V: Integer);
procedure SetTXMonVolume(V: Integer);
// True — сейчас звучит self-monitor даунлинка на передаче (TX + DUP + RX MUTE
// off). В этом контексте SetVolume/слайдер правят FTxMonVolume, иначе FVolume.
function MonitoringTX: Boolean;
// Активная громкость под текущий контекст (RX или TX-монитор) — то, что видит
// слайдер (rfVolume) и получает движок.
function ActiveVolume: Integer;
// Применить активную громкость к движку + синхронизировать слайдер (rfVolume).
// Зовётся на T/R-переходах и смене RX MUTE / DUP.
procedure ApplyActiveVolume;
procedure SetMute(On_: Boolean);
procedure ToggleMute;
procedure SetDrive(V: Integer);
procedure DriveBy(Delta: Integer);
procedure SetAtten(Db: Integer); // ADC step attenuator, дБ (0..31)
// ---- Мультислайсы (софт-fan-out) ----
// Создаёт слайс на TargetHz: заводит WDSP-канал в движке + свой аудио-выход
// на устройстве DevIndex (-1=default). Возвращает Id слайса или -1 при отказе
// (нет слотов / TargetHz вне полосы пана). DevName для персиста/UI.
// APanId>0 — слайс внутри доп. DDC-пана (тот должен быть создан AddPanDDC).
function AddSlice(TargetHz: Double; Mode, FilterLo, FilterHi: Integer;
AGC: TWDSPAGCMode; Vol: Double;
DevIndex: Integer; const DevName: string;
APanId: Integer = 0): Integer;
procedure RemoveSlice(Id: Integer);
procedure SetSliceTarget(Id: Integer; TargetHz: Double);
procedure SetSliceMode(Id, Mode: Integer);
procedure SetSliceFilter(Id, Low, High: Integer);
procedure SetSliceModeBW(Id, Mode, BW: Integer); // режим+полоса → кромки фильтра
class procedure FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
// --- Редактируемая таблица фильтров (пресеты + VAR1/VAR2 на режим) ---
function FilterSlot(Mode, Idx: Integer): TFilterSlot;
procedure SetFilterSlot(Mode, Idx: Integer; const S: TFilterSlot);
procedure ResetFilterSlot(Mode, Idx: Integer);
procedure ResetFilterSet(Mode: Integer);
// Произвольные кромки (drag на спектре, CAT ZZFL/ZZFH, web): пишутся в
// VAR1 и переключают на него, если текущий слот — пресет. Пресеты таким
// образом невозможно испортить случайным движением мыши.
procedure SetFilterEdges(LoHz, HiHz: Integer);
// Только state, без применения к тракту: UpdateFilterButtons — рендер,
// DSP в его путях перестраивается отдельно (как было до таблицы).
procedure SelectFilterSlotState(Idx: Integer);
procedure SanitizeFilterState;
// Знаковые края TX-полосы (для отрисовки полосы фильтра при передаче).
procedure TXFilterEdges(out Lo, Hi: Integer);
property FilterLo: Integer read FFilterLo;
property FilterHi: Integer read FFilterHi;
class function AGCModeFromUI(M: Integer): TWDSPAGCMode; // UI-индекс 0..4 → WDSP enum
class function AGCModeToUI(A: TWDSPAGCMode): Integer; // и обратно
procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
procedure SetSliceVolume(Id: Integer; Vol: Double); // 0..1
procedure SetSliceMute(Id: Integer; Mute: Boolean);
procedure SetSliceRxMuteOnTx(Id: Integer; On_: Boolean); // self-monitor слайса на TX
function PanPrimarySliceId(PanId: Integer): Integer; // первый созданный слайс пана
function PanHasFMSlice(PanId: Integer): Boolean; // есть слайс в канальном режиме (FM/DMR/FMRAW)?
procedure SetSliceDSP(Id, NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
procedure SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer);
function SliceSMeter(Id: Integer): Double; // dBm, из DSP-потока
// S-метр главного приёмника, dBm, с калибровочным офсетом текущего
// диапазона/трансвертера (движок отдаёт сырое значение).
function ReadSMeterDBm: Double;
// Офсет S-метра (дБ) для видимой частоты: слот трансвертера или диапазон.
function SMeterCalFor(VisibleHz: Double): Double;
// Офсет спектра/водопада (дБ) для дисплея: PanId 0 = главный, 1..N — пан.
// Колбэк движка (display-поток), см. TOnDispCalQuery.
function DispCalFor(PanId: Integer): Double;
// Поправка на регулируемое усиление входа (аттенюатор ADC у openHPSDR,
// hardwaregain у AD936x) — входит в оба офсета выше.
function FrontEndCalDB: Double;
procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string);
// Устройство ВВОДА слайса (источник модуляции, когда слайс = TX-источник).
// DevIndex=-1 / DevName='' — вернуться на общий вход (FAudioIn).
procedure SetSliceInDevice(Id, DevIndex: Integer; const DevName: string);
// Общее устройство ввода (главный VFO = слайс A + фолбэк для слайсов).
procedure SetMainMicDevice(DevIndex: Integer; const DevName: string);
// Поток/имя устройства ввода активного TX-источника (слайс или общий).
function ActiveMicInput: TAudioInput;
function ActiveMicInDevName: string;
function SliceCount: Integer;
function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
// Снимок слайса для фронтендов, читающих модель из СВОИХ потоков (TCI).
// См. TSliceView: копировать целиком TCtrlSlice оттуда нельзя.
function GetSliceView(Id: Integer; out V: TSliceView): Boolean;
// Слайсы пана по порядку (0-й = «канал A» пана): та же нумерация, что у
// флагов. Нужны TCI, где приёмник — это пан, а канал — слайс на нём.
function PanSliceCount(PanId: Integer): Integer;
function PanSliceId(PanId, Index: Integer): Integer; // 0 = нет такого
function SlicePanIndex(Id: Integer; out PanId, Index: Integer): Boolean;
// Слот ↔ слайс. Слот 0..MAX_SLICES-1 = буква B..G и не зависит от того,
// создан ли слайс сейчас: на слот вешаются настройки (CAT-порт, Auto TX).
class function SliceSlotLetter(Slot: Integer): Char;
function SliceIdBySlot(Slot: Integer): Integer; // 0 = слот пуст
function SliceSlotOf(Id: Integer): Integer; // -1 = нет такого слайса
// Auto TX слайса (см. FSliceAutoTx). Геттер по слоту — для UI настроек,
// по Id — для флага слайса и CAT.
function SliceSlotAutoTx(Slot: Integer): Boolean;
procedure SetSliceSlotAutoTx(Slot: Integer; On_: Boolean);
function SliceAutoTx(Id: Integer): Boolean;
// Аудио-устройства слота. Сеттер применяет их к слайсу, который стоит на
// слоте прямо сейчас (имя → PA-индекс текущей сессии); '' = общий.
function SliceSlotOutDev(Slot: Integer): string;
function SliceSlotInDev(Slot: Integer): string;
procedure SetSliceSlotAudio(Slot: Integer; const OutDev, InDev: string);
// Заявка на передачу ОТ ИМЕНИ слайса (CAT-порт слайса, TCI с номером
// приёмника). Единственная точка, где внешняя PTT может сменить TX-источник,
// поэтому здесь же — защита «в эфире одновременно только один слайс».
// Tune = True — то же самое, но несущей настройки (TUN) вместо модуляции.
// Выполнять в потоке контроллера.
procedure RequestSliceTx(SliceId: Integer; On_: Boolean;
Tune: Boolean = False);
// Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс.
procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0);
function SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean;
// Границы ВКЛЮЧЁННОГО сейчас диапазона (трансвертер — его FreqBegin/FreqEnd,
// иначе band-план бэкенда). False = диапазон неизвестен.
function CurrentBandEdges(out LoHz, HiHz: Double): Boolean;
// Перестроить слайс на TargetHz В ПРЕДЕЛАХ текущего диапазона: если частота
// вне захваченной полосы пана — двигаем окно DDC. False = отказ (другой
// диапазон либо окно не подвинуть, не оглушив главный приёмник).
function TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean;
// Уведомление UI «частота слайса пришла извне»: перезалить флаг (цифры +
// кромки фильтра), позиция флага и так живая.
procedure SliceFreqChanged(Id: Integer);
procedure SliceStateChanged(Id: Integer);
// ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ----
// Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр в ВИДИМЫХ
// Гц (как FVfoA/FCenterFreq: при активном XVTR транслируется в IF только
// на границе с железом, XvtrTranslate), RateKHz ∈ PanRateAllowed (иначе
// 384), ADCSource — АЦП платы. False = нет слота/бэкенд не умеет/не Running.
function AddPanDDC(PanId: Integer; FreqHz: Double; RateKHz: Word = 384;
ADCSource: Byte = 0): Boolean;
procedure RemovePanDDC(PanId: Integer);
procedure SetPanDDCFreq(PanId: Integer; FreqHz: Double); // ретюн (HP-пакет)
// Допустимое окно ВИДИМОЙ частоты пана: активный XVTR — его диапазон,
// иначе пределы железа (UI-кламп drag/тюна панов N).
procedure VisibleFreqBounds(out LoHz, HiHz: Double);
// Вход/выход/перекалибровка XVTR при живых панах: клампит видимые частоты
// панов в новое окно, перешивает freq-слова DDC (XvtrTranslate), слайсы
// панов — к краям их полос захвата.
procedure RefreshPanDDCs;
// Rate ∈ сетке бэкенда (Caps.RatePresets) и кратен 48 кГц (движок).
function PanRateAllowed(RateKHz: Word): Boolean;
// Смена rate живого пана: движок переоткрывает аккумулятор/анализатор/слайсы,
// DDC реконфигурируется; слайсы вне новой полосы клампятся к её краям.
function SetPanDDCRate(PanId: Integer; RateKHz: Word): Boolean;
// Переезд пана на другой аппаратный DDC (пан/слайсы/движок не пересоздаются,
// меняется только hw-индекс): бывает, что конкретный DDC платы ведёт себя
// хуже соседей — тогда пан можно просто увести на другой.
function MovePanDDC(PanId, NewDDC: Integer): Boolean;
function PanDDCIndex(PanId: Integer): Integer; // hw-индекс DDC пана
function MaxDDCIndex: Integer; // последний DDC железа
function DDCFree(DDCIdx: Integer): Boolean; // индекс свободен?
function PanDDCActive(PanId: Integer): Boolean;
function PanDDCFreq(PanId: Integer): Double;
function PanDDCRateKHz(PanId: Integer): Word;
// ADC-источник пана (3.4). PanId=0 = главный DDC (реконфиг ConfigureDDCs),
// N>0 = его пан-DDC (SetPanDDC). ADC ∈ [0..Caps.NumADCs-1].
// Антенные разъёмы AD936x: настройки (вкладка Antenna) + применение текущего
// контекста (диапазон | активный трансвертер) к железу.
procedure SetAnt936x(const A: TAD936xAnt);
procedure ApplyAntPorts;
property Ant936x: TAD936xAnt read FAnt936x;
function PanADC(PanId: Integer): Byte;
function SetPanADC(PanId: Integer; ADC: Byte): Boolean;
// ---- Зум/пан доп. панов (PanId=0 делегирует главному зуму) ----
// Видимое окно пана — та же формула, что span-clip его анализатора.
procedure GetPanViewWindow(PanId: Integer; out ACenter, ASpan: Double);
procedure SetPanZoomPan(PanId: Integer; AZoom, APan: Double);
procedure PanZoomIn(PanId: Integer); // шаг по лестнице, якорь = центр окна
procedure PanZoomOut(PanId: Integer);
procedure PanZoomDefault(PanId: Integer);
function PanZoomFactor(PanId: Integer): Double;
function PanPanSlider(PanId: Integer): Double;
// Pluto TX-мощность через аттенюацию: drive% → дБ (потолок FPlutoTxMaxAttDb на
// 100%, выкл на приёме). Логика/маппинг здесь; бэкенд лишь пишет hardwaregain.
function PlutoTxAttForDrive: Double; // дБ<0 под текущий FDrivePercent
procedure ApplyPlutoTxAtten; // ставит att по FTransmitting/FTuning
procedure SetPlutoTxMaxAtt(Db: Double); // потолок на drive=100% (внешний усилитель)
procedure SetCalibration(const C: TCalibration); // вкладка Calibration → FCal (офсеты S-метра — в SMeterCalFor)
procedure ApplyWDSPDrive(Pct: Integer); // цифровой drive WDSP: Pluto=1.0 (мощность — атт), HPSDR=Pct/100
// Диапазоны / трансвертеры / спан
procedure SetBand(Idx: Integer);
procedure BandUp;
procedure BandDown;
procedure SetXvtrBand(Idx: Integer); // -1 = выход в HF
procedure SetCurrentBandIdx(NewBand: Integer); // лёгкая смена HF-диапазона (band+drive+рендер), без restore
procedure SetSpan(Hz: Integer);
procedure SetSampleRate(Hz: Integer);
// ---- Zoom/pan спектра и водопада ----
// GetViewWindow — единая истина «видимого окна» (центр+span в Гц) для всех
// оверлеев и маппинга мыши: при зуме отдаёт окно из WDSP-анализатора, иначе
// базовые FCenterFreq/FSpanHz.
procedure GetViewWindow(out ACenter: Double; out ASpan: Double);
procedure ApplyZoom(AZoom, APan: Double); // задать зум+пан, сохранить в band, Changed(rfZoom)
procedure ZoomIn; // шаг увеличения (якорь — маркер активного VFO)
procedure ZoomOut; // шаг уменьшения
procedure ZoomDefault; // сброс в 1.0 (весь span)
function ZoomFactor: Double;
function PanSlider: Double;
// QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку.
// QO-100 beacon lock (= декодер + трим LOError; декодер = эталон частоты).
procedure SetBeaconLock(On_: Boolean); // вкл/выкл лок (запускает декодер)
procedure ServiceBeaconLock; // одна итерация контура (MeterTimer @10 Гц)
procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink)
procedure BeaconSeedAtHz(DisplayHz: Double); // клик по маяку → наведение декодера
function BeaconState: TBeaconState;
function BeaconCorrectionHz: Double; // коррекция за сессию лока (индикация; в LOError)
function BeaconSNRdB: Double;
function BeaconLockEnabled: Boolean;
function BeaconRefHz: Double; // опорная частота маяка (для маркера)
function BeaconTrackedFreqHz: Double;// измеренная display-частота маяка (наведение+остаток)
function BeaconDecodeFreqHz: Double; // частота наведения (для узкого маркера)
procedure SetBeaconDecodeInvert(On_: Boolean); // зеркальный baseband вкл/выкл
function BeaconDecodeInvert: Boolean;
function GetBeaconScope(out S: TBeaconScope): Boolean;
function GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
// --- Лупа маяка (окно наведения в BeaconScopeForm) -------------------
// Отдельный WDSP-анализатор на IQ главного тракта, вырезающий узкое окно
// вокруг маяка: на общей спектрограмме 576к маяк — пара пикселей, точно
// ткнуть в него нельзя. Все частоты — АБСОЛЮТНЫЕ display-Гц, как у
// BeaconSeedAtHz. Зовётся с таймера окна маяка; окно закрыто → Enabled=False
// и анализатор уничтожается.
procedure SetBeaconZoomWindow(AEnabled: Boolean; ACenterHz, ASpanHz: Double);
function GetBeaconZoomSpectrum(var Pixels: array of Single;
out Count: Integer; out LowHz, HighHz: Double): Boolean;
function GetBeaconZoomWaterfall(var Pixels: array of Single;
out Count: Integer): Boolean;
// Полоса захвата (display-Гц): за её пределы лупу не увести.
procedure GetCaptureWindow(out ALowHz, AHighHz: Double);
function BeaconZoomBinHz: Double; // разрешение лупы, Гц/бин (для подписи)
function GetDMRStatus(out S: TDMRStatus): Boolean;
function GetSliceDMRStatus(SliceId: Integer; out S: TDMRStatus): Boolean;
// Отображение / приём
procedure SetCTun(On_: Boolean);
procedure SetDuplex(On_: Boolean);
procedure SetRxMuteOnTx(On_: Boolean); // QO-100 self-monitor (кнопка RX MUTE)
procedure ApplyRxMuteToEngine; // FRxMuteOnTx → FDSPEngine.MonitorRXDuringTX
procedure SetWfAGC(On_: Boolean);
procedure SetWfNF(On_: Boolean);
// DSP-фильтры шума
procedure SetNR(Mode: Integer);
procedure SetNB(Mode: Integer);
procedure SetSNB(On_: Boolean);
procedure SetANF(On_: Boolean);
// Передача
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);
// Чужая манипуляция: один готовый интервал (посылка или пауза) заданной
// длины. Так приходит телеграф по TCI (команда KEYER) — знаков там нет,
// есть измеренные клиентом длительности. См. TCWElemPlayer.
procedure CWKeyerElement(Mark: Boolean; Ms: Integer);
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;
// Вкл/выкл авто-калибровки PureSignal (кнопка PS). Вооружает feedback-тракт
// (DDC0/1 на TX) и WDSP calcc automode; выкл = reset коррекции.
procedure SetPureSignal(On_: Boolean);
procedure PSSingleCal; // одиночная калибровка
procedure PSReset; // выключить и сбросить коррекцию
// Тик PS (~10 Гц, из UI-таймера): state machine команд (порт Thetis
// PSForm timer1/timer2), опрос GetPSInfo, auto-attenuate через AttOnTX.
procedure PureSignalTick;
procedure ApplyPSSettingsToDSP; // FTXSettings.PS* → WDSP calcc
// Вооружение/снятие feedback-тракта (сеть + движок), идемпотентно.
procedure PSSetFeedbackPath(En: Boolean);
// Статус для UI (кэш последнего PureSignalTick)
function PSFeedbackLevel: Integer; // info[4], оптимум ~PSFBTarget
// Эффективная цель уровня feedback: настройка ps_fb_target, либо авто по
// плате (Orion MkII/ANAN-7000/8000 → 22 — выше перегружается вход ADC,
// замеры eu2av на Anvelina PRO3; остальные → 152, классика Thetis).
function PSFBTarget: Integer;
function PSCalCount: Integer; // info[5]
function PSCorrecting: Boolean; // info[14]=1 — коррекция применяется
function PSMaxTXVal: Double; // GetPSMaxTX (пик TX для SetPk)
function PSSingleCalActive: Boolean; // идёт одиночная калибровка
// 2TON: двухтональник (PostGen two-tone) + MOX — тест IMD / проверка PS.
procedure SetTwoTone(On_: Boolean);
procedure SetSplit(TxOnB: Boolean); // split: TX на VFO-B (иначе VFO-A)
procedure SetVfoLock(On_: Boolean); // блокировка перестройки VFO
// FM
procedure SetFMStep(Idx: Integer);
procedure SetFMCTCSS(On_: Boolean);
procedure SetFMCTCSSTone(Idx: Integer);
procedure SetFMSquelch(On_: Boolean);
procedure SetFMSquelchLevel(V: Integer);
procedure SetFMRpt(Dir: Integer); // явное направление; снимает авто-флаг
procedure SetFMRptOffset(Hz: Double); // сдвиг репитера; применяет, если RPT вкл
procedure UpdateRptAutoState; // авто-MINUS для 2 м под FVfoA
// Каналы: применение/деактивация каналов вынесено в TChannelController
// (отдельный юнит-адаптер поверх контроллера), оркеструет команды ниже.
// Жизненный цикл движка / устройства
procedure StartStop;
procedure SetRun(On_: Boolean);
procedure StartRunning(SpectrumWidth: Integer); // Run=1 bring-up: General/DDC/DUC + DSP restore
procedure StopRunning; // Run=0: graceful-стоп RX-потока + сброс TX/телеметрии
function ConnectDevice(const Dev: THPSDRDevice): Boolean; // Connect + load-by-MAC; рендер через rfDevice
function ConnectAndRun(const Dev: THPSDRDevice; SpectrumWidth: Integer): Boolean; // ConnectDevice + band/xvtr/vfo restore + go-live
procedure StopAndDisconnect; // graceful Run=0 + персист настроек + Disconnect (headless)
// По IP/URI ищет устройство в store (discovered→saved), восстанавливая
// Kind/URI/Serial/BoardType/MAC. Нужно для Pluto (открывается по URI, выбор
// бэкенда — по Kind). Не найдено → простой HPSDR по IP. Общий путь GUI+демон.
function ResolveDevice(const IP: string): THPSDRDevice;
// Добирает MAC устройства, если он неизвестен (старт по сохранённому IP /
// AutoStart идёт без дискавери). MAC — ключ профиля настроек, без него
// настройки уезжают в «нулевой» профиль. Источники по порядку: список
// найденного в этой сессии → MAC из hpsdr_devices.ini → короткий поиск в
// сети (ответ discovery несёт MAC). False = добыть не удалось.
function EnsureDeviceMac(var Dev: THPSDRDevice): Boolean;
// Сохраняет устройство по введённому адресу. Схема URI ('ip:'/'usb:'/'local:')
// → Pluto (AddSavedPluto), иначе HPSDR-IP. Общий путь web-dev_add для GUI и
// демона (десктоп-диалог сохраняет Pluto своим путём через AddSavedPluto).
procedure AddSavedDevice(const AName, AAddr: string);
procedure Discover;
// Гарантирует, что FNetwork — бэкенд нужного типа (HPSDR/Pluto): при
// несовпадении пересоздаёт его, перенося колбэки данных. Вызывается из
// ConnectDevice по Dev.Kind. Discovery использует транзиентные экземпляры.
procedure EnsureBackend(Kind: TBackendKind);
// Возможности текущего бэкенда (для гейтинга UI / пресетов sample rate).
function BackendCaps: TBackendCaps;
// Единый источник пресетов sample-rate для UI (desktop overlay + web).
// Берётся из caps текущего бэкенда (HPSDR 48k..1536k / Pluto 576k..5760k).
function SampleRatePresets: TBackendRateArray;
// Единый источник band-плана для UI (HF / VHF-UHF по типу бэкенда).
function BandPlan: TBandPlanArray;
// True если активный бэкенд — Pluto (VHF/UHF band-план вместо HF).
function IsPluto: Boolean;
// True — активен QO-100 (Pluto + full-duplex XVTR-слот); гейт UI beacon.
function InQO100: Boolean;
// True — включён режим DUP (дисплей+RX держатся на TX). Гейт активности
// кнопки RX MUTE (self-monitor) на ЛЮБОМ бэкенде — без DUP звук на TX не идёт.
function DisplayDuplex: Boolean;
// Человекочитаемое имя подключённой платы для статус-строки (UI/Web).
// HPSDR → тип платы; Pluto/LibreSDR → hw_model + серийник (если есть).
function BoardDisplayName: string;
// Опрос аппаратной телеметрии Pluto (температура/RSSI) с троттлингом — зовётся
// с UI-таймера (MeterTimer @10 Гц), как ServiceBeaconLock. No-op для HPSDR.
// Результат — в FLastPlutoTempC/FLastPlutoRSSIdB (UI читает и форматирует).
procedure ServicePlutoTelemetry;
// Список URI для probe сетевых Pluto (mDNS недоступен) — задаёт фронтенд
// перед Discover (сохранённые Pluto + ручной ввод IP).
procedure SetPlutoProbeURIs(const URIs: array of string);
// Сидирует probe-список из сохранённых Pluto (для бэкенд-агностичного Discover
// без UI-диалога — демон). GUI добавляет ручной IP поверх в BuildProbeURIs.
procedure SeedPlutoProbeFromSaved;
// ---- События наружу ----
property OnInvoke: TUIInvokeEvent read FOnInvoke write FOnInvoke;
property OnStateChanged: TRadioStateEvent read FOnStateChanged write FOnStateChanged;
// Доп. наблюдатели состояния (помимо единственного OnStateChanged, который
// занимает MainForm). Нужны CAT/Andromeda для push индикаторов панели.
procedure AddStateListener(L: TRadioStateEvent);
// Снять наблюдателя. Обязателен для тех, кто умирает раньше контроллера
// (TCI-адаптер): иначе Changed() позовёт метод освобождённого объекта.
procedure RemoveStateListener(L: TRadioStateEvent);
// Пост-тюн хук: вызывается в КОНЦЕ SetVfoA (после Changed(rfVfoA), т.е. вне
// рендера) — сюда вешается канальная оркестрация (ChannelController.OnVfoTuned),
// чтобы любой путь тюнинга (desktop/web/CAT/энкодер) был канал-aware headless.
property OnAfterTune: TThreadMethod read FOnAfterTune write FOnAfterTune;
property OnBeforeStop: TThreadMethod read FOnBeforeStop write FOnBeforeStop;
property OnAudioConsume: TAudioConsumeEvent read FOnAudioConsume write FOnAudioConsume;
// Тапы RX-аудио: слушают, ничего не забирая (см. TRadioAudioTapEvent).
// Снимать обязан тот, кто умирает раньше контроллера.
procedure AddAudioTap(T: TRadioAudioTapEvent);
procedure RemoveAudioTap(T: TRadioAudioTapEvent);
// Тап сырого RX-IQ (потоки IQ по TCI). Пас в движок: он же и владелец
// потока, из которого тап зовётся. nil — снять.
procedure SetIQTap(T: TIQTapEvent);
// Частота дискретизации источника IQ для приёмника: главный тракт — rate
// устройства, доп. пан — rate его DDC.
function IQTapRateHz(PanId: Integer): Integer;
// TX-аудио от TCI-клиента: 48 кГц моно в тот же ринг, что и web-микрофон.
procedure PushTCIAudio(const Samples: array of Double; N: Integer);
// Локальный звук ПК (динамик RX / микрофон TX). Демон ставит False: аудио
// только через web (OnAudioConsume), локальная звуковая карта не трогается.
property LocalAudioEnabled: Boolean read FLocalAudio write FLocalAudio;
// Клиент TCI просит брать модуляцию из своего аудиопотока (TRX:0,true,tci,
// §4.2). Ставит адаптер, читает SetMOX при выборе источника.
property TCIMicRequested: Boolean read FTCIMicRequested write FTCIMicRequested;
// Идёт ли текущая передача с модуляцией из TCI (адаптеру — гнать ли
// маркеры TX_CHRONO).
property TCIMicActive: Boolean read FTCIMicActive;
property OnSpectrumData: TPixelDataEvent read FOnSpectrumData write FOnSpectrumData;
property OnWaterfallData: TPixelDataEvent read FOnWaterfallData write FOnWaterfallData;
end;
implementation
const
// Амплитуда TUN-тона (WDSP PostGen). НЕ 1.0: на резком старте тона фильтры
// TXA-тракта дают переходный выброс ~×1.18 (замерено: peak=1.176 при
// mag=1.0) поверх полной шкалы → жёсткий кламп 24-бит → сплэттер на старте
// TUN. 0.83×1.18≈0.98 — выброс остаётся в шкале. Мощность TUN задаёт drive
// (TUNLevel), падение уровня IQ ~1.6дБ компенсируется калибровкой TUNLevel.
TUN_TONE_MAG = 0.83;
// Состояния командной state machine PureSignal (порт Thetis eCMDState;
// Restore-ветка (7) не реализована — Save/Restore коррекции нет).
PS_ST_OFF = 0;
PS_ST_TURNON_AUTO = 1;
PS_ST_AUTOCAL = 2;
PS_ST_TURNON_SINGLE = 3;
PS_ST_SINGLECAL = 4;
PS_ST_STAYON = 5;
PS_ST_TURNOFF = 6;
constructor TDMRSliceDecoder.Create(AOwner: TRadioController;
ASliceId: Integer);
begin
inherited Create;
FOwner := AOwner;
FSliceId := ASliceId;
FDecoder := TDMRDecoder.Create;
FDecoder.SetInverted(True);
FDecoder.OnAudio := AudioReady;
FDecoder.SetEnabled(True);
end;
destructor TDMRSliceDecoder.Destroy;
begin
if Assigned(FDecoder) then
begin
FDecoder.SetEnabled(False);
FDecoder.OnAudio := nil;
FreeAndNil(FDecoder);
end;
FOwner := nil;
inherited Destroy;
end;
procedure TDMRSliceDecoder.FeedAudio(const Left, Right: array of Single;
Count: Integer);
begin
if Assigned(FDecoder) then FDecoder.FeedAudio(Left, Right, Count);
end;
procedure TDMRSliceDecoder.GetStatus(out S: TDMRStatus);
begin
if Assigned(FDecoder) then FDecoder.GetStatus(S)
else FillChar(S, SizeOf(S), 0);
end;
procedure TDMRSliceDecoder.AudioReady(const PCM: array of Single;
Count: Integer);
begin
if Assigned(FOwner) then FOwner.OnSliceDMRAudioReady(FSliceId, PCM, Count);
end;
constructor TRadioController.Create;
begin
inherited Create;
FSettings := TSettingsManager.Create; // владелец настроек (GUI и демон)
FDeviceStore := TDeviceStore.Create; // общий список устройств (desktop+web)
FAudioTapLock := TCriticalSection.Create;
FAudioTapCount := 0;
// Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны).
FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0;
FMode := MODE_USB; FFilter := 5;
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;
FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000;
FNextSliceId := 1; // мультислайсы: генератор id
FillChar(FSlices, SizeOf(FSlices), 0);
ResetPanDDCMaps; // паны: все слоты свободны
FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума)
FCurrentBand := 5; FCurrentXvtr := -1;
// Кадра ещё не было — 0 точек. Ненулевой счётчик заставил бы web рисовать
// нулевой (= 0 dBm, полная шкала) буфер белой полосой.
FSpectrumBufCount := 0; FWaterfallBufCount := 0; FWfAccumFresh := False;
FWebSpecPixels := 1024; // дефолт web.spec_pixels; хост переопределит из настроек
FPAMaxPower := 100.0;
FPlutoTxMaxAttDb := -10.0; // ADI-дефолт: линейный режим выходных каскадов
FWfAGCEnabled := True; FDitherEnabled := True; FRandomEnabled := True;
FWfPalette := 0; FWfGamma := 1.0; FDisplayPixels := 4096;
FWfSpeedDiv := 1; FSpecMSAA := 4;
FFMStepOn := True;
FRawFMDeviation := RAW_FM_DEV_DEFAULT;
FLocalAudio := True; // GUI: звук на локальную карту; демон выключит явно
TSettingsManager.DefaultCalibration(FCal);
FLastSMeter := -130; FLastSWR := 1; FLastSupplyV := -1; FLastSupplyA := -1;
FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE;
FLastPlutoRSSIdB := TELEMETRY_NONE;
FLastPlutoGainDb := TELEMETRY_NONE;
FPlutoTeleTick := 0; FPlutoTempBad := 0;
FillChar(FDevMAC, SizeOf(FDevMAC), 0);
end;
procedure TRadioController.AttachEngineCallbacks;
// Всё, что движок должен знать о САМОМ контроллере. Отдельным методом, потому
// что движок пересоздают на ходу (смена sample rate до старта): раньше эти
// колбэки навешивались только в CreateEngines, и после пересоздания движок
// оставался без отвода демодулятора — молча пропадали декодеры CW/DMR/FM RAW,
// аудио слайсов, калибровка дисплея и оба тапа TCI.
begin
if not Assigned(FDSPEngine) then Exit;
// Роутинг аудио слайсов на их звуковухи — внутренний (не зависит от
// фронтенда, в отличие от OnAudio/OnSpectrum).
FDSPEngine.OnSliceAudio := OnSliceAudioReady;
FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady;
FDSPEngine.OnDemodAudio := OnDemodAudioReady;
// Калибровка уровня спектра/водопада на диапазон/трансвертер — движок
// спрашивает её раз в кадр (сам он про диапазоны не знает).
FDSPEngine.OnDispCal := DispCalFor;
FDSPEngine.SetDisplayResLimit(FDisplayPixels);
// Тапы наружу (TCI): их владелец живёт дольше движка.
FDSPEngine.SetIQTap(FIQTap);
PushAudioTapsActive;
// Декодер маяка QO-100 создаётся позже самого движка (в CreateEngines ниже),
// зато при ПЕРЕсоздании движка он уже есть и его надо вернуть на место.
if Assigned(FBeaconDec) then FDSPEngine.SetBeaconDecoder(FBeaconDec);
end;
procedure TRadioController.CreateEngines(ASampleRate: Integer);
// Создаёт движки в полях контроллера. Колбэки (OnAudio/OnSpectrum/OnHPStatus…)
// навешивает вызывающий после возврата — они указывают на UI/демон.
begin
FNetwork := THPSDRNetwork.Create;
// BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший
// буфер вдвое сокращает построение downsampler в OpenChannel RXA.
FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
AttachEngineCallbacks;
// QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock).
FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink)
FBeaconLockHz := 0.0;
FBeaconLockOn := False;
// Декодер маяка: фронтенд BPSK 400 бод (тап IQ в движке).
FBeaconDec := TBeaconDecoder.Create;
// Тракт QO-100 (LNB+трансвертер) зеркалит спектр: проверено приборно (symMag ×4
// и prom 500-800 при invert=ON против шума при OFF, на наведении точно на маяк).
// Декодеру нужно сопряжение, чтобы наведённый offset доставлял маяк к DC.
FBeaconDecInvert := True;
FBcnZoomSpanHz := 40000.0;
FBcnZoomOffsetHz := 0.0;
FBcnZoomFollow := True;
FBcnZoomAuto := False;
FBeaconDec.SetInvert(FBeaconDecInvert);
FDSPEngine.SetBeaconDecoder(FBeaconDec);
// DMR front-end owns a worker thread. It stays idle until MODE_DMR is active.
FDMRDec := TDMRDecoder.Create;
// WDSP's FM discriminator polarity on the EWSDR RX path is opposite to the
// DMR dibit convention: captured handheld MS voice sync appears as MS data
// without this inversion (the two sync words are complements).
FDMRDec.SetInverted(True);
FDMRDec.OnAudio := OnDMRAudioReady;
// Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа
// формы / при подключении устройства).
FAudioOut := TAudioOutput.Create(48000);
FAudioOut.OutputBufferSize := FSettings.LoadAudioBufferSize;
FAudioIn := TAudioInput.Create(48000);
end;
procedure TRadioController.FreeEngines;
var i: Integer;
begin
// Stop new slice callbacks before their decoder/audio destinations vanish.
if Assigned(FDSPEngine) then
begin
FDSPEngine.OnSliceAudio := nil;
FDSPEngine.OnSliceDemodAudio := nil;
end;
// Stop the DMR worker before destroying any of its audio destinations.
if Assigned(FDMRDec) then
begin
FDMRDec.SetEnabled(False);
FDMRDec.OnAudio := nil;
FreeAndNil(FDMRDec);
end;
if Assigned(FNetwork) then
begin
if FNetwork.Connected then FNetwork.Disconnect;
FreeAndNil(FNetwork);
end;
// RemoveSlice takes the DSP slice lock. Besides closing the WDSP channel it
// waits out a callback that might already have entered before we detached it.
if Assigned(FDSPEngine) then
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then FDSPEngine.RemoveSlice(FSlices[i].Id);
// Слайсы: каналы уже закрыты, останавливаем декодеры и аудио.
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then
begin
FreeAndNil(FSlices[i].DMR);
if FSlices[i].Audio <> nil then
begin
FSlices[i].Audio.Close;
FreeAndNil(FSlices[i].Audio);
end;
FSlices[i].AudioIn := nil; // владелец — mic-пул (FreeMicPool ниже)
FSlices[i].Active := False;
end;
FreeMicPool;
if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end;
if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end;
if Assigned(FDSPEngine) then
begin
FDSPEngine.SetBeaconDecoder(nil);
FDSPEngine.Close;
FreeAndNil(FDSPEngine);
end;
if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec);
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(FCWElem) then
begin
FCWElem.AbortPlay;
FreeAndNil(FCWElem);
end;
if Assigned(FCWDec) then FCWDec.Enabled := False; // кольцо больше не кормим
FreeEngines;
// Декодер — ПОСЛЕ движка: его кольцо наполняет DSP-поток, и освобождать
// объект, пока поток жив, нельзя.
if Assigned(FCWDec) then FreeAndNil(FCWDec);
FreeAndNil(FDeviceStore);
FreeAndNil(FSettings);
// Лок тапов — последним: до FreeEngines по нему ходит DSP-поток.
FreeAndNil(FAudioTapLock);
inherited Destroy;
end;
procedure TRadioController.Changed(Field: TRadioField);
var i: Integer;
begin
if Assigned(FOnStateChanged) then FOnStateChanged(Self, Field);
for i := 0 to High(FStateListeners) do
if Assigned(FStateListeners[i]) then FStateListeners[i](Self, Field);
end;
procedure TRadioController.AddStateListener(L: TRadioStateEvent);
begin
if not Assigned(L) then Exit;
SetLength(FStateListeners, Length(FStateListeners) + 1);
FStateListeners[High(FStateListeners)] := L;
end;
procedure TRadioController.AddAudioTap(T: TRadioAudioTapEvent);
begin
if not Assigned(T) then Exit;
FAudioTapLock.Enter;
try
SetLength(FAudioTaps, Length(FAudioTaps) + 1);
FAudioTaps[High(FAudioTaps)] := T;
FAudioTapCount := Length(FAudioTaps);
finally
FAudioTapLock.Leave;
end;
PushAudioTapsActive;
end;
procedure TRadioController.RemoveAudioTap(T: TRadioAudioTapEvent);
var i, j: Integer;
begin
if not Assigned(T) then Exit;
FAudioTapLock.Enter;
try
for i := 0 to High(FAudioTaps) do
if (TMethod(FAudioTaps[i]).Code = TMethod(T).Code) and
(TMethod(FAudioTaps[i]).Data = TMethod(T).Data) then
begin
for j := i to High(FAudioTaps) - 1 do FAudioTaps[j] := FAudioTaps[j + 1];
SetLength(FAudioTaps, Length(FAudioTaps) - 1);
FAudioTapCount := Length(FAudioTaps);
Break;
end;
finally
FAudioTapLock.Leave;
end;
PushAudioTapsActive;
end;
procedure TRadioController.PushAudioTapsActive;
// Движку интересен только факт «слушатели есть»: от него зависит, считать ли
// молчащие (mute) слайсы — их звук всё равно уходит в RX_AUDIO по TCI.
begin
if Assigned(FDSPEngine) then
FDSPEngine.SetAudioTapsActive(FAudioTapCount > 0);
end;
procedure TRadioController.FireAudioTap(Kind: TRadioAudioKind;
PanId, SliceId: Integer; const Left, Right: array of Single; Count: Integer);
// DSP-поток. Без единого тапа (обычный случай) — одна проверка целого и выход:
// лок в звуковом маршруте на каждый блок иначе стоил бы дороже самой работы.
var i: Integer;
begin
if FAudioTapCount = 0 then Exit;
FAudioTapLock.Enter;
try
for i := 0 to High(FAudioTaps) do
if Assigned(FAudioTaps[i]) then
FAudioTaps[i](Kind, PanId, SliceId, Left, Right, Count);
finally
FAudioTapLock.Leave;
end;
end;
procedure TRadioController.SetIQTap(T: TIQTapEvent);
// Тап помним у себя: движок пересоздают (RecreateDSPEngine при смене sample
// rate до старта), и новый про тап не знал бы — поток IQ по TCI молча умирал
// на всю сессию. Восстанавливает его AttachEngineCallbacks.
begin
FIQTap := T;
if Assigned(FDSPEngine) then FDSPEngine.SetIQTap(T);
end;
function TRadioController.IQTapRateHz(PanId: Integer): Integer;
begin
if PanId <= 0 then Result := FSampleRate
else Result := PanDDCRateKHz(PanId) * 1000;
end;
procedure TRadioController.PushTCIAudio(const Samples: array of Double; N: Integer);
// Поток клиента TCI. Ринг микрофона в движке лок-фри и рассчитан ровно на это
// (тем же путём ходит web-микрофон); переполнение — дроп, как у железа.
begin
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.PushTXMicSamplesD(Samples, N);
end;
procedure TRadioController.RemoveStateListener(L: TRadioStateEvent);
var i, j: Integer;
begin
if not Assigned(L) then Exit;
for i := 0 to High(FStateListeners) do
if (TMethod(FStateListeners[i]).Code = TMethod(L).Code) and
(TMethod(FStateListeners[i]).Data = TMethod(L).Data) then
begin
for j := i to High(FStateListeners) - 1 do
FStateListeners[j] := FStateListeners[j + 1];
SetLength(FStateListeners, Length(FStateListeners) - 1);
Exit;
end;
end;
procedure TRadioController.PushNetworkState;
// Полный HP-кадр в радио: RX/TX частоты (через трансвертор), drive, состояние
// MOX. Вызывается командами, которые меняют центр/частоту/репитер-сдвиг.
begin
if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
// Состояние передачи для ЖЕЛЕЗА — RadioKeyed, а не FTransmitting: в
// телеграфе ключ замыкает прошивка, и реле T/R с антенной и OC обязаны
// ехать за ней. PTT в кадре этим не поднимается (он живёт в SendPTT).
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, RadioKeyed, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP;
end;
end;
procedure TRadioController.OnDDCIQ(DDCIndex: Integer; const Data: TDDCIQPacket);
// Сетевой поток: трекинг seq + подача IQ активного DDC в WDSP. UI не трогаем —
// статус рисует таймер по полям ниже. Backend-агностичен (Pluto синтезирует
// тот же TDDCIQPacket).
var
SamplesPerFrame: Integer;
Seq, ExpectedSeq: LongWord;
Delta: Int64;
begin
Inc(FRXPacketCount);
FRXLastPktTime := GetTickCount64;
Seq := (LongWord(Data.Seq[0]) shl 24) or (LongWord(Data.Seq[1]) shl 16)
or (LongWord(Data.Seq[2]) shl 8) or LongWord(Data.Seq[3]);
if (DDCIndex >= Low(FDDCLastSeq)) and (DDCIndex <= High(FDDCLastSeq)) then
begin
if Seq <> 0 then
begin
ExpectedSeq := FDDCLastSeq[DDCIndex] + 1;
if FDDCSeqValid[DDCIndex] and (Seq <> ExpectedSeq) then
begin
Inc(FSeqErrorCount);
FLastSeqErrorDDC := DDCIndex;
Delta := Int64(Seq) - Int64(ExpectedSeq);
FLastSeqErrorDelta := Delta;
FSeqOkStreak := 0;
end
else if FDDCSeqValid[DDCIndex] then
begin
if FSeqErrorCount > 0 then
begin
Inc(FSeqOkStreak);
if FSeqOkStreak >= 20 then
begin
FSeqErrorCount := 0;
FLastSeqErrorDDC := -1;
FLastSeqErrorDelta := 0;
FSeqOkStreak := 0;
end;
end;
end;
end;
FDDCLastSeq[DDCIndex] := Seq;
FDDCSeqValid[DDCIndex] := True;
end;
// Подаём IQ в DSP: активный DDC → главный тракт, пан-DDC → его пан (3.2),
// DDC0 при вооружённом PureSignal на TX → feedback в pscc (DDC0 несёт
// interleaved пары DDC0+DDC1; на PS-платах главный DDC ≥ 2, конфликта нет).
if FWDSPReady then
begin
SamplesPerFrame := (Integer(Data.SamplesPerFrame[0]) shl 8) or
Integer(Data.SamplesPerFrame[1]);
if SamplesPerFrame <= 0 then SamplesPerFrame := 238; // fallback
if (DDCIndex = 0) and FPSFeedbackOn and (FActiveDDC <> 0) then
begin
if FTransmitting then
FDSPEngine.PushPSFeedback(Data.IQData, 0, SamplesPerFrame);
end
else if DDCIndex = FActiveDDC then
FDSPEngine.PushDDCPacket(Data.IQData, 0, SamplesPerFrame)
else if (DDCIndex >= 0) and (DDCIndex < MAX_DDCS)
and (FDDCPanMap[DDCIndex] > 0) then
FDSPEngine.PushPanIQ(FDDCPanMap[DDCIndex], Data.IQData, 0, SamplesPerFrame);
end;
// Запоминаем последний seq — статус обновит таймер (без Synchronize в hot path)
FLastDDCSeq := Seq;
FLastDDCIndex := DDCIndex;
end;
procedure TRadioController.OnTXIQ(const Buf: array of Double; Count: Integer);
// WDSP TXA отдаёт IQ-пары @ TX rate. Копим хвост между вызовами и шлём только
// полные DUC-пакеты по 240 (зануление хвоста давало 47 Гц гул). Клампим в
// 24-bit, чтобы пики компрессора/EQ (чуть >1.0) не оборачивались через wrap.
// Backend-агностично: Pluto-backend в SendDUCIQ переведёт 24-bit→int16.
function ScaleIQ24(V: Double): Integer;
var S: Double;
begin
S := V * 8388607.0;
if S > 8388607.0 then Result := 8388607
else if S < -8388608.0 then Result := -8388608
else Result := Round(S);
end;
var
i: Integer;
begin
if not (Assigned(FNetwork) and FNetwork.Connected) then Exit;
for i := 0 to Count - 1 do
begin
FDUCPendingI[FDUCPendingCount] := ScaleIQ24(Buf[i * 2]);
FDUCPendingQ[FDUCPendingCount] := ScaleIQ24(Buf[i * 2 + 1]);
Inc(FDUCPendingCount);
if FDUCPendingCount >= 240 then
begin
FNetwork.SendDUCIQ(FDUCPendingI, FDUCPendingQ);
FDUCPendingCount := 0;
end;
end;
end;
procedure TRadioController.OnHPStatus(const Status: THighPriorityStatus);
// Сетевой поток: декод HP-статуса (board-specific supply/power/SWR) → pending →
// маршалинг в ApplyHPStatus (сглаживание + overload + HWPTT уже на потоке UI/ядра).
const
SWR_MIN_FWD_W = 1.0; // ниже — fwd это ADC-шум, SWR не считаем
SWR_CAP = 9.9;
BAND_6M = 10; // индекс 6m в HF-плане (160m..6m)
var
FwdPwr, RevPwr: Word;
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;
SupplyV := -1.0;
SupplyA := -1.0;
// Board 5 (Orion MkII): напряжение на UserADC0, ток на UserADC1 (как Thetis).
// Прочие платы — в SupplyVolts-байтах, без тока.
if BoardSupportsSupplyVoltage(FNetwork.Device.BoardType) then
begin
if FNetwork.Device.BoardType = 5 then
SupplyV := ADCToSupplyVolts(
(Status.UserADC0Hi shl 8) or Status.UserADC0Lo,
FNetwork.Device.BoardType)
else
SupplyV := ADCToSupplyVolts(
(Status.SupplyVoltsHi shl 8) or Status.SupplyVoltsLo,
FNetwork.Device.BoardType);
if SupplyV >= 0 then SupplyV := SupplyV * FCal.SupplyVoltCal;
if BoardSupportsSupplyCurrent(FNetwork.Device.BoardType) then
SupplyA := ADCToSupplyCurrent(
(Status.UserADC1Hi shl 8) or Status.UserADC1Lo,
FNetwork.Device.BoardType,
FCal.AmpVoffMV, FCal.AmpSensMVA);
end;
// Детектор Alex-моста: константы по модели (вкладка Calibration) + per-band
// множитель. Множитель общий для FWD/REV, поэтому КСВ от него не зависит
// (как Thetis PABandCalMult).
Bi := TxBandIdx;
Is6m := Bi = BAND_6M;
DetK := 1.0;
if (Bi >= 0) and (Bi < CFG_BAND_COUNT) and (FCal.PADetCal[Bi] > 0) then
DetK := FCal.PADetCal[Bi];
FwdW := AlexDetToWatts(FwdPwr, FCal.PwrDetModel, False, Is6m) * DetK;
RevW := AlexDetToWatts(RevPwr, FCal.PwrDetModel, True, Is6m) * DetK;
// FTransmitting — программный флаг; HPS_PTT — вход HW-PTT (footswitch/mic).
IsTx := FTransmitting or ((Status.StatusBits and HPS_PTT) <> 0);
// SWR = (1+ρ)/(1-ρ), ρ = sqrt(RevW/FwdW) (Thetis); только при значимой
// fwd-мощности.
if IsTx and (FwdW >= SWR_MIN_FWD_W) then
begin
Rho := Sqrt(RevW / FwdW);
if Rho >= 0.99 then
SWRV := SWR_CAP
else
begin
SWRV := (1.0 + Rho) / (1.0 - Rho);
if SWRV > SWR_CAP then SWRV := SWR_CAP;
if SWRV < 1.0 then SWRV := 1.0;
end;
end else
SWRV := 1.0;
if not IsTx then FwdW := 0;
FHPpFwdW := FwdW;
FHPpSWR := SWRV;
FHPpSupplyV := SupplyV;
FHPpSupplyA := SupplyA;
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;
procedure TRadioController.ApplyHPStatus;
// Маршалится из OnHPStatus. PEP-style баллистика (мгновенная атака, медленный
// спад) для FwdW/SWR; EMA для supply. + overload-событие + HWPTT-фронт → SetMOX.
const
DECAY_FWD = 0.02;
DECAY_SWR = 0.015;
ALPHA_SUPPLY = 0.05;
begin
if FHPpFwdW <= 0 then
FLastFwdW := 0
else if FHPpFwdW > FLastFwdW then
FLastFwdW := FHPpFwdW // attack: instant
else
FLastFwdW := DECAY_FWD * FHPpFwdW + (1.0 - DECAY_FWD) * FLastFwdW;
if FHPpSWR <= 1.0 then
FLastSWR := 1.0
else if FHPpSWR > FLastSWR then
FLastSWR := FHPpSWR // attack: instant
else
FLastSWR := DECAY_SWR * FHPpSWR + (1.0 - DECAY_SWR) * FLastSWR;
if FHPpSupplyV >= 0 then
begin
if FLastSupplyV < 0 then
FLastSupplyV := FHPpSupplyV // первое значение — без сглаживания
else
FLastSupplyV := ALPHA_SUPPLY * FHPpSupplyV + (1.0 - ALPHA_SUPPLY) * FLastSupplyV;
end else
FLastSupplyV := -1.0;
if FHPpSupplyA >= 0 then
begin
if FLastSupplyA < 0 then
FLastSupplyA := FHPpSupplyA
else
FLastSupplyA := ALPHA_SUPPLY * FHPpSupplyA + (1.0 - ALPHA_SUPPLY) * FLastSupplyA;
end else
FLastSupplyA := -1.0;
FLastPLLLock := FHPpPLL;
// ADC overload — индикатор рисует UI по rfADCOverload (только на изменении).
if FADCOverloadActive <> (FHPpOverload <> 0) then
begin
FADCOverloadActive := FHPpOverload <> 0;
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.
if FHPpHWPTT <> FHWPTTActive then
begin
FHWPTTActive := FHPpHWPTT;
if not FTuning then
begin
if FHPpHWPTT then
begin
if not FTransmitting then
begin
SetMOX(True);
FHWPTTStartedTX := True;
end;
end
else
begin
if FHWPTTStartedTX then
begin
FHWPTTStartedTX := False;
SetMOX(False);
end;
end;
end;
end;
end;
procedure TRadioController.OnAudioReady(const Left, Right: array of Single;
Count: Integer);
// DSP-поток: маршрут RX-аудио. Встроенный динамик трансивера (DDC Audio,
// port 1028) — независимый канал. Затем web-адаптер может перехватить аудио
// (активный клиент); если перехвачено — в локальный AudioOut не пишем.
begin
// Decoded DMR PCM will use this route later. Until then suppress raw 4FSK;
// 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);
// Линейный выход (TCI LINEOUT_STREAM) — здесь: это ровно то, что слышно,
// вместе с сайдтоном и после громкости. Тап слушает, ничего не забирая,
// поэтому стоит ДО перехвата web-клиентом.
FireAudioTap(rakLineOut, 0, 0, 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);
FireAudioTap(rakLineOut, 0, 0, Left, Right, Count);
if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, Count) then
Exit;
// Без локального звука (демон) на этом маршрут заканчивается: если web-клиент
// не активен, аудио просто отбрасывается, а не идёт на звуковую карту хоста.
if FLocalAudio then
FAudioOut.Write(Left, Right, Count);
end;
procedure TRadioController.OnDemodAudioReady(const Left, Right: array of Single;
Count: Integer);
// DSP thread, pre-volume/pre-mute. FeedAudio only copies into a bounded ring.
begin
// Аудиопоток приёмника для TCI (§3.4) — именно отсюда: скиммеру и цифре
// нужен звук приёмника, а не то, что осталось после ручки громкости и
// мьюта. Линейный выход у TCI отдельным потоком (см. OnAudioReady).
FireAudioTap(rakDemod, 0, 0, Left, Right, Count);
if FMode = MODE_DMR then
begin
if not Assigned(FDMRDec) then Exit;
if not FDMRDec.Enabled then FDMRDec.SetEnabled(True);
FDMRDec.FeedAudio(Left, Right, Count);
end
else if FMode = MODE_FMRAW then
begin
// FM RAW decoder audio deliberately bypasses speaker volume and mute.
// Значит и линейный выход у него — вот этот сигнал: OnAudioReady для
// FM RAW выходит в самом начале, и без этой строки поток LINEOUT по TCI
// на FM RAW не шёл вовсе.
FireAudioTap(rakLineOut, 0, 0, Left, Right, Count);
if FLocalAudio and Assigned(FAudioOut) then
FAudioOut.Write(Left, Right, Count);
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;
Count: Integer);
// DMR worker thread, 8 kHz mono from mbelib. Linear 6x interpolation brings
// it to the common 48 kHz output route used by local, web and radio speakers.
var
Left, Right: array of Single;
i, Phase, OutPos, N: Integer;
A, B, V, Gain: Single;
begin
if (FMode <> MODE_DMR) or FMuted or (Count <= 0) then Exit;
N := Min(Count, Length(PCM));
SetLength(Left, N * 6);
SetLength(Right, N * 6);
Gain := FVolume / 100.0;
OutPos := 0;
for i := 0 to N - 1 do
begin
A := PCM[i];
if i + 1 < N then B := PCM[i + 1] else B := A;
for Phase := 0 to 5 do
begin
V := (A + (B - A) * (Phase / 6.0)) * Gain;
Left[OutPos] := V;
Right[OutPos] := V;
Inc(OutPos);
end;
end;
if FSendAudioToRadio and Assigned(FNetwork) then
FNetwork.SendSpeakerAudio(Left, Right, OutPos);
FireAudioTap(rakLineOut, 0, 0, Left, Right, OutPos);
if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, OutPos) then Exit;
if FLocalAudio and Assigned(FAudioOut) then FAudioOut.Write(Left, Right, OutPos);
end;
// ---------------------------------------------------------------------------
// Мультислайсы (софт-fan-out)
// ---------------------------------------------------------------------------
function TRadioController.FindSliceIndex(Id: Integer): Integer;
var i: Integer;
begin
Result := -1;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].Id = Id) then Exit(i);
end;
// DSP-поток: аудио готового слайса → его собственный выход. Слайс — локальный
// монитор (в web/на радио не уходит), поэтому только на свою звуковуху.
procedure TRadioController.OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
// Линейный выход слайса для TCI — до гейта локального звука: в демоне
// звуковухи нет вовсе, а поток клиенту идти обязан.
FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, Count);
if not FLocalAudio then Exit;
if FSlices[idx].Mode in [MODE_DMR, MODE_FMRAW] then Exit;
if SliceMutedByTx(idx) then Exit;
if FSlices[idx].Audio <> nil then
FSlices[idx].Audio.Write(Left, Right, Count);
end;
// Слайс глушится на передаче? В full-duplex (Pluto/QO-100) RX-тракт живёт и во
// время TX, поэтому иначе оператор слышит сам себя на КАЖДОМ слайсе. У главного
// приёмника за это отвечает FRxMuteOnTx (кнопка RX MUTE), у слайса — свой флаг.
function TRadioController.SliceMutedByTx(Idx: Integer): Boolean;
begin
Result := FTransmitting and FSlices[Idx].RxMuteOnTx;
end;
// DSP thread: flat, volume-independent discriminator of the corresponding
// WDSP slice channel. FeedAudio only copies it into that decoder's ring.
procedure TRadioController.OnSliceDemodAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
FireAudioTap(rakDemod, FSlices[idx].PanId, SliceId, Left, Right, Count);
if (FSlices[idx].Mode = MODE_DMR) and Assigned(FSlices[idx].DMR) then
FSlices[idx].DMR.FeedAudio(Left, Right, Count);
if FSlices[idx].Mode = MODE_FMRAW then
begin
// У FM RAW звук идёт мимо громкости и мьюта, поэтому линейный выход слайса
// — это он же (OnSliceAudioReady для FM RAW не вызывается вовсе). Без этой
// строки поток LINEOUT по TCI на FM RAW-слайсе молчал.
FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, Count);
if FLocalAudio and Assigned(FSlices[idx].Audio) then
FSlices[idx].Audio.Write(Left, Right, Count);
end;
end;
// DMR worker thread: decoded 8 kHz mono -> this slice's 48 kHz local output.
procedure TRadioController.OnSliceDMRAudioReady(SliceId: Integer;
const PCM: array of Single; Count: Integer);
var
Left, Right: array of Single;
idx, i, Phase, OutPos, N: Integer;
A, B, V, Gain: Single;
begin
if (Count <= 0) or not (FLocalAudio or (FAudioTapCount > 0)) then Exit;
idx := FindSliceIndex(SliceId);
if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or FSlices[idx].Muted then Exit;
if SliceMutedByTx(idx) then Exit;
// Звуковухи может не быть вовсе (демон), но линейный выход слайса слушает
// ещё и TCI — тогда декодированный звук всё равно надо построить.
if not (Assigned(FSlices[idx].Audio) or (FAudioTapCount > 0)) then Exit;
N := Min(Count, Length(PCM));
SetLength(Left, N * 6);
SetLength(Right, N * 6);
Gain := FSlices[idx].Volume;
OutPos := 0;
for i := 0 to N - 1 do
begin
A := PCM[i];
if i + 1 < N then B := PCM[i + 1] else B := A;
for Phase := 0 to 5 do
begin
V := (A + (B - A) * (Phase / 6.0)) * Gain;
Left[OutPos] := V;
Right[OutPos] := V;
Inc(OutPos);
end;
end;
// Линейный выход DMR-слайса — декодированный звук, как и у главного тракта
// (см. OnDMRAudioReady): 4FSK до декодера в LINEOUT слать нечего.
FireAudioTap(rakLineOut, FSlices[idx].PanId, SliceId, Left, Right, OutPos);
if FLocalAudio and Assigned(FSlices[idx].Audio) then
FSlices[idx].Audio.Write(Left, Right, OutPos);
end;
procedure TRadioController.RefreshSliceShifts;
// Смена центра ГЛАВНОГО захвата: пересчитываем только слайсы пана 0 —
// слайсы доп. панов привязаны к центрам своих DDC (SetPanDDCFreq).
var i: Integer;
begin
if not Assigned(FDSPEngine) then Exit;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = 0) then
FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FCenterFreq);
end;
function TRadioController.PanCenterHz(PanId: Integer): Double;
begin
if (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0) then
Result := FPanFreqHz[PanId]
else
Result := FCenterFreq;
end;
procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double; PanId: Integer);
// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга).
var Half, Rate: Double;
begin
if (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0) then
Rate := FPanRateKHz[PanId] * 1000.0
else
Rate := FSampleRate;
Half := Rate * 0.475;
LoHz := PanCenterHz(PanId) - Half;
HiHz := PanCenterHz(PanId) + Half;
end;
function TRadioController.SliceFitsCapture(TargetHz: Double; PanId: Integer): Boolean;
var Lo, Hi: Double;
begin
CapturedSpan(Lo, Hi, PanId);
Result := (TargetHz >= Lo) and (TargetHz <= Hi);
end;
function TRadioController.CurrentBandEdges(out LoHz, HiHz: Double): Boolean;
begin
LoHz := 0; HiHz := 0;
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
FXvtrSettings.Entries[FCurrentXvtr].Enabled then
begin
// Трансвертер: диапазон — его собственный (2 м = 144..146 и т.п.).
LoHz := FXvtrSettings.Entries[FCurrentXvtr].FreqBegin;
HiHz := FXvtrSettings.Entries[FCurrentXvtr].FreqEnd;
Result := HiHz > LoHz;
end
else
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;
procedure TRadioController.SliceStateChanged(Id: Integer);
// У слайса сменилось что-то кроме частоты: режим, фильтр, АРУ, DSP, громкость,
// мьют, шумоподавитель. Зовут сами сеттеры — слайс правят и UI, и CAT, и TCI, а
// узнать об этом должны все фронтенды сразу (у главного тракта для этого есть
// свои поля rfMode/rfFilter/…, у слайсов не было ничего).
begin
FSliceFreqId := Id;
Changed(rfSliceState);
end;
function TRadioController.TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean;
// Свобода внешней программы: она двигает слайс куда угодно ВНУТРИ включённого
// диапазона (2 м на трансвертере: 144.174 при окне на 145.5 — можно), но сменить
// диапазон (7 МГц) — нет: это решение оператора, а не управляющего ПО.
//
// Если цель вне захваченной полосы — окно DDC переезжает центром на цель:
// • доп. пан (свой DDC) — свободно;
// • пан 0 — только если главный приёмник останется внутри нового окна
// (одно железное окно на двоих; иначе главный оглох бы).
var
idx, PanId: Integer;
Lo, Hi, Half, Dist, NewCenter, VisLo, VisHi: Double;
begin
Result := False;
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
if not CurrentBandEdges(Lo, Hi) then Exit;
if (TargetHz < Lo) or (TargetHz > Hi) then Exit; // другой диапазон — отказ
PanId := FSlices[idx].PanId;
if SliceFitsCapture(TargetHz, PanId) then
begin
SetSliceTarget(Id, TargetHz); // он же шлёт SliceFreqChanged
Exit(True);
end;
if PanId > 0 then
begin
if not PanDDCActive(PanId) then Exit;
VisibleFreqBounds(VisLo, VisHi);
NewCenter := EnsureRange(TargetHz, VisLo, VisHi);
SetPanDDCFreq(PanId, NewCenter);
SetSliceTarget(Id, TargetHz);
Changed(rfPanFreq); // UI: шапка/зум-бар/флаги пана под новый центр
// (несёт и перезалив флагов — SliceFreqChanged лишний)
Result := True;
end
else
begin
// Пан 0: центр общий с главным приёмником. Ставим его посередине между
// целью и активным VFO — тогда в окно попадают оба; не помещаются —
// отказываем (перестраивать главный ради слайса нельзя).
Half := FSampleRate * 0.475;
Dist := Abs(TargetHz - ActiveVfoHz);
if Dist > Half * 2 * 0.95 then Exit;
NewCenter := (TargetHz + ActiveVfoHz) / 2;
SetCenter(NewCenter); // несёт Changed(rfCenterFreq) + сдвиги слайсов
SetSliceTarget(Id, TargetHz); // SliceFreqChanged — внутри: rfCenterFreq
// флаги не перезаливает, нужен свой
Result := True;
end;
end;
{ ---- Панадаптеры на аппаратных DDC (этап 3.1) ---- }
procedure TRadioController.ResetPanDDCMaps;
var i: Integer;
begin
for i := 1 to MAX_PANS - 1 do
begin
FPanDDCIdx[i] := -1;
FPanFreqHz[i] := 0;
FPanRateKHz[i] := 0;
FPanADC[i] := 0;
FPanZoomF[i] := 0.0;
FPanPanSl[i] := 0.5;
end;
for i := 0 to MAX_DDCS - 1 do
FDDCPanMap[i] := -1;
end;
function TRadioController.PanRateAllowed(RateKHz: Word): Boolean;
// Полная сетка железа (Caps.RatePresets) минус некратные 48 кГц: движок
// требует целого отношения rate пана к аудио-rate (блок/WDSP-каналы).
var i: Integer;
begin
Result := False;
if not Assigned(FNetwork) then Exit;
if (RateKHz = 0) or ((Integer(RateKHz) * 1000) mod 48000 <> 0) then Exit;
for i := 0 to High(FNetwork.Caps.RatePresets) do
if FNetwork.Caps.RatePresets[i] = Integer(RateKHz) * 1000 then Exit(True);
end;
function TRadioController.AddPanDDC(PanId: Integer; FreqHz: Double;
RateKHz: Word; ADCSource: Byte): Boolean;
var
i, ddc: Integer;
begin
Result := False;
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
if FPanDDCIdx[PanId] >= 0 then Exit; // уже создан
if not (Assigned(FNetwork) and FRunning) then Exit;
if PanId >= FNetwork.Caps.MaxPans then Exit; // бэкенд не умеет (Pluto=1)
if not PanRateAllowed(RateKHz) then RateKHz := 384;
// hw-индекс: следующий свободный после главного DDC (слот-стабильности не
// нужно — hw-индекс скрыт за PanId, наружу торчит только PanId).
ddc := -1;
for i := FActiveDDC + 1 to FActiveDDC + MAX_PANS - 1 do
if (i < MAX_DDCS) and (FDDCPanMap[i] < 0) then begin ddc := i; Break; end;
if ddc < 0 then Exit;
// Движок: аккумулятор + analyzer пана (до включения DDC — к приходу первого
// пакета пан уже готов; PushPanIQ без пана просто дропает).
if FWDSPReady and not FDSPEngine.AddPan(PanId, Integer(RateKHz) * 1000) then
Exit;
FPanDDCIdx[PanId] := ddc;
FPanFreqHz[PanId] := FreqHz;
FPanRateKHz[PanId] := RateKHz;
FPanADC[PanId] := ADCSource;
FPanZoomF[PanId] := 0.0;
FPanPanSl[PanId] := 0.5;
FDDCPanMap[ddc] := PanId;
// В железо — IF-частота: FPanFreqHz хранит видимую (как FCenterFreq).
FNetwork.SetPanDDC(ddc, True, XvtrTranslate(FreqHz), RateKHz, ADCSource);
Result := True;
end;
function TRadioController.SetPanDDCRate(PanId: Integer; RateKHz: Word): Boolean;
var
i: Integer;
LoHz, HiHz: Double;
begin
Result := False;
if not PanDDCActive(PanId) then Exit;
if not PanRateAllowed(RateKHz) then Exit;
if FPanRateKHz[PanId] = RateKHz then Exit(True);
// Движок первым: под FSliceLock пересобирает аккумулятор/анализатор и
// переоткрывает слайс-каналы; провалился — состояние не трогаем.
if FWDSPReady and not FDSPEngine.SetPanRate(PanId, Integer(RateKHz) * 1000) then
Exit;
FPanRateKHz[PanId] := RateKHz;
// DDC железа: реконфиг rate (RebuildDDCSpecific + freq-слово, IF при XVTR).
if Assigned(FNetwork) then
FNetwork.SetPanDDC(FPanDDCIdx[PanId], True, XvtrTranslate(FPanFreqHz[PanId]),
RateKHz, FPanADC[PanId]);
// Слайсы, выпавшие из сузившейся полосы, — к её краям (абс. TargetHz + NCO).
CapturedSpan(LoHz, HiHz, PanId);
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) then
begin
if FSlices[i].TargetHz < LoHz then SetSliceTarget(FSlices[i].Id, LoHz);
if FSlices[i].TargetHz > HiHz then SetSliceTarget(FSlices[i].Id, HiHz);
end;
Result := True;
end;
function TRadioController.MovePanDDC(PanId, NewDDC: Integer): Boolean;
// Тот же пан (движок/анализатор/слайсы не трогаем) — только другой аппаратный
// DDC: старый гасим, новый включаем с теми же freq/rate/ADC. Короткий разрыв IQ
// на время реконфига — норма.
begin
Result := False;
if not PanDDCActive(PanId) then Exit;
if not Assigned(FNetwork) then Exit;
if (NewDDC <= FActiveDDC) or (NewDDC > MaxDDCIndex) then Exit;
if FPanDDCIdx[PanId] = NewDDC then Exit(True);
if FDDCPanMap[NewDDC] >= 0 then Exit; // занят другим паном
FNetwork.SetPanDDC(FPanDDCIdx[PanId], False, 0, 0, 0);
FDDCPanMap[FPanDDCIdx[PanId]] := -1;
// Seq-трекинг обоих индексов начинаем заново (новый поток — новый счётчик).
if FPanDDCIdx[PanId] <= High(FDDCSeqValid) then
FDDCSeqValid[FPanDDCIdx[PanId]] := False;
if NewDDC <= High(FDDCSeqValid) then
FDDCSeqValid[NewDDC] := False;
FPanDDCIdx[PanId] := NewDDC;
FDDCPanMap[NewDDC] := PanId;
FNetwork.SetPanDDC(NewDDC, True, XvtrTranslate(FPanFreqHz[PanId]),
FPanRateKHz[PanId], FPanADC[PanId]);
Result := True;
end;
function TRadioController.PanDDCIndex(PanId: Integer): Integer;
begin
if (PanId >= 1) and (PanId < MAX_PANS) then Result := FPanDDCIdx[PanId]
else if PanId = 0 then Result := FActiveDDC
else Result := -1;
end;
function TRadioController.MaxDDCIndex: Integer;
// Железо openHPSDR (Orion/Saturn) поднимает DDC0..DDC7 — DDC8 и выше молчат
// (проверено на плате: enable-бит ставится, поток не идёт).
begin
Result := 7;
end;
function TRadioController.DDCFree(DDCIdx: Integer): Boolean;
begin
Result := (DDCIdx > FActiveDDC) and (DDCIdx <= MaxDDCIndex)
and (FDDCPanMap[DDCIdx] < 0);
end;
procedure TRadioController.RemovePanDDC(PanId: Integer);
var ddc, i: Integer;
begin
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
ddc := FPanDDCIdx[PanId];
if ddc < 0 then Exit;
// Сначала снимаем роутинг (OnDDCIQ в сетевом потоке перестаёт брать пакеты
// этого DDC), потом слайсы пана (их звуковухи), потом движок и DDC в железе.
FDDCPanMap[ddc] := -1;
FPanDDCIdx[PanId] := -1;
FPanFreqHz[PanId] := 0;
FPanRateKHz[PanId] := 0;
FPanADC[PanId] := 0;
FPanZoomF[PanId] := 0.0;
FPanPanSl[PanId] := 0.5;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) then
RemoveSlice(FSlices[i].Id);
if FWDSPReady then FDSPEngine.RemovePan(PanId);
if Assigned(FNetwork) then FNetwork.SetPanDDC(ddc, False, 0, 0, 0);
end;
procedure TRadioController.SetPanDDCFreq(PanId: Integer; FreqHz: Double);
var i: Integer;
begin
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
if FPanDDCIdx[PanId] < 0 then Exit;
FPanFreqHz[PanId] := FreqHz; // видимая; в железо — IF при активном XVTR
if Assigned(FNetwork) then
FNetwork.SetPanDDCFreq(FPanDDCIdx[PanId], XvtrTranslate(FreqHz));
// Слайсы пана держат абсолютный TargetHz — обновляем их NCO-сдвиги
// относительно нового центра (аналог RefreshSliceShifts главного).
if Assigned(FDSPEngine) then
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) then
FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FreqHz);
end;
procedure TRadioController.VisibleFreqBounds(out LoHz, HiHz: Double);
begin
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
FXvtrSettings.Entries[FCurrentXvtr].Enabled then
begin
// Активный XVTR: видимые частоты живут в его диапазоне, в железо уходит
// IF (XvtrTranslate) — клампить видимую частоту пределами АЦП нельзя.
LoHz := FXvtrSettings.Entries[FCurrentXvtr].FreqBegin;
HiHz := FXvtrSettings.Entries[FCurrentXvtr].FreqEnd;
end
else if Assigned(FNetwork) then
begin
LoHz := FNetwork.Caps.MinFreqHz;
HiHz := FNetwork.Caps.MaxFreqHz;
end
else
begin
LoHz := 0;
HiHz := 61440000;
end;
end;
procedure TRadioController.RefreshPanDDCs;
var
p, i: Integer;
LoV, HiV, LoHz, HiHz: Double;
begin
VisibleFreqBounds(LoV, HiV);
for p := 1 to MAX_PANS - 1 do
if PanDDCActive(p) then
begin
// Видимый центр пана — в новое допустимое окно, freq-слово DDC — через
// свежую трансляцию (вход/выход XVTR, правка LO/LOError).
FPanFreqHz[p] := EnsureRange(FPanFreqHz[p], LoV, HiV);
if Assigned(FNetwork) then
FNetwork.SetPanDDCFreq(FPanDDCIdx[p], XvtrTranslate(FPanFreqHz[p]));
// Слайсы держат абсолютный видимый TargetHz: выпавшие из полосы захвата
// пана — к её краям (SetSliceTarget обновит NCO), остальные — свежий NCO.
CapturedSpan(LoHz, HiHz, p);
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = p) then
begin
if FSlices[i].TargetHz < LoHz then SetSliceTarget(FSlices[i].Id, LoHz)
else if FSlices[i].TargetHz > HiHz then SetSliceTarget(FSlices[i].Id, HiHz)
else if Assigned(FDSPEngine) then
FDSPEngine.SetSliceShift(FSlices[i].Id,
FSlices[i].TargetHz - FPanFreqHz[p]);
end;
end;
end;
function TRadioController.PanDDCActive(PanId: Integer): Boolean;
begin
Result := (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0);
end;
function TRadioController.PanDDCFreq(PanId: Integer): Double;
begin
if PanDDCActive(PanId) then Result := FPanFreqHz[PanId]
else Result := 0;
end;
function TRadioController.PanDDCRateKHz(PanId: Integer): Word;
begin
if PanDDCActive(PanId) then Result := FPanRateKHz[PanId]
else Result := 0;
end;
procedure TRadioController.SetAnt936x(const A: TAD936xAnt);
// Новая таблица «диапазон → разъём» из настроек: сохранить + применить текущий.
begin
FAnt936x := A;
if FDevConnected then
begin
FSettings.SaveAnt936x(FDevMAC, FAnt936x);
FSettings.Save;
end;
ApplyAntPorts;
end;
procedure TRadioController.ApplyAntPorts;
// Антенна — свойство диапазона (как RxAnt/TxAnt у Alex): при смене диапазона или
// входе/выходе из трансвертера включаем разъёмы, выбранные для этого контекста.
// RX-разъём берём по диапазону ПРИЁМА, TX — по диапазону ПЕРЕДАЧИ: с мультислайс-TX
// (или split) передача может идти не на частоте центра приёма.
var
Rx, Tx: Byte;
BiRx, BiTx: Integer;
begin
if not (Assigned(FNetwork) and FNetwork.Connected) then Exit;
if not FNetwork.Caps.HasAntSwitch then Exit;
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then
begin
Rx := FAnt936x.XvtrRxAnt[FCurrentXvtr];
Tx := FAnt936x.XvtrTxAnt[FCurrentXvtr];
end
else
begin
// Диапазоны — по ВИДИМЫМ частотам (VU-план AD936x).
BiRx := FreqToBandIdx(FCenterFreq, IsPluto);
if (BiRx < 0) or (BiRx >= CFG_BAND_COUNT) then Exit; // вне плана — не трогаем
Rx := FAnt936x.RxAnt[BiRx];
BiTx := FreqToBandIdx(ActiveTXFreqHz, IsPluto);
if (BiTx < 0) or (BiTx >= CFG_BAND_COUNT) then BiTx := BiRx; // TX вне плана — как RX
Tx := FAnt936x.TxAnt[BiTx];
end;
FNetwork.SetAntPorts(Rx, Tx);
end;
function TRadioController.PanADC(PanId: Integer): Byte;
begin
if PanId = 0 then Result := FMainADCSrc
else if PanDDCActive(PanId) then Result := FPanADC[PanId]
else Result := 0;
end;
function TRadioController.SetPanADC(PanId: Integer; ADC: Byte): Boolean;
begin
Result := False;
if not (Assigned(FNetwork) and FRunning) then Exit;
if ADC >= FNetwork.Caps.NumADCs then Exit;
if PanId = 0 then
begin
// Главный DDC: тот же ConfigureDDCs, что при смене rate (кэш+RebuildDDCSpecific).
if FMainADCSrc = ADC then Exit(True);
FMainADCSrc := ADC;
FNetwork.ConfigureDDCs(1, FSampleRate div 1000, ADC,
FDitherEnabled, FRandomEnabled);
Exit(True);
end;
if not PanDDCActive(PanId) then Exit;
if FPanADC[PanId] = ADC then Exit(True);
FPanADC[PanId] := ADC;
FNetwork.SetPanDDC(FPanDDCIdx[PanId], True, XvtrTranslate(FPanFreqHz[PanId]),
FPanRateKHz[PanId], ADC);
Result := True;
end;
function TRadioController.AddSlice(TargetHz: Double;
Mode, FilterLo, FilterHi: Integer; AGC: TWDSPAGCMode; Vol: Double;
DevIndex: Integer; const DevName: string; APanId: Integer): Integer;
var
i, slot, Id: Integer;
DevIndex_: Integer;
DevName_: string;
AO: TAudioOutput;
begin
Result := -1;
Mode := EnsureRange(Mode, MODE_MIN, MODE_MAX);
if not Assigned(FDSPEngine) then Exit;
if (APanId < 0) or (APanId >= MAX_PANS) then Exit;
if (APanId > 0) and (FPanDDCIdx[APanId] < 0) then Exit; // пан не создан
if not SliceFitsCapture(TargetHz, APanId) then Exit; // вне полосы пана
slot := -1;
for i := 0 to MAX_SLICES - 1 do
if not FSlices[i].Active then begin slot := i; Break; end;
if slot < 0 then Exit; // нет свободных слотов
// Устройство слота (настройка вкладки Slices) сильнее того, что попросил
// вызывающий: слот — это буква флага, и звук у неё всегда один и тот же.
if FSliceOutDev[slot] <> '' then
begin
DevName_ := FSliceOutDev[slot];
DevIndex_ := -1;
if Assigned(FAudioOut) then DevIndex_ := FAudioOut.FindDeviceByName(DevName_);
end
else
begin
DevName_ := DevName;
DevIndex_ := DevIndex;
end;
Id := FNextSliceId;
if not FDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi,
TargetHz - PanCenterHz(APanId), Vol, AGC,
APanId) then Exit;
Inc(FNextSliceId);
// Свой аудио-выход слайса на выбранной звуковухе.
AO := TAudioOutput.Create(48000);
AO.OutputBufferSize := FSettings.LoadAudioBufferSize;
AO.DeviceIndex := DevIndex_;
if FLocalAudio then AO.Open;
FSlices[slot].Active := True;
FSlices[slot].Id := Id;
// Буква флага. Слайс A — это главный приёмник (не в FSlices[]), поэтому доп.
// слайсы нумеруются с 'B' (slot 0 → B, 1 → C, …), иначе получалось два «A».
FSlices[slot].Letter := Chr(Ord('B') + slot);
FSlices[slot].PanId := APanId;
FSlices[slot].TargetHz := TargetHz;
FSlices[slot].Mode := Mode;
FSlices[slot].FilterLo := FilterLo;
FSlices[slot].FilterHi := FilterHi;
FSlices[slot].AGC := AGC;
FSlices[slot].Volume := Vol;
FSlices[slot].Muted := False;
FSlices[slot].RxMuteOnTx := True; // себя на передаче по умолчанию не слышим
FSlices[slot].FMSQOn := False; // squelch по умолчанию выключен
FSlices[slot].FMSQLevel := 40;
ClearTXProfModeTable(FSlices[slot].TXProfByMode); // звучит активный профиль
FSlices[slot].NRMode := 0; // DSP слайса стартует выключенным
FSlices[slot].NBMode := 0;
FSlices[slot].SNB := False;
FSlices[slot].ANF := False;
FSlices[slot].DeviceIndex := DevIndex_;
FSlices[slot].DevName := DevName_;
FSlices[slot].Audio := AO;
// Вход слайса по умолчанию общий (FAudioIn); свой ставится SetSliceInDevice.
FSlices[slot].InDeviceIndex := -1;
FSlices[slot].InDevName := '';
FSlices[slot].AudioIn := nil;
FSlices[slot].DMR := nil;
if Mode = MODE_DMR then
FSlices[slot].DMR := TDMRSliceDecoder.Create(Self, Id);
// Вход слота (настройка вкладки Slices) — тем же правилом, что и выход.
if (FSliceInDev[slot] <> '') and Assigned(FAudioIn) then
SetSliceInDeviceIdx(slot, FAudioIn.FindDeviceByName(FSliceInDev[slot]),
FSliceInDev[slot]);
Result := Id;
Changed(rfDevice);
end;
procedure TRadioController.RemoveSlice(Id: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
// Удаляемый слайс был источником TX — вернуть передачу на главный VFO.
if FTxSliceId = Id then SetTxSlice(0);
if Assigned(FDSPEngine) then FDSPEngine.RemoveSlice(Id);
FreeAndNil(FSlices[idx].DMR);
if FSlices[idx].Audio <> nil then
begin
FSlices[idx].Audio.Close;
FreeAndNil(FSlices[idx].Audio);
end;
ReleaseMicInput(FSlices[idx].AudioIn); // поток закроется, когда уйдёт последняя ссылка
FSlices[idx].InDeviceIndex := -1;
FSlices[idx].InDevName := '';
FSlices[idx].Active := False;
Changed(rfDevice);
end;
procedure TRadioController.SetSliceTarget(Id: Integer; TargetHz: Double);
var idx: Integer; Moved: Boolean;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
Moved := FSlices[idx].TargetHz <> TargetHz;
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;
// Уведомление — здесь, а не у каждого вызывающего: слайс двигают мышью, CAT,
// TCI и бэнд-логика, и каждый забывал сказать об этом остальным (TCI-клиенты
// оставались на старой частоте после перетаскивания флага мышью).
if Moved then SliceFreqChanged(Id);
end;
procedure TRadioController.SetSliceMode(Id, Mode: Integer);
var idx, OldMode: Integer;
begin
Mode := EnsureRange(Mode, MODE_MIN, MODE_MAX);
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
OldMode := FSlices[idx].Mode;
if (Mode = MODE_DMR) and not Assigned(FSlices[idx].DMR) then
FSlices[idx].DMR := TDMRSliceDecoder.Create(Self, Id);
FSlices[idx].Mode := Mode;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceMode(Id, Mode);
// DMR is receive-only. If this slice used to be the TX source, hand TX back
// to the main receiver as soon as it enters DMR. FM RAW supports flat TX.
if (Mode = MODE_DMR) and (FTxSliceId = Id) then SetTxSlice(0);
if (OldMode = MODE_DMR) and (Mode <> MODE_DMR) then
FreeAndNil(FSlices[idx].DMR);
// Этот слайс — источник передачи: передающий тракт идёт за его режимом
// (иначе после смены режима в эфир ушёл бы прежний вид модуляции). Вход FM
// RAW — внешний PCM, поэтому заодно пересматриваем источник модуляции.
// Профиль этого слайса в НОВОМ режиме — до пуша цепи (см. SetMode).
if (FTxSliceId = Id) and (Mode <> OldMode) then ApplyBoundTXProfile;
if (FTxSliceId = Id) and (Mode <> OldMode) and
FWDSPReady and Assigned(FDSPEngine) then
begin
FDSPEngine.SetTXMode(ActiveTXMode);
if (Mode = MODE_FMRAW) or (OldMode = MODE_FMRAW) then
begin
if Mode = MODE_FMRAW then
begin
FWebMicActive := False;
FTCIMicActive := False;
end;
FDSPEngine.SetTXMicSource(DefaultMicSource);
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
end;
end;
if FTxSliceId = Id then SyncCWKeyer;
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceFilter(Id, Low, High: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].FilterLo := Low;
FSlices[idx].FilterHi := High;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceFilter(Id, Low, High);
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].AGC := AGC;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceAGC(Id, AGC);
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceVolume(Id: Integer; Vol: Double);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].Volume := Vol;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceVolume(Id, Vol);
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceMute(Id: Integer; Mute: Boolean);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].Muted := Mute;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceMute(Id, Mute);
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceRxMuteOnTx(Id: Integer; On_: Boolean);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].RxMuteOnTx := On_;
end;
// «Главный» слайс пана = созданный на нём первым. Id монотонно растёт
// (FNextSliceId), поэтому первый = слайс с минимальным Id. 0 = слайсов нет.
function TRadioController.PanPrimarySliceId(PanId: Integer): Integer;
var i: Integer;
begin
Result := 0;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) and
((Result = 0) or (FSlices[i].Id < Result)) then
Result := FSlices[i].Id;
end;
function TRadioController.PanHasFMSlice(PanId: Integer): Boolean;
// Пан «канальный», если хотя бы один его слайс сидит в FM/DMR/FMRAW: под это
// UI рисует сетку частот по шагу FFMStepIdx (как на главном панадаптере).
var i: Integer;
begin
Result := False;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) and
(FSlices[i].Mode in [MODE_FM, MODE_DMR, MODE_FMRAW]) then
begin
Result := True;
Exit;
end;
end;
procedure TRadioController.SetSliceDSP(Id, NRMode, NBMode: Integer;
SNBOn, ANFOn: Boolean);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].NRMode := NRMode;
FSlices[idx].NBMode := NBMode;
FSlices[idx].SNB := SNBOn;
FSlices[idx].ANF := ANFOn;
if Assigned(FDSPEngine) then
begin
FDSPEngine.SetSliceNRMode(Id, NRMode);
FDSPEngine.SetSliceNBMode(Id, NBMode);
FDSPEngine.SetSliceSNB(Id, SNBOn);
FDSPEngine.SetSliceANF(Id, ANFOn);
end;
SliceStateChanged(Id);
end;
procedure TRadioController.SetSliceFMSquelch(Id: Integer; On_: Boolean; Level: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].FMSQOn := On_;
FSlices[idx].FMSQLevel := Level;
if Assigned(FDSPEngine) then
FDSPEngine.SetSliceFMSquelch(Id, On_, Level);
SliceStateChanged(Id);
end;
function TRadioController.SliceSMeter(Id: Integer): Double;
// Слайс может стоять на другом диапазоне (паны N кросс-банд), поэтому офсет
// берём по ЕГО видимой частоте, а не по главному диапазону.
var idx: Integer;
begin
Result := -130.0;
if not Assigned(FDSPEngine) then Exit;
Result := FDSPEngine.GetSliceSMeter(Id);
idx := FindSliceIndex(Id);
if idx >= 0 then Result := Result + SMeterCalFor(FSlices[idx].TargetHz);
end;
function TRadioController.ReadSMeterDBm: Double;
begin
if not Assigned(FDSPEngine) then Exit(-130.0);
Result := FDSPEngine.GetSMeterDBm + SMeterCalFor(ActiveVfoHz);
end;
function TRadioController.CalLookup(VisibleHz: Double;
const BandTbl: array of Double; const XvtrTbl: array of Double): Double;
// Трансвертер имеет приоритет над диапазоном: его окно частот — видимые
// (не IF), и усиление тракта задаёт конвертер/LNB, а не КВ-вход трансивера.
// Индекс диапазона — по плану активного железа (HF openHPSDR / VHF-UHF Pluto).
var i, Bi: Integer;
begin
Result := 0.0;
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT)
and FXvtrSettings.Entries[FCurrentXvtr].Enabled
and (VisibleHz >= FXvtrSettings.Entries[FCurrentXvtr].FreqBegin)
and (VisibleHz <= FXvtrSettings.Entries[FCurrentXvtr].FreqEnd) then
Exit(XvtrTbl[FCurrentXvtr]);
for i := 0 to CFG_XVTR_COUNT-1 do
if FXvtrSettings.Entries[i].Enabled
and (VisibleHz >= FXvtrSettings.Entries[i].FreqBegin)
and (VisibleHz <= FXvtrSettings.Entries[i].FreqEnd) then
Exit(XvtrTbl[i]);
Bi := FreqToBandIdx(VisibleHz, IsPluto);
if (Bi >= 0) and (Bi < CFG_BAND_COUNT) then Result := BandTbl[Bi];
end;
function TRadioController.SMeterCalFor(VisibleHz: Double): Double;
begin
Result := CalLookup(VisibleHz, FCal.SMeterBandDB, FCal.SMeterXvtrDB)
+ FrontEndCalDB;
end;
function TRadioController.FrontEndCalDB: Double;
// Поправка на регулируемое усиление входного тракта — то, что меняется ручкой
// или само и не должно двигать абсолютную шкалу dBm.
// openHPSDR: шаговый аттенюатор ADC режет вход → его дБ возвращаем в
// показания (так же делают Thetis и piHPSDR).
// AD936x: hardwaregain поднимает уровень (а в режимах AGC железо крутит
// его само) → вычитаем текущее значение.
// Постоянная часть тракта (LNB, кабель, конвертер) остаётся за таблицами
// калибровки на диапазон/трансвертер.
var G: Double;
begin
if not IsPluto then Exit(FAtten);
Result := 0.0;
if FNetwork = nil then Exit;
G := FNetwork.RxGainNowDb;
if G > TELEMETRY_NONE then Result := -G;
end;
function TRadioController.DispCalFor(PanId: Integer): Double;
// Зовётся из display-потока движка раз в кадр. Главный дисплей калибруем по
// центру полосы, доп. пан — по своей частоте (паны бывают кросс-банд).
var Hz: Double;
begin
if (PanId > 0) and (PanId < MAX_PANS) then Hz := FPanFreqHz[PanId]
else Hz := FCenterFreq;
if Hz <= 0 then Exit(0.0);
Result := CalLookup(Hz, FCal.DispBandDB, FCal.DispXvtrDB) + FrontEndCalDB;
end;
procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer;
const DevName: string);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSliceOutDev[idx] := DevName; // выбор во флаге = устройство слота (одна истина)
FSlices[idx].DeviceIndex := DevIndex;
FSlices[idx].DevName := DevName;
if FSlices[idx].Audio <> nil then
begin
FSlices[idx].Audio.Close;
FSlices[idx].Audio.DeviceIndex := DevIndex;
if FLocalAudio then FSlices[idx].Audio.Open;
end;
end;
// --- Пул входных потоков -----------------------------------------------------
function TRadioController.AcquireMicInput(DevIndex: Integer): TAudioInput;
// Ссылка на открытый поток устройства DevIndex (создаёт при первом обращении).
// DevIndex<0 = «своего входа нет» → nil (читатель упадёт на общий FAudioIn).
var
i, n: Integer;
AI: TAudioInput;
begin
Result := nil;
if DevIndex < 0 then Exit;
// Общий вход уже открыт на этом же устройстве — переиспользуем его: второй
// Open того же PA-устройства на эксклюзивном ALSA-hw просто не прошёл бы.
if Assigned(FAudioIn) and (FAudioIn.DeviceIndex = DevIndex) then
Exit(FAudioIn);
for i := 0 to High(FMicPool) do
if FMicPool[i].DevIdx = DevIndex then
begin
// Запись с Refs=0 — поток закрыт, но объект жив (см. ReleaseMicInput).
if (FMicPool[i].Refs <= 0) and FLocalAudio and
(not FMicPool[i].AI.IsOpen) then
try FMicPool[i].AI.Open; except end;
Inc(FMicPool[i].Refs);
Exit(FMicPool[i].AI);
end;
AI := TAudioInput.Create(48000);
AI.DeviceIndex := DevIndex;
if FLocalAudio then
try AI.Open; except end;
n := Length(FMicPool);
SetLength(FMicPool, n + 1);
FMicPool[n].DevIdx := DevIndex;
FMicPool[n].Refs := 1;
FMicPool[n].AI := AI;
Result := AI;
end;
procedure TRadioController.ReleaseMicInput(var AI: TAudioInput);
// Последняя ссылка ушла — поток ЗАКРЫВАЕМ, но объект оставляем в пуле до
// выхода: TX-поток (PullSoundCardMic) мог взять указатель прямо сейчас, а
// освобождение памяти под ним = падение. Повторный выбор устройства просто
// переоткроет ту же запись.
var i: Integer;
begin
if AI = nil then Exit;
if AI = FAudioIn then begin AI := nil; Exit; end; // общий вход не из пула
for i := 0 to High(FMicPool) do
if FMicPool[i].AI = AI then
begin
Dec(FMicPool[i].Refs);
if FMicPool[i].Refs <= 0 then
begin
FMicPool[i].Refs := 0;
FMicPool[i].AI.Close;
end;
Break;
end;
AI := nil;
end;
procedure TRadioController.FreeMicPool;
var i: Integer;
begin
for i := 0 to High(FMicPool) do
if FMicPool[i].AI <> nil then
begin
FMicPool[i].AI.Close;
FMicPool[i].AI.Free;
end;
SetLength(FMicPool, 0);
end;
procedure TRadioController.SetSliceInDevice(Id, DevIndex: Integer;
const DevName: string);
begin
SetSliceInDeviceIdx(FindSliceIndex(Id), DevIndex, DevName);
end;
procedure TRadioController.SetSliceInDeviceIdx(Idx, DevIndex: Integer;
const DevName: string);
begin
if (Idx < 0) or (Idx >= MAX_SLICES) or (not FSlices[Idx].Active) then Exit;
if DevName = '' then DevIndex := -1;
FSliceInDev[Idx] := DevName; // выбор во флаге = вход слота (одна истина)
ReleaseMicInput(FSlices[Idx].AudioIn);
FSlices[Idx].InDeviceIndex := DevIndex;
FSlices[Idx].InDevName := DevName;
FSlices[Idx].AudioIn := AcquireMicInput(DevIndex);
// Слайс сейчас источник TX — источник модуляции мог сменить тип
// (звуковуха ↔ микрофон радио).
if (FTxSliceId = FSlices[Idx].Id) and FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXMicSource(DefaultMicSource);
end;
procedure TRadioController.SetMainMicDevice(DevIndex: Integer;
const DevName: string);
var i: Integer;
begin
if Assigned(FAudioIn) then
begin
if FAudioIn.IsOpen then FAudioIn.Close;
FAudioIn.DeviceIndex := DevIndex;
if (DevIndex >= 0) and FLocalAudio then
try FAudioIn.Open; except end;
end;
FAudioInDevName := DevName;
// Слайсы могли ссылаться на общий вход (или держать свой поток на устройстве,
// которое теперь стало общим) — перепривязать все ссылки к новой картине.
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then
begin
ReleaseMicInput(FSlices[i].AudioIn);
FSlices[i].AudioIn := AcquireMicInput(FSlices[i].InDeviceIndex);
end;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXMicSource(DefaultMicSource);
end;
function TRadioController.ActiveMicInput: TAudioInput;
// Вход TX-источника: свой поток слайса, иначе общий (слайс без выбора).
var idx: Integer;
begin
Result := FAudioIn;
if FTxSliceId = 0 then Exit;
idx := FindSliceIndex(FTxSliceId);
// Поток слайса мог не открыться (устройство занято/исчезло) — тогда лучше
// передавать с общего входа, чем молчать в эфир.
if (idx >= 0) and (FSlices[idx].AudioIn <> nil) and
FSlices[idx].AudioIn.IsOpen then
Result := FSlices[idx].AudioIn;
end;
function TRadioController.ActiveMicInDevName: string;
var idx: Integer;
begin
Result := FAudioInDevName;
if FTxSliceId = 0 then Exit;
idx := FindSliceIndex(FTxSliceId);
if (idx >= 0) and (FSlices[idx].InDevName <> '') then
Result := FSlices[idx].InDevName;
end;
function TRadioController.SliceCount: Integer;
var i: Integer;
begin
Result := 0;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then Inc(Result);
end;
function TRadioController.GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
var idx: Integer;
begin
idx := FindSliceIndex(Id);
Result := idx >= 0;
if Result then S := FSlices[idx];
end;
function TRadioController.GetSliceView(Id: Integer; out V: TSliceView): Boolean;
// Читается из чужих потоков (TCI), поэтому — поле за полем и только скаляры.
// Active перечитывается после копирования: слайс могли удалить прямо во время
// снятия снимка, и тогда честнее вернуть False, чем полуживую запись.
var idx: Integer;
begin
FillChar(V, SizeOf(V), 0);
Result := False;
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
V.Id := FSlices[idx].Id;
V.PanId := FSlices[idx].PanId;
V.TargetHz := FSlices[idx].TargetHz;
V.Mode := FSlices[idx].Mode;
V.FilterLo := FSlices[idx].FilterLo;
V.FilterHi := FSlices[idx].FilterHi;
V.AGC := FSlices[idx].AGC;
V.Volume := FSlices[idx].Volume;
V.Muted := FSlices[idx].Muted;
V.FMSQOn := FSlices[idx].FMSQOn;
V.FMSQLevel := FSlices[idx].FMSQLevel;
V.NRMode := FSlices[idx].NRMode;
V.NBMode := FSlices[idx].NBMode;
V.SNB := FSlices[idx].SNB;
V.ANF := FSlices[idx].ANF;
Result := FSlices[idx].Active and (FSlices[idx].Id = Id);
end;
function TRadioController.PanSliceCount(PanId: Integer): Integer;
var i: Integer;
begin
Result := 0;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) then Inc(Result);
end;
function TRadioController.PanSliceId(PanId, Index: Integer): Integer;
var i, Seen: Integer;
begin
Result := 0;
if Index < 0 then Exit;
Seen := 0;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) then
begin
if Seen = Index then Exit(FSlices[i].Id);
Inc(Seen);
end;
end;
function TRadioController.SlicePanIndex(Id: Integer; out PanId, Index: Integer): Boolean;
var i, Seen, Pan: Integer;
begin
Result := False;
PanId := 0; Index := 0;
if Id <= 0 then Exit;
Pan := -1;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].Id = Id) then
begin
Pan := FSlices[i].PanId;
Break;
end;
if Pan < 0 then Exit;
Seen := 0;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = Pan) then
begin
if FSlices[i].Id = Id then
begin
PanId := Pan;
Index := Seen;
Exit(True);
end;
Inc(Seen);
end;
end;
// ---------------------------------------------------------------------------
// Слоты слайсов: настройки (CAT-порт, Auto TX) висят на слоте/букве, а слайс
// на слоте может появляться и исчезать.
// ---------------------------------------------------------------------------
class function TRadioController.SliceSlotLetter(Slot: Integer): Char;
begin
// Слайс A — главный приёмник, поэтому слоты нумеруются с 'B' (как AddSlice).
if (Slot < 0) or (Slot >= MAX_SLICES) then Exit('?');
Result := Chr(Ord('B') + Slot);
end;
function TRadioController.SliceIdBySlot(Slot: Integer): Integer;
begin
Result := 0;
if (Slot < 0) or (Slot >= MAX_SLICES) then Exit;
if FSlices[Slot].Active then Result := FSlices[Slot].Id;
end;
function TRadioController.SliceSlotOf(Id: Integer): Integer;
begin
Result := FindSliceIndex(Id);
end;
function TRadioController.SliceSlotAutoTx(Slot: Integer): Boolean;
begin
Result := (Slot >= 0) and (Slot < MAX_SLICES) and FSliceAutoTx[Slot];
end;
procedure TRadioController.SetSliceSlotAutoTx(Slot: Integer; On_: Boolean);
// Без Changed(): единственный вызывающий — TCATAdapter.ApplySettings, а он
// работает и на старте (до постройки виджетов), и на connect. Перерисовку
// бейджей делает UI-путь настроек (MainForm.OnSliceCATSettingsChange).
begin
if (Slot < 0) or (Slot >= MAX_SLICES) then Exit;
FSliceAutoTx[Slot] := On_;
end;
function TRadioController.SliceAutoTx(Id: Integer): Boolean;
begin
Result := SliceSlotAutoTx(FindSliceIndex(Id));
end;
function TRadioController.SliceSlotOutDev(Slot: Integer): string;
begin
if (Slot >= 0) and (Slot < MAX_SLICES) then Result := FSliceOutDev[Slot]
else Result := '';
end;
function TRadioController.SliceSlotInDev(Slot: Integer): string;
begin
if (Slot >= 0) and (Slot < MAX_SLICES) then Result := FSliceInDev[Slot]
else Result := '';
end;
procedure TRadioController.SetSliceSlotAudio(Slot: Integer; const OutDev, InDev: string);
// Настройка = устройство слота, поэтому применяем сразу: слайс, который стоит
// на слоте, переезжает на новое устройство (как выбор во вкладке AUD флага).
var Id: Integer;
begin
if (Slot < 0) or (Slot >= MAX_SLICES) then Exit;
Id := SliceIdBySlot(Slot);
if FSliceOutDev[Slot] <> OutDev then
begin
FSliceOutDev[Slot] := OutDev;
if Id > 0 then
begin
if (OutDev <> '') and Assigned(FAudioOut) then
SetSliceDevice(Id, FAudioOut.FindDeviceByName(OutDev), OutDev)
else
SetSliceDevice(Id, -1, ''); // общий выход приложения
end;
end;
if FSliceInDev[Slot] <> InDev then
begin
FSliceInDev[Slot] := InDev;
if Id > 0 then
begin
if (InDev <> '') and Assigned(FAudioIn) then
SetSliceInDevice(Id, FAudioIn.FindDeviceByName(InDev), InDev)
else
SetSliceInDevice(Id, -1, ''); // общий вход приложения
end;
end;
end;
procedure TRadioController.RequestSliceTx(SliceId: Integer; On_: Boolean;
Tune: Boolean = False);
// PTT, пришедшая по CAT-порту слайса или по TCI с номером приёмника. Аналог
// «Auto Switch TX Slice» в SmartSDR CAT: с Auto TX слайс сам становится
// TX-источником, без него — передаёт, только если оператор уже выбрал его
// источником (бейдж TX на флаге).
// Выполняется в потоке контроллера (CAT и TCI маршалят через Invoke), поэтому
// проверка «уже в эфире» и смена источника атомарны относительно GUI.
var idx: Integer;
begin
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
if not On_ then
begin
// Отпускание PTT снимает только СВОЮ передачу: RX; на порту слайса C не
// должен обрывать эфир слайса B.
if (FTxSliceId = SliceId) and (FTransmitting or FTuning) then
begin
if FTuning then SetTune(False) else SetMOX(False);
end;
Exit;
end;
// Передатчик один: пока кто-то в эфире (другой слайс, главный VFO, кнопка
// MOX/TUNE, аппаратная PTT) — чужую передачу не перехватываем и источник не
// переключаем. Своя же передача уже идёт, повторный TX; — no-op.
if FTransmitting or FTuning then Exit;
if FSlices[idx].Mode = MODE_DMR then Exit; // AMBE-передатчика нет
if FTxSliceId <> SliceId then
begin
if not FSliceAutoTx[idx] then Exit; // без Auto TX слайс сам себя не назначает
SetTxSlice(SliceId);
if FTxSliceId <> SliceId then Exit; // SetTxSlice отказал (DMR/нет слайса)
end;
if Tune then SetTune(True) else SetMOX(True);
end;
// Снимок кадра для web-зеркала: потолок точек задаёт настройка web.spec_pixels
// (Limit), при большем разрешении анализатора децимируем max-детектором
// (не обрезаем).
// DstCount — сколько точек РЕАЛЬНО заполнено; отправлять больше нельзя, иначе
// хвост буфера (нули = 0 dBm) уедет в эфир белой полосой, а частотная шкала
// клиента растянется на несуществующие точки.
//
// Группы — строго одинаковой ширины (целочисленный фактор). Прежнее деление
// «i*Count div N» давало на нецелом отношении соседние группы разной ширины
// (1 и 2 точки), а смещение max-детектора зависит от числа точек в группе —
// по спектру шла гребёнка ±3 дБ, в водопаде видимая как вертикальная рябь.
procedure SnapshotPixels(const Pixels: array of Single; Count: Integer;
var Dst: array of Single; Limit: Integer; out DstCount: Integer);
var
i, j, S0, S1, N, K, M: Integer;
V: Single;
begin
N := EnsureRange(Limit, 64, Length(Dst));
if Count > Length(Pixels) then Count := Length(Pixels);
if Count < 0 then Count := 0;
if Count <= N then
begin
for i := 0 to Count - 1 do Dst[i] := Pixels[i];
DstCount := Count;
Exit;
end;
K := (Count + N - 1) div N; // точек анализатора на точку web
M := (Count + K - 1) div K; // столько точек получится (<= N)
for i := 0 to M - 1 do
begin
S0 := i * K;
S1 := S0 + K - 1;
if S1 > Count - 1 then S1 := Count - 1; // хвостовая группа короче
V := Pixels[S0];
for j := S0 + 1 to S1 do
if Pixels[j] > V then V := Pixels[j];
Dst[i] := V;
end;
DstCount := M;
end;
procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
Count: Integer);
// DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу
// (UI рисует FSpecView). Децимации у спектра нет.
begin
SnapshotPixels(Pixels, Count, FSpectrumBuf, FWebSpecPixels, FSpectrumBufCount);
if Assigned(FOnSpectrumData) then FOnSpectrumData(Pixels, Count);
end;
procedure TRadioController.OnWaterfallReady(const Pixels: array of Single;
Count: Integer);
// DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада —
// забота фронтенда (OnWaterfallData).
//
// Web шлёт 20 строк/с, анализатор даёт до 60 кадров/с — просто «последний
// кадр» терял бы 2 из 3 (короткая посылка CW/FT8 могла не попасть ни в одну
// строку). Копим кадры max-hold'ом до тех пор, пока PushState не заберёт
// строку (идиома TWaterfallView.SetWaterfallData при WfFrameInterval>1).
var
Tmp: array[0..SPECTRUM_PIXELS-1] of Single;
N, i: Integer;
begin
SnapshotPixels(Pixels, Count, Tmp, FWebSpecPixels, N);
if (not FWfAccumFresh) or (N <> FWaterfallBufCount) then
begin
for i := 0 to N - 1 do FWaterfallBuf[i] := Tmp[i];
FWaterfallBufCount := N;
end
else
for i := 0 to N - 1 do
if Tmp[i] > FWaterfallBuf[i] then FWaterfallBuf[i] := Tmp[i];
FWfAccumFresh := True;
if Assigned(FOnWaterfallData) then FOnWaterfallData(Pixels, Count);
end;
procedure TRadioController.OnMicPacket(const Data: TMicDataPacket);
// HW mic-стрим уходит в WDSP только если выбран источник Radio; иначе сэмплы
// игнорируются (mic берётся со звуковой карты).
begin
if FTransmitting and FWDSPReady and
(FDSPEngine.TXMicSource = txmsRadio) then
FDSPEngine.PushTXMicSamples16(Data.Samples, 64);
end;
function TRadioController.PullSoundCardMic(MaxN: Integer): Integer;
// Колбэк TTXDSPThread при TXMicSource=txmsSoundCard. Тянем только реально
// доступные сэмплы из FAudioIn (иначе пушили бы тишину в mic-цепь WDSP),
// пачкой в локальный буфер — экономим будилки семафора.
var
i, n: Integer;
AI: TAudioInput;
Buf: array[0..2047] of Double;
begin
Result := 0;
AI := ActiveMicInput; // вход TX-слайса (свой) либо общий
if (AI = nil) or (not AI.IsOpen) then Exit;
n := AI.Available;
if n > MaxN then n := MaxN;
if n > Length(Buf) then n := Length(Buf);
for i := 0 to n - 1 do
Buf[i] := AI.ReadSample;
if n > 0 then
FDSPEngine.PushTXMicSamplesD(Buf, n);
Result := n;
end;
procedure TRadioController.Invoke(M: TThreadMethod);
begin
if Assigned(FOnInvoke) then FOnInvoke(M)
else if Assigned(M) then M(); // нет хозяина-маршалера — прямой вызов
end;
function TRadioController.GetSnapshot: TRadioSnapshot;
begin
FillChar(Result, SizeOf(Result), 0);
Result.VfoA := FVfoA; Result.VfoB := FVfoB;
Result.CenterFreq := FCenterFreq; Result.SpanHz := FSpanHz;
Result.SampleRate := FSampleRate; Result.ActiveVfo := FActiveVfo;
Result.Mode := FMode; Result.Filter := FFilter; Result.FilterBW := FFilterBW;
Result.AGCMode := FAGCMode; Result.AGCTop := FAGCTop;
Result.RxGainMode := FRxGainMode; Result.RxGainDb := FRxGainDb;
Result.Volume := FVolume; Result.Drive := FDrivePercent; Result.Atten := FAtten;
Result.Band := FCurrentBand; Result.Xvtr := FCurrentXvtr;
Result.Muted := FMuted; Result.CTun := FCTun; Result.Duplex := FDisplayDuplex;
Result.Running := FRunning; Result.Connected := FDevConnected;
Result.Transmitting := FTransmitting; Result.Tuning := FTuning;
Result.NRMode := FNRMode; Result.NBMode := FNBMode;
Result.SNB := FSNB; Result.ANF := FANF;
Result.WfAGC := FWfAGCEnabled; Result.WfNF := FWfNFEnabled;
Result.FMStepOn := FFMStepOn; Result.FMStepIdx := FFMStepIdx;
Result.FMCTCSSOn := FFMCTCSSOn; Result.FMCTCSSToneIdx := FFMCTCSSToneIdx;
Result.FMSQOn := FFMSQOn; Result.FMSQLevel := FFMSQLevel;
Result.FMRptDir := FFMRptDir;
Result.SMeter := FLastSMeter; Result.FwdW := FLastFwdW; Result.SWR := FLastSWR;
Result.SupplyV := FLastSupplyV; Result.SupplyA := FLastSupplyA;
Result.PLLLock := FLastPLLLock;
end;
function TRadioController.XvtrTranslate(VisibleHz: Double): Double;
begin
Result := VisibleHz;
if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
if not FXvtrSettings.Entries[FCurrentXvtr].Enabled then Exit;
// f_IF = f_visible - LOOffset + LOError.
// QO-100 beacon lock складывает свою коррекцию дрейфа LNB ПРЯМО в LOError
// (живая калибровка): она сразу видна в настройках и сохраняется. Отдельного
// слагаемого нет — иначе двойной счёт.
Result := VisibleHz
- FXvtrSettings.Entries[FCurrentXvtr].LOOffset
+ FXvtrSettings.Entries[FCurrentXvtr].LOError;
end;
function TRadioController.XvtrTranslateTX(VisibleHz: Double): Double;
begin
Result := VisibleHz;
if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
if not FXvtrSettings.Entries[FCurrentXvtr].Enabled then Exit;
// QO-100 / split-LO: TX идёт на собственный uplink-LO (visible - TxLOOffset),
// без LOError — дрейф LNB к передаче отношения не имеет.
if FXvtrSettings.Entries[FCurrentXvtr].FullDuplexTx then
// TO Error: сам транспондер-offset держим стандартным, уход тракта — здесь.
Result := VisibleHz - FXvtrSettings.Entries[FCurrentXvtr].TxLOOffset
+ FXvtrSettings.Entries[FCurrentXvtr].TxLOError
else
// Обычный трансвертер: RX=TX LO (с калибровкой LOError) — как раньше.
Result := VisibleHz
- FXvtrSettings.Entries[FCurrentXvtr].LOOffset
+ FXvtrSettings.Entries[FCurrentXvtr].LOError;
end;
function TRadioController.ActiveTXFreqHz: Double;
// Снимок (GetSliceView), а не GetSlice: функцию зовут и внешние фронтенды из
// своих потоков, а копия TCtrlSlice тащит managed-строки слайса.
var S: TSliceView;
begin
// Мультислайс-TX: если выбран слайс-источник — передаём на его частоте
// (слайсы не несут repeater-конфиг, FM-сдвиг не применяем).
if (FTxSliceId > 0) and GetSliceView(FTxSliceId, S) then
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;
var S: TCtrlSlice;
begin
if (FTxSliceId > 0) and GetSlice(FTxSliceId, S) then Result := S.Mode
else Result := FMode;
end;
function TRadioController.TxBandIdx: Integer;
begin
// При TX на слайсе другого диапазона (только КВ) калибровка/антенна — по
// банду TX-частоты, иначе главный банд.
Result := FCurrentBand;
if (FTxSliceId > 0) and (FCurrentXvtr < 0) then
begin
Result := FreqToBandIdx(ActiveTXFreqHz, IsPluto);
if (Result < 0) or (Result >= CFG_BAND_COUNT) then Result := FCurrentBand;
end;
end;
procedure TRadioController.SetTxSlice(SliceId: Integer);
var S: TCtrlSlice;
begin
if SliceId < 0 then SliceId := 0;
// Слайс-источник должен существовать (0 = главный всегда валиден).
if SliceId > 0 then
begin
if not GetSlice(SliceId, S) then Exit;
// EWSDR has no DMR transmitter/AMBE encoder.
if S.Mode = MODE_DMR then Exit;
end;
if FTxSliceId = SliceId then Exit;
// Selecting main A while it is in DMR is allowed as an RX selection, but it
// cannot inherit an active carrier from a previously selected TX slice.
if (SliceId = 0) and (FMode = MODE_DMR) then
begin
if FTuning then SetTune(False)
else if FTransmitting then SetMOX(False);
end;
FTxSliceId := SliceId;
// Профиль, привязанный к новому источнику, — ДО пуша режима и цепи: иначе
// ApplyTXSettingsToDSP внутри SelectTXProfile лёг бы на старый режим.
ApplyBoundTXProfile;
// Передающий тракт под режим нового источника (idle-канал, применится к PTT).
if FWDSPReady and Assigned(FDSPEngine) then
begin
FDSPEngine.SetTXMode(ActiveTXMode);
// Источник модуляции — вход НОВОГО слайса (у него может быть свой).
FDSPEngine.SetTXMicSource(DefaultMicSource);
end;
// Кольцо нового входа копило звук всё время, пока он не был источником:
// без сброса первые ~200мс передачи ушли бы в эфир «из прошлого».
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
SyncCWKeyer; // кейер прошивки — под режим нового источника
FDriveLevel := CalcDriveByte; // калибровка драйва банда TX-слайса
ApplyAntPorts; // AD936x: TX-разъём — по диапазону TX-слайса
PushNetworkState; // TX-частота/Alex через ActiveTXFreqHz/TxBandIdx
Changed(rfTransmitting); // UI: перерисовать TX-бейджи флагов/оверлей
end;
// ---------------------------------------------------------------------------
// QO-100 beacon lock
// ---------------------------------------------------------------------------
const
// --- контур коррекции (измерение частоты — от ДЕКОДЕРА, не от display-FFT) ---
// Декодер точно ведёт несущую маяка своим NCO (RunAcquisition + Costas-offload):
// суммарный остаток ResidHz+CarFreqHz = текущее смещение маяка от точки наведения.
// Это смещение медленно стекаем в LOError активного XVTR → ретюн LO компенсирует
// дрейф LNB, весь даунлинк стоит. Внутренний NCO декодера держит маяк
// декодированным во время плавного ретюна (наведение едет feed-forward за маяком).
BEACON_GAIN = 0.5; // гейн контура: <1 — запас от перелёта (декодерная
// оценка точная, но ретюн LO имеет латентность пайплайна)
BEACON_DEADBAND_HZ = 20.0; // внутри — коррекции стоп (декодер точнее FFT → дедбэнд уже)
BEACON_MAX_STEP_HZ = 400.0; // клам один шаг коррекции (защита от выброса оценки)
BEACON_SETTLE_TICKS = 8; // пауза (~0.8 с @10 Гц) после коррекции — осадка ретюна LO
BEACON_SAVE_TICKS = 30; // троттлинг записи LOError на диск (~3 с @10 Гц)
// --- знак отображения остатка декодера ---
// invert=True (QO-100/LNB) выравнивает частотную ось декодера с дисплеем, поэтому
// display_маяка = наведение + (ResidHz+CarFreqHz). Если на эфире контур убегает —
// ось перевёрнута, флипнуть на -1.0 (см. лог: e и дрейф должны иметь один знак).
BEACON_RESID_SIGN = 1.0;
// --- метрика лока (берём из снимка декодера: CarrierLock + SNR) ---
BEACON_LOCK_SNR_DB = 3.0; // солидный лок: декодер несёт несущую и SNR выше этого
BEACON_UNLOCK_SNR_DB = 0.0; // гистерезис: лок гаснет ниже этого
// --- «это точно BPSK, а не голая несущая» ---
// CarrierLock+SNR подтверждают лишь то, что в полосе есть узкая линия и Costas
// держит фазу. Ровная несущая (телеграф, пораженка, остаток несущей у цифры) даёт
// ровно это, причём с ОТЛИЧНЫМ SNR: захваченная Costas'ом, она лежит целиком на
// оси I, Q пустой. Контур тогда честно уводил LOError, подтягивая чужой сигнал к
// опорной частоте маяка. Различаем по констелляции: у настоящего BPSK точки ходят
// между двумя сгустками ±I (данные после свёрточного кодера равновероятны), у
// несущей все точки в одном. Меряем долю меньшинства по знаку I.
BEACON_BAL_MIN = 0.15; // ниже — на BPSK не похоже, лок не даём
BEACON_BAL_DROP = 0.08; // гистерезис: уже взятый лок гаснет ниже этого
BEACON_BAL_MIN_PTS = 64; // меньше точек в кольце — судить рано
procedure TRadioController.SetBeaconRefHz(Hz: Double);
begin
FBeaconRefHz := Hz;
end;
procedure TRadioController.BeaconSeedAtHz(DisplayHz: Double);
// Пользователь кликнул по маяку → наводим узкий тракт декодера на эту частоту и
// переинициализируем петли (RunAcquisition заново оценит несущую). Дальше декодер
// сам ведёт маяк своим NCO, а ServiceBeaconLock стекает измеренный дрейф в LOError.
begin
if not FBeaconLockOn then SetBeaconLock(True);
FBeaconDecHz := DisplayHz;
FBeaconTrackHz := DisplayHz;
if Assigned(FBeaconDec) then
begin
FBeaconDec.SetOffsetHz(DisplayHz - FCenterFreq);
FBeaconDec.Reseed; // одноразовый грубый захват частоты заново
end;
FBeaconBal := 0.0; // новое наведение — BPSK-ность доказывается заново
FBeaconLocked := False;
FBeaconCorrCooldown := BEACON_SETTLE_TICKS; // дать декодеру захватиться до 1-й коррекции
Changed(rfBeaconLock);
end;
procedure TRadioController.SetBeaconLock(On_: Boolean);
// Один концепт: лок = декодер + трим LOError. Включение запускает декодер маяка;
// пользователь кликает по маяку (BeaconSeedAtHz) для наведения. Когда декодер
// солидно залочен, ServiceBeaconLock стекает его измеренный дрейф в LOError.
begin
if On_ = FBeaconLockOn then Exit;
FBeaconLockOn := On_;
if On_ then
begin
FBeaconCorrCooldown := 0;
FBeaconSaveCooldown := 0;
FBeaconLocked := False;
FBeaconBal := 0.0;
FBeaconSNRdB := -99.0;
FBeaconLockHz := 0.0; // сессионный аккумулятор индикации (LOError несёт калибровку)
FBeaconDecHz := 0.0; // ещё не наведён — ждём клик по маяку
FBeaconTrackHz := FBeaconRefHz;
if Assigned(FBeaconDec) then
begin
FBeaconDec.SetOffsetHz(0);
FBeaconDec.SetEnabled(True); // ResetDSP внутри
end;
end
else
begin
if Assigned(FBeaconDec) then FBeaconDec.SetEnabled(False);
FBeaconDecHz := 0.0;
PersistBeaconCalibration; // финальный flush LOError на диск
end;
Changed(rfBeaconLock);
end;
function BeaconIQBalance(const S: TBeaconScope): Double;
// Доля меньшинства среди знаков I в кольце констелляции: 0.5 — идеально
// сбалансированный BPSK, 0 — все точки в одном сгустке (немодулированная
// несущая). -1 = точек мало, судить рано.
var
i, Neg: Integer;
begin
if S.Count < BEACON_BAL_MIN_PTS then Exit(-1.0);
Neg := 0;
for i := 0 to S.Count - 1 do
if S.PtI[i] < 0 then Inc(Neg);
Result := Min(Neg, S.Count - Neg) / S.Count;
end;
procedure TRadioController.ServiceBeaconLock;
// Одна итерация контура (UI-таймер @10 Гц, MeterTimer). Эталон частоты — ДЕКОДЕР:
// он точно ведёт несущую маяка своим NCO (RunAcquisition + Costas-offload), и его
// суммарный остаток ResidHz+CarFreqHz = текущее смещение маяка от точки наведения.
// Измеренную АБСОЛЮТНУЮ частоту маяка (наведение + остаток) подтягиваем к опорной,
// стекая ошибку в LOError активного XVTR → ретюн LO компенсирует дрейф LNB, весь
// даунлинк стоит. Наведение декодера едет feed-forward за маяком (FBeaconDecHz),
// так что внутренний NCO держит маяк декодированным сквозь плавный ретюн (нетто
// baseband-смещение ≈ 0: маяк сдвинулся на −dLock железом, наведение тоже на −dLock).
var
S: TBeaconScope;
residHz, measured, e, dLock, Bal: Double;
begin
if not FBeaconLockOn then Exit;
if not Assigned(FBeaconDec) then Exit;
// Держим наведение декодера через АКТУАЛЬНЫЙ FCenterFreq (панорама/CTUN не ломают
// снос). До клика (FBeaconDecHz=0) — DC; acquisition попробует поймать у центра.
if FBeaconDecHz > 0 then
FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq)
else
FBeaconDec.SetOffsetHz(0);
if FCurrentXvtr < 0 then Exit; // трим LOError только в XVTR-режиме
if FBeaconSaveCooldown > 0 then Dec(FBeaconSaveCooldown);
FBeaconDec.GetScope(S);
FBeaconSNRdB := FBeaconSNRdB + 0.2 * (S.SNRdB - FBeaconSNRdB);
// Доля меньшинства по знаку I: у BPSK ≈0.5, у голой несущей ≈0. Кольцо в 256
// точек при 400 Bd набирается за ~0.6 с, EMA добавляет ещё столько же — то есть
// чужой сигнал отваливается примерно за секунду, а не за десяток (кадр FEC).
Bal := BeaconIQBalance(S);
if Bal >= 0 then FBeaconBal := FBeaconBal + 0.2 * (Bal - FBeaconBal);
// Солидный лок: декодер несёт несущую, SNR выше порога И картинка похожа на
// BPSK (гистерезис по обоим — против мерцания).
if S.CarrierLock and (FBeaconSNRdB >= BEACON_LOCK_SNR_DB)
and (FBeaconBal >= BEACON_BAL_MIN) then FBeaconLocked := True
else if (FBeaconSNRdB < BEACON_UNLOCK_SNR_DB)
or (FBeaconBal < BEACON_BAL_DROP) then FBeaconLocked := False;
// Остаток несущей декодера (Гц, относительно наведения). invert выравнивает ось
// декодера с дисплеем → display_маяка = наведение + остаток (знак BEACON_RESID_SIGN).
residHz := BEACON_RESID_SIGN * (S.ResidHz + S.CarFreqHz);
if FBeaconDecHz > 0 then
begin
measured := FBeaconDecHz + residHz; // абсолютная display-частота маяка
FBeaconTrackHz := measured; // для оранжевого маркера
end
else
measured := FBeaconTrackHz;
// Коррекция: не каждый тик (ретюн LO имеет латентность пайплайна). После коррекции
// ждём BEACON_SETTLE_TICKS. Только при солидном локе и после наведения кликом.
if FBeaconCorrCooldown > 0 then
Dec(FBeaconCorrCooldown)
else if FBeaconLocked and (FBeaconDecHz > 0) then
begin
e := measured - FBeaconRefHz;
if Abs(e) > BEACON_DEADBAND_HZ then
begin
dLock := BEACON_GAIN * e;
if dLock > BEACON_MAX_STEP_HZ then dLock := BEACON_MAX_STEP_HZ;
if dLock < -BEACON_MAX_STEP_HZ then dLock := -BEACON_MAX_STEP_HZ;
ApplyBeaconLOError(dLock); // в LOError + ретюн + троттл-сохранение
// Маяк уедет на dLock (display); ведём наведение туда же feed-forward, чтобы
// baseband декодера не дёрнулся и лок не потерялся.
FBeaconDecHz := measured - dLock;
FBeaconTrackHz := FBeaconDecHz;
FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq);
FBeaconCorrCooldown := BEACON_SETTLE_TICKS;
end;
end;
Changed(rfBeaconLock);
end;
procedure TRadioController.ApplyBeaconLOError(dLock: Double);
// Складывает коррекцию маяка ПРЯМО в LOError активного трансвертера — это и есть
// живая калибровка дрейфа LNB: сразу видна в настройках, переживает рестарт.
// FBeaconLockHz — лишь сессионный аккумулятор для индикации. Запись на диск
// троттлим (BEACON_SAVE_TICKS): коррекции редки (deadband), но при долгом прогреве
// LNB их много — не молотим диск каждую.
begin
if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
FXvtrSettings.Entries[FCurrentXvtr].LOError :=
FXvtrSettings.Entries[FCurrentXvtr].LOError + dLock;
FBeaconLockHz := FBeaconLockHz + dLock; // сколько накрутили за сессию (индикация)
PushNetworkState; // ретюн: XvtrTranslate читает новый LOError
if (FBeaconSaveCooldown = 0) and FDevConnected then
begin
FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
FBeaconSaveCooldown := BEACON_SAVE_TICKS;
end;
end;
procedure TRadioController.PersistBeaconCalibration;
// Коррекция уже живёт в LOError (ApplyBeaconLOError). Здесь — финальный flush на
// диск при выключении лока, на случай неотправленной из-за троттлинга.
begin
if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
if FDevConnected then FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
FBeaconSaveCooldown := 0;
end;
function TRadioController.BeaconState: TBeaconState;
begin
if not FBeaconLockOn then Exit(bsIdle);
if FBeaconLocked then Result := bsLock else Result := bsSearch;
end;
function TRadioController.BeaconCorrectionHz: Double;
begin
Result := FBeaconLockHz;
end;
function TRadioController.BeaconSNRdB: Double;
begin
Result := FBeaconSNRdB; // SNR последнего снимка декодера (dB)
end;
function TRadioController.BeaconLockEnabled: Boolean;
begin
Result := FBeaconLockOn;
end;
function TRadioController.BeaconRefHz: Double;
begin
Result := FBeaconRefHz;
end;
function TRadioController.BeaconTrackedFreqHz: Double;
begin
Result := FBeaconTrackHz;
end;
function TRadioController.BeaconDecodeFreqHz: Double;
begin
Result := FBeaconDecHz;
end;
procedure TRadioController.SetBeaconDecodeInvert(On_: Boolean);
begin
FBeaconDecInvert := On_;
if Assigned(FBeaconDec) then FBeaconDec.SetInvert(On_);
Changed(rfBeaconLock);
end;
function TRadioController.BeaconDecodeInvert: Boolean;
begin
Result := FBeaconDecInvert;
end;
function TRadioController.GetBeaconScope(out S: TBeaconScope): Boolean;
begin
Result := Assigned(FBeaconDec);
if Result then FBeaconDec.GetScope(S)
else FillChar(S, SizeOf(S), 0);
end;
function TRadioController.GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
begin
Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors);
end;
// ---------------------------------------------------------------------------
// Лупа маяка: окно наведения высокого разрешения (BeaconScopeForm)
// ---------------------------------------------------------------------------
// Движок работает в Гц относительно центра DDC, наружу отдаём абсолютные
// display-Гц — в них живут BeaconSeedAtHz, маркеры и клики пользователя.
procedure TRadioController.GetCaptureWindow(out ALowHz, AHighHz: Double);
var Half: Double;
begin
Half := FSpanHz / 2.0;
if Half <= 0 then Half := FSampleRate / 2.0;
ALowHz := FCenterFreq - Half;
AHighHz := FCenterFreq + Half;
end;
procedure TRadioController.SetBeaconZoomWindow(AEnabled: Boolean;
ACenterHz, ASpanHz: Double);
begin
if not Assigned(FDSPEngine) then Exit;
if AEnabled and (ACenterHz > 0) then
FDSPEngine.SetBeaconZoom(True, ACenterHz - FCenterFreq, ASpanHz)
else
FDSPEngine.SetBeaconZoom(False, 0, 0);
end;
function TRadioController.GetBeaconZoomSpectrum(var Pixels: array of Single;
out Count: Integer; out LowHz, HighHz: Double): Boolean;
begin
Count := 0;
LowHz := 0;
HighHz := 0;
Result := False;
if not Assigned(FDSPEngine) then Exit;
Result := FDSPEngine.GetBeaconZoomSpectrum(Pixels, Count, LowHz, HighHz);
if Count > 0 then
begin
LowHz := LowHz + FCenterFreq;
HighHz := HighHz + FCenterFreq;
end;
end;
function TRadioController.GetBeaconZoomWaterfall(var Pixels: array of Single;
out Count: Integer): Boolean;
begin
Count := 0;
Result := False;
if not Assigned(FDSPEngine) then Exit;
Result := FDSPEngine.GetBeaconZoomWaterfall(Pixels, Count);
end;
function TRadioController.BeaconZoomBinHz: Double;
begin
Result := 0;
if not Assigned(FDSPEngine) then Exit;
if FDSPEngine.BeaconZoomFFT > 0 then
Result := FDSPEngine.SampleRate / FDSPEngine.BeaconZoomFFT;
end;
function TRadioController.GetDMRStatus(out S: TDMRStatus): Boolean;
begin
Result := Assigned(FDMRDec);
if Result then FDMRDec.GetStatus(S)
else FillChar(S, SizeOf(S), 0);
end;
function TRadioController.GetSliceDMRStatus(SliceId: Integer;
out S: TDMRStatus): Boolean;
var
Idx: Integer;
begin
Idx := FindSliceIndex(SliceId);
Result := (Idx >= 0) and (FSlices[Idx].Mode = MODE_DMR) and
Assigned(FSlices[Idx].DMR);
if Result then FSlices[Idx].DMR.GetStatus(S)
else FillChar(S, SizeOf(S), 0);
end;
// Кромки полосы под режим/полосу (единый расчёт для главного канала и слайсов).
class procedure TRadioController.FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
// Обёртка над единственной таблицей знака (Settings.FilterEdgesFromBW) —
// оставлена ради прежних вызывающих (MainForm, SetSliceModeBW).
begin
FilterEdgesFromBW(Mode, BW, Lo, Hi);
end;
procedure TRadioController.SetSliceModeBW(Id, Mode, BW: Integer);
var Lo, Hi: Integer;
begin
// DMR has one fixed RF channel width; callers select only the mode.
if Mode = MODE_DMR then BW := FILT_DMR_BW;
FilterEdgesFor(Mode, BW, Lo, Hi);
SetSliceMode(Id, Mode);
SetSliceFilter(Id, Lo, Hi);
end;
procedure TRadioController.ApplyModeFilter;
var
Lo, Hi: Integer;
begin
// Кромки берём как есть: они уже знаковые и уже под текущий режим (таблица
// фильтров ведётся ПО режиму), конвертировать боковую больше нечем и негде.
Lo := FFilterLo;
Hi := FFilterHi;
if FWDSPReady and Assigned(FDSPEngine) then
begin
// Только RX. TX-полоса НЕ следует за RX-фильтром (модель Thetis) — её
// единственный источник TX-фильтр из настроек (SetTXFilterFull/ApplyTXChain
// через TXSignedEdges). Иначе широкий RX раздувал бы излучаемый сигнал.
FDSPEngine.SetFilter(Lo, Hi);
if FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then
begin
if FMode = MODE_FMRAW then
FDSPEngine.SetRawFMDeviation(FRawFMDeviation)
else
FDSPEngine.SetRXFMDeviation(FFMDeviation);
if FMode <> MODE_FMRAW then
FDSPEngine.SetTXFMParams(FFMDeviation, FTXSettings.FMLowCut,
FTXSettings.FMHighCut, FTXSettings.FMEmphPosition);
// A squelch would remove sync preambles; DMR always receives open.
FDSPEngine.SetFMSquelch((FMode = MODE_FM) and FFMSQOn, FFMSQLevel);
end
else
FDSPEngine.SetFMSquelch(False, 0);
end;
end;
// Выбирает дефолтный фильтр (индекс + полоса + FM-девиация) под текущий FMode.
// Зеркалит state-часть UI UpdateFilterButtons, чтобы смена режима была
// корректной и без UI (headless). FM сохраняет текущий NFM/WFM, прочие — дефолт.
function TRadioController.ClampFilterIdx(Mode, Idx: Integer): Integer;
// VAR1/VAR2 есть у любого режима, поэтому проверяем только пресетную часть.
// Выход за таблицу режима возвращает ЗАВОДСКОЙ индекс (а не крайний) — так
// вело себя прежнее «if FFilter > FILT_WFM_COUNT-1 then FILT_WFM_DEF».
begin
Result := Idx;
if (Idx = FILT_VAR1) or (Idx = FILT_VAR2) then Exit;
if (Idx < 0) or (Idx > FilterPresetCount(Mode) - 1) then
Result := FilterDefIdx(Mode);
end;
procedure TRadioController.ApplyFilterSlot(Idx: Integer);
var S: TFilterSlot;
begin
FFilter := ClampFilterIdx(FMode, Idx);
S := FFilterTable[FMode][FFilter];
FFilterLo := S.Lo;
FFilterHi := S.Hi;
FFilterBW := FilterBWFromEdges(FMode, S.Lo, S.Hi);
// FM-девиация привязана к пресету (NFM/WFM), у VAR берём девиацию дефолта.
if FMode = MODE_FM then
begin
if FFilter <= 1 then FFMDeviation := FILT_FM_DEV[FFilter]
else FFMDeviation := FILT_FM_DEV[FILT_FM_DEF];
end
else if FMode = MODE_DMR then
FFMDeviation := DMR_FM_DEV;
end;
procedure TRadioController.ApplyModeDefaults;
begin
// FM/WFM/DMR/FMRAW сохраняют выбранный пресет (NFM↔WFM и т.п.), остальные
// режимы возвращаются к заводскому индексу — как было до таблицы фильтров.
case FMode of
MODE_FM, MODE_WFM, MODE_DMR, MODE_FMRAW: ApplyFilterSlot(FFilter);
else ApplyFilterSlot(FilterDefIdx(FMode));
end;
end;
function TRadioController.FilterBWFor(M, Idx: Integer): Integer;
// Полоса выбранного фильтра под режим. Зеркалит выбор таблицы в UpdateFilterButtons,
// но НЕ меняет индекс (в отличие от ApplyModeDefaults) — нужно для XVTR-restore.
var S: TFilterSlot;
begin
M := EnsureRange(M, 0, CFG_MODE_MAX);
S := FFilterTable[M][ClampFilterIdx(M, Idx)];
Result := FilterBWFromEdges(M, S.Lo, S.Hi);
end;
function TRadioController.FilterIdxFor(M, BW: Integer): Integer;
// Обратно к FilterBWFor: индекс пресета с минимальным отклонением от BW.
var
i, Cnt, D, Best: Integer;
begin
Result := 0;
case M of
MODE_FM: Cnt := FILT_FM_COUNT;
MODE_WFM: Cnt := FILT_WFM_COUNT;
MODE_FMRAW: Cnt := FILT_RAW_COUNT;
MODE_DMR: Exit;
else Cnt := FILT_COUNT;
end;
Best := MaxInt;
for i := 0 to Cnt - 1 do
begin
D := Abs(FilterBWFor(M, i) - BW);
if D < Best then begin Best := D; Result := i; end;
end;
end;
// =====================================================================
// Команды (Фаза 1 — каркас). Полная логика переносится из TMainForm.
// =====================================================================
function TRadioController.CalcDriveByte: Byte;
// Thetis-совместимая формула:
// drive_byte = int( min(target_volts/0.8, 1.0) * 1.02 * 255 )
// target_volts = sqrt( 10^(target_dBm/10) * 0.05 )
// target_dBm = 10*log10(pwr_W*1000) - Cal ; pwr_W = Pos/100 * FPAMaxPower
var
Cal: Double;
Pos: Integer;
XvtrActive: Boolean;
pwr_W: Double;
target_dBm: Double;
target_volts: Double;
audio_vol: Double;
begin
XvtrActive := (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
FXvtrSettings.Entries[FCurrentXvtr].Enabled;
if XvtrActive then
Cal := FVHFBandCal[FCurrentXvtr]
else if (TxBandIdx >= 0) and (TxBandIdx < CFG_BAND_COUNT) then
Cal := FPABandCal[TxBandIdx] // банд TX-слайса при кросс-банд TX
else
Cal := 38.8;
if FTuning then
begin
// Уровень настройки всегда идёт от глобального TUN Level — иначе ручка в
// Transmit в трансвертере оказывалась мёртвой. Мощность слота (если
// включена галка) работает МНОЖИТЕЛЕМ, ровно как для обычного drive ниже:
// это «сколько мощности трансиверa разрешено этому трансвертеру», и к
// настройке оно относится так же, как к передаче.
Pos := EnsureRange(FTXSettings.TUNLevel, 0, 100);
if XvtrActive and FXvtrSettings.UseXVTRTunePower then
Pos := Round(Pos * EnsureRange(FXvtrSettings.Entries[FCurrentXvtr].TXPower,
0, 100) / 100.0);
end
else
begin
if XvtrActive then
Pos := Round(FDrivePercent * FXvtrSettings.Entries[FCurrentXvtr].TXPower / 100.0)
else
Pos := FDrivePercent;
end;
if Pos <= 0 then begin Result := 0; Exit; end;
pwr_W := Pos / 100.0 * FPAMaxPower;
target_dBm := 10.0 * Log10(pwr_W * 1000.0) - Cal;
target_volts := Sqrt(Power(10.0, target_dBm * 0.1) * 0.05);
audio_vol := Min(target_volts / 0.8, 1.0);
Result := Min(Round(audio_vol * 1.02 * 255.0), 255);
end;
procedure TRadioController.SanitizeFilterForMode;
// У FM/DMR/FMRAW пресетов меньше, чем у SSB: приводим индекс к допустимому и
// синхронизируем кромки/BW/девиацию. Чистая (state) часть UpdateFilterButtons.
begin
if FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then ApplyFilterSlot(FFilter);
end;
function TRadioController.MakeBandSettings: TBandSettings;
begin
Result.VfoA := FVfoA;
Result.VfoB := FVfoB;
Result.Mode := FMode;
Result.FilterIdx := FFilter;
Result.FilterBW := FFilterBW;
Result.AGCMode := FAGCMode;
Result.AGCTop := FAGCTop;
Result.CTun := FCTun;
Result.CenterHz := FCenterFreq; // DDC-центр → восстановим offset CTun при загрузке
Result.SpanHz := FSpanHz;
Result.ZoomFactor := FZoomFactor;
Result.PanSlider := FPanSlider;
Result.FMSQOn := FFMSQOn;
Result.FMSQLevel := FFMSQLevel;
Result.CTCSSOn := FFMCTCSSOn;
Result.CTCSSToneIdx := FFMCTCSSToneIdx;
Result.FMStepOn := FFMStepOn;
Result.FMStepIdx := FFMStepIdx;
Result.FMRptDir := FFMRptDir;
Result.FMRptOffsetHz := FFMRptOffsetHz;
// Аппаратный AGC AD936x — на диапазон (усиление тракта разное на 2м и QO-100).
Result.RxGainMode := FRxGainMode;
Result.RxGainDb := FRxGainDb;
Result.AttenDB := FAtten; // шаговый аттенюатор ADC — тоже на диапазон
// Waterfall AGC/NF — глобальные (не диапазонные)
end;
procedure TRadioController.SaveCurrentBand;
// Сохраняет текущее состояние диапазона. В XVTR-режиме пишем в XVTR-слот
// (HF band-cache не трогаем — там сохранён HF-диапазон до активации XVTR).
var
i: Integer;
B: TBandSettings;
begin
if not FDevConnected then Exit;
if FCurrentXvtr >= 0 then
begin
if (FCurrentXvtr < CFG_XVTR_COUNT) then
begin
i := FCurrentXvtr;
FXvtrSettings.Entries[i].LastFreq := FVfoA;
FXvtrSettings.Entries[i].LastFreqB := FVfoB;
FXvtrSettings.Entries[i].LastMode := FMode;
FXvtrSettings.Entries[i].LastFilterIdx := FFilter;
FXvtrSettings.Entries[i].LastFMSQOn := FFMSQOn;
FXvtrSettings.Entries[i].LastFMSQLevel := FFMSQLevel;
FXvtrSettings.Entries[i].LastCTCSSOn := FFMCTCSSOn;
FXvtrSettings.Entries[i].LastCTCSSToneIdx := FFMCTCSSToneIdx;
FXvtrSettings.Entries[i].LastFMStepOn := FFMStepOn;
FXvtrSettings.Entries[i].LastFMStepIdx := FFMStepIdx;
FXvtrSettings.Entries[i].LastFMRptDir := FFMRptDir;
FXvtrSettings.Entries[i].LastFMRptOffsetHz := FFMRptOffsetHz;
FXvtrSettings.Entries[i].LastCTun := FCTun;
FXvtrSettings.Entries[i].LastCenterHz := FCenterFreq;
FXvtrSettings.Entries[i].LastAGCMode := FAGCMode;
FXvtrSettings.Entries[i].LastAGCTop := FAGCTop;
FXvtrSettings.Entries[i].LastCTCSSAutoActive := FFMCTCSSAutoActive;
FXvtrSettings.Entries[i].LastFMRptAutoActive := FFMRptAutoActive;
FXvtrSettings.Entries[i].LastZoomFactor := FZoomFactor;
FXvtrSettings.Entries[i].LastPanSlider := FPanSlider;
FXvtrSettings.Entries[i].LastRxGainMode := FRxGainMode;
FXvtrSettings.Entries[i].LastRxGainDb := FRxGainDb;
FXvtrSettings.Entries[i].LastAttenDB := FAtten;
end;
FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
Exit;
end;
// HPSDR: приёмник только HF — если VFO ушёл в VHF/UHF (напр. канал на 2м без
// XVTR), не сохраняем, иначе HF-кэш диапазона перезапишется VHF-частотой.
// Pluto: VHF/UHF — нативные диапазоны (VU-план), сохранять обязательно;
// от перезаписи чужим диапазоном защищает FreqToBandIdx-проверка ниже.
if (not IsPluto) and (FVfoA > 61000000) then Exit;
B := MakeBandSettings;
// Bandstack: VFO A и B независимы и могут стоять на разных бэндах. В кэш
// ЭТОГО бэнда пишем только ту частоту VFO, что реально в нём — иначе чужая
// частота (напр. VFO A на 40м при сохранении 30м) загрязнит memory бэнда.
if FreqToBandIdx(FVfoA, IsPluto) <> FCurrentBand then B.VfoA := FBandCache[FCurrentBand].VfoA;
if FreqToBandIdx(FVfoB, IsPluto) <> FCurrentBand then B.VfoB := FBandCache[FCurrentBand].VfoB;
FBandCache[FCurrentBand] := B;
FSettings.SaveBand(FDevMAC, FCurrentBand, FBandCache[FCurrentBand]);
end;
function TRadioController.BuildGlobalSettings: TGlobalSettings;
// Радио-поля — из живого состояния; тема/FPS/digits/CAT — сквозь из FLoadedGlobal
// (контроллер их не трактует). Сидируем загруженным блобом, поверх — радио.
var i: Integer;
begin
Result := FLoadedGlobal; // несёт не-радио поля (тема/FPS/digits/CAT) round-trip
Result.Volume := FVolume;
Result.TxMonVolume := FTxMonVolume;
Result.DriveLevel := FDrivePercent;
Result.PAMaxPower := FPAMaxPower;
Result.PlutoTxMaxAttDb := FPlutoTxMaxAttDb;
for i := 0 to CFG_BAND_COUNT - 1 do Result.PABandCal[i] := FPABandCal[i];
for i := 0 to CFG_XVTR_COUNT - 1 do Result.VHFBandCal[i] := FVHFBandCal[i];
Result.Cal := FCal;
Result.ActiveVfo := FActiveVfo;
Result.NRMode := FNRMode;
Result.NBMode := FNBMode;
Result.SNBEnabled := FSNB;
Result.ANFEnabled := FANF;
Result.AGCSlope := 0;
Result.AGCHangThreshold := 100;
Result.WfAGCEnabled := FWfAGCEnabled;
Result.WfNFEnabled := FWfNFEnabled;
Result.DitherEnabled := FDitherEnabled;
Result.RandomEnabled := FRandomEnabled;
Result.LastBand := FCurrentBand;
Result.LastXvtr := FCurrentXvtr;
Result.SampleRate := FSampleRate;
Result.RxGainMode := FRxGainMode;
Result.RxGainDb := FRxGainDb;
if FWDSPReady then
begin
Result.FFTSize := FDSPEngine.FFTSize;
Result.WindowType := FDSPEngine.WindowType;
Result.SpecDetector := FDSPEngine.SpecDetector;
Result.SpecAvgMode := FDSPEngine.SpecAvgMode;
Result.SpecAvgTimeMS := FDSPEngine.SpecAvgTimeMS;
Result.WfDetector := FDSPEngine.WfDetector;
Result.WfAvgMode := FDSPEngine.WfAvgMode;
Result.WfAvgTimeMS := FDSPEngine.WfAvgTimeMS;
end
else
begin
Result.FFTSize := 131072;
Result.WindowType := 2;
Result.SpecDetector := 0;
Result.SpecAvgMode := 3;
Result.SpecAvgTimeMS := 30.0;
Result.WfDetector := 0;
Result.WfAvgMode := 3;
Result.WfAvgTimeMS := 120.0;
end;
Result.WfManualHigh := FWfManualHigh;
Result.WfManualLow := FWfManualLow;
Result.WfAGCOffset := FWfAGCOffset;
Result.WfPalette := FWfPalette;
Result.WfGamma := FWfGamma;
Result.DisplayPixels := FDisplayPixels;
Result.WfSpeedDiv := FWfSpeedDiv;
Result.SpecMSAA := FSpecMSAA;
Result.SpecRefLevel := FSpecRefLevel;
Result.SpecRange := FSpecRange;
Result.SpecGridStep := FSpecGridStep;
Result.BeaconZoomSpanHz := FBcnZoomSpanHz;
Result.BeaconZoomOffsetHz := FBcnZoomOffsetHz;
Result.BeaconZoomFollow := FBcnZoomFollow;
Result.BeaconZoomAuto := FBcnZoomAuto;
Result.AudioSampleRate := FAudioOut.SampleRate;
Result.ShowSpectrum := FShowSpectrum;
Result.ShowWaterfall := FShowWaterfall;
Result.ShowWideband := FShowWideband;
Result.WidebandFill := FWidebandFill;
Result.SpectrumFill := FSpectrumFill;
Result.DisplayDuplex := FDisplayDuplex;
Result.RxMuteOnTx := FRxMuteOnTx;
Result.AudioOutDevice := FAudioOutDevName;
Result.AudioInDevice := FAudioInDevName;
Result.SendAudioToRadio := FSendAudioToRadio;
Result.RawFMDeviation := Round(FRawFMDeviation);
end;
procedure TRadioController.SaveGlobalSettings;
begin
if FDevConnected then FSettings.SaveGlobal(FDevMAC, BuildGlobalSettings);
end;
procedure TRadioController.ApplyBandDSP(const B: TBandSettings);
// DSP-настройки диапазона + события рендера. Частоты VFO/центр НЕ трогает.
begin
// --- Режим ---
FMode := EnsureRange(B.Mode, MODE_MIN, MODE_MAX);
if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR);
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetMode(FMode);
// --- Фильтр (санитизация FM до применения к DSP — как в оригинале) ---
// Банд помнит ТОЛЬКО выбранный слот; кромки живут в таблице фильтров
// (по режиму, как в Thetis). Иначе у VAR было бы два источника истины —
// ровно та болезнь, от которой уходили.
ApplyFilterSlot(B.FilterIdx);
ApplyModeFilter;
// --- AGC (единый маппинг — исправляет каст-баг RestoreBand) ---
FAGCMode := B.AGCMode;
FAGCTop := B.AGCTop;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetAGCTop(FAGCTop);
ApplyAGCToEngine;
// --- Аппаратный AGC AD936x (Pluto/LibreSDR): режим + manual-гейн диапазона.
// У openHPSDR SetRxGain — no-op, поля просто ездят с диапазоном.
FRxGainMode := EnsureRange(B.RxGainMode, 0, 3);
FRxGainDb := EnsureRange(B.RxGainDb, 0, 73);
ApplyRxGainToBackend;
Changed(rfRxGain); // UI: кнопки FAST/SLOW/HYB/MAN + ползунок
// --- Шаговый аттенюатор ADC (openHPSDR) — на диапазон/слот трансвертера.
FAtten := EnsureRange(B.AttenDB, 0, 31);
if Assigned(FNetwork) then FNetwork.SetStepAtten(Byte(FAtten));
Changed(rfAtten);
// --- FM squelch / CTCSS / step / repeater ---
FFMSQOn := B.FMSQOn;
FFMSQLevel := B.FMSQLevel;
FFMCTCSSOn := B.CTCSSOn;
FFMCTCSSToneIdx := B.CTCSSToneIdx;
if FWDSPReady and Assigned(FDSPEngine) then
begin
FDSPEngine.SetTXCTCSS(FFMCTCSSOn, CTCSS_TONES[FFMCTCSSToneIdx]);
FDSPEngine.SetFMSquelch(FFMSQOn and (FMode = MODE_FM), FFMSQLevel);
end;
FFMStepOn := B.FMStepOn;
FFMStepIdx := EnsureRange(B.FMStepIdx, 0, 3);
if FMode in [MODE_DMR, MODE_FMRAW] then
begin
FFMStepOn := True;
FFMStepIdx := 1; // DMR default raster: 12.5 kHz
end;
FFMRptDir := B.FMRptDir;
FFMRptOffsetHz := B.FMRptOffsetHz;
if FFMRptOffsetHz <= 0 then FFMRptOffsetHz := RptDefaultOffsetHz(B.VfoA);
// --- TX-профиль под новый режим ---
// Смена диапазона и вход/выход трансвертера меняют режим ГЛАВНОГО здесь, а
// не через SetMode, поэтому автоматику привязок надо звать и отсюда — иначе
// при переходе КВ↔трансвертер оставался профиль прошлого режима.
if FTxSliceId = 0 then ApplyBoundTXProfile;
// FDSPEngine.SetMode выше насильно выставил TXA в режим главного приёмника,
// но передавать можем со слайса — возвращаем режим настоящего источника.
if FWDSPReady and Assigned(FDSPEngine) and (ActiveTXMode <> FMode) then
FDSPEngine.SetTXMode(ActiveTXMode);
// --- CTUN ---
FCTun := B.CTun;
if FWDSPReady and Assigned(FDSPEngine) and not FCTun then FDSPEngine.SetShift(0.0);
// --- Span (sample rate глобальный, не per-band — лишь ресинк) ---
FSpanHz := FSampleRate;
// --- Рендер: единичные события (UI рисует через OnControllerState) ---
Changed(rfBand);
Changed(rfMode); // rfMode-рендер зовёт UpdateFilterButtons (FM-панели)
Changed(rfFilter);
Changed(rfAGCMode);
Changed(rfAGCTop);
Changed(rfFMSQ);
Changed(rfFMSQLevel);
Changed(rfFMCTCSS);
Changed(rfFMCTCSSTone);
Changed(rfFMStep); // кнопка вкл/выкл STEP
Changed(rfFMStepIdx); // дропдаун шага + web + grid
Changed(rfFMRpt);
Changed(rfCTun);
end;
procedure TRadioController.RestoreBand(BandIdx: Integer);
// Загружает состояние диапазона из кэша, применяет к DSP, делает финальную
// перестройку приёмника на активный VFO и шлёт события рендера. Полная команда —
// зовётся напрямую desktop/web/channel/connect-restore.
var B: TBandSettings;
begin
if (BandIdx < 0) or (BandIdx >= CFG_BAND_COUNT) then Exit;
Changed(rfBandRestore); // явная смена диапазона → UI сбросит Wf-avg буфер
B := FBandCache[BandIdx];
FCurrentBand := BandIdx;
// Pluto: кэш диапазона может нести HF-частоту (дефолты Settings). Если VfoA не
// попадает в VHF-слот — берём частоту по умолчанию из плана.
if IsPluto and (FreqToBandIdx(B.VfoA, True) <> BandIdx) then
begin
B.VfoA := BandDefaultFreq(BandIdx, True);
B.VfoB := B.VfoA;
end;
ApplyBandDSP(B);
// --- Zoom/pan диапазона: восстанавливаем и переармируем анализатор ---
FZoomFactor := EnsureRange(B.ZoomFactor, 0.0, 1.0);
FPanSlider := EnsureRange(B.PanSlider, 0.0, 1.0);
if Assigned(FDSPEngine) then FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
Changed(rfZoom);
// --- VFO / drive (только RestoreBand — bandstack-путь VFO не трогает) ---
// При CTun восстанавливаем сохранённый DDC-центр, чтобы VFO остался на своём
// месте в полосе (а не прыгал в центр). Берём только если offset влезает в
// текущий span — иначе (сменился sample rate / битый конфиг) центрируем на VFO.
// ApplyTuneCore в SetVfoA при необходимости сам прокрутит центр к краю.
if FCTun and (B.CenterHz > 0) and (Abs(B.VfoA - B.CenterHz) < FSpanHz / 2) then
FCenterFreq := B.CenterHz
else
FCenterFreq := B.VfoA;
FVfoB := B.VfoB;
FVfoA := B.VfoA;
FDriveLevel := CalcDriveByte;
Changed(rfVfoB);
// Финальная перестройка приёмника на АКТИВНЫЙ VFO. VFO A — через SetVfoA
// (хук OnAfterTune несёт канальную оркестрацию); VFO B — через SetVfoB.
// Иначе смена диапазона при активном B центрировала бы приёмник на A.
if FActiveVfo = 0 then SetVfoA(FVfoA)
else SetVfoB(FVfoB);
ApplyAntPorts; // AD936x: разъёмы, выбранные для этого диапазона
end;
function TRadioController.ActiveVfoHz: Double;
begin
if FActiveVfo = 0 then Result := FVfoA else Result := FVfoB;
end;
procedure TRadioController.ApplyTuneCore(VfoHz: Double);
// Общее ядро перестройки приёмника на частоту АКТИВНОГО VFO (A или B).
// CTUN OFF: DDC = VfoHz, сдвиг 0.
// CTUN ON: DDC фиксирован, демодулятор сдвигаем на VfoHz-FCenterFreq;
// если полоса фильтра вышла за край дисплея — прокручиваем центр.
// Ставит FCenterFreq / shift / FLastVfoScrolled.
var
Offset, FiltLo, FiltHi, HalfSpan: Double;
begin
FLastVfoScrolled := False;
if not FCTun then
begin
FCenterFreq := VfoHz;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
end
else
begin
Offset := VfoHz - FCenterFreq;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(Offset);
HalfSpan := FSpanHz / 2;
case FMode of
MODE_LSB: begin FiltLo := Offset - FFilterBW; FiltHi := Offset - 100; end;
MODE_USB: begin FiltLo := Offset + 100; FiltHi := Offset + FFilterBW; end;
else begin FiltLo := Offset - FFilterBW/2; FiltHi := Offset + FFilterBW/2; end;
end;
if (FiltHi > HalfSpan) or (FiltLo < -HalfSpan) then
begin
if FiltHi > HalfSpan then
FCenterFreq := VfoHz - HalfSpan * 0.5
else
FCenterFreq := VfoHz + HalfSpan * 0.5;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(VfoHz - FCenterFreq);
FLastVfoScrolled := True;
end;
end;
end;
procedure TRadioController.SetVfoA(Hz: Double);
// Перестройка VFO A: XVTR-клэмп, CTUN ядро, детект диапазона, FM авто-RPT,
// проталкивание в сеть. Рендер — в UI через OnControllerState(rfVfoA/rfBand).
// Канальную оркестрацию (авто-CTCSS, pre-channel drive) делает UI-путь.
var
NewFreq: Int64;
BandIdx: Integer;
begin
NewFreq := Round(Hz);
// Клэмп в пределах XVTR-band — не даём VFO уйти за FreqBegin/FreqEnd
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
FXvtrSettings.Entries[FCurrentXvtr].Enabled then
begin
if NewFreq < FXvtrSettings.Entries[FCurrentXvtr].FreqBegin then
NewFreq := Round(FXvtrSettings.Entries[FCurrentXvtr].FreqBegin);
if NewFreq > FXvtrSettings.Entries[FCurrentXvtr].FreqEnd then
NewFreq := Round(FXvtrSettings.Entries[FCurrentXvtr].FreqEnd);
end;
FVfoA := NewFreq;
ApplyTuneCore(FVfoA);
// Детект диапазона (только HF: в XVTR клэмп держит VFO внутри band,
// FreqToBandIdx вернёт -1 для VHF/UHF).
if FCurrentXvtr < 0 then
begin
BandIdx := FreqToBandIdx(FVfoA, IsPluto);
if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
begin
FCurrentBand := BandIdx;
FDriveLevel := CalcDriveByte;
Changed(rfBand);
end;
end;
// Авто-MINUS в 2 м репитерном сегменте (само снимает при уходе из сегмента).
if FMode = MODE_FM then UpdateRptAutoState;
PushNetworkState;
Changed(rfVfoA);
// Канальная оркестрация — ПОСЛЕ рендера rfVfoA (вне его try/finally-гарда), без
// ре-ентранси: OnVfoTuned зовёт команды (SetDrive/SetFMRpt), которым нужен
// чистый верхний уровень OnControllerState.
if Assigned(FOnAfterTune) then FOnAfterTune;
end;
procedure TRadioController.StoreVfoA(Hz: Double);
// Сохранить частоту VFO A БЕЗ перестройки приёмника (когда активен B — слушаем B,
// A только хранится/отображается). Симметрично «неактивной» ветке SetVfoB.
// Рендер FreqDispA — через rfVfoA (спектр он не трогает, т.к. SyncSpecViewFreq
// держит активный VFO B); смена band-кнопки — через rfBand.
var BandIdx: Integer;
begin
FVfoA := Hz;
if FCurrentXvtr < 0 then
begin
BandIdx := FreqToBandIdx(FVfoA, IsPluto);
if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
begin
FCurrentBand := BandIdx;
FDriveLevel := CalcDriveByte;
Changed(rfBand);
end;
end;
Changed(rfVfoA);
end;
procedure TRadioController.SetVfoB(Hz: Double);
// VFO B всегда сохраняется. Если B активен — перестраиваем приёмник через
// ApplyTuneCore (с учётом CTUN), детектим диапазон и толкаем в сеть. Если B
// неактивен (split, слушаем A) — только сохраняем частоту (для TX).
// Рендер FreqDispB/спектра — в UI через OnControllerState(rfVfoB).
var
BandIdx: Integer;
NewFreq: Int64;
begin
NewFreq := Round(Hz);
// Клэмп в пределах XVTR-band (как в SetVfoA) — VFO B тоже не уходит за край.
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
FXvtrSettings.Entries[FCurrentXvtr].Enabled then
begin
if NewFreq < FXvtrSettings.Entries[FCurrentXvtr].FreqBegin then
NewFreq := Round(FXvtrSettings.Entries[FCurrentXvtr].FreqBegin);
if NewFreq > FXvtrSettings.Entries[FCurrentXvtr].FreqEnd then
NewFreq := Round(FXvtrSettings.Entries[FCurrentXvtr].FreqEnd);
end;
FVfoB := NewFreq;
if FActiveVfo = 1 then
begin
ApplyTuneCore(FVfoB);
if FCurrentXvtr < 0 then
begin
BandIdx := FreqToBandIdx(FVfoB, IsPluto);
if (BandIdx >= 0) and (BandIdx <> FCurrentBand) then
begin
FCurrentBand := BandIdx;
FDriveLevel := CalcDriveByte;
Changed(rfBand);
end;
end;
PushNetworkState;
end;
Changed(rfVfoB);
end;
procedure TRadioController.SetActiveVfo(Idx: Integer);
// Переключение активного (RX) VFO с перестройкой приёмника на его частоту.
// Bandstack: если активируемый VFO стоит на ДРУГОМ ham-бэнде — сохраняем
// покидаемый бэнд и восстанавливаем DSP-настройки нового (mode/filter/AGC/CTUN/
// FM). Частоты обоих VFO при этом НЕ трогаем — каждый VFO несёт свой диапазон.
// CTUN (того же бэнда): если частота нового VFO видна в окне — только shift
// (DDC/сеть не трогаем); иначе CTUN сбрасывается и центрируемся на частоте.
// Рендер (дисплеи/спектр/band/CTUN-кнопка) — в UI через OnControllerState.
var
ActiveFreq: Double;
HalfSpan, Offset: Double;
FreqVisible, NeedPush: Boolean;
NewBand: Integer;
begin
FActiveVfo := Idx;
ActiveFreq := ActiveVfoHz;
NeedPush := False;
// --- Bandstack: активируемый VFO на другом ham-бэнде → восстановить бэнд ---
if FCurrentXvtr < 0 then
begin
NewBand := FreqToBandIdx(ActiveFreq, IsPluto);
if (NewBand >= 0) and (NewBand <> FCurrentBand) then
begin
SaveCurrentBand; // сохранить покидаемый бэнд
FCurrentBand := NewBand;
ApplyBandDSP(FBandCache[NewBand]); // mode/filter/AGC/CTUN/FM + рендер
FCenterFreq := ActiveFreq; // центр на активную (CTUN центрируется тут)
FDriveLevel := CalcDriveByte; // PA-калибровка нового бэнда
NeedPush := True; // DDC-центр сменился → толкнуть в сеть
end;
end;
if FCTun then
begin
HalfSpan := FSpanHz / 2;
Offset := ActiveFreq - FCenterFreq;
FreqVisible := (Offset > -HalfSpan) and (Offset < HalfSpan);
if FreqVisible then
begin
// Частота видна — просто сдвигаем демодулятор.
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(Offset);
end
else
begin
// Частота за окном — сбрасываем CTUN и центрируемся на ней.
FCTun := 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;
Changed(rfCTun);
end;
end
else
begin
FCenterFreq := ActiveFreq;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
NeedPush := True;
end;
RefreshSliceShifts; // центр мог сместиться — держим слайсы на их TargetHz
if NeedPush then PushNetworkState;
Changed(rfActiveVfo);
end;
procedure TRadioController.TuneActiveBy(DeltaHz: Double);
begin
if FVfoLock then Exit; // VFO заблокирован — перестройку игнорируем
if FActiveVfo = 0 then SetVfoA(FVfoA + DeltaHz)
else SetVfoB(FVfoB + DeltaHz);
end;
procedure TRadioController.SetCenter(Hz: Double);
// CTUN-скролл окна просмотра: двигаем DDC-центр БЕЗ смены VFO. Демодулятор
// сдвигаем = активный VFO − новый центр; полный HP-кадр в радио. Рендер
// (ruler/спектр) — в UI через OnControllerState(rfCenterFreq).
// Точки входа: десктопный CTUN-drag и web (SyncWebCenter).
begin
FCenterFreq := Hz;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetShift(ActiveVfoHz - FCenterFreq);
RefreshSliceShifts; // слайсы привязаны к абсолютной частоте — сдвинуть NCO
PushNetworkState;
Changed(rfCenterFreq);
end;
procedure TRadioController.VfoSwap;
// Меняем VFO A/B местами и перестраиваем приёмник на новую частоту активного
// VFO (центр=частота, shift 0 — CTUN-оффсет после свопа теряет смысл).
var
T: Double;
ActiveFreq: Double;
NewBand: Integer;
begin
T := FVfoA; FVfoA := FVfoB; FVfoB := T;
ActiveFreq := ActiveVfoHz;
// Bandstack: если активный после свопа VFO на другом ham-бэнде — сохраняем
// покидаемый бэнд и восстанавливаем DSP нового (mode/filter/AGC/CTUN/FM), как
// SetActiveVfo. Иначе своп на другой диапазон оставлял бы старые mode/фильтр/AGC
// при подсвеченной кнопке нового бэнда (рассинхрон).
if FCurrentXvtr < 0 then
begin
NewBand := FreqToBandIdx(ActiveFreq, IsPluto);
if (NewBand >= 0) and (NewBand <> FCurrentBand) then
begin
SaveCurrentBand;
FCurrentBand := NewBand;
ApplyBandDSP(FBandCache[NewBand]); // несёт Changed(rfBand) + рендер DSP-полей
FDriveLevel := CalcDriveByte; // PA-калибровка нового бэнда
end;
end;
FCenterFreq := ActiveFreq;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
RefreshSliceShifts;
PushNetworkState;
Changed(rfVfoA);
Changed(rfVfoB);
end;
procedure TRadioController.VfoACopyB; // A>B: частота A копируется в B
begin
SetVfoB(FVfoA); // сохраняет B:=A, перестраивает если B активен, рендер rfVfoB
end;
procedure TRadioController.VfoBCopyA; // B>A: частота B копируется в A
begin
FVfoA := FVfoB;
if FActiveVfo = 0 then SetVfoA(FVfoA) // A активен — перестраиваем приёмник
else Changed(rfVfoA); // A неактивен — только рендер дисплея
end;
procedure TRadioController.SetMode(M: Integer);
begin
M := EnsureRange(M, MODE_MIN, MODE_MAX);
FMode := M;
// DMR channels use a 12.5 kHz raster by default. Keep the ordinary STEP
// controls enabled so the operator can select another raster afterwards.
if FMode in [MODE_DMR, MODE_FMRAW] then
begin
FFMStepOn := True;
if FMode = MODE_DMR then FFMStepIdx := 1; // 12.5 kHz
Changed(rfFMStep);
Changed(rfFMStepIdx);
end;
// Entering receive-only DMR on the selected main TX source immediately
// releases an existing carrier. SetTune(False) also releases MOX.
if ActiveTXMode = MODE_DMR then
begin
if FTuning then SetTune(False)
else if FTransmitting then SetMOX(False);
end;
if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR);
ApplyModeDefaults; // дефолтный фильтр/девиация под режим
if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastMode := FMode
else FBandCache[FCurrentBand].Mode := FMode;
// Профиль привязан к паре «флаг + модуляция»: если передаём с главного,
// новый режим приносит свой профиль. До пуша в WDSP — ApplyTXChainSettings
// внутри SetTXMode ниже перешьёт цепь уже с новыми значениями.
if FTxSliceId = 0 then ApplyBoundTXProfile;
if FWDSPReady and Assigned(FDSPEngine) then
begin
FDSPEngine.SetMode(FMode);
// SetMode configures the main RX/TX pair; a selected slice may still be
// the actual TX source and therefore owns the transmitter mode.
FDSPEngine.SetTXMode(ActiveTXMode);
// A live mode change must also switch away from web/HW microphone when
// entering FM RAW; normally the modem is connected through PortAudio.
if FTransmitting and (ActiveTXMode = MODE_FMRAW) then
begin
FWebMicActive := False;
FTCIMicActive := False;
FDSPEngine.SetTXMicSource(DefaultMicSource);
if Assigned(ActiveMicInput) and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
end;
// Если TUN активен — пересчитываем знак частоты под новый режим
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;
procedure TRadioController.SetFilterIdx(Idx: Integer);
begin
// Публичная команда: Idx может прийти из CAT/web без проверки. Клампится
// внутри ApplyFilterSlot (у FM/WFM/FMRAW пресетов меньше; VAR всегда есть).
ApplyFilterSlot(Idx);
CacheFilterToBand;
ApplyModeFilter;
Changed(rfFilter);
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;
procedure TRadioController.SelectFilterSlotState(Idx: Integer);
begin
ApplyFilterSlot(Idx);
end;
procedure TRadioController.SanitizeFilterState;
begin
SanitizeFilterForMode;
end;
procedure TRadioController.TXFilterEdges(out Lo, Hi: Integer);
begin
Lo := 0; Hi := 0;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.TXFilterEdgesHz(Lo, Hi);
end;
function TRadioController.FilterSlot(Mode, Idx: Integer): TFilterSlot;
begin
Result := FFilterTable[EnsureRange(Mode, 0, CFG_MODE_MAX)]
[EnsureRange(Idx, 0, FILT_SLOTS - 1)];
end;
procedure TRadioController.SetFilterSlot(Mode, Idx: Integer; const S: TFilterSlot);
begin
Mode := EnsureRange(Mode, 0, CFG_MODE_MAX);
Idx := EnsureRange(Idx, 0, FILT_SLOTS - 1);
if S.Hi <= S.Lo then Exit; // пустая полоса = глухой приём
FFilterTable[Mode][Idx] := S;
if Assigned(FSettings) then FSettings.SaveFilters(FDevMAC, FFilterTable);
// Правка активного слота слышна сразу — форма настроек в EWSDR мгновенная.
if (Mode = FMode) and (Idx = FFilter) then
begin
ApplyFilterSlot(Idx);
CacheFilterToBand;
ApplyModeFilter;
Changed(rfFilter);
end;
end;
procedure TRadioController.ResetFilterSlot(Mode, Idx: Integer);
var Def: TFilterSet;
begin
Mode := EnsureRange(Mode, 0, CFG_MODE_MAX);
Def := DefaultFilterSet(Mode);
SetFilterSlot(Mode, Idx, Def[EnsureRange(Idx, 0, FILT_SLOTS - 1)]);
end;
procedure TRadioController.ResetFilterSet(Mode: Integer);
var i: Integer;
begin
for i := 0 to FILT_SLOTS - 1 do ResetFilterSlot(Mode, i);
end;
procedure TRadioController.SetFilterEdges(LoHz, HiHz: Integer);
// Drag на спектре / CAT ZZFL-ZZFH / web: пресеты не портим — уходим в VAR1.
var S: TFilterSlot; Target: Integer;
begin
if HiHz - LoHz < FILT_MIN_WIDTH then Exit;
if (FFilter = FILT_VAR1) or (FFilter = FILT_VAR2) then Target := FFilter
else Target := FILT_VAR1;
S := FFilterTable[FMode][Target];
S.Lo := LoHz;
S.Hi := HiHz;
FFilterTable[FMode][Target] := S;
if Assigned(FSettings) then FSettings.SaveFilters(FDevMAC, FFilterTable);
ApplyFilterSlot(Target);
CacheFilterToBand;
ApplyModeFilter;
Changed(rfFilter);
end;
procedure TRadioController.SetFilterBW(BWHz: Integer);
// Непрерывная полоса (web-ползунок). Точное совпадение с пресетом выбирает
// пресет, произвольная ширина уходит в VAR — как и перетаскивание края мышью.
var
Lo, Hi, i, n: Integer;
begin
if FMode = MODE_DMR then BWHz := FILT_DMR_BW;
FilterEdgesFromBW(FMode, BWHz, Lo, Hi);
n := FilterPresetCount(FMode);
for i := 0 to n - 1 do
if (FFilterTable[FMode][i].Lo = Lo) and (FFilterTable[FMode][i].Hi = Hi) then
begin
SetFilterIdx(i);
Exit;
end;
SetFilterEdges(Lo, Hi);
end;
procedure TRadioController.SetRawFMDeviation(Hz: Integer);
begin
FRawFMDeviation := EnsureRange(Hz, RAW_FM_DEV_MIN, RAW_FM_DEV_MAX);
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetRawFMDeviation(FRawFMDeviation);
SaveGlobalSettings;
Changed(rfFMDeviation);
end;
// UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для всех
// фронтендов и слайсов (раньше web/слайдер ошибочно кастовали индекс прямо в enum).
class function TRadioController.AGCModeFromUI(M: Integer): TWDSPAGCMode;
const
AGCModes: array[0..4] of TWDSPAGCMode = (agcFast, agcMedium, agcSlow, agcLong, agcOff);
begin
if (M < 0) or (M > 4) then M := 1;
Result := AGCModes[M];
end;
class function TRadioController.AGCModeToUI(A: TWDSPAGCMode): Integer;
// Обратно к AGCModeFromUI: 0=FAST,1=MED,2=SLOW,3=LONG,4=OFF (шкала UI и CAT).
begin
case A of
agcFast: Result := 0;
agcSlow: Result := 2;
agcLong: Result := 3;
agcOff: Result := 4;
else Result := 1; // agcMedium
end;
end;
procedure TRadioController.ApplyAGCToEngine;
begin
if FWDSPReady and Assigned(FDSPEngine) and (FAGCMode >= 0) and (FAGCMode <= 4) then
FDSPEngine.SetAGC(AGCModeFromUI(FAGCMode), 50.0);
end;
procedure TRadioController.SetAGCMode(M: Integer);
begin
if (M < 0) or (M > 4) then Exit;
FAGCMode := M;
ApplyAGCToEngine;
if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastAGCMode := FAGCMode
else FBandCache[FCurrentBand].AGCMode := FAGCMode;
Changed(rfAGCMode);
end;
procedure TRadioController.SetAGCTop(DB: Integer);
begin
if DB < 20 then DB := 20 else if DB > 120 then DB := 120;
FAGCTop := DB;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetAGCTop(FAGCTop);
ApplyAGCToEngine; // ре-применяем режим с новым уровнем
if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastAGCTop := FAGCTop
else FBandCache[FCurrentBand].AGCTop := FAGCTop;
Changed(rfAGCTop);
end;
procedure TRadioController.AGCTopBy(Delta: Integer);
begin SetAGCTop(FAGCTop + Delta); end;
procedure TRadioController.ApplyRxGainToBackend;
begin
if Assigned(FNetwork) and FNetwork.Connected then
FNetwork.SetRxGain(FRxGainMode, FRxGainDb);
end;
procedure TRadioController.SetRxGainMode(M: Integer);
begin
if M < 0 then M := 0 else if M > 3 then M := 3;
FRxGainMode := M;
ApplyRxGainToBackend;
Changed(rfRxGain);
end;
procedure TRadioController.SetRxGain(Db: Integer);
begin
if Db < 0 then Db := 0 else if Db > 73 then Db := 73;
FRxGainDb := Db;
// Ручная установка усиления подразумевает manual-режим (0).
FRxGainMode := 0;
ApplyRxGainToBackend;
Changed(rfRxGain);
end;
procedure TRadioController.RxGainBy(Delta: Integer);
begin SetRxGain(FRxGainDb + Delta); end;
function TRadioController.MonitoringTX: Boolean;
begin
// Звук даунлинка на передаче идёт только в full-duplex (DUP) при выключенном
// RX MUTE — тот же гейт, что в движке (FKeepRXDuringTX and FMonitorRXDuringTX).
Result := FTransmitting and FDisplayDuplex and (not FRxMuteOnTx);
end;
function TRadioController.ActiveVolume: Integer;
begin
if MonitoringTX then Result := FTxMonVolume else Result := FVolume;
end;
procedure TRadioController.ApplyActiveVolume;
begin
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetVolume(ActiveVolume / 100.0);
Changed(rfVolume); // слайдер/оверлей ← ActiveVolume
end;
procedure TRadioController.SetVolume(V: Integer);
begin
if V < 0 then V := 0 else if V > 100 then V := 100;
// Правим ту громкость, что сейчас активна: на TX-мониторе — FTxMonVolume,
// иначе FVolume. Обе персистятся; слайдер редактирует активную.
if MonitoringTX then FTxMonVolume := V else FVolume := V;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetVolume(ActiveVolume / 100.0);
// rfVolume — для слайдера и оверлея: они показывают ActiveVolume. А вот кто
// именно изменился, слайдеру всё равно, зато не всё равно внешним клиентам:
// у них громкость приёма и громкость монитора — РАЗНЫЕ величины.
if MonitoringTX then Changed(rfMonVolume);
Changed(rfVolume);
end;
procedure TRadioController.SetRxVolume(V: Integer);
begin
FVolume := EnsureRange(V, 0, 100);
if MonitoringTX then Changed(rfVolume) // звучит монитор — трогать тракт нечем
else ApplyActiveVolume; // он же несёт Changed(rfVolume)
end;
procedure TRadioController.VolumeBy(Delta: Integer);
begin SetVolume(ActiveVolume + Delta); end;
procedure TRadioController.SetTXMonVolume(V: Integer);
// Адресно правит громкость self-monitor'а. Слайдер добирается до неё только на
// передаче (SetVolume через ActiveVolume), CAT-клиенту такой контекст не нужен.
begin
FTxMonVolume := EnsureRange(V, 0, 100);
if MonitoringTX then ApplyActiveVolume;
Changed(rfMonVolume);
end;
procedure TRadioController.SetMute(On_: Boolean);
begin
FMuted := On_;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetMute(FMuted);
Changed(rfMute);
end;
procedure TRadioController.ToggleMute;
begin SetMute(not FMuted); end;
procedure TRadioController.SetDrive(V: Integer);
begin
if V < 0 then V := 0 else if V > 100 then V := 100;
FDrivePercent := V;
// Мощность входит в профиль: ведём её в активном без записи на диск (слайдер
// шлёт события пачками; на диск профили уходят на переключении/остановке).
if (FTXProfiles.ActiveIdx >= 0) and (FTXProfiles.ActiveIdx < FTXProfiles.Count) then
FTXProfiles.Items[FTXProfiles.ActiveIdx].DrivePercent := FDrivePercent;
FDriveLevel := CalcDriveByte;
ApplyWDSPDrive(FDrivePercent);
ApplyPlutoTxAtten; // Pluto: drive живьём меняет TX-аттенюацию во время передачи
PushNetworkState; // протолкнуть новый drive в радио (если подключено+Running)
Changed(rfDrive);
end;
procedure TRadioController.DriveBy(Delta: Integer);
begin SetDrive(FDrivePercent + Delta); end;
function TRadioController.PlutoTxAttForDrive: Double;
const
TX_OFF = -89.75; // макс. аттенюация AD9361 (0% → практически выкл)
var
Pct: Integer;
begin
// На тюне (TUN) уровень берём из TX-настроек (TUNLevel), иначе из drive%.
if FTuning then Pct := FTXSettings.TUNLevel else Pct := FDrivePercent;
if Pct < 0 then Pct := 0 else if Pct > 100 then Pct := 100;
if Pct <= 0 then begin Result := TX_OFF; Exit; end; // 0% → TX практически выкл
// power-linear: мощность ∝ drive%. dB = потолок + 20·log10(pct/100); 100% → потолок,
// 50% → 6 дБ, 10% → −20 дБ. (Раньше dB-линейно от −89.75 — низкие % улетали в −80 дБ.)
Result := FPlutoTxMaxAttDb + 20.0 * Log10(Pct / 100.0);
if Result > FPlutoTxMaxAttDb then Result := FPlutoTxMaxAttDb;
if Result < TX_OFF then Result := TX_OFF;
end;
procedure TRadioController.ApplyPlutoTxAtten;
const
TX_OFF = -89.75;
begin
if (FNetwork = nil) or not IsPluto then Exit;
// На передаче (MOX/TUN) — мощность от drive; на приёме — макс. аттенюация (выкл).
// ★В телеграфе FTransmitting не поднимается вовсе (передачей владеет кейер),
// поэтому спрашиваем и сессию локального генератора — иначе манипуляция шла бы
// в закрытый аттенюатор, то есть в никуда.
if FTransmitting or FTuning or FCWLocalTX then
FNetwork.SetTxAtten(PlutoTxAttForDrive)
else
FNetwork.SetTxAtten(TX_OFF);
end;
procedure TRadioController.SetPlutoTxMaxAtt(Db: Double);
begin
if Db > 0 then Db := 0 else if Db < -50 then Db := -50;
FPlutoTxMaxAttDb := Db;
ApplyPlutoTxAtten; // применить живьём, если сейчас передаём
SaveGlobalSettings; // персист потолка (per-device)
end;
procedure TRadioController.SetCalibration(const C: TCalibration);
begin
FCal := C;
// Санитизация: битый конфиг не должен дать деление на ноль/абсурдные ватты.
FCal.PwrDetModel := EnsureRange(FCal.PwrDetModel, 0, 3);
if FCal.AmpSensMVA <= 0 then FCal.AmpSensMVA := 120.0;
FCal.FreqCalPPM := EnsureRange(FCal.FreqCalPPM, -100.0, 100.0);
// Опорник: у HPSDR правим частотное слово, у AD936x — xo_correction.
// Ручные LOError/TO Error трансвертеров остаются сверху.
if Assigned(FNetwork) then FNetwork.SetFreqCalPPM(FCal.FreqCalPPM);
// Офсет S-метра берётся из FCal на каждом чтении (SMeterCalFor) — применять
// отдельно нечего: он зависит от диапазона/трансвертера, а не от движка.
SaveGlobalSettings; // персист (гейт по FDevConnected внутри)
end;
procedure TRadioController.ApplyWDSPDrive(Pct: Integer);
// На Pluto мощность задаётся ОДИН раз — железной аттенюацией (PlutoTxAttForDrive),
// поэтому цифровой уровень WDSP держим на максимуме (1.0), а не режем по drive%.
// HPSDR — как раньше, Pct/100 (реальная мощность там — drive-байт в радио).
begin
if not (FWDSPReady and Assigned(FDSPEngine)) then Exit;
if IsPluto then FDSPEngine.SetDriveLevel(1.0)
else FDSPEngine.SetDriveLevel(EnsureRange(Pct, 0, 100) / 100.0);
end;
procedure TRadioController.SetAtten(Db: Integer);
// Шаговый аттенюатор ADC — плавный 0..31 дБ (как в железе openHPSDR), а не
// три фиксированные ступени. Уровни S-метра/спектра компенсируются на ту же
// величину (CalLookup), поэтому шкала dBm остаётся абсолютной.
begin
FAtten := EnsureRange(Db, 0, 31);
if Assigned(FNetwork) then FNetwork.SetStepAtten(Byte(FAtten));
Changed(rfAtten);
// Персист — вместе с диапазоном/слотом (SaveCurrentBand на выходе из них),
// писать JSON на каждый шаг ползунка незачем, как и у drive.
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then
FXvtrSettings.Entries[FCurrentXvtr].LastAttenDB := FAtten
else if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then
FBandCache[FCurrentBand].AttenDB := FAtten;
end;
procedure TRadioController.SetBand(Idx: Integer);
// Смена HF-диапазона по индексу (web/CAT/BandUp-Down). Выход из XVTR (если активен),
// персист покидаемого, restore нового. Полная команда — не трогает UI.
begin
if (Idx < 0) or (Idx >= CFG_BAND_COUNT) then Exit;
// Пустой слот плана (HF: 12-й, зарезервирован под 12-местный план Pluto) —
// не диапазон, игнорируем (защита CAT/web/BandUp).
if BandName(Idx, IsPluto) = '' then Exit;
if FCurrentXvtr >= 0 then
begin
DeactivateXvtr; // выход в HF (HF-возврат внутри)
if Idx = FCurrentBand then Exit;
end
else if Idx = FCurrentBand then Exit;
if FDevConnected then begin SaveCurrentBand; FSettings.Save; end;
FCurrentBand := Idx;
RestoreBand(Idx);
end;
procedure TRadioController.BandUp;
begin if FCurrentBand < CFG_BAND_COUNT - 1 then SetBand(FCurrentBand + 1); end;
procedure TRadioController.BandDown;
begin if FCurrentBand > 0 then SetBand(FCurrentBand - 1); end;
procedure TRadioController.ApplyXvtrToNetwork;
// Передаёт XVTR-режим в сеть (enable bit + T/R relay suppression + RX ant).
var
En, DisablePA: Boolean;
RxAnt: Byte;
begin
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
FXvtrSettings.Entries[FCurrentXvtr].Enabled then
begin
En := True;
DisablePA := FXvtrSettings.Entries[FCurrentXvtr].DisablePA;
RxAnt := FXvtrSettings.Entries[FCurrentXvtr].RXAntenna;
end
else
begin
En := False; DisablePA := False; RxAnt := 0;
end;
if Assigned(FNetwork) and FNetwork.Connected then
if En then FNetwork.SetXvtrMode(En, DisablePA, RxAnt, FCurrentXvtr)
else FNetwork.SetXvtrMode(En, DisablePA, RxAnt, -1);
end;
procedure TRadioController.ActivateXvtr(Idx: Integer);
// Вход в XVTR-слот: state+DSP через общий ApplyBandDSP + VFO/центр/сеть.
// Рендер: rfXvtr (XVTR-кнопки), rfBand (XVTR-aware гасит HF), rfActiveVfo
// (дисплеи A/B + полный перерисов спектра). UI-обёртка добавляет wideband+web.
var
E: TXvtrEntry;
B: TBandSettings;
Vis: Double;
begin
if (Idx < 0) or (Idx >= CFG_XVTR_COUNT) then Exit;
if not FXvtrSettings.Entries[Idx].Enabled then Exit;
FCurrentXvtr := Idx;
E := FXvtrSettings.Entries[Idx];
// Начальная частота: LastFreq если в диапазоне, иначе середина.
Vis := E.LastFreq;
if (Vis < E.FreqBegin) or (Vis > E.FreqEnd) then
Vis := (E.FreqBegin + E.FreqEnd) / 2.0;
ApplyXvtrToNetwork; // XVTR enable bit + DisablePA + RX ant
// Авто-флаги канала (не входят в TBandSettings/ApplyBandDSP).
FFMCTCSSAutoActive := E.LastCTCSSAutoActive;
FFMRptAutoActive := E.LastFMRptAutoActive;
// DSP-настройки слота — через общий путь Band (mode/filter/AGC/CTUN/FM + рендер).
B.Mode := E.LastMode;
B.FilterIdx := E.LastFilterIdx;
B.FilterBW := FilterBWFor(E.LastMode, E.LastFilterIdx);
B.AGCMode := E.LastAGCMode;
B.AGCTop := E.LastAGCTop;
B.CTun := E.LastCTun;
B.SpanHz := FSampleRate;
B.FMSQOn := E.LastFMSQOn;
B.FMSQLevel := E.LastFMSQLevel;
B.CTCSSOn := E.LastCTCSSOn;
B.CTCSSToneIdx := E.LastCTCSSToneIdx;
B.FMStepOn := E.LastFMStepOn;
B.FMStepIdx := E.LastFMStepIdx;
B.FMRptDir := E.LastFMRptDir;
B.FMRptOffsetHz := E.LastFMRptOffsetHz;
B.RxGainMode := E.LastRxGainMode; // hw-AGC слота (AD936x)
B.RxGainDb := E.LastRxGainDb;
B.AttenDB := E.LastAttenDB; // аттенюатор ADC слота
B.VfoA := Vis;
B.VfoB := FVfoB;
ApplyBandDSP(B);
// Zoom/pan слота: восстанавливаем и переармируем анализатор.
FZoomFactor := EnsureRange(E.LastZoomFactor, 0.0, 1.0);
FPanSlider := EnsureRange(E.LastPanSlider, 0.0, 1.0);
if Assigned(FDSPEngine) then FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
Changed(rfZoom);
// VFO: B — своя сохранённая частота слота (клэмп в диапазон); A = visible.
FVfoB := EnsureRange(E.LastFreqB, E.FreqBegin, E.FreqEnd);
FVfoA := Vis;
// При CTun восстанавливаем сохранённый DDC-центр, чтобы VFO остался на своём
// месте в полосе (а не прыгал в центр). Берём только если offset влезает в
// текущий span — иначе центрируемся на видимой частоте.
if FCTun and (E.LastCenterHz > 0) and (Abs(Vis - E.LastCenterHz) < FSpanHz / 2) then
begin
FCenterFreq := E.LastCenterHz;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(Vis - FCenterFreq);
end
else
begin
FCenterFreq := Vis;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
end;
FDriveLevel := CalcDriveByte;
// QO-100 / split-LO: авто display-duplex (мониторинг своего downlink при TX).
if E.FullDuplexTx and not FDisplayDuplex then SetDuplex(True);
PushNetworkState;
RefreshPanDDCs; // живые паны — в диапазон XVTR, DDC — на IF
ApplyAntPorts; // AD936x: разъёмы, выбранные для этого трансвертера
Changed(rfXvtr);
Changed(rfActiveVfo);
end;
procedure TRadioController.DeactivateXvtr;
// Сохраняет текущий XVTR-слот и выходит в HF: HF-возврат (RestoreBand + финальная
// перестройка активного VFO + ResetWfAvgBuf через rfBandRestore) делается здесь.
// Changed(rfXvtr) шлём ПОСЛЕ RestoreBand — чтобы wideband/web-extras в его рендере
// увидели уже восстановленное HF-состояние.
begin
if FCurrentXvtr < 0 then Exit;
SaveCurrentBand; // XVTR-ветка: пишет весь слот + SaveXvtr
if FDevConnected then FSettings.Save;
FCurrentXvtr := -1;
ApplyXvtrToNetwork; // снять XVTR enable bit
RefreshPanDDCs; // живые паны — обратно в пределы железа
if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then
RestoreBand(FCurrentBand); // HF-возврат (состояние/DSP/retune)
Changed(rfXvtr); // погасить XVTR-кнопки + wideband/web (rfXvtr-рендер)
end;
procedure TRadioController.SetXvtrBand(Idx: Integer);
// Единая точка для GUI/Web/CAT: Idx>=0 — войти в слот, Idx<0 — выйти в HF.
// ВНИМАНИЕ: HF-возврат при выходе делает UI-обёртка DeactivateXvtr (RestoreBand).
begin
if Idx >= 0 then ActivateXvtr(Idx)
else DeactivateXvtr;
end;
procedure TRadioController.SetCurrentBandIdx(NewBand: Integer);
// Лёгкая смена HF-диапазона без полного restore: фиксируем индекс диапазона
// (для drive-кал и подсветки кнопок) и пересчитываем drive-байт. Используется
// канальным адаптером при переходе канала на другой HF-диапазон.
begin
FCurrentBand := NewBand;
FDriveLevel := CalcDriveByte;
Changed(rfBand);
end;
procedure TRadioController.SetSpan(Hz: Integer);
begin FSpanHz := Hz; Changed(rfSpan); { TODO wiring } end;
// ════════════════════════════════════════════════════════════════════════════
// Zoom / pan спектра и водопада
// ════════════════════════════════════════════════════════════════════════════
function TRadioController.ZoomFactor: Double;
begin Result := FZoomFactor; end;
function TRadioController.PanSlider: Double;
begin Result := FPanSlider; end;
procedure ViewWindowFor(BaseC, BaseS, AZoom, APan: Double;
out ACenter, ASpan: Double);
// Видимое окно (центр+span, Гц) с учётом зума. Формула совпадает с расчётом
// span-clip в WDSPEngine.Apply{RX,Pan}AnalyzerSettings (bw=BaseS, clp=0),
// поэтому оверлеи точно ложатся на пиксели. Без зума → базовые центр/span.
var
FVis, OneMinusFVis, VisSpan, LowEdgeOff, CenterOff: Double;
begin
if (AZoom <= 0.0) or (BaseS <= 0.0) then
begin
ACenter := BaseC;
ASpan := BaseS;
Exit;
end;
OneMinusFVis := 0.99 * Log10(9.0 * EnsureRange(AZoom, 0.0, 1.0) + 1.0);
FVis := 1.0 - OneMinusFVis; // доля span после зума
if FVis < 1.0 / 100.0 then FVis := 1.0 / 100.0; // ZOOM_LIMIT
VisSpan := FVis * BaseS;
LowEdgeOff := -BaseS / 2.0 + EnsureRange(APan, 0.0, 1.0) * OneMinusFVis * BaseS;
CenterOff := LowEdgeOff + VisSpan / 2.0;
ACenter := BaseC + CenterOff;
ASpan := VisSpan;
end;
procedure TRadioController.GetViewWindow(out ACenter: Double; out ASpan: Double);
begin
ViewWindowFor(FCenterFreq, FSpanHz, FZoomFactor, FPanSlider, ACenter, ASpan);
end;
function TRadioController.AnchoredPan(AZoom: Double): Double;
// Положение пана, при котором видимое окно центрировано на маркере активного
// VFO. Клампится в [0..1], чтобы окно не вылезало за span (у краёв маркер
// перестаёт быть строго по центру — окно упирается в край).
var OneMinusFVis, U: Double;
begin
if (AZoom <= 0.0) or (FSpanHz <= 0.0) then Exit(0.5);
OneMinusFVis := 0.99 * Log10(9.0 * EnsureRange(AZoom, 0.0, 1.0) + 1.0);
if OneMinusFVis <= 1e-9 then Exit(0.5);
U := (ActiveVfoHz - FCenterFreq) / FSpanHz; // нормализованный сдвиг маркера
Result := EnsureRange(0.5 + U / OneMinusFVis, 0.0, 1.0);
end;
procedure TRadioController.ApplyZoom(AZoom, APan: Double);
begin
FZoomFactor := EnsureRange(AZoom, 0.0, 1.0);
FPanSlider := EnsureRange(APan, 0.0, 1.0);
if Assigned(FDSPEngine) then
FDSPEngine.SetZoomPan(FZoomFactor, FPanSlider);
// Кэшируем в активный слот (XVTR-слот при активном трансверторе, иначе HF-band);
// персист на диск — в SaveCurrentBand при смене/выходе.
if FCurrentXvtr >= 0 then
begin
if FCurrentXvtr < CFG_XVTR_COUNT then
begin
FXvtrSettings.Entries[FCurrentXvtr].LastZoomFactor := FZoomFactor;
FXvtrSettings.Entries[FCurrentXvtr].LastPanSlider := FPanSlider;
end;
end
else if (FCurrentBand >= 0) and (FCurrentBand < CFG_BAND_COUNT) then
begin
FBandCache[FCurrentBand].ZoomFactor := FZoomFactor;
FBandCache[FCurrentBand].PanSlider := FPanSlider;
end;
Changed(rfZoom);
end;
// Лестница кратностей увеличения: мелкий шаг в начале (1.0→1.2→1.45…), крупнее
// к концу. Кнопки [+]/[-] шагают по ней; ZoomFactor (0..1) хранит соответствующий
// z. Перевод кратность↔z — инверсия формулы анализатора (см. ApplyRXAnalyzerSettings).
const
ZOOM_MAG_LADDER: array[0..16] of Double =
(1.0, 1.1, 1.15, 1.2, 1.45, 1.75, 2.1, 2.5, 3.0, 3.7, 4.7, 6.2, 8.5, 12.0, 20.0, 40.0, 100.0);
function ZoomMagToZ(M: Double): Double;
begin
if M <= 1.0 then Exit(0.0);
// M = 1/(1-0.99*log10(9z+1)) ⇒ z = (10^((1-1/M)/0.99) - 1)/9
Result := EnsureRange((Power(10.0, (1.0 - 1.0 / M) / 0.99) - 1.0) / 9.0, 0.0, 1.0);
end;
function ZoomZToMag(Z: Double): Double;
begin
Result := 1.0 / (1.0 - 0.99 * Log10(9.0 * EnsureRange(Z, 0.0, 1.0) + 1.0));
end;
procedure TRadioController.ZoomIn;
var CurMag, Z: Double; i: Integer;
begin
CurMag := ZoomZToMag(FZoomFactor);
for i := 0 to High(ZOOM_MAG_LADDER) do
if ZOOM_MAG_LADDER[i] > CurMag * 1.001 then
begin
Z := ZoomMagToZ(ZOOM_MAG_LADDER[i]);
ApplyZoom(Z, AnchoredPan(Z));
Exit;
end;
end;
procedure TRadioController.ZoomOut;
var CurMag, Z: Double; i: Integer;
begin
CurMag := ZoomZToMag(FZoomFactor);
for i := High(ZOOM_MAG_LADDER) downto 0 do
if ZOOM_MAG_LADDER[i] < CurMag / 1.001 then
begin
if ZOOM_MAG_LADDER[i] <= 1.0 then ApplyZoom(0.0, 0.5)
else
begin
Z := ZoomMagToZ(ZOOM_MAG_LADDER[i]);
ApplyZoom(Z, AnchoredPan(Z));
end;
Exit;
end;
ApplyZoom(0.0, 0.5);
end;
procedure TRadioController.ZoomDefault;
begin
ApplyZoom(0.0, 0.5);
end;
{ ---- Зум/пан доп. панов (PanId=0 делегирует главному) ---- }
procedure TRadioController.GetPanViewWindow(PanId: Integer;
out ACenter, ASpan: Double);
begin
if PanId = 0 then
begin
GetViewWindow(ACenter, ASpan);
Exit;
end;
ACenter := 0; ASpan := 0;
if not PanDDCActive(PanId) then Exit;
ViewWindowFor(FPanFreqHz[PanId], FPanRateKHz[PanId] * 1000.0,
FPanZoomF[PanId], FPanPanSl[PanId], ACenter, ASpan);
end;
procedure TRadioController.SetPanZoomPan(PanId: Integer; AZoom, APan: Double);
// Зум пана — состояние сессии пана (не персистится с бандом, в отличие от
// главного ApplyZoom): пан живёт до STOP.
begin
if PanId = 0 then
begin
ApplyZoom(AZoom, APan);
Exit;
end;
if not PanDDCActive(PanId) then Exit;
FPanZoomF[PanId] := EnsureRange(AZoom, 0.0, 1.0);
FPanPanSl[PanId] := EnsureRange(APan, 0.0, 1.0);
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetPanZoomPan(PanId, FPanZoomF[PanId], FPanPanSl[PanId]);
end;
procedure TRadioController.PanZoomStepTo(PanId: Integer; NewZ: Double);
// Ставит новый зум, удерживая ЦЕНТР текущего видимого окна (у пана нет VFO-якоря
// главного AnchoredPan; фиксация центра — естественный аналог).
var
C, S, BaseC, BaseS, U, OneMinus, FVis, NewPan: Double;
begin
if not PanDDCActive(PanId) then Exit;
GetPanViewWindow(PanId, C, S);
BaseC := FPanFreqHz[PanId];
BaseS := FPanRateKHz[PanId] * 1000.0;
if BaseS <= 0 then Exit;
NewZ := EnsureRange(NewZ, 0.0, 1.0);
// Инверсия ViewWindowFor: U = CenterOff/BaseS = 0.5 + Pan·OneMinus + FVis/2.
U := (C - BaseC) / BaseS;
OneMinus := 0.99 * Log10(9.0 * NewZ + 1.0);
if OneMinus <= 1e-9 then
NewPan := 0.5
else
begin
FVis := 1.0 - OneMinus;
if FVis < 1.0 / 100.0 then FVis := 1.0 / 100.0;
NewPan := EnsureRange((U + 0.5 - FVis / 2.0) / OneMinus, 0.0, 1.0);
end;
SetPanZoomPan(PanId, NewZ, NewPan);
end;
procedure TRadioController.PanZoomIn(PanId: Integer);
var CurMag: Double; i: Integer;
begin
if not PanDDCActive(PanId) then Exit;
CurMag := ZoomZToMag(FPanZoomF[PanId]);
for i := 0 to High(ZOOM_MAG_LADDER) do
if ZOOM_MAG_LADDER[i] > CurMag * 1.001 then
begin
PanZoomStepTo(PanId, ZoomMagToZ(ZOOM_MAG_LADDER[i]));
Exit;
end;
end;
procedure TRadioController.PanZoomOut(PanId: Integer);
var CurMag: Double; i: Integer;
begin
if not PanDDCActive(PanId) then Exit;
CurMag := ZoomZToMag(FPanZoomF[PanId]);
for i := High(ZOOM_MAG_LADDER) downto 0 do
if ZOOM_MAG_LADDER[i] < CurMag / 1.001 then
begin
if ZOOM_MAG_LADDER[i] <= 1.0 then SetPanZoomPan(PanId, 0.0, 0.5)
else PanZoomStepTo(PanId, ZoomMagToZ(ZOOM_MAG_LADDER[i]));
Exit;
end;
SetPanZoomPan(PanId, 0.0, 0.5);
end;
procedure TRadioController.PanZoomDefault(PanId: Integer);
begin
SetPanZoomPan(PanId, 0.0, 0.5);
end;
function TRadioController.PanZoomFactor(PanId: Integer): Double;
begin
if PanId = 0 then Result := FZoomFactor
else if PanDDCActive(PanId) then Result := FPanZoomF[PanId]
else Result := 0.0;
end;
function TRadioController.PanPanSlider(PanId: Integer): Double;
begin
if PanId = 0 then Result := FPanSlider
else if PanDDCActive(PanId) then Result := FPanPanSl[PanId]
else Result := 0.5;
end;
procedure TRadioController.SetSampleRate(Hz: Integer);
// Смена частоты дискретизации DDC. В этом приложении span ≡ sample rate.
// Несёт: пересоздание WDSP-канала (Close+FlushQueue+Open), новый rate в
// трансивер и полное восстановление DSP-состояния. Команда сама персистит
// (sample rate глобальный); рендер overlay/AGC-линий — в OnControllerState.
begin
if (Hz <= 0) or (Hz = FSampleRate) then Exit;
FSampleRate := Hz;
FSpanHz := Hz;
FBandCache[FCurrentBand].SpanHz := FSpanHz; // для обратной совместимости
// 1. Меняем rate главного тракта. Хирургически: движок гейтит и дропает
// только main-пакеты, DSP-поток/анализаторы/паны N живут без пауз —
// поэтому FWDSPReady НЕ сбрасываем (иначе OnDDCIQ перестал бы кормить
// паны на всё время пересборки и их водопады замирали).
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.ChangeSampleRate(Hz);
// 2. Новый rate трансиверу. Короткий хвост пакетов старого rate, дошедших
// до переключения радио, попадает в новый канал — транзиент в единицы мс
// (то же окно было и при старой схеме: FWDSPReady поднимался до того, как
// радио реально применяло DDC Specific).
if Assigned(FNetwork) and FNetwork.Connected then
FNetwork.ConfigureDDCs(1, Hz div 1000, FMainADCSrc,
FDitherEnabled, FRandomEnabled);
// 3. Восстанавливаем DSP-состояние (ChangeSampleRate пересоздал канал).
if Assigned(FDSPEngine) then
begin
FWDSPReady := FDSPEngine.Initialized;
if FWDSPReady then
begin
FDSPEngine.SetMode(FMode);
FDSPEngine.SetVolume(FVolume / 100.0);
ApplyModeFilter;
FDSPEngine.SetNRMode(FNRMode);
FDSPEngine.SetNBMode(FNBMode);
FDSPEngine.SetSNB(FSNB);
FDSPEngine.SetANF(FANF);
FDSPEngine.SetAGCTop(FAGCTop);
ApplyAGCToEngine; // единый маппинг (раньше здесь был ошибочный каст индекса)
if Assigned(FNetwork) and FNetwork.Connected then
FDSPEngine.SetShift(FVfoA - FCenterFreq);
end;
end;
SaveGlobalSettings; // sample rate глобальный — персистим (гейт по FDevConnected внутри)
Changed(rfSampleRate);
end;
procedure TRadioController.SetCTun(On_: Boolean);
begin
FCTun := On_;
if XvtrSlotActive then FXvtrSettings.Entries[FCurrentXvtr].LastCTun := FCTun
else FBandCache[FCurrentBand].CTun := FCTun;
if not FCTun then
begin
// Выключили CTUN: центрируемся на АКТИВНОМ VFO, shift=0. (Включение CTUN не
// трогает движок — FCenterFreq остаётся как есть, спектр не прыгает.)
FCenterFreq := ActiveVfoHz;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
PushNetworkState;
end;
Changed(rfCTun);
end;
procedure TRadioController.SetDuplex(On_: Boolean);
begin
FDisplayDuplex := On_;
// Применяем сразу: при передаче держим RX-IQ в DSP-цепи (full-duplex монитор —
// для QO-100 слышим свой downlink через спутник). SetMOX тоже это выставляет,
// но здесь — чтобы тумблер DUP действовал и в момент передачи.
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.KeepRXDuringTX := FDisplayDuplex;
ApplyActiveVolume; // смена DUP на передаче может включить/выключить TX-монитор
Changed(rfDuplex);
end;
procedure TRadioController.ApplyRxMuteToEngine;
begin
// Монитор имеет смысл только в full-duplex (KeepRXDuringTX); сам гейт в движке
// это учитывает. Здесь просто транслируем инверсию флага RX MUTE.
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.MonitorRXDuringTX := not FRxMuteOnTx;
end;
procedure TRadioController.SetRxMuteOnTx(On_: Boolean);
begin
FRxMuteOnTx := On_;
ApplyRxMuteToEngine;
ApplyActiveVolume; // на передаче тумблер монитора меняет активную громкость
Changed(rfRxMuteOnTx);
end;
procedure TRadioController.SetWfAGC(On_: Boolean);
begin FWfAGCEnabled := On_; Changed(rfWfAGC); end;
procedure TRadioController.SetWfNF(On_: Boolean);
begin FWfNFEnabled := On_; Changed(rfWfNF); end;
procedure TRadioController.SetNR(Mode: Integer);
begin
if Mode < 0 then Mode := 0 else if Mode > 4 then Mode := 4;
FNRMode := Mode;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetNRMode(FNRMode);
Changed(rfNR);
end;
procedure TRadioController.SetNB(Mode: Integer);
begin
if Mode < 0 then Mode := 0 else if Mode > 2 then Mode := 2;
FNBMode := Mode;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetNBMode(FNBMode);
Changed(rfNB);
end;
procedure TRadioController.SetSNB(On_: Boolean);
begin
FSNB := On_;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetSNB(FSNB);
Changed(rfSNB);
end;
procedure TRadioController.SetANF(On_: Boolean);
begin
FANF := On_;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetANF(FANF);
Changed(rfANF);
end;
function TRadioController.DefaultMicSource: TTXMicSource;
// Источник модуляции решает ТЕКУЩИЙ TX-слайс: у него может быть свой вход
// (виртуальный кабель), даже когда общего входа нет, и наоборот.
begin
if ActiveMicInDevName <> '' then Result := txmsSoundCard
else Result := txmsRadio;
end;
function TRadioController.BuildMicLineSelectByte: Byte;
// openHPSDR Ethernet Protocol v4.3, DUC Specific byte 50:
// bit 0 — Line In (1 = line-in вместо mic-in)
// bit 1 — Mic Boost (+20 dB); не ставим когда Line In
// bit 2 — 0 = Orion mic PTT enabled, 1 = disabled (инверсия!)
// bit 3 — Tip/Ring (0=Tip, 1=Ring; Orion/OrionMkII)
// bit 4 — Orion mic bias (1 = enable +5V bias)
// bit 5 — Saturn XLR/balanced (не используем)
begin
Result := 0;
if FTXSettings.MicLineIn then
Result := Result or $01 // bit 0: Line In
else if FTXSettings.MicBoost then
Result := Result or $02; // bit 1: Boost только при Mic In
if not FTXSettings.MicPTTEnabled then Result := Result or $04; // bit 2: инверсия
if FTXSettings.MicTipRing then Result := Result or $08; // bit 3: Ring
if FTXSettings.MicBias then Result := Result or $10; // bit 4: bias
end;
procedure TRadioController.ApplyNoiseFilterButtonsToDSP;
begin
if not FWDSPReady then Exit;
FDSPEngine.SetNRMode(FNRMode);
FDSPEngine.SetNBMode(FNBMode);
FDSPEngine.SetSNB(FSNB);
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
// Assigned обязателен: сюда приходят и по PTT/TUN, а нажать их можно и до
// того, как устройство выбрано, — тогда FNetwork ещё nil (то же и с движком
// ниже). Раньше на этом месте была Access violation, и разбирать её приходил
// уже кто-то другой: SetMOX зовёт SyncCWKeyer, а тот — эту процедуру.
if not (Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running) then Exit;
if not (FWDSPReady and Assigned(FDSPEngine)) then Exit;
FillChar(DUCPkt, SizeOf(DUCPkt), 0);
// DUC0 rate в ksps берём из WDSP-движка (фиксированный TX rate, не RX).
RateKsps := FDSPEngine.TXSampleRate div 1000;
DUCPkt.NumDACs := 1;
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;
DUCPkt.MicLineSelect := BuildMicLineSelectByte;
// Line In Gain (byte 51): 0=-34.5dB .. 31=+12dB (шаг 1.5 dB, формула Thetis).
// При Mic In значение игнорируется железом, но шлём корректное для Line In.
DUCPkt.LineInGain := Byte(EnsureRange(Round((FTXSettings.LineInGainDB + 34.5) / 1.5), 0, 31));
// ATT on TX — RX-ADC step attenuator во время передачи (Thetis-style protection).
// Дублируем значение во все три ADC-байта: на однопотоковых платах активен только
// ADC0, на Saturn/Hermes Lite 2 — могут использоваться ADC1/ADC2.
DUCPkt.StepAtten0 := Byte(EnsureRange(FTXSettings.AttOnTX, 0, 31));
DUCPkt.StepAtten1 := DUCPkt.StepAtten0;
DUCPkt.StepAtten2 := DUCPkt.StepAtten0;
FLastCWOptions := DUCPkt.CWOptions;
FNetwork.SendDUCSpecific(DUCPkt);
end;
// ---------------------------------------------------------------------------
// TX-профили
// ---------------------------------------------------------------------------
function TRadioController.TXProfileCount: Integer;
begin
Result := FTXProfiles.Count;
end;
function TRadioController.TXProfileName(Idx: Integer): string;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Result := ''
else Result := FTXProfiles.Items[Idx].Name;
end;
function TRadioController.ActiveTXProfile: Integer;
begin
Result := FTXProfiles.ActiveIdx;
end;
procedure TRadioController.SaveTXProfilesToConfig;
begin
if (not FDevConnected) or (not Assigned(FSettings)) then Exit;
FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
FSettings.Save;
end;
procedure TRadioController.SetTXSettings(const T: TTXSettings; Notify: Boolean);
// Одна дверь для правки TX-настроек — по образцу SetCWSettings. Клиенту
// (вкладка Transmit, CAT, web) достаточно отдать запись целиком: применение к
// WDSP, перепосыл DUC Specific при смене mic-битов/ATT, живое обновление TUN,
// запись в активный профиль и персист — здесь.
var
OldMicByte: Byte;
OldAtt, OldTUNLevel, OldTUNFreq: Integer;
begin
OldMicByte := BuildMicLineSelectByte;
OldAtt := FTXSettings.AttOnTX;
OldTUNLevel := FTXSettings.TUNLevel;
OldTUNFreq := FTXSettings.TUNFreq;
FTXSettings := T;
// TX-сетка спектра — рабочие поля рендера; их перерисовку заказывает вызывающий.
FTXSpecRefLevel := FTXSettings.TXSpecRefLevel;
FTXSpecRange := FTXSettings.TXSpecRange;
if FTXSettings.TXSpecGridStep > 0 then FTXSpecGridStep := FTXSettings.TXSpecGridStep;
ApplyTXSettingsToDSP;
// Маршрутизация микрофона и ATT on TX живут в железе (DUC Specific), WDSP тут
// ни при чём — пакет перепосылаем только когда байты реально поменялись.
if (BuildMicLineSelectByte <> OldMicByte) or (OldAtt <> FTXSettings.AttOnTX) then
SendDUCSpecificFromSettings;
// TUN-параметры live: идёт настройка — обновить тон/уровень, иначе просто запомнить.
if FTuning then
begin
if (OldTUNFreq <> FTXSettings.TUNFreq) and FWDSPReady then
FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, 1.0);
if OldTUNLevel <> FTXSettings.TUNLevel then
begin
FDriveLevel := CalcDriveByte;
if FWDSPReady then
FDSPEngine.SetDriveLevel(EnsureRange(FTXSettings.TUNLevel, 0, 100) / 100.0);
if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, FDriveLevel,
FTransmitting, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP;
end;
end;
end;
// Правка «звуковых» полей — это правка активного профиля: отдельной кнопки
// «сохранить» нет, профиль ведётся за живым состоянием (см. TTXProfile).
StoreActiveTXProfile;
if FDevConnected and Assigned(FSettings) then
begin
FSettings.SaveTX(FDevMAC, FTXSettings);
FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
FSettings.Save;
end;
if Notify then Changed(rfTXProfile);
end;
procedure TRadioController.StoreActiveTXProfile(Persist: Boolean = False);
// Живое состояние → активный профиль. Зовётся на каждой правке TX-настроек и
// на смене drive; Persist=False не трогает диск (drive ездит слайдером).
var Idx: Integer;
begin
Idx := FTXProfiles.ActiveIdx;
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
TXProfileCapture(FTXSettings, FDrivePercent, FTXProfiles.Items[Idx]);
if Persist then SaveTXProfilesToConfig;
end;
procedure TRadioController.SelectTXProfile(Idx: Integer; Auto: Boolean);
var
OldMic: Byte;
NewDrive: Integer;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
// Уходящий профиль дописываем живыми значениями — ручные правки не теряются.
StoreActiveTXProfile;
OldMic := BuildMicLineSelectByte;
NewDrive := FDrivePercent;
FTXProfiles.ActiveIdx := Idx;
TXProfileApply(FTXProfiles.Items[Idx], FTXSettings, NewDrive);
ApplyTXSettingsToDSP;
// Маршрутизация микрофона живёт в железе (DUC Specific byte 50) — при смене
// Mic In/Line In/boost/bias пакет надо перепослать, WDSP тут ни при чём.
if BuildMicLineSelectByte <> OldMic then SendDUCSpecificFromSettings;
// SetDrive пересчитает калиброванный байт, толкнёт WDSP/Pluto и HP-кадр;
// зовём безусловно — при активном TUN уровень берётся из TUNLevel профиля.
SetDrive(NewDrive);
if FTuning and FWDSPReady then
FDSPEngine.SetDriveLevel(EnsureRange(FTXSettings.TUNLevel, 0, 100) / 100.0);
// ★Ручной выбор ЗАПОМИНАЕТСЯ за парой «источник передачи + его модуляция» —
// это и есть обещанное «профиль помнится по виду модуляции». Без этого в
// таблице оказывалось только то, что назначено пилюлей во флаге, а профиль,
// выбранный дропдауном в блоке TX, нигде не оставался следа.
if not Auto then BindTXProfileToCurrentMode(Idx);
if FDevConnected and Assigned(FSettings) then
begin
FSettings.SaveTX(FDevMAC, FTXSettings);
FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
FSettings.Save;
end;
Changed(rfTXProfile);
end;
function TRadioController.AddTXProfile(const AName: string): Integer;
// Форк: новый профиль = текущий звук под новым именем, он же становится активным.
begin
Result := -1;
if FTXProfiles.Count >= TXPROF_MAX then Exit;
Result := FTXProfiles.Count;
TXProfileCapture(FTXSettings, FDrivePercent, FTXProfiles.Items[Result]);
FTXProfiles.Items[Result].Name := Copy(Trim(AName), 1, 31);
if FTXProfiles.Items[Result].Name = '' then
FTXProfiles.Items[Result].Name := 'Profile ' + IntToStr(Result + 1);
Inc(FTXProfiles.Count);
FTXProfiles.ActiveIdx := Result;
SaveTXProfilesToConfig;
Changed(rfTXProfile);
end;
procedure TRadioController.RenameTXProfile(Idx: Integer; const AName: string);
var S: string;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
S := Copy(Trim(AName), 1, 31);
if S = '' then Exit;
FTXProfiles.Items[Idx].Name := S;
SaveTXProfilesToConfig;
Changed(rfTXProfile);
end;
procedure TRadioController.DeleteTXProfile(Idx: Integer);
// Последний профиль не удаляем: список без активного элемента сломал бы
// авто-сохранение (StoreActiveTXProfile писать некуда).
var i: Integer;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) or (FTXProfiles.Count <= 1) then Exit;
for i := Idx to FTXProfiles.Count - 2 do
FTXProfiles.Items[i] := FTXProfiles.Items[i + 1];
Dec(FTXProfiles.Count);
// Привязки флагов — это ИНДЕКСЫ: после сдвига списка их надо переписать,
// иначе слайс молча начнёт включать соседний профиль.
RemapTXProfileBindings(Idx);
if FTXProfiles.ActiveIdx > Idx then Dec(FTXProfiles.ActiveIdx)
else if FTXProfiles.ActiveIdx = Idx then
begin
// Удалили активный: встаём на соседний и применяем его в эфир. ActiveIdx
// гасим — иначе StoreActiveTXProfile внутри Select затрёт соседа живым
// звуком удалённого профиля.
FTXProfiles.ActiveIdx := -1;
SelectTXProfile(EnsureRange(Idx, 0, FTXProfiles.Count - 1));
Exit; // SelectTXProfile уже сохранил и отрендерил
end;
SaveTXProfilesToConfig;
Changed(rfTXProfile);
end;
procedure TRadioController.RemapTXProfileBindings(Deleted: Integer);
// Профиль удалён из середины списка: привязку на него снимаем, привязки на
// профили правее — сдвигаем. Deleted < 0 = список заменён целиком, снимаем все.
procedure Fix(var B: Integer);
begin
if B < 0 then Exit;
if Deleted < 0 then B := -1
else if B = Deleted then B := -1
else if B > Deleted then Dec(B);
end;
var i, m: Integer;
begin
for m := 0 to CFG_MODE_MAX do Fix(FTXProfiles.MainByMode[m]);
for i := 0 to High(FSlices) do
if FSlices[i].Active then
for m := 0 to CFG_MODE_MAX do Fix(FSlices[i].TXProfByMode[m]);
end;
procedure TRadioController.BindTXProfileToCurrentMode(Idx: Integer);
// Пишет профиль в ячейку «текущий TX-источник + его модуляция». Отдельно от
// SetSliceTXProfile: там ещё и применение в эфир, а здесь профиль уже применён
// (нас зовут из SelectTXProfile), повторный ApplyBoundTXProfile был бы циклом.
var m, Slot: Integer;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
m := FlagMode(FTxSliceId);
if FTxSliceId <= 0 then
FTXProfiles.MainByMode[m] := Idx // персист — общим блоком в SelectTXProfile
else
begin
Slot := FindSliceIndex(FTxSliceId);
if Slot >= 0 then FSlices[Slot].TXProfByMode[m] := Idx;
end;
end;
function TRadioController.FlagMode(SliceId: Integer): Integer;
// Режим, в котором сейчас стоит этот флаг: у главного — FMode, у слайса — свой.
var idx: Integer;
begin
Result := FMode;
if SliceId > 0 then
begin
idx := FindSliceIndex(SliceId);
if idx >= 0 then Result := FSlices[idx].Mode;
end;
Result := EnsureRange(Result, 0, CFG_MODE_MAX);
end;
function TRadioController.SliceTXProfile(SliceId: Integer): Integer;
// Привязка «этот источник передачи В ЭТОМ РЕЖИМЕ → этот профиль».
// -1 = не переключать. Ключ — режим самого флага, поэтому смена модуляции
// сама достаёт профиль, с которым в этом виде работали в прошлый раз.
var idx: Integer;
begin
Result := -1;
if SliceId <= 0 then
Result := FTXProfiles.MainByMode[FlagMode(SliceId)]
else
begin
idx := FindSliceIndex(SliceId);
if idx >= 0 then Result := FSlices[idx].TXProfByMode[FlagMode(SliceId)];
end;
if (Result < 0) or (Result >= FTXProfiles.Count) then Result := -1;
end;
procedure TRadioController.SetSliceTXProfile(SliceId, ProfIdx: Integer);
// Пишем в ячейку ТЕКУЩЕГО режима этого флага: выбор запоминается за модуляцией.
var idx, m: Integer;
begin
if (ProfIdx < -1) or (ProfIdx >= FTXProfiles.Count) then ProfIdx := -1;
m := FlagMode(SliceId);
if SliceId <= 0 then
begin
if FTXProfiles.MainByMode[m] = ProfIdx then Exit;
FTXProfiles.MainByMode[m] := ProfIdx;
SaveTXProfilesToConfig;
end
else
begin
idx := FindSliceIndex(SliceId);
if (idx < 0) or (FSlices[idx].TXProfByMode[m] = ProfIdx) then Exit;
FSlices[idx].TXProfByMode[m] := ProfIdx;
// Персист слайсов делает MainForm на общем сохранении панов — здесь только
// состояние. Привязку главного пишем сразу: она живёт в секции профилей.
end;
// Привязали источник, который передаёт прямо сейчас, — применяем немедленно,
// иначе профиль подхватился бы только при следующей смене TX-источника.
if SliceId = FTxSliceId then ApplyBoundTXProfile;
Changed(rfTXProfile);
end;
procedure TRadioController.SetSliceTXProfileTable(SliceId: Integer;
const T: TTXProfModeTable);
// Восстановление сохранённой таблицы целиком (персист слайсов/панов).
var idx, m: Integer;
begin
if SliceId <= 0 then
begin
FTXProfiles.MainByMode := T;
Exit;
end;
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
for m := 0 to CFG_MODE_MAX do
if (T[m] >= 0) and (T[m] < FTXProfiles.Count) then
FSlices[idx].TXProfByMode[m] := T[m]
else
FSlices[idx].TXProfByMode[m] := -1;
end;
function TRadioController.SliceTXProfileTable(SliceId: Integer): TTXProfModeTable;
var idx: Integer;
begin
ClearTXProfModeTable(Result);
if SliceId <= 0 then Result := FTXProfiles.MainByMode
else
begin
idx := FindSliceIndex(SliceId);
if idx >= 0 then Result := FSlices[idx].TXProfByMode;
end;
end;
procedure TRadioController.ApplyBoundTXProfile;
// Активный профиль ОДНОЗНАЧНО определяется парой «TX-источник + его модуляция»:
// ячейка таблицы заполнена → её профиль;
// ячейка пуста (−1) → базовый профиль (первый в списке).
// Фолбэк обязателен: без него профиль прошлого режима оставался висеть в новом
// (симптом «перешёл из FM в USB, а звучит FM»). Правило детерминированное —
// работает и после перезапуска, в отличие от «вернуть, что было до автоматики».
var P, M: Integer;
begin
if FTXProfiles.Count = 0 then Exit;
M := FlagMode(FTxSliceId);
// В DMR передатчика нет, в FM RAW профиль ни на что не влияет
// (ApplyTXModeSettings глушит там всю обработку) — активный не трогаем вовсе,
// иначе заход в эти режимы молча перебивал бы выбор оператора.
if (M = MODE_DMR) or (M = MODE_FMRAW) then Exit;
P := SliceTXProfile(FTxSliceId);
if P < 0 then P := 0;
if P <> FTXProfiles.ActiveIdx then SelectTXProfile(P, True);
end;
procedure TRadioController.ResetTXProfilesToFactory;
// Пересобирает заводской набор поверх текущего списка. База — ЖИВЫЕ настройки,
// поэтому «Default» = микрофонный вход, полоса, динамика и мощность как сейчас,
// но с заводским (ровным) EQ. Единственный способ доехать до обновлённых
// заводских профилей с уже записанной секцией tx_profiles: она читается, а не
// пересоздаётся.
begin
DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles);
RemapTXProfileBindings(-1); // список другой — старые привязки бессмысленны
// ActiveIdx гасим перед Select: иначе StoreActiveTXProfile внутри него
// впишет в свежий «Default» живой EQ и сброс тембра тут же отменится.
FTXProfiles.ActiveIdx := -1;
// Применяем «Default» в эфир — иначе живой EQ остался бы кривым, а профиль
// говорил бы «ровный», и первая же правка TX-настроек вернула бы кривую.
SelectTXProfile(0); // сам сохранит и отрендерит
end;
procedure TRadioController.ApplyTXSettingsToDSP;
begin
if not FWDSPReady then Exit;
// Thetis держит CFIR постоянно включённым для openHPSDR Protocol 2:
// он компенсирует АЧХ FPGA CIC-интерполятора TX. Pluto этого фильтра не
// имеет, поэтому при смене бэкенда явно выключаем его.
FDSPEngine.SetTXCFIR(BackendCaps.Kind = bkHPSDR);
// Mic source — выставляем значение по умолчанию для MOX-кнопки;
// при реальном PTT SetMOX скорректирует источник.
FDSPEngine.SetTXMicSource(DefaultMicSource);
FDSPEngine.SetMicGain(FTXSettings.MicGainDB);
FDSPEngine.SetTXFilterFull(FTXSettings.FilterLow, FTXSettings.FilterHigh,
FTXSettings.FilterNC, FTXSettings.FilterMP,
FTXSettings.FilterWindow);
FDSPEngine.SetTXCompressor(FTXSettings.CompressorOn, FTXSettings.CompressorGain);
FDSPEngine.SetTXLeveler(FTXSettings.LevelerOn, FTXSettings.LevelerTop,
FTXSettings.LevelerDecay);
FDSPEngine.SetTXALC(FTXSettings.ALCOn, FTXSettings.ALCMaxGain,
FTXSettings.ALCDecay);
FDSPEngine.SetTXPhaseRot(FTXSettings.PhaseRotOn, FTXSettings.PhaseRotStages,
FTXSettings.PhaseRotFreq);
FDSPEngine.SetTXEQ(FTXSettings.EQOn, FTXSettings.EQNumBands,
FTXSettings.EQGains, FTXSettings.EQFreqs);
FDSPEngine.SetTXAMCarrierLevel(FTXSettings.AMCarrierLevel);
FDSPEngine.SetTXFMParams(FTXSettings.FMDeviation, FTXSettings.FMLowCut,
FTXSettings.FMHighCut, FTXSettings.FMEmphPosition);
FDSPEngine.SetTXCTCSS(FTXSettings.CTCSSOn, FTXSettings.CTCSSFreq);
// TX display
FDSPEngine.SetTXFFTParams(FTXSettings.TXFFTSize, FTXSettings.TXWindowType);
FDSPEngine.SetTXSpectrumDisplay(FTXSettings.TXSpecDetector,
FTXSettings.TXSpecAvgMode,
FTXSettings.TXSpecAvgTimeMS);
FDSPEngine.SetTXWaterfallDisplay(FTXSettings.TXWfDetector,
FTXSettings.TXWfAvgMode,
FTXSettings.TXWfAvgTimeMS);
// Reapply the mode overlay last: FM RAW must stay flat even when the user
// edits and saves ordinary voice-chain settings while this mode is active.
FDSPEngine.SetTXMode(ActiveTXMode);
// PureSignal-параметры calcc (mox/loop/tx delay, ptol, pin/map/stbl, SetPk)
ApplyPSSettingsToDSP;
end;
procedure TRadioController.SetMOX(On_: Boolean);
var
WasTransmitting: Boolean;
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: бэнды с 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
begin
FTwoTone := False;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXTwoTone(False, 0, 0);
Changed(rfTwoTone);
end;
if On_ then
begin
FDriveLevel := CalcDriveByte;
ApplyAntPorts; // AD936x: страховка — верный разъём до выхода в эфир
end;
FDUCPendingCount := 0; // сброс TX DUC-очереди на смене TX-состояния
// Сетевая PTT-последовательность TX-on: сначала реле (Alex bit27=1, drive) без
// PTT, затем PTT=1 — переключаем реле раньше передачи.
if FTransmitting and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
FNetwork.ClearDUCIQQueue;
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 после переключения реле
// PureSignal: перевести feedback-DDC0/1 в TX-конфигурацию (частоты = TX,
// sync, Alex bit18). No-op если PS не вооружён (гейт внутри бэкенда).
FNetwork.SetPureSignalTX(True);
// Pre-roll: THROTTLE (2000, порог FPGA — DAC не стартует с пустого буфера)
// + ДВА WDSP-блока (2×2048) нулевых пар подушкой в очередь. Раньше было 3
// блока: обоснование «2 не хватало — щелчок через ~0.5с» теперь под вопросом,
// т.к. тот щелчок по времени (0.5с, не ~21мс) похож не на underrun подушки, а
// на bfo-глитч тона (устранён коммитом f2d0519). Снижено до 2 блоков ради
// латентности старта TX (~10мс). Если под нагрузкой DUP / быстром пере-PTT
// вернётся щелчок РОВНО на старте — это реальный underrun, вернуть 8144.
FNetwork.PrimeDUCIQ(6096);
end;
if FWDSPReady and Assigned(FDSPEngine) then
begin
// Mic source автоматически по источнику TX. FM RAW always uses the
// configured default input (normally a virtual PortAudio cable), even if
// a web client is connected or hardware PTT keyed the transmitter.
if FTransmitting then
begin
if ActiveTXMode = MODE_FMRAW then
FDSPEngine.SetTXMicSource(DefaultMicSource)
// TCI впереди web: клиент попросил модуляцию из своего потока ЯВНО
// (TRX:0,true,tci), а web-микрофон включается самим фактом подключения
// браузера. Явная просьба сильнее умолчания.
else if FTCIMicRequested then
begin
FTCIMicActive := True;
FDSPEngine.SetTXMicSource(txmsWeb); // тот же ринг внешней подачи
end
else if FWebClientActive then
begin
FWebMicActive := True;
FDSPEngine.SetTXMicSource(txmsWeb);
end
else if FHWPTTActive then
FDSPEngine.SetTXMicSource(txmsRadio)
else
FDSPEngine.SetTXMicSource(DefaultMicSource);
end
else if FWebMicActive or FTCIMicActive then
begin
FWebMicActive := False;
FTCIMicActive := False;
FDSPEngine.SetTXMicSource(DefaultMicSource);
end;
// Сброс бэклога sound-card микрофона на RX→TX: пока шёл приём, ринг
// PortAudio наполнился до упора (~200мс). Без сброса весь этот «старый»
// звук бёрстом уходит в тракт: стоячая задержка ~190мс в DUC-очереди
// + в эфир передаётся аудио, записанное ДО нажатия PTT (на TUN оно
// складывается с тоном поверх полной шкалы → клип-всплеск).
if FTransmitting and (not WasTransmitting) and Assigned(ActiveMicInput)
and ActiveMicInput.IsOpen then
ActiveMicInput.Flush;
// В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX, чтобы
// TX leakage не накапливался в RXA pipeline / FFT-истории анализатора.
// ★Телеграф — исключение: приёмник должен жить, иначе с ним умолкает и
// программный сайдтон (он подмешивается в 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);
// ★Телеграф: голосовой 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;
if Assigned(FAudioOut) then
FAudioOut.Clear;
{$IFDEF WINDOWS}
// Windows: на TX→RX дропаем И RX-IQ, И аудио-выход — radio FPGA TX-buffer +
// PA slew-down дотравливают «хвост» (на Windows тянется 200500мс).
FDSPEngine.BeginPostTXMute(250);
{$ENDIF}
end;
// SetTXRun переключает FActiveDisplayID на TX_DISP_ID; при DUP возвращаем
// источник на RX, чтобы видеть приём во время передачи.
if FTransmitting and FDisplayDuplex then
FDSPEngine.SetDisplaySourceTX(False);
end;
// Сетевая PTT-последовательность TX-off: сначала снимаем PTT, затем реле RX.
if (not FTransmitting) and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
FNetwork.SendPTT(False); // 1й пакет: PTT=0 — сначала снимаем PTT
// PureSignal: вернуть DDC0/1 в RX-конфигурацию (убрать feedback-DDC).
FNetwork.SetPureSignalTX(False);
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;
procedure TRadioController.SetTune(On_: Boolean);
begin
if On_ = FTuning then Exit;
if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit;
// Safety: те же блокировки, что и в SetMOX — TUN это TX через WDSP PostGen-тон,
// он тоже должен молчать на бэндах с DoNotTx и на RX-only XVTR.
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
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);
if not FTransmitting then SetMOX(True); // PTT + run TXA-канал
end
else
begin
if FTransmitting then SetMOX(False);
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXTone(False, 0, 0);
FTuning := False;
FDriveLevel := CalcDriveByte; // вернуть обычный drive%
ApplyWDSPDrive(FDrivePercent);
if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then
begin
FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz,
FDriveLevel, FTransmitting, True, True, FTuning, FTwoTone);
FNetwork.SendFullHP;
end;
end;
ApplyPlutoTxAtten; // финальное состояние att по FTuning/FTransmitting (Pluto)
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;
// Телеграф правят и оператор, и CAT, и TCI, а скорость с задержкой макросов —
// величины общие для радио: об их смене обязаны узнать все фронтенды, а не
// только тот, кто её заказал.
Changed(rfCWSettings);
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)
or (Assigned(FCWElem) and FCWElem.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)
or (Assigned(FCWElem) and FCWElem.Busy);
end;
procedure TRadioController.CWElemKey(Down: Boolean);
// Поток проигрывателя. Чужая манипуляция — это ПРЯМОЙ КЛЮЧ, только с точными
// длительностями, поэтому и уходит она в те же две двери, что и ключ оператора:
// у Pluto — во вход прямого ключа локального генератора (он сам поднимает
// сессию, рисует огибающую и сайдтон), у openHPSDR — в бит CWX прошивки (тем же
// путём идёт и передача текста).
begin
if CWLocalSource then
begin
if Assigned(FCWLocal) then FCWLocal.StraightKey(Down);
Exit;
end;
CWXKeyEvent(Down);
end;
procedure TRadioController.CWKeyerElement(Mark: Boolean; Ms: Integer);
// Поток контроллера (адаптер TCI маршалит через Invoke). Ставим элемент в
// очередь — играет её отдельный поток по абсолютным дедлайнам.
begin
if Ms <= 0 then Exit;
// Тот же гейт, что у CWXSend: CWTXActive учитывает и режим, и запрет
// передачи (RX-only слот трансвертера, бэнд с DoNotTx) — там кейер разоружён,
// и трогать ключ незачем.
if not CWTXActive then Exit;
// У Pluto манипуляцию рисуем сами: генератор обязан быть заведён и вооружён,
// иначе первый же элемент уйдёт в никуда (то же делает CWXSend).
if CWLocalSource then
begin
EnsureCWLocal;
SyncCWKeyer;
end;
if not Assigned(FCWElem) then FCWElem := TCWElemPlayer.Create(Self.CWElemKey);
FCWElem.Enqueue(Mark, Ms);
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;
// Чужая манипуляция обрывается тем же движением: касание манипулятора,
// снятие MOX и уход из телеграфа гасят её так же, как передачу текста.
if Assigned(FCWElem) then FCWElem.AbortPlay;
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)
// ===========================================================================
function TRadioController.PSAvailable: Boolean;
begin
Result := FDevConnected and BackendCaps.HasPureSignal;
end;
procedure TRadioController.PSSetFeedbackPath(En: Boolean);
// Единая точка вкл/выкл feedback-тракта: сеть (DDC0/1 на TX) + движок (pscc).
// Намеренно БЕЗ раннего выхода при том же значении: движок сбрасывает armed
// при Close (ChangeSampleRate), сеть гейтится внутри — повторный вызов с тем
// же значением дёшев и восстанавливает рассинхрон.
begin
FPSFeedbackOn := En;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetPureSignalArmed(En);
if Assigned(FNetwork) then
begin
FNetwork.SetPureSignal(En);
// Включение/выключение посреди передачи — сразу перестроить DDC.
if FTransmitting then FNetwork.SetPureSignalTX(En);
end;
end;
procedure TRadioController.SetPureSignal(On_: Boolean);
// Кнопка PS = режим авто-калибровки (Thetis AutoCalEnabled). Выключение —
// через запрос сброса: state machine выполнит reset коррекции и погасит тракт.
begin
if On_ = FPSEnabled then Exit;
if On_ and not PSAvailable then Exit;
FPSEnabled := On_;
if On_ then
begin
ApplyPSSettingsToDSP; // параметры calcc до старта калибровки
FPSResetReq := False;
end
else
FPSResetReq := True;
Changed(rfPureSignal);
end;
procedure TRadioController.PSSingleCal;
// Одиночная калибровка (Thetis btnPSCalibrate): авто-режим выключается.
begin
if not PSAvailable then Exit;
if FPSSingleCal then begin FPSSingleCal := False; Exit; end; // отмена
ApplyPSSettingsToDSP;
FPSEnabled := False;
FPSSingleCal := True;
Changed(rfPureSignal);
end;
procedure TRadioController.PSReset;
begin
FPSEnabled := False;
FPSSingleCal := False;
FPSResetReq := True;
Changed(rfPureSignal);
end;
function TRadioController.PSFBTarget: Integer;
begin
Result := FTXSettings.PSFBTarget;
if Result <= 0 then
begin
if FDevConnected and (FNetwork <> nil) and (FNetwork.Device.BoardType = 5) then
Result := 22 // Orion MkII: замеры eu2av — при 152 фидбек-АЦП сам даёт IMD
else
Result := 152; // классическая цель Thetis
end;
end;
function TRadioController.PSOutlierSigmaEff: Double;
begin
Result := FTXSettings.PSOutlierSigma;
if Result < 0 then
begin
if FDevConnected and (FNetwork <> nil) and (FNetwork.Device.BoardType = 5) then
Result := 5.0 // Orion MkII: дефолт Thetis-Enhanced eu2av
else
Result := 0.0; // выключен
end;
end;
procedure TRadioController.ApplyPSSettingsToDSP;
var
Ints, Spi: Integer;
begin
if not (FWDSPReady and Assigned(FDSPEngine)) then Exit;
case FTXSettings.PSIntsIdx of
1: begin Ints := 8; Spi := 512; end;
2: begin Ints := 4; Spi := 1024; end;
else begin Ints := 16; Spi := 256; end;
end;
FDSPEngine.PSSetParams(FTXSettings.PSMoxDelay, FTXSettings.PSLoopDelay,
FTXSettings.PSTXDelayNs, FTXSettings.PSRelaxPtol,
FTXSettings.PSPin, FTXSettings.PSMap,
FTXSettings.PSStabilize, Ints, Spi,
FTXSettings.PSHWPeak, PSOutlierSigmaEff);
// Правка частот двухтональника при активном 2TON — применяем живьём.
if FTwoTone then
FDSPEngine.SetTXTwoTone(True, FTXSettings.PSTTFreq1, FTXSettings.PSTTFreq2);
end;
function TRadioController.PSFeedbackLevel: Integer;
begin
Result := FPSInfo[4];
end;
function TRadioController.PSCalCount: Integer;
begin
Result := FPSInfo[5];
end;
function TRadioController.PSCorrecting: Boolean;
begin
Result := FPSInfo[14] = 1;
end;
function TRadioController.PSMaxTXVal: Double;
begin
Result := FPSMaxTX;
end;
function TRadioController.PSSingleCalActive: Boolean;
begin
Result := FPSSingleCal
or (FPSCmdState in [PS_ST_TURNON_SINGLE, PS_ST_SINGLECAL]);
end;
procedure TRadioController.PureSignalTick;
// Вызывается из UI-таймера (~10 Гц). Командная state machine — порт Thetis
// PSForm timer1code (без Restore-ветки: Save/Restore коррекции не реализованы)
// + auto-attenuate (timer2code): подстройка AttOnTX по уровню feedback.
var
NewAtt: Integer;
FBTarget: Integer;
ddB: Double;
CalAttemptsChanged: Boolean;
begin
if not (FWDSPReady and Assigned(FDSPEngine)) then Exit;
if not PSAvailable then
begin
// Устройство сменилось/отключилось: гарантированно всё погасить.
if FPSFeedbackOn then PSSetFeedbackPath(False);
FPSCmdState := PS_ST_OFF;
Exit;
end;
// Полный простой (PS ни разу не включался / выключен и сброшен) — не дёргаем
// WDSP каждые 100 мс впустую.
if (FPSCmdState = PS_ST_OFF) and not (FPSEnabled or FPSSingleCal
or FPSResetReq or FPSFeedbackOn) then Exit;
// Опрос статуса WDSP calcc
Move(FPSInfo[0], FPSInfoPrev[0], SizeOf(FPSInfo));
FDSPEngine.PSGetInfo(FPSInfo);
FPSMaxTX := FDSPEngine.PSGetMaxTX;
// --- Командная state machine ---
case FPSCmdState of
PS_ST_OFF:
begin
FDSPEngine.PSControl(1, 0, 0, 0);
PSSetFeedbackPath(False);
FPSResetReq := False;
if FPSEnabled then
FPSCmdState := PS_ST_TURNON_AUTO
else if FPSSingleCal then
FPSCmdState := PS_ST_TURNON_SINGLE;
end;
PS_ST_TURNON_AUTO:
begin
FDSPEngine.PSControl(1, 0, 1, 0);
PSSetFeedbackPath(True);
FPSCmdState := PS_ST_AUTOCAL;
end;
PS_ST_AUTOCAL:
begin
PSSetFeedbackPath(True); // ре-синк (движок мог переоткрыться)
if FPSResetReq or (not FPSEnabled) then
FPSCmdState := PS_ST_TURNOFF
else if FPSSingleCal then
FPSCmdState := PS_ST_TURNON_SINGLE;
end;
PS_ST_TURNON_SINGLE:
begin
FDSPEngine.PSControl(1, 1, 0, 0);
PSSetFeedbackPath(True);
FPSCmdState := PS_ST_SINGLECAL;
end;
PS_ST_SINGLECAL:
begin
PSSetFeedbackPath(True); // ре-синк (движок мог переоткрыться)
FPSSingleCal := False;
if FPSResetReq then
FPSCmdState := PS_ST_TURNOFF
else if FPSEnabled then
FPSCmdState := PS_ST_TURNON_AUTO
else if PSCorrecting then
FPSCmdState := PS_ST_STAYON;
end;
PS_ST_STAYON:
begin
// Коррекция применяется, калибровка остановлена, feedback не нужен.
PSSetFeedbackPath(False);
if FPSResetReq then
FPSCmdState := PS_ST_TURNOFF
else if FPSEnabled then
FPSCmdState := PS_ST_TURNON_AUTO
else if FPSSingleCal then
FPSCmdState := PS_ST_TURNON_SINGLE;
end;
PS_ST_TURNOFF:
begin
// Reset исполняется внутри pscc — держим feedback-тракт, пока calcc
// не дойдёт до LRESET (как Thetis: PSEnabled=true в состоянии TurnOFF).
FDSPEngine.PSControl(1, 0, 0, 0);
PSSetFeedbackPath(True);
FPSResetReq := False;
if FPSEnabled then
FPSCmdState := PS_ST_TURNON_AUTO
else if FPSSingleCal then
FPSCmdState := PS_ST_TURNON_SINGLE
else if (not PSCorrecting) and (FPSInfo[15] = 0) then // 0 = LRESET
FPSCmdState := PS_ST_OFF;
end;
end;
// --- Auto-attenuate (Thetis-Enhanced eu2av timer2code) ---
// По завершении очередной попытки калибровки: если feedback слишком горячий
// (>1.5×цели) или слабый при ненулевом атт. (<0.7×цели), подстроить AttOnTX
// так, чтобы уровень лёг к цели PSFBTarget (22 на Orion MkII — выше вход
// фидбек-АЦП интермодулирует сам, и PS корректирует по искажённому сигналу).
FBTarget := PSFBTarget;
case FPSAAState of
0: begin
CalAttemptsChanged := FPSInfo[5] <> FPSInfoPrev[5];
if FTXSettings.PSAutoAtten and CalAttemptsChanged
and ((FPSInfo[4] > Round(FBTarget * 1.5)) or
((FPSInfo[4] < Round(FBTarget * 0.7)) and (FTXSettings.AttOnTX > 0))) then
begin
FPSAAState := 1;
if (FPSInfo[4] > 0) and (FPSInfo[4] <= 256) then
begin
ddB := 20.0 * Log10(FPSInfo[4] / FBTarget);
if ddB < -100.0 then ddB := -100.0;
if ddB > +100.0 then ddB := +100.0;
end
else
ddB := 31.1;
FPSAADeltaDB := Round(ddB);
FPSAASaveAuto := Ord(FPSCmdState = PS_ST_AUTOCAL);
FPSAASaveSingle := Ord(FPSCmdState = PS_ST_SINGLECAL);
FDSPEngine.PSControl(1, 0, 0, 0); // всё стоп + reset на время смены
end;
end;
1: begin
FPSAAState := 2;
NewAtt := EnsureRange(FTXSettings.AttOnTX + FPSAADeltaDB, 0, 31);
if NewAtt <> FTXSettings.AttOnTX then
begin
FTXSettings.AttOnTX := NewAtt;
SendDUCSpecificFromSettings; // применить атт. немедленно
Changed(rfPureSignal); // UI: показать новый ATT
end;
end;
2: begin
FPSAAState := 0;
FDSPEngine.PSControl(0, FPSAASaveSingle, FPSAASaveAuto, 0);
end;
end;
// UI-статус (feedback-лампа/счётчик) обновляем при изменении info
if not CompareMem(@FPSInfo[0], @FPSInfoPrev[0], SizeOf(FPSInfo)) then
Changed(rfPureSignal);
end;
procedure TRadioController.SetTwoTone(On_: Boolean);
// 2TON: двухтональник PostGen + MOX (как Thetis chkTestIMD). Уровень — обычный
// Drive; тона PSTTFreq1/2 (700/1900 по умолчанию).
begin
if On_ = FTwoTone then Exit;
if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit;
if On_ and FTuning then Exit; // не поверх TUN-тона
// Safety: те же блокировки, что SetMOX/SetTune.
if On_ and TXProhibited then Exit;
if On_ then
begin
// Генератор ДО запуска TX-тракта — первые блоки уже с тонами.
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXTwoTone(True, FTXSettings.PSTTFreq1, FTXSettings.PSTTFreq2);
FTwoTone := True;
if not FTransmitting then SetMOX(True);
end
else
begin
FTwoTone := False;
if FTransmitting then SetMOX(False);
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXTwoTone(False, 0, 0);
end;
Changed(rfTwoTone);
end;
procedure TRadioController.SetFMStep(Idx: Integer);
begin
if Idx < 0 then Idx := 0 else if Idx > 3 then Idx := 3; // FM_STEP_COUNT = 4
FFMStepIdx := Idx;
Changed(rfFMStepIdx);
end;
procedure TRadioController.SetFMCTCSS(On_: Boolean);
begin
FFMCTCSSOn := On_;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXCTCSS(FFMCTCSSOn, CTCSS_TONES[FFMCTCSSToneIdx]);
Changed(rfFMCTCSS);
end;
procedure TRadioController.SetFMCTCSSTone(Idx: Integer);
begin
if Idx < 0 then Idx := 0 else if Idx > CTCSS_COUNT - 1 then Idx := CTCSS_COUNT - 1;
FFMCTCSSToneIdx := Idx;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetTXCTCSS(FFMCTCSSOn, CTCSS_TONES[FFMCTCSSToneIdx]);
Changed(rfFMCTCSSTone);
end;
procedure TRadioController.SetFMSquelch(On_: Boolean);
begin
FFMSQOn := On_;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetFMSquelch(FFMSQOn and (FMode = MODE_FM), FFMSQLevel);
Changed(rfFMSQ);
end;
procedure TRadioController.SetFMSquelchLevel(V: Integer);
begin
FFMSQLevel := V;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetFMSquelch(FFMSQOn and (FMode = MODE_FM), FFMSQLevel);
Changed(rfFMSQLevel);
end;
procedure TRadioController.SetSplit(TxOnB: Boolean);
// Split: TX-VFO = B (TxOnB) либо A. TX-частота берётся из ActiveTXFreqHz
// (он смотрит на FSplitTxB), поэтому достаточно протолкнуть HP-кадр в сеть.
// Рендер VFO-дисплеев и подсветки TX-A/TX-B кнопок — через rfActiveVfo.
begin
if FSplitTxB = TxOnB then Exit;
FSplitTxB := TxOnB;
ApplyAntPorts; // AD936x: TX-разъём — по диапазону нового TX-VFO
PushNetworkState;
Changed(rfActiveVfo);
end;
procedure TRadioController.SetVfoLock(On_: Boolean);
// VFO Lock: пока включён, относительная перестройка (TuneActiveBy — энкодер VFO
// панели, CAT UP/DN) игнорируется. Подсветка — индикатор aiVFOLock (#11).
begin
if FVfoLock = On_ then Exit;
FVfoLock := On_;
Changed(rfVfoLock);
end;
procedure TRadioController.SetFMRpt(Dir: Integer);
begin
FFMRptDir := Dir;
FFMRptAutoActive := False; // явная установка направления — ручное действие
PushNetworkState; // TX-сдвиг применяется через ActiveTXFreqHz
Changed(rfFMRpt);
end;
procedure TRadioController.SetFMRptOffset(Hz: Double);
begin
if Hz <= 0.0 then Exit;
FFMRptOffsetHz := Hz;
if FFMRptDir <> RPT_NONE then PushNetworkState;
Changed(rfFMRpt);
end;
procedure TRadioController.UpdateRptAutoState;
// Авто-включение MINUS-сдвига в 2-метровом репитерном сегменте и авто-снятие
// при уходе из него (только если MINUS был выставлен автоматически).
begin
if RptAutoDir(FVfoA) = RPT_MINUS then
begin
if FFMRptDir = RPT_NONE then
begin
FFMRptDir := RPT_MINUS;
FFMRptAutoActive := True;
Changed(rfFMRpt);
end;
end
else
begin
if (FFMRptDir = RPT_MINUS) and FFMRptAutoActive then
begin
FFMRptDir := RPT_NONE;
FFMRptAutoActive := False;
Changed(rfFMRpt);
end;
end;
end;
procedure TRadioController.StartStop;
begin SetRun(not FRunning); end;
procedure TRadioController.SetRun(On_: Boolean);
begin FRunning := On_; Changed(rfRunning); { TODO wiring: логика BtnStartStopClick } end;
procedure TRadioController.StartRunning(SpectrumWidth: Integer);
// «Go-live» последовательность Run=1: General packet, wideband/DDC-конфиг,
// синхронизация WDSP sample rate, восстановление DSP (mode/volume/filter/NR/AGC),
// Run=1 → seq-сброс → DDC/DUC Specific + TX-цепь. Рендер run-кнопки/sleep — UI
// через OnControllerState(rfRunning); статус-бар/FSpecView.AGCTop/resize — обёртка.
var
GenPkt: TGeneralPacket;
begin
// General packet
FillChar(GenPkt, SizeOf(GenPkt), 0);
GenPkt.Command := CMD_GENERAL;
GenPkt.Flags37 := $08;
GenPkt.Flags38 := $01;
GenPkt.PAConfig := $01;
GenPkt.WBPort[0] := (PORT_WIDEBAND_ADC0 shr 8) and $FF;
GenPkt.WBPort[1] := PORT_WIDEBAND_ADC0 and $FF;
GenPkt.WBEnable := IfThen(FShowWideband, 1, 0);
GenPkt.WBSamplesPerPkt[0] := 512 shr 8;
GenPkt.WBSamplesPerPkt[1] := 512 and $FF;
GenPkt.WBSampleSize := 16;
GenPkt.WBUpdateRate := 70;
GenPkt.WBPacketsPerFrame := 32;
if FNetwork.Device.BoardType = 5 then
GenPkt.AlexEnable := $03
else
GenPkt.AlexEnable := $01;
FNetwork.SendGeneralPacket(GenPkt);
FNetwork.ConfigureWideband(0, FShowWideband, 512, 16, 70, 32);
// ANGELIA=3, ORION=4, ORION2=5, SATURN=10: DDC0/1 — PureSignal, юзерский с 2
// (тот же список, что DDCBaseIndex бэкенда; Saturn раньше был пропущен).
if FNetwork.Device.BoardType in [3, 4, 5, 10] then
FActiveDDC := 2
else
FActiveDDC := 0;
ResetPanDDCMaps; // пул пан-DDC заново от нового FActiveDDC
// Синхронизируем samplerate WDSP если нужно
if FWDSPReady and (FDSPEngine.SampleRate <> FSampleRate) then
begin
FWDSPReady := False;
FDSPEngine.ChangeSampleRate(FSampleRate);
FWDSPReady := FDSPEngine.Initialized;
end;
if FWDSPReady then
begin
FDSPEngine.SetMode(FMode);
FDSPEngine.SetVolume(FVolume / 100.0);
ApplyModeFilter;
ApplyNoiseFilterButtonsToDSP;
// ChangeSampleRate пересоздаёт WDSP-канал — восстанавливаем все настройки
FDSPEngine.SetAGCTop(FAGCTop);
ApplyAGCToEngine; // единый маппинг UI-индекс→enum (не прямой каст)
end;
// Сначала отправляем Run=1 — эмулятор/железо создают ddc_specific_thread
// (порт 1025) и rx_thread (порт 1035+) только после получения HP Run=1.
// DDC Specific и DUC Specific отправляем ПОСЛЕ, иначе порты ещё не открыты.
// Уровень пересчитываем здесь же: с вооружённым кейером он уедет в железо
// сразу, до всякой передачи, а после конструктора 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;
FRXPacketCount := 0;
FillChar(FDDCSeqValid, SizeOf(FDDCSeqValid), 0);
FSeqErrorCount := 0;
FLastSeqErrorDDC := -1;
FLastSeqErrorDelta := 0;
FSeqOkStreak := 0;
// DDC и DUC Specific — теперь потоки эмулятора слушают на своих портах.
// DUC Specific содержит mic-конфигурацию (boost/bias/line/PTT) из FTXSettings.
FNetwork.ConfigureDDCs(1, FSampleRate div 1000, FMainADCSrc,
FDitherEnabled, FRandomEnabled);
// Применяем сохранённое состояние динамика/отправки аудио (мьют byte 1400 bit1).
FNetwork.SetSpeakerAudio(FSendAudioToRadio);
// RX hw-gain в бэкенд (Pluto). HPSDR — no-op.
ApplyRxGainToBackend;
// Антенные разъёмы AD936x для текущего диапазона/трансвертера. HPSDR — no-op.
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;
// Принудительно обновляем AGC линии — гарантированно после всех SetAGC вызовов
if FWDSPReady then
FDSPEngine.UpdateAGCLines(SpectrumWidth); // ещё не получили ни одного пакета
Changed(rfRunning); // UI: caption STOP / MOX.Enabled / InhibitSleep
end;
procedure TRadioController.StopRunning;
// Graceful-останов RX-потока (Run=0) + сброс TX и RX-телеметрии. Сеть остаётся
// Connected — Disconnect отдельно (UI-обёртка делает это после persistence).
// Рендер run-кнопки/sleep — UI через OnControllerState(rfRunning); MOX — rfTransmitting.
var
p: Integer;
begin
if FRunning then
begin
// Персист панов/слайсов ДО их гашения (UI-снимок живого состояния).
if Assigned(FOnBeforeStop) then FOnBeforeStop;
// Паны на доп. DDC живут только внутри Run-сессии — гасим до Run=0
// (3.3 UI пересоздаст по персисту при следующем старте).
for p := 1 to MAX_PANS - 1 do
RemovePanDDC(p);
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-тон) — вернуться в выключенное.
FNetwork.ClearDUCIQQueue;
if FTuning then
begin
FTuning := False;
if FWDSPReady then FDSPEngine.SetTXTone(False, 0, 0);
Changed(rfTuning); // UI: TUN-кнопка off
end;
if FTwoTone then
begin
FTwoTone := False;
if FWDSPReady then FDSPEngine.SetTXTwoTone(False, 0, 0);
Changed(rfTwoTone); // UI: 2TON-кнопка off
end;
// PureSignal: погасить калибровку и feedback-тракт (без сброса коррекции —
// сеть уже останавливается; state machine стартует заново при след. Run).
if FPSEnabled or FPSSingleCal or FPSFeedbackOn then
begin
FPSEnabled := False;
FPSSingleCal := False;
FPSResetReq := False;
FPSCmdState := PS_ST_OFF;
FPSAAState := 0;
PSSetFeedbackPath(False);
if FWDSPReady then FDSPEngine.PSControl(1, 0, 0, 0);
Changed(rfPureSignal);
end;
if FWDSPReady then FDSPEngine.SetTXRun(False);
Changed(rfTransmitting); // MOX-кнопка off
end;
FRXPacketCount := 0;
FActiveDDC := 0;
FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE;
FLastPlutoRSSIdB := TELEMETRY_NONE;
FLastPlutoGainDb := TELEMETRY_NONE;
FPlutoTeleTick := 0; FPlutoTempBad := 0;
Changed(rfRunning); // безусловный сброс run-кнопки/MOX.Enabled/sleep (как старый хвост STOP)
end;
function TRadioController.ResolveDevice(const IP: string): THPSDRDevice;
// discovered→saved по IP/URI: восстанавливает Kind/URI/Serial/BoardType/MAC,
// иначе простой HPSDR по IP. См. одноимённую обёртку в GUI (TMainForm).
var
i: Integer;
dd: TDiscoveredDevice;
sd: TSavedDevice;
begin
FillChar(Result, SizeOf(Result), 0);
Result.Kind := bkHPSDR;
Result.IPAddress := IP;
i := FDeviceStore.FindDiscoveredByIP(IP);
if i >= 0 then
begin
dd := FDeviceStore.Discovered(i);
Result.Kind := dd.Kind;
Result.URI := dd.URI;
Result.Serial := dd.Serial;
Result.BoardType := dd.BoardType;
Move(dd.MAC[0], Result.MAC[0], 6);
Exit;
end;
for i := 0 to FDeviceStore.SavedCount - 1 do
begin
sd := FDeviceStore.Saved(i);
if SameText(sd.IPAddress, IP) or SameText(sd.URI, IP) then
begin
Result.Kind := sd.Kind;
Result.URI := sd.URI;
Result.Serial := sd.Serial;
Result.BoardType := sd.BoardType;
Move(sd.MAC[0], Result.MAC[0], 6); // MAC с прошлого connect — ключ профиля настроек
Exit;
end;
end;
end;
function TRadioController.EnsureDeviceMac(var Dev: THPSDRDevice): Boolean;
// См. комментарий в объявлении. Поиск в сети делаем с временно снятым
// OnDeviceFound: это служебный пробник, а не пользовательский DISCOVER —
// список устройств в UI он трогать не должен.
const
MAC_PROBE_MS = 700; // хватает на LAN: пакет уходит сразу, дальше только ожидание
var
i: Integer;
sd: TSavedDevice;
SavedCb: TOnDeviceFound;
SavedIP: string;
Found: THPSDRDeviceArray;
begin
Result := not MacIsZero(Dev.MAC);
if Result then Exit;
i := FDeviceStore.FindDiscoveredByIP(Dev.IPAddress);
if (i >= 0) and (not MacIsZero(FDeviceStore.Discovered(i).MAC)) then
begin
Move(FDeviceStore.Discovered(i).MAC[0], Dev.MAC[0], 6);
Exit(True);
end;
i := FDeviceStore.FindSavedByAddr(Dev.IPAddress);
if i >= 0 then
begin
sd := FDeviceStore.Saved(i);
if not MacIsZero(sd.MAC) then
begin
Move(sd.MAC[0], Dev.MAC[0], 6);
Exit(True);
end;
end;
SavedCb := FNetwork.OnDeviceFound;
SavedIP := FNetwork.DirectIP;
FNetwork.OnDeviceFound := nil;
FNetwork.DirectIP := Dev.IPAddress; // unicast — достанет радио и вне broadcast-домена
try
Found := FNetwork.Discover(MAC_PROBE_MS);
finally
FNetwork.OnDeviceFound := SavedCb;
FNetwork.DirectIP := SavedIP;
end;
for i := 0 to High(Found) do
if SameText(Found[i].IPAddress, Dev.IPAddress) and (not MacIsZero(Found[i].MAC)) then
begin
Move(Found[i].MAC[0], Dev.MAC[0], 6);
if Dev.BoardType = 0 then Dev.BoardType := Found[i].BoardType;
Exit(True);
end;
end;
procedure TRadioController.AddSavedDevice(const AName, AAddr: string);
// web-dev_add: Pluto по схеме URI ('ip:'/'usb:'/'local:'), иначе HPSDR-IP.
// Serial при ручном добавлении неизвестен (допишется при connect/probe).
var
A: string;
begin
A := Trim(AAddr);
if A = '' then Exit;
if (Pos('ip:', A) = 1) or (Pos('usb:', A) = 1) or (Pos('local:', A) = 1) then
FDeviceStore.AddSavedPluto(AName, A, '')
else
FDeviceStore.AddSaved(AName, A, 0);
end;
function TRadioController.ConnectDevice(const Dev: THPSDRDevice): Boolean;
// Подключение + загрузка per-device настроек по MAC в поля контроллера + конфиг
// движка (DSP display, audio). Возвращает False, если Connect не удался (UI
// покажет сообщение по FNetwork.LastError). Рендер ВСЕХ device-виджетов — UI
// через OnControllerState(rfDevice). Перестройку диапазона/VFO (RestoreBand/
// XVTR/ActiveVfo) делает UI-обёртка ПОСЛЕ (там финальная перестройка через
// ApplyVfoA + wideband/web-extras). FLoadedGlobal хранит загруженный G_Settings
// для рендера полей, которых нет в контроллере (FPS/FreqMhzDigits/CAT).
var
i: Integer;
D: THPSDRDevice;
begin
D := Dev;
// Подбираем бэкенд под тип устройства (HPSDR/Pluto) до подключения.
EnsureBackend(D.Kind);
// MAC — ключ профиля настроек. Старт по сохранённому IP (AutoStart/CONNECT без
// дискавери) приносит нулевой MAC, и весь профиль устройства уехал бы в чужую
// секцию конфига; добираем MAC до Connect.
if D.Kind = bkHPSDR then EnsureDeviceMac(D);
Result := FNetwork.Connect(D);
if not Result then Exit;
// Узнанный MAC запоминаем за сохранённым устройством — следующий старт по
// AutoStart возьмёт его без поиска в сети.
if D.IPAddress <> '' then FDeviceStore.SetSavedMac(D.IPAddress, D.MAC)
else FDeviceStore.SetSavedMac(D.URI, D.MAC);
// --- Загружаем настройки устройства по MAC ---
Move(FNetwork.Device.MAC[0], FDevMAC[0], 6);
FDevConnected := True;
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);
FSettings.LoadOC(FDevMAC, FOCSettings);
FNetwork.SetOCConfig(FOCSettings); // no-op на Pluto (base-класс)
FSettings.LoadFilters(FDevMAC, FFilterTable);
ApplyFilterSlot(FFilter); // кромки под таблицу этого устройства
FSettings.LoadXvtr(FDevMAC, FXvtrSettings);
FCurrentXvtr := -1;
ApplyXvtrToNetwork;
FTXSpecRefLevel := FTXSettings.TXSpecRefLevel;
FTXSpecRange := FTXSettings.TXSpecRange;
if FTXSettings.TXSpecGridStep > 0 then
FTXSpecGridStep := FTXSettings.TXSpecGridStep;
FVolume := FLoadedGlobal.Volume;
FTxMonVolume := EnsureRange(FLoadedGlobal.TxMonVolume, 0, 100);
FActiveVfo := EnsureRange(FLoadedGlobal.ActiveVfo, 0, 1); // битый конфиг не должен дать невалидный VFO
FPAMaxPower := FLoadedGlobal.PAMaxPower;
FPlutoTxMaxAttDb := EnsureRange(FLoadedGlobal.PlutoTxMaxAttDb, -50.0, 0.0);
for i := 0 to CFG_BAND_COUNT - 1 do
FPABandCal[i] := FLoadedGlobal.PABandCal[i];
for i := 0 to CFG_XVTR_COUNT - 1 do
FVHFBandCal[i] := FLoadedGlobal.VHFBandCal[i];
SetCalibration(FLoadedGlobal.Cal);
// drive% (0..100) — раньше грузился через TrkDrive.Position; теперь напрямую,
// слайдер выставит rfDevice-рендер. Калиброванный байт считаем после FCurrentBand.
FDrivePercent := EnsureRange(FLoadedGlobal.DriveLevel, 0, 100);
// TX-профили. Секции нет (конфиг до этой версии) — строим заводской набор из
// ТЕКУЩИХ настроек: «Default» повторяет их один в один, звук после
// обновления не меняется. Активный профиль на старте не применяем — живая
// секция "tx" уже хранит последнее состояние, оно и есть истина.
if not FSettings.LoadTXProfiles(FDevMAC, FTXProfiles) then
DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles);
// Клампим LastBand — иначе битый/старый конфиг → RestoreBand выйдет по гарду,
// а последующие SetMode/SetFilterIdx запишут в FBandCache[невалид] за массив.
FCurrentBand := EnsureRange(FLoadedGlobal.LastBand, 0, CFG_BAND_COUNT - 1);
FDriveLevel := CalcDriveByte;
// SampleRate — глобальный, до RestoreBand
if FLoadedGlobal.SampleRate > 0 then
begin
FSampleRate := FLoadedGlobal.SampleRate;
FSpanHz := FSampleRate;
end;
// Pluto: HPSDR-наследие rate (48..1536k, часто <520k) невалидно. Клампим к
// дефолту бэкенда, если ниже минимума (overlay покажет валидный набор).
with FNetwork.Caps do
if (Kind = bkPluto) and (FSampleRate < MinSampleRate) then
begin
FSampleRate := 1536000;
FSpanHz := FSampleRate;
end;
FRxGainMode := EnsureRange(FLoadedGlobal.RxGainMode, 0, 3);
FRxGainDb := EnsureRange(FLoadedGlobal.RxGainDb, 0, 73);
FNRMode := EnsureRange(FLoadedGlobal.NRMode, 0, 4);
FNBMode := EnsureRange(FLoadedGlobal.NBMode, 0, 2);
FSNB := FLoadedGlobal.SNBEnabled;
FANF := FLoadedGlobal.ANFEnabled;
FWfAGCEnabled := FLoadedGlobal.WfAGCEnabled;
FWfNFEnabled := FLoadedGlobal.WfNFEnabled;
FDitherEnabled := FLoadedGlobal.DitherEnabled;
FRandomEnabled := FLoadedGlobal.RandomEnabled;
FSendAudioToRadio := FLoadedGlobal.SendAudioToRadio;
FRawFMDeviation := EnsureRange(FLoadedGlobal.RawFMDeviation,
RAW_FM_DEV_MIN, RAW_FM_DEV_MAX);
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetRawFMDeviation(FRawFMDeviation);
FWfManualHigh := FLoadedGlobal.WfManualHigh;
FWfManualLow := FLoadedGlobal.WfManualLow;
FWfAGCOffset := FLoadedGlobal.WfAGCOffset;
FWfPalette := EnsureRange(FLoadedGlobal.WfPalette, 0, 2);
FWfGamma := EnsureRange(FLoadedGlobal.WfGamma, 0.3, 3.0);
FDisplayPixels := EnsureRange(FLoadedGlobal.DisplayPixels, 64, 4096);
FWfSpeedDiv := EnsureRange(FLoadedGlobal.WfSpeedDiv, 1, 8);
FSpecMSAA := EnsureRange(FLoadedGlobal.SpecMSAA, 1, 8);
FBcnZoomSpanHz := EnsureRange(FLoadedGlobal.BeaconZoomSpanHz, 600.0, 200000.0);
FBcnZoomOffsetHz := EnsureRange(FLoadedGlobal.BeaconZoomOffsetHz, -500000.0, 500000.0);
FBcnZoomFollow := FLoadedGlobal.BeaconZoomFollow;
FBcnZoomAuto := FLoadedGlobal.BeaconZoomAuto;
if FWDSPReady then FDSPEngine.SetDisplayResLimit(FDisplayPixels);
FShowSpectrum := FLoadedGlobal.ShowSpectrum;
FShowWaterfall := FLoadedGlobal.ShowWaterfall;
FShowWideband := FLoadedGlobal.ShowWideband;
FWidebandFill := FLoadedGlobal.WidebandFill;
FSpectrumFill := FLoadedGlobal.SpectrumFill;
FDisplayDuplex := FLoadedGlobal.DisplayDuplex;
FRxMuteOnTx := FLoadedGlobal.RxMuteOnTx;
// Настройки дисплея/сетки + DSP display + аудио-устройства
if FLoadedGlobal.FFTSize > 0 then
begin
FSpecRefLevel := FLoadedGlobal.SpecRefLevel;
FSpecRange := FLoadedGlobal.SpecRange;
if FLoadedGlobal.SpecGridStep > 0 then FSpecGridStep := FLoadedGlobal.SpecGridStep;
if FWDSPReady then
begin
FDSPEngine.SetFFTParams(FLoadedGlobal.FFTSize, FLoadedGlobal.WindowType);
FDSPEngine.SetSpectrumDisplay(FLoadedGlobal.SpecDetector, FLoadedGlobal.SpecAvgMode,
FLoadedGlobal.SpecAvgTimeMS);
FDSPEngine.SetWaterfallDisplay(FLoadedGlobal.WfDetector, FLoadedGlobal.WfAvgMode,
FLoadedGlobal.WfAvgTimeMS);
end;
// Локальная звуковая карта (динамик RX / микрофон TX). В демоне FLocalAudio=
// False: карту не открываем (иначе headless-хост хватает ALSA-устройство и
// играет звук). Web-аудио идёт через OnAudioConsume и от FAudioOut не зависит.
if FLocalAudio then
begin
// Аудио устройство вывода
FAudioOut.Close;
if FLoadedGlobal.AudioSampleRate > 0 then
FAudioOut.SampleRate := FLoadedGlobal.AudioSampleRate;
if FLoadedGlobal.AudioOutDevice <> '' then
begin
FAudioOut.DeviceIndex := FAudioOut.FindDeviceByName(FLoadedGlobal.AudioOutDevice);
FAudioOutDevName := FLoadedGlobal.AudioOutDevice;
end
else
begin
FAudioOut.DeviceIndex := -1;
FAudioOutDevName := '';
end;
FAudioOut.Open;
// Аудио устройство ввода (TX mic). MicSource определяется в SetMOX по PTT —
// здесь только открываем устройство.
if FLoadedGlobal.AudioInDevice <> '' then
SetMainMicDevice(FAudioIn.FindDeviceByName(FLoadedGlobal.AudioInDevice),
FLoadedGlobal.AudioInDevice)
else
SetMainMicDevice(-1, '');
end;
end;
Changed(rfDevice); // UI: полная синхронизация device-виджетов из состояния
end;
function TRadioController.ConnectAndRun(const Dev: THPSDRDevice;
SpectrumWidth: Integer): Boolean;
// Headless-оркестрация подключения: Connect+load-by-MAC (ConnectDevice) →
// перестройка диапазона/XVTR/активного VFO → go-live (StartRunning). Рендер всех
// device/band/run-виджетов идёт через события (rfDevice/rfBandRestore/rfXvtr/
// rfActiveVfo/rfRunning). UI-обёртка добавляет лишь статус-строки/AGCTop/resize.
// Возвращает False, если Connect не удался (тогда bring-up не делается).
begin
Result := ConnectDevice(Dev);
if not Result then Exit;
// Перестройка диапазона — команда несёт финальную перестройку по активному VFO.
RestoreBand(FCurrentBand);
// Pluto/VHF: HF-частоты вне диапазона. Если восстановленный VFO ниже минимума
// бэкенда — ставим дефолт VHF (временно 100 МГц, пока нет VHF band-плана).
with FNetwork.Caps do
if (Kind = bkPluto) and (FVfoA < MinFreqHz) then
begin
FVfoB := 100000000;
FCenterFreq := 100000000;
SetVfoA(100000000); // перестроит дисплей + LO
end;
// Восстанавливаем XVTR-режим, если он был активен при выходе. FCurrentBand уже
// загружен (HF-диапазон до XVTR) — ActivateXvtr только выставит FVfoA/FCurrentXvtr.
if (FLoadedGlobal.LastXvtr >= 0) and (FLoadedGlobal.LastXvtr < CFG_XVTR_COUNT)
and FXvtrSettings.Entries[FLoadedGlobal.LastXvtr].Enabled then
ActivateXvtr(FLoadedGlobal.LastXvtr);
// Активный VFO загружен из настроек; RestoreBand/ActivateXvtr строят только
// VFO A. Если активен B — перестраиваем приёмник И подсветку на него. Для
// активного A подсветку освежает UI-обёртка (чистый рендер).
if FActiveVfo = 1 then
SetActiveVfo(1);
// Go-live Run=1 (General/DDC/DUC + DSP restore); рендер — через rfRunning.
StartRunning(SpectrumWidth);
end;
procedure TRadioController.StopAndDisconnect;
// Headless STOP+teardown: graceful Run=0 (StopRunning) + персист настроек
// устройства/диапазона/глобала + разрыв сети. Персист идёт ДО Disconnect (saves
// читают живое состояние). Рендер run-кнопки/MOX/sleep — через StopRunning→
// rfRunning; UI-обёртка добавляет очистку спектра и offline-статус.
begin
StopRunning;
// Сохраняем текущую частоту/настройки чтобы следующий START восстановил их.
if FDevConnected then
begin
SaveCurrentBand;
SaveGlobalSettings;
FSettings.SaveAlex(FDevMAC, FAlexSettings);
FSettings.SaveAnt936x(FDevMAC, FAnt936x);
FSettings.SaveOC(FDevMAC, FOCSettings);
FSettings.SaveFilters(FDevMAC, FFilterTable);
// Активный TX-профиль дописываем живыми значениями (drive/громкость микро-
// фона ездят мимо диалога настроек) и пишем список целиком.
StoreActiveTXProfile;
FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
// XVTR LastFreq → актуальная видимая частота в момент остановки.
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then
FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA;
FSettings.SaveXvtr(FDevMAC, FXvtrSettings);
end;
FSettings.SaveStartupPreview(FVfoA, FVfoB, FSampleRate);
FSettings.Save;
FNetwork.Disconnect;
end;
// ---------------------------------------------------------------------------
// Discovery — поиск устройств в фоновом потоке. Найденные приходят через
// FNetwork.OnDeviceFound (=OnDeviceFound) и копятся в FDeviceStore.Discovered;
// по завершении без устройств — DiscoverFinishedNone. UI слушает rfDeviceList.
// ---------------------------------------------------------------------------
type
TDiscoverThread = class(TThread)
private
FCtrl: TRadioController;
protected
procedure Execute; override;
public
constructor Create(ACtrl: TRadioController);
end;
constructor TDiscoverThread.Create(ACtrl: TRadioController);
begin
FCtrl := ACtrl;
FreeOnTerminate := True;
inherited Create(False);
end;
procedure TDiscoverThread.Execute;
// Сканируем ОБА бэкенда транзиентными экземплярами (независимо от FNetwork —
// он может быть уже подключён/другого типа). Найденные сигналятся колбэком
// OnDeviceFound во время Discover() и копятся в store.
var
Found: Integer;
HP: THPSDRNetwork;
Pluto: TPlutoBackend;
PDev: TRadioDevice;
i: Integer;
begin
Found := 0;
// openHPSDR (UDP broadcast)
HP := THPSDRNetwork.Create;
try
HP.OnDeviceFound := FCtrl.OnDeviceFound;
Found := Found + Length(HP.Discover(2000));
finally
HP.Free;
end;
// Pluto (libiio scan + прямой probe сетевых URI) — если libiio доступна.
if IIOLoad then
begin
Pluto := TPlutoBackend.Create;
try
Pluto.OnDeviceFound := FCtrl.OnDeviceFound;
// USB scan
Found := Found + Length(Pluto.Discover(1500));
// Прямой probe сетевых адресов (mDNS недоступен) — из списка фронтенда.
for i := 0 to High(FCtrl.FPlutoProbeURIs) do
if Pluto.ProbeURI(FCtrl.FPlutoProbeURIs[i], PDev) then
Inc(Found);
finally
Pluto.Free;
end;
end;
if Found = 0 then
FCtrl.Invoke(FCtrl.DiscoverFinishedNone);
end;
procedure TRadioController.EnsureBackend(Kind: TBackendKind);
// Пересоздаёт FNetwork нужного класса, перенося колбэки данных. No-op если тип
// уже верный. Старый бэкенд отключается и освобождается.
var
NewB: TRadioBackend;
begin
if (FNetwork <> nil) and (FNetwork.Caps.Kind = Kind) then Exit;
case Kind of
bkPluto: NewB := TPlutoBackend.Create;
else NewB := THPSDRNetwork.Create;
end;
if FNetwork <> nil then
begin
if FNetwork.Connected then FNetwork.Disconnect;
NewB.OnDeviceFound := FNetwork.OnDeviceFound;
NewB.OnDDCIQ := FNetwork.OnDDCIQ;
NewB.OnMicPacket := FNetwork.OnMicPacket;
NewB.OnHPStatus := FNetwork.OnHPStatus;
NewB.OnWideband := FNetwork.OnWideband;
FreeAndNil(FNetwork);
end;
FNetwork := NewB;
end;
function TRadioController.BackendCaps: TBackendCaps;
begin
if FNetwork <> nil then Result := FNetwork.Caps
else FillChar(Result, SizeOf(Result), 0);
end;
function TRadioController.SampleRatePresets: TBackendRateArray;
begin
Result := BackendCaps.RatePresets;
end;
function TRadioController.BandPlan: TBandPlanArray;
begin
Result := BuildBandPlan(IsPluto);
end;
function TRadioController.IsPluto: Boolean;
begin
Result := (FNetwork <> nil) and (FNetwork.Caps.Kind = bkPluto);
end;
function TRadioController.InQO100: Boolean;
// True — активен full-duplex транспондер (QO-100): Pluto + выбран XVTR-слот с
// флагом FullDuplexTx. Гейт для UI-элементов, осмысленных только в этом режиме
// (beacon-lock, RX MUTE self-monitor).
begin
Result := IsPluto and (FCurrentXvtr >= 0)
and FXvtrSettings.Entries[FCurrentXvtr].FullDuplexTx;
end;
function TRadioController.DisplayDuplex: Boolean;
begin
Result := FDisplayDuplex;
end;
function TRadioController.BoardDisplayName: string;
var
Dev: THPSDRDevice;
begin
if FNetwork = nil then Exit('');
Dev := FNetwork.Device;
if Dev.Kind = bkPluto then
begin
Result := PlutoModelName(Dev.Model);
// Серийник дописываем, только если это реальный hw_serial, а не URI-fallback
// ('usb:1.5.5' / 'ip:…'), который PlutoBackend кладёт при отсутствии серийника.
if (Dev.Serial <> '') and (Pos('usb:', Dev.Serial) = 0)
and (Pos('ip:', Dev.Serial) = 0) then
Result := Result + ' SN:' + Dev.Serial;
end
else
Result := BoardTypeName(Dev.BoardType);
end;
procedure TRadioController.ServicePlutoTelemetry;
const
POLL_MS = 500; // опрос ~2 Гц: чтение attr — USB round-trip, чаще ни к чему
TEMP_MIN_C = 0.0; // работающий AD936x всегда теплее окружающей (само-нагрев)
TEMP_MAX_C = 120.0; // абсолютный потолок (датчик исправен)
TEMP_STEP_C = 12.0; // макс. физичный шаг между опросами (тепловая инерция чипа)
TEMP_FORCE = 3; // принять «выброс», если держится N раз подряд (не залипаем)
var
t, s, r: Double;
now_: QWord;
begin
if (FNetwork = nil) or (not FRunning) or (FNetwork.Caps.Kind <> bkPluto) then Exit;
now_ := GetTickCount64;
if (FPlutoTeleTick <> 0) and (now_ - FPlutoTeleTick < POLL_MS) then Exit;
FPlutoTeleTick := now_;
if not FNetwork.ReadTelemetry(t, s, r) then Exit;
// Температура: датчик AD9361 иногда отдаёт мусор (мид-конверсия / контеншн на
// USB-контексте с RX-потоком) — наблюдалось редкое -15 °C. Фильтруем: жёсткое
// окно правдоподобия + slew-limit по тепловой инерции; устойчивый «выброс»
// (реальный скачок) всё же принимаем через TEMP_FORCE опросов, чтобы не залипнуть.
if t > TELEMETRY_NONE then
begin
if (t >= TEMP_MIN_C) and (t <= TEMP_MAX_C) then
begin
if (FLastPlutoTempC <= TELEMETRY_NONE) or (Abs(t - FLastPlutoTempC) <= TEMP_STEP_C) then
begin
FLastPlutoTempC := t; FPlutoTempBad := 0;
end
else
begin
Inc(FPlutoTempBad);
if FPlutoTempBad >= TEMP_FORCE then
begin FLastPlutoTempC := t; FPlutoTempBad := 0; end;
end;
end;
// вне окна правдоподобия — молча отбрасываем, держим прошлое валидное значение
end;
// Температура Zynq SoC (xadc): стабильный локальный датчик (не подвержен мусору
// мид-конверсии AD9361) — достаточно окна правдоподобия без slew-limit.
if (s > TELEMETRY_NONE) and (s >= TEMP_MIN_C) and (s <= TEMP_MAX_C) then
FLastPlutoSocTempC := s;
// RSSI быстро меняется с сигналом — без сглаживания; обновляем, только если валиден.
if r > TELEMETRY_NONE then FLastPlutoRSSIdB := r;
// Фактическое усиление тракта (в MANUAL = заданное, в AGC — снятое с железа).
FLastPlutoGainDb := FNetwork.RxGainNowDb;
Changed(rfRxGain); // UI: живая цифра усиления в блоке RF AGC
Changed(rfSupply); // поле телеметрии статус-бара (поле 3); web-адаптер тоже
end;
procedure TRadioController.SetPlutoProbeURIs(const URIs: array of string);
var i: Integer;
begin
SetLength(FPlutoProbeURIs, Length(URIs));
for i := 0 to High(URIs) do FPlutoProbeURIs[i] := URIs[i];
end;
procedure TRadioController.SeedPlutoProbeFromSaved;
// Заполняет probe-список URI всех сохранённых Pluto (mDNS нет → сетевой Pluto
// находится только прямым probe его URI). Демон зовёт это перед Discover; GUI
// добавляет ещё и ручной IP из диалога. Дедупликация по URI.
var
URIs: array of string;
i, k: Integer;
U: string;
begin
SetLength(URIs, 0);
for i := 0 to FDeviceStore.SavedCount - 1 do
if FDeviceStore.Saved(i).Kind = bkPluto then
begin
U := FDeviceStore.Saved(i).URI;
if U = '' then Continue;
for k := 0 to High(URIs) do
if SameText(URIs[k], U) then begin U := ''; Break; end;
if U = '' then Continue;
SetLength(URIs, Length(URIs) + 1);
URIs[High(URIs)] := U;
end;
SetPlutoProbeURIs(URIs);
end;
procedure TRadioController.OnDeviceFound(const Dev: THPSDRDevice);
// Сетевой/scan-поток: строим строку для списка → pending → маршалинг в
// ApplyDeviceFound. Строка зависит от типа бэкенда (HPSDR vs Pluto).
begin
FPendingFoundDev := Dev;
if Dev.Kind = bkPluto then
FPendingFoundEntry := Format('PlutoSDR %s', [Dev.URI])
else
FPendingFoundEntry := Format('%s %s FW:%d DDC:%d',
[Dev.IPAddress, BoardTypeName(Dev.BoardType),
Dev.FirmwareVersion, Dev.NumDDCs]);
Invoke(ApplyDeviceFound);
end;
procedure TRadioController.ApplyDeviceFound;
// Маршалится из OnDeviceFound (Synchronize). Дописываем в store и уведомляем UI.
begin
FDiscoverNoneFound := False;
FDeviceStore.AddDiscovered(FPendingFoundDev.IPAddress, FPendingFoundEntry,
FPendingFoundDev.BoardType, FPendingFoundDev.MAC,
FPendingFoundDev.Kind, FPendingFoundDev.URI,
FPendingFoundDev.Serial);
Changed(rfDeviceList);
end;
procedure TRadioController.DiscoverFinishedNone;
// Маршалится из discover-потока, когда устройств не найдено.
begin
FDiscoverNoneFound := True;
Changed(rfDeviceList);
end;
procedure TRadioController.Discover;
// Старт поиска: чистим прошлый список, сбрасываем direct-IP, запускаем поток.
begin
FDeviceStore.ClearDiscovered;
FDiscoverNoneFound := False;
FNetwork.DirectIP := '';
Changed(rfDeviceList);
TDiscoverThread.Create(Self);
end;
end.