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, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; // Панадаптеры (этап 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, rfSMeter, rfPower, rfSWR, rfSupply, rfPLL, // телеметрия rfADCOverload, // ADC overload (рендер индикатора) rfBeaconLock, // QO-100 beacon lock: статус/коррекция rfDevice, // подключённое устройство сменилось rfDeviceList // список discovered устройств обновился ); 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; // Сырьё спектра/водопада наружу (фронтенды рисуют). Вызывается из DSP-потока. TPixelDataEvent = procedure(const Pixels: array of Single; Count: Integer) 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 DeviceIndex: Integer; // PortAudio-индекс (-1 = default) DevName: string; Audio: TAudioOutput; // владеет; свой поток вывода DMR: TDMRSliceDecoder; // владеет; nil для не-DMR слайса 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: децимация+рендер) procedure Changed(Field: TRadioField); // --- Мультислайсы --- 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; // слайс молчит на передаче? 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) 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..1023] of Single; FWaterfallBuf: array[0..1023] of Single; FSpectrumBufCount: Integer; FWaterfallBufCount: Integer; // ---- Движки (владение: см. шапку юнита) ---- // 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 слайсов FSettings: TSettingsManager; FChannelStore: TChannelStore; FDeviceStore: TDeviceStore; // общий список устройств (saved+discovered) FWDSPReady: Boolean; // ---- Частоты / режим / фильтр / AGC ---- FVfoA, FVfoB: Double; FActiveVfo: Integer; FMode: Integer; FFilter: Integer; FFilterBW: Integer; 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 из последнего снимка декодера (индикация лока) 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 // ---- 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 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 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; FAlexSettings: TAlexSettings; // Антенные разъёмы 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; // ---- Колбэки движков (вызываются из рабочих потоков сети/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; // Режим передающего тракта: слайс-источник 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 function PSOutlierSigmaEff: Double; // σ outlier-фильтра calcc (авто по плате) // ---- Управление диапазонами (состояние + 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; // ---- 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); // 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); class function AGCModeFromUI(M: Integer): TWDSPAGCMode; // UI-индекс 0..4 → WDSP enum 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; // первый созданный слайс пана 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); function SliceCount: Integer; function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; // Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс. procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0); function SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean; // ---- Панадаптеры на аппаратных 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; 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 // ---- 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; // Сохраняет устройство по введённому адресу. Схема 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); // Пост-тюн хук: вызывается в КОНЦЕ 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 / микрофон TX). Демон ставит False: аудио // только через web (OnAudioConsume), локальная звуковая карта не трогается. property LocalAudioEnabled: Boolean read FLocalAudio write FLocalAudio; 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) // Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны). FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0; FMode := MODE_USB; FFilter := 5; FFilterBW := 2700; FAGCMode := 1; FAGCTop := 90; 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; FSpectrumBufCount := 1024; FWaterfallBufCount := 1024; 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.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); // Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не // зависит от фронтенда, в отличие от OnAudio/OnSpectrum). FDSPEngine.OnSliceAudio := OnSliceAudioReady; FDSPEngine.OnSliceDemodAudio := OnSliceDemodAudioReady; FDSPEngine.OnDemodAudio := OnDemodAudioReady; // Калибровка уровня спектра/водопада на диапазон/трансвертер — движок // спрашивает её раз в кадр (сам он про диапазоны не знает). FDSPEngine.OnDispCal := DispCalFor; FDSPEngine.SetDisplayResLimit(FDisplayPixels); // 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; 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].Active := False; end; 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 FreeEngines; FreeAndNil(FDeviceStore); FreeAndNil(FSettings); 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.PushNetworkState; // Полный HP-кадр в радио: RX/TX частоты (через трансвертор), drive, состояние // MOX. Вызывается командами, которые меняют центр/частоту/репитер-сдвиг. begin if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then begin FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, FTransmitting, True, True); 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; 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; 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; // 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; if FSendAudioToRadio then FNetwork.SendSpeakerAudio(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 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. if FLocalAudio and Assigned(FAudioOut) then FAudioOut.Write(Left, Right, Count); end else if Assigned(FDMRDec) and FDMRDec.Enabled then FDMRDec.SetEnabled(False); 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); 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 if not FLocalAudio then Exit; idx := FindSliceIndex(SliceId); if idx < 0 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; 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) and FLocalAudio and Assigned(FSlices[idx].Audio) then FSlices[idx].Audio.Write(Left, Right, Count); 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 not FLocalAudio or (Count <= 0) then Exit; idx := FindSliceIndex(SliceId); if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) or FSlices[idx].Muted or not Assigned(FSlices[idx].Audio) then Exit; if SliceMutedByTx(idx) 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; 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; { ---- Панадаптеры на аппаратных 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; 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; // нет свободных слотов 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; FSlices[slot].DeviceIndex := DevIndex; FSlices[slot].DevName := DevName; FSlices[slot].Audio := AO; FSlices[slot].DMR := nil; if Mode = MODE_DMR then FSlices[slot].DMR := TDMRSliceDecoder.Create(Self, Id); 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; FSlices[idx].Active := False; Changed(rfDevice); end; procedure TRadioController.SetSliceTarget(Id: Integer; TargetHz: Double); var idx: Integer; begin idx := FindSliceIndex(Id); if idx < 0 then Exit; FSlices[idx].TargetHz := TargetHz; if Assigned(FDSPEngine) then FDSPEngine.SetSliceShift(Id, TargetHz - PanCenterHz(FSlices[idx].PanId)); 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); 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); 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); 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); 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); 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; procedure TRadioController.SetSliceDSP(Id, NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); var idx: Integer; begin idx := FindSliceIndex(Id); if idx < 0 then Exit; if Assigned(FDSPEngine) then begin FDSPEngine.SetSliceNRMode(Id, NRMode); FDSPEngine.SetSliceNBMode(Id, NBMode); FDSPEngine.SetSliceSNB(Id, SNBOn); FDSPEngine.SetSliceANF(Id, ANFOn); end; 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); 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; 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.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; // Снимок кадра для web-зеркала: протокол фиксирован на 1024 точки, поэтому // при большем разрешении анализатора децимируем max-детектором (не обрезаем). procedure SnapshotPixels(const Pixels: array of Single; Count: Integer; var Dst: array of Single; out DstCount: Integer); var i, j, S0, S1, N: Integer; V: Single; begin N := Length(Dst); if Count > Length(Pixels) then Count := Length(Pixels); if Count <= N then begin for i := 0 to Count - 1 do Dst[i] := Pixels[i]; DstCount := Count; Exit; end; for i := 0 to N - 1 do begin S0 := Int64(i) * Count div N; S1 := (Int64(i) + 1) * Count div N - 1; V := Pixels[S0]; for j := S0 + 1 to S1 do if Pixels[j] > V then V := Pixels[j]; Dst[i] := V; end; DstCount := N; end; procedure TRadioController.OnSpectrumReady(const Pixels: array of Single; Count: Integer); // DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу // (UI рисует FSpecView). Децимации у спектра нет. begin SnapshotPixels(Pixels, Count, FSpectrumBuf, FSpectrumBufCount); if Assigned(FOnSpectrumData) then FOnSpectrumData(Pixels, Count); end; procedure TRadioController.OnWaterfallReady(const Pixels: array of Single; Count: Integer); // DSP-поток: web-зеркало получает КАЖДЫЙ кадр; децимация рендера водопада — // забота фронтенда (OnWaterfallData). begin SnapshotPixels(Pixels, Count, FWaterfallBuf, FWaterfallBufCount); 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; Buf: array[0..2047] of Double; begin Result := 0; if (FAudioIn = nil) or (not FAudioIn.IsOpen) then Exit; n := FAudioIn.Available; if n > MaxN then n := MaxN; if n > Length(Buf) then n := Length(Buf); for i := 0 to n - 1 do Buf[i] := FAudioIn.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; var S: TCtrlSlice; begin // Мультислайс-TX: если выбран слайс-источник — передаём на его частоте // (слайсы не несут repeater-конфиг, FM-сдвиг не применяем). if (FTxSliceId > 0) and GetSlice(FTxSliceId, S) then Exit(S.TargetHz); // TX-VFO задаётся ТОЛЬКО селектором FSplitTxB (UI: кнопки TX-A/TX-B), // независимо от активного RX-VFO. Раньше при не-split TX ошибочно следовал // за активным RX (TX-A горит, но передача шла на B при активном B). if FSplitTxB then Result := FVfoB else Result := FVfoA; if FMode = MODE_FM then case FFMRptDir of RPT_MINUS: Result := Result - FFMRptOffsetHz; RPT_PLUS: Result := Result + FFMRptOffsetHz; end; 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; // Передающий тракт под режим нового источника (idle-канал, применится к PTT). if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetTXMode(ActiveTXMode); 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; // гистерезис: лок гаснет ниже этого 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; 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; 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; 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: 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); // Солидный лок: декодер несёт несущую И SNR выше порога (гистерезис против мерцания). if S.CarrierLock and (FBeaconSNRdB >= BEACON_LOCK_SNR_DB) then FBeaconLocked := True else if FBeaconSNRdB < BEACON_UNLOCK_SNR_DB 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; 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); var Half: Integer; begin Half := BW div 2; case Mode of MODE_LSB: begin Lo := -BW; Hi := -100; end; MODE_USB: begin Lo := 100; Hi := BW; end; // DIGI: полоса от нуля — поднесущие цифровых видов начинаются низко, // срез на 100 Гц как в SSB съел бы их. MODE_DIGL: begin Lo := -BW; Hi := 0; end; MODE_DIGU: begin Lo := 0; Hi := BW; end; else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM/WFM end; 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 FilterEdgesFor(FMode, FFilterBW, Lo, Hi); if FWDSPReady and Assigned(FDSPEngine) then begin FDSPEngine.SetFilter(Lo, Hi); FDSPEngine.SetTXFilter(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, прочие — дефолт. procedure TRadioController.ApplyModeDefaults; begin case FMode of MODE_FM: begin if FFilter > 1 then FFilter := FILT_FM_DEF; FFilterBW := FILT_FM_BW[FFilter]; FFMDeviation := FILT_FM_DEV[FFilter]; end; MODE_WFM: begin if FFilter > FILT_WFM_COUNT - 1 then FFilter := FILT_WFM_DEF; FFilterBW := FILT_WFM_BW[FFilter]; end; MODE_DMR: begin FFilter := 0; FFilterBW := FILT_DMR_BW; FFMDeviation := DMR_FM_DEV; end; MODE_FMRAW: begin if FFilter > FILT_RAW_COUNT - 1 then FFilter := FILT_RAW_DEF; FFilterBW := FILT_RAW_BW[FFilter]; end; MODE_LSB, MODE_USB: begin FFilter := FILT_SSB_DEF; FFilterBW := FILT_SSB_BW[FFilter]; end; MODE_DIGU, MODE_DIGL: begin FFilter := FILT_DIGI_DEF; FFilterBW := FILT_DIGI_BW[FFilter]; end; MODE_DSB: begin FFilter := FILT_DSB_DEF; FFilterBW := FILT_DSB_BW[FFilter]; end; MODE_CWL, MODE_CWU: begin FFilter := FILT_CW_DEF; FFilterBW := FILT_CW_BW[FFilter]; end; else begin FFilter := FILT_AM_DEF; FFilterBW := FILT_AM_BW[FFilter]; end; end; end; function TRadioController.FilterBWFor(M, Idx: Integer): Integer; // Полоса выбранного фильтра под режим. Зеркалит выбор таблицы в UpdateFilterButtons, // но НЕ меняет индекс (в отличие от ApplyModeDefaults) — нужно для XVTR-restore. begin case M of MODE_FM: Result := FILT_FM_BW[EnsureRange(Idx, 0, FILT_FM_COUNT - 1)]; MODE_WFM: Result := FILT_WFM_BW[EnsureRange(Idx, 0, FILT_WFM_COUNT - 1)]; MODE_DMR: Result := FILT_DMR_BW; MODE_FMRAW: Result := FILT_RAW_BW[EnsureRange(Idx, 0, FILT_RAW_COUNT - 1)]; MODE_LSB, MODE_USB: Result := FILT_SSB_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; MODE_DIGU, MODE_DIGL: Result := FILT_DIGI_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; MODE_DSB: Result := FILT_DSB_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; MODE_CWL, MODE_CWU: Result := FILT_CW_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; else Result := FILT_AM_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; 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 if XvtrActive and FXvtrSettings.UseXVTRTunePower then Pos := EnsureRange(FXvtrSettings.Entries[FCurrentXvtr].TXPower, 0, 100) else Pos := EnsureRange(FTXSettings.TUNLevel, 0, 100); 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 индексы фильтров только 0/1 (NFM/WFM); приводим FFilter и // синхронизируем FFilterBW/FFMDeviation. Чистая (state) часть UpdateFilterButtons. begin if FMode = MODE_FM then begin if FFilter > 1 then FFilter := FILT_FM_DEF; FFilterBW := FILT_FM_BW[FFilter]; FFMDeviation := FILT_FM_DEV[FFilter]; end; if FMode = MODE_DMR then begin FFilter := 0; FFilterBW := FILT_DMR_BW; FFMDeviation := DMR_FM_DEV; end; if FMode = MODE_FMRAW then begin if FFilter > FILT_RAW_COUNT - 1 then FFilter := FILT_RAW_DEF; FFilterBW := FILT_RAW_BW[FFilter]; end; 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.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 — как в оригинале) --- FFilter := B.FilterIdx; FFilterBW := B.FilterBW; SanitizeFilterForMode; 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); // --- 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; 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; // дефолтный фильтр/девиация под режим FBandCache[FCurrentBand].Mode := FMode; 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; FDSPEngine.SetTXMicSource(DefaultMicSource); if Assigned(FAudioIn) and FAudioIn.IsOpen then FAudioIn.Flush; end; // Если TUN активен — пересчитываем знак частоты под новый режим if FTuning then FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG); end; ApplyModeFilter; Changed(rfMode); end; procedure TRadioController.SetFilterIdx(Idx: Integer); begin // Клампим индекс под текущий режим (как FilterBWFor) — публичная команда, // Idx может прийти из CAT/web без проверки; FM/WFM-таблицы короче остальных. case FMode of MODE_FM: Idx := EnsureRange(Idx, 0, FILT_FM_COUNT - 1); MODE_WFM: Idx := EnsureRange(Idx, 0, FILT_WFM_COUNT - 1); MODE_DMR: Idx := 0; MODE_FMRAW: Idx := EnsureRange(Idx, 0, FILT_RAW_COUNT - 1); else Idx := EnsureRange(Idx, 0, FILT_COUNT - 1); end; FFilter := Idx; case FMode of MODE_LSB, MODE_USB: FFilterBW := FILT_SSB_BW[Idx]; MODE_DIGU, MODE_DIGL: FFilterBW := FILT_DIGI_BW[Idx]; MODE_DSB: FFilterBW := FILT_DSB_BW[Idx]; MODE_CWL, MODE_CWU: FFilterBW := FILT_CW_BW[Idx]; MODE_WFM: FFilterBW := FILT_WFM_BW[Idx]; MODE_DMR: FFilterBW := FILT_DMR_BW; MODE_FMRAW: FFilterBW := FILT_RAW_BW[Idx]; MODE_FM: begin FFilterBW := FILT_FM_BW[Idx]; FFMDeviation := FILT_FM_DEV[Idx]; end; else FFilterBW := FILT_AM_BW[Idx]; end; FBandCache[FCurrentBand].FilterBW := FFilterBW; ApplyModeFilter; Changed(rfFilter); end; procedure TRadioController.SetFilterBW(BWHz: Integer); // Непрерывная полоса (web-ползунок): применяем запрошенный BW как есть, а // FFilter подбираем ближайшим пресетом — только для подсветки кнопки. var BW_arr: array[0..FILT_COUNT-1] of Integer; best, dist, i: Integer; begin if FMode = MODE_DMR then BWHz := FILT_DMR_BW; FFilterBW := BWHz; for i := 0 to FILT_COUNT-1 do BW_arr[i] := MaxInt; case FMode of MODE_LSB, MODE_USB: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_SSB_BW[i]; MODE_DIGU, MODE_DIGL: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_DIGI_BW[i]; MODE_DSB: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_DSB_BW[i]; MODE_CWL, MODE_CWU: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_CW_BW[i]; MODE_FM: for i := 0 to FILT_FM_COUNT-1 do BW_arr[i] := FILT_FM_BW[i]; MODE_WFM: for i := 0 to FILT_WFM_COUNT-1 do BW_arr[i] := FILT_WFM_BW[i]; MODE_DMR: BW_arr[0] := FILT_DMR_BW; MODE_FMRAW: for i := 0 to FILT_RAW_COUNT-1 do BW_arr[i] := FILT_RAW_BW[i]; else for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_AM_BW[i]; end; best := 0; dist := MaxInt; for i := 0 to FILT_COUNT-1 do if Abs(BW_arr[i] - BWHz) < dist then begin dist := Abs(BW_arr[i] - BWHz); best := i; end; FFilter := best; if FMode = MODE_FM then FFMDeviation := FILT_FM_DEV[best]; FBandCache[FCurrentBand].FilterBW := FFilterBW; ApplyModeFilter; Changed(rfFilter); 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; 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; 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; // ре-применяем режим с новым уровнем 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); Changed(rfVolume); end; procedure TRadioController.VolumeBy(Delta: Integer); begin SetVolume(ActiveVolume + Delta); 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; 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; на приёме — макс. аттенюация (выкл). if FTransmitting or FTuning 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 FNetwork.SetXvtrMode(En, DisablePA, RxAnt); 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_; 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; begin if FAudioInDevName <> '' 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; procedure TRadioController.SendDUCSpecificFromSettings; var DUCPkt: TDUCSpecificPacket; RateKsps: Word; begin if not (FNetwork.Connected and FNetwork.Running) then Exit; FillChar(DUCPkt, SizeOf(DUCPkt), 0); // DUC0 rate в ksps берём из WDSP-движка (фиксированный TX rate, не RX). RateKsps := FDSPEngine.TXSampleRate div 1000; DUCPkt.NumDACs := 1; DUCPkt.SidetoneLevel := 50; DUCPkt.SidetoneFreqHi := Hi(600); DUCPkt.SidetoneFreqLo := Lo(600); DUCPkt.KeyerSpeed := 20; DUCPkt.KeyerWeight := 50; DUCPkt.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; FNetwork.SendDUCSpecific(DUCPkt); 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: блокируем TX на бэндах с DoNotTx (Alex) и на RX-only трансвертере. if On_ and FDevConnected and (FCurrentXvtr < 0) and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then Exit; if On_ and (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and FXvtrSettings.Entries[FCurrentXvtr].RXOnly then Exit; WasTransmitting := FTransmitting; 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), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, True, True, True); 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) 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 then begin FWebMicActive := False; FDSPEngine.SetTXMicSource(DefaultMicSource); end; // Сброс бэклога sound-card микрофона на RX→TX: пока шёл приём, ринг // PortAudio наполнился до упора (~200мс). Без сброса весь этот «старый» // звук бёрстом уходит в тракт: стоячая задержка ~190мс в DUC-очереди // + в эфир передаётся аудио, записанное ДО нажатия PTT (на TUN оно // складывается с тоном поверх полной шкалы → клип-всплеск). if FTransmitting and (not WasTransmitting) and Assigned(FAudioIn) and FAudioIn.IsOpen then FAudioIn.Flush; // В non-DUP RX-IQ пакеты дропаются на входе DSP-потока во время TX, чтобы // TX leakage не накапливался в RXA pipeline / FFT-истории анализатора. FDSPEngine.KeepRXDuringTX := FDisplayDuplex; // Мультислайс-TX: передающий тракт под режим слайса-источника (SetMode мог // оставить главный режим). Ставим перед стартом TX-потока. if FTransmitting then FDSPEngine.SetTXMode(ActiveTXMode); // PureSignal: mox-флаг calcc (гейтит сбор данных калибровки в pscc). FDSPEngine.PSSetMox(FTransmitting); 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 тянется 200–500мс). 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), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, False, True, True); FNetwork.ClearDUCIQQueue; FNetwork.SendFullHP; // 2й пакет: Alex bit27=0 (T/R relay RX) end; ApplyPlutoTxAtten; // Pluto: TX-мощность по drive на передаче, OFF на приёме ApplyActiveVolume; // T/R: переключаем громкость RX ↔ TX-монитор (если monitoring) 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 FDevConnected and (FCurrentXvtr < 0) and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then Exit; if On_ and (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and FXvtrSettings.Entries[FCurrentXvtr].RXOnly then Exit; if On_ and FTwoTone then SetTwoTone(False); // TUN поверх 2TON — гасим тона if On_ then begin // Тон включаем ДО запуска TX-аудио, чтобы первые сэмплы уже шли с тоном. if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetTXTone(True, FTXSettings.TUNFreq, TUN_TONE_MAG); 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), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, FTransmitting, True, True); FNetwork.SendFullHP; end; end; ApplyPlutoTxAtten; // финальное состояние att по FTuning/FTransmitting (Pluto) Changed(rfTuning); 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 FDevConnected and (FCurrentXvtr < 0) and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then Exit; if On_ and (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and FXvtrSettings.Entries[FCurrentXvtr].RXOnly then Exit; if On_ 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 отправляем ПОСЛЕ, иначе порты ещё не открыты. FNetwork.UpdateState(XvtrTranslate(FCenterFreq), XvtrTranslateTX(ActiveTXFreqHz), FDriveLevel, False, True, True); FNetwork.SetRunAndFreq(True, XvtrTranslate(FCenterFreq), XvtrTranslate(FCenterFreq), 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; SendDUCSpecificFromSettings; // Применяем 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), XvtrTranslateTX(ActiveTXFreqHz), 0, False, True, True); FNetwork.SetRunAndFreq(False, XvtrTranslate(FCenterFreq), XvtrTranslate(FCenterFreq), 0); FRunning := False; FTransmitting := False; // Стоп при активном 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; Exit; end; 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; begin // Подбираем бэкенд под тип устройства (HPSDR/Pluto) до подключения. EnsureBackend(Dev.Kind); Result := FNetwork.Connect(Dev); if not Result then Exit; // --- Загружаем настройки устройства по 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.LoadAlex(FDevMAC, FAlexSettings); FSettings.LoadAnt936x(FDevMAC, FAnt936x); FNetwork.SetAlexConfig(FAlexSettings); 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); // Клампим 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); 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 begin FAudioIn.DeviceIndex := FAudioIn.FindDeviceByName(FLoadedGlobal.AudioInDevice); FAudioInDevName := FLoadedGlobal.AudioInDevice; FAudioIn.Open; end else begin FAudioIn.DeviceIndex := -1; FAudioInDevName := ''; end; 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); // 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.