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). // Список устройств (saved+discovered) → FController.FDeviceStore (общий с web).
FDeviceDialog: TDeviceDialog; FDeviceDialog: TDeviceDialog;
FPendingIP: string; // IP устройства для подключения 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 слева вверху спектра FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра
FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра
FPanelHidden: Boolean; // True = левая панель скрыта FPanelHidden: Boolean; // True = левая панель скрыта
@@ -516,6 +523,7 @@ type
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
procedure BtnSettingsClick(Sender: TObject); procedure BtnSettingsClick(Sender: TObject);
procedure PositionVfoOverlay; procedure PositionVfoOverlay;
procedure UpdateVfoOverlayVisibility; // флаг A виден: панель скрыта ИЛИ есть слайсы B+
procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer); procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
procedure OnVfoOverlayAGCChange(AGCMode: Integer); procedure OnVfoOverlayAGCChange(AGCMode: Integer);
@@ -524,9 +532,26 @@ type
procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string); procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
procedure OnVfoOverlaySplit(Sender: TObject); procedure OnVfoOverlaySplit(Sender: TObject);
procedure OnVfoOverlayTxSelect(Sender: TObject); procedure OnVfoOverlayTxSelect(Sender: TObject);
procedure OnVfoOverlaySliceSelect(Sender: TObject); // выбрать слайс активным
procedure OnVfoOverlayClose(Sender: TObject); // × — удалить слайс
procedure ProcessPendingSliceClose; // отложенное удаление флага
procedure PushVfoOverlayExtState; procedure PushVfoOverlayExtState;
procedure PushVfoOverlayAudioDevices; procedure PushVfoOverlayAudioDevices;
procedure OnVfoOverlayInvalidate(Sender: TObject); 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 PbSpectrumDblClick(Sender: TObject);
procedure TrkDriveChange(Sender: TObject); procedure TrkDriveChange(Sender: TObject);
procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive
@@ -960,16 +985,14 @@ begin
FDeviceDialog.Store := FController.FDeviceStore; FDeviceDialog.Store := FController.FDeviceStore;
FDeviceDialog.OnDiscover := BtnDiscoverFromDialog; FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
FVfoOverlay := TVfoOverlay.Create(Self); FVfoOverlay := TVfoOverlay.Create(Self);
FVfoOverlay.OnSelect := OnModeFilterSelect; WireOverlayEvents(FVfoOverlay);
FVfoOverlay.OnDSPChange := OnVfoOverlayDSPChange; FVfoOverlay.SetSliceInfo(0, 'A', True); // главный приёмник = слайс A
FVfoOverlay.OnAGCChange := OnVfoOverlayAGCChange;
FVfoOverlay.OnVolume := OnVfoOverlayVolume;
FVfoOverlay.OnMute := OnVfoOverlayMute;
FVfoOverlay.OnAudioDevice := OnVfoOverlayAudioDevice;
FVfoOverlay.OnSplit := OnVfoOverlaySplit;
FVfoOverlay.OnTxSelect := OnVfoOverlayTxSelect;
FVfoOverlay.OnInvalidate := OnVfoOverlayInvalidate;
FVfoOverlay.Visible := False; FVfoOverlay.Visible := False;
FActiveSliceId := 0;
FActiveFlag := nil;
FPendingSliceClose := 0;
FSliceDrag := False;
FSliceDragId := 0;
FPanelHidden := False; FPanelHidden := False;
FController.FShowSpectrum := True; FController.FShowSpectrum := True;
FController.FShowWaterfall := True; FController.FShowWaterfall := True;
@@ -1413,6 +1436,7 @@ begin
SyncSpecViewFreq; SyncSpecViewFreq;
UpdatePanZoomBar; UpdatePanZoomBar;
PositionVfoOverlay; PositionVfoOverlay;
PositionSliceFlags;
FSpecView.DrawSpectrum; FSpecView.DrawSpectrum;
PbSpectrum.Invalidate; PbSpectrum.Invalidate;
FSpecView.DrawWaterfall; FSpecView.DrawWaterfall;
@@ -1497,6 +1521,16 @@ begin
PushDeviceListToWeb; // web-overlay тоже получает обновлённый список PushDeviceListToWeb; // web-overlay тоже получает обновлённый список
end; end;
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 finally
FSyncingFromController := False; FSyncingFromController := False;
end; end;
@@ -3188,6 +3222,7 @@ procedure TMainForm.SpectrumTimerTick(Sender: TObject);
var var
NowTick: QWord; NowTick: QWord;
ElapsedTick: QWord; ElapsedTick: QWord;
I: Integer;
begin begin
// Синхронизируем размеры если размер панели изменился (после ресайза). // Синхронизируем размеры если размер панели изменился (после ресайза).
// Важно: проверяем только видимые панели — когда спектр скрыт, // Важно: проверяем только видимые панели — когда спектр скрыт,
@@ -3266,6 +3301,19 @@ begin
PushVfoOverlayExtState; PushVfoOverlayExtState;
PositionVfoOverlay; PositionVfoOverlay;
end; 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 даёт новые строки чаще. // Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще.
if FSpectrumDirty then if FSpectrumDirty then
@@ -4421,11 +4469,22 @@ procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer); Shift: TShiftState; X, Y: Integer);
var VC, VS: Double; var VC, VS: Double;
begin begin
if Assigned(FVfoOverlay) and if DispatchFlagsMouseDown(Button, X, Y) then
FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit; begin
ProcessPendingSliceClose; // × мог пометить слайс к удалению
Exit;
end;
if Assigned(FSampleRateOverlay) and if Assigned(FSampleRateOverlay) and
FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit; 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 if Button = mbLeft then
begin begin
// Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено», // Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено»,
@@ -4441,6 +4500,18 @@ begin
UpdateBeaconButton; UpdateBeaconButton;
Exit; Exit;
end; 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 if FSpecView.MarkerActive then
begin begin
@@ -4485,18 +4556,40 @@ end;
procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState; procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
X, Y: Integer); X, Y: Integer);
var
VC, VS, NewHz, LoHz, HiHz: Double;
begin 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 if Assigned(FSampleRateOverlay) then
begin begin
FSampleRateOverlay.HandleMouseMove(X, Y); FSampleRateOverlay.HandleMouseMove(X, Y);
end; end;
if Assigned(FVfoOverlay) then DispatchFlagsMouseMove(X, Y);
FVfoOverlay.HandleMouseMove(X, Y);
if not FSpecDrag then if not FSpecDrag then
begin begin
if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or
(Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then (Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then
PbSpectrum.Cursor := crHandPoint PbSpectrum.Cursor := crHandPoint
else if SliceAtPixel(X) <> 0 then
PbSpectrum.Cursor := crSizeWE // над маркером слайса — его можно тащить
else else
PbSpectrum.Cursor := crDefault; PbSpectrum.Cursor := crDefault;
end; end;
@@ -4514,7 +4607,14 @@ end;
procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer); Shift: TShiftState; X, Y: Integer);
begin 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 begin
FSpecDrag := False; FSpecDrag := False;
PbSpectrum.Cursor := crDefault; PbSpectrum.Cursor := crDefault;
@@ -4533,8 +4633,7 @@ procedure TMainForm.PbSpectrumMouseLeave(Sender: TObject);
begin begin
if Assigned(FSampleRateOverlay) then if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.HandleMouseLeave; FSampleRateOverlay.HandleMouseLeave;
if Assigned(FVfoOverlay) then DispatchFlagsMouseLeave;
FVfoOverlay.HandleMouseLeave;
if not FSpecDrag then PbSpectrum.Cursor := crDefault; if not FSpecDrag then PbSpectrum.Cursor := crDefault;
end; end;
@@ -5008,6 +5107,28 @@ begin
begin begin
FLeftPanelW := PanelLeft.Width; // сохраняем реальную ширину (учитывает DPI на Win32) FLeftPanelW := PanelLeft.Width; // сохраняем реальную ширину (учитывает DPI на Win32)
PanelLeft.Width := 0; 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.Width := OVL_W;
FVfoOverlay.Height := FVfoOverlay.BaseHeight; // растёт сам при открытии fly-out FVfoOverlay.Height := FVfoOverlay.BaseHeight; // растёт сам при открытии fly-out
FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter); FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
@@ -5019,14 +5140,9 @@ begin
end end
else else
begin begin
PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину
FVfoOverlay.HandleMouseLeave; FVfoOverlay.HandleMouseLeave;
FVfoOverlay.Visible := False; FVfoOverlay.Visible := False;
end; end;
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.SetPanelHidden(FPanelHidden);
ResizeSpectrumPanels;
ResizeSMeter;
end; end;
procedure TMainForm.PositionVfoOverlay; procedure TMainForm.PositionVfoOverlay;
@@ -5089,6 +5205,14 @@ procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
var var
Idx: Integer; Idx: Integer;
begin begin
// Событие пришло от флага слайса (B+) — маршрутизируем в slice-API, главный не трогаем.
if CurSliceId <> 0 then
begin
FController.SetSliceModeBW(CurSliceId, Mode, FilterBW);
PushSliceFlagState(CurSliceId);
PositionSliceFlags;
Exit;
end;
FController.FMode := Mode; FController.FMode := Mode;
FController.FFilterBW := FilterBW; FController.FFilterBW := FilterBW;
if Mode = MODE_FM then if Mode = MODE_FM then
@@ -5118,6 +5242,11 @@ end;
procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
begin begin
if CurSliceId <> 0 then
begin
FController.SetSliceDSP(CurSliceId, NRMode, NBMode, SNBOn, ANFOn);
Exit;
end;
// Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки. // Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки.
FController.SetNR(NRMode); FController.SetNR(NRMode);
FController.SetNB(NBMode); FController.SetNB(NBMode);
@@ -5127,34 +5256,59 @@ end;
procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer); procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer);
begin begin
if CurSliceId <> 0 then
begin
FController.SetSliceAGCMode(CurSliceId, TRadioController.AGCModeFromUI(AGCMode));
Exit;
end;
FController.SetAGCMode(AGCMode); FController.SetAGCMode(AGCMode);
end; end;
procedure TMainForm.OnVfoOverlayVolume(Vol: Integer); procedure TMainForm.OnVfoOverlayVolume(Vol: Integer);
begin begin
if CurSliceId <> 0 then
begin
FController.SetSliceVolume(CurSliceId, Vol / 100.0);
Exit;
end;
FController.SetVolume(Vol); FController.SetVolume(Vol);
end; end;
procedure TMainForm.OnVfoOverlayMute(Sender: TObject); procedure TMainForm.OnVfoOverlayMute(Sender: TObject);
var S: TCtrlSlice;
begin begin
if CurSliceId <> 0 then
begin
if FController.GetSlice(CurSliceId, S) then
FController.SetSliceMute(CurSliceId, not S.Muted);
Exit;
end;
FController.ToggleMute; FController.ToggleMute;
end; end;
procedure TMainForm.OnVfoOverlaySplit(Sender: TObject); procedure TMainForm.OnVfoOverlaySplit(Sender: TObject);
begin begin
if CurSliceId <> 0 then Exit; // SPLIT — только главный (слайсы не передают)
FController.SetSplit(not FController.FSplitTxB); FController.SetSplit(not FController.FSplitTxB);
PushVfoOverlayExtState; PushVfoOverlayExtState;
end; end;
procedure TMainForm.OnVfoOverlayTxSelect(Sender: TObject); procedure TMainForm.OnVfoOverlayTxSelect(Sender: TObject);
begin begin
// TODO(слайсы): выбрать этот слайс как TX-слайс. Пока один слайс — // TODO(слайсы): выбор TX-слайса при полноценной мультислайс-передаче.
// он всегда TX-слайс, поэтому клик ничего не меняет (хук под мультислайсы). // Пока передаёт только главный канал — клик по TX неглавного игнорируем.
if CurSliceId <> 0 then Exit;
PushVfoOverlayExtState; PushVfoOverlayExtState;
end; end;
procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string); procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
begin begin
if CurSliceId <> 0 then
begin
FController.SetSliceDevice(CurSliceId, DevIndex, DevName);
PushSliceFlagState(CurSliceId); // обновить подсветку текущего устройства слайса
Exit;
end;
ApplyAudioDevice(DevIndex, DevName); ApplyAudioDevice(DevIndex, DevName);
PushVfoOverlayAudioDevices; // обновить подсветку текущего устройства PushVfoOverlayAudioDevices; // обновить подсветку текущего устройства
end; end;
@@ -5193,9 +5347,326 @@ begin
if Assigned(FSpecView) then if Assigned(FSpecView) then
begin begin
FSpecView.SpectrumDirty := True; 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; end;
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; end;
procedure TMainForm.OnSampleRateSelect(SampleRate: Integer); procedure TMainForm.OnSampleRateSelect(SampleRate: Integer);
@@ -5752,16 +6223,63 @@ procedure TMainForm.FormMouseWheel(Sender: TObject; Shift: TShiftState;
P := C.ScreenToClient(Mouse.CursorPos); P := C.ScreenToClient(Mouse.CursorPos);
Result := (P.X >= 0) and (P.Y >= 0) and (P.X < C.Width) and (P.Y < C.Height); Result := (P.X >= 0) and (P.Y >= 0) and (P.X < C.Width) and (P.Y < C.Height);
end; 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 var
Step: Int64; Step: Int64;
Delta: Int64; Delta: Int64;
Base: Int64; Base: Int64;
NewFreq: Int64; NewFreq: Int64;
SliceId: Integer;
SLo, SHi, SNew: Double;
Sl: TCtrlSlice;
begin begin
// Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно // Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно
// (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты: // (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты:
// виджет крутил бы B, а этот хендлер — активный A. // виджет крутил бы B, а этот хендлер — активный A.
if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit; 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 if (FController.FMode = MODE_FM) and FController.FFMStepOn and
not ((ssCtrl in Shift) or (ssShift in Shift)) then not ((ssCtrl in Shift) or (ssShift in Shift)) then
Step := FM_STEP_HZ[FController.FFMStepIdx] Step := FM_STEP_HZ[FController.FFMStepIdx]
+325 -11
View File
@@ -122,6 +122,24 @@ type
PLLLock: Boolean; PLLLock: Boolean;
end; 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 }
TRadioController = class TRadioController = class
private private
@@ -134,6 +152,11 @@ type
FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView) FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView)
FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер) FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер)
procedure Changed(Field: TRadioField); 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 ApplyAGCToEngine; // применяет FAGCMode к WDSP (единый маппинг)
procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode
procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state) procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state)
@@ -163,6 +186,12 @@ type
FDSPEngine: TWDSPEngine; FDSPEngine: TWDSPEngine;
FAudioOut: TAudioOutput; FAudioOut: TAudioOutput;
FAudioIn: TAudioInput; FAudioIn: TAudioInput;
// ---- Мультислайсы (софт-fan-out) ----
// Слайс = доп. приёмник (свой WDSP-канал в движке) + своя звуковуха.
// TargetHz абсолютна; ShiftHz в движок = TargetHz - FCenterFreq.
FSlices: array[0..MAX_SLICES-1] of TCtrlSlice;
FNextSliceId: Integer; // монотонный генератор id слайсов
FSettings: TSettingsManager; FSettings: TSettingsManager;
FChannelStore: TChannelStore; FChannelStore: TChannelStore;
FDeviceStore: TDeviceStore; // общий список устройств (saved+discovered) FDeviceStore: TDeviceStore; // общий список устройств (saved+discovered)
@@ -467,6 +496,32 @@ type
procedure DriveBy(Delta: Integer); procedure DriveBy(Delta: Integer);
procedure SetAtten(Idx: 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 на // Pluto TX-мощность через аттенюацию: drive% → дБ (потолок FPlutoTxMaxAttDb на
// 100%, выкл на приёме). Логика/маппинг здесь; бэкенд лишь пишет hardwaregain. // 100%, выкл на приёме). Логика/маппинг здесь; бэкенд лишь пишет hardwaregain.
function PlutoTxAttForDrive: Double; // дБ<0 под текущий FDrivePercent function PlutoTxAttForDrive: Double; // дБ<0 под текущий FDrivePercent
@@ -625,6 +680,8 @@ begin
FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX) FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX)
FVolume := 70; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0; FVolume := 70; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0;
FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000; FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000;
FNextSliceId := 1; // мультислайсы: генератор id
FillChar(FSlices, SizeOf(FSlices), 0);
FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума) FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума)
FCurrentBand := 5; FCurrentXvtr := -1; FCurrentBand := 5; FCurrentXvtr := -1;
FSpectrumBufCount := 1024; FWaterfallBufCount := 1024; FSpectrumBufCount := 1024; FWaterfallBufCount := 1024;
@@ -649,6 +706,9 @@ begin
// BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший // BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший
// буфер вдвое сокращает построение downsampler в OpenChannel RXA. // буфер вдвое сокращает построение downsampler в OpenChannel RXA.
FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512); FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
// Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не
// зависит от фронтенда, в отличие от OnAudio/OnSpectrum).
FDSPEngine.OnSliceAudio := OnSliceAudioReady;
// QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock). // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock).
FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink) FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink)
@@ -672,12 +732,21 @@ begin
end; end;
procedure TRadioController.FreeEngines; procedure TRadioController.FreeEngines;
var i: Integer;
begin begin
if Assigned(FNetwork) then if Assigned(FNetwork) then
begin begin
if FNetwork.Connected then FNetwork.Disconnect; if FNetwork.Connected then FNetwork.Disconnect;
FreeAndNil(FNetwork); FreeAndNil(FNetwork);
end; 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(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end;
if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end; if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end;
if Assigned(FDSPEngine) then if Assigned(FDSPEngine) then
@@ -966,6 +1035,227 @@ begin
FAudioOut.Write(Left, Right, Count); FAudioOut.Write(Left, Right, Count);
end; 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; procedure TRadioController.OnSpectrumReady(const Pixels: array of Single;
Count: Integer); Count: Integer);
// DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу // DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу
@@ -1335,16 +1625,31 @@ begin
Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors); Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors);
end; end;
procedure TRadioController.ApplyModeFilter; // Кромки полосы под режим/полосу (единый расчёт для главного канала и слайсов).
var class procedure TRadioController.FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer);
Lo, Hi, Half: Integer; var Half: Integer;
begin begin
Half := FFilterBW div 2; Half := BW div 2;
case FMode of case Mode of
0: begin Lo := -FFilterBW; Hi := -100; end; // LSB 0: begin Lo := -BW; Hi := -100; end; // LSB
1: begin Lo := 100; Hi := FFilterBW; end; // USB 1: begin Lo := 100; Hi := BW; end; // USB
else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM
end; 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: Integer;
begin
FilterEdgesFor(FMode, FFilterBW, Lo, Hi);
if FWDSPReady and Assigned(FDSPEngine) then if FWDSPReady and Assigned(FDSPEngine) then
begin begin
FDSPEngine.SetFilter(Lo, Hi); FDSPEngine.SetFilter(Lo, Hi);
@@ -1921,6 +2226,7 @@ begin
NeedPush := True; NeedPush := True;
end; end;
RefreshSliceShifts; // центр мог сместиться — держим слайсы на их TargetHz
if NeedPush then PushNetworkState; if NeedPush then PushNetworkState;
Changed(rfActiveVfo); Changed(rfActiveVfo);
end; end;
@@ -1941,6 +2247,7 @@ begin
FCenterFreq := Hz; FCenterFreq := Hz;
if FWDSPReady and Assigned(FDSPEngine) then if FWDSPReady and Assigned(FDSPEngine) then
FDSPEngine.SetShift(ActiveVfoHz - FCenterFreq); FDSPEngine.SetShift(ActiveVfoHz - FCenterFreq);
RefreshSliceShifts; // слайсы привязаны к абсолютной частоте — сдвинуть NCO
PushNetworkState; PushNetworkState;
Changed(rfCenterFreq); Changed(rfCenterFreq);
end; end;
@@ -1972,6 +2279,7 @@ begin
end; end;
FCenterFreq := ActiveFreq; FCenterFreq := ActiveFreq;
if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0); if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0);
RefreshSliceShifts;
PushNetworkState; PushNetworkState;
Changed(rfVfoA); Changed(rfVfoA);
Changed(rfVfoB); Changed(rfVfoB);
@@ -2057,14 +2365,20 @@ begin
Changed(rfFilter); Changed(rfFilter);
end; end;
procedure TRadioController.ApplyAGCToEngine; // UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для всех
// фронтендов и слайсов (раньше web/слайдер ошибочно кастовали индекс прямо в enum).
class function TRadioController.AGCModeFromUI(M: Integer): TWDSPAGCMode;
const const
// UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для
// всех фронтендов (раньше web/слайдер ошибочно кастовали индекс прямо в enum).
AGCModes: array[0..4] of TWDSPAGCMode = (agcFast, agcMedium, agcSlow, agcLong, agcOff); 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 begin
if FWDSPReady and Assigned(FDSPEngine) and (FAGCMode >= 0) and (FAGCMode <= 4) then 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; end;
procedure TRadioController.SetAGCMode(M: Integer); procedure TRadioController.SetAGCMode(M: Integer);
+84 -4
View File
@@ -70,6 +70,7 @@ type
FADCOverloadVisible: Boolean; FADCOverloadVisible: Boolean;
FSampleRateOverlay: TSampleRateOverlay; FSampleRateOverlay: TSampleRateOverlay;
FVfoOverlay: TVfoOverlay; FVfoOverlay: TVfoOverlay;
FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm)
FBandPlanOverlay: TBandPlanOverlay; FBandPlanOverlay: TBandPlanOverlay;
// ── Marker ──────────────────────────────────────────────────────────────── // ── Marker ────────────────────────────────────────────────────────────────
FMarkerActive: Boolean; FMarkerActive: Boolean;
@@ -108,12 +109,15 @@ type
procedure SetFillSpectrum(V: Boolean); procedure SetFillSpectrum(V: Boolean);
procedure DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer); procedure DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer);
procedure DrawMarkerLine(C: TCanvas; 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 DrawBeaconMarkers(C: TCanvas; W, H: Integer);
procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte); procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte);
procedure CopyGridToSpectrum(W, H: Integer); procedure CopyGridToSpectrum(W, H: Integer);
procedure DrawADCOverloadOverlay(C: TCanvas; W, H: Integer); procedure DrawADCOverloadOverlay(C: TCanvas; W, H: Integer);
procedure CalcFilterBandX(VfoFreq: Double; W: Integer; procedure CalcFilterBandX(VfoFreq: Double; W: Integer;
out X1, X2, VfoX: Integer); out X1, X2, VfoX: Integer);
procedure CalcFilterBandXFor(VfoFreq: Double; Mode, BW, W: Integer;
out X1, X2, VfoX: Integer);
function GetWaterfallDirty: Boolean; function GetWaterfallDirty: Boolean;
procedure SetWaterfallDirty(V: Boolean); procedure SetWaterfallDirty(V: Boolean);
// Waterfall sub-view delegating accessors // Waterfall sub-view delegating accessors
@@ -218,6 +222,11 @@ type
property PbSMeterRight: TPaintBox write SetPbSMeterRight; property PbSMeterRight: TPaintBox write SetPbSMeterRight;
property SampleRateOverlay: TSampleRateOverlay read FSampleRateOverlay write FSampleRateOverlay; property SampleRateOverlay: TSampleRateOverlay read FSampleRateOverlay write FSampleRateOverlay;
property VfoOverlay: TVfoOverlay read FVfoOverlay write FVfoOverlay; 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; property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay;
// ── Данные от DSP ───────────────────────────────────────────────────────── // ── Данные от DSP ─────────────────────────────────────────────────────────
@@ -244,6 +253,9 @@ type
// ── Утилиты ─────────────────────────────────────────────────────────────── // ── Утилиты ───────────────────────────────────────────────────────────────
procedure InvalidateGridCache; virtual; procedure InvalidateGridCache; virtual;
procedure InvalidateOverlayCache; virtual; procedure InvalidateOverlayCache; virtual;
// Инвалидация ТОЛЬКО текстуры главного VFO-флага (без band/samplerate/слайсов).
// Нужна для дешёвого обновления S-метра главного флага в GL-вьюхе.
procedure InvalidateVfoOverlay; virtual;
procedure SetTheme(const T: TAppTheme); virtual; procedure SetTheme(const T: TAppTheme); virtual;
procedure InvalidateRulerCache; virtual; procedure InvalidateRulerCache; virtual;
procedure ResetSpectrumBuf; virtual; procedure ResetSpectrumBuf; virtual;
@@ -329,6 +341,7 @@ begin
FWaterfall := TWaterfallView.Create; FWaterfall := TWaterfallView.Create;
FSMeter := TSMeterView.Create; FSMeter := TSMeterView.Create;
FRuler := TRulerView.Create; FRuler := TRulerView.Create;
FSliceOverlays := TFPList.Create;
ResetSpectrumBuf; ResetSpectrumBuf;
end; end;
@@ -339,9 +352,30 @@ begin
FWaterfall.Free; FWaterfall.Free;
FSMeter.Free; FSMeter.Free;
FRuler.Free; FRuler.Free;
FSliceOverlays.Free; // не владеет флагами — только список
inherited; inherited;
end; 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 // Setters with cascade to sub-views
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
@@ -583,6 +617,32 @@ begin
end; end;
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); procedure TSpectrumView.CopyGridToSpectrum(W, H: Integer);
var var
Y: Integer; Y: Integer;
@@ -620,14 +680,21 @@ end;
procedure TSpectrumView.CalcFilterBandX(VfoFreq: Double; W: Integer; procedure TSpectrumView.CalcFilterBandX(VfoFreq: Double; W: Integer;
out X1, X2, VfoX: 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 var
Lo_Hz, Hi_Hz, Half: Double; Lo_Hz, Hi_Hz, Half: Double;
begin begin
Half := FFilterBW / 2; Half := BW / 2;
VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W); VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W);
case FMode of case Mode of
0: begin Lo_Hz := -FFilterBW; Hi_Hz := -100; end; 0: begin Lo_Hz := -BW; Hi_Hz := -100; end;
1: begin Lo_Hz := 100; Hi_Hz := FFilterBW; end; 1: begin Lo_Hz := 100; Hi_Hz := BW; end;
else begin Lo_Hz := -Half; Hi_Hz := Half; end; else begin Lo_Hz := -Half; Hi_Hz := Half; end;
end; end;
X1 := Max(0, Min(W - 1, VfoX + Round(Lo_Hz / FSpanHz * W))); X1 := Max(0, Min(W - 1, VfoX + Round(Lo_Hz / FSpanHz * W)));
@@ -737,6 +804,13 @@ begin
FSpectrumDirty := True; FSpectrumDirty := True;
end; end;
procedure TSpectrumView.InvalidateVfoOverlay;
begin
// База (CPU-путь) перерисовывает флаг по FCacheDirty/FMeterDirty оверлея — здесь
// достаточно пометить спектр грязным. GL-подкласс переопределяет.
FSpectrumDirty := True;
end;
procedure TSpectrumView.SetTheme(const T: TAppTheme); procedure TSpectrumView.SetTheme(const T: TAppTheme);
begin begin
FTheme := T; FTheme := T;
@@ -905,6 +979,9 @@ begin
C.Pen.Style := psSolid; C.Pen.Style := psSolid;
end; end;
// Полосы фильтра слайсов (B+) — под кривой спектра, как в GL-вьюхе.
DrawSliceFilterMarkers(C, W, H);
// ── 3. AGC линии ────────────────────────────────────────────────────────── // ── 3. AGC линии ──────────────────────────────────────────────────────────
if FWDSPReady then if FWDSPReady then
begin begin
@@ -1009,6 +1086,9 @@ begin
FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H);
if Assigned(FVfoOverlay) then if Assigned(FVfoOverlay) then
FVfoOverlay.DrawOverlay(FSpectrumBitmap, W, H); FVfoOverlay.DrawOverlay(FSpectrumBitmap, W, H);
// Доп. слайс-флаги (B+) поверх главного.
for i := 0 to FSliceOverlays.Count - 1 do
TVfoOverlay(FSliceOverlays[i]).DrawOverlay(FSpectrumBitmap, W, H);
// Восстановление непрозрачности. На Cocoa проверено, что проход не нужен // Восстановление непрозрачности. На Cocoa проверено, что проход не нужен
// (Canvas-операции сохраняют альфу $FF), поэтому там его не делаем. // (Canvas-операции сохраняют альфу $FF), поэтому там его не делаем.
+112 -7
View File
@@ -43,6 +43,12 @@ type
FSampleOverlayTex: TGLTextureCache; FSampleOverlayTex: TGLTextureCache;
FVfoOverlayTex: TGLTextureCache; FVfoOverlayTex: TGLTextureCache;
FBandOverlayTex: TGLTextureCache; FBandOverlayTex: TGLTextureCache;
// Текстуры флагов слайсов (B+), привязка по указателю оверлея.
FSliceTex: array of record
Overlay: TVfoOverlay;
Tex: TGLTextureCache;
LastX, LastY, LastW, LastH: Integer;
end;
FLastAGCText: string; FLastAGCText: string;
FLastAGCY: Integer; FLastAGCY: Integer;
FLastAGCHangY: Integer; FLastAGCHangY: Integer;
@@ -83,6 +89,9 @@ type
procedure DrawBeaconMarkersGL(W, H: Integer); procedure DrawBeaconMarkersGL(W, H: Integer);
procedure DrawADCOverlay(W, H: Integer); procedure DrawADCOverlay(W, H: Integer);
procedure DrawCachedOverlays(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 FormatFreqGL(Hz: Double): string;
function ActiveVfoFrequency: Double; function ActiveVfoFrequency: Double;
public public
@@ -96,6 +105,7 @@ type
procedure PaintSpectrum(Sender: TObject); override; procedure PaintSpectrum(Sender: TObject); override;
procedure InvalidateGridCache; override; procedure InvalidateGridCache; override;
procedure InvalidateOverlayCache; override; procedure InvalidateOverlayCache; override;
procedure InvalidateVfoOverlay; override;
procedure SetTheme(const T: TAppTheme); override; procedure SetTheme(const T: TAppTheme); override;
end; end;
@@ -138,6 +148,7 @@ begin
end; end;
destructor TSpectrumViewOpenGL.Destroy; destructor TSpectrumViewOpenGL.Destroy;
var i: Integer;
begin begin
if Assigned(FGLControl) and FGLControl.MakeCurrent then if Assigned(FGLControl) and FGLControl.MakeCurrent then
begin begin
@@ -149,7 +160,9 @@ begin
DeleteTexture(FSampleOverlayTex); DeleteTexture(FSampleOverlayTex);
DeleteTexture(FVfoOverlayTex); DeleteTexture(FVfoOverlayTex);
DeleteTexture(FBandOverlayTex); DeleteTexture(FBandOverlayTex);
for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex);
end; end;
SetLength(FSliceTex, 0);
inherited Destroy; inherited Destroy;
end; end;
@@ -392,6 +405,13 @@ begin
FVfoOverlayDirty := True; FVfoOverlayDirty := True;
end; end;
procedure TSpectrumViewOpenGL.InvalidateVfoOverlay;
begin
// Только текстура главного флага — НЕ трогаем FOverlayDirty, иначе band/
// samplerate/слайсы зря перезаливались бы на каждый тик S-метра главного.
FVfoOverlayDirty := True;
end;
procedure TSpectrumViewOpenGL.SetTheme(const T: TAppTheme); procedure TSpectrumViewOpenGL.SetTheme(const T: TAppTheme);
begin begin
inherited SetTheme(T); inherited SetTheme(T);
@@ -536,6 +556,29 @@ begin
DrawLine(TXVfoX, 0, TXVfoX, H - 12, TColor($002030E0), 2, False); DrawLine(TXVfoX, 0, TXVfoX, H - 12, TColor($002030E0), 2, False);
DrawRect(TXVfoX - 5, 0, TXVfoX + 5, 8, TColor($002030E0), 1); DrawRect(TXVfoX - 5, 0, TXVfoX + 5, 8, TColor($002030E0), 1);
end; 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; end;
procedure TSpectrumViewOpenGL.DrawAGCLines(W, H: Integer; DBmax, procedure TSpectrumViewOpenGL.DrawAGCLines(W, H: Integer; DBmax,
@@ -783,16 +826,11 @@ begin
if Assigned(FVfoOverlay) and FVfoOverlay.Visible then if Assigned(FVfoOverlay) and FVfoOverlay.Visible then
begin begin
if FOverlayDirty or FVfoOverlayDirty or FVfoOverlayTex.Dirty or if FOverlayDirty or FVfoOverlayDirty or FVfoOverlayTex.Dirty or
FVfoOverlay.CacheDirty or FVfoOverlay.MeterDirty or
(FLastVfoX <> FVfoOverlay.Left) or (FLastVfoY <> FVfoOverlay.Top) or (FLastVfoX <> FVfoOverlay.Left) or (FLastVfoY <> FVfoOverlay.Top) or
(FLastVfoW <> FVfoOverlay.Width) or (FLastVfoH <> FVfoOverlay.Height) then (FLastVfoW <> FVfoOverlay.Width) or (FLastVfoH <> FVfoOverlay.Height) then
begin begin
B := TBitmap.Create; UploadBitmap(FVfoOverlayTex, FVfoOverlay.EnsureRendered, True, VFO_OVERLAY_ALPHA);
try
FVfoOverlay.DrawOverlayBitmap(B);
UploadBitmap(FVfoOverlayTex, B, True, VFO_OVERLAY_ALPHA);
finally
B.Free;
end;
FLastVfoX := FVfoOverlay.Left; FLastVfoX := FVfoOverlay.Left;
FLastVfoY := FVfoOverlay.Top; FLastVfoY := FVfoOverlay.Top;
FLastVfoW := FVfoOverlay.Width; FLastVfoW := FVfoOverlay.Width;
@@ -801,9 +839,76 @@ begin
end; end;
DrawTexture(FVfoOverlayTex, FVfoOverlay.Left, FVfoOverlay.Top); DrawTexture(FVfoOverlayTex, FVfoOverlay.Left, FVfoOverlay.Top);
end; end;
DrawSliceOverlays(W, H);
FOverlayDirty := False; FOverlayDirty := False;
end; 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); procedure TSpectrumViewOpenGL.PaintSpectrum(Sender: TObject);
var var
W, H: Integer; W, H: Integer;
+126 -7
View File
@@ -52,6 +52,7 @@ type
FTx: Boolean; // передача идёт (красный) FTx: Boolean; // передача идёт (красный)
FTxSel: Boolean; // этот слайс выбран для передачи (янтарный) FTxSel: Boolean; // этот слайс выбран для передачи (янтарный)
FSlice: Char; // буква слайса ('A') FSlice: Char; // буква слайса ('A')
FSMeterThreshold: Double; // мин. изменение S-метра (дБ) для перерисовки
FDevNames: TStringList; // имена устройств вывода FDevNames: TStringList; // имена устройств вывода
FDevIdx: array of Integer; // соответствующие PortAudio-индексы (-1=default) FDevIdx: array of Integer; // соответствующие PortAudio-индексы (-1=default)
@@ -72,12 +73,21 @@ type
FOnTxSelect: TNotifyEvent; FOnTxSelect: TNotifyEvent;
FOnInvalidate: TNotifyEvent; FOnInvalidate: TNotifyEvent;
FSliceId: Integer; // логический id слайса (владелец — MainForm/контроллер)
FIsMain: Boolean; // главный приёмник (слайс A) — без кнопки закрытия
FOnSliceSelect: TNotifyEvent; // клик по бейджу буквы — сделать слайс активным
FOnClose: TNotifyEvent; // клик по × (только неглавные) — удалить слайс
FHitRects: array[0..63] of TRect; FHitRects: array[0..63] of TRect;
FHitActions: array[0..63] of Integer; FHitActions: array[0..63] of Integer;
FHitCount: Integer; FHitCount: Integer;
FHotIdx: Integer; FHotIdx: Integer;
FCacheBitmap: TBitmap; FCacheBitmap: TBitmap;
FCacheDirty: Boolean; FCacheDirty: Boolean;
FMeterDirty: Boolean; // изменился только S-метр — перерисовать полоску, не весь флаг
FMeterRect: TRect; // прямоугольник сигнал-бара (запоминается при полном рендере)
FSRowTop: Integer; // верх строки частоты (для S-метки справа)
FLastSTextRect: TRect; // последний прямоугольник S-метки (для аккуратной очистки)
procedure DrawSelf(C: TCanvas; W, H: Integer); procedure DrawSelf(C: TCanvas; W, H: Integer);
procedure DrawButton(C: TCanvas; const R: TRect; const Lbl: string; procedure DrawButton(C: TCanvas; const R: TRect; const Lbl: string;
@@ -100,6 +110,8 @@ type
procedure SetPanel(P: TFlyPanel); procedure SetPanel(P: TFlyPanel);
procedure ApplyVolFromX(LX: Integer); procedure ApplyVolFromX(LX: Integer);
procedure RequestInvalidate; procedure RequestInvalidate;
procedure RequestMeterInvalidate;
procedure RefreshMeter(C: TCanvas);
procedure DoHit(HV: Integer); procedure DoHit(HV: Integer);
protected protected
@@ -114,6 +126,10 @@ type
destructor Destroy; override; destructor Destroy; override;
procedure DrawOverlay(Target: TBitmap; W, H: Integer); procedure DrawOverlay(Target: TBitmap; W, H: Integer);
procedure DrawOverlayBitmap(Target: TBitmap); procedure DrawOverlayBitmap(Target: TBitmap);
// Готовит внутренний кэш-битмап (полный рендер по CacheDirty либо дешёвая
// перерисовка одной полоски метра по MeterDirty) и возвращает его. Один путь
// для CPU (BlendBitmapKey) и GL (UploadBitmap).
function EnsureRendered: TBitmap;
function HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean; function HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
function HandleMouseMove(X, Y: Integer): Boolean; function HandleMouseMove(X, Y: Integer): Boolean;
function HandleMouseUp(X, Y: Integer): Boolean; function HandleMouseUp(X, Y: Integer): Boolean;
@@ -126,6 +142,20 @@ type
const CurName: string); const CurName: string);
procedure UpdateSMeter(DB: Double); procedure UpdateSMeter(DB: Double);
procedure UpdateVfo(AVfoHz: 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 HotIdx: Integer read FHotIdx;
property BaseHeight: Integer read ComputeHeight; property BaseHeight: Integer read ComputeHeight;
property OnSelect: TModeFilterEvent read FOnSelect write FOnSelect; property OnSelect: TModeFilterEvent read FOnSelect write FOnSelect;
@@ -136,6 +166,8 @@ type
property OnAudioDevice: TAudioDeviceEvent read FOnAudioDevice write FOnAudioDevice; property OnAudioDevice: TAudioDeviceEvent read FOnAudioDevice write FOnAudioDevice;
property OnSplit: TNotifyEvent read FOnSplit write FOnSplit; property OnSplit: TNotifyEvent read FOnSplit write FOnSplit;
property OnTxSelect: TNotifyEvent read FOnTxSelect write FOnTxSelect; 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; property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate;
end; end;
@@ -162,6 +194,8 @@ const
HIT_TX = -26; HIT_TX = -26;
HIT_AUD_UP = -27; HIT_AUD_UP = -27;
HIT_AUD_DN = -28; HIT_AUD_DN = -28;
HIT_SLICE_SEL = -29; // клик по бейджу буквы (выбрать слайс активным)
HIT_CLOSE = -30; // × закрытия слайса (неглавные)
HIT_AGC_PICK_BASE = 100000; HIT_AGC_PICK_BASE = 100000;
HIT_DEV_BASE = 200000; HIT_DEV_BASE = 200000;
@@ -338,6 +372,8 @@ begin
FNRMode := 0; FNBMode := 0; FSNBOn := False; FANFOn := False; FAGCMode := 0; FNRMode := 0; FNBMode := 0; FSNBOn := False; FANFOn := False; FAGCMode := 0;
FVolume := 70; FMuted := False; FSplit := False; FTx := False; FVolume := 70; FMuted := False; FSplit := False; FTx := False;
FTxSel := True; FSlice := 'A'; FTxSel := True; FSlice := 'A';
FSliceId := 0; FIsMain := True; // по умолчанию — главный приёмник (слайс A)
FSMeterThreshold := 0.4; // главный флаг — чувствительный метр
FPanel := fpNone; FPanel := fpNone;
FAudScroll := 0; FAudScroll := 0;
FVolDrag := False; FVolDrag := False;
@@ -346,6 +382,8 @@ begin
FCacheBitmap := TBitmap.Create; FCacheBitmap := TBitmap.Create;
FCacheBitmap.PixelFormat := pf32bit; FCacheBitmap.PixelFormat := pf32bit;
FCacheDirty := True; FCacheDirty := True;
FMeterDirty := False;
FLastSTextRect := Rect(0, 0, 0, 0);
Width := OVL_W; Width := OVL_W;
Height := OVL_BASE_H; Height := OVL_BASE_H;
Cursor := crHandPoint; Cursor := crHandPoint;
@@ -373,6 +411,53 @@ begin
if Assigned(FOnInvalidate) then FOnInvalidate(Self); if Assigned(FOnInvalidate) then FOnInvalidate(Self);
end; 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; function TVfoOverlay.FormatFreq(Hz: Double): string;
var var
T, iM, iK, iH: Int64; T, iM, iK, iH: Int64;
@@ -800,8 +885,16 @@ begin
RW := W - PAD*2; RW := W - PAD*2;
CurY := PAD; 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); 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]) if FFilterBW >= 1000 then BWStr := Format('%.1fk', [FFilterBW/1000.0])
else BWStr := IntToStr(FFilterBW); else BWStr := IntToStr(FFilterBW);
@@ -828,10 +921,12 @@ begin
DrawIndicator(C, X + 48, CurY + 2, 'ANF', FANFOn); DrawIndicator(C, X + 48, CurY + 2, 'ANF', FANFOn);
DrawIndicator(C, X + 80, CurY + 2, 'SNB', FSNBOn); DrawIndicator(C, X + 80, CurY + 2, 'SNB', FSNBOn);
MeterX := X + 112; MeterX := X + 112;
FMeterRect := Rect(MeterX, CurY, W - PAD, CurY + MET_H); // для RefreshMeter
DrawMeterBar(C, MeterX, CurY, W - PAD - MeterX, MET_H); DrawMeterBar(C, MeterX, CurY, W - PAD - MeterX, MET_H);
Inc(CurY, MET_H); Inc(CurY, MET_H);
{ --- Частота + S --- } { --- Частота + S --- }
FSRowTop := CurY; // для RefreshMeter
FreqStr := FormatFreq(FVfoHz); FreqStr := FormatFreq(FVfoHz);
C.Font.Name := 'Courier New'; C.Font.Size := 13; C.Font.Style := [fsBold]; C.Font.Name := 'Courier New'; C.Font.Size := 13; C.Font.Style := [fsBold];
C.Font.Color := CLR_FREQ; C.Brush.Style := bsClear; C.Font.Color := CLR_FREQ; C.Brush.Style := bsClear;
@@ -839,6 +934,7 @@ begin
SStr := DBmToSLabel(FSMeterDB); SStr := DBmToSLabel(FSMeterDB);
C.Font.Size := 9; C.Font.Color := CLR_SVAL; C.Font.Size := 9; C.Font.Color := CLR_SVAL;
C.TextOut(W - PAD - C.TextWidth(SStr) - 2, CurY + 6, SStr); 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); Inc(CurY, FRQ_H);
{ --- Нижний бар: 🔊 vol | MODE DSP AUD --- } { --- Нижний бар: 🔊 vol | MODE DSP AUD --- }
@@ -899,11 +995,8 @@ begin
DrawSelf(Canvas, Width, Height); DrawSelf(Canvas, Width, Height);
end; end;
procedure TVfoOverlay.DrawOverlay(Target: TBitmap; W, H: Integer); function TVfoOverlay.EnsureRendered: TBitmap;
begin 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 if (FCacheBitmap.Width <> Width) or (FCacheBitmap.Height <> Height) then
begin begin
FCacheBitmap.SetSize(Width, Height); FCacheBitmap.SetSize(Width, Height);
@@ -917,7 +1010,21 @@ begin
FCacheBitmap.Canvas.FillRect(Rect(0, 0, Width, Height)); FCacheBitmap.Canvas.FillRect(Rect(0, 0, Width, Height));
DrawSelf(FCacheBitmap.Canvas, Width, Height); DrawSelf(FCacheBitmap.Canvas, Width, Height);
FCacheDirty := False; FCacheDirty := False;
FMeterDirty := False;
end
else if FMeterDirty then
begin
RefreshMeter(FCacheBitmap.Canvas); // дешёвый апдейт: только полоска метра
FMeterDirty := False;
end; 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); BlendBitmapKey(Target, FCacheBitmap, Left, Top, VFO_OVERLAY_ALPHA);
end; end;
@@ -932,6 +1039,7 @@ begin
Target.Canvas.FillRect(Rect(0, 0, Width, Height)); Target.Canvas.FillRect(Rect(0, 0, Width, Height));
DrawSelf(Target.Canvas, Width, Height); DrawSelf(Target.Canvas, Width, Height);
FCacheDirty := False; FCacheDirty := False;
FMeterDirty := False;
end; end;
{ ---- Обработка хитов ---- } { ---- Обработка хитов ---- }
@@ -949,6 +1057,8 @@ begin
if Assigned(FOnMute) then FOnMute(Self); Exit; end; if Assigned(FOnMute) then FOnMute(Self); Exit; end;
HIT_SPLIT: begin if Assigned(FOnSplit) then FOnSplit(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_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); HIT_AUD_UP: begin if FAudScroll > 0 then Dec(FAudScroll);
RequestInvalidate; Exit; end; RequestInvalidate; Exit; end;
HIT_AUD_DN: begin if FAudScroll < FDevNames.Count - AUD_MAX_VIS then HIT_AUD_DN: begin if FAudScroll < FDevNames.Count - AUD_MAX_VIS then
@@ -1174,6 +1284,15 @@ begin
RequestInvalidate; RequestInvalidate;
end; 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; procedure TVfoOverlay.SetAudioDevices(Names: TStrings; const Indices: array of Integer;
const CurName: string); const CurName: string);
var var
@@ -1190,10 +1309,10 @@ end;
procedure TVfoOverlay.UpdateSMeter(DB: Double); procedure TVfoOverlay.UpdateSMeter(DB: Double);
begin begin
if Abs(DB - FSMeterDB) > 0.4 then if Abs(DB - FSMeterDB) > FSMeterThreshold then
begin begin
FSMeterDB := DB; FSMeterDB := DB;
RequestInvalidate; RequestMeterInvalidate; // только полоска метра, без ре-рендера всего флага
end; end;
end; end;
+517 -81
View File
@@ -96,6 +96,10 @@ const
RXA_CHAN = 0; RXA_CHAN = 0;
TXA_CHAN = 1; // используем разные каналы для RX и TX 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-спектра/водопада RX_DISP_ID = 0; // analyzer для RX-спектра/водопада
TX_DISP_ID = 1; // analyzer для TX-спектра/водопада TX_DISP_ID = 1; // analyzer для TX-спектра/водопада
DSP_BUFSIZE = 1024; DSP_BUFSIZE = 1024;
@@ -135,6 +139,10 @@ type
TOnTXIQReady = procedure(const Buf: array of Double; Count: Integer) of object; TOnTXIQReady = procedure(const Buf: array of Double; Count: Integer) of object;
TOnWaterfallReady = procedure(const Pixels: array of Single; TOnWaterfallReady = procedure(const Pixels: array of Single;
Count: Integer) of object; 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 дёргает этот колбэк перед каждым тиком, // Sound-card mic source: TTXDSPThread дёргает этот колбэк перед каждым тиком,
// чтобы вытянуть накопленные сэмплы из внешнего буфера (например PortAudio). // чтобы вытянуть накопленные сэмплы из внешнего буфера (например PortAudio).
// Реализация должна вернуть кол-во отправленных в WDSP сэмплов. // Реализация должна вернуть кол-во отправленных в WDSP сэмплов.
@@ -143,6 +151,28 @@ type
// TX mic source: откуда получаем сэмплы для модулятора. // TX mic source: откуда получаем сэмплы для модулятора.
TTXMicSource = (txmsRadio, txmsSoundCard, txmsWeb); 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 }
TWDSPEngine = class TWDSPEngine = class
private private
@@ -219,6 +249,15 @@ type
FOnWaterfall: TOnWaterfallReady; FOnWaterfall: TOnWaterfallReady;
FOnTXIQ: TOnTXIQReady; 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, // TX mic ring buffer (lock-free: один writer — receive thread,
// один reader — TX DSP thread) // один reader — TX DSP thread)
FTXMicRing: array[0..TX_MIC_RING-1] of Double; FTXMicRing: array[0..TX_MIC_RING-1] of Double;
@@ -311,6 +350,13 @@ type
function ModeToWDSP(Mode: Integer): Integer; function ModeToWDSP(Mode: Integer): Integer;
procedure ApplyDefaultFilter; 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 FeedDisplaySample(const I, Q: Double);
procedure ProcessRXBlock; procedure ProcessRXBlock;
procedure PushIQItemToDSP(const Item: TIQQueueItem); procedure PushIQItemToDSP(const Item: TIQQueueItem);
@@ -329,6 +375,10 @@ type
procedure ApplyNBState; procedure ApplyNBState;
procedure ApplySNBState; procedure ApplySNBState;
procedure ApplyANFState; 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 ApplyNoiseFilterState;
procedure ProcessTXBlock; // вызывается из TTXDSPThread procedure ProcessTXBlock; // вызывается из TTXDSPThread
procedure FeedTXDisplaySample(const I, Q: Double); procedure FeedTXDisplaySample(const I, Q: Double);
@@ -350,6 +400,26 @@ type
// QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет). // QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет).
procedure SetBeaconDecoder(D: TBeaconDecoder); 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 пакета // Подача 24-bit big-endian IQ из DDC пакета
// Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf // Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf
procedure PushDDCPacket(const Buf: array of Byte; procedure PushDDCPacket(const Buf: array of Byte;
@@ -475,6 +545,7 @@ type
property WfAvgMode: Integer read FWfAvgMode; property WfAvgMode: Integer read FWfAvgMode;
property WfAvgTimeMS: Double read FWfAvgTimeMS; property WfAvgTimeMS: Double read FWfAvgTimeMS;
property OnAudio: TOnAudioReady read FOnAudio write FOnAudio; property OnAudio: TOnAudioReady read FOnAudio write FOnAudio;
property OnSliceAudio: TOnSliceAudio read FOnSliceAudio write FOnSliceAudio;
property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum; property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum;
property OnWaterfall: TOnWaterfallReady read FOnWaterfall write FOnWaterfall; property OnWaterfall: TOnWaterfallReady read FOnWaterfall write FOnWaterfall;
property OnTXIQ: TOnTXIQReady read FOnTXIQ write FOnTXIQ; property OnTXIQ: TOnTXIQReady read FOnTXIQ write FOnTXIQ;
@@ -813,6 +884,12 @@ begin
FDSPThread := nil; FDSPThread := nil;
FDisplayThread := nil; FDisplayThread := nil;
// Мультислайсы
FSliceLock := TCriticalSection.Create;
FillChar(FSlices, SizeOf(FSlices), 0);
FOnSliceAudio := nil;
AllocSliceBuffers;
// TX инициализация // TX инициализация
FTXMicHead := 0; FTXMicHead := 0;
FTXMicTail := 0; FTXMicTail := 0;
@@ -887,6 +964,7 @@ begin
Close; Close;
RTLEventDestroy(FQueueSem); RTLEventDestroy(FQueueSem);
RTLEventDestroy(FTXMicSem); RTLEventDestroy(FTXMicSem);
FSliceLock.Free;
inherited; inherited;
end; end;
@@ -1052,6 +1130,7 @@ end;
function TWDSPEngine.Open: Boolean; function TWDSPEngine.Open: Boolean;
var var
DllPath: string; DllPath: string;
i: Integer;
begin begin
Result := False; Result := False;
FLastError := ''; FLastError := '';
@@ -1131,6 +1210,18 @@ begin
// Leveler, ALC, Compressor, Phase Rotator, AM Carrier, FM, CTCSS. // Leveler, ALC, Compressor, Phase Rotator, AM Carrier, FM, CTCSS.
ApplyTXChainSettings; ApplyTXChainSettings;
ApplyNoiseFilterState; 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; Result := True;
// Запускаем DSP поток после успешной инициализации WDSP // Запускаем DSP поток после успешной инициализации WDSP
@@ -1149,6 +1240,8 @@ begin
end; end;
procedure TWDSPEngine.Close; procedure TWDSPEngine.Close;
var
i: Integer;
begin begin
if not FInitialized then Exit; if not FInitialized then Exit;
@@ -1161,6 +1254,15 @@ begin
FreeAndNil(FDSPThread); FreeAndNil(FDSPThread);
end; 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; CloseAnalyzer;
destroy_anbEXT(RXA_CHAN); destroy_anbEXT(RXA_CHAN);
destroy_nobEXT(RXA_CHAN); destroy_nobEXT(RXA_CHAN);
@@ -1195,6 +1297,7 @@ begin
SetLength(FRXAccQ, FBufSize); SetLength(FRXAccQ, FBufSize);
SetLength(FDispBuf, DISPLAY_BLOCK_SIZE * 2); SetLength(FDispBuf, DISPLAY_BLOCK_SIZE * 2);
FDispPos := 0; FDispPos := 0;
AllocSliceBuffers;
// Переинициализируем WDSP с новым rate // Переинициализируем WDSP с новым rate
Open; Open;
@@ -1379,11 +1482,360 @@ begin
FOnAudio(FOutL, FOutR, FAudioBufSize); FOnAudio(FOutL, FOutR, FAudioBufSize);
end; end;
// Софтовые слайсы: fan-out того же IQ-блока на доп. WDSP-каналы.
ProcessSlices;
except except
// Защита от падения libwdsp в потоке — просто пропускаем блок // Защита от падения libwdsp в потоке — просто пропускаем блок
end; end;
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 управление // RX управление
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1412,42 +1864,7 @@ begin
if not FInitialized then Exit; if not FInitialized then Exit;
// Точно по piHPSDR receiver.c: set_agc() // Точно по piHPSDR receiver.c: set_agc()
SetRXAAGCMode(RXA_CHAN, Ord(Mode)); ApplyAGCToChan(RXA_CHAN, Mode, FixedGain);
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;
// Читаем обратно hang level и thresh для линии на спектре // Читаем обратно hang level и thresh для линии на спектре
UpdateAGCLines(FLastSpectrumW); UpdateAGCLines(FLastSpectrumW);
@@ -1502,69 +1919,88 @@ begin
FSampleRate); FSampleRate);
end; 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; procedure TWDSPEngine.ApplyNRState;
begin begin
if not FInitialized then Exit; if not FInitialized then Exit;
ApplyNRToChan(RXA_CHAN, FNRMode);
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));
end; end;
procedure TWDSPEngine.ApplyNBState; procedure TWDSPEngine.ApplyNBState;
begin begin
if not FInitialized then Exit; if not FInitialized then Exit;
ApplyNBToChan(RXA_CHAN, FNBMode);
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));
end; end;
procedure TWDSPEngine.ApplySNBState; procedure TWDSPEngine.ApplySNBState;
begin begin
if not FInitialized then Exit; if not FInitialized then Exit;
SetRXASNBARun(RXA_CHAN, Ord(FSNBEnabled)); ApplySNBToChan(RXA_CHAN, FSNBEnabled);
end; end;
procedure TWDSPEngine.ApplyANFState; procedure TWDSPEngine.ApplyANFState;
begin begin
if not FInitialized then Exit; if not FInitialized then Exit;
SetRXAANFPosition(RXA_CHAN, FILTER_NR_AGC_POSITION); ApplyANFToChan(RXA_CHAN, FANFEnabled);
SetRXAANFRun(RXA_CHAN, Ord(FANFEnabled));
end; end;
procedure TWDSPEngine.ApplyNoiseFilterState; 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"/> <UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/> <AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/> <MinorVersionNr Value="9"/>
<BuildNr Value="215"/> <BuildNr Value="232"/>
</VersionInfo> </VersionInfo>
<MacroValues Count="27"> <MacroValues Count="50">
<Macro1 Name="LCLWidgetType" Value="qt6"/> <Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/> <Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/> <Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -47,6 +47,29 @@
<Macro25 Name="LCLWidgetType" Value="qt6"/> <Macro25 Name="LCLWidgetType" Value="qt6"/>
<Macro26 Name="LCLWidgetType" Value="qt6"/> <Macro26 Name="LCLWidgetType" Value="qt6"/>
<Macro27 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> </MacroValues>
<BuildModes> <BuildModes>
<Item Name="Debug" Default="True"/> <Item Name="Debug" Default="True"/>