Files
ewsdr/Settings.pas
T
ew8bakandClaude Opus 5 79132f133c fix(tci): рекордер линейного выхода — память под потолком, файл только в своём каталоге
Два дефекта уровня P1 в LINE_OUT_RECORDER_*. Авторизации в протоколе нет
(§3.1), bind наружу разрешён — значит «сколько стоит одна строка из сети»
это вопрос живучести процесса, а не аккуратности.

1. Неограниченное выделение памяти. CmdRecorder проверял только ValidRx
   (номер в потолке), а конструктор выделял буфер целиком: 300 с × 48 кГц
   × 2 канала × int16 = 57.6 МБ на команду, приёмников 1+MAX_SLICES=7, то
   есть 403 МБ семью строками. У мёртвого приёмника Feed не зовут — значит
   срок записи никто не проверял; уход клиента рекордеры не трогал вовсе;
   DropDeadRxStreams бежит только на rfDevice/rfConnected и смене карты
   слайсов, в покое не срабатывает. Память жила до остановки сервера.

   Лечение тремя замками:
   - память набирается кусками по секунде, START не стоит ни байта;
     куски не перевыделяются (никакого realloc в DSP-потоке) и склеиваются
     один раз в Take — уже после того, как рекордер вынут из таблицы;
   - общий бюджет TCI_RECORD_MAX_BYTES (128 МБ) на все рекордеры сразу,
     спрашивается на каждый кусок; отказ не рушит запись, набранное
     остаётся сохраняемым;
   - освобождение по трём событиям: START требует живого приёмника
     (RxActive, ответ receiver is not running), тик сервера подметает
     истёкшие окна по часам (SweepRecorders — окно закрывается от START
     и без единого блока звука), уход клиента забирает его записи
     (DropClientRecorders; рекордер живёт на приёмнике, но платит за него
     тот, кто нажал START).

2. Перезапись произвольного файла. Путь из сети уходил в fmCreate почти
   как пришёл — вместе с '..' и абсолютными путями. Теперь TCIRecordPath
   берёт из строки ТОЛЬКО имя файла, каталог — настроенный tci.record_dir
   (пусто = <каталог конфигурации>/records). Каталог из просьбы
   отбрасывается молча: полный путь на сервере клиенту всё равно
   бесполезен, файл ложится не на его машину. Имя валидируется (пусто,
   '.', '..', управляющие, ':', длиннее 120, расширение не .wav →
   bad file name); после ExtractFileName выйти за каталог нечем. Файл
   создаётся эксклюзивно (TCICreateNewFile: O_EXCL|O_NOFOLLOW на Unix,
   CREATE_NEW на Windows) — ни перезаписи, ни симлинка, без окна между
   FileExists и созданием; клиенту заранее file exists.

Попутно: MainForm.ApplyTCISettings собирал TTCISettings по полям с
чистого листа — новое поле RecordDir обнулялось бы при каждом применении
вкладки CAT. В uses TCIStreams Windows стоит первым намеренно: иначе его
TCriticalSection перекрыл бы SyncObjs (та же грабля, что в DX-кластере).

Стенд 189/189 (было 169): 11 проверок имени файла (/etc/passwd.wav,
../../.., D|\rec\a.wav), бюджет (START не выделяет, растёт кусками,
потолок, отказ не рушит запись, срок истекает без Feed), существующий WAV
не перезаписывается, START на мёртвом приёмнике и с чужим номером, и
сквозная проверка в части E — клиент стартует запись, набирает память
живым звуком через WDSP, рвёт TCP, бюджет возвращается к нулю. Без фиксов
новые проверки краснеют. GUI (--ws=qt6) и демон зелёные.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:30:36 +03:00

3235 lines
156 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit Settings;
{
Settings.pas — JSON-конфигурация HPSDR SDR приложения.
Структура hpsdr_settings.json:
{ "AA:BB:CC:DD:EE:FF": { "global": {...}, "bands": {"5": {...}} } }
}
{$mode objfpc}{$H+}
interface
uses SysUtils, Classes, Math, fpJSON, jsonparser, jsonscanner, PlatformUtils,
RadioModes;
const
SETTINGS_FILE = 'hpsdr_settings.json';
CFG_BAND_COUNT = 12;
CFG_XVTR_COUNT = 8; // количество XVTR-слотов (трансверторов)
OC_PIN_COUNT = 7; // количество Open Collector выходов (Penny/Alex)
// TX pin action (byte-константы, как TXPinActions в Thetis Penny.cs):
// при каком состоянии передачи пин активен.
OCA_MOX = 0; // только обычный TX (не TUNE, не 2TON)
OCA_TUNE = 1; // только TUNE
OCA_TWOTONE = 2; // только 2TON
OCA_MOX_TUNE = 3; // TX или TUNE
OCA_MOX_TWOTONE = 4; // TX или 2TON
OCA_TUNE_TWOTONE = 5; // TUNE или 2TON
OCA_MOX_TUNE_TWOTONE = 6; // любое из трёх (дефолт)
CFG_MODE_MIN = RadioModes.MODE_MIN;
CFG_MODE_MAX = RadioModes.MODE_MAX;
CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS
CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES
CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090)
CW_MSG_COUNT = 8; // ячеек памяти телеграфных сообщений (F1..F8)
TXPROF_MAX = 16; // потолок именованных TX-профилей на устройство
QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100
// (split-LO транспондер; настраивается на
// отдельной странице, не в общей XVTR-таблице)
// PA gain defaults (dB) — match Thetis HERMES/HPSDR defaults
// order: 160m,80m,60m,40m,30m,20m,17m,15m,12m,10m,6m
// 12-й слот — пустой в HF-плане (используется только 12-местным VHF/UHF-планом
// Pluto, где частота/режим приходят из плана BoardUtils, а не отсюда).
CFG_PA_BAND_CAL_DEFAULT: array[0..CFG_BAND_COUNT-1] of Double = (
41.0, 41.2, 41.3, 41.3, 41.0, 40.5, 39.9, 38.8, 38.8, 38.8, 38.8, 38.8);
CFG_VHF_CAL_DEFAULT = 56.2; // dB, default for all XVTR slots
CFG_BAND_DEFAULT_FREQ: array[0..CFG_BAND_COUNT-1] of Double = (
1900000, 3750000, 5357000, 7100000, 10125000,
14200000, 18120000, 21200000, 24940000, 28500000, 50150000, 50150000);
CFG_BAND_DEFAULT_MODE: array[0..CFG_BAND_COUNT-1] of Integer = (
0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1);
// Полосы фильтров по типу модуляции (Гц) + дефолтные индексы. Нужны
// TRadioController для headless-выбора фильтра при смене режима; UI-подписи
// (FILT_*_NAMES) остаются в MainForm. NAMES-массивы там используют FILT_COUNT.
FILT_COUNT = 10;
FILT_SSB_BW: array[0..FILT_COUNT-1] of Integer =
(5000, 4400, 3800, 3300, 2900, 2700, 2400, 2100, 1800, 1000);
FILT_SSB_DEF = 5; // 2.7k
FILT_CW_BW: array[0..FILT_COUNT-1] of Integer =
(1000, 800, 600, 500, 400, 250, 150, 100, 50, 25);
FILT_CW_DEF = 3; // 500
FILT_DSB_BW: array[0..FILT_COUNT-1] of Integer =
(16000, 12000, 10000, 8000, 6600, 5200, 4000, 3100, 2900, 2400);
FILT_DSB_DEF = 3; // 8.0k
FILT_AM_BW: array[0..FILT_COUNT-1] of Integer =
(20000, 18000, 16000, 12000, 10000, 9000, 8000, 7000, 6000, 5000);
FILT_AM_DEF = 4; // 10k
// DIGI: полоса считается от нуля (кромки 0..BW), поэтому таблица своя —
// SSB-полосы отмерены от среза на 100 Гц и здесь не подходят.
FILT_DIGI_BW: array[0..FILT_COUNT-1] of Integer =
(3000, 2700, 2400, 2100, 1800, 1500, 1200, 900, 600, 300);
FILT_DIGI_DEF = 0; // 3.0k
FILT_FM_COUNT = 2;
FILT_FM_BW: array[0..1] of Integer = (11000, 16000);
FILT_FM_DEV: array[0..1] of Double = (2500.0, 5000.0);
FILT_FM_DEF = 0; // NFM
// WFM: вещательный канал. 200 кГц требует SampleRate 384k — при 192k
// WDSPEngine.SetFilter сам зажмёт кромки по Найквисту dsp_rate.
FILT_WFM_COUNT = 3;
FILT_WFM_BW: array[0..2] of Integer = (200000, 160000, 120000);
FILT_WFM_DEF = 1; // 160k — влезает и в 192k
// FM RAW: narrow, flat discriminator output for external digital decoders.
// These are RF channel widths; baseband stays flat at 20..7000 Hz.
FILT_RAW_COUNT = 5;
FILT_RAW_BW: array[0..FILT_RAW_COUNT-1] of Integer =
(6250, 10000, 12500, 20000, 25000);
FILT_RAW_DEF = 2; // 12.5 kHz
RAW_FM_DEV_DEFAULT = 2500;
RAW_FM_DEV_MIN = 500;
RAW_FM_DEV_MAX = 10000;
// DMR Tier II: 12.5 kHz RF channel, 4FSK nominal deviation +/-1.944 kHz.
FILT_DMR_BW = 12500;
DMR_FM_DEV = 2500;
// UI-подписи пресетов. Раньше жили в MainForm — переехали сюда, потому что
// из них строится заводская таблица фильтров (DefaultFilterSet).
FILT_SSB_NAMES: array[0..FILT_COUNT-1] of string =
('5.0k','4.4k','3.8k','3.3k','2.9k','2.7k','2.4k','2.1k','1.8k','1.0k');
FILT_CW_NAMES: array[0..FILT_COUNT-1] of string =
('1.0k','800','600','500','400','250','150','100','50','25');
FILT_DSB_NAMES: array[0..FILT_COUNT-1] of string =
('16k','12k','10k','8.0k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k');
FILT_AM_NAMES: array[0..FILT_COUNT-1] of string =
('20k','18k','16k','12k','10k','9.0k','8.0k','7.0k','6.0k','5.0k');
FILT_DIGI_NAMES: array[0..FILT_COUNT-1] of string =
('3.0k','2.7k','2.4k','2.1k','1.8k','1.5k','1.2k','900','600','300');
FILT_FM_NAMES: array[0..1] of string = ('NFM', 'FM');
FILT_WFM_NAMES: array[0..FILT_WFM_COUNT-1] of string = ('200k', '160k', '120k');
FILT_RAW_NAMES: array[0..FILT_RAW_COUNT-1] of string =
('6.25k', '10k', '12.5k', '20k', '25k');
// --- Редактируемая таблица фильтров -------------------------------------
// 10 пресетов + VAR1/VAR2 на режим (паритет с Thetis FilterForm). Кромки
// ЗНАКОВЫЕ и хранятся ПО РЕЖИМУ: у LSB своя строка (-2800..-100), у USB
// своя — поэтому при применении фильтра конвертировать боковую больше не
// нужно, и таблица знака в проекте остаётся ровно одна (FilterEdgesFromBW,
// ниже) — только для постройки заводских дефолтов.
FILT_VAR1 = FILT_COUNT; // 10
FILT_VAR2 = FILT_COUNT + 1; // 11
FILT_SLOTS = FILT_COUNT + 2; // 12 — сетка кнопок 2 ряда по 6
// Минимальная ширина полосы: ниже неё фильтр звенит, а перетаскивание края
// мышью легко схлопнуло бы полосу в ноль.
FILT_MIN_WIDTH = 50;
type
TFilterSlot = record
Name: string;
Lo, Hi: Integer; // знаковые кромки в Гц относительно несущей
end;
TFilterSet = array[0..FILT_SLOTS-1] of TFilterSlot; // на режим
TFilterTable = array[0..CFG_MODE_MAX] of TFilterSet; // по режиму
TBandSettings = record
VfoA: Double;
VfoB: Double;
Mode: Integer;
FilterIdx: Integer;
// FilterIdx — слот в таблице фильтров режима (см. TFilterSet); сами
// кромки живут в таблице, а не здесь, поэтому диапазон помнит только
// выбор. FilterBW остался легаси-полем для чтения старых конфигов.
FilterBW: Integer;
AGCMode: Integer;
AGCTop: Integer;
CTun: Boolean;
CenterHz: Double; // DDC-центр при CTun (восстановление offset VFO в полосе)
SpanHz: Double;
FMSQOn: Boolean;
FMSQLevel: Integer; // 0..100 → WDSP threshold 0.0..0.5
CTCSSOn: Boolean;
CTCSSToneIdx: Integer; // 0..37
FMStepOn: Boolean;
FMStepIdx: Integer; // 0..3 (6.25/12.5/20/25 kHz)
FMRptDir: Integer; // RPT_NONE/RPT_MINUS/RPT_PLUS
FMRptOffsetHz: Double; // Hz, 0 = use band default
ZoomFactor: Double; // зум спектра/водопада 0.0..1.0 (0.0 = без зума)
PanSlider: Double; // положение окна зума 0..1 (0.5 = центр)
// Аппаратный AGC AD936x (Pluto/LibreSDR) — на диапазон: усиление тракта
// сильно разное на 2м и на QO-100, поэтому режим и manual-гейн per-band.
RxGainMode: Integer; // 0=manual,1=fast,2=slow,3=hybrid
RxGainDb: Integer; // manual gain, дБ (0..73)
// Шаговый аттенюатор ADC (openHPSDR) — на диапазон: на 40м ночью нужен,
// на 10м нет. 0..31 дБ.
AttenDB: Integer;
end;
// Персист панадаптеров и слайсов (секция "pans" под MAC). Восстанавливается
// один раз при выходе в Run. Слайс = приёмный WDSP-канал внутри пана; частоты
// абсолютные (видимые). См. [[project_slices_plan]].
// Привязка «режим → индекс TX-профиля» (-1 = не переключать). Такая таблица
// есть у каждого флага: у главного в TTXProfileList, у слайса в TSliceCfg.
TTXProfModeTable = array[0..CFG_MODE_MAX] of Integer;
TSliceCfg = record
TargetHz: Double; // абсолютная видимая частота слайса
Mode: Integer;
FilterLo: Integer;
FilterHi: Integer;
AGC: Integer; // Ord(TWDSPAGCMode)
Volume: Double; // 0..1
Muted: Boolean;
RxMuteOnTx: Boolean; // True = слайс молчит на передаче (self-monitor выкл)
FMSQOn: Boolean;
FMSQLevel: Integer;
DevName: string; // имя PortAudio-устройства вывода ('' = default)
InDevName: string; // имя устройства ВВОДА слайса ('' = общий вход)
// TX-профиль слайса ПО РЕЖИМУ: -1 = какой активен, тот и звучит. Таблица,
// а не одно значение: слайс на FT8 и слайс голосом помнят каждый своё.
TXProfByMode: TTXProfModeTable;
end;
TPanCfg = record
Active: Boolean; // пан 1..N существует (пан 0 всегда есть)
FreqHz: Double; // видимый центр DDC (только паны N)
RateKHz: Integer;
ADC: Integer;
Zoom: Double; // 0..1
PanSlider: Double; // 0..1
StackShare: Double; // доля высоты в стеке панов
ColShare: Double; // доля ширины в грид-раскладке
SplitRatio: Double; // доля высоты спектра (сплиттер спектр/водопад)
// Per-pan дисплей (3.6): override глобального рендера. HasDisplay=False ⇒
// пан наследует глобальный дефолт (поля игнорируются).
HasDisplay: Boolean;
DPalette: Integer; // 0=Classic/1=Inferno/2=Turbo
DGamma: Double;
DSpecRef: Double; // верх шкалы спектра, dBm
DSpecRange: Double; // диапазон шкалы, dB
DWfAGC: Boolean; // авто-уровень водопада
DWfOffset: Double; // яркость водопада при AGC, dB
DWfHigh: Double; // ручной верх водопада, dBm
DWfLow: Double; // ручной низ водопада, dBm
Slices: array of TSliceCfg;
end;
TPansConfig = record
GridMode: Boolean; // грид-раскладка (▦) vs вертикальный стек (▤)
GridRowShare: Double; // доля высоты пана 0 в гриде
Pans: array[0..CFG_PANS_MAX-1] of TPanCfg; // [0]=главный (только Slices)
end;
// TX-настройки per-device. Хранятся в JSON-секции "tx" под MAC,
// отдельно от global/bands. Применяются к WDSP через SetTX* методы.
TTXSettings = record
// Radio mic options (DUC Specific byte 50/51)
MicLineIn: Boolean; // bit0: 1=Line In, 0=Mic In
MicBoost: Boolean; // bit1: +20 dB preamp (только когда Mic In)
MicPTTEnabled: Boolean; // bit2: Mic PTT enable (Orion)
MicTipRing: Boolean; // bit3: False=Tip, True=Ring (Orion/OrionMkII)
MicBias: Boolean; // bit4: электретный +5V bias (Orion)
LineInGainDB: Double; // byte51: -34.5..+12 dB (шаг 1.5; только когда Line In)
// Mic gain (dB, применяется в WDSP PanelGain1)
MicGainDB: Double; // -10..+30
// ATT on TX — ослабление RX-ADC во время передачи (защита от утечки TX).
// DUC Specific bytes 57/58/59 (StepAtten 0/1/2). Thetis default: 31 dB.
AttOnTX: Integer; // 0..31 dB
// Tune (кнопка TUN) — генерация тонального сигнала через WDSP PostGen.
// Уровень — отдельный от слайдера Drive, проходит через ту же
// per-band калибровку. Thetis defaults: level=10 %, freq=750 Hz.
TUNLevel: Integer; // 0..100 (% от полной шкалы Drive)
TUNFreq: Integer; // Hz, обычно 600..1500 для SSB tune
// TX bandpass filter
FilterLow: Integer; // Hz, для SSB обычно 100
FilterHigh: Integer; // Hz, для SSB обычно 2800
FilterNC: Integer; // FIR length (1024/2048/4096)
FilterMP: Integer; // 0=Linear Phase, 1=Low Latency
FilterWindow: Integer; // 0=Rect..6=BH7
// Compressor
CompressorOn: Boolean;
CompressorGain: Double; // 0..20 dB
// Leveler (TX AGC)
LevelerOn: Boolean;
LevelerTop: Double; // 0..10 dB
LevelerDecay: Integer; // ms
// ALC (Auto Level Control)
ALCOn: Boolean;
ALCMaxGain: Double; // -10..+10 dB
ALCDecay: Integer; // ms
// Phase Rotator
PhaseRotOn: Boolean;
PhaseRotStages: Integer; // 4/6/8
PhaseRotFreq: Double; // Hz, обычно 338
// EQ TX (10-band: [0]=preamp, [1..10]=bands)
EQOn: Boolean;
EQNumBands: Integer; // 3 или 10
EQGains: array[0..10] of Double; // dB, ±15
EQFreqs: array[0..10] of Double; // Hz
// AM modulator
AMCarrierLevel: Double; // 0..1
// FM modulator
FMDeviation: Double; // Hz, 2500..5000
FMLowCut: Integer; // AF filter low
FMHighCut: Integer; // AF filter high
FMEmphPosition: Integer; // 0=PreEQ, 1=PostEQ
// CTCSS
CTCSSOn: Boolean;
CTCSSFreq: Double; // Hz
// TX Display (отдельный analyzer для TX-спектра/водопада)
TXFFTSize: Integer; // 4096..262144
TXWindowType: Integer; // 0..6
TXSpecDetector: Integer; // 0=Peak,1=Rosenfell,2=Average,3=Sample,4=RMS
TXSpecAvgMode: Integer; // 0=None,1=Recursive,2=TimeWindow,3=LogRecursive
TXSpecAvgTimeMS: Double;
TXWfDetector: Integer;
TXWfAvgMode: Integer;
TXWfAvgTimeMS: Double;
// TX Grid (отдельные от RX — Thetis отрисовывает спектр TX в своих границах)
TXSpecRefLevel: Double; // dBm reference (top of scale), e.g. 0
TXSpecRange: Double; // dB range, e.g. 80
TXSpecGridStep: Double; // grid step in dB, e.g. 10
// PureSignal (WDSP calcc; дефолты = Thetis PSForm, протокол 2)
PSMoxDelay: Double; // с — задержка после MOX до сбора feedback (0.2)
PSLoopDelay: Double; // с — пауза между итерациями калибровки (0)
PSTXDelayNs: Integer; // нс — компенсация задержки TX-тракта (150)
PSRelaxPtol: Boolean; // ослабленный допуск фазы (ptol 0.4 вместо 0.8)
PSAutoAtten: Boolean; // авто-подстройка AttOnTX по уровню feedback
PSPin: Boolean; // pin mode (True)
PSMap: Boolean; // map mode (True)
PSStabilize: Boolean; // stabilize (False)
PSIntsIdx: Integer; // 0=16/256, 1=8/512, 2=4/1024
PSHWPeak: Double; // SetPk (0.2899 для P2, 0.6121 Saturn)
PSTTFreq1: Integer; // Гц — тон 1 двухтональника (700)
PSTTFreq2: Integer; // Гц — тон 2 двухтональника (1900)
// Калибровка фидбек-тракта (Thetis-Enhanced eu2av): на Orion MkII цель 152
// перегружает входные каскады ADC → IMD-«юбка», оптимум FB≈22 при ATT≈10дБ.
PSFBTarget: Integer; // цель уровня feedback info[4]; 0 = авто по плате
// (Orion MkII → 22, иначе 152)
PSOutlierSigma: Double; // σ outlier-фильтра calcc; <0 = авто по плате
// (Orion MkII → 5.0, иначе выкл.), 0 = выкл.
end;
// ---- CW ------------------------------------------------------------------
// Телеграф в openHPSDR P2 делает ПРОШИВКА: точки/тире формирует кейер в FPGA,
// мы только отдаём ему параметры (DUC Specific байты 5..13, 17). Поэтому
// почти всё здесь — свойство устройства, а не «звука», и живёт отдельно от
// TTXSettings/TTXProfile (профиль голоса телеграф не переключает).
// Раскладка байта 5 — HPSDRProtocol.CW_*; эталон Thetis (console.cs/setup.cs)
// и pihpsdr (new_protocol.c:1353).
TCWSettings = record
Pitch: Integer; // Гц, тон приёма/сайдтона (Thetis default 600)
// Память сообщений для программной передачи (окно CW Messages, F1..F8).
Messages: array[0..CW_MSG_COUNT-1] of string[63];
// Манипуляция разрешена вообще (False = телеграф без кейера: MOX + голосом
// ключа нет). ВМЕСТЕ с LocalKeyer образует одну развилку «кто рисует точки»,
// в UI это один список: прошивка / программа / выкл.
FWKeyer: Boolean;
KeyerMode: Integer; // 0=Straight, 1=Iambic A, 2=Iambic B
ReverseKeys: Boolean; // поменять точку и тире местами
StrictSpacing: Boolean; // строгие межзнаковые интервалы
Speed: Integer; // WPM, 5..60
Weight: Integer; // 33..66 (50 = точка:пауза 1:1)
BreakIn: Boolean; // прошивка сама поднимает PTT на нажатие ключа
HangTimeMS: Integer; // сколько держать TX после последнего элемента
RampMS: Integer; // форма фронта посылки (Thetis зашил 9 мс)
RFDelayMS: Integer; // задержка РЧ после PTT (byte 13, реле/усилитель)
SidetoneHW: Boolean; // тон в наушники САМОГО трансивера (байт 5/6)
SidetoneHWLevel: Integer; // 0..127
// Программный сайдтон — свой генератор в аудиотракте PC. Нужен тем, кто
// слушает через звуковую карту: аппаратный тон туда физически не попадает
// (он подмешивается в наушники трансивера, а не в IQ-поток).
// ★Точен там, где момент нажатия знает САМ PC: программная передача текста
// (CWX) и прямой ключ. При иамбике таймингом владеет FPGA, и PC видит лишь
// состояние лепестков манипулятора — там верен только аппаратный тон.
SidetoneSW: Boolean;
SidetoneSWLevel: Integer; // 0..100 % от полной шкалы аудио
// ---- Локальный (программный) кейер --------------------------------------
// Манипуляцию формирует САМ EWSDR: генератор рисует несущую с огибающей
// прямо в поток IQ (CWKeyer.pas). Для Pluto/LibreSDR это единственный
// возможный путь — кейера в железе там нет, и выбор «прошивка» вырождается
// туда сам; для openHPSDR это опция, по умолчанию выключенная (тайминги в
// FPGA точнее любой программы).
LocalKeyer: Boolean;
// Сдвиг несущей манипуляции от гетеродина. 0 = авто: у openHPSDR 0 (DUC —
// цифровой, утекать нечему), у zero-IF AD936x — CW_AUTO_OFFSET_HZ, иначе
// утечка LO сидит РОВНО на рабочей частоте и слышна между посылками.
CarrierOffsetHz: Integer;
// Манипулятор на модемных линиях COM/USB-serial (DTR/RTS питают ключ,
// CTS/DSR/DCD/RI читаются как лепестки).
KeyPortEnabled: Boolean;
KeyPort: string[63];
KeyDotLine: Integer; // 0=CTS, 1=DSR, 2=DCD, 3=RI
KeyDashLine: Integer;
KeyInvert: Boolean;
KeyPowerDTR: Boolean;
KeyPowerRTS: Boolean;
// ---- Окно-терминал: набор с клавиатуры + декодер приёма ----------------
Decoder: Boolean; // разбирать принимаемый телеграф в текст
DecoderStrip: Boolean; // бегущая лента расшифровки под спектром
KbdKey: Boolean; // Ctrl в окне терминала работает манипулятором
end;
// ---- TX-профили (именованные снимки «как я звучу») ----------------------
// Профиль хранит ТОЛЬКО то, что определяет звук в эфире: маршрутизацию
// микрофонного входа, усиление, кромки TX-фильтра, динамическую обработку,
// эквалайзер, уровень несущей AM и мощность. Всё остальное из TTXSettings
// (FIR-длина/фаза/окно фильтра, ATT on TX, FM/CTCSS, параметры анализатора
// и сетки, калибровка PureSignal) — свойство устройства, от профиля НЕ
// зависит: переключение профиля не должно перерисовывать спектр, менять
// задержку тракта или ломать калибровку PS.
// Разбиение — по образцу таблицы TXProfile у Thetis (database.cs), но без
// её хвоста из VAC/DSP-буферов, которых у нас нет.
TTXProfile = record
Name: string[31];
// Микрофонный вход (гарнитура / микшер-ESSB — физически разные входы)
MicLineIn: Boolean;
MicBoost: Boolean;
MicPTTEnabled: Boolean;
MicTipRing: Boolean;
MicBias: Boolean;
LineInGainDB: Double;
MicGainDB: Double;
// Кромки TX-фильтра (FIR-длина/фаза/окно остаются у устройства)
FilterLow: Integer;
FilterHigh: Integer;
// Динамическая обработка
CompressorOn: Boolean;
CompressorGain: Double;
LevelerOn: Boolean;
LevelerTop: Double;
LevelerDecay: Integer;
ALCOn: Boolean;
ALCMaxGain: Double;
ALCDecay: Integer;
PhaseRotOn: Boolean;
PhaseRotStages: Integer;
PhaseRotFreq: Double;
// Эквалайзер
EQOn: Boolean;
EQNumBands: Integer;
EQGains: array[0..10] of Double;
EQFreqs: array[0..10] of Double;
// Модуляция AM (несущая — часть «звука», в отличие от девиации FM)
AMCarrierLevel: Double;
// Мощность (Thetis держит Power/Tune_Power в профиле)
DrivePercent: Integer; // 0..100
TUNLevel: Integer; // 0..100
end;
TTXProfileList = record
Count: Integer;
ActiveIdx: Integer;
// Привязка профиля к ГЛАВНОМУ приёмнику (слайс A) ПО РЕЖИМУ; -1 = ничего
// не переключать. У слайсов B..G своя такая же в TSliceCfg.TXProfByMode.
// Таблица главного одна на устройство — общая для КВ и трансвертера
// (решение пользователя); у слайсов она сама разъезжается по контекстам,
// потому что их записи хранятся отдельно для КВ и трансвертера.
MainByMode: TTXProfModeTable;
Items: array[0..TXPROF_MAX-1] of TTXProfile;
end;
// Web-сервер — глобальные настройки (не привязаны к устройству, секция "web").
// DX-кластер: телнет-соединение + вид спотов на панадаптере.
// Хранится глобально (секция "dxcluster"), не per-device: кластер и позывной
// от железа не зависят.
TDXClusterSettings = record
Enabled: Boolean; // подключаться при старте
ShowSpots: Boolean; // рисовать оверлей на спектре
Host: string;
Port: Integer; // 1..65535
Login: string; // позывной для логина в кластер (он же «свой» в оверлее)
Password: string; // редкий кластер спрашивает — обычно пусто
PostLogin: string; // команды после логина (по строке; сюда же set/filter)
TTLMinutes: Integer; // сколько держать спот (он же горизонт затухания)
MaxRows: Integer; // рядов лесенки подписей (1..DXSPOT_MAX_ROWS)
// Фильтр по модам. Пустая маска = показывать все (в т.ч. неопознанные).
// Биты соответствуют TDXMode: 1=CW,2=SSB,3=DIGI,4=FT8,5=FT4,6=RTTY,7=PSK,
// 8=FM,9=SSTV; бит 0 = споты без распознанной моды.
ModeMask: LongWord;
end;
TWebSettings = record
Enabled: Boolean;
Port: Integer; // 1..65535
BindAddr: string; // e.g. '0.0.0.0' (все интерфейсы)
User: string;
Pass: string;
// Потолок точек в кадре спектра/водопада для веб-клиента (1024/2048/4096).
// Реально уходит min(этот потолок, разрешение анализатора = ширина
// панадаптера). Трафик линеен: 4096 точек — это ~650 КБ/с на клиента
// (два потока по 20 кадров/с), 1024 — ~160 КБ/с.
SpecPixels: Integer;
end;
// TCI-сервер (протокол Expert Electronics поверх WebSocket). Авторизации в
// протоколе нет, поэтому умолчание слушает только петлю: логгеры и цифровые
// программы обычно живут на том же ПК, а наружу порт открывается осознанно.
TTCISettings = record
Enabled: Boolean;
Port: Integer; // 1..65535, умолчание 40001 (как у ExpertSDR3)
BindAddr: string; // '127.0.0.1' или '0.0.0.0'
// ★Единственный каталог, куда TCI пишет записи линейного выхода
// (LINE_OUT_RECORDER_SAVE). Пусто — <каталог конфигурации>/records.
// Имя файла клиент выбирает сам, но КАТАЛОГ из его строки не берётся
// никогда: авторизации в протоколе нет, и полный путь из сети означал бы
// запись в любой доступный процессу файл (см. TCIRecordPath).
RecordDir: string;
end;
// Transverter (XVTR) entry — один трансвертер.
// Видимая частота VFO в [FreqBegin..FreqEnd] транслируется в IF-частоту,
// которую слышит трансивер: f_IF = f_visible - LOOffset + LOError.
// Примеры: 2m XVTR с LO=116 MHz → visible 144.200 ⇒ IF 28.200 MHz.
TXvtrEntry = record
Enabled: Boolean;
ButtonText: string[15]; // подпись на band-кнопке (например, '2m', '70cm')
FreqBegin: Double; // Hz
FreqEnd: Double; // Hz
LOOffset: Double; // Hz, отнимается от видимой частоты для получения IF (RX)
LOError: Double; // Hz, корректировка LO (положительная — IF растёт) — RX/LNB дрейф
// QO-100 / split-LO транспондер: RX и TX идут на РАЗНЫХ LO. RX = visible-LOOffset
// (+LOError, дрейф LNB по маяку). TX = visible-TxLOOffset (без LOError — uplink-LO
// собственный у SDR). FullDuplexTx помечает слот как такой транспондер.
FullDuplexTx: Boolean; // True — раздельный TX-LO (QO-100); авто display-duplex
TxLOOffset: Double; // Hz, TX LO-offset (для QO-100 = транспондер-offset, ~8089.5 МГц)
// Hz, поправка к TX LO-offset (TO Error). Сам offset держим стандартным
// (8089.5 МГц у QO-100), а уход конкретного тракта правим здесь.
// Знак как у LOError: положительная — TX IF растёт.
TxLOError: Double;
RXGain: Double; // dB, компенсация усиления RX тракта
RXOnly: Boolean; // True — запрещает TX на этом трансверторе
TXPower: Integer; // 0..100 % (drive level специально для XVTR)
DisablePA: Boolean; // True — отключить внутренний PA при TX (XVTR имеет свой)
RXAntenna: Byte; // 0=по-умолчанию(основной band), 1=ANT1, 2=ANT2, 3=ANT3
LastFreq: Double; // Hz — последняя tuned visible частота VFO A (BandStack)
LastFreqB: Double; // Hz — последняя tuned visible частота VFO B
LastMode: Integer; // Mode (LSB=0,USB=1,…FM=5)
LastFilterIdx: Integer; // слот фильтра 0..FILT_SLOTS-1 (10/11 = VAR1/VAR2)
LastFMSQOn: Boolean;
LastFMSQLevel: Integer;
LastCTCSSOn: Boolean;
LastCTCSSToneIdx: Integer;
LastFMStepOn: Boolean;
LastFMStepIdx: Integer;
LastCTun: Boolean;
LastCenterHz: Double; // Hz — DDC-центр при CTun (восстановление offset VFO)
LastAGCMode: Integer;
LastAGCTop: Integer;
LastFMRptDir: Integer;
LastFMRptOffsetHz: Double;
LastCTCSSAutoActive: Boolean; // CTCSS был включён каналом (авто)
LastFMRptAutoActive: Boolean; // RPT был включён каналом (авто)
LastZoomFactor: Double; // зум спектра/водопада 0.0..1.0 (0.0 = без зума)
LastPanSlider: Double; // положение окна зума 0..1 (0.5 = центр)
// Аппаратный AGC AD936x на слот трансвертера (у QO-100 своё усиление тракта).
LastRxGainMode: Integer; // 0=manual,1=fast,2=slow,3=hybrid
LastRxGainDb: Integer; // manual gain, дБ
LastAttenDB: Integer; // шаговый аттенюатор ADC слота, дБ (0..31)
end;
TXvtrSettings = record
Entries: array[0..CFG_XVTR_COUNT-1] of TXvtrEntry;
// Use XVTR TUN power — кнопка TUN использует XVTR-specific power.
UseXVTRTunePower: Boolean;
end;
// Alex (Antenna + Filter board) settings per-device.
// Хранятся в JSON-секции "alex" под MAC.
// Безопасные дефолты: ANT1 для всех диапазонов, вторичный вход выключен.
// Антенные разъёмы AD936x (Pluto/LibreSDR): какой физический вход/выход
// используется на каждом диапазоне. 0 = RX1/TX1, 1 = RX2/TX2.
// Диапазоны — VU-план BoardUtils (12 слотов), плюс слоты трансвертеров.
TAD936xAnt = record
RxAnt: array[0..CFG_BAND_COUNT-1] of Byte;
TxAnt: array[0..CFG_BAND_COUNT-1] of Byte;
XvtrRxAnt: array[0..CFG_XVTR_COUNT-1] of Byte;
XvtrTxAnt: array[0..CFG_XVTR_COUNT-1] of Byte;
end;
TAlexSettings = record
// Per-band (11 bands: 0=160m..10=6m)
RxAnt: array[0..CFG_BAND_COUNT-1] of Byte; // 1..3 (ANT1/2/3)
TxAnt: array[0..CFG_BAND_COUNT-1] of Byte; // 1..3 (ANT1/2/3)
// Вторичный RX вход: 0=выкл, 1=BYPS(Ext1+Bypass), 2=EXT1(Ext2+Bypass), 3=XVTR(XvtrDDC+Bypass)
RxOnly: array[0..CFG_BAND_COUNT-1] of Byte;
DoNotTx: array[0..CFG_BAND_COUNT-1] of Boolean; // программный запрет TX на диапазоне
// Глобальные флаги в режиме TX
RxBypassOnTx: Boolean; // бит 11 (Bypass relay) во время TX
Ext1OnTx: Boolean; // бит 9 (Ext1) + бит 11 во время TX
GndBpf2OnTx: Boolean; // заземление BPF2/RX2 во время TX (Alex1 бит 8, ANAN-8000DLE)
EnableXvtrHf: Boolean; // режим XVTR HF
end;
// OC Control (Open Collector выходы Penny/Alex, byte 1401 HP packet).
// Только openHPSDR — у AD936x (Pluto/LibreSDR) такого выхода нет.
// Хранятся в JSON-секции "oc" под MAC. Портировано из Thetis
// (Console/setup.cs UpdateOCBits + Console/HPSDR/Penny.cs), объём сокращён:
// без PA-override/hot-switching внешнего PA (в EWSDR нет такой концепции
// на панели) и без split-pins VFO A/B (у EWSDR мультислайсы).
// HF-группа — 11 диапазонов (0=160m..10=6m, как в TAlexSettings).
// VHF-группа — 8 слотов XVTR (аналог Thetis VHF0-13, но по нашим слотам).
TOCPinActions = array[0..OC_PIN_COUNT-1] of Byte; // OCA_* на пин
TOCSettings = record
Enabled: Boolean;
RxMaskHF: array[0..CFG_BAND_COUNT-1] of Byte; // 7-бит маска, биты 0..6 = пины 1..7
TxMaskHF: array[0..CFG_BAND_COUNT-1] of Byte;
ActionHF: TOCPinActions; // для HF TX-маски
RxMaskXvtr: array[0..CFG_XVTR_COUNT-1] of Byte;
TxMaskXvtr: array[0..CFG_XVTR_COUNT-1] of Byte;
ActionXvtr: TOCPinActions; // для XVTR TX-маски
end;
// Калибровки (вкладка Calibration): детектор мощности/КСВ Alex-моста,
// напряжение/ток питания, S-метр. Модель детектора задаёт константы моста
// (Vref/bridge_volt/ADC-offset, как в Thetis computeAlexFwdPower).
TCalibration = record
PwrDetModel: Integer; // 0=ANAN-10/100 (3.3V), 1=ANAN-200D, 2=ANAN-7000DLE/Anvelina Pro 3, 3=ANAN-8000DLE/Orion MkII
PADetCal: array[0..CFG_BAND_COUNT-1] of Double; // множитель FWD/REV на банд, 0.50..2.00 (1.0 = без коррекции)
SupplyVoltCal: Double; // множитель напряжения питания (1.0 = без коррекции)
AmpVoffMV: Double; // сдвиг нуля датчика тока, мВ (Thetis default 360)
AmpSensMVA: Double; // чувствительность датчика тока, мВ/А (Thetis default 120)
// Офсет S-метра, дБ — раздельно на диапазон и на слот трансвертера (усиление
// тракта разное на 160м и на 10м, а у XVTR своё — конвертер/LNB). Смысл
// индекса диапазона задаёт band-план железа (HF у openHPSDR, VHF/UHF у
// Pluto); таблицы не пересекаются, т.к. секция JSON ключуется по MAC.
SMeterBandDB: array[0..CFG_BAND_COUNT-1] of Double;
SMeterXvtrDB: array[0..CFG_XVTR_COUNT-1] of Double;
// Офсет спектра/водопада, дБ — та же разбивка. Отдельно от S-метра (как
// Display Cal Offset в Thetis): у display-тракта своё окно/детектор, и его
// уровень отличается от показаний стрелки на пару дБ.
DispBandDB: array[0..CFG_BAND_COUNT-1] of Double;
DispXvtrDB: array[0..CFG_XVTR_COUNT-1] of Double;
// Частотная калибровка опорника, ppm (Frequency Calibration в Thetis).
// Смысл: «опора выше номинала на N ppm» ⇒ до правки сигналы показываются
// ниже истинной частоты; положительное значение поднимает показания.
// Действует на всё железо разом; ручные LOError/TO Error трансвертеров
// остаются сверху — тракт тракту рознь.
FreqCalPPM: Double;
end;
TGlobalSettings = record
Volume: Integer;
TxMonVolume: Integer; // громкость self-monitor RX-аудио на TX (RX MUTE off)
DriveLevel: Integer;
ActiveVfo: Integer;
NRMode: Integer;
NBMode: Integer;
SNBEnabled: Boolean;
ANFEnabled: Boolean;
AGCSlope: Integer;
AGCHangThreshold: Integer;
WfAGCEnabled: Boolean;
WfNFEnabled: Boolean;
LastBand: Integer;
LastXvtr: Integer; // -1 = HF, 0..CFG_XVTR_COUNT-1 = активный трансвертер при выходе
SampleRate: Integer; // глобальный — один для всех диапазонов
RxGainMode: Integer; // Pluto: 0=manual,1=fast,2=slow,3=hybrid
RxGainDb: Integer; // Pluto: manual gain дБ
WindowLeft: Integer;
WindowTop: Integer;
WindowWidth: Integer;
WindowHeight: Integer;
// --- Display settings ---
FFTSize: Integer; // 4096..262144
WindowType: Integer; // 0=Rect,1=BH4,2=Hann,3=FlatTop,4=Hamming,5=Kaiser,6=BH7
SpecDetector: Integer; // 0=Peak,1=Rosenfell,2=Average,3=Sample,4=RMS
SpecAvgMode: Integer; // 0=None,1=Recursive,2=TimeWindow,3=LogRecursive
SpecAvgTimeMS: Double; // averaging time in milliseconds
WfDetector: Integer; // waterfall detector
WfAvgMode: Integer; // waterfall average mode
WfAvgTimeMS: Double; // waterfall averaging time in milliseconds
WfManualHigh: Double; // manual waterfall high threshold
WfManualLow: Double; // manual waterfall low threshold
WfAGCOffset: Double; // AGC offset in dB
WfPalette: Integer; // 0=Classic, 1=Inferno, 2=Turbo
WfGamma: Double; // контраст палитры (0.3..3.0, 1.0 = линейно)
DisplayPixels: Integer; // потолок точек анализатора (1024/2048/4096)
WfSpeedDiv: Integer; // делитель скорости водопада (1/2/4/8, max-hold)
SpecMSAA: Integer; // MSAA GL-спектра (1=off, 2, 4, 8)
SpecRefLevel: Double; // dBm reference (top of scale), e.g. -20
SpecRange: Double; // dB range, e.g. 110 (bottom = ref - range)
SpecGridStep: Double; // grid step in dB, e.g. 10
// --- Лупа наведения на маяк QO-100 (карточка BEACON TUNING) ---
// Центр хранится СДВИГОМ от опорной частоты маяка, а не абсолютом: так
// окно переживает и правку опорной частоты, и калибровку LOError.
BeaconZoomSpanHz: Double; // ширина окна лупы, Гц
BeaconZoomOffsetHz: Double; // центр окна = BeaconRefHz + этот сдвиг
BeaconZoomFollow: Boolean; // держать наведение декодера в окне
BeaconZoomAuto: Boolean; // автопоиск маяка в окне лупы
// --- Audio settings ---
AudioOutDevice: string; // PortAudio output device name ('' = default)
AudioSampleRate: Integer; // PortAudio stream sample rate (e.g. 48000)
AudioInDevice: string; // PortAudio input device name for TX mic ('' = none)
SendAudioToRadio: Boolean; // True = send RX audio stream to radio (built-in speaker)
RawFMDeviation: Integer; // Hz, flat RAW FM discriminator scale
// --- Visibility settings ---
ShowSpectrum: Boolean; // True = draw spectrum on main form
ShowWaterfall: Boolean; // True = draw waterfall on main form
ShowWideband: Boolean; // True = draw raw ADC wideband pane on main form
WidebandFill: Boolean; // True = fill wideband spectrum with gradient
SpectrumFill: Boolean; // True = fill main spectrum under the curve with gradient
DisplayDuplex: Boolean; // DUP: при TX показывать RX-водопад, TX-фильтр overlay'ем
RxMuteOnTx: Boolean; // QO-100 self-monitor: True=RX-аудио глушится на TX (умолч.)
// --- Display FPS ---
DisplayFPS: Integer; // spectrum/waterfall timer fps (1..100)
// --- Theme ---
LightTheme: Boolean; // False = dark (default), True = light
// --- VFO display digits ---
FreqMhzDigits: Integer; // minimum MHz digit count: 3=999MHz, 4=9.999GHz, 5=99.999GHz
// --- PA (Power Amplifier) settings ---
PAMaxPower: Double; // максимальная выходная мощность, Вт (5..200)
PlutoTxMaxAttDb: Double; // Pluto: TX-аттенюация на drive=100%, дБ<0 (дефолт -10)
PABandCal: array[0..CFG_BAND_COUNT-1] of Double; // калибровка на диапазон 38.8..100.0
VHFBandCal: array[0..CFG_XVTR_COUNT-1] of Double; // калибровка для XVTR слотов 38.8..100.0
// --- Calibration (детектор мощности/КСВ, напряжение/ток, S-метр) ---
Cal: TCalibration;
// --- ADC settings ---
DitherEnabled: Boolean; // DDC Specific byte 5 — уменьшает интермодуляцию
RandomEnabled: Boolean; // DDC Specific byte 6 — рандомизация ADC
// --- CAT (Computer Aided Transceiver) settings ---
// До 4 последовательных портов (Linux: /dev/ttyS0, Windows: COM1 и т.д.)
CATSerialEnabled: array[0..3] of Boolean;
CATSerialPort: array[0..3] of string; // имя порта ОС
CATSerialBaud: array[0..3] of Integer; // скорость: 1200..115200
CATSerialDataBits: array[0..3] of Integer; // 7 или 8
CATSerialStopBits: array[0..3] of Integer; // 1 или 2
CATSerialParity: array[0..3] of Integer; // 0=None, 1=Odd, 2=Even
CATSerialAndromeda:array[0..3] of Boolean; // порт — передняя панель Andromeda/G2
// TCP CAT сервер (один, многоклиентский) — главный приёмник (слайс A)
CATTcpEnabled: Boolean;
CATTcpPort: Integer; // порт, по умолчанию 19090
// Слайс-CAT: свой TCP-порт на каждый ДОПОЛНИТЕЛЬНЫЙ слайс (B, C, …).
// Индекс = слот слайса (0 = слайс B), см. CFG_CAT_SLICE_COUNT.
CATSliceEnabled: array[0..CFG_CAT_SLICE_COUNT-1] of Boolean;
CATSlicePort: array[0..CFG_CAT_SLICE_COUNT-1] of Integer; // 19091, 19092, …
// Auto TX (аналог «Auto Switch TX Slice» в SmartSDR CAT): команда TX по
// CAT-порту слайса сама назначает этот слайс источником передачи.
CATSliceAutoTX: array[0..CFG_CAT_SLICE_COUNT-1] of Boolean;
// Аудио слота слайса: имена PortAudio-устройств ('' = общий выход
// приложения / общий вход). Имена, а не индексы: порядок перечисления
// устройств меняется от запуска к запуску.
CATSliceOutDev: array[0..CFG_CAT_SLICE_COUNT-1] of string;
CATSliceInDev: array[0..CFG_CAT_SLICE_COUNT-1] of string;
end;
TSettingsManager = class
private
FFilePath: string;
FRoot: TJSONObject;
function EnsureObj(P: TJSONObject; const K: string): TJSONObject;
function GetDevObj(const M: string): TJSONObject;
function GetGlobalObj(D: TJSONObject): TJSONObject;
function GetBandObj(D: TJSONObject; Idx: Integer): TJSONObject;
function JI(O: TJSONObject; const K: string; Def: Integer): Integer;
function JD(O: TJSONObject; const K: string; Def: Double): Double;
function JB(O: TJSONObject; const K: string; Def: Boolean): Boolean;
function JS(O: TJSONObject; const K: string; const Def: string): string;
procedure JW(O: TJSONObject; const K: string; V: Integer); overload;
procedure JW(O: TJSONObject; const K: string; V: Double); overload;
procedure JW(O: TJSONObject; const K: string; V: Boolean); overload;
procedure JWS(O: TJSONObject; const K: string; const V: string);
procedure MigrateXvtrTemplate(var X: TXvtrSettings;
const AName: string; ABegin, AEnd, ALO: Double;
AFullDup: Boolean = False; ATxLO: Double = 0.0);
public
constructor Create(const FilePath: string = '');
destructor Destroy; override;
procedure Load;
procedure Save;
function LoadDevice(const MAC: array of Byte;
out G: TGlobalSettings;
var Bands: array of TBandSettings): Boolean;
procedure SaveGlobal(const MAC: array of Byte; const G: TGlobalSettings);
procedure SaveBand(const MAC: array of Byte; BandIdx: Integer;
const B: TBandSettings);
function LoadBand(const MAC: array of Byte; BandIdx: Integer;
out B: TBandSettings): Boolean;
// TX settings per-device (JSON-секция "tx" под MAC).
// LoadTX возвращает True если устройство уже зарегистрировано в JSON;
// в любом случае T заполняется (дефолтами или сохранёнными значениями).
function LoadTX(const MAC: array of Byte; out T: TTXSettings): Boolean;
procedure SaveTX(const MAC: array of Byte; const T: TTXSettings);
// CW settings per-device (JSON-секция "cw" под MAC).
class procedure DefaultCW(out C: TCWSettings);
function LoadCW(const MAC: array of Byte; out C: TCWSettings): Boolean;
procedure SaveCW(const MAC: array of Byte; const C: TCWSettings);
class function MacToStr(const MAC: array of Byte): string;
class procedure DefaultBand(BandIdx: Integer; out B: TBandSettings);
class procedure DefaultGlobal(out G: TGlobalSettings);
class procedure DefaultCalibration(out C: TCalibration);
class procedure DefaultTX(out T: TTXSettings);
// Размер окна — не привязан к MAC, хранится в корне JSON
procedure SaveWindowBounds(L, T, W, H, DPI: Integer; Maximized: Boolean);
procedure LoadWindowBounds(out L, T, W, H, DPI: Integer; out Maximized: Boolean);
procedure SaveStartupPreview(VfoA, VfoB: Double; SampleRate: Integer);
function LoadStartupPreview(out VfoA, VfoB: Double; out SampleRate: Integer): Boolean;
procedure SaveAudioBufferSize(BufferSize: Integer);
function LoadAudioBufferSize: Integer;
// CAT settings — не привязаны к MAC, хранятся в корне JSON (доступны до подключения)
procedure SaveCATSettings(const G: TGlobalSettings);
procedure LoadCATSettings(var G: TGlobalSettings);
// Alex (antenna) settings per-device (JSON-секция "alex" под MAC).
class procedure DefaultAnt936x(out A: TAD936xAnt);
function LoadAnt936x(const MAC: array of Byte; out A: TAD936xAnt): Boolean;
procedure SaveAnt936x(const MAC: array of Byte; const A: TAD936xAnt);
class procedure DefaultAlex(out A: TAlexSettings);
function LoadAlex(const MAC: array of Byte; out A: TAlexSettings): Boolean;
procedure SaveAlex(const MAC: array of Byte; const A: TAlexSettings);
// OC Control settings per-device (JSON-секция "oc" под MAC).
class procedure DefaultOC(out O: TOCSettings);
function LoadOC(const MAC: array of Byte; out O: TOCSettings): Boolean;
procedure SaveOC(const MAC: array of Byte; const O: TOCSettings);
// XVTR (Transverter) settings per-device (JSON-секция "xvtr" под MAC).
class procedure DefaultXvtr(out X: TXvtrSettings);
function LoadXvtr(const MAC: array of Byte; out X: TXvtrSettings): Boolean;
procedure SaveXvtr(const MAC: array of Byte; const X: TXvtrSettings);
// Панадаптеры + слайсы per-device (JSON-секция "pans" под MAC). Ctx —
// ключ контекста ('hf' или 'xvtr_N'): КВ и каждый трансвертер раздельно.
class procedure DefaultPans(out P: TPansConfig);
function LoadPans(const MAC: array of Byte; const Ctx: string; out P: TPansConfig): Boolean;
procedure SavePans(const MAC: array of Byte; const Ctx: string; const P: TPansConfig);
// Web-сервер — глобальные настройки (секция "web" в корне JSON).
class procedure DefaultDXCluster(out D: TDXClusterSettings);
procedure LoadDXClusterSettings(out D: TDXClusterSettings);
procedure SaveDXClusterSettings(const D: TDXClusterSettings);
class procedure DefaultWeb(out W: TWebSettings);
procedure LoadWebSettings(out W: TWebSettings);
procedure SaveWebSettings(const W: TWebSettings);
// TCI-сервер — глобальные настройки (секция "tci" в корне JSON).
class procedure DefaultTCI(out T: TTCISettings);
procedure LoadTCISettings(out T: TTCISettings);
procedure SaveTCISettings(const T: TTCISettings);
// Тема — глобальная настройка, не привязана к устройству
procedure SaveTheme(ALightTheme: Boolean);
function LoadTheme: Boolean;
// Таблица фильтров per-device (JSON-секция "filters" под MAC). Пишутся
// только режимы, отличающиеся от заводских, — конфиг не пухнет.
function LoadFilters(const MAC: array of Byte; out T: TFilterTable): Boolean;
procedure SaveFilters(const MAC: array of Byte; const T: TFilterTable);
// TX-профили per-device (JSON-секция "tx_profiles" под MAC). LoadTXProfiles
// возвращает False, если секции ещё нет: вызывающий строит заводской набор
// из текущих TX-настроек (DefaultTXProfiles) — миграция старого конфига.
function LoadTXProfiles(const MAC: array of Byte; out L: TTXProfileList): Boolean;
procedure SaveTXProfiles(const MAC: array of Byte; const L: TTXProfileList);
end;
// ---- TX-профили ------------------------------------------------------------
// Снимок «звуковых» полей живых настроек в профиль (имя не трогается) и
// обратное применение. Единственные две точки, знающие состав профиля, —
// добавляя поле в TTXProfile, правь обе.
procedure TXProfileCapture(const T: TTXSettings; DrivePct: Integer; var P: TTXProfile);
procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: Integer);
// Заводской набор профилей. Base — текущие настройки устройства: из них
// наследуются микрофонный вход, мощность и всё, что профиль не переопределяет
// (иначе переключение на «ESSB» сбрасывало бы Mic In/Line In и drive).
procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
out L: TTXProfileList);
// Таблица «режим → профиль» ↔ JSON-массив. Все элементы вне 0..TXPROF_MAX-1
// читаются как -1 («не переключать»), поэтому чужой/старый конфиг безопасен.
procedure ClearTXProfModeTable(var T: TTXProfModeTable);
function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray;
procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable);
// Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа).
function FilterPresetCount(Mode: Integer): Integer;
// Заводской индекс фильтра режима.
function FilterDefIdx(Mode: Integer): Integer;
// Единственная в проекте таблица «магнитуда полосы → знаковые кромки».
// Нужна только для постройки заводских дефолтов и для чтения старых
// конфигов, где хранилась одна ширина.
procedure FilterEdgesFromBW(Mode, BW: Integer; out Lo, Hi: Integer);
// Обратное преобразование для легаси-читателей FFilterBW. У SSB/DIGI «полоса»
// исторически = внешняя кромка (2.7k при кромках 100..2700), у остальных
// режимов — полная ширина; здесь это воспроизведено один в один.
function FilterBWFromEdges(Mode, Lo, Hi: Integer): Integer;
function DefaultFilterSet(Mode: Integer): TFilterSet;
procedure DefaultFilterTable(out T: TFilterTable);
implementation
function FilterPresetCount(Mode: Integer): Integer;
begin
case Mode of
MODE_FM: Result := FILT_FM_COUNT;
MODE_WFM: Result := FILT_WFM_COUNT;
MODE_DMR: Result := 1;
MODE_FMRAW: Result := FILT_RAW_COUNT;
else Result := FILT_COUNT;
end;
end;
function FilterDefIdx(Mode: Integer): Integer;
begin
case Mode of
MODE_LSB, MODE_USB: Result := FILT_SSB_DEF;
MODE_DIGL, MODE_DIGU: Result := FILT_DIGI_DEF;
MODE_DSB: Result := FILT_DSB_DEF;
MODE_CWL, MODE_CWU: Result := FILT_CW_DEF;
MODE_FM: Result := FILT_FM_DEF;
MODE_WFM: Result := FILT_WFM_DEF;
MODE_DMR: Result := 0;
MODE_FMRAW: Result := FILT_RAW_DEF;
else Result := FILT_AM_DEF;
end;
end;
procedure FilterEdgesFromBW(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;
function FilterBWFromEdges(Mode, Lo, Hi: Integer): Integer;
begin
case Mode of
MODE_LSB, MODE_USB, MODE_DIGL, MODE_DIGU:
Result := Max(Abs(Lo), Abs(Hi));
else
Result := Hi - Lo;
end;
end;
function DefaultFilterSet(Mode: Integer): TFilterSet;
var i, n, bw: Integer;
begin
n := FilterPresetCount(Mode);
for i := 0 to FILT_SLOTS - 1 do
begin
Result[i].Name := '';
Result[i].Lo := 0;
Result[i].Hi := 0;
end;
bw := 0;
for i := 0 to n - 1 do
begin
case Mode of
MODE_LSB, MODE_USB: begin bw := FILT_SSB_BW[i]; Result[i].Name := FILT_SSB_NAMES[i]; end;
MODE_DIGL, MODE_DIGU: begin bw := FILT_DIGI_BW[i]; Result[i].Name := FILT_DIGI_NAMES[i]; end;
MODE_DSB: begin bw := FILT_DSB_BW[i]; Result[i].Name := FILT_DSB_NAMES[i]; end;
MODE_CWL, MODE_CWU: begin bw := FILT_CW_BW[i]; Result[i].Name := FILT_CW_NAMES[i]; end;
MODE_FM: begin bw := FILT_FM_BW[i]; Result[i].Name := FILT_FM_NAMES[i]; end;
MODE_WFM: begin bw := FILT_WFM_BW[i]; Result[i].Name := FILT_WFM_NAMES[i]; end;
MODE_DMR: begin bw := FILT_DMR_BW; Result[i].Name := '12.5k'; end;
MODE_FMRAW: begin bw := FILT_RAW_BW[i]; Result[i].Name := FILT_RAW_NAMES[i]; end;
else begin bw := FILT_AM_BW[i]; Result[i].Name := FILT_AM_NAMES[i]; end;
end;
FilterEdgesFromBW(Mode, bw, Result[i].Lo, Result[i].Hi);
end;
// VAR1/VAR2 стартуют копией заводского пресета режима — «чистый лист»,
// который не сдвигает звук, пока его не тронули.
Result[FILT_VAR1] := Result[FilterDefIdx(Mode)];
Result[FILT_VAR1].Name := 'VAR1';
Result[FILT_VAR2] := Result[FilterDefIdx(Mode)];
Result[FILT_VAR2].Name := 'VAR2';
end;
procedure DefaultFilterTable(out T: TFilterTable);
var m: Integer;
begin
for m := 0 to CFG_MODE_MAX do T[m] := DefaultFilterSet(m);
end;
function LegacyAverageTimeMS(FPS, AvgMode, AvgCount: Integer;
Backmult, DefaultMS: Double): Double;
var
FPSv: Integer;
begin
if AvgMode <= 0 then
Exit(0.0);
FPSv := EnsureRange(FPS, 1, 100);
if (Backmult > 0.0) and (Backmult < 1.0) then
Result := -1000.0 / (FPSv * Ln(Backmult))
else
Result := (1000.0 * Max(1, AvgCount)) / FPSv;
if IsNan(Result) or IsInfinite(Result) or (Result <= 0.0) then
Result := DefaultMS;
Result := EnsureRange(Result, 10.0, 5000.0);
end;
class function TSettingsManager.MacToStr(const MAC: array of Byte): string;
begin
Result := Format('%02X:%02X:%02X:%02X:%02X:%02X',
[MAC[0],MAC[1],MAC[2],MAC[3],MAC[4],MAC[5]]);
end;
class procedure TSettingsManager.DefaultBand(BandIdx: Integer; out B: TBandSettings);
begin
FillChar(B, SizeOf(B), 0);
B.VfoA := CFG_BAND_DEFAULT_FREQ[BandIdx];
B.VfoB := CFG_BAND_DEFAULT_FREQ[BandIdx];
B.Mode := CFG_BAND_DEFAULT_MODE[BandIdx];
B.FilterIdx := 5;
B.FilterBW := 2700;
B.AGCMode := 1;
B.AGCTop := 90;
B.CTun := False;
B.CenterHz := 0; // 0 = нет сохранённого центра → центрировать на VFO
B.SpanHz := 192000;
B.RxGainMode := 2; // slow_attack (как глобальный дефолт Pluto)
B.RxGainDb := 40;
B.AttenDB := 0;
B.FMSQOn := False;
B.FMSQLevel := 30;
B.CTCSSOn := False;
B.CTCSSToneIdx := 0;
B.FMStepOn := True;
B.FMStepIdx := 3; // 25 kHz
B.FMRptDir := 0;
B.FMRptOffsetHz := 0.0;
B.ZoomFactor := 0.0;
B.PanSlider := 0.5;
end;
class procedure TSettingsManager.DefaultGlobal(out G: TGlobalSettings);
var i: Integer;
begin
FillChar(G, SizeOf(G), 0);
G.Volume := 70; G.TxMonVolume := 40; G.DriveLevel := 50; G.ActiveVfo := 0;
G.NRMode := 0; G.NBMode := 0; G.SNBEnabled := False; G.ANFEnabled := False;
G.AGCSlope := 0; G.AGCHangThreshold := 100; G.LastBand := 5;
G.LastXvtr := -1;
G.WfAGCEnabled := True;
G.WfNFEnabled := False;
G.SampleRate := 192000;
G.RxGainMode := 2; G.RxGainDb := 40;
G.PAMaxPower := 100.0;
G.PlutoTxMaxAttDb := -10.0;
for i := 0 to CFG_BAND_COUNT-1 do G.PABandCal[i] := CFG_PA_BAND_CAL_DEFAULT[i];
for i := 0 to CFG_XVTR_COUNT-1 do G.VHFBandCal[i] := CFG_VHF_CAL_DEFAULT;
// Display defaults
G.FFTSize := 131072;
G.WindowType := 2; // Hann
G.SpecDetector := 0; // Peak
G.SpecAvgMode := 3; // Log Recursive
G.SpecAvgTimeMS := 30.0;
G.WfDetector := 0; // Peak
G.WfAvgMode := 3; // Log Recursive
G.WfAvgTimeMS := 120.0;
G.WfManualHigh := -80.0;
G.WfManualLow := -130.0;
G.WfAGCOffset := 0.0;
G.WfPalette := 0; // Classic
G.WfGamma := 1.0;
G.DisplayPixels := 4096; // 1:1 до 4K-ширины
G.WfSpeedDiv := 1; // полная скорость
G.SpecMSAA := 4; // сглаживание кривой спектра
G.SpecRefLevel := -20.0;
G.SpecRange := 110.0;
G.SpecGridStep := 10.0;
G.BeaconZoomSpanHz := 40000.0;
G.BeaconZoomOffsetHz := 0.0;
G.BeaconZoomFollow := True;
G.BeaconZoomAuto := False;
G.AudioOutDevice := '';
G.AudioSampleRate := 48000;
G.AudioInDevice := '';
G.SendAudioToRadio := False;
G.RawFMDeviation := RAW_FM_DEV_DEFAULT;
G.ShowSpectrum := True;
G.ShowWaterfall := True;
G.ShowWideband := False;
G.WidebandFill := False;
G.SpectrumFill := True;
G.DisplayDuplex := False;
G.RxMuteOnTx := True;
G.DisplayFPS := 60;
G.FreqMhzDigits := 3;
// CAT defaults
for i := 0 to 3 do begin
G.CATSerialEnabled[i] := False;
{$IFDEF MSWINDOWS}
G.CATSerialPort[i] := 'COM' + IntToStr(i+1);
{$ELSE}
G.CATSerialPort[i] := '/dev/ttyS' + IntToStr(i);
{$ENDIF}
G.CATSerialBaud[i] := 9600;
G.CATSerialDataBits[i] := 8;
G.CATSerialStopBits[i] := 1;
G.CATSerialParity[i] := 0; // None
G.CATSerialAndromeda[i]:= False;
end;
G.CATTcpEnabled := False;
G.CATTcpPort := 19090;
for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin
G.CATSliceEnabled[i] := False;
G.CATSlicePort[i] := CFG_CAT_SLICE_PORT0 + i;
G.CATSliceAutoTX[i] := False;
G.CATSliceOutDev[i] := '';
G.CATSliceInDev[i] := '';
end;
// ADC — включены по умолчанию: поддерживаются на Hermes, Angelia, Orion
G.DitherEnabled := True;
G.RandomEnabled := True;
DefaultCalibration(G.Cal);
end;
class procedure TSettingsManager.DefaultCalibration(out C: TCalibration);
var i: Integer;
begin
FillChar(C, SizeOf(C), 0);
C.PwrDetModel := 0; // ANAN-10/100 — совпадает с прежней зашитой формулой
for i := 0 to CFG_BAND_COUNT-1 do C.PADetCal[i] := 1.0;
C.SupplyVoltCal := 1.0;
C.AmpVoffMV := 360.0;
C.AmpSensMVA := 120.0;
// SMeterBandDB/SMeterXvtrDB — нули после FillChar (без коррекции).
end;
constructor TSettingsManager.Create(const FilePath: string);
begin
inherited Create;
if FilePath = '' then
FFilePath := GetAppCfgDir + SETTINGS_FILE
else
FFilePath := FilePath;
FRoot := TJSONObject.Create;
end;
destructor TSettingsManager.Destroy;
begin
FRoot.Free;
inherited;
end;
procedure TSettingsManager.Load;
var F: TFileStream; P: TJSONParser; D: TJSONData;
begin
if not FileExists(FFilePath) then Exit;
try
F := TFileStream.Create(FFilePath, fmOpenRead or fmShareDenyNone);
try
P := TJSONParser.Create(F, [joUTF8]);
try
D := P.Parse;
if (D <> nil) and (D is TJSONObject) then
begin FRoot.Free; FRoot := TJSONObject(D); end
else
D.Free;
finally P.Free; end;
finally F.Free; end;
except
FreeAndNil(FRoot);
FRoot := TJSONObject.Create;
end;
end;
procedure TSettingsManager.Save;
var
S: string;
F: TFileStream;
Buf: TBytes;
begin
try
S := FRoot.FormatJSON([], 2);
Buf := TEncoding.UTF8.GetBytes(S);
F := TFileStream.Create(FFilePath, fmCreate);
try
if Length(Buf) > 0 then
F.WriteBuffer(Buf[0], Length(Buf));
finally F.Free; end;
except end;
end;
function TSettingsManager.EnsureObj(P: TJSONObject; const K: string): TJSONObject;
var D: TJSONData; Idx: Integer;
begin
D := P.Find(K);
if (D <> nil) and (D is TJSONObject) then
Result := TJSONObject(D)
else
begin
if D <> nil then begin Idx := P.IndexOfName(K); if Idx >= 0 then P.Delete(Idx); end;
Result := TJSONObject.Create;
P.Add(K, Result);
end;
end;
function TSettingsManager.GetDevObj(const M: string): TJSONObject;
begin Result := EnsureObj(FRoot, M); end;
function TSettingsManager.GetGlobalObj(D: TJSONObject): TJSONObject;
begin Result := EnsureObj(D, 'global'); end;
function TSettingsManager.GetBandObj(D: TJSONObject; Idx: Integer): TJSONObject;
begin Result := EnsureObj(EnsureObj(D, 'bands'), IntToStr(Idx)); end;
function TSettingsManager.JI(O: TJSONObject; const K: string; Def: Integer): Integer;
var D: TJSONData;
begin D := O.Find(K); if D<>nil then try Result:=D.AsInteger; except Result:=Def; end else Result:=Def; end;
function TSettingsManager.JD(O: TJSONObject; const K: string; Def: Double): Double;
var D: TJSONData;
begin D := O.Find(K); if D<>nil then try Result:=D.AsFloat; except Result:=Def; end else Result:=Def; end;
function TSettingsManager.JB(O: TJSONObject; const K: string; Def: Boolean): Boolean;
var D: TJSONData;
begin D := O.Find(K); if D<>nil then try Result:=D.AsBoolean; except Result:=Def; end else Result:=Def; end;
procedure TSettingsManager.JW(O: TJSONObject; const K: string; V: Integer);
var Idx: Integer;
begin
Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx);
O.Add(K, V);
end;
procedure TSettingsManager.JW(O: TJSONObject; const K: string; V: Double);
var Idx: Integer;
begin
Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx);
O.Add(K, V);
end;
procedure TSettingsManager.JW(O: TJSONObject; const K: string; V: Boolean);
var Idx: Integer;
begin
Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx);
O.Add(K, V);
end;
function TSettingsManager.JS(O: TJSONObject; const K: string; const Def: string): string;
var D: TJSONData;
begin D := O.Find(K); if D<>nil then try Result:=D.AsString; except Result:=Def; end else Result:=Def; end;
procedure TSettingsManager.JWS(O: TJSONObject; const K: string; const V: string);
var Idx: Integer;
begin
Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx);
O.Add(K, V);
end;
function TSettingsManager.LoadDevice(const MAC: array of Byte;
out G: TGlobalSettings;
var Bands: array of TBandSettings): Boolean;
var MacStr: string; DevObj,GObj,BObj: TJSONObject; i: Integer; SMLegacy: Double;
begin
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
DevObj := GetDevObj(MacStr);
GObj := GetGlobalObj(DevObj);
G.Volume := JI(GObj,'volume',70);
G.TxMonVolume := JI(GObj,'tx_mon_volume',40);
G.DriveLevel := JI(GObj,'drive_level',50);
G.ActiveVfo := JI(GObj,'active_vfo',0);
G.NRMode := JI(GObj,'nr_mode',Ord(JB(GObj,'nr_enabled',False)));
G.NBMode := JI(GObj,'nb_mode',Ord(JB(GObj,'nb_enabled',False)));
G.SNBEnabled := JB(GObj,'snb_enabled',False);
G.ANFEnabled := JB(GObj,'anf_enabled',False);
G.AGCSlope := JI(GObj,'agc_slope',0);
G.AGCHangThreshold := JI(GObj,'agc_hang_threshold',100);
G.WfAGCEnabled := JB(GObj,'wf_agc_enabled',True);
G.WfNFEnabled := JB(GObj,'wf_nf_enabled',False);
G.LastBand := JI(GObj,'last_band',5);
G.LastXvtr := JI(GObj,'last_xvtr',-1);
G.SampleRate := JI(GObj,'sample_rate',192000);
G.RxGainMode := JI(GObj,'rx_gain_mode',2);
G.RxGainDb := JI(GObj,'rx_gain_db',40);
// Display settings
G.FFTSize := JI(GObj,'fft_size',131072);
G.WindowType := JI(GObj,'win_type',2);
G.SpecDetector := JI(GObj,'spec_detector',0);
G.SpecAvgMode := JI(GObj,'spec_avg_mode',3);
if GObj.Find('spec_avg_time_ms') <> nil then
G.SpecAvgTimeMS := JD(GObj,'spec_avg_time_ms',30.0)
else if (GObj.Find('spec_avg_count') <> nil) or
(GObj.Find('spec_backmult') <> nil) then
begin
G.SpecAvgTimeMS := LegacyAverageTimeMS(
JI(GObj,'display_fps',60),
G.SpecAvgMode,
JI(GObj,'spec_avg_count',1),
JD(GObj,'spec_backmult',0.0),
30.0);
end
else
G.SpecAvgTimeMS := 30.0;
G.WfDetector := JI(GObj,'wf_detector',0);
G.WfAvgMode := JI(GObj,'wf_avg_mode',3);
if GObj.Find('wf_avg_time_ms') <> nil then
G.WfAvgTimeMS := JD(GObj,'wf_avg_time_ms',120.0)
else if (GObj.Find('wf_avg_count') <> nil) or
(GObj.Find('wf_backmult') <> nil) then
begin
G.WfAvgTimeMS := LegacyAverageTimeMS(
JI(GObj,'display_fps',60),
G.WfAvgMode,
JI(GObj,'wf_avg_count',2),
JD(GObj,'wf_backmult',0.25),
120.0);
end
else
G.WfAvgTimeMS := 120.0;
G.WfManualHigh := JD(GObj,'wf_manual_high',-80.0);
G.WfManualLow := JD(GObj,'wf_manual_low',-130.0);
G.WfAGCOffset := JD(GObj,'wf_agc_offset',0.0);
G.WfPalette := JI(GObj,'wf_palette',0);
G.WfGamma := JD(GObj,'wf_gamma',1.0);
G.DisplayPixels := JI(GObj,'display_pixels',4096);
G.WfSpeedDiv := JI(GObj,'wf_speed_div',1);
G.SpecMSAA := JI(GObj,'spec_msaa',4);
G.SpecRefLevel := JD(GObj,'spec_ref_level',-20.0);
G.SpecRange := JD(GObj,'spec_range',110.0);
G.SpecGridStep := JD(GObj,'spec_grid_step',10.0);
G.BeaconZoomSpanHz := JD(GObj,'beacon_zoom_span',40000.0);
G.BeaconZoomOffsetHz := JD(GObj,'beacon_zoom_offset',0.0);
G.BeaconZoomFollow := JB(GObj,'beacon_zoom_follow',True);
G.BeaconZoomAuto := JB(GObj,'beacon_zoom_auto',False);
// Audio settings
G.AudioOutDevice := JS(GObj,'audio_out_device','');
G.AudioSampleRate := JI(GObj,'audio_sample_rate',48000);
G.AudioInDevice := JS(GObj,'audio_in_device','');
G.SendAudioToRadio := JB(GObj,'send_audio_to_radio',False);
G.RawFMDeviation := EnsureRange(
JI(GObj,'raw_fm_deviation',RAW_FM_DEV_DEFAULT),
RAW_FM_DEV_MIN, RAW_FM_DEV_MAX);
// Visibility settings
G.ShowSpectrum := JB(GObj,'show_spectrum',True);
G.ShowWaterfall := JB(GObj,'show_waterfall',True);
G.ShowWideband := JB(GObj,'show_wideband',False);
G.WidebandFill := JB(GObj,'wideband_fill',False);
G.SpectrumFill := JB(GObj,'spectrum_fill',True);
G.DisplayDuplex := JB(GObj,'display_duplex',False);
G.RxMuteOnTx := JB(GObj,'rx_mute_on_tx',True);
G.DisplayFPS := JI(GObj,'display_fps',60);
G.LightTheme := JB(GObj,'light_theme',False);
G.FreqMhzDigits := EnsureRange(JI(GObj,'freq_mhz_digits',3), 3, 5);
// PA settings
G.PAMaxPower := JD(GObj,'pa_max_power',100.0);
G.PlutoTxMaxAttDb := JD(GObj,'pluto_tx_max_att',-10.0);
for i := 0 to CFG_BAND_COUNT-1 do
G.PABandCal[i] := JD(GObj,'pa_band_cal_'+IntToStr(i), CFG_PA_BAND_CAL_DEFAULT[i]);
for i := 0 to CFG_XVTR_COUNT-1 do
G.VHFBandCal[i] := JD(GObj,'vhf_cal_'+IntToStr(i), CFG_VHF_CAL_DEFAULT);
// CAT serial settings
for i := 0 to 3 do begin
G.CATSerialEnabled[i] := JB(GObj,'cat_serial_en_'+IntToStr(i), False);
{$IFDEF MSWINDOWS}
G.CATSerialPort[i] := JS(GObj,'cat_serial_port_'+IntToStr(i), 'COM'+IntToStr(i+1));
{$ELSE}
G.CATSerialPort[i] := JS(GObj,'cat_serial_port_'+IntToStr(i), '/dev/ttyS'+IntToStr(i));
{$ENDIF}
G.CATSerialBaud[i] := JI(GObj,'cat_serial_baud_'+IntToStr(i), 9600);
G.CATSerialDataBits[i] := JI(GObj,'cat_serial_dbits_'+IntToStr(i), 8);
G.CATSerialStopBits[i] := JI(GObj,'cat_serial_sbits_'+IntToStr(i), 1);
G.CATSerialParity[i] := JI(GObj,'cat_serial_parity_'+IntToStr(i), 0);
G.CATSerialAndromeda[i]:= JB(GObj,'cat_serial_androm_'+IntToStr(i), False);
end;
for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin
G.CATSliceEnabled[i] := JB(GObj,'cat_slice_en_' +IntToStr(i), False);
G.CATSlicePort[i] := JI(GObj,'cat_slice_port_' +IntToStr(i), CFG_CAT_SLICE_PORT0 + i);
G.CATSliceAutoTX[i] := JB(GObj,'cat_slice_autotx_'+IntToStr(i), False);
G.CATSliceOutDev[i] := JS(GObj,'cat_slice_out_' +IntToStr(i), '');
G.CATSliceInDev[i] := JS(GObj,'cat_slice_in_' +IntToStr(i), '');
end;
G.CATTcpEnabled := JB(GObj,'cat_tcp_enabled', False);
G.CATTcpPort := JI(GObj,'cat_tcp_port', 19090);
// ADC settings
G.DitherEnabled := JB(GObj,'dither_enabled', True);
G.RandomEnabled := JB(GObj,'random_enabled', True);
// Calibration (детектор мощности/КСВ, напряжение/ток, S-метр)
G.Cal.PwrDetModel := EnsureRange(JI(GObj,'cal_pwr_det_model', 0), 0, 3);
for i := 0 to CFG_BAND_COUNT-1 do
G.Cal.PADetCal[i] := EnsureRange(JD(GObj,'cal_pa_det_'+IntToStr(i), 1.0), 0.5, 2.0);
G.Cal.SupplyVoltCal := EnsureRange(JD(GObj,'cal_supply_volt', 1.0), 0.1, 5.0);
G.Cal.AmpVoffMV := EnsureRange(JD(GObj,'cal_amp_voff_mv', 360.0), 0.0, 2000.0);
G.Cal.AmpSensMVA := EnsureRange(JD(GObj,'cal_amp_sens_mva', 120.0), 1.0, 1000.0);
// S-метр: офсет на диапазон/трансвертер. Легаси-ключ (одно значение на всё
// устройство) становится дефолтом всех слотов — показания не меняются.
SMLegacy := EnsureRange(JD(GObj,'cal_smeter_db', 0.0), -30.0, 30.0);
for i := 0 to CFG_BAND_COUNT-1 do
G.Cal.SMeterBandDB[i] :=
EnsureRange(JD(GObj,'cal_smeter_band_'+IntToStr(i), SMLegacy), -30.0, 30.0);
for i := 0 to CFG_XVTR_COUNT-1 do
G.Cal.SMeterXvtrDB[i] :=
EnsureRange(JD(GObj,'cal_smeter_xvtr_'+IntToStr(i), SMLegacy), -30.0, 30.0);
// Спектр/водопад: своя таблица офсетов (дефолт 0 — как было).
for i := 0 to CFG_BAND_COUNT-1 do
G.Cal.DispBandDB[i] :=
EnsureRange(JD(GObj,'cal_disp_band_'+IntToStr(i), 0.0), -30.0, 30.0);
for i := 0 to CFG_XVTR_COUNT-1 do
G.Cal.DispXvtrDB[i] :=
EnsureRange(JD(GObj,'cal_disp_xvtr_'+IntToStr(i), 0.0), -30.0, 30.0);
G.Cal.FreqCalPPM := EnsureRange(JD(GObj,'cal_freq_ppm', 0.0), -100.0, 100.0);
for i := 0 to CFG_BAND_COUNT-1 do
begin
DefaultBand(i, Bands[i]);
BObj := GetBandObj(DevObj, i);
Bands[i].VfoA := JD(BObj,'vfo_a', Bands[i].VfoA);
Bands[i].VfoB := JD(BObj,'vfo_b', Bands[i].VfoB);
Bands[i].Mode := EnsureRange(JI(BObj,'mode', Bands[i].Mode), CFG_MODE_MIN, CFG_MODE_MAX);
Bands[i].FilterIdx := JI(BObj,'filter_idx', Bands[i].FilterIdx);
Bands[i].FilterBW := JI(BObj,'filter_bw', Bands[i].FilterBW);
Bands[i].AGCMode := JI(BObj,'agc_mode', Bands[i].AGCMode);
Bands[i].AGCTop := JI(BObj,'agc_top', Bands[i].AGCTop);
Bands[i].CTun := JB(BObj,'ctun', Bands[i].CTun);
Bands[i].CenterHz := JD(BObj,'center_hz', Bands[i].CenterHz);
Bands[i].SpanHz := JD(BObj,'span_hz', Bands[i].SpanHz);
// FM squelch / CTCSS / step / repeater — как в LoadBand (раньше пропускались
// здесь → на старте сбрасывались в дефолт, хотя SaveBand их пишет).
Bands[i].FMSQOn := JB(BObj,'fmsq_on', Bands[i].FMSQOn);
Bands[i].FMSQLevel := JI(BObj,'fmsq_level', Bands[i].FMSQLevel);
Bands[i].CTCSSOn := JB(BObj,'ctcss_on', Bands[i].CTCSSOn);
Bands[i].CTCSSToneIdx := EnsureRange(JI(BObj,'ctcss_idx', Bands[i].CTCSSToneIdx), 0, 37);
Bands[i].FMStepOn := JB(BObj,'fmstep_on', Bands[i].FMStepOn);
Bands[i].FMStepIdx := EnsureRange(JI(BObj,'fmstep_idx', Bands[i].FMStepIdx), 0, 3);
Bands[i].FMRptDir := EnsureRange(JI(BObj,'fmrpt_dir', Bands[i].FMRptDir), 0, 2);
Bands[i].FMRptOffsetHz := JD(BObj,'fmrpt_offset', Bands[i].FMRptOffsetHz);
Bands[i].RxGainMode := EnsureRange(JI(BObj,'rx_gain_mode', Bands[i].RxGainMode), 0, 3);
Bands[i].RxGainDb := EnsureRange(JI(BObj,'rx_gain_db', Bands[i].RxGainDb), 0, 73);
Bands[i].AttenDB := EnsureRange(JI(BObj,'atten_db', Bands[i].AttenDB), 0, 31);
end;
end;
procedure TSettingsManager.SaveGlobal(const MAC: array of Byte;
const G: TGlobalSettings);
var O: TJSONObject; i: Integer;
begin
O := GetGlobalObj(GetDevObj(MacToStr(MAC)));
JW(O,'volume',G.Volume); JW(O,'tx_mon_volume',G.TxMonVolume); JW(O,'drive_level',G.DriveLevel);
JW(O,'active_vfo',G.ActiveVfo);
JW(O,'nr_mode',G.NRMode); JW(O,'nb_mode',G.NBMode);
JW(O,'nr_enabled',G.NRMode <> 0); JW(O,'nb_enabled',G.NBMode <> 0);
JW(O,'snb_enabled',G.SNBEnabled);
JW(O,'anf_enabled',G.ANFEnabled);
JW(O,'agc_slope',G.AGCSlope);
JW(O,'agc_hang_threshold',G.AGCHangThreshold);
JW(O,'wf_agc_enabled',G.WfAGCEnabled);
JW(O,'wf_nf_enabled',G.WfNFEnabled);
JW(O,'last_band',G.LastBand);
JW(O,'last_xvtr',G.LastXvtr);
JW(O,'sample_rate',G.SampleRate);
JW(O,'rx_gain_mode',G.RxGainMode);
JW(O,'rx_gain_db',G.RxGainDb);
// Display settings
JW(O,'fft_size',G.FFTSize);
JW(O,'win_type',G.WindowType);
JW(O,'spec_detector',G.SpecDetector);
JW(O,'spec_avg_mode',G.SpecAvgMode);
JW(O,'spec_avg_time_ms',G.SpecAvgTimeMS);
JW(O,'wf_detector',G.WfDetector);
JW(O,'wf_avg_mode',G.WfAvgMode);
JW(O,'wf_avg_time_ms',G.WfAvgTimeMS);
JW(O,'wf_manual_high',G.WfManualHigh);
JW(O,'wf_manual_low',G.WfManualLow);
JW(O,'wf_agc_offset',G.WfAGCOffset);
JW(O,'wf_palette',G.WfPalette);
JW(O,'wf_gamma',G.WfGamma);
JW(O,'display_pixels',G.DisplayPixels);
JW(O,'wf_speed_div',G.WfSpeedDiv);
JW(O,'spec_msaa',G.SpecMSAA);
JW(O,'spec_ref_level',G.SpecRefLevel);
JW(O,'spec_range',G.SpecRange);
JW(O,'spec_grid_step',G.SpecGridStep);
JW(O,'beacon_zoom_span',G.BeaconZoomSpanHz);
JW(O,'beacon_zoom_offset',G.BeaconZoomOffsetHz);
JW(O,'beacon_zoom_follow',G.BeaconZoomFollow);
JW(O,'beacon_zoom_auto',G.BeaconZoomAuto);
// Audio settings
JWS(O,'audio_out_device',G.AudioOutDevice);
JW(O,'audio_sample_rate',G.AudioSampleRate);
JWS(O,'audio_in_device',G.AudioInDevice);
JW(O,'send_audio_to_radio',G.SendAudioToRadio);
JW(O,'raw_fm_deviation',G.RawFMDeviation);
// Visibility settings
JW(O,'show_spectrum',G.ShowSpectrum);
JW(O,'show_waterfall',G.ShowWaterfall);
JW(O,'show_wideband',G.ShowWideband);
JW(O,'wideband_fill',G.WidebandFill);
JW(O,'spectrum_fill',G.SpectrumFill);
JW(O,'display_duplex',G.DisplayDuplex);
JW(O,'rx_mute_on_tx',G.RxMuteOnTx);
JW(O,'display_fps',G.DisplayFPS);
JW(O,'light_theme',G.LightTheme);
JW(O,'freq_mhz_digits',G.FreqMhzDigits);
// PA settings
JW(O,'pa_max_power',G.PAMaxPower);
JW(O,'pluto_tx_max_att',G.PlutoTxMaxAttDb);
for i := 0 to CFG_BAND_COUNT-1 do
JW(O,'pa_band_cal_'+IntToStr(i),G.PABandCal[i]);
for i := 0 to CFG_XVTR_COUNT-1 do
JW(O,'vhf_cal_'+IntToStr(i),G.VHFBandCal[i]);
// CAT serial settings
for i := 0 to 3 do begin
JW(O,'cat_serial_en_'+IntToStr(i), G.CATSerialEnabled[i]);
JWS(O,'cat_serial_port_'+IntToStr(i),G.CATSerialPort[i]);
JW(O,'cat_serial_baud_'+IntToStr(i), G.CATSerialBaud[i]);
JW(O,'cat_serial_dbits_'+IntToStr(i),G.CATSerialDataBits[i]);
JW(O,'cat_serial_sbits_'+IntToStr(i),G.CATSerialStopBits[i]);
JW(O,'cat_serial_parity_'+IntToStr(i),G.CATSerialParity[i]);
JW(O,'cat_serial_androm_'+IntToStr(i),G.CATSerialAndromeda[i]);
end;
for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin
JW(O,'cat_slice_en_' +IntToStr(i), G.CATSliceEnabled[i]);
JW(O,'cat_slice_port_' +IntToStr(i), G.CATSlicePort[i]);
JW(O,'cat_slice_autotx_'+IntToStr(i), G.CATSliceAutoTX[i]);
JWS(O,'cat_slice_out_' +IntToStr(i), G.CATSliceOutDev[i]);
JWS(O,'cat_slice_in_' +IntToStr(i), G.CATSliceInDev[i]);
end;
JW(O,'cat_tcp_enabled',G.CATTcpEnabled);
JW(O,'cat_tcp_port',G.CATTcpPort);
// ADC settings
JW(O,'dither_enabled',G.DitherEnabled);
JW(O,'random_enabled',G.RandomEnabled);
// Calibration
JW(O,'cal_pwr_det_model',G.Cal.PwrDetModel);
for i := 0 to CFG_BAND_COUNT-1 do
JW(O,'cal_pa_det_'+IntToStr(i),G.Cal.PADetCal[i]);
JW(O,'cal_supply_volt',G.Cal.SupplyVoltCal);
JW(O,'cal_amp_voff_mv',G.Cal.AmpVoffMV);
JW(O,'cal_amp_sens_mva',G.Cal.AmpSensMVA);
for i := 0 to CFG_BAND_COUNT-1 do
JW(O,'cal_smeter_band_'+IntToStr(i),G.Cal.SMeterBandDB[i]);
for i := 0 to CFG_XVTR_COUNT-1 do
JW(O,'cal_smeter_xvtr_'+IntToStr(i),G.Cal.SMeterXvtrDB[i]);
for i := 0 to CFG_BAND_COUNT-1 do
JW(O,'cal_disp_band_'+IntToStr(i),G.Cal.DispBandDB[i]);
for i := 0 to CFG_XVTR_COUNT-1 do
JW(O,'cal_disp_xvtr_'+IntToStr(i),G.Cal.DispXvtrDB[i]);
JW(O,'cal_freq_ppm',G.Cal.FreqCalPPM);
end;
procedure TSettingsManager.SaveBand(const MAC: array of Byte; BandIdx: Integer;
const B: TBandSettings);
var O: TJSONObject;
begin
if (BandIdx < 0) or (BandIdx >= CFG_BAND_COUNT) then Exit;
O := GetBandObj(GetDevObj(MacToStr(MAC)), BandIdx);
JW(O,'vfo_a',B.VfoA); JW(O,'vfo_b',B.VfoB);
JW(O,'mode',B.Mode);
JW(O,'filter_idx',B.FilterIdx); JW(O,'filter_bw',B.FilterBW);
JW(O,'agc_mode',B.AGCMode); JW(O,'agc_top',B.AGCTop);
JW(O,'ctun',B.CTun); JW(O,'center_hz',B.CenterHz); JW(O,'span_hz',B.SpanHz);
JW(O,'fmsq_on',B.FMSQOn); JW(O,'fmsq_level',B.FMSQLevel);
JW(O,'ctcss_on',B.CTCSSOn); JW(O,'ctcss_idx',B.CTCSSToneIdx);
JW(O,'fmstep_on',B.FMStepOn); JW(O,'fmstep_idx',B.FMStepIdx);
JW(O,'fmrpt_dir',B.FMRptDir); JW(O,'fmrpt_offset',B.FMRptOffsetHz);
JW(O,'zoom_factor',B.ZoomFactor); JW(O,'pan_slider',B.PanSlider);
JW(O,'rx_gain_mode',B.RxGainMode); JW(O,'rx_gain_db',B.RxGainDb);
JW(O,'atten_db',B.AttenDB);
end;
function TSettingsManager.LoadBand(const MAC: array of Byte; BandIdx: Integer;
out B: TBandSettings): Boolean;
var MacStr: string; O: TJSONObject;
begin
DefaultBand(BandIdx, B);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
O := GetBandObj(GetDevObj(MacStr), BandIdx);
B.VfoA := JD(O,'vfo_a', B.VfoA);
B.VfoB := JD(O,'vfo_b', B.VfoB);
B.Mode := EnsureRange(JI(O,'mode', B.Mode), CFG_MODE_MIN, CFG_MODE_MAX);
B.FilterIdx := JI(O,'filter_idx', B.FilterIdx);
B.FilterBW := JI(O,'filter_bw', B.FilterBW);
B.AGCMode := JI(O,'agc_mode', B.AGCMode);
B.AGCTop := JI(O,'agc_top', B.AGCTop);
B.CTun := JB(O,'ctun', B.CTun);
B.CenterHz := JD(O,'center_hz', B.CenterHz);
B.SpanHz := JD(O,'span_hz', B.SpanHz);
B.FMSQOn := JB(O,'fmsq_on', B.FMSQOn);
B.FMSQLevel := JI(O,'fmsq_level', B.FMSQLevel);
B.CTCSSOn := JB(O,'ctcss_on', B.CTCSSOn);
B.CTCSSToneIdx := EnsureRange(JI(O,'ctcss_idx', B.CTCSSToneIdx), 0, 37);
B.FMStepOn := JB(O,'fmstep_on', B.FMStepOn);
B.FMStepIdx := EnsureRange(JI(O,'fmstep_idx', B.FMStepIdx), 0, 3);
B.FMRptDir := EnsureRange(JI(O,'fmrpt_dir', B.FMRptDir), 0, 2);
B.FMRptOffsetHz := JD(O,'fmrpt_offset', B.FMRptOffsetHz);
B.RxGainMode := EnsureRange(JI(O,'rx_gain_mode', B.RxGainMode), 0, 3);
B.RxGainDb := EnsureRange(JI(O,'rx_gain_db', B.RxGainDb), 0, 73);
B.AttenDB := EnsureRange(JI(O,'atten_db', B.AttenDB), 0, 31);
B.ZoomFactor := EnsureRange(JD(O,'zoom_factor', B.ZoomFactor), 0.0, 1.0);
B.PanSlider := EnsureRange(JD(O,'pan_slider', B.PanSlider), 0.0, 1.0);
end;
procedure TSettingsManager.SaveWindowBounds(L, T, W, H, DPI: Integer; Maximized: Boolean);
var O: TJSONObject;
begin
O := EnsureObj(FRoot, 'window');
JW(O, 'left', L);
JW(O, 'top', T);
JW(O, 'width', W);
JW(O, 'height', H);
JW(O, 'dpi', DPI);
JW(O, 'maximized', Maximized);
end;
procedure TSettingsManager.LoadWindowBounds(out L, T, W, H, DPI: Integer; out Maximized: Boolean);
var O: TJSONObject;
begin
L := 80; T := 80; W := 1400; H := 900; DPI := 0; Maximized := False;
if FRoot.Find('window') = nil then Exit;
O := EnsureObj(FRoot, 'window');
L := JI(O, 'left', 80);
T := JI(O, 'top', 80);
W := JI(O, 'width', 1400);
H := JI(O, 'height', 900);
DPI := JI(O, 'dpi', 0);
Maximized := JB(O, 'maximized', False);
end;
procedure TSettingsManager.SaveStartupPreview(VfoA, VfoB: Double; SampleRate: Integer);
var O: TJSONObject;
begin
O := EnsureObj(FRoot, 'startup');
JW(O, 'vfo_a', VfoA);
JW(O, 'vfo_b', VfoB);
JW(O, 'sample_rate', SampleRate);
end;
function TSettingsManager.LoadStartupPreview(out VfoA, VfoB: Double; out SampleRate: Integer): Boolean;
var O: TJSONObject;
begin
Result := FRoot.Find('startup') <> nil;
if not Result then Exit;
O := EnsureObj(FRoot, 'startup');
VfoA := JD(O, 'vfo_a', 14200000);
VfoB := JD(O, 'vfo_b', 7100000);
SampleRate := JI(O, 'sample_rate', 192000);
end;
procedure TSettingsManager.SaveAudioBufferSize(BufferSize: Integer);
var O: TJSONObject;
begin
if BufferSize <= 128 then BufferSize := 128
else if BufferSize <= 256 then BufferSize := 256
else BufferSize := 512;
O := EnsureObj(FRoot, 'audio');
JW(O, 'output_buffer_size', BufferSize);
end;
function TSettingsManager.LoadAudioBufferSize: Integer;
var O: TJSONObject;
begin
if FRoot.Find('audio') = nil then Exit(128);
O := EnsureObj(FRoot, 'audio');
Result := JI(O, 'output_buffer_size', 128);
if Result <= 128 then Result := 128
else if Result <= 256 then Result := 256
else Result := 512;
end;
procedure TSettingsManager.SaveCATSettings(const G: TGlobalSettings);
var O: TJSONObject; i: Integer;
begin
O := EnsureObj(FRoot, 'cat');
JW(O, 'tcp_en', G.CATTcpEnabled);
JW(O, 'tcp_port', G.CATTcpPort);
for i := 0 to 3 do
begin
JW(O, 'ser_en_' +IntToStr(i), G.CATSerialEnabled[i]);
JWS(O, 'ser_port_' +IntToStr(i), G.CATSerialPort[i]);
JW(O, 'ser_baud_' +IntToStr(i), G.CATSerialBaud[i]);
JW(O, 'ser_dbits_' +IntToStr(i), G.CATSerialDataBits[i]);
JW(O, 'ser_sbits_' +IntToStr(i), G.CATSerialStopBits[i]);
JW(O, 'ser_parity_'+IntToStr(i), G.CATSerialParity[i]);
JW(O, 'ser_androm_'+IntToStr(i), G.CATSerialAndromeda[i]);
end;
for i := 0 to CFG_CAT_SLICE_COUNT-1 do
begin
JW(O, 'slice_en_' +IntToStr(i), G.CATSliceEnabled[i]);
JW(O, 'slice_port_' +IntToStr(i), G.CATSlicePort[i]);
JW(O, 'slice_autotx_'+IntToStr(i), G.CATSliceAutoTX[i]);
JWS(O,'slice_out_' +IntToStr(i), G.CATSliceOutDev[i]);
JWS(O,'slice_in_' +IntToStr(i), G.CATSliceInDev[i]);
end;
end;
procedure TSettingsManager.LoadCATSettings(var G: TGlobalSettings);
var O: TJSONObject; i: Integer;
begin
if FRoot.Find('cat') = nil then Exit;
O := EnsureObj(FRoot, 'cat');
G.CATTcpEnabled := JB(O, 'tcp_en', False);
G.CATTcpPort := JI(O, 'tcp_port', 19090);
for i := 0 to 3 do
begin
G.CATSerialEnabled[i] := JB(O, 'ser_en_' +IntToStr(i), False);
{$IFDEF MSWINDOWS}
G.CATSerialPort[i] := JS(O, 'ser_port_' +IntToStr(i), 'COM'+IntToStr(i+1));
{$ELSE}
G.CATSerialPort[i] := JS(O, 'ser_port_' +IntToStr(i), '/dev/ttyS'+IntToStr(i));
{$ENDIF}
G.CATSerialBaud[i] := JI(O, 'ser_baud_' +IntToStr(i), 9600);
G.CATSerialDataBits[i] := JI(O, 'ser_dbits_' +IntToStr(i), 8);
G.CATSerialStopBits[i] := JI(O, 'ser_sbits_' +IntToStr(i), 1);
G.CATSerialParity[i] := JI(O, 'ser_parity_'+IntToStr(i), 0);
G.CATSerialAndromeda[i]:= JB(O, 'ser_androm_'+IntToStr(i), False);
end;
for i := 0 to CFG_CAT_SLICE_COUNT-1 do
begin
G.CATSliceEnabled[i] := JB(O, 'slice_en_' +IntToStr(i), False);
G.CATSlicePort[i] := JI(O, 'slice_port_' +IntToStr(i), CFG_CAT_SLICE_PORT0 + i);
G.CATSliceAutoTX[i] := JB(O, 'slice_autotx_'+IntToStr(i), False);
G.CATSliceOutDev[i] := JS(O, 'slice_out_' +IntToStr(i), '');
G.CATSliceInDev[i] := JS(O, 'slice_in_' +IntToStr(i), '');
end;
end;
const
// Заводская 10-полосная голосовая сетка EQ (Hz). [0]=слот преампа (частота
// не используется), [1..10]=узлы кривой. Подобрано под голос, а не под
// музыку: больше точек в 100..3500 Hz. Нужна и DefaultTX, и заводским
// профилям (сброс тембра в ноль), поэтому живёт на уровне модуля.
DEF_EQ_FREQS: array[0..10] of Double = (
0, 100, 200, 400, 700, 1100, 1500, 2000, 2500, 3000, 3500);
// ---------------------------------------------------------------------------
// TX settings (per-device, JSON-секция "tx" под MAC)
// ---------------------------------------------------------------------------
class procedure TSettingsManager.DefaultTX(out T: TTXSettings);
var
i: Integer;
begin
FillChar(T, SizeOf(T), 0);
T.MicLineIn := False;
T.MicBoost := False;
T.MicPTTEnabled := False;
T.MicTipRing := False; // Tip (mic at Tip, PTT at Ring)
T.MicBias := True;
T.LineInGainDB := 0.0; // 0 dB
T.MicGainDB := 10.0;
// ATT on TX — Thetis default 31 dB (полная защита RX-ADC во время передачи)
T.AttOnTX := 31;
// Tune — Thetis defaults
T.TUNLevel := 10;
T.TUNFreq := 750;
// SSB-фильтр по умолчанию
T.FilterLow := 200;
T.FilterHigh := 3100;
T.FilterNC := 1024;
T.FilterMP := 1; // Low Latency (как в piHPSDR)
T.FilterWindow := 1; // BH4 / Blackman-Harris
// Compressor — выкл по умолчанию
T.CompressorOn := False;
T.CompressorGain:= 5.0;
// Leveler — вкл, типичные значения piHPSDR/Thetis
T.LevelerOn := True;
T.LevelerTop := 5.0;
T.LevelerDecay := 500;
// ALC — вкл всегда (защита от перегруза DAC)
T.ALCOn := True;
T.ALCMaxGain := 0.0;
T.ALCDecay := 10;
// Phase Rotator — выкл
T.PhaseRotOn := False;
T.PhaseRotStages:= 8;
T.PhaseRotFreq := 338.0;
// EQ — выкл, нулевые гейны
T.EQOn := False;
T.EQNumBands := 10;
for i := 0 to 10 do
begin
T.EQGains[i] := 0.0;
T.EQFreqs[i] := DEF_EQ_FREQS[i];
end;
// AM
T.AMCarrierLevel := 0.5;
// FM
T.FMDeviation := 5000.0;
T.FMLowCut := 300;
T.FMHighCut := 3000;
T.FMEmphPosition := 1; // PostEQ
// CTCSS
T.CTCSSOn := False;
T.CTCSSFreq := 100.0;
// TX Display defaults — те же что у RX (см. DefaultGlobal)
T.TXFFTSize := 16384; // меньше чем RX — TX-полоса узкая, FFT не нужен большой
T.TXWindowType := 2; // Hann
T.TXSpecDetector := 0; // Peak
T.TXSpecAvgMode := 3; // Log Recursive
T.TXSpecAvgTimeMS := 30.0;
T.TXWfDetector := 0;
T.TXWfAvgMode := 3;
T.TXWfAvgTimeMS := 120.0;
// TX Grid — потолок 0 dBm (TX-уровни выше RX), 80 dB динамики, шаг 10 dB
T.TXSpecRefLevel := 0.0;
T.TXSpecRange := 80.0;
T.TXSpecGridStep := 10.0;
// PureSignal — дефолты Thetis PSForm (протокол 2)
T.PSMoxDelay := 0.2;
T.PSLoopDelay := 0.0;
T.PSTXDelayNs := 150;
T.PSRelaxPtol := False;
T.PSAutoAtten := True;
T.PSPin := True;
T.PSMap := True;
T.PSStabilize := False;
T.PSIntsIdx := 0; // 16 ints / 256 spi
T.PSHWPeak := 0.2899;
T.PSTTFreq1 := 700;
T.PSTTFreq2 := 1900;
T.PSFBTarget := 0; // авто по плате
T.PSOutlierSigma := -1.0; // авто по плате
end;
function TSettingsManager.LoadTX(const MAC: array of Byte;
out T: TTXSettings): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
DefaultTX(T);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
// Если секции "tx" ещё нет — оставляем дефолты, не материализуем пустую секцию
if DevObj.Find('tx') = nil then Exit;
O := EnsureObj(DevObj, 'tx');
T.MicLineIn := JB(O,'mic_line_in', T.MicLineIn);
T.MicBoost := JB(O,'mic_boost', T.MicBoost);
T.MicPTTEnabled := JB(O,'mic_ptt_enabled', T.MicPTTEnabled);
T.MicTipRing := JB(O,'mic_tip_ring', T.MicTipRing);
T.MicBias := JB(O,'mic_bias', T.MicBias);
T.LineInGainDB := EnsureRange(JD(O,'line_in_gain_db', T.LineInGainDB), -34.5, 12.0);
T.MicGainDB := JD(O,'mic_gain_db', T.MicGainDB);
T.AttOnTX := EnsureRange(JI(O,'att_on_tx', T.AttOnTX), 0, 31);
T.TUNLevel := EnsureRange(JI(O,'tun_level', T.TUNLevel), 0, 100);
T.TUNFreq := EnsureRange(JI(O,'tun_freq', T.TUNFreq), 100, 5000);
T.FilterLow := JI(O,'filter_low', T.FilterLow);
T.FilterHigh := JI(O,'filter_high', T.FilterHigh);
T.FilterNC := JI(O,'filter_nc', T.FilterNC);
T.FilterMP := JI(O,'filter_mp', T.FilterMP);
T.FilterWindow := JI(O,'filter_window', T.FilterWindow);
T.CompressorOn := JB(O,'compressor_on', T.CompressorOn);
T.CompressorGain := JD(O,'compressor_gain', T.CompressorGain);
T.LevelerOn := JB(O,'leveler_on', T.LevelerOn);
T.LevelerTop := JD(O,'leveler_top', T.LevelerTop);
T.LevelerDecay := JI(O,'leveler_decay', T.LevelerDecay);
T.ALCOn := JB(O,'alc_on', T.ALCOn);
T.ALCMaxGain := JD(O,'alc_max_gain', T.ALCMaxGain);
T.ALCDecay := JI(O,'alc_decay', T.ALCDecay);
T.PhaseRotOn := JB(O,'phase_rot_on', T.PhaseRotOn);
T.PhaseRotStages := JI(O,'phase_rot_stages',T.PhaseRotStages);
T.PhaseRotFreq := JD(O,'phase_rot_freq', T.PhaseRotFreq);
T.EQOn := JB(O,'eq_on', T.EQOn);
T.EQNumBands := JI(O,'eq_num_bands', T.EQNumBands);
for i := 0 to 10 do
begin
T.EQGains[i] := JD(O,'eq_g_'+IntToStr(i), T.EQGains[i]);
T.EQFreqs[i] := JD(O,'eq_f_'+IntToStr(i), T.EQFreqs[i]);
end;
T.AMCarrierLevel := JD(O,'am_carrier_level',T.AMCarrierLevel);
T.FMDeviation := JD(O,'fm_deviation', T.FMDeviation);
T.FMLowCut := JI(O,'fm_low_cut', T.FMLowCut);
T.FMHighCut := JI(O,'fm_high_cut', T.FMHighCut);
T.FMEmphPosition := JI(O,'fm_emph_position',T.FMEmphPosition);
T.CTCSSOn := JB(O,'ctcss_on', T.CTCSSOn);
T.CTCSSFreq := JD(O,'ctcss_freq', T.CTCSSFreq);
T.TXFFTSize := JI(O,'tx_fft_size', T.TXFFTSize);
T.TXWindowType := JI(O,'tx_win_type', T.TXWindowType);
T.TXSpecDetector := JI(O,'tx_spec_detector', T.TXSpecDetector);
T.TXSpecAvgMode := JI(O,'tx_spec_avg_mode', T.TXSpecAvgMode);
T.TXSpecAvgTimeMS := JD(O,'tx_spec_avg_time_ms', T.TXSpecAvgTimeMS);
T.TXWfDetector := JI(O,'tx_wf_detector', T.TXWfDetector);
T.TXWfAvgMode := JI(O,'tx_wf_avg_mode', T.TXWfAvgMode);
T.TXWfAvgTimeMS := JD(O,'tx_wf_avg_time_ms', T.TXWfAvgTimeMS);
T.TXSpecRefLevel := JD(O,'tx_spec_ref_level', T.TXSpecRefLevel);
T.TXSpecRange := JD(O,'tx_spec_range', T.TXSpecRange);
T.TXSpecGridStep := JD(O,'tx_spec_grid_step', T.TXSpecGridStep);
// PureSignal
T.PSMoxDelay := JD(O,'ps_mox_delay', T.PSMoxDelay);
T.PSLoopDelay := JD(O,'ps_loop_delay', T.PSLoopDelay);
T.PSTXDelayNs := JI(O,'ps_tx_delay_ns', T.PSTXDelayNs);
T.PSRelaxPtol := JB(O,'ps_relax_ptol', T.PSRelaxPtol);
T.PSAutoAtten := JB(O,'ps_auto_atten', T.PSAutoAtten);
T.PSPin := JB(O,'ps_pin', T.PSPin);
T.PSMap := JB(O,'ps_map', T.PSMap);
T.PSStabilize := JB(O,'ps_stabilize', T.PSStabilize);
T.PSIntsIdx := EnsureRange(JI(O,'ps_ints_idx', T.PSIntsIdx), 0, 2);
T.PSHWPeak := JD(O,'ps_hw_peak', T.PSHWPeak);
T.PSTTFreq1 := JI(O,'ps_tt_freq1', T.PSTTFreq1);
T.PSTTFreq2 := JI(O,'ps_tt_freq2', T.PSTTFreq2);
T.PSFBTarget := EnsureRange(JI(O,'ps_fb_target', T.PSFBTarget), 0, 256);
T.PSOutlierSigma := JD(O,'ps_outlier_sigma', T.PSOutlierSigma);
end;
class procedure TSettingsManager.DefaultCW(out C: TCWSettings);
begin
FillChar(C, SizeOf(C), 0);
C.Pitch := 600; // Thetis cw_pitch default
C.FWKeyer := True; // тайминги в FPGA — джиттер PC не влияет
C.KeyerMode := 2; // Iambic B
C.ReverseKeys := False;
C.StrictSpacing := False;
C.Speed := 20;
C.Weight := 50;
C.BreakIn := True;
C.HangTimeMS := 300;
C.RampMS := 9; // Thetis SetCWEdgeLength(9)
C.RFDelayMS := 0;
C.SidetoneHW := True;
C.SidetoneHWLevel := 50;
C.SidetoneSW := False;
C.SidetoneSWLevel := 30;
C.LocalKeyer := False; // у openHPSDR тайминги в FPGA; Pluto включит сам
C.CarrierOffsetHz := 0; // 0 = авто (по типу бэкенда)
C.KeyPortEnabled := False;
C.KeyPort := '';
C.KeyDotLine := 0; // CTS
C.KeyDashLine := 1; // DSR
C.KeyInvert := False;
C.KeyPowerDTR := True; // типовая распайка: ключ питается от DTR/RTS
C.KeyPowerRTS := True;
C.Decoder := True; // окно терминала без декодера бессмысленно
C.DecoderStrip := False; // лента под спектром — по желанию
C.KbdKey := False; // Ctrl-манипулятор включают осознанно
// Заводские заготовки: типовой вызов, ответ и стандартные концовки.
C.Messages[0] := 'CQ CQ DE ';
C.Messages[1] := 'DE ';
C.Messages[2] := 'UR RST 599 599 ';
C.Messages[3] := 'TU 73 GL';
C.Messages[4] := '?';
C.Messages[5] := 'AGN';
C.Messages[6] := 'QRZ?';
C.Messages[7] := 'TEST';
end;
function TSettingsManager.LoadCW(const MAC: array of Byte;
out C: TCWSettings): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
DefaultCW(C);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('cw') = nil then Exit; // нет секции — остаются дефолты
O := EnsureObj(DevObj, 'cw');
C.Pitch := EnsureRange(JI(O,'pitch', C.Pitch), 200, 1200);
C.FWKeyer := JB(O,'fw_keyer', C.FWKeyer);
C.KeyerMode := EnsureRange(JI(O,'keyer_mode', C.KeyerMode), 0, 2);
C.ReverseKeys := JB(O,'reverse_keys', C.ReverseKeys);
C.StrictSpacing := JB(O,'strict_spacing',C.StrictSpacing);
C.Speed := EnsureRange(JI(O,'speed', C.Speed), 5, 60);
C.Weight := EnsureRange(JI(O,'weight', C.Weight), 33, 66);
C.BreakIn := JB(O,'break_in', C.BreakIn);
C.HangTimeMS := EnsureRange(JI(O,'hang_time_ms', C.HangTimeMS), 0, 2000);
C.RampMS := EnsureRange(JI(O,'ramp_ms', C.RampMS), 0, 20);
C.RFDelayMS := EnsureRange(JI(O,'rf_delay_ms', C.RFDelayMS), 0, 255);
C.SidetoneHW := JB(O,'sidetone_hw', C.SidetoneHW);
C.SidetoneHWLevel := EnsureRange(JI(O,'sidetone_hw_level', C.SidetoneHWLevel), 0, 127);
C.SidetoneSW := JB(O,'sidetone_sw', C.SidetoneSW);
C.SidetoneSWLevel := EnsureRange(JI(O,'sidetone_sw_level', C.SidetoneSWLevel), 0, 100);
C.LocalKeyer := JB(O,'local_keyer', C.LocalKeyer);
C.CarrierOffsetHz := EnsureRange(JI(O,'carrier_offset_hz', C.CarrierOffsetHz), -1, 48000);
C.KeyPortEnabled := JB(O,'key_port_on', C.KeyPortEnabled);
C.KeyPort := Copy(JS(O,'key_port', C.KeyPort), 1, 63);
C.KeyDotLine := EnsureRange(JI(O,'key_dot_line', C.KeyDotLine), 0, 3);
C.KeyDashLine := EnsureRange(JI(O,'key_dash_line', C.KeyDashLine), 0, 3);
C.KeyInvert := JB(O,'key_invert', C.KeyInvert);
C.KeyPowerDTR := JB(O,'key_power_dtr', C.KeyPowerDTR);
C.KeyPowerRTS := JB(O,'key_power_rts', C.KeyPowerRTS);
C.Decoder := JB(O,'decoder', C.Decoder);
C.DecoderStrip := JB(O,'decoder_strip', C.DecoderStrip);
C.KbdKey := JB(O,'kbd_key', C.KbdKey);
for i := 0 to CW_MSG_COUNT - 1 do
C.Messages[i] := Copy(JS(O, 'msg_' + IntToStr(i), C.Messages[i]), 1, 63);
end;
procedure TSettingsManager.SaveCW(const MAC: array of Byte;
const C: TCWSettings);
var O: TJSONObject; i: Integer;
begin
O := EnsureObj(GetDevObj(MacToStr(MAC)), 'cw');
JW(O,'pitch', C.Pitch);
JW(O,'fw_keyer', C.FWKeyer);
JW(O,'keyer_mode', C.KeyerMode);
JW(O,'reverse_keys', C.ReverseKeys);
JW(O,'strict_spacing', C.StrictSpacing);
JW(O,'speed', C.Speed);
JW(O,'weight', C.Weight);
JW(O,'break_in', C.BreakIn);
JW(O,'hang_time_ms', C.HangTimeMS);
JW(O,'ramp_ms', C.RampMS);
JW(O,'rf_delay_ms', C.RFDelayMS);
JW(O,'sidetone_hw', C.SidetoneHW);
JW(O,'sidetone_hw_level', C.SidetoneHWLevel);
JW(O,'sidetone_sw', C.SidetoneSW);
JW(O,'sidetone_sw_level', C.SidetoneSWLevel);
JW(O,'local_keyer', C.LocalKeyer);
JW(O,'carrier_offset_hz', C.CarrierOffsetHz);
JW(O,'key_port_on', C.KeyPortEnabled);
JWS(O,'key_port', C.KeyPort);
JW(O,'key_dot_line', C.KeyDotLine);
JW(O,'key_dash_line', C.KeyDashLine);
JW(O,'key_invert', C.KeyInvert);
JW(O,'key_power_dtr', C.KeyPowerDTR);
JW(O,'key_power_rts', C.KeyPowerRTS);
JW(O,'decoder', C.Decoder);
JW(O,'decoder_strip', C.DecoderStrip);
JW(O,'kbd_key', C.KbdKey);
for i := 0 to CW_MSG_COUNT - 1 do
JWS(O, 'msg_' + IntToStr(i), C.Messages[i]);
end;
procedure TSettingsManager.SaveTX(const MAC: array of Byte;
const T: TTXSettings);
var O: TJSONObject; i: Integer;
begin
O := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx');
JW(O,'mic_line_in', T.MicLineIn);
JW(O,'mic_boost', T.MicBoost);
JW(O,'mic_ptt_enabled', T.MicPTTEnabled);
JW(O,'mic_tip_ring', T.MicTipRing);
JW(O,'mic_bias', T.MicBias);
JW(O,'line_in_gain_db', T.LineInGainDB);
JW(O,'mic_gain_db', T.MicGainDB);
JW(O,'att_on_tx', T.AttOnTX);
JW(O,'tun_level', T.TUNLevel);
JW(O,'tun_freq', T.TUNFreq);
JW(O,'filter_low', T.FilterLow);
JW(O,'filter_high', T.FilterHigh);
JW(O,'filter_nc', T.FilterNC);
JW(O,'filter_mp', T.FilterMP);
JW(O,'filter_window', T.FilterWindow);
JW(O,'compressor_on', T.CompressorOn);
JW(O,'compressor_gain', T.CompressorGain);
JW(O,'leveler_on', T.LevelerOn);
JW(O,'leveler_top', T.LevelerTop);
JW(O,'leveler_decay', T.LevelerDecay);
JW(O,'alc_on', T.ALCOn);
JW(O,'alc_max_gain', T.ALCMaxGain);
JW(O,'alc_decay', T.ALCDecay);
JW(O,'phase_rot_on', T.PhaseRotOn);
JW(O,'phase_rot_stages',T.PhaseRotStages);
JW(O,'phase_rot_freq', T.PhaseRotFreq);
JW(O,'eq_on', T.EQOn);
JW(O,'eq_num_bands', T.EQNumBands);
for i := 0 to 10 do
begin
JW(O,'eq_g_'+IntToStr(i), T.EQGains[i]);
JW(O,'eq_f_'+IntToStr(i), T.EQFreqs[i]);
end;
JW(O,'am_carrier_level',T.AMCarrierLevel);
JW(O,'fm_deviation', T.FMDeviation);
JW(O,'fm_low_cut', T.FMLowCut);
JW(O,'fm_high_cut', T.FMHighCut);
JW(O,'fm_emph_position',T.FMEmphPosition);
JW(O,'ctcss_on', T.CTCSSOn);
JW(O,'ctcss_freq', T.CTCSSFreq);
JW(O,'tx_fft_size', T.TXFFTSize);
JW(O,'tx_win_type', T.TXWindowType);
JW(O,'tx_spec_detector', T.TXSpecDetector);
JW(O,'tx_spec_avg_mode', T.TXSpecAvgMode);
JW(O,'tx_spec_avg_time_ms', T.TXSpecAvgTimeMS);
JW(O,'tx_wf_detector', T.TXWfDetector);
JW(O,'tx_wf_avg_mode', T.TXWfAvgMode);
JW(O,'tx_wf_avg_time_ms', T.TXWfAvgTimeMS);
JW(O,'tx_spec_ref_level', T.TXSpecRefLevel);
JW(O,'tx_spec_range', T.TXSpecRange);
JW(O,'tx_spec_grid_step', T.TXSpecGridStep);
// PureSignal
JW(O,'ps_mox_delay', T.PSMoxDelay);
JW(O,'ps_loop_delay', T.PSLoopDelay);
JW(O,'ps_tx_delay_ns', T.PSTXDelayNs);
JW(O,'ps_relax_ptol', T.PSRelaxPtol);
JW(O,'ps_auto_atten', T.PSAutoAtten);
JW(O,'ps_pin', T.PSPin);
JW(O,'ps_map', T.PSMap);
JW(O,'ps_stabilize', T.PSStabilize);
JW(O,'ps_ints_idx', T.PSIntsIdx);
JW(O,'ps_hw_peak', T.PSHWPeak);
JW(O,'ps_tt_freq1', T.PSTTFreq1);
JW(O,'ps_tt_freq2', T.PSTTFreq2);
JW(O,'ps_fb_target', T.PSFBTarget);
JW(O,'ps_outlier_sigma',T.PSOutlierSigma);
end;
// ---------------------------------------------------------------------------
// TX-профили: снимок/применение + заводской набор + персист
// ---------------------------------------------------------------------------
procedure TXProfileCapture(const T: TTXSettings; DrivePct: Integer; var P: TTXProfile);
var i: Integer;
begin
P.MicLineIn := T.MicLineIn;
P.MicBoost := T.MicBoost;
P.MicPTTEnabled := T.MicPTTEnabled;
P.MicTipRing := T.MicTipRing;
P.MicBias := T.MicBias;
P.LineInGainDB := T.LineInGainDB;
P.MicGainDB := T.MicGainDB;
P.FilterLow := T.FilterLow;
P.FilterHigh := T.FilterHigh;
P.CompressorOn := T.CompressorOn;
P.CompressorGain := T.CompressorGain;
P.LevelerOn := T.LevelerOn;
P.LevelerTop := T.LevelerTop;
P.LevelerDecay := T.LevelerDecay;
P.ALCOn := T.ALCOn;
P.ALCMaxGain := T.ALCMaxGain;
P.ALCDecay := T.ALCDecay;
P.PhaseRotOn := T.PhaseRotOn;
P.PhaseRotStages := T.PhaseRotStages;
P.PhaseRotFreq := T.PhaseRotFreq;
P.EQOn := T.EQOn;
P.EQNumBands := T.EQNumBands;
for i := 0 to 10 do
begin
P.EQGains[i] := T.EQGains[i];
P.EQFreqs[i] := T.EQFreqs[i];
end;
P.AMCarrierLevel := T.AMCarrierLevel;
P.DrivePercent := EnsureRange(DrivePct, 0, 100);
P.TUNLevel := EnsureRange(T.TUNLevel, 0, 100);
end;
procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: Integer);
var i: Integer;
begin
T.MicLineIn := P.MicLineIn;
T.MicBoost := P.MicBoost;
T.MicPTTEnabled := P.MicPTTEnabled;
T.MicTipRing := P.MicTipRing;
T.MicBias := P.MicBias;
T.LineInGainDB := EnsureRange(P.LineInGainDB, -34.5, 12.0);
T.MicGainDB := P.MicGainDB;
T.FilterLow := P.FilterLow;
T.FilterHigh := P.FilterHigh;
T.CompressorOn := P.CompressorOn;
T.CompressorGain := P.CompressorGain;
T.LevelerOn := P.LevelerOn;
T.LevelerTop := P.LevelerTop;
T.LevelerDecay := P.LevelerDecay;
T.ALCOn := P.ALCOn;
T.ALCMaxGain := P.ALCMaxGain;
T.ALCDecay := P.ALCDecay;
T.PhaseRotOn := P.PhaseRotOn;
T.PhaseRotStages := P.PhaseRotStages;
T.PhaseRotFreq := P.PhaseRotFreq;
T.EQOn := P.EQOn;
T.EQNumBands := P.EQNumBands;
for i := 0 to 10 do
begin
T.EQGains[i] := P.EQGains[i];
T.EQFreqs[i] := P.EQFreqs[i];
end;
T.AMCarrierLevel := P.AMCarrierLevel;
T.TUNLevel := EnsureRange(P.TUNLevel, 0, 100);
DrivePct := EnsureRange(P.DrivePercent, 0, 100);
end;
procedure ClearTXProfModeTable(var T: TTXProfModeTable);
var i: Integer;
begin
for i := 0 to CFG_MODE_MAX do T[i] := -1;
end;
function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray;
var i: Integer;
begin
Result := TJSONArray.Create;
for i := 0 to CFG_MODE_MAX do Result.Add(T[i]);
end;
procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable);
var
Arr: TJSONArray;
i, V: Integer;
begin
ClearTXProfModeTable(T);
if not (Node is TJSONArray) then Exit;
Arr := TJSONArray(Node);
for i := 0 to Min(CFG_MODE_MAX, Arr.Count - 1) do
begin
V := Arr.Integers[i];
if (V >= 0) and (V < TXPROF_MAX) then T[i] := V;
end;
end;
procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
out L: TTXProfileList);
var
// Заводской звук + ЖИВАЯ обвязка микрофона. Всё, что определяет тембр
// (полоса, динамика, EQ, AM carrier, TUN level), берётся из DefaultTX:
// «сброс к заводским» обязан сбрасывать именно это, иначе Default наследует
// ту полосу, на которой оператора застали (например 5000 от ESSB 5k).
// Не сбрасываем то, что описывает ЖЕЛЕЗО и безопасность: разъём и режим
// микрофона с его усилением (иначе слетит Line In у микшера) и мощность
// (внезапный скачок drive пошёл бы в усилитель).
Stock: TTXSettings;
const
// Кривые EQ для заводских ESSB. Значения — УЗЛЫ ломаной АЧХ WDSP (eq.c),
// а не «полосы»: между узлами интерполяция по дБ, а ЗА крайними узлами
// ctfmode=0 обрывает АЧХ на нет. Отсюда железное правило, по которому
// подобраны частоты: нижний узел ≤ FilterLow, верхний ≥ FilterHigh, иначе
// включённый EQ сам срежет полосу, ради которой ESSB и включают.
// Форма: срез бубнения снизу, ровная середина, мягкий подъём присутствия
// к верху. Преамп в минус — EQ стоит ДО компрессора и ALC, и плюсовые
// гейны иначе просто загоняют ALC в ограничение.
ESSB35_F: array[1..10] of Double = (100, 160, 250, 400, 700, 1200, 1800, 2500, 3100, 3600);
ESSB35_G: array[1..10] of Double = ( -6, -4, -2, 0, 0, 1, 2, 3, 4, 4);
ESSB35_PRE = -2.0;
ESSB50_F: array[1..10] of Double = (100, 180, 300, 550, 950, 1600, 2400, 3300, 4200, 5100);
ESSB50_G: array[1..10] of Double = ( -5, -3, -1, 0, 0, 1, 2, 3.5, 5, 5);
ESSB50_PRE = -3.0;
// FM: узлы охватывают AF-полосу модулятора (штатно 300..3000, вкладка
// Hardware), крайние с запасом — за ними EQ обрывает АЧХ. Верх НЕ поднимаем:
// предыскажение (pre-emphasis) само задирает его 6 дБ/окт и по умолчанию
// стоит ПОСЛЕ динамики (xemphp вторым, сразу перед xfmmod), то есть
// компрессор его уже не удержит — лишний буст ушёл бы в перемодуляцию на
// свистящих. Снизу режем бубнение, лёгкий подъём разборчивости 1.4..2.3 кГц.
FM_F: array[1..10] of Double = (200, 300, 450, 700, 1000, 1400, 1800, 2300, 2800, 3400);
FM_G: array[1..10] of Double = ( -8, -4, -1, 0, 0, 1, 2, 2, 1, -2);
FM_PRE = -1.0;
// Тембр у заводского профиля — заводской: EQ выключен, все усиления и
// преамп в нуле, сетка узлов штатная. Наследовать сюда живой EQ нельзя —
// иначе «сброс к заводским» не сбрасывал бы то, ради чего его жмут.
procedure FlatEQ(var P: TTXProfile);
var i: Integer;
begin
P.EQOn := False;
P.EQNumBands := 10;
for i := 0 to 10 do
begin
P.EQGains[i] := 0.0;
P.EQFreqs[i] := DEF_EQ_FREQS[i];
end;
end;
procedure Add(const AName: string; ALow, AHigh: Integer;
AComp: Boolean; ACompGain: Double);
begin
if L.Count >= TXPROF_MAX then Exit;
TXProfileCapture(Stock, DrivePct, L.Items[L.Count]);
L.Items[L.Count].Name := AName;
L.Items[L.Count].FilterLow := ALow;
L.Items[L.Count].FilterHigh := AHigh;
L.Items[L.Count].CompressorOn := AComp;
L.Items[L.Count].CompressorGain := ACompGain;
FlatEQ(L.Items[L.Count]);
Inc(L.Count);
end;
// То же плюс своя кривая EQ: у ESSB тембр — часть профиля, наследовать
// сетку 100..3500 от текущих настроек здесь нельзя (см. правило выше).
procedure AddEQ(const AName: string; ALow, AHigh: Integer;
AComp: Boolean; ACompGain: Double;
const AF, AG: array of Double; APreamp: Double);
var
i, Before: Integer;
begin
Before := L.Count;
Add(AName, ALow, AHigh, AComp, ACompGain);
if L.Count = Before then Exit; // упёрлись в TXPROF_MAX — ничего не добавлено
with L.Items[L.Count - 1] do
begin
EQOn := True;
EQNumBands := 10;
EQFreqs[0] := 0.0; // слот преампа — частота не используется
EQGains[0] := APreamp;
for i := 1 to 10 do
begin
EQFreqs[i] := AF[i - 1];
EQGains[i] := AG[i - 1];
end;
end;
end;
begin
FillChar(L, SizeOf(L), 0);
L.Count := 0;
L.ActiveIdx := 0;
ClearTXProfModeTable(L.MainByMode); // главный ничего не переключает
// База всех заводских профилей: заводской звук, живая обвязка микрофона.
TSettingsManager.DefaultTX(Stock);
Stock.MicLineIn := Base.MicLineIn;
Stock.MicBoost := Base.MicBoost;
Stock.MicBias := Base.MicBias;
Stock.MicPTTEnabled := Base.MicPTTEnabled;
Stock.MicTipRing := Base.MicTipRing;
Stock.LineInGainDB := Base.LineInGainDB;
Stock.MicGainDB := Base.MicGainDB;
// «Default» — ровно заводской звук: полоса 200..3100, компрессор выкл,
// leveler/ALC как в DefaultTX, EQ ровный.
TXProfileCapture(Stock, DrivePct, L.Items[0]);
L.Items[0].Name := 'Default';
FlatEQ(L.Items[0]);
L.Count := 1;
// SSB-профили отличаются от Default только полосой и компрессором;
// микрофонный вход и мощность наследуются, EQ у всех троих заводской-ровный.
// ESSB несут ещё и свою кривую: без неё «широкий» профиль звучит тем же
// телефонным тембром, а с чужой сеткой (верхний узел 3500) EQ обрезал бы
// верх ESSB 5k.
Add('SSB DX', 300, 2700, True, 8.0);
Add('SSB Wide', 200, 3100, False, 5.0);
AddEQ('ESSB 3.5k', 100, 3500, False, 5.0, ESSB35_F, ESSB35_G, ESSB35_PRE);
AddEQ('ESSB 5k', 100, 5000, False, 5.0, ESSB50_F, ESSB50_G, ESSB50_PRE);
// FM: плотная модуляция за счёт компрессора — девиация ограничена жёстко,
// поэтому громкость даёт средний уровень, а не пики. Полоса 300..3000 здесь
// косметическая: в FM края bp0 считает сам движок по Карсону (девиация +
// AF-срез), профильные значения работают, только если привязать этот профиль
// к голосовому режиму. Девиация и AF-срез — свойства аппарата (вкладка
// Hardware), профилем не переключаются.
AddEQ('FM', 300, 3000, True, 8.0, FM_F, FM_G, FM_PRE);
end;
function TSettingsManager.LoadTXProfiles(const MAC: array of Byte;
out L: TTXProfileList): Boolean;
var
MacStr: string;
DevObj, Root, O: TJSONObject;
Node: TJSONData;
Arr: TJSONArray;
BaseTX: TTXSettings;
DefP: TTXProfile;
i, j: Integer;
begin
FillChar(L, SizeOf(L), 0);
L.Count := 0;
L.ActiveIdx := 0;
ClearTXProfModeTable(L.MainByMode);
Result := False;
MacStr := MacToStr(MAC);
if FRoot.Find(MacStr) = nil then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('tx_profiles') = nil then Exit;
Root := EnsureObj(DevObj, 'tx_profiles');
Node := Root.Find('list');
if not (Node is TJSONArray) then Exit;
Arr := TJSONArray(Node);
// Заводской профиль как источник значений по умолчанию для отсутствующих
// ключей: конфиг, записанный старой версией, дочитывается без дыр.
DefaultTX(BaseTX);
TXProfileCapture(BaseTX, 50, DefP);
DefP.Name := 'Profile';
for i := 0 to Arr.Count - 1 do
begin
if L.Count >= TXPROF_MAX then Break;
if not (Arr.Items[i] is TJSONObject) then Continue;
O := TJSONObject(Arr.Items[i]);
L.Items[L.Count] := DefP;
with L.Items[L.Count] do
begin
Name := Copy(JS(O,'name', DefP.Name), 1, 31);
MicLineIn := JB(O,'mic_line_in', MicLineIn);
MicBoost := JB(O,'mic_boost', MicBoost);
MicPTTEnabled := JB(O,'mic_ptt', MicPTTEnabled);
MicTipRing := JB(O,'mic_tip_ring', MicTipRing);
MicBias := JB(O,'mic_bias', MicBias);
LineInGainDB := EnsureRange(JD(O,'line_in_gain_db', LineInGainDB), -34.5, 12.0);
MicGainDB := JD(O,'mic_gain_db', MicGainDB);
FilterLow := JI(O,'filter_low', FilterLow);
FilterHigh := JI(O,'filter_high', FilterHigh);
CompressorOn := JB(O,'comp_on', CompressorOn);
CompressorGain := JD(O,'comp_gain', CompressorGain);
LevelerOn := JB(O,'lev_on', LevelerOn);
LevelerTop := JD(O,'lev_top', LevelerTop);
LevelerDecay := JI(O,'lev_decay', LevelerDecay);
ALCOn := JB(O,'alc_on', ALCOn);
ALCMaxGain := JD(O,'alc_max_gain', ALCMaxGain);
ALCDecay := JI(O,'alc_decay', ALCDecay);
PhaseRotOn := JB(O,'phr_on', PhaseRotOn);
PhaseRotStages := JI(O,'phr_stages', PhaseRotStages);
PhaseRotFreq := JD(O,'phr_freq', PhaseRotFreq);
EQOn := JB(O,'eq_on', EQOn);
EQNumBands := JI(O,'eq_bands', EQNumBands);
for j := 0 to 10 do
begin
EQGains[j] := JD(O,'eq_g_'+IntToStr(j), EQGains[j]);
EQFreqs[j] := JD(O,'eq_f_'+IntToStr(j), EQFreqs[j]);
end;
AMCarrierLevel := JD(O,'am_carrier', AMCarrierLevel);
DrivePercent := EnsureRange(JI(O,'drive', DrivePercent), 0, 100);
TUNLevel := EnsureRange(JI(O,'tun_level', TUNLevel), 0, 100);
if Name = '' then Name := 'Profile ' + IntToStr(L.Count + 1);
end;
Inc(L.Count);
end;
if L.Count = 0 then Exit; // пустой список = как будто секции нет
L.ActiveIdx := JI(Root, 'active', 0);
if (L.ActiveIdx < 0) or (L.ActiveIdx >= L.Count) then L.ActiveIdx := 0;
JSONToModeTable(Root.Find('main_prof_modes'), L.MainByMode);
for i := 0 to CFG_MODE_MAX do
if L.MainByMode[i] >= L.Count then L.MainByMode[i] := -1;
Result := True;
end;
procedure TSettingsManager.SaveTXProfiles(const MAC: array of Byte;
const L: TTXProfileList);
var
Root, O: TJSONObject;
Arr: TJSONArray;
i, j, Idx: Integer;
begin
Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx_profiles');
JW(Root, 'active', EnsureRange(L.ActiveIdx, 0, Max(0, L.Count - 1)));
Idx := Root.IndexOfName('main_prof_modes');
if Idx >= 0 then Root.Delete(Idx);
Root.Add('main_prof_modes', ModeTableToJSON(L.MainByMode));
// Список пересоздаётся целиком: удаление профиля не должно оставлять хвост.
Idx := Root.IndexOfName('list');
if Idx >= 0 then Root.Delete(Idx);
Arr := TJSONArray.Create;
Root.Add('list', Arr);
for i := 0 to L.Count - 1 do
begin
O := TJSONObject.Create;
Arr.Add(O);
JWS(O,'name', L.Items[i].Name);
JW(O,'mic_line_in', L.Items[i].MicLineIn);
JW(O,'mic_boost', L.Items[i].MicBoost);
JW(O,'mic_ptt', L.Items[i].MicPTTEnabled);
JW(O,'mic_tip_ring', L.Items[i].MicTipRing);
JW(O,'mic_bias', L.Items[i].MicBias);
JW(O,'line_in_gain_db', L.Items[i].LineInGainDB);
JW(O,'mic_gain_db', L.Items[i].MicGainDB);
JW(O,'filter_low', L.Items[i].FilterLow);
JW(O,'filter_high', L.Items[i].FilterHigh);
JW(O,'comp_on', L.Items[i].CompressorOn);
JW(O,'comp_gain', L.Items[i].CompressorGain);
JW(O,'lev_on', L.Items[i].LevelerOn);
JW(O,'lev_top', L.Items[i].LevelerTop);
JW(O,'lev_decay', L.Items[i].LevelerDecay);
JW(O,'alc_on', L.Items[i].ALCOn);
JW(O,'alc_max_gain', L.Items[i].ALCMaxGain);
JW(O,'alc_decay', L.Items[i].ALCDecay);
JW(O,'phr_on', L.Items[i].PhaseRotOn);
JW(O,'phr_stages', L.Items[i].PhaseRotStages);
JW(O,'phr_freq', L.Items[i].PhaseRotFreq);
JW(O,'eq_on', L.Items[i].EQOn);
JW(O,'eq_bands', L.Items[i].EQNumBands);
for j := 0 to 10 do
begin
JW(O,'eq_g_'+IntToStr(j), L.Items[i].EQGains[j]);
JW(O,'eq_f_'+IntToStr(j), L.Items[i].EQFreqs[j]);
end;
JW(O,'am_carrier', L.Items[i].AMCarrierLevel);
JW(O,'drive', L.Items[i].DrivePercent);
JW(O,'tun_level', L.Items[i].TUNLevel);
end;
end;
class procedure TSettingsManager.DefaultAlex(out A: TAlexSettings);
var i: Integer;
begin
FillChar(A, SizeOf(A), 0);
for i := 0 to CFG_BAND_COUNT - 1 do
begin
A.RxAnt[i] := 1; // ANT1 — безопасный дефолт
A.TxAnt[i] := 1; // ANT1
A.RxOnly[i] := 0; // вторичный вход выключен
A.DoNotTx[i] := False;
end;
A.RxBypassOnTx := False;
A.Ext1OnTx := False;
A.GndBpf2OnTx := False;
A.EnableXvtrHf := False;
end;
function TSettingsManager.LoadAlex(const MAC: array of Byte;
out A: TAlexSettings): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
DefaultAlex(A);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('alex') = nil then Exit;
O := EnsureObj(DevObj, 'alex');
for i := 0 to CFG_BAND_COUNT - 1 do
begin
A.RxAnt[i] := EnsureRange(JI(O, 'rx_ant_' + IntToStr(i), 1), 1, 3);
A.TxAnt[i] := EnsureRange(JI(O, 'tx_ant_' + IntToStr(i), 1), 1, 3);
A.RxOnly[i] := EnsureRange(JI(O, 'rx_only_' + IntToStr(i), 0), 0, 3);
A.DoNotTx[i] := JB(O, 'do_not_tx_' + IntToStr(i), False);
end;
A.RxBypassOnTx := JB(O, 'rx_bypass_on_tx', False);
A.Ext1OnTx := JB(O, 'ext1_on_tx', False);
A.GndBpf2OnTx := JB(O, 'gnd_bpf2_on_tx', False);
A.EnableXvtrHf := JB(O, 'enable_xvtr_hf', False);
end;
class procedure TSettingsManager.DefaultOC(out O: TOCSettings);
var i: Integer;
begin
FillChar(O, SizeOf(O), 0);
O.Enabled := False;
for i := 0 to CFG_BAND_COUNT - 1 do
begin
O.RxMaskHF[i] := 0;
O.TxMaskHF[i] := 0;
end;
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
O.RxMaskXvtr[i] := 0;
O.TxMaskXvtr[i] := 0;
end;
for i := 0 to OC_PIN_COUNT - 1 do
begin
O.ActionHF[i] := OCA_MOX_TUNE_TWOTONE;
O.ActionXvtr[i] := OCA_MOX_TUNE_TWOTONE;
end;
end;
function TSettingsManager.LoadOC(const MAC: array of Byte;
out O: TOCSettings): Boolean;
var MacStr: string; DevObj, Obj: TJSONObject; i: Integer;
begin
DefaultOC(O);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('oc') = nil then begin Result := False; Exit; end;
Obj := EnsureObj(DevObj, 'oc');
O.Enabled := JB(Obj, 'enabled', False);
for i := 0 to CFG_BAND_COUNT - 1 do
begin
O.RxMaskHF[i] := EnsureRange(JI(Obj, 'rx_hf_' + IntToStr(i), 0), 0, 127);
O.TxMaskHF[i] := EnsureRange(JI(Obj, 'tx_hf_' + IntToStr(i), 0), 0, 127);
end;
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
O.RxMaskXvtr[i] := EnsureRange(JI(Obj, 'rx_xvtr_' + IntToStr(i), 0), 0, 127);
O.TxMaskXvtr[i] := EnsureRange(JI(Obj, 'tx_xvtr_' + IntToStr(i), 0), 0, 127);
end;
for i := 0 to OC_PIN_COUNT - 1 do
begin
O.ActionHF[i] := EnsureRange(JI(Obj, 'action_hf_' + IntToStr(i), OCA_MOX_TUNE_TWOTONE), 0, 6);
O.ActionXvtr[i] := EnsureRange(JI(Obj, 'action_xvtr_' + IntToStr(i), OCA_MOX_TUNE_TWOTONE), 0, 6);
end;
end;
procedure TSettingsManager.SaveOC(const MAC: array of Byte;
const O: TOCSettings);
var Obj: TJSONObject; i: Integer;
begin
Obj := EnsureObj(GetDevObj(MacToStr(MAC)), 'oc');
JW(Obj, 'enabled', O.Enabled);
for i := 0 to CFG_BAND_COUNT - 1 do
begin
JW(Obj, 'rx_hf_' + IntToStr(i), Integer(O.RxMaskHF[i]));
JW(Obj, 'tx_hf_' + IntToStr(i), Integer(O.TxMaskHF[i]));
end;
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
JW(Obj, 'rx_xvtr_' + IntToStr(i), Integer(O.RxMaskXvtr[i]));
JW(Obj, 'tx_xvtr_' + IntToStr(i), Integer(O.TxMaskXvtr[i]));
end;
for i := 0 to OC_PIN_COUNT - 1 do
begin
JW(Obj, 'action_hf_' + IntToStr(i), Integer(O.ActionHF[i]));
JW(Obj, 'action_xvtr_' + IntToStr(i), Integer(O.ActionXvtr[i]));
end;
end;
function TSettingsManager.LoadFilters(const MAC: array of Byte;
out T: TFilterTable): Boolean;
var
MacStr, K: string;
DevObj, Root, Obj: TJSONObject;
D: TJSONData;
m, s: Integer;
begin
DefaultFilterTable(T);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('filters') = nil then begin Result := False; Exit; end;
Root := EnsureObj(DevObj, 'filters');
for m := 0 to CFG_MODE_MAX do
begin
D := Root.Find('m' + IntToStr(m));
if (D = nil) or not (D is TJSONObject) then Continue;
Obj := TJSONObject(D);
for s := 0 to FILT_SLOTS - 1 do
begin
K := 's' + IntToStr(s) + '_';
T[m][s].Name := JS(Obj, K + 'n', T[m][s].Name);
T[m][s].Lo := JI(Obj, K + 'lo', T[m][s].Lo);
T[m][s].Hi := JI(Obj, K + 'hi', T[m][s].Hi);
if T[m][s].Hi <= T[m][s].Lo then // битый конфиг не должен глушить приём
begin
T[m][s].Lo := DefaultFilterSet(m)[s].Lo;
T[m][s].Hi := DefaultFilterSet(m)[s].Hi;
end;
end;
end;
end;
procedure TSettingsManager.SaveFilters(const MAC: array of Byte;
const T: TFilterTable);
var
K: string;
Root, Obj: TJSONObject;
Def: TFilterSet;
m, s, Idx: Integer;
Differs: Boolean;
begin
Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'filters');
for m := 0 to CFG_MODE_MAX do
begin
Def := DefaultFilterSet(m);
Differs := False;
for s := 0 to FILT_SLOTS - 1 do
if (T[m][s].Lo <> Def[s].Lo) or (T[m][s].Hi <> Def[s].Hi) or
(T[m][s].Name <> Def[s].Name) then Differs := True;
// Секцию режима всегда сносим целиком: иначе возврат слота к заводскому
// оставил бы старый ключ и он всплыл бы при следующей загрузке.
Idx := Root.IndexOfName('m' + IntToStr(m));
if Idx >= 0 then Root.Delete(Idx);
if not Differs then Continue;
Obj := EnsureObj(Root, 'm' + IntToStr(m));
for s := 0 to FILT_SLOTS - 1 do
begin
K := 's' + IntToStr(s) + '_';
JWS(Obj, K + 'n', T[m][s].Name);
JW (Obj, K + 'lo', T[m][s].Lo);
JW (Obj, K + 'hi', T[m][s].Hi);
end;
end;
end;
class procedure TSettingsManager.DefaultAnt936x(out A: TAD936xAnt);
var i: Integer;
begin
for i := 0 to CFG_BAND_COUNT - 1 do
begin
A.RxAnt[i] := 0; // RX1
A.TxAnt[i] := 0; // TX1
end;
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
A.XvtrRxAnt[i] := 0;
A.XvtrTxAnt[i] := 0;
end;
end;
function TSettingsManager.LoadAnt936x(const MAC: array of Byte;
out A: TAD936xAnt): Boolean;
var MacStr: string; DevObj, O: TJSONObject; i: Integer;
begin
DefaultAnt936x(A);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('ant936x') = nil then begin Result := False; Exit; end;
O := EnsureObj(DevObj, 'ant936x');
for i := 0 to CFG_BAND_COUNT - 1 do
begin
A.RxAnt[i] := EnsureRange(JI(O, 'rx_' + IntToStr(i), 0), 0, 1);
A.TxAnt[i] := EnsureRange(JI(O, 'tx_' + IntToStr(i), 0), 0, 1);
end;
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
A.XvtrRxAnt[i] := EnsureRange(JI(O, 'xvtr_rx_' + IntToStr(i), 0), 0, 1);
A.XvtrTxAnt[i] := EnsureRange(JI(O, 'xvtr_tx_' + IntToStr(i), 0), 0, 1);
end;
end;
procedure TSettingsManager.SaveAnt936x(const MAC: array of Byte;
const A: TAD936xAnt);
var O: TJSONObject; i: Integer;
begin
O := EnsureObj(GetDevObj(MacToStr(MAC)), 'ant936x');
for i := 0 to CFG_BAND_COUNT - 1 do
begin
JW(O, 'rx_' + IntToStr(i), Integer(A.RxAnt[i]));
JW(O, 'tx_' + IntToStr(i), Integer(A.TxAnt[i]));
end;
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
JW(O, 'xvtr_rx_' + IntToStr(i), Integer(A.XvtrRxAnt[i]));
JW(O, 'xvtr_tx_' + IntToStr(i), Integer(A.XvtrTxAnt[i]));
end;
end;
procedure TSettingsManager.SaveAlex(const MAC: array of Byte;
const A: TAlexSettings);
var O: TJSONObject; i: Integer;
begin
O := EnsureObj(GetDevObj(MacToStr(MAC)), 'alex');
for i := 0 to CFG_BAND_COUNT - 1 do
begin
JW(O, 'rx_ant_' + IntToStr(i), Integer(A.RxAnt[i]));
JW(O, 'tx_ant_' + IntToStr(i), Integer(A.TxAnt[i]));
JW(O, 'rx_only_' + IntToStr(i), Integer(A.RxOnly[i]));
JW(O, 'do_not_tx_' + IntToStr(i), A.DoNotTx[i]);
end;
JW(O, 'rx_bypass_on_tx', A.RxBypassOnTx);
JW(O, 'ext1_on_tx', A.Ext1OnTx);
JW(O, 'gnd_bpf2_on_tx', A.GndBpf2OnTx);
JW(O, 'enable_xvtr_hf', A.EnableXvtrHf);
end;
// ---------------------------------------------------------------------------
// XVTR settings (per-device, JSON-секция "xvtr" под MAC)
// ---------------------------------------------------------------------------
class procedure TSettingsManager.DefaultXvtr(out X: TXvtrSettings);
var i: Integer;
begin
FillChar(X, SizeOf(X), 0);
X.UseXVTRTunePower := False;
// Все слоты по умолчанию disabled. Первые два предзаполнены типичными
// значениями (2m с LO=116MHz, 70cm с LO=404MHz) — чтобы пользователь
// мог быстро включить.
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
X.Entries[i].Enabled := False;
X.Entries[i].ButtonText := '';
X.Entries[i].FreqBegin := 0.0;
X.Entries[i].FreqEnd := 0.0;
X.Entries[i].LOOffset := 0.0;
X.Entries[i].LOError := 0.0;
X.Entries[i].FullDuplexTx := False;
X.Entries[i].TxLOOffset := 0.0;
X.Entries[i].TxLOError := 0.0;
X.Entries[i].RXGain := 0.0;
X.Entries[i].RXOnly := False;
X.Entries[i].TXPower := 100;
X.Entries[i].DisablePA := False;
X.Entries[i].RXAntenna := 0;
X.Entries[i].LastFreq := 0.0;
X.Entries[i].LastFreqB := 0.0;
X.Entries[i].LastMode := MODE_USB; // VHF SSB calling default
X.Entries[i].LastFilterIdx := 0; // NFM
X.Entries[i].LastFMSQOn := False;
X.Entries[i].LastFMSQLevel := 30;
X.Entries[i].LastCTCSSOn := False;
X.Entries[i].LastCTCSSToneIdx := 0;
X.Entries[i].LastFMStepOn := True;
X.Entries[i].LastFMStepIdx := 3; // 25 kHz
X.Entries[i].LastCTun := False;
X.Entries[i].LastCenterHz := 0.0;
X.Entries[i].LastAGCMode := 1;
X.Entries[i].LastAGCTop := 90;
X.Entries[i].LastFMRptDir := 0;
X.Entries[i].LastFMRptOffsetHz := 0.0;
X.Entries[i].LastCTCSSAutoActive := False;
X.Entries[i].LastFMRptAutoActive := False;
X.Entries[i].LastZoomFactor := 0.0;
X.Entries[i].LastPanSlider := 0.5;
X.Entries[i].LastRxGainMode := 2; // slow_attack
X.Entries[i].LastRxGainDb := 40;
X.Entries[i].LastAttenDB := 0;
end;
// Шаблоны (пользователь должен сам Enable, чтобы трансверторы появились)
X.Entries[0].ButtonText := '2m';
X.Entries[0].FreqBegin := 144000000;
X.Entries[0].FreqEnd := 148000000;
X.Entries[0].LOOffset := 116000000;
X.Entries[0].LastFreq := 144300000;
X.Entries[0].LastFreqB := 145500000;
X.Entries[1].ButtonText := '70cm';
X.Entries[1].FreqBegin := 430000000;
X.Entries[1].FreqEnd := 440000000;
X.Entries[1].LOOffset := 404000000;
X.Entries[1].LastFreq := 432200000;
X.Entries[1].LastFreqB := 433000000;
// QO-100 (Es'hail-2) NB downlink: 10489.50010490.000 МГц, LO 9750 МГц.
// RX-трансвертер: tune = downlink LO. Калибровать LOError по маяку.
// QO-100 — в зарезервированном последнем слоте (отдельная страница настроек).
X.Entries[QO100_SLOT].ButtonText := 'QO-100';
X.Entries[QO100_SLOT].FreqBegin := 10489500000;
X.Entries[QO100_SLOT].FreqEnd := 10490000000;
X.Entries[QO100_SLOT].LOOffset := 9750000000; // LNB LO
X.Entries[QO100_SLOT].FullDuplexTx := True; // раздельный TX-LO + full-duplex
X.Entries[QO100_SLOT].TxLOOffset := 8089500000; // транспондер-offset (downlink-uplink)
X.Entries[QO100_SLOT].LastFreq := 10489750000;
X.Entries[QO100_SLOT].LastFreqB := 10489750000;
end;
function TSettingsManager.LoadXvtr(const MAC: array of Byte;
out X: TXvtrSettings): Boolean;
var
MacStr: string;
DevObj, Root, EObj: TJSONObject;
i: Integer;
begin
DefaultXvtr(X);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('xvtr') = nil then Exit;
Root := EnsureObj(DevObj, 'xvtr');
X.UseXVTRTunePower := JB(Root, 'use_tun_power', False);
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
if Root.Find('e' + IntToStr(i)) = nil then Continue;
EObj := EnsureObj(Root, 'e' + IntToStr(i));
X.Entries[i].Enabled := JB(EObj, 'en', X.Entries[i].Enabled);
X.Entries[i].ButtonText := JS(EObj, 'name', X.Entries[i].ButtonText);
X.Entries[i].FreqBegin := JD(EObj, 'fbeg', X.Entries[i].FreqBegin);
X.Entries[i].FreqEnd := JD(EObj, 'fend', X.Entries[i].FreqEnd);
X.Entries[i].LOOffset := JD(EObj, 'lo_off', X.Entries[i].LOOffset);
X.Entries[i].LOError := JD(EObj, 'lo_err', X.Entries[i].LOError);
X.Entries[i].FullDuplexTx := JB(EObj, 'full_dup', X.Entries[i].FullDuplexTx);
X.Entries[i].TxLOOffset := JD(EObj, 'tx_lo_off', X.Entries[i].TxLOOffset);
X.Entries[i].TxLOError := JD(EObj, 'tx_lo_error', X.Entries[i].TxLOError);
X.Entries[i].RXGain := JD(EObj, 'rx_gain', X.Entries[i].RXGain);
X.Entries[i].RXOnly := JB(EObj, 'rx_only', X.Entries[i].RXOnly);
X.Entries[i].TXPower := EnsureRange(JI(EObj, 'tx_pwr', X.Entries[i].TXPower), 0, 100);
X.Entries[i].DisablePA := JB(EObj, 'dis_pa', X.Entries[i].DisablePA);
X.Entries[i].RXAntenna := EnsureRange(JI(EObj, 'rx_ant', X.Entries[i].RXAntenna), 0, 3);
X.Entries[i].LastFreq := JD(EObj, 'last_freq', X.Entries[i].LastFreq);
X.Entries[i].LastFreqB := JD(EObj, 'last_freq_b', X.Entries[i].LastFreqB);
X.Entries[i].LastMode := EnsureRange(JI(EObj, 'last_mode', X.Entries[i].LastMode), CFG_MODE_MIN, CFG_MODE_MAX);
// Верхняя граница = FILT_SLOTS-1, а не 9: слоты VAR1/VAR2 (10/11) иначе
// схлопывались в 9, и при восстановлении трансвертера ClampFilterIdx
// видел индекс вне таблицы режима и откатывал на заводской фильтр.
X.Entries[i].LastFilterIdx := EnsureRange(JI(EObj, 'last_filt', X.Entries[i].LastFilterIdx), 0, FILT_SLOTS - 1);
X.Entries[i].LastFMSQOn := JB(EObj, 'last_fmsq_on', X.Entries[i].LastFMSQOn);
X.Entries[i].LastFMSQLevel := EnsureRange(JI(EObj, 'last_fmsq_lvl', X.Entries[i].LastFMSQLevel), 0, 100);
X.Entries[i].LastCTCSSOn := JB(EObj, 'last_ctcss_on', X.Entries[i].LastCTCSSOn);
X.Entries[i].LastCTCSSToneIdx := EnsureRange(JI(EObj, 'last_ctcss_idx', X.Entries[i].LastCTCSSToneIdx), 0, 37);
X.Entries[i].LastFMStepOn := JB(EObj, 'last_fmstep_on', X.Entries[i].LastFMStepOn);
X.Entries[i].LastFMStepIdx := EnsureRange(JI(EObj, 'last_fmstep_idx', X.Entries[i].LastFMStepIdx), 0, 3);
X.Entries[i].LastCTun := JB(EObj, 'last_ctun', X.Entries[i].LastCTun);
X.Entries[i].LastCenterHz := JD(EObj, 'last_center_hz', X.Entries[i].LastCenterHz);
X.Entries[i].LastAGCMode := EnsureRange(JI(EObj, 'last_agc_mode', X.Entries[i].LastAGCMode), 0, 4);
X.Entries[i].LastAGCTop := EnsureRange(JI(EObj, 'last_agc_top', X.Entries[i].LastAGCTop), 0, 120);
X.Entries[i].LastFMRptDir := EnsureRange(JI(EObj, 'last_fmrpt_dir', X.Entries[i].LastFMRptDir), 0, 2);
X.Entries[i].LastFMRptOffsetHz := JD(EObj, 'last_fmrpt_offset', X.Entries[i].LastFMRptOffsetHz);
X.Entries[i].LastCTCSSAutoActive := JB(EObj, 'last_ctcss_auto', X.Entries[i].LastCTCSSAutoActive);
X.Entries[i].LastFMRptAutoActive := JB(EObj, 'last_fmrpt_auto', X.Entries[i].LastFMRptAutoActive);
X.Entries[i].LastZoomFactor := EnsureRange(JD(EObj, 'last_zoom_factor', X.Entries[i].LastZoomFactor), 0.0, 1.0);
X.Entries[i].LastPanSlider := EnsureRange(JD(EObj, 'last_pan_slider', X.Entries[i].LastPanSlider), 0.0, 1.0);
X.Entries[i].LastRxGainMode := EnsureRange(JI(EObj, 'last_rx_gain_mode', X.Entries[i].LastRxGainMode), 0, 3);
X.Entries[i].LastRxGainDb := EnsureRange(JI(EObj, 'last_rx_gain_db', X.Entries[i].LastRxGainDb), 0, 73);
X.Entries[i].LastAttenDB := EnsureRange(JI(EObj, 'last_atten_db', X.Entries[i].LastAttenDB), 0, 31);
// Валидация: Last freqs должны быть в [FreqBegin..FreqEnd]
if (X.Entries[i].LastFreq < X.Entries[i].FreqBegin) or
(X.Entries[i].LastFreq > X.Entries[i].FreqEnd) then
X.Entries[i].LastFreq := (X.Entries[i].FreqBegin + X.Entries[i].FreqEnd) / 2.0;
if (X.Entries[i].LastFreqB < X.Entries[i].FreqBegin) or
(X.Entries[i].LastFreqB > X.Entries[i].FreqEnd) then
X.Entries[i].LastFreqB := (X.Entries[i].FreqBegin + X.Entries[i].FreqEnd) / 2.0;
end;
// QO-100 закреплён за зарезервированным слотом QO100_SLOT. Восстанавливаем
// шаблон, если слот не размечен как split-LO транспондер ИЛИ значения пустые
// (старый конфиг сохранял слот как обычный пустой XVTR с нулями — тогда
// FullDuplexTx по умолчанию True, но LO/offset == 0). Реальный QO-100 всегда
// имеет положительный транспондер-offset, так что валидный конфиг не трогаем.
if (not X.Entries[QO100_SLOT].FullDuplexTx)
or (X.Entries[QO100_SLOT].TxLOOffset <= 0)
or (X.Entries[QO100_SLOT].LOOffset <= 0) then
begin
X.Entries[QO100_SLOT].Enabled := False;
X.Entries[QO100_SLOT].ButtonText := 'QO-100';
X.Entries[QO100_SLOT].FreqBegin := 10489500000;
X.Entries[QO100_SLOT].FreqEnd := 10490000000;
X.Entries[QO100_SLOT].LOOffset := 9750000000;
X.Entries[QO100_SLOT].FullDuplexTx := True;
X.Entries[QO100_SLOT].TxLOOffset := 8089500000;
X.Entries[QO100_SLOT].LastFreq := 10489750000;
X.Entries[QO100_SLOT].LastFreqB := 10489750000;
end;
end;
procedure TSettingsManager.MigrateXvtrTemplate(var X: TXvtrSettings;
const AName: string; ABegin, AEnd, ALO: Double;
AFullDup: Boolean = False; ATxLO: Double = 0.0);
var
i, freeIdx: Integer;
begin
freeIdx := -1;
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
if SameText(X.Entries[i].ButtonText, AName) then Exit; // уже есть — ничего не делаем
if (freeIdx < 0) and (not X.Entries[i].Enabled) and
(Trim(X.Entries[i].ButtonText) = '') then
freeIdx := i;
end;
if freeIdx < 0 then Exit; // нет свободных слотов
X.Entries[freeIdx].ButtonText := AName;
X.Entries[freeIdx].FreqBegin := ABegin;
X.Entries[freeIdx].FreqEnd := AEnd;
X.Entries[freeIdx].LOOffset := ALO;
X.Entries[freeIdx].FullDuplexTx := AFullDup;
X.Entries[freeIdx].TxLOOffset := ATxLO;
X.Entries[freeIdx].LastFreq := (ABegin + AEnd) / 2.0;
X.Entries[freeIdx].LastFreqB := (ABegin + AEnd) / 2.0;
end;
procedure TSettingsManager.SaveXvtr(const MAC: array of Byte;
const X: TXvtrSettings);
var
Root, EObj: TJSONObject;
i: Integer;
begin
Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'xvtr');
JW(Root, 'use_tun_power', X.UseXVTRTunePower);
for i := 0 to CFG_XVTR_COUNT - 1 do
begin
EObj := EnsureObj(Root, 'e' + IntToStr(i));
JW(EObj, 'en', X.Entries[i].Enabled);
JWS(EObj, 'name', X.Entries[i].ButtonText);
JW(EObj, 'fbeg', X.Entries[i].FreqBegin);
JW(EObj, 'fend', X.Entries[i].FreqEnd);
JW(EObj, 'lo_off', X.Entries[i].LOOffset);
JW(EObj, 'lo_err', X.Entries[i].LOError);
JW(EObj, 'full_dup', X.Entries[i].FullDuplexTx);
JW(EObj, 'tx_lo_off', X.Entries[i].TxLOOffset);
JW(EObj, 'tx_lo_error', X.Entries[i].TxLOError);
JW(EObj, 'rx_gain', X.Entries[i].RXGain);
JW(EObj, 'rx_only', X.Entries[i].RXOnly);
JW(EObj, 'tx_pwr', X.Entries[i].TXPower);
JW(EObj, 'dis_pa', X.Entries[i].DisablePA);
JW(EObj, 'rx_ant', Integer(X.Entries[i].RXAntenna));
JW(EObj, 'last_freq', X.Entries[i].LastFreq);
JW(EObj, 'last_freq_b', X.Entries[i].LastFreqB);
JW(EObj, 'last_mode', X.Entries[i].LastMode);
JW(EObj, 'last_filt', X.Entries[i].LastFilterIdx);
JW(EObj, 'last_fmsq_on', X.Entries[i].LastFMSQOn);
JW(EObj, 'last_fmsq_lvl', X.Entries[i].LastFMSQLevel);
JW(EObj, 'last_ctcss_on', X.Entries[i].LastCTCSSOn);
JW(EObj, 'last_ctcss_idx',X.Entries[i].LastCTCSSToneIdx);
JW(EObj, 'last_fmstep_on', X.Entries[i].LastFMStepOn);
JW(EObj, 'last_fmstep_idx',X.Entries[i].LastFMStepIdx);
JW(EObj, 'last_ctun', X.Entries[i].LastCTun);
JW(EObj, 'last_center_hz', X.Entries[i].LastCenterHz);
JW(EObj, 'last_agc_mode', X.Entries[i].LastAGCMode);
JW(EObj, 'last_agc_top', X.Entries[i].LastAGCTop);
JW(EObj, 'last_fmrpt_dir', X.Entries[i].LastFMRptDir);
JW(EObj, 'last_fmrpt_offset', X.Entries[i].LastFMRptOffsetHz);
JW(EObj, 'last_ctcss_auto', X.Entries[i].LastCTCSSAutoActive);
JW(EObj, 'last_fmrpt_auto', X.Entries[i].LastFMRptAutoActive);
JW(EObj, 'last_zoom_factor', X.Entries[i].LastZoomFactor);
JW(EObj, 'last_pan_slider', X.Entries[i].LastPanSlider);
JW(EObj, 'last_rx_gain_mode', X.Entries[i].LastRxGainMode);
JW(EObj, 'last_rx_gain_db', X.Entries[i].LastRxGainDb);
JW(EObj, 'last_atten_db', X.Entries[i].LastAttenDB);
end;
end;
class procedure TSettingsManager.DefaultDXCluster(out D: TDXClusterSettings);
begin
D.Enabled := False; // без позывного подключаться всё равно нечем
D.ShowSpots := False; // подписи включает пользователь кнопкой DX
D.Host := 'cluster.dxfun.com';
D.Port := 8000;
D.Login := '';
D.Password := '';
D.PostLogin := '';
D.TTLMinutes := 30;
D.MaxRows := 4;
D.ModeMask := 0; // 0 = без фильтра по модам
end;
procedure TSettingsManager.LoadDXClusterSettings(out D: TDXClusterSettings);
var O: TJSONObject;
begin
DefaultDXCluster(D);
if FRoot.Find('dxcluster') = nil then Exit;
O := EnsureObj(FRoot, 'dxcluster');
D.Enabled := JB(O, 'enabled', False);
D.ShowSpots := JB(O, 'show_spots', False);
D.Host := JS(O, 'host', 'cluster.dxfun.com');
D.Port := EnsureRange(JI(O, 'port', 8000), 1, 65535);
D.Login := JS(O, 'login', '');
D.Password := JS(O, 'password', '');
D.PostLogin := JS(O, 'post_login', '');
D.TTLMinutes := EnsureRange(JI(O, 'ttl_minutes', 30), 1, 1440);
D.MaxRows := EnsureRange(JI(O, 'max_rows', 4), 1, 6);
D.ModeMask := LongWord(JI(O, 'mode_mask', 0));
end;
procedure TSettingsManager.SaveDXClusterSettings(const D: TDXClusterSettings);
var O: TJSONObject;
begin
O := EnsureObj(FRoot, 'dxcluster');
JW(O, 'enabled', D.Enabled);
JW(O, 'show_spots', D.ShowSpots);
JWS(O, 'host', D.Host);
JW(O, 'port', D.Port);
JWS(O, 'login', D.Login);
JWS(O, 'password', D.Password);
JWS(O, 'post_login', D.PostLogin);
JW(O, 'ttl_minutes', D.TTLMinutes);
JW(O, 'max_rows', D.MaxRows);
JW(O, 'mode_mask', Integer(D.ModeMask));
Save;
end;
class procedure TSettingsManager.DefaultWeb(out W: TWebSettings);
begin
W.Enabled := True;
W.Port := 8080;
W.BindAddr := '0.0.0.0';
W.User := 'admin';
W.Pass := 'hpsdr';
W.SpecPixels := 1024; // экономный дефолт: столько же, сколько слал протокол раньше
end;
procedure TSettingsManager.LoadWebSettings(out W: TWebSettings);
var O: TJSONObject;
begin
DefaultWeb(W);
if FRoot.Find('web') = nil then Exit;
O := EnsureObj(FRoot, 'web');
W.Enabled := JB(O, 'enabled', True);
W.Port := EnsureRange(JI(O, 'port', 8080), 1, 65535);
W.BindAddr := JS(O, 'bind_addr', '0.0.0.0');
W.User := JS(O, 'user', 'admin');
W.Pass := JS(O, 'pass', 'hpsdr');
W.SpecPixels := EnsureRange(JI(O, 'spec_pixels', 1024), 256, 4096);
end;
procedure TSettingsManager.SaveWebSettings(const W: TWebSettings);
var O: TJSONObject;
begin
O := EnsureObj(FRoot, 'web');
JW(O, 'enabled', W.Enabled);
JW(O, 'port', W.Port);
JWS(O, 'bind_addr', W.BindAddr);
JWS(O, 'user', W.User);
JWS(O, 'pass', W.Pass);
JW(O, 'spec_pixels', W.SpecPixels);
Save;
end;
class procedure TSettingsManager.DefaultTCI(out T: TTCISettings);
begin
T.Enabled := False; // включается осознанно, как CAT-транспорты
T.Port := 40001;
T.BindAddr := '127.0.0.1';
T.RecordDir := ''; // пусто = <каталог конфигурации>/records
end;
procedure TSettingsManager.LoadTCISettings(out T: TTCISettings);
var O: TJSONObject;
begin
DefaultTCI(T);
if FRoot.Find('tci') = nil then Exit;
O := EnsureObj(FRoot, 'tci');
T.Enabled := JB(O, 'enabled', False);
T.Port := EnsureRange(JI(O, 'port', 40001), 1, 65535);
T.BindAddr := JS(O, 'bind_addr', '127.0.0.1');
T.RecordDir := JS(O, 'record_dir', '');
end;
procedure TSettingsManager.SaveTCISettings(const T: TTCISettings);
var O: TJSONObject;
begin
O := EnsureObj(FRoot, 'tci');
JW(O, 'enabled', T.Enabled);
JW(O, 'port', T.Port);
JWS(O, 'bind_addr', T.BindAddr);
JWS(O, 'record_dir', T.RecordDir);
Save;
end;
procedure TSettingsManager.SaveTheme(ALightTheme: Boolean);
var O: TJSONObject;
begin
O := EnsureObj(FRoot, 'preferences');
JW(O, 'light_theme', ALightTheme);
end;
function TSettingsManager.LoadTheme: Boolean;
var O: TJSONObject;
begin
Result := False;
if FRoot.Find('preferences') = nil then Exit;
O := EnsureObj(FRoot, 'preferences');
Result := JB(O, 'light_theme', False);
end;
// ---------------------------------------------------------------------------
// Панадаптеры + слайсы (per-device, JSON-секция "pans" под MAC)
// ---------------------------------------------------------------------------
class procedure TSettingsManager.DefaultPans(out P: TPansConfig);
var i: Integer;
begin
P.GridMode := False;
P.GridRowShare := 0.5;
for i := 0 to CFG_PANS_MAX-1 do
begin
P.Pans[i].Active := False;
P.Pans[i].FreqHz := 0.0;
P.Pans[i].RateKHz := 384;
P.Pans[i].ADC := 0;
P.Pans[i].Zoom := 0.0;
P.Pans[i].PanSlider := 0.5;
P.Pans[i].StackShare := 1.0;
P.Pans[i].ColShare := 1.0;
P.Pans[i].SplitRatio := 0.40; // = TPanafallPanel дефолт
P.Pans[i].HasDisplay := False;
P.Pans[i].DPalette := 0;
P.Pans[i].DGamma := 1.0;
P.Pans[i].DSpecRef := -20.0;
P.Pans[i].DSpecRange := 110.0;
P.Pans[i].DWfAGC := True;
P.Pans[i].DWfOffset := 0.0;
P.Pans[i].DWfHigh := -60.0;
P.Pans[i].DWfLow := -120.0;
SetLength(P.Pans[i].Slices, 0);
end;
end;
procedure TSettingsManager.SavePans(const MAC: array of Byte; const Ctx: string;
const P: TPansConfig);
var
Root, PanObj, SliceObj, DispObj: TJSONObject;
PanArr, SliceArr: TJSONArray;
i, j, Idx: Integer;
begin
Root := EnsureObj(EnsureObj(GetDevObj(MacToStr(MAC)), 'pans'), Ctx);
JW(Root, 'grid_mode', P.GridMode);
JW(Root, 'grid_row_share', P.GridRowShare);
// Список панов пересоздаётся целиком (проще, чем merge по индексам).
Idx := Root.IndexOfName('list');
if Idx >= 0 then Root.Delete(Idx);
PanArr := TJSONArray.Create;
Root.Add('list', PanArr);
for i := 0 to CFG_PANS_MAX-1 do
begin
// Пан 0 пишем всегда (ради его доли стека/слайсов), паны N — только активные.
if (i > 0) and not P.Pans[i].Active then Continue;
PanObj := TJSONObject.Create;
PanArr.Add(PanObj);
PanObj.Add('id', i);
PanObj.Add('active', P.Pans[i].Active);
PanObj.Add('freq', P.Pans[i].FreqHz);
PanObj.Add('rate_khz', P.Pans[i].RateKHz);
PanObj.Add('adc', P.Pans[i].ADC);
PanObj.Add('zoom', P.Pans[i].Zoom);
PanObj.Add('pan', P.Pans[i].PanSlider);
PanObj.Add('stack_share', P.Pans[i].StackShare);
PanObj.Add('col_share', P.Pans[i].ColShare);
PanObj.Add('split_ratio', P.Pans[i].SplitRatio);
// Per-pan дисплей: пишем блок только при override (иначе пан = глоб. дефолт).
if P.Pans[i].HasDisplay then
begin
DispObj := TJSONObject.Create;
PanObj.Add('display', DispObj);
DispObj.Add('palette', P.Pans[i].DPalette);
DispObj.Add('gamma', P.Pans[i].DGamma);
DispObj.Add('spec_ref', P.Pans[i].DSpecRef);
DispObj.Add('spec_range', P.Pans[i].DSpecRange);
DispObj.Add('wf_agc', P.Pans[i].DWfAGC);
DispObj.Add('wf_offset', P.Pans[i].DWfOffset);
DispObj.Add('wf_high', P.Pans[i].DWfHigh);
DispObj.Add('wf_low', P.Pans[i].DWfLow);
end;
SliceArr := TJSONArray.Create;
PanObj.Add('slices', SliceArr);
for j := 0 to High(P.Pans[i].Slices) do
begin
SliceObj := TJSONObject.Create;
SliceArr.Add(SliceObj);
SliceObj.Add('target_hz', P.Pans[i].Slices[j].TargetHz);
SliceObj.Add('mode', P.Pans[i].Slices[j].Mode);
SliceObj.Add('filter_lo', P.Pans[i].Slices[j].FilterLo);
SliceObj.Add('filter_hi', P.Pans[i].Slices[j].FilterHi);
SliceObj.Add('agc', P.Pans[i].Slices[j].AGC);
SliceObj.Add('volume', P.Pans[i].Slices[j].Volume);
SliceObj.Add('muted', P.Pans[i].Slices[j].Muted);
SliceObj.Add('rx_mute_on_tx', P.Pans[i].Slices[j].RxMuteOnTx);
SliceObj.Add('fmsq_on', P.Pans[i].Slices[j].FMSQOn);
SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel);
SliceObj.Add('dev_name', P.Pans[i].Slices[j].DevName);
SliceObj.Add('in_dev_name', P.Pans[i].Slices[j].InDevName);
SliceObj.Add('tx_prof_modes',
ModeTableToJSON(P.Pans[i].Slices[j].TXProfByMode));
end;
end;
end;
function TSettingsManager.LoadPans(const MAC: array of Byte; const Ctx: string;
out P: TPansConfig): Boolean;
var
MacStr: string;
DevObj, PansObj, Root, PanObj, SliceObj, DispObj: TJSONObject;
ListData, SlData, DispData: TJSONData;
Arr, SArr: TJSONArray;
i, id, k, n: Integer;
begin
DefaultPans(P);
MacStr := MacToStr(MAC);
Result := FRoot.Find(MacStr) <> nil;
if not Result then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('pans') = nil then begin Result := False; Exit; end;
PansObj := EnsureObj(DevObj, 'pans');
if PansObj.Find(Ctx) = nil then begin Result := False; Exit; end;
Root := EnsureObj(PansObj, Ctx);
P.GridMode := JB(Root, 'grid_mode', False);
P.GridRowShare := EnsureRange(JD(Root, 'grid_row_share', 0.5), 0.15, 0.85);
ListData := Root.Find('list');
if (ListData = nil) or not (ListData is TJSONArray) then Exit;
Arr := TJSONArray(ListData);
for i := 0 to Arr.Count-1 do
begin
if not (Arr.Items[i] is TJSONObject) then Continue;
PanObj := TJSONObject(Arr.Items[i]);
id := JI(PanObj, 'id', -1);
if (id < 0) or (id >= CFG_PANS_MAX) then Continue;
P.Pans[id].Active := JB(PanObj, 'active', id > 0);
P.Pans[id].FreqHz := JD(PanObj, 'freq', 0.0);
P.Pans[id].RateKHz := JI(PanObj, 'rate_khz', 384);
P.Pans[id].ADC := JI(PanObj, 'adc', 0);
P.Pans[id].Zoom := EnsureRange(JD(PanObj, 'zoom', 0.0), 0.0, 1.0);
P.Pans[id].PanSlider := EnsureRange(JD(PanObj, 'pan', 0.5), 0.0, 1.0);
P.Pans[id].StackShare := JD(PanObj, 'stack_share', 1.0);
P.Pans[id].ColShare := JD(PanObj, 'col_share', 1.0);
P.Pans[id].SplitRatio := EnsureRange(JD(PanObj, 'split_ratio', 0.40), 0.05, 0.95);
// Per-pan дисплей: присутствие блока 'display' = override.
DispData := PanObj.Find('display');
if (DispData <> nil) and (DispData is TJSONObject) then
begin
DispObj := TJSONObject(DispData);
P.Pans[id].HasDisplay := True;
P.Pans[id].DPalette := EnsureRange(JI(DispObj, 'palette', 0), 0, 2);
P.Pans[id].DGamma := EnsureRange(JD(DispObj, 'gamma', 1.0), 0.30, 3.00);
P.Pans[id].DSpecRef := JD(DispObj, 'spec_ref', -20.0);
P.Pans[id].DSpecRange := JD(DispObj, 'spec_range', 110.0);
P.Pans[id].DWfAGC := JB(DispObj, 'wf_agc', True);
P.Pans[id].DWfOffset := JD(DispObj, 'wf_offset', 0.0);
P.Pans[id].DWfHigh := JD(DispObj, 'wf_high', -60.0);
P.Pans[id].DWfLow := JD(DispObj, 'wf_low', -120.0);
end;
SlData := PanObj.Find('slices');
if (SlData = nil) or not (SlData is TJSONArray) then Continue;
SArr := TJSONArray(SlData);
SetLength(P.Pans[id].Slices, 0);
for k := 0 to SArr.Count-1 do
begin
if not (SArr.Items[k] is TJSONObject) then Continue;
SliceObj := TJSONObject(SArr.Items[k]);
n := Length(P.Pans[id].Slices);
SetLength(P.Pans[id].Slices, n+1);
P.Pans[id].Slices[n].TargetHz := JD(SliceObj, 'target_hz', 0.0);
P.Pans[id].Slices[n].Mode := EnsureRange(
JI(SliceObj, 'mode', MODE_LSB), CFG_MODE_MIN, CFG_MODE_MAX);
P.Pans[id].Slices[n].FilterLo := JI(SliceObj, 'filter_lo', -1500);
P.Pans[id].Slices[n].FilterHi := JI(SliceObj, 'filter_hi', 1500);
P.Pans[id].Slices[n].AGC := JI(SliceObj, 'agc', 1);
P.Pans[id].Slices[n].Volume := EnsureRange(JD(SliceObj, 'volume', 0.7), 0.0, 1.0);
P.Pans[id].Slices[n].Muted := JB(SliceObj, 'muted', False);
P.Pans[id].Slices[n].RxMuteOnTx := JB(SliceObj, 'rx_mute_on_tx', True);
P.Pans[id].Slices[n].FMSQOn := JB(SliceObj, 'fmsq_on', False);
P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30);
P.Pans[id].Slices[n].DevName := JS(SliceObj, 'dev_name', '');
P.Pans[id].Slices[n].InDevName := JS(SliceObj, 'in_dev_name', '');
JSONToModeTable(SliceObj.Find('tx_prof_modes'),
P.Pans[id].Slices[n].TXProfByMode);
end;
end;
end;
end.