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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 23:46:30 +03:00
co-authored by Claude Opus 4.8
parent a04c592f1d
commit 1527b0f849
8 changed files with 1915 additions and 139 deletions
+544 -26
View File
@@ -122,7 +122,14 @@ type
// Список устройств (saved+discovered) → FController.FDeviceStore (общий с web).
FDeviceDialog: TDeviceDialog;
FPendingIP: string; // IP устройства для подключения
FVfoOverlay: TVfoOverlay; // накладка SmartSDR-стиль на спектре
FVfoOverlay: TVfoOverlay; // накладка SmartSDR-стиль на спектре (слайс A / главный)
FSliceFlags: array of TVfoOverlay; // флаги доп. слайсов (B+)
FActiveFlag: TVfoOverlay; // флаг, чьё событие сейчас обрабатывается (для роутинга по SliceId)
FActiveSliceId: Integer; // выбранный активный слайс (0 = главный)
FPendingSliceClose: Integer; // SliceId к удалению после возврата из мыши (0 = нет)
FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру
FSliceDragId: Integer; // какой слайс тащим
FLastSliceMeterMs: QWord; // троттлинг S-метра слайсов (~10 Гц)
FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра
FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра
FPanelHidden: Boolean; // True = левая панель скрыта
@@ -516,6 +523,7 @@ type
// -------------------------------------------------------------------------
procedure BtnSettingsClick(Sender: TObject);
procedure PositionVfoOverlay;
procedure UpdateVfoOverlayVisibility; // флаг A виден: панель скрыта ИЛИ есть слайсы B+
procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
procedure OnVfoOverlayAGCChange(AGCMode: Integer);
@@ -524,9 +532,26 @@ type
procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
procedure OnVfoOverlaySplit(Sender: TObject);
procedure OnVfoOverlayTxSelect(Sender: TObject);
procedure OnVfoOverlaySliceSelect(Sender: TObject); // выбрать слайс активным
procedure OnVfoOverlayClose(Sender: TObject); // × — удалить слайс
procedure ProcessPendingSliceClose; // отложенное удаление флага
procedure PushVfoOverlayExtState;
procedure PushVfoOverlayAudioDevices;
procedure OnVfoOverlayInvalidate(Sender: TObject);
// --- Мультислайсы (UI) ---
function CurSliceId: Integer; // SliceId обрабатываемого сейчас флага
procedure WireOverlayEvents(O: TVfoOverlay);
procedure CreateSliceFlag(SliceId: Integer);
procedure DestroySliceFlag(SliceId: Integer);
function FindSliceFlag(SliceId: Integer): TVfoOverlay;
procedure AddSliceAtFreq(Hz: Double);
procedure PositionSliceFlags;
procedure PushSliceFlagState(SliceId: Integer);
function DispatchFlagsMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
procedure DispatchFlagsMouseMove(X, Y: Integer);
function DispatchFlagsMouseUp(X, Y: Integer): Boolean;
procedure DispatchFlagsMouseLeave;
function SliceAtPixel(X: Integer): Integer; // SliceId, чья несущая под X (0 = нет)
procedure PbSpectrumDblClick(Sender: TObject);
procedure TrkDriveChange(Sender: TObject);
procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive
@@ -960,16 +985,14 @@ begin
FDeviceDialog.Store := FController.FDeviceStore;
FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
FVfoOverlay := TVfoOverlay.Create(Self);
FVfoOverlay.OnSelect := OnModeFilterSelect;
FVfoOverlay.OnDSPChange := OnVfoOverlayDSPChange;
FVfoOverlay.OnAGCChange := OnVfoOverlayAGCChange;
FVfoOverlay.OnVolume := OnVfoOverlayVolume;
FVfoOverlay.OnMute := OnVfoOverlayMute;
FVfoOverlay.OnAudioDevice := OnVfoOverlayAudioDevice;
FVfoOverlay.OnSplit := OnVfoOverlaySplit;
FVfoOverlay.OnTxSelect := OnVfoOverlayTxSelect;
FVfoOverlay.OnInvalidate := OnVfoOverlayInvalidate;
WireOverlayEvents(FVfoOverlay);
FVfoOverlay.SetSliceInfo(0, 'A', True); // главный приёмник = слайс A
FVfoOverlay.Visible := False;
FActiveSliceId := 0;
FActiveFlag := nil;
FPendingSliceClose := 0;
FSliceDrag := False;
FSliceDragId := 0;
FPanelHidden := False;
FController.FShowSpectrum := True;
FController.FShowWaterfall := True;
@@ -1413,6 +1436,7 @@ begin
SyncSpecViewFreq;
UpdatePanZoomBar;
PositionVfoOverlay;
PositionSliceFlags;
FSpecView.DrawSpectrum;
PbSpectrum.Invalidate;
FSpecView.DrawWaterfall;
@@ -1497,6 +1521,16 @@ begin
PushDeviceListToWeb; // web-overlay тоже получает обновлённый список
end;
end;
// Если флаг A виден при ОТКРЫТОЙ панели (есть слайсы B+), держим его в
// синхроне с DSP/режимом/полосой/AGC, пришедшими с ЛЕВОЙ панели. При скрытой
// панели флаг правит себя сам (DoHit), синк не нужен. Только дискретные поля —
// SetState/SetDSPState безусловно инвалидируют (не звать на потоковых rfVfoA).
if Assigned(FVfoOverlay) and FVfoOverlay.Visible and (not FPanelHidden) and
(Field in [rfMode, rfFilter, rfNR, rfNB, rfSNB, rfANF, rfAGCMode]) then
begin
FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode);
end;
finally
FSyncingFromController := False;
end;
@@ -3188,6 +3222,7 @@ procedure TMainForm.SpectrumTimerTick(Sender: TObject);
var
NowTick: QWord;
ElapsedTick: QWord;
I: Integer;
begin
// Синхронизируем размеры если размер панели изменился (после ресайза).
// Важно: проверяем только видимые панели — когда спектр скрыт,
@@ -3266,6 +3301,19 @@ begin
PushVfoOverlayExtState;
PositionVfoOverlay;
end;
if Length(FSliceFlags) > 0 then
begin
PositionSliceFlags; // держим слайс-флаги на частоте
// S-метр слайсов троттлим до ~10 Гц: каждое изменение метра = софтовый
// ре-рендер флага (дорого), 60 Гц там не нужно.
if GetTickCount64 - FLastSliceMeterMs >= 100 then
begin
FLastSliceMeterMs := GetTickCount64;
for I := 0 to High(FSliceFlags) do // S-метр каждого слайса из его канала
if FSliceFlags[I] <> nil then
FSliceFlags[I].UpdateSMeter(FController.SliceSMeter(FSliceFlags[I].SliceId));
end;
end;
// Панорама и водопад теперь рисуются независимо по факту новых кадров.
// Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще.
if FSpectrumDirty then
@@ -4421,11 +4469,22 @@ procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var VC, VS: Double;
begin
if Assigned(FVfoOverlay) and
FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit;
if DispatchFlagsMouseDown(Button, X, Y) then
begin
ProcessPendingSliceClose; // × мог пометить слайс к удалению
Exit;
end;
if Assigned(FSampleRateOverlay) and
FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit;
// Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
begin
FController.GetViewWindow(VC, VS);
AddSliceAtFreq(PixelToFreq(X, PbSpectrum.Width, VC, VS));
Exit;
end;
if Button = mbLeft then
begin
// Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено»,
@@ -4441,6 +4500,18 @@ begin
UpdateBeaconButton;
Exit;
end;
// ЛКМ по маркеру несущей слайса — начинаем его перетаскивание (перетюн).
if (PbSpectrum.Width > 0) then
begin
FSliceDragId := SliceAtPixel(X);
if FSliceDragId <> 0 then
begin
FSliceDrag := True;
FActiveSliceId := FSliceDragId;
PbSpectrum.Cursor := crSizeWE;
Exit;
end;
end;
// Левая кнопка: сбрасываем маркер если активен
if FSpecView.MarkerActive then
begin
@@ -4485,18 +4556,40 @@ end;
procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer);
var
VC, VS, NewHz, LoHz, HiHz: Double;
begin
// Перетаскивание маркера несущей слайса — перетюн в пределах захваченной полосы.
if FSliceDrag then
begin
if (ssLeft in Shift) and (PbSpectrum.Width > 0) then
begin
FController.GetViewWindow(VC, VS);
NewHz := PixelToFreq(X, PbSpectrum.Width, VC, VS);
FController.CapturedSpan(LoHz, HiHz);
if NewHz < LoHz then NewHz := LoHz;
if NewHz > HiHz then NewHz := HiHz;
FController.SetSliceTarget(FSliceDragId, NewHz);
PushSliceFlagState(FSliceDragId); // обновить частоту во флаге
PositionSliceFlags;
FSpectrumDirty := True;
PbSpectrum.Invalidate;
end;
Exit;
end;
if Assigned(FSampleRateOverlay) then
begin
FSampleRateOverlay.HandleMouseMove(X, Y);
end;
if Assigned(FVfoOverlay) then
FVfoOverlay.HandleMouseMove(X, Y);
DispatchFlagsMouseMove(X, Y);
if not FSpecDrag then
begin
if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or
(Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then
PbSpectrum.Cursor := crHandPoint
else if SliceAtPixel(X) <> 0 then
PbSpectrum.Cursor := crSizeWE // над маркером слайса — его можно тащить
else
PbSpectrum.Cursor := crDefault;
end;
@@ -4514,7 +4607,14 @@ end;
procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if Assigned(FVfoOverlay) and FVfoOverlay.HandleMouseUp(X, Y) then
if FSliceDrag then
begin
FSliceDrag := False;
FSliceDragId := 0;
PbSpectrum.Cursor := crDefault;
Exit;
end;
if DispatchFlagsMouseUp(X, Y) then
begin
FSpecDrag := False;
PbSpectrum.Cursor := crDefault;
@@ -4533,8 +4633,7 @@ procedure TMainForm.PbSpectrumMouseLeave(Sender: TObject);
begin
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.HandleMouseLeave;
if Assigned(FVfoOverlay) then
FVfoOverlay.HandleMouseLeave;
DispatchFlagsMouseLeave;
if not FSpecDrag then PbSpectrum.Cursor := crDefault;
end;
@@ -5008,6 +5107,28 @@ begin
begin
FLeftPanelW := PanelLeft.Width; // сохраняем реальную ширину (учитывает DPI на Win32)
PanelLeft.Width := 0;
end
else
PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину
UpdateVfoOverlayVisibility; // видимость флага A решает общий инвариант
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.SetPanelHidden(FPanelHidden);
ResizeSpectrumPanels;
ResizeSMeter;
end;
// Флаг главного приёмника (слайс A) виден, когда СКРЫТА левая панель ЛИБО есть
// доп. слайсы (B+): тогда A всегда представлен флагом на спектре рядом с B/C…,
// а сворачивание/раскрытие панели при живых слайсах не дёргает его появление.
procedure TMainForm.UpdateVfoOverlayVisibility;
var
ShouldShow: Boolean;
begin
if not Assigned(FVfoOverlay) then Exit;
ShouldShow := FPanelHidden or (Length(FSliceFlags) > 0);
if ShouldShow = FVfoOverlay.Visible then Exit;
if ShouldShow then
begin
FVfoOverlay.Width := OVL_W;
FVfoOverlay.Height := FVfoOverlay.BaseHeight; // растёт сам при открытии fly-out
FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
@@ -5019,14 +5140,9 @@ begin
end
else
begin
PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину
FVfoOverlay.HandleMouseLeave;
FVfoOverlay.Visible := False;
end;
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.SetPanelHidden(FPanelHidden);
ResizeSpectrumPanels;
ResizeSMeter;
end;
procedure TMainForm.PositionVfoOverlay;
@@ -5089,6 +5205,14 @@ procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
var
Idx: Integer;
begin
// Событие пришло от флага слайса (B+) — маршрутизируем в slice-API, главный не трогаем.
if CurSliceId <> 0 then
begin
FController.SetSliceModeBW(CurSliceId, Mode, FilterBW);
PushSliceFlagState(CurSliceId);
PositionSliceFlags;
Exit;
end;
FController.FMode := Mode;
FController.FFilterBW := FilterBW;
if Mode = MODE_FM then
@@ -5118,6 +5242,11 @@ end;
procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceDSP(CurSliceId, NRMode, NBMode, SNBOn, ANFOn);
Exit;
end;
// Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки.
FController.SetNR(NRMode);
FController.SetNB(NBMode);
@@ -5127,34 +5256,59 @@ end;
procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceAGCMode(CurSliceId, TRadioController.AGCModeFromUI(AGCMode));
Exit;
end;
FController.SetAGCMode(AGCMode);
end;
procedure TMainForm.OnVfoOverlayVolume(Vol: Integer);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceVolume(CurSliceId, Vol / 100.0);
Exit;
end;
FController.SetVolume(Vol);
end;
procedure TMainForm.OnVfoOverlayMute(Sender: TObject);
var S: TCtrlSlice;
begin
if CurSliceId <> 0 then
begin
if FController.GetSlice(CurSliceId, S) then
FController.SetSliceMute(CurSliceId, not S.Muted);
Exit;
end;
FController.ToggleMute;
end;
procedure TMainForm.OnVfoOverlaySplit(Sender: TObject);
begin
if CurSliceId <> 0 then Exit; // SPLIT — только главный (слайсы не передают)
FController.SetSplit(not FController.FSplitTxB);
PushVfoOverlayExtState;
end;
procedure TMainForm.OnVfoOverlayTxSelect(Sender: TObject);
begin
// TODO(слайсы): выбрать этот слайс как TX-слайс. Пока один слайс —
// он всегда TX-слайс, поэтому клик ничего не меняет (хук под мультислайсы).
// TODO(слайсы): выбор TX-слайса при полноценной мультислайс-передаче.
// Пока передаёт только главный канал — клик по TX неглавного игнорируем.
if CurSliceId <> 0 then Exit;
PushVfoOverlayExtState;
end;
procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
begin
if CurSliceId <> 0 then
begin
FController.SetSliceDevice(CurSliceId, DevIndex, DevName);
PushSliceFlagState(CurSliceId); // обновить подсветку текущего устройства слайса
Exit;
end;
ApplyAudioDevice(DevIndex, DevName);
PushVfoOverlayAudioDevices; // обновить подсветку текущего устройства
end;
@@ -5193,9 +5347,326 @@ begin
if Assigned(FSpecView) then
begin
FSpecView.SpectrumDirty := True;
FSpecView.InvalidateOverlayCache;
// Полная инвалидация кэша текстур оверлеев (band/samplerate/главный VFO) нужна
// ТОЛЬКО когда у главного оверлея изменился хром (CacheDirty). Если сдвинулся
// лишь S-метр (MeterDirty) — перезаливаем только текстуру главного флага, не
// дёргая band/samplerate/слайсы. Слайсы перезаливают свою текстуру сами.
if Sender = TObject(FVfoOverlay) then
begin
if FVfoOverlay.CacheDirty then
FSpecView.InvalidateOverlayCache
else
FSpecView.InvalidateVfoOverlay;
end;
end;
if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
if Assigned(PbSpectrum) then
begin
PbSpectrum.Invalidate;
end;
end;
// ===========================================================================
// Мультислайсы (UI): коллекция флагов, роутинг событий по SliceId
// ===========================================================================
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A).
function TMainForm.CurSliceId: Integer;
begin
if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0;
end;
// Единая проводка событий флага (главного и слайсов) на общие обработчики.
procedure TMainForm.WireOverlayEvents(O: TVfoOverlay);
begin
O.OnSelect := OnModeFilterSelect;
O.OnDSPChange := OnVfoOverlayDSPChange;
O.OnAGCChange := OnVfoOverlayAGCChange;
O.OnVolume := OnVfoOverlayVolume;
O.OnMute := OnVfoOverlayMute;
O.OnAudioDevice := OnVfoOverlayAudioDevice;
O.OnSplit := OnVfoOverlaySplit;
O.OnTxSelect := OnVfoOverlayTxSelect;
O.OnSliceSelect := OnVfoOverlaySliceSelect;
O.OnClose := OnVfoOverlayClose;
O.OnInvalidate := OnVfoOverlayInvalidate;
end;
function TMainForm.FindSliceFlag(SliceId: Integer): TVfoOverlay;
var i: Integer;
begin
Result := nil;
for i := 0 to High(FSliceFlags) do
if (FSliceFlags[i] <> nil) and (FSliceFlags[i].SliceId = SliceId) then
Exit(FSliceFlags[i]);
end;
procedure TMainForm.CreateSliceFlag(SliceId: Integer);
var
O: TVfoOverlay;
S: TCtrlSlice;
begin
if FindSliceFlag(SliceId) <> nil then Exit;
if not FController.GetSlice(SliceId, S) then Exit;
O := TVfoOverlay.Create(Self);
WireOverlayEvents(O);
O.SetSliceInfo(SliceId, S.Letter, False);
O.SMeterThreshold := 1.5; // слайсы: грубее метр → меньше дорогих ре-рендеров флага
O.Width := OVL_W;
O.Height := O.BaseHeight;
O.Visible := True; // флаги слайсов видны всегда (не зависят от скрытия панели)
SetLength(FSliceFlags, Length(FSliceFlags) + 1);
FSliceFlags[High(FSliceFlags)] := O;
FSpecView.AddSliceOverlay(O);
PushSliceFlagState(SliceId);
UpdateVfoOverlayVisibility; // появился слайс B+ → показать и флаг A (если панель открыта)
PositionSliceFlags;
end;
procedure TMainForm.DestroySliceFlag(SliceId: Integer);
var
i, j: Integer;
O: TVfoOverlay;
begin
O := FindSliceFlag(SliceId);
if O = nil then Exit;
FController.RemoveSlice(SliceId);
FSpecView.RemoveSliceOverlay(O);
// Убираем из массива со сжатием.
j := 0;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> O then
begin
FSliceFlags[j] := FSliceFlags[i];
Inc(j);
end;
SetLength(FSliceFlags, j);
O.Free;
if FActiveSliceId = SliceId then FActiveSliceId := 0;
UpdateVfoOverlayVisibility; // закрыт последний слайс при открытой панели → скрыть флаг A
OnVfoOverlayInvalidate(nil);
end;
// Добавить софт-слайс на частоте Hz (режим/полоса копируются с главного).
procedure TMainForm.AddSliceAtFreq(Hz: Double);
var
Lo, Hi, Id: Integer;
begin
if not FController.SliceFitsCapture(Hz) then Exit; // вне захваченной полосы (см. Этап 3)
TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '');
if Id < 0 then Exit;
CreateSliceFlag(Id);
FActiveSliceId := Id;
end;
// SliceId, чья несущая под пикселем X (в пределах допуска), 0 если нет.
function TMainForm.SliceAtPixel(X: Integer): Integer;
const TOL = 6;
var
i, CarrierX: Integer;
VC, VS: Double;
S: TCtrlSlice;
O: TVfoOverlay;
begin
Result := 0;
if (PbSpectrum = nil) or (PbSpectrum.Width <= 0) then Exit;
FController.GetViewWindow(VC, VS);
if VS <= 0 then Exit;
for i := 0 to High(FSliceFlags) do
begin
O := FSliceFlags[i];
if (O = nil) or (not O.Visible) then Continue;
if not FController.GetSlice(O.SliceId, S) then Continue;
CarrierX := Round((S.TargetHz - VC + VS / 2) / VS * PbSpectrum.Width);
if Abs(X - CarrierX) <= TOL then Exit(O.SliceId);
end;
end;
procedure TMainForm.PositionSliceFlags;
var
i, VfoX, OW, NewLeft, NewTop, FilterStartX, FilterEndX, BWs: Integer;
VC, VS, LoHz, HiHz: Double;
S: TCtrlSlice;
O: TVfoOverlay;
Moved: Boolean;
begin
if (Length(FSliceFlags) = 0) or (PbSpectrum = nil) then Exit;
Moved := False;
FController.GetViewWindow(VC, VS);
for i := 0 to High(FSliceFlags) do
begin
O := FSliceFlags[i];
if (O = nil) or (not O.Visible) then Continue;
if not FController.GetSlice(O.SliceId, S) then Continue;
OW := O.Width;
if (PbSpectrum.Width > 0) and (VS > 0) then
VfoX := Round((S.TargetHz - VC + VS / 2) / VS * PbSpectrum.Width)
else
VfoX := PbSpectrum.Width div 2;
// Кромки полосы фильтра слайса относительно несущей — как у главного флага
// (PositionVfoOverlay): флаг ставится СБОКУ от полосы, а не от несущей.
case S.Mode of
MODE_LSB: BWs := -S.FilterLo;
MODE_USB: BWs := S.FilterHi;
else BWs := S.FilterHi - S.FilterLo;
end;
case S.Mode of
MODE_LSB: begin LoHz := -BWs; HiHz := -100; end;
MODE_USB: begin LoHz := 100; HiHz := BWs; end;
else begin LoHz := -BWs / 2; HiHz := BWs / 2; end;
end;
if (PbSpectrum.Width > 0) and (VS > 0) then
begin
FilterStartX := VfoX + Round(LoHz / VS * PbSpectrum.Width);
FilterEndX := VfoX + Round(HiHz / VS * PbSpectrum.Width);
end
else
begin
FilterStartX := VfoX;
FilterEndX := VfoX;
end;
FilterStartX := Max(0, Min(PbSpectrum.Width - 1, FilterStartX));
FilterEndX := Max(0, Min(PbSpectrum.Width - 1, FilterEndX));
// По умолчанию справа от полосы; если не влезает — слева от неё.
NewLeft := FilterEndX + 6;
if NewLeft + OW > PbSpectrum.Width - 4 then NewLeft := FilterStartX - 6 - OW;
NewLeft := Max(4, Min(PbSpectrum.Width - OW - 4, NewLeft));
NewTop := 16 + i * 12; // лёгкий вертикальный разнос, чтобы флаги не слипались
if (O.Left <> NewLeft) or (O.Top <> NewTop) then
begin
O.Left := NewLeft;
O.Top := NewTop;
Moved := True;
end;
end;
// Инвалидируем спектр только если флаг реально сдвинулся (ретюн/зум/пан).
// Раньше звали безусловно каждый тик → форс полной перерисовки на пустом месте.
if Moved then
begin
OnVfoOverlayInvalidate(nil);
end;
end;
// Заливает во флаг слайса его текущее состояние из контроллера.
procedure TMainForm.PushSliceFlagState(SliceId: Integer);
var
O: TVfoOverlay;
S: TCtrlSlice;
BW, Cnt, I: Integer;
Names: TStringList;
Indices: array[0..63] of Integer;
Idx: array of Integer;
begin
O := FindSliceFlag(SliceId);
if (O = nil) or not FController.GetSlice(SliceId, S) then Exit;
case S.Mode of
MODE_LSB: BW := -S.FilterLo;
MODE_USB: BW := S.FilterHi;
else BW := S.FilterHi - S.FilterLo;
end;
O.SetState(S.Mode, BW, S.TargetHz, FController.SliceSMeter(SliceId));
O.SetDSPState(0, 0, False, False, FController.FAGCMode);
O.SetExtState(Round(S.Volume * 100), S.Muted, False, False, False);
// Список аудио-устройств (общий PA-список) + текущее устройство слайса.
if Assigned(FController.FAudioOut) then
begin
Names := TStringList.Create;
try
Cnt := 0;
FController.FAudioOut.EnumOutputDevices(Names, Indices, Cnt);
SetLength(Idx, Cnt);
for I := 0 to Cnt - 1 do Idx[I] := Indices[I];
O.SetAudioDevices(Names, Idx, S.DevName);
finally
Names.Free;
end;
end;
end;
procedure TMainForm.OnVfoOverlaySliceSelect(Sender: TObject);
begin
if Sender is TVfoOverlay then
FActiveSliceId := TVfoOverlay(Sender).SliceId;
end;
procedure TMainForm.OnVfoOverlayClose(Sender: TObject);
begin
if not (Sender is TVfoOverlay) then Exit;
if TVfoOverlay(Sender).IsMain then Exit;
// Нельзя освобождать флаг внутри его же обработчика мыши — помечаем к удалению,
// ProcessPendingSliceClose снимет его после возврата из диспетчера.
FPendingSliceClose := TVfoOverlay(Sender).SliceId;
end;
procedure TMainForm.ProcessPendingSliceClose;
var Id: Integer;
begin
if FPendingSliceClose = 0 then Exit;
Id := FPendingSliceClose;
FPendingSliceClose := 0;
DestroySliceFlag(Id);
end;
function TMainForm.DispatchFlagsMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
var i: Integer;
begin
Result := False;
if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
begin
FActiveFlag := FVfoOverlay;
if FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit(True);
end;
for i := 0 to High(FSliceFlags) do
if (FSliceFlags[i] <> nil) and FSliceFlags[i].Visible then
begin
FActiveFlag := FSliceFlags[i];
if FSliceFlags[i].HandleMouseDown(Button, X, Y) then Exit(True);
end;
FActiveFlag := nil;
end;
procedure TMainForm.DispatchFlagsMouseMove(X, Y: Integer);
var i: Integer;
begin
if Assigned(FVfoOverlay) then
begin
FActiveFlag := FVfoOverlay;
FVfoOverlay.HandleMouseMove(X, Y);
end;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then
begin
FActiveFlag := FSliceFlags[i];
FSliceFlags[i].HandleMouseMove(X, Y);
end;
FActiveFlag := nil;
end;
function TMainForm.DispatchFlagsMouseUp(X, Y: Integer): Boolean;
var i: Integer;
begin
Result := False;
if Assigned(FVfoOverlay) then
begin
FActiveFlag := FVfoOverlay;
if FVfoOverlay.HandleMouseUp(X, Y) then Result := True;
end;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then
begin
FActiveFlag := FSliceFlags[i];
if FSliceFlags[i].HandleMouseUp(X, Y) then Result := True;
end;
FActiveFlag := nil;
end;
procedure TMainForm.DispatchFlagsMouseLeave;
var i: Integer;
begin
if Assigned(FVfoOverlay) then FVfoOverlay.HandleMouseLeave;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then FSliceFlags[i].HandleMouseLeave;
end;
procedure TMainForm.OnSampleRateSelect(SampleRate: Integer);
@@ -5752,16 +6223,63 @@ procedure TMainForm.FormMouseWheel(Sender: TObject; Shift: TShiftState;
P := C.ScreenToClient(Mouse.CursorPos);
Result := (P.X >= 0) and (P.Y >= 0) and (P.X < C.Width) and (P.Y < C.Height);
end;
// Курсор над флагом-оверлеем слайса (в клиентских координатах спектра)?
function SliceUnderCursor: Integer;
var P: TPoint; i: Integer; O: TVfoOverlay;
begin
Result := 0;
if (PbSpectrum = nil) or (not PbSpectrum.Visible) then Exit;
P := PbSpectrum.ScreenToClient(Mouse.CursorPos);
for i := 0 to High(FSliceFlags) do
begin
O := FSliceFlags[i];
if (O = nil) or (not O.Visible) then Continue;
if (P.X >= O.Left) and (P.X < O.Left + O.Width) and
(P.Y >= O.Top) and (P.Y < O.Top + O.Height) then
Exit(O.SliceId);
end;
end;
var
Step: Int64;
Delta: Int64;
Base: Int64;
NewFreq: Int64;
SliceId: Integer;
SLo, SHi, SNew: Double;
Sl: TCtrlSlice;
begin
// Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно
// (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты:
// виджет крутил бы B, а этот хендлер — активный A.
if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit;
// Колесо над маркером слайса — двигаем частоту ЭТОГО слайса, а не главного VFO.
SliceId := SliceUnderCursor;
if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then
begin
if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
else if ssCtrl in Shift then Step := 1000
else if ssShift in Shift then Step := 10
else Step := 100;
Delta := 1;
if WheelDelta < 0 then Delta := -1;
// Квантование к сетке шага + шаг в сторону колеса.
Base := (Round(Sl.TargetHz) div Step) * Step;
if Delta > 0 then SNew := Base + Step
else if Round(Sl.TargetHz) = Base then SNew := Base - Step
else SNew := Base;
FController.CapturedSpan(SLo, SHi);
if SNew < SLo then SNew := SLo;
if SNew > SHi then SNew := SHi;
FController.SetSliceTarget(SliceId, SNew);
PushSliceFlagState(SliceId);
PositionSliceFlags;
FSpectrumDirty := True;
PbSpectrum.Invalidate;
FActiveSliceId := SliceId;
Handled := True;
Exit;
end;
if (FController.FMode = MODE_FM) and FController.FFMStepOn and
not ((ssCtrl in Shift) or (ssShift in Shift)) then
Step := FM_STEP_HZ[FController.FFMStepIdx]
+325 -11
View File
@@ -122,6 +122,24 @@ type
PLLLock: Boolean;
end;
// Слайс уровня контроллера: логическая обёртка над WDSP-слайс-каналом
// (в TWDSPEngine) + собственный аудио-выход со своей звуковухой.
TCtrlSlice = record
Active: Boolean;
Id: Integer; // = id слайса в TWDSPEngine
Letter: Char; // буква флага ('A'..'F'), пока для UI
TargetHz: Double; // абсолютная частота слайса
Mode: Integer;
FilterLo: Integer;
FilterHi: Integer;
AGC: TWDSPAGCMode;
Volume: Double; // 0..1
Muted: Boolean;
DeviceIndex: Integer; // PortAudio-индекс (-1 = default)
DevName: string;
Audio: TAudioOutput; // владеет; свой поток вывода
end;
{ TRadioController }
TRadioController = class
private
@@ -134,6 +152,11 @@ type
FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView)
FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер)
procedure Changed(Field: TRadioField);
// --- Мультислайсы ---
function FindSliceIndex(Id: Integer): Integer; // -1 если нет
procedure OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
procedure RefreshSliceShifts; // пересчёт ShiftHz всех слайсов при смене центра
procedure ApplyAGCToEngine; // применяет FAGCMode к WDSP (единый маппинг)
procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode
procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state)
@@ -163,6 +186,12 @@ type
FDSPEngine: TWDSPEngine;
FAudioOut: TAudioOutput;
FAudioIn: TAudioInput;
// ---- Мультислайсы (софт-fan-out) ----
// Слайс = доп. приёмник (свой WDSP-канал в движке) + своя звуковуха.
// TargetHz абсолютна; ShiftHz в движок = TargetHz - FCenterFreq.
FSlices: array[0..MAX_SLICES-1] of TCtrlSlice;
FNextSliceId: Integer; // монотонный генератор id слайсов
FSettings: TSettingsManager;
FChannelStore: TChannelStore;
FDeviceStore: TDeviceStore; // общий список устройств (saved+discovered)
@@ -467,6 +496,32 @@ type
procedure DriveBy(Delta: Integer);
procedure SetAtten(Idx: Integer);
// ---- Мультислайсы (софт-fan-out) ----
// Создаёт слайс на TargetHz: заводит WDSP-канал в движке + свой аудио-выход
// на устройстве DevIndex (-1=default). Возвращает Id слайса или -1 при отказе
// (нет слотов / TargetHz вне захваченной полосы). DevName для персиста/UI.
function AddSlice(TargetHz: Double; Mode, FilterLo, FilterHi: Integer;
AGC: TWDSPAGCMode; Vol: Double;
DevIndex: Integer; const DevName: string): Integer;
procedure RemoveSlice(Id: Integer);
procedure SetSliceTarget(Id: Integer; TargetHz: Double);
procedure SetSliceMode(Id, Mode: Integer);
procedure SetSliceFilter(Id, Low, High: Integer);
procedure SetSliceModeBW(Id, Mode, BW: Integer); // режим+полоса → кромки фильтра
class procedure FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
class function AGCModeFromUI(M: Integer): TWDSPAGCMode; // UI-индекс 0..4 → WDSP enum
procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
procedure SetSliceVolume(Id: Integer; Vol: Double); // 0..1
procedure SetSliceMute(Id: Integer; Mute: Boolean);
procedure SetSliceDSP(Id, NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
function SliceSMeter(Id: Integer): Double; // dBm, из DSP-потока
procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string);
function SliceCount: Integer;
function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
// Диапазон захваченной полосы вокруг центра, куда можно ставить софт-слайс.
procedure CapturedSpan(out LoHz, HiHz: Double);
function SliceFitsCapture(TargetHz: Double): Boolean;
// Pluto TX-мощность через аттенюацию: drive% → дБ (потолок FPlutoTxMaxAttDb на
// 100%, выкл на приёме). Логика/маппинг здесь; бэкенд лишь пишет hardwaregain.
function PlutoTxAttForDrive: Double; // дБ<0 под текущий FDrivePercent
@@ -625,6 +680,8 @@ begin
FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX)
FVolume := 70; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0;
FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000;
FNextSliceId := 1; // мультислайсы: генератор id
FillChar(FSlices, SizeOf(FSlices), 0);
FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума)
FCurrentBand := 5; FCurrentXvtr := -1;
FSpectrumBufCount := 1024; FWaterfallBufCount := 1024;
@@ -649,6 +706,9 @@ begin
// BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший
// буфер вдвое сокращает построение downsampler в OpenChannel RXA.
FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
// Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не
// зависит от фронтенда, в отличие от OnAudio/OnSpectrum).
FDSPEngine.OnSliceAudio := OnSliceAudioReady;
// QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock).
FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink)
@@ -672,12 +732,21 @@ begin
end;
procedure TRadioController.FreeEngines;
var i: Integer;
begin
if Assigned(FNetwork) then
begin
if FNetwork.Connected then FNetwork.Disconnect;
FreeAndNil(FNetwork);
end;
// Слайсы: закрываем их аудио-выходы (WDSP-каналы закроет FDSPEngine.Close ниже).
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].Audio <> nil) then
begin
FSlices[i].Audio.Close;
FreeAndNil(FSlices[i].Audio);
FSlices[i].Active := False;
end;
if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end;
if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end;
if Assigned(FDSPEngine) then
@@ -966,6 +1035,227 @@ begin
FAudioOut.Write(Left, Right, Count);
end;
// ---------------------------------------------------------------------------
// Мультислайсы (софт-fan-out)
// ---------------------------------------------------------------------------
function TRadioController.FindSliceIndex(Id: Integer): Integer;
var i: Integer;
begin
Result := -1;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active and (FSlices[i].Id = Id) then Exit(i);
end;
// DSP-поток: аудио готового слайса → его собственный выход. Слайс — локальный
// монитор (в web/на радио не уходит), поэтому только на свою звуковуху.
procedure TRadioController.OnSliceAudioReady(SliceId: Integer;
const Left, Right: array of Single; Count: Integer);
var idx: Integer;
begin
if not FLocalAudio then Exit;
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
if FSlices[idx].Audio <> nil then
FSlices[idx].Audio.Write(Left, Right, Count);
end;
procedure TRadioController.RefreshSliceShifts;
var i: Integer;
begin
if not Assigned(FDSPEngine) then Exit;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then
FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FCenterFreq);
end;
procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double);
// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга).
var Half: Double;
begin
Half := FSampleRate * 0.475;
LoHz := FCenterFreq - Half;
HiHz := FCenterFreq + Half;
end;
function TRadioController.SliceFitsCapture(TargetHz: Double): Boolean;
var Lo, Hi: Double;
begin
CapturedSpan(Lo, Hi);
Result := (TargetHz >= Lo) and (TargetHz <= Hi);
end;
function TRadioController.AddSlice(TargetHz: Double;
Mode, FilterLo, FilterHi: Integer; AGC: TWDSPAGCMode; Vol: Double;
DevIndex: Integer; const DevName: string): Integer;
var
i, slot, Id: Integer;
AO: TAudioOutput;
begin
Result := -1;
if not Assigned(FDSPEngine) then Exit;
if not SliceFitsCapture(TargetHz) then Exit; // вне захваченной полосы (Этап 3=HW DDC)
slot := -1;
for i := 0 to MAX_SLICES - 1 do
if not FSlices[i].Active then begin slot := i; Break; end;
if slot < 0 then Exit; // нет свободных слотов
Id := FNextSliceId;
if not FDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi,
TargetHz - FCenterFreq, Vol, AGC) then Exit;
Inc(FNextSliceId);
// Свой аудио-выход слайса на выбранной звуковухе.
AO := TAudioOutput.Create(48000);
AO.OutputBufferSize := FSettings.LoadAudioBufferSize;
AO.DeviceIndex := DevIndex;
if FLocalAudio then AO.Open;
FSlices[slot].Active := True;
FSlices[slot].Id := Id;
// Буква флага. Слайс A — это главный приёмник (не в FSlices[]), поэтому доп.
// слайсы нумеруются с 'B' (slot 0 → B, 1 → C, …), иначе получалось два «A».
FSlices[slot].Letter := Chr(Ord('B') + slot);
FSlices[slot].TargetHz := TargetHz;
FSlices[slot].Mode := Mode;
FSlices[slot].FilterLo := FilterLo;
FSlices[slot].FilterHi := FilterHi;
FSlices[slot].AGC := AGC;
FSlices[slot].Volume := Vol;
FSlices[slot].Muted := False;
FSlices[slot].DeviceIndex := DevIndex;
FSlices[slot].DevName := DevName;
FSlices[slot].Audio := AO;
Result := Id;
Changed(rfDevice);
end;
procedure TRadioController.RemoveSlice(Id: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
if Assigned(FDSPEngine) then FDSPEngine.RemoveSlice(Id);
if FSlices[idx].Audio <> nil then
begin
FSlices[idx].Audio.Close;
FreeAndNil(FSlices[idx].Audio);
end;
FSlices[idx].Active := False;
Changed(rfDevice);
end;
procedure TRadioController.SetSliceTarget(Id: Integer; TargetHz: Double);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].TargetHz := TargetHz;
if Assigned(FDSPEngine) then
FDSPEngine.SetSliceShift(Id, TargetHz - FCenterFreq);
end;
procedure TRadioController.SetSliceMode(Id, Mode: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].Mode := Mode;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceMode(Id, Mode);
end;
procedure TRadioController.SetSliceFilter(Id, Low, High: Integer);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].FilterLo := Low;
FSlices[idx].FilterHi := High;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceFilter(Id, Low, High);
end;
procedure TRadioController.SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].AGC := AGC;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceAGC(Id, AGC);
end;
procedure TRadioController.SetSliceVolume(Id: Integer; Vol: Double);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].Volume := Vol;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceVolume(Id, Vol);
end;
procedure TRadioController.SetSliceMute(Id: Integer; Mute: Boolean);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].Muted := Mute;
if Assigned(FDSPEngine) then FDSPEngine.SetSliceMute(Id, Mute);
end;
procedure TRadioController.SetSliceDSP(Id, NRMode, NBMode: Integer;
SNBOn, ANFOn: Boolean);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
if Assigned(FDSPEngine) then
begin
FDSPEngine.SetSliceNRMode(Id, NRMode);
FDSPEngine.SetSliceNBMode(Id, NBMode);
FDSPEngine.SetSliceSNB(Id, SNBOn);
FDSPEngine.SetSliceANF(Id, ANFOn);
end;
end;
function TRadioController.SliceSMeter(Id: Integer): Double;
begin
if Assigned(FDSPEngine) then Result := FDSPEngine.GetSliceSMeter(Id)
else Result := -130.0;
end;
procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer;
const DevName: string);
var idx: Integer;
begin
idx := FindSliceIndex(Id);
if idx < 0 then Exit;
FSlices[idx].DeviceIndex := DevIndex;
FSlices[idx].DevName := DevName;
if FSlices[idx].Audio <> nil then
begin
FSlices[idx].Audio.Close;
FSlices[idx].Audio.DeviceIndex := DevIndex;
if FLocalAudio then FSlices[idx].Audio.Open;
end;
end;
function TRadioController.SliceCount: Integer;
var i: Integer;
begin
Result := 0;
for i := 0 to MAX_SLICES - 1 do
if FSlices[i].Active then Inc(Result);
end;
function TRadioController.GetSlice(Id: Integer; out S: TCtrlSlice): Boolean;
var idx: Integer;
begin
idx := FindSliceIndex(Id);
Result := idx >= 0;
if Result then S := FSlices[idx];
end;
procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
Count: Integer);
// DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу
@@ -1335,16 +1625,31 @@ begin
Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors);
end;
// Кромки полосы под режим/полосу (единый расчёт для главного канала и слайсов).
class procedure TRadioController.FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
var Half: Integer;
begin
Half := BW div 2;
case Mode of
0: begin Lo := -BW; Hi := -100; end; // LSB
1: begin Lo := 100; Hi := BW; end; // USB
else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM
end;
end;
procedure TRadioController.SetSliceModeBW(Id, Mode, BW: Integer);
var Lo, Hi: Integer;
begin
FilterEdgesFor(Mode, BW, Lo, Hi);
SetSliceMode(Id, Mode);
SetSliceFilter(Id, Lo, Hi);
end;
procedure TRadioController.ApplyModeFilter;
var
Lo, Hi, Half: Integer;
Lo, Hi: Integer;
begin
Half := FFilterBW div 2;
case FMode of
0: begin Lo := -FFilterBW; Hi := -100; end; // LSB
1: begin Lo := 100; Hi := FFilterBW; end; // USB
else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM
end;
FilterEdgesFor(FMode, FFilterBW, Lo, Hi);
if FWDSPReady and Assigned(FDSPEngine) then
begin
FDSPEngine.SetFilter(Lo, Hi);
@@ -1921,6 +2226,7 @@ begin
NeedPush := True;
end;
RefreshSliceShifts; // центр мог сместиться — держим слайсы на их TargetHz
if NeedPush then PushNetworkState;
Changed(rfActiveVfo);
end;
@@ -1941,6 +2247,7 @@ begin
FCenterFreq := Hz;
if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetShift(ActiveVfoHz - FCenterFreq);
RefreshSliceShifts; // слайсы привязаны к абсолютной частоте — сдвинуть NCO
PushNetworkState;
Changed(rfCenterFreq);
end;
@@ -1972,6 +2279,7 @@ begin
end;
FCenterFreq := ActiveFreq;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
RefreshSliceShifts;
PushNetworkState;
Changed(rfVfoA);
Changed(rfVfoB);
@@ -2057,14 +2365,20 @@ begin
Changed(rfFilter);
end;
procedure TRadioController.ApplyAGCToEngine;
// UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для всех
// фронтендов и слайсов (раньше web/слайдер ошибочно кастовали индекс прямо в enum).
class function TRadioController.AGCModeFromUI(M: Integer): TWDSPAGCMode;
const
// UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для
// всех фронтендов (раньше web/слайдер ошибочно кастовали индекс прямо в enum).
AGCModes: array[0..4] of TWDSPAGCMode = (agcFast, agcMedium, agcSlow, agcLong, agcOff);
begin
if (M < 0) or (M > 4) then M := 1;
Result := AGCModes[M];
end;
procedure TRadioController.ApplyAGCToEngine;
begin
if FWDSPReady and Assigned(FDSPEngine) and (FAGCMode >= 0) and (FAGCMode <= 4) then
FDSPEngine.SetAGC(AGCModes[FAGCMode], 50.0);
FDSPEngine.SetAGC(AGCModeFromUI(FAGCMode), 50.0);
end;
procedure TRadioController.SetAGCMode(M: Integer);
+85 -5
View File
@@ -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), поэтому там его не делаем.
+112 -7
View File
@@ -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;
+126 -7
View File
@@ -52,6 +52,7 @@ type
FTx: Boolean; // передача идёт (красный)
FTxSel: Boolean; // этот слайс выбран для передачи (янтарный)
FSlice: Char; // буква слайса ('A')
FSMeterThreshold: Double; // мин. изменение S-метра (дБ) для перерисовки
FDevNames: TStringList; // имена устройств вывода
FDevIdx: array of Integer; // соответствующие PortAudio-индексы (-1=default)
@@ -72,12 +73,21 @@ type
FOnTxSelect: TNotifyEvent;
FOnInvalidate: TNotifyEvent;
FSliceId: Integer; // логический id слайса (владелец — MainForm/контроллер)
FIsMain: Boolean; // главный приёмник (слайс A) — без кнопки закрытия
FOnSliceSelect: TNotifyEvent; // клик по бейджу буквы — сделать слайс активным
FOnClose: TNotifyEvent; // клик по × (только неглавные) — удалить слайс
FHitRects: array[0..63] of TRect;
FHitActions: array[0..63] of Integer;
FHitCount: Integer;
FHotIdx: Integer;
FCacheBitmap: TBitmap;
FCacheDirty: Boolean;
FMeterDirty: Boolean; // изменился только S-метр — перерисовать полоску, не весь флаг
FMeterRect: TRect; // прямоугольник сигнал-бара (запоминается при полном рендере)
FSRowTop: Integer; // верх строки частоты (для S-метки справа)
FLastSTextRect: TRect; // последний прямоугольник S-метки (для аккуратной очистки)
procedure DrawSelf(C: TCanvas; W, H: Integer);
procedure DrawButton(C: TCanvas; const R: TRect; const Lbl: string;
@@ -100,6 +110,8 @@ type
procedure SetPanel(P: TFlyPanel);
procedure ApplyVolFromX(LX: Integer);
procedure RequestInvalidate;
procedure RequestMeterInvalidate;
procedure RefreshMeter(C: TCanvas);
procedure DoHit(HV: Integer);
protected
@@ -114,6 +126,10 @@ type
destructor Destroy; override;
procedure DrawOverlay(Target: TBitmap; W, H: Integer);
procedure DrawOverlayBitmap(Target: TBitmap);
// Готовит внутренний кэш-битмап (полный рендер по CacheDirty либо дешёвая
// перерисовка одной полоски метра по MeterDirty) и возвращает его. Один путь
// для CPU (BlendBitmapKey) и GL (UploadBitmap).
function EnsureRendered: TBitmap;
function HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
function HandleMouseMove(X, Y: Integer): Boolean;
function HandleMouseUp(X, Y: Integer): Boolean;
@@ -126,6 +142,20 @@ type
const CurName: string);
procedure UpdateSMeter(DB: Double);
procedure UpdateVfo(AVfoHz: Double);
// Привязка к слайсу: id, буква флага, признак главного (слайс A).
procedure SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean);
property SliceId: Integer read FSliceId;
property IsMain: Boolean read FIsMain;
// Для маркера фильтра слайса на спектре (читает GL-вьюха).
property DisplayVfoHz: Double read FVfoHz;
property DisplayMode: Integer read FMode;
property DisplayBW: Integer read FFilterBW;
// Битмап-кэш требует перерисовки (GL-вьюха решает, перезаливать ли текстуру).
property CacheDirty: Boolean read FCacheDirty;
// Изменился только S-метр (перезалить текстуру, но рендер дешёвый — одна полоска).
property MeterDirty: Boolean read FMeterDirty;
// Порог изменения S-метра (дБ) для перерисовки: у слайсов выше (меньше рендеров).
property SMeterThreshold: Double read FSMeterThreshold write FSMeterThreshold;
property HotIdx: Integer read FHotIdx;
property BaseHeight: Integer read ComputeHeight;
property OnSelect: TModeFilterEvent read FOnSelect write FOnSelect;
@@ -136,6 +166,8 @@ type
property OnAudioDevice: TAudioDeviceEvent read FOnAudioDevice write FOnAudioDevice;
property OnSplit: TNotifyEvent read FOnSplit write FOnSplit;
property OnTxSelect: TNotifyEvent read FOnTxSelect write FOnTxSelect;
property OnSliceSelect: TNotifyEvent read FOnSliceSelect write FOnSliceSelect;
property OnClose: TNotifyEvent read FOnClose write FOnClose;
property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate;
end;
@@ -162,6 +194,8 @@ const
HIT_TX = -26;
HIT_AUD_UP = -27;
HIT_AUD_DN = -28;
HIT_SLICE_SEL = -29; // клик по бейджу буквы (выбрать слайс активным)
HIT_CLOSE = -30; // × закрытия слайса (неглавные)
HIT_AGC_PICK_BASE = 100000;
HIT_DEV_BASE = 200000;
@@ -338,6 +372,8 @@ begin
FNRMode := 0; FNBMode := 0; FSNBOn := False; FANFOn := False; FAGCMode := 0;
FVolume := 70; FMuted := False; FSplit := False; FTx := False;
FTxSel := True; FSlice := 'A';
FSliceId := 0; FIsMain := True; // по умолчанию — главный приёмник (слайс A)
FSMeterThreshold := 0.4; // главный флаг — чувствительный метр
FPanel := fpNone;
FAudScroll := 0;
FVolDrag := False;
@@ -346,6 +382,8 @@ begin
FCacheBitmap := TBitmap.Create;
FCacheBitmap.PixelFormat := pf32bit;
FCacheDirty := True;
FMeterDirty := False;
FLastSTextRect := Rect(0, 0, 0, 0);
Width := OVL_W;
Height := OVL_BASE_H;
Cursor := crHandPoint;
@@ -373,6 +411,53 @@ begin
if Assigned(FOnInvalidate) then FOnInvalidate(Self);
end;
// Лёгкая инвалидация: изменился только S-метр. НЕ трогает FCacheDirty, поэтому
// хром флага (частота/режим/кнопки) не перерисовывается — перезаливается только
// полоска метра (RefreshMeter в EnsureRendered).
procedure TVfoOverlay.RequestMeterInvalidate;
begin
FMeterDirty := True;
inherited Invalidate;
if Assigned(FOnInvalidate) then FOnInvalidate(Self);
end;
// Дешёвая перерисовка одной полоски метра + S-метки поверх уже отрендеренного
// хрома. Обе зоны самодостаточны и целиком очищаются под фон карточки.
procedure TVfoOverlay.RefreshMeter(C: TCanvas);
var
SStr: string;
STextW: Integer;
ClearR, NewR: TRect;
begin
// Сигнал-бар — самодостаточный прямоугольник, перерисовывается целиком.
C.Brush.Color := CLR_CARD_BG;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(FMeterRect);
DrawMeterBar(C, FMeterRect.Left, FMeterRect.Top,
FMeterRect.Right - FMeterRect.Left, MET_H);
// S-метка в строке частоты (справа). Очищаем объединение старого и нового
// прямоугольника, чтобы не оставалось «хвостов» при сужении текста.
SStr := DBmToSLabel(FSMeterDB);
C.Font.Name := 'Courier New';
C.Font.Style := [fsBold];
C.Font.Size := 9;
STextW := C.TextWidth(SStr);
NewR := Rect(FMeterRect.Right - STextW - 3, FSRowTop + 4, FMeterRect.Right, FSRowTop + FRQ_H);
ClearR := NewR;
if FLastSTextRect.Left < ClearR.Left then ClearR.Left := FLastSTextRect.Left;
if FLastSTextRect.Top < ClearR.Top then ClearR.Top := FLastSTextRect.Top;
C.Brush.Color := CLR_CARD_BG;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(ClearR);
C.Font.Color := CLR_SVAL;
C.Brush.Style := bsClear;
C.TextOut(FMeterRect.Right - STextW - 2, FSRowTop + 6, SStr);
FLastSTextRect := NewR;
end;
function TVfoOverlay.FormatFreq(Hz: Double): string;
var
T, iM, iK, iH: Int64;
@@ -800,8 +885,16 @@ begin
RW := W - PAD*2;
CurY := PAD;
{ --- Шапка: [A] BW ... SPLIT [TX] --- }
{ --- Шапка: [A] BW ... [×] SPLIT [TX] --- }
DrawBadge(C, Rect(X, CurY, X+16, CurY+HDR_BADGE_H), FSlice, CLR_ACCENT, CLR_CARD_BG);
RegisterHit(Rect(X, CurY, X+16, CurY+HDR_BADGE_H), HIT_SLICE_SEL); // бейдж = выбор слайса
// Кнопка закрытия × — только у неглавных слайсов (главный приёмник не закрыть).
if not FIsMain then
begin
R := Rect(W - PAD - 26 - 44 - 6 - 14, CurY, W - PAD - 26 - 44 - 6, CurY + HDR_BADGE_H);
DrawBadge(C, R, #$C3#$97, CLR_BTN_NORM, CLR_DIM); // UTF-8 '×'
RegisterHit(R, HIT_CLOSE);
end;
// ширина фильтра
if FFilterBW >= 1000 then BWStr := Format('%.1fk', [FFilterBW/1000.0])
else BWStr := IntToStr(FFilterBW);
@@ -828,10 +921,12 @@ begin
DrawIndicator(C, X + 48, CurY + 2, 'ANF', FANFOn);
DrawIndicator(C, X + 80, CurY + 2, 'SNB', FSNBOn);
MeterX := X + 112;
FMeterRect := Rect(MeterX, CurY, W - PAD, CurY + MET_H); // для RefreshMeter
DrawMeterBar(C, MeterX, CurY, W - PAD - MeterX, MET_H);
Inc(CurY, MET_H);
{ --- Частота + S --- }
FSRowTop := CurY; // для RefreshMeter
FreqStr := FormatFreq(FVfoHz);
C.Font.Name := 'Courier New'; C.Font.Size := 13; C.Font.Style := [fsBold];
C.Font.Color := CLR_FREQ; C.Brush.Style := bsClear;
@@ -839,6 +934,7 @@ begin
SStr := DBmToSLabel(FSMeterDB);
C.Font.Size := 9; C.Font.Color := CLR_SVAL;
C.TextOut(W - PAD - C.TextWidth(SStr) - 2, CurY + 6, SStr);
FLastSTextRect := Rect(W - PAD - C.TextWidth(SStr) - 3, CurY + 4, W - PAD, CurY + FRQ_H);
Inc(CurY, FRQ_H);
{ --- Нижний бар: 🔊 vol | MODE DSP AUD --- }
@@ -899,11 +995,8 @@ begin
DrawSelf(Canvas, Width, Height);
end;
procedure TVfoOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
function TVfoOverlay.EnsureRendered: TBitmap;
begin
if (not Visible) or (Target = nil) or (Width <= 0) or (Height <= 0) then Exit;
if (Left >= W) or (Top >= H) then Exit;
if (FCacheBitmap.Width <> Width) or (FCacheBitmap.Height <> Height) then
begin
FCacheBitmap.SetSize(Width, Height);
@@ -917,7 +1010,21 @@ begin
FCacheBitmap.Canvas.FillRect(Rect(0, 0, Width, Height));
DrawSelf(FCacheBitmap.Canvas, Width, Height);
FCacheDirty := False;
FMeterDirty := False;
end
else if FMeterDirty then
begin
RefreshMeter(FCacheBitmap.Canvas); // дешёвый апдейт: только полоска метра
FMeterDirty := False;
end;
Result := FCacheBitmap;
end;
procedure TVfoOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
begin
if (not Visible) or (Target = nil) or (Width <= 0) or (Height <= 0) then Exit;
if (Left >= W) or (Top >= H) then Exit;
EnsureRendered;
BlendBitmapKey(Target, FCacheBitmap, Left, Top, VFO_OVERLAY_ALPHA);
end;
@@ -932,6 +1039,7 @@ begin
Target.Canvas.FillRect(Rect(0, 0, Width, Height));
DrawSelf(Target.Canvas, Width, Height);
FCacheDirty := False;
FMeterDirty := False;
end;
{ ---- Обработка хитов ---- }
@@ -949,6 +1057,8 @@ begin
if Assigned(FOnMute) then FOnMute(Self); Exit; end;
HIT_SPLIT: begin if Assigned(FOnSplit) then FOnSplit(Self); Exit; end;
HIT_TX: begin if Assigned(FOnTxSelect) then FOnTxSelect(Self); Exit; end;
HIT_SLICE_SEL: begin if Assigned(FOnSliceSelect) then FOnSliceSelect(Self); Exit; end;
HIT_CLOSE: begin if Assigned(FOnClose) then FOnClose(Self); Exit; end;
HIT_AUD_UP: begin if FAudScroll > 0 then Dec(FAudScroll);
RequestInvalidate; Exit; end;
HIT_AUD_DN: begin if FAudScroll < FDevNames.Count - AUD_MAX_VIS then
@@ -1174,6 +1284,15 @@ begin
RequestInvalidate;
end;
procedure TVfoOverlay.SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean);
begin
if (FSliceId = ASliceId) and (FSlice = ALetter) and (FIsMain = AIsMain) then Exit;
FSliceId := ASliceId;
FSlice := ALetter;
FIsMain := AIsMain;
RequestInvalidate;
end;
procedure TVfoOverlay.SetAudioDevices(Names: TStrings; const Indices: array of Integer;
const CurName: string);
var
@@ -1190,10 +1309,10 @@ end;
procedure TVfoOverlay.UpdateSMeter(DB: Double);
begin
if Abs(DB - FSMeterDB) > 0.4 then
if Abs(DB - FSMeterDB) > FSMeterThreshold then
begin
FSMeterDB := DB;
RequestInvalidate;
RequestMeterInvalidate; // только полоска метра, без ре-рендера всего флага
end;
end;
+517 -81
View File
@@ -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;
+181
View File
@@ -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)
+25 -2
View File
@@ -17,9 +17,9 @@
<UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/>
<BuildNr Value="215"/>
<BuildNr Value="232"/>
</VersionInfo>
<MacroValues Count="27">
<MacroValues Count="50">
<Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -47,6 +47,29 @@
<Macro25 Name="LCLWidgetType" Value="qt6"/>
<Macro26 Name="LCLWidgetType" Value="qt6"/>
<Macro27 Name="LCLWidgetType" Value="qt6"/>
<Macro28 Name="LCLWidgetType" Value="qt6"/>
<Macro29 Name="LCLWidgetType" Value="qt6"/>
<Macro30 Name="LCLWidgetType" Value="qt6"/>
<Macro31 Name="LCLWidgetType" Value="qt6"/>
<Macro32 Name="LCLWidgetType" Value="qt6"/>
<Macro33 Name="LCLWidgetType" Value="qt6"/>
<Macro34 Name="LCLWidgetType" Value="qt6"/>
<Macro35 Name="LCLWidgetType" Value="qt6"/>
<Macro36 Name="LCLWidgetType" Value="qt6"/>
<Macro37 Name="LCLWidgetType" Value="qt6"/>
<Macro38 Name="LCLWidgetType" Value="qt6"/>
<Macro39 Name="LCLWidgetType" Value="qt6"/>
<Macro40 Name="LCLWidgetType" Value="qt6"/>
<Macro41 Name="LCLWidgetType" Value="qt6"/>
<Macro42 Name="LCLWidgetType" Value="qt6"/>
<Macro43 Name="LCLWidgetType" Value="qt6"/>
<Macro44 Name="LCLWidgetType" Value="qt6"/>
<Macro45 Name="LCLWidgetType" Value="qt6"/>
<Macro46 Name="LCLWidgetType" Value="qt6"/>
<Macro47 Name="LCLWidgetType" Value="qt6"/>
<Macro48 Name="LCLWidgetType" Value="qt6"/>
<Macro49 Name="LCLWidgetType" Value="qt6"/>
<Macro50 Name="LCLWidgetType" Value="qt6"/>
</MacroValues>
<BuildModes>
<Item Name="Debug" Default="True"/>