feat(slices): multi-slice receivers (FlexRadio-style) — soft fan-out + per-slice flag/audio

Independent RX slices sharing one panadapter: each has its own
freq/mode/filter/AGC/NR-NB-SNB-ANF, S-meter, volume and sound card.
VFO A = main receiver; B+ are soft fan-out WDSP channels within the
captured span (ideal for Pluto/QO-100; HW DDC path is Stage 3).

- WDSPEngine: soft fan-out (TSliceChan, ProcessSlices in DSP thread,
  per-slice NCO/mode/filter/AGC/NR/NB/SNB/ANF + S-meter, OnSliceAudio),
  FSliceLock serialises add/remove/set vs DSP; reopened on rate change.
- RadioController: TCtrlSlice with its own TAudioOutput/device,
  Add/Remove/SetSlice*, RefreshSliceShifts keeps slices on target Hz
  across centre changes. Slice letters start at 'B' (A = main receiver).
- UI: per-slice flags (VfoOverlay) with letter badge/close, filter-band
  markers in BOTH GL and CPU renderers, drag-retune, wheel; flags placed
  beside the filter band with flip, like the main flag. Ctrl+click adds.
- Main flag (slice A) visible whenever the left panel is hidden OR any
  slice exists; synced with panel-driven DSP/mode/AGC changes.
- Perf: S-meter decoupled from flag chrome cache (FMeterDirty +
  meter-only RefreshMeter via EnsureRendered); InvalidateVfoOverlay
  avoids global overlay-cache churn on meter ticks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 23:46:30 +03:00
co-authored by Claude Opus 4.8
parent a04c592f1d
commit 1527b0f849
8 changed files with 1915 additions and 139 deletions
+325 -11
View File
@@ -122,6 +122,24 @@ type
PLLLock: Boolean;
end;
// Слайс уровня контроллера: логическая обёртка над WDSP-слайс-каналом
// (в TWDSPEngine) + собственный аудио-выход со своей звуковухой.
TCtrlSlice = record
Active: Boolean;
Id: Integer; // = id слайса в TWDSPEngine
Letter: Char; // буква флага ('A'..'F'), пока для UI
TargetHz: Double; // абсолютная частота слайса
Mode: Integer;
FilterLo: Integer;
FilterHi: Integer;
AGC: TWDSPAGCMode;
Volume: Double; // 0..1
Muted: Boolean;
DeviceIndex: Integer; // PortAudio-индекс (-1 = default)
DevName: string;
Audio: TAudioOutput; // владеет; свой поток вывода
end;
{ TRadioController }
TRadioController = class
private
@@ -134,6 +152,11 @@ type
FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView)
FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер)
procedure Changed(Field: TRadioField);
// --- Мультислайсы ---
function FindSliceIndex(Id: Integer): Integer; // -1 если нет
procedure OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
procedure RefreshSliceShifts; // пересчёт ShiftHz всех слайсов при смене центра
procedure ApplyAGCToEngine; // применяет FAGCMode к WDSP (единый маппинг)
procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode
procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state)
@@ -163,6 +186,12 @@ type
FDSPEngine: TWDSPEngine;
FAudioOut: TAudioOutput;
FAudioIn: TAudioInput;
// ---- Мультислайсы (софт-fan-out) ----
// Слайс = доп. приёмник (свой WDSP-канал в движке) + своя звуковуха.
// TargetHz абсолютна; ShiftHz в движок = TargetHz - FCenterFreq.
FSlices: array[0..MAX_SLICES-1] of TCtrlSlice;
FNextSliceId: Integer; // монотонный генератор id слайсов
FSettings: TSettingsManager;
FChannelStore: TChannelStore;
FDeviceStore: TDeviceStore; // общий список устройств (saved+discovered)
@@ -467,6 +496,32 @@ type
procedure DriveBy(Delta: Integer);
procedure SetAtten(Idx: Integer);
// ---- Мультислайсы (софт-fan-out) ----
// Создаёт слайс на TargetHz: заводит WDSP-канал в движке + свой аудио-выход
// на устройстве DevIndex (-1=default). Возвращает Id слайса или -1 при отказе
// (нет слотов / TargetHz вне захваченной полосы). DevName для персиста/UI.
function AddSlice(TargetHz: Double; Mode, FilterLo, FilterHi: Integer;
AGC: TWDSPAGCMode; Vol: Double;
DevIndex: Integer; const DevName: string): Integer;
procedure RemoveSlice(Id: Integer);
procedure SetSliceTarget(Id: Integer; TargetHz: Double);
procedure SetSliceMode(Id, Mode: Integer);
procedure SetSliceFilter(Id, Low, High: Integer);
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
procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
procedure SetSliceVolume(Id: Integer; Vol: Double); // 0..1
procedure SetSliceMute(Id: Integer; Mute: Boolean);
procedure SetSliceDSP(Id, NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
function SliceSMeter(Id: Integer): Double; // dBm, из DSP-потока
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;
// Pluto TX-мощность через аттенюацию: drive% → дБ (потолок FPlutoTxMaxAttDb на
// 100%, выкл на приёме). Логика/маппинг здесь; бэкенд лишь пишет hardwaregain.
function PlutoTxAttForDrive: Double; // дБ<0 под текущий FDrivePercent
@@ -625,6 +680,8 @@ begin
FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX)
FVolume := 70; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0;
FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000;
FNextSliceId := 1; // мультислайсы: генератор id
FillChar(FSlices, SizeOf(FSlices), 0);
FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума)
FCurrentBand := 5; FCurrentXvtr := -1;
FSpectrumBufCount := 1024; FWaterfallBufCount := 1024;
@@ -649,6 +706,9 @@ begin
// BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший
// буфер вдвое сокращает построение downsampler в OpenChannel RXA.
FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
// Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не
// зависит от фронтенда, в отличие от OnAudio/OnSpectrum).
FDSPEngine.OnSliceAudio := OnSliceAudioReady;
// QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock).
FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink)
@@ -672,12 +732,21 @@ begin
end;
procedure TRadioController.FreeEngines;
var i: Integer;
begin
if Assigned(FNetwork) then
begin
if FNetwork.Connected then FNetwork.Disconnect;
FreeAndNil(FNetwork);
end;
// Слайсы: закрываем их аудио-выходы (WDSP-каналы закроет FDSPEngine.Close ниже).
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].Audio <> nil) then
begin
FSlices[i].Audio.Close;
FreeAndNil(FSlices[i].Audio);
FSlices[i].Active := False;
end;
if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end;
if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end;
if Assigned(FDSPEngine) then
@@ -966,6 +1035,227 @@ begin
FAudioOut.Write(Left, Right, Count);
end;
// ---------------------------------------------------------------------------
// Мультислайсы (софт-fan-out)
// ---------------------------------------------------------------------------
function TRadioController.FindSliceIndex(Id: Integer): Integer;
var i: Integer;
begin
Result := -1;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].Id = Id) then Exit(i);
end;
// DSP-поток: аудио готового слайса → его собственный выход. Слайс — локальный
// монитор (в web/на радио не уходит), поэтому только на свою звуковуху.
procedure TRadioController.OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
var idx: Integer;
begin
if not FLocalAudio then Exit;
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
if FSlices[idx].Audio <> nil then
FSlices[idx].Audio.Write(Left, Right, Count);
end;
procedure TRadioController.RefreshSliceShifts;
var i: Integer;
begin
if not Assigned(FDSPEngine) then Exit;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then
FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FCenterFreq);
end;
procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double);
// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга).
var Half: Double;
begin
Half := FSampleRate * 0.475;
LoHz := FCenterFreq - Half;
HiHz := FCenterFreq + Half;
end;
function TRadioController.SliceFitsCapture(TargetHz: Double): Boolean;
var Lo, Hi: Double;
begin
CapturedSpan(Lo, Hi);
Result := (TargetHz >= Lo) and (TargetHz <= Hi);
end;
function TRadioController.AddSlice(TargetHz: Double;
Mode, FilterLo, FilterHi: Integer; AGC: TWDSPAGCMode; Vol: Double;
DevIndex: Integer; const DevName: string): 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)
slot := -1;
for i := 0 to MAX_SLICES - 1 do
if not FSlices[i].Active then begin slot := i; Break; end;
if slot < 0 then Exit; // нет свободных слотов
Id := FNextSliceId;
if not FDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi,
TargetHz - FCenterFreq, Vol, AGC) then Exit;
Inc(FNextSliceId);
// Свой аудио-выход слайса на выбранной звуковухе.
AO := TAudioOutput.Create(48000);
AO.OutputBufferSize := FSettings.LoadAudioBufferSize;
AO.DeviceIndex := DevIndex;
if FLocalAudio then AO.Open;
FSlices[slot].Active := True;
FSlices[slot].Id := Id;
// Буква флага. Слайс A — это главный приёмник (не в FSlices[]), поэтому доп.
// слайсы нумеруются с 'B' (slot 0 → B, 1 → C, …), иначе получалось два «A».
FSlices[slot].Letter := Chr(Ord('B') + slot);
FSlices[slot].TargetHz := TargetHz;
FSlices[slot].Mode := Mode;
FSlices[slot].FilterLo := FilterLo;
FSlices[slot].FilterHi := FilterHi;
FSlices[slot].AGC := AGC;
FSlices[slot].Volume := Vol;
FSlices[slot].Muted := False;
FSlices[slot].DeviceIndex := DevIndex;
FSlices[slot].DevName := DevName;
FSlices[slot].Audio := AO;
Result := Id;
Changed(rfDevice);
end;
procedure TRadioController.RemoveSlice(Id: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
if Assigned(FDSPEngine) then FDSPEngine.RemoveSlice(Id);
if FSlices[idx].Audio <> nil then
begin
FSlices[idx].Audio.Close;
FreeAndNil(FSlices[idx].Audio);
end;
FSlices[idx].Active := False;
Changed(rfDevice);
end;
procedure TRadioController.SetSliceTarget(Id: Integer; TargetHz: Double);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].TargetHz := TargetHz;
if Assigned(FDSPEngine) then
FDSPEngine.SetSliceShift(Id, TargetHz - FCenterFreq);
end;
procedure TRadioController.SetSliceMode(Id, Mode: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].Mode := Mode;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceMode(Id, Mode);
end;
procedure TRadioController.SetSliceFilter(Id, Low, High: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].FilterLo := Low;
FSlices[idx].FilterHi := High;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceFilter(Id, Low, High);
end;
procedure TRadioController.SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].AGC := AGC;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceAGC(Id, AGC);
end;
procedure TRadioController.SetSliceVolume(Id: Integer; Vol: Double);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].Volume := Vol;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceVolume(Id, Vol);
end;
procedure TRadioController.SetSliceMute(Id: Integer; Mute: Boolean);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].Muted := Mute;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceMute(Id, Mute);
end;
procedure TRadioController.SetSliceDSP(Id, NRMode, NBMode: Integer;
SNBOn, ANFOn: Boolean);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
if Assigned(FDSPEngine) then
begin
FDSPEngine.SetSliceNRMode(Id, NRMode);
FDSPEngine.SetSliceNBMode(Id, NBMode);
FDSPEngine.SetSliceSNB(Id, SNBOn);
FDSPEngine.SetSliceANF(Id, ANFOn);
end;
end;
function TRadioController.SliceSMeter(Id: Integer): Double;
begin
if Assigned(FDSPEngine) then Result := FDSPEngine.GetSliceSMeter(Id)
else Result := -130.0;
end;
procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer;
const DevName: string);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].DeviceIndex := DevIndex;
FSlices[idx].DevName := DevName;
if FSlices[idx].Audio <> nil then
begin
FSlices[idx].Audio.Close;
FSlices[idx].Audio.DeviceIndex := DevIndex;
if FLocalAudio then FSlices[idx].Audio.Open;
end;
end;
function TRadioController.SliceCount: Integer;
var i: Integer;
begin
Result := 0;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then Inc(Result);
end;
function TRadioController.GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
var idx: Integer;
begin
idx := FindSliceIndex(Id);
Result := idx >= 0;
if Result then S := FSlices[idx];
end;
procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
Count: Integer);
// DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу
@@ -1335,16 +1625,31 @@ begin
Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors);
end;
// Кромки полосы под режим/полосу (единый расчёт для главного канала и слайсов).
class procedure TRadioController.FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
var Half: Integer;
begin
Half := BW div 2;
case Mode of
0: begin Lo := -BW; Hi := -100; end; // LSB
1: begin Lo := 100; Hi := BW; end; // USB
else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM
end;
end;
procedure TRadioController.SetSliceModeBW(Id, Mode, BW: Integer);
var Lo, Hi: Integer;
begin
FilterEdgesFor(Mode, BW, Lo, Hi);
SetSliceMode(Id, Mode);
SetSliceFilter(Id, Lo, Hi);
end;
procedure TRadioController.ApplyModeFilter;
var
Lo, Hi, Half: Integer;
Lo, Hi: Integer;
begin
Half := FFilterBW div 2;
case FMode of
0: begin Lo := -FFilterBW; Hi := -100; end; // LSB
1: begin Lo := 100; Hi := FFilterBW; end; // USB
else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM
end;
FilterEdgesFor(FMode, FFilterBW, Lo, Hi);
if FWDSPReady and Assigned(FDSPEngine) then
begin
FDSPEngine.SetFilter(Lo, Hi);
@@ -1921,6 +2226,7 @@ begin
NeedPush := True;
end;
RefreshSliceShifts; // центр мог сместиться — держим слайсы на их TargetHz
if NeedPush then PushNetworkState;
Changed(rfActiveVfo);
end;
@@ -1941,6 +2247,7 @@ begin
FCenterFreq := Hz;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetShift(ActiveVfoHz - FCenterFreq);
RefreshSliceShifts; // слайсы привязаны к абсолютной частоте — сдвинуть NCO
PushNetworkState;
Changed(rfCenterFreq);
end;
@@ -1972,6 +2279,7 @@ begin
end;
FCenterFreq := ActiveFreq;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
RefreshSliceShifts;
PushNetworkState;
Changed(rfVfoA);
Changed(rfVfoB);
@@ -2057,14 +2365,20 @@ begin
Changed(rfFilter);
end;
procedure TRadioController.ApplyAGCToEngine;
// UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для всех
// фронтендов и слайсов (раньше web/слайдер ошибочно кастовали индекс прямо в enum).
class function TRadioController.AGCModeFromUI(M: Integer): TWDSPAGCMode;
const
// UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для
// всех фронтендов (раньше web/слайдер ошибочно кастовали индекс прямо в enum).
AGCModes: array[0..4] of TWDSPAGCMode = (agcFast, agcMedium, agcSlow, agcLong, agcOff);
begin
if (M < 0) or (M > 4) then M := 1;
Result := AGCModes[M];
end;
procedure TRadioController.ApplyAGCToEngine;
begin
if FWDSPReady and Assigned(FDSPEngine) and (FAGCMode >= 0) and (FAGCMode <= 4) then
FDSPEngine.SetAGC(AGCModes[FAGCMode], 50.0);
FDSPEngine.SetAGC(AGCModeFromUI(FAGCMode), 50.0);
end;
procedure TRadioController.SetAGCMode(M: Integer);