Merge feature/filter-edges: произвольные кромки фильтра, редактор, drag, TX-полоска

Фильтр главного приёмника стал таблицей (Name, Low, High) на режим: 10
пресетов + VAR1/VAR2, знаковые кромки. Поповер-редактор по ПКМ на кнопке
фильтра, перетаскивание края полосы на спектре (Alt = симметрично), полоса
при передаче рисуется по TX-фильтру, CAT ZZFL/ZZFH на главном порту,
энкодеры #4/#5 Андромеды крутят High/Low раздельно.

Попутно устранён дубль таблицы знака боковой (RX-конвенция жила и в
контроллере, и в отрисовке) и потеря информации при рисовании полосы слайса
(пара кромок схлопывалась в одну ширину).

Проверено на железе: 25521a2 + фикс ce43116 (слот VAR терялся при рестарте
в режиме трансвертера).
This commit is contained in:
2026-08-04 13:35:57 +03:00
13 changed files with 1334 additions and 310 deletions
+6 -5
View File
@@ -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
+7
View File
@@ -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;
+18
View File
@@ -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;
+514
View File
@@ -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.
+247 -137
View File
@@ -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
// ---------------------------------------------------------------------------
+3
View File
@@ -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);
+175 -135
View File
@@ -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);
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:
// Публичная команда: 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
FFilterBW := FILT_FM_BW[Idx];
FFMDeviation := FILT_FM_DEV[Idx];
ApplyFilterSlot(Idx);
CacheFilterToBand;
ApplyModeFilter;
Changed(rfFilter);
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;
+223 -2
View File
@@ -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;
@@ -300,7 +340,7 @@ type
LastFreq: Double; // Hz — последняя tuned visible частота VFO A (BandStack)
LastFreqB: Double; // Hz — последняя tuned visible частота VFO B
LastMode: Integer; // Mode (LSB=0,USB=1,…FM=5)
LastFilterIdx: Integer; // FM: 0=NFM, 1=WFM; иначе 0..FILT_COUNT-1
LastFilterIdx: Integer; // слот фильтра 0..FILT_SLOTS-1 (10/11 = VAR1/VAR2)
LastFMSQOn: Boolean;
LastFMSQLevel: Integer;
LastCTCSSOn: Boolean;
@@ -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
@@ -1867,7 +2085,10 @@ begin
X.Entries[i].LastFreq := JD(EObj, 'last_freq', X.Entries[i].LastFreq);
X.Entries[i].LastFreqB := JD(EObj, 'last_freq_b', X.Entries[i].LastFreqB);
X.Entries[i].LastMode := EnsureRange(JI(EObj, 'last_mode', X.Entries[i].LastMode), CFG_MODE_MIN, CFG_MODE_MAX);
X.Entries[i].LastFilterIdx := EnsureRange(JI(EObj, 'last_filt', X.Entries[i].LastFilterIdx), 0, 9);
// Верхняя граница = FILT_SLOTS-1, а не 9: слоты VAR1/VAR2 (10/11) иначе
// схлопывались в 9, и при восстановлении трансвертера ClampFilterIdx
// видел индекс вне таблицы режима и откатывал на заводской фильтр.
X.Entries[i].LastFilterIdx := EnsureRange(JI(EObj, 'last_filt', X.Entries[i].LastFilterIdx), 0, FILT_SLOTS - 1);
X.Entries[i].LastFMSQOn := JB(EObj, 'last_fmsq_on', X.Entries[i].LastFMSQOn);
X.Entries[i].LastFMSQLevel := EnsureRange(JI(EObj, 'last_fmsq_lvl', X.Entries[i].LastFMSQLevel), 0, 100);
X.Entries[i].LastCTCSSOn := JB(EObj, 'last_ctcss_on', X.Entries[i].LastCTCSSOn);
+96 -20
View File
@@ -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.SetRXEdges(LoHz, HiHz: Integer);
begin
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)
// Полоса фильтра (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;
// Точки кривой спектра
+10 -4
View File
@@ -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);
+16
View File
@@ -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;
+11
View File
@@ -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
+3 -2
View File
@@ -17,9 +17,9 @@
<UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/>
<BuildNr Value="293"/>
<BuildNr Value="294"/>
</VersionInfo>
<MacroValues Count="126">
<MacroValues Count="127">
<Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -146,6 +146,7 @@
<Macro124 Name="LCLWidgetType" Value="qt6"/>
<Macro125 Name="LCLWidgetType" Value="qt6"/>
<Macro126 Name="LCLWidgetType" Value="qt6"/>
<Macro127 Name="LCLWidgetType" Value="qt6"/>
</MacroValues>
<BuildModes>
<Item Name="Debug" Default="True"/>