feat(tx): заводские EQ-кривые ESSB, честная АЧХ в редакторе, фикс 3-полосного EQ

Ключ ко всему — семантика EQ в WDSP (wdsp/eq.c, eq_impulse + TXA.c):
F[1..n] это УЗЛЫ ломаной АЧХ, между ними интерполяция по децибелам, а за
крайними узлами при ctfmode=0 (так создаётся eqp у TXA, мы его не меняем)
идёт кумулятивный скат (f/f0)^4 на каждый бин — фактически обрыв. Значит
включённый EQ работает как второй полосовой фильтр, и верхний узел обязан
лежать за верхней кромкой TX-фильтра.

Settings: заводские ESSB несут свою кривую (AddEQ) — ESSB 3.5k узлы
100..3600, ESSB 5k узлы 100..5100, преамп в минус (EQ стоит до компрессора
и ALC). EQ у Default/SSB DX/SSB Wide сбрасывается в заводской ноль (FlatEQ),
а не наследует живой, иначе «сброс к заводским» не сбрасывал бы тембр.
Сетка узлов вынесена в модульную DEF_EQ_FREQS.

WDSPEngine: единая точка пуша EQ (PushTXEQProfile). Починен 3-полосный
режим: раньше в WDSP уходили первые три узла 10-полосной сетки (100/200/400)
и включённый EQ убивал весь голос выше 400 Гц; теперь legacy-раскладка
самого WDSP (150/400/1500/6000, как SetTXAGrphEQ).

EqualizerControl: кривая — точный порт eq_impulse вместо гауссовых горбов
(сверено с C численно, расхождение 0.0 дБ), рисуется без клампа ±15, чтобы
обрыв за крайними узлами был виден. В 3-полосном режиме ручки стоят на
150/1500/6000 и по частоте не таскаются, сетка профиля при этом цела.

RadioController/SettingsForm/MainForm: кнопка Factory — пересборка заводского
набора с применением Default. Без неё обновлённые заводские профили не доедут
до тех, у кого секция tx_profiles уже записана: она читается, а не создаётся.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:47:12 +03:00
co-authored by Claude Opus 5
parent c8781bee6a
commit 8563c4e4cc
6 changed files with 312 additions and 55 deletions
+136 -22
View File
@@ -31,6 +31,7 @@ type
FBtnBands3: TFlatButton;
FBtnBands10: TFlatButton;
FButtonTheme: TAppTheme;
FRespDB: array of Double; // АЧХ по бинам (дБ), пересчитывается в Paint
function GetBandGain(Index: Integer): Double;
function GetBandFreq(Index: Integer): Double;
procedure SetEnabledEQ(V: Boolean);
@@ -47,7 +48,9 @@ type
function BandX(Index: Integer; const R: TRect): Integer;
function XToFreq(X: Integer; const R: TRect): Double;
function BandColor(Index: Integer): TColor;
function GridBandFreq(Index: Integer): Double;
function InternalBandFreq(Index: Integer): Double;
procedure BuildResponse;
function ResponseAtFreq(AFreq: Double): Double;
function FormatFreq(AFreq: Double): string;
function HitTestControl(X, Y: Integer): Integer;
@@ -93,6 +96,20 @@ const
MAX_FREQ = 20000.0;
TOP_H = 44;
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);
var
@@ -300,7 +317,8 @@ begin
else Result := FClrAccent;
end;
function TEqualizerControl.InternalBandFreq(Index: Integer): Double;
function TEqualizerControl.GridBandFreq(Index: Integer): Double;
// Узел 10-полосной сетки профиля (то, что реально хранится и сохраняется).
begin
if (Index >= 1) and (Index <= 10) and (FFreqs[Index] > 0.0) then
Result := FFreqs[Index]
@@ -308,24 +326,113 @@ begin
Result := MIN_FREQ * Power(MAX_FREQ / MIN_FREQ, EnsureRange((Index - 1) / 9, 0.0, 1.0));
end;
function TEqualizerControl.ResponseAtFreq(AFreq: Double): Double;
var
i, N: Integer;
D, W, Sum, LF, LBand: Double;
function TEqualizerControl.InternalBandFreq(Index: Integer): Double;
// Частота, на которой ручка ДЕЙСТВУЕТ. В 3-полосном режиме это не наша сетка,
// а legacy-раскладка WDSP; FFreqs при этом не трогается, поэтому возврат к
// 10 полосам отдаёт пользователю его же кривую.
begin
Result := FPreampGain;
LF := Ln(EnsureRange(AFreq, MIN_FREQ, MAX_FREQ));
Sum := 0.0;
N := FBandCount;
for i := 1 to N do
if FBandCount = 3 then
Result := EQ3_F[EnsureRange(Index, 1, 3)]
else
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
LBand := Ln(InternalBandFreq(i));
D := (LF - LBand) / 0.30;
W := Exp(-0.5 * D * D);
Sum := Sum + FGains[i] * W;
// Ровно то, что уходит в 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
begin
Fp[i] := EnsureRange(2.0 * InternalBandFreq(i) / RESP_RATE, 0.0, 1.0);
Gp[i] := FGains[i];
end;
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;
Result := Result + Sum;
Result := EnsureRange(Result, -24.0, 24.0);
end;
function TEqualizerControl.FormatFreq(AFreq: Double): string;
@@ -387,7 +494,10 @@ begin
Canvas.Brush.Style := bsClear;
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.Font.Color := C;
Canvas.TextOut(TR.Left + 6, TR.Top + 49, FormatFloat('0.0', FGains[i]));
@@ -527,6 +637,7 @@ begin
Canvas.Pen.Color := FClrBorder;
Canvas.Line(R.Left + 1, ZeroY, R.Right - 1, ZeroY);
BuildResponse; // кривая считается заново на каждую отрисовку (1024 бина)
Canvas.Brush.Color := TColor($00304438);
Canvas.Pen.Color := TColor($00304438);
FillBottom := ZeroY;
@@ -537,7 +648,9 @@ begin
begin
F := XToFreq(GX, R);
G := ResponseAtFreq(F);
GY := GainToY(EnsureRange(G, MIN_GAIN, MAX_GAIN), R);
// Клампа по ±15 дБ нет намеренно: за крайними точками WDSP обрывает АЧХ,
// и кривая обязана уходить в пол графика — иначе обрыв не виден.
GY := GainToY(G, R);
if GX = R.Left then
LastFillY := GY
else
@@ -547,13 +660,13 @@ begin
end;
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.Width := 2;
for GX := CurveL + 1 to CurveR do
begin
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);
PrevX := GX;
PrevY := GY;
@@ -621,7 +734,8 @@ begin
begin
FDragging := H;
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;
@@ -636,7 +750,7 @@ begin
else if FDragging > 0 then
begin
BandGain[FDragging] := YToGain(Y, GraphRect);
BandFreq[FDragging] := XToFreq(X, GraphRect);
if FBandCount <> 3 then BandFreq[FDragging] := XToFreq(X, GraphRect);
end
else
begin
+7
View File
@@ -532,6 +532,7 @@ type
procedure OnTXProfileAddFromSettings(const AName: string);
procedure OnTXProfileRenameFromSettings(Idx: Integer; const AName: string);
procedure OnTXProfileDeleteFromSettings(Idx: Integer);
procedure OnTXProfileFactoryFromSettings(Sender: TObject);
procedure OnChannelStoreChanged(Sender: TObject);
procedure OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean);
procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
@@ -5769,6 +5770,11 @@ begin
FController.DeleteTXProfile(Idx);
end;
procedure TMainForm.OnTXProfileFactoryFromSettings(Sender: TObject);
begin
FController.ResetTXProfilesToFactory;
end;
procedure TMainForm.OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean);
begin
// Рендер кнопки Channel по событию адаптера каналов (Active=True → подсветка+имя).
@@ -8778,6 +8784,7 @@ begin
SF.OnTXProfileAdd := OnTXProfileAddFromSettings;
SF.OnTXProfileRename := OnTXProfileRenameFromSettings;
SF.OnTXProfileDelete := OnTXProfileDeleteFromSettings;
SF.OnTXProfileFactory := OnTXProfileFactoryFromSettings;
SF.OnAlexChange := OnAlexSettingsChange;
SF.OnXvtrChange := OnXvtrSettingsChange;
SF.OnOCChange := OnOCSettingsChange;
+17
View File
@@ -595,6 +595,7 @@ type
function AddTXProfile(const AName: string): Integer; // форк текущего звука
procedure RenameTXProfile(Idx: Integer; const AName: string);
procedure DeleteTXProfile(Idx: Integer);
procedure ResetTXProfilesToFactory; // заводской набор заново
procedure SaveTXProfilesToConfig;
// ---- Управление диапазонами (состояние + DSP; рендер через события) ----
@@ -5046,6 +5047,22 @@ begin
Changed(rfTXProfile);
end;
procedure TRadioController.ResetTXProfilesToFactory;
// Пересобирает заводской набор поверх текущего списка. База — ЖИВЫЕ настройки,
// поэтому «Default» = микрофонный вход, полоса, динамика и мощность как сейчас,
// но с заводским (ровным) EQ. Единственный способ доехать до обновлённых
// заводских профилей с уже записанной секцией tx_profiles: она читается, а не
// пересоздаётся.
begin
DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles);
// ActiveIdx гасим перед Select: иначе StoreActiveTXProfile внутри него
// впишет в свежий «Default» живой EQ и сброс тембра тут же отменится.
FTXProfiles.ActiveIdx := -1;
// Применяем «Default» в эфир — иначе живой EQ остался бы кривым, а профиль
// говорил бы «ровный», и первая же правка TX-настроек вернула бы кривую.
SelectTXProfile(0); // сам сохранит и отрендерит
end;
procedure TRadioController.ApplyTXSettingsToDSP;
begin
if not FWDSPReady then Exit;
+76 -13
View File
@@ -1536,6 +1536,14 @@ begin
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)
// ---------------------------------------------------------------------------
@@ -1543,12 +1551,6 @@ end;
class procedure TSettingsManager.DefaultTX(out T: TTXSettings);
var
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
FillChar(T, SizeOf(T), 0);
T.MicLineIn := False;
@@ -1590,7 +1592,7 @@ begin
for i := 0 to 10 do
begin
T.EQGains[i] := 0.0;
T.EQFreqs[i] := DefaultEQFreqs[i];
T.EQFreqs[i] := DEF_EQ_FREQS[i];
end;
// AM
T.AMCarrierLevel := 0.5;
@@ -1862,6 +1864,36 @@ end;
procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
out L: TTXProfileList);
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;
// Тембр у заводского профиля — заводской: 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);
@@ -1873,24 +1905,55 @@ procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
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;
// «Default» — ровно текущее состояние устройства: после миграции старого
// конфига звук в эфире не меняется ни на йоту.
// «Default» — текущее состояние устройства (микрофонный вход, полоса,
// динамика, мощность), но с заводским EQ: после миграции старого конфига
// звук в эфире не меняется, а сброс к заводским возвращает ровный тембр.
TXProfileCapture(Base, DrivePct, L.Items[0]);
L.Items[0].Name := 'Default';
FlatEQ(L.Items[0]);
L.Count := 1;
// Остальные заводские отличаются от Default ТОЛЬКО полосой и компрессором;
// микрофонный вход, EQ и мощность наследуются (см. коммент к DefaultTXProfiles).
// SSB-профили отличаются от Default только полосой и компрессором;
// микрофонный вход и мощность наследуются, EQ у всех троих заводской-ровный.
// ESSB несут ещё и свою кривую: без неё «широкий» профиль звучит тем же
// телефонным тембром, а с чужой сеткой (верхний узел 3500) EQ обрезал бы
// верх ESSB 5k.
Add('SSB DX', 300, 2700, True, 8.0);
Add('SSB Wide', 200, 3100, False, 5.0);
Add('ESSB 3.5k', 100, 3500, False, 5.0);
Add('ESSB 5k', 100, 5000, 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);
end;
function TSettingsManager.LoadTXProfiles(const MAC: array of Byte;
+34 -3
View File
@@ -226,6 +226,7 @@ type
FBtnTXProfNew: TFlatButton;
FBtnTXProfRen: TFlatButton;
FBtnTXProfDel: TFlatButton;
FBtnTXProfFactory: TFlatButton;
FLblTXProfDrive: TLabel; // «Drive профиля: N %» (ползунок — в главном окне)
FCmbTXMicJack: TFlatComboBox; // "Mic In" / "Line In"
FChkTXMicBoost: TFlatCheckBox; // +20 dB (только Mic In)
@@ -438,6 +439,7 @@ type
FOnTXProfileAdd: TOnTXProfileName;
FOnTXProfileRename: TOnTXProfileIdxName;
FOnTXProfileDelete: TOnTXProfileIdx;
FOnTXProfileFactory: TNotifyEvent;
FLoading: Boolean;
@@ -496,6 +498,7 @@ type
procedure BtnTXProfNewClick(Sender: TObject);
procedure BtnTXProfRenClick(Sender: TObject);
procedure BtnTXProfDelClick(Sender: TObject);
procedure BtnTXProfFactoryClick(Sender: TObject);
procedure ApplyTheme(const T: TAppTheme);
procedure NavClick(Sender: TObject);
procedure SelectPage(APage: TWinControl; ANav: TFlatButton);
@@ -655,6 +658,7 @@ type
property OnTXProfileAdd: TOnTXProfileName read FOnTXProfileAdd write FOnTXProfileAdd;
property OnTXProfileRename: TOnTXProfileIdxName read FOnTXProfileRename write FOnTXProfileRename;
property OnTXProfileDelete: TOnTXProfileIdx read FOnTXProfileDelete write FOnTXProfileDelete;
property OnTXProfileFactory: TNotifyEvent read FOnTXProfileFactory write FOnTXProfileFactory;
property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange;
property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange;
property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange;
@@ -1514,10 +1518,12 @@ begin
FBtnTXProfNew := MkBtn(Grp, PAD + 302, R1, 100, 'New...', BtnTXProfNewClick);
FBtnTXProfRen := MkBtn(Grp, PAD + 408, R1, 100, 'Rename...', BtnTXProfRenClick);
FBtnTXProfDel := MkBtn(Grp, PAD + 514, R1, 100, 'Delete', BtnTXProfDelClick);
FBtnTXProfFactory := MkBtn(Grp, PAD + 620, R1, 100, 'Factory', BtnTXProfFactoryClick);
MakeLbl(Grp, 'Правки на вкладках Profile и EQ пишутся в активный профиль сразу — '
+ 'отдельной кнопки «сохранить» нет.', PAD, R1 + 30, GRP_W - 2 * PAD);
MakeLbl(Grp, 'New... создаёт копию текущего звука под новым именем.',
MakeLbl(Grp, 'New... создаёт копию текущего звука под новым именем. '
+ 'Factory — заводской набор заново, эквалайзер в ноль.',
PAD, R1 + 48, GRP_W - 2 * PAD);
// ---- Сегменты подвкладок ------------------------------------------------
@@ -1627,7 +1633,7 @@ begin
// =========================================================================
SubPage(1, SubTop);
Grp := MakeGroupPanel(FTXSubPage[1], 'TX Equalizer', MARGIN, 0, GRP_W, 390);
Grp := MakeGroupPanel(FTXSubPage[1], 'TX Equalizer', MARGIN, 0, GRP_W, 434);
FTXEQControl := TEqualizerControl.Create(Self);
FTXEQControl.Parent := Grp;
@@ -1638,7 +1644,17 @@ begin
TColor($00383838), CLR_TEXT, CLR_TEXTDIM, CLR_ACCENT, TColor($0055DDEE));
FTXEQControl.OnChange := OnTXAnyChange;
FTXSubPage[1].Height := DpiScale(390 + 12);
// Главное, что надо знать про этот EQ: точки — не «полосы», а узлы кривой,
// и за крайними узлами WDSP обрывает АЧХ (см. WDSPEngine.PushTXEQProfile).
MakeLbl(Grp, 'Точки — узлы кривой: между ними интерполяция по дБ, '
+ 'за крайними точками АЧХ обрывается.', PAD, R1 + 332, GRP_W - 2 * PAD);
MakeLbl(Grp, 'Нижняя точка должна быть не выше нижней кромки TX-фильтра, '
+ 'верхняя — не ниже верхней, иначе EQ сам срежет полосу.',
PAD, R1 + 352, GRP_W - 2 * PAD);
MakeLbl(Grp, 'В режиме 3 полос частоты задаёт WDSP (150 / 1500 / 6000 Гц) — '
+ 'двигается только усиление.', PAD, R1 + 372, GRP_W - 2 * PAD);
FTXSubPage[1].Height := DpiScale(434 + 12);
// =========================================================================
// Подвкладка 2 — Hardware: свойства устройства, профилем не переключаются
@@ -1953,6 +1969,21 @@ begin
if Assigned(FOnTXProfileDelete) then FOnTXProfileDelete(Idx);
end;
procedure TSettingsForm.BtnTXProfFactoryClick(Sender: TObject);
// Пересоздать заводской набор. Нужен не только «на случай чего»: заводские
// профили пишутся в конфиг один раз при первом подключении устройства, и без
// этой кнопки обновлённые заводские настройки (например кривые EQ у ESSB)
// не доехали бы до тех, у кого секция tx_profiles уже записана.
begin
if MessageDlg('TX Profile',
'Заменить весь список профилей заводским набором?'#13#10
+ 'Свои профили и правки заводских будут потеряны.'#13#10
+ 'Активным станет «Default»: микрофон, полоса, динамика и мощность '
+ 'останутся как сейчас, эквалайзер вернётся в ноль.',
mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit;
if Assigned(FOnTXProfileFactory) then FOnTXProfileFactory(Self);
end;
procedure TSettingsForm.LoadTXProfiles(const Names: array of string;
ActiveIdx, DrivePct: Integer);
var
+42 -17
View File
@@ -554,6 +554,7 @@ type
procedure ProcessTXBlock; // вызывается из TTXDSPThread
procedure FeedTXDisplaySample(const I, Q: Double);
procedure ApplyTXChainSettings; // прокидывает все FTX* поля в WDSP
procedure PushTXEQProfile; // кривая EQ в WDSP (узлы + legacy 3 полосы)
procedure ApplyPSParams; // прокидывает FPS*-параметры в WDSP calcc
procedure ApplyRXAnalyzerSettings; // применяет RX FFT/Window/Det/Avg к RX_DISP_ID
procedure ApplyTXAnalyzerSettings; // применяет TX FFT/Window/Det/Avg к TX_DISP_ID
@@ -3542,8 +3543,6 @@ procedure TWDSPEngine.ApplyTXChainSettings;
// Вызывается после OpenChannel(TXA) и при старте TX. Прокидывает все
// FTX*-поля в WDSP. Идемпотентна, ничего не меняет если канал не открыт.
var
i: Integer;
Gains, Freqs, Qs: array[0..10] of Double;
BpLo, BpHi: Double;
begin
if not FInitialized then Exit;
@@ -3584,15 +3583,8 @@ begin
SetTXAPHROTNstages(TXA_CHAN, FTXPhaseRotStages);
SetTXAPHROTCorner(TXA_CHAN, FTXPhaseRotFreq);
// EQ TX (10-band: [0]=preamp, [1..10]=bands)
for i := 0 to 10 do
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));
// EQ TX ([0]=preamp, [1..10]=точки кривой)
PushTXEQProfile;
// AM
SetTXAAMCarrierLevel(TXA_CHAN, FTXAMCarrier);
@@ -3791,11 +3783,48 @@ begin
SetTXAPHROTRun(TXA_CHAN, Ord(Enable));
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;
const Gains, Freqs: array of Double);
var
i, n: Integer;
G, F: array[0..10] of Double;
begin
FTXEQOn := Enable;
FTXEQNumBands := NumBands;
@@ -3804,12 +3833,8 @@ begin
begin
FTXEQGains[i] := Gains[i];
FTXEQFreqs[i] := Freqs[i];
G[i] := Gains[i];
F[i] := Freqs[i];
end;
if not FInitialized then Exit;
SetTXAEQProfile(TXA_CHAN, NumBands, @F[0], @G[0]);
SetTXAEQRun(TXA_CHAN, Ord(Enable));
PushTXEQProfile;
end;
procedure TWDSPEngine.SetTXAMCarrierLevel(Level: Double);