mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +00:00
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:
+136
-22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user