From 1527b0f84938edfd568fbd0be411c6d30857b6e4 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 1 Jul 2026 23:46:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(slices):=20multi-slice=20receivers=20(Flex?= =?UTF-8?q?Radio-style)=20=E2=80=94=20soft=20fan-out=20+=20per-slice=20fla?= =?UTF-8?q?g/audio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- MainForm.pas | 570 +++++++++++++++++++++++++++++++++++++-- RadioController.pas | 336 ++++++++++++++++++++++- SpectrumView.pas | 90 ++++++- SpectrumViewOpengl.pas | 119 +++++++- VfoOverlay.pas | 133 ++++++++- WDSPEngine.pas | 598 +++++++++++++++++++++++++++++++++++------ doc/SLICES_PLAN.md | 181 +++++++++++++ ewsdr.lpi | 27 +- 8 files changed, 1915 insertions(+), 139 deletions(-) create mode 100644 doc/SLICES_PLAN.md diff --git a/MainForm.pas b/MainForm.pas index 48d9b1e..93da27e 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -122,7 +122,14 @@ type // Список устройств (saved+discovered) → FController.FDeviceStore (общий с web). FDeviceDialog: TDeviceDialog; FPendingIP: string; // IP устройства для подключения - FVfoOverlay: TVfoOverlay; // накладка SmartSDR-стиль на спектре + FVfoOverlay: TVfoOverlay; // накладка SmartSDR-стиль на спектре (слайс A / главный) + FSliceFlags: array of TVfoOverlay; // флаги доп. слайсов (B+) + FActiveFlag: TVfoOverlay; // флаг, чьё событие сейчас обрабатывается (для роутинга по SliceId) + FActiveSliceId: Integer; // выбранный активный слайс (0 = главный) + FPendingSliceClose: Integer; // SliceId к удалению после возврата из мыши (0 = нет) + FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру + FSliceDragId: Integer; // какой слайс тащим + FLastSliceMeterMs: QWord; // троттлинг S-метра слайсов (~10 Гц) FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра FPanelHidden: Boolean; // True = левая панель скрыта @@ -516,6 +523,7 @@ type // ------------------------------------------------------------------------- procedure BtnSettingsClick(Sender: TObject); procedure PositionVfoOverlay; + procedure UpdateVfoOverlayVisibility; // флаг A виден: панель скрыта ИЛИ есть слайсы B+ procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer); procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); procedure OnVfoOverlayAGCChange(AGCMode: Integer); @@ -524,9 +532,26 @@ type procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string); procedure OnVfoOverlaySplit(Sender: TObject); procedure OnVfoOverlayTxSelect(Sender: TObject); + procedure OnVfoOverlaySliceSelect(Sender: TObject); // выбрать слайс активным + procedure OnVfoOverlayClose(Sender: TObject); // × — удалить слайс + procedure ProcessPendingSliceClose; // отложенное удаление флага procedure PushVfoOverlayExtState; procedure PushVfoOverlayAudioDevices; procedure OnVfoOverlayInvalidate(Sender: TObject); + // --- Мультислайсы (UI) --- + function CurSliceId: Integer; // SliceId обрабатываемого сейчас флага + procedure WireOverlayEvents(O: TVfoOverlay); + procedure CreateSliceFlag(SliceId: Integer); + procedure DestroySliceFlag(SliceId: Integer); + function FindSliceFlag(SliceId: Integer): TVfoOverlay; + procedure AddSliceAtFreq(Hz: Double); + procedure PositionSliceFlags; + procedure PushSliceFlagState(SliceId: Integer); + function DispatchFlagsMouseDown(Button: TMouseButton; X, Y: Integer): Boolean; + procedure DispatchFlagsMouseMove(X, Y: Integer); + function DispatchFlagsMouseUp(X, Y: Integer): Boolean; + procedure DispatchFlagsMouseLeave; + function SliceAtPixel(X: Integer): Integer; // SliceId, чья несущая под X (0 = нет) procedure PbSpectrumDblClick(Sender: TObject); procedure TrkDriveChange(Sender: TObject); procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive @@ -960,16 +985,14 @@ begin FDeviceDialog.Store := FController.FDeviceStore; FDeviceDialog.OnDiscover := BtnDiscoverFromDialog; FVfoOverlay := TVfoOverlay.Create(Self); - FVfoOverlay.OnSelect := OnModeFilterSelect; - FVfoOverlay.OnDSPChange := OnVfoOverlayDSPChange; - FVfoOverlay.OnAGCChange := OnVfoOverlayAGCChange; - FVfoOverlay.OnVolume := OnVfoOverlayVolume; - FVfoOverlay.OnMute := OnVfoOverlayMute; - FVfoOverlay.OnAudioDevice := OnVfoOverlayAudioDevice; - FVfoOverlay.OnSplit := OnVfoOverlaySplit; - FVfoOverlay.OnTxSelect := OnVfoOverlayTxSelect; - FVfoOverlay.OnInvalidate := OnVfoOverlayInvalidate; + WireOverlayEvents(FVfoOverlay); + FVfoOverlay.SetSliceInfo(0, 'A', True); // главный приёмник = слайс A FVfoOverlay.Visible := False; + FActiveSliceId := 0; + FActiveFlag := nil; + FPendingSliceClose := 0; + FSliceDrag := False; + FSliceDragId := 0; FPanelHidden := False; FController.FShowSpectrum := True; FController.FShowWaterfall := True; @@ -1413,6 +1436,7 @@ begin SyncSpecViewFreq; UpdatePanZoomBar; PositionVfoOverlay; + PositionSliceFlags; FSpecView.DrawSpectrum; PbSpectrum.Invalidate; FSpecView.DrawWaterfall; @@ -1497,6 +1521,16 @@ begin PushDeviceListToWeb; // web-overlay тоже получает обновлённый список end; end; + // Если флаг A виден при ОТКРЫТОЙ панели (есть слайсы B+), держим его в + // синхроне с DSP/режимом/полосой/AGC, пришедшими с ЛЕВОЙ панели. При скрытой + // панели флаг правит себя сам (DoHit), синк не нужен. Только дискретные поля — + // SetState/SetDSPState безусловно инвалидируют (не звать на потоковых rfVfoA). + if Assigned(FVfoOverlay) and FVfoOverlay.Visible and (not FPanelHidden) and + (Field in [rfMode, rfFilter, rfNR, rfNB, rfSNB, rfANF, rfAGCMode]) then + begin + FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter); + FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode); + end; finally FSyncingFromController := False; end; @@ -3188,6 +3222,7 @@ procedure TMainForm.SpectrumTimerTick(Sender: TObject); var NowTick: QWord; ElapsedTick: QWord; + I: Integer; begin // Синхронизируем размеры если размер панели изменился (после ресайза). // Важно: проверяем только видимые панели — когда спектр скрыт, @@ -3266,6 +3301,19 @@ begin PushVfoOverlayExtState; PositionVfoOverlay; end; + if Length(FSliceFlags) > 0 then + begin + PositionSliceFlags; // держим слайс-флаги на частоте + // S-метр слайсов троттлим до ~10 Гц: каждое изменение метра = софтовый + // ре-рендер флага (дорого), 60 Гц там не нужно. + if GetTickCount64 - FLastSliceMeterMs >= 100 then + begin + FLastSliceMeterMs := GetTickCount64; + for I := 0 to High(FSliceFlags) do // S-метр каждого слайса из его канала + if FSliceFlags[I] <> nil then + FSliceFlags[I].UpdateSMeter(FController.SliceSMeter(FSliceFlags[I].SliceId)); + end; + end; // Панорама и водопад теперь рисуются независимо по факту новых кадров. // Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще. if FSpectrumDirty then @@ -4421,11 +4469,22 @@ procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); var VC, VS: Double; begin - if Assigned(FVfoOverlay) and - FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit; + if DispatchFlagsMouseDown(Button, X, Y) then + begin + ProcessPendingSliceClose; // × мог пометить слайс к удалению + Exit; + end; if Assigned(FSampleRateOverlay) and FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit; + // Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата). + if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then + begin + FController.GetViewWindow(VC, VS); + AddSliceAtFreq(PixelToFreq(X, PbSpectrum.Width, VC, VS)); + Exit; + end; + if Button = mbLeft then begin // Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено», @@ -4441,6 +4500,18 @@ begin UpdateBeaconButton; Exit; end; + // ЛКМ по маркеру несущей слайса — начинаем его перетаскивание (перетюн). + if (PbSpectrum.Width > 0) then + begin + FSliceDragId := SliceAtPixel(X); + if FSliceDragId <> 0 then + begin + FSliceDrag := True; + FActiveSliceId := FSliceDragId; + PbSpectrum.Cursor := crSizeWE; + Exit; + end; + end; // Левая кнопка: сбрасываем маркер если активен if FSpecView.MarkerActive then begin @@ -4485,18 +4556,40 @@ end; procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); +var + VC, VS, NewHz, LoHz, HiHz: Double; begin + // Перетаскивание маркера несущей слайса — перетюн в пределах захваченной полосы. + if FSliceDrag then + begin + if (ssLeft in Shift) and (PbSpectrum.Width > 0) then + begin + FController.GetViewWindow(VC, VS); + NewHz := PixelToFreq(X, PbSpectrum.Width, VC, VS); + FController.CapturedSpan(LoHz, HiHz); + if NewHz < LoHz then NewHz := LoHz; + if NewHz > HiHz then NewHz := HiHz; + FController.SetSliceTarget(FSliceDragId, NewHz); + PushSliceFlagState(FSliceDragId); // обновить частоту во флаге + PositionSliceFlags; + FSpectrumDirty := True; + PbSpectrum.Invalidate; + end; + Exit; + end; + if Assigned(FSampleRateOverlay) then begin FSampleRateOverlay.HandleMouseMove(X, Y); end; - if Assigned(FVfoOverlay) then - FVfoOverlay.HandleMouseMove(X, Y); + DispatchFlagsMouseMove(X, Y); if not FSpecDrag then begin if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or (Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then PbSpectrum.Cursor := crHandPoint + else if SliceAtPixel(X) <> 0 then + PbSpectrum.Cursor := crSizeWE // над маркером слайса — его можно тащить else PbSpectrum.Cursor := crDefault; end; @@ -4514,7 +4607,14 @@ end; procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin - if Assigned(FVfoOverlay) and FVfoOverlay.HandleMouseUp(X, Y) then + if FSliceDrag then + begin + FSliceDrag := False; + FSliceDragId := 0; + PbSpectrum.Cursor := crDefault; + Exit; + end; + if DispatchFlagsMouseUp(X, Y) then begin FSpecDrag := False; PbSpectrum.Cursor := crDefault; @@ -4533,8 +4633,7 @@ procedure TMainForm.PbSpectrumMouseLeave(Sender: TObject); begin if Assigned(FSampleRateOverlay) then FSampleRateOverlay.HandleMouseLeave; - if Assigned(FVfoOverlay) then - FVfoOverlay.HandleMouseLeave; + DispatchFlagsMouseLeave; if not FSpecDrag then PbSpectrum.Cursor := crDefault; end; @@ -5008,6 +5107,28 @@ begin begin FLeftPanelW := PanelLeft.Width; // сохраняем реальную ширину (учитывает DPI на Win32) PanelLeft.Width := 0; + end + else + PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину + UpdateVfoOverlayVisibility; // видимость флага A решает общий инвариант + if Assigned(FSampleRateOverlay) then + FSampleRateOverlay.SetPanelHidden(FPanelHidden); + ResizeSpectrumPanels; + ResizeSMeter; +end; + +// Флаг главного приёмника (слайс A) виден, когда СКРЫТА левая панель ЛИБО есть +// доп. слайсы (B+): тогда A всегда представлен флагом на спектре рядом с B/C…, +// а сворачивание/раскрытие панели при живых слайсах не дёргает его появление. +procedure TMainForm.UpdateVfoOverlayVisibility; +var + ShouldShow: Boolean; +begin + if not Assigned(FVfoOverlay) then Exit; + ShouldShow := FPanelHidden or (Length(FSliceFlags) > 0); + if ShouldShow = FVfoOverlay.Visible then Exit; + if ShouldShow then + begin FVfoOverlay.Width := OVL_W; FVfoOverlay.Height := FVfoOverlay.BaseHeight; // растёт сам при открытии fly-out FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter); @@ -5019,14 +5140,9 @@ begin end else begin - PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину FVfoOverlay.HandleMouseLeave; FVfoOverlay.Visible := False; end; - if Assigned(FSampleRateOverlay) then - FSampleRateOverlay.SetPanelHidden(FPanelHidden); - ResizeSpectrumPanels; - ResizeSMeter; end; procedure TMainForm.PositionVfoOverlay; @@ -5089,6 +5205,14 @@ procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer); var Idx: Integer; begin + // Событие пришло от флага слайса (B+) — маршрутизируем в slice-API, главный не трогаем. + if CurSliceId <> 0 then + begin + FController.SetSliceModeBW(CurSliceId, Mode, FilterBW); + PushSliceFlagState(CurSliceId); + PositionSliceFlags; + Exit; + end; FController.FMode := Mode; FController.FFilterBW := FilterBW; if Mode = MODE_FM then @@ -5118,6 +5242,11 @@ end; procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); begin + if CurSliceId <> 0 then + begin + FController.SetSliceDSP(CurSliceId, NRMode, NBMode, SNBOn, ANFOn); + Exit; + end; // Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки. FController.SetNR(NRMode); FController.SetNB(NBMode); @@ -5127,34 +5256,59 @@ end; procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer); begin + if CurSliceId <> 0 then + begin + FController.SetSliceAGCMode(CurSliceId, TRadioController.AGCModeFromUI(AGCMode)); + Exit; + end; FController.SetAGCMode(AGCMode); end; procedure TMainForm.OnVfoOverlayVolume(Vol: Integer); begin + if CurSliceId <> 0 then + begin + FController.SetSliceVolume(CurSliceId, Vol / 100.0); + Exit; + end; FController.SetVolume(Vol); end; procedure TMainForm.OnVfoOverlayMute(Sender: TObject); +var S: TCtrlSlice; begin + if CurSliceId <> 0 then + begin + if FController.GetSlice(CurSliceId, S) then + FController.SetSliceMute(CurSliceId, not S.Muted); + Exit; + end; FController.ToggleMute; end; procedure TMainForm.OnVfoOverlaySplit(Sender: TObject); begin + if CurSliceId <> 0 then Exit; // SPLIT — только главный (слайсы не передают) FController.SetSplit(not FController.FSplitTxB); PushVfoOverlayExtState; end; procedure TMainForm.OnVfoOverlayTxSelect(Sender: TObject); begin - // TODO(слайсы): выбрать этот слайс как TX-слайс. Пока один слайс — - // он всегда TX-слайс, поэтому клик ничего не меняет (хук под мультислайсы). + // TODO(слайсы): выбор TX-слайса при полноценной мультислайс-передаче. + // Пока передаёт только главный канал — клик по TX неглавного игнорируем. + if CurSliceId <> 0 then Exit; PushVfoOverlayExtState; end; procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string); begin + if CurSliceId <> 0 then + begin + FController.SetSliceDevice(CurSliceId, DevIndex, DevName); + PushSliceFlagState(CurSliceId); // обновить подсветку текущего устройства слайса + Exit; + end; ApplyAudioDevice(DevIndex, DevName); PushVfoOverlayAudioDevices; // обновить подсветку текущего устройства end; @@ -5193,9 +5347,326 @@ begin if Assigned(FSpecView) then begin FSpecView.SpectrumDirty := True; - FSpecView.InvalidateOverlayCache; + // Полная инвалидация кэша текстур оверлеев (band/samplerate/главный VFO) нужна + // ТОЛЬКО когда у главного оверлея изменился хром (CacheDirty). Если сдвинулся + // лишь S-метр (MeterDirty) — перезаливаем только текстуру главного флага, не + // дёргая band/samplerate/слайсы. Слайсы перезаливают свою текстуру сами. + if Sender = TObject(FVfoOverlay) then + begin + if FVfoOverlay.CacheDirty then + FSpecView.InvalidateOverlayCache + else + FSpecView.InvalidateVfoOverlay; + end; end; - if Assigned(PbSpectrum) then PbSpectrum.Invalidate; + if Assigned(PbSpectrum) then + begin + PbSpectrum.Invalidate; + end; +end; + +// =========================================================================== +// Мультислайсы (UI): коллекция флагов, роутинг событий по SliceId +// =========================================================================== + +// SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A). +function TMainForm.CurSliceId: Integer; +begin + if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0; +end; + +// Единая проводка событий флага (главного и слайсов) на общие обработчики. +procedure TMainForm.WireOverlayEvents(O: TVfoOverlay); +begin + O.OnSelect := OnModeFilterSelect; + O.OnDSPChange := OnVfoOverlayDSPChange; + O.OnAGCChange := OnVfoOverlayAGCChange; + O.OnVolume := OnVfoOverlayVolume; + O.OnMute := OnVfoOverlayMute; + O.OnAudioDevice := OnVfoOverlayAudioDevice; + O.OnSplit := OnVfoOverlaySplit; + O.OnTxSelect := OnVfoOverlayTxSelect; + O.OnSliceSelect := OnVfoOverlaySliceSelect; + O.OnClose := OnVfoOverlayClose; + O.OnInvalidate := OnVfoOverlayInvalidate; +end; + +function TMainForm.FindSliceFlag(SliceId: Integer): TVfoOverlay; +var i: Integer; +begin + Result := nil; + for i := 0 to High(FSliceFlags) do + if (FSliceFlags[i] <> nil) and (FSliceFlags[i].SliceId = SliceId) then + Exit(FSliceFlags[i]); +end; + +procedure TMainForm.CreateSliceFlag(SliceId: Integer); +var + O: TVfoOverlay; + S: TCtrlSlice; +begin + if FindSliceFlag(SliceId) <> nil then Exit; + if not FController.GetSlice(SliceId, S) then Exit; + O := TVfoOverlay.Create(Self); + WireOverlayEvents(O); + O.SetSliceInfo(SliceId, S.Letter, False); + O.SMeterThreshold := 1.5; // слайсы: грубее метр → меньше дорогих ре-рендеров флага + O.Width := OVL_W; + O.Height := O.BaseHeight; + O.Visible := True; // флаги слайсов видны всегда (не зависят от скрытия панели) + SetLength(FSliceFlags, Length(FSliceFlags) + 1); + FSliceFlags[High(FSliceFlags)] := O; + FSpecView.AddSliceOverlay(O); + PushSliceFlagState(SliceId); + UpdateVfoOverlayVisibility; // появился слайс B+ → показать и флаг A (если панель открыта) + PositionSliceFlags; +end; + +procedure TMainForm.DestroySliceFlag(SliceId: Integer); +var + i, j: Integer; + O: TVfoOverlay; +begin + O := FindSliceFlag(SliceId); + if O = nil then Exit; + FController.RemoveSlice(SliceId); + FSpecView.RemoveSliceOverlay(O); + // Убираем из массива со сжатием. + j := 0; + for i := 0 to High(FSliceFlags) do + if FSliceFlags[i] <> O then + begin + FSliceFlags[j] := FSliceFlags[i]; + Inc(j); + end; + SetLength(FSliceFlags, j); + O.Free; + if FActiveSliceId = SliceId then FActiveSliceId := 0; + UpdateVfoOverlayVisibility; // закрыт последний слайс при открытой панели → скрыть флаг A + OnVfoOverlayInvalidate(nil); +end; + +// Добавить софт-слайс на частоте Hz (режим/полоса копируются с главного). +procedure TMainForm.AddSliceAtFreq(Hz: Double); +var + Lo, Hi, Id: Integer; +begin + if not FController.SliceFitsCapture(Hz) then Exit; // вне захваченной полосы (см. Этап 3) + TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi); + Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi, + TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, ''); + if Id < 0 then Exit; + CreateSliceFlag(Id); + FActiveSliceId := Id; +end; + +// SliceId, чья несущая под пикселем X (в пределах допуска), 0 если нет. +function TMainForm.SliceAtPixel(X: Integer): Integer; +const TOL = 6; +var + i, CarrierX: Integer; + VC, VS: Double; + S: TCtrlSlice; + O: TVfoOverlay; +begin + Result := 0; + if (PbSpectrum = nil) or (PbSpectrum.Width <= 0) then Exit; + FController.GetViewWindow(VC, VS); + if VS <= 0 then Exit; + for i := 0 to High(FSliceFlags) do + begin + O := FSliceFlags[i]; + if (O = nil) or (not O.Visible) then Continue; + if not FController.GetSlice(O.SliceId, S) then Continue; + CarrierX := Round((S.TargetHz - VC + VS / 2) / VS * PbSpectrum.Width); + if Abs(X - CarrierX) <= TOL then Exit(O.SliceId); + end; +end; + +procedure TMainForm.PositionSliceFlags; +var + i, VfoX, OW, NewLeft, NewTop, FilterStartX, FilterEndX, BWs: Integer; + VC, VS, LoHz, HiHz: Double; + S: TCtrlSlice; + O: TVfoOverlay; + Moved: Boolean; +begin + if (Length(FSliceFlags) = 0) or (PbSpectrum = nil) then Exit; + Moved := False; + FController.GetViewWindow(VC, VS); + for i := 0 to High(FSliceFlags) do + begin + O := FSliceFlags[i]; + if (O = nil) or (not O.Visible) then Continue; + if not FController.GetSlice(O.SliceId, S) then Continue; + OW := O.Width; + if (PbSpectrum.Width > 0) and (VS > 0) then + VfoX := Round((S.TargetHz - VC + VS / 2) / VS * PbSpectrum.Width) + else + VfoX := PbSpectrum.Width div 2; + // Кромки полосы фильтра слайса относительно несущей — как у главного флага + // (PositionVfoOverlay): флаг ставится СБОКУ от полосы, а не от несущей. + case S.Mode of + MODE_LSB: BWs := -S.FilterLo; + MODE_USB: BWs := S.FilterHi; + else BWs := S.FilterHi - S.FilterLo; + end; + case S.Mode of + MODE_LSB: begin LoHz := -BWs; HiHz := -100; end; + MODE_USB: begin LoHz := 100; HiHz := BWs; end; + else begin LoHz := -BWs / 2; HiHz := BWs / 2; end; + end; + if (PbSpectrum.Width > 0) and (VS > 0) then + begin + FilterStartX := VfoX + Round(LoHz / VS * PbSpectrum.Width); + FilterEndX := VfoX + Round(HiHz / VS * PbSpectrum.Width); + end + else + begin + FilterStartX := VfoX; + FilterEndX := VfoX; + end; + FilterStartX := Max(0, Min(PbSpectrum.Width - 1, FilterStartX)); + FilterEndX := Max(0, Min(PbSpectrum.Width - 1, FilterEndX)); + // По умолчанию справа от полосы; если не влезает — слева от неё. + NewLeft := FilterEndX + 6; + if NewLeft + OW > PbSpectrum.Width - 4 then NewLeft := FilterStartX - 6 - OW; + NewLeft := Max(4, Min(PbSpectrum.Width - OW - 4, NewLeft)); + NewTop := 16 + i * 12; // лёгкий вертикальный разнос, чтобы флаги не слипались + if (O.Left <> NewLeft) or (O.Top <> NewTop) then + begin + O.Left := NewLeft; + O.Top := NewTop; + Moved := True; + end; + end; + // Инвалидируем спектр только если флаг реально сдвинулся (ретюн/зум/пан). + // Раньше звали безусловно каждый тик → форс полной перерисовки на пустом месте. + if Moved then + begin + OnVfoOverlayInvalidate(nil); + end; +end; + +// Заливает во флаг слайса его текущее состояние из контроллера. +procedure TMainForm.PushSliceFlagState(SliceId: Integer); +var + O: TVfoOverlay; + S: TCtrlSlice; + BW, Cnt, I: Integer; + Names: TStringList; + Indices: array[0..63] of Integer; + Idx: array of Integer; +begin + O := FindSliceFlag(SliceId); + if (O = nil) or not FController.GetSlice(SliceId, S) then Exit; + case S.Mode of + MODE_LSB: BW := -S.FilterLo; + MODE_USB: BW := S.FilterHi; + else BW := S.FilterHi - S.FilterLo; + end; + O.SetState(S.Mode, BW, S.TargetHz, FController.SliceSMeter(SliceId)); + O.SetDSPState(0, 0, False, False, FController.FAGCMode); + O.SetExtState(Round(S.Volume * 100), S.Muted, False, False, False); + // Список аудио-устройств (общий PA-список) + текущее устройство слайса. + if Assigned(FController.FAudioOut) then + begin + Names := TStringList.Create; + try + Cnt := 0; + FController.FAudioOut.EnumOutputDevices(Names, Indices, Cnt); + SetLength(Idx, Cnt); + for I := 0 to Cnt - 1 do Idx[I] := Indices[I]; + O.SetAudioDevices(Names, Idx, S.DevName); + finally + Names.Free; + end; + end; +end; + +procedure TMainForm.OnVfoOverlaySliceSelect(Sender: TObject); +begin + if Sender is TVfoOverlay then + FActiveSliceId := TVfoOverlay(Sender).SliceId; +end; + +procedure TMainForm.OnVfoOverlayClose(Sender: TObject); +begin + if not (Sender is TVfoOverlay) then Exit; + if TVfoOverlay(Sender).IsMain then Exit; + // Нельзя освобождать флаг внутри его же обработчика мыши — помечаем к удалению, + // ProcessPendingSliceClose снимет его после возврата из диспетчера. + FPendingSliceClose := TVfoOverlay(Sender).SliceId; +end; + +procedure TMainForm.ProcessPendingSliceClose; +var Id: Integer; +begin + if FPendingSliceClose = 0 then Exit; + Id := FPendingSliceClose; + FPendingSliceClose := 0; + DestroySliceFlag(Id); +end; + +function TMainForm.DispatchFlagsMouseDown(Button: TMouseButton; X, Y: Integer): Boolean; +var i: Integer; +begin + Result := False; + if Assigned(FVfoOverlay) and FVfoOverlay.Visible then + begin + FActiveFlag := FVfoOverlay; + if FVfoOverlay.HandleMouseDown(Button, X, Y) then Exit(True); + end; + for i := 0 to High(FSliceFlags) do + if (FSliceFlags[i] <> nil) and FSliceFlags[i].Visible then + begin + FActiveFlag := FSliceFlags[i]; + if FSliceFlags[i].HandleMouseDown(Button, X, Y) then Exit(True); + end; + FActiveFlag := nil; +end; + +procedure TMainForm.DispatchFlagsMouseMove(X, Y: Integer); +var i: Integer; +begin + if Assigned(FVfoOverlay) then + begin + FActiveFlag := FVfoOverlay; + FVfoOverlay.HandleMouseMove(X, Y); + end; + for i := 0 to High(FSliceFlags) do + if FSliceFlags[i] <> nil then + begin + FActiveFlag := FSliceFlags[i]; + FSliceFlags[i].HandleMouseMove(X, Y); + end; + FActiveFlag := nil; +end; + +function TMainForm.DispatchFlagsMouseUp(X, Y: Integer): Boolean; +var i: Integer; +begin + Result := False; + if Assigned(FVfoOverlay) then + begin + FActiveFlag := FVfoOverlay; + if FVfoOverlay.HandleMouseUp(X, Y) then Result := True; + end; + for i := 0 to High(FSliceFlags) do + if FSliceFlags[i] <> nil then + begin + FActiveFlag := FSliceFlags[i]; + if FSliceFlags[i].HandleMouseUp(X, Y) then Result := True; + end; + FActiveFlag := nil; +end; + +procedure TMainForm.DispatchFlagsMouseLeave; +var i: Integer; +begin + if Assigned(FVfoOverlay) then FVfoOverlay.HandleMouseLeave; + for i := 0 to High(FSliceFlags) do + if FSliceFlags[i] <> nil then FSliceFlags[i].HandleMouseLeave; end; procedure TMainForm.OnSampleRateSelect(SampleRate: Integer); @@ -5752,16 +6223,63 @@ procedure TMainForm.FormMouseWheel(Sender: TObject; Shift: TShiftState; P := C.ScreenToClient(Mouse.CursorPos); Result := (P.X >= 0) and (P.Y >= 0) and (P.X < C.Width) and (P.Y < C.Height); end; + // Курсор над флагом-оверлеем слайса (в клиентских координатах спектра)? + function SliceUnderCursor: Integer; + var P: TPoint; i: Integer; O: TVfoOverlay; + begin + Result := 0; + if (PbSpectrum = nil) or (not PbSpectrum.Visible) then Exit; + P := PbSpectrum.ScreenToClient(Mouse.CursorPos); + for i := 0 to High(FSliceFlags) do + begin + O := FSliceFlags[i]; + if (O = nil) or (not O.Visible) then Continue; + if (P.X >= O.Left) and (P.X < O.Left + O.Width) and + (P.Y >= O.Top) and (P.Y < O.Top + O.Height) then + Exit(O.SliceId); + end; + end; var Step: Int64; Delta: Int64; Base: Int64; NewFreq: Int64; + SliceId: Integer; + SLo, SHi, SNew: Double; + Sl: TCtrlSlice; begin // Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно // (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты: // виджет крутил бы B, а этот хендлер — активный A. if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit; + + // Колесо над маркером слайса — двигаем частоту ЭТОГО слайса, а не главного VFO. + SliceId := SliceUnderCursor; + if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then + begin + if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000 + else if ssCtrl in Shift then Step := 1000 + else if ssShift in Shift then Step := 10 + else Step := 100; + Delta := 1; + if WheelDelta < 0 then Delta := -1; + // Квантование к сетке шага + шаг в сторону колеса. + Base := (Round(Sl.TargetHz) div Step) * Step; + if Delta > 0 then SNew := Base + Step + else if Round(Sl.TargetHz) = Base then SNew := Base - Step + else SNew := Base; + FController.CapturedSpan(SLo, SHi); + if SNew < SLo then SNew := SLo; + if SNew > SHi then SNew := SHi; + FController.SetSliceTarget(SliceId, SNew); + PushSliceFlagState(SliceId); + PositionSliceFlags; + FSpectrumDirty := True; + PbSpectrum.Invalidate; + FActiveSliceId := SliceId; + Handled := True; + Exit; + end; if (FController.FMode = MODE_FM) and FController.FFMStepOn and not ((ssCtrl in Shift) or (ssShift in Shift)) then Step := FM_STEP_HZ[FController.FFMStepIdx] diff --git a/RadioController.pas b/RadioController.pas index 3567bf5..06d99f9 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -122,6 +122,24 @@ type PLLLock: Boolean; end; + // Слайс уровня контроллера: логическая обёртка над WDSP-слайс-каналом + // (в TWDSPEngine) + собственный аудио-выход со своей звуковухой. + TCtrlSlice = record + Active: Boolean; + Id: Integer; // = id слайса в TWDSPEngine + Letter: Char; // буква флага ('A'..'F'), пока для UI + TargetHz: Double; // абсолютная частота слайса + Mode: Integer; + FilterLo: Integer; + FilterHi: Integer; + AGC: TWDSPAGCMode; + Volume: Double; // 0..1 + Muted: Boolean; + DeviceIndex: Integer; // PortAudio-индекс (-1 = default) + DevName: string; + Audio: TAudioOutput; // владеет; свой поток вывода + end; + { TRadioController } TRadioController = class private @@ -134,6 +152,11 @@ type FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView) FOnWaterfallData: TPixelDataEvent; // сырьё водопада наружу (UI: децимация+рендер) procedure Changed(Field: TRadioField); + // --- Мультислайсы --- + function FindSliceIndex(Id: Integer): Integer; // -1 если нет + procedure OnSliceAudioReady(SliceId: Integer; + const Left, Right: array of Single; Count: Integer); + procedure RefreshSliceShifts; // пересчёт ShiftHz всех слайсов при смене центра procedure ApplyAGCToEngine; // применяет FAGCMode к WDSP (единый маппинг) procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state) @@ -163,6 +186,12 @@ type FDSPEngine: TWDSPEngine; FAudioOut: TAudioOutput; FAudioIn: TAudioInput; + + // ---- Мультислайсы (софт-fan-out) ---- + // Слайс = доп. приёмник (свой WDSP-канал в движке) + своя звуковуха. + // TargetHz абсолютна; ShiftHz в движок = TargetHz - FCenterFreq. + FSlices: array[0..MAX_SLICES-1] of TCtrlSlice; + FNextSliceId: Integer; // монотонный генератор id слайсов FSettings: TSettingsManager; FChannelStore: TChannelStore; FDeviceStore: TDeviceStore; // общий список устройств (saved+discovered) @@ -467,6 +496,32 @@ type procedure DriveBy(Delta: Integer); procedure SetAtten(Idx: Integer); + // ---- Мультислайсы (софт-fan-out) ---- + // Создаёт слайс на TargetHz: заводит WDSP-канал в движке + свой аудио-выход + // на устройстве DevIndex (-1=default). Возвращает Id слайса или -1 при отказе + // (нет слотов / TargetHz вне захваченной полосы). DevName для персиста/UI. + function AddSlice(TargetHz: Double; Mode, FilterLo, FilterHi: Integer; + AGC: TWDSPAGCMode; Vol: Double; + DevIndex: Integer; const DevName: string): Integer; + procedure RemoveSlice(Id: Integer); + procedure SetSliceTarget(Id: Integer; TargetHz: Double); + procedure SetSliceMode(Id, Mode: Integer); + procedure SetSliceFilter(Id, Low, High: Integer); + procedure SetSliceModeBW(Id, Mode, BW: Integer); // режим+полоса → кромки фильтра + class procedure FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer); + class function AGCModeFromUI(M: Integer): TWDSPAGCMode; // UI-индекс 0..4 → WDSP enum + procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode); + procedure SetSliceVolume(Id: Integer; Vol: Double); // 0..1 + procedure SetSliceMute(Id: Integer; Mute: Boolean); + procedure SetSliceDSP(Id, NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); + function SliceSMeter(Id: Integer): Double; // dBm, из DSP-потока + procedure SetSliceDevice(Id, DevIndex: Integer; const DevName: string); + function SliceCount: Integer; + function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; + // Диапазон захваченной полосы вокруг центра, куда можно ставить софт-слайс. + procedure CapturedSpan(out LoHz, HiHz: Double); + function SliceFitsCapture(TargetHz: Double): Boolean; + // Pluto TX-мощность через аттенюацию: drive% → дБ (потолок FPlutoTxMaxAttDb на // 100%, выкл на приёме). Логика/маппинг здесь; бэкенд лишь пишет hardwaregain. function PlutoTxAttForDrive: Double; // дБ<0 под текущий FDrivePercent @@ -625,6 +680,8 @@ begin FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX) FVolume := 70; FDrivePercent := 50; FDriveLevel := 0; FAtten := 0; FCenterFreq := FVfoA; FSpanHz := 192000; FSampleRate := 192000; + FNextSliceId := 1; // мультислайсы: генератор id + FillChar(FSlices, SizeOf(FSlices), 0); FZoomFactor := 0.0; FPanSlider := 0.5; // 0.0 = весь span (без зума) FCurrentBand := 5; FCurrentXvtr := -1; FSpectrumBufCount := 1024; FWaterfallBufCount := 1024; @@ -649,6 +706,9 @@ begin // BufSize=512: латентность 512/48000 ≈ 10.7мс — допустимо для SDR; меньший // буфер вдвое сокращает построение downsampler в OpenChannel RXA. FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512); + // Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не + // зависит от фронтенда, в отличие от OnAudio/OnSpectrum). + FDSPEngine.OnSliceAudio := OnSliceAudioReady; // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock). FBeaconRefHz := 10489750000.0; // центральный BPSK-маяк NB-транспондера (downlink) @@ -672,12 +732,21 @@ begin end; procedure TRadioController.FreeEngines; +var i: Integer; begin if Assigned(FNetwork) then begin if FNetwork.Connected then FNetwork.Disconnect; FreeAndNil(FNetwork); end; + // Слайсы: закрываем их аудио-выходы (WDSP-каналы закроет FDSPEngine.Close ниже). + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and (FSlices[i].Audio <> nil) then + begin + FSlices[i].Audio.Close; + FreeAndNil(FSlices[i].Audio); + FSlices[i].Active := False; + end; if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end; if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end; if Assigned(FDSPEngine) then @@ -966,6 +1035,227 @@ begin FAudioOut.Write(Left, Right, Count); end; +// --------------------------------------------------------------------------- +// Мультислайсы (софт-fan-out) +// --------------------------------------------------------------------------- + +function TRadioController.FindSliceIndex(Id: Integer): Integer; +var i: Integer; +begin + Result := -1; + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and (FSlices[i].Id = Id) then Exit(i); +end; + +// DSP-поток: аудио готового слайса → его собственный выход. Слайс — локальный +// монитор (в web/на радио не уходит), поэтому только на свою звуковуху. +procedure TRadioController.OnSliceAudioReady(SliceId: Integer; + const Left, Right: array of Single; Count: Integer); +var idx: Integer; +begin + if not FLocalAudio then Exit; + idx := FindSliceIndex(SliceId); + if idx < 0 then Exit; + if FSlices[idx].Audio <> nil then + FSlices[idx].Audio.Write(Left, Right, Count); +end; + +procedure TRadioController.RefreshSliceShifts; +var i: Integer; +begin + if not Assigned(FDSPEngine) then Exit; + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active then + FDSPEngine.SetSliceShift(FSlices[i].Id, FSlices[i].TargetHz - FCenterFreq); +end; + +procedure TRadioController.CapturedSpan(out LoHz, HiHz: Double); +// Полезная полоса ±47.5% от захвата (запас на краях фильтра/анти-алиасинга). +var Half: Double; +begin + Half := FSampleRate * 0.475; + LoHz := FCenterFreq - Half; + HiHz := FCenterFreq + Half; +end; + +function TRadioController.SliceFitsCapture(TargetHz: Double): Boolean; +var Lo, Hi: Double; +begin + CapturedSpan(Lo, Hi); + Result := (TargetHz >= Lo) and (TargetHz <= Hi); +end; + +function TRadioController.AddSlice(TargetHz: Double; + Mode, FilterLo, FilterHi: Integer; AGC: TWDSPAGCMode; Vol: Double; + DevIndex: Integer; const DevName: string): Integer; +var + i, slot, Id: Integer; + AO: TAudioOutput; +begin + Result := -1; + if not Assigned(FDSPEngine) then Exit; + if not SliceFitsCapture(TargetHz) then Exit; // вне захваченной полосы (Этап 3=HW DDC) + + slot := -1; + for i := 0 to MAX_SLICES - 1 do + if not FSlices[i].Active then begin slot := i; Break; end; + if slot < 0 then Exit; // нет свободных слотов + + Id := FNextSliceId; + if not FDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi, + TargetHz - FCenterFreq, Vol, AGC) then Exit; + Inc(FNextSliceId); + + // Свой аудио-выход слайса на выбранной звуковухе. + AO := TAudioOutput.Create(48000); + AO.OutputBufferSize := FSettings.LoadAudioBufferSize; + AO.DeviceIndex := DevIndex; + if FLocalAudio then AO.Open; + + FSlices[slot].Active := True; + FSlices[slot].Id := Id; + // Буква флага. Слайс A — это главный приёмник (не в FSlices[]), поэтому доп. + // слайсы нумеруются с 'B' (slot 0 → B, 1 → C, …), иначе получалось два «A». + FSlices[slot].Letter := Chr(Ord('B') + slot); + FSlices[slot].TargetHz := TargetHz; + FSlices[slot].Mode := Mode; + FSlices[slot].FilterLo := FilterLo; + FSlices[slot].FilterHi := FilterHi; + FSlices[slot].AGC := AGC; + FSlices[slot].Volume := Vol; + FSlices[slot].Muted := False; + FSlices[slot].DeviceIndex := DevIndex; + FSlices[slot].DevName := DevName; + FSlices[slot].Audio := AO; + + Result := Id; + Changed(rfDevice); +end; + +procedure TRadioController.RemoveSlice(Id: Integer); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + if Assigned(FDSPEngine) then FDSPEngine.RemoveSlice(Id); + if FSlices[idx].Audio <> nil then + begin + FSlices[idx].Audio.Close; + FreeAndNil(FSlices[idx].Audio); + end; + FSlices[idx].Active := False; + Changed(rfDevice); +end; + +procedure TRadioController.SetSliceTarget(Id: Integer; TargetHz: Double); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + FSlices[idx].TargetHz := TargetHz; + if Assigned(FDSPEngine) then + FDSPEngine.SetSliceShift(Id, TargetHz - FCenterFreq); +end; + +procedure TRadioController.SetSliceMode(Id, Mode: Integer); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + FSlices[idx].Mode := Mode; + if Assigned(FDSPEngine) then FDSPEngine.SetSliceMode(Id, Mode); +end; + +procedure TRadioController.SetSliceFilter(Id, Low, High: Integer); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + FSlices[idx].FilterLo := Low; + FSlices[idx].FilterHi := High; + if Assigned(FDSPEngine) then FDSPEngine.SetSliceFilter(Id, Low, High); +end; + +procedure TRadioController.SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + FSlices[idx].AGC := AGC; + if Assigned(FDSPEngine) then FDSPEngine.SetSliceAGC(Id, AGC); +end; + +procedure TRadioController.SetSliceVolume(Id: Integer; Vol: Double); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + FSlices[idx].Volume := Vol; + if Assigned(FDSPEngine) then FDSPEngine.SetSliceVolume(Id, Vol); +end; + +procedure TRadioController.SetSliceMute(Id: Integer; Mute: Boolean); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + FSlices[idx].Muted := Mute; + if Assigned(FDSPEngine) then FDSPEngine.SetSliceMute(Id, Mute); +end; + +procedure TRadioController.SetSliceDSP(Id, NRMode, NBMode: Integer; + SNBOn, ANFOn: Boolean); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + if Assigned(FDSPEngine) then + begin + FDSPEngine.SetSliceNRMode(Id, NRMode); + FDSPEngine.SetSliceNBMode(Id, NBMode); + FDSPEngine.SetSliceSNB(Id, SNBOn); + FDSPEngine.SetSliceANF(Id, ANFOn); + end; +end; + +function TRadioController.SliceSMeter(Id: Integer): Double; +begin + if Assigned(FDSPEngine) then Result := FDSPEngine.GetSliceSMeter(Id) + else Result := -130.0; +end; + +procedure TRadioController.SetSliceDevice(Id, DevIndex: Integer; + const DevName: string); +var idx: Integer; +begin + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + FSlices[idx].DeviceIndex := DevIndex; + FSlices[idx].DevName := DevName; + if FSlices[idx].Audio <> nil then + begin + FSlices[idx].Audio.Close; + FSlices[idx].Audio.DeviceIndex := DevIndex; + if FLocalAudio then FSlices[idx].Audio.Open; + end; +end; + +function TRadioController.SliceCount: Integer; +var i: Integer; +begin + Result := 0; + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active then Inc(Result); +end; + +function TRadioController.GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; +var idx: Integer; +begin + idx := FindSliceIndex(Id); + Result := idx >= 0; + if Result then S := FSlices[idx]; +end; + procedure TRadioController.OnSpectrumReady(const Pixels: array of Single; Count: Integer); // DSP-поток: сохраняем последний кадр для web-зеркала и отдаём сырьё наружу @@ -1335,16 +1625,31 @@ begin Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors); end; +// Кромки полосы под режим/полосу (единый расчёт для главного канала и слайсов). +class procedure TRadioController.FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer); +var Half: Integer; +begin + Half := BW div 2; + case Mode of + 0: begin Lo := -BW; Hi := -100; end; // LSB + 1: begin Lo := 100; Hi := BW; end; // USB + else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM + end; +end; + +procedure TRadioController.SetSliceModeBW(Id, Mode, BW: Integer); +var Lo, Hi: Integer; +begin + FilterEdgesFor(Mode, BW, Lo, Hi); + SetSliceMode(Id, Mode); + SetSliceFilter(Id, Lo, Hi); +end; + procedure TRadioController.ApplyModeFilter; var - Lo, Hi, Half: Integer; + Lo, Hi: Integer; begin - Half := FFilterBW div 2; - case FMode of - 0: begin Lo := -FFilterBW; Hi := -100; end; // LSB - 1: begin Lo := 100; Hi := FFilterBW; end; // USB - else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM - end; + FilterEdgesFor(FMode, FFilterBW, Lo, Hi); if FWDSPReady and Assigned(FDSPEngine) then begin FDSPEngine.SetFilter(Lo, Hi); @@ -1921,6 +2226,7 @@ begin NeedPush := True; end; + RefreshSliceShifts; // центр мог сместиться — держим слайсы на их TargetHz if NeedPush then PushNetworkState; Changed(rfActiveVfo); end; @@ -1941,6 +2247,7 @@ begin FCenterFreq := Hz; if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(ActiveVfoHz - FCenterFreq); + RefreshSliceShifts; // слайсы привязаны к абсолютной частоте — сдвинуть NCO PushNetworkState; Changed(rfCenterFreq); end; @@ -1972,6 +2279,7 @@ begin end; FCenterFreq := ActiveFreq; if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(0.0); + RefreshSliceShifts; PushNetworkState; Changed(rfVfoA); Changed(rfVfoB); @@ -2057,14 +2365,20 @@ begin Changed(rfFilter); end; -procedure TRadioController.ApplyAGCToEngine; +// UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для всех +// фронтендов и слайсов (раньше web/слайдер ошибочно кастовали индекс прямо в enum). +class function TRadioController.AGCModeFromUI(M: Integer): TWDSPAGCMode; const - // UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для - // всех фронтендов (раньше web/слайдер ошибочно кастовали индекс прямо в enum). AGCModes: array[0..4] of TWDSPAGCMode = (agcFast, agcMedium, agcSlow, agcLong, agcOff); +begin + if (M < 0) or (M > 4) then M := 1; + Result := AGCModes[M]; +end; + +procedure TRadioController.ApplyAGCToEngine; begin if FWDSPReady and Assigned(FDSPEngine) and (FAGCMode >= 0) and (FAGCMode <= 4) then - FDSPEngine.SetAGC(AGCModes[FAGCMode], 50.0); + FDSPEngine.SetAGC(AGCModeFromUI(FAGCMode), 50.0); end; procedure TRadioController.SetAGCMode(M: Integer); diff --git a/SpectrumView.pas b/SpectrumView.pas index dd90f78..f607df3 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -70,6 +70,7 @@ type FADCOverloadVisible: Boolean; FSampleRateOverlay: TSampleRateOverlay; FVfoOverlay: TVfoOverlay; + FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm) FBandPlanOverlay: TBandPlanOverlay; // ── Marker ──────────────────────────────────────────────────────────────── FMarkerActive: Boolean; @@ -108,12 +109,15 @@ type procedure SetFillSpectrum(V: Boolean); procedure DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer); procedure DrawMarkerLine(C: TCanvas; W, H: Integer); + procedure DrawSliceFilterMarkers(C: TCanvas; W, H: Integer); procedure DrawBeaconMarkers(C: TCanvas; W, H: Integer); procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte); procedure CopyGridToSpectrum(W, H: Integer); procedure DrawADCOverloadOverlay(C: TCanvas; W, H: Integer); procedure CalcFilterBandX(VfoFreq: Double; W: Integer; out X1, X2, VfoX: Integer); + procedure CalcFilterBandXFor(VfoFreq: Double; Mode, BW, W: Integer; + out X1, X2, VfoX: Integer); function GetWaterfallDirty: Boolean; procedure SetWaterfallDirty(V: Boolean); // Waterfall sub-view delegating accessors @@ -218,6 +222,11 @@ type property PbSMeterRight: TPaintBox write SetPbSMeterRight; property SampleRateOverlay: TSampleRateOverlay read FSampleRateOverlay write FSampleRateOverlay; property VfoOverlay: TVfoOverlay read FVfoOverlay write FVfoOverlay; + // Доп. слайс-флаги (B+): рисуются поверх спектра после главного оверлея. + procedure AddSliceOverlay(O: TVfoOverlay); + procedure RemoveSliceOverlay(O: TVfoOverlay); + function SliceOverlayCount: Integer; + function SliceOverlayAt(Index: Integer): TVfoOverlay; property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay; // ── Данные от DSP ───────────────────────────────────────────────────────── @@ -244,6 +253,9 @@ type // ── Утилиты ─────────────────────────────────────────────────────────────── procedure InvalidateGridCache; virtual; procedure InvalidateOverlayCache; virtual; + // Инвалидация ТОЛЬКО текстуры главного VFO-флага (без band/samplerate/слайсов). + // Нужна для дешёвого обновления S-метра главного флага в GL-вьюхе. + procedure InvalidateVfoOverlay; virtual; procedure SetTheme(const T: TAppTheme); virtual; procedure InvalidateRulerCache; virtual; procedure ResetSpectrumBuf; virtual; @@ -329,6 +341,7 @@ begin FWaterfall := TWaterfallView.Create; FSMeter := TSMeterView.Create; FRuler := TRulerView.Create; + FSliceOverlays := TFPList.Create; ResetSpectrumBuf; end; @@ -339,9 +352,30 @@ begin FWaterfall.Free; FSMeter.Free; FRuler.Free; + FSliceOverlays.Free; // не владеет флагами — только список inherited; end; +procedure TSpectrumView.AddSliceOverlay(O: TVfoOverlay); +begin + if (O <> nil) and (FSliceOverlays.IndexOf(O) < 0) then FSliceOverlays.Add(O); +end; + +procedure TSpectrumView.RemoveSliceOverlay(O: TVfoOverlay); +begin + FSliceOverlays.Remove(O); +end; + +function TSpectrumView.SliceOverlayCount: Integer; +begin + Result := FSliceOverlays.Count; +end; + +function TSpectrumView.SliceOverlayAt(Index: Integer): TVfoOverlay; +begin + Result := TVfoOverlay(FSliceOverlays[Index]); +end; + // ──────────────────────────────────────────────────────────────────────────── // Setters with cascade to sub-views // ──────────────────────────────────────────────────────────────────────────── @@ -583,6 +617,32 @@ begin end; end; +// Маркер фильтра каждого слайса (B+) на спектре — полупрозрачная полоса +// пропускания + края + центральная несущая. Янтарный цвет, как в GL-вьюхе +// (SpectrumViewOpengl.DrawSliceFilterMarkers). Рисуется под кривой спектра. +procedure TSpectrumView.DrawSliceFilterMarkers(C: TCanvas; W, H: Integer); +const + SLICE_R = $F0; SLICE_G = $A8; SLICE_B = $10; // янтарный (совпадает с SLICE_CLR в GL) + SLICE_CLR = TColor($0010A8F0); +var + i, SX1, SX2, SVfoX: Integer; + O: TVfoOverlay; +begin + for i := 0 to FSliceOverlays.Count - 1 do + begin + O := TVfoOverlay(FSliceOverlays[i]); + if (O = nil) or (not O.Visible) then Continue; + CalcFilterBandXFor(O.DisplayVfoHz, O.DisplayMode, O.DisplayBW, W, SX1, SX2, SVfoX); + if SX2 > SX1 then BlendBand(SX1, SX2, H, SLICE_R, SLICE_G, SLICE_B, 60); + C.Pen.Color := SLICE_CLR; C.Pen.Width := 1; C.Pen.Style := psSolid; + C.MoveTo(SX1, 0); C.LineTo(SX1, H); + C.MoveTo(SX2, 0); C.LineTo(SX2, H); + C.Pen.Width := 2; + C.MoveTo(SVfoX, 0); C.LineTo(SVfoX, H - 12); + C.Pen.Width := 1; + end; +end; + procedure TSpectrumView.CopyGridToSpectrum(W, H: Integer); var Y: Integer; @@ -620,15 +680,22 @@ end; procedure TSpectrumView.CalcFilterBandX(VfoFreq: Double; W: Integer; out X1, X2, VfoX: Integer); +begin + CalcFilterBandXFor(VfoFreq, FMode, FFilterBW, W, X1, X2, VfoX); +end; + +// То же, но для произвольного режима/полосы (маркеры слайсов). +procedure TSpectrumView.CalcFilterBandXFor(VfoFreq: Double; Mode, BW, W: Integer; + out X1, X2, VfoX: Integer); var Lo_Hz, Hi_Hz, Half: Double; begin - Half := FFilterBW / 2; + Half := BW / 2; VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W); - case FMode of - 0: begin Lo_Hz := -FFilterBW; Hi_Hz := -100; end; - 1: begin Lo_Hz := 100; Hi_Hz := FFilterBW; end; - else begin Lo_Hz := -Half; Hi_Hz := Half; end; + case Mode of + 0: begin Lo_Hz := -BW; Hi_Hz := -100; end; + 1: begin Lo_Hz := 100; Hi_Hz := BW; end; + else begin Lo_Hz := -Half; Hi_Hz := Half; end; end; X1 := Max(0, Min(W - 1, VfoX + Round(Lo_Hz / FSpanHz * W))); X2 := Max(0, Min(W - 1, VfoX + Round(Hi_Hz / FSpanHz * W))); @@ -737,6 +804,13 @@ begin FSpectrumDirty := True; end; +procedure TSpectrumView.InvalidateVfoOverlay; +begin + // База (CPU-путь) перерисовывает флаг по FCacheDirty/FMeterDirty оверлея — здесь + // достаточно пометить спектр грязным. GL-подкласс переопределяет. + FSpectrumDirty := True; +end; + procedure TSpectrumView.SetTheme(const T: TAppTheme); begin FTheme := T; @@ -905,6 +979,9 @@ begin C.Pen.Style := psSolid; end; + // Полосы фильтра слайсов (B+) — под кривой спектра, как в GL-вьюхе. + DrawSliceFilterMarkers(C, W, H); + // ── 3. AGC линии ────────────────────────────────────────────────────────── if FWDSPReady then begin @@ -1009,6 +1086,9 @@ begin FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); if Assigned(FVfoOverlay) then FVfoOverlay.DrawOverlay(FSpectrumBitmap, W, H); + // Доп. слайс-флаги (B+) поверх главного. + for i := 0 to FSliceOverlays.Count - 1 do + TVfoOverlay(FSliceOverlays[i]).DrawOverlay(FSpectrumBitmap, W, H); // Восстановление непрозрачности. На Cocoa проверено, что проход не нужен // (Canvas-операции сохраняют альфу $FF), поэтому там его не делаем. diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas index 941f38c..5ca6a8f 100644 --- a/SpectrumViewOpengl.pas +++ b/SpectrumViewOpengl.pas @@ -43,6 +43,12 @@ type FSampleOverlayTex: TGLTextureCache; FVfoOverlayTex: TGLTextureCache; FBandOverlayTex: TGLTextureCache; + // Текстуры флагов слайсов (B+), привязка по указателю оверлея. + FSliceTex: array of record + Overlay: TVfoOverlay; + Tex: TGLTextureCache; + LastX, LastY, LastW, LastH: Integer; + end; FLastAGCText: string; FLastAGCY: Integer; FLastAGCHangY: Integer; @@ -83,6 +89,9 @@ type procedure DrawBeaconMarkersGL(W, H: Integer); procedure DrawADCOverlay(W, H: Integer); procedure DrawCachedOverlays(W, H: Integer); + procedure DrawSliceOverlays(W, H: Integer); + procedure DrawSliceFilterMarkers(W, H: Integer); + function SliceTexIndex(O: TVfoOverlay): Integer; // индекс в FSliceTex, -1 если нет function FormatFreqGL(Hz: Double): string; function ActiveVfoFrequency: Double; public @@ -96,6 +105,7 @@ type procedure PaintSpectrum(Sender: TObject); override; procedure InvalidateGridCache; override; procedure InvalidateOverlayCache; override; + procedure InvalidateVfoOverlay; override; procedure SetTheme(const T: TAppTheme); override; end; @@ -138,6 +148,7 @@ begin end; destructor TSpectrumViewOpenGL.Destroy; +var i: Integer; begin if Assigned(FGLControl) and FGLControl.MakeCurrent then begin @@ -149,7 +160,9 @@ begin DeleteTexture(FSampleOverlayTex); DeleteTexture(FVfoOverlayTex); DeleteTexture(FBandOverlayTex); + for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex); end; + SetLength(FSliceTex, 0); inherited Destroy; end; @@ -392,6 +405,13 @@ begin FVfoOverlayDirty := True; end; +procedure TSpectrumViewOpenGL.InvalidateVfoOverlay; +begin + // Только текстура главного флага — НЕ трогаем FOverlayDirty, иначе band/ + // samplerate/слайсы зря перезаливались бы на каждый тик S-метра главного. + FVfoOverlayDirty := True; +end; + procedure TSpectrumViewOpenGL.SetTheme(const T: TAppTheme); begin inherited SetTheme(T); @@ -536,6 +556,29 @@ begin DrawLine(TXVfoX, 0, TXVfoX, H - 12, TColor($002030E0), 2, False); DrawRect(TXVfoX - 5, 0, TXVfoX + 5, 8, TColor($002030E0), 1); end; + + DrawSliceFilterMarkers(W, H); +end; + +// Маркер фильтра каждого слайса (B+) на спектре: полупрозрачная полоса +// пропускания + центральная несущая. Цвет янтарный — отличить от главного. +procedure TSpectrumViewOpenGL.DrawSliceFilterMarkers(W, H: Integer); +const + SLICE_CLR = TColor($0010A8F0); // янтарный (BGR), как акцент TX-select флага +var + i, SX1, SX2, SVfoX: Integer; + O: TVfoOverlay; +begin + for i := 0 to SliceOverlayCount - 1 do + begin + O := SliceOverlayAt(i); + if (O = nil) or (not O.Visible) then Continue; + CalcFilterBandXFor(O.DisplayVfoHz, O.DisplayMode, O.DisplayBW, W, SX1, SX2, SVfoX); + if SX2 > SX1 then DrawRect(SX1, 0, SX2, H, SLICE_CLR, 60 / 255); + DrawLine(SX1, 0, SX1, H, SLICE_CLR, 1, False); + DrawLine(SX2, 0, SX2, H, SLICE_CLR, 1, False); + DrawLine(SVfoX, 0, SVfoX, H - 12, SLICE_CLR, 2, False); + end; end; procedure TSpectrumViewOpenGL.DrawAGCLines(W, H: Integer; DBmax, @@ -783,16 +826,11 @@ begin if Assigned(FVfoOverlay) and FVfoOverlay.Visible then begin if FOverlayDirty or FVfoOverlayDirty or FVfoOverlayTex.Dirty or + FVfoOverlay.CacheDirty or FVfoOverlay.MeterDirty or (FLastVfoX <> FVfoOverlay.Left) or (FLastVfoY <> FVfoOverlay.Top) or (FLastVfoW <> FVfoOverlay.Width) or (FLastVfoH <> FVfoOverlay.Height) then begin - B := TBitmap.Create; - try - FVfoOverlay.DrawOverlayBitmap(B); - UploadBitmap(FVfoOverlayTex, B, True, VFO_OVERLAY_ALPHA); - finally - B.Free; - end; + UploadBitmap(FVfoOverlayTex, FVfoOverlay.EnsureRendered, True, VFO_OVERLAY_ALPHA); FLastVfoX := FVfoOverlay.Left; FLastVfoY := FVfoOverlay.Top; FLastVfoW := FVfoOverlay.Width; @@ -801,9 +839,76 @@ begin end; DrawTexture(FVfoOverlayTex, FVfoOverlay.Left, FVfoOverlay.Top); end; + + DrawSliceOverlays(W, H); FOverlayDirty := False; end; +function TSpectrumViewOpenGL.SliceTexIndex(O: TVfoOverlay): Integer; +var i: Integer; +begin + Result := -1; + for i := 0 to High(FSliceTex) do + if FSliceTex[i].Overlay = O then Exit(i); +end; + +// Флаги слайсов (B+): своя GL-текстура на флаг (привязка по указателю оверлея). +// Текстуры «сирот» (флаг удалён) вычищаются здесь же. +procedure TSpectrumViewOpenGL.DrawSliceOverlays(W, H: Integer); +var + n, i, ti, j: Integer; + O: TVfoOverlay; + Alive: Boolean; +begin + // 1. Убрать текстуры флагов, которых уже нет в списке. + i := 0; + while i <= High(FSliceTex) do + begin + Alive := False; + for j := 0 to SliceOverlayCount - 1 do + if SliceOverlayAt(j) = FSliceTex[i].Overlay then begin Alive := True; Break; end; + if not Alive then + begin + DeleteTexture(FSliceTex[i].Tex); + for j := i to High(FSliceTex) - 1 do FSliceTex[j] := FSliceTex[j + 1]; + SetLength(FSliceTex, Length(FSliceTex) - 1); + end + else + Inc(i); + end; + + // 2. Нарисовать каждый видимый флаг, при нужде (пере)залив текстуру. + n := SliceOverlayCount; + for i := 0 to n - 1 do + begin + O := SliceOverlayAt(i); + if (O = nil) or (not O.Visible) then Continue; + ti := SliceTexIndex(O); + if ti < 0 then + begin + SetLength(FSliceTex, Length(FSliceTex) + 1); + ti := High(FSliceTex); + FSliceTex[ti].Overlay := O; + FSliceTex[ti].Tex.Dirty := True; + FSliceTex[ti].LastX := MaxInt; // форсируем первую заливку + end; + // Перезаливаем текстуру слайса ТОЛЬКО когда изменился он сам (CacheDirty: + // режим/громкость, MeterDirty: S-метр) или его позиция — не по глобальному + // FOverlayDirty. При MeterDirty EnsureRendered перерисует лишь полоску метра. + if O.CacheDirty or O.MeterDirty or FSliceTex[ti].Tex.Dirty or + (FSliceTex[ti].LastX <> O.Left) or (FSliceTex[ti].LastY <> O.Top) or + (FSliceTex[ti].LastW <> O.Width) or (FSliceTex[ti].LastH <> O.Height) then + begin + UploadBitmap(FSliceTex[ti].Tex, O.EnsureRendered, True, VFO_OVERLAY_ALPHA); + FSliceTex[ti].LastX := O.Left; + FSliceTex[ti].LastY := O.Top; + FSliceTex[ti].LastW := O.Width; + FSliceTex[ti].LastH := O.Height; + end; + DrawTexture(FSliceTex[ti].Tex, O.Left, O.Top); + end; +end; + procedure TSpectrumViewOpenGL.PaintSpectrum(Sender: TObject); var W, H: Integer; diff --git a/VfoOverlay.pas b/VfoOverlay.pas index f989903..6c8f3f2 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -52,6 +52,7 @@ type FTx: Boolean; // передача идёт (красный) FTxSel: Boolean; // этот слайс выбран для передачи (янтарный) FSlice: Char; // буква слайса ('A') + FSMeterThreshold: Double; // мин. изменение S-метра (дБ) для перерисовки FDevNames: TStringList; // имена устройств вывода FDevIdx: array of Integer; // соответствующие PortAudio-индексы (-1=default) @@ -72,12 +73,21 @@ type FOnTxSelect: TNotifyEvent; FOnInvalidate: TNotifyEvent; + FSliceId: Integer; // логический id слайса (владелец — MainForm/контроллер) + FIsMain: Boolean; // главный приёмник (слайс A) — без кнопки закрытия + FOnSliceSelect: TNotifyEvent; // клик по бейджу буквы — сделать слайс активным + FOnClose: TNotifyEvent; // клик по × (только неглавные) — удалить слайс + FHitRects: array[0..63] of TRect; FHitActions: array[0..63] of Integer; FHitCount: Integer; FHotIdx: Integer; FCacheBitmap: TBitmap; FCacheDirty: Boolean; + FMeterDirty: Boolean; // изменился только S-метр — перерисовать полоску, не весь флаг + FMeterRect: TRect; // прямоугольник сигнал-бара (запоминается при полном рендере) + FSRowTop: Integer; // верх строки частоты (для S-метки справа) + FLastSTextRect: TRect; // последний прямоугольник S-метки (для аккуратной очистки) procedure DrawSelf(C: TCanvas; W, H: Integer); procedure DrawButton(C: TCanvas; const R: TRect; const Lbl: string; @@ -100,6 +110,8 @@ type procedure SetPanel(P: TFlyPanel); procedure ApplyVolFromX(LX: Integer); procedure RequestInvalidate; + procedure RequestMeterInvalidate; + procedure RefreshMeter(C: TCanvas); procedure DoHit(HV: Integer); protected @@ -114,6 +126,10 @@ type destructor Destroy; override; procedure DrawOverlay(Target: TBitmap; W, H: Integer); procedure DrawOverlayBitmap(Target: TBitmap); + // Готовит внутренний кэш-битмап (полный рендер по CacheDirty либо дешёвая + // перерисовка одной полоски метра по MeterDirty) и возвращает его. Один путь + // для CPU (BlendBitmapKey) и GL (UploadBitmap). + function EnsureRendered: TBitmap; function HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean; function HandleMouseMove(X, Y: Integer): Boolean; function HandleMouseUp(X, Y: Integer): Boolean; @@ -126,6 +142,20 @@ type const CurName: string); procedure UpdateSMeter(DB: Double); procedure UpdateVfo(AVfoHz: Double); + // Привязка к слайсу: id, буква флага, признак главного (слайс A). + procedure SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean); + property SliceId: Integer read FSliceId; + property IsMain: Boolean read FIsMain; + // Для маркера фильтра слайса на спектре (читает GL-вьюха). + property DisplayVfoHz: Double read FVfoHz; + property DisplayMode: Integer read FMode; + property DisplayBW: Integer read FFilterBW; + // Битмап-кэш требует перерисовки (GL-вьюха решает, перезаливать ли текстуру). + property CacheDirty: Boolean read FCacheDirty; + // Изменился только S-метр (перезалить текстуру, но рендер дешёвый — одна полоска). + property MeterDirty: Boolean read FMeterDirty; + // Порог изменения S-метра (дБ) для перерисовки: у слайсов выше (меньше рендеров). + property SMeterThreshold: Double read FSMeterThreshold write FSMeterThreshold; property HotIdx: Integer read FHotIdx; property BaseHeight: Integer read ComputeHeight; property OnSelect: TModeFilterEvent read FOnSelect write FOnSelect; @@ -136,6 +166,8 @@ type property OnAudioDevice: TAudioDeviceEvent read FOnAudioDevice write FOnAudioDevice; property OnSplit: TNotifyEvent read FOnSplit write FOnSplit; property OnTxSelect: TNotifyEvent read FOnTxSelect write FOnTxSelect; + property OnSliceSelect: TNotifyEvent read FOnSliceSelect write FOnSliceSelect; + property OnClose: TNotifyEvent read FOnClose write FOnClose; property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate; end; @@ -162,6 +194,8 @@ const HIT_TX = -26; HIT_AUD_UP = -27; HIT_AUD_DN = -28; + HIT_SLICE_SEL = -29; // клик по бейджу буквы (выбрать слайс активным) + HIT_CLOSE = -30; // × закрытия слайса (неглавные) HIT_AGC_PICK_BASE = 100000; HIT_DEV_BASE = 200000; @@ -338,6 +372,8 @@ begin FNRMode := 0; FNBMode := 0; FSNBOn := False; FANFOn := False; FAGCMode := 0; FVolume := 70; FMuted := False; FSplit := False; FTx := False; FTxSel := True; FSlice := 'A'; + FSliceId := 0; FIsMain := True; // по умолчанию — главный приёмник (слайс A) + FSMeterThreshold := 0.4; // главный флаг — чувствительный метр FPanel := fpNone; FAudScroll := 0; FVolDrag := False; @@ -346,6 +382,8 @@ begin FCacheBitmap := TBitmap.Create; FCacheBitmap.PixelFormat := pf32bit; FCacheDirty := True; + FMeterDirty := False; + FLastSTextRect := Rect(0, 0, 0, 0); Width := OVL_W; Height := OVL_BASE_H; Cursor := crHandPoint; @@ -373,6 +411,53 @@ begin if Assigned(FOnInvalidate) then FOnInvalidate(Self); end; +// Лёгкая инвалидация: изменился только S-метр. НЕ трогает FCacheDirty, поэтому +// хром флага (частота/режим/кнопки) не перерисовывается — перезаливается только +// полоска метра (RefreshMeter в EnsureRendered). +procedure TVfoOverlay.RequestMeterInvalidate; +begin + FMeterDirty := True; + inherited Invalidate; + if Assigned(FOnInvalidate) then FOnInvalidate(Self); +end; + +// Дешёвая перерисовка одной полоски метра + S-метки поверх уже отрендеренного +// хрома. Обе зоны самодостаточны и целиком очищаются под фон карточки. +procedure TVfoOverlay.RefreshMeter(C: TCanvas); +var + SStr: string; + STextW: Integer; + ClearR, NewR: TRect; +begin + // Сигнал-бар — самодостаточный прямоугольник, перерисовывается целиком. + C.Brush.Color := CLR_CARD_BG; + C.Brush.Style := bsSolid; + C.Pen.Style := psClear; + C.FillRect(FMeterRect); + DrawMeterBar(C, FMeterRect.Left, FMeterRect.Top, + FMeterRect.Right - FMeterRect.Left, MET_H); + + // S-метка в строке частоты (справа). Очищаем объединение старого и нового + // прямоугольника, чтобы не оставалось «хвостов» при сужении текста. + SStr := DBmToSLabel(FSMeterDB); + C.Font.Name := 'Courier New'; + C.Font.Style := [fsBold]; + C.Font.Size := 9; + STextW := C.TextWidth(SStr); + NewR := Rect(FMeterRect.Right - STextW - 3, FSRowTop + 4, FMeterRect.Right, FSRowTop + FRQ_H); + ClearR := NewR; + if FLastSTextRect.Left < ClearR.Left then ClearR.Left := FLastSTextRect.Left; + if FLastSTextRect.Top < ClearR.Top then ClearR.Top := FLastSTextRect.Top; + C.Brush.Color := CLR_CARD_BG; + C.Brush.Style := bsSolid; + C.Pen.Style := psClear; + C.FillRect(ClearR); + C.Font.Color := CLR_SVAL; + C.Brush.Style := bsClear; + C.TextOut(FMeterRect.Right - STextW - 2, FSRowTop + 6, SStr); + FLastSTextRect := NewR; +end; + function TVfoOverlay.FormatFreq(Hz: Double): string; var T, iM, iK, iH: Int64; @@ -800,8 +885,16 @@ begin RW := W - PAD*2; CurY := PAD; - { --- Шапка: [A] BW ... SPLIT [TX] --- } + { --- Шапка: [A] BW ... [×] SPLIT [TX] --- } DrawBadge(C, Rect(X, CurY, X+16, CurY+HDR_BADGE_H), FSlice, CLR_ACCENT, CLR_CARD_BG); + RegisterHit(Rect(X, CurY, X+16, CurY+HDR_BADGE_H), HIT_SLICE_SEL); // бейдж = выбор слайса + // Кнопка закрытия × — только у неглавных слайсов (главный приёмник не закрыть). + if not FIsMain then + begin + R := Rect(W - PAD - 26 - 44 - 6 - 14, CurY, W - PAD - 26 - 44 - 6, CurY + HDR_BADGE_H); + DrawBadge(C, R, #$C3#$97, CLR_BTN_NORM, CLR_DIM); // UTF-8 '×' + RegisterHit(R, HIT_CLOSE); + end; // ширина фильтра if FFilterBW >= 1000 then BWStr := Format('%.1fk', [FFilterBW/1000.0]) else BWStr := IntToStr(FFilterBW); @@ -828,10 +921,12 @@ begin DrawIndicator(C, X + 48, CurY + 2, 'ANF', FANFOn); DrawIndicator(C, X + 80, CurY + 2, 'SNB', FSNBOn); MeterX := X + 112; + FMeterRect := Rect(MeterX, CurY, W - PAD, CurY + MET_H); // для RefreshMeter DrawMeterBar(C, MeterX, CurY, W - PAD - MeterX, MET_H); Inc(CurY, MET_H); { --- Частота + S --- } + FSRowTop := CurY; // для RefreshMeter FreqStr := FormatFreq(FVfoHz); C.Font.Name := 'Courier New'; C.Font.Size := 13; C.Font.Style := [fsBold]; C.Font.Color := CLR_FREQ; C.Brush.Style := bsClear; @@ -839,6 +934,7 @@ begin SStr := DBmToSLabel(FSMeterDB); C.Font.Size := 9; C.Font.Color := CLR_SVAL; C.TextOut(W - PAD - C.TextWidth(SStr) - 2, CurY + 6, SStr); + FLastSTextRect := Rect(W - PAD - C.TextWidth(SStr) - 3, CurY + 4, W - PAD, CurY + FRQ_H); Inc(CurY, FRQ_H); { --- Нижний бар: 🔊 vol | MODE DSP AUD --- } @@ -899,11 +995,8 @@ begin DrawSelf(Canvas, Width, Height); end; -procedure TVfoOverlay.DrawOverlay(Target: TBitmap; W, H: Integer); +function TVfoOverlay.EnsureRendered: TBitmap; begin - if (not Visible) or (Target = nil) or (Width <= 0) or (Height <= 0) then Exit; - if (Left >= W) or (Top >= H) then Exit; - if (FCacheBitmap.Width <> Width) or (FCacheBitmap.Height <> Height) then begin FCacheBitmap.SetSize(Width, Height); @@ -917,7 +1010,21 @@ begin FCacheBitmap.Canvas.FillRect(Rect(0, 0, Width, Height)); DrawSelf(FCacheBitmap.Canvas, Width, Height); FCacheDirty := False; + FMeterDirty := False; + end + else if FMeterDirty then + begin + RefreshMeter(FCacheBitmap.Canvas); // дешёвый апдейт: только полоска метра + FMeterDirty := False; end; + Result := FCacheBitmap; +end; + +procedure TVfoOverlay.DrawOverlay(Target: TBitmap; W, H: Integer); +begin + if (not Visible) or (Target = nil) or (Width <= 0) or (Height <= 0) then Exit; + if (Left >= W) or (Top >= H) then Exit; + EnsureRendered; BlendBitmapKey(Target, FCacheBitmap, Left, Top, VFO_OVERLAY_ALPHA); end; @@ -932,6 +1039,7 @@ begin Target.Canvas.FillRect(Rect(0, 0, Width, Height)); DrawSelf(Target.Canvas, Width, Height); FCacheDirty := False; + FMeterDirty := False; end; { ---- Обработка хитов ---- } @@ -949,6 +1057,8 @@ begin if Assigned(FOnMute) then FOnMute(Self); Exit; end; HIT_SPLIT: begin if Assigned(FOnSplit) then FOnSplit(Self); Exit; end; HIT_TX: begin if Assigned(FOnTxSelect) then FOnTxSelect(Self); Exit; end; + HIT_SLICE_SEL: begin if Assigned(FOnSliceSelect) then FOnSliceSelect(Self); Exit; end; + HIT_CLOSE: begin if Assigned(FOnClose) then FOnClose(Self); Exit; end; HIT_AUD_UP: begin if FAudScroll > 0 then Dec(FAudScroll); RequestInvalidate; Exit; end; HIT_AUD_DN: begin if FAudScroll < FDevNames.Count - AUD_MAX_VIS then @@ -1174,6 +1284,15 @@ begin RequestInvalidate; end; +procedure TVfoOverlay.SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean); +begin + if (FSliceId = ASliceId) and (FSlice = ALetter) and (FIsMain = AIsMain) then Exit; + FSliceId := ASliceId; + FSlice := ALetter; + FIsMain := AIsMain; + RequestInvalidate; +end; + procedure TVfoOverlay.SetAudioDevices(Names: TStrings; const Indices: array of Integer; const CurName: string); var @@ -1190,10 +1309,10 @@ end; procedure TVfoOverlay.UpdateSMeter(DB: Double); begin - if Abs(DB - FSMeterDB) > 0.4 then + if Abs(DB - FSMeterDB) > FSMeterThreshold then begin FSMeterDB := DB; - RequestInvalidate; + RequestMeterInvalidate; // только полоска метра, без ре-рендера всего флага end; end; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 2cb3170..52aebca 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -96,6 +96,10 @@ const RXA_CHAN = 0; TXA_CHAN = 1; // используем разные каналы для RX и TX + // --- Мультислайсы (софт-fan-out) --- + MAX_SLICES = 6; // потолок одновременных софтовых слайсов + SLICE_CHAN_BASE = 2; // WDSP-каналы слайсов: 0=RXA, 1=TXA, слайсы с 2+ + RX_DISP_ID = 0; // analyzer для RX-спектра/водопада TX_DISP_ID = 1; // analyzer для TX-спектра/водопада DSP_BUFSIZE = 1024; @@ -135,6 +139,10 @@ type TOnTXIQReady = procedure(const Buf: array of Double; Count: Integer) of object; TOnWaterfallReady = procedure(const Pixels: array of Single; Count: Integer) of object; + // Аудио готового слайса — вызывается из DSP-потока по каждому активному слайсу. + // SliceId — логический id (из RadioController), не WDSP-канал. + TOnSliceAudio = procedure(SliceId: Integer; const Left, Right: array of Single; + Count: Integer) of object; // Sound-card mic source: TTXDSPThread дёргает этот колбэк перед каждым тиком, // чтобы вытянуть накопленные сэмплы из внешнего буфера (например PortAudio). // Реализация должна вернуть кол-во отправленных в WDSP сэмплов. @@ -143,6 +151,28 @@ type // TX mic source: откуда получаем сэмплы для модулятора. TTXMicSource = (txmsRadio, txmsSoundCard, txmsWeb); + // Софтовый слайс: отдельный WDSP-канал, кормится тем же IQ-блоком, что и + // RXA_CHAN, но со своим NCO-сдвигом/режимом/фильтром/AGC/громкостью и своим + // аудио-callback'ом. Геометрия канала = как у RXA_CHAN. + TSliceChan = record + Active: Boolean; // слот занят + Id: Integer; // логический id слайса + Chan: Integer; // WDSP-канал (SLICE_CHAN_BASE + слот) + ShiftHz: Double; // NCO-сдвиг = TargetHz - CapturedCenterHz + Mode: Integer; // индекс режима (MODE_*) + FilterLo: Integer; + FilterHi: Integer; + AGCMode: TWDSPAGCMode; + Volume: Double; // 0..1 + Muted: Boolean; + NRMode: Integer; // 0=off,1..4 + NBMode: Integer; // 0=off,1=NB,2=NB2 + SNBOn: Boolean; + ANFOn: Boolean; + SMeter: Double; // последнее показание S-метра канала (dBm), пишет DSP-поток + Opened: Boolean; // WDSP-канал реально открыт (после Open движка) + end; + { TWDSPEngine } TWDSPEngine = class private @@ -219,6 +249,15 @@ type FOnWaterfall: TOnWaterfallReady; FOnTXIQ: TOnTXIQReady; + // --- Мультислайсы --- + FSlices: array[0..MAX_SLICES-1] of TSliceChan; + FSliceLock: TCriticalSection; // сериализует add/remove/set vs DSP-обработку + FSliceIn: array of Double; // interleaved вход fexchange0 (переиспользуем) + FSliceOut: array of Double; // interleaved выход fexchange0 + FSliceOutL: array of Single; // деинтерливленный L для callback + FSliceOutR: array of Single; // R + FOnSliceAudio: TOnSliceAudio; + // TX mic ring buffer (lock-free: один writer — receive thread, // один reader — TX DSP thread) FTXMicRing: array[0..TX_MIC_RING-1] of Double; @@ -311,6 +350,13 @@ type function ModeToWDSP(Mode: Integer): Integer; procedure ApplyDefaultFilter; + // --- Мультислайсы --- + function FindSliceIdx(Id: Integer): Integer; // -1 если нет + procedure OpenSliceChannel(Idx: Integer); // OpenChannel + настройки + procedure CloseSliceChannel(Idx: Integer); // CloseChannel (запись хранит) + procedure ApplyAGCToChan(Chan: Integer; Mode: TWDSPAGCMode; FixedGain: Double); + procedure ProcessSlices; // fan-out в ProcessRXBlock + procedure AllocSliceBuffers; // (пере)аллокация буферов procedure FeedDisplaySample(const I, Q: Double); procedure ProcessRXBlock; procedure PushIQItemToDSP(const Item: TIQQueueItem); @@ -329,6 +375,10 @@ type procedure ApplyNBState; procedure ApplySNBState; procedure ApplyANFState; + procedure ApplyNRToChan(Chan, NRMode: Integer); + procedure ApplyNBToChan(Chan, NBMode: Integer); + procedure ApplySNBToChan(Chan: Integer; On_: Boolean); + procedure ApplyANFToChan(Chan: Integer; On_: Boolean); procedure ApplyNoiseFilterState; procedure ProcessTXBlock; // вызывается из TTXDSPThread procedure FeedTXDisplaySample(const I, Q: Double); @@ -350,6 +400,26 @@ type // QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет). procedure SetBeaconDecoder(D: TBeaconDecoder); + // --- Мультислайсы (софт-fan-out) --- + // Добавляет слайс с логическим Id (уникальным). ShiftHz = TargetHz - центр + // захвата. Возвращает False если нет свободных слотов или Id уже есть. + function AddSlice(Id, Mode, FilterLo, FilterHi: Integer; + ShiftHz, Volume: Double; AGC: TWDSPAGCMode): Boolean; + procedure RemoveSlice(Id: Integer); + procedure SetSliceShift(Id: Integer; ShiftHz: Double); + procedure SetSliceMode(Id, Mode: Integer); + procedure SetSliceFilter(Id, Low, High: Integer); + procedure SetSliceAGC(Id: Integer; Mode: TWDSPAGCMode); + procedure SetSliceVolume(Id: Integer; Vol: Double); + procedure SetSliceMute(Id: Integer; Mute: Boolean); + procedure SetSliceNRMode(Id, Mode: Integer); + procedure SetSliceNBMode(Id, Mode: Integer); + procedure SetSliceSNB(Id: Integer; On_: Boolean); + procedure SetSliceANF(Id: Integer; On_: Boolean); + function GetSliceSMeter(Id: Integer): Double; + function SliceCount: Integer; + function HasSlice(Id: Integer): Boolean; + // Подача 24-bit big-endian IQ из DDC пакета // Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf procedure PushDDCPacket(const Buf: array of Byte; @@ -475,6 +545,7 @@ type property WfAvgMode: Integer read FWfAvgMode; property WfAvgTimeMS: Double read FWfAvgTimeMS; property OnAudio: TOnAudioReady read FOnAudio write FOnAudio; + property OnSliceAudio: TOnSliceAudio read FOnSliceAudio write FOnSliceAudio; property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum; property OnWaterfall: TOnWaterfallReady read FOnWaterfall write FOnWaterfall; property OnTXIQ: TOnTXIQReady read FOnTXIQ write FOnTXIQ; @@ -813,6 +884,12 @@ begin FDSPThread := nil; FDisplayThread := nil; + // Мультислайсы + FSliceLock := TCriticalSection.Create; + FillChar(FSlices, SizeOf(FSlices), 0); + FOnSliceAudio := nil; + AllocSliceBuffers; + // TX инициализация FTXMicHead := 0; FTXMicTail := 0; @@ -887,6 +964,7 @@ begin Close; RTLEventDestroy(FQueueSem); RTLEventDestroy(FTXMicSem); + FSliceLock.Free; inherited; end; @@ -1052,6 +1130,7 @@ end; function TWDSPEngine.Open: Boolean; var DllPath: string; + i: Integer; begin Result := False; FLastError := ''; @@ -1131,6 +1210,18 @@ begin // Leveler, ALC, Compressor, Phase Rotator, AM Carrier, FM, CTCSS. ApplyTXChainSettings; ApplyNoiseFilterState; + + // Переоткрываем WDSP-каналы активных слайсов (например после ChangeSampleRate, + // где Close закрыл их, но записи FSlices сохранились). + FSliceLock.Enter; + try + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and not FSlices[i].Opened then + OpenSliceChannel(i); + finally + FSliceLock.Leave; + end; + Result := True; // Запускаем DSP поток после успешной инициализации WDSP @@ -1149,6 +1240,8 @@ begin end; procedure TWDSPEngine.Close; +var + i: Integer; begin if not FInitialized then Exit; @@ -1161,6 +1254,15 @@ begin FreeAndNil(FDSPThread); end; + // Закрываем WDSP-каналы слайсов (записи FSlices сохраняем — переоткроются в Open). + FSliceLock.Enter; + try + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Opened then CloseSliceChannel(i); + finally + FSliceLock.Leave; + end; + CloseAnalyzer; destroy_anbEXT(RXA_CHAN); destroy_nobEXT(RXA_CHAN); @@ -1195,6 +1297,7 @@ begin SetLength(FRXAccQ, FBufSize); SetLength(FDispBuf, DISPLAY_BLOCK_SIZE * 2); FDispPos := 0; + AllocSliceBuffers; // Переинициализируем WDSP с новым rate Open; @@ -1379,11 +1482,360 @@ begin FOnAudio(FOutL, FOutR, FAudioBufSize); end; + // Софтовые слайсы: fan-out того же IQ-блока на доп. WDSP-каналы. + ProcessSlices; + except // Защита от падения libwdsp в потоке — просто пропускаем блок end; end; +// --------------------------------------------------------------------------- +// Мультислайсы (софт-fan-out) +// --------------------------------------------------------------------------- + +procedure TWDSPEngine.AllocSliceBuffers; +begin + SetLength(FSliceIn, FBufSize * 2); + SetLength(FSliceOut, FBufSize * 2); // с запасом (>= FAudioBufSize*2) + SetLength(FSliceOutL, FAudioBufSize); + SetLength(FSliceOutR, FAudioBufSize); +end; + +function TWDSPEngine.FindSliceIdx(Id: Integer): Integer; +var i: Integer; +begin + Result := -1; + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and (FSlices[i].Id = Id) then Exit(i); +end; + +// Применяет AGC к произвольному каналу — вынесено из SetAGC чтобы делить с слайсами. +procedure TWDSPEngine.ApplyAGCToChan(Chan: Integer; Mode: TWDSPAGCMode; + FixedGain: Double); +begin + SetRXAAGCMode(Chan, Ord(Mode)); + SetRXAAGCSlope(Chan, FAGCSlope); + SetRXAAGCTop(Chan, FAGCTop); + case Mode of + agcOff: + SetRXAAGCFixed(Chan, FixedGain); + agcLong: begin + SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 2000); + SetRXAAGCDecay(Chan, 2000); SetRXAAGCHangThreshold(Chan, FAGCHangThreshold); + end; + agcSlow: begin + SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 1000); + SetRXAAGCDecay(Chan, 500); SetRXAAGCHangThreshold(Chan, FAGCHangThreshold); + end; + agcMedium: begin + SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 0); + SetRXAAGCDecay(Chan, 250); SetRXAAGCHangThreshold(Chan, 100); + end; + agcFast: begin + SetRXAAGCAttack(Chan, 2); SetRXAAGCHang(Chan, 0); + SetRXAAGCDecay(Chan, 50); SetRXAAGCHangThreshold(Chan, 100); + end; + end; +end; + +// Открывает WDSP-канал слайса с той же геометрией, что RXA_CHAN. Вызывать +// под FSliceLock и только при FInitialized. +procedure TWDSPEngine.OpenSliceChannel(Idx: Integer); +var Chan: Integer; +begin + if not FInitialized then Exit; + if FSlices[Idx].Opened then Exit; + Chan := FSlices[Idx].Chan; + OpenChannel(Chan, FBufSize, FAudioBufSize, FSampleRate, FAudioRate, FAudioRate, + 0, 0, 0.010, 0.010, 0.010, 0.010, 0); + // NB (ANB/NOB) требует создания EXT-блоков на канале, как для RXA_CHAN в Open. + create_anbEXT(Chan, 1, FBufSize, FSampleRate, 0.0001, 0.0001, 0.0001, 0.05, 20); + create_nobEXT(Chan, 1, 0, FBufSize, FSampleRate, 0.0001, 0.0001, 0.0001, 0.05, 20); + SetRXABandpassWindow(Chan, 1); + SetRXAMode(Chan, ModeToWDSP(FSlices[Idx].Mode)); + SetRXAShiftRun(Chan, 1); + SetRXAShiftFreq(Chan, FSlices[Idx].ShiftHz); + RXASetPassband(Chan, FSlices[Idx].FilterLo, FSlices[Idx].FilterHi); + ApplyAGCToChan(Chan, FSlices[Idx].AGCMode, 50.0); + // NR/NB/SNB/ANF из сохранённого состояния слайса. + ApplyNRToChan(Chan, FSlices[Idx].NRMode); + ApplyNBToChan(Chan, FSlices[Idx].NBMode); + ApplySNBToChan(Chan, FSlices[Idx].SNBOn); + ApplyANFToChan(Chan, FSlices[Idx].ANFOn); + SetRXAPanelGain1(Chan, FSlices[Idx].Volume); + SetRXAPanelSelect(Chan, 3); + SetRXAPanelRun(Chan, 1); + SetChannelState(Chan, 1, 0); + FSlices[Idx].Opened := True; +end; + +procedure TWDSPEngine.CloseSliceChannel(Idx: Integer); +begin + if not FSlices[Idx].Opened then Exit; + SetChannelState(FSlices[Idx].Chan, 0, 1); + destroy_anbEXT(FSlices[Idx].Chan); + destroy_nobEXT(FSlices[Idx].Chan); + CloseChannel(FSlices[Idx].Chan); + FSlices[Idx].Opened := False; +end; + +// Fan-out: прогоняет тот же входной IQ-блок через каждый активный слайс-канал. +// Вызывается из ProcessRXBlock (DSP-поток) после основного канала. +procedure TWDSPEngine.ProcessSlices; +var + s, i, k, Err: Integer; + V: Double; +begin + if not Assigned(FOnSliceAudio) then Exit; + FSliceLock.Enter; + try + for s := 0 to MAX_SLICES - 1 do + begin + if not (FSlices[s].Active and FSlices[s].Opened) then Continue; + if FSlices[s].Muted then Continue; + // Свежая копия входного IQ — fexchange0 портит вход in-place. + for i := 0 to FBufSize - 1 do + begin + FSliceIn[i * 2] := FRXAccI[i]; + FSliceIn[i * 2 + 1] := FRXAccQ[i]; + end; + Err := 0; + fexchange0(FSlices[s].Chan, @FSliceIn[0], @FSliceOut[0], @Err); + // S-метр канала читаем здесь (DSP-поток) — GetRXAMeter сбрасывает аккумулятор. + FSlices[s].SMeter := GetRXAMeter(FSlices[s].Chan, RXA_S_AV); + V := FSlices[s].Volume; + for k := 0 to FAudioBufSize - 1 do + begin + FSliceOutL[k] := FSliceOut[k * 2] * V; + FSliceOutR[k] := FSliceOut[k * 2 + 1] * V; + end; + FOnSliceAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize); + end; + finally + FSliceLock.Leave; + end; +end; + +function TWDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi: Integer; + ShiftHz, Volume: Double; AGC: TWDSPAGCMode): Boolean; +var i, slot: Integer; +begin + Result := False; + FSliceLock.Enter; + try + if FindSliceIdx(Id) >= 0 then Exit; // уже существует + slot := -1; + for i := 0 to MAX_SLICES - 1 do + if not FSlices[i].Active then begin slot := i; Break; end; + if slot < 0 then Exit; // нет свободных слотов + FSlices[slot].Active := True; + FSlices[slot].Id := Id; + FSlices[slot].Chan := SLICE_CHAN_BASE + slot; + FSlices[slot].Mode := Mode; + FSlices[slot].FilterLo := FilterLo; + FSlices[slot].FilterHi := FilterHi; + FSlices[slot].ShiftHz := ShiftHz; + FSlices[slot].Volume := Volume; + FSlices[slot].AGCMode := AGC; + FSlices[slot].Muted := False; + FSlices[slot].NRMode := NR_MODE_OFF; + FSlices[slot].NBMode := NB_MODE_OFF; + FSlices[slot].SNBOn := False; + FSlices[slot].ANFOn := False; + FSlices[slot].SMeter := -130.0; + FSlices[slot].Opened := False; + if FInitialized then OpenSliceChannel(slot); + Result := True; + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.RemoveSlice(Id: Integer); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); + if idx < 0 then Exit; + CloseSliceChannel(idx); + FSlices[idx].Active := False; + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceShift(Id: Integer; ShiftHz: Double); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].ShiftHz := ShiftHz; + if FSlices[idx].Opened then SetRXAShiftFreq(FSlices[idx].Chan, ShiftHz); + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceMode(Id, Mode: Integer); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].Mode := Mode; + if FSlices[idx].Opened then SetRXAMode(FSlices[idx].Chan, ModeToWDSP(Mode)); + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceFilter(Id, Low, High: Integer); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].FilterLo := Low; + FSlices[idx].FilterHi := High; + if FSlices[idx].Opened then RXASetPassband(FSlices[idx].Chan, Low, High); + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceAGC(Id: Integer; Mode: TWDSPAGCMode); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].AGCMode := Mode; + if FSlices[idx].Opened then ApplyAGCToChan(FSlices[idx].Chan, Mode, 50.0); + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceVolume(Id: Integer; Vol: Double); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].Volume := Vol; + if FSlices[idx].Opened then SetRXAPanelGain1(FSlices[idx].Chan, Vol); + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceMute(Id: Integer; Mute: Boolean); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].Muted := Mute; + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceNRMode(Id, Mode: Integer); +var idx: Integer; +begin + if Mode < NR_MODE_OFF then Mode := NR_MODE_OFF; + if Mode > NR_MODE_4 then Mode := NR_MODE_4; + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].NRMode := Mode; + if FSlices[idx].Opened then ApplyNRToChan(FSlices[idx].Chan, Mode); + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceNBMode(Id, Mode: Integer); +var idx: Integer; +begin + if Mode < NB_MODE_OFF then Mode := NB_MODE_OFF; + if Mode > NB_MODE_2 then Mode := NB_MODE_2; + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].NBMode := Mode; + if FSlices[idx].Opened then ApplyNBToChan(FSlices[idx].Chan, Mode); + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceSNB(Id: Integer; On_: Boolean); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].SNBOn := On_; + if FSlices[idx].Opened then ApplySNBToChan(FSlices[idx].Chan, On_); + finally + FSliceLock.Leave; + end; +end; + +procedure TWDSPEngine.SetSliceANF(Id: Integer; On_: Boolean); +var idx: Integer; +begin + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); if idx < 0 then Exit; + FSlices[idx].ANFOn := On_; + if FSlices[idx].Opened then ApplyANFToChan(FSlices[idx].Chan, On_); + finally + FSliceLock.Leave; + end; +end; + +function TWDSPEngine.GetSliceSMeter(Id: Integer): Double; +var idx: Integer; +begin + Result := -130.0; + FSliceLock.Enter; + try + idx := FindSliceIdx(Id); + if idx >= 0 then Result := FSlices[idx].SMeter; + finally + FSliceLock.Leave; + end; +end; + +function TWDSPEngine.SliceCount: Integer; +var i: Integer; +begin + Result := 0; + FSliceLock.Enter; + try + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active then Inc(Result); + finally + FSliceLock.Leave; + end; +end; + +function TWDSPEngine.HasSlice(Id: Integer): Boolean; +begin + FSliceLock.Enter; + try + Result := FindSliceIdx(Id) >= 0; + finally + FSliceLock.Leave; + end; +end; + // --------------------------------------------------------------------------- // RX управление // --------------------------------------------------------------------------- @@ -1412,42 +1864,7 @@ begin if not FInitialized then Exit; // Точно по piHPSDR receiver.c: set_agc() - SetRXAAGCMode(RXA_CHAN, Ord(Mode)); - SetRXAAGCSlope(RXA_CHAN, FAGCSlope); - SetRXAAGCTop(RXA_CHAN, FAGCTop); - - case Mode of - agcOff: - SetRXAAGCFixed(RXA_CHAN, FixedGain); - - agcLong: begin - SetRXAAGCAttack(RXA_CHAN, 2); - SetRXAAGCHang(RXA_CHAN, 2000); - SetRXAAGCDecay(RXA_CHAN, 2000); - SetRXAAGCHangThreshold(RXA_CHAN, FAGCHangThreshold); - end; - - agcSlow: begin - SetRXAAGCAttack(RXA_CHAN, 2); - SetRXAAGCHang(RXA_CHAN, 1000); - SetRXAAGCDecay(RXA_CHAN, 500); - SetRXAAGCHangThreshold(RXA_CHAN, FAGCHangThreshold); - end; - - agcMedium: begin - SetRXAAGCAttack(RXA_CHAN, 2); - SetRXAAGCHang(RXA_CHAN, 0); - SetRXAAGCDecay(RXA_CHAN, 250); - SetRXAAGCHangThreshold(RXA_CHAN, 100); - end; - - agcFast: begin - SetRXAAGCAttack(RXA_CHAN, 2); - SetRXAAGCHang(RXA_CHAN, 0); - SetRXAAGCDecay(RXA_CHAN, 50); - SetRXAAGCHangThreshold(RXA_CHAN, 100); - end; - end; + ApplyAGCToChan(RXA_CHAN, Mode, FixedGain); // Читаем обратно hang level и thresh для линии на спектре UpdateAGCLines(FLastSpectrumW); @@ -1502,69 +1919,88 @@ begin FSampleRate); end; +// Параметризованные по каналу версии — делятся между главным каналом и слайсами. +procedure TWDSPEngine.ApplyNRToChan(Chan, NRMode: Integer); +begin + SetRXAANRVals(Chan, 64, 16, 16e-4, 10e-7); + SetRXAANRPosition(Chan, FILTER_NR_AGC_POSITION); + SetRXAANRRun(Chan, Ord(NRMode = NR_MODE_1)); + + SetRXAEMNRPosition(Chan, FILTER_NR_AGC_POSITION); + SetRXAEMNRgainMethod(Chan, FILTER_NR2_GAIN_METHOD); + SetRXAEMNRnpeMethod(Chan, FILTER_NR2_NPE_METHOD); + SetRXAEMNRtrainZetaThresh(Chan, FILTER_NR2_TRAINED_THRESHOLD); + SetRXAEMNRtrainT2(Chan, FILTER_NR2_TRAINED_T2); + SetRXAEMNRpost2Taper(Chan, FILTER_NR2_POST_TAPER); + SetRXAEMNRpost2Nlevel(Chan, FILTER_NR2_POST_NLEVEL); + SetRXAEMNRpost2Factor(Chan, FILTER_NR2_POST_FACTOR); + SetRXAEMNRpost2Rate(Chan, FILTER_NR2_POST_RATE); + SetRXAEMNRaeRun(Chan, 1); + SetRXAEMNRpost2Run(Chan, FILTER_NR2_POST_RUN); + SetRXAEMNRRun(Chan, Ord(NRMode = NR_MODE_2)); + + SetRXARNNRPosition(Chan, FILTER_NR_AGC_POSITION); + SetRXARNNRRun(Chan, Ord(NRMode = NR_MODE_3)); + + SetRXASBNRreductionAmount(Chan, FILTER_NR4_REDUCTION_AMOUNT); + SetRXASBNRsmoothingFactor(Chan, FILTER_NR4_SMOOTHING_FACTOR); + SetRXASBNRwhiteningFactor(Chan, FILTER_NR4_WHITENING_FACTOR); + SetRXASBNRnoiseRescale(Chan, FILTER_NR4_NOISE_RESCALE); + SetRXASBNRpostFilterThreshold(Chan, FILTER_NR4_POST_THRESHOLD); + SetRXASBNRnoiseScalingType(Chan, FILTER_NR4_NOISE_SCALING_TYPE); + SetRXASBNRPosition(Chan, FILTER_NR_AGC_POSITION); + SetRXASBNRRun(Chan, Ord(NRMode = NR_MODE_4)); +end; + +procedure TWDSPEngine.ApplyNBToChan(Chan, NBMode: Integer); +begin + SetEXTANBTau(Chan, FILTER_NB_TAU); + SetEXTANBHangtime(Chan, FILTER_NB_HANGTIME); + SetEXTANBAdvtime(Chan, FILTER_NB_ADVTIME); + SetEXTANBThreshold(Chan, FILTER_NB_THRESHOLD); + SetEXTANBRun(Chan, Ord(NBMode = NB_MODE_1)); + + SetEXTNOBMode(Chan, FILTER_NB2_MODE); + SetEXTNOBTau(Chan, FILTER_NB_TAU); + SetEXTNOBHangtime(Chan, FILTER_NB_HANGTIME); + SetEXTNOBAdvtime(Chan, FILTER_NB_ADVTIME); + SetEXTNOBThreshold(Chan, FILTER_NB_THRESHOLD); + SetEXTNOBRun(Chan, Ord(NBMode = NB_MODE_2)); +end; + +procedure TWDSPEngine.ApplySNBToChan(Chan: Integer; On_: Boolean); +begin + SetRXASNBARun(Chan, Ord(On_)); +end; + +procedure TWDSPEngine.ApplyANFToChan(Chan: Integer; On_: Boolean); +begin + SetRXAANFPosition(Chan, FILTER_NR_AGC_POSITION); + SetRXAANFRun(Chan, Ord(On_)); +end; + procedure TWDSPEngine.ApplyNRState; begin if not FInitialized then Exit; - - SetRXAANRVals(RXA_CHAN, 64, 16, 16e-4, 10e-7); - SetRXAANRPosition(RXA_CHAN, FILTER_NR_AGC_POSITION); - SetRXAANRRun(RXA_CHAN, Ord(FNRMode = NR_MODE_1)); - - SetRXAEMNRPosition(RXA_CHAN, FILTER_NR_AGC_POSITION); - SetRXAEMNRgainMethod(RXA_CHAN, FILTER_NR2_GAIN_METHOD); - SetRXAEMNRnpeMethod(RXA_CHAN, FILTER_NR2_NPE_METHOD); - SetRXAEMNRtrainZetaThresh(RXA_CHAN, FILTER_NR2_TRAINED_THRESHOLD); - SetRXAEMNRtrainT2(RXA_CHAN, FILTER_NR2_TRAINED_T2); - SetRXAEMNRpost2Taper(RXA_CHAN, FILTER_NR2_POST_TAPER); - SetRXAEMNRpost2Nlevel(RXA_CHAN, FILTER_NR2_POST_NLEVEL); - SetRXAEMNRpost2Factor(RXA_CHAN, FILTER_NR2_POST_FACTOR); - SetRXAEMNRpost2Rate(RXA_CHAN, FILTER_NR2_POST_RATE); - SetRXAEMNRaeRun(RXA_CHAN, 1); - SetRXAEMNRpost2Run(RXA_CHAN, FILTER_NR2_POST_RUN); - SetRXAEMNRRun(RXA_CHAN, Ord(FNRMode = NR_MODE_2)); - - SetRXARNNRPosition(RXA_CHAN, FILTER_NR_AGC_POSITION); - SetRXARNNRRun(RXA_CHAN, Ord(FNRMode = NR_MODE_3)); - - SetRXASBNRreductionAmount(RXA_CHAN, FILTER_NR4_REDUCTION_AMOUNT); - SetRXASBNRsmoothingFactor(RXA_CHAN, FILTER_NR4_SMOOTHING_FACTOR); - SetRXASBNRwhiteningFactor(RXA_CHAN, FILTER_NR4_WHITENING_FACTOR); - SetRXASBNRnoiseRescale(RXA_CHAN, FILTER_NR4_NOISE_RESCALE); - SetRXASBNRpostFilterThreshold(RXA_CHAN, FILTER_NR4_POST_THRESHOLD); - SetRXASBNRnoiseScalingType(RXA_CHAN, FILTER_NR4_NOISE_SCALING_TYPE); - SetRXASBNRPosition(RXA_CHAN, FILTER_NR_AGC_POSITION); - SetRXASBNRRun(RXA_CHAN, Ord(FNRMode = NR_MODE_4)); + ApplyNRToChan(RXA_CHAN, FNRMode); end; procedure TWDSPEngine.ApplyNBState; begin if not FInitialized then Exit; - - SetEXTANBTau(RXA_CHAN, FILTER_NB_TAU); - SetEXTANBHangtime(RXA_CHAN, FILTER_NB_HANGTIME); - SetEXTANBAdvtime(RXA_CHAN, FILTER_NB_ADVTIME); - SetEXTANBThreshold(RXA_CHAN, FILTER_NB_THRESHOLD); - SetEXTANBRun(RXA_CHAN, Ord(FNBMode = NB_MODE_1)); - - SetEXTNOBMode(RXA_CHAN, FILTER_NB2_MODE); - SetEXTNOBTau(RXA_CHAN, FILTER_NB_TAU); - SetEXTNOBHangtime(RXA_CHAN, FILTER_NB_HANGTIME); - SetEXTNOBAdvtime(RXA_CHAN, FILTER_NB_ADVTIME); - SetEXTNOBThreshold(RXA_CHAN, FILTER_NB_THRESHOLD); - SetEXTNOBRun(RXA_CHAN, Ord(FNBMode = NB_MODE_2)); + ApplyNBToChan(RXA_CHAN, FNBMode); end; procedure TWDSPEngine.ApplySNBState; begin if not FInitialized then Exit; - SetRXASNBARun(RXA_CHAN, Ord(FSNBEnabled)); + ApplySNBToChan(RXA_CHAN, FSNBEnabled); end; procedure TWDSPEngine.ApplyANFState; begin if not FInitialized then Exit; - SetRXAANFPosition(RXA_CHAN, FILTER_NR_AGC_POSITION); - SetRXAANFRun(RXA_CHAN, Ord(FANFEnabled)); + ApplyANFToChan(RXA_CHAN, FANFEnabled); end; procedure TWDSPEngine.ApplyNoiseFilterState; diff --git a/doc/SLICES_PLAN.md b/doc/SLICES_PLAN.md new file mode 100644 index 0000000..f211beb --- /dev/null +++ b/doc/SLICES_PLAN.md @@ -0,0 +1,181 @@ +# Мультислайсы (несколько приёмных фильтров, как во FlexRadio) + +Статус: **план; UI-каркас флага слайса уже сделан** (см. «Что уже готово»), +DSP/роутинг (Этапы 1/3) — не начаты. Ничего не коммитить без явной просьбы. + +## Что уже готово + +`VfoOverlay.pas` переделан в **«флаг слайса»** в стиле SmartSDR (6 коммитов +`4a92256..a04c592`, уже в `main`). Это ровно UI-управление одним слайсом из +Этапа 2, только пока в единственном экземпляре: + +- Флаг-накладка поверх спектра с буквой слайса (`FSlice`, сейчас захардкожено `'A'`), + fly-out пикерами **MODE / DSP / AUD**, слайдером громкости, mute, SPLIT, + кликабельным **TX-бейджем** (выбор TX-слайса) и S-метром. +- **Per-flag выбор аудио-устройства** уже есть: `SetAudioDevices` + `OnAudioDevice` + (`VfoOverlay.pas:31,125,136`) — прокручиваемый список в AUD-fly-out. Это готовый + UI под «своё звуковое устройство на слайс» из абстракции `TSlice.DeviceIndex`. +- События наружу: `OnSelect/OnDSPChange/OnAGCChange/OnVolume/OnMute/OnAudioDevice/` + `OnSplit/OnTxSelect/OnInvalidate` (`VfoOverlay.pas:131-139`). +- Автопозиционирование: флаг флипается влево, если не влезает справа (`6ad5f29`). + +Привязка в приложении **пока одиночная**: один `FVfoOverlay` в `MainForm` +(`MainForm.pas:125`), созданный/подключённый на `MainForm.pas:962-972`, привязан к +VFO A (`UpdateVfo(FController.FVfoA)`, `MainForm.pas:3265`); хендлеры +`OnVfoOverlay*` шлют изменения в главный тракт. `SpectrumView` держит один +`FVfoOverlay` и рисует его в `DrawOverlay` (`SpectrumView.pas:72,220,1010`). + +Чтобы превратить это в мультислайс — нужно из одиночного флага сделать **коллекцию +флагов (по одному на слайс)** и развести их события на конкретный `SliceId` вместо +глобального VFO A. Детали — в обновлённом Этапе 2. + +## Цель + +Несколько независимых приёмных «слайсов» на одном ADC/одном IQ-потоке: +у каждого слайса своя частота, режим, полоса, AGC и **своё звуковое +устройство** (как DAX/наушники/динамик во Flex). Панадаптер (спектр+водопад) +остаётся один, слайсы рисуются маркерами поверх. + +## Как это устроено у Flex и что из этого берём + +Слайс во Flex — это **не «ещё один фильтр» на одном приёмнике**, а полноценный +независимый узкополосный приёмник (свой DDC в FPGA + демодулятор + фильтр + AGC + +аудио). Общий у слайсов — только панадаптер (широкий FFT-захват). + +Один WDSP-канал = один демодулятор = один фильтр = один аудиопоток. Значит для +«разный звук/режим/полоса на каждый слайс» нужен **отдельный WDSP-канал на слайс**. +Способ получить узкополосный IQ для канала различается по железу — см. ниже. + +## Ключевое различие бэкендов + +``` +openHPSDR (Protocol 2, V4.3, MAX_DDCS=80): + ADC0 ─┬─ DDC (FPGA-тюнер) → IQ-поток порт 1035+n → WDSP-канал → фильтр/AGC → AudioOut[dev] + ├─ DDC → ... + └─ DDC → ... + • Аппаратные DDC: слайс тюнится КУДА УГОДНО в полосе ADC (весь КВ), дёшево по CPU. + • На Angelia/Orion/Saturn DDC0/DDC1 заняты PureSignal → слайсы с DDC2+ (в коде DDCBase=2). + • RX2 = ВТОРОЙ ADC (ADC1), отдельная антенна/diversity — ОРТОГОНАЛЬНО слайсам, не нужно. + +Pluto (AD9363, libiio): + один RX-тракт: один LO, один rf_bandwidth, один IQ-поток (притворяется одним DDC, + пушится как TDDCIQPacket в тот же конвейер). + • Аппаратных DDC НЕТ. Слайсы = СОФТОВЫЙ fan-out одного IQ-блока на N WDSP-каналов, + каждый со своим SetRXAShiftFreq в пределах захваченной полосы вокруг LO. + • Для QO-100 идеально: весь транспондер (~500 кГц) в захвате, LO залочен на маяк. +``` + +## Единая абстракция + +```pascal +TSlice = record + Id: Integer; + TargetHz: Double; // абсолютная частота слайса + Mode: TRxMode; // USB/LSB/CW/FM/... + FilterLo, + FilterHi: Integer; + AGCMode: TWDSPAGCMode; + Volume: Double; + WDSPChannel: Integer; // канал WDSP (0..N); 0=главный, 1=TX заняты + Source: (srcSoftShift, srcHardDDC); // как получаем узкий IQ + DDCIndex: Integer; // если srcHardDDC — какой аппаратный DDC + ShiftHz: Double; // если srcSoftShift — сдвиг = TargetHz - CapturedCenterHz + AudioOut: TAudioOutput; // свой инстанс со своим DeviceIndex + DeviceIndex: Integer; // выбранная звуковуха (или -1 = default) + Enabled: Boolean; +end; +``` + +Правило маршрутизации по частоте (одно на оба бэкенда): +- слайс **внутри** текущего захваченного span → `srcSoftShift` (Pluto и HPSDR); +- слайс **вне** захваченного span → `srcHardDDC` (только HPSDR; Pluto так не умеет). + +## Порядок работ + +### Этап 1 — Софтовый fan-out в WDSPEngine (backend-agnostic) + +Даёт слайсы сразу на Pluto/QO-100 (единственный режим) и на HPSDR для слайсов +внутри видимой полосы. Pluto уже пушит IQ через тот же DDC-конвейер, поэтому +fan-out живёт в движке и не зависит от бэкенда. + +- `WDSPEngine.pas`: сейчас `RXA_CHAN=0` один канал, один `FRXOut`, один аудиовыход + (`WDSPEngine.pas:96`, alloc буферов ~`:159`, OpenChannel ~`:1081`). + - Добавить массив слайс-каналов: для каждого активного слайса `OpenChannel(chN, ...)` + с той же геометрией, что RXA_CHAN, + `SetRXAShiftRun/SetRXAShiftFreq(chN, ShiftHz)` + (механизм уже используется: `WDSPEngine.pas:1099-1100`), `SetRXAMode`, + `RXASetPassband`, AGC, `SetRXAPanelGain1`. + - В DSP-потоке после накопления IQ-блока (`FRXAccI/Q`, `FRXAccPos`) прогонять + `fexchange0` по КАЖДОМУ активному каналу из ОДНОГО и того же входного буфера + (входной буфер не портить in-place между каналами — держать копию, как `FSpecBuf`). + - Выход каждого канала → свой callback `OnSliceAudio(SliceId, L, R, Count)`. +- `AudioOutput.pas`: уже готов к мульти-устройству — `DeviceIndex`, `EnumOutputDevices`, + `FindDeviceByName` (`AudioOutput.pas:137-151`). Создавать **отдельный `TAudioOutput` + на слайс**, каждый со своим `DeviceIndex`. +- `RadioController.pas`: `TSlice[]` (add/remove/retune/setmode/setdevice), роутинг + `OnSliceAudio` → нужный `TAudioOutput`. + +Проверка Этапа 1: Pluto/QO-100 — 2 слайса (VFO + маяк-монитор), звук в две разные +звуковухи, независимые режимы/полосы. Сборка: `lazbuild --ws=qt6 ewsdr.lpr`. + +### Этап 2 — UI слайсов на водопаде + выбор устройства + +Каркас флага слайса уже сделан (см. «Что уже готово»): один `TVfoOverlay` со всеми +пикерами и per-flag выбором устройства. Остаётся размножить его на N слайсов и +привязать к позиции по частоте. Выпадашка устройства и dropdown-паттерн **уже есть в +самом флаге** (AUD fly-out) — отдельный `DeviceForm` не нужен. + +- **Коллекция флагов вместо одного.** Из `MainForm.FVfoOverlay` (одиночный, + `MainForm.pas:125`) сделать `array of TVfoOverlay` / список, по флагу на слайс; + `SpectrumView.FVfoOverlay` (`SpectrumView.pas:72`) — тоже коллекция, рисовать все в + `DrawOverlay` (`SpectrumView.pas:1010`). Разместить: `SetSlice(Id, Letter)` в оверлей, + события `OnVfoOverlay*` должны нести `SliceId` (сейчас бьют по VFO A напрямую — + завести маппинг оверлей→слайс, роутить в нужный `TSlice`/WDSP-канал из Этапа 1). +- **Позиционирование по частоте.** `PositionVfoOverlay` (`MainForm.pas:518`) сейчас + ставит один флаг; расширить на маппинг `TargetHz → X` через `GetViewWindow`/ + `SyncSpecViewFreq` (зум-работа). Слайс за краем видимой полосы — маркер у края + (флип влево/вправо уже есть, `6ad5f29`). +- **ЛКМ по водопаду** = поставить/двинуть активный слайс; выделение активного + (подсветка буквы/рамки флага). Хит-тест мыши флага уже разведён + (`HandleMouseDown/Move/Up`, `MainForm.pas:4424`) — добавить «клик мимо флага по + спектру = ретюн активного слайса». +- **TX-select** уже кликабелен (`OnTxSelect`, `08c71c4`) — при мультислайсе выбирать, + какой слайс идёт в TX; снимать флаг с остальных. +- Персист: список слайсов в `Settings.pas` (per-band или глобально — решить), включая + `DeviceIndex`/имя устройства каждого флага. + +### Этап 3 — Аппаратные DDC для openHPSDR (слайс куда угодно по КВ) + +Апгрейд поверх общего каркаса; нужен только чтобы ставить слайс ВНЕ захваченной +полосы. Плита ~на 70% есть: +- `ConfigureDDCs(NumDDCs, ...)` уже включает N DDC с per-DDC конфигом/ADC-source + (`HPSDRNetwork.pas:220`, `DDCConfig[ddc*6+0]=ADCSource`). +- Приём разведён по DDC: порты `1035+n` → `HandleDDCIQ(Buf,Len,DDCIdx)` + (`HPSDRNetwork.pas:392`). +- High Priority уже держит частоту на КАЖДЫЙ DDC (bytes 9-12=DDC0, 13-16=DDC1, …) + `HPSDRProtocol.pas:271`. + +Чего не хватает: +1. Per-DDC частота: сейчас `SetRunAndFreq(...; DDC0FreqHz; ...)` знает только DDC0 + (`HPSDRNetwork.pas:224`) — писать частотное слово каждого DDC в High Priority. +2. Роутинг `HandleDDCIQ(DDCIdx)` → соответствующий WDSP-канал слайса. +3. Учёт DDCBase=2 (PureSignal) и Ethernet-бюджета (реально 4–8 слайсов, не 80). + +`feature/rx2-support` — это про ВТОРОЙ ADC, не про слайсы; но её каркас +(доп. WDSP-канал + доп. аудиовыход + роутинг по индексу) частично переиспользуем. + +## Оговорки / риски + +- CPU софтовых слайсов ~линейно от их числа: каждый WDSP-канал жуёт полный rate и + децимирует сам (аппаратный DDC дешевле — децимирует железо). Для QO-100 span мал, + несколько слайсов — норма. +- Не портить входной IQ-буфер между каналами (fexchange0 пишет in-place). +- Pluto: слайсы только в пределах захваченной полосы; выход за край = ретюн LO + (двигает всё, при QO-100 LO залочен на маяк — не трогать). +- Пороговая аккуратность: старт/стоп WDSP-каналов на лету (см. заметку про задержки + RXA стоп/старт в `WDSPEngine.pas:~230`). + +## Связанные заметки памяти + +- project_pluto_integration, project_qo100_zoom_panel, project_qo100_decoder_lock +- project_spectrum_zoom (GetViewWindow / оверлеи / маппинг мыши) +- project_channels_feature (dropdown/форма-редактор как паттерн UI) diff --git a/ewsdr.lpi b/ewsdr.lpi index 83ebba2..ac97e97 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -17,9 +17,9 @@ - + - + @@ -47,6 +47,29 @@ + + + + + + + + + + + + + + + + + + + + + + +