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
+544 -26
View File
@@ -122,7 +122,14 @@ type
// Список устройств (saved+discovered) → FController.FDeviceStore (общий с web).
FDeviceDialog: TDeviceDialog;
FPendingIP: string; // IP устройства для подключения
FVfoOverlay: TVfoOverlay; // накладка SmartSDR-стиль на спектре
FVfoOverlay: TVfoOverlay; // накладка SmartSDR-стиль на спектре (слайс A / главный)
FSliceFlags: array of TVfoOverlay; // флаги доп. слайсов (B+)
FActiveFlag: TVfoOverlay; // флаг, чьё событие сейчас обрабатывается (для роутинга по SliceId)
FActiveSliceId: Integer; // выбранный активный слайс (0 = главный)
FPendingSliceClose: Integer; // SliceId к удалению после возврата из мыши (0 = нет)
FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру
FSliceDragId: Integer; // какой слайс тащим
FLastSliceMeterMs: QWord; // троттлинг S-метра слайсов (~10 Гц)
FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра
FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра
FPanelHidden: Boolean; // True = левая панель скрыта
@@ -516,6 +523,7 @@ type
// -------------------------------------------------------------------------
procedure BtnSettingsClick(Sender: TObject);
procedure PositionVfoOverlay;
procedure UpdateVfoOverlayVisibility; // флаг A виден: панель скрыта ИЛИ есть слайсы B+
procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
procedure OnVfoOverlayAGCChange(AGCMode: Integer);
@@ -524,9 +532,26 @@ type
procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
procedure OnVfoOverlaySplit(Sender: TObject);
procedure OnVfoOverlayTxSelect(Sender: TObject);
procedure OnVfoOverlaySliceSelect(Sender: TObject); // выбрать слайс активным
procedure OnVfoOverlayClose(Sender: TObject); // × — удалить слайс
procedure ProcessPendingSliceClose; // отложенное удаление флага
procedure PushVfoOverlayExtState;
procedure PushVfoOverlayAudioDevices;
procedure OnVfoOverlayInvalidate(Sender: TObject);
// --- Мультислайсы (UI) ---
function CurSliceId: Integer; // SliceId обрабатываемого сейчас флага
procedure WireOverlayEvents(O: TVfoOverlay);
procedure CreateSliceFlag(SliceId: Integer);
procedure DestroySliceFlag(SliceId: Integer);
function FindSliceFlag(SliceId: Integer): TVfoOverlay;
procedure AddSliceAtFreq(Hz: Double);
procedure PositionSliceFlags;
procedure PushSliceFlagState(SliceId: Integer);
function DispatchFlagsMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
procedure DispatchFlagsMouseMove(X, Y: Integer);
function DispatchFlagsMouseUp(X, Y: Integer): Boolean;
procedure DispatchFlagsMouseLeave;
function SliceAtPixel(X: Integer): Integer; // SliceId, чья несущая под X (0 = нет)
procedure PbSpectrumDblClick(Sender: TObject);
procedure TrkDriveChange(Sender: TObject);
procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive
@@ -960,16 +985,14 @@ begin
FDeviceDialog.Store := FController.FDeviceStore;
FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
FVfoOverlay := TVfoOverlay.Create(Self);
FVfoOverlay.OnSelect := OnModeFilterSelect;
FVfoOverlay.OnDSPChange := OnVfoOverlayDSPChange;
FVfoOverlay.OnAGCChange := OnVfoOverlayAGCChange;
FVfoOverlay.OnVolume := OnVfoOverlayVolume;
FVfoOverlay.OnMute := OnVfoOverlayMute;
FVfoOverlay.OnAudioDevice := OnVfoOverlayAudioDevice;
FVfoOverlay.OnSplit := OnVfoOverlaySplit;
FVfoOverlay.OnTxSelect := OnVfoOverlayTxSelect;
FVfoOverlay.OnInvalidate := OnVfoOverlayInvalidate;
WireOverlayEvents(FVfoOverlay);
FVfoOverlay.SetSliceInfo(0, 'A', True); // главный приёмник = слайс A
FVfoOverlay.Visible := False;
FActiveSliceId := 0;
FActiveFlag := nil;
FPendingSliceClose := 0;
FSliceDrag := False;
FSliceDragId := 0;
FPanelHidden := False;
FController.FShowSpectrum := True;
FController.FShowWaterfall := True;
@@ -1413,6 +1436,7 @@ begin
SyncSpecViewFreq;
UpdatePanZoomBar;
PositionVfoOverlay;
PositionSliceFlags;
FSpecView.DrawSpectrum;
PbSpectrum.Invalidate;
FSpecView.DrawWaterfall;
@@ -1497,6 +1521,16 @@ begin
PushDeviceListToWeb; // web-overlay тоже получает обновлённый список
end;
end;
// Если флаг A виден при ОТКРЫТОЙ панели (есть слайсы B+), держим его в
// синхроне с DSP/режимом/полосой/AGC, пришедшими с ЛЕВОЙ панели. При скрытой
// панели флаг правит себя сам (DoHit), синк не нужен. Только дискретные поля —
// SetState/SetDSPState безусловно инвалидируют (не звать на потоковых rfVfoA).
if Assigned(FVfoOverlay) and FVfoOverlay.Visible and (not FPanelHidden) and
(Field in [rfMode, rfFilter, rfNR, rfNB, rfSNB, rfANF, rfAGCMode]) then
begin
FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode);
end;
finally
FSyncingFromController := False;
end;
@@ -3188,6 +3222,7 @@ procedure TMainForm.SpectrumTimerTick(Sender: TObject);
var
NowTick: QWord;
ElapsedTick: QWord;
I: Integer;
begin
// Синхронизируем размеры если размер панели изменился (после ресайза).
// Важно: проверяем только видимые панели — когда спектр скрыт,
@@ -3266,6 +3301,19 @@ begin
PushVfoOverlayExtState;
PositionVfoOverlay;
end;
if Length(FSliceFlags) > 0 then
begin
PositionSliceFlags; // держим слайс-флаги на частоте
// S-метр слайсов троттлим до ~10 Гц: каждое изменение метра = софтовый
// ре-рендер флага (дорого), 60 Гц там не нужно.
if GetTickCount64 - FLastSliceMeterMs >= 100 then
begin
FLastSliceMeterMs := GetTickCount64;
for I := 0 to High(FSliceFlags) do // S-метр каждого слайса из его канала
if FSliceFlags[I] <> nil then
FSliceFlags[I].UpdateSMeter(FController.SliceSMeter(FSliceFlags[I].SliceId));
end;
end;
// Панорама и водопад теперь рисуются независимо по факту новых кадров.
// Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще.
if FSpectrumDirty then
@@ -4421,11 +4469,22 @@ procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var VC, VS: Double;
begin
if Assigned(FVfoOverlay) and
FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit;
if DispatchFlagsMouseDown(Button, X, Y) then
begin
ProcessPendingSliceClose; // × мог пометить слайс к удалению
Exit;
end;
if Assigned(FSampleRateOverlay) and
FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit;
// Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
begin
FController.GetViewWindow(VC, VS);
AddSliceAtFreq(PixelToFreq(X, PbSpectrum.Width, VC, VS));
Exit;
end;
if Button = mbLeft then
begin
// Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено»,
@@ -4441,6 +4500,18 @@ begin
UpdateBeaconButton;
Exit;
end;
// ЛКМ по маркеру несущей слайса — начинаем его перетаскивание (перетюн).
if (PbSpectrum.Width > 0) then
begin
FSliceDragId := SliceAtPixel(X);
if FSliceDragId <> 0 then
begin
FSliceDrag := True;
FActiveSliceId := FSliceDragId;
PbSpectrum.Cursor := crSizeWE;
Exit;
end;
end;
// Левая кнопка: сбрасываем маркер если активен
if FSpecView.MarkerActive then
begin
@@ -4485,18 +4556,40 @@ end;
procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer);
var
VC, VS, NewHz, LoHz, HiHz: Double;
begin
// Перетаскивание маркера несущей слайса — перетюн в пределах захваченной полосы.
if FSliceDrag then
begin
if (ssLeft in Shift) and (PbSpectrum.Width > 0) then
begin
FController.GetViewWindow(VC, VS);
NewHz := PixelToFreq(X, PbSpectrum.Width, VC, VS);
FController.CapturedSpan(LoHz, HiHz);
if NewHz < LoHz then NewHz := LoHz;
if NewHz > HiHz then NewHz := HiHz;
FController.SetSliceTarget(FSliceDragId, NewHz);
PushSliceFlagState(FSliceDragId); // обновить частоту во флаге
PositionSliceFlags;
FSpectrumDirty := True;
PbSpectrum.Invalidate;
end;
Exit;
end;
if Assigned(FSampleRateOverlay) then
begin
FSampleRateOverlay.HandleMouseMove(X, Y);
end;
if Assigned(FVfoOverlay) then
FVfoOverlay.HandleMouseMove(X, Y);
DispatchFlagsMouseMove(X, Y);
if not FSpecDrag then
begin
if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or
(Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then
PbSpectrum.Cursor := crHandPoint
else if SliceAtPixel(X) <> 0 then
PbSpectrum.Cursor := crSizeWE // над маркером слайса — его можно тащить
else
PbSpectrum.Cursor := crDefault;
end;
@@ -4514,7 +4607,14 @@ end;
procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if Assigned(FVfoOverlay) and FVfoOverlay.HandleMouseUp(X, Y) then
if FSliceDrag then
begin
FSliceDrag := False;
FSliceDragId := 0;
PbSpectrum.Cursor := crDefault;
Exit;
end;
if DispatchFlagsMouseUp(X, Y) then
begin
FSpecDrag := False;
PbSpectrum.Cursor := crDefault;
@@ -4533,8 +4633,7 @@ procedure TMainForm.PbSpectrumMouseLeave(Sender: TObject);
begin
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.HandleMouseLeave;
if Assigned(FVfoOverlay) then
FVfoOverlay.HandleMouseLeave;
DispatchFlagsMouseLeave;
if not FSpecDrag then PbSpectrum.Cursor := crDefault;
end;
@@ -5008,6 +5107,28 @@ begin
begin
FLeftPanelW := PanelLeft.Width; // сохраняем реальную ширину (учитывает DPI на Win32)
PanelLeft.Width := 0;
end
else
PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину
UpdateVfoOverlayVisibility; // видимость флага A решает общий инвариант
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.SetPanelHidden(FPanelHidden);
ResizeSpectrumPanels;
ResizeSMeter;
end;
// Флаг главного приёмника (слайс A) виден, когда СКРЫТА левая панель ЛИБО есть
// доп. слайсы (B+): тогда A всегда представлен флагом на спектре рядом с B/C…,
// а сворачивание/раскрытие панели при живых слайсах не дёргает его появление.
procedure TMainForm.UpdateVfoOverlayVisibility;
var
ShouldShow: Boolean;
begin
if not Assigned(FVfoOverlay) then Exit;
ShouldShow := FPanelHidden or (Length(FSliceFlags) > 0);
if ShouldShow = FVfoOverlay.Visible then Exit;
if ShouldShow then
begin
FVfoOverlay.Width := OVL_W;
FVfoOverlay.Height := FVfoOverlay.BaseHeight; // растёт сам при открытии fly-out
FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
@@ -5019,14 +5140,9 @@ begin
end
else
begin
PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину
FVfoOverlay.HandleMouseLeave;
FVfoOverlay.Visible := False;
end;
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.SetPanelHidden(FPanelHidden);
ResizeSpectrumPanels;
ResizeSMeter;
end;
procedure TMainForm.PositionVfoOverlay;
@@ -5089,6 +5205,14 @@ procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
var
Idx: Integer;
begin
// Событие пришло от флага слайса (B+) — маршрутизируем в slice-API, главный не трогаем.
if CurSliceId <> 0 then
begin
FController.SetSliceModeBW(CurSliceId, Mode, FilterBW);
PushSliceFlagState(CurSliceId);
PositionSliceFlags;
Exit;
end;
FController.FMode := Mode;
FController.FFilterBW := FilterBW;
if Mode = MODE_FM then
@@ -5118,6 +5242,11 @@ end;
procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceDSP(CurSliceId, NRMode, NBMode, SNBOn, ANFOn);
Exit;
end;
// Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки.
FController.SetNR(NRMode);
FController.SetNB(NBMode);
@@ -5127,34 +5256,59 @@ end;
procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceAGCMode(CurSliceId, TRadioController.AGCModeFromUI(AGCMode));
Exit;
end;
FController.SetAGCMode(AGCMode);
end;
procedure TMainForm.OnVfoOverlayVolume(Vol: Integer);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceVolume(CurSliceId, Vol / 100.0);
Exit;
end;
FController.SetVolume(Vol);
end;
procedure TMainForm.OnVfoOverlayMute(Sender: TObject);
var S: TCtrlSlice;
begin
if CurSliceId <> 0 then
begin
if FController.GetSlice(CurSliceId, S) then
FController.SetSliceMute(CurSliceId, not S.Muted);
Exit;
end;
FController.ToggleMute;
end;
procedure TMainForm.OnVfoOverlaySplit(Sender: TObject);
begin
if CurSliceId <> 0 then Exit; // SPLIT — только главный (слайсы не передают)
FController.SetSplit(not FController.FSplitTxB);
PushVfoOverlayExtState;
end;
procedure TMainForm.OnVfoOverlayTxSelect(Sender: TObject);
begin
// TODO(слайсы): выбрать этот слайс как TX-слайс. Пока один слайс —
// он всегда TX-слайс, поэтому клик ничего не меняет (хук под мультислайсы).
// TODO(слайсы): выбор TX-слайса при полноценной мультислайс-передаче.
// Пока передаёт только главный канал — клик по TX неглавного игнорируем.
if CurSliceId <> 0 then Exit;
PushVfoOverlayExtState;
end;
procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceDevice(CurSliceId, DevIndex, DevName);
PushSliceFlagState(CurSliceId); // обновить подсветку текущего устройства слайса
Exit;
end;
ApplyAudioDevice(DevIndex, DevName);
PushVfoOverlayAudioDevices; // обновить подсветку текущего устройства
end;
@@ -5193,9 +5347,326 @@ begin
if Assigned(FSpecView) then
begin
FSpecView.SpectrumDirty := True;
FSpecView.InvalidateOverlayCache;
// Полная инвалидация кэша текстур оверлеев (band/samplerate/главный VFO) нужна
// ТОЛЬКО когда у главного оверлея изменился хром (CacheDirty). Если сдвинулся
// лишь S-метр (MeterDirty) — перезаливаем только текстуру главного флага, не
// дёргая band/samplerate/слайсы. Слайсы перезаливают свою текстуру сами.
if Sender = TObject(FVfoOverlay) then
begin
if FVfoOverlay.CacheDirty then
FSpecView.InvalidateOverlayCache
else
FSpecView.InvalidateVfoOverlay;
end;
end;
if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
if Assigned(PbSpectrum) then
begin
PbSpectrum.Invalidate;
end;
end;
// ===========================================================================
// Мультислайсы (UI): коллекция флагов, роутинг событий по SliceId
// ===========================================================================
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A).
function TMainForm.CurSliceId: Integer;
begin
if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0;
end;
// Единая проводка событий флага (главного и слайсов) на общие обработчики.
procedure TMainForm.WireOverlayEvents(O: TVfoOverlay);
begin
O.OnSelect := OnModeFilterSelect;
O.OnDSPChange := OnVfoOverlayDSPChange;
O.OnAGCChange := OnVfoOverlayAGCChange;
O.OnVolume := OnVfoOverlayVolume;
O.OnMute := OnVfoOverlayMute;
O.OnAudioDevice := OnVfoOverlayAudioDevice;
O.OnSplit := OnVfoOverlaySplit;
O.OnTxSelect := OnVfoOverlayTxSelect;
O.OnSliceSelect := OnVfoOverlaySliceSelect;
O.OnClose := OnVfoOverlayClose;
O.OnInvalidate := OnVfoOverlayInvalidate;
end;
function TMainForm.FindSliceFlag(SliceId: Integer): TVfoOverlay;
var i: Integer;
begin
Result := nil;
for i := 0 to High(FSliceFlags) do
if (FSliceFlags[i] <> nil) and (FSliceFlags[i].SliceId = SliceId) then
Exit(FSliceFlags[i]);
end;
procedure TMainForm.CreateSliceFlag(SliceId: Integer);
var
O: TVfoOverlay;
S: TCtrlSlice;
begin
if FindSliceFlag(SliceId) <> nil then Exit;
if not FController.GetSlice(SliceId, S) then Exit;
O := TVfoOverlay.Create(Self);
WireOverlayEvents(O);
O.SetSliceInfo(SliceId, S.Letter, False);
O.SMeterThreshold := 1.5; // слайсы: грубее метр → меньше дорогих ре-рендеров флага
O.Width := OVL_W;
O.Height := O.BaseHeight;
O.Visible := True; // флаги слайсов видны всегда (не зависят от скрытия панели)
SetLength(FSliceFlags, Length(FSliceFlags) + 1);
FSliceFlags[High(FSliceFlags)] := O;
FSpecView.AddSliceOverlay(O);
PushSliceFlagState(SliceId);
UpdateVfoOverlayVisibility; // появился слайс B+ → показать и флаг A (если панель открыта)
PositionSliceFlags;
end;
procedure TMainForm.DestroySliceFlag(SliceId: Integer);
var
i, j: Integer;
O: TVfoOverlay;
begin
O := FindSliceFlag(SliceId);
if O = nil then Exit;
FController.RemoveSlice(SliceId);
FSpecView.RemoveSliceOverlay(O);
// Убираем из массива со сжатием.
j := 0;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> O then
begin
FSliceFlags[j] := FSliceFlags[i];
Inc(j);
end;
SetLength(FSliceFlags, j);
O.Free;
if FActiveSliceId = SliceId then FActiveSliceId := 0;
UpdateVfoOverlayVisibility; // закрыт последний слайс при открытой панели → скрыть флаг A
OnVfoOverlayInvalidate(nil);
end;
// Добавить софт-слайс на частоте Hz (режим/полоса копируются с главного).
procedure TMainForm.AddSliceAtFreq(Hz: Double);
var
Lo, Hi, Id: Integer;
begin
if not FController.SliceFitsCapture(Hz) then Exit; // вне захваченной полосы (см. Этап 3)
TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '');
if Id < 0 then Exit;
CreateSliceFlag(Id);
FActiveSliceId := Id;
end;
// SliceId, чья несущая под пикселем X (в пределах допуска), 0 если нет.
function TMainForm.SliceAtPixel(X: Integer): Integer;
const TOL = 6;
var
i, CarrierX: Integer;
VC, VS: Double;
S: TCtrlSlice;
O: TVfoOverlay;
begin
Result := 0;
if (PbSpectrum = nil) or (PbSpectrum.Width <= 0) then Exit;
FController.GetViewWindow(VC, VS);
if VS <= 0 then Exit;
for i := 0 to High(FSliceFlags) do
begin
O := FSliceFlags[i];
if (O = nil) or (not O.Visible) then Continue;
if not FController.GetSlice(O.SliceId, S) then Continue;
CarrierX := Round((S.TargetHz - VC + VS / 2) / VS * PbSpectrum.Width);
if Abs(X - CarrierX) <= TOL then Exit(O.SliceId);
end;
end;
procedure TMainForm.PositionSliceFlags;
var
i, VfoX, OW, NewLeft, NewTop, FilterStartX, FilterEndX, BWs: Integer;
VC, VS, LoHz, HiHz: Double;
S: TCtrlSlice;
O: TVfoOverlay;
Moved: Boolean;
begin
if (Length(FSliceFlags) = 0) or (PbSpectrum = nil) then Exit;
Moved := False;
FController.GetViewWindow(VC, VS);
for i := 0 to High(FSliceFlags) do
begin
O := FSliceFlags[i];
if (O = nil) or (not O.Visible) then Continue;
if not FController.GetSlice(O.SliceId, S) then Continue;
OW := O.Width;
if (PbSpectrum.Width > 0) and (VS > 0) then
VfoX := Round((S.TargetHz - VC + VS / 2) / VS * PbSpectrum.Width)
else
VfoX := PbSpectrum.Width div 2;
// Кромки полосы фильтра слайса относительно несущей — как у главного флага
// (PositionVfoOverlay): флаг ставится СБОКУ от полосы, а не от несущей.
case S.Mode of
MODE_LSB: BWs := -S.FilterLo;
MODE_USB: BWs := S.FilterHi;
else BWs := S.FilterHi - S.FilterLo;
end;
case S.Mode of
MODE_LSB: begin LoHz := -BWs; HiHz := -100; end;
MODE_USB: begin LoHz := 100; HiHz := BWs; end;
else begin LoHz := -BWs / 2; HiHz := BWs / 2; end;
end;
if (PbSpectrum.Width > 0) and (VS > 0) then
begin
FilterStartX := VfoX + Round(LoHz / VS * PbSpectrum.Width);
FilterEndX := VfoX + Round(HiHz / VS * PbSpectrum.Width);
end
else
begin
FilterStartX := VfoX;
FilterEndX := VfoX;
end;
FilterStartX := Max(0, Min(PbSpectrum.Width - 1, FilterStartX));
FilterEndX := Max(0, Min(PbSpectrum.Width - 1, FilterEndX));
// По умолчанию справа от полосы; если не влезает — слева от неё.
NewLeft := FilterEndX + 6;
if NewLeft + OW > PbSpectrum.Width - 4 then NewLeft := FilterStartX - 6 - OW;
NewLeft := Max(4, Min(PbSpectrum.Width - OW - 4, NewLeft));
NewTop := 16 + i * 12; // лёгкий вертикальный разнос, чтобы флаги не слипались
if (O.Left <> NewLeft) or (O.Top <> NewTop) then
begin
O.Left := NewLeft;
O.Top := NewTop;
Moved := True;
end;
end;
// Инвалидируем спектр только если флаг реально сдвинулся (ретюн/зум/пан).
// Раньше звали безусловно каждый тик → форс полной перерисовки на пустом месте.
if Moved then
begin
OnVfoOverlayInvalidate(nil);
end;
end;
// Заливает во флаг слайса его текущее состояние из контроллера.
procedure TMainForm.PushSliceFlagState(SliceId: Integer);
var
O: TVfoOverlay;
S: TCtrlSlice;
BW, Cnt, I: Integer;
Names: TStringList;
Indices: array[0..63] of Integer;
Idx: array of Integer;
begin
O := FindSliceFlag(SliceId);
if (O = nil) or not FController.GetSlice(SliceId, S) then Exit;
case S.Mode of
MODE_LSB: BW := -S.FilterLo;
MODE_USB: BW := S.FilterHi;
else BW := S.FilterHi - S.FilterLo;
end;
O.SetState(S.Mode, BW, S.TargetHz, FController.SliceSMeter(SliceId));
O.SetDSPState(0, 0, False, False, FController.FAGCMode);
O.SetExtState(Round(S.Volume * 100), S.Muted, False, False, False);
// Список аудио-устройств (общий PA-список) + текущее устройство слайса.
if Assigned(FController.FAudioOut) then
begin
Names := TStringList.Create;
try
Cnt := 0;
FController.FAudioOut.EnumOutputDevices(Names, Indices, Cnt);
SetLength(Idx, Cnt);
for I := 0 to Cnt - 1 do Idx[I] := Indices[I];
O.SetAudioDevices(Names, Idx, S.DevName);
finally
Names.Free;
end;
end;
end;
procedure TMainForm.OnVfoOverlaySliceSelect(Sender: TObject);
begin
if Sender is TVfoOverlay then
FActiveSliceId := TVfoOverlay(Sender).SliceId;
end;
procedure TMainForm.OnVfoOverlayClose(Sender: TObject);
begin
if not (Sender is TVfoOverlay) then Exit;
if TVfoOverlay(Sender).IsMain then Exit;
// Нельзя освобождать флаг внутри его же обработчика мыши — помечаем к удалению,
// ProcessPendingSliceClose снимет его после возврата из диспетчера.
FPendingSliceClose := TVfoOverlay(Sender).SliceId;
end;
procedure TMainForm.ProcessPendingSliceClose;
var Id: Integer;
begin
if FPendingSliceClose = 0 then Exit;
Id := FPendingSliceClose;
FPendingSliceClose := 0;
DestroySliceFlag(Id);
end;
function TMainForm.DispatchFlagsMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
var i: Integer;
begin
Result := False;
if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
begin
FActiveFlag := FVfoOverlay;
if FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit(True);
end;
for i := 0 to High(FSliceFlags) do
if (FSliceFlags[i] <> nil) and FSliceFlags[i].Visible then
begin
FActiveFlag := FSliceFlags[i];
if FSliceFlags[i].HandleMouseDown(Button, X, Y) then Exit(True);
end;
FActiveFlag := nil;
end;
procedure TMainForm.DispatchFlagsMouseMove(X, Y: Integer);
var i: Integer;
begin
if Assigned(FVfoOverlay) then
begin
FActiveFlag := FVfoOverlay;
FVfoOverlay.HandleMouseMove(X, Y);
end;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then
begin
FActiveFlag := FSliceFlags[i];
FSliceFlags[i].HandleMouseMove(X, Y);
end;
FActiveFlag := nil;
end;
function TMainForm.DispatchFlagsMouseUp(X, Y: Integer): Boolean;
var i: Integer;
begin
Result := False;
if Assigned(FVfoOverlay) then
begin
FActiveFlag := FVfoOverlay;
if FVfoOverlay.HandleMouseUp(X, Y) then Result := True;
end;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then
begin
FActiveFlag := FSliceFlags[i];
if FSliceFlags[i].HandleMouseUp(X, Y) then Result := True;
end;
FActiveFlag := nil;
end;
procedure TMainForm.DispatchFlagsMouseLeave;
var i: Integer;
begin
if Assigned(FVfoOverlay) then FVfoOverlay.HandleMouseLeave;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then FSliceFlags[i].HandleMouseLeave;
end;
procedure TMainForm.OnSampleRateSelect(SampleRate: Integer);
@@ -5752,16 +6223,63 @@ procedure TMainForm.FormMouseWheel(Sender: TObject; Shift: TShiftState;
P := C.ScreenToClient(Mouse.CursorPos);
Result := (P.X >= 0) and (P.Y >= 0) and (P.X < C.Width) and (P.Y < C.Height);
end;
// Курсор над флагом-оверлеем слайса (в клиентских координатах спектра)?
function SliceUnderCursor: Integer;
var P: TPoint; i: Integer; O: TVfoOverlay;
begin
Result := 0;
if (PbSpectrum = nil) or (not PbSpectrum.Visible) then Exit;
P := PbSpectrum.ScreenToClient(Mouse.CursorPos);
for i := 0 to High(FSliceFlags) do
begin
O := FSliceFlags[i];
if (O = nil) or (not O.Visible) then Continue;
if (P.X >= O.Left) and (P.X < O.Left + O.Width) and
(P.Y >= O.Top) and (P.Y < O.Top + O.Height) then
Exit(O.SliceId);
end;
end;
var
Step: Int64;
Delta: Int64;
Base: Int64;
NewFreq: Int64;
SliceId: Integer;
SLo, SHi, SNew: Double;
Sl: TCtrlSlice;
begin
// Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно
// (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты:
// виджет крутил бы B, а этот хендлер — активный A.
if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit;
// Колесо над маркером слайса — двигаем частоту ЭТОГО слайса, а не главного VFO.
SliceId := SliceUnderCursor;
if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then
begin
if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
else if ssCtrl in Shift then Step := 1000
else if ssShift in Shift then Step := 10
else Step := 100;
Delta := 1;
if WheelDelta < 0 then Delta := -1;
// Квантование к сетке шага + шаг в сторону колеса.
Base := (Round(Sl.TargetHz) div Step) * Step;
if Delta > 0 then SNew := Base + Step
else if Round(Sl.TargetHz) = Base then SNew := Base - Step
else SNew := Base;
FController.CapturedSpan(SLo, SHi);
if SNew < SLo then SNew := SLo;
if SNew > SHi then SNew := SHi;
FController.SetSliceTarget(SliceId, SNew);
PushSliceFlagState(SliceId);
PositionSliceFlags;
FSpectrumDirty := True;
PbSpectrum.Invalidate;
FActiveSliceId := SliceId;
Handled := True;
Exit;
end;
if (FController.FMode = MODE_FM) and FController.FFMStepOn and
not ((ssCtrl in Shift) or (ssShift in Shift)) then
Step := FM_STEP_HZ[FController.FFMStepIdx]