Merge feature/tx-profiles: TX-профили, подвкладки Transmit, заводские EQ ESSB, привязка профиля к «флаг + модуляция» + пласт UI/DPI

This commit is contained in:
2026-08-09 22:06:15 +03:00
25 changed files with 4028 additions and 1009 deletions
+307 -76
View File
@@ -17,19 +17,26 @@ interface
uses uses
Classes, SysUtils, Math, StrUtils, Classes, SysUtils, Math, StrUtils,
Forms, Controls, Graphics, ExtCtrls, StdCtrls, Forms, Controls, Graphics, ExtCtrls, StdCtrls,
AppTheme, BeaconDecoder, BeaconFEC, RadioController; AppTheme, BeaconDecoder, BeaconFEC, RadioController, DpiUtils, FlatMemo;
type type
TBeaconScopeForm = class(TForm) TBeaconScopeForm = class(TForm)
private private
FCtl: TRadioController; FCtl: TRadioController;
FHeader: TPanel;
FTitle: TLabel;
FSubtitle: TLabel;
FBox: TPaintBox; FBox: TPaintBox;
FMemo: TMemo; // декодированный текст бюллетеня (AO-40 кадры) FBulletin: TPanel;
FBulletinTitle: TLabel;
FBulletinHint: TLabel;
FMemo: TFlatMemo; // декодированный текст бюллетеня (AO-40 кадры)
FTimer: TTimer; FTimer: TTimer;
FTheme: TAppTheme; FTheme: TAppTheme;
FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового) FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового)
procedure DoTick(Sender: TObject); procedure DoTick(Sender: TObject);
procedure DoPaint(Sender: TObject); procedure DoPaint(Sender: TObject);
procedure FormResize(Sender: TObject);
procedure PollFrame; procedure PollFrame;
function FrameToText(const Frame: TBeaconFrame): string; function FrameToText(const Frame: TBeaconFrame): string;
public public
@@ -40,34 +47,112 @@ type
implementation implementation
constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController); constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController);
var
WorkArea: TRect;
MaxW, MaxH: Integer;
begin begin
inherited CreateNew(AOwner); inherited CreateNew(AOwner);
FCtl := ACtl; FCtl := ACtl;
FTheme := DarkTheme; FTheme := DarkTheme;
Caption := 'QO-100 Beacon — Constellation + Bulletin'; // Геометрия формы задаётся ниже уже в DPI-aware координатах.
Scaled := False;
Caption := 'QO-100 Beacon';
BorderStyle := bsSizeable; BorderStyle := bsSizeable;
Width := 600; if AOwner is TCustomForm then
Height := 680; begin
WorkArea := TCustomForm(AOwner).BoundsRect;
WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect;
Position := poOwnerFormCenter;
end
else
begin
WorkArea := Screen.PrimaryMonitor.WorkareaRect;
Position := poScreenCenter; Position := poScreenCenter;
end;
MaxW := WorkArea.Right - WorkArea.Left - DpiScale(48);
MaxH := WorkArea.Bottom - WorkArea.Top - DpiScale(48);
Width := Min(DpiScale(760), MaxW);
Height := Min(DpiScale(700), MaxH);
Constraints.MinWidth := Min(DpiScale(520), Width);
Constraints.MinHeight := Min(DpiScale(560), Height);
Color := FTheme.BG;
// Текст бюллетеня (декодированные AO-40 кадры) — снизу, моноширинный, ReadOnly. FHeader := TPanel.Create(Self);
FMemo := TMemo.Create(Self); FHeader.Parent := Self;
FMemo.Parent := Self; FHeader.Align := alTop;
FMemo.Align := alBottom; FHeader.Height := DpiScale(72);
FMemo.Height := 230; FHeader.BevelOuter := bvNone;
FHeader.Color := FTheme.Panel;
FTitle := TLabel.Create(Self);
FTitle.Parent := FHeader;
FTitle.Caption := 'QO-100 BEACON DECODER';
FTitle.AutoSize := False;
FTitle.SetBounds(DpiScale(20), DpiScale(12),
DpiScale(520), DpiScale(26));
FTitle.Font.Size := 15;
FTitle.Font.Style := [fsBold];
FTitle.Font.Color := FTheme.Text;
FSubtitle := TLabel.Create(Self);
FSubtitle.Parent := FHeader;
FSubtitle.Caption :=
'BPSK constellation, carrier recovery and AO-40 telemetry';
FSubtitle.AutoSize := False;
FSubtitle.SetBounds(DpiScale(20), DpiScale(40),
DpiScale(620), DpiScale(20));
FSubtitle.Font.Size := 9;
FSubtitle.Font.Color := FTheme.TextDim;
FBulletin := TPanel.Create(Self);
FBulletin.Parent := Self;
FBulletin.Align := alBottom;
FBulletin.Height := DpiScale(236);
FBulletin.BevelOuter := bvNone;
FBulletin.Color := FTheme.Panel;
FBulletinTitle := TLabel.Create(Self);
FBulletinTitle.Parent := FBulletin;
FBulletinTitle.Caption := 'DECODED BULLETIN';
FBulletinTitle.AutoSize := False;
FBulletinTitle.SetBounds(DpiScale(16), DpiScale(9),
DpiScale(220), DpiScale(20));
FBulletinTitle.Font.Size := 9;
FBulletinTitle.Font.Style := [fsBold];
FBulletinTitle.Font.Color := FTheme.MeterOn;
FBulletinHint := TLabel.Create(Self);
FBulletinHint.Parent := FBulletin;
FBulletinHint.Caption := 'latest AO-40 frames · read-only';
FBulletinHint.AutoSize := False;
FBulletinHint.SetBounds(DpiScale(250), DpiScale(9),
DpiScale(300), DpiScale(20));
FBulletinHint.Anchors := [akTop, akRight];
FBulletinHint.Left := FBulletin.ClientWidth - DpiScale(316);
FBulletinHint.Alignment := taRightJustify;
FBulletinHint.Font.Size := 8;
FBulletinHint.Font.Color := FTheme.TextDim;
// Бюллетень остаётся обычным read-only memo: текст можно выделить и скопировать.
FMemo := TFlatMemo.Create(Self);
FMemo.Parent := FBulletin;
FMemo.Align := alClient;
FMemo.BorderSpacing.Left := DpiScale(12);
FMemo.BorderSpacing.Top := DpiScale(36);
FMemo.BorderSpacing.Right := DpiScale(12);
FMemo.BorderSpacing.Bottom := DpiScale(12);
FMemo.ReadOnly := True; FMemo.ReadOnly := True;
FMemo.ScrollBars := ssVertical;
FMemo.WordWrap := False; FMemo.WordWrap := False;
FMemo.Font.Name := 'Courier New';
FMemo.Font.Size := 9; FMemo.Font.Size := 9;
FMemo.Color := FTheme.BG; FMemo.SetAppTheme(FTheme);
FMemo.Font.Color := FTheme.Text;
FBox := TPaintBox.Create(Self); FBox := TPaintBox.Create(Self);
FBox.Parent := Self; FBox.Parent := Self;
FBox.Align := alClient; FBox.Align := alClient;
FBox.OnPaint := @DoPaint; FBox.OnPaint := @DoPaint;
OnResize := @FormResize;
FormResize(nil);
FTimer := TTimer.Create(Self); FTimer := TTimer.Create(Self);
FTimer.Interval := 40; // ~25 Гц FTimer.Interval := 40; // ~25 Гц
@@ -78,10 +163,39 @@ end;
procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme); procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme);
begin begin
FTheme := T; FTheme := T;
Color := T.BG;
if FHeader <> nil then FHeader.Color := T.Panel;
if FTitle <> nil then FTitle.Font.Color := T.Text;
if FSubtitle <> nil then FSubtitle.Font.Color := T.TextDim;
if FBulletin <> nil then FBulletin.Color := T.Panel;
if FBulletinTitle <> nil then FBulletinTitle.Font.Color := T.MeterOn;
if FBulletinHint <> nil then FBulletinHint.Font.Color := T.TextDim;
if FMemo <> nil then if FMemo <> nil then
FMemo.SetAppTheme(T);
if FBox <> nil then FBox.Invalidate;
end;
procedure TBeaconScopeForm.FormResize(Sender: TObject);
var
BulletinH, MaxBulletinH: Integer;
begin begin
FMemo.Color := FTheme.BG; if (FHeader <> nil) and (FBulletin <> nil) then
FMemo.Font.Color := FTheme.Text; begin
MaxBulletinH := ClientHeight - FHeader.Height - DpiScale(270);
BulletinH := Min(DpiScale(236),
Max(DpiScale(150), MaxBulletinH));
if FBulletin.Height <> BulletinH then
FBulletin.Height := BulletinH;
end;
if FTitle <> nil then
FTitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40));
if FSubtitle <> nil then
FSubtitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40));
if FBulletinHint <> nil then
begin
FBulletinHint.Left := DpiScale(250);
FBulletinHint.Width := Max(0,
FBulletin.ClientWidth - DpiScale(266));
end; end;
if FBox <> nil then FBox.Invalidate; if FBox <> nil then FBox.Invalidate;
end; end;
@@ -134,46 +248,163 @@ end;
procedure TBeaconScopeForm.DoPaint(Sender: TObject); procedure TBeaconScopeForm.DoPaint(Sender: TObject);
var var
C: TCanvas; C: TCanvas;
W, H, cx, cy, r, i, px, py: Integer; W, H, M, Gap, CardGap, GraphSize, cx, cy, r, i, px, py: Integer;
GraphR, MetricsR, PlotR: TRect;
S: TBeaconScope; S: TBeaconScope;
ok: Boolean; ok, Wide: Boolean;
C1, C2: TColor; CarrierText, SymbolText, SNRText, BaudText: string;
st: string; OffsetText, ResidText, FramesText, FECText: string;
TechText: string;
CarrierColor, SymbolColor: TColor;
procedure SetUIFont(ASize: Integer; AStyle: TFontStyles = []);
begin
C.Font.Assign(Self.Font);
C.Font.Size := ASize;
C.Font.Style := AStyle;
end;
procedure DrawCard(const R: TRect; const ATitle: string);
begin
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.Panel;
C.Pen.Style := psSolid;
C.Pen.Width := 1;
C.Pen.Color := FTheme.Border;
C.RoundRect(R.Left, R.Top, R.Right, R.Bottom,
DpiScale(8), DpiScale(8));
SetUIFont(8, [fsBold]);
C.Font.Color := FTheme.TextDim;
C.Brush.Style := bsClear;
C.TextOut(R.Left + DpiScale(14), R.Top + DpiScale(10), ATitle);
end;
procedure DrawMetricTile(const R: TRect; const ALabel, AValue: string;
AValueColor: TColor);
begin
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.BG;
C.Pen.Style := psSolid;
C.Pen.Color := FTheme.Border;
C.RoundRect(R.Left, R.Top, R.Right, R.Bottom,
DpiScale(5), DpiScale(5));
C.Brush.Style := bsClear;
SetUIFont(8);
C.Font.Color := FTheme.TextDim;
C.TextOut(R.Left + DpiScale(9), R.Top + DpiScale(6), ALabel);
SetUIFont(9, [fsBold]);
C.Font.Color := AValueColor;
C.TextOut(R.Left + DpiScale(9), R.Top + DpiScale(21), AValue);
end;
procedure DrawMetrics;
const
LABELS: array[0..7] of string =
('CARRIER', 'SYMBOL', 'SNR', 'SYMBOL RATE',
'TUNE OFFSET', 'RESIDUAL', 'FRAMES', 'FEC');
var
Values: array[0..7] of string;
Colors: array[0..7] of TColor;
Cols, Rows, Col, Row, TileW, TileH, X, Y, n: Integer;
R: TRect;
begin
Values[0] := CarrierText; Values[1] := SymbolText;
Values[2] := SNRText; Values[3] := BaudText;
Values[4] := OffsetText; Values[5] := ResidText;
Values[6] := FramesText; Values[7] := FECText;
for n := 0 to 7 do Colors[n] := FTheme.Text;
Colors[0] := CarrierColor;
Colors[1] := SymbolColor;
if ok and S.HasFrame then Colors[6] := FTheme.MeterOn;
if ok and S.HasFrame then Colors[7] := FTheme.MeterOn;
if Wide then begin Cols := 2; Rows := 4; end
else begin Cols := 4; Rows := 2; end;
CardGap := DpiScale(8);
TileW := (MetricsR.Right - MetricsR.Left - DpiScale(28)
- (Cols - 1) * CardGap) div Cols;
TileH := (MetricsR.Bottom - MetricsR.Top - DpiScale(70)
- (Rows - 1) * CardGap) div Rows;
for n := 0 to 7 do
begin
Col := n mod Cols;
Row := n div Cols;
X := MetricsR.Left + DpiScale(14) + Col * (TileW + CardGap);
Y := MetricsR.Top + DpiScale(34) + Row * (TileH + CardGap);
R := Rect(X, Y, X + TileW, Y + TileH);
DrawMetricTile(R, LABELS[n], Values[n], Colors[n]);
end;
SetUIFont(7);
C.Font.Color := FTheme.TextDim;
C.Brush.Style := bsClear;
C.TextOut(MetricsR.Left + DpiScale(14),
MetricsR.Bottom - DpiScale(20), TechText);
end;
begin begin
C := FBox.Canvas; C := FBox.Canvas;
W := FBox.Width; H := FBox.Height; W := FBox.Width; H := FBox.Height;
// фон
C.Brush.Style := bsSolid; C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.BG; C.Brush.Color := FTheme.BG;
C.FillRect(0, 0, W, H); C.FillRect(0, 0, W, H);
if (W <= 0) or (H <= 0) then Exit;
// область констелляции — квадрат сверху, текст снизу M := DpiScale(16);
r := (Min(W, H - 70) - 20) div 2; Gap := DpiScale(14);
if r < 20 then r := 20; Wide := W >= DpiScale(600);
if r > 110 then r := 110; // кап: оставляем место метрикам над memo if Wide then
cx := W div 2; begin
cy := 10 + r; GraphSize := Min(H - 2 * M, (W - 3 * M) div 2);
GraphR := Rect(M, M, M + GraphSize, H - M);
MetricsR := Rect(GraphR.Right + Gap, M, W - M, H - M);
end
else
begin
GraphSize := Max(DpiScale(74), H - DpiScale(154) - 3 * M);
GraphR := Rect(M, M, W - M, M + GraphSize);
MetricsR := Rect(M, GraphR.Bottom + Gap, W - M, H - M);
end;
DrawCard(GraphR, 'CONSTELLATION');
DrawCard(MetricsR, 'DECODER STATUS');
// сетка/оси // Квадрат констелляции центрируется внутри своей карточки.
GraphSize := Min(GraphR.Right - GraphR.Left - DpiScale(28),
GraphR.Bottom - GraphR.Top - DpiScale(48));
GraphSize := Max(DpiScale(40), GraphSize);
PlotR.Left := GraphR.Left +
(GraphR.Right - GraphR.Left - GraphSize) div 2;
PlotR.Top := GraphR.Top + DpiScale(34) +
(GraphR.Bottom - GraphR.Top - DpiScale(40) - GraphSize) div 2;
PlotR.Right := PlotR.Left + GraphSize;
PlotR.Bottom := PlotR.Top + GraphSize;
cx := (PlotR.Left + PlotR.Right) div 2;
cy := (PlotR.Top + PlotR.Bottom) div 2;
r := GraphSize div 2 - DpiScale(8);
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.BG;
C.Pen.Style := psSolid;
C.Pen.Color := FTheme.Border;
C.Rectangle(PlotR.Left, PlotR.Top, PlotR.Right, PlotR.Bottom);
C.Pen.Style := psSolid; C.Pen.Style := psSolid;
C.Pen.Color := FTheme.SpecGrid; C.Pen.Color := FTheme.SpecGrid;
C.Line(cx - r, cy, cx + r, cy); // ось I (горизонт) C.Line(cx - r, cy, cx + r, cy);
C.Line(cx, cy - r, cx, cy + r); // ось Q (вертикаль) C.Line(cx, cy - r, cx, cy + r);
C.Pen.Color := FTheme.Border;
C.Brush.Style := bsClear; C.Brush.Style := bsClear;
C.Rectangle(cx - r, cy - r, cx + r, cy + r); C.Ellipse(cx - r, cy - r, cx + r, cy + r);
C.Ellipse(cx - r div 2, cy - r div 2, cx + r div 2, cy + r div 2);
// целевые точки BPSK (±1, 0) — ориентир
C.Pen.Color := FTheme.TextDim; C.Pen.Color := FTheme.TextDim;
C.Ellipse(cx + r div 2 - 3, cy - 3, cx + r div 2 + 3, cy + 3); C.Ellipse(cx + r div 2 - DpiScale(3), cy - DpiScale(3),
C.Ellipse(cx - r div 2 - 3, cy - 3, cx - r div 2 + 3, cy + 3); cx + r div 2 + DpiScale(3), cy + DpiScale(3));
C.Ellipse(cx - r div 2 - DpiScale(3), cy - DpiScale(3),
cx - r div 2 + DpiScale(3), cy + DpiScale(3));
ok := (FCtl <> nil) and FCtl.GetBeaconScope(S); ok := (FCtl <> nil) and FCtl.GetBeaconScope(S);
if ok and S.Enabled then if ok and S.Enabled then
begin begin
// точки констелляции (нормированы к ±1; масштаб r/2 → ±1 на половине радиуса)
C.Pen.Style := psClear; C.Pen.Style := psClear;
C.Brush.Style := bsSolid; C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.MeterOn; C.Brush.Color := FTheme.MeterOn;
@@ -182,53 +413,53 @@ begin
px := cx + Round(S.PtI[i] * (r / 2)); px := cx + Round(S.PtI[i] * (r / 2));
py := cy - Round(S.PtQ[i] * (r / 2)); py := cy - Round(S.PtQ[i] * (r / 2));
if (px >= cx - r) and (px <= cx + r) and (py >= cy - r) and (py <= cy + r) then if (px >= cx - r) and (px <= cx + r) and (py >= cy - r) and (py <= cy + r) then
C.FillRect(px - 1, py - 1, px + 2, py + 2); C.FillRect(px - DpiScale(1), py - DpiScale(1),
px + DpiScale(2), py + DpiScale(2));
end; end;
end; end;
// ----- метрики -----
C.Brush.Style := bsClear;
C.Font.Name := 'Courier New';
C.Font.Size := 9;
py := cy + r + 12;
if not ok then if not ok then
begin begin
C.Font.Color := FTheme.TextDim; CarrierText := 'UNAVAILABLE';
C.TextOut(14, py, 'decoder unavailable'); SymbolText := 'UNAVAILABLE';
Exit; SNRText := '--'; BaudText := '--';
OffsetText := '--'; ResidText := '--';
FramesText := '--'; FECText := '--';
TechText := 'Decoder data unavailable';
CarrierColor := FTheme.TextDim;
SymbolColor := FTheme.TextDim;
end; end;
if not S.Enabled then if ok and (not S.Enabled) then
begin begin
C.Font.Color := FTheme.TextDim; CarrierText := 'OFF'; SymbolText := 'OFF';
C.TextOut(14, py, 'decoder OFF (enable via BEACON \ decode)'); SNRText := '--'; BaudText := '--';
Exit; OffsetText := '--'; ResidText := '--';
FramesText := IntToStr(S.Frames);
FECText := '--';
TechText := 'Enable BEACON and select the beacon on the spectrum';
CarrierColor := FTheme.TextDim;
SymbolColor := FTheme.TextDim;
end; end;
if ok and S.Enabled then
if S.CarrierLock then C1 := FTheme.MeterOn else C1 := FTheme.TextDim; begin
if S.SymbolLock then C2 := FTheme.MeterOn else C2 := FTheme.TextDim; CarrierText := IfThen(S.CarrierLock, 'LOCKED', 'SEARCH');
SymbolText := IfThen(S.SymbolLock, 'LOCKED', 'SEARCH');
C.Font.Color := C1; if S.SNRdB > -50 then SNRText := Format('%.1f dB', [S.SNRdB])
C.TextOut(14, py, 'CARRIER ' + IfThen(S.CarrierLock, 'LOCK', '----')); else SNRText := '--';
C.Font.Color := C2; BaudText := Format('%.1f Bd', [S.SymRate]);
C.TextOut(14, py + 18, 'SYMBOL ' + IfThen(S.SymbolLock, 'LOCK', '----')); OffsetText := Format('%.0f Hz', [S.OffsetHz]);
ResidText := Format('%.0f Hz', [S.ResidHz]);
C.Font.Color := FTheme.Text; FramesText := IntToStr(S.Frames);
if S.SNRdB > -50 then st := Format('%.1f dB', [S.SNRdB]) else st := '--'; FECText := Format('RS %d · P%d', [S.LastRSErr, S.ManPhase]);
C.TextOut(14, py + 36, 'SNR ' + st); TechText := Format('INV %s · PROM %.0f · Fs %.0f · Fd %.0f · SPS %.1f',
C.TextOut(14, py + 54, Format('BAUD %.1f (off %.0f Hz)', [S.SymRate, S.OffsetHz])); [IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF'), S.CarProm,
C.Font.Color := FTheme.TextDim; S.FsHz, S.FdecHz, S.Sps]);
C.TextOut(14, py + 72, Format('CARRIER resid %.0f Hz prom %.0f', [S.ResidHz, S.CarProm])); if S.CarrierLock then CarrierColor := FTheme.MeterOn
if FCtl <> nil then else CarrierColor := FTheme.TextDim;
C.TextOut(14, py + 90, 'INVERT ' + IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF')); if S.SymbolLock then SymbolColor := FTheme.MeterOn
C.TextOut(14, py + 108, Format('RATE Fs %.0f Fdec %.0f sps %.1f', else SymbolColor := FTheme.TextDim;
[S.FsHz, S.FdecHz, S.Sps])); end;
DrawMetrics;
// ----- FEC (AO-40) -----
if S.HasFrame then C.Font.Color := FTheme.MeterOn else C.Font.Color := FTheme.TextDim;
C.TextOut(14, py + 132, Format('FRAMES %d (RS err %d)', [S.Frames, S.LastRSErr]));
C.Font.Color := FTheme.TextDim;
C.TextOut(14, py + 150, Format('MANCHESTER phase %d', [S.ManPhase]));
end; end;
end. end.
+245 -90
View File
@@ -14,7 +14,7 @@ uses
Forms, Controls, Graphics, StdCtrls, ExtCtrls, Forms, Controls, Graphics, StdCtrls, ExtCtrls,
FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatButton, FlatCheckBox, FlatComboBox, FlatEdit,
FlatSpinEdit, FlatFloatSpinEdit, FlatListBox, FlatSpinEdit, FlatFloatSpinEdit, FlatListBox,
AppTheme, ChannelStore, RadioModes; AppTheme, ChannelStore, RadioModes, DpiUtils;
const const
// Копии из MainForm: нужны для выпадающих списков // Копии из MainForm: нужны для выпадающих списков
@@ -39,10 +39,19 @@ type
FBtnDelete: TFlatButton; FBtnDelete: TFlatButton;
FBtnUp: TFlatButton; FBtnUp: TFlatButton;
FBtnDown: TFlatButton; FBtnDown: TFlatButton;
FBtnClose: TFlatButton;
FList: TFlatListBox; FList: TFlatListBox;
// --- Правая панель (поля редактирования) --- // --- Правая панель (поля редактирования) ---
FPanelRight: TPanel; FPanelRight: TPanel;
FFooterPanel: TPanel;
FCardIdentity: TPanel;
FCardRadio: TPanel;
FCardRepeater: TPanel;
FLeftTitle: TLabel;
FLeftHint: TLabel;
FRightTitle: TLabel;
FRightHint: TLabel;
// Labels (для перекраски темой) // Labels (для перекраски темой)
FLbls: array of TLabel; FLbls: array of TLabel;
@@ -66,11 +75,13 @@ type
FUpdating: Boolean; FUpdating: Boolean;
FOnChanged: TNotifyEvent; FOnChanged: TNotifyEvent;
FCurrentDefaults: TChannel; // значения по умолчанию для новых каналов FCurrentDefaults: TChannel; // значения по умолчанию для новых каналов
FTheme: TAppTheme;
procedure BuildUI; procedure BuildUI;
procedure StyleBtn(B: TFlatButton; AActive: Boolean = False); procedure StyleBtn(B: TFlatButton; AActive: Boolean = False);
function MakeLabel(AParent: TWinControl; const Cap: string; function MakeLabel(AParent: TWinControl; const Cap: string;
X, Y, W, H: Integer): TLabel; X, Y, W, H: Integer): TLabel;
function MakeGroupPanel(const Cap: string; Y, H: Integer): TPanel;
procedure LoadChannelToFields(Idx: Integer); procedure LoadChannelToFields(Idx: Integer);
procedure SaveFieldsToChannel; procedure SaveFieldsToChannel;
procedure RefreshList(KeepIdx: Integer = -1); procedure RefreshList(KeepIdx: Integer = -1);
@@ -90,6 +101,7 @@ type
procedure OnCTCSSChkChange(Sender: TObject); procedure OnCTCSSChkChange(Sender: TObject);
procedure OnCTCSSCmbChange(Sender: TObject); procedure OnCTCSSCmbChange(Sender: TObject);
// Handlers — форма // Handlers — форма
procedure OnCloseClick(Sender: TObject);
procedure OnFormClose(Sender: TObject; var CloseAction: TCloseAction); procedure OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
public public
constructor Create(AOwner: TComponent; AStore: TChannelStore; constructor Create(AOwner: TComponent; AStore: TChannelStore;
@@ -103,31 +115,54 @@ implementation
const const
// Геометрия // Геометрия
LPANEL_W = 185; // ширина левой панели LPANEL_W = 240;
TOOLBAR_H = 32; // высота тулбара с кнопками Add/Del/Up/Down TOOLBAR_Y = 66;
BTN_H = 24; TOOLBAR_H = 30;
BTN_H = 26;
FORM_W = 820;
FORM_H = 540;
PAGE_MARGIN = 18;
// Правая панель // Правая панель
LBL_X = 8; LBL_X = 14;
LBL_W = 95; LBL_W = 106;
CTRL_X = 106; CTRL_X = 126;
ROW_H = 30; // высота строки ROW_H = 28;
CTRL_H = 24; // высота контрола CTRL_H = 24;
constructor TChannelsForm.Create(AOwner: TComponent; AStore: TChannelStore; constructor TChannelsForm.Create(AOwner: TComponent; AStore: TChannelStore;
AOnChanged: TNotifyEvent); AOnChanged: TNotifyEvent);
var
WorkArea: TRect;
MaxW, MaxH: Integer;
begin begin
inherited CreateNew(AOwner); inherited CreateNew(AOwner);
FStore := AStore; FStore := AStore;
FOnChanged := AOnChanged; FOnChanged := AOnChanged;
FUpdating := False; FUpdating := False;
FTheme := DarkTheme;
TChannelStore.DefaultChannel(FCurrentDefaults); TChannelStore.DefaultChannel(FCurrentDefaults);
Scaled := False;
Caption := 'Channels'; Caption := 'Channels';
BorderStyle := bsSizeable; BorderStyle := bsSizeable;
Width := 720; if AOwner is TCustomForm then
Height := 480; begin
WorkArea := TCustomForm(AOwner).BoundsRect;
WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect;
Position := poOwnerFormCenter;
end
else
begin
WorkArea := Screen.PrimaryMonitor.WorkareaRect;
Position := poScreenCenter; Position := poScreenCenter;
end;
MaxW := WorkArea.Right - WorkArea.Left - DpiScale(48);
MaxH := WorkArea.Bottom - WorkArea.Top - DpiScale(48);
Width := Min(DpiScale(FORM_W), MaxW);
Height := Min(DpiScale(FORM_H), MaxH);
Constraints.MinWidth := Min(DpiScale(680), Width);
Constraints.MinHeight := Min(DpiScale(530), Height);
KeyPreview := True; KeyPreview := True;
OnClose := @OnFormClose; OnClose := @OnFormClose;
@@ -138,46 +173,72 @@ end;
procedure TChannelsForm.BuildUI; procedure TChannelsForm.BuildUI;
var var
i: Integer; i, BtnW, CardW, Y: Integer;
BtnW: Integer; L: TLabel;
RW: Integer; // ширина правой панели
Y: Integer; procedure PlaceRightUnit(AParent: TWinControl; const Cap: string;
ATop: Integer);
begin begin
L := MakeLabel(AParent, Cap, 0, ATop + 1, 42, CTRL_H);
L.SetBounds(AParent.ClientWidth - DpiScale(50), DpiScale(ATop + 1),
DpiScale(42), DpiScale(CTRL_H));
L.Anchors := [akTop, akRight];
end;
begin
SetLength(FLbls, 0);
// ----- Левая панель ----- // ----- Левая панель -----
FPanelLeft := TPanel.Create(Self); FPanelLeft := TPanel.Create(Self);
FPanelLeft.Parent := Self; FPanelLeft.Parent := Self;
FPanelLeft.Align := alLeft; FPanelLeft.Align := alLeft;
FPanelLeft.Width := LPANEL_W; FPanelLeft.Width := DpiScale(LPANEL_W);
FPanelLeft.BevelOuter := bvNone; FPanelLeft.BevelOuter := bvNone;
BtnW := (LPANEL_W - 4 - 3 * 2) div 4; // 4 кнопки с зазорами FLeftTitle := MakeLabel(FPanelLeft, 'CHANNEL MEMORY',
14, 12, LPANEL_W - 28, 24);
FLeftTitle.Font.Size := 9;
FLeftTitle.Font.Style := [fsBold];
FLeftTitle.Font.Color := FTheme.Text;
FLeftHint := MakeLabel(FPanelLeft, 'Saved frequencies and modes',
14, 37, LPANEL_W - 28, 18);
FLeftHint.Font.Size := 8;
BtnW := (DpiScale(LPANEL_W - 24) - 3 * DpiScale(6)) div 4;
FBtnAdd := TFlatButton.Create(Self); FBtnAdd := TFlatButton.Create(Self);
FBtnAdd.Parent := FPanelLeft; FBtnAdd.Parent := FPanelLeft;
FBtnAdd.SetBounds(2, 4, BtnW, BTN_H); FBtnAdd.SetBounds(DpiScale(12), DpiScale(TOOLBAR_Y),
BtnW, DpiScale(BTN_H));
FBtnAdd.Caption := 'ADD'; FBtnAdd.Caption := 'ADD';
FBtnAdd.OnClick := @OnAddClick; FBtnAdd.OnClick := @OnAddClick;
FBtnDelete := TFlatButton.Create(Self); FBtnDelete := TFlatButton.Create(Self);
FBtnDelete.Parent := FPanelLeft; FBtnDelete.Parent := FPanelLeft;
FBtnDelete.SetBounds(2 + (BtnW + 2), 4, BtnW, BTN_H); FBtnDelete.SetBounds(FBtnAdd.Left + BtnW + DpiScale(6),
FBtnAdd.Top, BtnW, FBtnAdd.Height);
FBtnDelete.Caption := 'DEL'; FBtnDelete.Caption := 'DEL';
FBtnDelete.OnClick := @OnDeleteClick; FBtnDelete.OnClick := @OnDeleteClick;
FBtnUp := TFlatButton.Create(Self); FBtnUp := TFlatButton.Create(Self);
FBtnUp.Parent := FPanelLeft; FBtnUp.Parent := FPanelLeft;
FBtnUp.SetBounds(2 + 2 * (BtnW + 2), 4, BtnW, BTN_H); FBtnUp.SetBounds(FBtnDelete.Left + BtnW + DpiScale(6),
FBtnAdd.Top, BtnW, FBtnAdd.Height);
FBtnUp.Caption := '↑'; FBtnUp.Caption := '↑';
FBtnUp.OnClick := @OnUpClick; FBtnUp.OnClick := @OnUpClick;
FBtnDown := TFlatButton.Create(Self); FBtnDown := TFlatButton.Create(Self);
FBtnDown.Parent := FPanelLeft; FBtnDown.Parent := FPanelLeft;
FBtnDown.SetBounds(2 + 3 * (BtnW + 2), 4, BtnW, BTN_H); FBtnDown.SetBounds(FBtnUp.Left + BtnW + DpiScale(6),
FBtnAdd.Top, BtnW, FBtnAdd.Height);
FBtnDown.Caption := '↓'; FBtnDown.Caption := '↓';
FBtnDown.OnClick := @OnDownClick; FBtnDown.OnClick := @OnDownClick;
FList := TFlatListBox.Create(Self); FList := TFlatListBox.Create(Self);
FList.Parent := FPanelLeft; FList.Parent := FPanelLeft;
FList.SetBounds(0, TOOLBAR_H, LPANEL_W, FPanelLeft.Height - TOOLBAR_H); FList.SetBounds(DpiScale(12), DpiScale(TOOLBAR_Y + TOOLBAR_H + 10),
FPanelLeft.ClientWidth - DpiScale(24),
FPanelLeft.ClientHeight - DpiScale(TOOLBAR_Y + TOOLBAR_H + 22));
FList.Anchors := [akLeft, akTop, akRight, akBottom]; FList.Anchors := [akLeft, akTop, akRight, akBottom];
FList.OnClick := @OnListClick; FList.OnClick := @OnListClick;
FList.OnDblClick := @OnListDblClick; FList.OnDblClick := @OnListDblClick;
@@ -188,34 +249,62 @@ begin
FPanelRight.Align := alClient; FPanelRight.Align := alClient;
FPanelRight.BevelOuter := bvNone; FPanelRight.BevelOuter := bvNone;
SetLength(FLbls, 0); FFooterPanel := TPanel.Create(Self);
RW := Width - LPANEL_W; FFooterPanel.Parent := FPanelRight;
FFooterPanel.Align := alBottom;
FFooterPanel.Height := DpiScale(50);
FFooterPanel.BevelOuter := bvNone;
Y := 8; FBtnClose := TFlatButton.Create(Self);
FBtnClose.Parent := FFooterPanel;
FBtnClose.Caption := 'Close';
FBtnClose.SetBounds(FFooterPanel.ClientWidth - DpiScale(110),
DpiScale(10), DpiScale(92), DpiScale(30));
FBtnClose.Anchors := [akTop, akRight];
FBtnClose.OnClick := @OnCloseClick;
// Group FRightTitle := MakeLabel(FPanelRight, 'CHANNEL DETAILS',
MakeLabel(FPanelRight, 'Group', LBL_X, Y + 1, LBL_W, CTRL_H); PAGE_MARGIN, 10, 420, 22);
FRightTitle.Font.Size := 9;
FRightTitle.Font.Style := [fsBold];
FRightTitle.Font.Color := FTheme.Text;
FRightTitle.Anchors := [akLeft, akTop, akRight];
FRightHint := MakeLabel(FPanelRight,
'Changes are saved automatically', PAGE_MARGIN, 31, 420, 18);
FRightHint.Font.Size := 8;
FRightHint.Anchors := [akLeft, akTop, akRight];
FCardIdentity := MakeGroupPanel('IDENTITY', 56, 90);
CardW := FCardIdentity.ClientWidth;
Y := 34;
MakeLabel(FCardIdentity, 'Group', LBL_X, Y + 1, LBL_W, CTRL_H);
FEdGroup := TFlatEdit.Create(Self); FEdGroup := TFlatEdit.Create(Self);
FEdGroup.Parent := FPanelRight; FEdGroup.Parent := FCardIdentity;
FEdGroup.SetBounds(CTRL_X, Y, RW - CTRL_X - 8, CTRL_H); FEdGroup.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
CardW - DpiScale(CTRL_X + 14), DpiScale(CTRL_H));
FEdGroup.Anchors := [akLeft, akTop, akRight]; FEdGroup.Anchors := [akLeft, akTop, akRight];
FEdGroup.OnChange := @OnFieldChange; FEdGroup.OnChange := @OnFieldChange;
Inc(Y, ROW_H); Inc(Y, ROW_H);
// Name MakeLabel(FCardIdentity, 'Name', LBL_X, Y + 1, LBL_W, CTRL_H);
MakeLabel(FPanelRight, 'Name', LBL_X, Y + 1, LBL_W, CTRL_H);
FEdName := TFlatEdit.Create(Self); FEdName := TFlatEdit.Create(Self);
FEdName.Parent := FPanelRight; FEdName.Parent := FCardIdentity;
FEdName.SetBounds(CTRL_X, Y, RW - CTRL_X - 8, CTRL_H); FEdName.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
CardW - DpiScale(CTRL_X + 14), DpiScale(CTRL_H));
FEdName.Anchors := [akLeft, akTop, akRight]; FEdName.Anchors := [akLeft, akTop, akRight];
FEdName.OnChange := @OnFieldChange; FEdName.OnChange := @OnFieldChange;
Inc(Y, ROW_H);
// RX Freq FCardRadio := MakeGroupPanel('RADIO', 156, 120);
MakeLabel(FPanelRight, 'RX Freq', LBL_X, Y + 1, LBL_W, CTRL_H); CardW := FCardRadio.ClientWidth;
Y := 34;
MakeLabel(FCardRadio, 'RX frequency', LBL_X, Y + 1, LBL_W, CTRL_H);
FSpinRXFreq := TFlatFloatSpinEdit.Create(Self); FSpinRXFreq := TFlatFloatSpinEdit.Create(Self);
FSpinRXFreq.Parent := FPanelRight; FSpinRXFreq.Parent := FCardRadio;
FSpinRXFreq.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H); FSpinRXFreq.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
CardW - DpiScale(CTRL_X + 56), DpiScale(CTRL_H));
FSpinRXFreq.Anchors := [akLeft, akTop, akRight]; FSpinRXFreq.Anchors := [akLeft, akTop, akRight];
FSpinRXFreq.MinValue := 0.001; FSpinRXFreq.MinValue := 0.001;
FSpinRXFreq.MaxValue := 2000.0; FSpinRXFreq.MaxValue := 2000.0;
@@ -223,14 +312,14 @@ begin
FSpinRXFreq.DecimalPlaces := 6; FSpinRXFreq.DecimalPlaces := 6;
FSpinRXFreq.Value := 145.5; FSpinRXFreq.Value := 145.5;
FSpinRXFreq.OnChange := @OnFieldChange; FSpinRXFreq.OnChange := @OnFieldChange;
MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H); PlaceRightUnit(FCardRadio, 'MHz', Y);
Inc(Y, ROW_H); Inc(Y, ROW_H);
// TX Freq MakeLabel(FCardRadio, 'TX frequency', LBL_X, Y + 1, LBL_W, CTRL_H);
MakeLabel(FPanelRight, 'TX Freq', LBL_X, Y + 1, LBL_W, CTRL_H);
FSpinTXFreq := TFlatFloatSpinEdit.Create(Self); FSpinTXFreq := TFlatFloatSpinEdit.Create(Self);
FSpinTXFreq.Parent := FPanelRight; FSpinTXFreq.Parent := FCardRadio;
FSpinTXFreq.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H); FSpinTXFreq.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
CardW - DpiScale(CTRL_X + 56), DpiScale(CTRL_H));
FSpinTXFreq.Anchors := [akLeft, akTop, akRight]; FSpinTXFreq.Anchors := [akLeft, akTop, akRight];
FSpinTXFreq.MinValue := 0.001; FSpinTXFreq.MinValue := 0.001;
FSpinTXFreq.MaxValue := 2000.0; FSpinTXFreq.MaxValue := 2000.0;
@@ -238,48 +327,53 @@ begin
FSpinTXFreq.DecimalPlaces := 6; FSpinTXFreq.DecimalPlaces := 6;
FSpinTXFreq.Value := 145.5; FSpinTXFreq.Value := 145.5;
FSpinTXFreq.OnChange := @OnFieldChange; FSpinTXFreq.OnChange := @OnFieldChange;
MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H); PlaceRightUnit(FCardRadio, 'MHz', Y);
Inc(Y, ROW_H); Inc(Y, ROW_H);
// Mode MakeLabel(FCardRadio, 'Mode', LBL_X, Y + 1, LBL_W, CTRL_H);
MakeLabel(FPanelRight, 'Mode', LBL_X, Y + 1, LBL_W, CTRL_H);
FCmbMode := TFlatComboBox.Create(Self); FCmbMode := TFlatComboBox.Create(Self);
FCmbMode.Parent := FPanelRight; FCmbMode.Parent := FCardRadio;
FCmbMode.SetBounds(CTRL_X, Y, 160, CTRL_H); FCmbMode.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
DpiScale(180), DpiScale(CTRL_H));
for i := 0 to CH_MODE_COUNT - 1 do FCmbMode.Items.Add(CH_MODE_NAMES[i]); for i := 0 to CH_MODE_COUNT - 1 do FCmbMode.Items.Add(CH_MODE_NAMES[i]);
FCmbMode.ItemIndex := 5; // FM default FCmbMode.ItemIndex := 5; // FM default
FCmbMode.OnChange := @OnFieldChange; FCmbMode.OnChange := @OnFieldChange;
Inc(Y, ROW_H);
// RPTR direction FCardRepeater := MakeGroupPanel('REPEATER / TRANSMIT', 286, 148);
MakeLabel(FPanelRight, 'RPTR', LBL_X, Y + 1, LBL_W, CTRL_H); CardW := FCardRepeater.ClientWidth;
Y := 34;
MakeLabel(FCardRepeater, 'Direction', LBL_X, Y + 1, LBL_W, CTRL_H);
FBtnRptNone := TFlatButton.Create(Self); FBtnRptNone := TFlatButton.Create(Self);
FBtnRptNone.Parent := FPanelRight; FBtnRptNone.Parent := FCardRepeater;
FBtnRptNone.SetBounds(CTRL_X, Y, 55, CTRL_H); FBtnRptNone.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
DpiScale(72), DpiScale(CTRL_H));
FBtnRptNone.Caption := 'NONE'; FBtnRptNone.Caption := 'NONE';
FBtnRptNone.Tag := 0; FBtnRptNone.Tag := 0;
FBtnRptNone.OnClick := @OnRptBtnClick; FBtnRptNone.OnClick := @OnRptBtnClick;
FBtnRptMinus := TFlatButton.Create(Self); FBtnRptMinus := TFlatButton.Create(Self);
FBtnRptMinus.Parent := FPanelRight; FBtnRptMinus.Parent := FCardRepeater;
FBtnRptMinus.SetBounds(CTRL_X + 58, Y, 38, CTRL_H); FBtnRptMinus.SetBounds(DpiScale(CTRL_X + 78), DpiScale(Y),
DpiScale(46), DpiScale(CTRL_H));
FBtnRptMinus.Caption := ''; FBtnRptMinus.Caption := '';
FBtnRptMinus.Tag := 1; FBtnRptMinus.Tag := 1;
FBtnRptMinus.OnClick := @OnRptBtnClick; FBtnRptMinus.OnClick := @OnRptBtnClick;
FBtnRptPlus := TFlatButton.Create(Self); FBtnRptPlus := TFlatButton.Create(Self);
FBtnRptPlus.Parent := FPanelRight; FBtnRptPlus.Parent := FCardRepeater;
FBtnRptPlus.SetBounds(CTRL_X + 99, Y, 38, CTRL_H); FBtnRptPlus.SetBounds(DpiScale(CTRL_X + 130), DpiScale(Y),
DpiScale(46), DpiScale(CTRL_H));
FBtnRptPlus.Caption := '+'; FBtnRptPlus.Caption := '+';
FBtnRptPlus.Tag := 2; FBtnRptPlus.Tag := 2;
FBtnRptPlus.OnClick := @OnRptBtnClick; FBtnRptPlus.OnClick := @OnRptBtnClick;
Inc(Y, ROW_H); Inc(Y, ROW_H);
// RPTR Offset MakeLabel(FCardRepeater, 'Offset', LBL_X, Y + 1, LBL_W, CTRL_H);
MakeLabel(FPanelRight, 'RPT Offset', LBL_X, Y + 1, LBL_W, CTRL_H);
FSpinRptOffset := TFlatFloatSpinEdit.Create(Self); FSpinRptOffset := TFlatFloatSpinEdit.Create(Self);
FSpinRptOffset.Parent := FPanelRight; FSpinRptOffset.Parent := FCardRepeater;
FSpinRptOffset.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H); FSpinRptOffset.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
CardW - DpiScale(CTRL_X + 56), DpiScale(CTRL_H));
FSpinRptOffset.Anchors := [akLeft, akTop, akRight]; FSpinRptOffset.Anchors := [akLeft, akTop, akRight];
FSpinRptOffset.MinValue := 0.0; FSpinRptOffset.MinValue := 0.0;
FSpinRptOffset.MaxValue := 100.0; FSpinRptOffset.MaxValue := 100.0;
@@ -287,37 +381,38 @@ begin
FSpinRptOffset.DecimalPlaces := 3; FSpinRptOffset.DecimalPlaces := 3;
FSpinRptOffset.Value := 0.6; FSpinRptOffset.Value := 0.6;
FSpinRptOffset.OnChange := @OnFieldChange; FSpinRptOffset.OnChange := @OnFieldChange;
MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H); PlaceRightUnit(FCardRepeater, 'MHz', Y);
Inc(Y, ROW_H); Inc(Y, ROW_H);
// CTCSS MakeLabel(FCardRepeater, 'CTCSS tone', LBL_X, Y + 1, LBL_W, CTRL_H);
MakeLabel(FPanelRight, 'CTCSS', LBL_X, Y + 1, LBL_W, CTRL_H);
FChkCTCSS := TFlatCheckBox.Create(Self); FChkCTCSS := TFlatCheckBox.Create(Self);
FChkCTCSS.Parent := FPanelRight; FChkCTCSS.Parent := FCardRepeater;
FChkCTCSS.SetBounds(CTRL_X, Y, 26, CTRL_H); FChkCTCSS.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
DpiScale(28), DpiScale(CTRL_H));
FChkCTCSS.Caption := ''; FChkCTCSS.Caption := '';
FChkCTCSS.Checked := False; FChkCTCSS.Checked := False;
FChkCTCSS.OnChange := @OnCTCSSChkChange; FChkCTCSS.OnChange := @OnCTCSSChkChange;
FCmbCTCSS := TFlatComboBox.Create(Self); FCmbCTCSS := TFlatComboBox.Create(Self);
FCmbCTCSS.Parent := FPanelRight; FCmbCTCSS.Parent := FCardRepeater;
FCmbCTCSS.SetBounds(CTRL_X + 30, Y, RW - CTRL_X - 38, CTRL_H); FCmbCTCSS.SetBounds(DpiScale(CTRL_X + 34), DpiScale(Y),
CardW - DpiScale(CTRL_X + 48), DpiScale(CTRL_H));
FCmbCTCSS.Anchors := [akLeft, akTop, akRight]; FCmbCTCSS.Anchors := [akLeft, akTop, akRight];
for i := 0 to CH_CTCSS_COUNT - 1 do FCmbCTCSS.Items.Add(CH_CTCSS_NAMES[i]); for i := 0 to CH_CTCSS_COUNT - 1 do FCmbCTCSS.Items.Add(CH_CTCSS_NAMES[i]);
FCmbCTCSS.ItemIndex := 0; FCmbCTCSS.ItemIndex := 0;
FCmbCTCSS.OnChange := @OnCTCSSCmbChange; FCmbCTCSS.OnChange := @OnCTCSSCmbChange;
Inc(Y, ROW_H); Inc(Y, ROW_H);
// Power MakeLabel(FCardRepeater, 'Power', LBL_X, Y + 1, LBL_W, CTRL_H);
MakeLabel(FPanelRight, 'Power', LBL_X, Y + 1, LBL_W, CTRL_H);
FSpinPower := TFlatSpinEdit.Create(Self); FSpinPower := TFlatSpinEdit.Create(Self);
FSpinPower.Parent := FPanelRight; FSpinPower.Parent := FCardRepeater;
FSpinPower.SetBounds(CTRL_X, Y, 80, CTRL_H); FSpinPower.SetBounds(DpiScale(CTRL_X), DpiScale(Y),
DpiScale(92), DpiScale(CTRL_H));
FSpinPower.MinValue := 0; FSpinPower.MinValue := 0;
FSpinPower.MaxValue := 100; FSpinPower.MaxValue := 100;
FSpinPower.Value := 100; FSpinPower.Value := 100;
FSpinPower.OnChange := @OnFieldChange; FSpinPower.OnChange := @OnFieldChange;
MakeLabel(FPanelRight, '%', CTRL_X + 84, Y + 1, 20, CTRL_H); MakeLabel(FCardRepeater, '%', CTRL_X + 98, Y + 1, 24, CTRL_H);
end; end;
function TChannelsForm.MakeLabel(AParent: TWinControl; const Cap: string; function TChannelsForm.MakeLabel(AParent: TWinControl; const Cap: string;
@@ -327,37 +422,86 @@ begin
L := TLabel.Create(Self); L := TLabel.Create(Self);
L.Parent := AParent; L.Parent := AParent;
L.Caption := Cap; L.Caption := Cap;
L.SetBounds(X, Y, W, H); L.AutoSize := False;
L.Font.Name := 'Courier New'; L.SetBounds(DpiScale(X), DpiScale(Y), DpiScale(W), DpiScale(H));
L.Layout := tlCenter;
L.Font.Size := 8; L.Font.Size := 8;
L.Font.Color := DarkTheme.TextDim; L.Font.Color := FTheme.TextDim;
// Сохраняем для последующей перекраски темой // Сохраняем для последующей перекраски темой
SetLength(FLbls, Length(FLbls) + 1); SetLength(FLbls, Length(FLbls) + 1);
FLbls[High(FLbls)] := L; FLbls[High(FLbls)] := L;
Result := L; Result := L;
end; end;
procedure TChannelsForm.StyleBtn(B: TFlatButton; AActive: Boolean); function TChannelsForm.MakeGroupPanel(const Cap: string; Y, H: Integer): TPanel;
var T: TAppTheme; var
Title: TLabel;
Divider: TPanel;
begin begin
T := DarkTheme; Result := TPanel.Create(Self);
Result.Parent := FPanelRight;
Result.SetBounds(DpiScale(PAGE_MARGIN), DpiScale(Y),
FPanelRight.ClientWidth - DpiScale(2 * PAGE_MARGIN), DpiScale(H));
Result.Anchors := [akLeft, akTop, akRight];
Result.BevelOuter := bvNone;
Result.Color := FTheme.Panel;
Title := MakeLabel(Result, Cap, 12, 2, 300, 24);
Title.Font.Size := 8;
Title.Font.Style := [fsBold];
Title.Font.Color := FTheme.FreqActive;
Divider := TPanel.Create(Self);
Divider.Parent := Result;
Divider.SetBounds(0, DpiScale(28), Result.ClientWidth, 1);
Divider.Anchors := [akLeft, akTop, akRight];
Divider.BevelOuter := bvNone;
Divider.Color := FTheme.Border;
end;
procedure TChannelsForm.StyleBtn(B: TFlatButton; AActive: Boolean);
begin
// Прямо созданные TFlatButton не проходят через MakeFlatBtn, поэтому
// явно даём им тот же системный шрифт, что и остальному интерфейсу.
B.Font.Assign(Font);
B.Font.Size := 8;
B.Font.Style := [];
B.Active := AActive; B.Active := AActive;
B.ClrNorm := T.BtnNorm; B.ClrNorm := FTheme.BtnNorm;
B.ClrActive := T.BtnActive; B.ClrActive := FTheme.BtnActive;
B.ClrHot := T.BtnHot; B.ClrHot := FTheme.BtnHot;
B.ClrBorder := IfThen(AActive, T.BtnBorderActive, T.BtnBorderNorm); B.ClrBorder := IfThen(AActive,
B.ClrText := T.BtnText; FTheme.BtnBorderActive, FTheme.BtnBorderNorm);
B.ClrTextAct := T.BtnTextActive; B.ClrText := FTheme.BtnText;
B.ClrTextAct := FTheme.BtnTextActive;
B.Invalidate;
end; end;
procedure TChannelsForm.ApplyTheme(const T: TAppTheme); procedure TChannelsForm.ApplyTheme(const T: TAppTheme);
var var
i: Integer; i: Integer;
Ch: TChannel; Ch: TChannel;
procedure ThemeCard(P: TPanel);
var
n: Integer;
begin begin
Color := T.Panel; if P = nil then Exit;
P.Color := T.Panel;
for n := 0 to P.ControlCount - 1 do
if (P.Controls[n] is TPanel) and (P.Controls[n].Height = 1) then
TPanel(P.Controls[n]).Color := T.Border;
end;
begin
FTheme := T;
Color := T.BG;
FPanelLeft.Color := T.Panel; FPanelLeft.Color := T.Panel;
FPanelRight.Color := T.Panel; FPanelRight.Color := T.BG;
FFooterPanel.Color := T.BG;
ThemeCard(FCardIdentity);
ThemeCard(FCardRadio);
ThemeCard(FCardRepeater);
FList.SetAppTheme(T); FList.SetAppTheme(T);
@@ -365,6 +509,7 @@ begin
StyleBtn(FBtnDelete); StyleBtn(FBtnDelete);
StyleBtn(FBtnUp); StyleBtn(FBtnUp);
StyleBtn(FBtnDown); StyleBtn(FBtnDown);
StyleBtn(FBtnClose);
// RPT buttons — пересчитываем Active из текущего состояния // RPT buttons — пересчитываем Active из текущего состояния
if SelectedIdx >= 0 then if SelectedIdx >= 0 then
@@ -393,9 +538,15 @@ begin
for i := 0 to High(FLbls) do for i := 0 to High(FLbls) do
begin begin
if FLbls[i] = FLeftTitle then
FLbls[i].Font.Color := T.Text
else if FLbls[i] = FRightTitle then
FLbls[i].Font.Color := T.Text
else if fsBold in FLbls[i].Font.Style then
FLbls[i].Font.Color := T.FreqActive
else
FLbls[i].Font.Color := T.TextDim; FLbls[i].Font.Color := T.TextDim;
FLbls[i].Color := T.Panel; FLbls[i].Transparent := True;
FLbls[i].ParentColor := False;
end; end;
end; end;
@@ -476,7 +627,6 @@ procedure TChannelsForm.SaveFieldsToChannel;
var var
Idx: Integer; Idx: Integer;
Ch: TChannel; Ch: TChannel;
i: Integer;
begin begin
if FUpdating then Exit; if FUpdating then Exit;
Idx := SelectedIdx; Idx := SelectedIdx;
@@ -607,6 +757,11 @@ begin
SaveFieldsToChannel; SaveFieldsToChannel;
end; end;
procedure TChannelsForm.OnCloseClick(Sender: TObject);
begin
Close;
end;
procedure TChannelsForm.OnFormClose(Sender: TObject; var CloseAction: TCloseAction); procedure TChannelsForm.OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
begin begin
CloseAction := caHide; // скрываем, не уничтожаем — указатель в MainForm остаётся валидным CloseAction := caHide; // скрываем, не уничтожаем — указатель в MainForm остаётся валидным
+14 -27
View File
@@ -7,7 +7,7 @@ interface
uses uses
Classes, SysUtils, FlatButton, FlatEdit, FlatListBox, AppTheme, Forms, Controls, Graphics, Dialogs, Classes, SysUtils, FlatButton, FlatEdit, FlatListBox, AppTheme, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, ComCtrls, LCLType, StdCtrls, ExtCtrls, ComCtrls, LCLType,
BoardUtils, PlatformUtils, DeviceStore, RadioBackend, DpiUtils; BoardUtils, DeviceStore, RadioBackend, DpiUtils;
type type
// Результат диалога // Результат диалога
@@ -26,8 +26,6 @@ type
FResult: TDeviceDialogResult; FResult: TDeviceDialogResult;
// UI // UI
PanelTop: TPanel;
PanelBottom: TPanel;
PanelLeft: TPanel; PanelLeft: TPanel;
PanelRight: TPanel; PanelRight: TPanel;
@@ -40,6 +38,7 @@ type
EdIP: TFlatEdit; EdIP: TFlatEdit;
LblName: TLabel; LblName: TLabel;
LblIP: TLabel; LblIP: TLabel;
LblAutoHint: TLabel;
LblFound: TLabel; LblFound: TLabel;
LstFound: TFlatListBox; LstFound: TFlatListBox;
@@ -52,7 +51,6 @@ type
FOnDiscover: TNotifyEvent; // внешний callback для запуска discovery FOnDiscover: TNotifyEvent; // внешний callback для запуска discovery
procedure BuildUI; procedure BuildUI;
procedure ApplyTheme;
procedure SetStore(AStore: TDeviceStore); procedure SetStore(AStore: TDeviceStore);
procedure RefreshSavedList; procedure RefreshSavedList;
@@ -105,7 +103,6 @@ const
CLR_PANEL = TColor($001A1A1A); CLR_PANEL = TColor($001A1A1A);
CLR_TEXT = TColor($00E0E0E0); CLR_TEXT = TColor($00E0E0E0);
CLR_TEXTDIM = TColor($00888888); CLR_TEXTDIM = TColor($00888888);
CLR_BORDER = TColor($00303030);
CLR_ACCENT = TColor($0040FF80); CLR_ACCENT = TColor($0040FF80);
CLR_AUTO = TColor($0000CCFF); // цвет авто-устройства CLR_AUTO = TColor($0000CCFF); // цвет авто-устройства
DLG_W = 760; DLG_W = 760;
@@ -123,18 +120,20 @@ const
constructor TDeviceDialog.Create(AOwner: TComponent); constructor TDeviceDialog.Create(AOwner: TComponent);
begin begin
inherited CreateNew(AOwner); inherited CreateNew(AOwner);
// Форма целиком строится кодом и уже масштабирует геометрию через DpiScale.
// Автоскейл LCL поверх неё дал бы повторный множитель (125% -> 156.25%).
Scaled := False;
Caption := 'Device Selection'; Caption := 'Device Selection';
Width := DpiScale(DLG_W); Width := DpiScale(DLG_W);
Height := DpiScale(DLG_H); Height := DpiScale(DLG_H);
Position := poScreenCenter; Position := poScreenCenter;
BorderStyle := bsDialog; BorderStyle := bsDialog;
Color := CLR_BG; Color := CLR_BG;
Font.Name := 'Courier New';
Font.Size := 8; Font.Size := 8;
Font.Color := CLR_TEXT; Font.Color := CLR_TEXT;
FStore := nil; // назначается владельцем через property Store до показа FStore := nil; // назначается владельцем через property Store до показа
FResult.Accepted := False; ClearResult;
BuildUI; BuildUI;
SetTheme(DarkTheme); SetTheme(DarkTheme);
@@ -163,7 +162,6 @@ begin
Result.Parent := AParent; Result.Parent := AParent;
Result.Caption := Cap; Result.Caption := Cap;
Result.Left := DpiScale(X); Result.Top := DpiScale(Y); Result.Left := DpiScale(X); Result.Top := DpiScale(Y);
Result.Font.Name := 'Courier New';
Result.Font.Size := 8; Result.Font.Size := 8;
Result.Font.Color := CLR_TEXTDIM; Result.Font.Color := CLR_TEXTDIM;
end; end;
@@ -172,7 +170,6 @@ procedure TDeviceDialog.BuildUI;
const const
LabelH = 18; LabelH = 18;
var var
LblHint: TLabel;
FieldTop, ButtonsTop, BottomTop: Integer; FieldTop, ButtonsTop, BottomTop: Integer;
begin begin
// --- Левая панель: сохранённые устройства --- // --- Левая панель: сохранённые устройства ---
@@ -182,36 +179,33 @@ begin
PanelLeft.BevelOuter := bvNone; PanelLeft.BevelOuter := bvNone;
PanelLeft.Color := CLR_PANEL; PanelLeft.Color := CLR_PANEL;
MakeLbl(PanelLeft, 'SAVED DEVICES', PAD, PAD); LblSaved := MakeLbl(PanelLeft, 'SAVED DEVICES', PAD, PAD);
LstSaved := TFlatListBox.Create(Self); LstSaved := TFlatListBox.Create(Self);
LstSaved.Parent := PanelLeft; LstSaved.Parent := PanelLeft;
LstSaved.SetBounds(DpiScale(PAD), DpiScale(PAD + LabelH), DpiScale(LEFT_W - PAD * 2), DpiScale(174)); LstSaved.SetBounds(DpiScale(PAD), DpiScale(PAD + LabelH), DpiScale(LEFT_W - PAD * 2), DpiScale(174));
LstSaved.Color := CLR_BG; LstSaved.Color := CLR_BG;
LstSaved.Font.Color:= CLR_TEXT; LstSaved.Font.Color:= CLR_TEXT;
LstSaved.Font.Name := 'Courier New';
LstSaved.Font.Size := 8; LstSaved.Font.Size := 8;
LstSaved.OnClick := @LstSavedClick; LstSaved.OnClick := @LstSavedClick;
LstSaved.OnDblClick := @LstSavedDblClick; LstSaved.OnDblClick := @LstSavedDblClick;
FieldTop := 212; FieldTop := 212;
MakeLbl(PanelLeft, 'Name:', PAD, FieldTop + 5); LblName := MakeLbl(PanelLeft, 'Name:', PAD, FieldTop + 5);
EdName := TFlatEdit.Create(Self); EdName := TFlatEdit.Create(Self);
EdName.Parent := PanelLeft; EdName.Parent := PanelLeft;
EdName.SetBounds(DpiScale(76), DpiScale(FieldTop), DpiScale(LEFT_W - 76 - PAD), DpiScale(EDIT_H)); EdName.SetBounds(DpiScale(76), DpiScale(FieldTop), DpiScale(LEFT_W - 76 - PAD), DpiScale(EDIT_H));
EdName.Color := CLR_BG; EdName.Color := CLR_BG;
EdName.Font.Color:= CLR_TEXT; EdName.Font.Color:= CLR_TEXT;
EdName.Font.Name := 'Courier New';
EdName.Font.Size := 8; EdName.Font.Size := 8;
Inc(FieldTop, EDIT_H + 10); Inc(FieldTop, EDIT_H + 10);
MakeLbl(PanelLeft, 'IP:', PAD, FieldTop + 5); LblIP := MakeLbl(PanelLeft, 'IP:', PAD, FieldTop + 5);
EdIP := TFlatEdit.Create(Self); EdIP := TFlatEdit.Create(Self);
EdIP.Parent := PanelLeft; EdIP.Parent := PanelLeft;
EdIP.SetBounds(DpiScale(76), DpiScale(FieldTop), DpiScale(LEFT_W - 76 - PAD), DpiScale(EDIT_H)); EdIP.SetBounds(DpiScale(76), DpiScale(FieldTop), DpiScale(LEFT_W - 76 - PAD), DpiScale(EDIT_H));
EdIP.Color := CLR_BG; EdIP.Color := CLR_BG;
EdIP.Font.Color:= CLR_TEXT; EdIP.Font.Color:= CLR_TEXT;
EdIP.Font.Name := 'Courier New';
EdIP.Font.Size := 8; EdIP.Font.Size := 8;
EdIP.TextHint := '192.168.1.x'; EdIP.TextHint := '192.168.1.x';
@@ -221,8 +215,9 @@ begin
Inc(ButtonsTop, BTN_H + 8); Inc(ButtonsTop, BTN_H + 8);
BtnSetAuto := MakeBtn(PanelLeft, 'SET AUTOSTART', PAD, ButtonsTop, 154, BTN_H, @BtnSetAutoClick); BtnSetAuto := MakeBtn(PanelLeft, 'SET AUTOSTART', PAD, ButtonsTop, 154, BTN_H, @BtnSetAutoClick);
LblHint := MakeLbl(PanelLeft, '* = autostart', PAD + 168, ButtonsTop + 6); LblAutoHint := MakeLbl(PanelLeft, '* = autostart',
LblHint.Font.Color := CLR_AUTO; PAD + 168, ButtonsTop + 6);
LblAutoHint.Font.Color := CLR_AUTO;
BottomTop := PANEL_H - PAD - BTN_H - 6; BottomTop := PANEL_H - PAD - BTN_H - 6;
BtnConnect := MakeBtn(PanelLeft, 'CONNECT', PAD, BottomTop, LEFT_W - PAD * 2, BTN_H + 6, @BtnConnectClick); BtnConnect := MakeBtn(PanelLeft, 'CONNECT', PAD, BottomTop, LEFT_W - PAD * 2, BTN_H + 6, @BtnConnectClick);
@@ -236,14 +231,13 @@ begin
PanelRight.BevelOuter := bvNone; PanelRight.BevelOuter := bvNone;
PanelRight.Color := CLR_PANEL; PanelRight.Color := CLR_PANEL;
MakeLbl(PanelRight, 'DISCOVERED DEVICES', PAD, PAD); LblFound := MakeLbl(PanelRight, 'DISCOVERED DEVICES', PAD, PAD);
LstFound := TFlatListBox.Create(Self); LstFound := TFlatListBox.Create(Self);
LstFound.Parent := PanelRight; LstFound.Parent := PanelRight;
LstFound.SetBounds(DpiScale(PAD), DpiScale(PAD + LabelH), DpiScale(RIGHT_W - PAD * 2), DpiScale(250)); LstFound.SetBounds(DpiScale(PAD), DpiScale(PAD + LabelH), DpiScale(RIGHT_W - PAD * 2), DpiScale(250));
LstFound.Color := CLR_BG; LstFound.Color := CLR_BG;
LstFound.Font.Color:= CLR_TEXT; LstFound.Font.Color:= CLR_TEXT;
LstFound.Font.Name := 'Courier New';
LstFound.Font.Size := 8; LstFound.Font.Size := 8;
LstFound.OnDblClick := @LstFoundDblClick; LstFound.OnDblClick := @LstFoundDblClick;
@@ -254,11 +248,6 @@ begin
BtnCancel := MakeBtn(PanelRight, 'CANCEL', RIGHT_W - PAD - 116, BottomTop, 116, BTN_H + 6, @BtnCancelClick); BtnCancel := MakeBtn(PanelRight, 'CANCEL', RIGHT_W - PAD - 116, BottomTop, 116, BTN_H + 6, @BtnCancelClick);
end; end;
procedure TDeviceDialog.ApplyTheme;
begin
SetTheme(DarkTheme);
end;
procedure TDeviceDialog.SetTheme(const T: TAppTheme); procedure TDeviceDialog.SetTheme(const T: TAppTheme);
procedure StyleButton(B: TFlatButton; Active: Boolean); procedure StyleButton(B: TFlatButton; Active: Boolean);
@@ -272,7 +261,6 @@ procedure TDeviceDialog.SetTheme(const T: TAppTheme);
else B.ClrBorder := T.BtnBorderNorm; else B.ClrBorder := T.BtnBorderNorm;
B.ClrText := T.BtnText; B.ClrText := T.BtnText;
B.ClrTextAct := T.BtnTextActive; B.ClrTextAct := T.BtnTextActive;
B.Font.Name := 'Courier New';
B.Font.Size := 8; B.Font.Size := 8;
B.Font.Color := T.Text; B.Font.Color := T.Text;
B.Invalidate; B.Invalidate;
@@ -288,7 +276,6 @@ procedure TDeviceDialog.SetTheme(const T: TAppTheme);
B.ClrBorder := T.TbDiscoverBorder; B.ClrBorder := T.TbDiscoverBorder;
B.ClrText := T.TbDiscoverText; B.ClrText := T.TbDiscoverText;
B.ClrTextAct := T.TbDiscoverText; B.ClrTextAct := T.TbDiscoverText;
B.Font.Name := 'Courier New';
B.Font.Size := 8; B.Font.Size := 8;
B.Font.Color := T.TbDiscoverText; B.Font.Color := T.TbDiscoverText;
B.Invalidate; B.Invalidate;
@@ -304,7 +291,6 @@ procedure TDeviceDialog.SetTheme(const T: TAppTheme);
B.ClrBorder := T.TbStartBorder; B.ClrBorder := T.TbStartBorder;
B.ClrText := T.TbStartText; B.ClrText := T.TbStartText;
B.ClrTextAct := T.TbStartText; B.ClrTextAct := T.TbStartText;
B.Font.Name := 'Courier New';
B.Font.Size := 8; B.Font.Size := 8;
B.Font.Color := T.TbStartText; B.Font.Color := T.TbStartText;
B.Invalidate; B.Invalidate;
@@ -340,6 +326,7 @@ begin
if EdName <> nil then EdName.SetAppTheme(T); if EdName <> nil then EdName.SetAppTheme(T);
if EdIP <> nil then EdIP.SetAppTheme(T); if EdIP <> nil then EdIP.SetAppTheme(T);
WalkLabels(Self); WalkLabels(Self);
if LblAutoHint <> nil then LblAutoHint.Font.Color := T.Amber;
Invalidate; Invalidate;
end; end;
+135 -22
View File
@@ -31,6 +31,7 @@ type
FBtnBands3: TFlatButton; FBtnBands3: TFlatButton;
FBtnBands10: TFlatButton; FBtnBands10: TFlatButton;
FButtonTheme: TAppTheme; FButtonTheme: TAppTheme;
FRespDB: array of Double; // АЧХ по бинам (дБ), пересчитывается в Paint
function GetBandGain(Index: Integer): Double; function GetBandGain(Index: Integer): Double;
function GetBandFreq(Index: Integer): Double; function GetBandFreq(Index: Integer): Double;
procedure SetEnabledEQ(V: Boolean); procedure SetEnabledEQ(V: Boolean);
@@ -47,7 +48,9 @@ type
function BandX(Index: Integer; const R: TRect): Integer; function BandX(Index: Integer; const R: TRect): Integer;
function XToFreq(X: Integer; const R: TRect): Double; function XToFreq(X: Integer; const R: TRect): Double;
function BandColor(Index: Integer): TColor; function BandColor(Index: Integer): TColor;
function GridBandFreq(Index: Integer): Double;
function InternalBandFreq(Index: Integer): Double; function InternalBandFreq(Index: Integer): Double;
procedure BuildResponse;
function ResponseAtFreq(AFreq: Double): Double; function ResponseAtFreq(AFreq: Double): Double;
function FormatFreq(AFreq: Double): string; function FormatFreq(AFreq: Double): string;
function HitTestControl(X, Y: Integer): Integer; function HitTestControl(X, Y: Integer): Integer;
@@ -93,6 +96,20 @@ const
MAX_FREQ = 20000.0; MAX_FREQ = 20000.0;
TOP_H = 44; TOP_H = 44;
BOTTOM_H = 82; BOTTOM_H = 82;
// Кривая = точное повторение расчёта WDSP (wdsp/eq.c, eq_impulse) под
// геометрию НАШЕГО TXA: канал открыт с dsp_rate = FAudioRate (48 кГц),
// eqp создаётся с nc = max(2048, dsp_size) и ctfmode = 0 (TXA.c) —
// SetTXAEQNC/SetTXAEQCtfmode мы не зовём. Отсюда N = 2048 ⇒ N/2 бинов
// до Найквиста. Рисовать «колокольчики», как принято у графических EQ,
// нельзя: WDSP строит ломаную по точкам и обрывает всё за крайними.
RESP_RATE = 48000.0;
RESP_MID = 1024; // N/2
RESP_BIN_HZ = RESP_RATE / 2.0 / RESP_MID; // 23.4375 Hz
RESP_FLOOR = -2000.0; // 1e-100 в амплитуде — пол WDSP
// Legacy-раскладка 3-полосного EQ у самого WDSP (SetTXAGrphEQ) — её же
// отдаёт движок (WDSPEngine.PushTXEQProfile). Частоты фиксированы, ручка
// Low держит полку 150..400 Гц, High тянется до 6 кГц.
EQ3_F: array[1..3] of Double = (150.0, 1500.0, 6000.0);
constructor TEqualizerControl.Create(AOwner: TComponent); constructor TEqualizerControl.Create(AOwner: TComponent);
var var
@@ -300,7 +317,8 @@ begin
else Result := FClrAccent; else Result := FClrAccent;
end; end;
function TEqualizerControl.InternalBandFreq(Index: Integer): Double; function TEqualizerControl.GridBandFreq(Index: Integer): Double;
// Узел 10-полосной сетки профиля (то, что реально хранится и сохраняется).
begin begin
if (Index >= 1) and (Index <= 10) and (FFreqs[Index] > 0.0) then if (Index >= 1) and (Index <= 10) and (FFreqs[Index] > 0.0) then
Result := FFreqs[Index] Result := FFreqs[Index]
@@ -308,24 +326,113 @@ begin
Result := MIN_FREQ * Power(MAX_FREQ / MIN_FREQ, EnsureRange((Index - 1) / 9, 0.0, 1.0)); Result := MIN_FREQ * Power(MAX_FREQ / MIN_FREQ, EnsureRange((Index - 1) / 9, 0.0, 1.0));
end; end;
function TEqualizerControl.ResponseAtFreq(AFreq: Double): Double; function TEqualizerControl.InternalBandFreq(Index: Integer): Double;
var // Частота, на которой ручка ДЕЙСТВУЕТ. В 3-полосном режиме это не наша сетка,
i, N: Integer; // а legacy-раскладка WDSP; FFreqs при этом не трогается, поэтому возврат к
D, W, Sum, LF, LBand: Double; // 10 полосам отдаёт пользователю его же кривую.
begin begin
Result := FPreampGain; if FBandCount = 3 then
LF := Ln(EnsureRange(AFreq, MIN_FREQ, MAX_FREQ)); Result := EQ3_F[EnsureRange(Index, 1, 3)]
Sum := 0.0; else
N := FBandCount; Result := GridBandFreq(Index);
end;
procedure TEqualizerControl.BuildResponse;
// Порт eq_impulse (wdsp/eq.c) в дБ, без построения самого FIR. Точки F/G —
// узлы ломаной: между ними линейная интерполяция ПО ДЕЦИБЕЛАМ, ниже первой и
// выше последней точки ctfmode=0 даёт кумулятивный скат (f/f0)^4 НА КАЖДЫЙ
// бин — то есть фактически обрыв. Именно поэтому включённый EQ работает как
// второй полосовой фильтр, и верхняя точка обязана лежать за краем TX-фильтра.
var
N, i, j, k, Lo, Hi: Integer;
Fp, Gp: array[0..11] of Double;
TF, TG, F, Frac, Preamp, MagDB, RefDB: Double;
begin
if Length(FRespDB) <> RESP_MID then SetLength(FRespDB, RESP_MID);
Preamp := FPreampGain;
if FBandCount = 3 then
begin
// Ровно то, что уходит в WDSP из PushTXEQProfile: 4 узла, полка на низах.
N := 4;
Fp[1] := 2.0 * 150.0 / RESP_RATE; Gp[1] := FGains[1];
Fp[2] := 2.0 * 400.0 / RESP_RATE; Gp[2] := FGains[1];
Fp[3] := 2.0 * 1500.0 / RESP_RATE; Gp[3] := FGains[2];
Fp[4] := 2.0 * 6000.0 / RESP_RATE; Gp[4] := FGains[3];
end
else
begin
N := EnsureRange(FBandCount, 1, 10);
for i := 1 to N do for i := 1 to N do
begin begin
LBand := Ln(InternalBandFreq(i)); Fp[i] := EnsureRange(2.0 * InternalBandFreq(i) / RESP_RATE, 0.0, 1.0);
D := (LF - LBand) / 0.30; Gp[i] := FGains[i];
W := Exp(-0.5 * D * D); end;
Sum := Sum + FGains[i] * W; end;
// WDSP сортирует точки по частоте (qsort) — порядок ручек значения не имеет.
for i := 1 to N - 1 do
for j := 1 to N - i do
if Fp[j] > Fp[j + 1] then
begin
TF := Fp[j]; Fp[j] := Fp[j + 1]; Fp[j + 1] := TF;
TG := Gp[j]; Gp[j] := Gp[j + 1]; Gp[j + 1] := TG;
end;
Fp[0] := 0.0; Gp[0] := Gp[1];
Fp[N + 1] := 1.0; Gp[N + 1] := Gp[N];
j := 0;
for i := 0 to RESP_MID - 1 do
begin
F := (i + 0.5) / RESP_MID;
while (j < N) and (F > Fp[j + 1]) do Inc(j);
if Fp[j + 1] > Fp[j] then Frac := (F - Fp[j]) / (Fp[j + 1] - Fp[j])
else Frac := 0.0;
FRespDB[i] := Frac * Gp[j + 1] + (1.0 - Frac) * Gp[j] + Preamp;
end;
// Скаты за крайними точками (ctfmode = 0, чётная ветка N). Множитель на бин
// — (f/f0)^4 в АМПЛИТУДЕ, в децибелах 80*log10(f/f0), и он накапливается.
Lo := Trunc(Fp[1] * RESP_MID - 0.5);
Hi := Trunc(Fp[N] * RESP_MID - 0.5);
if (Lo > 0) and (Lo < RESP_MID) then
begin
MagDB := FRespDB[Lo];
RefDB := 80.0 * Log10(Lo / RESP_MID);
for k := Lo - 1 downto 0 do
begin
if k > 0 then MagDB := MagDB + 80.0 * Log10(k / RESP_MID) - RefDB
else MagDB := RESP_FLOOR;
if MagDB < RESP_FLOOR then MagDB := RESP_FLOOR;
FRespDB[k] := MagDB;
end;
end;
if (Hi >= 0) and (Hi < RESP_MID - 1) then
begin
MagDB := FRespDB[Hi];
RefDB := 80.0 * Log10(Hi / RESP_MID);
for k := Hi + 1 to RESP_MID - 1 do
begin
MagDB := MagDB + RefDB - 80.0 * Log10(k / RESP_MID);
if MagDB < RESP_FLOOR then MagDB := RESP_FLOOR;
FRespDB[k] := MagDB;
end;
end;
end;
function TEqualizerControl.ResponseAtFreq(AFreq: Double): Double;
// Значение готовой АЧХ на частоте: линейная интерполяция между центрами бинов.
var
Pos: Double;
K: Integer;
begin
if Length(FRespDB) <> RESP_MID then BuildResponse;
Pos := AFreq / RESP_BIN_HZ - 0.5;
if Pos <= 0.0 then
Result := FRespDB[0]
else if Pos >= RESP_MID - 1 then
Result := FRespDB[RESP_MID - 1]
else
begin
K := Trunc(Pos);
Result := FRespDB[K] + (Pos - K) * (FRespDB[K + 1] - FRespDB[K]);
end; end;
Result := Result + Sum;
Result := EnsureRange(Result, -24.0, 24.0);
end; end;
function TEqualizerControl.FormatFreq(AFreq: Double): string; function TEqualizerControl.FormatFreq(AFreq: Double): string;
@@ -387,7 +494,10 @@ begin
Canvas.Brush.Style := bsClear; Canvas.Brush.Style := bsClear;
Canvas.Font.Color := FClrTextDim; Canvas.Font.Color := FClrTextDim;
S := FormatFreq(InternalBandFreq(i)); // Активные плитки подписаны рабочей частотой, погашенные — своим узлом
// сетки: иначе в 3-полосном режиме все семь показывали бы 6 кГц.
if i <= FBandCount then S := FormatFreq(InternalBandFreq(i))
else S := FormatFreq(GridBandFreq(i));
Canvas.TextOut(TR.Left + 6, TR.Top + 34, S); Canvas.TextOut(TR.Left + 6, TR.Top + 34, S);
Canvas.Font.Color := C; Canvas.Font.Color := C;
Canvas.TextOut(TR.Left + 6, TR.Top + 49, FormatFloat('0.0', FGains[i])); Canvas.TextOut(TR.Left + 6, TR.Top + 49, FormatFloat('0.0', FGains[i]));
@@ -419,7 +529,6 @@ begin
B.ClrBorder := FButtonTheme.BtnBorderNorm; B.ClrBorder := FButtonTheme.BtnBorderNorm;
B.ClrText := FButtonTheme.BtnText; B.ClrText := FButtonTheme.BtnText;
B.ClrTextAct := FButtonTheme.BtnTextActive; B.ClrTextAct := FButtonTheme.BtnTextActive;
B.Font.Name := 'Courier New';
B.Font.Size := 8; B.Font.Size := 8;
B.Invalidate; B.Invalidate;
end; end;
@@ -527,6 +636,7 @@ begin
Canvas.Pen.Color := FClrBorder; Canvas.Pen.Color := FClrBorder;
Canvas.Line(R.Left + 1, ZeroY, R.Right - 1, ZeroY); Canvas.Line(R.Left + 1, ZeroY, R.Right - 1, ZeroY);
BuildResponse; // кривая считается заново на каждую отрисовку (1024 бина)
Canvas.Brush.Color := TColor($00304438); Canvas.Brush.Color := TColor($00304438);
Canvas.Pen.Color := TColor($00304438); Canvas.Pen.Color := TColor($00304438);
FillBottom := ZeroY; FillBottom := ZeroY;
@@ -537,7 +647,9 @@ begin
begin begin
F := XToFreq(GX, R); F := XToFreq(GX, R);
G := ResponseAtFreq(F); G := ResponseAtFreq(F);
GY := GainToY(EnsureRange(G, MIN_GAIN, MAX_GAIN), R); // Клампа по ±15 дБ нет намеренно: за крайними точками WDSP обрывает АЧХ,
// и кривая обязана уходить в пол графика — иначе обрыв не виден.
GY := GainToY(G, R);
if GX = R.Left then if GX = R.Left then
LastFillY := GY LastFillY := GY
else else
@@ -547,13 +659,13 @@ begin
end; end;
PrevX := CurveL; PrevX := CurveL;
PrevY := GainToY(EnsureRange(ResponseAtFreq(XToFreq(CurveL, R)), MIN_GAIN, MAX_GAIN), R); PrevY := GainToY(ResponseAtFreq(XToFreq(CurveL, R)), R);
Canvas.Pen.Color := FClrCurve; Canvas.Pen.Color := FClrCurve;
Canvas.Pen.Width := 2; Canvas.Pen.Width := 2;
for GX := CurveL + 1 to CurveR do for GX := CurveL + 1 to CurveR do
begin begin
F := XToFreq(GX, R); F := XToFreq(GX, R);
GY := GainToY(EnsureRange(ResponseAtFreq(F), MIN_GAIN, MAX_GAIN), R); GY := GainToY(ResponseAtFreq(F), R);
Canvas.Line(PrevX, PrevY, GX, GY); Canvas.Line(PrevX, PrevY, GX, GY);
PrevX := GX; PrevX := GX;
PrevY := GY; PrevY := GY;
@@ -621,7 +733,8 @@ begin
begin begin
FDragging := H; FDragging := H;
BandGain[H] := YToGain(Y, GraphRect); BandGain[H] := YToGain(Y, GraphRect);
BandFreq[H] := XToFreq(X, GraphRect); // В 3-полосном режиме частоты задаёт WDSP (EQ3_F) — двигаем только гейн.
if FBandCount <> 3 then BandFreq[H] := XToFreq(X, GraphRect);
end; end;
end; end;
end; end;
@@ -636,7 +749,7 @@ begin
else if FDragging > 0 then else if FDragging > 0 then
begin begin
BandGain[FDragging] := YToGain(Y, GraphRect); BandGain[FDragging] := YToGain(Y, GraphRect);
BandFreq[FDragging] := XToFreq(X, GraphRect); if FBandCount <> 3 then BandFreq[FDragging] := XToFreq(X, GraphRect);
end end
else else
begin begin
-1
View File
@@ -204,7 +204,6 @@ begin
Result.ClrBorder := ClrBorder; Result.ClrBorder := ClrBorder;
Result.ClrText := ClrText; Result.ClrText := ClrText;
Result.ClrTextAct := ClrTextAct; Result.ClrTextAct := ClrTextAct;
Result.Font.Name := 'Courier New';
Result.Font.Size := 8; Result.Font.Size := 8;
Result.Font.Color := ClrText; Result.Font.Color := ClrText;
end; end;
-1
View File
@@ -77,7 +77,6 @@ begin
TabStop := True; TabStop := True;
Cursor := crHandPoint; Cursor := crHandPoint;
Font.Name := 'Courier New';
Font.Size := 9; Font.Size := 9;
FClrBG := TColor($00181818); FClrBG := TColor($00181818);
-1
View File
@@ -272,7 +272,6 @@ begin
TabStop := True; TabStop := True;
Cursor := crDefault; Cursor := crDefault;
Font.Name := 'Courier New';
Font.Size := 9; Font.Size := 9;
FClrBG := TColor($001A1A1A); FClrBG := TColor($001A1A1A);
+35 -16
View File
@@ -18,7 +18,7 @@ unit FlatDropDown;
interface interface
uses uses
Classes, SysUtils, Controls, Graphics, ExtCtrls, FlatButton; Classes, SysUtils, Controls, Graphics, ExtCtrls, Math, FlatButton;
type type
TButtonStyleProc = procedure(B: TFlatButton; Active: Boolean) of object; TButtonStyleProc = procedure(B: TFlatButton; Active: Boolean) of object;
@@ -32,14 +32,17 @@ type
FItemBtns: array of TFlatButton; FItemBtns: array of TFlatButton;
FItemIndex: Integer; FItemIndex: Integer;
FColumns: Integer; FColumns: Integer;
FItemBtnH: Integer; FItemBtnH96: Integer;
FRightInset96: Integer;
FIsOpen: Boolean; FIsOpen: Boolean;
FStyleProc: TButtonStyleProc;
FPanelColor: TColor;
FOnSelect: TDropDownSelectEvent; FOnSelect: TDropDownSelectEvent;
procedure ItemClick(Sender: TObject); procedure ItemClick(Sender: TObject);
procedure PositionPopup; procedure PositionPopup;
public public
constructor Create(AAnchor: TFlatButton; APopupParent: TWinControl; constructor Create(AAnchor: TFlatButton; APopupParent: TWinControl;
ACols, ABtnH: Integer); ACols, ABtnH: Integer; ARightInset96: Integer = 0);
destructor Destroy; override; destructor Destroy; override;
procedure SetItems(const ANames: array of string); procedure SetItems(const ANames: array of string);
procedure SetItemIndex(Idx: Integer); procedure SetItemIndex(Idx: Integer);
@@ -54,19 +57,23 @@ type
implementation implementation
constructor TFlatDropDown.Create(AAnchor: TFlatButton; APopupParent: TWinControl; constructor TFlatDropDown.Create(AAnchor: TFlatButton; APopupParent: TWinControl;
ACols, ABtnH: Integer); ACols, ABtnH: Integer;
ARightInset96: Integer = 0);
begin begin
inherited Create; inherited Create;
FAnchor := AAnchor; FAnchor := AAnchor;
FPopupParent := APopupParent; FPopupParent := APopupParent;
FColumns := ACols; FColumns := Max(1, ACols);
FItemBtnH := ABtnH; FItemBtnH96 := Max(1, ABtnH);
FRightInset96 := Max(0, ARightInset96);
FItemIndex := 0; FItemIndex := 0;
FIsOpen := False; FIsOpen := False;
FStyleProc := nil;
FPanelColor := TColor($00181818);
FPopup := TPanel.Create(nil); FPopup := TPanel.Create(nil);
FPopup.Parent := APopupParent; FPopup.Parent := APopupParent;
FPopup.BevelOuter := bvNone; FPopup.BevelOuter := bvNone;
FPopup.Color := TColor($00181818); FPopup.Color := FPanelColor;
FPopup.Visible := False; FPopup.Visible := False;
end; end;
@@ -78,25 +85,34 @@ end;
procedure TFlatDropDown.SetItems(const ANames: array of string); procedure TFlatDropDown.SetItems(const ANames: array of string);
var var
I, Count, BtnW, PopW: Integer; I, Count, BtnW, PopW, ItemBtnH, EdgePad, ItemGap: Integer;
B: TFlatButton; B: TFlatButton;
begin begin
while FPopup.ControlCount > 0 do while FPopup.ControlCount > 0 do
FPopup.Controls[0].Free; FPopup.Controls[0].Free;
Count := Length(ANames); Count := Length(ANames);
SetLength(FItemBtns, Count); SetLength(FItemBtns, Count);
PopW := FPopupParent.ClientWidth; // Popup может жить в контейнере с постоянно зарезервированным overlay-
BtnW := (PopW - 4) div FColumns; // scrollbar gutter. Inset задаётся в 96-DPI координатах и пересчитывается
// при каждом наполнении: SetItems бывает и до, и после DPI-scale формы.
PopW := Max(1, FPopupParent.ClientWidth -
FPopupParent.Scale96ToForm(FRightInset96));
ItemBtnH := FPopupParent.Scale96ToForm(FItemBtnH96);
EdgePad := FPopupParent.Scale96ToForm(2);
ItemGap := FPopupParent.Scale96ToForm(2);
BtnW := Max(1, (PopW - 2 * EdgePad) div FColumns);
FPopup.SetBounds(0, 0, PopW, FPopup.SetBounds(0, 0, PopW,
((Count + FColumns - 1) div FColumns) * FItemBtnH); ((Count + FColumns - 1) div FColumns) * ItemBtnH);
for I := 0 to Count - 1 do for I := 0 to Count - 1 do
begin begin
B := MakeFlatBtn(FPopup, ANames[I], B := MakeFlatBtn(FPopup, ANames[I],
2 + (I mod FColumns) * BtnW, EdgePad + (I mod FColumns) * BtnW,
(I div FColumns) * FItemBtnH, (I div FColumns) * ItemBtnH,
BtnW - 2, FItemBtnH, @ItemClick); Max(1, BtnW - ItemGap), ItemBtnH, @ItemClick);
B.Tag := I; B.Tag := I;
B.Active := (I = FItemIndex); B.Active := (I = FItemIndex);
if Assigned(FStyleProc) then
FStyleProc(B, B.Active);
FItemBtns[I] := B; FItemBtns[I] := B;
end; end;
end; end;
@@ -134,9 +150,10 @@ var
begin begin
P := FAnchor.ClientToScreen(Point(0, FAnchor.Height)); P := FAnchor.ClientToScreen(Point(0, FAnchor.Height));
P := FPopupParent.ScreenToClient(P); P := FPopupParent.ScreenToClient(P);
Y0 := P.Y + 2; Y0 := P.Y + FPopupParent.Scale96ToForm(2);
if Y0 + FPopup.Height > FPopupParent.ClientHeight then if Y0 + FPopup.Height > FPopupParent.ClientHeight then
Y0 := P.Y - FAnchor.Height - FPopup.Height - 2; Y0 := P.Y - FAnchor.Height - FPopup.Height -
FPopupParent.Scale96ToForm(2);
if Y0 < 0 then Y0 := 0; if Y0 < 0 then Y0 := 0;
FPopup.Top := Y0; FPopup.Top := Y0;
FPopup.Left := 0; FPopup.Left := 0;
@@ -166,6 +183,8 @@ procedure TFlatDropDown.ApplyStyle(AStyleProc: TButtonStyleProc; APanelColor: TC
var var
I: Integer; I: Integer;
begin begin
FStyleProc := AStyleProc;
FPanelColor := APanelColor;
FPopup.Color := APanelColor; FPopup.Color := APanelColor;
for I := 0 to High(FItemBtns) do for I := 0 to High(FItemBtns) do
if FItemBtns[I] <> nil then if FItemBtns[I] <> nil then
-1
View File
@@ -112,7 +112,6 @@ begin
FEditing := False; FEditing := False;
FDragging := False; FDragging := False;
Font.Name := 'Courier New';
Font.Size := 9; Font.Size := 9;
FClrOuterBG := TColor($00181818); FClrOuterBG := TColor($00181818);
-1
View File
@@ -127,7 +127,6 @@ begin
TabStop := True; TabStop := True;
Cursor := crIBeam; Cursor := crIBeam;
Font.Name := 'Courier New';
Font.Size := 9; Font.Size := 9;
FMinValue := 0.0; FMinValue := 0.0;
-1
View File
@@ -93,7 +93,6 @@ begin
TabStop := True; TabStop := True;
Cursor := crDefault; Cursor := crDefault;
Font.Name := 'Courier New';
Font.Size := 8; Font.Size := 8;
FClrOuterBG := TColor($00181818); FClrOuterBG := TColor($00181818);
+279
View File
@@ -0,0 +1,279 @@
unit FlatMemo;
{
Read-only/editable memo с полностью управляемым оформлением и тонким
overlay-scrollbar. Внутри остаётся обычный TMemo, поэтому выделение,
клавиатура и копирование работают штатно на всех LCL widgetset.
}
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Math, Types,
Forms, Controls, Graphics, StdCtrls, ExtCtrls,
AppTheme, DpiUtils, OverlayScrollBar;
type
TFlatMemo = class(TCustomControl)
private
FMemo: TMemo;
FScrollHost: TPanel;
FScrollBar: TOverlayScrollBar;
FTheme: TAppTheme;
FSyncing: Boolean;
function GetLines: TStrings;
function GetReadOnly: Boolean;
procedure SetReadOnly(AValue: Boolean);
function GetWordWrap: Boolean;
procedure SetWordWrap(AValue: Boolean);
function GetSelStart: Integer;
procedure SetSelStart(AValue: Integer);
function GetText: string;
procedure SetText(const AValue: string);
procedure MemoChange(Sender: TObject);
procedure MemoKeyUp(Sender: TObject; var Key: Word; Shift: TShiftState);
procedure MemoMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure MemoMouseWheel(Sender: TObject; Shift: TShiftState;
WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
procedure ScrollChanged(Sender: TObject);
procedure UpdateScrollBar;
procedure LayoutChildren;
protected
procedure Paint; override;
procedure Resize; override;
procedure FontChanged(Sender: TObject); override;
public
constructor Create(AOwner: TComponent); override;
procedure Append(const AValue: string);
procedure SetAppTheme(const T: TAppTheme);
procedure SyncScrollBar;
property Lines: TStrings read GetLines;
property ReadOnly: Boolean read GetReadOnly write SetReadOnly;
property WordWrap: Boolean read GetWordWrap write SetWordWrap;
property SelStart: Integer read GetSelStart write SetSelStart;
property Text: string read GetText write SetText;
property Align;
property Anchors;
property BorderSpacing;
property Color;
property Font;
property Enabled;
property TabOrder;
property TabStop;
property Visible;
end;
implementation
constructor TFlatMemo.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
ControlStyle := ControlStyle + [csOpaque];
Width := DpiScale(300);
Height := DpiScale(160);
FTheme := DarkTheme;
Color := FTheme.BG;
TabStop := False;
FMemo := TMemo.Create(Self);
FMemo.Parent := Self;
FMemo.Align := alClient;
FMemo.BorderSpacing.Around := 1;
FMemo.BorderStyle := bsNone;
FMemo.ScrollBars := ssVertical;
FMemo.WordWrap := False;
FMemo.ParentFont := True;
FMemo.Color := FTheme.BG;
FMemo.Font.Color := FTheme.Text;
FMemo.OnChange := @MemoChange;
FMemo.OnKeyUp := @MemoKeyUp;
FMemo.OnMouseUp := @MemoMouseUp;
FMemo.OnMouseWheel := @MemoMouseWheel;
// TPaintBox сам не может перекрыть оконный TMemo, поэтому scrollbar живёт
// внутри отдельного оконного TPanel, поднятого над нативной полосой.
FScrollHost := TPanel.Create(Self);
FScrollHost.Parent := Self;
FScrollHost.BevelOuter := bvNone;
FScrollHost.Color := FTheme.BG;
FScrollHost.OnMouseWheel := @MemoMouseWheel;
FScrollBar := TOverlayScrollBar.Create(Self);
FScrollBar.Parent := FScrollHost;
FScrollBar.Align := alClient;
FScrollBar.HoverTarget := Self;
FScrollBar.OnMouseWheel := @MemoMouseWheel;
FScrollBar.OnPositionChange := @ScrollChanged;
LayoutChildren;
SetAppTheme(FTheme);
end;
procedure TFlatMemo.Paint;
begin
Canvas.Brush.Style := bsSolid;
Canvas.Brush.Color := FTheme.Border;
Canvas.FillRect(ClientRect);
end;
procedure TFlatMemo.Resize;
begin
inherited Resize;
LayoutChildren;
UpdateScrollBar;
end;
procedure TFlatMemo.FontChanged(Sender: TObject);
begin
inherited FontChanged(Sender);
if FMemo <> nil then
begin
FMemo.Font.Assign(Font);
UpdateScrollBar;
end;
end;
procedure TFlatMemo.LayoutChildren;
var
GutterW: Integer;
begin
if (FMemo = nil) or (FScrollHost = nil) then Exit;
GutterW := Max(DpiScale(8), FMemo.VertScrollBar.Size);
FScrollHost.SetBounds(Max(1, ClientWidth - GutterW - 1), 1,
GutterW, Max(0, ClientHeight - 2));
FScrollHost.BringToFront;
end;
procedure TFlatMemo.UpdateScrollBar;
var
MaxPos, Viewport: Integer;
begin
if FSyncing or (FMemo = nil) or (FScrollBar = nil) then Exit;
FSyncing := True;
try
MaxPos := Max(0, FMemo.VertScrollBar.Range - FMemo.VertScrollBar.Page);
Viewport := Max(1, FMemo.VertScrollBar.Page);
FScrollBar.SetRange(MaxPos, Viewport);
FScrollBar.Position := EnsureRange(FMemo.VertScrollBar.Position, 0, MaxPos);
LayoutChildren;
finally
FSyncing := False;
end;
end;
procedure TFlatMemo.SyncScrollBar;
begin
UpdateScrollBar;
end;
procedure TFlatMemo.ScrollChanged(Sender: TObject);
begin
if FSyncing or (FMemo = nil) or (FScrollBar = nil) then Exit;
FMemo.VertScrollBar.Position := FScrollBar.Position;
end;
procedure TFlatMemo.MemoMouseWheel(Sender: TObject; Shift: TShiftState;
WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
var
Step: Integer;
begin
UpdateScrollBar;
if (FScrollBar = nil) or (FScrollBar.Maximum <= 0) then Exit;
Step := Max(1, FMemo.VertScrollBar.Increment * 3);
if WheelDelta > 0 then FScrollBar.ScrollBy(-Step)
else if WheelDelta < 0 then FScrollBar.ScrollBy(Step);
Handled := WheelDelta <> 0;
end;
procedure TFlatMemo.MemoChange(Sender: TObject);
begin
UpdateScrollBar;
end;
procedure TFlatMemo.MemoKeyUp(Sender: TObject; var Key: Word;
Shift: TShiftState);
begin
UpdateScrollBar;
end;
procedure TFlatMemo.MemoMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
UpdateScrollBar;
end;
function TFlatMemo.GetLines: TStrings;
begin
Result := FMemo.Lines;
end;
function TFlatMemo.GetReadOnly: Boolean;
begin
Result := FMemo.ReadOnly;
end;
procedure TFlatMemo.SetReadOnly(AValue: Boolean);
begin
FMemo.ReadOnly := AValue;
end;
function TFlatMemo.GetWordWrap: Boolean;
begin
Result := FMemo.WordWrap;
end;
procedure TFlatMemo.SetWordWrap(AValue: Boolean);
begin
FMemo.WordWrap := AValue;
UpdateScrollBar;
end;
function TFlatMemo.GetSelStart: Integer;
begin
Result := FMemo.SelStart;
end;
procedure TFlatMemo.SetSelStart(AValue: Integer);
begin
FMemo.SelStart := AValue;
UpdateScrollBar;
end;
function TFlatMemo.GetText: string;
begin
Result := FMemo.Text;
end;
procedure TFlatMemo.SetText(const AValue: string);
begin
FMemo.Text := AValue;
UpdateScrollBar;
end;
procedure TFlatMemo.Append(const AValue: string);
begin
FMemo.Append(AValue);
UpdateScrollBar;
end;
procedure TFlatMemo.SetAppTheme(const T: TAppTheme);
begin
FTheme := T;
Color := T.BG;
if FMemo <> nil then
begin
FMemo.Color := T.BG;
FMemo.Font.Color := T.Text;
end;
if FScrollHost <> nil then FScrollHost.Color := T.BG;
if FScrollBar <> nil then
FScrollBar.SetColors(T.BG, T.Border,
T.SliderThumbHot, T.SliderThumbDrag);
Invalidate;
end;
end.
-1
View File
@@ -78,7 +78,6 @@ begin
TabStop := True; TabStop := True;
Cursor := crHandPoint; Cursor := crHandPoint;
Font.Name := 'Courier New';
Font.Size := 9; Font.Size := 9;
FClrBG := TColor($00181818); FClrBG := TColor($00181818);
-1
View File
@@ -121,7 +121,6 @@ begin
TabStop := True; TabStop := True;
Cursor := crIBeam; Cursor := crIBeam;
Font.Name := 'Courier New';
Font.Size := 9; Font.Size := 9;
FMinValue := 0; FMinValue := 0;
-1
View File
@@ -7,7 +7,6 @@ object MainForm: TMainForm
Color = clBlack Color = clBlack
Font.Color = clSilver Font.Color = clSilver
Font.Height = -13 Font.Height = -13
Font.Name = 'Courier New'
Position = poDesigned Position = poDesigned
LCLVersion = '4.6.0.0' LCLVersion = '4.6.0.0'
OnClose = FormClose OnClose = FormClose
+565 -136
View File
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
unit OverlayScrollBar;
{
Тонкий вертикальный overlay-scrollbar без системного оформления.
Сам контрол всегда занимает зарезервированный gutter, но трек и бегунок
рисуются только при Max > 0 и наведении мыши на HoverTarget. Содержимое
контрол не двигает: владелец подписывается на OnPositionChange.
}
{$mode objfpc}{$H+}
interface
uses
Classes, Controls, ExtCtrls, Forms, Graphics, Math, Types, LCLType;
type
TOverlayScrollBar = class(TPaintBox)
private
FHoverTarget: TControl;
FMaximum: Integer;
FPosition: Integer;
FViewportSize: Integer;
FHover: Boolean;
FDragging: Boolean;
FDragY: Integer;
FDragPosition: Integer;
FBGColor: TColor;
FTrackColor: TColor;
FThumbColor: TColor;
FThumbDragColor: TColor;
FOnPositionChange: TNotifyEvent;
procedure AppUserInput(Sender: TObject; Msg: Cardinal);
procedure SetPosition(AValue: Integer);
function CursorOverTarget: Boolean;
function ThumbRect: TRect;
protected
procedure Paint; override;
procedure MouseDown(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer); override;
procedure MouseMove(Shift: TShiftState; X, Y: Integer); override;
procedure MouseUp(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer); override;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure SetRange(AMaximum, AViewportSize: Integer);
procedure ScrollBy(ADelta: Integer);
procedure SetColors(ABG, ATrack, AThumb, AThumbDrag: TColor);
property HoverTarget: TControl read FHoverTarget write FHoverTarget;
property Maximum: Integer read FMaximum;
property Position: Integer read FPosition write SetPosition;
property OnPositionChange: TNotifyEvent
read FOnPositionChange write FOnPositionChange;
end;
implementation
constructor TOverlayScrollBar.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
Width := 8;
FMaximum := 0;
FPosition := 0;
FViewportSize := 0;
FHover := False;
FDragging := False;
FBGColor := TColor($00181818);
FTrackColor := TColor($00303030);
FThumbColor := TColor($00407040);
FThumbDragColor := TColor($00509050);
Application.AddOnUserInputHandler(@AppUserInput);
end;
destructor TOverlayScrollBar.Destroy;
begin
Application.RemoveOnUserInputHandler(@AppUserInput);
if GetCaptureControl = Self then SetCaptureControl(nil);
inherited Destroy;
end;
procedure TOverlayScrollBar.SetColors(ABG, ATrack, AThumb,
AThumbDrag: TColor);
begin
FBGColor := ABG;
FTrackColor := ATrack;
FThumbColor := AThumb;
FThumbDragColor := AThumbDrag;
Invalidate;
end;
procedure TOverlayScrollBar.SetRange(AMaximum, AViewportSize: Integer);
var
OldMaximum: Integer;
begin
OldMaximum := FMaximum;
FMaximum := Max(0, AMaximum);
FViewportSize := Max(0, AViewportSize);
if FPosition > FMaximum then
SetPosition(FMaximum)
else if OldMaximum <> FMaximum then
Invalidate;
end;
procedure TOverlayScrollBar.SetPosition(AValue: Integer);
var
NewValue: Integer;
begin
NewValue := EnsureRange(AValue, 0, FMaximum);
if NewValue = FPosition then Exit;
FPosition := NewValue;
if Assigned(FOnPositionChange) then FOnPositionChange(Self);
Invalidate;
end;
procedure TOverlayScrollBar.ScrollBy(ADelta: Integer);
begin
SetPosition(FPosition + ADelta);
end;
function TOverlayScrollBar.CursorOverTarget: Boolean;
var
P: TPoint;
begin
Result := False;
if (FHoverTarget = nil) or (not FHoverTarget.Visible) then Exit;
P := FHoverTarget.ScreenToClient(Mouse.CursorPos);
Result := (P.X >= 0) and (P.Y >= 0) and
(P.X < FHoverTarget.Width) and (P.Y < FHoverTarget.Height);
end;
procedure TOverlayScrollBar.AppUserInput(Sender: TObject; Msg: Cardinal);
var
NewHover: Boolean;
begin
NewHover := CursorOverTarget;
if NewHover = FHover then Exit;
FHover := NewHover;
Invalidate;
end;
function TOverlayScrollBar.ThumbRect: TRect;
var
TrackTop, TrackH, ThumbH, Travel, ContentH: Integer;
begin
Result := Rect(0, 0, 0, 0);
if (FMaximum <= 0) or (FViewportSize <= 0) then Exit;
TrackTop := Scale96ToForm(4);
TrackH := Height - 2 * TrackTop;
if TrackH <= 0 then Exit;
ContentH := FViewportSize + FMaximum;
ThumbH := Max(Scale96ToForm(28),
MulDiv(TrackH, FViewportSize, Max(1, ContentH)));
ThumbH := Min(ThumbH, TrackH);
Travel := TrackH - ThumbH;
Result.Left := Max(1, (Width - Scale96ToForm(4)) div 2);
Result.Right := Width - Result.Left;
Result.Top := TrackTop;
if (Travel > 0) and (FMaximum > 0) then
Inc(Result.Top, MulDiv(Travel, FPosition, FMaximum));
Result.Bottom := Result.Top + ThumbH;
end;
procedure TOverlayScrollBar.Paint;
var
R: TRect;
TrackX: Integer;
begin
Canvas.Brush.Style := bsSolid;
Canvas.Brush.Color := FBGColor;
Canvas.FillRect(ClientRect);
if (FMaximum <= 0) or ((not FHover) and (not FDragging)) then Exit;
TrackX := Width div 2;
Canvas.Pen.Color := FTrackColor;
Canvas.Pen.Width := Max(1, Scale96ToForm(1));
Canvas.Line(TrackX, Scale96ToForm(4),
TrackX, Height - Scale96ToForm(4));
R := ThumbRect;
if IsRectEmpty(R) then Exit;
if FDragging then Canvas.Brush.Color := FThumbDragColor
else Canvas.Brush.Color := FThumbColor;
Canvas.Pen.Style := psClear;
Canvas.RoundRect(R.Left, R.Top, R.Right, R.Bottom,
Scale96ToForm(3), Scale96ToForm(3));
Canvas.Pen.Style := psSolid;
end;
procedure TOverlayScrollBar.MouseDown(Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var
R: TRect;
begin
inherited MouseDown(Button, Shift, X, Y);
if (Button <> mbLeft) or (FMaximum <= 0) then Exit;
R := ThumbRect;
if PtInRect(R, Point(X, Y)) then
begin
FDragging := True;
FDragY := Y;
FDragPosition := FPosition;
SetCaptureControl(Self);
end
else if Y < R.Top then
ScrollBy(-FViewportSize + Scale96ToForm(40))
else
ScrollBy(FViewportSize - Scale96ToForm(40));
Invalidate;
end;
procedure TOverlayScrollBar.MouseMove(Shift: TShiftState; X, Y: Integer);
var
R: TRect;
TrackH, Travel: Integer;
begin
inherited MouseMove(Shift, X, Y);
if not FDragging then Exit;
R := ThumbRect;
TrackH := Height - 2 * Scale96ToForm(4);
Travel := TrackH - (R.Bottom - R.Top);
if Travel <= 0 then Exit;
SetPosition(FDragPosition + MulDiv(Y - FDragY, FMaximum, Travel));
end;
procedure TOverlayScrollBar.MouseUp(Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
inherited MouseUp(Button, Shift, X, Y);
if Button <> mbLeft then Exit;
FDragging := False;
if GetCaptureControl = Self then SetCaptureControl(nil);
Invalidate;
end;
end.
+32
View File
@@ -286,6 +286,8 @@ type
procedure PushAllSliceFlagStates; // все флаги панели (после смены rate и т.п.) procedure PushAllSliceFlagStates; // все флаги панели (после смены rate и т.п.)
procedure PushMainFlagExtState; procedure PushMainFlagExtState;
procedure PushMainFlagAudioDevices; procedure PushMainFlagAudioDevices;
procedure PushFlagTXProfiles(O: TVfoOverlay; SliceId: Integer);
procedure PushAllFlagTXProfiles;
procedure UpdateSliceDMRFlags; procedure UpdateSliceDMRFlags;
// Тик: S-метр/частота главного флага + позиции + троттленный S-метр слайсов. // Тик: S-метр/частота главного флага + позиции + троттленный S-метр слайсов.
procedure TickFlags; procedure TickFlags;
@@ -1321,6 +1323,23 @@ begin
end; end;
end; end;
// Список TX-профилей (общий) + привязка ЭТОГО флага (SliceId 0 = главный).
procedure TPanafallPanel.PushFlagTXProfiles(O: TVfoOverlay; SliceId: Integer);
var
L: TStringList;
i: Integer;
begin
if (O = nil) or (FController = nil) then Exit;
L := TStringList.Create;
try
for i := 0 to FController.TXProfileCount - 1 do
L.Add(FController.TXProfileName(i));
O.SetTXProfiles(L, FController.SliceTXProfile(SliceId));
finally
L.Free;
end;
end;
// Заливает во флаг слайса его текущее состояние из контроллера. // Заливает во флаг слайса его текущее состояние из контроллера.
procedure TPanafallPanel.PushSliceFlagState(SliceId: Integer); procedure TPanafallPanel.PushSliceFlagState(SliceId: Integer);
var var
@@ -1352,6 +1371,7 @@ begin
// Auto TX (настройка CAT-порта слайса) — бейдж TX подписан AutoTX. // Auto TX (настройка CAT-порта слайса) — бейдж TX подписан AutoTX.
O.SetAutoTxState(FController.SliceAutoTx(SliceId)); O.SetAutoTxState(FController.SliceAutoTx(SliceId));
O.SetRxMuteTxState(S.RxMuteOnTx); // бейдж RXM O.SetRxMuteTxState(S.RxMuteOnTx); // бейдж RXM
PushFlagTXProfiles(O, SliceId); // пилюля TX-профиля слайса
// Список аудио-устройств (общий PA-список) + текущее устройство слайса. // Список аудио-устройств (общий PA-список) + текущее устройство слайса.
if Assigned(FController.FAudioOut) then if Assigned(FController.FAudioOut) then
begin begin
@@ -1399,6 +1419,18 @@ begin
FController.FSplitTxB and (FController.FTxSliceId = 0), FController.FSplitTxB and (FController.FTxSliceId = 0),
FController.FTransmitting and (FController.FTxSliceId = 0), FController.FTransmitting and (FController.FTxSliceId = 0),
FController.FTxSliceId = 0); FController.FTxSliceId = 0);
PushFlagTXProfiles(FMainFlag, 0);
end;
// Обновить пилюли TX-профиля на всех флагах панели (список профилей общий:
// переименовали/удалили — видно везде).
procedure TPanafallPanel.PushAllFlagTXProfiles;
var i: Integer;
begin
if Assigned(FMainFlag) then PushFlagTXProfiles(FMainFlag, 0);
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then
PushFlagTXProfiles(FSliceFlags[i], FSliceFlags[i].SliceId);
end; end;
procedure TPanafallPanel.PushMainFlagAudioDevices; procedure TPanafallPanel.PushMainFlagAudioDevices;
+368 -3
View File
@@ -80,6 +80,7 @@ type
rfFMDeviation, rfFMCTCSS, rfFMCTCSSTone, rfFMSQ, rfFMSQLevel, rfFMDeviation, rfFMCTCSS, rfFMCTCSSTone, rfFMSQ, rfFMSQLevel,
rfFMStep, rfFMStepIdx, rfFMRpt, rfFMStep, rfFMStepIdx, rfFMRpt,
rfChannel, rfChannel,
rfTXProfile, // сменился активный TX-профиль
rfSMeter, rfPower, rfSWR, rfSupply, rfPLL, // телеметрия rfSMeter, rfPower, rfSWR, rfSupply, rfPLL, // телеметрия
rfADCOverload, // ADC overload (рендер индикатора) rfADCOverload, // ADC overload (рендер индикатора)
rfBeaconLock, // QO-100 beacon lock: статус/коррекция rfBeaconLock, // QO-100 beacon lock: статус/коррекция
@@ -177,6 +178,10 @@ type
InDeviceIndex: Integer; // PortAudio-индекс ввода (-1 = общий) InDeviceIndex: Integer; // PortAudio-индекс ввода (-1 = общий)
InDevName: string; InDevName: string;
AudioIn: TAudioInput; // НЕ владеет: ссылка на поток из mic-пула AudioIn: TAudioInput; // НЕ владеет: ссылка на поток из mic-пула
// TX-профиль слайса ПО РЕЖИМУ: индекс в FTXProfiles или -1 = «не
// переключать». Применяется, когда слайс становится TX-источником
// (SetTxSlice) и когда он меняет режим, будучи источником.
TXProfByMode: TTXProfModeTable;
DMR: TDMRSliceDecoder; // владеет; nil для не-DMR слайса DMR: TDMRSliceDecoder; // владеет; nil для не-DMR слайса
end; end;
@@ -424,6 +429,10 @@ type
// ---- Конфиги / устройство (per-MAC) ---- // ---- Конфиги / устройство (per-MAC) ----
FTXSettings: TTXSettings; FTXSettings: TTXSettings;
// Именованные TX-профили («звуковая» часть FTXSettings + drive, см.
// TTXProfile). Истина для эфира — всегда живые FTXSettings/FDrivePercent;
// активный профиль ведётся за ними (StoreActiveTXProfile).
FTXProfiles: TTXProfileList;
FAlexSettings: TAlexSettings; FAlexSettings: TAlexSettings;
FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only) FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only)
// Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер. // Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер.
@@ -577,6 +586,36 @@ type
procedure ApplyTXSettingsToDSP; // TX-цепь (фильтр/gain/EQ/comp/...) → WDSP procedure ApplyTXSettingsToDSP; // TX-цепь (фильтр/gain/EQ/comp/...) → WDSP
function PSOutlierSigmaEff: Double; // σ outlier-фильтра calcc (авто по плате) function PSOutlierSigmaEff: Double; // σ outlier-фильтра calcc (авто по плате)
// ---- TX-профили --------------------------------------------------------
// Профиль — именованный снимок «звуковой» части TX-цепи (состав в
// TTXProfile). Отдельной кнопки «сохранить» нет: правки ручками пишутся в
// активный профиль сразу (StoreActiveTXProfile), как и всё остальное в
// EWSDR. Переключение применяет снимок в эфир немедленно.
function TXProfileCount: Integer;
function TXProfileName(Idx: Integer): string;
function ActiveTXProfile: Integer;
// Auto=True — переключение сделала автоматика привязок; ручной выбор
// (дропдаун, список настроек) дополнительно ЗАПОМИНАЕТСЯ за текущей парой
// «источник + модуляция» (BindTXProfileToCurrentMode).
procedure SelectTXProfile(Idx: Integer; Auto: Boolean = False);
procedure StoreActiveTXProfile(Persist: Boolean = False);
function AddTXProfile(const AName: string): Integer; // форк текущего звука
procedure RenameTXProfile(Idx: Integer; const AName: string);
procedure DeleteTXProfile(Idx: Integer);
procedure ResetTXProfilesToFactory; // заводской набор заново
// Профиль на TX-источник, ключ — режим этого флага (-1 = не переключать,
// звучит активный). Таблица у каждого флага своя: главный в FTXProfiles,
// слайсы в своих записях.
function FlagMode(SliceId: Integer): Integer;
procedure BindTXProfileToCurrentMode(Idx: Integer); // ручной выбор → в таблицу
function SliceTXProfile(SliceId: Integer): Integer;
procedure SetSliceTXProfile(SliceId, ProfIdx: Integer);
function SliceTXProfileTable(SliceId: Integer): TTXProfModeTable;
procedure SetSliceTXProfileTable(SliceId: Integer; const T: TTXProfModeTable);
procedure RemapTXProfileBindings(Deleted: Integer); // после удаления/замены списка
procedure ApplyBoundTXProfile; // профиль текущего TX-источника → в эфир
procedure SaveTXProfilesToConfig;
// ---- Управление диапазонами (состояние + DSP; рендер через события) ---- // ---- Управление диапазонами (состояние + DSP; рендер через события) ----
// MakeBandSettings — снимок текущего состояния в запись диапазона. // MakeBandSettings — снимок текущего состояния в запись диапазона.
// SaveCurrentBand — сохраняет текущее состояние в кэш+конфиг (или в XVTR-слот). // SaveCurrentBand — сохраняет текущее состояние в кэш+конфиг (или в XVTR-слот).
@@ -2127,6 +2166,7 @@ begin
FSlices[slot].RxMuteOnTx := True; // себя на передаче по умолчанию не слышим FSlices[slot].RxMuteOnTx := True; // себя на передаче по умолчанию не слышим
FSlices[slot].FMSQOn := False; // squelch по умолчанию выключен FSlices[slot].FMSQOn := False; // squelch по умолчанию выключен
FSlices[slot].FMSQLevel := 40; FSlices[slot].FMSQLevel := 40;
ClearTXProfModeTable(FSlices[slot].TXProfByMode); // звучит активный профиль
FSlices[slot].NRMode := 0; // DSP слайса стартует выключенным FSlices[slot].NRMode := 0; // DSP слайса стартует выключенным
FSlices[slot].NBMode := 0; FSlices[slot].NBMode := 0;
FSlices[slot].SNB := False; FSlices[slot].SNB := False;
@@ -2201,6 +2241,8 @@ begin
// Этот слайс — источник передачи: передающий тракт идёт за его режимом // Этот слайс — источник передачи: передающий тракт идёт за его режимом
// (иначе после смены режима в эфир ушёл бы прежний вид модуляции). Вход FM // (иначе после смены режима в эфир ушёл бы прежний вид модуляции). Вход FM
// RAW — внешний PCM, поэтому заодно пересматриваем источник модуляции. // RAW — внешний PCM, поэтому заодно пересматриваем источник модуляции.
// Профиль этого слайса в НОВОМ режиме — до пуша цепи (см. SetMode).
if (FTxSliceId = Id) and (Mode <> OldMode) then ApplyBoundTXProfile;
if (FTxSliceId = Id) and (Mode <> OldMode) and if (FTxSliceId = Id) and (Mode <> OldMode) and
FWDSPReady and Assigned(FDSPEngine) then FWDSPReady and Assigned(FDSPEngine) then
begin begin
@@ -2919,6 +2961,9 @@ begin
else if FTransmitting then SetMOX(False); else if FTransmitting then SetMOX(False);
end; end;
FTxSliceId := SliceId; FTxSliceId := SliceId;
// Профиль, привязанный к новому источнику, — ДО пуша режима и цепи: иначе
// ApplyTXSettingsToDSP внутри SelectTXProfile лёг бы на старый режим.
ApplyBoundTXProfile;
// Передающий тракт под режим нового источника (idle-канал, применится к PTT). // Передающий тракт под режим нового источника (idle-канал, применится к PTT).
if FWDSPReady and Assigned(FDSPEngine) then if FWDSPReady and Assigned(FDSPEngine) then
begin begin
@@ -3340,10 +3385,15 @@ begin
if FTuning then if FTuning then
begin begin
if XvtrActive and FXvtrSettings.UseXVTRTunePower then // Уровень настройки всегда идёт от глобального TUN Level — иначе ручка в
Pos := EnsureRange(FXvtrSettings.Entries[FCurrentXvtr].TXPower, 0, 100) // Transmit в трансвертере оказывалась мёртвой. Мощность слота (если
else // включена галка) работает МНОЖИТЕЛЕМ, ровно как для обычного drive ниже:
// это «сколько мощности трансиверa разрешено этому трансвертеру», и к
// настройке оно относится так же, как к передаче.
Pos := EnsureRange(FTXSettings.TUNLevel, 0, 100); Pos := EnsureRange(FTXSettings.TUNLevel, 0, 100);
if XvtrActive and FXvtrSettings.UseXVTRTunePower then
Pos := Round(Pos * EnsureRange(FXvtrSettings.Entries[FCurrentXvtr].TXPower,
0, 100) / 100.0);
end end
else else
begin begin
@@ -3590,6 +3640,16 @@ begin
FFMRptOffsetHz := B.FMRptOffsetHz; FFMRptOffsetHz := B.FMRptOffsetHz;
if FFMRptOffsetHz <= 0 then FFMRptOffsetHz := RptDefaultOffsetHz(B.VfoA); if FFMRptOffsetHz <= 0 then FFMRptOffsetHz := RptDefaultOffsetHz(B.VfoA);
// --- TX-профиль под новый режим ---
// Смена диапазона и вход/выход трансвертера меняют режим ГЛАВНОГО здесь, а
// не через SetMode, поэтому автоматику привязок надо звать и отсюда — иначе
// при переходе КВ↔трансвертер оставался профиль прошлого режима.
if FTxSliceId = 0 then ApplyBoundTXProfile;
// FDSPEngine.SetMode выше насильно выставил TXA в режим главного приёмника,
// но передавать можем со слайса — возвращаем режим настоящего источника.
if FWDSPReady and Assigned(FDSPEngine) and (ActiveTXMode <> FMode) then
FDSPEngine.SetTXMode(ActiveTXMode);
// --- CTUN --- // --- CTUN ---
FCTun := B.CTun; FCTun := B.CTun;
if FWDSPReady and Assigned(FDSPEngine) and not FCTun then FDSPEngine.SetShift(0.0); if FWDSPReady and Assigned(FDSPEngine) and not FCTun then FDSPEngine.SetShift(0.0);
@@ -3963,6 +4023,10 @@ begin
if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR); if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR);
ApplyModeDefaults; // дефолтный фильтр/девиация под режим ApplyModeDefaults; // дефолтный фильтр/девиация под режим
FBandCache[FCurrentBand].Mode := FMode; FBandCache[FCurrentBand].Mode := FMode;
// Профиль привязан к паре «флаг + модуляция»: если передаём с главного,
// новый режим приносит свой профиль. До пуша в WDSP — ApplyTXChainSettings
// внутри SetTXMode ниже перешьёт цепь уже с новыми значениями.
if FTxSliceId = 0 then ApplyBoundTXProfile;
if FWDSPReady and Assigned(FDSPEngine) then if FWDSPReady and Assigned(FDSPEngine) then
begin begin
FDSPEngine.SetMode(FMode); FDSPEngine.SetMode(FMode);
@@ -4222,6 +4286,10 @@ procedure TRadioController.SetDrive(V: Integer);
begin begin
if V < 0 then V := 0 else if V > 100 then V := 100; if V < 0 then V := 0 else if V > 100 then V := 100;
FDrivePercent := V; FDrivePercent := V;
// Мощность входит в профиль: ведём её в активном без записи на диск (слайдер
// шлёт события пачками; на диск профили уходят на переключении/остановке).
if (FTXProfiles.ActiveIdx >= 0) and (FTXProfiles.ActiveIdx < FTXProfiles.Count) then
FTXProfiles.Items[FTXProfiles.ActiveIdx].DrivePercent := FDrivePercent;
FDriveLevel := CalcDriveByte; FDriveLevel := CalcDriveByte;
ApplyWDSPDrive(FDrivePercent); ApplyWDSPDrive(FDrivePercent);
ApplyPlutoTxAtten; // Pluto: drive живьём меняет TX-аттенюацию во время передачи ApplyPlutoTxAtten; // Pluto: drive живьём меняет TX-аттенюацию во время передачи
@@ -4904,6 +4972,293 @@ begin
FNetwork.SendDUCSpecific(DUCPkt); FNetwork.SendDUCSpecific(DUCPkt);
end; end;
// ---------------------------------------------------------------------------
// TX-профили
// ---------------------------------------------------------------------------
function TRadioController.TXProfileCount: Integer;
begin
Result := FTXProfiles.Count;
end;
function TRadioController.TXProfileName(Idx: Integer): string;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Result := ''
else Result := FTXProfiles.Items[Idx].Name;
end;
function TRadioController.ActiveTXProfile: Integer;
begin
Result := FTXProfiles.ActiveIdx;
end;
procedure TRadioController.SaveTXProfilesToConfig;
begin
if (not FDevConnected) or (not Assigned(FSettings)) then Exit;
FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
FSettings.Save;
end;
procedure TRadioController.StoreActiveTXProfile(Persist: Boolean = False);
// Живое состояние → активный профиль. Зовётся на каждой правке TX-настроек и
// на смене drive; Persist=False не трогает диск (drive ездит слайдером).
var Idx: Integer;
begin
Idx := FTXProfiles.ActiveIdx;
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
TXProfileCapture(FTXSettings, FDrivePercent, FTXProfiles.Items[Idx]);
if Persist then SaveTXProfilesToConfig;
end;
procedure TRadioController.SelectTXProfile(Idx: Integer; Auto: Boolean);
var
OldMic: Byte;
NewDrive: Integer;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
// Уходящий профиль дописываем живыми значениями — ручные правки не теряются.
StoreActiveTXProfile;
OldMic := BuildMicLineSelectByte;
NewDrive := FDrivePercent;
FTXProfiles.ActiveIdx := Idx;
TXProfileApply(FTXProfiles.Items[Idx], FTXSettings, NewDrive);
ApplyTXSettingsToDSP;
// Маршрутизация микрофона живёт в железе (DUC Specific byte 50) — при смене
// Mic In/Line In/boost/bias пакет надо перепослать, WDSP тут ни при чём.
if BuildMicLineSelectByte <> OldMic then SendDUCSpecificFromSettings;
// SetDrive пересчитает калиброванный байт, толкнёт WDSP/Pluto и HP-кадр;
// зовём безусловно — при активном TUN уровень берётся из TUNLevel профиля.
SetDrive(NewDrive);
if FTuning and FWDSPReady then
FDSPEngine.SetDriveLevel(EnsureRange(FTXSettings.TUNLevel, 0, 100) / 100.0);
// ★Ручной выбор ЗАПОМИНАЕТСЯ за парой «источник передачи + его модуляция» —
// это и есть обещанное «профиль помнится по виду модуляции». Без этого в
// таблице оказывалось только то, что назначено пилюлей во флаге, а профиль,
// выбранный дропдауном в блоке TX, нигде не оставался следа.
if not Auto then BindTXProfileToCurrentMode(Idx);
if FDevConnected and Assigned(FSettings) then
begin
FSettings.SaveTX(FDevMAC, FTXSettings);
FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
FSettings.Save;
end;
Changed(rfTXProfile);
end;
function TRadioController.AddTXProfile(const AName: string): Integer;
// Форк: новый профиль = текущий звук под новым именем, он же становится активным.
begin
Result := -1;
if FTXProfiles.Count >= TXPROF_MAX then Exit;
Result := FTXProfiles.Count;
TXProfileCapture(FTXSettings, FDrivePercent, FTXProfiles.Items[Result]);
FTXProfiles.Items[Result].Name := Copy(Trim(AName), 1, 31);
if FTXProfiles.Items[Result].Name = '' then
FTXProfiles.Items[Result].Name := 'Profile ' + IntToStr(Result + 1);
Inc(FTXProfiles.Count);
FTXProfiles.ActiveIdx := Result;
SaveTXProfilesToConfig;
Changed(rfTXProfile);
end;
procedure TRadioController.RenameTXProfile(Idx: Integer; const AName: string);
var S: string;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
S := Copy(Trim(AName), 1, 31);
if S = '' then Exit;
FTXProfiles.Items[Idx].Name := S;
SaveTXProfilesToConfig;
Changed(rfTXProfile);
end;
procedure TRadioController.DeleteTXProfile(Idx: Integer);
// Последний профиль не удаляем: список без активного элемента сломал бы
// авто-сохранение (StoreActiveTXProfile писать некуда).
var i: Integer;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) or (FTXProfiles.Count <= 1) then Exit;
for i := Idx to FTXProfiles.Count - 2 do
FTXProfiles.Items[i] := FTXProfiles.Items[i + 1];
Dec(FTXProfiles.Count);
// Привязки флагов — это ИНДЕКСЫ: после сдвига списка их надо переписать,
// иначе слайс молча начнёт включать соседний профиль.
RemapTXProfileBindings(Idx);
if FTXProfiles.ActiveIdx > Idx then Dec(FTXProfiles.ActiveIdx)
else if FTXProfiles.ActiveIdx = Idx then
begin
// Удалили активный: встаём на соседний и применяем его в эфир. ActiveIdx
// гасим — иначе StoreActiveTXProfile внутри Select затрёт соседа живым
// звуком удалённого профиля.
FTXProfiles.ActiveIdx := -1;
SelectTXProfile(EnsureRange(Idx, 0, FTXProfiles.Count - 1));
Exit; // SelectTXProfile уже сохранил и отрендерил
end;
SaveTXProfilesToConfig;
Changed(rfTXProfile);
end;
procedure TRadioController.RemapTXProfileBindings(Deleted: Integer);
// Профиль удалён из середины списка: привязку на него снимаем, привязки на
// профили правее — сдвигаем. Deleted < 0 = список заменён целиком, снимаем все.
procedure Fix(var B: Integer);
begin
if B < 0 then Exit;
if Deleted < 0 then B := -1
else if B = Deleted then B := -1
else if B > Deleted then Dec(B);
end;
var i, m: Integer;
begin
for m := 0 to CFG_MODE_MAX do Fix(FTXProfiles.MainByMode[m]);
for i := 0 to High(FSlices) do
if FSlices[i].Active then
for m := 0 to CFG_MODE_MAX do Fix(FSlices[i].TXProfByMode[m]);
end;
procedure TRadioController.BindTXProfileToCurrentMode(Idx: Integer);
// Пишет профиль в ячейку «текущий TX-источник + его модуляция». Отдельно от
// SetSliceTXProfile: там ещё и применение в эфир, а здесь профиль уже применён
// (нас зовут из SelectTXProfile), повторный ApplyBoundTXProfile был бы циклом.
var m, Slot: Integer;
begin
if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit;
m := FlagMode(FTxSliceId);
if FTxSliceId <= 0 then
FTXProfiles.MainByMode[m] := Idx // персист — общим блоком в SelectTXProfile
else
begin
Slot := FindSliceIndex(FTxSliceId);
if Slot >= 0 then FSlices[Slot].TXProfByMode[m] := Idx;
end;
end;
function TRadioController.FlagMode(SliceId: Integer): Integer;
// Режим, в котором сейчас стоит этот флаг: у главного — FMode, у слайса — свой.
var idx: Integer;
begin
Result := FMode;
if SliceId > 0 then
begin
idx := FindSliceIndex(SliceId);
if idx >= 0 then Result := FSlices[idx].Mode;
end;
Result := EnsureRange(Result, 0, CFG_MODE_MAX);
end;
function TRadioController.SliceTXProfile(SliceId: Integer): Integer;
// Привязка «этот источник передачи В ЭТОМ РЕЖИМЕ → этот профиль».
// -1 = не переключать. Ключ — режим самого флага, поэтому смена модуляции
// сама достаёт профиль, с которым в этом виде работали в прошлый раз.
var idx: Integer;
begin
Result := -1;
if SliceId <= 0 then
Result := FTXProfiles.MainByMode[FlagMode(SliceId)]
else
begin
idx := FindSliceIndex(SliceId);
if idx >= 0 then Result := FSlices[idx].TXProfByMode[FlagMode(SliceId)];
end;
if (Result < 0) or (Result >= FTXProfiles.Count) then Result := -1;
end;
procedure TRadioController.SetSliceTXProfile(SliceId, ProfIdx: Integer);
// Пишем в ячейку ТЕКУЩЕГО режима этого флага: выбор запоминается за модуляцией.
var idx, m: Integer;
begin
if (ProfIdx < -1) or (ProfIdx >= FTXProfiles.Count) then ProfIdx := -1;
m := FlagMode(SliceId);
if SliceId <= 0 then
begin
if FTXProfiles.MainByMode[m] = ProfIdx then Exit;
FTXProfiles.MainByMode[m] := ProfIdx;
SaveTXProfilesToConfig;
end
else
begin
idx := FindSliceIndex(SliceId);
if (idx < 0) or (FSlices[idx].TXProfByMode[m] = ProfIdx) then Exit;
FSlices[idx].TXProfByMode[m] := ProfIdx;
// Персист слайсов делает MainForm на общем сохранении панов — здесь только
// состояние. Привязку главного пишем сразу: она живёт в секции профилей.
end;
// Привязали источник, который передаёт прямо сейчас, — применяем немедленно,
// иначе профиль подхватился бы только при следующей смене TX-источника.
if SliceId = FTxSliceId then ApplyBoundTXProfile;
Changed(rfTXProfile);
end;
procedure TRadioController.SetSliceTXProfileTable(SliceId: Integer;
const T: TTXProfModeTable);
// Восстановление сохранённой таблицы целиком (персист слайсов/панов).
var idx, m: Integer;
begin
if SliceId <= 0 then
begin
FTXProfiles.MainByMode := T;
Exit;
end;
idx := FindSliceIndex(SliceId);
if idx < 0 then Exit;
for m := 0 to CFG_MODE_MAX do
if (T[m] >= 0) and (T[m] < FTXProfiles.Count) then
FSlices[idx].TXProfByMode[m] := T[m]
else
FSlices[idx].TXProfByMode[m] := -1;
end;
function TRadioController.SliceTXProfileTable(SliceId: Integer): TTXProfModeTable;
var idx: Integer;
begin
ClearTXProfModeTable(Result);
if SliceId <= 0 then Result := FTXProfiles.MainByMode
else
begin
idx := FindSliceIndex(SliceId);
if idx >= 0 then Result := FSlices[idx].TXProfByMode;
end;
end;
procedure TRadioController.ApplyBoundTXProfile;
// Активный профиль ОДНОЗНАЧНО определяется парой «TX-источник + его модуляция»:
// ячейка таблицы заполнена → её профиль;
// ячейка пуста (−1) → базовый профиль (первый в списке).
// Фолбэк обязателен: без него профиль прошлого режима оставался висеть в новом
// (симптом «перешёл из FM в USB, а звучит FM»). Правило детерминированное —
// работает и после перезапуска, в отличие от «вернуть, что было до автоматики».
var P, M: Integer;
begin
if FTXProfiles.Count = 0 then Exit;
M := FlagMode(FTxSliceId);
// В DMR передатчика нет, в FM RAW профиль ни на что не влияет
// (ApplyTXModeSettings глушит там всю обработку) — активный не трогаем вовсе,
// иначе заход в эти режимы молча перебивал бы выбор оператора.
if (M = MODE_DMR) or (M = MODE_FMRAW) then Exit;
P := SliceTXProfile(FTxSliceId);
if P < 0 then P := 0;
if P <> FTXProfiles.ActiveIdx then SelectTXProfile(P, True);
end;
procedure TRadioController.ResetTXProfilesToFactory;
// Пересобирает заводской набор поверх текущего списка. База — ЖИВЫЕ настройки,
// поэтому «Default» = микрофонный вход, полоса, динамика и мощность как сейчас,
// но с заводским (ровным) EQ. Единственный способ доехать до обновлённых
// заводских профилей с уже записанной секцией tx_profiles: она читается, а не
// пересоздаётся.
begin
DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles);
RemapTXProfileBindings(-1); // список другой — старые привязки бессмысленны
// ActiveIdx гасим перед Select: иначе StoreActiveTXProfile внутри него
// впишет в свежий «Default» живой EQ и сброс тембра тут же отменится.
FTXProfiles.ActiveIdx := -1;
// Применяем «Default» в эфир — иначе живой EQ остался бы кривым, а профиль
// говорил бы «ровный», и первая же правка TX-настроек вернула бы кривую.
SelectTXProfile(0); // сам сохранит и отрендерит
end;
procedure TRadioController.ApplyTXSettingsToDSP; procedure TRadioController.ApplyTXSettingsToDSP;
begin begin
if not FWDSPReady then Exit; if not FWDSPReady then Exit;
@@ -5847,6 +6202,12 @@ begin
// drive% (0..100) — раньше грузился через TrkDrive.Position; теперь напрямую, // drive% (0..100) — раньше грузился через TrkDrive.Position; теперь напрямую,
// слайдер выставит rfDevice-рендер. Калиброванный байт считаем после FCurrentBand. // слайдер выставит rfDevice-рендер. Калиброванный байт считаем после FCurrentBand.
FDrivePercent := EnsureRange(FLoadedGlobal.DriveLevel, 0, 100); FDrivePercent := EnsureRange(FLoadedGlobal.DriveLevel, 0, 100);
// TX-профили. Секции нет (конфиг до этой версии) — строим заводской набор из
// ТЕКУЩИХ настроек: «Default» повторяет их один в один, звук после
// обновления не меняется. Активный профиль на старте не применяем — живая
// секция "tx" уже хранит последнее состояние, оно и есть истина.
if not FSettings.LoadTXProfiles(FDevMAC, FTXProfiles) then
DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles);
// Клампим LastBand — иначе битый/старый конфиг → RestoreBand выйдет по гарду, // Клампим LastBand — иначе битый/старый конфиг → RestoreBand выйдет по гарду,
// а последующие SetMode/SetFilterIdx запишут в FBandCache[невалид] за массив. // а последующие SetMode/SetFilterIdx запишут в FBandCache[невалид] за массив.
FCurrentBand := EnsureRange(FLoadedGlobal.LastBand, 0, CFG_BAND_COUNT - 1); FCurrentBand := EnsureRange(FLoadedGlobal.LastBand, 0, CFG_BAND_COUNT - 1);
@@ -5999,6 +6360,10 @@ begin
FSettings.SaveAnt936x(FDevMAC, FAnt936x); FSettings.SaveAnt936x(FDevMAC, FAnt936x);
FSettings.SaveOC(FDevMAC, FOCSettings); FSettings.SaveOC(FDevMAC, FOCSettings);
FSettings.SaveFilters(FDevMAC, FFilterTable); FSettings.SaveFilters(FDevMAC, FFilterTable);
// Активный TX-профиль дописываем живыми значениями (drive/громкость микро-
// фона ездят мимо диалога настроек) и пишем список целиком.
StoreActiveTXProfile;
FSettings.SaveTXProfiles(FDevMAC, FTXProfiles);
// XVTR LastFreq → актуальная видимая частота в момент остановки. // XVTR LastFreq → актуальная видимая частота в момент остановки.
if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then
FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA; FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA;
+45 -24
View File
@@ -13,7 +13,7 @@ unit SampleRateOverlay;
interface interface
uses uses
Classes, SysUtils, Controls, Graphics, Types, Math; Classes, SysUtils, Controls, Graphics, Types, Math, AppTheme;
type type
TSpanSelectEvent = procedure(SampleRate: Integer) of object; TSpanSelectEvent = procedure(SampleRate: Integer) of object;
@@ -35,6 +35,7 @@ type
FTop: Integer; FTop: Integer;
FWidth: Integer; FWidth: Integer;
FHeight: Integer; FHeight: Integer;
FTheme: TAppTheme;
FHitRects: array[0..10] of TRect; // [0]=hide button, [1..]=span buttons FHitRects: array[0..10] of TRect; // [0]=hide button, [1..]=span buttons
FHitValues: array[0..10] of Integer; // 0=hide, else sample rate FHitValues: array[0..10] of Integer; // 0=hide, else sample rate
@@ -76,6 +77,7 @@ type
procedure SetRatePresets(const Rates: array of Integer); procedure SetRatePresets(const Rates: array of Integer);
procedure SetRatePresetsDefault; // HPSDR 48k..1536k procedure SetRatePresetsDefault; // HPSDR 48k..1536k
procedure SetPanelHidden(Hidden: Boolean); procedure SetPanelHidden(Hidden: Boolean);
procedure SetTheme(const T: TAppTheme);
property Left: Integer read FLeft; property Left: Integer read FLeft;
property Top: Integer read FTop; property Top: Integer read FTop;
property Width: Integer read FWidth; property Width: Integer read FWidth;
@@ -97,9 +99,6 @@ const
PAD_Y = 4; PAD_Y = 4;
BTN_GAP = 3; BTN_GAP = 3;
CLR_TEXT = TColor($00D8E6EE);
CLR_BTN_BDR = TColor($00685624);
procedure BlendRect(Target: TBitmap; const Rct: TRect; R, G, B, Alpha: Byte); procedure BlendRect(Target: TBitmap; const Rct: TRect; R, G, B, Alpha: Byte);
var var
Y, X, InvA: Integer; Y, X, InvA: Integer;
@@ -141,6 +140,16 @@ begin
end; end;
end; end;
procedure BlendColorRect(Target: TBitmap; const Rct: TRect;
Color: TColor; Alpha: Byte);
var
C: LongInt;
begin
C := ColorToRGB(Color);
BlendRect(Target, Rct, C and $FF, (C shr 8) and $FF,
(C shr 16) and $FF, Alpha);
end;
function SpanBtnWidth(const Name: string; C: TCanvas): Integer; function SpanBtnWidth(const Name: string; C: TCanvas): Integer;
begin begin
C.Font.Name := 'Courier New'; C.Font.Name := 'Courier New';
@@ -160,6 +169,7 @@ begin
FHotIdx := -1; FHotIdx := -1;
FWidth := 320; FWidth := 320;
FHeight := SPAN_H + PAD_Y * 2; FHeight := SPAN_H + PAD_Y * 2;
FTheme := DarkTheme;
FCache := TBitmap.Create; FCache := TBitmap.Create;
FCache.PixelFormat := pf32bit; FCache.PixelFormat := pf32bit;
FCacheW := 0; FCacheW := 0;
@@ -224,30 +234,34 @@ begin
if ContentW > W then ContentW := W; if ContentW > W then ContentW := W;
FullR := Rect(OriginX, OriginY, OriginX + ContentW, OriginY + H); FullR := Rect(OriginX, OriginY, OriginX + ContentW, OriginY + H);
BlendRect(Target, FullR, $14, $14, $14, 104); BlendColorRect(Target, FullR, FTheme.Panel, 210);
BlendRect(Target, Rect(FullR.Left, FullR.Top, FullR.Right, FullR.Top + 1), BlendColorRect(Target,
$80, $80, $80, 56); Rect(FullR.Left, FullR.Top, FullR.Right, FullR.Top + 1),
BlendRect(Target, Rect(FullR.Left, FullR.Bottom - 1, FullR.Right, FullR.Bottom), FTheme.Border, 150);
$00, $00, $00, 48); BlendColorRect(Target,
BlendRect(Target, Rect(FullR.Left, FullR.Top, FullR.Left + 1, FullR.Bottom), Rect(FullR.Left, FullR.Bottom - 1, FullR.Right, FullR.Bottom),
$80, $80, $80, 42); FTheme.Border, 120);
BlendRect(Target, Rect(FullR.Right - 1, FullR.Top, FullR.Right, FullR.Bottom), BlendColorRect(Target,
$00, $00, $00, 42); Rect(FullR.Left, FullR.Top, FullR.Left + 1, FullR.Bottom),
FTheme.Border, 120);
BlendColorRect(Target,
Rect(FullR.Right - 1, FullR.Top, FullR.Right, FullR.Bottom),
FTheme.Border, 120);
X := OriginX + PAD_X; X := OriginX + PAD_X;
HR := Rect(X, OriginY + PAD_Y, X + HIDE_W, OriginY + PAD_Y + SPAN_H); HR := Rect(X, OriginY + PAD_Y, X + HIDE_W, OriginY + PAD_Y + SPAN_H);
IsHot := FHotIdx = 0; IsHot := FHotIdx = 0;
if IsHot then BlendRect(Target, HR, $1D, $4B, $62, 206) if IsHot then BlendColorRect(Target, HR, FTheme.SpanActive, 230)
else BlendRect(Target, HR, $18, $3F, $54, 182); else BlendColorRect(Target, HR, FTheme.SpanNorm, 215);
C.Pen.Style := psSolid; C.Pen.Style := psSolid;
C.Pen.Color := CLR_BTN_BDR; C.Pen.Color := FTheme.SpanBorder;
C.Brush.Style := bsClear; C.Brush.Style := bsClear;
C.Rectangle(HR); C.Rectangle(HR);
C.Font.Size := 8; C.Font.Size := 8;
if FPanelHidden then Cap := '>' else Cap := '<'; if FPanelHidden then Cap := '>' else Cap := '<';
TW := C.TextWidth(Cap); TW := C.TextWidth(Cap);
C.Font.Color := CLR_TEXT; C.Font.Color := FTheme.SpanText;
C.Brush.Style := bsClear; C.Brush.Style := bsClear;
C.TextOut(HR.Left + (HIDE_W - TW) div 2, C.TextOut(HR.Left + (HIDE_W - TW) div 2,
HR.Top + (SPAN_H - TH) div 2, Cap); HR.Top + (SPAN_H - TH) div 2, Cap);
@@ -262,16 +276,16 @@ begin
IsAct := FRates[I] = FCurrentRate; IsAct := FRates[I] = FCurrentRate;
IsHot := FHotIdx = I + 1; IsHot := FHotIdx = I + 1;
if IsAct then BlendRect(Target, HR, $20, $5A, $78, 220) if IsAct then BlendColorRect(Target, HR, FTheme.SpanActive, 240)
else if IsHot then BlendRect(Target, HR, $1D, $4B, $62, 206) else if IsHot then BlendColorRect(Target, HR, FTheme.SpanActive, 230)
else BlendRect(Target, HR, $18, $3F, $54, 182); else BlendColorRect(Target, HR, FTheme.SpanNorm, 215);
C.Pen.Style := psSolid; C.Pen.Style := psSolid;
C.Pen.Color := CLR_BTN_BDR; C.Pen.Color := FTheme.SpanBorder;
C.Brush.Style := bsClear; C.Brush.Style := bsClear;
C.Rectangle(HR); C.Rectangle(HR);
TW := C.TextWidth(FNames[I]); TW := C.TextWidth(FNames[I]);
C.Font.Color := CLR_TEXT; C.Font.Color := FTheme.SpanText;
C.Brush.Style := bsClear; C.Brush.Style := bsClear;
C.TextOut(HR.Left + (BtnW - TW) div 2, C.TextOut(HR.Left + (BtnW - TW) div 2,
HR.Top + (SPAN_H - TH) div 2, FNames[I]); HR.Top + (SPAN_H - TH) div 2, FNames[I]);
@@ -470,7 +484,7 @@ begin
ContentW := Min(ContentW, W); ContentW := Min(ContentW, W);
Target.SetSize(ContentW, H); Target.SetSize(ContentW, H);
Target.Canvas.Brush.Color := TColor($00141414); Target.Canvas.Brush.Color := FTheme.Panel;
Target.Canvas.Brush.Style := bsSolid; Target.Canvas.Brush.Style := bsSolid;
Target.Canvas.Pen.Style := psClear; Target.Canvas.Pen.Style := psClear;
Target.Canvas.FillRect(Rect(0, 0, ContentW, H)); Target.Canvas.FillRect(Rect(0, 0, ContentW, H));
@@ -480,7 +494,7 @@ begin
try try
HitTarget.PixelFormat := pf32bit; HitTarget.PixelFormat := pf32bit;
HitTarget.SetSize(Max(1, FLeft + ContentW), Max(1, FTop + H)); HitTarget.SetSize(Max(1, FLeft + ContentW), Max(1, FTop + H));
HitTarget.Canvas.Brush.Color := TColor($00141414); HitTarget.Canvas.Brush.Color := FTheme.Panel;
HitTarget.Canvas.FillRect(Rect(0, 0, HitTarget.Width, HitTarget.Height)); HitTarget.Canvas.FillRect(Rect(0, 0, HitTarget.Width, HitTarget.Height));
DrawSelf(HitTarget, HitTarget.Canvas, FLeft, FTop, ContentW, H); DrawSelf(HitTarget, HitTarget.Canvas, FLeft, FTop, ContentW, H);
finally finally
@@ -575,4 +589,11 @@ begin
RequestInvalidate; RequestInvalidate;
end; end;
procedure TSampleRateOverlay.SetTheme(const T: TAppTheme);
begin
FTheme := T;
FCacheDirty := True;
RequestInvalidate;
end;
end. end.
+468 -7
View File
@@ -28,6 +28,7 @@ const
CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS
CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES
CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090) CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090)
TXPROF_MAX = 16; // потолок именованных TX-профилей на устройство
QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100 QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100
// (split-LO транспондер; настраивается на // (split-LO транспондер; настраивается на
// отдельной странице, не в общей XVTR-таблице) // отдельной странице, не в общей XVTR-таблице)
@@ -163,6 +164,10 @@ type
// Персист панадаптеров и слайсов (секция "pans" под MAC). Восстанавливается // Персист панадаптеров и слайсов (секция "pans" под MAC). Восстанавливается
// один раз при выходе в Run. Слайс = приёмный WDSP-канал внутри пана; частоты // один раз при выходе в Run. Слайс = приёмный WDSP-канал внутри пана; частоты
// абсолютные (видимые). См. [[project_slices_plan]]. // абсолютные (видимые). См. [[project_slices_plan]].
// Привязка «режим → индекс TX-профиля» (-1 = не переключать). Такая таблица
// есть у каждого флага: у главного в TTXProfileList, у слайса в TSliceCfg.
TTXProfModeTable = array[0..CFG_MODE_MAX] of Integer;
TSliceCfg = record TSliceCfg = record
TargetHz: Double; // абсолютная видимая частота слайса TargetHz: Double; // абсолютная видимая частота слайса
Mode: Integer; Mode: Integer;
@@ -176,6 +181,9 @@ type
FMSQLevel: Integer; FMSQLevel: Integer;
DevName: string; // имя PortAudio-устройства вывода ('' = default) DevName: string; // имя PortAudio-устройства вывода ('' = default)
InDevName: string; // имя устройства ВВОДА слайса ('' = общий вход) InDevName: string; // имя устройства ВВОДА слайса ('' = общий вход)
// TX-профиль слайса ПО РЕЖИМУ: -1 = какой активен, тот и звучит. Таблица,
// а не одно значение: слайс на FT8 и слайс голосом помнят каждый своё.
TXProfByMode: TTXProfModeTable;
end; end;
TPanCfg = record TPanCfg = record
@@ -298,6 +306,65 @@ type
// (Orion MkII → 5.0, иначе выкл.), 0 = выкл. // (Orion MkII → 5.0, иначе выкл.), 0 = выкл.
end; end;
// ---- TX-профили (именованные снимки «как я звучу») ----------------------
// Профиль хранит ТОЛЬКО то, что определяет звук в эфире: маршрутизацию
// микрофонного входа, усиление, кромки TX-фильтра, динамическую обработку,
// эквалайзер, уровень несущей AM и мощность. Всё остальное из TTXSettings
// (FIR-длина/фаза/окно фильтра, ATT on TX, FM/CTCSS, параметры анализатора
// и сетки, калибровка PureSignal) — свойство устройства, от профиля НЕ
// зависит: переключение профиля не должно перерисовывать спектр, менять
// задержку тракта или ломать калибровку PS.
// Разбиение — по образцу таблицы TXProfile у Thetis (database.cs), но без
// её хвоста из VAC/DSP-буферов, которых у нас нет.
TTXProfile = record
Name: string[31];
// Микрофонный вход (гарнитура / микшер-ESSB — физически разные входы)
MicLineIn: Boolean;
MicBoost: Boolean;
MicPTTEnabled: Boolean;
MicTipRing: Boolean;
MicBias: Boolean;
LineInGainDB: Double;
MicGainDB: Double;
// Кромки TX-фильтра (FIR-длина/фаза/окно остаются у устройства)
FilterLow: Integer;
FilterHigh: Integer;
// Динамическая обработка
CompressorOn: Boolean;
CompressorGain: Double;
LevelerOn: Boolean;
LevelerTop: Double;
LevelerDecay: Integer;
ALCOn: Boolean;
ALCMaxGain: Double;
ALCDecay: Integer;
PhaseRotOn: Boolean;
PhaseRotStages: Integer;
PhaseRotFreq: Double;
// Эквалайзер
EQOn: Boolean;
EQNumBands: Integer;
EQGains: array[0..10] of Double;
EQFreqs: array[0..10] of Double;
// Модуляция AM (несущая — часть «звука», в отличие от девиации FM)
AMCarrierLevel: Double;
// Мощность (Thetis держит Power/Tune_Power в профиле)
DrivePercent: Integer; // 0..100
TUNLevel: Integer; // 0..100
end;
TTXProfileList = record
Count: Integer;
ActiveIdx: Integer;
// Привязка профиля к ГЛАВНОМУ приёмнику (слайс A) ПО РЕЖИМУ; -1 = ничего
// не переключать. У слайсов B..G своя такая же в TSliceCfg.TXProfByMode.
// Таблица главного одна на устройство — общая для КВ и трансвертера
// (решение пользователя); у слайсов она сама разъезжается по контекстам,
// потому что их записи хранятся отдельно для КВ и трансвертера.
MainByMode: TTXProfModeTable;
Items: array[0..TXPROF_MAX-1] of TTXProfile;
end;
// Web-сервер — глобальные настройки (не привязаны к устройству, секция "web"). // Web-сервер — глобальные настройки (не привязаны к устройству, секция "web").
TWebSettings = record TWebSettings = record
Enabled: Boolean; Enabled: Boolean;
@@ -625,8 +692,30 @@ type
// только режимы, отличающиеся от заводских, — конфиг не пухнет. // только режимы, отличающиеся от заводских, — конфиг не пухнет.
function LoadFilters(const MAC: array of Byte; out T: TFilterTable): Boolean; function LoadFilters(const MAC: array of Byte; out T: TFilterTable): Boolean;
procedure SaveFilters(const MAC: array of Byte; const T: TFilterTable); procedure SaveFilters(const MAC: array of Byte; const T: TFilterTable);
// TX-профили per-device (JSON-секция "tx_profiles" под MAC). LoadTXProfiles
// возвращает False, если секции ещё нет: вызывающий строит заводской набор
// из текущих TX-настроек (DefaultTXProfiles) — миграция старого конфига.
function LoadTXProfiles(const MAC: array of Byte; out L: TTXProfileList): Boolean;
procedure SaveTXProfiles(const MAC: array of Byte; const L: TTXProfileList);
end; end;
// ---- TX-профили ------------------------------------------------------------
// Снимок «звуковых» полей живых настроек в профиль (имя не трогается) и
// обратное применение. Единственные две точки, знающие состав профиля, —
// добавляя поле в TTXProfile, правь обе.
procedure TXProfileCapture(const T: TTXSettings; DrivePct: Integer; var P: TTXProfile);
procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: Integer);
// Заводской набор профилей. Base — текущие настройки устройства: из них
// наследуются микрофонный вход, мощность и всё, что профиль не переопределяет
// (иначе переключение на «ESSB» сбрасывало бы Mic In/Line In и drive).
procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
out L: TTXProfileList);
// Таблица «режим → профиль» ↔ JSON-массив. Все элементы вне 0..TXPROF_MAX-1
// читаются как -1 («не переключать»), поэтому чужой/старый конфиг безопасен.
procedure ClearTXProfModeTable(var T: TTXProfModeTable);
function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray;
procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable);
// Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа). // Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа).
function FilterPresetCount(Mode: Integer): Integer; function FilterPresetCount(Mode: Integer): Integer;
// Заводской индекс фильтра режима. // Заводской индекс фильтра режима.
@@ -1465,6 +1554,14 @@ begin
end; end;
end; end;
const
// Заводская 10-полосная голосовая сетка EQ (Hz). [0]=слот преампа (частота
// не используется), [1..10]=узлы кривой. Подобрано под голос, а не под
// музыку: больше точек в 100..3500 Hz. Нужна и DefaultTX, и заводским
// профилям (сброс тембра в ноль), поэтому живёт на уровне модуля.
DEF_EQ_FREQS: array[0..10] of Double = (
0, 100, 200, 400, 700, 1100, 1500, 2000, 2500, 3000, 3500);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TX settings (per-device, JSON-секция "tx" под MAC) // TX settings (per-device, JSON-секция "tx" под MAC)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1472,12 +1569,6 @@ end;
class procedure TSettingsManager.DefaultTX(out T: TTXSettings); class procedure TSettingsManager.DefaultTX(out T: TTXSettings);
var var
i: Integer; i: Integer;
const
// 10-полосный голосовой EQ (Hz). [0]=preamp slot, [1..10]=bands.
// Подобрано под голос, а не под музыку: больше точек в 100..3500 Hz.
DefaultEQFreqs: array[0..10] of Double = (
0, // [0] preamp slot — частота не используется
100, 200, 400, 700, 1100, 1500, 2000, 2500, 3000, 3500);
begin begin
FillChar(T, SizeOf(T), 0); FillChar(T, SizeOf(T), 0);
T.MicLineIn := False; T.MicLineIn := False;
@@ -1519,7 +1610,7 @@ begin
for i := 0 to 10 do for i := 0 to 10 do
begin begin
T.EQGains[i] := 0.0; T.EQGains[i] := 0.0;
T.EQFreqs[i] := DefaultEQFreqs[i]; T.EQFreqs[i] := DEF_EQ_FREQS[i];
end; end;
// AM // AM
T.AMCarrierLevel := 0.5; T.AMCarrierLevel := 0.5;
@@ -1715,6 +1806,372 @@ begin
JW(O,'ps_outlier_sigma',T.PSOutlierSigma); JW(O,'ps_outlier_sigma',T.PSOutlierSigma);
end; end;
// ---------------------------------------------------------------------------
// TX-профили: снимок/применение + заводской набор + персист
// ---------------------------------------------------------------------------
procedure TXProfileCapture(const T: TTXSettings; DrivePct: Integer; var P: TTXProfile);
var i: Integer;
begin
P.MicLineIn := T.MicLineIn;
P.MicBoost := T.MicBoost;
P.MicPTTEnabled := T.MicPTTEnabled;
P.MicTipRing := T.MicTipRing;
P.MicBias := T.MicBias;
P.LineInGainDB := T.LineInGainDB;
P.MicGainDB := T.MicGainDB;
P.FilterLow := T.FilterLow;
P.FilterHigh := T.FilterHigh;
P.CompressorOn := T.CompressorOn;
P.CompressorGain := T.CompressorGain;
P.LevelerOn := T.LevelerOn;
P.LevelerTop := T.LevelerTop;
P.LevelerDecay := T.LevelerDecay;
P.ALCOn := T.ALCOn;
P.ALCMaxGain := T.ALCMaxGain;
P.ALCDecay := T.ALCDecay;
P.PhaseRotOn := T.PhaseRotOn;
P.PhaseRotStages := T.PhaseRotStages;
P.PhaseRotFreq := T.PhaseRotFreq;
P.EQOn := T.EQOn;
P.EQNumBands := T.EQNumBands;
for i := 0 to 10 do
begin
P.EQGains[i] := T.EQGains[i];
P.EQFreqs[i] := T.EQFreqs[i];
end;
P.AMCarrierLevel := T.AMCarrierLevel;
P.DrivePercent := EnsureRange(DrivePct, 0, 100);
P.TUNLevel := EnsureRange(T.TUNLevel, 0, 100);
end;
procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: Integer);
var i: Integer;
begin
T.MicLineIn := P.MicLineIn;
T.MicBoost := P.MicBoost;
T.MicPTTEnabled := P.MicPTTEnabled;
T.MicTipRing := P.MicTipRing;
T.MicBias := P.MicBias;
T.LineInGainDB := EnsureRange(P.LineInGainDB, -34.5, 12.0);
T.MicGainDB := P.MicGainDB;
T.FilterLow := P.FilterLow;
T.FilterHigh := P.FilterHigh;
T.CompressorOn := P.CompressorOn;
T.CompressorGain := P.CompressorGain;
T.LevelerOn := P.LevelerOn;
T.LevelerTop := P.LevelerTop;
T.LevelerDecay := P.LevelerDecay;
T.ALCOn := P.ALCOn;
T.ALCMaxGain := P.ALCMaxGain;
T.ALCDecay := P.ALCDecay;
T.PhaseRotOn := P.PhaseRotOn;
T.PhaseRotStages := P.PhaseRotStages;
T.PhaseRotFreq := P.PhaseRotFreq;
T.EQOn := P.EQOn;
T.EQNumBands := P.EQNumBands;
for i := 0 to 10 do
begin
T.EQGains[i] := P.EQGains[i];
T.EQFreqs[i] := P.EQFreqs[i];
end;
T.AMCarrierLevel := P.AMCarrierLevel;
T.TUNLevel := EnsureRange(P.TUNLevel, 0, 100);
DrivePct := EnsureRange(P.DrivePercent, 0, 100);
end;
procedure ClearTXProfModeTable(var T: TTXProfModeTable);
var i: Integer;
begin
for i := 0 to CFG_MODE_MAX do T[i] := -1;
end;
function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray;
var i: Integer;
begin
Result := TJSONArray.Create;
for i := 0 to CFG_MODE_MAX do Result.Add(T[i]);
end;
procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable);
var
Arr: TJSONArray;
i, V: Integer;
begin
ClearTXProfModeTable(T);
if not (Node is TJSONArray) then Exit;
Arr := TJSONArray(Node);
for i := 0 to Min(CFG_MODE_MAX, Arr.Count - 1) do
begin
V := Arr.Integers[i];
if (V >= 0) and (V < TXPROF_MAX) then T[i] := V;
end;
end;
procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
out L: TTXProfileList);
var
// Заводской звук + ЖИВАЯ обвязка микрофона. Всё, что определяет тембр
// (полоса, динамика, EQ, AM carrier, TUN level), берётся из DefaultTX:
// «сброс к заводским» обязан сбрасывать именно это, иначе Default наследует
// ту полосу, на которой оператора застали (например 5000 от ESSB 5k).
// Не сбрасываем то, что описывает ЖЕЛЕЗО и безопасность: разъём и режим
// микрофона с его усилением (иначе слетит Line In у микшера) и мощность
// (внезапный скачок drive пошёл бы в усилитель).
Stock: TTXSettings;
const
// Кривые EQ для заводских ESSB. Значения — УЗЛЫ ломаной АЧХ WDSP (eq.c),
// а не «полосы»: между узлами интерполяция по дБ, а ЗА крайними узлами
// ctfmode=0 обрывает АЧХ на нет. Отсюда железное правило, по которому
// подобраны частоты: нижний узел ≤ FilterLow, верхний ≥ FilterHigh, иначе
// включённый EQ сам срежет полосу, ради которой ESSB и включают.
// Форма: срез бубнения снизу, ровная середина, мягкий подъём присутствия
// к верху. Преамп в минус — EQ стоит ДО компрессора и ALC, и плюсовые
// гейны иначе просто загоняют ALC в ограничение.
ESSB35_F: array[1..10] of Double = (100, 160, 250, 400, 700, 1200, 1800, 2500, 3100, 3600);
ESSB35_G: array[1..10] of Double = ( -6, -4, -2, 0, 0, 1, 2, 3, 4, 4);
ESSB35_PRE = -2.0;
ESSB50_F: array[1..10] of Double = (100, 180, 300, 550, 950, 1600, 2400, 3300, 4200, 5100);
ESSB50_G: array[1..10] of Double = ( -5, -3, -1, 0, 0, 1, 2, 3.5, 5, 5);
ESSB50_PRE = -3.0;
// FM: узлы охватывают AF-полосу модулятора (штатно 300..3000, вкладка
// Hardware), крайние с запасом — за ними EQ обрывает АЧХ. Верх НЕ поднимаем:
// предыскажение (pre-emphasis) само задирает его 6 дБ/окт и по умолчанию
// стоит ПОСЛЕ динамики (xemphp вторым, сразу перед xfmmod), то есть
// компрессор его уже не удержит — лишний буст ушёл бы в перемодуляцию на
// свистящих. Снизу режем бубнение, лёгкий подъём разборчивости 1.4..2.3 кГц.
FM_F: array[1..10] of Double = (200, 300, 450, 700, 1000, 1400, 1800, 2300, 2800, 3400);
FM_G: array[1..10] of Double = ( -8, -4, -1, 0, 0, 1, 2, 2, 1, -2);
FM_PRE = -1.0;
// Тембр у заводского профиля — заводской: EQ выключен, все усиления и
// преамп в нуле, сетка узлов штатная. Наследовать сюда живой EQ нельзя —
// иначе «сброс к заводским» не сбрасывал бы то, ради чего его жмут.
procedure FlatEQ(var P: TTXProfile);
var i: Integer;
begin
P.EQOn := False;
P.EQNumBands := 10;
for i := 0 to 10 do
begin
P.EQGains[i] := 0.0;
P.EQFreqs[i] := DEF_EQ_FREQS[i];
end;
end;
procedure Add(const AName: string; ALow, AHigh: Integer;
AComp: Boolean; ACompGain: Double);
begin
if L.Count >= TXPROF_MAX then Exit;
TXProfileCapture(Stock, DrivePct, L.Items[L.Count]);
L.Items[L.Count].Name := AName;
L.Items[L.Count].FilterLow := ALow;
L.Items[L.Count].FilterHigh := AHigh;
L.Items[L.Count].CompressorOn := AComp;
L.Items[L.Count].CompressorGain := ACompGain;
FlatEQ(L.Items[L.Count]);
Inc(L.Count);
end;
// То же плюс своя кривая EQ: у ESSB тембр — часть профиля, наследовать
// сетку 100..3500 от текущих настроек здесь нельзя (см. правило выше).
procedure AddEQ(const AName: string; ALow, AHigh: Integer;
AComp: Boolean; ACompGain: Double;
const AF, AG: array of Double; APreamp: Double);
var
i, Before: Integer;
begin
Before := L.Count;
Add(AName, ALow, AHigh, AComp, ACompGain);
if L.Count = Before then Exit; // упёрлись в TXPROF_MAX — ничего не добавлено
with L.Items[L.Count - 1] do
begin
EQOn := True;
EQNumBands := 10;
EQFreqs[0] := 0.0; // слот преампа — частота не используется
EQGains[0] := APreamp;
for i := 1 to 10 do
begin
EQFreqs[i] := AF[i - 1];
EQGains[i] := AG[i - 1];
end;
end;
end;
begin
FillChar(L, SizeOf(L), 0);
L.Count := 0;
L.ActiveIdx := 0;
ClearTXProfModeTable(L.MainByMode); // главный ничего не переключает
// База всех заводских профилей: заводской звук, живая обвязка микрофона.
TSettingsManager.DefaultTX(Stock);
Stock.MicLineIn := Base.MicLineIn;
Stock.MicBoost := Base.MicBoost;
Stock.MicBias := Base.MicBias;
Stock.MicPTTEnabled := Base.MicPTTEnabled;
Stock.MicTipRing := Base.MicTipRing;
Stock.LineInGainDB := Base.LineInGainDB;
Stock.MicGainDB := Base.MicGainDB;
// «Default» — ровно заводской звук: полоса 200..3100, компрессор выкл,
// leveler/ALC как в DefaultTX, EQ ровный.
TXProfileCapture(Stock, DrivePct, L.Items[0]);
L.Items[0].Name := 'Default';
FlatEQ(L.Items[0]);
L.Count := 1;
// SSB-профили отличаются от Default только полосой и компрессором;
// микрофонный вход и мощность наследуются, EQ у всех троих заводской-ровный.
// ESSB несут ещё и свою кривую: без неё «широкий» профиль звучит тем же
// телефонным тембром, а с чужой сеткой (верхний узел 3500) EQ обрезал бы
// верх ESSB 5k.
Add('SSB DX', 300, 2700, True, 8.0);
Add('SSB Wide', 200, 3100, False, 5.0);
AddEQ('ESSB 3.5k', 100, 3500, False, 5.0, ESSB35_F, ESSB35_G, ESSB35_PRE);
AddEQ('ESSB 5k', 100, 5000, False, 5.0, ESSB50_F, ESSB50_G, ESSB50_PRE);
// FM: плотная модуляция за счёт компрессора — девиация ограничена жёстко,
// поэтому громкость даёт средний уровень, а не пики. Полоса 300..3000 здесь
// косметическая: в FM края bp0 считает сам движок по Карсону (девиация +
// AF-срез), профильные значения работают, только если привязать этот профиль
// к голосовому режиму. Девиация и AF-срез — свойства аппарата (вкладка
// Hardware), профилем не переключаются.
AddEQ('FM', 300, 3000, True, 8.0, FM_F, FM_G, FM_PRE);
end;
function TSettingsManager.LoadTXProfiles(const MAC: array of Byte;
out L: TTXProfileList): Boolean;
var
MacStr: string;
DevObj, Root, O: TJSONObject;
Node: TJSONData;
Arr: TJSONArray;
BaseTX: TTXSettings;
DefP: TTXProfile;
i, j: Integer;
begin
FillChar(L, SizeOf(L), 0);
L.Count := 0;
L.ActiveIdx := 0;
ClearTXProfModeTable(L.MainByMode);
Result := False;
MacStr := MacToStr(MAC);
if FRoot.Find(MacStr) = nil then Exit;
DevObj := GetDevObj(MacStr);
if DevObj.Find('tx_profiles') = nil then Exit;
Root := EnsureObj(DevObj, 'tx_profiles');
Node := Root.Find('list');
if not (Node is TJSONArray) then Exit;
Arr := TJSONArray(Node);
// Заводской профиль как источник значений по умолчанию для отсутствующих
// ключей: конфиг, записанный старой версией, дочитывается без дыр.
DefaultTX(BaseTX);
TXProfileCapture(BaseTX, 50, DefP);
DefP.Name := 'Profile';
for i := 0 to Arr.Count - 1 do
begin
if L.Count >= TXPROF_MAX then Break;
if not (Arr.Items[i] is TJSONObject) then Continue;
O := TJSONObject(Arr.Items[i]);
L.Items[L.Count] := DefP;
with L.Items[L.Count] do
begin
Name := Copy(JS(O,'name', DefP.Name), 1, 31);
MicLineIn := JB(O,'mic_line_in', MicLineIn);
MicBoost := JB(O,'mic_boost', MicBoost);
MicPTTEnabled := JB(O,'mic_ptt', MicPTTEnabled);
MicTipRing := JB(O,'mic_tip_ring', MicTipRing);
MicBias := JB(O,'mic_bias', MicBias);
LineInGainDB := EnsureRange(JD(O,'line_in_gain_db', LineInGainDB), -34.5, 12.0);
MicGainDB := JD(O,'mic_gain_db', MicGainDB);
FilterLow := JI(O,'filter_low', FilterLow);
FilterHigh := JI(O,'filter_high', FilterHigh);
CompressorOn := JB(O,'comp_on', CompressorOn);
CompressorGain := JD(O,'comp_gain', CompressorGain);
LevelerOn := JB(O,'lev_on', LevelerOn);
LevelerTop := JD(O,'lev_top', LevelerTop);
LevelerDecay := JI(O,'lev_decay', LevelerDecay);
ALCOn := JB(O,'alc_on', ALCOn);
ALCMaxGain := JD(O,'alc_max_gain', ALCMaxGain);
ALCDecay := JI(O,'alc_decay', ALCDecay);
PhaseRotOn := JB(O,'phr_on', PhaseRotOn);
PhaseRotStages := JI(O,'phr_stages', PhaseRotStages);
PhaseRotFreq := JD(O,'phr_freq', PhaseRotFreq);
EQOn := JB(O,'eq_on', EQOn);
EQNumBands := JI(O,'eq_bands', EQNumBands);
for j := 0 to 10 do
begin
EQGains[j] := JD(O,'eq_g_'+IntToStr(j), EQGains[j]);
EQFreqs[j] := JD(O,'eq_f_'+IntToStr(j), EQFreqs[j]);
end;
AMCarrierLevel := JD(O,'am_carrier', AMCarrierLevel);
DrivePercent := EnsureRange(JI(O,'drive', DrivePercent), 0, 100);
TUNLevel := EnsureRange(JI(O,'tun_level', TUNLevel), 0, 100);
if Name = '' then Name := 'Profile ' + IntToStr(L.Count + 1);
end;
Inc(L.Count);
end;
if L.Count = 0 then Exit; // пустой список = как будто секции нет
L.ActiveIdx := JI(Root, 'active', 0);
if (L.ActiveIdx < 0) or (L.ActiveIdx >= L.Count) then L.ActiveIdx := 0;
JSONToModeTable(Root.Find('main_prof_modes'), L.MainByMode);
for i := 0 to CFG_MODE_MAX do
if L.MainByMode[i] >= L.Count then L.MainByMode[i] := -1;
Result := True;
end;
procedure TSettingsManager.SaveTXProfiles(const MAC: array of Byte;
const L: TTXProfileList);
var
Root, O: TJSONObject;
Arr: TJSONArray;
i, j, Idx: Integer;
begin
Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx_profiles');
JW(Root, 'active', EnsureRange(L.ActiveIdx, 0, Max(0, L.Count - 1)));
Idx := Root.IndexOfName('main_prof_modes');
if Idx >= 0 then Root.Delete(Idx);
Root.Add('main_prof_modes', ModeTableToJSON(L.MainByMode));
// Список пересоздаётся целиком: удаление профиля не должно оставлять хвост.
Idx := Root.IndexOfName('list');
if Idx >= 0 then Root.Delete(Idx);
Arr := TJSONArray.Create;
Root.Add('list', Arr);
for i := 0 to L.Count - 1 do
begin
O := TJSONObject.Create;
Arr.Add(O);
JWS(O,'name', L.Items[i].Name);
JW(O,'mic_line_in', L.Items[i].MicLineIn);
JW(O,'mic_boost', L.Items[i].MicBoost);
JW(O,'mic_ptt', L.Items[i].MicPTTEnabled);
JW(O,'mic_tip_ring', L.Items[i].MicTipRing);
JW(O,'mic_bias', L.Items[i].MicBias);
JW(O,'line_in_gain_db', L.Items[i].LineInGainDB);
JW(O,'mic_gain_db', L.Items[i].MicGainDB);
JW(O,'filter_low', L.Items[i].FilterLow);
JW(O,'filter_high', L.Items[i].FilterHigh);
JW(O,'comp_on', L.Items[i].CompressorOn);
JW(O,'comp_gain', L.Items[i].CompressorGain);
JW(O,'lev_on', L.Items[i].LevelerOn);
JW(O,'lev_top', L.Items[i].LevelerTop);
JW(O,'lev_decay', L.Items[i].LevelerDecay);
JW(O,'alc_on', L.Items[i].ALCOn);
JW(O,'alc_max_gain', L.Items[i].ALCMaxGain);
JW(O,'alc_decay', L.Items[i].ALCDecay);
JW(O,'phr_on', L.Items[i].PhaseRotOn);
JW(O,'phr_stages', L.Items[i].PhaseRotStages);
JW(O,'phr_freq', L.Items[i].PhaseRotFreq);
JW(O,'eq_on', L.Items[i].EQOn);
JW(O,'eq_bands', L.Items[i].EQNumBands);
for j := 0 to 10 do
begin
JW(O,'eq_g_'+IntToStr(j), L.Items[i].EQGains[j]);
JW(O,'eq_f_'+IntToStr(j), L.Items[i].EQFreqs[j]);
end;
JW(O,'am_carrier', L.Items[i].AMCarrierLevel);
JW(O,'drive', L.Items[i].DrivePercent);
JW(O,'tun_level', L.Items[i].TUNLevel);
end;
end;
class procedure TSettingsManager.DefaultAlex(out A: TAlexSettings); class procedure TSettingsManager.DefaultAlex(out A: TAlexSettings);
var i: Integer; var i: Integer;
begin begin
@@ -2362,6 +2819,8 @@ begin
SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel); SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel);
SliceObj.Add('dev_name', P.Pans[i].Slices[j].DevName); SliceObj.Add('dev_name', P.Pans[i].Slices[j].DevName);
SliceObj.Add('in_dev_name', P.Pans[i].Slices[j].InDevName); SliceObj.Add('in_dev_name', P.Pans[i].Slices[j].InDevName);
SliceObj.Add('tx_prof_modes',
ModeTableToJSON(P.Pans[i].Slices[j].TXProfByMode));
end; end;
end; end;
end; end;
@@ -2442,6 +2901,8 @@ begin
P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30); P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30);
P.Pans[id].Slices[n].DevName := JS(SliceObj, 'dev_name', ''); P.Pans[id].Slices[n].DevName := JS(SliceObj, 'dev_name', '');
P.Pans[id].Slices[n].InDevName := JS(SliceObj, 'in_dev_name', ''); P.Pans[id].Slices[n].InDevName := JS(SliceObj, 'in_dev_name', '');
JSONToModeTable(SliceObj.Find('tx_prof_modes'),
P.Pans[id].Slices[n].TXProfByMode);
end; end;
end; end;
end; end;
+959 -522
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -84,7 +84,6 @@ begin
FLabels[Index].Alignment := taLeftJustify; FLabels[Index].Alignment := taLeftJustify;
FLabels[Index].Layout := tlCenter; FLabels[Index].Layout := tlCenter;
FLabels[Index].Caption := Cap; FLabels[Index].Caption := Cap;
FLabels[Index].Font.Name := 'Courier New';
FLabels[Index].Font.Size := 8; FLabels[Index].Font.Size := 8;
end; end;
+256 -41
View File
@@ -57,7 +57,10 @@ type
TVolumeEvent = procedure(Vol: Integer) of object; TVolumeEvent = procedure(Vol: Integer) of object;
TAudioDeviceEvent = procedure(DevIndex: Integer; const DevName: string) of object; TAudioDeviceEvent = procedure(DevIndex: Integer; const DevName: string) of object;
TFlyPanel = (fpNone, fpMode, fpDsp, fpAud); TFlyPanel = (fpNone, fpMode, fpDsp, fpAud, fpTxProf);
// Выбран TX-профиль для этого флага: Idx = -1 («как активный») или индекс.
TTXProfilePickEvent = procedure(Idx: Integer) of object;
TVfoOverlay = class(TCustomControl) TVfoOverlay = class(TCustomControl)
private private
@@ -102,6 +105,13 @@ type
FCurInDev: string; // текущий вход слайса ('' = общий вход) FCurInDev: string; // текущий вход слайса ('' = общий вход)
FAudInTab: Boolean; // AUD-панель показывает вход (IN), а не выход FAudInTab: Boolean; // AUD-панель показывает вход (IN), а не выход
// TX-профили: список имён (общий на все флаги) + привязка ЭТОГО флага.
// -1 = флаг ничего не переключает, передаёт активный профиль.
FProfNames: TStringList;
FProfBound: Integer;
FProfScroll: Integer; // прокрутка списка профилей
FProfListRect: TRect; // область списка профилей (зона колёсика)
FPanel: TFlyPanel; // открытая fly-out панель FPanel: TFlyPanel; // открытая fly-out панель
FAudScroll: Integer; // прокрутка списка аудио-устройств (верхний индекс) FAudScroll: Integer; // прокрутка списка аудио-устройств (верхний индекс)
FAudListRect: TRect; // область списка AUD (лок. коорд.) — зона колёсика FAudListRect: TRect; // область списка AUD (лок. коорд.) — зона колёсика
@@ -121,6 +131,7 @@ type
FOnSplit: TNotifyEvent; FOnSplit: TNotifyEvent;
FOnTxSelect: TNotifyEvent; FOnTxSelect: TNotifyEvent;
FOnRxMuteTx: TNotifyEvent; FOnRxMuteTx: TNotifyEvent;
FOnTXProfile: TTXProfilePickEvent;
FOnInvalidate: TNotifyEvent; FOnInvalidate: TNotifyEvent;
FTheme: TAppTheme; // тема приложения (цвета кнопок и т.п.) FTheme: TAppTheme; // тема приложения (цвета кнопок и т.п.)
@@ -156,6 +167,13 @@ type
procedure DrawModePanel(C: TCanvas; X, Y, RowW: Integer); procedure DrawModePanel(C: TCanvas; X, Y, RowW: Integer);
procedure DrawDspPanel(C: TCanvas; X, Y, RowW: Integer); procedure DrawDspPanel(C: TCanvas; X, Y, RowW: Integer);
procedure DrawAudPanel(C: TCanvas; X, Y, RowW: Integer); procedure DrawAudPanel(C: TCanvas; X, Y, RowW: Integer);
procedure DrawProfPanel(C: TCanvas; X, Y, RowW: Integer);
// Стрелки прокрутки справа от списка (общие для AUD и профилей).
procedure DrawScrollArrows(C: TCanvas; X, Y, RowW, RowWidth, PanelH: Integer;
CanUp, CanDn: Boolean; HitUp, HitDn: Integer);
function ProfRowCount: Integer; // «— как активный» + профили
function ProfRowName(I: Integer): string;
procedure ScrollProfToCurrent;
// Строки активной вкладки AUD (OUT — устройства вывода, IN — ввода; // Строки активной вкладки AUD (OUT — устройства вывода, IN — ввода;
// у IN первая строка псевдо: «общий вход» / «нет»). // у IN первая строка псевдо: «общий вход» / «нет»).
function AudRowCount: Integer; function AudRowCount: Integer;
@@ -206,6 +224,8 @@ type
procedure SetDSPState(ANRMode, ANBMode: Integer; ASNBOn, AANFOn: Boolean; procedure SetDSPState(ANRMode, ANBMode: Integer; ASNBOn, AANFOn: Boolean;
AAGCMode: Integer); AAGCMode: Integer);
procedure SetSquelchState(AOn: Boolean; ALevel: Integer); procedure SetSquelchState(AOn: Boolean; ALevel: Integer);
// Список TX-профилей (одинаков у всех флагов) + привязка этого флага.
procedure SetTXProfiles(ANames: TStrings; ABound: Integer);
procedure SetExtState(AVolume: Integer; AMuted, ASplit, ATx, ATxSel: Boolean); procedure SetExtState(AVolume: Integer; AMuted, ASplit, ATx, ATxSel: Boolean);
procedure SetRxMuteTxState(AOn: Boolean); // бейдж RXM (self-monitor на TX) procedure SetRxMuteTxState(AOn: Boolean); // бейдж RXM (self-monitor на TX)
// Auto TX слайса (настройка CAT): бейдж TX превращается в AutoTX. // Auto TX слайса (настройка CAT): бейдж TX превращается в AutoTX.
@@ -257,6 +277,7 @@ type
property OnSplit: TNotifyEvent read FOnSplit write FOnSplit; property OnSplit: TNotifyEvent read FOnSplit write FOnSplit;
property OnTxSelect: TNotifyEvent read FOnTxSelect write FOnTxSelect; property OnTxSelect: TNotifyEvent read FOnTxSelect write FOnTxSelect;
property OnRxMuteTx: TNotifyEvent read FOnRxMuteTx write FOnRxMuteTx; property OnRxMuteTx: TNotifyEvent read FOnRxMuteTx write FOnRxMuteTx;
property OnTXProfile: TTXProfilePickEvent read FOnTXProfile write FOnTXProfile;
property OnSliceSelect: TNotifyEvent read FOnSliceSelect write FOnSliceSelect; property OnSliceSelect: TNotifyEvent read FOnSliceSelect write FOnSliceSelect;
property OnClose: TNotifyEvent read FOnClose write FOnClose; property OnClose: TNotifyEvent read FOnClose write FOnClose;
property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate; property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate;
@@ -300,8 +321,12 @@ const
HIT_SQL = -31; // тумблер FM-squelch (в DSP-панели) HIT_SQL = -31; // тумблер FM-squelch (в DSP-панели)
HIT_SQL_SLIDER = -32; // трек порога FM-squelch HIT_SQL_SLIDER = -32; // трек порога FM-squelch
HIT_RXMUTE = -33; // бейдж RXM: приём слайса на своей передаче HIT_RXMUTE = -33; // бейдж RXM: приём слайса на своей передаче
HIT_TXPROF = -36; // пилюля TX-профиля в шапке (открыть список)
HIT_PROF_UP = -37; // прокрутка списка профилей
HIT_PROF_DN = -38;
HIT_AGC_PICK_BASE = 100000; HIT_AGC_PICK_BASE = 100000;
HIT_DEV_BASE = 200000; HIT_DEV_BASE = 200000;
HIT_PROF_BASE = 300000; // 300000 + строка списка (0 = «как активный»)
// Свои диапазоны, чтобы не пересекаться с тумблерами (-9..-33) и с базами выше. // Свои диапазоны, чтобы не пересекаться с тумблерами (-9..-33) и с базами выше.
HIT_MODE_BASE = -1000; // -1000 .. -1000-(OVL_MODE_COUNT-1) HIT_MODE_BASE = -1000; // -1000 .. -1000-(OVL_MODE_COUNT-1)
HIT_FILT_BASE = 1000; // 1000 + индекс кнопки в ряду фильтров HIT_FILT_BASE = 1000; // 1000 + индекс кнопки в ряду фильтров
@@ -319,6 +344,9 @@ const
IGAP = 2; IGAP = 2;
AUD_ROW_H = 17; // высота строки устройства в AUD-панели AUD_ROW_H = 17; // высота строки устройства в AUD-панели
AUD_MAX_VIS = 6; // максимум видимых устройств (дальше — прокрутка) AUD_MAX_VIS = 6; // максимум видимых устройств (дальше — прокрутка)
PROF_MAX_VIS = 6; // столько же строк у списка TX-профилей
PROF_PILL_W = 92; // ширина пилюли профиля в шапке
PROF_PILL_MIN = 44; // уже этого не рисуем — шапка занята бейджами
OVL_BASE_H = PAD + HDR_H + MET_H + FRQ_H + BAR_H + PAD; // = 96 OVL_BASE_H = PAD + HDR_H + MET_H + FRQ_H + BAR_H + PAD; // = 96
// Палитра. Структурные цвета (фон/текст/акценты) берутся из темы — // Палитра. Структурные цвета (фон/текст/акценты) берутся из темы —
@@ -519,6 +547,10 @@ begin
FInDevNames := TStringList.Create; FInDevNames := TStringList.Create;
FCurInDev := ''; FCurInDev := '';
FAudInTab := False; // AUD открывается на вкладке OUT FAudInTab := False; // AUD открывается на вкладке OUT
FProfNames := TStringList.Create;
FProfBound := -1; // флаг не переключает профиль
FProfScroll := 0;
FProfListRect := Rect(0, 0, 0, 0);
FCacheBitmap := TBitmap.Create; FCacheBitmap := TBitmap.Create;
FCacheBitmap.PixelFormat := pf32bit; FCacheBitmap.PixelFormat := pf32bit;
FCacheDirty := True; FCacheDirty := True;
@@ -558,6 +590,7 @@ end;
destructor TVfoOverlay.Destroy; destructor TVfoOverlay.Destroy;
begin begin
FProfNames.Free;
FInDevNames.Free; FInDevNames.Free;
FDevNames.Free; FDevNames.Free;
FCacheBitmap.Free; FCacheBitmap.Free;
@@ -660,6 +693,7 @@ begin
// ряд вкладок OUT/IN + список устройств активной вкладки // ряд вкладок OUT/IN + список устройств активной вкладки
fpAud: Result := ROW_H + IGAP + fpAud: Result := ROW_H + IGAP +
Min(Max(1, AudRowCount), AUD_MAX_VIS) * AUD_ROW_H; Min(Max(1, AudRowCount), AUD_MAX_VIS) * AUD_ROW_H;
fpTxProf: Result := Min(ProfRowCount, PROF_MAX_VIS) * AUD_ROW_H;
else Result := 0; else Result := 0;
end; end;
end; end;
@@ -674,6 +708,7 @@ procedure TVfoOverlay.SetPanel(P: TFlyPanel);
begin begin
if FPanel = P then FPanel := fpNone else FPanel := P; if FPanel = P then FPanel := fpNone else FPanel := P;
if FPanel = fpAud then ScrollAudToCurrent; // сразу показать выбранное if FPanel = fpAud then ScrollAudToCurrent; // сразу показать выбранное
if FPanel = fpTxProf then ScrollProfToCurrent;
Height := ComputeHeight; Height := ComputeHeight;
RequestInvalidate; RequestInvalidate;
end; end;
@@ -996,15 +1031,143 @@ begin
end; end;
end; end;
function TVfoOverlay.ProfRowCount: Integer;
// Строка 0 — псевдо «— как активный», дальше сами профили.
begin
Result := FProfNames.Count + 1;
end;
function TVfoOverlay.ProfRowName(I: Integer): string;
begin
if I <= 0 then Result := 'As active'
else if I - 1 < FProfNames.Count then Result := FProfNames[I - 1]
else Result := '';
end;
procedure TVfoOverlay.ScrollProfToCurrent;
var Cnt, Cur: Integer;
begin
FProfScroll := 0;
Cnt := ProfRowCount;
if Cnt <= PROF_MAX_VIS then Exit;
Cur := FProfBound + 1; // -1 → строка 0
FProfScroll := Min(Max(0, Cur - PROF_MAX_VIS div 2), Cnt - PROF_MAX_VIS);
end;
procedure TVfoOverlay.SetTXProfiles(ANames: TStrings; ABound: Integer);
var Same: Boolean;
begin
Same := (ABound = FProfBound) and (ANames <> nil) and
(ANames.Count = FProfNames.Count) and (ANames.Text = FProfNames.Text);
if Same then Exit;
if ANames <> nil then FProfNames.Assign(ANames) else FProfNames.Clear;
if (ABound < -1) or (ABound >= FProfNames.Count) then ABound := -1;
FProfBound := ABound;
if FPanel = fpTxProf then
begin
ScrollProfToCurrent;
Height := ComputeHeight; // список мог стать длиннее/короче
end;
RequestInvalidate;
end;
procedure TVfoOverlay.DrawProfPanel(C: TCanvas; X, Y, RowW: Integer);
const
ARROW_W = 15;
var
I, Slot, VisN, RowWidth, MaxScroll, Cnt: Integer;
HR: TRect;
Nm, Full: string;
Cur, Scrollable: Boolean;
begin
C.Font.Name := 'Courier New';
C.Font.Size := 7;
C.Font.Style := [];
Cnt := ProfRowCount;
VisN := Min(Cnt, PROF_MAX_VIS);
FProfListRect := Rect(X, Y, X + RowW, Y + VisN * AUD_ROW_H);
Scrollable := Cnt > PROF_MAX_VIS;
MaxScroll := Cnt - VisN;
if FProfScroll > MaxScroll then FProfScroll := MaxScroll;
if FProfScroll < 0 then FProfScroll := 0;
if Scrollable then RowWidth := RowW - ARROW_W - 2 else RowWidth := RowW;
for Slot := 0 to VisN - 1 do
begin
I := FProfScroll + Slot;
HR := Rect(X, Y + Slot*AUD_ROW_H, X + RowWidth, Y + Slot*AUD_ROW_H + 16);
Full := ProfRowName(I);
Nm := Full;
Cur := (I - 1) = FProfBound;
if Cur then C.Brush.Color := CLR_ACCENT_D
else if (FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_PROF_BASE + I) then
C.Brush.Color := CLR_BTN_HOT
else C.Brush.Color := CLR_BTN_NORM;
C.Brush.Style := bsSolid;
C.Pen.Color := C.Brush.Color;
C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
if Cur then C.Font.Color := CLR_ACCENT else C.Font.Color := CLR_TEXT;
C.Brush.Style := bsClear;
while (Length(Nm) > 1) and (C.TextWidth(Nm + '…') > RowWidth - 8) do
SetLength(Nm, Length(Nm) - 1);
if Nm <> Full then Nm := Nm + '…';
C.TextOut(HR.Left + 4, HR.Top + (16 - C.TextHeight('A')) div 2, Nm);
RegisterHit(HR, HIT_PROF_BASE + I);
end;
if not Scrollable then Exit;
DrawScrollArrows(C, X, Y, RowW, RowWidth, VisN * AUD_ROW_H - 1,
FProfScroll > 0, FProfScroll < MaxScroll,
HIT_PROF_UP, HIT_PROF_DN);
end;
procedure TVfoOverlay.DrawScrollArrows(C: TCanvas; X, Y, RowW, RowWidth,
PanelH: Integer; CanUp, CanDn: Boolean; HitUp, HitDn: Integer);
// Полоса прокрутки справа от списка: ▲ / ▼. Общая для AUD и TX-профилей.
var
HR: TRect;
ArrowX, MidY, CY: Integer;
Tri: array[0..2] of TPoint;
begin
ArrowX := X + RowWidth + 2;
MidY := Y + PanelH div 2;
HR := Rect(ArrowX, Y, X + RowW, MidY - 1);
C.Brush.Color := CLR_BTN_NORM; C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BTN_NORM; C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
CY := (HR.Top + HR.Bottom) div 2;
if CanUp then C.Brush.Color := CLR_TEXT else C.Brush.Color := CLR_DIM;
C.Pen.Color := C.Brush.Color;
Tri[0] := Point((HR.Left+HR.Right) div 2, CY - 3);
Tri[1] := Point((HR.Left+HR.Right) div 2 - 4, CY + 2);
Tri[2] := Point((HR.Left+HR.Right) div 2 + 4, CY + 2);
C.Polygon(Tri);
RegisterHit(HR, HitUp);
HR := Rect(ArrowX, MidY + 1, X + RowW, Y + PanelH);
C.Brush.Color := CLR_BTN_NORM; C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BTN_NORM; C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
CY := (HR.Top + HR.Bottom) div 2;
if CanDn then C.Brush.Color := CLR_TEXT else C.Brush.Color := CLR_DIM;
C.Pen.Color := C.Brush.Color;
Tri[0] := Point((HR.Left+HR.Right) div 2, CY + 3);
Tri[1] := Point((HR.Left+HR.Right) div 2 - 4, CY - 2);
Tri[2] := Point((HR.Left+HR.Right) div 2 + 4, CY - 2);
C.Polygon(Tri);
RegisterHit(HR, HitDn);
end;
procedure TVfoOverlay.DrawAudPanel(C: TCanvas; X, Y, RowW: Integer); procedure TVfoOverlay.DrawAudPanel(C: TCanvas; X, Y, RowW: Integer);
const const
ARROW_W = 15; ARROW_W = 15;
var var
I, Slot, VisN, RowWidth, MaxScroll, PanelH, ArrowX, MidY, CY, Cnt: Integer; I, Slot, VisN, RowWidth, MaxScroll, Cnt: Integer;
HR, TabR: TRect; HR, TabR: TRect;
Nm: string; Nm: string;
Cur, Scrollable, CanUp, CanDn: Boolean; Cur, Scrollable: Boolean;
Tri: array[0..2] of TPoint;
begin begin
// Ряд вкладок: OUT (куда отдаём звук) / IN (откуда берём модуляцию). // Ряд вкладок: OUT (куда отдаём звук) / IN (откуда берём модуляцию).
TabR := Rect(X, Y, X + (RowW - 4) div 2, Y + ROW_H); TabR := Rect(X, Y, X + (RowW - 4) div 2, Y + ROW_H);
@@ -1062,50 +1225,20 @@ begin
end; end;
if not Scrollable then Exit; if not Scrollable then Exit;
DrawScrollArrows(C, X, Y, RowW, RowWidth, VisN * AUD_ROW_H - 1,
// Полоса прокрутки справа: ▲ (верх) / ▼ (низ) + счётчик позиции. FAudScroll > 0, FAudScroll < MaxScroll,
PanelH := VisN * AUD_ROW_H - 1; HIT_AUD_UP, HIT_AUD_DN);
ArrowX := X + RowWidth + 2;
MidY := Y + PanelH div 2;
CanUp := FAudScroll > 0;
CanDn := FAudScroll < MaxScroll;
// верхняя стрелка
HR := Rect(ArrowX, Y, X + RowW, MidY - 1);
C.Brush.Color := CLR_BTN_NORM; C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BTN_NORM; C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
CY := (HR.Top + HR.Bottom) div 2;
if CanUp then C.Brush.Color := CLR_TEXT else C.Brush.Color := CLR_DIM;
C.Pen.Color := C.Brush.Color;
Tri[0] := Point((HR.Left+HR.Right) div 2, CY - 3);
Tri[1] := Point((HR.Left+HR.Right) div 2 - 4, CY + 2);
Tri[2] := Point((HR.Left+HR.Right) div 2 + 4, CY + 2);
C.Polygon(Tri);
RegisterHit(HR, HIT_AUD_UP);
// нижняя стрелка
HR := Rect(ArrowX, MidY + 1, X + RowW, Y + PanelH);
C.Brush.Color := CLR_BTN_NORM; C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BTN_NORM; C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
CY := (HR.Top + HR.Bottom) div 2;
if CanDn then C.Brush.Color := CLR_TEXT else C.Brush.Color := CLR_DIM;
C.Pen.Color := C.Brush.Color;
Tri[0] := Point((HR.Left+HR.Right) div 2, CY + 3);
Tri[1] := Point((HR.Left+HR.Right) div 2 - 4, CY - 2);
Tri[2] := Point((HR.Left+HR.Right) div 2 + 4, CY - 2);
C.Polygon(Tri);
RegisterHit(HR, HIT_AUD_DN);
end; end;
{ ---- Основная отрисовка ---- } { ---- Основная отрисовка ---- }
procedure TVfoOverlay.DrawSelf(C: TCanvas; W, H: Integer); procedure TVfoOverlay.DrawSelf(C: TCanvas; W, H: Integer);
var var
FreqStr, SStr, BWStr, DMRStr, TxStr: string; FreqStr, SStr, BWStr, DMRStr, TxStr, PStr: string;
CurY, X, RW, i, DMRX, DMRRight, TxRight, TxW: Integer; CurY, X, RW, i, DMRX, DMRRight, TxRight, TxW: Integer;
ProfLeft, ProfRight, ProfW, TxtY, TriY: Integer;
R: TRect; R: TRect;
Tri3: array[0..2] of TPoint;
BarL, BarW, MeterX: Integer; BarL, BarW, MeterX: Integer;
begin begin
FHitCount := 0; FHitCount := 0;
@@ -1154,6 +1287,7 @@ begin
C.Font.Name := 'Courier New'; C.Font.Size := 8; C.Font.Style := []; C.Font.Name := 'Courier New'; C.Font.Size := 8; C.Font.Style := [];
C.Font.Color := CLR_DIM; C.Brush.Style := bsClear; C.Font.Color := CLR_DIM; C.Brush.Style := bsClear;
C.TextOut(X + 22, CurY + 1, BWStr); C.TextOut(X + 22, CurY + 1, BWStr);
ProfLeft := X + 22 + C.TextWidth(BWStr) + 8;
// Краткий статус дополнительного DMR-слайса. Он обновляется только при // Краткий статус дополнительного DMR-слайса. Он обновляется только при
// устойчивом изменении sync/slot/CC/TG, поэтому не заставляет GL-текстуру // устойчивом изменении sync/slot/CC/TG, поэтому не заставляет GL-текстуру
// перезаливаться на каждом burst. // перезаливаться на каждом burst.
@@ -1186,6 +1320,50 @@ begin
else DrawBadge(C, R, 'SPLIT', CLR_BTN_NORM, CLR_DIM); else DrawBadge(C, R, 'SPLIT', CLR_BTN_NORM, CLR_DIM);
RegisterHit(R, HIT_SPLIT); RegisterHit(R, HIT_SPLIT);
end; end;
// Пилюля TX-профиля этого флага — сразу за шириной фильтра. Правая граница:
// до SPLIT (главный) либо до RXM (слайс). У DMR передачи нет вовсе, у FMRAW
// профиль ни на что не влияет (ApplyTXModeSettings глушит всю обработку —
// компрессор, leveler, ALC, phase rot, EQ, mic gain), у пустого списка
// выбирать нечего — не рисуем. Тесная шапка (узкий флаг) — тоже.
if FIsMain then ProfRight := W - PAD - 26 - 44 - 6
else ProfRight := TxRight - TxW - 4 - 32 - 6;
if (FProfNames.Count > 0) and (FMode <> OVL_MODE_DMR) and
(FMode <> OVL_MODE_FMRAW) and
(ProfRight - ProfLeft >= PROF_PILL_MIN) then
begin
ProfW := Min(PROF_PILL_W, ProfRight - ProfLeft);
R := Rect(ProfLeft, CurY, ProfLeft + ProfW, CurY + HDR_BADGE_H);
if FProfBound >= 0 then C.Brush.Color := CLR_ACCENT_D
else if (FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_TXPROF) then
C.Brush.Color := CLR_BTN_HOT
else C.Brush.Color := CLR_BTN_NORM;
C.Brush.Style := bsSolid;
C.Pen.Color := C.Brush.Color; C.Pen.Style := psSolid;
C.RoundRect(R.Left, R.Top, R.Right, R.Bottom, 4, 4);
if FProfBound >= 0 then PStr := ProfRowName(FProfBound + 1)
else PStr := 'Profile'; // привязки нет — просто ярлык
C.Font.Size := 7;
if FProfBound >= 0 then C.Font.Color := CLR_ACCENT else C.Font.Color := CLR_DIM;
C.Brush.Style := bsClear;
while (Length(PStr) > 1) and (C.TextWidth(PStr) > ProfW - 14) do
SetLength(PStr, Length(PStr) - 1);
TxtY := R.Top + (HDR_BADGE_H - C.TextHeight('A')) div 2;
C.TextOut(R.Left + 4, TxtY, PStr);
// Треугольник «список» справа — рисуем, а не пишем: в Courier New
// юникодных стрелок может не оказаться. Центрируем не по прямоугольнику
// текста, а по САМИМ буквам: TextHeight включает выносные элементы, а в
// подписи одни заглавные, они сидят в верхней части бокса — иначе
// треугольник визуально проваливается ниже надписи.
TriY := TxtY + C.TextHeight('A') div 2 - 2;
C.Brush.Color := C.Font.Color; C.Brush.Style := bsSolid;
C.Pen.Color := C.Font.Color;
Tri3[0] := Point(R.Right - 9, TriY - 2);
Tri3[1] := Point(R.Right - 3, TriY - 2);
Tri3[2] := Point(R.Right - 6, TriY + 2);
C.Polygon(Tri3);
C.Font.Size := 8;
RegisterHit(R, HIT_TXPROF);
end;
Inc(CurY, HDR_H); Inc(CurY, HDR_H);
{ --- Метр: индикаторы + сигнал-бар --- } { --- Метр: индикаторы + сигнал-бар --- }
@@ -1259,6 +1437,7 @@ begin
fpMode: DrawModePanel(C, PAD, CurY, W - PAD*2); fpMode: DrawModePanel(C, PAD, CurY, W - PAD*2);
fpDsp: DrawDspPanel(C, PAD, CurY, W - PAD*2); fpDsp: DrawDspPanel(C, PAD, CurY, W - PAD*2);
fpAud: DrawAudPanel(C, PAD, CurY, W - PAD*2); fpAud: DrawAudPanel(C, PAD, CurY, W - PAD*2);
fpTxProf: DrawProfPanel(C, PAD, CurY, W - PAD*2);
end; end;
end; end;
end; end;
@@ -1339,6 +1518,12 @@ begin
end; end;
HIT_RXMUTE: begin FRxMuteTx := not FRxMuteTx; RequestInvalidate; HIT_RXMUTE: begin FRxMuteTx := not FRxMuteTx; RequestInvalidate;
if Assigned(FOnRxMuteTx) then FOnRxMuteTx(Self); Exit; end; if Assigned(FOnRxMuteTx) then FOnRxMuteTx(Self); Exit; end;
HIT_TXPROF: begin SetPanel(fpTxProf); Exit; end;
HIT_PROF_UP: begin if FProfScroll > 0 then Dec(FProfScroll);
RequestInvalidate; Exit; end;
HIT_PROF_DN: begin if FProfScroll < ProfRowCount - PROF_MAX_VIS then
Inc(FProfScroll);
RequestInvalidate; Exit; end;
HIT_SLICE_SEL: begin if Assigned(FOnSliceSelect) then FOnSliceSelect(Self); Exit; end; HIT_SLICE_SEL: begin if Assigned(FOnSliceSelect) then FOnSliceSelect(Self); Exit; end;
HIT_CLOSE: begin if Assigned(FOnClose) then FOnClose(Self); Exit; end; HIT_CLOSE: begin if Assigned(FOnClose) then FOnClose(Self); Exit; end;
HIT_AUD_UP: begin if FAudScroll > 0 then Dec(FAudScroll); HIT_AUD_UP: begin if FAudScroll > 0 then Dec(FAudScroll);
@@ -1360,6 +1545,18 @@ begin
end; end;
end; end;
// Строка списка TX-профилей: 0 = «как активный» (-1), дальше индексы профилей.
if HV >= HIT_PROF_BASE then
begin
NewMode := HV - HIT_PROF_BASE - 1; // -1 = «как активный»
if (NewMode < -1) or (NewMode >= FProfNames.Count) then Exit;
FProfBound := NewMode;
FPanel := fpNone; Height := ComputeHeight;
RequestInvalidate;
if Assigned(FOnTXProfile) then FOnTXProfile(NewMode);
Exit;
end;
// Устройство аудио (строка активной вкладки OUT/IN) // Устройство аудио (строка активной вкладки OUT/IN)
if HV >= HIT_DEV_BASE then if HV >= HIT_DEV_BASE then
begin begin
@@ -1533,7 +1730,20 @@ var
MaxScroll, Old: Integer; MaxScroll, Old: Integer;
begin begin
Result := False; Result := False;
if (not Visible) or (FPanel <> fpAud) then Exit; if not Visible then Exit;
if FPanel = fpTxProf then
begin
if not PtInRect(FProfListRect, Point(X - Left, Y - Top)) then Exit;
Result := True;
MaxScroll := ProfRowCount - PROF_MAX_VIS;
if MaxScroll <= 0 then Exit;
Old := FProfScroll;
if WheelDelta > 0 then Dec(FProfScroll) else Inc(FProfScroll);
FProfScroll := EnsureRange(FProfScroll, 0, MaxScroll);
if FProfScroll <> Old then RequestInvalidate;
Exit;
end;
if FPanel <> fpAud then Exit;
if not PtInRect(FAudListRect, Point(X - Left, Y - Top)) then Exit; if not PtInRect(FAudListRect, Point(X - Left, Y - Top)) then Exit;
Result := True; // зона наша — колесо в спектр не пускаем Result := True; // зона наша — колесо в спектр не пускаем
MaxScroll := AudRowCount - AUD_MAX_VIS; MaxScroll := AudRowCount - AUD_MAX_VIS;
@@ -1635,6 +1845,11 @@ begin
FDMRCandidateText := ''; FDMRCandidateText := '';
FDMRCandidateSince := 0; FDMRCandidateSince := 0;
end; end;
// Ушли в режим, где профиль ни на что не влияет: пилюли уже нет, открытый
// список закрываем сами — иначе он висел бы без своей кнопки.
if (FPanel = fpTxProf) and
((FMode = OVL_MODE_DMR) or (FMode = OVL_MODE_FMRAW)) then
FPanel := fpNone;
Height := ComputeHeight; Height := ComputeHeight;
RequestInvalidate; RequestInvalidate;
end; end;
+70 -22
View File
@@ -78,6 +78,10 @@ const
WFM_AF_HIGH = 15000.0; WFM_AF_HIGH = 15000.0;
WFM_TAU_DE_US = 50.0e-6; // Европа/Россия (Америка — 75 мкс) WFM_TAU_DE_US = 50.0e-6; // Европа/Россия (Америка — 75 мкс)
DMR_DEVIATION = 2500.0; DMR_DEVIATION = 2500.0;
// FM RAW: плоский baseband для внешних декодеров — одна величина на RX-AF,
// TX-AF и bp0 (раньше 20/7000 стояли числами в трёх местах).
RAW_AF_LOW = 20.0;
RAW_AF_HIGH = 7000.0;
// Потолок dsp_rate для WFM. Вещательному каналу (200 кГц) хватает 192 кГц, // Потолок dsp_rate для WFM. Вещательному каналу (200 кГц) хватает 192 кГц,
// а гнать цепь на полном IQ-rate нельзя: при 576 кГц dsp_size стал бы 12288, // а гнать цепь на полном IQ-rate нельзя: при 576 кГц dsp_size стал бы 12288,
// и fircore спланировал бы FFT на 2*12288 = 24576 точек. Это не степень // и fircore спланировал бы FFT на 2*12288 = 24576 точек. Это не степень
@@ -554,6 +558,7 @@ type
procedure ProcessTXBlock; // вызывается из TTXDSPThread procedure ProcessTXBlock; // вызывается из TTXDSPThread
procedure FeedTXDisplaySample(const I, Q: Double); procedure FeedTXDisplaySample(const I, Q: Double);
procedure ApplyTXChainSettings; // прокидывает все FTX* поля в WDSP procedure ApplyTXChainSettings; // прокидывает все FTX* поля в WDSP
procedure PushTXEQProfile; // кривая EQ в WDSP (узлы + legacy 3 полосы)
procedure ApplyPSParams; // прокидывает FPS*-параметры в WDSP calcc procedure ApplyPSParams; // прокидывает FPS*-параметры в WDSP calcc
procedure ApplyRXAnalyzerSettings; // применяет RX FFT/Window/Det/Avg к RX_DISP_ID procedure ApplyRXAnalyzerSettings; // применяет RX FFT/Window/Det/Avg к RX_DISP_ID
procedure ApplyTXAnalyzerSettings; // применяет TX FFT/Window/Det/Avg к TX_DISP_ID procedure ApplyTXAnalyzerSettings; // применяет TX FFT/Window/Det/Avg к TX_DISP_ID
@@ -1166,7 +1171,7 @@ procedure TWDSPEngine.ApplyRXRawToChan(Chan: Integer);
begin begin
// Decoder-grade discriminator: no broadcast de-emphasis or limiter. // Decoder-grade discriminator: no broadcast de-emphasis or limiter.
SetRXAWFMDeviation (Chan, FRawFMDeviation); SetRXAWFMDeviation (Chan, FRawFMDeviation);
SetRXAWFMAFFilter (Chan, 20.0, 7000.0); SetRXAWFMAFFilter (Chan, RAW_AF_LOW, RAW_AF_HIGH);
SetRXAWFMDeemphRun (Chan, 0); SetRXAWFMDeemphRun (Chan, 0);
SetRXAWFMLimRun (Chan, 0); SetRXAWFMLimRun (Chan, 0);
end; end;
@@ -1220,7 +1225,7 @@ begin
// Плоский baseband для внешних декодеров (совпадает с RX AF в ApplyRXRawToChan // Плоский baseband для внешних декодеров (совпадает с RX AF в ApplyRXRawToChan
// и с явным SetTXABandpassFreqs в ApplyTXModeSettings). РЧ-ширина FILT_RAW_BW // и с явным SetTXABandpassFreqs в ApplyTXModeSettings). РЧ-ширина FILT_RAW_BW
// (6..25 кГц) — селективность RX, к аудио-полосовику отношения не имеет. // (6..25 кГц) — селективность RX, к аудио-полосовику отношения не имеет.
begin Lo := 20.0; Hi := 7000.0; end; begin Lo := RAW_AF_LOW; Hi := RAW_AF_HIGH; end;
else else
begin Lo := L; Hi := H; end; // прочее — как есть begin Lo := L; Hi := H; end; // прочее — как есть
end; end;
@@ -1235,9 +1240,9 @@ begin
// selected peak deviation. Keep only a broad linear-phase AF bandpass; // selected peak deviation. Keep only a broad linear-phase AF bandpass;
// APRS and other external modems must not see voice EQ, dynamics or emphasis. // APRS and other external modems must not see voice EQ, dynamics or emphasis.
SetTXAWFMDeviation (TXA_CHAN, FRawFMDeviation); SetTXAWFMDeviation (TXA_CHAN, FRawFMDeviation);
SetTXAWFMAFFreqs (TXA_CHAN, 20.0, 7000.0); SetTXAWFMAFFreqs (TXA_CHAN, RAW_AF_LOW, RAW_AF_HIGH);
SetTXAWFMPreEmphRun (TXA_CHAN, 0); SetTXAWFMPreEmphRun (TXA_CHAN, 0);
SetTXABandpassFreqs (TXA_CHAN, 20.0, 7000.0); SetTXABandpassFreqs (TXA_CHAN, RAW_AF_LOW, RAW_AF_HIGH);
SetTXAPanelGain1 (TXA_CHAN, 1.0); SetTXAPanelGain1 (TXA_CHAN, 1.0);
SetTXACompressorRun (TXA_CHAN, 0); SetTXACompressorRun (TXA_CHAN, 0);
SetTXALevelerSt (TXA_CHAN, 0); SetTXALevelerSt (TXA_CHAN, 0);
@@ -3542,8 +3547,6 @@ procedure TWDSPEngine.ApplyTXChainSettings;
// Вызывается после OpenChannel(TXA) и при старте TX. Прокидывает все // Вызывается после OpenChannel(TXA) и при старте TX. Прокидывает все
// FTX*-поля в WDSP. Идемпотентна, ничего не меняет если канал не открыт. // FTX*-поля в WDSP. Идемпотентна, ничего не меняет если канал не открыт.
var var
i: Integer;
Gains, Freqs, Qs: array[0..10] of Double;
BpLo, BpHi: Double; BpLo, BpHi: Double;
begin begin
if not FInitialized then Exit; if not FInitialized then Exit;
@@ -3584,15 +3587,8 @@ begin
SetTXAPHROTNstages(TXA_CHAN, FTXPhaseRotStages); SetTXAPHROTNstages(TXA_CHAN, FTXPhaseRotStages);
SetTXAPHROTCorner(TXA_CHAN, FTXPhaseRotFreq); SetTXAPHROTCorner(TXA_CHAN, FTXPhaseRotFreq);
// EQ TX (10-band: [0]=preamp, [1..10]=bands) // EQ TX ([0]=preamp, [1..10]=точки кривой)
for i := 0 to 10 do PushTXEQProfile;
begin
Gains[i] := FTXEQGains[i];
Freqs[i] := FTXEQFreqs[i];
Qs[i] := 0.707; // умолчание Q
end;
SetTXAEQProfile(TXA_CHAN, FTXEQNumBands, @Freqs[0], @Gains[0]);
SetTXAEQRun(TXA_CHAN, Ord(FTXEQOn));
// AM // AM
SetTXAAMCarrierLevel(TXA_CHAN, FTXAMCarrier); SetTXAAMCarrierLevel(TXA_CHAN, FTXAMCarrier);
@@ -3723,8 +3719,27 @@ begin
end; end;
procedure TWDSPEngine.TXFilterEdgesHz(out Lo, Hi: Integer); procedure TWDSPEngine.TXFilterEdgesHz(out Lo, Hi: Integer);
var L, H: Double; // ЭКРАННЫЕ кромки — занимаемая полоса вокруг несущей, а НЕ bp0. У SSB/CW/DIGI/
// AM/DSB это одно и то же, поэтому здесь и стояла обёртка над TXSignedEdges.
// Но у ЧМ bp0 — АУДИО-полосовик ПЕРЕД модулятором: у FM RAW он (20..7000), у
// WFM (20..15000), и полоса на спектре уезжала вверх от несущей — визуально
// «переключалась на USB». Для ЧМ считаем по Карсону: ±(девиация + верхний срез
// аудио), симметрично несущей. У MODE_FM TXSignedEdges отдаёт ровно эту
// величину и без нас (порт Thetis), но пусть формула будет в одном месте.
var L, H, Half: Double;
begin begin
Half := 0.0;
case FTXMode of
MODE_FM: Half := FTXFMDeviation + FTXFMHighCut;
MODE_WFM: Half := WFM_DEVIATION + WFM_AF_HIGH;
MODE_FMRAW: Half := FRawFMDeviation + RAW_AF_HIGH;
end;
if Half > 0.0 then
begin
Lo := -Round(Half);
Hi := Round(Half);
Exit;
end;
TXSignedEdges(FTXMode, L, H); TXSignedEdges(FTXMode, L, H);
Lo := Round(L); Lo := Round(L);
Hi := Round(H); Hi := Round(H);
@@ -3791,11 +3806,48 @@ begin
SetTXAPHROTRun(TXA_CHAN, Ord(Enable)); SetTXAPHROTRun(TXA_CHAN, Ord(Enable));
end; end;
procedure TWDSPEngine.PushTXEQProfile;
// Единственная точка, отдающая кривую EQ в WDSP.
// F[1..n] — УЗЛЫ ломаной АЧХ (eq.c/eq_impulse), между ними интерполяция по
// децибелам, а за крайними узлами при ctfmode=0 (так создаётся eqp в TXA.c)
// идёт кумулятивный скат (f/f0)^4 на бин — фактически обрыв. Поэтому:
// • 10 полос — узлы берём из профиля как есть (за краями обязан быть запас,
// см. заводские ESSB-профили в Settings.DefaultTXProfiles);
// • 3 полосы — legacy-раскладка самого WDSP (SetTXAGrphEQ): 150/400/1500/
// 6000, ручка Low держит полку 150..400, High тянет до 6 кГц. Отдавать
// здесь первые три узла 10-полосной сетки (100/200/400) нельзя — весь
// голос выше 400 Гц ушёл бы в ноль.
var
F, G: array[0..10] of Double;
i, NF: Integer;
begin
if not FInitialized then Exit;
if FTXEQNumBands = 3 then
begin
NF := 4;
F[0] := 0.0; G[0] := FTXEQGains[0]; // preamp
F[1] := 150.0; G[1] := FTXEQGains[1]; // Low
F[2] := 400.0; G[2] := FTXEQGains[1];
F[3] := 1500.0; G[3] := FTXEQGains[2]; // Mid
F[4] := 6000.0; G[4] := FTXEQGains[3]; // High
end
else
begin
NF := 10;
for i := 0 to 10 do
begin
F[i] := FTXEQFreqs[i];
G[i] := FTXEQGains[i];
end;
end;
SetTXAEQProfile(TXA_CHAN, NF, @F[0], @G[0]);
SetTXAEQRun(TXA_CHAN, Ord(FTXEQOn));
end;
procedure TWDSPEngine.SetTXEQ(Enable: Boolean; NumBands: Integer; procedure TWDSPEngine.SetTXEQ(Enable: Boolean; NumBands: Integer;
const Gains, Freqs: array of Double); const Gains, Freqs: array of Double);
var var
i, n: Integer; i, n: Integer;
G, F: array[0..10] of Double;
begin begin
FTXEQOn := Enable; FTXEQOn := Enable;
FTXEQNumBands := NumBands; FTXEQNumBands := NumBands;
@@ -3804,12 +3856,8 @@ begin
begin begin
FTXEQGains[i] := Gains[i]; FTXEQGains[i] := Gains[i];
FTXEQFreqs[i] := Freqs[i]; FTXEQFreqs[i] := Freqs[i];
G[i] := Gains[i];
F[i] := Freqs[i];
end; end;
if not FInitialized then Exit; PushTXEQProfile;
SetTXAEQProfile(TXA_CHAN, NumBands, @F[0], @G[0]);
SetTXAEQRun(TXA_CHAN, Ord(Enable));
end; end;
procedure TWDSPEngine.SetTXAMCarrierLevel(Level: Double); procedure TWDSPEngine.SetTXAMCarrierLevel(Level: Double);