diff --git a/MainForm.pas b/MainForm.pas index 3de145c..3b2c686 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -184,6 +184,9 @@ type FPanSplitMinTop: Integer; FPanSplitMaxTop: Integer; FPendingPanClose: Integer; // «×» шапки: PanId к закрытию в тике (0 = нет) + // Меню rate пана («NNN kHz» в шапке панов N; создаётся лениво). + FPanRateMenu: TPopupMenu; + FPanRateMenuPanId: Integer; FSpecView: TSpectrumView; FWidebandView: TWidebandView; FUseOpenGLSpectrum: Boolean; @@ -570,6 +573,8 @@ type procedure DestroyAllExtraPans; // stop/выход procedure UpdatePanHeaders; // текст+видимость шапок procedure UpdateAddPanButton; + procedure OnPanRateClick(Sender: TObject); // «NNN kHz» шапки пана N + procedure PanRateMenuItemClick(Sender: TObject); procedure AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double); procedure PanClickTune(P: TPanafallPanel; X, W: Integer); // клик по фону пана N procedure PanViewWindow(P: TPanafallPanel; out VC, VS: Double); @@ -2679,6 +2684,11 @@ begin begin FPans[i].HeaderPanel.Color := T.Panel; FPans[i].HeaderLabel.Font.Color := T.Text; + // Rate-лейбл: кликабельный (паны N) подсвечиваем акцентом селектора. + if FPans[i].RateClickable then + FPans[i].HeaderRateLabel.Font.Color := T.BtnTextActive + else + FPans[i].HeaderRateLabel.Font.Color := T.Text; end; if i > 0 then begin @@ -5297,6 +5307,54 @@ begin FActiveSliceId := Id; end; +procedure TMainForm.OnPanRateClick(Sender: TObject); +// Клик по «NNN kHz» в шапке пана N: меню с полной сеткой rate бэкенда +// (фильтр PanRateAllowed — кратные 48 кГц). Текущий rate отмечен галкой. +var + P: TPanafallPanel; + MI: TMenuItem; + i, kHz: Integer; +begin + if not (Sender is TPanafallPanel) then Exit; + P := TPanafallPanel(Sender); + if (P.PanId < 1) or not FController.PanDDCActive(P.PanId) then Exit; + if FPanRateMenu = nil then + FPanRateMenu := TPopupMenu.Create(Self); + FPanRateMenu.Items.Clear; + for i := 0 to High(FController.BackendCaps.RatePresets) do + begin + kHz := FController.BackendCaps.RatePresets[i] div 1000; + if not FController.PanRateAllowed(Word(kHz)) then Continue; + MI := TMenuItem.Create(FPanRateMenu); + MI.Caption := Format('%d kHz', [kHz]); + MI.Tag := kHz; + MI.Checked := kHz = FController.PanDDCRateKHz(P.PanId); + MI.OnClick := PanRateMenuItemClick; + FPanRateMenu.Items.Add(MI); + end; + if FPanRateMenu.Items.Count = 0 then Exit; + FPanRateMenuPanId := P.PanId; + FPanRateMenu.PopUp; // в позиции курсора +end; + +procedure TMainForm.PanRateMenuItemClick(Sender: TObject); +var + P: TPanafallPanel; + PanId: Integer; +begin + PanId := FPanRateMenuPanId; + FPanRateMenuPanId := 0; + if (PanId < 1) or (PanId >= MAX_PANS) then Exit; + P := FPans[PanId]; + if P = nil then Exit; + if not FController.SetPanDDCRate(PanId, Word(TMenuItem(Sender).Tag)) then Exit; + // Вьюха/шапка/флаги под новый span (слайсы могли клампнуться к краям). + SyncPanViews; + P.PushAllSliceFlagStates; + P.LayoutFlags; + MarkPanDirty(P); +end; + procedure TMainForm.PanClickTune(P: TPanafallPanel; X, W: Integer); // Клик по фону доп. пана = перестроить его слайс на частоту клика (аналог // DoSpectrumClick главного: снап к 100 Гц). Цель — активный слайс, если он @@ -5347,11 +5405,17 @@ begin P.HeaderVisible := N > 1; if P.BtnClosePan <> nil then P.BtnClosePan.Visible := (N > 1) and (i > 0); if i = 0 then - P.SetHeaderText(Format('PAN 0 · %.6f MHz · %d kHz', - [FController.FCenterFreq / 1e6, FController.FSampleRate div 1000])) + begin + P.SetHeaderText(Format('PAN 0 · %.6f MHz ·', + [FController.FCenterFreq / 1e6])); + P.SetHeaderRate(Format('%d kHz', [FController.FSampleRate div 1000])); + end else - P.SetHeaderText(Format('PAN %d · %.6f MHz · %d kHz', - [i, FController.PanDDCFreq(i) / 1e6, FController.PanDDCRateKHz(i)])); + begin + P.SetHeaderText(Format('PAN %d · %.6f MHz ·', + [i, FController.PanDDCFreq(i) / 1e6])); + P.SetHeaderRate(Format('%d kHz', [FController.PanDDCRateKHz(i)])); + end; end; end; @@ -5370,6 +5434,7 @@ procedure TMainForm.OnAddPanClick(Sender: TObject); // ADC 0; в центре автоматически создаётся первый слайс. var PanId, i: Integer; + RateKHz: Word; Freq: Double; P: TPanafallPanel; Split: TPanel; @@ -5380,8 +5445,15 @@ begin if FPans[i] = nil then begin PanId := i; Break; end; if PanId < 0 then Exit; + // Rate наследуется от главного пана с клампом сверху (768к — потолок доп. + // пана по Ethernet/CPU; 48/96/192к наследуются как есть). Не из сетки + // (напр. Pluto-подобные) — контроллер откатит на 384к. Дальше rate пана + // независим: селектор «NNN kHz» в шапке, за сменой rate главного НЕ следует. + RateKHz := FController.FSampleRate div 1000; + if RateKHz > 768 then RateKHz := 768; + Freq := ActiveVfoFreq; - if not FController.AddPanDDC(PanId, Freq, 384, 0) then Exit; + if not FController.AddPanDDC(PanId, Freq, RateKHz, 0) then Exit; P := TPanafallPanel.Create(nil, FUseOpenGLSpectrum); P.PanId := PanId; @@ -5392,6 +5464,8 @@ begin P.OnFlagsInvalidate := OnVfoOverlayInvalidate; P.OnPanClose := OnPanHeaderClose; P.OnSplitterMoved := RightPanelResize; // ratio обновлён → полный re-layout + P.OnRateClick := OnPanRateClick; // «NNN kHz» в шапке → меню rate + P.RateClickable := True; P.SpectrumMouseDown := PanNSpectrumMouseDown; P.SpectrumMouseMove := PanNSpectrumMouseMove; P.SpectrumMouseUp := PanNSpectrumMouseUp; @@ -5416,7 +5490,7 @@ begin P.View.WfFrameInterval := EffectiveWfInterval; P.View.PAMaxPower := FController.FPAMaxPower; P.View.CenterFreq := Freq; - P.View.SpanHz := 384000; + P.View.SpanHz := FController.PanDDCRateKHz(PanId) * 1000.0; // VFO/TX-маркеры главного на доп. пане не рисуем — уводим за пределы окна. P.View.VfoA := -1e15; P.View.VfoB := -1e15; diff --git a/PanafallPanel.pas b/PanafallPanel.pas index 0878488..1690a28 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -95,6 +95,9 @@ type FStackHeight: Integer; // высота региона (0 = до низа родителя) FHeaderPanel: TPanel; // шапка пана (видна при панах >= 2) FHeaderLabel: TLabel; + FHeaderRateLabel: TLabel; // «NNN kHz»; у панов N>0 кликабелен (селектор rate) + FRateClickable: Boolean; + FOnRateClick: TNotifyEvent; FBtnClosePan: TFlatButton; // «×» в шапке (только паны N>0) FBtnAddPan: TFlatButton; // «⊞» в ряду пан/зума (только пан 0) FHeaderVisible: Boolean; @@ -105,6 +108,8 @@ type procedure BtnClosePanClick(Sender: TObject); procedure BtnAddPanClick(Sender: TObject); + procedure HeaderRateClick(Sender: TObject); + procedure SetRateClickable(AValue: Boolean); procedure PositionHeaderChildren(RW, H: Integer); // Видимое частотное окно ЭТОГО пана: пан 0 — окно главного (зум/пан), // паны N — центр их DDC ± rate/2 (пан-зум — этап 3.3+). @@ -169,11 +174,17 @@ type property PanId: Integer read FPanId write FPanId; property StackTop: Integer read FStackTop write FStackTop; property StackHeight: Integer read FStackHeight write FStackHeight; - // Шапка пана: текст ставит хозяин (частота/rate), «×» → OnPanClose. + // Шапка пана: текст ставит хозяин (частота), rate — отдельным лейблом + // (SetHeaderRate); у панов N>0 хозяин делает его кликабельным (селектор + // rate, RateClickable + OnRateClick). «×» → OnPanClose. property HeaderVisible: Boolean read FHeaderVisible write FHeaderVisible; procedure SetHeaderText(const S: string); + procedure SetHeaderRate(const S: string); + property RateClickable: Boolean read FRateClickable write SetRateClickable; + property OnRateClick: TNotifyEvent read FOnRateClick write FOnRateClick; property HeaderPanel: TPanel read FHeaderPanel; property HeaderLabel: TLabel read FHeaderLabel; + property HeaderRateLabel: TLabel read FHeaderRateLabel; property BtnClosePan: TFlatButton read FBtnClosePan; property BtnAddPan: TFlatButton read FBtnAddPan; // Ряд пан/зума: у панов N>0 скрыт (их зум — этап 3.3+). @@ -210,6 +221,7 @@ type // Флаг A виден: панель хозяина скрыта (MainFlagPinned) ИЛИ есть слайсы B+. procedure UpdateMainFlagVisibility; procedure PushSliceFlagState(SliceId: Integer); + procedure PushAllSliceFlagStates; // все флаги панели (после смены rate и т.п.) procedure PushMainFlagExtState; procedure PushMainFlagAudioDevices; // Тик: S-метр/частота главного флага + позиции + троттленный S-метр слайсов. @@ -362,6 +374,12 @@ begin FHeaderLabel.Parent := FHeaderPanel; FHeaderLabel.AutoSize := True; FHeaderLabel.Left := 8; + // Rate — отдельным лейблом за основным текстом: у панов N>0 он кликабелен + // (селектор rate DDC), у пана 0 — просто текст. + FHeaderRateLabel := TLabel.Create(Self); + FHeaderRateLabel.Parent := FHeaderPanel; + FHeaderRateLabel.AutoSize := True; + FHeaderRateLabel.OnClick := HeaderRateClick; FBtnClosePan := MakeFlatBtn(FHeaderPanel, '×', 0, 0, 10, 10, BtnClosePanClick); FBtnClosePan.Visible := False; // хозяин включает у панов N>0 end; @@ -378,7 +396,37 @@ end; procedure TPanafallPanel.SetHeaderText(const S: string); begin - if FHeaderLabel <> nil then FHeaderLabel.Caption := S; + if FHeaderLabel = nil then Exit; + if FHeaderLabel.Caption = S then Exit; + FHeaderLabel.Caption := S; + // Rate-лейбл прижат к основному тексту — сдвинуть за новой шириной. + if FHeaderRateLabel <> nil then + FHeaderRateLabel.Left := FHeaderLabel.Left + FHeaderLabel.Width + 6; +end; + +procedure TPanafallPanel.SetHeaderRate(const S: string); +begin + if FHeaderRateLabel = nil then Exit; + if FHeaderRateLabel.Caption <> S then FHeaderRateLabel.Caption := S; + if FHeaderLabel <> nil then + FHeaderRateLabel.Left := FHeaderLabel.Left + FHeaderLabel.Width + 6; +end; + +procedure TPanafallPanel.SetRateClickable(AValue: Boolean); +begin + FRateClickable := AValue; + if FHeaderRateLabel <> nil then + begin + if AValue then + FHeaderRateLabel.Cursor := crHandPoint + else + FHeaderRateLabel.Cursor := crArrow; + end; +end; + +procedure TPanafallPanel.HeaderRateClick(Sender: TObject); +begin + if FRateClickable and Assigned(FOnRateClick) then FOnRateClick(Self); end; procedure TPanafallPanel.ViewWindow(out VC, VS: Double); @@ -399,6 +447,11 @@ var BtnS: Integer; begin if FHeaderLabel <> nil then FHeaderLabel.Top := (H - FHeaderLabel.Height) div 2; + if (FHeaderRateLabel <> nil) and (FHeaderLabel <> nil) then + begin + FHeaderRateLabel.Top := (H - FHeaderRateLabel.Height) div 2; + FHeaderRateLabel.Left := FHeaderLabel.Left + FHeaderLabel.Width + 6; + end; if FBtnClosePan <> nil then begin BtnS := H - 4; @@ -1025,6 +1078,13 @@ begin end; end; +procedure TPanafallPanel.PushAllSliceFlagStates; +var i: Integer; +begin + for i := 0 to High(FSliceFlags) do + if FSliceFlags[i] <> nil then PushSliceFlagState(FSliceFlags[i].SliceId); +end; + procedure TPanafallPanel.PushMainFlagExtState; begin if not (Assigned(FMainFlag) and FMainFlag.Visible) or (FController = nil) then Exit; diff --git a/RadioController.pas b/RadioController.pas index be38326..96bf8b2 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -52,11 +52,10 @@ uses WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; -const - // Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для - // движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1). - // Допустимые rate доп. пан-DDC, кГц (дефолт 384). - PAN_DDC_RATES: array[0..2] of Word = (192, 384, 768); +// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для +// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1). +// Допустимые rate пан-DDC — полная сетка железа Caps.RatePresets (кратные +// 48 кГц — требование движка), см. PanRateAllowed. Дефолт/фолбэк — 384 кГц. type // QO-100 beacon lock — статус контура (для UI/маркеров). @@ -353,6 +352,7 @@ type FPanDDCIdx: array[1..MAX_PANS-1] of Integer; FPanFreqHz: array[1..MAX_PANS-1] of Double; FPanRateKHz: array[1..MAX_PANS-1] of Word; + FPanADC: array[1..MAX_PANS-1] of Byte; // ADC-источник пана (3.4) FDDCPanMap: array[0..MAX_DDCS-1] of Integer; FLastDDCSeq: LongWord; // последний seq (из сетевого потока) FLastDDCIndex: Integer; // последний DDC index @@ -562,12 +562,17 @@ type // ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ---- // Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр (сырые Гц - // железа, XVTR-трансляции у панов нет), RateKHz ∈ PAN_DDC_RATES (иначе 384), + // железа, XVTR-трансляции у панов нет), RateKHz ∈ PanRateAllowed (иначе 384), // ADCSource — АЦП платы. False = нет слота/бэкенд не умеет/не Running. function AddPanDDC(PanId: Integer; FreqHz: Double; RateKHz: Word = 384; ADCSource: Byte = 0): Boolean; procedure RemovePanDDC(PanId: Integer); procedure SetPanDDCFreq(PanId: Integer; FreqHz: Double); // ретюн (HP-пакет) + // Rate ∈ сетке бэкенда (Caps.RatePresets) и кратен 48 кГц (движок). + function PanRateAllowed(RateKHz: Word): Boolean; + // Смена rate живого пана: движок переоткрывает аккумулятор/анализатор/слайсы, + // DDC реконфигурируется; слайсы вне новой полосы клампятся к её краям. + function SetPanDDCRate(PanId: Integer; RateKHz: Word): Boolean; function PanDDCActive(PanId: Integer): Boolean; function PanDDCFreq(PanId: Integer): Double; function PanDDCRateKHz(PanId: Integer): Word; @@ -1178,16 +1183,28 @@ begin FPanDDCIdx[i] := -1; FPanFreqHz[i] := 0; FPanRateKHz[i] := 0; + FPanADC[i] := 0; end; for i := 0 to MAX_DDCS - 1 do FDDCPanMap[i] := -1; end; +function TRadioController.PanRateAllowed(RateKHz: Word): Boolean; +// Полная сетка железа (Caps.RatePresets) минус некратные 48 кГц: движок +// требует целого отношения rate пана к аудио-rate (блок/WDSP-каналы). +var i: Integer; +begin + Result := False; + if not Assigned(FNetwork) then Exit; + if (RateKHz = 0) or ((Integer(RateKHz) * 1000) mod 48000 <> 0) then Exit; + for i := 0 to High(FNetwork.Caps.RatePresets) do + if FNetwork.Caps.RatePresets[i] = Integer(RateKHz) * 1000 then Exit(True); +end; + function TRadioController.AddPanDDC(PanId: Integer; FreqHz: Double; RateKHz: Word; ADCSource: Byte): Boolean; var i, ddc: Integer; - RateOk: Boolean; begin Result := False; if (PanId < 1) or (PanId >= MAX_PANS) then Exit; @@ -1195,10 +1212,7 @@ begin if not (Assigned(FNetwork) and FRunning) then Exit; if PanId >= FNetwork.Caps.MaxPans then Exit; // бэкенд не умеет (Pluto=1) - RateOk := False; - for i := Low(PAN_DDC_RATES) to High(PAN_DDC_RATES) do - if PAN_DDC_RATES[i] = RateKHz then begin RateOk := True; Break; end; - if not RateOk then RateKHz := 384; + if not PanRateAllowed(RateKHz) then RateKHz := 384; // hw-индекс: следующий свободный после главного DDC (слот-стабильности не // нужно — hw-индекс скрыт за PanId, наружу торчит только PanId). @@ -1215,11 +1229,41 @@ begin FPanDDCIdx[PanId] := ddc; FPanFreqHz[PanId] := FreqHz; FPanRateKHz[PanId] := RateKHz; + FPanADC[PanId] := ADCSource; FDDCPanMap[ddc] := PanId; FNetwork.SetPanDDC(ddc, True, FreqHz, RateKHz, ADCSource); Result := True; end; +function TRadioController.SetPanDDCRate(PanId: Integer; RateKHz: Word): Boolean; +var + i: Integer; + LoHz, HiHz: Double; +begin + Result := False; + if not PanDDCActive(PanId) then Exit; + if not PanRateAllowed(RateKHz) then Exit; + if FPanRateKHz[PanId] = RateKHz then Exit(True); + // Движок первым: под FSliceLock пересобирает аккумулятор/анализатор и + // переоткрывает слайс-каналы; провалился — состояние не трогаем. + if FWDSPReady and not FDSPEngine.SetPanRate(PanId, Integer(RateKHz) * 1000) then + Exit; + FPanRateKHz[PanId] := RateKHz; + // DDC железа: реконфиг rate (RebuildDDCSpecific + freq-слово). + if Assigned(FNetwork) then + FNetwork.SetPanDDC(FPanDDCIdx[PanId], True, FPanFreqHz[PanId], RateKHz, + FPanADC[PanId]); + // Слайсы, выпавшие из сузившейся полосы, — к её краям (абс. TargetHz + NCO). + CapturedSpan(LoHz, HiHz, PanId); + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and (FSlices[i].PanId = PanId) then + begin + if FSlices[i].TargetHz < LoHz then SetSliceTarget(FSlices[i].Id, LoHz); + if FSlices[i].TargetHz > HiHz then SetSliceTarget(FSlices[i].Id, HiHz); + end; + Result := True; +end; + procedure TRadioController.RemovePanDDC(PanId: Integer); var ddc, i: Integer; begin @@ -1232,6 +1276,7 @@ begin FPanDDCIdx[PanId] := -1; FPanFreqHz[PanId] := 0; FPanRateKHz[PanId] := 0; + FPanADC[PanId] := 0; for i := 0 to MAX_SLICES - 1 do if FSlices[i].Active and (FSlices[i].PanId = PanId) then RemoveSlice(FSlices[i].Id); diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 7400e63..59317b4 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -509,6 +509,9 @@ type function AddPan(PanId, RateHz: Integer): Boolean; // Убирает пан вместе с его слайсами (аудио-выходы слайсов — забота вызывающего). procedure RemovePan(PanId: Integer); + // Смена rate живого пана: пересбор аккумулятора/анализатора + переоткрытие + // WDSP-каналов его слайсов на новом входном rate (state слайсов сохраняется). + function SetPanRate(PanId, RateHz: Integer): Boolean; function PanActive(PanId: Integer): Boolean; // --- Мультислайсы (софт-fan-out) --- @@ -2220,6 +2223,41 @@ begin end; end; +function TWDSPEngine.SetPanRate(PanId, RateHz: Integer): Boolean; +var s: Integer; +begin + Result := False; + if (PanId < 1) or (PanId >= MAX_PANS) then Exit; + // Те же требования кратности, что в AddPan. + if (RateHz < FAudioRate) or (RateHz mod FAudioRate <> 0) then Exit; + FSliceLock.Enter; + try + if not FPans[PanId].Active then Exit; + if FPans[PanId].Rate = RateHz then Exit(True); + // Анализатор держит старый rate — снести до пересбора аккумулятора. + DestroyPanAnalyzer(PanId); + FPans[PanId].Rate := RateHz; + FPans[PanId].BufSize := FAudioBufSize * (RateHz div FAudioRate); + SetLength(FPans[PanId].AccI, FPans[PanId].BufSize); + SetLength(FPans[PanId].AccQ, FPans[PanId].BufSize); + FPans[PanId].AccPos := 0; + FPans[PanId].DispPos := 0; + AllocSliceBuffers; // FSliceIn/Out под новый макс. блок + if FAnalyzerOpen then CreatePanAnalyzer(PanId); + // Слайсы пана: их каналы открыты с InRate/InSize пана — переоткрываем + // (OpenSliceChannel восстанавливает mode/filter/AGC/DSP из записи слайса). + for s := 0 to MAX_SLICES - 1 do + if FSlices[s].Active and (FSlices[s].PanId = PanId) then + begin + CloseSliceChannel(s); + OpenSliceChannel(s); + end; + Result := True; + finally + FSliceLock.Leave; + end; +end; + function TWDSPEngine.PanActive(PanId: Integer): Boolean; begin Result := (PanId >= 1) and (PanId < MAX_PANS) and FPans[PanId].Active; diff --git a/doc/SLICES_PLAN.md b/doc/SLICES_PLAN.md index 1cf2df1..6901a9e 100644 --- a/doc/SLICES_PLAN.md +++ b/doc/SLICES_PLAN.md @@ -413,10 +413,34 @@ ChangeSampleRate). не `crDefault`. ⚠Урок на 3.5 (pop-out) и вообще: любой динамически создаваемый нативный GL-виджет должен получать явный курсор при создании. -Статус: собирается (GUI+демон), НА ЭКРАНЕ НЕ ПРОВЕРЕНО (юзер проверяет). -Открыто из фидбека: юзер видел курсор-«палец» на водопаде пана N — в коде -панов N `crHandPoint` нигде не ставится (подозрение: артефакт сломанной -раскладки, перекрытие чужим контролом); перепроверить после фиксов. +Статус: все фиксы подтверждены юзером на экране, закоммичено 95b4f74 +(+ dae0c19 банд-столл отдельно). + +**RATE ПАНОВ (реализовано 2026-07-11, после коммита 3.3):** + +Решение (обсуждено с юзером): (1) при создании пан наследует rate главного +с клампом ≤768к (48/96/192к как есть; вне сетки → 384к); (2) rate пана +независим и меняется кликом по «NNN kHz» в шапке пана → меню полной сетки +железа; (3) за сменой rate главного НЕ следует (издержки Ethernet/CPU, churn). + +- `WDSPEngine.SetPanRate(PanId, RateHz)`: под FSliceLock — DestroyPanAnalyzer → + пересбор аккумулятора (BufSize/AccI/AccQ) → CreatePanAnalyzer → переоткрытие + слайс-каналов пана (Close+OpenSliceChannel, state слайсов сохраняется). +- `RadioController`: PAN_DDC_RATES (192/384/768) УДАЛЁН — валидация + `PanRateAllowed` = Caps.RatePresets ∩ кратные 48 кГц (полная сетка HPSDR + 48-1536к). `SetPanDDCRate`: движок → FPanRateKHz → `SetPanDDC` (реконфиг + DDC тем же RebuildDDCSpecific+SendFullHP) → кламп слайсов к новой полосе. + `FPanADC[]` — ADC пана теперь хранится (нужен реконфигу; база под 3.4). +- UI: rate в шапке — отдельный TLabel (`SetHeaderRate`, у панов N кликабелен, + акцентный цвет, crHandPoint), `OnPanRateClick` → TPopupMenu из RatePresets + (текущий отмечен) → `PanRateMenuItemClick` → SetPanDDCRate + SyncPanViews + + PushAllSliceFlagStates + LayoutFlags. Пан 0 показывает rate некликабельно + (его rate — существующий SampleRateOverlay). +- ★СМОУК-ТЕСТ БЕЗ ЖЕЛЕЗА ALL PASS (scratchpad panratetest.pas, паттерн 3.2): + синтетический тон +10.8кГц → пан 384к: пик спектра на месте, слайс 800Гц + RMS 0.33 → SetPanRate 192к → пик/аудио живы → 768к → живы → RemovePan чисто. +НА ЭКРАНЕ НЕ ПРОВЕРЕНО, НЕ закоммичено. Смена rate пана на эфире = короткий +разрыв IQ этого пана (реконфиг DDC) — ожидаемо. ### 3.4 — ADC-селектор панадаптера (+ антенны Alex, уточнено 2026-07-11)