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
+126 -7
View File
@@ -52,6 +52,7 @@ type
FTx: Boolean; // передача идёт (красный)
FTxSel: Boolean; // этот слайс выбран для передачи (янтарный)
FSlice: Char; // буква слайса ('A')
FSMeterThreshold: Double; // мин. изменение S-метра (дБ) для перерисовки
FDevNames: TStringList; // имена устройств вывода
FDevIdx: array of Integer; // соответствующие PortAudio-индексы (-1=default)
@@ -72,12 +73,21 @@ type
FOnTxSelect: TNotifyEvent;
FOnInvalidate: TNotifyEvent;
FSliceId: Integer; // логический id слайса (владелец — MainForm/контроллер)
FIsMain: Boolean; // главный приёмник (слайс A) — без кнопки закрытия
FOnSliceSelect: TNotifyEvent; // клик по бейджу буквы — сделать слайс активным
FOnClose: TNotifyEvent; // клик по × (только неглавные) — удалить слайс
FHitRects: array[0..63] of TRect;
FHitActions: array[0..63] of Integer;
FHitCount: Integer;
FHotIdx: Integer;
FCacheBitmap: TBitmap;
FCacheDirty: Boolean;
FMeterDirty: Boolean; // изменился только S-метр — перерисовать полоску, не весь флаг
FMeterRect: TRect; // прямоугольник сигнал-бара (запоминается при полном рендере)
FSRowTop: Integer; // верх строки частоты (для S-метки справа)
FLastSTextRect: TRect; // последний прямоугольник S-метки (для аккуратной очистки)
procedure DrawSelf(C: TCanvas; W, H: Integer);
procedure DrawButton(C: TCanvas; const R: TRect; const Lbl: string;
@@ -100,6 +110,8 @@ type
procedure SetPanel(P: TFlyPanel);
procedure ApplyVolFromX(LX: Integer);
procedure RequestInvalidate;
procedure RequestMeterInvalidate;
procedure RefreshMeter(C: TCanvas);
procedure DoHit(HV: Integer);
protected
@@ -114,6 +126,10 @@ type
destructor Destroy; override;
procedure DrawOverlay(Target: TBitmap; W, H: Integer);
procedure DrawOverlayBitmap(Target: TBitmap);
// Готовит внутренний кэш-битмап (полный рендер по CacheDirty либо дешёвая
// перерисовка одной полоски метра по MeterDirty) и возвращает его. Один путь
// для CPU (BlendBitmapKey) и GL (UploadBitmap).
function EnsureRendered: TBitmap;
function HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
function HandleMouseMove(X, Y: Integer): Boolean;
function HandleMouseUp(X, Y: Integer): Boolean;
@@ -126,6 +142,20 @@ type
const CurName: string);
procedure UpdateSMeter(DB: Double);
procedure UpdateVfo(AVfoHz: Double);
// Привязка к слайсу: id, буква флага, признак главного (слайс A).
procedure SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean);
property SliceId: Integer read FSliceId;
property IsMain: Boolean read FIsMain;
// Для маркера фильтра слайса на спектре (читает GL-вьюха).
property DisplayVfoHz: Double read FVfoHz;
property DisplayMode: Integer read FMode;
property DisplayBW: Integer read FFilterBW;
// Битмап-кэш требует перерисовки (GL-вьюха решает, перезаливать ли текстуру).
property CacheDirty: Boolean read FCacheDirty;
// Изменился только S-метр (перезалить текстуру, но рендер дешёвый — одна полоска).
property MeterDirty: Boolean read FMeterDirty;
// Порог изменения S-метра (дБ) для перерисовки: у слайсов выше (меньше рендеров).
property SMeterThreshold: Double read FSMeterThreshold write FSMeterThreshold;
property HotIdx: Integer read FHotIdx;
property BaseHeight: Integer read ComputeHeight;
property OnSelect: TModeFilterEvent read FOnSelect write FOnSelect;
@@ -136,6 +166,8 @@ type
property OnAudioDevice: TAudioDeviceEvent read FOnAudioDevice write FOnAudioDevice;
property OnSplit: TNotifyEvent read FOnSplit write FOnSplit;
property OnTxSelect: TNotifyEvent read FOnTxSelect write FOnTxSelect;
property OnSliceSelect: TNotifyEvent read FOnSliceSelect write FOnSliceSelect;
property OnClose: TNotifyEvent read FOnClose write FOnClose;
property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate;
end;
@@ -162,6 +194,8 @@ const
HIT_TX = -26;
HIT_AUD_UP = -27;
HIT_AUD_DN = -28;
HIT_SLICE_SEL = -29; // клик по бейджу буквы (выбрать слайс активным)
HIT_CLOSE = -30; // × закрытия слайса (неглавные)
HIT_AGC_PICK_BASE = 100000;
HIT_DEV_BASE = 200000;
@@ -338,6 +372,8 @@ begin
FNRMode := 0; FNBMode := 0; FSNBOn := False; FANFOn := False; FAGCMode := 0;
FVolume := 70; FMuted := False; FSplit := False; FTx := False;
FTxSel := True; FSlice := 'A';
FSliceId := 0; FIsMain := True; // по умолчанию — главный приёмник (слайс A)
FSMeterThreshold := 0.4; // главный флаг — чувствительный метр
FPanel := fpNone;
FAudScroll := 0;
FVolDrag := False;
@@ -346,6 +382,8 @@ begin
FCacheBitmap := TBitmap.Create;
FCacheBitmap.PixelFormat := pf32bit;
FCacheDirty := True;
FMeterDirty := False;
FLastSTextRect := Rect(0, 0, 0, 0);
Width := OVL_W;
Height := OVL_BASE_H;
Cursor := crHandPoint;
@@ -373,6 +411,53 @@ begin
if Assigned(FOnInvalidate) then FOnInvalidate(Self);
end;
// Лёгкая инвалидация: изменился только S-метр. НЕ трогает FCacheDirty, поэтому
// хром флага (частота/режим/кнопки) не перерисовывается — перезаливается только
// полоска метра (RefreshMeter в EnsureRendered).
procedure TVfoOverlay.RequestMeterInvalidate;
begin
FMeterDirty := True;
inherited Invalidate;
if Assigned(FOnInvalidate) then FOnInvalidate(Self);
end;
// Дешёвая перерисовка одной полоски метра + S-метки поверх уже отрендеренного
// хрома. Обе зоны самодостаточны и целиком очищаются под фон карточки.
procedure TVfoOverlay.RefreshMeter(C: TCanvas);
var
SStr: string;
STextW: Integer;
ClearR, NewR: TRect;
begin
// Сигнал-бар — самодостаточный прямоугольник, перерисовывается целиком.
C.Brush.Color := CLR_CARD_BG;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(FMeterRect);
DrawMeterBar(C, FMeterRect.Left, FMeterRect.Top,
FMeterRect.Right - FMeterRect.Left, MET_H);
// S-метка в строке частоты (справа). Очищаем объединение старого и нового
// прямоугольника, чтобы не оставалось «хвостов» при сужении текста.
SStr := DBmToSLabel(FSMeterDB);
C.Font.Name := 'Courier New';
C.Font.Style := [fsBold];
C.Font.Size := 9;
STextW := C.TextWidth(SStr);
NewR := Rect(FMeterRect.Right - STextW - 3, FSRowTop + 4, FMeterRect.Right, FSRowTop + FRQ_H);
ClearR := NewR;
if FLastSTextRect.Left < ClearR.Left then ClearR.Left := FLastSTextRect.Left;
if FLastSTextRect.Top < ClearR.Top then ClearR.Top := FLastSTextRect.Top;
C.Brush.Color := CLR_CARD_BG;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(ClearR);
C.Font.Color := CLR_SVAL;
C.Brush.Style := bsClear;
C.TextOut(FMeterRect.Right - STextW - 2, FSRowTop + 6, SStr);
FLastSTextRect := NewR;
end;
function TVfoOverlay.FormatFreq(Hz: Double): string;
var
T, iM, iK, iH: Int64;
@@ -800,8 +885,16 @@ begin
RW := W - PAD*2;
CurY := PAD;
{ --- Шапка: [A] BW ... SPLIT [TX] --- }
{ --- Шапка: [A] BW ... [×] SPLIT [TX] --- }
DrawBadge(C, Rect(X, CurY, X+16, CurY+HDR_BADGE_H), FSlice, CLR_ACCENT, CLR_CARD_BG);
RegisterHit(Rect(X, CurY, X+16, CurY+HDR_BADGE_H), HIT_SLICE_SEL); // бейдж = выбор слайса
// Кнопка закрытия × — только у неглавных слайсов (главный приёмник не закрыть).
if not FIsMain then
begin
R := Rect(W - PAD - 26 - 44 - 6 - 14, CurY, W - PAD - 26 - 44 - 6, CurY + HDR_BADGE_H);
DrawBadge(C, R, #$C3#$97, CLR_BTN_NORM, CLR_DIM); // UTF-8 '×'
RegisterHit(R, HIT_CLOSE);
end;
// ширина фильтра
if FFilterBW >= 1000 then BWStr := Format('%.1fk', [FFilterBW/1000.0])
else BWStr := IntToStr(FFilterBW);
@@ -828,10 +921,12 @@ begin
DrawIndicator(C, X + 48, CurY + 2, 'ANF', FANFOn);
DrawIndicator(C, X + 80, CurY + 2, 'SNB', FSNBOn);
MeterX := X + 112;
FMeterRect := Rect(MeterX, CurY, W - PAD, CurY + MET_H); // для RefreshMeter
DrawMeterBar(C, MeterX, CurY, W - PAD - MeterX, MET_H);
Inc(CurY, MET_H);
{ --- Частота + S --- }
FSRowTop := CurY; // для RefreshMeter
FreqStr := FormatFreq(FVfoHz);
C.Font.Name := 'Courier New'; C.Font.Size := 13; C.Font.Style := [fsBold];
C.Font.Color := CLR_FREQ; C.Brush.Style := bsClear;
@@ -839,6 +934,7 @@ begin
SStr := DBmToSLabel(FSMeterDB);
C.Font.Size := 9; C.Font.Color := CLR_SVAL;
C.TextOut(W - PAD - C.TextWidth(SStr) - 2, CurY + 6, SStr);
FLastSTextRect := Rect(W - PAD - C.TextWidth(SStr) - 3, CurY + 4, W - PAD, CurY + FRQ_H);
Inc(CurY, FRQ_H);
{ --- Нижний бар: 🔊 vol | MODE DSP AUD --- }
@@ -899,11 +995,8 @@ begin
DrawSelf(Canvas, Width, Height);
end;
procedure TVfoOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
function TVfoOverlay.EnsureRendered: TBitmap;
begin
if (not Visible) or (Target = nil) or (Width <= 0) or (Height <= 0) then Exit;
if (Left >= W) or (Top >= H) then Exit;
if (FCacheBitmap.Width <> Width) or (FCacheBitmap.Height <> Height) then
begin
FCacheBitmap.SetSize(Width, Height);
@@ -917,7 +1010,21 @@ begin
FCacheBitmap.Canvas.FillRect(Rect(0, 0, Width, Height));
DrawSelf(FCacheBitmap.Canvas, Width, Height);
FCacheDirty := False;
FMeterDirty := False;
end
else if FMeterDirty then
begin
RefreshMeter(FCacheBitmap.Canvas); // дешёвый апдейт: только полоска метра
FMeterDirty := False;
end;
Result := FCacheBitmap;
end;
procedure TVfoOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
begin
if (not Visible) or (Target = nil) or (Width <= 0) or (Height <= 0) then Exit;
if (Left >= W) or (Top >= H) then Exit;
EnsureRendered;
BlendBitmapKey(Target, FCacheBitmap, Left, Top, VFO_OVERLAY_ALPHA);
end;
@@ -932,6 +1039,7 @@ begin
Target.Canvas.FillRect(Rect(0, 0, Width, Height));
DrawSelf(Target.Canvas, Width, Height);
FCacheDirty := False;
FMeterDirty := False;
end;
{ ---- Обработка хитов ---- }
@@ -949,6 +1057,8 @@ begin
if Assigned(FOnMute) then FOnMute(Self); Exit; end;
HIT_SPLIT: begin if Assigned(FOnSplit) then FOnSplit(Self); Exit; end;
HIT_TX: begin if Assigned(FOnTxSelect) then FOnTxSelect(Self); Exit; end;
HIT_SLICE_SEL: begin if Assigned(FOnSliceSelect) then FOnSliceSelect(Self); Exit; end;
HIT_CLOSE: begin if Assigned(FOnClose) then FOnClose(Self); Exit; end;
HIT_AUD_UP: begin if FAudScroll > 0 then Dec(FAudScroll);
RequestInvalidate; Exit; end;
HIT_AUD_DN: begin if FAudScroll < FDevNames.Count - AUD_MAX_VIS then
@@ -1174,6 +1284,15 @@ begin
RequestInvalidate;
end;
procedure TVfoOverlay.SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean);
begin
if (FSliceId = ASliceId) and (FSlice = ALetter) and (FIsMain = AIsMain) then Exit;
FSliceId := ASliceId;
FSlice := ALetter;
FIsMain := AIsMain;
RequestInvalidate;
end;
procedure TVfoOverlay.SetAudioDevices(Names: TStrings; const Indices: array of Integer;
const CurName: string);
var
@@ -1190,10 +1309,10 @@ end;
procedure TVfoOverlay.UpdateSMeter(DB: Double);
begin
if Abs(DB - FSMeterDB) > 0.4 then
if Abs(DB - FSMeterDB) > FSMeterThreshold then
begin
FSMeterDB := DB;
RequestInvalidate;
RequestMeterInvalidate; // только полоска метра, без ре-рендера всего флага
end;
end;