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 @@
+