From 8563c4e4cc5339fc6f51367c0e71aa14e059be47 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 15:47:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(tx):=20=D0=B7=D0=B0=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B5=20EQ-=D0=BA=D1=80=D0=B8=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20ESSB,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=90=D0=A7=D0=A5=20=D0=B2=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B5,=20=D1=84=D0=B8=D0=BA=D1=81=203-=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D1=81=D0=BD=D0=BE=D0=B3=D0=BE=20EQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ключ ко всему — семантика 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 --- EqualizerControl.pas | 158 +++++++++++++++++++++++++++++++++++++------ MainForm.pas | 7 ++ RadioController.pas | 17 +++++ Settings.pas | 89 ++++++++++++++++++++---- SettingsForm.pas | 37 +++++++++- WDSPEngine.pas | 59 +++++++++++----- 6 files changed, 312 insertions(+), 55 deletions(-) diff --git a/EqualizerControl.pas b/EqualizerControl.pas index 1102e89..428e64d 100644 --- a/EqualizerControl.pas +++ b/EqualizerControl.pas @@ -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 diff --git a/MainForm.pas b/MainForm.pas index c487c9a..35b3513 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -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; diff --git a/RadioController.pas b/RadioController.pas index fceae6a..17126a9 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -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; diff --git a/Settings.pas b/Settings.pas index 7bb30da..8a0bc18 100644 --- a/Settings.pas +++ b/Settings.pas @@ -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; diff --git a/SettingsForm.pas b/SettingsForm.pas index 31bf755..912e99a 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -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 diff --git a/WDSPEngine.pas b/WDSPEngine.pas index f717232..46c3a2b 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -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);