mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 18:43:51 +00:00
feat(tx): TX-профили + вкладка Transmit на подвкладках
Профиль — именованный снимок «как я звучу», переключаемый одним кликом (идиома FlexRadio, состав по таблице TXProfile у Thetis). Граница «профиль ↔ устройство»: - в профиль: микрофонный вход (jack/boost/bias/PTT/Tip-Ring/Line-in gain), MicGain, кромки TX-фильтра, компрессор/leveler/ALC/phase rotator, EQ, AM carrier, Drive и Tune level; - у устройства остаются ATT on TX, FIR/фаза/окно фильтра (это задержка тракта, а не тембр), TUNFreq, FM/CTCSS, TX Display, TX Grid и все параметры PureSignal — смена профиля не должна перерисовывать спектр и ломать калибровку PS. Кнопки «сохранить» нет: активный профиль ведётся за живым состоянием (StoreActiveTXProfile из OnTXSettingsChange и SetDrive) — как и все остальные настройки в EWSDR, применяемые мгновенно. Settings.pas: TTXProfile/TTXProfileList, TXProfileCapture/TXProfileApply (единственные две точки, знающие состав профиля), DefaultTXProfiles (Default/SSB DX/SSB Wide/ESSB 3.5k/ESSB 5k — наследуют mic-вход и мощность от текущих настроек), Load/SaveTXProfiles (секция tx_profiles под MAC). Секции нет — заводской набор строится из текущих настроек, поэтому миграция старого конфига не меняет звук. RadioController: FTXProfiles, rfTXProfile, SelectTXProfile (снимок → DSP + перепосыл DUC Specific при смене mic-байта + SetDrive + персист), Add/Rename/Delete (последний профиль не удаляется). MainForm: кнопка профиля в свободных колонках ряда DUP/RX MUTE — блок TX не вырос по высоте; ЛКМ — список, ПКМ — Settings→Transmit→Profile. SettingsForm: страница Transmit разбита на подвкладки Profile / EQ / Hardware / Display + полоса профиля сверху; свиток 2000 px вместо этого раскладывается на четыре коротких экрана. Проверено: round-trip персиста отдельным тестом (все поля, активный индекс, усечение списка) и offscreen-снимки всех четырёх подвкладок. На железе не проверено. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+307
@@ -28,6 +28,7 @@ const
|
||||
CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS
|
||||
CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES
|
||||
CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090)
|
||||
TXPROF_MAX = 16; // потолок именованных TX-профилей на устройство
|
||||
QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100
|
||||
// (split-LO транспондер; настраивается на
|
||||
// отдельной странице, не в общей XVTR-таблице)
|
||||
@@ -298,6 +299,59 @@ type
|
||||
// (Orion MkII → 5.0, иначе выкл.), 0 = выкл.
|
||||
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;
|
||||
Items: array[0..TXPROF_MAX-1] of TTXProfile;
|
||||
end;
|
||||
|
||||
// Web-сервер — глобальные настройки (не привязаны к устройству, секция "web").
|
||||
TWebSettings = record
|
||||
Enabled: Boolean;
|
||||
@@ -625,8 +679,25 @@ type
|
||||
// только режимы, отличающиеся от заводских, — конфиг не пухнет.
|
||||
function LoadFilters(const MAC: array of Byte; out T: TFilterTable): Boolean;
|
||||
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;
|
||||
|
||||
// ---- 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);
|
||||
|
||||
// Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа).
|
||||
function FilterPresetCount(Mode: Integer): Integer;
|
||||
// Заводской индекс фильтра режима.
|
||||
@@ -1715,6 +1786,242 @@ begin
|
||||
JW(O,'ps_outlier_sigma',T.PSOutlierSigma);
|
||||
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 DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
|
||||
out L: TTXProfileList);
|
||||
|
||||
procedure Add(const AName: string; ALow, AHigh: Integer;
|
||||
AComp: Boolean; ACompGain: Double);
|
||||
begin
|
||||
if L.Count >= TXPROF_MAX then Exit;
|
||||
TXProfileCapture(Base, 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;
|
||||
Inc(L.Count);
|
||||
end;
|
||||
|
||||
begin
|
||||
FillChar(L, SizeOf(L), 0);
|
||||
L.Count := 0;
|
||||
L.ActiveIdx := 0;
|
||||
// «Default» — ровно текущее состояние устройства: после миграции старого
|
||||
// конфига звук в эфире не меняется ни на йоту.
|
||||
TXProfileCapture(Base, DrivePct, L.Items[0]);
|
||||
L.Items[0].Name := 'Default';
|
||||
L.Count := 1;
|
||||
// Остальные заводские отличаются от Default ТОЛЬКО полосой и компрессором;
|
||||
// микрофонный вход, EQ и мощность наследуются (см. коммент к DefaultTXProfiles).
|
||||
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);
|
||||
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;
|
||||
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;
|
||||
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('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);
|
||||
var i: Integer;
|
||||
begin
|
||||
|
||||
Reference in New Issue
Block a user