feat(slices): независимый rate панадаптеров (селектор в шапке)

- Создание пана: rate наследуется от главного с клампом ≤768к
  (вне сетки → 384к); за сменой rate главного паны не следуют
- Клик по «NNN kHz» в шапке пана N → меню полной сетки железа
  (PanRateAllowed = Caps.RatePresets ∩ кратные 48 кГц; захардкоженный
  PAN_DDC_RATES удалён)
- WDSPEngine.SetPanRate: под FSliceLock пересбор аккумулятора/анализатора
  пана + переоткрытие слайс-каналов на новом rate (state сохраняется)
- RadioController.SetPanDDCRate: движок → реконфиг DDC (SetPanDDC) →
  кламп слайсов к новой полосе; FPanADC[] хранится (база под 3.4)
- Шапка: rate отдельным лейблом (у панов N кликабелен, акцент/рука)

Смоук-тест без железа ALL PASS (тон: спектр+аудио слайса живы через
384→192→768). Проверено юзером на экране.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 23:28:25 +03:00
co-authored by Claude Fable 5
parent 95b4f749be
commit e5921b8196
5 changed files with 264 additions and 23 deletions
+56 -11
View File
@@ -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);