feat(slices): этап 3.2 — WDSPEngine per-pan (аккумулятор, анализаторы, слайсы в панах)

- общая IQ-очередь с TIQQueueItem.PanId (64→256 слотов); DSP-поток
  диспетчеризует пан-пакеты в PushPanItemToDSP: декод, feed анализатора
  PAN_DISP_BASE+PanId, аккумулятор, ProcessSlicesFor(PanId) под FSliceLock
- пан-анализаторы: Create/DestroyPanAnalyzer + ApplyPanAnalyzerSettings
  (rate пана, без зума — пан-зум в 3.3+), живут в OpenAnalyzer/CloseAnalyzer,
  общие настройки дисплея перекатываются (ReapplyPanDisplaySettings)
- слайсы в панах: TSliceChan.PanId, AddSlice(..., APanId), OpenSliceChannel
  от rate/блока своего пана, SliceDSPRateFor (WFM от rate пана); блок пана
  = FAudioBufSize*Rate/FAudioRate => аудио-блок слайсов неизменен
- выдача пикселей: OnPanSpectrum/OnPanWaterfall(PanId,...) из display-потока,
  GetPixels пана под FSliceLock, колбэки вне лока
- MAX_PANS=4 переехал в WDSPEngine (единый движок/контроллер/UI)
- контроллер: AddPanDDC создаёт пан в движке до включения DDC, RemovePanDDC
  убирает слайсы (со звуковухами)+движок+DDC, гейт/NCO слайсов от центра
  своего пана, ретюн пана двигает его слайсы
- смоук-тест на libwdsp без железа ALL PASS: пик спектра пана точно на месте,
  слайс пана выдал 1 кГц RMS 0.68, главный тракт жив, RemovePan чисто

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 11:45:12 +03:00
co-authored by Claude Fable 5
parent 5dfd7bbb6c
commit 3db823474d
3 changed files with 579 additions and 82 deletions
+62 -26
View File
@@ -53,10 +53,8 @@ uses
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore;
const
// Панадаптеры (этап 3): потолок панов = главный + до 3 доп. аппаратных DDC
// (кап по Ethernet/CPU, см. doc/SLICES_PLAN.md 3.1). Фактический лимит
// бэкенда — Caps.MaxPans (Pluto = 1).
MAX_PANS = 4;
// Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для
// движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1).
// Допустимые rate доп. пан-DDC, кГц (дефолт 384).
PAN_DDC_RATES: array[0..2] of Word = (192, 384, 768);
@@ -136,6 +134,7 @@ type
Active: Boolean;
Id: Integer; // = id слайса в TWDSPEngine
Letter: Char; // буква флага ('A'..'F'), пока для UI
PanId: Integer; // пан слайса: 0 = главный, 1.. = доп. DDC-пан
TargetHz: Double; // абсолютная частота слайса
Mode: Integer;
FilterLo: Integer;
@@ -166,9 +165,10 @@ type
function FindSliceIndex(Id: Integer): Integer; // -1 если нет
procedure OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
procedure RefreshSliceShifts; // пересчёт ShiftHz всех слайсов при смене центра
// --- Панадаптеры на аппаратных DDC (этап 3.1) ---
procedure RefreshSliceShifts; // пересчёт ShiftHz слайсов пана 0 при смене центра
// --- Панадаптеры на аппаратных DDC (этап 3.1/3.2) ---
procedure ResetPanDDCMaps; // все пан-слоты свободны, DDC→Pan пуст
function PanCenterHz(PanId: Integer): Double; // центр пана (0 = FCenterFreq)
procedure ApplyAGCToEngine; // применяет FAGCMode к WDSP (единый маппинг)
procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode
procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state)
@@ -534,10 +534,12 @@ type
// ---- Мультислайсы (софт-fan-out) ----
// Создаёт слайс на TargetHz: заводит WDSP-канал в движке + свой аудио-выход
// на устройстве DevIndex (-1=default). Возвращает Id слайса или -1 при отказе
// (нет слотов / TargetHz вне захваченной полосы). DevName для персиста/UI.
// (нет слотов / TargetHz вне полосы пана). DevName для персиста/UI.
// APanId>0 — слайс внутри доп. DDC-пана (тот должен быть создан AddPanDDC).
function AddSlice(TargetHz: Double; Mode, FilterLo, FilterHi: Integer;
AGC: TWDSPAGCMode; Vol: Double;
DevIndex: Integer; const DevName: string): Integer;
DevIndex: Integer; const DevName: string;
APanId: Integer = 0): Integer;
procedure RemoveSlice(Id: Integer);
procedure SetSliceTarget(Id: Integer; TargetHz: Double);
procedure SetSliceMode(Id, Mode: Integer);
@@ -554,9 +556,9 @@ type
procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string);
function SliceCount: Integer;
function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
// Диапазон захваченной полосы вокруг центра, куда можно ставить софт-слайс.
procedure CapturedSpan(out LoHz, HiHz: Double);
function SliceFitsCapture(TargetHz: Double): Boolean;
// Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс.
procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0);
function SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean;
// ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ----
// Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр (сырые Гц
@@ -1128,27 +1130,41 @@ begin
end;
procedure TRadioController.RefreshSliceShifts;
// Смена центра ГЛАВНОГО захвата: пересчитываем только слайсы пана 0 —
// слайсы доп. панов привязаны к центрам своих DDC (SetPanDDCFreq).
var i: Integer;
begin
if not Assigned(FDSPEngine) then Exit;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then
if FSlices[i].Active and (FSlices[i].PanId = 0) then
FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FCenterFreq);
end;
procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double);
// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга).
var Half: Double;
function TRadioController.PanCenterHz(PanId: Integer): Double;
begin
Half := FSampleRate * 0.475;
LoHz := FCenterFreq - Half;
HiHz := FCenterFreq + Half;
if (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0) then
Result := FPanFreqHz[PanId]
else
Result := FCenterFreq;
end;
function TRadioController.SliceFitsCapture(TargetHz: Double): Boolean;
procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double; PanId: Integer);
// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга).
var Half, Rate: Double;
begin
if (PanId >= 1) and (PanId < MAX_PANS) and (FPanDDCIdx[PanId] >= 0) then
Rate := FPanRateKHz[PanId] * 1000.0
else
Rate := FSampleRate;
Half := Rate * 0.475;
LoHz := PanCenterHz(PanId) - Half;
HiHz := PanCenterHz(PanId) + Half;
end;
function TRadioController.SliceFitsCapture(TargetHz: Double; PanId: Integer): Boolean;
var Lo, Hi: Double;
begin
CapturedSpan(Lo, Hi);
CapturedSpan(Lo, Hi, PanId);
Result := (TargetHz >= Lo) and (TargetHz <= Hi);
end;
@@ -1191,6 +1207,11 @@ begin
if (i < MAX_DDCS) and (FDDCPanMap[i] < 0) then begin ddc := i; Break; end;
if ddc < 0 then Exit;
// Движок: аккумулятор + analyzer пана (до включения DDC — к приходу первого
// пакета пан уже готов; PushPanIQ без пана просто дропает).
if FWDSPReady and not FDSPEngine.AddPan(PanId, Integer(RateKHz) * 1000) then
Exit;
FPanDDCIdx[PanId] := ddc;
FPanFreqHz[PanId] := FreqHz;
FPanRateKHz[PanId] := RateKHz;
@@ -1200,27 +1221,38 @@ begin
end;
procedure TRadioController.RemovePanDDC(PanId: Integer);
var ddc: Integer;
var ddc, i: Integer;
begin
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
ddc := FPanDDCIdx[PanId];
if ddc < 0 then Exit;
// Сначала снимаем роутинг (OnDDCIQ в сетевом потоке перестаёт брать пакеты
// этого DDC), потом гасим DDC в железе.
// этого DDC), потом слайсы пана (их звуковухи), потом движок и DDC в железе.
FDDCPanMap[ddc] := -1;
FPanDDCIdx[PanId] := -1;
FPanFreqHz[PanId] := 0;
FPanRateKHz[PanId] := 0;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) then
RemoveSlice(FSlices[i].Id);
if FWDSPReady then FDSPEngine.RemovePan(PanId);
if Assigned(FNetwork) then FNetwork.SetPanDDC(ddc, False, 0, 0, 0);
end;
procedure TRadioController.SetPanDDCFreq(PanId: Integer; FreqHz: Double);
var i: Integer;
begin
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
if FPanDDCIdx[PanId] < 0 then Exit;
FPanFreqHz[PanId] := FreqHz;
if Assigned(FNetwork) then
FNetwork.SetPanDDCFreq(FPanDDCIdx[PanId], FreqHz);
// Слайсы пана держат абсолютный TargetHz — обновляем их NCO-сдвиги
// относительно нового центра (аналог RefreshSliceShifts главного).
if Assigned(FDSPEngine) then
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].PanId = PanId) then
FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FreqHz);
end;
function TRadioController.PanDDCActive(PanId: Integer): Boolean;
@@ -1242,14 +1274,16 @@ end;
function TRadioController.AddSlice(TargetHz: Double;
Mode, FilterLo, FilterHi: Integer; AGC: TWDSPAGCMode; Vol: Double;
DevIndex: Integer; const DevName: string): Integer;
DevIndex: Integer; const DevName: string; APanId: Integer): Integer;
var
i, slot, Id: Integer;
AO: TAudioOutput;
begin
Result := -1;
if not Assigned(FDSPEngine) then Exit;
if not SliceFitsCapture(TargetHz) then Exit; // вне захваченной полосы (Этап 3=HW DDC)
if (APanId < 0) or (APanId >= MAX_PANS) then Exit;
if (APanId > 0) and (FPanDDCIdx[APanId] < 0) then Exit; // пан не создан
if not SliceFitsCapture(TargetHz, APanId) then Exit; // вне полосы пана
slot := -1;
for i := 0 to MAX_SLICES - 1 do
@@ -1258,7 +1292,8 @@ begin
Id := FNextSliceId;
if not FDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi,
TargetHz - FCenterFreq, Vol, AGC) then Exit;
TargetHz - PanCenterHz(APanId), Vol, AGC,
APanId) then Exit;
Inc(FNextSliceId);
// Свой аудио-выход слайса на выбранной звуковухе.
@@ -1272,6 +1307,7 @@ begin
// Буква флага. Слайс A — это главный приёмник (не в FSlices[]), поэтому доп.
// слайсы нумеруются с 'B' (slot 0 → B, 1 → C, …), иначе получалось два «A».
FSlices[slot].Letter := Chr(Ord('B') + slot);
FSlices[slot].PanId := APanId;
FSlices[slot].TargetHz := TargetHz;
FSlices[slot].Mode := Mode;
FSlices[slot].FilterLo := FilterLo;
@@ -1311,7 +1347,7 @@ begin
if idx < 0 then Exit;
FSlices[idx].TargetHz := TargetHz;
if Assigned(FDSPEngine) then
FDSPEngine.SetSliceShift(Id, TargetHz - FCenterFreq);
FDSPEngine.SetSliceShift(Id, TargetHz - PanCenterHz(FSlices[idx].PanId));
end;
procedure TRadioController.SetSliceMode(Id, Mode: Integer);