feat(cat): CAT-порт на каждый слайс + Auto TX (эталон SmartSDR CAT)

Внешняя программа управляет отдельным слайсом как отдельным трансивером
через свой TCP-порт. COM-порты и 19090 остаются за главным приёмником
(слайс A) и не тронуты.

Протокол не дублируется: TCATEngine — чистый парсер поверх записи
callback'ов TCATContext, TCATTcpServer — транспорт поверх движка, поэтому
слайс-CAT = тот же движок с другим контекстом. Новый TCATSliceEndpoint
(движок + TCP-сервер на слот), массивом владеет TCATAdapter. Привязка к
СЛОТУ (буква B..G), не к Id: порт живёт, даже когда слайса нет (PS0).

Auto TX = «Auto Switch TX Slice» из SmartSDR CAT. Вся логика в одной точке
— TRadioController.RequestSliceTx, исполняется в потоке контроллера:
  • пока кто-то уже в эфире (любой источник) — заявка игнорируется,
    перехвата передачи нет никогда;
  • без Auto TX слайс передаёт, только если уже выбран TX-источником;
  • RX; снимает только СВОЮ передачу;
  • TX-источник после отпускания остаётся на слайсе (как в SmartSDR).
На флаге слайса бейдж TX → AutoTX.

Частота слайса — TuneSliceInBand: свобода в пределах ВКЛЮЧЁННОГО диапазона
(трансвертер → его FreqBegin/FreqEnd, иначе band-план), другой диапазон —
отказ. Вне захваченной полосы окно DDC переезжает: доп. пан — центром на
цель (rfPanFreq → UI перекладывает флаги/шапку/зум-бар), пан 0 — центр
посередине между целью и главным VFO и только если оба влезают (общее
железное окно, главный приёмник не оглушаем).

Настройки: вкладка Slices (enable/порт 19091../Auto TX на слайс), персист
в секции cat + device-blob.

Попутно:
  • ZZFL/ZZFH больше не заглушки — новые callback'и кромок фильтра
    (у главного nil → прежний дефолт режима), хелпер SignedPad;
  • FilterIdxFor, AGCModeToUI, BoardUtils.BandEdges;
  • DSP-состояние слайса (NR/NB/SNB/ANF) зеркалится в TCtrlSlice — флаг
    слайса рисовал нули и AGC главного;
  • FUIReady в TMainForm.OnControllerState: адаптеры, создаваемые по ходу
    FormCreate, больше не могут уронить старт рендером до постройки
    виджетов (на этом падал Auto TX из настроек).

Проверено вживую: все порты 19091..19096 слушают, PS;/ZZFA;/MD;/ID;/SM0;/
ZZTX; отвечают; старт со всеми включёнными слайсами чист под gdb.
Документ — doc/CAT_SLICES.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:56:36 +03:00
co-authored by Claude Opus 5
parent 8611898ad1
commit 2d15da0462
13 changed files with 1357 additions and 34 deletions
+224 -1
View File
@@ -84,7 +84,8 @@ type
rfADCOverload, // ADC overload (рендер индикатора)
rfBeaconLock, // QO-100 beacon lock: статус/коррекция
rfDevice, // подключённое устройство сменилось
rfDeviceList // список discovered устройств обновился
rfDeviceList, // список discovered устройств обновился
rfPanFreq // центр доп. пана уехал (ретюн DDC извне)
);
TRadioStateEvent = procedure(Sender: TObject; Field: TRadioField) of object;
@@ -162,6 +163,12 @@ type
RxMuteOnTx: Boolean; // True (умолч.) = динамик слайса молчит на передаче
FMSQOn: Boolean; // FM-squelch включён (слайс в режиме FM)
FMSQLevel: Integer; // порог squelch 0..100
// Зеркало DSP-состояния слайса (движок его не отдаёт обратно): нужно UI
// флага и CAT-геттерам. Пишется только через SetSliceDSP.
NRMode: Integer; // 0=выкл, 1=NR, 2=NR2 (как у главного)
NBMode: Integer; // 0=выкл, 1=NB, 2=NB2
SNB: Boolean;
ANF: Boolean;
DeviceIndex: Integer; // PortAudio-индекс вывода (-1 = default)
DevName: string;
Audio: TAudioOutput; // владеет; свой поток вывода
@@ -330,6 +337,10 @@ type
FTwoTone: Boolean; // 2TON активен
FSplitTxB: Boolean;
FTxSliceId: Integer; // 0 = главный VFO (A/B split); >0 = слайс-источник TX
// Auto TX слайса (индекс = СЛОТ слайса, не Id): PTT по CAT-порту слайса сам
// назначает его TX-источником. Флаг живёт на слоте, а не на слайсе, потому
// что настройка привязана к букве (B..G) и переживает удаление слайса.
FSliceAutoTx: array[0..MAX_SLICES-1] of Boolean;
FVfoLock: Boolean; // блокировка перестройки VFO (Andromeda Lock)
FDisplayDuplex: Boolean;
// QO-100 full-duplex self-monitor: True — RX-аудио глушится на TX (по умолч.),
@@ -563,6 +574,9 @@ type
procedure ApplyBandDSP(const B: TBandSettings);
// Полоса фильтра для (режим, индекс фильтра) — выбор per-mode таблицы.
function FilterBWFor(M, Idx: Integer): Integer;
// Обратное отображение: ближайший индекс пресета к полосе BW (для CAT/UI,
// где полоса задана в Гц, а протокол оперирует индексом).
function FilterIdxFor(M, BW: Integer): Integer;
// ---- XVTR (трансвертер) ----
// ApplyXvtrToNetwork — XVTR enable bit + DisablePA + RX ant в сеть.
@@ -644,6 +658,7 @@ type
procedure SetSliceModeBW(Id, Mode, BW: Integer); // режим+полоса → кромки фильтра
class procedure FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
class function AGCModeFromUI(M: Integer): TWDSPAGCMode; // UI-индекс 0..4 → WDSP enum
class function AGCModeToUI(A: TWDSPAGCMode): Integer; // и обратно
procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
procedure SetSliceVolume(Id: Integer; Vol: Double); // 0..1
procedure SetSliceMute(Id: Integer; Mute: Boolean);
@@ -675,9 +690,30 @@ type
function ActiveMicInDevName: string;
function SliceCount: Integer;
function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
// Слот ↔ слайс. Слот 0..MAX_SLICES-1 = буква B..G и не зависит от того,
// создан ли слайс сейчас: на слот вешаются настройки (CAT-порт, Auto TX).
class function SliceSlotLetter(Slot: Integer): Char;
function SliceIdBySlot(Slot: Integer): Integer; // 0 = слот пуст
function SliceSlotOf(Id: Integer): Integer; // -1 = нет такого слайса
// Auto TX слайса (см. FSliceAutoTx). Геттер по слоту — для UI настроек,
// по Id — для флага слайса и CAT.
function SliceSlotAutoTx(Slot: Integer): Boolean;
procedure SetSliceSlotAutoTx(Slot: Integer; On_: Boolean);
function SliceAutoTx(Id: Integer): Boolean;
// Заявка на передачу ОТ ИМЕНИ слайса (CAT-порт слайса). Единственная точка,
// где внешняя PTT может сменить TX-источник, поэтому здесь же — защита
// «в эфире одновременно только один слайс». Выполнять в потоке контроллера.
procedure RequestSliceTx(SliceId: Integer; On_: Boolean);
// Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс.
procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0);
function SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean;
// Границы ВКЛЮЧЁННОГО сейчас диапазона (трансвертер — его FreqBegin/FreqEnd,
// иначе band-план бэкенда). False = диапазон неизвестен.
function CurrentBandEdges(out LoHz, HiHz: Double): Boolean;
// Перестроить слайс на TargetHz В ПРЕДЕЛАХ текущего диапазона: если частота
// вне захваченной полосы пана — двигаем окно DDC. False = отказ (другой
// диапазон либо окно не подвинуть, не оглушив главный приёмник).
function TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean;
// ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ----
// Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр в ВИДИМЫХ
@@ -1592,6 +1628,72 @@ begin
Result := (TargetHz >= Lo) and (TargetHz <= Hi);
end;
function TRadioController.CurrentBandEdges(out LoHz, HiHz: Double): Boolean;
begin
LoHz := 0; HiHz := 0;
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and
FXvtrSettings.Entries[FCurrentXvtr].Enabled then
begin
// Трансвертер: диапазон — его собственный (2 м = 144..146 и т.п.).
LoHz := FXvtrSettings.Entries[FCurrentXvtr].FreqBegin;
HiHz := FXvtrSettings.Entries[FCurrentXvtr].FreqEnd;
Result := HiHz > LoHz;
end
else
Result := BandEdges(FCurrentBand, IsPluto, LoHz, HiHz);
end;
function TRadioController.TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean;
// Свобода внешней программы: она двигает слайс куда угодно ВНУТРИ включённого
// диапазона (2 м на трансвертере: 144.174 при окне на 145.5 — можно), но сменить
// диапазон (7 МГц) — нет: это решение оператора, а не управляющего ПО.
//
// Если цель вне захваченной полосы — окно DDC переезжает центром на цель:
// • доп. пан (свой DDC) — свободно;
// • пан 0 — только если главный приёмник останется внутри нового окна
// (одно железное окно на двоих; иначе главный оглох бы).
var
idx, PanId: Integer;
Lo, Hi, Half, Dist, NewCenter, VisLo, VisHi: Double;
begin
Result := False;
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
if not CurrentBandEdges(Lo, Hi) then Exit;
if (TargetHz < Lo) or (TargetHz > Hi) then Exit; // другой диапазон — отказ
PanId := FSlices[idx].PanId;
if SliceFitsCapture(TargetHz, PanId) then
begin
SetSliceTarget(Id, TargetHz);
Exit(True);
end;
if PanId > 0 then
begin
if not PanDDCActive(PanId) then Exit;
VisibleFreqBounds(VisLo, VisHi);
NewCenter := EnsureRange(TargetHz, VisLo, VisHi);
SetPanDDCFreq(PanId, NewCenter);
SetSliceTarget(Id, TargetHz);
Changed(rfPanFreq); // UI: шапка/зум-бар/флаги пана под новый центр
Result := True;
end
else
begin
// Пан 0: центр общий с главным приёмником. Ставим его посередине между
// целью и активным VFO — тогда в окно попадают оба; не помещаются —
// отказываем (перестраивать главный ради слайса нельзя).
Half := FSampleRate * 0.475;
Dist := Abs(TargetHz - ActiveVfoHz);
if Dist > Half * 2 * 0.95 then Exit;
NewCenter := (TargetHz + ActiveVfoHz) / 2;
SetCenter(NewCenter); // несёт Changed(rfCenterFreq) + сдвиги слайсов
SetSliceTarget(Id, TargetHz);
Result := True;
end;
end;
{ ---- Панадаптеры на аппаратных DDC (этап 3.1) ---- }
procedure TRadioController.ResetPanDDCMaps;
@@ -1958,6 +2060,10 @@ begin
FSlices[slot].RxMuteOnTx := True; // себя на передаче по умолчанию не слышим
FSlices[slot].FMSQOn := False; // squelch по умолчанию выключен
FSlices[slot].FMSQLevel := 40;
FSlices[slot].NRMode := 0; // DSP слайса стартует выключенным
FSlices[slot].NBMode := 0;
FSlices[slot].SNB := False;
FSlices[slot].ANF := False;
FSlices[slot].DeviceIndex := DevIndex;
FSlices[slot].DevName := DevName;
FSlices[slot].Audio := AO;
@@ -2115,6 +2221,10 @@ var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].NRMode := NRMode;
FSlices[idx].NBMode := NBMode;
FSlices[idx].SNB := SNBOn;
FSlices[idx].ANF := ANFOn;
if Assigned(FDSPEngine) then
begin
FDSPEngine.SetSliceNRMode(Id, NRMode);
@@ -2383,6 +2493,86 @@ begin
if Result then S := FSlices[idx];
end;
// ---------------------------------------------------------------------------
// Слоты слайсов: настройки (CAT-порт, Auto TX) висят на слоте/букве, а слайс
// на слоте может появляться и исчезать.
// ---------------------------------------------------------------------------
class function TRadioController.SliceSlotLetter(Slot: Integer): Char;
begin
// Слайс A — главный приёмник, поэтому слоты нумеруются с 'B' (как AddSlice).
if (Slot < 0) or (Slot >= MAX_SLICES) then Exit('?');
Result := Chr(Ord('B') + Slot);
end;
function TRadioController.SliceIdBySlot(Slot: Integer): Integer;
begin
Result := 0;
if (Slot < 0) or (Slot >= MAX_SLICES) then Exit;
if FSlices[Slot].Active then Result := FSlices[Slot].Id;
end;
function TRadioController.SliceSlotOf(Id: Integer): Integer;
begin
Result := FindSliceIndex(Id);
end;
function TRadioController.SliceSlotAutoTx(Slot: Integer): Boolean;
begin
Result := (Slot >= 0) and (Slot < MAX_SLICES) and FSliceAutoTx[Slot];
end;
procedure TRadioController.SetSliceSlotAutoTx(Slot: Integer; On_: Boolean);
// Без Changed(): единственный вызывающий — TCATAdapter.ApplySettings, а он
// работает и на старте (до постройки виджетов), и на connect. Перерисовку
// бейджей делает UI-путь настроек (MainForm.OnSliceCATSettingsChange).
begin
if (Slot < 0) or (Slot >= MAX_SLICES) then Exit;
FSliceAutoTx[Slot] := On_;
end;
function TRadioController.SliceAutoTx(Id: Integer): Boolean;
begin
Result := SliceSlotAutoTx(FindSliceIndex(Id));
end;
procedure TRadioController.RequestSliceTx(SliceId: Integer; On_: Boolean);
// PTT, пришедшая по CAT-порту слайса. Аналог «Auto Switch TX Slice» в SmartSDR
// CAT: с Auto TX слайс сам становится TX-источником, без него — передаёт,
// только если оператор уже выбрал его источником (бейдж TX на флаге).
// Выполняется в потоке контроллера (CAT маршалит через Invoke), поэтому
// проверка «уже в эфире» и смена источника атомарны относительно GUI.
var idx: Integer;
begin
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
if not On_ then
begin
// Отпускание PTT снимает только СВОЮ передачу: RX; на порту слайса C не
// должен обрывать эфир слайса B.
if (FTxSliceId = SliceId) and (FTransmitting or FTuning) then
begin
if FTuning then SetTune(False) else SetMOX(False);
end;
Exit;
end;
// Передатчик один: пока кто-то в эфире (другой слайс, главный VFO, кнопка
// MOX/TUNE, аппаратная PTT) — чужую передачу не перехватываем и источник не
// переключаем. Своя же передача уже идёт, повторный TX; — no-op.
if FTransmitting or FTuning then Exit;
if FSlices[idx].Mode = MODE_DMR then Exit; // AMBE-передатчика нет
if FTxSliceId <> SliceId then
begin
if not FSliceAutoTx[idx] then Exit; // без Auto TX слайс сам себя не назначает
SetTxSlice(SliceId);
if FTxSliceId <> SliceId then Exit; // SetTxSlice отказал (DMR/нет слайса)
end;
SetMOX(True);
end;
// Снимок кадра для web-зеркала: протокол фиксирован на 1024 точки, поэтому
// при большем разрешении анализатора децимируем max-детектором (не обрезаем).
procedure SnapshotPixels(const Pixels: array of Single; Count: Integer;
@@ -2966,6 +3156,27 @@ begin
end;
end;
function TRadioController.FilterIdxFor(M, BW: Integer): Integer;
// Обратно к FilterBWFor: индекс пресета с минимальным отклонением от BW.
var
i, Cnt, D, Best: Integer;
begin
Result := 0;
case M of
MODE_FM: Cnt := FILT_FM_COUNT;
MODE_WFM: Cnt := FILT_WFM_COUNT;
MODE_FMRAW: Cnt := FILT_RAW_COUNT;
MODE_DMR: Exit;
else Cnt := FILT_COUNT;
end;
Best := MaxInt;
for i := 0 to Cnt - 1 do
begin
D := Abs(FilterBWFor(M, i) - BW);
if D < Best then begin Best := D; Result := i; end;
end;
end;
// =====================================================================
// Команды (Фаза 1 — каркас). Полная логика переносится из TMainForm.
// =====================================================================
@@ -3744,6 +3955,18 @@ begin
Result := AGCModes[M];
end;
class function TRadioController.AGCModeToUI(A: TWDSPAGCMode): Integer;
// Обратно к AGCModeFromUI: 0=FAST,1=MED,2=SLOW,3=LONG,4=OFF (шкала UI и CAT).
begin
case A of
agcFast: Result := 0;
agcSlow: Result := 2;
agcLong: Result := 3;
agcOff: Result := 4;
else Result := 1; // agcMedium
end;
end;
procedure TRadioController.ApplyAGCToEngine;
begin
if FWDSPReady and Assigned(FDSPEngine) and (FAGCMode >= 0) and (FAGCMode <= 4) then