diff --git a/MainForm.pas b/MainForm.pas
index 48d9b1e..93da27e 100644
--- a/MainForm.pas
+++ b/MainForm.pas
@@ -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]
diff --git a/RadioController.pas b/RadioController.pas
index 3567bf5..06d99f9 100644
--- a/RadioController.pas
+++ b/RadioController.pas
@@ -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);
diff --git a/SpectrumView.pas b/SpectrumView.pas
index dd90f78..f607df3 100644
--- a/SpectrumView.pas
+++ b/SpectrumView.pas
@@ -70,6 +70,7 @@ type
FADCOverloadVisible: Boolean;
FSampleRateOverlay: TSampleRateOverlay;
FVfoOverlay: TVfoOverlay;
+ FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm)
FBandPlanOverlay: TBandPlanOverlay;
// ── Marker ────────────────────────────────────────────────────────────────
FMarkerActive: Boolean;
@@ -108,12 +109,15 @@ type
procedure SetFillSpectrum(V: Boolean);
procedure DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer);
procedure DrawMarkerLine(C: TCanvas; W, H: Integer);
+ procedure DrawSliceFilterMarkers(C: TCanvas; W, H: Integer);
procedure DrawBeaconMarkers(C: TCanvas; W, H: Integer);
procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte);
procedure CopyGridToSpectrum(W, H: Integer);
procedure DrawADCOverloadOverlay(C: TCanvas; W, H: Integer);
procedure CalcFilterBandX(VfoFreq: Double; W: Integer;
out X1, X2, VfoX: Integer);
+ procedure CalcFilterBandXFor(VfoFreq: Double; Mode, BW, W: Integer;
+ out X1, X2, VfoX: Integer);
function GetWaterfallDirty: Boolean;
procedure SetWaterfallDirty(V: Boolean);
// Waterfall sub-view delegating accessors
@@ -218,6 +222,11 @@ type
property PbSMeterRight: TPaintBox write SetPbSMeterRight;
property SampleRateOverlay: TSampleRateOverlay read FSampleRateOverlay write FSampleRateOverlay;
property VfoOverlay: TVfoOverlay read FVfoOverlay write FVfoOverlay;
+ // Доп. слайс-флаги (B+): рисуются поверх спектра после главного оверлея.
+ procedure AddSliceOverlay(O: TVfoOverlay);
+ procedure RemoveSliceOverlay(O: TVfoOverlay);
+ function SliceOverlayCount: Integer;
+ function SliceOverlayAt(Index: Integer): TVfoOverlay;
property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay;
// ── Данные от DSP ─────────────────────────────────────────────────────────
@@ -244,6 +253,9 @@ type
// ── Утилиты ───────────────────────────────────────────────────────────────
procedure InvalidateGridCache; virtual;
procedure InvalidateOverlayCache; virtual;
+ // Инвалидация ТОЛЬКО текстуры главного VFO-флага (без band/samplerate/слайсов).
+ // Нужна для дешёвого обновления S-метра главного флага в GL-вьюхе.
+ procedure InvalidateVfoOverlay; virtual;
procedure SetTheme(const T: TAppTheme); virtual;
procedure InvalidateRulerCache; virtual;
procedure ResetSpectrumBuf; virtual;
@@ -329,6 +341,7 @@ begin
FWaterfall := TWaterfallView.Create;
FSMeter := TSMeterView.Create;
FRuler := TRulerView.Create;
+ FSliceOverlays := TFPList.Create;
ResetSpectrumBuf;
end;
@@ -339,9 +352,30 @@ begin
FWaterfall.Free;
FSMeter.Free;
FRuler.Free;
+ FSliceOverlays.Free; // не владеет флагами — только список
inherited;
end;
+procedure TSpectrumView.AddSliceOverlay(O: TVfoOverlay);
+begin
+ if (O <> nil) and (FSliceOverlays.IndexOf(O) < 0) then FSliceOverlays.Add(O);
+end;
+
+procedure TSpectrumView.RemoveSliceOverlay(O: TVfoOverlay);
+begin
+ FSliceOverlays.Remove(O);
+end;
+
+function TSpectrumView.SliceOverlayCount: Integer;
+begin
+ Result := FSliceOverlays.Count;
+end;
+
+function TSpectrumView.SliceOverlayAt(Index: Integer): TVfoOverlay;
+begin
+ Result := TVfoOverlay(FSliceOverlays[Index]);
+end;
+
// ────────────────────────────────────────────────────────────────────────────
// Setters with cascade to sub-views
// ────────────────────────────────────────────────────────────────────────────
@@ -583,6 +617,32 @@ begin
end;
end;
+// Маркер фильтра каждого слайса (B+) на спектре — полупрозрачная полоса
+// пропускания + края + центральная несущая. Янтарный цвет, как в GL-вьюхе
+// (SpectrumViewOpengl.DrawSliceFilterMarkers). Рисуется под кривой спектра.
+procedure TSpectrumView.DrawSliceFilterMarkers(C: TCanvas; W, H: Integer);
+const
+ SLICE_R = $F0; SLICE_G = $A8; SLICE_B = $10; // янтарный (совпадает с SLICE_CLR в GL)
+ SLICE_CLR = TColor($0010A8F0);
+var
+ i, SX1, SX2, SVfoX: Integer;
+ O: TVfoOverlay;
+begin
+ for i := 0 to FSliceOverlays.Count - 1 do
+ begin
+ O := TVfoOverlay(FSliceOverlays[i]);
+ if (O = nil) or (not O.Visible) then Continue;
+ CalcFilterBandXFor(O.DisplayVfoHz, O.DisplayMode, O.DisplayBW, W, SX1, SX2, SVfoX);
+ if SX2 > SX1 then BlendBand(SX1, SX2, H, SLICE_R, SLICE_G, SLICE_B, 60);
+ C.Pen.Color := SLICE_CLR; C.Pen.Width := 1; C.Pen.Style := psSolid;
+ C.MoveTo(SX1, 0); C.LineTo(SX1, H);
+ C.MoveTo(SX2, 0); C.LineTo(SX2, H);
+ C.Pen.Width := 2;
+ C.MoveTo(SVfoX, 0); C.LineTo(SVfoX, H - 12);
+ C.Pen.Width := 1;
+ end;
+end;
+
procedure TSpectrumView.CopyGridToSpectrum(W, H: Integer);
var
Y: Integer;
@@ -620,15 +680,22 @@ end;
procedure TSpectrumView.CalcFilterBandX(VfoFreq: Double; W: Integer;
out X1, X2, VfoX: Integer);
+begin
+ CalcFilterBandXFor(VfoFreq, FMode, FFilterBW, W, X1, X2, VfoX);
+end;
+
+// То же, но для произвольного режима/полосы (маркеры слайсов).
+procedure TSpectrumView.CalcFilterBandXFor(VfoFreq: Double; Mode, BW, W: Integer;
+ out X1, X2, VfoX: Integer);
var
Lo_Hz, Hi_Hz, Half: Double;
begin
- Half := FFilterBW / 2;
+ Half := BW / 2;
VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W);
- case FMode of
- 0: begin Lo_Hz := -FFilterBW; Hi_Hz := -100; end;
- 1: begin Lo_Hz := 100; Hi_Hz := FFilterBW; end;
- else begin Lo_Hz := -Half; Hi_Hz := Half; end;
+ case Mode of
+ 0: begin Lo_Hz := -BW; Hi_Hz := -100; end;
+ 1: begin Lo_Hz := 100; Hi_Hz := BW; end;
+ else begin Lo_Hz := -Half; Hi_Hz := Half; end;
end;
X1 := Max(0, Min(W - 1, VfoX + Round(Lo_Hz / FSpanHz * W)));
X2 := Max(0, Min(W - 1, VfoX + Round(Hi_Hz / FSpanHz * W)));
@@ -737,6 +804,13 @@ begin
FSpectrumDirty := True;
end;
+procedure TSpectrumView.InvalidateVfoOverlay;
+begin
+ // База (CPU-путь) перерисовывает флаг по FCacheDirty/FMeterDirty оверлея — здесь
+ // достаточно пометить спектр грязным. GL-подкласс переопределяет.
+ FSpectrumDirty := True;
+end;
+
procedure TSpectrumView.SetTheme(const T: TAppTheme);
begin
FTheme := T;
@@ -905,6 +979,9 @@ begin
C.Pen.Style := psSolid;
end;
+ // Полосы фильтра слайсов (B+) — под кривой спектра, как в GL-вьюхе.
+ DrawSliceFilterMarkers(C, W, H);
+
// ── 3. AGC линии ──────────────────────────────────────────────────────────
if FWDSPReady then
begin
@@ -1009,6 +1086,9 @@ begin
FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H);
if Assigned(FVfoOverlay) then
FVfoOverlay.DrawOverlay(FSpectrumBitmap, W, H);
+ // Доп. слайс-флаги (B+) поверх главного.
+ for i := 0 to FSliceOverlays.Count - 1 do
+ TVfoOverlay(FSliceOverlays[i]).DrawOverlay(FSpectrumBitmap, W, H);
// Восстановление непрозрачности. На Cocoa проверено, что проход не нужен
// (Canvas-операции сохраняют альфу $FF), поэтому там его не делаем.
diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas
index 941f38c..5ca6a8f 100644
--- a/SpectrumViewOpengl.pas
+++ b/SpectrumViewOpengl.pas
@@ -43,6 +43,12 @@ type
FSampleOverlayTex: TGLTextureCache;
FVfoOverlayTex: TGLTextureCache;
FBandOverlayTex: TGLTextureCache;
+ // Текстуры флагов слайсов (B+), привязка по указателю оверлея.
+ FSliceTex: array of record
+ Overlay: TVfoOverlay;
+ Tex: TGLTextureCache;
+ LastX, LastY, LastW, LastH: Integer;
+ end;
FLastAGCText: string;
FLastAGCY: Integer;
FLastAGCHangY: Integer;
@@ -83,6 +89,9 @@ type
procedure DrawBeaconMarkersGL(W, H: Integer);
procedure DrawADCOverlay(W, H: Integer);
procedure DrawCachedOverlays(W, H: Integer);
+ procedure DrawSliceOverlays(W, H: Integer);
+ procedure DrawSliceFilterMarkers(W, H: Integer);
+ function SliceTexIndex(O: TVfoOverlay): Integer; // индекс в FSliceTex, -1 если нет
function FormatFreqGL(Hz: Double): string;
function ActiveVfoFrequency: Double;
public
@@ -96,6 +105,7 @@ type
procedure PaintSpectrum(Sender: TObject); override;
procedure InvalidateGridCache; override;
procedure InvalidateOverlayCache; override;
+ procedure InvalidateVfoOverlay; override;
procedure SetTheme(const T: TAppTheme); override;
end;
@@ -138,6 +148,7 @@ begin
end;
destructor TSpectrumViewOpenGL.Destroy;
+var i: Integer;
begin
if Assigned(FGLControl) and FGLControl.MakeCurrent then
begin
@@ -149,7 +160,9 @@ begin
DeleteTexture(FSampleOverlayTex);
DeleteTexture(FVfoOverlayTex);
DeleteTexture(FBandOverlayTex);
+ for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex);
end;
+ SetLength(FSliceTex, 0);
inherited Destroy;
end;
@@ -392,6 +405,13 @@ begin
FVfoOverlayDirty := True;
end;
+procedure TSpectrumViewOpenGL.InvalidateVfoOverlay;
+begin
+ // Только текстура главного флага — НЕ трогаем FOverlayDirty, иначе band/
+ // samplerate/слайсы зря перезаливались бы на каждый тик S-метра главного.
+ FVfoOverlayDirty := True;
+end;
+
procedure TSpectrumViewOpenGL.SetTheme(const T: TAppTheme);
begin
inherited SetTheme(T);
@@ -536,6 +556,29 @@ begin
DrawLine(TXVfoX, 0, TXVfoX, H - 12, TColor($002030E0), 2, False);
DrawRect(TXVfoX - 5, 0, TXVfoX + 5, 8, TColor($002030E0), 1);
end;
+
+ DrawSliceFilterMarkers(W, H);
+end;
+
+// Маркер фильтра каждого слайса (B+) на спектре: полупрозрачная полоса
+// пропускания + центральная несущая. Цвет янтарный — отличить от главного.
+procedure TSpectrumViewOpenGL.DrawSliceFilterMarkers(W, H: Integer);
+const
+ SLICE_CLR = TColor($0010A8F0); // янтарный (BGR), как акцент TX-select флага
+var
+ i, SX1, SX2, SVfoX: Integer;
+ O: TVfoOverlay;
+begin
+ for i := 0 to SliceOverlayCount - 1 do
+ begin
+ O := SliceOverlayAt(i);
+ if (O = nil) or (not O.Visible) then Continue;
+ CalcFilterBandXFor(O.DisplayVfoHz, O.DisplayMode, O.DisplayBW, W, SX1, SX2, SVfoX);
+ if SX2 > SX1 then DrawRect(SX1, 0, SX2, H, SLICE_CLR, 60 / 255);
+ DrawLine(SX1, 0, SX1, H, SLICE_CLR, 1, False);
+ DrawLine(SX2, 0, SX2, H, SLICE_CLR, 1, False);
+ DrawLine(SVfoX, 0, SVfoX, H - 12, SLICE_CLR, 2, False);
+ end;
end;
procedure TSpectrumViewOpenGL.DrawAGCLines(W, H: Integer; DBmax,
@@ -783,16 +826,11 @@ begin
if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
begin
if FOverlayDirty or FVfoOverlayDirty or FVfoOverlayTex.Dirty or
+ FVfoOverlay.CacheDirty or FVfoOverlay.MeterDirty or
(FLastVfoX <> FVfoOverlay.Left) or (FLastVfoY <> FVfoOverlay.Top) or
(FLastVfoW <> FVfoOverlay.Width) or (FLastVfoH <> FVfoOverlay.Height) then
begin
- B := TBitmap.Create;
- try
- FVfoOverlay.DrawOverlayBitmap(B);
- UploadBitmap(FVfoOverlayTex, B, True, VFO_OVERLAY_ALPHA);
- finally
- B.Free;
- end;
+ UploadBitmap(FVfoOverlayTex, FVfoOverlay.EnsureRendered, True, VFO_OVERLAY_ALPHA);
FLastVfoX := FVfoOverlay.Left;
FLastVfoY := FVfoOverlay.Top;
FLastVfoW := FVfoOverlay.Width;
@@ -801,9 +839,76 @@ begin
end;
DrawTexture(FVfoOverlayTex, FVfoOverlay.Left, FVfoOverlay.Top);
end;
+
+ DrawSliceOverlays(W, H);
FOverlayDirty := False;
end;
+function TSpectrumViewOpenGL.SliceTexIndex(O: TVfoOverlay): Integer;
+var i: Integer;
+begin
+ Result := -1;
+ for i := 0 to High(FSliceTex) do
+ if FSliceTex[i].Overlay = O then Exit(i);
+end;
+
+// Флаги слайсов (B+): своя GL-текстура на флаг (привязка по указателю оверлея).
+// Текстуры «сирот» (флаг удалён) вычищаются здесь же.
+procedure TSpectrumViewOpenGL.DrawSliceOverlays(W, H: Integer);
+var
+ n, i, ti, j: Integer;
+ O: TVfoOverlay;
+ Alive: Boolean;
+begin
+ // 1. Убрать текстуры флагов, которых уже нет в списке.
+ i := 0;
+ while i <= High(FSliceTex) do
+ begin
+ Alive := False;
+ for j := 0 to SliceOverlayCount - 1 do
+ if SliceOverlayAt(j) = FSliceTex[i].Overlay then begin Alive := True; Break; end;
+ if not Alive then
+ begin
+ DeleteTexture(FSliceTex[i].Tex);
+ for j := i to High(FSliceTex) - 1 do FSliceTex[j] := FSliceTex[j + 1];
+ SetLength(FSliceTex, Length(FSliceTex) - 1);
+ end
+ else
+ Inc(i);
+ end;
+
+ // 2. Нарисовать каждый видимый флаг, при нужде (пере)залив текстуру.
+ n := SliceOverlayCount;
+ for i := 0 to n - 1 do
+ begin
+ O := SliceOverlayAt(i);
+ if (O = nil) or (not O.Visible) then Continue;
+ ti := SliceTexIndex(O);
+ if ti < 0 then
+ begin
+ SetLength(FSliceTex, Length(FSliceTex) + 1);
+ ti := High(FSliceTex);
+ FSliceTex[ti].Overlay := O;
+ FSliceTex[ti].Tex.Dirty := True;
+ FSliceTex[ti].LastX := MaxInt; // форсируем первую заливку
+ end;
+ // Перезаливаем текстуру слайса ТОЛЬКО когда изменился он сам (CacheDirty:
+ // режим/громкость, MeterDirty: S-метр) или его позиция — не по глобальному
+ // FOverlayDirty. При MeterDirty EnsureRendered перерисует лишь полоску метра.
+ if O.CacheDirty or O.MeterDirty or FSliceTex[ti].Tex.Dirty or
+ (FSliceTex[ti].LastX <> O.Left) or (FSliceTex[ti].LastY <> O.Top) or
+ (FSliceTex[ti].LastW <> O.Width) or (FSliceTex[ti].LastH <> O.Height) then
+ begin
+ UploadBitmap(FSliceTex[ti].Tex, O.EnsureRendered, True, VFO_OVERLAY_ALPHA);
+ FSliceTex[ti].LastX := O.Left;
+ FSliceTex[ti].LastY := O.Top;
+ FSliceTex[ti].LastW := O.Width;
+ FSliceTex[ti].LastH := O.Height;
+ end;
+ DrawTexture(FSliceTex[ti].Tex, O.Left, O.Top);
+ end;
+end;
+
procedure TSpectrumViewOpenGL.PaintSpectrum(Sender: TObject);
var
W, H: Integer;
diff --git a/VfoOverlay.pas b/VfoOverlay.pas
index f989903..6c8f3f2 100644
--- a/VfoOverlay.pas
+++ b/VfoOverlay.pas
@@ -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;
diff --git a/WDSPEngine.pas b/WDSPEngine.pas
index 2cb3170..52aebca 100644
--- a/WDSPEngine.pas
+++ b/WDSPEngine.pas
@@ -96,6 +96,10 @@ const
RXA_CHAN = 0;
TXA_CHAN = 1; // используем разные каналы для RX и TX
+ // --- Мультислайсы (софт-fan-out) ---
+ MAX_SLICES = 6; // потолок одновременных софтовых слайсов
+ SLICE_CHAN_BASE = 2; // WDSP-каналы слайсов: 0=RXA, 1=TXA, слайсы с 2+
+
RX_DISP_ID = 0; // analyzer для RX-спектра/водопада
TX_DISP_ID = 1; // analyzer для TX-спектра/водопада
DSP_BUFSIZE = 1024;
@@ -135,6 +139,10 @@ type
TOnTXIQReady = procedure(const Buf: array of Double; Count: Integer) of object;
TOnWaterfallReady = procedure(const Pixels: array of Single;
Count: Integer) of object;
+ // Аудио готового слайса — вызывается из DSP-потока по каждому активному слайсу.
+ // SliceId — логический id (из RadioController), не WDSP-канал.
+ TOnSliceAudio = procedure(SliceId: Integer; const Left, Right: array of Single;
+ Count: Integer) of object;
// Sound-card mic source: TTXDSPThread дёргает этот колбэк перед каждым тиком,
// чтобы вытянуть накопленные сэмплы из внешнего буфера (например PortAudio).
// Реализация должна вернуть кол-во отправленных в WDSP сэмплов.
@@ -143,6 +151,28 @@ type
// TX mic source: откуда получаем сэмплы для модулятора.
TTXMicSource = (txmsRadio, txmsSoundCard, txmsWeb);
+ // Софтовый слайс: отдельный WDSP-канал, кормится тем же IQ-блоком, что и
+ // RXA_CHAN, но со своим NCO-сдвигом/режимом/фильтром/AGC/громкостью и своим
+ // аудио-callback'ом. Геометрия канала = как у RXA_CHAN.
+ TSliceChan = record
+ Active: Boolean; // слот занят
+ Id: Integer; // логический id слайса
+ Chan: Integer; // WDSP-канал (SLICE_CHAN_BASE + слот)
+ ShiftHz: Double; // NCO-сдвиг = TargetHz - CapturedCenterHz
+ Mode: Integer; // индекс режима (MODE_*)
+ FilterLo: Integer;
+ FilterHi: Integer;
+ AGCMode: TWDSPAGCMode;
+ Volume: Double; // 0..1
+ Muted: Boolean;
+ NRMode: Integer; // 0=off,1..4
+ NBMode: Integer; // 0=off,1=NB,2=NB2
+ SNBOn: Boolean;
+ ANFOn: Boolean;
+ SMeter: Double; // последнее показание S-метра канала (dBm), пишет DSP-поток
+ Opened: Boolean; // WDSP-канал реально открыт (после Open движка)
+ end;
+
{ TWDSPEngine }
TWDSPEngine = class
private
@@ -219,6 +249,15 @@ type
FOnWaterfall: TOnWaterfallReady;
FOnTXIQ: TOnTXIQReady;
+ // --- Мультислайсы ---
+ FSlices: array[0..MAX_SLICES-1] of TSliceChan;
+ FSliceLock: TCriticalSection; // сериализует add/remove/set vs DSP-обработку
+ FSliceIn: array of Double; // interleaved вход fexchange0 (переиспользуем)
+ FSliceOut: array of Double; // interleaved выход fexchange0
+ FSliceOutL: array of Single; // деинтерливленный L для callback
+ FSliceOutR: array of Single; // R
+ FOnSliceAudio: TOnSliceAudio;
+
// TX mic ring buffer (lock-free: один writer — receive thread,
// один reader — TX DSP thread)
FTXMicRing: array[0..TX_MIC_RING-1] of Double;
@@ -311,6 +350,13 @@ type
function ModeToWDSP(Mode: Integer): Integer;
procedure ApplyDefaultFilter;
+ // --- Мультислайсы ---
+ function FindSliceIdx(Id: Integer): Integer; // -1 если нет
+ procedure OpenSliceChannel(Idx: Integer); // OpenChannel + настройки
+ procedure CloseSliceChannel(Idx: Integer); // CloseChannel (запись хранит)
+ procedure ApplyAGCToChan(Chan: Integer; Mode: TWDSPAGCMode; FixedGain: Double);
+ procedure ProcessSlices; // fan-out в ProcessRXBlock
+ procedure AllocSliceBuffers; // (пере)аллокация буферов
procedure FeedDisplaySample(const I, Q: Double);
procedure ProcessRXBlock;
procedure PushIQItemToDSP(const Item: TIQQueueItem);
@@ -329,6 +375,10 @@ type
procedure ApplyNBState;
procedure ApplySNBState;
procedure ApplyANFState;
+ procedure ApplyNRToChan(Chan, NRMode: Integer);
+ procedure ApplyNBToChan(Chan, NBMode: Integer);
+ procedure ApplySNBToChan(Chan: Integer; On_: Boolean);
+ procedure ApplyANFToChan(Chan: Integer; On_: Boolean);
procedure ApplyNoiseFilterState;
procedure ProcessTXBlock; // вызывается из TTXDSPThread
procedure FeedTXDisplaySample(const I, Q: Double);
@@ -350,6 +400,26 @@ type
// QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет).
procedure SetBeaconDecoder(D: TBeaconDecoder);
+ // --- Мультислайсы (софт-fan-out) ---
+ // Добавляет слайс с логическим Id (уникальным). ShiftHz = TargetHz - центр
+ // захвата. Возвращает False если нет свободных слотов или Id уже есть.
+ function AddSlice(Id, Mode, FilterLo, FilterHi: Integer;
+ ShiftHz, Volume: Double; AGC: TWDSPAGCMode): Boolean;
+ procedure RemoveSlice(Id: Integer);
+ procedure SetSliceShift(Id: Integer; ShiftHz: Double);
+ procedure SetSliceMode(Id, Mode: Integer);
+ procedure SetSliceFilter(Id, Low, High: Integer);
+ procedure SetSliceAGC(Id: Integer; Mode: TWDSPAGCMode);
+ procedure SetSliceVolume(Id: Integer; Vol: Double);
+ procedure SetSliceMute(Id: Integer; Mute: Boolean);
+ procedure SetSliceNRMode(Id, Mode: Integer);
+ procedure SetSliceNBMode(Id, Mode: Integer);
+ procedure SetSliceSNB(Id: Integer; On_: Boolean);
+ procedure SetSliceANF(Id: Integer; On_: Boolean);
+ function GetSliceSMeter(Id: Integer): Double;
+ function SliceCount: Integer;
+ function HasSlice(Id: Integer): Boolean;
+
// Подача 24-bit big-endian IQ из DDC пакета
// Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf
procedure PushDDCPacket(const Buf: array of Byte;
@@ -475,6 +545,7 @@ type
property WfAvgMode: Integer read FWfAvgMode;
property WfAvgTimeMS: Double read FWfAvgTimeMS;
property OnAudio: TOnAudioReady read FOnAudio write FOnAudio;
+ property OnSliceAudio: TOnSliceAudio read FOnSliceAudio write FOnSliceAudio;
property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum;
property OnWaterfall: TOnWaterfallReady read FOnWaterfall write FOnWaterfall;
property OnTXIQ: TOnTXIQReady read FOnTXIQ write FOnTXIQ;
@@ -813,6 +884,12 @@ begin
FDSPThread := nil;
FDisplayThread := nil;
+ // Мультислайсы
+ FSliceLock := TCriticalSection.Create;
+ FillChar(FSlices, SizeOf(FSlices), 0);
+ FOnSliceAudio := nil;
+ AllocSliceBuffers;
+
// TX инициализация
FTXMicHead := 0;
FTXMicTail := 0;
@@ -887,6 +964,7 @@ begin
Close;
RTLEventDestroy(FQueueSem);
RTLEventDestroy(FTXMicSem);
+ FSliceLock.Free;
inherited;
end;
@@ -1052,6 +1130,7 @@ end;
function TWDSPEngine.Open: Boolean;
var
DllPath: string;
+ i: Integer;
begin
Result := False;
FLastError := '';
@@ -1131,6 +1210,18 @@ begin
// Leveler, ALC, Compressor, Phase Rotator, AM Carrier, FM, CTCSS.
ApplyTXChainSettings;
ApplyNoiseFilterState;
+
+ // Переоткрываем WDSP-каналы активных слайсов (например после ChangeSampleRate,
+ // где Close закрыл их, но записи FSlices сохранились).
+ FSliceLock.Enter;
+ try
+ for i := 0 to MAX_SLICES - 1 do
+ if FSlices[i].Active and not FSlices[i].Opened then
+ OpenSliceChannel(i);
+ finally
+ FSliceLock.Leave;
+ end;
+
Result := True;
// Запускаем DSP поток после успешной инициализации WDSP
@@ -1149,6 +1240,8 @@ begin
end;
procedure TWDSPEngine.Close;
+var
+ i: Integer;
begin
if not FInitialized then Exit;
@@ -1161,6 +1254,15 @@ begin
FreeAndNil(FDSPThread);
end;
+ // Закрываем WDSP-каналы слайсов (записи FSlices сохраняем — переоткроются в Open).
+ FSliceLock.Enter;
+ try
+ for i := 0 to MAX_SLICES - 1 do
+ if FSlices[i].Opened then CloseSliceChannel(i);
+ finally
+ FSliceLock.Leave;
+ end;
+
CloseAnalyzer;
destroy_anbEXT(RXA_CHAN);
destroy_nobEXT(RXA_CHAN);
@@ -1195,6 +1297,7 @@ begin
SetLength(FRXAccQ, FBufSize);
SetLength(FDispBuf, DISPLAY_BLOCK_SIZE * 2);
FDispPos := 0;
+ AllocSliceBuffers;
// Переинициализируем WDSP с новым rate
Open;
@@ -1379,11 +1482,360 @@ begin
FOnAudio(FOutL, FOutR, FAudioBufSize);
end;
+ // Софтовые слайсы: fan-out того же IQ-блока на доп. WDSP-каналы.
+ ProcessSlices;
+
except
// Защита от падения libwdsp в потоке — просто пропускаем блок
end;
end;
+// ---------------------------------------------------------------------------
+// Мультислайсы (софт-fan-out)
+// ---------------------------------------------------------------------------
+
+procedure TWDSPEngine.AllocSliceBuffers;
+begin
+ SetLength(FSliceIn, FBufSize * 2);
+ SetLength(FSliceOut, FBufSize * 2); // с запасом (>= FAudioBufSize*2)
+ SetLength(FSliceOutL, FAudioBufSize);
+ SetLength(FSliceOutR, FAudioBufSize);
+end;
+
+function TWDSPEngine.FindSliceIdx(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;
+
+// Применяет AGC к произвольному каналу — вынесено из SetAGC чтобы делить с слайсами.
+procedure TWDSPEngine.ApplyAGCToChan(Chan: Integer; Mode: TWDSPAGCMode;
+ FixedGain: Double);
+begin
+ SetRXAAGCMode(Chan, Ord(Mode));
+ SetRXAAGCSlope(Chan, FAGCSlope);
+ SetRXAAGCTop(Chan, FAGCTop);
+ case Mode of
+ agcOff:
+ SetRXAAGCFixed(Chan, FixedGain);
+ agcLong: begin
+ SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 2000);
+ SetRXAAGCDecay(Chan, 2000); SetRXAAGCHangThreshold(Chan, FAGCHangThreshold);
+ end;
+ agcSlow: begin
+ SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 1000);
+ SetRXAAGCDecay(Chan, 500); SetRXAAGCHangThreshold(Chan, FAGCHangThreshold);
+ end;
+ agcMedium: begin
+ SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 0);
+ SetRXAAGCDecay(Chan, 250); SetRXAAGCHangThreshold(Chan, 100);
+ end;
+ agcFast: begin
+ SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 0);
+ SetRXAAGCDecay(Chan, 50); SetRXAAGCHangThreshold(Chan, 100);
+ end;
+ end;
+end;
+
+// Открывает WDSP-канал слайса с той же геометрией, что RXA_CHAN. Вызывать
+// под FSliceLock и только при FInitialized.
+procedure TWDSPEngine.OpenSliceChannel(Idx: Integer);
+var Chan: Integer;
+begin
+ if not FInitialized then Exit;
+ if FSlices[Idx].Opened then Exit;
+ Chan := FSlices[Idx].Chan;
+ OpenChannel(Chan, FBufSize, FAudioBufSize, FSampleRate, FAudioRate, FAudioRate,
+ 0, 0, 0.010, 0.010, 0.010, 0.010, 0);
+ // NB (ANB/NOB) требует создания EXT-блоков на канале, как для RXA_CHAN в Open.
+ create_anbEXT(Chan, 1, FBufSize, FSampleRate, 0.0001, 0.0001, 0.0001, 0.05, 20);
+ create_nobEXT(Chan, 1, 0, FBufSize, FSampleRate, 0.0001, 0.0001, 0.0001, 0.05, 20);
+ SetRXABandpassWindow(Chan, 1);
+ SetRXAMode(Chan, ModeToWDSP(FSlices[Idx].Mode));
+ SetRXAShiftRun(Chan, 1);
+ SetRXAShiftFreq(Chan, FSlices[Idx].ShiftHz);
+ RXASetPassband(Chan, FSlices[Idx].FilterLo, FSlices[Idx].FilterHi);
+ ApplyAGCToChan(Chan, FSlices[Idx].AGCMode, 50.0);
+ // NR/NB/SNB/ANF из сохранённого состояния слайса.
+ ApplyNRToChan(Chan, FSlices[Idx].NRMode);
+ ApplyNBToChan(Chan, FSlices[Idx].NBMode);
+ ApplySNBToChan(Chan, FSlices[Idx].SNBOn);
+ ApplyANFToChan(Chan, FSlices[Idx].ANFOn);
+ SetRXAPanelGain1(Chan, FSlices[Idx].Volume);
+ SetRXAPanelSelect(Chan, 3);
+ SetRXAPanelRun(Chan, 1);
+ SetChannelState(Chan, 1, 0);
+ FSlices[Idx].Opened := True;
+end;
+
+procedure TWDSPEngine.CloseSliceChannel(Idx: Integer);
+begin
+ if not FSlices[Idx].Opened then Exit;
+ SetChannelState(FSlices[Idx].Chan, 0, 1);
+ destroy_anbEXT(FSlices[Idx].Chan);
+ destroy_nobEXT(FSlices[Idx].Chan);
+ CloseChannel(FSlices[Idx].Chan);
+ FSlices[Idx].Opened := False;
+end;
+
+// Fan-out: прогоняет тот же входной IQ-блок через каждый активный слайс-канал.
+// Вызывается из ProcessRXBlock (DSP-поток) после основного канала.
+procedure TWDSPEngine.ProcessSlices;
+var
+ s, i, k, Err: Integer;
+ V: Double;
+begin
+ if not Assigned(FOnSliceAudio) then Exit;
+ FSliceLock.Enter;
+ try
+ for s := 0 to MAX_SLICES - 1 do
+ begin
+ if not (FSlices[s].Active and FSlices[s].Opened) then Continue;
+ if FSlices[s].Muted then Continue;
+ // Свежая копия входного IQ — fexchange0 портит вход in-place.
+ for i := 0 to FBufSize - 1 do
+ begin
+ FSliceIn[i * 2] := FRXAccI[i];
+ FSliceIn[i * 2 + 1] := FRXAccQ[i];
+ end;
+ Err := 0;
+ fexchange0(FSlices[s].Chan, @FSliceIn[0], @FSliceOut[0], @Err);
+ // S-метр канала читаем здесь (DSP-поток) — GetRXAMeter сбрасывает аккумулятор.
+ FSlices[s].SMeter := GetRXAMeter(FSlices[s].Chan, RXA_S_AV);
+ V := FSlices[s].Volume;
+ for k := 0 to FAudioBufSize - 1 do
+ begin
+ FSliceOutL[k] := FSliceOut[k * 2] * V;
+ FSliceOutR[k] := FSliceOut[k * 2 + 1] * V;
+ end;
+ FOnSliceAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize);
+ end;
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+function TWDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi: Integer;
+ ShiftHz, Volume: Double; AGC: TWDSPAGCMode): Boolean;
+var i, slot: Integer;
+begin
+ Result := False;
+ FSliceLock.Enter;
+ try
+ if FindSliceIdx(Id) >= 0 then Exit; // уже существует
+ 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; // нет свободных слотов
+ FSlices[slot].Active := True;
+ FSlices[slot].Id := Id;
+ FSlices[slot].Chan := SLICE_CHAN_BASE + slot;
+ FSlices[slot].Mode := Mode;
+ FSlices[slot].FilterLo := FilterLo;
+ FSlices[slot].FilterHi := FilterHi;
+ FSlices[slot].ShiftHz := ShiftHz;
+ FSlices[slot].Volume := Volume;
+ FSlices[slot].AGCMode := AGC;
+ FSlices[slot].Muted := False;
+ FSlices[slot].NRMode := NR_MODE_OFF;
+ FSlices[slot].NBMode := NB_MODE_OFF;
+ FSlices[slot].SNBOn := False;
+ FSlices[slot].ANFOn := False;
+ FSlices[slot].SMeter := -130.0;
+ FSlices[slot].Opened := False;
+ if FInitialized then OpenSliceChannel(slot);
+ Result := True;
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.RemoveSlice(Id: Integer);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id);
+ if idx < 0 then Exit;
+ CloseSliceChannel(idx);
+ FSlices[idx].Active := False;
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceShift(Id: Integer; ShiftHz: Double);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].ShiftHz := ShiftHz;
+ if FSlices[idx].Opened then SetRXAShiftFreq(FSlices[idx].Chan, ShiftHz);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceMode(Id, Mode: Integer);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].Mode := Mode;
+ if FSlices[idx].Opened then SetRXAMode(FSlices[idx].Chan, ModeToWDSP(Mode));
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceFilter(Id, Low, High: Integer);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].FilterLo := Low;
+ FSlices[idx].FilterHi := High;
+ if FSlices[idx].Opened then RXASetPassband(FSlices[idx].Chan, Low, High);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceAGC(Id: Integer; Mode: TWDSPAGCMode);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].AGCMode := Mode;
+ if FSlices[idx].Opened then ApplyAGCToChan(FSlices[idx].Chan, Mode, 50.0);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceVolume(Id: Integer; Vol: Double);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].Volume := Vol;
+ if FSlices[idx].Opened then SetRXAPanelGain1(FSlices[idx].Chan, Vol);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceMute(Id: Integer; Mute: Boolean);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].Muted := Mute;
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceNRMode(Id, Mode: Integer);
+var idx: Integer;
+begin
+ if Mode < NR_MODE_OFF then Mode := NR_MODE_OFF;
+ if Mode > NR_MODE_4 then Mode := NR_MODE_4;
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].NRMode := Mode;
+ if FSlices[idx].Opened then ApplyNRToChan(FSlices[idx].Chan, Mode);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceNBMode(Id, Mode: Integer);
+var idx: Integer;
+begin
+ if Mode < NB_MODE_OFF then Mode := NB_MODE_OFF;
+ if Mode > NB_MODE_2 then Mode := NB_MODE_2;
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].NBMode := Mode;
+ if FSlices[idx].Opened then ApplyNBToChan(FSlices[idx].Chan, Mode);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceSNB(Id: Integer; On_: Boolean);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].SNBOn := On_;
+ if FSlices[idx].Opened then ApplySNBToChan(FSlices[idx].Chan, On_);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+procedure TWDSPEngine.SetSliceANF(Id: Integer; On_: Boolean);
+var idx: Integer;
+begin
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id); if idx < 0 then Exit;
+ FSlices[idx].ANFOn := On_;
+ if FSlices[idx].Opened then ApplyANFToChan(FSlices[idx].Chan, On_);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+function TWDSPEngine.GetSliceSMeter(Id: Integer): Double;
+var idx: Integer;
+begin
+ Result := -130.0;
+ FSliceLock.Enter;
+ try
+ idx := FindSliceIdx(Id);
+ if idx >= 0 then Result := FSlices[idx].SMeter;
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+function TWDSPEngine.SliceCount: Integer;
+var i: Integer;
+begin
+ Result := 0;
+ FSliceLock.Enter;
+ try
+ for i := 0 to MAX_SLICES - 1 do
+ if FSlices[i].Active then Inc(Result);
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
+function TWDSPEngine.HasSlice(Id: Integer): Boolean;
+begin
+ FSliceLock.Enter;
+ try
+ Result := FindSliceIdx(Id) >= 0;
+ finally
+ FSliceLock.Leave;
+ end;
+end;
+
// ---------------------------------------------------------------------------
// RX управление
// ---------------------------------------------------------------------------
@@ -1412,42 +1864,7 @@ begin
if not FInitialized then Exit;
// Точно по piHPSDR receiver.c: set_agc()
- SetRXAAGCMode(RXA_CHAN, Ord(Mode));
- SetRXAAGCSlope(RXA_CHAN, FAGCSlope);
- SetRXAAGCTop(RXA_CHAN, FAGCTop);
-
- case Mode of
- agcOff:
- SetRXAAGCFixed(RXA_CHAN, FixedGain);
-
- agcLong: begin
- SetRXAAGCAttack(RXA_CHAN, 2);
- SetRXAAGCHang(RXA_CHAN, 2000);
- SetRXAAGCDecay(RXA_CHAN, 2000);
- SetRXAAGCHangThreshold(RXA_CHAN, FAGCHangThreshold);
- end;
-
- agcSlow: begin
- SetRXAAGCAttack(RXA_CHAN, 2);
- SetRXAAGCHang(RXA_CHAN, 1000);
- SetRXAAGCDecay(RXA_CHAN, 500);
- SetRXAAGCHangThreshold(RXA_CHAN, FAGCHangThreshold);
- end;
-
- agcMedium: begin
- SetRXAAGCAttack(RXA_CHAN, 2);
- SetRXAAGCHang(RXA_CHAN, 0);
- SetRXAAGCDecay(RXA_CHAN, 250);
- SetRXAAGCHangThreshold(RXA_CHAN, 100);
- end;
-
- agcFast: begin
- SetRXAAGCAttack(RXA_CHAN, 2);
- SetRXAAGCHang(RXA_CHAN, 0);
- SetRXAAGCDecay(RXA_CHAN, 50);
- SetRXAAGCHangThreshold(RXA_CHAN, 100);
- end;
- end;
+ ApplyAGCToChan(RXA_CHAN, Mode, FixedGain);
// Читаем обратно hang level и thresh для линии на спектре
UpdateAGCLines(FLastSpectrumW);
@@ -1502,69 +1919,88 @@ begin
FSampleRate);
end;
+// Параметризованные по каналу версии — делятся между главным каналом и слайсами.
+procedure TWDSPEngine.ApplyNRToChan(Chan, NRMode: Integer);
+begin
+ SetRXAANRVals(Chan, 64, 16, 16e-4, 10e-7);
+ SetRXAANRPosition(Chan, FILTER_NR_AGC_POSITION);
+ SetRXAANRRun(Chan, Ord(NRMode = NR_MODE_1));
+
+ SetRXAEMNRPosition(Chan, FILTER_NR_AGC_POSITION);
+ SetRXAEMNRgainMethod(Chan, FILTER_NR2_GAIN_METHOD);
+ SetRXAEMNRnpeMethod(Chan, FILTER_NR2_NPE_METHOD);
+ SetRXAEMNRtrainZetaThresh(Chan, FILTER_NR2_TRAINED_THRESHOLD);
+ SetRXAEMNRtrainT2(Chan, FILTER_NR2_TRAINED_T2);
+ SetRXAEMNRpost2Taper(Chan, FILTER_NR2_POST_TAPER);
+ SetRXAEMNRpost2Nlevel(Chan, FILTER_NR2_POST_NLEVEL);
+ SetRXAEMNRpost2Factor(Chan, FILTER_NR2_POST_FACTOR);
+ SetRXAEMNRpost2Rate(Chan, FILTER_NR2_POST_RATE);
+ SetRXAEMNRaeRun(Chan, 1);
+ SetRXAEMNRpost2Run(Chan, FILTER_NR2_POST_RUN);
+ SetRXAEMNRRun(Chan, Ord(NRMode = NR_MODE_2));
+
+ SetRXARNNRPosition(Chan, FILTER_NR_AGC_POSITION);
+ SetRXARNNRRun(Chan, Ord(NRMode = NR_MODE_3));
+
+ SetRXASBNRreductionAmount(Chan, FILTER_NR4_REDUCTION_AMOUNT);
+ SetRXASBNRsmoothingFactor(Chan, FILTER_NR4_SMOOTHING_FACTOR);
+ SetRXASBNRwhiteningFactor(Chan, FILTER_NR4_WHITENING_FACTOR);
+ SetRXASBNRnoiseRescale(Chan, FILTER_NR4_NOISE_RESCALE);
+ SetRXASBNRpostFilterThreshold(Chan, FILTER_NR4_POST_THRESHOLD);
+ SetRXASBNRnoiseScalingType(Chan, FILTER_NR4_NOISE_SCALING_TYPE);
+ SetRXASBNRPosition(Chan, FILTER_NR_AGC_POSITION);
+ SetRXASBNRRun(Chan, Ord(NRMode = NR_MODE_4));
+end;
+
+procedure TWDSPEngine.ApplyNBToChan(Chan, NBMode: Integer);
+begin
+ SetEXTANBTau(Chan, FILTER_NB_TAU);
+ SetEXTANBHangtime(Chan, FILTER_NB_HANGTIME);
+ SetEXTANBAdvtime(Chan, FILTER_NB_ADVTIME);
+ SetEXTANBThreshold(Chan, FILTER_NB_THRESHOLD);
+ SetEXTANBRun(Chan, Ord(NBMode = NB_MODE_1));
+
+ SetEXTNOBMode(Chan, FILTER_NB2_MODE);
+ SetEXTNOBTau(Chan, FILTER_NB_TAU);
+ SetEXTNOBHangtime(Chan, FILTER_NB_HANGTIME);
+ SetEXTNOBAdvtime(Chan, FILTER_NB_ADVTIME);
+ SetEXTNOBThreshold(Chan, FILTER_NB_THRESHOLD);
+ SetEXTNOBRun(Chan, Ord(NBMode = NB_MODE_2));
+end;
+
+procedure TWDSPEngine.ApplySNBToChan(Chan: Integer; On_: Boolean);
+begin
+ SetRXASNBARun(Chan, Ord(On_));
+end;
+
+procedure TWDSPEngine.ApplyANFToChan(Chan: Integer; On_: Boolean);
+begin
+ SetRXAANFPosition(Chan, FILTER_NR_AGC_POSITION);
+ SetRXAANFRun(Chan, Ord(On_));
+end;
+
procedure TWDSPEngine.ApplyNRState;
begin
if not FInitialized then Exit;
-
- SetRXAANRVals(RXA_CHAN, 64, 16, 16e-4, 10e-7);
- SetRXAANRPosition(RXA_CHAN, FILTER_NR_AGC_POSITION);
- SetRXAANRRun(RXA_CHAN, Ord(FNRMode = NR_MODE_1));
-
- SetRXAEMNRPosition(RXA_CHAN, FILTER_NR_AGC_POSITION);
- SetRXAEMNRgainMethod(RXA_CHAN, FILTER_NR2_GAIN_METHOD);
- SetRXAEMNRnpeMethod(RXA_CHAN, FILTER_NR2_NPE_METHOD);
- SetRXAEMNRtrainZetaThresh(RXA_CHAN, FILTER_NR2_TRAINED_THRESHOLD);
- SetRXAEMNRtrainT2(RXA_CHAN, FILTER_NR2_TRAINED_T2);
- SetRXAEMNRpost2Taper(RXA_CHAN, FILTER_NR2_POST_TAPER);
- SetRXAEMNRpost2Nlevel(RXA_CHAN, FILTER_NR2_POST_NLEVEL);
- SetRXAEMNRpost2Factor(RXA_CHAN, FILTER_NR2_POST_FACTOR);
- SetRXAEMNRpost2Rate(RXA_CHAN, FILTER_NR2_POST_RATE);
- SetRXAEMNRaeRun(RXA_CHAN, 1);
- SetRXAEMNRpost2Run(RXA_CHAN, FILTER_NR2_POST_RUN);
- SetRXAEMNRRun(RXA_CHAN, Ord(FNRMode = NR_MODE_2));
-
- SetRXARNNRPosition(RXA_CHAN, FILTER_NR_AGC_POSITION);
- SetRXARNNRRun(RXA_CHAN, Ord(FNRMode = NR_MODE_3));
-
- SetRXASBNRreductionAmount(RXA_CHAN, FILTER_NR4_REDUCTION_AMOUNT);
- SetRXASBNRsmoothingFactor(RXA_CHAN, FILTER_NR4_SMOOTHING_FACTOR);
- SetRXASBNRwhiteningFactor(RXA_CHAN, FILTER_NR4_WHITENING_FACTOR);
- SetRXASBNRnoiseRescale(RXA_CHAN, FILTER_NR4_NOISE_RESCALE);
- SetRXASBNRpostFilterThreshold(RXA_CHAN, FILTER_NR4_POST_THRESHOLD);
- SetRXASBNRnoiseScalingType(RXA_CHAN, FILTER_NR4_NOISE_SCALING_TYPE);
- SetRXASBNRPosition(RXA_CHAN, FILTER_NR_AGC_POSITION);
- SetRXASBNRRun(RXA_CHAN, Ord(FNRMode = NR_MODE_4));
+ ApplyNRToChan(RXA_CHAN, FNRMode);
end;
procedure TWDSPEngine.ApplyNBState;
begin
if not FInitialized then Exit;
-
- SetEXTANBTau(RXA_CHAN, FILTER_NB_TAU);
- SetEXTANBHangtime(RXA_CHAN, FILTER_NB_HANGTIME);
- SetEXTANBAdvtime(RXA_CHAN, FILTER_NB_ADVTIME);
- SetEXTANBThreshold(RXA_CHAN, FILTER_NB_THRESHOLD);
- SetEXTANBRun(RXA_CHAN, Ord(FNBMode = NB_MODE_1));
-
- SetEXTNOBMode(RXA_CHAN, FILTER_NB2_MODE);
- SetEXTNOBTau(RXA_CHAN, FILTER_NB_TAU);
- SetEXTNOBHangtime(RXA_CHAN, FILTER_NB_HANGTIME);
- SetEXTNOBAdvtime(RXA_CHAN, FILTER_NB_ADVTIME);
- SetEXTNOBThreshold(RXA_CHAN, FILTER_NB_THRESHOLD);
- SetEXTNOBRun(RXA_CHAN, Ord(FNBMode = NB_MODE_2));
+ ApplyNBToChan(RXA_CHAN, FNBMode);
end;
procedure TWDSPEngine.ApplySNBState;
begin
if not FInitialized then Exit;
- SetRXASNBARun(RXA_CHAN, Ord(FSNBEnabled));
+ ApplySNBToChan(RXA_CHAN, FSNBEnabled);
end;
procedure TWDSPEngine.ApplyANFState;
begin
if not FInitialized then Exit;
- SetRXAANFPosition(RXA_CHAN, FILTER_NR_AGC_POSITION);
- SetRXAANFRun(RXA_CHAN, Ord(FANFEnabled));
+ ApplyANFToChan(RXA_CHAN, FANFEnabled);
end;
procedure TWDSPEngine.ApplyNoiseFilterState;
diff --git a/doc/SLICES_PLAN.md b/doc/SLICES_PLAN.md
new file mode 100644
index 0000000..f211beb
--- /dev/null
+++ b/doc/SLICES_PLAN.md
@@ -0,0 +1,181 @@
+# Мультислайсы (несколько приёмных фильтров, как во FlexRadio)
+
+Статус: **план; UI-каркас флага слайса уже сделан** (см. «Что уже готово»),
+DSP/роутинг (Этапы 1/3) — не начаты. Ничего не коммитить без явной просьбы.
+
+## Что уже готово
+
+`VfoOverlay.pas` переделан в **«флаг слайса»** в стиле SmartSDR (6 коммитов
+`4a92256..a04c592`, уже в `main`). Это ровно UI-управление одним слайсом из
+Этапа 2, только пока в единственном экземпляре:
+
+- Флаг-накладка поверх спектра с буквой слайса (`FSlice`, сейчас захардкожено `'A'`),
+ fly-out пикерами **MODE / DSP / AUD**, слайдером громкости, mute, SPLIT,
+ кликабельным **TX-бейджем** (выбор TX-слайса) и S-метром.
+- **Per-flag выбор аудио-устройства** уже есть: `SetAudioDevices` + `OnAudioDevice`
+ (`VfoOverlay.pas:31,125,136`) — прокручиваемый список в AUD-fly-out. Это готовый
+ UI под «своё звуковое устройство на слайс» из абстракции `TSlice.DeviceIndex`.
+- События наружу: `OnSelect/OnDSPChange/OnAGCChange/OnVolume/OnMute/OnAudioDevice/`
+ `OnSplit/OnTxSelect/OnInvalidate` (`VfoOverlay.pas:131-139`).
+- Автопозиционирование: флаг флипается влево, если не влезает справа (`6ad5f29`).
+
+Привязка в приложении **пока одиночная**: один `FVfoOverlay` в `MainForm`
+(`MainForm.pas:125`), созданный/подключённый на `MainForm.pas:962-972`, привязан к
+VFO A (`UpdateVfo(FController.FVfoA)`, `MainForm.pas:3265`); хендлеры
+`OnVfoOverlay*` шлют изменения в главный тракт. `SpectrumView` держит один
+`FVfoOverlay` и рисует его в `DrawOverlay` (`SpectrumView.pas:72,220,1010`).
+
+Чтобы превратить это в мультислайс — нужно из одиночного флага сделать **коллекцию
+флагов (по одному на слайс)** и развести их события на конкретный `SliceId` вместо
+глобального VFO A. Детали — в обновлённом Этапе 2.
+
+## Цель
+
+Несколько независимых приёмных «слайсов» на одном ADC/одном IQ-потоке:
+у каждого слайса своя частота, режим, полоса, AGC и **своё звуковое
+устройство** (как DAX/наушники/динамик во Flex). Панадаптер (спектр+водопад)
+остаётся один, слайсы рисуются маркерами поверх.
+
+## Как это устроено у Flex и что из этого берём
+
+Слайс во Flex — это **не «ещё один фильтр» на одном приёмнике**, а полноценный
+независимый узкополосный приёмник (свой DDC в FPGA + демодулятор + фильтр + AGC +
+аудио). Общий у слайсов — только панадаптер (широкий FFT-захват).
+
+Один WDSP-канал = один демодулятор = один фильтр = один аудиопоток. Значит для
+«разный звук/режим/полоса на каждый слайс» нужен **отдельный WDSP-канал на слайс**.
+Способ получить узкополосный IQ для канала различается по железу — см. ниже.
+
+## Ключевое различие бэкендов
+
+```
+openHPSDR (Protocol 2, V4.3, MAX_DDCS=80):
+ ADC0 ─┬─ DDC (FPGA-тюнер) → IQ-поток порт 1035+n → WDSP-канал → фильтр/AGC → AudioOut[dev]
+ ├─ DDC → ...
+ └─ DDC → ...
+ • Аппаратные DDC: слайс тюнится КУДА УГОДНО в полосе ADC (весь КВ), дёшево по CPU.
+ • На Angelia/Orion/Saturn DDC0/DDC1 заняты PureSignal → слайсы с DDC2+ (в коде DDCBase=2).
+ • RX2 = ВТОРОЙ ADC (ADC1), отдельная антенна/diversity — ОРТОГОНАЛЬНО слайсам, не нужно.
+
+Pluto (AD9363, libiio):
+ один RX-тракт: один LO, один rf_bandwidth, один IQ-поток (притворяется одним DDC,
+ пушится как TDDCIQPacket в тот же конвейер).
+ • Аппаратных DDC НЕТ. Слайсы = СОФТОВЫЙ fan-out одного IQ-блока на N WDSP-каналов,
+ каждый со своим SetRXAShiftFreq в пределах захваченной полосы вокруг LO.
+ • Для QO-100 идеально: весь транспондер (~500 кГц) в захвате, LO залочен на маяк.
+```
+
+## Единая абстракция
+
+```pascal
+TSlice = record
+ Id: Integer;
+ TargetHz: Double; // абсолютная частота слайса
+ Mode: TRxMode; // USB/LSB/CW/FM/...
+ FilterLo,
+ FilterHi: Integer;
+ AGCMode: TWDSPAGCMode;
+ Volume: Double;
+ WDSPChannel: Integer; // канал WDSP (0..N); 0=главный, 1=TX заняты
+ Source: (srcSoftShift, srcHardDDC); // как получаем узкий IQ
+ DDCIndex: Integer; // если srcHardDDC — какой аппаратный DDC
+ ShiftHz: Double; // если srcSoftShift — сдвиг = TargetHz - CapturedCenterHz
+ AudioOut: TAudioOutput; // свой инстанс со своим DeviceIndex
+ DeviceIndex: Integer; // выбранная звуковуха (или -1 = default)
+ Enabled: Boolean;
+end;
+```
+
+Правило маршрутизации по частоте (одно на оба бэкенда):
+- слайс **внутри** текущего захваченного span → `srcSoftShift` (Pluto и HPSDR);
+- слайс **вне** захваченного span → `srcHardDDC` (только HPSDR; Pluto так не умеет).
+
+## Порядок работ
+
+### Этап 1 — Софтовый fan-out в WDSPEngine (backend-agnostic)
+
+Даёт слайсы сразу на Pluto/QO-100 (единственный режим) и на HPSDR для слайсов
+внутри видимой полосы. Pluto уже пушит IQ через тот же DDC-конвейер, поэтому
+fan-out живёт в движке и не зависит от бэкенда.
+
+- `WDSPEngine.pas`: сейчас `RXA_CHAN=0` один канал, один `FRXOut`, один аудиовыход
+ (`WDSPEngine.pas:96`, alloc буферов ~`:159`, OpenChannel ~`:1081`).
+ - Добавить массив слайс-каналов: для каждого активного слайса `OpenChannel(chN, ...)`
+ с той же геометрией, что RXA_CHAN, + `SetRXAShiftRun/SetRXAShiftFreq(chN, ShiftHz)`
+ (механизм уже используется: `WDSPEngine.pas:1099-1100`), `SetRXAMode`,
+ `RXASetPassband`, AGC, `SetRXAPanelGain1`.
+ - В DSP-потоке после накопления IQ-блока (`FRXAccI/Q`, `FRXAccPos`) прогонять
+ `fexchange0` по КАЖДОМУ активному каналу из ОДНОГО и того же входного буфера
+ (входной буфер не портить in-place между каналами — держать копию, как `FSpecBuf`).
+ - Выход каждого канала → свой callback `OnSliceAudio(SliceId, L, R, Count)`.
+- `AudioOutput.pas`: уже готов к мульти-устройству — `DeviceIndex`, `EnumOutputDevices`,
+ `FindDeviceByName` (`AudioOutput.pas:137-151`). Создавать **отдельный `TAudioOutput`
+ на слайс**, каждый со своим `DeviceIndex`.
+- `RadioController.pas`: `TSlice[]` (add/remove/retune/setmode/setdevice), роутинг
+ `OnSliceAudio` → нужный `TAudioOutput`.
+
+Проверка Этапа 1: Pluto/QO-100 — 2 слайса (VFO + маяк-монитор), звук в две разные
+звуковухи, независимые режимы/полосы. Сборка: `lazbuild --ws=qt6 ewsdr.lpr`.
+
+### Этап 2 — UI слайсов на водопаде + выбор устройства
+
+Каркас флага слайса уже сделан (см. «Что уже готово»): один `TVfoOverlay` со всеми
+пикерами и per-flag выбором устройства. Остаётся размножить его на N слайсов и
+привязать к позиции по частоте. Выпадашка устройства и dropdown-паттерн **уже есть в
+самом флаге** (AUD fly-out) — отдельный `DeviceForm` не нужен.
+
+- **Коллекция флагов вместо одного.** Из `MainForm.FVfoOverlay` (одиночный,
+ `MainForm.pas:125`) сделать `array of TVfoOverlay` / список, по флагу на слайс;
+ `SpectrumView.FVfoOverlay` (`SpectrumView.pas:72`) — тоже коллекция, рисовать все в
+ `DrawOverlay` (`SpectrumView.pas:1010`). Разместить: `SetSlice(Id, Letter)` в оверлей,
+ события `OnVfoOverlay*` должны нести `SliceId` (сейчас бьют по VFO A напрямую —
+ завести маппинг оверлей→слайс, роутить в нужный `TSlice`/WDSP-канал из Этапа 1).
+- **Позиционирование по частоте.** `PositionVfoOverlay` (`MainForm.pas:518`) сейчас
+ ставит один флаг; расширить на маппинг `TargetHz → X` через `GetViewWindow`/
+ `SyncSpecViewFreq` (зум-работа). Слайс за краем видимой полосы — маркер у края
+ (флип влево/вправо уже есть, `6ad5f29`).
+- **ЛКМ по водопаду** = поставить/двинуть активный слайс; выделение активного
+ (подсветка буквы/рамки флага). Хит-тест мыши флага уже разведён
+ (`HandleMouseDown/Move/Up`, `MainForm.pas:4424`) — добавить «клик мимо флага по
+ спектру = ретюн активного слайса».
+- **TX-select** уже кликабелен (`OnTxSelect`, `08c71c4`) — при мультислайсе выбирать,
+ какой слайс идёт в TX; снимать флаг с остальных.
+- Персист: список слайсов в `Settings.pas` (per-band или глобально — решить), включая
+ `DeviceIndex`/имя устройства каждого флага.
+
+### Этап 3 — Аппаратные DDC для openHPSDR (слайс куда угодно по КВ)
+
+Апгрейд поверх общего каркаса; нужен только чтобы ставить слайс ВНЕ захваченной
+полосы. Плита ~на 70% есть:
+- `ConfigureDDCs(NumDDCs, ...)` уже включает N DDC с per-DDC конфигом/ADC-source
+ (`HPSDRNetwork.pas:220`, `DDCConfig[ddc*6+0]=ADCSource`).
+- Приём разведён по DDC: порты `1035+n` → `HandleDDCIQ(Buf,Len,DDCIdx)`
+ (`HPSDRNetwork.pas:392`).
+- High Priority уже держит частоту на КАЖДЫЙ DDC (bytes 9-12=DDC0, 13-16=DDC1, …)
+ `HPSDRProtocol.pas:271`.
+
+Чего не хватает:
+1. Per-DDC частота: сейчас `SetRunAndFreq(...; DDC0FreqHz; ...)` знает только DDC0
+ (`HPSDRNetwork.pas:224`) — писать частотное слово каждого DDC в High Priority.
+2. Роутинг `HandleDDCIQ(DDCIdx)` → соответствующий WDSP-канал слайса.
+3. Учёт DDCBase=2 (PureSignal) и Ethernet-бюджета (реально 4–8 слайсов, не 80).
+
+`feature/rx2-support` — это про ВТОРОЙ ADC, не про слайсы; но её каркас
+(доп. WDSP-канал + доп. аудиовыход + роутинг по индексу) частично переиспользуем.
+
+## Оговорки / риски
+
+- CPU софтовых слайсов ~линейно от их числа: каждый WDSP-канал жуёт полный rate и
+ децимирует сам (аппаратный DDC дешевле — децимирует железо). Для QO-100 span мал,
+ несколько слайсов — норма.
+- Не портить входной IQ-буфер между каналами (fexchange0 пишет in-place).
+- Pluto: слайсы только в пределах захваченной полосы; выход за край = ретюн LO
+ (двигает всё, при QO-100 LO залочен на маяк — не трогать).
+- Пороговая аккуратность: старт/стоп WDSP-каналов на лету (см. заметку про задержки
+ RXA стоп/старт в `WDSPEngine.pas:~230`).
+
+## Связанные заметки памяти
+
+- project_pluto_integration, project_qo100_zoom_panel, project_qo100_decoder_lock
+- project_spectrum_zoom (GetViewWindow / оверлеи / маппинг мыши)
+- project_channels_feature (dropdown/форма-редактор как паттерн UI)
diff --git a/ewsdr.lpi b/ewsdr.lpi
index 83ebba2..ac97e97 100644
--- a/ewsdr.lpi
+++ b/ewsdr.lpi
@@ -17,9 +17,9 @@
-
+
-
+
@@ -47,6 +47,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+