diff --git a/AndromedaMap.pas b/AndromedaMap.pas index 33ad70d..78cbfd3 100644 --- a/AndromedaMap.pas +++ b/AndromedaMap.pas @@ -34,7 +34,7 @@ type // Параметр, управляемый поворотным энкодером панели. TAndEncoder = ( aeNone, aeAFGain, aeAGCLevel, aeDrive, aeSquelch, - aeFilterWidth, aeMulti); + aeFilterWidth, aeFilterHigh, aeFilterLow, aeMulti); // Индикатор (LED) панели. Порядок зеркалит EIndicatorActions Thetis. TAndIndicator = ( @@ -104,15 +104,16 @@ const // Раскладка энкодеров: индекс = номер энкодера (0-based), значение = параметр. // Нумерация = «Encoder Number» Thetis − 1 (дефолт MakeNewAndromedaDataset). // Энкодер #1 в Thetis — AGC Level (слайдер RF), поэтому aeAGCLevel. - // #4/#5 на панели — Filter High/Low (независимые края), но в ewsdr фильтр - // только симметричный (SetFilterBW): #4 крутит ширину, #5 пока не задействован. + // #4/#5 — Filter High/Low: настоящие независимые края (как на железе G2). + // aeFilterWidth оставлен в перечислении для раскладок, где нужен один + // энкодер «ширина» — в дефолтной раскладке не используется. AND_ENCODER_MAP: array[0..AND_MAX_ENCODERS-1] of TAndEncoder = ( aeAFGain, // 0 RX1 AF Gain aeAGCLevel, // 1 RX1 AGC Level (Thetis «RF») aeNone, // 2 RX2 AF — нет второго приёмника aeNone, // 3 RX2 AGC - aeFilterWidth, // 4 Filter High → ширина фильтра (краёв нет) - aeNone, // 5 Filter Low — не реализован + aeFilterHigh, // 4 Filter High — верхний край + aeFilterLow, // 5 Filter Low — нижний край aeNone, // 6 Diversity gain aeNone, // 7 Diversity phase aeNone, // 8 RIT diff --git a/AndromedaPanel.pas b/AndromedaPanel.pas index 73066ad..c14ee57 100644 --- a/AndromedaPanel.pas +++ b/AndromedaPanel.pas @@ -126,6 +126,13 @@ begin aeDrive: FController.SetDrive(FController.FDrivePercent + FPendingStep); aeSquelch: FController.SetFMSquelchLevel(FController.FFMSQLevel + FPendingStep); aeFilterWidth: FController.SetFilterBW(FController.FFilterBW + FPendingStep * AND_BW_STEP_HZ); + // Края независимы: крутим один, второй стоит. Контроллер уводит правку в + // VAR, поэтому пресеты энкодером не портятся. + aeFilterHigh: FController.SetFilterEdges(FController.FilterLo, + FController.FilterHi + FPendingStep * AND_BW_STEP_HZ); + aeFilterLow: FController.SetFilterEdges( + FController.FilterLo + FPendingStep * AND_BW_STEP_HZ, + FController.FilterHi); // aeMulti — мультифункц. энкодер: пока не назначен end; end; diff --git a/CATAdapter.pas b/CATAdapter.pas index ecc6014..1882c42 100644 --- a/CATAdapter.pas +++ b/CATAdapter.pas @@ -49,6 +49,10 @@ type function GetDriveLevel: Integer; function GetFilterIdx: Integer; function GetFilterBW: Integer; + function GetFilterLow: Integer; + function GetFilterHigh: Integer; + procedure SetFilterLow(V: Integer); + procedure SetFilterHigh(V: Integer); function GetNRMode: Integer; function GetNBMode: Integer; function GetSNB: Boolean; @@ -161,6 +165,10 @@ begin Ctx.GetDriveLevel := @GetDriveLevel; Ctx.GetFilterIdx := @GetFilterIdx; Ctx.GetFilterBW := @GetFilterBW; + // Кромки фильтра (ZZFL/ZZFH). Раньше у главного приёмника были nil (фильтр + // был индексным) — теперь он хранит те же знаковые кромки, что и слайс. + Ctx.GetFilterLow := @GetFilterLow; + Ctx.GetFilterHigh := @GetFilterHigh; Ctx.GetNRMode := @GetNRMode; Ctx.GetNBMode := @GetNBMode; Ctx.GetSNBEnabled := @GetSNB; @@ -177,6 +185,8 @@ begin Ctx.SetVolume := @SetVolume; Ctx.SetDriveLevel := @SetDriveLevel; Ctx.SetFilterIdx := @SetFilterIdx; + Ctx.SetFilterLow := @SetFilterLow; + Ctx.SetFilterHigh := @SetFilterHigh; Ctx.SetNRMode := @SetNRMode; Ctx.SetNBMode := @SetNBMode; Ctx.SetSNBEnabled := @SetSNB; @@ -297,6 +307,14 @@ function TCATAdapter.GetVolume: Integer; begin Result := FController.FVolume function TCATAdapter.GetDriveLevel: Integer; begin Result := FController.FDrivePercent; end; function TCATAdapter.GetFilterIdx: Integer; begin Result := FController.FFilter; end; function TCATAdapter.GetFilterBW: Integer; begin Result := FController.FFilterBW; end; +function TCATAdapter.GetFilterLow: Integer; begin Result := FController.FilterLo; end; +function TCATAdapter.GetFilterHigh: Integer; begin Result := FController.FilterHi; end; +// Кромку меняем по одной — вторую держим текущей (контроллер уводит правку в +// VAR, поэтому пресеты внешняя программа не испортит). +procedure TCATAdapter.SetFilterLow(V: Integer); +begin FController.SetFilterEdges(V, FController.FilterHi); end; +procedure TCATAdapter.SetFilterHigh(V: Integer); +begin FController.SetFilterEdges(FController.FilterLo, V); end; function TCATAdapter.GetNRMode: Integer; begin Result := FController.FNRMode; end; function TCATAdapter.GetNBMode: Integer; begin Result := FController.FNBMode; end; function TCATAdapter.GetSNB: Boolean; begin Result := FController.FSNB; end; diff --git a/FilterPopup.pas b/FilterPopup.pas new file mode 100644 index 0000000..53481ed --- /dev/null +++ b/FilterPopup.pas @@ -0,0 +1,514 @@ +unit FilterPopup; + +{ + TFilterPopup — поповер редактора фильтров приёмника, открывается ПКМ по + любой кнопке фильтра на левой панели. Слева список слотов режима (10 + пресетов + VAR1/VAR2), справа Name/Low/High/Width, внизу форма полосы в + масштабе на ЗНАКОВОЙ оси частот. + + Знак и есть главный смысл нижней картинки: у LSB полоса лежит в минусе, и + это видно глазами — ровно то место, где в проекте дважды заводились баги с + боковой. Скат нарисован схематично: реальная крутизна у WDSP определяется + длиной FIR (fircore) и никаким пользовательским параметром не управляется. + + Эталон содержимого — Thetis FilterForm.cs (12 слотов + Name/Low/High/Width); + графика полосы — наша добавка, у них её нет. + + Реализован как ДОЧЕРНИЙ контрол верхнеуровневой формы (не отдельное окно) — + как TPureSignalPopup/TPanDisplayPopup: Wayland не даёт клиенту + позиционировать top-level окна. +} + +{$IFDEF FPC} + {$MODE Delphi} +{$ENDIF} + +interface + +uses + Classes, SysUtils, Math, Types, Controls, ExtCtrls, StdCtrls, Graphics, Forms, + RadioController, RadioModes, Settings, + FlatButton, FlatEdit, FlatSpinEdit, FlatListBox; + +type + TFilterSlotEvent = procedure(Mode, Idx: Integer; const S: TFilterSlot) of object; + TFilterResetEvent = procedure(Mode, Idx: Integer) of object; + + TFilterPopup = class(TCustomControl) + private + FController: TRadioController; // не владеем + FMode: Integer; + FSlotIdx: Integer; + FSet: TFilterSet; + FPresets: Integer; // пресетов у режима (VAR идут сверх) + FLoading: Boolean; // подавляет Apply при программной заливке + FBg: TPanel; + FTitle: TLabel; + FBtnClose: TFlatButton; + FList: TFlatListBox; + FEdName: TFlatEdit; + FEdLow: TFlatSpinEdit; + FEdHigh: TFlatSpinEdit; + FEdWidth: TFlatSpinEdit; + FBtnReset: TFlatButton; + FBtnResetAll: TFlatButton; + FShape: TPaintBox; + FOnSlotEdited: TFilterSlotEvent; + FOnSlotReset: TFilterResetEvent; + function MakeLbl(const Cap: string; ALeft, ATop, AW: Integer): TLabel; + procedure FillList; + procedure ShowSlot; + procedure Commit; + procedure ListClick(Sender: TObject); + procedure EdgeChange(Sender: TObject); + procedure WidthChange(Sender: TObject); + procedure NameChange(Sender: TObject); + procedure ResetClick(Sender: TObject); + procedure ResetAllClick(Sender: TObject); + procedure CloseClick(Sender: TObject); + procedure ShapePaint(Sender: TObject); + function SlotLabel(Idx: Integer): string; + protected + procedure Paint; override; + public + constructor Create(AOwner: TComponent); override; + // Перечитывает таблицу контроллера и показывает слот SlotIdx режима Mode. + procedure LoadFrom(C: TRadioController; Mode, SlotIdx: Integer); + // Подсветить выбор, когда фильтр переключили снаружи (кнопка/CAT/drag). + procedure SyncSelection(Mode, Idx: Integer); + // Показать под контролом-якорем (кнопкой фильтра) как дочерний контрол формы. + procedure PopupBelow(C: TRadioController; Anchor: TControl; + Mode, SlotIdx: Integer); + property SlotIdx: Integer read FSlotIdx; + property OnSlotEdited: TFilterSlotEvent read FOnSlotEdited write FOnSlotEdited; + property OnSlotReset: TFilterResetEvent read FOnSlotReset write FOnSlotReset; + end; + +implementation + +const +{$IFDEF WINDOWS} + UI_FONT = 'Segoe UI'; +{$ELSE} + UI_FONT = 'Sans'; +{$ENDIF} + CLR_BG = TColor($00303030); // рамка (1px кайма поповера) + CLR_PANEL = TColor($001A1A1A); + CLR_TEXT = TColor($00E0E0E0); + CLR_TEXTDIM = TColor($00888888); + CLR_ACCENT = TColor($0040FF80); + CLR_INPUT = TColor($00FFFFFF); + CLR_INPUT_TEXT = TColor($00202020); + CLR_GRID = TColor($00303030); + CLR_BAND = TColor($0040FF80); + + POP_W = 300; + PAD = 10; + LISTW = 78; + LBLW = 40; + ROWH = 24; + ITEMH = 22; // = BASE_ITEM_H в FlatListBox + SHAPE_H = 96; + RULER_H = 13; + EDGE_LIM = 20000; + + FE_MODE_NAMES: array[0..MODE_MAX] of string = ( + 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FMRAW'); + +constructor TFilterPopup.Create(AOwner: TComponent); +var + Y, CtlX, CtlW, BW: Integer; + + function MakeSpin(ATop: Integer; H: TNotifyEvent): TFlatSpinEdit; + begin + Result := TFlatSpinEdit.Create(Self); + Result.Parent := FBg; + Result.SetBounds(CtlX, ATop, CtlW, ROWH - 2); + Result.MinValue := -EDGE_LIM; + Result.MaxValue := EDGE_LIM; + Result.Increment := 10; + Result.Color := CLR_INPUT; + Result.Font.Color := CLR_INPUT_TEXT; + Result.Font.Name := UI_FONT; + Result.Font.Size := 9; + Result.OnChange := H; + end; + +begin + inherited Create(AOwner); + ControlStyle := ControlStyle + [csOpaque]; + Visible := False; + Color := CLR_BG; + Font.Name := UI_FONT; + Font.Size := 9; + Font.Color := CLR_TEXT; + FMode := MODE_USB; + FSlotIdx := 0; + FPresets := FILT_COUNT; + // ★Гард на ВСЁ время постройки: сеттеры Flat*-контролов (напр. + // TFlatSpinEdit.SetMinValue → SetValue → DoChange) стреляют OnChange прямо + // из конструктора, а обработчики трогают контролы, созданные ниже. + FLoading := True; + + FBg := TPanel.Create(Self); + FBg.Parent := Self; + FBg.BevelOuter := bvNone; + FBg.Color := CLR_PANEL; + FBg.SetBounds(1, 1, POP_W - 2, 10); + + Y := PAD; + FTitle := MakeLbl('Filters', PAD, Y + 2, POP_W - 2 * PAD - 18); + FTitle.Font.Color := CLR_ACCENT; + FTitle.Font.Style := [fsBold]; + FBtnClose := MakeFlatBtn(FBg, '×', POP_W - PAD - 16, Y, 16, 16, CloseClick); + FBtnClose.ClrText := CLR_TEXTDIM; + FBtnClose.ClrTextAct := CLR_TEXT; + Inc(Y, ROWH); + + // ---- Слева список слотов, справа поля ---- + FList := TFlatListBox.Create(Self); + FList.Parent := FBg; + FList.SetBounds(PAD, Y, LISTW, FILT_SLOTS * ITEMH + 4); + FList.Font.Name := UI_FONT; + FList.Font.Size := 9; + FList.OnClick := ListClick; // выбор строки = клик (у TFlatListBox нет OnSelect) + + CtlX := PAD + LISTW + 10 + LBLW + 6; + CtlW := POP_W - PAD - CtlX; + + MakeLbl('Name', PAD + LISTW + 10, Y + 3, LBLW); + FEdName := TFlatEdit.Create(Self); + FEdName.Parent := FBg; + FEdName.SetBounds(CtlX, Y, CtlW, ROWH - 2); + FEdName.MaxLength := 6; + FEdName.Color := CLR_INPUT; + FEdName.Font.Color := CLR_INPUT_TEXT; + FEdName.Font.Name := UI_FONT; + FEdName.Font.Size := 9; + FEdName.OnChange := NameChange; + Inc(Y, ROWH + 4); + + MakeLbl('Low', PAD + LISTW + 10, Y + 3, LBLW); + FEdLow := MakeSpin(Y, EdgeChange); + Inc(Y, ROWH + 4); + + MakeLbl('High', PAD + LISTW + 10, Y + 3, LBLW); + FEdHigh := MakeSpin(Y, EdgeChange); + Inc(Y, ROWH + 4); + + MakeLbl('Width', PAD + LISTW + 10, Y + 3, LBLW); + FEdWidth := MakeSpin(Y, WidthChange); + FEdWidth.MinValue := FILT_MIN_WIDTH; + Inc(Y, ROWH + 8); + + BW := (POP_W - PAD - (PAD + LISTW + 10) - 6) div 2; + FBtnReset := MakeFlatBtn(FBg, 'Reset', PAD + LISTW + 10, Y, BW, ROWH - 2, + ResetClick); + FBtnResetAll := MakeFlatBtn(FBg, 'Reset all', PAD + LISTW + 10 + BW + 6, Y, + BW, ROWH - 2, ResetAllClick); + + // Список выше полей — берём максимум по высоте. + Y := Max(Y + ROWH - 2, FList.Top + FList.Height) + 8; + + MakeLbl('— Passband —', PAD, Y, POP_W - 2 * PAD).Font.Color := CLR_TEXTDIM; + Inc(Y, 18); + FShape := TPaintBox.Create(Self); + FShape.Parent := FBg; + FShape.SetBounds(PAD, Y, POP_W - 2 * PAD, SHAPE_H); + FShape.OnPaint := ShapePaint; + Inc(Y, SHAPE_H); + + FBg.Height := Y - 1 + PAD; + Width := POP_W; + Height := Y + 1 + PAD; + FLoading := False; +end; + +procedure TFilterPopup.Paint; +begin + Canvas.Brush.Style := bsSolid; + Canvas.Brush.Color := CLR_BG; + Canvas.Pen.Style := psClear; + Canvas.FillRect(0, 0, Width, Height); +end; + +function TFilterPopup.MakeLbl(const Cap: string; ALeft, ATop, AW: Integer): TLabel; +begin + Result := TLabel.Create(Self); + Result.Parent := FBg; + Result.Caption := Cap; + Result.Left := ALeft; + Result.Top := ATop; + if AW > 0 then Result.Width := AW; + Result.Font.Color := CLR_TEXT; + Result.Font.Name := UI_FONT; + Result.Font.Size := 9; +end; + +function TFilterPopup.SlotLabel(Idx: Integer): string; +begin + Result := FSet[Idx].Name; + if Result = '' then Result := '—'; +end; + +procedure TFilterPopup.FillList; +var i: Integer; +begin + FList.Items.BeginUpdate; + try + FList.Items.Clear; + for i := 0 to FPresets - 1 do FList.Items.Add(SlotLabel(i)); + FList.Items.Add(SlotLabel(FILT_VAR1)); + FList.Items.Add(SlotLabel(FILT_VAR2)); + finally + FList.Items.EndUpdate; + end; +end; + +procedure TFilterPopup.LoadFrom(C: TRadioController; Mode, SlotIdx: Integer); +var i: Integer; +begin + if C = nil then Exit; + FController := C; + FMode := EnsureRange(Mode, 0, CFG_MODE_MAX); + FPresets := FilterPresetCount(FMode); + for i := 0 to FILT_SLOTS - 1 do FSet[i] := C.FilterSlot(FMode, i); + FSlotIdx := EnsureRange(SlotIdx, 0, FILT_SLOTS - 1); + // Пресет вне таблицы режима (FM/WFM короче) — показываем VAR1. + if (FSlotIdx >= FPresets) and (FSlotIdx < FILT_VAR1) then FSlotIdx := FILT_VAR1; + FTitle.Caption := 'Filters — ' + FE_MODE_NAMES[EnsureRange(FMode, 0, MODE_MAX)]; + FLoading := True; + try + FillList; + ShowSlot; + finally + FLoading := False; + end; +end; + +procedure TFilterPopup.SyncSelection(Mode, Idx: Integer); +begin + if (Mode <> FMode) or (Idx = FSlotIdx) then Exit; + FSlotIdx := EnsureRange(Idx, 0, FILT_SLOTS - 1); + FLoading := True; + try ShowSlot; finally FLoading := False; end; +end; + +procedure TFilterPopup.ShowSlot; +var Row: Integer; +begin + if FSlotIdx >= FILT_VAR1 then Row := FPresets + (FSlotIdx - FILT_VAR1) + else Row := FSlotIdx; + FList.ItemIndex := Row; + FEdName.Text := FSet[FSlotIdx].Name; + FEdLow.Value := FSet[FSlotIdx].Lo; + FEdHigh.Value := FSet[FSlotIdx].Hi; + FEdWidth.Value := FSet[FSlotIdx].Hi - FSet[FSlotIdx].Lo; + FShape.Invalidate; +end; + +procedure TFilterPopup.Commit; +begin + if FLoading or (FShape = nil) then Exit; + FShape.Invalidate; + if Assigned(FOnSlotEdited) then FOnSlotEdited(FMode, FSlotIdx, FSet[FSlotIdx]); +end; + +procedure TFilterPopup.ListClick(Sender: TObject); +var Row: Integer; +begin + if FLoading then Exit; + Row := FList.ItemIndex; + if Row < 0 then Exit; + if Row >= FPresets then FSlotIdx := FILT_VAR1 + (Row - FPresets) + else FSlotIdx := Row; + FLoading := True; + try ShowSlot; finally FLoading := False; end; + // Клик по строке = выбрать этот фильтр в приёмнике (как кнопка на панели). + if Assigned(FController) then FController.SetFilterIdx(FSlotIdx); +end; + +procedure TFilterPopup.EdgeChange(Sender: TObject); +var Lo, Hi: Integer; +begin + if FLoading then Exit; + Lo := FEdLow.Value; + Hi := FEdHigh.Value; + if Hi - Lo < FILT_MIN_WIDTH then + begin + // Края не пускаем друг через друга: двигаем тот, который НЕ трогали. + if Sender = FEdLow then Hi := Lo + FILT_MIN_WIDTH + else Lo := Hi - FILT_MIN_WIDTH; + FLoading := True; + try + FEdLow.Value := Lo; + FEdHigh.Value := Hi; + finally + FLoading := False; + end; + end; + FSet[FSlotIdx].Lo := Lo; + FSet[FSlotIdx].Hi := Hi; + FLoading := True; + try FEdWidth.Value := Hi - Lo; finally FLoading := False; end; + Commit; +end; + +procedure TFilterPopup.WidthChange(Sender: TObject); +// Width производная: держим центр полосы, разъезжаются оба края (как Thetis). +var W, C, Lo, Hi: Integer; +begin + if FLoading then Exit; + W := Max(FILT_MIN_WIDTH, FEdWidth.Value); + C := (FSet[FSlotIdx].Lo + FSet[FSlotIdx].Hi) div 2; + Lo := C - W div 2; + Hi := Lo + W; + FSet[FSlotIdx].Lo := Lo; + FSet[FSlotIdx].Hi := Hi; + FLoading := True; + try + FEdLow.Value := Lo; + FEdHigh.Value := Hi; + finally + FLoading := False; + end; + Commit; +end; + +procedure TFilterPopup.NameChange(Sender: TObject); +var Row: Integer; +begin + if FLoading then Exit; + FSet[FSlotIdx].Name := FEdName.Text; + Row := FList.ItemIndex; + FLoading := True; + try + FillList; + FList.ItemIndex := Row; + finally + FLoading := False; + end; + Commit; +end; + +procedure TFilterPopup.ResetClick(Sender: TObject); +begin + if Assigned(FOnSlotReset) then FOnSlotReset(FMode, FSlotIdx); +end; + +procedure TFilterPopup.ResetAllClick(Sender: TObject); +begin + if Assigned(FOnSlotReset) then FOnSlotReset(FMode, -1); +end; + +procedure TFilterPopup.CloseClick(Sender: TObject); +begin + Hide; +end; + +procedure TFilterPopup.ShapePaint(Sender: TObject); +// Трапеция полосы в масштабе на знаковой оси. Окно оси симметрично нулю и +// шире полосы, поэтому сразу видно, по какую сторону от несущей она лежит. +var + W, H, GH, ZeroX, i, X, Skirt, Lo, Hi, X1, X2: Integer; + Span, Step: Double; + S: string; + + function FreqToX(F: Double): Integer; + begin + Result := Round((F + Span) / (2 * Span) * W); + end; + +begin + W := FShape.Width; + H := FShape.Height; + if (W <= 0) or (H <= 0) then Exit; + GH := H - RULER_H; + Lo := FSet[FSlotIdx].Lo; + Hi := FSet[FSlotIdx].Hi; + + with FShape.Canvas do + begin + Brush.Style := bsSolid; + Brush.Color := CLR_PANEL; + FillRect(0, 0, W, H); + + Span := Max(Abs(Lo), Abs(Hi)) * 1.6; + if Span < 500 then Span := 500; + + // Сетка: шаг кратен 1 кГц, подстраивается под окно. + Step := 1000; + while Span / Step > 12 do Step := Step * 2; + Pen.Style := psSolid; + Pen.Color := CLR_GRID; + i := -Trunc(Span / Step); + while i <= Trunc(Span / Step) do + begin + X := FreqToX(i * Step); + Line(X, 0, X, GH); + Inc(i); + end; + + // Ноль = несущая, ярче сетки. + ZeroX := FreqToX(0); + Pen.Color := CLR_TEXTDIM; + Line(ZeroX, 0, ZeroX, GH); + + // Полоса: плоская вершина + схематичные скаты. + X1 := FreqToX(Lo); + X2 := FreqToX(Hi); + Skirt := Max(2, Round(0.06 * (X2 - X1))); + Brush.Color := CLR_BAND; + Pen.Color := CLR_BAND; + Polygon([Point(X1, GH - 2), + Point(X1 + Skirt, Round(GH * 0.2)), + Point(X2 - Skirt, Round(GH * 0.2)), + Point(X2, GH - 2)]); + + // Линейка: края полосы + ноль. + Brush.Style := bsClear; + Font.Name := UI_FONT; + Font.Size := 7; + Font.Color := CLR_TEXTDIM; + S := '0'; + TextOut(ZeroX - TextWidth(S) div 2, GH, S); + Font.Color := CLR_BAND; + S := IntToStr(Lo); + TextOut(Max(0, Min(W - TextWidth(S), X1 - TextWidth(S) div 2)), GH, S); + S := IntToStr(Hi); + TextOut(Max(0, Min(W - TextWidth(S), X2 - TextWidth(S) div 2)), GH, S); + end; +end; + +procedure TFilterPopup.PopupBelow(C: TRadioController; Anchor: TControl; + Mode, SlotIdx: Integer); +var + Root: TCustomForm; + L, T: Integer; + Cl: TPoint; +begin + if Anchor = nil then Exit; + Root := GetParentForm(Anchor); + if Root = nil then Exit; + // ★Родителя назначаем ДО заливки данных. TFlatListBox при заполнении и при + // установке ItemIndex считает ItemHeight, а тот трогает Canvas + // (Canvas.Font.Assign / TextHeight) — у контрола без родительского окна это + // AV. У TPureSignalPopup списка нет, поэтому там порядок был обратный и + // проблемы не возникало. + Parent := Root; + LoadFrom(C, Mode, SlotIdx); + // Позиция под якорем в КЛИЕНТСКИХ координатах формы (не top-level окно!). + Cl := Root.ScreenToClient(Anchor.ClientToScreen(Point(0, Anchor.Height + 2))); + L := Cl.X; + T := Cl.Y; + if L + Width > Root.ClientWidth then L := Root.ClientWidth - Width; + if L < 0 then L := 0; + if T + Height > Root.ClientHeight then + T := Root.ScreenToClient(Anchor.ClientToScreen(Point(0, 0))).Y - Height; + if T < 0 then T := 0; + SetBounds(L, T, Width, Height); + Visible := True; + BringToFront; +end; + +end. diff --git a/MainForm.pas b/MainForm.pas index 9e2be8b..de6fb79 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -22,7 +22,8 @@ unit MainForm; interface uses - Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, SampleRateOverlay, BandPlanOverlay, + Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, FilterPopup, + VfoOverlay, SampleRateOverlay, BandPlanOverlay, FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs, StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs, OpenGLContextEx, @@ -68,23 +69,12 @@ const WDSP_WISDOM_FILE = 'wdspWisdom00'; BAND_COUNT = 12; // макс. слотов среди планов (Pluto VHF/UHF = 12, HF = 11) - // Полосы/дефолты фильтров (FILT_COUNT, FILT_*_BW, FILT_*_DEF, FILT_FM_*) - // перенесены в Settings.pas — нужны TRadioController. Здесь остаются только - // UI-подписи кнопок (NAMES). - FILT_SSB_NAMES: array[0..FILT_COUNT-1] of string = - ('5.0k','4.4k','3.8k','3.3k','2.9k','2.7k','2.4k','2.1k','1.8k','1.0k'); - FILT_CW_NAMES: array[0..FILT_COUNT-1] of string = - ('1.0k','800','600','500','400','250','150','100','50','25'); - FILT_DSB_NAMES: array[0..FILT_COUNT-1] of string = - ('16k','12k','10k','8.0k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k'); - FILT_AM_NAMES: array[0..FILT_COUNT-1] of string = - ('20k','18k','16k','12k','10k','9.0k','8.0k','7.0k','6.0k','5.0k'); - FILT_DIGI_NAMES: array[0..FILT_COUNT-1] of string = - ('3.0k','2.7k','2.4k','2.1k','1.8k','1.5k','1.2k','900','600','300'); - FILT_FM_NAMES: array[0..1] of string = ('NFM', 'FM'); - FILT_WFM_NAMES: array[0..FILT_WFM_COUNT-1] of string = ('200k', '160k', '120k'); - FILT_RAW_NAMES: array[0..FILT_RAW_COUNT-1] of string = - ('6.25k', '10k', '12.5k', '20k', '25k'); + // Полосы/дефолты/подписи фильтров (FILT_COUNT, FILT_*_BW, FILT_*_DEF, + // FILT_*_NAMES) живут в Settings.pas: из них строится заводская таблица + // фильтров, а подписи кнопок теперь берутся из живой таблицы контроллера. + FILT_GRID_COLS = 6; // сетка кнопок фильтра: 2 ряда по 6 (12 = 10 + VAR1/2) + EDGE_GRAB_PX = 5; // зона захвата края полосы фильтра мышью, px + EDGE_SNAP_HZ = 10; // снап края при перетаскивании // CTCSS tones (38 standard tones, no None — toggle via CTCSS button) // CTCSS_COUNT и CTCSS_TONES перенесены в FMRepeater (нужны TRadioController). @@ -132,11 +122,17 @@ type // ---- Network ---- // Список устройств (saved+discovered) → FController.FDeviceStore (общий с web). FDeviceDialog: TDeviceDialog; + FFilterPop: TFilterPopup; // поповер редактора фильтров (ПКМ по кнопке FILTER) FPendingIP: string; // IP устройства для подключения // Флаг главного (слайс A): создаётся здесь (проводка событий), живёт в // раскладке/диспетчере FPan (AttachMainFlag). Флаги B+ — целиком в FPan. FVfoOverlay: TVfoOverlay; FActiveSliceId: Integer; // выбранный активный слайс (0 = главный) + // Перетаскивание края полосы фильтра: -1 левый, +1 правый, 0 не тянем. + // Кромки на момент нажатия — чтобы неподвижный край не «плыл» от снапа. + FEdgeDrag: Integer; + FEdgeDragLo: Integer; + FEdgeDragHi: Integer; FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру FSliceDragId: Integer; // какой слайс тащим FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра @@ -298,7 +294,7 @@ type BtnMode: array[0..MODE_COUNT-1] of TFlatButton; PanelFilter: TPanel; - BtnFilter: array[0..FILT_COUNT-1] of TFlatButton; + BtnFilter: array[0..FILT_SLOTS-1] of TFlatButton; // 10 пресетов + VAR1/VAR2 BtnCTun: TFlatButton; BtnBeacon: TFlatButton; // QO-100 beacon lock (видим только на Pluto в трансвертере) BtnDUP: TFlatButton; @@ -467,6 +463,16 @@ type procedure BtnBandClick(Sender: TObject); procedure BtnModeClick(Sender: TObject); procedure BtnFilterClick(Sender: TObject); + procedure BtnFilterMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); + procedure LayoutFilterButtons(PresetCount: Integer); + procedure PushFilterEdgesToView; // кромки RX/TX в SpectrumView + procedure RefreshFilterCaptions; // подписи кнопок без сброса выбора + function FilterEdgeAtPixel(X: Integer): Integer; // -1 левый край, +1 правый, 0 мимо + procedure DoFilterEdgeDrag(X: Integer; Shift: TShiftState); + procedure OpenFilterEditor(SlotIdx: Integer); + procedure OnFilterSlotEdited(Mode, Idx: Integer; const S: TFilterSlot); + procedure OnFilterSlotReset(Mode, Idx: Integer); // Idx < 0 = весь режим procedure BtnCTunClick(Sender: TObject); procedure BtnBeaconClick(Sender: TObject); procedure BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton; @@ -980,7 +986,9 @@ begin FController.FAGCTop := 90; // −90 dBm // default: зависит от режима, будет сброшен в UpdateFilterButtons FController.FCTun := False; - FController.FFilterBW := 2700; + // Фильтр (индекс + кромки + производная ширина) — одной точкой, чтобы + // состояние не расползалось: ширину отдельно больше не выставляем. + FController.SelectFilterSlotState(FController.FFilter); FSpecDrag := False; FController.FDriveLevel := 0; FController.FPAMaxPower := 100.0; @@ -1426,6 +1434,7 @@ begin // TX со слайса краснеет полоса ТОГО слайса (DrawSliceFilterMarkers). FSpecView.TXOverlay := FController.FTransmitting and (FController.FTxSliceId = 0); + PushFilterEdgesToView; // на передаче полоса рисуется по TX-фильтру ApplySpecViewGridFromState; // сетка спектра под активный тракт RefreshTxIndicators; // TX-бейджи флагов (какой слайс передаёт) UpdateTXControlAvailability; @@ -1526,17 +1535,29 @@ begin SavedFilter := FController.FFilter; SavedFilterBW := FController.FFilterBW; UpdateFilterButtons; // подписи + FM-панели под режим - FController.FFilter := SavedFilter; - FController.FFilterBW := SavedFilterBW; - for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FController.FFilter); + // Возвращаем СЛОТ целиком: кромки — истина, одной ширины мало. + FController.SelectFilterSlotState(SavedFilter); + for i := 0 to FILT_SLOTS - 1 do StyleButton(BtnFilter[i], i = FController.FFilter); + PushFilterEdgesToView; + // Редактор всегда показывает фильтры ТЕКУЩЕГО режима — иначе правка + // ушла бы в чужую таблицу. + if (FFilterPop <> nil) and FFilterPop.Visible then + begin + if FController.FMode = MODE_DMR then FFilterPop.Hide + else FFilterPop.LoadFrom(FController, FController.FMode, + FController.FFilter); + end; SyncSpecViewFreq; // иначе полоса фильтра рисуется под старый режим UpdateTXControlAvailability; UpdateDMRStatus; end; rfFilter: begin - for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FController.FFilter); + for i := 0 to FILT_SLOTS - 1 do StyleButton(BtnFilter[i], i = FController.FFilter); + PushFilterEdgesToView; SyncSpecViewFreq; + if (FFilterPop <> nil) and FFilterPop.Visible then + FFilterPop.SyncSelection(FController.FMode, FController.FFilter); end; rfFMDeviation: if (TrkRawDev <> nil) and (LblRawDev <> nil) then @@ -1775,6 +1796,7 @@ begin rfFMSQ, rfFMSQLevel]) then begin FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter); + FVfoOverlay.SetFilterEdges(FController.FilterLo, FController.FilterHi); FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode); FVfoOverlay.SetSquelchState(FController.FFMSQOn, FController.FFMSQLevel); end; @@ -2315,20 +2337,22 @@ begin Inc(Y, MODE_PANEL_H + 2); - // Filter selector (10 кнопок в 2 ряда по 5) + // Filter selector (12 кнопок в 2 ряда по 6: 10 пресетов + VAR1/VAR2). + // ПКМ по любой из них открывает редактор фильтров. PanelFilter := TPanel.Create(Self); PanelFilter.Parent := PanelLeft; PanelFilter.SetBounds(0, Y, LEFT_W, 74); PanelFilter.BevelOuter := bvNone; MakeLbl(PanelFilter, 'FILTER', 4, 2); - for i := 0 to FILT_COUNT - 1 do + for i := 0 to FILT_SLOTS - 1 do begin B := MakeBtn(PanelFilter, '---', - LeftPanelButtonLeft(LEFT_W, 5, i mod 5), - 16 + (i div 5) * 27, - LeftPanelButtonWidth(LEFT_W, 5, i mod 5), + LeftPanelButtonLeft(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS), + 16 + (i div FILT_GRID_COLS) * 27, + LeftPanelButtonWidth(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS), BTN_SM, BtnFilterClick); B.Tag := i; + B.OnMouseDown := BtnFilterMouseDown; BtnFilter[i] := B; end; @@ -3121,7 +3145,7 @@ begin // Обычные кнопки — все массивы for i := 0 to BAND_COUNT-1 do StyleButton(BtnBand[i], BtnBand[i].Active); for i := 0 to MODE_COUNT-1 do StyleButton(BtnMode[i], BtnMode[i].Active); - for i := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[i], BtnFilter[i].Active); + for i := 0 to FILT_SLOTS-1 do StyleButton(BtnFilter[i], BtnFilter[i].Active); for i := 0 to 4 do StyleButton(BtnAGCMode[i],BtnAGCMode[i].Active); StyleButton(BtnCTun, BtnCTun.Active); StyleButton(BtnDUP, BtnDUP.Active); @@ -4454,10 +4478,38 @@ end; // --------------------------------------------------------------------------- // UpdateFilterButtons — обновляет подписи кнопок фильтра под текущий режим // --------------------------------------------------------------------------- +// Раскладывает N кнопок фильтра: пресеты режима + VAR1/VAR2 следом. Ряды по +// FILT_GRID_COLS, но узкие режимы (FM=2+2, WFM=3+2) занимают один ряд целиком, +// растягивая кнопки — как было до появления VAR. +procedure TMainForm.LayoutFilterButtons(PresetCount: Integer); +var + i, Total, Cols, Rows, RowOf, ColOf, InRow: Integer; +begin + Total := PresetCount + 2; // + VAR1/VAR2 + if Total <= FILT_GRID_COLS then Cols := Total // FM/WFM: всё в один ряд + else Cols := FILT_GRID_COLS; + Rows := (Total + Cols - 1) div Cols; + for i := 0 to FILT_SLOTS - 1 do + begin + if i < PresetCount then InRow := i // пресет на своём месте + else if i = FILT_VAR1 then InRow := PresetCount // VAR1 сразу за пресетами + else if i = FILT_VAR2 then InRow := PresetCount + 1 + else begin BtnFilter[i].Visible := False; Continue; end; + RowOf := InRow div Cols; + ColOf := InRow mod Cols; + BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, Cols, ColOf); + BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, Cols, ColOf); + BtnFilter[i].Top := 16 + RowOf * 27; + BtnFilter[i].Caption := FController.FilterSlot(FController.FMode, i).Name; + BtnFilter[i].Visible := True; + StyleButton(BtnFilter[i], i = FController.FFilter); + end; + PanelFilter.Height := 16 + Rows * 27 + 4; +end; + procedure TMainForm.UpdateFilterButtons; var i: Integer; - Names: array[0..FILT_COUNT-1] of string; DefIdx: Integer; begin // DMR has a mandatory 12.5 kHz channel. There is nothing for the operator @@ -4465,26 +4517,11 @@ begin PanelFilter.Visible := FController.FMode <> MODE_DMR; if FController.FMode = MODE_FM then begin - // FM: show only NFM and WFM buttons, resize them to fill the row + // FM: NFM/FM + VAR1/VAR2 в один ряд (панель остаётся низкой, SQL/CTCSS + // садятся сразу под ней). CloseCTCSSPopup; - if FController.FFilter > 1 then FController.FFilter := FILT_FM_DEF; // sanitize, keep NFM/WFM as-is - FController.FFilterBW := FILT_FM_BW[FController.FFilter]; - FController.FFMDeviation := FILT_FM_DEV[FController.FFilter]; - BtnFilter[0].Caption := FILT_FM_NAMES[0]; - BtnFilter[0].Left := LeftPanelButtonLeft(PanelFilter.Width, 2, 0); - BtnFilter[0].Width := LeftPanelButtonWidth(PanelFilter.Width, 2, 0); - BtnFilter[0].Top := 16; - BtnFilter[0].Visible := True; - StyleButton(BtnFilter[0], FController.FFilter = 0); - BtnFilter[1].Caption := FILT_FM_NAMES[1]; - BtnFilter[1].Left := LeftPanelButtonLeft(PanelFilter.Width, 2, 1); - BtnFilter[1].Width := LeftPanelButtonWidth(PanelFilter.Width, 2, 1); - BtnFilter[1].Top := 16; - BtnFilter[1].Visible := True; - StyleButton(BtnFilter[1], FController.FFilter = 1); - for i := 2 to FILT_COUNT - 1 do BtnFilter[i].Visible := False; - // Shrink panel to fit label + 1 button row so SQL/CTCSS sit right below - PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4; + FController.SanitizeFilterState; + LayoutFilterButtons(FILT_FM_COUNT); if PanelFMSQ <> nil then begin PanelFMSQ.Visible := True; @@ -4536,21 +4573,10 @@ begin if FController.FMode = MODE_WFM then begin - // WFM: полосы вещательного канала — три кнопки в один ряд. + // WFM: полосы вещательного канала — три кнопки + VAR1/VAR2 в один ряд. CloseCTCSSPopup; - if FController.FFilter > FILT_WFM_COUNT - 1 then FController.FFilter := FILT_WFM_DEF; - FController.FFilterBW := FILT_WFM_BW[FController.FFilter]; - for i := 0 to FILT_WFM_COUNT - 1 do - begin - BtnFilter[i].Caption := FILT_WFM_NAMES[i]; - BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, FILT_WFM_COUNT, i); - BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, FILT_WFM_COUNT, i); - BtnFilter[i].Top := 16; - BtnFilter[i].Visible := True; - StyleButton(BtnFilter[i], i = FController.FFilter); - end; - for i := FILT_WFM_COUNT to FILT_COUNT - 1 do BtnFilter[i].Visible := False; - PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4; + FController.SanitizeFilterState; + LayoutFilterButtons(FILT_WFM_COUNT); RelayoutBelowBands; Exit; end; @@ -4558,20 +4584,8 @@ begin if FController.FMode = MODE_FMRAW then begin CloseCTCSSPopup; - if FController.FFilter > FILT_RAW_COUNT - 1 then - FController.FFilter := FILT_RAW_DEF; - FController.FFilterBW := FILT_RAW_BW[FController.FFilter]; - for i := 0 to FILT_RAW_COUNT - 1 do - begin - BtnFilter[i].Caption := FILT_RAW_NAMES[i]; - BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, FILT_RAW_COUNT, i); - BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, FILT_RAW_COUNT, i); - BtnFilter[i].Top := 16; - BtnFilter[i].Visible := True; - StyleButton(BtnFilter[i], i = FController.FFilter); - end; - for i := FILT_RAW_COUNT to FILT_COUNT - 1 do BtnFilter[i].Visible := False; - PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4; + FController.SanitizeFilterState; + LayoutFilterButtons(FILT_RAW_COUNT); if PanelRawDev <> nil then begin TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50; @@ -4585,67 +4599,18 @@ begin if FController.FMode = MODE_DMR then begin CloseCTCSSPopup; - FController.FFilter := 0; - FController.FFilterBW := FILT_DMR_BW; - FController.FFMDeviation := DMR_FM_DEV; - for i := 0 to FILT_COUNT - 1 do BtnFilter[i].Visible := False; + FController.SanitizeFilterState; + for i := 0 to FILT_SLOTS - 1 do BtnFilter[i].Visible := False; RelayoutBelowBands; Exit; end; - for i := 0 to FILT_COUNT - 1 do - begin - BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, 5, i mod 5); - BtnFilter[i].Top := 16 + (i div 5) * 27; - BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, 5, i mod 5); - BtnFilter[i].Visible := True; - end; - - case FController.FMode of - MODE_LSB, MODE_USB: - begin - for i := 0 to FILT_COUNT-1 do Names[i] := FILT_SSB_NAMES[i]; - DefIdx := FILT_SSB_DEF; - end; - MODE_DIGU, MODE_DIGL: - begin - for i := 0 to FILT_COUNT-1 do Names[i] := FILT_DIGI_NAMES[i]; - DefIdx := FILT_DIGI_DEF; - end; - MODE_DSB: - begin - for i := 0 to FILT_COUNT-1 do Names[i] := FILT_DSB_NAMES[i]; - DefIdx := FILT_DSB_DEF; - end; - MODE_CWL, MODE_CWU: - begin - for i := 0 to FILT_COUNT-1 do Names[i] := FILT_CW_NAMES[i]; - DefIdx := FILT_CW_DEF; - end; - else begin - for i := 0 to FILT_COUNT-1 do Names[i] := FILT_AM_NAMES[i]; - DefIdx := FILT_AM_DEF; - end; - end; - - FController.FFilter := DefIdx; - case FController.FMode of - MODE_LSB, MODE_USB: - FController.FFilterBW := FILT_SSB_BW[DefIdx]; - MODE_DIGU, MODE_DIGL: - FController.FFilterBW := FILT_DIGI_BW[DefIdx]; - MODE_DSB: - FController.FFilterBW := FILT_DSB_BW[DefIdx]; - MODE_CWL, MODE_CWU: - FController.FFilterBW := FILT_CW_BW[DefIdx]; - else FController.FFilterBW := FILT_AM_BW[DefIdx]; - end; - - for i := 0 to FILT_COUNT-1 do - begin - BtnFilter[i].Caption := Names[i]; - StyleButton(BtnFilter[i], i = FController.FFilter); - end; + // Прочие режимы: смена режима возвращает заводской индекс фильтра (как было + // до таблицы), кромки и подписи приезжают из таблицы контроллера. + PanelFilter.Height := 74; + DefIdx := FilterDefIdx(FController.FMode); + FController.SelectFilterSlotState(DefIdx); + LayoutFilterButtons(FILT_COUNT); RelayoutBelowBands; end; @@ -5105,6 +5070,17 @@ begin UpdateBeaconButton; Exit; end; + // ЛКМ по краю полосы фильтра — тянем именно этот край (левый/правый + // независимо). Проверяется РАНЬШЕ слайс-маркера и панорамирования, иначе + // край у самой несущей (узкий CW) было бы не поймать. + if (PbSpectrum.Width > 0) and (FilterEdgeAtPixel(X) <> 0) then + begin + FEdgeDrag := FilterEdgeAtPixel(X); + FEdgeDragLo := FController.FilterLo; + FEdgeDragHi := FController.FilterHi; + PbSpectrum.Cursor := crSizeWE; + Exit; + end; // ЛКМ по маркеру несущей слайса — начинаем его перетаскивание (перетюн). if (PbSpectrum.Width > 0) then begin @@ -5164,6 +5140,13 @@ procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState; var VC, VS, NewHz, LoHz, HiHz: Double; begin + // Перетаскивание края полосы фильтра. + if FEdgeDrag <> 0 then + begin + if ssLeft in Shift then DoFilterEdgeDrag(X, Shift); + Exit; + end; + // Перетаскивание маркера несущей слайса — перетюн в пределах захваченной полосы. if FSliceDrag then begin @@ -5192,6 +5175,8 @@ begin if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or (Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then PbSpectrum.Cursor := crHandPoint + else if FilterEdgeAtPixel(X) <> 0 then + PbSpectrum.Cursor := crSizeWE // над краем фильтра — край можно тянуть else if FPan.SliceAtPixel(X) <> 0 then PbSpectrum.Cursor := crSizeWE // над маркером слайса — его можно тащить else @@ -5211,6 +5196,12 @@ end; procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin + if FEdgeDrag <> 0 then + begin + FEdgeDrag := 0; + PbSpectrum.Cursor := crDefault; + Exit; + end; if FSliceDrag then begin FSliceDrag := False; @@ -5335,11 +5326,130 @@ end; procedure TMainForm.BtnFilterClick(Sender: TObject); begin - // Логика (FFilter/FFilterBW по таблицам режима, ApplyModeFilter, band cache) - // — в контроллере; рендер — в OnControllerState(rfFilter). + // Логика (слот → кромки, ApplyModeFilter, band cache) — в контроллере; + // рендер — в OnControllerState(rfFilter). FController.SetFilterIdx((Sender as TFlatButton).Tag); end; +function TMainForm.FilterEdgeAtPixel(X: Integer): Integer; +// -1 = курсор у левого края полосы фильтра, +1 = у правого, 0 = мимо. +// Работает по тем же кромкам, что нарисованы (главный VFO, не слайсы). +var + VC, VS, Lo, Hi: Double; + W, X1, X2: Integer; +begin + Result := 0; + W := PbSpectrum.Width; + if (W <= 0) or (FController.FMode = MODE_DMR) then Exit; // DMR: полоса фиксирована + if FController.FTransmitting then Exit; // на передаче фильтр мышью не крутим + FController.GetViewWindow(VC, VS); + if VS <= 0 then Exit; + Lo := ActiveVfoFreq + FController.FilterLo; + Hi := ActiveVfoFreq + FController.FilterHi; + X1 := Round((Lo - VC + VS / 2) / VS * W); + X2 := Round((Hi - VC + VS / 2) / VS * W); + // Слишком узкая на экране полоса (CW на полном спане) — края слиплись с + // несущей, и захват съел бы обычный клик-тюн. Тогда фильтр правится только + // в редакторе; чтобы потянуть мышью, достаточно зазумиться. + if X2 - X1 < 2 * EDGE_GRAB_PX + 2 then Exit; + // Приоритет правому краю: при узкой полосе левый иначе «съедает» оба. + if Abs(X - X2) <= EDGE_GRAB_PX then Result := 1 + else if Abs(X - X1) <= EDGE_GRAB_PX then Result := -1; +end; + +procedure TMainForm.DoFilterEdgeDrag(X: Integer; Shift: TShiftState); +// Тянем ОДИН край; второй стоит. Alt — симметрично (второй край зеркалит). +// Именно Alt, а не Ctrl: Ctrl+ЛКМ по спектру уже создаёт слайс. Модификатор +// читаем из Shift обработчика, а не GetKeyState — надёжнее на Wayland. +var + VC, VS, Hz: Double; + W, Lo, Hi, D: Integer; +begin + W := PbSpectrum.Width; + if W <= 0 then Exit; + FController.GetViewWindow(VC, VS); + if VS <= 0 then Exit; + Hz := PixelToFreq(X, W, VC, VS) - ActiveVfoFreq; + Lo := FEdgeDragLo; + Hi := FEdgeDragHi; + if FEdgeDrag > 0 then Hi := Round(Hz / EDGE_SNAP_HZ) * EDGE_SNAP_HZ + else Lo := Round(Hz / EDGE_SNAP_HZ) * EDGE_SNAP_HZ; + if ssAlt in Shift then + begin + // Симметрия относительно центра исходной полосы. + D := (FEdgeDragLo + FEdgeDragHi) div 2; + if FEdgeDrag > 0 then Lo := 2 * D - Hi + else Hi := 2 * D - Lo; + end; + if Hi - Lo < FILT_MIN_WIDTH then Exit; // схлопывать полосу не даём + FController.SetFilterEdges(Lo, Hi); // уходит в VAR — пресеты целы + FSpectrumDirty := True; +end; + +procedure TMainForm.PushFilterEdgesToView; +// Кромки в спектр: RX — то, что реально стоит в приёмнике (фильтр может быть +// произвольным), TX — из TXSignedEdges движка. Полоса на экране обязана +// совпадать с тем, что слышно и что уходит в эфир. +var Lo, Hi: Integer; +begin + if FSpecView = nil then Exit; + FSpecView.SetRXEdges(FController.FilterLo, FController.FilterHi); + FController.TXFilterEdges(Lo, Hi); + FSpecView.SetTXEdges(Lo, Hi); +end; + +procedure TMainForm.BtnFilterMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +// ПКМ по кнопке фильтра открывает редактор на этом слоте (эталон Thetis: +// у них это пункт контекстного меню «RX1 Filter Configure»). ЛКМ не трогаем — +// иначе окно вылезало бы при каждом обычном переключении фильтра. +begin + if Button = mbRight then OpenFilterEditor((Sender as TFlatButton).Tag); +end; + +procedure TMainForm.OpenFilterEditor(SlotIdx: Integer); +var Anchor: TFlatButton; +begin + if FController.FMode = MODE_DMR then Exit; // у DMR полоса жёстко задана + if FFilterPop = nil then + begin + FFilterPop := TFilterPopup.Create(Self); + FFilterPop.OnSlotEdited := OnFilterSlotEdited; + FFilterPop.OnSlotReset := OnFilterSlotReset; + end; + if FFilterPop.Visible then begin FFilterPop.Hide; Exit; end; // повторный ПКМ закрывает + Anchor := BtnFilter[EnsureRange(SlotIdx, 0, FILT_SLOTS - 1)]; + if (Anchor = nil) or not Anchor.Visible then Anchor := BtnFilter[0]; + FFilterPop.PopupBelow(FController, Anchor, FController.FMode, SlotIdx); +end; + +// Обновляет ТОЛЬКО подписи/раскладку кнопок фильтра. Полный +// UpdateFilterButtons для не-FM режимов сбрасывает выбранный фильтр на +// заводской — из редактора это отобрало бы у оператора текущий фильтр на +// каждом нажатии клавиши. +procedure TMainForm.RefreshFilterCaptions; +begin + if FController.FMode = MODE_DMR then Exit; + LayoutFilterButtons(FilterPresetCount(FController.FMode)); +end; + +procedure TMainForm.OnFilterSlotEdited(Mode, Idx: Integer; const S: TFilterSlot); +begin + FController.SetFilterSlot(Mode, Idx, S); + RefreshFilterCaptions; // подпись слота могла измениться + OnControllerState(FController, rfFilter); +end; + +procedure TMainForm.OnFilterSlotReset(Mode, Idx: Integer); +begin + if Idx < 0 then FController.ResetFilterSet(Mode) + else FController.ResetFilterSlot(Mode, Idx); + if FFilterPop <> nil then + FFilterPop.LoadFrom(FController, Mode, FFilterPop.SlotIdx); + RefreshFilterCaptions; + OnControllerState(FController, rfFilter); +end; + // --------------------------------------------------------------------------- // FM CTCSS helpers // --------------------------------------------------------------------------- diff --git a/PanafallPanel.pas b/PanafallPanel.pas index 992fab8..f00d3e8 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -1339,6 +1339,9 @@ begin else BW := S.FilterHi - S.FilterLo; end; O.SetState(S.Mode, BW, S.TargetHz, FController.SliceSMeter(SliceId)); + // Ширина выше — для подписи во флаге; полосу на спектре рисуем по настоящим + // кромкам, иначе несимметричный фильтр (слайс-CAT ZZFL/ZZFH) уезжал бы. + O.SetFilterEdges(S.FilterLo, S.FilterHi); O.SetDSPState(S.NRMode, S.NBMode, S.SNB, S.ANF, TRadioController.AGCModeToUI(S.AGC)); O.SetSquelchState(S.FMSQOn, S.FMSQLevel); diff --git a/RadioController.pas b/RadioController.pas index ef84284..356ffcf 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -215,6 +215,11 @@ type procedure ApplyAGCToEngine; // применяет FAGCMode к WDSP (единый маппинг) procedure ApplyModeDefaults; // дефолтный фильтр/девиация под текущий FMode procedure SanitizeFilterForMode; // FM: индекс фильтра 0/1 + BW/девиация (state) + // Единственная точка, где выбранный слот превращается в состояние тракта + // (FFilter/кромки/производная BW/FM-девиация). Тракт не трогает — только state. + procedure ApplyFilterSlot(Idx: Integer); + function ClampFilterIdx(Mode, Idx: Integer): Integer; + procedure CacheFilterToBand; // фильтр текущего банда в band-cache function ActiveVfoHz: Double; // частота активного VFO (A или B) function AnchoredPan(AZoom: Double): Double; // пан, центрирующий окно на маркере VFO procedure ApplyTuneCore(VfoHz: Double); // CTUN shift/center/scroll для актив. VFO @@ -275,8 +280,14 @@ type FVfoA, FVfoB: Double; FActiveVfo: Integer; FMode: Integer; - FFilter: Integer; + FFilter: Integer; // слот 0..FILT_SLOTS-1 (10/11 = VAR1/VAR2) + // Знаковые кромки текущего фильтра — ИСТИНА. FFilterBW производная + // (FilterBWFromEdges) и оставлена ради прежних читателей (web, зум, + // band-cache, CAT); писать в неё напрямую нельзя. + FFilterLo: Integer; + FFilterHi: Integer; FFilterBW: Integer; + FFilterTable: TFilterTable; // редактируемые фильтры по режиму FAGCMode: Integer; FAGCTop: Integer; FRxGainMode: Integer; // Pluto hw-gain: 0=manual,1=fast,2=slow,3=hybrid @@ -670,6 +681,23 @@ type procedure SetSliceFilter(Id, Low, High: Integer); procedure SetSliceModeBW(Id, Mode, BW: Integer); // режим+полоса → кромки фильтра class procedure FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer); + // --- Редактируемая таблица фильтров (пресеты + VAR1/VAR2 на режим) --- + function FilterSlot(Mode, Idx: Integer): TFilterSlot; + procedure SetFilterSlot(Mode, Idx: Integer; const S: TFilterSlot); + procedure ResetFilterSlot(Mode, Idx: Integer); + procedure ResetFilterSet(Mode: Integer); + // Произвольные кромки (drag на спектре, CAT ZZFL/ZZFH, web): пишутся в + // VAR1 и переключают на него, если текущий слот — пресет. Пресеты таким + // образом невозможно испортить случайным движением мыши. + procedure SetFilterEdges(LoHz, HiHz: Integer); + // Только state, без применения к тракту: UpdateFilterButtons — рендер, + // DSP в его путях перестраивается отдельно (как было до таблицы). + procedure SelectFilterSlotState(Idx: Integer); + procedure SanitizeFilterState; + // Знаковые края TX-полосы (для отрисовки полосы фильтра при передаче). + procedure TXFilterEdges(out Lo, Hi: Integer); + property FilterLo: Integer read FFilterLo; + property FilterHi: Integer read FFilterHi; class function AGCModeFromUI(M: Integer): TWDSPAGCMode; // UI-индекс 0..4 → WDSP enum class function AGCModeToUI(A: TWDSPAGCMode): Integer; // и обратно procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode); @@ -1037,7 +1065,9 @@ begin FDeviceStore := TDeviceStore.Create; // общий список устройств (desktop+web) // Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны). FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0; - FMode := MODE_USB; FFilter := 5; FFilterBW := 2700; + FMode := MODE_USB; FFilter := 5; + DefaultFilterTable(FFilterTable); // до подключения — заводские фильтры + ApplyFilterSlot(FFilter); FAGCMode := 1; FAGCTop := 90; FRxGainMode := 2; FRxGainDb := 40; // Pluto: slow_attack по умолчанию FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX) @@ -3161,18 +3191,10 @@ end; // Кромки полосы под режим/полосу (единый расчёт для главного канала и слайсов). class procedure TRadioController.FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer); -var Half: Integer; +// Обёртка над единственной таблицей знака (Settings.FilterEdgesFromBW) — +// оставлена ради прежних вызывающих (MainForm, SetSliceModeBW). begin - Half := BW div 2; - case Mode of - MODE_LSB: begin Lo := -BW; Hi := -100; end; - MODE_USB: begin Lo := 100; Hi := BW; end; - // DIGI: полоса от нуля — поднесущие цифровых видов начинаются низко, - // срез на 100 Гц как в SSB съел бы их. - MODE_DIGL: begin Lo := -BW; Hi := 0; end; - MODE_DIGU: begin Lo := 0; Hi := BW; end; - else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM/WFM - end; + FilterEdgesFromBW(Mode, BW, Lo, Hi); end; procedure TRadioController.SetSliceModeBW(Id, Mode, BW: Integer); @@ -3189,7 +3211,10 @@ procedure TRadioController.ApplyModeFilter; var Lo, Hi: Integer; begin - FilterEdgesFor(FMode, FFilterBW, Lo, Hi); + // Кромки берём как есть: они уже знаковые и уже под текущий режим (таблица + // фильтров ведётся ПО режиму), конвертировать боковую больше нечем и негде. + Lo := FFilterLo; + Hi := FFilterHi; if FWDSPReady and Assigned(FDSPEngine) then begin // Только RX. TX-полоса НЕ следует за RX-фильтром (модель Thetis) — её @@ -3216,61 +3241,53 @@ end; // Выбирает дефолтный фильтр (индекс + полоса + FM-девиация) под текущий FMode. // Зеркалит state-часть UI UpdateFilterButtons, чтобы смена режима была // корректной и без UI (headless). FM сохраняет текущий NFM/WFM, прочие — дефолт. +function TRadioController.ClampFilterIdx(Mode, Idx: Integer): Integer; +// VAR1/VAR2 есть у любого режима, поэтому проверяем только пресетную часть. +// Выход за таблицу режима возвращает ЗАВОДСКОЙ индекс (а не крайний) — так +// вело себя прежнее «if FFilter > FILT_WFM_COUNT-1 then FILT_WFM_DEF». +begin + Result := Idx; + if (Idx = FILT_VAR1) or (Idx = FILT_VAR2) then Exit; + if (Idx < 0) or (Idx > FilterPresetCount(Mode) - 1) then + Result := FilterDefIdx(Mode); +end; + +procedure TRadioController.ApplyFilterSlot(Idx: Integer); +var S: TFilterSlot; +begin + FFilter := ClampFilterIdx(FMode, Idx); + S := FFilterTable[FMode][FFilter]; + FFilterLo := S.Lo; + FFilterHi := S.Hi; + FFilterBW := FilterBWFromEdges(FMode, S.Lo, S.Hi); + // FM-девиация привязана к пресету (NFM/WFM), у VAR берём девиацию дефолта. + if FMode = MODE_FM then + begin + if FFilter <= 1 then FFMDeviation := FILT_FM_DEV[FFilter] + else FFMDeviation := FILT_FM_DEV[FILT_FM_DEF]; + end + else if FMode = MODE_DMR then + FFMDeviation := DMR_FM_DEV; +end; + procedure TRadioController.ApplyModeDefaults; begin + // FM/WFM/DMR/FMRAW сохраняют выбранный пресет (NFM↔WFM и т.п.), остальные + // режимы возвращаются к заводскому индексу — как было до таблицы фильтров. case FMode of - MODE_FM: - begin - if FFilter > 1 then FFilter := FILT_FM_DEF; - FFilterBW := FILT_FM_BW[FFilter]; - FFMDeviation := FILT_FM_DEV[FFilter]; - end; - MODE_WFM: - begin - if FFilter > FILT_WFM_COUNT - 1 then FFilter := FILT_WFM_DEF; - FFilterBW := FILT_WFM_BW[FFilter]; - end; - MODE_DMR: - begin - FFilter := 0; - FFilterBW := FILT_DMR_BW; - FFMDeviation := DMR_FM_DEV; - end; - MODE_FMRAW: - begin - if FFilter > FILT_RAW_COUNT - 1 then FFilter := FILT_RAW_DEF; - FFilterBW := FILT_RAW_BW[FFilter]; - end; - MODE_LSB, MODE_USB: - begin FFilter := FILT_SSB_DEF; FFilterBW := FILT_SSB_BW[FFilter]; end; - MODE_DIGU, MODE_DIGL: - begin FFilter := FILT_DIGI_DEF; FFilterBW := FILT_DIGI_BW[FFilter]; end; - MODE_DSB: - begin FFilter := FILT_DSB_DEF; FFilterBW := FILT_DSB_BW[FFilter]; end; - MODE_CWL, MODE_CWU: - begin FFilter := FILT_CW_DEF; FFilterBW := FILT_CW_BW[FFilter]; end; - else begin FFilter := FILT_AM_DEF; FFilterBW := FILT_AM_BW[FFilter]; end; + MODE_FM, MODE_WFM, MODE_DMR, MODE_FMRAW: ApplyFilterSlot(FFilter); + else ApplyFilterSlot(FilterDefIdx(FMode)); end; end; function TRadioController.FilterBWFor(M, Idx: Integer): Integer; // Полоса выбранного фильтра под режим. Зеркалит выбор таблицы в UpdateFilterButtons, // но НЕ меняет индекс (в отличие от ApplyModeDefaults) — нужно для XVTR-restore. +var S: TFilterSlot; begin - case M of - MODE_FM: Result := FILT_FM_BW[EnsureRange(Idx, 0, FILT_FM_COUNT - 1)]; - MODE_WFM: Result := FILT_WFM_BW[EnsureRange(Idx, 0, FILT_WFM_COUNT - 1)]; - MODE_DMR: Result := FILT_DMR_BW; - MODE_FMRAW: Result := FILT_RAW_BW[EnsureRange(Idx, 0, FILT_RAW_COUNT - 1)]; - MODE_LSB, MODE_USB: - Result := FILT_SSB_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; - MODE_DIGU, MODE_DIGL: - Result := FILT_DIGI_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; - MODE_DSB: Result := FILT_DSB_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; - MODE_CWL, MODE_CWU: - Result := FILT_CW_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; - else Result := FILT_AM_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; - end; + M := EnsureRange(M, 0, CFG_MODE_MAX); + S := FFilterTable[M][ClampFilterIdx(M, Idx)]; + Result := FilterBWFromEdges(M, S.Lo, S.Hi); end; function TRadioController.FilterIdxFor(M, BW: Integer): Integer; @@ -3346,26 +3363,10 @@ begin end; procedure TRadioController.SanitizeFilterForMode; -// Для FM индексы фильтров только 0/1 (NFM/WFM); приводим FFilter и -// синхронизируем FFilterBW/FFMDeviation. Чистая (state) часть UpdateFilterButtons. +// У FM/DMR/FMRAW пресетов меньше, чем у SSB: приводим индекс к допустимому и +// синхронизируем кромки/BW/девиацию. Чистая (state) часть UpdateFilterButtons. begin - if FMode = MODE_FM then - begin - if FFilter > 1 then FFilter := FILT_FM_DEF; - FFilterBW := FILT_FM_BW[FFilter]; - FFMDeviation := FILT_FM_DEV[FFilter]; - end; - if FMode = MODE_DMR then - begin - FFilter := 0; - FFilterBW := FILT_DMR_BW; - FFMDeviation := DMR_FM_DEV; - end; - if FMode = MODE_FMRAW then - begin - if FFilter > FILT_RAW_COUNT - 1 then FFilter := FILT_RAW_DEF; - FFilterBW := FILT_RAW_BW[FFilter]; - end; + if FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then ApplyFilterSlot(FFilter); end; function TRadioController.MakeBandSettings: TBandSettings; @@ -3544,9 +3545,10 @@ begin if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetMode(FMode); // --- Фильтр (санитизация FM до применения к DSP — как в оригинале) --- - FFilter := B.FilterIdx; - FFilterBW := B.FilterBW; - SanitizeFilterForMode; + // Банд помнит ТОЛЬКО выбранный слот; кромки живут в таблице фильтров + // (по режиму, как в Thetis). Иначе у VAR было бы два источника истины — + // ровно та болезнь, от которой уходили. + ApplyFilterSlot(B.FilterIdx); ApplyModeFilter; // --- AGC (единый маппинг — исправляет каст-баг RestoreBand) --- @@ -3985,72 +3987,107 @@ end; procedure TRadioController.SetFilterIdx(Idx: Integer); begin - // Клампим индекс под текущий режим (как FilterBWFor) — публичная команда, - // Idx может прийти из CAT/web без проверки; FM/WFM-таблицы короче остальных. - case FMode of - MODE_FM: Idx := EnsureRange(Idx, 0, FILT_FM_COUNT - 1); - MODE_WFM: Idx := EnsureRange(Idx, 0, FILT_WFM_COUNT - 1); - MODE_DMR: Idx := 0; - MODE_FMRAW: Idx := EnsureRange(Idx, 0, FILT_RAW_COUNT - 1); - else Idx := EnsureRange(Idx, 0, FILT_COUNT - 1); + // Публичная команда: Idx может прийти из CAT/web без проверки. Клампится + // внутри ApplyFilterSlot (у FM/WFM/FMRAW пресетов меньше; VAR всегда есть). + ApplyFilterSlot(Idx); + CacheFilterToBand; + ApplyModeFilter; + Changed(rfFilter); +end; + +procedure TRadioController.CacheFilterToBand; +begin + FBandCache[FCurrentBand].FilterIdx := FFilter; + FBandCache[FCurrentBand].FilterBW := FFilterBW; // легаси-поле, для старых конфигов +end; + +procedure TRadioController.SelectFilterSlotState(Idx: Integer); +begin + ApplyFilterSlot(Idx); +end; + +procedure TRadioController.SanitizeFilterState; +begin + SanitizeFilterForMode; +end; + +procedure TRadioController.TXFilterEdges(out Lo, Hi: Integer); +begin + Lo := 0; Hi := 0; + if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.TXFilterEdgesHz(Lo, Hi); +end; + +function TRadioController.FilterSlot(Mode, Idx: Integer): TFilterSlot; +begin + Result := FFilterTable[EnsureRange(Mode, 0, CFG_MODE_MAX)] + [EnsureRange(Idx, 0, FILT_SLOTS - 1)]; +end; + +procedure TRadioController.SetFilterSlot(Mode, Idx: Integer; const S: TFilterSlot); +begin + Mode := EnsureRange(Mode, 0, CFG_MODE_MAX); + Idx := EnsureRange(Idx, 0, FILT_SLOTS - 1); + if S.Hi <= S.Lo then Exit; // пустая полоса = глухой приём + FFilterTable[Mode][Idx] := S; + if Assigned(FSettings) then FSettings.SaveFilters(FDevMAC, FFilterTable); + // Правка активного слота слышна сразу — форма настроек в EWSDR мгновенная. + if (Mode = FMode) and (Idx = FFilter) then + begin + ApplyFilterSlot(Idx); + CacheFilterToBand; + ApplyModeFilter; + Changed(rfFilter); end; - FFilter := Idx; - case FMode of - MODE_LSB, MODE_USB: FFilterBW := FILT_SSB_BW[Idx]; - MODE_DIGU, MODE_DIGL: FFilterBW := FILT_DIGI_BW[Idx]; - MODE_DSB: FFilterBW := FILT_DSB_BW[Idx]; - MODE_CWL, MODE_CWU: FFilterBW := FILT_CW_BW[Idx]; - MODE_WFM: FFilterBW := FILT_WFM_BW[Idx]; - MODE_DMR: FFilterBW := FILT_DMR_BW; - MODE_FMRAW: FFilterBW := FILT_RAW_BW[Idx]; - MODE_FM: - begin - FFilterBW := FILT_FM_BW[Idx]; - FFMDeviation := FILT_FM_DEV[Idx]; - end; - else FFilterBW := FILT_AM_BW[Idx]; - end; - FBandCache[FCurrentBand].FilterBW := FFilterBW; +end; + +procedure TRadioController.ResetFilterSlot(Mode, Idx: Integer); +var Def: TFilterSet; +begin + Mode := EnsureRange(Mode, 0, CFG_MODE_MAX); + Def := DefaultFilterSet(Mode); + SetFilterSlot(Mode, Idx, Def[EnsureRange(Idx, 0, FILT_SLOTS - 1)]); +end; + +procedure TRadioController.ResetFilterSet(Mode: Integer); +var i: Integer; +begin + for i := 0 to FILT_SLOTS - 1 do ResetFilterSlot(Mode, i); +end; + +procedure TRadioController.SetFilterEdges(LoHz, HiHz: Integer); +// Drag на спектре / CAT ZZFL-ZZFH / web: пресеты не портим — уходим в VAR1. +var S: TFilterSlot; Target: Integer; +begin + if HiHz - LoHz < FILT_MIN_WIDTH then Exit; + if (FFilter = FILT_VAR1) or (FFilter = FILT_VAR2) then Target := FFilter + else Target := FILT_VAR1; + S := FFilterTable[FMode][Target]; + S.Lo := LoHz; + S.Hi := HiHz; + FFilterTable[FMode][Target] := S; + if Assigned(FSettings) then FSettings.SaveFilters(FDevMAC, FFilterTable); + ApplyFilterSlot(Target); + CacheFilterToBand; ApplyModeFilter; Changed(rfFilter); end; procedure TRadioController.SetFilterBW(BWHz: Integer); -// Непрерывная полоса (web-ползунок): применяем запрошенный BW как есть, а -// FFilter подбираем ближайшим пресетом — только для подсветки кнопки. +// Непрерывная полоса (web-ползунок). Точное совпадение с пресетом выбирает +// пресет, произвольная ширина уходит в VAR — как и перетаскивание края мышью. var - BW_arr: array[0..FILT_COUNT-1] of Integer; - best, dist, i: Integer; + Lo, Hi, i, n: Integer; begin if FMode = MODE_DMR then BWHz := FILT_DMR_BW; - FFilterBW := BWHz; - for i := 0 to FILT_COUNT-1 do BW_arr[i] := MaxInt; - case FMode of - MODE_LSB, MODE_USB: - for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_SSB_BW[i]; - MODE_DIGU, MODE_DIGL: - for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_DIGI_BW[i]; - MODE_DSB: for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_DSB_BW[i]; - MODE_CWL, MODE_CWU: - for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_CW_BW[i]; - MODE_FM: for i := 0 to FILT_FM_COUNT-1 do BW_arr[i] := FILT_FM_BW[i]; - MODE_WFM: for i := 0 to FILT_WFM_COUNT-1 do BW_arr[i] := FILT_WFM_BW[i]; - MODE_DMR: BW_arr[0] := FILT_DMR_BW; - MODE_FMRAW: for i := 0 to FILT_RAW_COUNT-1 do BW_arr[i] := FILT_RAW_BW[i]; - else for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_AM_BW[i]; - end; - best := 0; dist := MaxInt; - for i := 0 to FILT_COUNT-1 do - if Abs(BW_arr[i] - BWHz) < dist then + FilterEdgesFromBW(FMode, BWHz, Lo, Hi); + n := FilterPresetCount(FMode); + for i := 0 to n - 1 do + if (FFilterTable[FMode][i].Lo = Lo) and (FFilterTable[FMode][i].Hi = Hi) then begin - dist := Abs(BW_arr[i] - BWHz); - best := i; + SetFilterIdx(i); + Exit; end; - FFilter := best; - if FMode = MODE_FM then FFMDeviation := FILT_FM_DEV[best]; - FBandCache[FCurrentBand].FilterBW := FFilterBW; - ApplyModeFilter; - Changed(rfFilter); + SetFilterEdges(Lo, Hi); end; procedure TRadioController.SetRawFMDeviation(Hz: Integer); @@ -5788,6 +5825,8 @@ begin FNetwork.SetAlexConfig(FAlexSettings); FSettings.LoadOC(FDevMAC, FOCSettings); FNetwork.SetOCConfig(FOCSettings); // no-op на Pluto (base-класс) + FSettings.LoadFilters(FDevMAC, FFilterTable); + ApplyFilterSlot(FFilter); // кромки под таблицу этого устройства FSettings.LoadXvtr(FDevMAC, FXvtrSettings); FCurrentXvtr := -1; ApplyXvtrToNetwork; @@ -5959,6 +5998,7 @@ begin FSettings.SaveAlex(FDevMAC, FAlexSettings); FSettings.SaveAnt936x(FDevMAC, FAnt936x); FSettings.SaveOC(FDevMAC, FOCSettings); + FSettings.SaveFilters(FDevMAC, FFilterTable); // XVTR LastFreq → актуальная видимая частота в момент остановки. if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA; diff --git a/Settings.pas b/Settings.pas index 4fa70b7..4362409 100644 --- a/Settings.pas +++ b/Settings.pas @@ -89,12 +89,52 @@ const FILT_DMR_BW = 12500; DMR_FM_DEV = 2500; + // UI-подписи пресетов. Раньше жили в MainForm — переехали сюда, потому что + // из них строится заводская таблица фильтров (DefaultFilterSet). + FILT_SSB_NAMES: array[0..FILT_COUNT-1] of string = + ('5.0k','4.4k','3.8k','3.3k','2.9k','2.7k','2.4k','2.1k','1.8k','1.0k'); + FILT_CW_NAMES: array[0..FILT_COUNT-1] of string = + ('1.0k','800','600','500','400','250','150','100','50','25'); + FILT_DSB_NAMES: array[0..FILT_COUNT-1] of string = + ('16k','12k','10k','8.0k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k'); + FILT_AM_NAMES: array[0..FILT_COUNT-1] of string = + ('20k','18k','16k','12k','10k','9.0k','8.0k','7.0k','6.0k','5.0k'); + FILT_DIGI_NAMES: array[0..FILT_COUNT-1] of string = + ('3.0k','2.7k','2.4k','2.1k','1.8k','1.5k','1.2k','900','600','300'); + FILT_FM_NAMES: array[0..1] of string = ('NFM', 'FM'); + FILT_WFM_NAMES: array[0..FILT_WFM_COUNT-1] of string = ('200k', '160k', '120k'); + FILT_RAW_NAMES: array[0..FILT_RAW_COUNT-1] of string = + ('6.25k', '10k', '12.5k', '20k', '25k'); + + // --- Редактируемая таблица фильтров ------------------------------------- + // 10 пресетов + VAR1/VAR2 на режим (паритет с Thetis FilterForm). Кромки + // ЗНАКОВЫЕ и хранятся ПО РЕЖИМУ: у LSB своя строка (-2800..-100), у USB + // своя — поэтому при применении фильтра конвертировать боковую больше не + // нужно, и таблица знака в проекте остаётся ровно одна (FilterEdgesFromBW, + // ниже) — только для постройки заводских дефолтов. + FILT_VAR1 = FILT_COUNT; // 10 + FILT_VAR2 = FILT_COUNT + 1; // 11 + FILT_SLOTS = FILT_COUNT + 2; // 12 — сетка кнопок 2 ряда по 6 + // Минимальная ширина полосы: ниже неё фильтр звенит, а перетаскивание края + // мышью легко схлопнуло бы полосу в ноль. + FILT_MIN_WIDTH = 50; + type + TFilterSlot = record + Name: string; + Lo, Hi: Integer; // знаковые кромки в Гц относительно несущей + end; + TFilterSet = array[0..FILT_SLOTS-1] of TFilterSlot; // на режим + TFilterTable = array[0..CFG_MODE_MAX] of TFilterSet; // по режиму + TBandSettings = record VfoA: Double; VfoB: Double; Mode: Integer; FilterIdx: Integer; + // FilterIdx — слот в таблице фильтров режима (см. TFilterSet); сами + // кромки живут в таблице, а не здесь, поэтому диапазон помнит только + // выбор. FilterBW остался легаси-полем для чтения старых конфигов. FilterBW: Integer; AGCMode: Integer; AGCTop: Integer; @@ -581,10 +621,120 @@ type // Тема — глобальная настройка, не привязана к устройству procedure SaveTheme(ALightTheme: Boolean); function LoadTheme: Boolean; + // Таблица фильтров per-device (JSON-секция "filters" под MAC). Пишутся + // только режимы, отличающиеся от заводских, — конфиг не пухнет. + function LoadFilters(const MAC: array of Byte; out T: TFilterTable): Boolean; + procedure SaveFilters(const MAC: array of Byte; const T: TFilterTable); end; +// Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа). +function FilterPresetCount(Mode: Integer): Integer; +// Заводской индекс фильтра режима. +function FilterDefIdx(Mode: Integer): Integer; +// Единственная в проекте таблица «магнитуда полосы → знаковые кромки». +// Нужна только для постройки заводских дефолтов и для чтения старых +// конфигов, где хранилась одна ширина. +procedure FilterEdgesFromBW(Mode, BW: Integer; out Lo, Hi: Integer); +// Обратное преобразование для легаси-читателей FFilterBW. У SSB/DIGI «полоса» +// исторически = внешняя кромка (2.7k при кромках 100..2700), у остальных +// режимов — полная ширина; здесь это воспроизведено один в один. +function FilterBWFromEdges(Mode, Lo, Hi: Integer): Integer; +function DefaultFilterSet(Mode: Integer): TFilterSet; +procedure DefaultFilterTable(out T: TFilterTable); + implementation +function FilterPresetCount(Mode: Integer): Integer; +begin + case Mode of + MODE_FM: Result := FILT_FM_COUNT; + MODE_WFM: Result := FILT_WFM_COUNT; + MODE_DMR: Result := 1; + MODE_FMRAW: Result := FILT_RAW_COUNT; + else Result := FILT_COUNT; + end; +end; + +function FilterDefIdx(Mode: Integer): Integer; +begin + case Mode of + MODE_LSB, MODE_USB: Result := FILT_SSB_DEF; + MODE_DIGL, MODE_DIGU: Result := FILT_DIGI_DEF; + MODE_DSB: Result := FILT_DSB_DEF; + MODE_CWL, MODE_CWU: Result := FILT_CW_DEF; + MODE_FM: Result := FILT_FM_DEF; + MODE_WFM: Result := FILT_WFM_DEF; + MODE_DMR: Result := 0; + MODE_FMRAW: Result := FILT_RAW_DEF; + else Result := FILT_AM_DEF; + end; +end; + +procedure FilterEdgesFromBW(Mode, BW: Integer; out Lo, Hi: Integer); +var Half: Integer; +begin + Half := BW div 2; + case Mode of + MODE_LSB: begin Lo := -BW; Hi := -100; end; + MODE_USB: begin Lo := 100; Hi := BW; end; + // DIGI: полоса считается от нуля — поднесущие цифровых видов начинаются + // низко, срез на 100 Гц как в SSB съел бы их. + MODE_DIGL: begin Lo := -BW; Hi := 0; end; + MODE_DIGU: begin Lo := 0; Hi := BW; end; + else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM/WFM + end; +end; + +function FilterBWFromEdges(Mode, Lo, Hi: Integer): Integer; +begin + case Mode of + MODE_LSB, MODE_USB, MODE_DIGL, MODE_DIGU: + Result := Max(Abs(Lo), Abs(Hi)); + else + Result := Hi - Lo; + end; +end; + +function DefaultFilterSet(Mode: Integer): TFilterSet; +var i, n, bw: Integer; +begin + n := FilterPresetCount(Mode); + for i := 0 to FILT_SLOTS - 1 do + begin + Result[i].Name := ''; + Result[i].Lo := 0; + Result[i].Hi := 0; + end; + bw := 0; + for i := 0 to n - 1 do + begin + case Mode of + MODE_LSB, MODE_USB: begin bw := FILT_SSB_BW[i]; Result[i].Name := FILT_SSB_NAMES[i]; end; + MODE_DIGL, MODE_DIGU: begin bw := FILT_DIGI_BW[i]; Result[i].Name := FILT_DIGI_NAMES[i]; end; + MODE_DSB: begin bw := FILT_DSB_BW[i]; Result[i].Name := FILT_DSB_NAMES[i]; end; + MODE_CWL, MODE_CWU: begin bw := FILT_CW_BW[i]; Result[i].Name := FILT_CW_NAMES[i]; end; + MODE_FM: begin bw := FILT_FM_BW[i]; Result[i].Name := FILT_FM_NAMES[i]; end; + MODE_WFM: begin bw := FILT_WFM_BW[i]; Result[i].Name := FILT_WFM_NAMES[i]; end; + MODE_DMR: begin bw := FILT_DMR_BW; Result[i].Name := '12.5k'; end; + MODE_FMRAW: begin bw := FILT_RAW_BW[i]; Result[i].Name := FILT_RAW_NAMES[i]; end; + else begin bw := FILT_AM_BW[i]; Result[i].Name := FILT_AM_NAMES[i]; end; + end; + FilterEdgesFromBW(Mode, bw, Result[i].Lo, Result[i].Hi); + end; + // VAR1/VAR2 стартуют копией заводского пресета режима — «чистый лист», + // который не сдвигает звук, пока его не тронули. + Result[FILT_VAR1] := Result[FilterDefIdx(Mode)]; + Result[FILT_VAR1].Name := 'VAR1'; + Result[FILT_VAR2] := Result[FilterDefIdx(Mode)]; + Result[FILT_VAR2].Name := 'VAR2'; +end; + +procedure DefaultFilterTable(out T: TFilterTable); +var m: Integer; +begin + for m := 0 to CFG_MODE_MAX do T[m] := DefaultFilterSet(m); +end; + function LegacyAverageTimeMS(FPS, AvgMode, AvgCount: Integer; Backmult, DefaultMS: Double): Double; var @@ -1680,6 +1830,74 @@ begin end; end; +function TSettingsManager.LoadFilters(const MAC: array of Byte; + out T: TFilterTable): Boolean; +var + MacStr, K: string; + DevObj, Root, Obj: TJSONObject; + D: TJSONData; + m, s: Integer; +begin + DefaultFilterTable(T); + MacStr := MacToStr(MAC); + Result := FRoot.Find(MacStr) <> nil; + if not Result then Exit; + DevObj := GetDevObj(MacStr); + if DevObj.Find('filters') = nil then begin Result := False; Exit; end; + Root := EnsureObj(DevObj, 'filters'); + for m := 0 to CFG_MODE_MAX do + begin + D := Root.Find('m' + IntToStr(m)); + if (D = nil) or not (D is TJSONObject) then Continue; + Obj := TJSONObject(D); + for s := 0 to FILT_SLOTS - 1 do + begin + K := 's' + IntToStr(s) + '_'; + T[m][s].Name := JS(Obj, K + 'n', T[m][s].Name); + T[m][s].Lo := JI(Obj, K + 'lo', T[m][s].Lo); + T[m][s].Hi := JI(Obj, K + 'hi', T[m][s].Hi); + if T[m][s].Hi <= T[m][s].Lo then // битый конфиг не должен глушить приём + begin + T[m][s].Lo := DefaultFilterSet(m)[s].Lo; + T[m][s].Hi := DefaultFilterSet(m)[s].Hi; + end; + end; + end; +end; + +procedure TSettingsManager.SaveFilters(const MAC: array of Byte; + const T: TFilterTable); +var + K: string; + Root, Obj: TJSONObject; + Def: TFilterSet; + m, s, Idx: Integer; + Differs: Boolean; +begin + Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'filters'); + for m := 0 to CFG_MODE_MAX do + begin + Def := DefaultFilterSet(m); + Differs := False; + for s := 0 to FILT_SLOTS - 1 do + if (T[m][s].Lo <> Def[s].Lo) or (T[m][s].Hi <> Def[s].Hi) or + (T[m][s].Name <> Def[s].Name) then Differs := True; + // Секцию режима всегда сносим целиком: иначе возврат слота к заводскому + // оставил бы старый ключ и он всплыл бы при следующей загрузке. + Idx := Root.IndexOfName('m' + IntToStr(m)); + if Idx >= 0 then Root.Delete(Idx); + if not Differs then Continue; + Obj := EnsureObj(Root, 'm' + IntToStr(m)); + for s := 0 to FILT_SLOTS - 1 do + begin + K := 's' + IntToStr(s) + '_'; + JWS(Obj, K + 'n', T[m][s].Name); + JW (Obj, K + 'lo', T[m][s].Lo); + JW (Obj, K + 'hi', T[m][s].Hi); + end; + end; +end; + class procedure TSettingsManager.DefaultAnt936x(out A: TAD936xAnt); var i: Integer; begin diff --git a/SpectrumView.pas b/SpectrumView.pas index 28484cc..017bde8 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -21,7 +21,8 @@ uses Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math, AppTheme, AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay, - WaterfallView, SMeterView, RulerView, RadioModes; + WaterfallView, SMeterView, RulerView, RadioModes, + Settings; // FilterEdgesFromBW — единственная таблица знака боковой const SV_CLR_BG = TColor($00101010); @@ -92,6 +93,12 @@ type FTXSpanHz: Double; FTXVfoIndex: Integer; FTXOverlay: Boolean; + // Знаковые кромки фильтров в Гц от несущей. RX — то, что реально стоит в + // приёмнике (фильтр может быть произвольным); TX — ширина передачи, она у + // Thetis-модели НЕ следует за RX, поэтому при передаче полоса рисуется по + // ней, а не по приёмной. 0/0 = не задано, работает прежний фолбэк. + FRXEdgeLo, FRXEdgeHi: Integer; + FTXEdgeLo, FTXEdgeHi: Integer; // ── ADC overload overlay ───────────────────────────────────────────────── FADCOverloadVisible: Boolean; FADCAlertImg: TAlertImage; // кэш образа плашки (BuildAlertImage, 1 раз) @@ -179,6 +186,12 @@ type out X1, X2, VfoX: Integer); procedure CalcFilterBandXFor(VfoFreq: Double; Mode, BW, W: Integer; out X1, X2, VfoX: Integer); + procedure CalcTXBandX(VfoFreq: Double; W: Integer; + out X1, X2, VfoX: Integer); + procedure CalcBandXEdges(VfoFreq, LoHz, HiHz: Double; W: Integer; + out X1, X2, VfoX: Integer); + procedure CalcSliceBandX(O: TVfoOverlay; W: Integer; + out X1, X2, VfoX: Integer); function GetWaterfallDirty: Boolean; procedure SetWaterfallDirty(V: Boolean); // Waterfall sub-view delegating accessors @@ -254,6 +267,10 @@ type property TXSpanHz: Double read FTXSpanHz write SetTXSpanHz; property TXVfoIndex: Integer read FTXVfoIndex write FTXVfoIndex; property TXOverlay: Boolean read FTXOverlay write FTXOverlay; + // Кромки фильтров (Гц от несущей, знаковые). Ставит MainForm: RX — из + // контроллера, TX — из TXSignedEdges движка. + procedure SetRXEdges(LoHz, HiHz: Integer); + procedure SetTXEdges(LoHz, HiHz: Integer); property ADCOverloadVisible: Boolean read FADCOverloadVisible write FADCOverloadVisible; @@ -1172,7 +1189,7 @@ begin // Передающий слайс — красная полоса (как у главного при TX), иначе бейдж-цвет. if O.TxActive then Clr := CLR_TX_BAND else Clr := SliceColor(O.SliceLetter); // $00BBGGRR - CalcFilterBandXFor(O.DisplayVfoHz, O.DisplayMode, O.DisplayBW, W, SX1, SX2, SVfoX); + CalcSliceBandX(O, W, SX1, SX2, SVfoX); if SX2 > SX1 then BlendBand(SX1, SX2, H, Clr and $FF, (Clr shr 8) and $FF, (Clr shr 16) and $FF, IfThen(O.TxActive, 100, 60)); @@ -1191,7 +1208,7 @@ begin if (O = nil) or (not O.Visible) then Continue; if O.TxActive then Clr := CLR_TX_BAND // передающий слайс else Clr := SliceColor(O.SliceLetter); // оттенок бейджа слайса - CalcFilterBandXFor(O.DisplayVfoHz, O.DisplayMode, O.DisplayBW, W, SX1, SX2, SVfoX); + CalcSliceBandX(O, W, SX1, SX2, SVfoX); RawVLine(SX1, 0, H - 1, Clr); RawVLine(SX2, 0, H - 1, Clr); RawVLine(SVfoX, 0, H - 13, Clr, 2); // несущая (жирнее) @@ -1255,29 +1272,81 @@ begin W - ALERT_PAD - FADCAlertImg.W, ALERT_PAD); end; -procedure TSpectrumView.CalcFilterBandX(VfoFreq: Double; W: Integer; - out X1, X2, VfoX: Integer); +procedure TSpectrumView.SetRXEdges(LoHz, HiHz: Integer); begin - CalcFilterBandXFor(VfoFreq, FMode, FFilterBW, W, X1, X2, VfoX); + if (LoHz = FRXEdgeLo) and (HiHz = FRXEdgeHi) then Exit; + FRXEdgeLo := LoHz; + FRXEdgeHi := HiHz; + FSpectrumDirty := True; end; -// То же, но для произвольного режима/полосы (маркеры слайсов). +procedure TSpectrumView.SetTXEdges(LoHz, HiHz: Integer); +begin + if (LoHz = FTXEdgeLo) and (HiHz = FTXEdgeHi) then Exit; + FTXEdgeLo := LoHz; + FTXEdgeHi := HiHz; + if FTXOverlay then FSpectrumDirty := True; +end; + +procedure TSpectrumView.CalcFilterBandX(VfoFreq: Double; W: Integer; + out X1, X2, VfoX: Integer); +// Главный VFO: кромки приходят из контроллера как есть (фильтр может быть +// произвольным — VAR/перетаскивание), выводить их из режима и ширины больше +// нельзя. Фолбэк по FMode/FFilterBW остаётся на случай, если кромки ещё не +// проставлены (первый кадр до первого rfFilter). +begin + if FRXEdgeHi > FRXEdgeLo then + CalcBandXEdges(VfoFreq, FRXEdgeLo, FRXEdgeHi, W, X1, X2, VfoX) + else + CalcFilterBandXFor(VfoFreq, FMode, FFilterBW, W, X1, X2, VfoX); +end; + +// Полоса TX-фильтра вокруг заданной несущей. Кромки уже знаковые и уже под +// режим передачи — их считает WDSPEngine.TXSignedEdges, единственный в проекте +// конвертер боковой для передачи. +procedure TSpectrumView.CalcTXBandX(VfoFreq: Double; W: Integer; + out X1, X2, VfoX: Integer); +begin + if FTXEdgeHi > FTXEdgeLo then + CalcBandXEdges(VfoFreq, FTXEdgeLo, FTXEdgeHi, W, X1, X2, VfoX) + else + CalcFilterBandX(VfoFreq, W, X1, X2, VfoX); +end; + +// Готовые знаковые кромки (Гц от несущей) → пиксели. +procedure TSpectrumView.CalcBandXEdges(VfoFreq, LoHz, HiHz: Double; W: Integer; + out X1, X2, VfoX: Integer); +begin + VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W); + X1 := Max(0, Min(W - 1, VfoX + Round(LoHz / FSpanHz * W))); + X2 := Max(0, Min(W - 1, VfoX + Round(HiHz / FSpanHz * W))); +end; + +// Полоса слайса: пока он передаёт — по TX-фильтру (TX-полоса общая, не на +// слайс), иначе по своему режиму и ширине. +procedure TSpectrumView.CalcSliceBandX(O: TVfoOverlay; W: Integer; + out X1, X2, VfoX: Integer); +begin + if O.TxActive and (FTXEdgeHi > FTXEdgeLo) then + CalcBandXEdges(O.DisplayVfoHz, FTXEdgeLo, FTXEdgeHi, W, X1, X2, VfoX) + else if O.DisplayHi > O.DisplayLo then + // Настоящие кромки слайса — как и у главного VFO. Вывод из режима и + // ширины врал бы на любом фильтре, не подчиняющемся конвенции режима. + CalcBandXEdges(O.DisplayVfoHz, O.DisplayLo, O.DisplayHi, W, X1, X2, VfoX) + else + CalcFilterBandXFor(O.DisplayVfoHz, O.DisplayMode, O.DisplayBW, W, X1, X2, VfoX); +end; + +// То же, но для произвольного режима/полосы (маркеры слайсов). Таблица знака +// не дублируется — берём общую (Settings.FilterEdgesFromBW), ту же, из которой +// строятся заводские фильтры и которой пользуется контроллер. procedure TSpectrumView.CalcFilterBandXFor(VfoFreq: Double; Mode, BW, W: Integer; out X1, X2, VfoX: Integer); var - Lo_Hz, Hi_Hz, Half: Double; + Lo, Hi: Integer; begin - Half := BW / 2; - VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W); - case Mode of - MODE_LSB: begin Lo_Hz := -BW; Hi_Hz := -100; end; - MODE_USB: begin Lo_Hz := 100; Hi_Hz := BW; end; - MODE_DIGL: begin Lo_Hz := -BW; Hi_Hz := 0; end; // полоса от нуля - MODE_DIGU: begin Lo_Hz := 0; 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))); + FilterEdgesFromBW(Mode, BW, Lo, Hi); + CalcBandXEdges(VfoFreq, Lo, Hi, W, X1, X2, VfoX); end; procedure TSpectrumView.DrawSpectrumGradient(const SpPts: array of TPoint; W, H: Integer); @@ -1584,14 +1653,21 @@ begin // фильтра в пару тысяч пикселей стоила 4× дороже полного memcpy кадра), // поэтому порядок операций здесь важнее «логичной» группировки по смыслу. - // Полоса фильтра (X-координаты главного и TX-VFO) - if FActiveVfo = 0 then CalcFilterBandX(FVfoA, W, X1, X2, VfoX) - else CalcFilterBandX(FVfoB, W, X1, X2, VfoX); + // Полоса фильтра (X-координаты главного и TX-VFO). На передаче полоса + // считается по TX-фильтру: его ширина независима от приёмной (модель Thetis), + // и раньше в эфир шло одно, а на экране рисовалось другое. + if FTXOverlay and (FTXVfoIndex = FActiveVfo) then + begin + if FActiveVfo = 0 then CalcTXBandX(FVfoA, W, X1, X2, VfoX) + else CalcTXBandX(FVfoB, W, X1, X2, VfoX); + end + else if FActiveVfo = 0 then CalcFilterBandX(FVfoA, W, X1, X2, VfoX) + else CalcFilterBandX(FVfoB, W, X1, X2, VfoX); TXX1 := 0; TXX2 := 0; TXVfoX := 0; if FTXOverlay and (FTXVfoIndex <> FActiveVfo) then begin - if FTXVfoIndex = 0 then CalcFilterBandX(FVfoA, W, TXX1, TXX2, TXVfoX) - else CalcFilterBandX(FVfoB, W, TXX1, TXX2, TXVfoX); + if FTXVfoIndex = 0 then CalcTXBandX(FVfoA, W, TXX1, TXX2, TXVfoX) + else CalcTXBandX(FVfoB, W, TXX1, TXX2, TXVfoX); end; // Точки кривой спектра diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas index 98d11a4..66af4af 100644 --- a/SpectrumViewOpengl.pas +++ b/SpectrumViewOpengl.pas @@ -586,7 +586,13 @@ begin TXX1 := 0; TXX2 := 0; TXVfoX := 0; - if FActiveVfo = 0 then CalcFilterBandX(FVfoA, W, X1, X2, VfoX) + // На передаче полоса активного VFO рисуется по TX-фильтру (см. CPU-путь). + if FTXOverlay and (FTXVfoIndex = FActiveVfo) then + begin + if FActiveVfo = 0 then CalcTXBandX(FVfoA, W, X1, X2, VfoX) + else CalcTXBandX(FVfoB, W, X1, X2, VfoX); + end + else if FActiveVfo = 0 then CalcFilterBandX(FVfoA, W, X1, X2, VfoX) else CalcFilterBandX(FVfoB, W, X1, X2, VfoX); if FTXOverlay then @@ -594,8 +600,8 @@ begin if FTXVfoIndex <> FActiveVfo then begin if X2 > X1 then DrawRect(X1, 0, X2, H, TColor($0030C030), 100 / 255); - if FTXVfoIndex = 0 then CalcFilterBandX(FVfoA, W, TXX1, TXX2, TXVfoX) - else CalcFilterBandX(FVfoB, W, TXX1, TXX2, TXVfoX); + if FTXVfoIndex = 0 then CalcTXBandX(FVfoA, W, TXX1, TXX2, TXVfoX) + else CalcTXBandX(FVfoB, W, TXX1, TXX2, TXVfoX); if TXX2 > TXX1 then DrawRect(TXX1, 0, TXX2, H, CLR_TX_BAND, 100 / 255); end else if X2 > X1 then DrawRect(X1, 0, X2, H, CLR_TX_BAND, 100 / 255); @@ -638,7 +644,7 @@ begin // Передающий слайс — красная полоса (как у главного при TX), иначе оттенок бейджа. if O.TxActive then Clr := CLR_TX_BAND else Clr := SliceColor(O.SliceLetter); - CalcFilterBandXFor(O.DisplayVfoHz, O.DisplayMode, O.DisplayBW, W, SX1, SX2, SVfoX); + CalcSliceBandX(O, W, SX1, SX2, SVfoX); if SX2 > SX1 then DrawRect(SX1, 0, SX2, H, Clr, IfThen(O.TxActive, 100, 60) / 255); DrawLine(SX1, 0, SX1, H, Clr, 1, False); diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 07ec639..7c2dec5 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -63,6 +63,8 @@ type private FMode: Integer; FFilterBW: Integer; + FFilterLo: Integer; // знаковые кромки (0/0 = не заданы) + FFilterHi: Integer; FVfoHz: Double; FSMeterDB: Double; FModeFilter: array[0..OVL_MODE_COUNT-1] of Integer; @@ -227,6 +229,13 @@ type property DisplayVfoHz: Double read FVfoHz; property DisplayMode: Integer read FMode; property DisplayBW: Integer read FFilterBW; + // Настоящие кромки фильтра слайса (Гц от несущей, знаковые). Одной ширины + // мало: пара→ширина→пара — преобразование с потерями, и полоса на экране + // разъезжалась со звуком, когда кромки заданы не по конвенции режима + // (слайс-CAT ZZFL/ZZFH). Hi<=Lo = не заданы, рисуем по режиму и ширине. + property DisplayLo: Integer read FFilterLo; + property DisplayHi: Integer read FFilterHi; + procedure SetFilterEdges(ALo, AHi: Integer); // Слайс сейчас передаёт (полоса фильтра на спектре краснеет, как у главного). property TxActive: Boolean read FTx; // Битмап-кэш требует перерисовки (GL-вьюха решает, перезаливать ли текстуру). @@ -1595,6 +1604,13 @@ end; { ---- Обновление состояния из контроллера ---- } +procedure TVfoOverlay.SetFilterEdges(ALo, AHi: Integer); +begin + if (ALo = FFilterLo) and (AHi = FFilterHi) then Exit; + FFilterLo := ALo; + FFilterHi := AHi; +end; + procedure TVfoOverlay.SetState(AMode, ABW: Integer; AVfoHz, ASMeterDB: Double); var OldMode: Integer; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 0711106..f717232 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -690,6 +690,9 @@ type // Расширенное TX управление (применяются мгновенно если канал открыт) procedure SetTXFilterFull(Low, High, NC, MP, Window: Integer); + // Знаковые края TX-полосы текущего режима передачи — ровно то, что стоит + // в bp0. Нужны спектру, чтобы на передаче рисовать TX-ширину, а не RX. + procedure TXFilterEdgesHz(out Lo, Hi: Integer); procedure SetTXCompressor(Enable: Boolean; GainDB: Double); procedure SetTXLeveler(Enable: Boolean; TopDB: Double; DecayMS: Integer); procedure SetTXALC(Enable: Boolean; MaxGainDB: Double; DecayMS: Integer); @@ -3719,6 +3722,14 @@ begin GetPSMaxTX(TXA_CHAN, @Result); end; +procedure TWDSPEngine.TXFilterEdgesHz(out Lo, Hi: Integer); +var L, H: Double; +begin + TXSignedEdges(FTXMode, L, H); + Lo := Round(L); + Hi := Round(H); +end; + procedure TWDSPEngine.SetTXFilterFull(Low, High, NC, MP, Window: Integer); var BpLo, BpHi: Double; begin diff --git a/ewsdr.lpi b/ewsdr.lpi index 121a566..9b5246a 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -17,9 +17,9 @@ - + - + @@ -146,6 +146,7 @@ +